1use 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#[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 pub owner_pubkey: String,
42 pub latest_snapshot_hash: String,
44 pub latest_event_log_hash: String,
46 pub network_locator: String,
50 pub signed_publish_at: String,
52 pub signature: String,
55}
56
57fn default_entry_schema() -> String {
58 ENTRY_SCHEMA.to_string()
59}
60
61#[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
85pub 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
106pub 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
117pub 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
123pub 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
146pub 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
174pub 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
227pub 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
243pub 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
258pub 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#[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
371pub 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 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 .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 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
462pub 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, ®istry)
478}
479
480pub 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
491pub 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#[derive(Debug, Clone)]
530pub struct PullResult {
531 pub primary_path: std::path::PathBuf,
533 pub deps: std::collections::HashMap<String, std::path::PathBuf>,
535 pub verified: Vec<String>,
538}
539
540pub 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 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 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; }
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 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 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 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(®istry, &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); let result = publish_entry(&path, entry);
776 assert!(result.is_err(), "unsigned entry must be rejected");
777 }
778
779 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 let (a_path, a_vfr, a_snap, a_eventlog) =
934 make_real_frontier(&stage, "frontier-a", "aaa", vec![]);
935 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(®istry, &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 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(®istry, &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 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(®istry, &b_vfr, &out, 4).unwrap_err();
1072 assert!(err.contains("not present in registry"));
1073 }
1074}