Skip to main content

opentdf_protocol/
manifest.rs

1//! TDF Manifest structures
2//!
3//! This module contains the data structures for TDF manifests, including:
4//! - Payload information
5//! - Encryption configuration
6//! - Key access objects
7//! - Integrity information (segments and root signature)
8//! - Assertions (statement + binding)
9//!
10//! Note: Cryptographic operations (HMAC, policy binding generation) are in the crypto crate.
11
12use serde::{Deserialize, Serialize};
13
14/// The TDF spec version written into `schemaVersion` by every writer.
15pub const TDF_SPEC_VERSION: &str = "4.3.0";
16
17/// TDF manifest structure
18#[derive(Debug, Serialize, Deserialize)]
19pub struct TdfManifest {
20    pub payload: Payload,
21    #[serde(rename = "encryptionInformation")]
22    pub encryption_information: EncryptionInformation,
23    #[serde(rename = "schemaVersion", skip_serializing_if = "Option::is_none")]
24    pub schema_version: Option<String>,
25    /// OpenTDF specification version. Omitted by default so existing
26    /// manifests serialize unchanged; the `gguf-tdf/1` writer sets it.
27    #[serde(rename = "tdf_spec_version", skip_serializing_if = "Option::is_none")]
28    pub tdf_spec_version: Option<String>,
29    /// `gguf-tdf/1` hybrid index. Absent for every other TDF profile.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub gguf: Option<crate::GgufIndex>,
32    /// Optional top-level `assertions` array (spec: assertion.md). Empty
33    /// when absent; omitted from JSON when empty so existing manifests
34    /// serialize unchanged.
35    #[serde(
36        default,
37        deserialize_with = "null_as_empty_vec",
38        skip_serializing_if = "Vec::is_empty"
39    )]
40    pub assertions: Vec<Assertion>,
41}
42
43/// Accept `"assertions": null` (Jackson-style writers emit it for an empty
44/// list) as an empty vector. `#[serde(default)]` alone covers only a missing key.
45fn null_as_empty_vec<'de, D>(deserializer: D) -> Result<Vec<Assertion>, D::Error>
46where
47    D: serde::Deserializer<'de>,
48{
49    Ok(Option::<Vec<Assertion>>::deserialize(deserializer)?.unwrap_or_default())
50}
51
52/// A verifiable statement about the TDF or its payload (spec: assertion.md).
53///
54/// Field names and JSON shape match the OpenTDF spec and the Go SDK
55/// (`sdk/assertion.go`).
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57pub struct Assertion {
58    /// Unique identifier for this assertion within the manifest.
59    pub id: String,
60    /// Categorizes the assertion's purpose, e.g. `handling` or `other`.
61    #[serde(rename = "type")]
62    pub assertion_type: String,
63    /// What the assertion applies to: `tdo` or `payload`.
64    pub scope: String,
65    /// Whether the statement applies to `encrypted` or `unencrypted` data.
66    /// Optional in the spec (default `encrypted`); omitted when unset.
67    #[serde(rename = "appliesToState", skip_serializing_if = "Option::is_none")]
68    pub applies_to_state: Option<String>,
69    /// The assertion content (spec: assertion_statement.md).
70    pub statement: AssertionStatement,
71    /// Cryptographic binding of the assertion to this TDF
72    /// (spec: assertion_binding.md). Omitted when unsigned.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub binding: Option<AssertionBinding>,
75}
76
77/// The `statement` object of an assertion (spec: assertion_statement.md).
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct AssertionStatement {
80    /// How `value` is encoded: `json-structured`, `base64binary`, `string`, ...
81    #[serde(default, skip_serializing_if = "String::is_empty")]
82    pub format: String,
83    /// Optional URI identifying the schema of `value`.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub schema: Option<String>,
86    /// The assertion content. Kept as raw JSON (string *or* structured
87    /// object/array/number/bool) so it round-trips byte-for-byte for
88    /// JCS hashing and never gets re-typed.
89    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
90    pub value: serde_json::Value,
91}
92
93/// The `binding` object of an assertion (spec: assertion_binding.md).
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
95///
96/// Both fields default to empty so that `"binding": {}`, which the Go SDK
97/// emits for an unsigned assertion (non-pointer struct with `omitempty`
98/// string fields), parses and re-serializes unchanged.
99pub struct AssertionBinding {
100    /// Signature method, e.g. `jws`.
101    #[serde(default, skip_serializing_if = "String::is_empty")]
102    pub method: String,
103    /// The Base64URL-encoded signature (e.g. a JWS compact serialization).
104    #[serde(default, skip_serializing_if = "String::is_empty")]
105    pub signature: String,
106}
107
108/// Payload reference in TDF manifest
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct Payload {
111    #[serde(rename = "type")]
112    pub payload_type: String,
113    pub url: String,
114    pub protocol: String,
115    #[serde(rename = "isEncrypted")]
116    pub is_encrypted: bool,
117    #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")]
118    pub mime_type: Option<String>,
119    #[serde(rename = "tdf_spec_version", skip_serializing_if = "Option::is_none")]
120    pub tdf_spec_version: Option<String>,
121}
122
123impl Default for Payload {
124    fn default() -> Self {
125        Self {
126            payload_type: "reference".to_string(),
127            url: "0.payload".to_string(),
128            protocol: "zip".to_string(),
129            is_encrypted: true,
130            mime_type: Some("application/octet-stream".to_string()),
131            // Match go/Swift: version lives on root `schemaVersion`, not payload.
132            // Emitting payload.tdf_spec_version breaks strict readers (e.g. otdf-python
133            // ManifestPayload, which has no such field).
134            tdf_spec_version: None,
135        }
136    }
137}
138
139/// Encryption information in TDF manifest
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct EncryptionInformation {
142    #[serde(rename = "type")]
143    pub encryption_type: String,
144    #[serde(rename = "keyAccess")]
145    pub key_access: Vec<KeyAccess>,
146    pub method: EncryptionMethod,
147    #[serde(rename = "integrityInformation")]
148    pub integrity_information: IntegrityInformation,
149    pub policy: String,
150}
151
152/// Policy binding structure
153#[derive(Debug, Serialize, Deserialize, Clone)]
154pub struct PolicyBinding {
155    pub alg: String,
156    pub hash: String,
157}
158
159/// Key access type constants for TDF3
160pub mod key_access_type {
161    /// Standard RSA-wrapped key (RSA-OAEP)
162    pub const WRAPPED: &str = "wrapped";
163    /// EC-wrapped key (ECIES: ECDH + HKDF + AES-GCM)
164    pub const EC_WRAPPED: &str = "ec-wrapped";
165    /// Remote key access (key stored on KAS)
166    pub const REMOTE: &str = "remote";
167    /// Remote wrapped key access
168    pub const REMOTE_WRAPPED: &str = "remoteWrapped";
169}
170
171/// Key access object in manifest
172#[derive(Debug, Serialize, Deserialize, Clone)]
173pub struct KeyAccess {
174    #[serde(rename = "type")]
175    pub access_type: String,
176    pub url: String,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub kid: Option<String>,
179    /// Optional key split (share) identifier (spec: key_access_object.md `sid`).
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub sid: Option<String>,
182    pub protocol: String,
183    #[serde(rename = "wrappedKey")]
184    pub wrapped_key: String,
185    #[serde(rename = "policyBinding")]
186    pub policy_binding: PolicyBinding,
187    #[serde(rename = "encryptedMetadata", skip_serializing_if = "Option::is_none")]
188    pub encrypted_metadata: Option<String>,
189    #[serde(rename = "schemaVersion", skip_serializing_if = "Option::is_none")]
190    pub schema_version: Option<String>,
191    /// Ephemeral public key for EC-wrapped keys (PEM format)
192    #[serde(rename = "ephemeralPublicKey", skip_serializing_if = "Option::is_none")]
193    pub ephemeral_public_key: Option<String>,
194}
195
196impl KeyAccess {
197    /// Creates a new KeyAccess object with default values (RSA-wrapped)
198    pub fn new(url: String) -> Self {
199        KeyAccess {
200            access_type: key_access_type::WRAPPED.to_string(),
201            url,
202            kid: None,
203            sid: None,
204            protocol: "kas".to_string(),
205            wrapped_key: String::new(),
206            policy_binding: PolicyBinding {
207                alg: "HS256".to_string(),
208                hash: String::new(),
209            },
210            encrypted_metadata: None,
211            schema_version: Some("1.0".to_string()),
212            ephemeral_public_key: None,
213        }
214    }
215
216    /// Creates a new KeyAccess object for EC-wrapped keys
217    pub fn new_ec_wrapped(url: String) -> Self {
218        KeyAccess {
219            access_type: key_access_type::EC_WRAPPED.to_string(),
220            url,
221            kid: None,
222            sid: None,
223            protocol: "kas".to_string(),
224            wrapped_key: String::new(),
225            policy_binding: PolicyBinding {
226                alg: "HS256".to_string(),
227                hash: String::new(),
228            },
229            encrypted_metadata: None,
230            schema_version: Some("1.0".to_string()),
231            ephemeral_public_key: None,
232        }
233    }
234
235    /// Check if this key access uses EC wrapping
236    pub fn is_ec_wrapped(&self) -> bool {
237        self.access_type == key_access_type::EC_WRAPPED
238    }
239
240    /// Check if this key access uses RSA wrapping
241    pub fn is_rsa_wrapped(&self) -> bool {
242        self.access_type == key_access_type::WRAPPED
243    }
244
245    /// Set the ephemeral public key (for EC-wrapped keys)
246    pub fn set_ephemeral_public_key(&mut self, pem: String) {
247        self.ephemeral_public_key = Some(pem);
248    }
249}
250
251/// Encryption method configuration
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct EncryptionMethod {
254    pub algorithm: String,
255    /// Spec: required. Pre-4.3.0 Java manifests omit it; default false.
256    #[serde(rename = "isStreamable", default)]
257    pub is_streamable: bool,
258    /// Spec prose: required. Every streaming SDK carries per-segment IVs in
259    /// the payload and writes `""` or omits the field (Java), so it defaults
260    /// to empty on read.
261    #[serde(default)]
262    pub iv: String,
263}
264
265impl Default for EncryptionMethod {
266    fn default() -> Self {
267        Self {
268            algorithm: "AES-256-GCM".to_string(),
269            is_streamable: true,
270            iv: String::new(),
271        }
272    }
273}
274
275/// Integrity information including segments and root signature
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct IntegrityInformation {
278    #[serde(rename = "rootSignature")]
279    pub root_signature: RootSignature,
280    #[serde(rename = "segmentHashAlg")]
281    pub segment_hash_alg: String,
282    pub segments: Vec<Segment>,
283    #[serde(rename = "segmentSizeDefault")]
284    pub segment_size_default: u64,
285    #[serde(rename = "encryptedSegmentSizeDefault")]
286    pub encrypted_segment_size_default: u64,
287}
288
289impl Default for IntegrityInformation {
290    fn default() -> Self {
291        Self {
292            root_signature: RootSignature::default(),
293            segment_hash_alg: "GMAC".to_string(),
294            segments: Vec::new(),
295            segment_size_default: 1024 * 1024,                // 1MB
296            encrypted_segment_size_default: 1024 * 1024 + 28, // +IV+tag
297        }
298    }
299}
300
301impl IntegrityInformation {
302    /// Resolve every segment's `(plaintext_size, encrypted_size)`.
303    ///
304    /// Spec (integrity_information.md): `segmentSize` and
305    /// `encryptedSegmentSize` are optional on a segment and are inferred from
306    /// `segmentSizeDefault` / `encryptedSegmentSizeDefault` when omitted.
307    pub fn segment_sizes(&self) -> Vec<(u64, u64)> {
308        self.segments
309            .iter()
310            .map(|s| {
311                (
312                    s.segment_size.unwrap_or(self.segment_size_default),
313                    s.encrypted_segment_size
314                        .unwrap_or(self.encrypted_segment_size_default),
315                )
316            })
317            .collect()
318    }
319}
320
321/// Root signature for integrity verification
322#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct RootSignature {
324    pub alg: String,
325    pub sig: String,
326}
327
328impl Default for RootSignature {
329    fn default() -> Self {
330        Self {
331            alg: "HS256".to_string(),
332            sig: String::new(),
333        }
334    }
335}
336
337/// Segment information
338#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct Segment {
340    pub hash: String,
341    #[serde(rename = "segmentSize", skip_serializing_if = "Option::is_none")]
342    pub segment_size: Option<u64>,
343    #[serde(
344        rename = "encryptedSegmentSize",
345        skip_serializing_if = "Option::is_none"
346    )]
347    pub encrypted_segment_size: Option<u64>,
348}
349
350impl TdfManifest {
351    /// Creates a new TDF manifest with basic structure
352    pub fn new(payload_url: String, kas_url: String) -> Self {
353        TdfManifest {
354            payload: Payload {
355                payload_type: "reference".to_string(),
356                url: payload_url,
357                protocol: "zip".to_string(),
358                is_encrypted: true,
359                mime_type: Some("application/octet-stream".to_string()),
360                // Omit by default — go/Swift only set root schemaVersion.
361                tdf_spec_version: None,
362            },
363            encryption_information: EncryptionInformation {
364                encryption_type: "split".to_string(),
365                key_access: vec![KeyAccess::new(kas_url)],
366                method: EncryptionMethod {
367                    algorithm: "AES-256-GCM".to_string(),
368                    is_streamable: true,
369                    iv: String::new(),
370                },
371                integrity_information: IntegrityInformation {
372                    root_signature: RootSignature {
373                        alg: "HS256".to_string(),
374                        sig: String::new(),
375                    },
376                    segment_hash_alg: "GMAC".to_string(),
377                    segments: Vec::new(),
378                    segment_size_default: 1024 * 1024, // 1MB default
379                    encrypted_segment_size_default: 1024 * 1024 + 28, // +IV+tag
380                },
381                policy: String::new(),
382            },
383            schema_version: Some(TDF_SPEC_VERSION.to_string()),
384            tdf_spec_version: None,
385            gguf: None,
386            assertions: Vec::new(),
387        }
388    }
389
390    /// Resolve the spec version a peer wrote, in priority order:
391    /// root `schemaVersion` (what every SDK writes), then root `tdf_spec_version`
392    /// (spec prose), then `payload.tdf_spec_version` (spec JSON schema).
393    pub fn spec_version(&self) -> Option<&str> {
394        [
395            self.schema_version.as_deref(),
396            self.tdf_spec_version.as_deref(),
397            self.payload.tdf_spec_version.as_deref(),
398        ]
399        .into_iter()
400        .flatten()
401        .find(|v| !v.is_empty())
402    }
403
404    /// Set the policy for the manifest using a raw string
405    pub fn set_policy_raw(&mut self, policy: &str) {
406        use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
407        self.encryption_information.policy = BASE64.encode(policy);
408    }
409
410    /// Get the decoded policy from the manifest as a raw string
411    pub fn get_policy_raw(&self) -> Result<String, base64::DecodeError> {
412        use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
413        let bytes = BASE64.decode(&self.encryption_information.policy)?;
414        String::from_utf8(bytes)
415            .map_err(|err| base64::DecodeError::InvalidByte(err.utf8_error().valid_up_to(), 0))
416    }
417
418    /// Add a segment to the manifest
419    pub fn add_segment(
420        &mut self,
421        hash: String,
422        segment_size: Option<u64>,
423        encrypted_segment_size: Option<u64>,
424    ) {
425        self.encryption_information
426            .integrity_information
427            .segments
428            .push(Segment {
429                hash,
430                segment_size,
431                encrypted_segment_size,
432            });
433    }
434
435    /// Serialize to JSON
436    pub fn to_json(&self) -> Result<String, serde_json::Error> {
437        serde_json::to_string(self)
438    }
439
440    /// Deserialize from JSON
441    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
442        serde_json::from_str(json)
443    }
444}
445
446impl KeyAccess {
447    /// Set encrypted metadata
448    pub fn set_encrypted_metadata(&mut self, metadata: &str) {
449        use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
450        self.encrypted_metadata = Some(BASE64.encode(metadata));
451    }
452
453    /// Clear encrypted metadata
454    pub fn clear_encrypted_metadata(&mut self) {
455        self.encrypted_metadata = None;
456    }
457}
458
459#[cfg(test)]
460mod gguf_index_tests {
461    use super::*;
462
463    #[test]
464    fn gguf_index_round_trips_and_stays_absent_by_default() {
465        // Existing manifests must serialize unchanged: no new keys appear.
466        let plain = TdfManifest::new(
467            "0.payload".to_string(),
468            "https://kas.example.com".to_string(),
469        );
470        let value: serde_json::Value = serde_json::from_str(&plain.to_json().unwrap()).unwrap();
471        assert!(
472            value.get("gguf").is_none(),
473            "gguf must be absent when unset: {value}"
474        );
475        assert!(
476            value.get("tdf_spec_version").is_none(),
477            "tdf_spec_version must be absent when unset: {value}"
478        );
479
480        // The gguf-tdf/1 writer sets both and they round-trip.
481        let mut m = TdfManifest::new("header".to_string(), "https://kas.example.com".to_string());
482        m.tdf_spec_version = Some("4.3.0".to_string());
483        m.gguf = Some(crate::GgufIndex {
484            profile: crate::GGUF_TDF_PROFILE_V1.to_string(),
485            alignment: 32,
486            header_bytes: 64,
487            virtual_size: 352,
488            max_segment: 128,
489            tensors: vec![crate::GgufTensor {
490                name: "token_embd.weight".to_string(),
491                offset: 64,
492                size: 256,
493                segments: [1, 3],
494            }],
495            segments: vec![crate::GgufSegment {
496                id: 0,
497                kind: crate::GgufSegmentKind::Header,
498                plain: 64,
499                entry: "header".to_string(),
500            }],
501        });
502
503        let json = m.to_json().unwrap();
504        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
505        assert_eq!(value["tdf_spec_version"], "4.3.0");
506        assert_eq!(value["gguf"]["profile"], "gguf-tdf/1");
507        assert_eq!(value["gguf"]["headerBytes"], 64);
508        assert_eq!(value["gguf"]["maxSegment"], 128);
509        assert_eq!(value["gguf"]["segments"][0]["kind"], "header");
510        assert_eq!(value["gguf"]["tensors"][0]["segments"][1], 3);
511
512        let back = TdfManifest::from_json(&json).unwrap();
513        assert_eq!(back.gguf.as_ref().unwrap().virtual_size, 352);
514        assert_eq!(back.tdf_spec_version.as_deref(), Some("4.3.0"));
515    }
516}
517
518#[cfg(test)]
519mod spec_version_tests {
520    use super::*;
521
522    fn minimal_json(top: &str, payload_extra: &str) -> String {
523        format!(
524            r#"{{
525            "payload": {{"type":"reference","url":"0.payload","protocol":"zip","isEncrypted":true{payload_extra}}},
526            "encryptionInformation": {{
527                "type":"split","keyAccess":[],
528                "method":{{"algorithm":"AES-256-GCM","isStreamable":true,"iv":""}},
529                "integrityInformation":{{"rootSignature":{{"alg":"HS256","sig":""}},"segmentHashAlg":"GMAC","segments":[],"segmentSizeDefault":0,"encryptedSegmentSizeDefault":0}},
530                "policy":""
531            }}{top}
532        }}"#
533        )
534    }
535
536    #[test]
537    fn spec_version_prefers_schema_version() {
538        let m = TdfManifest::from_json(&minimal_json(
539            r#","schemaVersion":"4.3.0","tdf_spec_version":"9.9.9""#,
540            r#","tdf_spec_version":"8.8.8""#,
541        ))
542        .unwrap();
543        assert_eq!(m.spec_version(), Some("4.3.0"));
544    }
545
546    #[test]
547    fn spec_version_then_top_level_tdf_spec_version() {
548        let m = TdfManifest::from_json(&minimal_json(
549            r#","tdf_spec_version":"9.9.9""#,
550            r#","tdf_spec_version":"8.8.8""#,
551        ))
552        .unwrap();
553        assert_eq!(m.spec_version(), Some("9.9.9"));
554    }
555
556    #[test]
557    fn spec_version_then_payload_tdf_spec_version() {
558        let m =
559            TdfManifest::from_json(&minimal_json("", r#","tdf_spec_version":"8.8.8""#)).unwrap();
560        assert_eq!(m.spec_version(), Some("8.8.8"));
561    }
562
563    #[test]
564    fn spec_version_absent_is_none() {
565        let m = TdfManifest::from_json(&minimal_json("", "")).unwrap();
566        assert_eq!(m.spec_version(), None);
567    }
568
569    #[test]
570    fn new_manifest_writes_schema_version_4_3_0_and_no_tdf_spec_version() {
571        let m = TdfManifest::new(
572            "0.payload".to_string(),
573            "https://kas.example.com".to_string(),
574        );
575        let v: serde_json::Value = serde_json::from_str(&m.to_json().unwrap()).unwrap();
576        assert_eq!(v["schemaVersion"], TDF_SPEC_VERSION);
577        assert_eq!(v["schemaVersion"], "4.3.0");
578        assert!(v.get("tdf_spec_version").is_none());
579        assert!(v["payload"].get("tdf_spec_version").is_none());
580    }
581}
582
583#[cfg(test)]
584mod peer_method_tolerance_tests {
585    use super::*;
586
587    /// Java SDK manifests omit `method.iv` (and the oldest omit `isStreamable`).
588    #[test]
589    fn method_without_iv_or_is_streamable_parses() {
590        let m: EncryptionMethod = serde_json::from_str(r#"{"algorithm":"AES-256-GCM"}"#).unwrap();
591        assert_eq!(m.algorithm, "AES-256-GCM");
592        assert!(!m.is_streamable);
593        assert_eq!(m.iv, "");
594
595        let m: EncryptionMethod =
596            serde_json::from_str(r#"{"algorithm":"AES-256-GCM","isStreamable":true}"#).unwrap();
597        assert!(m.is_streamable);
598        assert_eq!(m.iv, "");
599    }
600}
601
602#[cfg(test)]
603mod sid_and_assertion_tests {
604    use super::*;
605
606    /// Manifest carrying `keyAccess[].sid` and one assertion whose
607    /// `statement.value` is a structured JSON object (spec: assertion_statement.md).
608    const MANIFEST_WITH_SID_AND_ASSERTION: &str = r#"{
609        "payload": {"type":"reference","url":"0.payload","protocol":"zip","isEncrypted":true,"mimeType":"text/plain"},
610        "encryptionInformation": {
611            "type":"split",
612            "keyAccess":[{
613                "type":"wrapped",
614                "url":"https://kas.example.com",
615                "kid":"r1",
616                "sid":"split-id-1",
617                "protocol":"kas",
618                "wrappedKey":"AAAA",
619                "policyBinding":{"alg":"HS256","hash":"BBBB"}
620            }],
621            "method":{"algorithm":"AES-256-GCM","isStreamable":true,"iv":""},
622            "integrityInformation":{"rootSignature":{"alg":"HS256","sig":""},"segmentHashAlg":"GMAC","segments":[],"segmentSizeDefault":0,"encryptedSegmentSizeDefault":0},
623            "policy":""
624        },
625        "assertions":[{
626            "id":"nato-label-1",
627            "type":"handling",
628            "scope":"payload",
629            "appliesToState":"encrypted",
630            "statement":{
631                "schema":"urn:nato:stanag:4774:confidentialitymetadatalabel:1:0",
632                "format":"json-structured",
633                "value":{
634                    "Xmlns":"urn:nato:stanag:4774:confidentialitymetadatalabel:1:0",
635                    "CreationTime":"2015-08-29T16:15:00Z",
636                    "ConfidentialityInformation":{"PolicyIdentifier":"NATO","Classification":"SECRET","nested":[1,true,null,{"k":"v"}]}
637                }
638            },
639            "binding":{"method":"jws","signature":"eyJhbGciOiJSUzI1NiJ9.e30.sig"}
640        }],
641        "schemaVersion":"4.3.0"
642    }"#;
643
644    #[test]
645    fn manifest_round_trips_key_access_sid_and_assertions() {
646        let input: serde_json::Value =
647            serde_json::from_str(MANIFEST_WITH_SID_AND_ASSERTION).unwrap();
648
649        let m = TdfManifest::from_json(MANIFEST_WITH_SID_AND_ASSERTION).unwrap();
650
651        // Typed access documents the intent, not just Value equality.
652        assert_eq!(
653            m.encryption_information.key_access[0].sid.as_deref(),
654            Some("split-id-1")
655        );
656        assert_eq!(m.assertions.len(), 1);
657        let a = &m.assertions[0];
658        assert_eq!(a.id, "nato-label-1");
659        assert_eq!(a.assertion_type, "handling");
660        assert_eq!(a.scope, "payload");
661        assert_eq!(a.applies_to_state.as_deref(), Some("encrypted"));
662        assert_eq!(a.statement.format, "json-structured");
663        assert_eq!(
664            a.statement.schema.as_deref(),
665            Some("urn:nato:stanag:4774:confidentialitymetadatalabel:1:0")
666        );
667        assert!(
668            a.statement.value.is_object(),
669            "statement.value must stay structured JSON: {:?}",
670            a.statement.value
671        );
672        let b = a.binding.as_ref().expect("binding present");
673        assert_eq!(b.method, "jws");
674        assert_eq!(b.signature, "eyJhbGciOiJSUzI1NiJ9.e30.sig");
675
676        // Re-serialize: nothing dropped, nothing invented.
677        let output: serde_json::Value = serde_json::from_str(&m.to_json().unwrap()).unwrap();
678        assert_eq!(output, input);
679    }
680
681    #[test]
682    fn manifest_with_string_statement_value_round_trips() {
683        let json = MANIFEST_WITH_SID_AND_ASSERTION
684            .replace(r#""format":"json-structured","#, r#""format":"string","#);
685        // Swap the object value for a plain string value.
686        let mut input: serde_json::Value = serde_json::from_str(&json).unwrap();
687        input["assertions"][0]["statement"]["value"] =
688            serde_json::Value::String("plain text".into());
689
690        let m: TdfManifest = serde_json::from_value(input.clone()).unwrap();
691        assert_eq!(
692            m.assertions[0].statement.value,
693            serde_json::json!("plain text")
694        );
695
696        let output = serde_json::to_value(&m).unwrap();
697        assert_eq!(output, input);
698    }
699
700    #[test]
701    fn manifest_without_sid_or_assertions_serializes_unchanged() {
702        // Existing manifests (no sid, no assertions) must not grow new keys.
703        let m = TdfManifest::new(
704            "0.payload".to_string(),
705            "https://kas.example.com".to_string(),
706        );
707        assert!(m.encryption_information.key_access[0].sid.is_none());
708        assert!(m.assertions.is_empty());
709
710        let v: serde_json::Value = serde_json::from_str(&m.to_json().unwrap()).unwrap();
711        assert!(v.get("assertions").is_none(), "{v}");
712        assert!(
713            v["encryptionInformation"]["keyAccess"][0]
714                .get("sid")
715                .is_none(),
716            "{v}"
717        );
718
719        // And a peer manifest that omits both still parses (serde default).
720        let json = MANIFEST_WITH_SID_AND_ASSERTION.replace(r#""sid":"split-id-1","#, "");
721        let mut without: serde_json::Value = serde_json::from_str(&json).unwrap();
722        without.as_object_mut().unwrap().remove("assertions");
723        let m: TdfManifest = serde_json::from_value(without).unwrap();
724        assert!(m.assertions.is_empty());
725        assert!(m.encryption_information.key_access[0].sid.is_none());
726    }
727
728    /// Jackson-style writers serialize an empty list as `null`. serde's
729    /// `default` covers only a missing key, so this must be handled explicitly.
730    #[test]
731    fn assertions_null_parses_as_empty() {
732        let json = r#"{
733            "payload": {"type":"reference","url":"0.payload","protocol":"zip","isEncrypted":true},
734            "encryptionInformation": {
735                "type":"split",
736                "keyAccess":[],
737                "method":{"algorithm":"AES-256-GCM","isStreamable":true,"iv":""},
738                "integrityInformation":{"rootSignature":{"alg":"HS256","sig":""},"segmentHashAlg":"GMAC","segments":[],"segmentSizeDefault":0,"encryptedSegmentSizeDefault":0},
739                "policy":""
740            },
741            "assertions": null
742        }"#;
743        let m = TdfManifest::from_json(json).expect("null assertions must parse");
744        assert!(m.assertions.is_empty());
745    }
746
747    /// Go's `Assertion.Binding` is a non-pointer struct with `omitempty` on its
748    /// string fields, so an unsigned assertion serializes as `"binding": {}`.
749    #[test]
750    fn unsigned_assertion_with_empty_binding_object_parses_and_round_trips() {
751        let json = r#"{"id":"a1","type":"handling","scope":"payload","statement":{"format":"string","value":"x"},"binding":{}}"#;
752        let a: Assertion = serde_json::from_str(json).expect("empty binding object must parse");
753        let binding = a.binding.as_ref().expect("binding object is present");
754        assert_eq!(binding.method, "");
755        assert_eq!(binding.signature, "");
756
757        let out = serde_json::to_value(&a).unwrap();
758        assert_eq!(
759            out,
760            serde_json::from_str::<serde_json::Value>(json).unwrap()
761        );
762    }
763}
764
765#[cfg(test)]
766mod segment_size_fallback_tests {
767    use super::*;
768
769    #[test]
770    fn segment_sizes_fall_back_to_integrity_defaults_when_omitted() {
771        let mut info = IntegrityInformation {
772            segment_size_default: 1000,
773            encrypted_segment_size_default: 1028,
774            ..IntegrityInformation::default()
775        };
776        // Spec (integrity_information.md): segmentSize / encryptedSegmentSize are
777        // optional and inferred from the *Default values when omitted.
778        info.segments.push(Segment {
779            hash: "a".into(),
780            segment_size: None,
781            encrypted_segment_size: None,
782        });
783        // Explicit values always win (a re-encrypted or short tail segment).
784        info.segments.push(Segment {
785            hash: "b".into(),
786            segment_size: Some(7),
787            encrypted_segment_size: Some(35),
788        });
789        // Mixed: only one side omitted.
790        info.segments.push(Segment {
791            hash: "c".into(),
792            segment_size: Some(1000),
793            encrypted_segment_size: None,
794        });
795
796        assert_eq!(
797            info.segment_sizes(),
798            vec![(1000, 1028), (7, 35), (1000, 1028)]
799        );
800    }
801}