super-stt-daemon/src/registry/carry_over.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | //! Which downloaded model files survive a backend directory being replaced. |
3 | | //! |
4 | | //! Replacing a backend directory used to take its `models/` subtree with it, |
5 | | //! so an update discarded gigabytes of weights that immediately re-downloaded. |
6 | | //! A file is still valid when both manifests declare the same `destination` |
7 | | //! with the same `url`: a destination the new manifest no longer declares is a |
8 | | //! deleted file, and a changed `url` at the same destination is a changed one. |
9 | | //! |
10 | | //! `sha256` is deliberately not part of the predicate. `download::usable_existing` |
11 | | //! re-verifies every carried file against the new manifest's hash at provision |
12 | | //! time and re-downloads on mismatch, so a stale hash cannot survive; checking |
13 | | //! it here would only duplicate that. |
14 | | |
15 | | use std::collections::HashMap; |
16 | | use std::path::Path; |
17 | | |
18 | | use super_stt_registry_types::manifest::Manifest; |
19 | | use tokio::fs; |
20 | | |
21 | | /// Map every declared file destination to its download URL. |
22 | | /// |
23 | | /// `Manifest::parse` does not enforce destination uniqueness across models, |
24 | | /// so the same destination can legally appear more than once. When it does |
25 | | /// with conflicting URLs, the destination maps to `None`: we cannot tell |
26 | | /// which URL a file on disk (if any) actually came from, so treating it as |
27 | | /// unrecognized forces a re-download rather than risking the wrong file |
28 | | /// being carried over silently. The same destination declared twice with the |
29 | | /// *same* URL is not a conflict and still maps to `Some(url)`. |
30 | 56 | fn declared(m: &Manifest) -> HashMap<&str, Option<&str>> { |
31 | 56 | let mut out: HashMap<&str, Option<&str>> = HashMap::new(); |
32 | 62 | for model in &m.models56 { |
33 | 62 | for f54 in &model.files { |
34 | 54 | let dest = f.destination.as_str(); |
35 | 54 | let url = f.url.as_str(); |
36 | 54 | out.entry(dest) |
37 | 54 | .and_modify(|existing| {6 |
38 | 6 | if *existing != Some(url) { |
39 | 2 | *existing = None; |
40 | 4 | } |
41 | 6 | }) |
42 | 54 | .or_insert(Some(url)); |
43 | | } |
44 | | } |
45 | 56 | out |
46 | 56 | } |
47 | | |
48 | | /// Destinations declared unambiguously by both manifests with the same URL, |
49 | | /// sorted so the result is stable for logging and tests. |
50 | | /// |
51 | | /// A destination whose declaration is ambiguous in either manifest (see |
52 | | /// [`declared`]) never survives, even if one of its conflicting URLs happens |
53 | | /// to match. |
54 | | /// |
55 | | /// Every destination is validated as a safe relative path by `Manifest::parse`, |
56 | | /// so callers may join these onto a directory without escaping it — `carry` |
57 | | /// re-validates anyway, since it is not limited to receiving this function's |
58 | | /// output. |
59 | | #[must_use] |
60 | 28 | pub fn survivors(old: &Manifest, new: &Manifest) -> Vec<String> { |
61 | 28 | let old_files = declared(old); |
62 | 28 | let mut out: Vec<String> = declared(new) |
63 | 28 | .into_iter() |
64 | 28 | .filter_map(|(dest, url)| {24 |
65 | 24 | let url = url?0 ; |
66 | 24 | (old_files.get(dest) == Some(&Some(url))).then(|| dest16 .to_string16 ()) |
67 | 24 | }) |
68 | 28 | .collect(); |
69 | 28 | out.sort(); |
70 | 28 | out |
71 | 28 | } |
72 | | |
73 | | /// Move each of `destinations` from `from_dir` into `to_dir`, returning the |
74 | | /// total bytes moved. |
75 | | /// |
76 | | /// Each destination must be a safe relative path: it is joined onto both |
77 | | /// `from_dir` and `to_dir` below, so a `..` component would let it climb out |
78 | | /// of either. `survivors` only ever returns manifest-declared destinations, |
79 | | /// which `Manifest::parse` already validates this way, but `carry` is not |
80 | | /// limited to that input, so it re-checks every destination itself before |
81 | | /// touching the filesystem. |
82 | | /// |
83 | | /// A destination missing under `from_dir` is skipped, and one that already |
84 | | /// exists under `to_dir` is left alone — the staged copy is the newer one. |
85 | | /// Both directories live under the backends directory, so these are |
86 | | /// same-filesystem renames: constant-time, with no multi-gigabyte copy. |
87 | | /// |
88 | | /// Destinations are moved one at a time with no rollback. If an error occurs |
89 | | /// partway through — a permissions failure, a full disk — the destinations |
90 | | /// already processed stay moved; the caller must not assume `from_dir` is |
91 | | /// still intact after an `Err`. This is safe for `install`'s caller |
92 | | /// (`preserve_models`) because a file this leaves behind under `from_dir` is |
93 | | /// simply re-downloaded the next time it is provisioned |
94 | | /// (`stt_models::download::usable_existing` re-verifies every file's hash |
95 | | /// before trusting it), so the end state is always correct content — it is |
96 | | /// only ever less carry-over than intended, never wrong bytes served. See |
97 | | /// `preserve_models`'s doc comment for the one place that safety net does not |
98 | | /// fully cover: a same-version retry after a partial failure. |
99 | | /// |
100 | | /// # Errors |
101 | | /// Returns an `io::Error` if a destination is not a safe relative path, a |
102 | | /// directory cannot be created, or a rename fails. The error is wrapped with |
103 | | /// the source and destination paths so a failure log line names which of |
104 | | /// potentially dozens of model files it was. |
105 | 22 | pub async fn carry( |
106 | 22 | from_dir: &Path, |
107 | 22 | to_dir: &Path, |
108 | 22 | destinations: &[String], |
109 | 22 | ) -> std::io::Result<u64> { |
110 | | // Joined onto both directories below, so none may climb out of either. |
111 | | // The manifest parser guards `[[models.files]].destination` this way; |
112 | | // `carry` reaches the same join and gets the same guard, checked for |
113 | | // every destination up front so a bad entry mutates nothing. |
114 | 22 | for dest20 in destinations { |
115 | 20 | if !super_stt_registry_types::is_safe_relative_path(dest) { |
116 | 2 | return Err(std::io::Error::new( |
117 | 2 | std::io::ErrorKind::InvalidData, |
118 | 2 | format!("refusing to carry over unsafe destination: {dest}"), |
119 | 2 | )); |
120 | 18 | } |
121 | | } |
122 | 20 | let mut moved = 0u64; |
123 | 20 | for dest18 in destinations { |
124 | 18 | let src = from_dir.join(dest); |
125 | 18 | let dst = to_dir.join(dest); |
126 | 18 | let Ok(meta16 ) = fs::metadata(&src).await else { |
127 | 2 | continue; |
128 | | }; |
129 | 16 | if fs::metadata(&dst).await.is_ok() { |
130 | 2 | continue; |
131 | 14 | } |
132 | 14 | if let Some(parent) = dst.parent() { |
133 | 14 | fs::create_dir_all(parent).await.map_err(|e| {2 |
134 | 2 | std::io::Error::new( |
135 | 2 | e.kind(), |
136 | 2 | format!( |
137 | | "carry_over: creating parent of `{}` ({}): {e}", |
138 | 2 | dst.display(), |
139 | 2 | parent.display() |
140 | | ), |
141 | | ) |
142 | 2 | })?; |
143 | 0 | } |
144 | 12 | fs::rename(&src, &dst).await.map_err(|e| {0 |
145 | 0 | std::io::Error::new( |
146 | 0 | e.kind(), |
147 | 0 | format!( |
148 | | "carry_over: renaming `{}` to `{}`: {e}", |
149 | 0 | src.display(), |
150 | 0 | dst.display() |
151 | | ), |
152 | | ) |
153 | 0 | })?; |
154 | 12 | moved += meta.len(); |
155 | | } |
156 | 18 | Ok(moved) |
157 | 22 | } |
158 | | |
159 | | #[cfg(test)] |
160 | | mod tests { |
161 | | use super::{carry, survivors}; |
162 | | use super_stt_registry_types::manifest::Manifest; |
163 | | |
164 | 12 | fn manifest_with(files: &[(&str, &str)]) -> Manifest { |
165 | 12 | manifest_with_models(&[files]) |
166 | 12 | } |
167 | | |
168 | | /// Like `manifest_with`, but emits one `[[models]]` block per entry in |
169 | | /// `models`, so a destination can be declared more than once across |
170 | | /// separate models (to exercise the conflicting-URL case). |
171 | 20 | fn manifest_with_models(models: &[&[(&str, &str)]]) -> Manifest { |
172 | 20 | let blocks = models |
173 | 20 | .iter() |
174 | 20 | .enumerate() |
175 | 26 | .map20 (|(i, files)| { |
176 | 26 | let entries = files |
177 | 26 | .iter() |
178 | 34 | .map26 (|(url, dest)| format!("{{ url = \"{url}\", destination = \"{dest}\" }}")) |
179 | 26 | .collect::<Vec<_>>() |
180 | 26 | .join(",\n "); |
181 | 26 | format!( |
182 | | r#" |
183 | | [[models]] |
184 | | name = "m{i}" |
185 | | primary_language = "en" |
186 | | supported_languages = ["en"] |
187 | | supported_devices = ["cpu"] |
188 | | files = [ |
189 | | {entries} |
190 | | ] |
191 | | "# |
192 | | ) |
193 | 26 | }) |
194 | 20 | .collect::<Vec<_>>() |
195 | 20 | .join("\n"); |
196 | 20 | let text = format!( |
197 | | r#" |
198 | | [backend] |
199 | | source = "github.com/x/y" |
200 | | name = "Y" |
201 | | version = "1.0.0" |
202 | | kind = "subprocess" |
203 | | entrypoint = "y" |
204 | | contract = "v1" |
205 | | license = "Apache-2.0" |
206 | | description = "Test backend." |
207 | | |
208 | | [[assets.subprocess]] |
209 | | file = "y.tar.gz" |
210 | | target = "x86_64-unknown-linux-gnu" |
211 | | accel = ["cpu"] |
212 | | {blocks} |
213 | | "# |
214 | | ); |
215 | 20 | Manifest::parse(&text).expect("fixture manifest parses") |
216 | 20 | } |
217 | | |
218 | | #[test] |
219 | 2 | fn an_unchanged_url_at_the_same_destination_survives() { |
220 | 2 | let old = manifest_with(&[("https://h/a.bin", "models/m/a.bin")]); |
221 | 2 | let new = manifest_with(&[("https://h/a.bin", "models/m/a.bin")]); |
222 | 2 | assert_eq!(survivors(&old, &new), vec!["models/m/a.bin".to_string()]); |
223 | 2 | } |
224 | | |
225 | | #[test] |
226 | 2 | fn a_changed_url_does_not_survive() { |
227 | 2 | let old = manifest_with(&[("https://h/a.bin", "models/m/a.bin")]); |
228 | 2 | let new = manifest_with(&[("https://h/a-v2.bin", "models/m/a.bin")]); |
229 | 2 | assert!(survivors(&old, &new).is_empty()); |
230 | 2 | } |
231 | | |
232 | | #[test] |
233 | 2 | fn a_destination_the_new_manifest_drops_does_not_survive() { |
234 | 2 | let old = manifest_with(&[("https://h/a.bin", "models/m/a.bin")]); |
235 | 2 | let new = manifest_with(&[("https://h/b.bin", "models/m/b.bin")]); |
236 | 2 | assert!(survivors(&old, &new).is_empty()); |
237 | 2 | } |
238 | | |
239 | | #[test] |
240 | 2 | fn a_destination_declared_twice_with_conflicting_urls_does_not_survive() { |
241 | | // The old manifest ambiguously declares "shared.bin" across two |
242 | | // models with different URLs, and unambiguously declares "z.bin"; |
243 | | // the new manifest declares both destinations unambiguously, |
244 | | // "shared.bin" matching one of the two old URLs. The ambiguous old |
245 | | // declaration must still block "shared.bin", while "z.bin" — an |
246 | | // ordinary, unambiguous match — survives. |
247 | 2 | let old = manifest_with_models(&[ |
248 | 2 | &[ |
249 | 2 | ("https://h/shared-v1.bin", "models/m/shared.bin"), |
250 | 2 | ("https://h/z.bin", "models/m/z.bin"), |
251 | 2 | ], |
252 | 2 | &[("https://h/shared-v2.bin", "models/m/shared.bin")], |
253 | 2 | ]); |
254 | 2 | let new = manifest_with_models(&[&[ |
255 | 2 | ("https://h/shared-v1.bin", "models/m/shared.bin"), |
256 | 2 | ("https://h/z.bin", "models/m/z.bin"), |
257 | 2 | ]]); |
258 | 2 | assert_eq!( |
259 | 2 | survivors(&old, &new), |
260 | 2 | vec!["models/m/z.bin".to_string()], |
261 | | "shared.bin's old declaration is ambiguous, so it cannot survive" |
262 | | ); |
263 | 2 | } |
264 | | |
265 | | #[test] |
266 | 2 | fn a_destination_declared_twice_with_the_same_url_is_not_a_conflict() { |
267 | 2 | let files: &[(&str, &str)] = &[ |
268 | 2 | ("https://h/z.bin", "models/m/z.bin"), |
269 | 2 | ("https://h/a.bin", "models/m/a.bin"), |
270 | 2 | ]; |
271 | | // "a.bin" and "z.bin" are each declared identically by two separate |
272 | | // models in both manifests; the repeat must not read as a conflict. |
273 | 2 | let repeat: &[(&str, &str)] = &[("https://h/a.bin", "models/m/a.bin")]; |
274 | 2 | let old = manifest_with_models(&[files, repeat]); |
275 | 2 | let new = manifest_with_models(&[files, repeat]); |
276 | 2 | assert_eq!( |
277 | 2 | survivors(&old, &new), |
278 | 2 | vec!["models/m/a.bin".to_string(), "models/m/z.bin".to_string()], |
279 | | "both destinations survive, sorted, pinning `survivors`' sort" |
280 | | ); |
281 | 2 | } |
282 | | |
283 | | #[tokio::test] |
284 | 2 | async fn carry_refuses_a_traversing_destination() { |
285 | 2 | let from = tempfile::tempdir().unwrap(); |
286 | 2 | let to = tempfile::tempdir().unwrap(); |
287 | 2 | let from_nested = from.path().join("nested"); |
288 | 2 | std::fs::create_dir_all(&from_nested).unwrap(); |
289 | | // If the guard were missing, `from_nested.join("../escaped.bin")` |
290 | | // would resolve to this path — still inside our sandbox, so we can |
291 | | // assert it was never touched. |
292 | 2 | std::fs::write(from.path().join("escaped.bin"), b"secret").unwrap(); |
293 | | |
294 | 2 | let err = carry(&from_nested, to.path(), &["../escaped.bin".to_string()]) |
295 | 2 | .await |
296 | 2 | .expect_err("a traversing destination must be refused, not joined"); |
297 | | |
298 | 2 | assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); |
299 | 2 | assert!( |
300 | 2 | from.path().join("escaped.bin").exists(), |
301 | | "the file outside `from_nested` must be untouched, not moved" |
302 | | ); |
303 | 2 | assert!(!to.path().join("escaped.bin").exists()); |
304 | 2 | } |
305 | | |
306 | | #[tokio::test] |
307 | 2 | async fn carry_moves_only_what_is_present_and_absent_at_the_destination() { |
308 | 2 | let from = tempfile::tempdir().unwrap(); |
309 | 2 | let to = tempfile::tempdir().unwrap(); |
310 | | |
311 | 2 | std::fs::create_dir_all(from.path().join("models/m")).unwrap(); |
312 | 2 | std::fs::write(from.path().join("models/m/a.bin"), b"aaaa").unwrap(); |
313 | 2 | std::fs::write(from.path().join("models/m/b.bin"), b"bb").unwrap(); |
314 | | // Already staged: must not be overwritten by the older copy. |
315 | 2 | std::fs::create_dir_all(to.path().join("models/m")).unwrap(); |
316 | 2 | std::fs::write(to.path().join("models/m/b.bin"), b"NEW").unwrap(); |
317 | | |
318 | 2 | let moved = carry( |
319 | 2 | from.path(), |
320 | 2 | to.path(), |
321 | 2 | &[ |
322 | 2 | "models/m/a.bin".to_string(), |
323 | 2 | "models/m/b.bin".to_string(), |
324 | 2 | "models/m/missing.bin".to_string(), |
325 | 2 | ], |
326 | 2 | ) |
327 | 2 | .await |
328 | 2 | .expect("carry succeeds"); |
329 | | |
330 | 2 | assert_eq!(moved, 4, "only a.bin's bytes are counted"); |
331 | 2 | assert_eq!( |
332 | 2 | std::fs::read(to.path().join("models/m/a.bin")).unwrap(), |
333 | | b"aaaa" |
334 | | ); |
335 | 2 | assert_eq!( |
336 | 2 | std::fs::read(to.path().join("models/m/b.bin")).unwrap(), |
337 | | b"NEW", |
338 | | "an existing staged file wins" |
339 | | ); |
340 | 2 | assert!( |
341 | 2 | !from.path().join("models/m/a.bin").exists(), |
342 | 2 | "moved, not copied" |
343 | 2 | ); |
344 | 2 | } |
345 | | |
346 | | /// `carry` has no rollback: a failure partway through must leave earlier |
347 | | /// destinations moved and later ones untouched, never straddling both |
348 | | /// directories or silently losing bytes. Pins the documented |
349 | | /// no-rollback behaviour (see `carry`'s doc comment) so it cannot erode |
350 | | /// silently. |
351 | | /// |
352 | | /// The failure is triggered portably, without platform-specific |
353 | | /// permission tricks: the second destination's parent directory |
354 | | /// (`sub/`) already exists under `to_dir` as a plain *file*, so |
355 | | /// `create_dir_all` cannot turn it into a directory. |
356 | | #[tokio::test] |
357 | 2 | async fn a_partial_failure_leaves_earlier_moves_in_place_and_later_ones_untouched() { |
358 | 2 | let from = tempfile::tempdir().unwrap(); |
359 | 2 | let to = tempfile::tempdir().unwrap(); |
360 | | |
361 | 2 | std::fs::write(from.path().join("a.bin"), b"aaaa").unwrap(); |
362 | 2 | std::fs::create_dir_all(from.path().join("sub")).unwrap(); |
363 | 2 | std::fs::write(from.path().join("sub/b.bin"), b"bb").unwrap(); |
364 | | // Blocks `create_dir_all(to_dir.join("sub"))` for the second |
365 | | // destination: "sub" already exists under `to_dir`, but as a file. |
366 | 2 | std::fs::write(to.path().join("sub"), b"blocking file").unwrap(); |
367 | | |
368 | 2 | let err = carry( |
369 | 2 | from.path(), |
370 | 2 | to.path(), |
371 | 2 | &["a.bin".to_string(), "sub/b.bin".to_string()], |
372 | 2 | ) |
373 | 2 | .await |
374 | 2 | .expect_err("the second destination's parent cannot be created"); |
375 | 2 | assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists); |
376 | | |
377 | 2 | assert!( |
378 | 2 | !from.path().join("a.bin").exists(), |
379 | | "the first destination genuinely moved out of from_dir" |
380 | | ); |
381 | 2 | assert_eq!( |
382 | 2 | std::fs::read(to.path().join("a.bin")).unwrap(), |
383 | | b"aaaa", |
384 | | "the first destination genuinely moved into to_dir" |
385 | | ); |
386 | 2 | assert!( |
387 | 2 | from.path().join("sub/b.bin").exists(), |
388 | | "the second destination is untouched by the failed move" |
389 | | ); |
390 | 2 | assert_eq!( |
391 | 2 | std::fs::read(to.path().join("sub")).unwrap(), |
392 | 2 | b"blocking file", |
393 | 2 | "the blocking file itself must be untouched" |
394 | 2 | ); |
395 | 2 | } |
396 | | } |