fix(wiki): match glob against symlink-resolved scan root

The base directory is canonicalized via toRealPath before walking, so the
walked files carry the symlink-resolved prefix. The PathMatcher was built
from the literal pattern, so a symlinked base never matched and files were
silently dropped. Rebuild the glob against the resolved scan root, escaping
glob metacharacters in the base so a real directory name containing */?/{}/[]
is treated literally.
This commit is contained in:
倪程伟 2026-06-07 23:01:23 +08:00 committed by matevip
parent 7f4d3e3ab9
commit 37e9afa9de
2 changed files with 102 additions and 1 deletions

View File

@ -226,8 +226,17 @@ public class WikiDirectoryScanService {
errors.add("Base directory does not exist: " + scanRoot);
return;
}
// validateDirectory canonicalizes via toRealPath, so scanRoot is the
// symlink-resolved real path and walkFileTree yields real-path-prefixed
// files. The matcher must use that resolved base, not the literal pattern
// prefix otherwise a symlinked base never matches. Rebuild the pattern
// by swapping the literal base for the resolved scanRoot, keeping the
// wildcard tail; escape glob metacharacters in the base so a real
// directory name containing */?/{}/[] is treated literally.
String wildcardTail = pattern.substring(basePath.length());
String effectivePattern = globEscape(scanRoot.toString()) + wildcardTail;
try {
matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern);
matcher = FileSystems.getDefault().getPathMatcher("glob:" + effectivePattern);
} catch (IllegalArgumentException e) {
errors.add("Invalid glob pattern '" + pattern + "': " + e.getMessage());
return;
@ -302,6 +311,19 @@ public class WikiDirectoryScanService {
return s.contains("*") || s.contains("?") || s.contains("{") || s.contains("[");
}
/** Escape glob metacharacters so a literal path segment is matched verbatim. */
private static String globEscape(String s) {
StringBuilder b = new StringBuilder(s.length());
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if ("\\*?{}[]".indexOf(c) >= 0) {
b.append('\\');
}
b.append(c);
}
return b.toString();
}
private static String getExtension(String fileName) {
int dot = fileName.lastIndexOf('.');
return dot > 0 ? fileName.substring(dot + 1).toLowerCase() : "";

View File

@ -0,0 +1,79 @@
package vip.mate.wiki.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Verifies glob matching works when the pattern's base directory is reached
* through a symbolic link. {@code WikiSourcePathValidator} canonicalizes the
* base with {@code toRealPath()}, so the walked files carry the symlink-resolved
* prefix; the matcher must be built against that resolved scan root rather than
* the literal pattern prefix, otherwise nothing matches and files are silently
* dropped.
*/
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.NONE,
properties = {
"spring.flyway.enabled=true",
"spring.flyway.locations=classpath:db/migration/h2",
"mateclaw.feature-flag.refresh-ms=999999",
"mate.wiki.auto-process-on-upload=false"
}
)
class WikiGlobSymlinkBaseE2ETest {
@Autowired
private WikiDirectoryScanService scanService;
private static final java.util.concurrent.atomic.AtomicLong SEQ =
new java.util.concurrent.atomic.AtomicLong(System.nanoTime());
@Test
void globWithSymlinkBase_singleLevel_matches(@TempDir Path base) throws IOException {
Path realDir = Files.createDirectories(base.resolve("real"));
Files.writeString(realDir.resolve("a.txt"), "alpha");
Files.write(realDir.resolve("b.pdf"), "PDF-bytes".getBytes());
Path linkDir = base.resolve("link");
try {
Files.createSymbolicLink(linkDir, realDir);
} catch (UnsupportedOperationException | IOException e) {
return; // filesystem without symlink support skip
}
WikiDirectoryScanService.ScanResult result =
scanService.scanDirectory(SEQ.incrementAndGet(), linkDir + "/*.txt");
// Only a.txt matches; the .pdf is excluded by the explicit *.txt pattern.
// Before the fix the symlink-resolved file prefix never matched the literal
// pattern prefix, so added would be 0.
assertEquals(1, result.added(), "glob through a symlinked base must match the .txt file");
}
@Test
void globWithSymlinkBase_recursive_matchesSubdir(@TempDir Path base) throws IOException {
Path realDir = Files.createDirectories(base.resolve("real"));
Files.createDirectories(realDir.resolve("sub"));
Files.writeString(realDir.resolve("sub/c.txt"), "charlie");
Path linkDir = base.resolve("link");
try {
Files.createSymbolicLink(linkDir, realDir);
} catch (UnsupportedOperationException | IOException e) {
return; // filesystem without symlink support skip
}
WikiDirectoryScanService.ScanResult result =
scanService.scanDirectory(SEQ.incrementAndGet(), linkDir + "/**/*.txt");
assertEquals(1, result.added(), "recursive glob through a symlinked base must match the nested .txt file");
}
}