Merge pull request #457 from Silo-Server/t3code/review-discord-issue

fix(autoscan): match rewrite rules against UNC webhook paths
This commit is contained in:
Quick
2026-07-23 11:42:33 -04:00
committed by GitHub
3 changed files with 81 additions and 6 deletions
+24 -3
View File
@@ -10,7 +10,7 @@ func normalizeSeparators(path string) string {
}
// applyRewrites returns path with the MOST-SPECIFIC matching prefix rewrite
// applied, or path unchanged when none match.
// applied, or the normalized path unchanged when none match.
//
// "Most-specific" means the longest matching From wins, not the first one in the
// slice. A first-match strategy lets a broad rewrite (From="/data") shadow a
@@ -18,6 +18,25 @@ func normalizeSeparators(path string) string {
// be listed first; the arr plugin review flagged exactly this. Selecting the
// longest matching prefix makes the result independent of rule ordering.
func applyRewrites(path string, rewrites []PathRewrite) string {
// Normalize the incoming path the SAME way the stored From is normalized
// below. Separator swapping alone is not enough: a Windows UNC path like
// `\\NAS\Media\TV\...` becomes `//NAS/Media/TV/...`, while normalizePath
// collapses the From's leading `//` to `/NAS/Media/TV` — an asymmetry that
// made UNC roots unmatchable by any rewrite rule.
//
// A trailing separator is semantic downstream — filepath.Dir("/x/Show/")
// is the directory itself while filepath.Dir("/x/Show") is its parent, so
// legacy-scope changes rely on it to scan the notified directory rather
// than collapsing to a broader parent scan. normalizePath strips it for
// matching; restore it on whatever we return.
trailing := strings.HasSuffix(normalizeSeparators(strings.TrimSpace(path)), "/")
path = normalizePath(path)
restoreTrailing := func(p string) string {
if trailing && p != "/" {
return p + "/"
}
return p
}
bestIdx := -1
bestLen := -1
var bestTrimmed string
@@ -42,7 +61,9 @@ func applyRewrites(path string, rewrites []PathRewrite) string {
}
}
if bestIdx < 0 {
return path
return restoreTrailing(path)
}
return strings.TrimSpace(rewrites[bestIdx].To) + strings.TrimPrefix(path, bestTrimmed)
// Normalize the joined result too: a To stored with a trailing slash would
// otherwise yield a doubled separator at the join point.
return restoreTrailing(normalizePath(strings.TrimSpace(rewrites[bestIdx].To) + strings.TrimPrefix(path, bestTrimmed)))
}
+56 -2
View File
@@ -33,8 +33,7 @@ func TestApplyRewrites(t *testing.T) {
// TestApplyRewritesNormalizesStoredFrom verifies that a Windows-style / dup-slash
// stored From is normalized the same way coveredBy/normalizePath does, so a
// rewrite the suggester reports as "covered" actually matches at poll time. The
// incoming path is already separator-normalized by PollOnce before applyRewrites.
// rewrite the suggester reports as "covered" actually matches at poll time.
func TestApplyRewritesNormalizesStoredFrom(t *testing.T) {
// Backslash From: a Windows-hosted arr root stored verbatim.
winFrom := []PathRewrite{{From: `D:\data\tv`, To: "/mnt/media/tv"}}
@@ -53,6 +52,61 @@ func TestApplyRewritesNormalizesStoredFrom(t *testing.T) {
}
}
// TestApplyRewritesUNCPath verifies a Windows UNC root (\\NAS\Media\TV) from a
// Windows-hosted arr matches its rewrite rule. Separator swapping alone turns
// the incoming path into //NAS/... while the From normalizes to /NAS/..., so
// the prefix never matched — both sides must go through normalizePath.
func TestApplyRewritesUNCPath(t *testing.T) {
incoming := `\\NAS\Media\TV\Show\S01\E01.mkv`
for _, from := range []string{`\\NAS\Media\TV`, "//NAS/Media/TV", "/NAS/Media/TV"} {
rw := []PathRewrite{{From: from, To: "/mnt/media/tv"}}
if got := applyRewrites(incoming, rw); got != "/mnt/media/tv/Show/S01/E01.mkv" {
t.Fatalf("UNC path with From=%q: got %q", from, got)
}
}
// An unmatched UNC path still comes back normalized (collapsed slashes),
// consistent with what the resolver sees for matched paths.
if got := applyRewrites(incoming, nil); got != "/NAS/Media/TV/Show/S01/E01.mkv" {
t.Fatalf("unmatched UNC path: got %q", got)
}
// A trailing-slash To must not produce a doubled separator at the join.
slashTo := []PathRewrite{{From: `\\NAS\Media\TV`, To: "/mnt/media/tv/"}}
if got := applyRewrites(incoming, slashTo); got != "/mnt/media/tv/Show/S01/E01.mkv" {
t.Fatalf("trailing-slash To: got %q", got)
}
}
// TestApplyRewritesPreservesTrailingSlash verifies a trailing separator on the
// incoming path survives normalization and rewriting. It is semantic for
// legacy-scope changes: filepath.Dir("/x/Show/") is the directory itself while
// filepath.Dir("/x/Show") is its parent, so dropping it would widen a targeted
// directory notification into a parent/library scan.
func TestApplyRewritesPreservesTrailingSlash(t *testing.T) {
rw := []PathRewrite{{From: "/data/tv", To: "/mnt/media/tv"}}
if got := applyRewrites("/data/tv/Show/", rw); got != "/mnt/media/tv/Show/" {
t.Fatalf("rewritten dir: got %q", got)
}
// Unmatched paths keep it too.
if got := applyRewrites("/other/Show/", rw); got != "/other/Show/" {
t.Fatalf("unmatched dir: got %q", got)
}
// Windows separator form: trailing backslash counts as a trailing separator.
unc := []PathRewrite{{From: `\\NAS\Media\TV`, To: "/mnt/media/tv"}}
if got := applyRewrites(`\\NAS\Media\TV\Show\`, unc); got != "/mnt/media/tv/Show/" {
t.Fatalf("UNC dir: got %q", got)
}
// Files without a trailing separator stay without one.
if got := applyRewrites("/data/tv/Show/E01.mkv", rw); got != "/mnt/media/tv/Show/E01.mkv" {
t.Fatalf("file: got %q", got)
}
// Bare root never doubles.
if got := applyRewrites("/", nil); got != "/" {
t.Fatalf("root: got %q", got)
}
}
// TestApplyRewritesMostSpecificWins verifies the longest matching From wins
// regardless of slice ordering: a broad rule must not shadow a nested one.
func TestApplyRewritesMostSpecificWins(t *testing.T) {
+1 -1
View File
@@ -629,7 +629,7 @@ func (s *Service) resolveConnection(ctx context.Context, connectionID string) (R
func rewriteChanges(changes []Change, rewrites []PathRewrite) []Change {
rewritten := make([]Change, 0, len(changes))
for _, change := range changes {
path := applyRewrites(normalizeSeparators(change.SourcePath), rewrites)
path := applyRewrites(change.SourcePath, rewrites)
rewritten = append(rewritten, Change{SourcePath: path, Scope: change.Scope})
}
return rewritten