ruby-c2pa 0.4.0 → 0.5.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: 41362e47552927fd69a0bec7203dc35f59442af7eeaeb1d5f787216e08c18b79
4
+ data.tar.gz: 6e93a632b24a7baddf4ba4792d8a9f8043559fd5c168df1a685b8094a73f9689
5
5
  SHA512:
6
- metadata.gz: 65b8729aa03c0fb5145f1631fc5c847960be95cfd792553d0352c9c9e7f54451a077b3c7eb599c45ff3b7007698aaa9a91d3f05848c0307f192c5d5de2333c25
7
- data.tar.gz: 0f46873209bbc9b081a2ef24c276adb0daf079466366a01a712a504d3f0df1eb64b20949fa2f171c469eba9e0da48f429c569b09cba8d5ee48f6e978d3548f72
6
+ metadata.gz: b9d9de8da30c033cc5e64ea2d27609ce8be1a9b3d08c400c98f4a092c9ad85f27a985f347477ecd3b019366cf75f9c9c65d17a40f94ce1f82476b753b5dc4a85
7
+ data.tar.gz: d888086e87c7eec8fd09d5f8fadb4f6e5f9493d3ecc7199264c10c38bfc254aead1e380d881bbc3b9ad3c7569e30220f57764e85bebe1f98bd0f1a8b816c4e54
data/CHANGELOG.md CHANGED
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] — 2026-09-12
11
+
12
+ Adds capability on top of 0.4.0. Nothing is removed and no existing call
13
+ changes behaviour, so this is a minor release. The one change every caller
14
+ will feel is that native calls no longer hold the GVL, which only makes other
15
+ threads faster.
16
+
17
+ ### Added
18
+
19
+ - Buffer signing. `C2PA.sign_buffer(data:, format:, ...)` signs bytes held in
20
+ memory and returns the signed bytes; `C2PA.read_buffer(data:, format: nil)`
21
+ reads them back. Both require a binary string and raise `ArgumentError` for
22
+ any other encoding rather than transcoding the asset. The verify-after-sign
23
+ guard applies to buffers as it does to files. Memory use is about four
24
+ times the asset.
25
+ - The native calls (`sign_file`, `sign_buffer`, `read_file`, `read_buffer`)
26
+ release Ruby's global VM lock while c2pa-rs runs. Other Ruby threads keep
27
+ running during a sign; previously they were blocked until it returned.
28
+ Measured alongside a busy Ruby thread, the process now uses about 1.9
29
+ CPU-seconds per wall-second against 1.0 before. `Thread#kill` and
30
+ `Timeout` take effect when the native call returns, not during it.
31
+ - Thumbnails. `C2PA.configure` gains `thumbnails`, `thumbnail_size`,
32
+ `thumbnail_format` and `thumbnail_quality`. When enabled, a thumbnail of the
33
+ asset is embedded in its manifest, and of each ingredient supplied as a file.
34
+ Off by default: c2pa-rs upscales to its long-edge setting, so at its default
35
+ of 1024 a 160×120 image gets a 1024×768 thumbnail ten times its own size.
36
+ Produced for JPEG, PNG, WebP and TIFF; other formats sign without one.
37
+
38
+ ### Changed
39
+
40
+ - rb-sys is now a direct dependency of the native extension, for
41
+ `rb_thread_call_without_gvl`, which magnus does not wrap. It resolves to the
42
+ same copy magnus already uses.
43
+ - The native extension is built with c2pa-rs's `add_thumbnails` feature, which
44
+ adds the `image` crate: 16 more crates, about 17 seconds on a cold compile,
45
+ and 1.5 MB on the compiled extension.
46
+
10
47
  ## [0.4.0] — 2026-09-11
11
48
 
12
49
  Adds capability on top of 0.3.0. Nothing is removed and no existing call
@@ -146,7 +183,8 @@ Tagged retroactively. See the v0.2.1 tag for the defects it shipped with.
146
183
 
147
184
  Tagged retroactively.
148
185
 
149
- [Unreleased]: https://github.com/eddorre/ruby-c2pa/compare/v0.4.0...HEAD
186
+ [Unreleased]: https://github.com/eddorre/ruby-c2pa/compare/v0.5.0...HEAD
187
+ [0.5.0]: https://github.com/eddorre/ruby-c2pa/compare/v0.4.0...v0.5.0
150
188
  [0.4.0]: https://github.com/eddorre/ruby-c2pa/compare/v0.3.0...v0.4.0
151
189
  [0.3.0]: https://github.com/eddorre/ruby-c2pa/compare/v0.2.1...v0.3.0
152
190
  [0.2.1]: https://github.com/eddorre/ruby-c2pa/compare/v0.2.0...v0.2.1
data/CONTRIBUTING.md CHANGED
@@ -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
+ ### Three 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,17 @@ 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
+
86
103
  ## Where there is no oracle
87
104
 
88
105
  Most assertions can be checked against c2pa-rs, by signing a file and reading
data/README.md CHANGED
@@ -323,6 +323,46 @@ C2PA.sign(
323
323
  )
324
324
  ```
325
325
 
326
+ ### Signing bytes in memory
327
+
328
+ For data that never touches the filesystem, such as an upload held in a
329
+ request body or an image your application generated, `C2PA.sign_buffer` takes
330
+ the bytes and returns the signed bytes. The format must be given, since there
331
+ is no filename to infer it from.
332
+
333
+ ```ruby
334
+ signed = C2PA.sign_buffer(
335
+ data: request.body.read,
336
+ format: "image/jpeg",
337
+ certificate: "cert.pem",
338
+ key: "key.pem",
339
+ manifest: manifest
340
+ )
341
+ ```
342
+
343
+ The input must be a binary string (`Encoding::BINARY`, which is what
344
+ `File.binread` and `IO#read` on a binary-mode stream return). A string tagged
345
+ UTF-8 is rejected with an `ArgumentError` rather than transcoded, because a
346
+ transcoded JPEG is a corrupt JPEG and nothing notices until a verifier rejects
347
+ it. If you have such a string and know the bytes are intact, call `.b` on it.
348
+
349
+ The same verify-after-sign guard applies. A result that does not validate is
350
+ never returned; `C2PA::SigningError` is raised instead, and `verify: false`
351
+ returns it anyway. `algorithm:` and everything on the manifest, including
352
+ intents and ingredient files, work as they do for `C2PA.sign`.
353
+
354
+ Memory is the trade-off. The input, the copy c2pa-rs works on, and the signed
355
+ result on both sides of the Ruby boundary are resident at once at the peak,
356
+ so budget about four times the size of the asset per call. For a photo that
357
+ is nothing; for a feature-length video it is a reason to use `C2PA.sign` with
358
+ paths instead.
359
+
360
+ Signing and reading release Ruby's global VM lock while c2pa-rs works, so
361
+ other threads in the process keep running. A threaded server signing a large
362
+ video does not stall its other requests for the duration. One consequence:
363
+ `Thread#kill` and `Timeout` cannot interrupt a native call in progress; they
364
+ take effect when it returns.
365
+
326
366
  ### Reading a manifest
327
367
 
328
368
  ```ruby
@@ -333,6 +373,15 @@ puts active["title"]
333
373
  puts active["claim_generator_info"].first["name"] # => "ruby-c2pa"
334
374
  ```
335
375
 
376
+ From memory, `C2PA.read_buffer` takes the bytes. c2pa-rs identifies most
377
+ formats from the leading bytes, so the format is optional; it is needed for a
378
+ format with no signature to sniff, such as SVG.
379
+
380
+ ```ruby
381
+ result = C2PA.read_buffer(data: signed)
382
+ result = C2PA.read_buffer(data: svg_bytes, format: "image/svg+xml")
383
+ ```
384
+
336
385
  ### Naming your application
337
386
 
338
387
  Signed files credit `ruby-c2pa` by default. To credit your own application
@@ -355,8 +404,8 @@ The signed manifest then reads:
355
404
  {
356
405
  "name": "Acme Editor",
357
406
  "version": "2.0",
358
- "org.rubygems.ruby_c2pa": "0.3.0",
359
- "org.contentauth.c2pa_rs": "0.78.8"
407
+ "org.contentauth.c2pa_rs": "0.90.22",
408
+ "org.rubygems.ruby_c2pa": "0.5.0"
360
409
  }
361
410
  ```
362
411
 
@@ -370,12 +419,15 @@ calling application.
370
419
  ### Checking the SDK version
371
420
 
372
421
  ```ruby
373
- puts C2PA.sdk_version # => "0.78.3" (depends on the c2pa-rs version bundled with the gem)
422
+ puts C2PA.sdk_version # => "0.90.22" (the c2pa-rs version the gem was built against)
374
423
  ```
375
424
 
376
425
  ### Error handling
377
426
 
378
- All errors inherit from `C2PA::Error`, so you can rescue broadly or narrowly:
427
+ All errors inherit from `C2PA::Error`, so you can rescue broadly or narrowly.
428
+ `SigningError` covers signing and the post-signing verification, `ReadError`
429
+ reading, `InvalidManifestError` anything the builder rejects, and
430
+ `InvalidSettingsError` anything `C2PA.configure` cannot use.
379
431
 
380
432
  ```ruby
381
433
  begin
@@ -392,6 +444,12 @@ rescue C2PA::ReadError => e
392
444
  puts "Could not read manifest: #{e.message}"
393
445
  end
394
446
 
447
+ begin
448
+ C2PA.configure { |config| config.trust_anchors = "ca/root.pem" }
449
+ rescue C2PA::InvalidSettingsError => e
450
+ puts "Settings not usable: #{e.message}"
451
+ end
452
+
395
453
  # Or rescue any C2PA error broadly
396
454
  begin
397
455
  C2PA.sign(file: "photo.jpg", output: "photo_signed.jpg", certificate: "cert.pem", key: "key.pem", manifest: manifest)
@@ -429,6 +487,28 @@ C2PA.configure do |config|
429
487
  end
430
488
  ```
431
489
 
490
+ ### Thumbnails
491
+
492
+ c2pa-rs can embed a thumbnail of the asset in its manifest, and of each
493
+ ingredient supplied as a file. Verify tools show it alongside the credentials.
494
+ It is off unless you turn it on:
495
+
496
+ ```ruby
497
+ C2PA.configure do |config|
498
+ config.thumbnails = true
499
+ config.thumbnail_size = 512 # longest edge in pixels
500
+ end
501
+ ```
502
+
503
+ The default is off because c2pa-rs scales to a fixed long edge and upscales to
504
+ reach it. Its own default is 1024, so a 160×120 image gets a 1024×768
505
+ thumbnail, roughly ten times the size of the asset it describes. Set
506
+ `thumbnail_size` no larger than your assets, or leave thumbnails off for small
507
+ images.
508
+
509
+ Thumbnails are produced for JPEG, PNG, WebP and TIFF. Other formats sign
510
+ without one; c2pa-rs treats that as non-fatal.
511
+
432
512
  ### Everything configurable
433
513
 
434
514
  | Setting | Default | Purpose |
@@ -439,10 +519,15 @@ end
439
519
  | `verify_trust` | `true` | whether trust is checked at all |
440
520
  | `remote_manifest_fetch` | `true` | whether reading may fetch over the network |
441
521
  | `ocsp_fetch` | `false` | whether revocation is checked over OCSP |
522
+ | `thumbnails` | `false` | embed a thumbnail of the asset and of file-backed ingredients |
523
+ | `thumbnail_size` | 1024 | longest edge of the thumbnail, in pixels |
524
+ | `thumbnail_format` | smallest | `:jpeg`, `:png` or `:gif` |
525
+ | `thumbnail_quality` | `:medium` | `:low`, `:medium` or `:high` |
442
526
 
443
527
  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.
528
+ sent, so anything left alone keeps c2pa-rs's own default, with one exception:
529
+ `thumbnails` is always sent, because this gem's default differs from c2pa-rs's.
530
+ `C2PA.configure` with no block resets everything.
446
531
 
447
532
  Turning `verify_trust` off means nothing is ever reported as untrusted, which
448
533
  in a library for establishing provenance is rarely what you want. It exists for
@@ -525,20 +610,35 @@ Ruby (C2PA.sign)
525
610
 
526
611
  Rust (C2PA::Native.sign_file)
527
612
 
528
- calls c2pa-rs Builder API
613
+ │ c2pa-rs Builder, through a shared Context
529
614
 
530
615
  c2pa-rs — embeds signed manifest into the file
531
616
  ```
532
617
 
533
- The Rust extension (`ext/c2pa_native/src/lib.rs`) defines `C2PA::Native` with three methods:
618
+ The Rust extension (`ext/c2pa_native/src/lib.rs`) defines `C2PA::Native` with six methods:
534
619
 
535
620
  | Method | Description |
536
621
  |--------|-------------|
537
- | `C2PA::Native.sign_file` | Sign a file and write the result |
622
+ | `C2PA::Native.sign_file` | Sign a file and write the result. Takes the manifest JSON, an optional intent, and any ingredient files |
623
+ | `C2PA::Native.sign_buffer` | The same over bytes: a binary string in, the signed binary string out |
538
624
  | `C2PA::Native.read_file` | Read and return the manifest JSON |
625
+ | `C2PA::Native.read_buffer` | The same over bytes, with an optional format hint |
626
+ | `C2PA::Native.configure` | Replace the shared c2pa-rs Context with one built from a settings document |
539
627
  | `C2PA::Native.sdk_version` | Return the c2pa-rs version string |
540
628
 
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.
629
+ All four run with the global VM lock released, so other Ruby threads are not
630
+ blocked while c2pa-rs hashes and signs. The bytes and paths are copied out of
631
+ Ruby before the lock goes, and the result is turned into a Ruby object after
632
+ it is back; nothing in between touches the interpreter.
633
+
634
+ Signing and reading go through one c2pa-rs `Context`, built once and shared
635
+ across threads. `C2PA.configure` replaces it rather than mutating it, so a
636
+ signing call already in flight keeps the settings it started with.
637
+
638
+ Input validation (missing files, invalid manifests, unreadable ingredient
639
+ files, unusable settings) is handled in Ruby before calling into Rust. Errors
640
+ from the native layer are caught and re-raised as typed `C2PA::Error`
641
+ subclasses.
542
642
 
543
643
  ## Contributing
544
644
 
@@ -329,12 +329,24 @@ version = "3.20.3"
329
329
  source = "registry+https://github.com/rust-lang/crates.io-index"
330
330
  checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
331
331
 
332
+ [[package]]
333
+ name = "bytemuck"
334
+ version = "1.25.2"
335
+ source = "registry+https://github.com/rust-lang/crates.io-index"
336
+ checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
337
+
332
338
  [[package]]
333
339
  name = "byteorder"
334
340
  version = "1.5.0"
335
341
  source = "registry+https://github.com/rust-lang/crates.io-index"
336
342
  checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
337
343
 
344
+ [[package]]
345
+ name = "byteorder-lite"
346
+ version = "0.1.0"
347
+ source = "registry+https://github.com/rust-lang/crates.io-index"
348
+ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
349
+
338
350
  [[package]]
339
351
  name = "byteordered"
340
352
  version = "0.6.0"
@@ -382,6 +394,7 @@ dependencies = [
382
394
  "hex",
383
395
  "http",
384
396
  "id3",
397
+ "image",
385
398
  "img-parts",
386
399
  "iref",
387
400
  "jfifdump",
@@ -459,6 +472,7 @@ version = "0.1.0"
459
472
  dependencies = [
460
473
  "c2pa",
461
474
  "magnus",
475
+ "rb-sys",
462
476
  "serde_json",
463
477
  ]
464
478
 
@@ -584,6 +598,12 @@ dependencies = [
584
598
  "libloading",
585
599
  ]
586
600
 
601
+ [[package]]
602
+ name = "color_quant"
603
+ version = "1.1.0"
604
+ source = "registry+https://github.com/rust-lang/crates.io-index"
605
+ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
606
+
587
607
  [[package]]
588
608
  name = "console_log"
589
609
  version = "1.1.0"
@@ -1049,6 +1069,21 @@ version = "2.5.0"
1049
1069
  source = "registry+https://github.com/rust-lang/crates.io-index"
1050
1070
  checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
1051
1071
 
1072
+ [[package]]
1073
+ name = "fax"
1074
+ version = "0.2.7"
1075
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1076
+ checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
1077
+
1078
+ [[package]]
1079
+ name = "fdeflate"
1080
+ version = "0.3.7"
1081
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1082
+ checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
1083
+ dependencies = [
1084
+ "simd-adler32",
1085
+ ]
1086
+
1052
1087
  [[package]]
1053
1088
  name = "ff"
1054
1089
  version = "0.13.1"
@@ -1209,6 +1244,16 @@ dependencies = [
1209
1244
  "wasm-bindgen",
1210
1245
  ]
1211
1246
 
1247
+ [[package]]
1248
+ name = "gif"
1249
+ version = "0.14.2"
1250
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1251
+ checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
1252
+ dependencies = [
1253
+ "color_quant",
1254
+ "weezl 0.1.12",
1255
+ ]
1256
+
1212
1257
  [[package]]
1213
1258
  name = "glob"
1214
1259
  version = "0.3.4"
@@ -1559,6 +1604,35 @@ dependencies = [
1559
1604
  "icu_properties",
1560
1605
  ]
1561
1606
 
1607
+ [[package]]
1608
+ name = "image"
1609
+ version = "0.25.10"
1610
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1611
+ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
1612
+ dependencies = [
1613
+ "bytemuck",
1614
+ "byteorder-lite",
1615
+ "color_quant",
1616
+ "gif",
1617
+ "image-webp",
1618
+ "moxcms",
1619
+ "num-traits",
1620
+ "png",
1621
+ "tiff",
1622
+ "zune-core",
1623
+ "zune-jpeg",
1624
+ ]
1625
+
1626
+ [[package]]
1627
+ name = "image-webp"
1628
+ version = "0.2.4"
1629
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1630
+ checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
1631
+ dependencies = [
1632
+ "byteorder-lite",
1633
+ "quick-error",
1634
+ ]
1635
+
1562
1636
  [[package]]
1563
1637
  name = "img-parts"
1564
1638
  version = "0.4.0"
@@ -1801,7 +1875,7 @@ dependencies = [
1801
1875
  "stringprep",
1802
1876
  "thiserror 2.0.20",
1803
1877
  "time",
1804
- "weezl",
1878
+ "weezl 0.2.1",
1805
1879
  ]
1806
1880
 
1807
1881
  [[package]]
@@ -1862,6 +1936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1862
1936
  checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
1863
1937
  dependencies = [
1864
1938
  "adler2",
1939
+ "simd-adler32",
1865
1940
  ]
1866
1941
 
1867
1942
  [[package]]
@@ -1885,6 +1960,16 @@ dependencies = [
1885
1960
  "windows-sys 0.61.2",
1886
1961
  ]
1887
1962
 
1963
+ [[package]]
1964
+ name = "moxcms"
1965
+ version = "0.8.1"
1966
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1967
+ checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
1968
+ dependencies = [
1969
+ "num-traits",
1970
+ "pxfm",
1971
+ ]
1972
+
1888
1973
  [[package]]
1889
1974
  name = "multiversion"
1890
1975
  version = "0.9.0"
@@ -2195,6 +2280,19 @@ version = "0.3.34"
2195
2280
  source = "registry+https://github.com/rust-lang/crates.io-index"
2196
2281
  checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
2197
2282
 
2283
+ [[package]]
2284
+ name = "png"
2285
+ version = "0.18.1"
2286
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2287
+ checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
2288
+ dependencies = [
2289
+ "bitflags 2.13.2",
2290
+ "crc32fast",
2291
+ "fdeflate",
2292
+ "flate2",
2293
+ "miniz_oxide 0.8.9",
2294
+ ]
2295
+
2198
2296
  [[package]]
2199
2297
  name = "png_pong"
2200
2298
  version = "0.10.0"
@@ -2304,6 +2402,18 @@ dependencies = [
2304
2402
  "unarray",
2305
2403
  ]
2306
2404
 
2405
+ [[package]]
2406
+ name = "pxfm"
2407
+ version = "0.1.30"
2408
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2409
+ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
2410
+
2411
+ [[package]]
2412
+ name = "quick-error"
2413
+ version = "2.0.1"
2414
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2415
+ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
2416
+
2307
2417
  [[package]]
2308
2418
  name = "quick-xml"
2309
2419
  version = "0.41.0"
@@ -3372,6 +3482,20 @@ dependencies = [
3372
3482
  "syn 3.0.5",
3373
3483
  ]
3374
3484
 
3485
+ [[package]]
3486
+ name = "tiff"
3487
+ version = "0.11.3"
3488
+ source = "registry+https://github.com/rust-lang/crates.io-index"
3489
+ checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
3490
+ dependencies = [
3491
+ "fax",
3492
+ "flate2",
3493
+ "half",
3494
+ "quick-error",
3495
+ "weezl 0.1.12",
3496
+ "zune-jpeg",
3497
+ ]
3498
+
3375
3499
  [[package]]
3376
3500
  name = "time"
3377
3501
  version = "0.3.55"
@@ -3835,6 +3959,12 @@ dependencies = [
3835
3959
  "rustls-pki-types",
3836
3960
  ]
3837
3961
 
3962
+ [[package]]
3963
+ name = "weezl"
3964
+ version = "0.1.12"
3965
+ source = "registry+https://github.com/rust-lang/crates.io-index"
3966
+ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
3967
+
3838
3968
  [[package]]
3839
3969
  name = "weezl"
3840
3970
  version = "0.2.1"
@@ -4213,3 +4343,18 @@ name = "zmij"
4213
4343
  version = "1.0.23"
4214
4344
  source = "registry+https://github.com/rust-lang/crates.io-index"
4215
4345
  checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
4346
+
4347
+ [[package]]
4348
+ name = "zune-core"
4349
+ version = "0.5.3"
4350
+ source = "registry+https://github.com/rust-lang/crates.io-index"
4351
+ checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
4352
+
4353
+ [[package]]
4354
+ name = "zune-jpeg"
4355
+ version = "0.5.15"
4356
+ source = "registry+https://github.com/rust-lang/crates.io-index"
4357
+ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
4358
+ dependencies = [
4359
+ "zune-core",
4360
+ ]
@@ -9,7 +9,10 @@ crate-type = ["cdylib"]
9
9
 
10
10
  [dependencies]
11
11
  magnus = { version = "0.8", features = [] }
12
- c2pa = { version = "0.90", features = ["file_io", "pdf"] }
12
+ # Only for rb_thread_call_without_gvl, which magnus does not wrap. Kept at the
13
+ # range magnus itself depends on so Cargo resolves a single copy.
14
+ rb-sys = { version = "0.9.113", default-features = false }
15
+ c2pa = { version = "0.90", features = ["file_io", "pdf", "add_thumbnails"] }
13
16
  serde_json = "1"
14
17
 
15
18
  [profile.release]
@@ -1,8 +1,9 @@
1
1
  use std::fs::File;
2
+ use std::io::Cursor;
2
3
  use std::path::Path;
3
4
  use std::sync::{Arc, RwLock, OnceLock};
4
5
  use c2pa::{create_signer, Builder, BuilderIntent, Context, Reader, SigningAlg};
5
- use magnus::{function, prelude::*, Error, Ruby};
6
+ use magnus::{function, prelude::*, Error, RString, Ruby};
6
7
 
7
8
  // ─── Helpers ─────────────────────────────────────────────────────────────────
8
9
 
@@ -18,9 +19,21 @@ use magnus::{function, prelude::*, Error, Ruby};
18
19
  //
19
20
  // It currently carries defaults. Exposing settings to Ruby is a separate piece
20
21
  // of work; this is the seam that makes it possible.
22
+ // The one place this gem departs from c2pa-rs's defaults. c2pa-rs generates
23
+ // thumbnails when the feature is compiled in, scaling to a 1024px long edge —
24
+ // and it upscales, so a 160x120 source gets a 1024x768 "thumbnail" ten times
25
+ // its size. Off unless asked for; Config::to_json always states the choice.
26
+ const DEFAULT_SETTINGS: &str = r#"{"builder":{"thumbnail":{"enabled":false}}}"#;
27
+
21
28
  fn context_slot() -> &'static RwLock<Arc<Context>> {
22
29
  static CONTEXT: OnceLock<RwLock<Arc<Context>>> = OnceLock::new();
23
- CONTEXT.get_or_init(|| RwLock::new(Context::new().into_shared()))
30
+ CONTEXT.get_or_init(|| {
31
+ let context = Context::new()
32
+ .with_settings(DEFAULT_SETTINGS)
33
+ .expect("built-in default settings are valid")
34
+ .into_shared();
35
+ RwLock::new(context)
36
+ })
24
37
  }
25
38
 
26
39
  fn shared_context() -> Arc<Context> {
@@ -116,51 +129,89 @@ fn add_ingredient_files(
116
129
  Ok(())
117
130
  }
118
131
 
119
- fn do_sign_file(
120
- source_path: &str,
121
- dest_path: &str,
122
- cert_path: &str,
123
- key_path: &str,
124
- alg_str: &str,
125
- manifest_json: Option<&str>,
126
- intent_str: Option<&str>,
127
- ingredient_files_json: Option<&str>,
128
- ) -> Result<(), Box<dyn std::error::Error>> {
132
+ type BoxError = Box<dyn std::error::Error>;
133
+
134
+ // The description of what to sign, shared by the file and buffer paths.
135
+ struct SigningRequest<'a> {
136
+ cert_path: &'a str,
137
+ key_path: &'a str,
138
+ alg: &'a str,
139
+ manifest_json: Option<&'a str>,
140
+ intent: Option<&'a str>,
141
+ ingredient_files_json: Option<&'a str>,
142
+ }
143
+
144
+ fn build_signer(cert_path: &str, key_path: &str, alg_str: &str) -> Result<Box<dyn c2pa::Signer + Send + Sync>, BoxError> {
129
145
  let cert = std::fs::read(cert_path)
130
146
  .map_err(|e| format!("Cannot read certificate '{}': {}", cert_path, e))?;
131
147
  let key = std::fs::read(key_path)
132
148
  .map_err(|e| format!("Cannot read key '{}': {}", key_path, e))?;
133
149
 
134
150
  let alg = alg_from_str(alg_str)?;
135
- let signer = create_signer::from_keys(&cert, &key, alg, None)
136
- .map_err(|e| format!("Failed to create signer: {}", e))?;
151
+ create_signer::from_keys(&cert, &key, alg, None)
152
+ .map_err(|e| format!("Failed to create signer: {}", e).into())
153
+ }
137
154
 
138
- let title = Path::new(source_path)
139
- .file_name()
140
- .and_then(|n| n.to_str())
141
- .unwrap_or("unknown")
142
- .replace('"', "\\\"");
143
- let default_json = format!(r#"{{"title": "{}"}}"#, title);
144
- let json = manifest_json.unwrap_or(&default_json);
155
+ // A Builder carrying the manifest, intent and ingredients, ready to sign.
156
+ // `fallback_title` is used only when no manifest JSON was supplied.
157
+ fn build_builder(request: &SigningRequest, fallback_title: &str) -> Result<Builder, BoxError> {
158
+ let default_json = format!(r#"{{"title": "{}"}}"#, fallback_title.replace('"', "\\\""));
159
+ let json = request.manifest_json.unwrap_or(&default_json);
145
160
 
146
161
  let mut builder = Builder::from_shared_context(&shared_context())
147
162
  .with_definition(json)
148
163
  .map_err(|e| format!("Invalid manifest JSON: {}", e))?;
149
164
 
150
- if let Some(intent) = intent_str {
165
+ if let Some(intent) = request.intent {
151
166
  builder.set_intent(intent_from_str(intent)?);
152
167
  }
153
168
 
154
- if let Some(files) = ingredient_files_json {
169
+ if let Some(files) = request.ingredient_files_json {
155
170
  add_ingredient_files(&mut builder, files)?;
156
171
  }
157
172
 
158
- builder.sign_file(&*signer, source_path, dest_path)
173
+ Ok(builder)
174
+ }
175
+
176
+ fn do_sign_file(source_path: &str, dest_path: &str, request: &SigningRequest) -> Result<(), BoxError> {
177
+ let signer = build_signer(request.cert_path, request.key_path, request.alg)?;
178
+
179
+ let title = Path::new(source_path)
180
+ .file_name()
181
+ .and_then(|n| n.to_str())
182
+ .unwrap_or("unknown");
183
+ let mut builder = build_builder(request, title)?;
184
+
185
+ builder
186
+ .sign_file(&*signer, source_path, dest_path)
159
187
  .map_err(|e| format!("Signing failed: {}", e))?;
160
188
 
161
189
  Ok(())
162
190
  }
163
191
 
192
+ // Sign bytes held in memory. The source is read through a Cursor, and the
193
+ // destination has to be one too: c2pa-rs writes the asset and then seeks back
194
+ // to hash it and patch the manifest in, so a write-only sink will not do.
195
+ fn do_sign_buffer(data: &[u8], format: &str, request: &SigningRequest) -> Result<Vec<u8>, BoxError> {
196
+ let signer = build_signer(request.cert_path, request.key_path, request.alg)?;
197
+ let mut builder = build_builder(request, "buffer")?;
198
+
199
+ let mut source = Cursor::new(data);
200
+ let mut dest = Cursor::new(Vec::new());
201
+ builder
202
+ .sign(&*signer, format, &mut source, &mut dest)
203
+ .map_err(|e| format!("Signing failed: {}", e))?;
204
+
205
+ Ok(dest.into_inner())
206
+ }
207
+
208
+ fn do_read_buffer(data: &[u8], format: &str) -> Result<String, BoxError> {
209
+ let reader = Reader::from_shared_context(&shared_context())
210
+ .with_stream(format, Cursor::new(data))
211
+ .map_err(|e| format!("Failed to read manifest from buffer: {}", e))?;
212
+ Ok(reader.json())
213
+ }
214
+
164
215
  fn do_read_file(path: &str) -> Result<String, Box<dyn std::error::Error>> {
165
216
  let reader = Reader::from_shared_context(&shared_context())
166
217
  .with_file(path)
@@ -168,8 +219,68 @@ fn do_read_file(path: &str) -> Result<String, Box<dyn std::error::Error>> {
168
219
  Ok(reader.json())
169
220
  }
170
221
 
222
+ // ─── Running without the GVL ──────────────────────────────────────────────────
223
+ //
224
+ // Signing and reading are CPU-bound Rust with no need of the interpreter, so
225
+ // they run with Ruby's global VM lock released and other Ruby threads make
226
+ // progress meanwhile. magnus does not wrap rb_thread_call_without_gvl, hence
227
+ // the trampoline: the closure travels through the void pointer, its result
228
+ // travels back the same way, and nothing inside may touch Ruby.
229
+ //
230
+ // A panic must not unwind across the extern "C" frame (Rust aborts if it
231
+ // does), so it is caught on the far side and resumed once the lock is held.
232
+
233
+ type NoGvlSlot<F, R> = (Option<F>, Option<std::thread::Result<R>>);
234
+
235
+ unsafe extern "C" fn no_gvl_trampoline<F, R>(arg: *mut std::ffi::c_void) -> *mut std::ffi::c_void
236
+ where
237
+ F: FnOnce() -> R,
238
+ {
239
+ let slot = &mut *(arg as *mut NoGvlSlot<F, R>);
240
+ let f = slot.0.take().expect("closure taken twice");
241
+ slot.1 = Some(std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)));
242
+ std::ptr::null_mut()
243
+ }
244
+
245
+ fn without_gvl<F, R>(f: F) -> R
246
+ where
247
+ F: FnOnce() -> R,
248
+ {
249
+ let mut slot: NoGvlSlot<F, R> = (Some(f), None);
250
+
251
+ // RUBY_UBF_IO is a macro, not a symbol, so bindgen has no name for it. It
252
+ // is the sentinel (rb_unblock_function_t *)-1, which tells Ruby to use
253
+ // its own IO unblocker: a Thread#kill or Timeout aimed at this thread
254
+ // interrupts a blocking syscall (a remote manifest fetch, say) rather
255
+ // than waiting for the call to finish. Option<fn> has the null niche, so
256
+ // a non-null bit pattern is a valid Some that Ruby compares by value and
257
+ // never calls.
258
+ let ubf: rb_sys::rb_unblock_function_t = unsafe { std::mem::transmute(-1isize) };
259
+
260
+ unsafe {
261
+ rb_sys::rb_thread_call_without_gvl(
262
+ Some(no_gvl_trampoline::<F, R>),
263
+ &mut slot as *mut NoGvlSlot<F, R> as *mut std::ffi::c_void,
264
+ ubf,
265
+ std::ptr::null_mut(),
266
+ );
267
+ }
268
+
269
+ match slot.1.expect("closure did not run") {
270
+ Ok(value) => value,
271
+ Err(panic) => std::panic::resume_unwind(panic),
272
+ }
273
+ }
274
+
171
275
  // ─── Ruby-facing functions ────────────────────────────────────────────────────
172
276
 
277
+ fn runtime_error(e: BoxError) -> Error {
278
+ Error::new(
279
+ Ruby::get().expect("called from Ruby thread").exception_runtime_error(),
280
+ e.to_string(),
281
+ )
282
+ }
283
+
173
284
  fn sign_file(
174
285
  source: String,
175
286
  dest: String,
@@ -180,27 +291,63 @@ fn sign_file(
180
291
  intent: Option<String>,
181
292
  ingredient_files: Option<String>,
182
293
  ) -> Result<String, Error> {
183
- let alg_str = alg.as_deref().unwrap_or("es256");
184
-
185
- do_sign_file(&source, &dest, &cert, &key, alg_str, manifest_json.as_deref(),
186
- intent.as_deref(), ingredient_files.as_deref())
187
- .map_err(|e| Error::new(Ruby::get().expect("called from Ruby thread").exception_runtime_error(), e.to_string()))?;
188
-
294
+ let request = SigningRequest {
295
+ cert_path: &cert,
296
+ key_path: &key,
297
+ alg: alg.as_deref().unwrap_or("es256"),
298
+ manifest_json: manifest_json.as_deref(),
299
+ intent: intent.as_deref(),
300
+ ingredient_files_json: ingredient_files.as_deref(),
301
+ };
302
+
303
+ without_gvl(|| do_sign_file(&source, &dest, &request)).map_err(runtime_error)?;
189
304
  Ok(dest)
190
305
  }
191
306
 
307
+ // Takes an RString rather than a String so the bytes arrive untouched: a
308
+ // String argument would be transcoded to UTF-8, which is wrong for a JPEG.
309
+ // The slice is copied out at once, since Ruby may move or free the backing
310
+ // store the moment control returns to it.
311
+ fn sign_buffer(
312
+ ruby: &Ruby,
313
+ data: RString,
314
+ format: String,
315
+ cert: String,
316
+ key: String,
317
+ alg: Option<String>,
318
+ manifest_json: Option<String>,
319
+ intent: Option<String>,
320
+ ingredient_files: Option<String>,
321
+ ) -> Result<RString, Error> {
322
+ let bytes = unsafe { data.as_slice() }.to_vec();
323
+ let request = SigningRequest {
324
+ cert_path: &cert,
325
+ key_path: &key,
326
+ alg: alg.as_deref().unwrap_or("es256"),
327
+ manifest_json: manifest_json.as_deref(),
328
+ intent: intent.as_deref(),
329
+ ingredient_files_json: ingredient_files.as_deref(),
330
+ };
331
+
332
+ let signed = without_gvl(|| do_sign_buffer(&bytes, &format, &request)).map_err(runtime_error)?;
333
+ Ok(ruby.str_from_slice(&signed))
334
+ }
335
+
192
336
  fn read_file(path: String) -> Result<String, Error> {
193
- do_read_file(&path)
194
- .map_err(|e| Error::new(Ruby::get().expect("called from Ruby thread").exception_runtime_error(), e.to_string()))
337
+ without_gvl(|| do_read_file(&path)).map_err(runtime_error)
338
+ }
339
+
340
+ // c2pa-rs sniffs the container from the leading bytes and lets the hint win
341
+ // only when it agrees; the hint carries the decision alone when sniffing
342
+ // fails, as it does for SVG.
343
+ fn read_buffer(data: RString, format: Option<String>) -> Result<String, Error> {
344
+ let bytes = unsafe { data.as_slice() }.to_vec();
345
+ let format = format.as_deref().unwrap_or("application/octet-stream");
346
+ without_gvl(|| do_read_buffer(&bytes, format)).map_err(runtime_error)
195
347
  }
196
348
 
197
349
  fn configure(settings_json: String) -> Result<(), Error> {
198
- do_configure(&settings_json).map_err(|e| {
199
- Error::new(
200
- Ruby::get().expect("called from Ruby thread").exception_runtime_error(),
201
- e.to_string(),
202
- )
203
- })
350
+ do_configure(&settings_json).map_err(runtime_error)
204
351
  }
205
352
 
206
353
  fn sdk_version() -> String {
@@ -215,7 +362,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
215
362
  let native = c2pa.define_module("Native")?;
216
363
 
217
364
  native.define_singleton_method("sign_file", function!(sign_file, 8))?;
365
+ native.define_singleton_method("sign_buffer", function!(sign_buffer, 8))?;
218
366
  native.define_singleton_method("read_file", function!(read_file, 1))?;
367
+ native.define_singleton_method("read_buffer", function!(read_buffer, 2))?;
219
368
  native.define_singleton_method("configure", function!(configure, 1))?;
220
369
  native.define_singleton_method("sdk_version", function!(sdk_version, 0))?;
221
370
 
data/lib/c2pa/config.rb CHANGED
@@ -38,6 +38,27 @@ module C2PA
38
38
  # network requests.
39
39
  attr_accessor :ocsp_fetch
40
40
 
41
+ # Whether to embed a thumbnail of the asset in its manifest, and of each
42
+ # ingredient supplied as a file.
43
+ #
44
+ # Off by default, which departs from c2pa-rs. Its thumbnail generation
45
+ # scales to a fixed long edge and upscales to reach it, so a 160x120 image
46
+ # gets a 1024x768 thumbnail ten times its own size. Turn it on for assets
47
+ # that are larger than thumbnail_size, or set thumbnail_size to suit.
48
+ #
49
+ # Thumbnails are produced for JPEG, PNG, WebP and TIFF. Other formats are
50
+ # signed without one; c2pa-rs treats that as non-fatal.
51
+ attr_accessor :thumbnails
52
+
53
+ # Longest edge of the thumbnail in pixels. c2pa-rs's default is 1024.
54
+ attr_accessor :thumbnail_size
55
+
56
+ # :jpeg, :png or :gif. Left unset, c2pa-rs picks the smaller encoding.
57
+ attr_accessor :thumbnail_format
58
+
59
+ # :low, :medium or :high. c2pa-rs's default is :medium.
60
+ attr_accessor :thumbnail_quality
61
+
41
62
  def initialize
42
63
  @trust_anchors = nil
43
64
  @trust_list = nil
@@ -45,6 +66,10 @@ module C2PA
45
66
  @verify_trust = nil
46
67
  @remote_manifest_fetch = nil
47
68
  @ocsp_fetch = nil
69
+ @thumbnails = false
70
+ @thumbnail_size = nil
71
+ @thumbnail_format = nil
72
+ @thumbnail_quality = nil
48
73
  end
49
74
 
50
75
  # The settings document c2pa-rs expects.
@@ -64,7 +89,14 @@ module C2PA
64
89
  verify["remote_manifest_fetch"] = @remote_manifest_fetch unless @remote_manifest_fetch.nil?
65
90
  verify["ocsp_fetch"] = @ocsp_fetch unless @ocsp_fetch.nil?
66
91
 
67
- settings = {}
92
+ # enabled is always sent: this gem's default differs from c2pa-rs's, so
93
+ # leaving it out would mean inheriting the wrong one.
94
+ thumbnail = { "enabled" => @thumbnails == true }
95
+ thumbnail["long_edge"] = Integer(@thumbnail_size) unless @thumbnail_size.nil?
96
+ thumbnail["format"] = @thumbnail_format.to_s.downcase unless @thumbnail_format.nil?
97
+ thumbnail["quality"] = @thumbnail_quality.to_s.downcase unless @thumbnail_quality.nil?
98
+
99
+ settings = { "builder" => { "thumbnail" => thumbnail } }
68
100
  settings["trust"] = trust unless trust.empty?
69
101
  settings["verify"] = verify unless verify.empty?
70
102
 
data/lib/c2pa/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module C2PA
2
- VERSION = "0.4.0"
2
+ VERSION = "0.5.0"
3
3
  end
data/lib/c2pa.rb CHANGED
@@ -97,6 +97,75 @@ module C2PA
97
97
  output
98
98
  end
99
99
 
100
+ # Sign bytes held in memory, returning the signed bytes.
101
+ #
102
+ # The counterpart to C2PA.sign for data you already have loaded, such as an
103
+ # upload. The format must be given, since there is no filename to infer it
104
+ # from. Input must be binary; a UTF-8-tagged string is rejected rather than
105
+ # transcoded, because that would corrupt the asset.
106
+ #
107
+ # The input, the working copy and the result on both sides of the boundary
108
+ # are resident at once, so budget about four times the asset. Prefer
109
+ # C2PA.sign with paths for large video.
110
+ #
111
+ # @param data [String] the asset, as a binary string
112
+ # @param format [String] MIME type, e.g. "image/jpeg"
113
+ # @param certificate [String] path to a PEM-encoded X.509 certificate chain
114
+ # @param key [String] path to a PEM-encoded private key
115
+ # @param algorithm [String] signing algorithm (default: "es256")
116
+ # @param manifest [C2PA::Manifest] the manifest to embed
117
+ # @param verify [Boolean] read the result back and confirm it validates
118
+ # @return [String] the signed asset, as a binary string
119
+ # @raise [C2PA::SigningError] if signing fails, or if the result does not validate
120
+ #
121
+ # @example
122
+ # signed = C2PA.sign_buffer(
123
+ # data: File.binread("photo.jpg"),
124
+ # format: "image/jpeg",
125
+ # certificate: "cert.pem",
126
+ # key: "key.pem",
127
+ # manifest: manifest
128
+ # )
129
+ def self.sign_buffer(data:, format:, certificate:, key:, algorithm: "es256", manifest:, verify: true)
130
+ manifest_json = manifest.to_json
131
+ data = binary!(data, "data")
132
+
133
+ raise SigningError, "Certificate file not found: '#{certificate}'" unless File.exist?(certificate)
134
+ raise SigningError, "Key file not found: '#{key}'" unless File.exist?(key)
135
+
136
+ signed =
137
+ begin
138
+ intent = manifest.respond_to?(:intent) ? manifest.intent&.to_s : nil
139
+ files = manifest.respond_to?(:ingredient_files) ? manifest.ingredient_files : []
140
+ ingredient_files = files.empty? ? nil : JSON.generate(files)
141
+ Native.sign_buffer(data, format, certificate, key, algorithm, manifest_json,
142
+ intent, ingredient_files)
143
+ rescue RuntimeError => e
144
+ raise SigningError, e.message
145
+ end
146
+
147
+ verify_signed_buffer!(signed, format) if verify
148
+
149
+ signed
150
+ end
151
+
152
+ # Read the C2PA manifest embedded in bytes held in memory.
153
+ #
154
+ # c2pa-rs identifies most formats from the leading bytes and ignores the
155
+ # hint when the two disagree. The hint matters for formats with no signature
156
+ # to sniff, such as SVG, which cannot be read without it.
157
+ #
158
+ # @param data [String] the asset, as a binary string
159
+ # @param format [String, nil] MIME type or extension, e.g. "image/svg+xml"
160
+ # @return [Hash] parsed manifest JSON
161
+ # @raise [C2PA::ReadError] if the data has no valid manifest
162
+ def self.read_buffer(data:, format: nil)
163
+ data = binary!(data, "data")
164
+ JSON.parse(Native.read_buffer(data, format))
165
+ rescue RuntimeError => e
166
+ raise ReadError, e.message
167
+ end
168
+
100
169
  # Read the C2PA manifest embedded in a signed file.
101
170
  #
102
171
  # @param file [String] path to the signed file
@@ -155,6 +224,46 @@ module C2PA
155
224
  end
156
225
  private_class_method :verify_signed_output!
157
226
 
227
+ # The buffer counterpart to verify_signed_output!. Nothing to delete: a
228
+ # rejected result is simply not returned.
229
+ def self.verify_signed_buffer!(signed, format)
230
+ result =
231
+ begin
232
+ read_buffer(data: signed, format: format)
233
+ rescue ReadError => e
234
+ raise SigningError, "signed data failed verification: could not read it back: #{e.message}"
235
+ end
236
+
237
+ state = result["validation_state"]
238
+ return if VALID_STATES.include?(state)
239
+
240
+ failures = Array(result.dig("validation_results", "activeManifest", "failure"))
241
+ .map { |failure| "#{failure["code"]} (#{failure["explanation"]})" }
242
+ detail = failures.empty? ? "no failure detail reported" : failures.uniq.join(", ")
243
+
244
+ raise SigningError,
245
+ "signed data failed verification: validation_state=#{state.inspect}, #{detail}. " \
246
+ "Pass verify: false to receive it anyway."
247
+ end
248
+ private_class_method :verify_signed_buffer!
249
+
250
+ # Asset bytes must be binary. The native layer takes the raw bytes whatever
251
+ # the tag says, so a UTF-8-tagged string could be passed through as-is. It
252
+ # is refused instead because the tag means the bytes came through a text
253
+ # path (File.read rather than File.binread), and on Windows that path has
254
+ # already rewritten line endings. Nothing notices until a verifier rejects
255
+ # the result. Refusing early turns a silent corruption into an error with a
256
+ # fix in the message.
257
+ def self.binary!(data, name)
258
+ raise ArgumentError, "#{name} must be a String, got #{data.class}" unless data.is_a?(String)
259
+ return data if data.encoding == Encoding::BINARY
260
+
261
+ raise ArgumentError,
262
+ "#{name} must be a binary string (Encoding::BINARY), got #{data.encoding}. " \
263
+ "Use File.binread, or call .b on the string."
264
+ end
265
+ private_class_method :binary!
266
+
158
267
  # Remove a file this library created and is about to reject.
159
268
  def self.discard(path)
160
269
  File.delete(path) if File.exist?(path)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-c2pa
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carlos Rodriguez