Skip to main content

microvms_app/
workspace.rs

1// SPDX-License-Identifier: Apache-2.0
2//! A project directory moved between a host and a VM: the manifest, the diff, one incremental
3//! sync pass, and the guarded download of a remote tree (#260).
4//!
5//! # The daemon extracts uploads; the client extracts only what the caller chose
6//!
7//! The trust boundary is asymmetric and the code follows it. On the way *in*, the archive is
8//! built on the host from a tree the caller owns, and the daemon, whose openat2 confinement is
9//! the extraction surface this workspace hardened (`crates/agentd/src/fs.rs`), unpacks it. On
10//! the way *out*, the archive describes the VM's filesystem, and the VM is where untrusted work
11//! runs, so [`LocalTree::extract`] writes only the members the caller's globs selected, only
12//! when they're regular files, never under `.git`, and never outside the destination. A
13//! workload that appends `../../.ssh/authorized_keys`, a symlink or `.git/hooks/pre-commit` to
14//! the archive gets it skipped rather than written.
15//!
16//! The same holds for the guest's manifest: it's read from the VM, so a deletion it orders is
17//! executed only when [`deletable`] finds a plain relative path in it.
18//!
19//! # What lives here and what lives in the edges
20//!
21//! This module is the rules: which directories never travel, the daemon's budgets, what a
22//! manifest is, what a diff between two manifests orders, which deletion paths are safe, and
23//! the sync pass that composes them over a [`Session`]. It reads no file (ARCH-7). The walk,
24//! the hashing, the tar packing and the extraction are [`LocalTree`]'s, the port
25//! `microvms-edges` implements over the real filesystem, so a test can hand the pass a tree of
26//! its own.
27
28use std::collections::{BTreeMap, BTreeSet};
29use std::path::Path;
30use std::time::Duration;
31
32use crate::error::{Error, ErrorKind, WireKind};
33use crate::session::Session;
34
35/// Directory names never packed or hashed, whole.
36///
37/// `.git`, `target`, `node_modules` and `.venv`: a repository's object store and a build tree
38/// blow through the daemon's budgets while contributing nothing to a build. `.git` also
39/// protects the *extraction* side, see [`LocalTree::extract`], so removing it here without
40/// reading that contract would reopen a hole, not just widen an upload.
41pub const SKIPPED_DIRS: [&str; 4] = [".git", "target", "node_modules", ".venv"];
42
43/// The pack's byte budget: the daemon's `max_body_bytes` (512 MiB), checked on file sizes
44/// during the walk, so an over-budget tree is refused before its archive is allocated.
45pub const MAX_PACK_BYTES: u64 = 512 * 1024 * 1024;
46
47/// The pack's member budget: the daemon's `max_tar_members`.
48pub const MAX_PACK_MEMBERS: usize = 100_000;
49
50/// Whether a walk's running byte total is still within [`MAX_PACK_BYTES`]: at the budget is
51/// within it, one byte past isn't.
52pub fn within_byte_budget(total: u64) -> bool {
53    total <= MAX_PACK_BYTES
54}
55
56/// Whether a walk's running member count is still within [`MAX_PACK_MEMBERS`].
57pub fn within_member_budget(count: usize) -> bool {
58    count <= MAX_PACK_MEMBERS
59}
60
61/// Where a synced tree lands in the guest, and the working directory of what runs in it.
62///
63/// The daemon's `write_tar` creates the root it's given. A constant rather than a parameter:
64/// two callers against one VM must not disagree about where "the project" is.
65pub const REMOTE_WORKDIR: &str = "/workspace";
66
67/// Where the incremental manifest lives in the guest: inside the workspace, deliberately.
68///
69/// The manifest is a cache of what the last sync put in the workspace, and a cache must die
70/// with the thing it describes. A workload that wipes the workspace wipes the manifest too, so
71/// the next sync sees none and uploads everything instead of trusting a description of files
72/// that are gone.
73pub const MANIFEST_PATH: &str = "/workspace/.microvm-sync-manifest.json";
74
75/// The manifest's member name relative to the workspace. It never travels and never appears
76/// in a [`Delta`], so the manifest can't order its own deletion.
77pub const MANIFEST_NAME: &str = ".microvm-sync-manifest.json";
78
79/// The manifest format this build writes.
80pub const MANIFEST_VERSION: u32 = 1;
81
82/// The in-guest `rm`'s `timeout_sec` when a caller names none: `microvm sync --timeout`'s
83/// default, and the bindings' `sync_dir`'s.
84pub const DEFAULT_SYNC_DELETE_TIMEOUT: Duration = Duration::from_secs(60);
85
86/// How long past the in-guest `rm`'s own `timeout_sec` a sync pass waits for its answer.
87///
88/// Thirty seconds, not [`crate::session::DEFAULT_CLIENT_GRACE`]'s sixty: that margin covers an
89/// exec the daemon escalates from SIGTERM to SIGKILL, and a pass under `sync --watch` holds the
90/// next one while it waits.
91pub const SYNC_DELETE_GRACE: Duration = Duration::from_secs(30);
92
93/// How long a sync pass waits for its in-guest `rm`: the daemon's own budget plus
94/// [`SYNC_DELETE_GRACE`] for the answer to come back, so the daemon's deadline, not the
95/// client's, is what ends a slow one.
96fn delete_wait(budget: Duration) -> Duration {
97    budget.saturating_add(SYNC_DELETE_GRACE)
98}
99
100/// A local tree that couldn't be packed, hashed or written to.
101///
102/// Its own type, carried as the [`Error::source`] of the `ERR_INVALID_ARG` it becomes, so an
103/// adapter that reports local filesystem failures on a row of its own (the CLI's `ERR_SYNC`)
104/// can tell one from a failure the daemon reported without reading a message.
105#[derive(Debug, Clone, Eq, PartialEq)]
106pub struct WorkspaceError(String);
107
108impl WorkspaceError {
109    pub fn new(message: impl Into<String>) -> Self {
110        Self(message.into())
111    }
112}
113
114impl std::fmt::Display for WorkspaceError {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        self.0.fmt(f)
117    }
118}
119
120impl std::error::Error for WorkspaceError {}
121
122impl From<WorkspaceError> for Error {
123    /// `ERR_INVALID_ARG`: the tree is the caller's, and so is the fix (a smaller tree, a
124    /// readable path, a writable destination). Nothing the daemon said is involved.
125    fn from(error: WorkspaceError) -> Self {
126        Error::new(ErrorKind::InvalidArg, error.to_string()).with_source(error)
127    }
128}
129
130/// What one sync left in the guest: every member's identity, keyed by relative path.
131///
132/// Paths are `/`-separated on every platform, since a manifest crosses machines. Maps are
133/// ordered so the serialized form is deterministic and a test can assert on bytes.
134#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
135pub struct Manifest {
136    /// The format version, for a later reader deciding whether it understands this.
137    pub version: u32,
138    /// Regular files: relative path to the sha256 of the contents, lowercase hex.
139    pub files: BTreeMap<String, String>,
140    /// Symlinks: relative path to the link target, verbatim. Two links to different targets
141    /// are different members.
142    pub symlinks: BTreeMap<String, String>,
143    /// Directories, including empty ones a build script expects to exist.
144    pub dirs: BTreeSet<String>,
145}
146
147/// What an incremental sync has to do.
148#[derive(Debug, Default, PartialEq)]
149pub struct Delta {
150    /// Relative paths to pack and upload: new members, changed files, retargeted links.
151    pub upload: Vec<String>,
152    /// Relative paths present remotely and gone locally, deepest first, so a directory's
153    /// contents are named before the directory.
154    pub delete: Vec<String>,
155}
156
157impl Delta {
158    /// Nothing to send and nothing to remove.
159    pub fn is_empty(&self) -> bool {
160        self.upload.is_empty() && self.delete.is_empty()
161    }
162}
163
164/// What changed between the tree as it is (`local`) and as the last sync left it (`remote`).
165///
166/// Identity is category-scoped: a path that was a file and is now a symlink is uploaded and
167/// not deleted, since the upload overwrites the name in place, which is the daemon's own
168/// extraction contract. [`MANIFEST_NAME`] never appears in either set.
169pub fn diff(local: &Manifest, remote: &Manifest) -> Delta {
170    let mut delta = Delta::default();
171    for (path, hash) in &local.files {
172        if remote.files.get(path) != Some(hash) {
173            delta.upload.push(path.clone());
174        }
175    }
176    for (path, target) in &local.symlinks {
177        if remote.symlinks.get(path) != Some(target) {
178            delta.upload.push(path.clone());
179        }
180    }
181    for path in &local.dirs {
182        if !remote.dirs.contains(path) {
183            delta.upload.push(path.clone());
184        }
185    }
186    let lives_on = |path: &String| {
187        local.files.contains_key(path)
188            || local.symlinks.contains_key(path)
189            || local.dirs.contains(path)
190            || path == MANIFEST_NAME
191    };
192    delta.delete.extend(
193        remote
194            .files
195            .keys()
196            .chain(remote.symlinks.keys())
197            .chain(remote.dirs.iter())
198            .filter(|path| !lives_on(path))
199            .cloned(),
200    );
201    delta.upload.sort();
202    // Deepest first: `a/b/c` before `a/b`, so removing in order never needs recursion.
203    delta.delete.sort_by(|a, b| b.cmp(a));
204    delta
205}
206
207/// Whether a deletion path from the guest manifest is safe to hand to an in-guest `rm`.
208///
209/// The manifest is read *from the VM*, so a workload that rewrites it to claim
210/// `../../etc/passwd` or `/root/.ssh` was synced would otherwise get the client to order that
211/// deletion for it. Only a relative path with no `..`, no backslash and no empty component
212/// qualifies; anything else is skipped, not executed.
213pub fn deletable(path: &str) -> bool {
214    !path.is_empty()
215        && !path.starts_with('/')
216        && !path.contains('\\')
217        && path
218            .split('/')
219            .all(|component| !component.is_empty() && component != "..")
220}
221
222/// A packed archive and how many members it holds.
223#[derive(Debug)]
224pub struct Packed {
225    pub archive: Vec<u8>,
226    pub members: usize,
227}
228
229/// One member [`LocalTree::extract`] wrote.
230#[derive(Clone, Debug, Eq, PartialEq)]
231pub struct Artifact {
232    /// The member's path, relative to the destination.
233    pub path: String,
234    pub bytes: u64,
235}
236
237/// A local directory tree, packed, hashed and written to: the port `microvms-edges`
238/// implements over the real filesystem.
239///
240/// Every implementation walks the same way: [`SKIPPED_DIRS`] skipped whole; sockets, fifos
241/// and devices skipped individually; symlinks kept as links, never followed; the
242/// [`MAX_PACK_BYTES`] and [`MAX_PACK_MEMBERS`] budgets enforced during the walk.
243pub trait LocalTree: Send + Sync {
244    /// The walk of `dir`, hashed into a [`Manifest`]: exactly the set [`Self::pack`] uploads.
245    fn manifest(&self, dir: &Path) -> Result<Manifest, WorkspaceError>;
246
247    /// The walk of `dir` as a tar archive, members in sorted path order so the same tree packs
248    /// to the same bytes.
249    fn pack(&self, dir: &Path) -> Result<Packed, WorkspaceError>;
250
251    /// Exactly the named relative paths under `dir`, in sorted order.
252    fn pack_paths(&self, dir: &Path, paths: &[String]) -> Result<Packed, WorkspaceError>;
253
254    /// Writes the regular-file members of `archive` that match one of `globs` under `dir`,
255    /// and nothing else: no symlink, hardlink, directory or special member, nothing under
256    /// `.git` whatever the globs say, and nothing that would land outside `dir`. What it
257    /// skips is skipped, not refused, since the archive is the VM's word.
258    fn extract(
259        &self,
260        archive: &[u8],
261        globs: &[String],
262        dir: &Path,
263    ) -> Result<Vec<Artifact>, WorkspaceError>;
264}
265
266/// What one sync pass did.
267#[derive(Debug)]
268pub struct SyncPass {
269    /// The archive's size, when anything travelled.
270    pub uploaded_bytes: usize,
271    pub uploaded_members: usize,
272    /// The deletions the in-guest `rm` carried out.
273    pub deleted: usize,
274    /// The deletions the guest's manifest ordered that [`deletable`] refused.
275    pub refused_deletions: usize,
276    /// No baseline was given, so the whole tree travelled.
277    pub full: bool,
278    /// The baseline already matched the tree; nothing travelled at all.
279    pub unchanged: bool,
280    /// The tree as this pass left it in the guest: the next pass's baseline.
281    pub manifest: Manifest,
282}
283
284impl Session {
285    /// The manifest the last sync left in the guest, or `None` when there's none.
286    ///
287    /// A manifest that doesn't parse reads as `None` too: a different version, or a workload,
288    /// wrote that path, and the safe reading of "can't tell what's over there" is the same as
289    /// "nothing is": a full upload, which writes a manifest this build understands.
290    pub async fn sync_manifest(&self) -> Result<Option<Manifest>, Error> {
291        match self.download_file(MANIFEST_PATH).await {
292            Ok(bytes) => Ok(serde_json::from_slice(&bytes).ok()),
293            Err(error) if error.wire_kind() == Some(WireKind::NotFound) => Ok(None),
294            Err(error) => Err(error),
295        }
296    }
297
298    /// Syncs `dir` into [`REMOTE_WORKDIR`] once: against the guest's own manifest, or with
299    /// `full` against none, so everything travels. [`Self::sync_manifest`] then
300    /// [`Self::sync_pass`], the whole of a one-shot sync.
301    pub async fn sync_dir(
302        &self,
303        tree: &dyn LocalTree,
304        dir: &Path,
305        full: bool,
306        delete_timeout: Duration,
307    ) -> Result<SyncPass, Error> {
308        let baseline = if full {
309            None
310        } else {
311            self.sync_manifest().await?
312        };
313        self.sync_pass(tree, dir, baseline, delete_timeout).await
314    }
315
316    /// One incremental pass of `dir` into [`REMOTE_WORKDIR`]: hash, diff against `baseline`,
317    /// upload what changed, delete what vanished, and rewrite the guest manifest.
318    ///
319    /// `baseline` is what the guest holds, usually [`Self::sync_manifest`]'s answer or the
320    /// last pass's [`SyncPass::manifest`]; `None` uploads everything. The deletions run as one
321    /// `rm -rf --` in the guest with `delete_timeout` as the daemon's deadline, and a failed
322    /// one fails the pass with the guest manifest left as it was, so the next pass orders the
323    /// deletions again. Only [`deletable`] paths are removed.
324    pub async fn sync_pass(
325        &self,
326        tree: &dyn LocalTree,
327        dir: &Path,
328        baseline: Option<Manifest>,
329        delete_timeout: Duration,
330    ) -> Result<SyncPass, Error> {
331        let local = tree.manifest(dir)?;
332        let full = baseline.is_none();
333        let delta = diff(&local, &baseline.unwrap_or_default());
334        if delta.is_empty() && !full {
335            return Ok(SyncPass {
336                uploaded_bytes: 0,
337                uploaded_members: 0,
338                deleted: 0,
339                refused_deletions: 0,
340                full: false,
341                unchanged: true,
342                manifest: local,
343            });
344        }
345
346        let mut uploaded_bytes = 0;
347        let mut uploaded_members = 0;
348        if !delta.upload.is_empty() {
349            let packed = tree.pack_paths(dir, &delta.upload)?;
350            self.upload_tar(REMOTE_WORKDIR, &packed.archive).await?;
351            uploaded_bytes = packed.archive.len();
352            uploaded_members = packed.members;
353        }
354
355        let doomed: Vec<String> = delta
356            .delete
357            .iter()
358            .filter(|path| deletable(path))
359            .cloned()
360            .collect();
361        let refused_deletions = delta.delete.len() - doomed.len();
362        let mut deleted = 0;
363        if !doomed.is_empty() {
364            let count = doomed.len();
365            let command = ["rm", "-rf", "--"]
366                .into_iter()
367                .map(String::from)
368                .chain(doomed)
369                .collect();
370            // Uploads belong to the image's default user. Use the same identity here:
371            // a workload-controlled parent symlink must not elevate this rm above
372            // that user's privileges. Peers with no configured default still
373            // execute as the daemon's historical root user.
374            let request = protocol::exec::StartRequest::new(self.mint_exec_id(), command)
375                .with_cwd(Some(REMOTE_WORKDIR.into()))
376                .with_timeout_sec(Some(delete_timeout.as_secs_f64()));
377            let result = self.run_sync(request, delete_wait(delete_timeout)).await?;
378            match result.outcome {
379                Some(outcome) if outcome.exit_code == Some(0) => deleted = count,
380                outcome => {
381                    let detail = outcome
382                        .map(|outcome| {
383                            let stderr = outcome.stderr.trim();
384                            if stderr.is_empty() {
385                                format!("exit {:?}", outcome.exit_code)
386                            } else {
387                                stderr.to_string()
388                            }
389                        })
390                        .unwrap_or_else(|| "still running at the deadline".into());
391                    return Err(Error::new(
392                        ErrorKind::ExecFailed,
393                        format!(
394                            "the in-guest removal of {count} deleted path(s) failed: {detail}. \
395                             The uploaded members landed; the guest manifest was left as it \
396                             was, so the next sync will order these deletions again."
397                        ),
398                    ));
399                }
400            }
401        }
402
403        let body = serde_json::to_vec(&local).map_err(|error| {
404            Error::new(
405                ErrorKind::Unexpected,
406                format!("the manifest will not serialize: {error}"),
407            )
408        })?;
409        self.upload_file(MANIFEST_PATH, &body, None).await?;
410
411        Ok(SyncPass {
412            uploaded_bytes,
413            uploaded_members,
414            deleted,
415            refused_deletions,
416            full,
417            unchanged: false,
418            manifest: local,
419        })
420    }
421
422    /// The tree at `remote` in the guest, written under `dir` through [`LocalTree::extract`]:
423    /// only the regular files `globs` select, never under `.git`, never outside `dir`.
424    ///
425    /// The daemon packs the tree (`GET /v1/fs/tar`), so members are relative to `remote`.
426    pub async fn download_dir(
427        &self,
428        tree: &dyn LocalTree,
429        remote: &str,
430        globs: &[String],
431        dir: &Path,
432    ) -> Result<Vec<Artifact>, Error> {
433        let archive = self.download_tar(remote).await?;
434        Ok(tree.extract(&archive, globs, dir)?)
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use std::sync::{Arc, Mutex};
441
442    use super::*;
443    use crate::session::testing::{Recorder, Reply, session_with};
444
445    /// A tree whose manifest the test chooses: the pass's rules, with no filesystem. Its packs
446    /// name the members they carry, one per line, so a test reads what travelled.
447    #[derive(Default)]
448    struct FakeTree {
449        manifest: Manifest,
450        extracted: Mutex<Vec<(Vec<u8>, Vec<String>)>>,
451    }
452
453    impl LocalTree for FakeTree {
454        fn manifest(&self, _dir: &Path) -> Result<Manifest, WorkspaceError> {
455            Ok(self.manifest.clone())
456        }
457
458        fn pack(&self, _dir: &Path) -> Result<Packed, WorkspaceError> {
459            Err(WorkspaceError::new(
460                "a sync pass packs only the changed paths",
461            ))
462        }
463
464        fn pack_paths(&self, _dir: &Path, paths: &[String]) -> Result<Packed, WorkspaceError> {
465            Ok(Packed {
466                archive: paths.join("\n").into_bytes(),
467                members: paths.len(),
468            })
469        }
470
471        fn extract(
472            &self,
473            archive: &[u8],
474            globs: &[String],
475            _dir: &Path,
476        ) -> Result<Vec<Artifact>, WorkspaceError> {
477            self.extracted
478                .lock()
479                .expect("not poisoned")
480                .push((archive.to_vec(), globs.to_vec()));
481            Ok(vec![Artifact {
482                path: "dist/app".into(),
483                bytes: 3,
484            }])
485        }
486    }
487
488    fn files(entries: &[(&str, &str)]) -> Manifest {
489        Manifest {
490            version: MANIFEST_VERSION,
491            files: entries
492                .iter()
493                .map(|(path, hash)| ((*path).to_string(), (*hash).to_string()))
494                .collect(),
495            ..Manifest::default()
496        }
497    }
498
499    fn exited(exit_code: i32) -> Reply {
500        Reply::ok(serde_json::json!({
501            "exec_id": "x", "phase": "exited", "exit_code": exit_code, "signal": null,
502            "stdout": "", "stderr": "", "truncated": false, "writers_may_be_alive": false
503        }))
504    }
505
506    /// **A second pass after one edit uploads that one member and deletes nothing (#260).**
507    ///
508    /// The whole incremental bet: an archive proportional to the edit. The guest manifest is
509    /// rewritten to the tree as the pass left it, so the next pass diffs against it.
510    ///
511    /// **Falsification**: pack the whole local manifest instead of the delta's paths, and the
512    /// upload carries both members.
513    #[tokio::test]
514    async fn a_pass_after_one_edit_uploads_that_member_and_deletes_nothing() {
515        let recorder = Recorder::with([Reply::Body(200, Vec::new()), Reply::Body(200, Vec::new())]);
516        let (session, _, _) = session_with(Arc::clone(&recorder));
517        let tree = FakeTree {
518            manifest: files(&[("a.txt", "edited"), ("b.txt", "same")]),
519            ..FakeTree::default()
520        };
521        let baseline = files(&[("a.txt", "old"), ("b.txt", "same")]);
522
523        let pass = session
524            .sync_pass(
525                &tree,
526                Path::new("/tmp/project"),
527                Some(baseline),
528                Duration::from_secs(60),
529            )
530            .await
531            .expect("syncs");
532
533        let requests = recorder.requests();
534        assert_eq!(requests.len(), 2, "{requests:?}");
535        assert_eq!(requests[0].path, "/v1/fs/tar?path=%2Fworkspace");
536        assert_eq!(requests[0].body, b"a.txt", "only the edited member travels");
537        assert_eq!(
538            requests[1].path,
539            "/v1/fs/file?path=%2Fworkspace%2F.microvm-sync-manifest.json"
540        );
541        let written: Manifest = serde_json::from_slice(&requests[1].body).expect("a manifest");
542        assert_eq!(written, tree.manifest);
543        assert_eq!((pass.uploaded_members, pass.deleted), (1, 0));
544        assert!(!pass.full && !pass.unchanged);
545    }
546
547    /// An unchanged tree sends nothing at all, not even the manifest.
548    #[tokio::test]
549    async fn an_unchanged_tree_sends_nothing() {
550        let recorder = Recorder::with([]);
551        let (session, _, _) = session_with(Arc::clone(&recorder));
552        let tree = FakeTree {
553            manifest: files(&[("a.txt", "same")]),
554            ..FakeTree::default()
555        };
556        let pass = session
557            .sync_pass(
558                &tree,
559                Path::new("/tmp/p"),
560                Some(tree.manifest.clone()),
561                Duration::from_secs(60),
562            )
563            .await
564            .expect("syncs");
565        assert!(pass.unchanged);
566        assert!(recorder.requests().is_empty());
567    }
568
569    /// **A deletion the guest's manifest orders runs only for a plain relative path.**
570    ///
571    /// The manifest is the VM's word, so `../../etc/passwd` in it is refused, counted and
572    /// never handed to the in-guest `rm`, which gets the one allowed path from the workspace.
573    /// A permitted nested path can have a workload-controlled parent symlink, so the start
574    /// must retain the image default identity rather than elevate to root.
575    ///
576    /// **Falsification**: hand every deletion path to the `rm`, and its argv carries the
577    /// hostile one.
578    #[tokio::test(start_paused = true)]
579    async fn a_hostile_deletion_path_is_refused_not_executed() {
580        let recorder = Recorder::with([
581            Reply::ok(serde_json::json!({"exec_id": "x", "phase": "running"})),
582            exited(0),
583            exited(0),
584            Reply::Body(200, Vec::new()),
585        ]);
586        let (session, _, _) = session_with(Arc::clone(&recorder));
587        let tree = FakeTree {
588            manifest: files(&[("kept.txt", "same")]),
589            ..FakeTree::default()
590        };
591        let baseline = files(&[
592            ("kept.txt", "same"),
593            ("sub/gone.txt", "x"),
594            ("../../etc/passwd", "x"),
595        ]);
596
597        let pass = session
598            .sync_pass(
599                &tree,
600                Path::new("/tmp/p"),
601                Some(baseline),
602                Duration::from_secs(60),
603            )
604            .await
605            .expect("syncs");
606
607        let start = recorder
608            .requests()
609            .into_iter()
610            .find(|request| request.path == "/v1/exec/start")
611            .expect("an rm went out");
612        let body: serde_json::Value = serde_json::from_slice(&start.body).expect("JSON");
613        assert_eq!(
614            body["command"],
615            serde_json::json!(["rm", "-rf", "--", "sub/gone.txt"]),
616            "the rm carries only the deletable path"
617        );
618        assert_eq!(body["cwd"], "/workspace");
619        assert_eq!(body["timeout_sec"], 60.0);
620        assert!(
621            body["user"].is_null(),
622            "manifest deletion must not elevate above the image default user"
623        );
624        assert_eq!((pass.deleted, pass.refused_deletions), (1, 1));
625    }
626
627    /// A removal that fails fails the pass, and the manifest isn't rewritten, so the next pass
628    /// orders the deletion again.
629    #[tokio::test(start_paused = true)]
630    async fn a_failed_removal_fails_the_pass_and_keeps_the_old_manifest() {
631        let recorder = Recorder::with([
632            Reply::ok(serde_json::json!({"exec_id": "x", "phase": "running"})),
633            exited(1),
634            exited(1),
635        ]);
636        let (session, _, _) = session_with(Arc::clone(&recorder));
637        let tree = FakeTree::default();
638        let err = session
639            .sync_pass(
640                &tree,
641                Path::new("/tmp/p"),
642                Some(files(&[("gone.txt", "x")])),
643                Duration::from_secs(60),
644            )
645            .await
646            .expect_err("the rm failed");
647        assert_eq!(err.code(), "ERR_EXEC_FAILED", "{err}");
648        assert!(err.to_string().contains("next sync will order"), "{err}");
649        assert!(
650            !recorder
651                .requests()
652                .iter()
653                .any(|request| request.path.contains("manifest")),
654            "the manifest was rewritten after a failed removal"
655        );
656    }
657
658    /// The guest's manifest: `None` when absent or unreadable, the manifest otherwise.
659    #[tokio::test]
660    async fn the_guest_manifest_is_none_when_absent_or_unreadable() {
661        let written = files(&[("a.txt", "h")]);
662        let recorder = Recorder::with([
663            Reply::Body(404, b"no such file".to_vec()),
664            Reply::Body(200, b"not json".to_vec()),
665            Reply::Body(200, serde_json::to_vec(&written).expect("JSON")),
666            Reply::Body(401, b"wrong token".to_vec()),
667        ]);
668        let (session, _, _) = session_with(recorder);
669        assert_eq!(session.sync_manifest().await.expect("absent"), None);
670        assert_eq!(session.sync_manifest().await.expect("unreadable"), None);
671        assert_eq!(session.sync_manifest().await.expect("read"), Some(written));
672        let err = session
673            .sync_manifest()
674            .await
675            .expect_err("a 401 is not an absence");
676        assert_eq!(err.code(), "ERR_CREDENTIALS");
677    }
678
679    /// A one-shot sync reads the guest's manifest first, and `full` skips it, so everything
680    /// travels.
681    ///
682    /// **Falsification**: read the manifest whatever `full` says, and the full sync's first
683    /// request is the manifest read instead of the upload.
684    #[tokio::test]
685    async fn sync_dir_diffs_against_the_guests_manifest_unless_full() {
686        let tree = FakeTree {
687            manifest: files(&[("a.txt", "same"), ("b.txt", "new")]),
688            ..FakeTree::default()
689        };
690        let guest = files(&[("a.txt", "same")]);
691        let recorder = Recorder::with([
692            Reply::Body(200, serde_json::to_vec(&guest).expect("JSON")),
693            Reply::Body(200, Vec::new()),
694            Reply::Body(200, Vec::new()),
695            Reply::Body(200, Vec::new()),
696            Reply::Body(200, Vec::new()),
697        ]);
698        let (session, _, _) = session_with(Arc::clone(&recorder));
699
700        let incremental = session
701            .sync_dir(
702                &tree,
703                Path::new("/tmp/p"),
704                false,
705                DEFAULT_SYNC_DELETE_TIMEOUT,
706            )
707            .await
708            .expect("syncs");
709        assert_eq!(incremental.uploaded_members, 1, "only b.txt is new");
710        assert!(!incremental.full);
711
712        let full = session
713            .sync_dir(
714                &tree,
715                Path::new("/tmp/p"),
716                true,
717                DEFAULT_SYNC_DELETE_TIMEOUT,
718            )
719            .await
720            .expect("syncs");
721        assert!(full.full);
722        assert_eq!(full.uploaded_members, 2, "a full sync sends everything");
723        let paths: Vec<String> = recorder.requests().into_iter().map(|r| r.path).collect();
724        assert_eq!(
725            paths[3], "/v1/fs/tar?path=%2Fworkspace",
726            "a full sync starts with the upload, not a manifest read: {paths:?}"
727        );
728    }
729
730    /// `download_dir` hands the daemon's archive of the remote tree and the caller's globs to
731    /// the tree's guarded extraction.
732    #[tokio::test]
733    async fn download_dir_extracts_the_remote_archive_with_the_callers_globs() {
734        let recorder = Recorder::with([Reply::Body(200, b"tar bytes".to_vec())]);
735        let (session, _, _) = session_with(Arc::clone(&recorder));
736        let tree = FakeTree::default();
737        let got = session
738            .download_dir(
739                &tree,
740                "/workspace/out",
741                &["dist/**".into()],
742                Path::new("/tmp/d"),
743            )
744            .await
745            .expect("downloads");
746        assert_eq!(got.len(), 1);
747        assert_eq!(recorder.last().path, "/v1/fs/tar?path=%2Fworkspace%2Fout");
748        assert_eq!(
749            *tree.extracted.lock().expect("not poisoned"),
750            [(b"tar bytes".to_vec(), vec!["dist/**".to_string()])]
751        );
752    }
753
754    /// The pack budgets are the daemon's caps, `max_body_bytes` (512 MiB) and
755    /// `max_tar_members` (`crates/agentd/src/config.rs`), with the cap itself inside them.
756    #[test]
757    fn the_pack_budgets_are_the_daemons_caps_inclusive() {
758        assert_eq!(MAX_PACK_BYTES, 536_870_912);
759        assert_eq!(MAX_PACK_MEMBERS, 100_000);
760        assert!(within_byte_budget(536_870_912));
761        assert!(!within_byte_budget(536_870_913));
762        assert!(within_member_budget(100_000));
763        assert!(!within_member_budget(100_001));
764    }
765
766    /// A sync's `rm` waits its budget plus the delete grace, with no floor of its own: the
767    /// daemon's deadline is the budget, so a zero one is the daemon's to refuse.
768    ///
769    /// **Falsification**: `verify/guards/faults/seconds-flags.toml` entry `cli-sync-client-wait`
770    /// subtracts the grace instead, and the 60 s row reads 30 s.
771    #[test]
772    fn a_sync_waits_its_budget_plus_the_delete_grace() {
773        assert_eq!(
774            delete_wait(Duration::from_secs(60)),
775            Duration::from_secs(90)
776        );
777        assert_eq!(delete_wait(Duration::ZERO), Duration::from_secs(30));
778    }
779
780    /// A local tree's failure is `ERR_INVALID_ARG` and carries its [`WorkspaceError`] as the
781    /// source, so an adapter can report it on a row of its own without reading the message.
782    #[test]
783    fn a_workspace_error_is_invalid_arg_with_itself_as_the_source() {
784        let err = Error::from(WorkspaceError::new("the tree exceeds the budget"));
785        assert_eq!(err.to_string(), "the tree exceeds the budget");
786        assert_eq!(err.code(), "ERR_INVALID_ARG");
787        assert_eq!(err.wire_kind(), None);
788        let source = std::error::Error::source(&err).expect("a source");
789        assert_eq!(
790            source.downcast_ref::<WorkspaceError>(),
791            Some(&WorkspaceError::new("the tree exceeds the budget"))
792        );
793    }
794
795    /// Each change class lands in the right half of the delta: edits and additions
796    /// upload, disappearances delete, and the unchanged member stays home.
797    #[test]
798    fn a_diff_names_changed_new_and_deleted_members_and_nothing_else() {
799        let mut remote = Manifest {
800            version: MANIFEST_VERSION,
801            ..Manifest::default()
802        };
803        remote.files.insert("same.txt".into(), "hash-same".into());
804        remote.files.insert("edited.txt".into(), "hash-old".into());
805        remote.files.insert("removed.txt".into(), "hash-x".into());
806        remote.dirs.insert("gone-dir".into());
807        remote.dirs.insert("gone-dir/nested".into());
808        remote.symlinks.insert("link".into(), "old-target".into());
809
810        let mut local = Manifest {
811            version: MANIFEST_VERSION,
812            ..Manifest::default()
813        };
814        local.files.insert("same.txt".into(), "hash-same".into());
815        local.files.insert("edited.txt".into(), "hash-new".into());
816        local.files.insert("added.txt".into(), "hash-add".into());
817        local.symlinks.insert("link".into(), "new-target".into());
818        remote.dirs.insert("kept-dir".into());
819        local.dirs.insert("kept-dir".into());
820        local.dirs.insert("new-dir".into());
821
822        let delta = diff(&local, &remote);
823        assert_eq!(delta.upload, ["added.txt", "edited.txt", "link", "new-dir"]);
824        // Deepest first, so a non-recursive remove works in this order.
825        assert_eq!(delta.delete, ["removed.txt", "gone-dir/nested", "gone-dir"]);
826    }
827
828    /// The manifest never orders its own deletion.
829    ///
830    /// It lives in the workspace (see [`MANIFEST_PATH`] on why) and is therefore in the
831    /// remote tree without being in any local one, the one permanent asymmetry the
832    /// diff has to know about.
833    #[test]
834    fn a_diff_never_deletes_the_manifest_itself() {
835        let mut remote = Manifest::default();
836        remote.files.insert(MANIFEST_NAME.into(), "hash".into());
837        let delta = diff(&Manifest::default(), &remote);
838        assert!(delta.is_empty(), "{delta:?}");
839    }
840
841    /// A deletion path from the guest's manifest is removed only when it's a plain relative
842    /// path: the manifest is the VM's word.
843    #[test]
844    fn only_a_plain_relative_path_is_deletable() {
845        for kept in ["a.txt", "src/lib.rs", "a/b/c", ".hidden", "x..y"] {
846            assert!(deletable(kept), "{kept}");
847        }
848        for refused in [
849            "",
850            "/etc/passwd",
851            "../x",
852            "a/../b",
853            "a//b",
854            "a/",
855            "a\\b",
856            "..",
857        ] {
858            assert!(!deletable(refused), "{refused:?}");
859        }
860    }
861}