From 8b37e6e04035f9f8a3d01701dae121cdc67cbf43 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 27 Apr 2026 08:27:28 +0300 Subject: processor: fix uniqueID to return error instead of infinite loop on Stat errors Previously, uniqueID(postsDir, t) returned only a string and looped forever when os.Stat returned an error other than IsNotExist (e.g. permission denied). Change the signature to (string, error), propagate the error, and update the caller in processFile to handle it. Add positive and negative tests covering new ID generation, suffix collision, and permission-based stat failure. --- internal/processor/processor.go | 15 ++++++++--- internal/processor/processor_test.go | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 43f5023..d781a8b 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -103,7 +103,10 @@ func claimedByMarkdown(entries []os.DirEntry, inputDir string) (map[string]bool, // The source file is removed from the input dir on success. func processFile(srcPath, postsDir string) error { now := time.Now().UTC() - id := uniqueID(postsDir, now) + id, err := uniqueID(postsDir, now) + if err != nil { + return fmt.Errorf("generate unique ID: %w", err) + } postDir := filepath.Join(postsDir, id) if err := os.MkdirAll(postDir, 0o755); err != nil { @@ -260,11 +263,15 @@ func copyLocalImages(filenames []string, sourceDir, postDir string) ([]string, e // uniqueID generates a post ID for the given time that does not already exist // as a directory under postsDir. Appends a numeric suffix if needed. -func uniqueID(postsDir string, t time.Time) string { +func uniqueID(postsDir string, t time.Time) (string, error) { for i := 0; ; i++ { id := post.NewID(t, i) - if _, err := os.Stat(filepath.Join(postsDir, id)); os.IsNotExist(err) { - return id + _, err := os.Stat(filepath.Join(postsDir, id)) + if err != nil { + if os.IsNotExist(err) { + return id, nil + } + return "", fmt.Errorf("stat post dir %s: %w", id, err) } } } diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index d04a0d5..3b34675 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "testing" + "time" "codeberg.org/snonux/snonux/internal/config" "codeberg.org/snonux/snonux/internal/post" @@ -141,6 +142,57 @@ func TestRun_markdown(t *testing.T) { } } +func TestUniqueID_new(t *testing.T) { + t.Parallel() + + postsDir := t.TempDir() + id, err := uniqueID(postsDir, time.Now().UTC()) + if err != nil { + t.Fatalf("uniqueID: %v", err) + } + if id == "" { + t.Fatal("expected non-empty id") + } +} + +func TestUniqueID_collision(t *testing.T) { + t.Parallel() + + postsDir := t.TempDir() + now := time.Now().UTC() + + // Pre-create the first expected directory so uniqueID must pick the next suffix. + firstID := post.NewID(now, 0) + if err := os.MkdirAll(filepath.Join(postsDir, firstID), 0o755); err != nil { + t.Fatal(err) + } + + id, err := uniqueID(postsDir, now) + if err != nil { + t.Fatalf("uniqueID: %v", err) + } + if id == firstID { + t.Fatalf("expected different id, got %q", id) + } +} + +func TestUniqueID_statError(t *testing.T) { + t.Parallel() + + // Create a postsDir and remove read permission so Stat fails with + // a permission error rather than IsNotExist. + postsDir := t.TempDir() + if err := os.Chmod(postsDir, 0o000); err != nil { + t.Fatal(err) + } + defer os.Chmod(postsDir, 0o755) // restore for cleanup + + _, err := uniqueID(postsDir, time.Now().UTC()) + if err == nil { + t.Fatal("expected error when stat fails") + } +} + func TestRun_markdownWithLocalImage(t *testing.T) { t.Parallel() -- cgit v1.2.3