Repository navigation
fix: scan deeply nested paths in linear memory and past PATH_MAX - #1778
Merged
Merged
Conversation
egibs
enabled auto-merge (squash)
October 27, 2026 15:52
mattlorimor
approved these changes
Oct 27, 2026
The directory walkers recursed once per level and kept each level's path and root alive, so memory grew with the square of a path's length: a 130 KB zip nesting a file 32,765 directories deep took 3.6 GiB. Paths past PATH_MAX were not handled at all: a nested archive there was scanned as an opaque file, an archive found there in a scanned directory was skipped with "file name too long", and every file there holding the UPX marker was taken for a UPX binary, because UPX could not open it. - Add file.Walker, an iterative walker with fs.WalkDir's order, errors, and SkipDir semantics. It keeps one path buffer and lets go of a directory once it yields the directory's last entry, so a chain holds one open directory. file.WalkDir runs on it, and the scan walk keeps its 16-way parallelism by detaching subdirectories into goroutines that each run their own Walker. - Make file.OpenRoot, and the path helpers built on it, reach a path longer than the operating system accepts through a root on a prefix of it, which resolves the rest an element at a time. - Stage archives at paths longer than file.MaxDirectPath: link or copy the archive, through its directory's root, to a short path, extract it there, and move the result where it belongs. Extractors and their symlink checks are unchanged, and the files, and so the report keys, land where they would have. UPX checks a short copy likewise. - Detect nested archive kinds from content read through the root. - Serialize building, loading, and destroying YARA-X rules on macOS. wasmtime registers each compiled module's unwind information with libunwind, whose global cache deadlocks under many concurrent registrations and deregistrations: every caller waits for a lock no thread holds. internal/yrx wraps those calls, and replaces the binding's finalizer so rules the collector frees are destroyed through it too. Linux completed 361,680 concurrent builds over 10 minutes without stalling, so the lock is macOS-only. A forbidigo rule keeps calls going through internal/yrx. - make test, which CI runs, now covers ./internal/... as well. Signed-off-by: egibs <20933572+egibs@users.noreply.github.com>
egibs
force-pushed
the
fix/deep-path-walkers
branch
from
October 27, 2026 16:05
e5a5b44 to
15ee2ce
Compare
mattlorimor
approved these changes
Oct 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Follow-up to #1777. Deeply nested paths no longer cost memory quadratic in their length, and paths past
PATH_MAXare extracted and scanned instead of skipped or misread.What was wrong
Measured on Linux with a zip whose single entry nests a file 32,765 directories deep (a 64 KB name, about 130 KB of zip):
file.WalkDir, the scan walk) recursed once per level and kept every level's path string and directory root alive, so memory grew with the square of the path's length.PATH_MAX(1,024 bytes on macOS, 4,096 on Linux) broke in three ways, because extractors andupxopen and check paths whole:UPX!marker was classified as UPX, becauseupxcould not open it and any failure other than "not packed" counts as packed.mal scan, file 32,765 levels deepExtractArchiveToTempDir, same zipPATH_MAXPATH_MAXin a scanned directoryChanges
file.Walker: an iterative walker withfs.WalkDir's order, error entries, andSkipDir/SkipAllsemantics (TestWalkDirMatchesFSWalkDirstill compares it tofs.WalkDir). It keeps one path buffer and lets go of a directory once it yields the directory's last entry, so a chain holds one open directory.file.WalkDirruns on it. The scan walk keeps its 16-way parallelism byDetaching subdirectories into goroutines that each run their ownWalker, with results spliced back in order (TestWalkScanPathMatchesASequentialWalk).file.OpenRoot: when the OS refuses a path as too long, it opens a root on a prefix of at mostfile.MaxDirectPathbytes and resolves the rest through that root. Shorter paths take the existing route unchanged. Thepkg/filepath helpers andprogramkind.Filego through it.pkg/archive/stage.go): an archive at a path longer thanfile.MaxDirectPathis hard-linked (or copied) through its directory's root to a short path, extracted there, and the result moved into place withroot.Rename. Extractors and their symlink validation are unchanged. Symlinks they create stay within what they wrote, and the move keeps their relative layout. Single-file extractors that name output after the archive's directory get a staged copy in a directory of that name. Files, and so report keys, land exactly where they would have.IsValidUPXchecks a short temporary copy of a file at a long path. Passing the descriptor as/dev/fd/3doesn't work: on Linux that path is a symlink, whichupxrefuses.nestedTree.extractFilereads content through the root.macOS deadlock in yara-x
The first
make verifyrun on this branch stopped making progress inpkg/report. Many goroutines were insideyrx_compiler_buildandyrx_rules_destroy, all idle. A single-process reproducer (one thread compiling the bundled rules in a loop, twelve compiling, scanning, and destroying a small rule set) stalls on macOS after about 116,000 operations (40 seconds). Native stacks show every thread waiting for libunwind'sDwarfFDECachewrite lock, which no thread holds:On Linux the same reproducer completed 361,680 operations over 10 minutes without stalling.
internal/yrxwrapsBuild,Compile,ReadFrom, andDestroy, and runs them one at a time on macOS only.Buildreplaces the binding's finalizer, so rules the garbage collector frees are destroyed through the lock too.internal/yrx, the reproducer ran 219,318 operations over 3 minutes on macOS without stalling.forbidigorule with type analysis rejects direct calls outsideinternal/yrx. It caught three method-value uses (t.Cleanup(rules.Destroy)) that grep had missed.Testing
TestWalkDirDeepChainHoldsLinearMemoryandTestWalkScanPathDeepChainHoldsLinearMemory(2.7 MB held before, against a 102 KB limit; 0 and 18 KB now). Their 6.4 KB path exceedsPATH_MAXbut stays under the 8 KB that AppArmor checks; past that, GitHub's runners refuse to create the path at all;TestScanNestedArchiveBeyondPathMaxandTestScanArchiveBeyondPathMax;TestExtractArchiveToTempDirStagesDeepNestedArchives: zip, gzip, zstd (parent-named output), and a corrupt archive produce identical trees at a short path and pastPATH_MAX, with nothing left from staging;TestIsValidUPXBeyondPathMax, with a stubupxthat answers from the file it opens;TestPathHelpersBeyondPathMaxandTestSplitLong.internal/yrxtests: build, and round-trip throughReadFrom.TestCallsRunInsideTheLockrecords that every wrapper runs lock, then the yara-x call, then unlock, andTestLockHoldsTheMutex(macOS) checks thatlockholds the mutex. Both are deterministic and fail when either piece is removed.Walkerunit tests:Detach,NewDirWalker,SkipDiron files and directories, andClosepartway through releasing every descriptor.make verifypasses on darwin.make refresh-sample-testdata(run on Linux, as CI does) refreshed all 396 samples with no changes.