diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ec9a59d..52a1776 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ repos: 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 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..46829ee --- /dev/null +++ b/DEVELOPMENT.md @@ -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. diff --git a/README.md b/README.md index 2ae5bb2..fda884d 100644 --- a/README.md +++ b/README.md @@ -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**). @@ -65,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 @@ -178,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 ``` @@ -246,7 +250,7 @@ chguard -- chgrp staff file 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 @@ -271,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 +``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..711193a --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/chguard/cli.py b/chguard/cli.py index 8a4d00b..0f84d36 100644 --- a/chguard/cli.py +++ b/chguard/cli.py @@ -27,7 +27,7 @@ from chguard.db import ( prune_states_before, state_exists, ) -from chguard.restore import apply_restore, plan_restore +from chguard.restore import PlannedChange, apply_restore, plan_restore from chguard.scan import scan_tree from chguard.util import normalize_root @@ -270,6 +270,74 @@ def _captured_paths_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: @@ -607,51 +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() + ) - 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 - - try: - if ch.path.stat().st_uid != current_uid: - needs_root = True - except FileNotFoundError: - pass - - 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") @@ -660,19 +695,31 @@ 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] table.add_row( - str(path), row.get("owner", "—"), row.get("mode", "—") + 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]" diff --git a/poetry.lock b/poetry.lock index 433f80a..ca6c57c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.3 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" @@ -27,6 +27,19 @@ files = [ {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" @@ -39,6 +52,25 @@ files = [ {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.3" @@ -66,6 +98,18 @@ 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" @@ -114,6 +158,18 @@ files = [ {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" @@ -131,6 +187,22 @@ 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" @@ -156,7 +228,7 @@ version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, @@ -165,6 +237,30 @@ files = [ [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" @@ -267,6 +363,64 @@ 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" @@ -305,4 +459,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "c1c99dd4ff6557dd08b58f3a8c50b893e68acabcb8b08a48ca78f7319a361635" +content-hash = "e2c2d57a74d31e7a59cc63daae9511890c9e21db043df7a5c8d9ac33e967e393" diff --git a/pyproject.toml b/pyproject.toml index aa71d3c..68bcc9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,10 +20,14 @@ chguard = "chguard.cli:main" [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" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py new file mode 100644 index 0000000..e337363 --- /dev/null +++ b/tests/test_cli_helpers.py @@ -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() diff --git a/tests/test_cli_restore_preview.py b/tests/test_cli_restore_preview.py new file mode 100644 index 0000000..222a5ae --- /dev/null +++ b/tests/test_cli_restore_preview.py @@ -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 diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..743da08 --- /dev/null +++ b/tests/test_db.py @@ -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 diff --git a/tests/test_restore.py b/tests/test_restore.py new file mode 100644 index 0000000..eb5d8f8 --- /dev/null +++ b/tests/test_restore.py @@ -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"] diff --git a/tests/test_scan.py b/tests/test_scan.py new file mode 100644 index 0000000..a1c9eeb --- /dev/null +++ b/tests/test_scan.py @@ -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"