release: a dangling proof policy symlink is an error, not D3 off (H2)
Absence is now decided by Lstat: only nothing at the path is off. A symlink whose target has gone, a directory or an unreadable file at the path is an error, which the gate reads as on and waiting for every named-unit route.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+31
−6
internal/release/proofpolicy.go+11 −6internal/release/proofpolicy_test.go+20 −0
diff --git a/internal/release/proofpolicy.go b/internal/release/proofpolicy.go| index b1c25d317..c18a36454 100644 |
| --- a/internal/release/proofpolicy.go |
| +++ b/internal/release/proofpolicy.go |
| @@ -7,10 +7,12 @@ |
| // 0644 and holds no secrets: app names, the prover name, public keys and a |
| // duration. The agent and root both read it at the start of every pass. |
| // |
| -// Absent: D3 is off. Anything else that does not parse, including an unknown |
| -// key, is an error, and the gate reads an error as "on and waiting for every |
| -// named-unit route": the box cannot tell which apps were listed, and a typo |
| -// must not open the gate. |
| +// Absent: D3 is off. Absent means nothing at all is at the path (Lstat says |
| +// it does not exist). Anything else that cannot be read or does not parse, |
| +// including a dangling symlink, a directory, an unknown key or data after the |
| +// policy object, is an error, and the gate reads an error as "on and waiting |
| +// for every named-unit route": the box cannot tell which apps were listed, |
| +// and a typo or a broken link must not open the gate. |
| package release |
| import ( |
| @@ -57,10 +59,13 @@ type RequireEntry struct { |
| func (e RequireEntry) PublicKey() ed25519.PublicKey { return e.pub } |
| func LoadProofPolicy(path string) (*ProofPolicy, error) { |
| - b, err := os.ReadFile(path) |
| - if errors.Is(err, os.ErrNotExist) { |
| + // Lstat, not the read's error, decides absence: a symlink whose target |
| + // has gone reads as ENOENT, and reading that as "D3 off" would let an |
| + // unproven build in because of a broken config. |
| + if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { |
| return nil, nil |
| } |
| + b, err := os.ReadFile(path) |
| if err != nil { |
| return nil, fmt.Errorf("proof policy %s: %w", path, err) |
| } |
diff --git a/internal/release/proofpolicy_test.go b/internal/release/proofpolicy_test.go| index 176860466..187001447 100644 |
| --- a/internal/release/proofpolicy_test.go |
| +++ b/internal/release/proofpolicy_test.go |
| @@ -82,3 +82,23 @@ func TestProofPolicyTrailingWhitespaceParses(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| + |
| +// Absent means nothing is at the path. A dangling symlink or a directory |
| +// there is a broken config, and a broken config is closed, not off. |
| +func TestProofPolicyBrokenPathIsAnError(t *testing.T) { |
| + dir := t.TempDir() |
| + dangling := filepath.Join(dir, "dangling.json") |
| + if err := os.Symlink(filepath.Join(dir, "gone.json"), dangling); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p, err := LoadProofPolicy(dangling); err == nil { |
| + t.Fatalf("dangling symlink = %+v, nil; want an error (closed), not off", p) |
| + } |
| + asDir := filepath.Join(dir, "dir.json") |
| + if err := os.Mkdir(asDir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p, err := LoadProofPolicy(asDir); err == nil { |
| + t.Fatalf("directory = %+v, nil; want an error (closed), not off", p) |
| + } |
| +} |