summaryrefslogtreecommitdiff
path: root/rust/crates/transfer
diff options
context:
space:
mode:
Diffstat (limited to 'rust/crates/transfer')
-rw-r--r--rust/crates/transfer/Cargo.toml10
-rw-r--r--rust/crates/transfer/src/container.rs277
-rw-r--r--rust/crates/transfer/src/lib.rs8
-rw-r--r--rust/crates/transfer/tests/container.rs95
-rw-r--r--rust/crates/transfer/tests/cross_compat.rs56
-rw-r--r--rust/crates/transfer/tests/fixtures/web-built.ocpbin0 -> 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
new file mode 100644
index 00000000..473897de
--- /dev/null
+++ b/rust/crates/transfer/tests/fixtures/web-built.ocp
Binary files differ