blob: ace1b3b66c7b53e10fe037df82fdb6368af326d0 [file]
//! Leaving and arriving: export a domain to a bundle, import a bundle into a domain.
//!
//! Export is a read under the caller's filter, bounded by a label. The bundle is a
//! directory of entries plus `manifest.yaml`, and the manifest carries the highest
//! classification inside it. Nothing leaves below its classification.
//!
//! Import is the mirror. Every entry lands at trust raw unless the same owner exported it,
//! keeps its provenance and classification, and is keyed on origin so a second import
//! creates nothing twice. Why its entries were promoted travels with them: the records
//! about entries exported, at or below the bound, in a file the manifest holds the digest
//! of; an import keeps them where it keeps the tiers, from the same owner, and leaves them
//! behind where the entries land raw. A manifest path that is not a plain file name is refused before
//! anything is read, and an entry whose digest does not match its manifest line is
//! refused before anything is written.
use dm_core::erasure::body_digest;
use dm_core::manifest::{Manifest, ManifestEntry, ManifestPromotion, ManifestPromotions};
use dm_core::{digest, Entry, Label, ReadFilter, Status, TrustTier};
use dm_index::{Hit, Index};
use dm_ledger::PromotionRecord;
use dm_store::Store;
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
/// What can go wrong at the bundle boundary.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The record.
#[error(transparent)]
Store(#[from] dm_store::Error),
/// The index.
#[error(transparent)]
Index(#[from] dm_index::Error),
/// An entry or manifest that does not parse.
#[error(transparent)]
Core(#[from] dm_core::Error),
/// The bundle directory.
#[error("bundle {path}: {source}")]
Io {
/// The path.
path: String,
/// Why.
source: std::io::Error,
},
/// The bundle is not one this code reads.
#[error("bundle: {0}")]
Invalid(String),
}
fn io(path: &Path) -> impl FnOnce(std::io::Error) -> Error + '_ {
move |source| Error::Io {
path: path.display().to_string(),
source,
}
}
/// The outcome of an export.
#[derive(Clone, Debug, PartialEq)]
pub struct Exported {
/// The manifest as written.
pub manifest: Manifest,
}
/// Where an import's writes are logged: the root, and who writes.
#[derive(Clone, Copy, Debug)]
pub struct LogTo<'a> {
/// The root, whose `state/log` holds the domain's log.
pub root: &'a Path,
/// The author the log names.
pub by: &'a str,
}
/// Whether this code reads the bundle's format.
fn format_read(manifest: &Manifest) -> Result<(), Error> {
if manifest.format == dm_core::manifest::FORMAT {
return Ok(());
}
Err(Error::Invalid(format!(
"format {:?}; this code reads {:?}",
manifest.format,
dm_core::manifest::FORMAT
)))
}
impl LogTo<'_> {
fn put(self, domain: &str, entry: &Entry) -> Result<(), Error> {
crate::log::append(
self.root,
domain,
self.by,
crate::log::Change::Put {
id: entry.front.id.clone(),
digest: digest(&entry.render()),
active: entry.is_active(),
},
)
.map(|_| ())
.map_err(|e| Error::Invalid(format!("the log: {e}")))
}
}
/// The outcome of an import.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct Imported {
/// Ids written, in bundle order.
pub written: Vec<String>,
/// Ids skipped because their origin was already in the index.
pub skipped: Vec<String>,
/// Ids refused because their text was erased from this domain: the receipt kept the
/// digest, and a bundle from before the erasure still carries the body.
pub erased: Vec<String>,
/// For each entry written, the id it landed under, keyed by the bundle's: the same
/// unless the bundle's id was already in use here.
pub landed: BTreeMap<String, String>,
/// Transcript turns restored as excerpts of the entries written.
pub excerpts: usize,
/// Where the bundle came from: `bundle:<exporter>/<domain>`.
pub from: String,
/// The promotion records to restore, each moved to its entry as it stands here: the
/// entries written, and those skipped because they are here already, whose records
/// an earlier import may not have reached. From the same owner's bundle only. Another
/// owner's entries land raw, and the reasons for the tier they lost are left behind.
pub promotions: Vec<PromotionRecord>,
}
/// Where a bundle carries the promotion records of its entries.
pub const PROMOTIONS_FILE: &str = "promotions.json";
/// Refuse a manifest path that is not a plain file name in the bundle's directory.
fn plain_name(path: &str) -> Result<(), Error> {
if path.contains(['/', '\\']) || path.starts_with('.') || path.is_empty() {
return Err(Error::Invalid(format!(
"manifest path {path:?} is not a plain file name"
)));
}
Ok(())
}
/// Write the records about entries the manifest lists, at or below its bound, and list
/// them in it. Nothing is written when there are none.
async fn carry_promotions(
manifest: &mut Manifest,
records: Vec<PromotionRecord>,
dest: &Path,
) -> Result<(), Error> {
let carried: Vec<PromotionRecord> = records
.into_iter()
.filter(|r| {
r.spec.classification <= manifest.max_label
&& manifest.entries.iter().any(|e| e.id == r.spec.entry)
})
.collect();
if carried.is_empty() {
return Ok(());
}
let text = serde_json::to_string_pretty(&carried)
.map_err(|e| Error::Invalid(format!("promotion records: {e}")))?;
let path = dest.join(PROMOTIONS_FILE);
tokio::fs::write(&path, &text).await.map_err(io(&path))?;
manifest.set_promotions(ManifestPromotions {
path: PROMOTIONS_FILE.into(),
digest: digest(&text),
records: carried
.iter()
.map(|r| ManifestPromotion {
entry: r.spec.entry.clone(),
classification: r.spec.classification,
})
.collect(),
})?;
Ok(())
}
/// The promotion records a bundle carries, checked before anything is written: a plain
/// file name, its digest, and one record per manifest line, about the entry and at the
/// label the line says. A bundle with one bad record is refused whole.
fn carried_promotions(src: &Path, manifest: &Manifest) -> Result<Vec<PromotionRecord>, Error> {
let Some(listed) = &manifest.promotions else {
return Ok(Vec::new());
};
plain_name(&listed.path)?;
let path = src.join(&listed.path);
let text = std::fs::read_to_string(&path).map_err(io(&path))?;
if digest(&text) != listed.digest {
return Err(Error::Invalid(format!(
"{} does not match its manifest digest",
listed.path
)));
}
let records: Vec<PromotionRecord> =
serde_json::from_str(&text).map_err(|e| Error::Invalid(format!("{}: {e}", listed.path)))?;
let inside: Vec<(String, Label)> = records
.iter()
.map(|r| (r.spec.entry.clone(), r.spec.classification))
.collect();
manifest.check_promotions(&inside)?;
Ok(records)
}
/// An entry of the bundle as it stands here after the import: written now, or here
/// already from before. Its records are moved to it.
struct Placed {
/// The id the bundle names it by.
bundle_id: String,
/// The id it has here.
here: String,
/// The digest of its text in the bundle.
bundle_digest: String,
/// The digest of its text here: not the bundle's, since landing sets the origin and
/// may change the id, the domain and the classification.
digest: String,
/// Its classification here, which an import may have raised to the domain's default.
classification: Label,
}
/// The records to restore: those about an entry that is here, moved to it as it stands.
/// A record of the version the bundle carries names the version as it landed, so it
/// reads as landed; a record is at least as classified as its entry, as the ledger keeps
/// it. A record about an entry that is not here, refused or never written, is left out.
fn landed_promotions(
carried: Vec<PromotionRecord>,
placed: &[Placed],
into_domain: &str,
) -> Vec<PromotionRecord> {
carried
.into_iter()
.filter_map(|mut r| {
let p = placed.iter().find(|p| p.bundle_id == r.spec.entry)?;
r.spec.entry.clone_from(&p.here);
r.spec.domain = into_domain.to_string();
if r.spec.new_digest == p.bundle_digest {
r.spec.new_digest.clone_from(&p.digest);
}
r.spec.classification = r.spec.classification.max(p.classification);
Some(r)
})
.collect()
}
/// Where the binary keeps the bundles it serves, under the root's state.
pub const BUNDLES_DIR: &str = "state/bundles";
/// Where a domain's bundle at a label is exported for serving: one directory per
/// domain and label, replaced whole on each export.
#[must_use]
pub fn bundle_dir(root: &Path, domain: &str, label: Label) -> PathBuf {
root.join(BUNDLES_DIR).join(format!("{domain}-{label}"))
}
/// What an export reads: `filter` narrowed to `domain`, bounded at `max_label`, with
/// tombstones and every tier, since a bundle carries the domain whole. Fails if the
/// filter does not cover the domain.
///
/// # Errors
///
/// [`Error::Invalid`] for a domain the filter does not admit.
pub fn export_scope(
filter: &ReadFilter,
domain: &str,
max_label: Label,
) -> Result<ReadFilter, Error> {
if !filter.domains.is_empty() && !filter.domains.iter().any(|d| d == domain) {
return Err(Error::Invalid(format!(
"domain {domain} is not readable here"
)));
}
let mut scoped = filter.clone();
scoped.domains = vec![domain.to_string()];
scoped.handling = scoped.handling.min(max_label);
scoped.include_inactive = true;
scoped.min_tier = TrustTier::Raw;
Ok(scoped)
}
/// Export `hits`, the entries [`export_scope`] listed for `domain`, into `dest`, with
/// the `promotions` of those exported at or below the bound; those of an entry exported
/// as its erased stub go without the why. The index is consulted before this is called and never held across the record reads,
/// so the export can run on any executor thread. The manifest's word on each entry's
/// label is the record's, read from the text written, not the index's, which can lag
/// it: every receiver checks the manifest against the text, so an exporter must say
/// what the text says.
///
/// # Errors
///
/// A store or I/O error.
pub async fn export(
store: &Store,
hits: Vec<Hit>,
domain: &str,
max_label: Label,
exported_by: &str,
dest: &Path,
promotions: Vec<PromotionRecord>,
) -> Result<Exported, Error> {
tokio::fs::create_dir_all(dest).await.map_err(io(dest))?;
let mut manifest = Manifest::new(domain, exported_by, max_label);
let mut stubs = HashSet::new();
for hit in hits {
let Some(text) = store.get_text(domain, &hit.id).await? else {
continue;
};
let front = Entry::parse(&text)?.front;
if front.status == Some(Status::Erased) {
stubs.insert(hit.id.clone());
}
let classification = front.classification;
let file = format!("{}.md", hit.id);
let path = dest.join(&file);
tokio::fs::write(&path, &text).await.map_err(io(&path))?;
manifest.push(ManifestEntry {
id: hit.id.clone(),
path: file,
digest: digest(&text),
classification,
})?;
}
// an erasure writes the stub before it scrubs the ledger: the records of an entry
// exported as its stub go without the why, as the scrub leaves them
let promotions = promotions
.into_iter()
.map(|mut r| {
if stubs.contains(&r.spec.entry) {
r.spec.why.clear();
r.why_erased = true;
}
r
})
.collect();
carry_promotions(&mut manifest, promotions, dest).await?;
let mpath = dest.join("manifest.yaml");
tokio::fs::write(&mpath, manifest.to_yaml())
.await
.map_err(io(&mpath))?;
Ok(Exported { manifest })
}
/// Whether an origin that names this record and `domain` names an entry that is here:
/// `id` is what followed the record's own prefix, written by whoever made the bundle, so
/// one that is not a name is not ours rather than a refusal of the whole bundle.
async fn is_own(store: &Store, domain: &str, id: Option<&str>) -> Result<bool, Error> {
let Some(id) = id else {
return Ok(false);
};
match store.get(domain, id).await {
Ok(found) => Ok(found.is_some()),
Err(dm_store::Error::BadName(_)) => Ok(false),
Err(e) => Err(e.into()),
}
}
/// The id a bundle entry with `origin` has here, when it is here: this domain's own,
/// come back in a bundle it left in (`home` is the prefix of such an origin), or one a
/// previous import landed, known by its origin.
async fn present_here(
store: &Store,
index: &Index,
into_domain: &str,
origin: &str,
home: &str,
) -> Result<Option<String>, Error> {
let own = origin.strip_prefix(home);
if is_own(store, into_domain, own).await? {
return Ok(own.map(str::to_string));
}
Ok(index.by_origin(into_domain, origin)?)
}
/// How a bundle entry that is here already would have landed: what the import sets on an
/// entry it writes, so the text here can be compared with the bundle's.
struct Landing<'a> {
origin: &'a str,
here: &'a str,
default_classification: Label,
}
/// Where a bundle entry that is here already stands, for its records to be moved to:
/// only when what is here is this very text, landed as the import lands one, origin,
/// domain, id and classification set. A record is of a version, and the bundle's version
/// of the entry need not be the one here: another export of the entry, or a bundle
/// written by hand, carries a text of its own, and its records stay out. `None` too for
/// an erased one, whose records keep nothing of it.
async fn placed_here(
store: &Store,
into_domain: &str,
entry: &Entry,
landing: &Landing<'_>,
bundle_digest: String,
) -> Result<Option<Placed>, Error> {
let Some(e) = store.get(into_domain, landing.here).await? else {
return Ok(None);
};
if e.front.status == Some(dm_core::Status::Erased) {
return Ok(None);
}
let mut as_landed = entry.clone();
as_landed.front.origin = Some(landing.origin.to_string());
as_landed.front.domain = into_domain.to_string();
as_landed.front.id = landing.here.to_string();
as_landed.front.classification = as_landed
.front
.classification
.max(landing.default_classification);
let digest_here = digest(&e.render());
if digest(&as_landed.render()) != digest_here {
return Ok(None);
}
Ok(Some(Placed {
bundle_id: entry.front.id.clone(),
here: landing.here.to_string(),
bundle_digest,
digest: digest_here,
classification: e.front.classification,
}))
}
/// Pass one of an import: every path and digest, before anything is written, so a bundle
/// with one bad entry is refused whole and a partial import never happens. Text this
/// domain erased is known by its digest and is not an error, the bundle being older than
/// the erasure: it is named on `out` and left out. The manifest's word on the labels must
/// be the entries' own, erased ones included: a line or a highest that understates is
/// refused whole. Returns each entry to land with the digest of its text in the bundle,
/// which its records name.
fn check_entries(
src: &Path,
manifest: &Manifest,
erased: &HashSet<String>,
out: &mut Imported,
) -> Result<Vec<(Entry, String)>, Error> {
let mut checked: Vec<(Entry, String)> = Vec::with_capacity(manifest.entries.len());
let mut inside: Vec<(String, Label)> = Vec::with_capacity(manifest.entries.len());
for m in &manifest.entries {
plain_name(&m.path)?;
let path = src.join(&m.path);
let text = std::fs::read_to_string(&path).map_err(io(&path))?;
if digest(&text) != m.digest {
return Err(Error::Invalid(format!(
"{} does not match its manifest digest",
m.path
)));
}
let entry = Entry::parse(&text)?;
inside.push((entry.front.id.clone(), entry.front.classification));
if erased.contains(&m.digest) || erased.contains(&body_digest(&entry.body)) {
out.erased.push(m.id.clone());
continue;
}
checked.push((entry, m.digest.clone()));
}
manifest.check(&inside)?;
Ok(checked)
}
/// Import a bundle from `src` into `into_domain`. `owner` is this record's owner; a bundle
/// the same owner exported keeps its trust tiers, every other bundle lands at raw. An
/// entry whose text's digest, or whose trimmed body's, is in `erased`, what this
/// domain's receipts list as removed, is refused and named rather than written back.
/// Recognition is by digest: a copy that was reworded is a new text, and the receipt
/// says so.
///
/// # Errors
///
/// A bad or missing manifest, a path that is not a plain file name, a digest that does
/// not match, a manifest whose labels are not the entries' own, an entry that does not
/// parse, or a store or index error.
#[allow(clippy::too_many_arguments)] // the stores, the log, and what the import is told
pub async fn import(
store: &Store,
index: &mut Index,
log: LogTo<'_>,
owner: &str,
src: &Path,
into_domain: &str,
default_classification: Label,
erased: &HashSet<String>,
) -> Result<Imported, Error> {
let mpath = src.join("manifest.yaml");
let mtext = std::fs::read_to_string(&mpath).map_err(io(&mpath))?;
let manifest = Manifest::from_yaml(&mtext)?;
format_read(&manifest)?;
let same_owner = manifest.exported_by == owner;
let mut out = Imported {
from: format!("bundle:{}/{}", manifest.exported_by, manifest.domain),
..Imported::default()
};
let checked = check_entries(src, &manifest, erased, &mut out)?;
let carried = carried_promotions(src, &manifest)?;
// Pass two: land what is new. An entry is already here when a previous import landed
// it, known by its origin, or when it is this domain's own, come back in a bundle it
// left in: its origin names this record and this domain, and the id it names is here.
// Presence is all that is compared: a bundle carrying another version of an entry
// this domain holds is skipped, not landed as a version, since the record's versions
// are written here and a bundle is a copy.
let home = format!("bundle:{owner}/{into_domain}/");
let mut placed: Vec<Placed> = Vec::with_capacity(checked.len());
for (mut entry, bundle_digest) in checked {
let origin = entry.front.origin.clone().unwrap_or_else(|| {
format!(
"bundle:{}/{}/{}",
manifest.exported_by, manifest.domain, entry.front.id
)
});
if let Some(here) = present_here(store, index, into_domain, &origin, &home).await? {
// its records may still be owed, when the import that landed it stopped
// before they were restored: only when what is here is this very text as
// the import would land it, since a record is of a version
if same_owner {
let landed = Landing {
origin: &origin,
here: &here,
default_classification,
};
placed
.extend(placed_here(store, into_domain, &entry, &landed, bundle_digest).await?);
}
out.skipped.push(entry.front.id.clone());
continue;
}
entry.front.origin = Some(origin);
if entry.front.domain != into_domain {
entry.front.domain = into_domain.to_string();
}
// an id names one entry across the record, whichever domain holds it: a bundle's
// id that is in use here, in this domain or another, lands under a new one. The
// record is asked, since the index can lag it and is what a reindex rebuilds.
let bundle_id = entry.front.id.clone();
if store.holds(&entry.front.id).await? {
entry.front.id = dm_core::id::new_entry_id(into_domain);
}
if !same_owner {
entry.front.trust_tier = TrustTier::Raw;
}
entry.front.classification = entry.front.classification.max(default_classification);
// through the domain's log, as every write of an entry goes
log.put(into_domain, &entry)?;
store.put(&entry).await?;
index.upsert(&entry)?;
// the entry it replaces, which the store just marked superseded
if let Some(older) = &entry.front.supersedes {
if let Some(old) = store.get(&entry.front.domain, older).await? {
index.upsert(&old)?;
}
}
out.landed.insert(bundle_id.clone(), entry.front.id.clone());
out.written.push(entry.front.id.clone());
placed.push(Placed {
bundle_id,
here: entry.front.id.clone(),
bundle_digest,
digest: digest(&entry.render()),
classification: entry.front.classification,
});
}
if same_owner {
out.promotions = landed_promotions(carried, &placed, into_domain);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
const HELD: &str = "---\nid: mem-a-held0001\ntype: fact\ndomain: dom-a\nauthor: human:test:me\ncreated: 2026-10-01T09:00:00Z\ntrust_tier: curated\nclassification: restricted\n---\n# Held\n\nHeld.\n";
#[tokio::test]
async fn an_export_s_manifest_speaks_for_the_record_not_the_index() {
// the index can lag the record, which is what reindex exists for; the manifest
// every receiver now checks against the text must be written from the text, or
// an exporter hands out a bundle every receiver refuses
let dir = tempfile::tempdir().unwrap();
let store = Store::open_dir(dir.path()).unwrap();
let entry = Entry::parse(HELD).unwrap();
store.put(&entry).await.unwrap();
let stale = Hit {
id: entry.front.id.clone(),
domain: "dom-a".into(),
entry_type: entry.front.entry_type,
trust_tier: entry.front.trust_tier,
classification: Label::Public,
title: "Held".into(),
summary: "Held.".into(),
score: 0.0,
review_due: false,
};
let dest = tempfile::tempdir().unwrap();
let ex = export(
&store,
vec![stale],
"dom-a",
Label::Restricted,
"memory:test:me",
dest.path(),
Vec::new(),
)
.await
.unwrap();
assert_eq!(ex.manifest.entries[0].classification, Label::Restricted);
assert_eq!(ex.manifest.highest_label, Label::Restricted);
let written = Manifest::from_yaml(
&std::fs::read_to_string(dest.path().join("manifest.yaml")).unwrap(),
)
.unwrap();
written
.check(&[(entry.front.id.clone(), Label::Restricted)])
.unwrap();
}
/// A record about an entry that the export lists but does not write, since the
/// record no longer holds its text, stays home even within the bound: a bundle
/// carries records of its entries alone.
#[tokio::test]
async fn a_record_of_an_entry_left_home_is_not_carried_even_within_the_bound() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open_dir(dir.path()).unwrap();
let entry = Entry::parse(HELD).unwrap();
store.put(&entry).await.unwrap();
let hit = |id: &str| Hit {
id: id.into(),
domain: "dom-a".into(),
entry_type: entry.front.entry_type,
trust_tier: entry.front.trust_tier,
classification: Label::Public,
title: "Held".into(),
summary: "Held.".into(),
score: 0.0,
review_due: false,
};
let record = |id: &str| PromotionRecord {
spec: dm_ledger::PromotionSpec {
domain: "dom-a".into(),
entry: id.into(),
tier: TrustTier::Curated,
by: "agent:librarian".into(),
author: "human:test:me".into(),
why: format!("why {id} was promoted"),
classification: Label::Public,
digest: "d0".into(),
new_digest: "d1".into(),
},
why_erased: false,
at: time::OffsetDateTime::UNIX_EPOCH,
};
let dest = tempfile::tempdir().unwrap();
let ex = export(
&store,
vec![hit(&entry.front.id), hit("mem-a-gone0001")],
"dom-a",
Label::Restricted,
"memory:test:me",
dest.path(),
vec![record(&entry.front.id), record("mem-a-gone0001")],
)
.await
.unwrap();
let carried = ex
.manifest
.promotions
.as_ref()
.expect("the held entry's record is carried");
assert_eq!(carried.records.len(), 1, "{carried:?}");
assert_eq!(carried.records[0].entry, entry.front.id);
let text = std::fs::read_to_string(dest.path().join(PROMOTIONS_FILE)).unwrap();
assert!(!text.contains("mem-a-gone0001"), "{text}");
let records = carried_promotions(dest.path(), &ex.manifest).unwrap();
assert_eq!(records.len(), 1);
}
}