Skip to content

Commit e8260d1

Browse files
committed
fix: validate Default and roll back SetPath on later failure
Three review-driven hardenings rolled into one commit: 1. config.EnsureExists now Validate()s Default() before writing. TestDefaultPassesValidate already locks the happy path, but a future regression should fail EnsureExists rather than persist garbage that Load would later reject. 2. simulator.New captures the package-level path with config.Path() before the SetPath, then defers a rollback that fires unless a 'committed' flag flips at the end. Now Load() and rebuildAuthChain() failures restore the previous global path instead of leaving the constructor's mutation behind. 3. Tests: - newTestSimulator captures config.Path() into priorPath and the cleanup restores that value (instead of unconditionally setting '') so a parent test's path is preserved across nested fixtures. - TestNewDoesNotLeakActivePathOnEnsureFailure now installs a non-empty sentinel as the prior value so the assertion exercises the preserve-existing-path behaviour, not just preserve-empty. - New TestNewDoesNotLeakActivePathOnLoadFailure covers the second leak window — EnsureExists succeeds (file exists) but Load fails because the existing file is invalid JSON. The deferred rollback in New must restore the sentinel.
1 parent 1ca9ca0 commit e8260d1

3 files changed

Lines changed: 60 additions & 8 deletions

File tree

internal/config/config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -961,6 +961,14 @@ func EnsureExists(p string) (bool, error) {
961961
return false, fmt.Errorf("config: stat %s: %w", p, err)
962962
}
963963
cfg := Default()
964+
// Belt-and-braces: Validate Default() before persisting so a future
965+
// regression in the baseline can never write a file that Load would
966+
// immediately reject. TestDefaultPassesValidate locks the happy path,
967+
// but this guard turns any drift into an EnsureExists error instead
968+
// of a corrupted on-disk state.
969+
if err := Validate(&cfg); err != nil {
970+
return false, fmt.Errorf("config: default config failed validation: %w", err)
971+
}
964972
tmpPath, err := writeTempFile(p, &cfg)
965973
if err != nil {
966974
return false, err

internal/simulator/simulator.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,17 @@ func New(opts Options) (*Simulator, error) {
159159
if _, ensureErr := config.EnsureExists(cfgPath); ensureErr != nil {
160160
return nil, fmt.Errorf("simulator: ensure config: %w", ensureErr)
161161
}
162+
prevPath := config.Path()
162163
config.SetPath(cfgPath)
164+
// Roll the global active path back if any subsequent step (Load,
165+
// rebuildAuthChain, …) errors out. The flag flips to true at the
166+
// very end of New, after every fallible step has succeeded.
167+
committed := false
168+
defer func() {
169+
if !committed {
170+
config.SetPath(prevPath)
171+
}
172+
}()
163173

164174
cfg, err := config.Load()
165175
if err != nil {
@@ -192,6 +202,7 @@ func New(opts Options) (*Simulator, error) {
192202
if err := sim.rebuildAuthChain(&cfg); err != nil {
193203
return nil, fmt.Errorf("simulator: build auth chain: %w", err)
194204
}
205+
committed = true
195206

196207
sim.devHandler = devicesvc.NewHandler(sim.deviceProv, devicesvc.WithAuthHook(
197208
devicesvc.AuthFunc(sim.deviceAuthHook),

internal/simulator/simulator_test.go

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,10 @@ func newTestSimulator(t *testing.T) (sim *Simulator, cleanup func()) {
7373
t.Fatalf("write config: %v", writeErr)
7474
}
7575

76-
cleanup = func() { config.SetPath("") }
76+
// Capture the active path before construction so cleanup restores
77+
// whatever was set globally (instead of clobbering it with "").
78+
priorPath := config.Path()
79+
cleanup = func() { config.SetPath(priorPath) }
7780

7881
s, newErr := New(Options{EventBufferSize: 16, ConfigPath: cfgPath})
7982
if newErr != nil {
@@ -109,18 +112,48 @@ func TestNewDoesNotLeakActivePathOnEnsureFailure(t *testing.T) {
109112
// path under a regular file → MkdirAll inside writeTempFile fails.
110113
bogus := filepath.Join(blocker, config.FileName)
111114

112-
// Reset to a clean baseline before the test, snapshot it, and
113-
// restore on cleanup so test ordering doesn't matter.
114-
config.SetPath("")
115-
t.Cleanup(func() { config.SetPath("") })
116-
prior := config.Path()
115+
// Snapshot whatever was set before the test, install a non-empty
116+
// sentinel as the "prior" we expect to be preserved, and restore
117+
// the original value on cleanup so test ordering does not matter.
118+
original := config.Path()
119+
t.Cleanup(func() { config.SetPath(original) })
120+
const sentinel = "/some/sentinel/onvif-simulator.json"
121+
config.SetPath(sentinel)
117122

118123
_, err := New(Options{ConfigPath: bogus})
119124
if err == nil {
120125
t.Fatal("expected New to fail with bogus path")
121126
}
122-
if got := config.Path(); got != prior {
123-
t.Errorf("config.Path leaked after failed New: got %q, want %q", got, prior)
127+
if got := config.Path(); got != sentinel {
128+
t.Errorf("config.Path leaked after failed New: got %q, want %q", got, sentinel)
129+
}
130+
}
131+
132+
// TestNewDoesNotLeakActivePathOnLoadFailure complements the
133+
// EnsureExists case: even after EnsureExists succeeds and SetPath has
134+
// fired, a Load failure (e.g. a corrupted config file written by a
135+
// concurrent editor) must roll the global path back to its prior
136+
// value via the deferred restore in New.
137+
func TestNewDoesNotLeakActivePathOnLoadFailure(t *testing.T) {
138+
dir := t.TempDir()
139+
cfgPath := filepath.Join(dir, config.FileName)
140+
// Seed a file that EnsureExists treats as already-present (no-op)
141+
// but Load rejects because it is not valid JSON.
142+
if err := os.WriteFile(cfgPath, []byte("not json"), 0o600); err != nil {
143+
t.Fatalf("seed corrupted config: %v", err)
144+
}
145+
146+
original := config.Path()
147+
t.Cleanup(func() { config.SetPath(original) })
148+
const sentinel = "/some/sentinel/onvif-simulator.json"
149+
config.SetPath(sentinel)
150+
151+
_, err := New(Options{ConfigPath: cfgPath})
152+
if err == nil {
153+
t.Fatal("expected New to fail when config file is corrupt")
154+
}
155+
if got := config.Path(); got != sentinel {
156+
t.Errorf("config.Path leaked after Load failure: got %q, want %q", got, sentinel)
124157
}
125158
}
126159

0 commit comments

Comments
 (0)