ruby-c2pa 0.2.1 → 0.4.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,7 @@ crate-type = ["cdylib"]
9
9
 
10
10
  [dependencies]
11
11
  magnus = { version = "0.8", features = [] }
12
- c2pa = { version = "0.78", features = ["file_io"] }
12
+ c2pa = { version = "0.90", features = ["file_io", "pdf"] }
13
13
  serde_json = "1"
14
14
 
15
15
  [profile.release]
@@ -1,9 +1,52 @@
1
+ use std::fs::File;
1
2
  use std::path::Path;
2
- use c2pa::{create_signer, Builder, Reader, SigningAlg};
3
+ use std::sync::{Arc, RwLock, OnceLock};
4
+ use c2pa::{create_signer, Builder, BuilderIntent, Context, Reader, SigningAlg};
3
5
  use magnus::{function, prelude::*, Error, Ruby};
4
6
 
5
7
  // ─── Helpers ─────────────────────────────────────────────────────────────────
6
8
 
9
+ // A Context carries the settings c2pa-rs validates against — trust anchors,
10
+ // what to verify, whether to fetch remote manifests. The older entry points
11
+ // read those from thread-local state, which is why they are deprecated: a
12
+ // threaded Ruby application could see different settings depending on which
13
+ // thread it signed from.
14
+ //
15
+ // One shared Context is built lazily and reused. It is Send + Sync, so an Arc
16
+ // is all that sharing requires, and reusing it avoids re-reading configuration
17
+ // on every call.
18
+ //
19
+ // It currently carries defaults. Exposing settings to Ruby is a separate piece
20
+ // of work; this is the seam that makes it possible.
21
+ fn context_slot() -> &'static RwLock<Arc<Context>> {
22
+ static CONTEXT: OnceLock<RwLock<Arc<Context>>> = OnceLock::new();
23
+ CONTEXT.get_or_init(|| RwLock::new(Context::new().into_shared()))
24
+ }
25
+
26
+ fn shared_context() -> Arc<Context> {
27
+ context_slot()
28
+ .read()
29
+ .expect("context lock poisoned")
30
+ .clone()
31
+ }
32
+
33
+ // Replace the shared Context with one built from the supplied settings.
34
+ //
35
+ // c2pa-rs takes settings as JSON, so the Ruby side assembles the document and
36
+ // this only has to validate and install it. Rebuilding rather than mutating
37
+ // keeps the Context immutable once shared: signing already in flight on another
38
+ // thread continues against the settings it started with.
39
+ fn do_configure(settings_json: &str) -> Result<(), Box<dyn std::error::Error>> {
40
+ let context = Context::new()
41
+ .with_settings(settings_json)
42
+ .map_err(|e| format!("Invalid settings: {}", e))?
43
+ .into_shared();
44
+
45
+ *context_slot().write().map_err(|_| "context lock poisoned")? = context;
46
+
47
+ Ok(())
48
+ }
49
+
7
50
  fn alg_from_str(alg: &str) -> Result<SigningAlg, String> {
8
51
  match alg.to_lowercase().as_str() {
9
52
  "ps256" => Ok(SigningAlg::Ps256),
@@ -22,6 +65,57 @@ fn alg_from_str(alg: &str) -> Result<SigningAlg, String> {
22
65
 
23
66
  // ─── Core logic ──────────────────────────────────────────────────────────────
24
67
 
68
+ // A c2pa.opened action has to reference its parent ingredient by hashed URI,
69
+ // and that hash is computed over the ingredient assertion as c2pa-rs
70
+ // serialises it. Ruby cannot construct one. Declaring the intent instead lets
71
+ // the SDK derive the parent ingredient from the source and wire the action to
72
+ // it, which is the only way the edit workflow can be expressed.
73
+ fn intent_from_str(intent: &str) -> Result<BuilderIntent, String> {
74
+ match intent.to_lowercase().as_str() {
75
+ "edit" => Ok(BuilderIntent::Edit),
76
+ "update" => Ok(BuilderIntent::Update),
77
+ _ => Err(format!(
78
+ "Unknown intent: '{}'. Valid options: edit, update",
79
+ intent
80
+ )),
81
+ }
82
+ }
83
+
84
+ // Ingredients supplied as files rather than as descriptions. c2pa-rs reads the
85
+ // bytes to hash them, generate a thumbnail, and carry forward any manifest the
86
+ // file already holds; the JSON description takes precedence over anything
87
+ // derived from the stream.
88
+ //
89
+ // Expects a JSON array of {"json": "<ingredient JSON>", "format": "<mime>",
90
+ // "path": "<file>"}.
91
+ fn add_ingredient_files(
92
+ builder: &mut Builder,
93
+ ingredient_files_json: &str,
94
+ ) -> Result<(), Box<dyn std::error::Error>> {
95
+ let entries: serde_json::Value = serde_json::from_str(ingredient_files_json)
96
+ .map_err(|e| format!("Invalid ingredient list: {}", e))?;
97
+ let entries = entries
98
+ .as_array()
99
+ .ok_or("Invalid ingredient list: expected an array")?;
100
+
101
+ for entry in entries {
102
+ let field = |name: &str| -> Result<&str, String> {
103
+ entry[name]
104
+ .as_str()
105
+ .ok_or_else(|| format!("Invalid ingredient list: missing '{}'", name))
106
+ };
107
+ let (json, format, path) = (field("json")?, field("format")?, field("path")?);
108
+
109
+ let mut stream = File::open(path)
110
+ .map_err(|e| format!("Cannot read ingredient '{}': {}", path, e))?;
111
+ builder
112
+ .add_ingredient_from_stream(json, format, &mut stream)
113
+ .map_err(|e| format!("Cannot add ingredient '{}': {}", path, e))?;
114
+ }
115
+
116
+ Ok(())
117
+ }
118
+
25
119
  fn do_sign_file(
26
120
  source_path: &str,
27
121
  dest_path: &str,
@@ -29,6 +123,8 @@ fn do_sign_file(
29
123
  key_path: &str,
30
124
  alg_str: &str,
31
125
  manifest_json: Option<&str>,
126
+ intent_str: Option<&str>,
127
+ ingredient_files_json: Option<&str>,
32
128
  ) -> Result<(), Box<dyn std::error::Error>> {
33
129
  let cert = std::fs::read(cert_path)
34
130
  .map_err(|e| format!("Cannot read certificate '{}': {}", cert_path, e))?;
@@ -47,9 +143,18 @@ fn do_sign_file(
47
143
  let default_json = format!(r#"{{"title": "{}"}}"#, title);
48
144
  let json = manifest_json.unwrap_or(&default_json);
49
145
 
50
- let mut builder = Builder::from_json(json)
146
+ let mut builder = Builder::from_shared_context(&shared_context())
147
+ .with_definition(json)
51
148
  .map_err(|e| format!("Invalid manifest JSON: {}", e))?;
52
149
 
150
+ if let Some(intent) = intent_str {
151
+ builder.set_intent(intent_from_str(intent)?);
152
+ }
153
+
154
+ if let Some(files) = ingredient_files_json {
155
+ add_ingredient_files(&mut builder, files)?;
156
+ }
157
+
53
158
  builder.sign_file(&*signer, source_path, dest_path)
54
159
  .map_err(|e| format!("Signing failed: {}", e))?;
55
160
 
@@ -57,7 +162,8 @@ fn do_sign_file(
57
162
  }
58
163
 
59
164
  fn do_read_file(path: &str) -> Result<String, Box<dyn std::error::Error>> {
60
- let reader = Reader::from_file(path)
165
+ let reader = Reader::from_shared_context(&shared_context())
166
+ .with_file(path)
61
167
  .map_err(|e| format!("Failed to read manifest from '{}': {}", path, e))?;
62
168
  Ok(reader.json())
63
169
  }
@@ -71,10 +177,13 @@ fn sign_file(
71
177
  key: String,
72
178
  alg: Option<String>,
73
179
  manifest_json: Option<String>,
180
+ intent: Option<String>,
181
+ ingredient_files: Option<String>,
74
182
  ) -> Result<String, Error> {
75
183
  let alg_str = alg.as_deref().unwrap_or("es256");
76
184
 
77
- do_sign_file(&source, &dest, &cert, &key, alg_str, manifest_json.as_deref())
185
+ do_sign_file(&source, &dest, &cert, &key, alg_str, manifest_json.as_deref(),
186
+ intent.as_deref(), ingredient_files.as_deref())
78
187
  .map_err(|e| Error::new(Ruby::get().expect("called from Ruby thread").exception_runtime_error(), e.to_string()))?;
79
188
 
80
189
  Ok(dest)
@@ -85,6 +194,15 @@ fn read_file(path: String) -> Result<String, Error> {
85
194
  .map_err(|e| Error::new(Ruby::get().expect("called from Ruby thread").exception_runtime_error(), e.to_string()))
86
195
  }
87
196
 
197
+ 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
+ })
204
+ }
205
+
88
206
  fn sdk_version() -> String {
89
207
  c2pa::VERSION.to_string()
90
208
  }
@@ -96,8 +214,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
96
214
  let c2pa = ruby.define_module("C2PA")?;
97
215
  let native = c2pa.define_module("Native")?;
98
216
 
99
- native.define_singleton_method("sign_file", function!(sign_file, 6))?;
217
+ native.define_singleton_method("sign_file", function!(sign_file, 8))?;
100
218
  native.define_singleton_method("read_file", function!(read_file, 1))?;
219
+ native.define_singleton_method("configure", function!(configure, 1))?;
101
220
  native.define_singleton_method("sdk_version", function!(sdk_version, 0))?;
102
221
 
103
222
  Ok(())
@@ -0,0 +1,90 @@
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
+ def initialize
42
+ @trust_anchors = nil
43
+ @trust_list = nil
44
+ @allowed_certificates = nil
45
+ @verify_trust = nil
46
+ @remote_manifest_fetch = nil
47
+ @ocsp_fetch = nil
48
+ end
49
+
50
+ # The settings document c2pa-rs expects.
51
+ #
52
+ # Only values that were actually set are included, so anything left alone
53
+ # keeps the SDK's default rather than being pinned to ours.
54
+ #
55
+ # @return [String] JSON
56
+ def to_json
57
+ trust = {}
58
+ trust["user_anchors"] = read_pem(@trust_anchors) unless @trust_anchors.nil?
59
+ trust["trust_anchors"] = read_pem(@trust_list) unless @trust_list.nil?
60
+ trust["allowed_list"] = read_pem(@allowed_certificates) unless @allowed_certificates.nil?
61
+
62
+ verify = {}
63
+ verify["verify_trust"] = @verify_trust unless @verify_trust.nil?
64
+ verify["remote_manifest_fetch"] = @remote_manifest_fetch unless @remote_manifest_fetch.nil?
65
+ verify["ocsp_fetch"] = @ocsp_fetch unless @ocsp_fetch.nil?
66
+
67
+ settings = {}
68
+ settings["trust"] = trust unless trust.empty?
69
+ settings["verify"] = verify unless verify.empty?
70
+
71
+ JSON.generate(settings)
72
+ end
73
+
74
+ private
75
+
76
+ # Accept either PEM text or a path to a file containing it, since callers
77
+ # naturally have one or the other.
78
+ def read_pem(value)
79
+ string = value.to_s
80
+ return string if string.include?("BEGIN CERTIFICATE")
81
+
82
+ unless File.exist?(string)
83
+ raise InvalidSettingsError,
84
+ "expected PEM text or a readable file, got #{string.inspect}"
85
+ end
86
+
87
+ File.read(string)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,72 @@
1
+ module C2PA
2
+ # URIs from the IPTC Digital Source Type NewsCodes vocabulary.
3
+ #
4
+ # The C2PA specification requires every c2pa.created action to declare one of
5
+ # these, so consumers can tell how the asset came into being. Pick the one that
6
+ # honestly describes the asset — this is a provenance claim, and c2pa-rs will
7
+ # not second-guess the value you supply.
8
+ #
9
+ # @see https://cv.iptc.org/newscodes/digitalsourcetype/
10
+ module DigitalSourceTypes
11
+ BASE = "http://cv.iptc.org/newscodes/digitalsourcetype"
12
+
13
+ # Declining to claim a source type, rather than guessing at one.
14
+ #
15
+ # c2pa-rs requires c2pa.created to carry a digitalSourceType but accepts
16
+ # any string, so a wrong value validates silently. When the origin is not
17
+ # known — a generic signing service, say — this says so, instead of
18
+ # asserting something untrue. It is the value c2pa-rs uses throughout its
19
+ # own fixtures.
20
+ UNSPECIFIED = "http://c2pa.org/digitalsourcetype/empty"
21
+
22
+ # Captured from real life
23
+ DIGITAL_CAPTURE = "#{BASE}/digitalCapture"
24
+ COMPUTATIONAL_CAPTURE = "#{BASE}/computationalCapture"
25
+ SCREEN_CAPTURE = "#{BASE}/screenCapture"
26
+ VIRTUAL_RECORDING = "#{BASE}/virtualRecording"
27
+
28
+ # Digitised from a physical medium
29
+ NEGATIVE_FILM = "#{BASE}/negativeFilm"
30
+ POSITIVE_FILM = "#{BASE}/positiveFilm"
31
+ PRINT = "#{BASE}/print"
32
+
33
+ # Human- or software-authored
34
+ HUMAN_EDITS = "#{BASE}/humanEdits"
35
+ DIGITAL_CREATION = "#{BASE}/digitalCreation"
36
+ ALGORITHMICALLY_ENHANCED = "#{BASE}/algorithmicallyEnhanced"
37
+ DATA_DRIVEN_MEDIA = "#{BASE}/dataDrivenMedia"
38
+ ALGORITHMIC_MEDIA = "#{BASE}/algorithmicMedia"
39
+
40
+ # Generative AI
41
+ TRAINED_ALGORITHMIC_MEDIA = "#{BASE}/trainedAlgorithmicMedia"
42
+ COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA = "#{BASE}/compositeWithTrainedAlgorithmicMedia"
43
+ COMPOSITE_SYNTHETIC = "#{BASE}/compositeSynthetic"
44
+
45
+ # Composites
46
+ COMPOSITE = "#{BASE}/composite"
47
+ COMPOSITE_CAPTURE = "#{BASE}/compositeCapture"
48
+
49
+ # Every type defined above. Supplying a URI outside this list is allowed —
50
+ # the vocabulary is extensible — but the values here cover the standard set.
51
+ ALL = [
52
+ UNSPECIFIED,
53
+ DIGITAL_CAPTURE,
54
+ COMPUTATIONAL_CAPTURE,
55
+ SCREEN_CAPTURE,
56
+ VIRTUAL_RECORDING,
57
+ NEGATIVE_FILM,
58
+ POSITIVE_FILM,
59
+ PRINT,
60
+ HUMAN_EDITS,
61
+ DIGITAL_CREATION,
62
+ ALGORITHMICALLY_ENHANCED,
63
+ DATA_DRIVEN_MEDIA,
64
+ ALGORITHMIC_MEDIA,
65
+ TRAINED_ALGORITHMIC_MEDIA,
66
+ COMPOSITE_WITH_TRAINED_ALGORITHMIC_MEDIA,
67
+ COMPOSITE_SYNTHETIC,
68
+ COMPOSITE,
69
+ COMPOSITE_CAPTURE
70
+ ].freeze
71
+ end
72
+ 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
@@ -2,14 +2,55 @@ require "json"
2
2
 
3
3
  module C2PA
4
4
  class Manifest
5
- # @param title [String] human-readable title for this asset
6
- def initialize(title:)
5
+ # Intents this gem can express.
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.
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.
13
+ #
14
+ # Omitting the intent produces a manifest for a newly created asset.
15
+ INTENTS = %i[edit update].freeze
16
+
17
+ # c2pa-rs records itself in a namespaced field alongside the generator
18
+ # name, so the gem does the same when an application supplies its own.
19
+ GEM_FIELD = "org.rubygems.ruby_c2pa".freeze
20
+
21
+ # @return [Symbol, nil] the builder intent, if any
22
+ attr_reader :intent
23
+
24
+ # @param title [String] human-readable title for this asset
25
+ # @param intent [Symbol, nil] :edit; omit for a new creation
26
+ # @param generator_name [String, nil] the application doing the signing.
27
+ # Defaults to this gem. Supplying it credits your application as the
28
+ # claim generator, with the gem recorded alongside.
29
+ # @param generator_version [String, nil] version of that application
30
+ # @raise [C2PA::InvalidManifestError] if the intent is not recognised
31
+ def initialize(title:, intent: nil, generator_name: nil, generator_version: nil)
32
+ unless intent.nil? || INTENTS.include?(intent)
33
+ raise InvalidManifestError,
34
+ "unknown intent #{intent.inspect}. Valid options: #{INTENTS.map(&:inspect).join(', ')}"
35
+ end
36
+
7
37
  @title = title
38
+ @intent = intent
39
+ @generator_name = generator_name
40
+ @generator_version = generator_version
8
41
  @actions = []
9
42
  @assertions = []
10
43
  @ingredients = []
44
+ @ingredient_files = []
11
45
  end
12
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
+
13
54
  # Add a C2PA action to this manifest.
14
55
  #
15
56
  # @param action [String] one of the C2PA::Actions constants
@@ -26,6 +67,38 @@ module C2PA
26
67
  digital_source_type: nil,
27
68
  changed: nil,
28
69
  parameters: nil)
70
+ if action == Actions::OPENED
71
+ raise InvalidManifestError,
72
+ "#{Actions::OPENED} cannot be added directly. The specification requires it to " \
73
+ "reference a parentOf ingredient by hashed URI, and that hash is computed over " \
74
+ "the ingredient as c2pa-rs serialises it, so Ruby cannot construct one. Pass " \
75
+ "intent: :edit to C2PA::Manifest.new instead and the action will be added for you."
76
+ end
77
+
78
+ # Required as of c2pa-rs 0.90. Earlier versions accepted its absence, so
79
+ # manifests signed by releases before 0.3.0 are rejected by current
80
+ # verifiers. No default is supplied: c2pa-rs accepts any string here, so
81
+ # a guess would validate while asserting something untrue about where the
82
+ # asset came from. Use DigitalSourceTypes::UNSPECIFIED to decline.
83
+ if action == Actions::CREATED && to_s_or_nil(digital_source_type).nil?
84
+ raise InvalidManifestError,
85
+ "#{Actions::CREATED} requires a digital_source_type. Choose the value that " \
86
+ "describes how the asset was produced — for example " \
87
+ "C2PA::DigitalSourceTypes::DIGITAL_CAPTURE for a camera original, or " \
88
+ "TRAINED_ALGORITHMIC_MEDIA for generative AI. If the origin is genuinely " \
89
+ "unknown, use C2PA::DigitalSourceTypes::UNSPECIFIED rather than guessing."
90
+ end
91
+
92
+ # Also new in c2pa-rs 0.90.
93
+ if action == Actions::TRANSLATED
94
+ missing = %w[sourceLanguage targetLanguage].reject { |key| param_present?(parameters, key) }
95
+ unless missing.empty?
96
+ raise InvalidManifestError,
97
+ "#{Actions::TRANSLATED} requires #{missing.join(' and ')} in parameters, " \
98
+ "as RFC 5646 language codes"
99
+ end
100
+ end
101
+
29
102
  entry = { "action" => action }
30
103
  entry["when"] = when_time if when_time
31
104
  entry["softwareAgent"] = software_agent || "ruby-c2pa/#{VERSION}"
@@ -48,30 +121,62 @@ module C2PA
48
121
 
49
122
  # Add an ingredient (source asset) to this manifest.
50
123
  #
51
- # @param title [String] human-readable title of the ingredient
52
- # @param format [String] MIME type of the ingredient, e.g. "image/jpeg"
53
- # @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
54
140
  # @param relationship [String] relationship to this asset; defaults to "parentOf"
141
+ # @param file [String, nil] path to the ingredient file
55
142
  # @return [self]
56
- def add_ingredient(title:, format:, instance_id:, relationship: "parentOf")
57
- @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 = {
58
146
  "title" => title,
59
147
  "format" => format,
60
148
  "instance_id" => instance_id,
61
149
  "relationship" => relationship
62
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
+ }
63
166
  self
64
167
  end
65
168
 
66
169
  # Serialize to the JSON structure expected by c2pa-rs.
67
170
  #
68
171
  # @return [String]
69
- # @raise [C2PA::InvalidManifestError] if no actions have been added
172
+ # @raise [C2PA::InvalidManifestError] if no actions have been added, or if
173
+ # any value cannot be represented as JSON
70
174
  def to_json
71
175
  raise InvalidManifestError, "at least one action is required" if @actions.empty?
72
176
 
73
177
  manifest = {
74
178
  "title" => @title,
179
+ "claim_generator_info" => [claim_generator_info],
75
180
  "assertions" => [
76
181
  { "label" => "c2pa.actions.v2", "data" => { "actions" => @actions } },
77
182
  *@assertions
@@ -79,7 +184,49 @@ module C2PA
79
184
  }
80
185
  manifest["ingredients"] = @ingredients unless @ingredients.empty?
81
186
 
82
- JSON.generate(manifest)
187
+ begin
188
+ JSON.generate(manifest)
189
+ rescue JSON::GeneratorError => e
190
+ # Typically a string that is not valid UTF-8 — a filename or caption
191
+ # read in another encoding and passed through untouched. Without this
192
+ # the caller gets a JSON::GeneratorError, which is not a C2PA::Error
193
+ # and so escapes `rescue C2PA::Error`.
194
+ raise InvalidManifestError,
195
+ "manifest contains text that cannot be encoded as JSON: #{e.message}"
196
+ end
197
+ end
198
+
199
+ private
200
+
201
+ # Parameters may be keyed with strings or symbols depending on the caller.
202
+ def param_present?(parameters, key)
203
+ return false unless parameters.is_a?(Hash)
204
+
205
+ !to_s_or_nil(parameters[key] || parameters[key.to_sym]).nil?
206
+ end
207
+
208
+ def to_s_or_nil(value)
209
+ return nil if value.nil?
210
+
211
+ string = value.to_s
212
+ string.empty? ? nil : string
213
+ end
214
+
215
+ # Who signed this. Without it c2pa-rs names itself, so every asset this gem
216
+ # produced credited "c2pa-rs" and nothing identified the gem or the
217
+ # application using it.
218
+ #
219
+ # c2pa-rs 0.78 permits exactly one entry — supplying two fails with "only 1
220
+ # claim_generator_info allowed" — so an application name replaces the gem
221
+ # rather than preceding it, and the gem moves into a namespaced field. That
222
+ # mirrors how c2pa-rs records itself as org.contentauth.c2pa_rs.
223
+ def claim_generator_info
224
+ return { "name" => "ruby-c2pa", "version" => VERSION } if @generator_name.nil?
225
+
226
+ info = { "name" => @generator_name }
227
+ info["version"] = @generator_version if @generator_version
228
+ info[GEM_FIELD] = VERSION
229
+ info
83
230
  end
84
231
  end
85
232
  end
data/lib/c2pa/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module C2PA
2
- VERSION = "0.2.1"
2
+ VERSION = "0.4.0"
3
3
  end