Coverage Report

Created: 2026-09-05 23:00

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
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
}