Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f3935ffa1
|
||
|
|
11c6aa812b | ||
|
|
f466040677
|
||
|
|
c1140c1b9c
|
||
|
|
f3bd7b022d
|
||
|
|
e5699a3f60
|
||
|
|
e527e71488
|
||
|
|
ac49ea53e7
|
||
|
|
0f5a80e67c | ||
|
|
9f7bc804b0
|
||
|
|
f07cbe06c8 | ||
|
|
4abc89def9
|
||
|
|
5c63675ef5
|
||
|
|
88b2dfd1a5 | ||
|
|
ee80306d9a
|
||
|
|
cc97d3d7f1
|
||
|
|
9bf5ef1d7d
|
||
|
|
24696af083
|
||
|
|
fcef549eba
|
||
|
|
049273a13c
|
||
|
|
d00407bb33
|
||
|
|
a8dbe675f5
|
||
|
|
cc2b1fe791
|
||
|
|
db60b4e42b | ||
|
|
afc964a076
|
@@ -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/chguard",
|
||||||
|
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,61 +0,0 @@
|
|||||||
---
|
|
||||||
name: Trivy Scan
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: 17 8 * * *
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
security-scan:
|
|
||||||
runs-on: running-man
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Trivy scan via Docker
|
|
||||||
id: trivy
|
|
||||||
continue-on-error: true
|
|
||||||
run: |
|
|
||||||
docker run --rm \
|
|
||||||
--volumes-from "$HOSTNAME" \
|
|
||||||
aquasec/trivy:latest \
|
|
||||||
fs /workspace/guardutils/chguard \
|
|
||||||
--scanners vuln \
|
|
||||||
--pkg-types library \
|
|
||||||
--include-dev-deps \
|
|
||||||
--severity MEDIUM,HIGH,CRITICAL \
|
|
||||||
--ignore-unfixed \
|
|
||||||
--format json \
|
|
||||||
--output /workspace/guardutils/chguard/trivy.json \
|
|
||||||
--exit-code 1
|
|
||||||
|
|
||||||
- name: Notify Node-RED on vulnerabilities
|
|
||||||
if: steps.trivy.outcome == 'failure'
|
|
||||||
run: |
|
|
||||||
jq -r '
|
|
||||||
{
|
|
||||||
repo: "guardutils/chguard",
|
|
||||||
summary: (
|
|
||||||
"Total: " +
|
|
||||||
((.Results[].Vulnerabilities | length) | tostring)
|
|
||||||
),
|
|
||||||
vulnerabilities: [
|
|
||||||
.Results[].Vulnerabilities[] | {
|
|
||||||
library: .PkgName,
|
|
||||||
cve: .VulnerabilityID,
|
|
||||||
severity: .Severity,
|
|
||||||
installed: .InstalledVersion,
|
|
||||||
fixed: .FixedVersion,
|
|
||||||
title: .Title,
|
|
||||||
url: .PrimaryURL
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
' trivy.json \
|
|
||||||
| curl -s -X POST https://nodered.sysmd.uk/trivy-alert \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
--data-binary @-
|
|
||||||
|
|
||||||
- name: Fail workflow if vulnerabilities found
|
|
||||||
if: steps.trivy.outcome == 'failure'
|
|
||||||
run: exit 1
|
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/PyCQA/bandit
|
- repo: https://github.com/PyCQA/bandit
|
||||||
rev: 1.7.9
|
rev: 1.9.4
|
||||||
hooks:
|
hooks:
|
||||||
- id: bandit
|
- id: bandit
|
||||||
files: ^src/mirro/
|
files: ^chguard/
|
||||||
args: ["-lll", "-iii", "-s", "B110,B112"]
|
args: ["-lll", "-iii", "-s", "B110,B112"]
|
||||||
|
|
||||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||||
rev: 25.11.0
|
rev: 26.3.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
language_version: python3.13
|
language_version: python3
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.4.0
|
rev: v6.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
|
|||||||
+835
@@ -0,0 +1,835 @@
|
|||||||
|
# chguard Development Guide
|
||||||
|
|
||||||
|
Interested in the internals of chguard?
|
||||||
|
|
||||||
|
This guide describes the current `chguard` codebase for maintainers. It focuses on how the project is organised, what calls what, how filesystem metadata flows into SQLite snapshots, and which invariants matter when changing the code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What chguard does
|
||||||
|
|
||||||
|
`chguard` is a safety-first command-line tool for snapshotting and restoring filesystem ownership and permission metadata.
|
||||||
|
|
||||||
|
Its core pipeline is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Filesystem tree
|
||||||
|
|
|
||||||
|
| chguard --save PATH --name NAME
|
||||||
|
v
|
||||||
|
SQLite snapshot
|
||||||
|
states: snapshot metadata
|
||||||
|
entries: relative path, type, mode, uid, gid
|
||||||
|
|
|
||||||
|
| chguard --restore NAME [--dry-run] [--yes]
|
||||||
|
v
|
||||||
|
Restore plan
|
||||||
|
preview owner/mode differences
|
||||||
|
apply chmod/chown only after confirmation
|
||||||
|
```
|
||||||
|
|
||||||
|
`chguard` deliberately does not track file contents, hashes, ACLs, extended attributes, deleted files, or new files. It only records enough state to compare and restore:
|
||||||
|
|
||||||
|
```text
|
||||||
|
relative path
|
||||||
|
entry type: file | dir | symlink
|
||||||
|
permission bits
|
||||||
|
numeric uid
|
||||||
|
numeric gid
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrapper mode adds one more flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
chguard -- chown|chmod|chgrp ... PATH...
|
||||||
|
-> discover existing path arguments
|
||||||
|
-> save an automatic pre-command snapshot
|
||||||
|
-> run the wrapped command
|
||||||
|
-> exit with the wrapped command's return code
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrapper mode is intentionally limited to `chown`, `chmod`, and `chgrp`. Other commands are rejected because chguard only protects ownership and permission metadata.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Repository layout
|
||||||
|
|
||||||
|
The project is a single Python package under `chguard/`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
chguard/
|
||||||
|
__init__.py package marker
|
||||||
|
cli.py argparse CLI, user interaction, Rich output, dispatch
|
||||||
|
db.py SQLite path, schema creation, state CRUD helpers
|
||||||
|
scan.py filesystem tree scan into Entry objects
|
||||||
|
restore.py restore planning and chmod/chown application
|
||||||
|
util.py small path normalisation helper
|
||||||
|
|
||||||
|
pyproject.toml Poetry package metadata and console script
|
||||||
|
poetry.lock locked dependency graph
|
||||||
|
README.md user-facing documentation
|
||||||
|
.pre-commit-config.yaml 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]
|
||||||
|
chguard = "chguard.cli:main"
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no `chguard/__main__.py` at the time of writing, so `python -m chguard` is not the supported entry point. Use the installed `chguard` command or `poetry run chguard` during development.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Main runtime flows
|
||||||
|
|
||||||
|
### 3.1 CLI entry flow
|
||||||
|
|
||||||
|
All user-facing behaviour enters through `chguard.cli.main()`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
chguard command
|
||||||
|
-> chguard.cli.main()
|
||||||
|
-> split wrapper command after --, if present
|
||||||
|
-> build argparse parser
|
||||||
|
-> install argcomplete hooks
|
||||||
|
-> parse arguments
|
||||||
|
-> open/init SQLite database
|
||||||
|
-> dispatch to wrapper, prune, list, delete, save, or restore branch
|
||||||
|
```
|
||||||
|
|
||||||
|
The top-level mutually exclusive actions are:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--save PATH snapshot a path under a required --name
|
||||||
|
--restore STATE preview and optionally apply a saved state
|
||||||
|
--list list saved states
|
||||||
|
--delete STATE delete one saved state
|
||||||
|
--prune-states delete states older than an age, or all states
|
||||||
|
wrapper mode chguard -- chown|chmod|chgrp ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`cli.py` currently owns both orchestration and most presentation logic. The narrower modules should stay narrow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
db.py owns persistence helpers and schema setup
|
||||||
|
scan.py owns filesystem metadata scanning
|
||||||
|
restore.py owns compare/apply semantics
|
||||||
|
util.py owns shared small helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
If a change is not about command-line parsing, confirmation, or display, prefer keeping it out of `cli.py`.
|
||||||
|
|
||||||
|
### 3.2 Subcommand call graph
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[chguard.cli.main] --> B{wrapper command?}
|
||||||
|
B -->|yes| C[validate chown/chmod/chgrp]
|
||||||
|
C --> D[extract existing path args]
|
||||||
|
D --> E[create auto state in db]
|
||||||
|
E --> F[run wrapped command]
|
||||||
|
B -->|no| G[parse action]
|
||||||
|
G -->|--save| H[util.normalize_root]
|
||||||
|
H --> I[scan.scan_tree]
|
||||||
|
I --> J[db.create_state + entries insert]
|
||||||
|
G -->|--restore| K[db.get_state]
|
||||||
|
K --> L[restore.plan_restore]
|
||||||
|
L --> M[render Rich diff table]
|
||||||
|
M --> N{--dry-run?}
|
||||||
|
N -->|no| O[confirm and root check]
|
||||||
|
O --> P[restore.apply_restore]
|
||||||
|
G -->|--list| Q[query states and entries]
|
||||||
|
G -->|--delete| R[db.delete_state]
|
||||||
|
G -->|--prune-states| S[select cutoff/all]
|
||||||
|
S --> T[preview deletion table]
|
||||||
|
T --> U[confirm]
|
||||||
|
U --> V[db.prune_states_before or db.prune_all_states]
|
||||||
|
```
|
||||||
|
|
||||||
|
Important dependency direction:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cli.py
|
||||||
|
depends on db.py, scan.py, restore.py, util.py, Rich, argcomplete
|
||||||
|
|
||||||
|
scan.py
|
||||||
|
depends on pathlib, os.walk, lstat/stat only
|
||||||
|
|
||||||
|
restore.py
|
||||||
|
depends on pathlib, lstat/stat, os.chown, os.chmod only
|
||||||
|
|
||||||
|
db.py
|
||||||
|
depends on sqlite3 and platformdirs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Snapshot storage
|
||||||
|
|
||||||
|
Snapshots are stored in a local SQLite database.
|
||||||
|
|
||||||
|
Default path:
|
||||||
|
|
||||||
|
```text
|
||||||
|
platformdirs.user_data_dir("chguard")/states.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Users can override the database with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chguard --db /path/to/states.db ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 Database schema
|
||||||
|
|
||||||
|
The schema is created by `db.init_db()`.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS states (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
root_path TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
created_by_uid INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
|
state_id INTEGER NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
mode INTEGER NOT NULL,
|
||||||
|
uid INTEGER NOT NULL,
|
||||||
|
gid INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (state_id, path),
|
||||||
|
FOREIGN KEY (state_id) REFERENCES states(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The primary key on `(state_id, path)` means one snapshot cannot contain duplicate relative paths. Wrapper mode also keeps an in-memory `seen_paths` set to avoid duplicate inserts when a command names overlapping paths such as `foo` and `foo/bar`.
|
||||||
|
|
||||||
|
### 4.2 Stored values
|
||||||
|
|
||||||
|
`entries.path` is relative to `states.root_path`. The root itself is stored as the empty string `""`.
|
||||||
|
|
||||||
|
`entries.mode` stores permission bits only, using `stat.S_IMODE()`. It does not store file type bits.
|
||||||
|
|
||||||
|
`entries.uid` and `entries.gid` are numeric. User and group names are resolved only for restore preview display in `cli.py`.
|
||||||
|
|
||||||
|
`entries.type` can be:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dir
|
||||||
|
file
|
||||||
|
symlink
|
||||||
|
```
|
||||||
|
|
||||||
|
Special files such as devices, sockets, and FIFOs are skipped by `scan.py` and wrapper-mode entry collection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Data objects
|
||||||
|
|
||||||
|
The codebase uses a small set of dataclasses rather than a large domain model.
|
||||||
|
|
||||||
|
| Dataclass | File | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `db.State` | `db.py` | One row from `states`, used by restore. |
|
||||||
|
| `scan.Entry` | `scan.py` | One scanned filesystem item relative to a snapshot root. |
|
||||||
|
| `restore.PlannedChange` | `restore.py` | One restore comparison result, such as mode drift, owner drift, missing path, or type mismatch. |
|
||||||
|
|
||||||
|
The tuple shape passed from SQLite to restore functions is:
|
||||||
|
|
||||||
|
```python
|
||||||
|
(path: str, type: str, mode: int, uid: int, gid: int)
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep this shape stable or change both `cli.py` and `restore.py` together.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Saving snapshots
|
||||||
|
|
||||||
|
The save entry point is the `--save` branch in `cli.main()`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
--save PATH --name NAME
|
||||||
|
-> normalize_root(PATH)
|
||||||
|
-> reject existing NAME unless --overwrite
|
||||||
|
-> create states row
|
||||||
|
-> scan_tree(root, excludes=args.exclude)
|
||||||
|
-> refuse if a captured entry is root-owned and current process is not root
|
||||||
|
-> insert entries rows
|
||||||
|
```
|
||||||
|
|
||||||
|
`util.normalize_root()` expands `~` and resolves the path:
|
||||||
|
|
||||||
|
```python
|
||||||
|
Path(path).expanduser().resolve()
|
||||||
|
```
|
||||||
|
|
||||||
|
`scan.scan_tree()` uses `lstat()` and `os.walk(..., followlinks=False)`. It does not follow symlinks while scanning. It records regular files, directories, and symlinks, and skips special files.
|
||||||
|
|
||||||
|
### 6.1 Excludes
|
||||||
|
|
||||||
|
`--exclude` is implemented by `scan._is_excluded()` as simple relative prefix matching.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--exclude cache skips cache and cache/...
|
||||||
|
--exclude var/tmp skips var/tmp and var/tmp/...
|
||||||
|
```
|
||||||
|
|
||||||
|
Excludes are not globs or regular expressions. They are stripped of leading and trailing slashes before comparison.
|
||||||
|
|
||||||
|
### 6.2 Transaction behaviour
|
||||||
|
|
||||||
|
Save runs inside `with conn:`. If scanning finds a root-owned entry while the process is not root, `SystemExit` interrupts the transaction and sqlite3 rolls it back. This prevents partially saved states for the normal save flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Wrapper mode
|
||||||
|
|
||||||
|
Wrapper mode is detected before argparse parses normal options. Everything after the first `--` is treated as the wrapped command.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chguard -- chmod 755 file
|
||||||
|
chguard -- chown user:group file
|
||||||
|
chguard -- chgrp staff file
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported commands are checked by basename only:
|
||||||
|
|
||||||
|
```text
|
||||||
|
chown
|
||||||
|
chmod
|
||||||
|
chgrp
|
||||||
|
```
|
||||||
|
|
||||||
|
The wrapper snapshot flow is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
wrapper_cmd
|
||||||
|
-> _extract_paths_from_command()
|
||||||
|
-> _common_snapshot_root()
|
||||||
|
-> create auto-YYYYMMDD-HHMMSS state
|
||||||
|
-> _iter_entries_for_target() for each path
|
||||||
|
-> insert each relative path once
|
||||||
|
-> run subprocess.run(wrapper_cmd)
|
||||||
|
-> exit with subprocess return code
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.1 Path extraction limits
|
||||||
|
|
||||||
|
`_extract_paths_from_command()` is intentionally simple. It treats any existing non-option argument as a path and skips arguments starting with `-`.
|
||||||
|
|
||||||
|
This works for common forms such as:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chguard -- chmod 644 file
|
||||||
|
chguard -- chown user:group file1 file2
|
||||||
|
```
|
||||||
|
|
||||||
|
It is not a full parser for every `chmod`, `chown`, or `chgrp` option. Be careful when adding wrapper support for options that take path-like values or when supporting more commands.
|
||||||
|
|
||||||
|
### 7.2 Snapshot root selection
|
||||||
|
|
||||||
|
For one path, `_common_snapshot_root()` uses that path. For multiple paths, it uses `os.path.commonpath()` across resolved paths.
|
||||||
|
|
||||||
|
That means one auto snapshot can cover commands such as:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chguard -- chmod 700 foo1 foo2
|
||||||
|
```
|
||||||
|
|
||||||
|
without creating multiple entries with the empty relative path.
|
||||||
|
|
||||||
|
### 7.3 Empty path list
|
||||||
|
|
||||||
|
If no existing path arguments are found, wrapper mode does not create a snapshot. It still runs the wrapped command and returns the wrapped command's exit code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Restore planning and application
|
||||||
|
|
||||||
|
Restore is split into two phases:
|
||||||
|
|
||||||
|
```text
|
||||||
|
restore.plan_restore() compare current filesystem to saved rows
|
||||||
|
restore.apply_restore() apply selected chmod/chown operations
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI uses `plan_restore()` first, renders a Rich table, then applies only after confirmation unless `--dry-run` is set.
|
||||||
|
|
||||||
|
### 8.1 Restore target root
|
||||||
|
|
||||||
|
By default, restore targets the original `states.root_path`.
|
||||||
|
|
||||||
|
Users can override it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chguard --restore NAME --root /alternate/root
|
||||||
|
```
|
||||||
|
|
||||||
|
The override is normalised with `normalize_root()` before use. Stored relative paths are appended to the target root.
|
||||||
|
|
||||||
|
### 8.2 Scope flags
|
||||||
|
|
||||||
|
Restore scope is selected in `cli.py`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
default restore permissions and ownership
|
||||||
|
--permissions restore permission bits only
|
||||||
|
--owner restore uid/gid only
|
||||||
|
--permissions --owner argparse allows both; behaviour is both
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI computes two booleans and passes them to both planning and apply:
|
||||||
|
|
||||||
|
```python
|
||||||
|
restore_permissions
|
||||||
|
restore_owner
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 Planned change types
|
||||||
|
|
||||||
|
`restore.plan_restore()` can produce these `PlannedChange.kind` values:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mode current permission bits differ from saved mode
|
||||||
|
owner current uid/gid differ from saved uid/gid
|
||||||
|
missing saved path does not currently exist
|
||||||
|
type current path type differs from saved type
|
||||||
|
```
|
||||||
|
|
||||||
|
The CLI restore table displays applicable `mode` and `owner` changes, plus non-applicable `missing` and `type` drift in a `Skipped` column. Missing paths and type mismatches are previewed for operator visibility but are never created, deleted, replaced, or otherwise repaired by restore.
|
||||||
|
|
||||||
|
### 8.4 Applying changes
|
||||||
|
|
||||||
|
`restore.apply_restore()` re-checks each path with `lstat()` before applying. It skips missing paths, special files, and type mismatches.
|
||||||
|
|
||||||
|
When enabled by scope flags, it runs:
|
||||||
|
|
||||||
|
```python
|
||||||
|
os.chown(path, want_uid, want_gid, follow_symlinks=False)
|
||||||
|
os.chmod(path, want_mode, follow_symlinks=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
`PermissionError` and `NotImplementedError` are swallowed in `apply_restore()`. The CLI tries to catch obvious privilege problems before apply, but apply remains best-effort for platform differences such as chmod on symlinks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Safety and privilege model
|
||||||
|
|
||||||
|
Important product boundaries:
|
||||||
|
|
||||||
|
```text
|
||||||
|
chguard never creates files during restore
|
||||||
|
chguard never deletes files during restore
|
||||||
|
chguard never moves or renames files
|
||||||
|
chguard never changes file contents
|
||||||
|
chguard never escalates privileges automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.1 Root-owned files during save
|
||||||
|
|
||||||
|
Both normal save and wrapper snapshot creation refuse to save a root-owned entry when the process effective uid is not root.
|
||||||
|
|
||||||
|
Normal save error:
|
||||||
|
|
||||||
|
```text
|
||||||
|
This path contains root-owned files.
|
||||||
|
Saving this state requires sudo.
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrapper mode error:
|
||||||
|
|
||||||
|
```text
|
||||||
|
This command affects root-owned files.
|
||||||
|
Please re-run with sudo.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Root requirement during restore
|
||||||
|
|
||||||
|
Restore preview and dry-run do not require root.
|
||||||
|
|
||||||
|
Before applying, `cli.py` marks the operation as needing root when a changed path is not owned by the current effective uid. If root is needed and the process is not root, the CLI exits before confirmation and apply.
|
||||||
|
|
||||||
|
This is intentionally conservative around ownership and mode changes. Do not add automatic sudo execution.
|
||||||
|
|
||||||
|
### 9.3 Confirmation
|
||||||
|
|
||||||
|
Destructive or mutating operations require explicit confirmation unless `--yes` is provided:
|
||||||
|
|
||||||
|
```text
|
||||||
|
restore apply
|
||||||
|
prune states
|
||||||
|
```
|
||||||
|
|
||||||
|
If stdin is not a TTY and `--yes` is not provided, `_confirm_or_abort()` refuses to continue.
|
||||||
|
|
||||||
|
### 9.4 Symlinks
|
||||||
|
|
||||||
|
The implementation uses `lstat()` and `follow_symlinks=False`, so it does not follow symlink targets while scanning or restoring.
|
||||||
|
|
||||||
|
Current code can record symlink entries with type `symlink`. Ownership restore is attempted with `os.chown(..., follow_symlinks=False)`. Permission restore is attempted with `os.chmod(..., follow_symlinks=False)` and ignored if the platform does not support it.
|
||||||
|
|
||||||
|
The README currently describes symbolic links as skipped entirely. Treat this as a documentation/behaviour point to resolve carefully before making symlink-related changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Listing, deleting, and pruning states
|
||||||
|
|
||||||
|
### 10.1 Listing
|
||||||
|
|
||||||
|
`--list` queries all states newest-first and displays:
|
||||||
|
|
||||||
|
```text
|
||||||
|
State
|
||||||
|
Snapshot root
|
||||||
|
Captured paths
|
||||||
|
Created
|
||||||
|
```
|
||||||
|
|
||||||
|
`_captured_paths_summary()` displays `directory tree`, `file`, or `symlink` when the snapshot contains a root entry. Otherwise it shows up to five top-level captured relative paths.
|
||||||
|
|
||||||
|
Auto snapshots with names starting `auto-` are highlighted in bright cyan.
|
||||||
|
|
||||||
|
### 10.2 Deleting one state
|
||||||
|
|
||||||
|
`--delete STATE` calls `db.delete_state()`. The `entries` rows are removed by `ON DELETE CASCADE`.
|
||||||
|
|
||||||
|
There is currently no confirmation prompt for deleting one named state.
|
||||||
|
|
||||||
|
### 10.3 Pruning states
|
||||||
|
|
||||||
|
`--prune-states` supports three forms:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chguard --prune-states=14
|
||||||
|
chguard --prune-states=all
|
||||||
|
CHGUARD_STATES_LIFE=30 chguard --prune-states
|
||||||
|
```
|
||||||
|
|
||||||
|
Parsing lives in `_parse_prune_states_value()`.
|
||||||
|
|
||||||
|
`--dry-run` previews matching states without deleting. Without `--dry-run`, pruning requires confirmation or `--yes`.
|
||||||
|
|
||||||
|
The default age environment variable is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CHGUARD_STATES_LIFE
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Display and completion helpers
|
||||||
|
|
||||||
|
`cli.py` uses Rich for tables and colourised status output.
|
||||||
|
|
||||||
|
Important display helpers:
|
||||||
|
|
||||||
|
```text
|
||||||
|
_uid_to_name()
|
||||||
|
_gid_to_name()
|
||||||
|
_format_owner()
|
||||||
|
_mode_to_rwx()
|
||||||
|
_captured_paths_summary()
|
||||||
|
```
|
||||||
|
|
||||||
|
User/group name lookup falls back to numeric ids when the uid/gid does not exist on the current host.
|
||||||
|
|
||||||
|
Shell completion uses `argcomplete`. `complete_state_names()` opens the configured database and completes names from the `states` table. It catches all exceptions and returns an empty list so completion failures do not break normal shell use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Development commands
|
||||||
|
|
||||||
|
Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
poetry install
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the CLI in the development environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
poetry run chguard --help
|
||||||
|
```
|
||||||
|
|
||||||
|
Run pre-commit hooks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
poetry run pre-commit run --all-files
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the pytest suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
poetry run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Build release artifacts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
poetry build
|
||||||
|
```
|
||||||
|
|
||||||
|
The checked-in test suite uses pytest under `tests/`. When adding behaviour, add focused tests rather than relying only on manual CLI checks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Automation and security scanning
|
||||||
|
|
||||||
|
Gitea pull request workflow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.gitea/workflows/lint-and-security.yml
|
||||||
|
-> install pre-commit
|
||||||
|
-> pre-commit run --all-files
|
||||||
|
-> 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Common maintenance tasks
|
||||||
|
|
||||||
|
### 14.1 Add a new CLI option
|
||||||
|
|
||||||
|
1. Add the argparse option in `cli.py`.
|
||||||
|
2. Decide whether it affects save, restore, wrapper mode, pruning, or display.
|
||||||
|
3. Keep persistence changes in `db.py` if schema or state lookup changes.
|
||||||
|
4. Keep scanning changes in `scan.py` if filesystem enumeration changes.
|
||||||
|
5. Keep apply semantics in `restore.py` if restore comparison or mutation changes.
|
||||||
|
6. Update README usage examples.
|
||||||
|
7. Add tests for parser behaviour and the affected operation.
|
||||||
|
|
||||||
|
### 14.2 Change the database schema
|
||||||
|
|
||||||
|
1. Update `db.init_db()`.
|
||||||
|
2. Decide whether old databases must be migrated.
|
||||||
|
3. Update `db.State` or add new dataclasses as needed.
|
||||||
|
4. Update all SQL in `cli.py` and `db.py` that reads or writes affected columns.
|
||||||
|
5. Add tests using a temporary database file.
|
||||||
|
|
||||||
|
There is currently no migration system. Do not silently make schema changes that break existing user databases unless the project intentionally accepts that compatibility break.
|
||||||
|
|
||||||
|
### 14.3 Change scan behaviour
|
||||||
|
|
||||||
|
Start with `scan.py`.
|
||||||
|
|
||||||
|
Preserve these invariants unless intentionally redesigning the tool:
|
||||||
|
|
||||||
|
```text
|
||||||
|
use lstat rather than stat
|
||||||
|
do not follow symlinks
|
||||||
|
store relative paths under one snapshot root
|
||||||
|
store the root entry as the empty string
|
||||||
|
skip special files unless restore semantics are also designed
|
||||||
|
keep excludes predictable and documented
|
||||||
|
```
|
||||||
|
|
||||||
|
If changing excludes from prefix matching to glob or regex matching, preserve simple prefix behaviour or document the compatibility break.
|
||||||
|
|
||||||
|
### 14.4 Change restore behaviour
|
||||||
|
|
||||||
|
Start with `restore.plan_restore()` for comparisons and `restore.apply_restore()` for mutation.
|
||||||
|
|
||||||
|
Keep planning and application separate. The CLI depends on being able to preview before mutating.
|
||||||
|
|
||||||
|
Do not make restore create missing files, delete new files, replace mismatched types, or modify file contents without a deliberate product redesign.
|
||||||
|
|
||||||
|
### 14.5 Change wrapper mode
|
||||||
|
|
||||||
|
Start with these helpers in `cli.py`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
_extract_paths_from_command()
|
||||||
|
_common_snapshot_root()
|
||||||
|
_iter_entries_for_target()
|
||||||
|
```
|
||||||
|
|
||||||
|
Adding more wrapped commands requires understanding whether they only mutate ownership/permissions. Do not wrap commands that can create, delete, rename, or rewrite file contents unless the tool's scope changes.
|
||||||
|
|
||||||
|
### 14.6 Add tests
|
||||||
|
|
||||||
|
Good first test areas:
|
||||||
|
|
||||||
|
```text
|
||||||
|
scan_tree records root, dirs, files, symlinks, and excludes
|
||||||
|
plan_restore reports mode, owner, missing, and type changes
|
||||||
|
apply_restore skips missing/type mismatches
|
||||||
|
db.init_db creates schema and cascade delete works
|
||||||
|
prune age parsing handles integer, all, env, invalid values
|
||||||
|
wrapper path extraction handles common chmod/chown/chgrp shapes
|
||||||
|
CLI restore dry-run never applies changes
|
||||||
|
```
|
||||||
|
|
||||||
|
Use temporary directories and temporary SQLite files. Avoid tests that require root unless they are explicitly skipped when not root.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Important maintenance hazards
|
||||||
|
|
||||||
|
### 15.1 `cli.py` is doing a lot
|
||||||
|
|
||||||
|
`cli.py` currently contains parsing, wrapper mode, database insertion loops, restore table formatting, pruning display, completion, and privilege checks. As features grow, prefer moving domain logic into `scan.py`, `restore.py`, or new focused modules.
|
||||||
|
|
||||||
|
### 15.2 Restore preview separates applicable and skipped drift
|
||||||
|
|
||||||
|
`plan_restore()` reports owner, mode, missing, and type drift. The CLI displays owner/mode changes as applicable actions and missing/type drift as skipped items. Keep that distinction clear: showing skipped drift is useful, but restore must still not create missing files or replace mismatched paths.
|
||||||
|
|
||||||
|
### 15.3 Wrapper parsing is not command-specific
|
||||||
|
|
||||||
|
Wrapper mode does not fully parse `chmod`, `chown`, or `chgrp`. It snapshots existing non-option arguments. Any change that broadens wrapper usage should avoid giving users a false sense that every affected path was captured.
|
||||||
|
|
||||||
|
### 15.4 Numeric ids are the source of truth
|
||||||
|
|
||||||
|
Snapshots store uid/gid numbers. Display names are cosmetic and host-local. Do not make restore depend on resolving user or group names.
|
||||||
|
|
||||||
|
### 15.5 Path normalisation affects restore portability
|
||||||
|
|
||||||
|
Snapshot roots are resolved absolute paths. `--root` is the mechanism for applying a snapshot somewhere else. Do not change root/path semantics without considering existing databases.
|
||||||
|
|
||||||
|
### 15.6 Symlink behaviour needs care
|
||||||
|
|
||||||
|
The scanner records symlinks but never follows them. Restore uses no-follow operations where available. Platform differences around symlink chmod/chown are real, so keep user-facing wording precise: symlinks are recorded and handled best-effort without following targets.
|
||||||
|
|
||||||
|
### 15.7 Permission errors can be best-effort
|
||||||
|
|
||||||
|
The CLI attempts to detect when root is required, but `apply_restore()` still suppresses `PermissionError`. If maintainers need strict failure reporting, change both apply return values and CLI output so users can see partial failures.
|
||||||
|
|
||||||
|
### 15.8 Existing user databases matter
|
||||||
|
|
||||||
|
The default database is persistent user state. Schema and semantics changes can affect existing saved snapshots, not just new runs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Troubleshooting guide
|
||||||
|
|
||||||
|
### 16.1 `--save` says sudo is required
|
||||||
|
|
||||||
|
At least one captured entry has uid `0`, and the current process is not root. Re-run with sudo or narrow the saved path/excludes.
|
||||||
|
|
||||||
|
### 16.2 Restore says sudo is required
|
||||||
|
|
||||||
|
At least one owner or mode change targets a path not owned by the current effective uid. Preview and `--dry-run` are still available without sudo.
|
||||||
|
|
||||||
|
### 16.3 Restore shows skipped items
|
||||||
|
|
||||||
|
Missing paths and type mismatches are shown in the restore preview as skipped items. This is expected: chguard reports the drift but does not create missing files or replace paths with the wrong type.
|
||||||
|
|
||||||
|
### 16.4 A path was not captured
|
||||||
|
|
||||||
|
Check, in order:
|
||||||
|
|
||||||
|
1. Does the path exist at snapshot time?
|
||||||
|
2. Is it under the normalised snapshot root?
|
||||||
|
3. Was it excluded by `--exclude` prefix matching?
|
||||||
|
4. Is it a special file such as a socket, FIFO, or device?
|
||||||
|
5. In wrapper mode, did the argument start with `-` or not exist before the command ran?
|
||||||
|
|
||||||
|
### 16.5 Completion does not show state names
|
||||||
|
|
||||||
|
Check that argcomplete is installed and registered for the shell, and that `--db` points at the expected database. Completion failures are intentionally silent.
|
||||||
|
|
||||||
|
### 16.6 Prune without an age fails
|
||||||
|
|
||||||
|
`chguard --prune-states` without a value reads `CHGUARD_STATES_LIFE`. Set the environment variable or pass an explicit value such as `--prune-states=30` or `--prune-states=all`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Practical code-reading map
|
||||||
|
|
||||||
|
| Feature/question | Start with | Then read |
|
||||||
|
|---|---|---|
|
||||||
|
| CLI option behaviour | `cli.py:main()` | argparse branch for the action |
|
||||||
|
| Snapshot schema | `db.py:init_db()` | SQL call sites in `cli.py` |
|
||||||
|
| Default DB path | `db.py:default_db_path()` | `platformdirs.user_data_dir` docs |
|
||||||
|
| Save scanning | `scan.py:scan_tree()` | `cli.py` `args.save` branch |
|
||||||
|
| Exclude behaviour | `scan.py:_is_excluded()` | README usage docs |
|
||||||
|
| Wrapper snapshots | `cli.py` wrapper branch | `_extract_paths_from_command()` and `_iter_entries_for_target()` |
|
||||||
|
| Restore comparison | `restore.py:plan_restore()` | CLI restore display loop |
|
||||||
|
| Restore mutation | `restore.py:apply_restore()` | CLI confirmation/root checks |
|
||||||
|
| Rich output | `cli.py` table construction | `_format_owner()` and `_mode_to_rwx()` |
|
||||||
|
| State completion | `cli.py:complete_state_names()` | argcomplete setup |
|
||||||
|
| Pruning | `_parse_prune_states_value()` | `db.prune_states_before()` and `db.prune_all_states()` |
|
||||||
|
| Packaging | `pyproject.toml` | Poetry docs |
|
||||||
|
| Automation | `.gitea/workflows/` | `.pre-commit-config.yaml` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Glossary
|
||||||
|
|
||||||
|
**State**
|
||||||
|
A named snapshot row in the `states` table.
|
||||||
|
|
||||||
|
**Entry**
|
||||||
|
A captured file, directory, or symlink metadata row in the `entries` table.
|
||||||
|
|
||||||
|
**Snapshot root**
|
||||||
|
The absolute root path saved in `states.root_path`. Entry paths are relative to this root.
|
||||||
|
|
||||||
|
**Root entry**
|
||||||
|
The metadata entry for the snapshot root itself. It is stored with `entries.path = ""`.
|
||||||
|
|
||||||
|
**Restore plan**
|
||||||
|
The list of `PlannedChange` objects produced before any mutation occurs.
|
||||||
|
|
||||||
|
**Wrapper mode**
|
||||||
|
The `chguard -- chmod|chown|chgrp ...` mode that saves an automatic pre-command snapshot before running the command.
|
||||||
|
|
||||||
|
**Auto snapshot**
|
||||||
|
A wrapper-created state named like `auto-YYYYMMDD-HHMMSS`.
|
||||||
|
|
||||||
|
**Owner restore**
|
||||||
|
Restoring numeric uid/gid with `os.chown(..., follow_symlinks=False)`.
|
||||||
|
|
||||||
|
**Permission restore**
|
||||||
|
Restoring permission bits with `os.chmod(..., follow_symlinks=False)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Final maintenance model
|
||||||
|
|
||||||
|
Most changes should preserve this model:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Scan metadata without following symlinks
|
||||||
|
-> store target-neutral numeric ownership and mode in SQLite
|
||||||
|
-> preview differences before applying
|
||||||
|
-> apply only selected chmod/chown operations
|
||||||
|
-> never create, delete, move, or rewrite files
|
||||||
|
```
|
||||||
|
|
||||||
|
Before changing code, ask:
|
||||||
|
|
||||||
|
1. Is this a CLI concern, scan concern, persistence concern, or restore concern?
|
||||||
|
2. Does the SQLite schema need to change, and what happens to existing databases?
|
||||||
|
3. Does this preserve the no-content/no-create/no-delete scope?
|
||||||
|
4. Does wrapper mode still snapshot every path it claims to protect?
|
||||||
|
5. Does the restore preview still happen before mutation?
|
||||||
|
6. Does the change behave safely without root?
|
||||||
|
7. Are symlink and special-file behaviours explicit?
|
||||||
|
8. Do README examples and shell completion still match the command surface?
|
||||||
|
9. Are there focused tests for the edge case being changed?
|
||||||
|
|
||||||
|
Keeping those boundaries clear is the main way to maintain chguard without turning a narrow metadata guardrail into a misleading general-purpose undo tool.
|
||||||
@@ -19,10 +19,13 @@ applies changes after explicit confirmation.
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Snapshots ownership and permissions
|
### Snapshots ownership and permissions
|
||||||
Records numeric `uid`, `gid`, and file mode for files and directories.
|
Records numeric `uid`, `gid`, and file mode for files, directories, and
|
||||||
|
symbolic links.
|
||||||
|
|
||||||
### Preview before restore
|
### Preview before restore
|
||||||
Always shows a clear, readable table of differences before applying changes.
|
Always shows a clear, readable table of differences before applying changes.
|
||||||
|
Missing paths and type mismatches are shown as skipped items because restore does
|
||||||
|
not create, delete, or replace files.
|
||||||
|
|
||||||
### Interactive confirmation
|
### Interactive confirmation
|
||||||
A single confirmation prompt at the end of a restore (default: **No**).
|
A single confirmation prompt at the end of a restore (default: **No**).
|
||||||
@@ -65,7 +68,7 @@ Restore:
|
|||||||
* Never creates, deletes, or moves files
|
* Never creates, deletes, or moves files
|
||||||
* Missing files are ignored
|
* Missing files are ignored
|
||||||
* New files are ignored
|
* New files are ignored
|
||||||
* Symbolic links are skipped entirely
|
* Symbolic links are handled without following their targets
|
||||||
* Requires sudo **only when necessary**
|
* Requires sudo **only when necessary**
|
||||||
|
|
||||||
## Non-Goals
|
## Non-Goals
|
||||||
@@ -178,7 +181,8 @@ app-baseline /srv/app 2025-12-20 18:11:08 +00:00
|
|||||||
chguard --restore app-baseline
|
chguard --restore app-baseline
|
||||||
```
|
```
|
||||||
|
|
||||||
This shows a table of ownership and permission differences.
|
This shows a table of ownership and permission differences. Missing paths and
|
||||||
|
type mismatches are reported as skipped items.
|
||||||
|
|
||||||
### Restore with confirmation
|
### Restore with confirmation
|
||||||
```
|
```
|
||||||
@@ -203,6 +207,26 @@ chguard --restore app-baseline --permissions
|
|||||||
chguard --restore app-baseline --owner
|
chguard --restore app-baseline --owner
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Remove old states
|
||||||
|
```
|
||||||
|
chguard --prune-states
|
||||||
|
```
|
||||||
|
This removes states older than the number of days set in `CHGUARD_STATES_LIFE`
|
||||||
|
|
||||||
|
### Remove states older than _N_ days
|
||||||
|
```
|
||||||
|
chguard --prune-states=14
|
||||||
|
```
|
||||||
|
This removes all states older than 14 days.
|
||||||
|
|
||||||
|
### Remove all states
|
||||||
|
```
|
||||||
|
chguard --prune-states=all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variable
|
||||||
|
`CHGUARD_STATES_LIFE`controls the default number of days to keep when using `chguard --prune-states`.
|
||||||
|
|
||||||
### Wrapper mode
|
### Wrapper mode
|
||||||
|
|
||||||
Use `--` to separate `chguard` arguments from the wrapped command:
|
Use `--` to separate `chguard` arguments from the wrapped command:
|
||||||
@@ -226,7 +250,7 @@ chguard -- chgrp staff file
|
|||||||
Snapshots are stored in a local SQLite database containing:
|
Snapshots are stored in a local SQLite database containing:
|
||||||
|
|
||||||
* relative path
|
* relative path
|
||||||
* file type (file or directory)
|
* file type (file, directory, or symbolic link)
|
||||||
* numeric uid / gid
|
* numeric uid / gid
|
||||||
* numeric mode
|
* numeric mode
|
||||||
|
|
||||||
@@ -251,3 +275,11 @@ poetry install
|
|||||||
poetry run pre-commit install
|
poetry run pre-commit install
|
||||||
```
|
```
|
||||||
This ensures consistent formatting, catches common issues early, and keeps the codebase clean.
|
This ensures consistent formatting, catches common issues early, and keeps the codebase clean.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Run the pytest suite with:
|
||||||
|
|
||||||
|
```
|
||||||
|
poetry run pytest
|
||||||
|
```
|
||||||
|
|||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
# chguard Threat Model and Security Scope
|
||||||
|
|
||||||
|
chguard is a command-line systems administration tool. It is designed to be
|
||||||
|
executed intentionally by an operator, sometimes with elevated privileges, to
|
||||||
|
snapshot and restore filesystem ownership and permission metadata.
|
||||||
|
|
||||||
|
Because of that design, chguard's security model is different from that of a
|
||||||
|
network service, web application, daemon, sandbox, or setuid program. chguard
|
||||||
|
does not attempt to defend against arbitrary local compromise of the account
|
||||||
|
executing it. If an attacker can control the command line, environment, working
|
||||||
|
directory, `PATH`, selected database, installed Python package, or wrapped
|
||||||
|
system command used by the operator, they may be able to influence what chguard
|
||||||
|
does. That situation is considered a local trust-boundary failure outside
|
||||||
|
chguard's intended security model.
|
||||||
|
|
||||||
|
chguard only manages filesystem metadata. It does not read, store, compare,
|
||||||
|
restore, or protect file contents.
|
||||||
|
|
||||||
|
## Core Assumptions
|
||||||
|
|
||||||
|
chguard assumes that the person running the tool understands what they are
|
||||||
|
asking it to do.
|
||||||
|
|
||||||
|
In particular:
|
||||||
|
|
||||||
|
* If chguard is run as root, the root user is assumed to control and understand
|
||||||
|
the command line, environment, database path, restore root, and wrapped
|
||||||
|
command being used.
|
||||||
|
* If `--db` is used, the selected SQLite database path and its contents are
|
||||||
|
assumed to be trusted local administrative state chosen by the operator.
|
||||||
|
* If `--root` is used during restore, the alternate restore root is assumed to
|
||||||
|
be intentionally selected by the operator.
|
||||||
|
* If `--yes` is used, the operator is intentionally bypassing the interactive
|
||||||
|
confirmation prompt.
|
||||||
|
* Wrapper mode commands are assumed to be the trusted `chown`, `chmod`, or
|
||||||
|
`chgrp` implementation that the operator intended to execute.
|
||||||
|
* The operator is expected to understand the impact of restoring ownership and
|
||||||
|
permission bits, especially when restoring as root.
|
||||||
|
|
||||||
|
## What chguard Records
|
||||||
|
|
||||||
|
chguard snapshots a narrow set of filesystem metadata:
|
||||||
|
|
||||||
|
* Relative path under the snapshot root.
|
||||||
|
* Entry type: regular file, directory, or symbolic link.
|
||||||
|
* Permission bits.
|
||||||
|
* Numeric `uid`.
|
||||||
|
* Numeric `gid`.
|
||||||
|
|
||||||
|
chguard deliberately does not snapshot:
|
||||||
|
|
||||||
|
* File contents.
|
||||||
|
* File hashes.
|
||||||
|
* ACLs.
|
||||||
|
* Extended attributes.
|
||||||
|
* Capabilities.
|
||||||
|
* SELinux, AppArmor, or other MAC labels.
|
||||||
|
* Deleted files.
|
||||||
|
* Newly created files.
|
||||||
|
* Device nodes, sockets, FIFOs, or other special files.
|
||||||
|
|
||||||
|
User and group names are display-only. Numeric `uid` and `gid` values are the
|
||||||
|
source of truth.
|
||||||
|
|
||||||
|
## What Is In Scope
|
||||||
|
|
||||||
|
chguard tries to protect careful administrators from common and serious mistakes
|
||||||
|
that can occur when a privileged CLI tool records and restores filesystem
|
||||||
|
metadata.
|
||||||
|
|
||||||
|
In-scope security concerns include:
|
||||||
|
|
||||||
|
* Restore must not create, delete, move, rename, or rewrite files.
|
||||||
|
* Restore must not change file contents.
|
||||||
|
* Restore must preview applicable owner and mode changes before applying them.
|
||||||
|
* Mutating restore must require confirmation unless `--yes` is provided.
|
||||||
|
* Mutating prune operations must require confirmation unless `--yes` is
|
||||||
|
provided.
|
||||||
|
* Preview and dry-run restore should remain usable without root.
|
||||||
|
* chguard must not automatically run sudo or otherwise escalate privileges.
|
||||||
|
* Scanning and restoring should use no-follow filesystem operations and avoid
|
||||||
|
following symlink targets.
|
||||||
|
* Restore should re-check current filesystem state before applying chmod or
|
||||||
|
chown operations.
|
||||||
|
* Restore should skip missing paths, unsupported file types, and type mismatches
|
||||||
|
rather than replacing them.
|
||||||
|
* Wrapper mode should stay limited to ownership and permission commands:
|
||||||
|
`chown`, `chmod`, and `chgrp`.
|
||||||
|
* Wrapper mode should not use a shell to execute the wrapped command.
|
||||||
|
* SQLite access should use parameterized queries for operator-provided names and
|
||||||
|
paths.
|
||||||
|
* A state created by chguard through normal scanning should not contain relative
|
||||||
|
paths that escape the snapshot root.
|
||||||
|
|
||||||
|
These measures are defense-in-depth. They are intended to reduce the chance of
|
||||||
|
accidental metadata changes, symlink traversal, unintended privilege changes, or
|
||||||
|
unsafe restore behavior when chguard is used normally by an administrator.
|
||||||
|
|
||||||
|
## What Is Out Of Scope
|
||||||
|
|
||||||
|
The following are generally out of scope and should not be reported as chguard
|
||||||
|
vulnerabilities unless they also bypass one of chguard's explicit hardening
|
||||||
|
mechanisms:
|
||||||
|
|
||||||
|
* A malicious local user who can already control the root user's command line,
|
||||||
|
shell environment, working directory, `PATH`, Python environment, installed
|
||||||
|
package, or invoked binaries.
|
||||||
|
* A root user intentionally selecting a malicious or manually edited SQLite
|
||||||
|
database with `--db`.
|
||||||
|
* A root user intentionally restoring a snapshot that sets unsafe ownership or
|
||||||
|
permissions.
|
||||||
|
* A root user intentionally using `--root` to apply a trusted snapshot under a
|
||||||
|
different filesystem tree.
|
||||||
|
* A root user intentionally passing `--yes` and bypassing confirmation.
|
||||||
|
* A user intentionally wrapping a malicious binary whose basename is `chown`,
|
||||||
|
`chmod`, or `chgrp`.
|
||||||
|
* A user relying on chguard to restore file contents, deleted files, ACLs,
|
||||||
|
extended attributes, capabilities, MAC labels, or full undo semantics.
|
||||||
|
* A user relying on chguard as a sandbox for untrusted local users or untrusted
|
||||||
|
command execution.
|
||||||
|
* A compromised system where an attacker already controls root-owned files,
|
||||||
|
root's shell, root's Python packages, root's environment, or the privileged
|
||||||
|
tools chguard invokes.
|
||||||
|
* Reports that amount to "if root runs this tool with malicious options, root
|
||||||
|
can make the system do dangerous things."
|
||||||
|
|
||||||
|
chguard is a tool for 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 Snapshot Databases
|
||||||
|
|
||||||
|
chguard snapshots are stored in a local SQLite database. By default, chguard uses
|
||||||
|
the platform-specific user data directory for the invoking account. Operators
|
||||||
|
may override this with `--db`.
|
||||||
|
|
||||||
|
Snapshot databases should be treated as trusted administrative state. They can
|
||||||
|
contain filesystem paths, ownership, group, permission, timestamp, and snapshot
|
||||||
|
root information. They do not contain file contents, but the metadata can still
|
||||||
|
reveal operational details about a system.
|
||||||
|
|
||||||
|
Before running restore, especially as root or with `--yes`, the operator should
|
||||||
|
be confident that the selected database is the intended one and has not been
|
||||||
|
tampered with.
|
||||||
|
|
||||||
|
chguard-created snapshots are expected to contain paths relative to the snapshot
|
||||||
|
root. chguard does not treat an arbitrary attacker-supplied SQLite database as
|
||||||
|
untrusted input to be safely enforced.
|
||||||
|
|
||||||
|
## Wrapper Mode
|
||||||
|
|
||||||
|
Wrapper mode exists to take an automatic pre-command snapshot before running a
|
||||||
|
metadata-changing command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chguard -- chmod 755 path
|
||||||
|
chguard -- chown user:group path
|
||||||
|
chguard -- chgrp group path
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrapper mode is intentionally limited to `chmod`, `chown`, and `chgrp` by command
|
||||||
|
basename. chguard snapshots existing non-option path arguments that it can
|
||||||
|
identify, then runs the wrapped command and returns that command's exit code.
|
||||||
|
|
||||||
|
Wrapper mode is not a full parser for every possible option accepted by those
|
||||||
|
commands. It is a guardrail for common ownership and permission changes, not a
|
||||||
|
general command supervision framework.
|
||||||
|
|
||||||
|
## Symlinks And Filesystem Races
|
||||||
|
|
||||||
|
chguard uses `lstat()` and no-follow operations while scanning and restoring. It
|
||||||
|
records symbolic link entries and attempts no-follow ownership or permission
|
||||||
|
restoration where the platform supports it. It should not follow a symbolic link
|
||||||
|
target and apply changes to the target as part of scanning or restore.
|
||||||
|
|
||||||
|
Restore is best-effort across platforms. Some operations, such as changing
|
||||||
|
symlink permissions, are not supported everywhere and may be skipped.
|
||||||
|
|
||||||
|
Because chguard operates on a live filesystem, concurrent filesystem changes can
|
||||||
|
still affect what exists at the moment restore runs. chguard mitigates this by
|
||||||
|
re-checking paths before applying changes and by skipping missing paths, special
|
||||||
|
files, and type mismatches. It does not claim to provide a transactional
|
||||||
|
filesystem restore.
|
||||||
|
|
||||||
|
## Local Compromise
|
||||||
|
|
||||||
|
chguard includes hardening against some local filesystem attack patterns because
|
||||||
|
it is often run with high privileges. For example, it avoids symlink traversal,
|
||||||
|
does not use a shell for wrapper mode, previews restore changes, and does not
|
||||||
|
automatically escalate privileges.
|
||||||
|
|
||||||
|
However, local compromise cannot be ruled out completely for a privileged CLI
|
||||||
|
tool. If an attacker can influence the administrator's shell, environment,
|
||||||
|
database, binaries, Python packages, current working directory, or command-line
|
||||||
|
arguments, they may be able to influence chguard's behavior.
|
||||||
|
|
||||||
|
Such scenarios are treated as local compromise or operator trust failures, not
|
||||||
|
as vulnerabilities in chguard by themselves.
|
||||||
|
|
||||||
|
## Security Report Guidance
|
||||||
|
|
||||||
|
Useful vulnerability reports include issues where chguard behaves unsafely
|
||||||
|
despite the documented trust model. Examples include:
|
||||||
|
|
||||||
|
* chguard follows a symlink target during save or restore in a way that causes
|
||||||
|
unintended privileged chmod or chown operations.
|
||||||
|
* Restore creates, deletes, moves, renames, or rewrites files.
|
||||||
|
* Restore applies owner or mode changes without previewing them first.
|
||||||
|
* Restore applies changes without confirmation when `--yes` was not provided.
|
||||||
|
* Dry-run or preview applies filesystem changes.
|
||||||
|
* chguard automatically escalates privileges or invokes sudo.
|
||||||
|
* A snapshot produced by normal chguard scanning can contain paths that escape
|
||||||
|
the snapshot root during restore.
|
||||||
|
* Wrapper mode accepts and executes unsupported command classes outside `chown`,
|
||||||
|
`chmod`, or `chgrp`.
|
||||||
|
* Wrapper mode introduces shell injection when running ordinary operator-provided
|
||||||
|
path names or arguments.
|
||||||
|
* SQLite operations allow operator-provided state names or paths to alter
|
||||||
|
unintended database rows through injection.
|
||||||
|
* A failed safety check is silently ignored and chguard proceeds with a dangerous
|
||||||
|
operation anyway.
|
||||||
|
|
||||||
|
Less useful reports, and normally out of scope, include:
|
||||||
|
|
||||||
|
* "Root can restore dangerous permissions."
|
||||||
|
* "Root can pass `--yes` and bypass the confirmation prompt."
|
||||||
|
* "Root can point `--db` at a malicious SQLite database."
|
||||||
|
* "Root can use `--root` to restore metadata into a different tree."
|
||||||
|
* "A malicious local user can compromise chguard after already controlling
|
||||||
|
root's environment, Python packages, or binaries."
|
||||||
|
* "chguard does not restore file contents, ACLs, xattrs, capabilities, or full
|
||||||
|
deleted-file state."
|
||||||
|
|
||||||
|
Reports about concrete bypasses of chguard's hardening are welcome. The project
|
||||||
|
does not treat intentional administrator-controlled execution as a vulnerability
|
||||||
|
by itself.
|
||||||
+407
-190
@@ -2,33 +2,37 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import argcomplete
|
import argcomplete
|
||||||
|
import grp
|
||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import stat
|
|
||||||
import pwd
|
import pwd
|
||||||
import grp
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
from rich import box
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
from rich import box
|
|
||||||
|
|
||||||
from chguard.db import (
|
from chguard.db import (
|
||||||
connect,
|
connect,
|
||||||
init_db,
|
|
||||||
create_state,
|
create_state,
|
||||||
delete_state,
|
delete_state,
|
||||||
get_state,
|
get_state,
|
||||||
|
init_db,
|
||||||
|
prune_all_states,
|
||||||
|
prune_states_before,
|
||||||
state_exists,
|
state_exists,
|
||||||
)
|
)
|
||||||
|
from chguard.restore import PlannedChange, apply_restore, plan_restore
|
||||||
from chguard.scan import scan_tree
|
from chguard.scan import scan_tree
|
||||||
from chguard.restore import plan_restore, apply_restore
|
|
||||||
from chguard.util import normalize_root
|
from chguard.util import normalize_root
|
||||||
|
|
||||||
|
PRUNE_STATES_FROM_ENV = "__CHGUARD_PRUNE_STATES_FROM_ENV__"
|
||||||
|
|
||||||
|
|
||||||
def get_version():
|
def get_version():
|
||||||
try:
|
try:
|
||||||
@@ -88,6 +92,49 @@ def _is_root() -> bool:
|
|||||||
return os.geteuid() == 0
|
return os.geteuid() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_prune_states_value(value: str | None) -> int | str:
|
||||||
|
if value is None:
|
||||||
|
value = os.environ.get("CHGUARD_STATES_LIFE")
|
||||||
|
|
||||||
|
if not value:
|
||||||
|
raise SystemExit(
|
||||||
|
"Missing prune age. Use --prune-states=N or set "
|
||||||
|
"CHGUARD_STATES_LIFE."
|
||||||
|
)
|
||||||
|
|
||||||
|
value = value.strip().lower()
|
||||||
|
|
||||||
|
if value == "all":
|
||||||
|
return "all"
|
||||||
|
|
||||||
|
try:
|
||||||
|
days = int(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SystemExit(
|
||||||
|
"Invalid prune age. Use an integer number of days or 'all'."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if days < 0:
|
||||||
|
raise SystemExit("Invalid prune age. Value must be >= 0.")
|
||||||
|
|
||||||
|
return days
|
||||||
|
|
||||||
|
|
||||||
|
def _confirm_or_abort(*, yes: bool, prompt: str) -> None:
|
||||||
|
if yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
raise SystemExit(
|
||||||
|
"Refusing to continue without confirmation (no TTY).\n"
|
||||||
|
"Use --yes to force."
|
||||||
|
)
|
||||||
|
|
||||||
|
answer = input(f"\n{prompt} (y/N) ").strip().lower()
|
||||||
|
if answer not in ("y", "yes"):
|
||||||
|
raise SystemExit("Aborted.")
|
||||||
|
|
||||||
|
|
||||||
def complete_state_names(prefix, parsed_args, **kwargs):
|
def complete_state_names(prefix, parsed_args, **kwargs):
|
||||||
try:
|
try:
|
||||||
conn = connect(
|
conn = connect(
|
||||||
@@ -112,28 +159,192 @@ def _extract_paths_from_command(cmd: list[str]) -> list[Path]:
|
|||||||
return paths
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def _common_snapshot_root(paths: list[Path]) -> Path:
|
||||||
|
if len(paths) == 1:
|
||||||
|
return paths[0].resolve()
|
||||||
|
|
||||||
|
return Path(os.path.commonpath([str(p.resolve()) for p in paths]))
|
||||||
|
|
||||||
|
|
||||||
|
def _type_for_mode(mode: int) -> str | None:
|
||||||
|
if stat.S_ISDIR(mode):
|
||||||
|
return "dir"
|
||||||
|
if stat.S_ISREG(mode):
|
||||||
|
return "file"
|
||||||
|
if stat.S_ISLNK(mode):
|
||||||
|
return "symlink"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_entries_for_target(path: Path, snapshot_root: Path):
|
||||||
|
"""Yield Entry-like tuples for one wrapper-mode target.
|
||||||
|
|
||||||
|
The paths yielded here are relative to the single snapshot root for the
|
||||||
|
whole wrapped command, not relative to each individual command argument.
|
||||||
|
This lets one auto-snapshot cover commands such as:
|
||||||
|
|
||||||
|
chmod 700 foo1 foo2
|
||||||
|
|
||||||
|
without inserting multiple entries with the empty relative path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def entry_for(p: Path):
|
||||||
|
try:
|
||||||
|
st = p.lstat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
typ = _type_for_mode(st.st_mode)
|
||||||
|
if typ is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
rel = "" if p == snapshot_root else str(p.relative_to(snapshot_root))
|
||||||
|
return rel, typ, stat.S_IMODE(st.st_mode), st.st_uid, st.st_gid
|
||||||
|
|
||||||
|
first = entry_for(path)
|
||||||
|
if first is not None:
|
||||||
|
yield first
|
||||||
|
|
||||||
|
if not path.is_dir():
|
||||||
|
return
|
||||||
|
|
||||||
|
for dirpath, dirnames, filenames in os.walk(path, followlinks=False):
|
||||||
|
for name in list(dirnames) + list(filenames):
|
||||||
|
entry = entry_for(Path(dirpath) / name)
|
||||||
|
if entry is not None:
|
||||||
|
yield entry
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_is_under(rel: str, root: str) -> bool:
|
||||||
|
return rel == root or rel.startswith(root.rstrip("/") + "/")
|
||||||
|
|
||||||
|
|
||||||
|
def _root_entry_summary(entry_type: str) -> str:
|
||||||
|
if entry_type == "dir":
|
||||||
|
return "directory tree"
|
||||||
|
if entry_type == "file":
|
||||||
|
return "file"
|
||||||
|
if entry_type == "symlink":
|
||||||
|
return "symlink"
|
||||||
|
return entry_type
|
||||||
|
|
||||||
|
|
||||||
|
def _captured_paths_summary(
|
||||||
|
conn, state_id: int, root_path: str, limit: int = 5
|
||||||
|
) -> str:
|
||||||
|
root_entry = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT type
|
||||||
|
FROM entries
|
||||||
|
WHERE state_id = ? AND (path = '' OR path = ?)
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(state_id, root_path),
|
||||||
|
).fetchone()
|
||||||
|
if root_entry is not None:
|
||||||
|
return _root_entry_summary(root_entry[0])
|
||||||
|
|
||||||
|
roots: list[str] = []
|
||||||
|
truncated = False
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT path FROM entries WHERE state_id = ? ORDER BY path",
|
||||||
|
(state_id,),
|
||||||
|
)
|
||||||
|
for (rel,) in rows:
|
||||||
|
if any(_entry_is_under(rel, root) for root in roots):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(roots) >= limit:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
|
||||||
|
roots.append(rel)
|
||||||
|
|
||||||
|
if not roots:
|
||||||
|
return "—"
|
||||||
|
|
||||||
|
summary = ", ".join(roots)
|
||||||
|
if truncated:
|
||||||
|
summary += ", …"
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def _display_restore_path(path: Path, target_root: Path) -> Path:
|
||||||
|
try:
|
||||||
|
return path.relative_to(target_root)
|
||||||
|
except ValueError:
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _format_skipped_restore_change(change: PlannedChange) -> str:
|
||||||
|
if change.kind == "missing":
|
||||||
|
return "missing path"
|
||||||
|
|
||||||
|
if change.kind == "type":
|
||||||
|
got, expected = change.detail.split(" -> ", 1)
|
||||||
|
return f"found {got}, expected {expected}"
|
||||||
|
|
||||||
|
return change.detail
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_preview_rows(
|
||||||
|
changes: list[PlannedChange], target_root: Path, current_uid: int
|
||||||
|
) -> tuple[dict[Path, dict[str, str]], Counter, bool]:
|
||||||
|
per_path: dict[Path, dict[str, str]] = defaultdict(dict)
|
||||||
|
counts = Counter()
|
||||||
|
needs_root = False
|
||||||
|
|
||||||
|
for ch in changes:
|
||||||
|
rel = _display_restore_path(ch.path, target_root)
|
||||||
|
|
||||||
|
if ch.kind == "owner" and ch.will_apply:
|
||||||
|
before, after = ch.detail.split(" -> ")
|
||||||
|
bu, bg = map(int, before.split(":"))
|
||||||
|
au, ag = map(int, after.split(":"))
|
||||||
|
|
||||||
|
owner_change = f"{_format_owner(bu, bg)} → {_format_owner(au, ag)}"
|
||||||
|
per_path[rel]["owner"] = owner_change
|
||||||
|
counts["owner"] += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
if ch.path.lstat().st_uid != current_uid:
|
||||||
|
needs_root = True
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif ch.kind == "mode" and ch.will_apply:
|
||||||
|
before, after = ch.detail.split(" -> ")
|
||||||
|
per_path[rel]["mode"] = (
|
||||||
|
f"{_mode_to_rwx(int(before, 8))} → "
|
||||||
|
f"{_mode_to_rwx(int(after, 8))}"
|
||||||
|
)
|
||||||
|
counts["mode"] += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
if ch.path.lstat().st_uid != current_uid:
|
||||||
|
needs_root = True
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif ch.kind in ("missing", "type"):
|
||||||
|
skipped = _format_skipped_restore_change(ch)
|
||||||
|
existing = per_path[rel].get("skipped")
|
||||||
|
per_path[rel]["skipped"] = (
|
||||||
|
f"{existing}; {skipped}" if existing else skipped
|
||||||
|
)
|
||||||
|
counts["skipped"] += 1
|
||||||
|
|
||||||
|
return per_path, counts, needs_root
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""
|
|
||||||
Entry point for the CLI.
|
|
||||||
|
|
||||||
Behavior summary:
|
|
||||||
- --save snapshots ownership and permissions
|
|
||||||
- --restore previews changes, then asks for confirmation
|
|
||||||
- Root privileges are required only when necessary
|
|
||||||
- Symlinks are skipped during scanning
|
|
||||||
"""
|
|
||||||
|
|
||||||
wrapper_cmd = None
|
wrapper_cmd = None
|
||||||
if "--" in sys.argv:
|
if "--" in sys.argv:
|
||||||
idx = sys.argv.index("--")
|
idx = sys.argv.index("--")
|
||||||
wrapper_cmd = sys.argv[idx + 1 :]
|
wrapper_cmd = sys.argv[idx + 1 :]
|
||||||
sys.argv = sys.argv[:idx]
|
sys.argv = sys.argv[:idx]
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
prog="chguard",
|
|
||||||
description="Snapshot and restore filesystem ownership and permissions.",
|
|
||||||
)
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="chguard",
|
prog="chguard",
|
||||||
description="Snapshot and restore filesystem ownership and permissions.",
|
description="Snapshot and restore filesystem ownership and permissions.",
|
||||||
@@ -158,75 +369,53 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
actions.add_argument(
|
actions.add_argument(
|
||||||
"--save",
|
"--save", metavar="PATH", help="Save state for PATH"
|
||||||
metavar="PATH",
|
|
||||||
help="Save state for PATH",
|
|
||||||
).completer = argcomplete.FilesCompleter()
|
).completer = argcomplete.FilesCompleter()
|
||||||
|
|
||||||
actions.add_argument(
|
actions.add_argument(
|
||||||
"--restore",
|
"--restore", action="store_true", help="Restore a saved state"
|
||||||
action="store_true",
|
)
|
||||||
help="Restore a saved state",
|
actions.add_argument(
|
||||||
|
"--list", action="store_true", help="List saved states"
|
||||||
)
|
)
|
||||||
|
|
||||||
actions.add_argument(
|
actions.add_argument(
|
||||||
"--list",
|
"--delete", metavar="STATE", help="Delete a saved state"
|
||||||
action="store_true",
|
|
||||||
help="List saved states",
|
|
||||||
)
|
|
||||||
|
|
||||||
actions.add_argument(
|
|
||||||
"--delete",
|
|
||||||
metavar="STATE",
|
|
||||||
help="Delete a saved state",
|
|
||||||
).completer = complete_state_names
|
).completer = complete_state_names
|
||||||
|
|
||||||
# positional STATE
|
actions.add_argument(
|
||||||
parser.add_argument(
|
"--prune-states",
|
||||||
"state",
|
|
||||||
nargs="?",
|
nargs="?",
|
||||||
help="State name (required with --restore)",
|
const=PRUNE_STATES_FROM_ENV,
|
||||||
).completer = complete_state_names
|
metavar="N",
|
||||||
|
help=(
|
||||||
|
"Delete states older than N days. If N is omitted, "
|
||||||
|
"CHGUARD_STATES_LIFE is used. Use 'all' to delete all states."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument("state", nargs="?", help="State name").completer = (
|
||||||
|
complete_state_names
|
||||||
|
)
|
||||||
|
parser.add_argument("--name", help="State name")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--name",
|
"--overwrite", action="store_true", help="Overwrite existing state"
|
||||||
help="State name (required with --save)",
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--permissions", action="store_true", help="Restore MODE only"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--owner", action="store_true", help="Restore OWNER only"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true", help="Preview only; do not apply"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--yes", action="store_true", help="Apply without confirmation"
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--overwrite",
|
"--root", metavar="PATH", help="Override restore root"
|
||||||
action="store_true",
|
|
||||||
help="Overwrite existing state",
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--permissions",
|
|
||||||
action="store_true",
|
|
||||||
help="Restore MODE only",
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--owner",
|
|
||||||
action="store_true",
|
|
||||||
help="Restore OWNER only",
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--dry-run",
|
|
||||||
action="store_true",
|
|
||||||
help="Preview only; do not apply",
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--yes",
|
|
||||||
action="store_true",
|
|
||||||
help="Apply without confirmation",
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--root",
|
|
||||||
metavar="PATH",
|
|
||||||
help="Override restore root",
|
|
||||||
).completer = argcomplete.FilesCompleter()
|
).completer = argcomplete.FilesCompleter()
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -237,9 +426,7 @@ def main() -> None:
|
|||||||
).completer = argcomplete.FilesCompleter()
|
).completer = argcomplete.FilesCompleter()
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--db",
|
"--db", metavar="PATH", help="Override database path"
|
||||||
metavar="PATH",
|
|
||||||
help="Override database path",
|
|
||||||
).completer = argcomplete.FilesCompleter()
|
).completer = argcomplete.FilesCompleter()
|
||||||
|
|
||||||
argcomplete.autocomplete(parser)
|
argcomplete.autocomplete(parser)
|
||||||
@@ -250,7 +437,6 @@ def main() -> None:
|
|||||||
raise SystemExit("No command provided after '--'")
|
raise SystemExit("No command provided after '--'")
|
||||||
|
|
||||||
cmd = Path(wrapper_cmd[0]).name
|
cmd = Path(wrapper_cmd[0]).name
|
||||||
|
|
||||||
if cmd not in ("chown", "chmod", "chgrp"):
|
if cmd not in ("chown", "chmod", "chgrp"):
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"Wrapper mode only supports chown, chmod, and chgrp"
|
"Wrapper mode only supports chown, chmod, and chgrp"
|
||||||
@@ -263,56 +449,41 @@ def main() -> None:
|
|||||||
|
|
||||||
if wrapper_cmd:
|
if wrapper_cmd:
|
||||||
paths = _extract_paths_from_command(wrapper_cmd)
|
paths = _extract_paths_from_command(wrapper_cmd)
|
||||||
|
|
||||||
if paths:
|
if paths:
|
||||||
auto_name = f"auto-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
auto_name = f"auto-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
||||||
|
root_path = _common_snapshot_root(paths)
|
||||||
|
|
||||||
with conn:
|
with conn:
|
||||||
root_path = str(Path(paths[0]).resolve())
|
|
||||||
state_id = create_state(
|
state_id = create_state(
|
||||||
conn, auto_name, root_path, os.getuid(), commit=False
|
conn, auto_name, str(root_path), os.getuid(), commit=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
seen_paths: set[str] = set()
|
||||||
for path in paths:
|
for path in paths:
|
||||||
if path.is_dir():
|
for rel, typ, mode, uid, gid in _iter_entries_for_target(
|
||||||
for entry in scan_tree(path):
|
path, root_path
|
||||||
if entry.uid == 0 and not _is_root():
|
):
|
||||||
|
# A command may name the same path more than once, or
|
||||||
|
# name overlapping trees such as "foo" and "foo/bar".
|
||||||
|
# Store the pre-command state once per path.
|
||||||
|
if rel in seen_paths:
|
||||||
|
continue
|
||||||
|
seen_paths.add(rel)
|
||||||
|
|
||||||
|
if uid == 0 and not _is_root():
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"This command affects root-owned files.\n"
|
"This command affects root-owned files.\n"
|
||||||
"Please re-run with sudo."
|
"Please re-run with sudo."
|
||||||
)
|
)
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO entries (state_id, path, type, mode, uid, gid)
|
INSERT INTO entries
|
||||||
|
(state_id, path, type, mode, uid, gid)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(state_id, rel, typ, mode, uid, gid),
|
||||||
state_id,
|
|
||||||
entry.path,
|
|
||||||
entry.type,
|
|
||||||
entry.mode,
|
|
||||||
entry.uid,
|
|
||||||
entry.gid,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
st = path.lstat()
|
|
||||||
if st.st_uid == 0 and not _is_root():
|
|
||||||
raise SystemExit(
|
|
||||||
"This command affects root-owned files.\n"
|
|
||||||
"Please re-run with sudo."
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO entries (state_id, path, type, mode, uid, gid)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
state_id,
|
|
||||||
str(path),
|
|
||||||
"file",
|
|
||||||
stat.S_IMODE(st.st_mode),
|
|
||||||
st.st_uid,
|
|
||||||
st.st_gid,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
console.print(
|
console.print(
|
||||||
@@ -322,22 +493,108 @@ def main() -> None:
|
|||||||
proc = subprocess.run(wrapper_cmd)
|
proc = subprocess.run(wrapper_cmd)
|
||||||
sys.exit(proc.returncode)
|
sys.exit(proc.returncode)
|
||||||
|
|
||||||
if args.list:
|
if args.prune_states is not None:
|
||||||
|
value = (
|
||||||
|
None
|
||||||
|
if args.prune_states == PRUNE_STATES_FROM_ENV
|
||||||
|
else args.prune_states
|
||||||
|
)
|
||||||
|
life = _parse_prune_states_value(value)
|
||||||
|
|
||||||
|
if life == "all":
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT name, root_path, created_at
|
||||||
|
FROM states
|
||||||
|
ORDER BY created_at
|
||||||
|
""").fetchall()
|
||||||
|
cutoff_iso = None
|
||||||
|
else:
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=life)
|
||||||
|
cutoff_iso = cutoff.isoformat(timespec="seconds")
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT name, root_path, created_at FROM states ORDER BY created_at DESC"
|
"""
|
||||||
|
SELECT name, root_path, created_at
|
||||||
|
FROM states
|
||||||
|
WHERE created_at < ?
|
||||||
|
ORDER BY created_at
|
||||||
|
""",
|
||||||
|
(cutoff_iso,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
console.print("No states matched.")
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print(
|
||||||
|
f"\nThe following {len(rows)} state(s) will be deleted:\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
table = Table(box=box.SIMPLE, header_style="bold")
|
||||||
|
table.add_column("State")
|
||||||
|
table.add_column("Root path")
|
||||||
|
table.add_column("Created")
|
||||||
|
|
||||||
|
for name, root, created in rows:
|
||||||
|
state_name = (
|
||||||
|
f"[bright_cyan]{name}[/bright_cyan]"
|
||||||
|
if name.startswith("auto-")
|
||||||
|
else name
|
||||||
|
)
|
||||||
|
table.add_row(
|
||||||
|
state_name,
|
||||||
|
f"[bright_magenta]{root}[/bright_magenta]",
|
||||||
|
f"[bright_cyan]{created}[/bright_cyan]",
|
||||||
|
)
|
||||||
|
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
console.print(
|
||||||
|
"\n[yellow]Dry-run only. No states were deleted.[/yellow]"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
_confirm_or_abort(yes=args.yes, prompt="Delete these states?")
|
||||||
|
|
||||||
|
if life == "all":
|
||||||
|
deleted = prune_all_states(conn)
|
||||||
|
else:
|
||||||
|
deleted = prune_states_before(conn, cutoff_iso)
|
||||||
|
|
||||||
|
console.print(f"\nDeleted {deleted} state(s).")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT id, name, root_path, created_at
|
||||||
|
FROM states
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
console.print("No saved states.")
|
console.print("No saved states.")
|
||||||
return
|
return
|
||||||
|
|
||||||
for name, root, created in rows:
|
table = Table(box=box.SIMPLE, header_style="bold")
|
||||||
dt = datetime.fromisoformat(created)
|
table.add_column("State")
|
||||||
ts = dt.strftime("%Y-%m-%d %H:%M:%S %z")
|
table.add_column("Snapshot root")
|
||||||
if name.startswith("auto-"):
|
table.add_column("Captured paths")
|
||||||
console.print(f"[cyan]{name}[/cyan]\t{root}\t{ts}")
|
table.add_column("Created")
|
||||||
else:
|
|
||||||
console.print(f"{name}\t{root}\t{ts}")
|
for state_id, name, root, created in rows:
|
||||||
|
state_name = (
|
||||||
|
f"[bright_cyan]{name}[/bright_cyan]"
|
||||||
|
if name.startswith("auto-")
|
||||||
|
else name
|
||||||
|
)
|
||||||
|
table.add_row(
|
||||||
|
state_name,
|
||||||
|
f"[bright_magenta]{root}[/bright_magenta]",
|
||||||
|
_captured_paths_summary(conn, state_id, root),
|
||||||
|
f"[bright_cyan]{created}[/bright_cyan]",
|
||||||
|
)
|
||||||
|
|
||||||
|
console.print(table)
|
||||||
return
|
return
|
||||||
|
|
||||||
if args.delete:
|
if args.delete:
|
||||||
@@ -352,22 +609,18 @@ def main() -> None:
|
|||||||
|
|
||||||
root = normalize_root(args.save)
|
root = normalize_root(args.save)
|
||||||
|
|
||||||
try:
|
with conn:
|
||||||
with conn: # start transaction
|
|
||||||
if state_exists(conn, args.name):
|
if state_exists(conn, args.name):
|
||||||
if not args.overwrite:
|
if not args.overwrite:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"State '{args.name}' already exists (use --overwrite)"
|
f"State '{args.name}' already exists (use --overwrite)"
|
||||||
)
|
)
|
||||||
# if the new save fails, this delete_state step will also roll back
|
|
||||||
delete_state(conn, args.name, commit=False)
|
delete_state(conn, args.name, commit=False)
|
||||||
|
|
||||||
state_id = create_state(
|
state_id = create_state(
|
||||||
conn, args.name, str(root), os.getuid(), commit=False
|
conn, args.name, str(root), os.getuid(), commit=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# Abort early if root-owned files exist and user is not root.
|
|
||||||
# This prevents creating snapshots that cannot be meaningfully restored.
|
|
||||||
for entry in scan_tree(root, excludes=args.exclude):
|
for entry in scan_tree(root, excludes=args.exclude):
|
||||||
if entry.uid == 0 and not _is_root():
|
if entry.uid == 0 and not _is_root():
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
@@ -377,7 +630,8 @@ def main() -> None:
|
|||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO entries (state_id, path, type, mode, uid, gid)
|
INSERT INTO entries
|
||||||
|
(state_id, path, type, mode, uid, gid)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
@@ -393,9 +647,6 @@ def main() -> None:
|
|||||||
console.print(f"Saved state '{args.name}' for {root}")
|
console.print(f"Saved state '{args.name}' for {root}")
|
||||||
return
|
return
|
||||||
|
|
||||||
except SystemExit:
|
|
||||||
raise
|
|
||||||
|
|
||||||
if args.restore:
|
if args.restore:
|
||||||
if not args.state:
|
if not args.state:
|
||||||
parser.error("STATE is required with --restore")
|
parser.error("STATE is required with --restore")
|
||||||
@@ -407,7 +658,6 @@ def main() -> None:
|
|||||||
snapshot_root = Path(state.root_path)
|
snapshot_root = Path(state.root_path)
|
||||||
target_root = normalize_root(args.root) if args.root else snapshot_root
|
target_root = normalize_root(args.root) if args.root else snapshot_root
|
||||||
|
|
||||||
# Default restore behavior is OWNER + MODE unless narrowed explicitly.
|
|
||||||
restore_permissions = args.permissions or (
|
restore_permissions = args.permissions or (
|
||||||
not args.permissions and not args.owner
|
not args.permissions and not args.owner
|
||||||
)
|
)
|
||||||
@@ -425,49 +675,18 @@ def main() -> None:
|
|||||||
restore_owner=restore_owner,
|
restore_owner=restore_owner,
|
||||||
)
|
)
|
||||||
|
|
||||||
per_path: dict[Path, dict[str, str]] = defaultdict(dict)
|
per_path, counts, needs_root = _restore_preview_rows(
|
||||||
counts = Counter()
|
changes, target_root, os.geteuid()
|
||||||
needs_root = False
|
)
|
||||||
current_uid = os.geteuid()
|
|
||||||
|
|
||||||
# Build a per-path view of owner/mode changes and detect privilege needs.
|
if not changes:
|
||||||
for ch in changes:
|
console.print("No differences found.")
|
||||||
if ch.kind not in ("owner", "mode"):
|
return
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
rel = ch.path.relative_to(target_root)
|
|
||||||
except ValueError:
|
|
||||||
rel = ch.path
|
|
||||||
|
|
||||||
if ch.kind == "owner" and restore_owner:
|
|
||||||
before, after = ch.detail.split(" -> ")
|
|
||||||
bu, bg = map(int, before.split(":"))
|
|
||||||
au, ag = map(int, after.split(":"))
|
|
||||||
|
|
||||||
per_path[rel][
|
|
||||||
"owner"
|
|
||||||
] = f"{_format_owner(bu, bg)} → {_format_owner(au, ag)}"
|
|
||||||
counts["owner"] += 1
|
|
||||||
|
|
||||||
if ch.path.stat().st_uid != current_uid:
|
|
||||||
needs_root = True
|
|
||||||
|
|
||||||
elif ch.kind == "mode" and restore_permissions:
|
|
||||||
b, a = ch.detail.split(" -> ")
|
|
||||||
per_path[rel][
|
|
||||||
"mode"
|
|
||||||
] = f"{_mode_to_rwx(int(b, 8))} → {_mode_to_rwx(int(a, 8))}"
|
|
||||||
counts["mode"] += 1
|
|
||||||
|
|
||||||
try:
|
|
||||||
if ch.path.stat().st_uid != current_uid:
|
|
||||||
needs_root = True
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not per_path:
|
if not per_path:
|
||||||
console.print("No differences found.")
|
console.print(
|
||||||
|
"No differences found for the selected restore scope."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
console.print(f"\nRestoring under: {target_root}\n")
|
console.print(f"\nRestoring under: {target_root}\n")
|
||||||
@@ -476,6 +695,7 @@ def main() -> None:
|
|||||||
table.add_column("Path")
|
table.add_column("Path")
|
||||||
table.add_column("Owner change", style="cyan")
|
table.add_column("Owner change", style="cyan")
|
||||||
table.add_column("Mode change", style="green")
|
table.add_column("Mode change", style="green")
|
||||||
|
table.add_column("Skipped", style="yellow")
|
||||||
|
|
||||||
for path in sorted(per_path):
|
for path in sorted(per_path):
|
||||||
row = per_path[path]
|
row = per_path[path]
|
||||||
@@ -483,14 +703,23 @@ def main() -> None:
|
|||||||
str(path),
|
str(path),
|
||||||
row.get("owner", "—"),
|
row.get("owner", "—"),
|
||||||
row.get("mode", "—"),
|
row.get("mode", "—"),
|
||||||
|
row.get("skipped", "—"),
|
||||||
)
|
)
|
||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
console.print(
|
console.print(
|
||||||
f"\nSummary: {counts['mode']} mode change(s), "
|
f"\nSummary: {counts['mode']} mode change(s), "
|
||||||
f"{counts['owner']} owner change(s)"
|
f"{counts['owner']} owner change(s), "
|
||||||
|
f"{counts['skipped']} skipped item(s)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if counts["mode"] == 0 and counts["owner"] == 0:
|
||||||
|
console.print(
|
||||||
|
"\n[yellow]No applicable changes. "
|
||||||
|
"Skipped items were not restored.[/yellow]"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
console.print(
|
console.print(
|
||||||
"\n[yellow]Dry-run only. No changes were applied.[/yellow]"
|
"\n[yellow]Dry-run only. No changes were applied.[/yellow]"
|
||||||
@@ -503,22 +732,10 @@ def main() -> None:
|
|||||||
"Please re-run the command with sudo."
|
"Please re-run the command with sudo."
|
||||||
)
|
)
|
||||||
|
|
||||||
if not args.yes:
|
_confirm_or_abort(
|
||||||
if not sys.stdin.isatty():
|
yes=args.yes, prompt="Do you want to restore this state?"
|
||||||
raise SystemExit(
|
|
||||||
"Refusing to apply changes without confirmation (no TTY).\n"
|
|
||||||
"Use --yes to force."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
answer = (
|
|
||||||
input("\nDo you want to restore this state? (y/N) ")
|
|
||||||
.strip()
|
|
||||||
.lower()
|
|
||||||
)
|
|
||||||
if answer not in ("y", "yes"):
|
|
||||||
console.print("\nAborted.")
|
|
||||||
return
|
|
||||||
|
|
||||||
apply_restore(
|
apply_restore(
|
||||||
root=target_root,
|
root=target_root,
|
||||||
rows=rows,
|
rows=rows,
|
||||||
|
|||||||
+29
-7
@@ -4,8 +4,8 @@ import sqlite3
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from platformdirs import user_data_dir
|
|
||||||
|
|
||||||
|
from platformdirs import user_data_dir
|
||||||
|
|
||||||
APP_NAME = "chguard"
|
APP_NAME = "chguard"
|
||||||
|
|
||||||
@@ -24,8 +24,7 @@ def connect(db_path: Path | None = None) -> sqlite3.Connection:
|
|||||||
|
|
||||||
|
|
||||||
def init_db(conn: sqlite3.Connection) -> None:
|
def init_db(conn: sqlite3.Connection) -> None:
|
||||||
conn.executescript(
|
conn.executescript("""
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS states (
|
CREATE TABLE IF NOT EXISTS states (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
name TEXT UNIQUE NOT NULL,
|
name TEXT UNIQUE NOT NULL,
|
||||||
@@ -46,8 +45,7 @@ def init_db(conn: sqlite3.Connection) -> None:
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_entries_state_id ON entries(state_id);
|
CREATE INDEX IF NOT EXISTS idx_entries_state_id ON entries(state_id);
|
||||||
"""
|
""")
|
||||||
)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -69,7 +67,8 @@ def create_state(
|
|||||||
commit: bool = True,
|
commit: bool = True,
|
||||||
) -> int:
|
) -> int:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO states (name, root_path, created_at, created_by_uid) VALUES (?, ?, ?, ?)",
|
"INSERT INTO states (name, root_path, created_at, created_by_uid) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
(name, root_path, utc_now_iso(), created_by_uid),
|
(name, root_path, utc_now_iso(), created_by_uid),
|
||||||
)
|
)
|
||||||
if commit:
|
if commit:
|
||||||
@@ -86,6 +85,28 @@ def delete_state(
|
|||||||
return cur.rowcount
|
return cur.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def prune_states_before(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
cutoff_iso: str,
|
||||||
|
*,
|
||||||
|
commit: bool = True,
|
||||||
|
) -> int:
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM states WHERE created_at < ?",
|
||||||
|
(cutoff_iso,),
|
||||||
|
)
|
||||||
|
if commit:
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def prune_all_states(conn: sqlite3.Connection, *, commit: bool = True) -> int:
|
||||||
|
cur = conn.execute("DELETE FROM states")
|
||||||
|
if commit:
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class State:
|
class State:
|
||||||
id: int
|
id: int
|
||||||
@@ -97,7 +118,8 @@ class State:
|
|||||||
|
|
||||||
def get_state(conn: sqlite3.Connection, name: str) -> State | None:
|
def get_state(conn: sqlite3.Connection, name: str) -> State | None:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"SELECT id, name, root_path, created_at, created_by_uid FROM states WHERE name = ?",
|
"SELECT id, name, root_path, created_at, created_by_uid "
|
||||||
|
"FROM states WHERE name = ?",
|
||||||
(name,),
|
(name,),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
|
|||||||
+37
-21
@@ -27,9 +27,43 @@ def _is_excluded(rel: str, excludes: Iterable[str]) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_for_path(p: Path, root: Path) -> Entry | None:
|
||||||
|
try:
|
||||||
|
st = p.lstat() # never follow symlinks
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if stat.S_ISDIR(st.st_mode):
|
||||||
|
typ = "dir"
|
||||||
|
elif stat.S_ISREG(st.st_mode):
|
||||||
|
typ = "file"
|
||||||
|
elif stat.S_ISLNK(st.st_mode):
|
||||||
|
typ = "symlink"
|
||||||
|
else:
|
||||||
|
# skip special files (devices, sockets, fifos) in v0.1
|
||||||
|
return None
|
||||||
|
|
||||||
|
rel = "" if p == root else str(p.relative_to(root))
|
||||||
|
|
||||||
|
return Entry(
|
||||||
|
path=rel,
|
||||||
|
type=typ,
|
||||||
|
mode=stat.S_IMODE(st.st_mode),
|
||||||
|
uid=st.st_uid,
|
||||||
|
gid=st.st_gid,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def scan_tree(root: Path, excludes: Iterable[str] = ()) -> Iterator[Entry]:
|
def scan_tree(root: Path, excludes: Iterable[str] = ()) -> Iterator[Entry]:
|
||||||
root = root.resolve()
|
root = root.resolve()
|
||||||
|
|
||||||
|
root_entry = _entry_for_path(root, root)
|
||||||
|
if root_entry is not None:
|
||||||
|
yield root_entry
|
||||||
|
|
||||||
|
if not root.is_dir():
|
||||||
|
return
|
||||||
|
|
||||||
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
||||||
# prune excluded directories early
|
# prune excluded directories early
|
||||||
rel_dir = (
|
rel_dir = (
|
||||||
@@ -56,24 +90,6 @@ def scan_tree(root: Path, excludes: Iterable[str] = ()) -> Iterator[Entry]:
|
|||||||
# record dirs and files
|
# record dirs and files
|
||||||
for name in list(dirnames) + list(files):
|
for name in list(dirnames) + list(files):
|
||||||
p = Path(dirpath) / name
|
p = Path(dirpath) / name
|
||||||
try:
|
entry = _entry_for_path(p, root)
|
||||||
st = p.lstat() # never follow symlinks
|
if entry is not None:
|
||||||
except FileNotFoundError:
|
yield entry
|
||||||
continue
|
|
||||||
|
|
||||||
rel = str(p.relative_to(root))
|
|
||||||
|
|
||||||
if stat.S_ISDIR(st.st_mode):
|
|
||||||
typ = "dir"
|
|
||||||
elif stat.S_ISREG(st.st_mode):
|
|
||||||
typ = "file"
|
|
||||||
elif stat.S_ISLNK(st.st_mode):
|
|
||||||
typ = "symlink"
|
|
||||||
else:
|
|
||||||
# skip special files (devices, sockets, fifos) in v0.1
|
|
||||||
continue
|
|
||||||
|
|
||||||
mode = stat.S_IMODE(st.st_mode)
|
|
||||||
yield Entry(
|
|
||||||
path=rel, type=typ, mode=mode, uid=st.st_uid, gid=st.st_gid
|
|
||||||
)
|
|
||||||
|
|||||||
Generated
+178
-8
@@ -1,4 +1,4 @@
|
|||||||
# This file is automatically @generated by Poetry 1.8.4 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"
|
||||||
@@ -6,6 +6,7 @@ version = "3.6.3"
|
|||||||
description = "Bash tab completion for argparse"
|
description = "Bash tab completion for argparse"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce"},
|
{file = "argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce"},
|
||||||
{file = "argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c"},
|
{file = "argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c"},
|
||||||
@@ -20,28 +21,63 @@ version = "3.5.0"
|
|||||||
description = "Validate configuration and produce human readable error messages."
|
description = "Validate configuration and produce human readable error messages."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"},
|
{file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"},
|
||||||
{file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"},
|
{file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[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"
|
||||||
description = "Distribution utilities"
|
description = "Distribution utilities"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
python-versions = "*"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"},
|
{file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"},
|
||||||
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
|
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "exceptiongroup"
|
||||||
|
version = "1.3.1"
|
||||||
|
description = "Backport of PEP 654 (exception groups)"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.7"
|
||||||
|
groups = ["dev"]
|
||||||
|
markers = "python_version == \"3.10\""
|
||||||
|
files = [
|
||||||
|
{file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"},
|
||||||
|
{file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
test = ["pytest (>=6)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.20.3"
|
version = "3.20.3"
|
||||||
description = "A platform independent file lock."
|
description = "A platform independent file lock."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main", "dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"},
|
{file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"},
|
||||||
{file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"},
|
{file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"},
|
||||||
@@ -53,6 +89,7 @@ version = "2.6.15"
|
|||||||
description = "File identification library for Python"
|
description = "File identification library for Python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"},
|
{file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"},
|
||||||
{file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"},
|
{file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"},
|
||||||
@@ -61,12 +98,25 @@ files = [
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
license = ["ukkonen"]
|
license = ["ukkonen"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
description = "brain-dead simple config-ini parsing"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
|
||||||
|
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markdown-it-py"
|
name = "markdown-it-py"
|
||||||
version = "4.0.0"
|
version = "4.0.0"
|
||||||
description = "Python port of markdown-it. Markdown parsing, done right!"
|
description = "Python port of markdown-it. Markdown parsing, done right!"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"},
|
{file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"},
|
||||||
{file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"},
|
{file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"},
|
||||||
@@ -90,6 +140,7 @@ version = "0.1.2"
|
|||||||
description = "Markdown URL utilities"
|
description = "Markdown URL utilities"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
|
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
|
||||||
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
|
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
|
||||||
@@ -101,17 +152,31 @@ version = "1.10.0"
|
|||||||
description = "Node.js virtual environment builder"
|
description = "Node.js virtual environment builder"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"},
|
{file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"},
|
||||||
{file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"},
|
{file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.2"
|
||||||
|
description = "Core utilities for Python packages"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"},
|
||||||
|
{file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "platformdirs"
|
name = "platformdirs"
|
||||||
version = "4.5.1"
|
version = "4.5.1"
|
||||||
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main", "dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"},
|
{file = "platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"},
|
||||||
{file = "platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda"},
|
{file = "platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda"},
|
||||||
@@ -122,12 +187,29 @@ docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-
|
|||||||
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"]
|
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)"]
|
type = ["mypy (>=1.18.2)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
description = "plugin and hook calling mechanisms for python"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
|
||||||
|
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["pre-commit", "tox"]
|
||||||
|
testing = ["coverage", "pytest", "pytest-benchmark"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pre-commit"
|
name = "pre-commit"
|
||||||
version = "3.8.0"
|
version = "3.8.0"
|
||||||
description = "A framework for managing and maintaining multi-language pre-commit hooks."
|
description = "A framework for managing and maintaining multi-language pre-commit hooks."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
|
{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"},
|
{file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
|
||||||
@@ -142,24 +224,50 @@ virtualenv = ">=20.10.0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.19.2"
|
version = "2.20.0"
|
||||||
description = "Pygments is a syntax highlighting package written in Python."
|
description = "Pygments is a syntax highlighting package written in Python."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main", "dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
|
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
|
||||||
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
|
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
windows-terminal = ["colorama (>=0.4.6)"]
|
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\""}
|
||||||
|
exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""}
|
||||||
|
iniconfig = ">=1.0.1"
|
||||||
|
packaging = ">=22"
|
||||||
|
pluggy = ">=1.5,<2"
|
||||||
|
pygments = ">=2.7.2"
|
||||||
|
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyyaml"
|
name = "pyyaml"
|
||||||
version = "6.0.3"
|
version = "6.0.3"
|
||||||
description = "YAML parser and emitter for Python"
|
description = "YAML parser and emitter for Python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
|
{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_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
|
||||||
@@ -242,6 +350,7 @@ version = "14.2.0"
|
|||||||
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
|
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8.0"
|
python-versions = ">=3.8.0"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"},
|
{file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"},
|
||||||
{file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"},
|
{file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"},
|
||||||
@@ -254,12 +363,72 @@ pygments = ">=2.13.0,<3.0.0"
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
jupyter = ["ipywidgets (>=7.5.1,<9)"]
|
jupyter = ["ipywidgets (>=7.5.1,<9)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tomli"
|
||||||
|
version = "2.4.1"
|
||||||
|
description = "A lil' TOML parser"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
groups = ["dev"]
|
||||||
|
markers = "python_version == \"3.10\""
|
||||||
|
files = [
|
||||||
|
{file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"},
|
||||||
|
{file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"},
|
||||||
|
{file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"},
|
||||||
|
{file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"},
|
||||||
|
{file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"},
|
||||||
|
{file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"},
|
||||||
|
{file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"},
|
||||||
|
{file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"},
|
||||||
|
{file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"},
|
||||||
|
{file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"},
|
||||||
|
{file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"},
|
||||||
|
{file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"},
|
||||||
|
{file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"},
|
||||||
|
{file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"},
|
||||||
|
{file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"},
|
||||||
|
{file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"},
|
||||||
|
{file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"},
|
||||||
|
{file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"},
|
||||||
|
{file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"},
|
||||||
|
{file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"},
|
||||||
|
{file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"},
|
||||||
|
{file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"},
|
||||||
|
{file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"},
|
||||||
|
{file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"},
|
||||||
|
{file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"},
|
||||||
|
{file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"},
|
||||||
|
{file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"},
|
||||||
|
{file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"},
|
||||||
|
{file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"},
|
||||||
|
{file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"},
|
||||||
|
{file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"},
|
||||||
|
{file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"},
|
||||||
|
{file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"},
|
||||||
|
{file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"},
|
||||||
|
{file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"},
|
||||||
|
{file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"},
|
||||||
|
{file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"},
|
||||||
|
{file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"},
|
||||||
|
{file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"},
|
||||||
|
{file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"},
|
||||||
|
{file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"},
|
||||||
|
{file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"},
|
||||||
|
{file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"},
|
||||||
|
{file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"},
|
||||||
|
{file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"},
|
||||||
|
{file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"},
|
||||||
|
{file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
description = "Backported and Experimental Type Hints for Python 3.9+"
|
description = "Backported and Experimental Type Hints for Python 3.9+"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
|
markers = "python_version == \"3.10\""
|
||||||
files = [
|
files = [
|
||||||
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
||||||
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
||||||
@@ -271,6 +440,7 @@ version = "20.36.1"
|
|||||||
description = "Virtual Python Environment builder"
|
description = "Virtual Python Environment builder"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f"},
|
{file = "virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f"},
|
||||||
{file = "virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba"},
|
{file = "virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba"},
|
||||||
@@ -284,9 +454,9 @@ typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""
|
|||||||
|
|
||||||
[package.extras]
|
[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)"]
|
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)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
|
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]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.1"
|
||||||
python-versions = ">=3.10,<4.0"
|
python-versions = ">=3.10,<4.0"
|
||||||
content-hash = "8cfa38f4e2f17dba430ea08f7be3c91890a0c7a4535b69d9565b84d714f589bc"
|
content-hash = "e2c2d57a74d31e7a59cc63daae9511890c9e21db043df7a5c8d9ac33e967e393"
|
||||||
|
|||||||
+6
-2
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "chguard"
|
name = "chguard"
|
||||||
version = "0.3.2"
|
version = "0.5.0"
|
||||||
description = "Safety-first tool to snapshot and restore filesystem ownership and permissions."
|
description = "Safety-first tool to snapshot and restore filesystem ownership and permissions."
|
||||||
authors = ["Marco D'Aleo <marco@marcodaleo.com>"]
|
authors = ["Marco D'Aleo <marco@marcodaleo.com>"]
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
@@ -18,12 +18,16 @@ filelock = ">=3.15.4"
|
|||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
chguard = "chguard.cli:main"
|
chguard = "chguard.cli:main"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
pre-commit = "^3.8"
|
pre-commit = "^3.8"
|
||||||
|
pytest = "^9.1.1"
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 79
|
line-length = 79
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core"]
|
requires = ["poetry-core"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from chguard.cli import (
|
||||||
|
_common_snapshot_root,
|
||||||
|
_extract_paths_from_command,
|
||||||
|
_parse_prune_states_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_prune_states_value_accepts_days_and_all(monkeypatch) -> None:
|
||||||
|
assert _parse_prune_states_value("14") == 14
|
||||||
|
assert _parse_prune_states_value(" all ") == "all"
|
||||||
|
|
||||||
|
monkeypatch.setenv("CHGUARD_STATES_LIFE", "30")
|
||||||
|
assert _parse_prune_states_value(None) == 30
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["", "abc", "-1"])
|
||||||
|
def test_parse_prune_states_value_rejects_invalid_values(
|
||||||
|
value: str, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("CHGUARD_STATES_LIFE", raising=False)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
_parse_prune_states_value(value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_paths_from_command_returns_existing_non_options(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
target = tmp_path / "file.txt"
|
||||||
|
target.write_text("data", encoding="utf-8")
|
||||||
|
|
||||||
|
paths = _extract_paths_from_command(
|
||||||
|
["chmod", "-R", "644", "file.txt", "missing.txt"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert paths == [target.resolve()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_common_snapshot_root_uses_single_path_or_common_parent(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
one = tmp_path / "one"
|
||||||
|
two = tmp_path / "two"
|
||||||
|
one.mkdir()
|
||||||
|
two.mkdir()
|
||||||
|
|
||||||
|
assert _common_snapshot_root([one]) == one.resolve()
|
||||||
|
assert _common_snapshot_root([one, two]) == tmp_path.resolve()
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from chguard.cli import _restore_preview_rows, main
|
||||||
|
from chguard.db import connect, create_state, init_db
|
||||||
|
from chguard.restore import PlannedChange
|
||||||
|
|
||||||
|
|
||||||
|
class FakePath:
|
||||||
|
def relative_to(self, root: Path) -> Path:
|
||||||
|
return Path("link")
|
||||||
|
|
||||||
|
def lstat(self):
|
||||||
|
return SimpleNamespace(st_uid=123)
|
||||||
|
|
||||||
|
def stat(self):
|
||||||
|
raise AssertionError("restore preview must not follow symlinks")
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_and_type_changes_are_reported_as_skipped() -> None:
|
||||||
|
root = Path("/snapshot")
|
||||||
|
changes = [
|
||||||
|
PlannedChange(
|
||||||
|
root / "deleted.conf",
|
||||||
|
"missing",
|
||||||
|
"path does not exist",
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
PlannedChange(root / "logs", "type", "file -> dir", False),
|
||||||
|
]
|
||||||
|
|
||||||
|
rows, counts, needs_root = _restore_preview_rows(
|
||||||
|
changes, root, current_uid=999
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not needs_root
|
||||||
|
assert counts["skipped"] == 2
|
||||||
|
assert rows[Path("deleted.conf")]["skipped"] == "missing path"
|
||||||
|
assert rows[Path("logs")]["skipped"] == "found file, expected dir"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unselected_scope_changes_are_not_reported() -> None:
|
||||||
|
root = Path("/snapshot")
|
||||||
|
changes = [
|
||||||
|
PlannedChange(root / "file.txt", "mode", "0o644 -> 0o600", False)
|
||||||
|
]
|
||||||
|
|
||||||
|
rows, counts, needs_root = _restore_preview_rows(
|
||||||
|
changes, root, current_uid=999
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rows == {}
|
||||||
|
assert counts["mode"] == 0
|
||||||
|
assert not needs_root
|
||||||
|
|
||||||
|
|
||||||
|
def test_applicable_changes_use_lstat_for_privilege_check() -> None:
|
||||||
|
changes = [PlannedChange(FakePath(), "mode", "0o644 -> 0o600", True)]
|
||||||
|
|
||||||
|
rows, counts, needs_root = _restore_preview_rows(
|
||||||
|
changes, Path("/snapshot"), current_uid=999
|
||||||
|
)
|
||||||
|
|
||||||
|
assert needs_root
|
||||||
|
assert counts["mode"] == 1
|
||||||
|
assert "rw-r--r--" in rows[Path("link")]["mode"]
|
||||||
|
assert "rw-------" in rows[Path("link")]["mode"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_with_only_skipped_items_does_not_prompt(
|
||||||
|
tmp_path: Path, monkeypatch, capsys
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
|
||||||
|
conn = connect(tmp_path / "states.db")
|
||||||
|
init_db(conn)
|
||||||
|
with conn:
|
||||||
|
state_id = create_state(
|
||||||
|
conn, "baseline", str(root), os.getuid(), commit=False
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO entries (state_id, path, type, mode, uid, gid)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(state_id, "deleted.conf", "file", 0o644, os.getuid(), 0),
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
[
|
||||||
|
"chguard",
|
||||||
|
"--db",
|
||||||
|
str(tmp_path / "states.db"),
|
||||||
|
"--restore",
|
||||||
|
"baseline",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
main()
|
||||||
|
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "missing path" in output
|
||||||
|
assert "No applicable changes" in output
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from chguard.db import (
|
||||||
|
connect,
|
||||||
|
create_state,
|
||||||
|
delete_state,
|
||||||
|
get_state,
|
||||||
|
init_db,
|
||||||
|
prune_all_states,
|
||||||
|
prune_states_before,
|
||||||
|
state_exists,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_crud_and_entry_cascade_delete(tmp_path: Path) -> None:
|
||||||
|
conn = connect(tmp_path / "states.db")
|
||||||
|
init_db(conn)
|
||||||
|
|
||||||
|
state_id = create_state(conn, "baseline", "/srv/app", 1000)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO entries (state_id, path, type, mode, uid, gid)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(state_id, "config.txt", "file", 0o644, 1000, 1000),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
assert state_exists(conn, "baseline")
|
||||||
|
state = get_state(conn, "baseline")
|
||||||
|
assert state is not None
|
||||||
|
assert state.id == state_id
|
||||||
|
assert state.root_path == "/srv/app"
|
||||||
|
|
||||||
|
assert delete_state(conn, "baseline") == 1
|
||||||
|
assert not state_exists(conn, "baseline")
|
||||||
|
remaining_entries = conn.execute("SELECT COUNT(*) FROM entries").fetchone()
|
||||||
|
assert remaining_entries[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_states_before_deletes_only_old_states(tmp_path: Path) -> None:
|
||||||
|
conn = connect(tmp_path / "states.db")
|
||||||
|
init_db(conn)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO states (name, root_path, created_at, created_by_uid)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
("old", "/old", "2024-01-01T00:00:00+00:00", 1000),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO states (name, root_path, created_at, created_by_uid)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
("new", "/new", "2024-02-01T00:00:00+00:00", 1000),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
deleted = prune_states_before(conn, "2024-01-15T00:00:00+00:00")
|
||||||
|
|
||||||
|
assert deleted == 1
|
||||||
|
assert not state_exists(conn, "old")
|
||||||
|
assert state_exists(conn, "new")
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_all_states_deletes_every_state(tmp_path: Path) -> None:
|
||||||
|
conn = connect(tmp_path / "states.db")
|
||||||
|
init_db(conn)
|
||||||
|
create_state(conn, "one", "/one", 1000)
|
||||||
|
create_state(conn, "two", "/two", 1000)
|
||||||
|
|
||||||
|
assert prune_all_states(conn) == 2
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM states").fetchone()[0] == 0
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from chguard.restore import apply_restore, plan_restore
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_restore_reports_mode_owner_missing_and_type_changes(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
config = root / "config.txt"
|
||||||
|
config.write_text("config", encoding="utf-8")
|
||||||
|
config.chmod(0o644)
|
||||||
|
logs = root / "logs"
|
||||||
|
logs.write_text("not a directory", encoding="utf-8")
|
||||||
|
|
||||||
|
current = config.lstat()
|
||||||
|
rows = [
|
||||||
|
("config.txt", "file", 0o600, current.st_uid + 1, current.st_gid),
|
||||||
|
("missing.txt", "file", 0o644, current.st_uid, current.st_gid),
|
||||||
|
("logs", "dir", 0o755, current.st_uid, current.st_gid),
|
||||||
|
]
|
||||||
|
|
||||||
|
changes = plan_restore(
|
||||||
|
root=root,
|
||||||
|
rows=rows,
|
||||||
|
restore_permissions=True,
|
||||||
|
restore_owner=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
kinds_by_path = {(change.path.name, change.kind) for change in changes}
|
||||||
|
assert ("config.txt", "mode") in kinds_by_path
|
||||||
|
assert ("config.txt", "owner") in kinds_by_path
|
||||||
|
assert ("missing.txt", "missing") in kinds_by_path
|
||||||
|
assert ("logs", "type") in kinds_by_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_restore_marks_unselected_scope_as_not_applicable(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
config = root / "config.txt"
|
||||||
|
config.write_text("config", encoding="utf-8")
|
||||||
|
config.chmod(0o644)
|
||||||
|
st = config.lstat()
|
||||||
|
|
||||||
|
changes = plan_restore(
|
||||||
|
root=root,
|
||||||
|
rows=[("config.txt", "file", 0o600, st.st_uid, st.st_gid)],
|
||||||
|
restore_permissions=False,
|
||||||
|
restore_owner=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(changes) == 1
|
||||||
|
assert changes[0].kind == "mode"
|
||||||
|
assert not changes[0].will_apply
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_restore_changes_mode_without_changing_contents(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
config = root / "config.txt"
|
||||||
|
config.write_text("config", encoding="utf-8")
|
||||||
|
config.chmod(0o644)
|
||||||
|
st = config.lstat()
|
||||||
|
|
||||||
|
apply_restore(
|
||||||
|
root=root,
|
||||||
|
rows=[("config.txt", "file", 0o600, st.st_uid, st.st_gid)],
|
||||||
|
restore_permissions=True,
|
||||||
|
restore_owner=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert stat.S_IMODE(config.lstat().st_mode) == 0o600
|
||||||
|
assert config.read_text(encoding="utf-8") == "config"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_restore_skips_missing_and_type_mismatch(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
config = root / "config.txt"
|
||||||
|
config.write_text("config", encoding="utf-8")
|
||||||
|
config.chmod(0o644)
|
||||||
|
st = config.lstat()
|
||||||
|
|
||||||
|
apply_restore(
|
||||||
|
root=root,
|
||||||
|
rows=[
|
||||||
|
("missing.txt", "file", 0o600, st.st_uid, st.st_gid),
|
||||||
|
("config.txt", "dir", 0o600, st.st_uid, st.st_gid),
|
||||||
|
],
|
||||||
|
restore_permissions=True,
|
||||||
|
restore_owner=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not (root / "missing.txt").exists()
|
||||||
|
assert stat.S_IMODE(config.lstat().st_mode) == 0o644
|
||||||
|
assert os.listdir(root) == ["config.txt"]
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import stat
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from chguard.scan import scan_tree
|
||||||
|
|
||||||
|
|
||||||
|
def entries_by_path(root: Path, excludes: tuple[str, ...] = ()):
|
||||||
|
return {entry.path: entry for entry in scan_tree(root, excludes=excludes)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_tree_records_root_dirs_files_and_symlinks(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
data_dir = root / "data"
|
||||||
|
data_dir.mkdir()
|
||||||
|
file_path = data_dir / "config.txt"
|
||||||
|
file_path.write_text("config", encoding="utf-8")
|
||||||
|
file_path.chmod(0o640)
|
||||||
|
(root / "config-link").symlink_to(file_path)
|
||||||
|
|
||||||
|
entries = entries_by_path(root)
|
||||||
|
|
||||||
|
assert entries[""].type == "dir"
|
||||||
|
assert entries["data"].type == "dir"
|
||||||
|
assert entries["data/config.txt"].type == "file"
|
||||||
|
assert entries["data/config.txt"].mode == 0o640
|
||||||
|
assert entries["config-link"].type == "symlink"
|
||||||
|
assert entries["config-link"].mode == stat.S_IMODE(
|
||||||
|
(root / "config-link").lstat().st_mode
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_tree_excludes_path_prefixes(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "root"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "keep").mkdir()
|
||||||
|
(root / "keep" / "file.txt").write_text("keep", encoding="utf-8")
|
||||||
|
(root / "cache").mkdir()
|
||||||
|
(root / "cache" / "file.txt").write_text("cache", encoding="utf-8")
|
||||||
|
(root / "var").mkdir()
|
||||||
|
(root / "var" / "tmp").mkdir()
|
||||||
|
(root / "var" / "tmp" / "file.txt").write_text("tmp", encoding="utf-8")
|
||||||
|
|
||||||
|
entries = entries_by_path(root, excludes=("cache", "var/tmp"))
|
||||||
|
|
||||||
|
assert "keep" in entries
|
||||||
|
assert "keep/file.txt" in entries
|
||||||
|
assert "cache" not in entries
|
||||||
|
assert "cache/file.txt" not in entries
|
||||||
|
assert "var" in entries
|
||||||
|
assert "var/tmp" not in entries
|
||||||
|
assert "var/tmp/file.txt" not in entries
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_tree_file_root_records_only_root_entry(tmp_path: Path) -> None:
|
||||||
|
root_file = tmp_path / "single.txt"
|
||||||
|
root_file.write_text("single", encoding="utf-8")
|
||||||
|
|
||||||
|
entries = list(scan_tree(root_file))
|
||||||
|
|
||||||
|
assert len(entries) == 1
|
||||||
|
assert entries[0].path == ""
|
||||||
|
assert entries[0].type == "file"
|
||||||
Reference in New Issue
Block a user