ruby-c2pa 0.3.0 → 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.90", 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, BuilderIntent, 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),
@@ -38,6 +81,41 @@ fn intent_from_str(intent: &str) -> Result<BuilderIntent, String> {
38
81
  }
39
82
  }
40
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
+
41
119
  fn do_sign_file(
42
120
  source_path: &str,
43
121
  dest_path: &str,
@@ -46,6 +124,7 @@ fn do_sign_file(
46
124
  alg_str: &str,
47
125
  manifest_json: Option<&str>,
48
126
  intent_str: Option<&str>,
127
+ ingredient_files_json: Option<&str>,
49
128
  ) -> Result<(), Box<dyn std::error::Error>> {
50
129
  let cert = std::fs::read(cert_path)
51
130
  .map_err(|e| format!("Cannot read certificate '{}': {}", cert_path, e))?;
@@ -64,13 +143,18 @@ fn do_sign_file(
64
143
  let default_json = format!(r#"{{"title": "{}"}}"#, title);
65
144
  let json = manifest_json.unwrap_or(&default_json);
66
145
 
67
- let mut builder = Builder::from_json(json)
146
+ let mut builder = Builder::from_shared_context(&shared_context())
147
+ .with_definition(json)
68
148
  .map_err(|e| format!("Invalid manifest JSON: {}", e))?;
69
149
 
70
150
  if let Some(intent) = intent_str {
71
151
  builder.set_intent(intent_from_str(intent)?);
72
152
  }
73
153
 
154
+ if let Some(files) = ingredient_files_json {
155
+ add_ingredient_files(&mut builder, files)?;
156
+ }
157
+
74
158
  builder.sign_file(&*signer, source_path, dest_path)
75
159
  .map_err(|e| format!("Signing failed: {}", e))?;
76
160
 
@@ -78,7 +162,8 @@ fn do_sign_file(
78
162
  }
79
163
 
80
164
  fn do_read_file(path: &str) -> Result<String, Box<dyn std::error::Error>> {
81
- let reader = Reader::from_file(path)
165
+ let reader = Reader::from_shared_context(&shared_context())
166
+ .with_file(path)
82
167
  .map_err(|e| format!("Failed to read manifest from '{}': {}", path, e))?;
83
168
  Ok(reader.json())
84
169
  }
@@ -93,10 +178,12 @@ fn sign_file(
93
178
  alg: Option<String>,
94
179
  manifest_json: Option<String>,
95
180
  intent: Option<String>,
181
+ ingredient_files: Option<String>,
96
182
  ) -> Result<String, Error> {
97
183
  let alg_str = alg.as_deref().unwrap_or("es256");
98
184
 
99
- do_sign_file(&source, &dest, &cert, &key, alg_str, manifest_json.as_deref(), intent.as_deref())
185
+ do_sign_file(&source, &dest, &cert, &key, alg_str, manifest_json.as_deref(),
186
+ intent.as_deref(), ingredient_files.as_deref())
100
187
  .map_err(|e| Error::new(Ruby::get().expect("called from Ruby thread").exception_runtime_error(), e.to_string()))?;
101
188
 
102
189
  Ok(dest)
@@ -107,6 +194,15 @@ fn read_file(path: String) -> Result<String, Error> {
107
194
  .map_err(|e| Error::new(Ruby::get().expect("called from Ruby thread").exception_runtime_error(), e.to_string()))
108
195
  }
109
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
+
110
206
  fn sdk_version() -> String {
111
207
  c2pa::VERSION.to_string()
112
208
  }
@@ -118,8 +214,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
118
214
  let c2pa = ruby.define_module("C2PA")?;
119
215
  let native = c2pa.define_module("Native")?;
120
216
 
121
- native.define_singleton_method("sign_file", function!(sign_file, 7))?;
217
+ native.define_singleton_method("sign_file", function!(sign_file, 8))?;
122
218
  native.define_singleton_method("read_file", function!(read_file, 1))?;
219
+ native.define_singleton_method("configure", function!(configure, 1))?;
123
220
  native.define_singleton_method("sdk_version", function!(sdk_version, 0))?;
124
221
 
125
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
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.4.0"
3
3
  end
data/lib/c2pa.rb CHANGED
@@ -3,6 +3,7 @@ require_relative "c2pa/version"
3
3
  require_relative "c2pa/error"
4
4
  require_relative "c2pa/actions"
5
5
  require_relative "c2pa/digital_source_types"
6
+ require_relative "c2pa/config"
6
7
  require_relative "c2pa/manifest"
7
8
  require "c2pa/c2pa_native"
8
9
 
@@ -14,6 +15,39 @@ module C2PA
14
15
  # production files that are most correct.
15
16
  VALID_STATES = %w[Valid Trusted].freeze
16
17
 
18
+ # Configure how c2pa-rs validates.
19
+ #
20
+ # Settings are global and take effect for subsequent calls. Signing already
21
+ # in flight on another thread continues against the settings it started
22
+ # with, since the underlying context is replaced rather than mutated.
23
+ #
24
+ # @yieldparam config [C2PA::Config]
25
+ # @return [C2PA::Config] the configuration that was applied
26
+ # @raise [C2PA::InvalidSettingsError] if the settings are not usable
27
+ #
28
+ # @example Trusting a private CA
29
+ # C2PA.configure do |config|
30
+ # config.trust_anchors = "ca/root.pem"
31
+ # end
32
+ #
33
+ # @example An offline environment
34
+ # C2PA.configure do |config|
35
+ # config.remote_manifest_fetch = false
36
+ # config.ocsp_fetch = false
37
+ # end
38
+ def self.configure
39
+ config = Config.new
40
+ yield config if block_given?
41
+
42
+ begin
43
+ Native.configure(config.to_json)
44
+ rescue RuntimeError => e
45
+ raise InvalidSettingsError, e.message
46
+ end
47
+
48
+ config
49
+ end
50
+
17
51
  # Sign a file with a C2PA manifest.
18
52
  #
19
53
  # @param file [String] path to the input file
@@ -50,7 +84,10 @@ module C2PA
50
84
  # to_json is the only thing genuinely required of a manifest, so an
51
85
  # object that provides just that still signs — as a creation.
52
86
  intent = manifest.respond_to?(:intent) ? manifest.intent&.to_s : nil
53
- Native.sign_file(file, output, certificate, key, algorithm, manifest_json, intent)
87
+ files = manifest.respond_to?(:ingredient_files) ? manifest.ingredient_files : []
88
+ ingredient_files = files.empty? ? nil : JSON.generate(files)
89
+ Native.sign_file(file, output, certificate, key, algorithm, manifest_json,
90
+ intent, ingredient_files)
54
91
  rescue RuntimeError => e
55
92
  raise SigningError, e.message
56
93
  end
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carlos Rodriguez
@@ -85,6 +85,7 @@ files:
85
85
  - ext/c2pa_native/src/lib.rs
86
86
  - lib/c2pa.rb
87
87
  - lib/c2pa/actions.rb
88
+ - lib/c2pa/config.rb
88
89
  - lib/c2pa/digital_source_types.rb
89
90
  - lib/c2pa/error.rb
90
91
  - lib/c2pa/manifest.rb