Skip to main content

vela_protocol/
registry.rs

1//! Phase S (v0.5): registry primitive — verifiable distribution.
2//!
3//! A registry is a directory of `RegistryEntry`s, each one a signed
4//! manifest pointing at a frontier publication. Pulling a frontier
5//! through a registry verifies:
6//!
7//! 1. The manifest signature was produced by the owner's pubkey.
8//! 2. The pulled frontier's snapshot_hash matches the registered value.
9//! 3. The pulled frontier's event_log_hash matches the registered value.
10//!
11//! Cross-frontier *links* (`vf_…@vfr_…` references) are deferred to
12//! v0.6. v0.5's registry is the npm-tarball-with-a-signature shape:
13//! archival, reproducibility, integrity-checked transfer between
14//! collaborating institutions.
15//!
16//! A registry is NOT a Vela frontier (deferred to v0.6 once
17//! cross-frontier semantics exist). For now it's a flat
18//! `entries.json` + `pubkeys.json` pair on disk or fetched over HTTP.
19
20use std::path::{Path, PathBuf};
21
22use serde::{Deserialize, Serialize};
23use serde_json::json;
24
25pub const REGISTRY_SCHEMA: &str = "vela.registry.v0.1";
26pub const ENTRY_SCHEMA: &str = "vela.registry-entry.v0.1";
27
28/// A single signed publication of a frontier into a registry. The
29/// `signature` is Ed25519 over the canonical preimage of the entry's
30/// fields *minus* the signature itself. Two implementations agree on
31/// the signing-bytes derivation by following the same canonical-JSON
32/// rule already used for `vev_…`/`vpr_…`.
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34pub struct RegistryEntry {
35    #[serde(default = "default_entry_schema")]
36    pub schema: String,
37    pub vfr_id: String,
38    pub name: String,
39    pub owner_actor_id: String,
40    /// Hex-encoded Ed25519 public key (64 hex chars).
41    pub owner_pubkey: String,
42    /// SHA-256 hex of the canonical snapshot at publication time.
43    pub latest_snapshot_hash: String,
44    /// SHA-256 hex of the canonical event log at publication time.
45    pub latest_event_log_hash: String,
46    /// Where to fetch the frontier from (`file://`, `http://`, or
47    /// `git+...`). v0.5 supports `file://` and bare paths; HTTP and git
48    /// transports are scaffolded but unimplemented (v0.6).
49    pub network_locator: String,
50    /// RFC3339 timestamp of when the entry was signed.
51    pub signed_publish_at: String,
52    /// Hex-encoded Ed25519 signature over the canonical preimage of
53    /// the entry's other fields.
54    pub signature: String,
55}
56
57fn default_entry_schema() -> String {
58    ENTRY_SCHEMA.to_string()
59}
60
61/// On-disk registry shape: a JSON file containing the schema marker
62/// and an array of entries. Multiple publications of the same `vfr_id`
63/// are appended; readers select the latest by `signed_publish_at`.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct Registry {
66    #[serde(default = "default_registry_schema")]
67    pub schema: String,
68    #[serde(default)]
69    pub entries: Vec<RegistryEntry>,
70}
71
72fn default_registry_schema() -> String {
73    REGISTRY_SCHEMA.to_string()
74}
75
76impl Default for Registry {
77    fn default() -> Self {
78        Self {
79            schema: REGISTRY_SCHEMA.to_string(),
80            entries: Vec::new(),
81        }
82    }
83}
84
85/// Build the canonical preimage for an entry's signature.
86///
87/// Excludes the `signature` field itself. Same canonical-JSON rule as
88/// `event_signing_bytes` and `proposal_signing_bytes`: a second
89/// implementation following only the canonical-JSON spec produces
90/// byte-identical signing bytes.
91pub fn entry_signing_bytes(entry: &RegistryEntry) -> Result<Vec<u8>, String> {
92    let preimage = json!({
93        "schema": entry.schema,
94        "vfr_id": entry.vfr_id,
95        "name": entry.name,
96        "owner_actor_id": entry.owner_actor_id,
97        "owner_pubkey": entry.owner_pubkey,
98        "latest_snapshot_hash": entry.latest_snapshot_hash,
99        "latest_event_log_hash": entry.latest_event_log_hash,
100        "network_locator": entry.network_locator,
101        "signed_publish_at": entry.signed_publish_at,
102    });
103    crate::canonical::to_canonical_bytes(&preimage)
104}
105
106/// Sign an unsigned entry (with `signature` as empty string), returning
107/// a hex-encoded Ed25519 signature.
108pub fn sign_entry(
109    entry: &RegistryEntry,
110    signing_key: &ed25519_dalek::SigningKey,
111) -> Result<String, String> {
112    use ed25519_dalek::Signer;
113    let bytes = entry_signing_bytes(entry)?;
114    Ok(hex::encode(signing_key.sign(&bytes).to_bytes()))
115}
116
117/// Verify an entry's `signature` against `owner_pubkey`.
118pub fn verify_entry(entry: &RegistryEntry) -> Result<bool, String> {
119    let bytes = entry_signing_bytes(entry)?;
120    crate::sign::verify_action_signature(&bytes, &entry.signature, &entry.owner_pubkey)
121}
122
123// ── Local file-backed registry ───────────────────────────────────────
124
125/// Load a registry from a local file (JSON). Returns an empty registry
126/// if the file does not exist.
127pub fn load_local(path: &Path) -> Result<Registry, String> {
128    if !path.exists() {
129        return Ok(Registry::default());
130    }
131    let raw = std::fs::read_to_string(path)
132        .map_err(|e| format!("read registry {}: {e}", path.display()))?;
133    serde_json::from_str(&raw).map_err(|e| format!("parse registry {}: {e}", path.display()))
134}
135
136pub fn save_local(path: &Path, registry: &Registry) -> Result<(), String> {
137    if let Some(parent) = path.parent() {
138        std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
139    }
140    let raw =
141        serde_json::to_string_pretty(registry).map_err(|e| format!("serialize registry: {e}"))?;
142    std::fs::write(path, raw).map_err(|e| format!("write registry {}: {e}", path.display()))?;
143    Ok(())
144}
145
146/// Resolve a registry URL/path into a local *write* path. Used by
147/// `vela registry publish` which can only target a local file.
148/// v0.6 supports:
149///   - bare path: `/path/to/registry.json`
150///   - `file://` URL
151///   - directory: appends `entries.json`
152///
153/// HTTP and git transports are rejected here (publish-side only); for
154/// read-side fetches use [`load_any`] which handles HTTP.
155pub fn resolve_local(locator: &str) -> Result<PathBuf, String> {
156    if locator.starts_with("http://") || locator.starts_with("https://") {
157        return Err(
158            "HTTP transport for registry write (publish) is deferred to v0.8; for reads, use https:// with `vela registry list/pull`."
159                .to_string(),
160        );
161    }
162    if locator.starts_with("git+") {
163        return Err("Git transport for registries is deferred to v0.8".to_string());
164    }
165    let stripped = locator.strip_prefix("file://").unwrap_or(locator);
166    let path = PathBuf::from(stripped);
167    if path.is_dir() {
168        Ok(path.join("entries.json"))
169    } else {
170        Ok(path)
171    }
172}
173
174/// Fetch a registry from anywhere it might live. v0.7 (this phase):
175///   - bare path / `file://` — local file (delegates to `load_local`)
176///   - `https://…/entries.json` — fetched via blocking HTTP, parsed
177///     identically to a local file
178///   - `https://hub.example` / `https://hub.example/` — appends
179///     `/entries` automatically
180///
181/// HTTP fetch returns the same `Registry` shape; the hub serves the
182/// canonical-JSON manifest verbatim, so signature verification works
183/// byte-for-byte without re-canonicalization.
184pub fn load_any(locator: &str) -> Result<Registry, String> {
185    if locator.starts_with("http://") || locator.starts_with("https://") {
186        let url = registry_listing_url(locator);
187        std::thread::spawn(move || {
188            let client = reqwest::blocking::Client::builder()
189                .user_agent(concat!("vela/", env!("CARGO_PKG_VERSION")))
190                .timeout(std::time::Duration::from_secs(30))
191                .build()
192                .map_err(|e| format!("build http client: {e}"))?;
193            let resp = client
194                .get(&url)
195                .send()
196                .map_err(|e| format!("GET {url}: {e}"))?;
197            if !resp.status().is_success() {
198                return Err(format!("GET {url}: HTTP {}", resp.status()));
199            }
200            let text = resp
201                .text()
202                .map_err(|e| format!("read response body: {e}"))?;
203            serde_json::from_str(&text).map_err(|e| format!("parse remote registry {url}: {e}"))
204        })
205        .join()
206        .map_err(|_| "remote registry fetch worker panicked".to_string())?
207    } else {
208        let path = resolve_local(locator)?;
209        load_local(&path)
210    }
211}
212
213fn registry_listing_url(locator: &str) -> String {
214    let trimmed = locator.trim_end_matches('/');
215    if trimmed.ends_with("/entries") || trimmed.ends_with("/entries.json") {
216        return trimmed.to_string();
217    }
218    let without_scheme = trimmed
219        .strip_prefix("https://")
220        .or_else(|| trimmed.strip_prefix("http://"));
221    if without_scheme.is_some_and(|rest| !rest.contains('/')) {
222        return format!("{trimmed}/entries");
223    }
224    locator.to_string()
225}
226
227/// Fetch a frontier file from its locator (the `network_locator` field
228/// on a registry entry) into a local destination path. Supports
229/// `file://`, bare paths, and `https://`. Returns the destination path
230/// on success.
231pub fn fetch_frontier_to(locator: &str, dest: &Path) -> Result<(), String> {
232    if locator.starts_with("http://") || locator.starts_with("https://") {
233        fetch_http_frontier_to(locator, dest).map_err(|e| e.to_string())
234    } else {
235        let stripped = locator.strip_prefix("file://").unwrap_or(locator);
236        let source = PathBuf::from(stripped);
237        std::fs::copy(&source, dest)
238            .map(|_| ())
239            .map_err(|e| format!("copy {} → {}: {e}", source.display(), dest.display()))
240    }
241}
242
243/// Build the event-first snapshot endpoint for a hub registry locator.
244/// Returns `None` for local registries. The caller should still verify
245/// the downloaded bytes against the signed manifest.
246pub fn event_first_snapshot_locator(registry_locator: &str, vfr_id: &str) -> Option<String> {
247    if !registry_locator.starts_with("http://") && !registry_locator.starts_with("https://") {
248        return None;
249    }
250    let trimmed = registry_locator.trim_end_matches('/');
251    let root = trimmed
252        .strip_suffix("/entries")
253        .or_else(|| trimmed.strip_suffix("/entries.json"))
254        .unwrap_or(trimmed);
255    Some(format!("{root}/entries/{vfr_id}/snapshot"))
256}
257
258/// Fetch a frontier for a registry entry, preferring the event-first hub
259/// read path when the registry itself came from a hub URL. Falls back
260/// to `network_locator` only for older hubs that do not expose the
261/// snapshot endpoint. Verification remains the caller's job.
262pub fn fetch_frontier_to_prefer_event_hub(
263    entry: &RegistryEntry,
264    registry_locator: Option<&str>,
265    dest: &Path,
266) -> Result<(), String> {
267    if let Some(hub_snapshot) =
268        registry_locator.and_then(|locator| event_first_snapshot_locator(locator, &entry.vfr_id))
269    {
270        match fetch_http_frontier_to(&hub_snapshot, dest) {
271            Ok(()) => return Ok(()),
272            Err(e) if e.status_is_legacy_endpoint_miss() => {}
273            Err(e) => {
274                return Err(format!(
275                    "event-first hub snapshot fetch failed: {e}; not falling back to network_locator"
276                ));
277            }
278        }
279    }
280    fetch_frontier_to(&entry.network_locator, dest)
281}
282
283#[derive(Debug)]
284struct HttpFetchError {
285    locator: String,
286    status: Option<reqwest::StatusCode>,
287    message: String,
288}
289
290impl HttpFetchError {
291    fn status_is_legacy_endpoint_miss(&self) -> bool {
292        matches!(self.status.map(|s| s.as_u16()), Some(404 | 405 | 501))
293    }
294}
295
296impl std::fmt::Display for HttpFetchError {
297    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298        match self.status {
299            Some(status) => write!(f, "GET {}: HTTP {status}", self.locator),
300            None => write!(f, "GET {}: {}", self.locator, self.message),
301        }
302    }
303}
304
305fn fetch_http_frontier_to(locator: &str, dest: &Path) -> Result<(), HttpFetchError> {
306    let locator_for_error = locator.to_string();
307    let locator = locator.to_string();
308    let dest = dest.to_path_buf();
309    std::thread::spawn(move || {
310        let client = reqwest::blocking::Client::builder()
311            .user_agent(concat!("vela/", env!("CARGO_PKG_VERSION")))
312            .timeout(std::time::Duration::from_secs(60))
313            .build()
314            .map_err(|e| HttpFetchError {
315                locator: locator.clone(),
316                status: None,
317                message: format!("build http client: {e}"),
318            })?;
319        let resp = client.get(&locator).send().map_err(|e| HttpFetchError {
320            locator: locator.clone(),
321            status: None,
322            message: e.to_string(),
323        })?;
324        if !resp.status().is_success() {
325            return Err(HttpFetchError {
326                locator: locator.clone(),
327                status: Some(resp.status()),
328                message: String::new(),
329            });
330        }
331        let bytes = resp.bytes().map_err(|e| HttpFetchError {
332            locator: locator.clone(),
333            status: None,
334            message: format!("read frontier bytes: {e}"),
335        })?;
336        if let Some(parent) = dest.parent() {
337            std::fs::create_dir_all(parent).map_err(|e| HttpFetchError {
338                locator: locator.clone(),
339                status: None,
340                message: format!("mkdir {}: {e}", parent.display()),
341            })?;
342        }
343        std::fs::write(&dest, &bytes).map_err(|e| HttpFetchError {
344            locator,
345            status: None,
346            message: format!("write {}: {e}", dest.display()),
347        })
348    })
349    .join()
350    .unwrap_or_else(|_| {
351        Err(HttpFetchError {
352            locator: locator_for_error,
353            status: None,
354            message: "HTTP fetch worker panicked".to_string(),
355        })
356    })
357}
358
359/// Server response shape from `POST <hub>/entries`.
360#[derive(Debug, Clone, Deserialize)]
361pub struct PublishResponse {
362    pub ok: bool,
363    #[serde(default)]
364    pub duplicate: bool,
365    #[serde(default)]
366    pub vfr_id: String,
367    #[serde(default)]
368    pub signed_publish_at: String,
369}
370
371/// Push a signed entry to a remote hub. The transport is doctrine-light:
372/// canonical JSON over HTTPS POST. The hub verifies the signature and
373/// stores the bytes verbatim.
374///
375/// `hub_url` may be either the hub root (`https://vela-hub.fly.dev`) or
376/// the entries endpoint (`https://vela-hub.fly.dev/entries`); we append
377/// `/entries` if missing.
378///
379/// v0.55: when `substrate` is `Some`, the full Project is included
380/// inline in the publish body. The hub verifies its hash against the
381/// signed manifest, stores a snapshot export when configured, and
382/// promotes event/projection rows for live reads. Pass `None` only for
383/// manifest-only registry history; event-first hubs will not promote
384/// that row to live frontier state.
385///
386/// The signed preimage is not affected: `entry_signing_bytes` (and
387/// therefore the signature) excludes `substrate`. The hub re-canonicalises
388/// the entry portion of the body to verify the signature, ignoring
389/// the `substrate` sibling.
390pub fn publish_remote(
391    entry: &RegistryEntry,
392    hub_url: &str,
393    substrate: Option<&crate::project::Project>,
394) -> Result<PublishResponse, String> {
395    if !hub_url.starts_with("http://") && !hub_url.starts_with("https://") {
396        return Err(format!(
397            "publish_remote requires http:// or https:// URL, got: {hub_url}"
398        ));
399    }
400    let trimmed = hub_url.trim_end_matches('/');
401    let url = if trimmed.ends_with("/entries") {
402        trimmed.to_string()
403    } else {
404        format!("{trimmed}/entries")
405    };
406
407    // Body shape:
408    //   - manifest-only (legacy): canonical bytes of `entry`.
409    //   - inline substrate (v0.55+): entry fields + "substrate" sibling
410    //     key, serialised as plain (non-canonical) JSON. The hub
411    //     re-canonicalises just the entry portion to verify the signature.
412    let body: Vec<u8> = match substrate {
413        None => crate::canonical::to_canonical_bytes(entry)
414            .map_err(|e| format!("canonicalize entry: {e}"))?,
415        Some(project) => {
416            let mut wrapper =
417                serde_json::to_value(entry).map_err(|e| format!("serialise entry: {e}"))?;
418            let project_value =
419                serde_json::to_value(project).map_err(|e| format!("serialise substrate: {e}"))?;
420            if let serde_json::Value::Object(map) = &mut wrapper {
421                map.insert("substrate".to_string(), project_value);
422            } else {
423                return Err("entry did not serialise to a JSON object".to_string());
424            }
425            serde_json::to_vec(&wrapper).map_err(|e| format!("serialise body: {e}"))?
426        }
427    };
428
429    std::thread::spawn(move || {
430        let client = reqwest::blocking::Client::builder()
431            .user_agent(concat!("vela/", env!("CARGO_PKG_VERSION")))
432            // Substrate can be tens of MB on broad frontiers (the BBB-superset
433            // ALZ corpus is ~21 MB). Allow generous time on the upload + hub
434            // hash verification + DB insert.
435            .timeout(std::time::Duration::from_secs(120))
436            .build()
437            .map_err(|e| format!("build http client: {e}"))?;
438        let resp = client
439            .post(&url)
440            .header("content-type", "application/json")
441            .body(body)
442            .send()
443            .map_err(|e| format!("POST {url}: {e}"))?;
444        let status = resp.status();
445        let text = resp
446            .text()
447            .map_err(|e| format!("read response body: {e}"))?;
448        if !status.is_success() {
449            // Try to extract a server-supplied message; otherwise surface the body.
450            let msg = serde_json::from_str::<serde_json::Value>(&text)
451                .ok()
452                .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_string))
453                .unwrap_or(text);
454            return Err(format!("POST {url}: HTTP {status}: {msg}"));
455        }
456        serde_json::from_str(&text).map_err(|e| format!("parse publish response: {e}"))
457    })
458    .join()
459    .map_err(|_| "remote publish worker panicked".to_string())?
460}
461
462/// Append a signed entry to a registry, replacing any prior entry
463/// for the same `vfr_id` (latest-publish-wins).
464///
465/// Verifies the entry's signature against its declared `owner_pubkey`
466/// before persisting; refuses to register an entry that fails
467/// verification (callers must sign first).
468pub fn publish_entry(registry_path: &Path, entry: RegistryEntry) -> Result<(), String> {
469    if !verify_entry(&entry)? {
470        return Err("registry entry signature does not verify".to_string());
471    }
472    let mut registry = load_local(registry_path)?;
473    registry
474        .entries
475        .retain(|existing| existing.vfr_id != entry.vfr_id);
476    registry.entries.push(entry);
477    save_local(registry_path, &registry)
478}
479
480/// Find the latest entry for `vfr_id` in a local registry, by
481/// `signed_publish_at`. Returns None if no entry exists.
482pub fn find_latest(registry: &Registry, vfr_id: &str) -> Option<RegistryEntry> {
483    registry
484        .entries
485        .iter()
486        .filter(|entry| entry.vfr_id == vfr_id)
487        .max_by_key(|entry| entry.signed_publish_at.clone())
488        .cloned()
489}
490
491/// Pull verification: given a registry entry and the path to a
492/// pulled-frontier file on disk, verify that:
493///
494/// 1. The entry's signature verifies against its declared pubkey.
495/// 2. The frontier's `snapshot_hash` matches the entry's
496///    `latest_snapshot_hash`.
497/// 3. The frontier's `event_log_hash` matches the entry's
498///    `latest_event_log_hash`.
499///
500/// Returns Ok(()) if all three hold; Err(reason) on any mismatch.
501pub fn verify_pull(entry: &RegistryEntry, frontier_path: &Path) -> Result<(), String> {
502    if !verify_entry(entry)? {
503        return Err("registry entry signature does not verify".to_string());
504    }
505    let frontier = crate::repo::load_from_path(frontier_path)
506        .map_err(|e| format!("load frontier {}: {e}", frontier_path.display()))?;
507    let snapshot = crate::events::snapshot_hash(&frontier);
508    if snapshot != entry.latest_snapshot_hash {
509        return Err(format!(
510            "snapshot_hash mismatch: registry={}, frontier={}",
511            entry.latest_snapshot_hash, snapshot
512        ));
513    }
514    let event_log = crate::events::event_log_hash(&frontier.events);
515    if event_log != entry.latest_event_log_hash {
516        return Err(format!(
517            "event_log_hash mismatch: registry={}, frontier={}",
518            entry.latest_event_log_hash, event_log
519        ));
520    }
521    Ok(())
522}
523
524// ── v0.8: transitive pull-and-verify ─────────────────────────────────
525
526/// Outcome of `pull_transitive`. The primary frontier and every
527/// recursively-resolved cross-frontier dependency end up as files on
528/// disk; `verified` lists the `vfr_id`s whose snapshot pin matched.
529#[derive(Debug, Clone)]
530pub struct PullResult {
531    /// Path to the primary frontier file.
532    pub primary_path: std::path::PathBuf,
533    /// `vfr_id` → on-disk path for every dependency successfully pulled.
534    pub deps: std::collections::HashMap<String, std::path::PathBuf>,
535    /// Order in which `vfr_id`s were verified (primary first, then deps
536    /// in walk order). Useful for stable output / logging.
537    pub verified: Vec<String>,
538}
539
540/// Pull a frontier and recursively pull every cross-frontier
541/// dependency it declares, verifying each pinned snapshot hash along
542/// the way. The primary's manifest must live in `registry`.
543///
544/// Doctrine notes:
545/// - Verification is total: any snapshot mismatch, missing locator, or
546///   missing dep-manifest aborts the whole pull. Partial trust is not
547///   a state v0.8 supports.
548/// - Cycles are impossible by content-addressing (a vfr_id is a hash
549///   of bytes that include the dependency list). A visited-set guards
550///   anyway; revisiting the same vfr_id is a no-op.
551/// - `max_depth` caps recursion. The primary is depth 0; its direct
552///   deps are depth 1, and so on. Reaching `max_depth` without
553///   exhausting deps is an error so the caller can decide to retry
554///   with a higher cap.
555pub fn pull_transitive(
556    registry: &Registry,
557    primary_vfr: &str,
558    out_dir: &Path,
559    max_depth: usize,
560) -> Result<PullResult, String> {
561    use std::collections::{HashMap, HashSet, VecDeque};
562
563    std::fs::create_dir_all(out_dir).map_err(|e| format!("mkdir {}: {e}", out_dir.display()))?;
564
565    // Look up the primary entry, fetch the frontier, verify total.
566    let primary_entry = find_latest(registry, primary_vfr)
567        .ok_or_else(|| format!("primary {primary_vfr} not found in registry"))?;
568    let primary_path = out_dir.join(format!("{primary_vfr}.json"));
569    fetch_frontier_to(&primary_entry.network_locator, &primary_path)
570        .map_err(|e| format!("fetch primary {primary_vfr}: {e}"))?;
571    verify_pull(&primary_entry, &primary_path)
572        .map_err(|e| format!("verify primary {primary_vfr}: {e}"))?;
573
574    let mut deps: HashMap<String, std::path::PathBuf> = HashMap::new();
575    let mut verified: Vec<String> = vec![primary_vfr.to_string()];
576    let mut visited: HashSet<String> = HashSet::new();
577    visited.insert(primary_vfr.to_string());
578
579    // BFS: each queue entry carries (vfr_id, frontier_path, depth).
580    let mut queue: VecDeque<(String, std::path::PathBuf, usize)> = VecDeque::new();
581    queue.push_back((primary_vfr.to_string(), primary_path.clone(), 0));
582
583    while let Some((cur_vfr, cur_path, depth)) = queue.pop_front() {
584        let frontier =
585            crate::repo::load_from_path(&cur_path).map_err(|e| format!("reload {cur_vfr}: {e}"))?;
586
587        for dep in frontier.cross_frontier_deps() {
588            let Some(dep_vfr) = dep.vfr_id.clone() else {
589                continue;
590            };
591            if visited.contains(&dep_vfr) {
592                continue; // already pulled (deduped + cycle-safe)
593            }
594            if depth + 1 > max_depth {
595                return Err(format!(
596                    "transitive pull exceeded max depth {max_depth} at {dep_vfr} (declared by {cur_vfr})"
597                ));
598            }
599            let dep_locator = dep
600                .locator
601                .as_deref()
602                .filter(|s| !s.is_empty())
603                .ok_or_else(|| {
604                    format!(
605                        "cross-frontier dep {dep_vfr} (declared by {cur_vfr}) has no locator; cannot fetch"
606                    )
607                })?;
608            let dep_pinned = dep
609                .pinned_snapshot_hash
610                .as_deref()
611                .filter(|s| !s.is_empty())
612                .ok_or_else(|| {
613                    format!(
614                        "cross-frontier dep {dep_vfr} (declared by {cur_vfr}) has no pinned_snapshot_hash; cannot verify"
615                    )
616                })?;
617
618            // Manifest must live in this same registry. (Hub-to-hub
619            // federation — pulling deps from a different registry — is
620            // deferred to v0.9.)
621            let dep_entry = find_latest(registry, &dep_vfr).ok_or_else(|| {
622                format!(
623                    "cross-frontier dep {dep_vfr} (declared by {cur_vfr}) not present in registry"
624                )
625            })?;
626
627            let dep_path = out_dir.join(format!("{dep_vfr}.json"));
628            fetch_frontier_to(dep_locator, &dep_path)
629                .map_err(|e| format!("fetch dep {dep_vfr}: {e}"))?;
630            verify_pull(&dep_entry, &dep_path).map_err(|e| format!("verify dep {dep_vfr}: {e}"))?;
631
632            // Heart of the pin: compare the *dependent's* declared
633            // pinned snapshot to the *dependency's* actual snapshot.
634            // The dep's manifest snapshot is what `verify_pull` already
635            // checked against the file; equality there means the file's
636            // canonical snapshot equals `dep_entry.latest_snapshot_hash`.
637            // So the pin check is just: dep_pinned == dep_entry's hash.
638            if dep_pinned != dep_entry.latest_snapshot_hash {
639                return Err(format!(
640                    "pinned_snapshot_hash mismatch for {dep_vfr}: dependent {cur_vfr} pinned {dep_pinned}, registry has {actual}",
641                    actual = dep_entry.latest_snapshot_hash
642                ));
643            }
644
645            visited.insert(dep_vfr.clone());
646            verified.push(dep_vfr.clone());
647            deps.insert(dep_vfr.clone(), dep_path.clone());
648            queue.push_back((dep_vfr, dep_path, depth + 1));
649        }
650    }
651
652    Ok(PullResult {
653        primary_path,
654        deps,
655        verified,
656    })
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662    use ed25519_dalek::SigningKey;
663    use rand::rngs::OsRng;
664    use tempfile::TempDir;
665
666    fn keypair() -> (SigningKey, String) {
667        let key = SigningKey::generate(&mut OsRng);
668        let pubkey = hex::encode(key.verifying_key().to_bytes());
669        (key, pubkey)
670    }
671
672    #[test]
673    fn event_first_snapshot_locator_normalizes_hub_registry_urls() {
674        assert_eq!(
675            event_first_snapshot_locator("https://vela-hub.fly.dev/entries", "vfr_demo").as_deref(),
676            Some("https://vela-hub.fly.dev/entries/vfr_demo/snapshot")
677        );
678        assert_eq!(
679            event_first_snapshot_locator("https://vela-hub.fly.dev/", "vfr_demo").as_deref(),
680            Some("https://vela-hub.fly.dev/entries/vfr_demo/snapshot")
681        );
682        assert_eq!(
683            event_first_snapshot_locator("file:///tmp/registry.json", "vfr_demo"),
684            None
685        );
686    }
687
688    #[test]
689    fn registry_listing_url_accepts_hub_roots() {
690        assert_eq!(
691            registry_listing_url("https://vela-hub.fly.dev"),
692            "https://vela-hub.fly.dev/entries"
693        );
694        assert_eq!(
695            registry_listing_url("https://vela-hub.fly.dev/"),
696            "https://vela-hub.fly.dev/entries"
697        );
698        assert_eq!(
699            registry_listing_url("https://vela-hub.fly.dev/entries"),
700            "https://vela-hub.fly.dev/entries"
701        );
702        assert_eq!(
703            registry_listing_url("https://example.com/registry.json"),
704            "https://example.com/registry.json"
705        );
706    }
707
708    fn sample_entry(pubkey: &str) -> RegistryEntry {
709        RegistryEntry {
710            schema: ENTRY_SCHEMA.to_string(),
711            vfr_id: "vfr_aaaaaaaaaaaaaaaa".to_string(),
712            name: "Test Frontier".to_string(),
713            owner_actor_id: "reviewer:test".to_string(),
714            owner_pubkey: pubkey.to_string(),
715            latest_snapshot_hash: "a".repeat(64),
716            latest_event_log_hash: "b".repeat(64),
717            network_locator: "/tmp/x.json".to_string(),
718            signed_publish_at: "2026-04-25T00:00:00Z".to_string(),
719            signature: String::new(),
720        }
721    }
722
723    #[test]
724    fn entry_sign_and_verify_round_trip() {
725        let (key, pubkey) = keypair();
726        let mut entry = sample_entry(&pubkey);
727        entry.signature = sign_entry(&entry, &key).unwrap();
728        assert!(verify_entry(&entry).unwrap(), "entry must self-verify");
729    }
730
731    #[test]
732    fn tampered_entry_fails_verification() {
733        let (key, pubkey) = keypair();
734        let mut entry = sample_entry(&pubkey);
735        entry.signature = sign_entry(&entry, &key).unwrap();
736        entry.latest_snapshot_hash = "f".repeat(64);
737        assert!(
738            !verify_entry(&entry).unwrap(),
739            "tampered entry must fail to verify"
740        );
741    }
742
743    #[test]
744    fn publish_entry_replaces_prior_for_same_vfr_id() {
745        let (key, pubkey) = keypair();
746        let tmp = TempDir::new().unwrap();
747        let path = tmp.path().join("entries.json");
748        let mut entry = sample_entry(&pubkey);
749        entry.signature = sign_entry(&entry, &key).unwrap();
750        publish_entry(&path, entry.clone()).unwrap();
751
752        // Re-publish with newer timestamp + new snapshot.
753        let mut entry2 = entry.clone();
754        entry2.latest_snapshot_hash = "c".repeat(64);
755        entry2.signed_publish_at = "2026-04-26T00:00:00Z".to_string();
756        entry2.signature = sign_entry(&entry2, &key).unwrap();
757        publish_entry(&path, entry2.clone()).unwrap();
758
759        let registry = load_local(&path).unwrap();
760        assert_eq!(registry.entries.len(), 1);
761        assert_eq!(
762            registry.entries[0].latest_snapshot_hash,
763            entry2.latest_snapshot_hash
764        );
765        let latest = find_latest(&registry, &entry.vfr_id).unwrap();
766        assert_eq!(latest.signed_publish_at, "2026-04-26T00:00:00Z");
767    }
768
769    #[test]
770    fn publish_rejects_unsigned_entry() {
771        let (_key, pubkey) = keypair();
772        let tmp = TempDir::new().unwrap();
773        let path = tmp.path().join("entries.json");
774        let entry = sample_entry(&pubkey); // signature is empty
775        let result = publish_entry(&path, entry);
776        assert!(result.is_err(), "unsigned entry must be rejected");
777    }
778
779    // ── v0.8: transitive pull-and-verify ──────────────────────────────
780
781    /// Build a frontier file at `path`, return its (vfr_id,
782    /// snapshot_hash, event_log_hash). Uses `project::assemble` to make
783    /// a real frontier (real frontier_id, real hashes), so the
784    /// resulting RegistryEntry is verifiable end-to-end.
785    fn make_real_frontier(
786        dir: &Path,
787        name: &str,
788        seed: &str,
789        deps: Vec<crate::project::ProjectDependency>,
790    ) -> (std::path::PathBuf, String, String, String) {
791        use crate::bundle::{
792            Assertion, Conditions, Confidence, ConfidenceMethod, Evidence, Extraction,
793            FindingBundle, Flags, Provenance,
794        };
795        let assertion = Assertion {
796            text: format!("Test assertion {seed}"),
797            assertion_type: "mechanism".into(),
798            entities: vec![],
799            relation: None,
800            direction: None,
801            causal_claim: None,
802            causal_evidence_grade: None,
803        };
804        let provenance = Provenance {
805            source_type: "published_paper".into(),
806            doi: Some(format!("10.0000/{seed}")),
807            pmid: None,
808            pmc: None,
809            openalex_id: None,
810            url: None,
811            title: format!("Test {seed}"),
812            authors: vec![],
813            year: Some(2024),
814            journal: None,
815            license: None,
816            publisher: None,
817            funders: vec![],
818            citation_count: None,
819            extraction: Extraction {
820                method: "llm_extraction".into(),
821                model: None,
822                model_version: None,
823                extracted_at: "1970-01-01T00:00:00Z".into(),
824                extractor_version: "vela/0.2.0".into(),
825            },
826            review: None,
827        };
828        let id = FindingBundle::content_address(&assertion, &provenance);
829        let finding = FindingBundle {
830            id,
831            version: 1,
832            previous_version: None,
833            assertion,
834            evidence: Evidence {
835                evidence_type: "experimental".into(),
836                model_system: String::new(),
837                species: None,
838                method: String::new(),
839                sample_size: None,
840                effect_size: None,
841                p_value: None,
842                replicated: false,
843                replication_count: None,
844                evidence_spans: vec![],
845            },
846            conditions: Conditions {
847                text: String::new(),
848                species_verified: vec![],
849                species_unverified: vec![],
850                in_vitro: false,
851                in_vivo: false,
852                human_data: false,
853                clinical_trial: false,
854                concentration_range: None,
855                duration: None,
856                age_group: None,
857                cell_type: None,
858            },
859            confidence: Confidence {
860                kind: Default::default(),
861                score: 0.5,
862                basis: "test".into(),
863                method: ConfidenceMethod::LlmInitial,
864                components: None,
865                extraction_confidence: 0.5,
866            },
867            provenance,
868            flags: Flags {
869                gap: false,
870                negative_space: false,
871                contested: false,
872                retracted: false,
873                declining: false,
874                gravity_well: false,
875                review_state: None,
876                superseded: false,
877                signature_threshold: None,
878                jointly_accepted: false,
879            },
880            links: vec![],
881            annotations: vec![],
882            attachments: vec![],
883            created: chrono::Utc::now().to_rfc3339(),
884            updated: None,
885
886            access_tier: crate::access_tier::AccessTier::Public,
887        };
888        let mut p = crate::project::assemble(name, vec![finding], 1, 0, "Test");
889        p.project.dependencies = deps;
890        let path = dir.join(format!("{name}.json"));
891        let json = serde_json::to_string_pretty(&p).unwrap();
892        std::fs::write(&path, json).unwrap();
893        let vfr_id = p.frontier_id();
894        let snapshot = crate::events::snapshot_hash(&p);
895        let event_log = crate::events::event_log_hash(&p.events);
896        (path, vfr_id, snapshot, event_log)
897    }
898
899    fn signed_entry(
900        key: &SigningKey,
901        pubkey: &str,
902        vfr_id: &str,
903        name: &str,
904        path: &Path,
905        snapshot: &str,
906        event_log: &str,
907    ) -> RegistryEntry {
908        let mut entry = RegistryEntry {
909            schema: ENTRY_SCHEMA.to_string(),
910            vfr_id: vfr_id.to_string(),
911            name: name.to_string(),
912            owner_actor_id: "reviewer:test".to_string(),
913            owner_pubkey: pubkey.to_string(),
914            latest_snapshot_hash: snapshot.to_string(),
915            latest_event_log_hash: event_log.to_string(),
916            network_locator: format!("file://{}", path.display()),
917            signed_publish_at: chrono::Utc::now().to_rfc3339(),
918            signature: String::new(),
919        };
920        entry.signature = sign_entry(&entry, key).unwrap();
921        entry
922    }
923
924    #[test]
925    fn pull_transitive_resolves_one_level() {
926        let (key, pubkey) = keypair();
927        let tmp = TempDir::new().unwrap();
928        let stage = tmp.path().join("stage");
929        std::fs::create_dir_all(&stage).unwrap();
930        let out = tmp.path().join("out");
931
932        // Frontier A — leaf, no deps.
933        let (a_path, a_vfr, a_snap, a_eventlog) =
934            make_real_frontier(&stage, "frontier-a", "aaa", vec![]);
935        // Frontier B declares A as a dep with the right snapshot pin.
936        let (b_path, b_vfr, b_snap, b_eventlog) = make_real_frontier(
937            &stage,
938            "frontier-b",
939            "bbb",
940            vec![crate::project::ProjectDependency {
941                name: "frontier-a".into(),
942                source: "vela.hub".into(),
943                version: None,
944                pinned_hash: None,
945                vfr_id: Some(a_vfr.clone()),
946                locator: Some(format!("file://{}", a_path.display())),
947                pinned_snapshot_hash: Some(a_snap.clone()),
948            }],
949        );
950
951        let mut registry = Registry::default();
952        registry.entries.push(signed_entry(
953            &key,
954            &pubkey,
955            &a_vfr,
956            "frontier-a",
957            &a_path,
958            &a_snap,
959            &a_eventlog,
960        ));
961        registry.entries.push(signed_entry(
962            &key,
963            &pubkey,
964            &b_vfr,
965            "frontier-b",
966            &b_path,
967            &b_snap,
968            &b_eventlog,
969        ));
970
971        let result = pull_transitive(&registry, &b_vfr, &out, 4).unwrap();
972        assert_eq!(result.verified.len(), 2, "both frontiers verified");
973        assert!(result.verified.contains(&b_vfr));
974        assert!(result.verified.contains(&a_vfr));
975        assert!(result.deps.contains_key(&a_vfr));
976        assert!(out.join(format!("{b_vfr}.json")).exists());
977        assert!(out.join(format!("{a_vfr}.json")).exists());
978    }
979
980    #[test]
981    fn pull_transitive_fails_on_pin_mismatch() {
982        let (key, pubkey) = keypair();
983        let tmp = TempDir::new().unwrap();
984        let stage = tmp.path().join("stage");
985        std::fs::create_dir_all(&stage).unwrap();
986        let out = tmp.path().join("out");
987
988        let (a_path, a_vfr, a_snap, a_eventlog) =
989            make_real_frontier(&stage, "frontier-a", "aaa", vec![]);
990        // B pins a *different* snapshot than the registry has for A.
991        let bad_pin = "f".repeat(64);
992        let (b_path, b_vfr, b_snap, b_eventlog) = make_real_frontier(
993            &stage,
994            "frontier-b",
995            "bbb",
996            vec![crate::project::ProjectDependency {
997                name: "frontier-a".into(),
998                source: "vela.hub".into(),
999                version: None,
1000                pinned_hash: None,
1001                vfr_id: Some(a_vfr.clone()),
1002                locator: Some(format!("file://{}", a_path.display())),
1003                pinned_snapshot_hash: Some(bad_pin),
1004            }],
1005        );
1006
1007        let mut registry = Registry::default();
1008        registry.entries.push(signed_entry(
1009            &key,
1010            &pubkey,
1011            &a_vfr,
1012            "frontier-a",
1013            &a_path,
1014            &a_snap,
1015            &a_eventlog,
1016        ));
1017        registry.entries.push(signed_entry(
1018            &key,
1019            &pubkey,
1020            &b_vfr,
1021            "frontier-b",
1022            &b_path,
1023            &b_snap,
1024            &b_eventlog,
1025        ));
1026
1027        let err = pull_transitive(&registry, &b_vfr, &out, 4).unwrap_err();
1028        assert!(
1029            err.contains("pinned_snapshot_hash mismatch"),
1030            "expected pin-mismatch error, got: {err}"
1031        );
1032    }
1033
1034    #[test]
1035    fn pull_transitive_errors_when_dep_missing_from_registry() {
1036        let (key, pubkey) = keypair();
1037        let tmp = TempDir::new().unwrap();
1038        let stage = tmp.path().join("stage");
1039        std::fs::create_dir_all(&stage).unwrap();
1040        let out = tmp.path().join("out");
1041
1042        let (a_path, a_vfr, a_snap, _a_eventlog) =
1043            make_real_frontier(&stage, "frontier-a", "aaa", vec![]);
1044        let (b_path, b_vfr, b_snap, b_eventlog) = make_real_frontier(
1045            &stage,
1046            "frontier-b",
1047            "bbb",
1048            vec![crate::project::ProjectDependency {
1049                name: "frontier-a".into(),
1050                source: "vela.hub".into(),
1051                version: None,
1052                pinned_hash: None,
1053                vfr_id: Some(a_vfr.clone()),
1054                locator: Some(format!("file://{}", a_path.display())),
1055                pinned_snapshot_hash: Some(a_snap),
1056            }],
1057        );
1058
1059        // Registry only has B; A is not registered.
1060        let mut registry = Registry::default();
1061        registry.entries.push(signed_entry(
1062            &key,
1063            &pubkey,
1064            &b_vfr,
1065            "frontier-b",
1066            &b_path,
1067            &b_snap,
1068            &b_eventlog,
1069        ));
1070
1071        let err = pull_transitive(&registry, &b_vfr, &out, 4).unwrap_err();
1072        assert!(err.contains("not present in registry"));
1073    }
1074}