diff --git a/contracts/go/clients/meshdeliverycheckerclient/client.go b/contracts/go/clients/meshdeliverycheckerclient/client.go index 5263a5c8..7e2acbf3 100644 --- a/contracts/go/clients/meshdeliverycheckerclient/client.go +++ b/contracts/go/clients/meshdeliverycheckerclient/client.go @@ -77,6 +77,32 @@ func checkComposition(c *checker.VerdictsByGroupHeadsValueComposition) error { return nil } +// checkJudgedBy refuses a checking program named at no repository, full commit and package, kept under no program key, +// or over no head or a head at no full commit (novox/hq ADR 0644, ADR 0656; issue 1921). +func checkJudgedBy(j *checker.VerdictsByGroupHeadsValueJudgedBy) error { + const who = "meshdeliverycheckerclient: " + if j == nil { + return nil + } + if CheckRepository(j.Program.Repository) != nil || !isCommit(j.Program.Commit) || j.Program.Package == "" { + return fmt.Errorf(who+"the verdict is judged by a checking program built at no repository, full commit and package (%+v)", j.Program) + } + if !strings.HasPrefix(j.Key, "program-") { + return fmt.Errorf(who+"the verdict's checking program is kept under %q, which is no program key", j.Key) + } + if len(j.Heads) == 0 { + return fmt.Errorf(who + "the verdict's checking program read no head") + } + for _, h := range j.Heads { + if CheckRepository(h.Repository) != nil || !isCommit(h.Commit) { + return fmt.Errorf(who+"the verdict's checking program read %s at %q, which is no full commit", h.Repository, h.Commit) + } + } + return nil +} + +func isCommit(s string) bool { return len(s) == 40 && strings.Trim(s, "0123456789abcdef") == "" } + // CheckVerdict refuses a verdict a reader could not place: no group, heads that are not 12 lowercase hex digits, a repository // both merged and read beside, a carried-from that carries nothing, a series change from a series to itself or named twice, a // level that is neither unit nor integration or counts a negative number, a time below zero, a pass that says nothing of a @@ -145,6 +171,9 @@ func CheckVerdict(v checker.VerdictsByGroupHeadsValue) error { if err := checkComposition(v.Composition); err != nil { return err } + if err := checkJudgedBy(v.JudgedBy); err != nil { + return err + } seen := map[string]bool{} for _, level := range v.OverBound { if err := checkLevel(level); err != nil { diff --git a/contracts/go/clients/meshdeliverycheckerclient/client_test.go b/contracts/go/clients/meshdeliverycheckerclient/client_test.go index d1e1a99f..38c651c0 100644 --- a/contracts/go/clients/meshdeliverycheckerclient/client_test.go +++ b/contracts/go/clients/meshdeliverycheckerclient/client_test.go @@ -1,6 +1,7 @@ package meshdeliverycheckerclient import ( + "encoding/json" "strings" "testing" @@ -44,6 +45,11 @@ func TestAVerdictIsRefusedForWhatAReaderCouldNotPlace(t *testing.T) { v.Composition = &checker.VerdictsByGroupHeadsValueComposition{State: CompositionPass} }, "mesh-sdk commit"}, "the same level over bound twice": {func(v *verdict) { v.OverBound = []string{LevelUnit, LevelUnit} }, "twice"}, + // 1921 (B0, G): the checking program that judged it. + "judged at no full commit": {func(v *verdict) { j := judgedBy(); j.Program.Commit = "0123456"; v.JudgedBy = &j }, "checking program built at no repository, full commit"}, + "judged under no key": {func(v *verdict) { j := judgedBy(); j.Key = "composer-1"; v.JudgedBy = &j }, "composer-1"}, + "judged over no head": {func(v *verdict) { j := judgedBy(); j.Heads = nil; v.JudgedBy = &j }, "no head"}, + "judged over a branch": {func(v *verdict) { j := judgedBy(); j.Heads[0].Commit = "main"; v.JudgedBy = &j }, "novox/mesh-delivery"}, } { v := passing() c.edit(&v) @@ -51,6 +57,12 @@ func TestAVerdictIsRefusedForWhatAReaderCouldNotPlace(t *testing.T) { t.Errorf("%s: %v, want a refusal saying %q", name, err, c.says) } } + judged := passing() + j := judgedBy() + judged.JudgedBy = &j + if err := CheckVerdict(judged); err != nil { + t.Errorf("a pass judged by a checking program is refused: %v", err) + } failed := passing() failed.State, failed.Failed = VerdictFail, &checker.VerdictsByGroupHeadsValueFailed{Machine: "ace"} if err := CheckVerdict(failed); err != nil { @@ -191,6 +203,33 @@ func TestTheCodecsWriteAtTheVersionAndReadBackThroughTheRules(t *testing.T) { if _, err := EncodePreview(p); err == nil { t.Error("a preview with no merge commit and no conflict is written") } + // 1921 (B0, G): a checking program's input is asked of the seat's verb input by its digest, and an answer for + // another digest, or bytes that are not the digest, is refused. + body := []byte(`{"v":1}`) + digest := DigestOf(body) + for name, c := range map[string]struct { + answer any + says string + }{ + "its own bytes": {InputAnswerOf(body), ""}, + "another digest": {checker.InputAnswer{Digest: DigestOf([]byte("x")), Input: InputAnswerOf(body).Input}, "answered the input"}, + "bytes that are not it": {checker.InputAnswer{Digest: digest, Input: InputAnswerOf([]byte(`{"v":2}`)).Input}, "whose digest is"}, + "bytes in no base64": {checker.InputAnswer{Digest: digest, Input: "%%"}, "no base64"}, + "no answer of the layout": {"nothing", "answered no input"}, + } { + got, err := ReadInput(func(key string, b any) (json.RawMessage, error) { + if key != "seat:mesh-delivery-checker.input" || b != (checker.InputInput{Digest: digest}) { + t.Errorf("%s: asked %s %+v", name, key, b) + } + return json.Marshal(c.answer) + }, digest) + switch { + case c.says == "" && (err != nil || string(got) != string(body)): + t.Errorf("%s: %q, %v", name, got, err) + case c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)): + t.Errorf("%s: %v, want a refusal saying %q", name, err, c.says) + } + } } // The kept and preview keys: a key reads back to its parts, and a part that does not key is refused. @@ -221,3 +260,10 @@ func TestKeptAndPreviewKeysReadBack(t *testing.T) { } } } + +func judgedBy() checker.VerdictsByGroupHeadsValueJudgedBy { + const c = "0123456789abcdef0123456789abcdef01234567" + return checker.VerdictsByGroupHeadsValueJudgedBy{ + Program: checker.VerdictsByGroupHeadsValueJudgedByProgram{Repository: "novox/mesh-delivery", Commit: c, Dir: "modules/mesh-delivery-checker", Package: "p"}, + Key: "program-0123456789abcdef", Built: true, Heads: []checker.VerdictsByGroupHeadsValueJudgedByHeadsItem{{Repository: "novox/mesh-delivery", Commit: c}}} +} diff --git a/contracts/go/clients/meshdeliverycheckerclient/input.go b/contracts/go/clients/meshdeliverycheckerclient/input.go new file mode 100644 index 00000000..1594df80 --- /dev/null +++ b/contracts/go/clients/meshdeliverycheckerclient/input.go @@ -0,0 +1,53 @@ +package meshdeliverycheckerclient + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + + checker "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker" +) + +// InputVerb is the seat's verb input as a manifest's `invokes` names it and a run ask's input names it: asked of the +// seat, on no one machine (novox/hq ADR 0644, ADR 0656; issue 1921). A checking program's input is kept by the checker +// by the sha256 of its exact bytes, and asked of it by the build module that runs the program (ADR 0596 §2). +const InputVerb = "seat:" + checker.Seat + "." + checker.VerbInput + +// InputAnswerOf is the verb input's answer for body: its digest and its exact bytes, base64. +func InputAnswerOf(body []byte) checker.InputAnswer { + return checker.InputAnswer{Digest: DigestOf(body), Input: base64.StdEncoding.EncodeToString(body)} +} + +// DigestOf is the digest a checking program's input is kept and asked by: the sha256 of its bytes, 64 lowercase hex +// digits. +func DigestOf(body []byte) string { + sum := sha256.Sum256(body) + return hex.EncodeToString(sum[:]) +} + +// ReadInput asks the verb input, with ask (a tools bundle's stdio.Ask, a process's Conn.Call), for the input kept by +// digest and answers its bytes, refusing an answer for another digest and bytes whose sha256 is not the digest. +func ReadInput(ask func(key string, body any) (json.RawMessage, error), digest string) ([]byte, error) { + const who = "meshdeliverycheckerclient: " + raw, err := ask(InputVerb, checker.InputInput{Digest: digest}) + if err != nil { + return nil, fmt.Errorf(who+"asking %s for the input %s: %w", InputVerb, digest, err) + } + var a checker.InputAnswer + if err := json.Unmarshal(raw, &a); err != nil { + return nil, fmt.Errorf(who+"%s answered no input for %s: %w", InputVerb, digest, err) + } + if a.Digest != digest { + return nil, fmt.Errorf(who+"%s answered the input %q, asked %s", InputVerb, a.Digest, digest) + } + body, err := base64.StdEncoding.DecodeString(a.Input) + if err != nil { + return nil, fmt.Errorf(who+"%s answered the input %s in no base64: %w", InputVerb, digest, err) + } + if got := DigestOf(body); got != digest { + return nil, fmt.Errorf(who+"the input %s answered %d bytes whose digest is %s", digest, len(body), got) + } + return body, nil +} diff --git a/sdk/buildrun/stale.go b/sdk/buildrun/stale.go index 6e0b6075..e0344de0 100644 --- a/sdk/buildrun/stale.go +++ b/sdk/buildrun/stale.go @@ -118,6 +118,12 @@ func AskWhat(a buildstep.Ask) string { } case a.Compose != nil: parts = append(parts, "compose", str(a.Compose.Fetch, "repository")+"@"+short(str(a.Compose.Fetch, "commit"))) + case a.Run != nil: + parts = append(parts, "run "+a.Run.Program.Package, a.Run.Program.Repository+"@"+short(a.Run.Program.Commit)) + if len(a.Run.Args) > 0 { + parts = append(parts, strings.Join(a.Run.Args, " ")) + } + parts = append(parts, fmt.Sprintf("%d heads", len(a.Run.Heads))) } return strings.Join(parts, ", ") } diff --git a/sdk/buildrun/stale_test.go b/sdk/buildrun/stale_test.go index d6b48cd5..e9f570b8 100644 --- a/sdk/buildrun/stale_test.go +++ b/sdk/buildrun/stale_test.go @@ -165,4 +165,9 @@ func TestAskWhat(t *testing.T) { if got, want := AskWhat(build), "delivery ask, module mesh-x, fp abcdef012345"; got != want { t.Errorf("build = %q", got) } + // 1921: a run ask's step names the program, where it is built, its arguments and how many heads it reads. + run := buildstep.Ask{Kind: "check", Run: &buildstep.RunJob{Program: buildstep.RunProgram{Repository: "novox/mesh-delivery", Commit: "fedcba9876543210", Package: "./cmd/mesh-checking-program"}, Heads: []buildstep.RunHead{{}, {}}, Args: []string{"-verdict"}}} + if got, want := AskWhat(run), "check ask, run ./cmd/mesh-checking-program, novox/mesh-delivery@fedcba987654, -verdict, 2 heads"; got != want { + t.Errorf("run = %q", got) + } } diff --git a/sdk/buildstep/ask.go b/sdk/buildstep/ask.go index 9418a2be..6184dad3 100644 --- a/sdk/buildstep/ask.go +++ b/sdk/buildstep/ask.go @@ -197,6 +197,9 @@ type StepAsk struct { Build *BuildJob `json:"build,omitempty"` Check *CheckJob `json:"check,omitempty"` Compose *ComposeJob `json:"compose,omitempty"` + // Run is a run ask's job (run.go): a program built from a change and run over the heads, on the Go seat's check + // stream alone, as a compose. + Run *RunJob `json:"run,omitempty"` // Needs are what a check's tests need of the holder's node (CheckNeed); a holder lacking one declines the ask. Needs []string `json:"needs,omitempty"` // Packages pin the mesh packages a build or a check builds against. @@ -323,16 +326,18 @@ func (a StepAsk) check() error { return fmt.Errorf("buildstep: the ask %s names no time", a.ID) } jobs := 0 - for _, set := range []bool{a.Build != nil, a.Check != nil, a.Compose != nil} { + for _, set := range []bool{a.Build != nil, a.Check != nil, a.Compose != nil, a.Run != nil} { if set { jobs++ } } switch { case jobs != 1: - return fmt.Errorf("buildstep: the ask %s holds a build, a check or a compose, exactly one", a.ID) + return fmt.Errorf("buildstep: the ask %s holds a build, a check, a compose or a run, exactly one", a.ID) case a.Compose != nil && (a.Kind != AskCheck || a.Language != AskGo): return fmt.Errorf("buildstep: the ask %s is a compose, which the Go seat's check stream alone carries", a.ID) + case a.Run != nil && (a.Kind != AskCheck || a.Language != AskGo): + return fmt.Errorf("buildstep: the ask %s is a run, which the Go seat's check stream alone carries", a.ID) case a.Build != nil && a.Kind == AskRecheck: return fmt.Errorf("buildstep: the ask %s is a build on the recheck stream, which carries checks alone", a.ID) case a.Check != nil && a.Kind == AskDelivery: @@ -357,6 +362,8 @@ func (a StepAsk) check() error { return a.Build.check(a.ID) case a.Compose != nil: return a.Compose.check(a.ID) + case a.Run != nil: + return a.Run.check(a.ID) } return a.Check.check(a.ID) } @@ -597,7 +604,7 @@ type FetchRefusal struct { Reason string `json:"reason"` } -// StepResult is what any step answered: one layout for the four kinds, as the seats' `StepResult` (the fields of the +// StepResult is what any step answered: one layout for the five kinds, as the seats' `StepResult` (the fields of the // kinds do not meet). A step's result bytes read into it strictly. type StepResult struct { Artifacts []AskBuilt `json:"artifacts,omitempty"` @@ -609,6 +616,9 @@ type StepResult struct { Built bool `json:"built,omitempty"` Key string `json:"key,omitempty"` Verdict json.RawMessage `json:"verdict,omitempty"` + Exit int `json:"exit,omitempty"` + Output json.RawMessage `json:"output,omitempty"` + Said string `json:"said,omitempty"` Declined bool `json:"declined,omitempty"` Needs []string `json:"needs,omitempty"` Lacks []string `json:"lacks,omitempty"` diff --git a/sdk/buildstep/ask_test.go b/sdk/buildstep/ask_test.go index 3b409b4e..e8272b88 100644 --- a/sdk/buildstep/ask_test.go +++ b/sdk/buildstep/ask_test.go @@ -29,10 +29,24 @@ func goodCheck() StepAsk { Check: &CheckJob{Fetch: Fetch{Repository: "novox/mesh-catalog", Commit: testCommit}, Levels: []string{LevelUnit}}} } +// runAsk is the checker's run of a checking program built at a mesh-delivery head, over two heads (issue 1921, B0). +func runAsk() StepAsk { + in, err := RunInputOf("seat:mesh-delivery-checker.input", []byte(`{"v":1}`)) + if err != nil { + panic(err) + } + return StepAsk{ID: testAskID, Kind: AskCheck, Language: AskGo, Asker: "mesh-delivery-checker", + At: time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC), + Run: &RunJob{Program: RunProgram{Repository: "novox/mesh-delivery", Commit: testCommit, Dir: "modules/mesh-delivery-checker", + Package: "./cmd/mesh-checking-program"}, + Heads: []RunHead{{Repository: "novox/mesh-delivery", Commit: testCommit}, {Repository: "novox/mesh-sdk", Commit: testCommit}}, + Input: in, Args: []string{"-verdict"}}} +} + // The ask the three build seats share is one neutral form: it round-trips, and every ill-formed ask is refused, // naming what is wrong (the rules of the old delivery.StepAsk, now the shared runner's). func TestAskCheckAndRoundTrip(t *testing.T) { - for _, a := range []StepAsk{goodBuild(), goodCheck()} { + for _, a := range []StepAsk{goodBuild(), goodCheck(), runAsk()} { raw, err := EncodeStepAsk(a) if err != nil { t.Fatal(err) @@ -66,6 +80,44 @@ func TestAskCheckAndRoundTrip(t *testing.T) { t.Errorf("%s: %v, want %q", c.name, err, c.want) } } + // 1921 (B0, G): a run ask goes only on the Go seat's check stream, alone, builds a clean package at a full commit + // over heads named once at full commits, and names its input on a seat's verb by a digest within its bound. + for _, c := range []struct { + name string + edit func(*StepAsk) + want string + }{ + {"run beside a check", func(a *StepAsk) { a.Check = goodCheck().Check }, "or a run, exactly one"}, + {"run on the delivery stream", func(a *StepAsk) { a.Kind = AskDelivery }, "is a run"}, + {"run on the Node seat", func(a *StepAsk) { a.Language = AskNode }, "is a run"}, + {"run at a short commit", func(a *StepAsk) { a.Run.Program.Commit = "0123456" }, "no full commit"}, + {"run outside the repository", func(a *StepAsk) { a.Run.Program.Dir = "../x" }, "no clean directory"}, + {"run of no package", func(a *StepAsk) { a.Run.Program.Package = "" }, "no package"}, + {"run over no head", func(a *StepAsk) { a.Run.Heads = nil }, "no head"}, + {"run over a head twice", func(a *StepAsk) { a.Run.Heads = append(a.Run.Heads, a.Run.Heads[0]) }, "twice"}, + {"run over a branch", func(a *StepAsk) { a.Run.Heads[1].Commit = "main" }, "novox/mesh-sdk at no full commit"}, + {"input on a state", func(a *StepAsk) { a.Run.Input.Verb = "mesh-delivery-checker.inputs" }, "no seat:."}, + {"input on one machine", func(a *StepAsk) { a.Run.Input.Verb = "seat:mesh-delivery-checker.input@ace" }, "no seat:."}, + {"input by no digest", func(a *StepAsk) { a.Run.Input.Digest = "d1" }, "no sha256 digest"}, + {"input over its bound", func(a *StepAsk) { a.Run.Input.Size = RunInputBound + 1 }, "outside 1 to"}, + {"argument of the shell", func(a *StepAsk) { a.Run.Args = []string{"-x; rm"} }, "no word or flag"}, + } { + a := runAsk() + c.edit(&a) + if _, err := EncodeStepAsk(a); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: %v, want %q", c.name, err, c.want) + } + } + if _, err := RunInputOf("seat:mesh-delivery-checker.input", make([]byte, RunInputBound+1)); err == nil || !strings.Contains(err.Error(), "262145 bytes") { + t.Errorf("an input over its bound: %v", err) + } + in := runAsk().Run.Input + if err := in.Verify([]byte(`{"v":1}`)); err != nil { + t.Errorf("the input's own bytes: %v", err) + } + if err := in.Verify([]byte(`{"v":2}`)); err == nil || !strings.Contains(err.Error(), "not the one the ask names") { + t.Errorf("bytes that are not the digest: %v", err) + } chk := goodCheck() chk.Check.Levels = []string{"nightly"} if _, err := EncodeStepAsk(chk); err == nil || !strings.Contains(err.Error(), "nightly") { @@ -230,6 +282,12 @@ func TestPauseCapacityAndComposerKey(t *testing.T) { if k == ComposerKey("novox/r", testCommit, "d", "p", tc, PackageVersions{"m/x": b, "m/y": a}) || k == ComposerKey("novox/r", testCommit, "d", "p", tc, nil) { t.Error("other versions, or none, share the key") } + // 1921 (B0): a run's program is kept the same way, under a key of its own kind. + p := RunProgram{Repository: "novox/mesh-delivery", Commit: testCommit, Dir: "modules/mesh-delivery-checker", Package: "p"} + if rk := RunKey(p, tc, nil); !strings.HasPrefix(rk, "program-") || len(rk) != len("program-")+16 || rk != RunKey(p, tc, nil) || + RunKey(p, tc, PackageVersions{"m/x": a}) == rk { + t.Errorf("run key = %q", rk) + } if ComposerKey("novox/r", testCommit, "d", "p", tc, nil) != ComposerKey("novox/r", testCommit, "d", "p", tc, PackageVersions{}) { t.Error("an empty ask changed the key") } diff --git a/sdk/buildstep/run.go b/sdk/buildstep/run.go new file mode 100644 index 00000000..e975cdf4 --- /dev/null +++ b/sdk/buildstep/run.go @@ -0,0 +1,181 @@ +package buildstep + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "path" + "regexp" + "strings" + + "git.novox.be/novox/mesh-sdk/go/calladdress" +) + +// **A run ask has a program built from a change and run in the build seat's sandbox** (novox/hq ADR 0644, ADR 0656; +// design 47, *What ADR 0656 leaves to this design*: the build seat's ask `run`; issue 1921). It goes on the Go seat's +// check stream, a whole job pulled where there is capacity, like a compose: the build module fetches Program at its +// commit and builds it once per RunKey, in a step that runs nothing but the build; fetches every head of Heads and lays +// each read-only under the directory RunHeadsEnv names, at its repository's path (`/`); reads the input +// Input names, checks its digest and writes it to the file RunInputEnv names; then runs the program with Args, no +// network and no bus account (ADR 0448 §2, §4), and answers RunAnswer on its step key. +// +// The input is carried by reference, as a compose carries its manifests: the asker keeps it by its digest, measured +// against RunInputBound before it asks, and serves it on a verb of its seat (RunInput.Verb); the build module asks that +// verb as its manifest's `invokes` grants it, never reading the asker's state (ADR 0596 §2), so the ask on the stream +// never carries it (issue 1930). The layouts are the part build-step's `RunJob`, `RunProgram`, `RunHead`, `RunInput` +// and `RunAnswer`. + +// RunJob is a run ask's job. +type RunJob struct { + // Program is where the program is built: the repository and full commit fetched, the Go module's directory in it and + // the program's import path. The ask's Packages are the mesh package versions it builds against (ADR 0573 §2, §4). + Program RunProgram `json:"program"` + // Heads are the commits the program reads, each laid read-only at its repository's path; a repository at most once. + Heads []RunHead `json:"heads"` + // Input names the program's input, kept by the asker by its digest and served on a verb of its seat. + Input RunInput `json:"input"` + // Args are the program's arguments (a checking program's `-seats` or `-verdict`), each a word or a flag. + Args []string `json:"args,omitempty"` + // Reader names, for a reader of the ask, what the run is for ("checking program of mesh-delivery head"). + Reader string `json:"reader,omitempty"` +} + +// RunProgram is where a run's program is built from. +type RunProgram struct { + Repository string `json:"repository"` + Commit string `json:"commit"` + Dir string `json:"dir"` + Package string `json:"package"` +} + +// RunHead is one commit a run's program reads. +type RunHead struct { + Repository string `json:"repository"` + Commit string `json:"commit"` +} + +// RunInput is a run's input by reference: the seat verb the asker serves it on, named as a manifest's `invokes` names it +// (`seat:.`, asked of no one machine), its digest (the sha256 of its bytes, 64 lowercase hex digits) and its +// size in bytes. +type RunInput struct { + Verb string `json:"verb"` + Digest string `json:"digest"` + Size int `json:"size"` +} + +// RunAnswer is a run ask's step result: whether this run built the program or took the one kept under its key, the +// key, the program's exit, the last line of its standard output (Output, raw when it is JSON, else as a JSON string), +// and Said: the first line of its standard error, or why the step could not run it. +type RunAnswer struct { + Built bool `json:"built"` + Key string `json:"key"` + Exit int `json:"exit"` + Output json.RawMessage `json:"output,omitempty"` + Said string `json:"said,omitempty"` +} + +// RunInputBound is the most bytes a run's input may hold: the bus's value limit the design already uses (256 KiB), so +// the asker keeps it as one value (the lead's ruling on milestone 47/B0, 2026-11-06). +const RunInputBound = 256 << 10 + +// RunHeadsEnv names, for the program, the directory its heads are laid in; RunInputEnv the file its input is written to. +const ( + RunHeadsEnv = "MESH_RUN_HEADS" + RunInputEnv = "MESH_RUN_INPUT" +) + +// RunInputOf is the reference to body served on verb (`seat:.`), refusing a body over RunInputBound, naming +// its size, so an asker refuses it before asking. +func RunInputOf(verb string, body []byte) (RunInput, error) { + if len(body) > RunInputBound { + return RunInput{}, fmt.Errorf("buildstep: a run's input of %d bytes is over its bound of %d bytes", len(body), RunInputBound) + } + sum := sha256.Sum256(body) + in := RunInput{Verb: verb, Digest: hex.EncodeToString(sum[:]), Size: len(body)} + return in, in.check("") +} + +// Verify refuses body when it is not the input i names: another size or another digest. +func (i RunInput) Verify(body []byte) error { + sum := sha256.Sum256(body) + if len(body) != i.Size || hex.EncodeToString(sum[:]) != i.Digest { + return fmt.Errorf("buildstep: the input %s answered for %s is not the one the ask names (%d bytes, %d named)", i.Verb, i.Digest, len(body), i.Size) + } + return nil +} + +var ( + runDigestOK = regexp.MustCompile(`^[0-9a-f]{64}$`) + runArgOK = regexp.MustCompile(`^[-=_./a-zA-Z0-9]+$`) +) + +func (i RunInput) check(id string) error { + switch { + case !runVerbOK(i.Verb): + return fmt.Errorf("buildstep: the run ask %s serves its input on %q, which is no seat:. of no one machine", id, i.Verb) + case !runDigestOK.MatchString(i.Digest): + return fmt.Errorf("buildstep: the run ask %s names its input by %q, which is no sha256 digest", id, i.Digest) + case i.Size <= 0 || i.Size > RunInputBound: + return fmt.Errorf("buildstep: the run ask %s names an input of %d bytes, outside 1 to %d", id, i.Size, RunInputBound) + } + return nil +} + +// runVerbOK says key names a seat's verb, asked of no one machine. +func runVerbOK(key string) bool { + a, err := calladdress.Parse(key) + return err == nil && a.Seat != "" && a.Node == "" +} + +func checkRunRepository(id, what, repository string) error { + owner, name, ok := strings.Cut(repository, "/") + if !ok || owner == "" || name == "" || strings.Contains(name, "/") { + return fmt.Errorf("buildstep: the run ask %s names %s %q, which is no repository /", id, what, repository) + } + return nil +} + +func (r RunJob) check(id string) error { + p := r.Program + if err := checkRunRepository(id, "its program's repository", p.Repository); err != nil { + return err + } + if !askCommitOK.MatchString(p.Commit) { + return fmt.Errorf("buildstep: the run ask %s builds its program at no full commit", id) + } + if p.Dir == "" || p.Dir != path.Clean(p.Dir) || strings.HasPrefix(p.Dir, "/") || p.Dir == ".." || strings.HasPrefix(p.Dir, "../") { + return fmt.Errorf("buildstep: the run ask %s names no clean directory inside the repository (%q)", id, p.Dir) + } + if p.Package == "" || strings.ContainsAny(p.Package, " \t\n") { + return fmt.Errorf("buildstep: the run ask %s names no package to build (%q)", id, p.Package) + } + if len(r.Heads) == 0 { + return fmt.Errorf("buildstep: the run ask %s names no head", id) + } + seen := map[string]bool{} + for _, h := range r.Heads { + if err := checkRunRepository(id, "a head of", h.Repository); err != nil { + return err + } + if !askCommitOK.MatchString(h.Commit) { + return fmt.Errorf("buildstep: the run ask %s names the head of %s at no full commit", id, h.Repository) + } + if seen[h.Repository] { + return fmt.Errorf("buildstep: the run ask %s names %s twice", id, h.Repository) + } + seen[h.Repository] = true + } + for _, a := range r.Args { + if !runArgOK.MatchString(a) { + return fmt.Errorf("buildstep: the run ask %s names the argument %q, which is no word or flag", id, a) + } + } + return r.Input.check(id) +} + +// RunKey is the key a build module keeps a built program under, as ComposerKey keeps a composer: what it was built +// from, the toolchain and the asked package versions, hashed (`program-` and 16 hex digits). +func RunKey(p RunProgram, toolchain ToolchainUsed, versions PackageVersions) string { + return "program-" + strings.TrimPrefix(ComposerKey(p.Repository, p.Commit, p.Dir, p.Package, toolchain, versions), "composer-") +} diff --git a/sdk/buildstep/steps.go b/sdk/buildstep/steps.go index e39bb9f9..11a78687 100644 --- a/sdk/buildstep/steps.go +++ b/sdk/buildstep/steps.go @@ -186,6 +186,7 @@ type Ask struct { Compose *struct { Fetch map[string]any `json:"fetch"` } `json:"compose"` + Run *RunJob `json:"run"` } // CheckAskID refuses an id that is no ask's: base-36 milliseconds and six hex digits, as NewID gives. Its form keeps it apart from