Task 9: atomic bundle publication - #9
Merged
Conversation
Adds the bundle publication section: payload shaping, the manifest, and write_bundle(). Two properties are enforced rather than trusted. Nothing raw escapes. Serialization is by an allowlist of contract types, not by dataclass structure -- the collector records holding raw most-common values and raw query text are dataclasses too, so a structural walker would have written them straight to disk. Every CSV is shaped from already-tokenized records, and query text is replaced by an HMAC token because pg_stat_statements normalizes literals for most statements but not for utility statements or parser-folded constants. Publication is all-or-nothing. The archive is built in a temporary file beside the destination, fsynced, and moved into place with os.replace(), with the manifest serialized last after the payloads it hashes. Any failure removes the temporary file and leaves the previous bundle untouched. Entry paths are validated against one regex that rejects absolute paths, parent traversal, empty and dot segments, backslashes, drive letters, whitespace and control characters by construction, then checked against the allowlist of the nine legal payload paths. Every entry carries an explicit regular-file mode so no extractor sees a symlink bit, and a symlink destination is refused outright. Sections omitted by degradation are keyed off the collector's warning code rather than off an empty record set, so "the role could not read this view" stays distinguishable from "this database has no user tables". pg_stat_indexes.csv joins is_unique and is_primary from the catalog: idx_scan == 0 marks a drop candidate, but whether it can be dropped depends on whether it backs a constraint, and both facts were already collected. The negative assertions search decompressed members as well as the stored bytes -- searching only the compressed file would have let DEFLATE hide the literal the test is looking for -- and the temporary file is captured before the rename so the pre-publication artifact is searched too. Three literals are planted, plus the tokenization key and every connection detail. The properties were mutation-tested: publishing raw query text, using a structural serializer, replacing the rename with a copy, and dropping the temp-file cleanup are each caught. 317 tests pass; --check-safety and ruff are clean; the source parses under Python 3.9. Co-Authored-By: roachdev-claude <roachdev-claude-bot@cockroachlabs.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds the bundle publication section of
dbprofiler.py: payload shaping, the manifest, andwrite_bundle(). This is the only code in the tool that writes to disk, so both of its properties are enforced rather than trusted.Nothing raw escapes
Serialization is by an allowlist of contract types, not by dataclass structure. The obvious implementation is
dataclasses.asdict(), and it would have been a hole: the collector records holding raw most-common values (ColumnStatistics) and raw query text (StatementActivity) are dataclasses too, so a structural walker serializes them happily.to_jsonable()checks membership inCONTRACT_TYPESand raises otherwise, so making a record serializable is a visible edit in one place.Every CSV is shaped from already-tokenized records. Query text is tokenized whole:
pg_stat_statementsnormalizes literals to$1for most statements but not for utility statements or parser-folded constants, so there is no subset of the text that is safe by construction. That costs the text's analytical value and leavesqueryidas the correlation handle — a partial redaction would be a guess dressed up as a guarantee.Publication is all-or-nothing
The archive is built in a temporary file beside the destination, fsynced, and moved into place with
os.replace(), withmanifest.jsonserialized last after the payloads it hashes. Any failure — includingKeyboardInterrupt, henceexcept BaseException— removes the temporary file and leaves the previous bundle untouched.Entry paths are validated against one regex that rejects absolute paths, parent traversal, empty and dot segments, backslashes, drive letters, whitespace and control characters by construction, then checked against the allowlist of the nine legal payload paths. Symlink defence is applied at both ends: every entry carries an explicit
S_IFREG | 0644mode (zipfile's default is zero, and some extractors then fall back to the umask), and a symlink destination is refused rather than resolved.Archives are byte-reproducible — fixed entry timestamps, sorted order, manifest last — so a bundle can be diffed.
Degradation
Omission is keyed off the collector's warning code, not off an empty record set. Otherwise a database that genuinely has no user tables is indistinguishable from one whose role could not read the view; the first should ship an empty CSV, the second should ship nothing and say why.
The negative assertions
They search decompressed members as well as the stored bytes. The archive is DEFLATE-compressed, so searching only the raw file would let compression hide the very literal the test is looking for. The temporary file is captured by wrapping
os.replace, so the pre-publication artifact is searched too. Three literals are planted — an unnormalized query literal, a most-common value, and a histogram bound — plus the tokenization key and every connection detail.These properties were mutation-tested: publishing raw query text, swapping the type allowlist for a structural dataclass check, replacing the rename with a non-atomic copy, and dropping the temp-file cleanup were each applied in turn, and every one is caught by the suite. A negative assertion that cannot fail is decoration.
Smaller notes
pg_stat_indexes.csvgainsis_uniqueandis_primaryfromCatalogIndex, which task 5 collected and nothing had consumed.idx_scan == 0marks a drop candidate, but whether it can be dropped depends on whether it backs a constraint.wal_bytesis not inpg_stat_statements.csv: the plan named it, butSQL_STATEMENTSnever selected it. The CSV carries what was collected, includingstddev_exec_timeand the temp-block counters the plan's list omitted.=-leading identifiers would desynchronize the CSVs fromprofile.json, which carries the same identifiers unquoted.Verification
python3 dbprofiler.py --check-safety— OK, 14 SQL constants (no new SQL in this change)ruff check— clean