use std::io::{BufRead, Write};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use gix_hash::ObjectId;
use gunnar_core::MirrorCredentialKind;
use gunnar_store::git_storage_trait::{GitOps, RefCas};
use gunnar_wire::fetch_pack::{self, FetchPack, FetchProgress, FetchReport, LocalRefs};
use crate::job_queue::Reporter;
use crate::mirror_queue::{Mirror, MirrorOrder};
use crate::stores::ServerStores;
const TARGET_GIT: &str = "gunnar::git";
pub const MIRROR_KNOWN_HOSTS: &str = "mirror_known_hosts";
pub const MIRROR_STRICT_HOST_KEY: &str = "mirror_strict_host_key";
pub fn strict_host_keys(data_root: &std::path::Path) -> bool {
data_root.join(MIRROR_STRICT_HOST_KEY).exists()
}
pub const MIRROR_ALLOW_PRIVATE: &str = "mirror_allow_private_destinations";
pub enum Credential {
None,
SshKey(Vec<u8>),
Token { username: String, secret: String },
}
impl Credential {
fn kind(&self) -> MirrorCredentialKind {
match self {
Credential::None => MirrorCredentialKind::None,
Credential::SshKey(_) => MirrorCredentialKind::SshKey,
Credential::Token { .. } => MirrorCredentialKind::Token,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Wire {
Ssh,
Http,
}
impl Wire {
fn name(self) -> &'static str {
match self {
Wire::Ssh => "ssh",
Wire::Http => "smart http",
}
}
}
pub fn classify(url: &str) -> Result<Wire, String> {
let url = url.trim();
if url.starts_with("ssh://") || url.starts_with("git+ssh://") {
return Ok(Wire::Ssh);
}
if url.starts_with("https://") || url.starts_with("http://") {
return Ok(Wire::Http);
}
if url.contains("://") {
return Err(format!(
"{} names a scheme gunnar cannot mirror from. The two transports are `ssh://` \
and `https://`",
scrub(url)
));
}
match (url.find(':'), url.find('/')) {
(Some(colon), slash) if colon > 0 && slash.is_none_or(|slash| colon < slash) => {
Ok(Wire::Ssh)
}
_ => Err(format!(
"{url:?} is not a remote gunnar can mirror from. Write it as \
`ssh://git@host/ns/repo.git`, `git@host:ns/repo.git` or `https://host/ns/repo.git`"
)),
}
}
pub(crate) fn authority(url: &str) -> Result<(String, u16), String> {
let url = url.trim();
let (default_port, rest) = if let Some(rest) = url.strip_prefix("ssh://") {
(22, rest)
} else if let Some(rest) = url.strip_prefix("git+ssh://") {
(22, rest)
} else if let Some(rest) = url.strip_prefix("https://") {
(443, rest)
} else if let Some(rest) = url.strip_prefix("http://") {
(80, rest)
} else {
let rest = url.rsplit('@').next().unwrap_or_default();
let host = match rest.strip_prefix('[') {
Some(inside) => inside.split(']').next().unwrap_or_default(),
None => rest.split(':').next().unwrap_or_default(),
};
return non_empty(host, url).map(|host| (host, 22));
};
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
let authority = authority.rsplit('@').next().unwrap_or_default();
if let Some(rest) = authority.strip_prefix('[') {
let Some((host, tail)) = rest.split_once(']') else {
return Err(format!("{} names an unterminated IPv6 literal", scrub(url)));
};
let port = match tail.strip_prefix(':') {
None => default_port,
Some(digits) => digits
.parse()
.map_err(|_| format!("{} names a port that is not a number", scrub(url)))?,
};
return non_empty(host, url).map(|host| (host, port));
}
match authority.split_once(':') {
None => non_empty(authority, url).map(|host| (host, default_port)),
Some((host, digits)) => {
let port = digits
.parse()
.map_err(|_| format!("{} names a port that is not a number", scrub(url)))?;
non_empty(host, url).map(|host| (host, port))
}
}
}
fn non_empty(host: &str, url: &str) -> Result<String, String> {
if host.is_empty() {
return Err(format!("{} names no host to dial", scrub(url)));
}
Ok(host.to_owned())
}
pub(crate) fn address_verdict(host: &str, addrs: &[std::net::IpAddr]) -> Result<(), String> {
if addrs.is_empty() {
return Err(format!("{host} resolves to no address at all"));
}
for addr in addrs {
if let Some(why) = not_routable(*addr) {
return Err(format!(
"{host} resolves to {addr}, which is {why}. A mirror dials from the server, so a \
destination the caller could not reach on their own is the server's network \
position being borrowed. Create `{MIRROR_ALLOW_PRIVATE}` under this box's data \
root to allow it deliberately."
));
}
}
Ok(())
}
fn not_routable(addr: std::net::IpAddr) -> Option<&'static str> {
use std::net::IpAddr;
match addr {
IpAddr::V4(v4) => {
let [a, b, c, _] = v4.octets();
match () {
_ if v4.is_unspecified() => Some("the unspecified address"),
_ if v4.is_loopback() => Some("loopback — this server itself"),
_ if v4.is_private() => Some("a private network (RFC 1918)"),
_ if v4.is_link_local() => {
Some("link-local (RFC 3927), the range cloud metadata services answer on")
}
_ if v4.is_broadcast() => Some("the broadcast address"),
_ if v4.is_multicast() => Some("multicast"),
_ if v4.is_documentation() => Some("a documentation range (RFC 5737)"),
_ if a == 100 && (64..128).contains(&b) => Some("carrier-grade NAT (RFC 6598)"),
_ if a == 192 && b == 0 && c == 0 => Some("an IETF protocol assignment"),
_ if a == 198 && (b == 18 || b == 19) => Some("a benchmarking range (RFC 2544)"),
_ if a >= 240 => Some("a reserved range"),
_ => None,
}
}
IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some() => {
not_routable(IpAddr::V4(v6.to_ipv4_mapped().expect("just matched")))
}
IpAddr::V6(v6) => {
let segments = v6.segments();
match () {
_ if v6.is_unspecified() => Some("the unspecified address"),
_ if v6.is_loopback() => Some("loopback — this server itself"),
_ if v6.is_multicast() => Some("multicast"),
_ if segments[0] & 0xfe00 == 0xfc00 => Some("a unique-local address (RFC 4193)"),
_ if segments[0] & 0xffc0 == 0xfe80 => Some("link-local (RFC 4291)"),
_ if segments[0] == 0x2001 && segments[1] == 0x0db8 => {
Some("a documentation range (RFC 3849)")
}
_ => None,
}
}
}
}
pub fn check_destination(url: &str, data_root: &std::path::Path) -> Result<(), String> {
use std::net::ToSocketAddrs as _;
if data_root.join(MIRROR_ALLOW_PRIVATE).exists() {
return Ok(());
}
let (host, port) = authority(url)?;
if let Ok(addr) = host.parse::<std::net::IpAddr>() {
return address_verdict(&host, &[addr]);
}
let addrs: Vec<std::net::IpAddr> = (host.as_str(), port)
.to_socket_addrs()
.map_err(|err| format!("{host} could not be resolved: {err}"))?
.map(|socket| socket.ip())
.collect();
address_verdict(&host, &addrs)
}
pub fn check_credential(wire: Wire, credential: &Credential) -> Result<(), String> {
match (wire, credential) {
(_, Credential::None) => Ok(()),
(Wire::Ssh, Credential::SshKey(_)) => Ok(()),
(Wire::Http, Credential::Token { .. }) => Ok(()),
(wire, held) => Err(format!(
"this mirror's remote is dialled over {}, and the credential stored for it is {}. \
{} cannot carry it, so the fetch would go out unauthenticated and be refused as \
though no credential were set. Store {} instead, with `Mirror.SetCredential`.",
wire.name(),
held.kind().name(),
wire.name(),
match wire {
Wire::Ssh => "an ssh private key",
Wire::Http => "a token",
}
)),
}
}
struct Protection {
gate: gunnar_policy::BranchProtectionGate,
keys: gunnar_policy::StaticKeyDirectory,
repo: gunnar_policy::RepoId,
}
impl Protection {
fn open(
stores: &ServerStores,
row: &gunnar_core::Repo,
git_dir: &std::path::Path,
) -> std::result::Result<Option<Self>, String> {
let config = crate::transport::load_policy(
stores,
row,
&git_dir.join(crate::transport::POLICY_FILE),
)
.map_err(|why| format!("refusing to mirror into {}: {why}", row.key()))?;
if config.protection.is_empty() {
return Ok(None);
}
Ok(Some(Protection {
gate: gunnar_policy::BranchProtectionGate::new(config.protection.clone()),
keys: crate::transport::key_directory(stores),
repo: gunnar_policy::RepoId::new(&row.namespace, &row.name),
}))
}
fn check(
&self,
git: &dyn gunnar_store::git_storage_trait::GitServe,
updates: &[gunnar_policy::RefUpdate],
new_objects: std::result::Result<&[ObjectId], &str>,
) -> Result<(), String> {
use gunnar_policy::Gate as _;
for update in updates {
for rule in self.gate.rules() {
if !rule.pattern.matches_ref(&update.name) {
continue;
}
if rule.allowed_principals.is_some() {
return Err(format!(
"{} is protected by {}, which names the principals that may move it. A \
mirror sync carries no principal — the control-plane verb takes a \
repository and not a caller — so that rule cannot be satisfied and this \
sync is refused rather than applied unchecked. Nothing was moved.",
update.name, rule.pattern
));
}
break;
}
}
let new_objects = new_objects.map_err(|reason| {
format!(
"this repository has branch protection and the fetched pack's object set could \
not be enumerated ({reason}), so the gate could not decide. Nothing was moved."
)
})?;
let principal = gunnar_policy::Principal::machine("mirror");
let ctx = gunnar_policy::PushContext::new(
&principal,
&self.repo,
updates,
new_objects,
git,
&self.keys,
);
match self.gate.check(&ctx) {
Ok(gunnar_policy::Verdict::Allow) => Ok(()),
Ok(gunnar_policy::Verdict::Reject(rejection)) => Err(format!(
"branch protection refused this mirror's reference batch: {}. Nothing was moved.",
rejection.reason
)),
Err(err) => Err(format!(
"branch protection could not decide this mirror's reference batch: {err}. \
Nothing was moved."
)),
}
}
}
struct StoreRefs<'a> {
git: &'a dyn gunnar_store::git_storage_trait::GitServe,
extra_haves: Vec<ObjectId>,
}
impl LocalRefs for StoreRefs<'_> {
fn tips(&self) -> gunnar_wire::Result<Vec<(String, ObjectId)>> {
let rows = GitOps::refs(self.git).map_err(|err| {
gunnar_wire::Error::Store(gunnar_store::Error::backend(format!(
"reading this repository's refs to offer them as haves: {err:#}"
)))
})?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
if row.name == "HEAD" {
continue;
}
let Some(raw) = row.oid.as_deref() else {
continue;
};
let Ok(oid) = gix_hash::oid::try_from_bytes(raw) else {
return Err(gunnar_wire::Error::Store(gunnar_store::Error::backend(
format!("{} holds an oid this store cannot decode", row.name),
)));
};
out.push((row.name, oid.to_owned()));
}
for (n, oid) in self.extra_haves.iter().enumerate() {
out.push((format!("refs/gunnar/resume/{n}"), *oid));
}
Ok(out)
}
}
struct QueueProgress<'a> {
report: &'a Reporter<Mirror>,
total: AtomicU64,
watching: Option<&'a dyn git_storage_trait::PackProgress>,
}
impl QueueProgress<'_> {
fn publish(&self, done: u64, total: u64) {
self.report.progress(done, total);
if let Some(watcher) = self.watching {
watcher.indexed(done, total);
}
}
}
impl FetchProgress for QueueProgress<'_> {
fn objects_total(&self, objects: u32) {
self.total.store(u64::from(objects), Ordering::SeqCst);
self.publish(0, u64::from(objects));
}
fn pack_bytes(&self, streamed: u64) {
self.report.report(|mirror| mirror.bytes = streamed);
}
}
impl git_storage_trait::PackProgress for QueueProgress<'_> {
fn indexed(&self, done: u64, total: u64) {
let announced = self.total.load(Ordering::SeqCst);
let total = if announced == 0 || announced == total {
total
} else {
tracing::warn!(
target: TARGET_GIT,
announced,
indexed_total = total,
"the packfile header the wire read and the one the store read declare \
different object counts; the wire's is the one being rendered"
);
announced
};
self.publish(done, total);
}
}
pub fn sync_once(
stores: &Arc<ServerStores>,
order: &MirrorOrder,
credential: &Credential,
report: &Reporter<Mirror>,
) -> Result<(), String> {
sync_once_watching(stores, order, credential, report, None)
}
pub fn sync_once_watching(
stores: &Arc<ServerStores>,
order: &MirrorOrder,
credential: &Credential,
report: &Reporter<Mirror>,
watching: Option<&dyn git_storage_trait::PackProgress>,
) -> Result<(), String> {
let wire = classify(&order.upstream)?;
check_credential(wire, credential)?;
check_destination(&order.upstream, stores.root())?;
let repo_ref = gunnar_core::resolve_repo_path(&order.path).map_err(|err| {
format!(
"`{}` is not a valid <namespace>/<name>: {err:#}",
order.path
)
})?;
let existing = stores
.catalog()
.get(&repo_ref)
.map_err(|err| format!("reading the catalog row for {}: {err:#}", order.path))?;
let to_create = existing.is_none()
&& stores
.mirrors()
.get(&repo_ref)
.map_err(|err| format!("reading the registration for {}: {err:#}", order.path))?
.is_some_and(|record| record.creates_repo);
if existing.is_none() && !to_create {
return Err(format!("{} is no longer a live repository", order.path));
}
if existing.as_ref().is_some_and(|row| row.encrypted) {
return Err(format!(
"{} is encrypted, so it is not fetched into: a mirror copies another server's \
plaintext, and an encrypted repository holds only what its members sealed \
(remove the mirror)",
order.path
));
}
let ceiling = room_for(&stores.plan_gates(), repo_ref.namespace()).map_err(|(why, line)| {
report.report(|r| r.refused = why);
line
})?;
let auth = match credential {
Credential::Token { username, secret } if username.is_empty() => {
Some(format!("Bearer {secret}"))
}
Credential::Token { username, secret } => {
Some(format!("Basic {}", base64_basic(username, secret)))
}
_ => None,
};
let row = match existing {
Some(row) => row,
None => create_from_upstream(stores, &repo_ref, order, wire, credential, auth.clone())?,
};
let fetched = fetch_into(
stores, &row, order, wire, credential, auth, ceiling, report, watching,
)
.and_then(|advertised| landed(stores, &row, order, &advertised).map(|()| advertised));
match &fetched {
Ok(advertised) => after_landing(stores, &repo_ref, &row, order, advertised),
Err(_) if to_create => discard_created(stores, &repo_ref, order),
Err(_) => {}
}
fetched.map(|_| ())
}
fn landed(
stores: &Arc<ServerStores>,
row: &gunnar_core::model::Repo,
order: &MirrorOrder,
advertised: &[gunnar_wire::upload_pack::AdvertisedRef],
) -> Result<(), String> {
let wanted: Vec<&str> = advertised
.iter()
.filter(|r| fetch_pack::mirror(&r.name) && r.target.is_some())
.map(|r| r.name.as_str())
.collect();
let upstream = scrub(&order.upstream);
if wanted.is_empty() {
return Err(format!(
"{upstream} has no branches or tags, so there was nothing to import into {}. \
Check the address, or push a first commit there and sync again.",
order.path
));
}
let store = stores
.repo_store(row)
.map_err(|err| format!("opening {} to check what landed: {err:#}", order.path))?;
let held = GitOps::refs(store.serve()).map_err(|err| {
format!(
"reading {}'s references to check what landed: {err:#}",
order.path
)
})?;
let missing: Vec<&str> = wanted
.iter()
.copied()
.filter(|name| !held.iter().any(|r| r.name == *name && r.oid.is_some()))
.collect();
if let Some(first) = missing.first() {
return Err(format!(
"the fetch from {upstream} ended without its references landing: {} of {} \
(first {first}) are not in {}. Nothing is reported as imported that is not \
here; sync again.",
missing.len(),
wanted.len(),
order.path
));
}
Ok(())
}
fn create_from_upstream(
stores: &Arc<ServerStores>,
repo_ref: &gunnar_core::RepoRef,
order: &MirrorOrder,
wire: Wire,
credential: &Credential,
auth: Option<String>,
) -> Result<gunnar_core::model::Repo, String> {
let advertisement = ls_remote(stores, order, wire, credential, auth)?;
let hash = match advertisement.hash_kind {
gix_hash::Kind::Sha1 => gunnar_core::HashKind::Sha1,
_ => gunnar_core::HashKind::Sha256,
};
let mut new = gunnar_core::NewRepo::new(repo_ref.clone())
.hash_kind(hash)
.visibility(gunnar_core::Visibility::Private);
new.description = format!("mirror of {}", scrub(&order.upstream));
if let Some(branch) = advertised_head(&advertisement.refs) {
new = new.default_branch(branch);
}
tracing::info!(
target: TARGET_GIT,
event = "mirror.create_from_upstream",
repo = %order.path,
hash = ?hash,
"an import's repository is made at the upstream's object format and default branch"
);
let row = stores
.create_repo(new, false)
.map_err(|err| format!("creating {} for the import: {err:#}", order.path))?;
stores
.repo_store(&row)
.map_err(|err| format!("opening {} to mirror into it: {err:#}", order.path))?;
Ok(row)
}
fn ls_remote(
stores: &Arc<ServerStores>,
order: &MirrorOrder,
wire: Wire,
credential: &Credential,
auth: Option<String>,
) -> Result<gunnar_wire::fetch_pack::Advertisement, String> {
let listed =
|err: gunnar_wire::Error| format!("asking {} what it holds: {err}", scrub(&order.upstream));
match wire {
Wire::Http => {
let (mut reader, mut writer) = open_http(&order.upstream, auth)?;
gunnar_wire::ls_remote_any(&mut reader, &mut writer).map_err(listed)
}
Wire::Ssh => dial_ssh(
&order.upstream,
credential,
&stores.root().join(MIRROR_KNOWN_HOSTS),
strict_host_keys(stores.root()),
|reader, writer| gunnar_wire::ls_remote_any(reader, writer),
)?
.map_err(listed),
}
}
#[allow(clippy::too_many_arguments)]
fn already_here(
stores: &Arc<ServerStores>,
git: &dyn gunnar_store::git_storage_trait::GitServe,
git_dir: &std::path::Path,
order: &MirrorOrder,
wire: Wire,
credential: &Credential,
auth: Option<String>,
report: &Reporter<Mirror>,
) -> Result<Vec<ObjectId>, String> {
let held =
GitOps::refs(git).map_err(|err| format!("reading {}'s references: {err:#}", order.path))?;
if held
.iter()
.any(|r| fetch_pack::mirror(&r.name) && r.oid.is_some())
{
return Ok(Vec::new());
}
let advertisement = ls_remote(stores, order, wire, credential, auth)?;
let held = crate::import_room::MemoryLimit::read()
.and_then(|l| CgroupHold::start(stores, git_dir, &l));
index_landed(git, git_dir, stores.engine(), report, held.as_ref()).map_err(|err| {
format!(
"{} holds objects this server could not index: {err}",
order.path
)
})?;
let mut out = Vec::new();
for advertised in &advertisement.refs {
let Some(target) = advertised.target else {
continue;
};
if !fetch_pack::mirror(&advertised.name) || out.contains(&target) {
continue;
}
if GitOps::has(git, target.as_bytes()).unwrap_or(false) {
out.push(target);
}
}
if !out.is_empty() {
tracing::info!(
target: TARGET_GIT,
event = "mirror.sync.resumed",
repo = %order.path,
haves = out.len(),
"an import that stopped after its pack landed resumes without fetching it again"
);
}
Ok(out)
}
fn advertised_head(refs: &[gunnar_wire::upload_pack::AdvertisedRef]) -> Option<String> {
refs.iter()
.find(|r| r.name == "HEAD")
.and_then(|r| r.symref_target.clone())
.filter(|target| {
target.starts_with("refs/heads/") && gunnar_store::refname::validate(target).is_ok()
})
}
fn discard_created(
stores: &Arc<ServerStores>,
repo_ref: &gunnar_core::RepoRef,
order: &MirrorOrder,
) {
let removed = stores
.catalog()
.delete(repo_ref)
.map_err(|err| format!("{err:#}"))
.and_then(|_| {
stores
.purge_repo(repo_ref)
.map_err(|err| format!("{err:#}"))
});
if let Err(err) = removed {
tracing::warn!(
target: TARGET_GIT,
repo = %order.path,
%err,
"a failed first import could not remove the repository it had made"
);
}
}
fn after_landing(
stores: &Arc<ServerStores>,
repo_ref: &gunnar_core::RepoRef,
row: &gunnar_core::model::Repo,
order: &MirrorOrder,
advertised: &[gunnar_wire::upload_pack::AdvertisedRef],
) {
let mut row = row.clone();
if let Some(branch) = advertised_head(advertised) {
let held = stores
.repo_store(&row)
.ok()
.and_then(|store| GitOps::refs(store.serve()).ok())
.is_some_and(|refs| refs.iter().any(|r| r.name == branch && r.oid.is_some()));
if held && branch != row.default_branch {
match stores.catalog().set_default_branch(repo_ref, &branch) {
Ok(moved) => {
row = moved;
if let Err(err) = stores.repoint_head(&row) {
tracing::warn!(target: TARGET_GIT, repo = %order.path, %err, "pointing HEAD at the upstream's default branch");
}
}
Err(err) => {
tracing::warn!(target: TARGET_GIT, repo = %order.path, %err, "taking the upstream's default branch");
}
}
}
}
let lfs = uses_lfs(stores, &row);
if let Err(err) = stores.mirrors().set_lfs_detected(repo_ref, lfs) {
tracing::warn!(target: TARGET_GIT, repo = %order.path, %err, "recording whether the upstream uses LFS");
}
}
fn uses_lfs(stores: &Arc<ServerStores>, row: &gunnar_core::model::Repo) -> bool {
use gunnar_store::git_storage_trait::ObjType;
use gunnar_store::object;
let Ok(store) = stores.repo_store(row) else {
return false;
};
let git = store.serve();
let hash = store.hash_kind();
let Some(tip) = GitOps::refs(git)
.ok()
.and_then(|refs| refs.into_iter().find(|r| r.name == row.default_branch))
.and_then(|r| r.oid)
.and_then(|raw| ObjectId::try_from(&raw[..]).ok())
else {
return false;
};
let read = |id: &ObjectId, want: ObjType| match git.read(id.as_bytes()) {
Ok(Some((kind, bytes))) if kind == want => Some(bytes),
_ => None,
};
let Some(commit) =
read(&tip, ObjType::Commit).and_then(|bytes| object::commit(&bytes, hash).ok())
else {
return false;
};
let Some(tree) = read(&commit.tree, ObjType::Tree) else {
return false;
};
let Ok(Some(entry)) = object::tree_entry_named(&tree, hash, b".gitattributes") else {
return false;
};
read(&entry.oid, ObjType::Blob).is_some_and(|attributes| {
String::from_utf8_lossy(&attributes)
.lines()
.any(|line| !line.trim_start().starts_with('#') && line.contains("filter=lfs"))
})
}
#[allow(clippy::too_many_arguments)]
fn fetch_into(
stores: &Arc<ServerStores>,
row: &gunnar_core::model::Repo,
order: &MirrorOrder,
wire: Wire,
credential: &Credential,
auth: Option<String>,
ceiling: Option<gunnar_wire::PackCeiling>,
report: &Reporter<Mirror>,
watching: Option<&dyn git_storage_trait::PackProgress>,
) -> Result<Vec<gunnar_wire::upload_pack::AdvertisedRef>, String> {
let row = row.clone();
let store = stores
.repo_store(&row)
.map_err(|err| format!("opening {} to mirror into it: {err:#}", order.path))?;
let git = store.serve();
let hash_kind = store.hash_kind();
let protection = Protection::open(stores, &row, store.git_dir())?;
let extra_haves = already_here(
stores,
git,
store.git_dir(),
order,
wire,
credential,
auth.clone(),
report,
)?;
let local = StoreRefs { git, extra_haves };
let progress = QueueProgress {
report,
total: AtomicU64::new(0),
watching,
};
let ingest = crate::transport::PushIngest::new(
store.git_dir().to_path_buf(),
hash_kind,
stores.engine(),
if protection.is_some() {
crate::storage::ObjectSet::Required
} else {
crate::storage::ObjectSet::NotNeeded
},
Some(&progress),
Some(crate::transport::PackFrame::of(stores, &row)),
)
.with_space(stores.space_ledger());
let mut fetch = FetchPack::new(hash_kind, &fetch_pack::mirror, &local).with_progress(&progress);
if let Some(ceiling) = ceiling {
fetch = fetch.with_ceiling(ceiling);
}
let outcome = match wire {
Wire::Http => {
let (mut reader, mut writer) = open_http(&order.upstream, auth)?;
fetch.fetch(&ingest, &mut reader, &mut writer)
}
Wire::Ssh => dial_ssh(
&order.upstream,
credential,
&stores.root().join(MIRROR_KNOWN_HOSTS),
strict_host_keys(stores.root()),
|reader, writer| fetch.fetch(&ingest, reader, writer),
)?,
}
.map_err(|err| {
if matches!(err, gunnar_wire::Error::OverCeiling(_)) {
report.report(|r| r.refused = crate::mirror_queue::Refusal::PackCap);
}
format!(
"fetching {} from {}: {err}",
order.path,
scrub(&order.upstream)
)
})?;
if outcome.receipt.is_some() {
let objects = progress.total.load(Ordering::SeqCst);
let limit = crate::import_room::MemoryLimit::read();
if let Some(limit) = &limit {
crate::import_room::import_room(&order.path, objects, limit)?;
}
let held = limit
.as_ref()
.and_then(|l| CgroupHold::start(stores, store.git_dir(), l));
index_landed(git, store.git_dir(), stores.engine(), report, held.as_ref()).map_err(
|err| {
format!(
"{} was fetched and stored, and this server could not index it: {err}. Nothing \
was moved. The repository is larger than this server can take in; a server \
with more memory imports it.",
order.path
)
},
)?;
}
apply(git, order, &outcome, &progress, protection.as_ref())?;
Ok(outcome.advertised)
}
fn index_landed(
git: &dyn gunnar_store::git_storage_trait::GitServe,
git_dir: &std::path::Path,
engine: crate::storage::Engine,
report: &Reporter<Mirror>,
held: Option<&CgroupHold>,
) -> Result<usize, String> {
let indexed = indexed_with_progress(
|| git.index_now().map_err(|err| format!("{err:#}")),
|| {
if let Some(held) = held {
held.refresh();
}
if let Some((_, row)) = engine
.index_progress()
.into_iter()
.find(|(dir, row)| dir == git_dir && row.active && row.objects_total > 0)
{
report.progress(row.objects_done, row.objects_total);
}
},
);
if let Ok(packs) = &indexed {
tracing::info!(
target: TARGET_GIT,
event = "mirror.sync.indexed",
packs,
"the fetched pack was indexed once, before the references were checked"
);
}
indexed
}
struct CgroupHold {
engine: crate::storage::Engine,
git_dir: std::path::PathBuf,
cgroup: std::path::PathBuf,
file: std::path::PathBuf,
}
impl CgroupHold {
fn start(
stores: &Arc<ServerStores>,
git_dir: &std::path::Path,
limit: &crate::import_room::MemoryLimit,
) -> Option<CgroupHold> {
let cgroup = limit.cgroup_dir()?.to_path_buf();
let dir = stores.root().join("import-memory");
std::fs::create_dir_all(&dir).ok()?;
let name: String = git_dir
.to_string_lossy()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
let held = CgroupHold {
engine: stores.engine(),
git_dir: git_dir.to_path_buf(),
cgroup,
file: dir.join(format!("{name}.meminfo")),
};
if !held.refresh() {
return None;
}
let floor = limit.bytes / 100 * crate::import_room::MARGIN_PERCENT;
if !held
.engine
.hold_index_memory(&held.git_dir, Some((&held.file, floor)))
{
let _ = std::fs::remove_file(&held.file);
return None;
}
tracing::info!(
target: TARGET_GIT,
event = "mirror.import.memory_held",
repo = %held.git_dir.display(),
limit = limit.bytes,
floor,
"the import's index reads this server's cgroup memory ({}) and pauses under {}",
limit.words(),
crate::budget::human(floor)
);
Some(held)
}
fn refresh(&self) -> bool {
let Some(c) = crate::import_room::cgroup_memory_in(&self.cgroup) else {
return false;
};
let tmp = self.file.with_extension("meminfo.tmp");
std::fs::write(&tmp, crate::import_room::cgroup_meminfo(&c)).is_ok()
&& std::fs::rename(&tmp, &self.file).is_ok()
}
}
impl Drop for CgroupHold {
fn drop(&mut self) {
self.engine.hold_index_memory(&self.git_dir, None);
let _ = std::fs::remove_file(&self.file);
}
}
fn indexed_with_progress(
index: impl FnOnce() -> Result<usize, String>,
progress: impl Fn() + Sync,
) -> Result<usize, String> {
let done = std::sync::atomic::AtomicBool::new(false);
std::thread::scope(|scope| {
scope.spawn(|| {
while !done.load(Ordering::SeqCst) {
progress();
for _ in 0..10 {
if done.load(Ordering::SeqCst) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
});
let indexed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(index));
done.store(true, Ordering::SeqCst);
indexed.unwrap_or_else(|panic| {
let words = panic
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "a panic with no words".into());
Err(format!("the index panicked: {words}"))
})
})
}
pub(crate) fn room_for(
gates: &[Arc<dyn gunnar_ssh::PlanGate>],
namespace: &str,
) -> Result<Option<gunnar_wire::PackCeiling>, (crate::mirror_queue::Refusal, String)> {
use gunnar_ssh::{PlanAction, PlanVerdict};
if gates.is_empty() {
return Ok(None);
}
let mark = gunnar_lfs::AllowanceLedger::shared_pack().mark(namespace);
let mut tightest: Option<gunnar_ssh::PackCeiling> = None;
for gate in gates {
if let PlanVerdict::Refuse(line) = gate.verdict(namespace, PlanAction::Write) {
return Err((crate::mirror_queue::Refusal::Plan, line));
}
if let PlanVerdict::Refuse(line) = gate.pack_quota(namespace) {
return Err((crate::mirror_queue::Refusal::PackCap, line));
}
if let Some(asked) = gate.pack_ceiling(namespace) {
if tightest
.as_ref()
.is_none_or(|held| asked.bytes < held.bytes)
{
tightest = Some(asked);
}
}
}
Ok(tightest.map(|ceiling| gunnar_wire::PackCeiling {
bytes: ceiling.bytes,
refusal: ceiling.refusal,
ledger: Some(
Arc::new(crate::transport::PackMark { mark }) as Arc<dyn gunnar_wire::PackLedger>
),
}))
}
fn apply(
git: &dyn gunnar_store::git_storage_trait::GitServe,
order: &MirrorOrder,
outcome: &FetchReport,
progress: &QueueProgress<'_>,
protection: Option<&Protection>,
) -> Result<(), String> {
let zero = "0".repeat(outcome.hash_kind.len_in_hex());
type OwnedUpdate = (String, Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>);
let mut owned: Vec<OwnedUpdate> = Vec::new();
let mut updates: Vec<(String, String, String)> = Vec::new();
for plan in &outcome.updates {
gunnar_store::refname::validate(plan.name.as_bytes()).map_err(|err| {
format!(
"the upstream advertised a reference name gunnar will not write: {err}. Nothing \
was moved. A name is not a path, and this one would not have been a name."
)
})?;
let peeled = outcome
.advertised
.iter()
.find(|advertised| advertised.name == plan.name)
.and_then(|advertised| advertised.peeled)
.map(|oid| oid.as_bytes().to_vec());
updates.push((
plan.name.clone(),
plan.old
.map(|old| old.to_string())
.unwrap_or_else(|| zero.clone()),
plan.new.to_string(),
));
owned.push((
plan.name.clone(),
plan.old.map(|old| old.as_bytes().to_vec()),
Some(plan.new.as_bytes().to_vec()),
peeled,
));
}
if order.prune {
let held = GitOps::refs(git)
.map_err(|err| format!("reading {}'s refs to prune them: {err:#}", order.path))?;
for row in held {
if !fetch_pack::mirror(&row.name) {
continue;
}
if outcome
.advertised
.iter()
.any(|advertised| advertised.name == row.name)
{
continue;
}
let Some(old) = row.oid else { continue };
updates.push((row.name.clone(), hex(&old), zero.clone()));
owned.push((row.name, Some(old), None, None));
}
}
if owned.is_empty() {
finish(outcome, progress, Vec::new());
return Ok(());
}
if let Some(protection) = protection {
let gated: Vec<gunnar_policy::RefUpdate> = owned
.iter()
.map(|(name, old, new, _)| {
let decode = |raw: &Vec<u8>| {
gix_hash::oid::try_from_bytes(raw)
.map(gix_hash::oid::to_owned)
.map_err(|err| {
format!("{name} holds an oid this server cannot decode: {err}")
})
};
Ok(gunnar_policy::RefUpdate {
name: name.clone(),
old: old.as_ref().map(&decode).transpose()?,
new: new.as_ref().map(&decode).transpose()?,
})
})
.collect::<Result<_, String>>()?;
let new_objects = match outcome.receipt.as_ref() {
None => Ok(Vec::new()),
Some(receipt) => receipt.new_objects.clone(),
};
protection.check(git, &gated, new_objects.as_deref().map_err(String::as_str))?;
}
let batch: Vec<RefCas<'_>> = owned
.iter()
.map(|(name, old, new, peeled)| RefCas {
name: name.clone(),
old: old.as_deref(),
new: new.as_deref(),
peeled: peeled.clone(),
})
.collect();
GitOps::put_refs_cas(git, &batch).map_err(|err| {
format!(
"the fetch succeeded and the reference update did not: {err:#}. Nothing was moved. \
The usual cause is a push that landed on {} while the fetch was running; the \
expected values came from the snapshot the fetch negotiated against, and \
overwriting a concurrent push is the one thing a mirror must never do. Sync again.",
order.path
)
})?;
tracing::info!(
target: TARGET_GIT,
event = "mirror.sync.applied",
repo = %order.path,
refs = updates.len(),
objects = outcome.objects_done,
bytes = outcome.pack_bytes,
"a mirror sync moved this repository's references"
);
finish(outcome, progress, updates);
Ok(())
}
fn finish(
outcome: &FetchReport,
progress: &QueueProgress<'_>,
updates: Vec<(String, String, String)>,
) {
if outcome.receipt.is_some() {
let total = u64::from(outcome.objects_total);
progress.publish(u64::from(outcome.objects_done), total);
}
progress.report.report(|mirror| {
mirror.objects = u64::from(outcome.objects_done);
mirror.bytes = outcome.pack_bytes;
mirror.updates = updates;
});
}
fn hex(raw: &[u8]) -> String {
let mut out = String::with_capacity(raw.len() * 2);
for byte in raw {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
}
out
}
fn base64_basic(user: &str, secret: &str) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let raw = format!("{user}:{secret}");
let raw = raw.as_bytes();
let mut out = String::with_capacity(raw.len().div_ceil(3) * 4);
for chunk in raw.chunks(3) {
let b = [
chunk[0],
chunk.get(1).copied().unwrap_or(0),
chunk.get(2).copied().unwrap_or(0),
];
let packed = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
out.push(ALPHABET[(packed >> 18) as usize & 63] as char);
out.push(ALPHABET[(packed >> 12) as usize & 63] as char);
out.push(if chunk.len() > 1 {
ALPHABET[(packed >> 6) as usize & 63] as char
} else {
'='
});
out.push(if chunk.len() > 2 {
ALPHABET[packed as usize & 63] as char
} else {
'='
});
}
out
}
#[cfg(feature = "replication")]
fn dial_ssh<T>(
url: &str,
credential: &Credential,
known_hosts: &std::path::Path,
strict: bool,
run: impl FnOnce(&mut dyn BufRead, &mut dyn Write) -> T,
) -> Result<T, String> {
use gunnar_client::ssh::{HostKeyPolicy, SshOptions};
use std::io::BufReader;
let mut options = SshOptions::new();
options.use_agent = false;
let key_file = match credential {
Credential::SshKey(bytes) => {
let mut file = tempfile::Builder::new()
.prefix("gunnar-mirror-key-")
.tempfile()
.map_err(|err| format!("no room to materialise the mirror's ssh key: {err}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o600))
.map_err(|err| {
format!("the mirror's ssh key could not be made private: {err}")
})?;
}
file.write_all(bytes)
.and_then(|()| file.flush())
.map_err(|err| format!("writing the mirror's ssh key: {err}"))?;
options.key_path = Some(file.path().to_path_buf());
Some(file)
}
_ => None,
};
options.host_key = if strict {
HostKeyPolicy::StrictKnownHosts
} else {
HostKeyPolicy::TofuKnownHosts
};
options.known_hosts = Some(known_hosts.to_path_buf());
let location = nornir_git::ssh::parse_ssh_url(url)
.map_err(|err| format!("{} is not an ssh url: {err}", scrub(url)))?;
let mut channel = gunnar_client::connect_upload_pack(&location, &options).map_err(|err| {
format!(
"opening git-upload-pack on {}: {:#}",
scrub(url),
anyhow::Error::new(err)
)
})?;
let out = {
let (reader, writer) = channel.io();
let mut reader = BufReader::new(reader);
run(&mut reader, writer)
};
drop(key_file);
Ok(out)
}
#[cfg(not(feature = "replication"))]
fn dial_ssh<T>(
url: &str,
_credential: &Credential,
_known_hosts: &std::path::Path,
_strict: bool,
_run: impl FnOnce(&mut dyn BufRead, &mut dyn Write) -> T,
) -> Result<T, String> {
Err(format!(
"{} is an ssh remote and this build carries no ssh dialler: it was compiled without \
`--features replication`, which is what links `gunnar-client`'s russh transport. \
Mirror from the upstream's `https://` URL, or use a build that has the feature.",
scrub(url)
))
}
pub(crate) fn scrub(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else {
return url.to_owned();
};
let (authority, path) = match rest.find('/') {
Some(cut) => (&rest[..cut], &rest[cut..]),
None => (rest, ""),
};
match authority.rfind('@') {
Some(cut) => format!("{scheme}://<redacted>@{}{path}", &authority[cut + 1..]),
None => url.to_owned(),
}
}
#[cfg(feature = "mirror-http")]
fn open_http(
url: &str,
auth: Option<String>,
) -> Result<(crate::mirror_http::HttpRead, crate::mirror_http::HttpWrite), String> {
crate::mirror_http::open(url, auth)
}
pub const NO_MIRROR_HTTP: &str = "this build carries no HTTP client: it was compiled without gunnar-server's `mirror-http` feature (the appliance rows name it)";
#[cfg(not(feature = "mirror-http"))]
fn open_http(url: &str, _auth: Option<String>) -> Result<(std::io::Empty, std::io::Sink), String> {
Err(format!("{} is an https remote and {NO_MIRROR_HTTP}. Mirror from the upstream's ssh:// URL, or use a build that has the feature.", scrub(url)))
}
#[cfg(test)]
mod index_panic_tests {
use super::*;
#[test]
fn an_index_that_panics_is_a_named_failure_not_a_hung_job() {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let r = std::panic::catch_unwind(|| {
indexed_with_progress(|| panic!("the index broke"), || {})
});
let _ = tx.send(r);
});
let r = rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("the index step ended within 5 s: no hang");
let said = r.expect("a panic in the index is an answer, not a panic up the mirror job");
let why = said.expect_err("a refusal");
assert!(
why.contains("the index panicked") && why.contains("the index broke"),
"{why}"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(feature = "mirror-http"))]
#[test]
fn an_https_remote_is_refused_by_name_in_a_build_without_mirror_http() {
let e = open_http(
"https://user:tok3n@forge.mock.invalid/ns/repo.git",
Some("Bearer tok3n".into()),
)
.expect_err("refused");
assert!(
e.contains(NO_MIRROR_HTTP) && e.contains("mirror-http"),
"{e}"
);
assert!(!e.contains("tok3n"), "{e}");
}
#[test]
fn a_url_with_userinfo_never_shows_it() {
let scrubbed = scrub("https://alice:ghp_SUPERSECRET@example.test/ns/repo.git");
assert!(
!scrubbed.contains("ghp_SUPERSECRET"),
"the token survived the scrub: {scrubbed}"
);
assert!(
!scrubbed.contains("alice"),
"the whole userinfo goes, not just the password half: {scrubbed}"
);
assert_eq!(scrubbed, "https://<redacted>@example.test/ns/repo.git");
}
#[test]
fn a_plain_url_is_untouched() {
let url = "https://example.test/ns/repo.git";
assert_eq!(scrub(url), url);
assert_eq!(
scrub("ssh://git@example.test/ns/repo.git"),
"ssh://<redacted>@example.test/ns/repo.git"
);
}
#[test]
fn a_remote_url_names_its_transport() {
assert_eq!(classify("ssh://git@host/ns/repo.git"), Ok(Wire::Ssh));
assert_eq!(classify("git+ssh://git@host/ns/repo.git"), Ok(Wire::Ssh));
assert_eq!(classify("git@host:ns/repo.git"), Ok(Wire::Ssh));
assert_eq!(classify("https://host/ns/repo.git"), Ok(Wire::Http));
assert_eq!(
classify("http://127.0.0.1:9418/ns/repo.git"),
Ok(Wire::Http)
);
}
#[test]
fn a_remote_that_is_neither_is_refused_by_name() {
let err = classify("/srv/git/ns/repo.git").expect_err("a local path is not a remote");
assert!(err.contains("ssh://") && err.contains("https://"), "{err}");
let err = classify("git://host/ns/repo.git").expect_err("the dumb git protocol");
assert!(err.contains("scheme gunnar cannot mirror from"), "{err}");
}
#[test]
fn a_credential_of_the_wrong_kind_is_refused_by_name() {
let key = Credential::SshKey(b"-----BEGIN OPENSSH PRIVATE KEY-----".to_vec());
let token = Credential::Token {
username: String::new(),
secret: "ghp_SECRET".to_owned(),
};
let err = check_credential(Wire::Http, &key).expect_err("a key cannot ride on http");
assert!(
err.contains("smart http") && err.contains("ssh private key"),
"{err}"
);
assert!(
!err.contains("BEGIN OPENSSH"),
"the refusal quoted the key: {err}"
);
let err = check_credential(Wire::Ssh, &token).expect_err("a token cannot ride on ssh");
assert!(err.contains("ssh") && err.contains("a token"), "{err}");
assert!(
!err.contains("ghp_SECRET"),
"the refusal quoted the token: {err}"
);
check_credential(Wire::Ssh, &key).expect("a key over ssh");
check_credential(Wire::Http, &token).expect("a token over http");
check_credential(Wire::Ssh, &Credential::None).expect("a public ssh upstream");
check_credential(Wire::Http, &Credential::None).expect("a public http upstream");
}
#[test]
fn a_remote_url_names_the_machine_it_would_dial() {
for (url, want) in [
("https://host/ns/repo.git", ("host".to_owned(), 443)),
("http://host/ns/repo.git", ("host".to_owned(), 80)),
("http://host:9418/ns/repo.git", ("host".to_owned(), 9418)),
("ssh://git@host/ns/repo.git", ("host".to_owned(), 22)),
("ssh://git@host:2222/ns/repo.git", ("host".to_owned(), 2222)),
("git@host:ns/repo.git", ("host".to_owned(), 22)),
(
"https://user:pass@host/ns/repo.git",
("host".to_owned(), 443),
),
("ssh://git@[::1]:2222/ns/repo.git", ("::1".to_owned(), 2222)),
(
"https://[2606:4700::1111]/ns/repo.git",
("2606:4700::1111".to_owned(), 443),
),
("git@[::1]:ns/repo.git", ("::1".to_owned(), 22)),
] {
assert_eq!(authority(url), Ok(want.clone()), "{url}");
}
assert!(authority("https:///ns/repo.git").is_err(), "no host at all");
}
#[test]
fn a_mirror_refuses_every_address_only_the_server_can_reach() {
for addr in [
"127.0.0.1",
"0.0.0.0",
"10.0.0.5",
"172.16.0.1",
"192.168.1.1",
"169.254.169.254",
"100.64.0.1",
"192.0.0.1",
"198.18.0.1",
"240.0.0.1",
"255.255.255.255",
"224.0.0.1",
"::1",
"::",
"fc00::1",
"fe80::1",
"ff02::1",
"::ffff:127.0.0.1",
"::ffff:169.254.169.254",
] {
let ip: std::net::IpAddr = addr.parse().expect("a literal");
let err = address_verdict("upstream.invalid", &[ip])
.expect_err("{addr} must not be dialled on a caller's say-so");
assert!(
err.contains(addr) || err.contains(&ip.to_string()),
"{addr}: {err}"
);
assert!(err.contains(MIRROR_ALLOW_PRIVATE), "{addr}: {err}");
}
for addr in ["93.184.216.34", "8.8.8.8", "2606:4700:4700::1111"] {
let ip: std::net::IpAddr = addr.parse().expect("a literal");
address_verdict("upstream.invalid", &[ip])
.unwrap_or_else(|err| panic!("{addr} is an ordinary public address: {err}"));
}
}
#[test]
fn one_private_answer_among_several_refuses_the_whole_name() {
let addrs: Vec<std::net::IpAddr> = ["93.184.216.34", "127.0.0.1"]
.iter()
.map(|a| a.parse().expect("a literal"))
.collect();
let err = address_verdict("both.invalid", &addrs).expect_err("one of them is loopback");
assert!(err.contains("127.0.0.1"), "{err}");
assert!(address_verdict("nowhere.invalid", &[]).is_err());
}
#[test]
fn basic_auth_is_ordinary_base64() {
assert_eq!(
base64_basic("Aladdin", "open sesame"),
"QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
);
assert_eq!(base64_basic("a", "b"), "YTpi");
assert_eq!(base64_basic("ab", "b"), "YWI6Yg==");
}
}