//! Federation-side commands. //! //! These wrap `levcs-client` to talk to a configured instance. The instance //! URL lives in the repository's `.levcs/config` (TOML) under //! `instance.url`. A user-global config at `$XDG_CONFIG_HOME/levcs/config.toml` //! is consulted as a fallback. use std::collections::BTreeMap; use std::fs; use std::path::PathBuf; use anyhow::{anyhow, bail, Context, Result}; use serde::{Deserialize, Serialize}; use levcs_core::object::{ObjectType, SignedObject}; use levcs_core::refs::Head; use levcs_core::{Commit, CommitFlags, ObjectId, Repository, ZERO_ID}; use levcs_identity::authority::{ AuthorityBody, MemberEntry, PolicyEntry, Role, AUTHORITY_SCHEMA_VERSION, }; use levcs_identity::sign::{sign_authority, sign_commit}; use levcs_protocol::{Pack, PushManifest, PushUpdate}; use crate::cli::*; use crate::ctx::{load_secret, now_micros, open_repo}; #[derive(Default, Serialize, Deserialize)] struct RepoConfig { #[serde(default)] instance: BTreeMap, } fn read_instance_url(repo: &Repository) -> Result> { let path = repo.config_path(); let s = match fs::read_to_string(&path) { Ok(s) => s, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => return Err(e.into()), }; let cfg: RepoConfig = toml::from_str(&s).unwrap_or_default(); Ok(cfg .instance .get("url") .and_then(|v| v.as_str()) .map(|s| s.to_string())) } fn write_instance_url(repo: &Repository, url: &str) -> Result<()> { let path = repo.config_path(); let s = fs::read_to_string(&path).unwrap_or_default(); let mut cfg: RepoConfig = toml::from_str(&s).unwrap_or_default(); cfg.instance .insert("url".into(), toml::Value::String(url.to_string())); let out = toml::to_string_pretty(&cfg)?; fs::write(&path, out)?; Ok(()) } fn user_config_path() -> PathBuf { if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { PathBuf::from(xdg).join("levcs").join("config.toml") } else if let Some(home) = std::env::var_os("HOME") { PathBuf::from(home) .join(".config") .join("levcs") .join("config.toml") } else { PathBuf::from(".levcs.toml") } } #[derive(Default, Serialize, Deserialize)] struct UserConfig { #[serde(default)] instances: Vec, #[serde(default)] active: Option, } fn read_user_cfg() -> Result { let p = user_config_path(); let s = match fs::read_to_string(&p) { Ok(s) => s, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Default::default()), Err(e) => return Err(e.into()), }; Ok(toml::from_str(&s).unwrap_or_default()) } fn write_user_cfg(cfg: &UserConfig) -> Result<()> { let p = user_config_path(); if let Some(parent) = p.parent() { fs::create_dir_all(parent)?; } fs::write(&p, toml::to_string_pretty(cfg)?)?; Ok(()) } pub fn instance(args: InstanceArgs) -> Result<()> { if let Some(url) = args.set { if let Ok(repo) = open_repo() { write_instance_url(&repo, &url)?; eprintln!("set repository instance url to {url}"); } else { let mut cfg = read_user_cfg()?; cfg.active = Some(url.clone()); if !cfg.instances.contains(&url) { cfg.instances.push(url.clone()); } write_user_cfg(&cfg)?; eprintln!("set global active instance to {url}"); } return Ok(()); } if args.info { let url = active_instance()?; let client = levcs_client::Client::new(url); let info = client.instance_info()?; println!("{}", serde_json::to_string_pretty(&info)?); return Ok(()); } if let Some(url) = args.add { let mut cfg = read_user_cfg()?; if !cfg.instances.contains(&url) { cfg.instances.push(url); } write_user_cfg(&cfg)?; return Ok(()); } if args.list { let cfg = read_user_cfg()?; for u in cfg.instances { let star = if Some(u.clone()) == cfg.active { "*" } else { " " }; println!("{star} {u}"); } return Ok(()); } if let Some(url) = args.remove { let mut cfg = read_user_cfg()?; cfg.instances.retain(|u| u != &url); if cfg.active.as_deref() == Some(url.as_str()) { cfg.active = None; } write_user_cfg(&cfg)?; return Ok(()); } eprintln!("usage: levcs instance --set URL | --info | --add URL | --list | --remove URL"); Ok(()) } fn active_instance() -> Result { if let Ok(repo) = open_repo() { if let Some(u) = read_instance_url(&repo)? { return Ok(u); } } let cfg = read_user_cfg()?; cfg.active .ok_or_else(|| anyhow!("no active instance; run `levcs instance --set `")) } pub fn push(args: PushArgs) -> Result<()> { let repo = open_repo()?; let url = active_instance()?; let (_label, sk) = load_secret(args.key.as_deref())?; let pk = sk.public(); let _ = pk; let refs_to_push = if args.refs.is_empty() { let branch = repo .current_branch()? .ok_or_else(|| anyhow!("no current branch (detached HEAD)"))?; vec![branch] } else { args.refs }; if args.force { eprintln!("force-push: requesting non-fast-forward update; instance will require maintainer or owner role"); } let mut updates = Vec::new(); let mut needed: Vec = Vec::new(); for r in &refs_to_push { let new = repo .refs .read(r)? .ok_or_else(|| anyhow!("local ref does not exist: {r}"))?; // Build closure of objects reachable from `new`. let mut stack = vec![new]; let mut seen = std::collections::HashSet::::new(); while let Some(id) = stack.pop() { if !seen.insert(id) { continue; } if let Ok(raw) = repo.objects.read_object(id) { match raw.object_type { ObjectType::Tree => { if let Ok(t) = levcs_core::Tree::parse_body(&raw.body) { for e in t.entries { stack.push(e.hash); } } } ObjectType::Commit => { if let Ok(c) = Commit::parse_body(&raw.body) { stack.push(c.tree); stack.push(c.authority); stack.extend(c.parents); } } ObjectType::Release => { if let Ok(rel) = levcs_core::Release::parse_body(&raw.body) { stack.push(rel.tree); stack.push(rel.predecessor); stack.push(rel.authority); if !rel.parent_release.is_zero() { stack.push(rel.parent_release); } } } ObjectType::Authority => { if let Ok(b) = AuthorityBody::parse(&raw.body) { if !b.previous_authority.is_zero() { stack.push(b.previous_authority); } } } ObjectType::Blob => {} } } } needed.extend(seen.into_iter()); updates.push(PushUpdate { r#ref: r.clone(), old_hash: None, new_hash: new.to_hex(), }); } let auth = repo .current_authority()? .ok_or_else(|| anyhow!("no current authority"))?; let manifest = PushManifest { updates, authority_hash: auth.to_hex(), timestamp: now_micros(), force: args.force, }; let mut pack = Pack::new(); let mut deduped = std::collections::HashSet::new(); for id in needed { if !deduped.insert(id) { continue; } if let Ok(bytes) = repo.objects.read_raw(id) { if bytes.len() >= 5 { pack.push(bytes[4], bytes); } } } let repo_id = compute_repo_id(&repo)?; let client = levcs_client::Client::new(url); match client.push(&sk, &repo_id, &pack, &manifest) { Ok(()) => {} Err(levcs_client::ClientError::Server { status: 404, .. }) => { // Repo not yet on instance; register it then retry. let genesis = repo .genesis_authority()? .ok_or_else(|| anyhow!("local repo has no genesis authority"))?; let bytes = repo.objects.read_raw(genesis)?; eprintln!("repo not yet on instance; initialising"); client.init(&sk, &repo_id, &bytes)?; client.push(&sk, &repo_id, &pack, &manifest)?; } Err(e) => return Err(e.into()), } eprintln!("pushed {} ref(s)", manifest.updates.len()); Ok(()) } pub fn pull(args: PullArgs) -> Result<()> { let repo = open_repo()?; let url = active_instance()?; let client = levcs_client::Client::new(url); let _ = args.key; let repo_id = compute_repo_id(&repo)?; let remote_refs = client.refs(&repo_id)?; let want_refs: Vec = if args.refs.is_empty() { remote_refs.branches.keys().cloned().collect() } else { args.refs }; let mut want_ids = Vec::new(); for r in &want_refs { if let Some(h) = remote_refs.branches.get(r) { want_ids.push(ObjectId::from_hex(h)?); } } let have_ids: Vec = repo .refs .list_branches()? .into_iter() .map(|(_, id)| id) .collect(); let pack = client.get_pack(&repo_id, &have_ids, &want_ids)?; for ent in &pack.entries { repo.objects.write_raw(&ent.bytes)?; } for (r, h) in remote_refs.branches { if want_refs.contains(&r) { let id = ObjectId::from_hex(&h)?; repo.refs .write(&format!("refs/remote/origin/branches/{r}"), id)?; } } eprintln!( "pulled {} object(s) from {} ref(s)", pack.entries.len(), want_refs.len() ); Ok(()) } pub fn fork(args: ForkArgs) -> Result<()> { let url = match args.from.clone() { Some(u) => u, None => active_instance()?, }; let (label, sk) = load_secret(args.key.as_deref())?; let pk = sk.public(); let dest_name = args .name .clone() .unwrap_or_else(|| format!("fork-{}", &args.repo_id[..8.min(args.repo_id.len())])); let dest = std::env::current_dir()?.join(&dest_name); if dest.exists() { bail!("destination already exists: {:?}", dest); } // 1. Talk to the source instance. let client = levcs_client::Client::new(url.clone()); let info = client.repo_info(&args.repo_id)?; if info.repo_id.is_empty() { bail!("source instance returned no repo_id"); } let refs = client.refs(&args.repo_id)?; // 2. Choose a source tip: prefer "main", else any branch. let (source_branch, source_tip_hex) = refs .branches .iter() .find(|(k, _)| *k == "main") .or_else(|| refs.branches.iter().next()) .ok_or_else(|| anyhow!("source repo has no branches; nothing to fork"))?; let source_tip = ObjectId::from_hex(source_tip_hex)?; // 3. Pull the closure of objects reachable from the source tip and the // current authority. let mut want = vec![source_tip]; if !info.current_authority.is_empty() { want.push(ObjectId::from_hex(&info.current_authority)?); } let pack = client.get_pack(&args.repo_id, &[], &want)?; // 4. Initialise the destination repository skeleton (no objects yet). let repo = Repository::init_skeleton(&dest)?; for ent in &pack.entries { repo.objects.write_raw(&ent.bytes)?; } // 5. Locate the source HEAD commit and its authority. let source_commit_signed = repo.read_signed(source_tip)?; let source_commit = Commit::from_signed(&source_commit_signed)?; let source_auth_signed = repo.read_signed(source_commit.authority)?; let source_auth_body = AuthorityBody::parse(&source_auth_signed.body)?; // 6. Client-side authorization check (the receiving instance also enforces // this via verify_fork during push). Per §3.5.2. if !source_auth_body.public_read() && source_auth_body.find_member(&pk).is_none() { bail!( "source repository is not public-read and your key {} has no access; \ ask an owner to grant you reader role first", pk ); } // 7. Generate the new genesis authority. Sole owner is the forking user. let now = now_micros(); let mut new_auth_body = AuthorityBody { schema_version: AUTHORITY_SCHEMA_VERSION, repo_id: ZERO_ID, previous_authority: ZERO_ID, version: 1, created_micros: now, members: vec![MemberEntry { key: pk, handle: label.clone(), role: Role::Owner, added_micros: now, added_by: pk, }], policy: vec![ PolicyEntry { key: "public_read".into(), value: vec![0x01], }, PolicyEntry { key: "require_signed_releases".into(), value: vec![0x01], }, PolicyEntry { key: "allowed_handlers".into(), value: b"builtin".to_vec(), }, ], }; new_auth_body.normalize()?; new_auth_body.assign_genesis_repo_id()?; if new_auth_body.repo_id == source_auth_body.repo_id { bail!("derived repo_id collides with source; refusing to fork"); } let new_auth_signed = sign_authority(&new_auth_body, &sk)?; let new_auth_id = repo.write_signed(&new_auth_signed)?; // 8. Build the fork commit's tree: source's tree, with .levcs/authority // pointing at the new genesis. let new_tree_id = crate::tree_helpers::put_authority_in_tree(&repo, source_commit.tree, new_auth_id)?; // 9. Construct and sign the fork commit. let flags = CommitFlags(CommitFlags::MODIFIES_AUTHORITY.0 | CommitFlags::FORK.0); let fork_commit = Commit { tree: new_tree_id, parents: vec![source_tip], authority: new_auth_id, author_key: pk.0, timestamp_micros: now_micros(), flags, message: format!( "fork from blake3:{} (branch {}, tip {})", args.repo_id, source_branch, source_tip ), }; let fork_signed = sign_commit(fork_commit, &sk)?; let fork_id = repo.write_signed(&fork_signed)?; // 10. Wire up refs and HEAD, then materialise the working tree. repo.set_genesis_authority(new_auth_id)?; repo.set_current_authority(new_auth_id)?; let main_ref = "refs/branches/main".to_string(); repo.refs.write(&main_ref, fork_id)?; repo.refs.write_head(&Head::Branch(main_ref.clone()))?; repo.checkout_tree(source_commit.tree, &dest)?; eprintln!( "forked {} into {:?}\n new repo_id = blake3:{}\n fork commit = {}\n source tip = {} ({})\n source auth = {}", args.repo_id, dest, new_auth_body.repo_id, fork_id, source_tip, source_branch, source_commit.authority, ); Ok(()) } pub fn inspect(args: InspectArgs) -> Result<()> { use levcs_core::object::ObjectType; use levcs_core::{Commit, EntryType, ObjectId, Tree}; let url = match args.from { Some(u) => u, None => active_instance()?, }; let client = levcs_client::Client::new(url); // Header: repo_info + authority hash + branch heads. This is the // §7.3.5 "fetches the current authority, branch heads" data; we // print it as a small structured summary so users get a quick // snapshot before deciding to fork or clone. let info = client.repo_info(&args.repo_id)?; println!("repo_id : {}", args.repo_id); if !info.current_authority.is_empty() { println!("current authority : {}", info.current_authority); } println!(); let refs = client.refs(&args.repo_id)?; if !refs.branches.is_empty() { println!("branches:"); for (name, hash) in &refs.branches { println!(" {name:<24} {hash}"); } } if !refs.releases.is_empty() { println!("releases:"); for (name, hash) in &refs.releases { println!(" {name:<24} {hash}"); } } println!(); // Tree at (default root). §7.3.5 says "tree at // (default: root)" — that's the contents listing the user can // browse without pulling. We resolve `main` (then any branch) to // a commit, walk to the requested path, and print one line per // entry. Blobs are listed but not fetched — the point of inspect // is to be a quick peek without bulk transfer. let tip_branch = refs .branches .iter() .find(|(n, _)| *n == "main") .or_else(|| refs.branches.iter().next()); let Some((tip_name, tip_hash)) = tip_branch else { // Bare repo with no branches; nothing more to show. return Ok(()); }; let tip_id = ObjectId::from_hex(tip_hash)?; let raw = client.get_object(&args.repo_id, tip_id)?; let signed = levcs_core::object::SignedObject::parse(&raw)?; let commit = Commit::from_signed(&signed)?; let path = args.path.unwrap_or_default(); let path_components: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); let display_path = if path_components.is_empty() { "/".to_string() } else { format!("/{}", path_components.join("/")) }; println!("tree at {display_path} (branch {tip_name} → {tip_id}):"); // Resolve the path one segment at a time over the wire — each step // is one /objects/ fetch. let mut current_tree_id = commit.tree; for comp in &path_components { let raw = client.get_object(&args.repo_id, current_tree_id)?; let parsed = levcs_core::object::RawObject::parse(&raw)?; if parsed.object_type != ObjectType::Tree { anyhow::bail!("path component {:?} is not a tree", comp); } let tree = Tree::parse_body(&parsed.body)?; let entry = tree .entries .iter() .find(|e| &e.name == *comp) .ok_or_else(|| anyhow!("path not found: {comp}"))?; match entry.entry_type { EntryType::Tree => { current_tree_id = entry.hash; } EntryType::Blob => { // Path resolves to a single blob — list it as one entry. println!(" blob {:>10} {}", "?", entry.name); return Ok(()); } } } let raw = client.get_object(&args.repo_id, current_tree_id)?; let parsed = levcs_core::object::RawObject::parse(&raw)?; let tree = Tree::parse_body(&parsed.body)?; for entry in &tree.entries { let kind = match entry.entry_type { EntryType::Tree => "tree", EntryType::Blob => "blob", }; println!(" {kind} {} {}", entry.hash, entry.name); } Ok(()) } pub fn deploy(args: DeployArgs) -> Result<()> { use std::net::TcpListener; let repo = open_repo()?; let (_label, sk) = load_secret(args.key.as_deref())?; let recipient_pub = levcs_identity::keys::PublicKey::parse_levcs(&args.recipient_key) .map_err(|e| anyhow!("invalid recipient_key: {e}"))?; // Build the manifest + pack we'll ship once a dialer connects. Doing // this up-front means the listener can answer instantly and lets us // surface any local error (missing authority, empty repo) before we // bind a port. let (manifest, pack) = build_deploy_archive(&repo, args.release)?; let pack_bytes = pack.encode(); let listener = TcpListener::bind(&args.listen).with_context(|| format!("bind {}", args.listen))?; let local = listener.local_addr()?; eprintln!( "deploy listening on {local}\n recipient = {}\n send {} branch(es), {} release(s), {} object(s) ({} bytes packed)", recipient_pub.to_levcs(), manifest.branches.len(), manifest.releases.len(), pack.entries.len(), pack_bytes.len(), ); eprintln!( " share with peer: levcs dial {} {}", local, recipient_pub.to_levcs() ); // One dialer, then exit. The spec characterizes this as a one-shot // transfer, not a long-lived service — repeated transfers should run // the command again so the user is in the loop on each session. let (stream, peer_addr) = listener.accept()?; eprintln!("dialer connected from {peer_addr}"); let mut session = match levcs_protocol::p2p::handshake_listen(stream, &sk, &recipient_pub) { Ok(s) => s, Err(e) => bail!("handshake failed: {e}"), }; session .send_manifest(&manifest) .map_err(|e| anyhow!("send manifest: {e}"))?; session .send_pack(&pack_bytes) .map_err(|e| anyhow!("send pack: {e}"))?; session.send_done().map_err(|e| anyhow!("send done: {e}"))?; eprintln!("deploy complete"); Ok(()) } /// Assemble the archive a deploy session sends: manifest of refs and tip /// hashes plus a pack containing the closure of all referenced objects. /// Splitting this out keeps the listener loop simple — it never has to /// touch the repository once a dialer is on the line. fn build_deploy_archive( repo: &Repository, release_only: bool, ) -> Result<(levcs_protocol::p2p::DeployManifest, Pack)> { let repo_id = compute_repo_id(repo)?; let genesis = repo .genesis_authority()? .ok_or_else(|| anyhow!("repository has no genesis authority"))?; let auth = repo .current_authority()? .ok_or_else(|| anyhow!("no current authority"))?; let releases = repo.refs.list_releases()?; let branches = if release_only { Vec::new() } else { repo.refs.list_branches()? }; if branches.is_empty() && releases.is_empty() { bail!( "nothing to deploy: repository has no {}", if release_only { "releases" } else { "branches or releases" } ); } let mut needed = std::collections::HashSet::::new(); let mut branch_map = BTreeMap::new(); for (name, id) in &branches { walk_closure(repo, *id, &mut needed); branch_map.insert(name.clone(), id.to_hex()); } let mut release_map = BTreeMap::new(); for (name, id) in &releases { walk_closure(repo, *id, &mut needed); release_map.insert(name.clone(), id.to_hex()); } walk_closure(repo, auth, &mut needed); walk_closure(repo, genesis, &mut needed); let mut pack = Pack::new(); for id in needed { if let Ok(bytes) = repo.objects.read_raw(id) { if bytes.len() >= 5 { pack.push(bytes[4], bytes); } } } let manifest = levcs_protocol::p2p::DeployManifest { repo_id, mode: if release_only { "release".into() } else { "all".into() }, branches: branch_map, releases: release_map, authority_hash: auth.to_hex(), genesis_authority: genesis.to_hex(), timestamp_micros: now_micros(), }; Ok((manifest, pack)) } /// Move the local repository to a different instance, preserving repo_id /// and full history (§5.7). One-shot orchestration: /// 1. /init the destination with the genesis authority object, /// 2. push every branch and release in a single signed manifest, /// with a pack containing the closure of all refs and the current /// authority chain. /// Idempotent at the wire level: if the destination already has the /// repo_id (e.g. a previous attempt got partway), init returns 409 and /// we skip straight to push. pub fn migrate(args: MigrateArgs) -> Result<()> { let repo = open_repo()?; let (_label, sk) = load_secret(args.key.as_deref())?; let repo_id = compute_repo_id(&repo)?; let client = levcs_client::Client::new(args.to.clone()); // Step 1: ensure the destination has the repository, initialised from // genesis. /init is idempotent in spirit — duplicate calls return 409, // which we treat as "already there, carry on". let genesis_id = repo .genesis_authority()? .ok_or_else(|| anyhow!("local repo has no genesis authority"))?; let genesis_bytes = repo.objects.read_raw(genesis_id)?; match client.init(&sk, &repo_id, &genesis_bytes) { Ok(()) => eprintln!("initialised {repo_id} on {}", args.to), Err(levcs_client::ClientError::Server { status: 409, .. }) => { eprintln!("repo already exists on destination; skipping init"); } Err(e) => return Err(e.into()), } // Step 2: collect every ref we want to advance on the destination — // both branches and releases — and union their reachable closures // into a single pack. Authority chain comes along automatically as a // dependency of every commit/release. let branches = repo.refs.list_branches()?; let releases = repo.refs.list_releases()?; if branches.is_empty() && releases.is_empty() { bail!("nothing to migrate: repository has no branches or releases"); } let mut updates = Vec::with_capacity(branches.len() + releases.len()); let mut needed = std::collections::HashSet::::new(); for (name, id) in &branches { walk_closure(&repo, *id, &mut needed); updates.push(PushUpdate { r#ref: format!("refs/branches/{name}"), old_hash: None, new_hash: id.to_hex(), }); } for (name, id) in &releases { walk_closure(&repo, *id, &mut needed); updates.push(PushUpdate { r#ref: format!("refs/releases/{name}"), old_hash: None, new_hash: id.to_hex(), }); } let auth = repo .current_authority()? .ok_or_else(|| anyhow!("no current authority"))?; walk_closure(&repo, auth, &mut needed); let mut pack = Pack::new(); for id in needed { if let Ok(bytes) = repo.objects.read_raw(id) { if bytes.len() >= 5 { pack.push(bytes[4], bytes); } } } let manifest = PushManifest { updates, authority_hash: auth.to_hex(), timestamp: now_micros(), // Mirror replication never force-pushes — every update through // here is by construction a fast-forward (the source has the // commits the destination is missing). force: false, }; // Step 3: push. The destination verifies signatures end-to-end against // the same authority chain we just sent — there's no trust delegation // to the new instance, only authentication of the pusher. client.push(&sk, &repo_id, &pack, &manifest)?; eprintln!( "migrated {repo_id}: {} branch(es), {} release(s), {} object(s)", branches.len(), releases.len(), pack.entries.len() ); if args.set_active { write_instance_url(&repo, &args.to)?; eprintln!("active instance for this repo set to {}", args.to); } else { eprintln!( "(run `levcs instance --set {}` to repoint future operations)", args.to ); } Ok(()) } /// Reachability walk shared by push() and migrate(). Inserts every object /// transitively referenced from `start` into `out`, including blobs. fn walk_closure(repo: &Repository, start: ObjectId, out: &mut std::collections::HashSet) { let mut stack = vec![start]; while let Some(id) = stack.pop() { if !out.insert(id) { continue; } let raw = match repo.objects.read_object(id) { Ok(r) => r, Err(_) => continue, }; match raw.object_type { ObjectType::Tree => { if let Ok(t) = levcs_core::Tree::parse_body(&raw.body) { for e in t.entries { stack.push(e.hash); } } } ObjectType::Commit => { if let Ok(c) = Commit::parse_body(&raw.body) { stack.push(c.tree); stack.push(c.authority); stack.extend(c.parents); } } ObjectType::Release => { if let Ok(rel) = levcs_core::Release::parse_body(&raw.body) { stack.push(rel.tree); stack.push(rel.predecessor); stack.push(rel.authority); if !rel.parent_release.is_zero() { stack.push(rel.parent_release); } } } ObjectType::Authority => { if let Ok(b) = AuthorityBody::parse(&raw.body) { if !b.previous_authority.is_zero() { stack.push(b.previous_authority); } } } ObjectType::Blob => {} } } } pub fn dial(args: DialArgs) -> Result<()> { use std::net::TcpStream; let (_label, sk) = load_secret(args.key.as_deref())?; let sender_pub = levcs_identity::keys::PublicKey::parse_levcs(&args.sender_key) .map_err(|e| anyhow!("invalid sender_key: {e}"))?; let stream = TcpStream::connect(&args.sender_host) .with_context(|| format!("connect {}", args.sender_host))?; eprintln!("dialing {} as {}", args.sender_host, sender_pub.to_levcs()); let mut session = match levcs_protocol::p2p::handshake_dial(stream, &sk, &sender_pub) { Ok(s) => s, Err(e) => bail!("handshake failed: {e}"), }; let manifest = session .recv_manifest() .map_err(|e| anyhow!("recv manifest: {e}"))?; let pack_bytes = session.recv_pack().map_err(|e| anyhow!("recv pack: {e}"))?; session.recv_done().map_err(|e| anyhow!("recv done: {e}"))?; let pack = Pack::decode(&pack_bytes).map_err(|e| anyhow!("decode pack: {e}"))?; eprintln!( "received {} object(s); manifest reports {} branch(es), {} release(s)", pack.entries.len(), manifest.branches.len(), manifest.releases.len() ); // Pick a destination directory. Default is `` in cwd — // mirrors the convention `levcs fork` uses. let dest = match args.path { Some(p) => p, None => std::env::current_dir()?.join(format!( "dial-{}", &manifest.repo_id[..8.min(manifest.repo_id.len())] )), }; if dest.exists() { bail!("destination already exists: {:?}", dest); } let repo = Repository::init_skeleton(&dest)?; // Write objects first so verify_commit / verify_release can read them // by hash. Each entry is an already-framed SignedObject — write_raw // re-hashes and rejects content that doesn't match its declared id. for ent in &pack.entries { repo.objects.write_raw(&ent.bytes)?; } // Cross-check the manifest's repo_id against the genesis authority we // just received. The recipient never trusts the manifest's word for it. let genesis_id = ObjectId::from_hex(&manifest.genesis_authority) .map_err(|_| anyhow!("manifest genesis_authority not a valid hash"))?; let genesis_signed = repo.read_signed(genesis_id)?; let genesis_body = AuthorityBody::parse(&genesis_signed.body)?; let derived_repo_id = genesis_body.repo_id.to_hex(); if derived_repo_id != manifest.repo_id { bail!( "manifest repo_id {} does not match genesis-derived {}", manifest.repo_id, derived_repo_id ); } // Verify each tip end-to-end. verify_commit / verify_release walk the // authority chain and the embedded signatures — refuse to advance any // ref whose tip can't be verified, even if the bytes hash-match. for (_name, hex) in &manifest.branches { let id = ObjectId::from_hex(hex)?; levcs_identity::verify::verify_commit(&repo.objects, id, None) .map_err(|e| anyhow!("verify branch tip {hex}: {e}"))?; } for (_name, hex) in &manifest.releases { let id = ObjectId::from_hex(hex)?; levcs_identity::verify::verify_release(&repo.objects, id) .map_err(|e| anyhow!("verify release {hex}: {e}"))?; } // Now wire up refs. Authority pointers come from the manifest; we // already have those objects in the store and verified them through // the commit/release walks. repo.set_genesis_authority(genesis_id)?; let auth_id = ObjectId::from_hex(&manifest.authority_hash)?; repo.set_current_authority(auth_id)?; for (name, hex) in &manifest.branches { let id = ObjectId::from_hex(hex)?; repo.refs.write(&format!("refs/branches/{name}"), id)?; } for (name, hex) in &manifest.releases { let id = ObjectId::from_hex(hex)?; repo.refs.write(&format!("refs/releases/{name}"), id)?; } // HEAD on main if present, otherwise any branch we got. Releases-only // archives leave HEAD detached at the latest release's predecessor — // there's no branch to point at. if let Some((name, _)) = manifest .branches .iter() .find(|(k, _)| k.as_str() == "main") .or_else(|| manifest.branches.iter().next()) { let r = format!("refs/branches/{name}"); repo.refs.write_head(&Head::Branch(r))?; // Materialize a working tree from the chosen branch tip. if let Some(id) = manifest.branches.get(name) { let id = ObjectId::from_hex(id)?; let commit_signed = repo.read_signed(id)?; let commit = Commit::from_signed(&commit_signed)?; repo.checkout_tree(commit.tree, &dest)?; } } else if let Some((_name, hex)) = manifest.releases.iter().next() { let id = ObjectId::from_hex(hex)?; repo.refs.write_head(&Head::Detached(id))?; let rel_signed = repo.read_signed(id)?; let rel = levcs_core::Release::from_signed(&rel_signed)?; repo.checkout_tree(rel.tree, &dest)?; } eprintln!( "dial complete: repository at {:?}\n repo_id = blake3:{}\n authority = {}", dest, derived_repo_id, manifest.authority_hash ); Ok(()) } fn compute_repo_id(repo: &Repository) -> Result { let genesis = repo .genesis_authority()? .ok_or_else(|| anyhow!("repository has no genesis authority"))?; let signed = repo.read_signed(genesis)?; if signed.object_type != ObjectType::Authority { bail!("genesis is not an authority"); } let body = AuthorityBody::parse(&signed.body)?; Ok(body.repo_id.to_hex()) } #[allow(dead_code)] fn _unused(_: SignedObject) {}