use std::collections::BTreeSet;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use fig::Segment;
use crate::document::{is_opaque_payload, whole_file_extension};
use crate::edit::MetaEditor;
use crate::error::{Error, Result};
use crate::fs::Storage;
use crate::identity::{IdentityPolicy, Trigger};
use crate::index::IndexStore;
use crate::link;
use crate::meta::{Mapping, Value};
use crate::workspace::Workspace;
const SIDECAR_EXTENSIONS: &[&str] = &["yaml", "yml", "json", "toml", "fig", "figl"];
pub(crate) fn sidecar_candidates(payload: &Path) -> impl Iterator<Item = PathBuf> + '_ {
let name = payload
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
SIDECAR_EXTENSIONS
.iter()
.map(move |ext| payload.with_file_name(format!("{name}.{ext}")))
}
fn sidecar_path(payload: &Path, format: fig::Format) -> PathBuf {
let ext = whole_file_extension(format);
let name = payload
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
payload.with_file_name(format!("{name}.{ext}"))
}
impl<FS: Storage, Id, Ix: IndexStore> Workspace<FS, Id, Ix> {
pub async fn attachment_for(&self, payload: &Path) -> Result<Option<PathBuf>> {
let payload = link::normalize(payload);
for candidate in sidecar_candidates(&payload) {
if !self.fs().try_exists(&self.root().join(&candidate)).await? {
continue;
}
if self.sidecar_claims(&candidate, &payload).await {
return Ok(Some(candidate));
}
}
Ok(None)
}
async fn sidecar_claims(&self, candidate: &Path, payload: &Path) -> bool {
let Ok((_, doc)) = self.load(candidate).await else {
return false;
};
let Some(content) = doc.content_attr() else {
return false;
};
let dir = candidate.parent().unwrap_or(Path::new(""));
doc.is_attachment() && link::normalize(dir.join(content)) == payload
}
pub(crate) async fn is_shadowed_payload(
&self,
path: &Path,
listing: &BTreeSet<PathBuf>,
) -> bool {
for candidate in sidecar_candidates(path) {
if listing.contains(&candidate) && self.sidecar_claims(&candidate, path).await {
return true;
}
}
false
}
pub async fn loose_attachments(&self) -> Result<Vec<PathBuf>> {
let mut found = Vec::new();
self.scan_loose(PathBuf::new(), &mut found).await?;
found.sort();
Ok(found)
}
pub async fn loose_attachments_in(&self, start: &Path) -> Result<Vec<PathBuf>> {
let mut reachable: BTreeSet<PathBuf> = BTreeSet::new();
reachable.insert(link::normalize(start));
for entry in self.census(start).await? {
if let Some(p) = entry.resolution.resolved_path() {
reachable.insert(p.clone());
}
}
let reached_dirs = Self::reached_dirs(&reachable);
let mut found = Vec::new();
for file in self.direct_child_files(&reached_dirs).await? {
if is_opaque_payload(&file) && self.attachment_for(&file).await?.is_none() {
found.push(file);
}
}
found.sort();
Ok(found)
}
fn scan_loose<'a>(
&'a self,
rel_dir: PathBuf,
out: &'a mut Vec<PathBuf>,
) -> Pin<Box<dyn Future<Output = Result<()>> + 'a>> {
Box::pin(async move {
let Ok(entries) = self.fs().read_dir(&self.root().join(&rel_dir)).await else {
return Ok(());
};
for entry in entries {
let Some(name) = entry
.file_name()
.and_then(|n| n.to_str())
.map(str::to_owned)
else {
continue;
};
if name.starts_with('.') {
continue;
}
let rel = if rel_dir.as_os_str().is_empty() {
PathBuf::from(&name)
} else {
rel_dir.join(&name)
};
if entry.file_type().is_dir() {
self.scan_loose(rel, out).await?;
} else if entry.file_type().is_file()
&& is_opaque_payload(&rel)
&& self.attachment_for(&rel).await?.is_none()
{
out.push(rel);
}
}
Ok(())
})
}
}
impl<FS: Storage, IdP: IdentityPolicy, Ix: IndexStore> Workspace<FS, IdP, Ix> {
pub async fn attach(&mut self, payload: &Path, parent: &Path) -> Result<PathBuf> {
self.attach_titled(payload, parent, None, false).await
}
pub async fn attach_opaque(&mut self, payload: &Path, parent: &Path) -> Result<PathBuf> {
self.attach_titled(payload, parent, None, true).await
}
pub(crate) async fn attach_titled(
&mut self,
payload: &Path,
parent: &Path,
title_override: Option<&str>,
opaque: bool,
) -> Result<PathBuf> {
let payload = link::normalize(payload);
let parent = link::normalize(parent);
if !self.fs().try_exists(&self.root().join(&payload)).await? {
return Err(Error::NotFound(payload.to_path_buf()));
}
if !opaque && !is_opaque_payload(&payload) {
return Err(Error::Structure(format!(
"{} is a prov document, not an opaque attachment — use `adopt`, \
or `--opaque` to shadow it unread",
payload.display()
)));
}
let (spanning, inverse) = self.spanning_pair()?;
let format = self.default_embed_format();
let node = sidecar_path(&payload, format);
if self.fs().try_exists(&self.root().join(&node)).await? {
return Err(Error::AlreadyExists(node.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(&payload));
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 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 payload_ref = payload
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
let mut map = Mapping::new();
map.insert("title".into(), Value::String(title));
map.insert(inverse.clone(), Value::String(up));
map.insert("content".into(), Value::String(payload_ref));
map.insert("attachment".into(), Value::Bool(true));
if self.fixity().covers_payloads() {
let bytes = self.fs().read(&self.root().join(&payload)).await?;
map.insert(
"content_hash".into(),
Value::String(crate::fixity::digest(&bytes)),
);
}
let node_text = crate::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(&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)
}
}
#[cfg(all(test, feature = "yaml"))]
mod tests {
use super::*;
use crate::exec::block_on;
use crate::fs::StdFs;
use crate::title::TitleMatch;
use crate::validate::Finding;
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-attach-{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()
}
#[test]
fn attach_gives_a_binary_a_linked_metadata_sidecar() {
let dir = tempdir("basic");
write(&dir, "index.md", b"---\ntitle: Home\n---\n");
write(&dir, "photo.jpg", &[0xff, 0xd8, 0xff, 0xe0, 0x00]);
let node =
block_on(ws(&dir).attach(Path::new("photo.jpg"), Path::new("index.md"))).unwrap();
assert_eq!(node, PathBuf::from("photo.jpg.yaml"));
let sidecar = read(&dir, "photo.jpg.yaml");
assert!(sidecar.contains("title: Photo"), "{sidecar}");
assert!(
sidecar.contains("content: photo.jpg"),
"points at the payload: {sidecar}"
);
assert!(
sidecar.contains("attachment: true"),
"flagged as an attachment: {sidecar}"
);
assert!(
sidecar.contains("index.md"),
"inverse link up to the parent: {sidecar}"
);
let index = read(&dir, "index.md");
assert!(index.contains("photo.jpg.yaml"), "{index}");
assert!(
!index.contains("[photo.jpg]") && !index.contains("(photo.jpg)"),
"{index}"
);
assert_eq!(
std::fs::read(dir.join("photo.jpg")).unwrap(),
[0xff, 0xd8, 0xff, 0xe0, 0x00]
);
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[test]
fn attach_records_a_payload_checksum_that_check_verifies() {
let dir = tempdir("fixity-record");
write(&dir, "index.md", b"---\ntitle: Home\n---\n");
let payload: &[u8] = &[0xff, 0xd8, 0xff, 0xe0, 0x01, 0x02, 0x03];
write(&dir, "photo.jpg", payload);
block_on(ws(&dir).attach(Path::new("photo.jpg"), Path::new("index.md"))).unwrap();
let sidecar = read(&dir, "photo.jpg.yaml");
let expected = crate::fixity::digest(payload);
assert!(
sidecar.contains(&format!("content_hash: {expected}")),
"{sidecar}"
);
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[test]
fn check_catches_a_corrupted_payload() {
let dir = tempdir("fixity-rot");
write(&dir, "index.md", b"---\ntitle: Home\n---\n");
write(&dir, "photo.jpg", &[0xff, 0xd8, 0xff, 0xe0, 0x01]);
block_on(ws(&dir).attach(Path::new("photo.jpg"), Path::new("index.md"))).unwrap();
write(&dir, "photo.jpg", &[0xff, 0xd8, 0xff, 0xe0, 0x99]);
let findings = block_on(ws(&dir).check("index.md")).unwrap();
assert!(
findings.iter().any(|f| matches!(
f,
Finding::FixityMismatch { doc, .. } if doc == Path::new("photo.jpg.yaml")
)),
"expected a fixity mismatch, got: {findings:?}"
);
}
#[test]
fn restamping_accepts_a_changed_payload_and_clears_the_finding() {
let dir = tempdir("fixity-restamp");
write(&dir, "index.md", b"---\ntitle: Home\n---\n");
write(&dir, "photo.jpg", &[0x01, 0x02, 0x03]);
block_on(ws(&dir).attach(Path::new("photo.jpg"), Path::new("index.md"))).unwrap();
write(&dir, "photo.jpg", &[0x04, 0x05, 0x06]);
let mut w = ws(&dir);
let finding = block_on(w.check("index.md"))
.unwrap()
.into_iter()
.find(|f| matches!(f, Finding::FixityMismatch { .. }))
.expect("a mismatch to re-stamp");
let fix = block_on(w.suggest_fix(&finding))
.unwrap()
.expect("a re-stamp fix");
block_on(w.apply_fix(&fix)).unwrap();
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
assert!(read(&dir, "photo.jpg.yaml").contains(&crate::fixity::digest(&[0x04, 0x05, 0x06])));
}
#[test]
fn fixity_off_records_no_checksum() {
let dir = tempdir("fixity-off");
write(&dir, "index.md", b"---\ntitle: Home\n---\n");
write(&dir, "photo.jpg", &[0x01, 0x02, 0x03]);
let w = || {
Workspace::builder(StdFs)
.root(&dir)
.fixity(crate::config::Fixity::Off)
.build()
};
block_on(w().attach(Path::new("photo.jpg"), Path::new("index.md"))).unwrap();
assert!(
!read(&dir, "photo.jpg.yaml").contains("content_hash"),
"off records nothing"
);
assert_eq!(block_on(w().check("index.md")).unwrap(), vec![]);
}
#[test]
fn attachment_for_finds_the_sidecar_and_refuses_a_document_payload() {
let dir = tempdir("lookup");
write(&dir, "index.md", b"---\ntitle: Home\n---\n");
write(&dir, "assets/logo.png", &[0x89, 0x50, 0x4e, 0x47]);
assert!(
block_on(ws(&dir).attachment_for(Path::new("assets/logo.png")))
.unwrap()
.is_none()
);
block_on(ws(&dir).attach(Path::new("assets/logo.png"), Path::new("index.md"))).unwrap();
assert_eq!(
block_on(ws(&dir).attachment_for(Path::new("assets/logo.png"))).unwrap(),
Some(PathBuf::from("assets/logo.png.yaml"))
);
write(&dir, "note.md", b"---\ntitle: Note\n---\nbody\n");
let err =
block_on(ws(&dir).attach(Path::new("note.md"), Path::new("index.md"))).unwrap_err();
assert!(
err.to_string().contains("not an opaque attachment"),
"{err}"
);
}
const SPECIMEN: &[u8] = b"---\ntitle: How this workspace is organized\nid: fpk38j\n\
contents:\n- some-other-workspace.md\n---\n# Example\n";
#[test]
fn attach_opaque_shadows_a_readable_document_without_touching_it() {
let dir = tempdir("opaque");
write(&dir, "index.md", b"---\ntitle: Home\n---\n");
write(&dir, "examples/sample.md", SPECIMEN);
let node = block_on(
ws(&dir).attach_opaque(Path::new("examples/sample.md"), Path::new("index.md")),
)
.unwrap();
assert_eq!(node, PathBuf::from("examples/sample.md.yaml"));
let sidecar = read(&dir, "examples/sample.md.yaml");
assert!(sidecar.contains("attachment: true"), "{sidecar}");
assert!(sidecar.contains("content: sample.md"), "{sidecar}");
assert!(
sidecar.contains(&crate::fixity::digest(SPECIMEN)),
"{sidecar}"
);
assert_eq!(
std::fs::read(dir.join("examples/sample.md")).unwrap(),
SPECIMEN
);
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
#[test]
fn a_shadowed_payloads_title_and_id_stay_out_of_the_scans() {
let dir = tempdir("opaque-scans");
write(&dir, "index.md", b"---\ntitle: Home\n---\n");
write(&dir, "examples/sample.md", SPECIMEN);
assert_eq!(
block_on(ws(&dir).title_index())
.unwrap()
.resolve("How this workspace is organized"),
TitleMatch::Unique(PathBuf::from("examples/sample.md"))
);
assert_eq!(block_on(ws(&dir).scan_ids()).unwrap().len(), 1);
block_on(ws(&dir).attach_opaque(Path::new("examples/sample.md"), Path::new("index.md")))
.unwrap();
for index in [
block_on(ws(&dir).title_index()).unwrap(),
block_on(ws(&dir).title_index_scoped(Path::new("index.md"))).unwrap(),
] {
assert_eq!(
index.resolve("How this workspace is organized"),
TitleMatch::Unknown,
"a specimen's title must not answer an alias"
);
assert_eq!(index.resolve("sample"), TitleMatch::Unknown, "nor its stem");
}
assert_eq!(
block_on(ws(&dir).scan_ids()).unwrap(),
vec![],
"an example id is not a claim on the registry"
);
}
const HAZARDOUS_SPECIMEN: &[u8] = b"---\ntitle: A Captured Export\naudience: someone-else\n\
content_hash: sha256:0000000000000000000000000000000000000000000000000000000000000000\n\
---\n# Example\n";
#[test]
fn attach_opaque_shadows_a_payloads_own_vocabulary_and_fixity_findings() {
let dir = tempdir("opaque-hazards");
write(
&dir,
"index.md",
b"---\ntitle: Home\n\
prov:\n fields:\n audience:\n values: closed\n vocabulary: vocab/audiences.yaml\n\
---\n",
);
write(
&dir,
"vocab/audiences.yaml",
b"title: Audiences\npart_of: /index.md\nvocabulary:\n field: audience\n values: closed\n\
terms:\n public: {}\n friends: {}\n",
);
write(&dir, "examples/sample.md", HAZARDOUS_SPECIMEN);
let mut w = ws(&dir);
block_on(w.attach_opaque(Path::new("examples/sample.md"), Path::new("index.md"))).unwrap();
let findings = block_on(w.check("index.md")).unwrap();
assert_eq!(findings, vec![], "{findings:?}");
assert_eq!(
std::fs::read(dir.join("examples/sample.md")).unwrap(),
HAZARDOUS_SPECIMEN,
"no fix may have rewritten the exhibit"
);
}
#[test]
fn the_same_hazards_adopted_readably_are_still_caught() {
let dir = tempdir("opaque-hazards-control");
write(
&dir,
"index.md",
b"---\ntitle: Home\n\
prov:\n fields:\n audience:\n values: closed\n vocabulary: vocab/audiences.yaml\n\
---\n",
);
write(
&dir,
"vocab/audiences.yaml",
b"title: Audiences\npart_of: /index.md\nvocabulary:\n field: audience\n values: closed\n\
terms:\n public: {}\n friends: {}\n",
);
write(&dir, "examples/sample.md", HAZARDOUS_SPECIMEN);
let mut w = ws(&dir);
block_on(w.adopt(Path::new("examples/sample.md"), Path::new("index.md"))).unwrap();
let findings = block_on(w.check("index.md")).unwrap();
assert!(
findings.iter().any(|f| matches!(
f,
Finding::UnknownTerm { doc, field, value, .. }
if doc == Path::new("examples/sample.md")
&& field == "audience"
&& value == "someone-else"
)),
"{findings:?}"
);
assert!(
findings.iter().any(|f| matches!(
f,
Finding::FixityMismatch { doc, .. } if doc == Path::new("examples/sample.md")
)),
"{findings:?}"
);
}
#[test]
fn a_separated_prose_body_is_not_shadowed() {
let dir = tempdir("opaque-separated");
write(
&dir,
"index.md",
b"---\ntitle: Home\ncontents:\n- note.yaml\n---\n",
);
write(
&dir,
"note.yaml",
b"title: Split Note\npart_of: index.md\ncontent: note.md\n",
);
write(&dir, "note.md", b"# Split Note\n");
assert!(
matches!(
block_on(ws(&dir).title_index()).unwrap().resolve("note"),
TitleMatch::Ambiguous(paths) if paths.contains(&PathBuf::from("note.md"))
),
"a separated body is still a document the scans read"
);
}
#[test]
fn loose_attachments_lists_only_unsidecarred_binaries() {
let dir = tempdir("loose");
write(&dir, "index.md", b"---\ntitle: Home\n---\n");
write(&dir, "a.pdf", b"%PDF-1.7\n");
write(&dir, "sub/b.png", &[0x89, 0x50]);
write(&dir, "sub/note.md", b"---\ntitle: Note\n---\n");
let mut loose = block_on(ws(&dir).loose_attachments()).unwrap();
loose.sort();
assert_eq!(
loose,
vec![PathBuf::from("a.pdf"), PathBuf::from("sub/b.png")]
);
block_on(ws(&dir).attach(Path::new("a.pdf"), Path::new("index.md"))).unwrap();
assert_eq!(
block_on(ws(&dir).loose_attachments()).unwrap(),
vec![PathBuf::from("sub/b.png")]
);
}
#[test]
fn renaming_a_sidecar_moves_its_payload_and_keeps_content_correct() {
let dir = tempdir("rename");
write(&dir, "index.md", b"---\ntitle: Home\n---\n");
write(&dir, "photo.jpg", &[0xff, 0xd8]);
block_on(ws(&dir).attach(Path::new("photo.jpg"), Path::new("index.md"))).unwrap();
block_on(ws(&dir).rename(
Path::new("photo.jpg.yaml"),
Path::new("media/hero.jpg.yaml"),
))
.unwrap();
assert!(
dir.join("media/hero.jpg").exists(),
"payload moved beside the sidecar"
);
assert!(!dir.join("photo.jpg").exists(), "old payload gone");
assert!(
read(&dir, "media/hero.jpg.yaml").contains("content: hero.jpg"),
"content repointed"
);
assert_eq!(block_on(ws(&dir).check("index.md")).unwrap(), vec![]);
}
}