v0.1.2
What's new
Tests and documentation. No behaviour changed.
Strip the docstrings and parse src/ at both tags: one explanatory string under a constant in constants.py is the only difference. Same API, same wire format, same bytes — text marked by 0.1.1 verifies under 0.1.2 and the reverse. The suite goes 1,195 → 1,344 tests, coverage unchanged at 99.83%.
Two things drove it: tests that graded the code against itself, and shipped claims with nothing holding them. Several of those claims were false.
Not published yet.
buildand TestPyPI succeeded, and the attestations exist.publish-pypithen waited fifteen minutes for a runner, ran no step, and was cancelled, soupload-releasewas skipped. This page carries no assets, 0.1.2 is not on PyPI, andpip install c2patxtgives you 0.1.1. Nothing is wrong with the artifact; the publish jobs need re-running. Release run.
$ pip install c2patxtPython 3.10 – 3.14, CPython. One runtime dependency: cryptography~=48.0.
Read
VALIDcorrectly. The credential in the quickstart is self-signed and this package ships zero trust anchors, so a correct mark verifies asVALIDcarryingsigningCredential.untrusted. C2PA 14.3.5 defines Valid without requiring trust; 14.3.6 adds trust separately. Treating that as an error is the mistake this package most expects.
Breaking changes
None in the package. One for anyone vendoring the conformance vectors:
- The file is
A8ConformanceTest-1.2.1.txt. Three records added, none changed. - The parse recipe is the other way round — discard from the first
#, then split on;. The old order returns eight or nine fields on 34% of the corpus, because ten records carry a semicolon inside a comment. SHA256SUMSnow coversbadandstreamingas well. Onlythird_party/carries attribution obligations.
Tests that had no oracle
Conformance vector file, 1.1.0 → 1.2.1
Two header rules were false: the blanket UTF-8 claim (payload_hex, and an extract record's expect_hex, are opaque manifest bytes) and the @PartN grammar (six of the nine markers do not match it).
Rule 9 is new. Rule 4 reports corruptedWrapper for a malformed magic-matching candidate and rule 1 skips a non-matching run. Neither covers both in one text — where raising on the first hands a denial of service to anyone who can prepend twenty characters.
Three records added: X0021 (rule 9, the discriminating one), X0022 (manifestLength 0), X0023 (the 0x0F/0x10 boundary). 1.2.1 over 1.2.0 is a comment change alone.
Third-party interop
test_third_party_interop imported nothing from c2patxt. It checked EncypherAI's and writerslogic's vectors against a transcription of the specification in the test file.
Their vectors now run against build_wrapper and the scanner both ways, including the 256-byte unicode_all_bytes payload no extract test had carried. A real embed() wrapper is re-derived from A.8.2.2, and a real embed() signature checked against a Sig_structure built from RFC 9052 rather than from _cose.
Certificate profile
check_claim_signing_profile was exercised only as a unit call, through one certificate shape. Eight buildable violations now sign a document and require signingCredential.invalid carrying its diagnosis.
Delete the call from _accept_credential: seven of the eight fail, and all 41 tests in test_trust.py stay green. The eighth, an EKU that will not parse, dies at the hostile-parse boundary first.
embed() output
The round trip checked .state, and test_the_wrapper_carries_the_magic_number asserted MAGIC against itself. Now asserted: the success codes an intact mark carries, the magic in the emitted bytes, the emitted modelType, the assertion label set, the COSE alg.
COSE vectors
Eleven of the twelve vendored cose-wg files publish a top-level intermediates.ToBeSign_hex. One was asserted against.
Ten now run as positive oracles, sign-pass-02 as the external_aad negative oracle 13.2.3 requires us to fail, and eddsa-01's — nested under signers — as the multi-signer shape we never emit. An accounting test refuses to let a vendored file go unread.
CBOR refusal corpora
cbor-wg publishes rfc8949/bad (47 ill-formed inputs) and rfc8949-appendixA/streaming (11 indefinite-length encodings). Neither was here. Both are parametrized and must raise CborDecodeError. make download-vectors-cbor fetches them.
Two bad items are accepted on purpose: a tag 0 carrying a map is invalid under §5.3.2 and still well-formed under Appendix C, and 15.10.3.1 rejects only what is not well-formed.
A corpus is not a substitute for the test that names the rule. Delete the reserved-additional-information check and one test fails, the one written for it. The six Invalid AI rows stay green: those inputs then declare a width past the end of the buffer and die a step later, for the wrong reason.
Also gone: test_cbor.py's assert expected != "" or description, which could not fail, and a column it read that was never compared to anything.
Documentation corrections
Each re-checked against the primary source, not against our own notes.
| We published | True |
|---|---|
| c2pa-rs emits the manifest-relative signature URI — our stated reason for emitting that form | It emits the store-relative form (sdk/src/jumbf/labels.rs:79 at 9b6b2e52, pinned by its own test) and accepts the other on read |
| CRA Article 24(1) requires a coordinated vulnerability disclosure policy | 24(1) requires a cybersecurity policy. Disclosure is Article 13, a manufacturer duty |
| The HypoFuzz licence conflicts for an open-source project inside a company | Its carve-out reaches projects "not commercially supported, or … governed by a non-profit organization", which excludes this one |
| The JUMBF toggle assignment is ISO's | Bits 0–3 are JPEG WG1's JLINK WD 3.0, Table A.2; Private is C2PA 11.1.4.1.2's xxx1xxxx. 11.1.4.1.2 gives neither ID nor Signature, which is why three implementations get those two wrong |
| 152 conformance-listed products | 156, every entry specVersion: ["2.2"], at c2pa-org/conformance-public commit 7d19b332. Pinned by commit, not date: the list syncs more than once a day |
SECURITY.md now also carries the staged CRA dates: Chapter IV from 11 June 2026, Article 14 reporting from 11 September 2026, Article 24 from 11 December 2027.
Deviation 21 records what actually holds our signature-URI choice: what we emit is a MAJOR version of this package and of the vector file, and both readers take both forms.
The specification build hash resolves too. gh api repos/c2pa-org/specifications/commits/c7e55d5a returns c7e55d5a3c1e758eeabad058e501fadbb8cfe777, committed 2026-04-23T17:38:51Z — three minutes before the published PDF's last-modified time.
Three retractions
- The byte-level parse of
image_5jumbf.jpgwas cited as proof that real assets carry atoggles=0x02box. The file is not vendored, we can no longer say which corpus it came from, and nothing in the suite touches it. Now an observation; the divergence stands on the five implementations. - "No implementation anywhere enforces the label rules" now reads "of the implementations surveyed in this document, none does". Same for "every other implementation uses 4 bytes" on the box ID.
- The third mutation pass reported eleven survivors. There were thirteen: one provably equivalent, twelve suite gaps. And one filed as equivalent was not — dropping
_hashed_uri_list'sisinstance(key, str)narrowing failstest_attacker_cbor_of_the_wrong_shape_narrows_rather_than_raising, which has existed since the initial commit, while a comment in the source repeated the wrong finding.
New checks
Commit df22b89 stopped grading documentation in pytest, and two documents still claimed a test held them. The replacements constrain structure, never wording.
| Test | Holds |
|---|---|
test_compatibility.py |
Each row's "Where" column names a file citing that clause, and the core table is in clause order. Ten rows named a file that does not cite the clause |
test_deviations_index.py |
The clause index in deviations.md, regenerated from the document's own sections, and the numbering dense and ordered — five files cite deviations by ordinal, two in src/ |
test_readme_examples.py |
Every README Python block imports what it uses, and the quickstart runs as written |
test_doc_references.py |
Every relative link and backticked path, in every shipped document |
Two gaps stay, documented: a row naming no file passes, and nothing checks the reverse direction.
Claims that now name a test
Signerhas an escape hatch. "Refuses a non-conformant certificate at construction" was true only by omission:allow_nonconformant=Truepredates this release and is what lets the verifier be tested against credentials it must reject.- Size. The published band was measured against a pinned certificate serial while its spread was attributed to a random one — three attempts, each looking like a correction of the last. Published now: the ratio, 3.90 UTF-8 bytes per manifest byte, plus one reproducible point, 7,001 B per mark on a 1,797 B store (
test_the_published_size_figures_are_still_true). Other certificates are the caller's to measure. - CPU and memory. About 0.10 ms per MB of unmarked text, linear to 4.32 MB — pinned as a mechanism, one
str.findand one encode per call (test_verify_walks_unmarked_text_exactly_once). Peak memory about 3.4× the manifest store, not the document: 1.77 MiB on a 0.53 MiB store (test_a_repeated_actions_link_allocates_a_bounded_multiple_of_its_input). - Thread safety. Exercised rather than inferred, by
test_one_signer_marks_correctly_from_many_threads. - A robustness row measured something other than its name. "markdown round-trip (reflow)" is now "whitespace collapse (NBSP folding)".
str.split()splits on U+00A0 as well as ASCII whitespace, and every document in the PAN'26 corpus is a single line, so nothing reflows. The 150 of 300 that fail are exactly the 150 containing a non-breaking space — the 0.500 in the table. embed()'s padding-search cost moved to the test that enforces it, beside the analytic ceiling of 801.__all__. Five functions are the call surface; a mark needs eight names. The thirty are broken down rather than counted.
Deleted, moved, withdrawn
docs/platform-handoff.md— internal correspondence about a document that ships nowhere a reader can reach, and wrong about which CRA chapter holds Article 24. Three scratch files left the repository root, and every citation of two internal documents went with them, including fromsrc/docstrings. What the handoff had right — the hash-binding order is 15.12.1.3.1's and must not be "fixed" to match A.8.7.3 — is a warning in_verify.py.- Moved. The release runbook to
docs/releasing.md. The AppSec section out of the README into SECURITY.md, where each property names the test holding it. "Why this exists" below the code, with the Article 50(2) argument now openingdocs/c2pa-compatibility.md.docs/upstream-filing.mdis retitled "Four ambiguities in Annex A.8" and drops itsgh issue createcommands. - Deviations 16 and 19 withdrawn — a cosmetic cross-reference that was never a deviation, and a failure code 15.10.3.2.3 states in full. The numbers stay, because five files cite them by ordinal.
deviations.mdnow opens with which of the twenty-nine matter: a conforming peer reading the specification the other way produces different bytes on 1, 2, 3, 4, 8 and 28; 21 differs only in what we emit; 23 bears on the Article 50(2) disclosure. - Known divergence 8 added. Padding for the two-pass search goes in three places:
encypherai/c2pa-textpads the selector run,writerslogic/c2pa-text-bindingdeclarespadand leaves it empty, we fillpad— the only one of the three inside the signature. Nothing breaks on read; it is recorded because padding outside the signature is attacker-malleable space in a provenance format.
For contributors
Branch from develop, open the pull request against develop. main is what the publish workflow reads and what git describe tags. Commit subjects are Conventional Commits. No CLA, no DCO.
Two traps turn a deliberately broken probe green, now in CONTRIBUTING.md:
- Stale bytecode after a same-length revert. Python invalidates a
.pycon source size and mtime, and0xFFFE→0xFFFFchanges neither at second granularity. - zsh does not word-split unquoted expansions.
pytest $ARGSwithARGS="file -k selector"hands pytest one argument, selects nothing, and printsno tests ran.
Evaluated and rejected
- Emitting the store-relative signature URI, now that c2pa-rs is known to emit it. A MAJOR bump of the package and the vector file, for bytes both readers already accept. Not filed upstream, same reason.
- Restoring prose grading in pytest. A test that reads documentation for wording fails on a rewrite and passes on a lie.
- A test for
trust._der_contents's<versus<=. Provably equivalent: the guard above it means control arrives only whenfirst != 0x80. Recorded as a comment so nobody writes a test that cannot fail. - Automating the mutation audit.
mutmut3.x skips module-level constants, which is what a codec most needs mutated;atheris3.1.0 ships cp312/313/314 manylinux x86_64 wheels only;HypoFuzzpermits non-commercial use only. - Filtering the
badcorpus to the 45 items we refuse. A filtered file cannot be diffed against upstream. - A second copy of the memory-instrument argument in
docs/benchmarks.md. It is argued incodspeed.yml, beside the setting it argues about.
Verify this release yourself
$ gh attestation verify ./c2patxt-0.1.2-py3-none-any.whl -R dualeai/c2patxt
$ pypi-attestations verify pypi --repository https://github.com/dualeai/c2patxt <url>The first proves artifact → commit; the predicate names refs/tags/v0.1.2 and commit 7a87f2ffc7004800647858708470c9767dd4091e. The second proves artifact → publisher (PEP 740) and needs no GitHub account.
Three attestations sign the same wheel and sdist digests — https://slsa.dev/provenance/v1, https://cyclonedx.org/bom, https://spdx.dev/Document/v2.3. Either SBOM can be checked alone with --predicate-type https://cyclonedx.org/bom.
In PyPI's own words: "An attestation will tell you where a PyPI package came from, but not whether you should trust it." Neither pip nor uv gates installation on attestations.
Versioning
The public API stays unstable until 1.0.0; anything outside c2patxt.__all__ is private. Any change to the bytes we emit is a MAJOR version of both this package and the vector file, because text marked by an older version must keep verifying.
What ships and what deliberately does not: release scope · where we disagree with other implementations, and why: known divergences · how a release is cut: releasing
Full Changelog: v0.1.1...v0.1.2