Fix symlink handling in CleanupLocalProcess + real-filesystem tests - #2
Open
nitrobass24 wants to merge 1 commit into
Open
Fix symlink handling in CleanupLocalProcess + real-filesystem tests#2nitrobass24 wants to merge 1 commit into
nitrobass24 wants to merge 1 commit into
Conversation
…ing them (#663) Follow-up to the #663 review. Three gaps remained after d65ab79: 1. `islink` was still checked after the containment check. `_is_contained` resolves the final component, so a local-only symlink pointing outside the folder resolved outside the base and was reported as path traversal — never unlinked. Since failures now raise, that turned into a permanently failing cleanup: the button stays enabled and every retry fails. Replaced with `_resolve_child_path`, which resolves ancestor components (a symlinked directory still cannot be used to escape) but leaves the final component unresolved, so the link is unlinked where it sits without touching its target. Also rejects `.`/`..`/empty basenames — `..` passed commonpath and would have rmtree'd the parent folder. 2. `os.path.exists` is False for a dangling symlink, so it was logged as non-existing and left in place. Switched to `os.path.lexists`. 3. The new tests mocked realpath/islink/isfile/exists, which is what hid (1) — the traversal test asserted against a fake realpath. TestCleanupLocalProcess now runs against real temp directories and covers symlink-to-dir, symlink-pointing-outside, dangling symlink, real `../../outside.txt` traversal, symlinked-ancestor escape, `..` basename, and batch continuation. The PermissionError case faults os.unlink for a single path instead of using chmod, which does not deny root in the CI container. Also adds the missing Angular spec for the `remote_size !== null` guard in isCleanupLocalable, with a positive re-emit so it cannot pass on the status gate. Verified: the two symlink tests fail against d65ab79 and pass here.
nitrobass24
force-pushed
the
fix/663-cleanup-symlink-handling
branch
from
August 26, 2026 02:42
a9521fc to
9d67497
Compare
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.
Follow-up to my review on nitrobass24#663. Everything else from that review landed cleanly in d65ab79 — this covers the three things still outstanding on the
delete_process.pycomment, plus one missing Angular spec. Targets this branch so it rides along in nitrobass24#663 rather than becoming a separate PR.1. Symlink pointing outside the folder was never removed
islinkwas still checked after the containment check._is_containedresolves the final component viarealpath, so a local-only symlink pointing outside the folder resolves outside the base and is reported as path traversal — it never gets unlinked.This is reachable: the scanner's
entry.is_dir()follows symlinks, so a symlink to an external directory is scanned as a directory, enters the model withremote_size = None, and is returned by_find_local_only_paths.Because failures now raise (correctly), it became a permanently failing cleanup — the other paths get deleted, but the command always reports failure,
isCleanupLocalablestays true, and every retry fails the same way.Replaced with
_resolve_child_path, which resolves ancestor components — so a symlinked directory still can't be used to escape the base — but leaves the final component unresolved, so the link is unlinked where it sits without touching its target.It also rejects
./../ empty basenames...previously passedcommonpath(commonpath(["/base/f", "/base/f/.."])returns/base/f) and would havermtree'd the parent folder.2. Dangling symlinks
os.path.existsisFalsefor a dangling symlink, so it was logged as non-existing and left in place. Nowos.path.lexists.Low severity in practice — the scanner's
entry.stat()raisesFileNotFoundErroron a dangling link and skips it, so it only reaches this code through a scan/cleanup race — but it's a one-word fix.3. Tests now run against a real filesystem
The mocked tests are what hid issue 1:
test_relative_path_escaping_base_blockedasserted against arealpathstub returning/etc/passwd, so it proved nothing about real resolution, and symlink-to-dir / dangling-symlink had no coverage at all.TestCleanupLocalProcessnow uses real temp directories and covers symlink-to-dir, symlink-pointing-outside, dangling symlink, real../../outside.txttraversal (asserting the outside file survives), symlinked-ancestor escape,..basename, and batch continuation.The
PermissionErrorcase faultsos.unlinkfor a single path rather than usingchmod, which doesn't deny root in the CI container. Note it has to pass through**kwargs—rmtreeunlinks its own entries via the same patch.4. Missing Angular spec
Added the spec for the
modelFile.remote_size !== nullguard inisCleanupLocalable. The guard is load-bearing (aDEFAULTfolder maps to a status inLOCAL_ACTION_STATUSES) but had no coverage. Includes a positive re-emit so it can't pass on the status gate instead.Test plan
delete_process.pyaloneTestCleanupLocalProcess19/19 passtest_scan_file_with_latin_chars, which also fails on a cleandevelop— APFS rejects the non-UTF8 filename)ruff check/ruff format --check/C901clean,pyright0 errorsng test578 passed,ng lintcleanOne thing left for you to call
I left "path does not exist" counting as a failure rather than a warning. With
lexistsit now only fires when something genuinely vanished, but a benign scan/cleanup race will still fail the whole batch. Happy to soften it to a warning if you'd rather.