Files
mesh-delivery/sdk/buildstep/request.go
T

162 lines
6.0 KiB
Go

package buildstep
import (
"fmt"
"maps"
"path"
"regexp"
"strings"
)
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 (Ask.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 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 <owner>/<name>, 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) (PackageVersions, error) {
out := PackageVersions{}
switch v := v.(type) {
case nil:
return nil, nil
case 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, <owner>/<name>", 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, "../")
}