994 lines
35 KiB
Rust
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) {}
|