host: system apply never blocks on a planted FIFO; refusals are typed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
8 files changed,
+159
−32
internal/host/systemapply.go+38 −9internal/host/systemapply_test.go+7 −4internal/host/systemapply_unix.go+7 −3internal/host/systemapply_unix_test.go+63 −0internal/host/systemapply_windows.go+4 −7internal/host/systemstate.go+13 −2internal/release/system.go+21 −3internal/release/system_test.go+6 −4
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go| index 0d46a97fe..df6995191 100644 |
| --- a/internal/host/systemapply.go |
| +++ b/internal/host/systemapply.go |
| @@ -45,6 +45,11 @@ var ( |
| errSystemRefused = errors.New("refused") |
| errSystemDeferred = errors.New("deferred") |
| errNotYetImplemented = errors.New("system apply: install path not implemented") |
| + |
| + // errSystemUnsupported is what the non-linux build's primitives return. |
| + // It is declared here so Run can tell it from a lock another apply holds |
| + // without knowing which build it is in. |
| + errSystemUnsupported = errors.New("carlos system apply: unsupported on this platform") |
| ) |
| // sysOps is the seam between the apply and the box: systemctl, exec with a |
| @@ -95,6 +100,10 @@ func (a *systemApplier) Run(ctx context.Context, recover bool) error { |
| return err |
| } |
| } else if err := flockExclusiveNonblock(lock); err != nil { |
| + if errors.Is(err, errSystemUnsupported) { |
| + a.ops.logf("system apply: %v", err) |
| + return err |
| + } |
| a.ops.logf("system apply: another apply is running; leaving it to finish") |
| return nil |
| } |
| @@ -137,7 +146,7 @@ func (a *systemApplier) Run(ctx context.Context, recover bool) error { |
| if req.NoOp || p.Current.Version == a.onDiskBuild(ctx) { |
| return a.writeResult(req, systemOutcomeNoted, "") |
| } |
| - staged, err := a.stageCandidate(p, req) |
| + staged, err := a.stageCandidate(ctx, p, req) |
| if err != nil { |
| return a.record(req, fmt.Errorf("%w: %v", errSystemRefused, err)) |
| } |
| @@ -187,6 +196,8 @@ func (a *systemApplier) readRequest() (systemRequest, error) { |
| if err != nil { |
| return systemRequest{}, err |
| } |
| + // Regular-file only: a FIFO planted here is refused, having already been |
| + // opened non-blocking so the open itself could not park root. |
| if !st.Mode().IsRegular() || st.Size() > systemMaxRequest { |
| return systemRequest{}, fmt.Errorf("request: not a regular file under %d bytes", systemMaxRequest) |
| } |
| @@ -213,7 +224,7 @@ func (a *systemApplier) verifyPointer(ctx context.Context) (*release.SystemPoint |
| return p, nil |
| case errors.Is(err, os.ErrNotExist): |
| return nil, fmt.Errorf("%w: no %s pointer for this box's channel", errSystemRefused, a.channel) |
| - case strings.Contains(err.Error(), "system pointer"): |
| + case errors.Is(err, release.ErrBadSystemPointer): |
| return nil, fmt.Errorf("%w: %v", errSystemRefused, err) |
| default: |
| return nil, fmt.Errorf("%w: %v", errSystemDeferred, err) |
| @@ -227,13 +238,24 @@ func (a *systemApplier) onDiskBuild(ctx context.Context) string { |
| if err != nil { |
| return "" |
| } |
| - return strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(out), "carlos ")) |
| + return carlosVersionOf(out) |
| +} |
| + |
| +// carlosVersionOf reads `carlos version` output — exactly "carlos <build>", |
| +// nothing else. A build that merely starts with the expected version, or |
| +// output with anything else on the line, yields "" and matches nothing. |
| +func carlosVersionOf(out string) string { |
| + v, ok := strings.CutPrefix(strings.TrimSpace(out), "carlos ") |
| + if !ok { |
| + return "" |
| + } |
| + return v |
| } |
| // stageCandidate copies the agent's cache into staging/ (0700) as 0600, |
| // verifies the copy's sha against the pointer, makes it executable, and |
| // runs its `version`. Nothing under /data is read after the copy. |
| -func (a *systemApplier) stageCandidate(p *release.SystemPointer, req systemRequest) (string, error) { |
| +func (a *systemApplier) stageCandidate(ctx context.Context, p *release.SystemPointer, req systemRequest) (string, error) { |
| art, ok := p.Current.Artifacts[a.platform] |
| if !ok { |
| return "", fmt.Errorf("pointer names no artifact for %s", a.platform) |
| @@ -245,11 +267,13 @@ func (a *systemApplier) stageCandidate(p *release.SystemPointer, req systemReque |
| if err := os.Chmod(a.stagingDir(), 0o700); err != nil { |
| return "", err |
| } |
| - src, err := openBeneath(a.dataDir, "system", systemComponent, req.Version, "carlos") |
| + src, err := openBeneath(a.dataDir, systemCacheParts(req.Version)...) |
| if err != nil { |
| return "", fmt.Errorf("candidate: %w", err) |
| } |
| defer src.Close() |
| + // Regular-file only: a FIFO or device the sandboxed user planted at the |
| + // cache path is refused here, having already been opened non-blocking. |
| if st, err := src.Stat(); err != nil || !st.Mode().IsRegular() { |
| return "", errors.New("candidate: not a regular file") |
| } |
| @@ -260,9 +284,11 @@ func (a *systemApplier) stageCandidate(p *release.SystemPointer, req systemReque |
| } |
| h := sha256.New() |
| n, err := io.Copy(io.MultiWriter(dst, h), io.LimitReader(src, systemArtifactLimit+1)) |
| - dst.Close() |
| + if cerr := dst.Close(); err == nil { |
| + err = cerr // a close that fails is a copy that did not land |
| + } |
| if err != nil { |
| - return "", err |
| + return "", fmt.Errorf("candidate: copying into staging: %w", err) |
| } |
| if n > systemArtifactLimit { |
| return "", fmt.Errorf("candidate: larger than %d bytes", systemArtifactLimit) |
| @@ -273,8 +299,11 @@ func (a *systemApplier) stageCandidate(p *release.SystemPointer, req systemReque |
| if err := os.Chmod(staged, 0o755); err != nil { |
| return "", err |
| } |
| - out, err := a.ops.run(context.Background(), systemVersionWait, staged, "version") |
| - if err != nil || !strings.Contains(out, "carlos "+p.Current.Version) { |
| + out, err := a.ops.run(ctx, systemVersionWait, staged, "version") |
| + if err != nil { |
| + return "", fmt.Errorf("candidate: running its version: %w", err) |
| + } |
| + if carlosVersionOf(out) != p.Current.Version { |
| return "", fmt.Errorf("candidate reports %q, not %s", strings.TrimSpace(out), p.Current.Version) |
| } |
| return staged, nil |
diff --git a/internal/host/systemapply_test.go b/internal/host/systemapply_test.go| index 26ffb937d..3a5aca1d8 100644 |
| --- a/internal/host/systemapply_test.go |
| +++ b/internal/host/systemapply_test.go |
| @@ -319,7 +319,7 @@ func TestApplyStagesTheCandidateFromARootOwnedCopy(t *testing.T) { |
| t.Fatal(err) |
| } |
| req, _ := f.a.readRequest() |
| - staged, err := f.a.stageCandidate(p, req) |
| + staged, err := f.a.stageCandidate(context.Background(), p, req) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -343,16 +343,19 @@ func TestApplyStagesTheCandidateFromARootOwnedCopy(t *testing.T) { |
| os.WriteFile(systemCachePath(f2.a.dataDir, "v2"), []byte("evil"), 0o644) |
| p2, _ := f2.a.verifyPointer(context.Background()) |
| req2, _ := f2.a.readRequest() |
| - if _, err := f2.a.stageCandidate(p2, req2); err == nil || !strings.Contains(err.Error(), "sha256") { |
| + if _, err := f2.a.stageCandidate(context.Background(), p2, req2); err == nil || !strings.Contains(err.Error(), "sha256") { |
| t.Fatalf("err = %v", err) |
| } |
| + if st, err := os.Stat(filepath.Join(f2.a.stagingDir(), "carlos")); err == nil && st.Mode().Perm() != 0o600 { |
| + t.Fatalf("an unverified copy was made executable: mode = %v", st.Mode().Perm()) |
| + } |
| // A candidate that lies about its version is refused. |
| f3 := newApplyFixture(t, "stable", "v1") |
| f3.promote("v2", body, false) |
| f3.answer["version:"+filepath.Join(f3.a.stagingDir(), "carlos")] = "carlos v9\n" |
| p3, _ := f3.a.verifyPointer(context.Background()) |
| req3, _ := f3.a.readRequest() |
| - if _, err := f3.a.stageCandidate(p3, req3); err == nil || !strings.Contains(err.Error(), "reports") { |
| + if _, err := f3.a.stageCandidate(context.Background(), p3, req3); err == nil || !strings.Contains(err.Error(), "reports") { |
| t.Fatalf("err = %v", err) |
| } |
| // A symlinked cache file is refused. |
| @@ -363,7 +366,7 @@ func TestApplyStagesTheCandidateFromARootOwnedCopy(t *testing.T) { |
| os.Symlink(filepath.Join(f4.a.dataDir, "real"), systemCachePath(f4.a.dataDir, "v2")) |
| p4, _ := f4.a.verifyPointer(context.Background()) |
| req4, _ := f4.a.readRequest() |
| - if _, err := f4.a.stageCandidate(p4, req4); err == nil { |
| + if _, err := f4.a.stageCandidate(context.Background(), p4, req4); err == nil { |
| t.Fatal("followed a symlinked cache file") |
| } |
| } |
diff --git a/internal/host/systemapply_unix.go b/internal/host/systemapply_unix.go| index d906a1116..50efc63c7 100644 |
| --- a/internal/host/systemapply_unix.go |
| +++ b/internal/host/systemapply_unix.go |
| @@ -15,14 +15,18 @@ import ( |
| // openBeneath opens root/parts... one component at a time with |
| // O_DIRECTORY|O_NOFOLLOW for every directory and O_NOFOLLOW for the leaf, |
| -// so no component under a user-owned tree can be a symlink. |
| +// so no component under a user-owned tree can be a symlink. O_NONBLOCK is |
| +// on every open: it is a no-op for regular files and directories, and it is |
| +// what stops a FIFO planted at the leaf by the sandboxed user from parking |
| +// root inside open(2) forever while it holds the apply lock. The callers' |
| +// IsRegular() check then refuses the FIFO. |
| func openBeneath(root string, parts ...string) (*os.File, error) { |
| - fd, err := syscall.Open(root, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_CLOEXEC, 0) |
| + fd, err := syscall.Open(root, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NONBLOCK|syscall.O_CLOEXEC, 0) |
| if err != nil { |
| return nil, fmt.Errorf("open %s: %w", root, err) |
| } |
| for i, part := range parts { |
| - flags := syscall.O_RDONLY | syscall.O_NOFOLLOW | syscall.O_CLOEXEC |
| + flags := syscall.O_RDONLY | syscall.O_NOFOLLOW | syscall.O_NONBLOCK | syscall.O_CLOEXEC |
| if i < len(parts)-1 { |
| flags |= syscall.O_DIRECTORY |
| } |
diff --git a/internal/host/systemapply_unix_test.go b/internal/host/systemapply_unix_test.go| new file mode 100644 |
| index 000000000..9895f89dc |
| --- /dev/null |
| +++ b/internal/host/systemapply_unix_test.go |
| @@ -0,0 +1,63 @@ |
| +//go:build !windows |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "os" |
| + "strings" |
| + "syscall" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// The sandboxed agent owns /data. A FIFO planted where root expects a |
| +// regular file would park root inside open(2) — while it holds the apply |
| +// lock, wedging every future apply on the box. Both reads under /data must |
| +// come back, refusing, in no time at all. |
| +func TestApplyNeverBlocksOnAPlantedFIFO(t *testing.T) { |
| + within := func(t *testing.T, what string, fn func() error) { |
| + t.Helper() |
| + done := make(chan error, 1) |
| + go func() { done <- fn() }() |
| + select { |
| + case err := <-done: |
| + if err == nil { |
| + t.Fatalf("%s: a FIFO was accepted", what) |
| + } |
| + if !strings.Contains(err.Error(), "regular file") { |
| + t.Fatalf("%s: err = %v, want the not-a-regular-file refusal", what, err) |
| + } |
| + case <-time.After(2 * time.Second): |
| + t.Fatalf("%s: parked on a FIFO — root would hold the apply lock forever", what) |
| + } |
| + } |
| + |
| + // The request. |
| + f := newApplyFixture(t, "stable", "v1") |
| + os.MkdirAll(systemDataDir(f.a.dataDir), 0o755) |
| + if err := syscall.Mkfifo(systemRequestPath(f.a.dataDir), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + within(t, "readRequest", func() error { _, err := f.a.readRequest(); return err }) |
| + |
| + // The artifact cache. |
| + f2 := newApplyFixture(t, "stable", "v1") |
| + f2.promote("v2", []byte("#!/bin/sh\necho carlos v2\n"), false) |
| + os.Remove(systemCachePath(f2.a.dataDir, "v2")) |
| + if err := syscall.Mkfifo(systemCachePath(f2.a.dataDir, "v2"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + p, err := f2.a.verifyPointer(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req, err := f2.a.readRequest() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + within(t, "stageCandidate", func() error { |
| + _, err := f2.a.stageCandidate(context.Background(), p, req) |
| + return err |
| + }) |
| +} |
diff --git a/internal/host/systemapply_windows.go b/internal/host/systemapply_windows.go| index 1dec23f4d..f28035db3 100644 |
| --- a/internal/host/systemapply_windows.go |
| +++ b/internal/host/systemapply_windows.go |
| @@ -2,17 +2,14 @@ |
| package host |
| -import ( |
| - "errors" |
| - "os" |
| -) |
| +import "os" |
| // The platform self-update is a root oneshot on a Linux box; the Windows |
| // build exists only so the carlos CLI compiles for workstation use. Both |
| // primitives refuse rather than pretend: an apply that cannot lock, or that |
| -// cannot open a path without following symlinks, must do nothing at all. |
| - |
| -var errSystemUnsupported = errors.New("carlos system apply: unsupported on this platform") |
| +// cannot open a path without following symlinks, must do nothing at all — |
| +// and Run tells this refusal apart from a lock another apply is holding, so |
| +// an unsupported platform is an error rather than a quiet no-op. |
| func openBeneath(root string, parts ...string) (*os.File, error) { |
| return nil, errSystemUnsupported |
diff --git a/internal/host/systemstate.go b/internal/host/systemstate.go| index 560c28e3f..1bef0ebe9 100644 |
| --- a/internal/host/systemstate.go |
| +++ b/internal/host/systemstate.go |
| @@ -110,8 +110,16 @@ func systemDataDir(dd string) string { return filepath.Join(dd, "system", system |
| func systemRequestPath(dd string) string { |
| return filepath.Join(systemDataDir(dd), "requested.json") |
| } |
| + |
| +// systemCacheParts are the components of the agent's cache entry below the |
| +// data dir. Root walks them one at a time (openBeneath) rather than opening |
| +// the joined path, so the two must come from here and never drift apart. |
| +func systemCacheParts(version string) []string { |
| + return []string{"system", systemComponent, version, "carlos"} |
| +} |
| + |
| func systemCachePath(dd, version string) string { |
| - return filepath.Join(systemDataDir(dd), version, "carlos") |
| + return filepath.Join(append([]string{dd}, systemCacheParts(version)...)...) |
| } |
| func systemRootDir(optDir string) string { return filepath.Join(optDir, "system", systemComponent) } |
| func systemPrevPath(optDir string) string { return filepath.Join(optDir, "carlos.prev") } |
| @@ -186,7 +194,10 @@ func readSystemSeen(optDir string) (systemSeen, error) { |
| s := systemSeen{} |
| err := readJSONFile(filepath.Join(systemRootDir(optDir), "seen.json"), &s) |
| if errors.Is(err, os.ErrNotExist) { |
| - return s, nil |
| + return systemSeen{}, nil |
| + } |
| + if s == nil { |
| + s = systemSeen{} // a literal `null` on disk unmarshals to a nil map |
| } |
| return s, err |
| } |
diff --git a/internal/release/system.go b/internal/release/system.go| index d4a8529e7..88c60b18d 100644 |
| --- a/internal/release/system.go |
| +++ b/internal/release/system.go |
| @@ -54,6 +54,14 @@ const SystemComponentRouter = "router" |
| // moved: there is nothing older to go back to. |
| var ErrNoSystemHistory = errors.New("system pointer: nothing to roll back to") |
| +// ErrBadSystemPointer marks every way an object on the wire can fail to be |
| +// this component and channel's signed pointer: a torn wrapper, a grant, a |
| +// signature that does not verify, a payload that does not validate. Callers |
| +// that must tell "this is not authority" from "the bucket is unreachable" — |
| +// the box's self-update refuses on the first and defers on the second — test |
| +// with errors.Is instead of reading the message. |
| +var ErrBadSystemPointer = errors.New("system pointer does not verify") |
| + |
| const ( |
| systemSpreadMax = 24 * time.Hour |
| systemSpreadDefault = 5 * time.Minute |
| @@ -166,10 +174,20 @@ func signSystemPointer(sg Signer, p *SystemPointer) ([]byte, error) { |
| return json.MarshalIndent(signedWrapper{Payload: b64, Sig: base64.StdEncoding.EncodeToString(sig)}, "", " ") |
| } |
| -// verifySystemPointer is the single parse site for system pointers. A grant |
| -// on the wire is refused before the signature is looked at (the production |
| -// flag's rule, production.go). |
| +// verifySystemPointer is the single parse site for system pointers, and so |
| +// the one place ErrBadSystemPointer is attached: every refusal below reaches |
| +// the caller wrapped, whatever its own wording. |
| func verifySystemPointer(pub ed25519.PublicKey, wrapper []byte, wantComponent, wantChannel string) (*SystemPointer, error) { |
| + p, err := parseSystemPointer(pub, wrapper, wantComponent, wantChannel) |
| + if err != nil { |
| + return nil, fmt.Errorf("%w: %v", ErrBadSystemPointer, err) |
| + } |
| + return p, nil |
| +} |
| + |
| +// parseSystemPointer does the work. A grant on the wire is refused before |
| +// the signature is looked at (the production flag's rule, production.go). |
| +func parseSystemPointer(pub ed25519.PublicKey, wrapper []byte, wantComponent, wantChannel string) (*SystemPointer, error) { |
| var sw signedWrapper |
| if err := json.Unmarshal(wrapper, &sw); err != nil { |
| return nil, fmt.Errorf("system pointer %s/%s: %w", wantComponent, wantChannel, err) |
diff --git a/internal/release/system_test.go b/internal/release/system_test.go| index dcb2a9b2b..bc5ee372b 100644 |
| --- a/internal/release/system_test.go |
| +++ b/internal/release/system_test.go |
| @@ -148,8 +148,10 @@ func TestSystemPointerTamperAndCrossChannelRefused(t *testing.T) { |
| if _, err := s.Put(ctx, SystemPointerKey("router", "stable"), body, ""); err != nil { |
| t.Fatal(err) |
| } |
| - if _, _, err := GetSystemPointer(ctx, s, pub, "router", "stable"); err == nil { |
| - t.Fatal("a canary pointer verified as stable") |
| + // Refusals are typed, not stringly: a box tells "not authority" (refuse) |
| + // from "the bucket is unreachable" (defer) with errors.Is. |
| + if _, _, err := GetSystemPointer(ctx, s, pub, "router", "stable"); err == nil || !errors.Is(err, ErrBadSystemPointer) { |
| + t.Fatalf("a canary pointer verified as stable: err = %v", err) |
| } |
| // Tampered signature. |
| var sw signedWrapper |
| @@ -160,8 +162,8 @@ func TestSystemPointerTamperAndCrossChannelRefused(t *testing.T) { |
| if _, err := s.Put(ctx, SystemPointerKey("router", "canary"), bad, etag); err != nil { |
| t.Fatal(err) |
| } |
| - if _, _, err := GetSystemPointer(ctx, s, pub, "router", "canary"); err == nil { |
| - t.Fatal("tampered signature verified") |
| + if _, _, err := GetSystemPointer(ctx, s, pub, "router", "canary"); err == nil || !errors.Is(err, ErrBadSystemPointer) { |
| + t.Fatalf("tampered signature verified: err = %v", err) |
| } |
| } |