Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab6b78e408
|
||
|
|
4697891fea
|
||
|
|
a6dfb4e627
|
||
|
|
23138a1d5e | ||
|
|
0588915bc1 | ||
|
|
daf17175d0 | ||
|
|
272ea7ec59 | ||
|
|
74642385f1 | ||
|
|
9323e2d6ae
|
||
|
|
aae2c572fa
|
||
|
|
650c3a82c3 | ||
|
|
337625dcf3
|
||
|
|
4200498273
|
||
|
|
9b121ebd3a
|
No files matched your search
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* @mdaleo404
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
github: mdaleo404
|
||||||
|
ko_fi: mdaleo404
|
||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 == []
|
||||||
Reference in new issue
Block a user