Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c404ea6ea5 | ||
|
|
80e6fee2df | ||
|
|
cfbfcb6f4d | ||
|
|
dc8ce892f8 | ||
|
|
7ef733b36e | ||
|
|
58247bf06f | ||
|
|
b833fe0deb | ||
|
|
626b9e91d8 | ||
|
|
345ed69dc1
|
||
|
|
a317582b72
|
||
|
|
e01c21446f | ||
|
|
0bade103d0 | ||
|
|
1df5a31a7c | ||
|
|
3d3e9dae13
|
||
|
|
f6a04e3166
|
No files matched your search
@@ -77,3 +77,28 @@ jobs:
|
||||
and
|
||||
( [ .dependencies[] | (.vulns // [])[] | select((.fix_versions | length) > 0) ] | length == 0 )
|
||||
' pip-audit.json
|
||||
|
||||
tests:
|
||||
name: tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install Poetry
|
||||
run: pip install "poetry>=2,<3"
|
||||
|
||||
- name: Install project and dependencies
|
||||
run: poetry install --with dev
|
||||
|
||||
- name: Run tests
|
||||
run: poetry run pytest
|
||||
@@ -77,3 +77,28 @@ jobs:
|
||||
and
|
||||
( [ .dependencies[] | (.vulns // [])[] | select((.fix_versions | length) > 0) ] | length == 0 )
|
||||
' pip-audit.json
|
||||
|
||||
tests:
|
||||
name: tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install Poetry
|
||||
run: pip install "poetry>=2,<3"
|
||||
|
||||
- name: Install project and dependencies
|
||||
run: poetry install --with dev
|
||||
|
||||
- name: Run tests
|
||||
run: poetry run pytest
|
||||
+3
-2
@@ -316,7 +316,7 @@ The wrapper snapshot flow is:
|
||||
wrapper_cmd
|
||||
-> _extract_paths_from_command()
|
||||
-> _common_snapshot_root()
|
||||
-> create auto-YYYYMMDD-HHMMSS state
|
||||
-> create a unique auto-YYYYMMDD-HHMMSS[-N] state
|
||||
-> _iter_entries_for_target() for each path
|
||||
-> insert each relative path once
|
||||
-> run subprocess.run(wrapper_cmd)
|
||||
@@ -798,7 +798,8 @@ The list of `PlannedChange` objects produced before any mutation occurs.
|
||||
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`.
|
||||
A wrapper-created state named like `auto-YYYYMMDD-HHMMSS`, with a numeric
|
||||
suffix when needed to keep same-second snapshots unique.
|
||||
|
||||
**Owner restore**
|
||||
Restoring numeric uid/gid with `os.chown(..., follow_symlinks=False)`.
|
||||
|
||||
@@ -57,6 +57,8 @@ auto-20251230-161301
|
||||
```
|
||||
|
||||
Auto-generated snapshots are visually distinguished in the output so they are easy to identify.
|
||||
Snapshots created during the same second use a numeric suffix to remain unique,
|
||||
for example `auto-20251230-161301-1`.
|
||||
|
||||
### Scope control
|
||||
Restore:
|
||||
@@ -85,7 +87,7 @@ It only concerns itself with **ownership** and **permissions**.
|
||||
|
||||
## Installation
|
||||
|
||||
### From GuardUtils package repo
|
||||
### From the SysMD package repository
|
||||
|
||||
This is the preferred method of installation.
|
||||
|
||||
@@ -95,7 +97,7 @@ This is the preferred method of installation.
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /usr/share/keyrings
|
||||
curl -fsSL https://repo.sysmd.uk/guardutils/guardutils.gpg | sudo gpg --dearmor -o /usr/share/keyrings/guardutils.gpg
|
||||
curl -fsSL https://repo.sysmd.uk/sysmd/sysmd.gpg | sudo gpg --dearmor -o /usr/share/keyrings/sysmd.gpg
|
||||
```
|
||||
|
||||
The GPG fingerprint is `0032C71FA6A11EF9567D4434C5C06BD4603C28B1`.
|
||||
@@ -103,7 +105,7 @@ The GPG fingerprint is `0032C71FA6A11EF9567D4434C5C06BD4603C28B1`.
|
||||
#### 2) Add the APT source
|
||||
|
||||
```bash
|
||||
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/guardutils.gpg] https://repo.sysmd.uk/guardutils/debian stable main" | sudo tee /etc/apt/sources.list.d/guardutils.list
|
||||
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/sysmd.gpg] https://repo.sysmd.uk/sysmd/debian stable main" | sudo tee /etc/apt/sources.list.d/sysmd.list
|
||||
```
|
||||
|
||||
#### 3) Update and install
|
||||
@@ -118,20 +120,20 @@ sudo apt install chguard
|
||||
#### 1) Import the GPG key
|
||||
|
||||
```
|
||||
sudo rpm --import https://repo.sysmd.uk/guardutils/guardutils.gpg
|
||||
sudo rpm --import https://repo.sysmd.uk/sysmd/sysmd.gpg
|
||||
```
|
||||
|
||||
#### 2) Add the repository configuration
|
||||
|
||||
```
|
||||
sudo tee /etc/yum.repos.d/guardutils.repo > /dev/null << 'EOF'
|
||||
[guardutils]
|
||||
name=GuardUtils Repository
|
||||
baseurl=https://repo.sysmd.uk/guardutils/rpm/$basearch
|
||||
sudo tee /etc/yum.repos.d/sysmd.repo > /dev/null << 'EOF'
|
||||
[sysmd]
|
||||
name=SysMD Repository
|
||||
baseurl=https://repo.sysmd.uk/sysmd/rpm/$basearch
|
||||
enabled=1
|
||||
gpgcheck=1
|
||||
repo_gpgcheck=1
|
||||
gpgkey=https://repo.sysmd.uk/guardutils/guardutils.gpg
|
||||
gpgkey=https://repo.sysmd.uk/sysmd/sysmd.gpg
|
||||
EOF
|
||||
```
|
||||
|
||||
|
||||
+25
-3
@@ -6,6 +6,7 @@ import grp
|
||||
import importlib.metadata
|
||||
import os
|
||||
import pwd
|
||||
import sqlite3
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -166,6 +167,28 @@ def _common_snapshot_root(paths: list[Path]) -> Path:
|
||||
return Path(os.path.commonpath([str(p.resolve()) for p in paths]))
|
||||
|
||||
|
||||
def _create_auto_state(
|
||||
conn, root_path: Path, created_by_uid: int
|
||||
) -> tuple[int, str]:
|
||||
base_name = f"auto-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
||||
suffix = 0
|
||||
|
||||
while True:
|
||||
auto_name = base_name if suffix == 0 else f"{base_name}-{suffix}"
|
||||
try:
|
||||
state_id = create_state(
|
||||
conn,
|
||||
auto_name,
|
||||
str(root_path),
|
||||
created_by_uid,
|
||||
commit=False,
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
suffix += 1
|
||||
else:
|
||||
return state_id, auto_name
|
||||
|
||||
|
||||
def _type_for_mode(mode: int) -> str | None:
|
||||
if stat.S_ISDIR(mode):
|
||||
return "dir"
|
||||
@@ -451,12 +474,11 @@ def main() -> None:
|
||||
paths = _extract_paths_from_command(wrapper_cmd)
|
||||
|
||||
if paths:
|
||||
auto_name = f"auto-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
||||
root_path = _common_snapshot_root(paths)
|
||||
|
||||
with conn:
|
||||
state_id = create_state(
|
||||
conn, auto_name, str(root_path), os.getuid(), commit=False
|
||||
state_id, auto_name = _create_auto_state(
|
||||
conn, root_path, os.getuid()
|
||||
)
|
||||
|
||||
seen_paths: set[str] = set()
|
||||
|
||||
Generated
+20
-25
@@ -1,15 +1,15 @@
|
||||
# This file is automatically @generated by Poetry 2.5.0 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"
|
||||
version = "3.6.3"
|
||||
version = "3.7.2"
|
||||
description = "Bash tab completion for argparse"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.10"
|
||||
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"},
|
||||
{file = "argcomplete-3.7.2-py3-none-any.whl", hash = "sha256:6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19"},
|
||||
{file = "argcomplete-3.7.2.tar.gz", hash = "sha256:aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@@ -54,14 +54,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "4.0.8"
|
||||
version = "4.1.0"
|
||||
description = "A platform independent file lock."
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "filelock-4.0.8-py3-none-any.whl", hash = "sha256:325ff22f358c18443b1fcdfa0a7aa3faec4b500c2807c554719da4567b533d31"},
|
||||
{file = "filelock-4.0.8.tar.gz", hash = "sha256:733d9b6b153fc63672f86104324186818b6bbe9dd7db84e9bb9887b6a04a2775"},
|
||||
{file = "filelock-4.1.0-py3-none-any.whl", hash = "sha256:2ce9818e3e2d8f284c1a964414447ef148d42a5fd5e2a477a7118e574b293ec1"},
|
||||
{file = "filelock-4.1.0.tar.gz", hash = "sha256:ad7f724afef953e731b1cc39bcd3a09166d72ed7fcdf29e6e88b1c3235c6715d"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -153,21 +153,16 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.5.1"
|
||||
version = "4.12.4"
|
||||
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"},
|
||||
{file = "platformdirs-4.12.4-py3-none-any.whl", hash = "sha256:78bfb9db2a8471ed7eebe3c3c932da413911042994e699b384fbb4493fa872d7"},
|
||||
{file = "platformdirs-4.12.4.tar.gz", hash = "sha256:63743c02414e755de4e31b8f68125c1407495b86c5a006e203c01ff8b9924250"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"]
|
||||
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"
|
||||
@@ -186,14 +181,14 @@ testing = ["coverage", "pytest", "pytest-benchmark"]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "3.8.0"
|
||||
version = "4.6.2"
|
||||
description = "A framework for managing and maintaining multi-language pre-commit hooks."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.10"
|
||||
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"},
|
||||
{file = "pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e"},
|
||||
{file = "pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -340,14 +335,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.2.0"
|
||||
version = "15.0.0"
|
||||
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
|
||||
optional = false
|
||||
python-versions = ">=3.8.0"
|
||||
python-versions = ">=3.9.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"},
|
||||
{file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"},
|
||||
{file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -379,4 +374,4 @@ python-discovery = ">=1.6"
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11,<4.0"
|
||||
content-hash = "16fd1da8878d300a16fcff44591a79e0b90041b844eba6c976a53766a3fd5ec3"
|
||||
content-hash = "b7038c1e7741ec33308a455f42c3dc2e9430365483f42a33b2ef07cdcd819805"
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "chguard"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
description = "Safety-first tool to snapshot and restore filesystem ownership and permissions."
|
||||
authors = [{ name = "Marco D'Aleo", email = "marco@marcodaleo.com" }]
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -21,7 +21,7 @@ Repository = "https://git.sysmd.uk/guardutils/chguard"
|
||||
chguard = "chguard.cli:main"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pre-commit = "^3.8"
|
||||
pre-commit = "^4.0.0"
|
||||
pytest = "^9.1.1"
|
||||
|
||||
[tool.black]
|
||||
|
||||
@@ -2,9 +2,13 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import chguard.cli as cli
|
||||
from chguard.cli import _restore_preview_rows, main
|
||||
from chguard.db import connect, create_state, init_db
|
||||
from chguard.restore import PlannedChange
|
||||
@@ -109,3 +113,52 @@ def test_restore_with_only_skipped_items_does_not_prompt(
|
||||
output = capsys.readouterr().out
|
||||
assert "missing path" in output
|
||||
assert "No applicable changes" in output
|
||||
|
||||
|
||||
def test_wrapper_uses_unique_auto_names_within_same_second(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
db_path = tmp_path / "states.db"
|
||||
target = tmp_path / "target"
|
||||
target.write_text("data", encoding="utf-8")
|
||||
|
||||
class FixedDatetime:
|
||||
@classmethod
|
||||
def now(cls) -> datetime:
|
||||
return datetime(2026, 10, 10, 0, 10, 48)
|
||||
|
||||
monkeypatch.setattr(cli, "datetime", FixedDatetime)
|
||||
monkeypatch.setattr(
|
||||
cli.subprocess,
|
||||
"run",
|
||||
lambda command: SimpleNamespace(returncode=0),
|
||||
)
|
||||
|
||||
for _ in range(2):
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"chguard",
|
||||
"--db",
|
||||
str(db_path),
|
||||
"--",
|
||||
"chmod",
|
||||
"600",
|
||||
str(target),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
conn = connect(db_path)
|
||||
rows = conn.execute("SELECT name FROM states ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
|
||||
assert rows == [
|
||||
("auto-20261010-001048",),
|
||||
("auto-20261010-001048-1",),
|
||||
]
|
||||
Reference in new issue
Block a user