Skip to main content

jacs_binding_core/
simple_wrapper.rs

1//! `SimpleAgentWrapper` — thin FFI adapter over the narrow `SimpleAgent` contract.
2//!
3//! This module contains zero business logic. Every method delegates to
4//! `jacs::simple::SimpleAgent` and marshals the result to FFI-safe types
5//! (String in/out, base64 for bytes, JSON for structured data).
6
7use crate::{BindingCoreError, BindingResult, ErrorKind};
8use base64::{Engine as _, engine::general_purpose::STANDARD};
9use jacs::simple::SimpleAgent;
10use serde::Serialize;
11use std::sync::Arc;
12
13/// Thread-safe, Clone-able FFI wrapper around the narrow [`SimpleAgent`] contract.
14///
15/// All methods return `BindingResult<String>` (or simple scalars) so that
16/// language bindings (Python/PyO3, Node/NAPI, Go/CGo) never touch Rust-only types.
17#[derive(Clone)]
18pub struct SimpleAgentWrapper {
19    inner: Arc<SimpleAgent>,
20}
21
22// Compile-time proof of thread safety.
23const _: () = {
24    fn _assert<T: Send + Sync>() {}
25    let _ = _assert::<SimpleAgentWrapper>;
26};
27
28fn serialize_json<T: Serialize>(value: &T, context: &str) -> BindingResult<String> {
29    serde_json::to_string(value).map_err(|e| {
30        BindingCoreError::serialization_failed(format!("Failed to serialize {}: {}", context, e))
31    })
32}
33
34fn encode_base64(bytes: &[u8]) -> String {
35    STANDARD.encode(bytes)
36}
37
38fn decode_base64(input: &str, label: &str) -> BindingResult<Vec<u8>> {
39    STANDARD
40        .decode(input)
41        .map_err(|e| BindingCoreError::invalid_argument(format!("Invalid base64 {}: {}", label, e)))
42}
43
44fn conversion_error(operation: &str, err: impl std::fmt::Display) -> BindingCoreError {
45    BindingCoreError::new(
46        ErrorKind::SerializationFailed,
47        format!("{} failed: {}", operation, err),
48    )
49}
50
51impl SimpleAgentWrapper {
52    // WARNING: If you add or remove a public method here, update BOTH:
53    //   1. binding-core/tests/fixtures/method_parity.json  (canonical method list)
54    //   2. binding-core/tests/method_parity.rs::known_methods()  (compile-time anchor)
55    // All language bindings (Python, Node, Go) have parity tests against that fixture.
56
57    // =========================================================================
58    // Constructors
59    // =========================================================================
60
61    /// Create a new agent with persistent identity.
62    ///
63    /// Returns `(wrapper, info_json)` where `info_json` is a serialized
64    /// [`jacs::simple::AgentInfo`].
65    pub fn create(
66        name: &str,
67        purpose: Option<&str>,
68        key_algorithm: Option<&str>,
69    ) -> BindingResult<(Self, String)> {
70        let (agent, info) = SimpleAgent::create(name, purpose, key_algorithm)
71            .map_err(|e| BindingCoreError::agent_load(format!("Failed to create agent: {}", e)))?;
72        let info_json = crate::serialize_agent_info(&info)?;
73
74        Ok((Self::from_agent(agent), info_json))
75    }
76
77    /// Load an existing agent from a config file.
78    pub fn load(config_path: Option<&str>, strict: Option<bool>) -> BindingResult<Self> {
79        let (wrapper, _info_json) = Self::load_with_info(config_path, strict)?;
80        Ok(wrapper)
81    }
82
83    /// Load an existing agent from a config file and return canonical metadata.
84    pub fn load_with_info(
85        config_path: Option<&str>,
86        strict: Option<bool>,
87    ) -> BindingResult<(Self, String)> {
88        let requested_path = config_path.unwrap_or("./jacs.config.json");
89        let resolved_config_path = crate::resolve_existing_config_path(requested_path)?;
90        let agent = SimpleAgent::load(Some(&resolved_config_path), strict)
91            .map_err(|e| BindingCoreError::agent_load(format!("Failed to load agent: {}", e)))?;
92        let info = agent
93            .loaded_info()
94            .map_err(|e| BindingCoreError::agent_load(format!("Failed to load agent: {}", e)))?;
95        let info_json = crate::serialize_agent_info(&info)?;
96
97        Ok((Self::from_agent(agent), info_json))
98    }
99
100    /// Create an ephemeral (in-memory, throwaway) agent.
101    ///
102    /// Returns `(wrapper, info_json)`.
103    pub fn ephemeral(algorithm: Option<&str>) -> BindingResult<(Self, String)> {
104        let (agent, info) = SimpleAgent::ephemeral(algorithm).map_err(|e| {
105            BindingCoreError::agent_load(format!("Failed to create ephemeral agent: {}", e))
106        })?;
107        let info_json = crate::serialize_agent_info(&info)?;
108
109        Ok((Self::from_agent(agent), info_json))
110    }
111
112    /// Create an agent with full programmatic control via JSON parameters.
113    ///
114    /// `params_json` is a JSON string of [`CreateAgentParams`] fields.
115    /// The `storage` field is skipped during deserialization (use builder for that).
116    /// Returns `(wrapper, info_json)` where `info_json` is a serialized
117    /// [`jacs::simple::AgentInfo`].
118    pub fn create_with_params(params_json: &str) -> BindingResult<(Self, String)> {
119        let params: jacs::simple::CreateAgentParams =
120            serde_json::from_str(params_json).map_err(|e| {
121                BindingCoreError::invalid_argument(format!("Invalid CreateAgentParams JSON: {}", e))
122            })?;
123
124        let (agent, info) = SimpleAgent::create_with_params(params).map_err(|e| {
125            BindingCoreError::agent_load(format!("Failed to create agent with params: {}", e))
126        })?;
127        let info_json = crate::serialize_agent_info(&info)?;
128
129        Ok((Self::from_agent(agent), info_json))
130    }
131
132    /// Wrap an existing `SimpleAgent` in a `SimpleAgentWrapper`.
133    pub fn from_agent(agent: SimpleAgent) -> Self {
134        Self {
135            inner: Arc::new(agent),
136        }
137    }
138
139    /// Get a reference to the inner `SimpleAgent`.
140    ///
141    /// This is intended for advanced operations (attestation, reencrypt, etc.)
142    /// that need direct access to the underlying agent. Language bindings
143    /// should prefer the wrapper methods for the narrow contract.
144    pub fn inner_ref(&self) -> &SimpleAgent {
145        &self.inner
146    }
147
148    // =========================================================================
149    // Identity / Introspection
150    // =========================================================================
151
152    /// Get the agent's unique ID.
153    pub fn get_agent_id(&self) -> BindingResult<String> {
154        self.inner
155            .get_agent_id()
156            .map_err(|e| BindingCoreError::generic(format!("Failed to get agent ID: {}", e)))
157    }
158
159    /// Get the JACS key ID (signing key identifier).
160    pub fn key_id(&self) -> BindingResult<String> {
161        self.inner
162            .key_id()
163            .map_err(|e| BindingCoreError::generic(format!("Failed to get key ID: {}", e)))
164    }
165
166    /// Whether the agent is in strict mode.
167    pub fn is_strict(&self) -> bool {
168        self.inner.is_strict()
169    }
170
171    /// Config file path, if loaded from disk.
172    pub fn config_path(&self) -> Option<String> {
173        self.inner.config_path().map(|s| s.to_string())
174    }
175
176    /// Export the agent's identity JSON for P2P exchange.
177    pub fn export_agent(&self) -> BindingResult<String> {
178        self.inner
179            .export_agent()
180            .map_err(|e| BindingCoreError::generic(format!("Failed to export agent: {}", e)))
181    }
182
183    /// Get the public key as a PEM string.
184    pub fn get_public_key_pem(&self) -> BindingResult<String> {
185        self.inner.get_public_key_pem().map_err(|e| {
186            BindingCoreError::key_not_found(format!("Failed to get public key PEM: {}", e))
187        })
188    }
189
190    /// Get the public key as base64-encoded raw bytes (FFI-safe).
191    pub fn get_public_key_base64(&self) -> BindingResult<String> {
192        let bytes = self.inner.get_public_key().map_err(|e| {
193            BindingCoreError::key_not_found(format!("Failed to get public key: {}", e))
194        })?;
195        Ok(encode_base64(&bytes))
196    }
197
198    /// Runtime diagnostic info as a JSON string.
199    pub fn diagnostics(&self) -> String {
200        self.inner.diagnostics().to_string()
201    }
202
203    // =========================================================================
204    // W3C AI Agent Protocol interop
205    // =========================================================================
206
207    /// Export this agent's did:wba identifier.
208    pub fn export_w3c_did(&self, origin: Option<&str>) -> BindingResult<String> {
209        jacs::simple::w3c::export_w3c_did_identifier_with_origin(&self.inner, origin)
210            .map_err(|e| BindingCoreError::generic(format!("Failed to export W3C DID: {}", e)))
211    }
212
213    /// Export this agent's did:wba DID document as JSON.
214    pub fn export_w3c_did_document_json(&self, origin: Option<&str>) -> BindingResult<String> {
215        let document =
216            jacs::simple::w3c::export_w3c_did_document(&self.inner, origin).map_err(|e| {
217                BindingCoreError::generic(format!("Failed to export W3C DID document: {}", e))
218            })?;
219        serialize_json(&document, "W3C DID document")
220    }
221
222    /// Export this agent's W3C agent description as JSON.
223    pub fn export_w3c_agent_description_json(&self, origin: Option<&str>) -> BindingResult<String> {
224        let description = jacs::simple::w3c::export_w3c_agent_description(&self.inner, origin)
225            .map_err(|e| {
226                BindingCoreError::generic(format!("Failed to export W3C agent description: {}", e))
227            })?;
228        serialize_json(&description, "W3C agent description")
229    }
230
231    /// Generate W3C well-known discovery documents as a JSON object keyed by path.
232    pub fn generate_w3c_well_known_json(&self, origin: Option<&str>) -> BindingResult<String> {
233        let documents =
234            jacs::simple::w3c::generate_w3c_well_known(&self.inner, origin).map_err(|e| {
235                BindingCoreError::generic(format!(
236                    "Failed to generate W3C well-known documents: {}",
237                    e
238                ))
239            })?;
240        let mut by_path = serde_json::Map::new();
241        for (path, value) in documents {
242            by_path.insert(path, value);
243        }
244        serialize_json(
245            &serde_json::Value::Object(by_path),
246            "W3C well-known documents",
247        )
248    }
249
250    /// Sign a request-bound DID authentication proof.
251    ///
252    /// `params_json` is a JSON string of `W3cRequestProofParams`.
253    pub fn sign_w3c_request_json(&self, params_json: &str) -> BindingResult<String> {
254        let params: jacs::w3c::W3cRequestProofParams =
255            serde_json::from_str(params_json).map_err(|e| {
256                BindingCoreError::invalid_argument(format!(
257                    "Invalid W3C request proof params JSON: {}",
258                    e
259                ))
260            })?;
261        let proof = jacs::simple::w3c::sign_w3c_request(&self.inner, params).map_err(|e| {
262            BindingCoreError::signing_failed(format!("Failed to sign W3C request proof: {}", e))
263        })?;
264        serialize_json(&proof, "W3C request proof")
265    }
266
267    /// Verify a request-bound DID authentication proof.
268    pub fn verify_w3c_request_json(
269        &self,
270        proof_json: &str,
271        did_document_json: &str,
272        body: Option<&str>,
273        max_age_seconds: u64,
274        expected_method: Option<&str>,
275        expected_url: Option<&str>,
276    ) -> BindingResult<String> {
277        let result = jacs::simple::w3c::verify_w3c_request(
278            &self.inner,
279            proof_json,
280            did_document_json,
281            body,
282            max_age_seconds,
283            expected_method,
284            expected_url,
285        )
286        .map_err(|e| {
287            BindingCoreError::verification_failed(format!(
288                "Failed to verify W3C request proof: {}",
289                e
290            ))
291        })?;
292        serialize_json(&result, "W3C request proof verification")
293    }
294
295    // =========================================================================
296    // Verification
297    // =========================================================================
298
299    /// Verify the agent's own document signature. Returns JSON `VerificationResult`.
300    pub fn verify_self(&self) -> BindingResult<String> {
301        let result = self.inner.verify_self().map_err(|e| {
302            BindingCoreError::verification_failed(format!("Verify self failed: {}", e))
303        })?;
304        serialize_json(&result, "VerificationResult")
305    }
306
307    /// Verify a signed document JSON string. Returns JSON `VerificationResult`.
308    pub fn verify_json(&self, signed_document: &str) -> BindingResult<String> {
309        let result = self.inner.verify(signed_document).map_err(|e| {
310            BindingCoreError::verification_failed(format!("Verification failed: {}", e))
311        })?;
312        serialize_json(&result, "VerificationResult")
313    }
314
315    /// Verify a signed document with an explicit public key (base64-encoded).
316    /// Returns JSON `VerificationResult`.
317    pub fn verify_with_key_json(
318        &self,
319        signed_document: &str,
320        public_key_base64: &str,
321    ) -> BindingResult<String> {
322        let key_bytes = decode_base64(public_key_base64, "public key")?;
323
324        let result = self
325            .inner
326            .verify_with_key(signed_document, key_bytes)
327            .map_err(|e| {
328                BindingCoreError::verification_failed(format!(
329                    "Verification with key failed: {}",
330                    e
331                ))
332            })?;
333        serialize_json(&result, "VerificationResult")
334    }
335
336    /// Verify a stored document by its ID (e.g., "uuid:version").
337    /// Returns JSON `VerificationResult`.
338    pub fn verify_by_id_json(&self, document_id: &str) -> BindingResult<String> {
339        let result = self.inner.verify_by_id(document_id).map_err(|e| {
340            BindingCoreError::verification_failed(format!("Verify by ID failed: {}", e))
341        })?;
342        serialize_json(&result, "VerificationResult")
343    }
344
345    // =========================================================================
346    // Signing
347    // =========================================================================
348
349    /// Sign a JSON message string. Returns the signed JACS document JSON.
350    pub fn sign_message_json(&self, data_json: &str) -> BindingResult<String> {
351        let value: serde_json::Value = serde_json::from_str(data_json).map_err(|e| {
352            BindingCoreError::invalid_argument(format!("Invalid JSON input: {}", e))
353        })?;
354
355        let signed = self
356            .inner
357            .sign_message(&value)
358            .map_err(|e| BindingCoreError::signing_failed(format!("Sign message failed: {}", e)))?;
359
360        Ok(signed.raw)
361    }
362
363    /// Sign raw bytes and return the signature as base64 (FFI-safe).
364    pub fn sign_raw_bytes_base64(&self, data: &[u8]) -> BindingResult<String> {
365        let sig_bytes = self.inner.sign_raw_bytes(data).map_err(|e| {
366            BindingCoreError::signing_failed(format!("Sign raw bytes failed: {}", e))
367        })?;
368        Ok(encode_base64(&sig_bytes))
369    }
370
371    /// Sign a file with optional content embedding.
372    /// Returns the signed JACS document JSON.
373    pub fn sign_file_json(&self, file_path: &str, embed: bool) -> BindingResult<String> {
374        let signed = self
375            .inner
376            .sign_file(file_path, embed)
377            .map_err(|e| BindingCoreError::signing_failed(format!("Sign file failed: {}", e)))?;
378        Ok(signed.raw)
379    }
380
381    // =========================================================================
382    // Agreement v2 (feature-gated protocol surface)
383    // =========================================================================
384
385    /// Create a standalone agreement v2 document from JSON input.
386    #[cfg(feature = "agreements")]
387    pub fn create_agreement_v2_json(&self, input_json: &str) -> BindingResult<String> {
388        let input: jacs::agreements::v2::CreateAgreementV2 = serde_json::from_str(input_json)
389            .map_err(|e| {
390                BindingCoreError::validation(format!(
391                    "{}: {}",
392                    crate::agreement_v2::CTX_INVALID_CREATE_INPUT,
393                    e
394                ))
395            })?;
396        let signed = jacs::agreements::v2::create(&self.inner, input).map_err(|e| {
397            BindingCoreError::agreement_failed(format!(
398                "{}: {}",
399                crate::agreement_v2::CTX_CREATE,
400                e
401            ))
402        })?;
403        Ok(signed.raw)
404    }
405
406    /// Apply a JSON mutation to an agreement v2 document.
407    #[cfg(feature = "agreements")]
408    pub fn apply_agreement_v2_json(
409        &self,
410        document_json: &str,
411        mutation_json: &str,
412    ) -> BindingResult<String> {
413        let mutation: jacs::agreements::v2::AgreementV2Mutation =
414            serde_json::from_str(mutation_json).map_err(|e| {
415                BindingCoreError::validation(format!(
416                    "{}: {}",
417                    crate::agreement_v2::CTX_INVALID_MUTATION,
418                    e
419                ))
420            })?;
421        let signed =
422            jacs::agreements::v2::apply(&self.inner, document_json, mutation).map_err(|e| {
423                BindingCoreError::agreement_failed(format!(
424                    "{}: {}",
425                    crate::agreement_v2::CTX_APPLY,
426                    e
427                ))
428            })?;
429        Ok(signed.raw)
430    }
431
432    /// Sign an agreement v2 document as signer, witness, or notary.
433    #[cfg(feature = "agreements")]
434    pub fn sign_agreement_v2_json(&self, document_json: &str, role: &str) -> BindingResult<String> {
435        let role = crate::agreement_v2::parse_agreement_v2_role(role)?;
436        let signed = jacs::agreements::v2::sign(&self.inner, document_json, role).map_err(|e| {
437            BindingCoreError::agreement_failed(format!("{}: {}", crate::agreement_v2::CTX_SIGN, e))
438        })?;
439        Ok(signed.raw)
440    }
441
442    /// Verify agreement v2 hashes, signature policy, role membership, and signatures.
443    #[cfg(feature = "agreements")]
444    pub fn verify_agreement_v2_json(&self, document_json: &str) -> BindingResult<String> {
445        let report = jacs::agreements::v2::verify(&self.inner, document_json).map_err(|e| {
446            BindingCoreError::verification_failed(format!(
447                "{}: {}",
448                crate::agreement_v2::CTX_VERIFY,
449                e
450            ))
451        })?;
452        serde_json::to_string(&report).map_err(|e| {
453            BindingCoreError::serialization_failed(format!(
454                "{}: {}",
455                crate::agreement_v2::CTX_SERIALIZE_VERIFY,
456                e
457            ))
458        })
459    }
460
461    /// Analyze whether two agreement v2 branches are auto-mergeable.
462    #[cfg(feature = "agreements")]
463    pub fn detect_agreement_v2_branch_conflict_json(
464        &self,
465        base_document_json: &str,
466        left_document_json: &str,
467        right_document_json: &str,
468    ) -> BindingResult<String> {
469        let analysis = jacs::agreements::v2::detect_branch_conflict(
470            base_document_json,
471            left_document_json,
472            right_document_json,
473        )
474        .map_err(|e| {
475            BindingCoreError::agreement_failed(format!(
476                "{}: {}",
477                crate::agreement_v2::CTX_DETECT,
478                e
479            ))
480        })?;
481        serde_json::to_string(&analysis).map_err(|e| {
482            BindingCoreError::serialization_failed(format!(
483                "{}: {}",
484                crate::agreement_v2::CTX_SERIALIZE_BRANCH,
485                e
486            ))
487        })
488    }
489
490    /// Auto-merge transcript-only agreement v2 branches.
491    #[cfg(feature = "agreements")]
492    pub fn merge_agreement_v2_transcript_branches_json(
493        &self,
494        base_document_json: &str,
495        left_document_json: &str,
496        right_document_json: &str,
497    ) -> BindingResult<String> {
498        let signed = jacs::agreements::v2::merge_transcript_branches(
499            &self.inner,
500            base_document_json,
501            left_document_json,
502            right_document_json,
503        )
504        .map_err(|e| {
505            BindingCoreError::agreement_failed(format!("{}: {}", crate::agreement_v2::CTX_MERGE, e))
506        })?;
507        Ok(signed.raw)
508    }
509
510    /// Resolve an agreement v2 branch conflict with an explicit mutation.
511    #[cfg(feature = "agreements")]
512    pub fn resolve_agreement_v2_branch_conflict_json(
513        &self,
514        base_document_json: &str,
515        previous_document_json: &str,
516        side_branch_document_json: &str,
517        mutation_json: &str,
518    ) -> BindingResult<String> {
519        let mutation: jacs::agreements::v2::AgreementV2Mutation =
520            serde_json::from_str(mutation_json).map_err(|e| {
521                BindingCoreError::validation(format!(
522                    "{}: {}",
523                    crate::agreement_v2::CTX_INVALID_RESOLUTION_MUTATION,
524                    e
525                ))
526            })?;
527        let signed = jacs::agreements::v2::resolve_branch_conflict(
528            &self.inner,
529            base_document_json,
530            previous_document_json,
531            side_branch_document_json,
532            mutation,
533        )
534        .map_err(|e| {
535            BindingCoreError::agreement_failed(format!(
536                "{}: {}",
537                crate::agreement_v2::CTX_RESOLVE,
538                e
539            ))
540        })?;
541        Ok(signed.raw)
542    }
543
544    // =========================================================================
545    // Format Conversion (stateless -- no agent lock needed)
546    // =========================================================================
547
548    /// Convert a JSON string to YAML.
549    pub fn to_yaml(&self, json_str: &str) -> BindingResult<String> {
550        jacs::convert::jacs_to_yaml(json_str).map_err(|e| conversion_error("to_yaml", e))
551    }
552
553    /// Convert a YAML string to pretty-printed JSON.
554    pub fn from_yaml(&self, yaml_str: &str) -> BindingResult<String> {
555        jacs::convert::yaml_to_jacs(yaml_str).map_err(|e| conversion_error("from_yaml", e))
556    }
557
558    /// Convert a JSON string to a self-contained HTML document.
559    pub fn to_html(&self, json_str: &str) -> BindingResult<String> {
560        jacs::convert::jacs_to_html(json_str).map_err(|e| conversion_error("to_html", e))
561    }
562
563    /// Extract JSON from an HTML document produced by `to_html`.
564    pub fn from_html(&self, html_str: &str) -> BindingResult<String> {
565        jacs::convert::html_to_jacs(html_str).map_err(|e| conversion_error("from_html", e))
566    }
567
568    // =========================================================================
569    // Key rotation
570    // =========================================================================
571
572    /// Rotate the agent's cryptographic keys.
573    ///
574    /// Optionally change the signing algorithm. Returns a JSON string of the
575    /// `RotationResult` (jacs_id, old_version, new_version, key hash, proof).
576    pub fn rotate_keys(&self, algorithm: Option<&str>) -> BindingResult<String> {
577        let result = jacs::simple::advanced::rotate(&self.inner, algorithm).map_err(|e| {
578            BindingCoreError::new(ErrorKind::Generic, format!("Key rotation failed: {}", e))
579        })?;
580        serialize_json(&result, "rotation result")
581    }
582
583    // =========================================================================
584    // Inline text + media signature methods (Task 05 + Task 06, PRD §4.1, §4.2)
585    // =========================================================================
586
587    /// Sign a text / markdown file in-place. PRD §4.1.
588    ///
589    /// `opts_json` accepts:
590    /// - `""` | `"null"` | `"{}"` — defaults (`backup: true`, `allow_duplicate: false`).
591    /// - `{"backup": false}` — disable .bak.
592    /// - `{"allow_duplicate": true}` — allow duplicate-signer no-op to still write.
593    ///
594    /// Returns a JSON string of [`jacs::simple::types::SignTextOutcome`].
595    pub fn sign_text_file_json(&self, path: &str, opts_json: &str) -> BindingResult<String> {
596        let opts = parse_sign_text_options(opts_json)?;
597        let outcome = jacs::simple::advanced::sign_text_file(&self.inner, path, opts)
598            .map_err(|e| map_jacs_err(e, "sign_text_file"))?;
599        serialize_json(&outcome, "sign_text_file outcome")
600    }
601
602    /// Verify a signed text file. PRD §4.1, §4.1.5.
603    ///
604    /// `opts_json` accepts:
605    /// - `""` | `"null"` | `"{}"` — strict=false, key_dir=None (permissive).
606    /// - `{"strict": true}` — strict mode (missing-signature returns Err).
607    /// - `{"keyDir": "/abs/path"}` — `--key-dir` override.
608    /// - `{"strict": true, "keyDir": "..."}` — both.
609    ///
610    /// Permissive returns JSON with a `status` discriminator. Strict mode on
611    /// an unsigned file returns `Err(BindingCoreError::missing_signature(path))`.
612    pub fn verify_text_file_json(&self, path: &str, opts_json: &str) -> BindingResult<String> {
613        let opts = parse_verify_options(opts_json)?;
614        let strict = opts.strict;
615        match jacs::simple::advanced::verify_text_file(&self.inner, path, opts) {
616            Ok(result) => serialize_verify_text_result(&result),
617            Err(jacs::error::JacsError::MissingSignature(p)) if strict => Err(
618                BindingCoreError::missing_signature(format!("no JACS signature found in {}", p)),
619            ),
620            // R-008: route to map_jacs_err so callers get precise error kinds
621            // (FileNotFound -> InvalidArgument, validation -> InvalidArgument,
622            // etc.) instead of every error collapsing to VerificationFailed.
623            Err(e) => Err(map_jacs_err(e, "verify_text_file")),
624        }
625    }
626
627    /// Sign an image (PNG/JPEG/WebP). PRD §4.2.
628    ///
629    /// `opts_json` accepts (all keys optional):
630    /// - `{"robust": bool}` — enable LSB embedding (PNG/JPEG only).
631    /// - `{"refuseOverwrite": bool}` — refuse if input already signed.
632    /// - `{"backup": bool}` — auto-backup before in-place writes (default true).
633    /// - `{"unsafeBakMode": 0o644}` — override the default 0o600 backup mode.
634    pub fn sign_image_json(
635        &self,
636        in_path: &str,
637        out_path: &str,
638        opts_json: &str,
639    ) -> BindingResult<String> {
640        let opts = parse_sign_image_options(opts_json)?;
641        let outcome = jacs::simple::advanced::sign_image(&self.inner, in_path, out_path, opts)
642            .map_err(|e| map_jacs_err(e, "sign_image"))?;
643        serialize_json(&outcome, "sign_image outcome")
644    }
645
646    /// Verify an image signature. PRD §4.2.
647    ///
648    /// `opts_json` accepts:
649    /// - `{"strict": bool}` — strict-mode missing-signature is an error.
650    /// - `{"keyDir": "/abs/path"}` — `--key-dir` override.
651    /// - `{"robust": bool}` — scan LSB channel as a fallback (default false).
652    pub fn verify_image_json(&self, path: &str, opts_json: &str) -> BindingResult<String> {
653        let opts = parse_verify_image_options(opts_json)?;
654        let strict = opts.base.strict;
655        match jacs::simple::advanced::verify_image(&self.inner, path, opts) {
656            Ok(result) => serialize_json(&result, "verify_image result"),
657            Err(jacs::error::JacsError::MissingSignature(p)) if strict => Err(
658                BindingCoreError::missing_signature(format!("no JACS signature found in {}", p)),
659            ),
660            // R-008: precise error kinds (see verify_text_file_json comment).
661            Err(e) => Err(map_jacs_err(e, "verify_image")),
662        }
663    }
664
665    /// Extract the JACS signature payload from an image. PRD §3.2.
666    ///
667    /// `opts_json` accepts:
668    /// - `{"rawPayload": bool}` (default false = decoded JSON)
669    /// - `{"scanRobust": bool}` / `{"scan_robust": bool}` (R-011, default
670    ///   false). When true, fall back to LSB scan if the metadata channel
671    ///   has no payload — mirrors `verify_image --robust` (PRD §4.2.4).
672    ///
673    /// Returns a JSON envelope `{ "present": bool, "payload": string | null }`.
674    pub fn extract_media_signature_json(
675        &self,
676        path: &str,
677        opts_json: &str,
678    ) -> BindingResult<String> {
679        let parsed = parse_extract_options(opts_json)?;
680        let opts = jacs::simple::types::ExtractMediaOptions {
681            scan_robust: parsed.scan_robust,
682        };
683        let result = if parsed.raw_payload {
684            jacs::simple::advanced::extract_media_signature_raw_with_options(path, opts)
685        } else {
686            jacs::simple::advanced::extract_media_signature_with_options(path, opts)
687        };
688        let payload = result.map_err(|e| map_jacs_err(e, "extract_media_signature"))?;
689        let envelope = serde_json::json!({
690            "present": payload.is_some(),
691            "payload": payload,
692        });
693        Ok(envelope.to_string())
694    }
695}
696
697// =============================================================================
698// Option parsing helpers
699// =============================================================================
700
701fn map_jacs_err(e: jacs::error::JacsError, op: &str) -> BindingCoreError {
702    use jacs::error::JacsError;
703    match e {
704        JacsError::MissingSignature(p) => BindingCoreError::missing_signature(p),
705        JacsError::ValidationError(msg) => BindingCoreError::invalid_argument(msg),
706        JacsError::FileNotFound { path } => {
707            BindingCoreError::invalid_argument(format!("file not found: {}", path))
708        }
709        JacsError::FileReadFailed { path, reason } => {
710            BindingCoreError::invalid_argument(format!("read {} failed: {}", path, reason))
711        }
712        JacsError::FileWriteFailed { path, reason } => BindingCoreError::new(
713            ErrorKind::Generic,
714            format!("write {} failed: {}", path, reason),
715        ),
716        other => BindingCoreError::new(ErrorKind::Generic, format!("{}: {}", op, other)),
717    }
718}
719
720fn opts_is_default(s: &str) -> bool {
721    let t = s.trim();
722    t.is_empty() || t == "null" || t == "{}"
723}
724
725fn parse_sign_text_options(opts_json: &str) -> BindingResult<jacs::simple::types::SignTextOptions> {
726    if opts_is_default(opts_json) {
727        return Ok(jacs::simple::types::SignTextOptions::default());
728    }
729    let v: serde_json::Value = serde_json::from_str(opts_json)
730        .map_err(|e| BindingCoreError::invalid_argument(format!("sign_text_file opts: {}", e)))?;
731    let mut o = jacs::simple::types::SignTextOptions::default();
732    if let Some(b) = v.get("backup").and_then(|x| x.as_bool()) {
733        o.backup = b;
734    }
735    if let Some(b) = v.get("allow_duplicate").and_then(|x| x.as_bool()) {
736        o.allow_duplicate = b;
737    }
738    if let Some(b) = v.get("allowDuplicate").and_then(|x| x.as_bool()) {
739        o.allow_duplicate = b;
740    }
741    // R-007: PRD §4.2.4b applies the unsafe_bak_mode override to text and
742    // image .bak files alike. Mirror parse_sign_image_options so language
743    // bindings can override the 0o600 default consistently.
744    if let Some(n) = v
745        .get("unsafeBakMode")
746        .or_else(|| v.get("unsafe_bak_mode"))
747        .and_then(|x| x.as_u64())
748    {
749        o.unsafe_bak_mode = Some(n as u32);
750    }
751    Ok(o)
752}
753
754fn parse_verify_options(opts_json: &str) -> BindingResult<jacs::inline::VerifyOptions> {
755    if opts_is_default(opts_json) {
756        return Ok(jacs::inline::VerifyOptions::default());
757    }
758    let v: serde_json::Value = serde_json::from_str(opts_json)
759        .map_err(|e| BindingCoreError::invalid_argument(format!("verify opts: {}", e)))?;
760    let strict = v.get("strict").and_then(|x| x.as_bool()).unwrap_or(false);
761    let key_dir = v
762        .get("keyDir")
763        .or_else(|| v.get("key_dir"))
764        .and_then(|x| x.as_str())
765        .map(std::path::PathBuf::from);
766    Ok(jacs::inline::VerifyOptions { strict, key_dir })
767}
768
769fn parse_sign_image_options(
770    opts_json: &str,
771) -> BindingResult<jacs::simple::types::SignImageOptions> {
772    if opts_is_default(opts_json) {
773        return Ok(jacs::simple::types::SignImageOptions::default());
774    }
775    let v: serde_json::Value = serde_json::from_str(opts_json)
776        .map_err(|e| BindingCoreError::invalid_argument(format!("sign_image opts: {}", e)))?;
777    let mut o = jacs::simple::types::SignImageOptions::default();
778    if let Some(b) = v.get("robust").and_then(|x| x.as_bool()) {
779        o.robust = b;
780    }
781    if let Some(b) = v
782        .get("refuseOverwrite")
783        .or_else(|| v.get("refuse_overwrite"))
784        .and_then(|x| x.as_bool())
785    {
786        o.refuse_overwrite = b;
787    }
788    if let Some(b) = v.get("backup").and_then(|x| x.as_bool()) {
789        o.backup = b;
790    }
791    if let Some(n) = v
792        .get("unsafeBakMode")
793        .or_else(|| v.get("unsafe_bak_mode"))
794        .and_then(|x| x.as_u64())
795    {
796        o.unsafe_bak_mode = Some(n as u32);
797    }
798    if let Some(s) = v
799        .get("formatHint")
800        .or_else(|| v.get("format_hint"))
801        .and_then(|x| x.as_str())
802    {
803        o.format_hint = Some(s.to_string());
804    }
805    Ok(o)
806}
807
808fn parse_verify_image_options(
809    opts_json: &str,
810) -> BindingResult<jacs::simple::types::VerifyImageOptions> {
811    if opts_is_default(opts_json) {
812        return Ok(jacs::simple::types::VerifyImageOptions::default());
813    }
814    let v: serde_json::Value = serde_json::from_str(opts_json)
815        .map_err(|e| BindingCoreError::invalid_argument(format!("verify_image opts: {}", e)))?;
816    let strict = v.get("strict").and_then(|x| x.as_bool()).unwrap_or(false);
817    let key_dir = v
818        .get("keyDir")
819        .or_else(|| v.get("key_dir"))
820        .and_then(|x| x.as_str())
821        .map(std::path::PathBuf::from);
822    let scan_robust = v
823        .get("robust")
824        .or_else(|| v.get("scan_robust"))
825        .and_then(|x| x.as_bool())
826        .unwrap_or(false);
827    Ok(jacs::simple::types::VerifyImageOptions {
828        base: jacs::inline::VerifyOptions { strict, key_dir },
829        scan_robust,
830    })
831}
832
833/// Parsed `extract_media_signature` options. Fields default to false so
834/// `parse_extract_options("{}")` matches `Default::default()`.
835#[derive(Debug, Clone, Copy, Default)]
836struct ParsedExtractOptions {
837    raw_payload: bool,
838    /// R-011: opt-in LSB scan fallback (mirrors verify_image --robust).
839    scan_robust: bool,
840}
841
842fn parse_extract_options(opts_json: &str) -> BindingResult<ParsedExtractOptions> {
843    if opts_is_default(opts_json) {
844        return Ok(ParsedExtractOptions::default());
845    }
846    let v: serde_json::Value = serde_json::from_str(opts_json).map_err(|e| {
847        BindingCoreError::invalid_argument(format!("extract_media_signature opts: {}", e))
848    })?;
849    let raw_payload = v
850        .get("rawPayload")
851        .or_else(|| v.get("raw_payload"))
852        .and_then(|x| x.as_bool())
853        .unwrap_or(false);
854    let scan_robust = v
855        .get("scanRobust")
856        .or_else(|| v.get("scan_robust"))
857        .or_else(|| v.get("robust"))
858        .and_then(|x| x.as_bool())
859        .unwrap_or(false);
860    Ok(ParsedExtractOptions {
861        raw_payload,
862        scan_robust,
863    })
864}
865
866fn serialize_verify_text_result(result: &jacs::inline::VerifyTextResult) -> BindingResult<String> {
867    use jacs::inline::{SignatureStatus, VerifyTextResult};
868    let v = match result {
869        VerifyTextResult::MissingSignature => {
870            serde_json::json!({"status": "missing_signature"})
871        }
872        VerifyTextResult::Malformed(detail) => {
873            serde_json::json!({"status": "malformed", "error": detail})
874        }
875        VerifyTextResult::Signed { signatures } => {
876            let entries: Vec<serde_json::Value> = signatures
877                .iter()
878                .map(|e| {
879                    let (status_str, error) = match &e.status {
880                        SignatureStatus::Valid => ("valid", None),
881                        SignatureStatus::InvalidSignature => ("invalid_signature", None),
882                        SignatureStatus::HashMismatch => ("hash_mismatch", None),
883                        SignatureStatus::KeyNotFound => ("key_not_found", None),
884                        SignatureStatus::UnsupportedAlgorithm => ("unsupported_algorithm", None),
885                        SignatureStatus::Malformed(s) => ("malformed", Some(s.clone())),
886                    };
887                    let mut o = serde_json::json!({
888                        "signer_id": e.signer_id,
889                        "algorithm": e.algorithm,
890                        "timestamp": e.timestamp,
891                        "status": status_str,
892                    });
893                    if let Some(err) = error {
894                        o["error"] = serde_json::Value::String(err);
895                    }
896                    o
897                })
898                .collect();
899            serde_json::json!({"status": "signed", "signatures": entries})
900        }
901    };
902    Ok(v.to_string())
903}
904
905// =============================================================================
906// Free functions for Go FFI (C-style calling convention friendly)
907// =============================================================================
908
909/// Sign a JSON message — free function for Go FFI.
910pub fn sign_message_json(wrapper: &SimpleAgentWrapper, data_json: &str) -> BindingResult<String> {
911    wrapper.sign_message_json(data_json)
912}
913
914/// Verify a signed document — free function for Go FFI.
915pub fn verify_json(wrapper: &SimpleAgentWrapper, signed_document: &str) -> BindingResult<String> {
916    wrapper.verify_json(signed_document)
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922
923    /// Create a wrapper for conversion tests. Conversion methods are stateless
924    /// so we only need a default wrapper (no agent loaded).
925    fn test_wrapper() -> SimpleAgentWrapper {
926        let (wrapper, _info) =
927            SimpleAgentWrapper::ephemeral(Some("ed25519")).expect("ephemeral agent");
928        wrapper
929    }
930
931    #[test]
932    fn to_yaml_valid_json_succeeds() {
933        let wrapper = test_wrapper();
934        let result = wrapper.to_yaml(r#"{"key": "value"}"#);
935        assert!(result.is_ok(), "to_yaml should succeed for valid JSON");
936        let yaml = result.unwrap();
937        assert!(yaml.contains("key"), "YAML should contain 'key'");
938        assert!(yaml.contains("value"), "YAML should contain 'value'");
939    }
940
941    #[test]
942    fn from_yaml_valid_yaml_succeeds() {
943        let wrapper = test_wrapper();
944        let result = wrapper.from_yaml("key: value\n");
945        assert!(result.is_ok(), "from_yaml should succeed for valid YAML");
946        let json = result.unwrap();
947        assert!(json.contains("\"key\""), "JSON should contain key");
948        assert!(json.contains("\"value\""), "JSON should contain value");
949    }
950
951    #[test]
952    fn to_html_valid_json_succeeds() {
953        let wrapper = test_wrapper();
954        let result = wrapper.to_html(r#"{"key": "value"}"#);
955        assert!(result.is_ok(), "to_html should succeed for valid JSON");
956        let html = result.unwrap();
957        assert!(html.contains("<!DOCTYPE html>"), "HTML should have DOCTYPE");
958        assert!(
959            html.contains(r#"id="jacs-data">"#),
960            "HTML should have jacs-data script tag"
961        );
962    }
963
964    #[test]
965    fn from_html_valid_html_succeeds() {
966        let wrapper = test_wrapper();
967        let json = r#"{"key": "value"}"#;
968        let html = wrapper.to_html(json).unwrap();
969        let result = wrapper.from_html(&html);
970        assert!(result.is_ok(), "from_html should succeed for valid HTML");
971        assert_eq!(result.unwrap(), json, "Extracted JSON should match input");
972    }
973
974    #[test]
975    fn yaml_round_trip_preserves_content() {
976        let wrapper = test_wrapper();
977        let json = r#"{"hello": "world", "count": 42}"#;
978        let yaml = wrapper.to_yaml(json).unwrap();
979        let back = wrapper.from_yaml(&yaml).unwrap();
980        let original: serde_json::Value = serde_json::from_str(json).unwrap();
981        let reconstituted: serde_json::Value = serde_json::from_str(&back).unwrap();
982        assert_eq!(
983            original, reconstituted,
984            "YAML round-trip should preserve content"
985        );
986    }
987
988    #[test]
989    fn html_round_trip_preserves_content() {
990        let wrapper = test_wrapper();
991        let json = r#"{"hello": "world", "count": 42}"#;
992        let html = wrapper.to_html(json).unwrap();
993        let back = wrapper.from_html(&html).unwrap();
994        assert_eq!(back, json, "HTML round-trip should preserve exact JSON");
995    }
996
997    #[test]
998    fn to_yaml_invalid_json_returns_serialization_failed() {
999        let wrapper = test_wrapper();
1000        let result = wrapper.to_yaml("{not valid json}");
1001        assert!(result.is_err(), "to_yaml should fail for invalid JSON");
1002        let err = result.unwrap_err();
1003        assert_eq!(
1004            err.kind,
1005            crate::ErrorKind::SerializationFailed,
1006            "Error should be SerializationFailed, got: {:?}",
1007            err.kind
1008        );
1009    }
1010
1011    #[test]
1012    fn from_yaml_invalid_yaml_returns_serialization_failed() {
1013        let wrapper = test_wrapper();
1014        let result = wrapper.from_yaml("{{{{ not yaml ::::");
1015        assert!(result.is_err(), "from_yaml should fail for invalid YAML");
1016        let err = result.unwrap_err();
1017        assert_eq!(
1018            err.kind,
1019            crate::ErrorKind::SerializationFailed,
1020            "Error should be SerializationFailed, got: {:?}",
1021            err.kind
1022        );
1023    }
1024
1025    #[test]
1026    fn from_html_no_script_tag_returns_serialization_failed() {
1027        let wrapper = test_wrapper();
1028        let result = wrapper.from_html("<html><body>No jacs data here</body></html>");
1029        assert!(
1030            result.is_err(),
1031            "from_html should fail without jacs-data tag"
1032        );
1033        let err = result.unwrap_err();
1034        assert_eq!(
1035            err.kind,
1036            crate::ErrorKind::SerializationFailed,
1037            "Error should be SerializationFailed, got: {:?}",
1038            err.kind
1039        );
1040    }
1041
1042    // ========================================================================
1043    // R-007: parse_sign_text_options must honour `unsafe_bak_mode` /
1044    // `unsafeBakMode` parity with parse_sign_image_options. Before the fix
1045    // the parser silently dropped the field — language bindings could not
1046    // override the default 0o600 backup mode for text files.
1047    // ========================================================================
1048
1049    #[test]
1050    fn parse_sign_text_options_honours_unsafe_bak_mode_snake_case() {
1051        let opts =
1052            parse_sign_text_options(r#"{"unsafe_bak_mode": 420}"#).expect("parse should succeed");
1053        assert_eq!(
1054            opts.unsafe_bak_mode,
1055            Some(420),
1056            "snake_case unsafe_bak_mode must round-trip"
1057        );
1058    }
1059
1060    #[test]
1061    fn parse_sign_text_options_honours_unsafe_bak_mode_camel_case() {
1062        let opts =
1063            parse_sign_text_options(r#"{"unsafeBakMode": 420}"#).expect("parse should succeed");
1064        assert_eq!(
1065            opts.unsafe_bak_mode,
1066            Some(420),
1067            "camelCase unsafeBakMode must round-trip"
1068        );
1069    }
1070
1071    #[test]
1072    fn parse_sign_text_options_default_unsafe_bak_mode_is_none() {
1073        let opts = parse_sign_text_options(r#"{"backup": true}"#).expect("parse should succeed");
1074        assert_eq!(
1075            opts.unsafe_bak_mode, None,
1076            "absent unsafe_bak_mode must remain None (uses 0o600 default at write time)"
1077        );
1078    }
1079
1080    #[test]
1081    fn parse_sign_text_options_combines_with_other_fields() {
1082        let opts = parse_sign_text_options(
1083            r#"{"backup": false, "allowDuplicate": true, "unsafeBakMode": 384}"#,
1084        )
1085        .expect("parse should succeed");
1086        assert!(!opts.backup);
1087        assert!(opts.allow_duplicate);
1088        assert_eq!(opts.unsafe_bak_mode, Some(384));
1089    }
1090
1091    // ========================================================================
1092    // R-008: verify_text_file_json and verify_image_json must use map_jacs_err
1093    // for non-MissingSignature errors instead of collapsing every JacsError to
1094    // ErrorKind::VerificationFailed. Test by feeding a non-existent path.
1095    // ========================================================================
1096
1097    #[test]
1098    fn verify_text_file_json_non_existent_path_returns_invalid_argument() {
1099        let wrapper = test_wrapper();
1100        let result =
1101            wrapper.verify_text_file_json("/tmp/jacs-binding-core-r008-does-not-exist.md", "{}");
1102        assert!(result.is_err(), "verify on non-existent path should fail");
1103        let err = result.unwrap_err();
1104        // map_jacs_err routes file-not-found to InvalidArgument (PRD §4.1.2
1105        // "validation taxonomy"). Before R-008 fix the wrapper collapsed
1106        // every error to VerificationFailed.
1107        assert_eq!(
1108            err.kind,
1109            crate::ErrorKind::InvalidArgument,
1110            "expected InvalidArgument for non-existent path, got: {:?}",
1111            err.kind
1112        );
1113    }
1114
1115    #[test]
1116    fn verify_image_json_non_existent_path_returns_invalid_argument() {
1117        let wrapper = test_wrapper();
1118        let result =
1119            wrapper.verify_image_json("/tmp/jacs-binding-core-r008-does-not-exist.png", "{}");
1120        assert!(result.is_err(), "verify on non-existent path should fail");
1121        let err = result.unwrap_err();
1122        assert_eq!(
1123            err.kind,
1124            crate::ErrorKind::InvalidArgument,
1125            "expected InvalidArgument for non-existent path, got: {:?}",
1126            err.kind
1127        );
1128    }
1129
1130    // ========================================================================
1131    // R-011: parse_extract_options must surface scan_robust under either
1132    // camelCase or snake_case (and the shorter alias `robust` for parity with
1133    // the verify-image options shape).
1134    // ========================================================================
1135
1136    #[test]
1137    fn parse_extract_options_default_has_no_robust_scan_or_raw() {
1138        let parsed = parse_extract_options("{}").expect("ok");
1139        assert!(!parsed.raw_payload);
1140        assert!(!parsed.scan_robust);
1141    }
1142
1143    #[test]
1144    fn parse_extract_options_honours_scan_robust_camel() {
1145        let parsed = parse_extract_options(r#"{"scanRobust": true}"#).expect("ok");
1146        assert!(parsed.scan_robust);
1147        assert!(!parsed.raw_payload);
1148    }
1149
1150    #[test]
1151    fn parse_extract_options_honours_scan_robust_snake() {
1152        let parsed = parse_extract_options(r#"{"scan_robust": true}"#).expect("ok");
1153        assert!(parsed.scan_robust);
1154    }
1155
1156    #[test]
1157    fn parse_extract_options_honours_short_robust_alias() {
1158        let parsed = parse_extract_options(r#"{"robust": true}"#).expect("ok");
1159        assert!(parsed.scan_robust);
1160    }
1161
1162    #[test]
1163    fn parse_extract_options_combines_raw_payload_and_scan_robust() {
1164        let parsed =
1165            parse_extract_options(r#"{"rawPayload": true, "scanRobust": true}"#).expect("ok");
1166        assert!(parsed.raw_payload);
1167        assert!(parsed.scan_robust);
1168    }
1169
1170    // ========================================================================
1171    // R-007 follow-up (verify thinness): the parser tests above prove that
1172    // `unsafe_bak_mode` populates `SignTextOptions`; the jacs-side test
1173    // `text_backup_unsafe_mode_override` proves the field, when populated by
1174    // a Rust caller, results in the right on-disk mode. What was NOT covered
1175    // is the END-TO-END contract through the binding-core JSON envelope:
1176    // `sign_text_file_json` with `{"unsafeBakMode": 0o644}` must produce a
1177    // `.bak` whose Unix mode is 0o644. This proves parser → wrapper → core →
1178    // disk in one shot, the way every PyO3 / NAPI / CGo binding actually
1179    // exercises it.
1180    // ========================================================================
1181
1182    #[test]
1183    #[cfg(unix)]
1184    fn sign_text_file_json_routes_unsafe_bak_mode_camel_to_disk() {
1185        use std::os::unix::fs::PermissionsExt;
1186        let wrapper = test_wrapper();
1187        let dir = tempfile::TempDir::new().expect("tempdir");
1188        let path = dir.path().join("doc.md");
1189        std::fs::write(&path, b"# Hello\n\nbody\n").expect("write fixture");
1190
1191        let outcome_json = wrapper
1192            .sign_text_file_json(
1193                path.to_str().unwrap(),
1194                r#"{"backup": true, "unsafeBakMode": 420}"#,
1195            )
1196            .expect("sign_text_file_json should succeed");
1197
1198        // 420 == 0o644
1199        let outcome: serde_json::Value =
1200            serde_json::from_str(&outcome_json).expect("outcome is JSON");
1201        let bak_path = outcome
1202            .get("backup_path")
1203            .and_then(|v| v.as_str())
1204            .expect("backup_path present");
1205        let mode = std::fs::metadata(bak_path)
1206            .expect("bak exists")
1207            .permissions()
1208            .mode()
1209            & 0o777;
1210        assert_eq!(
1211            mode, 0o644,
1212            "JSON envelope unsafeBakMode=420 must reach the on-disk .bak; got {:o}",
1213            mode
1214        );
1215    }
1216
1217    #[test]
1218    #[cfg(unix)]
1219    fn sign_text_file_json_default_is_owner_only() {
1220        use std::os::unix::fs::PermissionsExt;
1221        let wrapper = test_wrapper();
1222        let dir = tempfile::TempDir::new().expect("tempdir");
1223        let path = dir.path().join("doc.md");
1224        std::fs::write(&path, b"# Hello\n\nbody\n").expect("write fixture");
1225
1226        let outcome_json = wrapper
1227            .sign_text_file_json(path.to_str().unwrap(), "{}")
1228            .expect("sign_text_file_json default opts should succeed");
1229        let outcome: serde_json::Value =
1230            serde_json::from_str(&outcome_json).expect("outcome is JSON");
1231        let bak_path = outcome
1232            .get("backup_path")
1233            .and_then(|v| v.as_str())
1234            .expect("backup_path present");
1235        let mode = std::fs::metadata(bak_path)
1236            .expect("bak exists")
1237            .permissions()
1238            .mode()
1239            & 0o777;
1240        assert_eq!(
1241            mode, 0o600,
1242            "default .bak mode through JSON envelope must be 0o600; got {:o}",
1243            mode
1244        );
1245    }
1246
1247    // ========================================================================
1248    // R-008 follow-up: the existing tests prove file-not-found maps to
1249    // InvalidArgument. Add the symmetric case — a file-level malformed
1250    // signature block (BEGIN with no matching END) returns
1251    // ValidationError from `verify_text_file`, which `map_jacs_err` must
1252    // also route to InvalidArgument (NOT VerificationFailed and NOT
1253    // Generic). This locks in the per-block-vs-file-level error
1254    // taxonomy from PRD §4.1.2.
1255    // ========================================================================
1256
1257    #[test]
1258    fn verify_text_file_json_malformed_block_strict_returns_invalid_argument() {
1259        let wrapper = test_wrapper();
1260        let dir = tempfile::TempDir::new().expect("tempdir");
1261        let path = dir.path().join("malformed.md");
1262        // BEGIN sentinel with no END sentinel: file-level malformed per PRD §4.1.2.
1263        // In strict mode this escalates to Err(JacsError::ValidationError(...))
1264        // which map_jacs_err must route to InvalidArgument.
1265        std::fs::write(
1266            &path,
1267            b"# Doc\n\n-----BEGIN JACS SIGNATURE-----\nsigner: x\n",
1268        )
1269        .expect("write fixture");
1270
1271        let result = wrapper.verify_text_file_json(path.to_str().unwrap(), r#"{"strict": true}"#);
1272        assert!(
1273            result.is_err(),
1274            "strict verify on malformed block should fail with Err"
1275        );
1276        let err = result.unwrap_err();
1277        // map_jacs_err routes ValidationError -> InvalidArgument. Before R-008
1278        // fix this collapsed to VerificationFailed.
1279        assert_eq!(
1280            err.kind,
1281            crate::ErrorKind::InvalidArgument,
1282            "expected InvalidArgument for malformed-block, got: {:?} (msg: {})",
1283            err.kind,
1284            err.message
1285        );
1286    }
1287
1288    #[test]
1289    fn verify_text_file_json_malformed_block_permissive_returns_status() {
1290        let wrapper = test_wrapper();
1291        let dir = tempfile::TempDir::new().expect("tempdir");
1292        let path = dir.path().join("malformed_permissive.md");
1293        // Same fixture as above. Permissive returns Ok with status discriminator,
1294        // never escalating to Err — this proves the binding's permissive contract
1295        // is honoured for malformed files (per PRD §4.1.5).
1296        std::fs::write(
1297            &path,
1298            b"# Doc\n\n-----BEGIN JACS SIGNATURE-----\nsigner: x\n",
1299        )
1300        .expect("write fixture");
1301
1302        let result = wrapper
1303            .verify_text_file_json(path.to_str().unwrap(), "{}")
1304            .expect("permissive verify of malformed file must NOT error");
1305        let v: serde_json::Value = serde_json::from_str(&result).expect("result is JSON");
1306        let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1307        assert_eq!(
1308            status, "malformed",
1309            "permissive verify must report status=malformed; got JSON: {}",
1310            v
1311        );
1312    }
1313
1314    #[test]
1315    fn verify_text_file_json_unsigned_permissive_returns_ok_status() {
1316        let wrapper = test_wrapper();
1317        let dir = tempfile::TempDir::new().expect("tempdir");
1318        let path = dir.path().join("unsigned.md");
1319        std::fs::write(&path, b"# Plain\n\nno signatures here\n").expect("write fixture");
1320
1321        // Permissive mode: missing signature is NOT an error. Returns Ok with
1322        // a status discriminator that downstream callers can branch on. This
1323        // negative test pins the documented contract from §4.1.5.
1324        let result = wrapper
1325            .verify_text_file_json(path.to_str().unwrap(), "{}")
1326            .expect("permissive verify of unsigned file must NOT error");
1327        let v: serde_json::Value = serde_json::from_str(&result).expect("result is JSON");
1328        let status = v.get("status").and_then(|s| s.as_str()).unwrap_or("");
1329        assert_eq!(
1330            status, "missing_signature",
1331            "permissive verify of unsigned file must report status=missing_signature; got JSON: {}",
1332            v
1333        );
1334    }
1335
1336    #[test]
1337    fn verify_json_accepts_inline_signed_markdown_string() {
1338        let wrapper = test_wrapper();
1339        let dir = tempfile::TempDir::new().expect("tempdir");
1340        let path = dir.path().join("signed.md");
1341        std::fs::write(&path, b"# Plain\n\nsigned through the inline footer\n")
1342            .expect("write fixture");
1343
1344        wrapper
1345            .sign_text_file_json(path.to_str().unwrap(), r#"{"backup": false}"#)
1346            .expect("sign text");
1347        let signed_markdown = std::fs::read_to_string(&path).expect("read signed markdown");
1348        let result = wrapper
1349            .verify_json(&signed_markdown)
1350            .expect("verify_json must dispatch inline signed markdown");
1351        let v: serde_json::Value = serde_json::from_str(&result).expect("result is JSON");
1352
1353        assert_eq!(v["valid"], true);
1354        assert_eq!(v["data"]["verificationType"], "inline-text");
1355        assert_eq!(v["data"]["signatures"][0]["status"], "valid");
1356    }
1357}