use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::time::Duration;
use crate::error::{Error, ErrorKind, WireKind};
use crate::session::Session;
pub const SKIPPED_DIRS: [&str; 4] = [".git", "target", "node_modules", ".venv"];
pub const MAX_PACK_BYTES: u64 = 512 * 1024 * 1024;
pub const MAX_PACK_MEMBERS: usize = 100_000;
pub fn within_byte_budget(total: u64) -> bool {
total <= MAX_PACK_BYTES
}
pub fn within_member_budget(count: usize) -> bool {
count <= MAX_PACK_MEMBERS
}
pub const REMOTE_WORKDIR: &str = "/workspace";
pub const MANIFEST_PATH: &str = "/workspace/.microvm-sync-manifest.json";
pub const MANIFEST_NAME: &str = ".microvm-sync-manifest.json";
pub const MANIFEST_VERSION: u32 = 1;
pub const DEFAULT_SYNC_DELETE_TIMEOUT: Duration = Duration::from_secs(60);
pub const SYNC_DELETE_GRACE: Duration = Duration::from_secs(30);
fn delete_wait(budget: Duration) -> Duration {
budget.saturating_add(SYNC_DELETE_GRACE)
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct WorkspaceError(String);
impl WorkspaceError {
pub fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
}
impl std::fmt::Display for WorkspaceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl std::error::Error for WorkspaceError {}
impl From<WorkspaceError> for Error {
fn from(error: WorkspaceError) -> Self {
Error::new(ErrorKind::InvalidArg, error.to_string()).with_source(error)
}
}
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Manifest {
pub version: u32,
pub files: BTreeMap<String, String>,
pub symlinks: BTreeMap<String, String>,
pub dirs: BTreeSet<String>,
}
#[derive(Debug, Default, PartialEq)]
pub struct Delta {
pub upload: Vec<String>,
pub delete: Vec<String>,
}
impl Delta {
pub fn is_empty(&self) -> bool {
self.upload.is_empty() && self.delete.is_empty()
}
}
pub fn diff(local: &Manifest, remote: &Manifest) -> Delta {
let mut delta = Delta::default();
for (path, hash) in &local.files {
if remote.files.get(path) != Some(hash) {
delta.upload.push(path.clone());
}
}
for (path, target) in &local.symlinks {
if remote.symlinks.get(path) != Some(target) {
delta.upload.push(path.clone());
}
}
for path in &local.dirs {
if !remote.dirs.contains(path) {
delta.upload.push(path.clone());
}
}
let lives_on = |path: &String| {
local.files.contains_key(path)
|| local.symlinks.contains_key(path)
|| local.dirs.contains(path)
|| path == MANIFEST_NAME
};
delta.delete.extend(
remote
.files
.keys()
.chain(remote.symlinks.keys())
.chain(remote.dirs.iter())
.filter(|path| !lives_on(path))
.cloned(),
);
delta.upload.sort();
delta.delete.sort_by(|a, b| b.cmp(a));
delta
}
pub fn deletable(path: &str) -> bool {
!path.is_empty()
&& !path.starts_with('/')
&& !path.contains('\\')
&& path
.split('/')
.all(|component| !component.is_empty() && component != "..")
}
#[derive(Debug)]
pub struct Packed {
pub archive: Vec<u8>,
pub members: usize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Artifact {
pub path: String,
pub bytes: u64,
}
pub trait LocalTree: Send + Sync {
fn manifest(&self, dir: &Path) -> Result<Manifest, WorkspaceError>;
fn pack(&self, dir: &Path) -> Result<Packed, WorkspaceError>;
fn pack_paths(&self, dir: &Path, paths: &[String]) -> Result<Packed, WorkspaceError>;
fn extract(
&self,
archive: &[u8],
globs: &[String],
dir: &Path,
) -> Result<Vec<Artifact>, WorkspaceError>;
}
#[derive(Debug)]
pub struct SyncPass {
pub uploaded_bytes: usize,
pub uploaded_members: usize,
pub deleted: usize,
pub refused_deletions: usize,
pub full: bool,
pub unchanged: bool,
pub manifest: Manifest,
}
impl Session {
pub async fn sync_manifest(&self) -> Result<Option<Manifest>, Error> {
match self.download_file(MANIFEST_PATH).await {
Ok(bytes) => Ok(serde_json::from_slice(&bytes).ok()),
Err(error) if error.wire_kind() == Some(WireKind::NotFound) => Ok(None),
Err(error) => Err(error),
}
}
pub async fn sync_dir(
&self,
tree: &dyn LocalTree,
dir: &Path,
full: bool,
delete_timeout: Duration,
) -> Result<SyncPass, Error> {
let baseline = if full {
None
} else {
self.sync_manifest().await?
};
self.sync_pass(tree, dir, baseline, delete_timeout).await
}
pub async fn sync_pass(
&self,
tree: &dyn LocalTree,
dir: &Path,
baseline: Option<Manifest>,
delete_timeout: Duration,
) -> Result<SyncPass, Error> {
let local = tree.manifest(dir)?;
let full = baseline.is_none();
let delta = diff(&local, &baseline.unwrap_or_default());
if delta.is_empty() && !full {
return Ok(SyncPass {
uploaded_bytes: 0,
uploaded_members: 0,
deleted: 0,
refused_deletions: 0,
full: false,
unchanged: true,
manifest: local,
});
}
let mut uploaded_bytes = 0;
let mut uploaded_members = 0;
if !delta.upload.is_empty() {
let packed = tree.pack_paths(dir, &delta.upload)?;
self.upload_tar(REMOTE_WORKDIR, &packed.archive).await?;
uploaded_bytes = packed.archive.len();
uploaded_members = packed.members;
}
let doomed: Vec<String> = delta
.delete
.iter()
.filter(|path| deletable(path))
.cloned()
.collect();
let refused_deletions = delta.delete.len() - doomed.len();
let mut deleted = 0;
if !doomed.is_empty() {
let count = doomed.len();
let command = ["rm", "-rf", "--"]
.into_iter()
.map(String::from)
.chain(doomed)
.collect();
let request = protocol::exec::StartRequest::new(self.mint_exec_id(), command)
.with_cwd(Some(REMOTE_WORKDIR.into()))
.with_timeout_sec(Some(delete_timeout.as_secs_f64()));
let result = self.run_sync(request, delete_wait(delete_timeout)).await?;
match result.outcome {
Some(outcome) if outcome.exit_code == Some(0) => deleted = count,
outcome => {
let detail = outcome
.map(|outcome| {
let stderr = outcome.stderr.trim();
if stderr.is_empty() {
format!("exit {:?}", outcome.exit_code)
} else {
stderr.to_string()
}
})
.unwrap_or_else(|| "still running at the deadline".into());
return Err(Error::new(
ErrorKind::ExecFailed,
format!(
"the in-guest removal of {count} deleted path(s) failed: {detail}. \
The uploaded members landed; the guest manifest was left as it \
was, so the next sync will order these deletions again."
),
));
}
}
}
let body = serde_json::to_vec(&local).map_err(|error| {
Error::new(
ErrorKind::Unexpected,
format!("the manifest will not serialize: {error}"),
)
})?;
self.upload_file(MANIFEST_PATH, &body, None).await?;
Ok(SyncPass {
uploaded_bytes,
uploaded_members,
deleted,
refused_deletions,
full,
unchanged: false,
manifest: local,
})
}
pub async fn download_dir(
&self,
tree: &dyn LocalTree,
remote: &str,
globs: &[String],
dir: &Path,
) -> Result<Vec<Artifact>, Error> {
let archive = self.download_tar(remote).await?;
Ok(tree.extract(&archive, globs, dir)?)
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use super::*;
use crate::session::testing::{Recorder, Reply, session_with};
#[derive(Default)]
struct FakeTree {
manifest: Manifest,
extracted: Mutex<Vec<(Vec<u8>, Vec<String>)>>,
}
impl LocalTree for FakeTree {
fn manifest(&self, _dir: &Path) -> Result<Manifest, WorkspaceError> {
Ok(self.manifest.clone())
}
fn pack(&self, _dir: &Path) -> Result<Packed, WorkspaceError> {
Err(WorkspaceError::new(
"a sync pass packs only the changed paths",
))
}
fn pack_paths(&self, _dir: &Path, paths: &[String]) -> Result<Packed, WorkspaceError> {
Ok(Packed {
archive: paths.join("\n").into_bytes(),
members: paths.len(),
})
}
fn extract(
&self,
archive: &[u8],
globs: &[String],
_dir: &Path,
) -> Result<Vec<Artifact>, WorkspaceError> {
self.extracted
.lock()
.expect("not poisoned")
.push((archive.to_vec(), globs.to_vec()));
Ok(vec![Artifact {
path: "dist/app".into(),
bytes: 3,
}])
}
}
fn files(entries: &[(&str, &str)]) -> Manifest {
Manifest {
version: MANIFEST_VERSION,
files: entries
.iter()
.map(|(path, hash)| ((*path).to_string(), (*hash).to_string()))
.collect(),
..Manifest::default()
}
}
fn exited(exit_code: i32) -> Reply {
Reply::ok(serde_json::json!({
"exec_id": "x", "phase": "exited", "exit_code": exit_code, "signal": null,
"stdout": "", "stderr": "", "truncated": false, "writers_may_be_alive": false
}))
}
#[tokio::test]
async fn a_pass_after_one_edit_uploads_that_member_and_deletes_nothing() {
let recorder = Recorder::with([Reply::Body(200, Vec::new()), Reply::Body(200, Vec::new())]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let tree = FakeTree {
manifest: files(&[("a.txt", "edited"), ("b.txt", "same")]),
..FakeTree::default()
};
let baseline = files(&[("a.txt", "old"), ("b.txt", "same")]);
let pass = session
.sync_pass(
&tree,
Path::new("/tmp/project"),
Some(baseline),
Duration::from_secs(60),
)
.await
.expect("syncs");
let requests = recorder.requests();
assert_eq!(requests.len(), 2, "{requests:?}");
assert_eq!(requests[0].path, "/v1/fs/tar?path=%2Fworkspace");
assert_eq!(requests[0].body, b"a.txt", "only the edited member travels");
assert_eq!(
requests[1].path,
"/v1/fs/file?path=%2Fworkspace%2F.microvm-sync-manifest.json"
);
let written: Manifest = serde_json::from_slice(&requests[1].body).expect("a manifest");
assert_eq!(written, tree.manifest);
assert_eq!((pass.uploaded_members, pass.deleted), (1, 0));
assert!(!pass.full && !pass.unchanged);
}
#[tokio::test]
async fn an_unchanged_tree_sends_nothing() {
let recorder = Recorder::with([]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let tree = FakeTree {
manifest: files(&[("a.txt", "same")]),
..FakeTree::default()
};
let pass = session
.sync_pass(
&tree,
Path::new("/tmp/p"),
Some(tree.manifest.clone()),
Duration::from_secs(60),
)
.await
.expect("syncs");
assert!(pass.unchanged);
assert!(recorder.requests().is_empty());
}
#[tokio::test(start_paused = true)]
async fn a_hostile_deletion_path_is_refused_not_executed() {
let recorder = Recorder::with([
Reply::ok(serde_json::json!({"exec_id": "x", "phase": "running"})),
exited(0),
exited(0),
Reply::Body(200, Vec::new()),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let tree = FakeTree {
manifest: files(&[("kept.txt", "same")]),
..FakeTree::default()
};
let baseline = files(&[
("kept.txt", "same"),
("sub/gone.txt", "x"),
("../../etc/passwd", "x"),
]);
let pass = session
.sync_pass(
&tree,
Path::new("/tmp/p"),
Some(baseline),
Duration::from_secs(60),
)
.await
.expect("syncs");
let start = recorder
.requests()
.into_iter()
.find(|request| request.path == "/v1/exec/start")
.expect("an rm went out");
let body: serde_json::Value = serde_json::from_slice(&start.body).expect("JSON");
assert_eq!(
body["command"],
serde_json::json!(["rm", "-rf", "--", "sub/gone.txt"]),
"the rm carries only the deletable path"
);
assert_eq!(body["cwd"], "/workspace");
assert_eq!(body["timeout_sec"], 60.0);
assert!(
body["user"].is_null(),
"manifest deletion must not elevate above the image default user"
);
assert_eq!((pass.deleted, pass.refused_deletions), (1, 1));
}
#[tokio::test(start_paused = true)]
async fn a_failed_removal_fails_the_pass_and_keeps_the_old_manifest() {
let recorder = Recorder::with([
Reply::ok(serde_json::json!({"exec_id": "x", "phase": "running"})),
exited(1),
exited(1),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let tree = FakeTree::default();
let err = session
.sync_pass(
&tree,
Path::new("/tmp/p"),
Some(files(&[("gone.txt", "x")])),
Duration::from_secs(60),
)
.await
.expect_err("the rm failed");
assert_eq!(err.code(), "ERR_EXEC_FAILED", "{err}");
assert!(err.to_string().contains("next sync will order"), "{err}");
assert!(
!recorder
.requests()
.iter()
.any(|request| request.path.contains("manifest")),
"the manifest was rewritten after a failed removal"
);
}
#[tokio::test]
async fn the_guest_manifest_is_none_when_absent_or_unreadable() {
let written = files(&[("a.txt", "h")]);
let recorder = Recorder::with([
Reply::Body(404, b"no such file".to_vec()),
Reply::Body(200, b"not json".to_vec()),
Reply::Body(200, serde_json::to_vec(&written).expect("JSON")),
Reply::Body(401, b"wrong token".to_vec()),
]);
let (session, _, _) = session_with(recorder);
assert_eq!(session.sync_manifest().await.expect("absent"), None);
assert_eq!(session.sync_manifest().await.expect("unreadable"), None);
assert_eq!(session.sync_manifest().await.expect("read"), Some(written));
let err = session
.sync_manifest()
.await
.expect_err("a 401 is not an absence");
assert_eq!(err.code(), "ERR_CREDENTIALS");
}
#[tokio::test]
async fn sync_dir_diffs_against_the_guests_manifest_unless_full() {
let tree = FakeTree {
manifest: files(&[("a.txt", "same"), ("b.txt", "new")]),
..FakeTree::default()
};
let guest = files(&[("a.txt", "same")]);
let recorder = Recorder::with([
Reply::Body(200, serde_json::to_vec(&guest).expect("JSON")),
Reply::Body(200, Vec::new()),
Reply::Body(200, Vec::new()),
Reply::Body(200, Vec::new()),
Reply::Body(200, Vec::new()),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let incremental = session
.sync_dir(
&tree,
Path::new("/tmp/p"),
false,
DEFAULT_SYNC_DELETE_TIMEOUT,
)
.await
.expect("syncs");
assert_eq!(incremental.uploaded_members, 1, "only b.txt is new");
assert!(!incremental.full);
let full = session
.sync_dir(
&tree,
Path::new("/tmp/p"),
true,
DEFAULT_SYNC_DELETE_TIMEOUT,
)
.await
.expect("syncs");
assert!(full.full);
assert_eq!(full.uploaded_members, 2, "a full sync sends everything");
let paths: Vec<String> = recorder.requests().into_iter().map(|r| r.path).collect();
assert_eq!(
paths[3], "/v1/fs/tar?path=%2Fworkspace",
"a full sync starts with the upload, not a manifest read: {paths:?}"
);
}
#[tokio::test]
async fn download_dir_extracts_the_remote_archive_with_the_callers_globs() {
let recorder = Recorder::with([Reply::Body(200, b"tar bytes".to_vec())]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let tree = FakeTree::default();
let got = session
.download_dir(
&tree,
"/workspace/out",
&["dist/**".into()],
Path::new("/tmp/d"),
)
.await
.expect("downloads");
assert_eq!(got.len(), 1);
assert_eq!(recorder.last().path, "/v1/fs/tar?path=%2Fworkspace%2Fout");
assert_eq!(
*tree.extracted.lock().expect("not poisoned"),
[(b"tar bytes".to_vec(), vec!["dist/**".to_string()])]
);
}
#[test]
fn the_pack_budgets_are_the_daemons_caps_inclusive() {
assert_eq!(MAX_PACK_BYTES, 536_870_912);
assert_eq!(MAX_PACK_MEMBERS, 100_000);
assert!(within_byte_budget(536_870_912));
assert!(!within_byte_budget(536_870_913));
assert!(within_member_budget(100_000));
assert!(!within_member_budget(100_001));
}
#[test]
fn a_sync_waits_its_budget_plus_the_delete_grace() {
assert_eq!(
delete_wait(Duration::from_secs(60)),
Duration::from_secs(90)
);
assert_eq!(delete_wait(Duration::ZERO), Duration::from_secs(30));
}
#[test]
fn a_workspace_error_is_invalid_arg_with_itself_as_the_source() {
let err = Error::from(WorkspaceError::new("the tree exceeds the budget"));
assert_eq!(err.to_string(), "the tree exceeds the budget");
assert_eq!(err.code(), "ERR_INVALID_ARG");
assert_eq!(err.wire_kind(), None);
let source = std::error::Error::source(&err).expect("a source");
assert_eq!(
source.downcast_ref::<WorkspaceError>(),
Some(&WorkspaceError::new("the tree exceeds the budget"))
);
}
#[test]
fn a_diff_names_changed_new_and_deleted_members_and_nothing_else() {
let mut remote = Manifest {
version: MANIFEST_VERSION,
..Manifest::default()
};
remote.files.insert("same.txt".into(), "hash-same".into());
remote.files.insert("edited.txt".into(), "hash-old".into());
remote.files.insert("removed.txt".into(), "hash-x".into());
remote.dirs.insert("gone-dir".into());
remote.dirs.insert("gone-dir/nested".into());
remote.symlinks.insert("link".into(), "old-target".into());
let mut local = Manifest {
version: MANIFEST_VERSION,
..Manifest::default()
};
local.files.insert("same.txt".into(), "hash-same".into());
local.files.insert("edited.txt".into(), "hash-new".into());
local.files.insert("added.txt".into(), "hash-add".into());
local.symlinks.insert("link".into(), "new-target".into());
remote.dirs.insert("kept-dir".into());
local.dirs.insert("kept-dir".into());
local.dirs.insert("new-dir".into());
let delta = diff(&local, &remote);
assert_eq!(delta.upload, ["added.txt", "edited.txt", "link", "new-dir"]);
assert_eq!(delta.delete, ["removed.txt", "gone-dir/nested", "gone-dir"]);
}
#[test]
fn a_diff_never_deletes_the_manifest_itself() {
let mut remote = Manifest::default();
remote.files.insert(MANIFEST_NAME.into(), "hash".into());
let delta = diff(&Manifest::default(), &remote);
assert!(delta.is_empty(), "{delta:?}");
}
#[test]
fn only_a_plain_relative_path_is_deletable() {
for kept in ["a.txt", "src/lib.rs", "a/b/c", ".hidden", "x..y"] {
assert!(deletable(kept), "{kept}");
}
for refused in [
"",
"/etc/passwd",
"../x",
"a/../b",
"a//b",
"a/",
"a\\b",
"..",
] {
assert!(!deletable(refused), "{refused:?}");
}
}
}