use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::path::Path;
use std::path::PathBuf;
use tracing::error;
use tracing::info;
use crate::Res;
use crate::checksum::refresh_hash;
use crate::error::PackageOpError;
use crate::flow;
use crate::flow::Applied;
use crate::flow::LocalWork;
use crate::flow::PullOutcome;
use crate::flow::apply_latest_update;
use crate::flow::classify_pull;
use crate::flow::identical_to_latest;
use crate::flow::pull_outcome::RemoteChange;
use crate::flow::remote_delta;
use crate::io::manifest::resolve_tag;
use crate::io::remote::HostConfig;
use crate::io::remote::Remote;
use crate::io::storage::Storage;
use crate::lineage::ChangeSet;
use crate::lineage::InstalledPackageStatus;
use crate::lineage::LineagePaths;
use crate::lineage::PackageLineage;
use crate::lineage::SyncScope;
use crate::manifest::Manifest;
use crate::paths::DomainPaths;
use quilt_uri::ManifestUri;
use quilt_uri::Namespace;
use quilt_uri::Tag;
#[derive(Debug)]
pub struct PullSnapshot {
pub status: InstalledPackageStatus,
pub latest: ManifestUri,
pub latest_manifest: Manifest,
}
pub async fn snapshot_for_pull(
mut lineage: PackageLineage,
base_manifest: &Manifest,
paths: &DomainPaths,
storage: &(impl Storage + Sync),
remote: &impl Remote,
package_home: impl AsRef<Path>,
host_config: HostConfig,
) -> Res<(PackageLineage, PullSnapshot)> {
let remote_uri = lineage.remote()?.clone();
let origin = remote_uri.origin.clone();
let latest = resolve_tag(remote, origin.as_ref(), remote_uri, Tag::Latest).await?;
lineage.latest_hash.clone_from(&latest.hash);
if latest.hash == lineage.base_hash {
return Err(PackageOpError::AlreadyUpToDate.into());
}
let latest_manifest = flow::cache_remote_manifest(paths, storage, remote, &latest).await?;
let (lineage, status) =
flow::status(lineage, storage, base_manifest, package_home, host_config).await?;
Ok((
lineage,
PullSnapshot {
status,
latest,
latest_manifest,
},
))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PullReport {
pub manifest_uri: ManifestUri,
pub added: Vec<PathBuf>,
pub added_not_fetched: Vec<PathBuf>,
pub updated: Vec<PathBuf>,
pub removed: Vec<PathBuf>,
pub message: Option<String>,
}
impl PullReport {
#[must_use]
pub fn is_empty(&self) -> bool {
self.added.is_empty()
&& self.added_not_fetched.is_empty()
&& self.updated.is_empty()
&& self.removed.is_empty()
}
}
fn report_of(
manifest_uri: ManifestUri,
delta: &BTreeMap<PathBuf, RemoteChange>,
applied: &Applied,
locally_changed: &ChangeSet,
message: Option<String>,
) -> PullReport {
let installed: BTreeSet<&PathBuf> = applied.installed.iter().collect();
let replaced: BTreeSet<&PathBuf> = applied.replaced.iter().collect();
let uninstalled: BTreeSet<&PathBuf> = applied.uninstalled.iter().collect();
let mut report = PullReport {
manifest_uri,
added: Vec::new(),
added_not_fetched: Vec::new(),
updated: Vec::new(),
removed: Vec::new(),
message: message.filter(|m| !m.is_empty()),
};
for (path, change) in delta {
match (installed.contains(path), uninstalled.contains(path)) {
(true, _) if replaced.contains(path) => report.updated.push(path.clone()),
(true, _) => report.added.push(path.clone()),
(false, true) => report.removed.push(path.clone()),
(false, false) => {
if matches!(change, RemoteChange::Added(_)) && !locally_changed.contains_key(path) {
report.added_not_fetched.push(path.clone());
}
}
}
}
report
}
fn touch_set(
remote_changed: impl IntoIterator<Item = PathBuf>,
tracked: &LineagePaths,
locally_changed: &ChangeSet,
scope: SyncScope,
) -> Vec<PathBuf> {
remote_changed
.into_iter()
.filter(|p| scope.covers_untracked() || tracked.contains_key(p))
.filter(|p| !locally_changed.contains_key(p))
.collect()
}
#[allow(clippy::too_many_arguments)]
pub async fn pull_package(
lineage: PackageLineage,
manifest: &mut Manifest,
paths: &DomainPaths,
storage: &(impl Storage + Sync),
remote: &impl Remote,
working_dir: PathBuf,
snapshot: PullSnapshot,
namespace: Namespace,
scope: SyncScope,
) -> Res<(PackageLineage, PullReport)> {
info!("⏳ Starting pull for package {namespace} (scope={scope:?})");
if lineage.commit.is_some() {
error!("❌ Found pending commits, cannot pull");
return Err(PackageOpError::Package("package has pending commits".to_string()).into());
}
let remote_uri = lineage.remote()?.clone();
if remote_uri.hash != lineage.base_hash {
error!("❌ Package has diverged from remote");
return Err(PackageOpError::Package("package has diverged".to_string()).into());
}
if lineage.base_hash == lineage.latest_hash {
error!("❌ Package is already up-to-date");
return Err(PackageOpError::AlreadyUpToDate.into());
}
let identical = identical_to_latest(
storage,
&working_dir,
&snapshot.status,
manifest,
&snapshot.latest_manifest,
)
.await?;
let outcome = classify_pull(
&snapshot.status,
manifest,
&snapshot.latest_manifest,
&identical,
);
match &outcome {
PullOutcome::UpToDate => {
return Err(PackageOpError::AlreadyUpToDate.into());
}
PullOutcome::Blocked { conflicts } => {
error!("❌ Pull blocked by conflicts: {conflicts:?}");
return Err(PackageOpError::PullConflict(conflicts.clone()).into());
}
PullOutcome::CleanUpdate | PullOutcome::KeepsLocalChanges { .. } => {}
}
let delta = remote_delta(manifest, &snapshot.latest_manifest);
let touched = touch_set(
delta.keys().cloned(),
&lineage.paths,
&snapshot.status.changes,
scope,
);
let mut drifted = Vec::new();
for path in &touched {
if !lineage.paths.contains_key(path) {
continue;
}
let Some(base_row) = manifest.get_record(path) else {
continue;
};
match refresh_hash(storage, &working_dir.join(path), base_row.clone()).await {
Ok(None) => {}
Ok(Some(_)) => drifted.push(path.clone()),
Err(err) if err.is_not_found() => drifted.push(path.clone()),
Err(err) => return Err(err),
}
}
if !drifted.is_empty() {
error!("❌ Working-tree drift on touched paths since the walk: {drifted:?}");
return Err(PackageOpError::PullConflict(drifted).into());
}
let latest = snapshot.latest.clone();
let (lineage, applied) = apply_latest_update(
lineage,
manifest,
paths,
storage,
remote,
working_dir,
namespace,
snapshot.latest,
&touched,
&LocalWork::Protect,
)
.await?;
let report = report_of(
latest,
&delta,
&applied,
&snapshot.status.changes,
snapshot.latest_manifest.header.message.clone(),
);
info!("✔️ Successfully pulled (surgical), outcome={outcome:?}");
Ok((lineage, report))
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
use std::collections::BTreeMap;
use aws_sdk_s3::primitives::ByteStream;
use multihash::Multihash;
use crate::checksum::calculate_hash;
use crate::io::remote::HostChecksums;
use crate::io::remote::HostConfig;
use crate::io::remote::mocks::MockRemote;
use crate::io::storage::StorageExt;
use crate::io::storage::mocks::MockStorage;
use crate::lineage::Change;
use crate::lineage::CommitState;
use crate::lineage::PathState;
use crate::lineage::UpstreamState;
use crate::manifest::ManifestRow;
use crate::object_hash::Hash;
use crate::object_hash::Sha256Hash;
use quilt_uri::ManifestUri;
use quilt_uri::S3Uri;
fn row(key: &str, hash_seed: &[u8]) -> ManifestRow {
ManifestRow {
logical_key: PathBuf::from(key),
physical_key: format!("s3://b/{key}"),
hash: Multihash::<256>::wrap(0x12, hash_seed)
.unwrap()
.try_into()
.unwrap(),
size: hash_seed.len() as u64,
meta: None,
}
}
fn hash_of(seed: &[u8]) -> crate::object_hash::ObjectHash {
Multihash::<256>::wrap(0x12, seed)
.unwrap()
.try_into()
.unwrap()
}
fn uri() -> ManifestUri {
ManifestUri {
bucket: "b".to_string(),
namespace: ("acme", "demo").into(),
hash: "h".to_string(),
origin: None,
}
}
fn paths(names: &[&str]) -> Vec<PathBuf> {
names.iter().map(PathBuf::from).collect()
}
fn applied(installed: &[&str], replaced: &[&str], uninstalled: &[&str]) -> Applied {
Applied {
installed: paths(installed),
replaced: paths(replaced),
uninstalled: paths(uninstalled),
}
}
#[test]
fn report_groups_by_what_happened_to_this_copy() {
let delta = BTreeMap::from([
(
PathBuf::from("fetched.csv"),
RemoteChange::Added(hash_of(b"a")),
),
(
PathBuf::from("listed.csv"),
RemoteChange::Added(hash_of(b"b")),
),
(
PathBuf::from("changed.csv"),
RemoteChange::Modified(hash_of(b"c")),
),
(PathBuf::from("dropped.csv"), RemoteChange::Removed),
]);
let applied = applied(
&["fetched.csv", "changed.csv"],
&["changed.csv"],
&["dropped.csv"],
);
let report = report_of(
uri(),
&delta,
&applied,
&ChangeSet::new(),
Some("a message".to_owned()),
);
assert_eq!(report.added, paths(&["fetched.csv"]));
assert_eq!(report.added_not_fetched, paths(&["listed.csv"]));
assert_eq!(report.updated, paths(&["changed.csv"]));
assert_eq!(report.removed, paths(&["dropped.csv"]));
assert_eq!(report.message.as_deref(), Some("a message"));
assert!(!report.is_empty());
}
#[test(tokio::test)]
async fn the_interrupted_tree_retries_cleanly() -> Res {
let storage = MockStorage::default();
let working_dir = PathBuf::from("/wd");
let written = PathBuf::from("done.csv");
let pending = [PathBuf::from("todo-1.csv"), PathBuf::from("todo-2.csv")];
storage
.write_byte_stream(
working_dir.join(&written),
ByteStream::from_static(b"latest content for done.csv"),
)
.await?;
let crc64 = HostConfig {
checksums: HostChecksums::Crc64,
host: None,
};
let sha_chunked = HostConfig {
checksums: HostChecksums::Sha256Chunked,
host: None,
};
let latest_row =
calculate_hash(&storage, &working_dir.join(&written), &written, &crc64).await?;
let local_row = calculate_hash(
&storage,
&working_dir.join(&written),
&written,
&sha_chunked,
)
.await?;
let base = manifest_of(vec![
row("done.csv", b"base-done"),
row("todo-1.csv", b"base-1"),
row("todo-2.csv", b"base-2"),
]);
let latest = manifest_of(vec![
latest_row,
row("todo-1.csv", b"latest-1"),
row("todo-2.csv", b"latest-2"),
]);
let status = InstalledPackageStatus::new(
UpstreamState::Behind,
ChangeSet::from([(written.clone(), Change::Modified(local_row))]),
);
let identical =
identical_to_latest(&storage, &working_dir, &status, &base, &latest).await?;
assert!(
identical.contains(&written),
"the file the interrupted apply wrote already holds latest's content"
);
assert_eq!(
classify_pull(&status, &base, &latest, &identical),
PullOutcome::KeepsLocalChanges {
added: vec![],
modified: vec![],
removed: vec![],
},
"the interrupted tree must retry, not block"
);
let tracked: LineagePaths = [&written, &pending[0], &pending[1]]
.into_iter()
.map(|p| (p.clone(), PathState::default()))
.collect();
let touched = touch_set(
remote_delta(&base, &latest).into_keys(),
&tracked,
&status.changes,
SyncScope::IndividualFiles,
);
assert_eq!(
touched,
vec![pending[0].clone(), pending[1].clone()],
"the already-written file must not be fetched again"
);
Ok(())
}
#[test]
fn untracked_paths_are_reported_by_what_the_apply_did() {
let delta = BTreeMap::from([
(PathBuf::from("never-had.csv"), RemoteChange::Removed),
(
PathBuf::from("first-fetch.csv"),
RemoteChange::Modified(hash_of(b"m")),
),
]);
let applied = applied(&["first-fetch.csv"], &[], &[]);
let report = report_of(uri(), &delta, &applied, &ChangeSet::new(), None);
assert!(
report.removed.is_empty(),
"a path with no local file was reported as removed: {report:?}"
);
assert_eq!(
report.added,
paths(&["first-fetch.csv"]),
"a first fetch is new to this copy, not an update"
);
assert!(report.updated.is_empty(), "nothing was overwritten");
}
#[test]
fn nothing_moved_means_nothing_reported() {
let nothing = Applied::default();
let kept = BTreeMap::from([(
PathBuf::from("mine.csv"),
RemoteChange::Modified(hash_of(b"x")),
)]);
let report = report_of(uri(), &kept, ¬hing, &ChangeSet::new(), None);
assert!(
report.is_empty(),
"kept local work was reported: {report:?}"
);
let both_removed = BTreeMap::from([(PathBuf::from("gone.csv"), RemoteChange::Removed)]);
let report = report_of(uri(), &both_removed, ¬hing, &ChangeSet::new(), None);
assert!(
report.is_empty(),
"a both-removed path was reported: {report:?}"
);
let both_added = BTreeMap::from([(
PathBuf::from("same.csv"),
RemoteChange::Added(hash_of(b"same")),
)]);
let locally = ChangeSet::from([(
PathBuf::from("same.csv"),
Change::Added(ManifestRow::default()),
)]);
let report = report_of(uri(), &both_added, ¬hing, &locally, None);
assert!(
report.is_empty(),
"a path the user already has was reported as outstanding: {report:?}"
);
let report = report_of(
uri(),
&BTreeMap::new(),
¬hing,
&ChangeSet::new(),
Some("retagged".to_owned()),
);
assert!(report.is_empty(), "a metadata-only revision named files");
assert_eq!(report.message.as_deref(), Some("retagged"));
}
#[test]
fn an_empty_message_is_absent() {
let report = report_of(
uri(),
&BTreeMap::new(),
&Applied::default(),
&ChangeSet::new(),
Some(String::new()),
);
assert_eq!(report.message, None);
}
fn manifest_of(rows: Vec<ManifestRow>) -> Manifest {
Manifest {
rows,
..Manifest::default()
}
}
fn snapshot_with(status: InstalledPackageStatus, latest_manifest: Manifest) -> PullSnapshot {
PullSnapshot {
status,
latest: ManifestUri::default(),
latest_manifest,
}
}
#[test(tokio::test)]
async fn added_file_does_not_block_the_guard() {
let storage = MockStorage::default();
let remote = MockRemote::default();
let lineage = PackageLineage {
remote_uri: Some(ManifestUri {
hash: "a".to_string(),
..ManifestUri::default()
}),
base_hash: "a".to_string(),
latest_hash: "b".to_string(),
..PackageLineage::default()
};
let status = InstalledPackageStatus {
changes: BTreeMap::from([(
PathBuf::from("new"),
Change::Added(ManifestRow::default()),
)]),
..InstalledPackageStatus::default()
};
let error = pull_package(
lineage,
&mut Manifest::default(),
&DomainPaths::default(),
&storage,
&remote,
PathBuf::default(),
snapshot_with(status, Manifest::default()),
Namespace::default(),
SyncScope::IndividualFiles,
)
.await;
assert!(matches!(
error.unwrap_err(),
crate::Error::PackageOp(PackageOpError::AlreadyUpToDate)
));
}
#[test(tokio::test)]
async fn snapshot_short_circuits_when_latest_equals_base() {
let storage = MockStorage::default();
let remote = MockRemote::default();
let bucket = "bkt";
let base = "base";
let lineage = PackageLineage {
remote_uri: Some(ManifestUri {
bucket: bucket.to_string(),
namespace: ("f", "b").into(),
hash: base.to_string(),
origin: None,
}),
base_hash: base.to_string(),
latest_hash: base.to_string(),
..PackageLineage::default()
};
let tag_uri =
S3Uri::try_from(format!("s3://{bucket}/.quilt/named_packages/f/b/latest").as_str())
.unwrap();
remote
.put_object(None, &tag_uri, base.as_bytes().to_vec())
.await
.unwrap();
let result = snapshot_for_pull(
lineage,
&Manifest::default(),
&DomainPaths::default(),
&storage,
&remote,
PathBuf::default(),
HostConfig::default(),
)
.await;
assert!(matches!(
result.unwrap_err(),
crate::Error::PackageOp(PackageOpError::AlreadyUpToDate)
));
let manifest_uri = format!("s3://{bucket}/.quilt/packages/{base}");
assert_eq!(remote.get_object_count(&manifest_uri), 0);
}
#[test(tokio::test)]
async fn test_no_pull_if_commit() {
let storage = MockStorage::default();
let remote = MockRemote::default();
let lineage = PackageLineage {
commit: Some(CommitState::default()),
..PackageLineage::default()
};
let error = pull_package(
lineage,
&mut Manifest::default(),
&DomainPaths::default(),
&storage,
&remote,
PathBuf::default(),
snapshot_with(InstalledPackageStatus::default(), Manifest::default()),
Namespace::default(),
SyncScope::IndividualFiles,
)
.await;
assert_eq!(
error.unwrap_err().to_string(),
"General error regarding package: package has pending commits".to_string()
);
}
#[test(tokio::test)]
async fn test_no_pull_if_diverged() {
let storage = MockStorage::default();
let remote = MockRemote::default();
let lineage = PackageLineage {
remote_uri: Some(ManifestUri {
hash: "a".to_string(),
..ManifestUri::default()
}),
base_hash: "b".to_string(),
..PackageLineage::default()
};
let error = pull_package(
lineage,
&mut Manifest::default(),
&DomainPaths::default(),
&storage,
&remote,
PathBuf::default(),
snapshot_with(InstalledPackageStatus::default(), Manifest::default()),
Namespace::default(),
SyncScope::IndividualFiles,
)
.await;
assert_eq!(
error.unwrap_err().to_string(),
"General error regarding package: package has diverged".to_string()
);
}
#[test(tokio::test)]
async fn test_no_pull_if_up_to_date() {
let storage = MockStorage::default();
let remote = MockRemote::default();
let lineage = PackageLineage {
remote_uri: Some(ManifestUri {
hash: "a".to_string(),
..ManifestUri::default()
}),
base_hash: "a".to_string(),
latest_hash: "a".to_string(),
..PackageLineage::default()
};
let error = pull_package(
lineage,
&mut Manifest::default(),
&DomainPaths::default(),
&storage,
&remote,
PathBuf::default(),
snapshot_with(InstalledPackageStatus::default(), Manifest::default()),
Namespace::default(),
SyncScope::IndividualFiles,
)
.await;
assert!(matches!(
error.unwrap_err(),
crate::Error::PackageOp(PackageOpError::AlreadyUpToDate)
));
}
#[test(tokio::test)]
async fn racing_edit_on_touched_path_aborts_as_conflict() {
let storage = MockStorage::default();
let remote = MockRemote::default();
let working_dir = PathBuf::from("/wd");
let path = PathBuf::from("a");
let edited = b"raced edit after the walk";
storage
.write_byte_stream(working_dir.join(&path), ByteStream::from_static(edited))
.await
.unwrap();
let base = manifest_of(vec![row("a", b"v1")]);
let latest = manifest_of(vec![row("a", b"v2")]);
let lineage = PackageLineage {
remote_uri: Some(ManifestUri {
hash: "a".to_string(),
..ManifestUri::default()
}),
base_hash: "a".to_string(),
latest_hash: "b".to_string(),
paths: BTreeMap::from([(path.clone(), PathState::default())]),
..PackageLineage::default()
};
let status = InstalledPackageStatus::default();
let mut base = base;
let error = pull_package(
lineage,
&mut base,
&DomainPaths::default(),
&storage,
&remote,
working_dir.clone(),
snapshot_with(status, latest),
Namespace::default(),
SyncScope::IndividualFiles,
)
.await;
assert!(
matches!(
error.as_ref().unwrap_err(),
crate::Error::PackageOp(PackageOpError::PullConflict(paths)) if paths == &vec![path.clone()]
),
"expected PullConflict naming `a`, got: {error:?}"
);
assert_eq!(
storage.read_bytes(&working_dir.join(&path)).await.unwrap(),
edited
);
}
#[test(tokio::test)]
async fn missing_touched_path_aborts_as_conflict() {
let storage = MockStorage::default();
let remote = MockRemote::default();
let working_dir = PathBuf::from("/wd");
let path = PathBuf::from("a");
let base = manifest_of(vec![row("a", b"v1")]);
let latest = manifest_of(vec![row("a", b"v2")]);
let lineage = PackageLineage {
remote_uri: Some(ManifestUri {
hash: "a".to_string(),
..ManifestUri::default()
}),
base_hash: "a".to_string(),
latest_hash: "b".to_string(),
paths: BTreeMap::from([(path.clone(), PathState::default())]),
..PackageLineage::default()
};
let status = InstalledPackageStatus::default();
let mut base = base;
let error = pull_package(
lineage,
&mut base,
&DomainPaths::default(),
&storage,
&remote,
working_dir.clone(),
snapshot_with(status, latest),
Namespace::default(),
SyncScope::IndividualFiles,
)
.await;
assert!(
matches!(
error.as_ref().unwrap_err(),
crate::Error::PackageOp(PackageOpError::PullConflict(paths)) if paths == &vec![path.clone()]
),
"expected PullConflict naming `a`, got: {error:?}"
);
}
fn tracked_map<const N: usize>(names: [&str; N]) -> LineagePaths {
names
.iter()
.map(|n| (PathBuf::from(n), PathState::default()))
.collect()
}
#[test]
fn a_remote_added_path_is_taken_only_under_whole_package_scope() {
let remote_changed = || vec![PathBuf::from("have.csv"), PathBuf::from("added.csv")];
let tracked = tracked_map(["have.csv"]);
let none = ChangeSet::new();
assert_eq!(
touch_set(
remote_changed(),
&tracked,
&none,
SyncScope::IndividualFiles
),
vec![PathBuf::from("have.csv")],
"sparse checkout leaves a path it does not track"
);
assert_eq!(
touch_set(remote_changed(), &tracked, &none, SyncScope::EntirePackage),
vec![PathBuf::from("have.csv"), PathBuf::from("added.csv")],
"whole-package scope takes it"
);
}
#[test]
fn a_never_installed_path_follows_the_same_rule() {
let never = vec![PathBuf::from("never-installed.csv")];
let tracked = tracked_map(["something-else.csv"]);
let none = ChangeSet::new();
assert!(touch_set(never.clone(), &tracked, &none, SyncScope::IndividualFiles).is_empty());
assert_eq!(
touch_set(never.clone(), &tracked, &none, SyncScope::EntirePackage),
never
);
}
#[test]
fn a_locally_changed_path_is_dropped_under_either_scope() {
let remote_changed = || vec![PathBuf::from("mine.csv")];
let tracked = tracked_map(["mine.csv"]);
let mine = ChangeSet::from([(
PathBuf::from("mine.csv"),
Change::Modified(ManifestRow::default()),
)]);
for scope in [SyncScope::IndividualFiles, SyncScope::EntirePackage] {
assert!(
touch_set(remote_changed(), &tracked, &mine, scope).is_empty(),
"{scope:?} must not overwrite the user's own change"
);
}
}
#[test(tokio::test)]
async fn a_never_installed_path_does_not_abort_the_verify_pass() -> crate::Res {
let storage = MockStorage::default();
let remote = MockRemote::default();
let bucket = "bkt";
let namespace: Namespace = ("f", "b").into();
let (base_hash, latest_hash) = ("OLD", "NEW");
let working_dir = PathBuf::from("/wd");
let never = PathBuf::from("never-installed.csv");
let paths = DomainPaths::default();
paths.scaffold_for_caching(&storage, bucket).await?;
let base = manifest_of(vec![ManifestRow {
logical_key: never.clone(),
..ManifestRow::default()
}]);
let latest_manifest = manifest_of(vec![]);
remote
.put_object(
None,
&S3Uri::try_from(format!("s3://{bucket}/.quilt/packages/{latest_hash}").as_str())?,
br#"{"version": "v0"}"#.to_vec(),
)
.await?;
let lineage = PackageLineage {
remote_uri: Some(ManifestUri {
bucket: bucket.to_string(),
namespace: namespace.clone(),
hash: base_hash.to_string(),
origin: None,
}),
base_hash: base_hash.to_string(),
latest_hash: latest_hash.to_string(),
paths: BTreeMap::new(),
..PackageLineage::default()
};
let snapshot = PullSnapshot {
status: InstalledPackageStatus::default(),
latest: ManifestUri {
bucket: bucket.to_string(),
namespace: namespace.clone(),
hash: latest_hash.to_string(),
origin: None,
},
latest_manifest,
};
let mut base = base;
let (lineage, _report) = pull_package(
lineage,
&mut base,
&paths,
&storage,
&remote,
working_dir,
snapshot,
namespace,
SyncScope::EntirePackage,
)
.await?;
assert_eq!(
lineage.base_hash, latest_hash,
"the pull completed instead of reporting drift on a file that was never written"
);
Ok(())
}
#[test(tokio::test)]
async fn touched_path_matching_base_passes_verify_and_pulls() -> crate::Res {
let storage = MockStorage::default();
let remote = MockRemote::default();
let bucket = "bkt";
let namespace: Namespace = ("f", "b").into();
let base_hash = "OLD";
let latest_hash = "NEW";
let working_dir = PathBuf::from("/wd");
let path = PathBuf::from("a");
let paths = DomainPaths::default();
paths.scaffold_for_caching(&storage, bucket).await?;
let content = b"the base content of a";
storage
.write_byte_stream(working_dir.join(&path), ByteStream::from_static(content))
.await?;
let file = storage.open_file(&working_dir.join(&path)).await?;
let hash: Multihash<256> = Sha256Hash::from_reader(file, content.len() as u64)
.await?
.into();
let base_row = ManifestRow {
logical_key: path.clone(),
hash: hash.try_into()?,
size: content.len() as u64,
..ManifestRow::default()
};
let base = manifest_of(vec![base_row]);
let latest_manifest = manifest_of(vec![]);
let latest_uri = ManifestUri {
bucket: bucket.to_string(),
namespace: namespace.clone(),
hash: latest_hash.to_string(),
origin: None,
};
remote
.put_object(
None,
&S3Uri::try_from(format!("s3://{bucket}/.quilt/packages/{latest_hash}").as_str())?,
br#"{"version": "v0"}"#.to_vec(),
)
.await?;
let lineage = PackageLineage {
remote_uri: Some(ManifestUri {
bucket: bucket.to_string(),
namespace: namespace.clone(),
hash: base_hash.to_string(),
origin: None,
}),
base_hash: base_hash.to_string(),
latest_hash: latest_hash.to_string(),
paths: BTreeMap::from([(path.clone(), PathState::default())]),
..PackageLineage::default()
};
let snapshot = PullSnapshot {
status: InstalledPackageStatus::default(),
latest: latest_uri,
latest_manifest,
};
let mut base = base;
let (lineage, _report) = pull_package(
lineage,
&mut base,
&paths,
&storage,
&remote,
working_dir.clone(),
snapshot,
namespace,
SyncScope::IndividualFiles,
)
.await?;
assert_eq!(lineage.base_hash, latest_hash);
assert!(!lineage.paths.contains_key(&path));
Ok(())
}
}