memdebug 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {memdebug-0.2.0 → memdebug-0.3.0}/CHANGELOG.md +21 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/PKG-INFO +60 -7
- {memdebug-0.2.0 → memdebug-0.3.0}/README.md +59 -6
- {memdebug-0.2.0 → memdebug-0.3.0}/ROADMAP.md +8 -8
- {memdebug-0.2.0 → memdebug-0.3.0}/docs/agents.md +1 -1
- {memdebug-0.2.0 → memdebug-0.3.0}/docs/releasing.md +4 -3
- {memdebug-0.2.0 → memdebug-0.3.0}/docs/threat-model.md +22 -1
- {memdebug-0.2.0 → memdebug-0.3.0}/pyproject.toml +1 -1
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/__init__.py +1 -1
- memdebug-0.3.0/src/memdebug/adapters/fileops.py +190 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/adapters/folder.py +5 -0
- memdebug-0.3.0/src/memdebug/adapters/folder_restore.py +275 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/adapters/restore.py +5 -159
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/cli.py +103 -12
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/monitor.py +24 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/paths.py +5 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/rollback_flow.py +12 -3
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/selftest.py +49 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/viewer/pages.py +80 -3
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/viewer/style.py +1 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_demo.py +9 -0
- memdebug-0.3.0/tests/test_folder_restore.py +473 -0
- memdebug-0.3.0/tests/test_folder_rollback_cli.py +235 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_restore.py +3 -3
- memdebug-0.3.0/tests/test_viewer_putback.py +257 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/.gitattributes +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/.github/dependabot.yml +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/.github/workflows/ci.yml +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/.github/workflows/release.yml +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/.gitignore +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/CONTRIBUTING.md +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/LICENSE +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/NOTICE +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/SECURITY.md +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/__main__.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/adapters/__init__.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/adapters/base.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/adapters/common.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/adapters/markdown_git.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/adapters/mem0.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/adapters/openwebui.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/agents.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/backends.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/demo.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/describe.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/diff.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/docker_source.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/errors.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/hints.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/ledger.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/models.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/reconcile.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/report.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/stores.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/sync.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/textsafe.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/viewer/__init__.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/viewer/html.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/viewer/redline.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/viewer/server.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/src/memdebug/witness.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/conftest.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/payloads.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_agents.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_cli.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_diff.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_docker_source.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_folder_and_openwebui.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_hints.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_ledger.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_ledger_read.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_markdown_git.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_mem0_adapter.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_models.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_platform.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_provenance_openwebui.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_readable_ids_and_honest_wording.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_reconcile.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_release_hygiene.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_report.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_rollback_cli.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_snapshots.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_stores_and_monitor.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_sync.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_textsafe.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_viewer.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_viewer_html.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_viewer_redline.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_viewer_rollback.py +0 -0
- {memdebug-0.2.0 → memdebug-0.3.0}/tests/test_witness.py +0 -0
|
@@ -3,6 +3,27 @@
|
|
|
3
3
|
All notable changes. The format follows [Keep a Changelog](https://keepachangelog.com/); versions follow [Semantic Versioning](https://semver.org/)
|
|
4
4
|
(alpha: anything may change before 1.0).
|
|
5
5
|
|
|
6
|
+
## [0.3.0] - 2026-10-07
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **Rollback for plain folders.** `memdebug rollback store NAME --to s1` restores a watched folder of notes (and, by the same command, git notes) to a
|
|
10
|
+
snapshot: a dry run first, everything it replaces saved first byte for byte in a private folder next to the ledger, every write undone if a later
|
|
11
|
+
step fails, and the rollback recorded in the ledger and itself undoable. A folder is rebuilt from the snapshot's text, so line endings are LF (or CRLF
|
|
12
|
+
if the file it replaces uses CRLF throughout), and text a snapshot could not keep faithfully is skipped, never written.
|
|
13
|
+
- `memdebug selftest` has a new check, "folder rollback is safe", that proves the plain-folder rollback claims on your machine without needing git.
|
|
14
|
+
- `memdebug snapshot store NAME` saves a known-good copy of a watched store to roll back to. It refuses while the ledger holds an outside-history
|
|
15
|
+
change or flagged wording since the store's last snapshot, until you add `--include-changes`.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- **The viewer** shows how to put a store back: snapshot pages, the compare page, the page of a change made outside a store's history and the
|
|
19
|
+
overview's "Needs a look" give the `memdebug rollback store` command as text to copy (the viewer itself still only reads). A store name from the
|
|
20
|
+
ledger is only put into a command if it is safe to paste; for an outside change the snapshot offered is always one taken before it. A rollback of a
|
|
21
|
+
plain folder is described as in place with a backup, not as a commit.
|
|
22
|
+
- The file-writing safety code of the git rollback engine (links, junctions, case clashes, atomic writes, undo) now lives in one shared module used by
|
|
23
|
+
both engines. No behaviour change.
|
|
24
|
+
- `memdebug demo` now ends by pointing at `memdebug setup`, the guided way to watch your own agents, instead of an advanced command.
|
|
25
|
+
- Documentation: the README explains installing from PyPI, with a route that works on Windows without pipx.
|
|
26
|
+
|
|
6
27
|
## [0.2.0] - 2026-10-07
|
|
7
28
|
|
|
8
29
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memdebug
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Inspect, compare and roll back what an AI agent's memory holds. Local-first and agent-neutral.
|
|
5
5
|
Project-URL: Homepage, https://github.com/juraj-jumic/memdebug
|
|
6
6
|
Project-URL: Source, https://github.com/juraj-jumic/memdebug
|
|
@@ -45,15 +45,29 @@ It is an **observer**: it never sits between the agent and its memory, never tal
|
|
|
45
45
|
computer. It does not block attacks as they happen (run it next to runtime guards), and it does not yet say *which
|
|
46
46
|
conversation* wrote a memory. See [docs/threat-model.md](docs/threat-model.md) for exactly what it does and does not do.
|
|
47
47
|
|
|
48
|
-
> **Status: alpha (0.
|
|
48
|
+
> **Status: alpha (0.3).** The parts described here work. The tests run on every push on Linux, Windows and macOS (Python 3.10, 3.12 and 3.14), and
|
|
49
49
|
> the author also runs them on Windows 11, but expect rough edges. [ROADMAP.md](ROADMAP.md) lists what is built and what is next.
|
|
50
50
|
|
|
51
51
|
## Try it in a minute
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
You need Python 3.10 or newer and git 2.31 or newer. Install memdebug from PyPI into its own virtual environment, which works the same way
|
|
54
|
+
everywhere:
|
|
55
|
+
|
|
56
|
+
python -m venv memdebug-env
|
|
57
|
+
memdebug-env\Scripts\activate # Windows (PowerShell or cmd); on macOS and Linux: source memdebug-env/bin/activate
|
|
58
|
+
pip install memdebug
|
|
59
|
+
|
|
60
|
+
If you use [pipx](https://pipx.pypa.io/), `pipx install memdebug` does the same and keeps the `memdebug` command available everywhere. pipx is not
|
|
61
|
+
installed on Windows by default; to get it: `py -m pip install --user pipx`, then `py -m pipx ensurepath`, then open a new terminal. If your shell
|
|
62
|
+
cannot find the `memdebug` command after installing, `python -m memdebug` (on Windows `py -m memdebug`) does the same thing.
|
|
63
|
+
|
|
64
|
+
Then:
|
|
65
|
+
|
|
54
66
|
memdebug demo # made-up agent, made-up attack, the real tools; nothing of yours is touched
|
|
55
67
|
memdebug demo --serve # ...and then look at it in the browser viewer
|
|
56
68
|
|
|
69
|
+
(To work on memdebug itself, install from a checkout instead: see [CONTRIBUTING.md](CONTRIBUTING.md).)
|
|
70
|
+
|
|
57
71
|
The demo plants an instruction into a note behind git's back, shows memdebug catching it, rolls the file back without losing
|
|
58
72
|
the planted text, and shows the ledger noticing a tampered copy. It works in a throwaway folder and removes it afterwards.
|
|
59
73
|
|
|
@@ -79,7 +93,7 @@ Or register stores yourself (this is what a script would do):
|
|
|
79
93
|
| Store | What it is | Change history | "Changed outside the history" | Rollback |
|
|
80
94
|
| --- | --- | --- | --- | --- |
|
|
81
95
|
| markdown (git) | markdown notes in a git repository | git history | an uncommitted edit | yes |
|
|
82
|
-
| folder | markdown notes in a plain folder (for example Claude Code's per-project memory folder) | none: changes are noticed between looks | not applicable |
|
|
96
|
+
| folder | markdown notes in a plain folder (for example Claude Code's per-project memory folder) | none: changes are noticed between looks | not applicable | yes, from a snapshot (see below) |
|
|
83
97
|
| openwebui | the `memory` table of Open WebUI's `webui.db`, copied from its Docker container (or from a copy you made) | none | not applicable | no |
|
|
84
98
|
| mem0 | self-hosted Mem0 | Mem0's `history.db` | a change made directly in storage | no |
|
|
85
99
|
|
|
@@ -92,7 +106,9 @@ provider's cloud: there is nothing local to watch, and memdebug says so rather t
|
|
|
92
106
|
|
|
93
107
|
## Tools for people who want more
|
|
94
108
|
|
|
95
|
-
memdebug
|
|
109
|
+
memdebug snapshot store NAME [--label TEXT] # save a known-good copy of a watched store, to roll back to later
|
|
110
|
+
memdebug rollback store NAME --to s1 # a dry run; add --apply. Works for folders and git notes
|
|
111
|
+
memdebug timeline | verify | diff s1 s2 --full | snapshot ... | rollback markdown --path REPO --to s1
|
|
96
112
|
memdebug report --format markdown|json|sarif [--out FILE] [--fail-on findings|hints] # for people, programs and CI
|
|
97
113
|
memdebug witness --file E:\memdebug-witness.txt # a second copy of the ledger's fingerprint, kept somewhere else
|
|
98
114
|
memdebug verify --witness E:\memdebug-witness.txt # catches a rewritten or cut-short ledger
|
|
@@ -129,6 +145,12 @@ the secret moves into a cookie and disappears from the address bar. It uses only
|
|
|
129
145
|
library and sends no JavaScript at all. Run `memdebug verify` once first if the ledger is from an
|
|
130
146
|
older version (the viewer itself never upgrades or creates anything).
|
|
131
147
|
|
|
148
|
+
The viewer can only look, so where you may want to act it shows you the command instead, as text to paste into a terminal: a snapshot page, the
|
|
149
|
+
compare page, the page of a change made outside a store's history, and the overview's "Needs a look" all say how to put a watched store back
|
|
150
|
+
(`memdebug rollback store NAME --to sN`), and for an outside change they point at the last snapshot taken *before* it, never one that already includes
|
|
151
|
+
it. Stores memdebug only reads (Open WebUI, Mem0) say so instead of offering a command. A rollback of a plain folder is described as it really is:
|
|
152
|
+
changed in place, with the replaced files saved in a backup folder, not as a git commit.
|
|
153
|
+
|
|
132
154
|
Security of the viewer, threat by threat: [docs/threat-model.md](docs/threat-model.md#the-viewer). Its limits: it is plain HTTP on your own
|
|
133
155
|
machine, the first link (with the secret) stays in your browser history, and processes running as you can read the secret.
|
|
134
156
|
Choose Auto, Light or Dark at the top right.
|
|
@@ -163,7 +185,38 @@ installed. Adding a store type is described in [CONTRIBUTING.md](CONTRIBUTING.md
|
|
|
163
185
|
|
|
164
186
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for the ground rules (everything read from a store is untrusted; adapters only read).
|
|
165
187
|
|
|
166
|
-
## Rolling back (
|
|
188
|
+
## Rolling back a watched store (folders and git notes)
|
|
189
|
+
|
|
190
|
+
`memdebug rollback store NAME --to s1` does the same job by the name you see in `memdebug stores`, for a plain folder of notes as well as for
|
|
191
|
+
git notes. First save a known-good copy while the store is healthy:
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
memdebug snapshot store claude-code-shop --label "good, after the move" # a snapshot you can come back to
|
|
195
|
+
memdebug snapshot list # names and dates of snapshots
|
|
196
|
+
memdebug rollback store claude-code-shop --to s2 # shows what would change; writes nothing
|
|
197
|
+
memdebug rollback store claude-code-shop --to s2 --apply # does it, after you type the snapshot id
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`snapshot store` refuses to save while the ledger holds a change made outside the store's own history, or wording flagged as worth a second look,
|
|
201
|
+
since the store's last snapshot: a snapshot is what a rollback later treats as good. Look at the flagged changes first (`memdebug serve`), then add
|
|
202
|
+
`--include-changes` if they are fine. This does not go away if you run it twice.
|
|
203
|
+
|
|
204
|
+
For a **plain folder** there is no git to take exact file versions from, so it works from the snapshot's text, and it is honest about what that means:
|
|
205
|
+
|
|
206
|
+
* **Backups are files.** Anything the rollback would replace or remove is first copied, byte for byte, into a private folder next to your ledger
|
|
207
|
+
(`backups\<store>\<date>-<id>`, with a `manifest.json`), and each copy is read back and checked before your notes are touched. Get a file
|
|
208
|
+
back by copying it out of that folder's `files` folder. These copies contain your memory text and memdebug never deletes them: delete old ones
|
|
209
|
+
yourself when you no longer need them.
|
|
210
|
+
* **Line endings.** A snapshot does not keep them. Files come back as UTF-8 with LF line endings, or with CRLF if the file being replaced uses CRLF
|
|
211
|
+
throughout. A file with mixed endings comes back with LF.
|
|
212
|
+
* **Nothing it cannot restore faithfully is written.** Text that was cut, marked too large, or had bytes that are not valid text is skipped, and
|
|
213
|
+
the dry run says which files and why.
|
|
214
|
+
* **Everything else is the same** as for git notes: the plan writes nothing, applying refuses if anything changed since you saw the plan, links,
|
|
215
|
+
junctions and unsafe names are refused, every write is undone if a later step fails, and the rollback is recorded and can itself be undone.
|
|
216
|
+
|
|
217
|
+
Open WebUI and Mem0 keep their memory in databases that memdebug only ever reads, so they cannot be rolled back; change those in the app.
|
|
218
|
+
|
|
219
|
+
## Rolling back markdown in git, by path
|
|
167
220
|
|
|
168
221
|
`memdebug rollback markdown` puts a memory folder back to what a snapshot held. It is the only command that changes
|
|
169
222
|
your files, so it is cautious by design.
|
|
@@ -193,7 +246,7 @@ What it guarantees:
|
|
|
193
246
|
* **It can be undone.** It takes a snapshot just before and just after, and records a `ROLLBACK` entry in the ledger.
|
|
194
247
|
To undo a rollback, roll back to the "before" snapshot it printed.
|
|
195
248
|
|
|
196
|
-
`memdebug selftest` proves the "no programs run" and "exact bytes" claims on your computer.
|
|
249
|
+
`memdebug selftest` proves the "no programs run" and "exact bytes" claims, and the folder-rollback claims, on your computer.
|
|
197
250
|
|
|
198
251
|
## Fonts
|
|
199
252
|
|
|
@@ -12,15 +12,29 @@ It is an **observer**: it never sits between the agent and its memory, never tal
|
|
|
12
12
|
computer. It does not block attacks as they happen (run it next to runtime guards), and it does not yet say *which
|
|
13
13
|
conversation* wrote a memory. See [docs/threat-model.md](docs/threat-model.md) for exactly what it does and does not do.
|
|
14
14
|
|
|
15
|
-
> **Status: alpha (0.
|
|
15
|
+
> **Status: alpha (0.3).** The parts described here work. The tests run on every push on Linux, Windows and macOS (Python 3.10, 3.12 and 3.14), and
|
|
16
16
|
> the author also runs them on Windows 11, but expect rough edges. [ROADMAP.md](ROADMAP.md) lists what is built and what is next.
|
|
17
17
|
|
|
18
18
|
## Try it in a minute
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
You need Python 3.10 or newer and git 2.31 or newer. Install memdebug from PyPI into its own virtual environment, which works the same way
|
|
21
|
+
everywhere:
|
|
22
|
+
|
|
23
|
+
python -m venv memdebug-env
|
|
24
|
+
memdebug-env\Scripts\activate # Windows (PowerShell or cmd); on macOS and Linux: source memdebug-env/bin/activate
|
|
25
|
+
pip install memdebug
|
|
26
|
+
|
|
27
|
+
If you use [pipx](https://pipx.pypa.io/), `pipx install memdebug` does the same and keeps the `memdebug` command available everywhere. pipx is not
|
|
28
|
+
installed on Windows by default; to get it: `py -m pip install --user pipx`, then `py -m pipx ensurepath`, then open a new terminal. If your shell
|
|
29
|
+
cannot find the `memdebug` command after installing, `python -m memdebug` (on Windows `py -m memdebug`) does the same thing.
|
|
30
|
+
|
|
31
|
+
Then:
|
|
32
|
+
|
|
21
33
|
memdebug demo # made-up agent, made-up attack, the real tools; nothing of yours is touched
|
|
22
34
|
memdebug demo --serve # ...and then look at it in the browser viewer
|
|
23
35
|
|
|
36
|
+
(To work on memdebug itself, install from a checkout instead: see [CONTRIBUTING.md](CONTRIBUTING.md).)
|
|
37
|
+
|
|
24
38
|
The demo plants an instruction into a note behind git's back, shows memdebug catching it, rolls the file back without losing
|
|
25
39
|
the planted text, and shows the ledger noticing a tampered copy. It works in a throwaway folder and removes it afterwards.
|
|
26
40
|
|
|
@@ -46,7 +60,7 @@ Or register stores yourself (this is what a script would do):
|
|
|
46
60
|
| Store | What it is | Change history | "Changed outside the history" | Rollback |
|
|
47
61
|
| --- | --- | --- | --- | --- |
|
|
48
62
|
| markdown (git) | markdown notes in a git repository | git history | an uncommitted edit | yes |
|
|
49
|
-
| folder | markdown notes in a plain folder (for example Claude Code's per-project memory folder) | none: changes are noticed between looks | not applicable |
|
|
63
|
+
| folder | markdown notes in a plain folder (for example Claude Code's per-project memory folder) | none: changes are noticed between looks | not applicable | yes, from a snapshot (see below) |
|
|
50
64
|
| openwebui | the `memory` table of Open WebUI's `webui.db`, copied from its Docker container (or from a copy you made) | none | not applicable | no |
|
|
51
65
|
| mem0 | self-hosted Mem0 | Mem0's `history.db` | a change made directly in storage | no |
|
|
52
66
|
|
|
@@ -59,7 +73,9 @@ provider's cloud: there is nothing local to watch, and memdebug says so rather t
|
|
|
59
73
|
|
|
60
74
|
## Tools for people who want more
|
|
61
75
|
|
|
62
|
-
memdebug
|
|
76
|
+
memdebug snapshot store NAME [--label TEXT] # save a known-good copy of a watched store, to roll back to later
|
|
77
|
+
memdebug rollback store NAME --to s1 # a dry run; add --apply. Works for folders and git notes
|
|
78
|
+
memdebug timeline | verify | diff s1 s2 --full | snapshot ... | rollback markdown --path REPO --to s1
|
|
63
79
|
memdebug report --format markdown|json|sarif [--out FILE] [--fail-on findings|hints] # for people, programs and CI
|
|
64
80
|
memdebug witness --file E:\memdebug-witness.txt # a second copy of the ledger's fingerprint, kept somewhere else
|
|
65
81
|
memdebug verify --witness E:\memdebug-witness.txt # catches a rewritten or cut-short ledger
|
|
@@ -96,6 +112,12 @@ the secret moves into a cookie and disappears from the address bar. It uses only
|
|
|
96
112
|
library and sends no JavaScript at all. Run `memdebug verify` once first if the ledger is from an
|
|
97
113
|
older version (the viewer itself never upgrades or creates anything).
|
|
98
114
|
|
|
115
|
+
The viewer can only look, so where you may want to act it shows you the command instead, as text to paste into a terminal: a snapshot page, the
|
|
116
|
+
compare page, the page of a change made outside a store's history, and the overview's "Needs a look" all say how to put a watched store back
|
|
117
|
+
(`memdebug rollback store NAME --to sN`), and for an outside change they point at the last snapshot taken *before* it, never one that already includes
|
|
118
|
+
it. Stores memdebug only reads (Open WebUI, Mem0) say so instead of offering a command. A rollback of a plain folder is described as it really is:
|
|
119
|
+
changed in place, with the replaced files saved in a backup folder, not as a git commit.
|
|
120
|
+
|
|
99
121
|
Security of the viewer, threat by threat: [docs/threat-model.md](docs/threat-model.md#the-viewer). Its limits: it is plain HTTP on your own
|
|
100
122
|
machine, the first link (with the secret) stays in your browser history, and processes running as you can read the secret.
|
|
101
123
|
Choose Auto, Light or Dark at the top right.
|
|
@@ -130,7 +152,38 @@ installed. Adding a store type is described in [CONTRIBUTING.md](CONTRIBUTING.md
|
|
|
130
152
|
|
|
131
153
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for the ground rules (everything read from a store is untrusted; adapters only read).
|
|
132
154
|
|
|
133
|
-
## Rolling back (
|
|
155
|
+
## Rolling back a watched store (folders and git notes)
|
|
156
|
+
|
|
157
|
+
`memdebug rollback store NAME --to s1` does the same job by the name you see in `memdebug stores`, for a plain folder of notes as well as for
|
|
158
|
+
git notes. First save a known-good copy while the store is healthy:
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
memdebug snapshot store claude-code-shop --label "good, after the move" # a snapshot you can come back to
|
|
162
|
+
memdebug snapshot list # names and dates of snapshots
|
|
163
|
+
memdebug rollback store claude-code-shop --to s2 # shows what would change; writes nothing
|
|
164
|
+
memdebug rollback store claude-code-shop --to s2 --apply # does it, after you type the snapshot id
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`snapshot store` refuses to save while the ledger holds a change made outside the store's own history, or wording flagged as worth a second look,
|
|
168
|
+
since the store's last snapshot: a snapshot is what a rollback later treats as good. Look at the flagged changes first (`memdebug serve`), then add
|
|
169
|
+
`--include-changes` if they are fine. This does not go away if you run it twice.
|
|
170
|
+
|
|
171
|
+
For a **plain folder** there is no git to take exact file versions from, so it works from the snapshot's text, and it is honest about what that means:
|
|
172
|
+
|
|
173
|
+
* **Backups are files.** Anything the rollback would replace or remove is first copied, byte for byte, into a private folder next to your ledger
|
|
174
|
+
(`backups\<store>\<date>-<id>`, with a `manifest.json`), and each copy is read back and checked before your notes are touched. Get a file
|
|
175
|
+
back by copying it out of that folder's `files` folder. These copies contain your memory text and memdebug never deletes them: delete old ones
|
|
176
|
+
yourself when you no longer need them.
|
|
177
|
+
* **Line endings.** A snapshot does not keep them. Files come back as UTF-8 with LF line endings, or with CRLF if the file being replaced uses CRLF
|
|
178
|
+
throughout. A file with mixed endings comes back with LF.
|
|
179
|
+
* **Nothing it cannot restore faithfully is written.** Text that was cut, marked too large, or had bytes that are not valid text is skipped, and
|
|
180
|
+
the dry run says which files and why.
|
|
181
|
+
* **Everything else is the same** as for git notes: the plan writes nothing, applying refuses if anything changed since you saw the plan, links,
|
|
182
|
+
junctions and unsafe names are refused, every write is undone if a later step fails, and the rollback is recorded and can itself be undone.
|
|
183
|
+
|
|
184
|
+
Open WebUI and Mem0 keep their memory in databases that memdebug only ever reads, so they cannot be rolled back; change those in the app.
|
|
185
|
+
|
|
186
|
+
## Rolling back markdown in git, by path
|
|
134
187
|
|
|
135
188
|
`memdebug rollback markdown` puts a memory folder back to what a snapshot held. It is the only command that changes
|
|
136
189
|
your files, so it is cautious by design.
|
|
@@ -160,7 +213,7 @@ What it guarantees:
|
|
|
160
213
|
* **It can be undone.** It takes a snapshot just before and just after, and records a `ROLLBACK` entry in the ledger.
|
|
161
214
|
To undo a rollback, roll back to the "before" snapshot it printed.
|
|
162
215
|
|
|
163
|
-
`memdebug selftest` proves the "no programs run" and "exact bytes" claims on your computer.
|
|
216
|
+
`memdebug selftest` proves the "no programs run" and "exact bytes" claims, and the folder-rollback claims, on your computer.
|
|
164
217
|
|
|
165
218
|
## Fonts
|
|
166
219
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Where memdebug is and where it is going. It is alpha software: the pieces below that are built work and are tested,
|
|
4
4
|
but expect rough edges and breaking changes before 1.0. Order is a plan, not a promise.
|
|
5
5
|
|
|
6
|
-
## Built (0.
|
|
6
|
+
## Built (0.3)
|
|
7
7
|
|
|
8
8
|
* Tamper-evident ledger (hash chain) with `verify`, snapshots whose content is chained in, and compare.
|
|
9
9
|
* **Witness:** a second copy of the ledger's head kept elsewhere, so a rewritten or cut-short ledger is noticed.
|
|
@@ -16,6 +16,9 @@ but expect rough edges and breaking changes before 1.0. Order is a plan, not a p
|
|
|
16
16
|
(exit code 1 when something needs a look), `status`, `watch`.
|
|
17
17
|
* Detection of changes that bypassed a store's own history, with false-alarm guards.
|
|
18
18
|
* **Reports** in Markdown, JSON and SARIF, with CI exit codes.
|
|
19
|
+
* **Rollback for plain folders** (`memdebug rollback store`, and `memdebug snapshot store` to save a known-good copy first), rebuilt from the snapshot's
|
|
20
|
+
text with backups of anything replaced; Open WebUI and Mem0 still cannot be rolled back.
|
|
21
|
+
* **Published on PyPI** as `memdebug` (`pip install memdebug`), released by a tag-triggered workflow that needs an approval click.
|
|
19
22
|
* **A first slice of provenance for Open WebUI:** its own `created_by` label and a chat-timing comparison, as evidence only.
|
|
20
23
|
* **Hints** ("worth a second look"): heuristics for instructions to send data, remove confirmation or weaken safeguards,
|
|
21
24
|
hidden characters and secret-like strings. Labelled as guesses; secrets are never repeated.
|
|
@@ -27,19 +30,16 @@ but expect rough edges and breaking changes before 1.0. Order is a plan, not a p
|
|
|
27
30
|
|
|
28
31
|
## Next, roughly in this order
|
|
29
32
|
|
|
30
|
-
1. **Rollback for plain folders
|
|
31
|
-
memdebug, with the same guarantees as the git version (dry run, backups, undo on failure). It adds a second write path into
|
|
32
|
-
your files, so it gets the same adversarial testing before it ships.
|
|
33
|
+
1. **Rollback for plain folders: built** (see Built). Still to do: a way to list and clean up old backups, and trying it on real agent memory folders.
|
|
33
34
|
2. **More provenance.** A first slice exists for Open WebUI (the app's own label and how close a chat was). Still to do: which conversation turn
|
|
34
35
|
wrote a memory and what the assistant had just read, and readers for other agents' session logs (Claude Code transcripts, OpenClaw).
|
|
35
36
|
The earlier plan, in full: **Provenance from session logs.** Which conversation turn wrote a memory, and whether it came from the user or from
|
|
36
37
|
something the agent read. This turns a change log into an incident-response tool. It needs a reader per agent (first
|
|
37
38
|
candidates: Open WebUI chat records, Claude Code session transcripts), and depends on seeing real log formats.
|
|
38
39
|
3. **More agents,** each added only once its memory location is documented (candidates: Cursor, Cline, Aider, Continue, Goose, Claude Desktop's local files).
|
|
39
|
-
4. **
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
CI and the release dry run have run on GitHub and passed on all three systems.
|
|
40
|
+
4. **Releases.** 0.2.0 was published to GitHub and PyPI on 2026-10-07 through the tag-triggered workflow (PyPI trusted publishing, with a
|
|
41
|
+
required approval and provenance attestations). The install from PyPI has been checked in a clean Linux environment and on Windows 11 (pipx, Python 3.14); macOS
|
|
42
|
+
still needs the same check. Still to do: signed release notes and a smoother route for people without Python (item 5).
|
|
43
43
|
5. **Better setup for non-advanced users:** an installer with no Python knowledge needed, and a way to keep `watch` running
|
|
44
44
|
without a terminal.
|
|
45
45
|
6. **Viewer:** filter by store, show hints in the overview, a status page that matches `memdebug status`.
|
|
@@ -20,7 +20,7 @@ only. It does not list or open anything else in the folder.
|
|
|
20
20
|
ChatGPT, Claude's apps (claude.ai on the web, desktop and phone), Gemini and Copilot keep their memory in the provider's cloud.
|
|
21
21
|
There is nothing on this computer to point memdebug at, and memdebug cannot tell whether those apps are installed, so it never
|
|
22
22
|
claims to have found them. Review or clear that memory in each app's settings. To keep a record of how it changes, copy it
|
|
23
|
-
into a markdown file now and then, in a folder you add to memdebug (`memdebug add <folder>`).
|
|
23
|
+
into a markdown file now and then, in a folder you add to memdebug (`memdebug add <folder>`). A watched folder can be rolled back to a snapshot with `memdebug rollback store NAME --to s1`.
|
|
24
24
|
|
|
25
25
|
## Adding an agent
|
|
26
26
|
|
|
@@ -31,10 +31,11 @@ proposes new versions of the upload and download actions) so a problem shows up
|
|
|
31
31
|
## Each release
|
|
32
32
|
|
|
33
33
|
1. Run `memdebug selftest` and the tests on your own machine (Windows is the one CI cannot fully stand in for).
|
|
34
|
-
2. Update `__version__` in `src/memdebug/__init__.py` and `version` in `pyproject.toml` (they must match) and
|
|
35
|
-
|
|
34
|
+
2. Update `__version__` in `src/memdebug/__init__.py` and `version` in `pyproject.toml` (they must match) and rename the changelog's `[Unreleased]` heading to the new
|
|
35
|
+
version with the release date, for example `## [0.2.1] - 2026-11-02`.
|
|
36
36
|
3. Commit, then tag and push: `git tag v0.2.0 && git push origin v0.2.0`. The workflow refuses to publish if the tag and the version differ.
|
|
37
|
-
4. Approve the `pypi` environment if you required that. After a minute
|
|
37
|
+
4. Approve the `pypi` environment if you required that. After a minute, in a clean virtual environment (not your working one), check the real
|
|
38
|
+
install: `pip install memdebug==<version>`, then `memdebug --version` and `memdebug demo`. Do this on Windows as well as Linux or macOS.
|
|
38
39
|
|
|
39
40
|
## If something goes wrong
|
|
40
41
|
|
|
@@ -83,6 +83,7 @@ a control, that the attack works against ordinary git, and reports SKIP rather t
|
|
|
83
83
|
| Other users or programs on the machine | 192-bit secret, HttpOnly SameSite=Strict cookie, constant-time comparison; nothing is served without it |
|
|
84
84
|
| Another program taking the port | The port is taken exclusively (matters on Windows); `selftest` checks it |
|
|
85
85
|
| Changing anything | Only GET and HEAD; the ledger is opened read-only. The theme switch sets one cookie and nothing else |
|
|
86
|
+
| A command shown for copying that a hostile ledger turned into something else | The "put it back" guidance is only text, never a link or a button. A store name from the ledger goes into a command only if it passes the same strict rule as a watched store's name (lowercase letters, digits, dot, dash, underscore); anything else gets a `<name>` placeholder, so pasting a command can never do more than the command says. Stores memdebug cannot write to never get one. For an outside change it names only a snapshot taken before it |
|
|
86
87
|
| Odd, oversized or slow requests | Strict limits on request line, path, query, headers, connections and time; only fixed routes and validated ids; nothing from the request is echoed |
|
|
87
88
|
|
|
88
89
|
## Rollback (markdown/git)
|
|
@@ -101,6 +102,26 @@ Rollback is the only part of memdebug that changes your files, so it is the most
|
|
|
101
102
|
| A failure half-way | Every step is journaled; on any failure (or Ctrl+C) files, index and branch are put back and you are told |
|
|
102
103
|
| An unrecorded rollback | The ledger record is validated before any file is touched; the state is snapshotted before and after; a `ROLLBACK` entry is chained into the ledger; undoing is rolling back to the "before" snapshot |
|
|
103
104
|
|
|
105
|
+
|
|
106
|
+
## Rollback of a plain folder
|
|
107
|
+
|
|
108
|
+
Rollback of a watched store by name (`memdebug rollback store`) uses the same file-writing code as the git engine (`adapters/fileops.py`), so links,
|
|
109
|
+
junctions, case clashes, atomic writes and the undo journal are identical. What differs, because a folder has no git history:
|
|
110
|
+
|
|
111
|
+
| Threat | Protection |
|
|
112
|
+
| --- | --- |
|
|
113
|
+
| Restoring text a snapshot could not keep faithfully | The snapshot's text is the only source. Text that was cut, marked too large or had undecodable bytes is refused and named in the plan, never written. Line endings are LF, or CRLF if the file being replaced uses CRLF throughout; mixed endings become LF. |
|
|
114
|
+
| Losing what the rollback replaces | Every file that would be overwritten or removed is first copied byte for byte into a private folder next to the ledger (0700/0600 on POSIX), the copy is read back and checked, and nothing in the notes is touched if that fails. |
|
|
115
|
+
| Backups landing inside the notes (and being read as memories), or the notes inside the backups | The plan refuses (a blocker) when either folder contains the other, and applying refuses again. |
|
|
116
|
+
| A "good" snapshot that is really an attack | `memdebug snapshot store` refuses while the ledger holds an outside-history change or flagged wording since the store's last snapshot. It reads the ledger, so looking again does not clear it; only a deliberate `--include-changes` does. |
|
|
117
|
+
| The folder changing between the plan and the write | Applying re-plans and refuses if the plan differs; every target is re-checked before the first backup or write. |
|
|
118
|
+
| A snapshot (or a tampered ledger) naming unsafe files | Names are validated by the same rule as the reader (no `..`, absolute, device or case-clashing names), and files outside the store's subfolder, or outside a named-files store, are never written. |
|
|
119
|
+
|
|
120
|
+
Known limits: backups hold your memory text and are never deleted by memdebug; a plain-folder rollback cannot restore exact bytes for files whose
|
|
121
|
+
line endings were mixed; a store with no history cannot show an outside-history bypass, only the changes observed between looks. The Windows
|
|
122
|
+
permissions of the backup folder are those of your user profile (memdebug sets no ACL), and the junction guard is exercised on every platform by
|
|
123
|
+
simulation, but real junctions are only tested on Windows runs of the suite.
|
|
124
|
+
|
|
104
125
|
## Stores, settings and discovery
|
|
105
126
|
|
|
106
127
|
| Threat | Defence |
|
|
@@ -147,7 +168,7 @@ Read these. They are why this is alpha software.
|
|
|
147
168
|
installation. It cannot do more than you could already do with `docker exec`, but it does require that access.
|
|
148
169
|
* **Stores without a history cannot show a bypass.** For a plain folder or Open WebUI memdebug sees only the state at each look, so a change
|
|
149
170
|
made and reverted between looks is invisible, and an attacker's edit looks like any other. Prefer a git repository when you can.
|
|
150
|
-
* **Rollback for
|
|
171
|
+
* **Rollback for Open WebUI and Mem0 does not exist** (memdebug only reads those). Plain folders and git notes can be rolled back.
|
|
151
172
|
* **Rollback restores files, not the world.** It does not change what a *running* agent has already loaded (restart the
|
|
152
173
|
session), does not rebuild anything derived from the memory (summaries, embeddings), cannot undo what the agent did
|
|
153
174
|
because of a bad memory, and a still-running agent can write the same text back. Stop the agent first.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "memdebug"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Inspect, compare and roll back what an AI agent's memory holds. Local-first and agent-neutral."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""The file-level half of a rollback, shared by the git engine (restore.py) and the plain-folder engine (folder_restore.py).
|
|
2
|
+
|
|
3
|
+
Nothing here knows about git. It reads, checks, writes and removes files under one root folder, and it is the only code that
|
|
4
|
+
does, so the same rules hold for every store type:
|
|
5
|
+
|
|
6
|
+
* links, junctions and anything that is not a plain file or folder are never followed and never written through;
|
|
7
|
+
* a file whose name differs from another in the same folder only by letter case is refused;
|
|
8
|
+
* files are written through a temporary file in the same folder and an atomic rename;
|
|
9
|
+
* every write is journaled so that a failed step can be undone, newest first.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
import stat
|
|
16
|
+
from typing import Sequence
|
|
17
|
+
|
|
18
|
+
from ..errors import RestoreError
|
|
19
|
+
from ..textsafe import safe_text
|
|
20
|
+
from .markdown_git import MAX_FILE_BYTES, _is_reparse_point, _text_from_bytes
|
|
21
|
+
|
|
22
|
+
_O_BINARY = getattr(os, "O_BINARY", 0)
|
|
23
|
+
_O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _is_link(info: os.stat_result) -> bool:
|
|
27
|
+
return stat.S_ISLNK(info.st_mode) or _is_reparse_point(info)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FileOps:
|
|
31
|
+
"""Subclasses set `self._root` (the folder every path is relative to) and plan what to write."""
|
|
32
|
+
|
|
33
|
+
_root: str | os.PathLike[str]
|
|
34
|
+
|
|
35
|
+
def _inspect(self, relpath: str) -> tuple[str, bytes | None]:
|
|
36
|
+
"""Read a working-tree file without following links: ("ok", bytes), ("missing", None), ("large", None) or
|
|
37
|
+
("unsafe", None)."""
|
|
38
|
+
current = str(self._root)
|
|
39
|
+
parts = relpath.split("/")
|
|
40
|
+
for part in parts[:-1]:
|
|
41
|
+
current = os.path.join(current, part)
|
|
42
|
+
try:
|
|
43
|
+
info = os.lstat(current)
|
|
44
|
+
except FileNotFoundError:
|
|
45
|
+
return "missing", None
|
|
46
|
+
except OSError:
|
|
47
|
+
return "unsafe", None
|
|
48
|
+
if _is_link(info) or not stat.S_ISDIR(info.st_mode):
|
|
49
|
+
return "unsafe", None
|
|
50
|
+
full = os.path.join(current, parts[-1])
|
|
51
|
+
try:
|
|
52
|
+
info = os.lstat(full)
|
|
53
|
+
except FileNotFoundError:
|
|
54
|
+
return "missing", None
|
|
55
|
+
except OSError:
|
|
56
|
+
return "unsafe", None
|
|
57
|
+
if _is_link(info) or not stat.S_ISREG(info.st_mode):
|
|
58
|
+
return "unsafe", None
|
|
59
|
+
if info.st_size > MAX_FILE_BYTES:
|
|
60
|
+
return "large", None
|
|
61
|
+
try:
|
|
62
|
+
fd = os.open(full, os.O_RDONLY | _O_BINARY | _O_NOFOLLOW | getattr(os, "O_NONBLOCK", 0))
|
|
63
|
+
except OSError:
|
|
64
|
+
return "unsafe", None
|
|
65
|
+
try:
|
|
66
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
67
|
+
return "unsafe", None
|
|
68
|
+
with os.fdopen(fd, "rb", closefd=False) as handle:
|
|
69
|
+
data = handle.read(MAX_FILE_BYTES + 1)
|
|
70
|
+
except OSError:
|
|
71
|
+
return "unsafe", None
|
|
72
|
+
finally:
|
|
73
|
+
os.close(fd)
|
|
74
|
+
return ("large", None) if len(data) > MAX_FILE_BYTES else ("ok", data)
|
|
75
|
+
|
|
76
|
+
def _check_target(self, rel: str, must_exist: bool = False) -> None:
|
|
77
|
+
current = str(self._root)
|
|
78
|
+
parts = rel.split("/")
|
|
79
|
+
for part in parts[:-1]:
|
|
80
|
+
current = os.path.join(current, part)
|
|
81
|
+
try:
|
|
82
|
+
info = os.lstat(current)
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
if must_exist:
|
|
85
|
+
raise RestoreError(f"{safe_text(rel, 60)}: a folder disappeared") from None
|
|
86
|
+
break
|
|
87
|
+
if _is_link(info) or not stat.S_ISDIR(info.st_mode):
|
|
88
|
+
raise RestoreError(f"{safe_text(rel, 60)}: a folder on the way is a link or not a folder")
|
|
89
|
+
else:
|
|
90
|
+
try:
|
|
91
|
+
names = os.listdir(current)
|
|
92
|
+
except OSError as exc:
|
|
93
|
+
raise RestoreError(f"{safe_text(rel, 60)}: cannot read its folder ({exc.strerror})") from exc
|
|
94
|
+
name = parts[-1]
|
|
95
|
+
if name not in names and any(other.casefold() == name.casefold() for other in names):
|
|
96
|
+
raise RestoreError(f"{safe_text(rel, 60)}: another file in that folder differs only by letter case")
|
|
97
|
+
target = os.path.join(current, name)
|
|
98
|
+
try:
|
|
99
|
+
info = os.lstat(target)
|
|
100
|
+
except FileNotFoundError:
|
|
101
|
+
if must_exist:
|
|
102
|
+
raise RestoreError(f"{safe_text(rel, 60)}: the file disappeared") from None
|
|
103
|
+
return
|
|
104
|
+
if _is_link(info) or not stat.S_ISREG(info.st_mode):
|
|
105
|
+
raise RestoreError(f"{safe_text(rel, 60)}: not a plain file")
|
|
106
|
+
|
|
107
|
+
def _write_file(self, rel: str, data: bytes) -> list[str]:
|
|
108
|
+
"""Replace or create a file through a temporary file and an atomic rename. Returns the folders it created."""
|
|
109
|
+
created: list[str] = []
|
|
110
|
+
try:
|
|
111
|
+
current = str(self._root)
|
|
112
|
+
parts = rel.split("/")
|
|
113
|
+
for part in parts[:-1]:
|
|
114
|
+
current = os.path.join(current, part)
|
|
115
|
+
try:
|
|
116
|
+
info = os.lstat(current)
|
|
117
|
+
except FileNotFoundError:
|
|
118
|
+
os.mkdir(current)
|
|
119
|
+
created.append(current)
|
|
120
|
+
info = os.lstat(current)
|
|
121
|
+
if _is_link(info) or not stat.S_ISDIR(info.st_mode):
|
|
122
|
+
raise RestoreError(f"{safe_text(rel, 60)}: a folder on the way is a link or not a folder")
|
|
123
|
+
target = os.path.join(current, parts[-1])
|
|
124
|
+
mode = None
|
|
125
|
+
try:
|
|
126
|
+
info = os.lstat(target)
|
|
127
|
+
if _is_link(info) or not stat.S_ISREG(info.st_mode):
|
|
128
|
+
raise RestoreError(f"{safe_text(rel, 60)}: not a plain file")
|
|
129
|
+
mode = stat.S_IMODE(info.st_mode)
|
|
130
|
+
except FileNotFoundError:
|
|
131
|
+
pass
|
|
132
|
+
temporary = os.path.join(current, f".memdebug-{secrets.token_hex(8)}.tmp")
|
|
133
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | _O_BINARY | _O_NOFOLLOW, 0o666)
|
|
134
|
+
try:
|
|
135
|
+
with os.fdopen(fd, "wb") as handle:
|
|
136
|
+
handle.write(data)
|
|
137
|
+
handle.flush()
|
|
138
|
+
os.fsync(handle.fileno())
|
|
139
|
+
if mode is not None and os.name == "posix":
|
|
140
|
+
os.chmod(temporary, mode)
|
|
141
|
+
os.replace(temporary, target)
|
|
142
|
+
except BaseException:
|
|
143
|
+
try:
|
|
144
|
+
os.unlink(temporary)
|
|
145
|
+
except OSError:
|
|
146
|
+
pass
|
|
147
|
+
raise
|
|
148
|
+
return created
|
|
149
|
+
except BaseException:
|
|
150
|
+
for folder in reversed(created):
|
|
151
|
+
try:
|
|
152
|
+
os.rmdir(folder)
|
|
153
|
+
except OSError:
|
|
154
|
+
pass
|
|
155
|
+
raise
|
|
156
|
+
|
|
157
|
+
def _remove_file(self, rel: str) -> None:
|
|
158
|
+
self._check_target(rel, must_exist=True)
|
|
159
|
+
os.unlink(os.path.join(str(self._root), *rel.split("/")))
|
|
160
|
+
|
|
161
|
+
def _verify_files(self, items: Sequence) -> None:
|
|
162
|
+
"""Every item must now be on disk exactly as planned."""
|
|
163
|
+
for item in items:
|
|
164
|
+
status, data = self._inspect(item.path)
|
|
165
|
+
if item.action == "remove":
|
|
166
|
+
if status != "missing":
|
|
167
|
+
raise RestoreError(f"{safe_text(item.path, 60)} is still there after removal")
|
|
168
|
+
elif status != "ok" or _text_from_bytes(data or b"") != item.target_text:
|
|
169
|
+
raise RestoreError(f"{safe_text(item.path, 60)} does not hold the restored text after writing")
|
|
170
|
+
|
|
171
|
+
def _undo_files(self, journal: Sequence, created_dirs: Sequence[str]) -> list[str]:
|
|
172
|
+
"""Put the files back, newest step first. Returns what could not be undone."""
|
|
173
|
+
problems: list[str] = []
|
|
174
|
+
for path, old in reversed(journal):
|
|
175
|
+
try:
|
|
176
|
+
if old is None:
|
|
177
|
+
full = os.path.join(str(self._root), *path.split("/"))
|
|
178
|
+
if os.path.lexists(full):
|
|
179
|
+
os.unlink(full)
|
|
180
|
+
else:
|
|
181
|
+
self._write_file(path, old)
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
problems.append(f"{safe_text(path, 60)}: {safe_text(exc, 80)}")
|
|
184
|
+
for folder in reversed(created_dirs):
|
|
185
|
+
try:
|
|
186
|
+
os.rmdir(folder)
|
|
187
|
+
except OSError:
|
|
188
|
+
pass
|
|
189
|
+
return problems
|
|
190
|
+
|
|
@@ -62,6 +62,11 @@ class FolderAdapter(MarkdownGitAdapter):
|
|
|
62
62
|
raise AdapterError("the files to watch must be 1 to 10 plain markdown file names in the folder itself")
|
|
63
63
|
self._only = names
|
|
64
64
|
|
|
65
|
+
@property
|
|
66
|
+
def only(self) -> tuple[str, ...] | None:
|
|
67
|
+
"""The named files this store is limited to, or None when it covers the whole folder."""
|
|
68
|
+
return self._only
|
|
69
|
+
|
|
65
70
|
def read_history(self, max_rows: int) -> HistoryRead:
|
|
66
71
|
return HistoryRead(events=[], refs=set(), truncated=False)
|
|
67
72
|
|