LeVCS/crates/levcs-cli/src/fed_cmds.rs

994 lines
35 KiB
Rust

//! 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<String, toml::Value>,
}
fn read_instance_url(repo: &Repository) -> Result<Option<String>> {
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<String>,
#[serde(default)]
active: Option<String>,
}
fn read_user_cfg() -> Result<UserConfig> {
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<String> {
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 <URL>`"))
}
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<ObjectId> = 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::<ObjectId>::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<String> = 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<ObjectId> = 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 <path> (default root). §7.3.5 says "tree at <path>
// (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/<hash> 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::<ObjectId>::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::<ObjectId>::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<ObjectId>) {
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 `<repo_id_prefix>` 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<String> {
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) {}