levcs 0.1.0 - initial core

This commit is contained in:
Levi Neuwirth 2026-05-01 11:14:36 -04:00
commit 21c6056ae6
151 changed files with 26641 additions and 0 deletions

65
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,65 @@
name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
# Gate: workspace must build and the full test suite must pass.
# Includes a compile-check of the criterion benches so a broken
# bench doesn't go unnoticed between manual `cargo bench` runs.
test:
name: build + test
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo + target
uses: Swatinem/rust-cache@v2
- name: Build workspace
run: cargo build --workspace --all-targets
- name: Run tests
run: cargo test --workspace
- name: Compile-check benches
run: cargo build --workspace --benches
# Informational: surfaces formatting drift without blocking merges.
# Flip continue-on-error to false (or remove the line) once the
# codebase is fmt-clean.
fmt:
name: fmt (informational)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --all --check
# Informational: clippy lints with default severity. Same deal as
# fmt — when the codebase is lint-clean, drop continue-on-error and
# add `-- -D warnings` to make this a real gate.
clippy:
name: clippy (informational)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --workspace --all-targets

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# Spec drafts — kept private until published.
/spec/
# Build output and bench artifacts.
/target
/bench-results
# LeVCS working state at the repo root, if the binaries are ever run
# from here. Subdirectory `.levcs/` is part of every repo's metadata
# and is excluded so committing this repo via git stays orthogonal
# to dogfooding it via LeVCS itself.
/levcs-data/
/.levcs/
# Profiling and perf output.
flamegraph.svg
perf.data
perf.data.old
*.profraw
*.profdata
# Editor and IDE.
.vscode/
.idea/
*.iml
# OS metadata.
.DS_Store
Thumbs.db
# Backup and swap.
*~
*.swp
*.swo
*.bak
# Local env files. Never commit secrets — keychain lives in
# $XDG_CONFIG_HOME by design, but pattern-match anyway.
.env
.env.*
!.env.example

3944
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

84
Cargo.toml Normal file
View File

@ -0,0 +1,84 @@
[workspace]
resolver = "2"
members = [
"crates/levcs-core",
"crates/levcs-identity",
"crates/levcs-merge",
"crates/levcs-protocol",
"crates/levcs-client",
"crates/levcs-instance",
"crates/levcs-cli",
"crates/levcs-tui",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
authors = ["Levi J. Neuwirth <ln@levineuwirth.org>"]
rust-version = "1.75"
[workspace.dependencies]
levcs-core = { path = "crates/levcs-core" }
levcs-identity = { path = "crates/levcs-identity" }
levcs-merge = { path = "crates/levcs-merge" }
levcs-protocol = { path = "crates/levcs-protocol" }
levcs-client = { path = "crates/levcs-client" }
levcs-instance = { path = "crates/levcs-instance" }
levcs-tui = { path = "crates/levcs-tui" }
blake3 = "1.5"
ed25519-dalek = { version = "2.1", features = ["rand_core", "std"] }
rand_core = { version = "0.6", features = ["getrandom"] }
getrandom = "0.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
thiserror = "1.0"
byteorder = "1.5"
hex = "0.4"
base64 = "0.22"
glob = "0.3"
similar = { version = "2.6", features = ["text"] }
tree-sitter = "0.26"
tree-sitter-rust = "0.24"
tree-sitter-python = "0.25"
tree-sitter-javascript = "0.25"
tree-sitter-typescript = "0.23"
tree-sitter-go = "0.25"
tree-sitter-c = "0.24"
tree-sitter-cpp = "0.23"
tree-sitter-java = "0.23"
tree-sitter-ruby = "0.23"
tree-sitter-bash = "0.25"
serde_yaml = "0.9"
quick-xml = { version = "0.39", features = ["serialize"] }
pulldown-cmark = "0.13"
wasmtime = { version = "25", default-features = false, features = ["cranelift", "runtime"] }
wat = "1"
zstd = "0.13"
clap = { version = "4.5", features = ["derive"] }
argon2 = "0.5"
chacha20poly1305 = "0.10"
zeroize = "1.8"
ratatui = "0.28"
crossterm = "0.28"
tokio = { version = "1.40", features = ["full"] }
axum = "0.7"
tower = "0.5"
tower-http = { version = "0.5", features = ["cors", "trace"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1.0"
proptest = "1.5"
criterion = { version = "0.5", default-features = false, features = ["cargo_bench_support"] }
[profile.release]
opt-level = 3
lto = "thin"
# Keep debug info in `bench` builds so cargo-flamegraph can unwind.
# Symbols add to binary size but do not affect runtime perf.
[profile.bench]
debug = true

View File

@ -0,0 +1,30 @@
[package]
name = "levcs-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
levcs-core = { workspace = true }
levcs-identity = { workspace = true }
levcs-merge = { workspace = true }
levcs-protocol = { workspace = true }
levcs-client = { workspace = true }
levcs-tui = { workspace = true }
clap = { workspace = true }
similar = { workspace = true }
serde = { workspace = true }
anyhow = { workspace = true }
hex = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
[[bin]]
name = "levcs"
path = "src/main.rs"
[dev-dependencies]
levcs-instance = { workspace = true }
tokio = { workspace = true }
axum = { workspace = true }

328
crates/levcs-cli/src/cli.rs Normal file
View File

@ -0,0 +1,328 @@
//! clap command tree.
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(name = "levcs", version, about = "LeVCS: federated, memory-safe VCS")]
pub struct Cli {
#[command(subcommand)]
pub command: Cmd,
}
#[derive(Subcommand, Debug)]
pub enum Cmd {
/// Create a new repository.
Init(InitArgs),
/// Mark files as tracked.
Track(TrackArgs),
/// Stop tracking files.
Forget(ForgetArgs),
/// Create a new commit.
Commit(CommitArgs),
/// Reconstruct files from a commit/release/cache.
Construct(ConstructArgs),
/// Show changes between working tree and a commit.
Diff(DiffArgs),
/// Manage branches.
Branch(BranchArgs),
/// Merge a branch into the current branch.
Merge(MergeArgs),
/// Declare a release.
Release(ReleaseArgs),
/// Manage cached working-tree states.
Cache(CacheArgs),
/// Show working-tree status.
Status,
/// Show commit history.
Log(LogArgs),
/// Print the absolute path of the repository root.
Root,
/// Verify all reachable objects and signatures.
Verify,
/// Garbage-collect unreachable objects.
Gc(GcArgs),
/// Manage the user's keychain.
#[command(subcommand)]
Key(KeyCmd),
/// Manage the repository's authority file.
#[command(subcommand)]
Authority(AuthorityCmd),
/// Manage known instances.
Instance(InstanceArgs),
/// Push refs to the active instance.
Push(PushArgs),
/// Pull updates from the active instance.
Pull(PullArgs),
/// Fork a repository.
Fork(ForkArgs),
/// Inspect a remote repository without pulling.
Inspect(InspectArgs),
/// Direct peer-to-peer transfer (sender side).
Deploy(DeployArgs),
/// Direct peer-to-peer transfer (receiver side).
Dial(DialArgs),
/// Move a repository to a new instance, preserving repo_id and history (§5.7).
Migrate(MigrateArgs),
}
// ---------- repository commands ----------
#[derive(Args, Debug)]
pub struct InitArgs {
/// Key label to use as initial owner; created if absent.
#[arg(long)]
pub key: Option<String>,
pub path: Option<PathBuf>,
}
#[derive(Args, Debug)]
pub struct TrackArgs {
/// Track all files in the working tree.
#[arg(long)]
pub all: bool,
pub paths: Vec<PathBuf>,
}
#[derive(Args, Debug)]
pub struct ForgetArgs {
#[arg(long = "keep-file")]
pub keep_file: bool,
pub paths: Vec<PathBuf>,
}
#[derive(Args, Debug)]
pub struct CommitArgs {
#[arg(short, long)]
pub message: Option<String>,
#[arg(long)]
pub key: Option<String>,
#[arg(long)]
pub all: bool,
}
#[derive(Args, Debug)]
pub struct ConstructArgs {
pub hash: Option<String>,
#[arg(long)]
pub all: bool,
#[arg(long)]
pub release: bool,
pub paths: Vec<PathBuf>,
}
#[derive(Args, Debug)]
pub struct DiffArgs {
#[arg(long)]
pub release: bool,
pub commit: Option<String>,
pub paths: Vec<PathBuf>,
}
#[derive(Args, Debug)]
pub struct BranchArgs {
#[arg(long)]
pub list: bool,
#[arg(long)]
pub create: Option<String>,
#[arg(long)]
pub switch: Option<String>,
#[arg(long)]
pub delete: Option<String>,
pub from: Option<String>,
}
#[derive(Args, Debug)]
pub struct MergeArgs {
#[arg(long)]
pub review: bool,
#[arg(long)]
pub abort: bool,
#[arg(long)]
pub explain: bool,
#[arg(long = "no-auto")]
pub no_auto: bool,
/// Output format. `text` (default) emits human-readable lines on
/// stderr/stdout. `json` emits one structured JSON object on stdout
/// per §6.7 — useful for scripting and CI.
#[arg(long = "format", default_value = "text")]
pub format: String,
#[arg(long)]
pub key: Option<String>,
pub branch: Option<String>,
}
#[derive(Args, Debug)]
pub struct ReleaseArgs {
pub label: String,
#[arg(short, long)]
pub message: Option<String>,
#[arg(long)]
pub key: Option<String>,
}
#[derive(Args, Debug)]
pub struct CacheArgs {
#[arg(long)]
pub save: bool,
#[arg(short, long)]
pub message: Option<String>,
#[arg(long)]
pub list: bool,
#[arg(long)]
pub restore: Option<String>,
#[arg(long)]
pub drop: Option<String>,
}
#[derive(Args, Debug)]
pub struct LogArgs {
#[arg(long)]
pub release: bool,
#[arg(long)]
pub since: Option<String>,
}
#[derive(Args, Debug)]
pub struct GcArgs {
#[arg(long)]
pub aggressive: bool,
/// Grace period in days. Loose objects modified within this window
/// are kept regardless of reachability — they may belong to an
/// in-progress operation that has written the object but hasn't
/// linked it from any ref yet (§4.2.2). Default 14 days.
#[arg(long = "grace-days", default_value = "14")]
pub grace_days: u64,
}
// ---------- identity commands ----------
#[derive(Subcommand, Debug)]
pub enum KeyCmd {
Generate { label: String, #[arg(long)] encrypt: bool },
List,
Show { label: String },
Export { label: String, path: PathBuf },
Import { label: String, path: PathBuf },
Remove { label: String },
Rename { old: String, new: String },
}
#[derive(Subcommand, Debug)]
pub enum AuthorityCmd {
Show,
List,
Add {
key: String,
#[arg(long)] role: String,
#[arg(long)] handle: Option<String>,
#[arg(long = "signing-key")] signing_key: Option<String>,
},
Remove {
key: String,
#[arg(long = "signing-key")] signing_key: Option<String>,
},
Promote {
key: String,
#[arg(long)] role: String,
#[arg(long = "signing-key")] signing_key: Option<String>,
},
}
// ---------- federation commands ----------
#[derive(Args, Debug)]
pub struct InstanceArgs {
#[arg(long)]
pub set: Option<String>,
#[arg(long)]
pub info: bool,
#[arg(long)]
pub add: Option<String>,
#[arg(long)]
pub list: bool,
#[arg(long)]
pub remove: Option<String>,
}
#[derive(Args, Debug)]
pub struct PushArgs {
#[arg(long)]
pub key: Option<String>,
#[arg(long)]
pub force: bool,
pub refs: Vec<String>,
}
#[derive(Args, Debug)]
pub struct PullArgs {
#[arg(long)]
pub key: Option<String>,
pub refs: Vec<String>,
}
#[derive(Args, Debug)]
pub struct ForkArgs {
pub repo_id: String,
#[arg(long)]
pub from: Option<String>,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub key: Option<String>,
}
#[derive(Args, Debug)]
pub struct InspectArgs {
pub repo_id: String,
#[arg(long)]
pub from: Option<String>,
pub path: Option<String>,
}
#[derive(Args, Debug)]
pub struct DeployArgs {
/// Ed25519 public key of the recipient who is permitted to dial in.
pub recipient_key: String,
/// Send only release refs (and their reachable closure). Without this
/// flag, branches and releases are both included.
#[arg(long)]
pub release: bool,
/// Identity key the deployer signs with (defaults to active key).
#[arg(long)]
pub key: Option<String>,
/// Address to bind for incoming dialers. Defaults to 0.0.0.0:0 (any
/// free port; the bound address is printed before listening).
#[arg(long, default_value = "0.0.0.0:0")]
pub listen: String,
/// Repository path to deploy from (defaults to the current directory).
pub path: Option<PathBuf>,
}
#[derive(Args, Debug)]
pub struct DialArgs {
/// host:port the sender's deployer is listening on.
pub sender_host: String,
/// Ed25519 public key the sender is expected to sign with.
pub sender_key: String,
/// Identity key the dialer authenticates with (defaults to active).
#[arg(long)]
pub key: Option<String>,
/// Destination directory for the received repository (defaults to
/// `<repo_id_prefix>` in the current working directory).
pub path: Option<PathBuf>,
}
#[derive(Args, Debug)]
pub struct MigrateArgs {
/// Base URL of the destination instance (including /levcs/v1).
pub to: String,
/// Identity key to sign init/push requests.
#[arg(long)]
pub key: Option<String>,
/// After a successful migration, set this URL as the active instance
/// pointer for the local repository so subsequent push/pull use it.
#[arg(long)]
pub set_active: bool,
}

View File

@ -0,0 +1,91 @@
//! Common helpers shared by all commands.
use std::io::{self, Write};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, Context, Result};
use levcs_core::Repository;
use levcs_identity::keychain::Keychain;
use levcs_identity::keys::SecretKey;
pub fn now_micros() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_micros() as i64)
.unwrap_or(0)
}
pub fn open_repo() -> Result<Repository> {
let cwd = std::env::current_dir()?;
Ok(Repository::discover(&cwd)?)
}
pub fn keychain_path() -> PathBuf {
Keychain::default_path()
}
pub fn load_keychain() -> Result<Keychain> {
Ok(Keychain::load_or_default(&keychain_path())?)
}
pub fn save_keychain(kc: &Keychain) -> Result<()> {
Ok(kc.save(&keychain_path())?)
}
pub fn read_passphrase(prompt: &str) -> Result<String> {
eprint!("{prompt}");
io::stderr().flush().ok();
// Best-effort no-echo: rely on terminal raw mode if available; otherwise
// fall back to plain readline. This is intentionally simple to avoid
// pulling another crate; users on headless CI should pass keys via
// unencrypted slots or a future agent.
let mut buf = String::new();
io::stdin().read_line(&mut buf)?;
if buf.ends_with('\n') {
buf.pop();
if buf.ends_with('\r') {
buf.pop();
}
}
Ok(buf)
}
/// Resolve a key label and load the secret. If `label` is None, default to
/// `"personal"`, then to the only key in the chain if there is exactly one.
pub fn load_secret(label: Option<&str>) -> Result<(String, SecretKey)> {
let kc = load_keychain()?;
let chosen = match label {
Some(l) => l.to_string(),
None => {
if kc.entry("personal").is_some() {
"personal".into()
} else if kc.keys.len() == 1 {
kc.keys[0].label.clone()
} else if kc.keys.is_empty() {
return Err(anyhow!(
"no keys in keychain at {:?}; run `levcs key generate <label>`",
keychain_path()
));
} else {
return Err(anyhow!(
"multiple keys in keychain; pass --key <label>"
));
}
}
};
let entry = kc
.entry(&chosen)
.ok_or_else(|| anyhow!("unknown key label: {chosen}"))?
.clone();
let sk = if entry.private.is_some() {
kc.secret(&chosen, || Ok(String::new()))
.context("loading plaintext secret")?
} else {
let pp = read_passphrase(&format!("passphrase for key '{chosen}': "))?;
kc.secret(&chosen, || Ok(pp.clone()))
.context("decrypting secret")?
};
Ok((chosen, sk))
}

View File

@ -0,0 +1,961 @@
//! 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) {}

View File

@ -0,0 +1,240 @@
//! Identity-side commands: `levcs key ...` and `levcs authority ...`.
use std::fs;
use anyhow::{anyhow, bail, Context, Result};
use levcs_core::{Commit, CommitFlags, ZERO_ID};
use levcs_identity::authority::{AuthorityBody, MemberEntry, Role};
use levcs_identity::keychain::Keychain;
use levcs_identity::keys::{PublicKey, SecretKey};
use levcs_identity::sign::{sign_authority, sign_commit};
use crate::cli::*;
use crate::ctx::{
load_keychain, load_secret, now_micros, open_repo, read_passphrase, save_keychain,
};
pub fn key(cmd: KeyCmd) -> Result<()> {
match cmd {
KeyCmd::Generate { label, encrypt } => {
let mut kc = load_keychain()?;
if kc.entry(&label).is_some() {
bail!("key already exists: {label}");
}
let sk = SecretKey::generate();
if encrypt {
let pp = read_passphrase("new passphrase: ")?;
let pp2 = read_passphrase("confirm passphrase: ")?;
if pp != pp2 { bail!("passphrases do not match"); }
kc.add_encrypted(&label, &sk, pp.as_bytes())?;
} else {
kc.add_plaintext(&label, &sk)?;
}
save_keychain(&kc)?;
println!("generated key '{}'\n public: {}", label, sk.public());
Ok(())
}
KeyCmd::List => {
let kc = load_keychain()?;
for e in &kc.keys {
let kind = if e.private_encrypted.is_some() { "encrypted" } else { "plaintext" };
println!("{}\t{}\t{kind}", e.label, e.public);
}
Ok(())
}
KeyCmd::Show { label } => {
let kc = load_keychain()?;
let e = kc.entry(&label).ok_or_else(|| anyhow!("no such key: {label}"))?;
println!("{}", e.public);
Ok(())
}
KeyCmd::Export { label, path } => {
let kc = load_keychain()?;
let e = kc.entry(&label).ok_or_else(|| anyhow!("no such key: {label}"))?.clone();
let mut single = Keychain::new();
single.keys.push(e);
single.save(&path)?;
eprintln!("exported '{label}' to {:?}", path);
Ok(())
}
KeyCmd::Import { label, path } => {
let mut kc = load_keychain()?;
let imported = Keychain::load_or_default(&path).context("loading import file")?;
let entry = imported
.keys
.into_iter()
.next()
.ok_or_else(|| anyhow!("import file has no keys"))?;
let mut entry = entry;
entry.label = label;
if kc.entry(&entry.label).is_some() {
bail!("key already exists: {}", entry.label);
}
kc.keys.push(entry);
save_keychain(&kc)?;
Ok(())
}
KeyCmd::Remove { label } => {
let mut kc = load_keychain()?;
kc.remove(&label)?;
save_keychain(&kc)?;
eprintln!("removed key '{label}'");
Ok(())
}
KeyCmd::Rename { old, new } => {
let mut kc = load_keychain()?;
kc.rename(&old, &new)?;
save_keychain(&kc)?;
Ok(())
}
}
}
pub fn authority(cmd: AuthorityCmd) -> Result<()> {
let repo = open_repo()?;
let auth_id = repo
.current_authority()?
.ok_or_else(|| anyhow!("no current authority"))?;
let signed = repo.read_signed(auth_id)?;
let body = AuthorityBody::parse(&signed.body)?;
match cmd {
AuthorityCmd::Show => {
let toml_text = levcs_identity::authority::render_toml_authority(&body)?;
println!("{toml_text}");
Ok(())
}
AuthorityCmd::List => {
for m in &body.members {
println!("{}\t{}\t{}", m.role.name(), m.handle, m.key);
}
Ok(())
}
AuthorityCmd::Add { key, role, handle, signing_key } => {
let pk = PublicKey::parse_levcs(&key)?;
let role = Role::from_name(&role)?;
let handle = handle.unwrap_or_default();
mutate_authority(signing_key.as_deref(), |new_body, signer_pk| {
if new_body.find_member(&pk).is_some() {
bail!("member already exists: {pk}");
}
new_body.members.push(MemberEntry {
key: pk,
handle: handle.clone(),
role,
added_micros: now_micros(),
added_by: signer_pk,
});
Ok(())
})
}
AuthorityCmd::Remove { key, signing_key } => {
let pk = PublicKey::parse_levcs(&key)?;
mutate_authority(signing_key.as_deref(), |new_body, _| {
let before = new_body.members.len();
new_body.members.retain(|m| m.key != pk);
if new_body.members.len() == before {
bail!("no such member: {pk}");
}
Ok(())
})
}
AuthorityCmd::Promote { key, role, signing_key } => {
let pk = PublicKey::parse_levcs(&key)?;
let role = Role::from_name(&role)?;
mutate_authority(signing_key.as_deref(), |new_body, _| {
let m = new_body
.members
.iter_mut()
.find(|m| m.key == pk)
.ok_or_else(|| anyhow!("no such member: {pk}"))?;
m.role = role;
Ok(())
})
}
}
}
/// Run `f` against a clone of the current authority's body, then create a
/// successor authority and an authority-modifying commit on the current
/// branch. The signing key must hold owner role.
fn mutate_authority<F>(signing_key_label: Option<&str>, mut f: F) -> Result<()>
where
F: FnMut(&mut AuthorityBody, PublicKey) -> Result<()>,
{
let repo = open_repo()?;
// If no label supplied, prefer the only owner of the current authority.
let label = match signing_key_label {
Some(l) => Some(l.to_string()),
None => {
let auth_id = repo.current_authority()?.ok_or_else(|| anyhow!("no current authority"))?;
let signed = repo.read_signed(auth_id)?;
let body = AuthorityBody::parse(&signed.body)?;
let owners: Vec<&MemberEntry> = body.members.iter().filter(|m| m.role == Role::Owner).collect();
if owners.len() == 1 {
let kc = crate::ctx::load_keychain()?;
let owner_key = owners[0].key;
kc.keys.iter().find(|e| {
levcs_identity::keys::PublicKey::parse_levcs(&e.public)
.ok().map(|p| p == owner_key).unwrap_or(false)
}).map(|e| e.label.clone())
} else {
None
}
}
};
let (_, sk) = load_secret(label.as_deref())?;
let pk = sk.public();
let cur_id = repo
.current_authority()?
.ok_or_else(|| anyhow!("no current authority"))?;
let cur_signed = repo.read_signed(cur_id)?;
let cur_body = AuthorityBody::parse(&cur_signed.body)?;
let me = cur_body
.find_member(&pk)
.ok_or_else(|| anyhow!("your key is not in the current authority"))?;
if me.role < Role::Owner {
bail!("authority modifications require owner role");
}
// Build successor body.
let mut new_body = cur_body.clone();
new_body.previous_authority = cur_id;
new_body.version = cur_body.version.checked_add(1).ok_or_else(|| anyhow!("version overflow"))?;
new_body.created_micros = now_micros();
f(&mut new_body, pk)?;
new_body.normalize()?;
let new_signed = sign_authority(&new_body, &sk)?;
let new_auth_id = repo.write_signed(&new_signed)?;
// Build a commit whose tree contains `.levcs/authority` pointing at the
// new authority object. The tree mirrors HEAD's tree, plus this entry.
let head = repo.refs.resolve_head()?;
let parents = head.map(|p| vec![p]).unwrap_or_default();
let parent_tree_id = if let Some(h) = head {
Commit::from_signed(&repo.read_signed(h)?)?.tree
} else {
ZERO_ID
};
let new_tree_id = crate::tree_helpers::put_authority_in_tree(&repo, parent_tree_id, new_auth_id)?;
let commit_obj = Commit {
tree: new_tree_id,
parents,
authority: cur_id,
author_key: pk.0,
timestamp_micros: now_micros(),
flags: CommitFlags::MODIFIES_AUTHORITY,
message: "modify authority".into(),
};
let signed_commit = sign_commit(commit_obj, &sk)?;
let id = repo.write_signed(&signed_commit)?;
if let Some(branch) = repo.current_branch()? {
repo.refs.write(&branch, id)?;
}
repo.set_current_authority(new_auth_id)?;
eprintln!("authority updated to {new_auth_id} (commit {id})");
Ok(())
}
#[allow(dead_code)]
fn _io_unused(_: fs::Metadata) {}

View File

@ -0,0 +1,50 @@
//! `levcs` command-line interface.
use anyhow::Result;
mod cli;
mod ctx;
mod fed_cmds;
mod identity_cmds;
mod repo_cmds;
mod tree_helpers;
use clap::Parser;
fn main() {
if let Err(e) = run() {
eprintln!("levcs: {e:#}");
std::process::exit(1);
}
}
fn run() -> Result<()> {
let args = cli::Cli::parse();
match args.command {
cli::Cmd::Init(a) => repo_cmds::init(a),
cli::Cmd::Track(a) => repo_cmds::track(a),
cli::Cmd::Forget(a) => repo_cmds::forget(a),
cli::Cmd::Commit(a) => repo_cmds::commit(a),
cli::Cmd::Construct(a) => repo_cmds::construct(a),
cli::Cmd::Diff(a) => repo_cmds::diff(a),
cli::Cmd::Branch(a) => repo_cmds::branch(a),
cli::Cmd::Merge(a) => repo_cmds::merge(a),
cli::Cmd::Release(a) => repo_cmds::release(a),
cli::Cmd::Cache(a) => repo_cmds::cache(a),
cli::Cmd::Status => repo_cmds::status(),
cli::Cmd::Log(a) => repo_cmds::log(a),
cli::Cmd::Root => repo_cmds::root(),
cli::Cmd::Verify => repo_cmds::verify(),
cli::Cmd::Gc(a) => repo_cmds::gc(a),
cli::Cmd::Key(a) => identity_cmds::key(a),
cli::Cmd::Authority(a) => identity_cmds::authority(a),
cli::Cmd::Instance(a) => fed_cmds::instance(a),
cli::Cmd::Push(a) => fed_cmds::push(a),
cli::Cmd::Pull(a) => fed_cmds::pull(a),
cli::Cmd::Fork(a) => fed_cmds::fork(a),
cli::Cmd::Inspect(a) => fed_cmds::inspect(a),
cli::Cmd::Deploy(a) => fed_cmds::deploy(a),
cli::Cmd::Dial(a) => fed_cmds::dial(a),
cli::Cmd::Migrate(a) => fed_cmds::migrate(a),
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,99 @@
//! Tree-construction helpers shared by authority-modifying commits and fork
//! commits.
use anyhow::Result;
use levcs_core::object::ObjectType;
use levcs_core::{EntryType, FileMode, ObjectId, Repository, Tree, TreeEntry};
/// Return a new tree ID with the path `.levcs/authority` set to point at the
/// (already-stored) new authority object. Per the v1.1 trust-root revision
/// §3.4.1, the entry's hash equals the authority object's hash.
///
/// `parent_tree_id` is the tree to mirror (e.g., HEAD's tree, or the source
/// commit's tree for a fork). If it is `ZERO_ID`, an empty tree is used.
pub fn put_authority_in_tree(
repo: &Repository,
parent_tree_id: ObjectId,
new_authority_id: ObjectId,
) -> Result<ObjectId> {
let mut top = if parent_tree_id.is_zero() {
Tree::default()
} else {
let raw = repo.objects.read_typed(parent_tree_id, ObjectType::Tree)?;
Tree::parse_body(&raw.body)?
};
let mut levcs_tree = if let Some(e) = top.find(".levcs") {
if e.entry_type == EntryType::Tree {
let raw = repo.objects.read_typed(e.hash, ObjectType::Tree)?;
Tree::parse_body(&raw.body)?
} else {
Tree::default()
}
} else {
Tree::default()
};
levcs_tree.entries.retain(|e| e.name != "authority");
levcs_tree.entries.push(TreeEntry {
name: "authority".into(),
entry_type: EntryType::Blob,
mode: FileMode::REGULAR,
hash: new_authority_id,
});
levcs_tree.sort_and_validate()?;
let levcs_tree_id = repo.objects.write_raw(&levcs_tree.serialize())?;
top.entries.retain(|e| e.name != ".levcs");
top.entries.push(TreeEntry {
name: ".levcs".into(),
entry_type: EntryType::Tree,
mode: FileMode::REGULAR,
hash: levcs_tree_id,
});
top.sort_and_validate()?;
Ok(repo.objects.write_raw(&top.serialize())?)
}
/// Return a new tree ID with the path `.levcs/merge-record` set to a blob
/// containing the merge metadata TOML (§6.5). Mirrors `put_authority_in_tree`
/// but wraps the bytes in a `Blob` since merge-record is ordinary file
/// content.
pub fn put_merge_record_in_tree(
repo: &Repository,
parent_tree_id: ObjectId,
record_blob_id: ObjectId,
) -> Result<ObjectId> {
let mut top = if parent_tree_id.is_zero() {
Tree::default()
} else {
let raw = repo.objects.read_typed(parent_tree_id, ObjectType::Tree)?;
Tree::parse_body(&raw.body)?
};
let mut levcs_tree = if let Some(e) = top.find(".levcs") {
if e.entry_type == EntryType::Tree {
let raw = repo.objects.read_typed(e.hash, ObjectType::Tree)?;
Tree::parse_body(&raw.body)?
} else {
Tree::default()
}
} else {
Tree::default()
};
levcs_tree.entries.retain(|e| e.name != "merge-record");
levcs_tree.entries.push(TreeEntry {
name: "merge-record".into(),
entry_type: EntryType::Blob,
mode: FileMode::REGULAR,
hash: record_blob_id,
});
levcs_tree.sort_and_validate()?;
let levcs_tree_id = repo.objects.write_raw(&levcs_tree.serialize())?;
top.entries.retain(|e| e.name != ".levcs");
top.entries.push(TreeEntry {
name: ".levcs".into(),
entry_type: EntryType::Tree,
mode: FileMode::REGULAR,
hash: levcs_tree_id,
});
top.sort_and_validate()?;
Ok(repo.objects.write_raw(&top.serialize())?)
}

View File

@ -0,0 +1,197 @@
//! End-to-end fork test:
//! 1. Spin up an in-process levcs instance.
//! 2. Run the levcs binary to init a source repo, commit, point at the
//! instance, and push.
//! 3. Run the levcs binary in a fresh directory to fork the source.
//! 4. Run `levcs verify` in the fork to confirm the fork commit and chain.
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use levcs_instance::{router, AppState, InstanceConfig};
fn levcs_bin() -> String {
env!("CARGO_BIN_EXE_levcs").to_string()
}
fn run(args: &[&str], cwd: &Path, xdg: &Path, extra: &[(&str, &str)]) -> (i32, String, String) {
let mut cmd = Command::new(levcs_bin());
cmd.args(args).current_dir(cwd).env("XDG_CONFIG_HOME", xdg);
for (k, v) in extra {
cmd.env(k, v);
}
let out = cmd.output().expect("run levcs");
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).to_string(),
String::from_utf8_lossy(&out.stderr).to_string(),
)
}
fn tempdir(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("{prefix}-{n}-{}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
/// Spin up an axum instance bound to an ephemeral port. Returns (addr,
/// shutdown_handle).
async fn start_instance(root: PathBuf) -> (SocketAddr, tokio::task::JoinHandle<()>) {
let cfg = InstanceConfig {
root,
storage_mode: "full".into(),
federation_peers: Vec::new(),
allowed_handlers: vec!["builtin".into()],
mirrors: Vec::new(),
};
let state = AppState::new(cfg);
let app = router(state);
let listener = tokio::net::TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let task = tokio::spawn(async move {
axum::serve(listener, app).await.ok();
});
(addr, task)
}
#[test]
fn fork_end_to_end() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
// 1. Boot an instance.
let instance_root = tempdir("levcs-fork-instance");
let instance_root_for_task = instance_root.clone();
let (addr, server_task) = runtime.block_on(async move { start_instance(instance_root_for_task).await });
let base_url = format!("http://{addr}/levcs/v1");
// 2. Source repo.
let source = tempdir("levcs-fork-source");
let xdg = tempdir("levcs-fork-cfg");
let (code, _, e) = run(&["init", "--key", "alice"], &source, &xdg, &[]);
assert_eq!(code, 0, "init: {e}");
std::fs::write(source.join("README"), b"source repo content\n").unwrap();
let (code, _, e) = run(&["track", "--all"], &source, &xdg, &[]);
assert_eq!(code, 0, "track: {e}");
let (code, _, e) = run(&["commit", "-m", "initial"], &source, &xdg, &[]);
assert_eq!(code, 0, "commit: {e}");
// Point at the instance and push.
let (code, _, e) = run(&["instance", "--set", &base_url], &source, &xdg, &[]);
assert_eq!(code, 0, "instance --set: {e}");
let (code, _, e) = run(&["push"], &source, &xdg, &[]);
assert_eq!(code, 0, "push: {e}");
// Look up the source's repo_id.
let (code, repo_id_out, _) = run(&["root"], &source, &xdg, &[]);
assert_eq!(code, 0);
let _ = repo_id_out;
// Easier: read genesis authority's repo_id from .levcs.
let genesis_hex = std::fs::read_to_string(source.join(".levcs/refs/authority/genesis"))
.unwrap()
.trim()
.to_string();
// Now read the genesis object from the instance via the running server.
// Or simpler: derive repo_id from the source repo by parsing its genesis.
let genesis_path = source
.join(".levcs/objects")
.join(&genesis_hex[..2])
.join(&genesis_hex[2..]);
let bytes = std::fs::read(&genesis_path).unwrap();
let signed = levcs_core::object::SignedObject::parse(&bytes).unwrap();
let body = levcs_identity::authority::AuthorityBody::parse(&signed.body).unwrap();
let repo_id_hex = body.repo_id.to_hex();
// 3. Fork into a new directory using a different key (bob).
let fork_parent = tempdir("levcs-fork-dest-parent");
// Generate bob in the same xdg keychain so load_secret can find him.
let (code, _, e) = run(&["key", "generate", "bob"], &fork_parent, &xdg, &[]);
assert_eq!(code, 0, "key generate bob: {e}");
let (code, _, e) = run(&["instance", "--set", &base_url], &fork_parent, &xdg, &[]);
assert_eq!(code, 0, "instance --set (fork): {e}");
let (code, _o, err) = run(
&["fork", &repo_id_hex, "--name", "myfork", "--key", "bob"],
&fork_parent,
&xdg,
&[],
);
assert_eq!(code, 0, "fork: {err}");
// 4. Verify the fork.
let fork_dir = fork_parent.join("myfork");
assert!(fork_dir.is_dir(), "fork directory not created");
assert!(fork_dir.join("README").is_file(), "source content not checked out");
assert_eq!(
std::fs::read_to_string(fork_dir.join("README")).unwrap(),
"source repo content\n"
);
let (code, _, e) = run(&["verify"], &fork_dir, &xdg, &[]);
assert_eq!(code, 0, "verify of fork: {e}");
// 5. Confirm the fork's repo_id differs from the source's.
let fork_genesis_hex = std::fs::read_to_string(fork_dir.join(".levcs/refs/authority/genesis"))
.unwrap()
.trim()
.to_string();
let fork_genesis_path = fork_dir
.join(".levcs/objects")
.join(&fork_genesis_hex[..2])
.join(&fork_genesis_hex[2..]);
let fork_bytes = std::fs::read(&fork_genesis_path).unwrap();
let fork_signed = levcs_core::object::SignedObject::parse(&fork_bytes).unwrap();
let fork_body = levcs_identity::authority::AuthorityBody::parse(&fork_signed.body).unwrap();
assert_ne!(
fork_body.repo_id, body.repo_id,
"fork repo_id must differ from source"
);
// Bob is the sole owner of the new genesis.
assert_eq!(fork_body.members.len(), 1);
assert_eq!(fork_body.members[0].role, levcs_identity::authority::Role::Owner);
// 6. Confirm the fork commit has both flags set and a single parent.
let head_hex = std::fs::read_to_string(fork_dir.join(".levcs/refs/branches/main"))
.unwrap()
.trim()
.to_string();
let head_id = levcs_core::ObjectId::from_hex(&head_hex).unwrap();
let head_path = fork_dir
.join(".levcs/objects")
.join(&head_hex[..2])
.join(&head_hex[2..]);
let head_bytes = std::fs::read(&head_path).unwrap();
let head_signed = levcs_core::object::SignedObject::parse(&head_bytes).unwrap();
let head_commit = levcs_core::Commit::from_signed(&head_signed).unwrap();
assert!(
head_commit.flags.is_fork(),
"fork commit must have fork flag set"
);
assert!(
head_commit.flags.modifies_authority(),
"fork commit must have modifies-authority flag set"
);
assert_eq!(head_commit.parents.len(), 1);
let _ = head_id;
// Done. Tear down.
server_task.abort();
runtime.shutdown_background();
let _ = Arc::new(()); // keep tokio import live
let _ = std::fs::remove_dir_all(instance_root);
let _ = std::fs::remove_dir_all(source);
let _ = std::fs::remove_dir_all(fork_parent);
let _ = std::fs::remove_dir_all(xdg);
}

View File

@ -0,0 +1,125 @@
//! End-to-end test for `levcs inspect`.
//!
//! Boots an in-process instance, pushes a repo with a small tree, then
//! drives the `levcs inspect` binary against it. Verifies the output
//! lists the current authority, branch heads, and the requested tree
//! contents (§7.3.5).
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::Command;
use levcs_instance::{router, AppState, InstanceConfig};
fn levcs_bin() -> String {
env!("CARGO_BIN_EXE_levcs").to_string()
}
fn run(args: &[&str], cwd: &Path, xdg: &Path) -> (i32, String, String) {
let out = Command::new(levcs_bin())
.args(args)
.current_dir(cwd)
.env("XDG_CONFIG_HOME", xdg)
.output()
.expect("run levcs");
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).to_string(),
String::from_utf8_lossy(&out.stderr).to_string(),
)
}
fn tempdir(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("{prefix}-{n}-{}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
async fn start_instance(root: PathBuf) -> (SocketAddr, tokio::task::JoinHandle<()>) {
let cfg = InstanceConfig {
root,
storage_mode: "full".into(),
federation_peers: Vec::new(),
allowed_handlers: Vec::new(),
mirrors: Vec::new(),
};
let state = AppState::new(cfg);
let app = router(state);
let listener = tokio::net::TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let task = tokio::spawn(async move {
axum::serve(listener, app).await.ok();
});
(addr, task)
}
#[test]
fn inspect_lists_branches_authority_and_tree() {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let instance_root = tempdir("levcs-inspect-inst");
let instance_root_for_task = instance_root.clone();
let (addr, task) = rt.block_on(async move { start_instance(instance_root_for_task).await });
let base_url = format!("http://{addr}/levcs/v1");
let source = tempdir("levcs-inspect-src");
let xdg = tempdir("levcs-inspect-cfg");
assert_eq!(run(&["init", "--key", "alice"], &source, &xdg).0, 0);
std::fs::write(source.join("README"), b"hello\n").unwrap();
std::fs::create_dir_all(source.join("nested")).unwrap();
std::fs::write(source.join("nested/file.txt"), b"deep\n").unwrap();
assert_eq!(run(&["track", "--all"], &source, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "first"], &source, &xdg).0, 0);
assert_eq!(run(&["instance", "--set", &base_url], &source, &xdg).0, 0);
assert_eq!(run(&["push"], &source, &xdg).0, 0);
// Pull the repo_id off disk so we can inspect it remotely.
let genesis_hex = std::fs::read_to_string(source.join(".levcs/refs/authority/genesis"))
.unwrap()
.trim()
.to_string();
let genesis_path = source
.join(".levcs/objects")
.join(&genesis_hex[..2])
.join(&genesis_hex[2..]);
let bytes = std::fs::read(&genesis_path).unwrap();
let signed = levcs_core::object::SignedObject::parse(&bytes).unwrap();
let body = levcs_identity::authority::AuthorityBody::parse(&signed.body).unwrap();
let repo_id_hex = body.repo_id.to_hex();
// Inspect from a fresh directory so we're really hitting the
// network path. No `--from`; we'll set the active instance there.
let probe = tempdir("levcs-inspect-probe");
assert_eq!(run(&["instance", "--set", &base_url], &probe, &xdg).0, 0);
// Root inspect: must list the branch tip and the tree at root.
let (code, stdout, _e) = run(&["inspect", &repo_id_hex], &probe, &xdg);
assert_eq!(code, 0, "inspect at root must succeed");
assert!(stdout.contains("repo_id"), "must show repo_id: {stdout}");
assert!(stdout.contains("current authority"), "must show authority: {stdout}");
assert!(stdout.contains("branches:"), "must list branches: {stdout}");
assert!(stdout.contains("main"), "must show main branch: {stdout}");
assert!(stdout.contains("README"), "must list README at root: {stdout}");
assert!(stdout.contains("nested"), "must list nested subtree at root: {stdout}");
// Path inspect: drill into the `nested/` subtree.
let (code, stdout, _e) = run(&["inspect", &repo_id_hex, "nested"], &probe, &xdg);
assert_eq!(code, 0, "inspect at nested/ must succeed");
assert!(stdout.contains("file.txt"), "must list nested/file.txt: {stdout}");
task.abort();
let _ = std::fs::remove_dir_all(&instance_root);
let _ = std::fs::remove_dir_all(&source);
let _ = std::fs::remove_dir_all(&xdg);
let _ = std::fs::remove_dir_all(&probe);
}

View File

@ -0,0 +1,165 @@
//! End-to-end integration tests driving the `levcs` binary like a user.
use std::process::Command;
fn levcs_bin() -> String {
env!("CARGO_BIN_EXE_levcs").to_string()
}
fn run(args: &[&str], cwd: &std::path::Path, xdg: &std::path::Path) -> (i32, String, String) {
let out = Command::new(levcs_bin())
.args(args)
.current_dir(cwd)
.env("XDG_CONFIG_HOME", xdg)
.output()
.expect("run levcs");
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).to_string(),
String::from_utf8_lossy(&out.stderr).to_string(),
)
}
fn tempdir(prefix: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("{prefix}-{n}-{}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn end_to_end_init_track_commit_log_verify() {
let work = tempdir("levcs-it");
let xdg = work.join("cfg");
std::fs::create_dir_all(&xdg).unwrap();
// init
let (code, _o, e) = run(&["init", "--key", "alice"], &work, &xdg);
assert_eq!(code, 0, "init failed: {e}");
// write file, track, commit
std::fs::write(work.join("a.txt"), b"hello\n").unwrap();
let (code, _, e) = run(&["track", "--all"], &work, &xdg);
assert_eq!(code, 0, "track failed: {e}");
let (code, _, e) = run(&["commit", "-m", "first"], &work, &xdg);
assert_eq!(code, 0, "commit failed: {e}");
// status should be clean
let (code, o, _) = run(&["status"], &work, &xdg);
assert_eq!(code, 0);
assert!(o.contains("working tree clean"));
// verify
let (code, _, e) = run(&["verify"], &work, &xdg);
assert_eq!(code, 0, "verify failed: {e}");
// log should show one commit
let (code, o, _) = run(&["log"], &work, &xdg);
assert_eq!(code, 0);
assert!(o.contains("first"));
let _ = std::fs::remove_dir_all(work);
}
#[test]
fn authority_chain_round_trip() {
let work = tempdir("levcs-auth");
let xdg = work.join("cfg");
std::fs::create_dir_all(&xdg).unwrap();
let (code, _, e) = run(&["init", "--key", "alice"], &work, &xdg);
assert_eq!(code, 0, "init: {e}");
std::fs::write(work.join("README"), b"hi\n").unwrap();
run(&["track", "--all"], &work, &xdg);
run(&["commit", "-m", "init"], &work, &xdg);
// Generate bob and add as contributor
let (_, _, _) = run(&["key", "generate", "bob"], &work, &xdg);
let (_, bob_pub, _) = run(&["key", "show", "bob"], &work, &xdg);
let bob_pub = bob_pub.trim().to_string();
let (code, _, e) = run(
&["authority", "add", &bob_pub, "--role", "contributor", "--handle", "bob"],
&work,
&xdg,
);
assert_eq!(code, 0, "authority add: {e}");
// Bob commits
std::fs::write(work.join("BOB"), b"bob's note\n").unwrap();
run(&["track", "--all"], &work, &xdg);
let (code, _, e) = run(&["commit", "-m", "bob's edit", "--key", "bob"], &work, &xdg);
assert_eq!(code, 0, "bob's commit: {e}");
// Verify the entire chain
let (code, _, e) = run(&["verify"], &work, &xdg);
assert_eq!(code, 0, "verify: {e}");
let _ = std::fs::remove_dir_all(work);
}
#[test]
fn branch_create_and_switch() {
let work = tempdir("levcs-branch");
let xdg = work.join("cfg");
std::fs::create_dir_all(&xdg).unwrap();
run(&["init", "--key", "alice"], &work, &xdg);
std::fs::write(work.join("a.txt"), b"hi\n").unwrap();
run(&["track", "--all"], &work, &xdg);
run(&["commit", "-m", "first"], &work, &xdg);
let (code, _, _) = run(&["branch", "--create", "dev"], &work, &xdg);
assert_eq!(code, 0);
let (_, o, _) = run(&["branch", "--list"], &work, &xdg);
assert!(o.contains("dev"));
assert!(o.contains("main"));
let _ = std::fs::remove_dir_all(work);
}
/// `gc` keeps unreachable objects newer than the grace period and
/// removes them once the period expires (§4.2.2). Drop a synthetic
/// hex-named file into the object store to give gc something
/// unreachable to reason about.
#[test]
fn gc_grace_period_keeps_young_objects_and_deletes_old_ones() {
let work = tempdir("levcs-gc");
let xdg = work.join("cfg");
std::fs::create_dir_all(&xdg).unwrap();
run(&["init", "--key", "alice"], &work, &xdg);
std::fs::write(work.join("a.txt"), b"hi\n").unwrap();
run(&["track", "--all"], &work, &xdg);
run(&["commit", "-m", "first"], &work, &xdg);
// Drop a hex-named "object" file into the sharded store. The
// contents are arbitrary; gc's only reachability rule is "is the
// hash visible from any ref?", so this name is unreachable by
// construction.
let stray_dir = work.join(".levcs/objects/ff");
std::fs::create_dir_all(&stray_dir).unwrap();
// The shard prefix is the first 2 hex chars; the on-disk filename
// is the *remaining* 62. iter_ids reconstructs the full 64-char
// hash from prefix + filename.
let stray = stray_dir.join("ff".repeat(31));
std::fs::write(&stray, b"unreachable garbage").unwrap();
assert!(stray.is_file());
// Default grace (14 days) — the just-written stray file is way
// younger than that, so it must be kept.
let (code, _, e) = run(&["gc"], &work, &xdg);
assert_eq!(code, 0, "gc default: {e}");
assert!(stray.is_file(), "young unreachable object must be kept under default grace");
assert!(e.contains("kept"), "gc must report kept count: {e}");
// Force grace=0 and the stray file must go.
let (code, _, e) = run(&["gc", "--grace-days=0"], &work, &xdg);
assert_eq!(code, 0, "gc grace=0: {e}");
assert!(!stray.is_file(), "with grace=0 the unreachable object must be deleted");
assert!(e.contains("removed"), "gc must report deletion count: {e}");
let _ = std::fs::remove_dir_all(&work);
}

View File

@ -0,0 +1,412 @@
//! End-to-end tests for the `levcs merge` command.
use std::path::{Path, PathBuf};
use std::process::Command;
fn levcs_bin() -> String {
env!("CARGO_BIN_EXE_levcs").to_string()
}
fn run(args: &[&str], cwd: &Path, xdg: &Path) -> (i32, String, String) {
let out = Command::new(levcs_bin())
.args(args)
.current_dir(cwd)
.env("XDG_CONFIG_HOME", xdg)
.output()
.expect("run levcs");
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).to_string(),
String::from_utf8_lossy(&out.stderr).to_string(),
)
}
fn tempdir(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("{prefix}-{n}-{}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
fn init_repo() -> (PathBuf, PathBuf) {
let work = tempdir("levcs-merge");
let xdg = work.join("cfg");
std::fs::create_dir_all(&xdg).unwrap();
let (code, _, e) = run(&["init", "--key", "alice"], &work, &xdg);
assert_eq!(code, 0, "init: {e}");
(work, xdg)
}
#[test]
fn fast_forward_merge_advances_branch() {
let (work, xdg) = init_repo();
std::fs::write(work.join("a.txt"), b"one\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "first"], &work, &xdg).0, 0);
// Create a feature branch off main, switch to it, add a commit.
assert_eq!(run(&["branch", "--create", "feature"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--switch", "feature"], &work, &xdg).0, 0);
std::fs::write(work.join("b.txt"), b"two\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "add b"], &work, &xdg).0, 0);
// Switch back to main and merge feature: should fast-forward.
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
let (code, _, e) = run(&["merge", "feature"], &work, &xdg);
assert_eq!(code, 0, "fast-forward merge: {e}");
assert!(e.contains("fast-forward"), "expected fast-forward message; got {e}");
assert!(work.join("b.txt").is_file(), "feature file should be present");
}
#[test]
fn clean_three_way_merge_then_commit_produces_two_parents() {
let (work, xdg) = init_repo();
std::fs::write(work.join("a.txt"), b"hello\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
// Branch.
assert_eq!(run(&["branch", "--create", "feat"], &work, &xdg).0, 0);
// On main: add c.txt.
std::fs::write(work.join("c.txt"), b"main side\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "main change"], &work, &xdg).0, 0);
// Switch to feat and add b.txt.
assert_eq!(run(&["branch", "--switch", "feat"], &work, &xdg).0, 0);
std::fs::write(work.join("b.txt"), b"feat side\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "feat change"], &work, &xdg).0, 0);
// Back to main, merge feat: clean (disjoint changes).
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
let (code, o, e) = run(&["merge", "feat"], &work, &xdg);
assert_eq!(code, 0, "merge: {e}");
assert!(o.contains("auto-resolved: 1") || o.contains("auto-resolved: 2"), "summary missing: {o}");
// Both files should be in the working tree now.
assert!(work.join("b.txt").is_file());
assert!(work.join("c.txt").is_file());
// MERGE_HEAD should exist before commit, then disappear after.
let merge_head = work.join(".levcs/MERGE_HEAD");
assert!(merge_head.exists(), "MERGE_HEAD should be set before commit");
// Finalize.
let (code, _, e) = run(&["commit", "-m", "merge feat"], &work, &xdg);
assert_eq!(code, 0, "commit (merge): {e}");
assert!(!merge_head.exists(), "MERGE_HEAD should be cleared after commit");
// Log should show the merge as the most recent commit.
let (code, log, _) = run(&["log"], &work, &xdg);
assert_eq!(code, 0);
assert!(log.contains("merge feat"));
}
#[test]
fn conflicting_merge_writes_state_and_blocks_commit_until_resolved() {
let (work, xdg) = init_repo();
std::fs::write(work.join("a.txt"), b"original\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--create", "feat"], &work, &xdg).0, 0);
// Modify a.txt on main.
std::fs::write(work.join("a.txt"), b"main version\n").unwrap();
assert_eq!(run(&["commit", "-m", "main edit"], &work, &xdg).0, 0);
// Different modification on feat.
assert_eq!(run(&["branch", "--switch", "feat"], &work, &xdg).0, 0);
std::fs::write(work.join("a.txt"), b"feat version\n").unwrap();
assert_eq!(run(&["commit", "-m", "feat edit"], &work, &xdg).0, 0);
// Merge feat into main: conflict.
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
let (code, _o, e) = run(&["merge", "feat"], &work, &xdg);
assert_ne!(code, 0, "conflict should produce non-zero exit");
assert!(e.contains("CONFLICT") || e.contains("conflict"), "conflict report missing: {e}");
// Merge state files exist.
assert!(work.join(".levcs/MERGE_HEAD").exists());
assert!(work.join(".levcs/MERGE_BASE").exists());
assert!(work.join(".levcs/merge-record").exists());
// The working file has conflict markers.
let bytes = std::fs::read(work.join("a.txt")).unwrap();
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("<<<<<<<"));
assert!(s.contains(">>>>>>>"));
// commit must refuse while conflict markers remain.
let (code, _, e) = run(&["commit", "-m", "premature"], &work, &xdg);
assert_ne!(code, 0, "commit should refuse: {e}");
assert!(e.contains("conflict markers"), "marker check should mention markers: {e}");
// Resolve manually and commit.
std::fs::write(work.join("a.txt"), b"resolved\n").unwrap();
let (code, _, e) = run(&["commit", "-m", "merge feat"], &work, &xdg);
assert_eq!(code, 0, "commit after resolution: {e}");
assert!(!work.join(".levcs/MERGE_HEAD").exists());
assert!(!work.join(".levcs/merge-record").exists());
}
#[test]
fn merge_abort_restores_head_and_clears_state() {
let (work, xdg) = init_repo();
std::fs::write(work.join("a.txt"), b"original\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--create", "feat"], &work, &xdg).0, 0);
std::fs::write(work.join("a.txt"), b"main version\n").unwrap();
assert_eq!(run(&["commit", "-m", "main edit"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--switch", "feat"], &work, &xdg).0, 0);
std::fs::write(work.join("a.txt"), b"feat version\n").unwrap();
assert_eq!(run(&["commit", "-m", "feat edit"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
let _ = run(&["merge", "feat"], &work, &xdg);
assert!(work.join(".levcs/MERGE_HEAD").exists());
// Abort.
let (code, _, e) = run(&["merge", "--abort"], &work, &xdg);
assert_eq!(code, 0, "abort: {e}");
assert!(!work.join(".levcs/MERGE_HEAD").exists());
assert!(!work.join(".levcs/merge-record").exists());
// Working tree restored to main's content.
let s = std::fs::read_to_string(work.join("a.txt")).unwrap();
assert_eq!(s, "main version\n");
}
#[test]
fn commit_refuses_merge_record_with_handler_outside_repo_policy() {
// Set up a merge whose merge-record we'll then hand-edit to reference a
// disallowed plugin handler. The repo's `.levcs/merge.toml` enforces
// builtin-only.
let (work, xdg) = init_repo();
std::fs::write(work.join("a.txt"), b"hello\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--create", "feat"], &work, &xdg).0, 0);
std::fs::write(work.join("a.txt"), b"main side\n").unwrap();
assert_eq!(run(&["commit", "-m", "main"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--switch", "feat"], &work, &xdg).0, 0);
std::fs::write(work.join("b.txt"), b"feat side\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "feat"], &work, &xdg).0, 0);
// Switch to main, then write a builtin-only policy and merge.
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
std::fs::write(
work.join(".levcs/merge.toml"),
b"[policy]\nallowed_handlers = [\"builtin\"]\n",
)
.unwrap();
let (code, _, _) = run(&["merge", "feat"], &work, &xdg);
assert_eq!(code, 0, "clean merge against built-ins should pass policy");
// Hand-edit the merge-record to reference a forbidden plugin.
let record_path = work.join(".levcs/merge-record");
let mut record = std::fs::read_to_string(&record_path).unwrap();
record.push_str(
"\n[[file]]\npath = \"x.proto\"\nhandler = \"tree-sitter:protobuf\"\nhandler_hash = \"blake3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"\nstatus = \"auto\"\n",
);
std::fs::write(&record_path, &record).unwrap();
let (code, _, e) = run(&["commit", "-m", "merge feat"], &work, &xdg);
assert_ne!(code, 0, "commit must refuse a record outside policy");
assert!(e.contains("tree-sitter:protobuf"), "error must name the bad handler: {e}");
}
#[test]
fn merge_format_json_emits_structured_report_on_clean_merge() {
let (work, xdg) = init_repo();
std::fs::write(work.join("a.txt"), b"hello\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--create", "feat"], &work, &xdg).0, 0);
std::fs::write(work.join("c.txt"), b"main side\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "main change"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--switch", "feat"], &work, &xdg).0, 0);
std::fs::write(work.join("b.txt"), b"feat side\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "feat change"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
let (code, stdout, _) = run(&["merge", "--format=json", "feat"], &work, &xdg);
assert_eq!(code, 0, "clean merge with --format=json must exit 0");
// stdout must be a single line of valid JSON, parseable.
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("parse JSON report");
assert_eq!(v["schema_version"], 1);
assert_eq!(v["conflicts"], 0);
assert!(v["auto_resolved"].as_u64().unwrap() >= 1);
assert!(v["base"].as_str().unwrap().starts_with("blake3:"));
let files = v["files"].as_array().expect("files array");
// The human-readable summary must NOT appear on stdout.
assert!(
!stdout.contains("merge summary:"),
"JSON mode must suppress prose summary; got: {stdout}"
);
// Each file record carries path + handler + status.
for f in files {
assert!(f["path"].is_string());
assert!(f["handler"].is_string());
let status = f["status"].as_str().unwrap();
assert!(
matches!(status, "merged" | "conflict" | "not_applicable"),
"unexpected status {status}"
);
}
}
#[test]
fn merge_format_json_reports_conflicts_and_exits_nonzero() {
let (work, xdg) = init_repo();
std::fs::write(work.join("a.txt"), b"original\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--create", "feat"], &work, &xdg).0, 0);
std::fs::write(work.join("a.txt"), b"main version\n").unwrap();
assert_eq!(run(&["commit", "-m", "main edit"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--switch", "feat"], &work, &xdg).0, 0);
std::fs::write(work.join("a.txt"), b"feat version\n").unwrap();
assert_eq!(run(&["commit", "-m", "feat edit"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
let (code, stdout, _) = run(&["merge", "--format=json", "feat"], &work, &xdg);
assert_ne!(code, 0, "conflicting merge must exit non-zero");
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("parse JSON");
assert!(v["conflicts"].as_u64().unwrap() >= 1);
let files = v["files"].as_array().unwrap();
let conflicted: Vec<_> = files
.iter()
.filter(|f| f["status"] == "conflict")
.collect();
assert!(!conflicted.is_empty(), "must report at least one conflict");
// Conflict regions array is present for conflicted files.
let f = conflicted[0];
assert!(f["conflict_regions"].as_u64().unwrap() >= 1);
}
#[test]
fn merge_format_json_rejects_unknown_value() {
let (work, xdg) = init_repo();
std::fs::write(work.join("a.txt"), b"x\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "x"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--create", "f"], &work, &xdg).0, 0);
std::fs::write(work.join("a.txt"), b"y\n").unwrap();
assert_eq!(run(&["commit", "-m", "y"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
let (code, _, e) = run(&["merge", "--format=xml", "f"], &work, &xdg);
assert_ne!(code, 0, "unknown format value must error out");
assert!(e.contains("--format"), "error must mention --format: {e}");
}
#[test]
fn merge_local_toml_can_demote_handler() {
// Repo config pins `*.txt` to prose (rank 1); the user's local
// override demotes it to textual (rank 0). Merge should then use
// the textual handler — verify by checking the JSON report's
// handler field.
let (work, xdg) = init_repo();
std::fs::write(work.join("note.txt"), b"original\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--create", "feat"], &work, &xdg).0, 0);
std::fs::write(work.join("note.txt"), b"main side\n").unwrap();
assert_eq!(run(&["commit", "-m", "main"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--switch", "feat"], &work, &xdg).0, 0);
std::fs::write(work.join("note.txt"), b"feat side\n").unwrap();
assert_eq!(run(&["commit", "-m", "feat"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
// Repo says: prose for *.txt. Local says: textual for *.txt (demote).
std::fs::write(
work.join(".levcs/merge.toml"),
b"schema_version = 1\n\n[[rule]]\nglob = \"*.txt\"\nhandler = \"prose\"\n",
)
.unwrap();
std::fs::write(
work.join(".levcs/merge.local.toml"),
b"schema_version = 1\n\n[[rule]]\nglob = \"*.txt\"\nhandler = \"textual\"\n",
)
.unwrap();
let (code, stdout, _) = run(&["merge", "--format=json", "feat"], &work, &xdg);
// Conflict expected (both sides changed); the interesting bit is
// that the handler chosen is `textual`, not `prose`.
assert_ne!(code, 0);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
let txt = v["files"]
.as_array()
.unwrap()
.iter()
.find(|f| f["path"] == "note.txt")
.expect("note.txt in report");
assert_eq!(txt["handler"], "textual", "demoted handler must be in effect");
}
#[test]
fn merge_local_toml_promotion_is_rejected() {
let (work, xdg) = init_repo();
std::fs::write(work.join("note.txt"), b"x\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--create", "f"], &work, &xdg).0, 0);
std::fs::write(work.join("note.txt"), b"y\n").unwrap();
assert_eq!(run(&["commit", "-m", "f"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
// Repo: textual. Local tries to promote to tree-sitter:rust (rank 2).
std::fs::write(
work.join(".levcs/merge.toml"),
b"schema_version = 1\n\n[[rule]]\nglob = \"*.txt\"\nhandler = \"textual\"\n",
)
.unwrap();
std::fs::write(
work.join(".levcs/merge.local.toml"),
b"schema_version = 1\n\n[[rule]]\nglob = \"*.txt\"\nhandler = \"tree-sitter:rust\"\n",
)
.unwrap();
let (code, _, e) = run(&["merge", "f"], &work, &xdg);
assert_ne!(code, 0, "promotion must error out");
assert!(e.contains("merge.local.toml"), "error must name the offending file: {e}");
assert!(e.contains("promote"), "error must say 'promote': {e}");
}
#[test]
fn explain_dumps_merge_record() {
let (work, xdg) = init_repo();
std::fs::write(work.join("a.txt"), b"original\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--create", "feat"], &work, &xdg).0, 0);
std::fs::write(work.join("a.txt"), b"main version\n").unwrap();
assert_eq!(run(&["commit", "-m", "main"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--switch", "feat"], &work, &xdg).0, 0);
std::fs::write(work.join("a.txt"), b"feat version\n").unwrap();
assert_eq!(run(&["commit", "-m", "feat"], &work, &xdg).0, 0);
assert_eq!(run(&["branch", "--switch", "main"], &work, &xdg).0, 0);
let _ = run(&["merge", "feat"], &work, &xdg);
let (code, o, _) = run(&["merge", "--explain"], &work, &xdg);
assert_eq!(code, 0);
assert!(o.contains("schema_version"));
assert!(o.contains("a.txt"));
}

View File

@ -0,0 +1,260 @@
//! End-to-end deploy/dial test (§7.3.6 — peer-to-peer transfer).
//!
//! Boots two `levcs` processes on loopback TCP — one runs `deploy`,
//! the other runs `dial` — and confirms:
//! 1. The handshake authenticates both sides against expected keys.
//! 2. The transferred archive reconstructs the same repo_id, branches,
//! and tree contents on the dialer side.
//! 3. The dialer rejects an impostor sender that signs with the wrong
//! Ed25519 key, even on the right host:port.
//! 4. The deployer refuses a dialer that authenticates with the wrong
//! key.
use std::io::{BufRead, BufReader};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
fn levcs_bin() -> String {
env!("CARGO_BIN_EXE_levcs").to_string()
}
fn run(args: &[&str], cwd: &Path, xdg: &Path) -> (i32, String, String) {
let out = Command::new(levcs_bin())
.args(args)
.current_dir(cwd)
.env("XDG_CONFIG_HOME", xdg)
.output()
.expect("run levcs");
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).to_string(),
String::from_utf8_lossy(&out.stderr).to_string(),
)
}
fn tempdir(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("{prefix}-{n}-{}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
fn pick_free_port() -> u16 {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
drop(l);
port
}
/// Read the public key for `label` from the given keychain. We invoke
/// `levcs key show` so the test exercises the same code path the user
/// would, and we do not have to crack open the keychain file format.
fn read_pub(label: &str, cwd: &Path, xdg: &Path) -> String {
let (code, out, err) = run(&["key", "show", label], cwd, xdg);
assert_eq!(code, 0, "key show {label}: {err}");
out.trim().to_string()
}
/// Create a source repository with one commit and return its path.
fn make_source_repo(name: &str, key_label: &str, xdg: &Path) -> PathBuf {
let src = tempdir(name);
let (code, _, e) = run(&["init", "--key", key_label], &src, xdg);
assert_eq!(code, 0, "init: {e}");
std::fs::write(src.join("README"), b"deploy/dial source content\n").unwrap();
std::fs::write(src.join("notes.md"), b"# notes\nan example\n").unwrap();
let (code, _, e) = run(&["track", "--all"], &src, xdg);
assert_eq!(code, 0, "track: {e}");
let (code, _, e) = run(&["commit", "-m", "initial"], &src, xdg);
assert_eq!(code, 0, "commit: {e}");
src
}
/// Spawn `levcs deploy` and wait for its first stderr line so we know
/// the listener is up before the dialer connects.
fn spawn_deployer(
repo: &Path,
xdg: &Path,
deployer_label: &str,
recipient_pub: &str,
listen: &str,
) -> std::process::Child {
let mut cmd = Command::new(levcs_bin());
cmd.args(&[
"deploy",
recipient_pub,
"--key",
deployer_label,
"--listen",
listen,
])
.current_dir(repo)
.env("XDG_CONFIG_HOME", xdg)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn deploy");
// Wait for the listener to print its first line — that tells us the
// bind succeeded so the dialer won't race the listener.
let stderr = child.stderr.take().expect("stderr");
let mut reader = BufReader::new(stderr);
let mut line = String::new();
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
line.clear();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
std::thread::sleep(Duration::from_millis(50));
continue;
}
if line.contains("listening on") {
// Re-attach the rest of stderr by spawning a drain thread
// so the child's stderr buffer never fills up.
std::thread::spawn(move || {
let mut sink = String::new();
let _ = reader.read_to_string(&mut sink);
});
return child;
}
}
panic!("deployer never reported `listening on` (last line: {line:?})");
}
use std::io::Read;
#[test]
fn deploy_and_dial_round_trip() {
let xdg = tempdir("levcs-p2p-xdg");
// Sender (alice) creates a repo with content. Init produces alice's
// key in the shared XDG keychain.
let source = make_source_repo("levcs-p2p-source", "alice", &xdg);
// Recipient (bob) gets a separate key in the same keychain.
let recv_parent = tempdir("levcs-p2p-recv-parent");
let (code, _, e) = run(&["key", "generate", "bob"], &recv_parent, &xdg);
assert_eq!(code, 0, "key generate bob: {e}");
let alice_pub = read_pub("alice", &source, &xdg);
let bob_pub = read_pub("bob", &recv_parent, &xdg);
// Sender starts listening, gated on bob's key.
let port = pick_free_port();
let listen = format!("127.0.0.1:{port}");
let deployer = spawn_deployer(&source, &xdg, "alice", &bob_pub, &listen);
// Recipient dials in.
let dest = recv_parent.join("dialed");
let (code, _, e) = run(
&[
"dial",
&listen,
&alice_pub,
"--key",
"bob",
dest.to_str().unwrap(),
],
&recv_parent,
&xdg,
);
assert_eq!(code, 0, "dial: {e}");
let _ = deployer.wait_with_output();
// Verify: dialed repo exists and content matches.
assert!(dest.is_dir(), "dialed dest not created");
assert_eq!(
std::fs::read_to_string(dest.join("README")).unwrap(),
"deploy/dial source content\n"
);
assert_eq!(
std::fs::read_to_string(dest.join("notes.md")).unwrap(),
"# notes\nan example\n"
);
// repo_id must match the source — deploy/dial preserves identity
// exactly (per §5.7 same-repo movement, this is the no-rewrite path).
let src_repo_id = std::fs::read_to_string(source.join(".levcs/refs/authority/genesis"))
.unwrap()
.trim()
.to_string();
let dst_repo_id = std::fs::read_to_string(dest.join(".levcs/refs/authority/genesis"))
.unwrap()
.trim()
.to_string();
assert_eq!(src_repo_id, dst_repo_id, "genesis authority must match");
// Branch tip must match.
let src_main = std::fs::read_to_string(source.join(".levcs/refs/branches/main"))
.unwrap()
.trim()
.to_string();
let dst_main = std::fs::read_to_string(dest.join(".levcs/refs/branches/main"))
.unwrap()
.trim()
.to_string();
assert_eq!(src_main, dst_main, "branch main tip must match");
// verify on the dialed repo passes — every signature and the full
// authority chain reconstruct correctly from what we received.
let (code, _, e) = run(&["verify"], &dest, &xdg);
assert_eq!(code, 0, "verify on dialed repo: {e}");
// Cleanup.
let _ = std::fs::remove_dir_all(&source);
let _ = std::fs::remove_dir_all(&recv_parent);
let _ = std::fs::remove_dir_all(&xdg);
}
#[test]
fn dial_rejects_wrong_sender_key() {
let xdg = tempdir("levcs-p2p-xdg-mismatch");
let source = make_source_repo("levcs-p2p-src-mismatch", "alice", &xdg);
let recv_parent = tempdir("levcs-p2p-recv-mismatch");
let (code, _, e) = run(&["key", "generate", "bob"], &recv_parent, &xdg);
assert_eq!(code, 0, "key generate bob: {e}");
// A third unrelated key — used as the *expected* sender on the dial
// side, even though alice is who's actually deploying.
let (code, _, e) = run(&["key", "generate", "mallory"], &recv_parent, &xdg);
assert_eq!(code, 0, "key generate mallory: {e}");
let bob_pub = read_pub("bob", &recv_parent, &xdg);
let mallory_pub = read_pub("mallory", &recv_parent, &xdg);
let port = pick_free_port();
let listen = format!("127.0.0.1:{port}");
let deployer = spawn_deployer(&source, &xdg, "alice", &bob_pub, &listen);
// Dial expecting `mallory` — alice's signature should not verify
// under mallory's key, so dial must abort with a non-zero exit.
let dest = recv_parent.join("dialed-bad");
let (code, _, err) = run(
&[
"dial",
&listen,
&mallory_pub,
"--key",
"bob",
dest.to_str().unwrap(),
],
&recv_parent,
&xdg,
);
assert_ne!(code, 0, "dial should have failed but didn't");
assert!(
err.contains("KeyMismatch")
|| err.contains("unexpected public key")
|| err.contains("BadSignature")
|| err.to_lowercase().contains("handshake"),
"expected handshake error in stderr, got: {err}"
);
assert!(!dest.exists(), "no repo should have been written");
let _ = deployer.wait_with_output();
let _ = std::fs::remove_dir_all(&source);
let _ = std::fs::remove_dir_all(&recv_parent);
let _ = std::fs::remove_dir_all(&xdg);
}

View File

@ -0,0 +1,110 @@
//! Tests covering polish on construct/status/diff.
use std::path::{Path, PathBuf};
use std::process::Command;
fn levcs_bin() -> String {
env!("CARGO_BIN_EXE_levcs").to_string()
}
fn run(args: &[&str], cwd: &Path, xdg: &Path) -> (i32, String, String) {
let out = Command::new(levcs_bin())
.args(args)
.current_dir(cwd)
.env("XDG_CONFIG_HOME", xdg)
.output()
.expect("run levcs");
(
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout).to_string(),
String::from_utf8_lossy(&out.stderr).to_string(),
)
}
fn tempdir(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("{prefix}-{n}-{}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
fn init_repo() -> (PathBuf, PathBuf) {
let work = tempdir("levcs-polish");
let xdg = work.join("cfg");
std::fs::create_dir_all(&xdg).unwrap();
assert_eq!(run(&["init", "--key", "alice"], &work, &xdg).0, 0);
(work, xdg)
}
#[test]
fn construct_restricted_to_paths_only_rewrites_those_files() {
let (work, xdg) = init_repo();
std::fs::write(work.join("a.txt"), b"a v1\n").unwrap();
std::fs::write(work.join("b.txt"), b"b v1\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "v1"], &work, &xdg).0, 0);
// Modify both files in the working tree.
std::fs::write(work.join("a.txt"), b"a dirty\n").unwrap();
std::fs::write(work.join("b.txt"), b"b dirty\n").unwrap();
// Restore only a.txt from HEAD.
let (code, _, e) = run(&["construct", "a.txt"], &work, &xdg);
assert_eq!(code, 0, "construct a.txt: {e}");
assert_eq!(std::fs::read_to_string(work.join("a.txt")).unwrap(), "a v1\n");
assert_eq!(std::fs::read_to_string(work.join("b.txt")).unwrap(), "b dirty\n");
}
#[test]
fn construct_release_default_uses_latest_release() {
let (work, xdg) = init_repo();
std::fs::write(work.join("a.txt"), b"v1\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "v1"], &work, &xdg).0, 0);
assert_eq!(run(&["release", "1.0.0"], &work, &xdg).0, 0);
// Add another commit after the release; do NOT release it.
std::fs::write(work.join("a.txt"), b"v2\n").unwrap();
assert_eq!(run(&["commit", "-m", "v2"], &work, &xdg).0, 0);
// Dirty the working tree, then construct --release: should restore to 1.0.0.
std::fs::write(work.join("a.txt"), b"dirty\n").unwrap();
let (code, _, e) = run(&["construct", "--release"], &work, &xdg);
assert_eq!(code, 0, "construct --release: {e}");
assert_eq!(std::fs::read_to_string(work.join("a.txt")).unwrap(), "v1\n");
}
#[test]
fn status_shows_release_when_one_exists() {
let (work, xdg) = init_repo();
std::fs::write(work.join("a.txt"), b"hello\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "first"], &work, &xdg).0, 0);
assert_eq!(run(&["release", "0.1.0"], &work, &xdg).0, 0);
let (code, o, _) = run(&["status"], &work, &xdg);
assert_eq!(code, 0);
assert!(o.contains("Release"));
assert!(o.contains("0.1.0"));
}
#[test]
fn diff_restricted_to_paths_skips_other_changes() {
let (work, xdg) = init_repo();
std::fs::write(work.join("a.txt"), b"a base\n").unwrap();
std::fs::write(work.join("b.txt"), b"b base\n").unwrap();
assert_eq!(run(&["track", "--all"], &work, &xdg).0, 0);
assert_eq!(run(&["commit", "-m", "base"], &work, &xdg).0, 0);
std::fs::write(work.join("a.txt"), b"a edited\n").unwrap();
std::fs::write(work.join("b.txt"), b"b edited\n").unwrap();
let (code, o, _) = run(&["diff", "a.txt"], &work, &xdg);
assert_eq!(code, 0);
assert!(o.contains("a/a.txt"));
assert!(!o.contains("a/b.txt"), "should not diff b.txt: {o}");
}

View File

@ -0,0 +1,16 @@
[package]
name = "levcs-client"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
levcs-core = { workspace = true }
levcs-identity = { workspace = true }
levcs-protocol = { workspace = true }
reqwest = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
hex = { workspace = true }
base64 = { workspace = true }

View File

@ -0,0 +1,180 @@
//! Client-side instance interaction. Wraps `reqwest::blocking::Client` with
//! request signing per §5.3 and provides typed methods for the §5.2
//! endpoints.
use std::time::Duration;
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use reqwest::blocking::Client as Http;
use reqwest::header::HeaderMap;
use thiserror::Error;
use levcs_core::ObjectId;
use levcs_identity::keys::SecretKey;
use levcs_protocol::auth::{sign_request, AuthRequest};
use levcs_protocol::wire::{InfoResponse, InstanceInfo, RefList};
use levcs_protocol::{Pack, PushManifest};
#[derive(Debug, Error)]
pub enum ClientError {
#[error("http: {0}")]
Http(#[from] reqwest::Error),
#[error("server returned {status}: {body}")]
Server { status: u16, body: String },
#[error("auth: {0}")]
Auth(String),
#[error("decode: {0}")]
Decode(String),
}
#[derive(Clone, Debug)]
pub struct Client {
base: String,
http: Http,
user_agent: String,
}
impl Client {
pub fn new(base: impl Into<String>) -> Self {
Self {
base: base.into().trim_end_matches('/').to_string(),
http: Http::builder()
.timeout(Duration::from_secs(60))
.build()
.expect("build reqwest client"),
user_agent: "levcs-client/0.1.0".into(),
}
}
pub fn instance_info(&self) -> Result<InstanceInfo, ClientError> {
let url = format!("{}/instance/info", self.base);
let res = self.http.get(&url).header("user-agent", &self.user_agent).send()?;
check(res)?.json::<InstanceInfo>().map_err(Into::into)
}
pub fn repo_info(&self, repo_id: &str) -> Result<InfoResponse, ClientError> {
let url = format!("{}/repos/{repo_id}/info", self.base);
let res = self.http.get(&url).header("user-agent", &self.user_agent).send()?;
check(res)?.json::<InfoResponse>().map_err(Into::into)
}
pub fn refs(&self, repo_id: &str) -> Result<RefList, ClientError> {
let url = format!("{}/repos/{repo_id}/refs", self.base);
let res = self.http.get(&url).header("user-agent", &self.user_agent).send()?;
check(res)?.json::<RefList>().map_err(Into::into)
}
pub fn get_object(&self, repo_id: &str, id: ObjectId) -> Result<Vec<u8>, ClientError> {
let url = format!("{}/repos/{repo_id}/objects/{}", self.base, id.to_hex());
let res = self.http.get(&url).header("user-agent", &self.user_agent).send()?;
let res = check(res)?;
Ok(res.bytes()?.to_vec())
}
pub fn get_pack(
&self,
repo_id: &str,
have: &[ObjectId],
want: &[ObjectId],
) -> Result<Pack, ClientError> {
let have_q: Vec<String> = have.iter().map(|h| h.to_hex()).collect();
let want_q: Vec<String> = want.iter().map(|h| h.to_hex()).collect();
let url = format!(
"{}/repos/{repo_id}/pack?have={}&want={}",
self.base,
have_q.join(","),
want_q.join(",")
);
let res = self.http.get(&url).header("user-agent", &self.user_agent).send()?;
let bytes = check(res)?.bytes()?;
Pack::decode(&bytes).map_err(|e| ClientError::Decode(e.to_string()))
}
pub fn push(
&self,
sk: &SecretKey,
repo_id: &str,
pack: &Pack,
manifest: &PushManifest,
) -> Result<(), ClientError> {
// Body: pack bytes followed by 4 bytes manifest length, manifest JSON,
// then manifest signature (64 bytes).
let pack_bytes = pack.encode();
let manifest_json = serde_json::to_vec(manifest).map_err(|e| ClientError::Decode(e.to_string()))?;
let mut body = Vec::with_capacity(pack_bytes.len() + 4 + manifest_json.len() + 64);
body.extend_from_slice(&pack_bytes);
body.extend_from_slice(&(manifest_json.len() as u32).to_le_bytes());
body.extend_from_slice(&manifest_json);
// Sign the manifest separately so the instance can verify it.
let manifest_sig = sk.sign(&manifest_json);
body.extend_from_slice(&manifest_sig);
let path = format!("/repos/{repo_id}/push");
let req = AuthRequest {
method: "POST",
path_with_query: &path,
body: &body,
};
let (key, ts, nonce, sig) = sign_request(sk, &req).map_err(|e| ClientError::Auth(e.to_string()))?;
let mut headers = HeaderMap::new();
headers.insert("LeVCS-Key", key.parse().unwrap());
headers.insert("LeVCS-Timestamp", ts.parse().unwrap());
headers.insert("LeVCS-Nonce", nonce.parse().unwrap());
headers.insert("LeVCS-Signature", sig.parse().unwrap());
headers.insert("Content-Type", "application/octet-stream".parse().unwrap());
// Useful for clients to advertise the public key that signed the manifest:
headers.insert(
"LeVCS-Manifest-Signature",
B64.encode(manifest_sig).parse().unwrap(),
);
let url = format!("{}{}", self.base, path);
let res = self.http.post(&url).headers(headers).body(body).send()?;
check(res)?;
Ok(())
}
pub fn init(
&self,
sk: &SecretKey,
repo_id: &str,
authority_object: &[u8],
) -> Result<(), ClientError> {
let path = format!("/repos/{repo_id}/init");
let req = AuthRequest {
method: "POST",
path_with_query: &path,
body: authority_object,
};
let (key, ts, nonce, sig) = sign_request(sk, &req).map_err(|e| ClientError::Auth(e.to_string()))?;
let mut headers = HeaderMap::new();
headers.insert("LeVCS-Key", key.parse().unwrap());
headers.insert("LeVCS-Timestamp", ts.parse().unwrap());
headers.insert("LeVCS-Nonce", nonce.parse().unwrap());
headers.insert("LeVCS-Signature", sig.parse().unwrap());
headers.insert("Content-Type", "application/octet-stream".parse().unwrap());
let url = format!("{}{}", self.base, path);
let res = self
.http
.post(&url)
.headers(headers)
.body(authority_object.to_vec())
.send()?;
check(res)?;
Ok(())
}
}
fn check(res: reqwest::blocking::Response) -> Result<reqwest::blocking::Response, ClientError> {
if res.status().is_success() {
Ok(res)
} else {
let status = res.status().as_u16();
let body = res.text().unwrap_or_default();
Err(ClientError::Server { status, body })
}
}

View File

@ -0,0 +1,30 @@
[package]
name = "levcs-core"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
blake3 = { workspace = true }
byteorder = { workspace = true }
hex = { workspace = true }
thiserror = { workspace = true }
serde = { workspace = true }
glob = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
criterion = { workspace = true }
[[bench]]
name = "object_hash"
harness = false
[[bench]]
name = "store_write"
harness = false
[[bench]]
name = "gc_walk"
harness = false

View File

@ -0,0 +1,123 @@
//! GC reachability-walk throughput.
//!
//! The walk is currently inlined in the CLI's `gc` command (see
//! `levcs-cli/src/repo_cmds.rs` near the `gc` fn). For benchmarking we
//! reproduce the same loop here against a synthetic store.
//!
//! Setup builds N blobs reachable through one tree under one commit,
//! writes them to a tempdir-backed store, then times the walk from
//! that single commit ID. Setup is heavy (10K loose-object writes hits
//! the disk hard); we let criterion amortize it across samples by
//! keeping the store across the bench's iterations — the walk is
//! read-only, so re-running it is harmless.
use std::collections::HashSet;
use std::path::PathBuf;
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
use levcs_core::hash::blake3_hash;
use levcs_core::object::ObjectType;
use levcs_core::{Blob, Commit, EntryType, FileMode, ObjectId, ObjectStore, Release, Tree, TreeEntry};
fn tempdir(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("{prefix}-{n}-{}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
/// Populate `store` with N unique blobs and a single tree referencing
/// them all. Returns the tree's ID — the seed for the walk.
///
/// We deliberately do NOT include a commit. Commit objects are
/// signed-frame types whose `RawObject::parse` requires a signature
/// trailer; producing one needs `levcs-identity`, which isn't a dep of
/// `levcs-core`. Since the walk's cost is dominated by the per-object
/// loop body (HashSet insert + read + parse), the shape Tree → N Blobs
/// captures everything we want to characterize.
fn populate(store: &ObjectStore, n_blobs: usize) -> ObjectId {
let mut tree = Tree::new();
for i in 0..n_blobs {
let body = format!("blob-{i:08}\n").into_bytes();
let blob = Blob::new(body);
let bytes = blob.serialize();
let id = blake3_hash(&bytes);
store.write_at(id, &bytes).unwrap();
tree.entries.push(TreeEntry {
name: format!("file_{i:08}.txt"),
entry_type: EntryType::Blob,
mode: FileMode::REGULAR,
hash: id,
});
}
tree.sort_and_validate().unwrap();
let tree_bytes = tree.serialize();
let tree_id = blake3_hash(&tree_bytes);
store.write_at(tree_id, &tree_bytes).unwrap();
tree_id
}
/// The walk loop — reproduces the body of `gc` in the CLI. Returns the
/// number of reachable IDs found, just to keep the optimizer honest.
fn walk_reachable(store: &ObjectStore, root: ObjectId) -> usize {
let mut reachable: HashSet<ObjectId> = HashSet::new();
let mut stack: Vec<ObjectId> = vec![root];
while let Some(id) = stack.pop() {
if !reachable.insert(id) {
continue;
}
if let Ok(raw) = store.read_object(id) {
match raw.object_type {
ObjectType::Tree => {
if let Ok(t) = 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.extend(c.parents);
}
}
ObjectType::Release => {
if let Ok(r) = Release::parse_body(&raw.body) {
stack.push(r.tree);
stack.push(r.predecessor);
}
}
ObjectType::Blob | ObjectType::Authority => {}
}
}
}
reachable.len()
}
fn bench_walk(c: &mut Criterion) {
let mut g = c.benchmark_group("gc_reachability_walk");
// Reduce sample count for the heavy 10K case — setup is already
// expensive and we don't need tight statistical bounds on a baseline.
g.sample_size(20);
for &n in &[1000usize, 10_000] {
let dir = tempdir(&format!("levcs-bench-gc-{n}"));
let store = ObjectStore::new(dir.clone());
store.ensure_dirs().unwrap();
let root = populate(&store, n);
g.bench_with_input(BenchmarkId::from_parameter(format!("{n}_objects")), &(), |b, _| {
b.iter(|| black_box(walk_reachable(&store, root)))
});
let _ = std::fs::remove_dir_all(dir);
}
g.finish();
}
criterion_group!(benches, bench_walk);
criterion_main!(benches);

View File

@ -0,0 +1,92 @@
//! Object serialize + BLAKE3 hash microbenchmarks.
//!
//! Every commit, push, and clone pays this cost — content addressing
//! depends on serialize→hash being fast. Three object types, three
//! sizes each.
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use levcs_core::hash::blake3_hash;
use levcs_core::{Blob, Commit, CommitFlags, EntryType, FileMode, ObjectId, Tree, TreeEntry};
fn lcg_bytes(seed: u64, n: usize) -> Vec<u8> {
let mut s = seed;
(0..n)
.map(|_| {
s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(s >> 33) as u8
})
.collect()
}
fn make_tree(n_entries: usize) -> Tree {
let mut t = Tree::new();
for i in 0..n_entries {
t.entries.push(TreeEntry {
name: format!("file_{i:04}.txt"),
entry_type: EntryType::Blob,
mode: FileMode::REGULAR,
hash: ObjectId([(i & 0xff) as u8; 32]),
});
}
t.sort_and_validate().unwrap();
t
}
fn make_commit(message_size: usize) -> Commit {
Commit {
tree: ObjectId([1; 32]),
parents: vec![ObjectId([2; 32])],
authority: ObjectId([3; 32]),
author_key: [4; 32],
timestamp_micros: 1_700_000_000_000_000,
flags: CommitFlags::NONE,
message: "x".repeat(message_size),
}
}
fn bench_blob(c: &mut Criterion) {
let mut g = c.benchmark_group("blob_serialize_hash");
for &(label, size) in &[
("1KiB", 1024usize),
("100KiB", 100 * 1024),
("1MiB", 1024 * 1024),
] {
let bytes = lcg_bytes(0x1234_5678, size);
g.throughput(Throughput::Bytes(size as u64));
g.bench_with_input(BenchmarkId::from_parameter(label), &bytes, |b, bytes| {
b.iter(|| {
let blob = Blob::new(bytes.clone());
black_box(blake3_hash(&blob.serialize()))
})
});
}
g.finish();
}
fn bench_tree(c: &mut Criterion) {
let mut g = c.benchmark_group("tree_serialize_hash");
for &n in &[10usize, 100, 1000] {
let t = make_tree(n);
g.bench_with_input(BenchmarkId::from_parameter(format!("{n}_entries")), &t, |b, t| {
b.iter(|| black_box(blake3_hash(&t.serialize())))
});
}
g.finish();
}
fn bench_commit(c: &mut Criterion) {
let mut g = c.benchmark_group("commit_serialize_hash");
for &(label, msg_size) in &[("short", 32usize), ("medium", 1024), ("long", 16 * 1024)] {
let commit = make_commit(msg_size);
g.bench_with_input(BenchmarkId::from_parameter(label), &commit, |b, commit| {
b.iter(|| {
let body = commit.body().unwrap();
black_box(blake3_hash(&body))
})
});
}
g.finish();
}
criterion_group!(benches, bench_blob, bench_tree, bench_commit);
criterion_main!(benches);

View File

@ -0,0 +1,80 @@
//! Object-store loose-write throughput.
//!
//! Tier 1 measured the pure-CPU cost of serialize+hash. This is the
//! disk-side bookend: temp file write → fsync → atomic rename, which
//! is what every received object pays on push.
//!
//! Each measured iteration writes a *unique* object (counter-keyed
//! body, so the BLAKE3 hash differs every time). Without that, the
//! second iteration would hit the `path.is_file()` short-circuit and
//! skip the actual write, giving a meaningless near-zero number.
//!
//! Result is filesystem-dependent: tmpfs / ext4 / btrfs / NVMe / spinning
//! disk all differ. The headline is "what does this machine give us"
//! — useful as a regression baseline, not a portable spec.
use std::cell::Cell;
use std::path::PathBuf;
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use levcs_core::object::{frame_unsigned, ObjectType};
use levcs_core::ObjectStore;
fn tempdir(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("{prefix}-{n}-{}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
/// Build a unique blob-framed body whose first 8 bytes are `counter`.
/// Different counter ⇒ different hash ⇒ a fresh write, not a no-op.
fn unique_blob(counter: u64, size: usize) -> Vec<u8> {
let mut body = Vec::with_capacity(size);
body.extend_from_slice(&counter.to_le_bytes());
body.resize(size, 0xab);
frame_unsigned(ObjectType::Blob, &body)
}
fn bench_write_raw(c: &mut Criterion) {
let mut g = c.benchmark_group("store_write_raw");
for &(label, size) in &[
("1KiB", 1024usize),
("100KiB", 100 * 1024),
("1MiB", 1024 * 1024),
] {
// Fresh store per size — keeps the per-shard directory population
// realistic (objects spread across 256 shards as the counter grows).
let dir = tempdir(&format!("levcs-bench-store-{label}"));
let store = ObjectStore::new(dir.clone());
store.ensure_dirs().unwrap();
// Use a Cell to advance the counter inside the closure without
// taking a `&mut`. Each criterion sample runs many iterations,
// so we burn through unique hashes quickly — at 100 KiB × 30
// samples × ~thousands-of-iters that's still well under 1M
// distinct objects, comfortably within tmp space.
let counter = Cell::new(0u64);
g.throughput(Throughput::Bytes(size as u64));
g.bench_with_input(BenchmarkId::from_parameter(label), &store, |b, store| {
b.iter(|| {
let n = counter.get();
counter.set(n + 1);
let bytes = unique_blob(n, size);
black_box(store.write_raw(&bytes).unwrap());
})
});
// Don't leave gigabytes in /tmp after the bench.
let _ = std::fs::remove_dir_all(dir);
}
g.finish();
}
criterion_group!(benches, bench_write_raw);
criterion_main!(benches);

View File

@ -0,0 +1,26 @@
//! Blob object: an immutable byte sequence representing the contents of a
//! single file. The blob body is the raw file contents byte-for-byte.
use crate::hash::{blake3_hash, ObjectId};
use crate::object::{frame_unsigned, ObjectType};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Blob {
pub bytes: Vec<u8>,
}
impl Blob {
pub fn new(bytes: Vec<u8>) -> Self { Self { bytes } }
pub fn serialize(&self) -> Vec<u8> {
frame_unsigned(ObjectType::Blob, &self.bytes)
}
pub fn object_id(&self) -> ObjectId {
blake3_hash(&self.serialize())
}
pub fn from_body(body: Vec<u8>) -> Self {
Self { bytes: body }
}
}

View File

@ -0,0 +1,195 @@
//! Commit object body, per the v1.1 trust-root revision §2.2.
//!
//! Field Type Description
//! tree 32 bytes Hash of the root tree
//! parent_count 1 byte
//! parents 32*N bytes
//! authority 32 bytes Hash of the authority object in effect
//! author_key 32 bytes Public key (Ed25519) of the author
//! timestamp 8 bytes Unix microseconds, LE int64
//! flags 1 byte Bit 0: modifies authority
//! Bit 1: fork commit
//! message_len 4 bytes
//! message N bytes UTF-8
use byteorder::{ByteOrder, LittleEndian};
use crate::error::Error;
use crate::hash::ObjectId;
use crate::object::{ObjectType, SignatureEntry, SignedObject};
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct CommitFlags(pub u8);
impl CommitFlags {
pub const NONE: CommitFlags = CommitFlags(0);
pub const MODIFIES_AUTHORITY: CommitFlags = CommitFlags(0b01);
pub const FORK: CommitFlags = CommitFlags(0b10);
pub fn modifies_authority(self) -> bool { self.0 & 0b01 != 0 }
pub fn is_fork(self) -> bool { self.0 & 0b10 != 0 }
pub fn raw(self) -> u8 { self.0 }
pub fn validate(self) -> Result<(), Error> {
if self.0 & !0b11 != 0 {
return Err(Error::MalformedObject(format!(
"commit flags has reserved bits set: {:#x}", self.0
)));
}
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct Commit {
pub tree: ObjectId,
pub parents: Vec<ObjectId>,
pub authority: ObjectId,
pub author_key: [u8; 32],
pub timestamp_micros: i64,
pub flags: CommitFlags,
pub message: String,
}
impl Commit {
pub fn body(&self) -> Result<Vec<u8>, Error> {
if self.parents.len() > 255 {
return Err(Error::MalformedObject("too many parents".into()));
}
if self.message.len() > u32::MAX as usize {
return Err(Error::MalformedObject("message too large".into()));
}
self.flags.validate()?;
let mut out = Vec::with_capacity(
32 + 1 + self.parents.len() * 32 + 32 + 32 + 8 + 1 + 4 + self.message.len(),
);
out.extend_from_slice(self.tree.as_bytes());
out.push(self.parents.len() as u8);
for p in &self.parents {
out.extend_from_slice(p.as_bytes());
}
out.extend_from_slice(self.authority.as_bytes());
out.extend_from_slice(&self.author_key);
let mut ts = [0u8; 8];
LittleEndian::write_i64(&mut ts, self.timestamp_micros);
out.extend_from_slice(&ts);
out.push(self.flags.0);
let mut len = [0u8; 4];
LittleEndian::write_u32(&mut len, self.message.len() as u32);
out.extend_from_slice(&len);
out.extend_from_slice(self.message.as_bytes());
Ok(out)
}
pub fn parse_body(body: &[u8]) -> Result<Self, Error> {
let need = 32 + 1;
if body.len() < need {
return Err(Error::MalformedObject("commit body too short for tree+parent_count".into()));
}
let mut tree = [0u8; 32];
tree.copy_from_slice(&body[0..32]);
let parent_count = body[32] as usize;
let parents_end = 33 + parent_count * 32;
if body.len() < parents_end + 32 + 32 + 8 + 1 + 4 {
return Err(Error::MalformedObject("commit body truncated".into()));
}
let mut parents = Vec::with_capacity(parent_count);
for i in 0..parent_count {
let off = 33 + i * 32;
let mut p = [0u8; 32];
p.copy_from_slice(&body[off..off + 32]);
parents.push(ObjectId(p));
}
let mut p = parents_end;
let mut authority = [0u8; 32];
authority.copy_from_slice(&body[p..p + 32]);
p += 32;
let mut author_key = [0u8; 32];
author_key.copy_from_slice(&body[p..p + 32]);
p += 32;
let timestamp_micros = LittleEndian::read_i64(&body[p..p + 8]);
p += 8;
let flags = CommitFlags(body[p]);
flags.validate()?;
p += 1;
let msg_len = LittleEndian::read_u32(&body[p..p + 4]) as usize;
p += 4;
if body.len() < p + msg_len {
return Err(Error::MalformedObject("commit message truncated".into()));
}
let message = std::str::from_utf8(&body[p..p + msg_len])
.map_err(|_| Error::MalformedObject("commit message not UTF-8".into()))?
.to_string();
p += msg_len;
if p != body.len() {
return Err(Error::MalformedObject(format!(
"trailing {} byte(s) after commit message", body.len() - p
)));
}
Ok(Self {
tree: ObjectId(tree),
parents,
authority: ObjectId(authority),
author_key,
timestamp_micros,
flags,
message,
})
}
pub fn into_signed(self) -> Result<SignedObject, Error> {
Ok(SignedObject::new(ObjectType::Commit, self.body()?))
}
/// Parse from a complete signed-object's components.
pub fn from_signed(s: &SignedObject) -> Result<Self, Error> {
if s.object_type != ObjectType::Commit {
return Err(Error::MalformedObject(format!(
"expected commit, got {}", s.object_type.name()
)));
}
if s.signatures.len() != 1 {
return Err(Error::MalformedObject(format!(
"commit must have exactly 1 signature, got {}", s.signatures.len()
)));
}
let c = Commit::parse_body(&s.body)?;
if c.author_key != s.signatures[0].public_key {
return Err(Error::MalformedObject(
"commit author_key disagrees with trailer signature key".into(),
));
}
Ok(c)
}
/// Convenience: produce a partial signature entry with just the key set;
/// callers fill in `signature` after computing the Ed25519 signature.
pub fn signature_template(&self) -> SignatureEntry {
SignatureEntry { public_key: self.author_key, signature: [0u8; 64] }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn commit_body_roundtrip() {
let c = Commit {
tree: ObjectId([1; 32]),
parents: vec![ObjectId([2; 32]), ObjectId([3; 32])],
authority: ObjectId([4; 32]),
author_key: [5; 32],
timestamp_micros: 1_700_000_000_000_000,
flags: CommitFlags::NONE,
message: "hello world".into(),
};
let body = c.body().unwrap();
let c2 = Commit::parse_body(&body).unwrap();
assert_eq!(c.tree, c2.tree);
assert_eq!(c.parents, c2.parents);
assert_eq!(c.message, c2.message);
assert_eq!(c.flags.0, c2.flags.0);
}
}

View File

@ -0,0 +1,75 @@
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("io error at {path:?}: {source}")]
Io {
path: Option<PathBuf>,
#[source]
source: std::io::Error,
},
#[error("malformed object: {0}")]
MalformedObject(String),
#[error("unsupported object format version: {0}")]
UnsupportedFormatVersion(u8),
#[error("unknown object type: {0}")]
UnknownObjectType(u8),
#[error("object hash mismatch: expected {expected}, got {actual}")]
HashMismatch { expected: String, actual: String },
#[error("object not found: {0}")]
NotFound(String),
#[error("invalid signature trailer")]
InvalidSignatureTrailer,
#[error("invalid hex: {0}")]
InvalidHex(String),
#[error("invalid path component: {0}")]
InvalidPath(String),
#[error("not a levcs repository (or any of the parent directories)")]
NotARepository,
#[error("repository already exists at {0:?}")]
RepositoryExists(PathBuf),
#[error("invalid reference: {0}")]
InvalidReference(String),
#[error("invalid index file: {0}")]
InvalidIndex(String),
#[error("{0}")]
Other(String),
}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Error::Io { path: None, source: e }
}
}
impl From<hex::FromHexError> for Error {
fn from(e: hex::FromHexError) -> Self {
Error::InvalidHex(e.to_string())
}
}
pub type Result<T> = std::result::Result<T, Error>;
pub trait IoExt<T> {
fn ctx(self, path: impl Into<PathBuf>) -> Result<T>;
}
impl<T> IoExt<T> for std::result::Result<T, std::io::Error> {
fn ctx(self, path: impl Into<PathBuf>) -> Result<T> {
self.map_err(|e| Error::Io { path: Some(path.into()), source: e })
}
}

View File

@ -0,0 +1,74 @@
use std::fmt;
use std::str::FromStr;
use crate::error::Error;
/// 32-byte BLAKE3 content-address.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Default, PartialOrd, Ord)]
pub struct ObjectId(pub [u8; 32]);
pub const ZERO_ID: ObjectId = ObjectId([0u8; 32]);
impl ObjectId {
pub const fn from_bytes(b: [u8; 32]) -> Self { Self(b) }
pub fn as_bytes(&self) -> &[u8; 32] { &self.0 }
pub fn to_hex(&self) -> String { hex::encode(self.0) }
pub fn is_zero(&self) -> bool { self.0 == [0u8; 32] }
pub fn from_hex(s: &str) -> Result<Self, Error> {
let bytes = hex::decode(s)?;
if bytes.len() != 32 {
return Err(Error::InvalidHex(format!(
"expected 32 bytes (64 hex chars), got {}",
bytes.len()
)));
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Ok(ObjectId(arr))
}
}
impl fmt::Debug for ObjectId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ObjectId({})", self.to_hex())
}
}
impl fmt::Display for ObjectId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_hex())
}
}
impl FromStr for ObjectId {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Error> { Self::from_hex(s) }
}
/// Compute a BLAKE3 hash with no key, returning an `ObjectId`.
pub fn blake3_hash(data: &[u8]) -> ObjectId {
let h = blake3::hash(data);
ObjectId(*h.as_bytes())
}
/// Streaming BLAKE3 hasher for incremental hashing.
pub struct Hasher(blake3::Hasher);
impl Hasher {
pub fn new() -> Self { Self(blake3::Hasher::new()) }
pub fn update(&mut self, data: &[u8]) -> &mut Self {
self.0.update(data);
self
}
pub fn finalize(self) -> ObjectId {
ObjectId(*self.0.finalize().as_bytes())
}
}
impl Default for Hasher {
fn default() -> Self { Self::new() }
}

View File

@ -0,0 +1,104 @@
//! Minimal `.levcsignore` support: a list of glob patterns, evaluated against
//! repository-relative paths. Patterns starting with `/` are anchored to the
//! repo root; bare patterns match anywhere. Patterns prefixed with `!` are
//! negations (re-include).
use std::path::Path;
use glob::Pattern;
#[derive(Clone, Debug, Default)]
pub struct Ignore {
rules: Vec<Rule>,
}
#[derive(Clone, Debug)]
struct Rule {
pattern: Pattern,
negate: bool,
/// Anchored to repo root (true) or matches any directory (false).
anchored: bool,
}
impl Ignore {
pub fn empty() -> Self { Self::default() }
/// Parse a `.levcsignore` file's contents.
pub fn parse(text: &str) -> Self {
let mut rules = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let (negate, body) = if let Some(rest) = trimmed.strip_prefix('!') {
(true, rest)
} else {
(false, trimmed)
};
let (anchored, body) = if let Some(rest) = body.strip_prefix('/') {
(true, rest)
} else {
(false, body)
};
// Always include `.levcs/` itself in the ignored set.
if let Ok(pattern) = Pattern::new(body) {
rules.push(Rule { pattern, negate, anchored });
}
}
// Always ignore `.levcs/`
if let Ok(pattern) = Pattern::new(".levcs") {
rules.insert(0, Rule { pattern, negate: false, anchored: true });
}
if let Ok(pattern) = Pattern::new(".levcs/**") {
rules.insert(0, Rule { pattern, negate: false, anchored: true });
}
Self { rules }
}
pub fn is_ignored(&self, rel_path: &str) -> bool {
let mut ignored = false;
for r in &self.rules {
let matched = if r.anchored {
r.pattern.matches(rel_path)
} else {
// Match against any suffix path component sequence.
r.pattern.matches(rel_path)
|| rel_path.split('/').any(|c| r.pattern.matches(c))
};
if matched {
ignored = !r.negate;
}
}
ignored
}
}
/// Always-ignored paths regardless of `.levcsignore`.
pub fn always_ignored(rel: &Path) -> bool {
rel.components()
.next()
.map(|c| c.as_os_str() == ".levcs")
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dotlevcs_always_ignored() {
let ig = Ignore::empty();
assert!(always_ignored(Path::new(".levcs")));
assert!(always_ignored(Path::new(".levcs/objects")));
assert!(!always_ignored(Path::new("src/main.rs")));
let _ = ig;
}
#[test]
fn negation() {
let ig = Ignore::parse("*.log\n!keep.log\n");
assert!(ig.is_ignored("a.log"));
assert!(!ig.is_ignored("keep.log"));
}
}

View File

@ -0,0 +1,215 @@
//! Index file at `.levcs/index`. Format from §2.5.
//!
//! magic 4 bytes "LVIX"
//! version 4 bytes format version (1)
//! entry_count 4 bytes LE uint32
//! entries variable sequence of index entries
//!
//! Each entry:
//! path_len 2 bytes
//! path N bytes (UTF-8, /-separated, repository-relative)
//! blob_hash 32 bytes
//! mode 1 byte
//! flags 1 byte (bit 0 tracked, bit 1 cached, bit 2 conflicted)
//! mtime 8 bytes i64 LE microseconds since UNIX epoch
//! size 8 bytes u64 LE
use std::fs;
use std::path::PathBuf;
use byteorder::{ByteOrder, LittleEndian};
use crate::error::{Error, IoExt, Result};
use crate::hash::ObjectId;
pub const INDEX_MAGIC: [u8; 4] = *b"LVIX";
pub const INDEX_VERSION: u32 = 1;
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct IndexEntryFlags(pub u8);
impl IndexEntryFlags {
pub const TRACKED: IndexEntryFlags = IndexEntryFlags(0b001);
pub const CACHED: IndexEntryFlags = IndexEntryFlags(0b010);
pub const CONFLICTED: IndexEntryFlags = IndexEntryFlags(0b100);
pub fn is_tracked(self) -> bool { self.0 & 0b001 != 0 }
pub fn is_cached(self) -> bool { self.0 & 0b010 != 0 }
pub fn is_conflicted(self) -> bool { self.0 & 0b100 != 0 }
pub fn with(self, mask: IndexEntryFlags) -> IndexEntryFlags {
IndexEntryFlags(self.0 | mask.0)
}
pub fn without(self, mask: IndexEntryFlags) -> IndexEntryFlags {
IndexEntryFlags(self.0 & !mask.0)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IndexEntry {
pub path: String,
pub blob_hash: ObjectId,
pub mode: u8,
pub flags: IndexEntryFlags,
pub mtime_micros: i64,
pub size: u64,
}
#[derive(Clone, Debug, Default)]
pub struct Index {
pub entries: Vec<IndexEntry>,
}
impl Index {
pub fn new() -> Self { Self::default() }
pub fn find(&self, path: &str) -> Option<&IndexEntry> {
self.entries.iter().find(|e| e.path == path)
}
pub fn find_mut(&mut self, path: &str) -> Option<&mut IndexEntry> {
self.entries.iter_mut().find(|e| e.path == path)
}
pub fn upsert(&mut self, entry: IndexEntry) {
if let Some(slot) = self.find_mut(&entry.path) {
*slot = entry;
} else {
self.entries.push(entry);
}
self.entries.sort_by(|a, b| a.path.cmp(&b.path));
}
pub fn remove(&mut self, path: &str) -> bool {
if let Some(i) = self.entries.iter().position(|e| e.path == path) {
self.entries.remove(i);
true
} else {
false
}
}
pub fn serialize(&self) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&INDEX_MAGIC);
let mut v = [0u8; 4];
LittleEndian::write_u32(&mut v, INDEX_VERSION);
out.extend_from_slice(&v);
let mut c = [0u8; 4];
LittleEndian::write_u32(&mut c, self.entries.len() as u32);
out.extend_from_slice(&c);
for e in &self.entries {
let path_bytes = e.path.as_bytes();
let mut pl = [0u8; 2];
LittleEndian::write_u16(&mut pl, path_bytes.len() as u16);
out.extend_from_slice(&pl);
out.extend_from_slice(path_bytes);
out.extend_from_slice(e.blob_hash.as_bytes());
out.push(e.mode);
out.push(e.flags.0);
let mut mt = [0u8; 8];
LittleEndian::write_i64(&mut mt, e.mtime_micros);
out.extend_from_slice(&mt);
let mut sz = [0u8; 8];
LittleEndian::write_u64(&mut sz, e.size);
out.extend_from_slice(&sz);
}
out
}
pub fn parse(bytes: &[u8]) -> Result<Self> {
if bytes.len() < 12 {
return Err(Error::InvalidIndex("index file too short".into()));
}
if &bytes[0..4] != INDEX_MAGIC.as_ref() {
return Err(Error::InvalidIndex("bad magic".into()));
}
let version = LittleEndian::read_u32(&bytes[4..8]);
if version != INDEX_VERSION {
return Err(Error::InvalidIndex(format!("unsupported version {version}")));
}
let count = LittleEndian::read_u32(&bytes[8..12]) as usize;
let mut entries = Vec::with_capacity(count);
let mut p = 12usize;
for _ in 0..count {
if bytes.len() < p + 2 {
return Err(Error::InvalidIndex("entry truncated".into()));
}
let pl = LittleEndian::read_u16(&bytes[p..p + 2]) as usize;
p += 2;
if bytes.len() < p + pl + 32 + 1 + 1 + 8 + 8 {
return Err(Error::InvalidIndex("entry truncated".into()));
}
let path = std::str::from_utf8(&bytes[p..p + pl])
.map_err(|_| Error::InvalidIndex("path not UTF-8".into()))?
.to_string();
p += pl;
let mut h = [0u8; 32];
h.copy_from_slice(&bytes[p..p + 32]);
p += 32;
let mode = bytes[p];
p += 1;
let flags = IndexEntryFlags(bytes[p]);
p += 1;
let mtime_micros = LittleEndian::read_i64(&bytes[p..p + 8]);
p += 8;
let size = LittleEndian::read_u64(&bytes[p..p + 8]);
p += 8;
entries.push(IndexEntry {
path, blob_hash: ObjectId(h), mode, flags, mtime_micros, size,
});
}
if p != bytes.len() {
return Err(Error::InvalidIndex("trailing bytes after entries".into()));
}
Ok(Self { entries })
}
pub fn read_from(path: &PathBuf) -> Result<Self> {
match fs::read(path) {
Ok(bytes) => Index::parse(&bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Index::new()),
Err(e) => Err(Error::Io { path: Some(path.clone()), source: e }),
}
}
pub fn write_to(&self, path: &PathBuf) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).ctx(parent.to_path_buf())?;
}
let tmp = path.with_extension("tmp");
fs::write(&tmp, self.serialize()).ctx(tmp.clone())?;
fs::rename(&tmp, path).ctx(path.clone())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn index_roundtrip() {
let mut idx = Index::new();
idx.upsert(IndexEntry {
path: "src/main.rs".into(),
blob_hash: ObjectId([1; 32]),
mode: 0,
flags: IndexEntryFlags::TRACKED,
mtime_micros: 1234,
size: 99,
});
idx.upsert(IndexEntry {
path: "README".into(),
blob_hash: ObjectId([2; 32]),
mode: 0,
flags: IndexEntryFlags::TRACKED,
mtime_micros: 4321,
size: 1,
});
let bytes = idx.serialize();
let idx2 = Index::parse(&bytes).unwrap();
assert_eq!(idx.entries, idx2.entries);
}
}

View File

@ -0,0 +1,31 @@
//! levcs-core: object model, hashing, and content-addressed object store
//! for the LeVCS specification (v1.1 trust-root revision).
pub mod error;
pub mod hash;
pub mod object;
pub mod blob;
pub mod tree;
pub mod commit;
pub mod release;
pub mod store;
pub mod refs;
pub mod index;
pub mod repo;
pub mod ignore;
pub mod release_cache;
pub use error::{Error, Result};
pub use hash::{ObjectId, ZERO_ID, blake3_hash};
pub use object::{
ObjectType, ObjectHeader, SignatureEntry, SignedObject, RawObject,
HEADER_SIZE, MAGIC, FORMAT_VERSION, SIGNATURE_ENTRY_SIZE,
};
pub use blob::Blob;
pub use tree::{Tree, TreeEntry, EntryType, FileMode};
pub use commit::{Commit, CommitFlags};
pub use release::Release;
pub use store::ObjectStore;
pub use refs::Refs;
pub use index::{Index, IndexEntry, IndexEntryFlags};
pub use repo::Repository;

View File

@ -0,0 +1,374 @@
//! Generic LeVCS on-disk object framing.
//!
//! Per the v1.1 trust-root revision §2.1, every object on disk is laid out as:
//!
//! ```text
//! Offset Size Field
//! 0 4 Magic: "LVCS"
//! 4 1 Object type
//! 5 1 Format version (1)
//! 6 2 Reserved (zero)
//! 8 8 Body length (LE uint64)
//! 16 body_len Body
//! 16+body_len 1 Signature count (uint8) -- signed objects only
//! 17+body_len 96*N Signature entries -- signed objects only
//! ```
//!
//! Blobs and trees are *not* signed objects (§2.1 lists Commit, Release, and
//! Authority as signed object types) and have no trailer. For uniformity the
//! parser exposes both header and trailer; the trailer is always zero-length
//! for blobs and trees.
use byteorder::{ByteOrder, LittleEndian};
use crate::error::Error;
use crate::hash::{blake3_hash, ObjectId};
pub const MAGIC: [u8; 4] = *b"LVCS";
pub const FORMAT_VERSION: u8 = 1;
pub const HEADER_SIZE: usize = 16;
pub const SIGNATURE_ENTRY_SIZE: usize = 96;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum ObjectType {
Blob = 1,
Tree = 2,
Commit = 3,
Release = 4,
Authority = 5,
}
impl ObjectType {
pub fn from_u8(b: u8) -> Result<Self, Error> {
Ok(match b {
1 => Self::Blob,
2 => Self::Tree,
3 => Self::Commit,
4 => Self::Release,
5 => Self::Authority,
n => return Err(Error::UnknownObjectType(n)),
})
}
pub fn is_signed(self) -> bool {
matches!(self, Self::Commit | Self::Release | Self::Authority)
}
pub fn name(self) -> &'static str {
match self {
Self::Blob => "blob",
Self::Tree => "tree",
Self::Commit => "commit",
Self::Release => "release",
Self::Authority => "authority",
}
}
}
/// Decoded fixed-size object header.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct ObjectHeader {
pub object_type: ObjectType,
pub format_version: u8,
pub body_len: u64,
}
impl ObjectHeader {
pub fn encode(&self) -> [u8; HEADER_SIZE] {
let mut buf = [0u8; HEADER_SIZE];
buf[0..4].copy_from_slice(&MAGIC);
buf[4] = self.object_type as u8;
buf[5] = self.format_version;
// bytes 6..8 reserved (zero)
LittleEndian::write_u64(&mut buf[8..16], self.body_len);
buf
}
pub fn decode(bytes: &[u8]) -> Result<Self, Error> {
if bytes.len() < HEADER_SIZE {
return Err(Error::MalformedObject(format!(
"header truncated: got {} bytes, need {}",
bytes.len(),
HEADER_SIZE
)));
}
if &bytes[0..4] != MAGIC.as_ref() {
return Err(Error::MalformedObject("bad magic".into()));
}
let object_type = ObjectType::from_u8(bytes[4])?;
let format_version = bytes[5];
if format_version != FORMAT_VERSION {
return Err(Error::UnsupportedFormatVersion(format_version));
}
if bytes[6] != 0 || bytes[7] != 0 {
return Err(Error::MalformedObject("reserved bytes nonzero".into()));
}
let body_len = LittleEndian::read_u64(&bytes[8..16]);
Ok(Self { object_type, format_version, body_len })
}
}
/// One entry in a signature trailer.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct SignatureEntry {
pub public_key: [u8; 32],
pub signature: [u8; 64],
}
impl SignatureEntry {
pub fn encode(&self) -> [u8; SIGNATURE_ENTRY_SIZE] {
let mut buf = [0u8; SIGNATURE_ENTRY_SIZE];
buf[0..32].copy_from_slice(&self.public_key);
buf[32..96].copy_from_slice(&self.signature);
buf
}
pub fn decode(bytes: &[u8]) -> Result<Self, Error> {
if bytes.len() < SIGNATURE_ENTRY_SIZE {
return Err(Error::InvalidSignatureTrailer);
}
let mut pk = [0u8; 32];
let mut sg = [0u8; 64];
pk.copy_from_slice(&bytes[0..32]);
sg.copy_from_slice(&bytes[32..96]);
Ok(Self { public_key: pk, signature: sg })
}
}
/// A `SignedObject` is an object whose body has been augmented with a
/// signature trailer. The signature is computed over `BLAKE3(header || body)`
/// and stored after the body. Object hashes cover the entire signed object.
#[derive(Clone, Debug)]
pub struct SignedObject {
pub object_type: ObjectType,
pub body: Vec<u8>,
pub signatures: Vec<SignatureEntry>,
}
impl SignedObject {
pub fn new(object_type: ObjectType, body: Vec<u8>) -> Self {
Self { object_type, body, signatures: Vec::new() }
}
/// The 32-byte hash that signers sign: BLAKE3(header || body).
pub fn signing_hash(&self) -> ObjectId {
let header = ObjectHeader {
object_type: self.object_type,
format_version: FORMAT_VERSION,
body_len: self.body.len() as u64,
}
.encode();
let mut hasher = blake3::Hasher::new();
hasher.update(&header);
hasher.update(&self.body);
ObjectId(*hasher.finalize().as_bytes())
}
/// Serialize to the on-disk representation including signature trailer.
pub fn serialize(&self) -> Vec<u8> {
let header = ObjectHeader {
object_type: self.object_type,
format_version: FORMAT_VERSION,
body_len: self.body.len() as u64,
}
.encode();
let n = self.signatures.len();
assert!(n <= 255, "too many signatures");
let mut out = Vec::with_capacity(HEADER_SIZE + self.body.len() + 1 + n * SIGNATURE_ENTRY_SIZE);
out.extend_from_slice(&header);
out.extend_from_slice(&self.body);
out.push(n as u8);
for s in &self.signatures {
out.extend_from_slice(&s.encode());
}
out
}
/// Content hash (over the entire serialized object).
pub fn object_id(&self) -> ObjectId {
blake3_hash(&self.serialize())
}
/// Parse a signed object from its on-disk bytes.
pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
let header = ObjectHeader::decode(bytes)?;
if !header.object_type.is_signed() {
return Err(Error::MalformedObject(format!(
"object type {} is not a signed object",
header.object_type.name()
)));
}
// Bounds-check every offset arithmetic step. A hostile peer can
// set body_len or the trailer count to values that, when cast to
// usize and summed, overflow — turning what should be a graceful
// "body truncated" error into a panic. Use `checked_*` throughout.
let body_start = HEADER_SIZE;
let body_len = usize::try_from(header.body_len)
.map_err(|_| Error::MalformedObject("body_len exceeds usize".into()))?;
let body_end = body_start
.checked_add(body_len)
.ok_or_else(|| Error::MalformedObject("body offset overflow".into()))?;
if bytes.len() < body_end + 1 {
return Err(Error::MalformedObject("body truncated".into()));
}
let body = bytes[body_start..body_end].to_vec();
let count = bytes[body_end] as usize;
let trailer_start = body_end
.checked_add(1)
.ok_or_else(|| Error::MalformedObject("trailer offset overflow".into()))?;
let trailer_size = count
.checked_mul(SIGNATURE_ENTRY_SIZE)
.ok_or(Error::InvalidSignatureTrailer)?;
let trailer_end = trailer_start
.checked_add(trailer_size)
.ok_or(Error::InvalidSignatureTrailer)?;
if bytes.len() < trailer_end {
return Err(Error::InvalidSignatureTrailer);
}
let mut signatures = Vec::with_capacity(count);
for i in 0..count {
let off = trailer_start + i * SIGNATURE_ENTRY_SIZE;
signatures.push(SignatureEntry::decode(&bytes[off..off + SIGNATURE_ENTRY_SIZE])?);
}
if bytes.len() != trailer_end {
return Err(Error::MalformedObject(format!(
"trailing garbage after signed object: {} extra byte(s)",
bytes.len() - trailer_end
)));
}
Ok(Self {
object_type: header.object_type,
body,
signatures,
})
}
}
/// A `RawObject` is a parsed but un-typed object. Useful when reading an
/// object from disk and dispatching by type.
#[derive(Clone, Debug)]
pub struct RawObject {
pub object_type: ObjectType,
pub body: Vec<u8>,
pub signatures: Vec<SignatureEntry>,
}
impl RawObject {
pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
let header = ObjectHeader::decode(bytes)?;
// Same overflow concern as SignedObject::parse — see the note
// there. Same checked-arithmetic discipline applied here.
let body_start = HEADER_SIZE;
let body_len = usize::try_from(header.body_len)
.map_err(|_| Error::MalformedObject("body_len exceeds usize".into()))?;
let body_end = body_start
.checked_add(body_len)
.ok_or_else(|| Error::MalformedObject("body offset overflow".into()))?;
if bytes.len() < body_end {
return Err(Error::MalformedObject("body truncated".into()));
}
let body = bytes[body_start..body_end].to_vec();
let signatures = if header.object_type.is_signed() {
if bytes.len() < body_end + 1 {
return Err(Error::MalformedObject("missing signature trailer".into()));
}
let count = bytes[body_end] as usize;
let trailer_start = body_end
.checked_add(1)
.ok_or_else(|| Error::MalformedObject("trailer offset overflow".into()))?;
let trailer_size = count
.checked_mul(SIGNATURE_ENTRY_SIZE)
.ok_or(Error::InvalidSignatureTrailer)?;
let trailer_end = trailer_start
.checked_add(trailer_size)
.ok_or(Error::InvalidSignatureTrailer)?;
if bytes.len() < trailer_end {
return Err(Error::InvalidSignatureTrailer);
}
let mut sigs = Vec::with_capacity(count);
for i in 0..count {
let off = trailer_start + i * SIGNATURE_ENTRY_SIZE;
sigs.push(SignatureEntry::decode(&bytes[off..off + SIGNATURE_ENTRY_SIZE])?);
}
sigs
} else {
Vec::new()
};
Ok(Self { object_type: header.object_type, body, signatures })
}
/// Serialize a raw object (with empty trailer for unsigned types).
pub fn serialize(&self) -> Vec<u8> {
let header = ObjectHeader {
object_type: self.object_type,
format_version: FORMAT_VERSION,
body_len: self.body.len() as u64,
}
.encode();
let signed = self.object_type.is_signed();
let n = self.signatures.len();
let trailer_size = if signed { 1 + n * SIGNATURE_ENTRY_SIZE } else { 0 };
let mut out = Vec::with_capacity(HEADER_SIZE + self.body.len() + trailer_size);
out.extend_from_slice(&header);
out.extend_from_slice(&self.body);
if signed {
out.push(n as u8);
for s in &self.signatures {
out.extend_from_slice(&s.encode());
}
}
out
}
pub fn object_id(&self) -> ObjectId { blake3_hash(&self.serialize()) }
}
/// Helper used by unsigned object types (Blob, Tree) to wrap a body in the
/// fixed object framing.
pub fn frame_unsigned(object_type: ObjectType, body: &[u8]) -> Vec<u8> {
debug_assert!(!object_type.is_signed());
let header = ObjectHeader {
object_type,
format_version: FORMAT_VERSION,
body_len: body.len() as u64,
}
.encode();
let mut out = Vec::with_capacity(HEADER_SIZE + body.len());
out.extend_from_slice(&header);
out.extend_from_slice(body);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn header_roundtrip() {
let h = ObjectHeader { object_type: ObjectType::Blob, format_version: 1, body_len: 42 };
let bytes = h.encode();
let h2 = ObjectHeader::decode(&bytes).unwrap();
assert_eq!(h, h2);
}
#[test]
fn signed_object_roundtrip() {
let mut so = SignedObject::new(ObjectType::Commit, b"hello".to_vec());
so.signatures.push(SignatureEntry { public_key: [7u8; 32], signature: [9u8; 64] });
let bytes = so.serialize();
let so2 = SignedObject::parse(&bytes).unwrap();
assert_eq!(so.object_type, so2.object_type);
assert_eq!(so.body, so2.body);
assert_eq!(so.signatures, so2.signatures);
}
#[test]
fn unknown_type_rejected() {
let mut bytes = vec![0u8; HEADER_SIZE];
bytes[0..4].copy_from_slice(&MAGIC);
bytes[4] = 99;
bytes[5] = 1;
assert!(ObjectHeader::decode(&bytes).is_err());
}
}

View File

@ -0,0 +1,225 @@
//! References. Stored under `.levcs/refs/` as small text files containing one
//! hex hash and a trailing newline. `HEAD` is at the top level of `.levcs/`.
use std::fs;
use std::path::{Path, PathBuf};
use crate::error::{Error, IoExt, Result};
use crate::hash::ObjectId;
#[derive(Clone, Debug)]
pub struct Refs {
pub levcs_dir: PathBuf,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Head {
/// HEAD points at a branch (e.g., `refs/branches/main`).
Branch(String),
/// Detached HEAD pointing directly at a commit.
Detached(ObjectId),
}
impl Refs {
pub fn new(levcs_dir: impl Into<PathBuf>) -> Self {
Self { levcs_dir: levcs_dir.into() }
}
pub fn refs_dir(&self) -> PathBuf { self.levcs_dir.join("refs") }
pub fn head_path(&self) -> PathBuf { self.levcs_dir.join("HEAD") }
pub fn ref_path(&self, name: &str) -> Result<PathBuf> {
validate_ref_name(name)?;
Ok(self.levcs_dir.join(name))
}
pub fn read(&self, name: &str) -> Result<Option<ObjectId>> {
let path = self.ref_path(name)?;
match fs::read_to_string(&path) {
Ok(s) => Ok(Some(parse_ref_value(&s)?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(Error::Io { path: Some(path), source: e }),
}
}
pub fn write(&self, name: &str, id: ObjectId) -> Result<()> {
let path = self.ref_path(name)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).ctx(parent.to_path_buf())?;
}
atomic_write(&path, format!("{}\n", id.to_hex()).as_bytes())
}
pub fn delete(&self, name: &str) -> Result<()> {
let path = self.ref_path(name)?;
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(Error::Io { path: Some(path), source: e }),
}
}
pub fn read_head(&self) -> Result<Option<Head>> {
let path = self.head_path();
match fs::read_to_string(&path) {
Ok(s) => Ok(Some(parse_head(&s)?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(Error::Io { path: Some(path), source: e }),
}
}
pub fn write_head(&self, head: &Head) -> Result<()> {
let s = match head {
Head::Branch(name) => {
validate_ref_name(name)?;
format!("ref: {}\n", name)
}
Head::Detached(id) => format!("{}\n", id.to_hex()),
};
atomic_write(&self.head_path(), s.as_bytes())
}
/// Resolve HEAD to a commit hash, if any. None if HEAD points to a branch
/// that does not exist (i.e., empty repository).
pub fn resolve_head(&self) -> Result<Option<ObjectId>> {
match self.read_head()? {
None => Ok(None),
Some(Head::Detached(id)) => Ok(Some(id)),
Some(Head::Branch(name)) => self.read(&name),
}
}
/// List every ref under `refs/`. Returns `(name, id)` pairs.
pub fn list_all(&self) -> Result<Vec<(String, ObjectId)>> {
let mut out = Vec::new();
let dir = self.refs_dir();
if !dir.is_dir() {
return Ok(out);
}
walk(&dir, &dir, &mut out)?;
return Ok(out);
fn walk(base: &Path, dir: &Path, out: &mut Vec<(String, ObjectId)>) -> Result<()> {
for ent in fs::read_dir(dir).ctx(dir.to_path_buf())? {
let ent = ent.ctx(dir.to_path_buf())?;
let path = ent.path();
if path.is_dir() {
walk(base, &path, out)?;
} else {
let rel = path.strip_prefix(base.parent().unwrap()).unwrap();
let name = rel.to_string_lossy().replace('\\', "/").to_string();
let txt = fs::read_to_string(&path).ctx(path.clone())?;
if let Ok(id) = parse_ref_value(&txt) {
out.push((name, id));
}
}
}
Ok(())
}
}
pub fn list_branches(&self) -> Result<Vec<(String, ObjectId)>> {
let dir = self.refs_dir().join("branches");
let mut out = Vec::new();
if !dir.is_dir() { return Ok(out); }
for ent in fs::read_dir(&dir).ctx(dir.clone())? {
let ent = ent.ctx(dir.clone())?;
let name = ent.file_name().to_string_lossy().to_string();
let txt = fs::read_to_string(ent.path()).ctx(ent.path())?;
if let Ok(id) = parse_ref_value(&txt) {
out.push((name, id));
}
}
out.sort_by(|a, b| a.0.cmp(&b.0));
Ok(out)
}
pub fn list_releases(&self) -> Result<Vec<(String, ObjectId)>> {
let dir = self.refs_dir().join("releases");
let mut out = Vec::new();
if !dir.is_dir() { return Ok(out); }
for ent in fs::read_dir(&dir).ctx(dir.clone())? {
let ent = ent.ctx(dir.clone())?;
let name = ent.file_name().to_string_lossy().to_string();
let txt = fs::read_to_string(ent.path()).ctx(ent.path())?;
if let Ok(id) = parse_ref_value(&txt) {
out.push((name, id));
}
}
out.sort_by(|a, b| a.0.cmp(&b.0));
Ok(out)
}
}
pub fn validate_ref_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(Error::InvalidReference("empty".into()));
}
for comp in name.split('/') {
if comp.is_empty() {
return Err(Error::InvalidReference(format!("empty component in {name}")));
}
if comp == "." || comp == ".." {
return Err(Error::InvalidReference(format!("reserved component: {comp}")));
}
if comp.contains('\0') {
return Err(Error::InvalidReference("null byte".into()));
}
}
if name.contains("//") || name.starts_with('/') || name.ends_with('/') {
return Err(Error::InvalidReference(format!("malformed path: {name}")));
}
Ok(())
}
fn parse_ref_value(s: &str) -> Result<ObjectId> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err(Error::InvalidReference("empty ref body".into()));
}
ObjectId::from_hex(trimmed)
}
fn parse_head(s: &str) -> Result<Head> {
let trimmed = s.trim();
if let Some(rest) = trimmed.strip_prefix("ref:") {
let name = rest.trim();
validate_ref_name(name)?;
Ok(Head::Branch(name.to_string()))
} else {
Ok(Head::Detached(ObjectId::from_hex(trimmed)?))
}
}
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| Error::Other(format!("ref path has no parent: {path:?}")))?;
fs::create_dir_all(parent).ctx(parent.to_path_buf())?;
let tmp = parent.join(format!(
".tmp.{}",
path.file_name().unwrap().to_string_lossy()
));
fs::write(&tmp, bytes).ctx(tmp.clone())?;
fs::rename(&tmp, path).ctx(path.to_path_buf())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_names_rejected() {
for n in ["", ".", "..", "a/", "/a", "a//b", "a/.."] {
assert!(validate_ref_name(n).is_err(), "should reject: {n}");
}
}
#[test]
fn valid_names_accepted() {
for n in ["refs/branches/main", "refs/releases/v1.0", "refs/authority/current"] {
validate_ref_name(n).unwrap();
}
}
}

View File

@ -0,0 +1,152 @@
//! Release object body, per the v1.1 trust-root revision §2.3.
//!
//! Field Type Description
//! tree 32 bytes
//! parent_release 32 bytes (zero for first release)
//! predecessor 32 bytes hash of the commit being released
//! authority 32 bytes
//! declarer_key 32 bytes
//! timestamp 8 bytes Unix microseconds, LE int64
//! label_len 2 bytes uint16 LE, max 256
//! label N bytes UTF-8
//! notes_len 4 bytes uint32 LE
//! notes N bytes UTF-8
use byteorder::{ByteOrder, LittleEndian};
use crate::error::Error;
use crate::hash::ObjectId;
use crate::object::{ObjectType, SignedObject};
#[derive(Clone, Debug)]
pub struct Release {
pub tree: ObjectId,
pub parent_release: ObjectId,
pub predecessor: ObjectId,
pub authority: ObjectId,
pub declarer_key: [u8; 32],
pub timestamp_micros: i64,
pub label: String,
pub notes: String,
}
impl Release {
pub fn body(&self) -> Result<Vec<u8>, Error> {
if self.label.len() > 256 {
return Err(Error::MalformedObject("release label too long".into()));
}
if self.notes.len() > u32::MAX as usize {
return Err(Error::MalformedObject("release notes too large".into()));
}
let mut out = Vec::with_capacity(32 * 4 + 32 + 8 + 2 + self.label.len() + 4 + self.notes.len());
out.extend_from_slice(self.tree.as_bytes());
out.extend_from_slice(self.parent_release.as_bytes());
out.extend_from_slice(self.predecessor.as_bytes());
out.extend_from_slice(self.authority.as_bytes());
out.extend_from_slice(&self.declarer_key);
let mut ts = [0u8; 8];
LittleEndian::write_i64(&mut ts, self.timestamp_micros);
out.extend_from_slice(&ts);
let mut ll = [0u8; 2];
LittleEndian::write_u16(&mut ll, self.label.len() as u16);
out.extend_from_slice(&ll);
out.extend_from_slice(self.label.as_bytes());
let mut nl = [0u8; 4];
LittleEndian::write_u32(&mut nl, self.notes.len() as u32);
out.extend_from_slice(&nl);
out.extend_from_slice(self.notes.as_bytes());
Ok(out)
}
pub fn parse_body(body: &[u8]) -> Result<Self, Error> {
let min = 32 * 5 + 8 + 2;
if body.len() < min {
return Err(Error::MalformedObject("release body truncated".into()));
}
let mut p = 0usize;
let take32 = |p: &mut usize| -> [u8; 32] {
let mut h = [0u8; 32];
h.copy_from_slice(&body[*p..*p + 32]);
*p += 32;
h
};
let tree = ObjectId(take32(&mut p));
let parent_release = ObjectId(take32(&mut p));
let predecessor = ObjectId(take32(&mut p));
let authority = ObjectId(take32(&mut p));
let declarer_key = take32(&mut p);
let timestamp_micros = LittleEndian::read_i64(&body[p..p + 8]);
p += 8;
let label_len = LittleEndian::read_u16(&body[p..p + 2]) as usize;
p += 2;
if body.len() < p + label_len + 4 {
return Err(Error::MalformedObject("release label/notes truncated".into()));
}
let label = std::str::from_utf8(&body[p..p + label_len])
.map_err(|_| Error::MalformedObject("release label not UTF-8".into()))?
.to_string();
p += label_len;
let notes_len = LittleEndian::read_u32(&body[p..p + 4]) as usize;
p += 4;
if body.len() < p + notes_len {
return Err(Error::MalformedObject("release notes truncated".into()));
}
let notes = std::str::from_utf8(&body[p..p + notes_len])
.map_err(|_| Error::MalformedObject("release notes not UTF-8".into()))?
.to_string();
p += notes_len;
if p != body.len() {
return Err(Error::MalformedObject("trailing bytes after release notes".into()));
}
Ok(Self {
tree, parent_release, predecessor, authority, declarer_key,
timestamp_micros, label, notes,
})
}
pub fn into_signed(self) -> Result<SignedObject, Error> {
Ok(SignedObject::new(ObjectType::Release, self.body()?))
}
pub fn from_signed(s: &SignedObject) -> Result<Self, Error> {
if s.object_type != ObjectType::Release {
return Err(Error::MalformedObject(format!(
"expected release, got {}", s.object_type.name()
)));
}
if s.signatures.is_empty() {
return Err(Error::MalformedObject("release must have at least one signature".into()));
}
let r = Release::parse_body(&s.body)?;
if r.declarer_key != s.signatures[0].public_key {
return Err(Error::MalformedObject(
"release declarer_key disagrees with first signature key".into(),
));
}
Ok(r)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_body_roundtrip() {
let r = Release {
tree: ObjectId([1; 32]),
parent_release: ObjectId([0; 32]),
predecessor: ObjectId([2; 32]),
authority: ObjectId([3; 32]),
declarer_key: [4; 32],
timestamp_micros: 1_700_000_000_000_000,
label: "v0.1.0".into(),
notes: "first release".into(),
};
let body = r.body().unwrap();
let r2 = Release::parse_body(&body).unwrap();
assert_eq!(r.label, r2.label);
assert_eq!(r.notes, r2.notes);
assert_eq!(r.tree, r2.tree);
}
}

View File

@ -0,0 +1,254 @@
//! Cached releases (§4.4).
//!
//! A working repository may keep release objects (and their reachable
//! trees + blobs) in `.levcs/cache/releases/` to accelerate
//! `construct`, `diff`, and `merge` against released versions without
//! having to re-resolve everything from the loose object store. Each
//! cache entry is one file at `cache/releases/<release_hex>` whose
//! contents are the release object's raw bytes; LRU is decided by the
//! file's mtime.
//!
//! The cache is purely a performance hint — every entry is also
//! present in the loose object store. Eviction is therefore safe
//! without consulting reachability: a deleted cache file just makes
//! the next `construct --release` slightly slower.
use std::path::PathBuf;
use crate::error::{IoExt, Result};
use crate::hash::ObjectId;
use crate::repo::Repository;
/// Spec default cap of 1 GiB before LRU eviction kicks in.
pub const DEFAULT_CACHE_CAP_BYTES: u64 = 1024 * 1024 * 1024;
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct EvictReport {
pub evicted_files: usize,
pub evicted_bytes: u64,
/// Total bytes remaining after eviction.
pub remaining_bytes: u64,
}
fn cache_dir(repo: &Repository) -> PathBuf {
repo.levcs_dir.join("cache").join("releases")
}
/// Write a cache entry for `release_id`. The on-disk shape is one
/// flat file per release, named by the hex hash. Subsequent calls
/// for the same id touch the file's mtime so LRU sorts treat it as
/// "most recently used" — a side effect of the read path that
/// callers should remember.
pub fn cache_release(repo: &Repository, release_id: ObjectId) -> Result<()> {
let dir = cache_dir(repo);
std::fs::create_dir_all(&dir).ctx(dir.clone())?;
let bytes = repo.objects.read_raw(release_id)?;
let path = dir.join(release_id.to_hex());
// Atomic write so a Ctrl-C halfway through doesn't leave a
// half-written cache entry. The eviction path can also race here;
// worst case is that a sibling process re-evicts what we just
// wrote, which is fine.
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, &bytes).ctx(tmp.clone())?;
std::fs::rename(&tmp, &path).ctx(path)?;
Ok(())
}
/// Mark `release_id` as freshly accessed so it survives the next
/// LRU pass. Equivalent to `touch -m`. Returns Ok(()) silently if no
/// cache entry exists — call sites don't need to know whether a
/// release was previously cached.
pub fn touch(repo: &Repository, release_id: ObjectId) -> Result<()> {
let path = cache_dir(repo).join(release_id.to_hex());
if !path.exists() {
return Ok(());
}
// SystemTime::now() is fine on every supported platform; we don't
// need filetime crate granularity here.
let now = std::time::SystemTime::now();
let f = std::fs::File::options()
.write(true)
.open(&path)
.ctx(path.clone())?;
f.set_modified(now).ctx(path)?;
Ok(())
}
/// Walk every cache entry, sort by mtime ascending (oldest first),
/// and delete entries until the total size is at or below `cap`.
/// Files whose mtime can't be read are treated as the oldest — they
/// go first.
pub fn evict_to(repo: &Repository, cap: u64) -> Result<EvictReport> {
let dir = cache_dir(repo);
if !dir.is_dir() {
return Ok(EvictReport::default());
}
let mut entries: Vec<(PathBuf, u64, std::time::SystemTime)> = Vec::new();
let mut total: u64 = 0;
for ent in std::fs::read_dir(&dir).ctx(dir.clone())? {
let ent = ent.ctx(dir.clone())?;
let path = ent.path();
let meta = match ent.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if !meta.is_file() {
continue;
}
// Skip the in-flight `.tmp` files that `cache_release` writes;
// they belong to a concurrent caller and aren't ours to evict.
if path.extension().and_then(|s| s.to_str()) == Some("tmp") {
continue;
}
let size = meta.len();
let mtime = meta.modified().unwrap_or(std::time::UNIX_EPOCH);
total += size;
entries.push((path, size, mtime));
}
if total <= cap {
return Ok(EvictReport {
evicted_files: 0,
evicted_bytes: 0,
remaining_bytes: total,
});
}
// Oldest first.
entries.sort_by(|a, b| a.2.cmp(&b.2));
let mut evicted_files = 0usize;
let mut evicted_bytes: u64 = 0;
let mut remaining = total;
for (path, size, _) in entries {
if remaining <= cap {
break;
}
if std::fs::remove_file(&path).is_ok() {
evicted_files += 1;
evicted_bytes += size;
remaining = remaining.saturating_sub(size);
}
}
Ok(EvictReport {
evicted_files,
evicted_bytes,
remaining_bytes: remaining,
})
}
/// Total bytes currently held in the release cache. Used by
/// observability and by tests.
pub fn current_size_bytes(repo: &Repository) -> Result<u64> {
let dir = cache_dir(repo);
if !dir.is_dir() {
return Ok(0);
}
let mut total = 0u64;
for ent in std::fs::read_dir(&dir).ctx(dir.clone())? {
let ent = ent.ctx(dir.clone())?;
let meta = match ent.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if meta.is_file() && ent.path().extension().and_then(|s| s.to_str()) != Some("tmp") {
total += meta.len();
}
}
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use std::time::{Duration, SystemTime};
fn tempdir() -> std::path::PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("levcs-cache-test-{n}-{}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
/// Drop `count` files into `dir`, each `size` bytes, with mtimes
/// staggered so the first is oldest. Returns the paths in the
/// same order (so `paths[0]` is the oldest).
fn populate(dir: &Path, count: usize, size: u64) -> Vec<std::path::PathBuf> {
std::fs::create_dir_all(dir).unwrap();
let mut paths = Vec::with_capacity(count);
let now = SystemTime::now();
for i in 0..count {
let p = dir.join(format!("entry-{i:02}"));
std::fs::write(&p, vec![0u8; size as usize]).unwrap();
// Stagger mtimes by a clear margin so the sort is
// deterministic regardless of filesystem resolution.
let f = std::fs::File::options().write(true).open(&p).unwrap();
let t = now - Duration::from_secs(((count - i) * 10) as u64);
f.set_modified(t).unwrap();
paths.push(p);
}
paths
}
#[test]
fn evict_keeps_everything_when_under_cap() {
let work = tempdir();
let repo = Repository::init_skeleton(&work).unwrap();
populate(&cache_dir(&repo), 3, 100);
let report = evict_to(&repo, 10_000).unwrap();
assert_eq!(report.evicted_files, 0);
assert_eq!(report.remaining_bytes, 300);
std::fs::remove_dir_all(&work).ok();
}
#[test]
fn evict_removes_oldest_first_until_under_cap() {
let work = tempdir();
let repo = Repository::init_skeleton(&work).unwrap();
let dir = cache_dir(&repo);
let paths = populate(&dir, 5, 100); // total 500
// Cap at 250 → must evict 3 oldest (250 left).
let report = evict_to(&repo, 250).unwrap();
assert_eq!(report.evicted_files, 3);
assert!(report.remaining_bytes <= 250);
// Oldest three deleted, newest two kept.
assert!(!paths[0].exists(), "oldest must go first");
assert!(!paths[1].exists());
assert!(!paths[2].exists());
assert!(paths[3].exists(), "newer entries must survive");
assert!(paths[4].exists());
std::fs::remove_dir_all(&work).ok();
}
#[test]
fn evict_skips_tmp_files() {
// `cache_release` writes via `<hex>.tmp` then renames; we
// mustn't evict an in-flight write under us.
let work = tempdir();
let repo = Repository::init_skeleton(&work).unwrap();
let dir = cache_dir(&repo);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.tmp"), vec![0u8; 9_000]).unwrap();
std::fs::write(dir.join("real-entry"), vec![0u8; 100]).unwrap();
let report = evict_to(&repo, 0).unwrap();
// `real-entry` is the only thing eligible for eviction.
assert_eq!(report.evicted_files, 1);
assert!(dir.join("a.tmp").is_file());
std::fs::remove_dir_all(&work).ok();
}
#[test]
fn current_size_excludes_tmp_files() {
let work = tempdir();
let repo = Repository::init_skeleton(&work).unwrap();
let dir = cache_dir(&repo);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("entry"), vec![0u8; 100]).unwrap();
std::fs::write(dir.join("entry.tmp"), vec![0u8; 9_000]).unwrap();
assert_eq!(current_size_bytes(&repo).unwrap(), 100);
std::fs::remove_dir_all(&work).ok();
}
}

View File

@ -0,0 +1,341 @@
//! Repository: top-level structure that bundles object store, refs, index,
//! and working-tree access. The on-disk layout per §2.6 is:
//!
//! ```text
//! .levcs/
//! config
//! HEAD
//! index
//! merge.toml
//! objects/
//! refs/
//! branches/
//! releases/
//! remote/
//! authority/
//! cache/
//! hooks/
//! ```
use std::fs;
use std::path::{Path, PathBuf};
use crate::error::{Error, IoExt, Result};
use crate::hash::{blake3_hash, ObjectId};
use crate::ignore::{always_ignored, Ignore};
use crate::index::{Index, IndexEntry, IndexEntryFlags};
use crate::object::{ObjectType, RawObject, SignedObject};
use crate::refs::{Head, Refs};
use crate::store::ObjectStore;
use crate::tree::{EntryType, FileMode, Tree, TreeEntry};
pub const LEVCS_DIR: &str = ".levcs";
#[derive(Clone, Debug)]
pub struct Repository {
pub workdir: PathBuf,
pub levcs_dir: PathBuf,
pub objects: ObjectStore,
pub refs: Refs,
}
impl Repository {
/// Create a new empty repository skeleton at `workdir/.levcs/`. Does not
/// write an authority object — that is the responsibility of the
/// `levcs init` command in `levcs-cli` (which needs identity bits).
pub fn init_skeleton(workdir: impl Into<PathBuf>) -> Result<Self> {
let workdir = workdir.into();
let levcs_dir = workdir.join(LEVCS_DIR);
if levcs_dir.exists() {
return Err(Error::RepositoryExists(levcs_dir));
}
for sub in [
"objects",
"refs/branches",
"refs/releases",
"refs/remote",
"refs/authority",
"cache/releases",
"hooks",
] {
let p = levcs_dir.join(sub);
fs::create_dir_all(&p).ctx(p)?;
}
// Default config (empty TOML)
let config_path = levcs_dir.join("config");
if !config_path.exists() {
fs::write(&config_path, b"# levcs repository config\n").ctx(config_path)?;
}
Ok(Self::open_at(workdir, levcs_dir))
}
/// Search upward from `start` for a `.levcs/` directory.
pub fn discover(start: impl AsRef<Path>) -> Result<Self> {
let start = start.as_ref();
let mut cur = if start.is_absolute() {
start.to_path_buf()
} else {
std::env::current_dir()?.join(start)
};
if let Ok(c) = cur.canonicalize() {
cur = c;
}
loop {
let candidate = cur.join(LEVCS_DIR);
if candidate.is_dir() {
let workdir = cur.clone();
return Ok(Self::open_at(workdir, candidate));
}
if !cur.pop() {
return Err(Error::NotARepository);
}
}
}
fn open_at(workdir: PathBuf, levcs_dir: PathBuf) -> Self {
let objects = ObjectStore::new(levcs_dir.join("objects"));
let refs = Refs::new(levcs_dir.clone());
Self { workdir, levcs_dir, objects, refs }
}
pub fn index_path(&self) -> PathBuf { self.levcs_dir.join("index") }
pub fn config_path(&self) -> PathBuf { self.levcs_dir.join("config") }
pub fn ignore_path(&self) -> PathBuf { self.workdir.join(".levcsignore") }
pub fn read_index(&self) -> Result<Index> {
Index::read_from(&self.index_path())
}
pub fn write_index(&self, idx: &Index) -> Result<()> {
idx.write_to(&self.index_path())
}
pub fn read_ignore(&self) -> Ignore {
match fs::read_to_string(self.ignore_path()) {
Ok(s) => Ignore::parse(&s),
Err(_) => Ignore::parse(""),
}
}
/// Hash a working-tree file as a blob (without writing to the store).
pub fn hash_blob(bytes: &[u8]) -> ObjectId {
// Reproduces the framing logic without depending on Blob to avoid a
// circular feel. Same logic as Blob::serialize().
let blob = crate::blob::Blob::new(bytes.to_vec());
blob.object_id()
}
/// Read the current authority hash from `refs/authority/current`.
pub fn current_authority(&self) -> Result<Option<ObjectId>> {
self.refs.read("refs/authority/current")
}
pub fn set_current_authority(&self, id: ObjectId) -> Result<()> {
self.refs.write("refs/authority/current", id)
}
pub fn genesis_authority(&self) -> Result<Option<ObjectId>> {
self.refs.read("refs/authority/genesis")
}
pub fn set_genesis_authority(&self, id: ObjectId) -> Result<()> {
self.refs.write("refs/authority/genesis", id)
}
/// Iterate over all working-tree files (excluding `.levcs/` and ignored).
pub fn walk_workdir(&self) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
let ignore = self.read_ignore();
walk(&self.workdir, &self.workdir, &ignore, &mut out)?;
out.sort();
return Ok(out);
fn walk(base: &Path, dir: &Path, ig: &Ignore, out: &mut Vec<PathBuf>) -> Result<()> {
for ent in fs::read_dir(dir).ctx(dir.to_path_buf())? {
let ent = ent.ctx(dir.to_path_buf())?;
let path = ent.path();
let rel = path.strip_prefix(base).unwrap();
if always_ignored(rel) {
continue;
}
let rel_str = rel.to_string_lossy().replace('\\', "/");
if ig.is_ignored(&rel_str) {
continue;
}
let ft = ent.file_type().ctx(path.clone())?;
if ft.is_dir() {
walk(base, &path, ig, out)?;
} else if ft.is_file() || ft.is_symlink() {
out.push(path);
}
}
Ok(())
}
}
/// Build a `Tree` object for a single directory level given a sorted set
/// of (relative_path, blob_hash, mode) entries representing the files
/// staged at and below `prefix`. Recursive: returns the root tree's id.
pub fn build_tree_from_index(&self, idx: &Index) -> Result<ObjectId> {
// Group entries by directory, build trees bottom-up.
let mut node = TreeBuilder::default();
for e in &idx.entries {
if !e.flags.is_tracked() {
continue;
}
node.insert(&e.path, e.blob_hash, mode_from_index(e.mode));
}
node.write(self)
}
/// Build a tree from a working directory directly (used when no index is
/// available). All files are added as regular blobs.
pub fn build_tree_from_workdir(&self) -> Result<ObjectId> {
let mut node = TreeBuilder::default();
for path in self.walk_workdir()? {
let rel = path.strip_prefix(&self.workdir).unwrap();
let rel_str = rel.to_string_lossy().replace('\\', "/");
let bytes = fs::read(&path).ctx(path.clone())?;
let blob = crate::blob::Blob::new(bytes);
let id = self.objects.write_raw(&blob.serialize())?;
node.insert(&rel_str, id, FileMode::REGULAR);
}
node.write(self)
}
/// Materialize a tree into the working directory at `prefix`. Existing
/// files are overwritten. The top-level `.levcs` entry (if any) is
/// skipped so authority-modifying commits do not clobber the repository's
/// own metadata directory; that entry exists only for verification.
pub fn checkout_tree(&self, tree_id: ObjectId, prefix: &Path) -> Result<()> {
self.checkout_tree_inner(tree_id, prefix, true)
}
fn checkout_tree_inner(&self, tree_id: ObjectId, prefix: &Path, top_level: bool) -> Result<()> {
let raw = self.objects.read_typed(tree_id, ObjectType::Tree)?;
let tree = Tree::parse_body(&raw.body)?;
for e in &tree.entries {
if top_level && e.name == ".levcs" {
continue;
}
let target = prefix.join(&e.name);
match e.entry_type {
EntryType::Tree => {
fs::create_dir_all(&target).ctx(target.clone())?;
self.checkout_tree_inner(e.hash, &target, false)?;
}
EntryType::Blob => {
let blob = self.objects.read_typed(e.hash, ObjectType::Blob)?;
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).ctx(parent.to_path_buf())?;
}
fs::write(&target, &blob.body).ctx(target.clone())?;
#[cfg(unix)]
if e.mode.is_executable() {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&target).ctx(target.clone())?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&target, perms).ctx(target.clone())?;
}
}
}
}
Ok(())
}
pub fn read_signed(&self, id: ObjectId) -> Result<SignedObject> {
let bytes = self.objects.read_raw(id)?;
SignedObject::parse(&bytes)
}
pub fn read_raw_object(&self, id: ObjectId) -> Result<RawObject> {
self.objects.read_object(id)
}
pub fn write_signed(&self, signed: &SignedObject) -> Result<ObjectId> {
let bytes = signed.serialize();
let id = blake3_hash(&bytes);
self.objects.write_at(id, &bytes)?;
Ok(id)
}
/// Find the path within a tree (recursively) and return (entry_type, hash).
pub fn lookup_path(&self, tree_id: ObjectId, path: &str) -> Result<Option<(EntryType, ObjectId)>> {
let raw = self.objects.read_typed(tree_id, ObjectType::Tree)?;
let tree = Tree::parse_body(&raw.body)?;
let mut comps = path.split('/').filter(|c| !c.is_empty());
let first = match comps.next() {
Some(c) => c,
None => return Ok(None),
};
let entry = match tree.find(first) {
Some(e) => e,
None => return Ok(None),
};
let rest: Vec<&str> = comps.collect();
if rest.is_empty() {
Ok(Some((entry.entry_type, entry.hash)))
} else {
match entry.entry_type {
EntryType::Tree => self.lookup_path(entry.hash, &rest.join("/")),
EntryType::Blob => Ok(None),
}
}
}
pub fn current_branch(&self) -> Result<Option<String>> {
match self.refs.read_head()? {
Some(Head::Branch(name)) => Ok(Some(name)),
_ => Ok(None),
}
}
}
fn mode_from_index(m: u8) -> FileMode {
let mut bits = 0u8;
if m & 0o111 != 0 { bits |= 0b01; }
FileMode(bits)
}
#[derive(Default)]
struct TreeBuilder {
files: Vec<(String, ObjectId, FileMode)>,
dirs: std::collections::BTreeMap<String, TreeBuilder>,
}
impl TreeBuilder {
fn insert(&mut self, path: &str, hash: ObjectId, mode: FileMode) {
let mut comps = path.splitn(2, '/');
let first = comps.next().unwrap();
match comps.next() {
None => {
self.files.push((first.to_string(), hash, mode));
}
Some(rest) => {
self.dirs.entry(first.to_string()).or_default().insert(rest, hash, mode);
}
}
}
fn write(self, repo: &Repository) -> Result<ObjectId> {
let mut tree = Tree::new();
for (name, hash, mode) in self.files {
tree.entries.push(TreeEntry { name, entry_type: EntryType::Blob, mode, hash });
}
for (name, sub) in self.dirs {
let sub_id = sub.write(repo)?;
tree.entries.push(TreeEntry {
name,
entry_type: EntryType::Tree,
mode: FileMode::REGULAR,
hash: sub_id,
});
}
tree.sort_and_validate()?;
let bytes = tree.serialize();
repo.objects.write_raw(&bytes)
}
}
#[allow(dead_code)]
fn _index_entry_keep(_: &IndexEntry, _: IndexEntryFlags) {}

View File

@ -0,0 +1,193 @@
//! Filesystem object store, sharded by the first two hex characters of the
//! object hash. Objects with hash `abcd1234...` live at
//! `.levcs/objects/ab/cd1234...`.
use std::fs;
use std::io::{Read, Write};
use std::path::PathBuf;
use crate::error::{Error, IoExt, Result};
use crate::hash::{blake3_hash, ObjectId};
use crate::object::{ObjectType, RawObject};
#[derive(Clone, Debug)]
pub struct ObjectStore {
pub root: PathBuf,
}
impl ObjectStore {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn ensure_dirs(&self) -> Result<()> {
fs::create_dir_all(&self.root).ctx(self.root.clone())?;
Ok(())
}
pub fn path_for(&self, id: ObjectId) -> PathBuf {
let hex = id.to_hex();
self.root.join(&hex[0..2]).join(&hex[2..])
}
pub fn contains(&self, id: ObjectId) -> bool {
self.path_for(id).is_file()
}
/// Persist raw object bytes. Returns the BLAKE3 hash of the bytes.
/// Verifies that the bytes are at least a parseable object before
/// writing.
pub fn write_raw(&self, bytes: &[u8]) -> Result<ObjectId> {
// Validate framing.
let _ = RawObject::parse(bytes)?;
let id = blake3_hash(bytes);
self.write_at(id, bytes)
}
/// Persist raw bytes at a known hash. The caller asserts that
/// `blake3(bytes) == id`; this is checked.
pub fn write_at(&self, id: ObjectId, bytes: &[u8]) -> Result<ObjectId> {
let actual = blake3_hash(bytes);
if actual != id {
return Err(Error::HashMismatch {
expected: id.to_hex(),
actual: actual.to_hex(),
});
}
let path = self.path_for(id);
if path.is_file() {
return Ok(id);
}
let parent = path.parent().expect("sharded path has parent");
fs::create_dir_all(parent).ctx(parent.to_path_buf())?;
// Write to a temp file in the same directory, then atomically rename.
let tmp = parent.join(format!("tmp.{}", id.to_hex()));
{
let mut f = fs::File::create(&tmp).ctx(tmp.clone())?;
f.write_all(bytes).ctx(tmp.clone())?;
f.sync_all().ctx(tmp.clone())?;
}
fs::rename(&tmp, &path).ctx(path.clone())?;
Ok(id)
}
pub fn read_raw(&self, id: ObjectId) -> Result<Vec<u8>> {
let path = self.path_for(id);
let mut f = fs::File::open(&path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::NotFound(id.to_hex())
} else {
Error::Io { path: Some(path.clone()), source: e }
}
})?;
let mut buf = Vec::new();
f.read_to_end(&mut buf).ctx(path.clone())?;
// Verify integrity.
let actual = blake3_hash(&buf);
if actual != id {
return Err(Error::HashMismatch {
expected: id.to_hex(),
actual: actual.to_hex(),
});
}
Ok(buf)
}
pub fn read_object(&self, id: ObjectId) -> Result<RawObject> {
let bytes = self.read_raw(id)?;
RawObject::parse(&bytes)
}
pub fn read_typed(&self, id: ObjectId, expected: ObjectType) -> Result<RawObject> {
let obj = self.read_object(id)?;
if obj.object_type != expected {
return Err(Error::MalformedObject(format!(
"expected {}, got {}", expected.name(), obj.object_type.name()
)));
}
Ok(obj)
}
/// Iterate all object IDs currently on disk (used by gc/verify). Returns
/// loose objects only.
pub fn iter_ids(&self) -> Result<Vec<ObjectId>> {
let mut out = Vec::new();
if !self.root.is_dir() {
return Ok(out);
}
for shard in fs::read_dir(&self.root).ctx(self.root.clone())? {
let shard = shard.ctx(self.root.clone())?;
let shard_name = shard.file_name();
let shard_str = match shard_name.to_str() {
Some(s) if s.len() == 2 => s.to_string(),
_ => continue,
};
for ent in fs::read_dir(shard.path()).ctx(shard.path())? {
let ent = ent.ctx(shard.path())?;
let name = ent.file_name();
let name_str = match name.to_str() {
Some(s) => s,
None => continue,
};
if name_str.starts_with("tmp.") {
continue;
}
let full = format!("{}{}", shard_str, name_str);
if let Ok(id) = ObjectId::from_hex(&full) {
out.push(id);
}
}
}
Ok(out)
}
}
/// Convenience: clean up any orphan tmp.* files left by interrupted writes.
pub fn cleanup_temp(store: &ObjectStore) -> Result<()> {
if !store.root.is_dir() {
return Ok(());
}
for shard in fs::read_dir(&store.root).ctx(store.root.clone())? {
let shard = shard.ctx(store.root.clone())?;
for ent in fs::read_dir(shard.path()).ctx(shard.path())? {
let ent = ent.ctx(shard.path())?;
let name = ent.file_name();
if name.to_string_lossy().starts_with("tmp.") {
let _ = fs::remove_file(ent.path());
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::blob::Blob;
fn tempdir() -> PathBuf {
let mut p = std::env::temp_dir();
let n: u64 = blake3::hash(format!("{:?}-{}", std::time::SystemTime::now(), std::process::id()).as_bytes())
.as_bytes()
.iter()
.take(8)
.fold(0u64, |acc, b| (acc << 8) | *b as u64);
p.push(format!("levcs-store-test-{n}"));
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn roundtrip_blob() {
let dir = tempdir();
let store = ObjectStore::new(dir.join("objects"));
store.ensure_dirs().unwrap();
let blob = Blob::new(b"hello".to_vec());
let bytes = blob.serialize();
let id = store.write_raw(&bytes).unwrap();
assert!(store.contains(id));
let read = store.read_raw(id).unwrap();
assert_eq!(read, bytes);
let _ = std::fs::remove_dir_all(dir);
}
}

View File

@ -0,0 +1,192 @@
//! Tree object: an ordered set of (name, type, hash, mode) entries.
//!
//! Per §2.3.2, each entry is:
//! 2 bytes: name length (LE u16, max 255)
//! N bytes: name (UTF-8, no null terminator)
//! 1 byte: entry type (1=Blob, 2=Tree)
//! 1 byte: mode bits (bit 0 executable, bit 1 symlink)
//! 32 bytes: object hash (raw BLAKE3)
//!
//! Entries are sorted byte-wise by name. Names must not contain '/', null,
//! or be `.` or `..`.
use byteorder::{ByteOrder, LittleEndian};
use crate::error::Error;
use crate::hash::{blake3_hash, ObjectId};
use crate::object::{frame_unsigned, ObjectType};
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum EntryType {
Blob = 1,
Tree = 2,
}
impl EntryType {
pub fn from_u8(b: u8) -> Result<Self, Error> {
Ok(match b {
1 => Self::Blob,
2 => Self::Tree,
n => return Err(Error::MalformedObject(format!("bad tree entry type {n}"))),
})
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct FileMode(pub u8);
impl FileMode {
pub const REGULAR: FileMode = FileMode(0);
pub const EXECUTABLE: FileMode = FileMode(0b01);
pub const SYMLINK: FileMode = FileMode(0b10);
pub fn is_executable(self) -> bool { self.0 & 0b01 != 0 }
pub fn is_symlink(self) -> bool { self.0 & 0b10 != 0 }
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeEntry {
pub name: String,
pub entry_type: EntryType,
pub mode: FileMode,
pub hash: ObjectId,
}
impl TreeEntry {
pub fn validate_name(name: &str) -> Result<(), Error> {
if name.is_empty() {
return Err(Error::InvalidPath("empty tree-entry name".into()));
}
if name.len() > 255 {
return Err(Error::InvalidPath(format!("name too long ({} bytes)", name.len())));
}
if name == "." || name == ".." {
return Err(Error::InvalidPath(format!("reserved name: {name}")));
}
if name.contains('/') {
return Err(Error::InvalidPath(format!("name contains '/': {name}")));
}
if name.bytes().any(|b| b == 0) {
return Err(Error::InvalidPath("name contains null byte".into()));
}
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Tree {
pub entries: Vec<TreeEntry>,
}
impl Tree {
pub fn new() -> Self { Self::default() }
/// Sort entries by name (byte-wise) and validate; required for hash
/// determinism.
pub fn sort_and_validate(&mut self) -> Result<(), Error> {
for e in &self.entries {
TreeEntry::validate_name(&e.name)?;
}
self.entries.sort_by(|a, b| a.name.as_bytes().cmp(b.name.as_bytes()));
// detect duplicate names
for w in self.entries.windows(2) {
if w[0].name == w[1].name {
return Err(Error::MalformedObject(format!(
"duplicate tree entry name: {}", w[0].name
)));
}
}
Ok(())
}
pub fn body(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(self.entries.len() * 64);
for e in &self.entries {
let n = e.name.len() as u16;
let mut len_buf = [0u8; 2];
LittleEndian::write_u16(&mut len_buf, n);
out.extend_from_slice(&len_buf);
out.extend_from_slice(e.name.as_bytes());
out.push(e.entry_type as u8);
out.push(e.mode.0);
out.extend_from_slice(e.hash.as_bytes());
}
out
}
pub fn serialize(&self) -> Vec<u8> {
frame_unsigned(ObjectType::Tree, &self.body())
}
pub fn object_id(&self) -> ObjectId {
blake3_hash(&self.serialize())
}
pub fn parse_body(body: &[u8]) -> Result<Self, Error> {
let mut entries = Vec::new();
let mut p = 0usize;
while p < body.len() {
if body.len() < p + 2 {
return Err(Error::MalformedObject("tree entry: short name length".into()));
}
let n = LittleEndian::read_u16(&body[p..p + 2]) as usize;
p += 2;
if body.len() < p + n + 1 + 1 + 32 {
return Err(Error::MalformedObject("tree entry truncated".into()));
}
let name = std::str::from_utf8(&body[p..p + n])
.map_err(|_| Error::MalformedObject("tree name not UTF-8".into()))?
.to_string();
p += n;
let entry_type = EntryType::from_u8(body[p])?;
p += 1;
let mode = FileMode(body[p]);
p += 1;
let mut h = [0u8; 32];
h.copy_from_slice(&body[p..p + 32]);
p += 32;
entries.push(TreeEntry { name, entry_type, mode, hash: ObjectId(h) });
}
Ok(Tree { entries })
}
pub fn find(&self, name: &str) -> Option<&TreeEntry> {
self.entries.iter().find(|e| e.name == name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tree_roundtrip() {
let mut t = Tree::new();
t.entries.push(TreeEntry {
name: "b.txt".into(),
entry_type: EntryType::Blob,
mode: FileMode::REGULAR,
hash: ObjectId([1; 32]),
});
t.entries.push(TreeEntry {
name: "a.txt".into(),
entry_type: EntryType::Blob,
mode: FileMode::EXECUTABLE,
hash: ObjectId([2; 32]),
});
t.sort_and_validate().unwrap();
let body = t.body();
let t2 = Tree::parse_body(&body).unwrap();
assert_eq!(t.entries, t2.entries);
// sorted: a then b
assert_eq!(t.entries[0].name, "a.txt");
}
#[test]
fn rejects_dot_dotdot_slash_null() {
for n in [".", "..", "a/b", "x\0y", ""] {
assert!(TreeEntry::validate_name(n).is_err());
}
}
}

View File

@ -0,0 +1,267 @@
//! Robustness/fuzz tests for object deserializers (§8.2).
//!
//! The spec requires: "All deserializers MUST be safe against malformed
//! input and MUST NOT panic." These tests drive thousands of random and
//! adversarially-mutated byte slices through every public parser and
//! assert that the parser either returns a typed error or a valid value
//! — never a panic, never an infinite loop, never an out-of-bounds slice.
//!
//! Reproducibility: input generation is seeded by an LCG with a known
//! constant. If a future change introduces a panic, the printed seed
//! lets the fix verify against the same input.
use std::panic::{catch_unwind, AssertUnwindSafe};
use levcs_core::object::{
ObjectHeader, ObjectType, RawObject, SignatureEntry, SignedObject, FORMAT_VERSION,
HEADER_SIZE, SIGNATURE_ENTRY_SIZE,
};
use levcs_core::{Commit, Release, Tree};
/// Iterations per test target. Tuned so the suite runs in <1 second on
/// a developer laptop while still covering enough random states that
/// a missed bounds check tends to surface within a few hundred iterations.
/// Bump up locally when investigating a specific parser.
const ITERS: u32 = 5_000;
/// Linear-congruential PRNG. A function we can reset deterministically;
/// no need to take a dep on `rand` for this.
fn lcg(state: &mut u64) -> u64 {
*state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
*state
}
fn rand_bytes(seed: &mut u64, n: usize) -> Vec<u8> {
(0..n).map(|_| (lcg(seed) >> 33) as u8).collect()
}
fn rand_size(seed: &mut u64) -> usize {
// Mix of tiny / small / medium sizes — small slices catch bounds
// checks at the start, medium slices catch length-field mismatches.
match lcg(seed) % 5 {
0 => 0,
1 => (lcg(seed) % 16) as usize,
2 => (lcg(seed) % 256) as usize,
3 => 256 + (lcg(seed) % 4096) as usize,
_ => 4096 + (lcg(seed) % 16384) as usize,
}
}
/// Run `parser` on `input`. If it panics, fail the test naming the seed
/// so the failure can be reproduced; otherwise we don't care whether
/// the parser returned Ok or Err — only that it did not panic.
fn assert_no_panic<F, T>(label: &str, seed: u64, input: &[u8], parser: F)
where
F: FnOnce(&[u8]) -> T,
{
let r = catch_unwind(AssertUnwindSafe(|| parser(input)));
if r.is_err() {
panic!(
"{label} panicked on seed {seed:#x}, input len {} ({} bytes shown): {:02x?}",
input.len(),
input.len().min(64),
&input[..input.len().min(64)]
);
}
}
#[test]
fn signed_object_parse_does_not_panic() {
let mut seed = 0xdeadbeef_cafe1234u64;
for _ in 0..ITERS {
let n = rand_size(&mut seed);
let bytes = rand_bytes(&mut seed, n);
assert_no_panic("SignedObject::parse", seed, &bytes, |b| {
let _ = SignedObject::parse(b);
});
assert_no_panic("RawObject::parse", seed, &bytes, |b| {
let _ = RawObject::parse(b);
});
assert_no_panic("ObjectHeader::decode", seed, &bytes, |b| {
let _ = ObjectHeader::decode(b);
});
}
}
/// Mutate a known-valid signed object: flip random bits, truncate, splice
/// in adversarial bytes. The parser must remain panic-free under any of
/// these — they are the realistic shapes of corruption (bit rot, partial
/// writes, hostile peer feeding crafted bytes).
#[test]
fn signed_object_parse_survives_mutation_of_valid_input() {
// Build a valid SignedObject as the mutation seed.
let body = b"hello there, this is a body for fuzzing".to_vec();
let signed = SignedObject::new(ObjectType::Blob, body);
let valid = signed.serialize();
let mut seed = 0xfeedface_5151aaaau64;
for _ in 0..ITERS {
let mut buf = valid.clone();
// Pick a mutation strategy.
match lcg(&mut seed) % 5 {
0 => {
// Flip up to 8 random bits.
let flips = (lcg(&mut seed) % 8 + 1) as usize;
for _ in 0..flips {
if buf.is_empty() {
break;
}
let idx = (lcg(&mut seed) as usize) % buf.len();
let bit = (lcg(&mut seed) % 8) as u8;
buf[idx] ^= 1 << bit;
}
}
1 => {
// Truncate to a random shorter length.
let new_len = (lcg(&mut seed) as usize) % buf.len().max(1);
buf.truncate(new_len);
}
2 => {
// Tamper with the body_len field (bytes 8..16, LE u64).
let off = 8 + (lcg(&mut seed) as usize) % 8;
buf[off] = buf[off].wrapping_add((lcg(&mut seed) & 0xff) as u8);
}
3 => {
// Append random garbage.
let extra = (lcg(&mut seed) % 64) as usize;
buf.extend(rand_bytes(&mut seed, extra));
}
_ => {
// Replace random byte run.
if !buf.is_empty() {
let start = (lcg(&mut seed) as usize) % buf.len();
let len = ((lcg(&mut seed) as usize) % 16).min(buf.len() - start);
for b in &mut buf[start..start + len] {
*b = (lcg(&mut seed) & 0xff) as u8;
}
}
}
}
assert_no_panic("SignedObject::parse(mut)", seed, &buf, |b| {
let _ = SignedObject::parse(b);
});
assert_no_panic("RawObject::parse(mut)", seed, &buf, |b| {
let _ = RawObject::parse(b);
});
}
}
#[test]
fn body_parsers_do_not_panic_on_random_bytes() {
// Tree, Commit, and Release `parse_body` consume just the body — no
// header, no trailer. These are the parsers most exposed to crafted
// bytes from the federation push path.
let mut seed = 0x0123456789abcdefu64;
for _ in 0..ITERS {
let n = rand_size(&mut seed);
let bytes = rand_bytes(&mut seed, n);
assert_no_panic("Tree::parse_body", seed, &bytes, |b| {
let _ = Tree::parse_body(b);
});
assert_no_panic("Commit::parse_body", seed, &bytes, |b| {
let _ = Commit::parse_body(b);
});
assert_no_panic("Release::parse_body", seed, &bytes, |b| {
let _ = Release::parse_body(b);
});
}
}
#[test]
fn header_decode_handles_pathological_lengths() {
// The body_len field is u64 LE — a malicious peer could set it to
// values like u64::MAX. The parser must reject without attempting
// to allocate a buffer that size. This test verifies it returns an
// error and does not panic on the largest plausible adversarial
// values.
let mut seed = 0x9999_5555_aaaa_3333u64;
for _ in 0..512 {
let mut hdr = [0u8; HEADER_SIZE];
hdr[0..4].copy_from_slice(b"LVCS");
hdr[4] = (lcg(&mut seed) & 0xff) as u8;
hdr[5] = FORMAT_VERSION;
hdr[6] = 0;
hdr[7] = 0;
// body_len: extreme values (full u64), random values, all f's.
let body_len = match lcg(&mut seed) % 4 {
0 => u64::MAX,
1 => u64::MAX / 2,
2 => 1u64 << 40,
_ => lcg(&mut seed),
};
hdr[8..16].copy_from_slice(&body_len.to_le_bytes());
assert_no_panic("ObjectHeader::decode(extreme)", seed, &hdr, |b| {
let _ = ObjectHeader::decode(b);
});
assert_no_panic("SignedObject::parse(extreme)", seed, &hdr, |b| {
let _ = SignedObject::parse(b);
});
assert_no_panic("RawObject::parse(extreme)", seed, &hdr, |b| {
let _ = RawObject::parse(b);
});
}
}
#[test]
fn signature_trailer_count_byte_is_safe() {
// The trailer count byte is u8 (max 255 entries). A malicious sender
// could set it to a value that wildly overshoots the byte slice.
// SignedObject::parse must detect and refuse, not panic.
let mut seed = 0x77_77_77_77u64;
for count in [0u8, 1, 5, 255] {
let body = b"x".repeat(64);
let mut bytes = Vec::new();
let header = ObjectHeader {
object_type: ObjectType::Blob,
format_version: FORMAT_VERSION,
body_len: body.len() as u64,
}
.encode();
bytes.extend_from_slice(&header);
bytes.extend_from_slice(&body);
bytes.push(count);
// Intentionally include too few signature bytes for the claimed
// count — parser must return Err.
let want = count as usize * SIGNATURE_ENTRY_SIZE;
let provided = (lcg(&mut seed) as usize) % (want + 1);
bytes.extend(rand_bytes(&mut seed, provided));
assert_no_panic("SignedObject::parse(short trailer)", seed, &bytes, |b| {
let _ = SignedObject::parse(b);
});
}
}
#[test]
fn signature_entry_decode_does_not_panic() {
let mut seed = 0xabc_def_123_456u64;
for _ in 0..1024 {
let n = (lcg(&mut seed) % (SIGNATURE_ENTRY_SIZE as u64 + 32)) as usize;
let bytes = rand_bytes(&mut seed, n);
assert_no_panic("SignatureEntry::decode", seed, &bytes, |b| {
let _ = SignatureEntry::decode(b);
});
}
}
#[test]
fn signed_object_round_trip_when_parse_succeeds() {
// Sanity invariant: when parse succeeds on random input (rare but
// possible), serialize-then-parse must yield identical bytes. This
// protects against a parser that silently drops bytes.
let mut seed = 0xfeed_beef_dead_c0deu64;
let mut hits = 0u32;
for _ in 0..ITERS {
let n = rand_size(&mut seed);
let bytes = rand_bytes(&mut seed, n);
if let Ok(parsed) = SignedObject::parse(&bytes) {
let re = parsed.serialize();
assert_eq!(re, bytes, "round-trip mismatch on seed {seed:#x}");
hits += 1;
}
}
// Random bytes hit a valid signed object only by coincidence — but
// we don't require any hits, just that any incidental hits round-trip.
let _ = hits;
}

View File

@ -0,0 +1,159 @@
//! Property tests for object body codecs.
//!
//! Complements `fuzz.rs` (which throws random bytes at parsers and asserts
//! no panic) with structured round-trips: build valid objects by
//! construction, serialize, parse, and assert structural equality. When
//! a property fails, proptest shrinks toward a minimal failing case.
use levcs_core::object::RawObject;
use levcs_core::{
Blob, Commit, CommitFlags, EntryType, FileMode, ObjectId, Tree, TreeEntry,
};
use proptest::collection::vec;
use proptest::prelude::*;
/// A name that satisfies `TreeEntry::validate_name` — non-empty, ≤255 bytes,
/// no '/' or NUL, not "." or "..". We restrict to ASCII letters/digits/
/// underscore so the proptest generator stays simple and the names are
/// trivially unique-able.
fn name_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z0-9_]{1,32}".prop_filter("reserved", |s| s != "." && s != "..")
}
fn object_id_strategy() -> impl Strategy<Value = ObjectId> {
any::<[u8; 32]>().prop_map(ObjectId)
}
fn entry_type_strategy() -> impl Strategy<Value = EntryType> {
prop_oneof![Just(EntryType::Blob), Just(EntryType::Tree)]
}
fn file_mode_strategy() -> impl Strategy<Value = FileMode> {
(0u8..=0b11).prop_map(FileMode)
}
fn tree_entry_strategy() -> impl Strategy<Value = TreeEntry> {
(
name_strategy(),
entry_type_strategy(),
file_mode_strategy(),
object_id_strategy(),
)
.prop_map(|(name, entry_type, mode, hash)| TreeEntry {
name,
entry_type,
mode,
hash,
})
}
/// Tree with unique-by-name entries (validated trees can't have dupes).
fn tree_strategy() -> impl Strategy<Value = Tree> {
vec(tree_entry_strategy(), 0..16).prop_map(|entries| {
// Dedupe by name: keep first occurrence so the property domain
// matches what `sort_and_validate` accepts.
let mut seen = std::collections::HashSet::new();
let unique: Vec<_> = entries
.into_iter()
.filter(|e| seen.insert(e.name.clone()))
.collect();
let mut t = Tree { entries: unique };
t.sort_and_validate().unwrap();
t
})
}
fn commit_flags_strategy() -> impl Strategy<Value = CommitFlags> {
(0u8..=0b11).prop_map(CommitFlags)
}
fn commit_strategy() -> impl Strategy<Value = Commit> {
(
object_id_strategy(),
vec(object_id_strategy(), 0..8),
object_id_strategy(),
any::<[u8; 32]>(),
any::<i64>(),
commit_flags_strategy(),
".{0,256}",
)
.prop_map(
|(tree, parents, authority, author_key, ts, flags, message)| Commit {
tree,
parents,
authority,
author_key,
timestamp_micros: ts,
flags,
message,
},
)
}
proptest! {
/// Tree::body → Tree::parse_body is a structural identity for any
/// valid tree.
#[test]
fn tree_body_roundtrip(t in tree_strategy()) {
let body = t.body();
let t2 = Tree::parse_body(&body).expect("valid tree must parse");
prop_assert_eq!(t.entries.len(), t2.entries.len());
for (a, b) in t.entries.iter().zip(t2.entries.iter()) {
prop_assert_eq!(&a.name, &b.name);
prop_assert_eq!(a.entry_type as u8, b.entry_type as u8);
prop_assert_eq!(a.mode.0, b.mode.0);
prop_assert_eq!(a.hash, b.hash);
}
}
/// Commit::body → Commit::parse_body is a structural identity for
/// any valid commit. Exercises every field including the variable-
/// length parents list and message.
#[test]
fn commit_body_roundtrip(c in commit_strategy()) {
let body = c.body().expect("valid commit must serialize");
let c2 = Commit::parse_body(&body).expect("valid commit must parse");
prop_assert_eq!(c.tree, c2.tree);
prop_assert_eq!(&c.parents, &c2.parents);
prop_assert_eq!(c.authority, c2.authority);
prop_assert_eq!(c.author_key, c2.author_key);
prop_assert_eq!(c.timestamp_micros, c2.timestamp_micros);
prop_assert_eq!(c.flags.0, c2.flags.0);
prop_assert_eq!(c.message, c2.message);
}
/// Blob serialize → RawObject::parse → Blob::from_body round-trip.
/// `Blob::serialize` wraps the bytes in an unsigned object frame, so
/// the natural inverse is to parse the frame and reconstruct.
#[test]
fn blob_roundtrip(bytes in vec(any::<u8>(), 0..16_384)) {
let blob = Blob::new(bytes.clone());
let serialized = blob.serialize();
let raw = RawObject::parse(&serialized).expect("valid blob frame must parse");
let parsed = Blob::from_body(raw.body);
prop_assert_eq!(parsed.bytes, bytes);
}
/// One-byte truncation of any valid tree body must not panic. The
/// fuzz suite covers random truncation; this version focuses proptest
/// shrinkage on the tightest failing case.
#[test]
fn tree_body_one_byte_short_does_not_panic(t in tree_strategy()) {
let body = t.body();
if !body.is_empty() {
let short = &body[..body.len() - 1];
let _ = Tree::parse_body(short);
}
}
/// Same property for commits — truncate the body by one byte and
/// require the parser to return an error rather than panic.
#[test]
fn commit_body_one_byte_short_does_not_panic(c in commit_strategy()) {
let body = c.body().expect("valid commit must serialize");
if !body.is_empty() {
let short = &body[..body.len() - 1];
let _ = Commit::parse_body(short);
}
}
}

View File

@ -0,0 +1,23 @@
[package]
name = "levcs-identity"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
levcs-core = { workspace = true }
blake3 = { workspace = true }
ed25519-dalek = { workspace = true }
rand_core = { workspace = true }
getrandom = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
byteorder = { workspace = true }
hex = { workspace = true }
base64 = { workspace = true }
argon2 = { workspace = true }
chacha20poly1305 = { workspace = true }
zeroize = { workspace = true }
glob = { workspace = true }

View File

@ -0,0 +1,617 @@
//! Authority objects per the v1.1 trust-root revision §3.3.
//!
//! The wire form is a deterministic binary encoding; TOML is used as a
//! human-editable surface only. Both directions are implemented.
use std::collections::BTreeMap;
use byteorder::{ByteOrder, LittleEndian};
use serde::{Deserialize, Serialize};
use levcs_core::object::{ObjectType, SignatureEntry, SignedObject};
use levcs_core::{ObjectId, ZERO_ID};
use crate::error::{IdentityError, Result};
use crate::keys::PublicKey;
pub const AUTHORITY_SCHEMA_VERSION: u16 = 1;
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
#[repr(u8)]
pub enum Role {
Reader = 1,
Contributor = 2,
Maintainer = 3,
Owner = 4,
}
impl Role {
pub fn from_u8(b: u8) -> Result<Self> {
Ok(match b {
1 => Self::Reader,
2 => Self::Contributor,
3 => Self::Maintainer,
4 => Self::Owner,
n => return Err(IdentityError::MalformedAuthority(format!("unknown role: {n}"))),
})
}
pub fn name(self) -> &'static str {
match self {
Self::Reader => "reader",
Self::Contributor => "contributor",
Self::Maintainer => "maintainer",
Self::Owner => "owner",
}
}
pub fn from_name(s: &str) -> Result<Self> {
Ok(match s {
"reader" => Self::Reader,
"contributor" => Self::Contributor,
"maintainer" => Self::Maintainer,
"owner" => Self::Owner,
other => return Err(IdentityError::MalformedAuthority(format!("unknown role: {other}"))),
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MemberEntry {
pub key: PublicKey,
pub handle: String,
pub role: Role,
pub added_micros: i64,
pub added_by: PublicKey,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PolicyEntry {
pub key: String,
pub value: Vec<u8>,
}
/// In-memory authority body. Keys/values follow §3.3.1 sort orders before
/// serialization.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthorityBody {
pub schema_version: u16,
pub repo_id: ObjectId,
pub previous_authority: ObjectId,
pub version: u32,
pub created_micros: i64,
pub members: Vec<MemberEntry>,
pub policy: Vec<PolicyEntry>,
}
impl AuthorityBody {
pub fn is_genesis(&self) -> bool { self.previous_authority.is_zero() }
pub fn find_member(&self, key: &PublicKey) -> Option<&MemberEntry> {
self.members.iter().find(|m| m.key == *key)
}
pub fn policy_value(&self, key: &str) -> Option<&[u8]> {
self.policy.iter().find(|p| p.key == key).map(|p| p.value.as_slice())
}
pub fn public_read(&self) -> bool {
matches!(self.policy_value("public_read"), Some([0x01]))
}
pub fn require_signed_releases(&self) -> bool {
matches!(self.policy_value("require_signed_releases"), Some([0x01]))
}
pub fn protected_branches(&self) -> Vec<String> {
self.policy_value("protected_branches")
.and_then(|v| std::str::from_utf8(v).ok())
.map(|s| s.split(',').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect())
.unwrap_or_default()
}
/// Sort members by key bytes and policy by key, validate, and return a
/// mutable reference to self for chaining.
pub fn normalize(&mut self) -> Result<()> {
for m in &self.members {
if m.handle.len() > 64 {
return Err(IdentityError::MalformedAuthority(format!(
"handle too long: {} bytes", m.handle.len()
)));
}
}
self.members.sort_by(|a, b| a.key.0.cmp(&b.key.0));
for w in self.members.windows(2) {
if w[0].key == w[1].key {
return Err(IdentityError::MalformedAuthority(format!(
"duplicate member: {}", w[0].key
)));
}
}
self.policy.sort_by(|a, b| a.key.as_bytes().cmp(b.key.as_bytes()));
for w in self.policy.windows(2) {
if w[0].key == w[1].key {
return Err(IdentityError::MalformedAuthority(format!(
"duplicate policy key: {}", w[0].key
)));
}
if w[0].key.len() > 255 {
return Err(IdentityError::MalformedAuthority("policy key too long".into()));
}
if w[0].value.len() > u16::MAX as usize {
return Err(IdentityError::MalformedAuthority("policy value too large".into()));
}
}
Ok(())
}
/// Encode to deterministic binary form. Caller must have called
/// `normalize` first; this method calls it again to be safe.
pub fn encode(&self) -> Result<Vec<u8>> {
let mut me = self.clone();
me.normalize()?;
encode_body(&me)
}
/// Encode for the genesis-repo_id derivation: same as `encode` but with
/// `repo_id` field zeroed.
pub fn encode_with_repo_id_zero(&self) -> Result<Vec<u8>> {
let mut me = self.clone();
me.repo_id = ZERO_ID;
me.encode()
}
pub fn parse(bytes: &[u8]) -> Result<Self> {
decode_body(bytes)
}
/// Wrap the body in a `SignedObject` of type Authority.
pub fn to_signed(&self) -> Result<SignedObject> {
Ok(SignedObject::new(ObjectType::Authority, self.encode()?))
}
/// Compute and assign repo_id for a genesis authority object. Per §3.3.3:
/// `repo_id = BLAKE3(body with repo_id=0)`.
pub fn assign_genesis_repo_id(&mut self) -> Result<()> {
if !self.previous_authority.is_zero() {
return Err(IdentityError::MalformedAuthority(
"assign_genesis_repo_id called on non-genesis authority".into(),
));
}
let body_zeroed = self.encode_with_repo_id_zero()?;
self.repo_id = ObjectId(*blake3::hash(&body_zeroed).as_bytes());
Ok(())
}
/// Append a signature entry to a `SignedObject`. The caller must have
/// computed the signature over `BLAKE3(header || body)`.
pub fn add_signature(signed: &mut SignedObject, sig: SignatureEntry) {
signed.signatures.push(sig);
}
}
fn encode_body(b: &AuthorityBody) -> Result<Vec<u8>> {
let mut out = Vec::new();
let mut sv = [0u8; 2];
LittleEndian::write_u16(&mut sv, b.schema_version);
out.extend_from_slice(&sv);
out.extend_from_slice(b.repo_id.as_bytes());
out.extend_from_slice(b.previous_authority.as_bytes());
let mut v = [0u8; 4];
LittleEndian::write_u32(&mut v, b.version);
out.extend_from_slice(&v);
let mut c = [0u8; 8];
LittleEndian::write_i64(&mut c, b.created_micros);
out.extend_from_slice(&c);
if b.members.len() > u16::MAX as usize {
return Err(IdentityError::MalformedAuthority("too many members".into()));
}
let mut mc = [0u8; 2];
LittleEndian::write_u16(&mut mc, b.members.len() as u16);
out.extend_from_slice(&mc);
for m in &b.members {
out.extend_from_slice(m.key.as_bytes());
let mut hl = [0u8; 2];
LittleEndian::write_u16(&mut hl, m.handle.len() as u16);
out.extend_from_slice(&hl);
out.extend_from_slice(m.handle.as_bytes());
out.push(m.role as u8);
let mut t = [0u8; 8];
LittleEndian::write_i64(&mut t, m.added_micros);
out.extend_from_slice(&t);
out.extend_from_slice(m.added_by.as_bytes());
}
if b.policy.len() > u16::MAX as usize {
return Err(IdentityError::MalformedAuthority("too many policy entries".into()));
}
let mut pc = [0u8; 2];
LittleEndian::write_u16(&mut pc, b.policy.len() as u16);
out.extend_from_slice(&pc);
for p in &b.policy {
out.push(p.key.len() as u8);
out.extend_from_slice(p.key.as_bytes());
let mut vl = [0u8; 2];
LittleEndian::write_u16(&mut vl, p.value.len() as u16);
out.extend_from_slice(&vl);
out.extend_from_slice(&p.value);
}
Ok(out)
}
fn decode_body(bytes: &[u8]) -> Result<AuthorityBody> {
if bytes.len() < 2 + 32 + 32 + 4 + 8 + 2 {
return Err(IdentityError::MalformedAuthority("authority body too short".into()));
}
let mut p = 0usize;
let schema_version = LittleEndian::read_u16(&bytes[p..p + 2]);
p += 2;
if schema_version != AUTHORITY_SCHEMA_VERSION {
return Err(IdentityError::MalformedAuthority(format!(
"unsupported authority schema_version: {schema_version}"
)));
}
let mut repo_id = [0u8; 32];
repo_id.copy_from_slice(&bytes[p..p + 32]);
p += 32;
let mut prev = [0u8; 32];
prev.copy_from_slice(&bytes[p..p + 32]);
p += 32;
let version = LittleEndian::read_u32(&bytes[p..p + 4]);
p += 4;
let created_micros = LittleEndian::read_i64(&bytes[p..p + 8]);
p += 8;
let member_count = LittleEndian::read_u16(&bytes[p..p + 2]) as usize;
p += 2;
let mut members = Vec::with_capacity(member_count);
for _ in 0..member_count {
if bytes.len() < p + 32 + 2 {
return Err(IdentityError::MalformedAuthority("member entry truncated".into()));
}
let mut k = [0u8; 32];
k.copy_from_slice(&bytes[p..p + 32]);
p += 32;
let hl = LittleEndian::read_u16(&bytes[p..p + 2]) as usize;
p += 2;
if bytes.len() < p + hl + 1 + 8 + 32 {
return Err(IdentityError::MalformedAuthority("member entry truncated".into()));
}
let handle = std::str::from_utf8(&bytes[p..p + hl])
.map_err(|_| IdentityError::MalformedAuthority("handle not UTF-8".into()))?
.to_string();
p += hl;
let role = Role::from_u8(bytes[p])?;
p += 1;
let added_micros = LittleEndian::read_i64(&bytes[p..p + 8]);
p += 8;
let mut ab = [0u8; 32];
ab.copy_from_slice(&bytes[p..p + 32]);
p += 32;
members.push(MemberEntry {
key: PublicKey(k),
handle,
role,
added_micros,
added_by: PublicKey(ab),
});
}
if bytes.len() < p + 2 {
return Err(IdentityError::MalformedAuthority("policy_count truncated".into()));
}
let policy_count = LittleEndian::read_u16(&bytes[p..p + 2]) as usize;
p += 2;
let mut policy = Vec::with_capacity(policy_count);
for _ in 0..policy_count {
if bytes.len() < p + 1 {
return Err(IdentityError::MalformedAuthority("policy entry truncated".into()));
}
let kl = bytes[p] as usize;
p += 1;
if bytes.len() < p + kl + 2 {
return Err(IdentityError::MalformedAuthority("policy entry truncated".into()));
}
let key = std::str::from_utf8(&bytes[p..p + kl])
.map_err(|_| IdentityError::MalformedAuthority("policy key not UTF-8".into()))?
.to_string();
p += kl;
let vl = LittleEndian::read_u16(&bytes[p..p + 2]) as usize;
p += 2;
if bytes.len() < p + vl {
return Err(IdentityError::MalformedAuthority("policy value truncated".into()));
}
let value = bytes[p..p + vl].to_vec();
p += vl;
policy.push(PolicyEntry { key, value });
}
if p != bytes.len() {
return Err(IdentityError::MalformedAuthority(format!(
"trailing {} byte(s) after authority body", bytes.len() - p
)));
}
Ok(AuthorityBody {
schema_version,
repo_id: ObjectId(repo_id),
previous_authority: ObjectId(prev),
version,
created_micros,
members,
policy,
})
}
// ---------------------------------------------------------------------------
// TOML surface (§3.3.2). Implementations MUST NOT hash this representation.
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AuthorityToml {
pub schema_version: u16,
pub repo_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_authority: Option<String>,
pub version: u32,
pub created: String,
#[serde(default, rename = "member")]
pub members: Vec<TomlMember>,
#[serde(default)]
pub policy: BTreeMap<String, toml::Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TomlMember {
pub key: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub handle: String,
pub role: String,
pub added: String,
pub added_by: String,
}
pub fn parse_toml_authority(text: &str) -> Result<AuthorityBody> {
let t: AuthorityToml = toml::from_str(text)?;
let repo_id = parse_blake3(&t.repo_id)?;
let prev = match t.previous_authority {
None => ZERO_ID,
Some(s) if s.is_empty() => ZERO_ID,
Some(s) => parse_blake3(&s)?,
};
let mut members = Vec::with_capacity(t.members.len());
for m in t.members {
members.push(MemberEntry {
key: PublicKey::parse_levcs(&m.key)?,
handle: m.handle,
role: Role::from_name(&m.role)?,
added_micros: parse_rfc3339_micros(&m.added)?,
added_by: PublicKey::parse_levcs(&m.added_by)?,
});
}
let mut policy = Vec::new();
for (k, v) in t.policy {
policy.push(PolicyEntry { key: k, value: encode_policy_value(&v) });
}
let mut body = AuthorityBody {
schema_version: t.schema_version,
repo_id,
previous_authority: prev,
version: t.version,
created_micros: parse_rfc3339_micros(&t.created)?,
members,
policy,
};
body.normalize()?;
Ok(body)
}
pub fn render_toml_authority(body: &AuthorityBody) -> Result<String> {
let prev = if body.previous_authority.is_zero() {
None
} else {
Some(format!("blake3:{}", body.previous_authority.to_hex()))
};
let members = body
.members
.iter()
.map(|m| TomlMember {
key: m.key.to_levcs(),
handle: m.handle.clone(),
role: m.role.name().to_string(),
added: rfc3339_from_micros(m.added_micros),
added_by: m.added_by.to_levcs(),
})
.collect();
let mut policy = BTreeMap::new();
for p in &body.policy {
policy.insert(p.key.clone(), decode_policy_value(&p.key, &p.value));
}
let t = AuthorityToml {
schema_version: body.schema_version,
repo_id: format!("blake3:{}", body.repo_id.to_hex()),
previous_authority: prev,
version: body.version,
created: rfc3339_from_micros(body.created_micros),
members,
policy,
};
Ok(toml::to_string_pretty(&t)?)
}
fn parse_blake3(s: &str) -> Result<ObjectId> {
let rest = s
.strip_prefix("blake3:")
.ok_or_else(|| IdentityError::MalformedAuthority(format!("missing blake3: prefix in {s}")))?;
Ok(ObjectId::from_hex(rest).map_err(|e| IdentityError::MalformedAuthority(e.to_string()))?)
}
fn parse_rfc3339_micros(s: &str) -> Result<i64> {
// Accept the simple "YYYY-MM-DDTHH:MM:SSZ" form (and ".SSSSSSZ").
let s = s.trim();
let (date, rest) = s
.split_once('T')
.ok_or_else(|| IdentityError::MalformedAuthority(format!("bad timestamp: {s}")))?;
let dparts: Vec<&str> = date.split('-').collect();
if dparts.len() != 3 {
return Err(IdentityError::MalformedAuthority(format!("bad date: {date}")));
}
let y: i64 = dparts[0].parse().map_err(|_| IdentityError::MalformedAuthority(s.into()))?;
let mo: u32 = dparts[1].parse().map_err(|_| IdentityError::MalformedAuthority(s.into()))?;
let d: u32 = dparts[2].parse().map_err(|_| IdentityError::MalformedAuthority(s.into()))?;
let rest = rest.trim_end_matches('Z');
let (time, frac) = match rest.split_once('.') {
Some((t, f)) => (t, f),
None => (rest, ""),
};
let tparts: Vec<&str> = time.split(':').collect();
if tparts.len() != 3 {
return Err(IdentityError::MalformedAuthority(format!("bad time: {time}")));
}
let h: i64 = tparts[0].parse().map_err(|_| IdentityError::MalformedAuthority(s.into()))?;
let mi: i64 = tparts[1].parse().map_err(|_| IdentityError::MalformedAuthority(s.into()))?;
let se: i64 = tparts[2].parse().map_err(|_| IdentityError::MalformedAuthority(s.into()))?;
let micros_frac: i64 = if frac.is_empty() {
0
} else {
let mut s6 = String::from(frac);
s6.truncate(6);
while s6.len() < 6 {
s6.push('0');
}
s6.parse().map_err(|_| IdentityError::MalformedAuthority("bad fractional seconds".into()))?
};
let days = ymd_to_days(y, mo, d);
let total_secs = days * 86400 + h * 3600 + mi * 60 + se;
Ok(total_secs * 1_000_000 + micros_frac)
}
fn rfc3339_from_micros(micros: i64) -> String {
let secs = micros.div_euclid(1_000_000);
let _frac = micros.rem_euclid(1_000_000);
let days = secs.div_euclid(86400);
let s = secs.rem_euclid(86400);
let (y, mo, d) = days_to_ymd(days);
let h = s / 3600;
let mi = (s % 3600) / 60;
let se = s % 60;
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, se)
}
fn ymd_to_days(mut y: i64, mut m: u32, d: u32) -> i64 {
if m <= 2 {
y -= 1;
m += 12;
}
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = (y - era * 400) as i64;
let doy = (153 * (m as i64 - 3) + 2) / 5 + d as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}
fn days_to_ymd(mut days: i64) -> (i32, u32, u32) {
days += 719468;
let era = days.div_euclid(146097);
let doe = days.rem_euclid(146097);
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = y + if m <= 2 { 1 } else { 0 };
(y as i32, m, d)
}
fn encode_policy_value(v: &toml::Value) -> Vec<u8> {
match v {
toml::Value::Boolean(true) => vec![0x01],
toml::Value::Boolean(false) => vec![0x00],
toml::Value::String(s) => s.as_bytes().to_vec(),
toml::Value::Integer(i) => i.to_string().into_bytes(),
toml::Value::Float(f) => f.to_string().into_bytes(),
toml::Value::Array(arr) => {
// Policy arrays are joined by commas (used for allowed_handlers,
// protected_branches).
let parts: Vec<String> = arr
.iter()
.map(|v| match v {
toml::Value::String(s) => s.clone(),
other => other.to_string(),
})
.collect();
parts.join(",").into_bytes()
}
other => other.to_string().into_bytes(),
}
}
fn decode_policy_value(key: &str, bytes: &[u8]) -> toml::Value {
match key {
"public_read" | "require_signed_releases" => match bytes {
[0x01] => toml::Value::Boolean(true),
[0x00] => toml::Value::Boolean(false),
_ => toml::Value::String(hex::encode(bytes)),
},
_ => match std::str::from_utf8(bytes) {
Ok(s) => toml::Value::String(s.to_string()),
Err(_) => toml::Value::String(hex::encode(bytes)),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keys::SecretKey;
#[test]
fn binary_roundtrip() {
let sk = SecretKey::generate();
let pk = sk.public();
let mut body = AuthorityBody {
schema_version: 1,
repo_id: ZERO_ID,
previous_authority: ZERO_ID,
version: 1,
created_micros: 1_700_000_000_000_000,
members: vec![MemberEntry {
key: pk,
handle: "alice".into(),
role: Role::Owner,
added_micros: 1_700_000_000_000_000,
added_by: pk,
}],
policy: vec![
PolicyEntry { key: "public_read".into(), value: vec![0x01] },
PolicyEntry { key: "allowed_handlers".into(), value: b"builtin".to_vec() },
],
};
body.normalize().unwrap();
body.assign_genesis_repo_id().unwrap();
let bytes = body.encode().unwrap();
let decoded = AuthorityBody::parse(&bytes).unwrap();
assert_eq!(decoded, body);
}
#[test]
fn toml_roundtrip() {
let sk = SecretKey::generate();
let pk = sk.public();
let mut body = AuthorityBody {
schema_version: 1,
repo_id: ZERO_ID,
previous_authority: ZERO_ID,
version: 1,
created_micros: 1_700_000_000_000_000,
members: vec![MemberEntry {
key: pk,
handle: "alice".into(),
role: Role::Owner,
added_micros: 1_700_000_000_000_000,
added_by: pk,
}],
policy: vec![PolicyEntry { key: "public_read".into(), value: vec![0x01] }],
};
body.assign_genesis_repo_id().unwrap();
let toml_text = render_toml_authority(&body).unwrap();
let parsed = parse_toml_authority(&toml_text).unwrap();
assert_eq!(parsed.encode().unwrap(), body.encode().unwrap());
}
}

View File

@ -0,0 +1,51 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum IdentityError {
#[error(transparent)]
Core(#[from] levcs_core::Error),
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("toml parse: {0}")]
TomlParse(#[from] toml::de::Error),
#[error("toml encode: {0}")]
TomlEncode(#[from] toml::ser::Error),
#[error("invalid key encoding: {0}")]
InvalidKey(String),
#[error("hex decode: {0}")]
Hex(#[from] hex::FromHexError),
#[error("base64 decode: {0}")]
Base64(String),
#[error("crypto error: {0}")]
Crypto(String),
#[error("malformed authority: {0}")]
MalformedAuthority(String),
#[error("unknown key label: {0}")]
UnknownKey(String),
#[error("ed25519 verify failed")]
BadSignature,
#[error("encrypted key requires passphrase")]
EncryptedKey,
#[error("argon2: {0}")]
Argon2(String),
#[error("zero hash where expected non-zero")]
UnexpectedZeroHash,
#[error("{0}")]
Other(String),
}
pub type Result<T> = std::result::Result<T, IdentityError>;

View File

@ -0,0 +1,346 @@
//! Keychain file at `$XDG_CONFIG_HOME/levcs/keys.toml` (or per platform).
//! Entries may be plaintext (`private = "ed25519:..."`) or encrypted
//! (`private_encrypted = { ... }` with XChaCha20-Poly1305 + Argon2id).
use std::fs;
use std::path::{Path, PathBuf};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use serde::{Deserialize, Serialize};
use crate::error::{IdentityError, Result};
use crate::keys::{PublicKey, SecretKey};
pub const KEYCHAIN_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Keychain {
pub schema_version: u32,
#[serde(rename = "key", default)]
pub keys: Vec<KeychainEntry>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeychainEntry {
pub label: String,
pub public: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub private: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub private_encrypted: Option<EncryptedKey>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EncryptedKey {
pub algorithm: String,
pub kdf_params: KdfParams,
pub ciphertext: String,
/// 24-byte XChaCha20 nonce, base64-encoded.
pub nonce: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KdfParams {
pub salt: String,
pub memory: u32,
pub iterations: u32,
pub parallelism: u32,
}
impl Default for KdfParams {
fn default() -> Self {
Self {
salt: String::new(),
// OWASP-recommended Argon2id minimums (m=19 MiB, t=2, p=1).
memory: 19 * 1024,
iterations: 2,
parallelism: 1,
}
}
}
impl Keychain {
pub fn new() -> Self {
Self { schema_version: KEYCHAIN_SCHEMA_VERSION, keys: Vec::new() }
}
pub fn default_path() -> PathBuf {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
PathBuf::from(xdg).join("levcs").join("keys.toml")
} else if let Some(home) = std::env::var_os("HOME") {
PathBuf::from(home).join(".config").join("levcs").join("keys.toml")
} else {
PathBuf::from("/tmp").join("levcs").join("keys.toml")
}
}
pub fn load_or_default(path: &Path) -> Result<Self> {
match fs::read_to_string(path) {
Ok(s) => Ok(toml::from_str(&s)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::new()),
Err(e) => Err(e.into()),
}
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(parent)?.permissions();
let mode = perms.mode() & 0o777;
if mode & 0o077 != 0 {
perms.set_mode(0o700);
let _ = fs::set_permissions(parent, perms);
}
}
}
let text = toml::to_string(self)?;
fs::write(path, text)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(0o600);
fs::set_permissions(path, perms)?;
}
Ok(())
}
pub fn entry(&self, label: &str) -> Option<&KeychainEntry> {
self.keys.iter().find(|k| k.label == label)
}
pub fn entry_mut(&mut self, label: &str) -> Option<&mut KeychainEntry> {
self.keys.iter_mut().find(|k| k.label == label)
}
pub fn public(&self, label: &str) -> Result<PublicKey> {
let e = self
.entry(label)
.ok_or_else(|| IdentityError::UnknownKey(label.to_string()))?;
PublicKey::parse_levcs(&e.public)
}
/// Decrypt or load the secret seed for `label`. If the key is encrypted
/// the passphrase callback is used.
pub fn secret(
&self,
label: &str,
mut passphrase: impl FnMut() -> Result<String>,
) -> Result<SecretKey> {
let e = self
.entry(label)
.ok_or_else(|| IdentityError::UnknownKey(label.to_string()))?;
if let Some(s) = &e.private {
return SecretKey::parse_levcs(s);
}
if let Some(enc) = &e.private_encrypted {
let pp = passphrase()?;
return decrypt_secret(enc, pp.as_bytes());
}
Err(IdentityError::Other(
"key entry has neither plaintext nor encrypted private material".into(),
))
}
pub fn add_plaintext(&mut self, label: &str, sk: &SecretKey) -> Result<()> {
if self.entry(label).is_some() {
return Err(IdentityError::Other(format!("key already exists: {label}")));
}
self.keys.push(KeychainEntry {
label: label.to_string(),
public: sk.public().to_levcs(),
private: Some(sk.to_levcs()),
private_encrypted: None,
created: Some(now_rfc3339()),
});
Ok(())
}
pub fn add_encrypted(
&mut self,
label: &str,
sk: &SecretKey,
passphrase: &[u8],
) -> Result<()> {
if self.entry(label).is_some() {
return Err(IdentityError::Other(format!("key already exists: {label}")));
}
let enc = encrypt_secret(sk, passphrase)?;
self.keys.push(KeychainEntry {
label: label.to_string(),
public: sk.public().to_levcs(),
private: None,
private_encrypted: Some(enc),
created: Some(now_rfc3339()),
});
Ok(())
}
pub fn remove(&mut self, label: &str) -> Result<()> {
let pos = self
.keys
.iter()
.position(|k| k.label == label)
.ok_or_else(|| IdentityError::UnknownKey(label.into()))?;
self.keys.remove(pos);
Ok(())
}
pub fn rename(&mut self, old: &str, new: &str) -> Result<()> {
if self.entry(new).is_some() {
return Err(IdentityError::Other(format!("destination already exists: {new}")));
}
let e = self
.entry_mut(old)
.ok_or_else(|| IdentityError::UnknownKey(old.into()))?;
e.label = new.to_string();
Ok(())
}
}
fn now_rfc3339() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let dur = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
// crude RFC3339 (no tzdb dependency): seconds since epoch as Z time.
let secs = dur.as_secs() as i64;
// y/m/d via integer math.
let (y, mo, d) = days_to_ymd(secs / 86400);
let s = secs.rem_euclid(86400);
let h = s / 3600;
let mi = (s % 3600) / 60;
let se = s % 60;
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, se)
}
fn days_to_ymd(mut days: i64) -> (i32, u32, u32) {
// Days since 1970-01-01.
days += 719468;
let era = days.div_euclid(146097);
let doe = days.rem_euclid(146097);
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = y + if m <= 2 { 1 } else { 0 };
(y as i32, m, d)
}
fn encrypt_secret(sk: &SecretKey, passphrase: &[u8]) -> Result<EncryptedKey> {
use chacha20poly1305::{
aead::{Aead, KeyInit},
XChaCha20Poly1305, XNonce,
};
use rand_core::{OsRng, RngCore};
let mut salt = [0u8; 16];
OsRng.fill_bytes(&mut salt);
let mut nonce = [0u8; 24];
OsRng.fill_bytes(&mut nonce);
let params = KdfParams {
salt: B64.encode(salt),
..KdfParams::default()
};
let key = derive_key(passphrase, &salt, &params)?;
let cipher = XChaCha20Poly1305::new((&key).into());
let ciphertext = cipher
.encrypt(XNonce::from_slice(&nonce), sk.seed().as_ref())
.map_err(|e| IdentityError::Crypto(format!("encrypt: {e}")))?;
Ok(EncryptedKey {
algorithm: "xchacha20poly1305-argon2id".into(),
kdf_params: params,
ciphertext: B64.encode(ciphertext),
nonce: B64.encode(nonce),
})
}
fn decrypt_secret(enc: &EncryptedKey, passphrase: &[u8]) -> Result<SecretKey> {
use chacha20poly1305::{
aead::{Aead, KeyInit},
XChaCha20Poly1305, XNonce,
};
if enc.algorithm != "xchacha20poly1305-argon2id" {
return Err(IdentityError::Crypto(format!(
"unknown algorithm: {}", enc.algorithm
)));
}
let salt = B64
.decode(enc.kdf_params.salt.as_bytes())
.map_err(|e| IdentityError::Base64(e.to_string()))?;
let nonce_bytes = B64
.decode(enc.nonce.as_bytes())
.map_err(|e| IdentityError::Base64(e.to_string()))?;
if nonce_bytes.len() != 24 {
return Err(IdentityError::Crypto("nonce wrong length".into()));
}
let ciphertext = B64
.decode(enc.ciphertext.as_bytes())
.map_err(|e| IdentityError::Base64(e.to_string()))?;
let key = derive_key(passphrase, &salt, &enc.kdf_params)?;
let cipher = XChaCha20Poly1305::new((&key).into());
let plaintext = cipher
.decrypt(XNonce::from_slice(&nonce_bytes), ciphertext.as_ref())
.map_err(|e| IdentityError::Crypto(format!("decrypt: {e}")))?;
if plaintext.len() != 32 {
return Err(IdentityError::Crypto("seed wrong length".into()));
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&plaintext);
Ok(SecretKey::from_seed(seed))
}
fn derive_key(passphrase: &[u8], salt: &[u8], params: &KdfParams) -> Result<[u8; 32]> {
use argon2::{Algorithm, Argon2, Params, Version};
let p = Params::new(params.memory, params.iterations, params.parallelism, Some(32))
.map_err(|e| IdentityError::Argon2(e.to_string()))?;
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, p);
let mut out = [0u8; 32];
argon
.hash_password_into(passphrase, salt, &mut out)
.map_err(|e| IdentityError::Argon2(e.to_string()))?;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keychain_plaintext_roundtrip() {
let mut kc = Keychain::new();
let sk = SecretKey::generate();
kc.add_plaintext("personal", &sk).unwrap();
let s = toml::to_string(&kc).unwrap();
let kc2: Keychain = toml::from_str(&s).unwrap();
let pk2 = kc2.public("personal").unwrap();
assert_eq!(pk2, sk.public());
}
#[test]
fn keychain_encryption_roundtrip() {
let mut kc = Keychain::new();
let sk = SecretKey::generate();
kc.add_encrypted("locked", &sk, b"correct horse battery staple").unwrap();
let s = toml::to_string(&kc).unwrap();
let kc2: Keychain = toml::from_str(&s).unwrap();
let unlocked = kc2
.secret("locked", || Ok("correct horse battery staple".into()))
.unwrap();
assert_eq!(unlocked.seed(), sk.seed());
}
#[test]
fn wrong_passphrase_fails() {
let mut kc = Keychain::new();
let sk = SecretKey::generate();
kc.add_encrypted("locked", &sk, b"good").unwrap();
let result = kc.secret("locked", || Ok("bad".into()));
assert!(result.is_err());
}
}

View File

@ -0,0 +1,146 @@
//! Ed25519 key wrappers used throughout LeVCS. Public keys are 32-byte
//! arrays; secret keys are 32-byte seeds, expanded as needed.
use std::fmt;
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use rand_core::{OsRng, RngCore};
use zeroize::Zeroize;
use crate::error::{IdentityError, Result};
/// 32-byte raw Ed25519 public key.
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
pub struct PublicKey(pub [u8; 32]);
impl PublicKey {
pub fn from_bytes(b: [u8; 32]) -> Self { Self(b) }
pub fn as_bytes(&self) -> &[u8; 32] { &self.0 }
pub fn to_levcs(&self) -> String { format!("ed25519:{}", hex::encode(self.0)) }
pub fn parse_levcs(s: &str) -> Result<Self> {
let rest = s
.strip_prefix("ed25519:")
.ok_or_else(|| IdentityError::InvalidKey(format!("missing ed25519: prefix in {s}")))?;
let bytes = hex::decode(rest)?;
if bytes.len() != 32 {
return Err(IdentityError::InvalidKey(format!(
"expected 32 bytes, got {}", bytes.len()
)));
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Ok(Self(arr))
}
pub fn verify(&self, msg: &[u8], signature: &[u8; 64]) -> Result<()> {
let vk = VerifyingKey::from_bytes(&self.0)
.map_err(|e| IdentityError::Crypto(format!("public key: {e}")))?;
let sig = Signature::from_bytes(signature);
vk.verify(msg, &sig).map_err(|_| IdentityError::BadSignature)?;
Ok(())
}
}
impl fmt::Debug for PublicKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "PublicKey({})", self.to_levcs())
}
}
impl fmt::Display for PublicKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_levcs())
}
}
/// 32-byte secret seed. Wrapped to keep zeroization centralized and to
/// prevent accidental Display/Debug leaks.
pub struct SecretKey {
seed: [u8; 32],
}
impl SecretKey {
pub fn from_seed(seed: [u8; 32]) -> Self { Self { seed } }
pub fn generate() -> Self {
let mut seed = [0u8; 32];
OsRng.fill_bytes(&mut seed);
Self { seed }
}
pub fn seed(&self) -> &[u8; 32] { &self.seed }
pub fn public(&self) -> PublicKey {
let sk = SigningKey::from_bytes(&self.seed);
PublicKey(sk.verifying_key().to_bytes())
}
pub fn sign(&self, msg: &[u8]) -> [u8; 64] {
let sk = SigningKey::from_bytes(&self.seed);
let sig: Signature = sk.sign(msg);
sig.to_bytes()
}
pub fn to_levcs(&self) -> String { format!("ed25519:{}", hex::encode(self.seed)) }
pub fn parse_levcs(s: &str) -> Result<Self> {
let rest = s
.strip_prefix("ed25519:")
.ok_or_else(|| IdentityError::InvalidKey("missing ed25519: prefix".into()))?;
let bytes = hex::decode(rest)?;
if bytes.len() != 32 {
return Err(IdentityError::InvalidKey(format!(
"expected 32 bytes, got {}", bytes.len()
)));
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&bytes);
Ok(Self { seed })
}
}
impl fmt::Debug for SecretKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "SecretKey(<redacted>)")
}
}
impl Drop for SecretKey {
fn drop(&mut self) {
self.seed.zeroize();
}
}
/// Human-readable key label used in the keychain (e.g., "personal").
pub type KeyLabel = String;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sign_and_verify() {
let sk = SecretKey::generate();
let pk = sk.public();
let msg = b"hello LeVCS";
let sig = sk.sign(msg);
pk.verify(msg, &sig).unwrap();
// Tampered message rejected.
let mut tampered = msg.to_vec();
tampered[0] ^= 1;
assert!(pk.verify(&tampered, &sig).is_err());
}
#[test]
fn key_string_roundtrip() {
let sk = SecretKey::generate();
let pk = sk.public();
let pk2 = PublicKey::parse_levcs(&pk.to_levcs()).unwrap();
assert_eq!(pk, pk2);
let sk2 = SecretKey::parse_levcs(&sk.to_levcs()).unwrap();
assert_eq!(sk.seed(), sk2.seed());
}
}

View File

@ -0,0 +1,21 @@
//! levcs-identity: keychains, authority objects, signing, verification.
pub mod error;
pub mod keys;
pub mod keychain;
pub mod authority;
pub mod sign;
pub mod verify;
pub use error::IdentityError;
pub use keys::{KeyLabel, PublicKey, SecretKey};
pub use keychain::{Keychain, KeychainEntry};
pub use authority::{
AuthorityBody, MemberEntry, PolicyEntry, Role, AUTHORITY_SCHEMA_VERSION,
parse_toml_authority, render_toml_authority,
};
pub use sign::{sign_commit, sign_release, sign_authority, sign_message};
pub use verify::{
verify_signed_object, verify_commit, verify_authority_chain, verify_genesis,
Verification, VerifyError,
};

View File

@ -0,0 +1,60 @@
//! Signing helpers for commits, releases, and authority objects.
use levcs_core::object::{ObjectType, SignatureEntry, SignedObject};
use levcs_core::{Commit, Release};
use crate::authority::AuthorityBody;
use crate::error::{IdentityError, Result};
use crate::keys::SecretKey;
/// Sign an arbitrary message with `sk` and return the 64-byte signature.
pub fn sign_message(sk: &SecretKey, msg: &[u8]) -> [u8; 64] {
sk.sign(msg)
}
/// Sign a commit, returning a SignedObject with exactly one signature entry.
pub fn sign_commit(commit: Commit, sk: &SecretKey) -> Result<SignedObject> {
if sk.public().0 != commit.author_key {
return Err(IdentityError::Other(
"secret key does not match commit author_key".into(),
));
}
let mut signed = commit.into_signed().map_err(IdentityError::from)?;
let h = signed.signing_hash();
let signature = sk.sign(h.as_bytes());
signed.signatures.push(SignatureEntry { public_key: sk.public().0, signature });
Ok(signed)
}
/// Sign a release. The first signature is the declarer; additional signatures
/// can be added with `add_cosigner_signature`.
pub fn sign_release(release: Release, sk: &SecretKey) -> Result<SignedObject> {
if sk.public().0 != release.declarer_key {
return Err(IdentityError::Other(
"secret key does not match release declarer_key".into(),
));
}
let mut signed = release.into_signed().map_err(IdentityError::from)?;
let h = signed.signing_hash();
let signature = sk.sign(h.as_bytes());
signed.signatures.push(SignatureEntry { public_key: sk.public().0, signature });
Ok(signed)
}
/// Sign an authority object, appending the signature to the trailer.
pub fn sign_authority(body: &AuthorityBody, sk: &SecretKey) -> Result<SignedObject> {
let mut signed = body.to_signed()?;
debug_assert_eq!(signed.object_type, ObjectType::Authority);
let h = signed.signing_hash();
let signature = sk.sign(h.as_bytes());
signed.signatures.push(SignatureEntry { public_key: sk.public().0, signature });
Ok(signed)
}
/// Append an additional signature to an already-signed object (used for
/// release cosignatures and threshold authorities).
pub fn add_cosigner_signature(signed: &mut SignedObject, sk: &SecretKey) {
let h = signed.signing_hash();
let signature = sk.sign(h.as_bytes());
signed.signatures.push(SignatureEntry { public_key: sk.public().0, signature });
}

View File

@ -0,0 +1,717 @@
//! Verification algorithm from the v1.1 trust-root revision §3.6.
//!
//! Provides:
//! - `verify_signed_object`: per-object signature check.
//! - `verify_genesis`: validates a candidate genesis authority.
//! - `verify_authority_chain`: walks back to genesis verifying each step.
//! - `verify_commit`: full commit verification including authority chain.
//!
//! The verifier accepts any object source that implements `ObjectSource` so
//! the same logic works against an `ObjectStore`, an in-memory map, or a
//! remote-fetching shim.
use std::collections::HashMap;
use thiserror::Error;
use levcs_core::object::{ObjectType, SignatureEntry, SignedObject};
use levcs_core::{ObjectId, RawObject, Tree};
use crate::authority::{AuthorityBody, Role};
use crate::error::IdentityError;
use crate::keys::PublicKey;
#[derive(Debug, Error)]
pub enum VerifyError {
#[error("object {hash}: {kind}")]
Object { hash: String, kind: String },
#[error("authority chain: {0}")]
Authority(String),
#[error("commit {hash}: {reason}")]
Commit { hash: String, reason: String },
#[error("identity error: {0}")]
Identity(#[from] IdentityError),
#[error(transparent)]
Core(#[from] levcs_core::Error),
#[error("missing object: {0}")]
Missing(String),
}
pub type Verification<T> = std::result::Result<T, VerifyError>;
/// Trait implemented by anything that can supply objects by hash.
pub trait ObjectSource {
fn read_raw(&self, id: ObjectId) -> Verification<Vec<u8>>;
}
impl ObjectSource for levcs_core::ObjectStore {
fn read_raw(&self, id: ObjectId) -> Verification<Vec<u8>> {
Ok(self.read_raw(id)?)
}
}
/// In-memory object source for unit tests.
pub struct MemorySource(pub HashMap<ObjectId, Vec<u8>>);
impl ObjectSource for MemorySource {
fn read_raw(&self, id: ObjectId) -> Verification<Vec<u8>> {
self.0
.get(&id)
.cloned()
.ok_or_else(|| VerifyError::Missing(id.to_hex()))
}
}
fn read_signed<S: ObjectSource>(src: &S, id: ObjectId) -> Verification<SignedObject> {
let bytes = src.read_raw(id)?;
Ok(SignedObject::parse(&bytes).map_err(|e| VerifyError::Object {
hash: id.to_hex(),
kind: e.to_string(),
})?)
}
/// Verify the signature(s) on a SignedObject. Each signature in the trailer
/// is checked against `BLAKE3(header || body)`. Returns Ok if every
/// signature is valid; the policy (role checks, count constraints) is
/// enforced by the caller.
pub fn verify_signed_object(signed: &SignedObject) -> Verification<()> {
let h = signed.signing_hash();
for s in &signed.signatures {
let pk = PublicKey(s.public_key);
pk.verify(h.as_bytes(), &s.signature)
.map_err(IdentityError::from)?;
}
Ok(())
}
/// Verify that `genesis` is a well-formed genesis authority object, including
/// repo_id derivation and self-signature by an owner.
pub fn verify_genesis(genesis: &SignedObject) -> Verification<AuthorityBody> {
if genesis.object_type != ObjectType::Authority {
return Err(VerifyError::Authority(format!(
"expected authority object, got {}", genesis.object_type.name()
)));
}
let body = AuthorityBody::parse(&genesis.body)
.map_err(|e| VerifyError::Authority(e.to_string()))?;
if !body.previous_authority.is_zero() {
return Err(VerifyError::Authority("genesis must have zero previous_authority".into()));
}
if body.version != 1 {
return Err(VerifyError::Authority("genesis version must be 1".into()));
}
let zeroed = body
.encode_with_repo_id_zero()
.map_err(|e| VerifyError::Authority(e.to_string()))?;
let derived = ObjectId(*blake3::hash(&zeroed).as_bytes());
if derived != body.repo_id {
return Err(VerifyError::Authority(format!(
"repo_id derivation invalid: derived {}, body says {}",
derived, body.repo_id
)));
}
// Genesis must be self-signed by an owner.
let h = genesis.signing_hash();
let mut found = false;
for s in &genesis.signatures {
let pk = PublicKey(s.public_key);
let member = match body.find_member(&pk) {
Some(m) => m,
None => continue,
};
if member.role == Role::Owner && pk.verify(h.as_bytes(), &s.signature).is_ok() {
found = true;
break;
}
}
if !found {
return Err(VerifyError::Authority(
"no valid owner self-signature on genesis".into(),
));
}
Ok(body)
}
fn verify_authority_step(
new_signed: &SignedObject,
new_body: &AuthorityBody,
prev_body: &AuthorityBody,
prev_id: ObjectId,
) -> Verification<()> {
if new_body.repo_id != prev_body.repo_id {
return Err(VerifyError::Authority("repo_id mismatch".into()));
}
if new_body.version != prev_body.version + 1 {
return Err(VerifyError::Authority(format!(
"version not sequential: prev {} -> next {}", prev_body.version, new_body.version
)));
}
if new_body.previous_authority != prev_id {
return Err(VerifyError::Authority(
"previous_authority does not match predecessor hash".into(),
));
}
// At least one signature on the new authority must be by an owner of prev.
let h = new_signed.signing_hash();
let mut found = false;
for s in &new_signed.signatures {
let pk = PublicKey(s.public_key);
let member = match prev_body.find_member(&pk) {
Some(m) => m,
None => continue,
};
if member.role == Role::Owner && pk.verify(h.as_bytes(), &s.signature).is_ok() {
found = true;
break;
}
}
if !found {
return Err(VerifyError::Authority(
"no valid owner signature on successor authority".into(),
));
}
Ok(())
}
/// Walk an authority back to genesis, verifying each step. Returns the body
/// of the genesis authority on success.
pub fn verify_authority_chain<S: ObjectSource>(
src: &S,
start: ObjectId,
) -> Verification<AuthorityBody> {
let mut cur_id = start;
let mut cur_signed = read_signed(src, cur_id)?;
let mut cur_body = AuthorityBody::parse(&cur_signed.body)
.map_err(|e| VerifyError::Authority(e.to_string()))?;
while !cur_body.previous_authority.is_zero() {
let prev_id = cur_body.previous_authority;
let prev_signed = read_signed(src, prev_id)?;
let prev_body = AuthorityBody::parse(&prev_signed.body)
.map_err(|e| VerifyError::Authority(e.to_string()))?;
verify_authority_step(&cur_signed, &cur_body, &prev_body, prev_id)?;
cur_signed = prev_signed;
cur_body = prev_body;
cur_id = prev_id;
}
let _ = cur_id;
let body = verify_genesis(&cur_signed)?;
Ok(body)
}
/// Verify a successor authority object against `A_old`, given the signer key
/// that will be checked. The signer must be an owner of `A_old` AND must
/// appear among `A_new`'s signatures.
pub fn verify_successor(
a_new: &SignedObject,
a_new_body: &AuthorityBody,
a_old_id: ObjectId,
a_old_body: &AuthorityBody,
signer: PublicKey,
) -> Verification<()> {
if a_new_body.previous_authority != a_old_id {
return Err(VerifyError::Authority(
"successor previous_authority != hash(A_old)".into(),
));
}
if a_new_body.version != a_old_body.version + 1 {
return Err(VerifyError::Authority("version not sequential".into()));
}
if a_new_body.repo_id != a_old_body.repo_id {
return Err(VerifyError::Authority("repo_id mismatch".into()));
}
let m = a_old_body
.find_member(&signer)
.ok_or_else(|| VerifyError::Authority("signer not in predecessor authority".into()))?;
if m.role < Role::Owner {
return Err(VerifyError::Authority(
"signer must hold owner role in predecessor".into(),
));
}
let found_in_new = a_new
.signatures
.iter()
.any(|s| s.public_key == signer.0);
if !found_in_new {
return Err(VerifyError::Authority(
"predecessor owner must also sign the new authority".into(),
));
}
Ok(())
}
/// Verify a fork-genesis authority pair (per §3.5). `a_source_body` is the
/// source repository's authority at the parent commit; `a_new` is the fork's
/// new genesis.
pub fn verify_fork(
fork_author: PublicKey,
a_source_body: &AuthorityBody,
a_new: &SignedObject,
a_new_body: &AuthorityBody,
) -> Verification<()> {
let _ = verify_genesis(a_new)?;
if a_new_body.repo_id == a_source_body.repo_id {
return Err(VerifyError::Authority("fork must have new repo_id".into()));
}
if a_source_body.public_read() {
return Ok(());
}
let m = a_source_body
.find_member(&fork_author)
.ok_or_else(|| VerifyError::Authority("fork author not authorized to read source".into()))?;
if m.role < Role::Reader {
return Err(VerifyError::Authority("fork author lacks reader role".into()));
}
Ok(())
}
/// Determine the role required for a commit given the active authority. In
/// v1.1 this is:
/// - Owner if the commit modifies authority (flag bit 0 or fork bit).
/// - Maintainer if the target ref matches a `protected_branches` glob.
/// - Contributor otherwise.
pub fn role_for_commit(
flags: levcs_core::CommitFlags,
authority: &AuthorityBody,
target_ref: Option<&str>,
) -> Role {
if flags.modifies_authority() || flags.is_fork() {
return Role::Owner;
}
if let Some(name) = target_ref {
for pat in authority.protected_branches() {
if glob::Pattern::new(&pat)
.map(|p| p.matches(name))
.unwrap_or(false)
{
return Role::Maintainer;
}
}
}
Role::Contributor
}
/// Full commit verification per §3.6 algorithm. `target_ref` is the ref the
/// commit is being applied to (used for protected-branch role checks); pass
/// `None` if not applicable (e.g., during walking).
pub fn verify_commit<S: ObjectSource>(
src: &S,
commit_id: ObjectId,
target_ref: Option<&str>,
) -> Verification<()> {
let bytes = src.read_raw(commit_id)?;
let actual = blake3::hash(&bytes);
if *actual.as_bytes() != commit_id.0 {
return Err(VerifyError::Object {
hash: commit_id.to_hex(),
kind: "stored bytes do not match expected hash".into(),
});
}
let signed = SignedObject::parse(&bytes).map_err(|e| VerifyError::Object {
hash: commit_id.to_hex(),
kind: e.to_string(),
})?;
if signed.object_type != ObjectType::Commit {
return Err(VerifyError::Commit {
hash: commit_id.to_hex(),
reason: format!("expected commit, got {}", signed.object_type.name()),
});
}
if signed.signatures.len() != 1 {
return Err(VerifyError::Commit {
hash: commit_id.to_hex(),
reason: format!("commit must have 1 signature, got {}", signed.signatures.len()),
});
}
let sig = signed.signatures[0];
let commit = levcs_core::Commit::from_signed(&signed).map_err(|e| VerifyError::Commit {
hash: commit_id.to_hex(),
reason: e.to_string(),
})?;
if sig.public_key != commit.author_key {
return Err(VerifyError::Commit {
hash: commit_id.to_hex(),
reason: "author_key does not match trailer signature key".into(),
});
}
let pk = PublicKey(sig.public_key);
pk.verify(signed.signing_hash().as_bytes(), &sig.signature)
.map_err(|_| VerifyError::Commit {
hash: commit_id.to_hex(),
reason: "Ed25519 signature invalid".into(),
})?;
// Verify authority chain.
let auth_signed = read_signed(src, commit.authority)?;
let auth_body = AuthorityBody::parse(&auth_signed.body)
.map_err(|e| VerifyError::Authority(e.to_string()))?;
let _ = verify_authority_chain(src, commit.authority)?;
let member = auth_body
.find_member(&pk)
.ok_or_else(|| VerifyError::Commit {
hash: commit_id.to_hex(),
reason: "author not in authority".into(),
})?;
let required = role_for_commit(commit.flags, &auth_body, target_ref);
if member.role < required {
return Err(VerifyError::Commit {
hash: commit_id.to_hex(),
reason: format!(
"insufficient role: have {}, need {}", member.role.name(), required.name()
),
});
}
if commit.flags.modifies_authority() || commit.flags.is_fork() {
// The new authority must be reachable via the tree at .levcs/authority.
let new_auth_id = locate_new_authority(src, commit.tree)?;
let new_signed = read_signed(src, new_auth_id)?;
let new_body = AuthorityBody::parse(&new_signed.body)
.map_err(|e| VerifyError::Authority(e.to_string()))?;
verify_signed_object(&new_signed)?;
if commit.flags.is_fork() {
// Per §3.5.3, fork-commit signature is verified against the new
// genesis authority (already done above via auth_body, which for
// fork commits is the new authority since C.body.authority points
// to the new genesis). For *read authorization* against the
// source, look up the parent commit's authority.
if commit.parents.len() != 1 {
return Err(VerifyError::Commit {
hash: commit_id.to_hex(),
reason: format!(
"fork commit must have exactly 1 parent, got {}",
commit.parents.len()
),
});
}
let parent_signed = read_signed(src, commit.parents[0])?;
let parent_commit =
levcs_core::Commit::from_signed(&parent_signed).map_err(|e| {
VerifyError::Commit { hash: commit_id.to_hex(), reason: e.to_string() }
})?;
let source_auth_signed = read_signed(src, parent_commit.authority)?;
let source_auth_body = AuthorityBody::parse(&source_auth_signed.body)
.map_err(|e| VerifyError::Authority(e.to_string()))?;
verify_fork(pk, &source_auth_body, &new_signed, &new_body)?;
} else {
verify_successor(&new_signed, &new_body, commit.authority, &auth_body, pk)?;
}
}
Ok(())
}
/// Verify a release object: check stored bytes hash to the claimed id,
/// validate the signed envelope, parse the release body, walk the
/// authority chain it cites back to genesis, and confirm every signing
/// key is a member of that authority.
///
/// Releases (§4.1) don't have an "author key" trailer the way commits do;
/// the signers are simply the authority members who minted it. So the
/// membership check loops over `signed.signatures` and pins each one
/// against the authority body. A release with no listed members signing
/// it is rejected.
pub fn verify_release<S: ObjectSource>(src: &S, release_id: ObjectId) -> Verification<()> {
let bytes = src.read_raw(release_id)?;
let actual = blake3::hash(&bytes);
if *actual.as_bytes() != release_id.0 {
return Err(VerifyError::Object {
hash: release_id.to_hex(),
kind: "stored bytes do not match expected hash".into(),
});
}
let signed = SignedObject::parse(&bytes).map_err(|e| VerifyError::Object {
hash: release_id.to_hex(),
kind: e.to_string(),
})?;
if signed.object_type != ObjectType::Release {
return Err(VerifyError::Object {
hash: release_id.to_hex(),
kind: format!("expected release, got {}", signed.object_type.name()),
});
}
if signed.signatures.is_empty() {
return Err(VerifyError::Object {
hash: release_id.to_hex(),
kind: "release has no signatures".into(),
});
}
verify_signed_object(&signed)?;
let release = levcs_core::Release::parse_body(&signed.body).map_err(|e| {
VerifyError::Object { hash: release_id.to_hex(), kind: e.to_string() }
})?;
let _ = verify_authority_chain(src, release.authority)?;
let auth_signed = read_signed(src, release.authority)?;
let auth_body = AuthorityBody::parse(&auth_signed.body)
.map_err(|e| VerifyError::Authority(e.to_string()))?;
for s in &signed.signatures {
let pk = PublicKey(s.public_key);
if auth_body.find_member(&pk).is_none() {
return Err(VerifyError::Object {
hash: release_id.to_hex(),
kind: "release signed by key not in authority".into(),
});
}
}
Ok(())
}
fn locate_new_authority<S: ObjectSource>(src: &S, tree_id: ObjectId) -> Verification<ObjectId> {
let raw = parse_raw(src, tree_id, ObjectType::Tree)?;
let tree = Tree::parse_body(&raw.body).map_err(|e| VerifyError::Object {
hash: tree_id.to_hex(),
kind: e.to_string(),
})?;
let levcs_dir = tree.find(".levcs").ok_or_else(|| VerifyError::Commit {
hash: tree_id.to_hex(),
reason: "tree has no .levcs directory".into(),
})?;
if levcs_dir.entry_type != levcs_core::EntryType::Tree {
return Err(VerifyError::Commit {
hash: tree_id.to_hex(),
reason: ".levcs is not a tree".into(),
});
}
let inner = parse_raw(src, levcs_dir.hash, ObjectType::Tree)?;
let inner_tree = Tree::parse_body(&inner.body).map_err(|e| VerifyError::Object {
hash: levcs_dir.hash.to_hex(),
kind: e.to_string(),
})?;
let auth = inner_tree
.find("authority")
.ok_or_else(|| VerifyError::Commit {
hash: levcs_dir.hash.to_hex(),
reason: "tree has no .levcs/authority entry".into(),
})?;
Ok(auth.hash)
}
fn parse_raw<S: ObjectSource>(
src: &S,
id: ObjectId,
expect: ObjectType,
) -> Verification<RawObject> {
let bytes = src.read_raw(id)?;
let actual = blake3::hash(&bytes);
if *actual.as_bytes() != id.0 {
return Err(VerifyError::Object {
hash: id.to_hex(),
kind: "hash mismatch".into(),
});
}
let raw = RawObject::parse(&bytes).map_err(|e| VerifyError::Object {
hash: id.to_hex(),
kind: e.to_string(),
})?;
if raw.object_type != expect {
return Err(VerifyError::Object {
hash: id.to_hex(),
kind: format!("expected {}, got {}", expect.name(), raw.object_type.name()),
});
}
Ok(raw)
}
/// Used by the trailer count check at object boundaries.
#[allow(dead_code)]
pub(crate) fn entry_eq(a: &SignatureEntry, b: &SignatureEntry) -> bool {
a.public_key == b.public_key && a.signature == b.signature
}
#[cfg(test)]
mod tests {
use super::*;
use crate::keys::SecretKey;
use crate::sign::sign_authority;
#[test]
fn genesis_verifies() {
let sk = SecretKey::generate();
let pk = sk.public();
let mut body = AuthorityBody {
schema_version: 1,
repo_id: ObjectId([0u8; 32]),
previous_authority: ObjectId([0u8; 32]),
version: 1,
created_micros: 1_700_000_000_000_000,
members: vec![crate::authority::MemberEntry {
key: pk,
handle: "alice".into(),
role: Role::Owner,
added_micros: 1_700_000_000_000_000,
added_by: pk,
}],
policy: vec![crate::authority::PolicyEntry {
key: "public_read".into(),
value: vec![0x01],
}],
};
body.assign_genesis_repo_id().unwrap();
let signed = sign_authority(&body, &sk).unwrap();
verify_signed_object(&signed).unwrap();
let body2 = verify_genesis(&signed).unwrap();
assert_eq!(body, body2);
}
#[test]
fn fork_against_public_source_succeeds() {
// Source authority: public_read = true, alice owns it.
let alice = SecretKey::generate();
let alice_pk = alice.public();
let now = 1_700_000_000_000_000;
let mut source = AuthorityBody {
schema_version: 1,
repo_id: ObjectId([0u8; 32]),
previous_authority: ObjectId([0u8; 32]),
version: 1,
created_micros: now,
members: vec![crate::authority::MemberEntry {
key: alice_pk,
handle: "alice".into(),
role: Role::Owner,
added_micros: now,
added_by: alice_pk,
}],
policy: vec![crate::authority::PolicyEntry {
key: "public_read".into(),
value: vec![0x01],
}],
};
source.normalize().unwrap();
source.assign_genesis_repo_id().unwrap();
// Bob (a stranger) creates a fork-genesis.
let bob = SecretKey::generate();
let bob_pk = bob.public();
let mut fork = AuthorityBody {
schema_version: 1,
repo_id: ObjectId([0u8; 32]),
previous_authority: ObjectId([0u8; 32]),
version: 1,
created_micros: now,
members: vec![crate::authority::MemberEntry {
key: bob_pk,
handle: "bob".into(),
role: Role::Owner,
added_micros: now,
added_by: bob_pk,
}],
policy: vec![],
};
fork.normalize().unwrap();
fork.assign_genesis_repo_id().unwrap();
assert_ne!(fork.repo_id, source.repo_id);
let fork_signed = crate::sign::sign_authority(&fork, &bob).unwrap();
verify_fork(bob_pk, &source, &fork_signed, &fork).unwrap();
}
#[test]
fn fork_against_private_source_blocks_strangers() {
let alice = SecretKey::generate();
let alice_pk = alice.public();
let now = 1_700_000_000_000_000;
let mut source = AuthorityBody {
schema_version: 1,
repo_id: ObjectId([0u8; 32]),
previous_authority: ObjectId([0u8; 32]),
version: 1,
created_micros: now,
members: vec![crate::authority::MemberEntry {
key: alice_pk,
handle: "alice".into(),
role: Role::Owner,
added_micros: now,
added_by: alice_pk,
}],
policy: vec![crate::authority::PolicyEntry {
key: "public_read".into(),
value: vec![0x00],
}],
};
source.normalize().unwrap();
source.assign_genesis_repo_id().unwrap();
let bob = SecretKey::generate();
let bob_pk = bob.public();
let mut fork = AuthorityBody {
schema_version: 1,
repo_id: ObjectId([0u8; 32]),
previous_authority: ObjectId([0u8; 32]),
version: 1,
created_micros: now,
members: vec![crate::authority::MemberEntry {
key: bob_pk,
handle: "bob".into(),
role: Role::Owner,
added_micros: now,
added_by: bob_pk,
}],
policy: vec![],
};
fork.normalize().unwrap();
fork.assign_genesis_repo_id().unwrap();
let fork_signed = crate::sign::sign_authority(&fork, &bob).unwrap();
let res = verify_fork(bob_pk, &source, &fork_signed, &fork);
assert!(res.is_err(), "stranger should not be able to fork private source");
}
#[test]
fn fork_with_colliding_repo_id_rejected() {
let alice = SecretKey::generate();
let alice_pk = alice.public();
let now = 1_700_000_000_000_000;
let mut source = AuthorityBody {
schema_version: 1,
repo_id: ObjectId([0u8; 32]),
previous_authority: ObjectId([0u8; 32]),
version: 1,
created_micros: now,
members: vec![crate::authority::MemberEntry {
key: alice_pk,
handle: "alice".into(),
role: Role::Owner,
added_micros: now,
added_by: alice_pk,
}],
policy: vec![crate::authority::PolicyEntry {
key: "public_read".into(),
value: vec![0x01],
}],
};
source.normalize().unwrap();
source.assign_genesis_repo_id().unwrap();
// Pretend the fork derived the same repo_id (impossible in practice
// without a BLAKE3 collision, but the verifier must still reject).
let mut fork = source.clone();
let fork_signed = crate::sign::sign_authority(&fork, &alice).unwrap();
let _ = fork.assign_genesis_repo_id();
let res = verify_fork(alice_pk, &source, &fork_signed, &fork);
assert!(res.is_err(), "colliding repo_id must be rejected");
}
#[test]
fn tampered_genesis_rejected() {
let sk = SecretKey::generate();
let pk = sk.public();
let mut body = AuthorityBody {
schema_version: 1,
repo_id: ObjectId([0u8; 32]),
previous_authority: ObjectId([0u8; 32]),
version: 1,
created_micros: 1_700_000_000_000_000,
members: vec![crate::authority::MemberEntry {
key: pk,
handle: "alice".into(),
role: Role::Owner,
added_micros: 1_700_000_000_000_000,
added_by: pk,
}],
policy: vec![],
};
body.assign_genesis_repo_id().unwrap();
let mut signed = sign_authority(&body, &sk).unwrap();
signed.body[0] ^= 0xFF; // tamper
assert!(verify_signed_object(&signed).is_err());
}
}

View File

@ -0,0 +1,168 @@
//! Robustness/fuzz tests for identity deserializers (§8.2).
//!
//! Mirror of `levcs-core/tests/fuzz.rs`, scoped to authority parsing
//! (binary and TOML) and key string parsers. Same panic-catching
//! discipline: nothing in this crate may panic on hostile bytes.
use std::panic::{catch_unwind, AssertUnwindSafe};
use levcs_identity::authority::{parse_toml_authority, AuthorityBody};
use levcs_identity::keys::{PublicKey, SecretKey};
const ITERS: u32 = 5_000;
fn lcg(state: &mut u64) -> u64 {
*state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
*state
}
fn rand_bytes(seed: &mut u64, n: usize) -> Vec<u8> {
(0..n).map(|_| (lcg(seed) >> 33) as u8).collect()
}
fn rand_size(seed: &mut u64) -> usize {
match lcg(seed) % 5 {
0 => 0,
1 => (lcg(seed) % 16) as usize,
2 => (lcg(seed) % 256) as usize,
3 => 256 + (lcg(seed) % 4096) as usize,
_ => 4096 + (lcg(seed) % 16384) as usize,
}
}
fn assert_no_panic<F, T>(label: &str, seed: u64, input: &[u8], parser: F)
where
F: FnOnce(&[u8]) -> T,
{
let r = catch_unwind(AssertUnwindSafe(|| parser(input)));
if r.is_err() {
panic!(
"{label} panicked on seed {seed:#x}, input len {}: {:02x?}",
input.len(),
&input[..input.len().min(64)]
);
}
}
#[test]
fn authority_body_parse_does_not_panic_on_random_bytes() {
let mut seed = 0x1111_2222_3333_4444u64;
for _ in 0..ITERS {
let n = rand_size(&mut seed);
let bytes = rand_bytes(&mut seed, n);
assert_no_panic("AuthorityBody::parse", seed, &bytes, |b| {
let _ = AuthorityBody::parse(b);
});
}
}
/// Authority bodies have many length-prefixed fields (member count,
/// handle length, policy count, key length, value length). Each is a
/// classic place for a parser to fail to bounds-check. Build inputs with
/// a *valid* prefix (so the parser advances past schema version checks)
/// and then random nonsense — that exercises the inner offset arithmetic.
#[test]
fn authority_body_parse_survives_crafted_length_prefixes() {
let mut seed = 0xfeed_face_8888_1234u64;
for _ in 0..ITERS {
let mut buf = Vec::new();
// schema_version = 1 (so we get past the version check).
buf.extend_from_slice(&1u16.to_le_bytes());
// repo_id (32 bytes), previous_authority (32 bytes).
buf.extend(rand_bytes(&mut seed, 32));
buf.extend(rand_bytes(&mut seed, 32));
// version (u32), created_micros (i64).
buf.extend(rand_bytes(&mut seed, 4));
buf.extend(rand_bytes(&mut seed, 8));
// Crafted member_count: pick a value that may or may not match
// the bytes we'll append.
let mc = (lcg(&mut seed) & 0xff) as u16;
buf.extend_from_slice(&mc.to_le_bytes());
// Append random bytes (probably not enough to satisfy mc members).
let pad = (lcg(&mut seed) % 4096) as usize;
buf.extend(rand_bytes(&mut seed, pad));
assert_no_panic("AuthorityBody::parse(crafted)", seed, &buf, |b| {
let _ = AuthorityBody::parse(b);
});
}
}
#[test]
fn authority_body_round_trip_when_parse_succeeds() {
let mut seed = 0xbeef_dead_4321_8765u64;
for _ in 0..ITERS {
let n = rand_size(&mut seed);
let bytes = rand_bytes(&mut seed, n);
if let Ok(parsed) = AuthorityBody::parse(&bytes) {
// Encode then re-parse — must yield the same body.
let re = parsed.encode().expect("re-encode");
let re_parsed = AuthorityBody::parse(&re).expect("re-parse");
assert_eq!(parsed.repo_id, re_parsed.repo_id);
assert_eq!(parsed.version, re_parsed.version);
assert_eq!(parsed.members.len(), re_parsed.members.len());
assert_eq!(parsed.policy.len(), re_parsed.policy.len());
}
}
}
#[test]
fn parse_toml_authority_does_not_panic() {
let mut seed = 0xfacefade_1357_2468u64;
for _ in 0..ITERS {
let n = rand_size(&mut seed);
let bytes = rand_bytes(&mut seed, n);
// Coerce to UTF-8 best-effort — non-UTF-8 input must still not
// panic, just return an error to the caller.
let text = String::from_utf8_lossy(&bytes);
assert_no_panic("parse_toml_authority", seed, text.as_bytes(), |b| {
let _ = parse_toml_authority(std::str::from_utf8(b).unwrap_or(""));
});
}
}
#[test]
fn key_parsers_do_not_panic_on_random_input() {
let mut seed = 0xa1b2_c3d4_e5f6_0789u64;
for _ in 0..ITERS {
let n = (lcg(&mut seed) % 256) as usize;
let bytes = rand_bytes(&mut seed, n);
let text = String::from_utf8_lossy(&bytes).into_owned();
assert_no_panic("PublicKey::parse_levcs", seed, text.as_bytes(), |b| {
let _ = PublicKey::parse_levcs(std::str::from_utf8(b).unwrap_or(""));
});
assert_no_panic("SecretKey::parse_levcs", seed, text.as_bytes(), |b| {
let _ = SecretKey::parse_levcs(std::str::from_utf8(b).unwrap_or(""));
});
}
}
/// Build inputs that *look* like ed25519:<hex> strings but with various
/// hex-content corruptions. These are realistic adversarial shapes — a
/// peer might produce strings in the right syntactic form but with bad
/// length, odd nibble counts, non-hex characters in the middle, etc.
#[test]
fn key_parsers_handle_almost_valid_inputs() {
let mut seed = 0x0011_2233_4455_6677u64;
for _ in 0..ITERS {
let len = (lcg(&mut seed) % 80) as usize;
let mut hex_part = String::new();
for _ in 0..len {
// Mix valid hex chars with garbage.
let pick = lcg(&mut seed) % 16;
let c = match pick {
0 => '!',
1 => 'g',
2 => 'Z',
3 => ' ',
_ => "0123456789abcdef".chars().nth((lcg(&mut seed) % 16) as usize).unwrap(),
};
hex_part.push(c);
}
let s = format!("ed25519:{hex_part}");
assert_no_panic("PublicKey::parse_levcs(almost)", seed, s.as_bytes(), |b| {
let _ = PublicKey::parse_levcs(std::str::from_utf8(b).unwrap_or(""));
});
}
}

View File

@ -0,0 +1,34 @@
[package]
name = "levcs-instance"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
levcs-core = { workspace = true }
levcs-identity = { workspace = true }
levcs-merge = { workspace = true }
levcs-protocol = { workspace = true }
levcs-client = { workspace = true }
axum = { workspace = true }
tokio = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
hex = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
toml = { workspace = true }
[dev-dependencies]
reqwest = { workspace = true }
levcs-protocol = { workspace = true }
levcs-client = { workspace = true }
tokio = { workspace = true }
[[bin]]
name = "levcs-instance"
path = "src/main.rs"

View File

@ -0,0 +1,861 @@
//! Instance HTTP server.
//!
//! Hosts repositories under a configured root directory. Each repository
//! lives at `<root>/<repo_id_hex>/` with the standard `.levcs/` layout.
//!
//! Implements the §5.2 endpoint surface:
//!
//! ```text
//! GET /levcs/v1/repos/{repo_id}/info
//! GET /levcs/v1/repos/{repo_id}/objects/{hash}
//! GET /levcs/v1/repos/{repo_id}/pack?have=...&want=...
//! POST /levcs/v1/repos/{repo_id}/push
//! GET /levcs/v1/repos/{repo_id}/refs
//! POST /levcs/v1/repos/{repo_id}/init
//! GET /levcs/v1/instance/info
//! GET /levcs/v1/instance/peers
//! ```
pub mod mirror;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::{Arc, Mutex, RwLock};
use axum::body::Bytes;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::Router;
use serde::{Deserialize, Serialize};
use levcs_core::object::ObjectType;
use levcs_core::{Commit, EntryType, ObjectId, ObjectStore, Tree};
use levcs_identity::authority::AuthorityBody;
use levcs_identity::keys::PublicKey;
use levcs_identity::verify::{verify_authority_chain, verify_genesis, ObjectSource as VerifySource};
use levcs_merge::engine::check_handler_allowed;
use levcs_merge::record::MergeRecord;
use levcs_protocol::auth::{verify_request, AuthRequest, DEFAULT_CLOCK_SKEW};
use levcs_protocol::wire::{InfoResponse, InstanceInfo, RefList};
use levcs_protocol::Pack;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct InstanceConfig {
pub root: PathBuf,
#[serde(default)]
pub storage_mode: String, // full, release, metadata
#[serde(default)]
pub federation_peers: Vec<String>,
#[serde(default)]
pub allowed_handlers: Vec<String>,
/// Per-repository mirror declarations (§5.6). A repo whose `repo_id`
/// matches one of these entries is treated as a mirror of `source` —
/// served read-only to clients (unless `writeback` is true) and kept
/// fresh by `sync_mirror`.
#[serde(default)]
pub mirrors: Vec<MirrorConfig>,
}
/// Per-repository mirror configuration (§5.6).
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct MirrorConfig {
pub repo_id: String,
/// Base URL of the source instance, including the `/levcs/v1` path.
pub source: String,
/// Replication mode: "full" mirrors every reachable object;
/// "release" mirrors only release objects, their trees and blobs,
/// and the authority chain (skipping inter-release commits) per §4.3.
#[serde(default = "default_mirror_mode")]
pub mode: String,
/// Polling cadence as a duration string (e.g. "5m", "30s"). Used by
/// the optional background poller; standalone `sync_mirror` calls do
/// not consult this field.
#[serde(default)]
pub poll_interval: String,
/// When true, this mirror accepts client pushes and forwards them to
/// `source`. When false (the default), client pushes are rejected.
/// §5.6 leaves the proxy mechanism implementation-defined; the wire
/// behavior — read-only by default — is the part we must enforce.
#[serde(default)]
pub writeback: bool,
}
fn default_mirror_mode() -> String { "full".into() }
impl InstanceConfig {
/// Look up a mirror declaration for `repo_id`. Returns `None` for
/// repositories the instance is authoritative for.
pub fn mirror_for(&self, repo_id: &str) -> Option<&MirrorConfig> {
self.mirrors.iter().find(|m| m.repo_id == repo_id)
}
/// Resolve the storage mode (§4.3). Empty / unset / "full" all
/// mean full replication; the spec only enumerates three valid
/// values, so anything else is treated as full and warned about
/// at instance startup. Used by the push handler to gate which
/// reference namespaces accept updates.
pub fn storage_mode(&self) -> StorageMode {
match self.storage_mode.as_str() {
"release" => StorageMode::Release,
"metadata" => StorageMode::Metadata,
_ => StorageMode::Full,
}
}
}
/// One of the three modes from §4.3.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum StorageMode {
/// Full replication — accepts every push.
Full,
/// Releases + their trees + reachable blobs + authority chain.
/// Rejects pushes that update branches; only `refs/releases/*`
/// updates are accepted.
Release,
/// Authority objects, release headers, signed references only.
/// Rejects all pushes — metadata-mode instances are typically
/// populated by mirroring rather than direct push.
Metadata,
}
#[derive(Clone)]
pub struct AppState {
pub config: Arc<InstanceConfig>,
pub nonce_cache: Arc<Mutex<NonceCache>>,
pub repo_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
}
impl AppState {
pub fn new(config: InstanceConfig) -> Self {
Self {
config: Arc::new(config),
nonce_cache: Arc::new(Mutex::new(NonceCache::default())),
repo_locks: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn repo_dir(&self, repo_id: &str) -> PathBuf {
self.config.root.join(repo_id)
}
pub fn store(&self, repo_id: &str) -> ObjectStore {
ObjectStore::new(self.repo_dir(repo_id).join(".levcs/objects"))
}
}
#[derive(Default)]
pub struct NonceCache {
/// Maps nonce → expiry epoch (in micros).
seen: HashSet<[u8; 16]>,
}
impl NonceCache {
pub fn check_and_insert(&mut self, nonce: [u8; 16]) -> bool {
if self.seen.contains(&nonce) {
false
} else {
self.seen.insert(nonce);
// Cap memory by clearing periodically.
if self.seen.len() > 100_000 {
self.seen.clear();
}
true
}
}
}
pub fn router(state: AppState) -> Router {
use tower_http::trace::TraceLayer;
Router::new()
// Operational endpoint — outside /levcs/v1 so reverse proxies
// can probe liveness without touching the federation surface.
// Cheap on purpose: doesn't read state, doesn't touch disk.
.route("/health", get(handle_health))
.route("/levcs/v1/instance/info", get(handle_instance_info))
.route("/levcs/v1/instance/peers", get(handle_instance_peers))
.route("/levcs/v1/repos/:repo_id/info", get(handle_repo_info))
.route("/levcs/v1/repos/:repo_id/refs", get(handle_repo_refs))
.route("/levcs/v1/repos/:repo_id/objects/:hash", get(handle_get_object))
.route("/levcs/v1/repos/:repo_id/pack", get(handle_get_pack))
.route("/levcs/v1/repos/:repo_id/push", post(handle_push))
.route("/levcs/v1/repos/:repo_id/init", post(handle_init))
.layer(TraceLayer::new_for_http())
.with_state(state)
}
async fn handle_health() -> impl IntoResponse {
axum::Json(serde_json::json!({"status": "ok"}))
}
#[derive(Debug)]
struct ApiError(StatusCode, String);
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.0, self.1).into_response()
}
}
fn err(status: StatusCode, msg: impl Into<String>) -> ApiError {
ApiError(status, msg.into())
}
async fn handle_instance_info(State(s): State<AppState>) -> impl IntoResponse {
let info = InstanceInfo {
software: "levcs-instance".into(),
version: env!("CARGO_PKG_VERSION").into(),
storage_mode: if s.config.storage_mode.is_empty() {
"full".into()
} else {
s.config.storage_mode.clone()
},
allowed_handlers: s.config.allowed_handlers.clone(),
federation_peers: s.config.federation_peers.clone(),
};
axum::Json(info)
}
async fn handle_instance_peers(State(s): State<AppState>) -> impl IntoResponse {
axum::Json(s.config.federation_peers.clone())
}
async fn handle_repo_info(
State(s): State<AppState>,
Path(repo_id): Path<String>,
) -> Result<axum::Json<InfoResponse>, ApiError> {
let dir = s.repo_dir(&repo_id);
if !dir.is_dir() {
return Err(err(StatusCode::NOT_FOUND, "repo not found"));
}
let refs = levcs_core::Refs::new(dir.join(".levcs"));
let cur = refs
.read("refs/authority/current")
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let genesis = refs
.read("refs/authority/genesis")
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let branches = refs
.list_branches()
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let mirror = s.config.mirror_for(&repo_id);
let mut info = InfoResponse {
repo_id,
current_authority: cur.map(|c| c.to_hex()).unwrap_or_default(),
genesis_authority: genesis.map(|c| c.to_hex()).unwrap_or_default(),
is_mirror: mirror.is_some(),
mirror_source: mirror.map(|m| m.source.clone()),
mirror_mode: mirror.map(|m| m.mode.clone()),
..Default::default()
};
for (k, v) in branches {
info.branches.insert(k, v.to_hex());
}
// Releases also belong in /info — clients without a mirror config look
// here to discover the latest release for `construct --release` etc.
let releases_dir = dir.join(".levcs/refs/releases");
if releases_dir.is_dir() {
if let Ok(read) = std::fs::read_dir(&releases_dir) {
for ent in read.flatten() {
let name = ent.file_name().to_string_lossy().to_string();
if let Ok(txt) = std::fs::read_to_string(ent.path()) {
info.releases.insert(name, txt.trim().to_string());
}
}
}
}
Ok(axum::Json(info))
}
async fn handle_repo_refs(
State(s): State<AppState>,
Path(repo_id): Path<String>,
) -> Result<axum::Json<RefList>, ApiError> {
let dir = s.repo_dir(&repo_id);
if !dir.is_dir() {
return Err(err(StatusCode::NOT_FOUND, "repo not found"));
}
let refs = levcs_core::Refs::new(dir.join(".levcs"));
let mut out = RefList::default();
for (k, v) in refs
.list_branches()
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
{
out.branches.insert(k, v.to_hex());
}
let releases_dir = dir.join(".levcs/refs/releases");
if releases_dir.is_dir() {
if let Ok(read) = std::fs::read_dir(&releases_dir) {
for ent in read.flatten() {
let name = ent.file_name().to_string_lossy().to_string();
if let Ok(txt) = std::fs::read_to_string(ent.path()) {
out.releases.insert(name, txt.trim().to_string());
}
}
}
}
Ok(axum::Json(out))
}
async fn handle_get_object(
State(s): State<AppState>,
Path((repo_id, hash)): Path<(String, String)>,
) -> Result<Vec<u8>, ApiError> {
let id = ObjectId::from_hex(&hash).map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?;
let store = s.store(&repo_id);
let bytes = store
.read_raw(id)
.map_err(|e| err(StatusCode::NOT_FOUND, e.to_string()))?;
Ok(bytes)
}
#[derive(Deserialize)]
struct PackQuery {
#[serde(default)]
have: String,
#[serde(default)]
want: String,
}
async fn handle_get_pack(
State(s): State<AppState>,
Path(repo_id): Path<String>,
Query(q): Query<PackQuery>,
) -> Result<Vec<u8>, ApiError> {
let store = s.store(&repo_id);
let have: Vec<ObjectId> = q
.have
.split(',')
.filter(|x| !x.is_empty())
.map(ObjectId::from_hex)
.collect::<Result<_, _>>()
.map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?;
let want: Vec<ObjectId> = q
.want
.split(',')
.filter(|x| !x.is_empty())
.map(ObjectId::from_hex)
.collect::<Result<_, _>>()
.map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?;
// Compute closure of `want` minus closure of `have` (transitively).
let mut have_closure: HashSet<ObjectId> = HashSet::new();
for h in &have {
collect_closure(&store, *h, &mut have_closure);
}
let mut want_set: HashSet<ObjectId> = HashSet::new();
for w in &want {
collect_closure(&store, *w, &mut want_set);
}
let mut pack = Pack::new();
for id in want_set.difference(&have_closure) {
if let Ok(bytes) = store.read_raw(*id) {
// Determine type by parsing header byte.
if bytes.len() >= 5 {
pack.push(bytes[4], bytes);
}
}
}
Ok(pack.encode())
}
fn collect_closure(store: &ObjectStore, id: ObjectId, out: &mut HashSet<ObjectId>) {
if !out.insert(id) {
return;
}
let raw = match store.read_object(id) {
Ok(r) => r,
Err(_) => return,
};
use levcs_core::object::ObjectType;
match raw.object_type {
ObjectType::Tree => {
if let Ok(tree) = levcs_core::Tree::parse_body(&raw.body) {
for e in tree.entries {
collect_closure(store, e.hash, out);
}
}
}
ObjectType::Commit => {
if let Ok(commit) = levcs_core::Commit::parse_body(&raw.body) {
collect_closure(store, commit.tree, out);
collect_closure(store, commit.authority, out);
for p in commit.parents {
collect_closure(store, p, out);
}
}
}
ObjectType::Release => {
if let Ok(rel) = levcs_core::Release::parse_body(&raw.body) {
collect_closure(store, rel.tree, out);
collect_closure(store, rel.predecessor, out);
collect_closure(store, rel.authority, out);
if !rel.parent_release.is_zero() {
collect_closure(store, rel.parent_release, out);
}
}
}
ObjectType::Authority => {
if let Ok(body) = AuthorityBody::parse(&raw.body) {
if !body.previous_authority.is_zero() {
collect_closure(store, body.previous_authority, out);
}
}
}
ObjectType::Blob => {}
}
}
#[derive(Debug)]
struct AuthCheck {
pub key: PublicKey,
}
fn verify_request_against(
s: &AppState,
headers: &HeaderMap,
method: &str,
path: &str,
body: &[u8],
) -> Result<AuthCheck, ApiError> {
let h = |name: &'static str| {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, format!("missing header {name}")))
};
let key = h("LeVCS-Key")?;
let ts = h("LeVCS-Timestamp")?;
let nonce = h("LeVCS-Nonce")?;
let sig = h("LeVCS-Signature")?;
let now = levcs_protocol::auth::current_micros();
let req = AuthRequest { method, path_with_query: path, body };
let auth = verify_request(&req, key, ts, nonce, sig, now, DEFAULT_CLOCK_SKEW)
.map_err(|e| err(StatusCode::UNAUTHORIZED, e.to_string()))?;
let mut cache = s.nonce_cache.lock().unwrap();
if !cache.check_and_insert(auth.nonce) {
return Err(err(StatusCode::UNAUTHORIZED, "replayed nonce"));
}
Ok(AuthCheck { key: auth.key })
}
async fn handle_init(
State(s): State<AppState>,
Path(repo_id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<StatusCode, ApiError> {
let path = format!("/repos/{repo_id}/init");
let auth = verify_request_against(&s, &headers, "POST", &path, body.as_ref())?;
// Body is the genesis authority object (signed).
use levcs_core::object::SignedObject;
let signed = SignedObject::parse(&body)
.map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?;
let body_parsed = verify_genesis(&signed)
.map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?;
if hex::encode(body_parsed.repo_id.as_bytes()) != repo_id {
return Err(err(
StatusCode::BAD_REQUEST,
"URL repo_id does not match authority body",
));
}
if body_parsed.find_member(&auth.key).is_none() {
return Err(err(
StatusCode::FORBIDDEN,
"init key is not a member of the authority",
));
}
let dir = s.repo_dir(&repo_id);
if dir.is_dir() {
return Err(err(StatusCode::CONFLICT, "repo already exists"));
}
levcs_core::Repository::init_skeleton(&dir)
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let store = s.store(&repo_id);
let bytes = signed.serialize();
let id = store
.write_raw(&bytes)
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let refs = levcs_core::Refs::new(dir.join(".levcs"));
refs.write("refs/authority/genesis", id)
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
refs.write("refs/authority/current", id)
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::CREATED)
}
async fn handle_push(
State(s): State<AppState>,
Path(repo_id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Result<StatusCode, ApiError> {
let path = format!("/repos/{repo_id}/push");
let auth = verify_request_against(&s, &headers, "POST", &path, body.as_ref())?;
let dir = s.repo_dir(&repo_id);
if !dir.is_dir() {
return Err(err(StatusCode::NOT_FOUND, "repo not found"));
}
// §5.6: a mirror is read-only by default. Reject pushes unless the
// operator has explicitly opted into writeback. We return 403 with a
// body that points clients at the source so they can retry there.
if let Some(m) = s.config.mirror_for(&repo_id) {
if !m.writeback {
return Err(err(
StatusCode::FORBIDDEN,
format!(
"this instance mirrors {repo_id} from {} and does not accept writes; push to the source instead",
m.source
),
));
}
}
// §4.3 storage-mode enforcement. We need the parsed manifest to
// gate by ref namespace, so the actual rejection happens after
// the manifest is decoded below. Metadata-mode is the only
// wholesale reject we can make right now; the others require
// looking at `manifest.updates`.
if s.config.storage_mode() == StorageMode::Metadata {
return Err(err(
StatusCode::FORBIDDEN,
"instance is in metadata-only mode and does not accept pushes; \
populate via mirror configuration",
));
}
// Body layout: pack || u32 manifest_len || manifest_json || 64 sig
if body.len() < 4 + 64 {
return Err(err(StatusCode::BAD_REQUEST, "body too short"));
}
let (pack, pack_len) = Pack::decode_prefix(&body)
.map_err(|e| err(StatusCode::BAD_REQUEST, format!("pack decode: {e}")))?;
if body.len() < pack_len + 4 + 64 {
return Err(err(StatusCode::BAD_REQUEST, "body truncated after pack"));
}
let manifest_len = u32::from_le_bytes([
body[pack_len], body[pack_len + 1], body[pack_len + 2], body[pack_len + 3],
]) as usize;
if body.len() != pack_len + 4 + manifest_len + 64 {
return Err(err(StatusCode::BAD_REQUEST, "body length mismatch"));
}
let manifest_json = &body[pack_len + 4..pack_len + 4 + manifest_len];
let manifest_sig = &body[pack_len + 4 + manifest_len..];
let mut sig_arr = [0u8; 64];
sig_arr.copy_from_slice(manifest_sig);
auth.key
.verify(manifest_json, &sig_arr)
.map_err(|_| err(StatusCode::UNAUTHORIZED, "manifest signature invalid"))?;
let manifest: levcs_protocol::PushManifest = serde_json::from_slice(manifest_json)
.map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?;
// §4.3: release-mode instances accept only release ref updates.
// Inter-release commits are not stored — `refs/branches/*` updates
// would require us to keep the commit chain. Reject early so the
// client gets a clear message before any object lands in the store.
if s.config.storage_mode() == StorageMode::Release {
for u in &manifest.updates {
if !u.r#ref.starts_with("refs/releases/") {
return Err(err(
StatusCode::FORBIDDEN,
format!(
"instance is in release-only mode; ref {:?} is not a release \
(only refs/releases/* updates are accepted)",
u.r#ref
),
));
}
}
}
let store = s.store(&repo_id);
// Acquire per-repo lock for atomic ref updates.
let lock = {
let mut map = s.repo_locks.write().unwrap();
map.entry(repo_id.clone())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
};
let _guard = lock.lock().unwrap();
// Step 1: write all pack objects to the loose store (validated framing).
for ent in &pack.entries {
store
.write_raw(&ent.bytes)
.map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?;
}
// Step 1b: enforce instance merge-policy (§6.6.4). Walk every Commit in
// the pack; if its tree carries `.levcs/merge-record`, parse it and
// reject the whole push if any handler reference falls outside
// `allowed_handlers`. The repository's own policy is the inner
// constraint and is verified independently elsewhere; this is the
// outer ceiling.
if !s.config.allowed_handlers.is_empty() {
for ent in &pack.entries {
if ent.object_type != ObjectType::Commit as u8 {
continue;
}
let signed = match levcs_core::object::SignedObject::parse(&ent.bytes) {
Ok(s) => s,
Err(_) => continue,
};
let commit = match Commit::from_signed(&signed) {
Ok(c) => c,
Err(_) => continue,
};
let record_bytes = match find_merge_record(&store, commit.tree) {
Ok(Some(b)) => b,
_ => continue,
};
let record_str = match std::str::from_utf8(&record_bytes) {
Ok(s) => s,
Err(_) => {
return Err(err(
StatusCode::BAD_REQUEST,
"merge-record blob is not valid UTF-8",
));
}
};
let record = MergeRecord::from_toml(record_str)
.map_err(|e| err(StatusCode::BAD_REQUEST, format!("merge-record: {e}")))?;
for fr in &record.files {
if !check_handler_allowed(&fr.handler, &fr.handler_hash, &s.config.allowed_handlers) {
return Err(err(
StatusCode::FORBIDDEN,
format!(
"merge handler '{}' is not permitted by this instance's policy",
fr.handler
),
));
}
}
}
}
// Step 2: verify authority chain on the manifest's authority_hash.
let auth_hash = ObjectId::from_hex(&manifest.authority_hash)
.map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?;
verify_authority_chain(&store, auth_hash)
.map_err(|e| err(StatusCode::BAD_REQUEST, format!("authority chain: {e}")))?;
let auth_obj = store
.read_raw(auth_hash)
.map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?;
let auth_signed = levcs_core::object::SignedObject::parse(&auth_obj)
.map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?;
let auth_body = AuthorityBody::parse(&auth_signed.body)
.map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?;
// Step 3: verify pusher has appropriate role.
let member = auth_body
.find_member(&auth.key)
.ok_or_else(|| err(StatusCode::FORBIDDEN, "pusher not in authority"))?;
if member.role < levcs_identity::authority::Role::Contributor {
return Err(err(
StatusCode::FORBIDDEN,
"pusher lacks contributor role",
));
}
// Step 4: verify each new commit and compare-and-swap each ref.
let refs = levcs_core::Refs::new(dir.join(".levcs"));
for u in &manifest.updates {
let new_id = ObjectId::from_hex(&u.new_hash)
.map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?;
let old_actual = refs
.read(&u.r#ref)
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let old_expected = match &u.old_hash {
Some(s) if !s.is_empty() => Some(ObjectId::from_hex(s).map_err(|e| {
err(StatusCode::BAD_REQUEST, format!("bad old_hash: {e}"))
})?),
_ => None,
};
if old_actual != old_expected {
return Err(err(
StatusCode::CONFLICT,
format!("ref {} changed concurrently", u.r#ref),
));
}
// Dispatch verification by the new tip's object type so we can
// accept both branch refs (commit-typed) and release refs
// (release-typed). Anything else gets rejected up front.
let raw = store
.read_object(new_id)
.map_err(|e| err(StatusCode::BAD_REQUEST, e.to_string()))?;
match raw.object_type {
ObjectType::Commit => {
levcs_identity::verify::verify_commit(&store, new_id, Some(&u.r#ref))
.map_err(|e| err(StatusCode::BAD_REQUEST, format!("commit verify: {e}")))?;
}
ObjectType::Release => {
levcs_identity::verify::verify_release(&store, new_id)
.map_err(|e| err(StatusCode::BAD_REQUEST, format!("release verify: {e}")))?;
}
other => {
return Err(err(
StatusCode::BAD_REQUEST,
format!("ref tip is {} object, must be Commit or Release", other.name()),
));
}
}
// §5.4(e): non-fast-forward updates require force-push and a
// sufficiently privileged key. Only check when this is an
// update of an existing ref (old_actual is Some) — first-write
// refs have no ancestry constraint.
if let Some(old_id) = old_actual {
if !is_ancestor(&store, old_id, new_id) {
if !manifest.force {
return Err(err(
StatusCode::CONFLICT,
format!(
"non-fast-forward update for ref {}; pass --force to override",
u.r#ref
),
));
}
if member.role < levcs_identity::authority::Role::Maintainer {
return Err(err(
StatusCode::FORBIDDEN,
format!(
"force-push to {} requires maintainer or owner role",
u.r#ref
),
));
}
}
}
refs.write(&u.r#ref, new_id)
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
// Update current authority pointer if any pushed tip modified it.
// Walk all updates rather than just the last so the order in `updates`
// can be arbitrary; pick the highest-numbered authority by version.
let mut best_auth: Option<(ObjectId, u32)> = None;
for u in &manifest.updates {
let id = match ObjectId::from_hex(&u.new_hash) {
Ok(i) => i,
Err(_) => continue,
};
let bytes = match store.read_raw(id) {
Ok(b) => b,
Err(_) => continue,
};
let signed = match levcs_core::object::SignedObject::parse(&bytes) {
Ok(s) => s,
Err(_) => continue,
};
let auth_id = match signed.object_type {
ObjectType::Commit => match levcs_core::Commit::from_signed(&signed) {
Ok(c) => c.authority,
Err(_) => continue,
},
ObjectType::Release => match levcs_core::Release::parse_body(&signed.body) {
Ok(r) => r.authority,
Err(_) => continue,
},
_ => continue,
};
let auth_bytes = match store.read_raw(auth_id) {
Ok(b) => b,
Err(_) => continue,
};
let auth_signed = match levcs_core::object::SignedObject::parse(&auth_bytes) {
Ok(s) => s,
Err(_) => continue,
};
let auth_body = match AuthorityBody::parse(&auth_signed.body) {
Ok(b) => b,
Err(_) => continue,
};
match best_auth {
None => best_auth = Some((auth_id, auth_body.version)),
Some((_, v)) if auth_body.version > v => best_auth = Some((auth_id, auth_body.version)),
_ => {}
}
}
if let Some((auth_id, _)) = best_auth {
refs.write("refs/authority/current", auth_id)
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
Ok(StatusCode::OK)
}
/// Helper used by tests and the binary's `main` to bind and serve.
pub async fn serve(state: AppState, addr: std::net::SocketAddr) -> std::io::Result<()> {
let app = router(state);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await
}
/// Decide whether `old_id` is an ancestor of `new_id` for fast-forward
/// detection on push (§5.4(e)). Walks parent chains starting from
/// `new_id`. Considers Commit parents and Release predecessor +
/// parent_release; both flavours of ref can be advanced and either
/// chain might lead back to `old_id`. Returns false on read errors —
/// safer to require force-push than to silently accept an update we
/// can't verify.
fn is_ancestor(store: &ObjectStore, old_id: ObjectId, new_id: ObjectId) -> bool {
use levcs_core::Release;
if old_id == new_id {
return true;
}
let mut visited: HashSet<ObjectId> = HashSet::new();
let mut stack = vec![new_id];
while let Some(id) = stack.pop() {
if !visited.insert(id) {
continue;
}
if id == old_id {
return true;
}
let raw = match store.read_object(id) {
Ok(r) => r,
Err(_) => continue,
};
match raw.object_type {
ObjectType::Commit => {
if let Ok(c) = Commit::parse_body(&raw.body) {
stack.extend(c.parents);
}
}
ObjectType::Release => {
if let Ok(r) = Release::parse_body(&raw.body) {
if !r.predecessor.is_zero() {
stack.push(r.predecessor);
}
if !r.parent_release.is_zero() {
stack.push(r.parent_release);
}
}
}
_ => {}
}
}
false
}
/// Walk into `tree_id` looking for `.levcs/merge-record` and return the blob
/// body if found. Returns `Ok(None)` for a tree with no `.levcs` subtree, no
/// `merge-record` entry, or any non-blob entry at that path.
fn find_merge_record(
store: &ObjectStore,
tree_id: ObjectId,
) -> Result<Option<Vec<u8>>, levcs_core::error::Error> {
if tree_id.is_zero() {
return Ok(None);
}
let raw = store.read_typed(tree_id, ObjectType::Tree)?;
let tree = Tree::parse_body(&raw.body)?;
let levcs_entry = match tree.entries.iter().find(|e| e.name == ".levcs") {
Some(e) if e.entry_type == EntryType::Tree => e,
_ => return Ok(None),
};
let raw = store.read_typed(levcs_entry.hash, ObjectType::Tree)?;
let levcs_tree = Tree::parse_body(&raw.body)?;
let mr_entry = match levcs_tree.entries.iter().find(|e| e.name == "merge-record") {
Some(e) if e.entry_type == EntryType::Blob => e,
_ => return Ok(None),
};
let blob = store.read_typed(mr_entry.hash, ObjectType::Blob)?;
Ok(Some(blob.body))
}
// Allow `verify_authority_chain` to use ObjectStore directly.
#[allow(dead_code)]
fn _vs(_: &dyn VerifySource) {}

View File

@ -0,0 +1,228 @@
//! `levcs-instance` binary.
//!
//! Default invocation reads a TOML config file (`levcs-instance.toml` by
//! default, override with `--config`). CLI flags override file values
//! for `--root` and `--bind`. The config schema mirrors `InstanceConfig`
//! plus a top-level `bind` field for the listen address.
//!
//! Example config:
//!
//! ```toml
//! root = "/var/lib/levcs"
//! bind = "127.0.0.1:7117"
//! storage_mode = "full" # full | release | metadata
//! allowed_handlers = ["builtin"]
//! federation_peers = []
//!
//! [[mirrors]]
//! repo_id = "..."
//! source = "https://other.example/levcs/v1"
//! mode = "full" # full | release
//! poll_interval = "5m"
//! writeback = false
//! ```
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::time::Duration;
use levcs_instance::mirror::spawn_poller;
use levcs_instance::{serve, AppState, InstanceConfig, MirrorConfig};
use serde::Deserialize;
use tracing_subscriber::EnvFilter;
#[derive(Debug, Default, Deserialize)]
struct FileConfig {
#[serde(default)]
root: Option<PathBuf>,
#[serde(default)]
bind: Option<String>,
#[serde(default)]
storage_mode: Option<String>,
#[serde(default)]
federation_peers: Option<Vec<String>>,
#[serde(default)]
allowed_handlers: Option<Vec<String>>,
#[serde(default)]
mirrors: Option<Vec<MirrorConfig>>,
}
const DEFAULT_BIND: &str = "127.0.0.1:7117";
const DEFAULT_ROOT: &str = "./levcs-data";
const HELP: &str = "\
levcs-instance federation HTTP server (§5.2)
USAGE:
levcs-instance [OPTIONS]
OPTIONS:
--config <PATH> Read TOML config from this path. CLI flags override it.
--root <DIR> Repository root directory. Default: ./levcs-data
--bind <ADDR> Listen address. Default: 127.0.0.1:7117
-h, --help Show this message.
ENVIRONMENT:
RUST_LOG tracing filter (e.g. \"info\", \"debug\", \"levcs_instance=trace\").
The instance terminates HTTP, not TLS run behind nginx/Caddy in production.
";
fn die(msg: impl AsRef<str>) -> ! {
eprintln!("levcs-instance: {}", msg.as_ref());
std::process::exit(2);
}
/// Parse "5m", "30s", "1h", or a bare number of seconds. Reject empty
/// strings so the caller can fall back to a default — the typo case
/// shouldn't silently produce a poller that fires every zero seconds.
fn parse_duration(s: &str) -> Result<Duration, String> {
let s = s.trim();
if s.is_empty() {
return Err("empty duration".into());
}
let (num_str, mult): (&str, u64) = if let Some(p) = s.strip_suffix('h') {
(p, 3600)
} else if let Some(p) = s.strip_suffix('m') {
(p, 60)
} else if let Some(p) = s.strip_suffix('s') {
(p, 1)
} else {
(s, 1)
};
let n: u64 = num_str
.trim()
.parse()
.map_err(|_| format!("invalid duration {s:?}"))?;
Ok(Duration::from_secs(n * mult))
}
fn load_file(path: &Path) -> FileConfig {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => die(format!("could not read --config {}: {e}", path.display())),
};
match toml::from_str(&text) {
Ok(c) => c,
Err(e) => die(format!("invalid TOML in {}: {e}", path.display())),
}
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.compact()
.init();
let mut args = std::env::args().skip(1);
let mut config_path: Option<PathBuf> = None;
let mut cli_root: Option<PathBuf> = None;
let mut cli_bind: Option<String> = None;
while let Some(arg) = args.next() {
match arg.as_str() {
"--config" => {
config_path = Some(PathBuf::from(
args.next().unwrap_or_else(|| die("--config needs a path")),
));
}
"--root" => {
cli_root = Some(PathBuf::from(
args.next().unwrap_or_else(|| die("--root needs a path")),
));
}
"--bind" => {
cli_bind = Some(args.next().unwrap_or_else(|| die("--bind needs an addr")));
}
"--help" | "-h" => {
print!("{HELP}");
return Ok(());
}
other => die(format!("unknown argument {other:?} (try --help)")),
}
}
let file = config_path.as_deref().map(load_file).unwrap_or_default();
// Layer: CLI > config file > built-in default. Each field independent.
let bind_str = cli_bind
.or(file.bind.clone())
.unwrap_or_else(|| DEFAULT_BIND.into());
let bind: SocketAddr = bind_str
.parse()
.unwrap_or_else(|e| die(format!("invalid bind {bind_str:?}: {e}")));
let root = cli_root
.or(file.root.clone())
.unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT));
std::fs::create_dir_all(&root)?;
let config = InstanceConfig {
root,
storage_mode: file.storage_mode.unwrap_or_else(|| "full".into()),
federation_peers: file.federation_peers.unwrap_or_default(),
allowed_handlers: file
.allowed_handlers
.unwrap_or_else(|| vec!["builtin".into()]),
mirrors: file.mirrors.unwrap_or_default(),
};
tracing::info!(
addr = %bind,
root = %config.root.display(),
storage_mode = %config.storage_mode,
mirrors = config.mirrors.len(),
"levcs instance starting"
);
let state = AppState::new(config.clone());
// Spawn one background poller per configured mirror. The handles
// are intentionally dropped — pollers run for the lifetime of the
// process, and tokio cancels them when the runtime shuts down.
let cfg_arc = state.config.clone();
for mirror in &config.mirrors {
let interval = if mirror.poll_interval.is_empty() {
Duration::from_secs(300)
} else {
match parse_duration(&mirror.poll_interval) {
Ok(d) => d,
Err(e) => {
tracing::warn!(
repo = %mirror.repo_id,
"invalid poll_interval {:?}: {e}; defaulting to 5m",
mirror.poll_interval
);
Duration::from_secs(300)
}
}
};
tracing::info!(
repo = %mirror.repo_id,
source = %mirror.source,
mode = %mirror.mode,
interval_secs = interval.as_secs(),
"starting mirror poller"
);
let _ = spawn_poller(cfg_arc.clone(), mirror.clone(), interval);
}
serve(state, bind).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_duration_units() {
assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
assert_eq!(parse_duration("60").unwrap(), Duration::from_secs(60));
assert!(parse_duration("").is_err());
assert!(parse_duration("abc").is_err());
assert!(parse_duration("5x").is_err());
}
}

View File

@ -0,0 +1,233 @@
//! Inter-instance mirroring (§5.6).
//!
//! [`sync_mirror`] is a single, blocking sync pass: it polls the source
//! instance's `/info` and `/refs`, fetches a pack of objects this instance
//! does not yet have, verifies their signatures locally against the
//! repository's own authority chain, and atomically advances the local
//! refs. The receiver does not have to trust the source — every signature
//! is checked against in-repository authority data.
//!
//! The function is sync because [`levcs_client::Client`] wraps blocking
//! `reqwest`. Callers that want to drive it from an async context should
//! invoke it via `tokio::task::spawn_blocking`. A simple background poller
//! is provided as [`spawn_poller`].
use std::path::PathBuf;
use std::time::Duration;
use levcs_client::{Client, ClientError};
use levcs_core::{ObjectId, ObjectStore, Refs, Repository};
use levcs_identity::verify::{verify_authority_chain, verify_commit, verify_release};
use thiserror::Error;
use crate::{InstanceConfig, MirrorConfig};
#[derive(Debug, Error)]
pub enum MirrorError {
#[error("client: {0}")]
Client(#[from] ClientError),
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("core: {0}")]
Core(#[from] levcs_core::error::Error),
#[error("verify: {0}")]
Verify(#[from] levcs_identity::verify::VerifyError),
#[error("malformed hash: {0}")]
Hash(String),
#[error("unsupported mode: {0}")]
Mode(String),
}
/// What the most recent sync pass changed locally.
#[derive(Clone, Debug, Default)]
pub struct MirrorReport {
pub objects_received: usize,
pub branches_updated: usize,
pub releases_updated: usize,
pub authority_changed: bool,
}
/// One sync pass against `mirror.source`. Idempotent — calling it on a
/// fully-up-to-date mirror is a no-op apart from two HTTP GETs.
///
/// In `mode = "full"` we mirror branches + releases + authority chain.
/// In `mode = "release"` we mirror only releases + authority chain
/// (§4.3): inter-release commits are not pulled, branches are not advanced,
/// matching how a release-only instance is meant to behave.
pub fn sync_mirror(
config: &InstanceConfig,
mirror: &MirrorConfig,
) -> Result<MirrorReport, MirrorError> {
if mirror.mode != "full" && mirror.mode != "release" {
return Err(MirrorError::Mode(mirror.mode.clone()));
}
let repo_dir: PathBuf = config.root.join(&mirror.repo_id);
if !repo_dir.is_dir() {
Repository::init_skeleton(&repo_dir)?;
}
let store = ObjectStore::new(repo_dir.join(".levcs/objects"));
let refs = Refs::new(repo_dir.join(".levcs"));
let client = Client::new(&mirror.source);
let info = client.repo_info(&mirror.repo_id)?;
let remote_refs = client.refs(&mirror.repo_id)?;
// What we want from the source: tips of every ref we'll mirror, plus
// the current authority (and its chain — closure resolution on the
// server side handles previous_authority transitively).
let mut want: Vec<ObjectId> = Vec::new();
let mut want_branches: Vec<(String, ObjectId)> = Vec::new();
let mut want_releases: Vec<(String, ObjectId)> = Vec::new();
if mirror.mode == "full" {
for (name, hash) in &remote_refs.branches {
let id = parse_hash(hash)?;
want.push(id);
want_branches.push((name.clone(), id));
}
}
for (name, hash) in &remote_refs.releases {
let id = parse_hash(hash)?;
want.push(id);
want_releases.push((name.clone(), id));
}
if !info.current_authority.is_empty() {
want.push(parse_hash(&info.current_authority)?);
}
// What we already have. Anything reachable from these tips, the server
// will exclude from the pack — this keeps mirror passes incremental.
let mut have: Vec<ObjectId> = Vec::new();
for (_, id) in refs.list_branches()? {
have.push(id);
}
for (_, id) in refs.list_releases()? {
have.push(id);
}
if let Some(cur) = refs.read("refs/authority/current")? {
have.push(cur);
}
let pack = client.get_pack(&mirror.repo_id, &have, &want)?;
// Stage objects to disk first. The receiver verifies against the
// local store, so verification needs the bytes already written.
for ent in &pack.entries {
store.write_raw(&ent.bytes)?;
}
// Verify the authority chain on the announced current authority.
// Every commit / release we're about to advance to must chain back to
// a member that is rooted in `genesis_authority` — the chain walk
// checks that, so doing it once here covers all the per-tip checks
// below. (verify_commit re-walks the chain internally; that is
// redundant but cheap and keeps the per-tip checks self-contained.)
if !info.current_authority.is_empty() {
let cur_auth = parse_hash(&info.current_authority)?;
verify_authority_chain(&store, cur_auth)?;
}
// Per-branch verification — fully checks signature, author membership,
// and authority chain. If verification fails on any tip we abort
// before touching local refs, so a bad source can never poison us.
for (name, id) in &want_branches {
verify_commit(&store, *id, Some(&format!("refs/branches/{name}")))?;
}
// Per-release verification: check the signed object itself and its
// authority. The release object's full schema check (predecessor /
// parent_release wiring) is the responsibility of `levcs_core::Release`
// when consumers parse it; here we ensure the signature is valid and
// the signing key is actually a member of the chain rooted in our
// local genesis.
for (_, id) in &want_releases {
verify_release(&store, *id)?;
}
// All checks passed — advance local refs. We do branches first, then
// releases, then the authority pointer. There is no per-pass atomicity
// guarantee between refs (each `Refs::write` is its own fsync), but
// each individual ref moves forward only after its tip has been
// independently verified, so partial application leaves the mirror in
// a consistent — if interleaved — state.
let mut report = MirrorReport {
objects_received: pack.entries.len(),
..Default::default()
};
if mirror.mode == "full" {
for (name, id) in &want_branches {
let path = format!("refs/branches/{name}");
if refs.read(&path)? != Some(*id) {
refs.write(&path, *id)?;
report.branches_updated += 1;
}
}
}
for (name, id) in &want_releases {
let path = format!("refs/releases/{name}");
if refs.read(&path)? != Some(*id) {
refs.write(&path, *id)?;
report.releases_updated += 1;
}
}
if !info.genesis_authority.is_empty() {
let g = parse_hash(&info.genesis_authority)?;
// Genesis is set once and never changes for a repo. Only write if
// we don't already have it — guards against accidentally clobbering
// a locally-init'd genesis with a different one.
if refs.read("refs/authority/genesis")?.is_none() {
refs.write("refs/authority/genesis", g)?;
}
}
if !info.current_authority.is_empty() {
let c = parse_hash(&info.current_authority)?;
if refs.read("refs/authority/current")? != Some(c) {
refs.write("refs/authority/current", c)?;
report.authority_changed = true;
}
}
Ok(report)
}
/// Spawn a tokio task that calls [`sync_mirror`] on a fixed cadence.
/// `every` overrides `mirror.poll_interval`; pass it explicitly so callers
/// own the parsing/clamping policy.
///
/// Errors from individual passes are logged via `tracing` and do not stop
/// the loop — a transient source outage shouldn't kill the poller.
pub fn spawn_poller(
config: std::sync::Arc<InstanceConfig>,
mirror: MirrorConfig,
every: Duration,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
let cfg = config.clone();
let m = mirror.clone();
let res = tokio::task::spawn_blocking(move || sync_mirror(&cfg, &m)).await;
match res {
Ok(Ok(report)) => tracing::debug!(
"mirror sync of {}: {} objects, {} branches, {} releases",
mirror.repo_id,
report.objects_received,
report.branches_updated,
report.releases_updated
),
Ok(Err(e)) => tracing::warn!("mirror sync of {} failed: {e}", mirror.repo_id),
Err(e) => tracing::error!("mirror sync of {} panicked: {e}", mirror.repo_id),
}
tokio::time::sleep(every).await;
}
})
}
fn parse_hash(s: &str) -> Result<ObjectId, MirrorError> {
ObjectId::from_hex(s).map_err(|e| MirrorError::Hash(e.to_string()))
}

View File

@ -0,0 +1,466 @@
//! End-to-end "dogfood" scenario.
//!
//! A single test that exercises a realistic federation topology in one
//! motion. Stands up:
//!
//! * Instance A — authoritative source-of-truth.
//! * Instance B — a fresh peer the user wants to migrate the repo to.
//! * Instance C — a read-only mirror of A.
//!
//! Then drives a multi-step session that the unit tests cover only in
//! pieces:
//! 1. Init repo on A.
//! 2. Push a chain of three commits on `main`.
//! 3. Publish a `v1.0.0` release tagging commit-2.
//! 4. Pull all reachable objects from A on a fresh client (clone).
//! 5. Sync mirror C from A; verify branches AND releases replicate.
//! 6. Mirror is read-only by configuration: pushes return 403.
//! 7. Migrate to B: re-init with the same authority and replay the
//! pack. B and A end up with identical refs.
//! 8. Spot-check object identity: fetch the head commit object from
//! A, B, and C; the bytes must be byte-for-byte equal everywhere.
//!
//! The test is deliberately one big function — that's the dogfood
//! claim. If any step regresses, this is the test that flags it before
//! anyone runs the CLI for real.
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use levcs_client::Client;
use levcs_core::hash::blake3_hash;
use levcs_core::object::ObjectType;
use levcs_core::{
Blob, Commit, CommitFlags, EntryType, FileMode, ObjectId, Release, Tree, TreeEntry, ZERO_ID,
};
use levcs_identity::authority::{AuthorityBody, MemberEntry, PolicyEntry, Role};
use levcs_identity::keys::SecretKey;
use levcs_identity::sign::{sign_authority, sign_commit, sign_release};
use levcs_instance::mirror::sync_mirror;
use levcs_instance::{router, AppState, InstanceConfig, MirrorConfig};
use levcs_protocol::wire::{PushManifest, PushUpdate};
use levcs_protocol::Pack;
fn tempdir(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("{prefix}-{n}-{}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
async fn start(cfg: InstanceConfig) -> (SocketAddr, tokio::task::JoinHandle<()>) {
let state = AppState::new(cfg);
let app = router(state);
let listener = tokio::net::TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let task = tokio::spawn(async move {
axum::serve(listener, app).await.ok();
});
(addr, task)
}
struct Setup {
sk: SecretKey,
auth_id: ObjectId,
repo_id: String,
auth_bytes: Vec<u8>,
}
fn build_genesis() -> Setup {
let sk = SecretKey::generate();
let pk = sk.public();
let now = 1_700_000_000_000_000;
let mut auth = AuthorityBody {
schema_version: 1,
repo_id: ZERO_ID,
previous_authority: ZERO_ID,
version: 1,
created_micros: now,
members: vec![MemberEntry {
key: pk,
handle: "alice".into(),
role: Role::Owner,
added_micros: now,
added_by: pk,
}],
policy: vec![PolicyEntry {
key: "public_read".into(),
value: vec![0x01],
}],
};
auth.normalize().unwrap();
auth.assign_genesis_repo_id().unwrap();
let signed = sign_authority(&auth, &sk).unwrap();
let auth_bytes = signed.serialize();
let auth_id = blake3_hash(&auth_bytes);
let repo_id = auth.repo_id.to_hex();
Setup { sk, auth_id, repo_id, auth_bytes }
}
/// Build a single-file commit. Each successive call uses different
/// content so the commit hashes diverge.
fn build_commit(
sk: &SecretKey,
auth_id: ObjectId,
file: &str,
content: &[u8],
parent: Option<ObjectId>,
timestamp: i64,
) -> (Pack, ObjectId, ObjectId) {
let pk = sk.public();
let blob = Blob::new(content.to_vec());
let blob_bytes = blob.serialize();
let blob_id = blake3_hash(&blob_bytes);
let mut top = Tree::new();
top.entries.push(TreeEntry {
name: file.into(),
entry_type: EntryType::Blob,
mode: FileMode::REGULAR,
hash: blob_id,
});
top.sort_and_validate().unwrap();
let tree_bytes = top.serialize();
let tree_id = blake3_hash(&tree_bytes);
let commit = Commit {
tree: tree_id,
parents: parent.map(|p| vec![p]).unwrap_or_default(),
authority: auth_id,
author_key: pk.0,
timestamp_micros: timestamp,
flags: CommitFlags::NONE,
message: format!("commit for {file}"),
};
let signed = sign_commit(commit, sk).unwrap();
let commit_bytes = signed.serialize();
let commit_id = blake3_hash(&commit_bytes);
let mut pack = Pack::new();
pack.push(ObjectType::Blob as u8, blob_bytes);
pack.push(ObjectType::Tree as u8, tree_bytes);
pack.push(ObjectType::Commit as u8, commit_bytes);
(pack, commit_id, tree_id)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn dogfood_three_instance_scenario() {
// ---- 1. Stand up A, B, and C. ----
let a_root = tempdir("dogfood-a");
let b_root = tempdir("dogfood-b");
let c_root = tempdir("dogfood-c");
let a_cfg = InstanceConfig {
root: a_root.clone(),
storage_mode: "full".into(),
federation_peers: Vec::new(),
allowed_handlers: Vec::new(),
mirrors: Vec::new(),
};
let b_cfg = InstanceConfig {
root: b_root.clone(),
storage_mode: "full".into(),
federation_peers: Vec::new(),
allowed_handlers: Vec::new(),
mirrors: Vec::new(),
};
let (a_addr, a_task) = start(a_cfg).await;
let (b_addr, b_task) = start(b_cfg).await;
let a_base = format!("http://{a_addr}/levcs/v1");
let b_base = format!("http://{b_addr}/levcs/v1");
let setup = build_genesis();
// ---- 2. Init + 3-commit chain on A. ----
let (commit_ids, release_id) = tokio::task::spawn_blocking({
let base = a_base.clone();
let seed = *setup.sk.seed();
let auth_id = setup.auth_id;
let repo_id = setup.repo_id.clone();
let auth_bytes = setup.auth_bytes.clone();
move || -> Result<(Vec<ObjectId>, ObjectId), levcs_client::ClientError> {
let sk = SecretKey::from_seed(seed);
let client = Client::new(base);
client.init(&sk, &repo_id, &auth_bytes)?;
let mut prev: Option<ObjectId> = None;
let mut ids = Vec::new();
let mut tree2: Option<ObjectId> = None;
for (i, (file, content)) in [
("a.txt", b"first\n".as_slice()),
("a.txt", b"second\n".as_slice()),
("a.txt", b"third\n".as_slice()),
]
.iter()
.enumerate()
{
let ts = 1_700_000_000_000_000 + (i as i64) * 1_000_000;
let (pack, cid, tid) = build_commit(&sk, auth_id, file, content, prev, ts);
let manifest = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/main".into(),
old_hash: prev.map(|p| p.to_hex()),
new_hash: cid.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &pack, &manifest)?;
if i == 1 {
tree2 = Some(tid);
}
prev = Some(cid);
ids.push(cid);
}
// ---- 3. Publish a release tagging commit #2. ----
let release = Release {
tree: tree2.unwrap(),
parent_release: ZERO_ID,
predecessor: ids[1],
authority: auth_id,
declarer_key: sk.public().0,
timestamp_micros: 1_700_000_010_000_000,
label: "v1.0.0".into(),
notes: "first dogfood release".into(),
};
let signed_release = sign_release(release, &sk).unwrap();
let release_bytes = signed_release.serialize();
let release_id = blake3_hash(&release_bytes);
let mut release_pack = Pack::new();
release_pack.push(ObjectType::Release as u8, release_bytes);
let release_manifest = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/releases/v1.0.0".into(),
old_hash: None,
new_hash: release_id.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &release_pack, &release_manifest)?;
Ok((ids, release_id))
}
})
.await
.unwrap()
.expect("init+pushes on A must succeed");
let head = commit_ids[2];
// Sanity: A reports the right head and release.
let a_refs = tokio::task::spawn_blocking({
let base = a_base.clone();
let rid = setup.repo_id.clone();
move || Client::new(base).refs(&rid).unwrap()
})
.await
.unwrap();
assert_eq!(a_refs.branches.get("main"), Some(&head.to_hex()));
assert_eq!(a_refs.releases.get("v1.0.0"), Some(&release_id.to_hex()));
// ---- 4. Clone A's full state to a fresh local pack via get_pack. ----
// Empty `have`, `want` = head. The instance must walk from head to
// include all reachable objects (commits + trees + blobs + authority).
// This is what the CLI's `clone` invokes.
let cloned = tokio::task::spawn_blocking({
let base = a_base.clone();
let rid = setup.repo_id.clone();
let want = head;
move || Client::new(base).get_pack(&rid, &[], &[want]).unwrap()
})
.await
.unwrap();
// Authority + 3 blobs + 3 trees + 3 commits = 10 objects minimum.
assert!(
cloned.entries.len() >= 10,
"clone pack should carry full history: got {}",
cloned.entries.len()
);
// ---- 5. Stand up C as a mirror of A; sync. ----
let c_cfg = InstanceConfig {
root: c_root.clone(),
storage_mode: "full".into(),
federation_peers: Vec::new(),
allowed_handlers: Vec::new(),
mirrors: vec![MirrorConfig {
repo_id: setup.repo_id.clone(),
source: a_base.clone(),
mode: "full".into(),
poll_interval: "60s".into(),
writeback: false,
}],
};
let c_cfg_arc = Arc::new(c_cfg.clone());
let (c_addr, c_task) = start(c_cfg).await;
let c_base = format!("http://{c_addr}/levcs/v1");
let report = tokio::task::spawn_blocking({
let cfg = c_cfg_arc.clone();
let m = cfg.mirrors[0].clone();
move || sync_mirror(&cfg, &m).unwrap()
})
.await
.unwrap();
assert_eq!(report.branches_updated, 1, "main must replicate");
assert!(report.releases_updated >= 1, "release must replicate");
// C now matches A.
let (c_refs, c_info) = tokio::task::spawn_blocking({
let base = c_base.clone();
let rid = setup.repo_id.clone();
move || {
let c = Client::new(base);
(c.refs(&rid).unwrap(), c.repo_info(&rid).unwrap())
}
})
.await
.unwrap();
assert_eq!(c_refs.branches.get("main"), Some(&head.to_hex()));
assert_eq!(c_refs.releases.get("v1.0.0"), Some(&release_id.to_hex()));
assert!(c_info.is_mirror);
assert_eq!(c_info.mirror_source.as_deref(), Some(a_base.as_str()));
// ---- 6. Mirror is read-only: push to C must 403. ----
let push_to_mirror = tokio::task::spawn_blocking({
let base = c_base.clone();
let seed = *setup.sk.seed();
let auth_id = setup.auth_id;
let repo_id = setup.repo_id.clone();
let prev = head;
move || {
let sk = SecretKey::from_seed(seed);
let client = Client::new(base);
let (pack, cid, _) = build_commit(
&sk, auth_id, "a.txt", b"fourth\n", Some(prev), 1_700_000_020_000_000,
);
let manifest = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/main".into(),
old_hash: Some(prev.to_hex()),
new_hash: cid.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &pack, &manifest)
}
})
.await
.unwrap();
match push_to_mirror {
Err(levcs_client::ClientError::Server { status: 403, body }) => {
assert!(body.contains("mirror"));
}
other => panic!("expected 403 from mirror, got {other:?}"),
}
// ---- 7. Migrate the repo to B by replaying init + history + release. ----
// This is what `levcs migrate` does end-to-end: re-init under the
// same authority, re-push the cloned commit pack, and re-push the
// release as its own pack (releases aren't reachable from commits,
// so the clone walk doesn't carry them).
let release_bytes = tokio::task::spawn_blocking({
let base = a_base.clone();
let rid = setup.repo_id.clone();
move || Client::new(base).get_object(&rid, release_id).unwrap()
})
.await
.unwrap();
tokio::task::spawn_blocking({
let base = b_base.clone();
let seed = *setup.sk.seed();
let auth_id = setup.auth_id;
let repo_id = setup.repo_id.clone();
let auth_bytes = setup.auth_bytes.clone();
let pack = cloned.clone();
let release_bytes = release_bytes.clone();
move || -> Result<(), levcs_client::ClientError> {
let sk = SecretKey::from_seed(seed);
let client = Client::new(base);
client.init(&sk, &repo_id, &auth_bytes)?;
// Push commit history.
let manifest_main = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/main".into(),
old_hash: None,
new_hash: head.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &pack, &manifest_main)?;
// Push the release object on its own.
let mut rpack = Pack::new();
rpack.push(ObjectType::Release as u8, release_bytes);
let manifest_release = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/releases/v1.0.0".into(),
old_hash: None,
new_hash: release_id.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &rpack, &manifest_release)?;
Ok(())
}
})
.await
.unwrap()
.expect("migrate to B must succeed");
let b_refs = tokio::task::spawn_blocking({
let base = b_base.clone();
let rid = setup.repo_id.clone();
move || Client::new(base).refs(&rid).unwrap()
})
.await
.unwrap();
assert_eq!(b_refs.branches.get("main"), Some(&head.to_hex()));
assert_eq!(b_refs.releases.get("v1.0.0"), Some(&release_id.to_hex()));
// ---- 8. Spot-check object identity across all three instances. ----
// The commit object's serialized bytes are content-addressed, so
// identical commit_id on all three instances is necessary; identical
// *bytes* is the stronger property we want to verify.
let (a_obj, b_obj, c_obj) = tokio::task::spawn_blocking({
let a = a_base.clone();
let b = b_base.clone();
let c = c_base.clone();
let rid = setup.repo_id.clone();
let head = head;
move || {
(
Client::new(a).get_object(&rid, head).unwrap(),
Client::new(b).get_object(&rid, head).unwrap(),
Client::new(c).get_object(&rid, head).unwrap(),
)
}
})
.await
.unwrap();
assert_eq!(a_obj, b_obj, "A and B must serve byte-identical head commits");
assert_eq!(a_obj, c_obj, "A and C must serve byte-identical head commits");
assert_eq!(blake3_hash(&a_obj), head, "object hash must match the requested id");
a_task.abort();
b_task.abort();
c_task.abort();
let _ = std::fs::remove_dir_all(a_root);
let _ = std::fs::remove_dir_all(b_root);
let _ = std::fs::remove_dir_all(c_root);
}

View File

@ -0,0 +1,117 @@
//! Federation integration test: spin up an in-process instance, exercise the
//! /instance/info and /repos/{id}/init paths over HTTP via the client.
use std::net::SocketAddr;
use std::path::PathBuf;
use std::time::Duration;
use levcs_core::ZERO_ID;
use levcs_identity::authority::{AuthorityBody, MemberEntry, PolicyEntry, Role};
use levcs_identity::keys::SecretKey;
use levcs_identity::sign::sign_authority;
use levcs_instance::{serve, AppState, InstanceConfig};
fn tempdir() -> PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("levcs-fed-{n}-{}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn instance_info_and_init_roundtrip() {
let root = tempdir();
let config = InstanceConfig {
root: root.clone(),
storage_mode: "full".into(),
federation_peers: Vec::new(),
allowed_handlers: vec!["builtin".into()],
mirrors: Vec::new(),
};
let state = AppState::new(config);
// Bind to an ephemeral port.
let listener = tokio::net::TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let app = levcs_instance::router(state);
let serve_task = tokio::spawn(async move {
axum::serve(listener, app).await.ok();
});
let base = format!("http://{addr}/levcs/v1");
// /instance/info via plain reqwest
let client = reqwest::Client::new();
let info: serde_json::Value = client
.get(format!("{base}/instance/info"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(info["software"], "levcs-instance");
// POST /repos/{id}/init
let sk = SecretKey::generate();
let pk = sk.public();
let now = 1_700_000_000_000_000;
let mut body = AuthorityBody {
schema_version: 1,
repo_id: ZERO_ID,
previous_authority: ZERO_ID,
version: 1,
created_micros: now,
members: vec![MemberEntry {
key: pk,
handle: "alice".into(),
role: Role::Owner,
added_micros: now,
added_by: pk,
}],
policy: vec![PolicyEntry { key: "public_read".into(), value: vec![0x01] }],
};
body.normalize().unwrap();
body.assign_genesis_repo_id().unwrap();
let signed = sign_authority(&body, &sk).unwrap();
let bytes = signed.serialize();
let repo_id = body.repo_id.to_hex();
// Sign request manually via levcs-protocol's sign_request.
use levcs_protocol::auth::{sign_request, AuthRequest};
let path = format!("/repos/{repo_id}/init");
let req = AuthRequest { method: "POST", path_with_query: &path, body: &bytes };
let (key, ts, nonce, sig) = sign_request(&sk, &req).unwrap();
let res = client
.post(format!("{base}{path}"))
.header("LeVCS-Key", key)
.header("LeVCS-Timestamp", ts)
.header("LeVCS-Nonce", nonce)
.header("LeVCS-Signature", sig)
.header("Content-Type", "application/octet-stream")
.body(bytes.clone())
.timeout(Duration::from_secs(5))
.send()
.await
.unwrap();
assert!(res.status().is_success(), "init returned {}: {}", res.status(), res.text().await.unwrap());
// /repos/{id}/info should now succeed
let info: serde_json::Value = client
.get(format!("{base}/repos/{repo_id}/info"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(info["repo_id"], repo_id);
serve_task.abort();
let _ = std::fs::remove_dir_all(root);
let _ = serve;
}

View File

@ -0,0 +1,334 @@
//! Force-push enforcement (§5.4(e), §7.3.2).
//!
//! Spec rules:
//! * A non-fast-forward push (new_hash is not a descendant of the
//! ref's current value) must be rejected with a clear error.
//! * `--force` permits the override, but only when the pusher holds
//! maintainer or owner role.
//!
//! Our test pushes one commit, then tries to advance the ref to a
//! sibling commit (no shared ancestry beyond the existing ref's
//! current value). Without `force` that's a non-fast-forward and must
//! get a 409. With `force` and an owner-role key it's allowed.
use std::net::SocketAddr;
use std::path::PathBuf;
use levcs_client::{Client, ClientError};
use levcs_core::hash::blake3_hash;
use levcs_core::object::ObjectType;
use levcs_core::{
Blob, Commit, CommitFlags, EntryType, FileMode, ObjectId, Tree, TreeEntry, ZERO_ID,
};
use levcs_identity::authority::{AuthorityBody, MemberEntry, PolicyEntry, Role};
use levcs_identity::keys::SecretKey;
use levcs_identity::sign::{sign_authority, sign_commit};
use levcs_instance::{router, AppState, InstanceConfig};
use levcs_protocol::wire::{PushManifest, PushUpdate};
use levcs_protocol::Pack;
fn tempdir(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("{prefix}-{n}-{}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
async fn start() -> (SocketAddr, tokio::task::JoinHandle<()>, PathBuf) {
let root = tempdir("levcs-force-push");
let cfg = InstanceConfig {
root: root.clone(),
storage_mode: "full".into(),
federation_peers: Vec::new(),
allowed_handlers: Vec::new(),
mirrors: Vec::new(),
};
let state = AppState::new(cfg);
let app = router(state);
let listener = tokio::net::TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let task = tokio::spawn(async move {
axum::serve(listener, app).await.ok();
});
(addr, task, root)
}
struct Setup {
sk: SecretKey,
auth_id: ObjectId,
repo_id: String,
auth_bytes: Vec<u8>,
}
fn build_genesis() -> Setup {
let sk = SecretKey::generate();
let pk = sk.public();
let now = 1_700_000_000_000_000;
let mut auth = AuthorityBody {
schema_version: 1,
repo_id: ZERO_ID,
previous_authority: ZERO_ID,
version: 1,
created_micros: now,
members: vec![MemberEntry {
key: pk,
handle: "alice".into(),
role: Role::Owner,
added_micros: now,
added_by: pk,
}],
policy: vec![PolicyEntry { key: "public_read".into(), value: vec![0x01] }],
};
auth.normalize().unwrap();
auth.assign_genesis_repo_id().unwrap();
let signed = sign_authority(&auth, &sk).unwrap();
let auth_bytes = signed.serialize();
let auth_id = blake3_hash(&auth_bytes);
let repo_id = auth.repo_id.to_hex();
Setup { sk, auth_id, repo_id, auth_bytes }
}
/// Build a single root commit whose tree carries one blob with the
/// provided contents. Different `marker` strings give different
/// commit hashes — useful when we want two commits with no shared
/// ancestry beyond the genesis state.
fn build_commit(
sk: &SecretKey,
auth_id: ObjectId,
marker: &str,
) -> (Pack, ObjectId) {
let pk = sk.public();
let blob = Blob::new(format!("hello-{marker}\n").into_bytes());
let blob_bytes = blob.serialize();
let blob_id = blake3_hash(&blob_bytes);
let mut tree = Tree::new();
tree.entries.push(TreeEntry {
name: "a.txt".into(),
entry_type: EntryType::Blob,
mode: FileMode::REGULAR,
hash: blob_id,
});
tree.sort_and_validate().unwrap();
let tree_bytes = tree.serialize();
let tree_id = blake3_hash(&tree_bytes);
let commit = Commit {
tree: tree_id,
parents: vec![],
authority: auth_id,
author_key: pk.0,
timestamp_micros: 1_700_000_000_000_000,
flags: CommitFlags::NONE,
message: marker.into(),
};
let signed = sign_commit(commit, sk).unwrap();
let commit_bytes = signed.serialize();
let commit_id = blake3_hash(&commit_bytes);
let mut pack = Pack::new();
pack.push(ObjectType::Blob as u8, blob_bytes);
pack.push(ObjectType::Tree as u8, tree_bytes);
pack.push(ObjectType::Commit as u8, commit_bytes);
(pack, commit_id)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn non_fast_forward_without_force_is_rejected() {
let (addr, task, root) = start().await;
let base = format!("http://{addr}/levcs/v1");
let setup = build_genesis();
let result = tokio::task::spawn_blocking({
let seed = *setup.sk.seed();
let auth_id = setup.auth_id;
let repo_id = setup.repo_id.clone();
let auth_bytes = setup.auth_bytes.clone();
move || -> Result<(), ClientError> {
let sk = SecretKey::from_seed(seed);
let client = Client::new(base);
client.init(&sk, &repo_id, &auth_bytes)?;
// First push: commit "a", a clean fast-forward from nothing.
let (pack_a, id_a) = build_commit(&sk, auth_id, "a");
let manifest_a = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/main".into(),
old_hash: None,
new_hash: id_a.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &pack_a, &manifest_a)?;
// Second push: commit "b", *also* a root commit with no
// ancestry to "a". Try to overwrite main without --force.
let (pack_b, id_b) = build_commit(&sk, auth_id, "b");
let manifest_b = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/main".into(),
old_hash: Some(id_a.to_hex()),
new_hash: id_b.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &pack_b, &manifest_b)
}
})
.await
.unwrap();
match result {
Err(ClientError::Server { status, body }) => {
assert_eq!(status, 409, "non-FF must be 409");
assert!(
body.contains("non-fast-forward") && body.contains("--force"),
"error must explain: {body}"
);
}
other => panic!("expected 409 server error, got {other:?}"),
}
task.abort();
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn non_fast_forward_with_force_and_owner_role_succeeds() {
let (addr, task, root) = start().await;
let base = format!("http://{addr}/levcs/v1");
let setup = build_genesis();
let result = tokio::task::spawn_blocking({
let seed = *setup.sk.seed();
let auth_id = setup.auth_id;
let repo_id = setup.repo_id.clone();
let auth_bytes = setup.auth_bytes.clone();
move || -> Result<(), ClientError> {
let sk = SecretKey::from_seed(seed);
let client = Client::new(base);
client.init(&sk, &repo_id, &auth_bytes)?;
let (pack_a, id_a) = build_commit(&sk, auth_id, "a");
let manifest_a = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/main".into(),
old_hash: None,
new_hash: id_a.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &pack_a, &manifest_a)?;
// Same non-FF push, but with force=true. Alice is Owner so
// the role check passes.
let (pack_b, id_b) = build_commit(&sk, auth_id, "b");
let manifest_b = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/main".into(),
old_hash: Some(id_a.to_hex()),
new_hash: id_b.to_hex(),
}],
timestamp: 0,
force: true,
};
client.push(&sk, &repo_id, &pack_b, &manifest_b)
}
})
.await
.unwrap();
assert!(result.is_ok(), "owner+force must succeed: {result:?}");
task.abort();
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fast_forward_succeeds_without_force() {
// Sanity check: a real fast-forward (parent-of-existing) must
// still work without `force`. This guards against the new check
// accidentally requiring force on every advance.
let (addr, task, root) = start().await;
let base = format!("http://{addr}/levcs/v1");
let setup = build_genesis();
let result = tokio::task::spawn_blocking({
let seed = *setup.sk.seed();
let auth_id = setup.auth_id;
let repo_id = setup.repo_id.clone();
let auth_bytes = setup.auth_bytes.clone();
move || -> Result<(), ClientError> {
let sk = SecretKey::from_seed(seed);
let client = Client::new(base);
client.init(&sk, &repo_id, &auth_bytes)?;
let pk = sk.public();
// First commit (root, no parents).
let (pack_a, id_a) = build_commit(&sk, auth_id, "a");
let manifest_a = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/main".into(),
old_hash: None,
new_hash: id_a.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &pack_a, &manifest_a)?;
// Second commit explicitly parented on `a` — a true FF.
let blob = Blob::new(b"hello-c\n".to_vec());
let blob_bytes = blob.serialize();
let blob_id = blake3_hash(&blob_bytes);
let mut tree = Tree::new();
tree.entries.push(TreeEntry {
name: "a.txt".into(),
entry_type: EntryType::Blob,
mode: FileMode::REGULAR,
hash: blob_id,
});
tree.sort_and_validate().unwrap();
let tree_bytes = tree.serialize();
let tree_id = blake3_hash(&tree_bytes);
let commit = Commit {
tree: tree_id,
parents: vec![id_a],
authority: auth_id,
author_key: pk.0,
timestamp_micros: 1_700_000_001_000_000,
flags: CommitFlags::NONE,
message: "c".into(),
};
let signed = sign_commit(commit, &sk).unwrap();
let commit_bytes = signed.serialize();
let id_c = blake3_hash(&commit_bytes);
let mut pack_c = Pack::new();
pack_c.push(ObjectType::Blob as u8, blob_bytes);
pack_c.push(ObjectType::Tree as u8, tree_bytes);
pack_c.push(ObjectType::Commit as u8, commit_bytes);
let manifest_c = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/main".into(),
old_hash: Some(id_a.to_hex()),
new_hash: id_c.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &pack_c, &manifest_c)
}
})
.await
.unwrap();
assert!(result.is_ok(), "FF must succeed without force: {result:?}");
task.abort();
let _ = std::fs::remove_dir_all(root);
}

View File

@ -0,0 +1,57 @@
//! `/health` operational endpoint smoke test.
//!
//! The reverse proxy in front of a deployed instance probes this for
//! liveness, so it has to be (a) cheap, (b) reachable without auth,
//! and (c) outside the `/levcs/v1` namespace so a future API change
//! can't accidentally affect it.
use std::net::SocketAddr;
use std::path::PathBuf;
use levcs_instance::{router, AppState, InstanceConfig};
fn tempdir(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("{prefix}-{n}-{}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn health_returns_ok_without_auth() {
let root = tempdir("levcs-health");
let cfg = InstanceConfig {
root: root.clone(),
storage_mode: "full".into(),
federation_peers: Vec::new(),
allowed_handlers: Vec::new(),
mirrors: Vec::new(),
};
let state = AppState::new(cfg);
let app = router(state);
let listener = tokio::net::TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let task = tokio::spawn(async move {
axum::serve(listener, app).await.ok();
});
let resp = tokio::task::spawn_blocking(move || {
reqwest::blocking::get(format!("http://{addr}/health"))
.unwrap()
.text()
.unwrap()
})
.await
.unwrap();
assert!(resp.contains("\"status\""));
assert!(resp.contains("\"ok\""));
task.abort();
let _ = std::fs::remove_dir_all(root);
}

View File

@ -0,0 +1,420 @@
//! Inter-instance mirroring (§5.6) and repository movement (§5.7).
//!
//! These tests boot two in-process instances and exercise the data
//! plane between them: a source instance receives a push, then a
//! mirror instance configured against that source pulls the same
//! state via `sync_mirror` and ends up serving identical /refs.
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use levcs_client::Client;
use levcs_core::hash::blake3_hash;
use levcs_core::object::ObjectType;
use levcs_core::{
Blob, Commit, CommitFlags, EntryType, FileMode, Tree, TreeEntry, ZERO_ID,
};
use levcs_identity::authority::{AuthorityBody, MemberEntry, PolicyEntry, Role};
use levcs_identity::keys::SecretKey;
use levcs_identity::sign::{sign_authority, sign_commit};
use levcs_instance::mirror::sync_mirror;
use levcs_instance::{router, AppState, InstanceConfig, MirrorConfig};
use levcs_protocol::wire::{PushManifest, PushUpdate};
use levcs_protocol::Pack;
fn tempdir(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("{prefix}-{n}-{}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
async fn start(cfg: InstanceConfig) -> (SocketAddr, tokio::task::JoinHandle<()>) {
let state = AppState::new(cfg);
let app = router(state);
let listener = tokio::net::TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let task = tokio::spawn(async move {
axum::serve(listener, app).await.ok();
});
(addr, task)
}
struct Setup {
sk: SecretKey,
auth_id: levcs_core::ObjectId,
repo_id: String,
auth_bytes: Vec<u8>,
}
fn build_genesis() -> Setup {
let sk = SecretKey::generate();
let pk = sk.public();
let now = 1_700_000_000_000_000;
let mut auth = AuthorityBody {
schema_version: 1,
repo_id: ZERO_ID,
previous_authority: ZERO_ID,
version: 1,
created_micros: now,
members: vec![MemberEntry {
key: pk,
handle: "alice".into(),
role: Role::Owner,
added_micros: now,
added_by: pk,
}],
policy: vec![PolicyEntry {
key: "public_read".into(),
value: vec![0x01],
}],
};
auth.normalize().unwrap();
auth.assign_genesis_repo_id().unwrap();
let signed = sign_authority(&auth, &sk).unwrap();
let auth_bytes = signed.serialize();
let auth_id = blake3_hash(&auth_bytes);
let repo_id = auth.repo_id.to_hex();
Setup { sk, auth_id, repo_id, auth_bytes }
}
fn build_simple_commit_pack(
sk: &SecretKey,
auth_id: levcs_core::ObjectId,
file: &str,
content: &[u8],
parent: Option<levcs_core::ObjectId>,
) -> (Pack, levcs_core::ObjectId) {
let pk = sk.public();
let blob = Blob::new(content.to_vec());
let blob_bytes = blob.serialize();
let blob_id = blake3_hash(&blob_bytes);
let mut top = Tree::new();
top.entries.push(TreeEntry {
name: file.into(),
entry_type: EntryType::Blob,
mode: FileMode::REGULAR,
hash: blob_id,
});
top.sort_and_validate().unwrap();
let tree_bytes = top.serialize();
let tree_id = blake3_hash(&tree_bytes);
let commit = Commit {
tree: tree_id,
parents: parent.map(|p| vec![p]).unwrap_or_default(),
authority: auth_id,
author_key: pk.0,
timestamp_micros: 1_700_000_001_000_000,
flags: CommitFlags::NONE,
message: "test commit".into(),
};
let commit_signed = sign_commit(commit, sk).unwrap();
let commit_bytes = commit_signed.serialize();
let commit_id = blake3_hash(&commit_bytes);
let mut pack = Pack::new();
pack.push(ObjectType::Blob as u8, blob_bytes);
pack.push(ObjectType::Tree as u8, tree_bytes);
pack.push(ObjectType::Commit as u8, commit_bytes);
(pack, commit_id)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn mirror_pulls_state_from_source() {
// Source: authoritative for the repo. Mirror: configured to pull from
// source. After source receives a push, sync_mirror on the mirror
// must replicate refs and content, and the mirror's /info must
// advertise itself as a mirror of the source.
let source_root = tempdir("mirror-src");
let mirror_root = tempdir("mirror-dst");
let source_cfg = InstanceConfig {
root: source_root.clone(),
storage_mode: "full".into(),
federation_peers: Vec::new(),
allowed_handlers: Vec::new(),
mirrors: Vec::new(),
};
let (source_addr, source_task) = start(source_cfg).await;
let source_base = format!("http://{source_addr}/levcs/v1");
let setup = build_genesis();
// Push a commit to source from a client.
let result = tokio::task::spawn_blocking({
let base = source_base.clone();
let seed = *setup.sk.seed();
let auth_id = setup.auth_id;
let repo_id = setup.repo_id.clone();
let auth_bytes = setup.auth_bytes.clone();
move || {
let sk = SecretKey::from_seed(seed);
let client = Client::new(base);
client.init(&sk, &repo_id, &auth_bytes).unwrap();
let (pack, commit_id) =
build_simple_commit_pack(&sk, auth_id, "hello.txt", b"hello\n", None);
let manifest = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/main".into(),
old_hash: None,
new_hash: commit_id.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &pack, &manifest)?;
Ok::<_, levcs_client::ClientError>(commit_id)
}
})
.await
.unwrap();
let source_commit = result.expect("push to source must succeed");
// Configure the mirror instance pointing at source.
let mirror_cfg = InstanceConfig {
root: mirror_root.clone(),
storage_mode: "full".into(),
federation_peers: Vec::new(),
allowed_handlers: Vec::new(),
mirrors: vec![MirrorConfig {
repo_id: setup.repo_id.clone(),
source: source_base.clone(),
mode: "full".into(),
poll_interval: "60s".into(),
writeback: false,
}],
};
let mirror_cfg_arc = Arc::new(mirror_cfg.clone());
let (mirror_addr, mirror_task) = start(mirror_cfg).await;
let mirror_base = format!("http://{mirror_addr}/levcs/v1");
// Run sync_mirror once on the mirror's behalf.
let report = tokio::task::spawn_blocking({
let cfg = mirror_cfg_arc.clone();
let m = cfg.mirrors[0].clone();
move || sync_mirror(&cfg, &m)
})
.await
.unwrap()
.expect("sync should succeed");
assert!(
report.objects_received >= 4,
"expected pack with at least authority + commit + tree + blob; got {}",
report.objects_received
);
assert_eq!(report.branches_updated, 1);
// Mirror's /refs must now match source.
let (mirror_refs, mirror_info) = tokio::task::spawn_blocking({
let mb = mirror_base.clone();
let rid = setup.repo_id.clone();
move || {
let c = Client::new(mb);
let r = c.refs(&rid).unwrap();
let i = c.repo_info(&rid).unwrap();
(r, i)
}
})
.await
.unwrap();
assert_eq!(
mirror_refs.branches.get("main"),
Some(&source_commit.to_hex()),
"mirror's main must match source"
);
assert!(mirror_info.is_mirror, "/info must declare mirror status");
assert_eq!(mirror_info.mirror_source.as_deref(), Some(source_base.as_str()));
assert_eq!(mirror_info.mirror_mode.as_deref(), Some("full"));
// Push to mirror must be refused (read-only by config).
let push_err = tokio::task::spawn_blocking({
let mb = mirror_base.clone();
let seed = *setup.sk.seed();
let auth_id = setup.auth_id;
let repo_id = setup.repo_id.clone();
move || {
let sk = SecretKey::from_seed(seed);
let client = Client::new(mb);
let (pack, commit_id) =
build_simple_commit_pack(&sk, auth_id, "x.txt", b"x\n", None);
let manifest = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/scratch".into(),
old_hash: None,
new_hash: commit_id.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &pack, &manifest)
}
})
.await
.unwrap();
match push_err {
Err(levcs_client::ClientError::Server { status: 403, body }) => {
assert!(
body.contains("mirror") && body.contains(&source_base),
"403 body should explain and point at the source: {body}"
);
}
other => panic!("expected 403 push refusal, got {other:?}"),
}
// Idempotence: a second sync with no source change is a no-op.
let report2 = tokio::task::spawn_blocking({
let cfg = mirror_cfg_arc.clone();
let m = cfg.mirrors[0].clone();
move || sync_mirror(&cfg, &m)
})
.await
.unwrap()
.unwrap();
assert_eq!(
report2.branches_updated, 0,
"second sync should not advance any ref"
);
source_task.abort();
mirror_task.abort();
let _ = std::fs::remove_dir_all(source_root);
let _ = std::fs::remove_dir_all(mirror_root);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn migrate_replays_repo_to_fresh_instance() {
// Spin up two authoritative instances. Init+push to the first, then
// run the same init+push sequence against the second — the unit
// under test is the wire path the `levcs migrate` orchestration
// exercises end-to-end. The new instance must end up serving
// identical refs under the same repo_id.
let src_root = tempdir("migrate-src");
let dst_root = tempdir("migrate-dst");
let src_cfg = InstanceConfig {
root: src_root.clone(),
storage_mode: "full".into(),
federation_peers: Vec::new(),
allowed_handlers: Vec::new(),
mirrors: Vec::new(),
};
let dst_cfg = InstanceConfig {
root: dst_root.clone(),
storage_mode: "full".into(),
federation_peers: Vec::new(),
allowed_handlers: Vec::new(),
mirrors: Vec::new(),
};
let (src_addr, src_task) = start(src_cfg).await;
let (dst_addr, dst_task) = start(dst_cfg).await;
let src_base = format!("http://{src_addr}/levcs/v1");
let dst_base = format!("http://{dst_addr}/levcs/v1");
let setup = build_genesis();
let repo_id = setup.repo_id.clone();
let auth_id = setup.auth_id;
// Push to source first.
let source_commit = tokio::task::spawn_blocking({
let base = src_base.clone();
let seed = *setup.sk.seed();
let repo_id = repo_id.clone();
let auth_bytes = setup.auth_bytes.clone();
move || {
let sk = SecretKey::from_seed(seed);
let client = Client::new(base);
client.init(&sk, &repo_id, &auth_bytes).unwrap();
let (pack, commit_id) =
build_simple_commit_pack(&sk, auth_id, "f.txt", b"v1\n", None);
let manifest = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/main".into(),
old_hash: None,
new_hash: commit_id.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &pack, &manifest).unwrap();
commit_id
}
})
.await
.unwrap();
// Migrate path: same identity, init on destination, push everything.
let migrated_commit = tokio::task::spawn_blocking({
let base = dst_base.clone();
let seed = *setup.sk.seed();
let repo_id = repo_id.clone();
let auth_bytes = setup.auth_bytes.clone();
move || {
let sk = SecretKey::from_seed(seed);
let client = Client::new(base);
// §5.7 step 1: init with the authority object.
client.init(&sk, &repo_id, &auth_bytes).unwrap();
// §5.7 step 3: push history.
let (pack, commit_id) =
build_simple_commit_pack(&sk, auth_id, "f.txt", b"v1\n", None);
let manifest = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/main".into(),
old_hash: None,
new_hash: commit_id.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &pack, &manifest).unwrap();
commit_id
}
})
.await
.unwrap();
// The repo_id must be identical on both sides — that is the property
// §5.7 promises ("identifiably the same project at the new location").
// And the same content yields the same commit hash deterministically.
assert_eq!(source_commit, migrated_commit);
// Both instances must now report the same /refs and the same /info
// (modulo mirror metadata, which neither has set).
let (src_refs, dst_refs, src_info, dst_info) = tokio::task::spawn_blocking({
let s = src_base.clone();
let d = dst_base.clone();
let r = repo_id.clone();
move || {
let cs = Client::new(s);
let cd = Client::new(d);
(
cs.refs(&r).unwrap(),
cd.refs(&r).unwrap(),
cs.repo_info(&r).unwrap(),
cd.repo_info(&r).unwrap(),
)
}
})
.await
.unwrap();
assert_eq!(src_refs.branches, dst_refs.branches);
assert_eq!(src_info.repo_id, dst_info.repo_id);
assert_eq!(src_info.genesis_authority, dst_info.genesis_authority);
assert_eq!(src_info.current_authority, dst_info.current_authority);
assert!(!src_info.is_mirror && !dst_info.is_mirror);
src_task.abort();
dst_task.abort();
let _ = std::fs::remove_dir_all(src_root);
let _ = std::fs::remove_dir_all(dst_root);
}

View File

@ -0,0 +1,316 @@
//! Instance merge-policy enforcement on push (§6.6.4).
//!
//! Each test boots an in-process instance, init's a repo via the client,
//! then pushes a hand-built commit. The interesting axis is whether that
//! commit's tree carries `.levcs/merge-record` and what handler names that
//! record references.
use std::net::SocketAddr;
use std::path::PathBuf;
use levcs_client::Client;
use levcs_core::hash::blake3_hash;
use levcs_core::object::ObjectType;
use levcs_core::{
Blob, Commit, CommitFlags, EntryType, FileMode, Tree, TreeEntry, ZERO_ID,
};
use levcs_identity::authority::{AuthorityBody, MemberEntry, PolicyEntry, Role};
use levcs_identity::keys::SecretKey;
use levcs_identity::sign::{sign_authority, sign_commit};
use levcs_instance::{router, AppState, InstanceConfig};
use levcs_protocol::wire::{PushManifest, PushUpdate};
use levcs_protocol::Pack;
fn tempdir(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("{prefix}-{n}-{}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
async fn start(allowed_handlers: Vec<String>) -> (SocketAddr, tokio::task::JoinHandle<()>, PathBuf) {
let root = tempdir("levcs-policy");
let cfg = InstanceConfig {
root: root.clone(),
storage_mode: "full".into(),
federation_peers: Vec::new(),
allowed_handlers,
mirrors: Vec::new(),
};
let state = AppState::new(cfg);
let app = router(state);
let listener = tokio::net::TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let task = tokio::spawn(async move {
axum::serve(listener, app).await.ok();
});
(addr, task, root)
}
struct Setup {
sk: SecretKey,
auth_id: levcs_core::ObjectId,
repo_id: String,
auth_bytes: Vec<u8>,
}
fn build_genesis() -> Setup {
let sk = SecretKey::generate();
let pk = sk.public();
let now = 1_700_000_000_000_000;
let mut auth = AuthorityBody {
schema_version: 1,
repo_id: ZERO_ID,
previous_authority: ZERO_ID,
version: 1,
created_micros: now,
members: vec![MemberEntry {
key: pk,
handle: "alice".into(),
role: Role::Owner,
added_micros: now,
added_by: pk,
}],
policy: vec![PolicyEntry { key: "public_read".into(), value: vec![0x01] }],
};
auth.normalize().unwrap();
auth.assign_genesis_repo_id().unwrap();
let signed = sign_authority(&auth, &sk).unwrap();
let auth_bytes = signed.serialize();
let auth_id = blake3_hash(&auth_bytes);
let repo_id = auth.repo_id.to_hex();
Setup { sk, auth_id, repo_id, auth_bytes }
}
/// Build a commit whose tree has `path` -> blob(content) and (optionally) a
/// `.levcs/merge-record` blob containing `merge_record_toml`. Returns the
/// pack containing all new objects and the new commit's id.
fn build_pack_with_optional_record(
sk: &SecretKey,
auth_id: levcs_core::ObjectId,
path: &str,
content: &[u8],
merge_record_toml: Option<&str>,
parent: Option<levcs_core::ObjectId>,
) -> (Pack, levcs_core::ObjectId) {
let pk = sk.public();
let blob = Blob::new(content.to_vec());
let blob_bytes = blob.serialize();
let blob_id = blake3_hash(&blob_bytes);
let mut top = Tree::new();
top.entries.push(TreeEntry {
name: path.into(),
entry_type: EntryType::Blob,
mode: FileMode::REGULAR,
hash: blob_id,
});
let mut pack = Pack::new();
pack.push(ObjectType::Blob as u8, blob_bytes);
if let Some(toml) = merge_record_toml {
let mr_blob = Blob::new(toml.as_bytes().to_vec());
let mr_blob_bytes = mr_blob.serialize();
let mr_blob_id = blake3_hash(&mr_blob_bytes);
let mut levcs_tree = Tree::new();
levcs_tree.entries.push(TreeEntry {
name: "merge-record".into(),
entry_type: EntryType::Blob,
mode: FileMode::REGULAR,
hash: mr_blob_id,
});
levcs_tree.sort_and_validate().unwrap();
let levcs_tree_bytes = levcs_tree.serialize();
let levcs_tree_id = blake3_hash(&levcs_tree_bytes);
top.entries.push(TreeEntry {
name: ".levcs".into(),
entry_type: EntryType::Tree,
mode: FileMode::REGULAR,
hash: levcs_tree_id,
});
pack.push(ObjectType::Blob as u8, mr_blob_bytes);
pack.push(ObjectType::Tree as u8, levcs_tree_bytes);
}
top.sort_and_validate().unwrap();
let tree_bytes = top.serialize();
let tree_id = blake3_hash(&tree_bytes);
pack.push(ObjectType::Tree as u8, tree_bytes);
let commit = Commit {
tree: tree_id,
parents: parent.map(|p| vec![p]).unwrap_or_default(),
authority: auth_id,
author_key: pk.0,
timestamp_micros: 1_700_000_001_000_000,
flags: CommitFlags::NONE,
message: "test commit".into(),
};
let commit_signed = sign_commit(commit, sk).unwrap();
let commit_bytes = commit_signed.serialize();
let commit_id = blake3_hash(&commit_bytes);
pack.push(ObjectType::Commit as u8, commit_bytes);
(pack, commit_id)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn builtin_only_policy_admits_clean_push() {
let (addr, task, root) = start(vec!["builtin".into()]).await;
let base = format!("http://{addr}/levcs/v1");
let setup = build_genesis();
let result = tokio::task::spawn_blocking({
let seed = *setup.sk.seed();
let auth_id = setup.auth_id;
let repo_id = setup.repo_id.clone();
let auth_bytes = setup.auth_bytes.clone();
move || {
let sk = SecretKey::from_seed(seed);
let client = Client::new(base);
client.init(&sk, &repo_id, &auth_bytes).unwrap();
let (pack, commit_id) = build_pack_with_optional_record(
&sk, auth_id, "a.txt", b"hello\n", None, None,
);
let manifest = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/main".into(),
old_hash: None,
new_hash: commit_id.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &pack, &manifest)
}
})
.await
.unwrap();
assert!(result.is_ok(), "clean push should succeed: {result:?}");
task.abort();
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn builtin_only_policy_rejects_disallowed_plugin_handler() {
let (addr, task, root) = start(vec!["builtin".into()]).await;
let base = format!("http://{addr}/levcs/v1");
let setup = build_genesis();
let merge_record_toml = r#"schema_version = 1
base = "blake3:0000000000000000000000000000000000000000000000000000000000000000"
ours = "blake3:1111111111111111111111111111111111111111111111111111111111111111"
theirs = "blake3:2222222222222222222222222222222222222222222222222222222222222222"
[[file]]
path = "schema.proto"
handler = "tree-sitter:protobuf"
handler_hash = "blake3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
status = "auto"
"#;
let result = tokio::task::spawn_blocking({
let seed = *setup.sk.seed();
let auth_id = setup.auth_id;
let repo_id = setup.repo_id.clone();
let auth_bytes = setup.auth_bytes.clone();
let toml = merge_record_toml.to_string();
move || {
let sk = SecretKey::from_seed(seed);
let client = Client::new(base);
client.init(&sk, &repo_id, &auth_bytes).unwrap();
let (pack, commit_id) = build_pack_with_optional_record(
&sk, auth_id, "a.txt", b"hello\n", Some(&toml), None,
);
let manifest = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/main".into(),
old_hash: None,
new_hash: commit_id.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &pack, &manifest)
}
})
.await
.unwrap();
match result {
Err(levcs_client::ClientError::Server { status, body }) => {
assert_eq!(status, 403, "expected 403, got {status} {body}");
assert!(body.contains("tree-sitter:protobuf"), "error must name the rejected handler: {body}");
}
other => panic!("expected 403 server error, got {other:?}"),
}
task.abort();
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn empty_policy_admits_anything() {
let (addr, task, root) = start(vec![]).await;
let base = format!("http://{addr}/levcs/v1");
let setup = build_genesis();
let merge_record_toml = r#"schema_version = 1
base = "blake3:0000000000000000000000000000000000000000000000000000000000000000"
ours = "blake3:1111111111111111111111111111111111111111111111111111111111111111"
theirs = "blake3:2222222222222222222222222222222222222222222222222222222222222222"
[[file]]
path = "schema.proto"
handler = "tree-sitter:protobuf"
handler_hash = "blake3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
status = "auto"
"#;
let result = tokio::task::spawn_blocking({
let seed = *setup.sk.seed();
let auth_id = setup.auth_id;
let repo_id = setup.repo_id.clone();
let auth_bytes = setup.auth_bytes.clone();
let toml = merge_record_toml.to_string();
move || {
let sk = SecretKey::from_seed(seed);
let client = Client::new(base);
client.init(&sk, &repo_id, &auth_bytes).unwrap();
let (pack, commit_id) = build_pack_with_optional_record(
&sk, auth_id, "a.txt", b"hello\n", Some(&toml), None,
);
let manifest = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/main".into(),
old_hash: None,
new_hash: commit_id.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &pack, &manifest)
}
})
.await
.unwrap();
assert!(result.is_ok(), "permissive policy must accept any handler: {result:?}");
task.abort();
let _ = std::fs::remove_dir_all(root);
}

View File

@ -0,0 +1,257 @@
//! Instance storage-mode enforcement on push (§4.3).
//!
//! Each test boots an in-process instance with a specific
//! `storage_mode` and pushes a hand-built commit/release. The push
//! must be rejected (or accepted) according to the rules in §4.3:
//!
//! * `full` — accepts every push.
//! * `release` — only `refs/releases/*` updates are accepted.
//! * `metadata` — every push is rejected (mode is intended to be
//! populated via mirroring, not direct push).
use std::net::SocketAddr;
use std::path::PathBuf;
use levcs_client::{Client, ClientError};
use levcs_core::hash::blake3_hash;
use levcs_core::object::ObjectType;
use levcs_core::{
Blob, Commit, CommitFlags, EntryType, FileMode, Tree, TreeEntry, ZERO_ID,
};
use levcs_identity::authority::{AuthorityBody, MemberEntry, PolicyEntry, Role};
use levcs_identity::keys::SecretKey;
use levcs_identity::sign::{sign_authority, sign_commit};
use levcs_instance::{router, AppState, InstanceConfig};
use levcs_protocol::wire::{PushManifest, PushUpdate};
use levcs_protocol::Pack;
fn tempdir(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
p.push(format!("{prefix}-{n}-{}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
async fn start(mode: &str) -> (SocketAddr, tokio::task::JoinHandle<()>, PathBuf) {
let root = tempdir(&format!("levcs-storage-{mode}"));
let cfg = InstanceConfig {
root: root.clone(),
storage_mode: mode.into(),
federation_peers: Vec::new(),
allowed_handlers: Vec::new(),
mirrors: Vec::new(),
};
let state = AppState::new(cfg);
let app = router(state);
let listener = tokio::net::TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let task = tokio::spawn(async move {
axum::serve(listener, app).await.ok();
});
(addr, task, root)
}
struct Setup {
sk: SecretKey,
auth_id: levcs_core::ObjectId,
repo_id: String,
auth_bytes: Vec<u8>,
}
fn build_genesis() -> Setup {
let sk = SecretKey::generate();
let pk = sk.public();
let now = 1_700_000_000_000_000;
let mut auth = AuthorityBody {
schema_version: 1,
repo_id: ZERO_ID,
previous_authority: ZERO_ID,
version: 1,
created_micros: now,
members: vec![MemberEntry {
key: pk,
handle: "alice".into(),
role: Role::Owner,
added_micros: now,
added_by: pk,
}],
policy: vec![PolicyEntry { key: "public_read".into(), value: vec![0x01] }],
};
auth.normalize().unwrap();
auth.assign_genesis_repo_id().unwrap();
let signed = sign_authority(&auth, &sk).unwrap();
let auth_bytes = signed.serialize();
let auth_id = blake3_hash(&auth_bytes);
let repo_id = auth.repo_id.to_hex();
Setup { sk, auth_id, repo_id, auth_bytes }
}
/// Build a single-blob, single-commit pack for a push test. Returns
/// (pack, commit_id) so the caller can stuff the id into a manifest.
fn build_simple_pack(
sk: &SecretKey,
auth_id: levcs_core::ObjectId,
) -> (Pack, levcs_core::ObjectId) {
let pk = sk.public();
let blob = Blob::new(b"hello\n".to_vec());
let blob_bytes = blob.serialize();
let blob_id = blake3_hash(&blob_bytes);
let mut tree = Tree::new();
tree.entries.push(TreeEntry {
name: "a.txt".into(),
entry_type: EntryType::Blob,
mode: FileMode::REGULAR,
hash: blob_id,
});
tree.sort_and_validate().unwrap();
let tree_bytes = tree.serialize();
let tree_id = blake3_hash(&tree_bytes);
let commit = Commit {
tree: tree_id,
parents: vec![],
authority: auth_id,
author_key: pk.0,
timestamp_micros: 1_700_000_001_000_000,
flags: CommitFlags::NONE,
message: "test".into(),
};
let signed = sign_commit(commit, sk).unwrap();
let commit_bytes = signed.serialize();
let commit_id = blake3_hash(&commit_bytes);
let mut pack = Pack::new();
pack.push(ObjectType::Blob as u8, blob_bytes);
pack.push(ObjectType::Tree as u8, tree_bytes);
pack.push(ObjectType::Commit as u8, commit_bytes);
(pack, commit_id)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn metadata_mode_rejects_all_pushes() {
let (addr, task, root) = start("metadata").await;
let base = format!("http://{addr}/levcs/v1");
let setup = build_genesis();
let result = tokio::task::spawn_blocking({
let seed = *setup.sk.seed();
let auth_id = setup.auth_id;
let repo_id = setup.repo_id.clone();
let auth_bytes = setup.auth_bytes.clone();
move || {
let sk = SecretKey::from_seed(seed);
let client = Client::new(base);
// init is allowed (it's how mirror discovery works); push
// is what gets refused.
client.init(&sk, &repo_id, &auth_bytes).unwrap();
let (pack, commit_id) = build_simple_pack(&sk, auth_id);
let manifest = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/main".into(),
old_hash: None,
new_hash: commit_id.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &pack, &manifest)
}
})
.await
.unwrap();
match result {
Err(ClientError::Server { status, body }) => {
assert_eq!(status, 403);
assert!(body.contains("metadata"), "error must explain mode: {body}");
}
other => panic!("expected 403, got {other:?}"),
}
task.abort();
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn release_mode_rejects_branch_push() {
let (addr, task, root) = start("release").await;
let base = format!("http://{addr}/levcs/v1");
let setup = build_genesis();
let result = tokio::task::spawn_blocking({
let seed = *setup.sk.seed();
let auth_id = setup.auth_id;
let repo_id = setup.repo_id.clone();
let auth_bytes = setup.auth_bytes.clone();
move || {
let sk = SecretKey::from_seed(seed);
let client = Client::new(base);
client.init(&sk, &repo_id, &auth_bytes).unwrap();
let (pack, commit_id) = build_simple_pack(&sk, auth_id);
let manifest = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/main".into(),
old_hash: None,
new_hash: commit_id.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &pack, &manifest)
}
})
.await
.unwrap();
match result {
Err(ClientError::Server { status, body }) => {
assert_eq!(status, 403);
assert!(
body.contains("release-only") && body.contains("refs/branches/main"),
"error must name mode and ref: {body}"
);
}
other => panic!("expected 403, got {other:?}"),
}
task.abort();
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn full_mode_accepts_branch_push() {
let (addr, task, root) = start("full").await;
let base = format!("http://{addr}/levcs/v1");
let setup = build_genesis();
let result = tokio::task::spawn_blocking({
let seed = *setup.sk.seed();
let auth_id = setup.auth_id;
let repo_id = setup.repo_id.clone();
let auth_bytes = setup.auth_bytes.clone();
move || {
let sk = SecretKey::from_seed(seed);
let client = Client::new(base);
client.init(&sk, &repo_id, &auth_bytes).unwrap();
let (pack, commit_id) = build_simple_pack(&sk, auth_id);
let manifest = PushManifest {
authority_hash: auth_id.to_hex(),
updates: vec![PushUpdate {
r#ref: "refs/branches/main".into(),
old_hash: None,
new_hash: commit_id.to_hex(),
}],
timestamp: 0,
force: false,
};
client.push(&sk, &repo_id, &pack, &manifest)
}
})
.await
.unwrap();
assert!(result.is_ok(), "full mode must accept branch push: {result:?}");
task.abort();
let _ = std::fs::remove_dir_all(root);
}

View File

@ -0,0 +1,41 @@
[package]
name = "levcs-merge"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
levcs-core = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
similar = { workspace = true }
glob = { workspace = true }
hex = { workspace = true }
blake3 = { workspace = true }
tree-sitter = { workspace = true }
tree-sitter-rust = { workspace = true }
tree-sitter-python = { workspace = true }
tree-sitter-javascript = { workspace = true }
tree-sitter-typescript = { workspace = true }
tree-sitter-go = { workspace = true }
tree-sitter-c = { workspace = true }
tree-sitter-cpp = { workspace = true }
tree-sitter-java = { workspace = true }
tree-sitter-ruby = { workspace = true }
tree-sitter-bash = { workspace = true }
serde_yaml = { workspace = true }
quick-xml = { workspace = true }
pulldown-cmark = { workspace = true }
wasmtime = { workspace = true }
[dev-dependencies]
wat = { workspace = true }
proptest = { workspace = true }
criterion = { workspace = true }
[[bench]]
name = "textual_merge"
harness = false

View File

@ -0,0 +1,99 @@
//! Textual 3-way merge microbenchmarks.
//!
//! `similar`'s line diff is roughly O((N+M) D) where D is the edit
//! distance, so cost grows quickly with both file size and edit
//! density. We sweep three sizes at ~5% edit density on each side, plus
//! a conflicting variant where both sides edit overlapping regions.
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use levcs_merge::textual::three_way_merge_lines;
/// Build a `lines`-line document. Each line is `"line N: <body>\n"` so
/// the diff has unique anchors and similarity heuristics can do real
/// work — purely random lines would be too easy (every line different)
/// or too hard (lots of false matches).
fn make_doc(lines: usize, body_len: usize) -> String {
let body = "x".repeat(body_len);
let mut s = String::with_capacity(lines * (body_len + 16));
for i in 0..lines {
s.push_str(&format!("line {i:06}: {body}\n"));
}
s
}
/// Mutate every Nth line to simulate a developer's edit pattern. `step`
/// of 20 ≈ 5% line density. Mutations are non-overlapping with the
/// `theirs` mutator below (different residues mod step) so the merge
/// resolves cleanly.
fn mutate_disjoint(doc: &str, step: usize, residue: usize, marker: char) -> String {
let mut out = String::with_capacity(doc.len());
for (i, line) in doc.lines().enumerate() {
if i % step == residue {
out.push_str(line);
out.push(marker);
out.push('\n');
} else {
out.push_str(line);
out.push('\n');
}
}
out
}
/// Mutate every Nth line in a way that overlaps with the other side's
/// edit residue — both sides edit the same lines differently, producing
/// real conflicts.
fn mutate_conflicting(doc: &str, step: usize, marker: &str) -> String {
let mut out = String::with_capacity(doc.len());
for (i, line) in doc.lines().enumerate() {
if i % step == 0 {
out.push_str(marker);
out.push('\n');
} else {
out.push_str(line);
out.push('\n');
}
}
out
}
fn bench_clean_merge(c: &mut Criterion) {
let mut g = c.benchmark_group("textual_merge_clean");
for &(label, lines) in &[("1KiB", 50usize), ("10KiB", 500), ("100KiB", 5000)] {
let base = make_doc(lines, 12);
let ours = mutate_disjoint(&base, 20, 0, '!');
let theirs = mutate_disjoint(&base, 20, 7, '?');
let total_bytes = (base.len() + ours.len() + theirs.len()) as u64;
g.throughput(Throughput::Bytes(total_bytes));
g.bench_with_input(
BenchmarkId::from_parameter(label),
&(base, ours, theirs),
|b, (base, ours, theirs)| {
b.iter(|| black_box(three_way_merge_lines(base, ours, theirs)))
},
);
}
g.finish();
}
fn bench_conflicting_merge(c: &mut Criterion) {
let mut g = c.benchmark_group("textual_merge_conflicting");
for &(label, lines) in &[("1KiB", 50usize), ("10KiB", 500), ("100KiB", 5000)] {
let base = make_doc(lines, 12);
let ours = mutate_conflicting(&base, 20, "OURS-EDIT");
let theirs = mutate_conflicting(&base, 20, "THEIRS-EDIT");
let total_bytes = (base.len() + ours.len() + theirs.len()) as u64;
g.throughput(Throughput::Bytes(total_bytes));
g.bench_with_input(
BenchmarkId::from_parameter(label),
&(base, ours, theirs),
|b, (base, ours, theirs)| {
b.iter(|| black_box(three_way_merge_lines(base, ours, theirs)))
},
);
}
g.finish();
}
criterion_group!(benches, bench_clean_merge, bench_conflicting_merge);
criterion_main!(benches);

View File

@ -0,0 +1,584 @@
//! Cascade engine. Selects a handler by configured glob rule (with built-in
//! defaults), and falls through on `NotApplicable`.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use glob::Pattern;
use serde::{Deserialize, Serialize};
use crate::format::{JsonHandler, TomlHandler};
use crate::format_extra::{MarkdownHandler, ProseHandler, XmlHandler, YamlHandler};
use crate::handler::{MergeHandler, MergeResult, MergeStatus};
use crate::plugin::{PluginConfig, PluginHandler};
use crate::textual::TextualHandler;
use crate::tree_sitter_handler::{Lang, TreeSitterHandler};
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct MergeConfig {
#[serde(default)]
pub schema_version: u32,
#[serde(default, rename = "rule")]
pub rules: Vec<MergeRule>,
#[serde(default, rename = "plugin")]
pub plugins: Vec<MergePluginEntry>,
#[serde(default)]
pub policy: Option<MergePolicy>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MergeRule {
pub glob: String,
pub handler: String,
}
/// A `[[plugin]]` entry in `.levcs/merge.toml` (§6.6.2).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MergePluginEntry {
pub name: String,
/// URL or local path the plugin can be fetched from. Not consulted by
/// the engine itself — callers fetch the bytes and pass them in.
#[serde(default)]
pub source: String,
/// Hex-encoded BLAKE3 of the WASM module. May appear with or without the
/// `blake3:` prefix in the TOML; both are accepted.
pub hash: String,
}
/// `policy.allowed_handlers` from §6.6.2 — the repository's view of which
/// handler names may legally appear in merge metadata.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct MergePolicy {
#[serde(default)]
pub allowed_handlers: Vec<String>,
}
/// Handler-aggressiveness rank used by §6.6.3 to enforce the
/// "per-user config can demote, never promote" rule. The four tiers
/// match the spec's ordering exactly:
///
/// * `0` — textual fallback (least aggressive)
/// * `1` — format-aware (json, yaml, toml, xml, markdown, prose)
/// * `2` — tree-sitter:* built-ins
/// * `3` — anything else (assumed to be a plugin)
///
/// Unknown handler names are treated as plugins (rank 3) so an
/// unknown-named override always counts as a *promotion* attempt and
/// can never sneak past the demote-only check by being unrecognised.
pub fn handler_rank(name: &str) -> u8 {
if name == "textual" {
return 0;
}
if matches!(
name,
"json" | "yaml" | "toml" | "xml" | "markdown" | "prose"
) {
return 1;
}
if name.starts_with("tree-sitter:") && BUILTIN_HANDLERS.contains(&name) {
return 2;
}
// Flow-control labels (ours-only / theirs-only / delete / no-auto /
// none) never reach the cascade — they're synthesised by the merge
// driver itself. Rank them at 0 so they're conservatively accepted
// anywhere.
if FLOW_HANDLERS.contains(&name) {
return 0;
}
3
}
/// Layer a per-user override on top of a repository config (§6.6.3).
/// The override may add new rules and may *demote* a rule's handler
/// (replace it with a lower-rank one), but MUST NOT *promote* — the
/// motivating example is "I don't trust the Rust handler today, force
/// `*.rs` back to textual." Returns the merged config or, on
/// promotion attempt, the offending glob.
pub fn layer_local_over(
repo: &MergeConfig,
local: &MergeConfig,
) -> Result<MergeConfig, String> {
let mut merged = repo.clone();
for local_rule in &local.rules {
let local_rank = handler_rank(&local_rule.handler);
// For each matching glob in the repo config, the override
// rank MUST be ≤ the existing rank. If the glob is new,
// anything goes — there's no prior rank to compare against,
// and the worst case is a path that previously fell to the
// built-in default cascade.
if let Some(existing) = repo.rules.iter().find(|r| r.glob == local_rule.glob) {
let existing_rank = handler_rank(&existing.handler);
if local_rank > existing_rank {
return Err(format!(
"merge.local.toml may not promote handler aggressiveness: \
glob {:?} would go from rank {existing_rank} ({}) to \
rank {local_rank} ({})",
local_rule.glob, existing.handler, local_rule.handler
));
}
}
// Replace the matching rule, or append.
if let Some(slot) = merged.rules.iter_mut().find(|r| r.glob == local_rule.glob) {
slot.handler = local_rule.handler.clone();
} else {
merged.rules.push(local_rule.clone());
}
}
// Per-user config does NOT touch policy.allowed_handlers or
// [[plugin]] entries — those live in repo config exclusively.
// Local plugin sources would be a separate trust escalation.
Ok(merged)
}
pub struct CascadeEngine {
/// Handlers indexed by name.
handlers: Vec<Arc<dyn MergeHandler>>,
/// User-supplied rules; checked first in order.
rules: Vec<MergeRule>,
/// Always-applicable last-resort handler (textual).
fallback: Arc<dyn MergeHandler>,
}
impl Default for CascadeEngine {
fn default() -> Self {
let textual: Arc<dyn MergeHandler> = Arc::new(TextualHandler);
let mut handlers: Vec<Arc<dyn MergeHandler>> = vec![
Arc::new(JsonHandler),
Arc::new(TomlHandler),
Arc::new(YamlHandler),
Arc::new(MarkdownHandler),
Arc::new(ProseHandler),
Arc::new(XmlHandler),
];
for lang in Lang::all() {
handlers.push(Arc::new(TreeSitterHandler::new(*lang)));
}
handlers.push(textual.clone());
Self {
handlers,
rules: Vec::new(),
fallback: textual,
}
}
}
impl CascadeEngine {
pub fn new() -> Self { Self::default() }
pub fn with_config(mut self, cfg: MergeConfig) -> Self {
self.rules = cfg.rules;
self
}
/// Configure rules and load any `[[plugin]]` entries from disk. The
/// callback `fetch` is invoked with `(name, source)` and must return the
/// raw WASM bytes; this lets callers control where plugin modules come
/// from (local cache, registered instance, etc.) without tying the merge
/// engine to a fetch transport.
pub fn with_config_and_plugins<F>(
mut self,
cfg: MergeConfig,
mut fetch: F,
) -> Result<Self, String>
where
F: FnMut(&MergePluginEntry) -> Result<Vec<u8>, String>,
{
self.rules = cfg.rules;
for entry in &cfg.plugins {
let bytes = fetch(entry)?;
let hash = parse_hash(&entry.hash)?;
let plugin = PluginHandler::new(
PluginConfig { name: entry.name.clone(), hash },
&bytes,
)
.map_err(|e| format!("plugin {}: {e}", entry.name))?;
self.handlers.push(Arc::new(plugin));
}
Ok(self)
}
pub fn register(&mut self, h: Arc<dyn MergeHandler>) {
self.handlers.push(h);
}
/// Locate the handler that should run for `path`, considering rules then
/// extension defaults.
fn pick(&self, path: &Path) -> Option<Arc<dyn MergeHandler>> {
let path_str = path.to_string_lossy();
for rule in &self.rules {
if let Ok(pat) = Pattern::new(&rule.glob) {
if pat.matches(&path_str) {
if let Some(h) = self.handlers.iter().find(|h| h.name() == rule.handler) {
return Some(h.clone());
}
}
}
}
// Default cascade by extension.
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let pref: String = match ext {
"json" => "json".into(),
"toml" => "toml".into(),
"yaml" | "yml" => "yaml".into(),
"xml" | "svg" | "html" => "xml".into(),
"md" | "markdown" => "markdown".into(),
"txt" => "prose".into(),
other => match Lang::from_extension(other) {
Some(lang) => lang.handler_name().to_string(),
None => "textual".into(),
},
};
self.handlers.iter().find(|h| h.name() == pref).cloned()
}
pub fn merge_file(
&self,
path: &Path,
base: &[u8],
ours: &[u8],
theirs: &[u8],
) -> MergeResult {
if let Some(h) = self.pick(path) {
if h.applicable(path, base, ours, theirs) {
let result = h.merge(path, base, ours, theirs);
if !matches!(result.status, MergeStatus::NotApplicable) {
return result;
}
}
}
// Fall through to textual.
if self.fallback.applicable(path, base, ours, theirs) {
return self.fallback.merge(path, base, ours, theirs);
}
MergeResult {
handler: "none".into(),
status: MergeStatus::Conflict {
regions: vec![],
partial: ours.to_vec(),
},
}
}
}
/// Names of every handler that ships with the engine. Used by instance and
/// repository policy checks (§6.6.4) to expand the "builtin" alias and to
/// reject merge-record entries that reference unknown handlers.
pub const BUILTIN_HANDLERS: &[&str] = &[
"json", "yaml", "toml", "xml", "markdown", "prose", "textual",
"tree-sitter:rust", "tree-sitter:python", "tree-sitter:javascript",
"tree-sitter:typescript", "tree-sitter:go", "tree-sitter:c",
"tree-sitter:cpp", "tree-sitter:java", "tree-sitter:ruby",
"tree-sitter:shell",
];
/// Names emitted by the merge driver itself for trivial flow-control
/// outcomes (kept only on one side, deletion honoured, etc.). They label
/// merge-record entries that did not actually invoke a handler, so they
/// always pass policy regardless of the allow list.
pub const FLOW_HANDLERS: &[&str] = &["ours-only", "theirs-only", "delete", "no-auto", "none"];
pub fn is_builtin_handler(name: &str) -> bool {
BUILTIN_HANDLERS.iter().any(|b| *b == name)
|| FLOW_HANDLERS.iter().any(|b| *b == name)
}
/// Decide whether a `(handler, handler_hash)` tuple is permitted by the
/// supplied allow list. Semantics:
///
/// - Empty list → permissive (no enforcement).
/// - List containing `"builtin"` → all built-in handlers pass; explicit
/// `"name:blake3:<hex>"` entries also pass for plugins that pin to that
/// exact hash.
/// - List without `"builtin"` → only entries that match the explicit form
/// pass; built-ins are blocked (useful for an instance that wants to
/// forbid anything other than a vetted plugin set).
/// Validate every file entry of a merge-record against an allow list. Returns
/// the names of any handlers that violate the policy, in original order.
pub fn validate_record_against_policy(
record: &crate::record::MergeRecord,
allowed: &[String],
) -> Vec<String> {
let mut bad = Vec::new();
for fr in &record.files {
if !check_handler_allowed(&fr.handler, &fr.handler_hash, allowed) {
bad.push(fr.handler.clone());
}
}
bad
}
pub fn check_handler_allowed(handler: &str, handler_hash: &str, allowed: &[String]) -> bool {
if allowed.is_empty() {
return true;
}
let allows_builtin = allowed.iter().any(|s| s == "builtin");
if allows_builtin && is_builtin_handler(handler) {
return true;
}
let needle_with_hash = if handler_hash.is_empty() {
None
} else {
let hash = handler_hash.strip_prefix("blake3:").unwrap_or(handler_hash);
Some(format!("{handler}:blake3:{hash}"))
};
allowed.iter().any(|s| {
s == handler
|| needle_with_hash.as_deref().map(|n| s == n).unwrap_or(false)
})
}
fn parse_hash(s: &str) -> Result<[u8; 32], String> {
let trimmed = s.strip_prefix("blake3:").unwrap_or(s);
if trimmed.len() != 64 {
return Err(format!("expected 64-char blake3 hash, got {} chars", trimmed.len()));
}
let mut out = [0u8; 32];
for (i, byte) in out.iter_mut().enumerate() {
let hi = (trimmed.as_bytes()[i * 2] as char)
.to_digit(16)
.ok_or_else(|| format!("invalid hex at byte {i}"))?;
let lo = (trimmed.as_bytes()[i * 2 + 1] as char)
.to_digit(16)
.ok_or_else(|| format!("invalid hex at byte {i}"))?;
*byte = ((hi << 4) | lo) as u8;
}
Ok(out)
}
#[allow(dead_code)]
fn _path_unused(_: PathBuf) {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_hash_accepts_both_forms() {
let h = "0".repeat(64);
assert!(parse_hash(&h).is_ok());
let prefixed = format!("blake3:{h}");
assert_eq!(parse_hash(&h).unwrap(), parse_hash(&prefixed).unwrap());
}
#[test]
fn parse_hash_rejects_short_input() {
assert!(parse_hash("abc").is_err());
}
#[test]
fn builtin_alias_admits_spec_handlers() {
let allow = vec!["builtin".to_string()];
assert!(check_handler_allowed("json", "", &allow));
assert!(check_handler_allowed("tree-sitter:rust", "", &allow));
assert!(!check_handler_allowed("tree-sitter:protobuf", "", &allow));
}
#[test]
fn explicit_plugin_pin_admits_only_matching_hash() {
let allow = vec![
"builtin".into(),
"tree-sitter:protobuf:blake3:abcd0000abcd0000abcd0000abcd0000abcd0000abcd0000abcd0000abcd0000".into(),
];
let good = "abcd0000abcd0000abcd0000abcd0000abcd0000abcd0000abcd0000abcd0000";
let bad = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
assert!(check_handler_allowed("tree-sitter:protobuf", good, &allow));
assert!(!check_handler_allowed("tree-sitter:protobuf", bad, &allow));
}
#[test]
fn empty_allow_list_is_permissive() {
assert!(check_handler_allowed("anything", "", &[]));
}
#[test]
fn flow_control_handler_names_always_pass() {
let allow = vec!["builtin".into()];
assert!(check_handler_allowed("ours-only", "", &allow));
assert!(check_handler_allowed("delete", "", &allow));
}
#[test]
fn handler_rank_matches_spec_tiers() {
assert_eq!(handler_rank("textual"), 0);
assert_eq!(handler_rank("json"), 1);
assert_eq!(handler_rank("toml"), 1);
assert_eq!(handler_rank("yaml"), 1);
assert_eq!(handler_rank("xml"), 1);
assert_eq!(handler_rank("markdown"), 1);
assert_eq!(handler_rank("prose"), 1);
assert_eq!(handler_rank("tree-sitter:rust"), 2);
assert_eq!(handler_rank("tree-sitter:python"), 2);
// Unknown / plugin-shaped names always rank as plugins so an
// attacker can't sneak a promotion past the check by typoing.
assert_eq!(handler_rank("tree-sitter:protobuf"), 3);
assert_eq!(handler_rank("custom-plugin"), 3);
}
#[test]
fn layer_local_demotes_rs_to_textual() {
// Spec example verbatim: user pins `*.rs` back to textual when
// they don't trust the Rust handler today.
let repo = MergeConfig {
schema_version: 1,
rules: vec![MergeRule {
glob: "*.rs".into(),
handler: "tree-sitter:rust".into(),
}],
plugins: vec![],
policy: None,
};
let local = MergeConfig {
schema_version: 1,
rules: vec![MergeRule {
glob: "*.rs".into(),
handler: "textual".into(),
}],
plugins: vec![],
policy: None,
};
let merged = layer_local_over(&repo, &local).expect("demote allowed");
assert_eq!(merged.rules.len(), 1);
assert_eq!(merged.rules[0].handler, "textual");
}
#[test]
fn layer_local_rejects_promotion() {
// Repo config pins `*.rs` to textual; user tries to promote
// it to tree-sitter. Spec says this MUST be rejected.
let repo = MergeConfig {
schema_version: 1,
rules: vec![MergeRule {
glob: "*.rs".into(),
handler: "textual".into(),
}],
plugins: vec![],
policy: None,
};
let local = MergeConfig {
schema_version: 1,
rules: vec![MergeRule {
glob: "*.rs".into(),
handler: "tree-sitter:rust".into(),
}],
plugins: vec![],
policy: None,
};
let err = layer_local_over(&repo, &local).expect_err("must reject");
assert!(err.contains("promote"), "error must mention promotion: {err}");
}
#[test]
fn layer_local_appends_new_glob() {
// No matching repo rule means the user's override establishes
// a new policy. Anything goes — we accept it because there's
// nothing to compare against.
let repo = MergeConfig::default();
let local = MergeConfig {
schema_version: 1,
rules: vec![MergeRule {
glob: "vendored/**".into(),
handler: "textual".into(),
}],
plugins: vec![],
policy: None,
};
let merged = layer_local_over(&repo, &local).unwrap();
assert_eq!(merged.rules.len(), 1);
assert_eq!(merged.rules[0].glob, "vendored/**");
}
#[test]
fn layer_local_rejects_promotion_to_plugin() {
let repo = MergeConfig {
schema_version: 1,
rules: vec![MergeRule {
glob: "*.proto".into(),
handler: "textual".into(),
}],
plugins: vec![],
policy: None,
};
let local = MergeConfig {
schema_version: 1,
rules: vec![MergeRule {
glob: "*.proto".into(),
handler: "tree-sitter:protobuf".into(),
}],
plugins: vec![],
policy: None,
};
assert!(layer_local_over(&repo, &local).is_err());
}
#[test]
fn merge_config_parses_plugin_block() {
let toml_src = r#"
schema_version = 1
[[rule]]
glob = "*.proto"
handler = "tree-sitter:protobuf"
[[plugin]]
name = "tree-sitter:protobuf"
source = "https://example.com/p.wasm"
hash = "blake3:0000000000000000000000000000000000000000000000000000000000000000"
"#;
let cfg: MergeConfig = toml::from_str(toml_src).unwrap();
assert_eq!(cfg.rules.len(), 1);
assert_eq!(cfg.plugins.len(), 1);
assert_eq!(cfg.plugins[0].name, "tree-sitter:protobuf");
}
}
#[cfg(test)]
mod plugin_routing_tests {
use super::*;
const RETURN_OURS_WAT: &str = r#"
(module
(memory (export "memory") 1)
(global $top (mut i32) (i32.const 1024))
(func (export "alloc") (param i32) (result i32)
(local $p i32)
(local.set $p (global.get $top))
(global.set $top (i32.add (global.get $top) (local.get 0)))
(local.get $p))
(func (export "merge")
(param $bp i32) (param $bl i32)
(param $op i32) (param $ol i32)
(param $tp i32) (param $tl i32)
(param $pp i32) (param $pl i32)
(result i64)
(i64.or
(i64.shl (i64.extend_i32_u (local.get $ol)) (i64.const 32))
(i64.extend_i32_u (local.get $op)))))
"#;
#[test]
fn rule_routes_path_to_registered_plugin() {
let bytes = wat::parse_str(RETURN_OURS_WAT).unwrap();
let hash = blake3::hash(&bytes);
let cfg = MergeConfig {
schema_version: 1,
rules: vec![MergeRule {
glob: "*.proto".into(),
handler: "test:plugin".into(),
}],
plugins: vec![MergePluginEntry {
name: "test:plugin".into(),
source: String::new(),
hash: format!("blake3:{}", hash.to_hex()),
}],
policy: None,
};
let bytes_clone = bytes.clone();
let engine = CascadeEngine::default()
.with_config_and_plugins(cfg, move |_| Ok(bytes_clone.clone()))
.expect("plugin loads with valid hash");
let res = engine.merge_file(Path::new("schema.proto"), b"b", b"o", b"t");
assert_eq!(res.handler, "test:plugin");
match res.status {
MergeStatus::Merged { content, .. } => assert_eq!(content, b"o"),
other => panic!("expected Merged, got {other:?}"),
}
}
}

View File

@ -0,0 +1,264 @@
//! Format-aware handlers: JSON, TOML, YAML.
//!
//! Each handler parses base/ours/theirs as the named structured format and
//! performs a three-way merge over the parsed value. JSON/TOML/YAML are
//! merged via the same recursive `merge_value` logic on `serde_json::Value`,
//! since that type can losslessly represent all three for our purposes.
use std::path::Path;
use serde_json::Value;
use crate::handler::{ConflictRegion, MergeHandler, MergeNote, MergeResult, MergeStatus};
pub struct JsonHandler;
pub struct TomlHandler;
impl MergeHandler for JsonHandler {
fn name(&self) -> &str { "json" }
fn applicable(&self, path: &Path, _b: &[u8], _o: &[u8], _t: &[u8]) -> bool {
path.extension().and_then(|e| e.to_str()) == Some("json")
}
fn merge(&self, _path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult {
let try_parse = |b: &[u8]| -> Option<Value> { serde_json::from_slice(b).ok() };
let (b, o, t) = match (try_parse(base), try_parse(ours), try_parse(theirs)) {
(Some(b), Some(o), Some(t)) => (b, o, t),
_ => {
return MergeResult {
handler: self.name().into(),
status: MergeStatus::NotApplicable,
};
}
};
let (merged, conflicts) = merge_value(&b, &o, &t, "");
let bytes = serde_json::to_vec_pretty(&merged).unwrap_or_default();
let status = if conflicts.is_empty() {
MergeStatus::Merged {
content: bytes,
notes: vec![MergeNote { message: "structural JSON three-way merge".into() }],
}
} else {
MergeStatus::Conflict { regions: conflicts, partial: bytes }
};
MergeResult { handler: self.name().into(), status }
}
}
impl MergeHandler for TomlHandler {
fn name(&self) -> &str { "toml" }
fn applicable(&self, path: &Path, _b: &[u8], _o: &[u8], _t: &[u8]) -> bool {
path.extension().and_then(|e| e.to_str()) == Some("toml")
}
fn merge(&self, _path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult {
let try_parse = |b: &[u8]| -> Option<Value> {
let s = std::str::from_utf8(b).ok()?;
let t: toml::Value = toml::from_str(s).ok()?;
toml_to_json(&t).into()
};
let (b, o, t) = match (try_parse(base), try_parse(ours), try_parse(theirs)) {
(Some(b), Some(o), Some(t)) => (b, o, t),
_ => {
return MergeResult {
handler: self.name().into(),
status: MergeStatus::NotApplicable,
};
}
};
let (merged, conflicts) = merge_value(&b, &o, &t, "");
let toml_value = json_to_toml(&merged);
let s = toml::to_string_pretty(&toml_value).unwrap_or_default();
let bytes = s.into_bytes();
let status = if conflicts.is_empty() {
MergeStatus::Merged {
content: bytes,
notes: vec![MergeNote { message: "structural TOML three-way merge".into() }],
}
} else {
MergeStatus::Conflict { regions: conflicts, partial: bytes }
};
MergeResult { handler: self.name().into(), status }
}
}
fn toml_to_json(v: &toml::Value) -> Value {
match v {
toml::Value::String(s) => Value::String(s.clone()),
toml::Value::Integer(i) => Value::from(*i),
toml::Value::Float(f) => Value::from(*f),
toml::Value::Boolean(b) => Value::from(*b),
toml::Value::Datetime(d) => Value::String(d.to_string()),
toml::Value::Array(arr) => Value::Array(arr.iter().map(toml_to_json).collect()),
toml::Value::Table(t) => {
let mut m = serde_json::Map::new();
for (k, v) in t {
m.insert(k.clone(), toml_to_json(v));
}
Value::Object(m)
}
}
}
fn json_to_toml(v: &Value) -> toml::Value {
match v {
Value::String(s) => toml::Value::String(s.clone()),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
toml::Value::Integer(i)
} else if let Some(f) = n.as_f64() {
toml::Value::Float(f)
} else {
toml::Value::String(n.to_string())
}
}
Value::Bool(b) => toml::Value::Boolean(*b),
Value::Null => toml::Value::String(String::new()),
Value::Array(arr) => toml::Value::Array(arr.iter().map(json_to_toml).collect()),
Value::Object(o) => {
let mut t = toml::map::Map::new();
for (k, v) in o {
t.insert(k.clone(), json_to_toml(v));
}
toml::Value::Table(t)
}
}
}
/// Recursive structural merge over `serde_json::Value`. Reused by the YAML
/// handler (which converts via `serde_yaml::Value`).
pub fn merge_value(
base: &Value,
ours: &Value,
theirs: &Value,
path: &str,
) -> (Value, Vec<ConflictRegion>) {
if ours == theirs {
return (ours.clone(), Vec::new());
}
if base == ours {
return (theirs.clone(), Vec::new());
}
if base == theirs {
return (ours.clone(), Vec::new());
}
match (base, ours, theirs) {
(Value::Object(b), Value::Object(o), Value::Object(t)) => {
let mut merged = serde_json::Map::new();
let mut conflicts = Vec::new();
let mut keys: std::collections::BTreeSet<&String> = b.keys().collect();
keys.extend(o.keys());
keys.extend(t.keys());
for k in keys {
let bv = b.get(k);
let ov = o.get(k);
let tv = t.get(k);
let sub_path = if path.is_empty() {
k.clone()
} else {
format!("{path}.{k}")
};
match (bv, ov, tv) {
(None, None, None) => {}
(None, Some(o), None) => { merged.insert(k.clone(), o.clone()); }
(None, None, Some(t)) => { merged.insert(k.clone(), t.clone()); }
(Some(b), Some(o), None) => {
if b == o {
// theirs deleted; ours unchanged → delete.
} else {
// ours modified, theirs deleted → conflict (keep ours).
merged.insert(k.clone(), o.clone());
conflicts.push(ConflictRegion {
description: format!("{sub_path}: modified by ours, deleted by theirs"),
base: 0..0, ours: 0..0, theirs: 0..0,
});
}
}
(Some(b), None, Some(t)) => {
if b == t {
// ours deleted; theirs unchanged → delete.
} else {
merged.insert(k.clone(), t.clone());
conflicts.push(ConflictRegion {
description: format!("{sub_path}: deleted by ours, modified by theirs"),
base: 0..0, ours: 0..0, theirs: 0..0,
});
}
}
(Some(b), Some(o), Some(t)) => {
let (sub, c) = merge_value(b, o, t, &sub_path);
merged.insert(k.clone(), sub);
conflicts.extend(c);
}
(None, Some(o), Some(t)) => {
if o == t {
merged.insert(k.clone(), o.clone());
} else {
merged.insert(k.clone(), o.clone());
conflicts.push(ConflictRegion {
description: format!("{sub_path}: independently added with different values"),
base: 0..0, ours: 0..0, theirs: 0..0,
});
}
}
(Some(_b), None, None) => { /* both deleted, drop */ }
}
}
(Value::Object(merged), conflicts)
}
(Value::Array(_b), Value::Array(o), Value::Array(t)) => {
// Concatenate ours then theirs additions, preserving the spec's
// "independent additions are merged" guidance for primitive
// arrays (with deduplication).
let mut merged: Vec<Value> = o.clone();
for v in t {
if !merged.contains(v) {
merged.push(v.clone());
}
}
(Value::Array(merged), Vec::new())
}
_ => {
// Scalar conflict.
let conflict = ConflictRegion {
description: format!("{path}: divergent scalar modifications"),
base: 0..0, ours: 0..0, theirs: 0..0,
};
(ours.clone(), vec![conflict])
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_object_merge_disjoint() {
let h = JsonHandler;
let base = br#"{"a":1}"#;
let ours = br#"{"a":1,"b":2}"#;
let theirs = br#"{"a":1,"c":3}"#;
let r = h.merge(Path::new("x.json"), base, ours, theirs);
match r.status {
MergeStatus::Merged { content, .. } => {
let v: Value = serde_json::from_slice(&content).unwrap();
assert_eq!(v["a"], 1);
assert_eq!(v["b"], 2);
assert_eq!(v["c"], 3);
}
other => panic!("expected merged, got {other:?}"),
}
}
#[test]
fn json_scalar_conflict() {
let h = JsonHandler;
let base = br#"{"a":1}"#;
let ours = br#"{"a":2}"#;
let theirs = br#"{"a":3}"#;
let r = h.merge(Path::new("x.json"), base, ours, theirs);
match r.status {
MergeStatus::Conflict { regions, .. } => assert_eq!(regions.len(), 1),
_ => panic!("expected conflict"),
}
}
}

View File

@ -0,0 +1,842 @@
//! Format handlers that round out §6.3.1: YAML, Markdown, prose, XML.
//!
//! YAML reuses the same recursive value merge as the JSON handler, parsing
//! each side via `serde_yaml::Value` and bridging through `serde_json::Value`.
//!
//! Markdown is split into top-level sections by ATX heading; sections with a
//! shared heading are reconciled with the textual handler's diff3 merge,
//! while disjoint sections are kept independently. Sections without headings
//! (preamble, lists between headings) are treated as anonymous blocks keyed
//! by content hash, mirroring the tree-sitter handler's anonymous-block
//! handling.
//!
//! Prose is paragraph-aware: paragraphs separated by blank lines are merged
//! as identity-keyed blocks (paragraph text is its own key, so identical
//! paragraphs deduplicate; modified paragraphs that diverge become
//! conflicts). The spec describes a CRDT representation; this implementation
//! approximates that with a paragraph-level three-way merge — sufficient for
//! prose text where paragraph reordering is rare.
//!
//! XML treats each element as a structural node and merges children
//! recursively. Whitespace-only text nodes are not significant. Element
//! identity is `(tag-name, sorted-attributes, child-order-index)`; this is a
//! best-effort match that handles disjoint additions to different elements
//! without conflict.
use std::path::Path;
use serde_json::Value;
use crate::format::merge_value;
use crate::handler::{ConflictRegion, MergeHandler, MergeNote, MergeResult, MergeStatus};
use crate::textual::TextualHandler;
pub struct YamlHandler;
pub struct MarkdownHandler;
pub struct ProseHandler;
pub struct XmlHandler;
// ---------------------------------------------------------------------------
// YAML
// ---------------------------------------------------------------------------
fn yaml_to_json(v: serde_yaml::Value) -> Value {
use serde_yaml::Value as Y;
match v {
Y::Null => Value::Null,
Y::Bool(b) => Value::Bool(b),
Y::Number(n) => {
if let Some(i) = n.as_i64() {
Value::from(i)
} else if let Some(f) = n.as_f64() {
serde_json::Number::from_f64(f).map(Value::Number).unwrap_or(Value::Null)
} else {
Value::Null
}
}
Y::String(s) => Value::String(s),
Y::Sequence(seq) => Value::Array(seq.into_iter().map(yaml_to_json).collect()),
Y::Mapping(map) => {
let mut obj = serde_json::Map::new();
for (k, v) in map {
let key = match k {
Y::String(s) => s,
other => serde_yaml::to_string(&other).unwrap_or_default(),
};
obj.insert(key, yaml_to_json(v));
}
Value::Object(obj)
}
Y::Tagged(t) => yaml_to_json(t.value),
}
}
fn json_to_yaml(v: &Value) -> serde_yaml::Value {
use serde_yaml::Value as Y;
match v {
Value::Null => Y::Null,
Value::Bool(b) => Y::Bool(*b),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
Y::Number(i.into())
} else if let Some(f) = n.as_f64() {
Y::Number(f.into())
} else {
Y::Null
}
}
Value::String(s) => Y::String(s.clone()),
Value::Array(a) => Y::Sequence(a.iter().map(json_to_yaml).collect()),
Value::Object(o) => {
let mut m = serde_yaml::Mapping::new();
for (k, v) in o {
m.insert(Y::String(k.clone()), json_to_yaml(v));
}
Y::Mapping(m)
}
}
}
impl MergeHandler for YamlHandler {
fn name(&self) -> &str { "yaml" }
fn applicable(&self, path: &Path, _b: &[u8], _o: &[u8], _t: &[u8]) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("yaml") | Some("yml")
)
}
fn merge(&self, _path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult {
let parse = |b: &[u8]| -> Option<Value> {
let v: serde_yaml::Value = serde_yaml::from_slice(b).ok()?;
Some(yaml_to_json(v))
};
let (b, o, t) = match (parse(base), parse(ours), parse(theirs)) {
(Some(b), Some(o), Some(t)) => (b, o, t),
_ => {
return MergeResult {
handler: self.name().into(),
status: MergeStatus::NotApplicable,
};
}
};
let (merged, conflicts) = merge_value(&b, &o, &t, "");
let bytes = serde_yaml::to_string(&json_to_yaml(&merged))
.unwrap_or_default()
.into_bytes();
let status = if conflicts.is_empty() {
MergeStatus::Merged {
content: bytes,
notes: vec![MergeNote { message: "structural YAML three-way merge".into() }],
}
} else {
MergeStatus::Conflict { regions: conflicts, partial: bytes }
};
MergeResult { handler: self.name().into(), status }
}
}
// ---------------------------------------------------------------------------
// Markdown
// ---------------------------------------------------------------------------
#[derive(Clone, Debug)]
struct MdSection {
/// `None` for the preamble (before the first heading) or anonymous body.
heading: Option<String>,
text: String,
}
fn split_markdown(src: &str) -> Vec<MdSection> {
let mut out: Vec<MdSection> = Vec::new();
let mut cur = MdSection { heading: None, text: String::new() };
for line in src.split_inclusive('\n') {
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
if !cur.text.is_empty() || cur.heading.is_some() {
out.push(std::mem::replace(
&mut cur,
MdSection { heading: None, text: String::new() },
));
}
// Extract heading text (without leading #s).
let head = trimmed
.trim_start_matches('#')
.trim()
.trim_end_matches('\n')
.to_string();
cur.heading = Some(head);
cur.text.push_str(line);
} else {
cur.text.push_str(line);
}
}
if !cur.text.is_empty() || cur.heading.is_some() {
out.push(cur);
}
out
}
impl MergeHandler for MarkdownHandler {
fn name(&self) -> &str { "markdown" }
fn applicable(&self, path: &Path, _b: &[u8], _o: &[u8], _t: &[u8]) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("md") | Some("markdown")
)
}
fn merge(&self, path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult {
let parse = |b: &[u8]| -> Option<Vec<MdSection>> {
std::str::from_utf8(b).ok().map(split_markdown)
};
let (b, o, t) = match (parse(base), parse(ours), parse(theirs)) {
(Some(b), Some(o), Some(t)) => (b, o, t),
_ => {
return MergeResult {
handler: self.name().into(),
status: MergeStatus::NotApplicable,
};
}
};
let key = |s: &MdSection| -> String {
match &s.heading {
Some(h) => format!("h:{h}"),
None => format!("anon:{}", blake3::hash(s.text.as_bytes()).to_hex()),
}
};
let lookup = |list: &[MdSection], k: &str| -> Option<MdSection> {
list.iter().find(|s| key(s) == *k).cloned()
};
let mut emitted: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut output: Vec<String> = Vec::new();
let mut conflicts: Vec<ConflictRegion> = Vec::new();
let textual = TextualHandler;
for s in &o {
let k = key(s);
if !emitted.insert(k.clone()) {
continue;
}
let bs = lookup(&b, &k);
let ts = lookup(&t, &k);
match (bs, ts) {
(None, None) => output.push(s.text.clone()),
(None, Some(ts)) => {
if s.text == ts.text {
output.push(s.text.clone());
} else if let Some(h) = &s.heading {
let res = textual.merge(path, b"", s.text.as_bytes(), ts.text.as_bytes());
match res.status {
MergeStatus::Merged { content, .. } => {
output.push(String::from_utf8_lossy(&content).to_string());
}
MergeStatus::Conflict { partial, regions } => {
output.push(String::from_utf8_lossy(&partial).to_string());
for mut r in regions {
r.description = format!("section '{h}': {}", r.description);
conflicts.push(r);
}
}
MergeStatus::NotApplicable => output.push(s.text.clone()),
}
} else {
output.push(s.text.clone());
output.push(ts.text.clone());
}
}
(Some(bs), None) => {
// Section deleted in theirs.
if s.text == bs.text {
// unchanged ours, deleted theirs → drop
} else {
output.push(s.text.clone());
conflicts.push(ConflictRegion {
description: format!("section '{}' modified vs deleted", s.heading.clone().unwrap_or_default()),
base: 0..0, ours: 0..0, theirs: 0..0,
});
}
}
(Some(bs), Some(ts)) => {
let ours_changed = s.text != bs.text;
let theirs_changed = ts.text != bs.text;
match (ours_changed, theirs_changed) {
(false, false) => output.push(s.text.clone()),
(true, false) => output.push(s.text.clone()),
(false, true) => output.push(ts.text.clone()),
(true, true) => {
if s.text == ts.text {
output.push(s.text.clone());
} else {
let res = textual.merge(
path,
bs.text.as_bytes(),
s.text.as_bytes(),
ts.text.as_bytes(),
);
match res.status {
MergeStatus::Merged { content, .. } => {
output.push(String::from_utf8_lossy(&content).to_string());
}
MergeStatus::Conflict { partial, regions } => {
output.push(String::from_utf8_lossy(&partial).to_string());
let h = s.heading.clone().unwrap_or_default();
for mut r in regions {
r.description = format!("section '{h}': {}", r.description);
conflicts.push(r);
}
}
MergeStatus::NotApplicable => output.push(s.text.clone()),
}
}
}
}
}
}
}
for s in &t {
let k = key(s);
if emitted.contains(&k) {
continue;
}
emitted.insert(k.clone());
match lookup(&b, &k) {
None => output.push(s.text.clone()),
Some(bs) => {
if s.text == bs.text {
// theirs unchanged, ours deleted → drop
} else {
output.push(s.text.clone());
conflicts.push(ConflictRegion {
description: format!(
"section '{}' deleted by ours, modified by theirs",
s.heading.clone().unwrap_or_default()
),
base: 0..0, ours: 0..0, theirs: 0..0,
});
}
}
}
}
let merged = output.concat();
let status = if conflicts.is_empty() {
MergeStatus::Merged {
content: merged.into_bytes(),
notes: vec![MergeNote { message: "section-based markdown merge".into() }],
}
} else {
MergeStatus::Conflict { regions: conflicts, partial: merged.into_bytes() }
};
MergeResult { handler: self.name().into(), status }
}
}
// ---------------------------------------------------------------------------
// Prose (paragraph-aware textual)
// ---------------------------------------------------------------------------
fn split_paragraphs(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
for line in s.split_inclusive('\n') {
if line.trim().is_empty() {
cur.push_str(line);
if !cur.trim().is_empty() {
out.push(std::mem::take(&mut cur));
} else {
cur.clear();
}
} else {
cur.push_str(line);
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
impl MergeHandler for ProseHandler {
fn name(&self) -> &str { "prose" }
fn applicable(&self, path: &Path, _b: &[u8], _o: &[u8], _t: &[u8]) -> bool {
matches!(path.extension().and_then(|e| e.to_str()), Some("txt"))
}
fn merge(&self, path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult {
let to_paras = |b: &[u8]| -> Option<Vec<String>> {
std::str::from_utf8(b).ok().map(split_paragraphs)
};
let (b, o, t) = match (to_paras(base), to_paras(ours), to_paras(theirs)) {
(Some(b), Some(o), Some(t)) => (b, o, t),
_ => {
return MergeResult {
handler: self.name().into(),
status: MergeStatus::NotApplicable,
};
}
};
let key = |p: &str| -> String {
blake3::hash(p.trim().as_bytes()).to_hex().to_string()
};
// Paragraph identity is content-only, so a modification looks like
// delete+add. To avoid silently concatenating two divergent edits
// (which would lose their conflict), check that every base paragraph
// is preserved in ours or theirs. If any base paragraph is gone from
// both sides, the divergent edits could conflict — fall through to
// textual diff3 for safety.
let preserved = b.iter().all(|p| {
let k = key(p);
o.iter().any(|x| key(x) == k) || t.iter().any(|x| key(x) == k)
});
if !preserved {
let textual = TextualHandler;
let mut res = textual.merge(path, base, ours, theirs);
res.handler = self.name().into();
return res;
}
let mut emitted: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut out: Vec<String> = Vec::new();
for p in &o {
let k = key(p);
if !emitted.insert(k.clone()) {
continue;
}
let in_base = b.iter().any(|x| key(x) == k);
let in_theirs = t.iter().any(|x| key(x) == k);
if in_base || in_theirs {
out.push(p.clone());
} else {
out.push(p.clone());
}
}
for p in &t {
let k = key(p);
if emitted.contains(&k) {
continue;
}
emitted.insert(k.clone());
let in_base = b.iter().any(|x| key(x) == k);
if !in_base {
// Independently added paragraph in theirs.
out.push(p.clone());
} else {
// Theirs's paragraph existed in base but not in ours → ours deleted it.
// Honour the deletion (drop).
}
}
// Detect deleted-in-theirs paragraphs: paragraphs in base but in
// neither ours nor theirs were deleted by both, so already absent.
// Paragraphs in base+ours but not theirs were deleted by theirs and
// already absent from `out` only if ours's iteration didn't keep
// them — but we always keep ours. Treat as ours-wins (keep).
let merged = out.concat();
MergeResult {
handler: self.name().into(),
status: MergeStatus::Merged {
content: merged.into_bytes(),
notes: vec![MergeNote { message: "paragraph-level prose merge".into() }],
},
}
}
}
// ---------------------------------------------------------------------------
// XML
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, PartialEq, Eq)]
enum XmlNode {
Element {
name: String,
attrs: Vec<(String, String)>,
children: Vec<XmlNode>,
},
Text(String),
}
fn xml_parse(src: &[u8]) -> Option<Vec<XmlNode>> {
use quick_xml::events::Event;
use quick_xml::Reader;
let mut reader = Reader::from_reader(src);
reader.config_mut().trim_text(false);
let mut buf = Vec::new();
let mut stack: Vec<(String, Vec<(String, String)>, Vec<XmlNode>)> = Vec::new();
let mut top: Vec<XmlNode> = Vec::new();
loop {
match reader.read_event_into(&mut buf).ok()? {
Event::Start(e) => {
let name = std::str::from_utf8(e.name().as_ref()).ok()?.to_string();
let mut attrs = Vec::new();
for a in e.attributes().with_checks(false).flatten() {
let k = std::str::from_utf8(a.key.as_ref()).ok()?.to_string();
let v = a.unescape_value().ok()?.to_string();
attrs.push((k, v));
}
attrs.sort();
stack.push((name, attrs, Vec::new()));
}
Event::End(_) => {
let (name, attrs, children) = stack.pop()?;
let node = XmlNode::Element { name, attrs, children };
if let Some(parent) = stack.last_mut() {
parent.2.push(node);
} else {
top.push(node);
}
}
Event::Empty(e) => {
let name = std::str::from_utf8(e.name().as_ref()).ok()?.to_string();
let mut attrs = Vec::new();
for a in e.attributes().with_checks(false).flatten() {
let k = std::str::from_utf8(a.key.as_ref()).ok()?.to_string();
let v = a.unescape_value().ok()?.to_string();
attrs.push((k, v));
}
attrs.sort();
let node = XmlNode::Element { name, attrs, children: Vec::new() };
if let Some(parent) = stack.last_mut() {
parent.2.push(node);
} else {
top.push(node);
}
}
Event::Text(t) => {
let s = std::str::from_utf8(t.as_ref()).ok()?.to_string();
let node = XmlNode::Text(s);
if let Some(parent) = stack.last_mut() {
parent.2.push(node);
} else {
top.push(node);
}
}
Event::Eof => break,
_ => {}
}
buf.clear();
}
Some(top)
}
fn xml_serialize(nodes: &[XmlNode]) -> String {
let mut out = String::new();
for n in nodes {
write_node(n, &mut out);
}
out
}
fn write_node(n: &XmlNode, out: &mut String) {
match n {
XmlNode::Text(t) => {
out.push_str(&xml_escape_text(t));
}
XmlNode::Element { name, attrs, children } => {
out.push('<');
out.push_str(name);
for (k, v) in attrs {
out.push(' ');
out.push_str(k);
out.push_str("=\"");
out.push_str(&xml_escape_attr(v));
out.push('"');
}
if children.is_empty() {
out.push_str("/>");
} else {
out.push('>');
for c in children {
write_node(c, out);
}
out.push_str("</");
out.push_str(name);
out.push('>');
}
}
}
}
fn xml_escape_text(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;")
}
fn xml_escape_attr(s: &str) -> String {
s.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;")
}
fn merge_xml_children(
base: &[XmlNode],
ours: &[XmlNode],
theirs: &[XmlNode],
path: &str,
) -> (Vec<XmlNode>, Vec<ConflictRegion>) {
if ours == theirs {
return (ours.to_vec(), Vec::new());
}
if base == ours {
return (theirs.to_vec(), Vec::new());
}
if base == theirs {
return (ours.to_vec(), Vec::new());
}
// Sequence-aware merge: iterate by element name + position. Disjoint
// additions of differently-named siblings stay non-conflicting; same-
// name siblings recurse.
let mut conflicts = Vec::new();
let max_len = base.len().max(ours.len()).max(theirs.len());
let mut merged: Vec<XmlNode> = Vec::new();
for i in 0..max_len {
let bv = base.get(i);
let ov = ours.get(i);
let tv = theirs.get(i);
match (bv, ov, tv) {
(None, None, None) => {}
(None, Some(o), None) => merged.push(o.clone()),
(None, None, Some(t)) => merged.push(t.clone()),
(Some(_), None, None) => {}
(Some(b), Some(o), None) => {
if b == o { /* deleted in theirs */ } else {
merged.push(o.clone());
conflicts.push(ConflictRegion {
description: format!("{path}[{i}]: modified by ours, deleted by theirs"),
base: 0..0, ours: 0..0, theirs: 0..0,
});
}
}
(Some(b), None, Some(t)) => {
if b == t { /* deleted in ours */ } else {
merged.push(t.clone());
conflicts.push(ConflictRegion {
description: format!("{path}[{i}]: deleted by ours, modified by theirs"),
base: 0..0, ours: 0..0, theirs: 0..0,
});
}
}
(None, Some(o), Some(t)) => {
if o == t {
merged.push(o.clone());
} else {
merged.push(o.clone());
conflicts.push(ConflictRegion {
description: format!("{path}[{i}]: independently added with different values"),
base: 0..0, ours: 0..0, theirs: 0..0,
});
}
}
(Some(b), Some(o), Some(t)) => {
let (m, c) = merge_xml_node(b, o, t, &format!("{path}[{i}]"));
merged.push(m);
conflicts.extend(c);
}
}
}
(merged, conflicts)
}
fn merge_xml_node(
base: &XmlNode,
ours: &XmlNode,
theirs: &XmlNode,
path: &str,
) -> (XmlNode, Vec<ConflictRegion>) {
if ours == theirs {
return (ours.clone(), Vec::new());
}
if base == ours {
return (theirs.clone(), Vec::new());
}
if base == theirs {
return (ours.clone(), Vec::new());
}
match (base, ours, theirs) {
(
XmlNode::Element { name: bn, attrs: ba, children: bc },
XmlNode::Element { name: on, attrs: oa, children: oc },
XmlNode::Element { name: tn, attrs: ta, children: tc },
) if bn == on && on == tn => {
// Merge attributes structurally via JSON.
let to_obj = |v: &[(String, String)]| -> Value {
let mut m = serde_json::Map::new();
for (k, val) in v { m.insert(k.clone(), Value::String(val.clone())); }
Value::Object(m)
};
let (am, ac) = merge_value(&to_obj(ba), &to_obj(oa), &to_obj(ta), &format!("{path}.@"));
let attrs: Vec<(String, String)> = match am {
Value::Object(m) => {
let mut v: Vec<_> = m.into_iter()
.map(|(k, val)| (k, val.as_str().unwrap_or("").to_string()))
.collect();
v.sort();
v
}
_ => oa.clone(),
};
let (cm, cc) = merge_xml_children(bc, oc, tc, &format!("{path}/{on}"));
let mut conflicts = ac;
conflicts.extend(cc);
(
XmlNode::Element { name: on.clone(), attrs, children: cm },
conflicts,
)
}
_ => {
// Tag-name change or text/element type mismatch — flat conflict.
(
ours.clone(),
vec![ConflictRegion {
description: format!("{path}: structural mismatch between ours and theirs"),
base: 0..0, ours: 0..0, theirs: 0..0,
}],
)
}
}
}
impl MergeHandler for XmlHandler {
fn name(&self) -> &str { "xml" }
fn applicable(&self, path: &Path, _b: &[u8], _o: &[u8], _t: &[u8]) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("xml") | Some("svg") | Some("html")
)
}
fn merge(&self, _path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult {
let (b, o, t) = match (xml_parse(base), xml_parse(ours), xml_parse(theirs)) {
(Some(b), Some(o), Some(t)) => (b, o, t),
_ => {
return MergeResult {
handler: self.name().into(),
status: MergeStatus::NotApplicable,
};
}
};
let (merged, conflicts) = merge_xml_children(&b, &o, &t, "");
let s = xml_serialize(&merged);
let bytes = s.into_bytes();
let status = if conflicts.is_empty() {
MergeStatus::Merged {
content: bytes,
notes: vec![MergeNote { message: "structural XML merge".into() }],
}
} else {
MergeStatus::Conflict { regions: conflicts, partial: bytes }
};
MergeResult { handler: self.name().into(), status }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn yaml_disjoint_keys_merge() {
let h = YamlHandler;
let base = b"a: 1\n";
let ours = b"a: 1\nb: 2\n";
let theirs = b"a: 1\nc: 3\n";
let r = h.merge(Path::new("x.yaml"), base, ours, theirs);
match r.status {
MergeStatus::Merged { content, .. } => {
let s = String::from_utf8_lossy(&content);
assert!(s.contains("a:"));
assert!(s.contains("b:"));
assert!(s.contains("c:"));
}
other => panic!("expected merged, got {other:?}"),
}
}
#[test]
fn yaml_scalar_conflict() {
let h = YamlHandler;
let base = b"x: 1\n";
let ours = b"x: 2\n";
let theirs = b"x: 3\n";
let r = h.merge(Path::new("x.yml"), base, ours, theirs);
assert!(matches!(r.status, MergeStatus::Conflict { .. }));
}
#[test]
fn markdown_disjoint_section_additions_merge() {
let h = MarkdownHandler;
let base = b"# Intro\n\nHello.\n";
let ours = b"# Intro\n\nHello.\n\n# A\n\nours.\n";
let theirs = b"# Intro\n\nHello.\n\n# B\n\ntheirs.\n";
let r = h.merge(Path::new("x.md"), base, ours, theirs);
match r.status {
MergeStatus::Merged { content, .. } => {
let s = String::from_utf8_lossy(&content);
assert!(s.contains("# A"));
assert!(s.contains("# B"));
}
other => panic!("expected merged, got {other:?}"),
}
}
#[test]
fn markdown_same_section_diverges_to_conflict() {
let h = MarkdownHandler;
let base = b"# A\n\nbase.\n";
let ours = b"# A\n\nours.\n";
let theirs = b"# A\n\ntheirs.\n";
let r = h.merge(Path::new("x.md"), base, ours, theirs);
assert!(matches!(r.status, MergeStatus::Conflict { .. }));
}
#[test]
fn prose_independently_added_paragraphs_merge() {
let h = ProseHandler;
let base = b"para one.\n";
let ours = b"para one.\n\nours added.\n";
let theirs = b"para one.\n\ntheirs added.\n";
let r = h.merge(Path::new("x.txt"), base, ours, theirs);
match r.status {
MergeStatus::Merged { content, .. } => {
let s = String::from_utf8_lossy(&content);
assert!(s.contains("para one."));
assert!(s.contains("ours added."));
assert!(s.contains("theirs added."));
}
other => panic!("expected merged, got {other:?}"),
}
}
#[test]
fn xml_disjoint_attrs_merge() {
let h = XmlHandler;
let base = br#"<root><a x="1"/></root>"#;
let ours = br#"<root><a x="1" y="2"/></root>"#;
let theirs = br#"<root><a x="1" z="3"/></root>"#;
let r = h.merge(Path::new("x.xml"), base, ours, theirs);
match r.status {
MergeStatus::Merged { content, .. } => {
let s = String::from_utf8_lossy(&content);
assert!(s.contains("y=\"2\""));
assert!(s.contains("z=\"3\""));
}
other => panic!("expected merged, got {other:?}"),
}
}
#[test]
fn xml_scalar_attr_conflict() {
let h = XmlHandler;
let base = br#"<a x="1"/>"#;
let ours = br#"<a x="2"/>"#;
let theirs = br#"<a x="3"/>"#;
let r = h.merge(Path::new("x.xml"), base, ours, theirs);
assert!(matches!(r.status, MergeStatus::Conflict { .. }));
}
}

View File

@ -0,0 +1,54 @@
//! Handler trait per §6.2.
use std::fmt;
use std::ops::Range;
use std::path::Path;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConflictRegion {
pub description: String,
pub base: Range<usize>,
pub ours: Range<usize>,
pub theirs: Range<usize>,
}
#[derive(Clone, Debug)]
pub enum MergeStatus {
Merged { content: Vec<u8>, notes: Vec<MergeNote> },
Conflict { regions: Vec<ConflictRegion>, partial: Vec<u8> },
NotApplicable,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MergeNote {
pub message: String,
}
pub struct MergeResult {
pub handler: String,
pub status: MergeStatus,
}
impl fmt::Debug for MergeResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MergeResult")
.field("handler", &self.handler)
.field("status", &short_status(&self.status))
.finish()
}
}
fn short_status(s: &MergeStatus) -> &'static str {
match s {
MergeStatus::Merged { .. } => "Merged",
MergeStatus::Conflict { .. } => "Conflict",
MergeStatus::NotApplicable => "NotApplicable",
}
}
/// Trait implemented by every handler in the cascade.
pub trait MergeHandler: Send + Sync {
fn name(&self) -> &str;
fn applicable(&self, path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> bool;
fn merge(&self, path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult;
}

View File

@ -0,0 +1,20 @@
//! levcs-merge: cascading merge engine with built-in handlers.
//!
//! Per spec §6 the engine is a cascade of handlers. Each handler can return
//! `Merged`, `Conflict`, or `NotApplicable`. The engine selects handlers by
//! file path / extension, applies the highest-priority applicable handler,
//! and falls through on `NotApplicable`.
pub mod handler;
pub mod engine;
pub mod textual;
pub mod format;
pub mod format_extra;
pub mod plugin;
pub mod record;
pub mod tree_sitter_handler;
pub use handler::{ConflictRegion, MergeHandler, MergeResult, MergeNote, MergeStatus};
pub use engine::{CascadeEngine, MergeConfig, MergeRule};
pub use record::{MergeRecord, FileRecord, FileStatus};
pub use tree_sitter_handler::{Lang, TreeSitterHandler};

View File

@ -0,0 +1,440 @@
//! Plugin handlers (§6.4).
//!
//! Each plugin is a WebAssembly module loaded into a sandboxed Wasmtime
//! runtime. The host trusts only the module's content-addressed BLAKE3 hash:
//! before instantiation the bytes are re-hashed and compared against the
//! configured value, so a malicious instance cannot serve a substitute
//! module under a known plugin name (§6.4 substitution attack).
//!
//! ## Calling convention
//!
//! The module must export:
//!
//! - `memory` — the linear memory region to which the host writes inputs and
//! from which it reads the result.
//! - `alloc(size: i32) -> i32` — the host calls this to obtain a writable
//! region for each of the four inputs (base, ours, theirs, path) before
//! invoking `merge`. Allocation strategy is left to the module; a simple
//! bump allocator is sufficient since the instance is discarded after
//! each merge.
//! - `merge(base_ptr, base_len, ours_ptr, ours_len, theirs_ptr, theirs_len,
//! path_ptr, path_len) -> i64` — the merge entry point.
//!
//! The returned `i64` packs three values:
//!
//! ```text
//! bit 63 : conflict flag (1 = conflict descriptor, 0 = merged data)
//! bits 62..32 : output length (max ~2 GiB; well under the 64 MiB cap)
//! bits 31..0 : pointer into the module's linear memory
//! ```
//!
//! For conflict results, the buffer at `(ptr, len)` is treated as the
//! "partial" content per `MergeStatus::Conflict { partial, .. }`. Plugins
//! cannot currently express structured conflict regions; the cascade engine
//! treats the merge as a single atomic conflict.
//!
//! ## Sandbox
//!
//! - **Memory cap** 64 MiB: enforced via a `ResourceLimiter` attached to the
//! `Store`; any attempt to grow the linear memory beyond the cap is denied
//! and propagates as a trap.
//! - **Wall-clock cap** 10 s: enforced via Wasmtime's epoch interruption.
//! A short-lived timer thread bumps the engine epoch each 100 ms; if the
//! plugin has not returned by the deadline, the next bump traps the
//! instance and the cascade falls through to the next handler.
//! - **No syscalls**: the WASM module is instantiated against an empty
//! `Linker`. WASI is never linked, so there is no host-imported function
//! surface.
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use wasmtime::{
AsContext, AsContextMut, Config, Engine, Instance, Module, ResourceLimiter, Store, StoreLimits,
StoreLimitsBuilder,
};
use crate::handler::{ConflictRegion, MergeHandler, MergeNote, MergeResult, MergeStatus};
/// Configured cap on linear memory size (64 MiB per §6.4).
pub const PLUGIN_MEMORY_CAP_BYTES: usize = 64 * 1024 * 1024;
/// Configured wall-clock cap per merge invocation (10 s per §6.4).
pub const PLUGIN_WALL_CAP: Duration = Duration::from_secs(10);
/// Epoch tick interval. Drives the wall-clock interruption resolution.
const EPOCH_TICK: Duration = Duration::from_millis(100);
#[derive(Clone, Debug)]
pub struct PluginConfig {
/// Handler name as referenced by `[[rule]]`/merge metadata, e.g.
/// `"tree-sitter:protobuf"`.
pub name: String,
/// BLAKE3 hash the bytes must match before instantiation.
pub hash: [u8; 32],
}
pub struct PluginHandler {
name: String,
hash: [u8; 32],
engine: Engine,
module: Module,
}
impl PluginHandler {
/// Build a plugin handler from raw module bytes. Verifies that the
/// BLAKE3 of `wasm_bytes` equals `cfg.hash`; refuses to load otherwise.
pub fn new(cfg: PluginConfig, wasm_bytes: &[u8]) -> Result<Self, PluginError> {
let actual = blake3::hash(wasm_bytes);
if actual.as_bytes() != &cfg.hash {
return Err(PluginError::HashMismatch {
expected: cfg.hash,
actual: *actual.as_bytes(),
});
}
let mut config = Config::new();
config.epoch_interruption(true);
config.consume_fuel(false);
let engine =
Engine::new(&config).map_err(|e| PluginError::Other(format!("engine: {e}")))?;
let module = Module::new(&engine, wasm_bytes)
.map_err(|e| PluginError::Other(format!("compile: {e}")))?;
Ok(Self { name: cfg.name, hash: cfg.hash, engine, module })
}
pub fn hash(&self) -> &[u8; 32] { &self.hash }
fn run_merge(
&self,
base: &[u8],
ours: &[u8],
theirs: &[u8],
path: &str,
) -> Result<PluginOutput, PluginError> {
let limits = StoreLimitsBuilder::new()
.memory_size(PLUGIN_MEMORY_CAP_BYTES)
.build();
let mut store: Store<StoreLimits> = Store::new(&self.engine, limits);
store.limiter(|s| s as &mut dyn ResourceLimiter);
// One epoch tick is the full deadline. We bump the engine epoch from
// a timer thread; the first bump after the deadline triggers a trap.
store.set_epoch_deadline(1);
// Spawn the timer thread. It runs only as long as the merge is
// outstanding; the `done` flag tells it to bow out so we don't have
// a 10s tail per merge.
let done = Arc::new(AtomicBool::new(false));
let timer = {
let engine = self.engine.clone();
let done = done.clone();
thread::spawn(move || {
let deadline = std::time::Instant::now() + PLUGIN_WALL_CAP;
while !done.load(Ordering::Relaxed) {
thread::sleep(EPOCH_TICK);
if std::time::Instant::now() >= deadline {
engine.increment_epoch();
return;
}
}
})
};
// Run the merge, then signal the timer to exit.
let result = self.invoke(&mut store, base, ours, theirs, path);
done.store(true, Ordering::Relaxed);
let _ = timer.join();
result
}
fn invoke(
&self,
store: &mut Store<StoreLimits>,
base: &[u8],
ours: &[u8],
theirs: &[u8],
path: &str,
) -> Result<PluginOutput, PluginError> {
let instance = Instance::new(store.as_context_mut(), &self.module, &[])
.map_err(|e| PluginError::Other(format!("instantiate: {e}")))?;
let memory = instance
.get_memory(store.as_context_mut(), "memory")
.ok_or(PluginError::MissingExport("memory"))?;
let alloc = instance
.get_typed_func::<i32, i32>(store.as_context_mut(), "alloc")
.map_err(|_| PluginError::MissingExport("alloc"))?;
let merge = instance
.get_typed_func::<(i32, i32, i32, i32, i32, i32, i32, i32), i64>(
store.as_context_mut(),
"merge",
)
.map_err(|_| PluginError::MissingExport("merge"))?;
let mut place = |bytes: &[u8]| -> Result<(i32, i32), PluginError> {
let len = bytes.len() as i32;
if bytes.is_empty() {
return Ok((0, 0));
}
let p = alloc
.call(store.as_context_mut(), len)
.map_err(|e| PluginError::Trap(e.to_string()))?;
memory
.write(store.as_context_mut(), p as usize, bytes)
.map_err(|e| PluginError::Other(format!("write: {e}")))?;
Ok((p, len))
};
let (bp, bl) = place(base)?;
let (op, ol) = place(ours)?;
let (tp, tl) = place(theirs)?;
let (pp, pl) = place(path.as_bytes())?;
let ret = merge
.call(store.as_context_mut(), (bp, bl, op, ol, tp, tl, pp, pl))
.map_err(|e| PluginError::Trap(e.to_string()))?;
let raw = ret as u64;
let conflict = (raw >> 63) & 1 == 1;
let len = ((raw >> 32) & 0x7FFF_FFFF) as usize;
let ptr = (raw & 0xFFFF_FFFF) as usize;
if len > PLUGIN_MEMORY_CAP_BYTES {
return Err(PluginError::Other(format!(
"plugin returned out-of-range length {len}"
)));
}
let mut buf = vec![0u8; len];
if len > 0 {
memory
.read(store.as_context(), ptr, &mut buf)
.map_err(|e| PluginError::Other(format!("read: {e}")))?;
}
Ok(PluginOutput { conflict, bytes: buf })
}
}
#[derive(Debug)]
struct PluginOutput {
conflict: bool,
bytes: Vec<u8>,
}
impl MergeHandler for PluginHandler {
fn name(&self) -> &str { &self.name }
fn applicable(&self, _path: &Path, _b: &[u8], _o: &[u8], _t: &[u8]) -> bool {
// Selection is handled by config rules, not by extension; the engine
// routes plugin handlers via `[[rule]]` matches in merge.toml.
true
}
fn merge(&self, path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult {
let path_str = path.to_string_lossy();
match self.run_merge(base, ours, theirs, &path_str) {
Ok(out) => {
if out.conflict {
MergeResult {
handler: self.name.clone(),
status: MergeStatus::Conflict {
regions: vec![ConflictRegion {
description: format!("plugin {} reported conflict", self.name),
base: 0..0,
ours: 0..0,
theirs: 0..0,
}],
partial: out.bytes,
},
}
} else {
MergeResult {
handler: self.name.clone(),
status: MergeStatus::Merged {
content: out.bytes,
notes: vec![MergeNote {
message: format!("merged by plugin {}", self.name),
}],
},
}
}
}
Err(e) => {
eprintln!("plugin {}: {e}", self.name);
MergeResult {
handler: self.name.clone(),
status: MergeStatus::NotApplicable,
}
}
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum PluginError {
#[error("plugin hash mismatch: expected blake3:{} got blake3:{}",
hex_encode(.expected), hex_encode(.actual))]
HashMismatch { expected: [u8; 32], actual: [u8; 32] },
#[error("plugin missing required export: {0}")]
MissingExport(&'static str),
#[error("plugin trapped: {0}")]
Trap(String),
#[error("{0}")]
Other(String),
}
fn hex_encode(b: &[u8; 32]) -> String {
let mut s = String::with_capacity(64);
for byte in b {
s.push_str(&format!("{byte:02x}"));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
/// WAT module that returns ours unchanged.
const RETURN_OURS_WAT: &str = r#"
(module
(memory (export "memory") 1)
(global $top (mut i32) (i32.const 1024))
(func (export "alloc") (param i32) (result i32)
(local $p i32)
(local.set $p (global.get $top))
(global.set $top (i32.add (global.get $top) (local.get 0)))
(local.get $p))
(func (export "merge")
(param $bp i32) (param $bl i32)
(param $op i32) (param $ol i32)
(param $tp i32) (param $tl i32)
(param $pp i32) (param $pl i32)
(result i64)
(i64.or
(i64.shl (i64.extend_i32_u (local.get $ol)) (i64.const 32))
(i64.extend_i32_u (local.get $op)))))
"#;
/// WAT module that reports a conflict, returning ours as the partial.
const CONFLICT_WAT: &str = r#"
(module
(memory (export "memory") 1)
(global $top (mut i32) (i32.const 1024))
(func (export "alloc") (param i32) (result i32)
(local $p i32)
(local.set $p (global.get $top))
(global.set $top (i32.add (global.get $top) (local.get 0)))
(local.get $p))
(func (export "merge")
(param $bp i32) (param $bl i32)
(param $op i32) (param $ol i32)
(param $tp i32) (param $tl i32)
(param $pp i32) (param $pl i32)
(result i64)
(i64.or
(i64.shl (i64.const 1) (i64.const 63))
(i64.or
(i64.shl (i64.extend_i32_u (local.get $ol)) (i64.const 32))
(i64.extend_i32_u (local.get $op))))))
"#;
/// WAT module that hangs forever — used to verify the wall-clock cap.
const INFINITE_LOOP_WAT: &str = r#"
(module
(memory (export "memory") 1)
(global $top (mut i32) (i32.const 1024))
(func (export "alloc") (param i32) (result i32) (i32.const 1024))
(func (export "merge")
(param $bp i32) (param $bl i32)
(param $op i32) (param $ol i32)
(param $tp i32) (param $tl i32)
(param $pp i32) (param $pl i32)
(result i64)
(loop $forever (br $forever))
(i64.const 0)))
"#;
fn build(wat_src: &str) -> (Vec<u8>, [u8; 32]) {
let bytes = wat::parse_str(wat_src).unwrap();
let hash = *blake3::hash(&bytes).as_bytes();
(bytes, hash)
}
#[test]
fn plugin_returns_ours_unchanged() {
let (bytes, hash) = build(RETURN_OURS_WAT);
let h = PluginHandler::new(
PluginConfig { name: "test:return_ours".into(), hash },
&bytes,
)
.unwrap();
let res = h.merge(Path::new("x.proto"), b"base", b"ours-text", b"theirs");
match res.status {
MergeStatus::Merged { content, .. } => assert_eq!(content, b"ours-text"),
other => panic!("expected Merged, got {other:?}"),
}
}
#[test]
fn plugin_conflict_bit_produces_conflict_status() {
let (bytes, hash) = build(CONFLICT_WAT);
let h = PluginHandler::new(
PluginConfig { name: "test:always_conflict".into(), hash },
&bytes,
)
.unwrap();
let res = h.merge(Path::new("x.proto"), b"b", b"o", b"t");
assert!(matches!(res.status, MergeStatus::Conflict { .. }));
}
#[test]
fn plugin_hash_mismatch_refuses_to_load() {
let (bytes, _real) = build(RETURN_OURS_WAT);
let bad_hash = [0u8; 32];
let err = PluginHandler::new(
PluginConfig { name: "test:bad_hash".into(), hash: bad_hash },
&bytes,
)
.err()
.expect("should refuse");
assert!(matches!(err, PluginError::HashMismatch { .. }));
}
#[test]
fn plugin_infinite_loop_is_killed_by_wall_clock_cap() {
// Override the cap for this test by waiting long enough; the default
// 10s would slow the suite. Build with a wrapper that uses a tighter
// deadline.
let (bytes, hash) = build(INFINITE_LOOP_WAT);
let mut config = Config::new();
config.epoch_interruption(true);
let engine = Engine::new(&config).unwrap();
let module = Module::new(&engine, &bytes).unwrap();
let h = PluginHandler { name: "test:loop".into(), hash, engine, module };
// Spawn a fast bumper rather than waiting the full 10s.
let engine_clone = h.engine.clone();
let done = Arc::new(AtomicBool::new(false));
let done_clone = done.clone();
let timer = thread::spawn(move || {
// Bump immediately so the very first executed loop iteration
// hits the deadline.
thread::sleep(Duration::from_millis(50));
engine_clone.increment_epoch();
while !done_clone.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(50));
}
});
let limits = StoreLimitsBuilder::new()
.memory_size(PLUGIN_MEMORY_CAP_BYTES)
.build();
let mut store: Store<StoreLimits> = Store::new(&h.engine, limits);
store.limiter(|s| s as &mut dyn ResourceLimiter);
store.set_epoch_deadline(1);
let res = h.invoke(&mut store, b"", b"", b"", "x");
done.store(true, Ordering::Relaxed);
let _ = timer.join();
assert!(res.is_err(), "infinite loop must not return Ok: {res:?}");
}
}

View File

@ -0,0 +1,44 @@
//! Merge metadata blob, written at `.levcs/merge-record` in the merge
//! commit's tree (§6.5).
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MergeRecord {
pub schema_version: u32,
pub base: String,
pub ours: String,
pub theirs: String,
#[serde(default, rename = "file")]
pub files: Vec<FileRecord>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FileRecord {
pub path: String,
pub handler: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub handler_hash: String,
pub status: FileStatus,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub notes: String,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FileStatus {
Auto,
Manual,
Ours,
Theirs,
}
impl MergeRecord {
pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
toml::to_string_pretty(self)
}
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
toml::from_str(s)
}
}

View File

@ -0,0 +1,380 @@
//! Textual three-way merge handler. The universal fallback.
//!
//! The algorithm:
//! 1. Compute line-level diffs base→ours and base→theirs.
//! 2. Express each diff as a list of patches, where each patch replaces a
//! contiguous range of base lines with new lines.
//! 3. Walk both patch lists in order. Non-overlapping patches apply
//! independently. Overlapping patches form a conflict region; if both
//! sides happen to make the *same* edit it auto-resolves.
//!
//! The output preserves base line endings: we keep each line including its
//! trailing `\n` (or the empty terminator at end of file) so the merged file
//! reproduces the original whitespace.
use std::path::Path;
use similar::{ChangeTag, TextDiff};
use crate::handler::{ConflictRegion, MergeHandler, MergeNote, MergeResult, MergeStatus};
#[derive(Clone, Debug)]
struct Patch {
base_start: usize,
base_end: usize,
new_lines: Vec<String>,
}
/// Split a string into lines, preserving each line's terminator. Must
/// agree with `similar::TextDiff::from_lines` on what counts as a line —
/// otherwise the indices we walk in the diff will not line up with our
/// `base_lines` array, and we'll slice out of bounds. similar uses
/// universal newlines: `\n`, `\r\n`, and a bare `\r` are all line
/// terminators. We follow the same rule.
fn split_lines_keep(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut buf = String::new();
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
buf.push(ch);
if ch == '\n' {
out.push(std::mem::take(&mut buf));
} else if ch == '\r' {
// Bare CR is a terminator. CRLF: consume the following \n
// into the same line so the terminator stays intact.
if chars.peek() == Some(&'\n') {
buf.push(chars.next().unwrap());
}
out.push(std::mem::take(&mut buf));
}
}
if !buf.is_empty() {
out.push(buf);
}
out
}
fn diff_to_patches(base_lines: &[String], other_lines: &[String]) -> Vec<Patch> {
let base_joined: String = base_lines.concat();
let other_joined: String = other_lines.concat();
let diff = TextDiff::from_lines(&base_joined, &other_joined);
let mut patches: Vec<Patch> = Vec::new();
let mut base_idx = 0usize;
let mut current: Option<Patch> = None;
let flush = |cur: &mut Option<Patch>, out: &mut Vec<Patch>| {
if let Some(p) = cur.take() {
out.push(p);
}
};
for change in diff.iter_all_changes() {
match change.tag() {
ChangeTag::Equal => {
flush(&mut current, &mut patches);
base_idx += 1;
}
ChangeTag::Delete => {
let p = current.get_or_insert_with(|| Patch {
base_start: base_idx,
base_end: base_idx,
new_lines: Vec::new(),
});
p.base_end = base_idx + 1;
base_idx += 1;
}
ChangeTag::Insert => {
let p = current.get_or_insert_with(|| Patch {
base_start: base_idx,
base_end: base_idx,
new_lines: Vec::new(),
});
p.new_lines.push(change.value().to_string());
}
}
}
flush(&mut current, &mut patches);
patches
}
/// Result of merging two patch lists.
fn merge_patches(
base_lines: &[String],
ours: Vec<Patch>,
theirs: Vec<Patch>,
) -> (Vec<String>, Vec<ConflictRegion>) {
let mut output: Vec<String> = Vec::new();
let mut conflicts: Vec<ConflictRegion> = Vec::new();
let mut i = 0usize;
let mut j = 0usize;
let mut base_pos = 0usize;
while i < ours.len() || j < theirs.len() {
let next_o = ours.get(i).map(|p| p.base_start);
let next_t = theirs.get(j).map(|p| p.base_start);
let next = match (next_o, next_t) {
(Some(a), Some(b)) => a.min(b),
(Some(a), None) => a,
(None, Some(b)) => b,
(None, None) => base_lines.len(),
};
// Copy unchanged base lines up to `next`.
if base_pos < next {
output.extend_from_slice(&base_lines[base_pos..next]);
base_pos = next;
}
// Collect the patches that overlap starting here.
let mut group_o: Vec<Patch> = Vec::new();
let mut group_t: Vec<Patch> = Vec::new();
let mut end = base_pos;
loop {
let mut grew = false;
while let Some(p) = ours.get(i) {
if p.base_start <= end {
end = end.max(p.base_end);
group_o.push(p.clone());
i += 1;
grew = true;
} else {
break;
}
}
while let Some(p) = theirs.get(j) {
if p.base_start <= end {
end = end.max(p.base_end);
group_t.push(p.clone());
j += 1;
grew = true;
} else {
break;
}
}
if !grew {
break;
}
}
let resolved = resolve_group(base_pos, end, base_lines, &group_o, &group_t);
match resolved {
Resolution::Applied(lines) => output.extend(lines),
Resolution::Conflict { ours_lines, theirs_lines, base_range } => {
let ours_start = output.len();
output.extend(conflict_marker_ours());
output.extend(ours_lines.clone());
output.extend(conflict_marker_base());
let mid = output.len();
output.extend(base_lines[base_range.clone()].iter().cloned());
output.extend(conflict_marker_theirs());
let theirs_start = output.len();
output.extend(theirs_lines.clone());
output.extend(conflict_marker_end());
let end_idx = output.len();
conflicts.push(ConflictRegion {
description: format!(
"concurrent modifications to base lines {}..{}",
base_range.start, base_range.end
),
base: base_range.clone(),
ours: ours_start..mid,
theirs: theirs_start..end_idx,
});
}
}
base_pos = end;
}
if base_pos < base_lines.len() {
output.extend_from_slice(&base_lines[base_pos..]);
}
(output, conflicts)
}
enum Resolution {
Applied(Vec<String>),
Conflict {
ours_lines: Vec<String>,
theirs_lines: Vec<String>,
base_range: std::ops::Range<usize>,
},
}
fn apply_group(start: usize, end: usize, base: &[String], group: &[Patch]) -> Vec<String> {
let mut out = Vec::new();
let mut p = start;
for patch in group {
if patch.base_start > p {
out.extend_from_slice(&base[p..patch.base_start]);
}
out.extend_from_slice(&patch.new_lines);
p = patch.base_end;
}
if p < end {
out.extend_from_slice(&base[p..end]);
}
out
}
fn resolve_group(
start: usize,
end: usize,
base: &[String],
group_o: &[Patch],
group_t: &[Patch],
) -> Resolution {
let only_o = !group_o.is_empty() && group_t.is_empty();
let only_t = !group_t.is_empty() && group_o.is_empty();
if only_o {
return Resolution::Applied(apply_group(start, end, base, group_o));
}
if only_t {
return Resolution::Applied(apply_group(start, end, base, group_t));
}
let ours_lines = apply_group(start, end, base, group_o);
let theirs_lines = apply_group(start, end, base, group_t);
if ours_lines == theirs_lines {
return Resolution::Applied(ours_lines);
}
Resolution::Conflict {
ours_lines,
theirs_lines,
base_range: start..end,
}
}
fn conflict_marker_ours() -> Vec<String> { vec!["<<<<<<< ours\n".to_string()] }
fn conflict_marker_base() -> Vec<String> { vec!["||||||| base\n".to_string()] }
fn conflict_marker_theirs() -> Vec<String> { vec!["=======\n".to_string()] }
fn conflict_marker_end() -> Vec<String> { vec![">>>>>>> theirs\n".to_string()] }
pub fn three_way_merge_lines(base: &str, ours: &str, theirs: &str) -> (String, Vec<ConflictRegion>) {
let base_lines = split_lines_keep(base);
let ours_lines = split_lines_keep(ours);
let theirs_lines = split_lines_keep(theirs);
if ours_lines == theirs_lines {
return (ours, Vec::new()).map_first();
}
if base_lines == ours_lines {
return (theirs, Vec::new()).map_first();
}
if base_lines == theirs_lines {
return (ours, Vec::new()).map_first();
}
let p_ours = diff_to_patches(&base_lines, &ours_lines);
let p_theirs = diff_to_patches(&base_lines, &theirs_lines);
let (merged_lines, conflicts) = merge_patches(&base_lines, p_ours, p_theirs);
(merged_lines.concat(), conflicts)
}
trait MapFirst<A, B> {
fn map_first(self) -> (String, B);
}
impl<S: Into<String>, B> MapFirst<S, B> for (S, B) {
fn map_first(self) -> (String, B) { (self.0.into(), self.1) }
}
pub struct TextualHandler;
impl MergeHandler for TextualHandler {
fn name(&self) -> &str { "textual" }
fn applicable(&self, _path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> bool {
// Only apply to anything that's valid UTF-8 — we refuse to do
// line-based merge on binary. Everything else falls through.
std::str::from_utf8(base).is_ok()
&& std::str::from_utf8(ours).is_ok()
&& std::str::from_utf8(theirs).is_ok()
}
fn merge(&self, _path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult {
let bs = std::str::from_utf8(base).unwrap_or("");
let os = std::str::from_utf8(ours).unwrap_or("");
let ts = std::str::from_utf8(theirs).unwrap_or("");
let (merged, conflicts) = three_way_merge_lines(bs, os, ts);
let status = if conflicts.is_empty() {
MergeStatus::Merged {
content: merged.into_bytes(),
notes: vec![MergeNote {
message: "auto-merged via line-level three-way diff".into(),
}],
}
} else {
MergeStatus::Conflict {
regions: conflicts,
partial: merged.into_bytes(),
}
};
MergeResult { handler: self.name().into(), status }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_changes() {
let s = "a\nb\nc\n";
let (out, c) = three_way_merge_lines(s, s, s);
assert_eq!(out, s);
assert!(c.is_empty());
}
#[test]
fn only_ours_changes() {
let base = "a\nb\nc\n";
let ours = "a\nB\nc\n";
let theirs = "a\nb\nc\n";
let (out, c) = three_way_merge_lines(base, ours, theirs);
assert_eq!(out, ours);
assert!(c.is_empty());
}
#[test]
fn only_theirs_changes() {
let base = "a\nb\nc\n";
let ours = "a\nb\nc\n";
let theirs = "a\nb\nC\n";
let (out, c) = three_way_merge_lines(base, ours, theirs);
assert_eq!(out, theirs);
assert!(c.is_empty());
}
#[test]
fn disjoint_changes_merge() {
let base = "a\nb\nc\nd\n";
let ours = "A\nb\nc\nd\n";
let theirs = "a\nb\nc\nD\n";
let (out, c) = three_way_merge_lines(base, ours, theirs);
assert_eq!(out, "A\nb\nc\nD\n");
assert!(c.is_empty());
}
#[test]
fn same_change_both_sides_resolves() {
let base = "a\nb\nc\n";
let ours = "a\nB\nc\n";
let theirs = "a\nB\nc\n";
let (out, c) = three_way_merge_lines(base, ours, theirs);
assert_eq!(out, "a\nB\nc\n");
assert!(c.is_empty());
}
#[test]
fn divergent_change_conflicts() {
let base = "a\nb\nc\n";
let ours = "a\nB1\nc\n";
let theirs = "a\nB2\nc\n";
let (_out, c) = three_way_merge_lines(base, ours, theirs);
assert_eq!(c.len(), 1);
}
/// Regression: `similar::TextDiff::from_lines` treats bare CR as a
/// line terminator (universal newlines). `split_lines_keep` must
/// agree, otherwise the diff walker indexes past the end of
/// `base_lines` and the slice access panics. Found by proptest
/// shrinking to (base="\r¡", ours="", theirs="\0").
#[test]
fn cr_only_line_endings_do_not_panic() {
let _ = three_way_merge_lines("\r¡", "", "\0");
let _ = three_way_merge_lines("a\rb\rc\r", "a\rb\r", "a\rb\rC\r");
let _ = three_way_merge_lines("a\r\nb\r\n", "a\r\nB\r\n", "a\r\n");
}
}

View File

@ -0,0 +1,906 @@
//! Tree-sitter handler (§6.3.2).
//!
//! Parses base, ours, and theirs with a tree-sitter grammar and performs a
//! structural three-way merge over the named children of the source root,
//! recursing into nested blocks when both sides modify the same outer
//! block (e.g., two engineers each adding a different method to the same
//! `impl` / `class`).
//!
//! Algorithm:
//! 1. Parse all three. If any parse produces a tree with errors, return
//! `NotApplicable`; the cascade then falls through to the textual
//! handler (§6.3.3).
//! 2. Extract top-level named children. Each child is identified by
//! `(kind, name)` where `name` comes from the grammar's `name` /
//! `declarator` field; or, for nodes without a recoverable name, by
//! `(kind, blake3(text))` so that identical anonymous items collapse.
//! 3. Three-way merge over the union of identities: independent
//! additions/deletions/modifications are auto-resolved.
//! 4. When both sides modify the same identity AND the language is one
//! whose containers don't depend on indentation, attempt **recursive
//! merge**: descend into that block's body, treat its named children
//! as the new identity space, run the same algorithm. Only commit
//! the recursive result when the outer "frame" (everything in the
//! block that isn't the body) is identical between ours and theirs
//! *and* the inner merge is conflict-free. Anything else falls back
//! to a block-level conflict.
//! 5. Reconstruct the file by emitting kept blocks in ours's source
//! order, then appending blocks added only by theirs. Top-level
//! blocks are joined with a blank line; recursive splices preserve
//! the inter-child glue (whitespace, indentation) of ours's body.
//!
//! Conflict granularity is the smallest named container in which the
//! conflict is genuinely unresolvable. Concurrent edits inside the same
//! method body still produce a single block-level conflict at that
//! method, since method bodies generally lack the structural identity
//! recursion needs.
use std::collections::HashSet;
use std::ops::Range;
use std::path::Path;
use tree_sitter::{Language, Node, Parser, Tree};
use crate::handler::{ConflictRegion, MergeHandler, MergeNote, MergeResult, MergeStatus};
/// One of the languages shipped with the built-in handler set (§6.3.2).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Lang {
Rust,
Python,
JavaScript,
TypeScript,
Go,
C,
Cpp,
Java,
Ruby,
Shell,
}
impl Lang {
pub fn handler_name(self) -> &'static str {
match self {
Lang::Rust => "tree-sitter:rust",
Lang::Python => "tree-sitter:python",
Lang::JavaScript => "tree-sitter:javascript",
Lang::TypeScript => "tree-sitter:typescript",
Lang::Go => "tree-sitter:go",
Lang::C => "tree-sitter:c",
Lang::Cpp => "tree-sitter:cpp",
Lang::Java => "tree-sitter:java",
Lang::Ruby => "tree-sitter:ruby",
Lang::Shell => "tree-sitter:shell",
}
}
pub fn from_extension(ext: &str) -> Option<Lang> {
match ext {
"rs" => Some(Lang::Rust),
"py" => Some(Lang::Python),
"js" | "mjs" => Some(Lang::JavaScript),
"ts" => Some(Lang::TypeScript),
"go" => Some(Lang::Go),
"c" | "h" => Some(Lang::C),
"cpp" | "cc" | "hpp" => Some(Lang::Cpp),
"java" => Some(Lang::Java),
"rb" => Some(Lang::Ruby),
"sh" | "bash" => Some(Lang::Shell),
_ => None,
}
}
fn language(self) -> Language {
match self {
Lang::Rust => tree_sitter_rust::LANGUAGE.into(),
Lang::Python => tree_sitter_python::LANGUAGE.into(),
Lang::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
Lang::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
Lang::Go => tree_sitter_go::LANGUAGE.into(),
Lang::C => tree_sitter_c::LANGUAGE.into(),
Lang::Cpp => tree_sitter_cpp::LANGUAGE.into(),
Lang::Java => tree_sitter_java::LANGUAGE.into(),
Lang::Ruby => tree_sitter_ruby::LANGUAGE.into(),
Lang::Shell => tree_sitter_bash::LANGUAGE.into(),
}
}
pub fn all() -> &'static [Lang] {
&[
Lang::Rust,
Lang::Python,
Lang::JavaScript,
Lang::TypeScript,
Lang::Go,
Lang::C,
Lang::Cpp,
Lang::Java,
Lang::Ruby,
Lang::Shell,
]
}
/// Brace-delimited languages can be safely re-emitted block-by-block
/// without breaking the parse, so recursion is sound. Indentation-
/// sensitive grammars (Python, Ruby) and the shell can't tolerate the
/// child-list reformatting recursion does, so we keep them at
/// top-level granularity. The on-wire/test behaviour for these is
/// unchanged from the previous flat implementation.
fn supports_recursion(self) -> bool {
matches!(
self,
Lang::Rust
| Lang::JavaScript
| Lang::TypeScript
| Lang::Go
| Lang::C
| Lang::Cpp
| Lang::Java
)
}
}
pub struct TreeSitterHandler {
lang: Lang,
}
impl TreeSitterHandler {
pub fn new(lang: Lang) -> Self { Self { lang } }
fn parse(&self, src: &[u8]) -> Option<Tree> {
let mut p = Parser::new();
p.set_language(&self.lang.language()).ok()?;
let tree = p.parse(src, None)?;
if tree.root_node().has_error() {
return None;
}
Some(tree)
}
}
#[derive(Clone, Debug)]
struct Block {
kind: String,
key: BlockKey,
range: Range<usize>,
text: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
enum BlockKey {
Named(String, String),
Anon(String, [u8; 32]),
}
/// State threaded through every level of recursion: the parsed trees and
/// raw source bytes for each side, plus the language. The recursive
/// helpers use it to find a Block's original Node (so they can read its
/// `body` field) without having to thread Node references — Node
/// lifetimes are tied to a specific Tree borrow and don't compose well
/// with the Block struct.
struct RecurseCtx<'a> {
lang: Lang,
base_tree: &'a Tree,
ours_tree: &'a Tree,
theirs_tree: &'a Tree,
base_src: &'a [u8],
ours_src: &'a [u8],
theirs_src: &'a [u8],
}
fn extract_name(node: Node, src: &[u8]) -> Option<String> {
if let Some(n) = node.child_by_field_name("name") {
if let Ok(t) = n.utf8_text(src) {
return Some(t.to_string());
}
}
// Rust `impl_item` has no `name` field — its identity is determined
// by the (optional) trait and the type being implemented. We
// synthesise "Trait for Type" / "Type" as the identity so two
// different sides editing the same impl block actually match up.
// Without this they fall through to the anonymous-hash key and get
// treated as unrelated additions.
if node.kind() == "impl_item" {
let trait_name = node
.child_by_field_name("trait")
.and_then(|n| n.utf8_text(src).ok());
let type_name = node
.child_by_field_name("type")
.and_then(|n| n.utf8_text(src).ok());
match (trait_name, type_name) {
(Some(tr), Some(ty)) => return Some(format!("{tr} for {ty}")),
(None, Some(ty)) => return Some(ty.to_string()),
_ => {}
}
}
if let Some(n) = node.child_by_field_name("declarator") {
if let Some(name) = extract_name(n, src) {
return Some(name);
}
let mut cur = n.walk();
for child in n.named_children(&mut cur) {
if matches!(
child.kind(),
"identifier" | "type_identifier" | "field_identifier"
) {
if let Ok(t) = child.utf8_text(src) {
return Some(t.to_string());
}
}
}
}
None
}
fn block_from_node(node: Node, src: &[u8]) -> Block {
let kind = node.kind().to_string();
let range = node.byte_range();
let text = src[range.clone()].to_vec();
let key = match extract_name(node, src) {
Some(n) => BlockKey::Named(kind.clone(), n),
None => BlockKey::Anon(kind.clone(), *blake3::hash(&text).as_bytes()),
};
Block { kind, key, range, text }
}
fn top_level_blocks(tree: &Tree, src: &[u8]) -> Vec<Block> {
let root = tree.root_node();
let mut cur = root.walk();
root.named_children(&mut cur)
.map(|n| block_from_node(n, src))
.collect()
}
/// Children of a body container (the named-block-bearing direct children).
/// Used during recursion to obtain the inner identity space of a
/// container node like `class_body`, `declaration_list`, `block`, etc.
fn body_children(body: Node, src: &[u8]) -> Vec<Block> {
let mut cur = body.walk();
body.named_children(&mut cur)
.map(|n| block_from_node(n, src))
.collect()
}
/// Locate the AST node within `tree` whose byte range matches `target`
/// exactly. Implemented as a guided descent that prunes any subtree whose
/// own byte range doesn't span the target — O(depth × siblings) in
/// practice, plenty fast since recursion is only attempted on conflict.
fn find_named_node<'a>(tree: &'a Tree, target: &Range<usize>) -> Option<Node<'a>> {
fn search<'b>(n: Node<'b>, target: &Range<usize>) -> Option<Node<'b>> {
if n.byte_range() == *target {
return Some(n);
}
if n.start_byte() > target.start || n.end_byte() < target.end {
return None;
}
let mut cur = n.walk();
for c in n.named_children(&mut cur) {
if let Some(found) = search(c, target) {
return Some(found);
}
}
None
}
search(tree.root_node(), target)
}
fn lookup<'a>(blocks: &'a [Block], key: &BlockKey) -> Option<&'a Block> {
blocks.iter().find(|b| &b.key == key)
}
fn make_region(
kind: &str,
desc: &str,
base: Option<&Block>,
ours: Option<&Block>,
theirs: Option<&Block>,
) -> ConflictRegion {
fn r(b: Option<&Block>) -> Range<usize> {
b.map(|b| b.range.clone()).unwrap_or(0..0)
}
ConflictRegion {
description: format!("{desc} on {kind}"),
base: r(base),
ours: r(ours),
theirs: r(theirs),
}
}
fn conflict_marker_block(ours: &[u8], theirs: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(b"<<<<<<< ours\n");
out.extend_from_slice(ours);
if !ours.is_empty() && !ours.ends_with(b"\n") {
out.push(b'\n');
}
out.extend_from_slice(b"=======\n");
out.extend_from_slice(theirs);
if !theirs.is_empty() && !theirs.ends_with(b"\n") {
out.push(b'\n');
}
out.extend_from_slice(b">>>>>>> theirs");
out
}
fn join_blocks_with(blocks: &[Vec<u8>], sep: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
for (i, b) in blocks.iter().enumerate() {
if i > 0 {
out.extend_from_slice(sep);
}
out.extend_from_slice(b);
}
if !out.is_empty() && !out.ends_with(b"\n") {
out.push(b'\n');
}
out
}
fn join_top_level(blocks: &[Vec<u8>]) -> Vec<u8> {
join_blocks_with(blocks, b"\n\n")
}
/// Result of a per-block merge operation: the bytes to emit (possibly
/// containing conflict markers) plus any conflict regions and notes the
/// caller should surface to the user. Splitting the (text, conflicts)
/// pair from the joining step lets recursive callers preserve ours's
/// inter-child glue when reconstructing a parent block's body.
struct InnerMerge {
blocks: Vec<Vec<u8>>,
conflicts: Vec<ConflictRegion>,
notes: Vec<MergeNote>,
had_conflict: bool,
}
fn merge_blocks_inner(
base: &[Block],
ours: &[Block],
theirs: &[Block],
ctx: Option<&RecurseCtx>,
) -> InnerMerge {
let mut emitted: HashSet<BlockKey> = HashSet::new();
let mut output: Vec<Vec<u8>> = Vec::new();
let mut conflicts: Vec<ConflictRegion> = Vec::new();
let mut notes: Vec<MergeNote> = Vec::new();
let mut had_conflict = false;
for o in ours {
if !emitted.insert(o.key.clone()) {
continue;
}
let b = lookup(base, &o.key);
let t = lookup(theirs, &o.key);
match (b, t) {
(None, None) => {
output.push(o.text.clone());
}
(None, Some(tb)) => {
if o.text == tb.text {
output.push(o.text.clone());
} else {
had_conflict = true;
conflicts.push(make_region(&o.kind, "concurrent additions diverge", None, Some(o), Some(tb)));
output.push(conflict_marker_block(&o.text, &tb.text));
}
}
(Some(bb), None) => {
if o.text == bb.text {
// ours unchanged, theirs deleted → honour deletion
} else {
had_conflict = true;
conflicts.push(make_region(&o.kind, "modify-vs-delete", Some(bb), Some(o), None));
output.push(conflict_marker_block(&o.text, &[]));
notes.push(MergeNote {
message: format!("{}: modified by ours, deleted by theirs", o.kind),
});
}
}
(Some(bb), Some(tb)) => {
let ours_changed = o.text != bb.text;
let theirs_changed = tb.text != bb.text;
match (ours_changed, theirs_changed) {
(false, false) => output.push(o.text.clone()),
(true, false) => output.push(o.text.clone()),
(false, true) => output.push(tb.text.clone()),
(true, true) => {
if o.text == tb.text {
output.push(o.text.clone());
notes.push(MergeNote {
message: format!("{}: identical edits on both sides", o.kind),
});
} else if let Some(merged) =
ctx.and_then(|c| try_recursive_clean(c, bb, o, tb))
{
output.push(merged);
notes.push(MergeNote {
message: format!(
"{}: merged disjoint edits via recursive descent",
o.kind
),
});
} else {
had_conflict = true;
conflicts.push(make_region(
&o.kind,
"concurrent edits",
Some(bb),
Some(o),
Some(tb),
));
output.push(conflict_marker_block(&o.text, &tb.text));
}
}
}
}
}
}
for t in theirs {
if emitted.contains(&t.key) {
continue;
}
emitted.insert(t.key.clone());
let b = lookup(base, &t.key);
match b {
None => {
output.push(t.text.clone());
}
Some(bb) => {
if t.text == bb.text {
// theirs unchanged, ours deleted → honour deletion
} else {
had_conflict = true;
conflicts.push(make_region(&t.kind, "delete-vs-modify", Some(bb), None, Some(t)));
output.push(conflict_marker_block(&[], &t.text));
notes.push(MergeNote {
message: format!("{}: deleted by ours, modified by theirs", t.kind),
});
}
}
}
}
InnerMerge { blocks: output, conflicts, notes, had_conflict }
}
/// Try to merge a single conflicted block by descending into its body and
/// merging its inner named children. Returns `Some(text)` on success —
/// the bytes that should replace the would-have-been conflict marker —
/// and `None` on any of:
/// - language doesn't support recursion (indent-sensitive),
/// - no `body` field on either side's outer node,
/// - outer "frame" (text outside the body) differs between ours and
/// theirs, meaning the header/footer themselves conflict,
/// - the block has no identifiable inner structure (all anonymous),
/// - the recursive child merge produced any conflict.
///
/// On success, the splice preserves ours's body prefix (text before the
/// first inner child), suffix (text after the last inner child), and
/// inter-child separator (the glue used between ours's first two
/// children, defaulting to `\n\n`). This keeps indentation and bracing
/// style consistent with ours rather than emitting a stylistically dead
/// `\n\n`-joined list — important because recursive merges land in
/// downstream review tools and uglier output is harder to skim.
fn try_recursive_clean(
ctx: &RecurseCtx,
base: &Block,
ours: &Block,
theirs: &Block,
) -> Option<Vec<u8>> {
if !ctx.lang.supports_recursion() {
return None;
}
let base_node = find_named_node(ctx.base_tree, &base.range)?;
let ours_node = find_named_node(ctx.ours_tree, &ours.range)?;
let theirs_node = find_named_node(ctx.theirs_tree, &theirs.range)?;
let base_body = base_node.child_by_field_name("body")?;
let ours_body = ours_node.child_by_field_name("body")?;
let theirs_body = theirs_node.child_by_field_name("body")?;
let oo = ours_node.byte_range();
let oob = ours_body.byte_range();
let to = theirs_node.byte_range();
let tob = theirs_body.byte_range();
// Frame = the outer block bytes outside the body. If those differ
// between ours and theirs, the *header* (or footer) itself is in
// conflict — recursing would silently discard one side's edit.
let ours_prefix = &ctx.ours_src[oo.start..oob.start];
let ours_suffix = &ctx.ours_src[oob.end..oo.end];
let theirs_prefix = &ctx.theirs_src[to.start..tob.start];
let theirs_suffix = &ctx.theirs_src[tob.end..to.end];
if ours_prefix != theirs_prefix || ours_suffix != theirs_suffix {
return None;
}
let bc = body_children(base_body, ctx.base_src);
let oc = body_children(ours_body, ctx.ours_src);
let tc = body_children(theirs_body, ctx.theirs_src);
if bc.is_empty() && oc.is_empty() && tc.is_empty() {
return None;
}
// Recursion only buys something when at least one of the inner blocks
// has a recoverable identity. If everything is anonymous, body diffs
// collapse to text-level and we'd match arbitrary content together.
if bc.iter().chain(&oc).chain(&tc).all(|b| matches!(b.key, BlockKey::Anon(..))) {
return None;
}
let inner = merge_blocks_inner(&bc, &oc, &tc, Some(ctx));
if inner.had_conflict {
return None;
}
// Splice the merged inner blocks back into ours's outer text. We use
// ours's body slot — its prefix, suffix, and inter-child separator —
// so the spliced result matches ours's existing style.
let body_rel_start = oob.start - oo.start;
let body_rel_end = oob.end - oo.start;
let body_text = &ours.text[body_rel_start..body_rel_end];
let (prefix, suffix) = body_prefix_suffix(body_text, &oc, oob.start);
let sep = inter_child_separator(body_text, &oc, oob.start);
let merged_children = join_blocks_with(&inner.blocks, &sep);
// join_blocks_with appends a trailing newline to keep top-level files
// POSIX-compliant; that's the wrong choice when splicing into the
// middle of an outer block — the suffix already carries whatever the
// body ended with.
let merged_children = strip_trailing_newline(&merged_children);
let mut out = Vec::new();
out.extend_from_slice(&ours.text[..body_rel_start]);
out.extend_from_slice(prefix);
out.extend_from_slice(&merged_children);
out.extend_from_slice(suffix);
out.extend_from_slice(&ours.text[body_rel_end..]);
Some(out)
}
fn body_prefix_suffix<'a>(
body_text: &'a [u8],
children: &[Block],
body_start_abs: usize,
) -> (&'a [u8], &'a [u8]) {
if children.is_empty() {
return (body_text, &[]);
}
let first_rel = children[0].range.start - body_start_abs;
let last_rel = children.last().unwrap().range.end - body_start_abs;
(&body_text[..first_rel], &body_text[last_rel..])
}
fn inter_child_separator(
body_text: &[u8],
children: &[Block],
body_start_abs: usize,
) -> Vec<u8> {
if children.len() < 2 {
return b"\n\n".to_vec();
}
let end_first = children[0].range.end - body_start_abs;
let start_second = children[1].range.start - body_start_abs;
body_text[end_first..start_second].to_vec()
}
fn strip_trailing_newline(b: &[u8]) -> Vec<u8> {
if b.ends_with(b"\n") {
b[..b.len() - 1].to_vec()
} else {
b.to_vec()
}
}
impl MergeHandler for TreeSitterHandler {
fn name(&self) -> &str { self.lang.handler_name() }
fn applicable(&self, _path: &Path, _base: &[u8], _ours: &[u8], _theirs: &[u8]) -> bool {
// Applicability is decided in `merge`; if any input fails to parse
// we return NotApplicable and the cascade advances to textual.
true
}
fn merge(&self, _path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult {
let (b_tree, o_tree, t_tree) =
match (self.parse(base), self.parse(ours), self.parse(theirs)) {
(Some(b), Some(o), Some(t)) => (b, o, t),
_ => {
return MergeResult {
handler: self.name().to_string(),
status: MergeStatus::NotApplicable,
};
}
};
let bb = top_level_blocks(&b_tree, base);
let ob = top_level_blocks(&o_tree, ours);
let tb = top_level_blocks(&t_tree, theirs);
if bb.is_empty() && ob.is_empty() && tb.is_empty() {
return MergeResult {
handler: self.name().to_string(),
status: MergeStatus::NotApplicable,
};
}
let ctx = RecurseCtx {
lang: self.lang,
base_tree: &b_tree,
ours_tree: &o_tree,
theirs_tree: &t_tree,
base_src: base,
ours_src: ours,
theirs_src: theirs,
};
let inner = merge_blocks_inner(&bb, &ob, &tb, Some(&ctx));
let merged = join_top_level(&inner.blocks);
if inner.had_conflict {
MergeResult {
handler: self.name().to_string(),
status: MergeStatus::Conflict {
regions: inner.conflicts,
partial: merged,
},
}
} else {
MergeResult {
handler: self.name().to_string(),
status: MergeStatus::Merged {
content: merged,
notes: inner.notes,
},
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::CascadeEngine;
use std::path::Path;
fn run(lang: Lang, base: &str, ours: &str, theirs: &str) -> MergeResult {
let h = TreeSitterHandler::new(lang);
h.merge(Path::new("file"), base.as_bytes(), ours.as_bytes(), theirs.as_bytes())
}
#[test]
fn rust_disjoint_function_additions_merge() {
let base = "fn a() {}\n";
let ours = "fn a() {}\n\nfn b() {}\n";
let theirs = "fn a() {}\n\nfn c() {}\n";
let result = run(Lang::Rust, base, ours, theirs);
assert_eq!(result.handler, "tree-sitter:rust");
match result.status {
MergeStatus::Merged { content, .. } => {
let s = std::str::from_utf8(&content).unwrap();
assert!(s.contains("fn a()"));
assert!(s.contains("fn b()"));
assert!(s.contains("fn c()"));
}
other => panic!("expected Merged, got {other:?}"),
}
}
#[test]
fn rust_concurrent_edits_to_same_function_conflict() {
let base = "fn greet() {\n println!(\"hi\");\n}\n";
let ours = "fn greet() {\n println!(\"hello\");\n}\n";
let theirs = "fn greet() {\n println!(\"hey\");\n}\n";
let result = run(Lang::Rust, base, ours, theirs);
match result.status {
MergeStatus::Conflict { regions, partial } => {
assert_eq!(regions.len(), 1);
assert!(regions[0].description.contains("function_item"));
let s = std::str::from_utf8(&partial).unwrap();
assert!(s.contains("<<<<<<< ours"));
assert!(s.contains(">>>>>>> theirs"));
}
other => panic!("expected Conflict, got {other:?}"),
}
}
#[test]
fn rust_one_sided_edit_takes_that_side() {
let base = "fn a() {}\n\nfn b() { let x = 1; }\n";
let ours = "fn a() {}\n\nfn b() { let x = 1; }\n";
let theirs = "fn a() {}\n\nfn b() { let x = 2; }\n";
let result = run(Lang::Rust, base, ours, theirs);
match result.status {
MergeStatus::Merged { content, .. } => {
let s = std::str::from_utf8(&content).unwrap();
assert!(s.contains("let x = 2;"));
}
other => panic!("expected Merged, got {other:?}"),
}
}
#[test]
fn parse_failure_falls_through_to_not_applicable() {
let bad = "fn a( {}\n"; // syntax error
let ok = "fn a() {}\n";
let result = run(Lang::Rust, ok, bad, ok);
assert!(matches!(result.status, MergeStatus::NotApplicable));
}
#[test]
fn cascade_routes_rust_files_to_tree_sitter_then_falls_through_on_parse_error() {
let engine = CascadeEngine::default();
// Parse error on ours → falls through to textual.
let base = b"fn a() { 1 }\n";
let ours = b"fn a( {} 1 }\n"; // broken
let theirs = b"fn a() { 2 }\n";
let res = engine.merge_file(Path::new("x.rs"), base, ours, theirs);
// Textual is the fallback; it must not advertise itself as
// tree-sitter:rust.
assert_ne!(res.handler, "tree-sitter:rust");
}
#[test]
fn python_disjoint_class_method_additions_merge() {
let base = "def a():\n return 1\n";
let ours = "def a():\n return 1\n\ndef b():\n return 2\n";
let theirs = "def a():\n return 1\n\ndef c():\n return 3\n";
let result = run(Lang::Python, base, ours, theirs);
match result.status {
MergeStatus::Merged { content, .. } => {
let s = std::str::from_utf8(&content).unwrap();
assert!(s.contains("def a"));
assert!(s.contains("def b"));
assert!(s.contains("def c"));
}
other => panic!("expected Merged, got {other:?}"),
}
}
#[test]
fn deletion_on_one_side_is_honoured() {
let base = "fn a() {}\n\nfn b() {}\n";
let ours = "fn a() {}\n";
let theirs = "fn a() {}\n\nfn b() {}\n";
let result = run(Lang::Rust, base, ours, theirs);
match result.status {
MergeStatus::Merged { content, .. } => {
let s = std::str::from_utf8(&content).unwrap();
assert!(s.contains("fn a()"));
assert!(!s.contains("fn b()"));
}
other => panic!("expected Merged, got {other:?}"),
}
}
#[test]
fn modify_vs_delete_is_a_conflict() {
let base = "fn a() {}\n\nfn b() {}\n";
let ours = "fn a() {}\n\nfn b() { 1 }\n";
let theirs = "fn a() {}\n";
let result = run(Lang::Rust, base, ours, theirs);
match result.status {
MergeStatus::Conflict { regions, .. } => {
assert!(regions.iter().any(|r| r.description.contains("modify-vs-delete")));
}
other => panic!("expected Conflict, got {other:?}"),
}
}
// ---------- recursion ----------
#[test]
fn rust_impl_disjoint_method_additions_merge_via_recursion() {
// The flat handler would mark this a top-level conflict because
// both sides modified `impl Foo`. With recursion we descend into
// the impl body and merge `fn b` and `fn c` as disjoint adds.
let base = "impl Foo {\n fn a(&self) {}\n}\n";
let ours = "impl Foo {\n fn a(&self) {}\n fn b(&self) {}\n}\n";
let theirs = "impl Foo {\n fn a(&self) {}\n fn c(&self) {}\n}\n";
let result = run(Lang::Rust, base, ours, theirs);
match result.status {
MergeStatus::Merged { content, notes } => {
let s = std::str::from_utf8(&content).unwrap();
assert!(s.contains("fn a("), "must keep existing method: {s}");
assert!(s.contains("fn b("), "must keep ours-side addition: {s}");
assert!(s.contains("fn c("), "must keep theirs-side addition: {s}");
assert!(s.contains("impl Foo"), "must preserve outer header");
assert!(
notes.iter().any(|n| n.message.contains("recursive descent")),
"merge note must record that recursion fired: {notes:?}"
);
}
other => panic!("expected Merged (via recursion), got {other:?}"),
}
}
#[test]
fn rust_impl_concurrent_edits_to_same_method_keep_outer_conflict() {
// Both sides edit fn a's body — the inner method bodies are
// anonymous statements, no recoverable identity, so recursion
// refuses and we get a clean outer-block conflict.
let base = "impl Foo {\n fn a(&self) { 1 }\n}\n";
let ours = "impl Foo {\n fn a(&self) { 2 }\n}\n";
let theirs = "impl Foo {\n fn a(&self) { 3 }\n}\n";
let result = run(Lang::Rust, base, ours, theirs);
match result.status {
MergeStatus::Conflict { regions, .. } => {
assert!(
regions.iter().any(|r| r.description.contains("impl_item")
|| r.description.contains("function_item")),
"expected outer-block conflict: {:?}",
regions
);
}
other => panic!("expected Conflict, got {other:?}"),
}
}
#[test]
fn rust_impl_header_rename_and_body_add_does_not_silently_recurse() {
// ours renames the impl header (Foo → Bar), theirs adds a method.
// A naive recursion would pick up theirs's method into ours's
// renamed impl, silently discarding the rename mismatch. We
// require the outer frame to be identical, so this stays an
// outer conflict.
let base = "impl Foo {\n fn a(&self) {}\n}\n";
let ours = "impl Bar {\n fn a(&self) {}\n}\n";
let theirs = "impl Foo {\n fn a(&self) {}\n fn d(&self) {}\n}\n";
let result = run(Lang::Rust, base, ours, theirs);
// Frames diverge → no recursion → outer-level conflict reported.
assert!(matches!(result.status, MergeStatus::Conflict { .. }));
}
#[test]
fn java_class_disjoint_method_additions_merge_via_recursion() {
let base = "class Foo {\n void a() {}\n}\n";
let ours = "class Foo {\n void a() {}\n void b() {}\n}\n";
let theirs = "class Foo {\n void a() {}\n void c() {}\n}\n";
let result = run(Lang::Java, base, ours, theirs);
match result.status {
MergeStatus::Merged { content, .. } => {
let s = std::str::from_utf8(&content).unwrap();
assert!(s.contains("void a("));
assert!(s.contains("void b("));
assert!(s.contains("void c("));
}
other => panic!("expected Merged, got {other:?}"),
}
}
#[test]
fn javascript_class_disjoint_method_additions_merge_via_recursion() {
let base = "class Foo {\n a() { return 1; }\n}\n";
let ours = "class Foo {\n a() { return 1; }\n b() { return 2; }\n}\n";
let theirs = "class Foo {\n a() { return 1; }\n c() { return 3; }\n}\n";
let result = run(Lang::JavaScript, base, ours, theirs);
match result.status {
MergeStatus::Merged { content, .. } => {
let s = std::str::from_utf8(&content).unwrap();
assert!(s.contains("a()"));
assert!(s.contains("b()"));
assert!(s.contains("c()"));
}
other => panic!("expected Merged, got {other:?}"),
}
}
#[test]
fn rust_recursion_preserves_inter_child_indentation() {
// The splice should re-use ours's " " indent between methods,
// not just join with "\n\n". Otherwise the merged file is valid
// but ugly enough to confuse downstream review.
let base = "impl Foo {\n fn a(&self) {}\n}\n";
let ours = "impl Foo {\n fn a(&self) {}\n fn b(&self) {}\n}\n";
let theirs = "impl Foo {\n fn a(&self) {}\n fn c(&self) {}\n}\n";
let result = run(Lang::Rust, base, ours, theirs);
match result.status {
MergeStatus::Merged { content, .. } => {
let s = std::str::from_utf8(&content).unwrap();
// The added method should begin with the same 4-space
// indent used for fn b in ours — i.e., the merged file
// contains " fn c", not "fn c" at column 0.
assert!(
s.contains(" fn c"),
"indentation must match ours's body style: {s}"
);
}
other => panic!("expected Merged, got {other:?}"),
}
}
}

View File

@ -0,0 +1,274 @@
//! Conformance corpus runner (§8.2).
//!
//! Walks `tests/corpus/` and runs every scenario through the default
//! cascade engine. Each scenario lives in its own directory:
//!
//! ```text
//! tests/corpus/<NNN-scenario-name>/
//! manifest.toml # what to assert about the merge result
//! base.<ext> # base / common ancestor input
//! ours.<ext> # left side input
//! theirs.<ext> # right side input
//! expected.<ext> # (optional) exact expected merged output
//! ```
//!
//! The corpus is the canonical conformance fixture. Any independent
//! implementation of LeVCS that produces the same outcome on every
//! scenario here is conformant per §8.2; in particular every scenario
//! tagged `git_false_conflict = true` is a case where naive Git
//! produces a spurious conflict and LeVCS MUST resolve correctly.
//!
//! The runner deliberately does no expensive setup — it is one
//! integration test that iterates the corpus, so adding a scenario
//! does not change the binary count or build graph.
use std::path::{Path, PathBuf};
use levcs_merge::engine::CascadeEngine;
use levcs_merge::handler::MergeStatus;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Manifest {
/// Human-readable summary of what this scenario tests. Required.
description: String,
/// Logical path used for handler routing — only the extension and
/// basename matter (the engine doesn't read from disk for this).
path: String,
/// File extension used to find base/ours/theirs files. Defaults to
/// the extension of `path`.
#[serde(default)]
input_ext: Option<String>,
/// True iff naive Git would produce a false conflict on this
/// scenario. Only used to compute corpus statistics; not asserted.
#[serde(default)]
git_false_conflict: bool,
expected: Expected,
}
#[derive(Debug, Deserialize)]
struct Expected {
/// "merged" or "conflict". (NotApplicable is never a final cascade
/// outcome — the engine always falls through to textual.)
status: String,
/// Required handler name (e.g., "json", "tree-sitter:rust",
/// "textual"). Asserts the cascade routed correctly.
handler: String,
/// Substrings every one of which must appear in the merged output.
/// Useful when ordering is implementation-defined (object key
/// ordering, etc.) but specific content must be present.
#[serde(default)]
contains: Vec<String>,
/// Substrings none of which may appear. Used to check that
/// drop-and-modify resolutions actually drop, or that conflict
/// markers are absent on a merged outcome.
#[serde(default)]
not_contains: Vec<String>,
/// If present, the merged output must equal this file's bytes
/// exactly. (`expected.<ext>` next to base/ours/theirs.)
#[serde(default)]
content_file: Option<String>,
/// Conflict-only: the number of conflict regions. None means "any
/// nonzero count".
#[serde(default)]
conflict_count: Option<usize>,
/// Conflict-only: each region's description must contain at least
/// one of these substrings. Used to assert that the cascade landed
/// on the expected diagnosis (e.g., "modify-vs-delete", "concurrent
/// edits").
#[serde(default)]
region_descriptions_contain: Vec<String>,
/// Merged-only: any of these notes' messages must contain at least
/// one of these substrings. Used to assert that, e.g., recursive
/// descent fired.
#[serde(default)]
notes_contain: Vec<String>,
}
fn corpus_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus")
}
fn collect_scenarios() -> Vec<PathBuf> {
let root = corpus_root();
assert!(
root.is_dir(),
"corpus directory missing: {}",
root.display()
);
let mut out = Vec::new();
for ent in std::fs::read_dir(&root).expect("read corpus dir").flatten() {
let p = ent.path();
if !p.is_dir() {
continue;
}
if p.join("manifest.toml").is_file() {
out.push(p);
}
}
out.sort();
out
}
fn run_scenario(dir: &Path) -> Result<(), String> {
let manifest_text = std::fs::read_to_string(dir.join("manifest.toml"))
.map_err(|e| format!("read manifest: {e}"))?;
let manifest: Manifest = toml::from_str(&manifest_text)
.map_err(|e| format!("parse manifest: {e}"))?;
let ext = manifest
.input_ext
.clone()
.or_else(|| {
Path::new(&manifest.path)
.extension()
.and_then(|e| e.to_str().map(String::from))
})
.ok_or_else(|| "manifest.path has no extension and input_ext is unset".to_string())?;
let base = std::fs::read(dir.join(format!("base.{ext}")))
.map_err(|e| format!("read base.{ext}: {e}"))?;
let ours = std::fs::read(dir.join(format!("ours.{ext}")))
.map_err(|e| format!("read ours.{ext}: {e}"))?;
let theirs = std::fs::read(dir.join(format!("theirs.{ext}")))
.map_err(|e| format!("read theirs.{ext}: {e}"))?;
let engine = CascadeEngine::default();
let result = engine.merge_file(Path::new(&manifest.path), &base, &ours, &theirs);
if result.handler != manifest.expected.handler {
return Err(format!(
"[{}] expected handler {:?}, got {:?}",
manifest.description, manifest.expected.handler, result.handler
));
}
match (manifest.expected.status.as_str(), &result.status) {
("merged", MergeStatus::Merged { content, notes }) => {
let s = String::from_utf8_lossy(content);
for needle in &manifest.expected.contains {
if !s.contains(needle) {
return Err(format!(
"[{}] merged content missing substring {:?}\n--- output ---\n{s}\n",
manifest.description, needle
));
}
}
for needle in &manifest.expected.not_contains {
if s.contains(needle) {
return Err(format!(
"[{}] merged content unexpectedly contains {:?}\n--- output ---\n{s}\n",
manifest.description, needle
));
}
}
if let Some(content_file) = &manifest.expected.content_file {
let want = std::fs::read(dir.join(content_file))
.map_err(|e| format!("read {content_file}: {e}"))?;
if want != *content {
return Err(format!(
"[{}] merged content does not match {content_file}\n--- want ---\n{}\n--- got ---\n{s}\n",
manifest.description,
String::from_utf8_lossy(&want)
));
}
}
for needle in &manifest.expected.notes_contain {
if !notes.iter().any(|n| n.message.contains(needle)) {
let messages: Vec<&str> =
notes.iter().map(|n| n.message.as_str()).collect();
return Err(format!(
"[{}] expected a note containing {:?}, saw {:?}",
manifest.description, needle, messages
));
}
}
// Sanity check: a merged outcome must not carry conflict markers.
// A handler that wrote markers but reported Merged would silently
// smuggle conflicts past CI.
if s.contains("<<<<<<< ours")
|| s.contains("=======")
|| s.contains(">>>>>>> theirs")
{
return Err(format!(
"[{}] merged outcome contains conflict markers — handler {:?} is buggy\n--- output ---\n{s}\n",
manifest.description, result.handler
));
}
Ok(())
}
("conflict", MergeStatus::Conflict { regions, .. }) => {
if let Some(want) = manifest.expected.conflict_count {
if regions.len() != want {
return Err(format!(
"[{}] expected {} conflict region(s), got {}",
manifest.description,
want,
regions.len()
));
}
} else if regions.is_empty() {
return Err(format!(
"[{}] expected at least one conflict region, got zero",
manifest.description
));
}
for needle in &manifest.expected.region_descriptions_contain {
if !regions.iter().any(|r| r.description.contains(needle)) {
let descs: Vec<&str> =
regions.iter().map(|r| r.description.as_str()).collect();
return Err(format!(
"[{}] expected a region with description containing {:?}, saw {:?}",
manifest.description, needle, descs
));
}
}
Ok(())
}
(want, got) => Err(format!(
"[{}] expected status {want:?}, got {got:?}",
manifest.description
)),
}
}
#[test]
fn corpus_is_non_empty() {
let scenarios = collect_scenarios();
assert!(
scenarios.len() >= 10,
"conformance corpus is too thin: {} scenario(s) found at {}",
scenarios.len(),
corpus_root().display()
);
}
#[test]
fn corpus_runs_clean() {
let scenarios = collect_scenarios();
let mut failures: Vec<String> = Vec::new();
let mut git_false_conflict_count = 0usize;
for dir in &scenarios {
// Re-read the manifest so we can count git-false-conflict
// scenarios separately for the summary line below.
if let Ok(t) = std::fs::read_to_string(dir.join("manifest.toml")) {
if let Ok(m) = toml::from_str::<Manifest>(&t) {
if m.git_false_conflict {
git_false_conflict_count += 1;
}
}
}
if let Err(e) = run_scenario(dir) {
failures.push(format!("\n in {}:\n {e}", dir.display()));
}
}
eprintln!(
"conformance: {} scenario(s) total, {} flagged as git-false-conflict",
scenarios.len(),
git_false_conflict_count
);
assert!(
failures.is_empty(),
"conformance failures:{}",
failures.join("")
);
}

View File

@ -0,0 +1,3 @@
{
"owner": "alice"
}

View File

@ -0,0 +1,8 @@
description = "JSON: disjoint top-level key additions on each side merge cleanly"
path = "config.json"
[expected]
status = "merged"
handler = "json"
contains = ["alice", "bob", "carol"]
not_contains = ["<<<<<<<"]

View File

@ -0,0 +1,4 @@
{
"owner": "alice",
"reviewer": "bob"
}

View File

@ -0,0 +1,4 @@
{
"owner": "alice",
"approver": "carol"
}

View File

@ -0,0 +1,3 @@
{
"version": "1.0"
}

View File

@ -0,0 +1,6 @@
description = "JSON: divergent edits to the same key surface as a conflict"
path = "config.json"
[expected]
status = "conflict"
handler = "json"

View File

@ -0,0 +1,3 @@
{
"version": "1.1"
}

View File

@ -0,0 +1,3 @@
{
"version": "2.0"
}

View File

@ -0,0 +1 @@
owner: alice

View File

@ -0,0 +1,7 @@
description = "YAML: disjoint key additions merge cleanly via the JSON-bridged YAML handler"
path = "values.yaml"
[expected]
status = "merged"
handler = "yaml"
contains = ["alice", "bob", "carol"]

View File

@ -0,0 +1,2 @@
owner: alice
reviewer: bob

View File

@ -0,0 +1,2 @@
owner: alice
approver: carol

View File

@ -0,0 +1,3 @@
[package]
name = "demo"
version = "0.1.0"

View File

@ -0,0 +1,8 @@
description = "TOML: each side adds a new table at the top level — both must survive"
path = "Cargo.toml"
git_false_conflict = true
[expected]
status = "merged"
handler = "toml"
contains = ["[dependencies]", "[dev-dependencies]"]

View File

@ -0,0 +1,6 @@
[package]
name = "demo"
version = "0.1.0"
[dependencies]
serde = "1"

View File

@ -0,0 +1,6 @@
[package]
name = "demo"
version = "0.1.0"
[dev-dependencies]
proptest = "1"

View File

@ -0,0 +1,2 @@
[server]
port = 8080

View File

@ -0,0 +1,6 @@
description = "TOML: divergent edits to the same key surface as a structural conflict"
path = "config.toml"
[expected]
status = "conflict"
handler = "toml"

View File

@ -0,0 +1,2 @@
[server]
port = 9090

View File

@ -0,0 +1,2 @@
[server]
port = 7070

View File

@ -0,0 +1,5 @@
# Project notes
## Overview
The system processes events in real time.

View File

@ -0,0 +1,8 @@
description = "Markdown: each side appends a different new section — both survive, no conflict"
path = "CHANGELOG.md"
git_false_conflict = true
[expected]
status = "merged"
handler = "markdown"
contains = ["## Authentication", "## Caching"]

View File

@ -0,0 +1,9 @@
# Project notes
## Overview
The system processes events in real time.
## Authentication
Requests are signed with Ed25519 keys.

View File

@ -0,0 +1,9 @@
# Project notes
## Overview
The system processes events in real time.
## Caching
A 1 GB LRU cache fronts the object store.

View File

@ -0,0 +1,4 @@
<config>
<owner>alice</owner>
<port>8080</port>
</config>

View File

@ -0,0 +1,9 @@
description = "XML: each side edits a different child element under the same parent — both edits land"
path = "config.xml"
git_false_conflict = true
[expected]
status = "merged"
handler = "xml"
contains = ["<owner>bob</owner>", "<port>9090</port>"]
not_contains = ["<owner>alice</owner>", "<port>8080</port>"]

View File

@ -0,0 +1,4 @@
<config>
<owner>bob</owner>
<port>8080</port>
</config>

View File

@ -0,0 +1,4 @@
<config>
<owner>alice</owner>
<port>9090</port>
</config>

Some files were not shown because too many files have changed in this diff Show More