63 Commits
Author SHA1 Message Date
mdaleo404 0613ee4f7f Reject directories as edit targets 2026-06-28 07:38:04 +01:00
mdaleo404 c58907dcc7 Add DEVELOPMENT.md and SECURITY.md 2026-06-28 07:34:15 +01:00
mdaleo404 c411e6240a Loosen up pre-commit Black python version 2026-06-24 13:35:26 +01:00
mdaleo404 d12ffbf251 Merge pull request 'Update pytest to 9.0.3 and pygments to 2.20.0' (#18) from pytest-pygments-update into main
Security Scan / security-scan (push) Successful in 1m23s
Reviewed-on: #18
2026-04-14 10:23:11 +00:00
mdaleo404 b674b64daf Update pytest to 9.0.3 and pygments to 2.20.0, mirro version bump
Lint & Security / precommit-and-security (pull_request) Successful in 1m0s
2026-04-14 11:21:13 +01:00
mdaleo404 d27bc37653 Merge pull request 'Poetry update' (#17) from poetry-update-2.3.3 into main
Security Scan / security-scan (push) Failing after 1m24s
Reviewed-on: #17
2026-04-03 07:13:28 +00:00
mdaleo404 47567e40e9 Black formatting
Lint & Security / precommit-and-security (pull_request) Successful in 1m0s
2026-04-03 08:11:42 +01:00
mdaleo404 bc94ac14bf Poetry update
Lint & Security / precommit-and-security (pull_request) Failing after 43s
2026-04-03 08:03:22 +01:00
mdaleo404 909c4eb6f8 Exclude unfixed vulnerabilities from security workflow results
Security Scan / security-scan (push) Successful in 1m18s
2026-03-25 16:36:25 +00:00
mdaleo404 95a989b770 Switch Trivy scan to Syft and Grype 2026-03-25 16:16:12 +00:00
mdaleo404 5ee910f3ad Disable trivy scan workflow 2026-03-23 08:02:54 +00:00
mdaleo404 6c166c53c8 Update pre-commit hooks version
Trivy Scan / security-scan (push) Successful in 27s
2026-03-21 07:22:43 +00:00
mdaleo404 f15738006e Ping Trivy docker image to 0.69.3@sha256:bcc376de8d77cfe086a917230e818dc9f8528e3c852f7b1aff648949b6258d1c 2026-03-21 07:03:47 +00:00
mdaleo404 7a680eaab2 Update filelock and virtualenv
Trivy Scan / security-scan (push) Successful in 26s
2026-01-15 17:03:50 +00:00
mdaleo404 12d6f5fd2f Add trivy-scan workflow 2026-01-15 16:50:56 +00:00
mdaleo404 07d31d6f83 Merge pull request 'Make pip-audit run inside Poetry' (#16) from pip_audit_tweak into main
Reviewed-on: #16
2025-12-25 10:28:13 +00:00
mdaleo404 65d2cd7fee Make pip-audit run inside Poetry
Lint & Security / precommit-and-security (pull_request) Successful in 56s
2025-12-25 10:26:46 +00:00
mdaleo404 46e83e3e48 Add logo file, update README 2025-12-21 08:47:55 +00:00
mdaleo404 3e1b2e5488 Merge pull request 'Add restore feature' (#15) from add_restore_feature into main
Reviewed-on: #15
2025-12-15 17:25:29 +00:00
mdaleo404 9adbb74602 Update README, version bump 0.6.0
Lint & Security / precommit-and-security (pull_request) Successful in 48s
2025-12-15 17:23:08 +00:00
mdaleo404 537397ac36 Update tests with --restore flag 2025-12-15 17:19:17 +00:00
mdaleo404 0dc9b827f9 Add restore (any) feature 2025-12-15 17:18:40 +00:00
mdaleo404 bf0dfac0c4 Fix repository's URL to point ad Gitea 2025-12-14 16:41:37 +00:00
mdaleo404 74db589391 Fix installation instructions 2025-12-09 16:14:17 +00:00
mdaleo404 cc764403e3 Merge pull request 'Update README and pyproject.toml' (#14) from update_mirro_20251209 into main
Reviewed-on: #14
2025-12-09 15:21:42 +00:00
mdaleo404 e66b5d95e9 Edit badges, update installation instructions, swap github.com entries to git.sysmd.uk
Lint & Security / precommit-and-security (pull_request) Successful in 47s
2025-12-09 15:20:06 +00:00
mdaleo404 b23ca5573e Merge pull request 'Rename .github folder to .gitea. Use pre-commit directly instead of action' (#13) from rename_github_folder into main
Reviewed-on: #13
2025-12-09 13:18:47 +00:00
mdaleo404 b930b4239e Rename .github folder to .gitea. Use pre-commit directly instead of action
Lint & Security / precommit-and-security (pull_request) Successful in 49s
2025-12-09 13:12:36 +00:00
Marco D'AleoandGitHub 4f681851ac Merge pull request #12 from guardutils/update_mirro_20251204
Add --status flag, TAB completion with argcomplete, update README, update tests
2025-12-04 15:12:37 +00:00
mdaleo404 e48adddd4c Add --status flag, TAB completion with argcomplete, update README, update tests 2025-12-04 15:10:03 +00:00
mdaleo404 d2fff45db7 Update badges URLs 2025-11-29 16:41:47 +00:00
Marco D'AleoandGitHub b2af78e643 Merge pull request #11 from guardutils/update_mirro_20251127
Switch ownership from mdaleo404 to guardutils in README and pyproject
2025-11-27 17:52:53 +00:00
mdaleo404 ea7c1384a0 Switch ownership from mdaleo404 to guardutils in README and pyproject 2025-11-27 17:51:27 +00:00
Marco D'AleoandGitHub e88a248463 Merge pull request #10 from mdaleo404/add_badges_to_readme
Add badges to README
2025-11-23 07:35:19 +00:00
mdaleo404 755b69ce99 Add badges to README 2025-11-23 07:34:15 +00:00
Marco D'AleoandGitHub 9fa7fc1c52 Update README.md
Fix package name in README
2025-11-21 18:07:54 +00:00
Marco D'AleoandGitHub d982509acb Merge pull request #9 from mdaleo404/update_mirro_20251121
Update mirro 20251121
2025-11-21 17:19:15 +00:00
mdaleo404 e66bdc9a8f Update tests 2025-11-21 17:17:38 +00:00
mdaleo404 598a71dcc8 Package version bump to 0.4.0 2025-11-21 17:07:44 +00:00
mdaleo404 385d721155 Add diff functionality 2025-11-21 17:04:59 +00:00
mdaleo404 1cb0bca865 Fix accidental removal of shebangs when restoring a script 2025-11-21 13:54:42 +00:00
Marco D'AleoandGitHub 6dbadc476a Merge pull request #8 from mdaleo404/update_readme_with_copr_ppa
Update README with new installation methods
2025-11-17 19:06:05 +00:00
mdaleo404 5f9d8a81d4 Update README with new installation methods 2025-11-17 19:03:35 +00:00
mdaleo404 60ff38e207 Rename workflow and make it trigger on pull requests 2025-11-17 15:12:01 +00:00
Marco D'AleoandGitHub 19f285db9c Merge pull request #7 from mdaleo404/update_tests_new_functionalities
Update tests for mirro's new flags
2025-11-16 15:03:17 +00:00
mdaleo404 8cf2a5f1ac Update tests for mirro's new flags 2025-11-16 15:00:44 +00:00
Marco D'AleoandGitHub 7cb2c3adb2 Merge pull request #6 from mdaleo404/update_mirro_20251116
Add more functionalities:
- Add new flags and their respective functionalities: --list, --restore-last, --prune-
- Change Python dependecies version
- Remove Black target-version from pyproject
- Mirro version bump to 0.3.0
- Update README
2025-11-16 14:46:29 +00:00
mdaleo404 a0d6acfa8a Remove CI workflow trigger 'pull_request' 2025-11-16 14:44:08 +00:00
mdaleo404 79473eb05a Update README with mirro's new functionalities 2025-11-16 14:39:40 +00:00
mdaleo404 fde29fe90d Add new flags and their respective functionalities: --list, --restore-last, --prune-backups. Change Python dependecies version. Remove Black target-version from pyproject.toml. Mirro version bump. 2025-11-16 14:17:11 +00:00
mdaleo404 a068c0a5bd Fix pyproject group for dev-dependencies, remove .bak from backed up file 2025-11-16 09:40:26 +00:00
mdaleo404 27b9039ddd Add more checks on pre-commit-config, add CI workflow 2025-11-16 06:59:02 +00:00
Marco D'AleoandGitHub e347b12d94 Merge pull request #5 from mdaleo404/remove_dev_dependencies
Remove bandit and black from pyproject.toml
2025-11-15 17:00:13 +00:00
mdaleo404 e213edf7b2 Remove bandit and black from pyproject.toml 2025-11-15 16:59:42 +00:00
Marco D'AleoandGitHub 695a0d33e1 Merge pull request #4 from mdaleo404/update_mirro_20251115
Add pre-commit framework and hooks config, update README
- black
- bandit
- trailing-whitespace
- end-of-file-fixer
2025-11-15 08:24:52 +00:00
mdaleo404 2b24d252a1 Add pre-commit framework and hooks config, update README 2025-11-15 08:23:20 +00:00
Marco D'AleoandGitHub 7e989355b9 Merge pull request #3 from mdaleo404/update_mirro_20251113
Improvements and tests
2025-11-13 16:27:56 +00:00
mdaleo404 59a3789ff5 Fix typo in README 2025-11-13 16:26:42 +00:00
mdaleo404 0fe9045f54 Black formatting 2025-11-13 16:25:10 +00:00
mdaleo404 80129d6bc5 Update README with tests and flags coverage 2025-11-13 16:22:59 +00:00
mdaleo404 863b1b3a21 Add logic to handle editor specific flags dynamically, add --version flag, edit .gitignore, add tests 2025-11-13 16:14:17 +00:00
mdaleo404 b829faf658 Edit README 2025-11-11 17:36:55 +00:00
mdaleo404 16ad34c953 Add CODEOWNERS file 2025-11-11 17:33:41 +00:00
13 changed files with 2778 additions and 27 deletions
+1
View File
@@ -0,0 +1 @@
* @mdaleo404
+36
View File
@@ -0,0 +1,36 @@
name: Lint & Security
on:
pull_request:
jobs:
precommit-and-security:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install pre-commit
run: pip install pre-commit
- name: Run pre-commit hooks
run: pre-commit run --all-files --color always
- name: Install Poetry
run: |
pip install poetry
poetry self add poetry-plugin-export
- name: Install pip-audit
run: pip install pip-audit
- name: Audit dependencies (Poetry lockfile)
run: |
poetry export -f requirements.txt --without-hashes \
| pip-audit -r /dev/stdin
+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/mirro",
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
+2 -1
View File
@@ -1,3 +1,4 @@
__pycache__ __pycache__
.pytest_cache .pytest_cache
dist dist
.coverage
+21
View File
@@ -0,0 +1,21 @@
repos:
- repo: https://github.com/PyCQA/bandit
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: 26.3.1
hooks:
- id: black
language_version: python3
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml
+616
View File
@@ -0,0 +1,616 @@
# mirro Development Guide
Interested in the internals of mirro?
This guide describes the current `mirro` codebase for maintainers. It focuses on how the project is organised, what the command does, how backup and restore data flows through the CLI, and which invariants matter when changing the code.
---
## 1. What mirro does
`mirro` is a small safety-first command-line wrapper for editing text files.
Its main edit pipeline is:
```text
target file
|
| mirro FILE
v
temporary editable copy
|
| $EDITOR exits
v
content comparison
|
| changed only
v
timestamped backup of original content
|
v
overwrite target with edited content
```
`mirro` deliberately keeps a narrow scope. It manages text content backups around manual editor sessions. It is not a filesystem snapshotter, a version-control system, a transactional editor, a privilege escalation tool, or a sandbox.
The command also supports backup inspection and maintenance flows:
```text
mirro --list list files in the backup directory
mirro --restore-last FILE restore newest backup matching FILE's basename
mirro --restore BACKUP restore backup to path from its header
mirro --prune-backups[=N|all] remove old or all backup files
mirro --diff FILE BACKUP show unified diff from backup to current file
mirro --status show current-directory files with backup history
```
---
## 2. Repository layout
The project is a single Python package under `src/mirro/`.
```text
src/mirro/
__init__.py package marker
main.py argparse CLI and all current runtime behavior
tests/
test_mirro.py pytest coverage for helpers and CLI branches
pyproject.toml Poetry package metadata and console script
poetry.lock locked dependency graph
README.md user-facing documentation
LICENCE GPL-3.0-or-later licence text
.pre-commit-config.yaml Bandit, Black, and generic pre-commit hooks
.gitea/workflows/ lint, dependency audit, SBOM, and Grype workflows
dist/ built release artifacts, not source
mirro.png README logo
```
The installed command is configured in `pyproject.toml`:
```toml
[tool.poetry.scripts]
mirro = "mirro.main:main"
```
There is no `src/mirro/__main__.py` at the time of writing, so `python -m mirro` is not the supported entry point. Use the installed `mirro` command or `poetry run mirro` during development.
---
## 3. Main runtime flows
All user-facing behavior enters through `mirro.main.main()`.
```text
mirro command
-> mirro.main.main()
-> build argparse parser
-> install argcomplete hook
-> parse known args, leaving editor/file positionals untouched
-> dispatch to diff, list, status, restore-last, restore, prune, or edit flow
```
The current implementation keeps parsing, filesystem operations, editor invocation, backup formatting, restore behavior, pruning, and display in one file. That is acceptable for the current size, but new behavior should avoid making `main.py` harder to reason about. If a feature grows beyond a few focused helpers, consider extracting it into a small module with tests.
### 3.1 CLI dispatch order
Dispatch order matters because each branch returns before later behavior runs:
```text
--diff
--list
--status
--restore-last
--restore
--prune-backups
normal edit flow
```
If a future option can be combined with other options, decide whether it should be a global modifier or an action branch. Most existing options are action branches.
### 3.2 Normal edit flow
The normal edit flow is the core product behavior:
```text
parse positional arguments
-> choose first non-option/non-+ argument as target file
-> pass remaining positionals to editor
-> read $EDITOR, defaulting to nano
-> resolve target and backup directory
-> check write access to target or parent directory
-> read existing target, or prepopulate a new file message
-> write content to a temporary file
-> run editor without a shell
-> read edited temp file
-> delete temp file
-> compare edited content to original content
-> if unchanged, print "file hasn't changed"
-> if changed, back up original content and overwrite target
```
The implementation reads and writes text as UTF-8:
```python
path.read_text(encoding="utf-8", errors="replace")
path.write_text(content, encoding="utf-8")
```
That means invalid input bytes are replaced during reads and rewritten as UTF-8 if the file is saved. `mirro` is intended for text files, not arbitrary binary files.
### 3.3 Editor argument handling
`main()` parses known `mirro` options and preserves unknown positional arguments for the editor. In normal edit mode, the first positional argument that does not start with `-` or `+` is treated as the target file; the rest are editor extras.
The editor command comes from:
```text
$EDITOR, or nano when unset
```
It is split with `editor.split()` and executed with `subprocess.call()` without `shell=True`.
For `nano`, extras are placed before the temporary path:
```text
nano EXTRA... TEMP
```
For other editors, extras are placed after the temporary path:
```text
editor TEMP EXTRA...
```
Be careful when changing this. Editor option order differs across editors, and the current behavior is intentionally permissive rather than a full editor-specific parser.
---
## 4. Backup storage
The default backup directory is:
```text
~/.local/share/mirro
```
Under `sudo`, `Path.home()` normally resolves to root's home, so the default becomes:
```text
/root/.local/share/mirro
```
Users can override it with:
```bash
mirro --backup-dir /path/to/backups FILE
```
Backups are plain text files named with the original basename and a UTC timestamp:
```text
filename.ext.orig.YYYYMMDDTHHMMSS
```
The backup content starts with a mirro header:
```text
# ---------------------------------------------------
# mirro backup
# Original file: /path/to/original
# Timestamp: 2025-11-10 17:44:00 UTC
# Delete this header if you want to restore the file
# ---------------------------------------------------
original file content follows here
```
The original path in the header is used by `--restore BACKUP` to decide where to write restored content.
### 4.1 Backup creation
`backup_original(original_path, original_content, backup_dir)` owns backup file creation.
It currently:
```text
creates backup_dir if needed
uses UTC timestamps
writes a text header
writes original_content after the header
returns the backup path
```
There is no database or manifest. The backup filename and header are the persistence format.
### 4.2 Backup matching limits
Several features match backups by basename:
```text
--restore-last FILE matches target.name + ".orig."
--diff FILE BACKUP requires backup name to start with target.name + ".orig."
--status maps backups to current-directory files by basename
```
This is simple and predictable, but two files with the same basename in different directories share the same backup-name prefix in a single backup directory. `--restore BACKUP` is more specific because it reads the original path from the backup header.
### 4.3 Timestamp collisions
Backup filenames have one-second timestamp resolution. If the same basename is backed up twice in the same backup directory during the same second, the later backup can reuse the same path.
If this becomes a practical problem, fix it by making backup names unique while preserving the existing readable prefix format.
---
## 5. Restore, diff, status, and prune behavior
### 5.1 `--restore-last FILE`
Restore-last flow:
```text
resolve backup directory
resolve target file
find backup files whose names start with target.name + ".orig."
choose newest by filesystem mtime
read backup text
strip mirro header only
write restored text to target
```
This action does not read the `Original file:` header to verify the backup belongs to the same absolute path. It is basename-based.
### 5.2 `--restore BACKUP`
Restore flow:
```text
resolve BACKUP as absolute/~ path or backup-dir filename
read backup text
extract Original file path from mirro header
strip mirro header only
check write access to target or target parent
create target parent directories if needed
write restored text to target
```
This action can create parent directories for the target path from the backup header. Treat backup files and backup directories as trusted local state.
### 5.3 Header stripping
`strip_mirro_header()` removes only a header that starts with mirro's separator line and continues through the first blank line.
It intentionally does not strip arbitrary leading comments, shebangs, or non-mirro headers.
### 5.4 `--diff FILE BACKUP`
Diff flow:
```text
resolve current file
resolve backup as absolute/~ path or backup-dir filename
require backup filename to start with current file basename + ".orig."
read current text
read backup text and strip mirro header
print unified diff from backup to current file
```
Diff output uses ANSI colour codes directly for file headers, hunk lines, additions, and removals.
### 5.5 `--status`
Status flow:
```text
read backup directory
group backup files by text before ".orig."
scan regular files in current directory only
print files whose basename has backups
```
`--status` does not recurse into subdirectories and does not validate backup headers.
### 5.6 `--prune-backups`
Prune supports three forms:
```bash
mirro --prune-backups
mirro --prune-backups=14
mirro --prune-backups=all
```
Default mode reads:
```text
MIRRO_BACKUPS_LIFE
```
If the environment variable is missing, invalid, or less than `1`, the code falls back to `30` days after printing a warning for invalid values.
Age-based pruning removes regular files in the backup directory whose mtime is older than the cutoff. `all` mode removes every regular file in the backup directory. There is currently no confirmation prompt, dry-run mode, or mirro-header validation before deletion.
---
## 6. Development commands
Install dependencies:
```bash
poetry install
```
Run the CLI in the development environment:
```bash
poetry run mirro --help
```
Run pre-commit hooks:
```bash
poetry run pre-commit run --all-files
```
Run the pytest suite:
```bash
poetry run pytest
```
Run the README's full coverage command:
```bash
poetry run pytest -vvvv --cov=mirro --cov-report=term-missing --disable-warnings
```
Build release artifacts:
```bash
poetry build
```
When adding behavior, add focused tests under `tests/`. Prefer temporary directories and monkeypatching over tests that modify real user files or depend on a real editor.
---
## 7. Automation and security scanning
Gitea pull request workflow:
```text
.gitea/workflows/lint-and-security.yml
-> install pre-commit
-> pre-commit run --all-files
-> install Poetry and poetry-plugin-export
-> export Poetry dependencies
-> pip-audit dependency audit
```
Scheduled/manual security workflow:
```text
.gitea/workflows/security-scan.yml
-> install Cosign
-> verify and install Syft
-> verify and install Grype
-> generate SBOM
-> scan for vulnerabilities
-> notify Node-RED on fixable Medium/High/Critical vulnerabilities
-> fail workflow on those vulnerabilities
```
Pre-commit currently includes Bandit, Black, trailing whitespace, EOF, YAML, and TOML checks.
---
## 8. Common maintenance tasks
### 8.1 Add a new CLI option
1. Add the argparse option in `main.py`.
2. Decide whether it is a global modifier or an action branch.
3. Place action branches before normal edit flow.
4. Preserve editor positional parsing unless the option intentionally changes it.
5. Update README usage examples.
6. Add tests for parser behavior and the affected operation.
### 8.2 Change backup format
1. Update `backup_original()`.
2. Update `strip_mirro_header()` or `extract_original_path()` if header semantics change.
3. Decide whether existing backup files must remain restorable.
4. Update README examples and this guide.
5. Add tests with representative old and new backup text.
Existing backups are user data. Do not break restoration of current backup headers without a deliberate compatibility decision.
### 8.3 Change restore behavior
Start with the `--restore` and `--restore-last` branches in `main.py`.
Be explicit about whether the change affects:
```text
basename matching
header parsing
target path creation
write permission checks
backup directory trust
confirmation requirements
```
If adding confirmation or dry-run support, cover both interactive and non-interactive behavior in tests.
### 8.4 Change editor handling
Start with normal edit flow near `$EDITOR` parsing.
Preserve these invariants unless intentionally redesigning editor invocation:
```text
do not edit the real file directly
write the initial content to a temporary file
run the editor without shell=True
compare content before creating a backup
create a backup before overwriting the target
delete the temporary file after reading it
```
If changing how editor arguments are ordered, test at least `nano` and one non-`nano` editor shape with monkeypatched `subprocess.call()`.
### 8.5 Add tests
Good first test areas:
```text
backup filename/header creation
header stripping preserves shebangs
restore rejects missing backup files
restore uses Original file from header
restore-last chooses newest basename match
diff rejects mismatched backup basenames
prune rejects invalid values and handles all mode
normal edit does not write unchanged files
normal edit backs up before changed writes
permission-denied branches return 1
```
Avoid tests that require root. Use `tmp_path`, `monkeypatch`, `capsys`, and patched `sys.argv` as the current suite does.
---
## 9. Important maintenance hazards
### 9.1 `main.py` owns everything
The project is currently simple enough for one runtime module, but `main.py` contains parsing, editor invocation, backup I/O, restore, diff, status, prune, and display. Keep new changes focused. If a branch becomes complicated, extract helper functions before adding more nested logic.
### 9.2 Backups are plain trusted files
Backups are not signed, checksummed, authenticated, or stored in a database. The restore path comes from the text header. Do not treat arbitrary attacker-controlled backup files as safe input.
### 9.3 Basename matching is intentionally simple
`--restore-last`, `--diff`, and `--status` mainly use backup filenames, not the `Original file:` header. This is convenient but can mix history for files with the same basename. Be careful when changing matching semantics because existing users may rely on the current naming scheme.
### 9.4 Prune removes regular files in the backup directory
`--prune-backups=all` removes every regular file in the selected backup directory, not only files with mirro headers or `.orig.` names. Age-based pruning has the same broad backup-directory scope. This makes `--backup-dir` a powerful option.
### 9.5 Restore can create parent directories
`--restore BACKUP` creates missing parent directories for the path stored in the backup header. This is useful for recovery, but it also means backup header paths are operationally significant.
### 9.6 Text encoding is lossy for invalid bytes
Reads use `errors="replace"`. If a file contains invalid UTF-8 and the edit is saved, replacement characters may be written. Keep user-facing language clear that `mirro` is for text files.
### 9.7 Symlinks are not special-cased
The current code uses `Path.exists()`, `read_text()`, `write_text()`, and `os.access()` in their normal path-following forms. If a target path is a symlink, operations generally affect the symlink target. Do not document or assume no-follow semantics unless the implementation is changed.
### 9.8 `$EDITOR` is trusted local configuration
`mirro` runs the configured editor as the current user. This is expected behavior, but it means `$EDITOR` is part of the local trust boundary.
---
## 10. Troubleshooting guide
### 10.1 `Need elevated privileges to open` or `create`
The target file or parent directory is not writable by the current process. Re-run in the correct account, adjust permissions, or use `sudo` when intentionally editing privileged files.
### 10.2 `file hasn't changed`
The content after the editor exited matched the content initially placed in the temporary file. No backup was created and the target was not overwritten.
### 10.3 `No history found for FILE`
`--restore-last` could not find backup files whose names start with `FILE`'s basename plus `.orig.` in the selected backup directory. Check `--backup-dir` and `mirro --list`.
### 10.4 `Could not determine original file location from backup header`
`--restore` found the backup file, but the text did not contain a readable `# Original file:` line before the first blank line.
### 10.5 Diff says the backup does not match the file
`--diff` requires the backup filename to start with the current file's basename plus `.orig.`. Use the matching backup file or pass the intended current file.
### 10.6 Prune did not use the expected age
`mirro --prune-backups` reads `MIRRO_BACKUPS_LIFE`. Invalid, missing, zero, or negative values fall back to `30` days. Use `mirro --prune-backups=N` to pass an explicit age.
---
## 11. Practical code-reading map
Feature/question | Start with | Then read
--- | --- | ---
Version output | `get_version()` | `pyproject.toml`
Backup writing | `backup_original()` | normal edit flow near end of `main()`
Header stripping | `strip_mirro_header()` | restore and diff branches
Original path parsing | `extract_original_path()` | `--restore` branch
CLI option behavior | `main()` parser setup | action branch dispatch order
Editor invocation | normal edit flow | tests using patched `subprocess.call()`
Backup listing | `--list` branch | README examples
Restore latest | `--restore-last` branch | backup filename format
Restore specific backup | `--restore` branch | `extract_original_path()`
Diff output | `--diff` branch | `difflib.unified_diff`
Status output | `--status` branch | basename grouping logic
Pruning | `--prune-backups` branch | `MIRRO_BACKUPS_LIFE` docs
Tests | `tests/test_mirro.py` | pytest docs
Packaging | `pyproject.toml` | Poetry docs
Automation | `.gitea/workflows/` | `.pre-commit-config.yaml`
---
## 12. Glossary
**Target file** The file the user asked `mirro` to edit or restore.
**Temporary file** The editable copy passed to `$EDITOR` during normal edit flow.
**Backup directory** The directory containing mirro backup files, defaulting to `~/.local/share/mirro`.
**Backup file** A plain text file named like `name.orig.YYYYMMDDTHHMMSS` containing a mirro header and original content.
**Mirro header** The leading comment block that records the original path and backup timestamp.
**Restore-last** The action that restores the newest backup matching a target basename.
**Restore** The action that restores a specific backup to the original path recorded in its header.
**Prune** Deletion of files from the selected backup directory by age or all-at-once mode.
---
## 13. Final maintenance model
Most changes should preserve this model:
```text
Edit a temporary text copy
-> compare before writing
-> back up original content only when changed
-> then overwrite the target with edited content
-> keep backup inspection and restoration simple and explicit
```
Before changing code, ask:
1. Is this a CLI parsing concern, backup-format concern, editor-invocation concern, or restore/prune concern?
2. Does the change preserve the temporary-file editing model?
3. Is the backup created before the target is overwritten?
4. Are existing backup files still restorable?
5. Does basename-based matching still behave predictably?
6. Does `--backup-dir` remain clearly treated as trusted local state?
7. Are text encoding and binary-file limitations explicit?
8. Are README examples and shell completion expectations still accurate?
9. Are there focused tests for the edge case being changed?
Keeping those boundaries clear is the main way to maintain `mirro` without turning a narrow safe-editing wrapper into a misleading general-purpose recovery system.
+193 -7
View File
@@ -1,12 +1,20 @@
[![Licence](https://img.shields.io/badge/GPL--3.0-orange?label=Licence)](https://git.sysmd.uk/guardutils/mirro/src/branch/main/LICENCE)
[![Gitea Release](https://img.shields.io/gitea/v/release/guardutils/mirro?gitea_url=https%3A%2F%2Fgit.sysmd.uk%2F&style=flat&color=orange&logo=gitea)](https://git.sysmd.uk/guardutils/mirro/releases)
[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-blue?logo=pre-commit&style=flat)](https://git.sysmd.uk/guardutils/mirro/src/branch/main/.pre-commit-config.yaml)
# mirro # mirro
<div align="center">
<img src="mirro.png" alt="mirro logo" width="256" />
</div>
**mirro** is a tiny safety-first editing wrapper for text files. **mirro** is a tiny safety-first editing wrapper for text files.
You edit a temporary file, **mirro** detects whether anything changed, and if it did, it saves a backup of the original before writing your changes. You edit a temporary file, **mirro** detects whether anything changed, and if it did, it saves a backup of the original before writing your changes.
## Why mirro? ## Why mirro?
Well... have you ever been in the _“ugh, I forgot to back this up first”_ situation? Well... have you ever been in the _“ugh, I forgot to back this up first”_ situation?
No? No?
@@ -26,6 +34,8 @@ Stop lying... 🥸
- requires `sudo` only when actually needed - requires `sudo` only when actually needed
- accepts most of your favourite editor's flags
Its simple, predictable, and hard to misuse. Its simple, predictable, and hard to misuse.
I mean... the only thing you need to remember is _to use it_. I mean... the only thing you need to remember is _to use it_.
@@ -74,22 +84,198 @@ so under `sudo`:
Backups are named like: Backups are named like:
``` ```
filename.ext.orig.20251110T174400.bak filename.ext.orig.20251110T174400
```
## Functionalities
### List all backup files stored in your backup directory.
```
mirro --list
```
Output includes permissions, owner/group, timestamps, and backup filenames.
### Restore the most recent backup for a given file.
```
mirro --restore-last ~/.config/myapp/config.ini
```
This:
1. finds the newest backup matching the filename,
2. strips the mirro header from it,
3. and overwrites the target file with its original contents.
### Restore ANY backup
```
mirro --restore filename.ext.orig.20251110T174400
Restored /path/to/filename.ext from backup filename.ext.orig.20251110T174400
```
### Remove old backup files.
```
mirro --prune-backups
```
This removes backups older than the number of days set in `MIRRO_BACKUPS_LIFE`.
### Remove backups older than _N_ days
```
mirro --prune-backups=14
```
This keeps the last 14 days of backups and removes everything older.
### Remove all backups
```
mirro --prune-backups=all
```
This deletes every backup in the backup directory.
### Environment Variable
`MIRRO_BACKUPS_LIFE` controls the default number of days to keep when using `mirro --prune-backups`.
Its default value is **30** if not set otherwise.
```
export MIRRO_BACKUPS_LIFE=7
```
Backups older than 7 days will be removed.
Invalid or non-numeric values fall back to 30 days.
**Note:** _a value of 0 is **invalid**_.
### Built-in diff
This shows a _git-like_ diff of the current file version and any of that file backups.
```
mirro --diff file file.orig.20251121T163121
```
### Shows current directory's history
Shows which files in the current directory have _**edit history**_ recorded by mirro.
For each file, it prints how many revisions exist and when the latest one was saved.
```
mirro --status
Files with history in /foo/bar:
baz.conf (3 revisions, latest: 2025-01-12 14:03 UTC)
``` ```
## Installation ## Installation
**NOTE**: To use mirro with sudo, the path to mirro must be in the $PATH seen by root. ### From GuardUtils package repo
Either install mirro as root (preferred), use sudo -E mirro, or add the $PATH to /etc/sudoers using its Defaults secure_path parameter.
Install via PyPI (preferred): This is the preferred method of installation.
### Debian/Ubuntu
#### 1) Import the GPG key
```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
```
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
```
#### 3) Update and install
```
sudo apt update
sudo apt install mirro
```
### Fedora/RHEL
#### 1) Import the GPG key
```
sudo rpm --import https://repo.sysmd.uk/guardutils/guardutils.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
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://repo.sysmd.uk/guardutils/guardutils.gpg
EOF
```
#### 4) Update and install
```
sudo dnf upgrade --refresh
sudo dnf install mirro
```
### From PyPI
**NOTE:** To use `mirro` with `sudo`, the path to `mirro` must be in the `$PATH` seen by `root`.\
Either:
* install `mirro` as `root`, or
* add the path to `mirro` to the `secure_path` parameter in `/etc/sudoers`. For example, where `/home/user/.local/bin` is where `mirro` is:
``` bash
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/user/.local/bin"
```
Install with:
``` ```
pip install mirro pip install mirro
``` ```
Or clone the repo and install locally: ### From this repository
``` ```
git clone https://github.com/mdaleo404/mirro.git git clone https://git.sysmd.uk/guardutils/mirro.git
cd mirro/ cd mirro/
poetry install poetry install
``` ```
## TAB completion
Add this to your `.bashrc`
```
eval "$(register-python-argcomplete mirro)"
```
And then
```
source ~/.bashrc
```
## How to run the tests
- Clone this repository
- Ensure you have Poetry installed
- Run `poetry run pytest -vvvv --cov=mirro --cov-report=term-missing --disable-warnings`
## pre-commit
This project uses [**pre-commit**](https://pre-commit.com/) to run automatic formatting and security checks before each commit (Black, Bandit, and various safety checks).
To enable it:
```
poetry install
poetry run pre-commit install
```
This ensures consistent formatting, catches common issues early, and keeps the codebase clean.
+193
View File
@@ -0,0 +1,193 @@
# mirro Threat Model and Security Scope
`mirro` is a command-line systems administration tool. It is designed to be executed intentionally by an operator, sometimes with elevated privileges, to edit text files through a temporary copy and save a backup before changed content is written back.
Because of that design, `mirro`'s security model is different from that of a network service, web application, daemon, sandbox, or setuid program. `mirro` does not attempt to defend against arbitrary local compromise of the account executing it. If an attacker can control the command line, environment, working directory, `EDITOR`, selected backup directory, installed Python package, or editor binary used by the operator, they may be able to influence what `mirro` does. That situation is considered a local trust-boundary failure outside `mirro`'s intended security model.
`mirro` is for text-file editing safety. It does not provide full filesystem rollback, access-control enforcement, sandboxing, cryptographic integrity, or protection from a hostile local environment.
## Core Assumptions
`mirro` assumes that the person running the tool understands what they are asking it to do.
In particular:
- If `mirro` is run as root, the root user is assumed to control and understand the command line, environment, `EDITOR`, backup directory, and target file being used.
- If `--backup-dir` is used, the selected directory and its contents are assumed to be trusted local administrative state chosen by the operator.
- If `--restore` is used, the selected backup file is assumed to be trusted and intentionally selected by the operator.
- If `--restore-last` is used, the operator accepts basename-based matching in the selected backup directory.
- If `--prune-backups` is used, the operator intends to delete regular files from the selected backup directory according to the requested mode.
- The configured editor is assumed to be the trusted editor implementation that the operator intended to execute.
- The operator is expected to understand the impact of editing or restoring privileged files, especially when running as root.
## What mirro Records
`mirro` backups are plain text files. A backup records:
- The original file path in a header.
- A UTC timestamp in the header.
- The original text content after the header.
Backup filenames include:
- The original file basename.
- The `.orig.` marker.
- A UTC timestamp with one-second resolution.
`mirro` does not record:
- File ownership.
- File permissions.
- ACLs.
- Extended attributes.
- Capabilities.
- SELinux, AppArmor, or other MAC labels.
- File hashes or signatures.
- A database manifest.
- A transactional history across multiple files.
Backups can contain sensitive file contents. Backup directories must be protected accordingly.
## What Is In Scope
`mirro` tries to protect careful administrators from common editing mistakes that occur when changing text files.
In-scope security and safety concerns include:
- Normal edit mode must not edit the target file directly through the editor.
- Normal edit mode must use a temporary file for the editor session.
- Normal edit mode must not overwrite the target when edited content is unchanged.
- Normal edit mode must create the backup before writing changed content to the target.
- The editor should be invoked without `shell=True`.
- The temporary file should be removed after the editor session is read back.
- Backup headers should preserve enough information to identify the original path.
- Header stripping should remove only mirro's own backup header, not arbitrary comments or shebangs.
- `--diff` should reject backups whose filename does not match the target file basename.
- Permission checks should fail clearly when the current process cannot write the target or target parent.
- Dependency and source scans should continue to run in project automation.
These measures are defense-in-depth. They reduce the chance of accidental data loss or unintended edits when `mirro` is used normally by an administrator.
## What Is Out Of Scope
The following are generally out of scope and should not be reported as `mirro` vulnerabilities unless they also bypass one of `mirro`'s explicit safety mechanisms:
- A malicious local user who can already control the root user's command line, shell environment, working directory, `EDITOR`, `PATH`, Python environment, installed package, or editor binary.
- A root user intentionally editing or restoring a sensitive file.
- A root user intentionally selecting a malicious backup file with `--restore`.
- A root user intentionally pointing `--backup-dir` at a malicious or shared directory.
- A user intentionally setting `EDITOR` to a malicious command.
- A user relying on `mirro` to preserve ownership, permissions, ACLs, xattrs, capabilities, MAC labels, or binary file bytes.
- A user relying on `mirro` as a sandbox for untrusted editors, untrusted local users, or untrusted backup files.
- A compromised system where an attacker already controls root-owned files, root's shell, root's Python packages, root's environment, or editor binaries.
- Reports that amount to "if root runs this tool with malicious options, root can overwrite files."
`mirro` is a tool for administrators, not a sandbox for hostile local users. It cannot make unsafe local trust decisions safe if the operator's own execution environment is already attacker-controlled.
## Trusted Backup Directories
By default, `mirro` stores backups in:
```text
~/.local/share/mirro
```
Operators may override this with `--backup-dir`.
Backup directories should be treated as trusted local state. Backups contain file contents and an `Original file:` path that `--restore` uses as the write target. A maliciously edited backup can cause `mirro --restore` to write attacker-chosen content to the path recorded in the header, subject to the privileges of the user running `mirro`.
The backup directory should not be world-writable or shared with untrusted users. Before restoring, especially as root, the operator should be confident that the selected backup is the intended one and has not been tampered with.
## Restore Behavior
`mirro` has two restore modes:
```bash
mirro --restore-last /path/to/file
mirro --restore file.orig.20250101T010203
```
`--restore-last` finds the newest backup whose filename starts with the target file's basename plus `.orig.`. It does not verify that the backup header's original path matches the requested target path.
`--restore` reads the selected backup file, extracts the `Original file:` path from its header, strips the mirro header, creates missing parent directories if needed, and writes the restored text to that target.
Both restore modes overwrite text at the target path when the current process has permission. There is currently no confirmation prompt or dry-run mode for restore.
This is intentional current behavior, but it means restore operations should be treated as privileged file writes when run with elevated permissions.
## Prune Behavior
`mirro --prune-backups` deletes regular files from the selected backup directory.
Supported forms are:
```bash
mirro --prune-backups
mirro --prune-backups=14
mirro --prune-backups=all
```
Default mode reads `MIRRO_BACKUPS_LIFE`, falling back to `30` days for missing or invalid values. Numeric mode removes regular files older than the cutoff. `all` mode removes every regular file in the backup directory.
Prune does not currently require confirmation, provide a dry run, or validate that files contain mirro headers before deleting them. Operators should use `--backup-dir` carefully.
## Editor Execution
`mirro` runs the configured editor as the current user:
```text
$EDITOR, or nano when unset
```
The editor command is split with `editor.split()` and executed with `subprocess.call()` without `shell=True`. This avoids shell expansion by `mirro` itself, but it does not make the editor trusted. A malicious editor can read, modify, delete, or exfiltrate files accessible to the current user.
`EDITOR` is part of the local trust boundary. Do not run `mirro` with elevated privileges while inheriting an untrusted environment.
## Text Encoding and Binary Files
`mirro` reads text using UTF-8 with replacement for invalid bytes and writes UTF-8 text.
This means `mirro` is not byte-preserving for arbitrary binary files or text files with invalid UTF-8. If such a file is edited and saved, invalid byte sequences may be replaced.
Reports that `mirro` is not a binary-safe editor wrapper are not security issues by themselves. User-facing documentation should keep describing `mirro` as a text-file editing wrapper.
## Symlinks and Filesystem Races
`mirro` does not currently implement no-follow symlink protections. It uses normal path operations such as `Path.exists()`, `read_text()`, `write_text()`, and `os.access()`. If the selected target path is a symlink, operations generally affect the symlink target.
Because `mirro` operates on a live filesystem, concurrent changes can affect what exists at the moment it reads, backs up, restores, prunes, or writes. `mirro` does not claim to provide transactional filesystem semantics.
Avoid using `mirro` in hostile writable directories or on paths that untrusted users can replace while the command is running, especially with elevated privileges.
## Local Compromise
`mirro` includes some hardening for ordinary safe editing, such as editing a temporary file, comparing before writeback, creating a backup before overwrite, and avoiding `shell=True` for editor invocation.
However, local compromise cannot be ruled out completely for a privileged CLI tool. If an attacker can influence the administrator's shell, environment, backup directory, backup files, Python packages, current working directory, editor binary, or command-line arguments, they may be able to influence `mirro`'s behavior.
Such scenarios are treated as local compromise or operator trust failures, not as vulnerabilities in `mirro` by themselves.
## Security Report Guidance
Useful vulnerability reports include issues where `mirro` behaves unsafely despite the documented trust model. Examples include:
- Normal edit mode overwrites the target file even though edited content is unchanged.
- Normal edit mode writes changed content before creating the backup.
- Normal edit mode invokes the editor through a shell in a way that enables shell injection.
- Header stripping removes non-mirro content such as a shebang or ordinary leading comments.
- `--diff` accepts a clearly mismatched backup filename despite its basename check.
- A permission failure is silently ignored and `mirro` proceeds with a write that should have been rejected.
- Temporary files are predictably named or left behind with sensitive content in ordinary successful operation.
- Project automation stops running meaningful lint, dependency audit, or security scans.
Less useful reports, and normally out of scope, include:
- "Root can edit dangerous files."
- "Root can restore malicious content from a malicious backup."
- "Root can choose a dangerous backup directory."
- "A malicious `$EDITOR` can execute code."
- "A malicious local user can compromise `mirro` after already controlling root's environment, Python packages, backup files, or editor binary."
- "`mirro` does not preserve file permissions, ownership, ACLs, xattrs, capabilities, or binary bytes."
- "`mirro --prune-backups=all` deletes files from the backup directory selected by the operator."
Reports about concrete bypasses of `mirro`'s documented safety behavior are welcome. The project does not treat intentional administrator-controlled execution as a vulnerability by itself.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Generated
+528 -5
View File
@@ -1,7 +1,530 @@
# 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 = []
[[package]]
name = "argcomplete"
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"},
]
[package.extras]
test = ["coverage", "mypy", "pexpect", "ruff", "wheel"]
[[package]]
name = "cfgv"
version = "3.4.0"
description = "Validate configuration and produce human readable error messages."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
]
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
markers = "sys_platform == \"win32\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "coverage"
version = "7.11.3"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "coverage-7.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0c986537abca9b064510f3fd104ba33e98d3036608c7f2f5537f869bc10e1ee5"},
{file = "coverage-7.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28c5251b3ab1d23e66f1130ca0c419747edfbcb4690de19467cd616861507af7"},
{file = "coverage-7.11.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4f2bb4ee8dd40f9b2a80bb4adb2aecece9480ba1fa60d9382e8c8e0bd558e2eb"},
{file = "coverage-7.11.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e5f4bfac975a2138215a38bda599ef00162e4143541cf7dd186da10a7f8e69f1"},
{file = "coverage-7.11.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4cbfff5cf01fa07464439a8510affc9df281535f41a1f5312fbd2b59b4ab5c"},
{file = "coverage-7.11.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:31663572f20bf3406d7ac00d6981c7bbbcec302539d26b5ac596ca499664de31"},
{file = "coverage-7.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9799bd6a910961cb666196b8583ed0ee125fa225c6fdee2cbf00232b861f29d2"},
{file = "coverage-7.11.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:097acc18bedf2c6e3144eaf09b5f6034926c3c9bb9e10574ffd0942717232507"},
{file = "coverage-7.11.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6f033dec603eea88204589175782290a038b436105a8f3637a81c4359df27832"},
{file = "coverage-7.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dd9ca2d44ed8018c90efb72f237a2a140325a4c3339971364d758e78b175f58e"},
{file = "coverage-7.11.3-cp310-cp310-win32.whl", hash = "sha256:900580bc99c145e2561ea91a2d207e639171870d8a18756eb57db944a017d4bb"},
{file = "coverage-7.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:c8be5bfcdc7832011b2652db29ed7672ce9d353dd19bce5272ca33dbcf60aaa8"},
{file = "coverage-7.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:200bb89fd2a8a07780eafcdff6463104dec459f3c838d980455cfa84f5e5e6e1"},
{file = "coverage-7.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d264402fc179776d43e557e1ca4a7d953020d3ee95f7ec19cc2c9d769277f06"},
{file = "coverage-7.11.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:385977d94fc155f8731c895accdfcc3dd0d9dd9ef90d102969df95d3c637ab80"},
{file = "coverage-7.11.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0542ddf6107adbd2592f29da9f59f5d9cff7947b5bb4f734805085c327dcffaa"},
{file = "coverage-7.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60bf4d7f886989ddf80e121a7f4d140d9eac91f1d2385ce8eb6bda93d563297"},
{file = "coverage-7.11.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0a3b6e32457535df0d41d2d895da46434706dd85dbaf53fbc0d3bd7d914b362"},
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:876a3ee7fd2613eb79602e4cdb39deb6b28c186e76124c3f29e580099ec21a87"},
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a730cd0824e8083989f304e97b3f884189efb48e2151e07f57e9e138ab104200"},
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b5cd111d3ab7390be0c07ad839235d5ad54d2ca497b5f5db86896098a77180a4"},
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:074e6a5cd38e06671580b4d872c1a67955d4e69639e4b04e87fc03b494c1f060"},
{file = "coverage-7.11.3-cp311-cp311-win32.whl", hash = "sha256:86d27d2dd7c7c5a44710565933c7dc9cd70e65ef97142e260d16d555667deef7"},
{file = "coverage-7.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:ca90ef33a152205fb6f2f0c1f3e55c50df4ef049bb0940ebba666edd4cdebc55"},
{file = "coverage-7.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:56f909a40d68947ef726ce6a34eb38f0ed241ffbe55c5007c64e616663bcbafc"},
{file = "coverage-7.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b771b59ac0dfb7f139f70c85b42717ef400a6790abb6475ebac1ecee8de782f"},
{file = "coverage-7.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:603c4414125fc9ae9000f17912dcfd3d3eb677d4e360b85206539240c96ea76e"},
{file = "coverage-7.11.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:77ffb3b7704eb7b9b3298a01fe4509cef70117a52d50bcba29cffc5f53dd326a"},
{file = "coverage-7.11.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4d4ca49f5ba432b0755ebb0fc3a56be944a19a16bb33802264bbc7311622c0d1"},
{file = "coverage-7.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05fd3fb6edff0c98874d752013588836f458261e5eba587afe4c547bba544afd"},
{file = "coverage-7.11.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e920567f8c3a3ce68ae5a42cf7c2dc4bb6cc389f18bff2235dd8c03fa405de5"},
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bec8c7160688bd5a34e65c82984b25409563134d63285d8943d0599efbc448e"},
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:adb9b7b42c802bd8cb3927de8c1c26368ce50c8fdaa83a9d8551384d77537044"},
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c8f563b245b4ddb591e99f28e3cd140b85f114b38b7f95b2e42542f0603eb7d7"},
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2a96fdc7643c9517a317553aca13b5cae9bad9a5f32f4654ce247ae4d321405"},
{file = "coverage-7.11.3-cp312-cp312-win32.whl", hash = "sha256:e8feeb5e8705835f0622af0fe7ff8d5cb388948454647086494d6c41ec142c2e"},
{file = "coverage-7.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:abb903ffe46bd319d99979cdba350ae7016759bb69f47882242f7b93f3356055"},
{file = "coverage-7.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:1451464fd855d9bd000c19b71bb7dafea9ab815741fb0bd9e813d9b671462d6f"},
{file = "coverage-7.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84b892e968164b7a0498ddc5746cdf4e985700b902128421bb5cec1080a6ee36"},
{file = "coverage-7.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f761dbcf45e9416ec4698e1a7649248005f0064ce3523a47402d1bff4af2779e"},
{file = "coverage-7.11.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1410bac9e98afd9623f53876fae7d8a5db9f5a0ac1c9e7c5188463cb4b3212e2"},
{file = "coverage-7.11.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:004cdcea3457c0ea3233622cd3464c1e32ebba9b41578421097402bee6461b63"},
{file = "coverage-7.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f067ada2c333609b52835ca4d4868645d3b63ac04fb2b9a658c55bba7f667d3"},
{file = "coverage-7.11.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07bc7745c945a6d95676953e86ba7cebb9f11de7773951c387f4c07dc76d03f5"},
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bba7e4743e37484ae17d5c3b8eb1ce78b564cb91b7ace2e2182b25f0f764cb5"},
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbffc22d80d86fbe456af9abb17f7a7766e7b2101f7edaacc3535501691563f7"},
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0dba4da36730e384669e05b765a2c49f39514dd3012fcc0398dd66fba8d746d5"},
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae12fe90b00b71a71b69f513773310782ce01d5f58d2ceb2b7c595ab9d222094"},
{file = "coverage-7.11.3-cp313-cp313-win32.whl", hash = "sha256:12d821de7408292530b0d241468b698bce18dd12ecaf45316149f53877885f8c"},
{file = "coverage-7.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:6bb599052a974bb6cedfa114f9778fedfad66854107cf81397ec87cb9b8fbcf2"},
{file = "coverage-7.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:bb9d7efdb063903b3fdf77caec7b77c3066885068bdc0d44bc1b0c171033f944"},
{file = "coverage-7.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fb58da65e3339b3dbe266b607bb936efb983d86b00b03eb04c4ad5b442c58428"},
{file = "coverage-7.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d16bbe566e16a71d123cd66382c1315fcd520c7573652a8074a8fe281b38c6a"},
{file = "coverage-7.11.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8258f10059b5ac837232c589a350a2df4a96406d6d5f2a09ec587cbdd539655"},
{file = "coverage-7.11.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c5627429f7fbff4f4131cfdd6abd530734ef7761116811a707b88b7e205afd7"},
{file = "coverage-7.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:465695268414e149bab754c54b0c45c8ceda73dd4a5c3ba255500da13984b16d"},
{file = "coverage-7.11.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ebcddfcdfb4c614233cff6e9a3967a09484114a8b2e4f2c7a62dc83676ba13f"},
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13b2066303a1c1833c654d2af0455bb009b6e1727b3883c9964bc5c2f643c1d0"},
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d8750dd20362a1b80e3cf84f58013d4672f89663aee457ea59336df50fab6739"},
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ab6212e62ea0e1006531a2234e209607f360d98d18d532c2fa8e403c1afbdd71"},
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b17c2b5e0b9bb7702449200f93e2d04cb04b1414c41424c08aa1e5d352da76"},
{file = "coverage-7.11.3-cp313-cp313t-win32.whl", hash = "sha256:426559f105f644b69290ea414e154a0d320c3ad8a2bb75e62884731f69cf8e2c"},
{file = "coverage-7.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:90a96fcd824564eae6137ec2563bd061d49a32944858d4bdbae5c00fb10e76ac"},
{file = "coverage-7.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:1e33d0bebf895c7a0905fcfaff2b07ab900885fc78bba2a12291a2cfbab014cc"},
{file = "coverage-7.11.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fdc5255eb4815babcdf236fa1a806ccb546724c8a9b129fd1ea4a5448a0bf07c"},
{file = "coverage-7.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fe3425dc6021f906c6325d3c415e048e7cdb955505a94f1eb774dafc779ba203"},
{file = "coverage-7.11.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4ca5f876bf41b24378ee67c41d688155f0e54cdc720de8ef9ad6544005899240"},
{file = "coverage-7.11.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9061a3e3c92b27fd8036dafa26f25d95695b6aa2e4514ab16a254f297e664f83"},
{file = "coverage-7.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abcea3b5f0dc44e1d01c27090bc32ce6ffb7aa665f884f1890710454113ea902"},
{file = "coverage-7.11.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68c4eb92997dbaaf839ea13527be463178ac0ddd37a7ac636b8bc11a51af2428"},
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:149eccc85d48c8f06547534068c41d69a1a35322deaa4d69ba1561e2e9127e75"},
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08c0bcf932e47795c49f0406054824b9d45671362dfc4269e0bc6e4bff010704"},
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:39764c6167c82d68a2d8c97c33dba45ec0ad9172570860e12191416f4f8e6e1b"},
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3224c7baf34e923ffc78cb45e793925539d640d42c96646db62dbd61bbcfa131"},
{file = "coverage-7.11.3-cp314-cp314-win32.whl", hash = "sha256:c713c1c528284d636cd37723b0b4c35c11190da6f932794e145fc40f8210a14a"},
{file = "coverage-7.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:c381a252317f63ca0179d2c7918e83b99a4ff3101e1b24849b999a00f9cd4f86"},
{file = "coverage-7.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:3e33a968672be1394eded257ec10d4acbb9af2ae263ba05a99ff901bb863557e"},
{file = "coverage-7.11.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f9c96a29c6d65bd36a91f5634fef800212dff69dacdb44345c4c9783943ab0df"},
{file = "coverage-7.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ec27a7a991d229213c8070d31e3ecf44d005d96a9edc30c78eaeafaa421c001"},
{file = "coverage-7.11.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:72c8b494bd20ae1c58528b97c4a67d5cfeafcb3845c73542875ecd43924296de"},
{file = "coverage-7.11.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:60ca149a446da255d56c2a7a813b51a80d9497a62250532598d249b3cdb1a926"},
{file = "coverage-7.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5069074db19a534de3859c43eec78e962d6d119f637c41c8e028c5ab3f59dd"},
{file = "coverage-7.11.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac5d5329c9c942bbe6295f4251b135d860ed9f86acd912d418dce186de7c19ac"},
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e22539b676fafba17f0a90ac725f029a309eb6e483f364c86dcadee060429d46"},
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2376e8a9c889016f25472c452389e98bc6e54a19570b107e27cde9d47f387b64"},
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4234914b8c67238a3c4af2bba648dc716aa029ca44d01f3d51536d44ac16854f"},
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0b4101e2b3c6c352ff1f70b3a6fcc7c17c1ab1a91ccb7a33013cb0782af9820"},
{file = "coverage-7.11.3-cp314-cp314t-win32.whl", hash = "sha256:305716afb19133762e8cf62745c46c4853ad6f9eeba54a593e373289e24ea237"},
{file = "coverage-7.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9245bd392572b9f799261c4c9e7216bafc9405537d0f4ce3ad93afe081a12dc9"},
{file = "coverage-7.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:9a1d577c20b4334e5e814c3d5fe07fa4a8c3ae42a601945e8d7940bab811d0bd"},
{file = "coverage-7.11.3-py3-none-any.whl", hash = "sha256:351511ae28e2509c8d8cae5311577ea7dd511ab8e746ffc8814a0896c3d33fbe"},
{file = "coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b"},
]
[package.dependencies]
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
[package.extras]
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]]
name = "distlib"
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"},
]
[[package]]
name = "exceptiongroup"
version = "1.3.0"
description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
markers = "python_version == \"3.10\""
files = [
{file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"},
{file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"},
]
[package.dependencies]
typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "filelock"
version = "3.20.3"
description = "A platform independent file lock."
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"},
{file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"},
]
[[package]]
name = "identify"
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"},
]
[package.extras]
license = ["ukkonen"]
[[package]]
name = "iniconfig"
version = "2.3.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
[[package]]
name = "nodeenv"
version = "1.9.1"
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.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
]
[[package]]
name = "packaging"
version = "25.0"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
]
[[package]]
name = "platformdirs"
version = "4.5.0"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"},
{file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"},
]
[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"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pre-commit"
version = "3.8.0"
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"},
]
[package.dependencies]
cfgv = ">=2.0.0"
identify = ">=1.0.0"
nodeenv = ">=0.11.1"
pyyaml = ">=5.1"
virtualenv = ">=20.10.0"
[[package]]
name = "pygments"
version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pytest"
version = "9.0.3"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"},
{file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""}
iniconfig = ">=1.0.1"
packaging = ">=22"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-cov"
version = "7.0.0"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"},
{file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"},
]
[package.dependencies]
coverage = {version = ">=7.10.6", extras = ["toml"]}
pluggy = ">=1.2"
pytest = ">=7"
[package.extras]
testing = ["process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "pyyaml"
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"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"},
{file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"},
{file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"},
{file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"},
{file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"},
{file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"},
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"},
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"},
{file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"},
{file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"},
{file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"},
{file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"},
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"},
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"},
{file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"},
{file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"},
{file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"},
{file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"},
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"},
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"},
{file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"},
{file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"},
{file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"},
{file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"},
{file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"},
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"},
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"},
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"},
{file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"},
{file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"},
{file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"},
{file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"},
{file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"},
{file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"},
{file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"},
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"},
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"},
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"},
{file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"},
{file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"},
{file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"},
{file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"},
{file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"},
{file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"},
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"},
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"},
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"},
{file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"},
{file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"},
{file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"},
{file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"},
{file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"},
{file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"},
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"},
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"},
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"},
{file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"},
{file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"},
{file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"},
{file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"},
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
]
[[package]]
name = "tomli"
version = "2.3.0"
description = "A lil' TOML parser"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
markers = "python_full_version <= \"3.11.0a6\""
files = [
{file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"},
{file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"},
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"},
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"},
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"},
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"},
{file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"},
{file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"},
{file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"},
{file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"},
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"},
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"},
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"},
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"},
{file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"},
{file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"},
{file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"},
{file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"},
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"},
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"},
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"},
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"},
{file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"},
{file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"},
{file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"},
{file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"},
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"},
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"},
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"},
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"},
{file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"},
{file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"},
{file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"},
{file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"},
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"},
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"},
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"},
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"},
{file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"},
{file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"},
{file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"},
{file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"},
]
[[package]]
name = "typing-extensions"
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"},
]
[[package]]
name = "virtualenv"
version = "20.36.1"
description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{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 = {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) ; 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] [metadata]
lock-version = "2.0" lock-version = "2.1"
python-versions = "^3.13" python-versions = ">=3.10,<4.0"
content-hash = "f01b553f3895e558c34b4f10542e05acdef39bf0527c8090bd136d914dc73f94" content-hash = "b90604ce6169c72e35550fd278fab34b383e08f72e6db019f3a8ae611c2eae0a"
+13 -4
View File
@@ -1,20 +1,29 @@
[tool.poetry] [tool.poetry]
name = "mirro" name = "mirro"
version = "0.1.0" version = "0.6.2"
description = "A safe editing wrapper: edits a temp copy, compares, and saves original backup if changed." description = "A safe editing wrapper: edits a temp copy, compares, and saves original backup if changed."
authors = ["Marco D'Aleo <marco@marcodaleo.com>"] authors = ["Marco D'Aleo <marco@marcodaleo.com>"]
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
readme = "README.md" readme = "README.md"
homepage = "https://github.com/mdaleo404/mirro" homepage = "https://git.sysmd.uk/guardutils/mirro"
repository = "https://github.com/mdaleo404/mirro" repository = "https://git.sysmd.uk/guardutils/mirro"
packages = [{include = "mirro", from = "src"}] packages = [{include = "mirro", from = "src"}]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.13" python = ">=3.10,<4.0"
argcomplete = ">=2"
[tool.poetry.scripts] [tool.poetry.scripts]
mirro = "mirro.main:main" mirro = "mirro.main:main"
[tool.poetry.group.dev.dependencies]
pytest = "^9.0.1"
pytest-cov = "^7.0.0"
pre-commit = "^3.8"
[tool.black]
line-length = 79
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
+450 -10
View File
@@ -1,11 +1,22 @@
import importlib.metadata
import argparse import argparse
import argcomplete
import tempfile import tempfile
import subprocess import subprocess
import os import os
import textwrap
import difflib
from pathlib import Path from pathlib import Path
import time import time
def get_version():
try:
return importlib.metadata.version("mirro")
except importlib.metadata.PackageNotFoundError:
return "unknown"
def read_file(path: Path) -> str: def read_file(path: Path) -> str:
if not path.exists(): if not path.exists():
return "" return ""
@@ -16,12 +27,14 @@ def write_file(path: Path, content: str):
path.write_text(content, encoding="utf-8") path.write_text(content, encoding="utf-8")
def backup_original(original_path: Path, original_content: str, backup_dir: Path) -> Path: def backup_original(
original_path: Path, original_content: str, backup_dir: Path
) -> Path:
backup_dir.mkdir(parents=True, exist_ok=True) backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()) timestamp = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
shortstamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime()) shortstamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime())
backup_name = f"{original_path.name}.orig.{shortstamp}.bak" backup_name = f"{original_path.name}.orig.{shortstamp}"
backup_path = backup_dir / backup_name backup_path = backup_dir / backup_name
header = ( header = (
@@ -38,11 +51,49 @@ def backup_original(original_path: Path, original_content: str, backup_dir: Path
return backup_path return backup_path
def strip_mirro_header(text: str) -> str:
"""
Strip only mirro's backup header (if present).
Never removes shebangs or anything else.
"""
lines = text.splitlines(keepends=True)
# If there's no mirro header, return the text unchanged
if not lines or not lines[0].startswith(
"# ---------------------------------------------------"
):
return text
# Otherwise skip all header lines until the first blank line
i = 0
while i < len(lines):
if lines[i].strip() == "":
i += 1 # skip the blank separator line
break
i += 1
# 'i' now points to the first real line of the original file
return "".join(lines[i:])
def extract_original_path(backup_text: str) -> Path | None:
"""
Extract the original file path from a mirro backup header.
"""
for line in backup_text.splitlines():
if line.startswith("# Original file:"):
path = line.split(":", 1)[1].strip()
return Path(path).expanduser()
if line.strip() == "":
break
return None
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Safely edit a file with automatic original backup if changed." description="Safely edit a file with automatic original backup if changed."
) )
parser.add_argument("file", type=str, help="Path to file to edit")
parser.add_argument( parser.add_argument(
"--backup-dir", "--backup-dir",
type=str, type=str,
@@ -50,13 +101,400 @@ def main():
help="Backup directory", help="Backup directory",
) )
args = parser.parse_args() parser.add_argument(
"--version",
action="version",
version=f"mirro {get_version()}",
)
editor = os.environ.get("EDITOR","nano") parser.add_argument(
target = Path(args.file).expanduser().resolve() "--list",
backup_dir = Path(args.backup_dir).expanduser().resolve() action="store_true",
help="List all backups in the backup directory and exit",
)
parser.add_argument(
"--restore-last",
metavar="FILE",
type=str,
help="Restore the last backup of the given file and exit",
)
parser.add_argument(
"--restore",
metavar="BACKUP",
type=str,
help="Restore the given backup file and exit",
)
parser.add_argument(
"--prune-backups",
nargs="?",
const="default",
help="Prune backups older than MIRRO_BACKUPS_LIFE days, or 'all' to delete all backups",
)
parser.add_argument(
"--diff",
nargs=2,
metavar=("FILE", "BACKUP"),
help="Show a unified diff between FILE and BACKUP and exit",
)
parser.add_argument(
"--status",
action="store_true",
help="Show which files in the current directory have 'revisions'",
)
argcomplete.autocomplete(parser)
# Parse only options. Leave everything else untouched.
args, positional = parser.parse_known_args()
if args.diff:
file_arg, backup_arg = args.diff
file_path = Path(file_arg).expanduser().resolve()
# Resolve backup: if its not absolute or ~, treat it as a filename in the backup dir
if os.path.isabs(backup_arg) or backup_arg.startswith("~"):
backup_path = Path(backup_arg).expanduser().resolve()
else:
backup_dir = Path(args.backup_dir).expanduser().resolve()
backup_path = backup_dir / backup_arg
if not file_path.exists():
print(f"File not found: {file_path}")
return 1
if not backup_path.exists():
print(f"Backup not found: {backup_path}")
return 1
# Enforce same base filename while diffing
target_name = file_path.name
backup_name = backup_path.name
if not backup_name.startswith(target_name + ".orig."):
print(
f"Error: Backup '{backup_name}' does not match the file being diffed.\n"
f"Expected backup file starting with: {target_name}.orig."
)
return 1
original = file_path.read_text(
encoding="utf-8", errors="replace"
).splitlines()
backup_raw = backup_path.read_text(encoding="utf-8", errors="replace")
backup_stripped = strip_mirro_header(backup_raw)
backup = backup_stripped.splitlines()
# Generate a clean diff (no trailing line noise)
diff = difflib.unified_diff(
backup,
original,
fromfile=f"a/{file_path.name}",
tofile=f"b/{file_path.name}",
lineterm="",
)
# Colors
RED = "\033[31m"
GREEN = "\033[32m"
CYAN = "\033[36m"
RESET = "\033[0m"
for line in diff:
if (
line.startswith("---")
or line.startswith("+++")
or line.startswith("@@")
):
print(f"{CYAN}{line}{RESET}")
elif line.startswith("+"):
print(f"{GREEN}{line}{RESET}")
elif line.startswith("-"):
print(f"{RED}{line}{RESET}")
else:
print(line)
return
if args.list:
import pwd, grp
backup_dir = Path(args.backup_dir).expanduser().resolve()
if not backup_dir.exists():
print("No backups found.")
return
backups = sorted(
backup_dir.iterdir(), key=os.path.getmtime, reverse=True
)
if not backups:
print("No backups found.")
return
def perms(mode):
is_file = "-"
perms = ""
flags = [
(mode & 0o400, "r"),
(mode & 0o200, "w"),
(mode & 0o100, "x"),
(mode & 0o040, "r"),
(mode & 0o020, "w"),
(mode & 0o010, "x"),
(mode & 0o004, "r"),
(mode & 0o002, "w"),
(mode & 0o001, "x"),
]
for bit, char in flags:
perms += char if bit else "-"
return is_file + perms
for b in backups:
stat = b.stat()
mode = perms(stat.st_mode)
try:
owner = pwd.getpwuid(stat.st_uid).pw_name
except KeyError:
owner = str(stat.st_uid)
try:
group = grp.getgrgid(stat.st_gid).gr_name
except KeyError:
group = str(stat.st_gid)
owner_group = f"{owner} {group}"
mtime = time.strftime(
"%Y-%m-%d %H:%M:%S", time.gmtime(stat.st_mtime)
)
print(f"{mode:11} {owner_group:20} {mtime} {b.name}")
return
if args.status:
backup_dir = Path(args.backup_dir).expanduser().resolve()
cwd = Path.cwd()
if not backup_dir.exists():
print(f"No mirro backups found in {cwd}.")
return 0
# Build map: filename -> list of backups
backup_map = {}
for b in backup_dir.iterdir():
name = b.name
if ".orig." not in name:
continue
filename, _, _ = name.partition(".orig.")
backup_map.setdefault(filename, []).append(b)
# Find files in current dir that have backups
entries = []
for file in cwd.iterdir():
if file.is_file() and file.name in backup_map:
backups = backup_map[file.name]
backups_sorted = sorted(
backups, key=lambda x: x.stat().st_mtime, reverse=True
)
latest = backups_sorted[0]
latest_mtime = time.strftime(
"%Y-%m-%d %H:%M:%S UTC",
time.gmtime(latest.stat().st_mtime),
)
entries.append((file.name, len(backups), latest_mtime))
# Nothing found?
if not entries:
print(f"No mirro backups found in {cwd}.")
return 0
# Otherwise print nice report
print(f"Files with history in {cwd}:")
for name, count, latest in entries:
print(f" {name:16} ({count} revision(s), latest: {latest})")
return 0
if args.restore_last:
backup_dir = Path(args.backup_dir).expanduser().resolve()
target = Path(args.restore_last).expanduser().resolve()
if not backup_dir.exists():
print("No backup directory found.")
return 1
# backup filenames look like: <name>.orig.<timestamp>
prefix = f"{target.name}.orig."
backups = [
b for b in backup_dir.iterdir() if b.name.startswith(prefix)
]
if not backups:
print(f"No history found for {target}")
return 1
# newest backup
last = max(backups, key=os.path.getmtime)
# read and strip header
raw = last.read_text(encoding="utf-8", errors="replace")
restored_text = strip_mirro_header(raw)
target.write_text(restored_text, encoding="utf-8")
print(f"Restored {target} from backup {last.name}")
return
if args.restore:
backup_arg = args.restore
backup_dir = Path(args.backup_dir).expanduser().resolve()
# Resolve backup path
if os.path.isabs(backup_arg) or backup_arg.startswith("~"):
backup_path = Path(backup_arg).expanduser().resolve()
else:
backup_path = backup_dir / backup_arg
if not backup_path.exists():
print(f"Backup not found: {backup_path}")
return 1
raw = backup_path.read_text(encoding="utf-8", errors="replace")
target = extract_original_path(raw)
if not target:
print(
"Could not determine original file location from backup header."
)
return 1
restored_text = strip_mirro_header(raw)
# Permission checks
if target.exists() and not os.access(target, os.W_OK):
print(f"Need elevated privileges to restore {target}")
return 1
if not target.exists() and not os.access(target.parent, os.W_OK):
print(f"Need elevated privileges to create {target}")
return 1
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(restored_text, encoding="utf-8")
print(f"Restored {target} from backup {backup_path.name}")
return 0
if args.prune_backups is not None:
mode = args.prune_backups
# ALL mode
if mode == "all":
prune_days = None
# default
elif mode == "default":
raw_env = os.environ.get("MIRRO_BACKUPS_LIFE", "30")
try:
prune_days = int(raw_env)
if prune_days < 1:
raise ValueError
except ValueError:
print(
f"Invalid MIRRO_BACKUPS_LIFE value: {raw_env}. "
"It must be an integer >= 1. Falling back to 30."
)
prune_days = 30
# numeric mode e.g. --prune-backups=7
else:
try:
prune_days = int(mode)
if prune_days < 1:
raise ValueError
except ValueError:
msg = f"""
Invalid value for --prune-backups: {mode}
--prune-backups use MIRRO_BACKUPS_LIFE (default: 30 days)
--prune-backups=N expire backups older than N days (N >= 1)
--prune-backups=all remove ALL backups
"""
print(textwrap.dedent(msg))
return 1
backup_dir = Path(args.backup_dir).expanduser().resolve()
if not backup_dir.exists():
print("No backup directory found.")
return 0
# prune EVERYTHING
if prune_days is None:
removed = []
for b in backup_dir.iterdir():
if b.is_file():
removed.append(b)
b.unlink()
print(f"Removed ALL backups ({len(removed)} file(s)).")
return 0
# prune by age
cutoff = time.time() - (prune_days * 86400)
removed = []
for b in backup_dir.iterdir():
if b.is_file() and b.stat().st_mtime < cutoff:
removed.append(b)
b.unlink()
if removed:
print(
f"Removed {len(removed)} backup(s) older than {prune_days} days."
)
else:
print(f"No backups older than {prune_days} days.")
return 0
# Flexible positional parsing
if not positional:
parser.error("the following arguments are required: file")
file_arg = None
editor_extra = []
for p in positional:
if (
file_arg is None
and not p.startswith("+")
and not p.startswith("-")
):
file_arg = p
else:
editor_extra.append(p)
if file_arg is None:
parser.error("the following arguments are required: file")
editor = os.environ.get("EDITOR", "nano")
editor_cmd = editor.split() editor_cmd = editor.split()
target = Path(file_arg).expanduser().resolve()
backup_dir = Path(args.backup_dir).expanduser().resolve()
if target.is_dir():
print(f"'{target}' is a directory!")
return 1
# Permission checks # Permission checks
parent = target.parent parent = target.parent
if target.exists() and not os.access(target, os.W_OK): if target.exists() and not os.access(target, os.W_OK):
@@ -77,11 +515,13 @@ def main():
delete=False, prefix="mirro-", suffix=target.suffix delete=False, prefix="mirro-", suffix=target.suffix
) as tf: ) as tf:
temp_path = Path(tf.name) temp_path = Path(tf.name)
# Write prepopulated or original content to temp file
write_file(temp_path, original_content) write_file(temp_path, original_content)
# Launch editor if "nano" in editor_cmd[0]:
subprocess.call(editor_cmd + [str(temp_path)]) subprocess.call(editor_cmd + editor_extra + [str(temp_path)])
else:
subprocess.call(editor_cmd + [str(temp_path)] + editor_extra)
# Read edited # Read edited
edited_content = read_file(temp_path) edited_content = read_file(temp_path)
+537
View File
@@ -0,0 +1,537 @@
import os
import time
import subprocess
from pathlib import Path
from unittest.mock import patch
import pytest
import mirro.main as mirro
# ============================================================
# get_version
# ============================================================
def test_get_version_found(monkeypatch):
monkeypatch.setattr(mirro.importlib.metadata, "version", lambda _: "1.2.3")
assert mirro.get_version() == "1.2.3"
def test_get_version_not_found(monkeypatch):
def raiser(_):
raise mirro.importlib.metadata.PackageNotFoundError
monkeypatch.setattr(mirro.importlib.metadata, "version", raiser)
assert mirro.get_version() == "unknown"
# ============================================================
# read_file / write_file
# ============================================================
def test_read_file_exists(tmp_path):
p = tmp_path / "x.txt"
p.write_text("hello\n", encoding="utf-8")
assert mirro.read_file(p) == "hello\n"
def test_read_file_missing(tmp_path):
assert mirro.read_file(tmp_path / "nope.txt") == ""
def test_write_file(tmp_path):
p = tmp_path / "y.txt"
mirro.write_file(p, "data")
assert p.read_text(encoding="utf-8") == "data"
# ============================================================
# strip_mirro_header
# ============================================================
def test_strip_header_removes_header():
header_text = (
"# ---------------------------------------------------\n"
"# mirro backup\n"
"# something\n"
"# ---------------------------------------------------\n"
"\n"
"#!/bin/bash\n"
"echo hi\n"
)
out = mirro.strip_mirro_header(header_text)
assert out.startswith("#!/bin/bash")
assert "mirro backup" not in out
def test_strip_header_preserves_shebang():
text = "#!/usr/bin/env python3\nprint('hi')\n"
out = mirro.strip_mirro_header(text)
assert out == text
def test_strip_header_non_header_file():
text = "# just a comment\nvalue\n"
out = mirro.strip_mirro_header(text)
assert out == text
# ============================================================
# backup_original
# ============================================================
def test_backup_original(tmp_path, monkeypatch):
original_path = tmp_path / "a.txt"
original_content = "ABC"
backup_dir = tmp_path / "backups"
monkeypatch.setattr(
time,
"gmtime",
lambda: time.struct_time((2023, 1, 2, 3, 4, 5, 0, 0, 0)),
)
monkeypatch.setattr(
time,
"strftime",
lambda fmt, _: {
"%Y-%m-%d %H:%M:%S UTC": "2023-01-02 03:04:05 UTC",
"%Y%m%dT%H%M%S": "20230102T030405",
}[fmt],
)
backup_path = mirro.backup_original(
original_path, original_content, backup_dir
)
assert backup_path.exists()
text = backup_path.read_text()
assert "mirro backup" in text
assert "Original file" in text
assert "ABC" in text
# ============================================================
# Helper to simulate main()
# ============================================================
def simulate_main(
monkeypatch,
capsys,
args,
*,
editor="nano",
start_content=None,
edited_content=None,
file_exists=True,
override_access=None,
):
monkeypatch.setenv("EDITOR", editor)
def fake_call(cmd):
temp = Path(cmd[-1])
if edited_content is None:
temp.write_text(start_content or "", encoding="utf-8")
else:
temp.write_text(edited_content, encoding="utf-8")
return 0
monkeypatch.setattr(subprocess, "call", fake_call)
if override_access:
monkeypatch.setattr(os, "access", override_access)
else:
monkeypatch.setattr(os, "access", lambda p, m: True)
target = Path(args[-1]).expanduser().resolve()
if file_exists:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(start_content or "", encoding="utf-8")
with patch("sys.argv", ["mirro"] + args):
result = mirro.main()
out = capsys.readouterr().out
return result, out
# ============================================================
# main: missing positional file
# ============================================================
def test_main_missing_argument(capsys):
with patch("sys.argv", ["mirro"]):
with pytest.raises(SystemExit):
mirro.main()
assert (
"the following arguments are required: file" in capsys.readouterr().err
)
def test_main_rejects_directory(tmp_path, capsys):
target = tmp_path / "dir"
target.mkdir()
with patch("sys.argv", ["mirro", str(target)]):
result = mirro.main()
assert result == 1
assert f"'{target}' is a directory!" in capsys.readouterr().out
# ============================================================
# main: unchanged file
# ============================================================
def test_main_existing_unchanged(tmp_path, monkeypatch, capsys):
target = tmp_path / "file.txt"
target.write_text("hello\n")
def fake_call(cmd):
temp = Path(cmd[-1])
temp.write_text("hello\n")
monkeypatch.setenv("EDITOR", "nano")
monkeypatch.setattr(subprocess, "call", fake_call)
monkeypatch.setattr(os, "access", lambda p, m: True)
with patch("sys.argv", ["mirro", str(target)]):
mirro.main()
assert "file hasn't changed" in capsys.readouterr().out
# ============================================================
# main: changed file
# ============================================================
def test_main_existing_changed(tmp_path, monkeypatch, capsys):
target = tmp_path / "f2.txt"
result, out = simulate_main(
monkeypatch,
capsys,
args=[str(target)],
start_content="old\n",
edited_content="new\n",
file_exists=True,
)
assert "file changed; original backed up at" in out
assert target.read_text() == "new\n"
# ============================================================
# main: new file unchanged
# ============================================================
def test_main_new_file_unchanged(tmp_path, monkeypatch, capsys):
new = tmp_path / "new.txt"
result, out = simulate_main(
monkeypatch,
capsys,
args=[str(new)],
start_content=None,
edited_content="This is a new file created with 'mirro'!\n",
file_exists=False,
)
assert "file hasn't changed" in out
assert not new.exists()
# ============================================================
# main: new file changed
# ============================================================
def test_main_new_file_changed(tmp_path, monkeypatch, capsys):
new = tmp_path / "new2.txt"
result, out = simulate_main(
monkeypatch,
capsys,
args=[str(new)],
start_content=None,
edited_content="XYZ\n",
file_exists=False,
)
assert "file changed; original backed up at" in out
assert new.read_text() == "XYZ\n"
# ============================================================
# Permission denied branches
# ============================================================
def test_main_permission_denied_existing(tmp_path, monkeypatch, capsys):
tgt = tmp_path / "blocked.txt"
tgt.write_text("hi")
monkeypatch.setenv("EDITOR", "nano")
monkeypatch.setattr(os, "access", lambda p, m: False)
with patch("sys.argv", ["mirro", str(tgt)]):
result = mirro.main()
assert result == 1
assert "Need elevated privileges to open" in capsys.readouterr().out
def test_main_permission_denied_create(tmp_path, monkeypatch, capsys):
new = tmp_path / "sub/xx.txt"
new.parent.mkdir(parents=True)
def fake_access(path, mode):
return False if path == new.parent else True
monkeypatch.setattr(os, "access", fake_access)
monkeypatch.setenv("EDITOR", "nano")
with patch("sys.argv", ["mirro", str(new)]):
result = mirro.main()
assert result == 1
assert "Need elevated privileges to create" in capsys.readouterr().out
# ============================================================
# --list
# ============================================================
def test_main_list_no_dir(tmp_path, capsys):
with patch(
"sys.argv", ["mirro", "--list", "--backup-dir", str(tmp_path / "none")]
):
mirro.main()
assert "No backups found." in capsys.readouterr().out
def test_main_list_entries(tmp_path, capsys):
d = tmp_path / "bk"
d.mkdir()
(d / "a.txt.orig.1").write_text("x")
(d / "b.txt.orig.2").write_text("y")
with patch("sys.argv", ["mirro", "--list", "--backup-dir", str(d)]):
mirro.main()
out = capsys.readouterr().out
assert "a.txt.orig.1" in out
assert "b.txt.orig.2" in out
# ============================================================
# --restore-last
# ============================================================
def test_restore_last_no_dir(tmp_path, capsys):
d = tmp_path / "none"
target = tmp_path / "x.txt"
with patch(
"sys.argv",
["mirro", "--restore-last", str(target), "--backup-dir", str(d)],
):
result = mirro.main()
assert result == 1
assert "No backup directory found." in capsys.readouterr().out
def test_restore_last_no_backups(tmp_path, capsys):
d = tmp_path / "bk"
d.mkdir()
target = tmp_path / "t.txt"
with patch(
"sys.argv",
["mirro", "--restore-last", str(target), "--backup-dir", str(d)],
):
result = mirro.main()
out = capsys.readouterr().out
assert result == 1
assert "No history found for" in out
assert str(target) in out
def test_restore_last_success(tmp_path, capsys):
d = tmp_path / "bk"
d.mkdir()
target = tmp_path / "t.txt"
mirro_header = (
"# ---------------------------------------------------\n"
"# mirro backup\n"
"# Original file: x\n"
"# Timestamp: test\n"
"# Delete this header if you want to restore the file\n"
"# ---------------------------------------------------\n"
"\n"
)
b1 = d / "t.txt.orig.2020"
b2 = d / "t.txt.orig.2021"
b1.write_text(mirro_header + "old1")
b2.write_text(mirro_header + "old2")
os.utime(b2, (time.time(), time.time()))
with patch(
"sys.argv",
["mirro", "--restore-last", str(target), "--backup-dir", str(d)],
):
mirro.main()
assert target.read_text() == "old2"
assert "Restored" in capsys.readouterr().out
# ============================================================
# --restore (new)
# ============================================================
def test_restore_backup_to_original_location(tmp_path, capsys):
backup_dir = tmp_path / "bk"
backup_dir.mkdir()
original_dir = tmp_path / "orig"
original_dir.mkdir()
target = original_dir / "file.txt"
mirro_header = (
"# ---------------------------------------------------\n"
"# mirro backup\n"
f"# Original file: {target}\n"
"# Timestamp: test\n"
"# Delete this header if you want to restore the file\n"
"# ---------------------------------------------------\n"
"\n"
)
backup = backup_dir / "file.txt.orig.20250101T010203"
backup.write_text(mirro_header + "restored content\n")
with patch(
"sys.argv",
["mirro", "--restore", backup.name, "--backup-dir", str(backup_dir)],
):
result = mirro.main()
assert result == 0
assert target.exists()
assert target.read_text() == "restored content\n"
assert "Restored" in capsys.readouterr().out
def test_restore_missing_backup(tmp_path, capsys):
backup_dir = tmp_path / "bk"
backup_dir.mkdir()
with patch(
"sys.argv",
[
"mirro",
"--restore",
"nope.orig.123",
"--backup-dir",
str(backup_dir),
],
):
result = mirro.main()
assert result == 1
assert "Backup not found" in capsys.readouterr().out
def test_restore_missing_original_path_in_header(tmp_path, capsys):
backup_dir = tmp_path / "bk"
backup_dir.mkdir()
bad_backup = backup_dir / "x.txt.orig.123"
bad_backup.write_text(
"# ---------------------------------------------------\n"
"# mirro backup\n"
"# Timestamp: test\n"
"\n"
"data\n"
)
with patch(
"sys.argv",
[
"mirro",
"--restore",
bad_backup.name,
"--backup-dir",
str(backup_dir),
],
):
result = mirro.main()
assert result == 1
assert (
"Could not determine original file location" in capsys.readouterr().out
)
# ============================================================
# --status
# ============================================================
def test_status_no_backups(tmp_path, monkeypatch, capsys):
monkeypatch.chdir(tmp_path)
backup_dir = tmp_path / "bk"
with patch(
"sys.argv", ["mirro", "--status", "--backup-dir", str(backup_dir)]
):
result = mirro.main()
out = capsys.readouterr().out
assert result == 0
assert f"No mirro backups found in {tmp_path}" in out
def test_status_backups_found(tmp_path, monkeypatch, capsys):
monkeypatch.chdir(tmp_path)
cwd = tmp_path
backup_dir = tmp_path / "bk"
backup_dir.mkdir()
(tmp_path / "a.txt").write_text("data1")
(tmp_path / "b.txt").write_text("data2")
(backup_dir / "a.txt.orig.1").write_text("x")
(backup_dir / "a.txt.orig.2").write_text("y")
(backup_dir / "b.txt.orig.3").write_text("z")
with patch(
"sys.argv", ["mirro", "--status", "--backup-dir", str(backup_dir)]
):
result = mirro.main()
out = capsys.readouterr().out
assert result == 0
assert f"Files with history in {cwd}:" in out
assert "a.txt" in out
assert "b.txt" in out