use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::meta::{Mapping, Value};
pub const MANIFEST_KEY: &str = "manifest";
pub const ROOT_KEY: &str = "root";
pub const FILES_KEY: &str = "files";
pub const PATH_KEY: &str = "path";
pub const HASH_KEY: &str = "hash";
pub const MANIFEST_INFIX: &str = "manifest";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestEntry {
pub path: PathBuf,
pub hash: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Manifest {
pub root: String,
pub files: Vec<ManifestEntry>,
}
impl Manifest {
pub fn from_meta(meta: &Value) -> Result<Self> {
let root = meta
.get(ROOT_KEY)
.and_then(Value::as_str)
.ok_or_else(|| Error::Structure(format!("manifest has no `{ROOT_KEY}`")))?
.to_string();
let rows = match meta.get(FILES_KEY) {
None => &[][..],
Some(Value::Null) => &[][..],
Some(value) => value.as_sequence().ok_or_else(|| {
Error::Structure(format!("manifest `{FILES_KEY}` must be a sequence"))
})?,
};
let mut files = Vec::with_capacity(rows.len());
for (i, row) in rows.iter().enumerate() {
let path = row
.get(PATH_KEY)
.and_then(Value::as_str)
.ok_or_else(|| Error::Structure(format!("manifest row {i} has no `{PATH_KEY}`")))?;
if crate::link::escapes_root(path) {
return Err(Error::Structure(format!(
"manifest row {i} (`{path}`) climbs outside the manifest's root"
)));
}
files.push(ManifestEntry {
path: crate::link::normalize(path),
hash: row
.get(HASH_KEY)
.and_then(Value::as_str)
.map(str::to_string),
});
}
Ok(Manifest { root, files })
}
pub fn to_mapping(&self, title: &str) -> Mapping {
let mut map = Mapping::new();
map.insert("title".into(), Value::String(title.to_string()));
map.insert(ROOT_KEY.into(), Value::String(self.root.clone()));
map.insert(
FILES_KEY.into(),
Value::Sequence(
self.files
.iter()
.map(|entry| {
let mut row = Mapping::new();
row.insert(PATH_KEY.into(), Value::String(slash_path(&entry.path)));
if let Some(hash) = &entry.hash {
row.insert(HASH_KEY.into(), Value::String(hash.clone()));
}
Value::Mapping(row)
})
.collect(),
),
);
map
}
pub fn covered_root(&self, manifest_doc: &Path) -> PathBuf {
crate::link::resolve(manifest_doc, &self.root)
}
pub fn checked_root(&self, manifest_doc: &Path) -> Result<PathBuf> {
let root = self.covered_root(manifest_doc);
if crate::link::escapes_root(&root) {
return Err(Error::Structure(format!(
"manifest `{ROOT_KEY}: {}` climbs outside the workspace",
self.root
)));
}
Ok(root)
}
pub fn file_path(&self, manifest_doc: &Path, entry: &ManifestEntry) -> PathBuf {
crate::link::normalize(self.covered_root(manifest_doc).join(&entry.path))
}
pub fn sort(&mut self) {
self.files.sort_by(|a, b| {
path_sort_key(&a.path)
.cmp(&path_sort_key(&b.path))
.then_with(|| a.hash.cmp(&b.hash))
});
}
pub fn is_hashed(&self) -> bool {
self.files.iter().all(|entry| entry.hash.is_some())
}
}
pub fn diff(listed: &[ManifestEntry], on_disk: &[PathBuf]) -> (Vec<PathBuf>, Vec<PathBuf>) {
let rows: std::collections::BTreeSet<&Path> = listed.iter().map(|e| e.path.as_path()).collect();
let disk: std::collections::BTreeSet<&Path> = on_disk.iter().map(PathBuf::as_path).collect();
(
rows.difference(&disk).map(|p| p.to_path_buf()).collect(),
disk.difference(&rows).map(|p| p.to_path_buf()).collect(),
)
}
pub fn slash_path(path: &Path) -> String {
path.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
pub fn path_sort_key(path: &Path) -> String {
slash_path(path)
}
pub fn manifest_sibling(node: &Path) -> PathBuf {
let stem = node
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default();
let ext = node.extension().and_then(|e| e.to_str()).unwrap_or("yaml");
node.with_file_name(format!("{stem}.{MANIFEST_INFIX}.{ext}"))
}
pub fn manifest_node_candidates(dir: &Path) -> impl Iterator<Item = PathBuf> + '_ {
crate::graph::sidecar_candidates(dir)
}
#[cfg(all(test, feature = "yaml"))]
mod tests {
use super::*;
fn parse(text: &str) -> Result<Manifest> {
let map = crate::meta::parse_mapping(text, fig::Format::Yaml).unwrap();
Manifest::from_meta(&Value::Mapping(map))
}
#[test]
fn reads_rows_with_and_without_hashes() {
let m = parse(
"title: Photos\nroot: photos/\nfiles:\n\
- path: a.jpg\n hash: sha256:abc\n\
- path: sub/b.jpg\n",
)
.unwrap();
assert_eq!(m.root, "photos/");
assert_eq!(m.files.len(), 2);
assert_eq!(m.files[0].hash.as_deref(), Some("sha256:abc"));
assert_eq!(m.files[1].path, PathBuf::from("sub/b.jpg"));
assert!(!m.is_hashed(), "one row carries no digest");
}
#[test]
fn an_empty_manifest_is_legal_and_a_rootless_one_is_not() {
assert!(parse("root: photos/\n").unwrap().files.is_empty());
assert!(parse("root: photos/\nfiles:\n").unwrap().files.is_empty());
assert!(parse("files:\n- path: a.jpg\n").is_err(), "no root");
}
#[test]
fn a_row_without_a_path_is_refused_rather_than_dropped() {
let err = parse("root: photos/\nfiles:\n- hash: sha256:abc\n").unwrap_err();
assert!(err.to_string().contains("path"), "{err}");
}
#[test]
fn neither_root_nor_row_may_climb_out_of_the_workspace() {
assert!(parse("root: photos/\nfiles:\n- path: ../../etc/passwd\n").is_err());
let m = parse("root: ../photos/\n").unwrap();
assert!(
m.checked_root(Path::new("albums/trip.manifest.yaml"))
.is_ok()
);
assert!(m.checked_root(Path::new("trip.manifest.yaml")).is_err());
}
#[test]
fn rows_resolve_against_the_root_not_the_workspace() {
let m = parse("root: photos/\nfiles:\n- path: 2019/a.jpg\n").unwrap();
let doc = Path::new("albums/trip.manifest.yaml");
assert_eq!(m.covered_root(doc), PathBuf::from("albums/photos"));
assert_eq!(
m.file_path(doc, &m.files[0]),
PathBuf::from("albums/photos/2019/a.jpg")
);
}
#[test]
fn rows_sort_byte_wise_on_the_joined_path() {
let mut m = Manifest {
root: "photos/".into(),
files: vec![
ManifestEntry {
path: PathBuf::from("a/b.jpg"),
hash: None,
},
ManifestEntry {
path: PathBuf::from("a.jpg"),
hash: None,
},
],
};
m.sort();
assert_eq!(m.files[0].path, PathBuf::from("a.jpg"));
}
#[test]
fn round_trips_through_a_mapping() {
let m = parse("root: photos/\nfiles:\n- path: a.jpg\n hash: sha256:abc\n").unwrap();
let text =
crate::meta::serialize_mapping(&m.to_mapping("Photos — manifest"), fig::Format::Yaml)
.unwrap();
assert!(text.contains("root: photos/"), "{text}");
assert_eq!(parse(&text).unwrap(), m);
}
#[test]
fn the_manifest_sits_beside_its_node_without_naming_it() {
assert_eq!(
manifest_sibling(Path::new("albums/photos.yaml")),
PathBuf::from("albums/photos.manifest.yaml")
);
assert_ne!(
manifest_sibling(Path::new("photos.yaml")),
PathBuf::from("photos.yaml")
);
}
}