runs: ids, keys, wire shapes and validation for carlos run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+686
−1
approved_run_copy_test.go+1 −1internal/runs/check.go+106 −0internal/runs/runs.go+226 −0internal/runs/runs_test.go+220 −0internal/runs/store.go+133 −0
diff --git a/approved_run_copy_test.go b/approved_run_copy_test.go| index 86006821f..48097ea28 100644 |
| --- a/approved_run_copy_test.go |
| +++ b/approved_run_copy_test.go |
| @@ -33,7 +33,7 @@ var runCopyIDs = func() []string { |
| // runCopyBuilt is every deck id whose text the code or docs now carry. The |
| // task that writes a row's text adds its id here in the same commit. |
| -var runCopyBuilt = []string{"RUN-C1", "RUN-C2", "RUN-C3", "RUN-C4", "RUN-C5", "RUN-C6", "RUN-C7", "RUN-C8", "RUN-C9", "RUN-C10", "RUN-C11", "RUN-C12", "RUN-C13", "RUN-C14", "RUN-C43"} |
| +var runCopyBuilt = []string{"RUN-C1", "RUN-C2", "RUN-C3", "RUN-C4", "RUN-C5", "RUN-C6", "RUN-C7", "RUN-C8", "RUN-C9", "RUN-C10", "RUN-C11", "RUN-C12", "RUN-C13", "RUN-C14", "RUN-C43", "RUN-C44", "RUN-C45", "RUN-C46", "RUN-C82"} |
| func mustParseRunRecord(t *testing.T) []copyRow { |
| t.Helper() |
diff --git a/internal/runs/check.go b/internal/runs/check.go| new file mode 100644 |
| index 000000000..52dfcda8e |
| --- /dev/null |
| +++ b/internal/runs/check.go |
| @@ -0,0 +1,106 @@ |
| +package runs |
| + |
| +import ( |
| + "errors" |
| + "strings" |
| + "time" |
| + "unicode" |
| + "unicode/utf8" |
| +) |
| + |
| +var ( |
| + ErrBadID = errors.New("runs: not a run id") |
| + ErrNoArgs = errors.New("runs: no arguments") |
| + ErrTooManyArgs = errors.New("runs: more than 256 arguments") |
| + ErrArgvTooLarge = errors.New("runs: arguments over 64 KB") |
| + ErrArgNUL = errors.New("runs: an argument holds a NUL byte") |
| + ErrTimeout = errors.New("runs: timeout outside 1s to 6h") |
| + ErrFilesTooLarge = errors.New("runs: files over 64 MB") |
| + ErrFileName = errors.New("runs: not a plain file name") |
| + ErrFileTwice = errors.New("runs: two files share a name") |
| +) |
| + |
| +// FileNameError names the file a refusal is about. |
| +type FileNameError struct { |
| + Name string |
| + Twice bool |
| +} |
| + |
| +func (e *FileNameError) Error() string { |
| + if e.Twice { |
| + return ErrFileTwice.Error() + ": " + e.Name |
| + } |
| + return ErrFileName.Error() + ": " + e.Name |
| +} |
| + |
| +func (e *FileNameError) Is(target error) bool { |
| + return (e.Twice && target == ErrFileTwice) || (!e.Twice && target == ErrFileName) |
| +} |
| + |
| +// CheckArgv is spec §5.1's argv rule. |
| +func CheckArgv(argv []string) error { |
| + switch { |
| + case len(argv) == 0: |
| + return ErrNoArgs |
| + case len(argv) > MaxArgs: |
| + return ErrTooManyArgs |
| + } |
| + total := 0 |
| + for _, a := range argv { |
| + if strings.IndexByte(a, 0) >= 0 { |
| + return ErrArgNUL |
| + } |
| + total += len(a) |
| + } |
| + if total > MaxArgvBytes { |
| + return ErrArgvTooLarge |
| + } |
| + return nil |
| +} |
| + |
| +// CheckTimeout is spec §5.1's timeout rule. |
| +func CheckTimeout(d time.Duration) error { |
| + if d < MinTimeout || d > MaxTimeout { |
| + return ErrTimeout |
| + } |
| + return nil |
| +} |
| + |
| +// PlainName reports whether name is a plain base name: not empty, at most |
| +// 255 bytes, valid UTF-8, no slash, backslash or control character, and |
| +// not "." or "..". The console checks it, and the box again (spec §7). |
| +func PlainName(name string) bool { |
| + if name == "" || name == "." || name == ".." || len(name) > 255 || !utf8.ValidString(name) { |
| + return false |
| + } |
| + for _, r := range name { |
| + if r == '/' || r == '\\' || unicode.IsControl(r) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// CheckFiles is spec §5.1's file rule: plain names, each once, sizes not |
| +// negative, 64 MB in total. |
| +func CheckFiles(files []MetaFile) error { |
| + seen := map[string]bool{} |
| + var total int64 |
| + for _, f := range files { |
| + if !PlainName(f.Name) { |
| + return &FileNameError{Name: f.Name} |
| + } |
| + if seen[f.Name] { |
| + return &FileNameError{Name: f.Name, Twice: true} |
| + } |
| + seen[f.Name] = true |
| + if f.Size < 0 { |
| + return ErrFilesTooLarge |
| + } |
| + total += f.Size |
| + if total > MaxFileBytes { |
| + return ErrFilesTooLarge |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/runs/runs.go b/internal/runs/runs.go| new file mode 100644 |
| index 000000000..db0f1f2f7 |
| --- /dev/null |
| +++ b/internal/runs/runs.go |
| @@ -0,0 +1,226 @@ |
| +// Package runs is what the console, the box and the CLI share about one |
| +// `carlos run` (run spec): ids, the run's objects under its app's subtree, |
| +// the wire shapes, the limits and the reasons a run ends without an exit. |
| +// It holds no policy beyond validation; the record's run state is |
| +// provision.RunState. |
| +package runs |
| + |
| +import ( |
| + "crypto/rand" |
| + "encoding/base32" |
| + "fmt" |
| + "io" |
| + "regexp" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const ( |
| + MaxArgs = 256 |
| + MaxArgvBytes = 64 << 10 |
| + MaxFileBytes = 64 << 20 |
| + MaxOutputBytes = 64 << 20 |
| + // MetaAllowance bounds the meta part and the multipart framing on top |
| + // of the files (run spec §5.1). |
| + MetaAllowance = 1 << 20 |
| + MinTimeout = time.Second |
| + MaxTimeout = 6 * time.Hour |
| + DefaultTimeout = 30 * time.Minute |
| + PickupWindow = 60 * time.Second |
| + LostAfter = time.Hour |
| + KeepFor = 24 * time.Hour |
| + ChunkBytes = 256 << 10 |
| + ChunkEvery = time.Second |
| + StreamOut = "o" |
| + StreamErr = "e" |
| + // A follow page holds at most PageChunks chunks and PageBytes of |
| + // output, so its JSON stays under the CLI's 1 MiB response cap. |
| + PageChunks = 64 |
| + PageBytes = 512 << 10 |
| +) |
| + |
| +// Why the box refused a run (result.json's refused). |
| +const ( |
| + RefusedFlat = "flat" |
| + RefusedClaim = "claim" |
| + RefusedWake = "wake" |
| + RefusedIncarnation = "incarnation" |
| + RefusedImport = "import" |
| +) |
| + |
| +// Why a run was interrupted, beside activator's Stop* reasons (RUN-C44 to |
| +// RUN-C46, RUN-C82). |
| +const ( |
| + InterruptedLost = "its box stopped reporting" |
| + InterruptedAgentRestart = "the box's agent restarted" |
| + InterruptedGone = "the command's unit went away" |
| + InterruptedStartFailed = "the box could not start the command" |
| +) |
| + |
| +// DirName is the directory under an instance's own data directory that |
| +// holds each run's files: activator.CmdRunDirName, restated here because |
| +// runs imports no activator (Plan 3 pins the two equal). |
| +const DirName = ".carlos-run" |
| + |
| +var idEnc = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding) |
| + |
| +var idShape = regexp.MustCompile(`^run_[a-z2-7]{26}$`) |
| + |
| +// NewID mints a run id from r: "run_" and 26 lowercase base32 characters, |
| +// the encoding of 16 random bytes (128 bits). A run id is the only thing that |
| +// stops the record from admitting a run twice and the only thing that names |
| +// a run's objects, so production code mints with MintID; NewID takes a reader |
| +// only so a test can supply a fixed one. |
| +func NewID(r io.Reader) (string, error) { |
| + var b [16]byte |
| + if _, err := io.ReadFull(r, b[:]); err != nil { |
| + return "", err |
| + } |
| + return "run_" + idEnc.EncodeToString(b[:]), nil |
| +} |
| + |
| +// MintID mints a run id from the operating system's cryptographic random |
| +// source. |
| +func MintID() (string, error) { return NewID(rand.Reader) } |
| + |
| +// ValidID reports whether id is a run id. Every id that passes is exactly 30 |
| +// bytes of [a-z2-7_] and so is one clean path element: never empty, with no |
| +// slash, backslash or dot, and never "." or "..". The console validates an |
| +// id from a request with it, the box validates one from a record with it, and |
| +// the store helpers below refuse an id that fails it. |
| +func ValidID(id string) bool { return idShape.MatchString(id) } |
| + |
| +// RunsPrefix is where an app's runs live: under its own subtree, so the |
| +// owner gate maps onto them and app trash and purge sweep them. |
| +func RunsPrefix(acct, app string) string { return release.AppPrefix(acct, app) + "runs/" } |
| + |
| +func Prefix(acct, app, id string) string { return RunsPrefix(acct, app) + id + "/" } |
| +func RequestKey(acct, app, id string) string { return Prefix(acct, app, id) + "request.json" } |
| +func ResultKey(acct, app, id string) string { return Prefix(acct, app, id) + "result.json" } |
| +func AuditedKey(acct, app, id string) string { return Prefix(acct, app, id) + "audited" } |
| +func OutPrefix(acct, app, id string) string { return Prefix(acct, app, id) + "out/" } |
| + |
| +func InputKey(acct, app, id, name string) string { return Prefix(acct, app, id) + "in/" + name } |
| + |
| +// ChunkKey is out/<seq>-<stream>, the seq zero-padded so keys sort in the |
| +// order the agent read the output. |
| +func ChunkKey(acct, app, id string, seq int64, stream string) string { |
| + return OutPrefix(acct, app, id) + fmt.Sprintf("%08d-%s", seq, stream) |
| +} |
| + |
| +// ParseChunkKey reads a key ChunkKey made under outPrefix. |
| +func ParseChunkKey(key, outPrefix string) (int64, string, bool) { |
| + name, ok := strings.CutPrefix(key, outPrefix) |
| + if !ok || len(name) != 10 || name[8] != '-' { |
| + return 0, "", false |
| + } |
| + stream := name[9:] |
| + if stream != StreamOut && stream != StreamErr { |
| + return 0, "", false |
| + } |
| + seq, err := strconv.ParseInt(name[:8], 10, 64) |
| + if err != nil || seq < 1 { |
| + return 0, "", false |
| + } |
| + for i := 0; i < 8; i++ { // ParseInt would also take a sign |
| + if name[i] < '0' || name[i] > '9' { |
| + return 0, "", false |
| + } |
| + } |
| + return seq, stream, true |
| +} |
| + |
| +// Meta is the create request's first part. |
| +type Meta struct { |
| + Argv []string `json:"argv"` |
| + TimeoutS int64 `json:"timeout_s"` |
| + Files []MetaFile `json:"files"` |
| +} |
| + |
| +type MetaFile struct { |
| + Name string `json:"name"` |
| + Size int64 `json:"size"` |
| +} |
| + |
| +// File is one uploaded file as request.json records it. |
| +type File struct { |
| + Name string `json:"name"` |
| + Size int64 `json:"size"` |
| + SHA256 string `json:"sha256"` |
| +} |
| + |
| +// Request is runs/<id>/request.json (run spec §5.1). Incarnation is the |
| +// record's (hostclaim.Incarnation of account, app, host and CreatedAt). |
| +type Request struct { |
| + Schema int `json:"schema"` |
| + ID string `json:"id"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Host string `json:"host"` |
| + Incarnation string `json:"incarnation"` |
| + Argv []string `json:"argv"` |
| + TimeoutS int64 `json:"timeout_s"` |
| + Files []File `json:"files"` |
| + By string `json:"by"` |
| + RequestedAt time.Time `json:"requested_at"` |
| +} |
| + |
| +// Timeout is the run's timeout as a duration. |
| +func (r Request) Timeout() time.Duration { return time.Duration(r.TimeoutS) * time.Second } |
| + |
| +// Result is runs/<id>/result.json (run spec §5.5), written only after the |
| +// last chunk. Signal is the decimal signal number when a signal ended the |
| +// command (index item 5); Detail is the member-safe wake error for a |
| +// refused wake (index item 4). |
| +type Result struct { |
| + Exit int `json:"exit"` |
| + Signal string `json:"signal"` |
| + TimedOut bool `json:"timed_out"` |
| + Cancelled bool `json:"cancelled"` |
| + Interrupted string `json:"interrupted"` |
| + Refused string `json:"refused"` |
| + Detail string `json:"detail,omitempty"` |
| + Started time.Time `json:"started"` |
| + Ended time.Time `json:"ended"` |
| + Box string `json:"box"` |
| + LastSeq int64 `json:"last_seq"` |
| + Truncated bool `json:"truncated"` |
| +} |
| + |
| +// Chunk is one output chunk in a follow page; Data is base64 in JSON. |
| +type Chunk struct { |
| + Seq int64 `json:"seq"` |
| + Stream string `json:"stream"` |
| + Data []byte `json:"data"` |
| +} |
| + |
| +// Page is GET …/runs/{id}?after=<seq>. State is the record's state for |
| +// this run, or "" when the record no longer names it. More says the page |
| +// was cut short: ask again at once. |
| +type Page struct { |
| + ID string `json:"id"` |
| + State string `json:"state"` |
| + Chunks []Chunk `json:"chunks"` |
| + Result *Result `json:"result,omitempty"` |
| + More bool `json:"more"` |
| +} |
| + |
| +// Ack is POST …/runs/{id}/ack. |
| +type Ack struct { |
| + LastSeq int64 `json:"last_seq"` |
| +} |
| + |
| +// Created is the 202 to a create. |
| +type Created struct { |
| + ID string `json:"id"` |
| + Host string `json:"host"` |
| + State string `json:"state"` |
| +} |
| + |
| +// StateAnswer is the answer to cancel, expire and ack. |
| +type StateAnswer struct { |
| + State string `json:"state"` |
| +} |
diff --git a/internal/runs/runs_test.go b/internal/runs/runs_test.go| new file mode 100644 |
| index 000000000..f30acfafc |
| --- /dev/null |
| +++ b/internal/runs/runs_test.go |
| @@ -0,0 +1,220 @@ |
| +package runs |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/rand" |
| + "errors" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + "testing/iotest" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestIDsAndKeys(t *testing.T) { |
| + id, err := NewID(rand.Reader) |
| + if err != nil || !regexp.MustCompile(`^run_[a-z2-7]{26}$`).MatchString(id) || !ValidID(id) { |
| + t.Fatalf("id %q, %v", id, err) |
| + } |
| + for _, bad := range []string{"", "run_", "run_ABCDEFGHIJKLMNOPQRSTUVWXYZ", "run_abcdefghijklmnopqrstuvwxy/", "mv_abcdefghijklmnopqrstuvwxyz"} { |
| + if ValidID(bad) { |
| + t.Errorf("%q accepted", bad) |
| + } |
| + } |
| + if got := Prefix("bab", "titogo", id); got != "apps/bab/titogo/runs/"+id+"/" { |
| + t.Errorf("Prefix = %q", got) |
| + } |
| + k := ChunkKey("bab", "titogo", id, 17, StreamErr) |
| + if k != OutPrefix("bab", "titogo", id)+"00000017-e" { |
| + t.Errorf("ChunkKey = %q", k) |
| + } |
| + seq, stream, ok := ParseChunkKey(k, OutPrefix("bab", "titogo", id)) |
| + if !ok || seq != 17 || stream != StreamErr { |
| + t.Errorf("ParseChunkKey = %d %q %v", seq, stream, ok) |
| + } |
| + for _, bad := range []string{OutPrefix("bab", "titogo", id) + "00000017-x", OutPrefix("bab", "titogo", id) + "17-o", "elsewhere"} { |
| + if _, _, ok := ParseChunkKey(bad, OutPrefix("bab", "titogo", id)); ok { |
| + t.Errorf("ParseChunkKey(%q) accepted", bad) |
| + } |
| + } |
| +} |
| + |
| +func TestCheckArgvAndTimeout(t *testing.T) { |
| + cases := []struct { |
| + argv []string |
| + want error |
| + }{ |
| + {nil, ErrNoArgs}, |
| + {make([]string, 257), ErrTooManyArgs}, |
| + {[]string{strings.Repeat("a", 64<<10+1)}, ErrArgvTooLarge}, |
| + {[]string{strings.Repeat("a", 64<<10-1), "b"}, nil}, // "at most 64 KB in total": the limit itself passes |
| + {[]string{strings.Repeat("a", 32<<10), strings.Repeat("b", 32<<10+1)}, ErrArgvTooLarge}, |
| + {[]string{"a\x00b"}, ErrArgNUL}, |
| + {[]string{"import", "--dry-run"}, nil}, |
| + } |
| + for i := range cases[1].argv { |
| + cases[1].argv[i] = "x" |
| + } |
| + for _, c := range cases { |
| + if err := CheckArgv(c.argv); !errors.Is(err, c.want) && !(c.want == nil && err == nil) { |
| + t.Errorf("CheckArgv(%d args) = %v, want %v", len(c.argv), err, c.want) |
| + } |
| + } |
| + for d, ok := range map[time.Duration]bool{0: false, 999 * time.Millisecond: false, time.Second: true, 6 * time.Hour: true, 6*time.Hour + time.Second: false} { |
| + if (CheckTimeout(d) == nil) != ok { |
| + t.Errorf("CheckTimeout(%s) = %v", d, CheckTimeout(d)) |
| + } |
| + } |
| +} |
| + |
| +func TestCheckFiles(t *testing.T) { |
| + if err := CheckFiles([]MetaFile{{"a.csv", 1}, {"b.json", 2}}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := CheckFiles([]MetaFile{{"a", MaxFileBytes}, {"b", 1}}); !errors.Is(err, ErrFilesTooLarge) { |
| + t.Errorf("over the cap: %v", err) |
| + } |
| + for _, name := range []string{"", ".", "..", "a/b", "../x", "a\x00b", "a\nb", strings.Repeat("n", 256)} { |
| + var fe *FileNameError |
| + if err := CheckFiles([]MetaFile{{name, 1}}); !errors.Is(err, ErrFileName) || !errors.As(err, &fe) || fe.Name != name { |
| + t.Errorf("name %q: %v", name, err) |
| + } |
| + } |
| + if err := CheckFiles([]MetaFile{{"a", 1}, {"a", 1}}); !errors.Is(err, ErrFileTwice) { |
| + t.Errorf("twice: %v", err) |
| + } |
| + if err := CheckFiles([]MetaFile{{"a", -1}}); !errors.Is(err, ErrFilesTooLarge) { |
| + t.Errorf("a negative size must be refused: %v", err) |
| + } |
| +} |
| + |
| +func TestStoreHelpers(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + id := "run_abcdefghijklmnopqrstuvwxyz" |
| + if r, err := ReadResult(ctx, s, "bab", "titogo", id); r != nil || err != nil { |
| + t.Fatalf("absent result = %v, %v", r, err) |
| + } |
| + for seq := int64(1); seq <= 5; seq++ { |
| + if _, err := s.Put(ctx, ChunkKey("bab", "titogo", id, seq, StreamOut), []byte("x"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + keys, err := ListChunks(ctx, s, "bab", "titogo", id, 2, 2) |
| + if err != nil || len(keys) != 2 || !strings.HasSuffix(keys[0], "00000003-o") { |
| + t.Fatalf("ListChunks = %q, %v", keys, err) |
| + } |
| + if last, err := LastChunkSeq(ctx, s, "bab", "titogo", id); err != nil || last != 5 { |
| + t.Fatalf("LastChunkSeq = %d, %v", last, err) |
| + } |
| + first := Result{Exit: 3, LastSeq: 5} |
| + if err := WriteResult(ctx, s, "bab", "titogo", id, first); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := WriteResult(ctx, s, "bab", "titogo", id, Result{Interrupted: InterruptedLost}); err != nil { |
| + t.Fatalf("a second result is not an error: %v", err) |
| + } |
| + if r, err := ReadResult(ctx, s, "bab", "titogo", id); err != nil || r.Exit != 3 || r.Interrupted != "" { |
| + t.Fatalf("the first result must win: %+v, %v", r, err) |
| + } |
| + if err := DeleteRun(ctx, s, "bab", "titogo", id); err != nil { |
| + t.Fatal(err) |
| + } |
| + if left, _ := s.List(ctx, Prefix("bab", "titogo", id)); len(left) != 0 { |
| + t.Fatalf("DeleteRun left %q", left) |
| + } |
| +} |
| + |
| +// A run id becomes one path element of an object key and of a directory on |
| +// the box, so ValidID must refuse anything that could be more or less. |
| +func TestValidIDIsOneCleanPathElement(t *testing.T) { |
| + good := "run_abcdefghijklmnopqrstuvwxyz" |
| + if !ValidID(good) { |
| + t.Fatalf("%q refused", good) |
| + } |
| + for _, bad := range []string{ |
| + "", ".", "..", "run_", "/", "run_/", "../" + good, good + "/", good + "/..", good + "\n", |
| + "\n" + good, good + "\x00", good + " ", "run_abcdefghijklmnopqrstuvwxy", good + "a", |
| + "run_abcdefghijklmnopqrstuvwx\\z", "run_abcdefghijklmnopqrstuvwx.z", "run_abcdefghijklmnopqrstuvwxé", |
| + "RUN_abcdefghijklmnopqrstuvwxyz", "run_abcdefghijklmnopqrstuvwx01", |
| + } { |
| + if ValidID(bad) { |
| + t.Errorf("ValidID(%q) accepted", bad) |
| + } |
| + } |
| +} |
| + |
| +func TestNewIDDrawsFromTheReader(t *testing.T) { |
| + // 16 bytes in, 128 bits of the id out: a different draw is a different id. |
| + a, err := NewID(bytes.NewReader(bytes.Repeat([]byte{0x00}, 16))) |
| + if err != nil || a != "run_"+strings.Repeat("a", 26) { |
| + t.Fatalf("zero draw = %q, %v", a, err) |
| + } |
| + b, err := NewID(bytes.NewReader(append(bytes.Repeat([]byte{0x00}, 15), 0x01))) |
| + if err != nil || b == a || !ValidID(b) { |
| + t.Fatalf("last bit draw = %q, %v", b, err) |
| + } |
| + if _, err := NewID(bytes.NewReader(make([]byte, 15))); err == nil { |
| + t.Error("a short read minted an id") |
| + } |
| + if _, err := NewID(iotest.ErrReader(errors.New("no entropy"))); err == nil { |
| + t.Error("a failing reader minted an id") |
| + } |
| +} |
| + |
| +func TestMintIDIsRandomAndValid(t *testing.T) { |
| + seen := map[string]bool{} |
| + for i := 0; i < 2000; i++ { |
| + id, err := MintID() |
| + if err != nil || !ValidID(id) { |
| + t.Fatalf("MintID = %q, %v", id, err) |
| + } |
| + if seen[id] { |
| + t.Fatalf("MintID repeated %q", id) |
| + } |
| + seen[id] = true |
| + } |
| +} |
| + |
| +func TestParseChunkKeyIsCanonicalOnly(t *testing.T) { |
| + out := OutPrefix("bab", "titogo", "run_abcdefghijklmnopqrstuvwxyz") |
| + for _, name := range []string{"+0000017-o", "-0000017-o", "0000000-o", "00000000-o", " 0000017-o", "0x000017-o", "0000017 -o"} { |
| + if _, _, ok := ParseChunkKey(out+name, out); ok { |
| + t.Errorf("ParseChunkKey(%q) accepted", name) |
| + } |
| + } |
| +} |
| + |
| +func TestStoreHelpersRefuseAnIdThatIsNotOne(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, err := s.Put(ctx, "apps/bab/titogo/runs/keep", []byte("x"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, id := range []string{"", "..", "../x", "run_x/../..", "run_abcdefghijklmnopqrstuvwxyz/"} { |
| + if _, err := ReadRequest(ctx, s, "bab", "titogo", id); !errors.Is(err, ErrBadID) { |
| + t.Errorf("ReadRequest(%q) = %v", id, err) |
| + } |
| + if _, err := ReadResult(ctx, s, "bab", "titogo", id); !errors.Is(err, ErrBadID) { |
| + t.Errorf("ReadResult(%q) = %v", id, err) |
| + } |
| + if err := WriteResult(ctx, s, "bab", "titogo", id, Result{}); !errors.Is(err, ErrBadID) { |
| + t.Errorf("WriteResult(%q) = %v", id, err) |
| + } |
| + if _, err := ListChunks(ctx, s, "bab", "titogo", id, 0, 0); !errors.Is(err, ErrBadID) { |
| + t.Errorf("ListChunks(%q) = %v", id, err) |
| + } |
| + if _, err := LastChunkSeq(ctx, s, "bab", "titogo", id); !errors.Is(err, ErrBadID) { |
| + t.Errorf("LastChunkSeq(%q) = %v", id, err) |
| + } |
| + if err := DeleteRun(ctx, s, "bab", "titogo", id); !errors.Is(err, ErrBadID) { |
| + t.Errorf("DeleteRun(%q) = %v", id, err) |
| + } |
| + } |
| + if left, _ := s.List(ctx, "apps/bab/titogo/runs/"); len(left) != 1 { |
| + t.Fatalf("a refused delete touched the store: %q", left) |
| + } |
| +} |
diff --git a/internal/runs/store.go b/internal/runs/store.go| new file mode 100644 |
| index 000000000..22d727f1c |
| --- /dev/null |
| +++ b/internal/runs/store.go |
| @@ -0,0 +1,133 @@ |
| +package runs |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "sort" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Every helper refuses an id that is not a run id (ErrBadID) before it builds |
| +// a key, so no caller can reach another run's objects, or a whole app's runs, |
| +// through a crafted one. |
| + |
| +// ReadRequest reads request.json; absent is os.ErrNotExist. |
| +func ReadRequest(ctx context.Context, s release.Store, acct, app, id string) (*Request, error) { |
| + if !ValidID(id) { |
| + return nil, ErrBadID |
| + } |
| + raw, _, err := s.Get(ctx, RequestKey(acct, app, id)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var r Request |
| + if err := json.Unmarshal(raw, &r); err != nil { |
| + return nil, err |
| + } |
| + return &r, nil |
| +} |
| + |
| +// ReadResult reads result.json; absent is (nil, nil). |
| +func ReadResult(ctx context.Context, s release.Store, acct, app, id string) (*Result, error) { |
| + if !ValidID(id) { |
| + return nil, ErrBadID |
| + } |
| + raw, _, err := s.Get(ctx, ResultKey(acct, app, id)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + var r Result |
| + if err := json.Unmarshal(raw, &r); err != nil { |
| + return nil, err |
| + } |
| + return &r, nil |
| +} |
| + |
| +// WriteResult writes result.json create-only: the first result wins, and a |
| +// later writer (the sweep's lost result racing the box's) changes nothing. |
| +func WriteResult(ctx context.Context, s release.Store, acct, app, id string, r Result) error { |
| + if !ValidID(id) { |
| + return ErrBadID |
| + } |
| + body, err := json.Marshal(r) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := s.Put(ctx, ResultKey(acct, app, id), body, ""); err != nil && !errors.Is(err, release.ErrCASConflict) { |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +// ListChunks lists chunk keys after seq, sorted, at most limit (0: all). |
| +func ListChunks(ctx context.Context, s release.Store, acct, app, id string, after int64, limit int) ([]string, error) { |
| + if !ValidID(id) { |
| + return nil, ErrBadID |
| + } |
| + prefix := OutPrefix(acct, app, id) |
| + startAfter := prefix |
| + if after > 0 { |
| + startAfter = ChunkKey(acct, app, id, after, "~") // "~" sorts after both streams of seq |
| + } |
| + if pl, ok := s.(release.PageLister); ok { |
| + return pl.ListPage(ctx, prefix, startAfter, limit) |
| + } |
| + keys, err := s.List(ctx, prefix) |
| + if err != nil { |
| + return nil, err |
| + } |
| + sort.Strings(keys) |
| + var out []string |
| + for _, k := range keys { |
| + if k <= startAfter { |
| + continue |
| + } |
| + out = append(out, k) |
| + if limit > 0 && len(out) == limit { |
| + break |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// LastChunkSeq is the highest seq written, 0 when none. |
| +func LastChunkSeq(ctx context.Context, s release.Store, acct, app, id string) (int64, error) { |
| + if !ValidID(id) { |
| + return 0, ErrBadID |
| + } |
| + keys, err := ListChunks(ctx, s, acct, app, id, 0, 0) |
| + if err != nil { |
| + return 0, err |
| + } |
| + var last int64 |
| + for _, k := range keys { |
| + if seq, _, ok := ParseChunkKey(k, OutPrefix(acct, app, id)); ok && seq > last { |
| + last = seq |
| + } |
| + } |
| + return last, nil |
| +} |
| + |
| +// DeleteRun deletes every object of the run. |
| +func DeleteRun(ctx context.Context, s release.Store, acct, app, id string) error { |
| + if !ValidID(id) { |
| + return ErrBadID |
| + } |
| + keys, err := s.List(ctx, Prefix(acct, app, id)) |
| + if err != nil { |
| + return err |
| + } |
| + var errs []error |
| + for _, k := range keys { |
| + if err := s.Delete(ctx, k); err != nil { |
| + errs = append(errs, err) |
| + } |
| + } |
| + return errors.Join(errs...) |
| +} |