ruby-c2pa 0.3.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.
@@ -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"] }
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,9 +1,65 @@
1
+ use std::fs::File;
2
+ use std::io::Cursor;
1
3
  use std::path::Path;
2
- use c2pa::{create_signer, Builder, BuilderIntent, Reader, SigningAlg};
3
- use magnus::{function, prelude::*, Error, Ruby};
4
+ use std::sync::{Arc, RwLock, OnceLock};
5
+ use c2pa::{create_signer, Builder, BuilderIntent, Context, Reader, SigningAlg};
6
+ use magnus::{function, prelude::*, Error, RString, Ruby};
4
7
 
5
8
  // ─── Helpers ─────────────────────────────────────────────────────────────────
6
9
 
10
+ // A Context carries the settings c2pa-rs validates against — trust anchors,
11
+ // what to verify, whether to fetch remote manifests. The older entry points
12
+ // read those from thread-local state, which is why they are deprecated: a
13
+ // threaded Ruby application could see different settings depending on which
14
+ // thread it signed from.
15
+ //
16
+ // One shared Context is built lazily and reused. It is Send + Sync, so an Arc
17
+ // is all that sharing requires, and reusing it avoids re-reading configuration
18
+ // on every call.
19
+ //
20
+ // It currently carries defaults. Exposing settings to Ruby is a separate piece
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
+
28
+ fn context_slot() -> &'static RwLock<Arc<Context>> {
29
+ static CONTEXT: OnceLock<RwLock<Arc<Context>>> = OnceLock::new();
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
+ })
37
+ }
38
+
39
+ fn shared_context() -> Arc<Context> {
40
+ context_slot()
41
+ .read()
42
+ .expect("context lock poisoned")
43
+ .clone()
44
+ }
45
+
46
+ // Replace the shared Context with one built from the supplied settings.
47
+ //
48
+ // c2pa-rs takes settings as JSON, so the Ruby side assembles the document and
49
+ // this only has to validate and install it. Rebuilding rather than mutating
50
+ // keeps the Context immutable once shared: signing already in flight on another
51
+ // thread continues against the settings it started with.
52
+ fn do_configure(settings_json: &str) -> Result<(), Box<dyn std::error::Error>> {
53
+ let context = Context::new()
54
+ .with_settings(settings_json)
55
+ .map_err(|e| format!("Invalid settings: {}", e))?
56
+ .into_shared();
57
+
58
+ *context_slot().write().map_err(|_| "context lock poisoned")? = context;
59
+
60
+ Ok(())
61
+ }
62
+
7
63
  fn alg_from_str(alg: &str) -> Result<SigningAlg, String> {
8
64
  match alg.to_lowercase().as_str() {
9
65
  "ps256" => Ok(SigningAlg::Ps256),
@@ -38,53 +94,193 @@ fn intent_from_str(intent: &str) -> Result<BuilderIntent, String> {
38
94
  }
39
95
  }
40
96
 
41
- fn do_sign_file(
42
- source_path: &str,
43
- dest_path: &str,
44
- cert_path: &str,
45
- key_path: &str,
46
- alg_str: &str,
47
- manifest_json: Option<&str>,
48
- intent_str: Option<&str>,
97
+ // Ingredients supplied as files rather than as descriptions. c2pa-rs reads the
98
+ // bytes to hash them, generate a thumbnail, and carry forward any manifest the
99
+ // file already holds; the JSON description takes precedence over anything
100
+ // derived from the stream.
101
+ //
102
+ // Expects a JSON array of {"json": "<ingredient JSON>", "format": "<mime>",
103
+ // "path": "<file>"}.
104
+ fn add_ingredient_files(
105
+ builder: &mut Builder,
106
+ ingredient_files_json: &str,
49
107
  ) -> Result<(), Box<dyn std::error::Error>> {
108
+ let entries: serde_json::Value = serde_json::from_str(ingredient_files_json)
109
+ .map_err(|e| format!("Invalid ingredient list: {}", e))?;
110
+ let entries = entries
111
+ .as_array()
112
+ .ok_or("Invalid ingredient list: expected an array")?;
113
+
114
+ for entry in entries {
115
+ let field = |name: &str| -> Result<&str, String> {
116
+ entry[name]
117
+ .as_str()
118
+ .ok_or_else(|| format!("Invalid ingredient list: missing '{}'", name))
119
+ };
120
+ let (json, format, path) = (field("json")?, field("format")?, field("path")?);
121
+
122
+ let mut stream = File::open(path)
123
+ .map_err(|e| format!("Cannot read ingredient '{}': {}", path, e))?;
124
+ builder
125
+ .add_ingredient_from_stream(json, format, &mut stream)
126
+ .map_err(|e| format!("Cannot add ingredient '{}': {}", path, e))?;
127
+ }
128
+
129
+ Ok(())
130
+ }
131
+
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> {
50
145
  let cert = std::fs::read(cert_path)
51
146
  .map_err(|e| format!("Cannot read certificate '{}': {}", cert_path, e))?;
52
147
  let key = std::fs::read(key_path)
53
148
  .map_err(|e| format!("Cannot read key '{}': {}", key_path, e))?;
54
149
 
55
150
  let alg = alg_from_str(alg_str)?;
56
- let signer = create_signer::from_keys(&cert, &key, alg, None)
57
- .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
+ }
58
154
 
59
- let title = Path::new(source_path)
60
- .file_name()
61
- .and_then(|n| n.to_str())
62
- .unwrap_or("unknown")
63
- .replace('"', "\\\"");
64
- let default_json = format!(r#"{{"title": "{}"}}"#, title);
65
- 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);
66
160
 
67
- let mut builder = Builder::from_json(json)
161
+ let mut builder = Builder::from_shared_context(&shared_context())
162
+ .with_definition(json)
68
163
  .map_err(|e| format!("Invalid manifest JSON: {}", e))?;
69
164
 
70
- if let Some(intent) = intent_str {
165
+ if let Some(intent) = request.intent {
71
166
  builder.set_intent(intent_from_str(intent)?);
72
167
  }
73
168
 
74
- builder.sign_file(&*signer, source_path, dest_path)
169
+ if let Some(files) = request.ingredient_files_json {
170
+ add_ingredient_files(&mut builder, files)?;
171
+ }
172
+
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)
75
187
  .map_err(|e| format!("Signing failed: {}", e))?;
76
188
 
77
189
  Ok(())
78
190
  }
79
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
+
80
215
  fn do_read_file(path: &str) -> Result<String, Box<dyn std::error::Error>> {
81
- let reader = Reader::from_file(path)
216
+ let reader = Reader::from_shared_context(&shared_context())
217
+ .with_file(path)
82
218
  .map_err(|e| format!("Failed to read manifest from '{}': {}", path, e))?;
83
219
  Ok(reader.json())
84
220
  }
85
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
+
86
275
  // ─── Ruby-facing functions ────────────────────────────────────────────────────
87
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
+
88
284
  fn sign_file(
89
285
  source: String,
90
286
  dest: String,
@@ -93,18 +289,65 @@ fn sign_file(
93
289
  alg: Option<String>,
94
290
  manifest_json: Option<String>,
95
291
  intent: Option<String>,
292
+ ingredient_files: Option<String>,
96
293
  ) -> Result<String, Error> {
97
- let alg_str = alg.as_deref().unwrap_or("es256");
98
-
99
- do_sign_file(&source, &dest, &cert, &key, alg_str, manifest_json.as_deref(), intent.as_deref())
100
- .map_err(|e| Error::new(Ruby::get().expect("called from Ruby thread").exception_runtime_error(), e.to_string()))?;
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
+ };
101
302
 
303
+ without_gvl(|| do_sign_file(&source, &dest, &request)).map_err(runtime_error)?;
102
304
  Ok(dest)
103
305
  }
104
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
+
105
336
  fn read_file(path: String) -> Result<String, Error> {
106
- do_read_file(&path)
107
- .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)
347
+ }
348
+
349
+ fn configure(settings_json: String) -> Result<(), Error> {
350
+ do_configure(&settings_json).map_err(runtime_error)
108
351
  }
109
352
 
110
353
  fn sdk_version() -> String {
@@ -118,8 +361,11 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
118
361
  let c2pa = ruby.define_module("C2PA")?;
119
362
  let native = c2pa.define_module("Native")?;
120
363
 
121
- native.define_singleton_method("sign_file", function!(sign_file, 7))?;
364
+ native.define_singleton_method("sign_file", function!(sign_file, 8))?;
365
+ native.define_singleton_method("sign_buffer", function!(sign_buffer, 8))?;
122
366
  native.define_singleton_method("read_file", function!(read_file, 1))?;
367
+ native.define_singleton_method("read_buffer", function!(read_buffer, 2))?;
368
+ native.define_singleton_method("configure", function!(configure, 1))?;
123
369
  native.define_singleton_method("sdk_version", function!(sdk_version, 0))?;
124
370
 
125
371
  Ok(())
@@ -0,0 +1,122 @@
1
+ require "json"
2
+
3
+ module C2PA
4
+ # Settings that govern how c2pa-rs validates.
5
+ #
6
+ # These reach the SDK through its Context, which is rebuilt whenever they
7
+ # change. Signing already in flight on another thread continues against the
8
+ # settings it started with.
9
+ #
10
+ # Defaults are c2pa-rs's own, so a gem that never calls C2PA.configure
11
+ # behaves exactly as it did before this existed.
12
+ class Config
13
+ # Additional root certificates to trust, as a PEM bundle. Use this for a
14
+ # private or enterprise CA: a certificate chaining to one of these
15
+ # validates as "Trusted" rather than carrying signingCredential.untrusted.
16
+ attr_accessor :trust_anchors
17
+
18
+ # The trust list proper — normally the C2PA-recognised anchors. Setting
19
+ # this replaces that list rather than adding to it, so prefer
20
+ # trust_anchors unless you mean to substitute the whole thing.
21
+ attr_accessor :trust_list
22
+
23
+ # Explicitly allowed certificates, as a PEM bundle.
24
+ attr_accessor :allowed_certificates
25
+
26
+ # Whether to check certificates against the trust list at all.
27
+ #
28
+ # Turning this off means nothing is ever reported as untrusted, which in a
29
+ # library for establishing provenance is rarely what you want. It exists
30
+ # for offline and air-gapped environments.
31
+ attr_accessor :verify_trust
32
+
33
+ # Whether reading an asset may fetch a manifest over the network.
34
+ # c2pa-rs defaults this to true, so reading can make an outbound request.
35
+ attr_accessor :remote_manifest_fetch
36
+
37
+ # Whether to check certificate revocation over OCSP, which also makes
38
+ # network requests.
39
+ attr_accessor :ocsp_fetch
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
+
62
+ def initialize
63
+ @trust_anchors = nil
64
+ @trust_list = nil
65
+ @allowed_certificates = nil
66
+ @verify_trust = nil
67
+ @remote_manifest_fetch = nil
68
+ @ocsp_fetch = nil
69
+ @thumbnails = false
70
+ @thumbnail_size = nil
71
+ @thumbnail_format = nil
72
+ @thumbnail_quality = nil
73
+ end
74
+
75
+ # The settings document c2pa-rs expects.
76
+ #
77
+ # Only values that were actually set are included, so anything left alone
78
+ # keeps the SDK's default rather than being pinned to ours.
79
+ #
80
+ # @return [String] JSON
81
+ def to_json
82
+ trust = {}
83
+ trust["user_anchors"] = read_pem(@trust_anchors) unless @trust_anchors.nil?
84
+ trust["trust_anchors"] = read_pem(@trust_list) unless @trust_list.nil?
85
+ trust["allowed_list"] = read_pem(@allowed_certificates) unless @allowed_certificates.nil?
86
+
87
+ verify = {}
88
+ verify["verify_trust"] = @verify_trust unless @verify_trust.nil?
89
+ verify["remote_manifest_fetch"] = @remote_manifest_fetch unless @remote_manifest_fetch.nil?
90
+ verify["ocsp_fetch"] = @ocsp_fetch unless @ocsp_fetch.nil?
91
+
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 } }
100
+ settings["trust"] = trust unless trust.empty?
101
+ settings["verify"] = verify unless verify.empty?
102
+
103
+ JSON.generate(settings)
104
+ end
105
+
106
+ private
107
+
108
+ # Accept either PEM text or a path to a file containing it, since callers
109
+ # naturally have one or the other.
110
+ def read_pem(value)
111
+ string = value.to_s
112
+ return string if string.include?("BEGIN CERTIFICATE")
113
+
114
+ unless File.exist?(string)
115
+ raise InvalidSettingsError,
116
+ "expected PEM text or a readable file, got #{string.inspect}"
117
+ end
118
+
119
+ File.read(string)
120
+ end
121
+ end
122
+ end
data/lib/c2pa/error.rb CHANGED
@@ -3,4 +3,7 @@ module C2PA
3
3
  class SigningError < Error; end
4
4
  class ReadError < Error; end
5
5
  class InvalidManifestError < Error; end
6
+
7
+ # Raised when C2PA.configure is given something it cannot use.
8
+ class InvalidSettingsError < Error; end
6
9
  end
data/lib/c2pa/manifest.rb CHANGED
@@ -4,17 +4,15 @@ module C2PA
4
4
  class Manifest
5
5
  # Intents this gem can express.
6
6
  #
7
- # :edit — this asset derives from a parent. c2pa-rs generates the parent
8
- # ingredient from the source file and adds a c2pa.opened action
9
- # wired to it by hashed URI.
7
+ # :edit — this asset derives from a parent. c2pa-rs generates the parent
8
+ # ingredient from the source file and adds a c2pa.opened action
9
+ # wired to it by hashed URI.
10
+ # :update — a restricted edit for non-editorial changes, such as fixing
11
+ # metadata. The parent is the source file itself; an explicit
12
+ # ingredient, if given, must be that same file.
10
13
  #
11
14
  # Omitting the intent produces a manifest for a newly created asset.
12
- #
13
- # c2pa-rs also has an :update intent, a restricted edit for non-editorial
14
- # changes. It is not offered here because it requires an ingredient with
15
- # real content, and add_ingredient records metadata only — signing with it
16
- # fails with "ingredient file not found". Tracked separately.
17
- INTENTS = %i[edit].freeze
15
+ INTENTS = %i[edit update].freeze
18
16
 
19
17
  # c2pa-rs records itself in a namespaced field alongside the generator
20
18
  # name, so the gem does the same when an application supplies its own.
@@ -43,8 +41,16 @@ module C2PA
43
41
  @actions = []
44
42
  @assertions = []
45
43
  @ingredients = []
44
+ @ingredient_files = []
46
45
  end
47
46
 
47
+ # Ingredients supplied as files, for the signing layer to hand to c2pa-rs.
48
+ # Each is a Hash with the ingredient's JSON description, its format, and
49
+ # the path to read.
50
+ #
51
+ # @return [Array<Hash>]
52
+ attr_reader :ingredient_files
53
+
48
54
  # Add a C2PA action to this manifest.
49
55
  #
50
56
  # @param action [String] one of the C2PA::Actions constants
@@ -115,18 +121,48 @@ module C2PA
115
121
 
116
122
  # Add an ingredient (source asset) to this manifest.
117
123
  #
118
- # @param title [String] human-readable title of the ingredient
119
- # @param format [String] MIME type of the ingredient, e.g. "image/jpeg"
120
- # @param instance_id [String] unique identifier for the ingredient instance
124
+ # With `file:`, c2pa-rs reads the ingredient itself. If that file carries
125
+ # content credentials, its manifest is embedded and the ingredient points
126
+ # at it, so provenance chains from the original through to this asset. A
127
+ # verifier can then follow and check the whole history.
128
+ #
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.)
133
+ #
134
+ # Without `file:`, only the description is recorded. Nothing binds it to
135
+ # any actual bytes. This form is kept for compatibility.
136
+ #
137
+ # @param title [String] human-readable title of the ingredient
138
+ # @param format [String] MIME type of the ingredient, e.g. "image/jpeg"
139
+ # @param instance_id [String] unique identifier for the ingredient instance
121
140
  # @param relationship [String] relationship to this asset; defaults to "parentOf"
141
+ # @param file [String, nil] path to the ingredient file
122
142
  # @return [self]
123
- def add_ingredient(title:, format:, instance_id:, relationship: "parentOf")
124
- @ingredients << {
143
+ # @raise [C2PA::InvalidManifestError] if `file` is given but cannot be read
144
+ def add_ingredient(title:, format:, instance_id:, relationship: "parentOf", file: nil)
145
+ description = {
125
146
  "title" => title,
126
147
  "format" => format,
127
148
  "instance_id" => instance_id,
128
149
  "relationship" => relationship
129
150
  }
151
+
152
+ if file.nil?
153
+ @ingredients << description
154
+ return self
155
+ end
156
+
157
+ unless File.file?(file) && File.readable?(file)
158
+ raise InvalidManifestError, "ingredient file not readable: #{file.inspect}"
159
+ end
160
+
161
+ @ingredient_files << {
162
+ "json" => JSON.generate(description),
163
+ "format" => format,
164
+ "path" => File.expand_path(file)
165
+ }
130
166
  self
131
167
  end
132
168
 
data/lib/c2pa/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module C2PA
2
- VERSION = "0.3.0"
2
+ VERSION = "0.5.0"
3
3
  end