Skip to content

path: Force-remove files in unlink() - #130

Merged
Rahix merged 3 commits into
Rahix:masterfrom
kvmajo:fix-unlink-force
Aug 25, 2026
Merged

path: Force-remove files in unlink()#130
Rahix merged 3 commits into
Rahix:masterfrom
kvmajo:fix-unlink-force

Conversation

@kvmajo

@kvmajo kvmajo commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Fixes #128.

Path.unlink() removes a file with a plain rm:

self.host.exec0("rm", self)

If the file is write-protected (e.g. -r--r--r--) but the containing
directory still permits deletion, GNU rm asks an interactive
confirmation question before removing it:

rm: remove write-protected regular file '...'?

Since tbot's channel has no way to answer that question, exec0()
never sees a shell prompt again and hangs indefinitely instead of
raising an error.

unlink() already resolves existence/missing_ok itself before
running rm, so nothing is lost by making the actual removal
non-interactive as well, via -f.

Testing

  • pre-commit's pinned black, flake8, and mypy all pass on the
    changed file.
  • selftest/tests/test_path.py has the same pass/fail counts before
    and after this change (two pre-existing, unrelated failures in
    test_rglob_error reproduce identically on unmodified master).
  • Reproduced the actual bug: unlinking a chmod 400 file hangs
    (confirmed via timeout) on unmodified master, and completes cleanly
    on this branch.

Path.unlink() removes a file with a plain `rm`.  If the file is
write-protected (e.g. -r--r--r--) but the containing directory still
permits deletion, GNU rm asks an interactive confirmation question
before removing it.  Since tbot's channel has no way to answer that
question, exec0() never sees a shell prompt again and hangs
indefinitely instead of raising an error.

unlink() already resolves existence/missing_ok itself before running
rm, so nothing is lost by making the actual removal non-interactive
as well, via -f.

Fixes: Rahix#128
Signed-off-by: Martin Jocic <martin.jocic@kvaser.com>
@Rahix

Rahix commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Hi, thanks for the contribution!

I just confirmed, Python pathlib's .unlink() also removes a 0400 file without complaining so we should do the same. I think your change is fine, but please add a new testcase that creates such an 0400 file and then unlinks it.

The -f makes me a bit anxious, I think we should add some more safeguards here. This would have been a good idea regardless, I wonder why I didn't do it back then already.

I just checked, busybox rm also supports -- to signal end of flags so maybe we should just add a -- before the self argument to ensure you can't pass filenames that will be interpreted as flags? Alternatively we could add a ./ to any non-absolute paths which may be a bit more portable.

@Rahix

Rahix commented Aug 20, 2026

Copy link
Copy Markdown
Owner

two pre-existing, unrelated failures in test_rglob_error reproduce identically on unmodified master

What were those about? I cannot reproduce them here and CI also seems happy... Can you open a new issue for this topic, please?

kvmajo added 2 commits August 24, 2026 11:55
rm -f self could misinterpret a filename that happens to start with a
dash as a flag instead of a path.  Add a `--` before the path
argument to make sure it is always treated as the operand, not an
option.  busybox's rm also supports `--`, so this works across the
rm implementations tbot might run against.

Signed-off-by: Martin Jocic <martin.jocic@kvaser.com>
Add a testcase that creates a 0400 (write-protected) file and then
unlinks it, to cover the fix for unlink() hanging on such files
(rm -f in path.py's unlink()).  Matches the behavior of Python's
pathlib, which also removes a write-protected file without
complaining as long as the containing directory permits it.

Signed-off-by: Martin Jocic <martin.jocic@kvaser.com>
@kvmajo

kvmajo commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Good points, thanks! I've pushed two commits addressing both:

  • path: Guard unlink() against flag-like filenames — adds -- before the path argument, so rm -f -- self.
  • selftest: Add testcase for unlinking write-protected files — adds test_unlink_write_protected, which creates a 0400 file and confirms unlink() still removes it as long as the containing directory allows it (matching pathlib's behavior).

Ran the selftest suite locally (LocalhostBash/LocalhostAsh backends) and the new test passes alongside the existing path/unlink tests, no regressions.

@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 62.47%. Comparing base (1853255) to head (1371013).
⚠️ Report is 8 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #130      +/-   ##
==========================================
+ Coverage   62.44%   62.47%   +0.02%     
==========================================
  Files          53       53              
  Lines        3762     3765       +3     
==========================================
+ Hits         2349     2352       +3     
  Misses       1413     1413              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@kvmajo

kvmajo commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

It's an assertion in test_rglob_error:

result = list(path.rglob("cpu"))
assert len(result) > 3

On my machine, /sys only has 3 entries literally named cpu: /sys/bus/cpu, /sys/kernel/reboot/cpu, /sys/devices/system/cpu. My best guess is the test's > 3 assumes a 4th one at /sys/fs/cgroup/cpu, which only exists under legacy cgroup v1/hybrid mounts. My machine runs pure cgroup v2 (unified hierarchy — cgroup2 on /sys/fs/cgroup), where CPU accounting is exposed as files, not a cpu-named directory, so there's nothing to make up that 4th match.

I haven't been able to confirm this against the actual CI runner's cgroup mode directly, so take it as a plausible explanation rather than a confirmed one — but it would fit why CI and your own machine are happy while mine isn't.

Given that, do you still want me to open a new issue for this or do you prefer to do it yourself?

@Rahix

Rahix commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Given that, do you still want me to open a new issue for this or do you prefer to do it yourself?

Damn, I see, made too many assumptions about Linux systems again. Yes, please do open a new issue anyway to track it. Or alternatively, if you want, you can also send a PR to fix it: it's fine if we just reduce the >3 to >2 or more ideally, create a synthetic structure inside the testdir and rglob() against that.

@Rahix Rahix left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks all good, thanks a lot!

@Rahix
Rahix merged commit fed12a8 into Rahix:master Aug 25, 2026
3 checks passed
@kvmajo

kvmajo commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Issue #132 opened.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Path.unlink() should force-remove to avoid hanging on an interactive "remove write-protected file?" prompt

2 participants