# 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 Black, trailing whitespace, EOF, YAML, and TOML checks. The Bandit hook exists, but its `files` pattern is `^src/mirro/`, which does not match the current `chguard/` package layout. If relying on Bandit for this project, fix that pattern. --- ## 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.