// The registry side: handles -> DIDs -> module records and blobs (checked against their CIDs), the // lockfile and vendored copies (`add`), and staging them for a static site or a server (`stage`, // `oauth`). Modules are com.fedfork.exec.wasm.module records; a lib is /. use std::path::{Path, PathBuf}; use std::sync::Arc; use atlibs_host::compose::{self, Composition}; use atlibs_host::run::{Hook, module_key_ok, module_pin}; use atlibs_host::host::Fetch; use atlibs_host::oauth::{AppRepo, ClientMetadataHandler, TEMPLATE_ORIGIN, compose_record}; use atlibs_host::wasm::sha256_hex; use atlibs_host::{iso_now, pretty}; use serde_json::{Map, Value, json}; use crate::{Args, Cli, env}; pub const LOCK: &str = "atlibs.lock.json"; pub use atlibs_host::registry::{ABI, CLIENT, COMPOSITION, Client, Lib, MODULE, Net, check_pin, fetch_lib, named, resolve, spec_of, split_uri}; /// Net with the environment's PLC, handle resolver and private-address switch (tests, mirrors). pub fn net_from_env(fetch: Arc) -> Net { Net { fetch, plc: env("ATLIBS_PLC_URL").unwrap_or_else(|| "https://plc.directory".into()), resolver: env("ATLIBS_HANDLE_RESOLVER").unwrap_or_else(|| "https://public.api.bsky.app".into()), allow_private: env("ATLIBS_ALLOW_PRIVATE").as_deref() == Some("1"), } } /// The first `rel` in `start` or a directory above it. pub fn find_up(start: &Path, rel: &str) -> Option { let start = std::fs::canonicalize(start).ok()?; start.ancestors().map(|d| d.join(rel)).find(|p| p.exists()) } /// A module directory: a path holding module.json, or a bare name for /modules/ (a module /// repo) or /examples/rust/ (this one), the nearest above `from`. pub fn module_dir(from: &Path, name: &str) -> Option { let direct = from.join(name); if direct.join("module.json").is_file() { return Some(direct); } if name.contains('/') { return None; } let start = std::fs::canonicalize(from).ok()?; start.ancestors().flat_map(|d| ["modules", "examples/rust"].map(|base| d.join(base).join(name))).find(|d| d.join("module.json").is_file()) } /// A local module: module.json, its `cargo wasm` build above it, records/.json if any. pub fn local_lib(dir: &Path) -> Result { let read = |p: &Path| std::fs::read(p).map_err(|e| format!("{}: {e}", p.display())); let spec: Value = serde_json::from_slice(&read(&dir.join("module.json"))?).map_err(|e| format!("{}/module.json: {e}", dir.display()))?; let spec = Value::Object(spec_of(&spec)); let dir = std::fs::canonicalize(dir).map_err(|e| format!("{}: {e}", dir.display()))?; let key = dir.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_string(); let rel = format!("target/wasm32-unknown-unknown/release/{}.wasm", key.replace('-', "_")); let path = find_up(&dir, &rel).ok_or_else(|| format!("{}: no {rel} in it or above it: run cargo wasm", dir.display()))?; let wasm = read(&path)?; let client = records_client(&dir, &spec)?; Ok(Lib { key, reference: None, uri: None, cid: None, sha256: sha256_hex(&wasm), wasm, spec, client, id: std::fs::canonicalize(&path).unwrap_or(path).display().to_string(), pinned: None }) } /// A local module.json's session record: records/.json in `dir` or a directory above it. fn records_client(dir: &Path, spec: &Value) -> Result, String> { let Some(s) = spec["session"].as_str() else { return Ok(None) }; let Some(p) = find_up(dir, &format!("records/{s}.json")) else { return Ok(None) }; let record = serde_json::from_slice(&std::fs::read(&p).map_err(|e| format!("{}: {e}", p.display()))?).map_err(|e| format!("{}: {e}", p.display()))?; Ok(Some(Client { rkey: s.into(), record, uri: None, cid: None })) } // ---- lockfile ------------------------------------------------------------------------------ pub fn read_lock(path: &Path) -> Result { match std::fs::read(path) { Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display())), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(json!({ "modules": {}, "sessions": {}, "compositions": {} })), Err(e) => Err(format!("{}: {e}", path.display())), } } pub fn write_json(path: &Path, value: &Value) -> Result<(), String> { if let Some(dir) = path.parent() { std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; } std::fs::write(path, format!("{}\n", pretty(value))).map_err(|e| format!("{}: {e}", path.display())) } fn write_bytes(path: &Path, bytes: &[u8]) -> Result<(), String> { if let Some(dir) = path.parent() { std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; } std::fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display())) } /// A module from the lockfile (or stage.json) in `dir`: its vendored wasm and record fields, re-checked /// against the entry's pin (module_pin: a changed wasm, calls or hooks no longer match). pub fn lock_lib(dir: &Path, lock: &Value, key: &str) -> Result { let entry = &lock["modules"][key]; let file = |k: &str| dir.join(entry[k].as_str().unwrap_or_default()); let wasm = std::fs::read(file("wasm")).map_err(|e| format!("{}: {e}", file("wasm").display()))?; let sha256 = sha256_hex(&wasm); let spec: Value = serde_json::from_slice(&std::fs::read(file("spec")).map_err(|e| format!("{}: {e}", file("spec").display()))?).map_err(|e| e.to_string())?; if entry["pin"].as_str() != Some(module_pin(&wasm, &spec).as_str()) { return Err(format!("{key}: {} and {} do not match the pin {} has for it", file("wasm").display(), file("spec").display(), dir.display())); } let client = entry["session"].as_str().and_then(|s| { let session = &lock["sessions"][s]; session.get("record").map(|record| Client { rkey: s.into(), record: record.clone(), uri: session["uri"].as_str().map(String::from), cid: session["cid"].as_str().map(String::from) }) }); let uri = entry["uri"].as_str().map(String::from); let id = uri.clone().unwrap_or_else(|| std::fs::canonicalize(file("wasm")).unwrap_or(file("wasm")).display().to_string()); Ok(Lib { key: key.into(), reference: entry["ref"].as_str().map(String::from), uri, cid: entry["cid"].as_str().map(String::from), wasm, sha256, spec, client, id, pinned: Some(dir.to_path_buf()) }) } fn vendor(dir: &Path, lock: &mut Value, lib: &Lib) -> Result<(), String> { if let Some(other) = lock["modules"][&lib.key]["uri"].as_str().filter(|u| Some(*u) != lib.uri.as_deref()) { return Err(format!("{} is already {other} in {LOCK}; remove it there first", lib.key)); } let (wasm, spec) = (format!("modules/{}.wasm", lib.key), format!("modules/{}.json", lib.key)); write_bytes(&dir.join(&wasm), &lib.wasm)?; write_json(&dir.join(&spec), &lib.spec)?; lock["modules"][&lib.key] = json!({ "ref": lib.reference, "uri": lib.uri, "cid": lib.cid, "pin": lib.pin(), "session": lib.client.as_ref().map(|c| &c.rkey), "wasm": wasm, "spec": spec }); if let Some(c) = &lib.client { lock["sessions"][&c.rkey] = json!({ "uri": c.uri, "cid": c.cid, "record": c.record }); } Ok(()) } // ---- refs ---------------------------------------------------------------------------------- /// A published ref as people read it (add's output and lockfile `ref`): /[@pin], a did:.../ /// or at:// ref named by its handle (`named`). The uri, cid and pin stay what identifies it. pub async fn shown(net: &Net, reference: &str, uri: &str) -> String { let (path, pin) = reference.split_once('@').map_or((reference, None), |(p, pin)| (p, Some(pin))); let name = if path.starts_with("at://") || path.starts_with("did:") { named(net, uri).await } else { path.to_string() }; pin.map_or(name.clone(), |p| format!("{name}@{p}")) } /// /[@pin] or did:.../, as opposed to a path. pub fn is_remote(reference: &str) -> bool { if reference.starts_with("at://") { return true; } let Some((head, lib)) = reference.split_once('/') else { return false }; !lib.is_empty() && !lib.contains('/') && (head.starts_with("did:") || head.contains('.')) && !Path::new(reference).exists() && !head.starts_with('.') } /// A module vendored in `base`: its atlibs.lock.json entry (lock_lib), else `atlibs stage` output, /// modules/.wasm + .json, whose id is where the wasm is (never something the json names). fn vendored_lib(base: &Path, key: &str) -> Result, String> { if base.join(LOCK).is_file() { let lock = read_lock(&base.join(LOCK))?; if lock["modules"].get(key).is_some() { return lock_lib(base, &lock, key).map(Some); } } if !module_key_ok(key) || !base.join(format!("modules/{key}.wasm")).is_file() { return Ok(None); } pair_lib(&base.join(format!("modules/{key}.wasm")), &base.join(format!("modules/{key}.json"))).map(Some) } /// : the module.json fields a host reads, keyed by the wasm's file stem; its session /// record as local_lib finds one (records/.json at or above the module.json). fn pair_lib(wasm_path: &Path, spec_path: &Path) -> Result { let read = |p: &Path| std::fs::read(p).map_err(|e| format!("{}: {e}", p.display())); let spec: Value = serde_json::from_slice(&read(spec_path)?).map_err(|e| format!("{}: {e}", spec_path.display()))?; let wasm = read(wasm_path)?; let id = std::fs::canonicalize(wasm_path).map_err(|e| format!("{}: {e}", wasm_path.display()))?.display().to_string(); let key = wasm_path.file_stem().and_then(|s| s.to_str()).unwrap_or_default().to_string(); let spec = Value::Object(spec_of(&spec)); let client = records_client(spec_path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")), &spec)?; Ok(Lib { key, reference: None, uri: None, cid: None, sha256: sha256_hex(&wasm), wasm, spec, client, id, pinned: None }) } /// One instance's module ref as a lib: an at:// URI; else a key of the publisher's (`publisher`); else (a /// local manifest) /[@pin]; else a module vendored in `base` (vendored_lib); else a local module directory. async fn instance_lib(net: &Net, reference: &str, publisher: Option<&str>, base: &Path, pin: Option<&str>) -> Result { match (reference.starts_with("at://"), publisher) { (true, _) => fetch_lib(net, reference, None, pin).await, (false, Some(did)) => atlibs_host::registry::instance_lib(net, reference, did, pin).await, (false, None) if is_remote(reference) => { let (uri, ref_pin) = resolve(net, reference).await?; let lib = fetch_lib(net, &uri, Some(reference), pin.or(ref_pin.as_deref())).await?; check_pin(&lib, ref_pin.as_deref())?; Ok(lib) } (false, None) => match vendored_lib(base, reference)? { Some(lib) => Ok(lib), None => { let cwd = std::env::current_dir().map_err(|e| e.to_string())?; let dir = module_dir(base, reference).or_else(|| module_dir(&cwd, reference)); local_lib(&dir.ok_or_else(|| format!("no module {reference} (modules/{reference}.wasm beside it, a module directory, modules/ or examples/rust/, or an at:// URI)"))?) } }, } } /// A composition as it runs (compose::load through instance_lib, hook defaults added; a run checks this /// build's transports: run::target_of), its libs, and the manifest stage and add write: each instance's /// module its lib's key, pinned (what vendored_lib reads next to modules/). Two modules that share a key are /// refused. pub async fn composition_libs(net: &Net, manifest: &Value, publisher: Option<&str>, base: &Path) -> Result<(Composition, Value, Vec), String> { let lib_of: atlibs_host::registry::LibOf = { let (net, publisher, base) = (net.clone(), publisher.map(String::from), base.to_path_buf()); Arc::new(move |reference: String, pin: Option| { let (net, publisher, base) = (net.clone(), publisher.clone(), base.clone()); Box::pin(async move { instance_lib(&net, &reference, publisher.as_deref(), &base, pin.as_deref()).await }) }) }; let (comp, found) = atlibs_host::registry::load_with(manifest, lib_of).await?; // routes now; transports are the running host's to check (any it may serve counts here) let hooked = comp.modules.values().flat_map(|m| m.hooks.iter().map(|h| h.nsid.clone())).collect(); compose::check(&comp, &hooked)?; let mut libs: Vec = vec![]; for (reference, lib) in &found { match libs.iter().find(|l| l.key == lib.key) { Some(l) if l.pin() == lib.pin() => {} Some(l) => return Err(format!("composition: {} and {reference} are both module {}: one key, two modules", l.reference.as_deref().unwrap_or(&l.id), lib.key)), None => libs.push(lib.clone()), } } let mut out = comp.manifest.clone(); for inst in out["instances"].as_array_mut().into_iter().flatten() { let lib = &found.iter().find(|(r, _)| inst["module"] == r.as_str()).expect("loaded").1; inst["module"] = json!(lib.key); inst["pin"] = json!(lib.pin()); } if let Some(m) = out.as_object_mut() { m.shift_remove("$type"); m.shift_remove("createdAt"); } Ok((comp, out, libs)) } /// The lib a hook default `key` of module `id` names, resolved as runs resolve it: from /// `pinned`'s atlibs.lock.json alone when the module is pinned (vendored with the same publisher's URI); /// else the same publisher's record for an at:// module; else examples/rust/ above its build. pub async fn default_lib(net: &Net, id: &str, pinned: Option<&Path>, key: &str) -> Result { if !module_key_ok(key) { return Err(format!("hook default {key} is not a module key")); } let sibling = split_uri(id).map(|(did, collection, _)| format!("at://{did}/{collection}/{key}")); if let Some(dir) = pinned { let lock = read_lock(&dir.join(LOCK))?; let entry = &lock["modules"][key]; if entry.is_null() || (sibling.is_some() && entry["uri"].as_str() != sibling.as_deref()) { return Err(format!("hook default {key}: {id} is pinned, but {} does not vendor {} (`atlibs add` the module again: it vendors its defaults)", dir.join(LOCK).display(), sibling.as_deref().unwrap_or(key))); } return lock_lib(dir, &lock, key); } match &sibling { Some(uri) => fetch_lib(net, uri, None, None).await, None => { let from = Path::new(id).parent().unwrap_or(Path::new(".")); local_lib(&module_dir(from, key).ok_or_else(|| format!("hook default {key}: no modules/{key} or examples/rust/{key} above {id}"))?) } } } /// `lib`'s hook defaults, and theirs, as default_lib resolves them (live for an unpinned lib: `add` and /// `stage` take them with it, so the lockfile or the staged site pins them too). async fn defaults_of(net: &Net, lib: &Lib, pinned: Option<&Path>) -> Result, String> { let mut out: Vec = vec![]; let mut todo = vec![lib.clone()]; while let Some(next) = todo.pop() { for key in hooks_of(&next.spec)?.into_iter().filter_map(|h| h.default) { if out.iter().any(|l| l.key == key) || key == lib.key { continue; } let found = default_lib(net, &next.id, pinned, &key).await?; todo.push(found.clone()); out.push(found); } } Ok(out) } fn hooks_of(spec: &Value) -> Result, String> { atlibs_host::run::Module::from_manifest(vec![], spec).map(|m| m.hooks) } /// What a ref names: every command (run, serve, add, stage, oauth, verify, run --handler) resolves its /// refs with resolve_ref, so a ref form means the same thing, with the same errors, everywhere. pub enum Ref { Module(Lib), /// `name`: its rkey, lockfile key or file stem ([.composition].json); `id`: its at:// URI, else /// the file's real path; `comp`: as it runs; `manifest`: as stage and add write it (composition_libs) Composition { name: String, id: String, cid: Option, comp: Composition, manifest: Value, libs: Vec }, Client(Client), } impl Ref { pub fn libs(&self) -> &[Lib] { match self { Ref::Module(lib) => std::slice::from_ref(lib), Ref::Composition { libs, .. } => libs, Ref::Client(_) => &[], } } } pub const REF_FORMS: &str = "/[@pin|@cid] (or /), an at:// URI, a name in atlibs.lock.json, a module directory or a name under examples/rust, a composition or clientMetadata .json, , or "; fn read_json(path: &Path) -> Result { serde_json::from_slice(&std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?).map_err(|e| format!("{}: {e}", path.display())) } fn real_path(path: &Path) -> Result { std::fs::canonicalize(path).map(|p| p.display().to_string()).map_err(|e| format!("{}: {e}", path.display())) } /// Resolves the ref at the front of `words`; returns it and the words after it (after the second word a /// .wasm, stage.json or lockfile takes). Names in a lockfile are looked up in `dir`'s; paths are the cwd's. pub async fn resolve_ref<'a>(net: &Net, dir: &Path, words: &'a [String]) -> Result<(Ref, &'a [String]), String> { let first = words.first().ok_or_else(|| format!("name a ref: {REF_FORMS}"))?; let second = || words.get(1).ok_or_else(|| format!("{first} needs a second word: {REF_FORMS}")); if is_remote(first) { let (uri, pin) = resolve(net, first).await?; let (did, collection, rkey) = split_uri(&uri).ok_or_else(|| format!("{uri} is not an at:// record URI"))?; let found = match collection { MODULE => { let mut lib = fetch_lib(net, &uri, Some(first), pin.as_deref()).await?; // pinned: its hook defaults only from the lockfile here (default_lib) lib.pinned = pin.map(|_| dir.to_path_buf()); Ref::Module(lib) } COMPOSITION => { let (cid, value) = atlibs_host::registry::composition(net, &uri, pin.as_deref()).await?; let (comp, manifest, libs) = composition_libs(net, &value, Some(did), dir).await?; Ref::Composition { name: rkey.into(), id: uri.clone(), cid: Some(cid), comp, manifest, libs } } CLIENT => { let (cid, value) = net.record(&uri).await?.ok_or_else(|| format!("{uri} not found"))?; if let Some(p) = pin.filter(|p| *p != cid) { return Err(format!("{rkey}: pinned to {p}, but it is record {cid}")); } Ref::Client(Client { rkey: rkey.into(), record: value, uri: Some(uri.clone()), cid: Some(cid) }) } _ => return Err(format!("{uri} is not a {MODULE}, {COMPOSITION} or {CLIENT} record")), }; return Ok((found, &words[1..])); } let path = Path::new(first); if first.ends_with(".wasm") { return Ok((Ref::Module(pair_lib(path, Path::new(second()?))?), &words[2..])); } if first.ends_with(".json") { let value = read_json(path)?; let stem = path.file_name().and_then(|s| s.to_str()).unwrap_or_default(); let stem = stem.strip_suffix(".composition.json").or(stem.strip_suffix(".json")).unwrap_or(stem).to_string(); if value["$type"] == CLIENT { // records/.json: a client with no module behind it return Ok((Ref::Client(Client { rkey: stem, record: value, uri: None, cid: None }), &words[1..])); } let base = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); if value.get("instances").is_some() { let (comp, manifest, libs) = composition_libs(net, &value, None, base).await?; return Ok((Ref::Composition { name: stem, id: real_path(path)?, cid: None, comp, manifest, libs }, &words[1..])); } // stage.json or atlibs.lock.json: return Ok((from_lock(net, base, &value, second()?).await?, &words[2..])); } let lock = read_lock(&dir.join(LOCK))?; if lock["modules"].get(first).is_some() || lock["compositions"].get(first).is_some() { return Ok((from_lock(net, dir, &lock, first).await?, &words[1..])); } let cwd = std::env::current_dir().map_err(|e| e.to_string())?; let found = module_dir(&cwd, first).ok_or_else(|| format!("no module {first}: not in {}, not a module directory (module.json), an at:// URI or /", dir.join(LOCK).display()))?; Ok((Ref::Module(local_lib(&found)?), &words[1..])) } /// `name` in a lockfile (or stage.json) in `dir`: a vendored module, or a composition next to its modules. async fn from_lock(net: &Net, dir: &Path, lock: &Value, name: &str) -> Result { if lock["modules"].get(name).is_some() { return Ok(Ref::Module(lock_lib(dir, lock, name)?)); } if let Some(file) = lock["compositions"][name]["file"].as_str() { let path = dir.join(file); let (comp, manifest, libs) = composition_libs(net, &read_json(&path)?, None, dir).await?; let entry = &lock["compositions"][name]; let id = match entry["uri"].as_str() { Some(uri) => uri.to_string(), None => real_path(&path)?, }; return Ok(Ref::Composition { name: name.into(), id, cid: entry["cid"].as_str().map(String::from), comp, manifest, libs }); } let known: Vec<&str> = ["modules", "compositions"].iter().flat_map(|k| lock[k].as_object().into_iter().flatten().map(|(k, _)| k.as_str())).collect(); Err(format!("no module {name} in {} (it has: {})", dir.display(), known.join(", "))) } /// What `stage`/`oauth` work from: modules, and compositions (name -> rewritten manifest). #[derive(Default)] pub struct Set { pub libs: Vec, pub compositions: Vec<(String, Value)>, /// clientMetadata records named directly (a page that runs no module, only signs in) pub clients: Vec, } impl Set { fn push(&mut self, lib: Lib) -> Result<(), String> { match self.libs.iter().find(|l| l.key == lib.key) { Some(l) if l.pin() == lib.pin() => Ok(()), Some(_) => Err(format!("module {} named twice", lib.key)), None => Ok(self.libs.push(lib)), } } /// A module comes with its hook defaults (defaults_of: from its lockfile when pinned), so the staged /// site has them beside it. async fn add(&mut self, net: &Net, found: Ref) -> Result<(), String> { match found { Ref::Module(lib) => { for d in defaults_of(net, &lib, lib.pinned.as_deref()).await? { self.push(d)?; } self.push(lib) } Ref::Composition { name, manifest, libs, .. } => { for lib in libs { self.push(lib)?; } Ok(self.compositions.push((name, manifest))) } Ref::Client(c) => Ok(self.clients.push(c)), } } } /// Refs (or, with none, everything in the lockfile in `dir`) as a Set. pub async fn gather(net: &Net, dir: &Path, refs: &[String]) -> Result { let mut set = Set::default(); if refs.is_empty() { let lock_path = dir.join(LOCK); if !lock_path.exists() { return Err(format!("no {}: `atlibs add /` first, or name modules", lock_path.display())); } let lock = read_lock(&lock_path)?; for k in ["modules", "compositions"].iter().flat_map(|k| lock[k].as_object().into_iter().flatten().map(|(k, _)| k)) { set.add(net, from_lock(net, dir, &lock, k).await?).await?; } return Ok(set); } let mut words = refs; while !words.is_empty() { let (found, rest) = resolve_ref(net, dir, words).await?; set.add(net, found).await?; words = rest; } Ok(set) } // ---- commands ------------------------------------------------------------------------------ /// atlibs add /[@pin]... [--dir .] pub async fn add(cli: &Cli, argv: &[String]) -> Result<(), String> { let args = Args::parse(argv, &[])?; args.only(&["dir"], "add")?; if args.pos.is_empty() { return Err(crate::usage("add")); } let dir = PathBuf::from(args.flag("dir").unwrap_or(".")); let lock_path = dir.join(LOCK); let mut lock = read_lock(&lock_path)?; for (i, r) in args.pos.iter().enumerate() { if !is_remote(r) { return Err(format!("{r}: add takes published refs, /[@pin|@cid] or an at:// URI\n{}", crate::usage("add"))); } match resolve_ref(&cli.net, &dir, &args.pos[i..]).await?.0 { Ref::Composition { name, id, cid, manifest, mut libs, .. } => { for lib in libs.iter_mut() { if lib.reference.is_none() && lib.uri.is_some() { lib.reference = Some(named(&cli.net, &lib.id).await); } vendor(&dir, &mut lock, lib)?; } let file = format!("{name}.composition.json"); write_json(&dir.join(&file), &json!(manifest.as_object().cloned().unwrap_or_default().into_iter().chain([("uri".to_string(), json!(id))]).collect::>()))?; let shown = shown(&cli.net, r, &id).await; lock["compositions"][&name] = json!({ "ref": shown, "uri": id, "cid": cid, "file": file }); eprintln!("added composition {name} ({}): {}", bare(&shown), libs.iter().map(|l| l.key.as_str()).collect::>().join(", ")); } Ref::Module(mut lib) => { lib.reference = Some(shown(&cli.net, r, &lib.id).await); // its hook defaults as published now, vendored beside it: a run of it takes them from here for mut d in defaults_of(&cli.net, &lib, None).await? { d.reference = Some(named(&cli.net, &d.id).await); vendor(&dir, &mut lock, &d)?; eprintln!("added {} ({}) pin {}, a hook default of {}", d.key, bare(d.reference.as_deref().unwrap_or_default()), d.pin(), lib.key); } vendor(&dir, &mut lock, &lib)?; eprintln!("added {} ({}) pin {}", lib.key, bare(lib.reference.as_deref().unwrap_or_default()), lib.pin()); } Ref::Client(c) => return Err(format!("{} is not a {MODULE} or {COMPOSITION} record", c.uri.unwrap_or_default())), } } write_json(&lock_path, &lock)?; eprintln!("wrote {}", lock_path.display()); write_bindings(&dir, &[]) } pub const BINDINGS: &str = "atlibs.modules"; /// atlibs.modules.js and .d.ts in `dir`: a binding per module atlibs.lock.json vendors (pinned), per module /// directory under modules/ (a module repo's own, unpinned) and per one in `extra`, for compose/build.js. The /// .d.ts is the same JSON as a literal type. pub fn write_bindings(dir: &Path, extra: &[PathBuf]) -> Result<(), String> { let read = |p: &Path| -> Result { serde_json::from_slice(&std::fs::read(p).map_err(|e| format!("{}: {e}", p.display()))?).map_err(|e| format!("{}: {e}", p.display())) }; let lock = read_lock(&dir.join(LOCK))?; let mut modules: Vec<(String, Option, Value)> = vec![]; for (key, e) in lock["modules"].as_object().into_iter().flatten() { let spec = e["spec"].as_str().ok_or_else(|| format!("{key}: no spec in {LOCK}"))?; modules.push((key.clone(), e["pin"].as_str().map(String::from), read(&dir.join(spec))?)); } let mut own: Vec = std::fs::read_dir(dir.join("modules")).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.join("module.json").is_file()).collect(); own.sort(); for d in own.iter().chain(extra) { let key = std::fs::canonicalize(d).map_err(|e| format!("{}: {e}", d.display()))?.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_string(); if !modules.iter().any(|(k, ..)| *k == key) { modules.push((key, None, read(&d.join("module.json"))?)); } } let head = "// generated by atlibs compose bindings from module.json: do not edit"; let (mut js, mut dts) = (vec!["/* @ts-self-types=\"./atlibs.modules.d.ts\" */".to_string(), head.into(), "const lock = {};".into()], vec![head.to_string()]); for (key, pin, spec) in &modules { let mut b = Map::new(); b.insert("key".into(), json!(key)); if let Some(p) = pin { b.insert("pin".into(), json!(p)); } for k in ["session", "calls", "anyHooks", "hooks", "implements", "config"] { if let Some(v) = spec.get(k).filter(|v| !v.is_null()) { b.insert(k.into(), v.clone()); } } b.insert("entries".into(), json!(spec["actions"].as_array().into_iter().flatten().filter_map(|a| a["entry"].as_str()).collect::>())); let (name, body) = (camel(key), Value::Object(b).to_string()); js.push(format!("export const {name} = (lock[{}] = {body});", json!(key))); dts.push(format!("export declare const {name}: {body};")); } js.push("for (const m of Object.values(lock)) Object.defineProperty(m, \"lock\", { value: lock });".into()); for (ext, lines) in [("js", js), ("d.ts", dts)] { let path = dir.join(format!("{BINDINGS}.{ext}")); std::fs::write(&path, format!("{}\n", lines.join("\n"))).map_err(|e| format!("{}: {e}", path.display()))?; } eprintln!("wrote {}", dir.join(format!("{BINDINGS}.js")).display()); Ok(()) } /// A module key as a JS name: vm-mcp -> vmMcp. fn camel(key: &str) -> String { let mut out = String::new(); let mut up = false; for c in key.chars() { if c == '-' || c == '_' { up = !out.is_empty(); } else { out.push(if up { c.to_ascii_uppercase() } else { c }); up = false; } } out } /// `atlibs compose bindings [--dir .] [ ...]`: write_bindings. pub fn compose_bindings(argv: &[String]) -> Result<(), String> { let args = Args::parse(argv, &[])?; args.only(&["dir"], "compose bindings")?; let dirs: Vec = args.pos.iter().map(PathBuf::from).collect(); write_bindings(Path::new(args.flag("dir").unwrap_or(".")), &dirs) } /// A shown ref without its @pin (add prints the pin itself). fn bare(shown: &str) -> &str { shown.split('@').next().unwrap_or(shown) } fn origin_flag(args: &Args) -> Result { let origin = args.flag("origin").ok_or("--origin is required")?; let url = url::Url::parse(origin).map_err(|_| format!("--origin {origin} is not a URL"))?; if url.scheme() != "https" { return Err("--origin must be https (localhost pages use the loopback client: clientId() in javascript/src/oauth)".into()); } Ok(url.to_string()) } /// session -> record, in the order modules name them. fn records(set: &Set, need: bool) -> Result, String> { let mut records = Map::new(); for lib in &set.libs { match (&lib.client, lib.spec["session"].as_str()) { (Some(c), _) => { records.entry(c.rkey.clone()).or_insert_with(|| c.record.clone()); } (None, Some(s)) if need => return Err(format!("{}: no records/{s}.json (its session's clientMetadata record)", lib.key)), _ => {} } } for c in &set.clients { if records.insert(c.rkey.clone(), c.record.clone()).is_some_and(|had| had != c.record) { return Err(format!("session {} named twice, with different records", c.rkey)); } } Ok(records) } /// atlibs stage [--target spa|server] [--out dist] [--origin URL (spa only; default a TEMPLATE_ORIGIN template)] [--oauth-path oauth/] [--name N [--record F]] [--dir .] [ref...] pub async fn stage(cli: &Cli, argv: &[String]) -> Result<(), String> { let args = Args::parse(argv, &[])?; args.only(&["target", "out", "origin", "oauth-path", "name", "record", "dir"], "stage")?; let target = args.flag("target").unwrap_or("spa"); if target != "spa" && target != "server" { return Err(format!("--target must be spa or server\n{}", crate::usage("stage"))); } let server = target == "server"; if server && args.has("name") { return Err("--name composes OAuth clients for a page; --target server mounts each session's (ClientMetadataHandler)".into()); } if server && args.has("origin") { return Err("--origin is a page's; a --target server is told its origin when it serves (atlibs serve --origin, ATLIBS_ORIGIN)".into()); } // spa with no --origin: a template on TEMPLATE_ORIGIN, filled at deploy time let origin = match (server, args.has("origin")) { (true, _) => None, (false, true) => Some(origin_flag(&args)?), (false, false) => Some(format!("{TEMPLATE_ORIGIN}/")), }; let template = origin.as_deref() == Some(format!("{TEMPLATE_ORIGIN}/").as_str()); let out = PathBuf::from(args.flag("out").unwrap_or("dist")); let oauth_path = args.flag("oauth-path").unwrap_or("oauth/").trim_matches('/').to_string(); let set = gather(&cli.net, Path::new(args.flag("dir").unwrap_or(".")), &args.pos).await?; let records = records(&set, !server)?; let mut modules = Map::new(); for lib in &set.libs { let (wasm, spec) = (format!("modules/{}.wasm", lib.key), format!("modules/{}.json", lib.key)); write_bytes(&out.join(&wasm), &lib.wasm)?; write_json(&out.join(&spec), &lib.spec)?; modules.insert(lib.key.clone(), json!({ "session": lib.client.as_ref().map(|c| &c.rkey).or(lib.spec["session"].as_str().map(String::from).as_ref()), "wasm": wasm, "spec": spec, "sha256": lib.sha256, "pin": lib.pin() })); } let manifest = if let Some(origin) = origin { let handler = ClientMetadataHandler { records: records.clone(), path: Some(format!("/{oauth_path}/")), origin: Some(origin.clone()), redirect_uri: None, name: args.flag("name").map(String::from) }; let base = origin.trim_end_matches('/'); let mut sessions = Map::new(); for session in records.keys() { let doc = handler.document(base, session).expect("a record")?; write_json(&out.join(format!("{oauth_path}/{session}/client-metadata.json")), &doc)?; let mut entry = json!({ "clientId": doc["client_id"], "scope": doc["scope"] }); let optional: Vec<&str> = records[session]["scopeGroups"].as_array().into_iter().flatten().filter(|g| g["optional"] == true).flat_map(|g| g["scopes"].as_array().into_iter().flatten().filter_map(Value::as_str)).collect(); if !optional.is_empty() { entry["required"] = json!(doc["scope"].as_str().unwrap_or_default().split(' ').filter(|s| !optional.contains(s)).collect::>().join(" ")); } sessions.insert(session.clone(), entry); } let mut manifest = json!({ "origin": origin, "clientId": null, "scope": null, "sessions": sessions, "modules": modules }); if let Some(name) = args.flag("name") { if records.contains_key(name) { return Err(format!("--name {name} is already a session name")); } let doc = handler.document(base, name).expect("the composite")?; write_json(&out.join(format!("{oauth_path}/{name}/client-metadata.json")), &doc)?; manifest["clientId"] = doc["client_id"].clone(); manifest["scope"] = doc["scope"].clone(); if let Some(file) = args.flag("record") { write_json(Path::new(file), &compose_record(&records, name, doc["client_id"].as_str().unwrap_or_default(), &format!("{base}/"), &iso_now())?)?; } } else if let [(_, one)] = &sessions.iter().collect::>()[..] { manifest["clientId"] = one["clientId"].clone(); manifest["scope"] = one["scope"].clone(); } if !template && records.values().any(|r| r.get("app").is_some()) { manifest["app"] = stage_app(cli, &records, &origin, &out).await?; } manifest } else { let sessions: Map = records.into_iter().map(|(k, record)| (k, json!({ "record": record }))).collect(); json!({ "target": target, "oauthPath": format!("/{oauth_path}/"), "sessions": sessions, "modules": modules }) }; let single = set.compositions.len() == 1; for (name, comp) in &set.compositions { let mut comp = comp.clone(); comp["target"] = json!(target); write_json(&out.join(if single { "composition.json".into() } else { format!("{name}.composition.json") }), &comp)?; } write_json(&out.join("stage.json"), &manifest)?; out!("{}", pretty(&manifest)); if template { let dir = out.display(); eprintln!("no --origin: {TEMPLATE_ORIGIN} is a placeholder; at deploy, with ORIGIN=https://host/page (no trailing slash):\n find {dir}/stage.json {dir}/{oauth_path} -name '*.json' -exec sed -i.bak \"s#{TEMPLATE_ORIGIN}#$ORIGIN#g\" {{}} + && find {dir} -name '*.json.bak' -delete\n (or stage again with --origin)"); } Ok(()) } /// The app identity (AppRepo) as files a static host serves at the origin's root: the DID document, atproto-did, /// the wellKnown files, and the repo reads a static host can answer whatever the query (describeRepo; getRecord /// and listRecords only when there is one record: a static file cannot pick by collection and rkey). async fn stage_app(cli: &Cli, records: &Map, origin: &str, out: &Path) -> Result { let site = url::Url::parse(origin).map_err(|_| format!("--origin {origin} is not a URL"))?; let root = site.origin().ascii_serialization(); let key = cli.store()?.didkey(&format!("app:{root}")).await?; let app = AppRepo::new(records, &root, key.did.trim_start_matches("did:key:"))?.ok_or("no app record")?; let did = app.did.clone(); let mut paths: Vec = ["/.well-known/did.json", "/.well-known/atproto-did"].map(String::from).to_vec(); paths.extend(app.well_known.keys().map(|f| format!("/.well-known/{f}"))); paths.push(format!("/xrpc/com.atproto.repo.describeRepo?repo={did}")); if let [(collection, rkey, ..)] = &app.records[..] { paths.push(format!("/xrpc/com.atproto.repo.getRecord?repo={did}&collection={collection}&rkey={rkey}")); paths.push(format!("/xrpc/com.atproto.repo.listRecords?repo={did}&collection={collection}")); } else { eprintln!("app {did}: {} records; a static host serves no getRecord or listRecords for more than one (serve them: atlibs serve --origin)", app.records.len()); } let mut files = vec![]; for path in paths { let (_, _, body) = app.answer(&path).ok_or("an app path")?; let file = path.split('?').next().unwrap_or_default().trim_start_matches('/').to_string(); write_bytes(&out.join(&file), body.as_bytes())?; files.push(file); } if site.path() != "/" { eprintln!("app {did}: a did:web resolves at the host root: serve {} from {root}/, not {origin}", files.join(", ")); } Ok(json!({ "did": did, "files": files })) } /// atlibs oauth --origin URL [--oauth-path oauth/] [--name N] [--dir .] [ref...]: the client metadata /// documents an origin serves, by session. pub async fn oauth(cli: &Cli, argv: &[String]) -> Result<(), String> { let args = Args::parse(argv, &[])?; args.only(&["origin", "oauth-path", "name", "dir"], "oauth")?; let origin = args.flag("origin").ok_or_else(|| format!("--origin is required\n{}", crate::usage("oauth")))?; url::Url::parse(origin).map_err(|_| format!("--origin {origin} is not a URL"))?; let set = gather(&cli.net, Path::new(args.flag("dir").unwrap_or(".")), &args.pos).await?; let handler = ClientMetadataHandler { records: records(&set, false)?, path: Some(format!("/{}/", args.flag("oauth-path").unwrap_or("oauth/").trim_matches('/'))), name: args.flag("name").map(String::from), ..Default::default() }; let mut docs = Map::new(); for session in handler.records.keys().chain(handler.name.as_ref()) { docs.insert(session.clone(), handler.document(origin.trim_end_matches('/'), session).expect("known")?); } out!("{}", pretty(&Value::Object(docs))); Ok(()) } /// atlibs verify /: rebuild a published module from its source records in a container /// (`engine()`) and compare the sha256 with its manifest and its blob. /// `atlibs compose check `: the hosts' load checks with no wasm (compose::check_specs). /// Prints the composition as it would run (hook defaults added); a failure is the error, exit 1. pub async fn compose_check(cli: &Cli, argv: &[String]) -> Result<(), String> { let args = Args::parse(argv, &[])?; args.only(&[], "compose check")?; let [file] = &args.pos[..] else { return Err(crate::usage("compose check")) }; let path = Path::new(file); let manifest: Value = serde_json::from_slice(&std::fs::read(path).map_err(|e| format!("{file}: {e}"))?).map_err(|e| format!("{file}: {e}"))?; let base = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")).to_path_buf(); let net = cli.net.clone(); let loader: compose::ModuleLoader = Arc::new(move |reference: String, pin: Option| { let (net, base) = (net.clone(), base.clone()); Box::pin(async move { spec_module_at(&net, &reference, &base, pin.as_deref()).await }) }); out!("{}", pretty(&compose::check_specs(&manifest, &loader).await?)); Ok(()) } /// A module ref's module for compose check, wasm read only where it is fetched: atlibs.lock.json's entry in /// `base` (its spec and pin), stage.json's, a published one (fetched and checked like `add`), else a module /// directory's module.json (no pin). async fn spec_module_at(net: &Net, reference: &str, base: &Path, pin: Option<&str>) -> Result { let read = |p: &Path| -> Result { serde_json::from_slice(&std::fs::read(p).map_err(|e| format!("{}: {e}", p.display()))?).map_err(|e| format!("{}: {e}", p.display())) }; for file in [LOCK, "stage.json"] { if !base.join(file).is_file() { continue; } let entry = &read(&base.join(file))?["modules"][reference]; if let Some(spec) = entry["spec"].as_str() { return compose::spec_module(&Value::Object(spec_of(&read(&base.join(spec))?)), entry["pin"].as_str()); } } if is_remote(reference) { return atlibs_host::registry::module_of(&instance_lib(net, reference, None, base, pin).await?); } let cwd = std::env::current_dir().map_err(|e| e.to_string())?; let dir = module_dir(base, reference).or_else(|| module_dir(&cwd, reference)).ok_or_else(|| format!("no module {reference} (atlibs.lock.json or stage.json beside the composition, a module directory, modules/ or examples/rust/, or a published ref)"))?; compose::spec_module(&Value::Object(spec_of(&read(&dir.join("module.json"))?)), None) } pub async fn verify(cli: &Cli, argv: &[String]) -> Result<(), String> { let args = Args::parse(argv, &[])?; args.only(&[], "verify")?; let [reference] = &args.pos[..] else { return Err(crate::usage("verify")) }; let found = if is_remote(reference) { Some(resolve_ref(&cli.net, Path::new("."), &args.pos).await?.0) } else { None }; let Some(Ref::Module(lib)) = found else { return Err(format!("{reference}: verify takes a published module\n{}", crate::usage("verify"))) }; let engine = engine()?; let work = std::env::temp_dir().join(format!("atlibs-verify-{}", atlibs_host::store::uuid())); let report = rebuild(cli, &lib, &engine, &work).await; let _ = std::fs::remove_dir_all(&work); let report = report?; out!("{}", pretty(&report)); if report["reproducible"] == true { Ok(()) } else { Err("not reproducible".into()) } } /// The container engine: CONTAINER_ENGINE, else the first of podman, docker and Apple's `container` (macOS) /// that answers `info` (`container system status`). Not `--version`: that also passes for a docker CLI whose /// daemon is down, or a docker look-alike whose `run` ends before the command does and still exits 0. pub fn engine() -> Result { if let Some(engine) = env("CONTAINER_ENGINE") { return Ok(engine); } let up = |engine: &str, probe: &[&str]| std::process::Command::new(engine).args(probe).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).status().is_ok_and(|s| s.success()); [("podman", &["info"][..]), ("docker", &["info"]), ("container", &["system", "status"])] .into_iter() .find(|(engine, probe)| up(engine, probe)) .map(|(engine, _)| engine.to_string()) .ok_or_else(|| "no container engine answers (podman info, docker info, container system status): start one, or set CONTAINER_ENGINE".into()) } /// The reference build, shared by `atlibs publish` and `verify`: `command` in docker.io/library/rust: with `src` at /src. Not on the host: cargo hashes `rustc -vV` (its `host:` /// line too) into every crate's -C metadata, which changes the wasm, so a macOS build never equals a Linux rebuild. pub fn container_build(engine: &str, src: &Path, rustc_version: &str, command: &str) -> Result<(), String> { let image = format!("docker.io/library/rust:{}", rustc_version.split(' ').nth(1).unwrap_or("latest")); eprintln!("building with '{command}' in {image} ({engine})"); let status = std::process::Command::new(engine) .args(["run", "--rm", "-v", &format!("{}:/src:Z", src.display()), "-w", "/src", &image, "bash", "-c", &format!("rustup target add wasm32-unknown-unknown >/dev/null && {command}")]) .stdout(std::io::stderr()) .status() .map_err(|e| format!("{engine}: {e}"))?; if !status.success() { return Err(format!("the build in {image} failed")); } Ok(()) } /// Writes the manifest's source files under `work`/src, builds them in its image with `engine`, and reports /// {built, manifest, published, reproducible}. async fn rebuild(cli: &Cli, lib: &Lib, engine: &str, work: &Path) -> Result { let uri = lib.id.clone(); let (_, module) = cli.net.record(&uri).await?.ok_or_else(|| format!("{uri} not found"))?; let source = module["source"]["uri"].as_str().ok_or_else(|| format!("{uri} has no source"))?; let (cid, manifest) = cli.net.record(source).await?.ok_or_else(|| format!("{source} not found"))?; if module["source"]["cid"] != cid.as_str() { return Err("the manifest's CID does not match the module's source reference".into()); } for entry in manifest["files"].as_array().into_iter().flatten() { let path = entry["path"].as_str().unwrap_or_default(); if path.is_empty() || path.starts_with('/') || path.split('/').any(|p| p == "..") { return Err(format!("refusing path {path}")); } let file_uri = entry["file"]["uri"].as_str().unwrap_or_default(); let (file_cid, file) = cli.net.record(file_uri).await?.ok_or_else(|| format!("{file_uri} not found"))?; let text = file["text"].as_str().unwrap_or_default(); if entry["file"]["cid"] != file_cid.as_str() || file["sha256"] != sha256_hex(text.as_bytes()).as_str() { return Err(format!("{path}: does not match its record or the manifest")); } write_bytes(&work.join("src").join(path), text.as_bytes())?; } let build = &manifest["build"]; if build["tool"] != "cargo" { return Err(format!("unknown build tool {}", build["tool"])); } let artifact = build["artifact"].as_str().unwrap_or_default(); eprintln!("rebuilding {}", manifest["name"]); container_build(engine, &work.join("src"), build["version"].as_str().unwrap_or_default(), build["command"].as_str().unwrap_or_default())?; let built = std::fs::read(work.join("src").join(artifact)).map_err(|e| format!("{engine} run exited 0 but the build left no {artifact} ({e}): is {engine} a working docker-compatible engine? (CONTAINER_ENGINE)"))?; let built = sha256_hex(&built); let expected = manifest["output"]["sha256"].as_str().unwrap_or_default(); Ok(json!({ "built": built, "manifest": expected, "published": lib.sha256, "reproducible": built == expected && built == lib.sha256 })) } #[cfg(test)] pub mod tests { use super::*; use crate::fake::Pds; #[tokio::test] async fn add_pins_vendors_and_checks_blobs_then_stage_writes_clients_at_the_paths() { let pds = Pds::new(); let module = pds.publish_module("alice.test", "echo", b"\0asm-echo", Some("echo")); let dir = tempdir("add"); let cli = pds.cli(&dir); add(&cli, &["alice.test/echo".into(), "--dir".into(), dir.display().to_string()]).await.unwrap(); let lock = read_lock(&dir.join(LOCK)).unwrap(); assert_eq!(lock["modules"]["echo"]["uri"], module); let spec: Value = serde_json::from_slice(&std::fs::read(dir.join("modules/echo.json")).unwrap()).unwrap(); assert_eq!(spec["uri"], module); assert!(spec.get("owner").is_none() && spec.get("id").is_none(), "a record cannot choose its identity: {spec}"); assert_eq!(lock["modules"]["echo"]["ref"], "alice.test/echo"); assert_eq!(std::fs::read(dir.join("modules/echo.wasm")).unwrap(), b"\0asm-echo"); assert_eq!(lock["sessions"]["echo"]["record"]["name"], "echo"); // and the bindings compose/build.js imports: the module, pinned let js = std::fs::read_to_string(dir.join("atlibs.modules.js")).unwrap(); let pin = lock["modules"]["echo"]["pin"].as_str().unwrap(); assert!(js.contains(&format!(r#"export const echo = (lock["echo"] = {{"key":"echo","pin":"{pin}""#)), "{js}"); assert!(std::fs::read_to_string(dir.join("atlibs.modules.d.ts")).unwrap().contains(&format!(r#"export declare const echo: {{"key":"echo","pin":"{pin}""#))); // a pin that does not match, a handle the DID document does not claim, a blob that does not match assert!(add(&cli, &["alice.test/echo@deadbeef00".into(), "--dir".into(), dir.display().to_string()]).await.unwrap_err().contains("too short")); assert!(add(&cli, &[format!("alice.test/echo@{}", "d".repeat(32)), "--dir".into(), dir.display().to_string()]).await.unwrap_err().contains(&format!("pin {} not published", "d".repeat(32)))); assert!(add(&cli, &["mallory.test/echo".into(), "--dir".into(), dir.display().to_string()]).await.unwrap_err().contains("does not claim")); pds.corrupt_blob(&module); assert!(add(&cli, &["alice.test/echo".into(), "--dir".into(), dir.display().to_string()]).await.unwrap_err().contains("does not match its CID")); let out = dir.join("dist"); let argv = |extra: &[&str]| -> Vec { ["--out", out.to_str().unwrap(), "--dir", dir.to_str().unwrap()].iter().chain(extra).map(|s| s.to_string()).collect() }; stage(&cli, &argv(&["--origin", "https://site.example/app", "--oauth-path", "auth/clients"])).await.unwrap(); let doc: Value = serde_json::from_slice(&std::fs::read(out.join("auth/clients/echo/client-metadata.json")).unwrap()).unwrap(); assert_eq!(doc["client_id"], "https://site.example/app/auth/clients/echo/client-metadata.json"); assert_eq!(doc["redirect_uris"], json!(["https://site.example/app/"])); let staged: Value = serde_json::from_slice(&std::fs::read(out.join("stage.json")).unwrap()).unwrap(); assert_eq!(staged["clientId"], doc["client_id"]); assert_eq!(std::fs::read(out.join("modules/echo.wasm")).unwrap(), b"\0asm-echo"); assert!(stage(&cli, &argv(&["--origin", "http://site.example"])).await.unwrap_err().contains("must be https")); // no --origin: a template whose one literal the deploy step replaces stage(&cli, &argv(&["--oauth-path", "auth/clients"])).await.unwrap(); let fill = |p: &str| -> Value { serde_json::from_str(&std::fs::read_to_string(out.join(p)).unwrap().replace(TEMPLATE_ORIGIN, "https://site.example/app")).unwrap() }; let (staged, filled) = (fill("stage.json"), fill("auth/clients/echo/client-metadata.json")); assert_eq!((&staged["clientId"], &filled["client_id"], &filled["redirect_uris"]), (&doc["client_id"], &doc["client_id"], &doc["redirect_uris"])); assert!(stage(&cli, &argv(&["--target", "server", "--origin", "https://site.example/"])).await.unwrap_err().starts_with("--origin is a page's")); stage(&cli, &argv(&["--target", "server"])).await.unwrap(); let staged: Value = serde_json::from_slice(&std::fs::read(out.join("stage.json")).unwrap()).unwrap(); assert_eq!(staged["target"], "server"); assert_eq!(staged["sessions"]["echo"]["record"]["name"], "echo"); let handler = ClientMetadataHandler::from_stage(&staged); assert_eq!(handler.document("https://srv.example", "echo").unwrap().unwrap()["client_id"], "https://srv.example/oauth/echo/client-metadata.json"); oauth(&cli, &["--origin".into(), "https://x.example".into(), "--dir".into(), dir.display().to_string()]).await.unwrap(); assert!(oauth(&cli, &["--dir".into(), dir.display().to_string()]).await.unwrap_err().contains("--origin")); assert!(stage(&cli, &argv(&["--orign", "x"])).await.unwrap_err().contains("unknown flag --orign")); } #[tokio::test] async fn stage_a_client_record_with_no_module() { let pds = Pds::new(); let dir = tempdir("client"); let cli = pds.cli(&dir); let record = dir.join("agents-page.json"); write_json(&record, &json!({ "$type": CLIENT, "name": "agents page", "clientMetadata": { "client_id": "https://tpl.test/c.json", "scope": "atproto rpc:a.b.c?aud=*" }, "createdAt": "x" })).unwrap(); let (uri, _) = pds.put("alice.test", CLIENT, "remote", json!({ "$type": CLIENT, "name": "remote", "clientMetadata": { "client_id": "https://tpl.test/c.json", "scope": "atproto" }, "createdAt": "x" })); let out = dir.join("site"); let argv = |refs: &[&str]| -> Vec { ["--origin", "https://site.example/agents/", "--out", out.to_str().unwrap()].iter().chain(refs).map(|s| s.to_string()).collect() }; stage(&cli, &argv(&[record.to_str().unwrap()])).await.unwrap(); let staged: Value = serde_json::from_slice(&std::fs::read(out.join("stage.json")).unwrap()).unwrap(); assert_eq!(staged["clientId"], "https://site.example/agents/oauth/agents-page/client-metadata.json"); assert_eq!(staged["scope"], "atproto rpc:a.b.c?aud=*"); assert_eq!(staged["modules"], json!({})); stage(&cli, &argv(&[record.to_str().unwrap(), &uri, "--name", "both"])).await.unwrap(); assert!(out.join("oauth/remote/client-metadata.json").is_file()); let both: Value = serde_json::from_slice(&std::fs::read(out.join("oauth/both/client-metadata.json")).unwrap()).unwrap(); assert_eq!(both["scope"], "atproto rpc:a.b.c?aud=*"); } #[tokio::test] async fn add_a_composition_vendors_its_modules_next_to_it() { let pds = Pds::new(); pds.publish_module("alice.test", "echo", b"\0asm-echo", None); pds.put_composition("alice.test", "pair", json!({ "$type": COMPOSITION, "name": "pair", "instances": [{ "name": "a", "module": "echo" }, { "name": "b", "module": "echo" }], "createdAt": "x" })); let dir = tempdir("comp"); let cli = pds.cli(&dir); add(&cli, &["alice.test/pair".into(), "--dir".into(), dir.display().to_string()]).await.unwrap(); let comp: Value = serde_json::from_slice(&std::fs::read(dir.join("pair.composition.json")).unwrap()).unwrap(); assert_eq!(comp["instances"][0]["module"], "echo"); let lock = read_lock(&dir.join(LOCK)).unwrap(); assert_eq!(comp["instances"][0]["pin"], lock["modules"]["echo"]["pin"]); let words = [dir.join("pair.composition.json").display().to_string()]; let Ref::Composition { comp, .. } = resolve_ref(&cli.net, &dir, &words).await.unwrap().0 else { panic!("a composition") }; assert_eq!(comp.modules.len(), 2); } /// atlibs_host::registry::published, what an embedding host (knotless-run) loads a binding's composition with. #[tokio::test] async fn a_host_loads_a_published_composition_pinned_by_cid() { let pds = Pds::new(); pds.publish_module("alice.test", "echo", b"\0asm-echo", None); let (uri, cid) = pds.put_composition("alice.test", "pair", json!({ "$type": COMPOSITION, "name": "pair", "instances": [{ "name": "a", "module": "echo" }], "createdAt": "x" })); let cli = pds.cli(&tempdir("published")); let (found, comp, libs) = atlibs_host::registry::published(&cli.net, &uri, Some(&cid)).await.unwrap(); assert_eq!((found, comp.modules.len(), libs[0].1.key.as_str()), (cid.clone(), 1, "echo")); let err = atlibs_host::registry::published(&cli.net, &uri, Some("bafyother")).await.err().unwrap(); assert_eq!(err, format!("{uri}: version bafyother not published ({uri}.bafyother not found)")); // republished: the binding's CID still loads its version; unpinned, the latest pds.put_composition("alice.test", "pair", json!({ "$type": COMPOSITION, "name": "pair", "instances": [{ "name": "a", "module": "echo" }, { "name": "b", "module": "echo" }], "createdAt": "x" })); assert_eq!(atlibs_host::registry::published(&cli.net, &uri, Some(&cid)).await.unwrap().1.modules.len(), 1); assert_eq!(atlibs_host::registry::published(&cli.net, &uri, None).await.unwrap().1.modules.len(), 2); assert!(atlibs_host::registry::published(&cli.net, &uri, Some("../x")).await.err().unwrap().contains("is not a record CID")); } /// A republished module leaves every composition that pins it as it was (knotless-run's binding broke on it). #[tokio::test] async fn a_pinned_instance_survives_its_modules_republish() { let pds = Pds::new(); let module = pds.publish_module("alice.test", "echo", b"\0asm-echo", None); let cli = pds.cli(&tempdir("republish")); let pin = fetch_lib(&cli.net, &module, None, None).await.unwrap().pin(); let (uri, cid) = pds.put_composition("alice.test", "pair", json!({ "$type": COMPOSITION, "name": "pair", "instances": [{ "name": "a", "module": "echo", "pin": pin }], "createdAt": "x" })); pds.publish_module("alice.test", "echo", b"\0asm-echo-2", None); let (_, comp, libs) = atlibs_host::registry::published(&cli.net, &uri, Some(&cid)).await.unwrap(); assert_eq!((comp.modules["a"].pin.as_str(), libs[0].1.id.as_str(), libs[0].1.key.as_str()), (pin.as_str(), module.as_str(), "echo")); // a pin publish never wrote (published before pins were kept): refused, by name let gone = "0".repeat(64); pds.put_composition("alice.test", "pair", json!({ "$type": COMPOSITION, "name": "pair", "instances": [{ "name": "a", "module": "echo", "pin": gone }], "createdAt": "x" })); let err = atlibs_host::registry::published(&cli.net, &uri, None).await.err().unwrap(); assert_eq!(err, format!("{module}: pin {gone} not published ({module}.{} not found)", &gone[..32])); } /// Every ref form, through resolve_ref, as stage/oauth (gather), run and serve (run::target_of) see it. #[tokio::test] async fn every_ref_form_resolves_the_same_for_every_command() { let pds = Pds::new(); let module = pds.publish_module("alice.test", "echo", b"\0asm-echo", Some("echo")); let (comp_uri, comp_cid) = pds.put_composition("alice.test", "pair", json!({ "$type": COMPOSITION, "name": "pair", "instances": [{ "name": "a", "module": "echo" }], "createdAt": "x" })); let (client_uri, _) = pds.put("alice.test", CLIENT, "page", json!({ "$type": CLIENT, "name": "page", "clientMetadata": { "client_id": "https://tpl.test/c.json", "scope": "atproto" }, "createdAt": "x" })); let dir = tempdir("refs"); let cli = pds.cli(&dir); let words = |w: &[&str]| -> Vec { w.iter().map(|s| s.to_string()).collect() }; let resolve = |w: Vec| { let (net, dir) = (cli.net.clone(), dir.clone()); async move { resolve_ref(&net, &dir, &w).await.map(|(found, rest)| (found, rest.len())) } }; let kind = |found: &Ref| match found { Ref::Module(lib) => format!("module {}", lib.key), Ref::Composition { name, libs, .. } => format!("composition {name} [{}]", libs.iter().map(|l| l.key.as_str()).collect::>().join(",")), Ref::Client(c) => format!("client {}", c.rkey), }; // published: /, pins (sha256 prefix, record CID), at:// URIs of each collection let cid = pds.get(&module).map(|_| fetch_lib(&cli.net, &module, None, None)).unwrap().await.unwrap().cid.unwrap(); let pin = fetch_lib(&cli.net, &module, None, None).await.unwrap().pin(); for (r, want) in [("alice.test/echo", "module echo"), (&format!("alice.test/echo@{}", &pin[..32]), "module echo"), (&format!("alice.test/echo@{pin}"), "module echo"), (&format!("alice.test/echo@{cid}"), "module echo"), (&module, "module echo"), ("alice.test/pair", "composition pair [echo]"), (&format!("alice.test/pair@{comp_cid}"), "composition pair [echo]"), (&comp_uri, "composition pair [echo]"), (&client_uri, "client page")] { let (found, rest) = resolve(words(&[r, "next"])).await.unwrap_or_else(|e| panic!("{r}: {e}")); assert_eq!((kind(&found), rest), (want.to_string(), 1), "{r}"); } let Ref::Module(lib) = resolve(words(&["alice.test/echo"])).await.unwrap().0 else { panic!() }; assert_eq!((lib.id.as_str(), lib.reference.as_deref()), (module.as_str(), Some("alice.test/echo")), "the run identity is the at:// URI"); // / and the at:// URI name the same record as / (add shows them by the handle, below) let did = crate::fake::did_of("alice.test"); for r in [format!("{did}/echo"), module.clone(), format!("{did}/echo@{cid}")] { let Ref::Module(same) = resolve(words(&[&r])).await.unwrap().0 else { panic!("{r}") }; assert_eq!((same.id.as_str(), same.cid.as_deref(), same.pin()), (module.as_str(), Some(cid.as_str()), pin.clone()), "{r}"); assert_eq!(shown(&cli.net, &r, &same.id).await, if r.contains('@') { format!("alice.test/echo@{cid}") } else { "alice.test/echo".into() }, "{r}"); } assert!(resolve(words(&["alice.test/echo@deadbeef00"])).await.err().unwrap().contains("pin deadbeef00 is too short")); assert!(resolve(words(&["alice.test/pair@bafynope"])).await.err().unwrap().contains("version bafynope not published")); let (other, _) = pds.put("alice.test", "com.example.other", "x", json!({})); assert!(resolve(words(&[&other])).await.err().unwrap().contains("is not a com.fedfork.exec.wasm.module, com.fedfork.exec.composition or com.fedfork.exec.oauth.clientMetadata record")); // local: a module directory (its cargo wasm build above it), let local = dir.join("local"); write_bytes(&local.join("target/wasm32-unknown-unknown/release/toy.wasm"), b"\0asm-toy").unwrap(); write_json(&local.join("toy/module.json"), &json!({ "name": "toy", "actions": [{ "entry": "go" }], "id": "/etc/passwd" })).unwrap(); let toy_dir = local.join("toy").display().to_string(); let toy_wasm = local.join("target/wasm32-unknown-unknown/release/toy.wasm").display().to_string(); let toy_json = local.join("toy/module.json").display().to_string(); assert_eq!(kind(&resolve(words(&[&toy_dir, "go"])).await.unwrap().0), "module toy"); let (pair, rest) = resolve(words(&[&toy_wasm, &toy_json, "go"])).await.unwrap(); assert_eq!((kind(&pair), rest), ("module toy".into(), 1)); assert!(pair.libs()[0].spec.get("id").is_none() && pair.libs()[0].id.ends_with("toy.wasm"), "the id is where the wasm is"); assert!(resolve(words(&[&toy_wasm])).await.err().unwrap().contains("needs a second word")); // a composition .json of module dirs; the same, staged (modules/ beside it, sha256 pinned) let comp_file = local.join("duo.composition.json"); write_json(&comp_file, &json!({ "instances": [{ "name": "x", "module": toy_dir }, { "name": "y", "module": toy_dir }] })).unwrap(); let (found, _) = resolve(words(&[comp_file.to_str().unwrap()])).await.unwrap(); assert_eq!(kind(&found), "composition duo [toy]"); // ... and of published modules by / or /, pins checked let remote_comp = local.join("remote.composition.json"); write_json(&remote_comp, &json!({ "instances": [{ "name": "e", "module": "alice.test/echo" }, { "name": "f", "module": format!("{did}/echo@{pin}") }] })).unwrap(); assert_eq!(kind(&resolve(words(&[remote_comp.to_str().unwrap()])).await.unwrap().0), "composition remote [echo]"); write_json(&remote_comp, &json!({ "instances": [{ "name": "e", "module": format!("alice.test/echo@{}", "0".repeat(32)) }] })).unwrap(); assert!(resolve(words(&[remote_comp.to_str().unwrap()])).await.err().unwrap().contains(&format!("pin {} not published", "0".repeat(32)))); let staged = dir.join("staged"); stage(&cli, &words(&["--target", "server", "--out", staged.to_str().unwrap(), comp_file.to_str().unwrap()])).await.unwrap(); let staged_comp = staged.join("composition.json").display().to_string(); let (found, _) = resolve(words(&[&staged_comp])).await.unwrap(); assert_eq!(kind(&found), "composition composition [toy]"); assert!(matches!(crate::run::target_of(found).unwrap(), crate::run::Target::Composition { .. })); let mut tampered: Value = serde_json::from_str(&std::fs::read_to_string(&staged_comp).unwrap()).unwrap(); tampered["instances"][0]["pin"] = json!("0".repeat(64)); write_json(Path::new(&staged_comp), &tampered).unwrap(); assert_eq!(resolve(words(&[&staged_comp])).await.err().unwrap(), "composition: instance x module does not match its pin"); // stage.json let stage_json = staged.join("stage.json").display().to_string(); assert_eq!(resolve(words(&[&stage_json, "toy", "go"])).await.map(|(f, r)| (kind(&f), r)).unwrap(), ("module toy".into(), 1)); assert!(resolve(words(&[&stage_json, "nope"])).await.err().unwrap().contains("no module nope in")); // a clientMetadata .json, and names in `dir`'s lockfile (module and composition); then gather let client_file = dir.join("agents-page.json"); write_json(&client_file, &json!({ "$type": CLIENT, "name": "agents page", "clientMetadata": { "client_id": "https://tpl.test/c.json", "scope": "atproto" }, "createdAt": "x" })).unwrap(); assert_eq!(kind(&resolve(words(&[client_file.to_str().unwrap()])).await.unwrap().0), "client agents-page"); assert!(crate::run::target_of(resolve(words(&[client_file.to_str().unwrap()])).await.unwrap().0).err().unwrap().contains("no module to run")); add(&cli, &words(&[&format!("{did}/echo"), &comp_uri, "--dir", dir.to_str().unwrap()])).await.unwrap(); let lock = read_lock(&dir.join(LOCK)).unwrap(); assert_eq!((&lock["modules"]["echo"]["ref"], &lock["modules"]["echo"]["uri"], &lock["compositions"]["pair"]["ref"]), (&json!("alice.test/echo"), &json!(module), &json!("alice.test/pair")), "the lockfile shows the handle, keeps the at:// URI"); add(&cli, &words(&["alice.test/echo", "alice.test/pair", "--dir", dir.to_str().unwrap()])).await.unwrap(); assert!(add(&cli, &words(&[&toy_dir, "--dir", dir.to_str().unwrap()])).await.err().unwrap().contains("add takes published refs")); assert!(add(&cli, &words(&[&client_uri, "--dir", dir.to_str().unwrap()])).await.err().unwrap().contains("is not a com.fedfork.exec.wasm.module or com.fedfork.exec.composition record")); let (echo, _) = resolve(words(&["echo"])).await.unwrap(); assert_eq!((kind(&echo), echo.libs()[0].id.as_str()), ("module echo".into(), module.as_str())); let Ref::Composition { id, cid, .. } = resolve(words(&["pair"])).await.unwrap().0 else { panic!("a composition") }; assert_eq!((id, cid), (comp_uri.clone(), Some(comp_cid.clone())), "a lockfile composition keeps its at:// identity"); let lock_json = dir.join(LOCK).display().to_string(); assert_eq!(kind(&resolve(words(&[&lock_json, "pair"])).await.unwrap().0), "composition pair [echo]"); assert!(resolve(words(&["no-such-module"])).await.err().unwrap().contains("no module no-such-module: not in")); assert!(resolve(vec![]).await.err().unwrap().starts_with("name a ref")); let set = gather(&cli.net, &dir, &words(&["echo", &toy_wasm, &toy_json, client_file.to_str().unwrap(), "pair"])).await.unwrap(); assert_eq!(set.libs.iter().map(|l| l.key.as_str()).collect::>(), ["echo", "toy"]); assert_eq!((set.clients[0].rkey.as_str(), set.compositions[0].0.as_str()), ("agents-page", "pair")); let all = gather(&cli.net, &dir, &[]).await.unwrap(); assert_eq!((all.libs.len(), all.compositions.len()), (1, 1)); } /// A module record with hooks: bob's asker, whose policy hook defaults to `pol` (review 4a H1, 4b #1). fn publish_asker(pds: &Pds, handle: &str, rkey: &str, wasm: &[u8], extra: Value) -> String { let mut value = json!({ "$type": MODULE, "name": rkey, "module": pds.blob(wasm), "abi": ABI, "actions": [], "calls": [], "createdAt": "x" }); value.as_object_mut().unwrap().extend(extra.as_object().cloned().unwrap_or_default()); pds.put_module(handle, rkey, value).0 } const H: &str = "com.example.hooks.check"; #[tokio::test] async fn a_hook_default_is_its_modules_publishers_and_pinned_with_it() { let pds = Pds::new(); let asker = publish_asker(&pds, "bob.test", "asker", b"\0asm-asker", json!({ "hooks": [{ "nsid": H, "default": "pol", "combine": "all-allow", "reason": "r" }] })); let pol = publish_asker(&pds, "bob.test", "pol", b"\0asm-pol", json!({ "calls": ["com.atproto.repo.getRecord"], "implements": [{ "nsid": H, "entry": "evaluate" }] })); let dir = tempdir("defaults"); let cli = pds.cli(&dir); // a composition of bob's module, local or published by carol: the default is bob's pol (4b #1) let manifest = json!({ "$type": COMPOSITION, "instances": [{ "name": "a", "module": asker }], "createdAt": "x" }); write_json(&dir.join("c.composition.json"), &manifest).unwrap(); pds.put("carol.test", COMPOSITION, "c", manifest); for r in [dir.join("c.composition.json").display().to_string(), "carol.test/c".to_string()] { let Ref::Composition { comp, manifest, libs, .. } = resolve_ref(&cli.net, &dir, &[r.clone()]).await.unwrap_or_else(|e| panic!("{r}: {e}")).0 else { panic!("{r}") }; assert_eq!(comp.manifest["instances"][1], json!({ "name": "a-pol", "module": pol }), "{r}"); assert_eq!(comp.modules["a-pol"].id.as_deref(), Some(pol.as_str())); assert_eq!((manifest["instances"][1]["module"].as_str(), libs.iter().map(|l| l.id.as_str()).collect::>()), (Some("pol"), vec![asker.as_str(), pol.as_str()])); } // add vendors the module's defaults; a pinned module's default comes from the lockfile only add(&cli, &["bob.test/asker".into(), "--dir".into(), dir.display().to_string()]).await.unwrap(); let lock = read_lock(&dir.join(LOCK)).unwrap(); assert_eq!(lock["modules"]["pol"]["uri"], pol); let vendored = lock["modules"]["pol"]["pin"].clone(); // bob (or whoever holds his PDS) republishes pol: no calls, a new wasm publish_asker(&pds, "bob.test", "pol", b"\0asm-allow-all", json!({ "implements": [{ "nsid": H, "entry": "evaluate" }] })); let Ref::Module(lib) = resolve_ref(&cli.net, &dir, &["asker".to_string()]).await.unwrap().0 else { panic!() }; let found = default_lib(&cli.net, &lib.id, lib.pinned.as_deref(), "pol").await.unwrap(); assert_eq!((found.wasm.as_slice(), json!(found.pin())), (&b"\0asm-pol"[..], vendored), "the vendored default, not the republished one"); // a ref pinned by @pin in a dir whose lockfile does not vendor its default: refused let elsewhere = tempdir("defaults-elsewhere"); let pinned = format!("bob.test/asker@{}", lib.pin()); let Ref::Module(lib) = resolve_ref(&cli.net, &elsewhere, &[pinned]).await.unwrap().0 else { panic!() }; let refused = default_lib(&cli.net, &lib.id, lib.pinned.as_deref(), "pol").await.err().unwrap(); assert!(refused.starts_with(&format!("hook default pol: {asker} is pinned, but")), "{refused}"); // unpinned: the live record let Ref::Module(lib) = resolve_ref(&cli.net, &elsewhere, &["bob.test/asker".to_string()]).await.unwrap().0 else { panic!() }; assert_eq!(default_lib(&cli.net, &lib.id, lib.pinned.as_deref(), "pol").await.unwrap().wasm, b"\0asm-allow-all"); // two publishers' modules under one key cannot be staged or vendored side by side (review 4a L4) let carols = publish_asker(&pds, "carol.test", "pol", b"\0asm-carol", json!({})); write_json(&dir.join("two.composition.json"), &json!({ "instances": [{ "name": "a", "module": pol }, { "name": "b", "module": carols }] })).unwrap(); let two = resolve_ref(&cli.net, &dir, &[dir.join("two.composition.json").display().to_string()]).await.err().unwrap(); assert!(two.ends_with("are both module pol: one key, two modules"), "{two}"); } #[tokio::test] async fn a_local_default_is_found_in_its_modules_repo() { // a module repo (atlibs-socialweb-computer-requester): modules/, one target above them let pds = Pds::new(); let repo = tempdir("module-repo"); let built = repo.join("target/wasm32-unknown-unknown/release"); std::fs::create_dir_all(&built).unwrap(); for key in ["asker", "policy-open"] { std::fs::create_dir_all(repo.join("modules").join(key)).unwrap(); std::fs::write(repo.join("modules").join(key).join("module.json"), r#"{"name":"m","calls":[]}"#).unwrap(); std::fs::write(built.join(format!("{}.wasm", key.replace('-', "_"))), b"\0asm").unwrap(); } let id = built.join("asker.wasm").display().to_string(); assert_eq!(default_lib(&pds.cli(&repo).net, &id, None, "policy-open").await.unwrap().key, "policy-open"); let missing = default_lib(&pds.cli(&repo).net, &id, None, "nope").await.err().unwrap(); assert!(missing.starts_with("hook default nope: no modules/nope or examples/rust/nope above"), "{missing}"); } #[tokio::test] async fn a_pin_covers_the_record_and_needs_32_hex_characters() { let pds = Pds::new(); publish_asker(&pds, "bob.test", "m", b"\0asm-m", json!({ "calls": ["com.atproto.repo.getRecord"] })); let cli = pds.cli(&tempdir("pins")); let resolve = |r: String| { let net = cli.net.clone(); async move { resolve_ref(&net, Path::new("."), &[r]).await.map(|_| ()) } }; let Ref::Module(lib) = resolve_ref(&cli.net, Path::new("."), &["bob.test/m".to_string()]).await.unwrap().0 else { panic!() }; let pin = lib.pin(); assert!(resolve(format!("bob.test/m@{pin}")).await.is_ok()); assert!(resolve(format!("bob.test/m@{}", &pin[..32])).await.is_ok()); for short in [7, 8, 31] { assert!(resolve(format!("bob.test/m@{}", &pin[..short])).await.unwrap_err().contains("is too short"), "{short}"); } // the same blob, its record's calls dropped (so it may call anything): a new pin; the old one still loads its version publish_asker(&pds, "bob.test", "m", b"\0asm-m", json!({})); let Ref::Module(old) = resolve_ref(&cli.net, Path::new("."), &[format!("bob.test/m@{pin}")]).await.unwrap().0 else { panic!() }; assert_eq!((old.pin(), old.spec["calls"].clone()), (pin.clone(), json!(["com.atproto.repo.getRecord"]))); // a record CID pins the record at its rkey, so a republish moves it assert!(resolve(format!("bob.test/m@{}", lib.cid.unwrap())).await.unwrap_err().contains("pinned to")); } /// verify end to end against the fake registry, offline: source file records -> manifest -> module, rebuilt /// by a stand-in engine that runs the container's command on the host in the bind-mounted dir (rustup a /// no-op); and one that exits 0 without running it (a docker look-alike seen on macOS) is named as the cause. /// The real engine: rust/host/cli.test.mjs. #[cfg(unix)] #[tokio::test] async fn verify_rebuilds_from_the_source_records_and_names_an_engine_that_built_nothing() { use std::os::unix::fs::PermissionsExt; let pds = Pds::new(); let wasm = b"\0asm-built"; let script = "mkdir -p target/out && printf '\\0asm-built' > target/out/m.wasm\n"; let (file, file_cid) = pds.put("alice.test", "com.fedfork.exec.source.file", "f", json!({ "sha256": sha256_hex(script.as_bytes()), "text": script })); let manifest = json!({ "name": "m", "files": [{ "path": "rust/src/build.sh", "file": { "uri": file, "cid": file_cid } }], "output": { "sha256": sha256_hex(wasm) }, "build": { "tool": "cargo", "version": "rustc 1.98.1 (x 2026-08-05)", "command": "bash rust/src/build.sh", "artifact": "target/out/m.wasm" } }); let (source, source_cid) = pds.put("alice.test", "com.fedfork.exec.source.manifest", "m", manifest); let uri = pds.publish_module("alice.test", "m", wasm, None); let mut module = pds.get(&uri).unwrap(); module["source"] = json!({ "uri": source, "cid": source_cid }); pds.put("alice.test", MODULE, "m", module); let dir = tempdir("verify"); let cli = pds.cli(&dir); let Ref::Module(lib) = resolve_ref(&cli.net, Path::new("."), &["alice.test/m".to_string()]).await.unwrap().0 else { panic!() }; let engine = |name: &str, body: &str| { let path = dir.join(name); std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); path.display().to_string() }; // run --rm -v :/src:Z -w /src bash -c let host = engine("host-engine", r#"[ "$7" = docker.io/library/rust:1.98.1 ] && cd "${4%%:/src:Z}" && exec bash -c "rustup() { :; }; ${10}""#); let report = rebuild(&cli, &lib, &host, &dir.join("work")).await.unwrap(); assert_eq!(report, json!({ "built": sha256_hex(wasm), "manifest": sha256_hex(wasm), "published": sha256_hex(wasm), "reproducible": true })); let lying = engine("lying-engine", "exit 0"); let err = rebuild(&cli, &lib, &lying, &dir.join("work2")).await.unwrap_err(); assert!(err.contains("exited 0 but the build left no target/out/m.wasm") && err.contains("CONTAINER_ENGINE"), "{err}"); } pub fn tempdir(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("atlibs-cli-{name}-{}", atlibs_host::store::uuid())); std::fs::create_dir_all(&dir).unwrap(); dir } }