From f0605f1c3f52882d92085a520981082b08167389 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 17 Jun 2026 15:32:41 +0200 Subject: [PATCH] Harden CLI local vault spool durability --- cmd/attesto/main.go | 24 ++++++++++++++++++-- cmd/attesto/main_test.go | 48 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/cmd/attesto/main.go b/cmd/attesto/main.go index ae74a0c..d31ed85 100644 --- a/cmd/attesto/main.go +++ b/cmd/attesto/main.go @@ -1051,6 +1051,12 @@ func (a *app) localVaultSpool(args []string) error { if err := fs.Parse(args); err != nil { return err } + if strings.TrimSpace(*spoolFile) == "" { + return errors.New("--spool-file is required") + } + if strings.TrimSpace(*file) == "" { + return errors.New("--file is required") + } raw, err := os.ReadFile(*file) if err != nil { return err @@ -1066,12 +1072,26 @@ func (a *app) localVaultSpool(args []string) error { if err != nil { return err } - defer fh.Close() canonical, err := attesto.CanonicalJSON(obj) if err != nil { + _ = fh.Close() return err } - if _, err := fh.Write(append(canonical, '\n')); err != nil { + line := append(canonical, '\n') + written, err := fh.Write(line) + if err != nil { + _ = fh.Close() + return err + } + if written != len(line) { + _ = fh.Close() + return fmt.Errorf("short spool write: wrote %d of %d bytes", written, len(line)) + } + if err := fh.Sync(); err != nil { + _ = fh.Close() + return err + } + if err := fh.Close(); err != nil { return err } return a.write(map[string]any{"ok": true, "spoolFile": *spoolFile}) diff --git a/cmd/attesto/main_test.go b/cmd/attesto/main_test.go index d519c76..5e2d61f 100644 --- a/cmd/attesto/main_test.go +++ b/cmd/attesto/main_test.go @@ -158,6 +158,54 @@ func TestConfigSetRedactsSecrets(t *testing.T) { } } +func TestLocalVaultSpoolAndStatus(t *testing.T) { + dir := t.TempDir() + eventFile := filepath.Join(dir, "event.json") + spoolFile := filepath.Join(dir, "spool", "events.jsonl") + if err := os.WriteFile(eventFile, []byte(`{"z":2,"a":1}`), 0o600); err != nil { + t.Fatal(err) + } + var stdout, stderr bytes.Buffer + code := run([]string{"--json", "local-vault", "spool", "--spool-file", spoolFile, "--file", eventFile}, &stdout, &stderr, testEnv(t, nil)) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr.String()) + } + raw, err := os.ReadFile(spoolFile) + if err != nil { + t.Fatal(err) + } + if string(raw) != "{\"a\":1,\"z\":2}\n" { + t.Fatalf("spool must write canonical JSONL, got %q", raw) + } + info, err := os.Stat(spoolFile) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("spool mode = %o, want 600", info.Mode().Perm()) + } + stdout.Reset() + stderr.Reset() + code = run([]string{"--json", "local-vault", "status", "--spool-file", spoolFile}, &stdout, &stderr, testEnv(t, nil)) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), `"events": 1`) || !strings.Contains(stdout.String(), `"ok": true`) { + t.Fatalf("unexpected status output: %s", stdout.String()) + } +} + +func TestLocalVaultSpoolRequiresPaths(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{"--json", "local-vault", "spool", "--spool-file", filepath.Join(t.TempDir(), "events.jsonl")}, &stdout, &stderr, testEnv(t, nil)) + if code == 0 { + t.Fatal("missing --file must fail") + } + if !strings.Contains(stderr.String(), "--file is required") { + t.Fatalf("missing required path error: %s", stderr.String()) + } +} + func TestStreamsCreateCallsAPI(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v2/streams" || r.Method != http.MethodPost {