Skip to content

Commit 4dc8b73

Browse files
authored
feat(middlewares): warn when a local preset file shadows a bundled name (#679) (#696)
## Summary \`PresetLoader.Load\` resolves bundled presets first and never falls through, so a file at \`\$LOCAL_DIR/json-post.yaml\` placed hoping to override the bundled \`json-post\` is silently ignored at attach time. Same applies to every bundled name: \`slack\`, \`discord\`, \`teams\`, \`matrix\`, \`ntfy\`, \`ntfy-token\`, \`gotify\`, \`pushover\`, \`pagerduty\`. \`AddLocalPresetDir\` now scans the registered directory for \`*.yaml\` / \`*.yml\` files whose stem collides with a bundled preset name and emits a startup \`slog.Warn\` per match. Missing or unreadable directories stay silent (operators commonly register a dir before populating it). Closes [#679](#679). ## Why not invert the lookup order The issue body called this out explicitly: a local typo accidentally shadowing \`slack.yaml\` would silently break Slack delivery for *every* webhook on the host. Operators trust the bundled set to behave consistently across hosts; warn-and-keep-bundled preserves that contract while surfacing the override gap. ## Tests Two new tests in \`middlewares/preset_test.go\` (reusing the existing \`captureSlog\` helper from \`webhook_security_warn_test.go\`): 1. **\`TestPresetLoader_AddLocalPresetDir_WarnsOnBundledShadow\`** — temp dir with \`json-post.yaml\` + \`slack.yml\` + \`my-custom.yaml\` + \`json-post.txt\`, asserts warnings for the two bundled collisions but NOT for the non-colliding file or the non-yaml file. Covers both \`.yaml\` and \`.yml\` extensions. 2. **\`TestPresetLoader_AddLocalPresetDir_NoWarnOnMissingDir\`** — registering a directory that doesn'"'"'t exist is silent, preserving the existing \`/tmp/presets\` fixture behavior. ## Docs New "**Preset Lookup Order**" subsection in \`docs/webhooks.md\` documents the bundled-first precedence and rationale. ## CHANGELOG Entry under \`[Unreleased]\` → \`### Added\`. ## Test plan - [x] \`go test ./...\` passes (full repo, 14 packages, ~58s) - [x] \`golangci-lint run\` clean - [x] \`go vet ./...\` clean - [ ] CI green ## References - Surfaced in: [#677](#677) (parallel-reviewer pass)
2 parents 91c48cc + 2064203 commit 4dc8b73

4 files changed

Lines changed: 175 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- `PresetLoader.AddLocalPresetDir` now scans the registered directory for `*.yaml` files whose stem collides with a bundled preset name (`slack`, `discord`, `teams`, `matrix`, `ntfy`, `ntfy-token`, `pushover`, `pagerduty`, `gotify`, `json-post`) and emits a startup `slog.Warn` per collision. Pre-fix, `PresetLoader.Load` resolved bundled presets first and never fell through, so a file at `$LOCAL_DIR/json-post.yaml` placed hoping to override the bundled `json-post` was silently ignored at attach time. The warning matches `Load`'s `.yaml`-only resolution path so a `.yml` rename suggestion never misleads operators. The lookup order is documented in `docs/webhooks.md` under "Preset Lookup Order"; inverting the order to prefer local files is deliberately rejected (a local typo shadowing `slack.yaml` would silently break Slack delivery host-wide). Closes [#679](https://github.com/netresearch/ofelia/issues/679).
13+
1014
### Removed
1115

1216
- **BREAKING (source-only, pre-1.0):** Removed unused `core/adapters/docker.ClientConfig.HTTPClient` field that was declared in [#681](https://github.com/netresearch/ofelia/pull/681) but never read — a caller setting `cfg.HTTPClient = someClient` silently got the auto-constructed transport instead of theirs. Downstream Go consumers that referenced the field in named struct literals or assignments will see a compile-time error after upgrade (semantically a no-op since the field was already ignored at runtime); permitted under SemVer for the current 0.y.z line (cf. [SemVer §4](https://semver.org/#spec-item-4)). Removing the field turns the silent footgun into a loud compile-time error rather than preserving it as a deprecated no-op. If you need a transport-level injection seam, file a feature request with the use case so the suppression of `disableHTTP2AutoConfig` on caller-supplied transports (the [#668](https://github.com/netresearch/ofelia/issues/668) invariant) can be wired in correctly. ([#693](https://github.com/netresearch/ofelia/pull/693), closes [#684](https://github.com/netresearch/ofelia/issues/684))

docs/webhooks.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,18 @@ Templates support these helper functions:
618618

619619
## Creating Custom Presets
620620

621+
### Preset Lookup Order
622+
623+
`PresetLoader.Load` resolves a preset name against these sources, in order, and stops at the first hit:
624+
625+
1. **Bundled presets** compiled into the binary (`slack`, `discord`, `teams`, `matrix`, `ntfy`, `ntfy-token`, `pushover`, `pagerduty`, `gotify`, `json-post`).
626+
2. **Path-prefixed local files** — preset names starting with `/`, `./`, or `../` are read from disk directly.
627+
3. **Local preset directories** — registered via `(*PresetLoader).AddLocalPresetDir(dir)`; `<dir>/<name>.yaml` lookup.
628+
4. **GitHub shorthand** — `gh:owner/repo/path/file.yaml@ref`.
629+
5. **Remote URLs** — `https://...`/`http://...` (requires `webhook-allow-remote-presets = true` AND a matching entry in `webhook-trusted-preset-sources`).
630+
631+
> **Bundled wins:** a file at `<local-preset-dir>/json-post.yaml` will **not** override the bundled `json-post` preset — the bundled lookup at step 1 fires first and never falls through. To make collisions visible, Ofelia emits a startup `slog.Warn` per local `.yaml` file whose stem matches a bundled preset name. Rename the local file (e.g. `my-json-post.yaml`) to use it via step 3. This precedence is intentional: inverting it would let a local typo (e.g. an accidentally-saved `slack.yaml`) silently break Slack delivery for every webhook on the host. See [#679](https://github.com/netresearch/ofelia/issues/679).
632+
621633
Custom presets use YAML format:
622634

623635
```yaml

middlewares/preset.go

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import (
88
"context"
99
"embed"
1010
"encoding/json"
11+
"errors"
1112
"fmt"
13+
"io/fs"
14+
"log/slog"
1215
"net/http"
1316
"os"
1417
"path/filepath"
@@ -140,9 +143,71 @@ func (l *PresetLoader) loadBundledPresets() error {
140143
return nil
141144
}
142145

143-
// AddLocalPresetDir adds a directory to search for local preset files
146+
// AddLocalPresetDir adds a directory to search for local preset files. It
147+
// also scans the directory for files whose names collide with a bundled
148+
// preset (e.g. "json-post.yaml" shadowing the bundled "json-post") and emits
149+
// a startup-time slog.Warn per collision. Load() resolves bundled presets
150+
// first and never falls through, so a local file at $dir/<name>.yaml meant
151+
// to override the bundled preset would silently be ignored — surfacing the
152+
// shadow at AddLocalPresetDir time turns a silent no-op into an actionable
153+
// warning. See https://github.com/netresearch/ofelia/issues/679.
154+
//
155+
// Inverting the lookup order to prefer local files is deliberately rejected:
156+
// a local typo that accidentally shadows "slack" would silently break Slack
157+
// delivery for every webhook on the host. Operators trust the bundled set
158+
// to behave consistently across hosts; warn-and-keep-bundled preserves that.
144159
func (l *PresetLoader) AddLocalPresetDir(dir string) {
145160
l.localPresetDirs = append(l.localPresetDirs, dir)
161+
l.warnOnBundledShadow(dir)
162+
}
163+
164+
// warnOnBundledShadow scans dir for *.yaml files whose stem matches a
165+
// bundled preset name and emits one slog.Warn per collision. Restricted to
166+
// the *.yaml extension on purpose: Load() only probes "<name>.yaml" in
167+
// localPresetDirs, so a *.yml file would not have been loaded anyway and
168+
// warning on it would mislead operators into thinking the rename to .yaml
169+
// is sufficient.
170+
//
171+
// A non-existent directory (operator registered before populating) is
172+
// silent. Other read failures are debug-logged so a permission-denied or
173+
// I/O error is still discoverable when an operator turns up the log level,
174+
// without becoming a startup-noise floor on a clean install.
175+
func (l *PresetLoader) warnOnBundledShadow(dir string) {
176+
if len(l.bundledPresets) == 0 {
177+
return
178+
}
179+
entries, err := os.ReadDir(dir)
180+
if err != nil {
181+
if !errors.Is(err, fs.ErrNotExist) {
182+
slog.Default().Debug(
183+
"PresetLoader: could not scan local preset dir for bundled-name shadows",
184+
"dir", dir,
185+
"error", err,
186+
)
187+
}
188+
return
189+
}
190+
for _, entry := range entries {
191+
if entry.IsDir() {
192+
continue
193+
}
194+
name := entry.Name()
195+
if filepath.Ext(name) != ".yaml" {
196+
continue
197+
}
198+
stem := strings.TrimSuffix(name, ".yaml")
199+
if _, shadowed := l.bundledPresets[stem]; !shadowed {
200+
continue
201+
}
202+
fullPath := filepath.Join(dir, name)
203+
slog.Default().Warn(
204+
fmt.Sprintf("Local preset file %q shadows bundled preset %q; the bundled preset wins "+
205+
"and the local file will not be loaded. Rename the file or remove it to silence this warning.",
206+
fullPath, stem),
207+
"file", fullPath,
208+
"bundled", stem,
209+
)
210+
}
146211
}
147212

148213
// DefaultPreset returns the effective global default preset name —

middlewares/preset_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http"
88
"net/http/httptest"
99
"os"
10+
"path/filepath"
1011
"testing"
1112
"time"
1213

@@ -324,6 +325,98 @@ func TestPresetLoader_AddLocalPresetDir(t *testing.T) {
324325
assert.Len(t, loader.localPresetDirs, 2)
325326
}
326327

328+
// TestPresetLoader_AddLocalPresetDir_WarnsOnBundledShadow pins the fix for
329+
// https://github.com/netresearch/ofelia/issues/679. PresetLoader.Load resolves
330+
// bundled presets first and never falls through, so a local file at
331+
// $dir/<bundled-name>.yaml is a silent no-op. AddLocalPresetDir now scans the
332+
// directory and emits a slog.Warn per collision so operators learn at startup
333+
// that their override won't take effect.
334+
//
335+
// Scope is restricted to *.yaml to mirror Load's resolution path: Load only
336+
// probes "<name>.yaml" in localPresetDirs, so *.yml files would not have
337+
// been loaded anyway and warning on them would mislead operators into
338+
// thinking a .yml -> .yaml rename is the fix. The .yml anti-warning case
339+
// pins that contract.
340+
//
341+
// Not parallel: shares slog.Default with other captureSlog tests in the
342+
// package (see the helper's comment).
343+
func TestPresetLoader_AddLocalPresetDir_WarnsOnBundledShadow(t *testing.T) {
344+
dir := t.TempDir()
345+
346+
// json-post is a bundled preset (since #676) — placing a local file
347+
// with the same stem and .yaml extension must trigger the shadow
348+
// warning.
349+
require.NoError(t, os.WriteFile(filepath.Join(dir, "json-post.yaml"), []byte("name: json-post\n"), 0o644))
350+
require.NoError(t, os.WriteFile(filepath.Join(dir, "slack.yaml"), []byte("name: slack\n"), 0o644))
351+
// Non-colliding file must NOT trigger a warning.
352+
require.NoError(t, os.WriteFile(filepath.Join(dir, "my-custom.yaml"), []byte("name: my-custom\n"), 0o644))
353+
// .yml extension: Load only probes .yaml, so warning on .yml would
354+
// mislead operators. This file's bundled stem must NOT trigger a warning.
355+
require.NoError(t, os.WriteFile(filepath.Join(dir, "discord.yml"), []byte("name: discord\n"), 0o644))
356+
// Non-yaml file must be ignored entirely.
357+
require.NoError(t, os.WriteFile(filepath.Join(dir, "json-post.txt"), []byte("not yaml"), 0o644))
358+
359+
buf := captureSlog(t)
360+
loader := NewPresetLoader(nil)
361+
loader.AddLocalPresetDir(dir)
362+
363+
logs := buf.String()
364+
assert.Contains(t, logs, "shadows bundled preset \\\"json-post\\\"",
365+
"should warn that json-post.yaml shadows the bundled json-post preset")
366+
assert.Contains(t, logs, "shadows bundled preset \\\"slack\\\"",
367+
"should warn that slack.yaml shadows the bundled slack preset")
368+
assert.Contains(t, logs, `"level":"WARN"`,
369+
"shadow notices must use WARN level so operators don't have to raise log verbosity to see them")
370+
assert.NotContains(t, logs, "my-custom",
371+
"should NOT warn about non-colliding local presets")
372+
assert.NotContains(t, logs, "discord",
373+
"should NOT warn about .yml files — Load only probes .yaml, so .yml is not a shadow case")
374+
assert.NotContains(t, logs, "json-post.txt",
375+
"should NOT scan non-yaml files")
376+
}
377+
378+
// TestPresetLoader_AddLocalPresetDir_NoWarnOnMissingDir verifies that
379+
// registering a directory that does not exist (yet) is silent. Operators
380+
// frequently configure the directory before populating it, and the existing
381+
// /tmp/presets fixture above relies on this behavior.
382+
func TestPresetLoader_AddLocalPresetDir_NoWarnOnMissingDir(t *testing.T) {
383+
buf := captureSlog(t)
384+
loader := NewPresetLoader(nil)
385+
loader.AddLocalPresetDir(filepath.Join(t.TempDir(), "does-not-exist"))
386+
assert.NotContains(t, buf.String(), "shadows bundled preset",
387+
"missing directory must not produce a shadow warning")
388+
assert.NotContains(t, buf.String(), "could not scan local preset dir",
389+
"fs.ErrNotExist must stay silent (no debug log either) — operators register dirs before populating")
390+
}
391+
392+
// TestPresetLoader_AddLocalPresetDir_DebugLogsNonENOENTError pins the debug-
393+
// log branch added in response to PR #696 review: read failures other than
394+
// "directory does not exist" (e.g. operator pointed at a file, permission
395+
// denied) are debug-logged so they surface when log level is raised, instead
396+
// of being silently swallowed alongside the legitimate not-yet-populated
397+
// case.
398+
//
399+
// We trigger ENOTDIR by registering a path that points at a regular file
400+
// rather than a directory — os.ReadDir then returns a non-ENOENT error,
401+
// which is exactly the branch we want to exercise.
402+
func TestPresetLoader_AddLocalPresetDir_DebugLogsNonENOENTError(t *testing.T) {
403+
dir := t.TempDir()
404+
notADir := filepath.Join(dir, "regular-file")
405+
require.NoError(t, os.WriteFile(notADir, []byte("just a file"), 0o644))
406+
407+
buf := captureSlog(t)
408+
loader := NewPresetLoader(nil)
409+
loader.AddLocalPresetDir(notADir)
410+
411+
logs := buf.String()
412+
assert.Contains(t, logs, "could not scan local preset dir",
413+
"non-ENOENT errors must surface as a debug log (e.g. ENOTDIR when path is a regular file)")
414+
assert.Contains(t, logs, `"level":"DEBUG"`,
415+
"non-ENOENT errors are debug-level, not warn — they're operator-recoverable misconfig, not a startup gate")
416+
assert.NotContains(t, logs, "shadows bundled preset",
417+
"failure to scan must not produce a shadow warning")
418+
}
419+
327420
func TestPresetLoader_LoadFromFile(t *testing.T) {
328421
t.Parallel()
329422

0 commit comments

Comments
 (0)