// Package jobs implements RelSpec declarative job files. // // A job file is a small YAML manifest that names one or more jobs and, // for each job, the RelSpec command to run plus its inputs, output and // options. It lets users run "relspec job run build-schema" instead of // repeating long command lines. // // The job-file system is deliberately NOT a shell: "command" is a closed // enum of vetted RelSpec workflows, every path is resolved relative to the // directory holding the job file and may not escape it, and remote database // credentials are referenced by environment-variable name only - never // embedded in the manifest. All discovery, parsing and validation in this // package is side-effect free; nothing here reads input schemas, opens // database connections or writes output. Execution lives in the CLI layer // and only runs after Validate and the caller's pre-flight checks pass. package jobs import ( "fmt" "os" "path/filepath" "sort" "strconv" "strings" "gopkg.in/yaml.v3" ) // CurrentSchemaVersion is the highest job-file schema version this build was // written for. MinSchemaVersion is the oldest it still accepts. A file that // declares a version in between loads normally; a newer version loads // best-effort with a warning (see Load); an older-than-minimum version is a // hard error. const ( CurrentSchemaVersion = 1 MinSchemaVersion = 1 ) // Built-in logfile rotation policy, used when neither the job nor its file's // defaults block sets one. const ( defaultLogMaxSizeBytes int64 = 5 << 20 // 5 MiB defaultLogKeep = 3 ) // Command names are a closed allow-list. Arbitrary strings are rejected. const ( CommandConvert = "convert" // read one or more schema files, optionally merge, write one output CommandMerge = "merge" // additive merge of two or more schema files into one output CommandScriptsList = "scripts-list" // deterministically list SQL scripts across one or more directories CommandScriptsExec = "scripts-exec" // execute SQL scripts across one or more directories against a live database CommandTempl = "templ" // apply a custom Go text template to one or more schemas CommandSplit = "split" // extract selected schemas/tables into a separate output CommandInspect = "inspect" // validate one or more schemas against rules and write a report CommandDiff = "diff" // compare exactly two schemas and write a differences report ) // SupportedCommands lists every accepted command, in help order. var SupportedCommands = []string{ CommandConvert, CommandMerge, CommandScriptsList, CommandScriptsExec, CommandTempl, CommandSplit, CommandInspect, CommandDiff, } // producerCommands are commands whose output is a schema file that another job // may consume via from_job. var producerCommands = map[string]bool{ CommandConvert: true, CommandMerge: true, CommandSplit: true, } // readerFormats are the file-based input formats a job may declare (path). var readerFormats = map[string]bool{ "dbml": true, "dctx": true, "drawdb": true, "graphql": true, "json": true, "yaml": true, "gorm": true, "bun": true, "drizzle": true, "prisma": true, "typeorm": true, "sqlite": true, } // inputDBFormats are input formats that can only come from a live connection, // referenced by conn_env. var inputDBFormats = map[string]bool{"pgsql": true, "mssql": true} // writerFormats are the output formats a job may declare. var writerFormats = map[string]bool{ "dbml": true, "dctx": true, "drawdb": true, "graphql": true, "json": true, "yaml": true, "gorm": true, "bun": true, "drizzle": true, "prisma": true, "typeorm": true, "pgsql": true, "mssql": true, "sqlite": true, } // execOutputFormats are output formats for which conn_env (execute against a // live database) is supported instead of writing a file. var execOutputFormats = map[string]bool{"pgsql": true} // singleFileFormats are output formats that emit exactly one file (as opposed // to a directory of files). Only these are eligible for atomic temp+rename // writes and for being consumed by another job via from_job. var singleFileFormats = map[string]bool{ "json": true, "yaml": true, "dbml": true, "dctx": true, "drawdb": true, "graphql": true, "pgsql": true, "mssql": true, "sqlite": true, } // SingleFileOutputFormat reports whether format writes exactly one file. func SingleFileOutputFormat(format string) bool { return singleFileFormats[strings.ToLower(format)] } // diffReportFormats and inspectReportFormats are the report.format values // accepted by the diff and inspect commands respectively. var ( diffReportFormats = map[string]bool{"summary": true, "json": true, "html": true} inspectReportFormats = map[string]bool{"markdown": true, "json": true} ) // File is the on-disk shape of a single job file. type File struct { Version int `yaml:"version"` Defaults *Defaults `yaml:"defaults"` Jobs map[string]*Job `yaml:"jobs"` } // Defaults carries file-wide settings that individual jobs may override. type Defaults struct { // LogMaxSize is a human-readable size ("5MB", "512KB", "1GB"). Empty // means "use the built-in default". LogMaxSize string `yaml:"log_max_size"` // LogKeep is how many rotated logfiles to retain. Zero means "use the // built-in default". LogKeep int `yaml:"log_keep"` } // Job is one named job within a job file. type Job struct { // Name and SourceFile are populated by Load, not parsed from YAML. Name string `yaml:"-"` SourceFile string `yaml:"-"` // fileDefaults is the Defaults block of the file that declared this job, // captured by Load. nil when the file had none. fileDefaults *Defaults `yaml:"-"` Command string `yaml:"command"` Description string `yaml:"description"` DependsOn []string `yaml:"depends_on"` Inputs []Input `yaml:"inputs"` ScriptDirs []string `yaml:"script_dirs"` Template string `yaml:"template"` Mode string `yaml:"mode"` FilenamePattern string `yaml:"filename_pattern"` Output *Output `yaml:"output"` Rules string `yaml:"rules"` Report *Report `yaml:"report"` Select *Select `yaml:"select"` Options Options `yaml:"options"` Logfile string `yaml:"logfile"` LogMaxSize string `yaml:"log_max_size"` LogKeep *int `yaml:"log_keep"` } // Input is one declared input schema. type Input struct { Path string `yaml:"path"` // Format is the RelSpec reader format (dbml, json, yaml, pgsql, ...). Format string `yaml:"format"` // ConnEnv is the NAME of an environment variable holding a connection // string, used with database formats. The value is never stored here. ConnEnv string `yaml:"conn_env"` // FromJob names another job in the set whose file output is used as this // input. It implies a dependency on that job. Path/Format/ConnEnv must be // empty when FromJob is set; the format is inherited from the producer. FromJob string `yaml:"from_job"` } // Output is the declared output target. type Output struct { Format string `yaml:"format"` Path string `yaml:"path"` ConnEnv string `yaml:"conn_env"` Overwrite bool `yaml:"overwrite"` } // Report is the output target for the inspect and diff commands. type Report struct { // Format is the report format: diff accepts summary|json|html, inspect // accepts markdown|json. Empty means the command's default. Format string `yaml:"format"` Path string `yaml:"path"` Overwrite bool `yaml:"overwrite"` } // Select carries the schema/table selection for the split command. type Select struct { Schemas []string `yaml:"schemas"` Tables []string `yaml:"tables"` ExcludeSchemas []string `yaml:"exclude_schemas"` ExcludeTables []string `yaml:"exclude_tables"` DatabaseName string `yaml:"database_name"` } // LogPolicy is the resolved logfile rotation policy for a job. type LogPolicy struct { MaxSizeBytes int64 Keep int } // ResolvedLogPolicy returns the effective rotation policy: the job's own // overrides win, then its file's defaults block, then the built-in default. func (j *Job) ResolvedLogPolicy() LogPolicy { p := LogPolicy{MaxSizeBytes: defaultLogMaxSizeBytes, Keep: defaultLogKeep} if j.fileDefaults != nil { if n, err := parseHumanSize(j.fileDefaults.LogMaxSize); err == nil && n > 0 { p.MaxSizeBytes = n } if j.fileDefaults.LogKeep > 0 { p.Keep = j.fileDefaults.LogKeep } } if n, err := parseHumanSize(j.LogMaxSize); err == nil && n > 0 { p.MaxSizeBytes = n } if j.LogKeep != nil && *j.LogKeep >= 0 { p.Keep = *j.LogKeep } return p } // effectiveDeps returns the union of explicit depends_on entries and the jobs // referenced by from_job inputs, deduplicated in stable order. func (j *Job) effectiveDeps() []string { seen := map[string]bool{} var deps []string add := func(name string) { if name == "" || name == j.Name || seen[name] { return } seen[name] = true deps = append(deps, name) } for _, d := range j.DependsOn { add(d) } for _, in := range j.Inputs { add(in.FromJob) } return deps } // parseHumanSize parses a byte size such as "5MB", "512 KB", "1gb" or a bare // byte count. An empty string returns (0, nil) so callers can fall back. func parseHumanSize(s string) (int64, error) { s = strings.TrimSpace(s) if s == "" { return 0, nil } upper := strings.ToUpper(s) mult := int64(1) // Check multi-character suffixes before the bare "B". for _, u := range []struct { suffix string m int64 }{ {"KB", 1 << 10}, {"MB", 1 << 20}, {"GB", 1 << 30}, {"B", 1}, } { if strings.HasSuffix(upper, u.suffix) { mult = u.m upper = strings.TrimSpace(strings.TrimSuffix(upper, u.suffix)) break } } n, err := strconv.ParseFloat(upper, 64) if err != nil { return 0, fmt.Errorf("invalid size %q", s) } if n < 0 { return 0, fmt.Errorf("negative size %q", s) } return int64(n * float64(mult)), nil } // Options carries the subset of command flags a job file may set. type Options struct { FlattenSchema bool `yaml:"flatten_schema"` Schema string `yaml:"schema"` Package string `yaml:"package"` ContinueOnError bool `yaml:"continue_on_error"` SkipRelations bool `yaml:"skip_relations"` SkipEnums bool `yaml:"skip_enums"` SkipViews bool `yaml:"skip_views"` SkipDomains bool `yaml:"skip_domains"` SkipSequences bool `yaml:"skip_sequences"` } // Dir returns the directory that a job's relative paths resolve against: // the directory containing the job file that declared it. func (j *Job) Dir() string { return filepath.Dir(j.SourceFile) } // Set is the merged view of all discovered/selected job files. type Set struct { // Files is the sorted list of job files that contributed jobs. Files []string // Jobs is keyed by job name. Jobs map[string]*Job // Warnings holds non-fatal load-time messages (e.g. a newer-than-known // schema version). Callers should surface these to the user. Warnings []string } // Names returns all job names in deterministic (sorted) order. func (s *Set) Names() []string { names := make([]string, 0, len(s.Jobs)) for n := range s.Jobs { names = append(names, n) } sort.Strings(names) return names } // Discover returns the job files in dir in deterministic order. The default // file "relspec.yml"/"relspec.yaml" sorts first, followed by named files // "relspec..yml"/"relspec..yaml" in lexical order. func Discover(dir string) ([]string, error) { if dir == "" { dir = "." } entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("failed to read directory %q: %w", dir, err) } var defaults, named []string for _, e := range entries { if e.IsDir() { continue } name := e.Name() if !isJobFileName(name) { continue } full := filepath.Join(dir, name) if name == "relspec.yml" || name == "relspec.yaml" { defaults = append(defaults, full) } else { named = append(named, full) } } sort.Strings(defaults) sort.Strings(named) return append(defaults, named...), nil } func isJobFileName(name string) bool { for _, ext := range []string{".yml", ".yaml"} { if name == "relspec"+ext { return true } if strings.HasPrefix(name, "relspec.") && strings.HasSuffix(name, ext) { return true } } return false } // Load parses every path, rejects unknown fields and unsupported versions, // and merges all jobs into one Set. A job name defined by more than one file // is a hard error. Load performs structural checks only; call Validate for // full semantic validation. func Load(paths []string) (*Set, error) { if len(paths) == 0 { return nil, fmt.Errorf("no job files found (looked for relspec.yml / relspec..yml)") } set := &Set{Jobs: map[string]*Job{}} origin := map[string]string{} // job name -> first file that defined it for _, path := range paths { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read job file %q: %w", path, err) } // Peek at the version first so a newer file can be parsed leniently // (unknown fields ignored) instead of failing outright. var probe struct { Version int `yaml:"version"` } if err := yaml.Unmarshal(data, &probe); err != nil { return nil, fmt.Errorf("invalid job file %q: %w", path, err) } version := probe.Version if version == 0 { version = CurrentSchemaVersion } if version < MinSchemaVersion { return nil, fmt.Errorf("job file %q: unsupported version %d (this build accepts %d or newer)", path, version, MinSchemaVersion) } strict := version <= CurrentSchemaVersion if !strict { set.Warnings = append(set.Warnings, fmt.Sprintf( "job file %q declares version %d, newer than this build understands (%d); loading best-effort and ignoring unknown fields", path, version, CurrentSchemaVersion)) } dec := yaml.NewDecoder(strings.NewReader(string(data))) dec.KnownFields(strict) var f File if err := dec.Decode(&f); err != nil { return nil, fmt.Errorf("invalid job file %q: %w", path, err) } if len(f.Jobs) == 0 { return nil, fmt.Errorf("job file %q: no jobs defined", path) } for name, job := range f.Jobs { if job == nil { return nil, fmt.Errorf("job file %q: job %q is empty", path, name) } if prev, dup := origin[name]; dup { return nil, fmt.Errorf("duplicate job %q defined in both %q and %q", name, prev, path) } job.Name = name job.SourceFile = path job.fileDefaults = f.Defaults origin[name] = path set.Jobs[name] = job } set.Files = append(set.Files, path) } return set, nil } // Validate runs full semantic validation over the whole set and returns a // single error describing every problem found. It never touches the // filesystem beyond what Load already read; existence of input files and // environment variables is checked by the caller immediately before // execution. func (s *Set) Validate() error { var errs []string for _, name := range s.Names() { for _, msg := range s.Jobs[name].validate() { errs = append(errs, fmt.Sprintf("job %q: %s", name, msg)) } } // Dependency references + cycles + from_job wiring. for _, name := range s.Names() { j := s.Jobs[name] for _, dep := range j.DependsOn { if _, ok := s.Jobs[dep]; !ok { errs = append(errs, fmt.Sprintf("job %q: depends_on unknown job %q", name, dep)) } } for i, in := range j.Inputs { if in.FromJob == "" { continue } producer, ok := s.Jobs[in.FromJob] if !ok { errs = append(errs, fmt.Sprintf("job %q: input[%d] from_job references unknown job %q", name, i, in.FromJob)) continue } if !producerCommands[producer.Command] || producer.Output == nil || producer.Output.Path == "" || !SingleFileOutputFormat(producer.Output.Format) { errs = append(errs, fmt.Sprintf( "job %q: input[%d] from_job %q must name a convert/merge/split job that writes a single-file output", name, i, in.FromJob)) } } } if cycle := s.findCycle(); cycle != "" { errs = append(errs, fmt.Sprintf("dependency cycle detected: %s", cycle)) } if len(errs) > 0 { sort.Strings(errs) return fmt.Errorf("job file validation failed:\n - %s", strings.Join(errs, "\n - ")) } return nil } func (j *Job) validate() []string { var e []string switch j.Command { case CommandConvert, CommandMerge, CommandScriptsList, CommandScriptsExec, CommandTempl, CommandSplit, CommandInspect, CommandDiff: case "": e = append(e, "missing command") return e default: e = append(e, fmt.Sprintf("unsupported command %q (supported: %s)", j.Command, strings.Join(SupportedCommands, ", "))) return e } // Path safety for every declared path. checkPath := func(label, p string) { if p == "" { return } if err := checkRelPath(p); err != nil { e = append(e, fmt.Sprintf("%s %q: %v", label, p, err)) } } checkPath("logfile", j.Logfile) checkPath("template", j.Template) checkPath("rules", j.Rules) for _, in := range j.Inputs { checkPath("input path", in.Path) } for _, d := range j.ScriptDirs { checkPath("script_dir", d) } if j.Output != nil { checkPath("output path", j.Output.Path) } if j.Report != nil { checkPath("report path", j.Report.Path) } if _, err := parseHumanSize(j.LogMaxSize); err != nil { e = append(e, fmt.Sprintf("log_max_size: %v", err)) } switch j.Command { case CommandConvert, CommandMerge: minInputs := 1 if j.Command == CommandMerge { minInputs = 2 } if len(j.Inputs) < minInputs { e = append(e, fmt.Sprintf("command %q requires at least %d input(s)", j.Command, minInputs)) } for i, in := range j.Inputs { e = append(e, validateInput(i, in)...) } if len(j.ScriptDirs) > 0 { e = append(e, fmt.Sprintf("script_dirs is not valid for command %q", j.Command)) } if j.Output == nil { e = append(e, "missing output") } else { e = append(e, validateOutput(*j.Output)...) } case CommandScriptsList: if len(j.ScriptDirs) == 0 { e = append(e, "command \"scripts-list\" requires at least one script_dir") } if len(j.Inputs) > 0 { e = append(e, "inputs is not valid for command \"scripts-list\"") } if j.Output != nil { e = append(e, "output is not valid for command \"scripts-list\"") } case CommandTempl: if len(j.Inputs) < 1 { e = append(e, "command \"templ\" requires at least 1 input") } for i, in := range j.Inputs { e = append(e, validateTemplInput(i, in)...) } if j.Template == "" { e = append(e, "command \"templ\" requires template") } mode := strings.ToLower(j.Mode) if mode == "" { mode = "database" } switch mode { case "database", "schema", "script", "table": default: e = append(e, fmt.Sprintf("command \"templ\" has unsupported mode %q (supported: database, schema, script, table)", j.Mode)) } if len(j.ScriptDirs) > 0 { e = append(e, "script_dirs is not valid for command \"templ\"") } if j.Output != nil && j.Output.ConnEnv != "" { e = append(e, "command \"templ\" does not support database output") } if j.Output != nil && j.Output.Format != "" { e = append(e, "output.format is not valid for command \"templ\"") } case CommandSplit: if len(j.Inputs) < 1 { e = append(e, "command \"split\" requires at least 1 input") } for i, in := range j.Inputs { e = append(e, validateInput(i, in)...) } if len(j.ScriptDirs) > 0 { e = append(e, "script_dirs is not valid for command \"split\"") } if j.Report != nil { e = append(e, "report is not valid for command \"split\" (use output)") } if j.Output == nil { e = append(e, "missing output") } else { if j.Output.ConnEnv != "" { e = append(e, "command \"split\" writes a file; output.conn_env is not supported") } e = append(e, validateOutput(*j.Output)...) } case CommandInspect: if len(j.Inputs) < 1 { e = append(e, "command \"inspect\" requires at least 1 input") } for i, in := range j.Inputs { e = append(e, validateInput(i, in)...) } if len(j.ScriptDirs) > 0 { e = append(e, "script_dirs is not valid for command \"inspect\"") } if j.Output != nil { e = append(e, "output is not valid for command \"inspect\" (use report)") } e = append(e, validateReport(j.Report, "inspect", inspectReportFormats, "markdown")...) case CommandDiff: if len(j.Inputs) != 2 { e = append(e, "command \"diff\" requires exactly 2 inputs (source, target)") } for i, in := range j.Inputs { e = append(e, validateInput(i, in)...) } if len(j.ScriptDirs) > 0 { e = append(e, "script_dirs is not valid for command \"diff\"") } if j.Output != nil { e = append(e, "output is not valid for command \"diff\" (use report)") } e = append(e, validateReport(j.Report, "diff", diffReportFormats, "summary")...) case CommandScriptsExec: if len(j.ScriptDirs) == 0 { e = append(e, "command \"scripts-exec\" requires at least one script_dir") } if len(j.Inputs) > 0 { e = append(e, "inputs is not valid for command \"scripts-exec\"") } if j.Report != nil { e = append(e, "report is not valid for command \"scripts-exec\"") } if j.Output == nil || j.Output.ConnEnv == "" { e = append(e, "command \"scripts-exec\" requires output.conn_env (an environment variable name holding a connection string)") } else { if j.Output.Path != "" { e = append(e, "command \"scripts-exec\" executes against a database; output.path is not supported") } f := strings.ToLower(j.Output.Format) if f != "" && f != "pgsql" { e = append(e, fmt.Sprintf("command \"scripts-exec\" only supports pgsql databases (got %q)", j.Output.Format)) } if looksLikeSecret(j.Output.ConnEnv) { e = append(e, "output: conn_env must be an environment variable name, not a connection string") } } } return e } // validateReport checks a Report block for the inspect/diff commands. func validateReport(r *Report, cmd string, allowed map[string]bool, defFmt string) []string { if r == nil { return []string{fmt.Sprintf("command %q requires a report block", cmd)} } var e []string f := strings.ToLower(r.Format) if f == "" { f = defFmt } if !allowed[f] { names := make([]string, 0, len(allowed)) for k := range allowed { names = append(names, k) } sort.Strings(names) e = append(e, fmt.Sprintf("command %q report.format %q is not supported (use: %s)", cmd, r.Format, strings.Join(names, ", "))) } // A diff summary may be written to the log; everything else needs a path. summaryToLog := cmd == "diff" && f == "summary" if r.Path == "" && !summaryToLog { e = append(e, fmt.Sprintf("command %q requires report.path", cmd)) } return e } func validateTemplInput(i int, in Input) []string { if in.FromJob != "" { return fromJobInputShape(i, in) } var e []string if in.Format == "" { return []string{fmt.Sprintf("input[%d]: missing format", i)} } f := strings.ToLower(in.Format) if f == "pgsql" { if in.ConnEnv == "" { e = append(e, fmt.Sprintf("input[%d]: format %q requires conn_env (an environment variable name)", i, in.Format)) } if in.Path != "" { e = append(e, fmt.Sprintf("input[%d]: format %q takes conn_env, not path", i, in.Format)) } } else if readerFormats[f] { if in.Path == "" { e = append(e, fmt.Sprintf("input[%d]: missing path", i)) } if in.ConnEnv != "" { e = append(e, fmt.Sprintf("input[%d]: format %q does not use conn_env", i, in.Format)) } } else { e = append(e, fmt.Sprintf("input[%d]: unsupported templ input format %q", i, in.Format)) } if looksLikeSecret(in.ConnEnv) { e = append(e, fmt.Sprintf("input[%d]: conn_env must be an environment variable name, not a connection string", i)) } return e } // fromJobInputShape checks the structural rules for an input that pulls its // schema from another job's output. The referenced job's existence and kind // are checked in Set.Validate, which can see the whole set. func fromJobInputShape(i int, in Input) []string { var e []string if in.Path != "" { e = append(e, fmt.Sprintf("input[%d]: from_job takes no path", i)) } if in.Format != "" { e = append(e, fmt.Sprintf("input[%d]: from_job inherits the producer's format; drop format", i)) } if in.ConnEnv != "" { e = append(e, fmt.Sprintf("input[%d]: from_job takes no conn_env", i)) } return e } func validateInput(i int, in Input) []string { if in.FromJob != "" { return fromJobInputShape(i, in) } var e []string if in.Format == "" { e = append(e, fmt.Sprintf("input[%d]: missing format", i)) return e } f := strings.ToLower(in.Format) switch { case inputDBFormats[f]: if in.ConnEnv == "" { e = append(e, fmt.Sprintf("input[%d]: format %q requires conn_env (an environment variable name)", i, in.Format)) } if in.Path != "" { e = append(e, fmt.Sprintf("input[%d]: format %q takes conn_env, not path", i, in.Format)) } case readerFormats[f]: if in.Path == "" { e = append(e, fmt.Sprintf("input[%d]: missing path", i)) } if in.ConnEnv != "" { e = append(e, fmt.Sprintf("input[%d]: format %q does not use conn_env", i, in.Format)) } default: e = append(e, fmt.Sprintf("input[%d]: unsupported input format %q", i, in.Format)) } if looksLikeSecret(in.ConnEnv) { e = append(e, fmt.Sprintf("input[%d]: conn_env must be an environment variable name, not a connection string", i)) } return e } func validateOutput(o Output) []string { var e []string if o.Format == "" { e = append(e, "output: missing format") return e } f := strings.ToLower(o.Format) if !writerFormats[f] { e = append(e, fmt.Sprintf("output: unsupported output format %q", o.Format)) return e } if o.ConnEnv != "" { if !execOutputFormats[f] { e = append(e, fmt.Sprintf("output: conn_env (live database execution) is not supported for format %q", o.Format)) } if o.Path != "" { e = append(e, "output: set either path or conn_env, not both") } } else if o.Path == "" { e = append(e, "output: missing path") } if looksLikeSecret(o.ConnEnv) { e = append(e, "output: conn_env must be an environment variable name, not a connection string") } return e } // looksLikeSecret reports whether s looks like a connection string rather // than a bare environment-variable name. func looksLikeSecret(s string) bool { if s == "" { return false } return strings.ContainsAny(s, ":/@ =") || strings.Contains(s, "//") } // checkRelPath rejects absolute paths and any path that escapes its root. func checkRelPath(p string) error { if p == "" { return fmt.Errorf("empty path") } if filepath.IsAbs(p) { return fmt.Errorf("absolute paths are not allowed; use a path relative to the job file") } if strings.HasPrefix(p, "~") { return fmt.Errorf("home-relative paths are not allowed") } clean := filepath.ToSlash(filepath.Clean(p)) if clean == ".." || strings.HasPrefix(clean, "../") { return fmt.Errorf("path escapes the job file directory") } return nil } // SafeJoin resolves rel against root and guarantees the result stays inside // root. It is the single choke point for turning a manifest path into a // filesystem path. func SafeJoin(root, rel string) (string, error) { if err := checkRelPath(rel); err != nil { return "", err } absRoot, err := filepath.Abs(root) if err != nil { return "", err } joined := filepath.Join(absRoot, rel) rp, err := filepath.Rel(absRoot, joined) if err != nil { return "", err } if rp == ".." || strings.HasPrefix(rp, ".."+string(filepath.Separator)) { return "", fmt.Errorf("path %q escapes the job file directory", rel) } // Symlink hardening: resolve symlinks on the root and on the deepest // existing ancestor of the target, and require the target to still live // inside the resolved root. This catches a symlink inside the job-file // directory that points outside it. realRoot, err := filepath.EvalSymlinks(absRoot) if err != nil { return "", fmt.Errorf("cannot resolve job file directory: %w", err) } realAnc, err := filepath.EvalSymlinks(deepestExistingAncestor(joined)) if err != nil { return "", fmt.Errorf("cannot resolve path %q: %w", rel, err) } if realAnc != realRoot { if r, err := filepath.Rel(realRoot, realAnc); err != nil || r == ".." || strings.HasPrefix(r, ".."+string(filepath.Separator)) { return "", fmt.Errorf("path %q resolves outside the job file directory via a symlink", rel) } } return joined, nil } // deepestExistingAncestor returns p itself if it exists, otherwise the nearest // existing parent directory (falling back to the filesystem root). func deepestExistingAncestor(p string) string { for { if _, err := os.Lstat(p); err == nil { return p } parent := filepath.Dir(p) if parent == p { return p } p = parent } } // Plan returns the jobs to execute for name in dependency order. When // includeDeps is false only the named job is returned (its declared // dependencies are still validated to exist and be acyclic by Validate). func (s *Set) Plan(name string, includeDeps bool) ([]*Job, error) { root, ok := s.Jobs[name] if !ok { return nil, fmt.Errorf("unknown job %q (known: %s)", name, strings.Join(s.Names(), ", ")) } if !includeDeps { return []*Job{root}, nil } var order []*Job visited := map[string]bool{} inProgress := map[string]bool{} var visit func(n string) error visit = func(n string) error { if visited[n] { return nil } if inProgress[n] { return fmt.Errorf("dependency cycle at job %q", n) } inProgress[n] = true j := s.Jobs[n] deps := j.effectiveDeps() sort.Strings(deps) for _, d := range deps { if _, ok := s.Jobs[d]; !ok { return fmt.Errorf("job %q depends on unknown job %q", n, d) } if err := visit(d); err != nil { return err } } inProgress[n] = false visited[n] = true order = append(order, j) return nil } if err := visit(name); err != nil { return nil, err } return order, nil } // findCycle returns a human-readable cycle path, or "" if the graph is acyclic. func (s *Set) findCycle() string { color := map[string]int{} // 0 unvisited, 1 in progress, 2 done var stack []string var dfs func(n string) []string dfs = func(n string) []string { color[n] = 1 stack = append(stack, n) deps := s.Jobs[n].effectiveDeps() sort.Strings(deps) for _, d := range deps { if _, ok := s.Jobs[d]; !ok { continue } switch color[d] { case 0: if c := dfs(d); c != nil { return c } case 1: // Found a back edge; build the cycle slice. for i, x := range stack { if x == d { return append(append([]string(nil), stack[i:]...), d) } } return []string{d, d} } } stack = stack[:len(stack)-1] color[n] = 2 return nil } for _, n := range s.Names() { if color[n] == 0 { if c := dfs(n); c != nil { return strings.Join(c, " -> ") } } } return "" }