|
| 1 | +//! Resolve relative asset paths against the scenario file that names them. |
| 2 | +//! |
| 3 | +//! `"src": "assets/logo.png"` used to resolve against the *process* working |
| 4 | +//! directory, so the same scenario rendered from its own folder and failed from |
| 5 | +//! anywhere else — including the studio, which runs from the repository root. |
| 6 | +//! `include` had always resolved relative to the including file; two path-like |
| 7 | +//! fields in one document following two different rules is the trap. |
| 8 | +//! |
| 9 | +//! The rewrite happens on the raw JSON, before deserialisation, so no component |
| 10 | +//! needs to know about it: by the time an `image` or an `audio` track is |
| 11 | +//! constructed its `src` is already absolute. |
| 12 | +
|
| 13 | +use std::path::Path; |
| 14 | + |
| 15 | +use serde_json::Value; |
| 16 | + |
| 17 | +/// Keys whose string value names a file on disk. |
| 18 | +/// |
| 19 | +/// `src` covers `image`, `video`, `gif`, `avatar` (and each entry of an |
| 20 | +/// `avatar_group`), `mockup`, `lottie` and `audio`; `track` is the audio-source |
| 21 | +/// reference on `waveform`/`audio_spectrum` and in `style.audio-reactive`, |
| 22 | +/// which must name the same string the audio track does or the analysis lookup |
| 23 | +/// misses. |
| 24 | +const PATH_KEYS: &[&str] = &["src", "track"]; |
| 25 | + |
| 26 | +fn is_remote(s: &str) -> bool { |
| 27 | + s.starts_with("http://") || s.starts_with("https://") || s.starts_with("data:") |
| 28 | +} |
| 29 | + |
| 30 | +/// Rewrite every relative asset path in `value` to an absolute one, resolved |
| 31 | +/// against `base_dir`. |
| 32 | +/// |
| 33 | +/// Deliberately conservative: a path is rewritten **only** when the file exists |
| 34 | +/// next to the scenario. Anything else is left exactly as written, so a path |
| 35 | +/// that used to resolve against the working directory still does, and a genuine |
| 36 | +/// typo still reaches the validator with the author's own spelling in the |
| 37 | +/// message rather than a rewritten one they never typed. |
| 38 | +pub fn rebase_relative_paths(value: &mut Value, base_dir: &Path) { |
| 39 | + match value { |
| 40 | + Value::Object(map) => { |
| 41 | + for (key, child) in map.iter_mut() { |
| 42 | + if PATH_KEYS.contains(&key.as_str()) { |
| 43 | + if let Value::String(s) = child { |
| 44 | + if let Some(abs) = rebased(s, base_dir) { |
| 45 | + *s = abs; |
| 46 | + continue; |
| 47 | + } |
| 48 | + } |
| 49 | + } |
| 50 | + rebase_relative_paths(child, base_dir); |
| 51 | + } |
| 52 | + } |
| 53 | + Value::Array(items) => { |
| 54 | + for item in items { |
| 55 | + rebase_relative_paths(item, base_dir); |
| 56 | + } |
| 57 | + } |
| 58 | + _ => {} |
| 59 | + } |
| 60 | +} |
| 61 | + |
| 62 | +/// `Some(absolute)` when `src` is relative and names an existing file under |
| 63 | +/// `base_dir`; `None` when it must be left alone. |
| 64 | +fn rebased(src: &str, base_dir: &Path) -> Option<String> { |
| 65 | + if src.is_empty() || is_remote(src) { |
| 66 | + return None; |
| 67 | + } |
| 68 | + let path = Path::new(src); |
| 69 | + if path.is_absolute() { |
| 70 | + return None; |
| 71 | + } |
| 72 | + let candidate = base_dir.join(path); |
| 73 | + if !candidate.is_file() { |
| 74 | + return None; |
| 75 | + } |
| 76 | + // `canonicalize` resolves `..` and symlinks so two spellings of the same |
| 77 | + // file share one cache key — the audio analysis and the GIF/image caches |
| 78 | + // are keyed by this string. |
| 79 | + let resolved = std::fs::canonicalize(&candidate).unwrap_or(candidate); |
| 80 | + Some(resolved.to_str()?.to_string()) |
| 81 | +} |
| 82 | + |
| 83 | +#[cfg(test)] |
| 84 | +mod tests { |
| 85 | + use super::*; |
| 86 | + use serde_json::json; |
| 87 | + |
| 88 | + fn scratch() -> std::path::PathBuf { |
| 89 | + let dir = std::env::temp_dir().join(format!( |
| 90 | + "rustmotion_assets_{}_{:?}", |
| 91 | + std::process::id(), |
| 92 | + std::thread::current().id() |
| 93 | + )); |
| 94 | + std::fs::create_dir_all(dir.join("assets")).expect("scratch dir"); |
| 95 | + dir |
| 96 | + } |
| 97 | + |
| 98 | + #[test] |
| 99 | + fn a_relative_src_next_to_the_scenario_becomes_absolute() { |
| 100 | + let dir = scratch(); |
| 101 | + std::fs::write(dir.join("assets/logo.png"), b"x").expect("fixture"); |
| 102 | + |
| 103 | + let mut v = |
| 104 | + json!({"scenes": [{"children": [{"type": "image", "src": "assets/logo.png"}]}]}); |
| 105 | + rebase_relative_paths(&mut v, &dir); |
| 106 | + |
| 107 | + let got = v["scenes"][0]["children"][0]["src"].as_str().expect("src"); |
| 108 | + assert!(Path::new(got).is_absolute(), "not rewritten: {got}"); |
| 109 | + assert!(Path::new(got).is_file(), "rewritten to a non-file: {got}"); |
| 110 | + std::fs::remove_dir_all(&dir).ok(); |
| 111 | + } |
| 112 | + |
| 113 | + /// The whole point: the rewrite must not depend on where the process runs. |
| 114 | + #[test] |
| 115 | + fn the_result_does_not_depend_on_the_working_directory() { |
| 116 | + let dir = scratch(); |
| 117 | + std::fs::write(dir.join("assets/logo.png"), b"x").expect("fixture"); |
| 118 | + |
| 119 | + let mut a = json!({"src": "assets/logo.png"}); |
| 120 | + let mut b = json!({"src": "assets/logo.png"}); |
| 121 | + rebase_relative_paths(&mut a, &dir); |
| 122 | + rebase_relative_paths(&mut b, &dir); |
| 123 | + assert_eq!(a, b); |
| 124 | + assert_ne!(a["src"], json!("assets/logo.png")); |
| 125 | + std::fs::remove_dir_all(&dir).ok(); |
| 126 | + } |
| 127 | + |
| 128 | + /// A path that does not exist beside the scenario keeps the author's own |
| 129 | + /// spelling, so the validator's message names what they typed. |
| 130 | + #[test] |
| 131 | + fn a_missing_file_is_left_untouched() { |
| 132 | + let dir = scratch(); |
| 133 | + let mut v = json!({"src": "assets/absent.png"}); |
| 134 | + rebase_relative_paths(&mut v, &dir); |
| 135 | + assert_eq!(v["src"], json!("assets/absent.png")); |
| 136 | + std::fs::remove_dir_all(&dir).ok(); |
| 137 | + } |
| 138 | + |
| 139 | + #[test] |
| 140 | + fn absolute_and_remote_sources_are_left_untouched() { |
| 141 | + let dir = scratch(); |
| 142 | + let mut v = json!({ |
| 143 | + "a": {"src": "/etc/hosts"}, |
| 144 | + "b": {"src": "https://example.com/x.png"}, |
| 145 | + "c": {"src": "data:image/png;base64,AAAA"} |
| 146 | + }); |
| 147 | + let before = v.clone(); |
| 148 | + rebase_relative_paths(&mut v, &dir); |
| 149 | + assert_eq!(v, before); |
| 150 | + std::fs::remove_dir_all(&dir).ok(); |
| 151 | + } |
| 152 | + |
| 153 | + /// `track` must be rewritten the same way as `src`: the audio analysis is |
| 154 | + /// cached under the track's `src`, and a `waveform` finds it by `track`. |
| 155 | + /// Rewriting one and not the other would make every lookup miss. |
| 156 | + #[test] |
| 157 | + fn track_is_rebased_like_src_so_the_analysis_lookup_still_matches() { |
| 158 | + let dir = scratch(); |
| 159 | + std::fs::write(dir.join("assets/t.wav"), b"x").expect("fixture"); |
| 160 | + |
| 161 | + let mut v = json!({ |
| 162 | + "audio": [{"src": "assets/t.wav"}], |
| 163 | + "scenes": [{"children": [{"type": "waveform", "track": "assets/t.wav"}]}] |
| 164 | + }); |
| 165 | + rebase_relative_paths(&mut v, &dir); |
| 166 | + |
| 167 | + assert_eq!( |
| 168 | + v["audio"][0]["src"], v["scenes"][0]["children"][0]["track"], |
| 169 | + "src and track must resolve to the same string" |
| 170 | + ); |
| 171 | + std::fs::remove_dir_all(&dir).ok(); |
| 172 | + } |
| 173 | + |
| 174 | + /// Keys that merely *contain* a path-like string are not touched — only the |
| 175 | + /// documented asset fields are. |
| 176 | + #[test] |
| 177 | + fn unrelated_keys_are_not_rewritten() { |
| 178 | + let dir = scratch(); |
| 179 | + std::fs::write(dir.join("assets/logo.png"), b"x").expect("fixture"); |
| 180 | + let mut v = json!({"content": "assets/logo.png", "title": "assets/logo.png"}); |
| 181 | + let before = v.clone(); |
| 182 | + rebase_relative_paths(&mut v, &dir); |
| 183 | + assert_eq!(v, before); |
| 184 | + std::fs::remove_dir_all(&dir).ok(); |
| 185 | + } |
| 186 | +} |
0 commit comments