45 Commits
Author SHA1 Message Date
mdaleo404 8f3935ffa1 Package version bump 0.5.0 2026-06-27 07:40:14 +01:00
mdaleo404 11c6aa812b Merge pull request 'Fix drift change' (#12) from fix-drift-change into main
Reviewed-on: #12
2026-06-23 15:14:12 +00:00
mdaleo404 f466040677 Fix mirro reference in DEVELOPMENT.md
Lint & Security / precommit-and-security (pull_request) Successful in 1m1s
2026-06-23 16:12:02 +01:00
mdaleo404 c1140c1b9c Fix pre-commit bandit scan path
Lint & Security / precommit-and-security (pull_request) Successful in 1m3s
2026-06-23 16:04:40 +01:00
mdaleo404 f3bd7b022d Fix restore preview to document skipped missing and type drift 2026-06-23 16:03:26 +01:00
mdaleo404 e5699a3f60 Add full test suite with pytest 2026-06-23 16:01:53 +01:00
mdaleo404 e527e71488 Add DEVELOPMENT.md and SECURITY.md 2026-06-23 16:01:05 +01:00
mdaleo404 ac49ea53e7 Fix pre-commit bandit scan path 2026-06-23 15:54:28 +01:00
mdaleo404 0f5a80e67c Merge pull request 'Add multiple arguments support, improve list table' (#11) from some-improvements into main
Reviewed-on: #11
2026-06-23 13:33:54 +00:00
mdaleo404 9f7bc804b0 Add multiple arguments support, improve list table
Lint & Security / precommit-and-security (pull_request) Successful in 2m4s
2026-06-23 14:13:28 +01:00
mdaleo404 f07cbe06c8 Merge pull request 'Add --prune-states + root path fix' (#10) from restore-root-folder into main
Reviewed-on: #10
2026-05-23 10:37:01 +00:00
mdaleo404 4abc89def9 Add prune state option
Lint & Security / precommit-and-security (pull_request) Successful in 2m27s
2026-05-23 11:26:16 +01:00
mdaleo404 5c63675ef5 Record the root path in the state for restoring, make pre-commit black use generic python3 version 2026-05-23 10:28:53 +01:00
mdaleo404 88b2dfd1a5 Merge pull request 'Poetry update' (#7) from poetry-update-2.3.3 into main
Security Scan / security-scan (push) Successful in 1m20s
Reviewed-on: #7
2026-04-03 07:24:44 +00:00
mdaleo404 ee80306d9a Version bump
Lint & Security / precommit-and-security (pull_request) Successful in 1m2s
2026-04-03 08:21:12 +01:00
mdaleo404 cc97d3d7f1 Update Pygments
Lint & Security / precommit-and-security (pull_request) Successful in 1m1s
2026-04-03 08:19:40 +01:00
mdaleo404 9bf5ef1d7d Black formatting
Lint & Security / precommit-and-security (pull_request) Failing after 1m1s
2026-04-03 08:16:56 +01:00
mdaleo404 24696af083 Poetry update
Lint & Security / precommit-and-security (pull_request) Failing after 44s
2026-04-03 08:14:37 +01:00
mdaleo404 fcef549eba Exclude unfixed vulnerabilities from security workflow results
Security Scan / security-scan (push) Successful in 1m17s
2026-03-25 16:31:27 +00:00
mdaleo404 049273a13c Switch Trivy scan to Syft and Grype 2026-03-25 16:10:01 +00:00
mdaleo404 d00407bb33 Disable trivy scan workflow 2026-03-23 08:02:12 +00:00
mdaleo404 a8dbe675f5 Update pre-commit hooks version
Trivy Scan / security-scan (push) Successful in 27s
2026-03-21 07:25:11 +00:00
mdaleo404 cc2b1fe791 Ping Trivy docker image to 0.69.3@sha256:bcc376de8d77cfe086a917230e818dc9f8528e3c852f7b1aff648949b6258d1c 2026-03-21 07:05:33 +00:00
mdaleo404 db60b4e42b Merge pull request 'Fix list view to be a rich table' (#6) from fix_list_view into main
Trivy Scan / security-scan (push) Successful in 27s
Reviewed-on: #6
2026-01-24 09:24:39 +00:00
mdaleo404 afc964a076 Fix list view to be a rich table
Lint & Security / precommit-and-security (pull_request) Successful in 1m50s
2026-01-24 09:21:51 +00:00
mdaleo404 d42f6d5fe1 Merge pull request 'Relax dependencies to match Fedora 42 packages' (#5) from fix_filelock_platformdird_deps into main
Trivy Scan / security-scan (push) Successful in 28s
Reviewed-on: #5
2026-01-17 16:44:06 +00:00
mdaleo404 103d6159d1 Relax dependencies to match Fedora 42 packages
Lint & Security / precommit-and-security (pull_request) Successful in 1m56s
2026-01-17 16:41:08 +00:00
mdaleo404 2ee9588fb9 Update filelock and virtualenv
Trivy Scan / security-scan (push) Successful in 26s
2026-01-15 17:06:08 +00:00
mdaleo404 a2720e245f Change trivy.json output path 2026-01-15 16:47:54 +00:00
mdaleo404 8e3404bd51 Rework the trivy scan job 2026-01-15 16:45:21 +00:00
mdaleo404 5ff1e935a3 Fix trivy scan volume path to be explicit 2026-01-15 16:39:40 +00:00
mdaleo404 7fafb1fa8e Fix trivy scan volume path 2026-01-15 16:36:23 +00:00
mdaleo404 f87eb5f438 Fix trivy scan volume path 2026-01-15 16:22:47 +00:00
mdaleo404 1c3025b2d6 Fix docker command to trivy scan 2026-01-15 16:19:08 +00:00
mdaleo404 4980903572 Fix trivy.json output path 2026-01-15 16:13:48 +00:00
mdaleo404 a7183b3286 Fix trivy.json output path 2026-01-15 16:09:17 +00:00
mdaleo404 984aeccfdd Fix trivy fs path 2026-01-15 16:05:51 +00:00
mdaleo404 dc6e7840c7 Modify severity level on trivy-scan workflow 2026-01-15 15:48:41 +00:00
mdaleo404 0464982b94 Add trivy-scan workflow 2026-01-15 15:41:29 +00:00
mdaleo404 4a4cb8183f Merge pull request 'Add wrapper mode' (#4) from ch_wrapper into main
Reviewed-on: #4
2025-12-30 17:31:45 +00:00
mdaleo404 20a0dca080 Add wrapper mode, update README, version bump 0.3.0
Lint & Security / precommit-and-security (pull_request) Successful in 2m7s
2025-12-30 17:27:15 +00:00
mdaleo404 7c391b8dbc Fix logo path in README 2025-12-23 16:18:18 +00:00
mdaleo404 aafad81bb6 Merge pull request 'Make save operation transactional' (#3) from transaction_patch into main
Reviewed-on: #3
2025-12-23 16:15:46 +00:00
mdaleo404 9658f534ea Run save inside a single transaction to avoid partial writes when permission checks fail or state creation errors occur
Lint & Security / precommit-and-security (pull_request) Successful in 59s
2025-12-23 16:07:46 +00:00
mdaleo404 5af28d21ca Add .python-version to .gitignore 2025-12-21 14:03:52 +00:00
17 changed files with 2525 additions and 237 deletions
+188
View File
@@ -0,0 +1,188 @@
name: Security Scan
on:
schedule:
- cron: 27 8 * * *
workflow_dispatch:
jobs:
security-scan:
runs-on: running-man
env:
TARGET_DIR: .
COSIGN_VERSION: v3.0.5
SYFT_VERSION: v1.42.3
GRYPE_VERSION: v0.110.0
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Cosign (bootstrap)
run: |
set -euo pipefail
FILE="cosign-linux-amd64"
curl -fLO https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/${FILE}
chmod +x ${FILE}
mv ${FILE} /usr/local/bin/cosign
cosign version
- name: Install Syft (verified)
run: |
set -euo pipefail
VERSION_NO_V="${SYFT_VERSION#v}"
FILE="syft_${VERSION_NO_V}_linux_amd64.tar.gz"
BASE_URL="https://github.com/anchore/syft/releases/download/${SYFT_VERSION}"
curl -fLO ${BASE_URL}/${FILE}
curl -fLO ${BASE_URL}/syft_${VERSION_NO_V}_checksums.txt
curl -fLO ${BASE_URL}/syft_${VERSION_NO_V}_checksums.txt.sig
curl -fLO ${BASE_URL}/syft_${VERSION_NO_V}_checksums.txt.pem
cosign verify-blob \
--signature syft_${VERSION_NO_V}_checksums.txt.sig \
--certificate syft_${VERSION_NO_V}_checksums.txt.pem \
--certificate-identity-regexp "https://github.com/anchore/syft" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
syft_${VERSION_NO_V}_checksums.txt
CHECKSUM_LINE=$(grep " ${FILE}$" syft_${VERSION_NO_V}_checksums.txt)
if [ -z "$CHECKSUM_LINE" ]; then
echo "Missing checksum entry for ${FILE}"
exit 1
fi
echo "$CHECKSUM_LINE" | sha256sum -c -
tar -xzf ${FILE}
mv syft /usr/local/bin/
syft version
- name: Install Grype (verified)
run: |
set -euo pipefail
VERSION_NO_V="${GRYPE_VERSION#v}"
FILE="grype_${VERSION_NO_V}_linux_amd64.tar.gz"
BASE_URL="https://github.com/anchore/grype/releases/download/${GRYPE_VERSION}"
curl -fLO ${BASE_URL}/${FILE}
curl -fLO ${BASE_URL}/grype_${VERSION_NO_V}_checksums.txt
curl -fLO ${BASE_URL}/grype_${VERSION_NO_V}_checksums.txt.sig
curl -fLO ${BASE_URL}/grype_${VERSION_NO_V}_checksums.txt.pem
cosign verify-blob \
--signature grype_${VERSION_NO_V}_checksums.txt.sig \
--certificate grype_${VERSION_NO_V}_checksums.txt.pem \
--certificate-identity-regexp "https://github.com/anchore/grype" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
grype_${VERSION_NO_V}_checksums.txt
CHECKSUM_LINE=$(grep " ${FILE}$" grype_${VERSION_NO_V}_checksums.txt)
if [ -z "$CHECKSUM_LINE" ]; then
echo "Missing checksum entry for ${FILE}"
exit 1
fi
echo "$CHECKSUM_LINE" | sha256sum -c -
tar -xzf ${FILE}
mv grype /usr/local/bin/
grype version
- name: Generate SBOM
working-directory: ${{ env.TARGET_DIR }}
run: |
syft dir:. -o json > sbom.json
- name: Show SBOM contents
working-directory: ${{ env.TARGET_DIR }}
run: |
echo "Packages discovered by Syft:"
jq -r '.artifacts[] | "\(.name)@\(.version) [\(.type)]"' sbom.json | sort
- name: Run Grype scan (JSON)
id: audit
continue-on-error: true
working-directory: ${{ env.TARGET_DIR }}
run: |
grype sbom:sbom.json -o json > grype.json
echo "Vulnerabilities (fixable only):"
jq -r '
.matches[]
| select((.vulnerability.fix.versions | length) > 0)
| "\(.artifact.name)@\(.artifact.version) -> \(.vulnerability.id) [\(.vulnerability.severity)] | fixed: \(.vulnerability.fix.versions[0])"
' grype.json
# Fail only on fixable MEDIUM/HIGH/CRITICAL
jq -e '
[
.matches[]?
| select(
(
.vulnerability.severity == "Medium" or
.vulnerability.severity == "High" or
.vulnerability.severity == "Critical"
)
and
(
(.vulnerability.fix.versions | length) > 0
)
)
]
| length == 0
' grype.json
- name: Show full Grype table
working-directory: ${{ env.TARGET_DIR }}
run: |
echo "Full Grype report:"
grype sbom:sbom.json -o table
- name: Notify Node-RED on vulnerabilities
if: steps.audit.outcome == 'failure'
working-directory: ${{ env.TARGET_DIR }}
run: |
jq '
{
repo: "guardutils/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 -1
View File
@@ -85,7 +85,7 @@ ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+5 -5
View File
@@ -1,19 +1,19 @@
repos:
- repo: https://github.com/PyCQA/bandit
rev: 1.7.9
rev: 1.9.4
hooks:
- id: bandit
files: ^src/mirro/
files: ^chguard/
args: ["-lll", "-iii", "-s", "B110,B112"]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 25.11.0
rev: 26.3.1
hooks:
- id: black
language_version: python3.13
language_version: python3
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
+835
View File
@@ -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.
+72 -6
View File
@@ -5,7 +5,7 @@
# chguard
<div align="center">
<img src="chguard.png" alt="chguard logo" width="256" />
<img src="https://git.sysmd.uk/guardutils/chguard/raw/branch/main/chguard.png" alt="chguard logo" width="256" />
</div>
@@ -19,10 +19,13 @@ applies changes after explicit confirmation.
## Features
### 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
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
A single confirmation prompt at the end of a restore (default: **No**).
@@ -30,6 +33,31 @@ A single confirmation prompt at the end of a restore (default: **No**).
### Dry-run mode
Preview restore operations without prompting or applying changes.
### Wrapper mode (automatic snapshots)
`chguard` can also run as a wrapper around ownership and permission commands.
In this mode, `chguard` automatically saves a snapshot before the command runs, so the user can easily restore the previous state if needed.
#### Supported commands
Wrapper mode is intentionally limited to commands that modify filesystem metadata only:
* `chown`
* `chmod`
* `chgrp`
Other commands are rejected to avoid giving a _false sense of protection_.
#### Automatic snapshot names
Snapshots created in wrapper mode are named automatically, for example:
```
auto-20251230-161301
```
Auto-generated snapshots are visually distinguished in the output so they are easy to identify.
### Scope control
Restore:
* both ownership and permissions (default)
@@ -40,7 +68,7 @@ Restore:
* Never creates, deletes, or moves files
* Missing 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**
## Non-Goals
@@ -55,7 +83,6 @@ Restore:
It only concerns itself with **ownership** and **permissions**.
## Installation
### From GuardUtils package repo
@@ -154,7 +181,8 @@ app-baseline /srv/app 2025-12-20 18:11:08 +00:00
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
```
@@ -179,6 +207,36 @@ chguard --restore app-baseline --permissions
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
Use `--` to separate `chguard` arguments from the wrapped command:
```
chguard -- chown user:group file
chguard -- chmod 755 file
chguard -- chgrp staff file
```
## Privilege model
`chguard` never escalates privileges automatically
@@ -192,7 +250,7 @@ chguard --restore app-baseline --owner
Snapshots are stored in a local SQLite database containing:
* relative path
* file type (file or directory)
* file type (file, directory, or symbolic link)
* numeric uid / gid
* numeric mode
@@ -217,3 +275,11 @@ poetry install
poetry run pre-commit install
```
This ensures consistent formatting, catches common issues early, and keeps the codebase clean.
## Tests
Run the pytest suite with:
```
poetry run pytest
```
+236
View File
@@ -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.
+502 -174
View File
@@ -2,32 +2,37 @@ from __future__ import annotations
import argparse
import argcomplete
import grp
import importlib.metadata
import os
import sys
import stat
import pwd
import grp
import stat
import subprocess
import sys
from collections import Counter, defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from datetime import datetime
from rich import box
from rich.console import Console
from rich.table import Table
from rich import box
from chguard.db import (
connect,
init_db,
create_state,
delete_state,
get_state,
init_db,
prune_all_states,
prune_states_before,
state_exists,
)
from chguard.restore import PlannedChange, apply_restore, plan_restore
from chguard.scan import scan_tree
from chguard.restore import plan_restore, apply_restore
from chguard.util import normalize_root
PRUNE_STATES_FROM_ENV = "__CHGUARD_PRUNE_STATES_FROM_ENV__"
def get_version():
try:
@@ -37,7 +42,6 @@ def get_version():
def _uid_to_name(uid: int) -> str:
"""Return username for uid, or uid as string if unknown."""
try:
return pwd.getpwuid(uid).pw_name
except KeyError:
@@ -45,7 +49,6 @@ def _uid_to_name(uid: int) -> str:
def _gid_to_name(gid: int) -> str:
"""Return group name for gid, or gid as string if unknown."""
try:
return grp.getgrgid(gid).gr_name
except KeyError:
@@ -53,12 +56,10 @@ def _gid_to_name(gid: int) -> str:
def _format_owner(uid: int, gid: int) -> str:
"""Format uid/gid as username:group."""
return f"{_uid_to_name(uid)}:{_gid_to_name(gid)}"
def _mode_to_rwx(mode: int) -> str:
"""Convert numeric mode to rwx-style permissions."""
bits = (
stat.S_IRUSR,
stat.S_IWUSR,
@@ -88,10 +89,52 @@ def _mode_to_rwx(mode: int) -> str:
def _is_root() -> bool:
"""Return True if running as root."""
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):
try:
conn = connect(
@@ -105,23 +148,219 @@ def complete_state_names(prefix, parsed_args, **kwargs):
return []
def main() -> None:
"""
Entry point for the CLI.
def _extract_paths_from_command(cmd: list[str]) -> list[Path]:
paths = []
for arg in cmd:
if arg.startswith("-"):
continue
p = Path(arg)
if p.exists():
paths.append(p.resolve())
return paths
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
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:
wrapper_cmd = None
if "--" in sys.argv:
idx = sys.argv.index("--")
wrapper_cmd = sys.argv[idx + 1 :]
sys.argv = sys.argv[:idx]
parser = argparse.ArgumentParser(
prog="chguard",
description="Snapshot and restore filesystem ownership and permissions.",
epilog=(
"Wrapper mode:\n"
" chguard -- chown [OPTIONS] PATH...\n"
" chguard -- chmod [OPTIONS] PATH...\n"
" chguard -- chgrp [OPTIONS] PATH...\n\n"
"In wrapper mode, chguard automatically saves a snapshot of ownership\n"
"and permissions for the affected paths before running the command.\n"
"Only chown, chmod, and chgrp are supported."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
actions = parser.add_mutually_exclusive_group(required=True)
actions = parser.add_mutually_exclusive_group(required=wrapper_cmd is None)
parser.add_argument(
"--version",
@@ -130,75 +369,53 @@ def main() -> None:
)
actions.add_argument(
"--save",
metavar="PATH",
help="Save state for PATH",
"--save", metavar="PATH", help="Save state for PATH"
).completer = argcomplete.FilesCompleter()
actions.add_argument(
"--restore",
action="store_true",
help="Restore a saved state",
"--restore", action="store_true", help="Restore a saved state"
)
actions.add_argument(
"--list", action="store_true", help="List saved states"
)
actions.add_argument(
"--list",
action="store_true",
help="List saved states",
)
actions.add_argument(
"--delete",
metavar="STATE",
help="Delete a saved state",
"--delete", metavar="STATE", help="Delete a saved state"
).completer = complete_state_names
# positional STATE
parser.add_argument(
"state",
actions.add_argument(
"--prune-states",
nargs="?",
help="State name (required with --restore)",
).completer = complete_state_names
const=PRUNE_STATES_FROM_ENV,
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(
"--name",
help="State name (required with --save)",
"--overwrite", 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(
"--overwrite",
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",
"--root", metavar="PATH", help="Override restore root"
).completer = argcomplete.FilesCompleter()
parser.add_argument(
@@ -209,31 +426,175 @@ def main() -> None:
).completer = argcomplete.FilesCompleter()
parser.add_argument(
"--db",
metavar="PATH",
help="Override database path",
"--db", metavar="PATH", help="Override database path"
).completer = argcomplete.FilesCompleter()
argcomplete.autocomplete(parser)
args = parser.parse_args()
if wrapper_cmd is not None:
if not wrapper_cmd:
raise SystemExit("No command provided after '--'")
cmd = Path(wrapper_cmd[0]).name
if cmd not in ("chown", "chmod", "chgrp"):
raise SystemExit(
"Wrapper mode only supports chown, chmod, and chgrp"
)
console = Console()
conn = connect(Path(args.db).expanduser().resolve() if args.db else None)
init_db(conn)
if wrapper_cmd:
paths = _extract_paths_from_command(wrapper_cmd)
if paths:
auto_name = f"auto-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
root_path = _common_snapshot_root(paths)
with conn:
state_id = create_state(
conn, auto_name, str(root_path), os.getuid(), commit=False
)
seen_paths: set[str] = set()
for path in paths:
for rel, typ, mode, uid, gid in _iter_entries_for_target(
path, root_path
):
# 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(
"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, rel, typ, mode, uid, gid),
)
console.print(
f"Saved pre-command snapshot: [cyan]{auto_name}[/cyan]"
)
proc = subprocess.run(wrapper_cmd)
sys.exit(proc.returncode)
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(
"""
SELECT name, root_path, created_at
FROM states
WHERE created_at < ?
ORDER BY created_at
""",
(cutoff_iso,),
).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 name, root_path, created_at FROM states ORDER BY created_at DESC"
).fetchall()
rows = conn.execute("""
SELECT id, name, root_path, created_at
FROM states
ORDER BY created_at DESC
""").fetchall()
if not rows:
console.print("No saved states.")
return
for name, root, created in rows:
dt = datetime.fromisoformat(created)
ts = dt.strftime("%Y-%m-%d %H:%M:%S %z")
console.print(f"{name}\t{root}\t[dim]{ts}[/dim]")
table = Table(box=box.SIMPLE, header_style="bold")
table.add_column("State")
table.add_column("Snapshot root")
table.add_column("Captured paths")
table.add_column("Created")
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
if args.delete:
@@ -248,40 +609,41 @@ def main() -> None:
root = normalize_root(args.save)
if state_exists(conn, args.name):
if not args.overwrite:
raise SystemExit(
f"State '{args.name}' already exists (use --overwrite)"
)
delete_state(conn, args.name)
with conn:
if state_exists(conn, args.name):
if not args.overwrite:
raise SystemExit(
f"State '{args.name}' already exists (use --overwrite)"
)
delete_state(conn, args.name, commit=False)
state_id = create_state(conn, args.name, str(root), os.getuid())
# 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):
if entry.uid == 0 and not _is_root():
raise SystemExit(
"This path contains root-owned files.\n"
"Saving this state requires sudo."
)
conn.execute(
"""
INSERT INTO entries (state_id, path, type, mode, uid, gid)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
state_id,
entry.path,
entry.type,
entry.mode,
entry.uid,
entry.gid,
),
state_id = create_state(
conn, args.name, str(root), os.getuid(), commit=False
)
conn.commit()
for entry in scan_tree(root, excludes=args.exclude):
if entry.uid == 0 and not _is_root():
raise SystemExit(
"This path contains root-owned files.\n"
"Saving this state requires sudo."
)
conn.execute(
"""
INSERT INTO entries
(state_id, path, type, mode, uid, gid)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
state_id,
entry.path,
entry.type,
entry.mode,
entry.uid,
entry.gid,
),
)
console.print(f"Saved state '{args.name}' for {root}")
return
@@ -296,7 +658,6 @@ def main() -> None:
snapshot_root = Path(state.root_path)
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 (
not args.permissions and not args.owner
)
@@ -314,49 +675,18 @@ def main() -> None:
restore_owner=restore_owner,
)
per_path: dict[Path, dict[str, str]] = defaultdict(dict)
counts = Counter()
needs_root = False
current_uid = os.geteuid()
per_path, counts, needs_root = _restore_preview_rows(
changes, target_root, os.geteuid()
)
# Build a per-path view of owner/mode changes and detect privilege needs.
for ch in changes:
if ch.kind not in ("owner", "mode"):
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 au != 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 changes:
console.print("No differences found.")
return
if not per_path:
console.print("No differences found.")
console.print(
"No differences found for the selected restore scope."
)
return
console.print(f"\nRestoring under: {target_root}\n")
@@ -365,6 +695,7 @@ def main() -> None:
table.add_column("Path")
table.add_column("Owner change", style="cyan")
table.add_column("Mode change", style="green")
table.add_column("Skipped", style="yellow")
for path in sorted(per_path):
row = per_path[path]
@@ -372,14 +703,23 @@ def main() -> None:
str(path),
row.get("owner", ""),
row.get("mode", ""),
row.get("skipped", ""),
)
console.print(table)
console.print(
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:
console.print(
"\n[yellow]Dry-run only. No changes were applied.[/yellow]"
@@ -392,21 +732,9 @@ def main() -> None:
"Please re-run the command with sudo."
)
if not args.yes:
if not sys.stdin.isatty():
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
_confirm_or_abort(
yes=args.yes, prompt="Do you want to restore this state?"
)
apply_restore(
root=target_root,
+42 -11
View File
@@ -4,8 +4,8 @@ import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from platformdirs import user_data_dir
from platformdirs import user_data_dir
APP_NAME = "chguard"
@@ -24,8 +24,7 @@ def connect(db_path: Path | None = None) -> sqlite3.Connection:
def init_db(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
conn.executescript("""
CREATE TABLE IF NOT EXISTS states (
id INTEGER PRIMARY KEY,
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);
"""
)
""")
conn.commit()
@@ -61,19 +59,51 @@ def state_exists(conn: sqlite3.Connection, name: str) -> bool:
def create_state(
conn: sqlite3.Connection, name: str, root_path: str, created_by_uid: int
conn: sqlite3.Connection,
name: str,
root_path: str,
created_by_uid: int,
*,
commit: bool = True,
) -> int:
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),
)
conn.commit()
if commit:
conn.commit()
return int(cur.lastrowid)
def delete_state(conn: sqlite3.Connection, name: str) -> int:
def delete_state(
conn: sqlite3.Connection, name: str, commit: bool = True
) -> int:
cur = conn.execute("DELETE FROM states WHERE name = ?", (name,))
conn.commit()
if commit:
conn.commit()
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
@@ -88,7 +118,8 @@ class State:
def get_state(conn: sqlite3.Connection, name: str) -> State | None:
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,),
)
row = cur.fetchone()
+37 -21
View File
@@ -27,9 +27,43 @@ def _is_excluded(rel: str, excludes: Iterable[str]) -> bool:
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]:
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):
# prune excluded directories early
rel_dir = (
@@ -56,24 +90,6 @@ def scan_tree(root: Path, excludes: Iterable[str] = ()) -> Iterator[Entry]:
# record dirs and files
for name in list(dirnames) + list(files):
p = Path(dirpath) / name
try:
st = p.lstat() # never follow symlinks
except FileNotFoundError:
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
)
entry = _entry_for_path(p, root)
if entry is not None:
yield entry
Generated
+185 -15
View File
@@ -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]]
name = "argcomplete"
@@ -6,6 +6,7 @@ version = "3.6.3"
description = "Bash tab completion for argparse"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce"},
{file = "argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c"},
@@ -20,31 +21,66 @@ version = "3.5.0"
description = "Validate configuration and produce human readable error messages."
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"},
{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]]
name = "distlib"
version = "0.4.0"
description = "Distribution utilities"
optional = false
python-versions = "*"
groups = ["dev"]
files = [
{file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"},
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
]
[[package]]
name = "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]]
name = "filelock"
version = "3.20.1"
version = "3.20.3"
description = "A platform independent file lock."
optional = false
python-versions = ">=3.10"
groups = ["main", "dev"]
files = [
{file = "filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a"},
{file = "filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c"},
{file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"},
{file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"},
]
[[package]]
@@ -53,6 +89,7 @@ version = "2.6.15"
description = "File identification library for Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"},
{file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"},
@@ -61,12 +98,25 @@ files = [
[package.extras]
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]]
name = "markdown-it-py"
version = "4.0.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{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"},
@@ -90,6 +140,7 @@ version = "0.1.2"
description = "Markdown URL utilities"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
@@ -101,17 +152,31 @@ version = "1.10.0"
description = "Node.js virtual environment builder"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
files = [
{file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"},
{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]]
name = "platformdirs"
version = "4.5.1"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.10"
groups = ["main", "dev"]
files = [
{file = "platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"},
{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)"]
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]]
name = "pre-commit"
version = "3.8.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
{file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
@@ -142,24 +224,50 @@ virtualenv = ">=20.10.0"
[[package]]
name = "pygments"
version = "2.19.2"
version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pytest"
version = "9.1.1"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"},
{file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
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]]
name = "pyyaml"
version = "6.0.3"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
@@ -242,6 +350,7 @@ version = "14.2.0"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
python-versions = ">=3.8.0"
groups = ["main"]
files = [
{file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"},
{file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"},
@@ -254,12 +363,72 @@ pygments = ">=2.13.0,<3.0.0"
[package.extras]
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]]
name = "typing-extensions"
version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
markers = "python_version == \"3.10\""
files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
@@ -267,26 +436,27 @@ files = [
[[package]]
name = "virtualenv"
version = "20.35.4"
version = "20.36.1"
description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b"},
{file = "virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c"},
{file = "virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f"},
{file = "virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba"},
]
[package.dependencies]
distlib = ">=0.3.7,<1"
filelock = ">=3.12.2,<4"
filelock = {version = ">=3.20.1,<4", markers = "python_version >= \"3.10\""}
platformdirs = ">=3.9.1,<5"
typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""}
[package.extras]
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "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]
lock-version = "2.0"
lock-version = "2.1"
python-versions = ">=3.10,<4.0"
content-hash = "4a5c993fcc16fe3739c43eb00bed750ce0803d45e37c7a786aa0b83bb4930267"
content-hash = "e2c2d57a74d31e7a59cc63daae9511890c9e21db043df7a5c8d9ac33e967e393"
+8 -4
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "chguard"
version = "0.2.1"
version = "0.5.0"
description = "Safety-first tool to snapshot and restore filesystem ownership and permissions."
authors = ["Marco D'Aleo <marco@marcodaleo.com>"]
license = "GPL-3.0-or-later"
@@ -12,18 +12,22 @@ repository = "https://git.sysmd.uk/guardutils/chguard"
python = ">=3.10,<4.0"
rich = ">=12"
argcomplete = ">=2"
platformdirs = ">=4.5.1"
filelock = ">=3.20.1"
platformdirs = ">=4.2.2"
filelock = ">=3.15.4"
[tool.poetry.scripts]
chguard = "chguard.cli:main"
[tool.poetry.dev-dependencies]
[tool.poetry.group.dev.dependencies]
pre-commit = "^3.8"
pytest = "^9.1.1"
[tool.black]
line-length = 79
[tool.pytest.ini_options]
testpaths = ["tests"]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
View File
+55
View File
@@ -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()
+111
View File
@@ -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
+76
View File
@@ -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
+105
View File
@@ -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"]
+67
View File
@@ -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"