14 Commits
Author SHA1 Message Date
mdaleo404 ab6b78e408 Standardise PR workflow on ci.yml and drop scheduled SBOM scan 2026-10-13 07:38:25 +01:00
mdaleo404 4697891fea Use composite action for scans 2026-10-10 17:20:00 +01:00
mdaleo404 a6dfb4e627 Bump resrm version to 0.5.1 2026-10-10 08:37:46 +01:00
mdaleo404 23138a1d5e Merge pull request 'Update dependency pre-commit to v4' (#32) from renovate/pre-commit-4.x into main
Reviewed-on: #32
2026-10-10 07:36:47 +00:00
mdaleo404 0588915bc1 Merge branch 'main' into renovate/pre-commit-4.x
Lint & Security / lint-and-security (pull_request) Successful in 1m4s
Lint & Security / tests (pull_request) Successful in 20s
2026-10-10 08:35:03 +01:00
mdaleo404 daf17175d0 Merge pull request 'Update dependency argcomplete to v3.7.2' (#31) from renovate/non-major-dependencies into main
Reviewed-on: #31
2026-10-10 07:34:33 +00:00
kamabot 272ea7ec59 Update dependency pre-commit to v4
renovate/stability-days Updates have met minimum release age requirement
Lint & Security / lint-and-security (pull_request) Successful in 1m6s
Lint & Security / tests (pull_request) Successful in 20s
2026-10-09 17:07:57 +00:00
kamabot 74642385f1 Update dependency argcomplete to v3.7.2
renovate/stability-days Updates have met minimum release age requirement
Lint & Security / lint-and-security (pull_request) Successful in 1m3s
Lint & Security / tests (pull_request) Successful in 21s
2026-10-09 17:07:53 +00:00
mdaleo404 9323e2d6ae Update workflows and tests 2026-10-09 18:00:50 +01:00
mdaleo404 aae2c572fa Update package installation for sysmd repository 2026-10-06 18:45:16 +01:00
mdaleo404 650c3a82c3 Merge pull request 'Drop Python 3.10 support' (#30) from mdaleo404/drop-python3.10 into main
Reviewed-on: #30
2026-10-05 10:01:06 +00:00
mdaleo404 337625dcf3 Drop Python 3.10 support
Lint & Security / lint-and-security (pull_request) Successful in 1m21s
2026-10-05 10:57:52 +01:00
mdaleo404 4200498273 Add FUNDING.yml 2026-10-03 11:33:31 +01:00
mdaleo404 9b121ebd3a Harden lint workflow and add GitHub mirror support 2026-10-03 09:51:59 +01:00
12 changed files with 581 additions and 281 deletions

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
name: CI
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-and-security:
name: lint-and-security
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install Poetry
run: pip install "poetry>=2,<3"
- name: Install dev dependencies (lockfile-pinned pre-commit)
run: poetry install --with dev --no-root
- name: Run pre-commit hooks
run: poetry run pre-commit run --all-files --color always
- name: Install pip-audit
run: pip install "pip-audit==2.10.1"
- name: Audit dependencies (Poetry lockfile)
run: |
set -euo pipefail
poetry self add poetry-plugin-export
poetry export -f requirements.txt --without-hashes -o requirements-audit.txt
# pip-audit exits non-zero for ANY vulnerability; capture the report, then gate below.
pip-audit --no-deps -r requirements-audit.txt -f json -o pip-audit.json || true
echo "Fixable vulnerabilities:"
jq -r '
.dependencies[]?
| select(has("vulns"))
| . as $d
| .vulns[]
| select((.fix_versions | length) > 0)
| "\($d.name)@\($d.version) -> \(.id) [fix: \(.fix_versions | join(", "))]"
' pip-audit.json
echo
echo "All reported vulnerabilities:"
jq -r '
.dependencies[]?
| select(has("vulns"))
| . as $d
| .vulns[]
| "\($d.name)@\($d.version) -> \(.id) [fix: \(if (.fix_versions | length) > 0 then (.fix_versions | join(", ")) else "none" end)]"
' pip-audit.json
# Fail only on fixable findings. The type guard ensures a crashed or empty
# report fails rather than silently passing. Dependency security updates
# are handled by the central Renovate job.
jq -e '
(.dependencies | type) == "array"
and
( [ .dependencies[] | (.vulns // [])[] | select((.fix_versions | length) > 0) ] | length == 0 )
' pip-audit.json
tests:
name: tests
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.13"]
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install Poetry
run: pip install "poetry>=2,<3"
- name: Install project and dependencies
run: poetry install --with dev
- name: Run tests
run: poetry run pytest
-36
View File
@@ -1,36 +0,0 @@
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>=2,<3"
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
@@ -1,188 +0,0 @@
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
@@ -0,0 +1 @@
* @mdaleo404
+2
View File
@@ -0,0 +1,2 @@
github: mdaleo404
ko_fi: mdaleo404
+109
View File
@@ -0,0 +1,109 @@
name: CI
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint-and-security:
name: lint-and-security
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install Poetry
run: pip install "poetry>=2,<3"
- name: Install dev dependencies (lockfile-pinned pre-commit)
run: poetry install --with dev --no-root
- name: Run pre-commit hooks
run: poetry run pre-commit run --all-files --color always
- name: Install pip-audit
run: pip install "pip-audit==2.10.1"
- name: Audit dependencies (Poetry lockfile)
run: |
set -euo pipefail
poetry self add poetry-plugin-export
poetry export -f requirements.txt --without-hashes -o requirements-audit.txt
# pip-audit exits non-zero for ANY vulnerability; capture the report, then gate below.
pip-audit --no-deps -r requirements-audit.txt -f json -o pip-audit.json || true
echo "Fixable vulnerabilities:"
jq -r '
.dependencies[]?
| select(has("vulns"))
| . as $d
| .vulns[]
| select((.fix_versions | length) > 0)
| "\($d.name)@\($d.version) -> \(.id) [fix: \(.fix_versions | join(", "))]"
' pip-audit.json
echo
echo "All reported vulnerabilities:"
jq -r '
.dependencies[]?
| select(has("vulns"))
| . as $d
| .vulns[]
| "\($d.name)@\($d.version) -> \(.id) [fix: \(if (.fix_versions | length) > 0 then (.fix_versions | join(", ")) else "none" end)]"
' pip-audit.json
# Fail only on fixable findings. The type guard ensures a crashed or empty
# report fails rather than silently passing. Dependency security updates
# are handled by the central Renovate job.
jq -e '
(.dependencies | type) == "array"
and
( [ .dependencies[] | (.vulns // [])[] | select((.fix_versions | length) > 0) ] | length == 0 )
' pip-audit.json
tests:
name: tests
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.13"]
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install Poetry
run: pip install "poetry>=2,<3"
- name: Install project and dependencies
run: poetry install --with dev
- name: Run tests
run: poetry run pytest
+21 -19
View File
@@ -58,14 +58,16 @@ src/resrm/
core.py CLI, trash metadata, restore, delete, prune logic core.py CLI, trash metadata, restore, delete, prune logic
tests/ tests/
__init__.py test package marker; no substantive tests currently __init__.py test package marker
conftest.py autouse fixture isolating trash state to a temp tree
test_core.py focused unit tests for metadata, move, restore, prune
pyproject.toml Poetry package metadata and console script pyproject.toml Poetry package metadata and console script
poetry.lock locked dependency graph poetry.lock locked dependency graph
README.md user-facing documentation README.md user-facing documentation
LICENCE GPL-3.0-or-later licence text LICENCE GPL-3.0-or-later licence text
.pre-commit-config.yaml Bandit, Black, and generic pre-commit hooks .pre-commit-config.yaml Bandit, Black, and generic pre-commit hooks
.gitea/workflows/ lint, dependency audit, SBOM and Grype workflows .gitea/workflows/ lint and dependency-audit workflows
dist/ built release artifacts, not source dist/ built release artifacts, not source
``` ```
@@ -493,13 +495,19 @@ Run pre-commit hooks:
poetry run pre-commit run --all-files poetry run pre-commit run --all-files
``` ```
Run the test suite:
```bash
poetry run pytest
```
Build release artifacts: Build release artifacts:
```bash ```bash
poetry build 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. `pytest` is a development dependency and a focused suite exists in `tests/test_core.py`. `tests/conftest.py` provides an autouse fixture that redirects `TRASH_DIR`, `META_FILE`, `meta`, and `pwd.getpwuid` into a temporary tree, so tests never touch a real user's trash. Prefer adding focused tests using that fixture rather than real files.
--- ---
@@ -509,22 +517,16 @@ Gitea pull request workflow:
```text ```text
.gitea/workflows/lint-and-security.yml .gitea/workflows/lint-and-security.yml
-> install pre-commit -> job lint-and-security:
-> pre-commit run --all-files -> install pre-commit
-> install Poetry and poetry-plugin-export -> pre-commit run --all-files
-> poetry export dependencies -> install Poetry and poetry-plugin-export
-> pip-audit dependency audit -> poetry export dependencies
``` -> pip-audit dependency audit
-> job tests:
Scheduled/manual security workflow: -> install Poetry
-> poetry install --with dev
```text -> poetry run pytest
.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. Pre-commit currently includes Bandit, Black, trailing whitespace, EOF, YAML, and TOML checks.
+9 -9
View File
@@ -27,7 +27,7 @@ It moves files to a per-user _trash_ instead of permanently deleting them, while
## Installation ## Installation
### From GuardUtils package repo ### From the SysMD package repository
This is the preferred method of installation. This is the preferred method of installation.
@@ -37,7 +37,7 @@ This is the preferred method of installation.
```bash ```bash
sudo mkdir -p /usr/share/keyrings sudo mkdir -p /usr/share/keyrings
curl -fsSL https://repo.sysmd.uk/guardutils/guardutils.gpg | sudo gpg --dearmor -o /usr/share/keyrings/guardutils.gpg curl -fsSL https://repo.sysmd.uk/sysmd/sysmd.gpg | sudo gpg --dearmor -o /usr/share/keyrings/sysmd.gpg
``` ```
The GPG fingerprint is `0032C71FA6A11EF9567D4434C5C06BD4603C28B1`. The GPG fingerprint is `0032C71FA6A11EF9567D4434C5C06BD4603C28B1`.
@@ -45,7 +45,7 @@ The GPG fingerprint is `0032C71FA6A11EF9567D4434C5C06BD4603C28B1`.
#### 2) Add the APT source #### 2) Add the APT source
```bash ```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 echo "deb [arch=amd64 signed-by=/usr/share/keyrings/sysmd.gpg] https://repo.sysmd.uk/sysmd/debian stable main" | sudo tee /etc/apt/sources.list.d/sysmd.list
``` ```
#### 3) Update and install #### 3) Update and install
@@ -60,20 +60,20 @@ sudo apt install resrm
#### 1) Import the GPG key #### 1) Import the GPG key
``` ```
sudo rpm --import https://repo.sysmd.uk/guardutils/guardutils.gpg sudo rpm --import https://repo.sysmd.uk/sysmd/sysmd.gpg
``` ```
#### 2) Add the repository configuration #### 2) Add the repository configuration
``` ```
sudo tee /etc/yum.repos.d/guardutils.repo > /dev/null << 'EOF' sudo tee /etc/yum.repos.d/sysmd.repo > /dev/null << 'EOF'
[guardutils] [sysmd]
name=GuardUtils Repository name=SysMD Repository
baseurl=https://repo.sysmd.uk/guardutils/rpm/$basearch baseurl=https://repo.sysmd.uk/sysmd/rpm/$basearch
enabled=1 enabled=1
gpgcheck=1 gpgcheck=1
repo_gpgcheck=1 repo_gpgcheck=1
gpgkey=https://repo.sysmd.uk/guardutils/guardutils.gpg gpgkey=https://repo.sysmd.uk/sysmd/sysmd.gpg
EOF EOF
``` ```
Generated
+90 -26
View File
@@ -1,15 +1,15 @@
# This file is automatically @generated by Poetry 2.5.0 and should not be changed by hand. # This file is automatically @generated by Poetry 2.4.0 and should not be changed by hand.
[[package]] [[package]]
name = "argcomplete" name = "argcomplete"
version = "3.6.3" version = "3.7.2"
description = "Bash tab completion for argparse" description = "Bash tab completion for argparse"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.10"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce"}, {file = "argcomplete-3.7.2-py3-none-any.whl", hash = "sha256:6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19"},
{file = "argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c"}, {file = "argcomplete-3.7.2.tar.gz", hash = "sha256:aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9"},
] ]
[package.extras] [package.extras]
@@ -27,6 +27,19 @@ files = [
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
] ]
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
markers = "sys_platform == \"win32\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]] [[package]]
name = "distlib" name = "distlib"
version = "0.4.0" version = "0.4.0"
@@ -66,6 +79,18 @@ files = [
[package.extras] [package.extras]
license = ["ukkonen"] license = ["ukkonen"]
[[package]]
name = "iniconfig"
version = "2.3.1"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7"},
{file = "iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960"},
]
[[package]] [[package]]
name = "nodeenv" name = "nodeenv"
version = "1.9.1" version = "1.9.1"
@@ -108,15 +133,31 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-
type = ["mypy (>=1.18.2)"] type = ["mypy (>=1.18.2)"]
[[package]] [[package]]
name = "pre-commit" name = "pluggy"
version = "3.8.0" version = "1.6.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks." description = "plugin and hook calling mechanisms for python"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["dev"] groups = ["dev"]
files = [ files = [
{file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pre-commit"
version = "4.6.2"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e"},
{file = "pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441"},
] ]
[package.dependencies] [package.dependencies]
@@ -126,6 +167,43 @@ nodeenv = ">=0.11.1"
pyyaml = ">=5.1" pyyaml = ">=5.1"
virtualenv = ">=20.10.0" virtualenv = ">=20.10.0"
[[package]]
name = "pygments"
version = "2.21.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"},
{file = "pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pytest"
version = "9.1.1"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"},
{file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
iniconfig = ">=1.0.1"
packaging = ">=22"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]] [[package]]
name = "python-discovery" name = "python-discovery"
version = "1.6.1" version = "1.6.1"
@@ -224,19 +302,6 @@ files = [
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, {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]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "21.14.2" version = "21.14.2"
@@ -255,9 +320,8 @@ filelock = {version = ">=3.24.2,<5", markers = "python_version >= \"3.10\""}
packaging = ">=23.1" packaging = ">=23.1"
platformdirs = ">=3.9.1,<5" platformdirs = ">=3.9.1,<5"
python-discovery = ">=1.6" python-discovery = ">=1.6"
typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""}
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.10,<4.0" python-versions = ">=3.11,<4.0"
content-hash = "48203cd9c9c4027b78f2f8ee405683b62aeda2cdeb1ec84bba0f193b80ad12b9" content-hash = "04806371cb668f5a99f2fae31494f1f88805cbcaa2cb29e7f379037cf08d9f26"
+4 -3
View File
@@ -1,11 +1,11 @@
[project] [project]
name = "resrm" name = "resrm"
version = "0.4.2" version = "0.5.1"
description = "drop-in replacement for rm with undo/restore built-in." description = "drop-in replacement for rm with undo/restore built-in."
authors = [{ name = "Marco D'Aleo", email = "marco@marcodaleo.com" }] authors = [{ name = "Marco D'Aleo", email = "marco@marcodaleo.com" }]
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10,<4.0" requires-python = ">=3.11,<4.0"
dependencies = [ dependencies = [
"argcomplete>=2", "argcomplete>=2",
] ]
@@ -21,7 +21,8 @@ resrm = "resrm.cli:main"
packages = [{include = "resrm", from = "src"}] packages = [{include = "resrm", from = "src"}]
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pre-commit = "^3.8" pre-commit = "^4.0.0"
pytest = "^9.1.1"
[tool.black] [tool.black]
line-length = 79 line-length = 79
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from resrm import core
@pytest.fixture(autouse=True)
def isolated_trash(tmp_path, monkeypatch):
"""Redirect all trash state into a temporary tree.
resrm resolves the trash base from the file owner's home directory and
loads metadata once at import time, so both are redirected here.
Without this fixture a test that moved a file would write into the
real ``~/.local/share/resrm``.
"""
home = tmp_path / "home"
base = home / ".local" / "share" / "resrm"
trash_dir = base / "files"
meta_file = base / "metadata.json"
trash_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(core, "TRASH_DIR", trash_dir)
monkeypatch.setattr(core, "META_FILE", meta_file)
monkeypatch.setattr(core, "meta", [])
# move_to_trash() and get_trash_base_for_user() import pwd locally and
# resolve the owner's home, so patch the module attribute rather than
# core's namespace.
monkeypatch.setattr(
"pwd.getpwuid", lambda uid: SimpleNamespace(pw_dir=str(home))
)
monkeypatch.chdir(tmp_path)
return SimpleNamespace(home=home, trash_dir=trash_dir, meta_file=meta_file)
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import datetime
import json
from pathlib import Path
import pytest
from resrm import core
def _entry(id_, orig_path, timestamp="2026-01-01T00:00:00"):
return {"id": id_, "orig_path": str(orig_path), "timestamp": timestamp}
def _iso_days_ago(days):
moment = datetime.datetime.now() - datetime.timedelta(days=days)
return moment.isoformat()
def test_trash_base_for_root_is_fixed_path():
assert core.get_trash_base_for_user(0) == Path("/root/.local/share/resrm")
def test_trash_base_uses_user_home(isolated_trash):
base = core.get_trash_base_for_user(12345)
assert base == isolated_trash.home / ".local" / "share" / "resrm"
def test_load_meta_missing_file_returns_empty(isolated_trash):
assert not isolated_trash.meta_file.exists()
assert core.load_meta() == []
def test_load_meta_malformed_file_returns_empty(isolated_trash):
isolated_trash.meta_file.write_text("{not json", encoding="utf-8")
assert core.load_meta() == []
def test_find_candidates_prefers_exact_basename(isolated_trash):
by_name = _entry("deadbeef" + "0" * 24, "/x/report.txt")
by_prefix = _entry("report.txt-extra", "/x/notes.md")
core.meta = [by_prefix, by_name]
assert core.find_candidates("report.txt") == [by_name]
def test_find_candidates_matches_id_prefix(isolated_trash):
first = _entry("abcd1234" + "0" * 24, "/x/a")
second = _entry("abcd9999" + "0" * 24, "/x/b")
core.meta = [first, second]
assert core.find_candidates("abcd12") == [first]
assert core.find_candidates("abcd") == [first, second]
assert core.find_candidates("zzz") == []
def test_move_to_trash_moves_file_and_writes_metadata(
isolated_trash, tmp_path
):
src = tmp_path / "keep.txt"
src.write_text("data", encoding="utf-8")
core.move_to_trash(src, interactive=False, force=False, skip_trash=False)
assert not src.exists()
stored = list(isolated_trash.trash_dir.iterdir())
assert len(stored) == 1
saved = json.loads(isolated_trash.meta_file.read_text(encoding="utf-8"))
assert saved[0]["orig_path"] == str(src.resolve())
assert saved[0]["id"] == stored[0].name
def test_skip_trash_deletes_file_permanently(isolated_trash, tmp_path):
src = tmp_path / "gone.txt"
src.write_text("x", encoding="utf-8")
core.move_to_trash(src, interactive=False, force=False, skip_trash=True)
assert not src.exists()
assert list(isolated_trash.trash_dir.iterdir()) == []
assert core.load_meta() == []
def test_skip_trash_removes_symlink_not_target(isolated_trash, tmp_path):
target = tmp_path / "target.txt"
target.write_text("data", encoding="utf-8")
link = tmp_path / "link.txt"
link.symlink_to(target)
core.move_to_trash(link, interactive=False, force=False, skip_trash=True)
assert not link.exists()
assert target.exists()
def test_missing_path_without_force_reports_error(
isolated_trash, tmp_path, capsys
):
missing = tmp_path / "nope.txt"
core.move_to_trash(
missing, interactive=False, force=False, skip_trash=False
)
assert "No such file or directory" in capsys.readouterr().out
def test_missing_path_with_force_is_silent(isolated_trash, tmp_path, capsys):
missing = tmp_path / "nope.txt"
core.move_to_trash(
missing, interactive=False, force=True, skip_trash=False
)
assert capsys.readouterr().out == ""
def test_directory_without_recursive_is_rejected(
isolated_trash, tmp_path, capsys
):
directory = tmp_path / "adir"
directory.mkdir()
core.main([str(directory)])
assert "Is a directory" in capsys.readouterr().out
assert directory.is_dir()
def test_restore_one_returns_to_original_path(isolated_trash, tmp_path):
original = tmp_path / "restored.txt"
stored = isolated_trash.trash_dir / ("a" * 32)
stored.write_text("data", encoding="utf-8")
entry = _entry("a" * 32, original)
core.meta = [entry]
assert core.restore_one(entry) is True
assert original.read_text(encoding="utf-8") == "data"
assert not stored.exists()
assert core.meta == []
assert core.load_meta() == []
def test_restore_one_falls_back_to_cwd_when_original_exists(
isolated_trash, tmp_path
):
(tmp_path / "sub").mkdir()
original = tmp_path / "sub" / "taken.txt"
original.write_text("original", encoding="utf-8")
stored = isolated_trash.trash_dir / ("b" * 32)
stored.write_text("trashed", encoding="utf-8")
entry = _entry("b" * 32, original)
core.meta = [entry]
assert core.restore_one(entry) is True
fallback = tmp_path / "taken.txt"
assert fallback.read_text(encoding="utf-8") == "trashed"
assert original.read_text(encoding="utf-8") == "original"
@pytest.mark.parametrize(
"life, days_old, expect_pruned",
[
("7", 10, True),
("7", 1, False),
("not-a-number", 10, True),
("0", 2, True),
],
)
def test_prune_old_trash_retention(
isolated_trash, monkeypatch, life, days_old, expect_pruned
):
monkeypatch.setenv("RESRM_TRASH_LIFE", life)
old = isolated_trash.trash_dir / ("c" * 32)
old.write_text("old", encoding="utf-8")
entry = _entry("c" * 32, "/x/old.txt", timestamp=_iso_days_ago(days_old))
core.meta = [entry]
core.prune_old_trash()
if expect_pruned:
assert not old.exists()
assert core.meta == []
else:
assert old.exists()
assert core.meta == [entry]
def test_empty_trash_clears_files_and_metadata(isolated_trash):
file_entry = isolated_trash.trash_dir / ("d" * 32)
file_entry.write_text("1", encoding="utf-8")
dir_entry = isolated_trash.trash_dir / ("e" * 32)
dir_entry.mkdir()
(dir_entry / "inner").write_text("2", encoding="utf-8")
core.meta = [_entry("d" * 32, "/x/1"), _entry("e" * 32, "/x/2")]
core.empty_trash()
assert list(isolated_trash.trash_dir.iterdir()) == []
assert core.meta == []
saved = json.loads(isolated_trash.meta_file.read_text(encoding="utf-8"))
assert saved == []