From 460b6ef05d549658733aeafe894c3ddc44eece24 Mon Sep 17 00:00:00 2001
From: TBX3D <88289044+TBX3D@users.noreply.github.com>
Date: Tue, 7 Jul 2026 12:12:34 -0700
Subject: [PATCH 1/4] fix(logger): create log dirs and flatten url log
filenames
CreateFile/Write kept the '/' from a target's url path when building the
log filename, so a target like http://host:port/ tried to open
/host:port/.log whose parent directory never existed. os.OpenFile
failed and scanTarget aborted the whole target before any scanning ran.
fold every '/' and '\\' run in the sanitized url into a single '_' via a
shared logPath helper so CreateFile and Write always resolve to the same
flat file, and mkdir the parent defensively in getWriter as a second line
of defense.
---
internal/logger/logger.go | 46 ++++++++++++--
internal/logger/logger_test.go | 106 +++++++++++++++++++++++++++++++++
2 files changed, 148 insertions(+), 4 deletions(-)
diff --git a/internal/logger/logger.go b/internal/logger/logger.go
index 61f66292..4bc89174 100644
--- a/internal/logger/logger.go
+++ b/internal/logger/logger.go
@@ -62,6 +62,14 @@ func (l *Logger) getWriter(path string) (*bufio.Writer, error) {
return w, nil
}
+ // belt-and-suspenders: buildLogPath already flattens '/' out of the
+ // filename so path's parent is always dir itself, but make sure that
+ // parent exists too in case a caller ever hands getWriter a nested path
+ // directly (e.g. a future caller, or a dir that was never Init'd).
+ if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
+ return nil, err
+ }
+
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return nil, err
@@ -122,13 +130,43 @@ func (l *Logger) Close() error {
return firstErr
}
-// CreateFile initializes a log file for the given URL and writes the header.
-func CreateFile(logFiles *[]string, url string, dir string) error {
+// flattenPath folds every '/' and '\\' run in s into a single '_' and trims
+// them from the ends. a target's path segments (or a stray backslash) would
+// otherwise be read as an implied - and almost always missing - subdirectory
+// tree; a URL maps to exactly one flat log file, never a directory tree.
+func flattenPath(s string) string {
+ var b strings.Builder
+ b.Grow(len(s))
+ prevSep := false
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ if c == '/' || c == '\\' {
+ if !prevSep {
+ b.WriteByte('_')
+ prevSep = true
+ }
+ continue
+ }
+ b.WriteByte(c)
+ prevSep = false
+ }
+ return strings.Trim(b.String(), "_")
+}
+
+// logPath builds the flattened log-file path for a URL under dir. CreateFile
+// and Write both go through it so the header and every later line for a
+// target always land in the same file.
+func logPath(dir, url string) string {
sanitizedURL := url
if _, after, ok := strings.Cut(url, "://"); ok {
sanitizedURL = after
}
- path := filepath.Join(dir, sanitizedURL+".log")
+ return filepath.Join(dir, flattenPath(sanitizedURL)+".log")
+}
+
+// CreateFile initializes a log file for the given URL and writes the header.
+func CreateFile(logFiles *[]string, url string, dir string) error {
+ path := logPath(dir, url)
header := fmt.Sprintf(" _____________\n__________(_)__ __/\n__ ___/_ /__ /_ \n_(__ )_ / _ __/ \n/____/ /_/ /_/ \n\nsif log file for %s\nhttps://sif.sh\n\n", url)
@@ -142,7 +180,7 @@ func CreateFile(logFiles *[]string, url string, dir string) error {
// Write appends text to the log file for the given URL.
func Write(url string, dir string, text string) error {
- path := filepath.Join(dir, url+".log")
+ path := logPath(dir, url)
return defaultLogger.write(path, text)
}
diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go
index a8fd36fe..477ef674 100644
--- a/internal/logger/logger_test.go
+++ b/internal/logger/logger_test.go
@@ -116,6 +116,112 @@ func TestCreateFile(t *testing.T) {
Close()
}
+// TestCreateFileURLWithPathDoesNotFail proves a target with a URL path (e.g.
+// http://127.0.0.1:8931/) gets a writable log file. Before the fix, the
+// scheme-stripped sanitized URL kept the '/' verbatim, so CreateFile tried to
+// open "/127.0.0.1:8931/.log" - a path whose parent directory was never
+// created - and OpenFile failed, aborting the whole target's scan.
+func TestCreateFileURLWithPathDoesNotFail(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ var logFiles []string
+ url := "http://127.0.0.1:8931/"
+ if err := CreateFile(&logFiles, url, tmpDir); err != nil {
+ t.Fatalf("CreateFile with path-bearing url: %v", err)
+ }
+
+ if err := Flush(); err != nil {
+ t.Fatalf("Flush failed: %v", err)
+ }
+
+ if len(logFiles) != 1 {
+ t.Fatalf("expected 1 log file, got %d", len(logFiles))
+ }
+
+ if filepath.Dir(logFiles[0]) != tmpDir {
+ t.Fatalf("expected log file directly under %q, got %q", tmpDir, logFiles[0])
+ }
+
+ content, err := os.ReadFile(logFiles[0])
+ if err != nil {
+ t.Fatalf("log file not readable: %v", err)
+ }
+ if !strings.Contains(string(content), "sif log file for "+url) {
+ t.Errorf("expected header content, got %q", content)
+ }
+
+ Close()
+}
+
+// TestCreateFileURLWithMultiSlashAndQueryDoesNotFail covers a messier target:
+// multiple path segments (and a doubled slash) plus query characters. None of
+// it should ever produce a nested directory or a failed OpenFile.
+func TestCreateFileURLWithMultiSlashAndQueryDoesNotFail(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ var logFiles []string
+ url := "https://example.com:8443//a/b/c?x=1&y=2"
+ if err := CreateFile(&logFiles, url, tmpDir); err != nil {
+ t.Fatalf("CreateFile with multi-slash/query url: %v", err)
+ }
+
+ if err := Flush(); err != nil {
+ t.Fatalf("Flush failed: %v", err)
+ }
+
+ if len(logFiles) != 1 {
+ t.Fatalf("expected 1 log file, got %d", len(logFiles))
+ }
+ if filepath.Dir(logFiles[0]) != tmpDir {
+ t.Fatalf("expected log file directly under %q, got %q", tmpDir, logFiles[0])
+ }
+ if _, err := os.Stat(logFiles[0]); err != nil {
+ t.Fatalf("log file not created: %v", err)
+ }
+
+ Close()
+}
+
+// TestWriteURLWithPathUsesSameFlatFile proves Write (used for every line after
+// the header) resolves to the exact same path CreateFile wrote the header to,
+// for a URL with a path component. Before the fix, both functions kept the raw
+// '/' verbatim and would have hit the same missing-subdirectory error.
+func TestWriteURLWithPathUsesSameFlatFile(t *testing.T) {
+ tmpDir := t.TempDir()
+ sanitizedURL := "127.0.0.1:8931/some/path"
+
+ if err := WriteHeader(sanitizedURL, tmpDir, "test"); err != nil {
+ t.Fatalf("WriteHeader with path-bearing url: %v", err)
+ }
+ if err := Write(sanitizedURL, tmpDir, "line two\n"); err != nil {
+ t.Fatalf("Write with path-bearing url: %v", err)
+ }
+ if err := Flush(); err != nil {
+ t.Fatalf("Flush failed: %v", err)
+ }
+
+ entries, err := os.ReadDir(tmpDir)
+ if err != nil {
+ t.Fatalf("ReadDir: %v", err)
+ }
+ if len(entries) != 1 {
+ t.Fatalf("expected exactly 1 flat log file under %q, got %d entries", tmpDir, len(entries))
+ }
+ if entries[0].IsDir() {
+ t.Fatalf("expected a flat file, got a directory: %s", entries[0].Name())
+ }
+
+ content, err := os.ReadFile(filepath.Join(tmpDir, entries[0].Name()))
+ if err != nil {
+ t.Fatalf("reading log file: %v", err)
+ }
+ if !strings.Contains(string(content), "line two") {
+ t.Errorf("Write did not land in the same file WriteHeader created: %q", content)
+ }
+
+ Close()
+}
+
func TestConcurrentWrites(t *testing.T) {
tmpDir := t.TempDir()
From 23ee797f9abf477042488b343048d03be02955c6 Mon Sep 17 00:00:00 2001
From: TBX3D <88289044+TBX3D@users.noreply.github.com>
Date: Tue, 7 Jul 2026 12:12:40 -0700
Subject: [PATCH 2/4] fix(scan): route module status lines through the locking
sink
subdomaintakeover, cloudstorage, and the next.js framework detector
printed their status/error lines with a bare fmt.Println straight to
os.Stdout, bypassing the output package's sink. under -concurrency>1
only the sink is lock-wrapped, so these lines could interleave or tear
against lines other targets write concurrently.
swap them for output.ScanStart / output.Error, matching every other
scanner in the package.
---
internal/scan/cloudstorage.go | 3 +-
internal/scan/js/frameworks/next.go | 8 ++--
.../scan/js/frameworks/stdout_bypass_test.go | 36 +++++++++++++++
internal/scan/stdout_bypass_test.go | 44 +++++++++++++++++++
internal/scan/subdomaintakeover.go | 3 +-
5 files changed, 88 insertions(+), 6 deletions(-)
create mode 100644 internal/scan/js/frameworks/stdout_bypass_test.go
create mode 100644 internal/scan/stdout_bypass_test.go
diff --git a/internal/scan/cloudstorage.go b/internal/scan/cloudstorage.go
index e22a6007..349e8f0c 100644
--- a/internal/scan/cloudstorage.go
+++ b/internal/scan/cloudstorage.go
@@ -23,6 +23,7 @@ import (
"github.com/charmbracelet/log"
"github.com/vmfunc/sif/internal/httpx"
"github.com/vmfunc/sif/internal/logger"
+ "github.com/vmfunc/sif/internal/output"
"github.com/vmfunc/sif/internal/styles"
)
@@ -36,7 +37,7 @@ type CloudStorageResult struct {
}
func CloudStorage(url string, timeout time.Duration, logdir string) ([]CloudStorageResult, error) {
- fmt.Println(styles.Separator.Render("Starting " + styles.Status.Render("Cloud Storage Misconfiguration Scan") + "..."))
+ output.ScanStart("Cloud Storage Misconfiguration Scan")
sanitizedURL := stripScheme(url)
diff --git a/internal/scan/js/frameworks/next.go b/internal/scan/js/frameworks/next.go
index 86e76009..b8f775e4 100644
--- a/internal/scan/js/frameworks/next.go
+++ b/internal/scan/js/frameworks/next.go
@@ -24,7 +24,6 @@ package frameworks
import (
"context"
- "fmt"
"io"
"net/http"
"regexp"
@@ -32,6 +31,7 @@ import (
urlutil "github.com/projectdiscovery/utils/url"
"github.com/vmfunc/sif/internal/httpx"
+ "github.com/vmfunc/sif/internal/output"
)
// nextPagesRegex matches JavaScript file references in Next.js build manifest.
@@ -49,7 +49,7 @@ func GetPagesRouterScripts(scriptUrl string) ([]string, error) {
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, scriptUrl, http.NoBody)
if err != nil {
- fmt.Println(err)
+ output.Error("%v", err)
return nil, err
}
@@ -57,14 +57,14 @@ func GetPagesRouterScripts(scriptUrl string) ([]string, error) {
// while still routing through the shared transport (proxy/headers/rate-limit).
resp, err := httpx.Client(0).Do(req)
if err != nil {
- fmt.Println(err)
+ output.Error("%v", err)
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxManifestSize))
if err != nil {
- fmt.Println(err)
+ output.Error("%v", err)
return nil, err
}
// the manifest ships minified on one line; strip line breaks so the regex
diff --git a/internal/scan/js/frameworks/stdout_bypass_test.go b/internal/scan/js/frameworks/stdout_bypass_test.go
new file mode 100644
index 00000000..d8978fa5
--- /dev/null
+++ b/internal/scan/js/frameworks/stdout_bypass_test.go
@@ -0,0 +1,36 @@
+/*
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+: :
+: █▀ █ █▀▀ · Blazing-fast pentesting suite :
+: ▄█ █ █▀ · BSD 3-Clause License :
+: :
+: (c) 2022-2026 vmfunc, xyzeva, :
+: lunchcat alumni & contributors :
+: :
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+*/
+
+package frameworks
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+// TestNextDoesNotPrintDirectlyToStdout mirrors internal/scan's guard: next.go
+// used to fmt.Println(err) straight to os.Stdout, which can interleave/tear
+// under -concurrency>1 since only the output sink is lock-wrapped there.
+func TestNextDoesNotPrintDirectlyToStdout(t *testing.T) {
+ data, err := os.ReadFile("next.go")
+ if err != nil {
+ t.Fatalf("reading next.go: %v", err)
+ }
+ src := string(data)
+ if strings.Contains(src, "fmt.Println(") {
+ t.Error("next.go still calls fmt.Println directly, want output.Error instead")
+ }
+ if strings.Contains(src, "os.Stdout") {
+ t.Error("next.go references os.Stdout directly, want the output sink instead")
+ }
+}
diff --git a/internal/scan/stdout_bypass_test.go b/internal/scan/stdout_bypass_test.go
new file mode 100644
index 00000000..508dd3f8
--- /dev/null
+++ b/internal/scan/stdout_bypass_test.go
@@ -0,0 +1,44 @@
+/*
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+: :
+: █▀ █ █▀▀ · Blazing-fast pentesting suite :
+: ▄█ █ █▀ · BSD 3-Clause License :
+: :
+: (c) 2022-2026 vmfunc, xyzeva, :
+: lunchcat alumni & contributors :
+: :
+·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+*/
+
+package scan
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+// TestModulesDoNotPrintDirectlyToStdout guards against status/error lines that
+// bypass the output sink via a raw fmt.Println(os.Stdout, ...): under
+// -concurrency>1 those interleave/tear with lines the rest of the run writes
+// through output.Info/Error, since only the sink is wrapped with a lock. every
+// module should route its chrome through the output package instead.
+func TestModulesDoNotPrintDirectlyToStdout(t *testing.T) {
+ files := []string{
+ "cloudstorage.go",
+ "subdomaintakeover.go",
+ }
+ for _, f := range files {
+ data, err := os.ReadFile(f)
+ if err != nil {
+ t.Fatalf("reading %s: %v", f, err)
+ }
+ src := string(data)
+ if strings.Contains(src, "fmt.Println(") {
+ t.Errorf("%s still calls fmt.Println directly, want output.Info/Error/ScanStart instead", f)
+ }
+ if strings.Contains(src, "os.Stdout") {
+ t.Errorf("%s references os.Stdout directly, want the output sink instead", f)
+ }
+ }
+}
diff --git a/internal/scan/subdomaintakeover.go b/internal/scan/subdomaintakeover.go
index 884cc938..e1989584 100644
--- a/internal/scan/subdomaintakeover.go
+++ b/internal/scan/subdomaintakeover.go
@@ -25,6 +25,7 @@ import (
"github.com/charmbracelet/log"
"github.com/vmfunc/sif/internal/httpx"
"github.com/vmfunc/sif/internal/logger"
+ "github.com/vmfunc/sif/internal/output"
"github.com/vmfunc/sif/internal/pool"
"github.com/vmfunc/sif/internal/styles"
)
@@ -70,7 +71,7 @@ var takeoverProviders = map[string]string{
// SubdomainTakeover checks dnsResults for dangling subdomains pointing at
// unclaimed third-party services.
func SubdomainTakeover(url string, dnsResults []string, timeout time.Duration, threads int, logdir string) ([]SubdomainTakeoverResult, error) {
- fmt.Println(styles.Separator.Render("Starting " + styles.Status.Render("Subdomain Takeover Vulnerability Check") + "..."))
+ output.ScanStart("Subdomain Takeover Vulnerability Check")
sanitizedURL := stripScheme(url)
From 11fefdde9049bc73750a58ab469fb167ff647492 Mon Sep 17 00:00:00 2001
From: TBX3D <88289044+TBX3D@users.noreply.github.com>
Date: Tue, 7 Jul 2026 12:16:26 -0700
Subject: [PATCH 3/4] fix(store): make snapshot filenames injective and writes
atomic
sanitize() folds every separator run (and a literal '_') to one '_', so
distinct targets like https://a.com/x and https://a.com//x, or
host:8443/path and host_8443_path, sanitized to the identical string and
shared one snapshot file - the second target's Save silently clobbered
the first's baseline. Save's os.WriteFile also wasn't atomic, so two
targets racing on a collided path (or -concurrency>1 in general) could
interleave partial writes.
pathFor now appends 16 hex chars of the target's sha256 to the sanitized
name, so distinct targets never collide, and Save writes through a temp
file in the same dir followed by os.Rename so a reader always sees a
complete snapshot or the previous one, never a partial write.
this changes the on-disk snapshot filename scheme: existing users' -diff
baselines under the old sanitize()-only names won't be found and the
next run re-baselines from scratch (every finding reports as newly
added once). noting this as a deliberate tradeoff for a local hardening
pass rather than silently orphaning them.
---
internal/store/store.go | 57 ++++++++++++++++++++--
internal/store/store_test.go | 95 +++++++++++++++++++++++++++++++++++-
2 files changed, 147 insertions(+), 5 deletions(-)
diff --git a/internal/store/store.go b/internal/store/store.go
index af2cd885..4ff4a33f 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -18,6 +18,8 @@
package store
import (
+ "crypto/sha256"
+ "encoding/hex"
"encoding/json"
"fmt"
"os"
@@ -95,11 +97,26 @@ func sanitize(target string) string {
return name
}
+// targetHashLen is how many hex characters of the target's sha256 are kept in
+// the snapshot filename. 16 hex chars is 64 bits of the hash - astronomically
+// collision-free for the number of targets a single run ever has - while
+// keeping the filename short and stable.
+const targetHashLen = 16
+
// pathFor builds the absolute snapshot path for a target under dir. kept private
-// so the sanitized-filename invariant lives in one place; Save and Load both go
-// through it so a target always maps to the same file.
+// so the filename invariant lives in one place; Save and Load both go through
+// it so a target always maps to the same file.
+//
+// sanitize alone is lossy - it folds every separator run (and a literal '_')
+// to one '_', so distinct targets like "https://a.com/x" and "https://a.com//x"
+// (or "host:8443/path" and "host_8443_path") produce the identical string and
+// would silently share - and clobber - one snapshot. appending a hash of the
+// full, un-sanitized target makes the path injective for distinct targets
+// while keeping the sanitized prefix for a human skimming the state dir.
func pathFor(dir, target string) string {
- return filepath.Join(dir, sanitize(target)+snapshotExt)
+ sum := sha256.Sum256([]byte(target))
+ suffix := hex.EncodeToString(sum[:])[:targetHashLen]
+ return filepath.Join(dir, sanitize(target)+"-"+suffix+snapshotExt)
}
// Save writes the run's findings for target as a json snapshot under dir,
@@ -126,12 +143,44 @@ func Save(dir, target string, findings []finding.Finding) error {
}
path := pathFor(dir, target)
- if err := os.WriteFile(path, data, snapshotFileMode); err != nil {
+ if err := writeFileAtomic(dir, path, data); err != nil {
return fmt.Errorf("writing snapshot %q: %w", path, err)
}
return nil
}
+// writeFileAtomic writes data to path without ever exposing a reader to a
+// partially-written file: it writes to a temp file in the same directory
+// (so the later rename is same-filesystem and atomic) and only renames it
+// onto path once the write and close both succeed. under -concurrency>1, two
+// targets whose sanitized names previously collided could otherwise race two
+// os.WriteFile calls on the same path and interleave their writes; a rename
+// is atomic, so a concurrent reader always sees one complete snapshot or the
+// other, never a mix.
+func writeFileAtomic(dir, path string, data []byte) error {
+ tmp, err := os.CreateTemp(dir, ".snapshot-*.tmp")
+ if err != nil {
+ return err
+ }
+ tmpPath := tmp.Name()
+ // on any early return the temp file must not linger; once the rename
+ // below succeeds this Remove is a harmless no-op (the path is already
+ // gone).
+ defer os.Remove(tmpPath)
+
+ if _, err := tmp.Write(data); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ if err := os.Chmod(tmpPath, snapshotFileMode); err != nil {
+ return err
+ }
+ return os.Rename(tmpPath, path)
+}
+
// Load reads the previously saved snapshot for target under dir. a missing
// snapshot is not an error - it's the first run for that target, so an empty
// slice comes back and the caller treats every current finding as new. a present
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
index 478b28c2..57080d8b 100644
--- a/internal/store/store_test.go
+++ b/internal/store/store_test.go
@@ -127,7 +127,7 @@ func TestLoadCorruptSnapshotErrors(t *testing.T) {
// would silently re-flag every finding as new on every run.
dir := t.TempDir()
const target = "https://corrupt.test"
- path := filepath.Join(dir, sanitize(target)+snapshotExt)
+ path := pathFor(dir, target)
if err := os.WriteFile(path, []byte("{not json"), snapshotFileMode); err != nil {
t.Fatalf("seeding corrupt snapshot: %v", err)
}
@@ -136,6 +136,99 @@ func TestLoadCorruptSnapshotErrors(t *testing.T) {
}
}
+func TestPathForDistinctTargetsNeverCollide(t *testing.T) {
+ // sanitize() folds every separator run to a single '_', so two distinct
+ // targets that only differ in separator punctuation collapse to the same
+ // sanitize() output - proven below - which used to mean the same snapshot
+ // file too, silently overwriting one target's baseline with another's.
+ dir := t.TempDir()
+ tests := [][2]string{
+ {"https://a.com/x", "https://a.com//x"},
+ {"https://a.com/x", "https://a_com/x"},
+ {"host:8443/path", "host_8443_path"},
+ }
+ for _, tt := range tests {
+ a, b := tt[0], tt[1]
+ if sanitize(a) != sanitize(b) {
+ t.Fatalf("test premise broken: sanitize(%q)=%q != sanitize(%q)=%q, pick a colliding pair", a, sanitize(a), b, sanitize(b))
+ }
+ pa, pb := pathFor(dir, a), pathFor(dir, b)
+ if pa == pb {
+ t.Errorf("pathFor collided for distinct targets %q and %q: both %q", a, b, pa)
+ }
+ }
+}
+
+func TestSaveDistinctCollidingTargetsRoundTripIndependently(t *testing.T) {
+ dir := t.TempDir()
+ a, b := "https://a.com/x", "https://a.com//x"
+ if sanitize(a) != sanitize(b) {
+ t.Fatalf("test premise broken: sanitize no longer collides for %q and %q", a, b)
+ }
+
+ findingsA := []finding.Finding{{Target: a, Module: "headers", Severity: finding.SeverityInfo, Key: "a", Title: "a"}}
+ findingsB := []finding.Finding{{Target: b, Module: "headers", Severity: finding.SeverityInfo, Key: "b", Title: "b"}}
+
+ if err := Save(dir, a, findingsA); err != nil {
+ t.Fatalf("Save a: %v", err)
+ }
+ if err := Save(dir, b, findingsB); err != nil {
+ t.Fatalf("Save b: %v", err)
+ }
+
+ gotA, err := Load(dir, a)
+ if err != nil {
+ t.Fatalf("Load a: %v", err)
+ }
+ gotB, err := Load(dir, b)
+ if err != nil {
+ t.Fatalf("Load b: %v", err)
+ }
+ if !reflect.DeepEqual(gotA, findingsA) {
+ t.Errorf("Load(a) = %#v, want %#v (b's save must not have clobbered a)", gotA, findingsA)
+ }
+ if !reflect.DeepEqual(gotB, findingsB) {
+ t.Errorf("Load(b) = %#v, want %#v", gotB, findingsB)
+ }
+}
+
+func TestSaveWritesAtomicallyViaTempAndRename(t *testing.T) {
+ // Save must never leave a stray temp file behind, and a reader must never
+ // observe a partially-written snapshot: it either sees the old file (not
+ // yet renamed) or the fully-written new one, never a half-written one at
+ // the final path.
+ dir := t.TempDir()
+ const target = "https://atomic.test"
+
+ if err := Save(dir, target, sampleFindings()); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatalf("ReadDir: %v", err)
+ }
+ if len(entries) != 1 {
+ t.Fatalf("dir has %d entries after Save, want exactly 1 (no leftover temp file): %v", len(entries), entries)
+ }
+ if entries[0].Name() != filepath.Base(pathFor(dir, target)) {
+ t.Fatalf("unexpected file left in state dir: %q", entries[0].Name())
+ }
+
+ // a second Save (overwrite path) must also leave exactly one file, proving
+ // the temp file it wrote for the update got renamed/cleaned up too.
+ if err := Save(dir, target, nil); err != nil {
+ t.Fatalf("second Save: %v", err)
+ }
+ entries, err = os.ReadDir(dir)
+ if err != nil {
+ t.Fatalf("ReadDir after second Save: %v", err)
+ }
+ if len(entries) != 1 {
+ t.Fatalf("dir has %d entries after overwrite, want exactly 1: %v", len(entries), entries)
+ }
+}
+
func TestDiffAddedAndRemoved(t *testing.T) {
base := sampleFindings()
From f5388d85f35992a99b73242f9bb258f274ff8077 Mon Sep 17 00:00:00 2001
From: TBX3D <88289044+TBX3D@users.noreply.github.com>
Date: Tue, 7 Jul 2026 12:18:01 -0700
Subject: [PATCH 4/4] fix(store): check the deferred temp-file cleanup error
golangci-lint (errcheck) flagged the bare defer os.Remove(tmpPath) in
writeFileAtomic; wrap it so the discard is explicit.
---
internal/logger/logger.go | 9 ++++-----
internal/logger/logger_test.go | 4 ++--
internal/store/store.go | 14 +++++++-------
internal/store/store_test.go | 5 +----
4 files changed, 14 insertions(+), 18 deletions(-)
diff --git a/internal/logger/logger.go b/internal/logger/logger.go
index 4bc89174..52abf85e 100644
--- a/internal/logger/logger.go
+++ b/internal/logger/logger.go
@@ -62,10 +62,9 @@ func (l *Logger) getWriter(path string) (*bufio.Writer, error) {
return w, nil
}
- // belt-and-suspenders: buildLogPath already flattens '/' out of the
- // filename so path's parent is always dir itself, but make sure that
- // parent exists too in case a caller ever hands getWriter a nested path
- // directly (e.g. a future caller, or a dir that was never Init'd).
+ // belt-and-suspenders: logPath already flattens '/' out of the filename
+ // so path's parent is always dir itself, but a future caller could still
+ // hand getWriter a nested path directly, so make sure it exists too.
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, err
}
@@ -132,7 +131,7 @@ func (l *Logger) Close() error {
// flattenPath folds every '/' and '\\' run in s into a single '_' and trims
// them from the ends. a target's path segments (or a stray backslash) would
-// otherwise be read as an implied - and almost always missing - subdirectory
+// otherwise be read as an implied (and almost always missing) subdirectory
// tree; a URL maps to exactly one flat log file, never a directory tree.
func flattenPath(s string) string {
var b strings.Builder
diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go
index 477ef674..803a54cf 100644
--- a/internal/logger/logger_test.go
+++ b/internal/logger/logger_test.go
@@ -119,8 +119,8 @@ func TestCreateFile(t *testing.T) {
// TestCreateFileURLWithPathDoesNotFail proves a target with a URL path (e.g.
// http://127.0.0.1:8931/) gets a writable log file. Before the fix, the
// scheme-stripped sanitized URL kept the '/' verbatim, so CreateFile tried to
-// open "/127.0.0.1:8931/.log" - a path whose parent directory was never
-// created - and OpenFile failed, aborting the whole target's scan.
+// open "/127.0.0.1:8931/.log", a path whose parent directory was never
+// created, and OpenFile failed, aborting the whole target's scan.
func TestCreateFileURLWithPathDoesNotFail(t *testing.T) {
tmpDir := t.TempDir()
diff --git a/internal/store/store.go b/internal/store/store.go
index 4ff4a33f..6dfaa7fd 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -98,8 +98,8 @@ func sanitize(target string) string {
}
// targetHashLen is how many hex characters of the target's sha256 are kept in
-// the snapshot filename. 16 hex chars is 64 bits of the hash - astronomically
-// collision-free for the number of targets a single run ever has - while
+// the snapshot filename. 16 hex chars is 64 bits of the hash, astronomically
+// collision-free for the number of targets a single run ever has, while
// keeping the filename short and stable.
const targetHashLen = 16
@@ -107,10 +107,10 @@ const targetHashLen = 16
// so the filename invariant lives in one place; Save and Load both go through
// it so a target always maps to the same file.
//
-// sanitize alone is lossy - it folds every separator run (and a literal '_')
+// sanitize alone is lossy: it folds every separator run (and a literal '_')
// to one '_', so distinct targets like "https://a.com/x" and "https://a.com//x"
// (or "host:8443/path" and "host_8443_path") produce the identical string and
-// would silently share - and clobber - one snapshot. appending a hash of the
+// would silently share, and clobber, one snapshot. appending a hash of the
// full, un-sanitized target makes the path injective for distinct targets
// while keeping the sanitized prefix for a human skimming the state dir.
func pathFor(dir, target string) string {
@@ -164,9 +164,9 @@ func writeFileAtomic(dir, path string, data []byte) error {
}
tmpPath := tmp.Name()
// on any early return the temp file must not linger; once the rename
- // below succeeds this Remove is a harmless no-op (the path is already
- // gone).
- defer os.Remove(tmpPath)
+ // below succeeds this is a no-op (the path is already gone) and its
+ // error is deliberately discarded.
+ defer func() { _ = os.Remove(tmpPath) }()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
index 57080d8b..f53a2cb3 100644
--- a/internal/store/store_test.go
+++ b/internal/store/store_test.go
@@ -137,10 +137,7 @@ func TestLoadCorruptSnapshotErrors(t *testing.T) {
}
func TestPathForDistinctTargetsNeverCollide(t *testing.T) {
- // sanitize() folds every separator run to a single '_', so two distinct
- // targets that only differ in separator punctuation collapse to the same
- // sanitize() output - proven below - which used to mean the same snapshot
- // file too, silently overwriting one target's baseline with another's.
+ // see pathFor's doc comment (store.go) for why this used to collide.
dir := t.TempDir()
tests := [][2]string{
{"https://a.com/x", "https://a.com//x"},