339 lines
12 KiB
Rust
339 lines
12 KiB
Rust
//! 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);
|
|
}
|