carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 4415e1ba85bcf8e45fd32ca7351cecb643e00bc0 parent ba7c661
4 files changed, +247 −10
  • internal/activator/instancekey.go +57 −4
  • internal/activator/instancekey_test.go +186 −0
  • internal/activator/ownenv_other.go +1 −2
  • internal/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
}