levcs 0.1.0 - initial core
This commit is contained in:
commit
21c6056ae6
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -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) {}
|
||||||
|
|
@ -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) {}
|
||||||
|
|
@ -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
|
|
@ -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())?)
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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}");
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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, ¶ms)?;
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(""));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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) {}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()))
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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('&', "&").replace('<', "<")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xml_escape_attr(s: &str) -> String {
|
||||||
|
s.replace('&', "&").replace('"', """).replace('<', "<")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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};
|
||||||
|
|
@ -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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"owner": "alice"
|
||||||
|
}
|
||||||
|
|
@ -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 = ["<<<<<<<"]
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"owner": "alice",
|
||||||
|
"reviewer": "bob"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"owner": "alice",
|
||||||
|
"approver": "carol"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
description = "JSON: divergent edits to the same key surface as a conflict"
|
||||||
|
path = "config.json"
|
||||||
|
|
||||||
|
[expected]
|
||||||
|
status = "conflict"
|
||||||
|
handler = "json"
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"version": "1.1"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"version": "2.0"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
owner: alice
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
owner: alice
|
||||||
|
reviewer: bob
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
owner: alice
|
||||||
|
approver: carol
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
[package]
|
||||||
|
name = "demo"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
@ -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]"]
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
[package]
|
||||||
|
name = "demo"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = "1"
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
[package]
|
||||||
|
name = "demo"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
proptest = "1"
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
[server]
|
||||||
|
port = 8080
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
[server]
|
||||||
|
port = 9090
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
[server]
|
||||||
|
port = 7070
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Project notes
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The system processes events in real time.
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Project notes
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The system processes events in real time.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Requests are signed with Ed25519 keys.
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Project notes
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The system processes events in real time.
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
A 1 GB LRU cache fronts the object store.
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<config>
|
||||||
|
<owner>alice</owner>
|
||||||
|
<port>8080</port>
|
||||||
|
</config>
|
||||||
|
|
@ -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>"]
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<config>
|
||||||
|
<owner>bob</owner>
|
||||||
|
<port>8080</port>
|
||||||
|
</config>
|
||||||
|
|
@ -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
Loading…
Reference in New Issue