diff --git a/docs/architecture.md b/docs/architecture.md index dd89df12..5c13559c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,6 +62,11 @@ that has no serialization or compatibility meaning. An exception at a public boundary must be explicit and documented; convenience alone is not an exception. +The local operator container-stop receipt has a separate, narrowly scoped +[Docker evidence exception](container-stop.md#contract-exception-and-lifecycle), +including its fixed shape, fresh-comparison lifecycle and refusal on incompatible +evidence. It does not extend the process-group registry exception to other state. + Per-consumer module update evidence and verdicts use `codefly.update.v0`. See [module updates](module-updates.md) for the offline comparison, authenticated release artifacts, usage completeness requirements, and delivery ownership. diff --git a/docs/container-stop.md b/docs/container-stop.md new file mode 100644 index 00000000..9001baaa --- /dev/null +++ b/docs/container-stop.md @@ -0,0 +1,127 @@ +# Explicit container stop with retained data + +`runners/dockerrun` supplies a bounded operator-selected stop operation, separate +from workspace adoption, process-group recovery, ledger reconciliation and +container destruction. CLI owns target selection and operator authorization. +Core owns inspection, expectation comparison, delivery and observed disposition. +Scope/name labels are comparison evidence, not permission to infer a parent +service. A receipt grants no authority by itself. + +## API and receipt + +`PreviewContainerStop(ctx, fullID)` accepts only a complete lowercase 64-hex +Docker ID. It never resolves a name or a shortened ID. The inspection must name +that exact ID, include Created, Config, HostConfig and State, have +`codefly.owner=true`, and agree between the actual container name and +`codefly.name`. **AutoRemove is refused before stopping**: Docker could otherwise +delete the object on exit. Missing HostConfig also refuses. + +The returned `ContainerStopExpectation` contains ID, Created, Name, every label, +and every field of Docker's `container.MountPoint` records. The producer sorts +mounts by their complete JSON representation and emits an explicit empty `[]` +for an unmounted container. These are custody facts, not a hash of stored data +or an invented ledger ownership claim. No environment variables or secret +configuration bodies are included. Existing labels and mount paths are included +verbatim, so receipts remain operator evidence and should be retained privately. + +Persist the receipt with the default `json.Encoder` or `json.Marshal`, and decode +with `ParseContainerStopExpectation`. It refuses unknown fields, duplicate JSON +keys, nulls, trailing documents and missing identity/owner/name/mount evidence. +Receipt fields use their exact lowercase schema spelling; nested MountPoint +fields use Docker's exact spelling (for example `RW`). Case aliases refuse, +including when placed before or after a correctly spelled field. Arbitrary +label keys remain case-sensitive data. Legal JSON escapes and whitespace remain +accepted; byte-canonical JSON is not required. +Missing or null mounts are not an empty mount list. The owner constant +`MaxContainerStopExpectationBytes` bounds the whole receipt to 64 KiB, including +the default Encoder newline. Preview, parser and direct execution validate the +same canonical-output budget; oversized inspections refuse instead of emitting +unusable receipts. CLI uses that constant and parser, not its own schema. + +## Contract exception and lifecycle + +Core explicitly chooses a narrow exception to the default protobuf contract rule +in [architecture](architecture.md#contract-source-of-truth) for +`ContainerStopExpectation` and its `ContainerStopResult` disposition. This is a +local operator inspection/compare/stop receipt, including when the CLI saves it +to a file. It is not covered implicitly by the process-group registry exception. + +The evidence owner is Docker: Core reuses the existing Docker inspection identity, +label map and complete native `container.MountPoint` values, then compares them +against a new inspection of the same immutable ID. Keeping those native mount +values avoids a second translated mount model that could omit custody fields +while still claiming complete comparison. The exception is bounded to this +operation and these types; it does not authorize JSON agent contracts, remote +recovery APIs, a new registry, or a general evidence format. Core owns the one +strict parser; consumers import it and never reproduce the schema. + +The current unversioned receipt shape is fixed, not an extensible protocol. +Preview and execution use the same reviewed Core implementation, and the saved +receipt has only the lifetime of that operator-selected attempt and unchanged +object. It is untrusted comparison input, never a reusable authorization grant. +Execution always obtains current evidence independently; an unchanged stopped +object may be rechecked, but changed or missing evidence requires a fresh preview +and operator selection. An archived result records only the observed disposition, +not continuing stopped state or permission for later effects. + +There is no cross-release migration or compatibility fallback. Unknown fields, +case aliases, duplicates, missing required fields and nulls refuse rather than +being discarded or filled in. After changing the executing implementation, take +a fresh preview rather than relying on an older saved receipt. Any future change +to this shape or its evidence meaning must use a separately versioned contract +and entrypoint under the protobuf default, unless a new explicit owner exception +is reviewed. Do not add optional fields or silently reinterpret this shape. +Docker dependency changes affecting `MountPoint` must requalify complete field +comparison and producer/parser roundtrip; they cannot inherit this exception as +permission to accept an incomplete projection. This lifecycle policy is a caller +requirement: the receipt carries no binary identity or version discriminator, +and Core does not claim to detect which release wrote identical receipt bytes. + +## Conditional stop and disposition + +`StopContainer(ctx, expected)` independently inspects the same full ID, refuses +AutoRemove or missing evidence, and compares every expected field before +calling the existing Docker `ContainerStop` on that ID. Mount order is not +significant; every mount field and label is. The stop has a three-second Docker +grace period and an overall fifteen-second context bound. It never resolves the +name again, claims a session ledger resource, changes labels, removes an object +or volume, starts a container, or falls back to another operation. + +Readback must find the same ID and Created, unchanged labels/name/mounts, and a +non-running, non-restarting, non-paused state. `ContainerStopResult` reports +`Stopped`, `Retained` and `MountsUnchanged`; success requires a nil error and all +three true. A repeat for the same unchanged stopped object succeeds without +another stop. A missing object is an error, not proof it was retained. A +same-name replacement is never selected by the old ID. + +Cancellation, timeout or a lost reply may follow a successful Docker effect. +Such errors do not claim that nothing happened. Readback failure returns an +error and only postconditions actually observed. Docker supplies no atomic +compare-and-stop transaction: the full immutable ID prevents a replacement from +being stopped, but an external actor can rename or restart the selected object +between observations. Changed custody in readback refuses success. This API +does not promise the object stays stopped after the observation. + +The intended CLI surface is `recover container FULL_ID --preview` and +`recover container FULL_ID --expect FILE`. Execution checks that the positional +ID equals the receipt ID and delegates to Core. It reports the owner result and +requires all postconditions for exit zero. Ordinary `stop`/`clear` remain +unchanged. Operational use requires a reviewed binary and fresh exact receipt; +the API does not authorize a target through its name or scope. + +## Qualification and limits + +Maintained Docker tests use only their own created IDs. They prove refusal of +changed ID/creation/name/scope/namespace/mount evidence, AutoRemove refusal while +the container stays running, preserved writable-layer and named-volume content, +same stopped-generation retry, survival of an authenticated sibling and an +unattributed control, and refusal to reach a same-name replacement. Transport +tests cover explicit empty mounts, bounds including Encoder newline, malformed +receipts and unavailable inspection fields. + +Stopping an old container does not add a parent-service label. The producer's +later exact-name reuse is a separate operation: its configuration fingerprint +may cause recreation, and identical reuse leaves legacy labels unchanged. +Qualify the corrected producer's retained-resource/data behavior independently +before claiming migration or workspace adoption. A matching mount receipt alone +does not prove application data contents or consistency. diff --git a/docs/session-ledger.md b/docs/session-ledger.md index ae9893cc..fee78c5d 100644 --- a/docs/session-ledger.md +++ b/docs/session-ledger.md @@ -316,8 +316,8 @@ protobuf contract rule in [architecture](architecture.md#contract-source-of-trut Core retains the existing strict JSON storage contract so it can recover records from independently released owners without rewriting their birth evidence. The versioned namespace isolates incompatible readers. Consumers use the Core -ownership API instead of implementing a JSON decoder; this exception does not -introduce another public serialization surface or relax the protobuf default. +ownership API instead of implementing a JSON decoder; the explicit recovery receipt described below is its only selection transport, +and the exception does not relax the protobuf default elsewhere. The v1 wire format is unchanged. Older strict readers ignore the v2 directory; they cannot recover its groups and must upgrade before claiming scoped cleanup @@ -334,8 +334,70 @@ identity nor signals the unverified process. Inspection, stale recovery and explicit signals share this refusal; an actual different start identity retains the existing reused-process handling. +## Explicit orphan-generation recovery + +`runners/base.PreviewProcessGroupRecovery(ctx, pgid)` is a separate, read-only +admission path for an explicitly operator-selected orphan registration. It is +not workspace-scoped stop, startup reaping, or permission to include unknown +workspaces. CLI owns selection, authorization and reporting; Core owns decoding, +registration identity, birth authentication, delivery and conditional removal. +The operation never assigns a workspace or rewrites stored boot evidence. + +The preview returns `ProcessGroupRecoveryExpectation`: namespace, SHA-256 of the +exact registration bytes, PGID, recorded leader and owner identities, and the +currently authenticated member identities. Recorded identities retain their +original boot representation; the owner's PGID is zero because it is not stored. +Members carry current boot/start identities for kernel-bound signal delivery. +The receipt is untrusted input, not a credential. Preserve it with `json.Marshal` +and read it with Core's `ParseProcessGroupRecoveryExpectation`, which refuses +unknown fields, duplicate JSON keys, nulls, trailing values and missing birth +proof. `MaxProcessGroupRecoveryExpectationBytes` limits a whole receipt to +1 MiB, independently of the unchanged 16 KiB registration cap. Preview reserves +one byte beyond compact `json.Marshal` output for the newline emitted by the +CLI's default `json.Encoder.Encode`; unsupported previews fail instead of emitting +an unusable receipt. Parsing enforces both this canonical-output budget and the +inclusive input-byte limit, so extra formatting also counts toward the limit. +Consumers use the exported owner limit when bounding file reads. This bounded +local JSON receipt extends the registry exception above; +it is not a new agent protocol or a parallel registry. Core reuses its existing +`internal/wire` document checks and JSON decoding, not a consumer parser. + +`RecoverProcessGroup(ctx, expected)` locks both supported namespaces in a fixed +order and independently authenticates the current registration. Missing or +changed registration bytes, namespace ambiguity, changed expected births, +incomplete proof, a live original owner, or uncertain inspection refuse before +signals. The full initial member set must still match at admission; take a fresh +preview if it changed. Each subsequent signal sweep rechecks the registration, +owner absence and group authentication. Existing escalation can reach surviving +same-generation descendants; this is not a frozen list of PIDs or authority over +a replacement group. PID, boot and start evidence remains required. + +Only this explicit path accepts Darwin's historical `sec:usec` boot format, and +only by exact equality to the kernel's current `kern.boottime`. A changed clock +value refuses; no UUID conversion, approximate match, producer-version roster, +or weakening of ordinary incompatible-boot refusal is permitted. Current +kernel-bound delivery is reused unchanged: Darwin acquires PIDVersion before +checking birth on every attempt, preserving safety across PID reuse and exec. +The old reader's signal implementation is not used. + +Both namespace locks remain held through termination and conditional removal. +The private file snapshot and expected bytes are rechecked before effects and +removal; a replacement record is retained. Cooperating writers use these registry +locks; this does not claim atomicity against a same-user actor deliberately +bypassing the filesystem protocol. The result reports proven `GroupDead` and +`RecordRemoved` postconditions. Success requires a nil error and both true. +An error may follow partial termination; false means that postcondition was not +established, not proof that the process is alive. A replacement or uncertain +record is never reported removed. A nil `RemoveIfDeadContext` alone is not used +as evidence of successful recovery. This operation deletes no container or data. + ## Startup container recovery scope +Explicit operator-selected stop without ledger adoption is documented in +[container-stop.md](container-stop.md). It compares fresh custody evidence and +stops a full immutable Docker ID while retaining the object and mounts. It does +not fabricate an invocation label or modify ledger ownership. + The legacy PID-based startup sweep requires a `codefly.recovery-scope` label before checking owner liveness. The label hashes canonical Codefly home, workspace path and resolved naming scope. A different home, workspace or scope diff --git a/runners/base/owned_group_test.go b/runners/base/owned_group_test.go index 6a494d39..3dd049ca 100644 --- a/runners/base/owned_group_test.go +++ b/runners/base/owned_group_test.go @@ -466,6 +466,11 @@ func TestAuthenticateOwnedProcessGroupLeaderlessDecision(t *testing.T) { // executable the one under test: until the exec chain settles the member is // still sh or env, whose environment darwin withholds, and every case would // take the unobservable branch no matter what the descendant carries. +// The platform binary has no ready-file protocol. Wait for its executable and, +// on Linux, its inherited credential before testing the leaderless decision. +// /proc can expose sleep's executable while environ is still empty during exec. +// This is fixture readiness, not a retry of the authentication under test; a +// missing credential fails setup. Darwin deliberately withholds that evidence. func startLeaderlessOwnedGroup(t *testing.T, script string, usesHelper bool) *OwnedProcessGroup { t.Helper() cmd := exec.Command("sh", "-c", script) @@ -495,10 +500,19 @@ func startLeaderlessOwnedGroup(t *testing.T, script string, usesHelper bool) *Ow if member.pid == group.PGID() { return false } + if !usesHelper && filepath.Base(member.executable) != "sleep" { + return false + } + if !usesHelper && runtime.GOOS == "linux" { + value, observed, err := readProcessGroupAuthentication(member.pid) + if err != nil || !observed || value != group.record.Authentication { + return false + } + } } return true }) { - t.Skip("could not observe a leaderless group holding the pgid") + t.Fatal("could not observe a ready leaderless group holding the pgid") } return group } diff --git a/runners/base/pgid.go b/runners/base/pgid.go index 6ae8d4ab..20571337 100644 --- a/runners/base/pgid.go +++ b/runners/base/pgid.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "crypto/rand" + "crypto/sha256" "encoding/hex" "encoding/json" "errors" @@ -156,7 +157,8 @@ type pgidRecordV2 struct { } type recordSnapshot struct { - info os.FileInfo + info os.FileInfo + digest string } // TrackedProcessGroup is the identity-bearing handle returned for a process @@ -764,7 +766,7 @@ func readPgidRecord(path string) (pgidRecord, recordSnapshot, error) { if !sameRecordFile(before, after) || !sameRecordFile(after, current) { return pgidRecord{}, recordSnapshot{}, errors.New("record changed while being read") } - snapshot := recordSnapshot{info: current} + snapshot := recordSnapshot{info: current, digest: fmt.Sprintf("%x", sha256.Sum256(data))} if len(data) > maxRecordSize { return pgidRecord{}, snapshot, errors.New("record is too large") } diff --git a/runners/base/pgid_decision_test.go b/runners/base/pgid_decision_test.go index f3624e66..c90930f4 100644 --- a/runners/base/pgid_decision_test.go +++ b/runners/base/pgid_decision_test.go @@ -39,11 +39,17 @@ func TestProcessGroupRegistryHelper(t *testing.T) { defer signal.Stop(stopping) writeTestFile(t, os.Getenv(processGroupReadyFileEnv), "ready") <-stopping + case "recovery-replace-on-term": + stopping := armTerminationSignal() + defer signal.Stop(stopping) + writeTestFile(t, os.Getenv(processGroupReadyFileEnv), "ready") + <-stopping + rewriteRecord(t, recordPath(t, os.Getpid()), func(rec *pgidRecord) { rec.Authentication = strings.Repeat("0", groupAuthBytes*2) }) case "ignores-term": signal.Ignore(syscall.SIGTERM) writeTestFile(t, os.Getenv(processGroupReadyFileEnv), "ready") select {} - case "owner", "stubborn-owner", "live-owner", "workspace-owner": + case "owner", "stubborn-owner", "live-owner", "workspace-owner", "recovery-replacement-owner": var stopping chan os.Signal if os.Getenv(processGroupRoleEnv) == "live-owner" { stopping = armTerminationSignal() @@ -53,6 +59,9 @@ func TestProcessGroupRegistryHelper(t *testing.T) { if os.Getenv(processGroupRoleEnv) == "stubborn-owner" { leaderRole = "ignores-term" } + if os.Getenv(processGroupRoleEnv) == "recovery-replacement-owner" { + leaderRole = "recovery-replace-on-term" + } leader := registryHelperCommand(leaderRole) leader.Env = append(leader.Env, processGroupReadyFileEnv+"="+os.Getenv(processGroupReadyFileEnv)) if os.Getenv(processGroupRoleEnv) == "workspace-owner" { diff --git a/runners/base/recovery.go b/runners/base/recovery.go new file mode 100644 index 00000000..8d8cee27 --- /dev/null +++ b/runners/base/recovery.go @@ -0,0 +1,376 @@ +package base + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/codefly-dev/core/internal/wire" + "github.com/gofrs/flock" + "gopkg.in/yaml.v3" +) + +// MaxProcessGroupRecoveryExpectationBytes bounds a complete serialized recovery +// receipt, including the newline emitted by json.Encoder.Encode. One MiB allows +// thousands of ordinary member identities without borrowing the much smaller +// registry-record limit. Consumers may use this bound when reading a receipt. +const MaxProcessGroupRecoveryExpectationBytes = 1 << 20 + +// ProcessGroupRecoveryExpectation is an untrusted, owner-produced selection +// receipt. Persist the complete receipt from PreviewProcessGroupRecovery; +// callers must not construct it from registry internals. It grants no authority +// by itself. Every field is checked again before recovery begins. +type ProcessGroupRecoveryExpectation struct { + Namespace string `json:"namespace"` + RecordSHA256 string `json:"record_sha256"` + PGID int `json:"pgid"` + Leader ProcessIdentity `json:"leader"` + Owner ProcessIdentity `json:"owner"` + Members []ProcessIdentity `json:"members"` +} + +// ParseProcessGroupRecoveryExpectation strictly decodes a saved preview receipt. +// Unknown fields, trailing values and incomplete member evidence are refused. +// Parsing is not authentication; RecoverProcessGroup revalidates every fact. +func ParseProcessGroupRecoveryExpectation(data []byte) (ProcessGroupRecoveryExpectation, error) { + var expected ProcessGroupRecoveryExpectation + if len(data) > MaxProcessGroupRecoveryExpectationBytes { + return expected, errors.New("recovery expectation is too large") + } + // Reuse Core's document checks for duplicate keys/nulls; JSON syntax and + // typed field admission remain encoding/json's responsibility. + var document yaml.Node + if err := yaml.Unmarshal(data, &document); err != nil { + return expected, err + } + if defect := wire.Check(&document); defect.Kind != wire.NoDefect { + return expected, fmt.Errorf("invalid recovery evidence at %s: %v (%s)", defect.Path, defect.Kind, defect.Detail) + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&expected); err != nil { + return expected, err + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return expected, errors.New("recovery expectation has trailing data") + } + if err := expected.validate(); err != nil { + return expected, err + } + return expected, nil +} + +// ProcessGroupRecoveryResult reports observed postconditions, including on +// error. Success requires both fields true. No workspace attribution is inferred. +type ProcessGroupRecoveryResult struct { + GroupDead bool `json:"group_dead"` + RecordRemoved bool `json:"record_removed"` +} + +// PreviewProcessGroupRecovery authenticates one explicitly selected orphan +// registration without signaling or modifying it. Unlike ordinary inspection, +// this operation supports Darwin's historical sec:usec boot representation, +// only when it still equals the running kernel's value. Recorded identities +// remain unchanged; Members carry current identities for safe signal delivery. +func PreviewProcessGroupRecovery(ctx context.Context, pgid int) (expected *ProcessGroupRecoveryExpectation, err error) { + unlock, err := lockRecoveryRegistries(ctx) + if err != nil { + return nil, err + } + defer func() { err = errors.Join(err, unlock()) }() + group, snapshot, err := readRecoveryRegistration(pgid) + if err != nil { + return nil, err + } + members, ok, err := authenticateRecoveryGroup(ctx, group.record) + if err != nil { + return nil, err + } + if !ok || len(members) == 0 { + return nil, errors.New("recovery requires an authenticated live generation") + } + expected, err = recoveryPreviewExpectation(group, snapshot, members) + if err != nil { + return nil, err + } + // A noncooperating writer must not change the receipt during observation. + if _, _, err = readExpectedRecoveryRegistration(*expected, snapshot); err != nil { + return nil, err + } + return expected, nil +} + +// RecoverProcessGroup conditionally terminates one selected orphan generation. +// The original owner must remain absent and the registration must remain exact. +// Initial expected members must still have their observed births at admission; +// later sweeps may reach authenticated descendants of that same registration. +// It uses current kernel-bound delivery, never legacy PID-only signaling. +// Failed admission has no effects. Partial termination is returned as an error; +// a replacement registration is preserved and never reported removed. +func RecoverProcessGroup(ctx context.Context, expected ProcessGroupRecoveryExpectation) (result *ProcessGroupRecoveryResult, err error) { + result = &ProcessGroupRecoveryResult{} + if err := expected.validate(); err != nil { + return result, err + } + unlock, err := lockRecoveryRegistries(ctx) + if err != nil { + return result, err + } + defer func() { err = errors.Join(err, unlock()) }() + group, snapshot, err := readExpectedRecoveryRegistration(expected, recordSnapshot{}) + if err != nil { + return result, err + } + members, ok, err := authenticateRecoveryGroup(ctx, group.record) + if err != nil { + return result, err + } + if !ok || len(members) == 0 { + return result, errors.New("recovery requires an authenticated live generation") + } + if err = validateRecoveryMembers(expected.Members, members); err != nil { + return result, err + } + // Pin the exact file as well as its bytes. Check before every signal sweep, + // including the first; both namespace locks cover admission through removal. + firstSweep := true + authenticate := func(ctx context.Context, rec pgidRecord) ([]processIdentity, bool, error) { + if _, _, checkErr := readExpectedRecoveryRegistration(expected, snapshot); checkErr != nil { + return nil, false, errors.Join(errProcessGroupIdentityChanged, checkErr) + } + found, authenticated, checkErr := authenticateRecoveryGroup(ctx, rec) + if checkErr != nil { + return nil, false, errors.Join(errProcessGroupIdentityChanged, checkErr) + } + if firstSweep { + if checkErr = validateRecoveryMembers(expected.Members, found); checkErr != nil { + return nil, false, errors.Join(errProcessGroupIdentityChanged, checkErr) + } + firstSweep = false + } + if _, _, checkErr = readExpectedRecoveryRegistration(expected, snapshot); checkErr != nil { + return nil, false, errors.Join(errProcessGroupIdentityChanged, checkErr) + } + return found, authenticated, nil + } + err = terminateGroup(ctx, group.record, authenticate, sigtermGrace) + result.GroupDead = !isProcessGroupAlive(expected.PGID) + if err != nil { + return result, err + } + if !result.GroupDead { + return result, errors.New("recovery target remains alive") + } + current, finalSnapshot, err := readExpectedRecoveryRegistration(expected, snapshot) + if err != nil { + return result, err + } + if err = recoveryOwnerAbsent(current.record); err != nil { + return result, err + } + if err = ctx.Err(); err != nil { + return result, err + } + if isProcessGroupAlive(expected.PGID) { + result.GroupDead = false + return result, errors.New("recovery group became live before removal") + } + dir, err := pgidNamespaceDir(expected.Namespace) + if err != nil { + return result, err + } + if err = removeRecord(filepath.Join(dir, fmt.Sprintf("%d.pgid", expected.PGID)), finalSnapshot); err != nil { + return result, err + } + // Prove absence in all supported namespaces, not merely a nil remove result. + _, checkErr := LookupProcessGroup(expected.PGID) + if !errors.Is(checkErr, ErrProcessGroupNotRegistered) { + return result, errors.Join(errors.New("registration remains after recovery"), checkErr) + } + result.RecordRemoved = true + return result, nil +} + +func lockRecoveryRegistries(ctx context.Context) (func() error, error) { + // acquireRegistryLock also holds the package-wide in-process mutex. Take it + // once, then lock the second namespace file in the same fixed order. + currentDir, err := pgidNamespaceDir(pgidRegistryNamespace) + if err != nil { + return nil, err + } + current, err := acquireRegistryLock(ctx, currentDir) + if err != nil { + return nil, err + } + previousDir, err := pgidNamespaceDir(pgidPreviousNamespace) + if err != nil { + return nil, errors.Join(err, releaseRegistryLock(current)) + } + previous := flock.New(filepath.Join(previousDir, pgidLockName), flock.SetPermissions(0600)) + locked, err := previous.TryLockContext(ctx, pgidLockRetry) + if err != nil || !locked { + return nil, errors.Join(errors.New("lock previous recovery namespace"), err, previous.Close(), releaseRegistryLock(current)) + } + return func() error { return errors.Join(previous.Unlock(), previous.Close(), releaseRegistryLock(current)) }, nil +} + +func readRecoveryRegistration(pgid int) (*TrackedProcessGroup, recordSnapshot, error) { + if pgid <= 1 { + return nil, recordSnapshot{}, errors.New("recovery requires a valid process group") + } + group, err := LookupProcessGroup(pgid) + if err != nil { + return nil, recordSnapshot{}, err + } + dir, err := pgidNamespaceDir(group.namespace) + if err != nil { + return nil, recordSnapshot{}, err + } + rec, snapshot, err := readPgidRecord(filepath.Join(dir, fmt.Sprintf("%d.pgid", pgid))) + if err != nil { + return nil, recordSnapshot{}, err + } + if !sameProcessGroupRegistration(rec, group.record) { + return nil, recordSnapshot{}, errors.New("registration changed during recovery lookup") + } + return &TrackedProcessGroup{namespace: group.namespace, record: rec}, snapshot, nil +} + +func recoveryPreviewExpectation(group *TrackedProcessGroup, snapshot recordSnapshot, members []processIdentity) (*ProcessGroupRecoveryExpectation, error) { + expected := recoveryExpectation(group, snapshot, members) + if err := expected.validate(); err != nil { + return nil, err + } + return expected, nil +} + +func recoveryExpectation(group *TrackedProcessGroup, snapshot recordSnapshot, members []processIdentity) *ProcessGroupRecoveryExpectation { + recorded := func(identity recordedProcessIdentity, pgid int) ProcessIdentity { + return ProcessIdentity{PID: identity.PID, PGID: pgid, BootID: identity.BootID, StartID: identity.StartID, Executable: identity.Executable} + } + e := &ProcessGroupRecoveryExpectation{Namespace: group.namespace, RecordSHA256: snapshot.digest, PGID: group.record.PGID, + Leader: recorded(group.record.Leader, group.record.PGID), Owner: recorded(group.record.Owner, 0)} + for _, m := range members { + e.Members = append(e.Members, ProcessIdentity{PID: m.pid, PGID: m.pgid, BootID: m.bootID, StartID: m.startID, Executable: m.executable}) + } + return e +} + +func readExpectedRecoveryRegistration(expected ProcessGroupRecoveryExpectation, original recordSnapshot) (*TrackedProcessGroup, recordSnapshot, error) { + group, snapshot, err := readRecoveryRegistration(expected.PGID) + if err != nil { + return nil, snapshot, err + } + actual := recoveryExpectation(group, snapshot, nil) + if expected.Namespace != actual.Namespace || expected.RecordSHA256 != actual.RecordSHA256 || expected.Leader != actual.Leader || expected.Owner != actual.Owner { + return nil, snapshot, errors.New("recovery registration does not match expected evidence") + } + if original.info != nil && !sameRecordFile(original.info, snapshot.info) { + return nil, snapshot, errors.New("recovery registration file was replaced") + } + return group, snapshot, nil +} + +func validateRecoveryMembers(expected []ProcessIdentity, members []processIdentity) error { + if len(expected) == 0 || len(expected) != len(members) { + return errors.New("recovery requires expected member births") + } + seen := make(map[int]bool) + for _, want := range expected { + if seen[want.PID] { + return errors.New("duplicate expected member") + } + seen[want.PID] = true + found := false + for _, m := range members { + got := ProcessIdentity{PID: m.pid, PGID: m.pgid, BootID: m.bootID, StartID: m.startID, Executable: m.executable} + if got == want { + found = true + break + } + } + if !found { + return fmt.Errorf("expected recovery member %d changed or disappeared", want.PID) + } + } + return nil +} + +// Historical representations are selected by syntax, not producer versions. +func recoveryBootMatches(recorded string, current string) (bool, error) { + if !strings.Contains(recorded, ":") { + return recorded == current, nil + } + return recoveryLegacyBootMatches(recorded) +} + +func recoveryOwnerAbsent(rec pgidRecord) error { + owner, err := inspectProcess(rec.Owner.PID) + if errors.Is(err, errProcessNotFound) { + return nil + } + if err != nil { + return err + } + // Same start with incompatible boot evidence is uncertainty, never absence. + if owner.startID == rec.Owner.StartID { + return errors.New("original recovery owner is live or has incompatible boot evidence") + } + return nil +} + +func authenticateRecoveryGroup(ctx context.Context, rec pgidRecord) ([]processIdentity, bool, error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + if err := recoveryOwnerAbsent(rec); err != nil { + return nil, false, err + } + members, err := inspectProcessGroup(ctx, rec.PGID) + if err != nil { + return nil, false, err + } + if len(members) == 0 { + return nil, false, errProcessGroupNotSignalable + } + matches, err := recoveryBootMatches(rec.Leader.BootID, members[0].bootID) + if err != nil || !matches { + return nil, false, errors.Join(errors.New("recovery boot evidence does not match current boot"), err) + } + for _, member := range members { + if member.pid == rec.Leader.PID { + return members, member.startID == rec.Leader.StartID, nil + } + } + credentialed, _, err := credentialEvidence(members, rec.Authentication) + return members, credentialed, err +} + +func (expected ProcessGroupRecoveryExpectation) validate() error { + // The producer and parser share the same canonical output budget. The CLI's + // default json.Encoder uses Marshal's escaping and appends one newline. + encoded, err := json.Marshal(expected) + if err != nil { + return err + } + if len(encoded)+1 > MaxProcessGroupRecoveryExpectationBytes { + return errors.New("recovery expectation is too large") + } + digest, err := hex.DecodeString(expected.RecordSHA256) + if err != nil || len(digest) != 32 || expected.RecordSHA256 != strings.ToLower(expected.RecordSHA256) || expected.PGID <= 1 || (expected.Namespace != pgidRegistryNamespace && expected.Namespace != pgidPreviousNamespace) || len(expected.Members) == 0 { + return errors.New("recovery expectation is incomplete or malformed") + } + for _, identity := range append([]ProcessIdentity{expected.Leader, expected.Owner}, expected.Members...) { + if identity.PID <= 0 || identity.BootID == "" || identity.StartID == 0 || identity.Executable == "" { + return errors.New("recovery expected birth is incomplete") + } + } + return nil +} diff --git a/runners/base/recovery_darwin.go b/runners/base/recovery_darwin.go new file mode 100644 index 00000000..fd9420e3 --- /dev/null +++ b/runners/base/recovery_darwin.go @@ -0,0 +1,17 @@ +//go:build darwin + +package base + +import ( + "fmt" + "golang.org/x/sys/unix" +) + +func recoveryLegacyBootMatches(recorded string) (bool, error) { + boot, err := unix.SysctlTimeval("kern.boottime") + if err != nil { + return false, err + } + // Exact historical formatting: no normalization or translation to a UUID. + return recorded == fmt.Sprintf("%d:%d", boot.Sec, boot.Usec), nil +} diff --git a/runners/base/recovery_darwin_test.go b/runners/base/recovery_darwin_test.go new file mode 100644 index 00000000..fc768ad5 --- /dev/null +++ b/runners/base/recovery_darwin_test.go @@ -0,0 +1,80 @@ +package base + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func TestRecoveryDarwinLegacyIdentity(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + pid, path, _ := recoveryOrphan(t, false) + rec, _, err := readPgidRecord(path) + require.NoError(t, err) + boot, err := unix.SysctlTimeval("kern.boottime") + require.NoError(t, err) + rec.Leader.BootID = fmt.Sprintf("%d:%d", boot.Sec, boot.Usec) + rec.Owner.BootID = rec.Leader.BootID + dir, err := pgidNamespaceDir(pgidPreviousNamespace) + require.NoError(t, err) + oldPath := filepath.Join(dir, filepath.Base(path)) + raw, err := json.Marshal(rec) + require.NoError(t, err) + require.NoError(t, os.WriteFile(oldPath, raw, 0600)) + require.NoError(t, os.Remove(path)) + group, err := LookupProcessGroup(pid) + require.NoError(t, err) + _, err = group.InspectOwnership(t.Context()) + require.ErrorContains(t, err, "incompatible boot identity") + expected, err := PreviewProcessGroupRecovery(t.Context(), pid) + require.NoError(t, err) + require.Equal(t, rec.Leader.BootID, expected.Leader.BootID) + require.NotEqual(t, expected.Leader.BootID, expected.Members[0].BootID) + after, err := os.ReadFile(oldPath) + require.NoError(t, err) + require.Equal(t, raw, after) + // The explicit path does not normalize a malformed or changed legacy boot. + for _, bad := range []string{rec.Leader.BootID + "0", "0:0", "not-a-boot"} { + changed := rec + changed.Leader.BootID = bad + changed.Owner.BootID = bad + bytes, err := json.Marshal(changed) + require.NoError(t, err) + require.NoError(t, os.WriteFile(oldPath, bytes, 0600)) + _, err = PreviewProcessGroupRecovery(t.Context(), pid) + require.Error(t, err) + assertGroupAlive(t, pid) + } + require.NoError(t, os.WriteFile(oldPath, raw, 0600)) + result, err := RecoverProcessGroup(t.Context(), *expected) + require.NoError(t, err) + require.True(t, result.GroupDead) + require.True(t, result.RecordRemoved) + _, err = os.Stat(oldPath) + require.ErrorIs(t, err, os.ErrNotExist) +} + +// This deterministic source invariant closes the PID-reuse interleaving: +// token(A), birth(B) refuses B; birth(A), token(B) would authorize B's token. +// Real stale-birth and expired-handle tests separately exercise kernel refusal. +func TestDarwinRecoveryDeliveryTokenPrecedesBirthCheck(t *testing.T) { + source, err := os.ReadFile("process_identity_darwin.go") + require.NoError(t, err) + body := string(source[strings.Index(string(source), "func (handle *darwinProcessSignalHandle) Signal("):]) + before := strings.Index(body, "readDarwinProcessUniqueInfo(handle.identity.pid)") + check := strings.Index(body, "inspectProcess(handle.identity.pid)") + compare := strings.Index(body, "!current.matches(handle.identity.recorded())") + deliver := strings.Index(body, "signalProcessBirth(handle.identity.pid, unique.PIDVersion, signal)") + safeOrder := func(token, birth, match, signal int) bool { + return token >= 0 && token < birth && birth < match && match < signal + } + require.True(t, safeOrder(before, check, compare, deliver), "PID-reuse safety requires token acquisition before expected-birth verification before delivery") + // Negative ordering from the old reader must never satisfy the guard. + require.False(t, safeOrder(check, before, compare, deliver)) +} diff --git a/runners/base/recovery_other.go b/runners/base/recovery_other.go new file mode 100644 index 00000000..f344f6d7 --- /dev/null +++ b/runners/base/recovery_other.go @@ -0,0 +1,5 @@ +//go:build !darwin + +package base + +func recoveryLegacyBootMatches(string) (bool, error) { return false, nil } diff --git a/runners/base/recovery_size_test.go b/runners/base/recovery_size_test.go new file mode 100644 index 00000000..e77fb02e --- /dev/null +++ b/runners/base/recovery_size_test.go @@ -0,0 +1,93 @@ +package base + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Serialization fixtures exercise the exact preview-construction seam. They +// name no live processes and grant no authority to execute recovery. +func recoveryReceiptSizeFixture(count int) (*TrackedProcessGroup, recordSnapshot, []processIdentity) { + const boot = "7CA8814F-130E-4F30-A0E9-14064ED54319" + leader := recordedProcessIdentity{PID: 12345, BootID: boot, StartID: 1793147946098482, Executable: "worker"} + owner := recordedProcessIdentity{PID: 11234, BootID: boot, StartID: 1793147904561399, Executable: "owner"} + group := &TrackedProcessGroup{namespace: pgidRegistryNamespace, record: pgidRecord{PGID: leader.PID, Leader: leader, Owner: owner, Authentication: strings.Repeat("a", groupAuthBytes*2)}} + members := make([]processIdentity, count) + for i := range members { + members[i] = processIdentity{pid: leader.PID + i, pgid: leader.PID, bootID: boot, startID: leader.StartID + uint64(i), executable: "worker"} + } + return group, recordSnapshot{digest: strings.Repeat("b", 64)}, members +} + +func encodeRecoveryReceipt(t *testing.T, expected *ProcessGroupRecoveryExpectation) []byte { + t.Helper() + var output bytes.Buffer + require.NoError(t, json.NewEncoder(&output).Encode(expected)) + require.True(t, bytes.HasSuffix(output.Bytes(), []byte("\n"))) + return output.Bytes() +} + +func TestRecoveryReceiptLargeGroupRoundTrip(t *testing.T) { + for _, count := range []int{150, 160} { + t.Run(fmt.Sprint(count), func(t *testing.T) { + group, snapshot, members := recoveryReceiptSizeFixture(count) + expected, err := recoveryPreviewExpectation(group, snapshot, members) + require.NoError(t, err) + output := encodeRecoveryReceipt(t, expected) + require.Greater(t, len(output), maxRecordSize) + actual, err := ParseProcessGroupRecoveryExpectation(output) + require.NoError(t, err) + require.Equal(t, *expected, actual) + t.Logf("%d members: %d bytes including Encoder newline", count, len(output)) + // Ordinary human formatting is admitted when its total bytes fit too. + var indented bytes.Buffer + require.NoError(t, json.Indent(&indented, output, "", " ")) + actual, err = ParseProcessGroupRecoveryExpectation(indented.Bytes()) + require.NoError(t, err) + require.Equal(t, *expected, actual) + }) + } +} + +func TestRecoveryReceiptInclusiveByteBoundary(t *testing.T) { + group, snapshot, members := recoveryReceiptSizeFixture(160) + // Include escaped characters so the guard counts encoded bytes, not fields + // or raw string lengths. Fill the remainder with single-byte ASCII. + members[0].executable = "worker<&>" + initial, err := recoveryPreviewExpectation(group, snapshot, members) + require.NoError(t, err) + initialBytes := encodeRecoveryReceipt(t, initial) + members[0].executable += strings.Repeat("x", MaxProcessGroupRecoveryExpectationBytes-len(initialBytes)) + exact, err := recoveryPreviewExpectation(group, snapshot, members) + require.NoError(t, err) + output := encodeRecoveryReceipt(t, exact) + require.Len(t, output, MaxProcessGroupRecoveryExpectationBytes) + parsed, err := ParseProcessGroupRecoveryExpectation(output) + require.NoError(t, err) + require.Equal(t, *exact, parsed) + compact, err := json.Marshal(exact) + require.NoError(t, err) + _, err = ParseProcessGroupRecoveryExpectation(compact) + require.NoError(t, err) + _, err = ParseProcessGroupRecoveryExpectation(append(output, ' ')) + require.ErrorContains(t, err, "too large") + // One extra encoded byte makes even the producer refuse. Removing the + // newline cannot bypass the shared canonical-output budget at the parser. + members[0].executable += "x" + oversize, err := recoveryPreviewExpectation(group, snapshot, members) + require.ErrorContains(t, err, "too large") + require.Nil(t, oversize) + raw, err := json.Marshal(recoveryExpectation(group, snapshot, members)) + require.NoError(t, err) + require.Len(t, raw, MaxProcessGroupRecoveryExpectationBytes) + _, err = ParseProcessGroupRecoveryExpectation(raw) + require.ErrorContains(t, err, "too large") + _, err = ParseProcessGroupRecoveryExpectation(append(raw, '\n')) + require.ErrorContains(t, err, "too large") + require.Equal(t, 16*1024, maxRecordSize, "receipt capacity must not change the registry cap") +} diff --git a/runners/base/recovery_test.go b/runners/base/recovery_test.go new file mode 100644 index 00000000..11526568 --- /dev/null +++ b/runners/base/recovery_test.go @@ -0,0 +1,204 @@ +package base + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "github.com/stretchr/testify/require" +) + +func recoveryOrphan(t *testing.T, leaderless bool) (int, string, *TrackedProcessGroup) { + t.Helper() + pid, path := spawnOrphanedGroup(t, leaderless) + group, err := LookupProcessGroup(pid) + require.NoError(t, err) + t.Cleanup(func() { _ = group.Terminate(context.Background()); _ = group.RemoveIfDead() }) + return pid, path, group +} + +func TestRecoveryExactGenerationAndControls(t *testing.T) { + for _, leaderless := range []bool{false, true} { + t.Run(map[bool]string{false: "leader", true: "credentialed descendants"}[leaderless], func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + pid, path, _ := recoveryOrphan(t, leaderless) + sibling := startRegistryLeader(t, "member") + defer stopRegistryLeader(t, sibling) + siblingPath := recordPath(t, sibling.Process.Pid) + siblingBytes, err := os.ReadFile(siblingPath) + require.NoError(t, err) + // An owned, unregistered control: no recovery credential or attribution. + foreign := registryHelperCommand("member") + ready := filepath.Join(t.TempDir(), "ready") + foreign.Env = append(foreign.Env, processGroupReadyFileEnv+"="+ready) + foreign.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + require.NoError(t, foreign.Start()) + waitForTestFile(t, ready) + identity, err := InspectProcess(foreign.Process.Pid) + require.NoError(t, err) + defer func() { + require.NoError(t, SignalProcess(t.Context(), identity, syscall.SIGTERM)) + require.NoError(t, foreign.Wait()) + }() + before, err := os.ReadFile(path) + require.NoError(t, err) + expected, err := PreviewProcessGroupRecovery(t.Context(), pid) + require.NoError(t, err) + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, after) + raw, err := json.Marshal(expected) + require.NoError(t, err) + decoded, err := ParseProcessGroupRecoveryExpectation(raw) + require.NoError(t, err) + require.Equal(t, *expected, decoded) + result, err := RecoverProcessGroup(t.Context(), decoded) + require.NoError(t, err) + require.Equal(t, &ProcessGroupRecoveryResult{GroupDead: true, RecordRemoved: true}, result) + _, err = os.Stat(path) + require.ErrorIs(t, err, os.ErrNotExist) + observed, err := sibling.group.InspectOwnership(t.Context()) + require.NoError(t, err) + require.True(t, observed.Authenticated) + after, err = os.ReadFile(siblingPath) + require.NoError(t, err) + require.Equal(t, siblingBytes, after) + current, err := InspectProcess(identity.PID) + require.NoError(t, err) + require.Equal(t, identity, current) + }) + } +} + +func TestRecoveryRefusesChangedEvidence(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + pid, path, _ := recoveryOrphan(t, false) + expected, err := PreviewProcessGroupRecovery(t.Context(), pid) + require.NoError(t, err) + before, err := os.ReadFile(path) + require.NoError(t, err) + for name, mutate := range map[string]func(*ProcessGroupRecoveryExpectation){ + "target": func(e *ProcessGroupRecoveryExpectation) { e.PGID++ }, + "record": func(e *ProcessGroupRecoveryExpectation) { e.RecordSHA256 = strings.Repeat("0", 64) }, + "namespace": func(e *ProcessGroupRecoveryExpectation) { e.Namespace = pgidPreviousNamespace }, + "leader birth": func(e *ProcessGroupRecoveryExpectation) { e.Leader.StartID++ }, + "owner birth": func(e *ProcessGroupRecoveryExpectation) { e.Owner.StartID++ }, + "boot": func(e *ProcessGroupRecoveryExpectation) { e.Leader.BootID = "wrong" }, + "member birth": func(e *ProcessGroupRecoveryExpectation) { e.Members[0].StartID++ }, + "member boot": func(e *ProcessGroupRecoveryExpectation) { e.Members[0].BootID = "wrong" }, + "missing proof": func(e *ProcessGroupRecoveryExpectation) { e.Members = nil }, + "duplicate member": func(e *ProcessGroupRecoveryExpectation) { e.Members = append(e.Members, e.Members[0]) }, + } { + t.Run(name, func(t *testing.T) { + changed := *expected + changed.Members = append([]ProcessIdentity(nil), expected.Members...) + mutate(&changed) + result, err := RecoverProcessGroup(t.Context(), changed) + require.Error(t, err) + require.False(t, result.RecordRemoved) + assertGroupAlive(t, pid) + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, after) + }) + } + for _, change := range []string{"replacement", "birth", "duplicate namespace", "missing record"} { + t.Run(change, func(t *testing.T) { + oldDir, err := pgidNamespaceDir(pgidPreviousNamespace) + require.NoError(t, err) + oldPath := filepath.Join(oldDir, filepath.Base(path)) + defer func() { _ = os.Remove(oldPath); require.NoError(t, os.WriteFile(path, before, 0600)) }() + switch change { + case "replacement": + rewriteRecord(t, path, func(r *pgidRecord) { r.Authentication = strings.Repeat("0", groupAuthBytes*2) }) + case "birth": + rewriteRecord(t, path, func(r *pgidRecord) { r.Leader.StartID++ }) + case "duplicate namespace": + group, err := LookupProcessGroup(pid) + require.NoError(t, err) + raw, err := json.Marshal(group.record) + require.NoError(t, err) + require.NoError(t, os.WriteFile(oldPath, raw, 0600)) + case "missing record": + require.NoError(t, os.Remove(path)) + } + selected, _ := os.ReadFile(path) + result, err := RecoverProcessGroup(t.Context(), *expected) + require.Error(t, err) + require.False(t, result.RecordRemoved) + assertGroupAlive(t, pid) + after, _ := os.ReadFile(path) + require.Equal(t, selected, after) + if change == "birth" { + _, err := PreviewProcessGroupRecovery(t.Context(), pid) + require.Error(t, err) + } + }) + } +} + +func TestRecoveryRefusesLiveOwnerAndMissingAuthentication(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + leader := startRegistryLeader(t, "member") + defer stopRegistryLeader(t, leader) + _, err := PreviewProcessGroupRecovery(t.Context(), leader.Process.Pid) + require.ErrorContains(t, err, "owner") + assertGroupAlive(t, leader.Process.Pid) + pid, path, _ := recoveryOrphan(t, true) + rewriteRecord(t, path, func(rec *pgidRecord) { rec.Authentication = strings.Repeat("0", groupAuthBytes*2) }) + _, err = PreviewProcessGroupRecovery(t.Context(), pid) + require.Error(t, err) + assertGroupAlive(t, pid) +} + +func TestRecoveryExpectationStrictDecoding(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + pid, _, _ := recoveryOrphan(t, false) + expected, err := PreviewProcessGroupRecovery(t.Context(), pid) + require.NoError(t, err) + data, err := json.Marshal(expected) + require.NoError(t, err) + for name, raw := range map[string]string{ + "unknown": strings.Replace(string(data), "{", `{"unknown":true,`, 1), + "nested unknown": strings.Replace(string(data), `"PID":`, `"unknown":true,"PID":`, 1), + "duplicate": strings.Replace(string(data), "{", `{"pgid":1,`, 1), + "nested duplicate": strings.Replace(string(data), `"PID":`, `"PID":1,"PID":`, 1), + "trailing": string(data) + " {}", + "null": "null", "empty": "{}", "malformed": "{", + "missing birth": strings.Replace(string(data), `"StartID":`, `"StartID":0,"old":`, 1), + } { + t.Run(name, func(t *testing.T) { _, err := ParseProcessGroupRecoveryExpectation([]byte(raw)); require.Error(t, err) }) + } + assertGroupAlive(t, pid) +} + +func TestRecoveryPreservesReplacementDuringTermination(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + dir := t.TempDir() + pidPath, ready := filepath.Join(dir, "pid"), filepath.Join(dir, "ready") + owner := registryHelperCommand("recovery-replacement-owner") + owner.Env = append(owner.Env, processGroupPIDFileEnv+"="+pidPath, processGroupReadyFileEnv+"="+ready) + require.NoError(t, owner.Run()) + raw, err := os.ReadFile(pidPath) + require.NoError(t, err) + var pid int + _, err = fmt.Sscan(string(raw), &pid) + require.NoError(t, err) + group, err := LookupProcessGroup(pid) + require.NoError(t, err) + defer func() { _ = group.Terminate(context.Background()) }() + expected, err := PreviewProcessGroupRecovery(t.Context(), pid) + require.NoError(t, err) + result, err := RecoverProcessGroup(t.Context(), *expected) + require.ErrorContains(t, err, "expected evidence") + require.True(t, result.GroupDead) + require.False(t, result.RecordRemoved) + rec, _, err := readPgidRecord(recordPath(t, pid)) + require.NoError(t, err) + require.Equal(t, strings.Repeat("0", groupAuthBytes*2), rec.Authentication) +} diff --git a/runners/dockerrun/container_stop.go b/runners/dockerrun/container_stop.go new file mode 100644 index 00000000..7ffa345d --- /dev/null +++ b/runners/dockerrun/container_stop.go @@ -0,0 +1,252 @@ +package dockerrun + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "reflect" + "slices" + "strings" + "time" + + "github.com/codefly-dev/core/internal/wire" + "github.com/docker/docker/api/types/container" + "gopkg.in/yaml.v3" +) + +// MaxContainerStopExpectationBytes includes the default JSON Encoder newline. +// Oversized inspections are refused rather than emitting an unusable receipt. +const MaxContainerStopExpectationBytes = 64 << 10 + +// ContainerStopExpectation is an untrusted observation of an explicitly +// selected container. It grants no authority and establishes no parent service. +// The caller owns operator authorization; Core compares every field before stop. +type ContainerStopExpectation struct { + ID string `json:"id"` + Created string `json:"created"` + Name string `json:"name"` + Labels map[string]string `json:"labels"` + Mounts []container.MountPoint `json:"mounts"` +} + +// ContainerStopResult describes observed postconditions, including on error. +// Success requires nil error and all three true. This is not data-content proof. +type ContainerStopResult struct { + Stopped bool `json:"stopped"` + Retained bool `json:"retained"` + MountsUnchanged bool `json:"mounts_unchanged"` +} + +func validateFullContainerID(id string) error { + decoded, err := hex.DecodeString(id) + if err != nil || len(decoded) != 32 || id != strings.ToLower(id) { + return errors.New("container stop requires a full lowercase 64-hex Docker ID") + } + return nil +} + +func (expected ContainerStopExpectation) validate() error { + if err := validateFullContainerID(expected.ID); err != nil { + return err + } + if _, err := time.Parse(time.RFC3339Nano, expected.Created); err != nil { + return fmt.Errorf("invalid container creation evidence: %w", err) + } + if expected.Name == "" || expected.Labels[LabelCodeflyOwner] != "true" || + expected.Labels[LabelCodeflyName] != expected.Name || expected.Mounts == nil { + return errors.New("container stop requires exact Codefly name, owner, labels and explicit mount evidence") + } + data, err := json.Marshal(expected) + if err != nil { + return err + } + if len(data)+1 > MaxContainerStopExpectationBytes { + return errors.New("container stop expectation is too large") + } + return nil +} + +// ParseContainerStopExpectation decodes a complete saved preview. Duplicate +// keys, nulls, unknown fields, trailing documents and missing evidence refuse. +func ParseContainerStopExpectation(data []byte) (ContainerStopExpectation, error) { + var expected ContainerStopExpectation + if len(data) > MaxContainerStopExpectationBytes { + return expected, errors.New("container stop expectation is too large") + } + var document yaml.Node + if err := yaml.Unmarshal(data, &document); err != nil { + return expected, err + } + if defect := wire.Check(&document); defect.Kind != wire.NoDefect { + return expected, fmt.Errorf("invalid container evidence at %s: %v (%s)", defect.Path, defect.Kind, defect.Detail) + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&expected); err != nil { + return expected, err + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return expected, errors.New("container stop expectation has trailing data") + } + if err := checkContainerStopFields(document.Content[0]); err != nil { + return expected, err + } + return expected, expected.validate() +} + +// encoding/json accepts case-insensitive struct fields. Check the two schema +// objects with the existing wire mapping reader before admitting that binding; +// arbitrary label keys remain case-sensitive data, not schema field names. +func checkContainerStopFields(node *yaml.Node) error { + mapping := wire.ReadMapping(node, func(key string, value *yaml.Node) (bool, error) { + switch key { + case "id", "created", "name", "labels": + return true, nil + case "mounts": + for _, item := range value.Content { + present := make(map[string]bool) + fields := wire.ReadMapping(item, func(key string, _ *yaml.Node) (bool, error) { + switch key { + case "Type", "Name", "Source", "Destination", "Driver", "Mode", "RW", "Propagation": + present[key] = true + return true, nil + default: + return false, nil + } + }) + if fields.Err != nil { + return true, fields.Err + } + if fields.HasUnknown { + return true, fmt.Errorf("unknown container mount field %q", fields.Unknown) + } + // These MountPoint fields have no omitempty tag. Their zero + // values must be supplied explicitly, not invented by decoding. + for _, field := range []string{"Source", "Destination", "Mode", "RW", "Propagation"} { + if !present[field] { + return true, fmt.Errorf("missing container mount field %q", field) + } + } + } + return true, nil + default: + return false, nil + } + }) + if mapping.Err != nil { + return mapping.Err + } + if mapping.HasUnknown { + return fmt.Errorf("unknown container stop field %q", mapping.Unknown) + } + return nil +} + +func canonicalContainerMounts(mounts []container.MountPoint) []container.MountPoint { + result := append([]container.MountPoint{}, mounts...) + slices.SortFunc(result, func(a, b container.MountPoint) int { + // MountPoint contains only strings and a bool: Marshal cannot fail. + left, _ := json.Marshal(a) + right, _ := json.Marshal(b) + return bytes.Compare(left, right) + }) + return result +} + +func containerStopObservation(inspect container.InspectResponse, id string) (ContainerStopExpectation, error) { + if inspect.ContainerJSONBase == nil || inspect.ID != id || inspect.Config == nil || + inspect.HostConfig == nil || inspect.State == nil { + return ContainerStopExpectation{}, errors.New("container inspection has incomplete or changed identity") + } + if inspect.HostConfig.AutoRemove { + return ContainerStopExpectation{}, errors.New("container stop refuses AutoRemove: exit could delete the retained object") + } + expected := ContainerStopExpectation{ + ID: inspect.ID, Created: inspect.Created, Name: strings.TrimPrefix(inspect.Name, "/"), + Labels: maps.Clone(inspect.Config.Labels), Mounts: canonicalContainerMounts(inspect.Mounts), + } + return expected, expected.validate() +} + +// PreviewContainerStop inspects one full immutable ID without any lifecycle +// effect. AutoRemove objects refuse; stopping them could delete their data. +func PreviewContainerStop(ctx context.Context, id string) (*ContainerStopExpectation, error) { + if err := validateFullContainerID(id); err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + cli, err := NewClient() + if err != nil { + return nil, err + } + defer cli.Close() + inspect, err := cli.ContainerInspect(ctx, id) + if err != nil { + return nil, err + } + expected, err := containerStopObservation(inspect, id) + if err != nil { + return nil, err + } + return &expected, nil +} + +// StopContainer conditionally stops one operator-selected immutable ID. It does +// not resolve a name, claim a ledger resource, relabel, remove or restart it. +// A canceled or failed call may have stopped the container; only readback can +// establish the postconditions. Another actor can restart it after observation. +func StopContainer(ctx context.Context, expected ContainerStopExpectation) (*ContainerStopResult, error) { + result := &ContainerStopResult{} + if err := expected.validate(); err != nil { + return result, err + } + expected.Mounts = canonicalContainerMounts(expected.Mounts) + cli, err := NewClient() + if err != nil { + return result, err + } + defer cli.Close() + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + before, err := cli.ContainerInspect(ctx, expected.ID) + if err != nil { + return result, err + } + observed, err := containerStopObservation(before, expected.ID) + if err != nil { + return result, err + } + if !reflect.DeepEqual(expected, observed) { + return result, errors.New("container stop evidence changed") + } + if err := ctx.Err(); err != nil { + return result, err + } + var stopErr error + if before.State.Running { + grace := 3 + stopErr = cli.ContainerStop(ctx, expected.ID, container.StopOptions{Timeout: &grace}) + } + after, err := cli.ContainerInspect(ctx, expected.ID) + if err != nil { + return result, errors.Join(stopErr, fmt.Errorf("cannot verify retained container: %w", err)) + } + if after.ContainerJSONBase == nil || after.ID != expected.ID || after.Created != expected.Created { + return result, errors.Join(stopErr, errors.New("retained container identity changed")) + } + result.Retained = true + result.Stopped = after.State != nil && !after.State.Running && !after.State.Restarting && !after.State.Paused + result.MountsUnchanged = reflect.DeepEqual(expected.Mounts, canonicalContainerMounts(after.Mounts)) + observed, inspectErr := containerStopObservation(after, expected.ID) + if inspectErr != nil || !reflect.DeepEqual(expected, observed) || !result.Stopped { + return result, errors.Join(stopErr, inspectErr, errors.New("container stop postconditions were not established")) + } + return result, stopErr +} diff --git a/runners/dockerrun/container_stop_integration_test.go b/runners/dockerrun/container_stop_integration_test.go new file mode 100644 index 00000000..b867a7b7 --- /dev/null +++ b/runners/dockerrun/container_stop_integration_test.go @@ -0,0 +1,164 @@ +//go:build !skip_infra + +package dockerrun + +import ( + "archive/tar" + "bytes" + "context" + "encoding/json" + "io" + "maps" + "slices" + "testing" + "time" + + "github.com/codefly-dev/core/resources" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/api/types/volume" + "github.com/stretchr/testify/require" +) + +func stopFixture(t *testing.T, name string, labels map[string]string, host *container.HostConfig, command string) (*DockerEnvironment, string) { + t.Helper() + if name == "" { + name = ContainerName(uniqueName(t)) + } + env, err := NewDockerHeadlessEnvironment(t.Context(), resources.NewDockerImage("alpine:latest"), name) + require.NoError(t, err) + if labels != nil { + labels = maps.Clone(labels) + labels[LabelCodeflyName] = name + } + response, err := env.client.ContainerCreate(t.Context(), &container.Config{Image: "alpine:latest", Labels: labels, + Cmd: []string{"sh", "-c", command}}, host, nil, nil, name) + require.NoError(t, err) + t.Cleanup(func() { + // This test created the exact ID; fixture disposal never resolves names. + _ = env.client.ContainerRemove(context.Background(), response.ID, container.RemoveOptions{Force: true}) + _ = env.client.Close() + }) + require.NoError(t, env.client.ContainerStart(t.Context(), response.ID, container.StartOptions{})) + return env, response.ID +} + +func TestContainerStopRetainsExactGenerationDataAndControls(t *testing.T) { + backend := newBackend(t) + vol, err := backend.client.VolumeCreate(t.Context(), volume.CreateOptions{Name: uniqueName(t)}) + require.NoError(t, err) + t.Cleanup(func() { _ = backend.client.VolumeRemove(context.Background(), vol.Name, true) }) + labels := map[string]string{LabelCodeflyOwner: "true", LabelCodeflyRecoveryScope: "owned-stop-test", LabelCodeflyRecoveryNamespace: "owned-test-host"} + env, id := stopFixture(t, "", labels, &container.HostConfig{Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: vol.Name, Target: "/data"}}}, + "echo layer > /retained.tmp; mv /retained.tmp /retained; echo volume > /data/retained.tmp; mv /data/retained.tmp /data/retained; exec sleep 300") + _, sibling := stopFixture(t, "", map[string]string{LabelCodeflyOwner: "true", LabelCodeflyRecoveryScope: "sibling"}, &container.HostConfig{}, "exec sleep 300") + _, foreign := stopFixture(t, "", nil, &container.HostConfig{}, "exec sleep 300") + controls := map[string]container.InspectResponse{} + for _, control := range []string{sibling, foreign} { + controls[control], err = backend.client.ContainerInspect(t.Context(), control) + require.NoError(t, err) + } + require.Eventually(t, func() bool { + _, err := env.client.ContainerStatPath(t.Context(), id, "/data/retained") + return err == nil + }, 5*time.Second, 10*time.Millisecond) + expected, err := PreviewContainerStop(t.Context(), id) + require.NoError(t, err) + require.NotContains(t, expected.Labels, LabelCodeflyService) + require.NotContains(t, expected.Labels, LabelCodeflyInvocation) + data, err := json.Marshal(expected) + require.NoError(t, err) + parsed, err := ParseContainerStopExpectation(append(data, '\n')) + require.NoError(t, err) + for name, mutate := range map[string]func(*ContainerStopExpectation){ + "ID": func(e *ContainerStopExpectation) { e.ID = sibling }, + "creation": func(e *ContainerStopExpectation) { e.Created = time.Now().UTC().Format(time.RFC3339Nano) }, + "name": func(e *ContainerStopExpectation) { e.Name += "-other"; e.Labels[LabelCodeflyName] = e.Name }, + "scope": func(e *ContainerStopExpectation) { e.Labels[LabelCodeflyRecoveryScope] = "other" }, + "namespace": func(e *ContainerStopExpectation) { e.Labels[LabelCodeflyRecoveryNamespace] = "other" }, + "mount": func(e *ContainerStopExpectation) { e.Mounts[0].Source += "-other" }, + } { + t.Run(name, func(t *testing.T) { + changed := parsed + changed.Labels, changed.Mounts = maps.Clone(parsed.Labels), slices.Clone(parsed.Mounts) + mutate(&changed) + result, err := StopContainer(t.Context(), changed) + require.Error(t, err) + require.False(t, result.Stopped) + actual, err := env.client.ContainerInspect(t.Context(), id) + require.NoError(t, err) + require.True(t, actual.State.Running) + }) + } + canceled, cancel := context.WithCancel(t.Context()) + cancel() + _, err = StopContainer(canceled, parsed) + require.Error(t, err) + result, err := StopContainer(t.Context(), parsed) + require.NoError(t, err) + require.Equal(t, &ContainerStopResult{Stopped: true, Retained: true, MountsUnchanged: true}, result) + result, err = StopContainer(t.Context(), parsed) + require.NoError(t, err, "same stopped generation is an idempotent retry") + require.True(t, result.Stopped && result.Retained && result.MountsUnchanged) + for file, want := range map[string]string{"/retained": "layer\n", "/data/retained": "volume\n"} { + reader, _, err := env.client.CopyFromContainer(t.Context(), id, file) + require.NoError(t, err) + archive := tar.NewReader(reader) + _, err = archive.Next() + require.NoError(t, err) + actual, err := io.ReadAll(archive) + require.NoError(t, err) + require.NoError(t, reader.Close()) + require.Equal(t, want, string(actual)) + } + for control, before := range controls { + after, err := env.client.ContainerInspect(t.Context(), control) + require.NoError(t, err) + require.True(t, after.State.Running) + require.Equal(t, before.Created, after.Created) + require.Equal(t, before.State.StartedAt, after.State.StartedAt) + require.Equal(t, before.Config.Labels, after.Config.Labels) + require.Equal(t, before.Mounts, after.Mounts) + } + // A new object with the same name is never resolved by an old receipt. + require.NoError(t, env.client.ContainerRemove(t.Context(), id, container.RemoveOptions{})) + _, replacement := stopFixture(t, expected.Name, labels, &container.HostConfig{}, "exec sleep 300") + _, err = StopContainer(t.Context(), parsed) + require.Error(t, err) + actual, err := env.client.ContainerInspect(t.Context(), replacement) + require.NoError(t, err) + require.True(t, actual.State.Running) +} + +func TestContainerStopRefusesAutoRemoveBeforeEffect(t *testing.T) { + env, id := stopFixture(t, "", map[string]string{LabelCodeflyOwner: "true"}, &container.HostConfig{AutoRemove: true}, "exec sleep 300") + _, err := PreviewContainerStop(t.Context(), id) + require.ErrorContains(t, err, "AutoRemove") + before, err := env.client.ContainerInspect(t.Context(), id) + require.NoError(t, err) + // Supplying an otherwise complete receipt cannot bypass execution's own + // AutoRemove inspection. No manufactured ledger ownership is involved. + expected := ContainerStopExpectation{ID: id, Created: before.Created, Name: before.Config.Labels[LabelCodeflyName], + Labels: maps.Clone(before.Config.Labels), Mounts: []container.MountPoint{}} + _, err = StopContainer(t.Context(), expected) + require.ErrorContains(t, err, "AutoRemove") + after, err := env.client.ContainerInspect(t.Context(), id) + require.NoError(t, err) + require.True(t, after.State.Running) + require.Equal(t, before.State.StartedAt, after.State.StartedAt) +} + +func TestContainerStopUnmountedEncoderRoundTrip(t *testing.T) { + _, id := stopFixture(t, "", map[string]string{LabelCodeflyOwner: "true"}, &container.HostConfig{}, "exec sleep 300") + expected, err := PreviewContainerStop(t.Context(), id) + require.NoError(t, err) + require.NotNil(t, expected.Mounts) + require.Empty(t, expected.Mounts) + var output bytes.Buffer + require.NoError(t, json.NewEncoder(&output).Encode(expected)) + parsed, err := ParseContainerStopExpectation(output.Bytes()) + require.NoError(t, err) + result, err := StopContainer(t.Context(), parsed) + require.NoError(t, err) + require.Equal(t, &ContainerStopResult{Stopped: true, Retained: true, MountsUnchanged: true}, result) +} diff --git a/runners/dockerrun/container_stop_test.go b/runners/dockerrun/container_stop_test.go new file mode 100644 index 00000000..2cf6fa20 --- /dev/null +++ b/runners/dockerrun/container_stop_test.go @@ -0,0 +1,156 @@ +package dockerrun + +import ( + "bytes" + "encoding/json" + "maps" + "strings" + "testing" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" + "github.com/stretchr/testify/require" +) + +func stopExpectationFixture() ContainerStopExpectation { + return ContainerStopExpectation{ + ID: strings.Repeat("a", 64), Created: "2026-10-28T00:38:43.921733968Z", Name: "codefly-test", + Labels: map[string]string{LabelCodeflyOwner: "true", LabelCodeflyName: "codefly-test"}, + Mounts: []container.MountPoint{}, + } +} + +func TestContainerStopExpectationTransport(t *testing.T) { + expected := stopExpectationFixture() + var encoded bytes.Buffer + require.NoError(t, json.NewEncoder(&encoded).Encode(expected)) + decoded, err := ParseContainerStopExpectation(encoded.Bytes()) + require.NoError(t, err) + require.Equal(t, expected, decoded) + for _, bad := range []string{ + strings.Replace(encoded.String(), `"mounts":[]`, `"mounts":null`, 1), + strings.Replace(encoded.String(), `,"mounts":[]`, "", 1), + strings.Replace(encoded.String(), `"labels":{`, `"extra":true,"labels":{`, 1), + strings.Replace(encoded.String(), `"mounts":[]`, `"mounts":[],"mounts":[]`, 1), + strings.Replace(encoded.String(), `"mounts":[]`, `"mounts":[],"\u006dounts":[]`, 1), + encoded.String() + "{}", + } { + _, err := ParseContainerStopExpectation([]byte(bad)) + require.Error(t, err, bad) + } + for name, mutate := range map[string]func(*ContainerStopExpectation){ + "short ID": func(e *ContainerStopExpectation) { e.ID = e.ID[:12] }, + "uppercase ID": func(e *ContainerStopExpectation) { e.ID = strings.ToUpper(e.ID) }, + "created": func(e *ContainerStopExpectation) { e.Created = "" }, + "owner": func(e *ContainerStopExpectation) { delete(e.Labels, LabelCodeflyOwner) }, + "name": func(e *ContainerStopExpectation) { e.Name = "other" }, + "mounts": func(e *ContainerStopExpectation) { e.Mounts = nil }, + } { + t.Run(name, func(t *testing.T) { + changed := expected + changed.Labels = maps.Clone(expected.Labels) + mutate(&changed) + require.Error(t, changed.validate()) + }) + } + + // The producer reserves the actual default Encoder newline, and the same + // validation is used by preview and direct execution. + expected.Labels["padding"] = "" + initial, err := json.Marshal(expected) + require.NoError(t, err) + expected.Labels["padding"] = strings.Repeat("x", MaxContainerStopExpectationBytes-len(initial)-1) + require.NoError(t, expected.validate()) + encoded.Reset() + require.NoError(t, json.NewEncoder(&encoded).Encode(expected)) + require.Equal(t, MaxContainerStopExpectationBytes, encoded.Len()) + _, err = ParseContainerStopExpectation(encoded.Bytes()) + require.NoError(t, err) + _, err = ParseContainerStopExpectation(append(encoded.Bytes(), ' ')) + require.Error(t, err) + expected.Labels["padding"] += "x" + require.Error(t, expected.validate()) + compact, err := json.Marshal(expected) + require.NoError(t, err) + _, err = ParseContainerStopExpectation(compact) + require.Error(t, err, "omitting the newline must not bypass the producer budget") +} + +func TestContainerStopObservationAndMountEvidence(t *testing.T) { + expected := stopExpectationFixture() + inspect := container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{ID: expected.ID, Created: expected.Created, Name: "/" + expected.Name, + HostConfig: &container.HostConfig{}, State: &container.State{}}, + Config: &container.Config{Labels: maps.Clone(expected.Labels)}, + } + observed, err := containerStopObservation(inspect, expected.ID) + require.NoError(t, err) + require.Equal(t, expected, observed, "Docker nil mounts must produce explicit []") + inspect.HostConfig.AutoRemove = true + _, err = containerStopObservation(inspect, expected.ID) + require.ErrorContains(t, err, "AutoRemove") + inspect.HostConfig = nil + _, err = containerStopObservation(inspect, expected.ID) + require.Error(t, err) + inspect.HostConfig = &container.HostConfig{} + inspect.ID = strings.Repeat("b", 64) + _, err = containerStopObservation(inspect, expected.ID) + require.Error(t, err) + + a := container.MountPoint{Type: mount.TypeBind, Source: "/source", Destination: "/data", Mode: "rw", RW: true, Propagation: mount.PropagationRPrivate} + b := container.MountPoint{Type: mount.TypeVolume, Name: "volume", Source: "/volume", Destination: "/other", Driver: "local", RW: true} + require.Equal(t, canonicalContainerMounts([]container.MountPoint{a, b}), canonicalContainerMounts([]container.MountPoint{b, a})) + for _, changed := range []container.MountPoint{ + {Type: a.Type, Source: a.Source, Destination: a.Destination, Mode: a.Mode, RW: false, Propagation: a.Propagation}, + {Type: a.Type, Source: "/replacement", Destination: a.Destination, Mode: a.Mode, RW: a.RW, Propagation: a.Propagation}, + } { + require.NotEqual(t, canonicalContainerMounts([]container.MountPoint{a}), canonicalContainerMounts([]container.MountPoint{changed})) + } +} + +func TestContainerStopExpectationRefusesSchemaAliases(t *testing.T) { + expected := stopExpectationFixture() + expected.Labels["Tenant"], expected.Labels["tenant"] = "upper", "lower" + expected.Mounts = []container.MountPoint{{Type: mount.TypeVolume, Name: "volume", Source: "/source", Destination: "/data", Driver: "local", Mode: "rw", RW: true, Propagation: mount.PropagationRPrivate}} + data, err := json.Marshal(expected) + require.NoError(t, err) + for _, field := range []string{"id", "created", "name", "labels", "mounts"} { + t.Run(field, func(t *testing.T) { + // A single noncanonical spelling and an overriding alias both + // refuse; ordering must not choose which evidence is authoritative. + alias := strings.ToUpper(field) + changed := strings.Replace(string(data), `"`+field+`":`, `"`+alias+`":`, 1) + _, err := ParseContainerStopExpectation([]byte(changed)) + require.Error(t, err) + }) + } + for _, field := range []string{"Type", "Name", "Source", "Destination", "Driver", "Mode", "RW", "Propagation"} { + t.Run("mount_"+field, func(t *testing.T) { + alias := strings.ToLower(field) + changed := strings.Replace(string(data), `"`+field+`":`, `"`+alias+`":`, 1) + _, err := ParseContainerStopExpectation([]byte(changed)) + require.Error(t, err) + }) + } + for _, injected := range []string{ + `"ID":"` + strings.Repeat("b", 64) + `",`, + `"Name":"other",`, + `"\u0049D":"` + strings.Repeat("b", 64) + `",`, + } { + _, err := ParseContainerStopExpectation([]byte("{" + injected + string(data[1:]))) + require.Error(t, err) + _, err = ParseContainerStopExpectation([]byte(string(data[:len(data)-1]) + "," + strings.TrimSuffix(injected, ",") + "}")) + require.Error(t, err) + } + for _, pair := range []string{`"RW":true,"rw":false`, `"rw":false,"RW":true`, `"RW":true,"\u0052W":false`} { + _, err := ParseContainerStopExpectation([]byte(strings.Replace(string(data), `"RW":true`, pair, 1))) + require.Error(t, err) + } + _, err = ParseContainerStopExpectation([]byte(strings.Replace(string(data), `"RW":true,`, "", 1))) + require.Error(t, err, "a missing mount field is not its zero value") + // Legal escaped spelling, whitespace and dynamic case-sensitive labels + // retain their meaning; this is schema strictness, not canonical JSON. + decoded, err := ParseContainerStopExpectation([]byte(" \n" + strings.Replace(string(data), `"id":`, `"\u0069d":`, 1) + "\n")) + require.NoError(t, err) + require.Equal(t, expected, decoded) +} diff --git a/version/info.codefly.yaml b/version/info.codefly.yaml index 13b55277..5a60d9f0 100644 --- a/version/info.codefly.yaml +++ b/version/info.codefly.yaml @@ -1 +1 @@ -version: 0.19.8 +version: 0.19.9