use std::path::{Path, PathBuf};
use fig::Segment;
use crate::identity::{IdentityPolicy, Trigger};
use crate::validate::Finding;
use crate::workspace::Workspace;
use prov_graph::error::{Error, Result};
use prov_graph::fs::ReadStorage;
use prov_graph::index::IdIndex;
use prov_graph::link;
use prov_graph::manifest::{Manifest, ManifestEntry, manifest_sibling};
use prov_graph::meta::{Mapping, Value};
use prov_store::edit::MetaEditor;
use prov_store::fs::Storage;
use prov_store::index::IndexStore;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ManifestUpdate {
pub manifest: PathBuf,
pub added: Vec<PathBuf>,
pub removed: Vec<PathBuf>,
pub changed: Vec<PathBuf>,
}
impl ManifestUpdate {
pub fn is_clean(&self) -> bool {
self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestStatus {
pub node: PathBuf,
pub manifest: PathBuf,
pub root: PathBuf,
pub hashed: bool,
pub listed: usize,
pub missing: Vec<PathBuf>,
pub extra: Vec<PathBuf>,
}
impl ManifestStatus {
pub fn agrees(&self) -> bool {
self.missing.is_empty() && self.extra.is_empty()
}
}
impl<FS: ReadStorage, Id, Ix: IdIndex> Workspace<FS, Id, Ix> {
pub async fn manifest_node_for(&self, dir: &Path) -> Result<Option<PathBuf>> {
self.graph().manifest_node_for(dir).await
}
pub async fn manifest_of(&self, node: &Path) -> Result<Option<(PathBuf, Manifest)>> {
self.graph().manifest_of(node).await
}
pub async fn build_manifest(
&self,
manifest_doc: &Path,
root: &str,
hash: bool,
) -> Result<Manifest> {
let covered = link::resolve(manifest_doc, root);
let mut files = Vec::new();
for rel in self.graph().scan_covered(&covered).await? {
let hash = if hash {
let bytes = self
.read_bytes(&link::normalize(covered.join(&rel)))
.await?;
Some(crate::fixity::digest(&bytes))
} else {
None
};
files.push(ManifestEntry { path: rel, hash });
}
let mut manifest = Manifest {
root: root.to_string(),
files,
};
manifest.sort();
Ok(manifest)
}
pub async fn manifest_node_covering(&self, dir: &Path) -> Result<Option<PathBuf>> {
let _scope = self.read_scope();
let dir = link::normalize(dir);
if let Some(node) = self.manifest_node_for(&dir).await? {
return Ok(Some(node));
}
let Some(root) = self.root_document().await? else {
return Ok(None);
};
let walk = self.walk(&root).await?;
let reachable = self
.reachable_documents(&root, &walk.census, &walk.content_bodies)
.await?;
for doc in reachable {
if let Ok(Some((manifest_doc, manifest))) = self.manifest_of(&doc).await
&& manifest.covered_root(&manifest_doc) == dir
{
return Ok(Some(doc));
}
}
Ok(None)
}
pub async fn manifest_status(&self, node: &Path) -> Result<Option<ManifestStatus>> {
let Some((manifest_doc, manifest)) = self.manifest_of(node).await? else {
return Ok(None);
};
let root = manifest.checked_root(&manifest_doc)?;
let on_disk = self.graph().scan_covered(&root).await?;
let (missing, extra) = prov_graph::manifest::diff(&manifest.files, &on_disk);
Ok(Some(ManifestStatus {
node: node.to_path_buf(),
manifest: manifest_doc,
root,
hashed: manifest.is_hashed(),
listed: manifest.files.len(),
missing,
extra,
}))
}
pub async fn verify_manifest(&self, node: &Path) -> Result<Vec<Finding>> {
let Some((manifest_doc, manifest)) = self.manifest_of(node).await? else {
return Ok(Vec::new());
};
let mut findings = Vec::new();
for entry in &manifest.files {
let Some(recorded) = &entry.hash else {
continue;
};
if !crate::fixity::is_recognized(recorded) {
continue;
}
let path = manifest.file_path(&manifest_doc, entry);
let Ok(bytes) = self.read_bytes(&path).await else {
continue; };
let actual = crate::fixity::digest(&bytes);
if &actual != recorded {
findings.push(Finding::ManifestMismatch {
node: node.to_path_buf(),
manifest: manifest_doc.clone(),
path,
recorded: recorded.clone(),
actual,
});
}
}
Ok(findings)
}
pub(crate) async fn plan_manifest_rebuild(
&self,
node: &Path,
) -> Result<(ManifestUpdate, Vec<(PathBuf, String)>)> {
let node = link::normalize(node);
let Some((manifest_doc, current)) = self.manifest_of(&node).await? else {
return Err(Error::Structure(format!(
"{} declares no manifest",
node.display()
)));
};
let hashed = if current.files.is_empty() {
self.fixity().covers_payloads()
} else {
current.is_hashed()
};
let fresh = self
.build_manifest(&manifest_doc, ¤t.root, hashed)
.await?;
let old: std::collections::BTreeMap<&Path, Option<&String>> = current
.files
.iter()
.map(|e| (e.path.as_path(), e.hash.as_ref()))
.collect();
let new: std::collections::BTreeMap<&Path, Option<&String>> = fresh
.files
.iter()
.map(|e| (e.path.as_path(), e.hash.as_ref()))
.collect();
let mut update = ManifestUpdate {
manifest: manifest_doc.clone(),
..Default::default()
};
for (path, hash) in &new {
match old.get(path) {
None => update.added.push(path.to_path_buf()),
Some(before) if before != hash => update.changed.push(path.to_path_buf()),
Some(_) => {}
}
}
for path in old.keys() {
if !new.contains_key(path) {
update.removed.push(path.to_path_buf());
}
}
if update.is_clean() {
return Ok((update, Vec::new()));
}
let (_, manifest_parsed) = self.load(&manifest_doc).await?;
let title = manifest_parsed
.meta
.get("title")
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| link::path_to_title(&manifest_doc));
let format = self.default_embed_format();
let new_text = prov_graph::meta::serialize_mapping(&fresh.to_mapping(&title), format)?;
let mut writes = vec![(manifest_doc, new_text.clone())];
let (node_text, node_doc) = self.load(&node).await?;
if node_doc.meta.get("content_hash").is_some() || self.fixity().covers_payloads() {
let restamped = prov_store::edit::set_in_text(
&node_text,
node_doc.carrier,
"content_hash",
fig::Value::Str(crate::fixity::digest(new_text.as_bytes())),
)?;
writes.push((node, restamped));
}
Ok((update, writes))
}
}
impl<FS: Storage, IdP: IdentityPolicy, Ix: IndexStore> Workspace<FS, IdP, Ix> {
pub async fn attach_manifest(&mut self, dir: &Path, parent: &Path) -> Result<PathBuf> {
let hash = self.fixity().covers_payloads();
self.attach_manifest_titled(dir, parent, None, hash).await
}
pub async fn attach_manifest_titled(
&mut self,
dir: &Path,
parent: &Path,
title_override: Option<&str>,
hash: bool,
) -> Result<PathBuf> {
let dir = link::normalize(dir);
let parent = link::normalize(parent);
if !self
.graph()
.stat(&dir)
.await
.map(|m| m.is_dir())
.unwrap_or(false)
{
return Err(Error::Structure(format!(
"{} is not a directory — a manifest covers a directory of files; \
use `attach` for a single one",
dir.display()
)));
}
if let Some(existing) = self.manifest_node_covering(&dir).await? {
return Err(Error::Structure(format!(
"{} is already covered by the manifest node {}",
dir.display(),
existing.display()
)));
}
let (spanning, inverse) = self.spanning_pair()?;
let format = self.default_embed_format();
let node = crate::attach::sidecar_path(&dir, format);
let manifest_doc = manifest_sibling(&node);
for path in [&node, &manifest_doc] {
if self.exists(path).await? {
return Err(Error::AlreadyExists(path.to_path_buf()));
}
}
let (parent_text, parent_doc) = self.load(&parent).await?;
let title = title_override
.map(str::to_owned)
.unwrap_or_else(|| link::path_to_title(&dir));
let parent_title = parent_doc
.meta
.get("title")
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| link::path_to_title(&parent));
let root = format!(
"{}/",
link::relative(manifest_doc.parent().unwrap_or(Path::new("")), &dir)
);
let manifest = self.build_manifest(&manifest_doc, &root, hash).await?;
let manifest_text = prov_graph::meta::serialize_mapping(
&manifest.to_mapping(&format!("{title} — manifest")),
format,
)?;
let mut cs = self.change();
let up = self
.authored_target(&inverse, &node, &parent, &parent_title, true)
.await?;
let down = self
.authored_target(&spanning, &parent, &node, &title, false)
.await?;
let mut map = Mapping::new();
map.insert("title".into(), Value::String(title));
map.insert(inverse.clone(), Value::String(up));
map.insert(
prov_graph::manifest::MANIFEST_KEY.into(),
Value::String(
manifest_doc
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string(),
),
);
if self.fixity().covers_payloads() {
map.insert(
"content_hash".into(),
Value::String(crate::fixity::digest(manifest_text.as_bytes())),
);
}
let node_text = prov_graph::meta::serialize_mapping(&map, format)?;
let mut parent_editor = MetaEditor::open_or_init(&parent_text, parent_doc.carrier)?;
let span_path = [Segment::Key(&spanning)];
if parent_editor
.append_value(&span_path, fig::Value::Str(down.clone()))
.is_err()
{
parent_editor.set_value(&span_path, fig::Value::Seq(vec![fig::Value::Str(down)]))?;
}
let parent_out = parent_editor.render()?;
cs.write(&manifest_doc, manifest_text);
cs.write(&node, node_text);
cs.write(&parent, parent_out);
if self.identity().registration().fires_on(Trigger::Create)
&& self.index().id_for_path(&node).is_none()
{
let id = self.mint_unique(&node);
self.index_mut().register(&id, &node);
}
self.commit(cs).await?;
Ok(node)
}
pub async fn update_manifest(&mut self, node: &Path) -> Result<ManifestUpdate> {
let (update, writes) = self.plan_manifest_rebuild(node).await?;
if writes.is_empty() {
return Ok(update);
}
let mut cs = self.change();
for (path, text) in writes {
cs.write(&path, text);
}
self.commit(cs).await?;
Ok(update)
}
}
#[cfg(all(test, feature = "yaml"))]
mod tests {
use super::*;
use prov_graph::exec::block_on;
use prov_graph::fs::StdFs;
fn write(dir: &Path, rel: &str, bytes: &[u8]) {
let p = dir.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, bytes).unwrap();
}
fn read(dir: &Path, rel: &str) -> String {
std::fs::read_to_string(dir.join(rel)).unwrap()
}
fn tempdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("prov-manifest-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn ws(dir: &Path) -> Workspace<StdFs> {
Workspace::builder(StdFs).root(dir).build()
}
fn photos(tag: &str) -> PathBuf {
let dir = tempdir(tag);
write(&dir, "index.md", b"---\ntitle: Home\n---\n");
write(&dir, "photos/a.jpg", &[0xff, 0xd8, 0x01]);
write(&dir, "photos/2019/b.jpg", &[0xff, 0xd8, 0x02]);
dir
}
#[test]
fn one_node_and_one_manifest_stand_for_a_whole_directory() {
let dir = photos("basic");
let node =
block_on(ws(&dir).attach_manifest(Path::new("photos"), Path::new("index.md"))).unwrap();
assert_eq!(node, PathBuf::from("photos.yaml"));
let node_text = read(&dir, "photos.yaml");
assert!(
node_text.contains("manifest: photos.manifest.yaml"),
"{node_text}"
);
assert!(
!node_text.contains("content:"),
"a manifest node has no payload: {node_text}"
);
let manifest = read(&dir, "photos.manifest.yaml");
assert!(manifest.contains("root: photos/"), "{manifest}");
assert!(manifest.contains("path: a.jpg"), "{manifest}");
assert!(manifest.contains("path: 2019/b.jpg"), "{manifest}");
assert!(
manifest.contains(&crate::fixity::digest(&[0xff, 0xd8, 0x02])),
"{manifest}"
);
assert!(
node_text.contains(&crate::fixity::digest(manifest.as_bytes())),
"{node_text}"
);
assert!(read(&dir, "index.md").contains("photos.yaml"));
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[test]
fn ten_thousand_photos_cost_two_files() {
let dir = tempdir("scale");
write(&dir, "index.md", b"---\ntitle: Home\n---\n");
for i in 0..500 {
write(&dir, &format!("photos/{i:04}.jpg"), &[0xff, i as u8]);
}
block_on(ws(&dir).attach_manifest(Path::new("photos"), Path::new("index.md"))).unwrap();
let created = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().unwrap().is_file())
.count();
assert_eq!(created, 3, "index.md + the node + the manifest");
assert_eq!(
block_on(ws(&dir).manifest_of(Path::new("photos.yaml")))
.unwrap()
.unwrap()
.1
.files
.len(),
500
);
}
#[test]
fn a_document_among_the_photos_is_not_claimed() {
let dir = photos("readable");
write(&dir, "photos/note.md", b"---\ntitle: Note\n---\nhi\n");
block_on(ws(&dir).attach_manifest(Path::new("photos"), Path::new("index.md"))).unwrap();
let manifest = read(&dir, "photos.manifest.yaml");
assert!(!manifest.contains("note.md"), "{manifest}");
}
#[test]
fn refusing_a_second_manifest_over_the_same_directory() {
let dir = photos("twice");
block_on(ws(&dir).attach_manifest(Path::new("photos"), Path::new("index.md"))).unwrap();
let err = block_on(ws(&dir).attach_manifest(Path::new("photos"), Path::new("index.md")))
.unwrap_err();
assert!(err.to_string().contains("already covered"), "{err}");
}
#[test]
fn a_single_file_is_not_a_manifest_subject() {
let dir = photos("notadir");
let err =
block_on(ws(&dir).attach_manifest(Path::new("photos/a.jpg"), Path::new("index.md")))
.unwrap_err();
assert!(err.to_string().contains("not a directory"), "{err}");
}
#[test]
fn update_catches_additions_removals_and_edits_and_restamps_the_node() {
let dir = photos("update");
block_on(ws(&dir).attach_manifest(Path::new("photos"), Path::new("index.md"))).unwrap();
write(&dir, "photos/c.jpg", &[0xff, 0xd8, 0x03]); std::fs::remove_file(dir.join("photos/a.jpg")).unwrap(); write(&dir, "photos/2019/b.jpg", &[0xff, 0xd8, 0x99]);
let mut w = ws(&dir);
let update = block_on(w.update_manifest(Path::new("photos.yaml"))).unwrap();
assert_eq!(update.added, vec![PathBuf::from("c.jpg")]);
assert_eq!(update.removed, vec![PathBuf::from("a.jpg")]);
assert_eq!(update.changed, vec![PathBuf::from("2019/b.jpg")]);
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[test]
fn an_unchanged_directory_rewrites_nothing() {
let dir = photos("noop");
block_on(ws(&dir).attach_manifest(Path::new("photos"), Path::new("index.md"))).unwrap();
let before = read(&dir, "photos.manifest.yaml");
let mut w = ws(&dir);
let update = block_on(w.update_manifest(Path::new("photos.yaml"))).unwrap();
assert!(update.is_clean());
assert_eq!(read(&dir, "photos.manifest.yaml"), before);
}
#[test]
fn an_inventory_stays_an_inventory_across_a_refresh() {
let dir = photos("unhashed");
block_on(ws(&dir).attach_manifest_titled(
Path::new("photos"),
Path::new("index.md"),
None,
false,
))
.unwrap();
assert!(!read(&dir, "photos.manifest.yaml").contains("hash:"));
write(&dir, "photos/c.jpg", &[0xff, 0xd8, 0x03]);
let mut w = ws(&dir);
block_on(w.update_manifest(Path::new("photos.yaml"))).unwrap();
assert!(
!read(&dir, "photos.manifest.yaml").contains("hash:"),
"a refresh must not silently start recording a baseline"
);
}
#[test]
fn deep_verification_finds_a_rotted_photo_that_check_cannot() {
let dir = photos("deep");
block_on(ws(&dir).attach_manifest(Path::new("photos"), Path::new("index.md"))).unwrap();
write(&dir, "photos/a.jpg", &[0xff, 0xd8, 0x77]);
assert_eq!(
block_on(ws(&dir).check("index.md")).unwrap(),
vec![],
"the cheap pass sees a present, listed file"
);
let deep = block_on(ws(&dir).verify_manifest(Path::new("photos.yaml"))).unwrap();
assert!(
matches!(&deep[..], [Finding::ManifestMismatch { path, .. }]
if path == Path::new("photos/a.jpg")),
"{deep:?}"
);
}
#[test]
fn check_reports_drift_in_both_directions_and_the_fix_accepts_it() {
let dir = photos("drift");
block_on(ws(&dir).attach_manifest(Path::new("photos"), Path::new("index.md"))).unwrap();
write(&dir, "photos/c.jpg", &[0xff, 0xd8, 0x03]);
std::fs::remove_file(dir.join("photos/2019/b.jpg")).unwrap();
let mut w = ws(&dir);
let findings = block_on(w.check("index.md")).unwrap();
let drift = findings
.iter()
.find_map(|f| match f {
Finding::ManifestDrift { missing, extra, .. } => {
Some((missing.clone(), extra.clone()))
}
_ => None,
})
.unwrap_or_else(|| panic!("expected drift, got {findings:?}"));
assert_eq!(drift.0, vec![PathBuf::from("2019/b.jpg")]);
assert_eq!(drift.1, vec![PathBuf::from("c.jpg")]);
let finding = findings
.iter()
.find(|f| matches!(f, Finding::ManifestDrift { .. }))
.unwrap();
let remedies = block_on(w.remedies(finding)).unwrap();
assert_eq!(remedies[0].warrant, crate::remedy::Warrant::Judgment);
block_on(w.apply_fix(&remedies[0].fix)).unwrap();
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
assert!(read(&dir, "photos.manifest.yaml").contains("path: c.jpg"));
}
#[test]
fn tampering_with_the_manifest_breaks_the_nodes_pin() {
let dir = photos("pin");
block_on(ws(&dir).attach_manifest(Path::new("photos"), Path::new("index.md"))).unwrap();
let manifest = read(&dir, "photos.manifest.yaml");
std::fs::write(
dir.join("photos.manifest.yaml"),
manifest.replace("sha256:", "sha256:0"),
)
.unwrap();
let tampered = read(&dir, "photos.manifest.yaml");
let findings = block_on(ws(&dir).check("index.md")).unwrap();
assert!(
findings.iter().any(|f| matches!(
f,
Finding::FixityMismatch { doc, actual, .. }
if doc == Path::new("photos.yaml")
&& actual == &crate::fixity::digest(tampered.as_bytes())
)),
"{findings:?}"
);
}
#[test]
fn a_manifest_that_will_not_parse_is_its_own_finding() {
let dir = photos("malformed");
block_on(ws(&dir).attach_manifest(Path::new("photos"), Path::new("index.md"))).unwrap();
write(
&dir,
"photos.manifest.yaml",
b"title: M\nroot: photos/\nfiles:\n- hash: sha256:x\n",
);
let findings = block_on(ws(&dir).check("index.md")).unwrap();
assert!(
findings
.iter()
.any(|f| matches!(f, Finding::ManifestMalformed { doc, .. }
if doc == Path::new("photos.manifest.yaml"))),
"{findings:?}"
);
assert!(
!findings
.iter()
.any(|f| matches!(f, Finding::ManifestDrift { .. })),
"{findings:?}"
);
}
#[test]
fn a_node_may_not_be_a_payloads_sidecar_and_a_directorys_at_once() {
let dir = photos("conflict");
write(
&dir,
"index.md",
b"---\ntitle: Home\ncontents:\n- both.yaml\n---\n",
);
write(
&dir,
"both.yaml",
b"title: Both\npart_of: index.md\ncontent: photos/a.jpg\nmanifest: both.manifest.yaml\n",
);
write(
&dir,
"both.manifest.yaml",
b"title: M\nroot: photos/\nfiles:\n",
);
let findings = block_on(ws(&dir).check("index.md")).unwrap();
assert!(
findings.iter().any(
|f| matches!(f, Finding::ManifestConflict { doc } if doc == Path::new("both.yaml"))
),
"{findings:?}"
);
}
#[test]
fn a_covered_directory_is_invisible_to_the_loose_sweeps() {
let dir = photos("loose");
write(&dir, "loose.pdf", b"%PDF-1.7\n");
block_on(ws(&dir).attach_manifest(Path::new("photos"), Path::new("index.md"))).unwrap();
assert_eq!(
block_on(ws(&dir).loose_attachments()).unwrap(),
vec![PathBuf::from("loose.pdf")],
"the recursive sweep walks into no covered directory"
);
assert_eq!(
block_on(ws(&dir).loose_attachments_in(Path::new("index.md"))).unwrap(),
vec![PathBuf::from("loose.pdf")]
);
let err = block_on(ws(&dir).attach(Path::new("photos/a.jpg"), Path::new("index.md")))
.unwrap_err();
assert!(err.to_string().contains("already covered"), "{err}");
}
#[test]
fn renaming_the_node_moves_its_manifest_and_leaves_the_archive_alone() {
let dir = photos("rename");
block_on(ws(&dir).attach_manifest(Path::new("photos"), Path::new("index.md"))).unwrap();
block_on(ws(&dir).rename(Path::new("photos.yaml"), Path::new("albums/trip.yaml"))).unwrap();
assert!(dir.join("albums/trip.manifest.yaml").exists());
assert!(!dir.join("photos.manifest.yaml").exists());
assert!(
dir.join("photos/a.jpg").exists(),
"a rename of the description must not move the archive"
);
let node = read(&dir, "albums/trip.yaml");
assert!(node.contains("manifest: trip.manifest.yaml"), "{node}");
let manifest = read(&dir, "albums/trip.manifest.yaml");
assert!(
manifest.contains("root: ../photos/"),
"root re-spelled from where the manifest now sits: {manifest}"
);
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[test]
fn a_renamed_node_still_covers_its_directory() {
let dir = photos("renamed-cover");
block_on(ws(&dir).attach_manifest(Path::new("photos"), Path::new("index.md"))).unwrap();
block_on(ws(&dir).rename(Path::new("photos.yaml"), Path::new("albums/trip.yaml"))).unwrap();
assert_eq!(
block_on(ws(&dir).manifest_node_for(Path::new("photos"))).unwrap(),
None,
"the probe cannot see it — which is why the verbs do not use it"
);
assert_eq!(
block_on(ws(&dir).manifest_node_covering(Path::new("photos"))).unwrap(),
Some(PathBuf::from("albums/trip.yaml"))
);
let err = block_on(ws(&dir).attach_manifest(Path::new("photos"), Path::new("index.md")))
.unwrap_err();
assert!(err.to_string().contains("already covered"), "{err}");
}
#[test]
fn deleting_the_node_deletes_the_manifest_and_keeps_the_photographs() {
let dir = photos("delete");
block_on(ws(&dir).attach_manifest(Path::new("photos"), Path::new("index.md"))).unwrap();
block_on(ws(&dir).delete(Path::new("photos.yaml"), false)).unwrap();
assert!(!dir.join("photos.yaml").exists());
assert!(!dir.join("photos.manifest.yaml").exists());
assert!(
dir.join("photos/a.jpg").exists() && dir.join("photos/2019/b.jpg").exists(),
"deleting a description is not deleting the archive"
);
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[test]
fn the_reachable_set_holds_the_manifest_and_not_the_archive() {
let dir = photos("reachable");
block_on(ws(&dir).attach_manifest(Path::new("photos"), Path::new("index.md"))).unwrap();
let reachable = block_on(ws(&dir).reachable_files("index.md")).unwrap();
assert!(reachable.contains(Path::new("photos.manifest.yaml")));
assert!(reachable.contains(Path::new("photos.yaml")));
assert!(
!reachable.contains(Path::new("photos/a.jpg")),
"an archive must not be duplicated into the history store"
);
}
#[test]
fn a_manifest_node_has_no_copy() {
let dir = photos("duplicate");
block_on(ws(&dir).attach_manifest(Path::new("photos"), Path::new("index.md"))).unwrap();
let err = block_on(ws(&dir).duplicate(Path::new("photos.yaml"))).unwrap_err();
assert!(err.to_string().contains("no copy"), "{err}");
}
#[test]
fn an_unhashed_manifest_promises_nothing_about_bytes() {
let dir = photos("deep-unhashed");
block_on(ws(&dir).attach_manifest_titled(
Path::new("photos"),
Path::new("index.md"),
None,
false,
))
.unwrap();
write(&dir, "photos/a.jpg", &[0xff, 0xd8, 0x77]);
assert_eq!(
block_on(ws(&dir).verify_manifest(Path::new("photos.yaml"))).unwrap(),
vec![]
);
}
}