#![cfg(feature = "mirror-http")]
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{SocketAddr, TcpListener};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use gix_object::Kind;
#[cfg(feature = "replication")]
use gunnar_core::model::{Grant, Level, PrincipalKind, Visibility};
use gunnar_core::{HashKind, NewRepo, RepoRef};
use gunnar_grpc::seam::ControlPlane as _;
use gunnar_proto::{MirrorCredentialKind, MirrorJobState, RepoPath};
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
use gunnar_store::conformance::pack::PackBuilder;
use gunnar_store::git_storage_trait::{GitOps, GitServe};
type Oid = gix_hash::ObjectId;
fn compute(hash: gix_hash::Kind, kind: Kind, content: &[u8]) -> Oid {
gix_object::compute_hash(hash, kind, content).expect("hashing an object")
}
fn tree_bytes(entries: &[(&str, &str, Oid)]) -> Vec<u8> {
let mut body = Vec::new();
for (mode, name, oid) in entries {
body.extend_from_slice(mode.as_bytes());
body.push(b' ');
body.extend_from_slice(name.as_bytes());
body.push(0);
body.extend_from_slice(oid.as_bytes());
}
body
}
fn commit_bytes(tree: Oid, message: &str) -> Vec<u8> {
let who = "Alice <alice@example.com> 1700000000 +0000";
let mut body = format!("tree {}\n", tree.to_hex());
body.push_str(&format!("author {who}\n"));
body.push_str(&format!("committer {who}\n\n"));
body.push_str(message);
body.push('\n');
body.into_bytes()
}
#[cfg(feature = "replication")]
fn plant_a_commit(git: &dyn GitServe, hash: gix_hash::Kind) -> (Oid, Oid) {
plant_a_commit_of(git, hash, b"the quick brown fox\n".to_vec())
}
fn noise(bytes: usize) -> Vec<u8> {
let mut state: u64 = 0x2545_F491_4F6C_DD1D;
(0..bytes)
.map(|_| {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(state >> 33) as u8
})
.collect()
}
fn plant_a_commit_of(git: &dyn GitServe, hash: gix_hash::Kind, blob: Vec<u8>) -> (Oid, Oid) {
let blob_id = compute(hash, Kind::Blob, &blob);
let tree = tree_bytes(&[("100644", "fox.txt", blob_id)]);
let tree_id = compute(hash, Kind::Tree, &tree);
let commit = commit_bytes(tree_id, "first");
let commit_id = compute(hash, Kind::Commit, &commit);
let mut pack = PackBuilder::new(hash);
for (kind, content) in [
(Kind::Blob, blob),
(Kind::Tree, tree),
(Kind::Commit, commit),
] {
pack.object(kind, content);
}
let fixture = pack.build().expect("building the pack");
git.put_pack(&fixture.bytes).expect("storing the pack");
git.update_ref("refs/heads/main", None, Some(commit_id.as_bytes()))
.expect("pointing main at the commit");
(commit_id, blob_id)
}
fn tip(git: &dyn GitServe, name: &str) -> Option<Oid> {
GitOps::refs(git)
.expect("reading refs")
.into_iter()
.find(|row| row.name == name)
.and_then(|row| row.oid)
.map(|raw| {
gix_hash::oid::try_from_bytes(&raw)
.expect("a stored oid decodes")
.to_owned()
})
}
struct Downstream {
_dir: tempfile::TempDir,
stores: Arc<ServerStores>,
plane: CoreControlPlane,
path: RepoPath,
repo: gunnar_core::model::Repo,
}
impl Downstream {
fn open(hash: HashKind) -> Downstream {
let down = Downstream::open_strict(hash);
std::fs::write(
down.stores
.root()
.join(gunnar_server::mirror_fetch::MIRROR_ALLOW_PRIVATE),
b"the upstreams in this test are on 127.0.0.1\n",
)
.expect("allowing private upstreams on this box");
down
}
fn open_strict(hash: HashKind) -> Downstream {
let dir = tempfile::tempdir().expect("a temp dir");
let stores = Arc::new(ServerStores::open(dir.path().join("data")).expect("stores"));
stores
.catalog()
.create_namespace("mirror")
.expect("namespace");
let repo_ref = RepoRef::new("mirror", "copy").expect("a legal repo path");
let repo = stores
.create_repo(NewRepo::new(repo_ref).hash_kind(hash), false)
.expect("create the local repository");
stores.repo_store(&repo).expect("open the local store");
let plane = CoreControlPlane::new(Arc::clone(&stores));
Downstream {
_dir: dir,
stores,
plane,
path: RepoPath::new("mirror", "copy"),
repo,
}
}
fn git(&self) -> Arc<gunnar_server::storage::OpenRepo> {
self.stores.repo_store(&self.repo).expect("open the store")
}
fn settle(&self) -> (gunnar_proto::MirrorJob, Vec<(bool, u64, u64)>) {
let deadline = Instant::now() + Duration::from_secs(120);
let mut seen: Vec<(bool, u64, u64)> = Vec::new();
while Instant::now() < deadline {
let job = self.job();
let reading = (job.has_progress, job.progress_done, job.progress_total);
if seen.last() != Some(&reading) {
seen.push(reading);
}
if job.state != MirrorJobState::Queued as i32
&& job.state != MirrorJobState::Running as i32
{
return (job, seen);
}
std::thread::sleep(Duration::from_millis(5));
}
panic!("the mirror sync never settled");
}
fn job(&self) -> gunnar_proto::MirrorJob {
self.plane
.mirror_status(None, Some(&self.path))
.expect("mirror_status is implemented")
.into_iter()
.next()
.expect("the registration is there")
.job
.expect("a job that was just enqueued is still known")
}
}
#[cfg(feature = "replication")]
struct SshUpstream {
_dir: tempfile::TempDir,
runtime: tokio::runtime::Runtime,
server: Option<gunnar_server::Server>,
key_pem: String,
url: String,
head: Oid,
blob: Oid,
}
#[cfg(feature = "replication")]
impl SshUpstream {
fn start(hash: HashKind) -> SshUpstream {
let dir = tempfile::tempdir().expect("a temp dir");
let stores = Arc::new(ServerStores::open(dir.path().join("data")).expect("stores"));
stores
.catalog()
.create_namespace("upstream")
.expect("namespace");
let repo_ref = RepoRef::new("upstream", "origin").expect("a legal repo path");
let repo = stores
.catalog()
.create(
NewRepo::new(repo_ref.clone())
.hash_kind(hash)
.visibility(Visibility::Private),
)
.expect("create the upstream repository");
let store = stores.repo_store(&repo).expect("materialise it");
let (head, blob) = plant_a_commit(store.serve(), store.hash_kind());
let key_pem = String::from_utf8(gunnar_core::mint_ed25519().expect("mint a key"))
.expect("an OpenSSH private key is utf-8 PEM");
let public = gunnar_core::ssh_key::PrivateKey::from_openssh(&key_pem)
.expect("the minted key parses")
.public_key()
.to_openssh()
.expect("render the public half");
stores
.auth()
.add_principal(
gunnar_core::Principal::new("mirrorer", "mirrorer", PrincipalKind::User).unwrap(),
)
.expect("add the principal");
stores
.auth()
.register_key("mirrorer", &public, None)
.expect("register the key");
stores
.auth()
.grant(Grant::repo("mirrorer", repo_ref, Level::Read))
.expect("grant read");
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("a tokio runtime");
let config = gunnar_server::ServeConfig {
no_grpc: true,
allow_anonymous: false,
..gunnar_server::ServeConfig::ephemeral()
};
let server = runtime
.block_on(gunnar_server::Server::start(Arc::clone(&stores), config))
.expect("the upstream server starts");
let port = server.ssh_addr().port();
SshUpstream {
_dir: dir,
runtime,
server: Some(server),
key_pem,
url: format!("ssh://mirrorer@127.0.0.1:{port}/upstream/origin.git"),
head,
blob,
}
}
}
#[cfg(feature = "replication")]
impl Drop for SshUpstream {
fn drop(&mut self) {
if let Some(server) = self.server.take() {
let _ = self.runtime.block_on(server.shutdown());
}
}
}
struct HttpUpstream {
addr: SocketAddr,
stop: Arc<AtomicBool>,
refused: Arc<AtomicU64>,
seen_auth: Arc<Mutex<Vec<String>>>,
#[allow(dead_code)]
hold: Option<Arc<AtomicBool>>,
#[allow(dead_code)]
rename: Option<(String, String)>,
}
impl HttpUpstream {
fn start(
stores: Arc<ServerStores>,
repo: gunnar_core::model::Repo,
require: Option<String>,
) -> HttpUpstream {
HttpUpstream::start_held(stores, repo, require, None)
}
fn start_renaming(
stores: Arc<ServerStores>,
repo: gunnar_core::model::Repo,
from: &str,
to: &str,
) -> HttpUpstream {
assert_eq!(
from.len(),
to.len(),
"a pkt-line carries its own length; the substitution must not change it"
);
HttpUpstream::start_with(
stores,
repo,
None,
None,
Some((from.to_owned(), to.to_owned())),
)
}
fn start_held(
stores: Arc<ServerStores>,
repo: gunnar_core::model::Repo,
require: Option<String>,
hold: Option<Arc<AtomicBool>>,
) -> HttpUpstream {
HttpUpstream::start_with(stores, repo, require, hold, None)
}
fn start_with(
stores: Arc<ServerStores>,
repo: gunnar_core::model::Repo,
require: Option<String>,
hold: Option<Arc<AtomicBool>>,
rename: Option<(String, String)>,
) -> HttpUpstream {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind a loopback port");
listener
.set_nonblocking(false)
.expect("a blocking listener");
let addr = listener.local_addr().expect("the bound address");
let stop = Arc::new(AtomicBool::new(false));
let refused = Arc::new(AtomicU64::new(0));
let seen_auth = Arc::new(Mutex::new(Vec::new()));
let worker = HttpUpstream {
addr,
stop: Arc::clone(&stop),
refused: Arc::clone(&refused),
seen_auth: Arc::clone(&seen_auth),
hold: hold.clone(),
rename: rename.clone(),
};
std::thread::spawn(move || {
for connection in listener.incoming() {
if stop.load(Ordering::SeqCst) {
return;
}
let Ok(mut connection) = connection else {
return;
};
let store = stores.repo_store(&repo).expect("open the upstream store");
let _ = serve_one_http(
&mut connection,
store.serve(),
store.hash_kind(),
require.as_deref(),
&refused,
&seen_auth,
hold.as_deref(),
rename.as_ref(),
);
}
});
worker
}
fn base(&self) -> String {
format!("http://{}/upstream/origin.git", self.addr)
}
}
impl Drop for HttpUpstream {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
let _ = std::net::TcpStream::connect(self.addr);
}
}
#[allow(clippy::too_many_arguments)]
fn serve_one_http(
connection: &mut std::net::TcpStream,
git: &dyn GitServe,
hash: gix_hash::Kind,
require: Option<&str>,
refused: &AtomicU64,
seen_auth: &Mutex<Vec<String>>,
hold: Option<&AtomicBool>,
rename: Option<&(String, String)>,
) -> std::io::Result<()> {
let mut reader = BufReader::new(connection.try_clone()?);
let mut line = String::new();
reader.read_line(&mut line)?;
let mut parts = line.split_whitespace();
let method = parts.next().unwrap_or_default().to_owned();
let target = parts.next().unwrap_or_default().to_owned();
let mut length = 0usize;
let mut authorization: Option<String> = None;
loop {
let mut header = String::new();
if reader.read_line(&mut header)? == 0 {
break;
}
let header = header.trim_end();
if header.is_empty() {
break;
}
let (name, value) = header.split_once(':').unwrap_or((header, ""));
if name.eq_ignore_ascii_case("content-length") {
length = value.trim().parse().unwrap_or(0);
}
if name.eq_ignore_ascii_case("authorization") {
authorization = Some(value.trim().to_owned());
}
}
if let Some(value) = &authorization {
seen_auth.lock().unwrap().push(value.clone());
}
if let Some(want) = require {
if authorization.as_deref() != Some(want) {
refused.fetch_add(1, Ordering::SeqCst);
connection.write_all(
b"HTTP/1.1 401 Unauthorized\r\n\
WWW-Authenticate: Basic realm=\"gunnar\"\r\n\
Content-Length: 0\r\n\
Connection: close\r\n\r\n",
)?;
return connection.flush();
}
}
let mut body = vec![0u8; length];
reader.read_exact(&mut body)?;
let is_fetch = body
.windows(b"command=fetch".len())
.any(|window| window == b"command=fetch");
let upload = gunnar_wire::UploadPack::new(git, hash);
let mut out = Vec::new();
let (content_type, outcome) = if method == "GET" && target.contains("/info/refs") {
(
"application/x-git-upload-pack-advertisement",
upload.advertise(gunnar_wire::ProtocolVersion::V2, &mut out),
)
} else {
let mut input = std::io::Cursor::new(body);
(
"application/x-git-upload-pack-result",
upload
.serve_session(
gunnar_wire::ProtocolVersion::V2,
gunnar_wire::Session::StatelessRpc,
&mut input,
&mut out,
)
.map(|_| ()),
)
};
if let Err(err) = outcome {
let body = format!("upload-pack failed: {err}");
connection.write_all(
format!(
"HTTP/1.1 500 Internal Server Error\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.as_bytes(),
)?;
return connection.flush();
}
if let Some((from, to)) = rename {
let (from, to) = (from.as_bytes(), to.as_bytes());
let mut at = 0;
while at + from.len() <= out.len() {
if &out[at..at + from.len()] == from {
out[at..at + from.len()].copy_from_slice(to);
at += from.len();
} else {
at += 1;
}
}
}
connection.write_all(
format!(
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n\
Cache-Control: no-cache\r\nConnection: close\r\n\r\n",
out.len()
)
.as_bytes(),
)?;
match (hold, is_fetch && out.len() > 32) {
(Some(gate), true) => {
let cut = out.len() - 24;
connection.write_all(&out[..cut])?;
connection.flush()?;
while !gate.load(Ordering::SeqCst) {
std::thread::yield_now();
}
connection.write_all(&out[cut..])?;
}
_ => connection.write_all(&out)?,
}
connection.flush()
}
fn http_upstream_stores(
hash: HashKind,
) -> (
tempfile::TempDir,
Arc<ServerStores>,
gunnar_core::model::Repo,
Oid,
Oid,
) {
http_upstream_stores_of(hash, b"the quick brown fox\n".to_vec())
}
fn http_upstream_stores_wide(
hash: HashKind,
blobs: usize,
) -> (
tempfile::TempDir,
Arc<ServerStores>,
gunnar_core::model::Repo,
Oid,
u64,
) {
let dir = tempfile::tempdir().expect("a temp dir");
let stores = Arc::new(ServerStores::open(dir.path().join("data")).expect("stores"));
stores
.catalog()
.create_namespace("upstream")
.expect("namespace");
let repo_ref = RepoRef::new("upstream", "origin").expect("a legal repo path");
let repo = stores
.catalog()
.create(NewRepo::new(repo_ref).hash_kind(hash))
.expect("create the upstream repository");
let store = stores.repo_store(&repo).expect("materialise it");
let git = store.serve();
let hash_kind = store.hash_kind();
let mut pack = PackBuilder::new(hash_kind);
let mut entries: Vec<(&'static str, String, Oid)> = Vec::with_capacity(blobs);
for n in 0..blobs {
let content = format!("file number {n}\n").into_bytes();
let id = compute(hash_kind, Kind::Blob, &content);
pack.object(Kind::Blob, content);
entries.push(("100644", format!("f{n:05}"), id));
}
let tree = tree_bytes(
&entries
.iter()
.map(|(mode, name, id)| (*mode, name.as_str(), *id))
.collect::<Vec<_>>(),
);
let tree_id = compute(hash_kind, Kind::Tree, &tree);
let commit = commit_bytes(tree_id, "wide");
let commit_id = compute(hash_kind, Kind::Commit, &commit);
pack.object(Kind::Tree, tree);
pack.object(Kind::Commit, commit);
let fixture = pack.build().expect("building the wide pack");
git.put_pack(&fixture.bytes).expect("storing the pack");
git.update_ref("refs/heads/main", None, Some(commit_id.as_bytes()))
.expect("pointing main at the commit");
(dir, stores, repo, commit_id, blobs as u64 + 2)
}
fn http_upstream_stores_of(
hash: HashKind,
blob: Vec<u8>,
) -> (
tempfile::TempDir,
Arc<ServerStores>,
gunnar_core::model::Repo,
Oid,
Oid,
) {
let dir = tempfile::tempdir().expect("a temp dir");
let stores = Arc::new(ServerStores::open(dir.path().join("data")).expect("stores"));
stores
.catalog()
.create_namespace("upstream")
.expect("namespace");
let repo_ref = RepoRef::new("upstream", "origin").expect("a legal repo path");
let repo = stores
.catalog()
.create(NewRepo::new(repo_ref).hash_kind(hash))
.expect("create the upstream repository");
let store = stores.repo_store(&repo).expect("materialise it");
let (head, blob_id) = plant_a_commit_of(store.serve(), store.hash_kind(), blob);
(dir, stores, repo, head, blob_id)
}
fn assert_mirrored(
down: &Downstream,
job: &gunnar_proto::MirrorJob,
progress: &[(bool, u64, u64)],
head: Oid,
blob: Oid,
) {
assert_eq!(
job.state,
MirrorJobState::Done as i32,
"the sync failed: {}",
job.error
);
let store = down.git();
assert_eq!(
tip(store.serve(), "refs/heads/main"),
Some(head),
"the mirror's branch is not at the upstream's commit"
);
for (what, oid) in [("the commit", head), ("the blob", blob)] {
assert!(
GitOps::extents(store.serve(), &[oid.as_bytes()])
.expect("asking the store for an object")
.first()
.is_some_and(Option::is_some),
"{what} the reference needs is not in the mirror's store"
);
}
assert_eq!(
job.objects, 3,
"the fixture is one commit, one tree and one blob: {job:?}"
);
assert!(job.bytes > 0, "a pack of three objects has a size: {job:?}");
assert_eq!(
job.updates.len(),
1,
"one reference moved, and the job must name it: {:?}",
job.updates
);
assert_eq!(job.updates[0].ref_name, "refs/heads/main");
assert_eq!(job.updates[0].new_oid, head.to_string());
assert_eq!(
job.updates[0].old_oid,
"0".repeat(head.to_string().len()),
"a branch that did not exist here before is a CREATE: all-zeroes old"
);
assert_eq!(
(job.has_progress, job.progress_done, job.progress_total),
(true, 3, 3),
"a finished sync reports every object it took: {job:?}"
);
for reading in progress {
assert!(
!reading.0 || (reading.2 == 3 && reading.1 <= 3),
"a progress reading with a denominator the packfile never declared: \
{reading:?} in {progress:?}"
);
}
}
fn register(down: &Downstream, url: &str, kind: MirrorCredentialKind, secret: &str, user: &str) {
down.plane
.mirror_add(&down.path, url, false, false)
.expect("register the mirror");
if kind != MirrorCredentialKind::Unspecified {
let config = down
.plane
.mirror_set_credential(&down.path, kind, secret, user)
.expect("store the credential");
assert_eq!(
config.credential, kind as i32,
"the registration must say WHICH kind is stored"
);
}
}
#[cfg(feature = "replication")]
fn mirror_over_ssh(hash: HashKind) {
let upstream = SshUpstream::start(hash);
let down = Downstream::open(hash);
register(
&down,
&upstream.url,
MirrorCredentialKind::SshKey,
&upstream.key_pem,
"",
);
let accepted = down
.plane
.mirror_sync(&down.path)
.expect("the sync is accepted");
let job = accepted.job.expect("Mirror.Sync answers with a job");
assert!(
job.state == MirrorJobState::Queued as i32 || job.state == MirrorJobState::Running as i32,
"Mirror.Sync must return with the job still live, not after fetching: {job:?}"
);
assert!(job.job_id.starts_with("mirror-"), "{job:?}");
let (done, progress) = down.settle();
assert_eq!(
done.job_id, job.job_id,
"the finished job must be the one that was accepted"
);
assert_mirrored(&down, &done, &progress, upstream.head, upstream.blob);
}
#[cfg(feature = "replication")]
#[test]
fn a_box_with_strict_host_keys_refuses_an_upstream_it_has_never_seen() {
let upstream = SshUpstream::start(HashKind::Sha256);
let down = Downstream::open(HashKind::Sha256);
let known_hosts = down
.stores
.root()
.join(gunnar_server::mirror_fetch::MIRROR_KNOWN_HOSTS);
assert!(
!known_hosts.exists(),
"the fixture must start with nothing pinned, or strictness proves nothing"
);
std::fs::write(
down.stores
.root()
.join(gunnar_server::mirror_fetch::MIRROR_STRICT_HOST_KEY),
b"this box does not learn host keys from the network\n",
)
.expect("asking this box to refuse an unknown host key");
register(
&down,
&upstream.url,
MirrorCredentialKind::SshKey,
&upstream.key_pem,
"",
);
down.plane
.mirror_sync(&down.path)
.expect("the sync is accepted");
let (job, _) = down.settle();
assert_eq!(
job.state,
MirrorJobState::Failed as i32,
"a box that refuses unknown host keys mirrored from one anyway: {job:?}"
);
assert!(
job.error.contains("host key") || job.error.contains("known_hosts"),
"the refusal must name what it refused: {}",
job.error
);
assert_eq!(
tip(down.git().serve(), "refs/heads/main"),
None,
"nothing was fetched"
);
assert!(
!known_hosts.exists(),
"a strict dial must LEARN NOTHING; `{}` was written by the fetch",
known_hosts.display()
);
}
#[cfg(feature = "replication")]
#[test]
fn a_mirror_sync_over_ssh_copies_a_sha256_repository() {
mirror_over_ssh(HashKind::Sha256);
}
#[cfg(feature = "replication")]
#[test]
fn a_mirror_sync_over_ssh_copies_a_sha1_repository() {
mirror_over_ssh(HashKind::Sha1);
}
fn mirror_over_http(hash: HashKind) {
let (_dir, stores, repo, head, blob) = http_upstream_stores(hash);
let upstream = HttpUpstream::start(stores, repo, None);
let down = Downstream::open(hash);
register(
&down,
&upstream.base(),
MirrorCredentialKind::Unspecified,
"",
"",
);
down.plane
.mirror_sync(&down.path)
.expect("the sync is accepted");
let (done, progress) = down.settle();
assert_mirrored(&down, &done, &progress, head, blob);
}
#[test]
fn a_mirror_sync_over_smart_http_copies_a_sha256_repository() {
mirror_over_http(HashKind::Sha256);
}
#[test]
fn a_mirror_sync_over_smart_http_copies_a_sha1_repository() {
mirror_over_http(HashKind::Sha1);
}
#[test]
fn a_private_http_upstream_needs_the_token_and_never_shows_it() {
const TOKEN: &str = "ghp_MirrorSyncNeverLeaksThis";
let (_dir, stores, repo, head, blob) = http_upstream_stores(HashKind::Sha256);
let upstream = HttpUpstream::start(
Arc::clone(&stores),
repo.clone(),
Some(format!("Bearer {TOKEN}")),
);
let down = Downstream::open(HashKind::Sha256);
register(
&down,
&upstream.base(),
MirrorCredentialKind::Unspecified,
"",
"",
);
down.plane.mirror_sync(&down.path).expect("accepted");
let (refused, _) = down.settle();
assert_eq!(
refused.state,
MirrorJobState::Failed as i32,
"a private upstream must refuse an anonymous mirror, not serve it an empty answer"
);
assert!(
refused.error.contains("401"),
"the failure must name what the upstream said: {}",
refused.error
);
assert!(
upstream.refused.load(Ordering::SeqCst) >= 1,
"the upstream never actually refused anything, so this proves nothing"
);
assert_eq!(
tip(down.git().serve(), "refs/heads/main"),
None,
"a refused sync must move no reference"
);
down.plane
.mirror_set_credential(&down.path, MirrorCredentialKind::Token, TOKEN, "")
.expect("store the token");
down.plane.mirror_sync(&down.path).expect("accepted");
let (done, progress) = down.settle();
assert_mirrored(&down, &done, &progress, head, blob);
let seen = upstream.seen_auth.lock().unwrap().clone();
assert!(
seen.iter().any(|value| value == &format!("Bearer {TOKEN}")),
"the upstream never received the token: {seen:?}"
);
let mut surfaces: Vec<String> = vec![
format!(
"{:?}",
down.plane.mirror_status(None, Some(&down.path)).unwrap()
),
format!("{done:?}"),
format!("{refused:?}"),
format!("{:?}", down.plane.mirror_status(None, None).unwrap()),
];
surfaces.push(format!(
"{:?}",
down.plane
.mirror_set_credential(&down.path, MirrorCredentialKind::Token, TOKEN, "")
.unwrap()
));
for surface in &surfaces {
assert!(
!surface.contains(TOKEN),
"the token appears in something a caller can read:\n{surface}"
);
}
down.plane
.mirror_set_credential(&down.path, MirrorCredentialKind::Unspecified, "", "")
.expect("forget the token");
down.plane.mirror_sync(&down.path).expect("accepted");
let (after, _) = down.settle();
assert_eq!(
after.state,
MirrorJobState::Failed as i32,
"a forgotten credential must actually be gone: {after:?}"
);
}
#[test]
fn the_progress_pair_moves_from_zero_to_n_while_the_sync_runs() {
let (_dir, stores, repo, head, blob) =
http_upstream_stores_of(HashKind::Sha256, noise(200 * 1024));
let release = Arc::new(AtomicBool::new(false));
let upstream = HttpUpstream::start_held(stores, repo, None, Some(Arc::clone(&release)));
let down = Downstream::open(HashKind::Sha256);
register(
&down,
&upstream.base(),
MirrorCredentialKind::Unspecified,
"",
"",
);
down.plane.mirror_sync(&down.path).expect("accepted");
let deadline = Instant::now() + Duration::from_secs(60);
loop {
assert!(
Instant::now() < deadline,
"the sync never published a denominator: {:?}",
down.job()
);
let job = down.job();
if job.has_progress {
assert_eq!(
(job.progress_done, job.progress_total),
(0, 3),
"the FIRST reading must be (0, N): the total is known from the packfile \
header, the numerator is not known until the ingest returns"
);
assert_eq!(
job.state,
MirrorJobState::Running as i32,
"progress must be readable WHILE the job runs, which is the only time \
anybody needs it: {job:?}"
);
break;
}
assert_ne!(
job.state,
MirrorJobState::Done as i32,
"the job finished without ever publishing a denominator: {job:?}"
);
std::thread::yield_now();
}
release.store(true, Ordering::SeqCst);
let (done, progress) = down.settle();
assert_mirrored(&down, &done, &progress, head, blob);
}
#[test]
fn the_object_count_moves_while_the_pack_lands() {
struct Series(Mutex<Vec<(u64, u64)>>);
impl gunnar_store::git_storage_trait::PackProgress for Series {
fn indexed(&self, done: u64, total: u64) {
self.0
.lock()
.unwrap_or_else(|p| p.into_inner())
.push((done, total));
}
}
let (_dir, up_stores, up_repo, head, objects) =
http_upstream_stores_wide(HashKind::Sha256, 300);
let upstream = HttpUpstream::start(up_stores, up_repo, None);
let down = Downstream::open(HashKind::Sha256);
let series = Arc::new(Series(Mutex::new(Vec::new())));
let sink = Arc::clone(&series);
let stores = Arc::clone(&down.stores);
let queue = gunnar_server::mirror_queue::MirrorQueue::new(Arc::new(move |order, report| {
gunnar_server::mirror_fetch::sync_once_watching(
&stores,
order,
&gunnar_server::mirror_fetch::Credential::None,
report,
Some(&*sink),
)
}));
let order = gunnar_server::mirror_queue::MirrorOrder {
path: "mirror/copy".to_owned(),
upstream: upstream.base(),
prune: false,
};
queue.enqueue(order);
let deadline = Instant::now() + Duration::from_secs(120);
let job = loop {
assert!(Instant::now() < deadline, "the mirror sync never settled");
let job = queue.status("mirror/copy").expect("the job is still known");
if job.state != gunnar_server::job_queue::JobState::Queued
&& job.state != gunnar_server::job_queue::JobState::Running
{
break job;
}
std::thread::sleep(Duration::from_millis(5));
};
queue.stop();
assert_eq!(
job.state,
gunnar_server::job_queue::JobState::Done,
"the sync failed: {:?}",
job.error
);
assert_eq!(
tip(down.git().serve(), "refs/heads/main"),
Some(head),
"the mirror's branch is not at the upstream's commit"
);
assert_eq!(job.objects, objects, "the pack's own object count");
let seen = series.0.lock().unwrap_or_else(|p| p.into_inner()).clone();
let dones: Vec<u64> = seen.iter().map(|(done, _)| *done).collect();
assert!(!seen.is_empty(), "the ingest published nothing at all");
for (done, total) in &seen {
assert_eq!(
*total, objects,
"the denominator moved mid-transfer: a reading declared {total} for a pack \
of {objects}"
);
assert!(
*done <= objects,
"a reading claims {done} of {objects} objects are in"
);
}
assert!(
seen.windows(2).all(|w| w[0].0 <= w[1].0),
"the count went backwards: {dones:?}"
);
assert_eq!(
seen.last().map(|(done, _)| *done),
Some(objects),
"the last reading is not ({objects}, {objects}); a bar that stops short of the \
end reads exactly like a transfer that hung"
);
let distinct = {
let mut values = dones.clone();
values.dedup();
values.len()
};
assert!(
distinct > 2,
"the count has {distinct} distinct value(s) over a pack of {objects} objects: \
{:?}. Two is `(0, N)` and `(N, N)` — the shape this test exists to make \
unshippable.",
if dones.len() > 8 {
&dones[..8]
} else {
&dones[..]
}
);
assert_eq!(
job.progress,
Some((objects, objects)),
"the job's own progress pair must end where the series does"
);
}
#[test]
fn an_upstream_may_not_name_a_reference_gunnar_would_not_accept() {
let (_dir, stores, repo, _head, _blob) = http_upstream_stores(HashKind::Sha256);
let upstream = HttpUpstream::start_renaming(stores, repo, "refs/heads/main", "refs/heads/../x");
let down = Downstream::open(HashKind::Sha256);
register(
&down,
&upstream.base(),
MirrorCredentialKind::Unspecified,
"",
"",
);
down.plane
.mirror_sync(&down.path)
.expect("the sync is accepted");
let (job, _) = down.settle();
assert_eq!(
job.state,
MirrorJobState::Failed as i32,
"a name with `..` in it was accepted from an upstream: {job:?}"
);
assert!(
job.error.contains("will not write") && job.error.contains(".."),
"the refusal must say which rule the name broke: {}",
job.error
);
let held = GitOps::refs(down.git().serve()).expect("reading refs");
assert!(
held.iter().all(|row| !row.name.contains("..")),
"a reference the upstream named is in this store: {:?}",
held.iter().map(|row| &row.name).collect::<Vec<_>>()
);
}
#[test]
fn a_mirror_may_not_be_pointed_at_an_address_only_the_server_can_reach() {
let down = Downstream::open_strict(HashKind::Sha256);
for url in [
"http://127.0.0.1:9418/ns/repo.git",
"http://169.254.169.254/latest/meta-data/",
"https://10.0.0.5/ns/repo.git",
"ssh://git@192.168.1.1/ns/repo.git",
"ssh://git@[::1]:2222/ns/repo.git",
"git@10.1.2.3:ns/repo.git",
] {
let err = down
.plane
.mirror_add(&down.path, url, false, false)
.expect_err("a destination the caller cannot reach on their own");
match err {
gunnar_grpc::seam::ControlError::InvalidArgument(why) => assert!(
why.contains("network position")
&& why.contains(gunnar_server::mirror_fetch::MIRROR_ALLOW_PRIVATE),
"the refusal must name the address and the way to allow it: {why}"
),
other => panic!("{url} must be INVALID_ARGUMENT, not {other:?}"),
}
assert!(
down.plane
.mirror_status(None, Some(&down.path))
.expect("status")
.is_empty(),
"{url} was refused and still written down"
);
}
down.plane
.mirror_add(
&down.path,
"https://93.184.216.34/ns/repo.git",
false,
false,
)
.expect("a routable destination registers");
}
fn protect_main(down: &Downstream, allowed: Vec<String>) {
down.plane
.set_protection(
&down.path,
gunnar_proto::BranchProtection {
ref_pattern: "refs/heads/main".to_owned(),
allow_force_push: false,
allow_delete: false,
allowed_principal_ids: allowed,
..Default::default()
},
)
.expect("the protection rule is stored");
}
#[test]
fn a_mirror_sync_does_not_force_push_over_a_protected_branch() {
let (_dir, stores, repo, upstream_head, _blob) = http_upstream_stores(HashKind::Sha256);
let upstream = HttpUpstream::start(stores, repo, None);
let down = Downstream::open(HashKind::Sha256);
let store = down.git();
let (local_head, _) = plant_a_commit_of(
store.serve(),
store.hash_kind(),
b"a history of our own\n".to_vec(),
);
assert_ne!(local_head, upstream_head, "the fixture must diverge");
protect_main(&down, Vec::new());
register(
&down,
&upstream.base(),
MirrorCredentialKind::Unspecified,
"",
"",
);
down.plane
.mirror_sync(&down.path)
.expect("the sync is accepted");
let (job, _) = down.settle();
assert_eq!(
job.state,
MirrorJobState::Failed as i32,
"a mirror that force-pushes over branch protection is the third independent way past \
the gate; the job must FAIL: {job:?}"
);
assert!(
job.error.contains("non-fast-forward") && job.error.contains("refs/heads/main"),
"the refusal must name what it refused and why: {}",
job.error
);
assert_eq!(
tip(down.git().serve(), "refs/heads/main"),
Some(local_head),
"nothing was moved: the protected reference still holds the commit it held"
);
}
#[test]
fn a_rule_that_names_its_principals_refuses_a_mirror_sync() {
let (_dir, stores, repo, _head, _blob) = http_upstream_stores(HashKind::Sha256);
let upstream = HttpUpstream::start(stores, repo, None);
let down = Downstream::open(HashKind::Sha256);
protect_main(&down, vec!["alice".to_owned()]);
register(
&down,
&upstream.base(),
MirrorCredentialKind::Unspecified,
"",
"",
);
down.plane
.mirror_sync(&down.path)
.expect("the sync is accepted");
let (job, _) = down.settle();
assert_eq!(
job.state,
MirrorJobState::Failed as i32,
"a rule this path cannot evaluate must refuse, not skip: {job:?}"
);
assert!(
job.error.contains("carries no principal"),
"the refusal must say WHY it could not decide, not invent an ancestry reason: {}",
job.error
);
assert_eq!(
tip(down.git().serve(), "refs/heads/main"),
None,
"nothing was moved"
);
}
#[test]
fn a_protected_repository_still_mirrors_a_fast_forward() {
let (_dir, stores, repo, head, blob) = http_upstream_stores(HashKind::Sha256);
let upstream = HttpUpstream::start(stores, repo, None);
let down = Downstream::open(HashKind::Sha256);
protect_main(&down, Vec::new());
register(
&down,
&upstream.base(),
MirrorCredentialKind::Unspecified,
"",
"",
);
down.plane
.mirror_sync(&down.path)
.expect("the sync is accepted");
let (done, progress) = down.settle();
assert_mirrored(&down, &done, &progress, head, blob);
}
#[test]
fn two_syncs_of_one_repository_are_one_job() {
let (_dir, stores, repo, head, blob) = http_upstream_stores(HashKind::Sha256);
let upstream = HttpUpstream::start(stores, repo, None);
let down = Downstream::open(HashKind::Sha256);
register(
&down,
&upstream.base(),
MirrorCredentialKind::Unspecified,
"",
"",
);
let first = down.plane.mirror_sync(&down.path).unwrap().job.unwrap();
let second = down.plane.mirror_sync(&down.path).unwrap().job.unwrap();
assert_eq!(
first.job_id, second.job_id,
"an impatient second click must join the job that is already there, not start another"
);
let (done, progress) = down.settle();
assert_eq!(done.job_id, first.job_id);
assert_mirrored(&down, &done, &progress, head, blob);
}
#[test]
fn a_sync_of_an_unregistered_repository_is_not_found() {
let down = Downstream::open(HashKind::Sha256);
let err = down
.plane
.mirror_sync(&down.path)
.expect_err("nothing was registered");
match err {
gunnar_grpc::seam::ControlError::NotFound(why) => assert!(
why.contains("no mirror registered for mirror/copy"),
"the refusal must name the repository: {why}"
),
other => panic!("an unregistered mirror must be NOT_FOUND, not {other:?}"),
}
}
#[test]
fn removing_a_mirror_deletes_its_sealed_credential() {
const TOKEN: &str = "ghp_ThisMustNotOutliveTheRegistration";
let down = Downstream::open(HashKind::Sha256);
register(
&down,
"https://93.184.216.34/ns/repo.git",
MirrorCredentialKind::Token,
TOKEN,
"",
);
let repo_ref = RepoRef::new("mirror", "copy").expect("a legal repo path");
let key = gunnar_core::credential_secret_key(&repo_ref);
let secrets = down.stores.secrets().expect("the sealed store");
assert_eq!(
gunnar_store::SecretStore::get(&secrets, &key).expect("reading the sealed store"),
Some(TOKEN.as_bytes().to_vec()),
"the fixture must actually have sealed something, or the assertion below is vacuous"
);
down.plane
.mirror_remove(&down.path)
.expect("remove the mirror");
assert_eq!(
gunnar_store::SecretStore::get(&secrets, &key).expect("reading the sealed store"),
None,
"the credential outlived the registration that was the only thing naming it"
);
assert!(
down.plane
.mirror_status(None, Some(&down.path))
.expect("status")
.is_empty(),
"and the registration is gone"
);
}
#[test]
fn a_credential_of_the_wrong_kind_is_refused_by_name() {
let (_dir, stores, repo, _head, _blob) = http_upstream_stores(HashKind::Sha256);
let upstream = HttpUpstream::start(stores, repo, None);
let down = Downstream::open(HashKind::Sha256);
let key = String::from_utf8(gunnar_core::mint_ed25519().expect("mint a key"))
.expect("an OpenSSH private key is utf-8 PEM");
register(
&down,
&upstream.base(),
MirrorCredentialKind::SshKey,
&key,
"",
);
down.plane.mirror_sync(&down.path).expect("accepted");
let (job, _) = down.settle();
assert_eq!(job.state, MirrorJobState::Failed as i32, "{job:?}");
assert!(
job.error.contains("smart http") && job.error.contains("ssh private key"),
"the refusal must name BOTH the transport and the credential it cannot carry: {}",
job.error
);
assert!(
!job.error.contains("PRIVATE KEY"),
"the refusal quoted the key: {}",
job.error
);
}
#[test]
fn a_credential_write_that_does_not_say_which_way_it_means_is_refused() {
let down = Downstream::open(HashKind::Sha256);
down.plane
.mirror_add(&down.path, "https://example.test/ns/repo.git", false, false)
.expect("register");
let err = down
.plane
.mirror_set_credential(&down.path, MirrorCredentialKind::Unspecified, "a-token", "")
.expect_err("a value with no kind");
assert!(
matches!(err, gunnar_grpc::seam::ControlError::InvalidArgument(_)),
"{err:?}"
);
let err = down
.plane
.mirror_set_credential(&down.path, MirrorCredentialKind::Token, "", "")
.expect_err("a kind with no value");
assert!(
matches!(err, gunnar_grpc::seam::ControlError::InvalidArgument(_)),
"{err:?}"
);
}
const CAP_WORDS: &str = "plan team would pass its pack quota with this push (cap 1 bytes): the \
transfer was stopped and nothing was stored";
struct Capped {
ceiling: u64,
full: bool,
}
impl gunnar_ssh::PlanGate for Capped {
fn name(&self) -> &'static str {
"capped-for-the-test"
}
fn verdict(&self, _: &str, _: gunnar_ssh::PlanAction) -> gunnar_ssh::PlanVerdict {
gunnar_ssh::PlanVerdict::Allow
}
fn pack_quota(&self, namespace: &str) -> gunnar_ssh::PlanVerdict {
if self.full && namespace == "mirror" {
gunnar_ssh::PlanVerdict::Refuse(CAP_WORDS.to_owned())
} else {
gunnar_ssh::PlanVerdict::Allow
}
}
fn pack_ceiling(&self, namespace: &str) -> Option<gunnar_ssh::PackCeiling> {
(namespace == "mirror").then(|| gunnar_ssh::PackCeiling {
bytes: self.ceiling,
refusal: CAP_WORDS.to_owned(),
})
}
}
fn capped_sync(ceiling: u64, full: bool) -> (Downstream, gunnar_proto::MirrorJob, Oid) {
let (_dir, stores, repo, head, _blob) =
http_upstream_stores_of(HashKind::Sha256, noise(512 * 1024));
let upstream = HttpUpstream::start(stores, repo, None);
let down = Downstream::open(HashKind::Sha256);
down.stores.install_plan_gates(vec![
Arc::new(Capped { ceiling, full }) as Arc<dyn gunnar_ssh::PlanGate>
]);
register(
&down,
&upstream.base(),
MirrorCredentialKind::Unspecified,
"",
"",
);
down.plane
.mirror_sync(&down.path)
.expect("the sync is accepted");
let (job, _) = down.settle();
(down, job, head)
}
#[test]
fn a_mirror_sync_past_the_namespaces_room_stops_at_the_cap_and_lands_nothing() {
let ceiling = 64 * 1024;
let (down, job, head) = capped_sync(ceiling, false);
assert_eq!(job.state, MirrorJobState::Failed as i32, "{job:?}");
assert!(
job.error.contains(CAP_WORDS),
"the job says {:?}",
job.error
);
assert_eq!(
job.refused_reason,
gunnar_proto::MirrorRefusal::PackCap as i32,
"the cap stop is a FIELD, not only gunnar's prose: {job:?}"
);
assert!(
job.bytes <= ceiling + 64 * 1024,
"the fetch took {} bytes off the wire past a {ceiling}-byte ceiling",
job.bytes
);
let store = down.git();
assert_eq!(
tip(store.serve(), "refs/heads/main"),
None,
"a cut sync moved a ref"
);
assert!(
GitOps::extents(store.serve(), &[head.as_bytes()])
.expect("asking the store")
.first()
.is_none_or(Option::is_none),
"a cut sync stored the commit"
);
}
#[test]
fn a_mirror_sync_into_a_full_namespace_fetches_nothing() {
let (down, job, _) = capped_sync(u64::MAX, true);
assert_eq!(job.state, MirrorJobState::Failed as i32, "{job:?}");
assert!(job.error.contains(CAP_WORDS), "{:?}", job.error);
assert_eq!(
job.refused_reason,
gunnar_proto::MirrorRefusal::PackCap as i32,
"{job:?}"
);
assert_eq!(job.bytes, 0, "a full namespace took bytes off the wire");
assert_eq!(tip(down.git().serve(), "refs/heads/main"), None);
}
#[test]
fn a_mirror_sync_under_the_ceiling_lands_whole() {
let (down, job, head) = capped_sync(64 * 1024 * 1024, false);
assert_eq!(job.state, MirrorJobState::Done as i32, "{}", job.error);
assert_eq!(
job.refused_reason,
gunnar_proto::MirrorRefusal::Unspecified as i32
);
assert_eq!(tip(down.git().serve(), "refs/heads/main"), Some(head));
}
struct Suspended;
const SUSPENDED_WORDS: &str = "plan team is suspended: writes are refused until it is paid";
impl gunnar_ssh::PlanGate for Suspended {
fn name(&self) -> &'static str {
"suspended-for-the-test"
}
fn verdict(&self, namespace: &str, _: gunnar_ssh::PlanAction) -> gunnar_ssh::PlanVerdict {
if namespace == "mirror" {
gunnar_ssh::PlanVerdict::Refuse(SUSPENDED_WORDS.to_owned())
} else {
gunnar_ssh::PlanVerdict::Allow
}
}
fn pack_quota(&self, _: &str) -> gunnar_ssh::PlanVerdict {
gunnar_ssh::PlanVerdict::Allow
}
fn pack_ceiling(&self, _: &str) -> Option<gunnar_ssh::PackCeiling> {
None
}
}
#[test]
fn a_mirror_sync_into_a_suspended_namespace_is_refused_by_its_plan() {
let (_dir, stores, repo, _head, _blob) = http_upstream_stores(HashKind::Sha256);
let upstream = HttpUpstream::start(stores, repo, None);
let down = Downstream::open(HashKind::Sha256);
down.stores
.install_plan_gates(vec![Arc::new(Suspended) as Arc<dyn gunnar_ssh::PlanGate>]);
register(
&down,
&upstream.base(),
MirrorCredentialKind::Unspecified,
"",
"",
);
down.plane
.mirror_sync(&down.path)
.expect("the sync is accepted");
let (job, _) = down.settle();
assert_eq!(job.state, MirrorJobState::Failed as i32, "{job:?}");
assert!(job.error.contains(SUSPENDED_WORDS), "{:?}", job.error);
assert_eq!(
job.refused_reason,
gunnar_proto::MirrorRefusal::Plan as i32,
"{job:?}"
);
assert_eq!(job.bytes, 0);
}
fn add_as_owner(
down: &Downstream,
url: &str,
create_repo: bool,
adopt: bool,
) -> Result<gunnar_proto::AddMirrorResponse, tonic::Status> {
use gunnar_proto::mirror_server::Mirror as _;
let _ = down.stores.auth().add_principal(gunnar_core::Principal {
id: "mirror".to_owned(),
name: "mirror".to_owned(),
kind: gunnar_core::PrincipalKind::User,
created: 0,
});
let plane: Arc<dyn gunnar_grpc::ControlPlane> =
Arc::new(CoreControlPlane::new(Arc::clone(&down.stores)));
let service = gunnar_grpc::MirrorService::new(gunnar_grpc::ServiceContext::new(
plane,
Arc::new(gunnar_grpc::EventBus::new(16)),
gunnar_grpc::ServerConfig::default(),
));
let mut request = tonic::Request::new(gunnar_proto::AddMirrorRequest {
repo: Some(down.path.clone()),
remote_url: url.to_owned(),
prune: true,
create_repo,
adopt,
});
request.extensions_mut().insert(gunnar_grpc::Caller {
principal_id: "mirror".to_owned(),
fingerprint: "SHA256:owner".to_owned(),
machine: false,
authenticated: true,
});
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime")
.block_on(service.add(request))
.map(tonic::Response::into_inner)
}
#[test]
fn a_mirror_that_would_create_its_repository_does_not_take_over_one_that_exists() {
let down = Downstream::open(HashKind::Sha256);
let url = "https://127.0.0.1:9/upstream.git";
let refused = add_as_owner(&down, url, true, false)
.expect_err("an existing, non-mirror repository is not taken over");
assert_eq!(
refused.code(),
tonic::Code::FailedPrecondition,
"{refused:?}"
);
assert!(
refused.message().contains("mirror/copy"),
"names it: {refused:?}"
);
assert!(
down.plane
.mirror_status(None, Some(&down.path))
.expect("mirror_status")
.is_empty(),
"a refused add registered the mirror anyway"
);
add_as_owner(&down, url, true, true).expect("adopt takes it over on purpose");
add_as_owner(&down, url, true, false).expect("re-adding an existing mirror is unchanged");
assert_eq!(
down.plane
.mirror_status(None, Some(&down.path))
.expect("mirror_status")
.len(),
1
);
}
type Imported = (
Downstream,
Oid,
Oid,
gunnar_proto::MirrorJob,
Vec<(bool, u64, u64)>,
);
fn import_creating(hash_of_upstream: HashKind) -> Imported {
let (_dir, stores, repo, head, blob) = http_upstream_stores(hash_of_upstream);
let upstream = Box::leak(Box::new(HttpUpstream::start(stores, repo, None)));
let dir = tempfile::tempdir().expect("a temp dir");
let stores = Arc::new(ServerStores::open(dir.path().join("data")).expect("stores"));
stores
.catalog()
.create_namespace("mirror")
.expect("namespace");
std::fs::write(
stores
.root()
.join(gunnar_server::mirror_fetch::MIRROR_ALLOW_PRIVATE),
b"the upstreams in this test are on 127.0.0.1\n",
)
.expect("allowing private upstreams on this box");
let plane = CoreControlPlane::new(Arc::clone(&stores));
let path = RepoPath::new("mirror", "copy");
let repo_ref = RepoRef::new("mirror", "copy").expect("a legal path");
plane
.mirror_add(&path, &upstream.base(), false, true)
.expect("register the import");
assert!(
stores
.catalog()
.get(&repo_ref)
.expect("the catalog answers")
.is_none(),
"Mirror.Add made the repository before the upstream said its format"
);
plane.mirror_sync(&path).expect("the sync is accepted");
let deadline = Instant::now() + Duration::from_secs(120);
let mut seen: Vec<(bool, u64, u64)> = Vec::new();
let job = loop {
assert!(Instant::now() < deadline, "the import never settled");
let job = plane
.mirror_status(None, Some(&path))
.expect("mirror_status")
.into_iter()
.next()
.and_then(|m| m.job)
.expect("the job");
let reading = (job.has_progress, job.progress_done, job.progress_total);
if seen.last() != Some(&reading) {
seen.push(reading);
}
if job.state != MirrorJobState::Queued as i32 && job.state != MirrorJobState::Running as i32
{
break job;
}
std::thread::sleep(Duration::from_millis(5));
};
let repo = stores
.catalog()
.get(&repo_ref)
.expect("the catalog answers")
.unwrap_or_else(|| panic!("the first sync made no repository: {}", job.error));
let down = Downstream {
_dir: dir,
stores,
plane,
path,
repo,
};
(down, head, blob, job, seen)
}
#[test]
fn a_mirror_that_creates_its_repository_takes_a_sha1_upstreams_format() {
let (down, head, blob, done, progress) = import_creating(HashKind::Sha1);
assert_eq!(
down.repo.hash_kind,
HashKind::Sha1,
"the repository was created at the wrong format"
);
assert_mirrored(&down, &done, &progress, head, blob);
}
#[test]
fn a_mirror_that_creates_its_repository_takes_a_sha256_upstreams_format() {
let (down, head, blob, done, progress) = import_creating(HashKind::Sha256);
assert_eq!(down.repo.hash_kind, HashKind::Sha256);
assert_mirrored(&down, &done, &progress, head, blob);
}
fn pack_frames(stores: &ServerStores) -> usize {
let mut n = 0;
stores
.txlog()
.scan_meta(|_, kind, _| {
if gunnar_core::txlog::Kind::from_u16(kind) == Some(gunnar_core::txlog::Kind::Pack) {
n += 1;
}
})
.expect("scan the transaction log");
n
}
#[test]
fn a_mirror_never_lands_plaintext_in_an_encrypted_repository() {
let (_dir, stores, repo, _head, blob) = http_upstream_stores(HashKind::Sha256);
let upstream = HttpUpstream::start(stores, repo, None);
let down = Downstream::open(HashKind::Sha256);
register(
&down,
&upstream.base(),
MirrorCredentialKind::Unspecified,
"",
"",
);
down.plane
.set_encrypted(&down.path, true)
.expect("the flag, set below the door");
let frames_before = pack_frames(&down.stores);
match down.plane.mirror_sync(&down.path) {
Err(err) => assert!(format!("{err}").contains("encrypted"), "{err}"),
Ok(_) => {
let (job, _) = down.settle();
assert_eq!(
job.state,
MirrorJobState::Failed as i32,
"the sync must FAIL: {job:?}"
);
assert!(
job.error.contains("encrypted"),
"the job says why: {}",
job.error
);
}
}
assert_eq!(
tip(down.git().serve(), "refs/heads/main"),
None,
"no reference moved"
);
assert!(
!GitOps::extents(down.git().serve(), &[blob.as_bytes()])
.expect("asking the store")
.first()
.is_some_and(Option::is_some),
"the upstream's plaintext blob is in the encrypted repository's store"
);
assert_eq!(
pack_frames(&down.stores),
frames_before,
"a pack was framed for the twin"
);
}