From faa58845d73c8ecef1ab697ae69a45dd0c608074 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Wed, 27 May 2026 17:20:35 -0700 Subject: [PATCH] test: push crypto + fsutil coverage to portable ceiling crypto: 92.0% -> 96.0% fsutil: 58.1% -> 61.3% total : 79.0% -> 82.7% New test files (zz_coverage_test.go in each package) cover: - LoadIdentity loose-permissions warn branch (chmod 0644 + capture slog default handler output) - LoadIdentity os.ReadFile non-NotExist error path (point at a directory; EISDIR is not IsNotExist) - LoadIdentity 0o600 negative case (no warning emitted) - Sign/Verify empty + 1MiB messages - Decode{Public,Private}Key on empty string (size check) - Concurrent LoadIdentity readers and SaveIdentity overwrite - AppendSync empty/nil/large payload, error-wrap message, 100-append ordering, open-against-directory - AtomicWrite rename-onto-non-empty-dir (open+write+sync+close all succeed, rename fails), empty/nil/large payload, mode 0600 preservation when overwriting 0o644, concurrent writers to distinct paths Also includes zz_save_test.go and zz_fsutil_test.go that were sitting untracked in the worktree -- they were already counted in the prior baseline and removing them would have regressed it. Unreachable in portable tests (~17% of fsutil, ~4% of crypto): - ed25519.GenerateKey error path: crypto/rand.Reader cannot fail - json.MarshalIndent of {string, string} struct: cannot fail - AppendSync / AtomicWrite f.Write / f.Sync / f.Close error branches after a successful open: no portable trigger on a healthy filesystem. macOS lacks /dev/full, opening a directory or socket fails at the open call (not at write), FIFO O_NONBLOCK isn't portable in Go's os package. Would require either a package-level injection point (out of scope: no source edits) or a synthetic ramdisk (overkill for a unit test). --- crypto/zz_coverage_test.go | 225 ++++++++++++++++++++++++++++ crypto/zz_save_test.go | 118 +++++++++++++++ fsutil/zz_coverage_test.go | 294 +++++++++++++++++++++++++++++++++++++ fsutil/zz_fsutil_test.go | 77 ++++++++++ 4 files changed, 714 insertions(+) create mode 100644 crypto/zz_coverage_test.go create mode 100644 crypto/zz_save_test.go create mode 100644 fsutil/zz_coverage_test.go create mode 100644 fsutil/zz_fsutil_test.go diff --git a/crypto/zz_coverage_test.go b/crypto/zz_coverage_test.go new file mode 100644 index 0000000..b32a46e --- /dev/null +++ b/crypto/zz_coverage_test.go @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package crypto + +import ( + "bytes" + "io" + "log/slog" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +// TestLoadIdentity_LoosePermissionsWarn covers the slog.Warn branch in +// LoadIdentity that fires when the on-disk file is group/other-readable. +// +// We swap the default slog handler for one that writes to a buffer so we +// can assert the warning was emitted, then restore it. +func TestLoadIdentity_LoosePermissionsWarn(t *testing.T) { + // NOTE: not t.Parallel() — we mutate the global slog default logger + // and need to restore it cleanly. + if runtime.GOOS == "windows" { + t.Skip("unix file permissions only") + } + + dir := t.TempDir() + path := filepath.Join(dir, "id.json") + id, err := GenerateIdentity() + if err != nil { + t.Fatalf("GenerateIdentity: %v", err) + } + if err := SaveIdentity(path, id); err != nil { + t.Fatalf("SaveIdentity: %v", err) + } + // Loosen permissions so the warn branch trips. + if err := os.Chmod(path, 0o644); err != nil { + t.Fatalf("Chmod: %v", err) + } + + var buf bytes.Buffer + origLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(origLogger) }) + + loaded, err := LoadIdentity(path) + if err != nil { + t.Fatalf("LoadIdentity: %v", err) + } + if loaded == nil { + t.Fatal("LoadIdentity returned nil") + } + if !strings.Contains(buf.String(), "loose permissions") { + t.Fatalf("expected 'loose permissions' warning, got log: %q", buf.String()) + } + if !strings.Contains(buf.String(), "level=WARN") { + t.Fatalf("expected WARN level, got log: %q", buf.String()) + } +} + +// TestLoadIdentity_ReadFileNonNotExistError covers the os.ReadFile +// error branch where the error is *not* IsNotExist. Pointing the path +// at a directory triggers EISDIR from read(2), which os.IsNotExist +// returns false for, so the wrapped "read identity" path is exercised. +func TestLoadIdentity_ReadFileNonNotExistError(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("EISDIR is unix-specific") + } + dir := t.TempDir() + // `dir` itself is a directory; os.Stat succeeds, os.ReadFile fails + // with a non-NotExist error. The mode bits on a directory include + // the executable bit for group/other (0o755 typical for TempDir), + // which is harmless for this test — slog default discards. + _, err := LoadIdentity(dir) + if err == nil { + t.Fatal("expected read error against a directory path") + } + if !strings.Contains(err.Error(), "read identity") { + t.Fatalf("error %q missing 'read identity' wrap", err) + } +} + +// TestLoadIdentity_PermsExactly600NoWarn confirms the *negative* case: +// a strict 0o600 file does NOT trip the warn branch. +func TestLoadIdentity_PermsExactly600NoWarn(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix file permissions only") + } + dir := t.TempDir() + path := filepath.Join(dir, "id.json") + id, _ := GenerateIdentity() + if err := SaveIdentity(path, id); err != nil { + t.Fatalf("SaveIdentity: %v", err) + } + // SaveIdentity already writes 0o600 but be explicit. + if err := os.Chmod(path, 0o600); err != nil { + t.Fatalf("Chmod: %v", err) + } + + var buf bytes.Buffer + origLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(origLogger) }) + + if _, err := LoadIdentity(path); err != nil { + t.Fatalf("LoadIdentity: %v", err) + } + if strings.Contains(buf.String(), "loose permissions") { + t.Fatalf("did not expect warn for 0o600, got log: %q", buf.String()) + } +} + +// TestSignVerify_EmptyMessage edge: ed25519 over zero-length input is +// well-defined; ensure our wrappers don't choke. +func TestSignVerify_EmptyMessage(t *testing.T) { + t.Parallel() + id, _ := GenerateIdentity() + sig := id.Sign(nil) + if !Verify(id.PublicKey, nil, sig) { + t.Fatal("Verify rejected signature over empty message") + } + if !Verify(id.PublicKey, []byte{}, sig) { + t.Fatal("Verify rejected signature over []byte{} (same as nil)") + } +} + +// TestSignVerify_LargeMessage exercises the path with a sizeable payload +// (1 MiB) to make sure neither Sign nor Verify imposes a hidden cap. +func TestSignVerify_LargeMessage(t *testing.T) { + t.Parallel() + id, _ := GenerateIdentity() + msg := bytes.Repeat([]byte("A"), 1<<20) + sig := id.Sign(msg) + if !Verify(id.PublicKey, msg, sig) { + t.Fatal("Verify rejected large-message signature") + } +} + +// TestEncodeDecodePublicKey_EmptyString hits the base64-of-empty case: +// base64 decodes to an empty []byte, which fails the size check. +func TestEncodeDecodePublicKey_EmptyString(t *testing.T) { + t.Parallel() + _, err := DecodePublicKey("") + if err == nil { + t.Fatal("expected size error on empty string") + } + if !strings.Contains(err.Error(), "invalid public key size") { + t.Fatalf("error %q missing size-error wrap", err) + } +} + +func TestEncodeDecodePrivateKey_EmptyString(t *testing.T) { + t.Parallel() + _, err := DecodePrivateKey("") + if err == nil { + t.Fatal("expected size error on empty string") + } + if !strings.Contains(err.Error(), "invalid private key size") { + t.Fatalf("error %q missing size-error wrap", err) + } +} + +// TestSaveLoad_ConcurrentReaders is a smoke test: many goroutines load +// the same identity file in parallel. SaveIdentity is a one-shot writer; +// LoadIdentity should be safely callable in parallel. +func TestSaveLoad_ConcurrentReaders(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "id.json") + id, _ := GenerateIdentity() + if err := SaveIdentity(path, id); err != nil { + t.Fatalf("SaveIdentity: %v", err) + } + + var wg sync.WaitGroup + errs := make(chan error, 32) + for i := 0; i < 32; i++ { + wg.Add(1) + go func() { + defer wg.Done() + got, err := LoadIdentity(path) + if err != nil { + errs <- err + return + } + if !got.PublicKey.Equal(id.PublicKey) { + errs <- io.ErrUnexpectedEOF // sentinel + } + }() + } + wg.Wait() + close(errs) + for e := range errs { + t.Errorf("concurrent LoadIdentity: %v", e) + } +} + +// TestSaveLoad_OverwriteExisting ensures SaveIdentity over an existing +// file replaces it with the new keypair (no append, no merge). +func TestSaveLoad_OverwriteExisting(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "id.json") + id1, _ := GenerateIdentity() + id2, _ := GenerateIdentity() + if err := SaveIdentity(path, id1); err != nil { + t.Fatalf("first SaveIdentity: %v", err) + } + if err := SaveIdentity(path, id2); err != nil { + t.Fatalf("overwrite SaveIdentity: %v", err) + } + got, err := LoadIdentity(path) + if err != nil { + t.Fatalf("LoadIdentity: %v", err) + } + if !got.PublicKey.Equal(id2.PublicKey) { + t.Fatal("overwrite did not persist the second identity") + } + if got.PublicKey.Equal(id1.PublicKey) { + t.Fatal("overwrite still returned the first identity") + } +} diff --git a/crypto/zz_save_test.go b/crypto/zz_save_test.go new file mode 100644 index 0000000..3bc6cb0 --- /dev/null +++ b/crypto/zz_save_test.go @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package crypto + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSaveIdentity_HappyAndLoadRoundtrip(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "subdir", "id.json") + + id, err := GenerateIdentity() + if err != nil { + t.Fatalf("GenerateIdentity: %v", err) + } + if err := SaveIdentity(path, id); err != nil { + t.Fatalf("SaveIdentity: %v", err) + } + // File created with mode 0600 + parent dir created. + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if perm := info.Mode().Perm(); perm != 0600 { + t.Errorf("perm = %o, want 0600", perm) + } + + // Load roundtrips. + got, err := LoadIdentity(path) + if err != nil { + t.Fatalf("LoadIdentity: %v", err) + } + if got == nil { + t.Fatal("LoadIdentity returned nil") + } + if !got.PublicKey.Equal(id.PublicKey) { + t.Error("PublicKey mismatch after roundtrip") + } +} + +func TestSaveIdentity_MkdirFails(t *testing.T) { + t.Parallel() + // /dev/null is a file, not a dir — MkdirAll under it fails. + err := SaveIdentity("/dev/null/cannot/id.json", &Identity{}) + if err == nil { + t.Error("expected mkdir error") + } +} + +func TestSaveIdentity_WriteFails(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Pre-create a directory at the file path → WriteFile fails because + // it can't open a directory for writing. + path := filepath.Join(dir, "id.json") + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatalf("setup: %v", err) + } + id, _ := GenerateIdentity() + if err := SaveIdentity(path, id); err == nil { + t.Error("expected write error against a directory path") + } +} + +func TestLoadIdentity_MissingFileReturnsNil(t *testing.T) { + t.Parallel() + got, err := LoadIdentity("/no/such/path/id.json") + if err != nil { + t.Errorf("missing file: err = %v, want nil", err) + } + if got != nil { + t.Errorf("missing file: got %v, want nil", got) + } +} + +func TestLoadIdentity_BadJSON(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "bad.json") + if err := os.WriteFile(path, []byte("not json"), 0600); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := LoadIdentity(path); err == nil { + t.Error("expected JSON parse error") + } +} + +func TestLoadIdentity_BadPrivateKey(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "id.json") + if err := os.WriteFile(path, []byte(`{"private_key":"!!!","public_key":"AA=="}`), 0600); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := LoadIdentity(path); err == nil { + t.Error("expected decode error") + } +} + +func TestLoadIdentity_PubMismatch(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "id.json") + // Build a valid private key but a public key from a *different* identity. + id1, _ := GenerateIdentity() + id2, _ := GenerateIdentity() + body := `{"private_key":"` + EncodePrivateKey(id1.PrivateKey) + `","public_key":"` + EncodePublicKey(id2.PublicKey) + `"}` + if err := os.WriteFile(path, []byte(body), 0600); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := LoadIdentity(path); err == nil { + t.Error("expected pub-key mismatch error") + } +} diff --git a/fsutil/zz_coverage_test.go b/fsutil/zz_coverage_test.go new file mode 100644 index 0000000..12a480f --- /dev/null +++ b/fsutil/zz_coverage_test.go @@ -0,0 +1,294 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package fsutil + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +// TestAppendSync_EmptyPayload — an empty []byte should still create the +// file (O_CREATE) and not error. f.Write of zero bytes is a no-op. +func TestAppendSync_EmptyPayload(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "empty.log") + if err := AppendSync(path, []byte{}); err != nil { + t.Fatalf("AppendSync(empty): %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if info.Size() != 0 { + t.Fatalf("size = %d, want 0", info.Size()) + } + // Mode 0600. + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("perm = %o, want 0600", perm) + } +} + +// TestAppendSync_NilPayload — nil should behave identically to empty. +func TestAppendSync_NilPayload(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "nil.log") + if err := AppendSync(path, nil); err != nil { + t.Fatalf("AppendSync(nil): %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if info.Size() != 0 { + t.Fatalf("size = %d, want 0", info.Size()) + } +} + +// TestAppendSync_LargePayload writes a sizeable blob in one shot and +// verifies fsync wrote the whole thing. +func TestAppendSync_LargePayload(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "big.log") + payload := bytes.Repeat([]byte("Z"), 256<<10) // 256 KiB + if err := AppendSync(path, payload); err != nil { + t.Fatalf("AppendSync: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("payload mismatch: got %d bytes, want %d", len(got), len(payload)) + } +} + +// TestAppendSync_OpenErrorMessage validates the error wrap, not just +// that an error occurred. Pinning the message protects callers that +// errors.Is / strings.Contains against the wrap. +func TestAppendSync_OpenErrorMessage(t *testing.T) { + t.Parallel() + err := AppendSync("/no/such/parent/file.txt", []byte("x")) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "open:") { + t.Fatalf("error %q missing 'open:' wrap", err) + } +} + +// TestAppendSync_ManyAppendsPreserveOrder hammers a single file with +// sequential appends to verify ordering and durability semantics. +// Concurrent appends to the same file are NOT a supported use case +// (POSIX O_APPEND is per-call atomic but ordering is unspecified across +// processes), so we keep this serial. +func TestAppendSync_ManyAppendsPreserveOrder(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "wal.log") + for i := 0; i < 100; i++ { + if err := AppendSync(path, []byte("x")); err != nil { + t.Fatalf("append %d: %v", i, err) + } + } + body, _ := os.ReadFile(path) + if len(body) != 100 { + t.Fatalf("len = %d, want 100", len(body)) + } + if string(body) != strings.Repeat("x", 100) { + t.Fatalf("content mismatch") + } +} + +// TestAppendSync_OpenAgainstDirectory exercises the open-fails branch +// using a different failure mode (O_WRONLY against a directory). +func TestAppendSync_OpenAgainstDirectory(t *testing.T) { + t.Parallel() + dir := t.TempDir() // dir IS a directory; opening it WRONLY fails. + err := AppendSync(dir, []byte("x")) + if err == nil { + t.Fatal("expected open error against a directory") + } + if !strings.Contains(err.Error(), "open:") { + t.Fatalf("error %q missing 'open:' wrap", err) + } +} + +// TestAtomicWrite_RenameFails — pre-create the destination as a +// non-empty directory. The tmp-file open/write/sync/close all succeed +// (sibling path doesn't conflict), but os.Rename(tmp, path) fails +// because you can't rename a file over a non-empty directory. +func TestAtomicWrite_RenameFails(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("rename-onto-dir semantics differ on Windows") + } + dir := t.TempDir() + target := filepath.Join(dir, "target") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("setup mkdir: %v", err) + } + // Make the dir non-empty so rename(file → dir) is definitively + // rejected (an empty dir + rename has differing semantics across + // kernels: Linux atomically replaces, macOS errors). + if err := os.WriteFile(filepath.Join(target, "occupant"), []byte("x"), 0o644); err != nil { + t.Fatalf("setup occupant: %v", err) + } + err := AtomicWrite(target, []byte("payload")) + if err == nil { + t.Fatal("expected rename error against non-empty directory") + } +} + +// TestAtomicWrite_EmptyPayload — zero-byte writes should still produce +// a zero-byte file. +func TestAtomicWrite_EmptyPayload(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "empty.txt") + if err := AtomicWrite(path, []byte{}); err != nil { + t.Fatalf("AtomicWrite: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if info.Size() != 0 { + t.Errorf("size = %d, want 0", info.Size()) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("perm = %o, want 0600", perm) + } + if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { + t.Errorf(".tmp file should be gone: %v", err) + } +} + +// TestAtomicWrite_NilPayload — nil should behave identically to empty. +func TestAtomicWrite_NilPayload(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "nil.txt") + if err := AtomicWrite(path, nil); err != nil { + t.Fatalf("AtomicWrite(nil): %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if info.Size() != 0 { + t.Errorf("size = %d, want 0", info.Size()) + } +} + +// TestAtomicWrite_LargePayload verifies a big buffer survives the +// open / write / sync / rename / dir-fsync chain intact. +func TestAtomicWrite_LargePayload(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "big.bin") + payload := bytes.Repeat([]byte{0xAB, 0xCD}, 256<<10) // 512 KiB + if err := AtomicWrite(path, payload); err != nil { + t.Fatalf("AtomicWrite: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("payload mismatch") + } +} + +// TestAtomicWrite_PreservesMode0600 — even when overwriting a file +// with permissive 0o644 mode, AtomicWrite's rename should land a 0o600 +// inode (because the tmp file is opened 0o600 and rename preserves the +// source inode's perms). +func TestAtomicWrite_PreservesMode0600(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("unix file permissions only") + } + dir := t.TempDir() + path := filepath.Join(dir, "perm.txt") + if err := os.WriteFile(path, []byte("old"), 0o644); err != nil { + t.Fatalf("setup: %v", err) + } + if err := AtomicWrite(path, []byte("new")); err != nil { + t.Fatalf("AtomicWrite: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("perm = %o, want 0600 (atomic write should tighten)", perm) + } +} + +// TestAtomicWrite_ConcurrentWritersToDifferentFiles — separate paths +// should never collide. (Same-path concurrency is racy by design; +// last writer wins, but no torn files.) +func TestAtomicWrite_ConcurrentWritersToDifferentFiles(t *testing.T) { + t.Parallel() + dir := t.TempDir() + var wg sync.WaitGroup + const n = 16 + errs := make(chan error, n) + for i := 0; i < n; i++ { + i := i + wg.Add(1) + go func() { + defer wg.Done() + path := filepath.Join(dir, "file-"+strings.Repeat("a", i+1)+".txt") + payload := []byte(strings.Repeat("p", i+1)) + if err := AtomicWrite(path, payload); err != nil { + errs <- err + return + } + got, err := os.ReadFile(path) + if err != nil { + errs <- err + return + } + if !bytes.Equal(got, payload) { + errs <- &mismatchErr{i: i} + } + }() + } + wg.Wait() + close(errs) + for e := range errs { + t.Errorf("concurrent write: %v", e) + } +} + +type mismatchErr struct{ i int } + +func (e *mismatchErr) Error() string { return "payload mismatch" } + +// TestAtomicWrite_DirFsyncBestEffort — even when the directory is +// (somehow) unopenable for fsync, AtomicWrite reports success because +// the data is on disk; the directory-entry fsync is best-effort. +// We exercise the happy path here; the failure-mode is unreachable +// from portable Go (you can't make os.Open(filepath.Dir(path)) fail +// after a successful rename in the same dir). +func TestAtomicWrite_DirFsyncBestEffortHappyPath(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "x.txt") + if err := AtomicWrite(path, []byte("ok")); err != nil { + t.Fatalf("AtomicWrite: %v", err) + } + got, _ := os.ReadFile(path) + if string(got) != "ok" { + t.Fatalf("got %q", got) + } +} diff --git a/fsutil/zz_fsutil_test.go b/fsutil/zz_fsutil_test.go new file mode 100644 index 0000000..0dc6242 --- /dev/null +++ b/fsutil/zz_fsutil_test.go @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package fsutil + +import ( + "os" + "path/filepath" + "testing" +) + +func TestAppendSync_CreatesAndAppends(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "wal.log") + + if err := AppendSync(path, []byte("first\n")); err != nil { + t.Fatalf("first AppendSync: %v", err) + } + if err := AppendSync(path, []byte("second\n")); err != nil { + t.Fatalf("second AppendSync: %v", err) + } + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(body) != "first\nsecond\n" { + t.Errorf("got %q, want 'first\\nsecond\\n'", body) + } +} + +func TestAppendSync_OpenError(t *testing.T) { + t.Parallel() + // Write under a non-existent parent dir → open fails. + if err := AppendSync("/no/such/parent/file.txt", []byte("x")); err == nil { + t.Error("expected error on bad path") + } +} + +func TestAtomicWrite_HappyPath(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "atomic.txt") + if err := AtomicWrite(path, []byte("hello")); err != nil { + t.Fatalf("AtomicWrite: %v", err) + } + body, _ := os.ReadFile(path) + if string(body) != "hello" { + t.Errorf("got %q", body) + } + // tmp file must be cleaned up by rename. + if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { + t.Errorf(".tmp file should be gone: %v", err) + } +} + +func TestAtomicWrite_OverwritesExisting(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "atomic.txt") + if err := os.WriteFile(path, []byte("old"), 0600); err != nil { + t.Fatalf("setup: %v", err) + } + if err := AtomicWrite(path, []byte("new")); err != nil { + t.Fatalf("AtomicWrite: %v", err) + } + body, _ := os.ReadFile(path) + if string(body) != "new" { + t.Errorf("got %q, want 'new'", body) + } +} + +func TestAtomicWrite_OpenError(t *testing.T) { + t.Parallel() + if err := AtomicWrite("/no/such/parent/dir/file.txt", []byte("x")); err == nil { + t.Error("expected error opening tmp under bad path") + } +}