From d6134e51cb72df1fae0c389212d143ecc919e880 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 05:48:58 -0500 Subject: [PATCH] craft-aaf: decode relative media locator escapes (#1101) * craft-aaf: decode relative media locator escapes * Preserve literal percent escapes in relative paths --- crates/aaf/src/aaf/read.rs | 19 ++++++++++++++++++- crates/aaf/src/aaf/tests.rs | 9 +++++++++ crates/aaf/src/aaf/write.rs | 6 +++++- crates/timeline/src/path.rs | 17 ++++++++++++----- 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/crates/aaf/src/aaf/read.rs b/crates/aaf/src/aaf/read.rs index 753675ce1..4d20cf021 100644 --- a/crates/aaf/src/aaf/read.rs +++ b/crates/aaf/src/aaf/read.rs @@ -526,7 +526,7 @@ impl R<'_> { DataKind::Sound => CKind::Sound, _ => kind, }; - let path = d.objs(pid::LOCATOR).iter().find_map(|l| l.string(pid::URL_STRING)).map(|u| craft_timeline::path::file_url_to_path(&u)); + let path = d.objs(pid::LOCATOR).iter().find_map(|l| l.string(pid::URL_STRING)).map(|u| locator_path(&u)); let file_rate = rate_of(d, pid::SAMPLE_RATE).unwrap_or(rate); let length = d .i64(pid::FILE_LENGTH) @@ -721,3 +721,20 @@ fn markers_of(seg: &Obj, rate: (i64, i64)) -> Vec { fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } + +fn locator_path(url: &str) -> String { + if url.contains("://") { craft_timeline::path::file_url_to_path(url) } else { craft_timeline::path::decode_uri_path(url) } +} + +#[cfg(test)] +mod locator_tests { + use super::locator_path; + + #[test] + fn decodes_relative_locator_escapes_without_losing_remote_authorities() { + assert_eq!(locator_path("Audio%20Files/Di%C3%A1logo%20%231.wav"), "Audio Files/Diálogo #1.wav"); + assert_eq!(locator_path("file:///tmp/a%20b.wav"), "/tmp/a b.wav"); + assert_eq!(locator_path("file://server/share/a.wav"), "//server/share/a.wav"); + assert_eq!(locator_path("https://example.com/a%20b.wav"), "https://example.com/a%20b.wav"); + } +} diff --git a/crates/aaf/src/aaf/tests.rs b/crates/aaf/src/aaf/tests.rs index ca2196d3f..5ff6c3c7b 100644 --- a/crates/aaf/src/aaf/tests.rs +++ b/crates/aaf/src/aaf/tests.rs @@ -472,3 +472,12 @@ fn audio_tracks_keep_their_names() { assert_eq!(back.compositions[0].tracks[1].name, name); } } + +#[test] +fn relative_media_locator_round_trips_escaped_characters() { + let mut doc = document(); + doc.sources[0].path = Some("Audio Files/100%20 #1.wav".into()); + let bytes = written(&doc, false); + let got = read_clean(&bytes); + assert!(got.sources.iter().any(|s| s.path.as_deref() == Some("Audio Files/100%20 #1.wav"))); +} diff --git a/crates/aaf/src/aaf/write.rs b/crates/aaf/src/aaf/write.rs index 7ab51cea9..170b8e621 100644 --- a/crates/aaf/src/aaf/write.rs +++ b/crates/aaf/src/aaf/write.rs @@ -793,7 +793,11 @@ fn descriptor(w: &mut W, s: &Source) -> Obj { d.set(pid::FILE_LENGTH, Value::Data(len.to_le_bytes().to_vec())); d.set(pid::CONTAINER_FORMAT, weak(path::CONTAINER_DEFS, container)); if let Some(p) = &s.path { - let url = if craft_timeline::path::is_absolute(p) { craft_timeline::path::path_to_file_url(p, false) } else { p.clone() }; + let url = if craft_timeline::path::is_absolute(p) { + craft_timeline::path::path_to_file_url(p, false) + } else { + craft_timeline::path::encode_uri_path(p) + }; d.set(pid::LOCATOR, Value::StrongVec(vec![Obj::new(cls::NETWORK_LOCATOR).data_prop(pid::URL_STRING, utf16z(&url))])); } d diff --git a/crates/timeline/src/path.rs b/crates/timeline/src/path.rs index e16447303..6f262a2fa 100644 --- a/crates/timeline/src/path.rs +++ b/crates/timeline/src/path.rs @@ -33,7 +33,14 @@ fn percent_decode(s: &str) -> String { String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()) } -fn percent_encode_path(s: &str) -> String { +/// Decode percent escapes in a URI path, including a relative media locator. +/// Invalid escapes remain literal and invalid UTF-8 is replaced. +pub fn decode_uri_path(path: &str) -> String { + percent_decode(path) +} + +/// Percent-encode a URI path while preserving path separators. +pub fn encode_uri_path(s: &str) -> String { let mut out = String::with_capacity(s.len()); for &c in s.as_bytes() { if c.is_ascii_alphanumeric() || b"/-._~!$&'()*+,;=:@".contains(&c) { @@ -82,11 +89,11 @@ pub fn path_to_file_url(path: &str, localhost: bool) -> String { }; let host = if localhost { "localhost" } else { "" }; if is_windows_abs(&p) { - format!("file://{host}/{}", percent_encode_path(&p)) + format!("file://{host}/{}", encode_uri_path(&p)) } else if let Some(unc) = p.strip_prefix("//") { - format!("file://{}", percent_encode_path(unc)) + format!("file://{}", encode_uri_path(unc)) } else { - format!("file://{host}{}", percent_encode_path(&p)) + format!("file://{host}{}", encode_uri_path(&p)) } } @@ -164,7 +171,7 @@ pub fn resolve_url(reference: &str, base_dir: Option<&str>) -> String { /// A relative path written as a URL (the inverse of [`resolve_url`]'s decoding). pub fn relative_url(path: &str) -> String { - percent_encode_path(path) + encode_uri_path(path) } /// Resolve a media reference from a document: file URLs become paths, relative paths are joined to