diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDirectoryScanService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDirectoryScanService.java index 6de513e5..1f7e5961 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDirectoryScanService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiDirectoryScanService.java @@ -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() : ""; diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiGlobSymlinkBaseE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiGlobSymlinkBaseE2ETest.java new file mode 100644 index 00000000..742132de --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiGlobSymlinkBaseE2ETest.java @@ -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"); + } +}