diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b6854ae..4e9a64d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -13,7 +13,7 @@ body: attributes: label: mp4forge Version description: Which version are you using? - placeholder: "0.6.0" + placeholder: "0.7.0" validations: required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index c81d85a..102e538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# 0.7.0 (April 28, 2026) + +- Added the feature-gated decryption release surface across sync library helpers, Tokio async + file-backed companions, and the sync-only `decrypt` CLI, covering the Common Encryption family, + PIFF compatibility, OMA DCF, Marlin IPMP ACBC and ACGK, and the retained IAEC protected-movie + path +- Added the typed OMA DCF, Marlin, ISMA-IAEC, and descriptor-command box or descriptor support + needed to drive the broader protected-format decryption paths without opaque byte-only shortcuts +- Expanded retained decrypt fixtures, parity harnesses, and cross-surface regression coverage so + sync, async, CLI, fragmented, protected-movie, and broader-format decrypt behavior are locked + against stable checked-in assets and comparison-backed expectations +- Extended the fragmented decrypt path to support multi-sample-entry track layouts with + per-fragment sample-description switching and ordered zero-KID track-key binding, and verified + the resulting clear fragmented output against the existing rebuild workflow +- Closed the older non-fragmented `sample_description_index` gap by preserving chunk-level sample + description identity in shared layout helpers, positively covering valid Marlin layouts above + `1`, and making the retained first-description-only OMA and IAEC protected-movie limit explicit + where the reviewed higher-level behavior still keeps that scope + # 0.6.0 (April 26, 2026) - Added an additive Tokio-based `async` feature for the library, covering seekable async traversal, extraction, typed codec decode and encode, writer flows, rewrite flows, probe surfaces, and top-level `sidx` helpers while keeping the CLI on the established synchronous path diff --git a/Cargo.toml b/Cargo.toml index aa5e372..bf83d5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mp4forge" -version = "0.6.0" +version = "0.7.0" edition = "2024" rust-version = "1.88" authors = ["bakgio"] @@ -19,12 +19,15 @@ rustdoc-args = ["--cfg", "docsrs"] [features] default = [] async = ["dep:tokio"] +decrypt = ["dep:aes"] serde = ["dep:serde"] [dependencies] +aes = { version = "0.8", optional = true } serde = { version = "1", features = ["derive"], optional = true } terminal_size = "0.4" tokio = { version = "1.52.1", features = ["fs", "io-util", "rt", "rt-multi-thread", "macros"], optional = true } [dev-dependencies] +aes = "0.8" serde_json = "1" diff --git a/README.md b/README.md index 8c1de03..6144282 100644 --- a/README.md +++ b/README.md @@ -20,18 +20,21 @@ - Low-level traversal, extraction, stringify, probe, and writer APIs - Thin typed path-based helpers and byte-slice convenience wrappers for common extraction, rewrite, and probe flows - Fragmented top-level `sidx` analysis, planning, and rewrite APIs for supported layouts -- Built-in CLI for `dump`, `extract`, `probe`, `psshdump`, `edit`, and `divide` +- Feature-gated decryption APIs and a sync-only `decrypt` CLI for the supported protected MP4 families +- Built-in CLI for `decrypt`, `dump`, `extract`, `probe`, `psshdump`, `edit`, and `divide` - Shared-fixture coverage for regular MP4, fragmented MP4, encrypted init segments, QuickTime-style metadata cases, and derived real codec fixtures for additional codec-family coverage ## Installation ```toml [dependencies] -mp4forge = "0.6.0" +mp4forge = "0.7.0" # With optional features: -# mp4forge = { version = "0.6.0", features = ["async"] } -# mp4forge = { version = "0.6.0", features = ["serde"] } +# mp4forge = { version = "0.7.0", features = ["async"] } +# mp4forge = { version = "0.7.0", features = ["decrypt"] } +# mp4forge = { version = "0.7.0", features = ["decrypt", "async"] } +# mp4forge = { version = "0.7.0", features = ["serde"] } ``` Install the CLI from crates.io: @@ -58,6 +61,13 @@ feature flags: `AsyncRead + AsyncSeek` and `AsyncWrite + AsyncSeek` inputs and outputs, supports normal multithreaded `tokio::spawn` usage for the supported library paths, and keeps the current CLI on the existing sync path. +- `decrypt`: enables the additive decryption input, progress, and support-matrix types that fix + the public shape for the decryption surface while keeping the default build unchanged. The + landed sync library path covers the Common Encryption family (`cenc`, `cens`, `cbc1`, `cbcs`), + PIFF-triggered compatibility behavior, OMA DCF atom files and protected movie layouts, Marlin + IPMP ACBC and ACGK OD-track movies, and the retained IAEC protected-movie path. When combined + with `async`, it also enables the additive file-backed Tokio async decrypt companions, while the + CLI remains on the synchronous path. - `serde`: derives `Serialize` and `Deserialize` for the reusable public report structs under `mp4forge::cli::probe` and `mp4forge::cli::dump`, along with their nested public codec-detail, media-characteristics, `FieldValue`, and `FourCc` data. This is intended for library-side report @@ -70,6 +80,7 @@ feature flags: USAGE: mp4forge COMMAND [ARGS] COMMAND: + decrypt decrypt a protected MP4 file divide split a fragmented MP4 into track playlists dump display the MP4 box tree edit rewrite selected boxes @@ -78,6 +89,11 @@ COMMAND: probe summarize an MP4 file ``` +`decrypt` is available when the crate is built with `--features decrypt`. The CLI stays +sync-only, accepts repeated `--key ID:KEY`, optional `--fragments-info FILE`, and optional +`--show-progress`, and reuses the same library decryption surface that backs the feature-gated +sync and async APIs. + `divide` currently targets fragmented inputs with up to one AVC video track and one MP4A audio track, including encrypted wrappers that preserve those original sample-entry formats. Pass `-validate` when you want the same probe-driven layout checks without creating any output files. @@ -107,7 +123,9 @@ field-order hints. Pass `-detail light` for a lighter-weight probe that skips pe per-chunk, bitrate, and IDR aggregation, or use `mp4forge::probe::ProbeOptions` from the library when you need the same control programmatically. -> See the [`examples/`](./examples) directory for the crate's low-level and high-level API usage patterns, including the Tokio-based async library example behind the optional `async` feature. +> See the [`examples/`](./examples) directory for the crate's low-level and high-level API usage +> patterns, including the feature-gated decrypt example and the Tokio-based async library example +> behind the optional `async` feature. ## License diff --git a/examples/decrypt_file.rs b/examples/decrypt_file.rs new file mode 100644 index 0000000..ea8e39b --- /dev/null +++ b/examples/decrypt_file.rs @@ -0,0 +1,74 @@ +#[cfg(feature = "decrypt")] +use std::env; +#[cfg(feature = "decrypt")] +use std::error::Error; +#[cfg(feature = "decrypt")] +use std::fs; +#[cfg(feature = "decrypt")] +use std::io; + +#[cfg(feature = "decrypt")] +use mp4forge::decrypt::{DecryptOptions, decrypt_file_with_progress}; + +#[cfg(feature = "decrypt")] +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} + +#[cfg(feature = "decrypt")] +fn run() -> Result<(), Box> { + let args = env::args().skip(1).collect::>(); + if args.len() < 3 { + return Err( + "usage: cargo run --example decrypt_file --features decrypt -- [more-keys...] [--fragments-info ]" + .into(), + ); + } + + let input_path = args[0].clone(); + let output_path = args[1].clone(); + let mut options = DecryptOptions::new(); + let mut cursor = 2usize; + while cursor < args.len() { + match args[cursor].as_str() { + "--fragments-info" => { + let fragments_info_path = args.get(cursor + 1).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "missing path after --fragments-info", + ) + })?; + options = options.with_fragments_info_bytes(fs::read(fragments_info_path)?); + cursor += 2; + } + key_spec => { + options = options.with_key_spec(key_spec)?; + cursor += 1; + } + } + } + + decrypt_file_with_progress( + &input_path, + &output_path, + &options, + |progress| match progress.total { + Some(total) => eprintln!("{:?}: {}/{}", progress.phase, progress.completed, total), + None => eprintln!("{:?}", progress.phase), + }, + )?; + + println!("wrote clear output to {output_path}"); + Ok(()) +} + +#[cfg(not(feature = "decrypt"))] +fn main() { + eprintln!( + "enable the decrypt feature: cargo run --example decrypt_file --features decrypt -- [more-keys...] [--fragments-info ]" + ); + std::process::exit(1); +} diff --git a/src/boxes/isma_cryp.rs b/src/boxes/isma_cryp.rs new file mode 100644 index 0000000..3187b33 --- /dev/null +++ b/src/boxes/isma_cryp.rs @@ -0,0 +1,424 @@ +//! ISMA Cryp protection-related box definitions. + +use std::io::Write; + +use crate::boxes::BoxRegistry; +use crate::codec::{ + CodecBox, CodecError, FieldHooks, FieldTable, FieldValue, FieldValueError, FieldValueRead, + FieldValueWrite, ImmutableBox, MutableBox, ReadSeek, StringFieldMode, read_exact_vec_untrusted, +}; +use crate::{FourCc, codec_field}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct FullBoxState { + version: u8, + flags: u32, +} + +fn missing_field(field_name: &'static str) -> FieldValueError { + FieldValueError::MissingField { field_name } +} + +fn unexpected_field(field_name: &'static str, value: FieldValue) -> FieldValueError { + FieldValueError::UnexpectedType { + field_name, + expected: "matching codec field value", + actual: value.kind_name(), + } +} + +fn invalid_value(field_name: &'static str, reason: &'static str) -> FieldValueError { + FieldValueError::InvalidValue { field_name, reason } +} + +fn decode_utf8_string(field_name: &'static str, bytes: &[u8]) -> Result { + String::from_utf8(bytes.to_vec()) + .map_err(|_| invalid_value(field_name, "value is not valid UTF-8")) +} + +fn write_u32(bytes: &mut Vec, value: u32) { + bytes.extend_from_slice(&value.to_be_bytes()); +} + +macro_rules! impl_full_box { + ($name:ident, $box_type:expr) => { + impl ImmutableBox for $name { + fn box_type(&self) -> FourCc { + FourCc::from_bytes($box_type) + } + + fn version(&self) -> u8 { + self.full_box.version + } + + fn flags(&self) -> u32 { + self.full_box.flags + } + } + + impl MutableBox for $name { + fn set_version(&mut self, version: u8) { + self.full_box.version = version; + } + + fn set_flags(&mut self, flags: u32) { + self.full_box.flags = flags; + } + } + }; +} + +macro_rules! impl_leaf_box { + ($name:ident, $box_type:expr) => { + impl ImmutableBox for $name { + fn box_type(&self) -> FourCc { + FourCc::from_bytes($box_type) + } + } + + impl MutableBox for $name {} + }; +} + +/// Key-management-system box carried under `schi` for IAEC-protected sample entries. +/// +/// Version `0` stores only a trailing null-terminated URI. Version `1` additionally prefixes a +/// KMS identifier and KMS version before the URI payload. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Ikms { + full_box: FullBoxState, + /// Optional KMS identifier carried by version-1 payloads. + pub kms_id: u32, + /// Optional KMS version carried by version-1 payloads. + pub kms_version: u32, + /// Key-management-system URI string. + pub kms_uri: String, +} + +impl FieldHooks for Ikms {} +impl_full_box!(Ikms, *b"iKMS"); + +impl FieldValueRead for Ikms { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "KmsId" => Ok(FieldValue::Unsigned(u64::from(self.kms_id))), + "KmsVersion" => Ok(FieldValue::Unsigned(u64::from(self.kms_version))), + "KmsUri" => Ok(FieldValue::String(self.kms_uri.clone())), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for Ikms { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("KmsId", FieldValue::Unsigned(value)) => { + self.kms_id = u32::try_from(value) + .map_err(|_| invalid_value(field_name, "value does not fit in u32"))?; + Ok(()) + } + ("KmsVersion", FieldValue::Unsigned(value)) => { + self.kms_version = u32::try_from(value) + .map_err(|_| invalid_value(field_name, "value does not fit in u32"))?; + Ok(()) + } + ("KmsUri", FieldValue::String(value)) => { + if value.as_bytes().contains(&0) { + return Err(invalid_value( + field_name, + "string value must not contain embedded null bytes", + )); + } + self.kms_uri = value; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for Ikms { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("Version", 0, with_bit_width(8), as_version_field()), + codec_field!("Flags", 1, with_bit_width(24), as_flags_field()), + codec_field!("KmsId", 2, with_bit_width(32), as_unsigned()), + codec_field!("KmsVersion", 3, with_bit_width(32), as_unsigned()), + codec_field!( + "KmsUri", + 4, + with_bit_width(8), + as_string(StringFieldMode::NullTerminated) + ), + ]); + const SUPPORTED_VERSIONS: &'static [u8] = &[0, 1]; + + fn custom_marshal(&self, writer: &mut dyn Write) -> Result, CodecError> { + if self.version() > 1 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version: self.version(), + }); + } + if self.kms_uri.as_bytes().contains(&0) { + return Err(invalid_value( + "KmsUri", + "string value must not contain embedded null bytes", + ) + .into()); + } + + let mut payload = + Vec::with_capacity(5 + self.kms_uri.len() + if self.version() == 1 { 8 } else { 0 }); + payload.push(self.version()); + payload.extend_from_slice(&self.flags().to_be_bytes()[1..]); + if self.version() == 1 { + write_u32(&mut payload, self.kms_id); + write_u32(&mut payload, self.kms_version); + } + payload.extend_from_slice(self.kms_uri.as_bytes()); + payload.push(0); + writer.write_all(&payload)?; + Ok(Some(payload.len() as u64)) + } + + fn custom_unmarshal( + &mut self, + reader: &mut dyn ReadSeek, + payload_size: u64, + ) -> Result, CodecError> { + let payload = read_exact_vec_untrusted( + reader, + usize::try_from(payload_size) + .map_err(|_| invalid_value("iKMS payload", "payload size does not fit in usize"))?, + )?; + if payload.len() < 4 { + return Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof).into()); + } + + self.set_version(payload[0]); + if self.version() > 1 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version: self.version(), + }); + } + self.set_flags(u32::from_be_bytes([0, payload[1], payload[2], payload[3]])); + + let mut cursor = 4usize; + if self.version() == 1 && payload.len().saturating_sub(cursor) >= 8 { + self.kms_id = u32::from_be_bytes(payload[cursor..cursor + 4].try_into().unwrap()); + self.kms_version = + u32::from_be_bytes(payload[cursor + 4..cursor + 8].try_into().unwrap()); + cursor += 8; + } else { + self.kms_id = 0; + self.kms_version = 0; + } + + if cursor < payload.len() { + let uri_bytes = &payload[cursor..]; + let uri_bytes = uri_bytes.strip_suffix(&[0]).unwrap_or(uri_bytes); + self.kms_uri = decode_utf8_string("KmsUri", uri_bytes)?; + } else { + self.kms_uri.clear(); + } + + Ok(Some(payload_size)) + } +} + +/// IAEC sample-format box that describes selective-encryption, key-indicator, and IV widths. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Isfm { + full_box: FullBoxState, + /// Whether sample payloads carry the leading selective-encryption flag byte. + pub selective_encryption: bool, + /// Number of bytes reserved for the key-indicator field inside encrypted samples. + pub key_indicator_length: u8, + /// Number of bytes reserved for the per-sample IV field inside encrypted samples. + pub iv_length: u8, +} + +impl FieldHooks for Isfm {} +impl_full_box!(Isfm, *b"iSFM"); + +impl FieldValueRead for Isfm { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "SelectiveEncryption" => Ok(FieldValue::Unsigned(u64::from(u8::from( + self.selective_encryption, + )))), + "KeyIndicatorLength" => Ok(FieldValue::Unsigned(u64::from(self.key_indicator_length))), + "IvLength" => Ok(FieldValue::Unsigned(u64::from(self.iv_length))), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for Isfm { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("SelectiveEncryption", FieldValue::Unsigned(value)) => { + self.selective_encryption = match value { + 0 => false, + 1 => true, + _ => { + return Err(invalid_value(field_name, "value must be either 0 or 1")); + } + }; + Ok(()) + } + ("KeyIndicatorLength", FieldValue::Unsigned(value)) => { + self.key_indicator_length = u8::try_from(value) + .map_err(|_| invalid_value(field_name, "value does not fit in u8"))?; + Ok(()) + } + ("IvLength", FieldValue::Unsigned(value)) => { + self.iv_length = u8::try_from(value) + .map_err(|_| invalid_value(field_name, "value does not fit in u8"))?; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for Isfm { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("Version", 0, with_bit_width(8), as_version_field()), + codec_field!("Flags", 1, with_bit_width(24), as_flags_field()), + codec_field!("SelectiveEncryption", 2, with_bit_width(8), as_unsigned()), + codec_field!("KeyIndicatorLength", 3, with_bit_width(8), as_unsigned()), + codec_field!("IvLength", 4, with_bit_width(8), as_unsigned()), + ]); + const SUPPORTED_VERSIONS: &'static [u8] = &[0]; + + fn custom_marshal(&self, writer: &mut dyn Write) -> Result, CodecError> { + if self.version() != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version: self.version(), + }); + } + + let payload = [ + self.version(), + self.flags().to_be_bytes()[1], + self.flags().to_be_bytes()[2], + self.flags().to_be_bytes()[3], + if self.selective_encryption { + 0x80 + } else { + 0x00 + }, + self.key_indicator_length, + self.iv_length, + ]; + writer.write_all(&payload)?; + Ok(Some(payload.len() as u64)) + } + + fn custom_unmarshal( + &mut self, + reader: &mut dyn ReadSeek, + payload_size: u64, + ) -> Result, CodecError> { + let payload = read_exact_vec_untrusted( + reader, + usize::try_from(payload_size) + .map_err(|_| invalid_value("iSFM payload", "payload size does not fit in usize"))?, + )?; + if payload.len() != 7 { + return Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof).into()); + } + + self.set_version(payload[0]); + if self.version() != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version: self.version(), + }); + } + self.set_flags(u32::from_be_bytes([0, payload[1], payload[2], payload[3]])); + self.selective_encryption = (payload[4] & 0x80) != 0; + self.key_indicator_length = payload[5]; + self.iv_length = payload[6]; + Ok(Some(payload_size)) + } +} + +/// IAEC salt box carried under `schi`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Islt { + /// Eight-byte salt prefix that seeds the stream cipher IV. + pub salt: [u8; 8], +} + +impl FieldHooks for Islt {} +impl_leaf_box!(Islt, *b"iSLT"); + +impl FieldValueRead for Islt { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "Salt" => Ok(FieldValue::Bytes(self.salt.to_vec())), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for Islt { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("Salt", FieldValue::Bytes(value)) => { + self.salt = value + .as_slice() + .try_into() + .map_err(|_| invalid_value(field_name, "value must be exactly 8 bytes"))?; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for Islt { + const FIELD_TABLE: FieldTable = + FieldTable::new(&[codec_field!("Salt", 0, with_bit_width(8), as_bytes())]); + + fn custom_marshal(&self, writer: &mut dyn Write) -> Result, CodecError> { + writer.write_all(&self.salt)?; + Ok(Some(self.salt.len() as u64)) + } + + fn custom_unmarshal( + &mut self, + reader: &mut dyn ReadSeek, + payload_size: u64, + ) -> Result, CodecError> { + if payload_size != 8 { + return Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof).into()); + } + let payload = read_exact_vec_untrusted(reader, 8)?; + self.salt.copy_from_slice(&payload); + Ok(Some(payload_size)) + } +} + +/// Registers the landed ISMA Cryp box families in the supplied registry. +pub fn register_boxes(registry: &mut BoxRegistry) { + registry.register::(FourCc::from_bytes(*b"iKMS")); + registry.register::(FourCc::from_bytes(*b"iSFM")); + registry.register::(FourCc::from_bytes(*b"iSLT")); +} diff --git a/src/boxes/iso14496_14.rs b/src/boxes/iso14496_14.rs index 5ecec1c..9055685 100644 --- a/src/boxes/iso14496_14.rs +++ b/src/boxes/iso14496_14.rs @@ -17,6 +17,22 @@ pub const DECODER_CONFIG_DESCRIPTOR_TAG: u8 = 0x04; pub const DECODER_SPECIFIC_INFO_TAG: u8 = 0x05; /// Descriptor tag used by the sync-layer configuration descriptor record. pub const SL_CONFIG_DESCRIPTOR_TAG: u8 = 0x06; +/// Descriptor tag used by the IPMP descriptor-pointer record. +pub const IPMP_DESCRIPTOR_POINTER_TAG: u8 = 0x0A; +/// Descriptor tag used by the IPMP descriptor record. +pub const IPMP_DESCRIPTOR_TAG: u8 = 0x0B; +/// Descriptor tag used by the ES-ID-increment descriptor record. +pub const ES_ID_INC_DESCRIPTOR_TAG: u8 = 0x0E; +/// Descriptor tag used by the ES-ID-reference descriptor record. +pub const ES_ID_REF_DESCRIPTOR_TAG: u8 = 0x0F; +/// Descriptor tag used by the MP4 initial-object descriptor record. +pub const MP4_INITIAL_OBJECT_DESCRIPTOR_TAG: u8 = 0x10; +/// Descriptor tag used by the MP4 object-descriptor record. +pub const MP4_OBJECT_DESCRIPTOR_TAG: u8 = 0x11; +/// Command tag used by the object-descriptor-update command record. +pub const OBJECT_DESCRIPTOR_UPDATE_COMMAND_TAG: u8 = 0x01; +/// Command tag used by the IPMP-descriptor-update command record. +pub const IPMP_DESCRIPTOR_UPDATE_COMMAND_TAG: u8 = 0x05; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] struct FullBoxState { @@ -164,10 +180,16 @@ fn write_uvarint( fn descriptor_tag_name(tag: u8) -> Option<&'static str> { match tag { + MP4_OBJECT_DESCRIPTOR_TAG => Some("MP4ObjectDescr"), + MP4_INITIAL_OBJECT_DESCRIPTOR_TAG => Some("MP4InitialObjectDescr"), ES_DESCRIPTOR_TAG => Some("ESDescr"), DECODER_CONFIG_DESCRIPTOR_TAG => Some("DecoderConfigDescr"), DECODER_SPECIFIC_INFO_TAG => Some("DecSpecificInfo"), SL_CONFIG_DESCRIPTOR_TAG => Some("SLConfigDescr"), + IPMP_DESCRIPTOR_POINTER_TAG => Some("IPMPDescrPointer"), + IPMP_DESCRIPTOR_TAG => Some("IPMPDescr"), + ES_ID_INC_DESCRIPTOR_TAG => Some("ES_ID_Inc"), + ES_ID_REF_DESCRIPTOR_TAG => Some("ES_ID_Ref"), _ => None, } } @@ -178,6 +200,14 @@ fn render_descriptor_tag(tag: u8) -> String { .unwrap_or_else(|| format!("0x{tag:x}")) } +fn command_tag_name(tag: u8) -> Option<&'static str> { + match tag { + OBJECT_DESCRIPTOR_UPDATE_COMMAND_TAG => Some("ObjectDescriptorUpdate"), + IPMP_DESCRIPTOR_UPDATE_COMMAND_TAG => Some("IPMPDescriptorUpdate"), + _ => None, + } +} + fn encode_es_descriptor( field_name: &'static str, descriptor: &EsDescriptor, @@ -247,6 +277,108 @@ fn encode_decoder_config_descriptor( Ok(buffer) } +fn encode_object_descriptor( + field_name: &'static str, + descriptor: &ObjectDescriptor, +) -> Result, FieldValueError> { + if descriptor.object_descriptor_id > 0x03ff { + return Err(invalid_value( + field_name, + "object descriptor id must fit in 10 bits", + )); + } + if descriptor.url_flag && usize::from(descriptor.url_length) != descriptor.url_string.len() { + return Err(invalid_value( + "URLString", + "value length does not match URLLength", + )); + } + + let mut buffer = Vec::new(); + write_u16( + &mut buffer, + (descriptor.object_descriptor_id << 6) | (u16::from(descriptor.url_flag) << 5) | 0x001f, + ); + if descriptor.url_flag { + buffer.push(descriptor.url_length); + buffer.extend_from_slice(&descriptor.url_string); + } + buffer.extend_from_slice(&encode_descriptor_stream(&descriptor.sub_descriptors)?); + Ok(buffer) +} + +fn encode_initial_object_descriptor( + field_name: &'static str, + descriptor: &InitialObjectDescriptor, +) -> Result, FieldValueError> { + if descriptor.object_descriptor_id > 0x03ff { + return Err(invalid_value( + field_name, + "object descriptor id must fit in 10 bits", + )); + } + if descriptor.url_flag && usize::from(descriptor.url_length) != descriptor.url_string.len() { + return Err(invalid_value( + "URLString", + "value length does not match URLLength", + )); + } + + let mut buffer = Vec::new(); + write_u16( + &mut buffer, + (descriptor.object_descriptor_id << 6) + | (u16::from(descriptor.url_flag) << 5) + | (u16::from(descriptor.include_inline_profile_level_flag) << 4) + | 0x000f, + ); + if descriptor.url_flag { + buffer.push(descriptor.url_length); + buffer.extend_from_slice(&descriptor.url_string); + } else { + buffer.extend_from_slice(&[ + descriptor.od_profile_level_indication, + descriptor.scene_profile_level_indication, + descriptor.audio_profile_level_indication, + descriptor.visual_profile_level_indication, + descriptor.graphics_profile_level_indication, + ]); + } + buffer.extend_from_slice(&encode_descriptor_stream(&descriptor.sub_descriptors)?); + Ok(buffer) +} + +fn encode_ipmp_descriptor_pointer(descriptor: &IpmpDescriptorPointer) -> Vec { + let mut buffer = Vec::new(); + buffer.push(descriptor.descriptor_id); + if descriptor.descriptor_id == 0xff { + write_u16(&mut buffer, descriptor.descriptor_id_ex); + write_u16(&mut buffer, descriptor.es_id); + } + buffer +} + +fn encode_ipmp_descriptor(descriptor: &IpmpDescriptor) -> Vec { + let mut buffer = Vec::new(); + buffer.push(descriptor.descriptor_id); + write_u16(&mut buffer, descriptor.ipmps_type); + if descriptor.descriptor_id == 0xff && descriptor.ipmps_type == 0xffff { + write_u16(&mut buffer, descriptor.descriptor_id_ex); + buffer.extend_from_slice(&descriptor.tool_id); + buffer.push(descriptor.control_point_code); + if descriptor.control_point_code > 0 { + buffer.push(descriptor.sequence_code); + } + buffer.extend_from_slice(&descriptor.data); + } else if descriptor.ipmps_type == 0 { + buffer.extend_from_slice(&descriptor.url_string); + buffer.push(0); + } else { + buffer.extend_from_slice(&descriptor.data); + } + buffer +} + fn encode_descriptor_stream(descriptors: &[Descriptor]) -> Result, FieldValueError> { let mut buffer = Vec::new(); for descriptor in descriptors { @@ -282,6 +414,15 @@ fn encode_descriptor_stream(descriptors: &[Descriptor]) -> Result, Field Ok(buffer) } +fn encode_command_payload(command: &DescriptorCommand) -> Result<(u8, Vec), FieldValueError> { + match command { + DescriptorCommand::DescriptorUpdate(command) => { + Ok((command.tag, encode_descriptor_stream(&command.descriptors)?)) + } + DescriptorCommand::Unknown(command) => Ok((command.tag, command.data.clone())), + } +} + fn parse_es_descriptor( field_name: &'static str, reader: &mut Cursor<&[u8]>, @@ -348,6 +489,179 @@ fn parse_decoder_config_descriptor( }) } +fn parse_object_descriptor_payload( + field_name: &'static str, + payload: &[u8], +) -> Result { + let mut reader = Cursor::new(payload); + let bits = read_u16(&mut reader, field_name)?; + let object_descriptor_id = bits >> 6; + let url_flag = bits & (1 << 5) != 0; + let (url_length, url_string) = if url_flag { + let url_length = read_u8(&mut reader, field_name)?; + let url_string = read_exact_bytes(&mut reader, usize::from(url_length), field_name)?; + (url_length, url_string) + } else { + (0, Vec::new()) + }; + let sub_descriptors = + parse_descriptor_stream(field_name, &payload[reader.position() as usize..])?; + + Ok(ObjectDescriptor { + object_descriptor_id, + url_flag, + url_length, + url_string, + sub_descriptors, + }) +} + +fn parse_initial_object_descriptor_payload( + field_name: &'static str, + payload: &[u8], +) -> Result { + let mut reader = Cursor::new(payload); + let bits = read_u16(&mut reader, field_name)?; + let object_descriptor_id = bits >> 6; + let url_flag = bits & (1 << 5) != 0; + let include_inline_profile_level_flag = bits & (1 << 4) != 0; + let (url_length, url_string) = if url_flag { + let url_length = read_u8(&mut reader, field_name)?; + let url_string = read_exact_bytes(&mut reader, usize::from(url_length), field_name)?; + (url_length, url_string) + } else { + (0, Vec::new()) + }; + let ( + od_profile_level_indication, + scene_profile_level_indication, + audio_profile_level_indication, + visual_profile_level_indication, + graphics_profile_level_indication, + ) = if url_flag { + (0, 0, 0, 0, 0) + } else { + ( + read_u8(&mut reader, field_name)?, + read_u8(&mut reader, field_name)?, + read_u8(&mut reader, field_name)?, + read_u8(&mut reader, field_name)?, + read_u8(&mut reader, field_name)?, + ) + }; + let sub_descriptors = + parse_descriptor_stream(field_name, &payload[reader.position() as usize..])?; + + Ok(InitialObjectDescriptor { + object_descriptor_id, + url_flag, + include_inline_profile_level_flag, + url_length, + url_string, + od_profile_level_indication, + scene_profile_level_indication, + audio_profile_level_indication, + visual_profile_level_indication, + graphics_profile_level_indication, + sub_descriptors, + }) +} + +fn parse_ipmp_descriptor_pointer_payload( + field_name: &'static str, + payload: &[u8], +) -> Result { + let mut reader = Cursor::new(payload); + let descriptor_id = read_u8(&mut reader, field_name)?; + let (descriptor_id_ex, es_id) = if descriptor_id == 0xff { + ( + read_u16(&mut reader, field_name)?, + read_u16(&mut reader, field_name)?, + ) + } else { + (0, 0) + }; + Ok(IpmpDescriptorPointer { + descriptor_id, + descriptor_id_ex, + es_id, + }) +} + +fn parse_ipmp_descriptor_payload( + field_name: &'static str, + payload: &[u8], +) -> Result { + let mut reader = Cursor::new(payload); + let descriptor_id = read_u8(&mut reader, field_name)?; + let ipmps_type = read_u16(&mut reader, field_name)?; + let mut tool_id = [0_u8; 16]; + let (descriptor_id_ex, control_point_code, sequence_code, url_string, data) = + if descriptor_id == 0xff && ipmps_type == 0xffff { + let descriptor_id_ex = read_u16(&mut reader, field_name)?; + let tool_id_bytes = read_exact_bytes(&mut reader, 16, field_name)?; + tool_id.copy_from_slice(&tool_id_bytes); + let control_point_code = read_u8(&mut reader, field_name)?; + let sequence_code = if control_point_code > 0 { + read_u8(&mut reader, field_name)? + } else { + 0 + }; + ( + descriptor_id_ex, + control_point_code, + sequence_code, + Vec::new(), + payload[reader.position() as usize..].to_vec(), + ) + } else if ipmps_type == 0 { + let mut url_string = payload[reader.position() as usize..].to_vec(); + if url_string.last().copied() == Some(0) { + url_string.pop(); + } + (0, 0, 0, url_string, Vec::new()) + } else { + ( + 0, + 0, + 0, + Vec::new(), + payload[reader.position() as usize..].to_vec(), + ) + }; + + Ok(IpmpDescriptor { + descriptor_id, + ipmps_type, + descriptor_id_ex, + tool_id, + control_point_code, + sequence_code, + url_string, + data, + }) +} + +fn parse_es_id_inc_descriptor_payload( + field_name: &'static str, + payload: &[u8], +) -> Result { + let mut reader = Cursor::new(payload); + Ok(EsIdIncDescriptor { + track_id: read_u32(&mut reader, field_name)?, + }) +} + +fn parse_es_id_ref_descriptor_payload( + field_name: &'static str, + payload: &[u8], +) -> Result { + let mut reader = Cursor::new(payload); + Ok(EsIdRefDescriptor { + ref_index: read_u16(&mut reader, field_name)?, + }) +} + fn parse_descriptor_stream( field_name: &'static str, bytes: &[u8], @@ -365,6 +679,17 @@ fn parse_descriptor_stream( }; match tag { + MP4_OBJECT_DESCRIPTOR_TAG => { + descriptor.data = read_exact_bytes(&mut reader, size as usize, field_name)?; + parse_object_descriptor_payload("ObjectDescriptor", &descriptor.data)?; + } + MP4_INITIAL_OBJECT_DESCRIPTOR_TAG => { + descriptor.data = read_exact_bytes(&mut reader, size as usize, field_name)?; + parse_initial_object_descriptor_payload( + "InitialObjectDescriptor", + &descriptor.data, + )?; + } ES_DESCRIPTOR_TAG => { descriptor.es_descriptor = Some(parse_es_descriptor("ESDescriptor", &mut reader)?); } @@ -374,6 +699,22 @@ fn parse_descriptor_stream( &mut reader, )?); } + ES_ID_INC_DESCRIPTOR_TAG => { + descriptor.data = read_exact_bytes(&mut reader, size as usize, field_name)?; + parse_es_id_inc_descriptor_payload("EsIdIncDescriptor", &descriptor.data)?; + } + ES_ID_REF_DESCRIPTOR_TAG => { + descriptor.data = read_exact_bytes(&mut reader, size as usize, field_name)?; + parse_es_id_ref_descriptor_payload("EsIdRefDescriptor", &descriptor.data)?; + } + IPMP_DESCRIPTOR_POINTER_TAG => { + descriptor.data = read_exact_bytes(&mut reader, size as usize, field_name)?; + parse_ipmp_descriptor_pointer_payload("IpmpDescriptorPointer", &descriptor.data)?; + } + IPMP_DESCRIPTOR_TAG => { + descriptor.data = read_exact_bytes(&mut reader, size as usize, field_name)?; + parse_ipmp_descriptor_payload("IpmpDescriptor", &descriptor.data)?; + } _ => { descriptor.data = read_exact_bytes(&mut reader, size as usize, field_name)?; } @@ -385,6 +726,50 @@ fn parse_descriptor_stream( Ok(descriptors) } +/// Decodes one OD-stream command payload into additive typed command records. +/// +/// The helper currently recognizes the object-descriptor-update and IPMP-descriptor-update +/// command families and preserves any other command tags as raw payload bytes. +pub fn parse_descriptor_commands(bytes: &[u8]) -> Result, FieldValueError> { + let mut reader = Cursor::new(bytes); + let mut commands = Vec::new(); + while (reader.position() as usize) < bytes.len() { + let tag = read_u8(&mut reader, "CommandTag")?; + let size = read_uvarint(&mut reader, "CommandSize")?; + let data = read_exact_bytes(&mut reader, size as usize, "CommandData")?; + match tag { + OBJECT_DESCRIPTOR_UPDATE_COMMAND_TAG | IPMP_DESCRIPTOR_UPDATE_COMMAND_TAG => { + commands.push(DescriptorCommand::DescriptorUpdate( + DescriptorUpdateCommand { + tag, + descriptors: parse_descriptor_stream("Descriptors", &data)?, + }, + )); + } + _ => commands.push(DescriptorCommand::Unknown(UnknownDescriptorCommand { + tag, + data, + })), + } + } + + Ok(commands) +} + +/// Encodes additive OD-stream command records into one contiguous command payload. +pub fn encode_descriptor_commands( + commands: &[DescriptorCommand], +) -> Result, FieldValueError> { + let mut buffer = Vec::new(); + for command in commands { + let (tag, data) = encode_command_payload(command)?; + buffer.push(tag); + write_uvarint(&mut buffer, "CommandSize", data.len() as u32)?; + buffer.extend_from_slice(&data); + } + Ok(buffer) +} + fn render_es_descriptor(descriptor: &EsDescriptor) -> String { let mut fields = vec![ format!("ESID={}", descriptor.es_id), @@ -424,6 +809,110 @@ fn render_decoder_config_descriptor(descriptor: &DecoderConfigDescriptor) -> Str .join(" ") } +fn render_object_descriptor(descriptor: &ObjectDescriptor) -> String { + let mut fields = vec![ + format!("ObjectDescriptorID={}", descriptor.object_descriptor_id), + format!("UrlFlag={}", descriptor.url_flag), + ]; + if descriptor.url_flag { + fields.push(format!("URLLength=0x{:x}", descriptor.url_length)); + fields.push(format!("URLString={}", quote_bytes(&descriptor.url_string))); + } + if !descriptor.sub_descriptors.is_empty() { + fields.push(format!( + "SubDescriptors={}", + render_descriptors(&descriptor.sub_descriptors) + )); + } + fields.join(" ") +} + +fn render_initial_object_descriptor(descriptor: &InitialObjectDescriptor) -> String { + let mut fields = vec![ + format!("ObjectDescriptorID={}", descriptor.object_descriptor_id), + format!("UrlFlag={}", descriptor.url_flag), + format!( + "IncludeInlineProfileLevelFlag={}", + descriptor.include_inline_profile_level_flag + ), + ]; + if descriptor.url_flag { + fields.push(format!("URLLength=0x{:x}", descriptor.url_length)); + fields.push(format!("URLString={}", quote_bytes(&descriptor.url_string))); + } else { + fields.extend([ + format!( + "ODProfileLevelIndication=0x{:x}", + descriptor.od_profile_level_indication + ), + format!( + "SceneProfileLevelIndication=0x{:x}", + descriptor.scene_profile_level_indication + ), + format!( + "AudioProfileLevelIndication=0x{:x}", + descriptor.audio_profile_level_indication + ), + format!( + "VisualProfileLevelIndication=0x{:x}", + descriptor.visual_profile_level_indication + ), + format!( + "GraphicsProfileLevelIndication=0x{:x}", + descriptor.graphics_profile_level_indication + ), + ]); + } + if !descriptor.sub_descriptors.is_empty() { + fields.push(format!( + "SubDescriptors={}", + render_descriptors(&descriptor.sub_descriptors) + )); + } + fields.join(" ") +} + +fn render_ipmp_descriptor_pointer(descriptor: &IpmpDescriptorPointer) -> String { + let mut fields = vec![format!("DescriptorID=0x{:x}", descriptor.descriptor_id)]; + if descriptor.descriptor_id == 0xff { + fields.push(format!( + "DescriptorIDEx=0x{:x}", + descriptor.descriptor_id_ex + )); + fields.push(format!("ESID={}", descriptor.es_id)); + } + fields.join(" ") +} + +fn render_ipmp_descriptor(descriptor: &IpmpDescriptor) -> String { + let mut fields = vec![ + format!("DescriptorID=0x{:x}", descriptor.descriptor_id), + format!("IPMPSType=0x{:x}", descriptor.ipmps_type), + ]; + if descriptor.descriptor_id == 0xff && descriptor.ipmps_type == 0xffff { + fields.push(format!( + "DescriptorIDEx=0x{:x}", + descriptor.descriptor_id_ex + )); + fields.push(format!("ToolID={}", render_hex_bytes(&descriptor.tool_id))); + fields.push(format!( + "ControlPointCode={}", + descriptor.control_point_code + )); + if descriptor.control_point_code > 0 { + fields.push(format!("SequenceCode={}", descriptor.sequence_code)); + } + if !descriptor.data.is_empty() { + fields.push(format!("Data={}", render_hex_bytes(&descriptor.data))); + } + } else if descriptor.ipmps_type == 0 { + fields.push(format!("URLString={}", quote_bytes(&descriptor.url_string))); + } else if !descriptor.data.is_empty() { + fields.push(format!("Data={}", render_hex_bytes(&descriptor.data))); + } + fields.join(" ") +} + fn render_descriptor(descriptor: &Descriptor) -> String { let mut fields = vec![ format!("Tag={}", render_descriptor_tag(descriptor.tag)), @@ -431,6 +920,16 @@ fn render_descriptor(descriptor: &Descriptor) -> String { ]; match descriptor.tag { + MP4_OBJECT_DESCRIPTOR_TAG => { + if let Some(nested) = descriptor.object_descriptor() { + fields.push(render_object_descriptor(&nested)); + } + } + MP4_INITIAL_OBJECT_DESCRIPTOR_TAG => { + if let Some(nested) = descriptor.initial_object_descriptor() { + fields.push(render_initial_object_descriptor(&nested)); + } + } ES_DESCRIPTOR_TAG => { if let Some(nested) = descriptor.es_descriptor.as_ref() { fields.push(render_es_descriptor(nested)); @@ -441,6 +940,26 @@ fn render_descriptor(descriptor: &Descriptor) -> String { fields.push(render_decoder_config_descriptor(nested)); } } + ES_ID_INC_DESCRIPTOR_TAG => { + if let Some(nested) = descriptor.es_id_inc_descriptor() { + fields.push(format!("TrackID={}", nested.track_id)); + } + } + ES_ID_REF_DESCRIPTOR_TAG => { + if let Some(nested) = descriptor.es_id_ref_descriptor() { + fields.push(format!("RefIndex={}", nested.ref_index)); + } + } + IPMP_DESCRIPTOR_POINTER_TAG => { + if let Some(nested) = descriptor.ipmp_descriptor_pointer() { + fields.push(render_ipmp_descriptor_pointer(&nested)); + } + } + IPMP_DESCRIPTOR_TAG => { + if let Some(nested) = descriptor.ipmp_descriptor() { + fields.push(render_ipmp_descriptor(&nested)); + } + } _ => { fields.push(format!("Data={}", render_hex_bytes(&descriptor.data))); } @@ -556,6 +1075,107 @@ impl CodecBox for Esds { const SUPPORTED_VERSIONS: &'static [u8] = &[0]; } +/// Initial-object descriptor box carried under `moov`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Iods { + full_box: FullBoxState, + pub descriptor: Option, +} + +impl FieldHooks for Iods { + fn display_field(&self, name: &'static str) -> Option { + match name { + "Descriptor" => self.descriptor.as_ref().map(render_descriptor), + _ => None, + } + } +} + +impl ImmutableBox for Iods { + fn box_type(&self) -> FourCc { + FourCc::from_bytes(*b"iods") + } + + fn version(&self) -> u8 { + self.full_box.version + } + + fn flags(&self) -> u32 { + self.full_box.flags + } +} + +impl MutableBox for Iods { + fn set_version(&mut self, version: u8) { + self.full_box.version = version; + } + + fn set_flags(&mut self, flags: u32) { + self.full_box.flags = flags; + } +} + +impl Iods { + /// Returns the typed descriptor carried by the box when present. + pub fn descriptor(&self) -> Option<&Descriptor> { + self.descriptor.as_ref() + } + + /// Returns the initial-object descriptor payload when the carried descriptor uses that tag. + pub fn initial_object_descriptor(&self) -> Option { + self.descriptor + .as_ref() + .and_then(Descriptor::initial_object_descriptor) + } +} + +impl FieldValueRead for Iods { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "Descriptor" => { + let descriptors = self.descriptor.iter().cloned().collect::>(); + Ok(FieldValue::Bytes(encode_descriptor_stream(&descriptors)?)) + } + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for Iods { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("Descriptor", FieldValue::Bytes(bytes)) => { + let descriptors = parse_descriptor_stream(field_name, &bytes)?; + self.descriptor = match descriptors.len() { + 0 => None, + 1 => Some(descriptors.into_iter().next().unwrap()), + _ => { + return Err(invalid_value( + field_name, + "iods may carry at most one descriptor", + )); + } + }; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for Iods { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("Version", 0, with_bit_width(8), as_version_field()), + codec_field!("Flags", 1, with_bit_width(24), as_flags_field()), + codec_field!("Descriptor", 2, with_bit_width(8), as_bytes()), + ]); + const SUPPORTED_VERSIONS: &'static [u8] = &[0]; +} + /// One tag-sized record within the `esds` descriptor stream. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Descriptor { @@ -571,6 +1191,195 @@ impl Descriptor { pub fn tag_name(&self) -> Option<&'static str> { descriptor_tag_name(self.tag) } + + /// Builds a typed MP4 object-descriptor record. + pub fn from_object_descriptor(descriptor: ObjectDescriptor) -> Result { + let data = encode_object_descriptor("ObjectDescriptor", &descriptor)?; + Ok(Self { + tag: MP4_OBJECT_DESCRIPTOR_TAG, + size: data.len() as u32, + data, + ..Self::default() + }) + } + + /// Builds a typed MP4 initial-object-descriptor record. + pub fn from_initial_object_descriptor( + descriptor: InitialObjectDescriptor, + ) -> Result { + let data = encode_initial_object_descriptor("InitialObjectDescriptor", &descriptor)?; + Ok(Self { + tag: MP4_INITIAL_OBJECT_DESCRIPTOR_TAG, + size: data.len() as u32, + data, + ..Self::default() + }) + } + + /// Builds a typed ES-ID-increment descriptor record. + pub fn from_es_id_inc_descriptor(descriptor: EsIdIncDescriptor) -> Self { + let mut data = Vec::new(); + write_u32(&mut data, descriptor.track_id); + Self { + tag: ES_ID_INC_DESCRIPTOR_TAG, + size: data.len() as u32, + data, + ..Self::default() + } + } + + /// Builds a typed ES-ID-reference descriptor record. + pub fn from_es_id_ref_descriptor(descriptor: EsIdRefDescriptor) -> Self { + let mut data = Vec::new(); + write_u16(&mut data, descriptor.ref_index); + Self { + tag: ES_ID_REF_DESCRIPTOR_TAG, + size: data.len() as u32, + data, + ..Self::default() + } + } + + /// Builds a typed IPMP descriptor-pointer record. + pub fn from_ipmp_descriptor_pointer(descriptor: IpmpDescriptorPointer) -> Self { + let data = encode_ipmp_descriptor_pointer(&descriptor); + Self { + tag: IPMP_DESCRIPTOR_POINTER_TAG, + size: data.len() as u32, + data, + ..Self::default() + } + } + + /// Builds a typed IPMP descriptor record. + pub fn from_ipmp_descriptor(descriptor: IpmpDescriptor) -> Self { + let data = encode_ipmp_descriptor(&descriptor); + Self { + tag: IPMP_DESCRIPTOR_TAG, + size: data.len() as u32, + data, + ..Self::default() + } + } + + /// Returns the typed MP4 object-descriptor payload when the tag matches. + pub fn object_descriptor(&self) -> Option { + (self.tag == MP4_OBJECT_DESCRIPTOR_TAG) + .then(|| parse_object_descriptor_payload("ObjectDescriptor", &self.data)) + .and_then(Result::ok) + } + + /// Returns the typed MP4 initial-object-descriptor payload when the tag matches. + pub fn initial_object_descriptor(&self) -> Option { + (self.tag == MP4_INITIAL_OBJECT_DESCRIPTOR_TAG) + .then(|| parse_initial_object_descriptor_payload("InitialObjectDescriptor", &self.data)) + .and_then(Result::ok) + } + + /// Returns the typed ES-ID-increment payload when the tag matches. + pub fn es_id_inc_descriptor(&self) -> Option { + (self.tag == ES_ID_INC_DESCRIPTOR_TAG) + .then(|| parse_es_id_inc_descriptor_payload("EsIdIncDescriptor", &self.data)) + .and_then(Result::ok) + } + + /// Returns the typed ES-ID-reference payload when the tag matches. + pub fn es_id_ref_descriptor(&self) -> Option { + (self.tag == ES_ID_REF_DESCRIPTOR_TAG) + .then(|| parse_es_id_ref_descriptor_payload("EsIdRefDescriptor", &self.data)) + .and_then(Result::ok) + } + + /// Returns the typed IPMP descriptor-pointer payload when the tag matches. + pub fn ipmp_descriptor_pointer(&self) -> Option { + (self.tag == IPMP_DESCRIPTOR_POINTER_TAG) + .then(|| parse_ipmp_descriptor_pointer_payload("IpmpDescriptorPointer", &self.data)) + .and_then(Result::ok) + } + + /// Returns the typed IPMP descriptor payload when the tag matches. + pub fn ipmp_descriptor(&self) -> Option { + (self.tag == IPMP_DESCRIPTOR_TAG) + .then(|| parse_ipmp_descriptor_payload("IpmpDescriptor", &self.data)) + .and_then(Result::ok) + } +} + +/// One parsed OD-stream command record. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DescriptorCommand { + /// A typed object-descriptor-update or IPMP-descriptor-update command. + DescriptorUpdate(DescriptorUpdateCommand), + /// A command tag that the current crate does not model yet. + Unknown(UnknownDescriptorCommand), +} + +impl DescriptorCommand { + /// Returns the raw command tag. + pub fn tag(&self) -> u8 { + match self { + Self::DescriptorUpdate(command) => command.tag, + Self::Unknown(command) => command.tag, + } + } + + /// Returns the standard command name for the current tag when one is known. + pub fn tag_name(&self) -> Option<&'static str> { + command_tag_name(self.tag()) + } + + /// Returns the typed descriptor-update payload when this command carries one. + pub fn descriptor_update(&self) -> Option<&DescriptorUpdateCommand> { + match self { + Self::DescriptorUpdate(command) => Some(command), + Self::Unknown(_) => None, + } + } + + /// Returns the raw unknown-command payload when this command is not yet modeled. + pub fn unknown(&self) -> Option<&UnknownDescriptorCommand> { + match self { + Self::DescriptorUpdate(_) => None, + Self::Unknown(command) => Some(command), + } + } +} + +/// A typed object-descriptor-update or IPMP-descriptor-update command payload. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DescriptorUpdateCommand { + pub tag: u8, + pub descriptors: Vec, +} + +impl DescriptorUpdateCommand { + /// Builds an object-descriptor-update command. + pub fn object_descriptor_update(descriptors: Vec) -> Self { + Self { + tag: OBJECT_DESCRIPTOR_UPDATE_COMMAND_TAG, + descriptors, + } + } + + /// Builds an IPMP-descriptor-update command. + pub fn ipmp_descriptor_update(descriptors: Vec) -> Self { + Self { + tag: IPMP_DESCRIPTOR_UPDATE_COMMAND_TAG, + descriptors, + } + } + + /// Returns the standard command name for the current tag when one is known. + pub fn tag_name(&self) -> Option<&'static str> { + command_tag_name(self.tag) + } +} + +/// One raw OD-stream command tag that the current crate does not model yet. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct UnknownDescriptorCommand { + pub tag: u8, + pub data: Vec, } /// Elementary-stream descriptor payload selected by tag `0x03`. @@ -599,7 +1408,67 @@ pub struct DecoderConfigDescriptor { pub avg_bitrate: u32, } +/// MP4 object descriptor payload selected by tag `0x11`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ObjectDescriptor { + pub object_descriptor_id: u16, + pub url_flag: bool, + pub url_length: u8, + pub url_string: Vec, + pub sub_descriptors: Vec, +} + +/// MP4 initial-object descriptor payload selected by tag `0x10`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct InitialObjectDescriptor { + pub object_descriptor_id: u16, + pub url_flag: bool, + pub include_inline_profile_level_flag: bool, + pub url_length: u8, + pub url_string: Vec, + pub od_profile_level_indication: u8, + pub scene_profile_level_indication: u8, + pub audio_profile_level_indication: u8, + pub visual_profile_level_indication: u8, + pub graphics_profile_level_indication: u8, + pub sub_descriptors: Vec, +} + +/// ES-ID-increment descriptor payload selected by tag `0x0e`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct EsIdIncDescriptor { + pub track_id: u32, +} + +/// ES-ID-reference descriptor payload selected by tag `0x0f`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct EsIdRefDescriptor { + pub ref_index: u16, +} + +/// IPMP descriptor-pointer payload selected by tag `0x0a`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct IpmpDescriptorPointer { + pub descriptor_id: u8, + pub descriptor_id_ex: u16, + pub es_id: u16, +} + +/// IPMP descriptor payload selected by tag `0x0b`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct IpmpDescriptor { + pub descriptor_id: u8, + pub ipmps_type: u16, + pub descriptor_id_ex: u16, + pub tool_id: [u8; 16], + pub control_point_code: u8, + pub sequence_code: u8, + pub url_string: Vec, + pub data: Vec, +} + /// Registers the currently implemented ISO/IEC 14496-14 boxes in `registry`. pub fn register_boxes(registry: &mut BoxRegistry) { registry.register::(FourCc::from_bytes(*b"esds")); + registry.register::(FourCc::from_bytes(*b"iods")); } diff --git a/src/boxes/iso23001_7.rs b/src/boxes/iso23001_7.rs index 8e22a9d..aeacf99 100644 --- a/src/boxes/iso23001_7.rs +++ b/src/boxes/iso23001_7.rs @@ -202,6 +202,49 @@ pub(crate) fn decode_senc_payload(payload: &[u8]) -> Result { Ok(senc) } +#[cfg(feature = "decrypt")] +pub(crate) fn decode_senc_payload_with_iv_size( + payload: &[u8], + iv_size: usize, +) -> Result { + if payload.len() < 8 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + + let version = payload[0]; + let flags = u32::from_be_bytes([0, payload[1], payload[2], payload[3]]); + let mut senc = Senc::default(); + if !senc.is_supported_version(version) { + return Err(CodecError::UnsupportedVersion { + box_type: senc.box_type(), + version, + }); + } + validate_senc_flags(flags)?; + + let sample_count = read_u32(payload, 4); + let sample_count_usize = usize::try_from(sample_count) + .map_err(|_| invalid_value("SampleCount", "sample count does not fit in usize"))?; + let samples = try_parse_senc_samples_with_iv_size( + &payload[8..], + sample_count_usize, + iv_size, + flags & SENC_USE_SUBSAMPLE_ENCRYPTION != 0, + ) + .ok_or_else(|| { + invalid_value( + "Samples", + "payload does not match the forced sample IV size", + ) + })?; + + senc.set_version(version); + senc.set_flags(flags); + senc.sample_count = sample_count; + senc.samples = samples; + Ok(senc) +} + fn resolve_senc_iv_size( field_name: &'static str, samples: &[SencSample], diff --git a/src/boxes/marlin.rs b/src/boxes/marlin.rs new file mode 100644 index 0000000..4c88ae5 --- /dev/null +++ b/src/boxes/marlin.rs @@ -0,0 +1,288 @@ +//! Marlin IPMP decryption-related box definitions and payload helpers. + +use std::fmt::Write as _; + +use crate::boxes::BoxRegistry; +use crate::codec::{ + CodecBox, FieldHooks, FieldTable, FieldValue, FieldValueError, FieldValueRead, FieldValueWrite, + ImmutableBox, MutableBox, +}; +use crate::{FourCc, codec_field}; + +/// File-type brand used by Marlin IPMP protected MP4-family files. +pub const MARLIN_BRAND_MGSV: FourCc = FourCc::from_bytes(*b"MGSV"); + +/// IPMP descriptor type used by Marlin `MGSV` object-descriptor data. +pub const MARLIN_IPMPS_TYPE_MGSV: u16 = 0xA551; + +/// Marlin track-key protection scheme carried by short-form `schm`. +pub const PROTECTION_SCHEME_TYPE_MARLIN_ACBC: FourCc = FourCc::from_bytes(*b"ACBC"); + +/// Marlin group-key protection scheme carried by short-form `schm`. +pub const PROTECTION_SCHEME_TYPE_MARLIN_ACGK: FourCc = FourCc::from_bytes(*b"ACGK"); + +/// Marlin `styp` value used for audio tracks inside carried `sinf` atoms. +pub const MARLIN_STYP_AUDIO: &str = "urn:marlin:organization:sne:content-type:audio"; + +/// Marlin `styp` value used for video tracks inside carried `sinf` atoms. +pub const MARLIN_STYP_VIDEO: &str = "urn:marlin:organization:sne:content-type:video"; + +fn missing_field(field_name: &'static str) -> FieldValueError { + FieldValueError::MissingField { field_name } +} + +fn unexpected_field(field_name: &'static str, value: FieldValue) -> FieldValueError { + FieldValueError::UnexpectedType { + field_name, + expected: "matching codec field value", + actual: value.kind_name(), + } +} + +fn invalid_value(field_name: &'static str, reason: &'static str) -> FieldValueError { + FieldValueError::InvalidValue { field_name, reason } +} + +fn render_hex_bytes(bytes: &[u8]) -> String { + let mut rendered = String::from("["); + let mut first = true; + for byte in bytes { + if !first { + rendered.push_str(", "); + } + first = false; + let _ = write!(&mut rendered, "0x{:x}", byte); + } + rendered.push(']'); + rendered +} + +macro_rules! impl_leaf_box { + ($name:ident, $box_type:expr) => { + impl ImmutableBox for $name { + fn box_type(&self) -> FourCc { + FourCc::from_bytes($box_type) + } + } + + impl MutableBox for $name {} + }; +} + +/// Container atom that groups Marlin stream-attribute records under `schi`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Satr; + +impl_leaf_box!(Satr, *b"satr"); +impl FieldHooks for Satr {} + +impl FieldValueRead for Satr { + fn field_value(&self, field_name: &'static str) -> Result { + Err(missing_field(field_name)) + } +} + +impl FieldValueWrite for Satr { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + Err(unexpected_field(field_name, value)) + } +} + +impl CodecBox for Satr { + const FIELD_TABLE: FieldTable = FieldTable::new(&[]); +} + +/// Raw `hmac` payload carried under Marlin `schi`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Hmac { + /// Raw keyed-hash bytes. + pub data: Vec, +} + +impl_leaf_box!(Hmac, *b"hmac"); + +impl FieldHooks for Hmac { + fn display_field(&self, name: &'static str) -> Option { + (name == "Data").then(|| render_hex_bytes(&self.data)) + } +} + +impl FieldValueRead for Hmac { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "Data" => Ok(FieldValue::Bytes(self.data.clone())), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for Hmac { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("Data", FieldValue::Bytes(value)) => { + self.data = value; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for Hmac { + const FIELD_TABLE: FieldTable = + FieldTable::new(&[codec_field!("Data", 0, with_bit_width(8), as_bytes())]); +} + +/// Raw `gkey` payload carried under Marlin `schi` for group-key unwrap data. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Gkey { + /// Raw wrapped group-key bytes. + pub data: Vec, +} + +impl_leaf_box!(Gkey, *b"gkey"); + +impl FieldHooks for Gkey { + fn display_field(&self, name: &'static str) -> Option { + (name == "Data").then(|| render_hex_bytes(&self.data)) + } +} + +impl FieldValueRead for Gkey { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "Data" => Ok(FieldValue::Bytes(self.data.clone())), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for Gkey { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("Data", FieldValue::Bytes(value)) => { + self.data = value; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for Gkey { + const FIELD_TABLE: FieldTable = + FieldTable::new(&[codec_field!("Data", 0, with_bit_width(8), as_bytes())]); +} + +/// Context-specific payload helper for the Marlin `styp` atom carried under `satr`. +/// +/// This helper is not globally registered because `styp` already has the standard segment-type +/// meaning elsewhere in the MP4 box catalog. The decode path mirrors the current reference +/// behavior by forcing the final payload byte to NUL before extracting the string. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MarlinStyp { + /// The carried Marlin stream-type URN. + pub value: String, +} + +impl MarlinStyp { + /// Decodes one Marlin `styp` payload from the raw atom bytes that follow the box header. + pub fn parse_payload(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Ok(Self::default()); + } + + let mut forced_nul = bytes.to_vec(); + *forced_nul.last_mut().unwrap() = 0; + let string_end = forced_nul.iter().position(|byte| *byte == 0).unwrap(); + let value = String::from_utf8(forced_nul[..string_end].to_vec()) + .map_err(|_| invalid_value("Value", "value is not valid UTF-8"))?; + Ok(Self { value }) + } + + /// Encodes one Marlin `styp` payload as a NUL-terminated byte string. + pub fn encode_payload(&self) -> Result, FieldValueError> { + if self.value.as_bytes().contains(&0) { + return Err(invalid_value("Value", "string contains an embedded NUL")); + } + + let mut payload = self.value.as_bytes().to_vec(); + payload.push(0); + Ok(payload) + } +} + +/// Context-specific short-form `schm` payload carried inside Marlin IPMP `sinf` atoms. +/// +/// This helper is not globally registered because `schm` already has the standard full-length +/// scheme-version layout elsewhere in the MP4 box catalog. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MarlinShortSchm { + /// The carried scheme type such as `ACBC` or `ACGK`. + pub scheme_type: FourCc, + /// The carried 16-bit scheme version. + pub scheme_version: u16, +} + +impl Default for MarlinShortSchm { + fn default() -> Self { + Self { + scheme_type: FourCc::from_bytes(*b"\0\0\0\0"), + scheme_version: 0, + } + } +} + +impl MarlinShortSchm { + /// Decodes the six-byte Marlin short-form `schm` payload after the full-box header. + pub fn parse_payload(bytes: &[u8]) -> Result { + if bytes.len() != 6 { + return Err(invalid_value( + "Payload", + "expected a 6-byte Marlin short-form schm payload", + )); + } + + Ok(Self { + scheme_type: FourCc::from_bytes(bytes[..4].try_into().unwrap()), + scheme_version: u16::from_be_bytes(bytes[4..6].try_into().unwrap()), + }) + } + + /// Encodes the six-byte Marlin short-form `schm` payload after the full-box header. + pub fn encode_payload(&self) -> [u8; 6] { + let mut payload = [0_u8; 6]; + payload[..4].copy_from_slice(self.scheme_type.as_bytes()); + payload[4..6].copy_from_slice(&self.scheme_version.to_be_bytes()); + payload + } + + /// Returns whether this short-form scheme selects the track-key branch. + pub fn uses_track_key(&self) -> bool { + self.scheme_type == PROTECTION_SCHEME_TYPE_MARLIN_ACBC && self.scheme_version == 0x0100 + } + + /// Returns whether this short-form scheme selects the group-key branch. + pub fn uses_group_key(&self) -> bool { + self.scheme_type == PROTECTION_SCHEME_TYPE_MARLIN_ACGK && self.scheme_version == 0x0100 + } +} + +/// Registers the currently implemented globally unique Marlin atoms in `registry`. +pub fn register_boxes(registry: &mut BoxRegistry) { + registry.register::(FourCc::from_bytes(*b"satr")); + registry.register::(FourCc::from_bytes(*b"hmac")); + registry.register::(FourCc::from_bytes(*b"gkey")); +} diff --git a/src/boxes/mod.rs b/src/boxes/mod.rs index 294f6da..806db41 100644 --- a/src/boxes/mod.rs +++ b/src/boxes/mod.rs @@ -15,6 +15,8 @@ pub mod etsi_ts_102_366; pub mod etsi_ts_103_190; /// FLAC sample-entry and decoder-configuration box definitions. pub mod flac; +/// ISMA Cryp protection-related box definitions. +pub mod isma_cryp; /// ISO/IEC 14496-12 box definitions and codec support. pub mod iso14496_12; /// ISO/IEC 14496-14 ES descriptor box definitions and codec support. @@ -27,10 +29,14 @@ pub mod iso14496_30; pub mod iso23001_5; /// ISO/IEC 23001-7 common-encryption box definitions and codec support. pub mod iso23001_7; +/// Marlin IPMP protection-related box definitions and payload helpers. +pub mod marlin; /// Item-list metadata and key-table box definitions. pub mod metadata; /// MPEG-H sample-entry and decoder-configuration box definitions. pub mod mpeg_h; +/// OMA DCF decryption-related box definitions. +pub mod oma_dcf; /// Opus sample-entry and decoder-configuration box definitions. pub mod opus; /// 3GPP `udta`-scoped metadata string box definitions and codec support. @@ -430,7 +436,10 @@ pub fn default_registry() -> BoxRegistry { iso14496_12::register_boxes(&mut registry); iso14496_14::register_boxes(&mut registry); iso14496_30::register_boxes(&mut registry); + isma_cryp::register_boxes(&mut registry); + marlin::register_boxes(&mut registry); metadata::register_boxes(&mut registry); + oma_dcf::register_boxes(&mut registry); threegpp::register_boxes(&mut registry); av1::register_boxes(&mut registry); avs3::register_boxes(&mut registry); diff --git a/src/boxes/oma_dcf.rs b/src/boxes/oma_dcf.rs new file mode 100644 index 0000000..ad9100d --- /dev/null +++ b/src/boxes/oma_dcf.rs @@ -0,0 +1,932 @@ +//! OMA DCF decryption-related box definitions. + +use std::io::Write; + +use crate::boxes::BoxRegistry; +use crate::codec::{ + CodecBox, CodecError, FieldHooks, FieldTable, FieldValue, FieldValueError, FieldValueRead, + FieldValueWrite, ImmutableBox, MutableBox, ReadSeek, StringFieldMode, read_exact_vec_untrusted, +}; +use crate::{FourCc, codec_field}; + +/// `ohdr` encryption-method value for already-clear payloads. +pub const OHDR_ENCRYPTION_METHOD_NULL: u8 = 0; +/// `ohdr` encryption-method value for AES-CBC protected payloads. +pub const OHDR_ENCRYPTION_METHOD_AES_CBC: u8 = 1; +/// `ohdr` encryption-method value for AES-CTR protected payloads. +pub const OHDR_ENCRYPTION_METHOD_AES_CTR: u8 = 2; + +/// `ohdr` padding-scheme value for unpadded payloads. +pub const OHDR_PADDING_SCHEME_NONE: u8 = 0; +/// `ohdr` padding-scheme value for RFC 2630 block padding. +pub const OHDR_PADDING_SCHEME_RFC_2630: u8 = 1; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct FullBoxState { + version: u8, + flags: u32, +} + +fn missing_field(field_name: &'static str) -> FieldValueError { + FieldValueError::MissingField { field_name } +} + +fn unexpected_field(field_name: &'static str, value: FieldValue) -> FieldValueError { + FieldValueError::UnexpectedType { + field_name, + expected: "matching codec field value", + actual: value.kind_name(), + } +} + +fn invalid_value(field_name: &'static str, reason: &'static str) -> FieldValueError { + FieldValueError::InvalidValue { field_name, reason } +} + +fn u8_from_unsigned(field_name: &'static str, value: u64) -> Result { + u8::try_from(value).map_err(|_| invalid_value(field_name, "value does not fit in u8")) +} + +fn read_u16(bytes: &[u8], offset: usize) -> u16 { + u16::from_be_bytes(bytes[offset..offset + 2].try_into().unwrap()) +} + +fn read_u64(bytes: &[u8], offset: usize) -> u64 { + u64::from_be_bytes(bytes[offset..offset + 8].try_into().unwrap()) +} + +fn push_uint( + field_name: &'static str, + bytes: &mut Vec, + width_bytes: usize, + value: u64, +) -> Result<(), FieldValueError> { + let max_value = if width_bytes == 8 { + u64::MAX + } else { + (1_u64 << (width_bytes * 8)) - 1 + }; + if value > max_value { + return Err(invalid_value( + field_name, + "value does not fit in the configured byte width", + )); + } + + for shift in (0..width_bytes).rev() { + bytes.push((value >> (shift * 8)) as u8); + } + Ok(()) +} + +fn validate_len_prefixed_string( + field_name: &'static str, + value: &str, + max_len: usize, +) -> Result<(), FieldValueError> { + if value.len() > max_len { + return Err(invalid_value( + field_name, + "string length exceeds the field capacity", + )); + } + Ok(()) +} + +fn decode_utf8_string(field_name: &'static str, bytes: &[u8]) -> Result { + String::from_utf8(bytes.to_vec()) + .map_err(|_| invalid_value(field_name, "value is not valid UTF-8")) +} + +macro_rules! impl_leaf_box { + ($name:ident, $box_type:expr) => { + impl ImmutableBox for $name { + fn box_type(&self) -> FourCc { + FourCc::from_bytes($box_type) + } + } + + impl MutableBox for $name {} + }; +} + +macro_rules! impl_full_box { + ($name:ident, $box_type:expr) => { + impl ImmutableBox for $name { + fn box_type(&self) -> FourCc { + FourCc::from_bytes($box_type) + } + + fn version(&self) -> u8 { + self.full_box.version + } + + fn flags(&self) -> u32 { + self.full_box.flags + } + } + + impl MutableBox for $name { + fn set_version(&mut self, version: u8) { + self.full_box.version = version; + } + + fn set_flags(&mut self, flags: u32) { + self.full_box.flags = flags; + } + } + }; +} + +macro_rules! empty_box_codec { + ($name:ident) => { + impl FieldValueRead for $name { + fn field_value(&self, field_name: &'static str) -> Result { + Err(missing_field(field_name)) + } + } + + impl FieldValueWrite for $name { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + Err(unexpected_field(field_name, value)) + } + } + + impl CodecBox for $name { + const FIELD_TABLE: FieldTable = FieldTable::new(&[]); + } + }; +} + +macro_rules! empty_full_box_codec { + ($name:ident) => { + impl FieldValueRead for $name { + fn field_value(&self, field_name: &'static str) -> Result { + Err(missing_field(field_name)) + } + } + + impl FieldValueWrite for $name { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + Err(unexpected_field(field_name, value)) + } + } + + impl CodecBox for $name { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("Version", 0, with_bit_width(8), as_version_field()), + codec_field!("Flags", 1, with_bit_width(24), as_flags_field()), + ]); + const SUPPORTED_VERSIONS: &'static [u8] = &[0]; + } + }; +} + +macro_rules! simple_container_box { + ($name:ident, $box_type:expr, $doc:literal) => { + #[doc = $doc] + #[derive(Clone, Debug, Default, PartialEq, Eq)] + pub struct $name; + + impl_leaf_box!($name, $box_type); + impl FieldHooks for $name {} + empty_box_codec!($name); + }; +} + +macro_rules! simple_full_container_box { + ($name:ident, $box_type:expr, $doc:literal) => { + #[doc = $doc] + #[derive(Clone, Debug, Default, PartialEq, Eq)] + pub struct $name { + full_box: FullBoxState, + } + + impl FieldHooks for $name {} + impl_full_box!($name, $box_type); + empty_full_box_codec!($name); + }; +} + +simple_container_box!( + Odrm, + *b"odrm", + "Container box that wraps top-level OMA DRM metadata and encrypted payload atoms." +); +simple_full_container_box!( + Odkm, + *b"odkm", + "Scheme-specific OMA DRM container carried under `schi`." +); + +/// OMA DRM header container that carries the protected content type and nested header metadata. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Odhe { + full_box: FullBoxState, + pub content_type: String, +} + +impl FieldHooks for Odhe {} +impl_full_box!(Odhe, *b"odhe"); + +impl FieldValueRead for Odhe { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "ContentType" => Ok(FieldValue::String(self.content_type.clone())), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for Odhe { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("ContentType", FieldValue::String(value)) => { + validate_len_prefixed_string(field_name, &value, usize::from(u8::MAX))?; + self.content_type = value; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for Odhe { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("Version", 0, with_bit_width(8), as_version_field()), + codec_field!("Flags", 1, with_bit_width(24), as_flags_field()), + codec_field!( + "ContentType", + 2, + with_bit_width(8), + as_string(StringFieldMode::PascalCompatible) + ), + ]); + const SUPPORTED_VERSIONS: &'static [u8] = &[0]; + + fn custom_marshal(&self, writer: &mut dyn Write) -> Result, CodecError> { + validate_len_prefixed_string("ContentType", &self.content_type, usize::from(u8::MAX))?; + if self.version() != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version: self.version(), + }); + } + + let mut payload = Vec::with_capacity(5 + self.content_type.len()); + payload.push(self.version()); + push_uint("Flags", &mut payload, 3, u64::from(self.flags()))?; + payload.push(self.content_type.len() as u8); + payload.extend_from_slice(self.content_type.as_bytes()); + writer.write_all(&payload)?; + Ok(Some(payload.len() as u64)) + } + + fn custom_unmarshal( + &mut self, + reader: &mut dyn ReadSeek, + payload_size: u64, + ) -> Result, CodecError> { + let payload_len = usize::try_from(payload_size) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; + let payload = read_exact_vec_untrusted(reader, payload_len).map_err(CodecError::Io)?; + + if payload.len() < 5 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + + let version = payload[0]; + if version != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + let content_type_len = usize::from(payload[4]); + let fixed_len = 5 + content_type_len; + if payload.len() < fixed_len { + return Err(invalid_value("Payload", "content-type bytes are truncated").into()); + } + + self.full_box = FullBoxState { + version, + flags: ((payload[1] as u32) << 16) | ((payload[2] as u32) << 8) | u32::from(payload[3]), + }; + self.content_type = decode_utf8_string("ContentType", &payload[5..fixed_len])?; + Ok(Some(fixed_len as u64)) + } +} + +/// OMA DRM information box that carries encryption parameters, content identifiers, and nested metadata. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Ohdr { + full_box: FullBoxState, + pub encryption_method: u8, + pub padding_scheme: u8, + pub plaintext_length: u64, + pub content_id: String, + pub rights_issuer_url: String, + pub textual_headers: Vec, +} + +impl FieldHooks for Ohdr { + fn field_length(&self, name: &'static str) -> Option { + match name { + "TextualHeaders" => u32::try_from(self.textual_headers.len()).ok(), + _ => None, + } + } +} +impl_full_box!(Ohdr, *b"ohdr"); + +impl FieldValueRead for Ohdr { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "EncryptionMethod" => Ok(FieldValue::Unsigned(u64::from(self.encryption_method))), + "PaddingScheme" => Ok(FieldValue::Unsigned(u64::from(self.padding_scheme))), + "PlaintextLength" => Ok(FieldValue::Unsigned(self.plaintext_length)), + "ContentId" => Ok(FieldValue::String(self.content_id.clone())), + "RightsIssuerUrl" => Ok(FieldValue::String(self.rights_issuer_url.clone())), + "TextualHeaders" => Ok(FieldValue::Bytes(self.textual_headers.clone())), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for Ohdr { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("EncryptionMethod", FieldValue::Unsigned(value)) => { + self.encryption_method = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("PaddingScheme", FieldValue::Unsigned(value)) => { + self.padding_scheme = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("PlaintextLength", FieldValue::Unsigned(value)) => { + self.plaintext_length = value; + Ok(()) + } + ("ContentId", FieldValue::String(value)) => { + validate_len_prefixed_string(field_name, &value, usize::from(u16::MAX))?; + self.content_id = value; + Ok(()) + } + ("RightsIssuerUrl", FieldValue::String(value)) => { + validate_len_prefixed_string(field_name, &value, usize::from(u16::MAX))?; + self.rights_issuer_url = value; + Ok(()) + } + ("TextualHeaders", FieldValue::Bytes(value)) => { + if value.len() > usize::from(u16::MAX) { + return Err(invalid_value( + field_name, + "payload length exceeds the field capacity", + )); + } + self.textual_headers = value; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for Ohdr { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("Version", 0, with_bit_width(8), as_version_field()), + codec_field!("Flags", 1, with_bit_width(24), as_flags_field()), + codec_field!("EncryptionMethod", 2, with_bit_width(8)), + codec_field!("PaddingScheme", 3, with_bit_width(8)), + codec_field!("PlaintextLength", 4, with_bit_width(64)), + codec_field!( + "ContentId", + 5, + with_bit_width(8), + as_string(StringFieldMode::PascalCompatible) + ), + codec_field!( + "RightsIssuerUrl", + 6, + with_bit_width(8), + as_string(StringFieldMode::PascalCompatible) + ), + codec_field!( + "TextualHeaders", + 7, + with_bit_width(8), + with_dynamic_length(), + as_bytes() + ), + ]); + const SUPPORTED_VERSIONS: &'static [u8] = &[0]; + + fn custom_marshal(&self, writer: &mut dyn Write) -> Result, CodecError> { + validate_len_prefixed_string("ContentId", &self.content_id, usize::from(u16::MAX))?; + validate_len_prefixed_string( + "RightsIssuerUrl", + &self.rights_issuer_url, + usize::from(u16::MAX), + )?; + if self.textual_headers.len() > usize::from(u16::MAX) { + return Err(invalid_value( + "TextualHeaders", + "payload length exceeds the field capacity", + ) + .into()); + } + if self.version() != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version: self.version(), + }); + } + + let mut payload = Vec::with_capacity( + 20 + self.content_id.len() + self.rights_issuer_url.len() + self.textual_headers.len(), + ); + payload.push(self.version()); + push_uint("Flags", &mut payload, 3, u64::from(self.flags()))?; + payload.push(self.encryption_method); + payload.push(self.padding_scheme); + payload.extend_from_slice(&self.plaintext_length.to_be_bytes()); + payload.extend_from_slice(&(self.content_id.len() as u16).to_be_bytes()); + payload.extend_from_slice(&(self.rights_issuer_url.len() as u16).to_be_bytes()); + payload.extend_from_slice(&(self.textual_headers.len() as u16).to_be_bytes()); + payload.extend_from_slice(self.content_id.as_bytes()); + payload.extend_from_slice(self.rights_issuer_url.as_bytes()); + payload.extend_from_slice(&self.textual_headers); + writer.write_all(&payload)?; + Ok(Some(payload.len() as u64)) + } + + fn custom_unmarshal( + &mut self, + reader: &mut dyn ReadSeek, + payload_size: u64, + ) -> Result, CodecError> { + let payload_len = usize::try_from(payload_size) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; + let payload = read_exact_vec_untrusted(reader, payload_len).map_err(CodecError::Io)?; + + if payload.len() < 20 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + + let version = payload[0]; + if version != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + let content_id_len = usize::from(read_u16(&payload, 14)); + let rights_issuer_url_len = usize::from(read_u16(&payload, 16)); + let textual_headers_len = usize::from(read_u16(&payload, 18)); + let fixed_len = 20 + content_id_len + rights_issuer_url_len + textual_headers_len; + if payload.len() < fixed_len { + return Err(invalid_value("Payload", "header strings are truncated").into()); + } + + let content_id_offset = 20; + let rights_issuer_url_offset = content_id_offset + content_id_len; + let textual_headers_offset = rights_issuer_url_offset + rights_issuer_url_len; + + self.full_box = FullBoxState { + version, + flags: ((payload[1] as u32) << 16) | ((payload[2] as u32) << 8) | u32::from(payload[3]), + }; + self.encryption_method = payload[4]; + self.padding_scheme = payload[5]; + self.plaintext_length = read_u64(&payload, 6); + self.content_id = decode_utf8_string( + "ContentId", + &payload[content_id_offset..rights_issuer_url_offset], + )?; + self.rights_issuer_url = decode_utf8_string( + "RightsIssuerUrl", + &payload[rights_issuer_url_offset..textual_headers_offset], + )?; + self.textual_headers = + payload[textual_headers_offset..textual_headers_offset + textual_headers_len].to_vec(); + Ok(Some(fixed_len as u64)) + } +} + +/// OMA access-unit format box carried under `odkm`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Odaf { + full_box: FullBoxState, + pub selective_encryption: bool, + pub key_indicator_length: u8, + pub iv_length: u8, +} + +impl FieldHooks for Odaf {} +impl_full_box!(Odaf, *b"odaf"); + +impl FieldValueRead for Odaf { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "SelectiveEncryption" => Ok(FieldValue::Boolean(self.selective_encryption)), + "KeyIndicatorLength" => Ok(FieldValue::Unsigned(u64::from(self.key_indicator_length))), + "IvLength" => Ok(FieldValue::Unsigned(u64::from(self.iv_length))), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for Odaf { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("SelectiveEncryption", FieldValue::Boolean(value)) => { + self.selective_encryption = value; + Ok(()) + } + ("KeyIndicatorLength", FieldValue::Unsigned(value)) => { + self.key_indicator_length = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("IvLength", FieldValue::Unsigned(value)) => { + self.iv_length = u8_from_unsigned(field_name, value)?; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for Odaf { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("Version", 0, with_bit_width(8), as_version_field()), + codec_field!("Flags", 1, with_bit_width(24), as_flags_field()), + codec_field!("SelectiveEncryption", 2, with_bit_width(1), as_boolean()), + codec_field!("KeyIndicatorLength", 3, with_bit_width(8)), + codec_field!("IvLength", 4, with_bit_width(8)), + ]); + const SUPPORTED_VERSIONS: &'static [u8] = &[0]; + + fn custom_marshal(&self, writer: &mut dyn Write) -> Result, CodecError> { + if self.version() != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version: self.version(), + }); + } + + let mut payload = Vec::with_capacity(7); + payload.push(self.version()); + push_uint("Flags", &mut payload, 3, u64::from(self.flags()))?; + payload.push(if self.selective_encryption { + 0x80 + } else { + 0x00 + }); + payload.push(self.key_indicator_length); + payload.push(self.iv_length); + writer.write_all(&payload)?; + Ok(Some(payload.len() as u64)) + } + + fn custom_unmarshal( + &mut self, + reader: &mut dyn ReadSeek, + payload_size: u64, + ) -> Result, CodecError> { + let payload_len = usize::try_from(payload_size) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; + let payload = read_exact_vec_untrusted(reader, payload_len).map_err(CodecError::Io)?; + + if payload.len() != 7 { + return Err(invalid_value("Payload", "payload length must be exactly 7 bytes").into()); + } + + let version = payload[0]; + if version != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + self.full_box = FullBoxState { + version, + flags: ((payload[1] as u32) << 16) | ((payload[2] as u32) << 8) | u32::from(payload[3]), + }; + self.selective_encryption = payload[4] & 0x80 != 0; + self.key_indicator_length = payload[5]; + self.iv_length = payload[6]; + Ok(Some(payload_size)) + } +} + +/// OMA encrypted-payload box that stores an explicit payload length followed by encrypted bytes. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Odda { + full_box: FullBoxState, + pub encrypted_payload: Vec, +} + +impl FieldHooks for Odda { + fn field_length(&self, name: &'static str) -> Option { + match name { + "EncryptedPayload" => u32::try_from(self.encrypted_payload.len()).ok(), + _ => None, + } + } +} +impl_full_box!(Odda, *b"odda"); + +impl Odda { + /// Returns the explicit encrypted-data length that will be written into the payload prefix. + pub fn encrypted_data_length(&self) -> u64 { + self.encrypted_payload.len() as u64 + } +} + +impl FieldValueRead for Odda { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "EncryptedPayload" => Ok(FieldValue::Bytes(self.encrypted_payload.clone())), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for Odda { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("EncryptedPayload", FieldValue::Bytes(value)) => { + self.encrypted_payload = value; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for Odda { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("Version", 0, with_bit_width(8), as_version_field()), + codec_field!("Flags", 1, with_bit_width(24), as_flags_field()), + codec_field!( + "EncryptedPayload", + 2, + with_bit_width(8), + with_dynamic_length(), + as_bytes() + ), + ]); + const SUPPORTED_VERSIONS: &'static [u8] = &[0]; + + fn custom_marshal(&self, writer: &mut dyn Write) -> Result, CodecError> { + if self.version() != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version: self.version(), + }); + } + + let mut payload = Vec::with_capacity(12 + self.encrypted_payload.len()); + payload.push(self.version()); + push_uint("Flags", &mut payload, 3, u64::from(self.flags()))?; + payload.extend_from_slice(&self.encrypted_data_length().to_be_bytes()); + payload.extend_from_slice(&self.encrypted_payload); + writer.write_all(&payload)?; + Ok(Some(payload.len() as u64)) + } + + fn custom_unmarshal( + &mut self, + reader: &mut dyn ReadSeek, + payload_size: u64, + ) -> Result, CodecError> { + let payload_len = usize::try_from(payload_size) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; + let payload = read_exact_vec_untrusted(reader, payload_len).map_err(CodecError::Io)?; + + if payload.len() < 12 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + + let version = payload[0]; + if version != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + let encrypted_data_length = usize::try_from(read_u64(&payload, 4)).map_err(|_| { + invalid_value("EncryptedPayload", "payload length does not fit in usize") + })?; + if payload.len() != 12 + encrypted_data_length { + return Err(invalid_value( + "EncryptedPayload", + "explicit payload length does not match the actual bytes", + ) + .into()); + } + + self.full_box = FullBoxState { + version, + flags: ((payload[1] as u32) << 16) | ((payload[2] as u32) << 8) | u32::from(payload[3]), + }; + self.encrypted_payload = payload[12..].to_vec(); + Ok(Some(payload_size)) + } +} + +/// OMA group-key box nested under `ohdr`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Grpi { + full_box: FullBoxState, + pub key_encryption_method: u8, + pub group_id: String, + pub group_key: Vec, +} + +impl FieldHooks for Grpi { + fn field_length(&self, name: &'static str) -> Option { + match name { + "GroupKey" => u32::try_from(self.group_key.len()).ok(), + _ => None, + } + } +} +impl_full_box!(Grpi, *b"grpi"); + +impl FieldValueRead for Grpi { + fn field_value(&self, field_name: &'static str) -> Result { + match field_name { + "KeyEncryptionMethod" => { + Ok(FieldValue::Unsigned(u64::from(self.key_encryption_method))) + } + "GroupId" => Ok(FieldValue::String(self.group_id.clone())), + "GroupKey" => Ok(FieldValue::Bytes(self.group_key.clone())), + _ => Err(missing_field(field_name)), + } + } +} + +impl FieldValueWrite for Grpi { + fn set_field_value( + &mut self, + field_name: &'static str, + value: FieldValue, + ) -> Result<(), FieldValueError> { + match (field_name, value) { + ("KeyEncryptionMethod", FieldValue::Unsigned(value)) => { + self.key_encryption_method = u8_from_unsigned(field_name, value)?; + Ok(()) + } + ("GroupId", FieldValue::String(value)) => { + validate_len_prefixed_string(field_name, &value, usize::from(u16::MAX))?; + self.group_id = value; + Ok(()) + } + ("GroupKey", FieldValue::Bytes(value)) => { + if value.len() > usize::from(u16::MAX) { + return Err(invalid_value( + field_name, + "payload length exceeds the field capacity", + )); + } + self.group_key = value; + Ok(()) + } + (field_name, value) => Err(unexpected_field(field_name, value)), + } + } +} + +impl CodecBox for Grpi { + const FIELD_TABLE: FieldTable = FieldTable::new(&[ + codec_field!("Version", 0, with_bit_width(8), as_version_field()), + codec_field!("Flags", 1, with_bit_width(24), as_flags_field()), + codec_field!("KeyEncryptionMethod", 2, with_bit_width(8)), + codec_field!( + "GroupId", + 3, + with_bit_width(8), + as_string(StringFieldMode::PascalCompatible) + ), + codec_field!( + "GroupKey", + 4, + with_bit_width(8), + with_dynamic_length(), + as_bytes() + ), + ]); + const SUPPORTED_VERSIONS: &'static [u8] = &[0]; + + fn custom_marshal(&self, writer: &mut dyn Write) -> Result, CodecError> { + validate_len_prefixed_string("GroupId", &self.group_id, usize::from(u16::MAX))?; + if self.group_key.len() > usize::from(u16::MAX) { + return Err( + invalid_value("GroupKey", "payload length exceeds the field capacity").into(), + ); + } + if self.version() != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version: self.version(), + }); + } + + let mut payload = Vec::with_capacity(9 + self.group_id.len() + self.group_key.len()); + payload.push(self.version()); + push_uint("Flags", &mut payload, 3, u64::from(self.flags()))?; + payload.extend_from_slice(&(self.group_id.len() as u16).to_be_bytes()); + payload.push(self.key_encryption_method); + payload.extend_from_slice(&(self.group_key.len() as u16).to_be_bytes()); + payload.extend_from_slice(self.group_id.as_bytes()); + payload.extend_from_slice(&self.group_key); + writer.write_all(&payload)?; + Ok(Some(payload.len() as u64)) + } + + fn custom_unmarshal( + &mut self, + reader: &mut dyn ReadSeek, + payload_size: u64, + ) -> Result, CodecError> { + let payload_len = usize::try_from(payload_size) + .map_err(|_| invalid_value("Payload", "payload is too large to decode"))?; + let payload = read_exact_vec_untrusted(reader, payload_len).map_err(CodecError::Io)?; + + if payload.len() < 9 { + return Err(invalid_value("Payload", "payload is too short").into()); + } + + let version = payload[0]; + if version != 0 { + return Err(CodecError::UnsupportedVersion { + box_type: self.box_type(), + version, + }); + } + + let group_id_len = usize::from(read_u16(&payload, 4)); + let group_key_len = usize::from(read_u16(&payload, 7)); + let fixed_len = 9 + group_id_len + group_key_len; + if payload.len() != fixed_len { + return Err(invalid_value( + "Payload", + "group-id and group-key lengths do not match the payload size", + ) + .into()); + } + + let group_id_offset = 9; + let group_key_offset = group_id_offset + group_id_len; + + self.full_box = FullBoxState { + version, + flags: ((payload[1] as u32) << 16) | ((payload[2] as u32) << 8) | u32::from(payload[3]), + }; + self.key_encryption_method = payload[6]; + self.group_id = decode_utf8_string("GroupId", &payload[group_id_offset..group_key_offset])?; + self.group_key = payload[group_key_offset..].to_vec(); + Ok(Some(payload_size)) + } +} + +/// Registers the built-in OMA DCF boxes in the supplied registry. +pub fn register_boxes(registry: &mut BoxRegistry) { + registry.register::(FourCc::from_bytes(*b"odrm")); + registry.register::(FourCc::from_bytes(*b"odkm")); + registry.register::(FourCc::from_bytes(*b"odhe")); + registry.register::(FourCc::from_bytes(*b"ohdr")); + registry.register::(FourCc::from_bytes(*b"odaf")); + registry.register::(FourCc::from_bytes(*b"odda")); + registry.register::(FourCc::from_bytes(*b"grpi")); +} diff --git a/src/cli/decrypt.rs b/src/cli/decrypt.rs new file mode 100644 index 0000000..8d95429 --- /dev/null +++ b/src/cli/decrypt.rs @@ -0,0 +1,270 @@ +//! Decrypt command support. + +use std::error::Error; +use std::fmt; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use crate::decrypt::{ + DecryptError, DecryptOptions, DecryptProgress, DecryptProgressPhase, ParseDecryptionKeyError, + decrypt_file, decrypt_file_with_progress, +}; + +/// Runs the decrypt subcommand with `args`, writing progress and failures to `stderr`. +pub fn run(args: &[String], stderr: &mut E) -> i32 +where + E: Write, +{ + match run_inner(args, stderr) { + Ok(()) => 0, + Err(DecryptCliError::UsageRequested) => { + let _ = write_usage(stderr); + 1 + } + Err(error) => { + let _ = writeln!(stderr, "Error: {error}"); + 1 + } + } +} + +/// Writes the decrypt subcommand usage text. +pub fn write_usage(writer: &mut W) -> io::Result<()> +where + W: Write, +{ + writeln!( + writer, + "USAGE: mp4forge decrypt --key [--key ...] [--fragments-info FILE] [--show-progress] INPUT OUTPUT" + )?; + writeln!(writer)?; + writeln!(writer, "OPTIONS:")?; + writeln!( + writer, + " --key Add one decryption key addressed by decimal track ID or 128-bit KID" + )?; + writeln!( + writer, + " --fragments-info Read matching initialization-segment bytes for standalone media-segment decrypt" + )?; + writeln!( + writer, + " --show-progress Write coarse decrypt progress snapshots to stderr" + )?; + writeln!(writer)?; + writeln!(writer, "Key syntax:")?; + writeln!(writer, " --key :")?; + writeln!( + writer, + " is either a track ID in decimal or a 128-bit KID in hex" + )?; + writeln!(writer, " is a 128-bit decryption key in hex")?; + writeln!( + writer, + " note: --fragments-info is typically the init segment when decrypting fragmented media segments" + ) +} + +#[derive(Debug)] +enum DecryptCliError { + Io(io::Error), + Decrypt(DecryptError), + ParseKey(ParseDecryptionKeyError), + InvalidArgument(String), + UsageRequested, +} + +impl fmt::Display for DecryptCliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => error.fmt(f), + Self::Decrypt(error) => error.fmt(f), + Self::ParseKey(error) => error.fmt(f), + Self::InvalidArgument(message) => f.write_str(message), + Self::UsageRequested => f.write_str("usage requested"), + } + } +} + +impl Error for DecryptCliError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Io(error) => Some(error), + Self::Decrypt(error) => Some(error), + Self::ParseKey(error) => Some(error), + Self::InvalidArgument(..) | Self::UsageRequested => None, + } + } +} + +impl From for DecryptCliError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +impl From for DecryptCliError { + fn from(value: DecryptError) -> Self { + Self::Decrypt(value) + } +} + +impl From for DecryptCliError { + fn from(value: ParseDecryptionKeyError) -> Self { + Self::ParseKey(value) + } +} + +struct ParsedArgs { + show_progress: bool, + key_specs: Vec, + fragments_info: Option, + input: PathBuf, + output: PathBuf, +} + +fn run_inner(args: &[String], stderr: &mut E) -> Result<(), DecryptCliError> +where + E: Write, +{ + let parsed = parse_args(args)?; + let mut options = DecryptOptions::new(); + for key_spec in &parsed.key_specs { + options.add_key_spec(key_spec)?; + } + + if let Some(path) = &parsed.fragments_info { + options.set_fragments_info_bytes(fs::read(path)?); + } + + if parsed.show_progress { + decrypt_file_with_cli_progress(&parsed.input, &parsed.output, &options, stderr) + } else { + decrypt_file(&parsed.input, &parsed.output, &options).map_err(Into::into) + } +} + +fn parse_args(args: &[String]) -> Result { + let mut show_progress = false; + let mut key_specs = Vec::new(); + let mut fragments_info = None; + let mut positional = Vec::new(); + let mut index = 0usize; + + while index < args.len() { + match args[index].as_str() { + "-h" | "--help" => return Err(DecryptCliError::UsageRequested), + "--show-progress" | "-show-progress" => { + show_progress = true; + index += 1; + } + "--key" | "-key" => { + let Some(value) = args.get(index + 1) else { + return Err(DecryptCliError::InvalidArgument( + "missing value for --key".to_string(), + )); + }; + key_specs.push(value.clone()); + index += 2; + } + "--fragments-info" | "-fragments-info" => { + let Some(value) = args.get(index + 1) else { + return Err(DecryptCliError::InvalidArgument( + "missing value for --fragments-info".to_string(), + )); + }; + if fragments_info.is_some() { + return Err(DecryptCliError::InvalidArgument( + "--fragments-info may only be provided once".to_string(), + )); + } + fragments_info = Some(PathBuf::from(value)); + index += 2; + } + value if value.starts_with('-') => { + return Err(DecryptCliError::InvalidArgument(format!( + "unknown decrypt option: {value}" + ))); + } + value => { + positional.push(PathBuf::from(value)); + index += 1; + } + } + } + + if positional.len() != 2 { + return Err(DecryptCliError::UsageRequested); + } + if key_specs.is_empty() { + return Err(DecryptCliError::InvalidArgument( + "at least one --key is required".to_string(), + )); + } + + Ok(ParsedArgs { + show_progress, + key_specs, + fragments_info, + input: positional.remove(0), + output: positional.remove(0), + }) +} + +fn decrypt_file_with_cli_progress( + input: &Path, + output: &Path, + options: &DecryptOptions, + stderr: &mut E, +) -> Result<(), DecryptCliError> +where + E: Write, +{ + let mut progress_write_error = None; + decrypt_file_with_progress(input, output, options, |snapshot| { + if progress_write_error.is_none() + && let Err(error) = write_progress_snapshot(stderr, snapshot) + { + progress_write_error = Some(error); + } + })?; + + if let Some(error) = progress_write_error { + return Err(DecryptCliError::Io(error)); + } + + Ok(()) +} + +fn write_progress_snapshot(writer: &mut W, snapshot: DecryptProgress) -> io::Result<()> +where + W: Write, +{ + match snapshot.total { + Some(total) => writeln!( + writer, + "{} {}/{}", + progress_phase_name(snapshot.phase), + snapshot.completed, + total + ), + None => writeln!( + writer, + "{} {}", + progress_phase_name(snapshot.phase), + snapshot.completed + ), + } +} + +fn progress_phase_name(phase: DecryptProgressPhase) -> &'static str { + match phase { + DecryptProgressPhase::OpenInput => "OpenInput", + DecryptProgressPhase::OpenOutput => "OpenOutput", + DecryptProgressPhase::OpenFragmentsInfo => "OpenFragmentsInfo", + DecryptProgressPhase::InspectStructure => "InspectStructure", + DecryptProgressPhase::ProcessSamples => "ProcessSamples", + DecryptProgressPhase::FinalizeOutput => "FinalizeOutput", + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index fc8eb1b..044de62 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2,6 +2,8 @@ use std::io::{self, Write}; +#[cfg(feature = "decrypt")] +pub mod decrypt; pub mod divide; pub mod dump; pub mod edit; @@ -26,6 +28,8 @@ where let _ = write_usage(stderr); 0 } + #[cfg(feature = "decrypt")] + "decrypt" => decrypt::run(&args[1..], stderr), "divide" => divide::run_with_output(&args[1..], stdout, stderr), "dump" => dump::run(&args[1..], stdout, stderr), "edit" => edit::run(&args[1..], stderr), @@ -51,6 +55,11 @@ where writer, " divide split a fragmented MP4 into track playlists" )?; + #[cfg(feature = "decrypt")] + writeln!( + writer, + " decrypt decrypt protected MP4-family content" + )?; writeln!(writer, " dump display the MP4 box tree")?; writeln!(writer, " edit rewrite selected boxes")?; writeln!(writer, " extract extract raw boxes by type or path")?; diff --git a/src/decrypt.rs b/src/decrypt.rs new file mode 100644 index 0000000..11c38c1 --- /dev/null +++ b/src/decrypt.rs @@ -0,0 +1,6683 @@ +//! Feature-gated synchronous decryption types and helpers. +//! +//! This module exposes the additive public shape for the native decryption rollout without +//! changing the current default build. The initial synchronous path targets the Common Encryption +//! family first, then extends through additive broader protected-format branches that compose with +//! the crate's existing synchronous and Tokio-based async library architecture. The in-memory +//! decrypt entry points stay on the synchronous path, while the additive async surface later +//! composes on top for file-backed decrypt workflows. + +use std::collections::BTreeMap; +use std::error::Error; +use std::fmt; +use std::fs; +use std::io::Cursor; +use std::io::Seek; +use std::path::Path; + +use aes::Aes128; +use aes::cipher::{Block, BlockDecrypt, BlockEncrypt, KeyInit}; +#[cfg(feature = "async")] +use tokio::fs as tokio_fs; + +use crate::BoxInfo; +use crate::FourCc; +use crate::boxes::isma_cryp::{Isfm, Islt}; +use crate::boxes::iso14496_12::{ + Co64, Frma, Ftyp, Mfro, Mpod, Saio, Saiz, Sbgp, Schm, Sgpd, Sidx, Stco, Stsc, Stsd, Stsz, + TFHD_BASE_DATA_OFFSET_PRESENT, TFHD_DEFAULT_BASE_IS_MOOF, TFHD_DEFAULT_SAMPLE_SIZE_PRESENT, + TFHD_SAMPLE_DESCRIPTION_INDEX_PRESENT, TRUN_DATA_OFFSET_PRESENT, TRUN_SAMPLE_SIZE_PRESENT, + Tfhd, Tfra, Tkhd, Trex, Trun, UUID_SAMPLE_ENCRYPTION, Uuid, UuidPayload, +}; +use crate::boxes::iso14496_14::{DescriptorCommand, Iods, parse_descriptor_commands}; +use crate::boxes::iso23001_7::{Senc, Tenc, decode_senc_payload_with_iv_size}; +use crate::boxes::marlin::{ + MARLIN_BRAND_MGSV, MARLIN_IPMPS_TYPE_MGSV, MarlinShortSchm, MarlinStyp, +}; +use crate::boxes::oma_dcf::{ + Grpi, OHDR_ENCRYPTION_METHOD_AES_CBC, OHDR_ENCRYPTION_METHOD_AES_CTR, + OHDR_ENCRYPTION_METHOD_NULL, OHDR_PADDING_SCHEME_NONE, OHDR_PADDING_SCHEME_RFC_2630, Odaf, + Odda, Odhe, Ohdr, +}; +use crate::codec::{ImmutableBox, MutableBox, marshal, unmarshal}; +use crate::encryption::{ + ResolveSampleEncryptionError, ResolvedSampleEncryptionSample, SampleEncryptionContext, + resolve_sample_encryption, +}; +use crate::extract::{ExtractError, extract_box, extract_box_as, extract_box_payload_bytes}; +use crate::sidx::{ + TopLevelSidxPlan, TopLevelSidxPlanAction, TopLevelSidxPlanOptions, + apply_top_level_sidx_plan_bytes, plan_top_level_sidx_update_bytes, +}; +use crate::walk::BoxPath; + +const CENC: FourCc = FourCc::from_bytes(*b"cenc"); +const CENS: FourCc = FourCc::from_bytes(*b"cens"); +const CBC1: FourCc = FourCc::from_bytes(*b"cbc1"); +const CBCS: FourCc = FourCc::from_bytes(*b"cbcs"); +const ENCV: FourCc = FourCc::from_bytes(*b"encv"); +const ENCA: FourCc = FourCc::from_bytes(*b"enca"); +const FREE: FourCc = FourCc::from_bytes(*b"free"); +const FTYP: FourCc = FourCc::from_bytes(*b"ftyp"); +const IODS: FourCc = FourCc::from_bytes(*b"iods"); +const MDAT: FourCc = FourCc::from_bytes(*b"mdat"); +const MFRA: FourCc = FourCc::from_bytes(*b"mfra"); +const MFRO: FourCc = FourCc::from_bytes(*b"mfro"); +const MOOF: FourCc = FourCc::from_bytes(*b"moof"); +const MOOV: FourCc = FourCc::from_bytes(*b"moov"); +const MVEX: FourCc = FourCc::from_bytes(*b"mvex"); +const ODCF: FourCc = FourCc::from_bytes(*b"odcf"); +const ODAF: FourCc = FourCc::from_bytes(*b"odaf"); +const ODDA: FourCc = FourCc::from_bytes(*b"odda"); +const ODHE: FourCc = FourCc::from_bytes(*b"odhe"); +const OHDR: FourCc = FourCc::from_bytes(*b"ohdr"); +const ODKM: FourCc = FourCc::from_bytes(*b"odkm"); +const ODRM: FourCc = FourCc::from_bytes(*b"odrm"); +const OPF2: FourCc = FourCc::from_bytes(*b"opf2"); +const GRPI: FourCc = FourCc::from_bytes(*b"grpi"); +const PIFF: FourCc = FourCc::from_bytes(*b"piff"); +const SBGP: FourCc = FourCc::from_bytes(*b"sbgp"); +const SGPD: FourCc = FourCc::from_bytes(*b"sgpd"); +const SAIO: FourCc = FourCc::from_bytes(*b"saio"); +const SAIZ: FourCc = FourCc::from_bytes(*b"saiz"); +const SENC: FourCc = FourCc::from_bytes(*b"senc"); +const SINF: FourCc = FourCc::from_bytes(*b"sinf"); +const SCHI: FourCc = FourCc::from_bytes(*b"schi"); +const SCHM: FourCc = FourCc::from_bytes(*b"schm"); +const GKEY: FourCc = FourCc::from_bytes(*b"gkey"); +const STBL: FourCc = FourCc::from_bytes(*b"stbl"); +const STCO: FourCc = FourCc::from_bytes(*b"stco"); +const STSC: FourCc = FourCc::from_bytes(*b"stsc"); +const STSD: FourCc = FourCc::from_bytes(*b"stsd"); +const STSZ: FourCc = FourCc::from_bytes(*b"stsz"); +const TKHD: FourCc = FourCc::from_bytes(*b"tkhd"); +const TRAF: FourCc = FourCc::from_bytes(*b"traf"); +const TRAK: FourCc = FourCc::from_bytes(*b"trak"); +const TENC: FourCc = FourCc::from_bytes(*b"tenc"); +const TFHD: FourCc = FourCc::from_bytes(*b"tfhd"); +const TFRA: FourCc = FourCc::from_bytes(*b"tfra"); +const TREX: FourCc = FourCc::from_bytes(*b"trex"); +const TRUN: FourCc = FourCc::from_bytes(*b"trun"); +const UUID: FourCc = FourCc::from_bytes(*b"uuid"); +const FRMA: FourCc = FourCc::from_bytes(*b"frma"); +const MDIA: FourCc = FourCc::from_bytes(*b"mdia"); +const MINF: FourCc = FourCc::from_bytes(*b"minf"); +const SEIG: FourCc = FourCc::from_bytes(*b"seig"); +const IAEC: FourCc = FourCc::from_bytes(*b"iAEC"); + +const PIFF_TRACK_ENCRYPTION_USER_TYPE: [u8; 16] = [ + 0x89, 0x74, 0xdb, 0xce, 0x7b, 0xe7, 0x4c, 0x51, 0x84, 0xf9, 0x71, 0x48, 0xf9, 0x88, 0x25, 0x54, +]; + +/// Native Common Encryption scheme types targeted by the first decryption implementation phase. +pub const NATIVE_COMMON_ENCRYPTION_SCHEME_TYPES: [FourCc; 4] = [CENC, CENS, CBC1, CBCS]; + +/// MP4-family decryption format groups covered by the full decryption roadmap. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum DecryptionFormatFamily { + /// The Common Encryption family, including `cenc`, `cens`, `cbc1`, and `cbcs`. + CommonEncryption, + /// OMA DCF protected MP4-family content. + OmaDcf, + /// Marlin IPMP protected MP4-family content. + MarlinIpmp, + /// PIFF-triggered compatibility behavior for protected fragmented content. + PiffCompatibility, + /// Generic protected MP4-family fallback behavior when a more specific family does not apply. + StandardProtected, +} + +/// Broader MP4-family decryption groups that extend beyond the native Common Encryption core. +pub const BROADER_MP4_DECRYPTION_FAMILIES: [DecryptionFormatFamily; 4] = [ + DecryptionFormatFamily::OmaDcf, + DecryptionFormatFamily::MarlinIpmp, + DecryptionFormatFamily::PiffCompatibility, + DecryptionFormatFamily::StandardProtected, +]; + +/// Full MP4-family decryption groups that the roadmap keeps in scope. +pub const FULL_MP4_DECRYPTION_FAMILIES: [DecryptionFormatFamily; 5] = [ + DecryptionFormatFamily::CommonEncryption, + DecryptionFormatFamily::OmaDcf, + DecryptionFormatFamily::MarlinIpmp, + DecryptionFormatFamily::PiffCompatibility, + DecryptionFormatFamily::StandardProtected, +]; + +/// Native Common Encryption scheme variants supported by the first decryption core landing. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum NativeCommonEncryptionScheme { + /// AES-CTR full-sample Common Encryption. + Cenc, + /// AES-CTR Common Encryption with pattern metadata when present. + Cens, + /// AES-CBC full-block Common Encryption. + Cbc1, + /// AES-CBC Common Encryption with pattern metadata when present. + Cbcs, +} + +impl NativeCommonEncryptionScheme { + /// Returns the four-character scheme type for this native variant. + pub const fn scheme_type(self) -> FourCc { + match self { + Self::Cenc => CENC, + Self::Cens => CENS, + Self::Cbc1 => CBC1, + Self::Cbcs => CBCS, + } + } + + /// Resolves one native Common Encryption variant from a four-character scheme type. + pub fn from_scheme_type(scheme_type: FourCc) -> Option { + match scheme_type { + CENC => Some(Self::Cenc), + CENS => Some(Self::Cens), + CBC1 => Some(Self::Cbc1), + CBCS => Some(Self::Cbcs), + _ => None, + } + } + + const fn uses_cbc(self) -> bool { + matches!(self, Self::Cbc1 | Self::Cbcs) + } + + const fn resets_iv_at_each_subsample(self) -> bool { + matches!(self, Self::Cbcs) + } +} + +/// Identifies a decryption key either by decimal track ID or by 128-bit KID. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum DecryptionKeyId { + /// A decimal track identifier. + TrackId(u32), + /// A 128-bit key identifier. + Kid([u8; 16]), +} + +impl DecryptionKeyId { + /// Parses one key identifier in the supported decryption syntax. + /// + /// The accepted forms are: + /// + /// - a decimal track ID such as `1` + /// - a 32-character hexadecimal KID such as `00112233445566778899aabbccddeeff` + pub fn from_spec(input: &str) -> Result { + if input.len() == 32 { + return Ok(Self::Kid(parse_hex_16("key id", input)?)); + } + + let track_id = + input + .parse::() + .map_err(|_| ParseDecryptionKeyError::InvalidTrackId { + input: input.to_owned(), + })?; + Ok(Self::TrackId(track_id)) + } +} + +/// One decryption key entry addressed either by decimal track ID or by KID. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct DecryptionKey { + id: DecryptionKeyId, + key: [u8; 16], +} + +impl DecryptionKey { + /// Parses one decryption key entry from the supported `ID:KEY` syntax. + pub fn from_spec(input: &str) -> Result { + let (id_text, key_text) = + input + .split_once(':') + .ok_or_else(|| ParseDecryptionKeyError::InvalidSpec { + input: input.to_owned(), + reason: "expected :", + })?; + + Ok(Self { + id: DecryptionKeyId::from_spec(id_text)?, + key: parse_hex_16("content key", key_text)?, + }) + } + + /// Creates a decryption key addressed by decimal track ID. + pub fn track(track_id: u32, key: [u8; 16]) -> Self { + Self { + id: DecryptionKeyId::TrackId(track_id), + key, + } + } + + /// Creates a decryption key addressed by 128-bit KID. + pub fn kid(kid: [u8; 16], key: [u8; 16]) -> Self { + Self { + id: DecryptionKeyId::Kid(kid), + key, + } + } + + /// Returns the identifier used to select this key. + pub fn id(&self) -> DecryptionKeyId { + self.id + } + + /// Returns the raw 16-byte content key. + pub fn key_bytes(&self) -> [u8; 16] { + self.key + } + + /// Formats this key entry back into `ID:KEY` syntax. + pub fn to_spec(&self) -> String { + match self.id { + DecryptionKeyId::TrackId(track_id) => { + format!("{track_id}:{}", encode_hex(self.key)) + } + DecryptionKeyId::Kid(kid) => format!("{}:{}", encode_hex(kid), encode_hex(self.key)), + } + } +} + +/// Coarse decryption progress phases shared by the sync and async decryption paths. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum DecryptProgressPhase { + /// Opening the encrypted input source. + OpenInput, + /// Opening the decrypted output target. + OpenOutput, + /// Opening the optional fragments-info input. + OpenFragmentsInfo, + /// Inspecting the file structure and resolving the active decrypt path. + InspectStructure, + /// Transforming encrypted samples into decrypted output. + ProcessSamples, + /// Finalizing the rewritten decrypted output. + FinalizeOutput, +} + +/// A snapshot of decryption progress for one sync or async operation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct DecryptProgress { + /// Current phase of the decryption operation. + pub phase: DecryptProgressPhase, + /// Completed work units for the current phase. + pub completed: u64, + /// Total work units for the current phase when they are known. + pub total: Option, +} + +impl DecryptProgress { + /// Creates one progress snapshot. + pub const fn new(phase: DecryptProgressPhase, completed: u64, total: Option) -> Self { + Self { + phase, + completed, + total, + } + } +} + +/// Additive synchronous decryption options for the native decryption path. +/// +/// The same option shape is intended to stay reusable by later async and CLI layers. Keys may be +/// supplied repeatedly, addressed either by decimal track ID or by 128-bit KID. When decrypting a +/// standalone media segment, callers can also supply the matching initialization-segment bytes +/// through `fragments_info`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DecryptOptions { + keys: Vec, + fragments_info: Option>, +} + +impl DecryptOptions { + /// Creates an empty option set. + pub fn new() -> Self { + Self::default() + } + + /// Returns the configured decryption keys in lookup order. + pub fn keys(&self) -> &[DecryptionKey] { + &self.keys + } + + /// Adds one already-parsed decryption key to this option set. + pub fn add_key(&mut self, key: DecryptionKey) -> &mut Self { + self.keys.push(key); + self + } + + /// Adds one already-parsed decryption key and returns the updated option set. + pub fn with_key(mut self, key: DecryptionKey) -> Self { + self.add_key(key); + self + } + + /// Parses and adds one `ID:KEY` entry to this option set. + pub fn add_key_spec(&mut self, input: &str) -> Result<&mut Self, ParseDecryptionKeyError> { + self.keys.push(DecryptionKey::from_spec(input)?); + Ok(self) + } + + /// Parses and adds one `ID:KEY` entry, returning the updated option set. + pub fn with_key_spec(mut self, input: &str) -> Result { + self.add_key_spec(input)?; + Ok(self) + } + + /// Returns the optional initialization-segment bytes used for standalone media segments. + pub fn fragments_info_bytes(&self) -> Option<&[u8]> { + self.fragments_info.as_deref() + } + + /// Stores initialization-segment bytes for later standalone media-segment decryption. + pub fn set_fragments_info_bytes(&mut self, fragments_info: impl AsRef<[u8]>) -> &mut Self { + self.fragments_info = Some(fragments_info.as_ref().to_vec()); + self + } + + /// Stores initialization-segment bytes and returns the updated option set. + pub fn with_fragments_info_bytes(mut self, fragments_info: impl AsRef<[u8]>) -> Self { + self.set_fragments_info_bytes(fragments_info); + self + } + + /// Clears any previously stored initialization-segment bytes. + pub fn clear_fragments_info_bytes(&mut self) -> &mut Self { + self.fragments_info = None; + self + } +} + +/// Errors raised while parsing decryption key input. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ParseDecryptionKeyError { + /// The outer `ID:KEY` form was malformed. + InvalidSpec { + /// Original user input. + input: String, + /// Human-readable reason for rejection. + reason: &'static str, + }, + /// The track-ID portion was not a valid unsigned decimal integer. + InvalidTrackId { + /// Original user input for the track ID field. + input: String, + }, + /// A fixed-length hexadecimal field had the wrong number of characters. + InvalidHexLength { + /// Field name used in the error message. + field: &'static str, + /// Actual character length of the field. + actual: usize, + }, + /// A hexadecimal field contained a non-hexadecimal character. + InvalidHexDigit { + /// Field name used in the error message. + field: &'static str, + /// Zero-based byte index of the rejected nibble pair. + index: usize, + /// Rejected character value. + value: char, + }, +} + +impl fmt::Display for ParseDecryptionKeyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidSpec { input, reason } => { + write!(f, "invalid decryption key spec {input:?}: {reason}") + } + Self::InvalidTrackId { input } => { + write!( + f, + "invalid track id {input:?}: expected an unsigned decimal integer" + ) + } + Self::InvalidHexLength { field, actual } => write!( + f, + "invalid {field}: expected 32 hexadecimal characters but found {actual}" + ), + Self::InvalidHexDigit { + field, + index, + value, + } => write!( + f, + "invalid {field}: character {value:?} at byte index {index} is not hexadecimal" + ), + } + } +} + +impl Error for ParseDecryptionKeyError {} + +/// Errors raised by the high-level synchronous decryption API. +#[derive(Debug)] +pub enum DecryptError { + /// File-backed decrypt I/O failed. + Io(std::io::Error), + /// The native decrypt-and-rewrite path rejected the current input or transform state. + Rewrite(DecryptRewriteError), + /// Standalone media-segment decrypt requires matching initialization-segment bytes. + MissingFragmentsInfo, + /// The input does not match one of the currently supported synchronous decrypt layouts. + InvalidInput { + /// Human-readable explanation of the rejected input shape. + reason: String, + }, +} + +impl fmt::Display for DecryptError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => error.fmt(f), + Self::Rewrite(error) => error.fmt(f), + Self::MissingFragmentsInfo => write!( + f, + "standalone media-segment decrypt requires matching fragments-info bytes" + ), + Self::InvalidInput { reason } => write!(f, "unsupported decrypt input: {reason}"), + } + } +} + +impl Error for DecryptError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Io(error) => Some(error), + Self::Rewrite(error) => Some(error), + Self::MissingFragmentsInfo | Self::InvalidInput { .. } => None, + } + } +} + +impl From for DecryptError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl From for DecryptError { + fn from(value: ExtractError) -> Self { + Self::Rewrite(value.into()) + } +} + +impl From for DecryptError { + fn from(value: DecryptRewriteError) -> Self { + Self::Rewrite(value) + } +} + +/// Errors raised by the native Common Encryption sample-transform core. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CommonEncryptionDecryptError { + /// The caller requested a scheme type outside the current native Common Encryption set. + UnsupportedNativeSchemeType { + /// Raw scheme type that could not be mapped to the native core. + scheme_type: FourCc, + }, + /// No key matched the current sample's track ID or KID. + MissingDecryptionKey { + /// Optional track ID supplied by the higher-level caller for key lookup precedence. + track_id: Option, + /// Effective sample KID resolved from typed encryption defaults. + kid: [u8; 16], + }, + /// A protected sample did not resolve any usable IV bytes. + MissingInitializationVector { + /// Native scheme that required the IV. + scheme: NativeCommonEncryptionScheme, + }, + /// A protected sample resolved an IV size that the native scheme does not accept. + InvalidInitializationVectorSize { + /// Native scheme that rejected the IV. + scheme: NativeCommonEncryptionScheme, + /// Actual resolved IV byte count. + actual: usize, + /// Human-readable allowed size set for the scheme. + expected: &'static str, + }, + /// One subsample declared more bytes than remain in the encrypted sample payload. + InvalidProtectedRegion { + /// Bytes left in the encrypted sample when the region was validated. + remaining: usize, + /// Clear bytes declared for the failing subsample region. + clear_bytes: usize, + /// Protected bytes declared for the failing subsample region. + protected_bytes: usize, + }, + /// A subsample region declared a protected-byte count that does not fit on this platform. + ProtectedByteCountOverflow { + /// Original 32-bit protected-byte count from the resolved sample metadata. + protected_bytes: u32, + }, +} + +impl fmt::Display for CommonEncryptionDecryptError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedNativeSchemeType { scheme_type } => { + write!( + f, + "unsupported native Common Encryption scheme type {scheme_type}" + ) + } + Self::MissingDecryptionKey { track_id, kid } => match track_id { + Some(track_id) => write!( + f, + "missing decryption key for track {track_id} or KID {}", + encode_hex(*kid) + ), + None => write!(f, "missing decryption key for KID {}", encode_hex(*kid)), + }, + Self::MissingInitializationVector { scheme } => { + write!( + f, + "protected {scheme:?} sample is missing its effective initialization vector" + ) + } + Self::InvalidInitializationVectorSize { + scheme, + actual, + expected, + } => write!( + f, + "{scheme:?} requires {expected} initialization vector bytes but resolved {actual}" + ), + Self::InvalidProtectedRegion { + remaining, + clear_bytes, + protected_bytes, + } => write!( + f, + "subsample region exceeds the encrypted sample bounds: remaining={remaining}, clear={clear_bytes}, protected={protected_bytes}" + ), + Self::ProtectedByteCountOverflow { protected_bytes } => write!( + f, + "protected subsample byte count {protected_bytes} does not fit in usize" + ), + } + } +} + +impl Error for CommonEncryptionDecryptError {} + +/// Errors raised while rewriting decrypted MP4 output for the native Common Encryption path. +#[derive(Debug)] +pub enum DecryptRewriteError { + /// Typed extraction failed while analyzing the current input layout. + Extract(ExtractError), + /// Resolved sample-encryption defaults were inconsistent for the current track fragment. + Resolve(ResolveSampleEncryptionError), + /// Sample-level native Common Encryption transform work failed. + Decrypt(CommonEncryptionDecryptError), + /// The current encrypted layout is not one of the supported native rewrite shapes. + InvalidLayout { + /// Human-readable explanation of the rejected layout. + reason: String, + }, + /// A keyed protected track uses a scheme type outside the current native rewrite set. + UnsupportedTrackSchemeType { + /// Track identifier from `tkhd` or `tfhd`. + track_id: u32, + /// Raw `schm` scheme type that could not be mapped to the native rewrite path. + scheme_type: FourCc, + }, + /// A computed sample byte range did not fit within any root `mdat` payload. + SampleDataRangeNotFound { + /// Track identifier from `tkhd` or `tfhd`. + track_id: u32, + /// One-based sample index inside the active fragment track run. + sample_index: u32, + /// Absolute byte offset that the rewrite path attempted to read. + absolute_offset: u64, + /// Sample byte length requested at that offset. + sample_size: u32, + }, +} + +impl fmt::Display for DecryptRewriteError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Extract(error) => error.fmt(f), + Self::Resolve(error) => error.fmt(f), + Self::Decrypt(error) => error.fmt(f), + Self::InvalidLayout { reason } => { + write!(f, "unsupported native decrypt layout: {reason}") + } + Self::UnsupportedTrackSchemeType { + track_id, + scheme_type, + } => write!( + f, + "track {track_id} uses unsupported native decrypt scheme type {scheme_type}" + ), + Self::SampleDataRangeNotFound { + track_id, + sample_index, + absolute_offset, + sample_size, + } => write!( + f, + "sample {sample_index} for track {track_id} points outside root media data: offset={absolute_offset}, size={sample_size}" + ), + } + } +} + +impl Error for DecryptRewriteError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Extract(error) => Some(error), + Self::Resolve(error) => Some(error), + Self::Decrypt(error) => Some(error), + Self::InvalidLayout { .. } + | Self::UnsupportedTrackSchemeType { .. } + | Self::SampleDataRangeNotFound { .. } => None, + } + } +} + +impl From for DecryptRewriteError { + fn from(value: ExtractError) -> Self { + Self::Extract(value) + } +} + +impl From for DecryptRewriteError { + fn from(value: ResolveSampleEncryptionError) -> Self { + Self::Resolve(value) + } +} + +impl From for DecryptRewriteError { + fn from(value: CommonEncryptionDecryptError) -> Self { + Self::Decrypt(value) + } +} + +/// Selects the content key for one resolved sample using the native precedence rules. +/// +/// The native path first looks for a track-ID match when one is supplied, then falls back to the +/// sample's effective KID. +pub fn select_decryption_key( + keys: &[DecryptionKey], + track_id: Option, + sample: &ResolvedSampleEncryptionSample<'_>, +) -> Result<[u8; 16], CommonEncryptionDecryptError> { + if let Some(track_id) = track_id + && let Some(key) = keys.iter().find_map(|entry| match entry.id { + DecryptionKeyId::TrackId(candidate) if candidate == track_id => Some(entry.key), + _ => None, + }) + { + return Ok(key); + } + + if let Some(key) = keys.iter().find_map(|entry| match entry.id { + DecryptionKeyId::Kid(candidate) if candidate == sample.kid => Some(entry.key), + _ => None, + }) { + return Ok(key); + } + + Err(CommonEncryptionDecryptError::MissingDecryptionKey { + track_id, + kid: sample.kid, + }) +} + +/// Decrypts one resolved Common Encryption sample using the supplied native scheme and content key. +/// +/// This primitive core is isolated from file traversal and rewrite policy so it can be reused by +/// both the later sync and async file-backed entry points. +pub fn decrypt_common_encryption_sample( + scheme: NativeCommonEncryptionScheme, + content_key: [u8; 16], + sample: &ResolvedSampleEncryptionSample<'_>, + encrypted_sample: &[u8], +) -> Result, CommonEncryptionDecryptError> { + if !sample.is_protected { + return Ok(encrypted_sample.to_vec()); + } + + let iv = effective_initialization_vector(scheme, sample)?; + let mut transformer = SampleTransformer::new( + scheme, + Aes128::new(&content_key.into()), + iv, + sample.crypt_byte_block, + sample.skip_byte_block, + ); + + let mut output = vec![0_u8; encrypted_sample.len()]; + if sample.subsamples.is_empty() { + transformer.transform_region(encrypted_sample, &mut output)?; + return Ok(output); + } + + let mut cursor = 0usize; + for subsample in sample.subsamples { + let clear_bytes = usize::from(subsample.bytes_of_clear_data); + let protected_bytes = usize::try_from(subsample.bytes_of_protected_data).map_err(|_| { + CommonEncryptionDecryptError::ProtectedByteCountOverflow { + protected_bytes: subsample.bytes_of_protected_data, + } + })?; + let region_len = clear_bytes.checked_add(protected_bytes).ok_or( + CommonEncryptionDecryptError::InvalidProtectedRegion { + remaining: encrypted_sample.len().saturating_sub(cursor), + clear_bytes, + protected_bytes, + }, + )?; + if encrypted_sample.len().saturating_sub(cursor) < region_len { + return Err(CommonEncryptionDecryptError::InvalidProtectedRegion { + remaining: encrypted_sample.len().saturating_sub(cursor), + clear_bytes, + protected_bytes, + }); + } + + output[cursor..cursor + clear_bytes] + .copy_from_slice(&encrypted_sample[cursor..cursor + clear_bytes]); + cursor += clear_bytes; + + if protected_bytes != 0 { + if scheme.resets_iv_at_each_subsample() { + transformer.reset_for_subsample(); + } + transformer.transform_region( + &encrypted_sample[cursor..cursor + protected_bytes], + &mut output[cursor..cursor + protected_bytes], + )?; + cursor += protected_bytes; + } + } + + output[cursor..].copy_from_slice(&encrypted_sample[cursor..]); + Ok(output) +} + +/// Resolves the content key and decrypts one resolved Common Encryption sample in one step. +pub fn decrypt_common_encryption_sample_with_keys( + scheme: NativeCommonEncryptionScheme, + track_id: Option, + keys: &[DecryptionKey], + sample: &ResolvedSampleEncryptionSample<'_>, + encrypted_sample: &[u8], +) -> Result, CommonEncryptionDecryptError> { + let content_key = select_decryption_key(keys, track_id, sample)?; + decrypt_common_encryption_sample(scheme, content_key, sample, encrypted_sample) +} + +/// Resolves a native scheme from a raw four-character code, then decrypts one sample with the +/// selected content key. +pub fn decrypt_common_encryption_sample_by_scheme_type_with_keys( + scheme_type: FourCc, + track_id: Option, + keys: &[DecryptionKey], + sample: &ResolvedSampleEncryptionSample<'_>, + encrypted_sample: &[u8], +) -> Result, CommonEncryptionDecryptError> { + let scheme = NativeCommonEncryptionScheme::from_scheme_type(scheme_type) + .ok_or(CommonEncryptionDecryptError::UnsupportedNativeSchemeType { scheme_type })?; + decrypt_common_encryption_sample_with_keys(scheme, track_id, keys, sample, encrypted_sample) +} + +fn decrypt_sample_for_active_track( + active: &ActiveTrackDecryption<'_>, + sample: &ResolvedSampleEncryptionSample<'_>, + encrypted_sample: &[u8], +) -> Result, CommonEncryptionDecryptError> { + if active.sample_entry.scheme_type == PIFF { + return Ok(encrypted_sample.to_vec()); + } + + decrypt_common_encryption_sample(active.scheme, active.key, sample, encrypted_sample) +} + +/// Rewrites one encrypted initialization segment into a clear variant for the currently keyed +/// native Common Encryption tracks. +/// +/// This helper rebuilds the affected sample-entry hierarchy into the canonical clear layout for +/// tracks with matching keys. PIFF-triggered compatibility tracks intentionally keep their +/// protected sample-entry structure so the output matches the established PIFF decrypt behavior. +/// Tracks without matching keys remain untouched so callers can perform partial decrypt workflows +/// without forcing the entire init segment to fail. +pub fn decrypt_common_encryption_init_bytes( + init_segment: &[u8], + keys: &[DecryptionKey], +) -> Result, DecryptRewriteError> { + let context = analyze_init_segment(init_segment)?; + let rebuilt_moov = rebuild_common_encryption_moov(init_segment, &context, keys)?; + let root_boxes = read_root_box_infos(init_segment)?; + let mut output = Vec::with_capacity(init_segment.len()); + for info in root_boxes { + if info.box_type() == MOOV { + output.extend_from_slice(&rebuilt_moov); + } else { + output.extend_from_slice(slice_box_bytes(init_segment, info)?); + } + } + Ok(output) +} + +fn decrypt_common_encryption_init_bytes_legacy( + init_segment: &[u8], + keys: &[DecryptionKey], +) -> Result, DecryptRewriteError> { + let context = analyze_init_segment(init_segment)?; + let mut output = init_segment.to_vec(); + for track in &context.tracks { + for sample_entry in &track.protected_sample_entries { + if resolve_key_for_sample_entry(track, sample_entry, keys)?.is_none() + || sample_entry.scheme_type == PIFF + { + continue; + } + patch_sample_entry_type( + &mut output, + sample_entry.sample_entry_info, + sample_entry.original_format, + )?; + replace_box_with_free(&mut output, sample_entry.sinf_info)?; + } + } + Ok(output) +} + +/// Rewrites one encrypted media segment into a clear variant using the keyed native Common +/// Encryption track definitions resolved from `init_segment`. +/// +/// Tracks without matching keys remain untouched. The supported native rewrite path expects the +/// fragment sample metadata to be carried by typed `senc` boxes plus the existing typed protection +/// helpers. PIFF-triggered compatibility tracks keep their fragment protection boxes in place so +/// the decrypted output remains byte-compatible with the established PIFF decrypt behavior. +pub fn decrypt_common_encryption_media_segment_bytes( + init_segment: &[u8], + media_segment: &[u8], + keys: &[DecryptionKey], +) -> Result, DecryptRewriteError> { + let context = analyze_init_segment(init_segment)?; + decrypt_media_bytes_with_context(media_segment, &context, keys) +} + +/// Rewrites one encrypted fragmented MP4 file into a clear variant for the currently keyed native +/// Common Encryption tracks. +/// +/// This helper supports the common single-file layout where the movie box and one or more +/// fragments appear in the same byte stream. Tracks without matching keys remain untouched. +/// PIFF-triggered compatibility tracks preserve their protected movie and fragment structure and +/// keep their retained reference payload bytes unchanged. +pub fn decrypt_common_encryption_file_bytes( + input: &[u8], + keys: &[DecryptionKey], +) -> Result, DecryptRewriteError> { + let context = analyze_init_segment(input)?; + if let Some(output) = try_rebuild_common_encryption_file_bytes(input, &context, keys)? { + return refresh_fragmented_top_level_sidx(output); + } + + let mut output = decrypt_common_encryption_init_bytes_legacy(input, keys)?; + decrypt_media_bytes_in_place_legacy(input, &mut output, &context, keys)?; + refresh_fragmented_top_level_sidx(output) +} + +/// Decrypts one encrypted byte slice through the additive synchronous library surface. +/// +/// Supported inputs are: +/// +/// - an init segment containing `moov` +/// - a standalone media segment containing `moof`, when `options` also carries matching +/// initialization-segment bytes through `fragments_info` +/// - a single fragmented file containing both `moov` and one or more `moof` boxes +/// - a non-fragmented movie file containing `moov`, `mdat`, and the currently supported OMA DCF +/// protected sample-entry layout +/// - a top-level OMA DCF atom file containing one or more root `odrm` boxes +pub fn decrypt_bytes(input: &[u8], options: &DecryptOptions) -> Result, DecryptError> { + decrypt_bytes_with_optional_progress(input, options, None::) +} + +/// Decrypts one encrypted byte slice and reports coarse synchronous progress snapshots. +pub fn decrypt_bytes_with_progress( + input: &[u8], + options: &DecryptOptions, + progress: F, +) -> Result, DecryptError> +where + F: FnMut(DecryptProgress), +{ + decrypt_bytes_with_optional_progress(input, options, Some(progress)) +} + +/// Decrypts one encrypted file path into a clear output file through the additive synchronous +/// library surface. +pub fn decrypt_file( + input_path: impl AsRef, + output_path: impl AsRef, + options: &DecryptOptions, +) -> Result<(), DecryptError> { + decrypt_file_with_optional_progress( + input_path.as_ref(), + output_path.as_ref(), + options, + None::, + ) +} + +/// Decrypts one encrypted file path into a clear output file and reports coarse synchronous +/// progress snapshots. +pub fn decrypt_file_with_progress( + input_path: impl AsRef, + output_path: impl AsRef, + options: &DecryptOptions, + progress: F, +) -> Result<(), DecryptError> +where + F: FnMut(DecryptProgress), +{ + decrypt_file_with_optional_progress( + input_path.as_ref(), + output_path.as_ref(), + options, + Some(progress), + ) +} + +/// Decrypts one encrypted file path into a clear output file through the additive Tokio-based +/// async library surface. +/// +/// The async decrypt rollout stays file-backed for now. Pure in-memory decrypt entry points remain +/// on the synchronous path because the native transform core itself does not perform asynchronous +/// I/O. The supported file-backed layouts are the same as the synchronous path, including +/// top-level OMA DCF atom files and the currently supported protected-sample-entry OMA DCF movie +/// layout. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn decrypt_file_async( + input_path: impl AsRef, + output_path: impl AsRef, + options: &DecryptOptions, +) -> Result<(), DecryptError> { + decrypt_file_with_optional_progress_async( + input_path.as_ref(), + output_path.as_ref(), + options, + None::, + ) + .await +} + +/// Decrypts one encrypted file path into a clear output file through the additive Tokio-based +/// async library surface and reports coarse progress snapshots. +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +pub async fn decrypt_file_with_progress_async( + input_path: impl AsRef, + output_path: impl AsRef, + options: &DecryptOptions, + progress: F, +) -> Result<(), DecryptError> +where + F: FnMut(DecryptProgress) + Send, +{ + decrypt_file_with_optional_progress_async( + input_path.as_ref(), + output_path.as_ref(), + options, + Some(progress), + ) + .await +} + +#[derive(Clone)] +struct InitDecryptContext { + moov_info: BoxInfo, + tracks: Vec, +} + +#[derive(Clone)] +struct ProtectedTrackState { + track_id: u32, + trak_info: BoxInfo, + mdia_info: BoxInfo, + minf_info: BoxInfo, + stbl_info: BoxInfo, + stsd_info: BoxInfo, + protected_sample_entries: Vec, + trex: Option, +} + +#[derive(Clone)] +struct ProtectedSampleEntryState { + sample_description_index: u32, + sample_entry_info: BoxInfo, + original_format: FourCc, + scheme_type: FourCc, + sinf_info: BoxInfo, + tenc: Tenc, + piff_protection_mode: Option, +} + +#[derive(Clone)] +struct OmaProtectedMovieContext { + ftyp_info: Option, + moov_info: BoxInfo, + tracks: Vec, + other_tracks: Vec, + mdat_infos: Vec, +} + +#[derive(Clone)] +struct OmaProtectedMovieTrackState { + track_id: u32, + trak_info: BoxInfo, + mdia_info: BoxInfo, + minf_info: BoxInfo, + stbl_info: BoxInfo, + stsd_info: BoxInfo, + sample_entry_info: BoxInfo, + original_format: FourCc, + sinf_info: BoxInfo, + stsz_info: BoxInfo, + stsz: Stsz, + stsc: Stsc, + chunk_offsets: ChunkOffsetBoxState, + sample_sizes: Vec, + odaf: Odaf, + ohdr: Ohdr, +} + +#[derive(Clone)] +struct IaecProtectedMovieContext { + ftyp_info: Option, + moov_info: BoxInfo, + tracks: Vec, + other_tracks: Vec, + mdat_infos: Vec, +} + +#[derive(Clone)] +struct IaecProtectedMovieTrackState { + track_id: u32, + trak_info: BoxInfo, + mdia_info: BoxInfo, + minf_info: BoxInfo, + stbl_info: BoxInfo, + stsd_info: BoxInfo, + sample_entry_info: BoxInfo, + original_format: FourCc, + sinf_info: BoxInfo, + stsz_info: BoxInfo, + stsz: Stsz, + stsc: Stsc, + chunk_offsets: ChunkOffsetBoxState, + sample_sizes: Vec, + isfm: Isfm, + islt: Option, +} + +#[derive(Clone)] +struct MovieChunkTrackState { + track_id: u32, + trak_info: BoxInfo, + mdia_info: BoxInfo, + minf_info: BoxInfo, + stbl_info: BoxInfo, + stsc: Stsc, + chunk_offsets: ChunkOffsetBoxState, + sample_sizes: Vec, +} + +type TrackRelativeChunkOffsets = BTreeMap>; +type RebuiltMovieSampleSizes = BTreeMap>; +type RebuiltMoviePayload = (Vec, RebuiltMovieSampleSizes, TrackRelativeChunkOffsets); + +#[derive(Clone, Copy)] +struct MovieRootRewriteContext<'a> { + input: &'a [u8], + ftyp_info: Option, + moov_info: BoxInfo, + mdat_infos: &'a [BoxInfo], +} + +#[derive(Clone)] +struct MarlinMovieContext { + ftyp_info: BoxInfo, + ftyp: Ftyp, + moov_info: BoxInfo, + iods_info: BoxInfo, + od_track_info: BoxInfo, + mdat_infos: Vec, + tracks: Vec, +} + +#[derive(Clone)] +struct MarlinMovieTrackState { + track_id: u32, + trak_info: BoxInfo, + mdia_info: BoxInfo, + minf_info: BoxInfo, + stbl_info: BoxInfo, + stsz_info: BoxInfo, + stsz: Stsz, + stsc: Stsc, + chunk_offsets: ChunkOffsetBoxState, + sample_sizes: Vec, + marlin: Option, +} + +#[derive(Clone)] +enum ChunkOffsetBoxState { + Stco { info: BoxInfo, box_value: Stco }, + Co64 { info: BoxInfo, box_value: Co64 }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum MarlinTrackKeyMode { + Track, + Group, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct MarlinTrackProtection { + key_mode: MarlinTrackKeyMode, + stream_type: Option, + wrapped_group_key: Option>, +} + +#[derive(Clone, Copy)] +struct MovieTrackPayloadPlan<'a> { + track_id: u32, + stsc: &'a Stsc, + chunk_offsets: &'a ChunkOffsetBoxState, + sample_sizes: &'a [u32], +} + +struct MovieTrackRewritePlan { + track_id: u32, + trak_info: BoxInfo, + mdia_info: BoxInfo, + minf_info: BoxInfo, + stbl_info: BoxInfo, + chunk_offsets: ChunkOffsetBoxState, + stsd_replacement: Option<(u64, Vec)>, + stsz_replacement: Option<(u64, Vec)>, +} + +#[derive(Clone, Copy)] +struct ActiveTrackDecryption<'a> { + track: &'a ProtectedTrackState, + sample_entry: &'a ProtectedSampleEntryState, + scheme: NativeCommonEncryptionScheme, + key: [u8; 16], +} + +#[derive(Clone, Copy)] +struct MediaDataRange { + start: u64, + end: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DecryptInputLayout { + InitSegment, + MediaSegment, + FragmentedFile, + MarlinIpmpFile, + OmaDcfProtectedMovieFile, + IaecProtectedMovieFile, + OmaDcfAtomFile, +} + +struct ProgressReporter { + callback: Option, +} + +impl ProgressReporter +where + F: FnMut(DecryptProgress), +{ + fn new(callback: Option) -> Self { + Self { callback } + } + + fn report(&mut self, phase: DecryptProgressPhase, completed: u64, total: Option) { + if let Some(callback) = self.callback.as_mut() { + callback(DecryptProgress::new(phase, completed, total)); + } + } +} + +fn decrypt_bytes_with_optional_progress( + input: &[u8], + options: &DecryptOptions, + progress: Option, +) -> Result, DecryptError> +where + F: FnMut(DecryptProgress), +{ + let mut reporter = ProgressReporter::new(progress); + let output = decrypt_input_bytes(input, options, &mut reporter)?; + reporter.report(DecryptProgressPhase::FinalizeOutput, 0, Some(1)); + reporter.report(DecryptProgressPhase::FinalizeOutput, 1, Some(1)); + Ok(output) +} + +fn decrypt_file_with_optional_progress( + input_path: &Path, + output_path: &Path, + options: &DecryptOptions, + progress: Option, +) -> Result<(), DecryptError> +where + F: FnMut(DecryptProgress), +{ + let mut reporter = ProgressReporter::new(progress); + reporter.report(DecryptProgressPhase::OpenInput, 0, Some(1)); + let input = fs::read(input_path)?; + reporter.report(DecryptProgressPhase::OpenInput, 1, Some(1)); + + let output = decrypt_input_bytes(&input, options, &mut reporter)?; + + reporter.report(DecryptProgressPhase::OpenOutput, 0, Some(1)); + fs::write(output_path, output)?; + reporter.report(DecryptProgressPhase::OpenOutput, 1, Some(1)); + reporter.report(DecryptProgressPhase::FinalizeOutput, 0, Some(1)); + reporter.report(DecryptProgressPhase::FinalizeOutput, 1, Some(1)); + Ok(()) +} + +#[cfg(feature = "async")] +async fn decrypt_file_with_optional_progress_async( + input_path: &Path, + output_path: &Path, + options: &DecryptOptions, + progress: Option, +) -> Result<(), DecryptError> +where + F: FnMut(DecryptProgress) + Send, +{ + let mut reporter = ProgressReporter::new(progress); + reporter.report(DecryptProgressPhase::OpenInput, 0, Some(1)); + let input = tokio_fs::read(input_path).await?; + reporter.report(DecryptProgressPhase::OpenInput, 1, Some(1)); + + let output = decrypt_input_bytes(&input, options, &mut reporter)?; + + reporter.report(DecryptProgressPhase::OpenOutput, 0, Some(1)); + tokio_fs::write(output_path, output).await?; + reporter.report(DecryptProgressPhase::OpenOutput, 1, Some(1)); + reporter.report(DecryptProgressPhase::FinalizeOutput, 0, Some(1)); + reporter.report(DecryptProgressPhase::FinalizeOutput, 1, Some(1)); + Ok(()) +} + +fn decrypt_input_bytes( + input: &[u8], + options: &DecryptOptions, + reporter: &mut ProgressReporter, +) -> Result, DecryptError> +where + F: FnMut(DecryptProgress), +{ + reporter.report(DecryptProgressPhase::InspectStructure, 0, Some(1)); + let layout = classify_decrypt_input(input)?; + reporter.report(DecryptProgressPhase::InspectStructure, 1, Some(1)); + match layout { + DecryptInputLayout::InitSegment => { + reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); + let output = decrypt_common_encryption_init_bytes(input, options.keys())?; + reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); + Ok(output) + } + DecryptInputLayout::MediaSegment => { + reporter.report(DecryptProgressPhase::OpenFragmentsInfo, 0, Some(1)); + let fragments_info = options + .fragments_info_bytes() + .ok_or(DecryptError::MissingFragmentsInfo)?; + reporter.report(DecryptProgressPhase::OpenFragmentsInfo, 1, Some(1)); + reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); + let output = decrypt_common_encryption_media_segment_bytes( + fragments_info, + input, + options.keys(), + )?; + reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); + Ok(output) + } + DecryptInputLayout::FragmentedFile => { + reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); + let output = decrypt_common_encryption_file_bytes(input, options.keys())?; + reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); + Ok(output) + } + DecryptInputLayout::MarlinIpmpFile => { + reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); + let output = decrypt_marlin_movie_file_bytes(input, options.keys())?; + reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); + Ok(output) + } + DecryptInputLayout::OmaDcfProtectedMovieFile => { + reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); + let output = decrypt_oma_dcf_movie_file_bytes(input, options.keys())?; + reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); + Ok(output) + } + DecryptInputLayout::IaecProtectedMovieFile => { + reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); + let output = decrypt_iaec_movie_file_bytes(input, options.keys())?; + reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); + Ok(output) + } + DecryptInputLayout::OmaDcfAtomFile => { + reporter.report(DecryptProgressPhase::ProcessSamples, 0, Some(1)); + let output = decrypt_oma_dcf_atom_file_bytes(input, options.keys())?; + reporter.report(DecryptProgressPhase::ProcessSamples, 1, Some(1)); + Ok(output) + } + } +} + +fn classify_decrypt_input(input: &[u8]) -> Result { + let mut reader = Cursor::new(input); + let has_moov = !extract_box(&mut reader, None, BoxPath::from([MOOV]))?.is_empty(); + let mut reader = Cursor::new(input); + let has_moof = !extract_box(&mut reader, None, BoxPath::from([MOOF]))?.is_empty(); + let mut reader = Cursor::new(input); + let has_mdat = !extract_box(&mut reader, None, BoxPath::from([MDAT]))?.is_empty(); + let mut reader = Cursor::new(input); + let has_odrm = !extract_box(&mut reader, None, BoxPath::from([ODRM]))?.is_empty(); + let mut reader = Cursor::new(input); + let ftyp = extract_box_as::<_, Ftyp>(&mut reader, None, BoxPath::from([FTYP]))?; + let is_marlin_ipmp_movie = ftyp.iter().any(|entry| { + entry.major_brand == MARLIN_BRAND_MGSV + || entry.compatible_brands.contains(&MARLIN_BRAND_MGSV) + }); + let is_oma_dcf_atom_file = has_odrm + && ftyp + .iter() + .any(|entry| entry.major_brand == ODCF || entry.compatible_brands.contains(&ODCF)); + let protected_movie_layout = + if has_moov && has_mdat && !has_moof && !is_oma_dcf_atom_file && is_marlin_ipmp_movie { + Some(DecryptInputLayout::MarlinIpmpFile) + } else if has_moov && has_mdat && !has_moof && !is_oma_dcf_atom_file { + detect_non_fragmented_protected_movie_layout(input)? + } else { + None + }; + + match ( + has_moov, + has_moof, + has_mdat, + is_oma_dcf_atom_file, + protected_movie_layout, + ) { + (false, false, _, true, _) => Ok(DecryptInputLayout::OmaDcfAtomFile), + (true, true, _, false, _) => Ok(DecryptInputLayout::FragmentedFile), + (true, false, true, false, Some(DecryptInputLayout::MarlinIpmpFile)) => { + Ok(DecryptInputLayout::MarlinIpmpFile) + } + (true, false, true, false, Some(DecryptInputLayout::OmaDcfProtectedMovieFile)) => { + Ok(DecryptInputLayout::OmaDcfProtectedMovieFile) + } + (true, false, true, false, Some(DecryptInputLayout::IaecProtectedMovieFile)) => { + Ok(DecryptInputLayout::IaecProtectedMovieFile) + } + (true, false, false, false, _) => Ok(DecryptInputLayout::InitSegment), + (false, true, _, false, _) => Ok(DecryptInputLayout::MediaSegment), + (false, false, false, false, _) => Err(DecryptError::InvalidInput { + reason: "expected a moov box, a moof box, both, or a root OMA DCF atom file" + .to_owned(), + }), + (_, _, _, true, _) => Err(DecryptError::InvalidInput { + reason: + "root OMA DCF atom files are expected to carry odrm without moov or moof at the top level" + .to_owned(), + }), + (true, false, true, false, None) => Err(DecryptError::InvalidInput { + reason: + "non-fragmented movie files are only supported for the current Marlin IPMP, OMA DCF, or IAEC protected layouts" + .to_owned(), + }), + _ => Err(DecryptError::InvalidInput { + reason: "input does not match one of the currently supported decrypt layouts" + .to_owned(), + }), + } +} + +fn detect_non_fragmented_protected_movie_layout( + input: &[u8], +) -> Result, DecryptError> { + if contains_oma_dcf_protected_sample_entries(input)? { + return Ok(Some(DecryptInputLayout::OmaDcfProtectedMovieFile)); + } + if contains_iaec_protected_sample_entries(input)? { + return Ok(Some(DecryptInputLayout::IaecProtectedMovieFile)); + } + Ok(None) +} + +fn contains_oma_dcf_protected_sample_entries(input: &[u8]) -> Result { + let mut reader = Cursor::new(input); + let odkm_infos = extract_box( + &mut reader, + None, + BoxPath::from([ + MOOV, + TRAK, + MDIA, + MINF, + STBL, + STSD, + FourCc::ANY, + SINF, + SCHI, + ODKM, + ]), + )?; + if !odkm_infos.is_empty() { + return Ok(true); + } + + let mut reader = Cursor::new(input); + let schm_boxes = extract_box_as::<_, Schm>( + &mut reader, + None, + BoxPath::from([MOOV, TRAK, MDIA, MINF, STBL, STSD, FourCc::ANY, SINF, SCHM]), + )?; + Ok(schm_boxes.iter().any(|entry| entry.scheme_type == ODKM)) +} + +fn contains_iaec_protected_sample_entries(input: &[u8]) -> Result { + let mut reader = Cursor::new(input); + let scheme_boxes = extract_box_as::<_, Schm>( + &mut reader, + None, + BoxPath::from([MOOV, TRAK, MDIA, MINF, STBL, STSD, FourCc::ANY, SINF, SCHM]), + )?; + Ok(scheme_boxes.iter().any(|entry| entry.scheme_type == IAEC)) +} + +fn decrypt_oma_dcf_atom_file_bytes( + input: &[u8], + keys: &[DecryptionKey], +) -> Result, DecryptRewriteError> { + let root_boxes = read_root_box_infos(input)?; + let mut output = Vec::with_capacity(input.len()); + let mut odrm_index = 0_u32; + + for info in root_boxes { + if info.box_type() != ODRM { + output.extend_from_slice(slice_box_bytes(input, info)?); + continue; + } + + odrm_index = odrm_index + .checked_add(1) + .ok_or_else(|| invalid_layout("OMA DCF atom index overflowed u32".to_string()))?; + let key = keys.iter().find_map(|entry| match entry.id() { + DecryptionKeyId::TrackId(candidate) if candidate == odrm_index => { + Some(entry.key_bytes()) + } + _ => None, + }); + + if let Some(key) = key { + output.extend_from_slice(&rewrite_oma_dcf_atom_box(input, info, key)?); + } else { + output.extend_from_slice(slice_box_bytes(input, info)?); + } + } + + Ok(output) +} + +fn rewrite_oma_dcf_atom_box( + input: &[u8], + odrm_info: BoxInfo, + key: [u8; 16], +) -> Result, DecryptRewriteError> { + let odrm_info = normalize_oma_dcf_atom_root_info(input, odrm_info)?; + let mut reader = Cursor::new(input); + let odhe = + extract_single_as::<_, Odhe>(&mut reader, Some(&odrm_info), BoxPath::from([ODHE]), "odhe")?; + let mut reader = Cursor::new(input); + let odhe_info = + extract_single_info(&mut reader, Some(&odrm_info), BoxPath::from([ODHE]), "odhe")?; + let mut reader = Cursor::new(input); + let ohdr = + extract_single_as::<_, Ohdr>(&mut reader, Some(&odhe_info), BoxPath::from([OHDR]), "ohdr")?; + let mut reader = Cursor::new(input); + let ohdr_info = + extract_single_info(&mut reader, Some(&odhe_info), BoxPath::from([OHDR]), "ohdr")?; + let mut reader = Cursor::new(input); + let odda = + extract_single_as::<_, Odda>(&mut reader, Some(&odrm_info), BoxPath::from([ODDA]), "odda")?; + let odda_info = { + let mut reader = Cursor::new(input); + extract_single_info(&mut reader, Some(&odrm_info), BoxPath::from([ODDA]), "odda")? + }; + let grpi = { + let mut reader = Cursor::new(input); + extract_optional_single_as::<_, Grpi>( + &mut reader, + Some(&ohdr_info), + BoxPath::from([GRPI]), + "grpi", + )? + }; + + if ohdr.encryption_method == OHDR_ENCRYPTION_METHOD_NULL { + return Ok(slice_box_bytes(input, odrm_info)?.to_vec()); + } + + let content_key = unwrap_oma_dcf_group_key(&ohdr, grpi.as_ref(), key)?; + let clear_payload = decrypt_oma_dcf_atom_payload(&ohdr, &odda, content_key)?; + let mut patched_ohdr = ohdr.clone(); + patched_ohdr.encryption_method = OHDR_ENCRYPTION_METHOD_NULL; + patched_ohdr.padding_scheme = OHDR_PADDING_SCHEME_NONE; + + let mut patched_odda = odda.clone(); + patched_odda.encrypted_payload = clear_payload; + + let rebuilt_odhe = rebuild_oma_dcf_odhe(input, odhe, odhe_info, patched_ohdr, ohdr_info)?; + let rebuilt_odda = + encode_box_with_children_and_header_size(&patched_odda, &[], odda_info.header_size())?; + + let mut reader = Cursor::new(input); + let child_infos = extract_box(&mut reader, Some(&odrm_info), BoxPath::from([FourCc::ANY]))?; + let mut odrm_children = Vec::new(); + for child_info in child_infos { + match child_info.box_type() { + ODHE => odrm_children.extend_from_slice(&rebuilt_odhe), + ODDA => odrm_children.extend_from_slice(&rebuilt_odda), + _ => odrm_children.extend_from_slice(slice_box_bytes(input, child_info)?), + } + } + + rebuild_oma_dcf_odrm(input, odrm_info, &odrm_children) +} + +fn rebuild_oma_dcf_odhe( + input: &[u8], + odhe: Odhe, + odhe_info: BoxInfo, + patched_ohdr: Ohdr, + ohdr_info: BoxInfo, +) -> Result, DecryptRewriteError> { + let rebuilt_ohdr = rebuild_oma_dcf_ohdr(input, patched_ohdr, ohdr_info)?; + let mut reader = Cursor::new(input); + let child_infos = extract_box(&mut reader, Some(&odhe_info), BoxPath::from([FourCc::ANY]))?; + let mut odhe_children = Vec::new(); + for child_info in child_infos { + match child_info.box_type() { + OHDR => odhe_children.extend_from_slice(&rebuilt_ohdr), + _ => odhe_children.extend_from_slice(slice_box_bytes(input, child_info)?), + } + } + encode_box_with_children(&odhe, &odhe_children) +} + +fn rebuild_oma_dcf_ohdr( + input: &[u8], + ohdr: Ohdr, + ohdr_info: BoxInfo, +) -> Result, DecryptRewriteError> { + let mut reader = Cursor::new(input); + let child_infos = extract_box(&mut reader, Some(&ohdr_info), BoxPath::from([FourCc::ANY]))?; + let mut ohdr_children = Vec::new(); + for child_info in child_infos { + ohdr_children.extend_from_slice(slice_box_bytes(input, child_info)?); + } + encode_box_with_children(&ohdr, &ohdr_children) +} + +fn normalize_oma_dcf_atom_root_info( + input: &[u8], + odrm_info: BoxInfo, +) -> Result { + let generic_header_size = raw_header_size(input, odrm_info)?; + let header_size = if generic_header_size == 16 { + let version_flags_offset = odrm_info + .offset() + .checked_add(generic_header_size) + .ok_or_else(|| { + invalid_layout("OMA DCF atom root header offset overflowed u64".to_owned()) + })?; + let child_header_offset = version_flags_offset.checked_add(4).ok_or_else(|| { + invalid_layout("OMA DCF atom root child offset overflowed u64".to_owned()) + })?; + let version_flags_offset = usize::try_from(version_flags_offset).map_err(|_| { + invalid_layout("OMA DCF atom root header offset does not fit in usize".to_owned()) + })?; + let child_header_offset = usize::try_from(child_header_offset).map_err(|_| { + invalid_layout("OMA DCF atom root child offset does not fit in usize".to_owned()) + })?; + let has_full_box_prefix = input + .get(version_flags_offset..version_flags_offset + 4) + .is_some_and(|prefix| prefix == [0, 0, 0, 0]) + && input + .get(child_header_offset + 4..child_header_offset + 8) + .is_some_and(|box_type| box_type == ODHE.as_bytes()); + if has_full_box_prefix { + 20 + } else { + generic_header_size + } + } else { + generic_header_size + }; + + Ok(odrm_info.with_header_size(header_size)) +} + +fn rebuild_oma_dcf_odrm( + input: &[u8], + odrm_info: BoxInfo, + children: &[u8], +) -> Result, DecryptRewriteError> { + let generic_header_size = raw_header_size(input, odrm_info)?; + let generic_header_size = usize::try_from(generic_header_size).map_err(|_| { + invalid_layout("OMA DCF atom root header size does not fit in usize".to_owned()) + })?; + let full_header_size = usize::try_from(odrm_info.header_size()).map_err(|_| { + invalid_layout("OMA DCF atom root normalized header size does not fit in usize".to_owned()) + })?; + let header_extra = input + .get( + usize::try_from(odrm_info.offset()).map_err(|_| { + invalid_layout("OMA DCF atom root offset does not fit in usize".to_owned()) + })? + generic_header_size + ..usize::try_from(odrm_info.offset()).map_err(|_| { + invalid_layout("OMA DCF atom root offset does not fit in usize".to_owned()) + })? + full_header_size, + ) + .ok_or_else(|| { + invalid_layout("OMA DCF atom root header bytes are outside the input buffer".to_owned()) + })?; + let mut payload = Vec::with_capacity(header_extra.len() + children.len()); + payload.extend_from_slice(header_extra); + payload.extend_from_slice(children); + encode_raw_box_with_header_size( + ODRM, + &payload, + u64::try_from(generic_header_size).unwrap_or(8), + ) +} + +fn decrypt_oma_dcf_atom_payload( + ohdr: &Ohdr, + odda: &Odda, + key: [u8; 16], +) -> Result, DecryptRewriteError> { + let plaintext_length = usize::try_from(ohdr.plaintext_length) + .map_err(|_| invalid_layout("OMA DCF plaintext length does not fit in usize".to_owned()))?; + + match ohdr.encryption_method { + OHDR_ENCRYPTION_METHOD_NULL => Ok(odda.encrypted_payload.clone()), + OHDR_ENCRYPTION_METHOD_AES_CBC => { + if ohdr.padding_scheme != OHDR_PADDING_SCHEME_RFC_2630 { + return Err(invalid_layout( + "OMA DCF AES-CBC atom payloads require RFC 2630 padding".to_owned(), + )); + } + decrypt_oma_dcf_cbc_payload(&odda.encrypted_payload, key, plaintext_length) + } + OHDR_ENCRYPTION_METHOD_AES_CTR => { + if ohdr.padding_scheme != OHDR_PADDING_SCHEME_NONE { + return Err(invalid_layout( + "OMA DCF AES-CTR atom payloads require no padding".to_owned(), + )); + } + decrypt_oma_dcf_ctr_payload(&odda.encrypted_payload, key, plaintext_length) + } + other => Err(invalid_layout(format!( + "OMA DCF atom payload uses unsupported encryption method {other}" + ))), + } +} + +fn unwrap_oma_dcf_group_key( + ohdr: &Ohdr, + grpi: Option<&Grpi>, + key: [u8; 16], +) -> Result<[u8; 16], DecryptRewriteError> { + let Some(grpi) = grpi else { + return Ok(key); + }; + + if grpi.group_key.len() < 32 { + return Err(invalid_layout( + "OMA DCF group-key-wrapped content keys must include a 16-byte IV plus wrapped key bytes" + .to_owned(), + )); + } + + let unwrapped = match ohdr.encryption_method { + OHDR_ENCRYPTION_METHOD_AES_CBC => decrypt_oma_dcf_cbc_payload(&grpi.group_key, key, 16)?, + OHDR_ENCRYPTION_METHOD_AES_CTR => decrypt_oma_dcf_ctr_payload(&grpi.group_key, key, 16)?, + OHDR_ENCRYPTION_METHOD_NULL => return Ok(key), + other => { + return Err(invalid_layout(format!( + "OMA DCF group-key unwrap uses unsupported encryption method {other}" + ))); + } + }; + + unwrapped.try_into().map_err(|_| { + invalid_layout("OMA DCF group-key unwrap did not yield one 16-byte content key".to_owned()) + }) +} + +fn decrypt_oma_dcf_cbc_payload( + payload: &[u8], + key: [u8; 16], + plaintext_length: usize, +) -> Result, DecryptRewriteError> { + if payload.len() < 32 || !payload.len().is_multiple_of(16) { + return Err(invalid_layout( + "OMA DCF AES-CBC atom payload must include a 16-byte IV plus block-aligned ciphertext" + .to_owned(), + )); + } + + let iv = &payload[..16]; + let ciphertext = &payload[16..]; + let cipher = Aes128::new(&key.into()); + let mut previous = [0_u8; 16]; + previous.copy_from_slice(iv); + let mut decrypted = Vec::with_capacity(ciphertext.len()); + + for chunk in ciphertext.chunks_exact(16) { + let mut block = Block::::default(); + block.copy_from_slice(chunk); + cipher.decrypt_block(&mut block); + for (index, value) in block.iter_mut().enumerate() { + *value ^= previous[index]; + } + decrypted.extend_from_slice(&block); + previous.copy_from_slice(chunk); + } + + let unpadded = remove_rfc_2630_padding(&decrypted)?; + if unpadded.len() != plaintext_length { + return Err(invalid_layout(format!( + "OMA DCF AES-CBC plaintext length mismatch: header declared {plaintext_length} bytes but decrypted {}", + unpadded.len() + ))); + } + Ok(unpadded) +} + +fn decrypt_oma_dcf_ctr_payload( + payload: &[u8], + key: [u8; 16], + plaintext_length: usize, +) -> Result, DecryptRewriteError> { + if payload.len() < 16 { + return Err(invalid_layout( + "OMA DCF AES-CTR atom payload must include a 16-byte IV".to_owned(), + )); + } + + let mut counter = [0_u8; 16]; + counter.copy_from_slice(&payload[..16]); + let ciphertext = &payload[16..]; + let cipher = Aes128::new(&key.into()); + let mut output = vec![0_u8; ciphertext.len()]; + + for (index, chunk) in ciphertext.chunks(16).enumerate() { + let mut keystream = Block::::default(); + keystream.copy_from_slice(&counter); + cipher.encrypt_block(&mut keystream); + let start = index * 16; + for (offset, byte) in chunk.iter().enumerate() { + output[start + offset] = byte ^ keystream[offset]; + } + increment_counter_be(&mut counter); + } + + if output.len() != plaintext_length { + return Err(invalid_layout(format!( + "OMA DCF AES-CTR plaintext length mismatch: header declared {plaintext_length} bytes but decrypted {}", + output.len() + ))); + } + Ok(output) +} + +fn remove_rfc_2630_padding(bytes: &[u8]) -> Result, DecryptRewriteError> { + let Some(&padding_size) = bytes.last() else { + return Err(invalid_layout( + "OMA DCF AES-CBC payload cannot be empty after decryption".to_owned(), + )); + }; + let padding_size = usize::from(padding_size); + if padding_size == 0 || padding_size > 16 || padding_size > bytes.len() { + return Err(invalid_layout( + "OMA DCF AES-CBC payload has invalid RFC 2630 padding".to_owned(), + )); + } + if !bytes[bytes.len() - padding_size..] + .iter() + .all(|byte| usize::from(*byte) == padding_size) + { + return Err(invalid_layout( + "OMA DCF AES-CBC payload has inconsistent RFC 2630 padding bytes".to_owned(), + )); + } + Ok(bytes[..bytes.len() - padding_size].to_vec()) +} + +fn increment_counter_be(counter: &mut [u8; 16]) { + for byte in counter.iter_mut().rev() { + let (value, carry) = byte.overflowing_add(1); + *byte = value; + if !carry { + break; + } + } +} + +fn read_root_box_infos(input: &[u8]) -> Result, DecryptRewriteError> { + let mut reader = Cursor::new(input); + let mut root_boxes = Vec::new(); + loop { + let position = reader.stream_position().map_err(|error| { + invalid_layout(format!("failed to read root-box position: {error}")) + })?; + if usize::try_from(position) + .ok() + .is_some_and(|offset| offset >= input.len()) + { + break; + } + + let info = BoxInfo::read(&mut reader) + .map_err(|error| invalid_layout(format!("failed to read root box header: {error}")))?; + info.seek_to_end(&mut reader) + .map_err(|error| invalid_layout(format!("failed to skip past root box: {error}")))?; + root_boxes.push(info); + } + Ok(root_boxes) +} + +fn slice_box_bytes(input: &[u8], info: BoxInfo) -> Result<&[u8], DecryptRewriteError> { + let start = usize::try_from(info.offset()) + .map_err(|_| invalid_layout("box offset does not fit in usize".to_owned()))?; + let end = usize::try_from(info.offset() + info.size()) + .map_err(|_| invalid_layout("box end does not fit in usize".to_owned()))?; + input.get(start..end).ok_or_else(|| { + invalid_layout(format!( + "box bytes for {} are outside the available input buffer", + info.box_type() + )) + }) +} + +fn encode_raw_box(box_type: FourCc, payload: &[u8]) -> Result, DecryptRewriteError> { + encode_raw_box_with_header_size(box_type, payload, 8) +} + +fn encode_raw_box_with_header_size( + box_type: FourCc, + payload: &[u8], + header_size: u64, +) -> Result, DecryptRewriteError> { + let size = header_size + .checked_add(u64::try_from(payload.len()).map_err(|_| { + invalid_layout("encoded box payload length does not fit in u64".to_owned()) + })?) + .ok_or_else(|| invalid_layout("encoded box size overflowed u64".to_owned()))?; + let info = BoxInfo::new(box_type, size).with_header_size(header_size); + let mut bytes = info.encode(); + bytes.extend_from_slice(payload); + Ok(bytes) +} + +fn encode_box_with_children( + box_value: &T, + children: &[u8], +) -> Result, DecryptRewriteError> +where + T: crate::codec::CodecBox + ImmutableBox, +{ + encode_box_with_children_and_header_size(box_value, children, 8) +} + +fn encode_box_with_children_and_header_size( + box_value: &T, + children: &[u8], + header_size: u64, +) -> Result, DecryptRewriteError> +where + T: crate::codec::CodecBox + ImmutableBox, +{ + let mut payload = Vec::new(); + marshal(&mut payload, box_value, None).map_err(|error| { + invalid_layout(format!( + "failed to encode {} payload: {error}", + box_value.box_type() + )) + })?; + payload.extend_from_slice(children); + encode_raw_box_with_header_size(box_value.box_type(), &payload, header_size) +} + +fn raw_header_size(input: &[u8], info: BoxInfo) -> Result { + let offset = usize::try_from(info.offset()) + .map_err(|_| invalid_layout("box offset does not fit in usize".to_owned()))?; + let size_field = input.get(offset..offset + 4).ok_or_else(|| { + invalid_layout("box header bytes are outside the input buffer".to_owned()) + })?; + let size_field = u32::from_be_bytes(size_field.try_into().unwrap()); + Ok(if size_field == 1 { 16 } else { 8 }) +} + +fn decrypt_marlin_movie_file_bytes( + input: &[u8], + keys: &[DecryptionKey], +) -> Result, DecryptRewriteError> { + let context = analyze_marlin_movie_file(input)?; + let root_boxes = read_root_box_infos(input)?; + let mdat_ranges = media_data_ranges_from_infos(&context.mdat_infos); + let mut track_keys = BTreeMap::new(); + for track in &context.tracks { + let Some(protection) = track.marlin.as_ref() else { + continue; + }; + if let Some(track_key) = resolve_marlin_track_key(track.track_id, protection, keys)? { + track_keys.insert(track.track_id, track_key); + } + } + + let payload_tracks = context + .tracks + .iter() + .map(|track| MovieTrackPayloadPlan { + track_id: track.track_id, + stsc: &track.stsc, + chunk_offsets: &track.chunk_offsets, + sample_sizes: &track.sample_sizes, + }) + .collect::>(); + let (clear_payload, clear_sizes_by_track, relative_chunk_offsets) = rebuild_movie_payload( + input, + &mdat_ranges, + &payload_tracks, + |track_id, _sample_index, _absolute_offset, _sample_size, sample_bytes| { + if let Some(key) = track_keys.get(&track_id).copied() { + decrypt_marlin_sample_payload(sample_bytes, key) + } else { + Ok(sample_bytes.to_vec()) + } + }, + )?; + + let mut track_plans = Vec::new(); + for track in &context.tracks { + let clear_sizes = clear_sizes_by_track.get(&track.track_id).ok_or_else(|| { + invalid_layout(format!( + "missing clear sample sizes for Marlin track {}", + track.track_id + )) + })?; + track_plans.push(MovieTrackRewritePlan { + track_id: track.track_id, + trak_info: track.trak_info, + mdia_info: track.mdia_info, + minf_info: track.minf_info, + stbl_info: track.stbl_info, + chunk_offsets: track.chunk_offsets.clone(), + stsd_replacement: None, + stsz_replacement: Some(( + track.stsz_info.offset(), + build_patched_stsz_bytes(&track.stsz, clear_sizes, "Marlin")?, + )), + }); + } + + let placeholder_offsets = track_plans + .iter() + .map(|plan| (plan.track_id, chunk_offsets_values(&plan.chunk_offsets))) + .collect::(); + let moov_placeholder = build_marlin_moov_with_track_replacements( + input, + &context, + &track_plans, + &placeholder_offsets, + )?; + let clear_mdat = encode_raw_box(MDAT, &clear_payload)?; + let clear_mdat_header_size = + u64::try_from(clear_mdat.len().saturating_sub(clear_payload.len())).map_err(|_| { + invalid_layout("clear Marlin mdat header size does not fit in u64".to_owned()) + })?; + let mdat_payload_start = compute_single_mdat_payload_offset( + input, + &root_boxes, + Some(context.ftyp_info), + context.moov_info, + Some(&encode_box_with_children( + &build_clear_marlin_ftyp(&context.ftyp), + &[], + )?), + &moov_placeholder, + clear_mdat_header_size, + )?; + + let absolute_offsets = relative_chunk_offsets + .iter() + .map(|(track_id, offsets)| { + let absolute = offsets + .iter() + .map(|offset| { + mdat_payload_start.checked_add(*offset).ok_or_else(|| { + invalid_layout("clear Marlin chunk offset overflowed u64".to_owned()) + }) + }) + .collect::, _>>()?; + Ok((*track_id, absolute)) + }) + .collect::>()?; + let clear_moov = build_marlin_moov_with_track_replacements( + input, + &context, + &track_plans, + &absolute_offsets, + )?; + let clear_ftyp = encode_box_with_children(&build_clear_marlin_ftyp(&context.ftyp), &[])?; + + rebuild_root_boxes_with_single_mdat( + input, + &root_boxes, + Some(context.ftyp_info), + context.moov_info, + Some(&clear_ftyp), + &clear_moov, + &clear_mdat, + ) +} + +fn build_clear_marlin_ftyp(ftyp: &Ftyp) -> Ftyp { + let mp42 = FourCc::from_bytes(*b"mp42"); + let mut clear = ftyp.clone(); + clear.major_brand = mp42; + clear.minor_version = 1; + for brand in &mut clear.compatible_brands { + if *brand == MARLIN_BRAND_MGSV { + *brand = mp42; + } + } + clear +} + +fn build_marlin_moov_with_track_replacements( + input: &[u8], + context: &MarlinMovieContext, + track_plans: &[MovieTrackRewritePlan], + chunk_offsets_by_track: &TrackRelativeChunkOffsets, +) -> Result, DecryptRewriteError> { + let mut moov_replacements = BTreeMap::from([ + (context.iods_info.offset(), None), + (context.od_track_info.offset(), None), + ]); + for plan in track_plans { + let new_offsets = chunk_offsets_by_track + .get(&plan.track_id) + .cloned() + .ok_or_else(|| { + invalid_layout(format!( + "missing rewritten chunk offsets for Marlin track {}", + plan.track_id + )) + })?; + let mut stbl_replacements = BTreeMap::new(); + stbl_replacements.insert( + chunk_offset_box_offset(&plan.chunk_offsets), + Some(build_patched_chunk_offset_box_bytes( + &plan.chunk_offsets, + &new_offsets, + )?), + ); + if let Some((offset, bytes)) = &plan.stsz_replacement { + stbl_replacements.insert(*offset, Some(bytes.clone())); + } + let trak_bytes = rebuild_track_with_stbl_replacements( + input, + plan.trak_info, + plan.mdia_info, + plan.minf_info, + plan.stbl_info, + &stbl_replacements, + )?; + moov_replacements.insert(plan.trak_info.offset(), Some(trak_bytes)); + } + rebuild_box_with_child_replacements(input, context.moov_info, &moov_replacements, None) +} + +fn analyze_marlin_movie_file(input: &[u8]) -> Result { + let root_boxes = read_root_box_infos(input)?; + let ftyp_info = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == FTYP) + .ok_or_else(|| { + invalid_layout("expected one root ftyp box in the Marlin movie file".to_owned()) + })?; + let moov_info = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOV) + .ok_or_else(|| { + invalid_layout("expected one root moov box in the Marlin movie file".to_owned()) + })?; + let mdat_infos = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MDAT) + .collect::>(); + if mdat_infos.is_empty() { + return Err(invalid_layout( + "expected at least one root mdat box in the Marlin movie file".to_owned(), + )); + } + + let mut reader = Cursor::new(input); + let ftyp = extract_single_as::<_, Ftyp>(&mut reader, None, BoxPath::from([FTYP]), "ftyp")?; + if ftyp.major_brand != MARLIN_BRAND_MGSV && !ftyp.compatible_brands.contains(&MARLIN_BRAND_MGSV) + { + return Err(invalid_layout( + "the current Marlin movie path expects the MGSV file-type brand".to_owned(), + )); + } + + let iods_info = { + let mut reader = Cursor::new(input); + extract_single_info(&mut reader, None, BoxPath::from([MOOV, IODS]), "iods")? + }; + let iods = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Iods>(&mut reader, None, BoxPath::from([MOOV, IODS]), "iods")? + }; + let initial_object_descriptor = iods.initial_object_descriptor().ok_or_else(|| { + invalid_layout( + "the current Marlin movie path expects one initial object descriptor in iods" + .to_owned(), + ) + })?; + let od_track_id = initial_object_descriptor + .sub_descriptors + .iter() + .find_map(|descriptor| descriptor.es_id_inc_descriptor()) + .map(|descriptor| descriptor.track_id) + .ok_or_else(|| { + invalid_layout( + "the current Marlin movie path expects iods to carry one ES-ID-increment descriptor" + .to_owned(), + ) + })?; + + let mut reader = Cursor::new(input); + let trak_infos = extract_box(&mut reader, None, BoxPath::from([MOOV, TRAK]))?; + let mut od_track_info = None; + for trak_info in &trak_infos { + let mut reader = Cursor::new(input); + let tkhd = extract_single_as::<_, Tkhd>( + &mut reader, + Some(trak_info), + BoxPath::from([TKHD]), + "trak/tkhd", + )?; + if tkhd.track_id == od_track_id { + od_track_info = Some(*trak_info); + break; + } + } + let od_track_info = od_track_info.ok_or_else(|| { + invalid_layout(format!( + "expected one Marlin object-descriptor track with track id {od_track_id}" + )) + })?; + + let mdat_ranges = media_data_ranges_from_infos(&mdat_infos); + let marlin_tracks = analyze_marlin_od_track(input, &od_track_info, &mdat_ranges)?; + if marlin_tracks.is_empty() { + return Err(invalid_layout( + "the current Marlin movie path found no carried track protection entries in the OD track" + .to_owned(), + )); + } + + let mut tracks = Vec::new(); + for trak_info in trak_infos { + if trak_info.offset() == od_track_info.offset() { + continue; + } + tracks.push(analyze_marlin_movie_track( + input, + &trak_info, + &marlin_tracks, + )?); + } + + Ok(MarlinMovieContext { + ftyp_info, + ftyp, + moov_info, + iods_info, + od_track_info, + mdat_infos, + tracks, + }) +} + +fn analyze_marlin_od_track( + input: &[u8], + od_track_info: &BoxInfo, + mdat_ranges: &[MediaDataRange], +) -> Result, DecryptRewriteError> { + let od_track_id = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Tkhd>( + &mut reader, + Some(od_track_info), + BoxPath::from([TKHD]), + "trak/tkhd", + )? + .track_id + }; + let mpod = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Mpod>( + &mut reader, + Some(od_track_info), + BoxPath::from([FourCc::from_bytes(*b"tref"), FourCc::from_bytes(*b"mpod")]), + "mpod", + )? + }; + if mpod.track_ids.is_empty() { + return Err(invalid_layout( + "the current Marlin OD track expects one or more mpod track references".to_owned(), + )); + } + + let stsz = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Stsz>( + &mut reader, + Some(od_track_info), + BoxPath::from([MDIA, MINF, STBL, STSZ]), + "stsz", + )? + }; + let od_sample_sizes = sample_sizes_from_stsz(&stsz)?; + if od_sample_sizes.is_empty() { + return Err(invalid_layout(format!( + "the current Marlin OD track path expects at least one OD sample but found {}", + od_sample_sizes.len() + ))); + } + + let stsc = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Stsc>( + &mut reader, + Some(od_track_info), + BoxPath::from([MDIA, MINF, STBL, STSC]), + "stsc", + )? + }; + let chunk_offsets = { + let mut reader = Cursor::new(input); + let stco = extract_optional_single_as::<_, Stco>( + &mut reader, + Some(od_track_info), + BoxPath::from([MDIA, MINF, STBL, STCO]), + "stco", + )?; + let mut reader = Cursor::new(input); + let co64 = extract_optional_single_as::<_, Co64>( + &mut reader, + Some(od_track_info), + BoxPath::from([MDIA, MINF, STBL, FourCc::from_bytes(*b"co64")]), + "co64", + )?; + let mut reader = Cursor::new(input); + let stco_info = extract_box( + &mut reader, + Some(od_track_info), + BoxPath::from([MDIA, MINF, STBL, STCO]), + )?; + let mut reader = Cursor::new(input); + let co64_info = extract_box( + &mut reader, + Some(od_track_info), + BoxPath::from([MDIA, MINF, STBL, FourCc::from_bytes(*b"co64")]), + )?; + match (stco, co64) { + (Some(_), Some(_)) => { + return Err(invalid_layout( + "the current Marlin OD track path does not support both stco and co64" + .to_owned(), + )); + } + (Some(stco), None) => { + let [info] = stco_info.as_slice() else { + return Err(invalid_layout(format!( + "expected exactly one stco box for the Marlin OD track but found {}", + stco_info.len() + ))); + }; + ChunkOffsetBoxState::Stco { + info: *info, + box_value: stco, + } + } + (None, Some(co64)) => { + let [info] = co64_info.as_slice() else { + return Err(invalid_layout(format!( + "expected exactly one co64 box for the Marlin OD track but found {}", + co64_info.len() + ))); + }; + ChunkOffsetBoxState::Co64 { + info: *info, + box_value: co64, + } + } + (None, None) => { + return Err(invalid_layout( + "the current Marlin OD track path expects stco or co64".to_owned(), + )); + } + } + }; + let od_chunks = compute_track_chunks(od_track_id, &stsc, &chunk_offsets, &od_sample_sizes)?; + let (sample_offset, sample_size) = od_chunks + .iter() + .find_map(|chunk| chunk.sample_sizes.first().map(|size| (chunk.offset, *size))) + .ok_or_else(|| { + invalid_layout( + "the current Marlin OD track path could not resolve the first OD sample".to_owned(), + ) + })?; + + let sample_bytes = read_sample_range(input, mdat_ranges, sample_offset, sample_size).ok_or( + DecryptRewriteError::SampleDataRangeNotFound { + track_id: od_track_id, + sample_index: 1, + absolute_offset: sample_offset, + sample_size, + }, + )?; + let commands = parse_descriptor_commands(sample_bytes).map_err(|error| { + invalid_layout(format!( + "failed to parse Marlin OD track command stream: {error}" + )) + })?; + let object_update = commands + .iter() + .find_map(|command| match command { + DescriptorCommand::DescriptorUpdate(update) if update.tag == 0x01 => Some(update), + _ => None, + }) + .ok_or_else(|| { + invalid_layout( + "the current Marlin OD track path expects one object-descriptor-update command" + .to_owned(), + ) + })?; + let ipmp_update = commands + .iter() + .find_map(|command| match command { + DescriptorCommand::DescriptorUpdate(update) if update.tag == 0x05 => Some(update), + _ => None, + }) + .ok_or_else(|| { + invalid_layout( + "the current Marlin OD track path expects one IPMP-descriptor-update command" + .to_owned(), + ) + })?; + + let mut tracks = BTreeMap::new(); + for descriptor in &object_update.descriptors { + let Some(object_descriptor) = descriptor.object_descriptor() else { + continue; + }; + let Some(es_id_ref) = object_descriptor + .sub_descriptors + .iter() + .find_map(|descriptor| descriptor.es_id_ref_descriptor()) + else { + continue; + }; + let ref_index = usize::from(es_id_ref.ref_index); + if ref_index == 0 || ref_index > mpod.track_ids.len() { + continue; + } + let track_id = mpod.track_ids[ref_index - 1]; + let Some(pointer) = object_descriptor + .sub_descriptors + .iter() + .find_map(|descriptor| descriptor.ipmp_descriptor_pointer()) + else { + continue; + }; + let Some(ipmp_descriptor) = ipmp_update.descriptors.iter().find_map(|descriptor| { + let ipmp_descriptor = descriptor.ipmp_descriptor()?; + (ipmp_descriptor.ipmps_type == MARLIN_IPMPS_TYPE_MGSV + && ipmp_descriptor.descriptor_id == pointer.descriptor_id) + .then_some(ipmp_descriptor) + }) else { + continue; + }; + let Some(protection) = parse_marlin_track_protection(&ipmp_descriptor.data)? else { + continue; + }; + tracks.insert(track_id, protection); + } + + Ok(tracks) +} + +fn parse_marlin_track_protection( + bytes: &[u8], +) -> Result, DecryptRewriteError> { + let carried_atoms = read_root_box_infos(bytes)?; + for atom_info in carried_atoms { + if atom_info.box_type() != SINF { + continue; + } + let atom_bytes = slice_box_bytes(bytes, atom_info)?; + if let Some(protection) = parse_marlin_sinf(atom_bytes)? { + return Ok(Some(protection)); + } + } + Ok(None) +} + +fn parse_marlin_sinf(bytes: &[u8]) -> Result, DecryptRewriteError> { + let payload = bytes.get(8..).ok_or_else(|| { + invalid_layout("Marlin sinf bytes are shorter than their box header".to_owned()) + })?; + let child_infos = read_root_box_infos(payload)?; + let satr_type = FourCc::from_bytes(*b"satr"); + let styp_type = FourCc::from_bytes(*b"styp"); + + let mut scheme = None; + let mut stream_type = None; + let mut wrapped_group_key = None; + for child_info in child_infos { + match child_info.box_type() { + SCHM => { + let child_bytes = slice_box_bytes(payload, child_info)?; + let versioned_payload = child_bytes + .get(usize::try_from(child_info.header_size()).unwrap_or(8)..) + .ok_or_else(|| { + invalid_layout("Marlin schm atom is shorter than expected".to_owned()) + })?; + let short_payload = versioned_payload.get(4..).ok_or_else(|| { + invalid_layout("Marlin schm atom is missing its short-form payload".to_owned()) + })?; + scheme = Some( + MarlinShortSchm::parse_payload(short_payload).map_err(|error| { + invalid_layout(format!( + "failed to parse Marlin short-form schm payload: {error}" + )) + })?, + ); + } + SCHI => { + let schi_bytes = slice_box_bytes(payload, child_info)?; + let schi_payload = schi_bytes + .get(usize::try_from(child_info.header_size()).unwrap_or(8)..) + .ok_or_else(|| { + invalid_layout("Marlin schi atom is shorter than expected".to_owned()) + })?; + let schi_children = read_root_box_infos(schi_payload)?; + for schi_child in schi_children { + match schi_child.box_type() { + GKEY => { + let gkey_bytes = slice_box_bytes(schi_payload, schi_child)?; + let gkey_payload = gkey_bytes + .get(usize::try_from(schi_child.header_size()).unwrap_or(8)..) + .ok_or_else(|| { + invalid_layout( + "Marlin gkey atom is shorter than expected".to_owned(), + ) + })?; + wrapped_group_key = Some(gkey_payload.to_vec()); + } + box_type if box_type == satr_type => { + let satr_bytes = slice_box_bytes(schi_payload, schi_child)?; + let satr_payload = satr_bytes + .get(usize::try_from(schi_child.header_size()).unwrap_or(8)..) + .ok_or_else(|| { + invalid_layout( + "Marlin satr atom is shorter than expected".to_owned(), + ) + })?; + let satr_children = read_root_box_infos(satr_payload)?; + for satr_child in satr_children { + if satr_child.box_type() != styp_type { + continue; + } + let styp_bytes = slice_box_bytes(satr_payload, satr_child)?; + let styp_payload = styp_bytes + .get(usize::try_from(satr_child.header_size()).unwrap_or(8)..) + .ok_or_else(|| { + invalid_layout( + "Marlin styp atom is shorter than expected".to_owned(), + ) + })?; + stream_type = Some( + MarlinStyp::parse_payload(styp_payload) + .map_err(|error| { + invalid_layout(format!( + "failed to parse Marlin styp payload: {error}" + )) + })? + .value, + ); + } + } + _ => {} + } + } + } + _ => {} + } + } + + let Some(scheme) = scheme else { + return Ok(None); + }; + let key_mode = if scheme.uses_track_key() { + MarlinTrackKeyMode::Track + } else if scheme.uses_group_key() { + MarlinTrackKeyMode::Group + } else { + return Ok(None); + }; + + Ok(Some(MarlinTrackProtection { + key_mode, + stream_type, + wrapped_group_key, + })) +} + +fn analyze_marlin_movie_track( + input: &[u8], + trak_info: &BoxInfo, + marlin_tracks: &BTreeMap, +) -> Result { + let mut reader = Cursor::new(input); + let tkhd = extract_single_as::<_, Tkhd>( + &mut reader, + Some(trak_info), + BoxPath::from([TKHD]), + "trak/tkhd", + )?; + let mdia_info = { + let mut reader = Cursor::new(input); + extract_single_info(&mut reader, Some(trak_info), BoxPath::from([MDIA]), "mdia")? + }; + let minf_info = { + let mut reader = Cursor::new(input); + extract_single_info( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF]), + "minf", + )? + }; + let stbl_info = { + let mut reader = Cursor::new(input); + extract_single_info( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL]), + "stbl", + )? + }; + + let stsz = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Stsz>( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STSZ]), + "stsz", + )? + }; + let stsz_info = { + let mut reader = Cursor::new(input); + extract_single_info( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STSZ]), + "stsz", + )? + }; + let sample_sizes = sample_sizes_from_stsz(&stsz)?; + + let stsc = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Stsc>( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STSC]), + "stsc", + )? + }; + let chunk_offsets = { + let mut reader = Cursor::new(input); + let stco = extract_optional_single_as::<_, Stco>( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STCO]), + "stco", + )?; + let mut reader = Cursor::new(input); + let co64 = extract_optional_single_as::<_, Co64>( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, FourCc::from_bytes(*b"co64")]), + "co64", + )?; + let mut reader = Cursor::new(input); + let stco_info = extract_box( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STCO]), + )?; + let mut reader = Cursor::new(input); + let co64_info = extract_box( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, FourCc::from_bytes(*b"co64")]), + )?; + match (stco, co64) { + (Some(_), Some(_)) => { + return Err(invalid_layout(format!( + "track {} has both stco and co64 chunk-offset boxes", + tkhd.track_id + ))); + } + (Some(stco), None) => { + let [info] = stco_info.as_slice() else { + return Err(invalid_layout(format!( + "expected exactly one stco box for track {} but found {}", + tkhd.track_id, + stco_info.len() + ))); + }; + ChunkOffsetBoxState::Stco { + info: *info, + box_value: stco, + } + } + (None, Some(co64)) => { + let [info] = co64_info.as_slice() else { + return Err(invalid_layout(format!( + "expected exactly one co64 box for track {} but found {}", + tkhd.track_id, + co64_info.len() + ))); + }; + ChunkOffsetBoxState::Co64 { + info: *info, + box_value: co64, + } + } + (None, None) => { + return Err(invalid_layout(format!( + "track {} is missing stco or co64 chunk offsets", + tkhd.track_id + ))); + } + } + }; + + Ok(MarlinMovieTrackState { + track_id: tkhd.track_id, + trak_info: *trak_info, + mdia_info, + minf_info, + stbl_info, + stsz_info, + stsz, + stsc, + chunk_offsets, + sample_sizes, + marlin: marlin_tracks.get(&tkhd.track_id).cloned(), + }) +} + +#[derive(Clone, Debug)] +struct TrackChunkLayout { + offset: u64, + sample_sizes: Vec, + // MP4 stores the selected sample description as a 1-based stsd index in stsc. + sample_description_index: u32, +} + +#[derive(Clone, Copy)] +struct ChunkLayoutMapping { + sample_count: u32, + sample_description_index: u32, +} + +fn compute_chunk_layout_mappings( + stsc: &Stsc, + chunk_count: usize, + sample_count: usize, + track_id: u32, +) -> Result, DecryptRewriteError> { + if chunk_count == 0 { + return Ok(Vec::new()); + } + if stsc.entries.is_empty() { + return Err(invalid_layout(format!( + "track {} is missing stsc entries for its {} chunk(s)", + track_id, chunk_count + ))); + } + + let mut counts = Vec::with_capacity(chunk_count); + for (index, entry) in stsc.entries.iter().enumerate() { + if entry.first_chunk == 0 { + return Err(invalid_layout(format!( + "track {} has an stsc entry with first_chunk 0", + track_id + ))); + } + if entry.sample_description_index == 0 { + return Err(invalid_layout(format!( + "track {} has an stsc entry with sample-description index 0", + track_id + ))); + } + let next_first_chunk = stsc + .entries + .get(index + 1) + .map(|entry| entry.first_chunk) + .unwrap_or(u32::try_from(chunk_count + 1).map_err(|_| { + invalid_layout("chunk-count sentinel does not fit in u32".to_owned()) + })?); + if next_first_chunk <= entry.first_chunk { + return Err(invalid_layout(format!( + "track {} has descending or duplicated stsc first_chunk values", + track_id + ))); + } + + for _ in entry.first_chunk..next_first_chunk { + counts.push(ChunkLayoutMapping { + sample_count: entry.samples_per_chunk, + sample_description_index: entry.sample_description_index, + }); + } + } + + if counts.len() != chunk_count { + return Err(invalid_layout(format!( + "track {} resolved {} chunk mappings from stsc but has {} chunk offset(s)", + track_id, + counts.len(), + chunk_count + ))); + } + let resolved_sample_count = counts.iter().try_fold(0usize, |total, count| { + total + .checked_add(usize::try_from(count.sample_count).map_err(|_| { + invalid_layout("stsc samples-per-chunk value does not fit in usize".to_owned()) + })?) + .ok_or_else(|| { + invalid_layout("resolved chunk sample count overflowed usize".to_owned()) + }) + })?; + if resolved_sample_count != sample_count { + return Err(invalid_layout(format!( + "track {} resolved {} samples from stsc but stsz reports {}", + track_id, resolved_sample_count, sample_count + ))); + } + + Ok(counts) +} + +fn chunk_offsets_values(chunk_offsets: &ChunkOffsetBoxState) -> Vec { + match chunk_offsets { + ChunkOffsetBoxState::Stco { box_value, .. } => box_value.chunk_offset.to_vec(), + ChunkOffsetBoxState::Co64 { box_value, .. } => box_value.chunk_offset.clone(), + } +} + +fn compute_track_chunks( + track_id: u32, + stsc: &Stsc, + chunk_offsets: &ChunkOffsetBoxState, + sample_sizes: &[u32], +) -> Result, DecryptRewriteError> { + let chunk_offsets = chunk_offsets_values(chunk_offsets); + let chunk_layouts = + compute_chunk_layout_mappings(stsc, chunk_offsets.len(), sample_sizes.len(), track_id)?; + + let mut sample_index = 0usize; + let mut chunks = Vec::with_capacity(chunk_offsets.len()); + for (offset, chunk_layout) in chunk_offsets.into_iter().zip(chunk_layouts) { + let sample_count = usize::try_from(chunk_layout.sample_count) + .map_err(|_| invalid_layout("chunk sample count does not fit in usize".to_owned()))?; + let end = sample_index + .checked_add(sample_count) + .ok_or_else(|| invalid_layout("track sample cursor overflowed usize".to_owned()))?; + let Some(sample_sizes) = sample_sizes.get(sample_index..end) else { + return Err(invalid_layout(format!( + "track {} chunk layout exceeds the available sample-size table", + track_id + ))); + }; + chunks.push(TrackChunkLayout { + offset, + sample_sizes: sample_sizes.to_vec(), + sample_description_index: chunk_layout.sample_description_index, + }); + sample_index = end; + } + if sample_index != sample_sizes.len() { + return Err(invalid_layout(format!( + "track {} chunk layout left {} sample-size entries unused", + track_id, + sample_sizes.len() - sample_index + ))); + } + + Ok(chunks) +} + +fn resolve_marlin_track_key( + track_id: u32, + protection: &MarlinTrackProtection, + keys: &[DecryptionKey], +) -> Result, DecryptRewriteError> { + match protection.key_mode { + MarlinTrackKeyMode::Track => Ok(keys.iter().find_map(|entry| match entry.id() { + DecryptionKeyId::TrackId(candidate) if candidate == track_id => Some(entry.key_bytes()), + _ => None, + })), + MarlinTrackKeyMode::Group => { + let Some(group_key) = keys.iter().find_map(|entry| match entry.id() { + DecryptionKeyId::TrackId(0) => Some(entry.key_bytes()), + _ => None, + }) else { + return Ok(None); + }; + let wrapped_key = protection.wrapped_group_key.as_ref().ok_or_else(|| { + invalid_layout(format!( + "Marlin group-key track {} is missing its wrapped gkey payload", + track_id + )) + })?; + Ok(Some(unwrap_marlin_group_key(group_key, wrapped_key)?)) + } + } +} + +fn unwrap_marlin_group_key( + group_key: [u8; 16], + wrapped_key: &[u8], +) -> Result<[u8; 16], DecryptRewriteError> { + if wrapped_key.len() < 24 || !wrapped_key.len().is_multiple_of(8) { + return Err(invalid_layout( + "Marlin group-key unwrap expects a wrapped key payload of at least 24 bytes and a multiple of 8" + .to_owned(), + )); + } + + let n = wrapped_key.len() / 8 - 1; + let mut a = wrapped_key[..8].try_into().unwrap(); + let mut r = wrapped_key[8..] + .chunks_exact(8) + .map(|chunk| chunk.try_into().unwrap()) + .collect::>(); + let aes = Aes128::new(&group_key.into()); + + for j in (0..=5usize).rev() { + for i in (1..=n).rev() { + let t = u64::try_from(n * j + i).map_err(|_| { + invalid_layout("Marlin group-key unwrap round index overflowed u64".to_owned()) + })?; + let mut block = Block::::default(); + let mut a_value = u64::from_be_bytes(a); + a_value ^= t; + block[..8].copy_from_slice(&a_value.to_be_bytes()); + block[8..].copy_from_slice(&r[i - 1]); + aes.decrypt_block(&mut block); + a.copy_from_slice(&block[..8]); + r[i - 1].copy_from_slice(&block[8..16]); + } + } + + if a != [0xA6; 8] { + return Err(invalid_layout( + "Marlin group-key unwrap failed its AES key-wrap integrity check".to_owned(), + )); + } + + let mut clear = Vec::with_capacity(r.len() * 8); + for chunk in r { + clear.extend_from_slice(&chunk); + } + let clear = <[u8; 16]>::try_from(clear.as_slice()).map_err(|_| { + invalid_layout("Marlin group-key unwrap did not yield one 16-byte track key".to_owned()) + })?; + Ok(clear) +} + +fn decrypt_marlin_sample_payload( + payload: &[u8], + key: [u8; 16], +) -> Result, DecryptRewriteError> { + decrypt_oma_dcf_cbc_sample_payload(payload, key) +} + +fn decrypt_oma_dcf_movie_file_bytes( + input: &[u8], + keys: &[DecryptionKey], +) -> Result, DecryptRewriteError> { + let context = analyze_oma_dcf_movie_file(input)?; + let protected_by_track = context + .tracks + .iter() + .map(|track| (track.track_id, track)) + .collect::>(); + let track_keys = keys + .iter() + .filter_map(|entry| match entry.id() { + DecryptionKeyId::TrackId(track_id) => Some((track_id, entry.key_bytes())), + _ => None, + }) + .collect::>(); + let mdat_ranges = media_data_ranges_from_infos(&context.mdat_infos); + + let mut payload_tracks = context + .tracks + .iter() + .map(|track| MovieTrackPayloadPlan { + track_id: track.track_id, + stsc: &track.stsc, + chunk_offsets: &track.chunk_offsets, + sample_sizes: &track.sample_sizes, + }) + .collect::>(); + payload_tracks.extend( + context + .other_tracks + .iter() + .map(|track| MovieTrackPayloadPlan { + track_id: track.track_id, + stsc: &track.stsc, + chunk_offsets: &track.chunk_offsets, + sample_sizes: &track.sample_sizes, + }), + ); + + let (clear_payload, clear_sample_sizes, track_chunk_offsets) = rebuild_movie_payload( + input, + &mdat_ranges, + &payload_tracks, + |track_id, _sample_index, _absolute_offset, _sample_size, sample_bytes| { + let Some(track) = protected_by_track.get(&track_id) else { + return Ok(sample_bytes.to_vec()); + }; + let Some(key) = track_keys.get(&track_id).copied() else { + return Ok(sample_bytes.to_vec()); + }; + decrypt_oma_dcf_sample_entry_payload(&track.odaf, &track.ohdr, key, sample_bytes) + }, + )?; + + let mut track_plans = Vec::new(); + for track in &context.tracks { + let stsd_replacement = if track_keys.contains_key(&track.track_id) { + Some(( + track.stsd_info.offset(), + rebuild_box_with_child_replacements( + input, + track.stsd_info, + &BTreeMap::from([( + track.sample_entry_info.offset(), + Some(build_clear_sample_entry_bytes( + input, + track.sample_entry_info, + track.original_format, + track.sinf_info, + )?), + )]), + None, + )?, + )) + } else { + None + }; + let stsz_replacement = if track_keys.contains_key(&track.track_id) { + Some(( + track.stsz_info.offset(), + build_patched_stsz_bytes( + &track.stsz, + clear_sample_sizes.get(&track.track_id).ok_or_else(|| { + invalid_layout(format!( + "missing rebuilt sample sizes for OMA DCF track {}", + track.track_id + )) + })?, + "OMA DCF", + )?, + )) + } else { + None + }; + track_plans.push(MovieTrackRewritePlan { + track_id: track.track_id, + trak_info: track.trak_info, + mdia_info: track.mdia_info, + minf_info: track.minf_info, + stbl_info: track.stbl_info, + chunk_offsets: track.chunk_offsets.clone(), + stsd_replacement, + stsz_replacement, + }); + } + track_plans.extend( + context + .other_tracks + .iter() + .map(|track| MovieTrackRewritePlan { + track_id: track.track_id, + trak_info: track.trak_info, + mdia_info: track.mdia_info, + minf_info: track.minf_info, + stbl_info: track.stbl_info, + chunk_offsets: track.chunk_offsets.clone(), + stsd_replacement: None, + stsz_replacement: None, + }), + ); + + rebuild_movie_file_with_track_plans( + MovieRootRewriteContext { + input, + ftyp_info: context.ftyp_info, + moov_info: context.moov_info, + mdat_infos: &context.mdat_infos, + }, + &track_plans, + &track_chunk_offsets, + &clear_payload, + build_patched_oma_clear_ftyp_bytes(input, context.ftyp_info)?, + ) +} + +fn analyze_movie_chunk_track( + input: &[u8], + trak_info: &BoxInfo, +) -> Result { + let mut reader = Cursor::new(input); + let tkhd = extract_single_as::<_, Tkhd>( + &mut reader, + Some(trak_info), + BoxPath::from([TKHD]), + "trak/tkhd", + )?; + let mdia_info = { + let mut reader = Cursor::new(input); + extract_single_info(&mut reader, Some(trak_info), BoxPath::from([MDIA]), "mdia")? + }; + let minf_info = { + let mut reader = Cursor::new(input); + extract_single_info( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF]), + "minf", + )? + }; + let stbl_info = { + let mut reader = Cursor::new(input); + extract_single_info( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL]), + "stbl", + )? + }; + let stsz = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Stsz>( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STSZ]), + "stsz", + )? + }; + if stsz.sample_count == 0 { + return Err(invalid_layout(format!( + "track {} has no samples to decrypt in stsz", + tkhd.track_id + ))); + } + let sample_sizes = sample_sizes_from_stsz(&stsz)?; + let stsc = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Stsc>( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STSC]), + "stsc", + )? + }; + let stco = { + let mut reader = Cursor::new(input); + extract_optional_single_as::<_, Stco>( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STCO]), + "stco", + )? + }; + let co64 = { + let mut reader = Cursor::new(input); + extract_optional_single_as::<_, Co64>( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, FourCc::from_bytes(*b"co64")]), + "co64", + )? + }; + let chunk_offsets = match (stco, co64) { + (Some(_), Some(_)) => { + return Err(invalid_layout(format!( + "track {} has both stco and co64 chunk-offset boxes", + tkhd.track_id + ))); + } + (Some(stco), None) => { + let info = { + let mut reader = Cursor::new(input); + extract_single_info( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STCO]), + "stco", + )? + }; + ChunkOffsetBoxState::Stco { + info, + box_value: stco, + } + } + (None, Some(co64)) => { + let info = { + let mut reader = Cursor::new(input); + extract_single_info( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, FourCc::from_bytes(*b"co64")]), + "co64", + )? + }; + ChunkOffsetBoxState::Co64 { + info, + box_value: co64, + } + } + (None, None) => { + return Err(invalid_layout(format!( + "track {} is missing stco or co64 chunk offsets", + tkhd.track_id + ))); + } + }; + + let _ = compute_track_chunks(tkhd.track_id, &stsc, &chunk_offsets, &sample_sizes)?; + + Ok(MovieChunkTrackState { + track_id: tkhd.track_id, + trak_info: *trak_info, + mdia_info, + minf_info, + stbl_info, + stsc, + chunk_offsets, + sample_sizes, + }) +} + +fn analyze_oma_dcf_movie_file( + input: &[u8], +) -> Result { + let root_boxes = read_root_box_infos(input)?; + let ftyp_info = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == FTYP); + let Some(moov_info) = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOV) + else { + return Err(invalid_layout( + "expected one root moov box in the protected movie file".to_owned(), + )); + }; + let mdat_infos = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MDAT) + .collect::>(); + if mdat_infos.is_empty() { + return Err(invalid_layout( + "expected at least one root mdat box in the protected movie file".to_owned(), + )); + } + let mut reader = Cursor::new(input); + let traks = extract_box(&mut reader, None, BoxPath::from([MOOV, TRAK]))?; + let mut protected_tracks = Vec::new(); + let mut other_tracks = Vec::new(); + for trak_info in traks { + if let Some(track) = analyze_oma_dcf_movie_track(input, &trak_info)? { + protected_tracks.push(track); + } else { + other_tracks.push(analyze_movie_chunk_track(input, &trak_info)?); + } + } + + if protected_tracks.is_empty() { + return Err(invalid_layout( + "expected at least one OMA DCF protected sample-entry track in the movie file" + .to_owned(), + )); + } + + Ok(OmaProtectedMovieContext { + ftyp_info, + moov_info, + tracks: protected_tracks, + other_tracks, + mdat_infos, + }) +} + +fn analyze_oma_dcf_movie_track( + input: &[u8], + trak_info: &BoxInfo, +) -> Result, DecryptRewriteError> { + let track_layout = analyze_movie_chunk_track(input, trak_info)?; + let stsd = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Stsd>( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STSD]), + "stsd", + )? + }; + let stsd_info = { + let mut reader = Cursor::new(input); + extract_single_info( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STSD]), + "stsd", + )? + }; + + let mut reader = Cursor::new(input); + let encv_infos = extract_box( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCV]), + )?; + let mut reader = Cursor::new(input); + let enca_infos = extract_box( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCA]), + )?; + let (sample_entry_info, sample_entry_type) = + match (encv_infos.as_slice(), enca_infos.as_slice()) { + ([], []) => return Ok(None), + ([info], []) => (*info, ENCV), + ([], [info]) => (*info, ENCA), + _ => { + return Err(invalid_layout(format!( + "track {} has an unsupported protected sample-entry count", + track_layout.track_id + ))); + } + }; + + let protected_prefix = BoxPath::from([MDIA, MINF, STBL, STSD, sample_entry_type]); + let protected_sinf_prefix = child_path(&protected_prefix, SINF); + let original_format = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Frma>( + &mut reader, + Some(trak_info), + child_path(&protected_sinf_prefix, FRMA), + "frma", + )? + .data_format + }; + let sinf_info = { + let mut reader = Cursor::new(input); + extract_single_info( + &mut reader, + Some(trak_info), + protected_sinf_prefix.clone(), + "sinf", + )? + }; + let schm = { + let mut reader = Cursor::new(input); + extract_optional_single_as::<_, Schm>( + &mut reader, + Some(trak_info), + child_path(&protected_sinf_prefix, SCHM), + "schm", + )? + }; + let odkm_prefix = child_path(&child_path(&protected_sinf_prefix, SCHI), ODKM); + let odkm_info = { + let mut reader = Cursor::new(input); + let mut infos = extract_box(&mut reader, Some(trak_info), odkm_prefix.clone())?; + if infos.len() > 1 { + return Err(invalid_layout(format!( + "expected at most one odkm box for track {} but found {}", + track_layout.track_id, + infos.len() + ))); + } + infos.pop() + }; + + let is_oma = match schm { + Some(schm) => schm.scheme_type == ODKM, + None => odkm_info.is_some(), + }; + if !is_oma { + return Ok(None); + } + + ensure_standard_protected_movie_uses_first_sample_description( + track_layout.track_id, + "OMA DCF", + &stsd, + &track_layout.stsc, + )?; + + let odaf = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Odaf>( + &mut reader, + Some(trak_info), + child_path(&odkm_prefix, ODAF), + "odaf", + )? + }; + if odaf.key_indicator_length != 0 { + return Err(invalid_layout(format!( + "track {} uses unsupported OMA DCF key-indicator length {}", + track_layout.track_id, odaf.key_indicator_length + ))); + } + if odaf.iv_length > 16 { + return Err(invalid_layout(format!( + "track {} uses unsupported OMA DCF IV length {}", + track_layout.track_id, odaf.iv_length + ))); + } + + let ohdr = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Ohdr>( + &mut reader, + Some(trak_info), + child_path(&odkm_prefix, OHDR), + "ohdr", + )? + }; + let ohdr_info = { + let mut reader = Cursor::new(input); + extract_single_info( + &mut reader, + Some(trak_info), + child_path(&odkm_prefix, OHDR), + "ohdr", + )? + }; + let mut reader = Cursor::new(input); + let grpi_children = extract_box(&mut reader, Some(&ohdr_info), BoxPath::from([GRPI]))?; + if !grpi_children.is_empty() { + return Err(invalid_layout( + "group-key-wrapped OMA DCF protected sample entries are not supported yet".to_owned(), + )); + } + + Ok(Some(OmaProtectedMovieTrackState { + track_id: track_layout.track_id, + trak_info: track_layout.trak_info, + mdia_info: track_layout.mdia_info, + minf_info: track_layout.minf_info, + stbl_info: track_layout.stbl_info, + stsd_info, + sample_entry_info, + original_format, + sinf_info, + stsz_info: { + let mut reader = Cursor::new(input); + extract_single_info( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STSZ]), + "stsz", + )? + }, + stsz: { + let mut reader = Cursor::new(input); + extract_single_as::<_, Stsz>( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STSZ]), + "stsz", + )? + }, + stsc: track_layout.stsc, + chunk_offsets: track_layout.chunk_offsets, + sample_sizes: track_layout.sample_sizes, + odaf, + ohdr, + })) +} + +fn sample_sizes_from_stsz(stsz: &Stsz) -> Result, DecryptRewriteError> { + if stsz.sample_size != 0 { + return Ok(vec![stsz.sample_size; stsz.sample_count as usize]); + } + + if stsz.entry_size.len() != stsz.sample_count as usize { + return Err(invalid_layout(format!( + "stsz entry-size count {} does not match sample_count {}", + stsz.entry_size.len(), + stsz.sample_count + ))); + } + stsz.entry_size + .iter() + .copied() + .map(|size| { + u32::try_from(size).map_err(|_| { + invalid_layout("protected movie sample size does not fit in u32".to_owned()) + }) + }) + .collect() +} + +fn build_clear_sample_entry_bytes( + input: &[u8], + sample_entry_info: BoxInfo, + original_format: FourCc, + sinf_info: BoxInfo, +) -> Result, DecryptRewriteError> { + let mut child_replacements = BTreeMap::new(); + child_replacements.insert(sinf_info.offset(), None); + let mut rebuilt = + rebuild_box_with_child_replacements(input, sample_entry_info, &child_replacements, None)?; + patch_box_type_bytes(&mut rebuilt, original_format)?; + Ok(rebuilt) +} + +fn build_patched_stsz_bytes( + stsz: &Stsz, + clear_sample_sizes: &[u64], + label: &str, +) -> Result, DecryptRewriteError> { + let mut patched_stsz = stsz.clone(); + patched_stsz.sample_count = u32::try_from(clear_sample_sizes.len()) + .map_err(|_| invalid_layout(format!("{label} sample count does not fit in u32")))?; + if patched_stsz.sample_size == 0 { + patched_stsz.entry_size = clear_sample_sizes.to_vec(); + } else if let Some(&uniform_size) = clear_sample_sizes.first() { + if !clear_sample_sizes.iter().all(|&size| size == uniform_size) { + return Err(invalid_layout(format!( + "fixed-size {label} sample tables require all decrypted samples to have the same size" + ))); + } + patched_stsz.sample_size = u32::try_from(uniform_size) + .map_err(|_| invalid_layout(format!("{label} sample size does not fit in u32")))?; + patched_stsz.entry_size.clear(); + } else { + patched_stsz.sample_size = 0; + patched_stsz.entry_size.clear(); + } + encode_box_with_children(&patched_stsz, &[]) +} + +fn build_patched_chunk_offset_box_bytes( + chunk_offsets: &ChunkOffsetBoxState, + new_offsets: &[u64], +) -> Result, DecryptRewriteError> { + match chunk_offsets { + ChunkOffsetBoxState::Stco { box_value, .. } => { + let mut patched = box_value.clone(); + patched.chunk_offset = new_offsets.to_vec(); + encode_box_with_children(&patched, &[]) + } + ChunkOffsetBoxState::Co64 { box_value, .. } => { + let mut patched = box_value.clone(); + patched.chunk_offset = new_offsets.to_vec(); + encode_box_with_children(&patched, &[]) + } + } +} + +fn build_patched_oma_clear_ftyp_bytes( + input: &[u8], + ftyp_info: Option, +) -> Result>, DecryptRewriteError> { + let Some(_ftyp_info) = ftyp_info else { + return Ok(None); + }; + let mut reader = Cursor::new(input); + let mut ftyp = extract_single_as::<_, Ftyp>(&mut reader, None, BoxPath::from([FTYP]), "ftyp")?; + ftyp.compatible_brands.retain(|brand| *brand != OPF2); + Ok(Some(encode_box_with_children(&ftyp, &[])?)) +} + +fn media_data_ranges_from_infos(mdat_infos: &[BoxInfo]) -> Vec { + mdat_infos + .iter() + .map(|info| MediaDataRange { + start: info.offset() + info.header_size(), + end: info.offset() + info.size(), + }) + .collect() +} + +fn build_movie_moov_with_track_replacements( + input: &[u8], + moov_info: BoxInfo, + track_plans: &[MovieTrackRewritePlan], + chunk_offsets_by_track: &TrackRelativeChunkOffsets, +) -> Result, DecryptRewriteError> { + let mut moov_replacements = BTreeMap::new(); + for plan in track_plans { + let new_offsets = chunk_offsets_by_track + .get(&plan.track_id) + .cloned() + .ok_or_else(|| { + invalid_layout(format!( + "missing rewritten chunk offsets for movie track {}", + plan.track_id + )) + })?; + let mut stbl_replacements = BTreeMap::new(); + stbl_replacements.insert( + chunk_offset_box_offset(&plan.chunk_offsets), + Some(build_patched_chunk_offset_box_bytes( + &plan.chunk_offsets, + &new_offsets, + )?), + ); + if let Some((offset, bytes)) = &plan.stsd_replacement { + stbl_replacements.insert(*offset, Some(bytes.clone())); + } + if let Some((offset, bytes)) = &plan.stsz_replacement { + stbl_replacements.insert(*offset, Some(bytes.clone())); + } + let trak_bytes = rebuild_track_with_stbl_replacements( + input, + plan.trak_info, + plan.mdia_info, + plan.minf_info, + plan.stbl_info, + &stbl_replacements, + )?; + moov_replacements.insert(plan.trak_info.offset(), Some(trak_bytes)); + } + rebuild_box_with_child_replacements(input, moov_info, &moov_replacements, None) +} + +fn compute_single_mdat_payload_offset( + input: &[u8], + root_boxes: &[BoxInfo], + ftyp_info: Option, + moov_info: BoxInfo, + patched_ftyp_bytes: Option<&[u8]>, + moov_bytes: &[u8], + mdat_header_size: u64, +) -> Result { + let mut offset = 0_u64; + for info in root_boxes { + if info.box_type() == MDAT { + continue; + } + let size = if Some(*info) == ftyp_info { + patched_ftyp_bytes + .map(|bytes| bytes.len() as u64) + .unwrap_or(info.size()) + } else if info.offset() == moov_info.offset() { + u64::try_from(moov_bytes.len()).map_err(|_| { + invalid_layout("replacement moov size does not fit in u64".to_owned()) + })? + } else { + u64::try_from(slice_box_bytes(input, *info)?.len()) + .map_err(|_| invalid_layout("root box size does not fit in u64".to_owned()))? + }; + offset = offset + .checked_add(size) + .ok_or_else(|| invalid_layout("root box offset overflowed u64".to_owned()))?; + } + offset + .checked_add(mdat_header_size) + .ok_or_else(|| invalid_layout("clear mdat payload offset overflowed u64".to_owned())) +} + +fn rebuild_root_boxes_with_single_mdat( + input: &[u8], + root_boxes: &[BoxInfo], + ftyp_info: Option, + moov_info: BoxInfo, + patched_ftyp_bytes: Option<&[u8]>, + moov_bytes: &[u8], + mdat_bytes: &[u8], +) -> Result, DecryptRewriteError> { + let mut output = Vec::new(); + for info in root_boxes { + if info.box_type() == MDAT { + continue; + } + if Some(*info) == ftyp_info { + if let Some(bytes) = patched_ftyp_bytes { + output.extend_from_slice(bytes); + } else { + output.extend_from_slice(slice_box_bytes(input, *info)?); + } + } else if info.offset() == moov_info.offset() { + output.extend_from_slice(moov_bytes); + } else { + output.extend_from_slice(slice_box_bytes(input, *info)?); + } + } + output.extend_from_slice(mdat_bytes); + Ok(output) +} + +fn rebuild_movie_payload( + input: &[u8], + mdat_ranges: &[MediaDataRange], + tracks: &[MovieTrackPayloadPlan<'_>], + mut process_sample: F, +) -> Result +where + F: FnMut(u32, u32, u64, u32, &[u8]) -> Result, DecryptRewriteError>, +{ + let mut all_chunks = Vec::new(); + let mut sample_indices = BTreeMap::new(); + let mut rebuilt_sample_sizes = BTreeMap::>::new(); + let mut relative_offsets = BTreeMap::>::new(); + for track in tracks { + sample_indices.insert(track.track_id, 0_u32); + rebuilt_sample_sizes.insert(track.track_id, Vec::new()); + relative_offsets.insert(track.track_id, Vec::new()); + for chunk in compute_track_chunks( + track.track_id, + track.stsc, + track.chunk_offsets, + track.sample_sizes, + )? { + all_chunks.push((track.track_id, chunk)); + } + } + all_chunks.sort_by_key(|(_, chunk)| chunk.offset); + + let mut payload = Vec::new(); + let mut previous_chunk_end = None; + for (track_id, chunk) in all_chunks { + let chunk_size = sum_chunk_size(&chunk.sample_sizes)?; + if let Some(previous_chunk_end) = previous_chunk_end + && chunk.offset < previous_chunk_end + { + return Err(invalid_layout(format!( + "track {track_id} has overlapping chunk ranges in the protected movie layout at sample-description index {}", + chunk.sample_description_index + ))); + } + previous_chunk_end = Some( + chunk + .offset + .checked_add(chunk_size) + .ok_or_else(|| invalid_layout("movie chunk end overflowed u64".to_owned()))?, + ); + + relative_offsets + .get_mut(&track_id) + .unwrap() + .push(u64::try_from(payload.len()).map_err(|_| { + invalid_layout("rebuilt mdat payload length does not fit in u64".to_owned()) + })?); + + let mut sample_offset = chunk.offset; + for sample_size in chunk.sample_sizes { + let sample_index = sample_indices.get_mut(&track_id).ok_or_else(|| { + invalid_layout(format!( + "missing sample index state for movie track {}", + track_id + )) + })?; + *sample_index = sample_index + .checked_add(1) + .ok_or_else(|| invalid_layout("movie sample index overflowed u32".to_owned()))?; + let sample_bytes = read_sample_range(input, mdat_ranges, sample_offset, sample_size) + .ok_or(DecryptRewriteError::SampleDataRangeNotFound { + track_id, + sample_index: *sample_index, + absolute_offset: sample_offset, + sample_size, + })?; + let rebuilt = process_sample( + track_id, + *sample_index, + sample_offset, + sample_size, + sample_bytes, + )?; + rebuilt_sample_sizes.get_mut(&track_id).unwrap().push( + u64::try_from(rebuilt.len()).map_err(|_| { + invalid_layout("rebuilt movie sample size does not fit in u64".to_owned()) + })?, + ); + payload.extend_from_slice(&rebuilt); + sample_offset = sample_offset + .checked_add(u64::from(sample_size)) + .ok_or_else(|| invalid_layout("movie sample offset overflowed u64".to_owned()))?; + } + } + + Ok((payload, rebuilt_sample_sizes, relative_offsets)) +} + +fn rebuild_movie_file_with_track_plans( + root: MovieRootRewriteContext<'_>, + track_plans: &[MovieTrackRewritePlan], + relative_chunk_offsets: &TrackRelativeChunkOffsets, + clear_payload: &[u8], + patched_ftyp_bytes: Option>, +) -> Result, DecryptRewriteError> { + if root.mdat_infos.is_empty() { + return Err(invalid_layout( + "expected at least one root mdat box in the protected movie file".to_owned(), + )); + } + + let root_boxes = read_root_box_infos(root.input)?; + let placeholder_offsets = track_plans + .iter() + .map(|plan| (plan.track_id, chunk_offsets_values(&plan.chunk_offsets))) + .collect::(); + let moov_placeholder = build_movie_moov_with_track_replacements( + root.input, + root.moov_info, + track_plans, + &placeholder_offsets, + )?; + + let mdat_bytes = encode_raw_box(MDAT, clear_payload)?; + let mdat_header_size = u64::try_from(mdat_bytes.len().saturating_sub(clear_payload.len())) + .map_err(|_| invalid_layout("clear mdat header size does not fit in u64".to_owned()))?; + let mdat_payload_offset = compute_single_mdat_payload_offset( + root.input, + &root_boxes, + root.ftyp_info, + root.moov_info, + patched_ftyp_bytes.as_deref(), + &moov_placeholder, + mdat_header_size, + )?; + let absolute_offsets = relative_chunk_offsets + .iter() + .map(|(track_id, offsets)| { + let absolute = offsets + .iter() + .map(|offset| { + mdat_payload_offset.checked_add(*offset).ok_or_else(|| { + invalid_layout("patched movie chunk offset overflowed u64".to_owned()) + }) + }) + .collect::, _>>()?; + Ok((*track_id, absolute)) + }) + .collect::>()?; + let moov_final = build_movie_moov_with_track_replacements( + root.input, + root.moov_info, + track_plans, + &absolute_offsets, + )?; + rebuild_root_boxes_with_single_mdat( + root.input, + &root_boxes, + root.ftyp_info, + root.moov_info, + patched_ftyp_bytes.as_deref(), + &moov_final, + &mdat_bytes, + ) +} + +#[allow(clippy::too_many_arguments)] +fn rebuild_track_with_stbl_replacements( + input: &[u8], + trak_info: BoxInfo, + mdia_info: BoxInfo, + minf_info: BoxInfo, + stbl_info: BoxInfo, + stbl_replacements: &BTreeMap>>, +) -> Result, DecryptRewriteError> { + let stbl = rebuild_box_with_child_replacements(input, stbl_info, stbl_replacements, None)?; + + let mut minf_replacements = BTreeMap::new(); + minf_replacements.insert(stbl_info.offset(), Some(stbl)); + let minf = rebuild_box_with_child_replacements(input, minf_info, &minf_replacements, None)?; + + let mut mdia_replacements = BTreeMap::new(); + mdia_replacements.insert(minf_info.offset(), Some(minf)); + let mdia = rebuild_box_with_child_replacements(input, mdia_info, &mdia_replacements, None)?; + + let mut trak_replacements = BTreeMap::new(); + trak_replacements.insert(mdia_info.offset(), Some(mdia)); + rebuild_box_with_child_replacements(input, trak_info, &trak_replacements, None) +} + +fn sum_chunk_size(sample_sizes: &[u32]) -> Result { + sample_sizes.iter().try_fold(0_u64, |total, size| { + total + .checked_add(u64::from(*size)) + .ok_or_else(|| invalid_layout("chunk byte size overflowed u64".to_owned())) + }) +} +fn chunk_offset_box_offset(chunk_offsets: &ChunkOffsetBoxState) -> u64 { + match chunk_offsets { + ChunkOffsetBoxState::Stco { info, .. } | ChunkOffsetBoxState::Co64 { info, .. } => { + info.offset() + } + } +} + +fn rebuild_box_with_child_replacements( + input: &[u8], + parent_info: BoxInfo, + child_replacements: &BTreeMap>>, + override_type: Option, +) -> Result, DecryptRewriteError> { + let parent_bytes = slice_box_bytes(input, parent_info)?; + let header_size = usize::try_from(parent_info.header_size()) + .map_err(|_| invalid_layout("box header size does not fit in usize".to_owned()))?; + let mut reader = Cursor::new(input); + let child_infos = extract_box( + &mut reader, + Some(&parent_info), + BoxPath::from([FourCc::ANY]), + )?; + + let mut payload = Vec::with_capacity(parent_bytes.len().saturating_sub(header_size)); + let mut cursor = header_size; + for child_info in child_infos { + let relative_start = usize::try_from(child_info.offset() - parent_info.offset()) + .map_err(|_| invalid_layout("child offset does not fit in usize".to_owned()))?; + let relative_end = + usize::try_from(child_info.offset() + child_info.size() - parent_info.offset()) + .map_err(|_| invalid_layout("child end does not fit in usize".to_owned()))?; + if relative_start < cursor || relative_end > parent_bytes.len() { + return Err(invalid_layout(format!( + "child {} lies outside the available parent payload while rebuilding {}", + child_info.box_type(), + parent_info.box_type() + ))); + } + payload.extend_from_slice(&parent_bytes[cursor..relative_start]); + match child_replacements.get(&child_info.offset()) { + Some(Some(replacement)) => payload.extend_from_slice(replacement), + Some(None) => {} + None => payload.extend_from_slice(&parent_bytes[relative_start..relative_end]), + } + cursor = relative_end; + } + payload.extend_from_slice(&parent_bytes[cursor..]); + + let box_type = override_type.unwrap_or(parent_info.box_type()); + let total_size = u64::try_from(header_size) + .ok() + .and_then(|header| header.checked_add(u64::try_from(payload.len()).ok()?)) + .ok_or_else(|| invalid_layout("rebuilt box size overflowed u64".to_owned()))?; + let mut rebuilt = BoxInfo::new(box_type, total_size) + .with_header_size(parent_info.header_size()) + .encode(); + rebuilt.extend_from_slice(&payload); + Ok(rebuilt) +} + +fn decrypt_oma_dcf_sample_entry_payload( + odaf: &Odaf, + ohdr: &Ohdr, + key: [u8; 16], + sample_bytes: &[u8], +) -> Result, DecryptRewriteError> { + let mut payload = sample_bytes; + let is_encrypted = if odaf.selective_encryption { + let Some((&flag, rest)) = payload.split_first() else { + return Err(invalid_layout( + "selectively encrypted OMA DCF sample is missing its encryption flag".to_owned(), + )); + }; + payload = rest; + (flag & 0x80) != 0 + } else { + true + }; + + if !is_encrypted || ohdr.encryption_method == OHDR_ENCRYPTION_METHOD_NULL { + return Ok(payload.to_vec()); + } + + let iv_length = usize::from(odaf.iv_length); + if iv_length == 0 || payload.len() < iv_length { + return Err(invalid_layout( + "encrypted OMA DCF sample is missing its initialization vector".to_owned(), + )); + } + + match ohdr.encryption_method { + OHDR_ENCRYPTION_METHOD_AES_CBC => { + if iv_length != 16 { + return Err(invalid_layout( + "OMA DCF CBC sample decrypt requires a 16-byte initialization vector" + .to_owned(), + )); + } + if ohdr.padding_scheme != OHDR_PADDING_SCHEME_RFC_2630 { + return Err(invalid_layout( + "OMA DCF CBC sample decrypt requires RFC 2630 padding".to_owned(), + )); + } + decrypt_oma_dcf_cbc_sample_payload(payload, key) + } + OHDR_ENCRYPTION_METHOD_AES_CTR => { + if ohdr.padding_scheme != OHDR_PADDING_SCHEME_NONE { + return Err(invalid_layout( + "OMA DCF CTR sample decrypt requires the no-padding scheme".to_owned(), + )); + } + decrypt_oma_dcf_ctr_sample_payload(payload, key, iv_length) + } + method => Err(invalid_layout(format!( + "unsupported OMA DCF sample encryption method {method}" + ))), + } +} + +fn decrypt_oma_dcf_cbc_sample_payload( + payload: &[u8], + key: [u8; 16], +) -> Result, DecryptRewriteError> { + if payload.len() < 32 || !(payload.len() - 16).is_multiple_of(16) { + return Err(invalid_layout( + "OMA DCF CBC sample payload has an invalid IV or ciphertext length".to_owned(), + )); + } + + let mut previous = [0_u8; 16]; + previous.copy_from_slice(&payload[..16]); + let ciphertext = &payload[16..]; + let aes = Aes128::new(&key.into()); + let mut plaintext = Vec::with_capacity(ciphertext.len()); + for chunk in ciphertext.chunks_exact(16) { + let mut block = Block::::default(); + block.copy_from_slice(chunk); + let encrypted = block; + aes.decrypt_block(&mut block); + for index in 0..16 { + block[index] ^= previous[index]; + } + plaintext.extend_from_slice(&block); + previous.copy_from_slice(&encrypted); + } + remove_rfc_2630_padding(&plaintext) +} + +fn decrypt_oma_dcf_ctr_sample_payload( + payload: &[u8], + key: [u8; 16], + iv_length: usize, +) -> Result, DecryptRewriteError> { + if payload.len() < iv_length { + return Err(invalid_layout( + "OMA DCF CTR sample payload is shorter than its initialization vector".to_owned(), + )); + } + + let mut counter = [0_u8; 16]; + counter[16 - iv_length..].copy_from_slice(&payload[..iv_length]); + let ciphertext = &payload[iv_length..]; + let aes = Aes128::new(&key.into()); + let mut output = vec![0_u8; ciphertext.len()]; + let mut cursor = 0usize; + while cursor < ciphertext.len() { + let mut stream_block = Block::::default(); + stream_block.copy_from_slice(&counter); + aes.encrypt_block(&mut stream_block); + let chunk_len = 16.min(ciphertext.len() - cursor); + for index in 0..chunk_len { + output[cursor + index] = ciphertext[cursor + index] ^ stream_block[index]; + } + cursor += chunk_len; + increment_counter_suffix_be(&mut counter, iv_length); + } + Ok(output) +} + +fn increment_counter_suffix_be(counter: &mut [u8; 16], counter_bytes: usize) { + for byte in counter[16 - counter_bytes..].iter_mut().rev() { + *byte = byte.wrapping_add(1); + if *byte != 0 { + break; + } + } +} + +fn decrypt_iaec_movie_file_bytes( + input: &[u8], + keys: &[DecryptionKey], +) -> Result, DecryptRewriteError> { + let context = analyze_iaec_movie_file(input)?; + let protected_by_track = context + .tracks + .iter() + .map(|track| (track.track_id, track)) + .collect::>(); + let track_keys = keys + .iter() + .filter_map(|entry| match entry.id() { + DecryptionKeyId::TrackId(track_id) => Some((track_id, entry.key_bytes())), + _ => None, + }) + .collect::>(); + let mdat_ranges = media_data_ranges_from_infos(&context.mdat_infos); + + let mut payload_tracks = context + .tracks + .iter() + .map(|track| MovieTrackPayloadPlan { + track_id: track.track_id, + stsc: &track.stsc, + chunk_offsets: &track.chunk_offsets, + sample_sizes: &track.sample_sizes, + }) + .collect::>(); + payload_tracks.extend( + context + .other_tracks + .iter() + .map(|track| MovieTrackPayloadPlan { + track_id: track.track_id, + stsc: &track.stsc, + chunk_offsets: &track.chunk_offsets, + sample_sizes: &track.sample_sizes, + }), + ); + + let (clear_payload, clear_sample_sizes, track_chunk_offsets) = rebuild_movie_payload( + input, + &mdat_ranges, + &payload_tracks, + |track_id, _sample_index, _absolute_offset, _sample_size, sample_bytes| { + let Some(track) = protected_by_track.get(&track_id) else { + return Ok(sample_bytes.to_vec()); + }; + let Some(key) = track_keys.get(&track_id).copied() else { + return Ok(sample_bytes.to_vec()); + }; + decrypt_iaec_sample_entry_payload(&track.isfm, track.islt.as_ref(), key, sample_bytes) + }, + )?; + + let mut track_plans = Vec::new(); + for track in &context.tracks { + let stsd_replacement = if track_keys.contains_key(&track.track_id) { + Some(( + track.stsd_info.offset(), + rebuild_box_with_child_replacements( + input, + track.stsd_info, + &BTreeMap::from([( + track.sample_entry_info.offset(), + Some(build_clear_sample_entry_bytes( + input, + track.sample_entry_info, + track.original_format, + track.sinf_info, + )?), + )]), + None, + )?, + )) + } else { + None + }; + let stsz_replacement = if track_keys.contains_key(&track.track_id) { + Some(( + track.stsz_info.offset(), + build_patched_stsz_bytes( + &track.stsz, + clear_sample_sizes.get(&track.track_id).ok_or_else(|| { + invalid_layout(format!( + "missing rebuilt sample sizes for IAEC track {}", + track.track_id + )) + })?, + "IAEC", + )?, + )) + } else { + None + }; + track_plans.push(MovieTrackRewritePlan { + track_id: track.track_id, + trak_info: track.trak_info, + mdia_info: track.mdia_info, + minf_info: track.minf_info, + stbl_info: track.stbl_info, + chunk_offsets: track.chunk_offsets.clone(), + stsd_replacement, + stsz_replacement, + }); + } + track_plans.extend( + context + .other_tracks + .iter() + .map(|track| MovieTrackRewritePlan { + track_id: track.track_id, + trak_info: track.trak_info, + mdia_info: track.mdia_info, + minf_info: track.minf_info, + stbl_info: track.stbl_info, + chunk_offsets: track.chunk_offsets.clone(), + stsd_replacement: None, + stsz_replacement: None, + }), + ); + + rebuild_movie_file_with_track_plans( + MovieRootRewriteContext { + input, + ftyp_info: context.ftyp_info, + moov_info: context.moov_info, + mdat_infos: &context.mdat_infos, + }, + &track_plans, + &track_chunk_offsets, + &clear_payload, + None, + ) +} + +fn analyze_iaec_movie_file(input: &[u8]) -> Result { + let root_boxes = read_root_box_infos(input)?; + let ftyp_info = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == FTYP); + let Some(moov_info) = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == MOOV) + else { + return Err(invalid_layout( + "expected one root moov box in the protected movie file".to_owned(), + )); + }; + let mdat_infos = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MDAT) + .collect::>(); + if mdat_infos.is_empty() { + return Err(invalid_layout( + "expected at least one root mdat box in the protected movie file".to_owned(), + )); + } + + let mut reader = Cursor::new(input); + let traks = extract_box(&mut reader, None, BoxPath::from([MOOV, TRAK]))?; + let mut protected_tracks = Vec::new(); + let mut other_tracks = Vec::new(); + for trak_info in traks { + if let Some(track) = analyze_iaec_movie_track(input, &trak_info)? { + protected_tracks.push(track); + } else { + other_tracks.push(analyze_movie_chunk_track(input, &trak_info)?); + } + } + + if protected_tracks.is_empty() { + return Err(invalid_layout( + "expected at least one IAEC protected sample-entry track in the movie file".to_owned(), + )); + } + + Ok(IaecProtectedMovieContext { + ftyp_info, + moov_info, + tracks: protected_tracks, + other_tracks, + mdat_infos, + }) +} + +fn analyze_iaec_movie_track( + input: &[u8], + trak_info: &BoxInfo, +) -> Result, DecryptRewriteError> { + let track_layout = analyze_movie_chunk_track(input, trak_info)?; + let stsd = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Stsd>( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STSD]), + "stsd", + )? + }; + let stsd_info = { + let mut reader = Cursor::new(input); + extract_single_info( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STSD]), + "stsd", + )? + }; + + let mut reader = Cursor::new(input); + let encv_infos = extract_box( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCV]), + )?; + let mut reader = Cursor::new(input); + let enca_infos = extract_box( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCA]), + )?; + let (sample_entry_info, sample_entry_type) = + match (encv_infos.as_slice(), enca_infos.as_slice()) { + ([], []) => return Ok(None), + ([info], []) => (*info, ENCV), + ([], [info]) => (*info, ENCA), + _ => { + return Err(invalid_layout(format!( + "track {} has an unsupported protected sample-entry count", + track_layout.track_id + ))); + } + }; + + let protected_prefix = BoxPath::from([MDIA, MINF, STBL, STSD, sample_entry_type]); + let protected_sinf_prefix = child_path(&protected_prefix, SINF); + let original_format = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Frma>( + &mut reader, + Some(trak_info), + child_path(&protected_sinf_prefix, FRMA), + "frma", + )? + .data_format + }; + let sinf_info = { + let mut reader = Cursor::new(input); + extract_single_info( + &mut reader, + Some(trak_info), + protected_sinf_prefix.clone(), + "sinf", + )? + }; + let schm = { + let mut reader = Cursor::new(input); + extract_optional_single_as::<_, Schm>( + &mut reader, + Some(trak_info), + child_path(&protected_sinf_prefix, SCHM), + "schm", + )? + }; + let is_iaec = matches!(schm, Some(schm) if schm.scheme_type == IAEC); + if !is_iaec { + return Ok(None); + } + + ensure_standard_protected_movie_uses_first_sample_description( + track_layout.track_id, + "IAEC", + &stsd, + &track_layout.stsc, + )?; + + let schi_prefix = child_path(&protected_sinf_prefix, SCHI); + let isfm = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Isfm>( + &mut reader, + Some(trak_info), + child_path(&schi_prefix, FourCc::from_bytes(*b"iSFM")), + "iSFM", + )? + }; + if isfm.iv_length > 8 { + return Err(invalid_layout(format!( + "track {} uses unsupported IAEC IV length {}", + track_layout.track_id, isfm.iv_length + ))); + } + let islt = { + let mut reader = Cursor::new(input); + extract_optional_single_as::<_, Islt>( + &mut reader, + Some(trak_info), + child_path(&schi_prefix, FourCc::from_bytes(*b"iSLT")), + "iSLT", + )? + }; + + Ok(Some(IaecProtectedMovieTrackState { + track_id: track_layout.track_id, + trak_info: track_layout.trak_info, + mdia_info: track_layout.mdia_info, + minf_info: track_layout.minf_info, + stbl_info: track_layout.stbl_info, + stsd_info, + sample_entry_info, + original_format, + sinf_info, + stsz_info: { + let mut reader = Cursor::new(input); + extract_single_info( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STSZ]), + "stsz", + )? + }, + stsz: { + let mut reader = Cursor::new(input); + extract_single_as::<_, Stsz>( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STSZ]), + "stsz", + )? + }, + stsc: track_layout.stsc, + chunk_offsets: track_layout.chunk_offsets, + sample_sizes: track_layout.sample_sizes, + isfm, + islt, + })) +} + +fn ensure_standard_protected_movie_uses_first_sample_description( + track_id: u32, + family_label: &str, + stsd: &Stsd, + stsc: &Stsc, +) -> Result<(), DecryptRewriteError> { + if stsd.entry_count != 1 { + return Err(invalid_layout(format!( + "track {track_id} uses {family_label} protected-movie sample-description layouts beyond the first entry, but the current {family_label} protected-movie path only supports the first protected sample description" + ))); + } + + if let Some(entry) = stsc + .entries + .iter() + .find(|entry| entry.sample_description_index != 1) + { + return Err(invalid_layout(format!( + "track {track_id} uses {family_label} protected-movie chunk groups that reference sample description {}, but the current {family_label} protected-movie path only supports the first protected sample description", + entry.sample_description_index + ))); + } + + Ok(()) +} + +fn decrypt_iaec_sample_entry_payload( + isfm: &Isfm, + islt: Option<&Islt>, + key: [u8; 16], + sample_bytes: &[u8], +) -> Result, DecryptRewriteError> { + if sample_bytes.is_empty() { + return Err(invalid_layout( + "IAEC sample payload must not be empty".to_owned(), + )); + } + + let selective_header_len = if isfm.selective_encryption { 1 } else { 0 }; + let mut payload_start = 0usize; + let is_encrypted = if isfm.selective_encryption { + payload_start = 1; + (sample_bytes[0] & 0x80) != 0 + } else { + true + }; + + let header_size = selective_header_len + + if is_encrypted { + usize::from(isfm.iv_length) + usize::from(isfm.key_indicator_length) + } else { + 0 + }; + if header_size > sample_bytes.len() { + return Err(invalid_layout( + "IAEC sample payload is shorter than its declared header".to_owned(), + )); + } + + if !is_encrypted { + return Ok(sample_bytes[selective_header_len..].to_vec()); + } + + let iv_end = payload_start + usize::from(isfm.iv_length); + let iv_bytes = &sample_bytes[payload_start..iv_end]; + payload_start = iv_end; + + let mut indicator_cursor = payload_start; + let mut remaining_indicator_bytes = usize::from(isfm.key_indicator_length); + while remaining_indicator_bytes > 4 { + remaining_indicator_bytes -= 1; + indicator_cursor += 1; + } + let mut key_indicator = 0u32; + for byte in &sample_bytes[indicator_cursor..indicator_cursor + remaining_indicator_bytes] { + key_indicator = (key_indicator << 8) | u32::from(*byte); + } + if key_indicator != 0 { + return Err(invalid_layout(format!( + "IAEC key indicators other than 0 are not supported yet (resolved {key_indicator})" + ))); + } + + let payload = &sample_bytes[header_size..]; + let salt = islt.map(|entry| entry.salt).unwrap_or([0u8; 8]); + decrypt_iaec_payload(payload, key, salt, iv_bytes) +} + +fn decrypt_iaec_payload( + payload: &[u8], + key: [u8; 16], + salt: [u8; 8], + iv_bytes: &[u8], +) -> Result, DecryptRewriteError> { + if iv_bytes.len() > 8 { + return Err(invalid_layout( + "IAEC currently supports IV lengths up to 8 bytes".to_owned(), + )); + } + + let aes = Aes128::new(&key.into()); + let mut byte_stream_offset_bytes = [0u8; 8]; + byte_stream_offset_bytes[8 - iv_bytes.len()..].copy_from_slice(iv_bytes); + let mut byte_stream_offset = u64::from_be_bytes(byte_stream_offset_bytes); + + let mut output = vec![0u8; payload.len()]; + let mut cursor = 0usize; + if !payload.is_empty() && !byte_stream_offset.is_multiple_of(16) { + let offset = usize::try_from(byte_stream_offset % 16).unwrap(); + let counter_block = iaec_counter_block(salt, byte_stream_offset / 16); + let mut keystream_block = Block::::default(); + keystream_block.copy_from_slice(&counter_block); + aes.encrypt_block(&mut keystream_block); + let chunk_len = (16 - offset).min(payload.len()); + for index in 0..chunk_len { + output[index] = payload[index] ^ keystream_block[offset + index]; + } + cursor += chunk_len; + byte_stream_offset += chunk_len as u64; + } + + while cursor < payload.len() { + let mut counter_block = Block::::default(); + counter_block.copy_from_slice(&iaec_counter_block(salt, byte_stream_offset / 16)); + aes.encrypt_block(&mut counter_block); + let chunk_len = 16.min(payload.len() - cursor); + for index in 0..chunk_len { + output[cursor + index] = payload[cursor + index] ^ counter_block[index]; + } + cursor += chunk_len; + byte_stream_offset += chunk_len as u64; + } + + Ok(output) +} + +fn iaec_counter_block(salt: [u8; 8], block_offset: u64) -> [u8; 16] { + let mut counter = [0u8; 16]; + counter[..8].copy_from_slice(&salt); + counter[8..].copy_from_slice(&block_offset.to_be_bytes()); + counter +} + +fn analyze_init_segment(input: &[u8]) -> Result { + let mut reader = Cursor::new(input); + let moovs = extract_box(&mut reader, None, BoxPath::from([MOOV]))?; + if moovs.len() != 1 { + return Err(invalid_layout(format!( + "expected exactly one moov box but found {}", + moovs.len() + ))); + } + + let mut reader = Cursor::new(input); + let trexes = extract_box_as::<_, Trex>(&mut reader, None, BoxPath::from([MOOV, MVEX, TREX]))?; + let trex_by_track = trexes + .into_iter() + .map(|trex| (trex.track_id, trex)) + .collect::>(); + + let mut reader = Cursor::new(input); + let traks = extract_box(&mut reader, None, BoxPath::from([MOOV, TRAK]))?; + let mut tracks = Vec::new(); + for trak in traks { + if let Some(track) = analyze_protected_track(input, &trak, &trex_by_track)? { + tracks.push(track); + } + } + + Ok(InitDecryptContext { + moov_info: moovs[0], + tracks, + }) +} + +fn analyze_protected_track( + input: &[u8], + trak_info: &BoxInfo, + trex_by_track: &BTreeMap, +) -> Result, DecryptRewriteError> { + let mut reader = Cursor::new(input); + let tkhd = extract_single_as::<_, Tkhd>( + &mut reader, + Some(trak_info), + BoxPath::from([TKHD]), + "trak/tkhd", + )?; + + let mdia_info = { + let mut reader = Cursor::new(input); + extract_single_info(&mut reader, Some(trak_info), BoxPath::from([MDIA]), "mdia")? + }; + let minf_info = { + let mut reader = Cursor::new(input); + extract_single_info( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF]), + "minf", + )? + }; + let stbl_info = { + let mut reader = Cursor::new(input); + extract_single_info( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL]), + "stbl", + )? + }; + let stsd_info = { + let mut reader = Cursor::new(input); + extract_single_info( + &mut reader, + Some(trak_info), + BoxPath::from([MDIA, MINF, STBL, STSD]), + "stsd", + )? + }; + let protected_sample_entries = + analyze_protected_sample_entries(input, tkhd.track_id, stsd_info)?; + if protected_sample_entries.is_empty() { + return Ok(None); + } + + Ok(Some(ProtectedTrackState { + track_id: tkhd.track_id, + trak_info: *trak_info, + mdia_info, + minf_info, + stbl_info, + stsd_info, + protected_sample_entries, + trex: trex_by_track.get(&tkhd.track_id).cloned(), + })) +} + +fn analyze_protected_sample_entries( + input: &[u8], + track_id: u32, + stsd_info: BoxInfo, +) -> Result, DecryptRewriteError> { + let mut reader = Cursor::new(input); + let sample_entry_infos = + extract_box(&mut reader, Some(&stsd_info), BoxPath::from([FourCc::ANY]))?; + let mut protected_sample_entries = Vec::new(); + + for (index, sample_entry_info) in sample_entry_infos.iter().copied().enumerate() { + let sample_entry_type = sample_entry_info.box_type(); + if sample_entry_type != ENCV && sample_entry_type != ENCA { + continue; + } + + let sample_description_index = u32::try_from(index + 1).map_err(|_| { + invalid_layout(format!( + "track {track_id} sample-description index does not fit in u32" + )) + })?; + let original_format = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Frma>( + &mut reader, + Some(&sample_entry_info), + BoxPath::from([SINF, FRMA]), + "frma", + )? + .data_format + }; + let scheme_type = { + let mut reader = Cursor::new(input); + extract_single_as::<_, Schm>( + &mut reader, + Some(&sample_entry_info), + BoxPath::from([SINF, SCHM]), + "schm", + )? + .scheme_type + }; + let sinf_info = { + let mut reader = Cursor::new(input); + extract_single_info( + &mut reader, + Some(&sample_entry_info), + BoxPath::from([SINF]), + "sinf", + )? + }; + let (tenc, piff_protection_mode) = extract_track_encryption_box(input, &sample_entry_info)?; + + protected_sample_entries.push(ProtectedSampleEntryState { + sample_description_index, + sample_entry_info, + original_format, + scheme_type, + sinf_info, + tenc, + piff_protection_mode, + }); + } + + if protected_sample_entries.len() > 1 { + let incompatible_types = protected_sample_entries.iter().any(|entry| { + entry.sample_entry_info.box_type() + != protected_sample_entries[0].sample_entry_info.box_type() + }); + if incompatible_types { + return Err(invalid_layout(format!( + "track {track_id} mixes incompatible protected sample-entry types under one stsd" + ))); + } + } + + Ok(protected_sample_entries) +} + +fn extract_track_encryption_box( + input: &[u8], + sample_entry_info: &BoxInfo, +) -> Result<(Tenc, Option), DecryptRewriteError> { + let mut reader = Cursor::new(input); + if let Some(tenc) = extract_optional_single_as::<_, Tenc>( + &mut reader, + Some(sample_entry_info), + BoxPath::from([SINF, SCHI, TENC]), + "tenc", + )? { + return Ok((tenc, None)); + } + + let mut reader = Cursor::new(input); + let uuid_boxes = extract_box_as::<_, Uuid>( + &mut reader, + Some(sample_entry_info), + BoxPath::from([SINF, SCHI, UUID]), + )?; + let mut matches = uuid_boxes + .into_iter() + .filter(|uuid| uuid.user_type == PIFF_TRACK_ENCRYPTION_USER_TYPE); + + let Some(uuid_box) = matches.next() else { + return Err(invalid_layout( + "expected one track encryption box under the protected sample entry".to_owned(), + )); + }; + if matches.next().is_some() { + return Err(invalid_layout( + "expected at most one PIFF UUID track encryption box under the protected sample entry" + .to_owned(), + )); + } + + decode_piff_track_encryption(uuid_box) +} + +fn decode_piff_track_encryption(uuid: Uuid) -> Result<(Tenc, Option), DecryptRewriteError> { + let UuidPayload::Raw(payload) = uuid.payload else { + return Err(invalid_layout( + "expected raw PIFF UUID track-encryption payload bytes".to_owned(), + )); + }; + if payload.len() < 24 { + return Err(invalid_layout( + "PIFF UUID track-encryption payload is too short".to_owned(), + )); + } + + let version = payload[0]; + if version != 0 { + return Err(invalid_layout(format!( + "PIFF UUID track-encryption payload version {version} is not supported" + ))); + } + let flags = u32::from_be_bytes([0, payload[1], payload[2], payload[3]]); + let reserved = payload[4]; + let second_reserved = payload[5]; + if second_reserved != 0 { + return Err(invalid_layout( + "PIFF UUID track-encryption payload reserved byte must be zero".to_owned(), + )); + } + + let default_is_protected = payload[6]; + let default_per_sample_iv_size = payload[7]; + let default_kid = payload[8..24].try_into().unwrap(); + + let mut tenc = Tenc::default(); + tenc.set_version(version); + tenc.set_flags(flags); + tenc.reserved = reserved; + tenc.default_is_protected = if default_is_protected == 0 { 0 } else { 1 }; + tenc.default_per_sample_iv_size = default_per_sample_iv_size; + tenc.default_kid = default_kid; + + let mut cursor = 24usize; + if default_per_sample_iv_size == 0 { + let Some(&constant_iv_size) = payload.get(cursor) else { + return Err(invalid_layout( + "PIFF UUID track-encryption payload is missing its constant IV size".to_owned(), + )); + }; + cursor += 1; + let end = cursor + usize::from(constant_iv_size); + if end > payload.len() { + return Err(invalid_layout( + "PIFF UUID track-encryption payload constant IV is truncated".to_owned(), + )); + } + tenc.default_constant_iv_size = constant_iv_size; + tenc.default_constant_iv = payload[cursor..end].to_vec(); + cursor = end; + } + + if cursor != payload.len() { + return Err(invalid_layout( + "PIFF UUID track-encryption payload has unexpected trailing bytes".to_owned(), + )); + } + + Ok((tenc, Some(default_is_protected))) +} + +#[derive(Clone)] +struct DirectChildEdit { + child_info: BoxInfo, + replacement: Option>, +} + +fn relative_box_range( + parent: BoxInfo, + child: BoxInfo, +) -> Result<(usize, usize), DecryptRewriteError> { + let start = child + .offset() + .checked_sub(parent.offset()) + .ok_or_else(|| invalid_layout("child box starts before its parent".to_owned()))?; + let end = start + .checked_add(child.size()) + .ok_or_else(|| invalid_layout("child box end overflowed u64".to_owned()))?; + let start = usize::try_from(start) + .map_err(|_| invalid_layout("relative child offset does not fit in usize".to_owned()))?; + let end = usize::try_from(end) + .map_err(|_| invalid_layout("relative child end does not fit in usize".to_owned()))?; + Ok((start, end)) +} + +fn rebuild_box_with_child_edits( + input: &[u8], + parent: BoxInfo, + edits: &[DirectChildEdit], +) -> Result, DecryptRewriteError> { + if edits.is_empty() { + return Ok(slice_box_bytes(input, parent)?.to_vec()); + } + + let parent_bytes = slice_box_bytes(input, parent)?; + let header_size = usize::try_from(parent.header_size()) + .map_err(|_| invalid_layout("box header size does not fit in usize".to_owned()))?; + if header_size > parent_bytes.len() { + return Err(invalid_layout(format!( + "{} header size exceeds the available parent bytes", + parent.box_type() + ))); + } + + let mut sorted_edits = edits.to_vec(); + sorted_edits.sort_by_key(|edit| edit.child_info.offset()); + + let mut payload = Vec::new(); + let mut cursor = header_size; + for edit in &sorted_edits { + let (start, end) = relative_box_range(parent, edit.child_info)?; + if start < cursor || end > parent_bytes.len() { + return Err(invalid_layout(format!( + "child edit for {} is not aligned within {}", + edit.child_info.box_type(), + parent.box_type() + ))); + } + payload.extend_from_slice(&parent_bytes[cursor..start]); + if let Some(replacement) = &edit.replacement { + payload.extend_from_slice(replacement); + } + cursor = end; + } + payload.extend_from_slice(&parent_bytes[cursor..]); + + let mut rebuilt = BoxInfo::new( + parent.box_type(), + parent + .header_size() + .checked_add(u64::try_from(payload.len()).map_err(|_| { + invalid_layout("rebuilt box payload length does not fit in u64".to_owned()) + })?) + .ok_or_else(|| invalid_layout("rebuilt box size overflowed u64".to_owned()))?, + ) + .with_header_size(parent.header_size()) + .encode(); + rebuilt.extend_from_slice(&payload); + Ok(rebuilt) +} + +fn patch_box_type_bytes(bytes: &mut [u8], box_type: FourCc) -> Result<(), DecryptRewriteError> { + if bytes.len() < 8 { + return Err(invalid_layout( + "box bytes are shorter than the standard box header".to_owned(), + )); + } + bytes[4..8].copy_from_slice(box_type.as_bytes()); + Ok(()) +} + +fn build_common_encryption_track_replacement( + input: &[u8], + track: &ProtectedTrackState, + keys: &[DecryptionKey], +) -> Result>, DecryptRewriteError> { + let mut sample_entry_replacements = BTreeMap::new(); + for sample_entry in &track.protected_sample_entries { + if resolve_key_for_sample_entry(track, sample_entry, keys)?.is_none() + || sample_entry.scheme_type == PIFF + { + continue; + } + sample_entry_replacements.insert( + sample_entry.sample_entry_info.offset(), + Some(build_clear_sample_entry_bytes( + input, + sample_entry.sample_entry_info, + sample_entry.original_format, + sample_entry.sinf_info, + )?), + ); + } + if sample_entry_replacements.is_empty() { + return Ok(None); + } + + let stsd_bytes = rebuild_box_with_child_replacements( + input, + track.stsd_info, + &sample_entry_replacements, + None, + )?; + let stbl_bytes = rebuild_box_with_child_edits( + input, + track.stbl_info, + &[DirectChildEdit { + child_info: track.stsd_info, + replacement: Some(stsd_bytes), + }], + )?; + let minf_bytes = rebuild_box_with_child_edits( + input, + track.minf_info, + &[DirectChildEdit { + child_info: track.stbl_info, + replacement: Some(stbl_bytes), + }], + )?; + let mdia_bytes = rebuild_box_with_child_edits( + input, + track.mdia_info, + &[DirectChildEdit { + child_info: track.minf_info, + replacement: Some(minf_bytes), + }], + )?; + rebuild_box_with_child_edits( + input, + track.trak_info, + &[DirectChildEdit { + child_info: track.mdia_info, + replacement: Some(mdia_bytes), + }], + ) + .map(Some) +} + +fn rebuild_common_encryption_moov( + input: &[u8], + context: &InitDecryptContext, + keys: &[DecryptionKey], +) -> Result, DecryptRewriteError> { + let mut track_edits = Vec::new(); + for track in &context.tracks { + if let Some(replacement) = build_common_encryption_track_replacement(input, track, keys)? { + track_edits.push(DirectChildEdit { + child_info: track.trak_info, + replacement: Some(replacement), + }); + } + } + rebuild_box_with_child_edits(input, context.moov_info, &track_edits) +} + +#[derive(Clone)] +struct TrafRewritePlan { + moof_info: BoxInfo, + traf_info: BoxInfo, + tfhd_flags: u32, + trun_infos: Vec, + truns: Vec, + remove_infos: Vec, +} + +fn decrypt_media_bytes_with_context( + media_segment: &[u8], + context: &InitDecryptContext, + keys: &[DecryptionKey], +) -> Result, DecryptRewriteError> { + let mut decrypted = media_segment.to_vec(); + if let Some(moof_replacements) = + build_common_encryption_fragment_replacements(media_segment, &mut decrypted, context, keys)? + { + return rebuild_common_encryption_root_bytes( + media_segment, + &decrypted, + None, + &moof_replacements, + &BTreeMap::new(), + ); + } + + decrypt_media_bytes_with_context_legacy(media_segment, context, keys) +} + +fn decrypt_media_bytes_with_context_legacy( + media_segment: &[u8], + context: &InitDecryptContext, + keys: &[DecryptionKey], +) -> Result, DecryptRewriteError> { + let mut output = media_segment.to_vec(); + decrypt_media_bytes_in_place_legacy(media_segment, &mut output, context, keys)?; + Ok(output) +} + +fn try_rebuild_common_encryption_file_bytes( + input: &[u8], + context: &InitDecryptContext, + keys: &[DecryptionKey], +) -> Result>, DecryptRewriteError> { + let mut decrypted = input.to_vec(); + let Some(moof_replacements) = + build_common_encryption_fragment_replacements(input, &mut decrypted, context, keys)? + else { + return Ok(None); + }; + + let rebuilt_moov = rebuild_common_encryption_moov(input, context, keys)?; + let mfra_replacements = build_common_encryption_mfra_replacements( + input, + &decrypted, + Some((context.moov_info.offset(), rebuilt_moov.as_slice())), + &moof_replacements, + )?; + Ok(Some(rebuild_common_encryption_root_bytes( + input, + &decrypted, + Some((context.moov_info.offset(), rebuilt_moov)), + &moof_replacements, + &mfra_replacements, + )?)) +} + +fn rebuild_common_encryption_root_bytes( + input: &[u8], + decrypted: &[u8], + moov_replacement: Option<(u64, Vec)>, + moof_replacements: &BTreeMap>, + extra_root_replacements: &BTreeMap>, +) -> Result, DecryptRewriteError> { + let root_boxes = read_root_box_infos(input)?; + let mut output = Vec::with_capacity(decrypted.len()); + for info in root_boxes { + if let Some((moov_offset, replacement)) = &moov_replacement + && info.offset() == *moov_offset + { + output.extend_from_slice(replacement); + continue; + } + if let Some(replacement) = extra_root_replacements.get(&info.offset()) { + output.extend_from_slice(replacement); + continue; + } + if let Some(replacement) = moof_replacements.get(&info.offset()) { + output.extend_from_slice(replacement); + continue; + } + if info.box_type() == MDAT { + output.extend_from_slice(slice_box_bytes(decrypted, info)?); + continue; + } + output.extend_from_slice(slice_box_bytes(input, info)?); + } + Ok(output) +} + +fn refresh_fragmented_top_level_sidx(bytes: Vec) -> Result, DecryptRewriteError> { + let Some(mut plan) = + plan_top_level_sidx_update_bytes(&bytes, TopLevelSidxPlanOptions::default()).map_err( + |error| { + invalid_layout(format!( + "failed to refresh top-level sidx after decrypt rewrite: {error}" + )) + }, + )? + else { + return Ok(bytes); + }; + preserve_existing_top_level_sidx_version(&bytes, &mut plan)?; + + apply_top_level_sidx_plan_bytes(&bytes, &plan).map_err(|error| { + invalid_layout(format!( + "failed to apply refreshed top-level sidx after decrypt rewrite: {error}" + )) + }) +} + +fn preserve_existing_top_level_sidx_version( + bytes: &[u8], + plan: &mut TopLevelSidxPlan, +) -> Result<(), DecryptRewriteError> { + let existing = match &plan.action { + TopLevelSidxPlanAction::Replace { existing } => existing, + TopLevelSidxPlanAction::Insert => return Ok(()), + }; + let existing_sidx = decode_existing_top_level_sidx(bytes, existing.info)?; + if existing_sidx.version() != 0 { + return Ok(()); + } + + let earliest_presentation_time = plan.sidx.earliest_presentation_time(); + if earliest_presentation_time > u64::from(u32::MAX) { + return Ok(()); + } + + plan.sidx.set_version(0); + plan.sidx.set_flags(existing_sidx.flags()); + plan.sidx.earliest_presentation_time_v0 = + u32::try_from(earliest_presentation_time).map_err(|_| { + invalid_layout( + "top-level sidx earliest presentation time does not fit version 0".to_owned(), + ) + })?; + plan.sidx.first_offset_v0 = 0; + Ok(()) +} + +fn decode_existing_top_level_sidx( + bytes: &[u8], + info: BoxInfo, +) -> Result { + let box_bytes = slice_box_bytes(bytes, info)?; + let header_size = usize::try_from(info.header_size()).map_err(|_| { + invalid_layout("existing top-level sidx header size does not fit usize".to_owned()) + })?; + let payload = box_bytes.get(header_size..).ok_or_else(|| { + invalid_layout( + "existing top-level sidx payload does not fit within the input bytes".to_owned(), + ) + })?; + let mut decoded = Sidx::default(); + unmarshal( + &mut Cursor::new(payload), + info.payload_size().map_err(|error| { + invalid_layout(format!( + "failed to read existing top-level sidx payload size before refresh: {error}" + )) + })?, + &mut decoded, + None, + ) + .map_err(|error| { + invalid_layout(format!( + "failed to decode existing top-level sidx before refresh: {error}" + )) + })?; + Ok(decoded) +} + +fn build_common_encryption_fragment_replacements( + input: &[u8], + decrypted: &mut [u8], + context: &InitDecryptContext, + keys: &[DecryptionKey], +) -> Result>>, DecryptRewriteError> { + let track_by_id = context + .tracks + .iter() + .map(|track| (track.track_id, track)) + .collect::>(); + + let root_boxes = read_root_box_infos(input)?; + let mdat_ranges = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MDAT) + .map(|info| MediaDataRange { + start: info.offset() + info.header_size(), + end: info.offset() + info.size(), + }) + .collect::>(); + let moofs = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MOOF) + .collect::>(); + + let mut reader = Cursor::new(input); + let trafs = extract_box(&mut reader, None, BoxPath::from([MOOF, TRAF]))?; + let mut plans = Vec::new(); + for traf_info in trafs { + let Some(moof_info) = moofs + .iter() + .copied() + .find(|moof| contains_box(*moof, traf_info)) + else { + return Err(invalid_layout(format!( + "traf at offset {} is not contained by any moof", + traf_info.offset() + ))); + }; + + let mut reader = Cursor::new(input); + let tfhd = extract_single_as::<_, Tfhd>( + &mut reader, + Some(&traf_info), + BoxPath::from([TFHD]), + "tfhd", + )?; + + let mut reader = Cursor::new(input); + let truns = + extract_box_as::<_, Trun>(&mut reader, Some(&traf_info), BoxPath::from([TRUN]))?; + let mut reader = Cursor::new(input); + let trun_infos = extract_box(&mut reader, Some(&traf_info), BoxPath::from([TRUN]))?; + if truns.is_empty() || truns.len() != trun_infos.len() { + return Err(invalid_layout(format!( + "track {} requires one or more aligned trun boxes", + tfhd.track_id + ))); + } + + let mut remove_infos = Vec::new(); + if let Some(track) = track_by_id.get(&tfhd.track_id).copied() { + let sample_description_index = resolve_fragment_sample_description_index(track, &tfhd)?; + if let Some(active) = + activate_track_sample_entry(track, sample_description_index, keys)? + { + let (senc, senc_info) = extract_fragment_sample_encryption_box( + input, + &traf_info, + &active.sample_entry.tenc, + )?; + + let mut reader = Cursor::new(input); + let saiz = extract_optional_single_as::<_, Saiz>( + &mut reader, + Some(&traf_info), + BoxPath::from([SAIZ]), + "saiz", + )?; + let mut reader = Cursor::new(input); + let saio = extract_optional_single_as::<_, Saio>( + &mut reader, + Some(&traf_info), + BoxPath::from([SAIO]), + "saio", + )?; + let mut reader = Cursor::new(input); + let sgpd_entries = extract_box_as::<_, Sgpd>( + &mut reader, + Some(&traf_info), + BoxPath::from([SGPD]), + )?; + let mut reader = Cursor::new(input); + let sgpd_infos = extract_box(&mut reader, Some(&traf_info), BoxPath::from([SGPD]))?; + let mut reader = Cursor::new(input); + let sbgp_entries = extract_box_as::<_, Sbgp>( + &mut reader, + Some(&traf_info), + BoxPath::from([SBGP]), + )?; + let mut reader = Cursor::new(input); + let sbgp_infos = extract_box(&mut reader, Some(&traf_info), BoxPath::from([SBGP]))?; + + let sgpd = select_seig_sgpd(&sgpd_entries); + let sbgp = select_seig_sbgp(&sbgp_entries); + let resolved = resolve_sample_encryption( + &senc, + SampleEncryptionContext { + tenc: Some(&active.sample_entry.tenc), + sgpd, + sbgp, + saiz: saiz.as_ref(), + }, + )?; + + let sample_spans = compute_sample_spans( + &tfhd, + active.track.trex.as_ref(), + moof_info.offset(), + &truns, + &trun_infos, + )?; + if sample_spans.len() != resolved.samples.len() { + return Err(invalid_layout(format!( + "track {} resolved {} encrypted sample records but {} sample span(s)", + active.track.track_id, + resolved.samples.len(), + sample_spans.len() + ))); + } + + for (sample, span) in resolved.samples.iter().zip(sample_spans.iter()) { + let encrypted = read_sample_range(input, &mdat_ranges, span.offset, span.size) + .ok_or(DecryptRewriteError::SampleDataRangeNotFound { + track_id: active.track.track_id, + sample_index: sample.sample_index, + absolute_offset: span.offset, + sample_size: span.size, + })?; + let clear = decrypt_sample_for_active_track(&active, sample, encrypted)?; + write_sample_range(decrypted, &mdat_ranges, span.offset, &clear).ok_or( + DecryptRewriteError::SampleDataRangeNotFound { + track_id: active.track.track_id, + sample_index: sample.sample_index, + absolute_offset: span.offset, + sample_size: span.size, + }, + )?; + } + + if active.sample_entry.scheme_type == PIFF { + plans.push(TrafRewritePlan { + moof_info, + traf_info, + tfhd_flags: tfhd.flags(), + trun_infos, + truns, + remove_infos, + }); + continue; + } + + remove_infos.push(senc_info); + if let Some(saiz_info) = + extract_optional_single_info_from_infos(&traf_info, SAIZ, input)? + { + remove_infos.push(saiz_info); + } + if let Some(saio_info) = + extract_optional_single_info_from_infos(&traf_info, SAIO, input)? + && saio.as_ref().is_none_or(|saio| { + saio.aux_info_type == FourCc::ANY + || saio.aux_info_type == active.sample_entry.scheme_type + }) + { + remove_infos.push(saio_info); + } + for (entry, info) in sbgp_entries.iter().zip(sbgp_infos.iter().copied()) { + if entry.grouping_type == u32::from_be_bytes(*b"seig") { + remove_infos.push(info); + } + } + for (entry, info) in sgpd_entries.iter().zip(sgpd_infos.iter().copied()) { + if entry.grouping_type == SEIG { + remove_infos.push(info); + } + } + } + } + + plans.push(TrafRewritePlan { + moof_info, + traf_info, + tfhd_flags: tfhd.flags(), + trun_infos, + truns, + remove_infos, + }); + } + + let mut moof_replacements = BTreeMap::new(); + for moof_info in &moofs { + let moof_plans = plans + .iter() + .filter(|plan| plan.moof_info.offset() == moof_info.offset()) + .collect::>(); + if moof_plans.is_empty() { + continue; + } + + let removed_in_moof = moof_plans + .iter() + .flat_map(|plan| plan.remove_infos.iter()) + .try_fold(0_u64, |acc, info| { + acc.checked_add(info.size()).ok_or_else(|| { + invalid_layout("removed fragment metadata size overflowed u64".to_owned()) + }) + })?; + + if removed_in_moof != 0 + && moof_plans.iter().any(|plan| { + plan.tfhd_flags & TFHD_BASE_DATA_OFFSET_PRESENT != 0 + || plan + .truns + .iter() + .any(|trun| trun.flags() & TRUN_DATA_OFFSET_PRESENT == 0) + }) + { + return Ok(None); + } + + let mut traf_edits = Vec::new(); + for plan in moof_plans { + let mut child_edits = Vec::new(); + for (trun_info, trun) in plan.trun_infos.iter().copied().zip(plan.truns.iter()) { + let mut patched_trun = trun.clone(); + if removed_in_moof != 0 { + let removed = i64::try_from(removed_in_moof).map_err(|_| { + invalid_layout( + "removed fragment metadata size does not fit in i64".to_owned(), + ) + })?; + let patched = i64::from(trun.data_offset) + .checked_sub(removed) + .ok_or_else(|| { + invalid_layout("patched trun data offset overflowed i64".to_owned()) + })?; + patched_trun.data_offset = i32::try_from(patched).map_err(|_| { + invalid_layout(format!( + "patched trun data offset for traf at {} does not fit in i32", + plan.traf_info.offset() + )) + })?; + } + child_edits.push(DirectChildEdit { + child_info: trun_info, + replacement: Some(encode_box_with_children(&patched_trun, &[])?), + }); + } + child_edits.extend( + plan.remove_infos + .iter() + .copied() + .map(|info| DirectChildEdit { + child_info: info, + replacement: None, + }), + ); + + let rebuilt_traf = rebuild_box_with_child_edits(input, plan.traf_info, &child_edits)?; + if rebuilt_traf != slice_box_bytes(input, plan.traf_info)? { + traf_edits.push(DirectChildEdit { + child_info: plan.traf_info, + replacement: Some(rebuilt_traf), + }); + } + } + + if !traf_edits.is_empty() { + moof_replacements.insert( + moof_info.offset(), + rebuild_box_with_child_edits(input, *moof_info, &traf_edits)?, + ); + } + } + + Ok(Some(moof_replacements)) +} + +fn build_common_encryption_mfra_replacements( + input: &[u8], + decrypted: &[u8], + moov_replacement: Option<(u64, &[u8])>, + moof_replacements: &BTreeMap>, +) -> Result>, DecryptRewriteError> { + let root_boxes = read_root_box_infos(input)?; + let mfra_infos = root_boxes + .iter() + .copied() + .filter(|info| info.box_type() == MFRA) + .collect::>(); + if mfra_infos.is_empty() { + return Ok(BTreeMap::new()); + } + + let rewritten_offsets = compute_rewritten_root_offsets( + input, + decrypted, + &root_boxes, + moov_replacement, + moof_replacements, + &BTreeMap::new(), + )?; + + let mut replacements = BTreeMap::new(); + for mfra_info in mfra_infos { + let mut reader = Cursor::new(input); + let tfra_boxes = + extract_box_as::<_, Tfra>(&mut reader, Some(&mfra_info), BoxPath::from([TFRA]))?; + let mut reader = Cursor::new(input); + let tfra_infos = extract_box(&mut reader, Some(&mfra_info), BoxPath::from([TFRA]))?; + if tfra_boxes.len() != tfra_infos.len() { + return Err(invalid_layout( + "expected aligned tfra boxes inside mfra for Common Encryption rewrite".to_owned(), + )); + } + + let mut child_edits = Vec::new(); + for (tfra_info, tfra_box) in tfra_infos.iter().copied().zip(tfra_boxes) { + let mut patched_tfra = tfra_box.clone(); + let version = patched_tfra.version(); + let mut changed = false; + for entry in &mut patched_tfra.entries { + let original_moof_offset = if version == 0 { + u64::from(entry.moof_offset_v0) + } else { + entry.moof_offset_v1 + }; + let Some(&rewritten_moof_offset) = rewritten_offsets.get(&original_moof_offset) + else { + continue; + }; + + if version == 0 { + let rewritten_moof_offset = + u32::try_from(rewritten_moof_offset).map_err(|_| { + invalid_layout( + "rewritten tfra moof offset does not fit in u32".to_owned(), + ) + })?; + if entry.moof_offset_v0 != rewritten_moof_offset { + entry.moof_offset_v0 = rewritten_moof_offset; + changed = true; + } + } else if entry.moof_offset_v1 != rewritten_moof_offset { + entry.moof_offset_v1 = rewritten_moof_offset; + changed = true; + } + } + if changed { + child_edits.push(DirectChildEdit { + child_info: tfra_info, + replacement: Some(encode_box_with_children(&patched_tfra, &[])?), + }); + } + } + + let mut rebuilt_mfra = rebuild_box_with_child_edits(input, mfra_info, &child_edits)?; + if let Some(mfro_info) = extract_optional_single_info_from_infos(&mfra_info, MFRO, input)? { + let mut reader = Cursor::new(input); + let Some(mut mfro) = extract_optional_single_as::<_, Mfro>( + &mut reader, + Some(&mfra_info), + BoxPath::from([MFRO]), + "mfro", + )? + else { + return Err(invalid_layout( + "expected mfro to decode when its box info is present".to_owned(), + )); + }; + mfro.size = u32::try_from(rebuilt_mfra.len()).map_err(|_| { + invalid_layout("rewritten mfra size does not fit in u32".to_owned()) + })?; + let mfro_replacement = encode_box_with_children(&mfro, &[])?; + rebuilt_mfra = rebuild_box_with_child_edits( + input, + mfra_info, + &[ + child_edits, + vec![DirectChildEdit { + child_info: mfro_info, + replacement: Some(mfro_replacement), + }], + ] + .concat(), + )?; + } + + if rebuilt_mfra != slice_box_bytes(input, mfra_info)? { + replacements.insert(mfra_info.offset(), rebuilt_mfra); + } + } + + Ok(replacements) +} + +fn compute_rewritten_root_offsets( + input: &[u8], + decrypted: &[u8], + root_boxes: &[BoxInfo], + moov_replacement: Option<(u64, &[u8])>, + moof_replacements: &BTreeMap>, + extra_root_replacements: &BTreeMap>, +) -> Result, DecryptRewriteError> { + let mut next_offset = 0_u64; + let mut offsets = BTreeMap::new(); + for info in root_boxes { + offsets.insert(info.offset(), next_offset); + next_offset = next_offset + .checked_add(rewritten_root_box_size( + input, + decrypted, + *info, + moov_replacement, + moof_replacements, + extra_root_replacements, + )?) + .ok_or_else(|| invalid_layout("rewritten root offset overflowed u64".to_owned()))?; + } + Ok(offsets) +} + +fn rewritten_root_box_size( + input: &[u8], + decrypted: &[u8], + info: BoxInfo, + moov_replacement: Option<(u64, &[u8])>, + moof_replacements: &BTreeMap>, + extra_root_replacements: &BTreeMap>, +) -> Result { + if let Some((moov_offset, replacement)) = moov_replacement + && info.offset() == moov_offset + { + return u64::try_from(replacement.len()) + .map_err(|_| invalid_layout("rebuilt moov size does not fit in u64".to_owned())); + } + if let Some(replacement) = extra_root_replacements.get(&info.offset()) { + return u64::try_from(replacement.len()).map_err(|_| { + invalid_layout("rewritten root replacement size does not fit in u64".to_owned()) + }); + } + if let Some(replacement) = moof_replacements.get(&info.offset()) { + return u64::try_from(replacement.len()) + .map_err(|_| invalid_layout("rebuilt moof size does not fit in u64".to_owned())); + } + if info.box_type() == MDAT { + return u64::try_from(slice_box_bytes(decrypted, info)?.len()) + .map_err(|_| invalid_layout("rewritten mdat size does not fit in u64".to_owned())); + } + u64::try_from(slice_box_bytes(input, info)?.len()) + .map_err(|_| invalid_layout("root box size does not fit in u64".to_owned())) +} + +fn decrypt_media_bytes_in_place_legacy( + input: &[u8], + output: &mut [u8], + context: &InitDecryptContext, + keys: &[DecryptionKey], +) -> Result<(), DecryptRewriteError> { + let track_by_id = context + .tracks + .iter() + .map(|track| (track.track_id, track)) + .collect::>(); + + let mut reader = Cursor::new(input); + let mdat_infos = extract_box(&mut reader, None, BoxPath::from([MDAT]))?; + let mdat_ranges = mdat_infos + .into_iter() + .map(|info| MediaDataRange { + start: info.offset() + info.header_size(), + end: info.offset() + info.size(), + }) + .collect::>(); + + let mut reader = Cursor::new(input); + let moofs = extract_box(&mut reader, None, BoxPath::from([MOOF]))?; + let mut reader = Cursor::new(input); + let trafs = extract_box(&mut reader, None, BoxPath::from([MOOF, TRAF]))?; + for traf_info in trafs { + let Some(moof_info) = moofs + .iter() + .copied() + .find(|moof| contains_box(*moof, traf_info)) + else { + return Err(invalid_layout(format!( + "traf at offset {} is not contained by any moof", + traf_info.offset() + ))); + }; + + let mut reader = Cursor::new(input); + let tfhd = extract_single_as::<_, Tfhd>( + &mut reader, + Some(&traf_info), + BoxPath::from([TFHD]), + "tfhd", + )?; + let Some(track) = track_by_id.get(&tfhd.track_id).copied() else { + continue; + }; + let sample_description_index = resolve_fragment_sample_description_index(track, &tfhd)?; + let Some(active) = activate_track_sample_entry(track, sample_description_index, keys)? + else { + continue; + }; + + let mut reader = Cursor::new(input); + let truns = + extract_box_as::<_, Trun>(&mut reader, Some(&traf_info), BoxPath::from([TRUN]))?; + let mut reader = Cursor::new(input); + let trun_infos = extract_box(&mut reader, Some(&traf_info), BoxPath::from([TRUN]))?; + if truns.is_empty() || truns.len() != trun_infos.len() { + return Err(invalid_layout(format!( + "track {} requires one or more aligned trun boxes", + active.track.track_id + ))); + } + + let (senc, senc_info) = + extract_fragment_sample_encryption_box(input, &traf_info, &active.sample_entry.tenc)?; + + let mut reader = Cursor::new(input); + let saiz = extract_optional_single_as::<_, Saiz>( + &mut reader, + Some(&traf_info), + BoxPath::from([SAIZ]), + "saiz", + )?; + let mut reader = Cursor::new(input); + let saio = extract_optional_single_as::<_, Saio>( + &mut reader, + Some(&traf_info), + BoxPath::from([SAIO]), + "saio", + )?; + let mut reader = Cursor::new(input); + let sgpd_entries = + extract_box_as::<_, Sgpd>(&mut reader, Some(&traf_info), BoxPath::from([SGPD]))?; + let mut reader = Cursor::new(input); + let sgpd_infos = extract_box(&mut reader, Some(&traf_info), BoxPath::from([SGPD]))?; + let mut reader = Cursor::new(input); + let sbgp_entries = + extract_box_as::<_, Sbgp>(&mut reader, Some(&traf_info), BoxPath::from([SBGP]))?; + let mut reader = Cursor::new(input); + let sbgp_infos = extract_box(&mut reader, Some(&traf_info), BoxPath::from([SBGP]))?; + + let sgpd = select_seig_sgpd(&sgpd_entries); + let sbgp = select_seig_sbgp(&sbgp_entries); + let resolved = resolve_sample_encryption( + &senc, + SampleEncryptionContext { + tenc: Some(&active.sample_entry.tenc), + sgpd, + sbgp, + saiz: saiz.as_ref(), + }, + )?; + + let sample_spans = compute_sample_spans( + &tfhd, + active.track.trex.as_ref(), + moof_info.offset(), + &truns, + &trun_infos, + )?; + if sample_spans.len() != resolved.samples.len() { + return Err(invalid_layout(format!( + "track {} resolved {} encrypted sample records but {} sample span(s)", + active.track.track_id, + resolved.samples.len(), + sample_spans.len() + ))); + } + + for (sample, span) in resolved.samples.iter().zip(sample_spans.iter()) { + let encrypted = read_sample_range(input, &mdat_ranges, span.offset, span.size).ok_or( + DecryptRewriteError::SampleDataRangeNotFound { + track_id: active.track.track_id, + sample_index: sample.sample_index, + absolute_offset: span.offset, + sample_size: span.size, + }, + )?; + let decrypted = decrypt_sample_for_active_track(&active, sample, encrypted)?; + write_sample_range(output, &mdat_ranges, span.offset, &decrypted).ok_or( + DecryptRewriteError::SampleDataRangeNotFound { + track_id: active.track.track_id, + sample_index: sample.sample_index, + absolute_offset: span.offset, + sample_size: span.size, + }, + )?; + } + + if active.sample_entry.scheme_type == PIFF { + continue; + } + + replace_box_with_free(output, senc_info)?; + if let Some(saiz_info) = extract_optional_single_info_from_infos(&traf_info, SAIZ, input)? { + replace_box_with_free(output, saiz_info)?; + } + if let Some(saio_info) = extract_optional_single_info_from_infos(&traf_info, SAIO, input)? + && saio.as_ref().is_none_or(|saio| { + saio.aux_info_type == FourCc::ANY + || saio.aux_info_type == active.sample_entry.scheme_type + }) + { + replace_box_with_free(output, saio_info)?; + } + for (entry, info) in sbgp_entries.iter().zip(sbgp_infos.iter().copied()) { + if entry.grouping_type == u32::from_be_bytes(*b"seig") { + replace_box_with_free(output, info)?; + } + } + for (entry, info) in sgpd_entries.iter().zip(sgpd_infos.iter().copied()) { + if entry.grouping_type == SEIG { + replace_box_with_free(output, info)?; + } + } + } + + Ok(()) +} + +#[derive(Clone, Copy)] +struct SampleSpan { + offset: u64, + size: u32, +} + +fn compute_sample_spans( + tfhd: &Tfhd, + trex: Option<&Trex>, + moof_offset: u64, + truns: &[Trun], + trun_infos: &[BoxInfo], +) -> Result, DecryptRewriteError> { + let base_data_offset = if tfhd.flags() & TFHD_BASE_DATA_OFFSET_PRESENT != 0 { + tfhd.base_data_offset + } else { + moof_offset + }; + let mut sample_spans = Vec::new(); + let mut next_offset = None::; + for (trun, trun_info) in truns.iter().zip(trun_infos.iter()) { + let mut current_offset = if trun.flags() & TRUN_DATA_OFFSET_PRESENT != 0 { + let absolute = i128::from(base_data_offset) + i128::from(trun.data_offset); + if absolute < 0 || absolute > i128::from(u64::MAX) { + return Err(invalid_layout(format!( + "trun at offset {} computed an invalid data offset", + trun_info.offset() + ))); + } + absolute as u64 + } else if let Some(next_offset) = next_offset { + next_offset + } else if tfhd.flags() & TFHD_DEFAULT_BASE_IS_MOOF != 0 { + moof_offset + } else { + base_data_offset + }; + + for sample_index in 0..usize::try_from(trun.sample_count).unwrap_or(0) { + let sample_size = if trun.flags() & TRUN_SAMPLE_SIZE_PRESENT != 0 { + trun.entries + .get(sample_index) + .map(|entry| entry.sample_size) + .ok_or_else(|| { + invalid_layout(format!( + "trun at offset {} is missing sample size entry {}", + trun_info.offset(), + sample_index + 1 + )) + })? + } else if tfhd.flags() & TFHD_DEFAULT_SAMPLE_SIZE_PRESENT != 0 { + tfhd.default_sample_size + } else if let Some(trex) = trex { + trex.default_sample_size + } else { + return Err(invalid_layout(format!( + "track {} sample sizes require tfhd or trex defaults", + tfhd.track_id + ))); + }; + + sample_spans.push(SampleSpan { + offset: current_offset, + size: sample_size, + }); + current_offset = current_offset + .checked_add(u64::from(sample_size)) + .ok_or_else(|| invalid_layout("sample offset overflowed u64".to_string()))?; + } + next_offset = Some(current_offset); + } + + Ok(sample_spans) +} + +fn activate_track_sample_entry<'a>( + track: &'a ProtectedTrackState, + sample_description_index: u32, + keys: &[DecryptionKey], +) -> Result>, DecryptRewriteError> { + let Some(sample_entry) = resolve_protected_sample_entry(track, sample_description_index)? + else { + return Ok(None); + }; + let Some(key) = resolve_key_for_sample_entry(track, sample_entry, keys)? else { + return Ok(None); + }; + let scheme = resolve_sample_entry_scheme(track.track_id, sample_entry)?; + + Ok(Some(ActiveTrackDecryption { + track, + sample_entry, + scheme, + key, + })) +} + +fn resolve_protected_sample_entry( + track: &ProtectedTrackState, + sample_description_index: u32, +) -> Result, DecryptRewriteError> { + if sample_description_index == 0 { + return Err(invalid_layout(format!( + "track {} uses invalid sample-description index 0", + track.track_id + ))); + } + Ok(track + .protected_sample_entries + .iter() + .find(|entry| entry.sample_description_index == sample_description_index)) +} + +fn resolve_fragment_sample_description_index( + track: &ProtectedTrackState, + tfhd: &Tfhd, +) -> Result { + if tfhd.flags() & TFHD_SAMPLE_DESCRIPTION_INDEX_PRESENT != 0 { + return Ok(tfhd.sample_description_index); + } + if let Some(trex) = track.trex.as_ref() { + return Ok(trex.default_sample_description_index); + } + if track.protected_sample_entries.len() == 1 { + return Ok(track.protected_sample_entries[0].sample_description_index); + } + + Err(invalid_layout(format!( + "track {} requires tfhd or trex sample-description defaults when multiple protected sample entries are present", + track.track_id + ))) +} + +fn resolve_key_for_sample_entry( + track: &ProtectedTrackState, + sample_entry: &ProtectedSampleEntryState, + keys: &[DecryptionKey], +) -> Result, DecryptRewriteError> { + if let Some(key) = keys.iter().find_map(|entry| match entry.id { + DecryptionKeyId::Kid(candidate) if candidate == sample_entry.tenc.default_kid => { + Some(entry.key) + } + _ => None, + }) { + return Ok(Some(key)); + } + + let track_keys = keys + .iter() + .filter_map(|entry| match entry.id { + DecryptionKeyId::TrackId(candidate) if candidate == track.track_id => Some(entry.key), + _ => None, + }) + .collect::>(); + let ordered_zero_kid_track_key = + resolve_ordered_track_key_for_zero_kid_sample_entry(track, sample_entry, &track_keys); + match track_keys.as_slice() { + [] => Ok(None), + [key] => Ok(Some(*key)), + [first, ..] if track.protected_sample_entries.len() == 1 => Ok(Some(*first)), + _ if ordered_zero_kid_track_key.is_some() => Ok(ordered_zero_kid_track_key), + _ => Err(invalid_layout(format!( + "track {} has multiple track-ID keys but sample-description {} needs per-entry key selection; use KID-addressed keys or provide one ordered track-ID key per zero-KID protected sample entry", + track.track_id, sample_entry.sample_description_index + ))), + } +} + +fn resolve_ordered_track_key_for_zero_kid_sample_entry( + track: &ProtectedTrackState, + sample_entry: &ProtectedSampleEntryState, + track_keys: &[[u8; 16]], +) -> Option<[u8; 16]> { + if sample_entry.tenc.default_kid != [0; 16] { + return None; + } + + let zero_kid_entries = track + .protected_sample_entries + .iter() + .filter(|entry| entry.tenc.default_kid == [0; 16]) + .collect::>(); + if zero_kid_entries.len() != track_keys.len() { + return None; + } + + zero_kid_entries + .iter() + .position(|entry| entry.sample_description_index == sample_entry.sample_description_index) + .map(|ordinal| track_keys[ordinal]) +} + +fn resolve_sample_entry_scheme( + track_id: u32, + sample_entry: &ProtectedSampleEntryState, +) -> Result { + if let Some(scheme) = NativeCommonEncryptionScheme::from_scheme_type(sample_entry.scheme_type) { + return Ok(scheme); + } + if sample_entry.scheme_type == PIFF { + return match sample_entry + .piff_protection_mode + .unwrap_or(sample_entry.tenc.default_is_protected) + { + 1 => Ok(NativeCommonEncryptionScheme::Cenc), + 2 => Ok(NativeCommonEncryptionScheme::Cbc1), + mode => Err(invalid_layout(format!( + "track {} uses unsupported PIFF protection mode {}", + track_id, mode + ))), + }; + } + + Err(DecryptRewriteError::UnsupportedTrackSchemeType { + track_id, + scheme_type: sample_entry.scheme_type, + }) +} + +fn extract_fragment_sample_encryption_box( + input: &[u8], + traf_info: &BoxInfo, + tenc: &Tenc, +) -> Result<(Senc, BoxInfo), DecryptRewriteError> { + let mut reader = Cursor::new(input); + let senc_infos = extract_box(&mut reader, Some(traf_info), BoxPath::from([SENC]))?; + let mut reader = Cursor::new(input); + let senc_payloads = + extract_box_payload_bytes(&mut reader, Some(traf_info), BoxPath::from([SENC]))?; + match (senc_payloads.len(), senc_infos.len()) { + (1, 1) => { + let senc = decode_senc_payload_with_iv_size( + &senc_payloads[0], + usize::from(tenc.default_per_sample_iv_size), + ) + .map_err(|error| { + invalid_layout(format!( + "failed to decode sample encryption box with the selected track defaults: {error}" + )) + })?; + return Ok((senc, senc_infos[0])); + } + (0, 0) => {} + _ => { + return Err(invalid_layout( + "expected aligned sample encryption boxes inside the track fragment".to_owned(), + )); + } + } + + let mut reader = Cursor::new(input); + let uuid_boxes = + extract_box_as::<_, Uuid>(&mut reader, Some(traf_info), BoxPath::from([UUID]))?; + let mut reader = Cursor::new(input); + let uuid_infos = extract_box(&mut reader, Some(traf_info), BoxPath::from([UUID]))?; + + let mut match_index = None; + let mut match_senc = None; + for (index, uuid_box) in uuid_boxes.into_iter().enumerate() { + if uuid_box.user_type != UUID_SAMPLE_ENCRYPTION { + continue; + } + let UuidPayload::SampleEncryption(senc) = uuid_box.payload else { + return Err(invalid_layout( + "expected typed sample-encryption data in the PIFF UUID sample box".to_owned(), + )); + }; + if match_index.is_some() { + return Err(invalid_layout( + "expected at most one PIFF UUID sample-encryption box in each track fragment" + .to_owned(), + )); + } + match_index = Some(index); + match_senc = Some(senc); + } + + match (match_index, match_senc) { + (Some(index), Some(senc)) => Ok((senc, uuid_infos[index])), + _ => Err(invalid_layout( + "expected one sample encryption box inside the protected track fragment".to_owned(), + )), + } +} + +fn select_seig_sgpd(entries: &[Sgpd]) -> Option<&Sgpd> { + entries.iter().find(|entry| entry.grouping_type == SEIG) +} + +fn select_seig_sbgp(entries: &[Sbgp]) -> Option<&Sbgp> { + entries + .iter() + .find(|entry| entry.grouping_type == u32::from_be_bytes(*b"seig")) +} + +fn patch_sample_entry_type( + bytes: &mut [u8], + sample_entry_info: BoxInfo, + original_format: FourCc, +) -> Result<(), DecryptRewriteError> { + let start = usize::try_from(sample_entry_info.offset()) + .map_err(|_| invalid_layout("sample entry offset does not fit in usize".to_string()))?; + let type_offset = start + .checked_add(4) + .ok_or_else(|| invalid_layout("sample entry offset overflowed".to_string()))?; + let end = type_offset + .checked_add(4) + .ok_or_else(|| invalid_layout("sample entry type offset overflowed".to_string()))?; + if end > bytes.len() { + return Err(invalid_layout( + "sample entry type patch is out of range".to_string(), + )); + } + bytes[type_offset..end].copy_from_slice(original_format.as_bytes()); + Ok(()) +} + +fn replace_box_with_free(bytes: &mut [u8], info: BoxInfo) -> Result<(), DecryptRewriteError> { + let start = usize::try_from(info.offset()) + .map_err(|_| invalid_layout("box offset does not fit in usize".to_string()))?; + let size = usize::try_from(info.size()) + .map_err(|_| invalid_layout("box size does not fit in usize".to_string()))?; + let end = start + .checked_add(size) + .ok_or_else(|| invalid_layout("box end overflowed".to_string()))?; + if end > bytes.len() { + return Err(invalid_layout(format!( + "box replacement for {} exceeds the available buffer", + info.box_type() + ))); + } + + let replacement = BoxInfo::new(FREE, info.size()) + .with_header_size(info.header_size()) + .encode(); + if replacement.len() as u64 != info.header_size() { + return Err(invalid_layout(format!( + "free replacement header size changed for {}", + info.box_type() + ))); + } + + bytes[start..start + replacement.len()].copy_from_slice(&replacement); + bytes[start + replacement.len()..end].fill(0); + Ok(()) +} + +fn read_sample_range<'a>( + bytes: &'a [u8], + ranges: &[MediaDataRange], + absolute_offset: u64, + sample_size: u32, +) -> Option<&'a [u8]> { + let size = u64::from(sample_size); + let end = absolute_offset.checked_add(size)?; + let range = ranges + .iter() + .find(|range| absolute_offset >= range.start && end <= range.end)?; + let start = usize::try_from(absolute_offset).ok()?; + let end = usize::try_from(end).ok()?; + if end > bytes.len() || absolute_offset < range.start || u64::try_from(end).ok()? > range.end { + return None; + } + Some(&bytes[start..end]) +} + +fn write_sample_range( + bytes: &mut [u8], + ranges: &[MediaDataRange], + absolute_offset: u64, + sample: &[u8], +) -> Option<()> { + let end = absolute_offset.checked_add(u64::try_from(sample.len()).ok()?)?; + let range = ranges + .iter() + .find(|range| absolute_offset >= range.start && end <= range.end)?; + let start = usize::try_from(absolute_offset).ok()?; + let end = usize::try_from(end).ok()?; + if end > bytes.len() || absolute_offset < range.start || u64::try_from(end).ok()? > range.end { + return None; + } + bytes[start..end].copy_from_slice(sample); + Some(()) +} + +fn contains_box(parent: BoxInfo, child: BoxInfo) -> bool { + child.offset() >= parent.offset() + && child.offset() + child.size() <= parent.offset() + parent.size() +} + +fn extract_single_as( + reader: &mut R, + parent: Option<&BoxInfo>, + path: BoxPath, + label: &'static str, +) -> Result +where + R: std::io::Read + std::io::Seek, + T: crate::codec::CodecBox + Clone + 'static, +{ + let mut values = extract_box_as::<_, T>(reader, parent, path)?; + if values.len() != 1 { + return Err(invalid_layout(format!( + "expected exactly one {label} box but found {}", + values.len() + ))); + } + Ok(values.remove(0)) +} + +fn extract_optional_single_as( + reader: &mut R, + parent: Option<&BoxInfo>, + path: BoxPath, + label: &'static str, +) -> Result, DecryptRewriteError> +where + R: std::io::Read + std::io::Seek, + T: crate::codec::CodecBox + Clone + 'static, +{ + let mut values = extract_box_as::<_, T>(reader, parent, path)?; + if values.len() > 1 { + return Err(invalid_layout(format!( + "expected at most one {label} box but found {}", + values.len() + ))); + } + Ok(values.pop()) +} + +fn extract_single_info( + reader: &mut R, + parent: Option<&BoxInfo>, + path: BoxPath, + label: &'static str, +) -> Result +where + R: std::io::Read + std::io::Seek, +{ + let mut infos = extract_box(reader, parent, path)?; + if infos.len() != 1 { + return Err(invalid_layout(format!( + "expected exactly one {label} box but found {}", + infos.len() + ))); + } + Ok(infos.remove(0)) +} + +fn extract_optional_single_info_from_infos( + parent: &BoxInfo, + box_type: FourCc, + input: &[u8], +) -> Result, DecryptRewriteError> { + let mut reader = Cursor::new(input); + let mut infos = extract_box(&mut reader, Some(parent), BoxPath::from([box_type]))?; + if infos.len() > 1 { + return Err(invalid_layout(format!( + "expected at most one {} box but found {}", + box_type, + infos.len() + ))); + } + Ok(infos.pop()) +} + +fn child_path(path: &BoxPath, child: FourCc) -> BoxPath { + path.iter().copied().chain(std::iter::once(child)).collect() +} + +fn invalid_layout(reason: impl Into) -> DecryptRewriteError { + DecryptRewriteError::InvalidLayout { + reason: reason.into(), + } +} + +fn parse_hex_16(field: &'static str, input: &str) -> Result<[u8; 16], ParseDecryptionKeyError> { + if input.len() != 32 { + return Err(ParseDecryptionKeyError::InvalidHexLength { + field, + actual: input.len(), + }); + } + + let bytes = input.as_bytes(); + let mut output = [0_u8; 16]; + for (index, chunk) in bytes.chunks_exact(2).enumerate() { + let high = decode_hex_nibble(field, index, chunk[0] as char)?; + let low = decode_hex_nibble(field, index, chunk[1] as char)?; + output[index] = (high << 4) | low; + } + + Ok(output) +} + +fn decode_hex_nibble( + field: &'static str, + index: usize, + value: char, +) -> Result { + match value { + '0'..='9' => Ok((value as u8) - b'0'), + 'a'..='f' => Ok((value as u8) - b'a' + 10), + 'A'..='F' => Ok((value as u8) - b'A' + 10), + _ => Err(ParseDecryptionKeyError::InvalidHexDigit { + field, + index, + value, + }), + } +} + +fn encode_hex(bytes: [u8; 16]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(32); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +fn effective_initialization_vector( + scheme: NativeCommonEncryptionScheme, + sample: &ResolvedSampleEncryptionSample<'_>, +) -> Result<[u8; 16], CommonEncryptionDecryptError> { + let bytes = sample.effective_initialization_vector(); + if bytes.is_empty() { + return Err(CommonEncryptionDecryptError::MissingInitializationVector { scheme }); + } + + let expected = if scheme.uses_cbc() { + "exactly 16" + } else { + "8 or 16" + }; + match (scheme.uses_cbc(), bytes.len()) { + (true, 16) | (false, 8 | 16) => {} + _ => { + return Err( + CommonEncryptionDecryptError::InvalidInitializationVectorSize { + scheme, + actual: bytes.len(), + expected, + }, + ); + } + } + + let mut iv = [0_u8; 16]; + iv[..bytes.len()].copy_from_slice(bytes); + Ok(iv) +} + +struct SampleTransformer { + crypt_byte_block: u8, + skip_byte_block: u8, + pattern_stream_offset: u64, + cipher: SampleCipher, +} + +impl SampleTransformer { + fn new( + scheme: NativeCommonEncryptionScheme, + aes: Aes128, + iv: [u8; 16], + crypt_byte_block: u8, + skip_byte_block: u8, + ) -> Self { + Self { + crypt_byte_block, + skip_byte_block, + pattern_stream_offset: 0, + cipher: if scheme.uses_cbc() { + SampleCipher::Cbc { + aes, + iv, + chain_block: iv, + } + } else { + SampleCipher::Ctr { + aes, + iv, + encrypted_offset: 0, + } + }, + } + } + + fn reset_for_subsample(&mut self) { + self.pattern_stream_offset = 0; + self.cipher.reset(); + } + + fn transform_region( + &mut self, + encrypted_region: &[u8], + output_region: &mut [u8], + ) -> Result<(), CommonEncryptionDecryptError> { + if encrypted_region.len() != output_region.len() { + return Err(CommonEncryptionDecryptError::InvalidProtectedRegion { + remaining: encrypted_region.len(), + clear_bytes: 0, + protected_bytes: output_region.len(), + }); + } + if self.crypt_byte_block != 0 && self.skip_byte_block != 0 { + self.transform_pattern_region(encrypted_region, output_region); + } else { + self.cipher + .process_encrypted_chunk(encrypted_region, output_region); + } + Ok(()) + } + + fn transform_pattern_region(&mut self, encrypted_region: &[u8], output_region: &mut [u8]) { + let pattern_span = usize::from(self.crypt_byte_block) + usize::from(self.skip_byte_block); + let mut cursor = 0usize; + while cursor < encrypted_region.len() { + let block_position = + usize::try_from(self.pattern_stream_offset / 16).unwrap_or(usize::MAX); + let pattern_position = block_position % pattern_span; + + let mut crypt_size = 0usize; + let mut skip_size = usize::from(self.skip_byte_block) * 16; + if pattern_position < usize::from(self.crypt_byte_block) { + crypt_size = (usize::from(self.crypt_byte_block) - pattern_position) * 16; + } else { + skip_size = (pattern_span - pattern_position) * 16; + } + + let remain = encrypted_region.len() - cursor; + if crypt_size > remain { + crypt_size = 16 * (remain / 16); + skip_size = remain - crypt_size; + } + if crypt_size + skip_size > remain { + skip_size = remain - crypt_size; + } + + if crypt_size != 0 { + self.cipher.process_encrypted_chunk( + &encrypted_region[cursor..cursor + crypt_size], + &mut output_region[cursor..cursor + crypt_size], + ); + cursor += crypt_size; + self.pattern_stream_offset += crypt_size as u64; + } + + if skip_size != 0 { + output_region[cursor..cursor + skip_size] + .copy_from_slice(&encrypted_region[cursor..cursor + skip_size]); + cursor += skip_size; + self.pattern_stream_offset += skip_size as u64; + } + } + } +} + +enum SampleCipher { + Ctr { + aes: Aes128, + iv: [u8; 16], + encrypted_offset: u64, + }, + Cbc { + aes: Aes128, + iv: [u8; 16], + chain_block: [u8; 16], + }, +} + +impl SampleCipher { + fn reset(&mut self) { + match self { + Self::Ctr { + encrypted_offset, .. + } => *encrypted_offset = 0, + Self::Cbc { + iv, chain_block, .. + } => *chain_block = *iv, + } + } + + fn process_encrypted_chunk(&mut self, input: &[u8], output: &mut [u8]) { + match self { + Self::Ctr { + aes, + iv, + encrypted_offset, + } => { + let mut cursor = 0usize; + while cursor < input.len() { + let block_offset = usize::try_from(*encrypted_offset % 16).unwrap(); + let chunk_len = (16 - block_offset).min(input.len() - cursor); + let mut counter_block = compute_ctr_counter_block(iv, *encrypted_offset); + aes.encrypt_block(&mut counter_block); + for index in 0..chunk_len { + output[cursor + index] = + input[cursor + index] ^ counter_block[block_offset + index]; + } + cursor += chunk_len; + *encrypted_offset += chunk_len as u64; + } + } + Self::Cbc { + aes, chain_block, .. + } => { + let full_blocks_len = input.len() - (input.len() % 16); + let mut cursor = 0usize; + while cursor < full_blocks_len { + let ciphertext = &input[cursor..cursor + 16]; + let mut block = Block::::clone_from_slice(ciphertext); + aes.decrypt_block(&mut block); + for index in 0..16 { + output[cursor + index] = block[index] ^ chain_block[index]; + } + chain_block.copy_from_slice(ciphertext); + cursor += 16; + } + output[full_blocks_len..].copy_from_slice(&input[full_blocks_len..]); + } + } + } +} + +fn compute_ctr_counter_block(iv: &[u8; 16], stream_offset: u64) -> Block { + let counter_offset = stream_offset / 16; + let counter_offset_bytes = counter_offset.to_be_bytes(); + let mut counter_block = Block::::default(); + + let mut carry = 0u16; + for index in 0..8 { + let offset = 15 - index; + let sum = u16::from(iv[offset]) + u16::from(counter_offset_bytes[7 - index]) + carry; + counter_block[offset] = (sum & 0xff) as u8; + carry = if sum >= 0x100 { 1 } else { 0 }; + } + for index in 8..16 { + let offset = 15 - index; + counter_block[offset] = iv[offset]; + } + + counter_block +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::boxes::iso14496_12::StscEntry; + + #[test] + fn compute_track_chunks_preserves_non_default_sample_description_indices() { + let mut stsc = Stsc::default(); + stsc.entry_count = 2; + stsc.entries = vec![ + StscEntry { + first_chunk: 1, + samples_per_chunk: 2, + sample_description_index: 1, + }, + StscEntry { + first_chunk: 2, + samples_per_chunk: 1, + sample_description_index: 2, + }, + ]; + + let mut stco = Stco::default(); + stco.entry_count = 2; + stco.chunk_offset = vec![100, 200]; + let chunk_offsets = ChunkOffsetBoxState::Stco { + info: BoxInfo::new(STCO, 16), + box_value: stco, + }; + + let chunks = compute_track_chunks(7, &stsc, &chunk_offsets, &[11, 12, 13]).unwrap(); + + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].offset, 100); + assert_eq!(chunks[0].sample_sizes, vec![11, 12]); + assert_eq!(chunks[0].sample_description_index, 1); + assert_eq!(chunks[1].offset, 200); + assert_eq!(chunks[1].sample_sizes, vec![13]); + assert_eq!(chunks[1].sample_description_index, 2); + } + + #[test] + fn compute_track_chunks_rejects_zero_sample_description_index() { + let mut stsc = Stsc::default(); + stsc.entry_count = 1; + stsc.entries = vec![StscEntry { + first_chunk: 1, + samples_per_chunk: 1, + sample_description_index: 0, + }]; + + let mut stco = Stco::default(); + stco.entry_count = 1; + stco.chunk_offset = vec![100]; + let chunk_offsets = ChunkOffsetBoxState::Stco { + info: BoxInfo::new(STCO, 12), + box_value: stco, + }; + + let error = compute_track_chunks(7, &stsc, &chunk_offsets, &[11]).unwrap_err(); + + assert!( + error.to_string().contains("sample-description index 0"), + "unexpected error: {error}" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index ef34e4e..b54cea9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,13 @@ //! intended for supported seekable Tokio I/O such as `tokio::fs::File` and seekable in-memory //! cursors, and it supports normal multithreaded `tokio::spawn` use for independent-file library //! work. The CLI remains on the synchronous path. +//! +//! Enable the optional `decrypt` feature when you want the additive decryption input and +//! progress types plus the feature-gated decryption surface. That landed surface covers the +//! Common Encryption family, PIFF compatibility, OMA DCF, Marlin IPMP, and the retained IAEC +//! protected-movie path while keeping the CLI on the synchronous path. Enable both `decrypt` and +//! `async` when you want the additive file-backed async decrypt companions on top of the existing +//! synchronous in-memory decrypt helpers. /// Tokio-based async I/O traits for the additive library-side async surface. #[cfg(feature = "async")] @@ -18,6 +25,10 @@ pub mod boxes; pub mod cli; /// Descriptor-driven binary codec primitives. pub mod codec; +/// Feature-gated synchronous decryption types and helpers. +#[cfg(feature = "decrypt")] +#[cfg_attr(docsrs, doc(cfg(feature = "decrypt")))] +pub mod decrypt; /// Resolved common-encryption metadata helpers built on typed box models. pub mod encryption; /// Path-based box extraction helpers, including typed convenience reads. diff --git a/src/sidx.rs b/src/sidx.rs index 1369650..2b5f99f 100644 --- a/src/sidx.rs +++ b/src/sidx.rs @@ -1284,6 +1284,13 @@ where let decoded = read_payload_as::<_, Sidx>(reader, info)?; let internal = analyze_existing_top_level_sidx(info, &decoded)?; existing_top_level_sidxs.push(internal); + } else if previous_box_type == Some(STYP) + && segments.last().is_some_and(is_pending_styp_prelude_segment) + { + let decoded = read_payload_as::<_, Sidx>(reader, info)?; + let internal = analyze_existing_top_level_sidx(info, &decoded)?; + existing_top_level_sidxs.push(internal); + segments.pop(); } else if previous_box_type == Some(MDAT) { start_segment(&mut segments, *info); let segment = segments.last_mut().unwrap(); @@ -1322,6 +1329,13 @@ where Ok((segments, existing_top_level_sidxs)) } +fn is_pending_styp_prelude_segment(segment: &SegmentAccumulator) -> bool { + segment.first_box.box_type() == STYP + && segment.moofs.is_empty() + && segment.segment_sidx_count == 0 + && segment.size == segment.first_box.size() +} + fn start_segment(segments: &mut Vec, first_box: BoxInfo) { segments.push(SegmentAccumulator { first_box, diff --git a/tests/box_catalog_isma_cryp.rs b/tests/box_catalog_isma_cryp.rs new file mode 100644 index 0000000..14beae3 --- /dev/null +++ b/tests/box_catalog_isma_cryp.rs @@ -0,0 +1,151 @@ +use std::any::type_name; +use std::fmt::Debug; +use std::io::Cursor; + +use mp4forge::FourCc; +use mp4forge::boxes::default_registry; +use mp4forge::boxes::isma_cryp::{Ikms, Isfm, Islt}; +use mp4forge::codec::{CodecBox, MutableBox, marshal, unmarshal, unmarshal_any}; + +fn assert_box_roundtrip(src: T, payload: &[u8]) +where + T: CodecBox + Default + PartialEq + Debug + 'static, +{ + let mut encoded = Vec::new(); + let written = marshal(&mut encoded, &src, None).unwrap(); + assert_eq!( + written, + payload.len() as u64, + "marshal length for {}", + type_name::() + ); + assert_eq!(encoded, payload, "marshal bytes for {}", type_name::()); + + let mut decoded = T::default(); + let mut reader = Cursor::new(payload.to_vec()); + let read = unmarshal(&mut reader, payload.len() as u64, &mut decoded, None).unwrap(); + assert_eq!( + read, + payload.len() as u64, + "unmarshal length for {}", + type_name::() + ); + assert_eq!(decoded, src, "unmarshal value for {}", type_name::()); + + let registry = default_registry(); + let mut any_reader = Cursor::new(payload.to_vec()); + let (any_box, any_read) = unmarshal_any( + &mut any_reader, + payload.len() as u64, + src.box_type(), + ®istry, + None, + ) + .unwrap(); + assert_eq!( + any_read, + payload.len() as u64, + "registry unmarshal length for {}", + type_name::() + ); + assert_eq!(any_box.as_any().downcast_ref::().unwrap(), &src); +} + +#[test] +fn ikms_catalog_roundtrips_versions_zero_and_one() { + let mut v0 = Ikms::default(); + v0.kms_uri = "https://kms.example/v0".into(); + v0.set_version(0); + assert_box_roundtrip( + v0, + &[ + 0x00, 0x00, 0x00, 0x00, b'h', b't', b't', b'p', b's', b':', b'/', b'/', b'k', b'm', + b's', b'.', b'e', b'x', b'a', b'm', b'p', b'l', b'e', b'/', b'v', b'0', 0x00, + ], + ); + + let mut v1 = Ikms::default(); + v1.kms_id = 0x6b6d_7331; + v1.kms_version = 7; + v1.kms_uri = "urn:keys:demo".into(); + v1.set_version(1); + assert_box_roundtrip( + v1, + &[ + 0x01, 0x00, 0x00, 0x00, 0x6b, 0x6d, 0x73, 0x31, 0x00, 0x00, 0x00, 0x07, b'u', b'r', + b'n', b':', b'k', b'e', b'y', b's', b':', b'd', b'e', b'm', b'o', 0x00, + ], + ); +} + +#[test] +fn isfm_catalog_roundtrips() { + let mut isfm = Isfm::default(); + isfm.selective_encryption = true; + isfm.key_indicator_length = 4; + isfm.iv_length = 8; + isfm.set_version(0); + isfm.set_flags(0x010203); + + assert_box_roundtrip(isfm, &[0x00, 0x01, 0x02, 0x03, 0x80, 0x04, 0x08]); +} + +#[test] +fn islt_catalog_roundtrips() { + assert_box_roundtrip( + Islt { + salt: [0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc, 0xfe], + }, + &[0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc, 0xfe], + ); +} + +#[test] +fn built_in_registry_reports_supported_versions_for_landed_isma_boxes() { + let registry = default_registry(); + + assert_eq!( + registry.supported_versions(FourCc::from_bytes(*b"iKMS")), + Some(&[0, 1][..]) + ); + assert_eq!( + registry.supported_versions(FourCc::from_bytes(*b"iSFM")), + Some(&[0][..]) + ); + assert_eq!( + registry.supported_versions(FourCc::from_bytes(*b"iSLT")), + Some(&[][..]) + ); + assert!(registry.is_registered(FourCc::from_bytes(*b"iKMS"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"iSFM"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"iSLT"))); +} + +#[test] +fn ikms_rejects_embedded_null_bytes_during_marshal() { + let mut ikms = Ikms::default(); + ikms.kms_uri = "bad\0uri".into(); + ikms.set_version(0); + + let error = marshal(&mut Vec::new(), &ikms, None).unwrap_err(); + assert_eq!( + error.to_string(), + "invalid field value for KmsUri: string value must not contain embedded null bytes" + ); +} + +#[test] +fn isfm_rejects_unsupported_versions_during_unmarshal() { + let mut isfm = Isfm::default(); + let payload = [0x01, 0x00, 0x00, 0x00, 0x80, 0x00, 0x08]; + + let error = unmarshal(&mut Cursor::new(payload), 7, &mut isfm, None).unwrap_err(); + assert_eq!(error.to_string(), "unsupported box version 1 for type iSFM"); +} + +#[test] +fn islt_rejects_non_eight_byte_payloads() { + let mut islt = Islt::default(); + let error = unmarshal(&mut Cursor::new(vec![0u8; 7]), 7, &mut islt, None).unwrap_err(); + assert_eq!(error.to_string(), "unexpected end of file"); +} diff --git a/tests/box_catalog_iso14496_14.rs b/tests/box_catalog_iso14496_14.rs index 184c465..7e2abd7 100644 --- a/tests/box_catalog_iso14496_14.rs +++ b/tests/box_catalog_iso14496_14.rs @@ -6,7 +6,11 @@ use mp4forge::FourCc; use mp4forge::boxes::default_registry; use mp4forge::boxes::iso14496_14::{ DECODER_CONFIG_DESCRIPTOR_TAG, DECODER_SPECIFIC_INFO_TAG, DecoderConfigDescriptor, Descriptor, - ES_DESCRIPTOR_TAG, EsDescriptor, Esds, SL_CONFIG_DESCRIPTOR_TAG, + DescriptorCommand, DescriptorUpdateCommand, ES_DESCRIPTOR_TAG, EsDescriptor, EsIdIncDescriptor, + EsIdRefDescriptor, Esds, IPMP_DESCRIPTOR_UPDATE_COMMAND_TAG, InitialObjectDescriptor, Iods, + IpmpDescriptor, IpmpDescriptorPointer, OBJECT_DESCRIPTOR_UPDATE_COMMAND_TAG, + SL_CONFIG_DESCRIPTOR_TAG, UnknownDescriptorCommand, encode_descriptor_commands, + parse_descriptor_commands, }; use mp4forge::codec::{CodecBox, MutableBox, marshal, unmarshal, unmarshal_any}; use mp4forge::stringify::stringify; @@ -131,6 +135,50 @@ fn descriptor_catalog_roundtrips() { ); } +#[test] +fn iods_catalog_roundtrips() { + let mut iods = Iods::default(); + iods.set_version(0); + iods.descriptor = Some( + Descriptor::from_initial_object_descriptor(InitialObjectDescriptor { + object_descriptor_id: 18, + include_inline_profile_level_flag: true, + od_profile_level_indication: 0x11, + scene_profile_level_indication: 0x22, + audio_profile_level_indication: 0x33, + visual_profile_level_indication: 0x44, + graphics_profile_level_indication: 0x55, + sub_descriptors: vec![ + Descriptor::from_es_id_inc_descriptor(EsIdIncDescriptor { track_id: 2 }), + Descriptor::from_es_id_ref_descriptor(EsIdRefDescriptor { ref_index: 3 }), + Descriptor::from_ipmp_descriptor_pointer(IpmpDescriptorPointer { + descriptor_id: 1, + ..IpmpDescriptorPointer::default() + }), + Descriptor::from_ipmp_descriptor(IpmpDescriptor { + descriptor_id: 1, + ipmps_type: 0xa551, + data: vec![0xaa, 0xbb], + ..IpmpDescriptor::default() + }), + ], + ..InitialObjectDescriptor::default() + }) + .unwrap(), + ); + + assert_box_roundtrip( + iods, + &[ + 0x00, 0x00, 0x00, 0x00, 0x10, 0x80, 0x80, 0x80, 0x27, 0x04, 0x9f, 0x11, 0x22, 0x33, + 0x44, 0x55, 0x0e, 0x80, 0x80, 0x80, 0x04, 0x00, 0x00, 0x00, 0x02, 0x0f, 0x80, 0x80, + 0x80, 0x02, 0x00, 0x03, 0x0a, 0x80, 0x80, 0x80, 0x01, 0x01, 0x0b, 0x80, 0x80, 0x80, + 0x05, 0x01, 0xa5, 0x51, 0xaa, 0xbb, + ], + "Version=0 Flags=0x000000 Descriptor={Tag=MP4InitialObjectDescr Size=39 ObjectDescriptorID=18 UrlFlag=false IncludeInlineProfileLevelFlag=true ODProfileLevelIndication=0x11 SceneProfileLevelIndication=0x22 AudioProfileLevelIndication=0x33 VisualProfileLevelIndication=0x44 GraphicsProfileLevelIndication=0x55 SubDescriptors=[{Tag=ES_ID_Inc Size=4 TrackID=2}, {Tag=ES_ID_Ref Size=2 RefIndex=3}, {Tag=IPMPDescrPointer Size=1 DescriptorID=0x1}, {Tag=IPMPDescr Size=5 DescriptorID=0x1 IPMPSType=0xa551 Data=[0xaa, 0xbb]}]}", + ); +} + #[test] fn built_in_registry_reports_supported_versions_for_landed_descriptor_types() { let registry = default_registry(); @@ -139,7 +187,12 @@ fn built_in_registry_reports_supported_versions_for_landed_descriptor_types() { registry.supported_versions(FourCc::from_bytes(*b"esds")), Some(&[0][..]) ); + assert_eq!( + registry.supported_versions(FourCc::from_bytes(*b"iods")), + Some(&[0][..]) + ); assert!(registry.is_registered(FourCc::from_bytes(*b"esds"))); + assert!(registry.is_registered(FourCc::from_bytes(*b"iods"))); } #[test] @@ -175,6 +228,31 @@ fn esds_helpers_surface_decoder_config_and_specific_info() { ); } +#[test] +fn iods_helpers_surface_initial_object_descriptor() { + let mut iods = Iods::default(); + iods.descriptor = Some( + Descriptor::from_initial_object_descriptor(InitialObjectDescriptor { + object_descriptor_id: 7, + sub_descriptors: vec![Descriptor::from_es_id_inc_descriptor(EsIdIncDescriptor { + track_id: 33, + })], + ..InitialObjectDescriptor::default() + }) + .unwrap(), + ); + + let initial = iods.initial_object_descriptor().unwrap(); + assert_eq!(initial.object_descriptor_id, 7); + assert_eq!( + initial.sub_descriptors[0] + .es_id_inc_descriptor() + .unwrap() + .track_id, + 33 + ); +} + #[test] fn esds_rejects_data_descriptor_size_mismatch_during_marshal() { let mut esds = Esds::default(); @@ -191,3 +269,92 @@ fn esds_rejects_data_descriptor_size_mismatch_during_marshal() { "invalid field value for Data: value length does not match Size" ); } + +#[test] +fn descriptor_command_helpers_roundtrip_known_update_streams() { + let commands = vec![ + DescriptorCommand::DescriptorUpdate(DescriptorUpdateCommand::object_descriptor_update( + vec![ + Descriptor::from_object_descriptor( + mp4forge::boxes::iso14496_14::ObjectDescriptor { + object_descriptor_id: 0x12, + sub_descriptors: vec![ + Descriptor::from_es_id_ref_descriptor(EsIdRefDescriptor { + ref_index: 1, + }), + Descriptor::from_ipmp_descriptor_pointer(IpmpDescriptorPointer { + descriptor_id: 7, + ..IpmpDescriptorPointer::default() + }), + ], + ..mp4forge::boxes::iso14496_14::ObjectDescriptor::default() + }, + ) + .unwrap(), + ], + )), + DescriptorCommand::DescriptorUpdate(DescriptorUpdateCommand::ipmp_descriptor_update(vec![ + Descriptor::from_ipmp_descriptor(IpmpDescriptor { + descriptor_id: 7, + ipmps_type: 0xa551, + data: vec![0xaa, 0xbb, 0xcc], + ..IpmpDescriptor::default() + }), + ])), + ]; + + let encoded = encode_descriptor_commands(&commands).unwrap(); + let decoded = parse_descriptor_commands(&encoded).unwrap(); + + assert_eq!(decoded, commands); + assert_eq!(decoded[0].tag(), OBJECT_DESCRIPTOR_UPDATE_COMMAND_TAG); + assert_eq!(decoded[0].tag_name(), Some("ObjectDescriptorUpdate")); + assert_eq!( + decoded[0].descriptor_update().unwrap().descriptors[0] + .object_descriptor() + .unwrap() + .sub_descriptors[0] + .es_id_ref_descriptor() + .unwrap() + .ref_index, + 1 + ); + assert_eq!(decoded[1].tag(), IPMP_DESCRIPTOR_UPDATE_COMMAND_TAG); + assert_eq!(decoded[1].tag_name(), Some("IPMPDescriptorUpdate")); + assert_eq!( + decoded[1].descriptor_update().unwrap().descriptors[0] + .ipmp_descriptor() + .unwrap() + .ipmps_type, + 0xa551 + ); +} + +#[test] +fn descriptor_command_helpers_preserve_unknown_commands_as_raw_payloads() { + let commands = vec![ + DescriptorCommand::Unknown(UnknownDescriptorCommand { + tag: 0x08, + data: vec![0x11, 0x22, 0x33, 0x44], + }), + DescriptorCommand::DescriptorUpdate(DescriptorUpdateCommand::ipmp_descriptor_update(vec![ + Descriptor::from_ipmp_descriptor(IpmpDescriptor { + descriptor_id: 1, + ipmps_type: 0xa551, + data: vec![0x77], + ..IpmpDescriptor::default() + }), + ])), + ]; + + let encoded = encode_descriptor_commands(&commands).unwrap(); + let decoded = parse_descriptor_commands(&encoded).unwrap(); + + assert_eq!(decoded, commands); + assert_eq!(decoded[0].tag(), 0x08); + assert!(decoded[0].tag_name().is_none()); + assert_eq!( + decoded[0].unknown().unwrap().data, + vec![0x11, 0x22, 0x33, 0x44] + ); +} diff --git a/tests/box_catalog_marlin.rs b/tests/box_catalog_marlin.rs new file mode 100644 index 0000000..6c092c2 --- /dev/null +++ b/tests/box_catalog_marlin.rs @@ -0,0 +1,158 @@ +use std::any::type_name; +use std::fmt::Debug; +use std::io::Cursor; + +use mp4forge::FourCc; +use mp4forge::boxes::default_registry; +use mp4forge::boxes::marlin::{ + Gkey, Hmac, MARLIN_BRAND_MGSV, MARLIN_IPMPS_TYPE_MGSV, MARLIN_STYP_AUDIO, MarlinShortSchm, + MarlinStyp, PROTECTION_SCHEME_TYPE_MARLIN_ACBC, PROTECTION_SCHEME_TYPE_MARLIN_ACGK, Satr, +}; +use mp4forge::codec::{CodecBox, marshal, unmarshal, unmarshal_any}; +use mp4forge::stringify::stringify; + +fn assert_box_roundtrip(src: T, payload: &[u8], expected: &str) +where + T: CodecBox + Default + PartialEq + Debug + 'static, +{ + let mut encoded = Vec::new(); + let written = marshal(&mut encoded, &src, None).unwrap(); + assert_eq!( + written, + payload.len() as u64, + "marshal length for {}", + type_name::() + ); + assert_eq!(encoded, payload, "marshal bytes for {}", type_name::()); + + let mut decoded = T::default(); + let mut reader = Cursor::new(payload.to_vec()); + let read = unmarshal(&mut reader, payload.len() as u64, &mut decoded, None).unwrap(); + assert_eq!( + read, + payload.len() as u64, + "unmarshal length for {}", + type_name::() + ); + assert_eq!(decoded, src, "unmarshal value for {}", type_name::()); + + let registry = default_registry(); + let mut any_reader = Cursor::new(payload.to_vec()); + let (any_box, any_read) = unmarshal_any( + &mut any_reader, + payload.len() as u64, + src.box_type(), + ®istry, + None, + ) + .unwrap(); + assert_eq!( + any_read, + payload.len() as u64, + "registry unmarshal length for {}", + type_name::() + ); + assert_eq!(any_box.as_any().downcast_ref::().unwrap(), &src); + + assert_eq!(stringify(&src, None).unwrap(), expected); +} + +#[test] +fn marlin_catalog_roundtrips_unique_atoms() { + assert_box_roundtrip(Satr, &[], ""); + + let hmac = Hmac { + data: vec![0xde, 0xad, 0xbe, 0xef], + }; + assert_box_roundtrip( + hmac, + &[0xde, 0xad, 0xbe, 0xef], + "Data=[0xde, 0xad, 0xbe, 0xef]", + ); + + let gkey = Gkey { + data: vec![0x10, 0x20, 0x30, 0x40], + }; + assert_box_roundtrip( + gkey, + &[0x10, 0x20, 0x30, 0x40], + "Data=[0x10, 0x20, 0x30, 0x40]", + ); +} + +#[test] +fn marlin_helper_payloads_roundtrip() { + assert_eq!(MARLIN_BRAND_MGSV, FourCc::from_bytes(*b"MGSV")); + assert_eq!(MARLIN_IPMPS_TYPE_MGSV, 0xA551); + + let styp = MarlinStyp { + value: MARLIN_STYP_AUDIO.into(), + }; + let styp_payload = styp.encode_payload().unwrap(); + assert_eq!(MarlinStyp::parse_payload(&styp_payload).unwrap(), styp); + + let forced = MarlinStyp::parse_payload(b"video").unwrap(); + assert_eq!(forced.value, "vide"); + + let track_key = MarlinShortSchm { + scheme_type: PROTECTION_SCHEME_TYPE_MARLIN_ACBC, + scheme_version: 0x0100, + }; + let group_key = MarlinShortSchm { + scheme_type: PROTECTION_SCHEME_TYPE_MARLIN_ACGK, + scheme_version: 0x0100, + }; + + assert_eq!( + MarlinShortSchm::parse_payload(&track_key.encode_payload()).unwrap(), + track_key + ); + assert!(track_key.uses_track_key()); + assert!(!track_key.uses_group_key()); + + assert_eq!( + MarlinShortSchm::parse_payload(&group_key.encode_payload()).unwrap(), + group_key + ); + assert!(!group_key.uses_track_key()); + assert!(group_key.uses_group_key()); +} + +#[test] +fn marlin_helpers_reject_invalid_payloads() { + let error = MarlinShortSchm::parse_payload(&[0, 1, 2, 3, 4]).unwrap_err(); + assert_eq!( + error.to_string(), + "invalid field value for Payload: expected a 6-byte Marlin short-form schm payload" + ); + + let error = MarlinStyp { + value: "bad\0value".into(), + } + .encode_payload() + .unwrap_err(); + assert_eq!( + error.to_string(), + "invalid field value for Value: string contains an embedded NUL" + ); +} + +#[test] +fn built_in_registry_reports_supported_versions_for_landed_marlin_types() { + let registry = default_registry(); + + for box_type in [ + FourCc::from_bytes(*b"satr"), + FourCc::from_bytes(*b"hmac"), + FourCc::from_bytes(*b"gkey"), + ] { + assert!( + registry.is_registered(box_type), + "missing registry entry for {box_type}" + ); + assert!( + registry.supported_versions(box_type) == Some(&[][..]), + "unexpected supported-version table for {box_type}" + ); + } +} diff --git a/tests/box_catalog_oma_dcf.rs b/tests/box_catalog_oma_dcf.rs new file mode 100644 index 0000000..310893f --- /dev/null +++ b/tests/box_catalog_oma_dcf.rs @@ -0,0 +1,180 @@ +use std::any::type_name; +use std::fmt::Debug; +use std::io::Cursor; + +use mp4forge::FourCc; +use mp4forge::boxes::default_registry; +use mp4forge::boxes::oma_dcf::{ + Grpi, OHDR_ENCRYPTION_METHOD_AES_CTR, OHDR_PADDING_SCHEME_NONE, Odaf, Odda, Odhe, Odkm, Ohdr, +}; +use mp4forge::codec::{CodecBox, marshal, unmarshal, unmarshal_any}; +use mp4forge::stringify::stringify; + +fn assert_box_roundtrip(src: T, payload: &[u8], expected: &str) +where + T: CodecBox + Default + PartialEq + Debug + 'static, +{ + let mut encoded = Vec::new(); + let written = marshal(&mut encoded, &src, None).unwrap(); + assert_eq!( + written, + payload.len() as u64, + "marshal length for {}", + type_name::() + ); + assert_eq!(encoded, payload, "marshal bytes for {}", type_name::()); + + let mut decoded = T::default(); + let mut reader = Cursor::new(payload.to_vec()); + let read = unmarshal(&mut reader, payload.len() as u64, &mut decoded, None).unwrap(); + assert_eq!( + read, + payload.len() as u64, + "unmarshal length for {}", + type_name::() + ); + assert_eq!(decoded, src, "unmarshal value for {}", type_name::()); + + let registry = default_registry(); + let mut any_reader = Cursor::new(payload.to_vec()); + let (any_box, any_read) = unmarshal_any( + &mut any_reader, + payload.len() as u64, + src.box_type(), + ®istry, + None, + ) + .unwrap(); + assert_eq!( + any_read, + payload.len() as u64, + "registry unmarshal length for {}", + type_name::() + ); + assert_eq!(any_box.as_any().downcast_ref::().unwrap(), &src); + + assert_eq!(stringify(&src, None).unwrap(), expected); +} + +#[test] +fn oma_dcf_catalog_roundtrips() { + let mut odhe = Odhe::default(); + odhe.content_type = "video/mp4".into(); + assert_box_roundtrip( + odhe, + &[ + 0x00, 0x00, 0x00, 0x00, 0x09, b'v', b'i', b'd', b'e', b'o', b'/', b'm', b'p', b'4', + ], + "Version=0 Flags=0x000000 ContentType=\"video/mp4\"", + ); + + let mut ohdr = Ohdr::default(); + ohdr.encryption_method = OHDR_ENCRYPTION_METHOD_AES_CTR; + ohdr.padding_scheme = OHDR_PADDING_SCHEME_NONE; + ohdr.plaintext_length = 0x0102_0304_0506_0708; + ohdr.content_id = "cid-7".into(); + ohdr.rights_issuer_url = "https://issuer.example".into(); + ohdr.textual_headers = b"Header-One: a\0Header-Two: b".to_vec(); + assert_box_roundtrip( + ohdr, + &[ + 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x00, 0x05, 0x00, 0x16, 0x00, 0x1b, b'c', b'i', b'd', b'-', b'7', b'h', b't', b't', + b'p', b's', b':', b'/', b'/', b'i', b's', b's', b'u', b'e', b'r', b'.', b'e', b'x', + b'a', b'm', b'p', b'l', b'e', b'H', b'e', b'a', b'd', b'e', b'r', b'-', b'O', b'n', + b'e', b':', b' ', b'a', 0x00, b'H', b'e', b'a', b'd', b'e', b'r', b'-', b'T', b'w', + b'o', b':', b' ', b'b', + ], + "Version=0 Flags=0x000000 EncryptionMethod=2 PaddingScheme=0 PlaintextLength=72623859790382856 ContentId=\"cid-7\" RightsIssuerUrl=\"https://issuer.example\" TextualHeaders=[0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x2d, 0x4f, 0x6e, 0x65, 0x3a, 0x20, 0x61, 0x0, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x2d, 0x54, 0x77, 0x6f, 0x3a, 0x20, 0x62]", + ); + + let mut odaf = Odaf::default(); + odaf.selective_encryption = true; + odaf.key_indicator_length = 0; + odaf.iv_length = 16; + assert_box_roundtrip( + odaf, + &[0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x10], + "Version=0 Flags=0x000000 SelectiveEncryption=true KeyIndicatorLength=0 IvLength=16", + ); + + assert_box_roundtrip( + Odkm::default(), + &[0x00, 0x00, 0x00, 0x00], + "Version=0 Flags=0x000000", + ); + + let mut odda = Odda::default(); + odda.encrypted_payload = vec![0xde, 0xad, 0xbe, 0xef]; + assert_box_roundtrip( + odda, + &[ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0xde, 0xad, + 0xbe, 0xef, + ], + "Version=0 Flags=0x000000 EncryptedPayload=[0xde, 0xad, 0xbe, 0xef]", + ); + + let mut grpi = Grpi::default(); + grpi.key_encryption_method = 1; + grpi.group_id = "group-a".into(); + grpi.group_key = vec![0x00, 0x11, 0x22, 0x33]; + assert_box_roundtrip( + grpi, + &[ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x01, 0x00, 0x04, b'g', b'r', b'o', b'u', b'p', + b'-', b'a', 0x00, 0x11, 0x22, 0x33, + ], + "Version=0 Flags=0x000000 KeyEncryptionMethod=1 GroupId=\"group-a\" GroupKey=[0x0, 0x11, 0x22, 0x33]", + ); +} + +#[test] +fn built_in_registry_reports_supported_versions_for_landed_oma_dcf_types() { + let registry = default_registry(); + + for box_type in [ + FourCc::from_bytes(*b"odrm"), + FourCc::from_bytes(*b"odkm"), + FourCc::from_bytes(*b"odhe"), + FourCc::from_bytes(*b"ohdr"), + FourCc::from_bytes(*b"odaf"), + FourCc::from_bytes(*b"odda"), + FourCc::from_bytes(*b"grpi"), + ] { + assert!( + registry.is_registered(box_type), + "missing registry entry for {box_type}" + ); + } + + assert_eq!( + registry.supported_versions(FourCc::from_bytes(*b"odhe")), + Some(&[0][..]) + ); + assert_eq!( + registry.supported_versions(FourCc::from_bytes(*b"ohdr")), + Some(&[0][..]) + ); + assert_eq!( + registry.supported_versions(FourCc::from_bytes(*b"odaf")), + Some(&[0][..]) + ); + assert_eq!( + registry.supported_versions(FourCc::from_bytes(*b"odkm")), + Some(&[0][..]) + ); + assert_eq!( + registry.supported_versions(FourCc::from_bytes(*b"odda")), + Some(&[0][..]) + ); + assert_eq!( + registry.supported_versions(FourCc::from_bytes(*b"grpi")), + Some(&[0][..]) + ); + assert!(registry.is_supported_version(FourCc::from_bytes(*b"odhe"), 0)); + assert!(!registry.is_supported_version(FourCc::from_bytes(*b"odhe"), 1)); + assert!(registry.is_supported_version(FourCc::from_bytes(*b"odrm"), 9)); + assert!(registry.is_supported_version(FourCc::from_bytes(*b"odkm"), 0)); + assert!(!registry.is_supported_version(FourCc::from_bytes(*b"odkm"), 1)); +} diff --git a/tests/cli_decrypt.rs b/tests/cli_decrypt.rs new file mode 100644 index 0000000..d0b34e6 --- /dev/null +++ b/tests/cli_decrypt.rs @@ -0,0 +1,634 @@ +#![cfg(feature = "decrypt")] + +mod support; + +use std::collections::BTreeMap; +use std::fs; +use std::io::Cursor; + +use mp4forge::cli::{self, decrypt}; +use mp4forge::extract::extract_box_payload_bytes; +use mp4forge::probe::probe_detailed; +use mp4forge::walk::BoxPath; + +use support::{ + ProtectedMovieTopologyFixture, RetainedDecryptFileFixture, RetainedFragmentedDecryptFixture, + build_decrypt_rewrite_fixture, build_iaec_broader_movie_fixture, + build_iaec_sample_description_index_unsupported_movie_fixture, + build_marlin_ipmp_acbc_broader_movie_fixture, + build_marlin_ipmp_acbc_sample_description_index_movie_fixture, + build_marlin_ipmp_acgk_broader_movie_fixture, + build_marlin_ipmp_acgk_sample_description_index_movie_fixture, + build_multi_sample_entry_decrypt_fixture, build_oma_dcf_broader_movie_fixture, + build_oma_dcf_sample_description_index_unsupported_movie_fixture, + build_zero_kid_multi_sample_entry_decrypt_fixture, common_encryption_fragment_fixture, + common_encryption_multi_track_fixture, fourcc, isma_iaec_fixture, marlin_ipmp_acbc_fixture, + marlin_ipmp_acgk_fixture, oma_dcf_cbc_fixture, oma_dcf_cbc_grpi_fixture, oma_dcf_ctr_fixture, + oma_dcf_ctr_grpi_fixture, piff_cbc_fixture, piff_cbc_segment_fixture, piff_ctr_fixture, + piff_ctr_segment_fixture, write_temp_file, +}; + +#[test] +fn decrypt_command_writes_clear_output_via_dispatch() { + let fixture = build_decrypt_rewrite_fixture(); + let input_path = write_temp_file("cli-decrypt-input", &fixture.single_file); + let output_path = write_temp_file("cli-decrypt-output", &[]); + let args = vec![ + "decrypt".to_string(), + "--key".to_string(), + fixture.all_keys[0].to_spec(), + "--key".to_string(), + fixture.all_keys[1].to_spec(), + input_path.to_string_lossy().into_owned(), + output_path.to_string_lossy().into_owned(), + ]; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = cli::dispatch(&args, &mut stdout, &mut stderr); + + let output = fs::read(&output_path).unwrap(); + let detailed = probe_detailed(&mut Cursor::new(output)).unwrap(); + let tracks = detailed + .tracks + .into_iter() + .map(|track| (track.summary.track_id, track)) + .collect::>(); + + let _ = fs::remove_file(&input_path); + let _ = fs::remove_file(&output_path); + + assert_eq!(exit_code, 0, "stderr={}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + for track_id in [fixture.first_track_id, fixture.second_track_id] { + let track = tracks.get(&track_id).unwrap(); + assert!(!track.summary.encrypted); + assert_eq!(track.sample_entry_type, Some(fourcc("avc1"))); + assert!(track.protection_scheme.is_none()); + } +} + +#[test] +fn decrypt_command_supports_fragments_info_files() { + let fixture = build_decrypt_rewrite_fixture(); + let init_path = write_temp_file("cli-decrypt-init", &fixture.init_segment); + let input_path = write_temp_file("cli-decrypt-media", &fixture.media_segment); + let output_path = write_temp_file("cli-decrypt-media-output", &[]); + let args = vec![ + "--key".to_string(), + fixture.all_keys[0].to_spec(), + "--key".to_string(), + fixture.all_keys[1].to_spec(), + "--fragments-info".to_string(), + init_path.to_string_lossy().into_owned(), + input_path.to_string_lossy().into_owned(), + output_path.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = decrypt::run(&args, &mut stderr); + + let output = fs::read(&output_path).unwrap(); + let mdat_payloads = extract_box_payload_bytes( + &mut Cursor::new(output), + None, + BoxPath::from([fourcc("mdat")]), + ) + .unwrap(); + + let _ = fs::remove_file(&init_path); + let _ = fs::remove_file(&input_path); + let _ = fs::remove_file(&output_path); + + assert_eq!(exit_code, 0, "stderr={}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!(mdat_payloads.len(), 1); + assert_eq!( + mdat_payloads[0], + [ + fixture.first_track_plaintext, + fixture.second_track_plaintext + ] + .concat() + ); +} + +#[test] +fn decrypt_command_writes_stable_progress_lines() { + let fixture = build_decrypt_rewrite_fixture(); + let input_path = write_temp_file("cli-decrypt-progress-input", &fixture.single_file); + let output_path = write_temp_file("cli-decrypt-progress-output", &[]); + let args = vec![ + "--show-progress".to_string(), + "--key".to_string(), + fixture.all_keys[0].to_spec(), + "--key".to_string(), + fixture.all_keys[1].to_spec(), + input_path.to_string_lossy().into_owned(), + output_path.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = decrypt::run(&args, &mut stderr); + + let _ = fs::remove_file(&input_path); + let _ = fs::remove_file(&output_path); + + assert_eq!(exit_code, 0, "stderr={}", String::from_utf8_lossy(&stderr)); + assert_eq!( + String::from_utf8(stderr).unwrap(), + concat!( + "OpenInput 0/1\n", + "OpenInput 1/1\n", + "InspectStructure 0/1\n", + "InspectStructure 1/1\n", + "ProcessSamples 0/1\n", + "ProcessSamples 1/1\n", + "OpenOutput 0/1\n", + "OpenOutput 1/1\n", + "FinalizeOutput 0/1\n", + "FinalizeOutput 1/1\n", + ) + ); +} + +#[test] +fn decrypt_command_rejects_invalid_arguments() { + let mut stderr = Vec::new(); + assert_eq!(decrypt::run(&[], &mut stderr), 1); + assert_eq!( + String::from_utf8(stderr).unwrap(), + concat!( + "USAGE: mp4forge decrypt --key [--key ...] [--fragments-info FILE] [--show-progress] INPUT OUTPUT\n", + "\n", + "OPTIONS:\n", + " --key Add one decryption key addressed by decimal track ID or 128-bit KID\n", + " --fragments-info Read matching initialization-segment bytes for standalone media-segment decrypt\n", + " --show-progress Write coarse decrypt progress snapshots to stderr\n", + "\n", + "Key syntax:\n", + " --key :\n", + " is either a track ID in decimal or a 128-bit KID in hex\n", + " is a 128-bit decryption key in hex\n", + " note: --fragments-info is typically the init segment when decrypting fragmented media segments\n", + ) + ); + + let mut stderr = Vec::new(); + assert_eq!( + decrypt::run( + &["input.mp4".to_string(), "output.mp4".to_string(),], + &mut stderr, + ), + 1 + ); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error: at least one --key is required\n" + ); + + let mut stderr = Vec::new(); + assert_eq!( + decrypt::run( + &[ + "--key".to_string(), + "bad".to_string(), + "input.mp4".to_string(), + "output.mp4".to_string(), + ], + &mut stderr, + ), + 1 + ); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error: invalid decryption key spec \"bad\": expected :\n" + ); +} + +fn assert_retained_file_fixture_cli_decrypts( + fixture: &RetainedDecryptFileFixture, + temp_prefix: &str, +) { + let output_path = write_temp_file(temp_prefix, &[]); + let expected = fs::read(&fixture.decrypted_path).unwrap(); + let mut args = vec!["decrypt".to_string()]; + for key in &fixture.keys { + args.push("--key".to_string()); + args.push(key.to_spec()); + } + args.push(fixture.encrypted_path.to_string_lossy().into_owned()); + args.push(output_path.to_string_lossy().into_owned()); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = cli::dispatch(&args, &mut stdout, &mut stderr); + let output = fs::read(&output_path).unwrap(); + + let _ = fs::remove_file(&output_path); + + assert_eq!(exit_code, 0, "stderr={}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!(output, expected); +} + +fn assert_retained_fragmented_fixture_cli_decrypts( + fixture: &RetainedFragmentedDecryptFixture, + temp_prefix: &str, +) { + let output_path = write_temp_file(temp_prefix, &[]); + let expected = fs::read(&fixture.clear_segment_path).unwrap(); + let mut args = vec!["decrypt".to_string()]; + for key in &fixture.keys { + args.push("--key".to_string()); + args.push(key.to_spec()); + } + args.push("--fragments-info".to_string()); + args.push(fixture.fragments_info_path.to_string_lossy().into_owned()); + args.push( + fixture + .encrypted_segment_path + .to_string_lossy() + .into_owned(), + ); + args.push(output_path.to_string_lossy().into_owned()); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = cli::dispatch(&args, &mut stdout, &mut stderr); + let output = fs::read(&output_path).unwrap(); + + let _ = fs::remove_file(&output_path); + + assert_eq!(exit_code, 0, "stderr={}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!(output, expected); +} + +fn assert_generated_topology_fixture_cli_decrypts( + fixture: ProtectedMovieTopologyFixture, + temp_prefix: &str, +) { + let input_path = write_temp_file(temp_prefix, &fixture.encrypted); + let output_path = write_temp_file(&format!("{temp_prefix}-output"), &[]); + let mut args = vec!["decrypt".to_string()]; + for key in &fixture.keys { + args.push("--key".to_string()); + args.push(key.to_spec()); + } + args.push(input_path.to_string_lossy().into_owned()); + args.push(output_path.to_string_lossy().into_owned()); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = cli::dispatch(&args, &mut stdout, &mut stderr); + let output = fs::read(&output_path).unwrap(); + + let _ = fs::remove_file(&input_path); + let _ = fs::remove_file(&output_path); + + assert_eq!(exit_code, 0, "stderr={}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!(output, fixture.decrypted); +} + +fn assert_generated_topology_fixture_cli_rejects_first_sample_description_limit( + fixture: ProtectedMovieTopologyFixture, + temp_prefix: &str, +) { + let input_path = write_temp_file(temp_prefix, &fixture.encrypted); + let output_path = write_temp_file(&format!("{temp_prefix}-output"), &[]); + let mut args = vec!["decrypt".to_string()]; + for key in &fixture.keys { + args.push("--key".to_string()); + args.push(key.to_spec()); + } + args.push(input_path.to_string_lossy().into_owned()); + args.push(output_path.to_string_lossy().into_owned()); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = cli::dispatch(&args, &mut stdout, &mut stderr); + let stderr_text = String::from_utf8(stderr).unwrap(); + + let _ = fs::remove_file(&input_path); + let _ = fs::remove_file(&output_path); + + assert_eq!(exit_code, 1, "stderr={stderr_text}"); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert!( + stderr_text.contains("only supports the first protected sample description"), + "unexpected stderr: {stderr_text}" + ); +} + +macro_rules! common_encryption_fragment_cli_case { + ($test_name:ident, $directory:literal, $track:literal, $prefix:literal) => { + #[test] + fn $test_name() { + let fixture = common_encryption_fragment_fixture($directory, $track); + assert_retained_fragmented_fixture_cli_decrypts(&fixture, $prefix); + } + }; +} + +#[test] +fn decrypt_command_supports_retained_oma_dcf_ctr_movie_files() { + assert_retained_file_fixture_cli_decrypts(&oma_dcf_ctr_fixture(), "cli-decrypt-oma-ctr-output"); +} + +#[test] +fn decrypt_command_supports_broader_oma_dcf_movie_layouts() { + assert_generated_topology_fixture_cli_decrypts( + build_oma_dcf_broader_movie_fixture(), + "cli-decrypt-oma-broader-input", + ); +} + +#[test] +fn decrypt_command_rejects_oma_dcf_movie_sample_description_indices_beyond_the_first_entry() { + assert_generated_topology_fixture_cli_rejects_first_sample_description_limit( + build_oma_dcf_sample_description_index_unsupported_movie_fixture(), + "cli-decrypt-oma-sample-description-index-input", + ); +} + +#[test] +fn decrypt_command_supports_retained_piff_ctr_movie_files() { + assert_retained_file_fixture_cli_decrypts(&piff_ctr_fixture(), "cli-decrypt-piff-ctr-output"); +} + +#[test] +fn decrypt_command_supports_retained_piff_cbc_movie_files() { + assert_retained_file_fixture_cli_decrypts(&piff_cbc_fixture(), "cli-decrypt-piff-cbc-output"); +} + +#[test] +fn decrypt_command_supports_retained_piff_ctr_media_segments() { + assert_retained_fragmented_fixture_cli_decrypts( + &piff_ctr_segment_fixture(), + "cli-decrypt-piff-ctr-segment-output", + ); +} + +#[test] +fn decrypt_command_supports_retained_piff_cbc_media_segments() { + assert_retained_fragmented_fixture_cli_decrypts( + &piff_cbc_segment_fixture(), + "cli-decrypt-piff-cbc-segment-output", + ); +} + +#[test] +fn decrypt_command_supports_retained_oma_dcf_cbc_movie_files() { + assert_retained_file_fixture_cli_decrypts(&oma_dcf_cbc_fixture(), "cli-decrypt-oma-cbc-output"); +} + +#[test] +fn decrypt_command_supports_retained_oma_dcf_ctr_grouped_atom_files() { + assert_retained_file_fixture_cli_decrypts( + &oma_dcf_ctr_grpi_fixture(), + "cli-decrypt-oma-ctr-grpi-output", + ); +} + +#[test] +fn decrypt_command_supports_retained_oma_dcf_cbc_grouped_atom_files() { + assert_retained_file_fixture_cli_decrypts( + &oma_dcf_cbc_grpi_fixture(), + "cli-decrypt-oma-cbc-grpi-output", + ); +} + +#[test] +fn decrypt_command_supports_retained_isma_iaec_movie_files() { + assert_retained_file_fixture_cli_decrypts(&isma_iaec_fixture(), "cli-decrypt-iaec-output"); +} + +#[test] +fn decrypt_command_rejects_iaec_movie_sample_description_indices_beyond_the_first_entry() { + assert_generated_topology_fixture_cli_rejects_first_sample_description_limit( + build_iaec_sample_description_index_unsupported_movie_fixture(), + "cli-decrypt-iaec-sample-description-index-input", + ); +} + +#[test] +fn decrypt_command_supports_broader_iaec_movie_layouts() { + assert_generated_topology_fixture_cli_decrypts( + build_iaec_broader_movie_fixture(), + "cli-decrypt-iaec-broader-input", + ); +} + +#[test] +fn decrypt_command_supports_retained_marlin_ipmp_acbc_movie_files() { + assert_retained_file_fixture_cli_decrypts( + &marlin_ipmp_acbc_fixture(), + "cli-decrypt-marlin-acbc-output", + ); +} + +#[test] +fn decrypt_command_supports_broader_marlin_ipmp_acbc_movie_layouts() { + assert_generated_topology_fixture_cli_decrypts( + build_marlin_ipmp_acbc_broader_movie_fixture(), + "cli-decrypt-marlin-acbc-broader-input", + ); +} + +#[test] +fn decrypt_command_supports_marlin_ipmp_acbc_od_track_sample_description_indices() { + assert_generated_topology_fixture_cli_decrypts( + build_marlin_ipmp_acbc_sample_description_index_movie_fixture(), + "cli-decrypt-marlin-acbc-stsc-input", + ); +} + +#[test] +fn decrypt_command_supports_retained_marlin_ipmp_acgk_movie_files() { + assert_retained_file_fixture_cli_decrypts( + &marlin_ipmp_acgk_fixture(), + "cli-decrypt-marlin-acgk-output", + ); +} + +#[test] +fn decrypt_command_supports_broader_marlin_ipmp_acgk_movie_layouts() { + assert_generated_topology_fixture_cli_decrypts( + build_marlin_ipmp_acgk_broader_movie_fixture(), + "cli-decrypt-marlin-acgk-broader-input", + ); +} + +#[test] +fn decrypt_command_supports_marlin_ipmp_acgk_od_track_sample_description_indices() { + assert_generated_topology_fixture_cli_decrypts( + build_marlin_ipmp_acgk_sample_description_index_movie_fixture(), + "cli-decrypt-marlin-acgk-stsc-input", + ); +} + +#[test] +fn decrypt_command_supports_retained_common_encryption_multi_track_files() { + assert_retained_file_fixture_cli_decrypts( + &common_encryption_multi_track_fixture(), + "cli-decrypt-cenc-multi-track-output", + ); +} + +#[test] +fn decrypt_command_supports_multi_sample_entry_fragmented_tracks() { + let fixture = build_multi_sample_entry_decrypt_fixture(); + let input_path = write_temp_file("cli-decrypt-multi-entry-input", &fixture.single_file); + let output_path = write_temp_file("cli-decrypt-multi-entry-output", &[]); + let mut args = vec!["decrypt".to_string()]; + for key in &fixture.all_keys { + args.push("--key".to_string()); + args.push(key.to_spec()); + } + args.push(input_path.to_string_lossy().into_owned()); + args.push(output_path.to_string_lossy().into_owned()); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = cli::dispatch(&args, &mut stdout, &mut stderr); + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + + let output = fs::read(&output_path).unwrap(); + let _ = fs::remove_file(&input_path); + let _ = fs::remove_file(&output_path); + assert_eq!(output, fixture.decrypted_single_file); +} + +#[test] +fn decrypt_command_supports_zero_kid_multi_sample_entry_fragmented_tracks() { + let fixture = build_zero_kid_multi_sample_entry_decrypt_fixture(); + let input_path = write_temp_file( + "cli-decrypt-zero-kid-multi-entry-input", + &fixture.single_file, + ); + let output_path = write_temp_file("cli-decrypt-zero-kid-multi-entry-output", &[]); + let mut args = vec!["decrypt".to_string()]; + for key in &fixture.ordered_track_id_keys { + args.push("--key".to_string()); + args.push(key.to_spec()); + } + args.push(input_path.to_string_lossy().into_owned()); + args.push(output_path.to_string_lossy().into_owned()); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = cli::dispatch(&args, &mut stdout, &mut stderr); + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + + let output = fs::read(&output_path).unwrap(); + let _ = fs::remove_file(&input_path); + let _ = fs::remove_file(&output_path); + assert_eq!(output, fixture.decrypted_single_file); +} + +common_encryption_fragment_cli_case!( + decrypt_command_supports_retained_cenc_single_video_media_segments, + "cenc-single", + "video", + "cli-decrypt-cenc-single-video-segment-output" +); +common_encryption_fragment_cli_case!( + decrypt_command_supports_retained_cenc_single_audio_media_segments, + "cenc-single", + "audio", + "cli-decrypt-cenc-single-audio-segment-output" +); +common_encryption_fragment_cli_case!( + decrypt_command_supports_retained_cenc_multi_video_media_segments, + "cenc-multi", + "video", + "cli-decrypt-cenc-multi-video-segment-output" +); +common_encryption_fragment_cli_case!( + decrypt_command_supports_retained_cenc_multi_audio_media_segments, + "cenc-multi", + "audio", + "cli-decrypt-cenc-multi-audio-segment-output" +); +common_encryption_fragment_cli_case!( + decrypt_command_supports_retained_cens_single_video_media_segments, + "cens-single", + "video", + "cli-decrypt-cens-single-video-segment-output" +); +common_encryption_fragment_cli_case!( + decrypt_command_supports_retained_cens_single_audio_media_segments, + "cens-single", + "audio", + "cli-decrypt-cens-single-audio-segment-output" +); +common_encryption_fragment_cli_case!( + decrypt_command_supports_retained_cens_multi_video_media_segments, + "cens-multi", + "video", + "cli-decrypt-cens-multi-video-segment-output" +); +common_encryption_fragment_cli_case!( + decrypt_command_supports_retained_cens_multi_audio_media_segments, + "cens-multi", + "audio", + "cli-decrypt-cens-multi-audio-segment-output" +); +common_encryption_fragment_cli_case!( + decrypt_command_supports_retained_cbc1_single_video_media_segments, + "cbc1-single", + "video", + "cli-decrypt-cbc1-single-video-segment-output" +); +common_encryption_fragment_cli_case!( + decrypt_command_supports_retained_cbc1_single_audio_media_segments, + "cbc1-single", + "audio", + "cli-decrypt-cbc1-single-audio-segment-output" +); +common_encryption_fragment_cli_case!( + decrypt_command_supports_retained_cbc1_multi_video_media_segments, + "cbc1-multi", + "video", + "cli-decrypt-cbc1-multi-video-segment-output" +); +common_encryption_fragment_cli_case!( + decrypt_command_supports_retained_cbc1_multi_audio_media_segments, + "cbc1-multi", + "audio", + "cli-decrypt-cbc1-multi-audio-segment-output" +); +common_encryption_fragment_cli_case!( + decrypt_command_supports_retained_cbcs_single_video_media_segments, + "cbcs-single", + "video", + "cli-decrypt-cbcs-single-video-segment-output" +); +common_encryption_fragment_cli_case!( + decrypt_command_supports_retained_cbcs_single_audio_media_segments, + "cbcs-single", + "audio", + "cli-decrypt-cbcs-single-audio-segment-output" +); +common_encryption_fragment_cli_case!( + decrypt_command_supports_retained_cbcs_multi_video_media_segments, + "cbcs-multi", + "video", + "cli-decrypt-cbcs-multi-video-segment-output" +); +common_encryption_fragment_cli_case!( + decrypt_command_supports_retained_cbcs_multi_audio_media_segments, + "cbcs-multi", + "audio", + "cli-decrypt-cbcs-multi-audio-segment-output" +); diff --git a/tests/cli_dispatch.rs b/tests/cli_dispatch.rs index ecc1caf..6432e63 100644 --- a/tests/cli_dispatch.rs +++ b/tests/cli_dispatch.rs @@ -6,20 +6,7 @@ fn dispatch_prints_usage_for_empty_or_unknown_commands() { let mut stderr = Vec::new(); assert_eq!(cli::dispatch(&[], &mut stdout, &mut stderr), 1); assert_eq!(String::from_utf8(stdout).unwrap(), ""); - assert_eq!( - String::from_utf8(stderr).unwrap(), - concat!( - "USAGE: mp4forge COMMAND [ARGS]\n", - "\n", - "COMMAND:\n", - " divide split a fragmented MP4 into track playlists\n", - " dump display the MP4 box tree\n", - " edit rewrite selected boxes\n", - " extract extract raw boxes by type or path\n", - " psshdump summarize pssh boxes\n", - " probe summarize an MP4 file\n" - ) - ); + assert_eq!(String::from_utf8(stderr).unwrap(), top_level_usage()); let mut stdout = Vec::new(); let mut stderr = Vec::new(); @@ -28,20 +15,7 @@ fn dispatch_prints_usage_for_empty_or_unknown_commands() { 1 ); assert_eq!(String::from_utf8(stdout).unwrap(), ""); - assert_eq!( - String::from_utf8(stderr).unwrap(), - concat!( - "USAGE: mp4forge COMMAND [ARGS]\n", - "\n", - "COMMAND:\n", - " divide split a fragmented MP4 into track playlists\n", - " dump display the MP4 box tree\n", - " edit rewrite selected boxes\n", - " extract extract raw boxes by type or path\n", - " psshdump summarize pssh boxes\n", - " probe summarize an MP4 file\n" - ) - ); + assert_eq!(String::from_utf8(stderr).unwrap(), top_level_usage()); } #[test] @@ -53,18 +27,50 @@ fn dispatch_handles_help() { 0 ); assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!(String::from_utf8(stderr).unwrap(), top_level_usage()); +} + +#[cfg(not(feature = "decrypt"))] +#[test] +fn dispatch_keeps_decrypt_unavailable_without_feature() { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); assert_eq!( - String::from_utf8(stderr).unwrap(), + cli::dispatch(&["decrypt".to_string()], &mut stdout, &mut stderr), + 1 + ); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!(String::from_utf8(stderr).unwrap(), top_level_usage()); +} + +fn top_level_usage() -> &'static str { + #[cfg(feature = "decrypt")] + { concat!( "USAGE: mp4forge COMMAND [ARGS]\n", "\n", "COMMAND:\n", " divide split a fragmented MP4 into track playlists\n", + " decrypt decrypt protected MP4-family content\n", " dump display the MP4 box tree\n", " edit rewrite selected boxes\n", " extract extract raw boxes by type or path\n", " psshdump summarize pssh boxes\n", " probe summarize an MP4 file\n" ) - ); + } + #[cfg(not(feature = "decrypt"))] + { + concat!( + "USAGE: mp4forge COMMAND [ARGS]\n", + "\n", + "COMMAND:\n", + " divide split a fragmented MP4 into track playlists\n", + " dump display the MP4 box tree\n", + " edit rewrite selected boxes\n", + " extract extract raw boxes by type or path\n", + " psshdump summarize pssh boxes\n", + " probe summarize an MP4 file\n" + ) + } } diff --git a/tests/decrypt.rs b/tests/decrypt.rs new file mode 100644 index 0000000..92a28be --- /dev/null +++ b/tests/decrypt.rs @@ -0,0 +1,574 @@ +#![cfg(feature = "decrypt")] + +use aes::Aes128; +use aes::cipher::{Block, BlockEncrypt, KeyInit}; + +use mp4forge::FourCc; +use mp4forge::boxes::iso23001_7::SencSubsample; +use mp4forge::decrypt::{ + CommonEncryptionDecryptError, DecryptionKey, NativeCommonEncryptionScheme, + decrypt_common_encryption_sample, decrypt_common_encryption_sample_by_scheme_type_with_keys, + decrypt_common_encryption_sample_with_keys, select_decryption_key, +}; +use mp4forge::encryption::{ResolvedSampleEncryptionSample, ResolvedSampleEncryptionSource}; + +#[test] +fn decrypt_cenc_audio_fragment_roundtrips_full_sample_ctr() { + let key = [0x11; 16]; + let sample = resolved_sample(SampleSpec { + is_protected: true, + initialization_vector: vec![1, 2, 3, 4, 5, 6, 7, 8], + constant_iv: None, + per_sample_iv_size: Some(8), + crypt_byte_block: 0, + skip_byte_block: 0, + kid: [0xaa; 16], + subsamples: vec![], + }); + let plaintext = (0u8..37).collect::>(); + let ciphertext = encrypt_sample(NativeCommonEncryptionScheme::Cenc, key, &sample, &plaintext); + + let decrypted = decrypt_common_encryption_sample( + NativeCommonEncryptionScheme::Cenc, + key, + &sample, + &ciphertext, + ) + .unwrap(); + assert_eq!(decrypted, plaintext); +} + +#[test] +fn decrypt_cens_video_fragment_keeps_pattern_position_across_subsamples() { + let key = [0x22; 16]; + let sample = resolved_sample(SampleSpec { + is_protected: true, + initialization_vector: vec![9, 8, 7, 6, 5, 4, 3, 2], + constant_iv: None, + per_sample_iv_size: Some(8), + crypt_byte_block: 1, + skip_byte_block: 1, + kid: [0xbb; 16], + subsamples: vec![ + SencSubsample { + bytes_of_clear_data: 4, + bytes_of_protected_data: 48, + }, + SencSubsample { + bytes_of_clear_data: 2, + bytes_of_protected_data: 32, + }, + ], + }); + let plaintext = (0u8..86) + .map(|value| value.wrapping_mul(3)) + .collect::>(); + let ciphertext = encrypt_sample(NativeCommonEncryptionScheme::Cens, key, &sample, &plaintext); + + let decrypted = decrypt_common_encryption_sample( + NativeCommonEncryptionScheme::Cens, + key, + &sample, + &ciphertext, + ) + .unwrap(); + assert_eq!(decrypted, plaintext); +} + +#[test] +fn decrypt_cbc1_audio_fragment_leaves_partial_tail_clear() { + let key = [0x33; 16]; + let sample = resolved_sample(SampleSpec { + is_protected: true, + initialization_vector: vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + constant_iv: None, + per_sample_iv_size: Some(16), + crypt_byte_block: 0, + skip_byte_block: 0, + kid: [0xcc; 16], + subsamples: vec![], + }); + let plaintext = (0u8..37).map(|value| value ^ 0x5a).collect::>(); + let ciphertext = encrypt_sample(NativeCommonEncryptionScheme::Cbc1, key, &sample, &plaintext); + + let decrypted = decrypt_common_encryption_sample( + NativeCommonEncryptionScheme::Cbc1, + key, + &sample, + &ciphertext, + ) + .unwrap(); + assert_eq!(decrypted, plaintext); + assert_eq!(&ciphertext[32..], &plaintext[32..]); +} + +#[test] +fn decrypt_cbcs_video_fragment_resets_iv_at_each_subsample() { + let key = [0x44; 16]; + let sample = resolved_sample(SampleSpec { + is_protected: true, + initialization_vector: vec![], + constant_iv: Some(vec![ + 0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc, 0xfe, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, + 0xcd, 0xef, + ]), + per_sample_iv_size: None, + crypt_byte_block: 1, + skip_byte_block: 1, + kid: [0xdd; 16], + subsamples: vec![ + SencSubsample { + bytes_of_clear_data: 4, + bytes_of_protected_data: 48, + }, + SencSubsample { + bytes_of_clear_data: 2, + bytes_of_protected_data: 32, + }, + ], + }); + let plaintext = (0u8..86) + .map(|value| value.wrapping_mul(5)) + .collect::>(); + let ciphertext = encrypt_sample(NativeCommonEncryptionScheme::Cbcs, key, &sample, &plaintext); + + let decrypted = decrypt_common_encryption_sample( + NativeCommonEncryptionScheme::Cbcs, + key, + &sample, + &ciphertext, + ) + .unwrap(); + assert_eq!(decrypted, plaintext); +} + +#[test] +fn decrypt_with_keys_prefers_track_id_before_kid() { + let track_key = [0x55; 16]; + let kid_key = [0x66; 16]; + let sample = resolved_sample(SampleSpec { + is_protected: true, + initialization_vector: vec![1, 1, 1, 1, 1, 1, 1, 1], + constant_iv: None, + per_sample_iv_size: Some(8), + crypt_byte_block: 0, + skip_byte_block: 0, + kid: [0xee; 16], + subsamples: vec![], + }); + let keys = vec![ + DecryptionKey::kid([0xee; 16], kid_key), + DecryptionKey::track(7, track_key), + ]; + let plaintext = (0u8..32).collect::>(); + let ciphertext = encrypt_sample( + NativeCommonEncryptionScheme::Cenc, + track_key, + &sample, + &plaintext, + ); + + assert_eq!( + select_decryption_key(&keys, Some(7), &sample).unwrap(), + track_key + ); + let decrypted = decrypt_common_encryption_sample_with_keys( + NativeCommonEncryptionScheme::Cenc, + Some(7), + &keys, + &sample, + &ciphertext, + ) + .unwrap(); + assert_eq!(decrypted, plaintext); +} + +#[test] +fn decrypt_with_keys_falls_back_to_kid_for_multi_key_layouts() { + let key = [0x77; 16]; + let sample = resolved_sample(SampleSpec { + is_protected: true, + initialization_vector: vec![2, 2, 2, 2, 2, 2, 2, 2], + constant_iv: None, + per_sample_iv_size: Some(8), + crypt_byte_block: 0, + skip_byte_block: 0, + kid: [0xfa; 16], + subsamples: vec![], + }); + let keys = vec![DecryptionKey::kid([0xfa; 16], key)]; + let plaintext = (0u8..48).map(|value| value ^ 0xa5).collect::>(); + let ciphertext = encrypt_sample(NativeCommonEncryptionScheme::Cenc, key, &sample, &plaintext); + + let decrypted = decrypt_common_encryption_sample_by_scheme_type_with_keys( + FourCc::from_bytes(*b"cenc"), + None, + &keys, + &sample, + &ciphertext, + ) + .unwrap(); + assert_eq!(decrypted, plaintext); +} + +#[test] +fn decrypt_reports_missing_key_and_invalid_iv_and_invalid_regions() { + let sample = resolved_sample(SampleSpec { + is_protected: true, + initialization_vector: vec![1, 2, 3, 4, 5, 6, 7, 8], + constant_iv: None, + per_sample_iv_size: Some(8), + crypt_byte_block: 0, + skip_byte_block: 0, + kid: [0x12; 16], + subsamples: vec![], + }); + let missing = decrypt_common_encryption_sample_with_keys( + NativeCommonEncryptionScheme::Cenc, + Some(99), + &[], + &sample, + &[0u8; 8], + ) + .unwrap_err(); + assert_eq!( + missing, + CommonEncryptionDecryptError::MissingDecryptionKey { + track_id: Some(99), + kid: [0x12; 16], + } + ); + + let invalid_iv = decrypt_common_encryption_sample( + NativeCommonEncryptionScheme::Cbc1, + [0x88; 16], + &sample, + &[0u8; 8], + ) + .unwrap_err(); + assert_eq!( + invalid_iv, + CommonEncryptionDecryptError::InvalidInitializationVectorSize { + scheme: NativeCommonEncryptionScheme::Cbc1, + actual: 8, + expected: "exactly 16", + } + ); + + let invalid_region_sample = resolved_sample(SampleSpec { + is_protected: true, + initialization_vector: vec![1, 2, 3, 4, 5, 6, 7, 8], + constant_iv: None, + per_sample_iv_size: Some(8), + crypt_byte_block: 0, + skip_byte_block: 0, + kid: [0x34; 16], + subsamples: vec![SencSubsample { + bytes_of_clear_data: 4, + bytes_of_protected_data: 16, + }], + }); + let invalid_region = decrypt_common_encryption_sample( + NativeCommonEncryptionScheme::Cenc, + [0x99; 16], + &invalid_region_sample, + &[0u8; 8], + ) + .unwrap_err(); + assert_eq!( + invalid_region, + CommonEncryptionDecryptError::InvalidProtectedRegion { + remaining: 8, + clear_bytes: 4, + protected_bytes: 16, + } + ); +} + +#[test] +fn decrypt_by_scheme_type_rejects_non_native_codes() { + let sample = resolved_sample(SampleSpec { + is_protected: false, + initialization_vector: vec![], + constant_iv: None, + per_sample_iv_size: None, + crypt_byte_block: 0, + skip_byte_block: 0, + kid: [0u8; 16], + subsamples: vec![], + }); + let error = decrypt_common_encryption_sample_by_scheme_type_with_keys( + FourCc::from_bytes(*b"piff"), + None, + &[], + &sample, + &[], + ) + .unwrap_err(); + assert_eq!( + error, + CommonEncryptionDecryptError::UnsupportedNativeSchemeType { + scheme_type: FourCc::from_bytes(*b"piff"), + } + ); +} + +struct SampleSpec { + is_protected: bool, + initialization_vector: Vec, + constant_iv: Option>, + per_sample_iv_size: Option, + crypt_byte_block: u8, + skip_byte_block: u8, + kid: [u8; 16], + subsamples: Vec, +} + +#[derive(Clone, Copy)] +struct EncryptPattern { + crypt_byte_block: u8, + skip_byte_block: u8, +} + +struct EncryptState { + ctr_offset: u64, + pattern_offset: u64, + chain_block: [u8; 16], +} + +fn resolved_sample(spec: SampleSpec) -> ResolvedSampleEncryptionSample<'static> { + let initialization_vector = Box::leak(spec.initialization_vector.into_boxed_slice()); + let constant_iv = spec + .constant_iv + .map(|bytes| Box::leak(bytes.into_boxed_slice()) as &'static [u8]); + let subsamples = Box::leak(spec.subsamples.into_boxed_slice()); + ResolvedSampleEncryptionSample { + sample_index: 1, + metadata_source: ResolvedSampleEncryptionSource::TrackEncryptionBox, + is_protected: spec.is_protected, + crypt_byte_block: spec.crypt_byte_block, + skip_byte_block: spec.skip_byte_block, + per_sample_iv_size: spec.per_sample_iv_size, + initialization_vector, + constant_iv, + kid: spec.kid, + subsamples, + auxiliary_info_size: 0, + } +} + +fn encrypt_sample( + scheme: NativeCommonEncryptionScheme, + key: [u8; 16], + sample: &ResolvedSampleEncryptionSample<'_>, + plaintext: &[u8], +) -> Vec { + if !sample.is_protected { + return plaintext.to_vec(); + } + + let iv = sample.effective_initialization_vector(); + let pattern = EncryptPattern { + crypt_byte_block: sample.crypt_byte_block, + skip_byte_block: sample.skip_byte_block, + }; + let mut output = plaintext.to_vec(); + if sample.subsamples.is_empty() { + encrypt_region( + scheme, + key, + iv.try_into().unwrap_or_else(|_| { + let mut padded = [0u8; 16]; + padded[..iv.len()].copy_from_slice(iv); + padded + }), + pattern, + plaintext, + &mut output, + ); + return output; + } + + let iv_block = if iv.len() == 16 { + iv.try_into().unwrap() + } else { + let mut padded = [0u8; 16]; + padded[..iv.len()].copy_from_slice(iv); + padded + }; + let mut cursor = 0usize; + let mut state = EncryptState { + ctr_offset: 0, + pattern_offset: 0, + chain_block: iv_block, + }; + for subsample in sample.subsamples { + let clear = usize::from(subsample.bytes_of_clear_data); + cursor += clear; + let protected = usize::try_from(subsample.bytes_of_protected_data).unwrap(); + if scheme == NativeCommonEncryptionScheme::Cbcs { + state.ctr_offset = 0; + state.pattern_offset = 0; + state.chain_block = iv_block; + } + encrypt_region_with_state( + scheme, + key, + iv_block, + pattern, + &mut state, + &plaintext[cursor..cursor + protected], + &mut output[cursor..cursor + protected], + ); + cursor += protected; + } + output +} + +fn encrypt_region( + scheme: NativeCommonEncryptionScheme, + key: [u8; 16], + iv: [u8; 16], + pattern: EncryptPattern, + plaintext: &[u8], + output: &mut [u8], +) { + let mut state = EncryptState { + ctr_offset: 0, + pattern_offset: 0, + chain_block: iv, + }; + encrypt_region_with_state(scheme, key, iv, pattern, &mut state, plaintext, output); +} + +fn encrypt_region_with_state( + scheme: NativeCommonEncryptionScheme, + key: [u8; 16], + iv: [u8; 16], + pattern: EncryptPattern, + state: &mut EncryptState, + plaintext: &[u8], + output: &mut [u8], +) { + if pattern.crypt_byte_block != 0 && pattern.skip_byte_block != 0 { + let pattern_span = + usize::from(pattern.crypt_byte_block) + usize::from(pattern.skip_byte_block); + let mut cursor = 0usize; + while cursor < plaintext.len() { + let block_position = usize::try_from(state.pattern_offset / 16).unwrap(); + let pattern_position = block_position % pattern_span; + let mut crypt_size = 0usize; + let mut skip_size = usize::from(pattern.skip_byte_block) * 16; + if pattern_position < usize::from(pattern.crypt_byte_block) { + crypt_size = (usize::from(pattern.crypt_byte_block) - pattern_position) * 16; + } else { + skip_size = (pattern_span - pattern_position) * 16; + } + + let remain = plaintext.len() - cursor; + if crypt_size > remain { + crypt_size = 16 * (remain / 16); + skip_size = remain - crypt_size; + } + if crypt_size + skip_size > remain { + skip_size = remain - crypt_size; + } + + if crypt_size != 0 { + encrypt_chunk( + scheme, + key, + iv, + &mut state.ctr_offset, + &mut state.chain_block, + &plaintext[cursor..cursor + crypt_size], + &mut output[cursor..cursor + crypt_size], + ); + cursor += crypt_size; + state.pattern_offset += crypt_size as u64; + } + + if skip_size != 0 { + output[cursor..cursor + skip_size] + .copy_from_slice(&plaintext[cursor..cursor + skip_size]); + cursor += skip_size; + state.pattern_offset += skip_size as u64; + } + } + } else { + encrypt_chunk( + scheme, + key, + iv, + &mut state.ctr_offset, + &mut state.chain_block, + plaintext, + output, + ); + } +} + +fn encrypt_chunk( + scheme: NativeCommonEncryptionScheme, + key: [u8; 16], + iv: [u8; 16], + ctr_offset: &mut u64, + chain_block: &mut [u8; 16], + plaintext: &[u8], + output: &mut [u8], +) { + match scheme { + NativeCommonEncryptionScheme::Cenc | NativeCommonEncryptionScheme::Cens => { + let aes = Aes128::new(&key.into()); + let mut cursor = 0usize; + while cursor < plaintext.len() { + let block_offset = usize::try_from(*ctr_offset % 16).unwrap(); + let chunk_len = (16 - block_offset).min(plaintext.len() - cursor); + let mut counter_block = compute_ctr_counter_block(iv, *ctr_offset); + aes.encrypt_block(&mut counter_block); + for index in 0..chunk_len { + output[cursor + index] = + plaintext[cursor + index] ^ counter_block[block_offset + index]; + } + cursor += chunk_len; + *ctr_offset += chunk_len as u64; + } + } + NativeCommonEncryptionScheme::Cbc1 | NativeCommonEncryptionScheme::Cbcs => { + let aes = Aes128::new(&key.into()); + let full_blocks_len = plaintext.len() - (plaintext.len() % 16); + let mut cursor = 0usize; + while cursor < full_blocks_len { + let mut block = Block::::clone_from_slice(&plaintext[cursor..cursor + 16]); + for index in 0..16 { + block[index] ^= chain_block[index]; + } + aes.encrypt_block(&mut block); + output[cursor..cursor + 16].copy_from_slice(&block); + chain_block.copy_from_slice(&block); + cursor += 16; + } + output[full_blocks_len..].copy_from_slice(&plaintext[full_blocks_len..]); + } + } +} + +fn compute_ctr_counter_block(iv: [u8; 16], stream_offset: u64) -> Block { + let counter_offset = stream_offset / 16; + let counter_offset_bytes = counter_offset.to_be_bytes(); + let mut counter_block = Block::::default(); + + let mut carry = 0u16; + for index in 0..8 { + let offset = 15 - index; + let sum = u16::from(iv[offset]) + u16::from(counter_offset_bytes[7 - index]) + carry; + counter_block[offset] = (sum & 0xff) as u8; + carry = if sum >= 0x100 { 1 } else { 0 }; + } + for index in 8..16 { + let offset = 15 - index; + counter_block[offset] = iv[offset]; + } + + counter_block +} diff --git a/tests/decrypt_api.rs b/tests/decrypt_api.rs new file mode 100644 index 0000000..079d624 --- /dev/null +++ b/tests/decrypt_api.rs @@ -0,0 +1,685 @@ +#![cfg(feature = "decrypt")] + +mod support; + +use std::fs; +use std::io::Cursor; + +use mp4forge::decrypt::{ + DecryptError, DecryptOptions, DecryptProgress, DecryptProgressPhase, DecryptRewriteError, + DecryptionKey, DecryptionKeyId, decrypt_bytes, decrypt_bytes_with_progress, + decrypt_file_with_progress, +}; +use mp4forge::extract::extract_box_payload_bytes; +use mp4forge::probe::probe_detailed; +use mp4forge::walk::BoxPath; + +use support::{ + ProtectedMovieTopologyFixture, RetainedDecryptFileFixture, RetainedFragmentedDecryptFixture, + build_decrypt_rewrite_fixture, build_iaec_broader_movie_fixture, + build_iaec_sample_description_index_unsupported_movie_fixture, + build_marlin_ipmp_acbc_broader_movie_fixture, + build_marlin_ipmp_acbc_sample_description_index_movie_fixture, + build_marlin_ipmp_acgk_broader_movie_fixture, + build_marlin_ipmp_acgk_sample_description_index_movie_fixture, + build_multi_sample_entry_decrypt_fixture, build_oma_dcf_broader_movie_fixture, + build_oma_dcf_sample_description_index_unsupported_movie_fixture, + build_zero_kid_multi_sample_entry_decrypt_fixture, common_encryption_fragment_fixture, + common_encryption_multi_track_fixture, common_encryption_single_key_fixture_keys, fourcc, + isma_iaec_fixture, marlin_ipmp_acbc_fixture, marlin_ipmp_acgk_fixture, oma_dcf_cbc_fixture, + oma_dcf_cbc_grpi_fixture, oma_dcf_ctr_fixture, oma_dcf_ctr_grpi_fixture, piff_cbc_fixture, + piff_cbc_segment_fixture, piff_ctr_fixture, piff_ctr_segment_fixture, write_temp_file, +}; + +#[test] +fn decrypt_options_builder_accepts_repeated_keys_and_fragments_info() { + let options = DecryptOptions::new() + .with_key_spec("7:00112233445566778899aabbccddeeff") + .unwrap() + .with_key(DecryptionKey::kid([0xaa; 16], [0xbb; 16])) + .with_fragments_info_bytes([1_u8, 2, 3, 4]); + + assert_eq!(options.keys().len(), 2); + assert_eq!(options.keys()[0].id(), DecryptionKeyId::TrackId(7)); + assert_eq!( + options.keys()[0].key_bytes(), + [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, + 0xee, 0xff, + ] + ); + assert_eq!(options.keys()[1].id(), DecryptionKeyId::Kid([0xaa; 16])); + assert_eq!(options.fragments_info_bytes(), Some(&[1_u8, 2, 3, 4][..])); +} + +#[test] +fn decrypt_bytes_requires_fragments_info_for_standalone_media_segments() { + let fixture = build_decrypt_rewrite_fixture(); + + let error = decrypt_bytes( + &fixture.media_segment, + &options_with_keys(&fixture.all_keys), + ) + .unwrap_err(); + + assert!(matches!(error, DecryptError::MissingFragmentsInfo)); +} + +#[test] +fn decrypt_bytes_decrypts_standalone_media_segments_with_fragments_info() { + let fixture = build_decrypt_rewrite_fixture(); + let options = + options_with_keys(&fixture.all_keys).with_fragments_info_bytes(&fixture.init_segment); + let mut progress = Vec::new(); + + let output = decrypt_bytes_with_progress(&fixture.media_segment, &options, |snapshot| { + progress.push(snapshot); + }) + .unwrap(); + + let mdat_payloads = extract_box_payload_bytes( + &mut Cursor::new(output), + None, + BoxPath::from([fourcc("mdat")]), + ) + .unwrap(); + assert_eq!(mdat_payloads.len(), 1); + assert_eq!( + mdat_payloads[0], + [ + fixture.first_track_plaintext, + fixture.second_track_plaintext + ] + .concat() + ); + assert_eq!( + phases(&progress), + vec![ + DecryptProgressPhase::InspectStructure, + DecryptProgressPhase::InspectStructure, + DecryptProgressPhase::OpenFragmentsInfo, + DecryptProgressPhase::OpenFragmentsInfo, + DecryptProgressPhase::ProcessSamples, + DecryptProgressPhase::ProcessSamples, + DecryptProgressPhase::FinalizeOutput, + DecryptProgressPhase::FinalizeOutput, + ] + ); +} + +#[test] +fn decrypt_bytes_keeps_partial_decrypt_behavior_for_missing_keys() { + let fixture = build_decrypt_rewrite_fixture(); + + let output = decrypt_bytes( + &fixture.single_file, + &options_with_keys(&fixture.first_track_only_keys), + ) + .unwrap(); + + let detailed = probe_detailed(&mut Cursor::new(output)).unwrap(); + let tracks = detailed + .tracks + .into_iter() + .map(|track| (track.summary.track_id, track)) + .collect::>(); + + let first = tracks.get(&fixture.first_track_id).unwrap(); + assert!(!first.summary.encrypted); + assert_eq!(first.sample_entry_type, Some(fourcc("avc1"))); + + let second = tracks.get(&fixture.second_track_id).unwrap(); + assert!(second.summary.encrypted); + assert_eq!(second.sample_entry_type, Some(fourcc("encv"))); + assert_eq!(second.original_format, Some(fourcc("avc1"))); +} + +#[test] +fn decrypt_file_with_progress_writes_clear_output() { + let fixture = build_decrypt_rewrite_fixture(); + let input_path = write_temp_file("decrypt-api-input", &fixture.single_file); + let output_path = write_temp_file("decrypt-api-output", &[]); + let mut progress = Vec::new(); + + decrypt_file_with_progress( + &input_path, + &output_path, + &options_with_keys(&fixture.all_keys), + |snapshot| progress.push(snapshot), + ) + .unwrap(); + + let output = fs::read(output_path).unwrap(); + let detailed = probe_detailed(&mut Cursor::new(output)).unwrap(); + let tracks = detailed + .tracks + .into_iter() + .map(|track| (track.summary.track_id, track)) + .collect::>(); + assert_eq!(tracks.len(), 2); + for track_id in [fixture.first_track_id, fixture.second_track_id] { + let track = tracks.get(&track_id).unwrap(); + assert!(!track.summary.encrypted); + assert_eq!(track.sample_entry_type, Some(fourcc("avc1"))); + assert!(track.protection_scheme.is_none()); + } + + assert_eq!( + phases(&progress), + vec![ + DecryptProgressPhase::OpenInput, + DecryptProgressPhase::OpenInput, + DecryptProgressPhase::InspectStructure, + DecryptProgressPhase::InspectStructure, + DecryptProgressPhase::ProcessSamples, + DecryptProgressPhase::ProcessSamples, + DecryptProgressPhase::OpenOutput, + DecryptProgressPhase::OpenOutput, + DecryptProgressPhase::FinalizeOutput, + DecryptProgressPhase::FinalizeOutput, + ] + ); +} + +fn assert_retained_file_fixture_decrypts_bytes(fixture: &RetainedDecryptFileFixture) { + let input = fs::read(&fixture.encrypted_path).unwrap(); + let expected = fs::read(&fixture.decrypted_path).unwrap(); + let output = decrypt_bytes(&input, &options_with_keys(&fixture.keys)).unwrap(); + assert_eq!(output, expected); +} + +fn assert_retained_file_fixture_decrypts_with_progress( + fixture: &RetainedDecryptFileFixture, + temp_prefix: &str, +) { + let output_path = write_temp_file(temp_prefix, &[]); + let expected = fs::read(&fixture.decrypted_path).unwrap(); + let mut progress = Vec::new(); + + decrypt_file_with_progress( + &fixture.encrypted_path, + &output_path, + &options_with_keys(&fixture.keys), + |snapshot| progress.push(snapshot), + ) + .unwrap(); + + let output = fs::read(output_path).unwrap(); + assert_eq!(output, expected); + assert_eq!(phases(&progress), expected_file_progress_phases()); +} + +fn assert_retained_fragmented_fixture_decrypts_bytes(fixture: &RetainedFragmentedDecryptFixture) { + let segment = fs::read(&fixture.encrypted_segment_path).unwrap(); + let expected = fs::read(&fixture.clear_segment_path).unwrap(); + let fragments_info = fs::read(&fixture.fragments_info_path).unwrap(); + let options = options_with_keys(&fixture.keys).with_fragments_info_bytes(fragments_info); + + let output = decrypt_bytes(&segment, &options).unwrap(); + + assert_eq!(output, expected); +} + +fn assert_generated_topology_fixture_decrypts_bytes(fixture: ProtectedMovieTopologyFixture) { + let output = decrypt_bytes(&fixture.encrypted, &options_with_keys(&fixture.keys)).unwrap(); + assert_eq!(output, fixture.decrypted); +} + +fn assert_generated_topology_fixture_decrypts_with_progress( + fixture: ProtectedMovieTopologyFixture, + temp_prefix: &str, +) { + let input_path = write_temp_file(temp_prefix, &fixture.encrypted); + let output_path = write_temp_file(&format!("{temp_prefix}-output"), &[]); + let mut progress = Vec::new(); + + decrypt_file_with_progress( + &input_path, + &output_path, + &options_with_keys(&fixture.keys), + |snapshot| progress.push(snapshot), + ) + .unwrap(); + + let output = fs::read(&output_path).unwrap(); + assert_eq!(output, fixture.decrypted); + assert_eq!(phases(&progress), expected_file_progress_phases()); +} + +fn assert_generated_topology_fixture_rejects_first_sample_description_limit( + fixture: ProtectedMovieTopologyFixture, +) { + let error = decrypt_bytes(&fixture.encrypted, &options_with_keys(&fixture.keys)).unwrap_err(); + + match error { + DecryptError::Rewrite(DecryptRewriteError::InvalidLayout { reason }) => { + assert!( + reason.contains("only supports the first protected sample description"), + "unexpected invalid-layout reason: {reason}" + ); + } + other => panic!("expected invalid-layout rejection, got {other}"), + } +} + +macro_rules! common_encryption_fragment_bytes_case { + ($test_name:ident, $directory:literal, $track:literal) => { + #[test] + fn $test_name() { + let fixture = common_encryption_fragment_fixture($directory, $track); + assert_retained_fragmented_fixture_decrypts_bytes(&fixture); + } + }; +} + +#[test] +fn decrypt_bytes_supports_retained_oma_dcf_ctr_movie_files() { + assert_retained_file_fixture_decrypts_bytes(&oma_dcf_ctr_fixture()); +} + +#[test] +fn decrypt_bytes_supports_broader_oma_dcf_movie_layouts() { + assert_generated_topology_fixture_decrypts_bytes(build_oma_dcf_broader_movie_fixture()); +} + +#[test] +fn decrypt_file_with_progress_supports_broader_oma_dcf_movie_layouts() { + assert_generated_topology_fixture_decrypts_with_progress( + build_oma_dcf_broader_movie_fixture(), + "decrypt-api-oma-broader-input", + ); +} + +#[test] +fn decrypt_bytes_rejects_oma_dcf_movie_sample_description_indices_beyond_the_first_entry() { + assert_generated_topology_fixture_rejects_first_sample_description_limit( + build_oma_dcf_sample_description_index_unsupported_movie_fixture(), + ); +} + +#[test] +fn decrypt_bytes_supports_retained_piff_ctr_movie_files() { + assert_retained_file_fixture_decrypts_bytes(&piff_ctr_fixture()); +} + +#[test] +fn decrypt_file_with_progress_supports_retained_piff_ctr_movie_files() { + assert_retained_file_fixture_decrypts_with_progress( + &piff_ctr_fixture(), + "decrypt-api-piff-ctr-output", + ); +} + +#[test] +fn decrypt_bytes_supports_retained_piff_cbc_movie_files() { + assert_retained_file_fixture_decrypts_bytes(&piff_cbc_fixture()); +} + +#[test] +fn decrypt_file_with_progress_supports_retained_piff_cbc_movie_files() { + assert_retained_file_fixture_decrypts_with_progress( + &piff_cbc_fixture(), + "decrypt-api-piff-cbc-output", + ); +} + +#[test] +fn decrypt_bytes_supports_retained_piff_ctr_media_segments() { + assert_retained_fragmented_fixture_decrypts_bytes(&piff_ctr_segment_fixture()); +} + +#[test] +fn decrypt_bytes_supports_retained_piff_cbc_media_segments() { + assert_retained_fragmented_fixture_decrypts_bytes(&piff_cbc_segment_fixture()); +} + +#[test] +fn decrypt_file_with_progress_supports_retained_oma_dcf_ctr_movie_files() { + assert_retained_file_fixture_decrypts_with_progress( + &oma_dcf_ctr_fixture(), + "decrypt-api-oma-ctr-output", + ); +} + +#[test] +fn decrypt_bytes_supports_retained_oma_dcf_cbc_movie_files() { + assert_retained_file_fixture_decrypts_bytes(&oma_dcf_cbc_fixture()); +} + +#[test] +fn decrypt_bytes_supports_retained_oma_dcf_ctr_grouped_atom_files() { + assert_retained_file_fixture_decrypts_bytes(&oma_dcf_ctr_grpi_fixture()); +} + +#[test] +fn decrypt_file_with_progress_supports_retained_oma_dcf_ctr_grouped_atom_files() { + assert_retained_file_fixture_decrypts_with_progress( + &oma_dcf_ctr_grpi_fixture(), + "decrypt-api-oma-ctr-grpi-output", + ); +} + +#[test] +fn decrypt_bytes_supports_retained_oma_dcf_cbc_grouped_atom_files() { + assert_retained_file_fixture_decrypts_bytes(&oma_dcf_cbc_grpi_fixture()); +} + +#[test] +fn decrypt_file_with_progress_supports_retained_oma_dcf_cbc_grouped_atom_files() { + assert_retained_file_fixture_decrypts_with_progress( + &oma_dcf_cbc_grpi_fixture(), + "decrypt-api-oma-cbc-grpi-output", + ); +} + +#[test] +fn decrypt_file_with_progress_supports_retained_oma_dcf_cbc_movie_files() { + assert_retained_file_fixture_decrypts_with_progress( + &oma_dcf_cbc_fixture(), + "decrypt-api-oma-cbc-output", + ); +} + +#[test] +fn decrypt_bytes_supports_retained_isma_iaec_movie_files() { + assert_retained_file_fixture_decrypts_bytes(&isma_iaec_fixture()); +} + +#[test] +fn decrypt_bytes_supports_broader_iaec_movie_layouts() { + assert_generated_topology_fixture_decrypts_bytes(build_iaec_broader_movie_fixture()); +} + +#[test] +fn decrypt_file_with_progress_supports_broader_iaec_movie_layouts() { + assert_generated_topology_fixture_decrypts_with_progress( + build_iaec_broader_movie_fixture(), + "decrypt-api-iaec-broader-input", + ); +} + +#[test] +fn decrypt_bytes_rejects_iaec_movie_sample_description_indices_beyond_the_first_entry() { + assert_generated_topology_fixture_rejects_first_sample_description_limit( + build_iaec_sample_description_index_unsupported_movie_fixture(), + ); +} + +#[test] +fn decrypt_file_with_progress_supports_retained_isma_iaec_movie_files() { + assert_retained_file_fixture_decrypts_with_progress( + &isma_iaec_fixture(), + "decrypt-api-iaec-output", + ); +} + +#[test] +fn decrypt_bytes_supports_retained_marlin_ipmp_acbc_movie_files() { + assert_retained_file_fixture_decrypts_bytes(&marlin_ipmp_acbc_fixture()); +} + +#[test] +fn decrypt_file_with_progress_supports_retained_marlin_ipmp_acbc_movie_files() { + assert_retained_file_fixture_decrypts_with_progress( + &marlin_ipmp_acbc_fixture(), + "decrypt-api-marlin-acbc-output", + ); +} + +#[test] +fn decrypt_bytes_supports_broader_marlin_ipmp_acbc_movie_layouts() { + assert_generated_topology_fixture_decrypts_bytes(build_marlin_ipmp_acbc_broader_movie_fixture()); +} + +#[test] +fn decrypt_file_with_progress_supports_broader_marlin_ipmp_acbc_movie_layouts() { + assert_generated_topology_fixture_decrypts_with_progress( + build_marlin_ipmp_acbc_broader_movie_fixture(), + "decrypt-api-marlin-acbc-broader-input", + ); +} + +#[test] +fn decrypt_bytes_supports_marlin_ipmp_acbc_od_track_sample_description_indices() { + assert_generated_topology_fixture_decrypts_bytes( + build_marlin_ipmp_acbc_sample_description_index_movie_fixture(), + ); +} + +#[test] +fn decrypt_file_with_progress_supports_marlin_ipmp_acbc_od_track_sample_description_indices() { + assert_generated_topology_fixture_decrypts_with_progress( + build_marlin_ipmp_acbc_sample_description_index_movie_fixture(), + "decrypt-api-marlin-acbc-stsc-input", + ); +} + +#[test] +fn decrypt_bytes_supports_retained_marlin_ipmp_acgk_movie_files() { + assert_retained_file_fixture_decrypts_bytes(&marlin_ipmp_acgk_fixture()); +} + +#[test] +fn decrypt_file_with_progress_supports_retained_marlin_ipmp_acgk_movie_files() { + assert_retained_file_fixture_decrypts_with_progress( + &marlin_ipmp_acgk_fixture(), + "decrypt-api-marlin-acgk-output", + ); +} + +#[test] +fn decrypt_bytes_supports_broader_marlin_ipmp_acgk_movie_layouts() { + assert_generated_topology_fixture_decrypts_bytes(build_marlin_ipmp_acgk_broader_movie_fixture()); +} + +#[test] +fn decrypt_file_with_progress_supports_broader_marlin_ipmp_acgk_movie_layouts() { + assert_generated_topology_fixture_decrypts_with_progress( + build_marlin_ipmp_acgk_broader_movie_fixture(), + "decrypt-api-marlin-acgk-broader-input", + ); +} + +#[test] +fn decrypt_bytes_supports_marlin_ipmp_acgk_od_track_sample_description_indices() { + assert_generated_topology_fixture_decrypts_bytes( + build_marlin_ipmp_acgk_sample_description_index_movie_fixture(), + ); +} + +#[test] +fn decrypt_file_with_progress_supports_marlin_ipmp_acgk_od_track_sample_description_indices() { + assert_generated_topology_fixture_decrypts_with_progress( + build_marlin_ipmp_acgk_sample_description_index_movie_fixture(), + "decrypt-api-marlin-acgk-stsc-input", + ); +} + +#[test] +fn decrypt_bytes_supports_retained_common_encryption_multi_track_files() { + assert_retained_file_fixture_decrypts_bytes(&common_encryption_multi_track_fixture()); +} + +#[test] +fn decrypt_file_with_progress_supports_retained_common_encryption_multi_track_files() { + assert_retained_file_fixture_decrypts_with_progress( + &common_encryption_multi_track_fixture(), + "decrypt-api-cenc-multi-track-output", + ); +} + +#[test] +fn decrypt_bytes_supports_multi_sample_entry_fragmented_tracks() { + let fixture = build_multi_sample_entry_decrypt_fixture(); + let output = + decrypt_bytes(&fixture.single_file, &options_with_keys(&fixture.all_keys)).unwrap(); + assert_eq!(output, fixture.decrypted_single_file); +} + +#[test] +fn decrypt_file_with_progress_supports_multi_sample_entry_fragmented_tracks() { + let fixture = build_multi_sample_entry_decrypt_fixture(); + let input_path = write_temp_file("decrypt-api-multi-entry-input", &fixture.single_file); + let output_path = write_temp_file("decrypt-api-multi-entry-output", &[]); + + decrypt_file_with_progress( + &input_path, + &output_path, + &options_with_keys(&fixture.all_keys), + |_| {}, + ) + .unwrap(); + + let output = fs::read(&output_path).unwrap(); + assert_eq!(output, fixture.decrypted_single_file); +} + +#[test] +fn decrypt_bytes_supports_zero_kid_multi_sample_entry_fragmented_tracks() { + let fixture = build_zero_kid_multi_sample_entry_decrypt_fixture(); + let output = decrypt_bytes( + &fixture.single_file, + &options_with_keys(&fixture.ordered_track_id_keys), + ) + .unwrap(); + assert_eq!(output, fixture.decrypted_single_file); +} + +#[test] +fn decrypt_bytes_rejects_ambiguous_track_id_keys_for_multi_sample_entry_tracks() { + let fixture = build_multi_sample_entry_decrypt_fixture(); + let error = decrypt_bytes( + &fixture.single_file, + &options_with_keys(&fixture.ambiguous_track_id_keys), + ) + .unwrap_err(); + + assert!(matches!( + error, + DecryptError::Rewrite(DecryptRewriteError::InvalidLayout { .. }) + )); +} + +common_encryption_fragment_bytes_case!( + decrypt_bytes_supports_retained_cenc_single_video_media_segments, + "cenc-single", + "video" +); +common_encryption_fragment_bytes_case!( + decrypt_bytes_supports_retained_cenc_single_audio_media_segments, + "cenc-single", + "audio" +); +common_encryption_fragment_bytes_case!( + decrypt_bytes_supports_retained_cenc_multi_video_media_segments, + "cenc-multi", + "video" +); +common_encryption_fragment_bytes_case!( + decrypt_bytes_supports_retained_cenc_multi_audio_media_segments, + "cenc-multi", + "audio" +); +common_encryption_fragment_bytes_case!( + decrypt_bytes_supports_retained_cens_single_video_media_segments, + "cens-single", + "video" +); +common_encryption_fragment_bytes_case!( + decrypt_bytes_supports_retained_cens_single_audio_media_segments, + "cens-single", + "audio" +); +common_encryption_fragment_bytes_case!( + decrypt_bytes_supports_retained_cens_multi_video_media_segments, + "cens-multi", + "video" +); +common_encryption_fragment_bytes_case!( + decrypt_bytes_supports_retained_cens_multi_audio_media_segments, + "cens-multi", + "audio" +); +common_encryption_fragment_bytes_case!( + decrypt_bytes_supports_retained_cbc1_single_video_media_segments, + "cbc1-single", + "video" +); +common_encryption_fragment_bytes_case!( + decrypt_bytes_supports_retained_cbc1_single_audio_media_segments, + "cbc1-single", + "audio" +); +common_encryption_fragment_bytes_case!( + decrypt_bytes_supports_retained_cbc1_multi_video_media_segments, + "cbc1-multi", + "video" +); +common_encryption_fragment_bytes_case!( + decrypt_bytes_supports_retained_cbc1_multi_audio_media_segments, + "cbc1-multi", + "audio" +); +common_encryption_fragment_bytes_case!( + decrypt_bytes_supports_retained_cbcs_single_video_media_segments, + "cbcs-single", + "video" +); +common_encryption_fragment_bytes_case!( + decrypt_bytes_supports_retained_cbcs_single_audio_media_segments, + "cbcs-single", + "audio" +); +common_encryption_fragment_bytes_case!( + decrypt_bytes_supports_retained_cbcs_multi_video_media_segments, + "cbcs-multi", + "video" +); +common_encryption_fragment_bytes_case!( + decrypt_bytes_supports_retained_cbcs_multi_audio_media_segments, + "cbcs-multi", + "audio" +); + +#[test] +fn decrypt_with_missing_audio_key_does_not_fully_decrypt_retained_common_encryption_multi_track_file() + { + let fixture = common_encryption_multi_track_fixture(); + let input = fs::read(&fixture.encrypted_path).unwrap(); + let expected = fs::read(&fixture.decrypted_path).unwrap(); + + let output = decrypt_bytes( + &input, + &options_with_keys(&common_encryption_single_key_fixture_keys()), + ) + .unwrap(); + + assert_ne!(output, expected); +} + +fn options_with_keys(keys: &[DecryptionKey]) -> DecryptOptions { + let mut options = DecryptOptions::new(); + for key in keys { + options.add_key(*key); + } + options +} + +fn phases(progress: &[DecryptProgress]) -> Vec { + progress.iter().map(|snapshot| snapshot.phase).collect() +} + +fn expected_file_progress_phases() -> Vec { + vec![ + DecryptProgressPhase::OpenInput, + DecryptProgressPhase::OpenInput, + DecryptProgressPhase::InspectStructure, + DecryptProgressPhase::InspectStructure, + DecryptProgressPhase::ProcessSamples, + DecryptProgressPhase::ProcessSamples, + DecryptProgressPhase::OpenOutput, + DecryptProgressPhase::OpenOutput, + DecryptProgressPhase::FinalizeOutput, + DecryptProgressPhase::FinalizeOutput, + ] +} diff --git a/tests/decrypt_async.rs b/tests/decrypt_async.rs new file mode 100644 index 0000000..ae49aee --- /dev/null +++ b/tests/decrypt_async.rs @@ -0,0 +1,588 @@ +#![cfg(all(feature = "decrypt", feature = "async"))] + +mod support; + +use std::fs; +use std::io::Cursor; + +use mp4forge::decrypt::{ + DecryptOptions, DecryptProgress, DecryptProgressPhase, DecryptionKey, decrypt_file_async, + decrypt_file_with_progress, decrypt_file_with_progress_async, +}; +use mp4forge::extract::extract_box_payload_bytes; +use mp4forge::probe::probe_detailed; +use mp4forge::walk::BoxPath; + +use support::{ + ProtectedMovieTopologyFixture, RetainedDecryptFileFixture, RetainedFragmentedDecryptFixture, + build_decrypt_rewrite_fixture, build_iaec_broader_movie_fixture, + build_iaec_sample_description_index_unsupported_movie_fixture, + build_marlin_ipmp_acbc_broader_movie_fixture, + build_marlin_ipmp_acbc_sample_description_index_movie_fixture, + build_marlin_ipmp_acgk_broader_movie_fixture, + build_marlin_ipmp_acgk_sample_description_index_movie_fixture, + build_multi_sample_entry_decrypt_fixture, build_oma_dcf_broader_movie_fixture, + build_oma_dcf_sample_description_index_unsupported_movie_fixture, + build_zero_kid_multi_sample_entry_decrypt_fixture, common_encryption_fragment_fixture, + common_encryption_multi_track_fixture, fourcc, isma_iaec_fixture, marlin_ipmp_acbc_fixture, + marlin_ipmp_acgk_fixture, oma_dcf_cbc_fixture, oma_dcf_cbc_grpi_fixture, oma_dcf_ctr_fixture, + oma_dcf_ctr_grpi_fixture, piff_cbc_fixture, piff_cbc_segment_fixture, piff_ctr_fixture, + piff_ctr_segment_fixture, write_temp_file, +}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_with_progress_matches_sync_output() { + let fixture = build_decrypt_rewrite_fixture(); + let input_path = write_temp_file("decrypt-async-parity-input", &fixture.single_file); + let sync_output_path = write_temp_file("decrypt-async-parity-sync-output", &[]); + let async_output_path = write_temp_file("decrypt-async-parity-async-output", &[]); + + let options = options_with_keys(&fixture.all_keys); + let mut sync_progress = Vec::new(); + decrypt_file_with_progress(&input_path, &sync_output_path, &options, |snapshot| { + sync_progress.push(snapshot); + }) + .unwrap(); + + let mut async_progress = Vec::new(); + decrypt_file_with_progress_async(&input_path, &async_output_path, &options, |snapshot| { + async_progress.push(snapshot); + }) + .await + .unwrap(); + + assert_eq!( + fs::read(sync_output_path).unwrap(), + fs::read(async_output_path).unwrap() + ); + assert_eq!(phases(&async_progress), phases(&sync_progress)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_helpers_can_run_on_tokio_worker_threads() { + let fixture = build_decrypt_rewrite_fixture(); + let input_path = write_temp_file("decrypt-async-worker-input", &fixture.single_file); + let output_path = write_temp_file("decrypt-async-worker-output", &[]); + let options = options_with_keys(&fixture.all_keys); + + let output = tokio::spawn(async move { + decrypt_file_async(&input_path, &output_path, &options) + .await + .unwrap(); + tokio::fs::read(output_path).await.unwrap() + }) + .await + .unwrap(); + + let detailed = probe_detailed(&mut Cursor::new(output)).unwrap(); + let tracks = detailed + .tracks + .into_iter() + .map(|track| (track.summary.track_id, track)) + .collect::>(); + assert_eq!(tracks.len(), 2); + for track_id in [fixture.first_track_id, fixture.second_track_id] { + let track = tracks.get(&track_id).unwrap(); + assert!(!track.summary.encrypted); + assert_eq!(track.sample_entry_type, Some(fourcc("avc1"))); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn async_decrypt_independent_file_tasks_can_run_concurrently_on_tokio_worker_threads() { + let fixture = build_decrypt_rewrite_fixture(); + + let full_input = write_temp_file("decrypt-async-concurrent-full-input", &fixture.single_file); + let full_output = write_temp_file("decrypt-async-concurrent-full-output", &[]); + let partial_input = write_temp_file( + "decrypt-async-concurrent-partial-input", + &fixture.single_file, + ); + let partial_output = write_temp_file("decrypt-async-concurrent-partial-output", &[]); + let media_input = write_temp_file( + "decrypt-async-concurrent-media-input", + &fixture.media_segment, + ); + let media_output = write_temp_file("decrypt-async-concurrent-media-output", &[]); + + let full_options = options_with_keys(&fixture.all_keys); + let partial_options = options_with_keys(&fixture.first_track_only_keys); + let media_options = + options_with_keys(&fixture.all_keys).with_fragments_info_bytes(&fixture.init_segment); + + let full_handle = tokio::spawn(async move { + decrypt_file_async(&full_input, &full_output, &full_options) + .await + .unwrap(); + tokio::fs::read(full_output).await.unwrap() + }); + let partial_handle = tokio::spawn(async move { + decrypt_file_async(&partial_input, &partial_output, &partial_options) + .await + .unwrap(); + tokio::fs::read(partial_output).await.unwrap() + }); + let media_handle = tokio::spawn(async move { + decrypt_file_async(&media_input, &media_output, &media_options) + .await + .unwrap(); + tokio::fs::read(media_output).await.unwrap() + }); + + let full_output = full_handle.await.unwrap(); + let partial_output = partial_handle.await.unwrap(); + let media_output = media_handle.await.unwrap(); + + let full_tracks = probe_detailed(&mut Cursor::new(full_output)) + .unwrap() + .tracks + .into_iter() + .map(|track| (track.summary.track_id, track)) + .collect::>(); + for track_id in [fixture.first_track_id, fixture.second_track_id] { + let track = full_tracks.get(&track_id).unwrap(); + assert!(!track.summary.encrypted); + assert_eq!(track.sample_entry_type, Some(fourcc("avc1"))); + } + + let partial_tracks = probe_detailed(&mut Cursor::new(partial_output)) + .unwrap() + .tracks + .into_iter() + .map(|track| (track.summary.track_id, track)) + .collect::>(); + assert!( + !partial_tracks + .get(&fixture.first_track_id) + .unwrap() + .summary + .encrypted + ); + assert!( + partial_tracks + .get(&fixture.second_track_id) + .unwrap() + .summary + .encrypted + ); + + let mdat_payloads = extract_box_payload_bytes( + &mut Cursor::new(media_output), + None, + BoxPath::from([fourcc("mdat")]), + ) + .unwrap(); + assert_eq!(mdat_payloads.len(), 1); + assert_eq!( + mdat_payloads[0], + [ + fixture.first_track_plaintext, + fixture.second_track_plaintext + ] + .concat() + ); +} + +async fn assert_retained_file_fixture_decrypts_async( + fixture: &RetainedDecryptFileFixture, + temp_prefix: &str, +) { + let output_path = write_temp_file(temp_prefix, &[]); + let expected = fs::read(&fixture.decrypted_path).unwrap(); + + decrypt_file_async( + &fixture.encrypted_path, + &output_path, + &options_with_keys(&fixture.keys), + ) + .await + .unwrap(); + + let output = fs::read(output_path).unwrap(); + assert_eq!(output, expected); +} + +async fn assert_retained_fragmented_fixture_decrypts_async( + fixture: &RetainedFragmentedDecryptFixture, + temp_prefix: &str, +) { + let output_path = write_temp_file(temp_prefix, &[]); + let expected = fs::read(&fixture.clear_segment_path).unwrap(); + let fragments_info = fs::read(&fixture.fragments_info_path).unwrap(); + let options = options_with_keys(&fixture.keys).with_fragments_info_bytes(fragments_info); + + decrypt_file_async(&fixture.encrypted_segment_path, &output_path, &options) + .await + .unwrap(); + + let output = fs::read(output_path).unwrap(); + assert_eq!(output, expected); +} + +async fn assert_generated_topology_fixture_decrypts_async( + fixture: ProtectedMovieTopologyFixture, + temp_prefix: &str, +) { + let input_path = write_temp_file(temp_prefix, &fixture.encrypted); + let output_path = write_temp_file(&format!("{temp_prefix}-output"), &[]); + + decrypt_file_async(&input_path, &output_path, &options_with_keys(&fixture.keys)) + .await + .unwrap(); + + let output = fs::read(output_path).unwrap(); + assert_eq!(output, fixture.decrypted); +} + +async fn assert_generated_topology_fixture_rejects_first_sample_description_limit_async( + fixture: ProtectedMovieTopologyFixture, + temp_prefix: &str, +) { + let input_path = write_temp_file(temp_prefix, &fixture.encrypted); + let output_path = write_temp_file(&format!("{temp_prefix}-output"), &[]); + + let error = decrypt_file_async(&input_path, &output_path, &options_with_keys(&fixture.keys)) + .await + .unwrap_err(); + let message = error.to_string(); + assert!( + message.contains("only supports the first protected sample description"), + "unexpected rejection message: {message}" + ); +} + +macro_rules! common_encryption_fragment_async_case { + ($test_name:ident, $directory:literal, $track:literal, $prefix:literal) => { + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn $test_name() { + let fixture = common_encryption_fragment_fixture($directory, $track); + assert_retained_fragmented_fixture_decrypts_async(&fixture, $prefix).await; + } + }; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_retained_oma_dcf_ctr_movie_files() { + assert_retained_file_fixture_decrypts_async( + &oma_dcf_ctr_fixture(), + "decrypt-async-oma-ctr-output", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_broader_oma_dcf_movie_layouts() { + assert_generated_topology_fixture_decrypts_async( + build_oma_dcf_broader_movie_fixture(), + "decrypt-async-oma-broader-input", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_rejects_oma_dcf_movie_sample_description_indices_beyond_the_first_entry() + { + assert_generated_topology_fixture_rejects_first_sample_description_limit_async( + build_oma_dcf_sample_description_index_unsupported_movie_fixture(), + "decrypt-async-oma-sample-description-index-input", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_retained_piff_ctr_movie_files() { + assert_retained_file_fixture_decrypts_async( + &piff_ctr_fixture(), + "decrypt-async-piff-ctr-output", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_retained_piff_cbc_movie_files() { + assert_retained_file_fixture_decrypts_async( + &piff_cbc_fixture(), + "decrypt-async-piff-cbc-output", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_retained_piff_ctr_media_segments() { + assert_retained_fragmented_fixture_decrypts_async( + &piff_ctr_segment_fixture(), + "decrypt-async-piff-ctr-segment-output", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_retained_piff_cbc_media_segments() { + assert_retained_fragmented_fixture_decrypts_async( + &piff_cbc_segment_fixture(), + "decrypt-async-piff-cbc-segment-output", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_retained_oma_dcf_cbc_movie_files() { + assert_retained_file_fixture_decrypts_async( + &oma_dcf_cbc_fixture(), + "decrypt-async-oma-cbc-output", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_retained_oma_dcf_ctr_grouped_atom_files() { + assert_retained_file_fixture_decrypts_async( + &oma_dcf_ctr_grpi_fixture(), + "decrypt-async-oma-ctr-grpi-output", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_retained_oma_dcf_cbc_grouped_atom_files() { + assert_retained_file_fixture_decrypts_async( + &oma_dcf_cbc_grpi_fixture(), + "decrypt-async-oma-cbc-grpi-output", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_retained_isma_iaec_movie_files() { + assert_retained_file_fixture_decrypts_async(&isma_iaec_fixture(), "decrypt-async-iaec-output") + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_broader_iaec_movie_layouts() { + assert_generated_topology_fixture_decrypts_async( + build_iaec_broader_movie_fixture(), + "decrypt-async-iaec-broader-input", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_rejects_iaec_movie_sample_description_indices_beyond_the_first_entry() { + assert_generated_topology_fixture_rejects_first_sample_description_limit_async( + build_iaec_sample_description_index_unsupported_movie_fixture(), + "decrypt-async-iaec-sample-description-index-input", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_retained_marlin_ipmp_acbc_movie_files() { + assert_retained_file_fixture_decrypts_async( + &marlin_ipmp_acbc_fixture(), + "decrypt-async-marlin-acbc-output", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_broader_marlin_ipmp_acbc_movie_layouts() { + assert_generated_topology_fixture_decrypts_async( + build_marlin_ipmp_acbc_broader_movie_fixture(), + "decrypt-async-marlin-acbc-broader-input", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_marlin_ipmp_acbc_od_track_sample_description_indices() { + assert_generated_topology_fixture_decrypts_async( + build_marlin_ipmp_acbc_sample_description_index_movie_fixture(), + "decrypt-async-marlin-acbc-stsc-input", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_retained_marlin_ipmp_acgk_movie_files() { + assert_retained_file_fixture_decrypts_async( + &marlin_ipmp_acgk_fixture(), + "decrypt-async-marlin-acgk-output", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_broader_marlin_ipmp_acgk_movie_layouts() { + assert_generated_topology_fixture_decrypts_async( + build_marlin_ipmp_acgk_broader_movie_fixture(), + "decrypt-async-marlin-acgk-broader-input", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_marlin_ipmp_acgk_od_track_sample_description_indices() { + assert_generated_topology_fixture_decrypts_async( + build_marlin_ipmp_acgk_sample_description_index_movie_fixture(), + "decrypt-async-marlin-acgk-stsc-input", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_retained_common_encryption_multi_track_files() { + assert_retained_file_fixture_decrypts_async( + &common_encryption_multi_track_fixture(), + "decrypt-async-cenc-multi-track-output", + ) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_multi_sample_entry_fragmented_tracks() { + let fixture = build_multi_sample_entry_decrypt_fixture(); + let input_path = write_temp_file("decrypt-async-multi-entry-input", &fixture.single_file); + let output_path = write_temp_file("decrypt-async-multi-entry-output", &[]); + + decrypt_file_async( + &input_path, + &output_path, + &options_with_keys(&fixture.all_keys), + ) + .await + .unwrap(); + + let output = fs::read(&output_path).unwrap(); + assert_eq!(output, fixture.decrypted_single_file); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_decrypt_file_supports_zero_kid_multi_sample_entry_fragmented_tracks() { + let fixture = build_zero_kid_multi_sample_entry_decrypt_fixture(); + let input_path = write_temp_file( + "decrypt-async-zero-kid-multi-entry-input", + &fixture.single_file, + ); + let output_path = write_temp_file("decrypt-async-zero-kid-multi-entry-output", &[]); + + decrypt_file_async( + &input_path, + &output_path, + &options_with_keys(&fixture.ordered_track_id_keys), + ) + .await + .unwrap(); + + let output = fs::read(&output_path).unwrap(); + assert_eq!(output, fixture.decrypted_single_file); +} + +common_encryption_fragment_async_case!( + async_decrypt_file_supports_retained_cenc_single_video_media_segments, + "cenc-single", + "video", + "decrypt-async-cenc-single-video-segment-output" +); +common_encryption_fragment_async_case!( + async_decrypt_file_supports_retained_cenc_single_audio_media_segments, + "cenc-single", + "audio", + "decrypt-async-cenc-single-audio-segment-output" +); +common_encryption_fragment_async_case!( + async_decrypt_file_supports_retained_cenc_multi_video_media_segments, + "cenc-multi", + "video", + "decrypt-async-cenc-multi-video-segment-output" +); +common_encryption_fragment_async_case!( + async_decrypt_file_supports_retained_cenc_multi_audio_media_segments, + "cenc-multi", + "audio", + "decrypt-async-cenc-multi-audio-segment-output" +); +common_encryption_fragment_async_case!( + async_decrypt_file_supports_retained_cens_single_video_media_segments, + "cens-single", + "video", + "decrypt-async-cens-single-video-segment-output" +); +common_encryption_fragment_async_case!( + async_decrypt_file_supports_retained_cens_single_audio_media_segments, + "cens-single", + "audio", + "decrypt-async-cens-single-audio-segment-output" +); +common_encryption_fragment_async_case!( + async_decrypt_file_supports_retained_cens_multi_video_media_segments, + "cens-multi", + "video", + "decrypt-async-cens-multi-video-segment-output" +); +common_encryption_fragment_async_case!( + async_decrypt_file_supports_retained_cens_multi_audio_media_segments, + "cens-multi", + "audio", + "decrypt-async-cens-multi-audio-segment-output" +); +common_encryption_fragment_async_case!( + async_decrypt_file_supports_retained_cbc1_single_video_media_segments, + "cbc1-single", + "video", + "decrypt-async-cbc1-single-video-segment-output" +); +common_encryption_fragment_async_case!( + async_decrypt_file_supports_retained_cbc1_single_audio_media_segments, + "cbc1-single", + "audio", + "decrypt-async-cbc1-single-audio-segment-output" +); +common_encryption_fragment_async_case!( + async_decrypt_file_supports_retained_cbc1_multi_video_media_segments, + "cbc1-multi", + "video", + "decrypt-async-cbc1-multi-video-segment-output" +); +common_encryption_fragment_async_case!( + async_decrypt_file_supports_retained_cbc1_multi_audio_media_segments, + "cbc1-multi", + "audio", + "decrypt-async-cbc1-multi-audio-segment-output" +); +common_encryption_fragment_async_case!( + async_decrypt_file_supports_retained_cbcs_single_video_media_segments, + "cbcs-single", + "video", + "decrypt-async-cbcs-single-video-segment-output" +); +common_encryption_fragment_async_case!( + async_decrypt_file_supports_retained_cbcs_single_audio_media_segments, + "cbcs-single", + "audio", + "decrypt-async-cbcs-single-audio-segment-output" +); +common_encryption_fragment_async_case!( + async_decrypt_file_supports_retained_cbcs_multi_video_media_segments, + "cbcs-multi", + "video", + "decrypt-async-cbcs-multi-video-segment-output" +); +common_encryption_fragment_async_case!( + async_decrypt_file_supports_retained_cbcs_multi_audio_media_segments, + "cbcs-multi", + "audio", + "decrypt-async-cbcs-multi-audio-segment-output" +); + +fn options_with_keys(keys: &[DecryptionKey]) -> DecryptOptions { + let mut options = DecryptOptions::new(); + for key in keys { + options.add_key(*key); + } + options +} + +fn phases(progress: &[DecryptProgress]) -> Vec { + progress.iter().map(|snapshot| snapshot.phase).collect() +} diff --git a/tests/decrypt_feature_gate.rs b/tests/decrypt_feature_gate.rs new file mode 100644 index 0000000..fd46aee --- /dev/null +++ b/tests/decrypt_feature_gate.rs @@ -0,0 +1,107 @@ +#![cfg(feature = "decrypt")] + +use mp4forge::FourCc; +use mp4forge::decrypt::{ + BROADER_MP4_DECRYPTION_FAMILIES, DecryptProgress, DecryptProgressPhase, DecryptionFormatFamily, + DecryptionKey, DecryptionKeyId, FULL_MP4_DECRYPTION_FAMILIES, + NATIVE_COMMON_ENCRYPTION_SCHEME_TYPES, ParseDecryptionKeyError, +}; + +#[test] +fn decrypt_feature_exposes_the_planned_support_matrix() { + assert_eq!( + NATIVE_COMMON_ENCRYPTION_SCHEME_TYPES, + [ + FourCc::from_bytes(*b"cenc"), + FourCc::from_bytes(*b"cens"), + FourCc::from_bytes(*b"cbc1"), + FourCc::from_bytes(*b"cbcs"), + ] + ); + + assert_eq!( + FULL_MP4_DECRYPTION_FAMILIES[0], + DecryptionFormatFamily::CommonEncryption + ); + assert_eq!( + BROADER_MP4_DECRYPTION_FAMILIES, + [ + DecryptionFormatFamily::OmaDcf, + DecryptionFormatFamily::MarlinIpmp, + DecryptionFormatFamily::PiffCompatibility, + DecryptionFormatFamily::StandardProtected, + ] + ); +} + +#[test] +fn decrypt_feature_parses_track_and_kid_key_specs() { + let track = DecryptionKey::from_spec("7:00112233445566778899aabbccddeeff").unwrap(); + assert_eq!(track.id(), DecryptionKeyId::TrackId(7)); + assert_eq!( + track.key_bytes(), + [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, + 0xee, 0xff, + ] + ); + assert_eq!(track.to_spec(), "7:00112233445566778899aabbccddeeff"); + + let kid = DecryptionKey::from_spec( + "00112233445566778899aabbccddeeff:ffeeddccbbaa99887766554433221100", + ) + .unwrap(); + assert_eq!( + kid.id(), + DecryptionKeyId::Kid([ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, + 0xee, 0xff, + ]) + ); + assert_eq!( + kid.key_bytes(), + [ + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, + 0x11, 0x00, + ] + ); + assert_eq!( + kid.to_spec(), + "00112233445566778899aabbccddeeff:ffeeddccbbaa99887766554433221100" + ); +} + +#[test] +fn decrypt_feature_reports_key_parse_errors_clearly() { + assert_eq!( + DecryptionKey::from_spec("missing-separator").unwrap_err(), + ParseDecryptionKeyError::InvalidSpec { + input: "missing-separator".to_owned(), + reason: "expected :", + } + ); + + assert_eq!( + DecryptionKey::from_spec("abc:00112233445566778899aabbccddeeff").unwrap_err(), + ParseDecryptionKeyError::InvalidTrackId { + input: "abc".to_owned(), + } + ); + + assert_eq!( + DecryptionKey::from_spec("1:001122").unwrap_err(), + ParseDecryptionKeyError::InvalidHexLength { + field: "content key", + actual: 6, + } + ); +} + +#[test] +fn decrypt_feature_progress_type_is_stable() { + let progress = DecryptProgress::new(DecryptProgressPhase::ProcessSamples, 3, Some(8)); + + assert_eq!(progress.phase, DecryptProgressPhase::ProcessSamples); + assert_eq!(progress.completed, 3); + assert_eq!(progress.total, Some(8)); +} diff --git a/tests/decrypt_rewrite.rs b/tests/decrypt_rewrite.rs new file mode 100644 index 0000000..565d7d9 --- /dev/null +++ b/tests/decrypt_rewrite.rs @@ -0,0 +1,409 @@ +#![cfg(feature = "decrypt")] + +mod support; + +use std::collections::BTreeMap; +use std::fs; +use std::io::Cursor; + +use mp4forge::boxes::iso14496_12::Trun; +use mp4forge::decrypt::{ + DecryptRewriteError, decrypt_common_encryption_file_bytes, + decrypt_common_encryption_init_bytes, decrypt_common_encryption_media_segment_bytes, +}; +use mp4forge::extract::{extract_box, extract_box_payload_bytes}; +use mp4forge::probe::probe_detailed; +use mp4forge::rewrite::rewrite_box_as_bytes; +use mp4forge::walk::BoxPath; + +use support::{ + RetainedDecryptFileFixture, build_decrypt_rewrite_fixture, + build_multi_sample_entry_decrypt_fixture, fourcc, piff_cbc_fixture, piff_cbc_segment_fixture, + piff_ctr_fixture, piff_ctr_segment_fixture, +}; + +#[test] +fn decrypt_common_encryption_init_bytes_clears_keyed_sample_entry_protection_state() { + let fixture = build_decrypt_rewrite_fixture(); + + let output = + decrypt_common_encryption_init_bytes(&fixture.init_segment, &fixture.all_keys).unwrap(); + + assert!( + extract_box( + &mut Cursor::new(output.clone()), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("encv") + ]), + ) + .unwrap() + .is_empty() + ); + assert_eq!( + extract_box( + &mut Cursor::new(output.clone()), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avc1") + ]), + ) + .unwrap() + .len(), + 2 + ); + assert!( + extract_box( + &mut Cursor::new(output), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avc1"), + fourcc("sinf") + ]), + ) + .unwrap() + .is_empty() + ); +} + +#[test] +fn decrypt_common_encryption_init_bytes_supports_multi_sample_entry_tracks() { + let fixture = build_multi_sample_entry_decrypt_fixture(); + + let output = + decrypt_common_encryption_init_bytes(&fixture.init_segment, &fixture.all_keys).unwrap(); + + assert_eq!(output, fixture.decrypted_init_segment); + assert!( + extract_box( + &mut Cursor::new(output.clone()), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("encv") + ]), + ) + .unwrap() + .is_empty() + ); + assert_eq!( + extract_box( + &mut Cursor::new(output), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avc1") + ]), + ) + .unwrap() + .len(), + 2 + ); +} + +#[test] +fn decrypt_common_encryption_media_segment_bytes_decrypts_samples_and_removes_fragment_boxes() { + let fixture = build_decrypt_rewrite_fixture(); + + let output = decrypt_common_encryption_media_segment_bytes( + &fixture.init_segment, + &fixture.media_segment, + &fixture.all_keys, + ) + .unwrap(); + + let mdat_payloads = extract_box_payload_bytes( + &mut Cursor::new(output.clone()), + None, + BoxPath::from([fourcc("mdat")]), + ) + .unwrap(); + assert_eq!(mdat_payloads.len(), 1); + assert_eq!( + mdat_payloads[0], + [ + fixture.first_track_plaintext, + fixture.second_track_plaintext + ] + .concat() + ); + + for path in [ + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("senc")]), + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("saiz")]), + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("saio")]), + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("sgpd")]), + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("sbgp")]), + ] { + assert!( + extract_box(&mut Cursor::new(output.clone()), None, path) + .unwrap() + .is_empty() + ); + } +} + +#[test] +fn decrypt_common_encryption_media_segment_bytes_supports_sample_description_switching() { + let fixture = build_multi_sample_entry_decrypt_fixture(); + + let output = decrypt_common_encryption_media_segment_bytes( + &fixture.init_segment, + &fixture.media_segment, + &fixture.all_keys, + ) + .unwrap(); + + assert_eq!(output, fixture.decrypted_media_segment); +} + +#[test] +fn decrypt_common_encryption_media_segment_bytes_supports_piff_uuid_sample_encryption() { + for fixture in [piff_ctr_segment_fixture(), piff_cbc_segment_fixture()] { + let init_segment = fs::read(&fixture.fragments_info_path).unwrap(); + let encrypted_media_segment = fs::read(&fixture.encrypted_segment_path).unwrap(); + let clear_media_segment = fs::read(&fixture.clear_segment_path).unwrap(); + let output = decrypt_common_encryption_media_segment_bytes( + &init_segment, + &encrypted_media_segment, + &fixture.keys, + ) + .unwrap(); + + assert_eq!(output, clear_media_segment); + assert_eq!( + extract_box( + &mut Cursor::new(output), + None, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("uuid")]), + ) + .unwrap() + .len(), + 1 + ); + } +} + +#[test] +fn decrypt_common_encryption_file_bytes_matches_split_outputs() { + let fixture = build_decrypt_rewrite_fixture(); + + let expected = [ + decrypt_common_encryption_init_bytes(&fixture.init_segment, &fixture.all_keys).unwrap(), + decrypt_common_encryption_media_segment_bytes( + &fixture.init_segment, + &fixture.media_segment, + &fixture.all_keys, + ) + .unwrap(), + ] + .concat(); + let actual = + decrypt_common_encryption_file_bytes(&fixture.single_file, &fixture.all_keys).unwrap(); + + assert_eq!(actual, expected); + + let detailed = probe_detailed(&mut Cursor::new(actual)).unwrap(); + let tracks = detailed + .tracks + .into_iter() + .map(|track| (track.summary.track_id, track)) + .collect::>(); + assert_eq!(tracks.len(), 2); + for track_id in [fixture.first_track_id, fixture.second_track_id] { + let track = tracks.get(&track_id).unwrap(); + assert!(!track.summary.encrypted); + assert_eq!(track.sample_entry_type, Some(fourcc("avc1"))); + assert!(track.original_format.is_none()); + assert!(track.protection_scheme.is_none()); + } +} + +#[test] +fn decrypt_common_encryption_file_bytes_supports_sample_description_switching() { + let fixture = build_multi_sample_entry_decrypt_fixture(); + + let output = + decrypt_common_encryption_file_bytes(&fixture.single_file, &fixture.all_keys).unwrap(); + + assert_eq!(output, fixture.decrypted_single_file); + + let detailed = probe_detailed(&mut Cursor::new(output)).unwrap(); + assert_eq!(detailed.tracks.len(), 1); + let track = &detailed.tracks[0]; + assert!(!track.summary.encrypted); + assert_eq!(track.sample_entry_type, Some(fourcc("avc1"))); + assert!(track.original_format.is_none()); + assert!(track.protection_scheme.is_none()); +} + +#[test] +fn decrypt_common_encryption_file_bytes_supports_piff_compatibility_tracks() { + for fixture in [piff_ctr_fixture(), piff_cbc_fixture()] { + assert_retained_piff_file_fixture_decrypts(&fixture); + } +} + +#[test] +fn decrypt_common_encryption_file_bytes_keeps_unkeyed_track_encrypted() { + let fixture = build_decrypt_rewrite_fixture(); + + let output = + decrypt_common_encryption_file_bytes(&fixture.single_file, &fixture.first_track_only_keys) + .unwrap(); + + let detailed = probe_detailed(&mut Cursor::new(output.clone())).unwrap(); + let tracks = detailed + .tracks + .into_iter() + .map(|track| (track.summary.track_id, track)) + .collect::>(); + + let first = tracks.get(&fixture.first_track_id).unwrap(); + assert!(!first.summary.encrypted); + assert_eq!(first.sample_entry_type, Some(fourcc("avc1"))); + assert!(first.protection_scheme.is_none()); + + let second = tracks.get(&fixture.second_track_id).unwrap(); + assert!(second.summary.encrypted); + assert_eq!(second.sample_entry_type, Some(fourcc("encv"))); + assert_eq!(second.original_format, Some(fourcc("avc1"))); + + assert_eq!( + extract_box( + &mut Cursor::new(output), + None, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("senc")]), + ) + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn decrypt_common_encryption_media_segment_bytes_rejects_invalid_trun_offsets() { + let fixture = build_decrypt_rewrite_fixture(); + let broken = rewrite_box_as_bytes::( + &fixture.media_segment, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("trun")]), + |trun| { + trun.data_offset = i32::MAX; + }, + ) + .unwrap(); + + let error = decrypt_common_encryption_media_segment_bytes( + &fixture.init_segment, + &broken, + &fixture.all_keys, + ) + .unwrap_err(); + + assert!(matches!( + error, + DecryptRewriteError::SampleDataRangeNotFound { .. } + | DecryptRewriteError::InvalidLayout { .. } + )); +} + +fn assert_retained_piff_file_fixture_decrypts(fixture: &RetainedDecryptFileFixture) { + let input = fs::read(&fixture.encrypted_path).unwrap(); + let expected = fs::read(&fixture.decrypted_path).unwrap(); + + let output = decrypt_common_encryption_file_bytes(&input, &fixture.keys).unwrap(); + + assert_eq!(output, expected); + assert!( + extract_box( + &mut Cursor::new(output.clone()), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("avc1"), + ]), + ) + .unwrap() + .is_empty() + ); + assert_eq!( + extract_box( + &mut Cursor::new(output.clone()), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("encv"), + ]), + ) + .unwrap() + .len(), + 1 + ); + assert_eq!( + extract_box( + &mut Cursor::new(output.clone()), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("encv"), + fourcc("sinf"), + ]), + ) + .unwrap() + .len(), + 1 + ); + assert_eq!( + extract_box( + &mut Cursor::new(output), + None, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("uuid")]), + ) + .unwrap() + .len(), + 1 + ); +} diff --git a/tests/extract.rs b/tests/extract.rs index e442a80..70a06da 100644 --- a/tests/extract.rs +++ b/tests/extract.rs @@ -3,13 +3,18 @@ use std::io::Cursor; use mp4forge::boxes::AnyTypeBox; use mp4forge::boxes::iso14496_12::{ Cdsc, Elng, Emeb, Emib, EventMessageSampleEntry, Ftyp, Leva, LevaLevel, Mdia, Meta, Minf, Moov, - Mvex, Saio, Saiz, Sbgp, Sgpd, Silb, Ssix, SsixRange, SsixSubsegment, Stbl, Subs, SubsEntry, - SubsSample, Tkhd, Trak, Tref, Trep, Udta, + Mvex, Saio, Saiz, Sbgp, Schi, Sgpd, Silb, Sinf, Ssix, SsixRange, SsixSubsegment, Stbl, Subs, + SubsEntry, SubsSample, Tkhd, Trak, Tref, Trep, Udta, }; +use mp4forge::boxes::iso14496_14::{Descriptor, EsIdIncDescriptor, InitialObjectDescriptor, Iods}; use mp4forge::boxes::iso23001_7::{Senc, Tenc}; use mp4forge::boxes::metadata::{ DATA_TYPE_STRING_UTF8, Data, Ilst, Key, Keys, NumberedMetadataItem, }; +use mp4forge::boxes::oma_dcf::{ + Grpi, OHDR_ENCRYPTION_METHOD_AES_CTR, OHDR_PADDING_SCHEME_NONE, Odaf, Odda, Odhe, Odkm, Odrm, + Ohdr, +}; use mp4forge::codec::{CodecBox, marshal}; use mp4forge::extract::{ ExtractError, extract_box, extract_box_as, extract_box_as_bytes, extract_box_bytes, @@ -27,6 +32,8 @@ use mp4forge::{BoxInfo, FourCc}; mod support; +#[cfg(feature = "decrypt")] +use mp4forge::boxes::isma_cryp::{Ikms, Isfm, Islt}; #[cfg(feature = "async")] use support::build_visual_sample_entry_box_with_trailing_bytes; #[cfg(feature = "async")] @@ -34,6 +41,8 @@ use support::write_temp_file; use support::{ build_encrypted_fragmented_video_file, build_event_message_movie_file, fixture_path, }; +#[cfg(feature = "decrypt")] +use support::{isma_iaec_fixture, oma_dcf_ctr_fixture}; #[cfg(feature = "async")] use tokio::fs::File as TokioFile; @@ -449,6 +458,591 @@ fn extract_box_as_returns_typed_payloads() { ); } +#[test] +fn extract_box_as_decodes_oma_dcf_layout_boxes() { + let mut grpi_box = Grpi::default(); + grpi_box.key_encryption_method = 1; + grpi_box.group_id = "group-a".into(); + grpi_box.group_key = vec![0x10, 0x20, 0x30, 0x40]; + let grpi = encode_supported_box(&grpi_box, &[]); + let mut ohdr_top_box = Ohdr::default(); + ohdr_top_box.encryption_method = OHDR_ENCRYPTION_METHOD_AES_CTR; + ohdr_top_box.padding_scheme = OHDR_PADDING_SCHEME_NONE; + ohdr_top_box.plaintext_length = 0x1234; + ohdr_top_box.content_id = "cid-top".into(); + ohdr_top_box.rights_issuer_url = "https://issuer.example".into(); + ohdr_top_box.textual_headers = b"Header: 1".to_vec(); + let ohdr_top = encode_supported_box(&ohdr_top_box, &grpi); + let mut odhe_box = Odhe::default(); + odhe_box.content_type = "video/mp4".into(); + let odhe = encode_supported_box(&odhe_box, &ohdr_top); + let mut odda_box = Odda::default(); + odda_box.encrypted_payload = vec![0xaa, 0xbb, 0xcc, 0xdd]; + let odda = encode_supported_box(&odda_box, &[]); + let odrm = encode_supported_box(&Odrm, &[odhe, odda].concat()); + + let mut odaf_box = Odaf::default(); + odaf_box.selective_encryption = true; + odaf_box.key_indicator_length = 0; + odaf_box.iv_length = 16; + let odaf = encode_supported_box(&odaf_box, &[]); + let mut ohdr_entry_box = Ohdr::default(); + ohdr_entry_box.encryption_method = OHDR_ENCRYPTION_METHOD_AES_CTR; + ohdr_entry_box.padding_scheme = OHDR_PADDING_SCHEME_NONE; + ohdr_entry_box.plaintext_length = 0x5678; + ohdr_entry_box.content_id = "cid-entry".into(); + ohdr_entry_box.rights_issuer_url = "https://entry.example".into(); + ohdr_entry_box.textual_headers = b"Entry: 1".to_vec(); + let ohdr_entry = encode_supported_box(&ohdr_entry_box, &[]); + let odkm = encode_supported_box(&Odkm::default(), &[odaf, ohdr_entry].concat()); + let schi = encode_supported_box(&Schi, &odkm); + let sinf = encode_supported_box(&Sinf, &schi); + let trak = encode_supported_box(&Trak, &sinf); + let moov = encode_supported_box(&Moov, &trak); + let file = [odrm, moov].concat(); + + let extracted_ohdr = extract_box_as::<_, Ohdr>( + &mut Cursor::new(file.clone()), + None, + BoxPath::from([fourcc("odrm"), fourcc("odhe"), fourcc("ohdr")]), + ) + .unwrap(); + assert_eq!(extracted_ohdr.len(), 1); + assert_eq!(extracted_ohdr[0].content_id, "cid-top"); + assert_eq!(extracted_ohdr[0].plaintext_length, 0x1234); + + let extracted_grpi = extract_box_as::<_, Grpi>( + &mut Cursor::new(file.clone()), + None, + BoxPath::from([ + fourcc("odrm"), + fourcc("odhe"), + fourcc("ohdr"), + fourcc("grpi"), + ]), + ) + .unwrap(); + assert_eq!(extracted_grpi.len(), 1); + assert_eq!(extracted_grpi[0].group_id, "group-a"); + assert_eq!(extracted_grpi[0].group_key, vec![0x10, 0x20, 0x30, 0x40]); + + let extracted_odda = extract_box_as::<_, Odda>( + &mut Cursor::new(file.clone()), + None, + BoxPath::from([fourcc("odrm"), fourcc("odda")]), + ) + .unwrap(); + assert_eq!(extracted_odda.len(), 1); + assert_eq!( + extracted_odda[0].encrypted_payload, + vec![0xaa, 0xbb, 0xcc, 0xdd] + ); + + let extracted_odaf = extract_box_as::<_, Odaf>( + &mut Cursor::new(file.clone()), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("sinf"), + fourcc("schi"), + fourcc("odkm"), + fourcc("odaf"), + ]), + ) + .unwrap(); + assert_eq!(extracted_odaf.len(), 1); + assert!(extracted_odaf[0].selective_encryption); + assert_eq!(extracted_odaf[0].iv_length, 16); + + let extracted_entry_ohdr = extract_box_as::<_, Ohdr>( + &mut Cursor::new(file), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("sinf"), + fourcc("schi"), + fourcc("odkm"), + fourcc("ohdr"), + ]), + ) + .unwrap(); + assert_eq!(extracted_entry_ohdr.len(), 1); + assert_eq!(extracted_entry_ohdr[0].content_id, "cid-entry"); +} + +#[cfg(feature = "decrypt")] +#[test] +fn extract_box_as_decodes_retained_oma_dcf_movie_layout_boxes() { + let fixture = oma_dcf_ctr_fixture(); + let input = std::fs::read(&fixture.encrypted_path).unwrap(); + + let extracted_odaf = extract_box_as::<_, Odaf>( + &mut Cursor::new(input.clone()), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("enca"), + fourcc("sinf"), + fourcc("schi"), + fourcc("odkm"), + fourcc("odaf"), + ]), + ) + .unwrap(); + assert_eq!(extracted_odaf.len(), 1); + assert!(extracted_odaf[0].selective_encryption); + assert_eq!(extracted_odaf[0].iv_length, 16); + + let extracted_ohdr = extract_box_as::<_, Ohdr>( + &mut Cursor::new(input), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("enca"), + fourcc("sinf"), + fourcc("schi"), + fourcc("odkm"), + fourcc("ohdr"), + ]), + ) + .unwrap(); + assert_eq!(extracted_ohdr.len(), 1); + assert_eq!( + extracted_ohdr[0].encryption_method, + OHDR_ENCRYPTION_METHOD_AES_CTR + ); + assert_eq!(extracted_ohdr[0].plaintext_length, 0); + assert_eq!(extracted_ohdr[0].content_id, "oma-ctr-aac"); + assert_eq!( + extracted_ohdr[0].rights_issuer_url, + "https://rights.example/oma-ctr" + ); +} + +#[cfg(feature = "decrypt")] +#[test] +fn extract_box_as_decodes_iaec_layout_boxes() { + let fixture = isma_iaec_fixture(); + let input = std::fs::read(&fixture.encrypted_path).unwrap(); + + let extracted_ikms = extract_box_as::<_, Ikms>( + &mut Cursor::new(input.clone()), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("enca"), + fourcc("sinf"), + fourcc("schi"), + fourcc("iKMS"), + ]), + ) + .unwrap(); + assert_eq!(extracted_ikms.len(), 1); + assert_eq!(extracted_ikms[0].kms_uri, "https://kms.example/iaec"); + assert_eq!(extracted_ikms[0].kms_version, 0); + + let extracted_isfm = extract_box_as::<_, Isfm>( + &mut Cursor::new(input.clone()), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("enca"), + fourcc("sinf"), + fourcc("schi"), + fourcc("iSFM"), + ]), + ) + .unwrap(); + assert_eq!(extracted_isfm.len(), 1); + assert!(!extracted_isfm[0].selective_encryption); + assert_eq!(extracted_isfm[0].iv_length, 8); + assert_eq!(extracted_isfm[0].key_indicator_length, 0); + + let extracted_islt = extract_box_as::<_, Islt>( + &mut Cursor::new(input), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("enca"), + fourcc("sinf"), + fourcc("schi"), + fourcc("iSLT"), + ]), + ) + .unwrap(); + assert_eq!(extracted_islt.len(), 1); + assert_eq!( + extracted_islt[0].salt, + [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] + ); +} + +#[test] +fn extract_box_as_decodes_iods_initial_object_descriptors() { + let mut iods = Iods::default(); + iods.descriptor = Some( + Descriptor::from_initial_object_descriptor(InitialObjectDescriptor { + object_descriptor_id: 4, + include_inline_profile_level_flag: true, + od_profile_level_indication: 0x11, + scene_profile_level_indication: 0x22, + audio_profile_level_indication: 0x33, + visual_profile_level_indication: 0x44, + graphics_profile_level_indication: 0x55, + sub_descriptors: vec![Descriptor::from_es_id_inc_descriptor(EsIdIncDescriptor { + track_id: 0x0102_0304, + })], + ..InitialObjectDescriptor::default() + }) + .unwrap(), + ); + let moov = encode_supported_box(&Moov, &encode_supported_box(&iods, &[])); + + let extracted = extract_box_as::<_, Iods>( + &mut Cursor::new(moov), + None, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ) + .unwrap(); + + assert_eq!(extracted.len(), 1); + let initial = extracted[0].initial_object_descriptor().unwrap(); + assert_eq!(initial.object_descriptor_id, 4); + assert_eq!( + initial.sub_descriptors[0] + .es_id_inc_descriptor() + .unwrap() + .track_id, + 0x0102_0304 + ); +} + +#[cfg(feature = "async")] +#[tokio::test] +async fn async_extract_box_as_decodes_oma_dcf_layout_boxes() { + let mut grpi_box = Grpi::default(); + grpi_box.key_encryption_method = 1; + grpi_box.group_id = "group-a".into(); + grpi_box.group_key = vec![0x10, 0x20, 0x30, 0x40]; + let grpi = encode_supported_box(&grpi_box, &[]); + let mut ohdr_top_box = Ohdr::default(); + ohdr_top_box.encryption_method = OHDR_ENCRYPTION_METHOD_AES_CTR; + ohdr_top_box.padding_scheme = OHDR_PADDING_SCHEME_NONE; + ohdr_top_box.plaintext_length = 0x1234; + ohdr_top_box.content_id = "cid-top".into(); + ohdr_top_box.rights_issuer_url = "https://issuer.example".into(); + ohdr_top_box.textual_headers = b"Header: 1".to_vec(); + let ohdr_top = encode_supported_box(&ohdr_top_box, &grpi); + let mut odhe_box = Odhe::default(); + odhe_box.content_type = "video/mp4".into(); + let odhe = encode_supported_box(&odhe_box, &ohdr_top); + let mut odda_box = Odda::default(); + odda_box.encrypted_payload = vec![0xaa, 0xbb, 0xcc, 0xdd]; + let odda = encode_supported_box(&odda_box, &[]); + let odrm = encode_supported_box(&Odrm, &[odhe, odda].concat()); + + let mut odaf_box = Odaf::default(); + odaf_box.selective_encryption = true; + odaf_box.key_indicator_length = 0; + odaf_box.iv_length = 16; + let odaf = encode_supported_box(&odaf_box, &[]); + let mut ohdr_entry_box = Ohdr::default(); + ohdr_entry_box.encryption_method = OHDR_ENCRYPTION_METHOD_AES_CTR; + ohdr_entry_box.padding_scheme = OHDR_PADDING_SCHEME_NONE; + ohdr_entry_box.plaintext_length = 0x5678; + ohdr_entry_box.content_id = "cid-entry".into(); + ohdr_entry_box.rights_issuer_url = "https://entry.example".into(); + ohdr_entry_box.textual_headers = b"Entry: 1".to_vec(); + let ohdr_entry = encode_supported_box(&ohdr_entry_box, &[]); + let odkm = encode_supported_box(&Odkm::default(), &[odaf, ohdr_entry].concat()); + let schi = encode_supported_box(&Schi, &odkm); + let sinf = encode_supported_box(&Sinf, &schi); + let trak = encode_supported_box(&Trak, &sinf); + let moov = encode_supported_box(&Moov, &trak); + let file = [odrm, moov].concat(); + + let extracted_ohdr = extract_box_as_async::<_, Ohdr>( + &mut Cursor::new(file.clone()), + None, + BoxPath::from([fourcc("odrm"), fourcc("odhe"), fourcc("ohdr")]), + ) + .await + .unwrap(); + assert_eq!(extracted_ohdr.len(), 1); + assert_eq!(extracted_ohdr[0].content_id, "cid-top"); + assert_eq!(extracted_ohdr[0].plaintext_length, 0x1234); + + let extracted_grpi = extract_box_as_async::<_, Grpi>( + &mut Cursor::new(file.clone()), + None, + BoxPath::from([ + fourcc("odrm"), + fourcc("odhe"), + fourcc("ohdr"), + fourcc("grpi"), + ]), + ) + .await + .unwrap(); + assert_eq!(extracted_grpi.len(), 1); + assert_eq!(extracted_grpi[0].group_id, "group-a"); + assert_eq!(extracted_grpi[0].group_key, vec![0x10, 0x20, 0x30, 0x40]); + + let extracted_odda = extract_box_as_async::<_, Odda>( + &mut Cursor::new(file.clone()), + None, + BoxPath::from([fourcc("odrm"), fourcc("odda")]), + ) + .await + .unwrap(); + assert_eq!(extracted_odda.len(), 1); + assert_eq!( + extracted_odda[0].encrypted_payload, + vec![0xaa, 0xbb, 0xcc, 0xdd] + ); + + let extracted_odaf = extract_box_as_async::<_, Odaf>( + &mut Cursor::new(file.clone()), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("sinf"), + fourcc("schi"), + fourcc("odkm"), + fourcc("odaf"), + ]), + ) + .await + .unwrap(); + assert_eq!(extracted_odaf.len(), 1); + assert!(extracted_odaf[0].selective_encryption); + assert_eq!(extracted_odaf[0].iv_length, 16); + + let extracted_entry_ohdr = extract_box_as_async::<_, Ohdr>( + &mut Cursor::new(file), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("sinf"), + fourcc("schi"), + fourcc("odkm"), + fourcc("ohdr"), + ]), + ) + .await + .unwrap(); + assert_eq!(extracted_entry_ohdr.len(), 1); + assert_eq!(extracted_entry_ohdr[0].content_id, "cid-entry"); +} + +#[cfg(all(feature = "decrypt", feature = "async"))] +#[tokio::test] +async fn async_extract_box_as_decodes_retained_oma_dcf_movie_layout_boxes() { + let fixture = oma_dcf_ctr_fixture(); + let input = std::fs::read(&fixture.encrypted_path).unwrap(); + + let extracted_odaf = extract_box_as_async::<_, Odaf>( + &mut Cursor::new(input.clone()), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("enca"), + fourcc("sinf"), + fourcc("schi"), + fourcc("odkm"), + fourcc("odaf"), + ]), + ) + .await + .unwrap(); + assert_eq!(extracted_odaf.len(), 1); + assert!(extracted_odaf[0].selective_encryption); + assert_eq!(extracted_odaf[0].iv_length, 16); + + let extracted_ohdr = extract_box_as_async::<_, Ohdr>( + &mut Cursor::new(input), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("enca"), + fourcc("sinf"), + fourcc("schi"), + fourcc("odkm"), + fourcc("ohdr"), + ]), + ) + .await + .unwrap(); + assert_eq!(extracted_ohdr.len(), 1); + assert_eq!( + extracted_ohdr[0].encryption_method, + OHDR_ENCRYPTION_METHOD_AES_CTR + ); + assert_eq!(extracted_ohdr[0].plaintext_length, 0); + assert_eq!(extracted_ohdr[0].content_id, "oma-ctr-aac"); + assert_eq!( + extracted_ohdr[0].rights_issuer_url, + "https://rights.example/oma-ctr" + ); +} + +#[cfg(all(feature = "decrypt", feature = "async"))] +#[tokio::test] +async fn async_extract_box_as_decodes_iaec_layout_boxes() { + let fixture = isma_iaec_fixture(); + let input = std::fs::read(&fixture.encrypted_path).unwrap(); + + let extracted_ikms = extract_box_as_async::<_, Ikms>( + &mut Cursor::new(input.clone()), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("enca"), + fourcc("sinf"), + fourcc("schi"), + fourcc("iKMS"), + ]), + ) + .await + .unwrap(); + assert_eq!(extracted_ikms.len(), 1); + assert_eq!(extracted_ikms[0].kms_uri, "https://kms.example/iaec"); + assert_eq!(extracted_ikms[0].kms_version, 0); + + let extracted_isfm = extract_box_as_async::<_, Isfm>( + &mut Cursor::new(input.clone()), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("enca"), + fourcc("sinf"), + fourcc("schi"), + fourcc("iSFM"), + ]), + ) + .await + .unwrap(); + assert_eq!(extracted_isfm.len(), 1); + assert!(!extracted_isfm[0].selective_encryption); + assert_eq!(extracted_isfm[0].iv_length, 8); + assert_eq!(extracted_isfm[0].key_indicator_length, 0); + + let extracted_islt = extract_box_as_async::<_, Islt>( + &mut Cursor::new(input), + None, + BoxPath::from([ + fourcc("moov"), + fourcc("trak"), + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + fourcc("enca"), + fourcc("sinf"), + fourcc("schi"), + fourcc("iSLT"), + ]), + ) + .await + .unwrap(); + assert_eq!(extracted_islt.len(), 1); + assert_eq!( + extracted_islt[0].salt, + [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] + ); +} + +#[cfg(feature = "async")] +#[tokio::test] +async fn async_extract_box_as_decodes_iods_initial_object_descriptors() { + let mut iods = Iods::default(); + iods.descriptor = Some( + Descriptor::from_initial_object_descriptor(InitialObjectDescriptor { + object_descriptor_id: 4, + include_inline_profile_level_flag: true, + od_profile_level_indication: 0x11, + scene_profile_level_indication: 0x22, + audio_profile_level_indication: 0x33, + visual_profile_level_indication: 0x44, + graphics_profile_level_indication: 0x55, + sub_descriptors: vec![Descriptor::from_es_id_inc_descriptor(EsIdIncDescriptor { + track_id: 0x0102_0304, + })], + ..InitialObjectDescriptor::default() + }) + .unwrap(), + ); + let moov = encode_supported_box(&Moov, &encode_supported_box(&iods, &[])); + + let extracted = extract_box_as_async::<_, Iods>( + &mut Cursor::new(moov), + None, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ) + .await + .unwrap(); + + assert_eq!(extracted.len(), 1); + let initial = extracted[0].initial_object_descriptor().unwrap(); + assert_eq!(initial.object_descriptor_id, 4); + assert_eq!( + initial.sub_descriptors[0] + .es_id_inc_descriptor() + .unwrap() + .track_id, + 0x0102_0304 + ); +} + #[cfg(feature = "async")] #[tokio::test] async fn async_extract_box_as_returns_typed_payloads() { diff --git a/tests/fixtures/cbc1-multi/audio_1.clear.m4s b/tests/fixtures/cbc1-multi/audio_1.clear.m4s new file mode 100644 index 0000000..fc0dbf9 Binary files /dev/null and b/tests/fixtures/cbc1-multi/audio_1.clear.m4s differ diff --git a/tests/fixtures/cbc1-multi/audio_1.m4s b/tests/fixtures/cbc1-multi/audio_1.m4s new file mode 100644 index 0000000..d561a23 Binary files /dev/null and b/tests/fixtures/cbc1-multi/audio_1.m4s differ diff --git a/tests/fixtures/cbc1-multi/audio_init.mp4 b/tests/fixtures/cbc1-multi/audio_init.mp4 new file mode 100644 index 0000000..06fd71a Binary files /dev/null and b/tests/fixtures/cbc1-multi/audio_init.mp4 differ diff --git a/tests/fixtures/cbc1-multi/video_1.clear.m4s b/tests/fixtures/cbc1-multi/video_1.clear.m4s new file mode 100644 index 0000000..182797e Binary files /dev/null and b/tests/fixtures/cbc1-multi/video_1.clear.m4s differ diff --git a/tests/fixtures/cbc1-multi/video_1.m4s b/tests/fixtures/cbc1-multi/video_1.m4s new file mode 100644 index 0000000..38d8111 Binary files /dev/null and b/tests/fixtures/cbc1-multi/video_1.m4s differ diff --git a/tests/fixtures/cbc1-multi/video_init.mp4 b/tests/fixtures/cbc1-multi/video_init.mp4 new file mode 100644 index 0000000..7a348f4 Binary files /dev/null and b/tests/fixtures/cbc1-multi/video_init.mp4 differ diff --git a/tests/fixtures/cbc1-single/audio_1.clear.m4s b/tests/fixtures/cbc1-single/audio_1.clear.m4s new file mode 100644 index 0000000..fc0dbf9 Binary files /dev/null and b/tests/fixtures/cbc1-single/audio_1.clear.m4s differ diff --git a/tests/fixtures/cbc1-single/audio_1.m4s b/tests/fixtures/cbc1-single/audio_1.m4s new file mode 100644 index 0000000..5ca2bef Binary files /dev/null and b/tests/fixtures/cbc1-single/audio_1.m4s differ diff --git a/tests/fixtures/cbc1-single/audio_init.mp4 b/tests/fixtures/cbc1-single/audio_init.mp4 new file mode 100644 index 0000000..d754455 Binary files /dev/null and b/tests/fixtures/cbc1-single/audio_init.mp4 differ diff --git a/tests/fixtures/cbc1-single/video_1.clear.m4s b/tests/fixtures/cbc1-single/video_1.clear.m4s new file mode 100644 index 0000000..182797e Binary files /dev/null and b/tests/fixtures/cbc1-single/video_1.clear.m4s differ diff --git a/tests/fixtures/cbc1-single/video_1.m4s b/tests/fixtures/cbc1-single/video_1.m4s new file mode 100644 index 0000000..bdfe797 Binary files /dev/null and b/tests/fixtures/cbc1-single/video_1.m4s differ diff --git a/tests/fixtures/cbc1-single/video_init.mp4 b/tests/fixtures/cbc1-single/video_init.mp4 new file mode 100644 index 0000000..b173a3d Binary files /dev/null and b/tests/fixtures/cbc1-single/video_init.mp4 differ diff --git a/tests/fixtures/cbcs-multi/audio_1.clear.m4s b/tests/fixtures/cbcs-multi/audio_1.clear.m4s new file mode 100644 index 0000000..edcc98e Binary files /dev/null and b/tests/fixtures/cbcs-multi/audio_1.clear.m4s differ diff --git a/tests/fixtures/cbcs-multi/audio_1.m4s b/tests/fixtures/cbcs-multi/audio_1.m4s new file mode 100644 index 0000000..4c13342 Binary files /dev/null and b/tests/fixtures/cbcs-multi/audio_1.m4s differ diff --git a/tests/fixtures/cbcs-multi/audio_init.mp4 b/tests/fixtures/cbcs-multi/audio_init.mp4 new file mode 100644 index 0000000..0c8c3ab Binary files /dev/null and b/tests/fixtures/cbcs-multi/audio_init.mp4 differ diff --git a/tests/fixtures/cbcs-multi/video_1.clear.m4s b/tests/fixtures/cbcs-multi/video_1.clear.m4s new file mode 100644 index 0000000..3eda6dd Binary files /dev/null and b/tests/fixtures/cbcs-multi/video_1.clear.m4s differ diff --git a/tests/fixtures/cbcs-multi/video_1.m4s b/tests/fixtures/cbcs-multi/video_1.m4s new file mode 100644 index 0000000..c3c70df Binary files /dev/null and b/tests/fixtures/cbcs-multi/video_1.m4s differ diff --git a/tests/fixtures/cbcs-multi/video_init.mp4 b/tests/fixtures/cbcs-multi/video_init.mp4 new file mode 100644 index 0000000..559ac65 Binary files /dev/null and b/tests/fixtures/cbcs-multi/video_init.mp4 differ diff --git a/tests/fixtures/cbcs-single/audio_1.clear.m4s b/tests/fixtures/cbcs-single/audio_1.clear.m4s new file mode 100644 index 0000000..edcc98e Binary files /dev/null and b/tests/fixtures/cbcs-single/audio_1.clear.m4s differ diff --git a/tests/fixtures/cbcs-single/audio_1.m4s b/tests/fixtures/cbcs-single/audio_1.m4s new file mode 100644 index 0000000..30d57f3 Binary files /dev/null and b/tests/fixtures/cbcs-single/audio_1.m4s differ diff --git a/tests/fixtures/cbcs-single/audio_init.mp4 b/tests/fixtures/cbcs-single/audio_init.mp4 new file mode 100644 index 0000000..347e2a5 Binary files /dev/null and b/tests/fixtures/cbcs-single/audio_init.mp4 differ diff --git a/tests/fixtures/cbcs-single/video_1.clear.m4s b/tests/fixtures/cbcs-single/video_1.clear.m4s new file mode 100644 index 0000000..3eda6dd Binary files /dev/null and b/tests/fixtures/cbcs-single/video_1.clear.m4s differ diff --git a/tests/fixtures/cbcs-single/video_1.m4s b/tests/fixtures/cbcs-single/video_1.m4s new file mode 100644 index 0000000..f06afb0 Binary files /dev/null and b/tests/fixtures/cbcs-single/video_1.m4s differ diff --git a/tests/fixtures/cbcs-single/video_init.mp4 b/tests/fixtures/cbcs-single/video_init.mp4 new file mode 100644 index 0000000..99e7c5e Binary files /dev/null and b/tests/fixtures/cbcs-single/video_init.mp4 differ diff --git a/tests/fixtures/cenc-multi-track/encrypted.mp4 b/tests/fixtures/cenc-multi-track/encrypted.mp4 new file mode 100644 index 0000000..98ac9cc Binary files /dev/null and b/tests/fixtures/cenc-multi-track/encrypted.mp4 differ diff --git a/tests/fixtures/cenc-multi-track/expected-decrypted.mp4 b/tests/fixtures/cenc-multi-track/expected-decrypted.mp4 new file mode 100644 index 0000000..0c843b2 Binary files /dev/null and b/tests/fixtures/cenc-multi-track/expected-decrypted.mp4 differ diff --git a/tests/fixtures/cenc-multi/audio_1.clear.m4s b/tests/fixtures/cenc-multi/audio_1.clear.m4s new file mode 100644 index 0000000..eb28225 Binary files /dev/null and b/tests/fixtures/cenc-multi/audio_1.clear.m4s differ diff --git a/tests/fixtures/cenc-multi/audio_1.m4s b/tests/fixtures/cenc-multi/audio_1.m4s new file mode 100644 index 0000000..52d7fc9 Binary files /dev/null and b/tests/fixtures/cenc-multi/audio_1.m4s differ diff --git a/tests/fixtures/cenc-multi/audio_init.mp4 b/tests/fixtures/cenc-multi/audio_init.mp4 new file mode 100644 index 0000000..4494bfd Binary files /dev/null and b/tests/fixtures/cenc-multi/audio_init.mp4 differ diff --git a/tests/fixtures/cenc-multi/video_1.clear.m4s b/tests/fixtures/cenc-multi/video_1.clear.m4s new file mode 100644 index 0000000..9ec9f3e Binary files /dev/null and b/tests/fixtures/cenc-multi/video_1.clear.m4s differ diff --git a/tests/fixtures/cenc-multi/video_1.m4s b/tests/fixtures/cenc-multi/video_1.m4s new file mode 100644 index 0000000..1e45532 Binary files /dev/null and b/tests/fixtures/cenc-multi/video_1.m4s differ diff --git a/tests/fixtures/cenc-multi/video_init.mp4 b/tests/fixtures/cenc-multi/video_init.mp4 new file mode 100644 index 0000000..29ea585 Binary files /dev/null and b/tests/fixtures/cenc-multi/video_init.mp4 differ diff --git a/tests/fixtures/cenc-single/audio_1.clear.m4s b/tests/fixtures/cenc-single/audio_1.clear.m4s new file mode 100644 index 0000000..eb28225 Binary files /dev/null and b/tests/fixtures/cenc-single/audio_1.clear.m4s differ diff --git a/tests/fixtures/cenc-single/audio_1.m4s b/tests/fixtures/cenc-single/audio_1.m4s new file mode 100644 index 0000000..94ec6ef Binary files /dev/null and b/tests/fixtures/cenc-single/audio_1.m4s differ diff --git a/tests/fixtures/cenc-single/audio_init.mp4 b/tests/fixtures/cenc-single/audio_init.mp4 new file mode 100644 index 0000000..dd48d1d Binary files /dev/null and b/tests/fixtures/cenc-single/audio_init.mp4 differ diff --git a/tests/fixtures/cenc-single/video_1.clear.m4s b/tests/fixtures/cenc-single/video_1.clear.m4s new file mode 100644 index 0000000..9ec9f3e Binary files /dev/null and b/tests/fixtures/cenc-single/video_1.clear.m4s differ diff --git a/tests/fixtures/cenc-single/video_1.m4s b/tests/fixtures/cenc-single/video_1.m4s new file mode 100644 index 0000000..0dc2929 Binary files /dev/null and b/tests/fixtures/cenc-single/video_1.m4s differ diff --git a/tests/fixtures/cenc-single/video_init.mp4 b/tests/fixtures/cenc-single/video_init.mp4 new file mode 100644 index 0000000..a15f2dd Binary files /dev/null and b/tests/fixtures/cenc-single/video_init.mp4 differ diff --git a/tests/fixtures/cens-multi/audio_1.clear.m4s b/tests/fixtures/cens-multi/audio_1.clear.m4s new file mode 100644 index 0000000..90597d8 Binary files /dev/null and b/tests/fixtures/cens-multi/audio_1.clear.m4s differ diff --git a/tests/fixtures/cens-multi/audio_1.m4s b/tests/fixtures/cens-multi/audio_1.m4s new file mode 100644 index 0000000..395fb9d Binary files /dev/null and b/tests/fixtures/cens-multi/audio_1.m4s differ diff --git a/tests/fixtures/cens-multi/audio_init.mp4 b/tests/fixtures/cens-multi/audio_init.mp4 new file mode 100644 index 0000000..1bf5e32 Binary files /dev/null and b/tests/fixtures/cens-multi/audio_init.mp4 differ diff --git a/tests/fixtures/cens-multi/video_1.clear.m4s b/tests/fixtures/cens-multi/video_1.clear.m4s new file mode 100644 index 0000000..9ec9f3e Binary files /dev/null and b/tests/fixtures/cens-multi/video_1.clear.m4s differ diff --git a/tests/fixtures/cens-multi/video_1.m4s b/tests/fixtures/cens-multi/video_1.m4s new file mode 100644 index 0000000..d728309 Binary files /dev/null and b/tests/fixtures/cens-multi/video_1.m4s differ diff --git a/tests/fixtures/cens-multi/video_init.mp4 b/tests/fixtures/cens-multi/video_init.mp4 new file mode 100644 index 0000000..62f96f4 Binary files /dev/null and b/tests/fixtures/cens-multi/video_init.mp4 differ diff --git a/tests/fixtures/cens-single/audio_1.clear.m4s b/tests/fixtures/cens-single/audio_1.clear.m4s new file mode 100644 index 0000000..b3bf0e8 Binary files /dev/null and b/tests/fixtures/cens-single/audio_1.clear.m4s differ diff --git a/tests/fixtures/cens-single/audio_1.m4s b/tests/fixtures/cens-single/audio_1.m4s new file mode 100644 index 0000000..725a45d Binary files /dev/null and b/tests/fixtures/cens-single/audio_1.m4s differ diff --git a/tests/fixtures/cens-single/audio_init.mp4 b/tests/fixtures/cens-single/audio_init.mp4 new file mode 100644 index 0000000..ae8659f Binary files /dev/null and b/tests/fixtures/cens-single/audio_init.mp4 differ diff --git a/tests/fixtures/cens-single/video_1.clear.m4s b/tests/fixtures/cens-single/video_1.clear.m4s new file mode 100644 index 0000000..9ec9f3e Binary files /dev/null and b/tests/fixtures/cens-single/video_1.clear.m4s differ diff --git a/tests/fixtures/cens-single/video_1.m4s b/tests/fixtures/cens-single/video_1.m4s new file mode 100644 index 0000000..7a188fc Binary files /dev/null and b/tests/fixtures/cens-single/video_1.m4s differ diff --git a/tests/fixtures/cens-single/video_init.mp4 b/tests/fixtures/cens-single/video_init.mp4 new file mode 100644 index 0000000..aca32b2 Binary files /dev/null and b/tests/fixtures/cens-single/video_init.mp4 differ diff --git a/tests/fixtures/isma_iaec_decrypted.mp4 b/tests/fixtures/isma_iaec_decrypted.mp4 new file mode 100644 index 0000000..5f49cf0 Binary files /dev/null and b/tests/fixtures/isma_iaec_decrypted.mp4 differ diff --git a/tests/fixtures/isma_iaec_encrypted.mp4 b/tests/fixtures/isma_iaec_encrypted.mp4 new file mode 100644 index 0000000..e9e1663 Binary files /dev/null and b/tests/fixtures/isma_iaec_encrypted.mp4 differ diff --git a/tests/fixtures/marlin_ipmp_acbc_decrypted.mp4 b/tests/fixtures/marlin_ipmp_acbc_decrypted.mp4 new file mode 100644 index 0000000..52e8953 Binary files /dev/null and b/tests/fixtures/marlin_ipmp_acbc_decrypted.mp4 differ diff --git a/tests/fixtures/marlin_ipmp_acbc_encrypted.mp4 b/tests/fixtures/marlin_ipmp_acbc_encrypted.mp4 new file mode 100644 index 0000000..5ea3300 Binary files /dev/null and b/tests/fixtures/marlin_ipmp_acbc_encrypted.mp4 differ diff --git a/tests/fixtures/marlin_ipmp_acgk_decrypted.mp4 b/tests/fixtures/marlin_ipmp_acgk_decrypted.mp4 new file mode 100644 index 0000000..52e8953 Binary files /dev/null and b/tests/fixtures/marlin_ipmp_acgk_decrypted.mp4 differ diff --git a/tests/fixtures/marlin_ipmp_acgk_encrypted.mp4 b/tests/fixtures/marlin_ipmp_acgk_encrypted.mp4 new file mode 100644 index 0000000..00ce146 Binary files /dev/null and b/tests/fixtures/marlin_ipmp_acgk_encrypted.mp4 differ diff --git a/tests/fixtures/oma_dcf_cbc_decrypted.mp4 b/tests/fixtures/oma_dcf_cbc_decrypted.mp4 new file mode 100644 index 0000000..5f49cf0 Binary files /dev/null and b/tests/fixtures/oma_dcf_cbc_decrypted.mp4 differ diff --git a/tests/fixtures/oma_dcf_cbc_encrypted.mp4 b/tests/fixtures/oma_dcf_cbc_encrypted.mp4 new file mode 100644 index 0000000..943e186 Binary files /dev/null and b/tests/fixtures/oma_dcf_cbc_encrypted.mp4 differ diff --git a/tests/fixtures/oma_dcf_cbc_grpi_decrypted.odf b/tests/fixtures/oma_dcf_cbc_grpi_decrypted.odf new file mode 100644 index 0000000..5f3ea99 Binary files /dev/null and b/tests/fixtures/oma_dcf_cbc_grpi_decrypted.odf differ diff --git a/tests/fixtures/oma_dcf_cbc_grpi_encrypted.odf b/tests/fixtures/oma_dcf_cbc_grpi_encrypted.odf new file mode 100644 index 0000000..acbae71 Binary files /dev/null and b/tests/fixtures/oma_dcf_cbc_grpi_encrypted.odf differ diff --git a/tests/fixtures/oma_dcf_ctr_decrypted.mp4 b/tests/fixtures/oma_dcf_ctr_decrypted.mp4 new file mode 100644 index 0000000..5f49cf0 Binary files /dev/null and b/tests/fixtures/oma_dcf_ctr_decrypted.mp4 differ diff --git a/tests/fixtures/oma_dcf_ctr_encrypted.mp4 b/tests/fixtures/oma_dcf_ctr_encrypted.mp4 new file mode 100644 index 0000000..c33daa2 Binary files /dev/null and b/tests/fixtures/oma_dcf_ctr_encrypted.mp4 differ diff --git a/tests/fixtures/oma_dcf_ctr_grpi_decrypted.odf b/tests/fixtures/oma_dcf_ctr_grpi_decrypted.odf new file mode 100644 index 0000000..b3e85af Binary files /dev/null and b/tests/fixtures/oma_dcf_ctr_grpi_decrypted.odf differ diff --git a/tests/fixtures/oma_dcf_ctr_grpi_encrypted.odf b/tests/fixtures/oma_dcf_ctr_grpi_encrypted.odf new file mode 100644 index 0000000..e49dab3 Binary files /dev/null and b/tests/fixtures/oma_dcf_ctr_grpi_encrypted.odf differ diff --git a/tests/fixtures/piff_cbc_decrypted.mp4 b/tests/fixtures/piff_cbc_decrypted.mp4 new file mode 100644 index 0000000..466e463 Binary files /dev/null and b/tests/fixtures/piff_cbc_decrypted.mp4 differ diff --git a/tests/fixtures/piff_cbc_encrypted.mp4 b/tests/fixtures/piff_cbc_encrypted.mp4 new file mode 100644 index 0000000..466e463 Binary files /dev/null and b/tests/fixtures/piff_cbc_encrypted.mp4 differ diff --git a/tests/fixtures/piff_cbc_init.mp4 b/tests/fixtures/piff_cbc_init.mp4 new file mode 100644 index 0000000..b96cca4 Binary files /dev/null and b/tests/fixtures/piff_cbc_init.mp4 differ diff --git a/tests/fixtures/piff_cbc_media_decrypted.m4s b/tests/fixtures/piff_cbc_media_decrypted.m4s new file mode 100644 index 0000000..39b4174 Binary files /dev/null and b/tests/fixtures/piff_cbc_media_decrypted.m4s differ diff --git a/tests/fixtures/piff_cbc_media_encrypted.m4s b/tests/fixtures/piff_cbc_media_encrypted.m4s new file mode 100644 index 0000000..39b4174 Binary files /dev/null and b/tests/fixtures/piff_cbc_media_encrypted.m4s differ diff --git a/tests/fixtures/piff_ctr_decrypted.mp4 b/tests/fixtures/piff_ctr_decrypted.mp4 new file mode 100644 index 0000000..55dacca Binary files /dev/null and b/tests/fixtures/piff_ctr_decrypted.mp4 differ diff --git a/tests/fixtures/piff_ctr_encrypted.mp4 b/tests/fixtures/piff_ctr_encrypted.mp4 new file mode 100644 index 0000000..55dacca Binary files /dev/null and b/tests/fixtures/piff_ctr_encrypted.mp4 differ diff --git a/tests/fixtures/piff_ctr_init.mp4 b/tests/fixtures/piff_ctr_init.mp4 new file mode 100644 index 0000000..1eb2a02 Binary files /dev/null and b/tests/fixtures/piff_ctr_init.mp4 differ diff --git a/tests/fixtures/piff_ctr_media_decrypted.m4s b/tests/fixtures/piff_ctr_media_decrypted.m4s new file mode 100644 index 0000000..0550e4f Binary files /dev/null and b/tests/fixtures/piff_ctr_media_decrypted.m4s differ diff --git a/tests/fixtures/piff_ctr_media_encrypted.m4s b/tests/fixtures/piff_ctr_media_encrypted.m4s new file mode 100644 index 0000000..0550e4f Binary files /dev/null and b/tests/fixtures/piff_ctr_media_encrypted.m4s differ diff --git a/tests/sidx.rs b/tests/sidx.rs index a3d5871..93c2d5c 100644 --- a/tests/sidx.rs +++ b/tests/sidx.rs @@ -133,6 +133,51 @@ fn analyze_top_level_sidx_update_uses_existing_top_level_sidx_boundaries() { assert_eq!(analysis.segments[1].size, moofs[1].size() + mdats[1].size()); } +#[test] +fn analyze_top_level_sidx_update_uses_existing_top_level_sidx_after_leading_styp() { + let input = build_styp_prefixed_top_level_sidx_fragmented_single_track_file(false); + let analysis = analyze_top_level_sidx_update_bytes(&input).unwrap(); + + let styps = extract_box( + &mut Cursor::new(input.clone()), + None, + BoxPath::from([fourcc("styp")]), + ) + .unwrap(); + let sidx = extract_box( + &mut Cursor::new(input.clone()), + None, + BoxPath::from([fourcc("sidx")]), + ) + .unwrap(); + let moofs = extract_box( + &mut Cursor::new(input.clone()), + None, + BoxPath::from([fourcc("moof")]), + ) + .unwrap(); + let mdats = extract_box( + &mut Cursor::new(input), + None, + BoxPath::from([fourcc("mdat")]), + ) + .unwrap(); + + assert_eq!(styps.len(), 1); + assert_eq!(analysis.placement.insertion_box, moofs[0]); + assert_eq!(analysis.placement.existing_top_level_sidxs.len(), 1); + assert_eq!(analysis.placement.existing_top_level_sidxs[0].info, sidx[0]); + assert_eq!( + analysis.placement.existing_top_level_sidxs[0].segment_starts, + moofs.iter().map(|info| info.offset()).collect::>() + ); + assert_eq!(analysis.segments.len(), 2); + assert_eq!(analysis.segments[0].first_box, moofs[0]); + assert_eq!(analysis.segments[0].size, moofs[0].size() + mdats[0].size()); + assert_eq!(analysis.segments[1].first_box, moofs[1]); + assert_eq!(analysis.segments[1].size, moofs[1].size() + mdats[1].size()); +} + #[test] fn analyze_top_level_sidx_update_matches_interleaved_fixture_grouping() { let input = fs::read(fixture_path("sample_fragmented.mp4")).unwrap(); @@ -315,6 +360,63 @@ fn plan_top_level_sidx_update_builds_replace_plan_with_non_zero_ept() { assert_eq!(plan.sidx.references[1].subsegment_duration, 40); } +#[test] +fn plan_top_level_sidx_update_replaces_existing_top_level_sidx_after_leading_styp() { + let input = build_styp_prefixed_top_level_sidx_fragmented_single_track_file(false); + + let plan = plan_top_level_sidx_update( + &mut Cursor::new(&input), + TopLevelSidxPlanOptions { + add_if_not_exists: false, + non_zero_ept: false, + }, + ) + .unwrap() + .unwrap(); + + let styps = extract_box( + &mut Cursor::new(input.clone()), + None, + BoxPath::from([fourcc("styp")]), + ) + .unwrap(); + let sidx = extract_box( + &mut Cursor::new(input.clone()), + None, + BoxPath::from([fourcc("sidx")]), + ) + .unwrap(); + let moofs = extract_box( + &mut Cursor::new(input.clone()), + None, + BoxPath::from([fourcc("moof")]), + ) + .unwrap(); + let mdats = extract_box( + &mut Cursor::new(input), + None, + BoxPath::from([fourcc("mdat")]), + ) + .unwrap(); + + assert_eq!(styps.len(), 1); + match &plan.action { + TopLevelSidxPlanAction::Replace { existing } => assert_eq!(existing.info, sidx[0]), + TopLevelSidxPlanAction::Insert => panic!("expected replace plan"), + } + assert_eq!(plan.insertion_box, moofs[0]); + assert_eq!(plan.entries[0].start_offset, moofs[0].offset()); + assert_eq!( + plan.entries[0].target_size, + u32::try_from(moofs[0].size() + mdats[0].size()).unwrap() + ); + assert_eq!(plan.entries[1].start_offset, moofs[1].offset()); + assert_eq!( + plan.entries[1].target_size, + u32::try_from(moofs[1].size() + mdats[1].size()).unwrap() + ); +} + #[test] fn plan_top_level_sidx_update_matches_interleaved_fixture_payload() { let input = fs::read(fixture_path("sample_fragmented.mp4")).unwrap(); @@ -803,6 +905,66 @@ fn build_top_level_sidx_fragmented_single_track_file(indirect_first_entry: bool) [ftyp, moov, sidx, moof1, mdat1, moof2, mdat2].concat() } +fn build_styp_prefixed_top_level_sidx_fragmented_single_track_file( + indirect_first_entry: bool, +) -> Vec { + let ftyp = fragmented_ftyp(); + let moov = build_fragmented_moov(&[TrackSpec { + track_id: 1, + handler_type: "vide", + timescale: 1_000, + }]); + let styp = segment_styp(); + let moof1 = build_moof(&[TrafSpec { + track_id: 1, + base_decode_time: 100, + default_sample_duration: None, + sample_durations: &[30, 30], + sample_sizes: &[4, 4], + composition_offsets: &[5, 0], + }]); + let mdat1 = encode_raw_box(fourcc("mdat"), &[0; 8]); + let moof2 = build_moof(&[TrafSpec { + track_id: 1, + base_decode_time: 160, + default_sample_duration: None, + sample_durations: &[40], + sample_sizes: &[6], + composition_offsets: &[0], + }]); + let mdat2 = encode_raw_box(fourcc("mdat"), &[0; 6]); + + let first_segment_size = u32::try_from(moof1.len() + mdat1.len()).unwrap(); + let second_segment_size = u32::try_from(moof2.len() + mdat2.len()).unwrap(); + + let mut sidx = Sidx::default(); + sidx.set_version(1); + sidx.reference_id = 1; + sidx.timescale = 1_000; + sidx.reference_count = 2; + sidx.references = vec![ + SidxReference { + reference_type: indirect_first_entry, + referenced_size: first_segment_size, + subsegment_duration: 60, + starts_with_sap: true, + sap_type: 1, + sap_delta_time: 0, + }, + SidxReference { + reference_type: false, + referenced_size: second_segment_size, + subsegment_duration: 40, + starts_with_sap: true, + sap_type: 1, + sap_delta_time: 0, + }, + ]; + let sidx = encode_supported_box(&sidx, &[]); + + [ftyp, moov, styp, sidx, moof1, mdat1, moof2, mdat2].concat() +} + fn build_gapped_top_level_sidx_fragmented_single_track_file_v0() -> Vec { let ftyp = fragmented_ftyp(); let moov = build_fragmented_moov(&[TrackSpec { diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 8aa5da6..b9c6f8b 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -1,11 +1,21 @@ #![allow(dead_code)] #![allow(clippy::field_reassign_with_default)] +#[cfg(feature = "decrypt")] +use std::collections::BTreeMap; use std::fs; +#[cfg(feature = "decrypt")] +use std::io::{Cursor, Seek}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; +#[cfg(feature = "decrypt")] +use aes::Aes128; +#[cfg(feature = "decrypt")] +use aes::cipher::{Block, BlockEncrypt, KeyInit}; use mp4forge::boxes::AnyTypeBox; +#[cfg(feature = "decrypt")] +use mp4forge::boxes::isma_cryp::{Isfm, Islt}; use mp4forge::boxes::iso14496_12::{ AVCDecoderConfiguration, Btrt, Emeb, Emib, EventMessageSampleEntry, Frma, Ftyp, Hdlr, Mdhd, Mdia, Mfhd, Minf, Moof, Moov, Mvex, Mvhd, Pasp, Saio, Saiz, SampleEntry, Sbgp, SbgpEntry, Schi, @@ -13,11 +23,31 @@ use mp4forge::boxes::iso14496_12::{ TFHD_DEFAULT_SAMPLE_DURATION_PRESENT, TFHD_DEFAULT_SAMPLE_SIZE_PRESENT, Tfdt, Tfhd, Traf, Trak, Trex, Trun, VisualSampleEntry, }; +#[cfg(feature = "decrypt")] +use mp4forge::boxes::iso14496_12::{StscEntry, UUID_SAMPLE_ENCRYPTION, Uuid, UuidPayload}; +#[cfg(feature = "decrypt")] +use mp4forge::boxes::iso14496_12::{ + TFHD_DEFAULT_BASE_IS_MOOF, TFHD_SAMPLE_DESCRIPTION_INDEX_PRESENT, TRUN_DATA_OFFSET_PRESENT, +}; +#[cfg(feature = "decrypt")] +use mp4forge::boxes::iso14496_14::Iods; use mp4forge::boxes::iso23001_7::{ SENC_USE_SUBSAMPLE_ENCRYPTION, Senc, SencSample, SencSubsample, Tenc, }; +#[cfg(feature = "decrypt")] +use mp4forge::boxes::oma_dcf::{ + OHDR_ENCRYPTION_METHOD_AES_CTR, OHDR_PADDING_SCHEME_NONE, Odaf, Odkm, Ohdr, +}; use mp4forge::codec::MutableBox; use mp4forge::codec::{CodecBox, marshal}; +#[cfg(feature = "decrypt")] +use mp4forge::decrypt::{DecryptionKey, NativeCommonEncryptionScheme}; +#[cfg(feature = "decrypt")] +use mp4forge::encryption::{ResolvedSampleEncryptionSample, ResolvedSampleEncryptionSource}; +#[cfg(feature = "decrypt")] +use mp4forge::extract::{extract_box, extract_box_as}; +#[cfg(feature = "decrypt")] +use mp4forge::walk::BoxPath; use mp4forge::{BoxInfo, FourCc}; pub fn encode_supported_box(box_value: &B, children: &[u8]) -> Vec @@ -69,75 +99,2634 @@ pub fn fixture_path(name: &str) -> PathBuf { .join(name) } -pub fn read_text(path: &Path) -> String { - normalize_text(&fs::read_to_string(path).unwrap()) +#[cfg(feature = "decrypt")] +pub struct RetainedDecryptFileFixture { + pub encrypted_path: PathBuf, + pub decrypted_path: PathBuf, + pub keys: Vec, } -pub fn read_golden(relative_path: &str) -> String { - read_text( - &PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests") - .join("golden") - .join(relative_path), +#[cfg(feature = "decrypt")] +pub struct RetainedFragmentedDecryptFixture { + pub fragments_info_path: PathBuf, + pub encrypted_segment_path: PathBuf, + pub clear_segment_path: PathBuf, + pub keys: Vec, +} + +#[cfg(feature = "decrypt")] +const COMMON_ENCRYPTION_VIDEO_KID: [u8; 16] = [ + 0xeb, 0x67, 0x6a, 0xbb, 0xcb, 0x34, 0x5e, 0x96, 0xbb, 0xcf, 0x61, 0x66, 0x30, 0xf1, 0xa3, 0xda, +]; + +#[cfg(feature = "decrypt")] +const COMMON_ENCRYPTION_VIDEO_KEY: [u8; 16] = [ + 0x10, 0x0b, 0x6c, 0x20, 0x94, 0x0f, 0x77, 0x9a, 0x45, 0x89, 0x15, 0x2b, 0x57, 0xd2, 0xda, 0xcb, +]; + +#[cfg(feature = "decrypt")] +const COMMON_ENCRYPTION_AUDIO_KID: [u8; 16] = [ + 0x63, 0xcb, 0x5f, 0x71, 0x84, 0xdd, 0x4b, 0x68, 0x9a, 0x5c, 0x5f, 0xf1, 0x1e, 0xe6, 0xa3, 0x28, +]; + +#[cfg(feature = "decrypt")] +const COMMON_ENCRYPTION_AUDIO_KEY: [u8; 16] = [ + 0x3b, 0xda, 0x33, 0x29, 0x15, 0x8a, 0x47, 0x89, 0x88, 0x08, 0x16, 0xa7, 0x0e, 0x7e, 0x43, 0x6d, +]; + +#[cfg(feature = "decrypt")] +fn retained_decrypt_file_fixture( + encrypted_name: &str, + decrypted_name: &str, + keys: Vec, +) -> RetainedDecryptFileFixture { + RetainedDecryptFileFixture { + encrypted_path: fixture_path(encrypted_name), + decrypted_path: fixture_path(decrypted_name), + keys, + } +} + +#[cfg(feature = "decrypt")] +fn retained_fragmented_decrypt_fixture( + fragments_info_name: &str, + encrypted_segment_name: &str, + clear_segment_name: &str, + keys: Vec, +) -> RetainedFragmentedDecryptFixture { + RetainedFragmentedDecryptFixture { + fragments_info_path: fixture_path(fragments_info_name), + encrypted_segment_path: fixture_path(encrypted_segment_name), + clear_segment_path: fixture_path(clear_segment_name), + keys, + } +} + +#[cfg(feature = "decrypt")] +pub fn oma_dcf_ctr_fixture() -> RetainedDecryptFileFixture { + retained_decrypt_file_fixture( + "oma_dcf_ctr_encrypted.mp4", + "oma_dcf_ctr_decrypted.mp4", + vec![DecryptionKey::track(1, [0x11; 16])], + ) +} + +#[cfg(feature = "decrypt")] +pub fn oma_dcf_cbc_fixture() -> RetainedDecryptFileFixture { + retained_decrypt_file_fixture( + "oma_dcf_cbc_encrypted.mp4", + "oma_dcf_cbc_decrypted.mp4", + vec![DecryptionKey::track(1, [0x11; 16])], + ) +} + +#[cfg(feature = "decrypt")] +pub fn oma_dcf_ctr_grpi_fixture() -> RetainedDecryptFileFixture { + retained_decrypt_file_fixture( + "oma_dcf_ctr_grpi_encrypted.odf", + "oma_dcf_ctr_grpi_decrypted.odf", + vec![DecryptionKey::track(1, [0x33; 16])], + ) +} + +#[cfg(feature = "decrypt")] +pub fn oma_dcf_cbc_grpi_fixture() -> RetainedDecryptFileFixture { + retained_decrypt_file_fixture( + "oma_dcf_cbc_grpi_encrypted.odf", + "oma_dcf_cbc_grpi_decrypted.odf", + vec![DecryptionKey::track(1, [0x33; 16])], + ) +} + +#[cfg(feature = "decrypt")] +pub fn isma_iaec_fixture() -> RetainedDecryptFileFixture { + retained_decrypt_file_fixture( + "isma_iaec_encrypted.mp4", + "isma_iaec_decrypted.mp4", + vec![DecryptionKey::track(1, [0x44; 16])], + ) +} + +#[cfg(feature = "decrypt")] +pub fn common_encryption_single_key_fixture_keys() -> Vec { + vec![DecryptionKey::kid( + COMMON_ENCRYPTION_VIDEO_KID, + COMMON_ENCRYPTION_VIDEO_KEY, + )] +} + +#[cfg(feature = "decrypt")] +pub fn common_encryption_multi_key_fixture_keys() -> Vec { + vec![ + DecryptionKey::kid(COMMON_ENCRYPTION_VIDEO_KID, COMMON_ENCRYPTION_VIDEO_KEY), + DecryptionKey::kid(COMMON_ENCRYPTION_AUDIO_KID, COMMON_ENCRYPTION_AUDIO_KEY), + ] +} + +#[cfg(feature = "decrypt")] +pub fn common_encryption_multi_track_fixture() -> RetainedDecryptFileFixture { + retained_decrypt_file_fixture( + "cenc-multi-track/encrypted.mp4", + "cenc-multi-track/expected-decrypted.mp4", + common_encryption_multi_key_fixture_keys(), + ) +} + +#[cfg(feature = "decrypt")] +pub fn common_encryption_fragment_fixture( + directory: &str, + track: &str, +) -> RetainedFragmentedDecryptFixture { + let keys = match directory { + value if value.ends_with("-single") => common_encryption_single_key_fixture_keys(), + value if value.ends_with("-multi") => common_encryption_multi_key_fixture_keys(), + _ => panic!("unsupported Common Encryption fixture directory: {directory}"), + }; + + RetainedFragmentedDecryptFixture { + fragments_info_path: fixture_path(directory).join(format!("{track}_init.mp4")), + encrypted_segment_path: fixture_path(directory).join(format!("{track}_1.m4s")), + clear_segment_path: fixture_path(directory).join(format!("{track}_1.clear.m4s")), + keys, + } +} + +#[cfg(feature = "decrypt")] +pub fn piff_ctr_fixture() -> RetainedDecryptFileFixture { + retained_decrypt_file_fixture( + "piff_ctr_encrypted.mp4", + "piff_ctr_decrypted.mp4", + common_encryption_single_key_fixture_keys(), + ) +} + +#[cfg(feature = "decrypt")] +pub fn piff_cbc_fixture() -> RetainedDecryptFileFixture { + retained_decrypt_file_fixture( + "piff_cbc_encrypted.mp4", + "piff_cbc_decrypted.mp4", + common_encryption_single_key_fixture_keys(), + ) +} + +#[cfg(feature = "decrypt")] +pub fn piff_ctr_segment_fixture() -> RetainedFragmentedDecryptFixture { + retained_fragmented_decrypt_fixture( + "piff_ctr_init.mp4", + "piff_ctr_media_encrypted.m4s", + "piff_ctr_media_decrypted.m4s", + common_encryption_single_key_fixture_keys(), + ) +} + +#[cfg(feature = "decrypt")] +pub fn piff_cbc_segment_fixture() -> RetainedFragmentedDecryptFixture { + retained_fragmented_decrypt_fixture( + "piff_cbc_init.mp4", + "piff_cbc_media_encrypted.m4s", + "piff_cbc_media_decrypted.m4s", + common_encryption_single_key_fixture_keys(), + ) +} + +#[cfg(feature = "decrypt")] +pub fn marlin_ipmp_acbc_encrypted_fixture_path() -> PathBuf { + fixture_path("marlin_ipmp_acbc_encrypted.mp4") +} + +#[cfg(feature = "decrypt")] +pub fn marlin_ipmp_acbc_decrypted_fixture_path() -> PathBuf { + fixture_path("marlin_ipmp_acbc_decrypted.mp4") +} + +#[cfg(feature = "decrypt")] +pub fn marlin_ipmp_acbc_fixture_keys() -> Vec { + vec![ + DecryptionKey::track( + 1, + [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, + 0xee, 0xff, + ], + ), + DecryptionKey::track( + 2, + [ + 0x10, 0x21, 0x32, 0x43, 0x54, 0x65, 0x76, 0x87, 0x98, 0xa9, 0xba, 0xbc, 0xbd, 0xdc, + 0xed, 0xfe, + ], + ), + ] +} + +#[cfg(feature = "decrypt")] +pub fn marlin_ipmp_acbc_fixture() -> RetainedDecryptFileFixture { + retained_decrypt_file_fixture( + "marlin_ipmp_acbc_encrypted.mp4", + "marlin_ipmp_acbc_decrypted.mp4", + marlin_ipmp_acbc_fixture_keys(), + ) +} + +#[cfg(feature = "decrypt")] +pub fn marlin_ipmp_acgk_encrypted_fixture_path() -> PathBuf { + fixture_path("marlin_ipmp_acgk_encrypted.mp4") +} + +#[cfg(feature = "decrypt")] +pub fn marlin_ipmp_acgk_decrypted_fixture_path() -> PathBuf { + fixture_path("marlin_ipmp_acgk_decrypted.mp4") +} + +#[cfg(feature = "decrypt")] +pub fn marlin_ipmp_acgk_fixture_keys() -> Vec { + vec![DecryptionKey::track( + 0, + [ + 0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, + 0x11, 0x00, + ], + )] +} + +#[cfg(feature = "decrypt")] +pub fn marlin_ipmp_acgk_fixture() -> RetainedDecryptFileFixture { + retained_decrypt_file_fixture( + "marlin_ipmp_acgk_encrypted.mp4", + "marlin_ipmp_acgk_decrypted.mp4", + marlin_ipmp_acgk_fixture_keys(), + ) +} + +#[cfg(feature = "decrypt")] +pub struct ProtectedMovieTopologyFixture { + pub encrypted: Vec, + pub decrypted: Vec, + pub keys: Vec, +} + +#[cfg(feature = "decrypt")] +struct SampleEntryMovieTrackSpec { + track_id: u32, + width: u16, + height: u16, + sample_entry: Vec, + samples: Vec>, + chunk_sample_counts: Vec, +} + +#[cfg(feature = "decrypt")] +#[derive(Clone)] +enum RetainedTrackChunkOffsetState { + Stco { + info: BoxInfo, + box_value: Stco, + }, + Co64 { + info: BoxInfo, + box_value: mp4forge::boxes::iso14496_12::Co64, + }, +} + +#[cfg(feature = "decrypt")] +#[derive(Clone)] +struct RetainedMarlinTrackLayout { + track_id: u32, + trak_info: BoxInfo, + mdia_info: BoxInfo, + minf_info: BoxInfo, + stbl_info: BoxInfo, + stsd_info: BoxInfo, + stsc_info: BoxInfo, + stsc: Stsc, + stsz_info: BoxInfo, + stsz: Stsz, + chunk_offsets: RetainedTrackChunkOffsetState, +} + +#[cfg(feature = "decrypt")] +struct GeneratedProtectedMovieTrackLayout { + track_id: u32, + trak_info: BoxInfo, + mdia_info: BoxInfo, + minf_info: BoxInfo, + stbl_info: BoxInfo, + stsd_info: BoxInfo, + stsc_info: BoxInfo, + stsz_info: BoxInfo, + stsz: Stsz, + chunk_offsets: RetainedTrackChunkOffsetState, +} + +#[cfg(feature = "decrypt")] +pub fn build_marlin_ipmp_acbc_broader_movie_fixture() -> ProtectedMovieTopologyFixture { + build_broader_marlin_movie_fixture(&marlin_ipmp_acbc_fixture()) +} + +#[cfg(feature = "decrypt")] +pub fn build_marlin_ipmp_acgk_broader_movie_fixture() -> ProtectedMovieTopologyFixture { + build_broader_marlin_movie_fixture(&marlin_ipmp_acgk_fixture()) +} + +#[cfg(feature = "decrypt")] +pub fn build_marlin_ipmp_acbc_sample_description_index_movie_fixture() +-> ProtectedMovieTopologyFixture { + build_sample_description_index_marlin_movie_fixture(&marlin_ipmp_acbc_fixture()) +} + +#[cfg(feature = "decrypt")] +pub fn build_marlin_ipmp_acgk_sample_description_index_movie_fixture() +-> ProtectedMovieTopologyFixture { + build_sample_description_index_marlin_movie_fixture(&marlin_ipmp_acgk_fixture()) +} + +#[cfg(feature = "decrypt")] +fn build_broader_marlin_movie_fixture( + retained: &RetainedDecryptFileFixture, +) -> ProtectedMovieTopologyFixture { + let trailing_free = encode_raw_box(fourcc("free"), &[0x4d, 0x34, 0x34, 0x34]); + let encrypted = fs::read(&retained.encrypted_path).unwrap(); + let decrypted = fs::read(&retained.decrypted_path).unwrap(); + + ProtectedMovieTopologyFixture { + encrypted: broaden_retained_marlin_movie_bytes(&encrypted, &trailing_free), + decrypted: insert_root_box_before_single_mdat_and_shift_offsets(&decrypted, &trailing_free), + keys: retained.keys.clone(), + } +} + +#[cfg(feature = "decrypt")] +fn build_sample_description_index_marlin_movie_fixture( + retained: &RetainedDecryptFileFixture, +) -> ProtectedMovieTopologyFixture { + let broader = build_broader_marlin_movie_fixture(retained); + ProtectedMovieTopologyFixture { + encrypted: patch_marlin_od_track_sample_description_index(&broader.encrypted), + decrypted: broader.decrypted, + keys: broader.keys, + } +} + +#[cfg(feature = "decrypt")] +fn broaden_retained_marlin_movie_bytes(input: &[u8], trailing_root_box: &[u8]) -> Vec { + let root_boxes = read_root_box_infos(input); + let moov_info = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == fourcc("moov")) + .unwrap(); + let mdat_info = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == fourcc("mdat")) + .unwrap(); + + let iods = extract_single_as_from_bytes::( + input, + None, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ); + let od_track_id = iods + .initial_object_descriptor() + .unwrap() + .sub_descriptors + .iter() + .find_map(|descriptor| descriptor.es_id_inc_descriptor()) + .unwrap() + .track_id; + + let trak_infos = + extract_infos_from_bytes(input, None, BoxPath::from([fourcc("moov"), fourcc("trak")])); + let track_layouts = trak_infos + .into_iter() + .map(|trak_info| analyze_retained_marlin_track_layout(input, trak_info)) + .collect::>(); + let od_track = track_layouts + .iter() + .find(|layout| layout.track_id == od_track_id) + .cloned() + .unwrap(); + + let original_sample_size = if od_track.stsz.sample_size == 0 { + u32::try_from(od_track.stsz.entry_size[0]).unwrap() + } else { + od_track.stsz.sample_size + }; + let original_offset = retained_track_chunk_offsets(&od_track.chunk_offsets)[0]; + let extra_sample = read_sample_bytes(input, original_offset, original_sample_size).to_vec(); + let appended_sample_offset = mdat_info.offset() + mdat_info.size(); + + let placeholder_od_track = rebuild_retained_marlin_track( + input, + &od_track, + patch_retained_track_stsz(&od_track.stsz, u64::try_from(extra_sample.len()).unwrap()), + patch_retained_track_chunk_offsets( + &od_track.chunk_offsets, + 0, + Some(appended_sample_offset), + ), + None, + None, + ); + let placeholder_moov = rebuild_container_box_with_replacements( + input, + moov_info, + &Moov, + &BTreeMap::from([(od_track.trak_info.offset(), placeholder_od_track)]), + ); + let moov_shift = u64::try_from(placeholder_moov.len()).unwrap() - moov_info.size(); + + let mut moov_replacements = BTreeMap::new(); + for track in &track_layouts { + let extra_offset = + (track.track_id == od_track_id).then_some(appended_sample_offset + moov_shift); + let stsz = if track.track_id == od_track_id { + patch_retained_track_stsz(&track.stsz, u64::try_from(extra_sample.len()).unwrap()) + } else { + track.stsz.clone() + }; + let rebuilt_trak = rebuild_retained_marlin_track( + input, + track, + stsz, + patch_retained_track_chunk_offsets(&track.chunk_offsets, moov_shift, extra_offset), + None, + None, + ); + moov_replacements.insert(track.trak_info.offset(), rebuilt_trak); + } + let rebuilt_moov = + rebuild_container_box_with_replacements(input, moov_info, &Moov, &moov_replacements); + + let mdat_payload = slice_box_bytes(input, mdat_info) + [usize::try_from(mdat_info.header_size()).unwrap()..] + .iter() + .copied() + .chain(extra_sample) + .collect::>(); + let rebuilt_mdat = encode_raw_box(fourcc("mdat"), &mdat_payload); + + let mut output = Vec::new(); + for root_info in root_boxes { + if root_info.offset() == moov_info.offset() { + output.extend_from_slice(&rebuilt_moov); + } else if root_info.offset() == mdat_info.offset() { + output.extend_from_slice(&rebuilt_mdat); + } else { + output.extend_from_slice(slice_box_bytes(input, root_info)); + } + } + output.extend_from_slice(trailing_root_box); + output +} + +#[cfg(feature = "decrypt")] +fn patch_marlin_od_track_sample_description_index(input: &[u8]) -> Vec { + let root_boxes = read_root_box_infos(input); + let moov_info = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == fourcc("moov")) + .unwrap(); + + let iods = extract_single_as_from_bytes::( + input, + None, + BoxPath::from([fourcc("moov"), fourcc("iods")]), + ); + let od_track_id = iods + .initial_object_descriptor() + .unwrap() + .sub_descriptors + .iter() + .find_map(|descriptor| descriptor.es_id_inc_descriptor()) + .unwrap() + .track_id; + + let trak_infos = + extract_infos_from_bytes(input, None, BoxPath::from([fourcc("moov"), fourcc("trak")])); + let track_layouts = trak_infos + .into_iter() + .map(|trak_info| analyze_retained_marlin_track_layout(input, trak_info)) + .collect::>(); + + let placeholder_replacements = track_layouts + .iter() + .map(|track| { + let (stsd_replacement, stsc_replacement) = if track.track_id == od_track_id { + ( + Some(duplicate_retained_marlin_od_track_sample_entry( + input, track, + )), + Some(patch_retained_track_stsc_sample_description_index( + &track.stsc, + 2, + )), + ) + } else { + (None, None) + }; + ( + track.trak_info.offset(), + rebuild_retained_marlin_track( + input, + track, + track.stsz.clone(), + patch_retained_track_chunk_offsets(&track.chunk_offsets, 0, None), + stsd_replacement, + stsc_replacement, + ), + ) + }) + .collect::>(); + let placeholder_moov = + rebuild_container_box_with_replacements(input, moov_info, &Moov, &placeholder_replacements); + let moov_shift = u64::try_from(placeholder_moov.len()).unwrap() - moov_info.size(); + + let moov_replacements = track_layouts + .iter() + .map(|track| { + let (stsd_replacement, stsc_replacement) = if track.track_id == od_track_id { + ( + Some(duplicate_retained_marlin_od_track_sample_entry( + input, track, + )), + Some(patch_retained_track_stsc_sample_description_index( + &track.stsc, + 2, + )), + ) + } else { + (None, None) + }; + ( + track.trak_info.offset(), + rebuild_retained_marlin_track( + input, + track, + track.stsz.clone(), + patch_retained_track_chunk_offsets(&track.chunk_offsets, moov_shift, None), + stsd_replacement, + stsc_replacement, + ), + ) + }) + .collect::>(); + let rebuilt_moov = + rebuild_container_box_with_replacements(input, moov_info, &Moov, &moov_replacements); + + let mut output = Vec::new(); + for root_info in root_boxes { + if root_info.offset() == moov_info.offset() { + output.extend_from_slice(&rebuilt_moov); + } else { + output.extend_from_slice(slice_box_bytes(input, root_info)); + } + } + output +} + +#[cfg(feature = "decrypt")] +fn read_root_box_infos(input: &[u8]) -> Vec { + let mut reader = Cursor::new(input); + let mut boxes = Vec::new(); + while usize::try_from(reader.stream_position().unwrap()) + .ok() + .is_some_and(|offset| offset < input.len()) + { + let info = BoxInfo::read(&mut reader).unwrap(); + info.seek_to_end(&mut reader).unwrap(); + boxes.push(info); + } + boxes +} + +#[cfg(feature = "decrypt")] +fn slice_box_bytes(input: &[u8], info: BoxInfo) -> &[u8] { + let start = usize::try_from(info.offset()).unwrap(); + let end = usize::try_from(info.offset() + info.size()).unwrap(); + &input[start..end] +} + +#[cfg(feature = "decrypt")] +fn extract_infos_from_bytes(input: &[u8], parent: Option<&BoxInfo>, path: BoxPath) -> Vec { + let mut reader = Cursor::new(input); + extract_box(&mut reader, parent, path).unwrap() +} + +#[cfg(feature = "decrypt")] +fn extract_single_info_from_bytes( + input: &[u8], + parent: Option<&BoxInfo>, + path: BoxPath, +) -> BoxInfo { + let infos = extract_infos_from_bytes(input, parent, path); + assert_eq!(infos.len(), 1); + infos[0] +} + +#[cfg(feature = "decrypt")] +fn extract_single_as_from_bytes(input: &[u8], parent: Option<&BoxInfo>, path: BoxPath) -> T +where + T: CodecBox + Clone + 'static, +{ + let mut reader = Cursor::new(input); + let mut values = extract_box_as::<_, T>(&mut reader, parent, path).unwrap(); + assert_eq!(values.len(), 1); + values.remove(0) +} + +#[cfg(feature = "decrypt")] +fn analyze_retained_marlin_track_layout( + input: &[u8], + trak_info: BoxInfo, +) -> RetainedMarlinTrackLayout { + let tkhd = extract_single_as_from_bytes::( + input, + Some(&trak_info), + BoxPath::from([fourcc("tkhd")]), + ); + let mdia_info = + extract_single_info_from_bytes(input, Some(&trak_info), BoxPath::from([fourcc("mdia")])); + let minf_info = extract_single_info_from_bytes( + input, + Some(&trak_info), + BoxPath::from([fourcc("mdia"), fourcc("minf")]), + ); + let stbl_info = extract_single_info_from_bytes( + input, + Some(&trak_info), + BoxPath::from([fourcc("mdia"), fourcc("minf"), fourcc("stbl")]), + ); + let stsd_info = extract_single_info_from_bytes( + input, + Some(&trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + ]), + ); + let stsc_info = extract_single_info_from_bytes( + input, + Some(&trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + let stsc = extract_single_as_from_bytes::( + input, + Some(&trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + let stsz_info = extract_single_info_from_bytes( + input, + Some(&trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + let stsz = extract_single_as_from_bytes::( + input, + Some(&trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + + let stco_infos = extract_infos_from_bytes( + input, + Some(&trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stco"), + ]), + ); + let co64_infos = extract_infos_from_bytes( + input, + Some(&trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("co64"), + ]), + ); + let chunk_offsets = if !stco_infos.is_empty() { + let stco = extract_single_as_from_bytes::( + input, + Some(&trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stco"), + ]), + ); + RetainedTrackChunkOffsetState::Stco { + info: stco_infos[0], + box_value: stco, + } + } else { + let co64 = extract_single_as_from_bytes::( + input, + Some(&trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("co64"), + ]), + ); + RetainedTrackChunkOffsetState::Co64 { + info: co64_infos[0], + box_value: co64, + } + }; + + RetainedMarlinTrackLayout { + track_id: tkhd.track_id, + trak_info, + mdia_info, + minf_info, + stbl_info, + stsd_info, + stsc_info, + stsc, + stsz_info, + stsz, + chunk_offsets, + } +} + +#[cfg(feature = "decrypt")] +fn retained_track_chunk_offsets(chunk_offsets: &RetainedTrackChunkOffsetState) -> Vec { + match chunk_offsets { + RetainedTrackChunkOffsetState::Stco { box_value, .. } => box_value.chunk_offset.to_vec(), + RetainedTrackChunkOffsetState::Co64 { box_value, .. } => box_value.chunk_offset.clone(), + } +} + +#[cfg(feature = "decrypt")] +fn patch_retained_track_stsz(stsz: &Stsz, extra_sample_size: u64) -> Stsz { + let mut patched = stsz.clone(); + patched.sample_count += 1; + if patched.sample_size == 0 { + patched.entry_size.push(extra_sample_size); + } else if u64::from(patched.sample_size) != extra_sample_size { + patched.entry_size = vec![u64::from(stsz.sample_size), extra_sample_size]; + patched.sample_size = 0; + } + patched +} + +#[cfg(feature = "decrypt")] +fn patch_retained_track_chunk_offsets( + chunk_offsets: &RetainedTrackChunkOffsetState, + shift: u64, + extra_offset: Option, +) -> Vec { + match chunk_offsets { + RetainedTrackChunkOffsetState::Stco { box_value, .. } => { + let mut patched = box_value.clone(); + patched.chunk_offset = patched + .chunk_offset + .iter() + .map(|offset| offset + shift) + .collect(); + if let Some(extra_offset) = extra_offset { + patched.chunk_offset.push(extra_offset); + patched.entry_count += 1; + } + encode_supported_box(&patched, &[]) + } + RetainedTrackChunkOffsetState::Co64 { box_value, .. } => { + let mut patched = box_value.clone(); + patched.chunk_offset = patched + .chunk_offset + .iter() + .map(|offset| offset + shift) + .collect(); + if let Some(extra_offset) = extra_offset { + patched.chunk_offset.push(extra_offset); + patched.entry_count += 1; + } + encode_supported_box(&patched, &[]) + } + } +} + +#[cfg(feature = "decrypt")] +fn rebuild_retained_marlin_track( + input: &[u8], + track: &RetainedMarlinTrackLayout, + stsz: Stsz, + chunk_offset_box: Vec, + stsd_box: Option>, + stsc_box: Option>, +) -> Vec { + let chunk_offset_info = match track.chunk_offsets { + RetainedTrackChunkOffsetState::Stco { info, .. } + | RetainedTrackChunkOffsetState::Co64 { info, .. } => info, + }; + let mut stbl_replacements = BTreeMap::from([ + (track.stsz_info.offset(), encode_supported_box(&stsz, &[])), + (chunk_offset_info.offset(), chunk_offset_box), + ]); + if let Some(stsd_box) = stsd_box { + stbl_replacements.insert(track.stsd_info.offset(), stsd_box); + } + if let Some(stsc_box) = stsc_box { + stbl_replacements.insert(track.stsc_info.offset(), stsc_box); + } + let stbl = + rebuild_container_box_with_replacements(input, track.stbl_info, &Stbl, &stbl_replacements); + let minf = rebuild_container_box_with_replacements( + input, + track.minf_info, + &Minf, + &BTreeMap::from([(track.stbl_info.offset(), stbl)]), + ); + let mdia = rebuild_container_box_with_replacements( + input, + track.mdia_info, + &Mdia, + &BTreeMap::from([(track.minf_info.offset(), minf)]), + ); + rebuild_container_box_with_replacements( + input, + track.trak_info, + &Trak, + &BTreeMap::from([(track.mdia_info.offset(), mdia)]), + ) +} + +#[cfg(feature = "decrypt")] +fn analyze_generated_protected_movie_track_layout( + input: &[u8], + trak_info: BoxInfo, +) -> GeneratedProtectedMovieTrackLayout { + let tkhd = extract_single_as_from_bytes::( + input, + Some(&trak_info), + BoxPath::from([fourcc("tkhd")]), + ); + let mdia_info = + extract_single_info_from_bytes(input, Some(&trak_info), BoxPath::from([fourcc("mdia")])); + let minf_info = extract_single_info_from_bytes( + input, + Some(&trak_info), + BoxPath::from([fourcc("mdia"), fourcc("minf")]), + ); + let stbl_info = extract_single_info_from_bytes( + input, + Some(&trak_info), + BoxPath::from([fourcc("mdia"), fourcc("minf"), fourcc("stbl")]), + ); + let stsd_info = extract_single_info_from_bytes( + input, + Some(&trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + ]), + ); + let stsc_info = extract_single_info_from_bytes( + input, + Some(&trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + let stsz_info = extract_single_info_from_bytes( + input, + Some(&trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + let stsz = extract_single_as_from_bytes::( + input, + Some(&trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsz"), + ]), + ); + let stco_infos = extract_infos_from_bytes( + input, + Some(&trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stco"), + ]), + ); + let co64_infos = extract_infos_from_bytes( + input, + Some(&trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("co64"), + ]), + ); + let chunk_offsets = if !stco_infos.is_empty() { + let stco = extract_single_as_from_bytes::( + input, + Some(&trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stco"), + ]), + ); + RetainedTrackChunkOffsetState::Stco { + info: stco_infos[0], + box_value: stco, + } + } else { + let co64 = extract_single_as_from_bytes::( + input, + Some(&trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("co64"), + ]), + ); + RetainedTrackChunkOffsetState::Co64 { + info: co64_infos[0], + box_value: co64, + } + }; + + GeneratedProtectedMovieTrackLayout { + track_id: tkhd.track_id, + trak_info, + mdia_info, + minf_info, + stbl_info, + stsd_info, + stsc_info, + stsz_info, + stsz, + chunk_offsets, + } +} + +#[cfg(feature = "decrypt")] +fn rebuild_generated_protected_movie_track( + input: &[u8], + track: &GeneratedProtectedMovieTrackLayout, + chunk_offset_box: Vec, + stsd_box: Option>, + stsc_box: Option>, +) -> Vec { + let chunk_offset_info = match track.chunk_offsets { + RetainedTrackChunkOffsetState::Stco { info, .. } + | RetainedTrackChunkOffsetState::Co64 { info, .. } => info, + }; + let mut stbl_replacements = BTreeMap::from([ + ( + track.stsz_info.offset(), + encode_supported_box(&track.stsz, &[]), + ), + (chunk_offset_info.offset(), chunk_offset_box), + ]); + if let Some(stsd_box) = stsd_box { + stbl_replacements.insert(track.stsd_info.offset(), stsd_box); + } + if let Some(stsc_box) = stsc_box { + stbl_replacements.insert(track.stsc_info.offset(), stsc_box); + } + let stbl = + rebuild_container_box_with_replacements(input, track.stbl_info, &Stbl, &stbl_replacements); + let minf = rebuild_container_box_with_replacements( + input, + track.minf_info, + &Minf, + &BTreeMap::from([(track.stbl_info.offset(), stbl)]), + ); + let mdia = rebuild_container_box_with_replacements( + input, + track.mdia_info, + &Mdia, + &BTreeMap::from([(track.minf_info.offset(), minf)]), + ); + rebuild_container_box_with_replacements( + input, + track.trak_info, + &Trak, + &BTreeMap::from([(track.mdia_info.offset(), mdia)]), + ) +} + +#[cfg(feature = "decrypt")] +fn duplicate_retained_marlin_od_track_sample_entry( + input: &[u8], + track: &RetainedMarlinTrackLayout, +) -> Vec { + let mut stsd = extract_single_as_from_bytes::( + input, + Some(&track.trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + ]), + ); + let sample_entry_infos = + extract_infos_from_bytes(input, Some(&track.stsd_info), BoxPath::from([FourCc::ANY])); + assert_eq!(sample_entry_infos.len(), 1); + let sample_entry = slice_box_bytes(input, sample_entry_infos[0]).to_vec(); + stsd.entry_count = 2; + encode_supported_box(&stsd, &[sample_entry.clone(), sample_entry].concat()) +} + +#[cfg(feature = "decrypt")] +fn append_generated_protected_movie_second_sample_entry( + input: &[u8], + track: &GeneratedProtectedMovieTrackLayout, +) -> Vec { + let mut stsd = extract_single_as_from_bytes::( + input, + Some(&track.trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsd"), + ]), + ); + let sample_entry_infos = + extract_infos_from_bytes(input, Some(&track.stsd_info), BoxPath::from([FourCc::ANY])); + assert_eq!(sample_entry_infos.len(), 1); + stsd.entry_count = 2; + encode_supported_box( + &stsd, + &[ + slice_box_bytes(input, sample_entry_infos[0]).to_vec(), + build_clear_avc1_sample_entry(320, 180), + ] + .concat(), + ) +} + +#[cfg(feature = "decrypt")] +fn patch_retained_track_stsc_sample_description_index( + stsc: &Stsc, + sample_description_index: u32, +) -> Vec { + let mut patched = stsc.clone(); + for entry in &mut patched.entries { + entry.sample_description_index = sample_description_index; + } + encode_supported_box(&patched, &[]) +} + +#[cfg(feature = "decrypt")] +fn patch_standard_protected_movie_track_sample_description_index( + input: &[u8], + protected_track_id: u32, +) -> Vec { + let root_boxes = read_root_box_infos(input); + let moov_info = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == fourcc("moov")) + .unwrap(); + let trak_infos = + extract_infos_from_bytes(input, None, BoxPath::from([fourcc("moov"), fourcc("trak")])); + let track_layouts = trak_infos + .into_iter() + .map(|trak_info| analyze_generated_protected_movie_track_layout(input, trak_info)) + .collect::>(); + + let build_replacement = |track: &GeneratedProtectedMovieTrackLayout, shift: u64| { + let (stsd_replacement, stsc_replacement) = if track.track_id == protected_track_id { + let stsc = extract_single_as_from_bytes::( + input, + Some(&track.trak_info), + BoxPath::from([ + fourcc("mdia"), + fourcc("minf"), + fourcc("stbl"), + fourcc("stsc"), + ]), + ); + ( + Some(append_generated_protected_movie_second_sample_entry( + input, track, + )), + Some(patch_retained_track_stsc_sample_description_index(&stsc, 2)), + ) + } else { + (None, None) + }; + rebuild_generated_protected_movie_track( + input, + track, + patch_retained_track_chunk_offsets(&track.chunk_offsets, shift, None), + stsd_replacement, + stsc_replacement, + ) + }; + + let placeholder_replacements = track_layouts + .iter() + .map(|track| (track.trak_info.offset(), build_replacement(track, 0))) + .collect::>(); + let placeholder_moov = + rebuild_container_box_with_replacements(input, moov_info, &Moov, &placeholder_replacements); + let moov_shift = + i64::try_from(placeholder_moov.len()).unwrap() - i64::try_from(moov_info.size()).unwrap(); + let shift = u64::try_from(moov_shift).unwrap(); + + let moov_replacements = track_layouts + .iter() + .map(|track| (track.trak_info.offset(), build_replacement(track, shift))) + .collect::>(); + let moov = rebuild_container_box_with_replacements(input, moov_info, &Moov, &moov_replacements); + + let mut output = Vec::new(); + for root_info in root_boxes { + if root_info.offset() == moov_info.offset() { + output.extend_from_slice(&moov); + } else { + output.extend_from_slice(slice_box_bytes(input, root_info)); + } + } + + output +} + +#[cfg(feature = "decrypt")] +fn insert_root_box_before_single_mdat_and_shift_offsets( + input: &[u8], + extra_root_box: &[u8], +) -> Vec { + let root_boxes = read_root_box_infos(input); + let moov_info = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == fourcc("moov")) + .unwrap(); + let mdat_info = root_boxes + .iter() + .copied() + .find(|info| info.box_type() == fourcc("mdat")) + .unwrap(); + let trak_infos = + extract_infos_from_bytes(input, None, BoxPath::from([fourcc("moov"), fourcc("trak")])); + let track_layouts = trak_infos + .into_iter() + .map(|trak_info| analyze_retained_marlin_track_layout(input, trak_info)) + .collect::>(); + let shift = u64::try_from(extra_root_box.len()).unwrap(); + let moov_replacements = track_layouts + .iter() + .map(|track| { + ( + track.trak_info.offset(), + rebuild_retained_marlin_track( + input, + track, + track.stsz.clone(), + patch_retained_track_chunk_offsets(&track.chunk_offsets, shift, None), + None, + None, + ), + ) + }) + .collect::>(); + let rebuilt_moov = + rebuild_container_box_with_replacements(input, moov_info, &Moov, &moov_replacements); + + let mut output = Vec::new(); + for root_info in root_boxes { + if root_info.offset() == moov_info.offset() { + output.extend_from_slice(&rebuilt_moov); + } else if root_info.offset() == mdat_info.offset() { + continue; + } else { + output.extend_from_slice(slice_box_bytes(input, root_info)); + } + } + output.extend_from_slice(extra_root_box); + output.extend_from_slice(slice_box_bytes(input, mdat_info)); + output +} + +#[cfg(feature = "decrypt")] +fn rebuild_container_box_with_replacements( + input: &[u8], + parent_info: BoxInfo, + box_value: &B, + replacements: &BTreeMap>, +) -> Vec +where + B: CodecBox, +{ + let child_infos = + extract_infos_from_bytes(input, Some(&parent_info), BoxPath::from([FourCc::ANY])); + let mut children = Vec::new(); + for child_info in child_infos { + if let Some(replacement) = replacements.get(&child_info.offset()) { + children.extend_from_slice(replacement); + } else { + children.extend_from_slice(slice_box_bytes(input, child_info)); + } + } + encode_supported_box(box_value, &children) +} + +#[cfg(feature = "decrypt")] +fn read_sample_bytes(input: &[u8], absolute_offset: u64, sample_size: u32) -> &[u8] { + let start = usize::try_from(absolute_offset).unwrap(); + let end = start + usize::try_from(sample_size).unwrap(); + &input[start..end] +} + +#[cfg(feature = "decrypt")] +pub fn build_oma_dcf_broader_movie_fixture() -> ProtectedMovieTopologyFixture { + let protected_track_id = 1; + let clear_track_id = 2; + let key = [0x55; 16]; + let protected_samples = vec![ + vec![0x11, 0x22, 0x33, 0x44, 0x55], + vec![0x66, 0x77, 0x88, 0x99], + vec![0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff], + ]; + let clear_track_samples = vec![vec![0x01, 0x03, 0x05], vec![0x07, 0x09, 0x0b, 0x0d]]; + let protected_chunk_sample_counts = [2_u32, 1]; + let clear_chunk_sample_counts = [1_u32, 1]; + let protected_ivs = [ + [ + 0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc, 0xfe, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, + 0x77, 0x88, + ], + [ + 0x20, 0x42, 0x64, 0x86, 0xa8, 0xca, 0xec, 0x0e, 0x21, 0x43, 0x65, 0x87, 0xa9, 0xcb, + 0xed, 0x0f, + ], + [ + 0x30, 0x52, 0x74, 0x96, 0xb8, 0xda, 0xfc, 0x1e, 0x31, 0x53, 0x75, 0x97, 0xb9, 0xdb, + 0xfd, 0x1f, + ], + ]; + let encrypted_protected_samples = protected_samples + .iter() + .zip(protected_ivs) + .map(|(sample, iv)| encrypt_oma_dcf_ctr_movie_sample(sample, key, iv)) + .collect::>(); + + let encrypted_ftyp = Ftyp { + major_brand: fourcc("odcf"), + minor_version: 1, + compatible_brands: vec![fourcc("odcf"), fourcc("opf2"), fourcc("isom")], + }; + let clear_ftyp = Ftyp { + major_brand: fourcc("odcf"), + minor_version: 1, + compatible_brands: vec![fourcc("odcf"), fourcc("isom")], + }; + let leading_empty_mdat = encode_raw_box(fourcc("mdat"), &[]); + let trailing_free = encode_raw_box(fourcc("free"), &[0xfa, 0xce, 0xb0, 0x0c]); + let encrypted_protected_track = SampleEntryMovieTrackSpec { + track_id: protected_track_id, + width: 320, + height: 180, + sample_entry: build_oma_dcf_protected_sample_entry(), + samples: encrypted_protected_samples, + chunk_sample_counts: protected_chunk_sample_counts.to_vec(), + }; + let clear_protected_track = SampleEntryMovieTrackSpec { + track_id: protected_track_id, + width: 320, + height: 180, + sample_entry: build_clear_avc1_sample_entry(320, 180), + samples: protected_samples, + chunk_sample_counts: protected_chunk_sample_counts.to_vec(), + }; + let clear_track = SampleEntryMovieTrackSpec { + track_id: clear_track_id, + width: 640, + height: 360, + sample_entry: build_clear_avc1_sample_entry(640, 360), + samples: clear_track_samples, + chunk_sample_counts: clear_chunk_sample_counts.to_vec(), + }; + + let encrypted = build_two_track_sample_entry_movie( + &encrypted_ftyp, + &encrypted_protected_track, + &clear_track, + &[leading_empty_mdat], + std::slice::from_ref(&trailing_free), + ); + let decrypted = build_two_track_sample_entry_movie( + &clear_ftyp, + &clear_protected_track, + &clear_track, + std::slice::from_ref(&trailing_free), + &[], + ); + + ProtectedMovieTopologyFixture { + encrypted, + decrypted, + keys: vec![DecryptionKey::track(protected_track_id, key)], + } +} + +#[cfg(feature = "decrypt")] +pub fn build_oma_dcf_sample_description_index_unsupported_movie_fixture() +-> ProtectedMovieTopologyFixture { + build_sample_description_index_unsupported_protected_movie_fixture( + build_oma_dcf_broader_movie_fixture(), + 1, + ) +} + +#[cfg(feature = "decrypt")] +pub fn build_iaec_broader_movie_fixture() -> ProtectedMovieTopologyFixture { + let protected_track_id = 1; + let clear_track_id = 2; + let key = [0x66; 16]; + let salt = [0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]; + let protected_samples = vec![ + vec![0x90, 0x91, 0x92, 0x93, 0x94, 0x95], + vec![0xa0, 0xa1, 0xa2], + vec![0xb0, 0xb1, 0xb2, 0xb3, 0xb4], + ]; + let clear_track_samples = vec![vec![0x31, 0x41, 0x59, 0x26], vec![0x53, 0x58, 0x97]]; + let protected_chunk_sample_counts = [2_u32, 1]; + let clear_chunk_sample_counts = [1_u32, 1]; + let protected_ivs = [[0_u8; 8], [0_u8; 8], [0_u8; 8]]; + let encrypted_protected_samples = protected_samples + .iter() + .zip(protected_ivs) + .map(|(sample, iv)| encrypt_iaec_movie_sample(sample, key, salt, iv)) + .collect::>(); + + let ftyp = Ftyp { + major_brand: fourcc("isom"), + minor_version: 1, + compatible_brands: vec![fourcc("isom"), fourcc("mp42")], + }; + let leading_empty_mdat = encode_raw_box(fourcc("mdat"), &[]); + let trailing_free = encode_raw_box(fourcc("free"), &[0x12, 0x34, 0x56, 0x78]); + let encrypted_protected_track = SampleEntryMovieTrackSpec { + track_id: protected_track_id, + width: 320, + height: 180, + sample_entry: build_iaec_protected_sample_entry(salt), + samples: encrypted_protected_samples, + chunk_sample_counts: protected_chunk_sample_counts.to_vec(), + }; + let clear_protected_track = SampleEntryMovieTrackSpec { + track_id: protected_track_id, + width: 320, + height: 180, + sample_entry: build_clear_avc1_sample_entry(320, 180), + samples: protected_samples, + chunk_sample_counts: protected_chunk_sample_counts.to_vec(), + }; + let clear_track = SampleEntryMovieTrackSpec { + track_id: clear_track_id, + width: 640, + height: 360, + sample_entry: build_clear_avc1_sample_entry(640, 360), + samples: clear_track_samples, + chunk_sample_counts: clear_chunk_sample_counts.to_vec(), + }; + + let encrypted = build_two_track_sample_entry_movie( + &ftyp, + &encrypted_protected_track, + &clear_track, + &[leading_empty_mdat], + std::slice::from_ref(&trailing_free), + ); + let decrypted = build_two_track_sample_entry_movie( + &ftyp, + &clear_protected_track, + &clear_track, + std::slice::from_ref(&trailing_free), + &[], + ); + + ProtectedMovieTopologyFixture { + encrypted, + decrypted, + keys: vec![DecryptionKey::track(protected_track_id, key)], + } +} + +#[cfg(feature = "decrypt")] +pub fn build_iaec_sample_description_index_unsupported_movie_fixture() +-> ProtectedMovieTopologyFixture { + build_sample_description_index_unsupported_protected_movie_fixture( + build_iaec_broader_movie_fixture(), + 1, + ) +} + +#[cfg(feature = "decrypt")] +fn build_sample_description_index_unsupported_protected_movie_fixture( + fixture: ProtectedMovieTopologyFixture, + protected_track_id: u32, +) -> ProtectedMovieTopologyFixture { + ProtectedMovieTopologyFixture { + encrypted: patch_standard_protected_movie_track_sample_description_index( + &fixture.encrypted, + protected_track_id, + ), + decrypted: fixture.decrypted, + keys: fixture.keys, + } +} + +#[cfg(feature = "decrypt")] +fn build_two_track_sample_entry_movie( + ftyp: &Ftyp, + protected_track: &SampleEntryMovieTrackSpec, + clear_track: &SampleEntryMovieTrackSpec, + root_boxes_before_mdat: &[Vec], + root_boxes_after_mdat: &[Vec], +) -> Vec { + let ftyp_bytes = encode_supported_box(ftyp, &[]); + let protected_chunks = chunk_payloads_from_samples( + &protected_track.samples, + &protected_track.chunk_sample_counts, + ); + let clear_chunks = + chunk_payloads_from_samples(&clear_track.samples, &clear_track.chunk_sample_counts); + + let protected_placeholder_track = build_sample_entry_movie_track( + protected_track.track_id, + protected_track.width, + protected_track.height, + protected_track.sample_entry.clone(), + sample_sizes_u64(&protected_track.samples), + &protected_track.chunk_sample_counts, + &vec![0; protected_chunks.len()], + ); + let clear_placeholder_track = build_sample_entry_movie_track( + clear_track.track_id, + clear_track.width, + clear_track.height, + clear_track.sample_entry.clone(), + sample_sizes_u64(&clear_track.samples), + &clear_track.chunk_sample_counts, + &vec![0; clear_chunks.len()], + ); + let moov_placeholder = + build_simple_movie_moov(&protected_placeholder_track, &clear_placeholder_track); + let mdat_payload_start = u64::try_from( + ftyp_bytes.len() + + moov_placeholder.len() + + root_boxes_before_mdat.iter().map(Vec::len).sum::() + + 8, + ) + .unwrap(); + + let mut protected_offsets = Vec::with_capacity(protected_chunks.len()); + let mut clear_offsets = Vec::with_capacity(clear_chunks.len()); + let mut payload = Vec::new(); + let max_chunks = protected_chunks.len().max(clear_chunks.len()); + for index in 0..max_chunks { + if let Some(chunk) = clear_chunks.get(index) { + clear_offsets.push(mdat_payload_start + u64::try_from(payload.len()).unwrap()); + payload.extend_from_slice(chunk); + } + if let Some(chunk) = protected_chunks.get(index) { + protected_offsets.push(mdat_payload_start + u64::try_from(payload.len()).unwrap()); + payload.extend_from_slice(chunk); + } + } + + let protected_track = build_sample_entry_movie_track( + protected_track.track_id, + protected_track.width, + protected_track.height, + protected_track.sample_entry.clone(), + sample_sizes_u64(&protected_track.samples), + &protected_track.chunk_sample_counts, + &protected_offsets, + ); + let clear_track = build_sample_entry_movie_track( + clear_track.track_id, + clear_track.width, + clear_track.height, + clear_track.sample_entry.clone(), + sample_sizes_u64(&clear_track.samples), + &clear_track.chunk_sample_counts, + &clear_offsets, + ); + let moov = build_simple_movie_moov(&protected_track, &clear_track); + let mdat = encode_raw_box(fourcc("mdat"), &payload); + + let mut output = Vec::new(); + output.extend_from_slice(&ftyp_bytes); + output.extend_from_slice(&moov); + for root_box in root_boxes_before_mdat { + output.extend_from_slice(root_box); + } + output.extend_from_slice(&mdat); + for root_box in root_boxes_after_mdat { + output.extend_from_slice(root_box); + } + output +} + +#[cfg(feature = "decrypt")] +fn build_simple_movie_moov(protected_track: &[u8], clear_track: &[u8]) -> Vec { + let mut mvhd = Mvhd::default(); + mvhd.timescale = 1_000; + mvhd.duration_v0 = 3_000; + mvhd.rate = 1 << 16; + mvhd.volume = 1 << 8; + mvhd.next_track_id = 3; + let mvhd = encode_supported_box(&mvhd, &[]); + + encode_supported_box( + &Moov, + &[mvhd, protected_track.to_vec(), clear_track.to_vec()].concat(), + ) +} + +#[cfg(feature = "decrypt")] +fn build_sample_entry_movie_track( + track_id: u32, + width: u16, + height: u16, + sample_entry: Vec, + sample_sizes: Vec, + chunk_sample_counts: &[u32], + chunk_offsets: &[u64], +) -> Vec { + let mut tkhd = mp4forge::boxes::iso14496_12::Tkhd::default(); + tkhd.track_id = track_id; + tkhd.width = u32::from(width) << 16; + tkhd.height = u32::from(height) << 16; + let tkhd = encode_supported_box(&tkhd, &[]); + + let mut mdhd = Mdhd::default(); + mdhd.timescale = 1_000; + mdhd.duration_v0 = 3_000; + mdhd.language = [5, 14, 7]; + let mdhd = encode_supported_box(&mdhd, &[]); + + let mut stsd = Stsd::default(); + stsd.entry_count = 1; + let stsd = encode_supported_box(&stsd, &sample_entry); + + let mut stco = Stco::default(); + stco.entry_count = u32::try_from(chunk_offsets.len()).unwrap(); + stco.chunk_offset = chunk_offsets.to_vec(); + let stco = encode_supported_box(&stco, &[]); + + let mut stts = Stts::default(); + stts.entry_count = 0; + let stts = encode_supported_box(&stts, &[]); + + let mut stsc = Stsc::default(); + stsc.entry_count = u32::try_from(chunk_sample_counts.len()).unwrap(); + let mut first_chunk = 1u32; + stsc.entries = chunk_sample_counts + .iter() + .map(|samples_per_chunk| { + let entry = StscEntry { + first_chunk, + samples_per_chunk: *samples_per_chunk, + sample_description_index: 1, + }; + first_chunk += 1; + entry + }) + .collect(); + let stsc = encode_supported_box(&stsc, &[]); + + let mut stsz = Stsz::default(); + stsz.sample_count = u32::try_from(sample_sizes.len()).unwrap(); + stsz.entry_size = sample_sizes; + let stsz = encode_supported_box(&stsz, &[]); + + let stbl = encode_supported_box(&Stbl, &[stsd, stco, stts, stsc, stsz].concat()); + let minf = encode_supported_box(&Minf, &stbl); + let mdia = encode_supported_box( + &Mdia, + &[mdhd, handler_box("vide", "VideoHandler"), minf].concat(), + ); + encode_supported_box(&Trak, &[tkhd, mdia].concat()) +} + +#[cfg(feature = "decrypt")] +fn chunk_payloads_from_samples(samples: &[Vec], chunk_sample_counts: &[u32]) -> Vec> { + let mut chunks = Vec::with_capacity(chunk_sample_counts.len()); + let mut cursor = 0usize; + for &sample_count in chunk_sample_counts { + let sample_count = usize::try_from(sample_count).unwrap(); + let end = cursor + sample_count; + let mut chunk = Vec::new(); + for sample in &samples[cursor..end] { + chunk.extend_from_slice(sample); + } + chunks.push(chunk); + cursor = end; + } + assert_eq!(cursor, samples.len()); + chunks +} + +#[cfg(feature = "decrypt")] +fn sample_sizes_u64(samples: &[Vec]) -> Vec { + samples + .iter() + .map(|sample| u64::try_from(sample.len()).unwrap()) + .collect() +} + +#[cfg(feature = "decrypt")] +fn build_clear_avc1_sample_entry(width: u16, height: u16) -> Vec { + encode_supported_box( + &video_sample_entry_with_type("avc1", width, height), + &encode_supported_box(&avc_config(), &[]), + ) +} + +#[cfg(feature = "decrypt")] +fn build_oma_dcf_protected_sample_entry() -> Vec { + let mut schm = Schm::default(); + schm.set_version(0); + schm.scheme_type = fourcc("odkm"); + schm.scheme_version = 0x0001_0000; + + let mut odaf = Odaf::default(); + odaf.set_version(0); + odaf.selective_encryption = false; + odaf.key_indicator_length = 0; + odaf.iv_length = 16; + + let mut ohdr = Ohdr::default(); + ohdr.set_version(0); + ohdr.encryption_method = OHDR_ENCRYPTION_METHOD_AES_CTR; + ohdr.padding_scheme = OHDR_PADDING_SCHEME_NONE; + ohdr.content_id = "oma-topology".to_owned(); + + let odkm = encode_supported_box( + &Odkm::default(), + &[ + encode_supported_box(&odaf, &[]), + encode_supported_box(&ohdr, &[]), + ] + .concat(), + ); + let schi = encode_supported_box(&Schi, &odkm); + let sinf = encode_supported_box( + &Sinf, + &[ + encode_supported_box( + &Frma { + data_format: fourcc("avc1"), + }, + &[], + ), + encode_supported_box(&schm, &[]), + schi, + ] + .concat(), + ); + + encode_supported_box( + &video_sample_entry_with_type("encv", 320, 180), + &[encode_supported_box(&avc_config(), &[]), sinf].concat(), + ) +} + +#[cfg(feature = "decrypt")] +fn build_iaec_protected_sample_entry(salt: [u8; 8]) -> Vec { + let mut schm = Schm::default(); + schm.set_version(0); + schm.scheme_type = fourcc("iAEC"); + schm.scheme_version = 0x0001_0000; + + let mut isfm = Isfm::default(); + isfm.set_version(0); + isfm.selective_encryption = false; + isfm.key_indicator_length = 0; + isfm.iv_length = 8; + + let islt = Islt { salt }; + let schi = encode_supported_box( + &Schi, + &[ + encode_supported_box(&isfm, &[]), + encode_supported_box(&islt, &[]), + ] + .concat(), + ); + let sinf = encode_supported_box( + &Sinf, + &[ + encode_supported_box( + &Frma { + data_format: fourcc("avc1"), + }, + &[], + ), + encode_supported_box(&schm, &[]), + schi, + ] + .concat(), + ); + + encode_supported_box( + &video_sample_entry_with_type("encv", 320, 180), + &[encode_supported_box(&avc_config(), &[]), sinf].concat(), + ) +} + +#[cfg(feature = "decrypt")] +fn encrypt_oma_dcf_ctr_movie_sample(sample: &[u8], key: [u8; 16], iv: [u8; 16]) -> Vec { + let aes = Aes128::new(&key.into()); + let mut counter = iv; + let mut ciphertext = vec![0_u8; sample.len()]; + let mut cursor = 0usize; + while cursor < sample.len() { + let mut stream_block = Block::::default(); + stream_block.copy_from_slice(&counter); + aes.encrypt_block(&mut stream_block); + let chunk_len = 16.min(sample.len() - cursor); + for index in 0..chunk_len { + ciphertext[cursor + index] = sample[cursor + index] ^ stream_block[index]; + } + cursor += chunk_len; + for byte in counter.iter_mut().rev() { + *byte = byte.wrapping_add(1); + if *byte != 0 { + break; + } + } + } + + [iv.to_vec(), ciphertext].concat() +} + +#[cfg(feature = "decrypt")] +fn encrypt_iaec_movie_sample(sample: &[u8], key: [u8; 16], salt: [u8; 8], iv: [u8; 8]) -> Vec { + let aes = Aes128::new(&key.into()); + let mut counter = [0_u8; 16]; + counter[..8].copy_from_slice(&salt); + counter[8..].copy_from_slice(&iv); + let mut ciphertext = vec![0_u8; sample.len()]; + let mut cursor = 0usize; + while cursor < sample.len() { + let mut stream_block = Block::::default(); + stream_block.copy_from_slice(&counter); + aes.encrypt_block(&mut stream_block); + let chunk_len = 16.min(sample.len() - cursor); + for index in 0..chunk_len { + ciphertext[cursor + index] = sample[cursor + index] ^ stream_block[index]; + } + cursor += chunk_len; + for byte in counter.iter_mut().rev() { + *byte = byte.wrapping_add(1); + if *byte != 0 { + break; + } + } + } + + [iv.to_vec(), ciphertext].concat() +} + +pub fn read_text(path: &Path) -> String { + normalize_text(&fs::read_to_string(path).unwrap()) +} + +pub fn read_golden(relative_path: &str) -> String { + read_text( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("golden") + .join(relative_path), + ) +} + +pub fn normalize_text(value: &str) -> String { + value.replace("\r\n", "\n") +} + +pub fn build_encrypted_fragmented_video_file() -> Vec { + let ftyp = encode_supported_box( + &Ftyp { + major_brand: fourcc("iso6"), + minor_version: 1, + compatible_brands: vec![fourcc("iso6"), fourcc("dash"), fourcc("cenc")], + }, + &[], + ); + let moov = build_encrypted_fragmented_video_moov(); + let moof = build_encrypted_fragmented_video_moof(); + let mdat = encode_raw_box(fourcc("mdat"), &[0xde, 0xad, 0xbe, 0xef]); + [ftyp, moov, moof, mdat].concat() +} + +pub fn build_visual_sample_entry_box_with_trailing_bytes() -> Vec { + let pasp = encode_supported_box( + &Pasp { + h_spacing: 1, + v_spacing: 1, + }, + &[], + ); + let mut extensions = pasp; + extensions.extend_from_slice(&visual_sample_entry_trailing_bytes()); + encode_supported_box(&video_sample_entry_with_type("avc1", 640, 360), &extensions) +} + +pub fn visual_sample_entry_trailing_bytes() -> Vec { + vec![0xde, 0xad, 0xbe] +} + +pub fn build_event_message_movie_file() -> Vec { + let ftyp = encode_supported_box( + &Ftyp { + major_brand: fourcc("isom"), + minor_version: 1, + compatible_brands: vec![fourcc("isom"), fourcc("iso8")], + }, + &[], + ); + let moov = build_event_message_moov(); + let emib = encode_supported_box(&event_message_instance_box(), &[]); + let emeb = encode_supported_box(&Emeb, &[]); + let mdat = encode_raw_box(fourcc("mdat"), &[0x01, 0x02, 0x03, 0x04]); + [ftyp, moov, emib, emeb, mdat].concat() +} + +#[cfg(feature = "decrypt")] +pub struct DecryptRewriteFixture { + pub init_segment: Vec, + pub media_segment: Vec, + pub single_file: Vec, + pub all_keys: Vec, + pub first_track_only_keys: Vec, + pub first_track_id: u32, + pub second_track_id: u32, + pub first_track_plaintext: Vec, + pub second_track_plaintext: Vec, +} + +#[cfg(feature = "decrypt")] +pub struct MultiSampleEntryDecryptFixture { + pub init_segment: Vec, + pub media_segment: Vec, + pub single_file: Vec, + pub decrypted_init_segment: Vec, + pub decrypted_media_segment: Vec, + pub decrypted_single_file: Vec, + pub all_keys: Vec, + pub ambiguous_track_id_keys: Vec, +} + +#[cfg(feature = "decrypt")] +pub struct ZeroKidMultiSampleEntryDecryptFixture { + pub init_segment: Vec, + pub media_segment: Vec, + pub single_file: Vec, + pub decrypted_init_segment: Vec, + pub decrypted_media_segment: Vec, + pub decrypted_single_file: Vec, + pub ordered_track_id_keys: Vec, +} + +#[cfg(feature = "decrypt")] +pub fn build_decrypt_rewrite_fixture() -> DecryptRewriteFixture { + build_decrypt_rewrite_fixture_with_mode(DecryptFixtureLayout::CommonEncryption) +} + +#[cfg(feature = "decrypt")] +pub fn build_piff_decrypt_rewrite_fixture() -> DecryptRewriteFixture { + build_decrypt_rewrite_fixture_with_mode(DecryptFixtureLayout::PiffCompatibility) +} + +#[cfg(feature = "decrypt")] +pub fn build_multi_sample_entry_decrypt_fixture() -> MultiSampleEntryDecryptFixture { + let first_spec = DecryptFixtureTrackSpec { + track_id: 1, + width: 320, + height: 180, + scheme_type: fourcc("cbcs"), + native_scheme: NativeCommonEncryptionScheme::Cbcs, + key: [0x31; 16], + kid: [0xc1; 16], + initialization_vector: vec![], + constant_iv: Some(vec![ + 0x01, 0x13, 0x25, 0x37, 0x49, 0x5b, 0x6d, 0x7f, 0x80, 0x92, 0xa4, 0xb6, 0xc8, 0xda, + 0xec, 0xfe, + ]), + per_sample_iv_size: None, + crypt_byte_block: 1, + skip_byte_block: 1, + subsamples: vec![ + SencSubsample { + bytes_of_clear_data: 4, + bytes_of_protected_data: 32, + }, + SencSubsample { + bytes_of_clear_data: 2, + bytes_of_protected_data: 16, + }, + ], + plaintext: (0u8..54).map(|value| value ^ 0x41).collect(), + use_fragment_group: false, + layout: DecryptFixtureLayout::CommonEncryption, + }; + let second_spec = DecryptFixtureTrackSpec { + track_id: 1, + width: 320, + height: 180, + scheme_type: fourcc("cbcs"), + native_scheme: NativeCommonEncryptionScheme::Cbcs, + key: [0x42; 16], + kid: [0xd2; 16], + initialization_vector: vec![], + constant_iv: Some(vec![ + 0xfe, 0xec, 0xda, 0xc8, 0xb6, 0xa4, 0x92, 0x80, 0x7f, 0x6d, 0x5b, 0x49, 0x37, 0x25, + 0x13, 0x01, + ]), + per_sample_iv_size: None, + crypt_byte_block: 1, + skip_byte_block: 1, + subsamples: vec![ + SencSubsample { + bytes_of_clear_data: 6, + bytes_of_protected_data: 24, + }, + SencSubsample { + bytes_of_clear_data: 0, + bytes_of_protected_data: 24, + }, + ], + plaintext: (0u8..54) + .map(|value| value.wrapping_mul(5) ^ 0x22) + .collect(), + use_fragment_group: false, + layout: DecryptFixtureLayout::CommonEncryption, + }; + + let encrypted_sample_entries = vec![ + build_fragmented_track_sample_entry(&first_spec, true), + build_fragmented_track_sample_entry(&second_spec, true), + ]; + let clear_sample_entries = vec![ + build_fragmented_track_sample_entry(&first_spec, false), + build_fragmented_track_sample_entry(&second_spec, false), + ]; + let init_segment = build_multi_sample_entry_init_segment(&encrypted_sample_entries); + let decrypted_init_segment = build_multi_sample_entry_init_segment(&clear_sample_entries); + + let first_ciphertext = encrypt_fixture_sample(&first_spec); + let second_ciphertext = encrypt_fixture_sample(&second_spec); + let media_segment = build_multi_sample_entry_media_segment( + [ + MultiSampleEntryFragmentSpec { + track_spec: &first_spec, + payload: &first_ciphertext, + sample_description_index: None, + base_media_decode_time: 0, + sequence_number: 1, + }, + MultiSampleEntryFragmentSpec { + track_spec: &second_spec, + payload: &second_ciphertext, + sample_description_index: Some(2), + base_media_decode_time: 1_000, + sequence_number: 2, + }, + ], + true, + ); + let decrypted_media_segment = build_multi_sample_entry_media_segment( + [ + MultiSampleEntryFragmentSpec { + track_spec: &first_spec, + payload: &first_spec.plaintext, + sample_description_index: None, + base_media_decode_time: 0, + sequence_number: 1, + }, + MultiSampleEntryFragmentSpec { + track_spec: &second_spec, + payload: &second_spec.plaintext, + sample_description_index: Some(2), + base_media_decode_time: 1_000, + sequence_number: 2, + }, + ], + false, + ); + let single_file = [init_segment.clone(), media_segment.clone()].concat(); + let decrypted_single_file = [ + decrypted_init_segment.clone(), + decrypted_media_segment.clone(), + ] + .concat(); + + MultiSampleEntryDecryptFixture { + init_segment, + media_segment, + single_file, + decrypted_init_segment, + decrypted_media_segment, + decrypted_single_file, + all_keys: vec![ + DecryptionKey::kid(first_spec.kid, first_spec.key), + DecryptionKey::kid(second_spec.kid, second_spec.key), + ], + ambiguous_track_id_keys: vec![ + DecryptionKey::track(first_spec.track_id, first_spec.key), + DecryptionKey::track(second_spec.track_id, second_spec.key), + ], + } +} + +#[cfg(feature = "decrypt")] +pub fn build_zero_kid_multi_sample_entry_decrypt_fixture() -> ZeroKidMultiSampleEntryDecryptFixture +{ + let first_spec = DecryptFixtureTrackSpec { + track_id: 1, + width: 320, + height: 180, + scheme_type: fourcc("cbcs"), + native_scheme: NativeCommonEncryptionScheme::Cbcs, + key: [0x31; 16], + kid: [0; 16], + initialization_vector: vec![], + constant_iv: Some(vec![ + 0x01, 0x13, 0x25, 0x37, 0x49, 0x5b, 0x6d, 0x7f, 0x80, 0x92, 0xa4, 0xb6, 0xc8, 0xda, + 0xec, 0xfe, + ]), + per_sample_iv_size: None, + crypt_byte_block: 1, + skip_byte_block: 1, + subsamples: vec![ + SencSubsample { + bytes_of_clear_data: 4, + bytes_of_protected_data: 32, + }, + SencSubsample { + bytes_of_clear_data: 2, + bytes_of_protected_data: 16, + }, + ], + plaintext: (0u8..54).map(|value| value ^ 0x41).collect(), + use_fragment_group: false, + layout: DecryptFixtureLayout::CommonEncryption, + }; + let second_spec = DecryptFixtureTrackSpec { + track_id: 1, + width: 320, + height: 180, + scheme_type: fourcc("cbcs"), + native_scheme: NativeCommonEncryptionScheme::Cbcs, + key: [0x42; 16], + kid: [0; 16], + initialization_vector: vec![], + constant_iv: Some(vec![ + 0xfe, 0xec, 0xda, 0xc8, 0xb6, 0xa4, 0x92, 0x80, 0x7f, 0x6d, 0x5b, 0x49, 0x37, 0x25, + 0x13, 0x01, + ]), + per_sample_iv_size: None, + crypt_byte_block: 1, + skip_byte_block: 1, + subsamples: vec![ + SencSubsample { + bytes_of_clear_data: 6, + bytes_of_protected_data: 24, + }, + SencSubsample { + bytes_of_clear_data: 0, + bytes_of_protected_data: 24, + }, + ], + plaintext: (0u8..54) + .map(|value| value.wrapping_mul(5) ^ 0x22) + .collect(), + use_fragment_group: false, + layout: DecryptFixtureLayout::CommonEncryption, + }; + + let encrypted_sample_entries = vec![ + build_fragmented_track_sample_entry(&first_spec, true), + build_fragmented_track_sample_entry(&second_spec, true), + ]; + let clear_sample_entries = vec![ + build_fragmented_track_sample_entry(&first_spec, false), + build_fragmented_track_sample_entry(&second_spec, false), + ]; + let init_segment = build_multi_sample_entry_init_segment(&encrypted_sample_entries); + let decrypted_init_segment = build_multi_sample_entry_init_segment(&clear_sample_entries); + + let first_ciphertext = encrypt_fixture_sample(&first_spec); + let second_ciphertext = encrypt_fixture_sample(&second_spec); + let media_segment = build_multi_sample_entry_media_segment( + [ + MultiSampleEntryFragmentSpec { + track_spec: &first_spec, + payload: &first_ciphertext, + sample_description_index: None, + base_media_decode_time: 0, + sequence_number: 1, + }, + MultiSampleEntryFragmentSpec { + track_spec: &second_spec, + payload: &second_ciphertext, + sample_description_index: Some(2), + base_media_decode_time: 1_000, + sequence_number: 2, + }, + ], + true, + ); + let decrypted_media_segment = build_multi_sample_entry_media_segment( + [ + MultiSampleEntryFragmentSpec { + track_spec: &first_spec, + payload: &first_spec.plaintext, + sample_description_index: None, + base_media_decode_time: 0, + sequence_number: 1, + }, + MultiSampleEntryFragmentSpec { + track_spec: &second_spec, + payload: &second_spec.plaintext, + sample_description_index: Some(2), + base_media_decode_time: 1_000, + sequence_number: 2, + }, + ], + false, + ); + let single_file = [init_segment.clone(), media_segment.clone()].concat(); + let decrypted_single_file = [ + decrypted_init_segment.clone(), + decrypted_media_segment.clone(), + ] + .concat(); + + ZeroKidMultiSampleEntryDecryptFixture { + init_segment, + media_segment, + single_file, + decrypted_init_segment, + decrypted_media_segment, + decrypted_single_file, + ordered_track_id_keys: vec![ + DecryptionKey::track(first_spec.track_id, first_spec.key), + DecryptionKey::track(second_spec.track_id, second_spec.key), + ], + } +} + +#[cfg(feature = "decrypt")] +fn build_decrypt_rewrite_fixture_with_mode(layout: DecryptFixtureLayout) -> DecryptRewriteFixture { + let first_spec = DecryptFixtureTrackSpec { + track_id: 1, + width: 320, + height: 180, + scheme_type: match layout { + DecryptFixtureLayout::CommonEncryption => fourcc("cenc"), + DecryptFixtureLayout::PiffCompatibility => fourcc("piff"), + }, + native_scheme: NativeCommonEncryptionScheme::Cenc, + key: [0x11; 16], + kid: [0xa1; 16], + initialization_vector: vec![1, 2, 3, 4, 5, 6, 7, 8], + constant_iv: None, + per_sample_iv_size: Some(8), + crypt_byte_block: 0, + skip_byte_block: 0, + subsamples: match layout { + DecryptFixtureLayout::CommonEncryption => vec![], + DecryptFixtureLayout::PiffCompatibility => vec![SencSubsample { + bytes_of_clear_data: 4, + bytes_of_protected_data: 32, + }], + }, + plaintext: (0u8..48).map(|value| value ^ 0x35).collect(), + use_fragment_group: false, + layout, + }; + let second_spec = DecryptFixtureTrackSpec { + track_id: 2, + width: 640, + height: 360, + scheme_type: match layout { + DecryptFixtureLayout::CommonEncryption => fourcc("cbcs"), + DecryptFixtureLayout::PiffCompatibility => fourcc("piff"), + }, + native_scheme: match layout { + DecryptFixtureLayout::CommonEncryption => NativeCommonEncryptionScheme::Cbcs, + DecryptFixtureLayout::PiffCompatibility => NativeCommonEncryptionScheme::Cbc1, + }, + key: [0x22; 16], + kid: [0xb2; 16], + initialization_vector: match layout { + DecryptFixtureLayout::CommonEncryption => vec![], + DecryptFixtureLayout::PiffCompatibility => { + vec![ + 0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc, 0xfe, 0x01, 0x23, 0x45, 0x67, 0x89, + 0xab, 0xcd, 0xef, + ] + } + }, + constant_iv: match layout { + DecryptFixtureLayout::CommonEncryption => Some(vec![ + 0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc, 0xfe, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, + 0xcd, 0xef, + ]), + DecryptFixtureLayout::PiffCompatibility => None, + }, + per_sample_iv_size: match layout { + DecryptFixtureLayout::CommonEncryption => None, + DecryptFixtureLayout::PiffCompatibility => Some(16), + }, + crypt_byte_block: match layout { + DecryptFixtureLayout::CommonEncryption => 1, + DecryptFixtureLayout::PiffCompatibility => 0, + }, + skip_byte_block: match layout { + DecryptFixtureLayout::CommonEncryption => 1, + DecryptFixtureLayout::PiffCompatibility => 0, + }, + subsamples: match layout { + DecryptFixtureLayout::CommonEncryption => vec![ + SencSubsample { + bytes_of_clear_data: 4, + bytes_of_protected_data: 48, + }, + SencSubsample { + bytes_of_clear_data: 2, + bytes_of_protected_data: 32, + }, + ], + DecryptFixtureLayout::PiffCompatibility => vec![SencSubsample { + bytes_of_clear_data: 0, + bytes_of_protected_data: 32, + }], + }, + plaintext: match layout { + DecryptFixtureLayout::CommonEncryption => { + (0u8..86).map(|value| value.wrapping_mul(7)).collect() + } + DecryptFixtureLayout::PiffCompatibility => { + (0u8..48).map(|value| value.wrapping_mul(7)).collect() + } + }, + use_fragment_group: matches!(layout, DecryptFixtureLayout::CommonEncryption), + layout, + }; + + let first_ciphertext = encrypt_fixture_sample(&first_spec); + let second_ciphertext = encrypt_fixture_sample(&second_spec); + let init_segment = build_decrypt_fixture_init_segment(&first_spec, &second_spec); + let media_segment = build_decrypt_fixture_media_segment( + &first_spec, + &second_spec, + &first_ciphertext, + &second_ciphertext, + ); + let single_file = [init_segment.clone(), media_segment.clone()].concat(); + + DecryptRewriteFixture { + init_segment, + media_segment, + single_file, + all_keys: vec![ + DecryptionKey::track(first_spec.track_id, first_spec.key), + DecryptionKey::kid(second_spec.kid, second_spec.key), + ], + first_track_only_keys: vec![DecryptionKey::track(first_spec.track_id, first_spec.key)], + first_track_id: first_spec.track_id, + second_track_id: second_spec.track_id, + first_track_plaintext: first_spec.plaintext, + second_track_plaintext: second_spec.plaintext, + } +} + +fn build_encrypted_fragmented_video_moov() -> Vec { + let mut mvhd = Mvhd::default(); + mvhd.timescale = 1_000; + mvhd.duration_v0 = 1_000; + mvhd.rate = 1 << 16; + mvhd.volume = 1 << 8; + mvhd.next_track_id = 2; + let mvhd = encode_supported_box(&mvhd, &[]); + + let mut trex = Trex::default(); + trex.track_id = 1; + trex.default_sample_description_index = 1; + let trex = encode_supported_box(&trex, &[]); + let mvex = encode_supported_box(&Mvex, &trex); + + encode_supported_box( + &Moov, + &[mvhd, build_encrypted_fragmented_video_trak(), mvex].concat(), + ) +} + +fn build_encrypted_fragmented_video_trak() -> Vec { + let mut tkhd = mp4forge::boxes::iso14496_12::Tkhd::default(); + tkhd.track_id = 1; + tkhd.width = u32::from(320_u16) << 16; + tkhd.height = u32::from(180_u16) << 16; + let tkhd = encode_supported_box(&tkhd, &[]); + + let mut mdhd = Mdhd::default(); + mdhd.timescale = 1_000; + mdhd.language = [5, 14, 7]; + let mdhd = encode_supported_box(&mdhd, &[]); + + let mut stsd = Stsd::default(); + stsd.entry_count = 1; + let stsd = encode_supported_box( + &stsd, + &encode_supported_box( + &video_sample_entry_with_type("encv", 320, 180), + &[ + encode_supported_box(&avc_config(), &[]), + build_encrypted_fragmented_video_sinf(), + ] + .concat(), + ), + ); + + let mut stco = Stco::default(); + stco.entry_count = 0; + let stco = encode_supported_box(&stco, &[]); + + let mut stts = Stts::default(); + stts.entry_count = 0; + let stts = encode_supported_box(&stts, &[]); + + let mut stsc = Stsc::default(); + stsc.entry_count = 0; + let stsc = encode_supported_box(&stsc, &[]); + + let mut stsz = Stsz::default(); + stsz.sample_count = 0; + let stsz = encode_supported_box(&stsz, &[]); + + let stbl = encode_supported_box(&Stbl, &[stsd, stco, stts, stsc, stsz].concat()); + let minf = encode_supported_box(&Minf, &stbl); + let mdia = encode_supported_box( + &Mdia, + &[mdhd, handler_box("vide", "VideoHandler"), minf].concat(), + ); + encode_supported_box(&Trak, &[tkhd, mdia].concat()) +} + +#[cfg(feature = "decrypt")] +fn build_decrypt_fixture_init_segment( + first_spec: &DecryptFixtureTrackSpec, + second_spec: &DecryptFixtureTrackSpec, +) -> Vec { + let ftyp = encode_supported_box( + &Ftyp { + major_brand: match first_spec.layout { + DecryptFixtureLayout::CommonEncryption => fourcc("iso6"), + DecryptFixtureLayout::PiffCompatibility => fourcc("piff"), + }, + minor_version: 1, + compatible_brands: match first_spec.layout { + DecryptFixtureLayout::CommonEncryption => { + vec![fourcc("iso6"), fourcc("dash"), fourcc("cenc")] + } + DecryptFixtureLayout::PiffCompatibility => { + vec![fourcc("piff"), fourcc("iso6"), fourcc("dash")] + } + }, + }, + &[], + ); + + let mut mvhd = Mvhd::default(); + mvhd.timescale = 1_000; + mvhd.duration_v0 = 1_000; + mvhd.rate = 1 << 16; + mvhd.volume = 1 << 8; + mvhd.next_track_id = 3; + let mvhd = encode_supported_box(&mvhd, &[]); + + let first_trex = build_decrypt_fixture_trex(first_spec); + let second_trex = build_decrypt_fixture_trex(second_spec); + let mvex = encode_supported_box(&Mvex, &[first_trex, second_trex].concat()); + + let moov = encode_supported_box( + &Moov, + &[ + mvhd, + build_decrypt_fixture_trak(first_spec), + build_decrypt_fixture_trak(second_spec), + mvex, + ] + .concat(), + ); + + [ftyp, moov].concat() +} + +#[cfg(feature = "decrypt")] +fn build_decrypt_fixture_trak(spec: &DecryptFixtureTrackSpec) -> Vec { + let mut tkhd = mp4forge::boxes::iso14496_12::Tkhd::default(); + tkhd.track_id = spec.track_id; + tkhd.width = u32::from(spec.width) << 16; + tkhd.height = u32::from(spec.height) << 16; + let tkhd = encode_supported_box(&tkhd, &[]); + + let mut mdhd = Mdhd::default(); + mdhd.timescale = 1_000; + mdhd.language = [5, 14, 7]; + let mdhd = encode_supported_box(&mdhd, &[]); + + let mut stsd = Stsd::default(); + stsd.entry_count = 1; + let stsd = encode_supported_box( + &stsd, + &encode_supported_box( + &video_sample_entry_with_type("encv", spec.width, spec.height), + &[ + encode_supported_box(&avc_config(), &[]), + build_decrypt_fixture_sinf(spec), + ] + .concat(), + ), + ); + + let mut stco = Stco::default(); + stco.entry_count = 0; + let stco = encode_supported_box(&stco, &[]); + + let mut stts = Stts::default(); + stts.entry_count = 0; + let stts = encode_supported_box(&stts, &[]); + + let mut stsc = Stsc::default(); + stsc.entry_count = 0; + let stsc = encode_supported_box(&stsc, &[]); + + let mut stsz = Stsz::default(); + stsz.sample_count = 0; + let stsz = encode_supported_box(&stsz, &[]); + + let stbl = encode_supported_box(&Stbl, &[stsd, stco, stts, stsc, stsz].concat()); + let minf = encode_supported_box(&Minf, &stbl); + let mdia = encode_supported_box( + &Mdia, + &[mdhd, handler_box("vide", "VideoHandler"), minf].concat(), + ); + encode_supported_box(&Trak, &[tkhd, mdia].concat()) +} + +#[cfg(feature = "decrypt")] +fn build_decrypt_fixture_sinf(spec: &DecryptFixtureTrackSpec) -> Vec { + let mut schm = Schm::default(); + schm.set_version(0); + schm.scheme_type = spec.scheme_type; + schm.scheme_version = 0x0001_0000; + + let mut tenc = Tenc::default(); + tenc.set_version(match spec.layout { + DecryptFixtureLayout::CommonEncryption => 1, + DecryptFixtureLayout::PiffCompatibility => 0, + }); + tenc.default_crypt_byte_block = spec.crypt_byte_block; + tenc.default_skip_byte_block = spec.skip_byte_block; + tenc.default_is_protected = match (spec.layout, spec.native_scheme) { + (DecryptFixtureLayout::CommonEncryption, _) => 1, + (DecryptFixtureLayout::PiffCompatibility, NativeCommonEncryptionScheme::Cenc) => 1, + (DecryptFixtureLayout::PiffCompatibility, NativeCommonEncryptionScheme::Cbc1) => 2, + (DecryptFixtureLayout::PiffCompatibility, _) => { + panic!("PIFF fixture layout only supports CTR and full-block CBC tracks") + } + }; + tenc.default_per_sample_iv_size = spec.per_sample_iv_size.unwrap_or(0); + tenc.default_kid = spec.kid; + if let Some(constant_iv) = &spec.constant_iv { + tenc.default_constant_iv_size = u8::try_from(constant_iv.len()).unwrap(); + tenc.default_constant_iv = constant_iv.clone(); + } + + let schi_child = match spec.layout { + DecryptFixtureLayout::CommonEncryption => encode_supported_box(&tenc, &[]), + DecryptFixtureLayout::PiffCompatibility => build_piff_track_encryption_uuid_box(&tenc), + }; + let schi = encode_supported_box(&Schi, &schi_child); + encode_supported_box( + &Sinf, + &[ + encode_supported_box( + &Frma { + data_format: fourcc("avc1"), + }, + &[], + ), + encode_supported_box(&schm, &[]), + schi, + ] + .concat(), ) } -pub fn normalize_text(value: &str) -> String { - value.replace("\r\n", "\n") +#[cfg(feature = "decrypt")] +fn build_decrypt_fixture_trex(spec: &DecryptFixtureTrackSpec) -> Vec { + let mut trex = Trex::default(); + trex.track_id = spec.track_id; + trex.default_sample_description_index = 1; + trex.default_sample_duration = 1_000; + trex.default_sample_size = u32::try_from(spec.plaintext.len()).unwrap(); + encode_supported_box(&trex, &[]) } -pub fn build_encrypted_fragmented_video_file() -> Vec { - let ftyp = encode_supported_box( - &Ftyp { - major_brand: fourcc("iso6"), - minor_version: 1, - compatible_brands: vec![fourcc("iso6"), fourcc("dash"), fourcc("cenc")], - }, - &[], - ); - let moov = build_encrypted_fragmented_video_moov(); - let moof = build_encrypted_fragmented_video_moof(); - let mdat = encode_raw_box(fourcc("mdat"), &[0xde, 0xad, 0xbe, 0xef]); - [ftyp, moov, moof, mdat].concat() +#[cfg(feature = "decrypt")] +struct MultiSampleEntryFragmentSpec<'a> { + track_spec: &'a DecryptFixtureTrackSpec, + payload: &'a [u8], + sample_description_index: Option, + base_media_decode_time: u64, + sequence_number: u32, } -pub fn build_visual_sample_entry_box_with_trailing_bytes() -> Vec { - let pasp = encode_supported_box( - &Pasp { - h_spacing: 1, - v_spacing: 1, - }, - &[], - ); - let mut extensions = pasp; - extensions.extend_from_slice(&visual_sample_entry_trailing_bytes()); - encode_supported_box(&video_sample_entry_with_type("avc1", 640, 360), &extensions) -} +#[cfg(feature = "decrypt")] +fn build_fragmented_track_sample_entry(spec: &DecryptFixtureTrackSpec, protected: bool) -> Vec { + if protected { + return encode_supported_box( + &video_sample_entry_with_type("encv", spec.width, spec.height), + &[ + encode_supported_box(&avc_config(), &[]), + build_decrypt_fixture_sinf(spec), + ] + .concat(), + ); + } -pub fn visual_sample_entry_trailing_bytes() -> Vec { - vec![0xde, 0xad, 0xbe] + encode_supported_box( + &video_sample_entry_with_type("avc1", spec.width, spec.height), + &encode_supported_box(&avc_config(), &[]), + ) } -pub fn build_event_message_movie_file() -> Vec { +#[cfg(feature = "decrypt")] +fn build_multi_sample_entry_init_segment(sample_entries: &[Vec]) -> Vec { let ftyp = encode_supported_box( &Ftyp { - major_brand: fourcc("isom"), - minor_version: 1, - compatible_brands: vec![fourcc("isom"), fourcc("iso8")], + major_brand: fourcc("iso6"), + minor_version: 0, + compatible_brands: vec![fourcc("iso6"), fourcc("isom"), fourcc("dash")], }, &[], ); - let moov = build_event_message_moov(); - let emib = encode_supported_box(&event_message_instance_box(), &[]); - let emeb = encode_supported_box(&Emeb, &[]); - let mdat = encode_raw_box(fourcc("mdat"), &[0x01, 0x02, 0x03, 0x04]); - [ftyp, moov, emib, emeb, mdat].concat() -} -fn build_encrypted_fragmented_video_moov() -> Vec { let mut mvhd = Mvhd::default(); mvhd.timescale = 1_000; - mvhd.duration_v0 = 1_000; + mvhd.duration_v0 = 2_000; mvhd.rate = 1 << 16; mvhd.volume = 1 << 8; mvhd.next_track_id = 2; @@ -146,20 +2735,33 @@ fn build_encrypted_fragmented_video_moov() -> Vec { let mut trex = Trex::default(); trex.track_id = 1; trex.default_sample_description_index = 1; - let trex = encode_supported_box(&trex, &[]); - let mvex = encode_supported_box(&Mvex, &trex); - - encode_supported_box( + trex.default_sample_duration = 1_000; + trex.default_sample_size = 54; + let mvex = encode_supported_box(&Mvex, &encode_supported_box(&trex, &[])); + let moov = encode_supported_box( &Moov, - &[mvhd, build_encrypted_fragmented_video_trak(), mvex].concat(), - ) + &[ + mvhd, + build_fragmented_track_with_sample_entries(1, 320, 180, sample_entries), + mvex, + ] + .concat(), + ); + + [ftyp, moov].concat() } -fn build_encrypted_fragmented_video_trak() -> Vec { +#[cfg(feature = "decrypt")] +fn build_fragmented_track_with_sample_entries( + track_id: u32, + width: u16, + height: u16, + sample_entries: &[Vec], +) -> Vec { let mut tkhd = mp4forge::boxes::iso14496_12::Tkhd::default(); - tkhd.track_id = 1; - tkhd.width = u32::from(320_u16) << 16; - tkhd.height = u32::from(180_u16) << 16; + tkhd.track_id = track_id; + tkhd.width = u32::from(width) << 16; + tkhd.height = u32::from(height) << 16; let tkhd = encode_supported_box(&tkhd, &[]); let mut mdhd = Mdhd::default(); @@ -168,18 +2770,8 @@ fn build_encrypted_fragmented_video_trak() -> Vec { let mdhd = encode_supported_box(&mdhd, &[]); let mut stsd = Stsd::default(); - stsd.entry_count = 1; - let stsd = encode_supported_box( - &stsd, - &encode_supported_box( - &video_sample_entry_with_type("encv", 320, 180), - &[ - encode_supported_box(&avc_config(), &[]), - build_encrypted_fragmented_video_sinf(), - ] - .concat(), - ), - ); + stsd.entry_count = u32::try_from(sample_entries.len()).unwrap(); + let stsd = encode_supported_box(&stsd, &sample_entries.concat()); let mut stco = Stco::default(); stco.entry_count = 0; @@ -206,6 +2798,294 @@ fn build_encrypted_fragmented_video_trak() -> Vec { encode_supported_box(&Trak, &[tkhd, mdia].concat()) } +#[cfg(feature = "decrypt")] +fn build_multi_sample_entry_media_segment( + fragments: [MultiSampleEntryFragmentSpec<'_>; 2], + encrypted: bool, +) -> Vec { + let styp = encode_supported_box( + &Ftyp { + major_brand: fourcc("msdh"), + minor_version: 0, + compatible_brands: vec![fourcc("msdh"), fourcc("msix")], + }, + &[], + ); + let mut output = styp; + for fragment in fragments { + let moof_placeholder = build_multi_sample_entry_fragment_moof(&fragment, 0, encrypted); + let data_offset = i32::try_from(moof_placeholder.len() + 8).unwrap(); + let moof = build_multi_sample_entry_fragment_moof(&fragment, data_offset, encrypted); + let mdat = encode_raw_box(fourcc("mdat"), fragment.payload); + output.extend_from_slice(&moof); + output.extend_from_slice(&mdat); + } + output +} + +#[cfg(feature = "decrypt")] +fn build_multi_sample_entry_fragment_moof( + fragment: &MultiSampleEntryFragmentSpec<'_>, + data_offset: i32, + encrypted: bool, +) -> Vec { + let mut mfhd = Mfhd::default(); + mfhd.sequence_number = fragment.sequence_number; + let mfhd = encode_supported_box(&mfhd, &[]); + let traf = if encrypted { + build_decrypt_fixture_traf_with_options( + fragment.track_spec, + data_offset, + fragment.sample_description_index, + fragment.base_media_decode_time, + ) + } else { + build_clear_fragment_traf( + fragment.track_spec.track_id, + u32::try_from(fragment.payload.len()).unwrap(), + data_offset, + fragment.sample_description_index, + fragment.base_media_decode_time, + ) + }; + encode_supported_box(&Moof, &[mfhd, traf].concat()) +} + +#[cfg(feature = "decrypt")] +fn build_clear_fragment_traf( + track_id: u32, + sample_size: u32, + data_offset: i32, + sample_description_index: Option, + base_media_decode_time: u64, +) -> Vec { + let mut tfhd = Tfhd::default(); + let mut tfhd_flags = TFHD_DEFAULT_BASE_IS_MOOF + | TFHD_DEFAULT_SAMPLE_DURATION_PRESENT + | TFHD_DEFAULT_SAMPLE_SIZE_PRESENT; + if sample_description_index.is_some() { + tfhd_flags |= TFHD_SAMPLE_DESCRIPTION_INDEX_PRESENT; + } + tfhd.set_flags(tfhd_flags); + tfhd.track_id = track_id; + tfhd.default_sample_duration = 1_000; + tfhd.default_sample_size = sample_size; + if let Some(sample_description_index) = sample_description_index { + tfhd.sample_description_index = sample_description_index; + } + let tfhd = encode_supported_box(&tfhd, &[]); + + let mut tfdt = Tfdt::default(); + tfdt.set_version(1); + tfdt.base_media_decode_time_v1 = base_media_decode_time; + let tfdt = encode_supported_box(&tfdt, &[]); + + let mut trun = Trun::default(); + trun.set_flags(TRUN_DATA_OFFSET_PRESENT); + trun.sample_count = 1; + trun.data_offset = data_offset; + let trun = encode_supported_box(&trun, &[]); + + encode_supported_box(&Traf, &[tfhd, tfdt, trun].concat()) +} + +#[cfg(feature = "decrypt")] +fn build_decrypt_fixture_media_segment( + first_spec: &DecryptFixtureTrackSpec, + second_spec: &DecryptFixtureTrackSpec, + first_ciphertext: &[u8], + second_ciphertext: &[u8], +) -> Vec { + let styp = encode_supported_box( + &Ftyp { + major_brand: fourcc("msdh"), + minor_version: 0, + compatible_brands: vec![fourcc("msdh"), fourcc("msix")], + }, + &[], + ); + + let moof_placeholder = build_decrypt_fixture_moof(first_spec, second_spec, 0, 0); + let first_data_offset = i32::try_from(moof_placeholder.len() + 8).unwrap(); + let second_data_offset = first_data_offset + i32::try_from(first_ciphertext.len()).unwrap(); + let moof = build_decrypt_fixture_moof( + first_spec, + second_spec, + first_data_offset, + second_data_offset, + ); + let mdat = encode_raw_box( + fourcc("mdat"), + &[first_ciphertext, second_ciphertext].concat(), + ); + [styp, moof, mdat].concat() +} + +#[cfg(feature = "decrypt")] +fn build_decrypt_fixture_moof( + first_spec: &DecryptFixtureTrackSpec, + second_spec: &DecryptFixtureTrackSpec, + first_data_offset: i32, + second_data_offset: i32, +) -> Vec { + let mut mfhd = Mfhd::default(); + mfhd.sequence_number = 1; + let mfhd = encode_supported_box(&mfhd, &[]); + let first_traf = build_decrypt_fixture_traf(first_spec, first_data_offset); + let second_traf = build_decrypt_fixture_traf(second_spec, second_data_offset); + encode_supported_box(&Moof, &[mfhd, first_traf, second_traf].concat()) +} + +#[cfg(feature = "decrypt")] +fn build_decrypt_fixture_traf(spec: &DecryptFixtureTrackSpec, data_offset: i32) -> Vec { + build_decrypt_fixture_traf_with_options(spec, data_offset, None, 0) +} + +#[cfg(feature = "decrypt")] +fn build_decrypt_fixture_traf_with_options( + spec: &DecryptFixtureTrackSpec, + data_offset: i32, + sample_description_index: Option, + base_media_decode_time: u64, +) -> Vec { + let mut tfhd = Tfhd::default(); + let mut tfhd_flags = TFHD_DEFAULT_BASE_IS_MOOF + | TFHD_DEFAULT_SAMPLE_DURATION_PRESENT + | TFHD_DEFAULT_SAMPLE_SIZE_PRESENT; + if sample_description_index.is_some() { + tfhd_flags |= TFHD_SAMPLE_DESCRIPTION_INDEX_PRESENT; + } + tfhd.set_flags(tfhd_flags); + tfhd.track_id = spec.track_id; + tfhd.default_sample_duration = 1_000; + tfhd.default_sample_size = u32::try_from(spec.plaintext.len()).unwrap(); + if let Some(sample_description_index) = sample_description_index { + tfhd.sample_description_index = sample_description_index; + } + let tfhd = encode_supported_box(&tfhd, &[]); + + let mut tfdt = Tfdt::default(); + tfdt.set_version(1); + tfdt.base_media_decode_time_v1 = base_media_decode_time; + let tfdt = encode_supported_box(&tfdt, &[]); + + let mut trun = Trun::default(); + trun.set_flags(TRUN_DATA_OFFSET_PRESENT); + trun.sample_count = 1; + trun.data_offset = data_offset; + let trun = encode_supported_box(&trun, &[]); + + let mut saiz = Saiz::default(); + saiz.sample_count = 1; + saiz.sample_info_size = vec![decrypt_fixture_aux_info_size(spec)]; + let saiz = encode_supported_box(&saiz, &[]); + + let mut saio = Saio::default(); + saio.entry_count = 1; + saio.offset_v0 = vec![0]; + let saio = encode_supported_box(&saio, &[]); + + let senc = match spec.layout { + DecryptFixtureLayout::CommonEncryption => { + encode_supported_box(&build_decrypt_fixture_senc(spec), &[]) + } + DecryptFixtureLayout::PiffCompatibility => { + let mut uuid = Uuid::default(); + uuid.user_type = UUID_SAMPLE_ENCRYPTION; + uuid.payload = UuidPayload::SampleEncryption(build_decrypt_fixture_senc(spec)); + encode_supported_box(&uuid, &[]) + } + }; + let sgpd = if spec.use_fragment_group { + build_decrypt_fixture_sgpd(spec) + } else { + Vec::new() + }; + let sbgp = if spec.use_fragment_group { + build_decrypt_fixture_sbgp() + } else { + Vec::new() + }; + + encode_supported_box( + &Traf, + &[tfhd, tfdt, trun, saiz, saio, senc, sgpd, sbgp].concat(), + ) +} + +#[cfg(feature = "decrypt")] +fn build_decrypt_fixture_senc(spec: &DecryptFixtureTrackSpec) -> Senc { + let mut senc = Senc::default(); + senc.set_version(0); + if !spec.subsamples.is_empty() { + senc.set_flags(SENC_USE_SUBSAMPLE_ENCRYPTION); + } + senc.sample_count = 1; + senc.samples = vec![SencSample { + initialization_vector: spec.initialization_vector.clone(), + subsamples: spec.subsamples.clone(), + }]; + senc +} + +#[cfg(feature = "decrypt")] +fn build_decrypt_fixture_sgpd(spec: &DecryptFixtureTrackSpec) -> Vec { + let mut sgpd = Sgpd::default(); + sgpd.set_version(1); + sgpd.grouping_type = fourcc("seig"); + sgpd.default_length = 0; + sgpd.entry_count = 1; + let mut seig = SeigEntry { + crypt_byte_block: spec.crypt_byte_block, + skip_byte_block: spec.skip_byte_block, + is_protected: 1, + per_sample_iv_size: spec.per_sample_iv_size.unwrap_or(0), + kid: spec.kid, + ..SeigEntry::default() + }; + if let Some(constant_iv) = &spec.constant_iv { + seig.constant_iv_size = u8::try_from(constant_iv.len()).unwrap(); + seig.constant_iv = constant_iv.clone(); + } + sgpd.seig_entries_l = vec![SeigEntryL { + description_length: decrypt_fixture_seig_description_length(&seig), + seig_entry: seig, + }]; + encode_supported_box(&sgpd, &[]) +} + +#[cfg(feature = "decrypt")] +fn decrypt_fixture_seig_description_length(entry: &SeigEntry) -> u32 { + let mut length = 20u32; + if entry.is_protected == 1 && entry.per_sample_iv_size == 0 { + length += 1 + u32::from(entry.constant_iv_size); + } + length +} + +#[cfg(feature = "decrypt")] +fn build_decrypt_fixture_sbgp() -> Vec { + let mut sbgp = Sbgp::default(); + sbgp.grouping_type = u32::from_be_bytes(*b"seig"); + sbgp.entry_count = 1; + sbgp.entries = vec![SbgpEntry { + sample_count: 1, + group_description_index: 65_537, + }]; + encode_supported_box(&sbgp, &[]) +} + +#[cfg(feature = "decrypt")] +fn decrypt_fixture_aux_info_size(spec: &DecryptFixtureTrackSpec) -> u8 { + let iv_size = spec.per_sample_iv_size.unwrap_or(0); + let subsample_bytes = if spec.subsamples.is_empty() { + 0 + } else { + 2 + (6 * u32::try_from(spec.subsamples.len()).unwrap()) + }; + u8::try_from(u32::from(iv_size) + subsample_bytes).unwrap() +} + fn build_event_message_moov() -> Vec { let mut mvhd = Mvhd::default(); mvhd.timescale = 1_000; @@ -433,6 +3313,313 @@ fn build_encrypted_fragmented_video_moof() -> Vec { encode_supported_box(&Moof, &[mfhd, traf].concat()) } +#[cfg(feature = "decrypt")] +struct DecryptFixtureTrackSpec { + track_id: u32, + width: u16, + height: u16, + scheme_type: FourCc, + native_scheme: NativeCommonEncryptionScheme, + key: [u8; 16], + kid: [u8; 16], + initialization_vector: Vec, + constant_iv: Option>, + per_sample_iv_size: Option, + crypt_byte_block: u8, + skip_byte_block: u8, + subsamples: Vec, + plaintext: Vec, + use_fragment_group: bool, + layout: DecryptFixtureLayout, +} + +#[cfg(feature = "decrypt")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DecryptFixtureLayout { + CommonEncryption, + PiffCompatibility, +} + +#[cfg(feature = "decrypt")] +fn encrypt_fixture_sample(spec: &DecryptFixtureTrackSpec) -> Vec { + let sample = resolved_decrypt_fixture_sample(spec); + let iv = sample.effective_initialization_vector(); + let pattern = DecryptFixturePattern { + crypt_byte_block: spec.crypt_byte_block, + skip_byte_block: spec.skip_byte_block, + }; + let iv_block = if iv.len() == 16 { + iv.try_into().unwrap() + } else { + let mut padded = [0u8; 16]; + padded[..iv.len()].copy_from_slice(iv); + padded + }; + let scheme = spec.native_scheme; + let mut output = spec.plaintext.clone(); + + if sample.subsamples.is_empty() { + encrypt_fixture_region( + scheme, + spec.key, + iv_block, + pattern, + &spec.plaintext, + &mut output, + ); + return output; + } + + let mut cursor = 0usize; + let mut state = DecryptFixtureEncryptState { + ctr_offset: 0, + pattern_offset: 0, + chain_block: iv_block, + }; + for subsample in sample.subsamples { + cursor += usize::from(subsample.bytes_of_clear_data); + let protected = usize::try_from(subsample.bytes_of_protected_data).unwrap(); + if scheme == NativeCommonEncryptionScheme::Cbcs { + state.ctr_offset = 0; + state.pattern_offset = 0; + state.chain_block = iv_block; + } + encrypt_fixture_region_with_state( + scheme, + spec.key, + iv_block, + pattern, + &mut state, + &spec.plaintext[cursor..cursor + protected], + &mut output[cursor..cursor + protected], + ); + cursor += protected; + } + + output +} + +#[cfg(feature = "decrypt")] +fn build_piff_track_encryption_uuid_box(tenc: &Tenc) -> Vec { + let mut payload = vec![0, 0, 0, 0]; + payload.push(tenc.reserved); + payload.push(0); + payload.push(tenc.default_is_protected); + payload.push(tenc.default_per_sample_iv_size); + payload.extend_from_slice(&tenc.default_kid); + if tenc.default_per_sample_iv_size == 0 { + payload.push(tenc.default_constant_iv_size); + payload.extend_from_slice(&tenc.default_constant_iv); + } + encode_uuid_box( + [ + 0x89, 0x74, 0xdb, 0xce, 0x7b, 0xe7, 0x4c, 0x51, 0x84, 0xf9, 0x71, 0x48, 0xf9, 0x88, + 0x25, 0x54, + ], + &payload, + ) +} + +#[cfg(feature = "decrypt")] +fn encode_uuid_box(user_type: [u8; 16], payload: &[u8]) -> Vec { + let info = BoxInfo::new(fourcc("uuid"), 8 + 16 + payload.len() as u64); + let mut bytes = info.encode(); + bytes.extend_from_slice(&user_type); + bytes.extend_from_slice(payload); + bytes +} + +#[cfg(feature = "decrypt")] +fn resolved_decrypt_fixture_sample( + spec: &DecryptFixtureTrackSpec, +) -> ResolvedSampleEncryptionSample<'static> { + let initialization_vector = Box::leak(spec.initialization_vector.clone().into_boxed_slice()); + let constant_iv = spec + .constant_iv + .clone() + .map(|bytes| Box::leak(bytes.into_boxed_slice()) as &'static [u8]); + let subsamples = Box::leak(spec.subsamples.clone().into_boxed_slice()); + ResolvedSampleEncryptionSample { + sample_index: 1, + metadata_source: ResolvedSampleEncryptionSource::TrackEncryptionBox, + is_protected: true, + crypt_byte_block: spec.crypt_byte_block, + skip_byte_block: spec.skip_byte_block, + per_sample_iv_size: spec.per_sample_iv_size, + initialization_vector, + constant_iv, + kid: spec.kid, + subsamples, + auxiliary_info_size: 0, + } +} + +#[cfg(feature = "decrypt")] +struct DecryptFixtureEncryptState { + ctr_offset: u64, + pattern_offset: u64, + chain_block: [u8; 16], +} + +#[cfg(feature = "decrypt")] +#[derive(Clone, Copy)] +struct DecryptFixturePattern { + crypt_byte_block: u8, + skip_byte_block: u8, +} + +#[cfg(feature = "decrypt")] +fn encrypt_fixture_region( + scheme: NativeCommonEncryptionScheme, + key: [u8; 16], + iv: [u8; 16], + pattern: DecryptFixturePattern, + plaintext: &[u8], + output: &mut [u8], +) { + let mut state = DecryptFixtureEncryptState { + ctr_offset: 0, + pattern_offset: 0, + chain_block: iv, + }; + encrypt_fixture_region_with_state(scheme, key, iv, pattern, &mut state, plaintext, output); +} + +#[cfg(feature = "decrypt")] +fn encrypt_fixture_region_with_state( + scheme: NativeCommonEncryptionScheme, + key: [u8; 16], + iv: [u8; 16], + pattern: DecryptFixturePattern, + state: &mut DecryptFixtureEncryptState, + plaintext: &[u8], + output: &mut [u8], +) { + if pattern.crypt_byte_block != 0 && pattern.skip_byte_block != 0 { + let pattern_span = + usize::from(pattern.crypt_byte_block) + usize::from(pattern.skip_byte_block); + let mut cursor = 0usize; + while cursor < plaintext.len() { + let block_position = usize::try_from(state.pattern_offset / 16).unwrap(); + let pattern_position = block_position % pattern_span; + let mut crypt_size = 0usize; + let mut skip_size = usize::from(pattern.skip_byte_block) * 16; + if pattern_position < usize::from(pattern.crypt_byte_block) { + crypt_size = (usize::from(pattern.crypt_byte_block) - pattern_position) * 16; + } else { + skip_size = (pattern_span - pattern_position) * 16; + } + + let remain = plaintext.len() - cursor; + if crypt_size > remain { + crypt_size = 16 * (remain / 16); + skip_size = remain - crypt_size; + } + if crypt_size + skip_size > remain { + skip_size = remain - crypt_size; + } + + if crypt_size != 0 { + encrypt_fixture_chunk( + scheme, + key, + iv, + &mut state.ctr_offset, + &mut state.chain_block, + &plaintext[cursor..cursor + crypt_size], + &mut output[cursor..cursor + crypt_size], + ); + cursor += crypt_size; + state.pattern_offset += crypt_size as u64; + } + + if skip_size != 0 { + output[cursor..cursor + skip_size] + .copy_from_slice(&plaintext[cursor..cursor + skip_size]); + cursor += skip_size; + state.pattern_offset += skip_size as u64; + } + } + } else { + encrypt_fixture_chunk( + scheme, + key, + iv, + &mut state.ctr_offset, + &mut state.chain_block, + plaintext, + output, + ); + } +} + +#[cfg(feature = "decrypt")] +fn encrypt_fixture_chunk( + scheme: NativeCommonEncryptionScheme, + key: [u8; 16], + iv: [u8; 16], + ctr_offset: &mut u64, + chain_block: &mut [u8; 16], + plaintext: &[u8], + output: &mut [u8], +) { + match scheme { + NativeCommonEncryptionScheme::Cenc | NativeCommonEncryptionScheme::Cens => { + let aes = Aes128::new(&key.into()); + let mut cursor = 0usize; + while cursor < plaintext.len() { + let block_offset = usize::try_from(*ctr_offset % 16).unwrap(); + let chunk_len = (16 - block_offset).min(plaintext.len() - cursor); + let mut counter_block = compute_fixture_ctr_counter_block(iv, *ctr_offset); + aes.encrypt_block(&mut counter_block); + for index in 0..chunk_len { + output[cursor + index] = + plaintext[cursor + index] ^ counter_block[block_offset + index]; + } + cursor += chunk_len; + *ctr_offset += chunk_len as u64; + } + } + NativeCommonEncryptionScheme::Cbc1 | NativeCommonEncryptionScheme::Cbcs => { + let aes = Aes128::new(&key.into()); + let full_blocks_len = plaintext.len() - (plaintext.len() % 16); + let mut cursor = 0usize; + while cursor < full_blocks_len { + let mut block = Block::::clone_from_slice(&plaintext[cursor..cursor + 16]); + for index in 0..16 { + block[index] ^= chain_block[index]; + } + aes.encrypt_block(&mut block); + output[cursor..cursor + 16].copy_from_slice(&block); + chain_block.copy_from_slice(&block); + cursor += 16; + } + output[full_blocks_len..].copy_from_slice(&plaintext[full_blocks_len..]); + } + } +} + +#[cfg(feature = "decrypt")] +fn compute_fixture_ctr_counter_block(iv: [u8; 16], stream_offset: u64) -> Block { + let counter_offset = stream_offset / 16; + let counter_offset_bytes = counter_offset.to_be_bytes(); + let mut counter_block = Block::::default(); + + let mut carry = 0u16; + for index in 0..8 { + let offset = 15 - index; + let sum = u16::from(iv[offset]) + u16::from(counter_offset_bytes[7 - index]) + carry; + counter_block[offset] = (sum & 0xff) as u8; + carry = if sum >= 0x100 { 1 } else { 0 }; + } + for index in 8..16 { + let offset = 15 - index; + counter_block[offset] = iv[offset]; + } + + counter_block +} + fn encrypted_fragment_default_kid() -> [u8; 16] { [ 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc,