51 Commits
Author SHA1 Message Date
mdaleo404 6405ae07f1 Add DEVELOPMENT.md and SECURITY.md, fix python version in pre-commit config 2026-06-28 09:12:51 +01:00
mdaleo404 779477de7a Loosen up pre-commit Black python version 2026-06-24 13:36:14 +01:00
mdaleo404 f7fe951d15 Merge pull request 'Poetry update' (#26) from poetry-update-2.3.3 into main
Security Scan / security-scan (push) Successful in 1m20s
Reviewed-on: #26
2026-04-03 07:02:12 +00:00
mdaleo404 53f594ce13 Poetry update
Lint & Security / precommit-and-security (pull_request) Successful in 2m1s
2026-04-03 07:58:42 +01:00
mdaleo404 bf1480f2c2 Exclude unfixed vulnerabilities from security workflow results
Security Scan / security-scan (push) Successful in 1m19s
2026-03-25 16:38:11 +00:00
mdaleo404 fa003084e6 Switch Trivy scan to Syft and Grype 2026-03-25 16:18:22 +00:00
mdaleo404 c1b73d15d9 Disable trivy scan workflow 2026-03-23 08:03:11 +00:00
mdaleo404 5e7ea16d90 Update pre-commit hooks version
Trivy Scan / security-scan (push) Successful in 26s
2026-03-21 07:24:19 +00:00
mdaleo404 5e3f9e309e Ping Trivy docker image to 0.69.3@sha256:bcc376de8d77cfe086a917230e818dc9f8528e3c852f7b1aff648949b6258d1c 2026-03-21 07:04:56 +00:00
mdaleo404 38ed42f4b7 Update filelock and virtualenv
Trivy Scan / security-scan (push) Successful in 26s
2026-01-15 17:00:28 +00:00
mdaleo404 1f95d5b1b1 Add trivy-scan workflow 2026-01-15 16:51:20 +00:00
mdaleo404 c08693d39c Merge pull request 'Make pip-audit run inside Poetry' (#25) from pip_audit_tweak into main
Reviewed-on: #25
2025-12-25 10:25:41 +00:00
mdaleo404 a8a15bab36 Make pip-audit run inside Poetry
Lint & Security / precommit-and-security (pull_request) Successful in 59s
2025-12-25 10:24:05 +00:00
mdaleo404 7719d2442d Add logo file, update README 2025-12-21 07:39:28 +00:00
mdaleo404 58c682d4d3 Merge pull request 'Improve trash output' (#24) from improve_trash_list into main
Reviewed-on: #24
2025-12-13 18:09:46 +00:00
mdaleo404 45d9f5f6c8 Update README, version bump
Lint & Security / precommit-and-security (pull_request) Successful in 1m2s
2025-12-13 18:06:59 +00:00
mdaleo404 659a76f5c9 Make --empty delete dangling files in trash folder not associated with metadata file, edit completer function's name to be reusable 2025-12-13 18:04:55 +00:00
mdaleo404 250077c592 Add --inspect flag 2025-12-13 17:34:35 +00:00
mdaleo404 631843b3c5 Fix installation instructions 2025-12-09 16:15:13 +00:00
mdaleo404 9c653e44a4 Fix release badge link 2025-12-09 15:15:19 +00:00
mdaleo404 cdd3ba0cbd Merge pull request 'Update README and pyproject.toml' (#23) from update_resrm_20251209 into main
Reviewed-on: #23
2025-12-09 15:13:46 +00:00
mdaleo404 eee00bb6ee Edit badges, update installation instructions, swap github.com entries to git.sysmd.uk
Lint & Security / precommit-and-security (pull_request) Successful in 48s
2025-12-09 15:11:54 +00:00
mdaleo404 f9586bbd0e Merge pull request 'Rename .github folder to .gitea. Use pre-commit directly instead of action' (#22) from rename_github_folder into main
Reviewed-on: #22
2025-12-09 13:23:09 +00:00
mdaleo404 51a7001bf2 Rename .github folder to .gitea. Use pre-commit directly instead of action
Lint & Security / precommit-and-security (pull_request) Successful in 47s
2025-12-09 13:19:46 +00:00
mdaleo404 ccf383ebfb Remove .coverage and add that to the .gitignore 2025-12-03 11:58:46 +00:00
Marco D'Aleo 6670c79d47 Merge pull request #21 from guardutils/args_list_fix
Fix list flag to use the long name
2025-12-03 11:57:06 +00:00
mdaleo404 3285fbaef4 Fix list flag to use the long name 2025-12-03 11:55:15 +00:00
Marco D'Aleo c07b7598d0 Merge pull request #20 from guardutils/update_resrm_20251202
Tab completion
2025-12-02 18:02:56 +00:00
mdaleo404 9edae1d233 Add tab completion using argcomplete, update README 2025-12-02 18:01:17 +00:00
mdaleo404 e36ac044d9 Update badges URLs 2025-11-29 16:43:12 +00:00
Marco D'Aleo 1ad635e37e Merge pull request #19 from guardutils/update_resrm_20251127
Switch ownership from mdaleo404 to guardutils in README and pyproject
2025-11-27 17:56:39 +00:00
mdaleo404 649e16c03a Switch ownership from mdaleo404 to guardutils in README and pyproject 2025-11-27 17:55:29 +00:00
Marco D'Aleo fc02895965 Merge pull request #18 from mdaleo404/add_badges_to_readme
Add badges to README
2025-11-23 07:32:05 +00:00
mdaleo404 feb0d313e8 Add badges to README 2025-11-23 07:29:54 +00:00
mdaleo404 af6c7a0797 Fix README 2025-11-17 19:05:32 +00:00
mdaleo404 ccaa2dcb25 Update README with new installation methods 2025-11-17 18:56:51 +00:00
Marco D'Aleo 5bb1437a49 Merge pull request #17 from mdaleo404/update_resrm_20251117
Rename workflow and make it trigger on pull requests
2025-11-17 15:10:09 +00:00
mdaleo404 3c4bbcbc34 Fix typo in workflow's file name 2025-11-17 15:08:51 +00:00
mdaleo404 ba29cc590d Rename workflow and make it trigger on pull requests 2025-11-17 15:06:57 +00:00
Marco D'Aleo 2fd6fbb2c2 Merge pull request #16 from mig5/mig/fix-args-force
Use args.force, not args.f
2025-11-17 14:56:00 +00:00
Miguel Jacq 96f7ebf4fc Use args.force, not args.f 2025-11-17 14:11:32 +11:00
mdaleo404 7ba20632ab Change Python dependecies version. Remove Black target-version from pyproject.toml.Remove CI pull_request trigger. 2025-11-16 14:53:57 +00:00
mdaleo404 f46e699420 Add more checks on pre-commit-config, add CI workflow 2025-11-16 07:04:13 +00:00
Marco D'Aleo b3aff6d8c5 Merge pull request #15 from mdaleo404/dynamic_version
Add function to fetch package version from pyproject.toml
2025-11-15 18:19:22 +00:00
mdaleo404 2013e6b645 Add function to fetch package version from pyproject.toml" 2025-11-15 18:16:50 +00:00
Marco D'Aleo 6a73270f23 Merge pull request #14 from mdaleo404/remove_dev_dependencies
Remove bandit and black from pyproject.toml
2025-11-15 16:57:17 +00:00
mdaleo404 4f1e5043fd Remove bandit and black from pyproject.toml 2025-11-15 16:56:33 +00:00
mdaleo404 877a490b57 Adjust bandit's severity and confidence levels 2025-11-15 08:27:04 +00:00
Marco D'Aleo bcad54d94e Merge pull request #13 from mdaleo404/update_resrm_20251115
Add pre-commit framework and hooks config
- bandit
- black
- trailing-whitespace
- end-of-file-fixer
2025-11-15 08:06:31 +00:00
mdaleo404 80fb24b7c5 Add pre-commit section to README 2025-11-15 08:04:30 +00:00
mdaleo404 5f8d6c03d8 Add pre-commit framework and hooks config 2025-11-15 07:53:04 +00:00
13 changed files with 1777 additions and 60 deletions
+36
View File
@@ -0,0 +1,36 @@
name: Lint & Security
on:
pull_request:
jobs:
precommit-and-security:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install pre-commit
run: pip install pre-commit
- name: Run pre-commit hooks
run: pre-commit run --all-files --color always
- name: Install Poetry
run: |
pip install poetry
poetry self add poetry-plugin-export
- name: Install pip-audit
run: pip install pip-audit
- name: Audit dependencies (Poetry lockfile)
run: |
poetry export -f requirements.txt --without-hashes \
| pip-audit -r /dev/stdin
+188
View File
@@ -0,0 +1,188 @@
name: Security Scan
on:
schedule:
- cron: 27 8 * * *
workflow_dispatch:
jobs:
security-scan:
runs-on: running-man
env:
TARGET_DIR: .
COSIGN_VERSION: v3.0.5
SYFT_VERSION: v1.42.3
GRYPE_VERSION: v0.110.0
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Cosign (bootstrap)
run: |
set -euo pipefail
FILE="cosign-linux-amd64"
curl -fLO https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/${FILE}
chmod +x ${FILE}
mv ${FILE} /usr/local/bin/cosign
cosign version
- name: Install Syft (verified)
run: |
set -euo pipefail
VERSION_NO_V="${SYFT_VERSION#v}"
FILE="syft_${VERSION_NO_V}_linux_amd64.tar.gz"
BASE_URL="https://github.com/anchore/syft/releases/download/${SYFT_VERSION}"
curl -fLO ${BASE_URL}/${FILE}
curl -fLO ${BASE_URL}/syft_${VERSION_NO_V}_checksums.txt
curl -fLO ${BASE_URL}/syft_${VERSION_NO_V}_checksums.txt.sig
curl -fLO ${BASE_URL}/syft_${VERSION_NO_V}_checksums.txt.pem
cosign verify-blob \
--signature syft_${VERSION_NO_V}_checksums.txt.sig \
--certificate syft_${VERSION_NO_V}_checksums.txt.pem \
--certificate-identity-regexp "https://github.com/anchore/syft" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
syft_${VERSION_NO_V}_checksums.txt
CHECKSUM_LINE=$(grep " ${FILE}$" syft_${VERSION_NO_V}_checksums.txt)
if [ -z "$CHECKSUM_LINE" ]; then
echo "Missing checksum entry for ${FILE}"
exit 1
fi
echo "$CHECKSUM_LINE" | sha256sum -c -
tar -xzf ${FILE}
mv syft /usr/local/bin/
syft version
- name: Install Grype (verified)
run: |
set -euo pipefail
VERSION_NO_V="${GRYPE_VERSION#v}"
FILE="grype_${VERSION_NO_V}_linux_amd64.tar.gz"
BASE_URL="https://github.com/anchore/grype/releases/download/${GRYPE_VERSION}"
curl -fLO ${BASE_URL}/${FILE}
curl -fLO ${BASE_URL}/grype_${VERSION_NO_V}_checksums.txt
curl -fLO ${BASE_URL}/grype_${VERSION_NO_V}_checksums.txt.sig
curl -fLO ${BASE_URL}/grype_${VERSION_NO_V}_checksums.txt.pem
cosign verify-blob \
--signature grype_${VERSION_NO_V}_checksums.txt.sig \
--certificate grype_${VERSION_NO_V}_checksums.txt.pem \
--certificate-identity-regexp "https://github.com/anchore/grype" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
grype_${VERSION_NO_V}_checksums.txt
CHECKSUM_LINE=$(grep " ${FILE}$" grype_${VERSION_NO_V}_checksums.txt)
if [ -z "$CHECKSUM_LINE" ]; then
echo "Missing checksum entry for ${FILE}"
exit 1
fi
echo "$CHECKSUM_LINE" | sha256sum -c -
tar -xzf ${FILE}
mv grype /usr/local/bin/
grype version
- name: Generate SBOM
working-directory: ${{ env.TARGET_DIR }}
run: |
syft dir:. -o json > sbom.json
- name: Show SBOM contents
working-directory: ${{ env.TARGET_DIR }}
run: |
echo "Packages discovered by Syft:"
jq -r '.artifacts[] | "\(.name)@\(.version) [\(.type)]"' sbom.json | sort
- name: Run Grype scan (JSON)
id: audit
continue-on-error: true
working-directory: ${{ env.TARGET_DIR }}
run: |
grype sbom:sbom.json -o json > grype.json
echo "Vulnerabilities (fixable only):"
jq -r '
.matches[]
| select((.vulnerability.fix.versions | length) > 0)
| "\(.artifact.name)@\(.artifact.version) -> \(.vulnerability.id) [\(.vulnerability.severity)] | fixed: \(.vulnerability.fix.versions[0])"
' grype.json
# Fail only on fixable MEDIUM/HIGH/CRITICAL
jq -e '
[
.matches[]?
| select(
(
.vulnerability.severity == "Medium" or
.vulnerability.severity == "High" or
.vulnerability.severity == "Critical"
)
and
(
(.vulnerability.fix.versions | length) > 0
)
)
]
| length == 0
' grype.json
- name: Show full Grype table
working-directory: ${{ env.TARGET_DIR }}
run: |
echo "Full Grype report:"
grype sbom:sbom.json -o table
- name: Notify Node-RED on vulnerabilities
if: steps.audit.outcome == 'failure'
working-directory: ${{ env.TARGET_DIR }}
run: |
jq '
{
repo: "guardutils/resrm",
summary: (
"Total: " +
(
[
.matches[]
| select((.vulnerability.fix.versions | length) > 0)
] | length | tostring
)
),
vulnerabilities: [
.matches[]
| select((.vulnerability.fix.versions | length) > 0)
| {
library: .artifact.name,
cve: .vulnerability.id,
severity: .vulnerability.severity,
installed: .artifact.version,
fixed: (.vulnerability.fix.versions[0]),
title: .vulnerability.description,
url: .vulnerability.dataSource
}
]
}
' grype.json \
| curl -s -X POST https://nodered.sysmd.uk/vulns-alert \
-H "Content-Type: application/json" \
--data-binary @-
- name: Fail workflow if vulnerabilities found
if: steps.audit.outcome == 'failure'
run: exit 1
+1
View File
@@ -1,3 +1,4 @@
__pycache__
.pytest_cache
dist
.coverage
+21
View File
@@ -0,0 +1,21 @@
repos:
- repo: https://github.com/PyCQA/bandit
rev: 1.9.4
hooks:
- id: bandit
files: ^src/resrm/
args: ["-lll", "-iii", "-s", "B110,B112"]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 26.3.1
hooks:
- id: black
language_version: python3
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml
+774
View File
@@ -0,0 +1,774 @@
# resrm Development Guide
Interested in the internals of resrm?
This guide describes the current `resrm` codebase for maintainers. It focuses on how the project is organised, what calls what, how removed files flow into the trash store, and which invariants matter when changing the code.
---
## 1. What resrm does
`resrm` is a command-line replacement for common `rm` usage. By default it moves files and directories into a per-user trash directory instead of permanently deleting them.
Its core pipeline is:
```text
Filesystem path
|
| resrm PATH
v
Per-user trash directory
files/<uuid> moved file or directory
metadata.json original path, uuid, deletion timestamp
|
| resrm --restore ID_OR_BASENAME
v
Restored path
original location, or current directory if the original path exists
```
`resrm` deliberately keeps the implementation simple. It does not try to be a full desktop-trash implementation, filesystem snapshot tool, backup system, sandbox, or forensic recovery tool.
The main data stored for a trashed item is:
```text
id random uuid hex string
orig_path resolved original path
timestamp deletion time in ISO format
```
Permanent deletion is still available with:
```bash
resrm --skip-trash PATH
```
Trash entries are also permanently removed by automatic pruning and by `resrm --empty`.
---
## 2. Repository layout
The project is a small Python package under `src/resrm/`.
```text
src/resrm/
__init__.py package marker
cli.py console-script shim that imports core.main
core.py CLI, trash metadata, restore, delete, prune logic
tests/
__init__.py test package marker; no substantive tests currently
pyproject.toml Poetry package metadata and console script
poetry.lock locked dependency graph
README.md user-facing documentation
LICENCE GPL-3.0-or-later licence text
.pre-commit-config.yaml Bandit, Black, and generic pre-commit hooks
.gitea/workflows/ lint, dependency audit, SBOM and Grype workflows
dist/ built release artifacts, not source
```
The installed command is configured in `pyproject.toml`:
```toml
[tool.poetry.scripts]
resrm = "resrm.cli:main"
```
`src/resrm/cli.py` currently contains only:
```python
from resrm.core import main
```
All runtime behaviour is in `src/resrm/core.py`.
---
## 3. Main runtime flows
### 3.1 CLI entry flow
All user-facing behaviour enters through `resrm.core.main()`.
```text
resrm command
-> resrm.cli.main import shim
-> resrm.core.main(argv)
-> prune_old_trash()
-> build argparse parser
-> install argcomplete hooks
-> parse arguments
-> dispatch to list, inspect, empty, restore, or remove branch
```
The supported action surface is:
```text
PATH... move paths to trash by default
-r allow directories
-f, --force ignore missing paths and suppress interactive prompt
-i ask before removing each path
--skip-trash permanently delete instead of moving to trash
-l, --list list current user's trash metadata
--restore ITEM... restore by id prefix or exact basename
--inspect ITEM... show metadata and filesystem details for matches
--empty permanently remove all current user's trash entries
-V, --version print installed package version
```
### 3.2 Subcommand call graph
```mermaid
flowchart TD
A[resrm.core.main] --> B[prune_old_trash]
B --> C[build argparse parser]
C --> D[argcomplete.autocomplete]
D --> E[parse args]
E -->|--list| F[list_trash]
E -->|--inspect| G[inspect_entry]
E -->|--empty| H[empty_trash]
E -->|--restore| I[restore_many]
I --> J[find_candidates]
J --> K[restore_one]
E -->|paths| L[recursive rm-like directory check]
L --> M[move_to_trash]
M -->|default| N[move path into trash/files/uuid]
N --> O[append metadata.json entry]
M -->|--skip-trash| P[unlink or shutil.rmtree]
```
Current dependency direction is intentionally minimal:
```text
cli.py
imports core.main only
core.py
depends on argparse, argcomplete, json, os, shutil, sys, uuid,
datetime, textwrap, importlib.metadata, pathlib, and standard library
pwd/grp/stat imports in platform-specific helper paths
```
If the codebase grows, prefer moving focused behaviours into modules such as `trash.py`, `metadata.py`, and `restore.py` rather than making `core.py` larger.
---
## 4. Trash storage
Trash storage is local filesystem state. There is no database server.
For the effective uid running the command, `get_trash_paths()` returns:
```text
trash directory: <base>/files
metadata file: <base>/metadata.json
```
The base path is chosen by `get_trash_base_for_user(uid)`:
```text
uid 0: /root/.local/share/resrm
other uid: <home from pwd.getpwuid(uid)>/.local/share/resrm
fallback: Path.home()/.local/share/resrm
```
At import time, these globals are initialised:
```python
TRASH_DIR, META_FILE = get_trash_paths()
meta = load_meta()
```
Because `meta` is loaded once at import time, code that changes metadata on disk through a different metadata file must load and save that file explicitly.
### 4.1 Metadata format
`metadata.json` is a JSON list of dictionaries.
Example entry:
```json
{
"id": "f7f3e07ef50a4ec8be0a843f79fbdf1a",
"orig_path": "/home/alice/project/file.txt",
"timestamp": "2026-06-28T10:24:03.123456"
}
```
Important details:
```text
id generated with uuid.uuid4().hex
orig_path stored as str(path.resolve()) after the move succeeds
timestamp generated with datetime.datetime.now().isoformat()
```
There is no schema version field at the time of writing. If metadata format changes, decide whether old metadata files need migration or tolerant reading.
### 4.2 Trash object naming
Trashed filesystem objects are moved to:
```text
<trash base>/files/<uuid hex>
```
The original basename is not used in the stored filename. User-facing commands expose the first eight characters through `short_id()`.
Short ids are convenient but not guaranteed globally unique. `find_candidates()` treats the provided identifier as an id prefix after checking exact basename matches.
---
## 5. Data objects
The codebase currently uses dictionaries rather than dataclasses.
Metadata dictionaries are expected to contain:
```text
id: str
orig_path: str
timestamp: str
```
Primary helpers that consume metadata entries:
```text
short_id(fullid) returns first eight characters
human_time(ts) displays ISO timestamp as YYYY-MM-DD HH:MM
entry_display(entry, width) formats one list-style row; currently unused
find_candidates(identifier) exact basename first, then id prefix
restore_one(entry) moves files/<id> back to a target path
inspect_entry(identifier) prints details from metadata and lstat
```
If adding richer metadata, update every helper that assumes these keys exist.
---
## 6. Removing paths
The removal entry point is the path-processing branch in `main()`.
```text
PATH...
-> for each argument
-> reject directory unless -r is provided
-> move_to_trash(path, interactive, force, skip_trash)
```
`move_to_trash()` handles several behaviours:
```text
missing path:
-f/--force: ignore
otherwise: print an rm-like error
interactive mode:
-i without -f prompts before removal
--skip-trash:
directory and not symlink: shutil.rmtree(path)
otherwise: path.unlink()
default trash mode:
reject root-owned path unless running as euid 0
choose trash base from owner uid when possible
move path to files/<uuid>
append metadata entry to that owner's metadata.json
```
### 6.1 Directory handling
The CLI mimics common `rm` behaviour for directories:
```text
directory without -r: print "Is a directory" and skip
directory with -r: move the directory tree to trash
directory with -r --skip-trash: permanently remove it with shutil.rmtree
```
There is no separate `-R` alias at the time of writing.
### 6.2 Force and interactive behaviour
`-f` suppresses errors for missing paths and disables the interactive prompt in `move_to_trash()` because the prompt only runs when `interactive and not force`.
`-i` asks:
```text
remove 'PATH'? [y/N]
```
Only the exact answer `y` proceeds.
### 6.3 Root-owned files
Before moving to trash, `move_to_trash()` checks:
```python
st = path.stat()
if st.st_uid == 0 and os.geteuid() != 0:
print("resrm: permission denied: ... (root-owned file, try sudo)")
return
```
This is a product guardrail. It avoids giving non-root users the impression that `resrm` can safely or consistently manage root-owned files. It is not a privilege boundary by itself.
### 6.4 Owner-based trash selection
Default trash mode selects the trash base from the file owner's uid when possible, not necessarily from the invoking user's uid:
```text
owner uid -> pwd.getpwuid(owner_uid).pw_dir -> ~/.local/share/resrm
fallback -> current TRASH_DIR.parent
```
This matters for `sudo resrm`: root can move a user-owned file into that user's resrm trash area instead of root's global trash area.
If changing this behaviour, consider restore visibility, ownership, sudo workflows, and existing metadata files.
---
## 7. Listing and inspecting trash
### 7.1 Listing
`list_trash()` reads the in-memory `meta` list for the current effective user and prints:
```text
ID Deleted at Original path
-------- ------------------- -------------
```
Long paths are shortened from the left to fit a display width of 80 characters.
`list_trash()` does not verify that each corresponding `files/<id>` object still exists. It displays metadata state.
### 7.2 Inspecting
`inspect_entry(identifier)` uses `find_candidates()` and prints details for every matching entry:
```text
ID
Original
Deleted at
Stored at
Type
Size
Permissions
Ownership
```
It uses `trash_path.lstat()` so symlink entries are inspected as symlinks rather than through their targets. Type display currently distinguishes directories, symlinks, and files.
`--inspect` uses the current effective user's global `TRASH_DIR` and `meta`, so it inspects the trash visible to the invoking user.
---
## 8. Restoring files
Restore is driven by `restore_many()` and `restore_one()`.
```text
--restore ITEM...
-> for each item
-> find_candidates(item)
-> if no match: print and continue
-> if one match: restore_one(entry)
-> if multiple matches: prompt for selection
```
Candidate lookup is intentionally simple:
```text
1. exact basename match against Path(entry["orig_path"]).name
2. id prefix match against entry["id"].startswith(identifier)
```
Exact basename matches take priority over id prefix matches.
### 8.1 Restore target path
`restore_one()` starts with:
```python
src = TRASH_DIR / entry["id"]
dest = Path(entry["orig_path"])
```
If the original destination already exists, restore falls back to the current directory with the original basename:
```python
if dest.exists():
dest = Path.cwd() / dest.name
```
Then it creates parent directories and moves the trashed object:
```python
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(dest))
```
After a successful move, it removes the metadata entry from `meta` and saves the current metadata file.
### 8.2 Restore limitations
Current restore behaviour does not provide conflict resolution beyond the current-directory fallback. If that fallback destination also exists, `shutil.move()` may fail or may apply platform-dependent behaviour.
Restore does not validate that `entry["orig_path"]` is safe, expected, or still belongs to the user. The metadata file is trusted local state selected by the invoking user.
---
## 9. Pruning and emptying trash
### 9.1 Automatic pruning
`main()` calls `prune_old_trash()` before argument parsing. This means any invocation can delete old trash entries before performing the requested action.
The retention period is controlled by:
```text
RESRM_TRASH_LIFE
```
Rules:
```text
default: 7 days
invalid value: 7 days
minimum: 1 day
```
Entries older than the cutoff are removed from `TRASH_DIR/files/<id>` and from `meta`. Directories are removed with `shutil.rmtree(..., ignore_errors=True)`. Files are removed with `unlink(missing_ok=True)`.
### 9.2 Emptying trash
`empty_trash()` permanently removes every object directly under the current user's `TRASH_DIR`, clears `meta`, and writes the empty metadata list.
There is no confirmation prompt for `--empty` at the time of writing. Treat changes to this behaviour as user-facing compatibility changes.
---
## 10. Symlink behaviour
Symlink handling depends on the operation:
```text
default trash mode:
shutil.move moves the symlink itself when the path argument is a symlink
--skip-trash:
path.is_dir() and not path.is_symlink() uses shutil.rmtree
otherwise path.unlink removes the symlink itself
inspect:
lstat is used and symlink targets are displayed with os.readlink
```
One important detail: the root-owned-file guard currently uses `path.stat()`, which follows symlinks. If changing symlink semantics, review that guard carefully and decide whether `lstat()` is more appropriate for the intended security model.
---
## 11. Development commands
Install dependencies:
```bash
poetry install
```
Run the CLI in the development environment:
```bash
poetry run resrm --help
```
Run pre-commit hooks:
```bash
poetry run pre-commit run --all-files
```
Build release artifacts:
```bash
poetry build
```
There is a `tests/` package marker, but no substantive pytest suite is configured in `pyproject.toml` at the time of writing. If tests are added, add pytest as a development dependency and prefer focused tests using temporary directories and isolated metadata files.
---
## 12. Automation and security scanning
Gitea pull request workflow:
```text
.gitea/workflows/lint-and-security.yml
-> install pre-commit
-> pre-commit run --all-files
-> install Poetry and poetry-plugin-export
-> poetry export dependencies
-> pip-audit dependency audit
```
Scheduled/manual security workflow:
```text
.gitea/workflows/security-scan.yml
-> install verified Cosign, Syft, and Grype
-> generate SBOM
-> scan for vulnerabilities
-> notify Node-RED on fixable Medium/High/Critical vulnerabilities
-> fail workflow on those vulnerabilities
```
Pre-commit currently includes Bandit, Black, trailing whitespace, EOF, YAML, and TOML checks.
Bandit is configured for `src/resrm/` with:
```text
-lll -iii -s B110,B112
```
Be careful when suppressing security checks. Prefer making the code obviously safe and documenting intentional tradeoffs.
---
## 13. Common maintenance tasks
### 13.1 Add a new CLI option
1. Add the argparse option in `core.py`.
2. Decide whether it affects removal, restore, listing, inspection, pruning, or emptying.
3. Update argcomplete if the option accepts trash identifiers.
4. Update README usage examples.
5. Update this guide if the runtime flow or safety model changes.
6. Add focused tests if a test suite exists, or add the test infrastructure if the behaviour is important enough to protect.
### 13.2 Change metadata format
1. Update the metadata writer in `move_to_trash()`.
2. Update `load_meta()`, `save_meta()`, `find_candidates()`, `restore_one()`, `inspect_entry()`, and `list_trash()` as needed.
3. Decide whether old metadata files should continue to work.
4. Consider adding a `version` field before making incompatible changes.
5. Add tests with temporary metadata files.
There is currently no migration system. Do not silently break existing user metadata unless the project intentionally accepts that compatibility break.
### 13.3 Change trash location semantics
Start with these functions and call sites:
```text
get_trash_base_for_user()
get_trash_paths()
move_to_trash() owner-based trash selection
restore_one() source path construction
inspect_entry() stored path display
```
Important questions:
1. Which user should own the trash entry when running under sudo?
2. Which metadata file should `--list`, `--restore`, and `--inspect` read?
3. What happens to existing trash entries under the old path?
4. Does the change affect root-owned files or normal user files differently?
### 13.4 Change restore behaviour
Start with `find_candidates()`, `restore_many()`, and `restore_one()`.
Preserve these expectations unless intentionally redesigning the tool:
```text
restores by exact basename or id prefix
prompts when there are multiple candidates
does not overwrite an existing original path
removes metadata only after a successful move
prints a clear failure message when restore fails
```
If adding overwrite or merge behaviour, require explicit user intent and document the consequences.
### 13.5 Change permanent deletion behaviour
Start with the `skip_trash` branch in `move_to_trash()`, `prune_old_trash()`, and `empty_trash()`.
Permanent deletion paths are the highest-risk parts of the tool. Review directory handling, symlink handling, error reporting, and confirmation semantics before changing them.
### 13.6 Add tests
Good first test areas:
```text
get_trash_base_for_user chooses expected paths
load_meta returns [] for missing or malformed metadata
find_candidates prioritises exact basename before id prefix
move_to_trash moves files and writes metadata
move_to_trash rejects directories without -r at the CLI layer
--skip-trash removes a symlink rather than its target
restore_one restores to original path when free
restore_one falls back to current directory when original path exists
prune_old_trash honours default, invalid, and minimum retention values
empty_trash clears files and metadata
```
Use temporary directories and monkeypatch module globals such as `TRASH_DIR`, `META_FILE`, and `meta` to avoid touching a real user's trash.
---
## 14. Important maintenance hazards
### 14.1 Import-time global state
`TRASH_DIR`, `META_FILE`, and `meta` are initialised at import time. This keeps the script simple, but it makes testing and multi-user behaviour easier to get wrong.
If refactoring, consider passing a small trash context object through functions instead of relying on globals.
### 14.2 Metadata is trusted local state
Restore uses `orig_path` from metadata to decide where to create parent directories and move restored files. Do not treat arbitrary attacker-controlled metadata as safely sandboxed input.
### 14.3 `--empty` and pruning are permanent
The default remove flow is reversible, but `--empty`, auto-prune, and `--skip-trash` are not. Keep this distinction clear in code paths and documentation.
### 14.4 Short ids can collide
The display id is the first eight characters of a UUID. Code should be prepared for multiple matches and should not assume an eight-character prefix uniquely identifies an entry.
### 14.5 Basename lookup can be ambiguous
Exact basename restore is convenient but ambiguous. `restore_many()` prompts when multiple candidates match. Preserve that behaviour when changing lookup logic.
### 14.6 Symlink and ownership checks need care
Some operations use `stat()` and some use `lstat()`. Be explicit about whether the code should operate on a symlink itself or its target.
### 14.7 Root and sudo workflows are product-sensitive
The README promises sudo support for root-owned files. Changes that affect euid handling, owner-based trash paths, root-owned rejection, or `/root/.local/share/resrm` should be tested manually under sudo before release.
### 14.8 Existing user trash matters
Users may have real files in `~/.local/share/resrm/files` and important metadata in `metadata.json`. Migration and compatibility decisions can affect their ability to restore data.
---
## 15. Troubleshooting guide
### 15.1 `resrm` says a path is root-owned
The path's owner uid is `0`, and the current effective uid is not root. Re-run with sudo if you intentionally want root to manage that path.
### 15.2 A directory is not removed
Like `rm`, `resrm` requires `-r` for directories:
```bash
resrm -r directory
```
### 15.3 A restored file does not return to its original path
If the original path already exists, `restore_one()` restores to the current directory using the original basename.
### 15.4 A trash item is missing
Check, in order:
1. Was it removed with `--skip-trash`?
2. Was it removed by `resrm --empty`?
3. Was it pruned because it was older than `RESRM_TRASH_LIFE` days?
4. Are you running as the same effective user that owns the relevant trash metadata?
5. Was it moved into the file owner's trash while running through sudo?
### 15.5 Completion does not show entries
Check that argcomplete is installed and registered for the shell:
```bash
eval "$(register-python-argcomplete resrm)"
```
Completion candidates come from the metadata loaded for the current effective user.
### 15.6 Pruning happens unexpectedly
Every `resrm` invocation calls `prune_old_trash()` before parsing arguments. Check `RESRM_TRASH_LIFE`; invalid values fall back to 7 days and values below 1 are treated as 1 day.
---
## 16. Practical code-reading map
```text
Feature/question Start with
CLI option behaviour core.py:main()
Console script entry point pyproject.toml and cli.py
Trash base path get_trash_base_for_user()
Current user's trash globals get_trash_paths(), TRASH_DIR, META_FILE
Metadata loading/saving load_meta(), save_meta()
Automatic pruning prune_old_trash()
Listing trash list_trash()
Identifier matching find_candidates()
Restore flow restore_many(), restore_one()
Permanent delete move_to_trash(skip_trash=True)
Default move to trash move_to_trash(skip_trash=False)
Inspection output inspect_entry()
Shell completion id_name_completer inside main()
Packaging pyproject.toml
Automation .gitea/workflows/ and .pre-commit-config.yaml
```
---
## 17. Glossary
**Trash base** The directory containing `files/` and `metadata.json` for one user.
**Trash file directory** The `files/` directory under the trash base, containing UUID-named moved objects.
**Metadata file** The JSON file that records ids, original paths, and timestamps.
**Trash id** The full UUID hex string generated for a trashed object.
**Short id** The first eight characters of a trash id, used for display and restore convenience.
**Original path** The resolved path stored before a moved object is restored.
**Skip trash** Permanent deletion mode enabled with `--skip-trash`.
**Prune** Automatic permanent deletion of old trash entries according to `RESRM_TRASH_LIFE`.
---
## 18. Final maintenance model
Most changes should preserve this model:
```text
Move paths into a per-user trash area by default
-> store minimal metadata needed to find and restore them
-> list, inspect, and restore from trusted local metadata
-> avoid overwriting existing original paths during restore
-> reserve permanent deletion for explicit or retention-based flows
```
Before changing code, ask:
1. Is this a remove, restore, metadata, pruning, or presentation concern?
2. Does this touch permanent deletion or only trash movement?
3. What happens to existing `metadata.json` files?
4. Which effective user and which file owner should control the trash entry?
5. Does the change behave correctly under sudo?
6. Does it operate on symlinks or symlink targets?
7. Does it preserve non-overwrite restore behaviour?
8. Are `--skip-trash`, `--empty`, and auto-prune clearly documented as permanent?
9. Are there focused tests or manual checks for the edge case being changed?
Keeping those boundaries clear is the main way to maintain `resrm` without turning a narrow safer-rm utility into a misleading backup or sandbox tool.
+104 -15
View File
@@ -1,5 +1,13 @@
[![Licence](https://img.shields.io/badge/GPL--3.0-orange?label=Licence)](https://git.sysmd.uk/guardutils/resrm/src/branch/main/LICENCE)
[![Gitea Release](https://img.shields.io/gitea/v/release/guardutils/resrm?gitea_url=https%3A%2F%2Fgit.sysmd.uk%2F&style=flat&color=orange&logo=gitea)](https://git.sysmd.uk/guardutils/resrm/releases)
[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-blue?logo=pre-commit&style=flat)](https://git.sysmd.uk/guardutils/resrm/src/branch/main/.pre-commit-config.yaml)
# resrm
<div align="center">
<img src="resrm.png" alt="resrm logo" width="256" />
</div>
**resrm** is a safe, drop-in replacement for the Linux `rm` command with **undo/restore support**.
It moves files to a per-user _trash_ instead of permanently deleting them, while still allowing full `sudo` support for root-owned files.
@@ -13,42 +21,91 @@ It moves files to a per-user _trash_ instead of permanently deleting them, while
- Supports `-r`, `-f`, `-i`, `--skip-trash` options
- Works with `sudo` for root-owned files
- Automatically prunes Trash entries older than `$RESRM_TRASH_LIFE` days (default **7**, minimum **1**)
> Note: if you need immediate deletion, use the regular `rm` command instead.
---
> Note: if you need immediate deletion, use the `--skip-trash` flag.
## Configuration
To control how long trashed files are kept, add this line to your shell configuration (e.g. `~/.bashrc`):
```bash
export RESRM_TRASH_LIFE=10
```
---
## Installation
### From GuardUtils package repo
This is the preferred method of installation.
### Debian/Ubuntu
#### 1) Import the GPG key
```bash
sudo mkdir -p /usr/share/keyrings
curl -fsSL https://repo.sysmd.uk/guardutils/guardutils.gpg | sudo gpg --dearmor -o /usr/share/keyrings/guardutils.gpg
```
The GPG fingerprint is `0032C71FA6A11EF9567D4434C5C06BD4603C28B1`.
#### 2) Add the APT source
```bash
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/guardutils.gpg] https://repo.sysmd.uk/guardutils/debian stable main" | sudo tee /etc/apt/sources.list.d/guardutils.list
```
#### 3) Update and install
```
sudo apt update
sudo apt install resrm
```
### Fedora/RHEL
#### 1) Import the GPG key
```
sudo rpm --import https://repo.sysmd.uk/guardutils/guardutils.gpg
```
#### 2) Add the repository configuration
```
sudo tee /etc/yum.repos.d/guardutils.repo > /dev/null << 'EOF'
[guardutils]
name=GuardUtils Repository
baseurl=https://repo.sysmd.uk/guardutils/rpm/$basearch
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://repo.sysmd.uk/guardutils/guardutils.gpg
EOF
```
#### 4) Update and install
```
sudo dnf upgrade --refresh
sudo dnf install resrm
```
### From PyPI
**NOTE:** To use `resrm` with `sudo`, the path to `resrm` must be in the `$PATH` seen by `root`.\
Either:
* install `resrm` as `root` (_preferred_), or
* install `resrm` as `root`, or
* add the path to `resrm` to the `secure_path` parameter in `/etc/sudoers`. For example, where `/home/user/.local/bin` is where `resrm` is:
``` bash
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/user/.local/bin"
```
Install via PyPI (_preferred_):
Install with:
```bash
pip install resrm
```
Or clone the repo and install locally:
### From this repository
```bash
git clone https://github.com/mdaleo404/resrm.git
git clone https://git.sysmd.uk/guardutils/resrm.git
cd resrm/
poetry install
```
@@ -77,12 +134,44 @@ resrm -l
# Restore a file by ID or basename
resrm --restore <id|name>
# Show full details of trashed item
resrm --inspect <id|name>
# Empty the trash permanently
resrm --empty
```
## Trash Location
Normal users: `~/.local/share/resrm/files`
Root user: `/root/.local/share/resrm/files`
## Configuration
To control how long trashed files are kept, add this line to your shell configuration (e.g. `~/.bashrc`):
```bash
export RESRM_TRASH_LIFE=10
```
### TAB completion
Add this to your `.bashrc`
```
eval "$(register-python-argcomplete resrm)"
```
And then
```
source ~/.bashrc
```
## pre-commit
This project uses [**pre-commit**](https://pre-commit.com/) to run automatic formatting and security checks before each commit (Black, Bandit, and various safety checks).
To enable it:
```
poetry install
poetry run pre-commit install
```
This ensures consistent formatting, catches common issues early, and keeps the codebase clean.
+210
View File
@@ -0,0 +1,210 @@
# resrm Threat Model and Security Scope
`resrm` is a command-line filesystem utility. It is designed to be executed intentionally by an operator, sometimes with elevated privileges, as a safer replacement for common `rm` usage. By default it moves files and directories into a per-user trash area and records minimal JSON metadata so they can later be listed, inspected, restored, pruned, or permanently emptied.
Because of that design, `resrm`'s security model is different from that of a network service, web application, daemon, sandbox, or setuid program. `resrm` does not attempt to defend against arbitrary local compromise of the account executing it. If an attacker can control the command line, environment, current working directory, installed Python package, metadata file, trash directory, or Python runtime used by the operator, they may be able to influence what `resrm` does. That situation is considered a local trust-boundary failure outside `resrm`'s intended security model.
`resrm` is not a secure deletion tool. It moves files by default and permanently removes files only when requested or when pruning/emptying trash. It does not overwrite storage blocks, wipe free space, defeat snapshots, or guarantee that file contents cannot be recovered by other means.
## Core Assumptions
`resrm` assumes that the person running the tool understands what they are asking it to do.
In particular:
- If `resrm` is run as root, the root user is assumed to control and understand the command line, environment, current working directory, target paths, trash metadata, and installed Python package being used.
- If `--skip-trash` is used, the operator is intentionally bypassing the recoverable trash path and requesting permanent deletion.
- If `--empty` is used, the operator is intentionally permanently deleting the current user's trash contents.
- If `RESRM_TRASH_LIFE` is set, the operator is intentionally controlling the automatic trash retention period.
- The per-user `metadata.json` file and `files/` directory are trusted local state for the user that owns them.
- The operator is expected to understand the impact of running filesystem removal and restore commands as root.
## What resrm Stores
`resrm` stores trashed files and directories in a local per-user trash area.
For the current effective user, the default locations are:
```text
normal users: ~/.local/share/resrm/files
root: /root/.local/share/resrm/files
metadata: <trash base>/metadata.json
```
For each trashed item, metadata currently includes:
- A random UUID hex `id`.
- The resolved original path as `orig_path`.
- The deletion timestamp as `timestamp`.
The file or directory contents are moved into:
```text
<trash base>/files/<id>
```
`resrm` does not store:
- File hashes.
- A metadata schema version.
- ACLs as separate structured metadata.
- Extended attributes as separate structured metadata.
- Capabilities as separate structured metadata.
- SELinux, AppArmor, or other MAC labels as separate structured metadata.
- A cryptographic integrity record for metadata or trashed contents.
- A transaction log for crash recovery.
Some filesystem metadata may remain attached to the moved file or directory depending on the filesystem, platform, and `shutil.move()` behaviour. `resrm` does not model that metadata independently.
## What Is In Scope
`resrm` tries to protect careful users and administrators from common accidental deletion mistakes while preserving familiar `rm`-style ergonomics.
In-scope security concerns include:
- Default removal should move files and directories to trash rather than permanently deleting them.
- `--skip-trash` should be the explicit path for immediate permanent deletion.
- `--empty` and automatic pruning should operate within the current user's configured `resrm` trash directory.
- Restore should not overwrite an existing original path; it should fall back to the current directory when the original path exists.
- Non-root users should not be given a false impression that they can manage root-owned files without sudo.
- Root-owned path handling should be explicit and understandable.
- Directory removal should require `-r` unless behaviour is intentionally redesigned.
- Symlink handling should avoid surprising target deletion in permanent-delete paths.
- Metadata parsing failures should not cause arbitrary code execution.
- Shell completion should use local metadata only and should not execute metadata contents.
- `resrm` should not automatically run sudo or otherwise escalate privileges.
- Python and subprocess-free implementation paths should avoid shell injection concerns for ordinary path names.
These measures are defense-in-depth. They are intended to reduce accidental permanent deletion, unexpected overwrite, unsafe restore behaviour, and misleading privilege behaviour when `resrm` is used normally.
## What Is Out Of Scope
The following are generally out of scope and should not be reported as `resrm` vulnerabilities unless they also bypass one of `resrm`'s explicit hardening mechanisms:
- A malicious local user who can already control the invoking user's command line, shell environment, current working directory, Python environment, installed package, or filesystem permissions.
- A root user intentionally deleting dangerous paths with `--skip-trash`.
- A root user intentionally emptying root's trash with `--empty`.
- A user intentionally setting `RESRM_TRASH_LIFE` to a short retention period.
- A user relying on `resrm` as a backup system after trash has been emptied, pruned, moved, corrupted, or manually edited.
- A user relying on `resrm` for secure deletion or anti-forensic wiping.
- A user relying on `resrm` to preserve ACLs, xattrs, capabilities, MAC labels, hard-link relationships, or every filesystem-specific attribute across moves and restores.
- A user relying on `resrm` as a sandbox for untrusted local users or untrusted path names selected by an attacker.
- A compromised system where an attacker already controls the user's trash directory, metadata file, shell, Python packages, environment, or filesystem namespace.
- Reports that amount to "if root runs this tool with malicious options, root can delete or move important files."
`resrm` is a tool for users and administrators, not a sandbox for hostile local users. It cannot make unsafe local trust decisions safe if the operator's own execution environment is already attacker-controlled.
## Trusted Trash Metadata
`resrm` metadata is stored in a local JSON file named `metadata.json` under the user's trash base. This file should be treated as trusted local user state.
Metadata can contain filesystem paths and deletion timestamps. It does not contain a complete copy of filesystem metadata, but it can still reveal sensitive operational details such as filenames, directory layouts, and deletion times.
Before running restore, especially as root, the operator should be confident that the selected trash metadata is the intended local state and has not been tampered with.
`resrm` does not treat an arbitrary attacker-supplied `metadata.json` as untrusted input to be safely enforced. If an attacker can edit metadata, they may be able to influence restore destinations or make restore fail.
## Default Trash Mode
Default removal moves an existing path to a UUID-named location under the selected trash directory and appends a metadata entry.
```bash
resrm file
resrm -r directory
```
For root-owned paths, a non-root process prints a permission message and refuses the default trash operation:
```text
resrm: permission denied: 'path' (root-owned file, try sudo)
```
When possible, default trash mode chooses the trash base from the file owner's home directory. This is intended to support sudo workflows where root removes a user-owned file but the file remains associated with that user's `resrm` trash.
This is convenience behaviour, not a privilege boundary. Operators should still understand which user owns the file, which effective user is running the process, and which trash area will receive the moved object.
## Permanent Deletion Paths
`resrm` has three permanent deletion mechanisms:
```text
resrm --skip-trash PATH immediate permanent deletion
resrm --empty permanently remove current user's trash contents
automatic pruning remove entries older than RESRM_TRASH_LIFE days
```
These operations are not recoverable by `resrm`.
`--skip-trash` uses `shutil.rmtree()` for directories and `Path.unlink()` for non-directories and symlinks. It is the operator's responsibility to use this only when immediate deletion is intended.
`--empty` removes entries inside the current user's trash file directory and clears current metadata.
Automatic pruning runs at the start of every `resrm` invocation. The retention period defaults to 7 days, falls back to 7 for invalid values, and has a minimum of 1 day.
## Restore Behaviour
Restore uses trusted metadata to find the trashed object and original path.
```bash
resrm --restore <id-or-basename>
```
Candidate lookup works as follows:
```text
1. exact basename match against the stored original path
2. id prefix match against the stored UUID
```
If multiple candidates match, `resrm` prompts for a selection.
Restore starts with the stored original path. If that path already exists, `resrm` restores to the current working directory using the original basename instead. This avoids direct overwrite of the original path.
Restore creates parent directories for the selected destination. Because restore destinations come from trusted metadata, metadata tampering is considered a local trust failure rather than something `resrm` promises to sandbox.
## Symlinks And Filesystem Races
`resrm` operates on a live filesystem. Concurrent filesystem changes can affect what exists at the moment a remove, restore, empty, or prune operation runs.
Current symlink behaviour is operation-specific:
- Default trash mode uses `shutil.move()`, which normally moves the symlink itself when the path argument is a symlink.
- `--skip-trash` deletes symlink path entries with `Path.unlink()` rather than recursively deleting their targets.
- `--inspect` uses `lstat()` and displays symlink targets with `os.readlink()`.
- Some ownership checks use `stat()` and therefore follow symlinks.
Reports that identify concrete symlink target deletion, unintended overwrite, or privilege-impacting time-of-check/time-of-use behaviour in normal `resrm` operations are useful. Reports that require the operator's account or root environment to already be attacker-controlled are usually out of scope.
## Local Compromise
`resrm` includes guardrails against some dangerous local mistakes because it handles deletion-like operations. For example, default removal uses trash instead of immediate deletion, directories require `-r`, restore avoids overwriting an existing original path, and `resrm` does not automatically escalate privileges.
However, local compromise cannot be ruled out completely for a CLI filesystem tool. If an attacker can influence the user's shell, environment, metadata file, trash directory, Python packages, current working directory, or command-line arguments, they may be able to influence `resrm`'s behaviour.
Such scenarios are treated as local compromise or operator trust failures, not as vulnerabilities in `resrm` by themselves.
## Security Report Guidance
Useful vulnerability reports include issues where `resrm` behaves unsafely despite the documented trust model. Examples include:
- Default `resrm PATH` permanently deletes a file instead of moving it to trash under normal conditions.
- `--skip-trash` on a symlink deletes the target rather than the symlink path itself.
- Restore overwrites an existing original path without explicit operator approval.
- `--empty` removes files outside the current user's `resrm` trash directory.
- Automatic pruning removes files outside the current user's `resrm` trash directory.
- A non-root user can use normal `resrm` behaviour to move or delete root-owned files without appropriate filesystem permissions.
- Shell completion or metadata parsing executes code from metadata.
- Ordinary path names cause shell injection or command execution.
- A failed safety check is silently ignored and `resrm` proceeds with a dangerous permanent deletion.
Less useful reports, and normally out of scope, include:
- "Root can delete important files with `--skip-trash`."
- "A user can empty their own trash with `--empty`."
- "A user can set `RESRM_TRASH_LIFE=1` and old trash is pruned."
- "A user can manually delete or corrupt their own trash directory."
- "A malicious local user can compromise `resrm` after already controlling the invoking user's environment, Python packages, or filesystem permissions."
- "`resrm` does not securely wipe deleted file contents from disk."
- "`resrm` does not guarantee backup-grade recovery after pruning, emptying, metadata tampering, or filesystem failure."
Reports about concrete bypasses of `resrm`'s guardrails are welcome. The project does not treat intentional administrator-controlled execution as a vulnerability by itself.
Generated
+236 -5
View File
@@ -1,7 +1,238 @@
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
package = []
# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand.
[[package]]
name = "argcomplete"
version = "3.6.3"
description = "Bash tab completion for argparse"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce"},
{file = "argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c"},
]
[package.extras]
test = ["coverage", "mypy", "pexpect", "ruff", "wheel"]
[[package]]
name = "cfgv"
version = "3.4.0"
description = "Validate configuration and produce human readable error messages."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
]
[[package]]
name = "distlib"
version = "0.4.0"
description = "Distribution utilities"
optional = false
python-versions = "*"
groups = ["dev"]
files = [
{file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"},
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
]
[[package]]
name = "filelock"
version = "3.20.3"
description = "A platform independent file lock."
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"},
{file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"},
]
[[package]]
name = "identify"
version = "2.6.15"
description = "File identification library for Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"},
{file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"},
]
[package.extras]
license = ["ukkonen"]
[[package]]
name = "nodeenv"
version = "1.9.1"
description = "Node.js virtual environment builder"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
files = [
{file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
]
[[package]]
name = "platformdirs"
version = "4.5.0"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"},
{file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"},
]
[package.extras]
docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"]
type = ["mypy (>=1.18.2)"]
[[package]]
name = "pre-commit"
version = "3.8.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
{file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
]
[package.dependencies]
cfgv = ">=2.0.0"
identify = ">=1.0.0"
nodeenv = ">=0.11.1"
pyyaml = ">=5.1"
virtualenv = ">=20.10.0"
[[package]]
name = "pyyaml"
version = "6.0.3"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"},
{file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"},
{file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"},
{file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"},
{file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"},
{file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"},
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"},
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"},
{file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"},
{file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"},
{file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"},
{file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"},
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"},
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"},
{file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"},
{file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"},
{file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"},
{file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"},
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"},
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"},
{file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"},
{file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"},
{file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"},
{file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"},
{file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"},
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"},
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"},
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"},
{file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"},
{file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"},
{file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"},
{file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"},
{file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"},
{file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"},
{file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"},
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"},
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"},
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"},
{file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"},
{file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"},
{file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"},
{file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"},
{file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"},
{file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"},
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"},
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"},
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"},
{file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"},
{file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"},
{file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"},
{file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"},
{file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"},
{file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"},
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"},
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"},
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"},
{file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"},
{file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"},
{file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"},
{file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"},
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
markers = "python_version == \"3.10\""
files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[[package]]
name = "virtualenv"
version = "20.36.1"
description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f"},
{file = "virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba"},
]
[package.dependencies]
distlib = ">=0.3.7,<1"
filelock = {version = ">=3.20.1,<4", markers = "python_version >= \"3.10\""}
platformdirs = ">=3.9.1,<5"
typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""}
[package.extras]
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""]
[metadata]
lock-version = "2.0"
python-versions = "^3.13"
content-hash = "f01b553f3895e558c34b4f10542e05acdef39bf0527c8090bd136d914dc73f94"
lock-version = "2.1"
python-versions = ">=3.10,<4.0"
content-hash = "c1a693306d3782f1efa2e4b00cbef8f1cb16b3b0c93d847d234ae8bc41c1d702"
+11 -4
View File
@@ -1,20 +1,27 @@
[tool.poetry]
name = "resrm"
version = "0.3.0"
version = "0.4.1"
description = "drop-in replacement for rm with undo/restore built-in."
authors = ["Marco D'Aleo <marco@marcodaleo.com>"]
license = "GPL-3.0-or-later"
readme = "README.md"
homepage = "https://github.com/mdaleo404/resrm"
repository = "https://github.com/mdaleo404/resrm"
homepage = "https://git.sysmd.uk/guardutils/resrm"
repository = "https://git.sysmd.uk/guardutils/resrm"
packages = [{include = "resrm", from = "src"}]
[tool.poetry.dependencies]
python = "^3.13"
python = ">=3.10,<4.0"
argcomplete = ">=2"
[tool.poetry.group.dev.dependencies]
pre-commit = "^3.8"
[tool.poetry.scripts]
resrm = "resrm.cli:main"
[tool.black]
line-length = 79
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+192 -32
View File
@@ -8,13 +8,15 @@ Basic usage:
resrm -f file # ignore nonexistent, no prompt
resrm -i file # interactive prompt before removal
resrm --skip-trash file # permanent delete (bypass trash)
resrm -l # list trash entries (neat table)
resrm -l|--list # list trash entries (neat table)
resrm --restore <id|name> # restore by short-id (8 chars) or exact basename
resrm --inspect <id|name> # output full detail list of trashed item
resrm --empty # empty trash entries (permanent)
"""
from __future__ import annotations
import argparse
import argcomplete
import json
import os
import shutil
@@ -22,10 +24,19 @@ import sys
import uuid
import datetime
import textwrap
import importlib.metadata
from pathlib import Path
from typing import List, Dict, Optional
# Config
def get_version():
try:
return importlib.metadata.version("resrm")
except importlib.metadata.PackageNotFoundError:
return "unknown"
def get_trash_base_for_user(uid: int) -> Path:
"""Return the trash base path depending on whether user is root or normal."""
if uid == 0:
@@ -33,6 +44,7 @@ def get_trash_base_for_user(uid: int) -> Path:
else:
try:
import pwd
user_info = pwd.getpwuid(uid)
home_dir = Path(user_info.pw_dir)
except Exception:
@@ -53,6 +65,7 @@ def get_trash_paths() -> tuple[Path, Path]:
TRASH_DIR, META_FILE = get_trash_paths()
DATEFMT = "%Y-%m-%d %H:%M"
def prune_old_trash():
"""Remove trash entries older than RESRM_TRASH_LIFE days (default 7)."""
try:
@@ -87,7 +100,10 @@ def prune_old_trash():
if removed > 0:
save_meta(meta)
print(f"Pruned {removed} trash entr{'y' if removed == 1 else 'ies'} older than {life_days} da{'y' if life_days == 1 else 'ys'}.")
print(
f"Pruned {removed} trash entr{'y' if removed == 1 else 'ies'} older than {life_days} da{'y' if life_days == 1 else 'ys'}."
)
def load_meta() -> List[Dict]:
if META_FILE.exists():
@@ -98,15 +114,19 @@ def load_meta() -> List[Dict]:
return []
return []
def save_meta(meta: List[Dict]):
with META_FILE.open("w", encoding="utf-8") as f:
json.dump(meta, f, indent=2, ensure_ascii=False)
meta = load_meta()
def short_id(fullid: str) -> str:
return fullid[:8]
def human_time(ts: str) -> str:
"""
Convert ISO timestamp string from metadata to a human-readable format.
@@ -118,13 +138,15 @@ def human_time(ts: str) -> str:
# Fallback: just return the raw string
return ts
def entry_display(entry: Dict, width: int = 80) -> str:
id8 = short_id(entry["id"])
ts = human_time(entry["timestamp"])
path = entry["orig_path"]
wrapped = textwrap.fill(path, width=width-32)
wrapped = textwrap.fill(path, width=width - 32)
return f"{id8:<8} {ts:<19} {wrapped}"
def list_trash():
if not meta:
print("Trash empty.")
@@ -132,16 +154,17 @@ def list_trash():
header = f"{'ID':<8} {'Deleted at':<19} {'Original path'}"
print(header)
print('-' * len(header))
print("-" * len(header))
for entry in meta:
id8 = short_id(entry["id"])
ts = human_time(entry["timestamp"])
path = entry["orig_path"]
max_path_len = 80
if len(path) > max_path_len:
path = "" + path[-(max_path_len - 1):]
path = "" + path[-(max_path_len - 1) :]
print(f"{id8:<8} {ts:<19} {path}")
def find_candidates(identifier: str) -> List[Dict]:
# exact basename match first
exact = [m for m in meta if Path(m["orig_path"]).name == identifier]
@@ -154,6 +177,7 @@ def find_candidates(identifier: str) -> List[Dict]:
return []
def restore_many(identifiers: List[str]):
"""Restore multiple files, prompting when needed."""
for identifier in identifiers:
@@ -171,7 +195,9 @@ def restore_many(identifiers: List[str]):
# Multiple matches - prompt user
print(f"Multiple matches for '{identifier}':")
for i, entry in enumerate(candidates, start=1):
print(f"{i}) {short_id(entry['id'])} {entry['orig_path']} ({entry['timestamp']})")
print(
f"{i}) {short_id(entry['id'])} {entry['orig_path']} ({entry['timestamp']})"
)
try:
choice = input("Choose number to restore (or skip): ").strip()
@@ -189,6 +215,7 @@ def restore_many(identifiers: List[str]):
else:
print("Invalid selection. Skipped.")
def restore_one(entry: Dict) -> bool:
src = TRASH_DIR / entry["id"]
dest = Path(entry["orig_path"])
@@ -210,6 +237,7 @@ def restore_one(entry: Dict) -> bool:
print(f"Restored to: {dest}")
return True
def restore(identifier: str):
candidates = find_candidates(identifier)
if not candidates:
@@ -221,7 +249,9 @@ def restore(identifier: str):
# multiple candidates -> show list and ask
print("Multiple matches:")
for i, e in enumerate(candidates, start=1):
print(f"{i}) {short_id(e['id'])} {e['orig_path']} ({e['timestamp']})")
print(
f"{i}) {short_id(e['id'])} {e['orig_path']} ({e['timestamp']})"
)
try:
choice = input("Choose number to restore (or abort): ").strip()
except KeyboardInterrupt:
@@ -236,25 +266,32 @@ def restore(identifier: str):
return
restore_one(candidates[idx])
def empty_trash():
"""Permanently remove all trashed files and clear metadata."""
# Remove everything inside the trash directory
count = 0
for entry in list(meta):
f = TRASH_DIR / entry["id"]
for item in TRASH_DIR.iterdir():
try:
if f.exists():
if f.is_dir():
shutil.rmtree(f, ignore_errors=True)
else:
f.unlink(missing_ok=True)
meta.remove(entry)
if item.is_dir():
shutil.rmtree(item, ignore_errors=True)
else:
item.unlink(missing_ok=True)
count += 1
except Exception as e:
print(f"Failed to remove {f}: {e}")
print(f"Failed to remove {item}: {e}")
# Clear metadata
meta.clear()
save_meta(meta)
print(f"Trash emptied ({count} entries removed).")
def move_to_trash(path: Path, interactive: bool, force: bool, skip_trash: bool):
def move_to_trash(
path: Path, interactive: bool, force: bool, skip_trash: bool
):
if not path.exists():
if force:
return
@@ -286,7 +323,9 @@ def move_to_trash(path: Path, interactive: bool, force: bool, skip_trash: bool):
try:
st = path.stat()
if st.st_uid == 0 and os.geteuid() != 0:
print(f"resrm: permission denied: '{path}' (root-owned file, try sudo)")
print(
f"resrm: permission denied: '{path}' (root-owned file, try sudo)"
)
return
except Exception:
pass
@@ -294,6 +333,7 @@ def move_to_trash(path: Path, interactive: bool, force: bool, skip_trash: bool):
# Detect which trash to use (based on file owner)
try:
import pwd
owner_uid = path.stat().st_uid
owner_info = pwd.getpwuid(owner_uid)
owner_home = Path(owner_info.pw_dir)
@@ -329,7 +369,7 @@ def move_to_trash(path: Path, interactive: bool, force: bool, skip_trash: bool):
entry = {
"id": uid,
"orig_path": str(path.resolve()),
"timestamp": datetime.datetime.now().isoformat()
"timestamp": datetime.datetime.now().isoformat(),
}
owner_meta.append(entry)
with meta_file.open("w", encoding="utf-8") as f:
@@ -337,6 +377,80 @@ def move_to_trash(path: Path, interactive: bool, force: bool, skip_trash: bool):
print(f"Removed '{path}' -> trash id {short_id(uid)}")
def inspect_entry(identifier: str):
"""Show full information about trash entries matching the identifier."""
candidates = find_candidates(identifier)
if not candidates:
print(f"No match found for '{identifier}'")
return
for entry in candidates:
# Validate entry structure
if not isinstance(entry, dict):
print(f"Invalid metadata entry (not a dict): {entry!r}")
print()
continue
entry_id = entry.get("id")
orig_path = entry.get("orig_path", "?")
timestamp = entry.get("timestamp", "?")
if not entry_id:
print(f"Invalid metadata entry (missing id): {entry}")
continue
trash_path = TRASH_DIR / entry_id
print(f"ID: {short_id(entry_id)}")
print(f"Original: {orig_path}")
print(f"Deleted at: {human_time(timestamp)}")
print(f"Stored at: {trash_path}")
try:
st = trash_path.lstat() # preserves symlink info
import stat, pwd, grp
# Type detection
if stat.S_ISDIR(st.st_mode):
ftype = "directory"
elif stat.S_ISLNK(st.st_mode):
try:
target = os.readlink(trash_path)
ftype = f"symlink → {target}"
except Exception:
ftype = "symlink"
else:
ftype = "file"
# Permissions
perms = stat.filemode(st.st_mode)
# Ownership
try:
user = pwd.getpwuid(st.st_uid).pw_name
except Exception:
user = st.st_uid
try:
group = grp.getgrgid(st.st_gid).gr_name
except Exception:
group = st.st_gid
owner = f"{user}:{group}"
# Size (bytes for file, recursive for directories)
size = st.st_size
print(f"Type: {ftype}")
print(f"Size: {size} bytes")
print(f"Permissions: {perms}")
print(f"Ownership: {owner}")
except Exception as e:
print(f"Unknown stats for {e}")
def main(argv: Optional[List[str]] = None):
if argv is None:
argv = sys.argv[1:]
@@ -346,12 +460,50 @@ def main(argv: Optional[List[str]] = None):
parser.add_argument("-r", action="store_true", help="recursive")
parser.add_argument("-f", "--force", action="store_true", help="force")
parser.add_argument("-i", action="store_true", help="interactive")
parser.add_argument("--skip-trash", action="store_true", help="permanent delete")
parser.add_argument("--restore", nargs="+", metavar="item", help="restore by id or basename")
parser.add_argument("-l", action="store_true", help="list trash")
parser.add_argument("--empty", action="store_true", help="empty the trash permanently")
parser.add_argument(
"--skip-trash", action="store_true", help="permanent delete"
)
inspect_arg = parser.add_argument(
"--inspect",
"-I",
nargs="+",
metavar="item",
help="show full metadata and original path for this trash entry",
)
restore_arg = parser.add_argument(
"--restore",
nargs="+",
metavar="item",
help="restore by id or basename",
)
# completer
def id_name_completer(prefix, parsed_args, **kwargs):
return [
short_id(m["id"])
for m in meta
if short_id(m["id"]).startswith(prefix)
] + [
Path(m["orig_path"]).name
for m in meta
if Path(m["orig_path"]).name.startswith(prefix)
]
restore_arg.completer = id_name_completer
inspect_arg.completer = id_name_completer
parser.add_argument("-l", "--list", action="store_true", help="list trash")
parser.add_argument(
"--empty", action="store_true", help="empty the trash permanently"
)
parser.add_argument("-h", "--help", action="store_true", help="show help")
parser.add_argument("-V", "--version", action="store_true", help="show version")
parser.add_argument(
"-V", "--version", action="version", version=f"resrm {get_version()}"
)
argcomplete.autocomplete(parser)
args = parser.parse_args(argv)
# Always print docstring if -h or --help
@@ -359,19 +511,22 @@ def main(argv: Optional[List[str]] = None):
print(__doc__)
return
if args.version:
print("resrm 0.2.1")
return
if not args.paths and not (args.l or args.empty or args.restore):
if not args.paths and not (
args.list or args.empty or args.restore or args.inspect
):
print("resrm: missing operand")
print("Try 'resrm --help' for more information.")
return
if args.l:
if args.list:
list_trash()
return
if args.inspect:
for item in args.inspect:
inspect_entry(item)
return
if args.empty:
empty_trash()
return
@@ -389,8 +544,13 @@ def main(argv: Optional[List[str]] = None):
pth = Path(p)
# simplistic recursive handling: if -r not given and it's a directory, mimic rm behavior: error unless -r
if pth.is_dir() and not args.r:
if args.f:
if args.force:
continue
print(f"resrm: cannot remove '{pth}': Is a directory")
continue
move_to_trash(pth, interactive=args.i, force=args.force, skip_trash=args.skip_trash)
move_to_trash(
pth,
interactive=args.i,
force=args.force,
skip_trash=args.skip_trash,
)