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.
@@ -9,8 +9,16 @@ 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.91.1", features = ["file_io", "pdf", "add_thumbnails"] }
13
16
  serde_json = "1"
17
+ # Not used directly. c2pa-rs depends on zip with default features off, which
18
+ # leaves it able to read only stored entries, and real EPUB, Office and
19
+ # OpenDocument files deflate theirs. Cargo unifies features, so enabling
20
+ # deflate here gives it to c2pa-rs. flate2 is already in the tree.
21
+ zip = { version = "8.6", default-features = false, features = ["deflate-flate2"] }
14
22
 
15
23
  [profile.release]
16
24
  opt-level = 3
@@ -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
- use c2pa::{create_signer, Builder, BuilderIntent, Context, Reader, SigningAlg};
5
- use magnus::{function, prelude::*, Error, Ruby};
5
+ use c2pa::{create_signer, format_from_path, Builder, BuilderIntent, Context, Reader, SigningAlg};
6
+ use magnus::{function, prelude::*, Error, RString, Ruby};
6
7
 
7
8
  // ─── Helpers ─────────────────────────────────────────────────────────────────
8
9
 
@@ -18,9 +19,30 @@ 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 two places this gem departs from c2pa-rs's defaults; Config::to_json
23
+ // always states both.
24
+ //
25
+ // Thumbnails: c2pa-rs generates them when the feature is compiled in, scaling
26
+ // to a 1024px long edge — and it upscales, so a 160x120 source gets a
27
+ // 1024x768 "thumbnail" ten times its size. Off unless asked for.
28
+ //
29
+ // verify_after_sign: since 0.91 c2pa-rs validates the manifest before writing
30
+ // and refuses an invalid one. The gem's own guard reads the finished output
31
+ // back, which also checks the hashes on the bytes written (upstream's check
32
+ // skips them by default), and honours verify: false for inspecting an invalid
33
+ // file. Running both would read every output twice, so upstream's is off.
34
+ const DEFAULT_SETTINGS: &str =
35
+ r#"{"builder":{"thumbnail":{"enabled":false}},"verify":{"verify_after_sign":false}}"#;
36
+
21
37
  fn context_slot() -> &'static RwLock<Arc<Context>> {
22
38
  static CONTEXT: OnceLock<RwLock<Arc<Context>>> = OnceLock::new();
23
- CONTEXT.get_or_init(|| RwLock::new(Context::new().into_shared()))
39
+ CONTEXT.get_or_init(|| {
40
+ let context = Context::new()
41
+ .with_settings(DEFAULT_SETTINGS)
42
+ .expect("built-in default settings are valid")
43
+ .into_shared();
44
+ RwLock::new(context)
45
+ })
24
46
  }
25
47
 
26
48
  fn shared_context() -> Arc<Context> {
@@ -116,51 +138,122 @@ fn add_ingredient_files(
116
138
  Ok(())
117
139
  }
118
140
 
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>> {
141
+ type BoxError = Box<dyn std::error::Error>;
142
+
143
+ // The description of what to sign, shared by the file and buffer paths.
144
+ struct SigningRequest<'a> {
145
+ cert_path: &'a str,
146
+ key_path: &'a str,
147
+ alg: &'a str,
148
+ manifest_json: Option<&'a str>,
149
+ intent: Option<&'a str>,
150
+ ingredient_files_json: Option<&'a str>,
151
+ }
152
+
153
+ fn build_signer(cert_path: &str, key_path: &str, alg_str: &str) -> Result<Box<dyn c2pa::Signer + Send + Sync>, BoxError> {
129
154
  let cert = std::fs::read(cert_path)
130
155
  .map_err(|e| format!("Cannot read certificate '{}': {}", cert_path, e))?;
131
156
  let key = std::fs::read(key_path)
132
157
  .map_err(|e| format!("Cannot read key '{}': {}", key_path, e))?;
133
158
 
134
159
  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))?;
160
+ create_signer::from_keys(&cert, &key, alg, None)
161
+ .map_err(|e| format!("Failed to create signer: {}", e).into())
162
+ }
137
163
 
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);
164
+ // A Builder carrying the manifest, intent and ingredients, ready to sign.
165
+ // `fallback_title` is used only when no manifest JSON was supplied.
166
+ fn build_builder(request: &SigningRequest, fallback_title: &str) -> Result<Builder, BoxError> {
167
+ let default_json = format!(r#"{{"title": "{}"}}"#, fallback_title.replace('"', "\\\""));
168
+ let json = request.manifest_json.unwrap_or(&default_json);
145
169
 
146
170
  let mut builder = Builder::from_shared_context(&shared_context())
147
171
  .with_definition(json)
148
172
  .map_err(|e| format!("Invalid manifest JSON: {}", e))?;
149
173
 
150
- if let Some(intent) = intent_str {
174
+ if let Some(intent) = request.intent {
151
175
  builder.set_intent(intent_from_str(intent)?);
152
176
  }
153
177
 
154
- if let Some(files) = ingredient_files_json {
178
+ if let Some(files) = request.ingredient_files_json {
155
179
  add_ingredient_files(&mut builder, files)?;
156
180
  }
157
181
 
158
- builder.sign_file(&*signer, source_path, dest_path)
182
+ Ok(builder)
183
+ }
184
+
185
+ fn do_sign_file(source_path: &str, dest_path: &str, request: &SigningRequest) -> Result<(), BoxError> {
186
+ let signer = build_signer(request.cert_path, request.key_path, request.alg)?;
187
+
188
+ let title = Path::new(source_path)
189
+ .file_name()
190
+ .and_then(|n| n.to_str())
191
+ .unwrap_or("unknown");
192
+ let mut builder = build_builder(request, title)?;
193
+
194
+ builder
195
+ .sign_file(&*signer, source_path, dest_path)
196
+ .map_err(|e| format!("Signing failed: {}", e))?;
197
+
198
+ Ok(())
199
+ }
200
+
201
+ // Sign without embedding: the asset is hashed as it is, the output carries no
202
+ // manifest, and the manifest store goes to its own file. Hashing the asset
203
+ // unmodified is what makes the pair valid; stripping an embedded manifest out
204
+ // afterwards does not work, because embedding moves offsets elsewhere in the
205
+ // file (chunk offsets in an MP4's moov, for one).
206
+ fn do_sign_file_detached(source_path: &str, dest_path: &str, sidecar_path: &str, request: &SigningRequest)
207
+ -> Result<(), BoxError> {
208
+ let signer = build_signer(request.cert_path, request.key_path, request.alg)?;
209
+
210
+ let title = Path::new(source_path)
211
+ .file_name()
212
+ .and_then(|n| n.to_str())
213
+ .unwrap_or("unknown");
214
+ let mut builder = build_builder(request, title)?;
215
+ builder.set_no_embed(true);
216
+
217
+ let format = format_from_path(source_path)
218
+ .ok_or_else(|| format!("Signing failed: cannot tell the format of '{}' from its extension", source_path))?;
219
+ let mut source = File::open(source_path)
220
+ .map_err(|e| format!("Signing failed: cannot open '{}': {}", source_path, e))?;
221
+ let mut dest = std::fs::OpenOptions::new().read(true).write(true).create_new(true).open(dest_path)
222
+ .map_err(|e| format!("Signing failed: cannot create '{}': {}", dest_path, e))?;
223
+
224
+ let manifest = builder
225
+ .sign(&*signer, &format, &mut source, &mut dest)
159
226
  .map_err(|e| format!("Signing failed: {}", e))?;
160
227
 
228
+ std::fs::write(sidecar_path, manifest)
229
+ .map_err(|e| format!("Signing failed: cannot write '{}': {}", sidecar_path, e))?;
230
+
161
231
  Ok(())
162
232
  }
163
233
 
234
+ // Sign bytes held in memory. The source is read through a Cursor, and the
235
+ // destination has to be one too: c2pa-rs writes the asset and then seeks back
236
+ // to hash it and patch the manifest in, so a write-only sink will not do.
237
+ fn do_sign_buffer(data: &[u8], format: &str, request: &SigningRequest) -> Result<Vec<u8>, BoxError> {
238
+ let signer = build_signer(request.cert_path, request.key_path, request.alg)?;
239
+ let mut builder = build_builder(request, "buffer")?;
240
+
241
+ let mut source = Cursor::new(data);
242
+ let mut dest = Cursor::new(Vec::new());
243
+ builder
244
+ .sign(&*signer, format, &mut source, &mut dest)
245
+ .map_err(|e| format!("Signing failed: {}", e))?;
246
+
247
+ Ok(dest.into_inner())
248
+ }
249
+
250
+ fn do_read_buffer(data: &[u8], format: &str) -> Result<String, BoxError> {
251
+ let reader = Reader::from_shared_context(&shared_context())
252
+ .with_stream(format, Cursor::new(data))
253
+ .map_err(|e| format!("Failed to read manifest from buffer: {}", e))?;
254
+ Ok(reader.json())
255
+ }
256
+
164
257
  fn do_read_file(path: &str) -> Result<String, Box<dyn std::error::Error>> {
165
258
  let reader = Reader::from_shared_context(&shared_context())
166
259
  .with_file(path)
@@ -168,8 +261,89 @@ fn do_read_file(path: &str) -> Result<String, Box<dyn std::error::Error>> {
168
261
  Ok(reader.json())
169
262
  }
170
263
 
264
+ // A manifest store kept apart from its asset, as a .c2pa sidecar or fetched
265
+ // from a remote URL. The store is validated against the asset's bytes; read
266
+ // on its own, a store's hard binding hashes the store itself and never
267
+ // matches.
268
+ fn do_read_detached(manifest: &[u8], format: &str, asset: impl std::io::Read + std::io::Seek + Send)
269
+ -> Result<String, BoxError> {
270
+ let reader = Reader::from_shared_context(&shared_context())
271
+ .with_manifest_data_and_stream(manifest, format, asset)
272
+ .map_err(|e| format!("Failed to read detached manifest: {}", e))?;
273
+ Ok(reader.json())
274
+ }
275
+
276
+ fn do_read_file_with_manifest(path: &str, manifest_path: &str) -> Result<String, BoxError> {
277
+ let manifest = std::fs::read(manifest_path)
278
+ .map_err(|e| format!("Failed to read manifest file '{}': {}", manifest_path, e))?;
279
+ let format = format_from_path(path)
280
+ .ok_or_else(|| format!("Failed to read '{}': cannot tell its format from the extension", path))?;
281
+ let asset = File::open(path).map_err(|e| format!("Failed to open '{}': {}", path, e))?;
282
+ do_read_detached(&manifest, &format, asset)
283
+ }
284
+
285
+ // ─── Running without the GVL ──────────────────────────────────────────────────
286
+ //
287
+ // Signing and reading are CPU-bound Rust with no need of the interpreter, so
288
+ // they run with Ruby's global VM lock released and other Ruby threads make
289
+ // progress meanwhile. magnus does not wrap rb_thread_call_without_gvl, hence
290
+ // the trampoline: the closure travels through the void pointer, its result
291
+ // travels back the same way, and nothing inside may touch Ruby.
292
+ //
293
+ // A panic must not unwind across the extern "C" frame (Rust aborts if it
294
+ // does), so it is caught on the far side and resumed once the lock is held.
295
+
296
+ type NoGvlSlot<F, R> = (Option<F>, Option<std::thread::Result<R>>);
297
+
298
+ unsafe extern "C" fn no_gvl_trampoline<F, R>(arg: *mut std::ffi::c_void) -> *mut std::ffi::c_void
299
+ where
300
+ F: FnOnce() -> R,
301
+ {
302
+ let slot = &mut *(arg as *mut NoGvlSlot<F, R>);
303
+ let f = slot.0.take().expect("closure taken twice");
304
+ slot.1 = Some(std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)));
305
+ std::ptr::null_mut()
306
+ }
307
+
308
+ fn without_gvl<F, R>(f: F) -> R
309
+ where
310
+ F: FnOnce() -> R,
311
+ {
312
+ let mut slot: NoGvlSlot<F, R> = (Some(f), None);
313
+
314
+ // RUBY_UBF_IO is a macro, not a symbol, so bindgen has no name for it. It
315
+ // is the sentinel (rb_unblock_function_t *)-1, which tells Ruby to use
316
+ // its own IO unblocker: a Thread#kill or Timeout aimed at this thread
317
+ // interrupts a blocking syscall (a remote manifest fetch, say) rather
318
+ // than waiting for the call to finish. Option<fn> has the null niche, so
319
+ // a non-null bit pattern is a valid Some that Ruby compares by value and
320
+ // never calls.
321
+ let ubf: rb_sys::rb_unblock_function_t = unsafe { std::mem::transmute(-1isize) };
322
+
323
+ unsafe {
324
+ rb_sys::rb_thread_call_without_gvl(
325
+ Some(no_gvl_trampoline::<F, R>),
326
+ &mut slot as *mut NoGvlSlot<F, R> as *mut std::ffi::c_void,
327
+ ubf,
328
+ std::ptr::null_mut(),
329
+ );
330
+ }
331
+
332
+ match slot.1.expect("closure did not run") {
333
+ Ok(value) => value,
334
+ Err(panic) => std::panic::resume_unwind(panic),
335
+ }
336
+ }
337
+
171
338
  // ─── Ruby-facing functions ────────────────────────────────────────────────────
172
339
 
340
+ fn runtime_error(e: BoxError) -> Error {
341
+ Error::new(
342
+ Ruby::get().expect("called from Ruby thread").exception_runtime_error(),
343
+ e.to_string(),
344
+ )
345
+ }
346
+
173
347
  fn sign_file(
174
348
  source: String,
175
349
  dest: String,
@@ -179,28 +353,81 @@ fn sign_file(
179
353
  manifest_json: Option<String>,
180
354
  intent: Option<String>,
181
355
  ingredient_files: Option<String>,
356
+ sidecar: Option<String>,
182
357
  ) -> 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
-
358
+ let request = SigningRequest {
359
+ cert_path: &cert,
360
+ key_path: &key,
361
+ alg: alg.as_deref().unwrap_or("es256"),
362
+ manifest_json: manifest_json.as_deref(),
363
+ intent: intent.as_deref(),
364
+ ingredient_files_json: ingredient_files.as_deref(),
365
+ };
366
+
367
+ without_gvl(|| match &sidecar {
368
+ Some(sidecar) => do_sign_file_detached(&source, &dest, sidecar, &request),
369
+ None => do_sign_file(&source, &dest, &request),
370
+ })
371
+ .map_err(runtime_error)?;
189
372
  Ok(dest)
190
373
  }
191
374
 
375
+ // Takes an RString rather than a String so the bytes arrive untouched: a
376
+ // String argument would be transcoded to UTF-8, which is wrong for a JPEG.
377
+ // The slice is copied out at once, since Ruby may move or free the backing
378
+ // store the moment control returns to it.
379
+ fn sign_buffer(
380
+ ruby: &Ruby,
381
+ data: RString,
382
+ format: String,
383
+ cert: String,
384
+ key: String,
385
+ alg: Option<String>,
386
+ manifest_json: Option<String>,
387
+ intent: Option<String>,
388
+ ingredient_files: Option<String>,
389
+ ) -> Result<RString, Error> {
390
+ let bytes = unsafe { data.as_slice() }.to_vec();
391
+ let request = SigningRequest {
392
+ cert_path: &cert,
393
+ key_path: &key,
394
+ alg: alg.as_deref().unwrap_or("es256"),
395
+ manifest_json: manifest_json.as_deref(),
396
+ intent: intent.as_deref(),
397
+ ingredient_files_json: ingredient_files.as_deref(),
398
+ };
399
+
400
+ let signed = without_gvl(|| do_sign_buffer(&bytes, &format, &request)).map_err(runtime_error)?;
401
+ Ok(ruby.str_from_slice(&signed))
402
+ }
403
+
192
404
  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()))
405
+ without_gvl(|| do_read_file(&path)).map_err(runtime_error)
406
+ }
407
+
408
+ // c2pa-rs sniffs the container from the leading bytes and lets the hint win
409
+ // only when it agrees; the hint carries the decision alone when sniffing
410
+ // fails, as it does for SVG.
411
+ fn read_buffer(data: RString, format: Option<String>) -> Result<String, Error> {
412
+ let bytes = unsafe { data.as_slice() }.to_vec();
413
+ let format = format.as_deref().unwrap_or("application/octet-stream");
414
+ without_gvl(|| do_read_buffer(&bytes, format)).map_err(runtime_error)
415
+ }
416
+
417
+ fn read_file_with_manifest(path: String, manifest_path: String) -> Result<String, Error> {
418
+ without_gvl(|| do_read_file_with_manifest(&path, &manifest_path)).map_err(runtime_error)
419
+ }
420
+
421
+ // Unlike read_buffer, nothing is sniffed from the asset here, so the format
422
+ // is required.
423
+ fn read_buffer_with_manifest(data: RString, format: String, manifest: RString) -> Result<String, Error> {
424
+ let bytes = unsafe { data.as_slice() }.to_vec();
425
+ let manifest = unsafe { manifest.as_slice() }.to_vec();
426
+ without_gvl(|| do_read_detached(&manifest, &format, Cursor::new(bytes))).map_err(runtime_error)
195
427
  }
196
428
 
197
429
  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
- })
430
+ do_configure(&settings_json).map_err(runtime_error)
204
431
  }
205
432
 
206
433
  fn sdk_version() -> String {
@@ -214,8 +441,12 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
214
441
  let c2pa = ruby.define_module("C2PA")?;
215
442
  let native = c2pa.define_module("Native")?;
216
443
 
217
- native.define_singleton_method("sign_file", function!(sign_file, 8))?;
444
+ native.define_singleton_method("sign_file", function!(sign_file, 9))?;
445
+ native.define_singleton_method("sign_buffer", function!(sign_buffer, 8))?;
218
446
  native.define_singleton_method("read_file", function!(read_file, 1))?;
447
+ native.define_singleton_method("read_buffer", function!(read_buffer, 2))?;
448
+ native.define_singleton_method("read_file_with_manifest", function!(read_file_with_manifest, 2))?;
449
+ native.define_singleton_method("read_buffer_with_manifest", function!(read_buffer_with_manifest, 3))?;
219
450
  native.define_singleton_method("configure", function!(configure, 1))?;
220
451
  native.define_singleton_method("sdk_version", function!(sdk_version, 0))?;
221
452
 
data/lib/c2pa/config.rb CHANGED
@@ -38,6 +38,47 @@ module C2PA
38
38
  # network requests.
39
39
  attr_accessor :ocsp_fetch
40
40
 
41
+ # Whether network requests made while reading and validating (remote
42
+ # manifests, OCSP, timestamps) may follow HTTP redirects.
43
+ #
44
+ # c2pa-rs defaults this to true but refuses a redirect to an internal
45
+ # address, such as loopback, a private network or a cloud metadata
46
+ # endpoint, because the URL can come from untrusted content. false stops
47
+ # redirects altogether. Neither setting affects a URL that names an
48
+ # internal host directly.
49
+ attr_accessor :allow_redirects
50
+
51
+ # Whether signing an MP4, MOV or other BMFF file leaves its /free and /skip
52
+ # boxes out of the hash. c2pa-rs defaults this to true: those boxes are
53
+ # padding that tools commonly rewrite after signing, and the specification
54
+ # allows excluding them. Set false and any later change to them breaks the
55
+ # signature, like a change to the media itself.
56
+ #
57
+ # It affects signing only. The choice is recorded in the signed file, and
58
+ # readers follow the file whatever their own setting.
59
+ attr_accessor :exclude_free_and_skip_boxes
60
+
61
+ # Whether to embed a thumbnail of the asset in its manifest, and of each
62
+ # ingredient supplied as a file.
63
+ #
64
+ # Off by default, which departs from c2pa-rs. Its thumbnail generation
65
+ # scales to a fixed long edge and upscales to reach it, so a 160x120 image
66
+ # gets a 1024x768 thumbnail ten times its own size. Turn it on for assets
67
+ # that are larger than thumbnail_size, or set thumbnail_size to suit.
68
+ #
69
+ # Thumbnails are produced for JPEG, PNG, WebP and TIFF. Other formats are
70
+ # signed without one; c2pa-rs treats that as non-fatal.
71
+ attr_accessor :thumbnails
72
+
73
+ # Longest edge of the thumbnail in pixels. c2pa-rs's default is 1024.
74
+ attr_accessor :thumbnail_size
75
+
76
+ # :jpeg, :png or :gif. Left unset, c2pa-rs picks the smaller encoding.
77
+ attr_accessor :thumbnail_format
78
+
79
+ # :low, :medium or :high. c2pa-rs's default is :medium.
80
+ attr_accessor :thumbnail_quality
81
+
41
82
  def initialize
42
83
  @trust_anchors = nil
43
84
  @trust_list = nil
@@ -45,6 +86,12 @@ module C2PA
45
86
  @verify_trust = nil
46
87
  @remote_manifest_fetch = nil
47
88
  @ocsp_fetch = nil
89
+ @allow_redirects = nil
90
+ @exclude_free_and_skip_boxes = nil
91
+ @thumbnails = false
92
+ @thumbnail_size = nil
93
+ @thumbnail_format = nil
94
+ @thumbnail_quality = nil
48
95
  end
49
96
 
50
97
  # The settings document c2pa-rs expects.
@@ -59,14 +106,29 @@ module C2PA
59
106
  trust["trust_anchors"] = read_pem(@trust_list) unless @trust_list.nil?
60
107
  trust["allowed_list"] = read_pem(@allowed_certificates) unless @allowed_certificates.nil?
61
108
 
62
- verify = {}
109
+ # verify_after_sign is always sent: C2PA.sign runs its own check on the
110
+ # finished output, so c2pa-rs's pre-write check stays off.
111
+ verify = { "verify_after_sign" => false }
63
112
  verify["verify_trust"] = @verify_trust unless @verify_trust.nil?
64
113
  verify["remote_manifest_fetch"] = @remote_manifest_fetch unless @remote_manifest_fetch.nil?
65
114
  verify["ocsp_fetch"] = @ocsp_fetch unless @ocsp_fetch.nil?
66
115
 
67
- settings = {}
116
+ # enabled is always sent: this gem's default differs from c2pa-rs's, so
117
+ # leaving it out would mean inheriting the wrong one.
118
+ thumbnail = { "enabled" => @thumbnails == true }
119
+ thumbnail["long_edge"] = Integer(@thumbnail_size) unless @thumbnail_size.nil?
120
+ thumbnail["format"] = @thumbnail_format.to_s.downcase unless @thumbnail_format.nil?
121
+ thumbnail["quality"] = @thumbnail_quality.to_s.downcase unless @thumbnail_quality.nil?
122
+
123
+ builder = { "thumbnail" => thumbnail }
124
+ unless @exclude_free_and_skip_boxes.nil?
125
+ builder["bmff_hash_exclude_free_and_skip_boxes"] = @exclude_free_and_skip_boxes
126
+ end
127
+
128
+ settings = { "builder" => builder }
129
+ settings["core"] = { "allow_redirects" => @allow_redirects } unless @allow_redirects.nil?
68
130
  settings["trust"] = trust unless trust.empty?
69
- settings["verify"] = verify unless verify.empty?
131
+ settings["verify"] = verify
70
132
 
71
133
  JSON.generate(settings)
72
134
  end
data/lib/c2pa/manifest.rb CHANGED
@@ -75,6 +75,17 @@ module C2PA
75
75
  "intent: :edit to C2PA::Manifest.new instead and the action will be added for you."
76
76
  end
77
77
 
78
+ # Same limit as c2pa.opened: each entry is a hashed URI that only c2pa-rs
79
+ # can compute, and in 0.91 its builder does not compute them, only
80
+ # checks them when reading. A hand-written hash would pass that check
81
+ # today while claiming a binding nothing verified.
82
+ if parameters.is_a?(Hash) && (parameters.key?("relatedAssertions") || parameters.key?(:relatedAssertions))
83
+ raise InvalidManifestError,
84
+ "relatedAssertions cannot be set. Each entry must be a hashed URI computed over " \
85
+ "the assertion as c2pa-rs serialises it, and c2pa-rs #{C2PA.sdk_version} does not " \
86
+ "compute them when building a manifest, so Ruby cannot produce a genuine one."
87
+ end
88
+
78
89
  # Required as of c2pa-rs 0.90. Earlier versions accepted its absence, so
79
90
  # manifests signed by releases before 0.3.0 are rejected by current
80
91
  # verifiers. No default is supplied: c2pa-rs accepts any string here, so
@@ -126,10 +137,9 @@ module C2PA
126
137
  # at it, so provenance chains from the original through to this asset. A
127
138
  # verifier can then follow and check the whole history.
128
139
  #
129
- # For a file with no credentials there is nothing to carry forward, and
130
- # the result is the same as the description alone. (Thumbnails would be
131
- # the other contribution, but they need c2pa-rs's `add_thumbnails`
132
- # feature, which this gem does not enable.)
140
+ # For a file with no credentials there is no manifest to carry forward.
141
+ # The file still contributes a thumbnail when C2PA::Config#thumbnails is
142
+ # on.
133
143
  #
134
144
  # Without `file:`, only the description is recorded. Nothing binds it to
135
145
  # any actual bytes. This form is kept for compatibility.
@@ -139,15 +149,23 @@ module C2PA
139
149
  # @param instance_id [String] unique identifier for the ingredient instance
140
150
  # @param relationship [String] relationship to this asset; defaults to "parentOf"
141
151
  # @param file [String, nil] path to the ingredient file
152
+ # @param digital_source_type [String, nil] URI from the C2PA digitalSourceType
153
+ # vocabulary, for an ingredient with no content credentials of its own,
154
+ # such as a generated image composited into this asset
142
155
  # @return [self]
143
- # @raise [C2PA::InvalidManifestError] if `file` is given but cannot be read
144
- def add_ingredient(title:, format:, instance_id:, relationship: "parentOf", file: nil)
156
+ # @raise [C2PA::InvalidManifestError] if `file` is given but cannot be read,
157
+ # or if `digital_source_type` is given for a file that carries content
158
+ # credentials
159
+ def add_ingredient(title:, format:, instance_id:, relationship: "parentOf", file: nil,
160
+ digital_source_type: nil)
145
161
  description = {
146
162
  "title" => title,
147
163
  "format" => format,
148
164
  "instance_id" => instance_id,
149
165
  "relationship" => relationship
150
166
  }
167
+ digital_source_type = to_s_or_nil(digital_source_type)
168
+ description["digital_source_type"] = digital_source_type if digital_source_type
151
169
 
152
170
  if file.nil?
153
171
  @ingredients << description
@@ -158,6 +176,16 @@ module C2PA
158
176
  raise InvalidManifestError, "ingredient file not readable: #{file.inspect}"
159
177
  end
160
178
 
179
+ # The specification forbids a source type alongside an embedded manifest:
180
+ # an ingredient with credentials already records its own origin. c2pa-rs
181
+ # refuses the combination only at signing, as "unable to encode assertion
182
+ # data", so it is caught here where the cause is clear.
183
+ if digital_source_type && content_credentials?(file)
184
+ raise InvalidManifestError,
185
+ "digital_source_type cannot be set for #{file.inspect}: it carries content " \
186
+ "credentials, which already record where it came from"
187
+ end
188
+
161
189
  @ingredient_files << {
162
190
  "json" => JSON.generate(description),
163
191
  "format" => format,
@@ -198,6 +226,14 @@ module C2PA
198
226
 
199
227
  private
200
228
 
229
+ # A file with no manifest, or one c2pa-rs cannot read, counts as having no
230
+ # credentials; if it does carry some, signing still fails upstream.
231
+ def content_credentials?(file)
232
+ !C2PA.read(file: file)["active_manifest"].nil?
233
+ rescue ReadError
234
+ false
235
+ end
236
+
201
237
  # Parameters may be keyed with strings or symbols depending on the caller.
202
238
  def param_present?(parameters, key)
203
239
  return false unless parameters.is_a?(Hash)
data/lib/c2pa/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module C2PA
2
- VERSION = "0.4.0"
2
+ VERSION = "0.6.0"
3
3
  end