ruby-c2pa 0.4.0 → 0.6.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 66e048e5359881f08def1d3fd82ed11fb8b9b3dfc0d8232d9dbc145026841d39
4
- data.tar.gz: bd9108538b9e6d47bd3684d974d2145963345cbdbe67ec44250a1892dd277957
3
+ metadata.gz: fd571866dd99e9811e93072f9e642625f844a418bc7878d437905231b5cda679
4
+ data.tar.gz: 8854aaaf99e78cce57e4ba8f60102cf64e9aeda243c17b1395878dedb83eb904
5
5
  SHA512:
6
- metadata.gz: 65b8729aa03c0fb5145f1631fc5c847960be95cfd792553d0352c9c9e7f54451a077b3c7eb599c45ff3b7007698aaa9a91d3f05848c0307f192c5d5de2333c25
7
- data.tar.gz: 0f46873209bbc9b081a2ef24c276adb0daf079466366a01a712a504d3f0df1eb64b20949fa2f171c469eba9e0da48f429c569b09cba8d5ee48f6e978d3548f72
6
+ metadata.gz: a3839eadfa75e5d7f3bd2eb22164cec8301195a4fdb7eee7d39cccafdf8abc67fa09b855ece3a43a0bcc5a6e3dddcb408bc1c4f5c73fa0ba5097fd51623384e1
7
+ data.tar.gz: d84bd7979e4a3b14f7039ca3e0f5c3b3cf4b35f663d333cd8d246d52afcbc910602d7a462e5bf5dd22d32b5be3fa603a29f224bc84a848cfd13da46b81bb66cc
data/CHANGELOG.md CHANGED
@@ -7,6 +7,96 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] — 2026-09-29
11
+
12
+ Adds capability on top of 0.5.0. Nothing is removed and no existing call
13
+ changes behaviour, so this is a minor release. Building it needs Rust 1.96
14
+ or newer, which c2pa-rs 0.91 requires.
15
+
16
+ ### Added
17
+
18
+ - Detached manifests. `C2PA.sign(sidecar:)` writes the manifest to its own
19
+ `.c2pa` file instead of embedding it, and leaves the asset as it was hashed.
20
+ `C2PA.read(manifest_file:)` and `C2PA.read_buffer(manifest_data:)` validate an
21
+ asset against a sidecar. A sidecar with the asset's name and a `.c2pa`
22
+ extension is found without being named. The verify guard checks the pair and
23
+ removes both files if it fails.
24
+ - Manifests stored under the `c2md` JUMBF type are read. The specification
25
+ requires readers to accept them; c2pa-rs 0.91 does, and earlier versions
26
+ skipped them.
27
+ - Signing and reading EPUB, Word (DOCX), OpenDocument text (ODT) and OpenXPS
28
+ files. The manifest goes in `META-INF/content_credential.c2pa`, and every
29
+ other part of the package is covered by the signature. c2pa-rs could only
30
+ read uncompressed ZIP entries, which no real document uses, so the extension
31
+ now enables deflate in its zip dependency. `C2PA.read_buffer` needs
32
+ `format:` for these, since they all start with the same ZIP header.
33
+ - `C2PA::Config#exclude_free_and_skip_boxes`. Set it to false when signing
34
+ MP4, MOV and other BMFF files so that any later change to their `free` and
35
+ `skip` padding boxes breaks the signature. Left unset, c2pa-rs excludes
36
+ them, as it always has.
37
+ - `C2PA::Config#allow_redirects`. Set it to false to stop network fetches
38
+ during reading and validation from following redirects. Left unset, c2pa-rs
39
+ follows redirects but refuses those that point at internal addresses.
40
+ - Signing PDFs. c2pa-rs added a PDF writer in 0.91.1, after years of having
41
+ none, so `C2PA.sign` and `C2PA.sign_buffer` now accept PDFs and the manifest
42
+ goes into the document's associated files. Every release up to 0.5.0
43
+ documented PDF signing as impossible, which it was at the time.
44
+ - `Manifest#add_ingredient` takes `digital_source_type:`, recording how an
45
+ ingredient with no content credentials was produced. Giving one for a file
46
+ that carries credentials raises `C2PA::InvalidManifestError`.
47
+
48
+ ### Changed
49
+
50
+ - `Manifest#add_action` raises `C2PA::InvalidManifestError` when
51
+ `parameters` includes `relatedAssertions`. c2pa-rs 0.91 checks these links
52
+ when reading but cannot create them, so the gem has no genuine value to
53
+ send.
54
+ - Built against c2pa-rs 0.91.1, which needs Rust 1.96 or newer to compile.
55
+ 0.91.1 also carries a batch of hardening fixes over 0.91.0: a panic on
56
+ oversized XMP when writing JPEG APP1, an underflow in XMP trailer parsing,
57
+ unbounded TIFF IFD entry counts, uncapped ID3v2 frame decompression, and
58
+ corrections to OCSP responder validation. That release validates manifests
59
+ before writing by default; the gem turns that off and keeps its own
60
+ verify-after-sign guard, which reads the finished output back and checks
61
+ its hashes. Errors and the `verify:` option behave as before.
62
+
63
+ ## [0.5.0] — 2026-09-12
64
+
65
+ Adds capability on top of 0.4.0. Nothing is removed and no existing call
66
+ changes behaviour, so this is a minor release. The one change every caller
67
+ will feel is that native calls no longer hold the GVL, which only makes other
68
+ threads faster.
69
+
70
+ ### Added
71
+
72
+ - Buffer signing. `C2PA.sign_buffer(data:, format:, ...)` signs bytes held in
73
+ memory and returns the signed bytes; `C2PA.read_buffer(data:, format: nil)`
74
+ reads them back. Both require a binary string and raise `ArgumentError` for
75
+ any other encoding rather than transcoding the asset. The verify-after-sign
76
+ guard applies to buffers as it does to files. Memory use is about four
77
+ times the asset.
78
+ - The native calls (`sign_file`, `sign_buffer`, `read_file`, `read_buffer`)
79
+ release Ruby's global VM lock while c2pa-rs runs. Other Ruby threads keep
80
+ running during a sign; previously they were blocked until it returned.
81
+ Measured alongside a busy Ruby thread, the process now uses about 1.9
82
+ CPU-seconds per wall-second against 1.0 before. `Thread#kill` and
83
+ `Timeout` take effect when the native call returns, not during it.
84
+ - Thumbnails. `C2PA.configure` gains `thumbnails`, `thumbnail_size`,
85
+ `thumbnail_format` and `thumbnail_quality`. When enabled, a thumbnail of the
86
+ asset is embedded in its manifest, and of each ingredient supplied as a file.
87
+ Off by default: c2pa-rs upscales to its long-edge setting, so at its default
88
+ of 1024 a 160×120 image gets a 1024×768 thumbnail ten times its own size.
89
+ Produced for JPEG, PNG, WebP and TIFF; other formats sign without one.
90
+
91
+ ### Changed
92
+
93
+ - rb-sys is now a direct dependency of the native extension, for
94
+ `rb_thread_call_without_gvl`, which magnus does not wrap. It resolves to the
95
+ same copy magnus already uses.
96
+ - The native extension is built with c2pa-rs's `add_thumbnails` feature, which
97
+ adds the `image` crate: 16 more crates, about 17 seconds on a cold compile,
98
+ and 1.5 MB on the compiled extension.
99
+
10
100
  ## [0.4.0] — 2026-09-11
11
101
 
12
102
  Adds capability on top of 0.3.0. Nothing is removed and no existing call
@@ -146,7 +236,9 @@ Tagged retroactively. See the v0.2.1 tag for the defects it shipped with.
146
236
 
147
237
  Tagged retroactively.
148
238
 
149
- [Unreleased]: https://github.com/eddorre/ruby-c2pa/compare/v0.4.0...HEAD
239
+ [Unreleased]: https://github.com/eddorre/ruby-c2pa/compare/v0.6.0...HEAD
240
+ [0.6.0]: https://github.com/eddorre/ruby-c2pa/compare/v0.5.0...v0.6.0
241
+ [0.5.0]: https://github.com/eddorre/ruby-c2pa/compare/v0.4.0...v0.5.0
150
242
  [0.4.0]: https://github.com/eddorre/ruby-c2pa/compare/v0.3.0...v0.4.0
151
243
  [0.3.0]: https://github.com/eddorre/ruby-c2pa/compare/v0.2.1...v0.3.0
152
244
  [0.2.1]: https://github.com/eddorre/ruby-c2pa/compare/v0.2.0...v0.2.1
data/CONTRIBUTING.md CHANGED
@@ -14,7 +14,7 @@ Regenerating the media fixtures is the one thing that needs extra tooling, and
14
14
  only if you are changing them:
15
15
 
16
16
  ```bash
17
- brew install ffmpeg webp jpeg-xl exiftool # macOS
17
+ brew install ffmpeg webp jpeg-xl exiftool # macOS; zip ships with it
18
18
  ./test/fixtures/generate.sh
19
19
  ```
20
20
 
@@ -29,7 +29,7 @@ that suite passed while the gem:
29
29
 
30
30
  - aborted the Ruby process on any TIFF input
31
31
  - emitted manifests that modern verifiers reject
32
- - advertised PDF signing, which c2pa-rs cannot do at any version
32
+ - advertised PDF signing, which no c2pa-rs release could do until 0.91.1
33
33
  - documented an editing workflow that has never produced a valid file
34
34
 
35
35
  The suite passed because its assertions compared the code to itself:
@@ -57,9 +57,15 @@ bundle exec ruby -Ilib -Itest test/c2pa_test.rb
57
57
  git checkout -- lib/c2pa/manifest.rb
58
58
  ```
59
59
 
60
+ `git checkout --` is only safe if the file has no uncommitted changes of its
61
+ own. Mid-change it reverts your work along with the mutation, and the suite
62
+ then fails for a reason that has nothing to do with the test. Copy the file
63
+ aside before mutating and restore from the copy, and run the unmutated suite
64
+ afterwards as a control. That control run is what catches a bad restore.
65
+
60
66
  Then state it in the pull request:
61
67
 
62
- > Verified by reversing the title in `Manifest#to_json`: 19 failures, against 0
68
+ > Verified by reversing the title in `Manifest#to_json`: 21 failures, against 0
63
69
  > for the unmutated suite.
64
70
 
65
71
  A broad mutation like that trips many tests, which is fine. A narrow one that
@@ -69,7 +75,7 @@ test is specific as well as present.
69
75
  Mutating the Rust in `ext/c2pa_native/` counts double — it proves the test
70
76
  reaches through the FFI boundary rather than stopping at Ruby.
71
77
 
72
- ### Two traps, both hit while building this suite
78
+ ### Four traps, all hit while building this suite
73
79
 
74
80
  **A mutation that does not do what you think.** A NUL-handling test appeared to
75
81
  survive a mutation that stripped NUL bytes, which would have meant the test was
@@ -83,6 +89,26 @@ verdict rather than on our own code, mutate in both directions. A harness made
83
89
  to report no failures and a harness made to report spurious ones fail different
84
90
  tests; checking only one leaves the other half unverified.
85
91
 
92
+ **A probe that measures the wrong thing.** The first two designs for the
93
+ GVL tests looked at wall-clock effects: how long a Ruby thread stalled during
94
+ a native call, and how much progress it made. Both reported the lock released
95
+ when it was held, because `C2PA.sign`'s own `File.exist?` checks release the
96
+ lock (stat does) and the wait to get it back swamped everything else. A
97
+ `sample` of the process showed the main thread spending three quarters of its
98
+ time waiting on the lock after a stat, not inside the native call. The test
99
+ that works measures CPU parallelism, which waiting cannot inflate. When a
100
+ timing test passes under the mutation it was written to catch, suspect the
101
+ probe before the code.
102
+
103
+ **An assertion satisfied by the wrong thing.** A test for "signing a PDF
104
+ leaves it a readable PDF" checked that the output still contained
105
+ `ruby-c2pa`, the text on the fixture's page. It passed under a mutation that
106
+ rewrote the page content before signing, because `ruby-c2pa` also appears in
107
+ the manifest's claim generator: the assertion was reading the manifest, not
108
+ the document. Matching the page's text operator, `(ruby-c2pa) Tj`, fixed it.
109
+ When a string you assert on could come from more than one place in the
110
+ output, match something only the thing under test can produce.
111
+
86
112
  ## Where there is no oracle
87
113
 
88
114
  Most assertions can be checked against c2pa-rs, by signing a file and reading
data/README.md CHANGED
@@ -27,7 +27,9 @@ The binding layer is a native Ruby extension written in Rust using [magnus](http
27
27
  ## Requirements
28
28
 
29
29
  - Ruby >= 3.0
30
- - Rust and Cargo (to compile the native library)
30
+ - Rust >= 1.96 and Cargo (to compile the native library). c2pa-rs 0.91 sets
31
+ that floor; an older toolchain stops with
32
+ `c2pa@0.91.1 requires rustc 1.96.0` during installation.
31
33
  - OpenSSL (usually already present on macOS and Linux)
32
34
 
33
35
  ### Installing Rust
@@ -256,6 +258,23 @@ Omitting `file:` records the description alone. Nothing binds it to any bytes,
256
258
  so a verifier cannot check the claim. That form is kept for compatibility;
257
259
  prefer the file.
258
260
 
261
+ An ingredient with no credentials can say how it was produced, the same way a
262
+ `c2pa.created` action does for the asset itself:
263
+
264
+ ```ruby
265
+ manifest.add_ingredient(
266
+ title: "Generated background",
267
+ format: "image/png",
268
+ instance_id: "xmp:iid:background-uuid-here",
269
+ relationship: "componentOf",
270
+ file: "background.png",
271
+ digital_source_type: C2PA::DigitalSourceTypes::TRAINED_ALGORITHMIC_MEDIA
272
+ )
273
+ ```
274
+
275
+ An ingredient that carries content credentials already records its own origin,
276
+ so `add_ingredient` raises `C2PA::InvalidManifestError` if you give it one.
277
+
259
278
  ### Updating an existing asset
260
279
 
261
280
  For a non-editorial change to an asset, such as correcting metadata, use
@@ -323,6 +342,46 @@ C2PA.sign(
323
342
  )
324
343
  ```
325
344
 
345
+ ### Signing bytes in memory
346
+
347
+ For data that never touches the filesystem, such as an upload held in a
348
+ request body or an image your application generated, `C2PA.sign_buffer` takes
349
+ the bytes and returns the signed bytes. The format must be given, since there
350
+ is no filename to infer it from.
351
+
352
+ ```ruby
353
+ signed = C2PA.sign_buffer(
354
+ data: request.body.read,
355
+ format: "image/jpeg",
356
+ certificate: "cert.pem",
357
+ key: "key.pem",
358
+ manifest: manifest
359
+ )
360
+ ```
361
+
362
+ The input must be a binary string (`Encoding::BINARY`, which is what
363
+ `File.binread` and `IO#read` on a binary-mode stream return). A string tagged
364
+ UTF-8 is rejected with an `ArgumentError` rather than transcoded, because a
365
+ transcoded JPEG is a corrupt JPEG and nothing notices until a verifier rejects
366
+ it. If you have such a string and know the bytes are intact, call `.b` on it.
367
+
368
+ The same verify-after-sign guard applies. A result that does not validate is
369
+ never returned; `C2PA::SigningError` is raised instead, and `verify: false`
370
+ returns it anyway. `algorithm:` and everything on the manifest, including
371
+ intents and ingredient files, work as they do for `C2PA.sign`.
372
+
373
+ Memory is the trade-off. The input, the copy c2pa-rs works on, and the signed
374
+ result on both sides of the Ruby boundary are resident at once at the peak,
375
+ so budget about four times the size of the asset per call. For a photo that
376
+ is nothing; for a feature-length video it is a reason to use `C2PA.sign` with
377
+ paths instead.
378
+
379
+ Signing and reading release Ruby's global VM lock while c2pa-rs works, so
380
+ other threads in the process keep running. A threaded server signing a large
381
+ video does not stall its other requests for the duration. One consequence:
382
+ `Thread#kill` and `Timeout` cannot interrupt a native call in progress; they
383
+ take effect when it returns.
384
+
326
385
  ### Reading a manifest
327
386
 
328
387
  ```ruby
@@ -333,6 +392,49 @@ puts active["title"]
333
392
  puts active["claim_generator_info"].first["name"] # => "ruby-c2pa"
334
393
  ```
335
394
 
395
+ From memory, `C2PA.read_buffer` takes the bytes. c2pa-rs identifies most
396
+ formats from the leading bytes, so the format is optional; it is needed for a
397
+ format with no signature to sniff, such as SVG.
398
+
399
+ ```ruby
400
+ result = C2PA.read_buffer(data: signed)
401
+ result = C2PA.read_buffer(data: svg_bytes, format: "image/svg+xml")
402
+ ```
403
+
404
+ ### Keeping the manifest in a separate file
405
+
406
+ Pass `sidecar:` to write the manifest to its own `.c2pa` file instead of
407
+ embedding it. The output is the asset exactly as it was hashed, which is
408
+ usually byte-for-byte the input. Use this when the file can't be modified, or
409
+ when the manifest is served separately.
410
+
411
+ ```ruby
412
+ C2PA.sign(
413
+ file: "photo.jpg",
414
+ output: "published/photo.jpg",
415
+ sidecar: "published/photo.c2pa",
416
+ certificate: "cert.pem",
417
+ key: "key.pem",
418
+ manifest: manifest
419
+ )
420
+ ```
421
+
422
+ The asset is only valid together with its sidecar. Changing either one, or
423
+ pairing the sidecar with a different file, makes it invalid.
424
+
425
+ When a file has no embedded manifest, `C2PA.read` looks beside it for the same
426
+ name with a `.c2pa` extension, so the pair above reads with no extra argument.
427
+ For a sidecar kept anywhere else, name it:
428
+
429
+ ```ruby
430
+ C2PA.read(file: "photo.jpg", manifest_file: "manifests/1234.c2pa")
431
+ C2PA.read_buffer(data: bytes, format: "image/jpeg", manifest_data: sidecar_bytes)
432
+ ```
433
+
434
+ Reading a `.c2pa` file on its own returns the manifest, but it always reports
435
+ `Invalid` with `assertion.dataHash.mismatch` (or the matching BMFF code),
436
+ because there's no asset for the hash to be checked against.
437
+
336
438
  ### Naming your application
337
439
 
338
440
  Signed files credit `ruby-c2pa` by default. To credit your own application
@@ -355,8 +457,8 @@ The signed manifest then reads:
355
457
  {
356
458
  "name": "Acme Editor",
357
459
  "version": "2.0",
358
- "org.rubygems.ruby_c2pa": "0.3.0",
359
- "org.contentauth.c2pa_rs": "0.78.8"
460
+ "org.contentauth.c2pa_rs": "0.90.22",
461
+ "org.rubygems.ruby_c2pa": "0.6.0"
360
462
  }
361
463
  ```
362
464
 
@@ -370,12 +472,15 @@ calling application.
370
472
  ### Checking the SDK version
371
473
 
372
474
  ```ruby
373
- puts C2PA.sdk_version # => "0.78.3" (depends on the c2pa-rs version bundled with the gem)
475
+ puts C2PA.sdk_version # => "0.90.22" (the c2pa-rs version the gem was built against)
374
476
  ```
375
477
 
376
478
  ### Error handling
377
479
 
378
- All errors inherit from `C2PA::Error`, so you can rescue broadly or narrowly:
480
+ All errors inherit from `C2PA::Error`, so you can rescue broadly or narrowly.
481
+ `SigningError` covers signing and the post-signing verification, `ReadError`
482
+ reading, `InvalidManifestError` anything the builder rejects, and
483
+ `InvalidSettingsError` anything `C2PA.configure` cannot use.
379
484
 
380
485
  ```ruby
381
486
  begin
@@ -392,6 +497,12 @@ rescue C2PA::ReadError => e
392
497
  puts "Could not read manifest: #{e.message}"
393
498
  end
394
499
 
500
+ begin
501
+ C2PA.configure { |config| config.trust_anchors = "ca/root.pem" }
502
+ rescue C2PA::InvalidSettingsError => e
503
+ puts "Settings not usable: #{e.message}"
504
+ end
505
+
395
506
  # Or rescue any C2PA error broadly
396
507
  begin
397
508
  C2PA.sign(file: "photo.jpg", output: "photo_signed.jpg", certificate: "cert.pem", key: "key.pem", manifest: manifest)
@@ -429,6 +540,56 @@ C2PA.configure do |config|
429
540
  end
430
541
  ```
431
542
 
543
+ When fetches stay on, c2pa-rs follows HTTP redirects but refuses one that
544
+ points at an internal address, such as localhost, a private network or a cloud
545
+ metadata endpoint. The URL can come from the asset being read, so this stops a
546
+ crafted file from reaching your internal services. To refuse redirects
547
+ altogether:
548
+
549
+ ```ruby
550
+ C2PA.configure do |config|
551
+ config.allow_redirects = false
552
+ end
553
+ ```
554
+
555
+ ### Thumbnails
556
+
557
+ c2pa-rs can embed a thumbnail of the asset in its manifest, and of each
558
+ ingredient supplied as a file. Verify tools show it alongside the credentials.
559
+ It is off unless you turn it on:
560
+
561
+ ```ruby
562
+ C2PA.configure do |config|
563
+ config.thumbnails = true
564
+ config.thumbnail_size = 512 # longest edge in pixels
565
+ end
566
+ ```
567
+
568
+ The default is off because c2pa-rs scales to a fixed long edge and upscales to
569
+ reach it. Its own default is 1024, so a 160×120 image gets a 1024×768
570
+ thumbnail, roughly ten times the size of the asset it describes. Set
571
+ `thumbnail_size` no larger than your assets, or leave thumbnails off for small
572
+ images.
573
+
574
+ Thumbnails are produced for JPEG, PNG, WebP and TIFF. Other formats sign
575
+ without one; c2pa-rs treats that as non-fatal.
576
+
577
+ ### Padding in MP4 and other BMFF files
578
+
579
+ MP4, MOV and related formats can hold `free` and `skip` boxes, which are
580
+ padding. Tools often rewrite them after a file is signed, so by default the
581
+ signature leaves them out and those edits don't break it. To make any change
582
+ to the padding break the signature, as a change to the media would:
583
+
584
+ ```ruby
585
+ C2PA.configure do |config|
586
+ config.exclude_free_and_skip_boxes = false
587
+ end
588
+ ```
589
+
590
+ This only affects signing. The choice is recorded in the signed file, and any
591
+ reader holds the file to it.
592
+
432
593
  ### Everything configurable
433
594
 
434
595
  | Setting | Default | Purpose |
@@ -439,10 +600,19 @@ end
439
600
  | `verify_trust` | `true` | whether trust is checked at all |
440
601
  | `remote_manifest_fetch` | `true` | whether reading may fetch over the network |
441
602
  | `ocsp_fetch` | `false` | whether revocation is checked over OCSP |
603
+ | `allow_redirects` | `true` | whether network fetches follow redirects; internal targets are always refused |
604
+ | `exclude_free_and_skip_boxes` | `true` | whether BMFF padding boxes are left out of the signature |
605
+ | `thumbnails` | `false` | embed a thumbnail of the asset and of file-backed ingredients |
606
+ | `thumbnail_size` | 1024 | longest edge of the thumbnail, in pixels |
607
+ | `thumbnail_format` | smallest | `:jpeg`, `:png` or `:gif` |
608
+ | `thumbnail_quality` | `:medium` | `:low`, `:medium` or `:high` |
442
609
 
443
610
  Settings are global and apply to subsequent calls. Only values you set are
444
- sent, so anything left alone keeps c2pa-rs's own default. `C2PA.configure` with
445
- no block resets everything.
611
+ sent, so anything left alone keeps c2pa-rs's own default. Two exceptions are
612
+ always sent, because this gem's default differs from c2pa-rs's: `thumbnails`,
613
+ and c2pa-rs's own verify-after-sign check, which the gem turns off in favour of
614
+ its `verify:` guard.
615
+ `C2PA.configure` with no block resets everything.
446
616
 
447
617
  Turning `verify_trust` off means nothing is ever reported as untrusted, which
448
618
  in a library for establishing provenance is rarely what you want. It exists for
@@ -465,31 +635,59 @@ signed, read back, and asserted to validate.
465
635
  | MOV | `video/quicktime` |
466
636
  | MP3 | `audio/mpeg` |
467
637
  | WAV | `audio/wav` |
468
- | PDF | `application/pdf` | read only, see below |
638
+ | EPUB | `application/epub+zip` |
639
+ | Word (DOCX) | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` |
640
+ | OpenDocument text (ODT) | `application/vnd.oasis.opendocument.text` |
641
+ | OpenXPS | `application/oxps` |
642
+ | PDF | `application/pdf` |
469
643
 
470
644
  The format is detected automatically from the file extension.
471
645
 
646
+ ### EPUB, Office and OpenDocument files
647
+
648
+ These formats are ZIP packages. The manifest is added as
649
+ `META-INF/content_credential.c2pa`, and every other part of the package is
650
+ covered by the signature, so editing the document, or adding or removing a
651
+ file in it, makes it invalid. The document still opens in its usual app after
652
+ signing.
653
+
654
+ c2pa-rs lists the other Office Open XML and OpenDocument types as well: Excel
655
+ and PowerPoint, their macro-enabled forms, OpenDocument spreadsheets,
656
+ presentations, drawings and templates. They use the same code, but only the
657
+ four above have fixtures in the suite.
658
+
659
+ When reading one of these from memory, pass `format:` to `C2PA.read_buffer`.
660
+ They all begin with the same ZIP header, so c2pa-rs can't identify them from
661
+ the bytes alone.
662
+
472
663
  JPEG XL must be in the ISOBMFF container form. A bare codestream has no boxes
473
664
  to hold a manifest, and c2pa-rs rejects it.
474
665
 
475
- ### PDF is read-only
666
+ ### PDF
476
667
 
477
- `C2PA.read` works on a PDF that carries content credentials, such as one signed
478
- by Adobe Acrobat. `C2PA.sign` does not: c2pa-rs has no PDF writer at any
479
- version. `get_writer` returns `None` and `save_cai_store` returns
480
- `NotImplemented`, and upstream closed the request to expose one in December
481
- 2025 (contentauth/c2pa-rs#527). Signing a PDF raises `C2PA::SigningError`
482
- with `type is unsupported`.
668
+ PDFs sign and read like any other format. The manifest goes into the
669
+ document's associated files, as `/AFRelationship /C2PA_Manifest`, and the file
670
+ still opens in a PDF reader afterwards.
483
671
 
484
- Earlier releases of this gem listed PDF as signable. That was never correct.
672
+ ```ruby
673
+ C2PA.sign(
674
+ file: "contract.pdf",
675
+ output: "contract_signed.pdf",
676
+ certificate: "cert.pem",
677
+ key: "key.pem",
678
+ manifest: manifest
679
+ )
680
+ ```
681
+
682
+ This is new. c2pa-rs had no PDF writer until 0.91.1 (September 2026):
683
+ `get_writer` returned `None`, every write path returned `NotImplemented`, and
684
+ upstream had closed the request for one in December 2025. Releases of this gem
685
+ up to 0.5.0 said PDF signing was impossible, and while they shipped that was
686
+ true. It is not true now, and 0.6.0 is the first release where a PDF signs.
485
687
 
486
- One limit on what the test suite can show. It proves the PDF handler is active,
487
- by reading a PDF with no credentials and getting `no JUMBF data found` rather
488
- than `type is unsupported`. It cannot prove that a signed PDF returns its
489
- manifest, because nothing available can produce one: c2pa-rs cannot write
490
- them, and no local tool can either. The code doing the reading is c2pa-rs's
491
- own and is tested upstream; what is untested here is only this gem's
492
- integration with the success path.
688
+ Expect the signed file to be much larger than the original when the document
689
+ is small. The manifest carries a full certificate chain, which is a few
690
+ kilobytes whatever the document weighs.
493
691
 
494
692
  ### Test fixtures
495
693
 
@@ -499,10 +697,11 @@ sources, so they carry no third-party content and no licence obligations. They
499
697
  are deliberately real files rather than placeholders — 160×120 images with
500
698
  actual detail, real audio samples, real video frames, and EXIF metadata on the
501
699
  JPEG — because C2PA writes into container structures that an empty file would
502
- not exercise. All ten total 92 KB.
700
+ not exercise. The PDF and the four ZIP-based documents are written by hand in
701
+ the same script. All fifteen total about 68 KB.
503
702
 
504
- Regenerating them needs `ffmpeg`, `cjxl` and `exiftool`; running the tests does
505
- not.
703
+ Regenerating them needs `ffmpeg`, `cwebp`, `cjxl`, `exiftool` and `zip`; running
704
+ the tests does not.
506
705
 
507
706
  Signing certificates are generated on demand, one chain per key type, so every
508
707
  supported algorithm is covered:
@@ -525,20 +724,35 @@ Ruby (C2PA.sign)
525
724
  ▼
526
725
  Rust (C2PA::Native.sign_file)
527
726
  │
528
- │ calls c2pa-rs Builder API
727
+ │ c2pa-rs Builder, through a shared Context
529
728
  ▼
530
729
  c2pa-rs — embeds signed manifest into the file
531
730
  ```
532
731
 
533
- The Rust extension (`ext/c2pa_native/src/lib.rs`) defines `C2PA::Native` with three methods:
732
+ The Rust extension (`ext/c2pa_native/src/lib.rs`) defines `C2PA::Native` with six methods:
534
733
 
535
734
  | Method | Description |
536
735
  |--------|-------------|
537
- | `C2PA::Native.sign_file` | Sign a file and write the result |
736
+ | `C2PA::Native.sign_file` | Sign a file and write the result. Takes the manifest JSON, an optional intent, and any ingredient files |
737
+ | `C2PA::Native.sign_buffer` | The same over bytes: a binary string in, the signed binary string out |
538
738
  | `C2PA::Native.read_file` | Read and return the manifest JSON |
739
+ | `C2PA::Native.read_buffer` | The same over bytes, with an optional format hint |
740
+ | `C2PA::Native.configure` | Replace the shared c2pa-rs Context with one built from a settings document |
539
741
  | `C2PA::Native.sdk_version` | Return the c2pa-rs version string |
540
742
 
541
- Input validation (missing files, invalid manifests) is handled in Ruby before calling into Rust. Errors from the native layer are caught and re-raised as typed `C2PA::Error` subclasses.
743
+ All four run with the global VM lock released, so other Ruby threads are not
744
+ blocked while c2pa-rs hashes and signs. The bytes and paths are copied out of
745
+ Ruby before the lock goes, and the result is turned into a Ruby object after
746
+ it is back; nothing in between touches the interpreter.
747
+
748
+ Signing and reading go through one c2pa-rs `Context`, built once and shared
749
+ across threads. `C2PA.configure` replaces it rather than mutating it, so a
750
+ signing call already in flight keeps the settings it started with.
751
+
752
+ Input validation (missing files, invalid manifests, unreadable ingredient
753
+ files, unusable settings) is handled in Ruby before calling into Rust. Errors
754
+ from the native layer are caught and re-raised as typed `C2PA::Error`
755
+ subclasses.
542
756
 
543
757
  ## Contributing
544
758