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"];
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);
let Some(name) = payload.file_name().and_then(|n| n.to_str()) else {
return Ok(None);
};
for ext in SIDECAR_EXTENSIONS {
let candidate = payload.with_file_name(format!("{name}.{ext}"));
if !self.fs().try_exists(&self.root().join(&candidate)).await? {
continue;
}
let (_, doc) = self.load(&candidate).await?;
if let Some(content) = doc.content_attr() {
let dir = candidate.parent().unwrap_or(Path::new(""));
if link::normalize(dir.join(content)) == payload {
return Ok(Some(candidate));
}
}
}
Ok(None)
}
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).await
}
pub(crate) async fn attach_titled(
&mut self,
payload: &Path,
parent: &Path,
title_override: Option<&str>,
) -> 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 !is_opaque_payload(&payload) {
return Err(Error::Structure(format!(
"{} is a prov document, not an opaque attachment — use `adopt`",
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::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}"
);
}
#[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![]);
}
}