package buildstep import ( "fmt" "maps" "path" "regexp" "strings" "git.novox.be/novox/mesh-sdk/go/delivery" ) var ( requestCommitOK = regexp.MustCompile(`^[0-9a-f]{40}$`) requestRepositoryOK = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`) ) // FetchRequest is what a build module's fetch tool is asked: the repository and the full commit, the pull request's // ref when only its head carries the commit, the trunk commit to merge onto for a merge preview, and, for a build of a // module whose every artifact names its paths, that module's directory and its named paths (novox/hq ADR 0499 clause 4). // // **The paths come from the builder, not from a manifest** (ADR 0495 clause 1; the lead's decision L8 of R4e): the // builder fills Dir and Paths from the indexer's Module value (Path, and KeptInputs2.Paths' keys), and the build module // gives the build and test steps a workspace holding those paths, the manifest and its lock files. A request without // them builds from the module's directory, as before. type FetchRequest struct { Repository string `json:"repository"` Commit string `json:"commit"` Ref string `json:"ref,omitempty"` Base string `json:"base,omitempty"` Dir string `json:"dir,omitempty"` Paths []string `json:"paths,omitempty"` // Packages are the version of each mesh package the build or the check builds against (delivery.StepAsk.Packages, // novox/hq ADR 0573 §2, §4): the build module writes each into the workspace's go.mod and go.sum, whatever version // the repository names, checksum-verified, and names them in its log. Packages delivery.PackageVersions `json:"packages,omitempty"` } // FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input. func FetchRequestProperties() map[string]any { return map[string]any{ "dir": map[string]any{"type": "string", "description": "the module's directory in the repository (\".\" for its root), with paths"}, "paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "the paths the module names, relative to dir, from the indexer's kept inputs: the build sees only these, the manifest and its lock files"}, "packages": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}, "description": "optional: the version (a full commit) of each mesh package to build against, by its module path, whatever version go.mod names (novox/hq ADR 0573 §2); the builder and the checker fill it"}, } } // Args is r as a tool call's arguments. func (r FetchRequest) Args() map[string]any { args := map[string]any{"repository": r.Repository, "commit": r.Commit} for k, v := range map[string]string{"ref": r.Ref, "base": r.Base, "dir": r.Dir} { if v != "" { args[k] = v } } if len(r.Paths) > 0 { args["paths"] = append([]string(nil), r.Paths...) } if len(r.Packages) > 0 { args["packages"] = map[string]any{} for p, v := range r.Packages { args["packages"].(map[string]any)[p] = v } } return args } // ParseFetchRequest reads a fetch tool's arguments; arguments it does not know (a build module's own) are left to the // caller. It refuses a repository that is not /, a commit or a base that is not 40 hex digits, paths with no // module directory or a directory with no path, a directory leaving the repository and a path leaving the directory, // naming each. func ParseFetchRequest(args map[string]any) (FetchRequest, error) { var r FetchRequest r.Repository, _ = args["repository"].(string) r.Commit, _ = args["commit"].(string) r.Ref, _ = args["ref"].(string) r.Base, _ = args["base"].(string) r.Dir, _ = args["dir"].(string) switch v := args["paths"].(type) { case nil: case []string: r.Paths = append([]string(nil), v...) case []any: for _, p := range v { s, ok := p.(string) if !ok { return FetchRequest{}, fmt.Errorf("paths is a list of strings") } r.Paths = append(r.Paths, s) } default: return FetchRequest{}, fmt.Errorf("paths is a list of strings") } packages, err := PackagesArg(args["packages"]) if err != nil { return FetchRequest{}, err } r.Packages = packages return r, r.Check() } // PackagesArg reads a tool call's packages argument, an object of strings (as JSON gives it) or the map an ask gives; // nil for none. func PackagesArg(v any) (delivery.PackageVersions, error) { out := delivery.PackageVersions{} switch v := v.(type) { case nil: return nil, nil case delivery.PackageVersions: maps.Copy(out, v) case map[string]string: maps.Copy(out, v) case map[string]any: for p, version := range v { s, ok := version.(string) if !ok { return nil, fmt.Errorf("packages is an object of versions (strings), by package") } out[p] = s } default: return nil, fmt.Errorf("packages is an object of versions (strings), by package") } if len(out) == 0 { return nil, nil } return out, out.Check() } // Check is what ParseFetchRequest refuses. func (r FetchRequest) Check() error { if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") { return fmt.Errorf("%q is not a repository, /", r.Repository) } if !requestCommitOK.MatchString(r.Commit) { return fmt.Errorf("%q is not a full commit (40 hex digits)", r.Commit) } if r.Base != "" && !requestCommitOK.MatchString(r.Base) { return fmt.Errorf("%q is not a trunk commit (40 hex digits)", r.Base) } switch { case len(r.Paths) > 0 && r.Dir == "": return fmt.Errorf("the request names paths and names no module directory (dir) they are relative to") case len(r.Paths) == 0 && r.Dir != "": return fmt.Errorf("the request names a module directory and no path") } if r.Dir != "" && (outside(r.Dir) || strings.HasPrefix(r.Dir, "/")) { return fmt.Errorf("the module directory %q leaves the repository", r.Dir) } for _, p := range r.Paths { if p == "" || strings.HasPrefix(p, "/") || outside(p) || path.Clean(p) == "." { return fmt.Errorf("the path %q leaves the module's directory or is not a path in it", p) } } return nil } func outside(p string) bool { c := path.Clean(p) return c == ".." || strings.HasPrefix(c, "../") }