chguard 0.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
chguard/__init__.py ADDED
File without changes
chguard/cli.py ADDED
@@ -0,0 +1,545 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import argcomplete
5
+ import importlib.metadata
6
+ import os
7
+ import sys
8
+ import stat
9
+ import pwd
10
+ import grp
11
+ import subprocess
12
+ from collections import Counter, defaultdict
13
+ from pathlib import Path
14
+ from datetime import datetime
15
+
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+ from rich import box
19
+
20
+ from chguard.db import (
21
+ connect,
22
+ init_db,
23
+ create_state,
24
+ delete_state,
25
+ get_state,
26
+ state_exists,
27
+ )
28
+ from chguard.scan import scan_tree
29
+ from chguard.restore import plan_restore, apply_restore
30
+ from chguard.util import normalize_root
31
+
32
+
33
+ def get_version():
34
+ try:
35
+ return importlib.metadata.version("chguard")
36
+ except importlib.metadata.PackageNotFoundError:
37
+ return "unknown"
38
+
39
+
40
+ def _uid_to_name(uid: int) -> str:
41
+ try:
42
+ return pwd.getpwuid(uid).pw_name
43
+ except KeyError:
44
+ return str(uid)
45
+
46
+
47
+ def _gid_to_name(gid: int) -> str:
48
+ try:
49
+ return grp.getgrgid(gid).gr_name
50
+ except KeyError:
51
+ return str(gid)
52
+
53
+
54
+ def _format_owner(uid: int, gid: int) -> str:
55
+ return f"{_uid_to_name(uid)}:{_gid_to_name(gid)}"
56
+
57
+
58
+ def _mode_to_rwx(mode: int) -> str:
59
+ bits = (
60
+ stat.S_IRUSR,
61
+ stat.S_IWUSR,
62
+ stat.S_IXUSR,
63
+ stat.S_IRGRP,
64
+ stat.S_IWGRP,
65
+ stat.S_IXGRP,
66
+ stat.S_IROTH,
67
+ stat.S_IWOTH,
68
+ stat.S_IXOTH,
69
+ )
70
+ out = []
71
+ for b in bits:
72
+ if mode & b:
73
+ out.append(
74
+ "r"
75
+ if b in (stat.S_IRUSR, stat.S_IRGRP, stat.S_IROTH)
76
+ else (
77
+ "w"
78
+ if b in (stat.S_IWUSR, stat.S_IWGRP, stat.S_IWOTH)
79
+ else "x"
80
+ )
81
+ )
82
+ else:
83
+ out.append("-")
84
+ return "".join(out)
85
+
86
+
87
+ def _is_root() -> bool:
88
+ return os.geteuid() == 0
89
+
90
+
91
+ def complete_state_names(prefix, parsed_args, **kwargs):
92
+ try:
93
+ conn = connect(
94
+ Path(parsed_args.db).expanduser().resolve()
95
+ if parsed_args.db
96
+ else None
97
+ )
98
+ rows = conn.execute("SELECT name FROM states").fetchall()
99
+ return [name for (name,) in rows if name.startswith(prefix)]
100
+ except Exception:
101
+ return []
102
+
103
+
104
+ def _extract_paths_from_command(cmd: list[str]) -> list[Path]:
105
+ paths = []
106
+ for arg in cmd:
107
+ if arg.startswith("-"):
108
+ continue
109
+ p = Path(arg)
110
+ if p.exists():
111
+ paths.append(p.resolve())
112
+ return paths
113
+
114
+
115
+ def main() -> None:
116
+ """
117
+ Entry point for the CLI.
118
+
119
+ Behavior summary:
120
+ - --save snapshots ownership and permissions
121
+ - --restore previews changes, then asks for confirmation
122
+ - Root privileges are required only when necessary
123
+ - Symlinks are skipped during scanning
124
+ """
125
+
126
+ wrapper_cmd = None
127
+ if "--" in sys.argv:
128
+ idx = sys.argv.index("--")
129
+ wrapper_cmd = sys.argv[idx + 1 :]
130
+ sys.argv = sys.argv[:idx]
131
+
132
+ parser = argparse.ArgumentParser(
133
+ prog="chguard",
134
+ description="Snapshot and restore filesystem ownership and permissions.",
135
+ )
136
+
137
+ parser = argparse.ArgumentParser(
138
+ prog="chguard",
139
+ description="Snapshot and restore filesystem ownership and permissions.",
140
+ epilog=(
141
+ "Wrapper mode:\n"
142
+ " chguard -- chown [OPTIONS] PATH...\n"
143
+ " chguard -- chmod [OPTIONS] PATH...\n"
144
+ " chguard -- chgrp [OPTIONS] PATH...\n\n"
145
+ "In wrapper mode, chguard automatically saves a snapshot of ownership\n"
146
+ "and permissions for the affected paths before running the command.\n"
147
+ "Only chown, chmod, and chgrp are supported."
148
+ ),
149
+ formatter_class=argparse.RawDescriptionHelpFormatter,
150
+ )
151
+
152
+ actions = parser.add_mutually_exclusive_group(required=wrapper_cmd is None)
153
+
154
+ parser.add_argument(
155
+ "--version",
156
+ action="version",
157
+ version=f"chguard {get_version()}",
158
+ )
159
+
160
+ actions.add_argument(
161
+ "--save",
162
+ metavar="PATH",
163
+ help="Save state for PATH",
164
+ ).completer = argcomplete.FilesCompleter()
165
+
166
+ actions.add_argument(
167
+ "--restore",
168
+ action="store_true",
169
+ help="Restore a saved state",
170
+ )
171
+
172
+ actions.add_argument(
173
+ "--list",
174
+ action="store_true",
175
+ help="List saved states",
176
+ )
177
+
178
+ actions.add_argument(
179
+ "--delete",
180
+ metavar="STATE",
181
+ help="Delete a saved state",
182
+ ).completer = complete_state_names
183
+
184
+ # positional STATE
185
+ parser.add_argument(
186
+ "state",
187
+ nargs="?",
188
+ help="State name (required with --restore)",
189
+ ).completer = complete_state_names
190
+
191
+ parser.add_argument(
192
+ "--name",
193
+ help="State name (required with --save)",
194
+ )
195
+
196
+ parser.add_argument(
197
+ "--overwrite",
198
+ action="store_true",
199
+ help="Overwrite existing state",
200
+ )
201
+
202
+ parser.add_argument(
203
+ "--permissions",
204
+ action="store_true",
205
+ help="Restore MODE only",
206
+ )
207
+
208
+ parser.add_argument(
209
+ "--owner",
210
+ action="store_true",
211
+ help="Restore OWNER only",
212
+ )
213
+
214
+ parser.add_argument(
215
+ "--dry-run",
216
+ action="store_true",
217
+ help="Preview only; do not apply",
218
+ )
219
+
220
+ parser.add_argument(
221
+ "--yes",
222
+ action="store_true",
223
+ help="Apply without confirmation",
224
+ )
225
+
226
+ parser.add_argument(
227
+ "--root",
228
+ metavar="PATH",
229
+ help="Override restore root",
230
+ ).completer = argcomplete.FilesCompleter()
231
+
232
+ parser.add_argument(
233
+ "--exclude",
234
+ action="append",
235
+ default=[],
236
+ help="Exclude path prefix",
237
+ ).completer = argcomplete.FilesCompleter()
238
+
239
+ parser.add_argument(
240
+ "--db",
241
+ metavar="PATH",
242
+ help="Override database path",
243
+ ).completer = argcomplete.FilesCompleter()
244
+
245
+ argcomplete.autocomplete(parser)
246
+ args = parser.parse_args()
247
+
248
+ if wrapper_cmd is not None:
249
+ if not wrapper_cmd:
250
+ raise SystemExit("No command provided after '--'")
251
+
252
+ cmd = Path(wrapper_cmd[0]).name
253
+
254
+ if cmd not in ("chown", "chmod", "chgrp"):
255
+ raise SystemExit(
256
+ "Wrapper mode only supports chown, chmod, and chgrp"
257
+ )
258
+
259
+ console = Console()
260
+
261
+ conn = connect(Path(args.db).expanduser().resolve() if args.db else None)
262
+ init_db(conn)
263
+
264
+ if wrapper_cmd:
265
+ paths = _extract_paths_from_command(wrapper_cmd)
266
+ if paths:
267
+ auto_name = f"auto-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
268
+ with conn:
269
+ root_path = str(Path(paths[0]).resolve())
270
+ state_id = create_state(
271
+ conn, auto_name, root_path, os.getuid(), commit=False
272
+ )
273
+
274
+ for path in paths:
275
+ if path.is_dir():
276
+ for entry in scan_tree(path):
277
+ if entry.uid == 0 and not _is_root():
278
+ raise SystemExit(
279
+ "This command affects root-owned files.\n"
280
+ "Please re-run with sudo."
281
+ )
282
+ conn.execute(
283
+ """
284
+ INSERT INTO entries (state_id, path, type, mode, uid, gid)
285
+ VALUES (?, ?, ?, ?, ?, ?)
286
+ """,
287
+ (
288
+ state_id,
289
+ entry.path,
290
+ entry.type,
291
+ entry.mode,
292
+ entry.uid,
293
+ entry.gid,
294
+ ),
295
+ )
296
+ else:
297
+ st = path.lstat()
298
+ if st.st_uid == 0 and not _is_root():
299
+ raise SystemExit(
300
+ "This command affects root-owned files.\n"
301
+ "Please re-run with sudo."
302
+ )
303
+ conn.execute(
304
+ """
305
+ INSERT INTO entries (state_id, path, type, mode, uid, gid)
306
+ VALUES (?, ?, ?, ?, ?, ?)
307
+ """,
308
+ (
309
+ state_id,
310
+ str(path),
311
+ "file",
312
+ stat.S_IMODE(st.st_mode),
313
+ st.st_uid,
314
+ st.st_gid,
315
+ ),
316
+ )
317
+
318
+ console.print(
319
+ f"Saved pre-command snapshot: [cyan]{auto_name}[/cyan]"
320
+ )
321
+
322
+ proc = subprocess.run(wrapper_cmd)
323
+ sys.exit(proc.returncode)
324
+
325
+ if args.list:
326
+ rows = conn.execute(
327
+ "SELECT name, root_path, created_at FROM states ORDER BY created_at DESC"
328
+ ).fetchall()
329
+
330
+ if not rows:
331
+ console.print("No saved states.")
332
+ return
333
+
334
+ table = Table(box=box.SIMPLE, header_style="bold")
335
+
336
+ table.add_column("State")
337
+ table.add_column("Root path")
338
+ table.add_column("Created")
339
+
340
+ for name, root, created in rows:
341
+ dt = datetime.fromisoformat(created)
342
+ ts = dt.strftime("%Y-%m-%d %H:%M:%S %z")
343
+
344
+ state_name = (
345
+ f"[bright_cyan]{name}[/bright_cyan]"
346
+ if name.startswith("auto-")
347
+ else name
348
+ )
349
+ root = f"[bright_magenta]{root}[/bright_magenta]"
350
+ ts = f"[bright_cyan]{created}[/bright_cyan]"
351
+
352
+ table.add_row(
353
+ state_name,
354
+ root,
355
+ ts,
356
+ )
357
+
358
+ console.print(table)
359
+
360
+ if args.delete:
361
+ if delete_state(conn, args.delete) == 0:
362
+ raise SystemExit(f"No such state: {args.delete}")
363
+ console.print(f"Deleted state '{args.delete}'")
364
+ return
365
+
366
+ if args.save:
367
+ if not args.name:
368
+ parser.error("--name is required with --save")
369
+
370
+ root = normalize_root(args.save)
371
+
372
+ try:
373
+ with conn: # start transaction
374
+ if state_exists(conn, args.name):
375
+ if not args.overwrite:
376
+ raise SystemExit(
377
+ f"State '{args.name}' already exists (use --overwrite)"
378
+ )
379
+ # if the new save fails, this delete_state step will also roll back
380
+ delete_state(conn, args.name, commit=False)
381
+
382
+ state_id = create_state(
383
+ conn, args.name, str(root), os.getuid(), commit=False
384
+ )
385
+
386
+ # Abort early if root-owned files exist and user is not root.
387
+ # This prevents creating snapshots that cannot be meaningfully restored.
388
+ for entry in scan_tree(root, excludes=args.exclude):
389
+ if entry.uid == 0 and not _is_root():
390
+ raise SystemExit(
391
+ "This path contains root-owned files.\n"
392
+ "Saving this state requires sudo."
393
+ )
394
+
395
+ conn.execute(
396
+ """
397
+ INSERT INTO entries (state_id, path, type, mode, uid, gid)
398
+ VALUES (?, ?, ?, ?, ?, ?)
399
+ """,
400
+ (
401
+ state_id,
402
+ entry.path,
403
+ entry.type,
404
+ entry.mode,
405
+ entry.uid,
406
+ entry.gid,
407
+ ),
408
+ )
409
+
410
+ console.print(f"Saved state '{args.name}' for {root}")
411
+ return
412
+
413
+ except SystemExit:
414
+ raise
415
+
416
+ if args.restore:
417
+ if not args.state:
418
+ parser.error("STATE is required with --restore")
419
+
420
+ state = get_state(conn, args.state)
421
+ if not state:
422
+ raise SystemExit(f"No such state: {args.state}")
423
+
424
+ snapshot_root = Path(state.root_path)
425
+ target_root = normalize_root(args.root) if args.root else snapshot_root
426
+
427
+ # Default restore behavior is OWNER + MODE unless narrowed explicitly.
428
+ restore_permissions = args.permissions or (
429
+ not args.permissions and not args.owner
430
+ )
431
+ restore_owner = args.owner or (not args.permissions and not args.owner)
432
+
433
+ rows = conn.execute(
434
+ "SELECT path, type, mode, uid, gid FROM entries WHERE state_id = ?",
435
+ (state.id,),
436
+ ).fetchall()
437
+
438
+ changes = plan_restore(
439
+ root=target_root,
440
+ rows=rows,
441
+ restore_permissions=restore_permissions,
442
+ restore_owner=restore_owner,
443
+ )
444
+
445
+ per_path: dict[Path, dict[str, str]] = defaultdict(dict)
446
+ counts = Counter()
447
+ needs_root = False
448
+ current_uid = os.geteuid()
449
+
450
+ # Build a per-path view of owner/mode changes and detect privilege needs.
451
+ for ch in changes:
452
+ if ch.kind not in ("owner", "mode"):
453
+ continue
454
+
455
+ try:
456
+ rel = ch.path.relative_to(target_root)
457
+ except ValueError:
458
+ rel = ch.path
459
+
460
+ if ch.kind == "owner" and restore_owner:
461
+ before, after = ch.detail.split(" -> ")
462
+ bu, bg = map(int, before.split(":"))
463
+ au, ag = map(int, after.split(":"))
464
+
465
+ per_path[rel][
466
+ "owner"
467
+ ] = f"{_format_owner(bu, bg)} → {_format_owner(au, ag)}"
468
+ counts["owner"] += 1
469
+
470
+ if ch.path.stat().st_uid != current_uid:
471
+ needs_root = True
472
+
473
+ elif ch.kind == "mode" and restore_permissions:
474
+ b, a = ch.detail.split(" -> ")
475
+ per_path[rel][
476
+ "mode"
477
+ ] = f"{_mode_to_rwx(int(b, 8))} → {_mode_to_rwx(int(a, 8))}"
478
+ counts["mode"] += 1
479
+
480
+ try:
481
+ if ch.path.stat().st_uid != current_uid:
482
+ needs_root = True
483
+ except FileNotFoundError:
484
+ pass
485
+
486
+ if not per_path:
487
+ console.print("No differences found.")
488
+ return
489
+
490
+ console.print(f"\nRestoring under: {target_root}\n")
491
+
492
+ table = Table(box=box.SIMPLE, header_style="bold")
493
+ table.add_column("Path")
494
+ table.add_column("Owner change", style="cyan")
495
+ table.add_column("Mode change", style="green")
496
+
497
+ for path in sorted(per_path):
498
+ row = per_path[path]
499
+ table.add_row(
500
+ str(path),
501
+ row.get("owner", "—"),
502
+ row.get("mode", "—"),
503
+ )
504
+
505
+ console.print(table)
506
+ console.print(
507
+ f"\nSummary: {counts['mode']} mode change(s), "
508
+ f"{counts['owner']} owner change(s)"
509
+ )
510
+
511
+ if args.dry_run:
512
+ console.print(
513
+ "\n[yellow]Dry-run only. No changes were applied.[/yellow]"
514
+ )
515
+ return
516
+
517
+ if needs_root and not _is_root():
518
+ raise SystemExit(
519
+ "This restore requires elevated privileges.\n"
520
+ "Please re-run the command with sudo."
521
+ )
522
+
523
+ if not args.yes:
524
+ if not sys.stdin.isatty():
525
+ raise SystemExit(
526
+ "Refusing to apply changes without confirmation (no TTY).\n"
527
+ "Use --yes to force."
528
+ )
529
+
530
+ answer = (
531
+ input("\nDo you want to restore this state? (y/N) ")
532
+ .strip()
533
+ .lower()
534
+ )
535
+ if answer not in ("y", "yes"):
536
+ console.print("\nAborted.")
537
+ return
538
+
539
+ apply_restore(
540
+ root=target_root,
541
+ rows=rows,
542
+ restore_permissions=restore_permissions,
543
+ restore_owner=restore_owner,
544
+ )
545
+ console.print("\n[green]Restore complete.[/green]")
chguard/db.py ADDED
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from platformdirs import user_data_dir
8
+
9
+
10
+ APP_NAME = "chguard"
11
+
12
+
13
+ def default_db_path() -> Path:
14
+ base = Path(user_data_dir(APP_NAME))
15
+ base.mkdir(parents=True, exist_ok=True)
16
+ return base / "states.db"
17
+
18
+
19
+ def connect(db_path: Path | None = None) -> sqlite3.Connection:
20
+ path = db_path or default_db_path()
21
+ conn = sqlite3.connect(str(path))
22
+ conn.execute("PRAGMA foreign_keys = ON")
23
+ return conn
24
+
25
+
26
+ def init_db(conn: sqlite3.Connection) -> None:
27
+ conn.executescript(
28
+ """
29
+ CREATE TABLE IF NOT EXISTS states (
30
+ id INTEGER PRIMARY KEY,
31
+ name TEXT UNIQUE NOT NULL,
32
+ root_path TEXT NOT NULL,
33
+ created_at TEXT NOT NULL,
34
+ created_by_uid INTEGER NOT NULL
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS entries (
38
+ state_id INTEGER NOT NULL,
39
+ path TEXT NOT NULL,
40
+ type TEXT NOT NULL,
41
+ mode INTEGER NOT NULL,
42
+ uid INTEGER NOT NULL,
43
+ gid INTEGER NOT NULL,
44
+ PRIMARY KEY (state_id, path),
45
+ FOREIGN KEY (state_id) REFERENCES states(id) ON DELETE CASCADE
46
+ );
47
+
48
+ CREATE INDEX IF NOT EXISTS idx_entries_state_id ON entries(state_id);
49
+ """
50
+ )
51
+ conn.commit()
52
+
53
+
54
+ def utc_now_iso() -> str:
55
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
56
+
57
+
58
+ def state_exists(conn: sqlite3.Connection, name: str) -> bool:
59
+ cur = conn.execute("SELECT 1 FROM states WHERE name = ? LIMIT 1", (name,))
60
+ return cur.fetchone() is not None
61
+
62
+
63
+ def create_state(
64
+ conn: sqlite3.Connection,
65
+ name: str,
66
+ root_path: str,
67
+ created_by_uid: int,
68
+ *,
69
+ commit: bool = True,
70
+ ) -> int:
71
+ cur = conn.execute(
72
+ "INSERT INTO states (name, root_path, created_at, created_by_uid) VALUES (?, ?, ?, ?)",
73
+ (name, root_path, utc_now_iso(), created_by_uid),
74
+ )
75
+ if commit:
76
+ conn.commit()
77
+ return int(cur.lastrowid)
78
+
79
+
80
+ def delete_state(
81
+ conn: sqlite3.Connection, name: str, commit: bool = True
82
+ ) -> int:
83
+ cur = conn.execute("DELETE FROM states WHERE name = ?", (name,))
84
+ if commit:
85
+ conn.commit()
86
+ return cur.rowcount
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class State:
91
+ id: int
92
+ name: str
93
+ root_path: str
94
+ created_at: str
95
+ created_by_uid: int
96
+
97
+
98
+ def get_state(conn: sqlite3.Connection, name: str) -> State | None:
99
+ cur = conn.execute(
100
+ "SELECT id, name, root_path, created_at, created_by_uid FROM states WHERE name = ?",
101
+ (name,),
102
+ )
103
+ row = cur.fetchone()
104
+ if not row:
105
+ return None
106
+ return State(*row)