31 Commits

Author SHA1 Message Date
mdaleo404 f07cbe06c8 Merge pull request 'Add --prune-states + root path fix' (#10) from restore-root-folder into main
Reviewed-on: #10
2026-05-23 10:37:01 +00:00
mdaleo404 4abc89def9 Add prune state option
Lint & Security / precommit-and-security (pull_request) Successful in 2m27s
2026-05-23 11:26:16 +01:00
mdaleo404 5c63675ef5 Record the root path in the state for restoring, make pre-commit black use generic python3 version 2026-05-23 10:28:53 +01:00
mdaleo404 88b2dfd1a5 Merge pull request 'Poetry update' (#7) from poetry-update-2.3.3 into main
Security Scan / security-scan (push) Successful in 1m20s
Reviewed-on: #7
2026-04-03 07:24:44 +00:00
mdaleo404 ee80306d9a Version bump
Lint & Security / precommit-and-security (pull_request) Successful in 1m2s
2026-04-03 08:21:12 +01:00
mdaleo404 cc97d3d7f1 Update Pygments
Lint & Security / precommit-and-security (pull_request) Successful in 1m1s
2026-04-03 08:19:40 +01:00
mdaleo404 9bf5ef1d7d Black formatting
Lint & Security / precommit-and-security (pull_request) Failing after 1m1s
2026-04-03 08:16:56 +01:00
mdaleo404 24696af083 Poetry update
Lint & Security / precommit-and-security (pull_request) Failing after 44s
2026-04-03 08:14:37 +01:00
mdaleo404 fcef549eba Exclude unfixed vulnerabilities from security workflow results
Security Scan / security-scan (push) Successful in 1m17s
2026-03-25 16:31:27 +00:00
mdaleo404 049273a13c Switch Trivy scan to Syft and Grype 2026-03-25 16:10:01 +00:00
mdaleo404 d00407bb33 Disable trivy scan workflow 2026-03-23 08:02:12 +00:00
mdaleo404 a8dbe675f5 Update pre-commit hooks version
Trivy Scan / security-scan (push) Successful in 27s
2026-03-21 07:25:11 +00:00
mdaleo404 cc2b1fe791 Ping Trivy docker image to 0.69.3@sha256:bcc376de8d77cfe086a917230e818dc9f8528e3c852f7b1aff648949b6258d1c 2026-03-21 07:05:33 +00:00
mdaleo404 db60b4e42b Merge pull request 'Fix list view to be a rich table' (#6) from fix_list_view into main
Trivy Scan / security-scan (push) Successful in 27s
Reviewed-on: #6
2026-01-24 09:24:39 +00:00
mdaleo404 afc964a076 Fix list view to be a rich table
Lint & Security / precommit-and-security (pull_request) Successful in 1m50s
2026-01-24 09:21:51 +00:00
mdaleo404 d42f6d5fe1 Merge pull request 'Relax dependencies to match Fedora 42 packages' (#5) from fix_filelock_platformdird_deps into main
Trivy Scan / security-scan (push) Successful in 28s
Reviewed-on: #5
2026-01-17 16:44:06 +00:00
mdaleo404 103d6159d1 Relax dependencies to match Fedora 42 packages
Lint & Security / precommit-and-security (pull_request) Successful in 1m56s
2026-01-17 16:41:08 +00:00
mdaleo404 2ee9588fb9 Update filelock and virtualenv
Trivy Scan / security-scan (push) Successful in 26s
2026-01-15 17:06:08 +00:00
mdaleo404 a2720e245f Change trivy.json output path 2026-01-15 16:47:54 +00:00
mdaleo404 8e3404bd51 Rework the trivy scan job 2026-01-15 16:45:21 +00:00
mdaleo404 5ff1e935a3 Fix trivy scan volume path to be explicit 2026-01-15 16:39:40 +00:00
mdaleo404 7fafb1fa8e Fix trivy scan volume path 2026-01-15 16:36:23 +00:00
mdaleo404 f87eb5f438 Fix trivy scan volume path 2026-01-15 16:22:47 +00:00
mdaleo404 1c3025b2d6 Fix docker command to trivy scan 2026-01-15 16:19:08 +00:00
mdaleo404 4980903572 Fix trivy.json output path 2026-01-15 16:13:48 +00:00
mdaleo404 a7183b3286 Fix trivy.json output path 2026-01-15 16:09:17 +00:00
mdaleo404 984aeccfdd Fix trivy fs path 2026-01-15 16:05:51 +00:00
mdaleo404 dc6e7840c7 Modify severity level on trivy-scan workflow 2026-01-15 15:48:41 +00:00
mdaleo404 0464982b94 Add trivy-scan workflow 2026-01-15 15:41:29 +00:00
mdaleo404 4a4cb8183f Merge pull request 'Add wrapper mode' (#4) from ch_wrapper into main
Reviewed-on: #4
2025-12-30 17:31:45 +00:00
mdaleo404 20a0dca080 Add wrapper mode, update README, version bump 0.3.0
Lint & Security / precommit-and-security (pull_request) Successful in 2m7s
2025-12-30 17:27:15 +00:00
8 changed files with 690 additions and 198 deletions
+188
View File
@@ -0,0 +1,188 @@
name: Security Scan
on:
schedule:
- cron: 27 8 * * *
workflow_dispatch:
jobs:
security-scan:
runs-on: running-man
env:
TARGET_DIR: .
COSIGN_VERSION: v3.0.5
SYFT_VERSION: v1.42.3
GRYPE_VERSION: v0.110.0
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Cosign (bootstrap)
run: |
set -euo pipefail
FILE="cosign-linux-amd64"
curl -fLO https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/${FILE}
chmod +x ${FILE}
mv ${FILE} /usr/local/bin/cosign
cosign version
- name: Install Syft (verified)
run: |
set -euo pipefail
VERSION_NO_V="${SYFT_VERSION#v}"
FILE="syft_${VERSION_NO_V}_linux_amd64.tar.gz"
BASE_URL="https://github.com/anchore/syft/releases/download/${SYFT_VERSION}"
curl -fLO ${BASE_URL}/${FILE}
curl -fLO ${BASE_URL}/syft_${VERSION_NO_V}_checksums.txt
curl -fLO ${BASE_URL}/syft_${VERSION_NO_V}_checksums.txt.sig
curl -fLO ${BASE_URL}/syft_${VERSION_NO_V}_checksums.txt.pem
cosign verify-blob \
--signature syft_${VERSION_NO_V}_checksums.txt.sig \
--certificate syft_${VERSION_NO_V}_checksums.txt.pem \
--certificate-identity-regexp "https://github.com/anchore/syft" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
syft_${VERSION_NO_V}_checksums.txt
CHECKSUM_LINE=$(grep " ${FILE}$" syft_${VERSION_NO_V}_checksums.txt)
if [ -z "$CHECKSUM_LINE" ]; then
echo "Missing checksum entry for ${FILE}"
exit 1
fi
echo "$CHECKSUM_LINE" | sha256sum -c -
tar -xzf ${FILE}
mv syft /usr/local/bin/
syft version
- name: Install Grype (verified)
run: |
set -euo pipefail
VERSION_NO_V="${GRYPE_VERSION#v}"
FILE="grype_${VERSION_NO_V}_linux_amd64.tar.gz"
BASE_URL="https://github.com/anchore/grype/releases/download/${GRYPE_VERSION}"
curl -fLO ${BASE_URL}/${FILE}
curl -fLO ${BASE_URL}/grype_${VERSION_NO_V}_checksums.txt
curl -fLO ${BASE_URL}/grype_${VERSION_NO_V}_checksums.txt.sig
curl -fLO ${BASE_URL}/grype_${VERSION_NO_V}_checksums.txt.pem
cosign verify-blob \
--signature grype_${VERSION_NO_V}_checksums.txt.sig \
--certificate grype_${VERSION_NO_V}_checksums.txt.pem \
--certificate-identity-regexp "https://github.com/anchore/grype" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
grype_${VERSION_NO_V}_checksums.txt
CHECKSUM_LINE=$(grep " ${FILE}$" grype_${VERSION_NO_V}_checksums.txt)
if [ -z "$CHECKSUM_LINE" ]; then
echo "Missing checksum entry for ${FILE}"
exit 1
fi
echo "$CHECKSUM_LINE" | sha256sum -c -
tar -xzf ${FILE}
mv grype /usr/local/bin/
grype version
- name: Generate SBOM
working-directory: ${{ env.TARGET_DIR }}
run: |
syft dir:. -o json > sbom.json
- name: Show SBOM contents
working-directory: ${{ env.TARGET_DIR }}
run: |
echo "Packages discovered by Syft:"
jq -r '.artifacts[] | "\(.name)@\(.version) [\(.type)]"' sbom.json | sort
- name: Run Grype scan (JSON)
id: audit
continue-on-error: true
working-directory: ${{ env.TARGET_DIR }}
run: |
grype sbom:sbom.json -o json > grype.json
echo "Vulnerabilities (fixable only):"
jq -r '
.matches[]
| select((.vulnerability.fix.versions | length) > 0)
| "\(.artifact.name)@\(.artifact.version) -> \(.vulnerability.id) [\(.vulnerability.severity)] | fixed: \(.vulnerability.fix.versions[0])"
' grype.json
# Fail only on fixable MEDIUM/HIGH/CRITICAL
jq -e '
[
.matches[]?
| select(
(
.vulnerability.severity == "Medium" or
.vulnerability.severity == "High" or
.vulnerability.severity == "Critical"
)
and
(
(.vulnerability.fix.versions | length) > 0
)
)
]
| length == 0
' grype.json
- name: Show full Grype table
working-directory: ${{ env.TARGET_DIR }}
run: |
echo "Full Grype report:"
grype sbom:sbom.json -o table
- name: Notify Node-RED on vulnerabilities
if: steps.audit.outcome == 'failure'
working-directory: ${{ env.TARGET_DIR }}
run: |
jq '
{
repo: "guardutils/chguard",
summary: (
"Total: " +
(
[
.matches[]
| select((.vulnerability.fix.versions | length) > 0)
] | length | tostring
)
),
vulnerabilities: [
.matches[]
| select((.vulnerability.fix.versions | length) > 0)
| {
library: .artifact.name,
cve: .vulnerability.id,
severity: .vulnerability.severity,
installed: .artifact.version,
fixed: (.vulnerability.fix.versions[0]),
title: .vulnerability.description,
url: .vulnerability.dataSource
}
]
}
' grype.json \
| curl -s -X POST https://nodered.sysmd.uk/vulns-alert \
-H "Content-Type: application/json" \
--data-binary @-
- name: Fail workflow if vulnerabilities found
if: steps.audit.outcome == 'failure'
run: exit 1
+4 -4
View File
@@ -1,19 +1,19 @@
repos:
- repo: https://github.com/PyCQA/bandit
rev: 1.7.9
rev: 1.9.4
hooks:
- id: bandit
files: ^src/mirro/
args: ["-lll", "-iii", "-s", "B110,B112"]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 25.11.0
rev: 26.3.1
hooks:
- id: black
language_version: python3.13
language_version: python3
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
+55 -1
View File
@@ -30,6 +30,31 @@ A single confirmation prompt at the end of a restore (default: **No**).
### Dry-run mode
Preview restore operations without prompting or applying changes.
### Wrapper mode (automatic snapshots)
`chguard` can also run as a wrapper around ownership and permission commands.
In this mode, `chguard` automatically saves a snapshot before the command runs, so the user can easily restore the previous state if needed.
#### Supported commands
Wrapper mode is intentionally limited to commands that modify filesystem metadata only:
* `chown`
* `chmod`
* `chgrp`
Other commands are rejected to avoid giving a _false sense of protection_.
#### Automatic snapshot names
Snapshots created in wrapper mode are named automatically, for example:
```
auto-20251230-161301
```
Auto-generated snapshots are visually distinguished in the output so they are easy to identify.
### Scope control
Restore:
* both ownership and permissions (default)
@@ -55,7 +80,6 @@ Restore:
It only concerns itself with **ownership** and **permissions**.
## Installation
### From GuardUtils package repo
@@ -179,6 +203,36 @@ chguard --restore app-baseline --permissions
chguard --restore app-baseline --owner
```
### Remove old states
```
chguard --prune-states
```
This removes states older than the number of days set in `CHGUARD_STATES_LIFE`
### Remove states older than _N_ days
```
chguard --prune-states=14
```
This removes all states older than 14 days.
### Remove all states
```
chguard --prune-states=all
```
### Environment Variable
`CHGUARD_STATES_LIFE`controls the default number of days to keep when using `chguard --prune-states`.
### Wrapper mode
Use `--` to separate `chguard` arguments from the wrapped command:
```
chguard -- chown user:group file
chguard -- chmod 755 file
chguard -- chgrp staff file
```
## Privilege model
`chguard` never escalates privileges automatically
+310 -114
View File
@@ -2,32 +2,37 @@ from __future__ import annotations
import argparse
import argcomplete
import grp
import importlib.metadata
import os
import sys
import stat
import pwd
import grp
import stat
import subprocess
import sys
from collections import Counter, defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from datetime import datetime
from rich import box
from rich.console import Console
from rich.table import Table
from rich import box
from chguard.db import (
connect,
init_db,
create_state,
delete_state,
get_state,
init_db,
prune_all_states,
prune_states_before,
state_exists,
)
from chguard.restore import apply_restore, plan_restore
from chguard.scan import scan_tree
from chguard.restore import plan_restore, apply_restore
from chguard.util import normalize_root
PRUNE_STATES_FROM_ENV = "__CHGUARD_PRUNE_STATES_FROM_ENV__"
def get_version():
try:
@@ -37,7 +42,6 @@ def get_version():
def _uid_to_name(uid: int) -> str:
"""Return username for uid, or uid as string if unknown."""
try:
return pwd.getpwuid(uid).pw_name
except KeyError:
@@ -45,7 +49,6 @@ def _uid_to_name(uid: int) -> str:
def _gid_to_name(gid: int) -> str:
"""Return group name for gid, or gid as string if unknown."""
try:
return grp.getgrgid(gid).gr_name
except KeyError:
@@ -53,12 +56,10 @@ def _gid_to_name(gid: int) -> str:
def _format_owner(uid: int, gid: int) -> str:
"""Format uid/gid as username:group."""
return f"{_uid_to_name(uid)}:{_gid_to_name(gid)}"
def _mode_to_rwx(mode: int) -> str:
"""Convert numeric mode to rwx-style permissions."""
bits = (
stat.S_IRUSR,
stat.S_IWUSR,
@@ -88,10 +89,52 @@ def _mode_to_rwx(mode: int) -> str:
def _is_root() -> bool:
"""Return True if running as root."""
return os.geteuid() == 0
def _parse_prune_states_value(value: str | None) -> int | str:
if value is None:
value = os.environ.get("CHGUARD_STATES_LIFE")
if not value:
raise SystemExit(
"Missing prune age. Use --prune-states=N or set "
"CHGUARD_STATES_LIFE."
)
value = value.strip().lower()
if value == "all":
return "all"
try:
days = int(value)
except ValueError as exc:
raise SystemExit(
"Invalid prune age. Use an integer number of days or 'all'."
) from exc
if days < 0:
raise SystemExit("Invalid prune age. Value must be >= 0.")
return days
def _confirm_or_abort(*, yes: bool, prompt: str) -> None:
if yes:
return
if not sys.stdin.isatty():
raise SystemExit(
"Refusing to continue without confirmation (no TTY).\n"
"Use --yes to force."
)
answer = input(f"\n{prompt} (y/N) ").strip().lower()
if answer not in ("y", "yes"):
raise SystemExit("Aborted.")
def complete_state_names(prefix, parsed_args, **kwargs):
try:
conn = connect(
@@ -105,23 +148,40 @@ def complete_state_names(prefix, parsed_args, **kwargs):
return []
def main() -> None:
"""
Entry point for the CLI.
def _extract_paths_from_command(cmd: list[str]) -> list[Path]:
paths = []
for arg in cmd:
if arg.startswith("-"):
continue
p = Path(arg)
if p.exists():
paths.append(p.resolve())
return paths
Behavior summary:
- --save snapshots ownership and permissions
- --restore previews changes, then asks for confirmation
- Root privileges are required only when necessary
- Symlinks are skipped during scanning
"""
def main() -> None:
wrapper_cmd = None
if "--" in sys.argv:
idx = sys.argv.index("--")
wrapper_cmd = sys.argv[idx + 1 :]
sys.argv = sys.argv[:idx]
parser = argparse.ArgumentParser(
prog="chguard",
description="Snapshot and restore filesystem ownership and permissions.",
epilog=(
"Wrapper mode:\n"
" chguard -- chown [OPTIONS] PATH...\n"
" chguard -- chmod [OPTIONS] PATH...\n"
" chguard -- chgrp [OPTIONS] PATH...\n\n"
"In wrapper mode, chguard automatically saves a snapshot of ownership\n"
"and permissions for the affected paths before running the command.\n"
"Only chown, chmod, and chgrp are supported."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
actions = parser.add_mutually_exclusive_group(required=True)
actions = parser.add_mutually_exclusive_group(required=wrapper_cmd is None)
parser.add_argument(
"--version",
@@ -130,75 +190,53 @@ def main() -> None:
)
actions.add_argument(
"--save",
metavar="PATH",
help="Save state for PATH",
"--save", metavar="PATH", help="Save state for PATH"
).completer = argcomplete.FilesCompleter()
actions.add_argument(
"--restore",
action="store_true",
help="Restore a saved state",
"--restore", action="store_true", help="Restore a saved state"
)
actions.add_argument(
"--list", action="store_true", help="List saved states"
)
actions.add_argument(
"--list",
action="store_true",
help="List saved states",
)
actions.add_argument(
"--delete",
metavar="STATE",
help="Delete a saved state",
"--delete", metavar="STATE", help="Delete a saved state"
).completer = complete_state_names
# positional STATE
parser.add_argument(
"state",
actions.add_argument(
"--prune-states",
nargs="?",
help="State name (required with --restore)",
).completer = complete_state_names
const=PRUNE_STATES_FROM_ENV,
metavar="N",
help=(
"Delete states older than N days. If N is omitted, "
"CHGUARD_STATES_LIFE is used. Use 'all' to delete all states."
),
)
parser.add_argument("state", nargs="?", help="State name").completer = (
complete_state_names
)
parser.add_argument("--name", help="State name")
parser.add_argument(
"--name",
help="State name (required with --save)",
"--overwrite", action="store_true", help="Overwrite existing state"
)
parser.add_argument(
"--permissions", action="store_true", help="Restore MODE only"
)
parser.add_argument(
"--owner", action="store_true", help="Restore OWNER only"
)
parser.add_argument(
"--dry-run", action="store_true", help="Preview only; do not apply"
)
parser.add_argument(
"--yes", action="store_true", help="Apply without confirmation"
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Overwrite existing state",
)
parser.add_argument(
"--permissions",
action="store_true",
help="Restore MODE only",
)
parser.add_argument(
"--owner",
action="store_true",
help="Restore OWNER only",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview only; do not apply",
)
parser.add_argument(
"--yes",
action="store_true",
help="Apply without confirmation",
)
parser.add_argument(
"--root",
metavar="PATH",
help="Override restore root",
"--root", metavar="PATH", help="Override restore root"
).completer = argcomplete.FilesCompleter()
parser.add_argument(
@@ -209,31 +247,208 @@ def main() -> None:
).completer = argcomplete.FilesCompleter()
parser.add_argument(
"--db",
metavar="PATH",
help="Override database path",
"--db", metavar="PATH", help="Override database path"
).completer = argcomplete.FilesCompleter()
argcomplete.autocomplete(parser)
args = parser.parse_args()
if wrapper_cmd is not None:
if not wrapper_cmd:
raise SystemExit("No command provided after '--'")
cmd = Path(wrapper_cmd[0]).name
if cmd not in ("chown", "chmod", "chgrp"):
raise SystemExit(
"Wrapper mode only supports chown, chmod, and chgrp"
)
console = Console()
conn = connect(Path(args.db).expanduser().resolve() if args.db else None)
init_db(conn)
if args.list:
if wrapper_cmd:
paths = _extract_paths_from_command(wrapper_cmd)
if paths:
auto_name = f"auto-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
root_path = paths[0].resolve()
with conn:
state_id = create_state(
conn, auto_name, str(root_path), os.getuid(), commit=False
)
for path in paths:
if path.is_dir():
for entry in scan_tree(path):
if entry.uid == 0 and not _is_root():
raise SystemExit(
"This command affects root-owned files.\n"
"Please re-run with sudo."
)
conn.execute(
"""
INSERT INTO entries
(state_id, path, type, mode, uid, gid)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
state_id,
entry.path,
entry.type,
entry.mode,
entry.uid,
entry.gid,
),
)
else:
st = path.lstat()
if st.st_uid == 0 and not _is_root():
raise SystemExit(
"This command affects root-owned files.\n"
"Please re-run with sudo."
)
if stat.S_ISLNK(st.st_mode):
typ = "symlink"
elif stat.S_ISREG(st.st_mode):
typ = "file"
elif stat.S_ISDIR(st.st_mode):
typ = "dir"
else:
continue
rel = (
""
if path.resolve() == root_path
else str(path.resolve().relative_to(root_path))
)
conn.execute(
"""
INSERT INTO entries
(state_id, path, type, mode, uid, gid)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
state_id,
rel,
typ,
stat.S_IMODE(st.st_mode),
st.st_uid,
st.st_gid,
),
)
console.print(
f"Saved pre-command snapshot: [cyan]{auto_name}[/cyan]"
)
proc = subprocess.run(wrapper_cmd)
sys.exit(proc.returncode)
if args.prune_states is not None:
value = (
None
if args.prune_states == PRUNE_STATES_FROM_ENV
else args.prune_states
)
life = _parse_prune_states_value(value)
if life == "all":
rows = conn.execute("""
SELECT name, root_path, created_at
FROM states
ORDER BY created_at
""").fetchall()
cutoff_iso = None
else:
cutoff = datetime.now(timezone.utc) - timedelta(days=life)
cutoff_iso = cutoff.isoformat(timespec="seconds")
rows = conn.execute(
"SELECT name, root_path, created_at FROM states ORDER BY created_at DESC"
"""
SELECT name, root_path, created_at
FROM states
WHERE created_at < ?
ORDER BY created_at
""",
(cutoff_iso,),
).fetchall()
if not rows:
console.print("No states matched.")
return
console.print(
f"\nThe following {len(rows)} state(s) will be deleted:\n"
)
table = Table(box=box.SIMPLE, header_style="bold")
table.add_column("State")
table.add_column("Root path")
table.add_column("Created")
for name, root, created in rows:
state_name = (
f"[bright_cyan]{name}[/bright_cyan]"
if name.startswith("auto-")
else name
)
table.add_row(
state_name,
f"[bright_magenta]{root}[/bright_magenta]",
f"[bright_cyan]{created}[/bright_cyan]",
)
console.print(table)
if args.dry_run:
console.print(
"\n[yellow]Dry-run only. No states were deleted.[/yellow]"
)
return
_confirm_or_abort(yes=args.yes, prompt="Delete these states?")
if life == "all":
deleted = prune_all_states(conn)
else:
deleted = prune_states_before(conn, cutoff_iso)
console.print(f"\nDeleted {deleted} state(s).")
return
if args.list:
rows = conn.execute("""
SELECT name, root_path, created_at
FROM states
ORDER BY created_at DESC
""").fetchall()
if not rows:
console.print("No saved states.")
return
table = Table(box=box.SIMPLE, header_style="bold")
table.add_column("State")
table.add_column("Root path")
table.add_column("Created")
for name, root, created in rows:
dt = datetime.fromisoformat(created)
ts = dt.strftime("%Y-%m-%d %H:%M:%S %z")
console.print(f"{name}\t{root}\t[dim]{ts}[/dim]")
state_name = (
f"[bright_cyan]{name}[/bright_cyan]"
if name.startswith("auto-")
else name
)
table.add_row(
state_name,
f"[bright_magenta]{root}[/bright_magenta]",
f"[bright_cyan]{created}[/bright_cyan]",
)
console.print(table)
return
if args.delete:
@@ -248,22 +463,18 @@ def main() -> None:
root = normalize_root(args.save)
try:
with conn: # start transaction
with conn:
if state_exists(conn, args.name):
if not args.overwrite:
raise SystemExit(
f"State '{args.name}' already exists (use --overwrite)"
)
# if the new save fails, this delete_state step will also roll back
delete_state(conn, args.name, commit=False)
state_id = create_state(
conn, args.name, str(root), os.getuid(), commit=False
)
# Abort early if root-owned files exist and user is not root.
# This prevents creating snapshots that cannot be meaningfully restored.
for entry in scan_tree(root, excludes=args.exclude):
if entry.uid == 0 and not _is_root():
raise SystemExit(
@@ -273,7 +484,8 @@ def main() -> None:
conn.execute(
"""
INSERT INTO entries (state_id, path, type, mode, uid, gid)
INSERT INTO entries
(state_id, path, type, mode, uid, gid)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
@@ -289,9 +501,6 @@ def main() -> None:
console.print(f"Saved state '{args.name}' for {root}")
return
except SystemExit:
raise
if args.restore:
if not args.state:
parser.error("STATE is required with --restore")
@@ -303,7 +512,6 @@ def main() -> None:
snapshot_root = Path(state.root_path)
target_root = normalize_root(args.root) if args.root else snapshot_root
# Default restore behavior is OWNER + MODE unless narrowed explicitly.
restore_permissions = args.permissions or (
not args.permissions and not args.owner
)
@@ -326,7 +534,6 @@ def main() -> None:
needs_root = False
current_uid = os.geteuid()
# Build a per-path view of owner/mode changes and detect privilege needs.
for ch in changes:
if ch.kind not in ("owner", "mode"):
continue
@@ -346,8 +553,11 @@ def main() -> None:
] = f"{_format_owner(bu, bg)}{_format_owner(au, ag)}"
counts["owner"] += 1
if au != current_uid:
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(" -> ")
@@ -376,9 +586,7 @@ def main() -> None:
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", "")
)
console.print(table)
@@ -399,22 +607,10 @@ def main() -> None:
"Please re-run the command with sudo."
)
if not args.yes:
if not sys.stdin.isatty():
raise SystemExit(
"Refusing to apply changes without confirmation (no TTY).\n"
"Use --yes to force."
_confirm_or_abort(
yes=args.yes, prompt="Do you want to restore this state?"
)
answer = (
input("\nDo you want to restore this state? (y/N) ")
.strip()
.lower()
)
if answer not in ("y", "yes"):
console.print("\nAborted.")
return
apply_restore(
root=target_root,
rows=rows,
+29 -7
View File
@@ -4,8 +4,8 @@ import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from platformdirs import user_data_dir
from platformdirs import user_data_dir
APP_NAME = "chguard"
@@ -24,8 +24,7 @@ def connect(db_path: Path | None = None) -> sqlite3.Connection:
def init_db(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
conn.executescript("""
CREATE TABLE IF NOT EXISTS states (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
@@ -46,8 +45,7 @@ def init_db(conn: sqlite3.Connection) -> None:
);
CREATE INDEX IF NOT EXISTS idx_entries_state_id ON entries(state_id);
"""
)
""")
conn.commit()
@@ -69,7 +67,8 @@ def create_state(
commit: bool = True,
) -> int:
cur = conn.execute(
"INSERT INTO states (name, root_path, created_at, created_by_uid) VALUES (?, ?, ?, ?)",
"INSERT INTO states (name, root_path, created_at, created_by_uid) "
"VALUES (?, ?, ?, ?)",
(name, root_path, utc_now_iso(), created_by_uid),
)
if commit:
@@ -86,6 +85,28 @@ def delete_state(
return cur.rowcount
def prune_states_before(
conn: sqlite3.Connection,
cutoff_iso: str,
*,
commit: bool = True,
) -> int:
cur = conn.execute(
"DELETE FROM states WHERE created_at < ?",
(cutoff_iso,),
)
if commit:
conn.commit()
return cur.rowcount
def prune_all_states(conn: sqlite3.Connection, *, commit: bool = True) -> int:
cur = conn.execute("DELETE FROM states")
if commit:
conn.commit()
return cur.rowcount
@dataclass(frozen=True)
class State:
id: int
@@ -97,7 +118,8 @@ class State:
def get_state(conn: sqlite3.Connection, name: str) -> State | None:
cur = conn.execute(
"SELECT id, name, root_path, created_at, created_by_uid FROM states WHERE name = ?",
"SELECT id, name, root_path, created_at, created_by_uid "
"FROM states WHERE name = ?",
(name,),
)
row = cur.fetchone()
+37 -21
View File
@@ -27,9 +27,43 @@ def _is_excluded(rel: str, excludes: Iterable[str]) -> bool:
return False
def _entry_for_path(p: Path, root: Path) -> Entry | None:
try:
st = p.lstat() # never follow symlinks
except FileNotFoundError:
return None
if stat.S_ISDIR(st.st_mode):
typ = "dir"
elif stat.S_ISREG(st.st_mode):
typ = "file"
elif stat.S_ISLNK(st.st_mode):
typ = "symlink"
else:
# skip special files (devices, sockets, fifos) in v0.1
return None
rel = "" if p == root else str(p.relative_to(root))
return Entry(
path=rel,
type=typ,
mode=stat.S_IMODE(st.st_mode),
uid=st.st_uid,
gid=st.st_gid,
)
def scan_tree(root: Path, excludes: Iterable[str] = ()) -> Iterator[Entry]:
root = root.resolve()
root_entry = _entry_for_path(root, root)
if root_entry is not None:
yield root_entry
if not root.is_dir():
return
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
# prune excluded directories early
rel_dir = (
@@ -56,24 +90,6 @@ def scan_tree(root: Path, excludes: Iterable[str] = ()) -> Iterator[Entry]:
# record dirs and files
for name in list(dirnames) + list(files):
p = Path(dirpath) / name
try:
st = p.lstat() # never follow symlinks
except FileNotFoundError:
continue
rel = str(p.relative_to(root))
if stat.S_ISDIR(st.st_mode):
typ = "dir"
elif stat.S_ISREG(st.st_mode):
typ = "file"
elif stat.S_ISLNK(st.st_mode):
typ = "symlink"
else:
# skip special files (devices, sockets, fifos) in v0.1
continue
mode = stat.S_IMODE(st.st_mode)
yield Entry(
path=rel, type=typ, mode=mode, uid=st.st_uid, gid=st.st_gid
)
entry = _entry_for_path(p, root)
if entry is not None:
yield entry
Generated
+31 -15
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand.
[[package]]
name = "argcomplete"
@@ -6,6 +6,7 @@ version = "3.6.3"
description = "Bash tab completion for argparse"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce"},
{file = "argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c"},
@@ -20,6 +21,7 @@ version = "3.5.0"
description = "Validate configuration and produce human readable error messages."
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"},
{file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"},
@@ -31,6 +33,7 @@ version = "0.4.0"
description = "Distribution utilities"
optional = false
python-versions = "*"
groups = ["dev"]
files = [
{file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"},
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
@@ -38,13 +41,14 @@ files = [
[[package]]
name = "filelock"
version = "3.20.1"
version = "3.20.3"
description = "A platform independent file lock."
optional = false
python-versions = ">=3.10"
groups = ["main", "dev"]
files = [
{file = "filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a"},
{file = "filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c"},
{file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"},
{file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"},
]
[[package]]
@@ -53,6 +57,7 @@ version = "2.6.15"
description = "File identification library for Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"},
{file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"},
@@ -67,6 +72,7 @@ version = "4.0.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"},
{file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"},
@@ -90,6 +96,7 @@ version = "0.1.2"
description = "Markdown URL utilities"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
@@ -101,6 +108,7 @@ version = "1.10.0"
description = "Node.js virtual environment builder"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
files = [
{file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"},
{file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"},
@@ -112,6 +120,7 @@ version = "4.5.1"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.10"
groups = ["main", "dev"]
files = [
{file = "platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"},
{file = "platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda"},
@@ -128,6 +137,7 @@ version = "3.8.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
{file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
@@ -142,13 +152,14 @@ virtualenv = ">=20.10.0"
[[package]]
name = "pygments"
version = "2.19.2"
version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
[package.extras]
@@ -160,6 +171,7 @@ version = "6.0.3"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
@@ -242,6 +254,7 @@ version = "14.2.0"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
python-versions = ">=3.8.0"
groups = ["main"]
files = [
{file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"},
{file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"},
@@ -260,6 +273,8 @@ version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
markers = "python_version == \"3.10\""
files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
@@ -267,26 +282,27 @@ files = [
[[package]]
name = "virtualenv"
version = "20.35.4"
version = "20.36.1"
description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b"},
{file = "virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c"},
{file = "virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f"},
{file = "virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba"},
]
[package.dependencies]
distlib = ">=0.3.7,<1"
filelock = ">=3.12.2,<4"
filelock = {version = ">=3.20.1,<4", markers = "python_version >= \"3.10\""}
platformdirs = ">=3.9.1,<5"
typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""}
[package.extras]
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""]
[metadata]
lock-version = "2.0"
lock-version = "2.1"
python-versions = ">=3.10,<4.0"
content-hash = "4a5c993fcc16fe3739c43eb00bed750ce0803d45e37c7a786aa0b83bb4930267"
content-hash = "c1c99dd4ff6557dd08b58f3a8c50b893e68acabcb8b08a48ca78f7319a361635"
+4 -4
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "chguard"
version = "0.2.2"
version = "0.4.0"
description = "Safety-first tool to snapshot and restore filesystem ownership and permissions."
authors = ["Marco D'Aleo <marco@marcodaleo.com>"]
license = "GPL-3.0-or-later"
@@ -12,13 +12,13 @@ repository = "https://git.sysmd.uk/guardutils/chguard"
python = ">=3.10,<4.0"
rich = ">=12"
argcomplete = ">=2"
platformdirs = ">=4.5.1"
filelock = ">=3.20.1"
platformdirs = ">=4.2.2"
filelock = ">=3.15.4"
[tool.poetry.scripts]
chguard = "chguard.cli:main"
[tool.poetry.dev-dependencies]
[tool.poetry.group.dev.dependencies]
pre-commit = "^3.8"
[tool.black]