feat(raw): raw derive unpacks ZIP archives and mails with attachments into a folder
CI / verify (push) Successful in 6m36s
CI / pwsh (push) Successful in 2m20s
Release / release (push) Successful in 35s
Release / wiki-mirror (push) Skipped

A ZIP becomes incoming/<stem>/ with its files and a derived text beside each
document, the ZIP is removed and its sha256 named in the output. A mail with
attachments becomes a folder with original.eml, derived.md and the attachments;
a ZIP attached to a mail is unpacked once, an attached mail is quoted. Unsafe
entries, nested ZIPs and size limits are refused before anything is written.

Files changed:
- CHANGES.md
- VERSION
- docs/how-to/ingest-a-source.md
- docs/reference/commands/README.md
- docs/reference/commands/raw-material-and-uploads.md
- raw/CONTRACT.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/commands/raw_cmd.py
- tools/chemenu/filters/__init__.py
- tools/chemenu/filters/archive.py
- tools/chemenu/filters/mail.py
- tools/chemenu/tests/test_portability.py
- tools/chemenu/tests/test_raw_derive.py

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
torbenandClaude Code committed 2026-10-08 13:04:01 +02:00
1 parent 9c0bb6bfbf
commit 6659ca5409
14 files changed
+828 -94

No files matched your search

+7 -5
View File
@@ -123,7 +123,7 @@ sources coverage read idempotent budget:counted exit:0
sources trace read idempotent budget:counted exit:0,1 Trace provenance in either direction: raw file, or page.
sources rebuild-index write idempotent budget:counted exit:0,1 Regenerate the `kb/provenance.md` reverse index.
raw fetch write non-idempotent budget:counted exit:0,1 Capture a web page the user names into `incoming/`: the HTML as received plus a derived text, for `raw accept` to promote.
raw derive write non-idempotent budget:counted exit:0,1 Derive reading text from a PDF, e-mail, Office file or web page in `incoming/` and leave the original beside it, for `raw accept` to promote.
raw derive write non-idempotent budget:counted exit:0,1 Derive reading text from a PDF, e-mail, Office file or web page in `incoming/`, or unpack a ZIP, and leave the original beside it, for `raw accept` to promote.
raw capture write non-idempotent budget:counted exit:0,1 Capture documentation from a git repository into `incoming/<bundle>/`, with a manifest naming the repository, ref rule and commit, for `raw accept` to promote.
raw status read idempotent budget:counted exit:0 Report which captured bundles under `raw/` have fallen behind their repository, with each changed file as `A`/`M`/`D`.
raw pending read idempotent budget:counted exit:0 List what waits in `incoming/`, oldest first, and name the entry an ingest without an argument takes next.
@@ -1469,7 +1469,7 @@ Capture a web page the user names into `incoming/`: the HTML as received plus a
#### `raw derive`
Derive reading text from a PDF, e-mail, Office file or web page in `incoming/` and leave the original beside it, for `raw accept` to promote.
Derive reading text from a PDF, e-mail, Office file or web page in `incoming/`, or unpack a ZIP, and leave the original beside it, for `raw accept` to promote.
**SYNOPSIS**
@@ -1489,19 +1489,20 @@ Derive reading text from a PDF, e-mail, Office file or web page in `incoming/` a
- `tools/wikitool raw derive incoming/report.docx`
- `tools/wikitool raw derive incoming/Angebot-XYZ.xlsx --name angebot-xyz`
- `tools/wikitool raw derive incoming/meeting-files`
- `tools/wikitool raw derive incoming/scans.zip`
**EXIT STATUS**
- 0 success
- 1 The path is not directly in `incoming/`, does not exist, or no filter reads the file's extension
- 1 The target folder or a `.md` already exists, or the path would be over the path budget
- 1 The document cannot be read (damaged, encrypted, a mail with attachments) or the filter's package is not installed
- 1 The document cannot be read (damaged, encrypted), a container breaks one of its rules (a ZIP inside a ZIP, an unsafe path, a limit) or the filter's package is not installed
**ON FAILURE**
- The path is not directly in `incoming/`, does not exist, or no filter reads the file's extension -> Nothing was written. Fix the argument and retry once - a file in a subfolder is moved up, or its folder is derived whole
- The target folder or a `.md` already exists, or the path would be over the path budget -> Nothing was written. Remove what is there or pass `--name <shorter stem>`, then retry once. An existing `.md` is never overwritten
- The document cannot be read (damaged, encrypted, a mail with attachments) or the filter's package is not installed -> Nothing was written. A missing package is reported with the preflight hint - show it to the user and stop, never install it. Any other filter keeps working
- The document cannot be read (damaged, encrypted), a container breaks one of its rules (a ZIP inside a ZIP, an unsafe path, a limit) or the filter's package is not installed -> Nothing was written. A missing package is reported with the preflight hint - show it to the user and stop, never install it. Any other filter keeps working
**NEVER**
@@ -1514,7 +1515,8 @@ Derive reading text from a PDF, e-mail, Office file or web page in `incoming/` a
- A single file directly in `incoming/` becomes a folder named after its stem (or `--name`) holding `original.<ext>` - the original bytes, moved - and `derived.md`. The fixed names keep the path inside the path budget; the header of `derived.md` records the file's own name as `original_name`.
- A folder is derived in place: each file with a filter gets `<stem>.md` beside it, nothing is renamed, and the files without a filter are named in the output.
- `derived.md` starts with a fixed header (`derived_by`, `filter`, `engine`, `derived`, `derived_from`, `original_name` for a renamed file, `title`, then the fields of the format) followed by the derived text. The same bytes always give the same text; only `derived` differs between runs. `engine` names the tool version and, where a package reads the format, its version.
- Formats: web pages (`.html`), Word (`.docx`), PowerPoint (`.pptx`), Excel (`.xlsx`, `.xlsm`) and e-mail (`.eml`) read with the standard library only. A mail with attachments is refused.
- Formats: web pages (`.html`), Word (`.docx`), PowerPoint (`.pptx`), Excel (`.xlsx`, `.xlsm`), e-mail (`.eml`) and ZIP archives (`.zip`), read with the standard library only.
- A ZIP and a mail are containers. A ZIP at the top of `incoming/` becomes the folder `incoming/<stem>/` holding its files under their own names, each with a `<stem>.md` where a filter reads it; the ZIP is removed and the output names it with its sha256. A mail becomes a folder with `original.eml`, `derived.md` and its attachments under their own names; a ZIP attached to it is unpacked once into a folder of its own, and an attached mail is quoted in `derived.md`, not unpacked. A ZIP inside a ZIP, an absolute or `..` path, a symbolic link, an encrypted entry, more than 1000 entries, a file over 25 MiB or more than 250 MiB in total refuse the whole call. Hidden entries (`__MACOSX/`, `.DS_Store`, anything starting with a dot) are named, not written. A name taken twice, or reserved (`original.*` and `derived.md` at the top of a mail's folder, `_capture.json` anywhere), gets `-2`, `-3` before its extension, and the output lists each rename.
- A name already taken under `raw/` is a WARN, not a refusal: whether the folder is a later edition of that source or a separate one is the human's call. A later edition of a derived source is replaced file by file with `raw accept --replaces`; there is no `--replaces-bundle` for it.
- After a success the output prints the warnings the filters raised, at most three `CHECK` lines per format that was derived in this run - what to look at before the text is trusted - and the `raw accept` line for the result.
- Never edit `derived.md` by hand: a better derivation is a new `raw derive` of the original.
+1 -1
View File
@@ -112,7 +112,7 @@ tools/
kb_state.py the KB version (.wikitool-kb.json) and the migration chain
corpus_diff.py invariant comparison of kb/ between two revisions
web_capture.py `raw fetch`'s core: fetch a page, decide its charset, derive Markdown-like text from the HTML - standard library only, deterministic
filters/ the import filters behind `raw derive`: a registry (`__init__.py`) and one module per format family (HTML, mail, Office), each registering the extensions it reads - standard library, or a package imported lazily so a missing one fails only its own format
filters/ the import filters behind `raw derive`: a registry (`__init__.py`) and one module per format family (HTML, mail, Office, ZIP), each registering the extensions it reads - standard library, or a package imported lazily so a missing one fails only its own format
repo_capture.py `raw capture`/`raw status`'s core: resolve a ref rule, fetch it into a bare cache, select files by glob as blobs, the `_capture.json` manifest - git with the host's credentials, never a prompt; plus the one write back, a single-file commit pushed without force for `export guidelines`
guideline_export.py `export guidelines`' core: select pages by `search` predicates, render them into one deterministic GUIDELINES.md, and recognise an export again (`EXPORT_MARKER`) - with no CLI attached
search/ pluggable search backends, plus service.py - the search core
+140 -55
View File
@@ -1886,9 +1886,24 @@ def _collision_warning(name: str) -> Optional[str]:
return _folder_collision_message(name, holder)
def _plan_derive_file(path: Path, name: Optional[str]):
@dataclass(frozen=True)
class _FilePlan:
"""A single file turned into a folder, not yet written."""
folder: Path
stem: str
candidate: filters.Filter
original: Optional[Path] # where the source moves to; None when the source is only unpacked
derived: Optional[Path]
markdown: bytes
container: Optional[filters.Container]
derivations: list[filters.Derivation]
def _plan_derive_file(path: Path, name: Optional[str]) -> _FilePlan:
"""The single-file form: `incoming/<stem>/` with the original moved in as
`original.<ext>` and the derived text beside it."""
`original.<ext>` and the derived text beside it - or, for a container (a ZIP,
a mail), the members under their own names."""
incoming = _incoming_dir()
if not path.is_file():
raise _Refused(f"{rel_path(path)} does not exist or is not a file.")
@@ -1916,46 +1931,67 @@ def _plan_derive_file(path: Path, name: Optional[str]):
"or remove what is there, or pass --name <stem>."
)
extension = filters.extension_of(path.name)
original = folder / f"{ORIGINAL_STEM}{extension}"
derived = folder / DERIVED_NAME
for target in (original, derived):
problem = path_budget_problem_for(_shard_dir() / stem / target.name)
data = _read_for_derive(path)
container = None
try:
filters.require_available(candidate)
container = candidate.container(data, path.name)
except ValidationError as exc:
raise _Refused(f"{rel_path(path)}: {exc}") from None
if container is not None and container.text is None:
original = derived = None
markdown = b""
targets = [rel for rel, _data in container.files]
derivations = list(container.derivations)
else:
original = folder / f"{ORIGINAL_STEM}{extension}"
derived = folder / DERIVED_NAME
derivation = container.text if container is not None else _run_filter(candidate, data, path)
assert derivation is not None
markdown = filters.render(
candidate, derivation, derived_from=original.name, original_name=path.name
)
targets = [original.name, derived.name] + (
[rel for rel, _data in container.files] if container is not None else []
)
derivations = [derivation] + (list(container.derivations) if container is not None else [])
for target in targets:
problem = path_budget_problem_for(_shard_dir() / stem / target)
if problem:
raise _Refused(
f"Cannot write {problem}. The folder name comes from the file's name: pass "
"--name <shorter stem>."
)
derivation = _run_filter(candidate, _read_for_derive(path), path)
markdown = filters.render(
candidate,
derivation,
derived_from=original.name,
original_name=path.name,
)
return folder, original, derived, candidate, derivation, markdown, stem
return _FilePlan(folder, stem, candidate, original, derived, markdown, container, derivations)
def _move_in(source: Path, folder: Path, original: Path, derived: Path, markdown: bytes) -> None:
"""Create `folder`, write the derived text, then move the original in - last,
so a failure before it has taken nothing from `incoming/`. A failure part-way
puts back what this call changed."""
def _write_folder(source: Path, plan: _FilePlan) -> None:
"""Create the folder and write everything into it, then move the original in
(or, for an unpacked archive, remove it) - last, so a failure before it has
taken nothing from `incoming/`. A failure part-way removes the folder this
call made and puts the original back."""
moved = False
try:
folder.mkdir()
with derived.open("xb") as handle:
handle.write(markdown)
source.rename(original)
moved = True
plan.folder.mkdir()
if plan.derived is not None:
with plan.derived.open("xb") as handle:
handle.write(plan.markdown)
if plan.container is not None:
for rel, data in plan.container.files:
target = plan.folder / rel
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("xb") as handle:
handle.write(data)
if plan.original is not None:
source.rename(plan.original)
moved = True
else:
source.unlink()
except OSError as exc:
if moved:
original.rename(source)
derived.unlink(missing_ok=True)
if folder.is_dir():
try:
folder.rmdir()
except OSError:
pass
fail(escape(f"Could not write {rel_path(folder)}: {exc}. incoming/ was left as it was."))
if moved and plan.original is not None:
plan.original.rename(source)
shutil.rmtree(plan.folder, ignore_errors=True)
fail(escape(f"Could not write {rel_path(plan.folder)}: {exc}. incoming/ was left as it was."))
def _plan_derive_folder(folder: Path):
@@ -1988,7 +2024,7 @@ def _plan_derive_folder(folder: Path):
untouched: list[Path] = []
for file in files:
candidate = filters.filter_for(file.name)
if candidate is None:
if candidate is None or candidate.container_only:
untouched.append(file)
else:
plan.append((file, candidate, file.with_suffix(".md")))
@@ -2024,14 +2060,34 @@ def _plan_derive_folder(folder: Path):
return derived, untouched
def _echo_notes(derivations: list[filters.Derivation]) -> None:
for derivation in derivations:
for warning in derivation.warnings:
typer.echo(f"WARN {warning}")
for line in filters.unique(c for d in derivations for c in d.checks):
def _echo_notes(
derivations: list[filters.Derivation], container: Optional[filters.Container] = None
) -> None:
warnings = [w for d in derivations for w in d.warnings]
checks = [c for d in derivations for c in d.checks]
if container is not None:
warnings += container.warnings
checks += container.checks
for warning in filters.unique(warnings):
typer.echo(f"WARN {warning}")
for line in filters.unique(checks):
typer.echo(f"CHECK {line}")
def _container_report(container: filters.Container) -> list[str]:
lines = [f" {src} -> {md} ({name})" for src, md, name in container.derived]
if container.plain:
lines.append(" No filter for, written as they are: " + ", ".join(container.plain))
if container.renamed:
lines.append(
" Renamed (a name was taken or reserved): "
+ ", ".join(f"{old} -> {new}" for old, new in container.renamed)
)
if container.hidden:
lines.append(" Hidden entries, named but not written: " + ", ".join(container.hidden))
return lines
def _derive_next(target: Path) -> str:
return (
" Next (after the commitment question in wiki-ingest):\n"
@@ -2041,8 +2097,8 @@ def _derive_next(target: Path) -> str:
@cli_contract.record(cli_contract.CommandRecord(
path="raw derive",
summary="Derive reading text from a PDF, e-mail, Office file or web page in `incoming/` and "
"leave the original beside it, for `raw accept` to promote.",
summary="Derive reading text from a PDF, e-mail, Office file or web page in `incoming/`, or "
"unpack a ZIP, and leave the original beside it, for `raw accept` to promote.",
synopsis=(
cli_contract.Variant(
usage="raw derive incoming/<file> [--name <stem>]",
@@ -2078,8 +2134,20 @@ def _derive_next(target: Path) -> str:
"`derived` differs between runs. `engine` names the tool version and, where a package "
"reads the format, its version.",
"Formats: web pages (`.html`), Word (`.docx`), PowerPoint (`.pptx`), Excel (`.xlsx`, "
"`.xlsm`) and e-mail (`.eml`) read with the standard library only. A mail with "
"attachments is refused.",
"`.xlsm`), e-mail (`.eml`) and ZIP archives (`.zip`), read with the standard library "
"only.",
"A ZIP and a mail are containers. A ZIP at the top of `incoming/` becomes the folder "
"`incoming/<stem>/` holding its files under their own names, each with a `<stem>.md` "
"where a filter reads it; the ZIP is removed and the output names it with its sha256. A "
"mail becomes a folder with `original.eml`, `derived.md` and its attachments under their "
"own names; a ZIP attached to it is unpacked once into a folder of its own, and an "
"attached mail is quoted in `derived.md`, not unpacked. A ZIP inside a ZIP, an "
"absolute or `..` path, a symbolic link, an encrypted entry, more than 1000 entries, a "
"file over 25 MiB or more than 250 MiB in total refuse the whole call. Hidden entries "
"(`__MACOSX/`, `.DS_Store`, anything starting with a dot) are named, not written. A name "
"taken twice, or reserved (`original.*` and `derived.md` at the top of a mail's folder, "
"`_capture.json` anywhere), gets `-2`, `-3` before its extension, and the output lists "
"each rename.",
"A name already taken under `raw/` is a WARN, not a refusal: whether the folder is a "
"later edition of that source or a separate one is the human's call. A later edition of "
"a derived source is replaced file by file with `raw accept --replaces`; there is no "
@@ -2103,8 +2171,9 @@ def _derive_next(target: Path) -> str:
"then retry once. An existing `.md` is never overwritten",
),
cli_contract.Failure(
cause="The document cannot be read (damaged, encrypted, a mail with attachments) or "
"the filter's package is not installed",
cause="The document cannot be read (damaged, encrypted), a container breaks one of its "
"rules (a ZIP inside a ZIP, an unsafe path, a limit) or the filter's package is not "
"installed",
reaction="Nothing was written. A missing package is reported with the preflight "
"hint - show it to the user and stop, never install it. Any other filter keeps "
"working",
@@ -2114,6 +2183,7 @@ def _derive_next(target: Path) -> str:
"tools/wikitool raw derive incoming/report.docx",
"tools/wikitool raw derive incoming/Angebot-XYZ.xlsx --name angebot-xyz",
"tools/wikitool raw derive incoming/meeting-files",
"tools/wikitool raw derive incoming/scans.zip",
),
never=(
"Never edit `derived.md` or its header by hand.",
@@ -2154,19 +2224,34 @@ def raw_derive_command(
typer.echo(_derive_next(resolved))
return
folder, original, derived_md, candidate, derivation, markdown, stem = _refusals_fail(
_plan_derive_file, resolved, name
)
_move_in(resolved, folder, original, derived_md, markdown)
success(escape(
f"Derived {rel_path(derived_md)} from {resolved.name} ({candidate.name}); the original is "
f"now {rel_path(original)}."
))
warning = _collision_warning(stem)
plan = _refusals_fail(_plan_derive_file, resolved, name)
_write_folder(resolved, plan)
container = plan.container
if plan.original is None:
success(escape(
f"Unpacked {resolved.name} into {rel_path(plan.folder)}/ ({plan.candidate.name}); "
f"{resolved.name} is removed."
))
elif container is not None and container.files:
success(escape(
f"Derived {rel_path(plan.derived)} from {resolved.name} ({plan.candidate.name}) with "
f"{len(container.files)} file(s) from its attachments; the original is now "
f"{rel_path(plan.original)}."
))
else:
success(escape(
f"Derived {rel_path(plan.derived)} from {resolved.name} ({plan.candidate.name}); the "
f"original is now {rel_path(plan.original)}."
))
if container is not None:
report = _container_report(container)
if report:
typer.echo("\n".join(report))
warning = _collision_warning(plan.stem)
if warning:
typer.echo("WARN " + warning)
_echo_notes([derivation])
typer.echo(_derive_next(folder))
_echo_notes(plan.derivations, container)
typer.echo(_derive_next(plan.folder))
# --- raw capture / raw status -------------------------------------------------
+38 -2
View File
@@ -49,12 +49,43 @@ class Derivation:
raise ValueError(f"a filter prints at most {MAX_CHECKS} CHECK lines, got {len(self.checks)}")
@dataclass(frozen=True)
class Container:
"""What a container filter makes of one document that holds other files.
`files` is everything `raw derive` writes into the new folder besides the
container's own `derived.md`: the members under their claimed names and the
`.md` beside each member that has a filter. `text` is the container's own
derivation (a mail's body); a ZIP has none, because the archive is removed.
"""
files: tuple[tuple[str, bytes], ...] = ()
derived: tuple[tuple[str, str, str], ...] = () # (member, its .md, filter name)
derivations: tuple[Derivation, ...] = ()
plain: tuple[str, ...] = () # members written as they are - no filter reads them
renamed: tuple[tuple[str, str], ...] = () # (name in the container, name written)
hidden: tuple[str, ...] = () # named but not written
warnings: tuple[str, ...] = ()
checks: tuple[str, ...] = ()
text: Optional[Derivation] = None
def __post_init__(self) -> None:
if len(self.checks) > MAX_CHECKS:
raise ValueError(f"a filter prints at most {MAX_CHECKS} CHECK lines, got {len(self.checks)}")
class Filter:
"""One format family. Subclasses set the class attributes and implement `derive`."""
"""One format family. Subclasses set the class attributes and implement `derive`.
A filter that can hold other files also implements `container`; with
`container_only` it is not a document format inside a folder or another
container - a ZIP is unpacked at the top or refused, never derived in place.
"""
name: str = ""
extensions: tuple[str, ...] = ()
requires: tuple[str, ...] = ()
container_only: bool = False
def available(self) -> bool:
for package in self.requires:
@@ -88,6 +119,11 @@ class Filter:
def derive(self, data: bytes, name: str) -> Derivation:
raise NotImplementedError
def container(self, data: bytes, name: str) -> Optional[Container]:
"""The folder content for a document at the top of `incoming/`, or None
when the document is an ordinary single file."""
return None
_REGISTRY: list[Filter] = []
@@ -169,4 +205,4 @@ def unique(lines: Iterable[str]) -> list[str]:
return out
from chemenu.filters import html_page, mail, ooxml # noqa: E402,F401 (register themselves)
from chemenu.filters import archive, html_page, mail, ooxml # noqa: E402,F401 (register themselves)
+255
View File
@@ -0,0 +1,255 @@
"""ZIP archives, and the machinery every container shares: claiming names in the
new folder, unpacking an archive under limits, and deriving the members.
A container's members are checked and derived completely before `raw derive`
writes the first file, so a refusal leaves `incoming/` as it was.
"""
from __future__ import annotations
import hashlib
import io
import os
import zipfile
from typing import Optional
from chemenu import repo_capture
from chemenu.errors import ValidationError
from chemenu.filters import (
MAX_CHECKS,
Container,
Derivation,
Filter,
filter_for,
register,
render,
require_available,
)
MAX_ENTRIES = 1000
MAX_FILE_BYTES = 25 * 1024 * 1024
MAX_TOTAL_BYTES = 250 * 1024 * 1024
_SYMLINK = 0o120000
_TYPE_MASK = 0o170000
def clean_name(name: str, fallback: str) -> str:
"""One file name out of whatever a mail or an archive calls an attachment:
the part after the last separator, without control characters or leading
dots."""
base = name.replace("\\", "/").rsplit("/", 1)[-1]
base = "".join(ch for ch in base if ch >= " " and ch != "\x7f").strip().lstrip(".").strip()
return base or fallback
def _split(name: str) -> tuple[str, str]:
base, ext = os.path.splitext(name)
return (name, "") if not base else (base, ext)
def _is_hidden(parts: list[str]) -> bool:
return any(part.startswith(".") or part == "__MACOSX" for part in parts)
class Tree:
"""The names claimed in the new folder. A name already taken, or reserved,
gets `-2`, `-3` before its extension; names compare case-insensitively
because the folder may travel to a case-insensitive file system."""
def __init__(self, reserve_top: bool) -> None:
self._reserve_top = reserve_top
self._files: set[str] = set()
self._dirs: set[str] = set()
def _reserved(self, path: str) -> bool:
name = path.rsplit("/", 1)[-1].lower()
if name == repo_capture.MANIFEST_NAME.lower():
return True
return self._reserve_top and "/" not in path and (
name == "derived.md" or name == "original" or name.startswith("original.")
)
def _taken(self, path: str) -> bool:
key = path.lower()
return key in self._files or key in self._dirs or self._reserved(path)
def _free(self, path: str, split: bool) -> str:
directory, _, name = path.rpartition("/")
base, ext = _split(name) if split else (name, "")
candidate, number = path, 1
while self._taken(candidate):
number += 1
candidate = (directory + "/" if directory else "") + f"{base}-{number}{ext}"
return candidate
def _check_parents(self, path: str, original: str) -> None:
parts = path.split("/")
for depth in range(1, len(parts)):
parent = "/".join(parts[:depth])
if parent.lower() in self._files:
raise ValidationError(
f"{original!r} needs a folder named {parent!r}, but a file of that name is "
"already in the container."
)
self._dirs.add(parent.lower())
def claim(self, path: str) -> str:
claimed = self._free(path, split=True)
self._check_parents(claimed, path)
self._files.add(claimed.lower())
return claimed
def claim_folder(self, path: str) -> str:
claimed = self._free(path, split=False)
self._check_parents(claimed, path)
self._dirs.add(claimed.lower())
return claimed
class Collector:
"""Collects the members of one container, then derives them."""
def __init__(self, reserve_top: bool) -> None:
self._tree = Tree(reserve_top)
self._pending: list[tuple[str, str, bytes]] = [] # (claimed path, name it came with, data)
self._hidden: list[str] = []
self._renamed: list[tuple[str, str]] = []
self._unpacked: list[tuple[str, str]] = []
self._bytes = 0
def add_file(self, path: str, data: bytes, came_as: Optional[str] = None) -> str:
claimed = self._tree.claim(path)
if claimed != path:
self._renamed.append((came_as or path, claimed))
self._pending.append((claimed, (came_as or path).rsplit("/", 1)[-1], data))
return claimed
def add_zip(self, folder: str, data: bytes, label: str) -> str:
"""Unpack an archive under `folder` (empty: at the top). Refuses an archive that
holds another one, an unsafe entry or anything over a limit."""
prefix = ""
if folder:
claimed = self._tree.claim_folder(folder)
if claimed != folder:
self._renamed.append((folder + "/", claimed + "/"))
prefix = claimed + "/"
try:
archive = zipfile.ZipFile(io.BytesIO(data))
infos = archive.infolist()
except zipfile.BadZipFile as exc:
raise ValidationError(f"{label} is not a readable ZIP archive: {exc}.") from None
if len(infos) > MAX_ENTRIES:
raise ValidationError(
f"{label} holds {len(infos)} entries; raw derive unpacks at most {MAX_ENTRIES}."
)
added = hidden_here = 0
for info in infos:
name = info.filename.replace("\\", "/")
if info.is_dir() or name.endswith("/"):
continue
if name.startswith("/") or (len(name) > 1 and name[1] == ":"):
raise ValidationError(f"{label}: the entry {info.filename!r} has an absolute path.")
parts = [part for part in name.split("/") if part not in ("", ".")]
if ".." in parts:
raise ValidationError(f"{label}: the entry {info.filename!r} leaves the folder (..).")
if (info.external_attr >> 16) & _TYPE_MASK == _SYMLINK:
raise ValidationError(f"{label}: the entry {info.filename!r} is a symbolic link.")
if info.flag_bits & 0x1:
raise ValidationError(f"{label}: the entry {info.filename!r} is encrypted.")
if not parts:
continue
if _is_hidden(parts):
self._hidden.append(prefix + "/".join(parts))
hidden_here += 1
continue
if parts[-1].lower().endswith(".zip"):
raise ValidationError(
f"{label}: the entry {info.filename!r} is itself a ZIP archive; nested archives "
"are not unpacked. Unpack it by hand and put its files into incoming/."
)
if info.file_size > MAX_FILE_BYTES:
raise ValidationError(
f"{label}: the entry {info.filename!r} is {info.file_size // (1024 * 1024)} MiB "
f"unpacked; raw derive unpacks files up to {MAX_FILE_BYTES // (1024 * 1024)} MiB."
)
try:
with archive.open(info) as handle:
content = handle.read(MAX_FILE_BYTES + 1)
except (zipfile.BadZipFile, NotImplementedError, RuntimeError, OSError) as exc:
raise ValidationError(f"{label}: cannot read {info.filename!r}: {exc}.") from None
if len(content) > MAX_FILE_BYTES:
raise ValidationError(f"{label}: the entry {info.filename!r} is larger than it says.")
self._bytes += len(content)
if self._bytes > MAX_TOTAL_BYTES:
raise ValidationError(
f"{label} unpacks to more than {MAX_TOTAL_BYTES // (1024 * 1024)} MiB in total; "
"raw derive refuses it."
)
cleaned = [clean_name(part, "file") for part in parts]
self.add_file(prefix + "/".join(cleaned), content, came_as="/".join(parts))
added += 1
if added == 0 and hidden_here == 0:
raise ValidationError(f"{label} holds no files.")
self._unpacked.append((label, hashlib.sha256(data).hexdigest()))
return prefix
def finish(self, text: Optional[Derivation] = None, warnings: tuple[str, ...] = ()) -> Container:
files: list[tuple[str, bytes]] = [(path, data) for path, _name, data in self._pending]
derived: list[tuple[str, str, str]] = []
derivations: list[Derivation] = []
plain: list[str] = []
for path, came_as, data in self._pending:
base = path.rsplit("/", 1)[-1]
candidate = filter_for(base)
if candidate is None or candidate.container_only:
plain.append(path)
continue
try:
require_available(candidate)
derivation = candidate.derive(data, base)
except ValidationError as exc:
raise ValidationError(f"{path}: {exc}") from None
stem = path[: len(path) - len(os.path.splitext(base)[1])]
md = self._tree.claim(stem + ".md")
files.append((
md,
render(candidate, derivation, derived_from=base, original_name=came_as if came_as != base else None),
))
derived.append((path, md, candidate.name))
derivations.append(derivation)
checks: list[str] = []
if self._unpacked:
named = ", ".join(f"{label} (sha256 {digest})" for label, digest in self._unpacked)
checks.append(
f"Unpacked from {named}; the archive is not kept - name it on the source page."
)
return Container(
files=tuple(files),
derived=tuple(derived),
derivations=tuple(derivations),
plain=tuple(plain),
renamed=tuple(self._renamed),
hidden=tuple(self._hidden),
warnings=warnings,
checks=tuple(checks[:MAX_CHECKS]),
text=text,
)
class ZipFilter(Filter):
"""A ZIP archive at the top of `incoming/` becomes a folder of its files."""
name = "zip"
extensions = (".zip",)
container_only = True
def derive(self, data: bytes, name: str) -> Derivation:
raise ValidationError(f"{name}: a ZIP archive is unpacked at the top of incoming/, not derived in place.")
def container(self, data: bytes, name: str) -> Optional[Container]:
collector = Collector(reserve_top=False)
collector.add_zip("", data, name)
return collector.finish()
register(ZipFilter())
+79 -11
View File
@@ -9,7 +9,8 @@ from typing import Optional
from chemenu import web_capture
from chemenu.errors import ValidationError
from chemenu.filters import Derivation, Filter, register
from chemenu.filters import Container, Derivation, Filter, register
from chemenu.filters.archive import Collector, clean_name
_QUOTED = re.compile(
r"^(>|-{2,}\s*(Original Message|Ursprüngliche Nachricht)\s*-{2,}|On .{5,200} wrote:\s*$|Am .{5,200} schrieb .{0,200}:\s*$)",
@@ -25,7 +26,7 @@ class Attachment:
@property
def is_message(self) -> bool:
return self.content_type == "message/rfc822" or self.name.lower().endswith((".eml", ".msg"))
return self.content_type == "message/rfc822" or self.name.lower().endswith(".eml")
@dataclass
@@ -88,6 +89,16 @@ def _filename(part: EmailMessage, number: int) -> str:
return name
def _leaves(part: EmailMessage):
"""The parts of a mail that are not containers, without descending into an
attached message: its own body and attachments belong to it, not to this mail."""
if part.get_content_type() == "message/rfc822" or not part.is_multipart():
yield part
return
for child in part.iter_parts():
yield from _leaves(child)
def parse(data: bytes) -> ParsedMail:
msg = message_from_bytes(data, policy=policy.default)
if not isinstance(msg, EmailMessage) or not (msg.keys() or msg.get_payload()):
@@ -108,8 +119,8 @@ def parse(data: bytes) -> ParsedMail:
out.body = web_capture.derive_text(_text_of(body_part), "").strip()
else:
out.body = _text_of(body_part).replace("\r\n", "\n").replace("\r", "\n").strip()
for number, part in enumerate(msg.walk(), 1):
if part.is_multipart() or part is body_part or part is msg:
for number, part in enumerate(_leaves(msg), 1):
if part is body_part or part is msg:
continue
disposition = (part.get_content_disposition() or "").lower()
if (
@@ -152,26 +163,83 @@ def mail_checks(parsed: ParsedMail) -> tuple[str, ...]:
return tuple(checks)
def _quote_section(inner: ParsedMail) -> str:
h = inner.headers
lines = [f"## Attached message: {h['subject'] or '(no subject)'}", ""]
for label, key in (("From", "from"), ("To", "to"), ("Cc", "cc"), ("Date", "date")):
value = h[key]
value = ", ".join(value) if isinstance(value, list) else value
if value:
lines.append(f"{label}: {value}")
if inner.attachments:
lines.append("Attachments (not unpacked): " + ", ".join(a.name for a in inner.attachments))
lines.append("")
lines += [f"> {line}" if line else ">" for line in inner.body.splitlines()]
return "\n".join(lines)
def _subject(parsed: ParsedMail) -> str:
subject = parsed.headers["subject"]
return subject if isinstance(subject, str) else ""
class EmlFilter(Filter):
"""A mail. At the top of `incoming/` it is a container: its attachments become files
beside it. Anywhere else - in a ZIP or in a folder - its attachments are only named."""
name = "eml"
extensions = (".eml",)
def derive(self, data: bytes, name: str) -> Derivation:
parsed = parse(data)
warnings = list(parsed.warnings)
if parsed.attachments:
names = ", ".join(a.name for a in parsed.attachments)
raise ValidationError(
f"The mail carries {len(parsed.attachments)} attachment(s) ({names}); deriving a mail "
"with attachments is not supported yet. Save the attachments as files beside the "
"mail and derive each file on its own."
warnings.append(
f"The mail has {len(parsed.attachments)} attachment(s) that are not unpacked here: "
+ ", ".join(a.name for a in parsed.attachments)
)
return Derivation(
text=parsed.body,
title=parsed.headers["subject"] if isinstance(parsed.headers["subject"], str) else "",
title=_subject(parsed),
fields=mail_fields(parsed),
warnings=tuple(parsed.warnings),
warnings=tuple(warnings),
checks=mail_checks(parsed),
)
def container(self, data: bytes, name: str) -> Optional[Container]:
parsed = parse(data)
collector = Collector(reserve_top=True)
sections: list[str] = []
warnings = list(parsed.warnings)
for number, attachment in enumerate(parsed.attachments, 1):
file_name = clean_name(attachment.name, f"attachment-{number}")
if attachment.is_message:
try:
inner = parse(attachment.data)
except ValidationError:
collector.add_file(file_name, attachment.data)
continue
sections.append(_quote_section(inner))
if inner.attachments:
warnings.append(
f"The attached message {attachment.name!r} has attachments that are not "
"unpacked: " + ", ".join(a.name for a in inner.attachments)
)
elif file_name.lower().endswith(".zip"):
collector.add_zip(file_name[: -len(".zip")] or "archive", attachment.data, file_name)
else:
collector.add_file(file_name, attachment.data)
text = parsed.body
if sections:
text = (text + "\n\n" if text else "") + "\n\n".join(sections)
derivation = Derivation(
text=text,
title=_subject(parsed),
fields=mail_fields(parsed),
warnings=tuple(warnings),
checks=mail_checks(parsed),
)
return collector.finish(text=derivation)
register(EmlFilter())
+4 -3
View File
@@ -27,9 +27,10 @@ TOOLS_DIR = Path(__file__).resolve().parents[2]
PACKAGE_DIR = TOOLS_DIR / "chemenu"
# Receivers whose `open` is not a text-file open: `os.open` takes flags, the
# archive modules open binary members, and `opener` is a `urllib`
# `OpenerDirector` (`web_capture.fetch`), whose `open` sends an HTTP request.
NOT_TEXT_OPEN = {"os", "tarfile", "zipfile", "gzip", "opener"}
# archive modules open binary members (`archive` is the `zipfile.ZipFile` in
# `filters/archive.py`), and `opener` is a `urllib` `OpenerDirector`
# (`web_capture.fetch`), whose `open` sends an HTTP request.
NOT_TEXT_OPEN = {"os", "tarfile", "zipfile", "gzip", "archive", "opener"}
# The one module allowed to import the platform lock modules.
LOCK_MODULE = PACKAGE_DIR / "filelock.py"
+278 -8
View File
@@ -5,9 +5,11 @@ readable in the test that uses it and no binary sits in the repository.
"""
from __future__ import annotations
import hashlib
import io
import json
import zipfile
from email import message_from_bytes, policy
from email.message import EmailMessage
from pathlib import Path
@@ -565,14 +567,6 @@ def test_eml_with_inline_image_warns_and_is_not_an_attachment(tree, capsys):
assert "inline image" in capsys.readouterr().out
def test_eml_with_an_attachment_is_refused_untouched(tree, capsys):
_put(tree, "mail.eml", make_eml(attach=True))
before = _snapshot(tree / "incoming")
_refused("incoming/mail.eml")
assert "a.bin" in capsys.readouterr().out
assert _snapshot(tree / "incoming") == before
# --- the registry -----------------------------------------------------------
@@ -674,3 +668,279 @@ def test_the_web_page_filter_derives_a_saved_html_file(tree):
assert header["filter"] == "html" and header["title"] == "T"
assert header["charset"].startswith("utf-8")
assert "# Kopf" in text
# --- containers: ZIP and mail with attachments -------------------------------
def make_zip(entries: dict[str, bytes]) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for name, content in entries.items():
zf.writestr(name, content)
return buffer.getvalue()
def make_zip_with(info: zipfile.ZipInfo, content: bytes = b"x") -> bytes:
encrypted = bool(info.flag_bits & 0x1)
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
zf.writestr(info, content)
data = buffer.getvalue()
if encrypted:
# zipfile resets the flags on write; set the encryption bit in both headers by hand
marker = data.index(b"PK\x01\x02")
flags = marker + 8
data = data[:flags] + bytes([data[flags] | 1]) + data[flags + 1:]
return data
def make_mail(attachments: list[tuple[str, bytes, str]], body: str = "See attached.\n") -> bytes:
msg = EmailMessage()
msg["From"] = "Anna Beispiel <anna@example.org>"
msg["To"] = "bob@example.org"
msg["Subject"] = "Unterlagen"
msg["Date"] = "Wed, 07 Oct 2026 09:30:00 +0200"
msg.set_content(body)
for name, data, ctype in attachments:
maintype, subtype = ctype.split("/")
if ctype == "message/rfc822":
msg.add_attachment(message_from_bytes(data, policy=policy.default))
else:
msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=name)
return msg.as_bytes()
DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
def _fresh_docx(text: str) -> bytes:
return make_docx(_p(text))
def test_a_zip_becomes_a_folder_of_its_files_and_is_removed(tree, capsys):
one, two = _fresh_docx("One"), _fresh_docx("Two")
entries = {"one.docx": one, "sub/two.docx": two, "notes.txt": b"plain\n"}
archive = make_zip(entries)
_put(tree, "bundle.zip", archive)
_derive("incoming/bundle.zip")
out = capsys.readouterr().out
folder = tree / "incoming/bundle"
assert (folder / "one.docx").read_bytes() == one
assert (folder / "sub/two.docx").read_bytes() == two
assert (folder / "notes.txt").read_bytes() == b"plain\n"
assert "One" in (folder / "one.md").read_text(encoding="utf-8")
assert "Two" in (folder / "sub/two.md").read_text(encoding="utf-8")
assert not (tree / "incoming/bundle.zip").exists()
assert not (folder / "original.zip").exists() and not (folder / "derived.md").exists()
assert "bundle.zip" in out and hashlib.sha256(archive).hexdigest() in out
assert "notes.txt" in out
raw_accept_command(
files=[Path("incoming/bundle")], fidelity="verbatim", authority="reporting",
page=None, replaces=None, dry_run=False,
)
assert list((tree / "raw").rglob("one.docx"))
def test_a_zip_takes_name_and_names_the_hidden_entries(tree, capsys):
_put(tree, "bundle.zip", make_zip({
"a.txt": b"a", "__MACOSX/._a.txt": b"x", ".DS_Store": b"x", "sub/.hidden": b"x",
}))
_derive("incoming/bundle.zip", "kurz")
out = capsys.readouterr().out
assert sorted(p.name for p in (tree / "incoming/kurz").rglob("*") if p.is_file()) == ["a.txt"]
for hidden in ("__MACOSX/._a.txt", ".DS_Store", "sub/.hidden"):
assert hidden in out
@pytest.mark.parametrize("builder", [
lambda: make_zip({"inner.zip": make_zip({"a.txt": b"a"})}),
lambda: make_zip({"../escape.txt": b"x"}),
lambda: make_zip({"/abs.txt": b"x"}),
lambda: make_zip_with(_info("link", symlink=True)),
lambda: make_zip_with(_info("secret.txt", encrypted=True)),
lambda: b"not a zip at all",
])
def test_a_zip_that_breaks_a_rule_is_refused_and_incoming_is_untouched(tree, builder):
_put(tree, "bad.zip", builder())
before = _snapshot(tree / "incoming")
_refused("incoming/bad.zip")
assert _snapshot(tree / "incoming") == before
assert not (tree / "incoming/bad").exists()
def _info(name: str, *, symlink: bool = False, encrypted: bool = False) -> zipfile.ZipInfo:
info = zipfile.ZipInfo(name)
if symlink:
info.external_attr = 0o120777 << 16
if encrypted:
info.flag_bits |= 0x1
return info
@pytest.mark.parametrize("limit", ["MAX_ENTRIES", "MAX_FILE_BYTES", "MAX_TOTAL_BYTES"])
def test_a_zip_over_a_limit_is_refused(tree, monkeypatch, limit):
from chemenu.filters import archive
monkeypatch.setattr(archive, limit, 2 if limit == "MAX_ENTRIES" else 5)
_put(tree, "big.zip", make_zip({"a.txt": b"123456", "b.txt": b"123456", "c.txt": b"1"}))
before = _snapshot(tree / "incoming")
_refused("incoming/big.zip")
assert _snapshot(tree / "incoming") == before
def test_a_zip_entry_whose_path_would_be_over_budget_is_refused_naming_name(tree, capsys):
_put(tree, "z.zip", make_zip({"d" * 100 + "/" + "f" * 80 + ".txt": b"x"}))
before = _snapshot(tree / "incoming")
_refused("incoming/z.zip")
assert "--name" in capsys.readouterr().out
assert _snapshot(tree / "incoming") == before
def test_a_utf8_entry_name_survives(tree):
_put(tree, "u.zip", make_zip({"Übersicht Ä.txt": b"x"}))
_derive("incoming/u.zip")
assert (tree / "incoming/u/Übersicht Ä.txt").is_file()
def test_duplicate_zip_entries_get_a_number(tree, capsys):
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
zf.writestr("a.txt", b"first")
zf.writestr("A.txt", b"second")
zf.writestr("_capture.json", b"{}")
_put(tree, "d.zip", buffer.getvalue())
_derive("incoming/d.zip")
out = capsys.readouterr().out
names = sorted(p.name for p in (tree / "incoming/d").iterdir())
assert names == ["A-2.txt", "_capture-2.json", "a.txt"]
assert "A.txt -> A-2.txt" in out
def test_a_zip_inside_a_folder_is_left_alone_and_named(tree, capsys):
folder = tree / "incoming/f"
folder.mkdir()
(folder / "a.docx").write_bytes(_fresh_docx("A"))
(folder / "z.zip").write_bytes(make_zip({"a.txt": b"a"}))
_derive("incoming/f")
assert "z.zip" in capsys.readouterr().out
assert (folder / "z.zip").is_file() and (folder / "a.md").is_file()
def test_a_mail_with_attachments_is_a_folder_of_them(tree, capsys):
report = _fresh_docx("Report")
inner_one, inner_two = _fresh_docx("Inner one"), _fresh_docx("Inner two")
archive = make_zip({"i1.docx": inner_one, "i2.docx": inner_two})
raw_mail = make_mail([
("report.docx", report, DOCX),
("papers.zip", archive, "application/zip"),
("picture.bin", b"\x00\x01", "application/octet-stream"),
])
_put(tree, "mail.eml", raw_mail)
_derive("incoming/mail.eml")
out = capsys.readouterr().out
folder = tree / "incoming/mail"
assert (folder / "original.eml").read_bytes() == raw_mail
assert (folder / "report.docx").read_bytes() == report
assert "Report" in (folder / "report.md").read_text(encoding="utf-8")
assert (folder / "papers/i1.docx").read_bytes() == inner_one
assert "Inner two" in (folder / "papers/i2.md").read_text(encoding="utf-8")
assert (folder / "picture.bin").is_file()
assert not (folder / "papers.zip").exists() and not (tree / "incoming/mail.eml").exists()
header, text = _header_and_text(folder / "derived.md")
assert header["attachments"] == ["report.docx", "papers.zip", "picture.bin"]
assert "See attached." in text
assert "papers.zip" in out and "sha256" in out
raw_accept_command(
files=[Path("incoming/mail")], fidelity="verbatim", authority="reporting",
page=None, replaces=None, dry_run=False,
)
assert list((tree / "raw").rglob("original.eml"))
def test_a_mail_whose_zip_attachment_holds_a_zip_is_refused_untouched(tree):
nested = make_zip({"inner.zip": make_zip({"a.txt": b"a"})})
_put(tree, "mail.eml", make_mail([("n.zip", nested, "application/zip")]))
before = _snapshot(tree / "incoming")
_refused("incoming/mail.eml")
assert _snapshot(tree / "incoming") == before
def test_an_attached_mail_is_quoted_not_unpacked(tree, capsys):
inner = make_mail([("deep.bin", b"zz", "application/octet-stream")], body="Inner words\n")
_put(tree, "mail.eml", make_mail([("fwd.eml", inner, "message/rfc822")]))
_derive("incoming/mail.eml")
out = capsys.readouterr().out
folder = tree / "incoming/mail"
assert sorted(p.name for p in folder.iterdir()) == ["derived.md", "original.eml"]
_header, text = _header_and_text(folder / "derived.md")
assert "## Attached message: Unterlagen" in text
assert "> Inner words" in text
assert "deep.bin" in text
assert "WARN" in out and "deep.bin" in out
def test_a_mail_inside_a_zip_gets_its_md_and_its_attachments_are_only_named(tree, capsys):
mail = make_mail([("a.bin", b"x", "application/octet-stream")])
_put(tree, "m.zip", make_zip({"msg.eml": mail}))
_derive("incoming/m.zip")
out = capsys.readouterr().out
folder = tree / "incoming/m"
assert sorted(p.name for p in folder.iterdir()) == ["msg.eml", "msg.md"]
assert "a.bin" in out and "WARN" in out
def test_attachment_names_that_collide_get_a_number(tree, capsys):
_put(tree, "mail.eml", make_mail([
("a.bin", b"1", "application/octet-stream"),
("a.bin", b"2", "application/octet-stream"),
("derived.md", b"3", "text/markdown"),
("_capture.json", b"4", "application/json"),
("original.docx", _fresh_docx("O"), DOCX),
("../evil/b.bin", b"5", "application/octet-stream"),
]))
_derive("incoming/mail.eml")
out = capsys.readouterr().out
names = sorted(p.name for p in (tree / "incoming/mail").iterdir())
assert names == [
"_capture-2.json", "a-2.bin", "a.bin", "b.bin", "derived-2.md", "derived.md",
"original-2.docx", "original-2.md", "original.eml",
]
for renamed in ("a.bin -> a-2.bin", "derived.md -> derived-2.md", "_capture.json -> _capture-2.json"):
assert renamed in out
def test_a_derived_md_never_takes_the_name_of_a_real_file(tree):
_put(tree, "z.zip", make_zip({"a.docx": _fresh_docx("From docx"), "a.md": b"real notes\n"}))
_derive("incoming/z.zip")
folder = tree / "incoming/z"
assert (folder / "a.md").read_bytes() == b"real notes\n"
assert "From docx" in (folder / "a-2.md").read_text(encoding="utf-8")
def test_a_container_failing_to_write_leaves_incoming_as_it_was(tree, monkeypatch):
_put(tree, "bundle.zip", make_zip({"a.txt": b"a", "b.txt": b"b"}))
before = _snapshot(tree / "incoming")
def broken(self):
raise OSError("disk full")
monkeypatch.setattr(Path, "unlink", broken)
with pytest.raises(typer.Exit):
_derive("incoming/bundle.zip")
monkeypatch.undo()
assert _snapshot(tree / "incoming") == before
assert not (tree / "incoming/bundle").exists()
def test_check_lines_for_a_zip_of_xlsx_have_no_mail_line(tree, capsys):
_put(tree, "s.zip", make_zip({"sheet.xlsx": make_xlsx()}))
_derive("incoming/s.zip")
out = capsys.readouterr().out
checks = [line for line in out.splitlines() if line.startswith("CHECK")]
assert any("Values are as last saved" in line for line in checks)
assert not any("commitment" in line for line in checks)