Preserve leading slash context for absolute double-star filters

This commit is contained in:
Andrea Mazzoleni 2026-08-26 20:06:26 +02:00
parent fcb6a5ca91
commit fa81c20ee6
4 changed files with 170 additions and 7 deletions

View file

@ -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 */

View file

@ -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",
{

View file

@ -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);
}

View file

@ -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);