activator: undo a partial instance-key append, test the append open's own link refusal, and name the fsync-after-EIO limit (task 2 review fixes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+247
−10
internal/activator/instancekey.go+57 −4internal/activator/instancekey_test.go+186 −0internal/activator/ownenv_other.go+1 −2internal/activator/ownenv_unix.go+3 −4
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.015s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 317.372s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.998s
ok github.com/carlosframework/platform/internal/activator 136.197s
ok github.com/carlosframework/platform/internal/agentsock 0.027s
ok github.com/carlosframework/platform/internal/alerting 0.008s
ok github.com/carlosframework/platform/internal/analytics 0.031s
ok github.com/carlosframework/platform/internal/appconfig 2.326s
ok github.com/carlosframework/platform/internal/apperr 0.173s
ok github.com/carlosframework/platform/internal/applog 0.201s
ok github.com/carlosframework/platform/internal/awsinv 0.007s
ok github.com/carlosframework/platform/internal/boxchoice 0.007s
ok github.com/carlosframework/platform/internal/boxid 0.817s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.011s
ok github.com/carlosframework/platform/internal/caps 0.015s
ok github.com/carlosframework/platform/internal/certwarm 0.003s
ok github.com/carlosframework/platform/internal/cgroup 0.200s
ok github.com/carlosframework/platform/internal/changelog 0.401s
ok github.com/carlosframework/platform/internal/clamd 0.059s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 320.527s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 6.119s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.015s
ok github.com/carlosframework/platform/internal/dbverify 0.005s
ok github.com/carlosframework/platform/internal/dnsrec 0.004s
ok github.com/carlosframework/platform/internal/docsite 0.009s
ok github.com/carlosframework/platform/internal/domains 2.346s
ok github.com/carlosframework/platform/internal/doorkey 0.012s
ok github.com/carlosframework/platform/internal/economics 0.007s
ok github.com/carlosframework/platform/internal/edge 26.625s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.010s
ok github.com/carlosframework/platform/internal/fleetapi 0.322s
ok github.com/carlosframework/platform/internal/gate 1.855s
ok github.com/carlosframework/platform/internal/genselect 0.039s
ok github.com/carlosframework/platform/internal/geo 0.045s
ok github.com/carlosframework/platform/internal/geoip 0.063s
ok github.com/carlosframework/platform/internal/host 46.205s
ok github.com/carlosframework/platform/internal/hostactors 0.669s
ok github.com/carlosframework/platform/internal/hostbox 1.649s
ok github.com/carlosframework/platform/internal/hostclaim 0.463s
ok github.com/carlosframework/platform/internal/hostevidence 0.006s
ok github.com/carlosframework/platform/internal/hostexec 0.325s
ok github.com/carlosframework/platform/internal/hostfence 0.075s
ok github.com/carlosframework/platform/internal/hostfiles 4.172s
ok github.com/carlosframework/platform/internal/hostfloor 0.015s
ok github.com/carlosframework/platform/internal/hostguard 0.156s
ok github.com/carlosframework/platform/internal/hostops 1.946s
ok github.com/carlosframework/platform/internal/hostquarantine 0.007s
ok github.com/carlosframework/platform/internal/hostresolve 0.290s
ok github.com/carlosframework/platform/internal/hostwrite 0.012s
ok github.com/carlosframework/platform/internal/infraview 0.012s
ok github.com/carlosframework/platform/internal/instancectl 1.719s
ok github.com/carlosframework/platform/internal/instanceexec 2.773s
ok github.com/carlosframework/platform/internal/instancefs 0.015s
ok github.com/carlosframework/platform/internal/instancekey 0.010s
ok github.com/carlosframework/platform/internal/layout 1.788s
ok github.com/carlosframework/platform/internal/lease 9.600s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.027s
ok github.com/carlosframework/platform/internal/ledger 0.032s
ok github.com/carlosframework/platform/internal/mail 0.323s
ok github.com/carlosframework/platform/internal/maintenance 0.119s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.645s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.189s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 21.848s
ok github.com/carlosframework/platform/internal/moves/archive 1.090s
ok github.com/carlosframework/platform/internal/objstore 0.404s
ok github.com/carlosframework/platform/internal/ops 0.175s
ok github.com/carlosframework/platform/internal/park 0.393s
ok github.com/carlosframework/platform/internal/privatefile 0.005s
ok github.com/carlosframework/platform/internal/provision 3.520s
ok github.com/carlosframework/platform/internal/publicrelease 0.014s
ok github.com/carlosframework/platform/internal/registry 3.829s
ok github.com/carlosframework/platform/internal/relay 7.268s
ok github.com/carlosframework/platform/internal/release 19.009s
ok github.com/carlosframework/platform/internal/rollout 1.658s
ok github.com/carlosframework/platform/internal/rootaws 0.008s
ok github.com/carlosframework/platform/internal/s3compat 0.006s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.452s
ok github.com/carlosframework/platform/internal/sim/hostlife 4.168s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.367s
ok github.com/carlosframework/platform/internal/status 0.045s
ok github.com/carlosframework/platform/internal/steering 0.009s
ok github.com/carlosframework/platform/internal/stopevidence 0.262s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.009s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.002s
ok github.com/carlosframework/platform/internal/usage 0.008s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.842s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.023s
=== RUN TestT4StorageProbeMinio
2026/10/11 14:25:03 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DD7FC350638A9E, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.00s)
PASS
ok github.com/carlosframework/platform/internal/host 0.015s
minio contract: PASS
diff --git a/internal/activator/instancekey.go b/internal/activator/instancekey.go| index 2f614677c..7ae7348a5 100644 |
| --- a/internal/activator/instancekey.go |
| +++ b/internal/activator/instancekey.go |
| @@ -22,9 +22,16 @@ package activator |
| // 0600 (fchmod on its descriptor), the file is fsynced and so is its |
| // directory, when the key was already there too. A sync that failed |
| // once is redone by the next ensure, never skipped because the bytes |
| -// are already there; |
| +// are already there. The known limit: once fsync has returned EIO, |
| +// Linux may mark the pages clean, so a later fsync's success does not |
| +// prove the bytes reached the disk. The retry narrows the gap; it |
| +// cannot close it, and no create-only design can; |
| +// - a mint that fails part way through its append is undone: the file |
| +// is truncated back to its length before the append and fsynced, so a |
| +// short key line never survives to be read as present. Those bytes |
| +// were never reported, so removing them rewrites no key; |
| // - the value is never logged, never put in an error, and never leaves |
| -// the file except in the launch environment. |
| +// the file except in the launch environment; |
| // - a flat row's own file is opened O_NOFOLLOW on every path, read and |
| // append alike (openOwnEnvNoFollow): a symlink there is refused, never |
| // followed into a file a same-uid sibling chose. A nested row's file |
| @@ -112,6 +119,18 @@ func syncDir(dir string) error { |
| return err |
| } |
| +// instanceKeyWrite is the mint's append, a seam so a test can make it fail |
| +// part way (a short write, ENOSPC). Read and replaced only under |
| +// adminTokenMu. |
| +var instanceKeyWrite = func(f *os.File, b []byte) (int, error) { return f.Write(b) } |
| + |
| +// instanceKeyBeforeOpen, when set, runs under adminTokenMu after the read |
| +// and the decision and before the ensure opens the file to append or |
| +// settle: a test hook to change the file between the two, so the open's |
| +// own symlink refusal is pinned and not only the read's. nil outside |
| +// tests. |
| +var instanceKeyBeforeOpen func(path string) |
| + |
| func mintInstanceKey() (string, error) { |
| raw := make([]byte, instanceKeyBytes) |
| if _, err := rand.Read(raw); err != nil { |
| @@ -278,6 +297,9 @@ func ensureInstanceKey(dir, name string, beneath, create bool) (InstanceKeyOutco |
| // Present is not a no-op: a mint whose sync failed left these |
| // bytes behind, and this call is the retry that makes them |
| // durable before anything is started with them. |
| + if instanceKeyBeforeOpen != nil { |
| + instanceKeyBeforeOpen(path) |
| + } |
| f, err := openInstanceEnv(dir, name, beneath, false) |
| if err != nil { |
| return InstanceKeyPresent, fmt.Errorf("open %s: %w", path, err) |
| @@ -292,6 +314,9 @@ func ensureInstanceKey(dir, name string, beneath, create bool) (InstanceKeyOutco |
| if err != nil { |
| return InstanceKeyPresent, fmt.Errorf("mint instance key for %s: %w", path, err) |
| } |
| + if instanceKeyBeforeOpen != nil { |
| + instanceKeyBeforeOpen(path) |
| + } |
| f, err := openInstanceEnv(dir, name, beneath, create) |
| if errors.Is(err, os.ErrNotExist) && !create { |
| return InstanceKeyNoEnvFile, nil // removed since the read: nothing to backfill |
| @@ -300,12 +325,24 @@ func ensureInstanceKey(dir, name string, beneath, create bool) (InstanceKeyOutco |
| return InstanceKeyPresent, fmt.Errorf("open %s: %w", path, err) |
| } |
| defer f.Close() |
| + // The length the append starts from, taken from the open descriptor: |
| + // what a failed append is undone to. Under adminTokenMu it is |
| + // len(raw); reading it here also keeps any byte another process |
| + // appended since the read. |
| + st, err := f.Stat() |
| + if err != nil { |
| + return InstanceKeyPresent, fmt.Errorf("stat %s: %w", path, err) |
| + } |
| prefix := "" |
| if len(raw) > 0 && raw[len(raw)-1] != '\n' { |
| prefix = "\n" |
| } |
| - if _, err := fmt.Fprintf(f, "%s%s=%s\n", prefix, InstanceKeyVar, minted); err != nil { |
| - return InstanceKeyPresent, fmt.Errorf("write %s: %w", path, err) |
| + line := []byte(prefix + InstanceKeyVar + "=" + minted + "\n") |
| + if n, err := instanceKeyWrite(f, line); err != nil || n != len(line) { |
| + if err == nil { |
| + err = io.ErrShortWrite |
| + } |
| + return InstanceKeyPresent, undoInstanceKeyAppend(f, st.Size(), path, err) |
| } |
| if err := settleInstanceEnv(f, dir, path); err != nil { |
| return InstanceKeyPresent, err |
| @@ -313,6 +350,22 @@ func ensureInstanceKey(dir, name string, beneath, create bool) (InstanceKeyOutco |
| return InstanceKeyMinted, nil |
| } |
| +// undoInstanceKeyAppend removes a failed append's bytes: it truncates f |
| +// back to size, the length before the append, and fsyncs, so a short key |
| +// line can never survive to be read as present. Nothing past size was |
| +// ever reported to anyone. Returns the write error, joined with any |
| +// failure to undo it. Caller holds adminTokenMu. |
| +func undoInstanceKeyAppend(f *os.File, size int64, path string, werr error) error { |
| + err := fmt.Errorf("write %s: %w", path, werr) |
| + if terr := f.Truncate(size); terr != nil { |
| + return errors.Join(err, fmt.Errorf("undo the partial write to %s: %w", path, terr)) |
| + } |
| + if serr := instanceKeySync.file(f); serr != nil { |
| + return errors.Join(err, fmt.Errorf("sync %s after undoing the partial write: %w", path, serr)) |
| + } |
| + return err |
| +} |
| + |
| // errInstanceEnvNeedsWrite is settleClaimInstanceEnv's answer for a file |
| // whose mode is not 0600: making it so is a write, which the claim path |
| // does only under its guard. |
diff --git a/internal/activator/instancekey_test.go b/internal/activator/instancekey_test.go| index 8e2bdae0e..949bc5ed1 100644 |
| --- a/internal/activator/instancekey_test.go |
| +++ b/internal/activator/instancekey_test.go |
| @@ -9,6 +9,7 @@ import ( |
| "path/filepath" |
| "strings" |
| "sync" |
| + "syscall" |
| "testing" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -454,3 +455,188 @@ func TestOpenOwnEnvNoFollowRefusesALinkForEveryFlagSet(t *testing.T) { |
| } |
| f.Close() |
| } |
| + |
| +// setInstanceKeySeams swaps the append and the before-open hook for one |
| +// test, under adminTokenMu as their readers require. A nil argument |
| +// leaves that seam as it is. |
| +func setInstanceKeySeams(t *testing.T, write func(*os.File, []byte) (int, error), beforeOpen func(string)) { |
| + t.Helper() |
| + adminTokenMu.Lock() |
| + oldWrite, oldHook := instanceKeyWrite, instanceKeyBeforeOpen |
| + if write != nil { |
| + instanceKeyWrite = write |
| + } |
| + if beforeOpen != nil { |
| + instanceKeyBeforeOpen = beforeOpen |
| + } |
| + adminTokenMu.Unlock() |
| + t.Cleanup(func() { |
| + adminTokenMu.Lock() |
| + instanceKeyWrite, instanceKeyBeforeOpen = oldWrite, oldHook |
| + adminTokenMu.Unlock() |
| + }) |
| +} |
| + |
| +// A mint whose append fails part way (a full disk) is undone: the file is |
| +// byte for byte what it was, so no short key line survives to be read as |
| +// present, and the next call mints a full key. |
| +func TestEnsureInstanceKeyUndoesAPartialAppend(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + before *string // nil: no file before the call |
| + write func(f *os.File, b []byte) (int, error) |
| + }{ |
| + {"nothing written, ENOSPC", ptr("A=1\n"), func(*os.File, []byte) (int, error) { return 0, syscall.ENOSPC }}, |
| + {"short key line, ENOSPC", ptr("A=1\n"), func(f *os.File, b []byte) (int, error) { |
| + n, _ := f.Write(b[:len(InstanceKeyVar)+5]) |
| + return n, syscall.ENOSPC |
| + }}, |
| + {"short after the fresh-line prefix, ENOSPC", ptr("A=1"), func(f *os.File, b []byte) (int, error) { |
| + n, _ := f.Write(b[:3]) |
| + return n, syscall.ENOSPC |
| + }}, |
| + {"short with no error", ptr("A=1\n"), func(f *os.File, b []byte) (int, error) { |
| + return f.Write(b[:len(b)-1]) |
| + }}, |
| + {"new file, short key line", nil, func(f *os.File, b []byte) (int, error) { |
| + n, _ := f.Write(b[:10]) |
| + return n, syscall.ENOSPC |
| + }}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + probe := probeInstanceKeySync(t) |
| + p := filepath.Join(t.TempDir(), "shop.example.com.env") |
| + if tc.before != nil { |
| + if err := os.WriteFile(p, []byte(*tc.before), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + setInstanceKeySeams(t, tc.write, nil) |
| + if _, err := EnsureInstanceKey(p); err == nil { |
| + t.Fatal("a mint whose append failed reported success") |
| + } |
| + raw, err := os.ReadFile(p) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := "" |
| + if tc.before != nil { |
| + want = *tc.before |
| + } |
| + if string(raw) != want { |
| + t.Fatalf("after the failed append the file is %q, want it undone to %q", raw, want) |
| + } |
| + if probe.file != 1 { |
| + t.Fatalf("the undo synced the file %d times, want once", probe.file) |
| + } |
| + setInstanceKeySeams(t, func(f *os.File, b []byte) (int, error) { return f.Write(b) }, nil) |
| + got, err := EnsureInstanceKey(p) |
| + if err != nil || got != InstanceKeyMinted { |
| + t.Fatalf("next ensure = %v, %v; want minted", got, err) |
| + } |
| + raw, _ = os.ReadFile(p) |
| + keys := keyLines(raw) |
| + if len(keys) != 1 || !wellFormedInstanceKey(keys[0]) { |
| + t.Fatalf("file = %q: want one full key", raw) |
| + } |
| + if !strings.HasPrefix(string(raw), want) { |
| + t.Fatalf("file = %q: the original bytes were lost", raw) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func ptr(s string) *string { return &s } |
| + |
| +// The append open refuses a link on its own, not only because the read |
| +// before it did: a link swapped in after a clean read (the hook) is |
| +// refused on the mint, the existing-key and the backfill paths, and its |
| +// target is left exactly as it was. |
| +func TestEnsureInstanceKeyOpenRefusesALinkSwappedInAfterTheRead(t *testing.T) { |
| + wellFormed := InstanceKeyVar + "=" + strings.Repeat("A", 43) + "=\n" |
| + for _, tc := range []struct { |
| + name string |
| + own string |
| + call func(b *ExecBackend, rt registry.Route, path string) error |
| + }{ |
| + {"mint", "A=1\n", func(_ *ExecBackend, _ registry.Route, path string) error { |
| + _, err := EnsureInstanceKey(path) |
| + return err |
| + }}, |
| + {"existing key", wellFormed, func(_ *ExecBackend, _ registry.Route, path string) error { |
| + _, err := EnsureInstanceKey(path) |
| + return err |
| + }}, |
| + {"backfill mint", "A=1\n", func(b *ExecBackend, rt registry.Route, _ string) error { |
| + _, err := b.BackfillInstanceKey(rt) |
| + return err |
| + }}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + b := &ExecBackend{DataDir: t.TempDir()} |
| + rt := registry.Route{Host: "shop.example.com", Socket: "/run/carlos/shop.sock"} |
| + path := filepath.Join(b.DataDir, rt.Host+".env") |
| + if err := os.WriteFile(path, []byte(tc.own), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + target := filepath.Join(t.TempDir(), "someone-elses-file") |
| + const targetBody = "B=2\n" |
| + if err := os.WriteFile(target, []byte(targetBody), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chmod(target, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + swapped := 0 |
| + setInstanceKeySeams(t, nil, func(p string) { |
| + if p != path { |
| + return |
| + } |
| + swapped++ |
| + if err := os.Remove(p); err != nil { |
| + t.Error(err) |
| + } |
| + if err := os.Symlink(target, p); err != nil { |
| + t.Error(err) |
| + } |
| + }) |
| + err := tc.call(b, rt, path) |
| + if swapped != 1 { |
| + t.Fatalf("the hook ran %d times, want once", swapped) |
| + } |
| + if !errors.Is(err, errOwnEnvSymlink) { |
| + t.Fatalf("err = %v, want errOwnEnvSymlink", err) |
| + } |
| + st, err := os.Stat(target) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if st.Mode().Perm() != 0o644 { |
| + t.Fatalf("mode of the symlink's TARGET = %o, want the untouched 644", st.Mode().Perm()) |
| + } |
| + if raw, _ := os.ReadFile(target); string(raw) != targetBody { |
| + t.Fatalf("the symlink's TARGET changed: %q", raw) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A refusal names the path once. |
| +func TestEnsureInstanceKeySymlinkErrorNamesThePathOnce(t *testing.T) { |
| + dir := t.TempDir() |
| + target := filepath.Join(dir, "target") |
| + if err := os.WriteFile(target, []byte("A=1\n"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + link := filepath.Join(dir, "shop.example.com.env") |
| + if err := os.Symlink(target, link); err != nil { |
| + t.Skipf("this filesystem will not make symlinks: %v", err) |
| + } |
| + _, err := EnsureInstanceKey(link) |
| + if !errors.Is(err, errOwnEnvSymlink) { |
| + t.Fatalf("err = %v, want errOwnEnvSymlink", err) |
| + } |
| + if n := strings.Count(err.Error(), link); n != 1 { |
| + t.Fatalf("error %q names the path %d times, want once", err, n) |
| + } |
| +} |
diff --git a/internal/activator/ownenv_other.go b/internal/activator/ownenv_other.go| index 035ea007b..1f22d2b77 100644 |
| --- a/internal/activator/ownenv_other.go |
| +++ b/internal/activator/ownenv_other.go |
| @@ -3,7 +3,6 @@ |
| package activator |
| import ( |
| - "fmt" |
| "os" |
| ) |
| @@ -14,7 +13,7 @@ import ( |
| // it is weaker than the unix open, which is the one that matters. |
| func openOwnEnvNoFollow(path string, flag int, perm os.FileMode) (*os.File, error) { |
| if fi, err := os.Lstat(path); err == nil && fi.Mode()&os.ModeSymlink != 0 { |
| - return nil, fmt.Errorf("%w: %s", errOwnEnvSymlink, path) |
| + return nil, errOwnEnvSymlink |
| } |
| return os.OpenFile(path, flag, perm) |
| } |
diff --git a/internal/activator/ownenv_unix.go b/internal/activator/ownenv_unix.go| index bc17196ba..f37c31789 100644 |
| --- a/internal/activator/ownenv_unix.go |
| +++ b/internal/activator/ownenv_unix.go |
| @@ -4,7 +4,6 @@ package activator |
| import ( |
| "errors" |
| - "fmt" |
| "os" |
| "golang.org/x/sys/unix" |
| @@ -13,12 +12,12 @@ import ( |
| // openOwnEnvNoFollow opens a flat row's own <host>.env and refuses a |
| // symlink at it in the open itself (O_NOFOLLOW), so there is no window |
| // between a check and the open for a same-uid sibling (spec §7.2) to plant |
| -// a link in. A refused link is errOwnEnvSymlink. A nested row's file needs |
| -// none of this: instancefs.OpenBeneath already refuses links. |
| +// a link in. A refused link is the bare errOwnEnvSymlink: the caller names |
| +// the path, once. A nested row's file needs none of this: instancefs.OpenBeneath already refuses links. |
| func openOwnEnvNoFollow(path string, flag int, perm os.FileMode) (*os.File, error) { |
| f, err := os.OpenFile(path, flag|unix.O_NOFOLLOW, perm) |
| if errors.Is(err, unix.ELOOP) { |
| - return nil, fmt.Errorf("%w: %s", errOwnEnvSymlink, path) |
| + return nil, errOwnEnvSymlink |
| } |
| return f, err |
| } |