From fa81c20ee6ecf754bb4e1a5638ce5c0f63ca31b0 Mon Sep 17 00:00:00 2001 From: Andrea Mazzoleni Date: Wed, 26 Aug 2026 20:06:26 +0200 Subject: [PATCH] Preserve leading slash context for absolute double-star filters --- cmdline/elem.c | 4 +- cmdline/selftest.c | 138 +++++++++++++++++++++++++++++++++++++++++++++ cmdline/str.c | 21 +++++-- cmdline/str.h | 14 +++++ 4 files changed, 170 insertions(+), 7 deletions(-) diff --git a/cmdline/elem.c b/cmdline/elem.c index 21b7d239..87754d5d 100644 --- a/cmdline/elem.c +++ b/cmdline/elem.c @@ -247,8 +247,8 @@ static int filter_apply(struct snapraid_filter* filter, struct snapraid_filter** int ret = 0; if (filter->is_abs) { - /* skip initial slash, as always missing in the path */ - if (wnmatch_sub(filter->pattern + 1, path, match_sub) == 0) + /* preserve the skipped slash because it gives an initial ** its recursive meaning */ + if (wnmatch_sub_prev(filter->pattern + 1, path, match_sub, '/') == 0) ret = filter->direction; } else { /* the path is relative, first try to match from the root */ diff --git a/cmdline/selftest.c b/cmdline/selftest.c index c6d33e42..317f1462 100644 --- a/cmdline/selftest.c +++ b/cmdline/selftest.c @@ -791,6 +791,10 @@ struct { { "**pkg/init.py", "libs/core/pkg/init.py", 0, 1 }, { "**.log", "error.log", 0, 0 }, { "**.log", "var/log/sys.log", 0, 1 }, + { "**.jpg", "photo.jpg", 0, 0 }, + { "**.jpg", "dir/photo.jpg", 0, 1 }, + { "foo/*.jpg", "foo/photo.jpg", 0, 0 }, + { "foo/*.jpg", "foo/a/photo.jpg", 0, 1 }, { "src/**.js", "src/app.js", 0, 0 }, { "src/**.js", "src/components/ui/button.js", 0, 0 }, /* <<<<< .GITIGNORE DIFFERS */ { "a**b.txt", "ab.txt", 0, 0 }, @@ -905,6 +909,29 @@ struct { { 0 } }; +static const struct { + const char* pattern; + const char* text; + int match_sub; + int result; +} WNMATCH_ABSOLUTE_TEST[] = { + /* an absolute filter skips its slash but preserves it as initial context */ + { "**.jpg", "photo.jpg", 0, 0 }, + { "**.jpg", "dir/photo.jpg", 0, 0 }, + { "**.jpg", "a/b/photo.jpg", 0, 0 }, + { "**.jpg", "a/b/photo.png", 0, 1 }, + { "**foo", "foo", 0, 0 }, + { "**foo", "a/b/foo", 0, 0 }, + { "**foo", "a/b/bar", 0, 1 }, + { "**", "a/b/file", 0, 0 }, + { "**foo", "a/b/foo/file", 1, 0 }, + { "**foo", "a/b/bar/file", 1, 1 }, + { "**/foo", "foo", 0, 0 }, + { "**/foo", "a/b/foo", 0, 0 }, + { "foo/**/bar", "foo/a/b/bar", 0, 0 }, + { 0 } +}; + static void test_wnmatch(void) { for (int i = 0; WNMATCH_TEST[i].pattern; ++i) { @@ -915,6 +942,15 @@ static void test_wnmatch(void) /* LCOV_EXCL_STOP */ } } + + for (int i = 0; WNMATCH_ABSOLUTE_TEST[i].pattern; ++i) { + if (wnmatch_sub_prev(WNMATCH_ABSOLUTE_TEST[i].pattern, WNMATCH_ABSOLUTE_TEST[i].text, WNMATCH_ABSOLUTE_TEST[i].match_sub, '/') != WNMATCH_ABSOLUTE_TEST[i].result) { + /* LCOV_EXCL_START */ + log_fatal(EINTERNAL, "Failed absolute wnmatch test %s %s, expected %d\n", WNMATCH_ABSOLUTE_TEST[i].pattern, WNMATCH_ABSOLUTE_TEST[i].text, WNMATCH_ABSOLUTE_TEST[i].result); + exit(EXIT_FAILURE); + /* LCOV_EXCL_STOP */ + } + } } static void test_path(void) @@ -1344,6 +1380,108 @@ static const struct filter_scenario FILTER_SCENARIOS[] = { { 0 } } }, + { + "absolute leading double-star include", + { + { 1, 0, "", "/**.jpg" }, + { 0 } + }, + { + { "d1", "photo.jpg", 0, 0, -1 }, + { "d1", "dir/photo.jpg", 0, 0, -1 }, + { "d1", "a/b/photo.jpg", 0, 0, -1 }, + { "d1", "photo.png", -1, 0, -1 }, + { "d1", "dir/photo.png", -1, 0, -1 }, + { 0 } + } + }, + { + "absolute leading double-star exclude", + { + { -1, 0, "", "/**.tmp" }, + { 0 } + }, + { + { "d1", "file.tmp", -1, 0, 0 }, + { "d1", "a/file.tmp", -1, 0, 0 }, + { "d1", "a/b/file.tmp", -1, 0, 0 }, + { "d1", "file.txt", 0, 0, 0 }, + { "d1", "a/b/file.txt", 0, 0, 0 }, + { 0 } + } + }, + { + "local absolute leading double-star exclude", + { + { -1, 0, "projects/sub/", "/**.tmp" }, + { 0 } + }, + { + { "d1", "projects/sub/file.tmp", -1, 0, 0 }, + { "d1", "projects/sub/a/file.tmp", -1, 0, 0 }, + { "d1", "projects/sub/a/b/file.tmp", -1, 0, 0 }, + { "d1", "projects/file.tmp", 0, 0, 0 }, + { "d1", "projects/submarine/file.tmp", 0, 0, 0 }, + { 0 } + } + }, + { + "absolute leading double-star suffix", + { + { 1, 0, "", "/**foo" }, + { 0 } + }, + { + { "d1", "foo", 0, 0, -1 }, + { "d1", "dir/foo", 0, 0, -1 }, + { "d1", "a/b/foo", 0, 0, -1 }, + { "d1", "bar", -1, 0, -1 }, + { 0 } + } + }, + { + "absolute trailing double-star", + { + { 1, 0, "", "/**" }, + { 0 } + }, + { + { "d1", "file", 0, 0, -1 }, + { "d1", "dir/file", 0, 0, -1 }, + { "d1", "a/b/file", 0, 0, -1 }, + { 0 } + } + }, + { + "absolute leading double-star directory", + { + { -1, 0, "", "/**foo/" }, + { 0 } + }, + { + { "d1", "foo", 0, -1, -1 }, + { "d1", "a/b/foo", 0, -1, -1 }, + { "d1", "a/b/foo/file", -1, 0, 0 }, + { "d1", "a/b/bar", 0, 0, 0 }, + { 0 } + } + }, + { + "established absolute double-star forms", + { + { 1, 0, "", "/**/foo" }, + { 1, 0, "", "/foo/**/bar" }, + { 0 } + }, + { + { "d1", "foo", 0, 0, -1 }, + { "d1", "a/b/foo", 0, 0, -1 }, + { "d1", "foo/bar", 0, 0, -1 }, + { "d1", "foo/a/b/bar", 0, 0, -1 }, + { "d1", "other/bar", -1, 0, -1 }, + { 0 } + } + }, { "filter with literal caret in pattern and root", { diff --git a/cmdline/str.c b/cmdline/str.c index f2bd43b0..d338a56b 100644 --- a/cmdline/str.c +++ b/cmdline/str.c @@ -373,9 +373,15 @@ static inline int is_wild_escape(char c) #endif } -int wnmatch_sub(const char* p, const char* t, int match_sub) +/* + * Match with the previous pattern character provided in prev. + * + * An initial '**' needs to know if it was preceded by '/' to distinguish + * between multi-directory recursive matching (like /##.ext) and single-directory + * matching (like ##.ext). + */ +int wnmatch_sub_prev(const char* p, const char* t, int match_sub, char prev) { - char p1 = 0; /* previous char */ while (*p) { char p0 = *p; switch (p0) { @@ -417,7 +423,7 @@ int wnmatch_sub(const char* p, const char* t, int match_sub) ++p; /* if its not near a slash, it's like a single * */ - if (p1 == '/' || *p == '/') { + if (prev == '/' || *p == '/') { /* a ** at end matches everything */ if (*p == 0) return 0; @@ -432,7 +438,7 @@ int wnmatch_sub(const char* p, const char* t, int match_sub) * "##/file.txt" matching "file.txt" * "x##/file.txt" NOT matching "xfile.txt" */ - if (*p == '/' && (p1 == 0 || p1 == '/')) { + if (*p == '/' && (prev == 0 || prev == '/')) { /* try reducing to nothing */ if (wnmatch_sub(p + 1, t, match_sub) == 0) return 0; @@ -489,9 +495,14 @@ int wnmatch_sub(const char* p, const char* t, int match_sub) ++t; break; } - p1 = p0; + prev = p0; } return wnmatch_end(t, match_sub); } +int wnmatch_sub(const char* p, const char* t, int match_sub) +{ + return wnmatch_sub_prev(p, t, match_sub, 0); +} + diff --git a/cmdline/str.h b/cmdline/str.h index 1b03bf19..e19770e1 100644 --- a/cmdline/str.h +++ b/cmdline/str.h @@ -133,6 +133,20 @@ unsigned strtou(const char* nptr, char** endptr, int base); */ int wnmatch_sub(const char* p, const char* t, int match_sub); +/** + * Match with the pattern character immediately before p provided as prev context. + * + * This is needed for absolute filters where the leading '/' in the filter pattern + * is stripped before calling match (because target paths do not have a leading slash). + * Providing prev='/' preserves the information that an initial '**' was preceded by + * a slash, enabling recursive multi-directory matching (e.g. /##.ext or /##foo, + * using # instead of * to avoid C comment delimiter collision). + * + * Recursive sub-matches intentionally start with prev=0 because intermediate matching + * evaluates suffixes where preceding pattern characters are tracked in the loop. + */ +int wnmatch_sub_prev(const char* p, const char* t, int match_sub, char prev); + static inline int wnmatch(const char* p, const char* t) { return wnmatch_sub(p, t, 0);