diff options
Diffstat (limited to 'rust/crates/transfer')
| -rw-r--r-- | rust/crates/transfer/Cargo.toml | 10 | ||||
| -rw-r--r-- | rust/crates/transfer/src/container.rs | 277 | ||||
| -rw-r--r-- | rust/crates/transfer/src/lib.rs | 8 | ||||
| -rw-r--r-- | rust/crates/transfer/tests/container.rs | 95 | ||||
| -rw-r--r-- | rust/crates/transfer/tests/cross_compat.rs | 56 | ||||
| -rw-r--r-- | rust/crates/transfer/tests/fixtures/web-built.ocp | bin | 0 -> 660 bytes |
6 files changed, 446 insertions, 0 deletions
diff --git a/rust/crates/transfer/Cargo.toml b/rust/crates/transfer/Cargo.toml new file mode 100644 index 00000000..f13cb413 --- /dev/null +++ b/rust/crates/transfer/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "transfer" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2" +zip = { version = "2", default-features = false } diff --git a/rust/crates/transfer/src/container.rs b/rust/crates/transfer/src/container.rs new file mode 100644 index 00000000..bcdc2494 --- /dev/null +++ b/rust/crates/transfer/src/container.rs @@ -0,0 +1,277 @@ +use std::collections::BTreeMap; +use std::io::{Cursor, Read, Write}; + +use serde::{Deserialize, Serialize}; +use zip::write::SimpleFileOptions; +use zip::{ZipArchive, ZipWriter}; + +pub const PROJECT_ARCHIVE_EXTENSION: &str = ".ocp"; +pub const PROJECT_JSON_ENTRY: &str = "project.json"; +pub const MEDIA_MANIFEST_ENTRY: &str = "media/manifest.json"; +pub const MEDIA_ENTRY_PREFIX: &str = "media/"; + +const MEDIA_MANIFEST_VERSION: u32 = 1; + +#[derive(Debug, thiserror::Error)] +#[error("{message}")] +pub struct ProjectArchiveError { + message: String, +} + +impl ProjectArchiveError { + fn new(message: impl Into<String>) -> Self { + Self { + message: message.into(), + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ArchivedMediaMetadata { + pub id: String, + pub name: String, + pub media_type: String, + pub file_type: String, + pub last_modified: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option<f64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub height: Option<f64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration: Option<f64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ephemeral: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thumbnail_url: Option<String>, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ArchivedMediaEntry { + #[serde(flatten)] + pub metadata: ArchivedMediaMetadata, + pub entry: String, +} + +pub struct ArchiveMediaInput { + pub metadata: ArchivedMediaMetadata, + pub data: Vec<u8>, +} + +pub struct ParsedProjectArchive { + pub project: serde_json::Map<String, serde_json::Value>, + pub media: Vec<ArchivedMediaEntry>, + files: BTreeMap<String, Vec<u8>>, +} + +impl std::fmt::Debug for ParsedProjectArchive { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ParsedProjectArchive") + .field("project", &self.project) + .field("media", &self.media) + .finish_non_exhaustive() + } +} + +impl ParsedProjectArchive { + pub fn read_media_data(&self, entry: &str) -> Result<&[u8], ProjectArchiveError> { + self.files + .get(entry) + .map(Vec::as_slice) + .ok_or_else(|| { + ProjectArchiveError::new(format!( + "Media file is missing from the archive ({entry})" + )) + }) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct MediaManifest { + version: u32, + media: Vec<ArchivedMediaEntry>, +} + +pub fn sanitize_file_name(name: &str) -> String { + let mut sanitized = String::with_capacity(name.len()); + let mut last_was_underscore = false; + for ch in name.trim().chars() { + let valid = ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-'); + if valid { + sanitized.push(ch); + last_was_underscore = false; + } else if !last_was_underscore { + sanitized.push('_'); + last_was_underscore = true; + } + } + let trimmed = sanitized.trim_start_matches('.'); + if trimmed.is_empty() { + "file".to_string() + } else { + trimmed.to_string() + } +} + +pub fn build_media_entry_name(asset_id: &str, name: &str) -> String { + format!("{MEDIA_ENTRY_PREFIX}{asset_id}-{}", sanitize_file_name(name)) +} + +pub fn build_project_archive( + project_json: &str, + media: Vec<ArchiveMediaInput>, +) -> Result<Vec<u8>, ProjectArchiveError> { + let manifest: Vec<ArchivedMediaEntry> = media + .iter() + .map(|input| ArchivedMediaEntry { + entry: build_media_entry_name(&input.metadata.id, &input.metadata.name), + metadata: input.metadata.clone(), + }) + .collect(); + + let manifest_json = serde_json::to_string(&MediaManifest { + version: MEDIA_MANIFEST_VERSION, + media: manifest.clone(), + }) + .map_err(|error| ProjectArchiveError::new(error.to_string()))?; + + let mut cursor = Cursor::new(Vec::new()); + { + let mut writer = ZipWriter::new(&mut cursor); + let options = SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + let entries: Vec<(&str, &[u8])> = [ + (PROJECT_JSON_ENTRY, project_json.as_bytes()), + (MEDIA_MANIFEST_ENTRY, manifest_json.as_bytes()), + ] + .into_iter() + .chain( + manifest + .iter() + .zip(media.iter()) + .map(|(entry, input)| (entry.entry.as_str(), input.data.as_slice())), + ) + .collect(); + for (name, data) in entries { + writer + .start_file(name, options) + .map_err(|error| ProjectArchiveError::new(error.to_string()))?; + writer + .write_all(data) + .map_err(|error| ProjectArchiveError::new(error.to_string()))?; + } + writer + .finish() + .map_err(|error| ProjectArchiveError::new(error.to_string()))?; + } + Ok(cursor.into_inner()) +} + +pub fn parse_project_archive( + archive: &[u8], +) -> Result<ParsedProjectArchive, ProjectArchiveError> { + let mut zip = ZipArchive::new(Cursor::new(archive)) + .map_err(|_| ProjectArchiveError::new("Not a valid project archive (unreadable zip file)"))?; + + let mut files = BTreeMap::new(); + for index in 0..zip.len() { + let mut file = zip + .by_index(index) + .map_err(|error| ProjectArchiveError::new(error.to_string()))?; + let mut data = Vec::with_capacity(file.size() as usize); + file.read_to_end(&mut data) + .map_err(|error| ProjectArchiveError::new(error.to_string()))?; + files.insert(file.name().to_string(), data); + } + + let project_bytes = files + .get(PROJECT_JSON_ENTRY) + .ok_or_else(|| ProjectArchiveError::new("Not a valid project archive (project.json is missing)"))?; + let project: serde_json::Value = serde_json::from_slice(project_bytes).map_err(|_| { + ProjectArchiveError::new("Not a valid project archive (project.json is not valid JSON)") + })?; + let serde_json::Value::Object(project) = project else { + return Err(ProjectArchiveError::new( + "Not a valid project archive (project.json is not a project)", + )); + }; + + let media = parse_media_manifest(&files)?; + + Ok(ParsedProjectArchive { + project, + media, + files, + }) +} + +fn parse_media_manifest( + files: &BTreeMap<String, Vec<u8>>, +) -> Result<Vec<ArchivedMediaEntry>, ProjectArchiveError> { + let Some(manifest_bytes) = files.get(MEDIA_MANIFEST_ENTRY) else { + if files.keys().any(|name| name.starts_with(MEDIA_ENTRY_PREFIX)) { + return Err(ProjectArchiveError::new( + "Not a valid project archive (media manifest is missing)", + )); + } + return Ok(Vec::new()); + }; + + let parsed: serde_json::Value = serde_json::from_slice(manifest_bytes).map_err(|_| { + ProjectArchiveError::new("Not a valid project archive (media manifest is not valid JSON)") + })?; + let Some(media_values) = parsed.get("media").and_then(serde_json::Value::as_array) else { + return Err(ProjectArchiveError::new( + "Not a valid project archive (media manifest is malformed)", + )); + }; + + media_values + .iter() + .map(|value| parse_manifest_entry(value, files)) + .collect() +} + +fn parse_manifest_entry( + value: &serde_json::Value, + files: &BTreeMap<String, Vec<u8>>, +) -> Result<ArchivedMediaEntry, ProjectArchiveError> { + let invalid = |reason: &str| { + ProjectArchiveError::new(format!( + "Not a valid project archive (media manifest entry {reason})" + )) + }; + + let entry: ArchivedMediaEntry = + serde_json::from_value(value.clone()).map_err(|_| invalid("is malformed"))?; + let metadata = &entry.metadata; + + if metadata.id.is_empty() { + return Err(invalid("has no id")); + } + if !entry.entry.starts_with(MEDIA_ENTRY_PREFIX) || entry.entry.contains("..") { + return Err(invalid(&format!("({}) has an invalid file path", metadata.id))); + } + if !matches!(metadata.media_type.as_str(), "image" | "video" | "audio") { + return Err(invalid(&format!("({}) has an invalid media type", metadata.id))); + } + if metadata.file_type.is_empty() { + return Err(invalid(&format!("({}) has no file type", metadata.id))); + } + if !metadata.last_modified.is_finite() { + return Err(invalid(&format!( + "({}) has no last-modified timestamp", + metadata.id + ))); + } + if !files.contains_key(&entry.entry) { + return Err(ProjectArchiveError::new(format!( + "Not a valid project archive (media file {} is missing)", + entry.entry + ))); + } + + Ok(entry) +} diff --git a/rust/crates/transfer/src/lib.rs b/rust/crates/transfer/src/lib.rs new file mode 100644 index 00000000..a541e485 --- /dev/null +++ b/rust/crates/transfer/src/lib.rs @@ -0,0 +1,8 @@ +mod container; + +pub use container::{ + ArchivedMediaEntry, ArchivedMediaMetadata, ArchiveMediaInput, MEDIA_ENTRY_PREFIX, + MEDIA_MANIFEST_ENTRY, PROJECT_ARCHIVE_EXTENSION, PROJECT_JSON_ENTRY, ParsedProjectArchive, + ProjectArchiveError, build_media_entry_name, build_project_archive, parse_project_archive, + sanitize_file_name, +}; diff --git a/rust/crates/transfer/tests/container.rs b/rust/crates/transfer/tests/container.rs new file mode 100644 index 00000000..f05ef329 --- /dev/null +++ b/rust/crates/transfer/tests/container.rs @@ -0,0 +1,95 @@ +use std::io::Write as _; + +use transfer::*; + +fn sample_media() -> Vec<ArchiveMediaInput> { + vec![ + ArchiveMediaInput { + metadata: ArchivedMediaMetadata { + id: "asset-1".into(), + name: "clip one.mp4".into(), + media_type: "video".into(), + file_type: "video/mp4".into(), + last_modified: 1_753_000_000_000.0, + width: Some(1920.0), + height: Some(1080.0), + duration: Some(12.5), + ephemeral: None, + thumbnail_url: None, + }, + data: vec![1, 2, 3, 4], + }, + ArchiveMediaInput { + metadata: ArchivedMediaMetadata { + id: "asset-2".into(), + name: ".hidden".into(), + media_type: "audio".into(), + file_type: "audio/wav".into(), + last_modified: 1_753_000_000_001.0, + width: None, + height: None, + duration: None, + ephemeral: Some(true), + thumbnail_url: None, + }, + data: vec![5, 6, 7], + }, + ] +} + +#[test] +fn sanitize_matches_web_rules() { + assert_eq!(sanitize_file_name("clip one.mp4"), "clip_one.mp4"); + assert_eq!(sanitize_file_name(".."), "file"); + assert_eq!(sanitize_file_name("...weird..name.."), "weird..name.."); + assert_eq!(sanitize_file_name("a b"), "a_b"); + assert_eq!(sanitize_file_name(" "), "file"); + assert_eq!(sanitize_file_name("caf\u{00e9}.mov"), "caf_.mov"); +} + +#[test] +fn archive_round_trip() { + let project_json = r#"{"version":7,"metadata":{"id":"p1"}}"#; + let archive = build_project_archive(project_json, sample_media()).expect("build"); + let parsed = parse_project_archive(&archive).expect("parse"); + + assert_eq!( + parsed.project.get("version").and_then(|v| v.as_u64()), + Some(7) + ); + assert_eq!(parsed.media.len(), 2); + assert_eq!(parsed.media[0].entry, "media/asset-1-clip_one.mp4"); + assert_eq!(parsed.media[1].entry, "media/asset-2-hidden"); + assert_eq!( + parsed.read_media_data("media/asset-1-clip_one.mp4").unwrap(), + &[1, 2, 3, 4] + ); + assert_eq!(parsed.read_media_data("media/asset-2-hidden").unwrap(), &[5, 6, 7]); +} + +#[test] +fn archive_without_media_is_valid() { + let archive = build_project_archive("{}", Vec::new()).expect("build"); + let parsed = parse_project_archive(&archive).expect("parse"); + assert!(parsed.media.is_empty()); +} + +#[test] +fn rejects_garbage() { + let error = parse_project_archive(b"not a zip").unwrap_err(); + assert!(error.to_string().contains("unreadable zip file")); +} + +#[test] +fn rejects_missing_project_json() { + let mut cursor = std::io::Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut cursor); + let options: zip::write::SimpleFileOptions = Default::default(); + writer.start_file("other.txt", options).unwrap(); + writer.write_all(b"hi").unwrap(); + writer.finish().unwrap(); + } + let error = parse_project_archive(&cursor.into_inner()).unwrap_err(); + assert!(error.to_string().contains("project.json is missing")); +} diff --git a/rust/crates/transfer/tests/cross_compat.rs b/rust/crates/transfer/tests/cross_compat.rs new file mode 100644 index 00000000..02e53ac6 --- /dev/null +++ b/rust/crates/transfer/tests/cross_compat.rs @@ -0,0 +1,56 @@ +use transfer::*; + +#[test] +fn parses_web_built_archive() { + let archive = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/web-built.ocp")) + .expect("web-built fixture"); + let parsed = parse_project_archive(&archive).expect("parse web archive"); + + assert_eq!( + parsed + .project + .get("nested") + .and_then(|n| n.get("value")) + .and_then(|v| v.as_u64()), + Some(42) + ); + assert_eq!(parsed.media.len(), 1); + assert_eq!(parsed.media[0].entry, "media/web-asset-web_clip.mov"); + assert_eq!(parsed.media[0].metadata.media_type, "video"); + assert_eq!(parsed.media[0].metadata.width, Some(1280.0)); + assert_eq!( + parsed.read_media_data("media/web-asset-web_clip.mov").unwrap(), + &[9, 8, 7, 6] + ); +} + +#[test] +fn writes_rust_built_archive_for_web_parse() { + let archive = build_project_archive( + r#"{"version":7,"metadata":{"id":"rust-built"}}"#, + vec![ArchiveMediaInput { + metadata: ArchivedMediaMetadata { + id: "rust-asset".into(), + name: "rust audio.mp3".into(), + media_type: "audio".into(), + file_type: "audio/mpeg".into(), + last_modified: 1_753_000_000_000.0, + width: None, + height: None, + duration: Some(1.5), + ephemeral: None, + thumbnail_url: None, + }, + data: vec![4, 3, 2, 1], + }], + ) + .expect("build"); + let parsed = parse_project_archive(&archive).expect("re-parse"); + assert_eq!(parsed.media[0].entry, "media/rust-asset-rust_audio.mp3"); + assert_eq!( + parsed + .read_media_data("media/rust-asset-rust_audio.mp3") + .unwrap(), + &[4, 3, 2, 1] + ); +} diff --git a/rust/crates/transfer/tests/fixtures/web-built.ocp b/rust/crates/transfer/tests/fixtures/web-built.ocp Binary files differnew file mode 100644 index 00000000..473897de --- /dev/null +++ b/rust/crates/transfer/tests/fixtures/web-built.ocp |
