hanko_xml 0.1.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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +44 -0
  3. data/Cargo.lock +401 -0
  4. data/Cargo.toml +7 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +222 -0
  7. data/THIRD_PARTY_LICENSES.md +392 -0
  8. data/dependencies.yml +87 -0
  9. data/ext/hanko_xml/Cargo.toml +20 -0
  10. data/ext/hanko_xml/build/vendor.rs +817 -0
  11. data/ext/hanko_xml/build.rs +298 -0
  12. data/ext/hanko_xml/extconf.rb +6 -0
  13. data/ext/hanko_xml/src/dsig.rs +1851 -0
  14. data/ext/hanko_xml/src/errors.rs +151 -0
  15. data/ext/hanko_xml/src/lib.rs +490 -0
  16. data/ext/hanko_xml/src/sys.rs +43 -0
  17. data/lib/hanko_xml/algorithms.rb +228 -0
  18. data/lib/hanko_xml/dom.rb +137 -0
  19. data/lib/hanko_xml/errors.rb +132 -0
  20. data/lib/hanko_xml/exc-c14n.xsd +26 -0
  21. data/lib/hanko_xml/input.rb +50 -0
  22. data/lib/hanko_xml/key.rb +213 -0
  23. data/lib/hanko_xml/key_info.rb +67 -0
  24. data/lib/hanko_xml/native_version.rb +75 -0
  25. data/lib/hanko_xml/nokogiri_ext.rb +34 -0
  26. data/lib/hanko_xml/placement.rb +127 -0
  27. data/lib/hanko_xml/policy.rb +502 -0
  28. data/lib/hanko_xml/reference.rb +49 -0
  29. data/lib/hanko_xml/signature_info.rb +134 -0
  30. data/lib/hanko_xml/signed_document.rb +155 -0
  31. data/lib/hanko_xml/signer.rb +572 -0
  32. data/lib/hanko_xml/template.rb +150 -0
  33. data/lib/hanko_xml/transform.rb +87 -0
  34. data/lib/hanko_xml/verification.rb +377 -0
  35. data/lib/hanko_xml/verifier.rb +694 -0
  36. data/lib/hanko_xml/version.rb +5 -0
  37. data/lib/hanko_xml/xades/verifier.rb +319 -0
  38. data/lib/hanko_xml/xades.rb +287 -0
  39. data/lib/hanko_xml/xml.rb +93 -0
  40. data/lib/hanko_xml/xmldsig-core-schema.xsd +332 -0
  41. data/lib/hanko_xml.rb +122 -0
  42. data/sig/hanko_xml/key.rbs +48 -0
  43. data/sig/hanko_xml/native.rbs +12 -0
  44. data/sig/hanko_xml/native_version.rbs +13 -0
  45. data/sig/hanko_xml/nokogiri_ext.rbs +23 -0
  46. data/sig/hanko_xml/placement.rbs +27 -0
  47. data/sig/hanko_xml/policy.rbs +83 -0
  48. data/sig/hanko_xml/reference.rbs +36 -0
  49. data/sig/hanko_xml/signed_document.rbs +66 -0
  50. data/sig/hanko_xml/signer.rbs +50 -0
  51. data/sig/hanko_xml/template.rbs +41 -0
  52. data/sig/hanko_xml/verification.rbs +100 -0
  53. data/sig/hanko_xml/verifier.rbs +45 -0
  54. data/sig/hanko_xml/xades.rbs +70 -0
  55. data/sig/hanko_xml.rbs +62 -0
  56. metadata +105 -0
@@ -0,0 +1,151 @@
1
+ //! Error callback capture for libxmlsec1.
2
+ //!
3
+ //! libxmlsec1's default error handler prints to stderr, which is
4
+ //! unacceptable in a library. This module installs a custom callback
5
+ //! via `xmlSecErrorsSetCallback` that captures error messages into a
6
+ //! thread-local buffer. Each native call should bracket with
7
+ //! [`clear_errors`] / [`drain_errors`] to collect diagnostics.
8
+ //!
9
+ //! Thread-safety: the GVL is held for the entire duration of every
10
+ //! native call (§7.1), so a thread-local `RefCell<Vec<String>>` is
11
+ //! sufficient — no `Mutex` needed.
12
+
13
+ use std::cell::{Cell, RefCell};
14
+ use std::ffi::CStr;
15
+ use std::os::raw::{c_char, c_int, c_void};
16
+
17
+ thread_local! {
18
+ static ERROR_BUF: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
19
+ /// How many error-level libxml2 diagnostics the structured handler has
20
+ /// seen on this thread. Callers compare it before and after a parse.
21
+ static LIBXML2_ERRORS: Cell<usize> = const { Cell::new(0) };
22
+ }
23
+
24
+ /// Clears the thread-local error buffer. Call at the start of each
25
+ /// native entry point.
26
+ pub fn clear_errors() {
27
+ ERROR_BUF.with(|buf| buf.borrow_mut().clear());
28
+ }
29
+
30
+ /// Drains and returns all captured error messages since the last
31
+ /// [`clear_errors`]. Call at the end of each native entry point.
32
+ pub fn drain_errors() -> Vec<String> {
33
+ ERROR_BUF.with(|buf| {
34
+ let mut b = buf.borrow_mut();
35
+ let drained = b.clone();
36
+ b.clear();
37
+ drained
38
+ })
39
+ }
40
+
41
+ /// The `extern "C"` callback installed via `xmlSecErrorsSetCallback`.
42
+ ///
43
+ /// Signature matches `xmlSecErrorsCallback`:
44
+ /// ```c
45
+ /// typedef void (*xmlSecErrorsCallback)(
46
+ /// const char* file, int line, const char* func,
47
+ /// const char* errorObject, const char* errorSubject,
48
+ /// int reason, const char* msg);
49
+ /// ```
50
+ ///
51
+ /// # Safety
52
+ ///
53
+ /// Called by libxmlsec1 internally. All pointer arguments are either
54
+ /// valid C strings or null. We treat null as empty and never retain
55
+ /// any pointer past this function's return.
56
+ unsafe extern "C" fn error_callback(
57
+ file: *const c_char,
58
+ line: c_int,
59
+ func: *const c_char,
60
+ error_object: *const c_char,
61
+ error_subject: *const c_char,
62
+ reason: c_int,
63
+ msg: *const c_char,
64
+ ) {
65
+ let safe_str = |ptr: *const c_char| -> &str {
66
+ if ptr.is_null() {
67
+ ""
68
+ } else {
69
+ // SAFETY: libxmlsec1 passes NUL-terminated C strings or null.
70
+ unsafe { CStr::from_ptr(ptr) }.to_str().unwrap_or("")
71
+ }
72
+ };
73
+
74
+ let file_s = safe_str(file);
75
+ let func_s = safe_str(func);
76
+ let obj_s = safe_str(error_object);
77
+ let subj_s = safe_str(error_subject);
78
+ let msg_s = safe_str(msg).trim_end();
79
+
80
+ let entry = format!(
81
+ "func={func_s}:file={file_s}:line={line}:obj={obj_s}:subj={subj_s}:error={reason}:{msg_s}"
82
+ );
83
+
84
+ ERROR_BUF.with(|buf| {
85
+ buf.borrow_mut().push(entry);
86
+ });
87
+ }
88
+
89
+ /// Installs our error callback. Must be called once during
90
+ /// `init_xmlsec()`, after `xmlSecInit()`.
91
+ ///
92
+ /// # Safety
93
+ ///
94
+ /// `xmlSecErrorsSetCallback` is safe to call after `xmlSecInit()`.
95
+ pub unsafe fn install_error_callback() {
96
+ unsafe {
97
+ crate::sys::xmlSecErrorsSetCallback(Some(error_callback));
98
+ }
99
+ }
100
+
101
+ /// libxml2's structured error handler, installed around each native parse
102
+ /// and schema validation by `dsig::Libxml2Capture`. Keeps libxml2's
103
+ /// diagnostics off stderr and in the same buffer as xmlsec's, so they reach
104
+ /// `#xmlsec_errors` and never `#message`: they can quote the document.
105
+ ///
106
+ /// Only error and fatal levels are kept. Warnings are dropped, matching
107
+ /// what a strict Nokogiri parse rejects (§6.3).
108
+ ///
109
+ /// # Safety
110
+ ///
111
+ /// Called by libxml2 with a valid `xmlError` or null, and never retained.
112
+ /// Declared with `*const` (libxml2 2.12+); 2.9 passes the same pointer as
113
+ /// `*mut`, which has the same ABI.
114
+ unsafe extern "C" fn libxml2_error_callback(_ctx: *mut c_void, error: *const crate::sys::xmlError) {
115
+ if error.is_null() {
116
+ return;
117
+ }
118
+ // SAFETY: libxml2 passes a valid xmlError for the duration of the call.
119
+ let err = unsafe { &*error };
120
+ if (err.level as i64) < (crate::sys::xmlErrorLevel_XML_ERR_ERROR as i64) {
121
+ return;
122
+ }
123
+ let message = if err.message.is_null() {
124
+ ""
125
+ } else {
126
+ // SAFETY: a non-null message is a NUL-terminated C string.
127
+ unsafe { CStr::from_ptr(err.message) }.to_str().unwrap_or("")
128
+ };
129
+ let entry = format!("{}:{}: ERROR: {}", err.line, err.int2, message.trim_end());
130
+
131
+ LIBXML2_ERRORS.with(|count| count.set(count.get() + 1));
132
+ ERROR_BUF.with(|buf| buf.borrow_mut().push(entry));
133
+ }
134
+
135
+ /// The handler above, typed as libxml2's `xmlStructuredErrorFunc`, whose
136
+ /// pointer argument is `*mut` before libxml2 2.12 and `*const` after.
137
+ #[allow(
138
+ clippy::useless_transmute,
139
+ reason = "a no-op on libxml2 2.12+, where the types already match; needed on 2.9"
140
+ )]
141
+ pub fn libxml2_error_handler() -> crate::sys::xmlStructuredErrorFunc {
142
+ let handler: unsafe extern "C" fn(*mut c_void, *const crate::sys::xmlError) = libxml2_error_callback;
143
+ // SAFETY: both spellings of the callback type have the same ABI; only
144
+ // the constness of the xmlError pointer differs between libxml2 versions.
145
+ unsafe { std::mem::transmute(Some(handler)) }
146
+ }
147
+
148
+ /// Error-level libxml2 diagnostics seen on this thread so far.
149
+ pub fn libxml2_error_count() -> usize {
150
+ LIBXML2_ERRORS.with(Cell::get)
151
+ }
@@ -0,0 +1,490 @@
1
+ //! Magnus glue: 5 functions on `HankoXml::Native`, matching the §7 contract.
2
+ //!
3
+ //! Every argument from Ruby is copied into a Rust-owned `Vec<u8>` or `String`
4
+ //! **before** any native call, so there is no `unsafe` in this file — no
5
+ //! `RString::as_slice()` across a call that might release the GVL.
6
+
7
+ mod dsig;
8
+ mod errors;
9
+ mod sys;
10
+
11
+ use magnus::{
12
+ function, prelude::*, Error, ExceptionClass, IntoValue, RArray, RHash, RModule, RString,
13
+ Ruby, TryConvert, Value,
14
+ };
15
+ use std::collections::HashMap;
16
+ use std::sync::Once;
17
+
18
+ static INIT: Once = Once::new();
19
+
20
+ /// Runs [`dsig::init_xmlsec`] exactly once per process.
21
+ fn ensure_initialized() -> Result<(), &'static str> {
22
+ let mut result = Ok(());
23
+ INIT.call_once(|| result = dsig::init_xmlsec());
24
+ result
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Error construction helpers
29
+ // ---------------------------------------------------------------------------
30
+
31
+ fn error_class(ruby: &Ruby, class_name: &str) -> ExceptionClass {
32
+ ruby.eval::<ExceptionClass>(class_name)
33
+ .unwrap_or_else(|_| ruby.exception_runtime_error())
34
+ }
35
+
36
+ fn native_error(ruby: &Ruby, message: String) -> Error {
37
+ Error::new(error_class(ruby, "HankoXml::NativeError"), message)
38
+ }
39
+
40
+ /// Converts a [`dsig::DsigError`] into the matching `HankoXml::Error`
41
+ /// subclass (S-13). The class comes from `err.kind`, and the message is the
42
+ /// fixed string from `dsig.rs`. The xmlsec log captured during the call is
43
+ /// drained here and set as `#xmlsec_errors`, never appended to `#message`:
44
+ /// it can contain document text (hrefs, URIs, node names) that applications
45
+ /// commonly log or display.
46
+ fn dsig_error(ruby: &Ruby, err: dsig::DsigError) -> Error {
47
+ let class_name = match err.kind {
48
+ dsig::ErrorKind::Parse => "HankoXml::ParseError",
49
+ dsig::ErrorKind::KeyLoad => "HankoXml::KeyLoadError",
50
+ dsig::ErrorKind::Signing => "HankoXml::SigningError",
51
+ dsig::ErrorKind::Native => "HankoXml::NativeError",
52
+ };
53
+ let cls = error_class(ruby, class_name);
54
+ let xmlsec_errors = errors::drain_errors();
55
+
56
+ let exception = match cls.new_instance((err.message,)) {
57
+ Ok(exception) => exception,
58
+ Err(e) => return e,
59
+ };
60
+ if let Ok(log) = string_slice_to_rarray(ruby, &xmlsec_errors) {
61
+ // Best effort: without the attribute the typed error is still right.
62
+ let _: Result<Value, Error> = exception.funcall("xmlsec_errors=", (log,));
63
+ }
64
+ Error::from(exception)
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Argument extraction and Ruby array helpers
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /// Extracts an RString to an owned Vec<u8>.
72
+ fn rstring_to_bytes(s: RString) -> Vec<u8> {
73
+ // SAFETY: s is a live Ruby String argument on the call stack.
74
+ // We immediately copy to a Vec<u8> so the slice is not retained.
75
+ unsafe { s.as_slice() }.to_vec()
76
+ }
77
+
78
+ /// Converts a slice of Strings to a Ruby Array of RStrings.
79
+ fn string_slice_to_rarray(ruby: &Ruby, strings: &[String]) -> Result<RArray, Error> {
80
+ let arr = ruby.ary_new();
81
+ for s in strings {
82
+ arr.push(ruby.str_new(s))
83
+ .map_err(|e| native_error(ruby, format!("array push error: {e}")))?;
84
+ }
85
+ Ok(arr)
86
+ }
87
+
88
+ /// Extracts an RArray of RString into a Vec<Vec<u8>>.
89
+ fn rarray_to_byte_vecs(ruby: &Ruby, arr: RArray) -> Result<Vec<Vec<u8>>, Error> {
90
+ let mut result = Vec::new();
91
+ for item in arr.into_iter() {
92
+ let s: RString = TryConvert::try_convert(item)
93
+ .map_err(|_| native_error(ruby, "array element is not a String".into()))?;
94
+ result.push(rstring_to_bytes(s));
95
+ }
96
+ Ok(result)
97
+ }
98
+
99
+ /// Extracts an RArray of `[String, String]` pairs into a map.
100
+ fn rarray_to_byte_pairs(ruby: &Ruby, arr: RArray) -> Result<HashMap<Vec<u8>, Vec<u8>>, Error> {
101
+ let mut result = HashMap::new();
102
+ for item in arr.into_iter() {
103
+ let pair: RArray = TryConvert::try_convert(item)
104
+ .map_err(|_| native_error(ruby, "referenced document is not a [uri, bytes] pair".into()))?;
105
+ let [uri, bytes] = rarray_to_byte_vecs(ruby, pair)?
106
+ .try_into()
107
+ .map_err(|_| native_error(ruby, "referenced document is not a [uri, bytes] pair".into()))?;
108
+ result.insert(uri, bytes);
109
+ }
110
+ Ok(result)
111
+ }
112
+
113
+ /// Extracts an RArray of RString into a Vec<String>.
114
+ fn rarray_to_strings(ruby: &Ruby, arr: RArray) -> Result<Vec<String>, Error> {
115
+ let mut result = Vec::new();
116
+ for item in arr.into_iter() {
117
+ let s: RString = TryConvert::try_convert(item)
118
+ .map_err(|_| native_error(ruby, "array element is not a String".into()))?;
119
+ result.push(
120
+ String::from_utf8(rstring_to_bytes(s))
121
+ .map_err(|_| native_error(ruby, "id_attribute is not valid UTF-8".into()))?,
122
+ );
123
+ }
124
+ Ok(result)
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Native functions — the §7 contract
129
+ // ---------------------------------------------------------------------------
130
+
131
+ /// `HankoXml::Native.sign(xml, key_pem, cert_pems, id_attributes, signature_index)`
132
+ ///
133
+ /// Signs the document's nth `<ds:Signature>` template and returns the
134
+ /// complete signed XML as a binary String.
135
+ fn native_sign(
136
+ ruby: &Ruby,
137
+ xml: RString,
138
+ key_pem: RString,
139
+ cert_pems: RArray,
140
+ id_attributes: RArray,
141
+ signature_index: usize,
142
+ ) -> Result<RString, Error> {
143
+ ensure_initialized().map_err(|msg| native_error(ruby, msg.into()))?;
144
+
145
+ let xml_bytes = rstring_to_bytes(xml);
146
+ let key_bytes = rstring_to_bytes(key_pem);
147
+ let cert_bytes = rarray_to_byte_vecs(ruby, cert_pems)?;
148
+ let id_attrs = rarray_to_strings(ruby, id_attributes)?;
149
+
150
+ match dsig::sign_document(&xml_bytes, &key_bytes, &cert_bytes, &id_attrs, signature_index) {
151
+ Ok(signed_bytes) => Ok(ruby.str_from_slice(&signed_bytes)),
152
+ Err(err) => Err(dsig_error(ruby, err)),
153
+ }
154
+ }
155
+
156
+ /// `HankoXml::Native.verify(xml, trusted_pems, untrusted_pems, id_attributes, signature_index, referenced_documents)`
157
+ ///
158
+ /// Verifies the nth `<ds:Signature>` and returns a Hash:
159
+ /// `{ status:, errors:, reference_element_indexes: Array<Integer|nil>,
160
+ /// reference_valid: Array<true|false|nil> }`
161
+ ///
162
+ /// `reference_element_indexes` is the document-order element index of each
163
+ /// SignedInfo Reference's target as resolved by libxml2's ID table (the same
164
+ /// table xmlsec used for `#id` verification). Empty-URI references report the
165
+ /// root. Unresolved / non-fragment URIs are `nil`.
166
+ ///
167
+ /// `reference_valid` is each SignedInfo Reference's own digest verification
168
+ /// status, same order, independent of the overall signature/key outcome
169
+ /// (S-09): a harmless-fragment Reference and the payload Reference can
170
+ /// genuinely disagree. `nil` means the reference was never processed
171
+ /// (verification aborted before reaching it).
172
+ ///
173
+ /// Only raises for conditions that are not a verification verdict.
174
+ #[allow(
175
+ clippy::too_many_arguments,
176
+ reason = "one positional argument per Native.verify parameter (API_CONTRACT §7)"
177
+ )]
178
+ fn native_verify(
179
+ ruby: &Ruby,
180
+ xml: RString,
181
+ trusted_pems: RArray,
182
+ untrusted_pems: RArray,
183
+ id_attributes: RArray,
184
+ signature_index: usize,
185
+ referenced_documents: RArray,
186
+ allow_xpath_filter2: bool,
187
+ ) -> Result<RHash, Error> {
188
+ ensure_initialized().map_err(|msg| native_error(ruby, msg.into()))?;
189
+
190
+ let xml_bytes = rstring_to_bytes(xml);
191
+ let trusted = rarray_to_byte_vecs(ruby, trusted_pems)?;
192
+ let untrusted = rarray_to_byte_vecs(ruby, untrusted_pems)?;
193
+ let id_attrs = rarray_to_strings(ruby, id_attributes)?;
194
+ let referenced = rarray_to_byte_pairs(ruby, referenced_documents)?;
195
+
196
+ match dsig::verify_document(
197
+ &xml_bytes,
198
+ &trusted,
199
+ &untrusted,
200
+ &id_attrs,
201
+ signature_index,
202
+ referenced,
203
+ allow_xpath_filter2,
204
+ ) {
205
+ Ok(result) => {
206
+ let hash = ruby.hash_new();
207
+ let status_sym = ruby.to_symbol(result.status.as_str());
208
+ hash.aset(ruby.to_symbol("status"), status_sym)
209
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
210
+
211
+ let errors_array = string_slice_to_rarray(ruby, &result.errors)?;
212
+ hash.aset(ruby.to_symbol("errors"), errors_array)
213
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
214
+
215
+ let indexes = ruby.ary_new();
216
+ for entry in &result.reference_element_indexes {
217
+ let value = match entry {
218
+ Some(i) => ruby.integer_from_i64(*i as i64).as_value(),
219
+ None => ruby.qnil().as_value(),
220
+ };
221
+ indexes
222
+ .push(value)
223
+ .map_err(|e| native_error(ruby, format!("array error: {e}")))?;
224
+ }
225
+ hash.aset(ruby.to_symbol("reference_element_indexes"), indexes)
226
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
227
+
228
+ let reference_valid = ruby.ary_new();
229
+ for entry in &result.reference_valid {
230
+ let value = match entry {
231
+ Some(valid) => valid.into_value_with(ruby),
232
+ None => ruby.qnil().as_value(),
233
+ };
234
+ reference_valid
235
+ .push(value)
236
+ .map_err(|e| native_error(ruby, format!("array error: {e}")))?;
237
+ }
238
+ hash.aset(ruby.to_symbol("reference_valid"), reference_valid)
239
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
240
+
241
+ let trusted_index = match result.trusted_index {
242
+ Some(i) => ruby.integer_from_i64(i as i64).as_value(),
243
+ None => ruby.qnil().as_value(),
244
+ };
245
+ hash.aset(ruby.to_symbol("trusted_index"), trusted_index)
246
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
247
+
248
+ Ok(hash)
249
+ }
250
+ Err(err) => Err(dsig_error(ruby, err)),
251
+ }
252
+ }
253
+
254
+ /// `HankoXml::Native.scan(xml, id_attributes, strict)`
255
+ ///
256
+ /// Returns the document's structure as a Hash, without cryptography:
257
+ /// `{ signature_count:, signature_values: Array<bool>,
258
+ /// signature_element_indexes: Array<Integer|nil>, duplicate_ids: bool,
259
+ /// root_ids: Array<String> }`. `strict` rejects any libxml2 error with a
260
+ /// `ParseError`, as a strict Nokogiri parse would (#102).
261
+ fn native_scan(ruby: &Ruby, xml: RString, id_attributes: RArray, strict: bool) -> Result<RHash, Error> {
262
+ ensure_initialized().map_err(|msg| native_error(ruby, msg.into()))?;
263
+
264
+ let xml_bytes = rstring_to_bytes(xml);
265
+ let id_attrs = rarray_to_strings(ruby, id_attributes)?;
266
+
267
+ let result = dsig::scan_document(&xml_bytes, &id_attrs, strict).map_err(|err| dsig_error(ruby, err))?;
268
+ let hash = ruby.hash_new();
269
+ let set = |key: &str, value: Value| {
270
+ hash.aset(ruby.to_symbol(key), value)
271
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))
272
+ };
273
+ set("signature_count", ruby.integer_from_i64(result.signature_count as i64).as_value())?;
274
+ let values = ruby.ary_new();
275
+ for present in result.signature_values {
276
+ values.push(present).map_err(|e| native_error(ruby, format!("array error: {e}")))?;
277
+ }
278
+ set("signature_values", values.as_value())?;
279
+ let indexes = ruby.ary_new();
280
+ for entry in result.signature_element_indexes {
281
+ let value = match entry {
282
+ Some(i) => ruby.integer_from_i64(i as i64).as_value(),
283
+ None => ruby.qnil().as_value(),
284
+ };
285
+ indexes.push(value).map_err(|e| native_error(ruby, format!("array error: {e}")))?;
286
+ }
287
+ set("signature_element_indexes", indexes.as_value())?;
288
+ set("duplicate_ids", result.duplicate_ids.into_value_with(ruby))?;
289
+ set("root_ids", string_slice_to_rarray(ruby, &result.root_ids)?.as_value())?;
290
+ Ok(hash)
291
+ }
292
+
293
+ /// `HankoXml::Native.validate_schema(xml, signature_index, xsd_path)`
294
+ ///
295
+ /// Validates a copy of the nth `<ds:Signature>` against the XSD at
296
+ /// `xsd_path`. Returns libxml2's validation errors; empty means valid.
297
+ fn native_validate_schema(
298
+ ruby: &Ruby,
299
+ xml: RString,
300
+ signature_index: usize,
301
+ xsd_path: String,
302
+ ) -> Result<RArray, Error> {
303
+ ensure_initialized().map_err(|msg| native_error(ruby, msg.into()))?;
304
+
305
+ let xml_bytes = rstring_to_bytes(xml);
306
+ let messages = dsig::validate_signature_schema(&xml_bytes, signature_index, &xsd_path)
307
+ .map_err(|err| dsig_error(ruby, err))?;
308
+ string_slice_to_rarray(ruby, &messages)
309
+ }
310
+
311
+ /// `HankoXml::Native.inspect_sig(xml, signature_index)`
312
+ ///
313
+ /// Returns metadata about the nth `<ds:Signature>` without performing
314
+ /// any cryptography. Used by Policy enforcement before verify/sign.
315
+ fn native_inspect(
316
+ ruby: &Ruby,
317
+ xml: RString,
318
+ signature_index: usize,
319
+ ) -> Result<RHash, Error> {
320
+ ensure_initialized().map_err(|msg| native_error(ruby, msg.into()))?;
321
+
322
+ let xml_bytes = rstring_to_bytes(xml);
323
+
324
+ errors::clear_errors();
325
+ match dsig::inspect_document(&xml_bytes, signature_index) {
326
+ Ok(result) => {
327
+ let hash = ruby.hash_new();
328
+
329
+ hash.aset(
330
+ ruby.to_symbol("signature_method"),
331
+ ruby.str_new(&result.signature_method),
332
+ )
333
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
334
+
335
+ hash.aset(
336
+ ruby.to_symbol("canonicalization_method"),
337
+ ruby.str_new(&result.canonicalization_method),
338
+ )
339
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
340
+
341
+ hash.aset(
342
+ ruby.to_symbol("signature_count"),
343
+ result.signature_count,
344
+ )
345
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
346
+
347
+ // key_names array
348
+ let key_names = string_slice_to_rarray(ruby, &result.key_names)?;
349
+ hash.aset(ruby.to_symbol("key_names"), key_names)
350
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
351
+
352
+ // certificates array
353
+ let certs = string_slice_to_rarray(ruby, &result.certificates)?;
354
+ hash.aset(ruby.to_symbol("certificates"), certs)
355
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
356
+
357
+ // references array of hashes
358
+ let refs = ruby.ary_new();
359
+ for r in &result.references {
360
+ let ref_hash = ruby.hash_new();
361
+ ref_hash
362
+ .aset(ruby.to_symbol("uri"), ruby.str_new(&r.uri))
363
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
364
+ ref_hash
365
+ .aset(ruby.to_symbol("digest_method"), ruby.str_new(&r.digest_method))
366
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
367
+
368
+ let transforms = string_slice_to_rarray(ruby, &r.transforms)?;
369
+ ref_hash
370
+ .aset(ruby.to_symbol("transforms"), transforms)
371
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
372
+
373
+ ref_hash
374
+ .aset(
375
+ ruby.to_symbol("id"),
376
+ r.id.as_ref()
377
+ .map(|s| ruby.str_new(s).as_value())
378
+ .unwrap_or_else(|| ruby.qnil().as_value()),
379
+ )
380
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
381
+
382
+ ref_hash
383
+ .aset(
384
+ ruby.to_symbol("type"),
385
+ r.r#type
386
+ .as_ref()
387
+ .map(|s| ruby.str_new(s).as_value())
388
+ .unwrap_or_else(|| ruby.qnil().as_value()),
389
+ )
390
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
391
+
392
+ let filters = ruby.ary_new();
393
+ for f in &r.xpath_filters {
394
+ let filter = ruby.hash_new();
395
+ let namespaces = ruby.hash_new();
396
+ for (prefix, href) in &f.namespaces {
397
+ namespaces
398
+ .aset(ruby.str_new(prefix), ruby.str_new(href))
399
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
400
+ }
401
+ for (key, value) in [
402
+ ("filter", ruby.str_new(&f.filter).as_value()),
403
+ ("expression", ruby.str_new(&f.expression).as_value()),
404
+ ("namespaces", namespaces.as_value()),
405
+ ] {
406
+ filter
407
+ .aset(ruby.to_symbol(key), value)
408
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
409
+ }
410
+ filters.push(filter).map_err(|e| native_error(ruby, format!("array error: {e}")))?;
411
+ }
412
+ ref_hash
413
+ .aset(ruby.to_symbol("xpath_filters"), filters)
414
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
415
+
416
+ refs.push(ref_hash)
417
+ .map_err(|e| native_error(ruby, format!("array error: {e}")))?;
418
+ }
419
+ hash.aset(ruby.to_symbol("references"), refs)
420
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
421
+
422
+ hash.aset(
423
+ ruby.to_symbol("has_retrieval_method"),
424
+ result.has_retrieval_method.into_value_with(ruby),
425
+ )
426
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
427
+ for (key, value) in [
428
+ ("empty_key_names", result.empty_key_names),
429
+ ("key_derived_values", result.key_derived_values),
430
+ ] {
431
+ hash.aset(ruby.to_symbol(key), value.into_value_with(ruby))
432
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
433
+ }
434
+
435
+ Ok(hash)
436
+ }
437
+ Err(err) => Err(dsig_error(ruby, err)),
438
+ }
439
+ }
440
+
441
+ /// `HankoXml::Native.versions`
442
+ ///
443
+ /// Returns `{ xmlsec: "1.3.12", libxml2: "2.15.3", crypto: "openssl" }`.
444
+ fn native_versions(ruby: &Ruby) -> Result<RHash, Error> {
445
+ ensure_initialized().map_err(|msg| native_error(ruby, msg.into()))?;
446
+
447
+ let (xmlsec, libxml2, crypto) = dsig::versions();
448
+ let hash = ruby.hash_new();
449
+ hash.aset(ruby.to_symbol("xmlsec"), ruby.str_new(&xmlsec))
450
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
451
+ hash.aset(ruby.to_symbol("libxml2"), ruby.str_new(&libxml2))
452
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
453
+ hash.aset(ruby.to_symbol("crypto"), ruby.str_new(&crypto))
454
+ .map_err(|e| native_error(ruby, format!("hash error: {e}")))?;
455
+ Ok(hash)
456
+ }
457
+
458
+ /// `HankoXml::Native.check_version` — kept from the POC.
459
+ ///
460
+ /// Exact-match check: true only when the loaded libxmlsec1 is the same
461
+ /// major.minor.patch these bindings were generated against.
462
+ fn native_check_version() -> bool {
463
+ dsig::check_version()
464
+ }
465
+
466
+ /// `HankoXml::Native.abi_compatible?`
467
+ ///
468
+ /// True when the loaded libxmlsec1 is ABI compatible with the headers this
469
+ /// extension was compiled against. `HankoXml::NativeVersion` calls this at
470
+ /// require time; see [`dsig::abi_compatible`] for why it has to be fatal.
471
+ fn native_abi_compatible() -> bool {
472
+ dsig::abi_compatible()
473
+ }
474
+
475
+ #[magnus::init]
476
+ fn init(ruby: &Ruby) -> Result<(), Error> {
477
+ let module = ruby.define_module("HankoXml")?;
478
+ let native: RModule = module.define_module("Native")?;
479
+
480
+ native.define_singleton_method("check_version", function!(native_check_version, 0))?;
481
+ native.define_singleton_method("abi_compatible?", function!(native_abi_compatible, 0))?;
482
+ native.define_singleton_method("versions", function!(native_versions, 0))?;
483
+ native.define_singleton_method("sign", function!(native_sign, 5))?;
484
+ native.define_singleton_method("verify", function!(native_verify, 7))?;
485
+ native.define_singleton_method("inspect_sig", function!(native_inspect, 2))?;
486
+ native.define_singleton_method("scan", function!(native_scan, 3))?;
487
+ native.define_singleton_method("validate_schema", function!(native_validate_schema, 3))?;
488
+
489
+ Ok(())
490
+ }
@@ -0,0 +1,43 @@
1
+ //! Raw FFI bindings generated by bindgen from the xmlsec1/libxml2 headers,
2
+ //! plus thin aliases for the enum constants whose generated names are
3
+ //! unreadable (§7.2).
4
+ //!
5
+ //! Nothing in here is safe to call directly — every call site must uphold
6
+ //! libxmlsec1's own invariants (init-before-use, non-null docs/nodes,
7
+ //! matching create/destroy pairs, etc). [`crate::dsig`] is the only module
8
+ //! that calls into this one; everything *it* exposes is safe.
9
+
10
+ #![allow(
11
+ non_upper_case_globals,
12
+ non_camel_case_types,
13
+ non_snake_case,
14
+ dead_code,
15
+ clippy::upper_case_acronyms
16
+ )]
17
+
18
+ include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
19
+
20
+ // --- Readable aliases for bindgen's unreadable enum constant names ------
21
+
22
+ /// PEM key data format (`xmlSecKeyDataFormatPem`).
23
+ pub const KEY_FORMAT_PEM: xmlSecKeyDataFormat =
24
+ xmlSecKeyDataFormat_xmlSecKeyDataFormatPem;
25
+
26
+ /// PEM certificate format (`xmlSecKeyDataFormatCertPem`).
27
+ pub const KEY_FORMAT_CERT_PEM: xmlSecKeyDataFormat =
28
+ xmlSecKeyDataFormat_xmlSecKeyDataFormatCertPem;
29
+
30
+ /// Trusted key data type (`xmlSecKeyDataTypeTrusted`).
31
+ pub const KEY_DATA_TYPE_TRUSTED: xmlSecKeyDataType = xmlSecKeyDataTypeTrusted;
32
+
33
+ /// DSig verification succeeded.
34
+ pub const DSIG_STATUS_SUCCEEDED: xmlSecDSigStatus =
35
+ xmlSecDSigStatus_xmlSecDSigStatusSucceeded;
36
+
37
+ /// DSig verification found an invalid signature.
38
+ pub const DSIG_STATUS_INVALID: xmlSecDSigStatus =
39
+ xmlSecDSigStatus_xmlSecDSigStatusInvalid;
40
+
41
+ /// Same-document Reference URIs: empty `URI=""` and `URI="#id"`.
42
+ pub const URI_TYPE_EMPTY: xmlSecTransformUriType = xmlSecTransformUriTypeEmpty;
43
+ pub const URI_TYPE_SAME_DOCUMENT: xmlSecTransformUriType = xmlSecTransformUriTypeSameDocument;