From 6bdc1d32b4037628ba94ed1ac72ee508734b1720 Mon Sep 17 00:00:00 2001 From: Yann Collet Date: Sat, 7 Nov 2026 20:45:55 -0800 Subject: [PATCH] programs: don't follow symbolic links and junctions in -r on Windows The CLI does not follow symbolic links by default (only -f does), but on Windows UTIL_isLink() always returned 0, and the recursive directory walk entered symbolic links and junctions like ordinary directories. -r could then compress, and with --rm delete, files outside the given directory. Treat name surrogate reparse points, i.e. symbolic links and junctions, as links, like lstat() does on POSIX. Other reparse points, such as OneDrive placeholders or deduplicated files, remain regular files and directories. Fixes #4853. The test comes from #4860. Co-authored-by: Nmd-sketch --- programs/util.c | 34 ++++++++++++++++++++++++++++++++++ tests/playTests.sh | 28 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/programs/util.c b/programs/util.c index 652530b1223..edd858ba723 100644 --- a/programs/util.c +++ b/programs/util.c @@ -490,6 +490,17 @@ int UTIL_isBlockDevStat(const stat_t* statbuf) return 0; } +#if defined(_WIN32) +/* Symbolic links and junctions are "name surrogate" reparse points. + * Other reparse points (OneDrive placeholders, deduplicated files, ...) + * are regular files and directories : they must not be treated as links. */ +static int UTIL_isNameSurrogate(DWORD attributes, DWORD reparseTag) +{ + return (attributes & FILE_ATTRIBUTE_REPARSE_POINT) + && IsReparseTagNameSurrogate(reparseTag); +} +#endif + int UTIL_isLink(const char* infilename) { UTIL_TRACE_CALL("UTIL_isLink(%s)", infilename); @@ -503,6 +514,24 @@ int UTIL_isLink(const char* infilename) return 1; } } +#elif defined(_WIN32) + { + DWORD const attributes = GetFileAttributesA(infilename); + if ( attributes != INVALID_FILE_ATTRIBUTES + && (attributes & FILE_ATTRIBUTE_REPARSE_POINT) ) { + /* GetFileAttributes() does not report the reparse tag, FindFirstFile() does */ + WIN32_FIND_DATAA findData; + HANDLE const hFind = FindFirstFileA(infilename, &findData); + if (hFind != INVALID_HANDLE_VALUE) { + int const isLink = UTIL_isNameSurrogate(findData.dwFileAttributes, findData.dwReserved0); + FindClose(hFind); + if (isLink) { + UTIL_TRACE_RET(1); + return 1; + } + } + } + } #endif (void)infilename; UTIL_TRACE_RET(0); @@ -942,6 +971,11 @@ static int UTIL_prepareFileList(const char* dirName, memcpy(path+dirLength+1, cFile.cFileName, fnameLength); pathLength = dirLength+1+fnameLength; path[pathLength] = 0; + if (!followLinks && UTIL_isNameSurrogate(cFile.dwFileAttributes, cFile.dwReserved0)) { + UTIL_DISPLAYLEVEL(2, "Warning : %s is a symbolic link, ignoring\n", path); + free(path); + continue; + } if (cFile.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { if ( strcmp (cFile.cFileName, "..") == 0 || strcmp (cFile.cFileName, ".") == 0 ) diff --git a/tests/playTests.sh b/tests/playTests.sh index fd20d59c25f..f1c26c8d9a7 100755 --- a/tests/playTests.sh +++ b/tests/playTests.sh @@ -443,6 +443,34 @@ mkdir -p tmpEmptyDir zstd -r tmpEmptyDir rm -rf tmpEmptyDir +println "test : directory links are not followed by -r" +# remove leftovers of a previous failed run +if [ "$isWindows" = true ]; then cmd //c "rmdir tmpLinkDir\tmpLink" 2> /dev/null || :; fi +rm -rf tmpLinkDir tmpOutside tmpStdErr +mkdir -p tmpLinkDir tmpOutside +echo "inner" > tmpLinkDir/tmpInner +echo "victim" > tmpOutside/tmpVictim +if [ "$isWindows" = true ]; then + # junctions don't require the privileges that symlinks do + (cd tmpLinkDir && cmd //c "mklink /J tmpLink ..\tmpOutside") +else + ln -s ../tmpOutside tmpLinkDir/tmpLink +fi +zstd -r --rm tmpLinkDir 2> tmpStdErr +# remove the link before asserting, so a failing test never leaves a live +# link behind for the later "rm -rf tmp*" cleanups to encounter +if [ "$isWindows" = true ]; then + cmd //c "rmdir tmpLinkDir\tmpLink" +else + rm -f tmpLinkDir/tmpLink +fi +$GREP "symbolic link" tmpStdErr +test -f tmpLinkDir/tmpInner.zst # file inside the tree was compressed +test ! -f tmpLinkDir/tmpInner # ... and removed (--rm) +test -f tmpOutside/tmpVictim # file outside the tree was left untouched +test ! -f tmpOutside/tmpVictim.zst # ... not compressed through the link +rm -rf tmpLinkDir tmpOutside tmpStdErr + println "\n===> file removal" zstd -f --rm tmp