use std::collections::HashMap;
use std::future::Future;
use std::io::{BufRead, BufReader, BufWriter, Write as _};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use gix_hash::ObjectId;
use gunnar_core::observe::{ServerMetrics, TARGET_GIT, TARGET_POLICY, TARGET_STORE};
use gunnar_grpc::EventBus;
use gunnar_policy::{
KeyDirectory, PolicyConfig, PolicyEngine, PushContext, RefUpdate, RepoId, StaticKeyDirectory,
};
use gunnar_proto::{event, PushAttestation, PushRejected, RefDeleted, RefUpdated, RepoPath};
use gunnar_ssh::seam::{Delivery, DuplexStream, GitTransport, ServiceRequest, TransportError};
use gunnar_ssh::GitService;
use gunnar_store::git_storage_trait::GitServe;
use gunnar_wire::{
PackReceipt, ProtocolVersion, PushFacts, PushGate, PushOutcome, ReceivePack, RefCommand,
RefStatus, Session, UploadPack,
};
use tokio_util::io::SyncIoBridge;
use crate::attest::AttestationLog;
use crate::storage::OpenRepo;
use crate::stores::ServerStores;
pub const POLICY_FILE: &str = "gunnar-policy.json";
fn protocol_version(request: &ServiceRequest) -> ProtocolVersion {
ProtocolVersion::from_git_protocol(request.git_protocol.as_deref())
}
#[derive(Debug)]
pub(crate) struct PackMark {
pub(crate) mark: gunnar_lfs::Mark,
}
impl gunnar_wire::PackLedger for PackMark {
fn admit_upto(&self, want: u64, room: u64) -> u64 {
self.mark.admit_upto(want, room)
}
}
fn wire_session(request: &ServiceRequest) -> Session {
match request.delivery {
Delivery::Session | Delivery::Advertisement => Session::Connected,
Delivery::StatelessRpc => Session::StatelessRpc,
}
}
#[derive(Debug, Clone)]
pub struct WireConfig {
pub upload: gunnar_wire::upload_pack::Config,
pub receive: gunnar_wire::receive_pack::Config,
pub lfs: gunnar_lfs::Limits,
pub attest_objects: bool,
}
impl Default for WireConfig {
fn default() -> Self {
WireConfig {
upload: Default::default(),
receive: Default::default(),
lfs: Default::default(),
attest_objects: true,
}
}
}
impl WireConfig {
pub fn from_serve_args(args: &gunnar_common::cli::ServeArgs) -> anyhow::Result<Self> {
let mut wire = WireConfig::default();
if args.no_attest_objects {
wire.attest_objects = false;
}
if args.no_filter {
wire.upload.offered.filter = false;
}
if let Some(policy) = args.want_policy {
wire.upload.want_policy = policy.to_wire();
}
if let Some(budget) = args.fetch_budget {
anyhow::ensure!(
budget > 0,
"--fetch-budget 0 would refuse every fetch rather than allow every fetch: the \
walk stops when it REACHES the budget. Leave the flag off for the default of \
{}, or give the number of objects a fetch may visit.",
gunnar_wire::DEFAULT_BUDGET
);
wire.upload.budget = Some(budget);
}
if let Some(bytes) = args.max_fetch_request_bytes {
wire.upload.limits.request_bytes = (bytes > 0).then_some(bytes);
}
if let Some(bytes) = args.max_fetch_object_bytes {
wire.upload.limits.object_bytes = (bytes > 0).then_some(bytes);
}
if let Some(bytes) = args.max_fetch_walk_bytes {
wire.upload.limits.walk_bytes = (bytes > 0).then_some(bytes);
}
wire.upload.walk_pool = match args.max_walk_bytes_total {
Some(0) => None,
Some(bytes) => Some(gunnar_wire::WalkPool::new(bytes)),
None => Some(gunnar_wire::WalkPool::for_this_machine()),
};
if let Some(bytes) = args.max_lfs_object_bytes {
anyhow::ensure!(
bytes > 0,
"--max-lfs-object-bytes 0 would refuse every upload, not allow every upload. \
Leave the flag off for the default of {} bytes.",
gunnar_lfs::DEFAULT_MAX_OBJECT_BYTES
);
wire.lfs = wire.lfs.max_object_bytes(bytes);
}
if let Some(objects) = args.max_lfs_batch_objects {
anyhow::ensure!(
objects > 0,
"--max-lfs-batch-objects 0 would refuse every batch. Leave the flag off for the \
default of {}.",
gunnar_lfs::DEFAULT_MAX_BATCH_OBJECTS
);
wire.lfs = wire.lfs.max_batch_objects(objects);
}
if let Some(bytes) = args.max_lfs_drain_bytes {
anyhow::ensure!(
bytes > 0,
"--max-lfs-drain-bytes 0 would drop the channel on the first refusal instead of \
draining to the client's flush-pkt, so the refusal itself would not be \
delivered. Leave the flag off for the default."
);
wire.lfs = wire.lfs.max_drain_bytes(bytes);
}
Ok(wire)
}
pub fn lfs_session<'a>(
&self,
store: &'a dyn gunnar_lfs::LfsStore,
operation: gunnar_lfs::Operation,
) -> gunnar_lfs::Session<'a> {
gunnar_lfs::Session::new(store, operation).with_limits(self.lfs)
}
}
pub struct WireTransport {
stores: Arc<ServerStores>,
bus: Option<Arc<EventBus>>,
attestations: AttestationLog,
metrics: Arc<ServerMetrics>,
wire: Arc<WireConfig>,
}
impl WireTransport {
pub fn new(stores: Arc<ServerStores>) -> Self {
let attestations = AttestationLog::under(stores.root());
WireTransport {
stores,
bus: None,
attestations,
metrics: Arc::new(ServerMetrics::new()),
wire: Arc::new(WireConfig::default()),
}
}
pub fn with_wire_config(mut self, wire: WireConfig) -> Self {
self.wire = Arc::new(wire);
self
}
pub fn with_metrics(mut self, metrics: Arc<ServerMetrics>) -> Self {
self.metrics = metrics;
self
}
pub fn with_bus(mut self, bus: Arc<EventBus>) -> Self {
self.bus = Some(bus);
self
}
pub fn attestation_log(&self) -> &AttestationLog {
&self.attestations
}
}
impl gunnar_ssh::seam::LfsTransport for WireTransport {
fn open_lfs(
&self,
request: &ServiceRequest,
) -> Result<gunnar_ssh::seam::LfsAccess, TransportError> {
if request.prospective {
return Ok(gunnar_ssh::seam::LfsAccess {
store: Arc::new(ProspectiveLfs::new(
Arc::clone(&self.stores),
request.clone(),
)),
limits: self.wire.lfs,
allowance: request.lfs_allowance.clone(),
});
}
let store = open_repo_store(&self.stores, request)?;
let lfs = open_lfs_store(&self.stores, &store, request)?;
Ok(gunnar_ssh::seam::LfsAccess {
store: Arc::new(lfs),
limits: self.wire.lfs,
allowance: request.lfs_allowance.clone(),
})
}
}
fn open_repo_store(
stores: &ServerStores,
request: &ServiceRequest,
) -> Result<Arc<OpenRepo>, TransportError> {
stores.repo_store(&request.repo).map_err(|err| {
tracing::error!(
target: TARGET_GIT,
error = format!("{err:#}"),
"the repository could not be opened for a transfer that was already authorised"
);
TransportError::Refused(match locked_path(&err) {
Some(_) => format!(
"the repository {} is locked by maintenance on the server (a repack or a \
second gunnar holds its store lock); the transfer was not started, so retry",
request.repo.key()
),
None => format!(
"the repository {} could not be opened on the server; the transfer was not \
started. The reason is in the server log",
request.repo.key()
),
})
})
}
fn open_lfs_store(
stores: &ServerStores,
store: &OpenRepo,
request: &ServiceRequest,
) -> Result<crate::txlog_lfs::LoggedLfs<gunnar_lfs::FsLfsStore>, TransportError> {
let repo = format!("{}/{}", request.repo.namespace, request.repo.name);
gunnar_lfs::FsLfsStore::open_or_init(store.git_dir())
.map(|fs| {
crate::txlog_lfs::LoggedLfs::new(fs, Arc::clone(stores.txlog()), repo)
.with_space(stores.space_ledger())
})
.map_err(|err| {
tracing::error!(
target: TARGET_GIT,
error = format!("{err}"),
"the lfs object store could not be opened for an authorised transfer"
);
TransportError::Refused(format!(
"the lfs object store for {} could not be opened on the server; the \
transfer was not started. The reason is in the server log",
request.repo.key()
))
})
}
pub(crate) struct ProspectiveLfs<S> {
stores: S,
request: ServiceRequest,
made: std::sync::OnceLock<(
Arc<OpenRepo>,
crate::txlog_lfs::LoggedLfs<gunnar_lfs::FsLfsStore>,
)>,
making: Mutex<()>,
}
impl<S: std::ops::Deref<Target = ServerStores> + Send + Sync> ProspectiveLfs<S> {
pub(crate) fn new(stores: S, request: ServiceRequest) -> Self {
ProspectiveLfs {
stores,
request,
made: std::sync::OnceLock::new(),
making: Mutex::new(()),
}
}
fn made(&self) -> Option<&crate::txlog_lfs::LoggedLfs<gunnar_lfs::FsLfsStore>> {
self.made.get().map(|(_, lfs)| lfs)
}
fn made_or_create(
&self,
) -> gunnar_lfs::Result<&crate::txlog_lfs::LoggedLfs<gunnar_lfs::FsLfsStore>> {
if let Some(lfs) = self.made() {
return Ok(lfs);
}
let _one = self.making.lock().unwrap_or_else(|p| p.into_inner());
if let Some(lfs) = self.made() {
return Ok(lfs);
}
let stores: &ServerStores = &self.stores;
let path = self.request.repo.path();
let (repo, store) = create_prospect(stores, &self.request).map_err(|sentence| {
if sentence.starts_with("repository not found") {
gunnar_lfs::Error::NotFound(sentence)
} else {
gunnar_lfs::Error::Store(sentence)
}
})?;
let mut admitted = self.request.clone();
admitted.repo = repo;
admitted.prospective = false;
let lfs = open_lfs_store(stores, &store, &admitted)
.map_err(|err| gunnar_lfs::Error::Store(err.to_string()))?;
tracing::info!(
target: TARGET_GIT,
event = "git.lfs_transfer.created_repo",
repo = %path,
principal = %self.request.principal.id,
"an LFS upload's first object made the repository (the push-create door)"
);
Ok(&self.made.get_or_init(|| (store, lfs)).1)
}
}
impl<S: std::ops::Deref<Target = ServerStores> + Send + Sync> gunnar_lfs::LfsStore
for ProspectiveLfs<S>
{
fn size_of(&self, oid: &gunnar_lfs::Oid) -> gunnar_lfs::Result<Option<u64>> {
match self.made() {
Some(lfs) => lfs.size_of(oid),
None => Ok(None),
}
}
fn open(&self, oid: &gunnar_lfs::Oid) -> gunnar_lfs::Result<Option<gunnar_lfs::LfsObject>> {
match self.made() {
Some(lfs) => lfs.open(oid),
None => Ok(None),
}
}
fn begin_write(
&self,
oid: &gunnar_lfs::Oid,
declared_size: u64,
) -> gunnar_lfs::Result<Box<dyn gunnar_lfs::PendingWrite + '_>> {
self.made_or_create()?.begin_write(oid, declared_size)
}
fn delete(&self, oid: &gunnar_lfs::Oid) -> gunnar_lfs::Result<bool> {
match self.made() {
Some(lfs) => lfs.delete(oid),
None => Ok(false),
}
}
}
impl GitTransport for WireTransport {
fn serve<'a>(
&'a self,
request: ServiceRequest,
stream: Box<dyn DuplexStream>,
) -> Pin<Box<dyn Future<Output = Result<(), TransportError>> + Send + 'a>> {
let stores = Arc::clone(&self.stores);
let bus = self.bus.clone();
let attestations = self.attestations.clone();
let metrics = Arc::clone(&self.metrics);
let wire = Arc::clone(&self.wire);
Box::pin(async move {
let parent = tracing::Span::current();
let (read_half, write_half) = tokio::io::split(stream);
let cancel = request.cancel.clone();
let _stop_if_dropped = CancelOnDrop(cancel.clone());
let mut reader = Cancellable {
inner: SyncIoBridge::new(read_half),
cancel: cancel.clone(),
};
let mut writer = Cancellable {
inner: SyncIoBridge::new(write_half),
cancel,
};
tokio::task::spawn_blocking(move || {
let _entered = parent.enter();
serve_blocking(
&Serving {
stores: &stores,
bus: bus.as_deref(),
attestations: &attestations,
metrics: &metrics,
wire: &wire,
},
request,
&mut reader,
&mut writer,
)
})
.await
.map_err(|err| {
TransportError::Protocol(format!("the git transport task did not finish: {err}"))
})?
})
}
}
struct CancelOnDrop(gunnar_ssh::channel::Cancel);
impl Drop for CancelOnDrop {
fn drop(&mut self) {
self.0.cancel();
}
}
struct Cancellable<T> {
inner: T,
cancel: gunnar_ssh::channel::Cancel,
}
impl<T> Cancellable<T> {
fn check(&self) -> std::io::Result<()> {
if self.cancel.is_cancelled() {
return Err(told_to_stop());
}
Ok(())
}
}
fn told_to_stop() -> std::io::Error {
std::io::Error::new(
std::io::ErrorKind::ConnectionAborted,
"the transfer was told to stop: its client's session ended and the transfer outlived the grace",
)
}
impl<T: std::io::Read> std::io::Read for Cancellable<T> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.check()?;
self.inner.read(buf)
}
}
impl<T: std::io::Write> std::io::Write for Cancellable<T> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.check()?;
self.inner.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
self.check()?;
self.inner.flush()
}
}
fn wire_error(err: gunnar_wire::Error) -> TransportError {
TransportError::Protocol(err.to_string())
}
struct Metered<T> {
inner: T,
total: Arc<AtomicU64>,
}
impl<T> Metered<T> {
fn new(inner: T) -> (Metered<T>, Arc<AtomicU64>) {
let total = Arc::new(AtomicU64::new(0));
(
Metered {
inner,
total: Arc::clone(&total),
},
total,
)
}
}
impl<T: std::io::Read> std::io::Read for Metered<T> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = self.inner.read(buf)?;
self.total.fetch_add(n as u64, Ordering::Relaxed);
Ok(n)
}
}
impl<T: std::io::Write> std::io::Write for Metered<T> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let n = self.inner.write(buf)?;
self.total.fetch_add(n as u64, Ordering::Relaxed);
Ok(n)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
struct FlowRecording<'a> {
flow: &'a gunnar_core::FlowLedger,
namespace: String,
principal: String,
bytes_in: Arc<AtomicU64>,
bytes_out: Arc<AtomicU64>,
fetched: std::cell::Cell<bool>,
pushed: std::cell::Cell<bool>,
lfs_bytes_in: std::cell::Cell<u64>,
lfs_bytes_out: std::cell::Cell<u64>,
}
impl Drop for FlowRecording<'_> {
fn drop(&mut self) {
self.flow.record(
&self.namespace,
gunnar_core::now_ms(),
&gunnar_core::Transfer {
bytes_in: self.bytes_in.load(Ordering::Relaxed),
bytes_out: self.bytes_out.load(Ordering::Relaxed),
pushed: self.pushed.get(),
fetched: self.fetched.get(),
lfs_bytes_in: self.lfs_bytes_in.get(),
lfs_bytes_out: self.lfs_bytes_out.get(),
principal: self.principal.clone(),
},
);
}
}
fn locked_path(err: &anyhow::Error) -> Option<&std::path::Path> {
match err.downcast_ref::<gunnar_store::Error>() {
Some(gunnar_store::Error::StoreLocked { path }) => Some(path.as_path()),
_ => None,
}
}
struct Serving<'a> {
stores: &'a ServerStores,
bus: Option<&'a EventBus>,
attestations: &'a AttestationLog,
metrics: &'a Arc<ServerMetrics>,
wire: &'a WireConfig,
}
fn serve_blocking(
serving: &Serving<'_>,
request: ServiceRequest,
reader: &mut dyn std::io::Read,
writer: &mut dyn std::io::Write,
) -> Result<(), TransportError> {
let Serving {
stores,
metrics,
wire,
..
} = *serving;
if request.cancel.is_cancelled() {
return Err(TransportError::Io(told_to_stop()));
}
let store = if request.prospective {
None
} else {
Some(open_repo_store(stores, &request)?)
};
let (metered_in, bytes_in) = Metered::new(reader);
let (metered_out, bytes_out) = Metered::new(writer);
let flow = FlowRecording {
flow: stores.flow().as_ref(),
namespace: request.repo.namespace.clone(),
principal: request
.key_fingerprint
.as_ref()
.map(|f| f.to_string())
.unwrap_or_default(),
bytes_in: Arc::clone(&bytes_in),
bytes_out: Arc::clone(&bytes_out),
fetched: std::cell::Cell::new(false),
pushed: std::cell::Cell::new(false),
lfs_bytes_in: std::cell::Cell::new(0),
lfs_bytes_out: std::cell::Cell::new(0),
};
let mut input = BufReader::with_capacity(gunnar_wire::TRANSPORT_BUFFER_BYTES, metered_in);
let mut output = BufWriter::with_capacity(gunnar_wire::TRANSPORT_BUFFER_BYTES, metered_out);
let started = std::time::Instant::now();
let (request, store, preamble) = match store {
Some(store) => (request, Some(store), None),
None if matches!(request.service, GitService::LfsTransfer(_)) => (request, None, None),
None => match admit_prospective_push(serving, &request, &mut input, &mut output)? {
Some((request, store, preamble)) => (request, Some(store), Some(preamble)),
None => {
output.flush()?;
metrics.add_bytes_out(bytes_out.load(Ordering::Relaxed));
metrics.add_bytes_in(bytes_in.load(Ordering::Relaxed));
tracing::info!(
target: TARGET_GIT,
event = "git.receive_pack.prospect_abandoned",
repo = %request.repo.key(),
elapsed_ms = started.elapsed().as_millis() as u64,
"the client took the advertisement of a repository that does not exist and \
sent no command; nothing was created"
);
return Ok(());
}
},
};
let git_store = || {
store.clone().ok_or_else(|| {
TransportError::Refused(format!(
"repository not found, or you do not have access: {}",
request.repo.path()
))
})
};
match request.service {
GitService::UploadPack => {
let span = tracing::info_span!(target: TARGET_GIT, "git.upload_pack");
let _entered = span.enter();
let store = git_store()?;
let version = protocol_version(&request);
let git = store.serve();
let upload = UploadPack::new(git, store.hash_kind()).with_config(wire.upload.clone());
if request.delivery == Delivery::Advertisement {
upload.advertise(version, &mut output).map_err(wire_error)?;
output.flush()?;
let out = bytes_out.load(Ordering::Relaxed);
metrics.add_bytes_out(out);
tracing::info!(
target: TARGET_GIT,
event = "git.upload_pack.advertised",
protocol = version.number(),
bytes_out = out,
elapsed_ms = started.elapsed().as_millis() as u64,
"served the upload-pack advertisement"
);
return Ok(());
}
flow.fetched.set(true);
let summary = upload
.serve_session(version, wire_session(&request), &mut input, &mut output)
.map_err(|err| {
tracing::warn!(
target: TARGET_GIT,
event = "git.upload_pack.failed",
elapsed_ms = started.elapsed().as_millis() as u64,
bytes_out = bytes_out.load(Ordering::Relaxed),
%err,
"git-upload-pack failed"
);
wire_error(err)
})?;
output.flush()?;
let out = bytes_out.load(Ordering::Relaxed);
if summary.objects_sent > 0 {
metrics.add_packs_served(1);
}
metrics.add_objects_sent(summary.objects_sent as u64);
metrics.add_bytes_out(out);
metrics.add_bytes_in(bytes_in.load(Ordering::Relaxed));
tracing::info!(
target: TARGET_GIT,
event = "git.upload_pack.served",
protocol = summary.protocol.number(),
ls_refs = summary.ls_refs,
rounds = summary.fetch_rounds,
common = summary.common,
objects = summary.objects_sent,
from_index = summary.answered_from_bitmap,
bytes_out = out,
elapsed_ms = started.elapsed().as_millis() as u64,
"served git-upload-pack"
);
}
GitService::ReceivePack => {
let span = tracing::info_span!(target: TARGET_GIT, "git.receive_pack");
let _entered = span.enter();
let store = git_store()?;
let outcome =
serve_receive_pack(serving, &request, &store, preamble, &mut input, &mut output);
let out = bytes_out.load(Ordering::Relaxed);
let inn = bytes_in.load(Ordering::Relaxed);
metrics.add_bytes_out(out);
metrics.add_bytes_in(inn);
flow.pushed.set(outcome.is_ok());
if let Err(err) = &outcome {
tracing::warn!(
target: TARGET_GIT,
event = "git.receive_pack.failed",
elapsed_ms = started.elapsed().as_millis() as u64,
bytes_in = inn,
%err,
"git-receive-pack failed"
);
}
outcome?;
}
GitService::LfsTransfer(operation) => {
let span = tracing::info_span!(target: TARGET_GIT, "git.lfs_transfer");
let _entered = span.enter();
let lfs: Box<dyn gunnar_lfs::LfsStore + '_> = match &store {
Some(store) => Box::new(open_lfs_store(stores, store, &request)?),
None => Box::new(ProspectiveLfs::new(stores, request.clone())),
};
let mut session = wire.lfs_session(lfs.as_ref(), operation);
if let Some(allowance) = request.lfs_allowance.clone() {
session = session.with_allowance(allowance);
}
let effective = session.limits();
tracing::debug!(
target: TARGET_GIT,
max_object_bytes = effective.max_object_bytes,
max_batch_objects = effective.max_batch_objects,
max_drain_bytes = effective.max_drain_bytes,
lfs_allowance_bytes = session.allowance().map(|a| a.bytes),
"lfs session limits"
);
let outcome = session.serve(&mut input, &mut output);
output.flush()?;
let out = bytes_out.load(Ordering::Relaxed);
let inn = bytes_in.load(Ordering::Relaxed);
metrics.add_bytes_out(out);
metrics.add_bytes_in(inn);
let summary = outcome.map_err(|err| {
tracing::warn!(
target: TARGET_GIT,
event = "git.lfs_transfer.failed",
operation = %operation,
elapsed_ms = started.elapsed().as_millis() as u64,
%err,
"git-lfs-transfer failed"
);
TransportError::Protocol(err.to_string())
})?;
flow.lfs_bytes_in.set(summary.bytes_received);
flow.lfs_bytes_out.set(summary.bytes_sent);
tracing::info!(
target: TARGET_GIT,
event = "git.lfs_transfer.served",
operation = %operation,
commands = summary.commands,
batches = summary.batches,
batch_objects = summary.batch_objects,
objects_sent = summary.objects_sent,
objects_received = summary.objects_received,
lfs_bytes_out = summary.bytes_sent,
lfs_bytes_in = summary.bytes_received,
clean_quit = summary.clean_quit,
bytes_out = out,
bytes_in = inn,
elapsed_ms = started.elapsed().as_millis() as u64,
"served git-lfs-transfer"
);
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn admit_prospective_push(
serving: &Serving<'_>,
request: &ServiceRequest,
input: &mut dyn BufRead,
output: &mut dyn std::io::Write,
) -> Result<Option<(ServiceRequest, Arc<OpenRepo>, gunnar_wire::Preamble)>, TransportError> {
let Serving { stores, wire, .. } = *serving;
if request.service != GitService::ReceivePack {
return Err(TransportError::Refused(format!(
"repository not found, or you do not have access: {}",
request.repo.path()
)));
}
let hash_kind: gix_hash::Kind = request.repo.hash_kind.into();
if wire_session(request).advertises_inline() || request.delivery == Delivery::Advertisement {
ReceivePack::write_empty_advertisement(hash_kind, &wire.receive, output)
.map_err(wire_error)?;
if request.delivery == Delivery::Advertisement {
return Ok(None);
}
}
let Some(preamble) = ReceivePack::read_preamble(hash_kind, input).map_err(wire_error)? else {
return Ok(None);
};
match create_prospect(stores, request) {
Ok((repo, store)) => {
let mut admitted = request.clone();
admitted.repo = repo;
admitted.prospective = false;
Ok(Some((admitted, store, preamble)))
}
Err(sentence) => {
let _ = gunnar_wire::err_packet(output, &sentence);
let _ = output.flush();
Err(TransportError::Protocol(sentence))
}
}
}
fn create_prospect(
stores: &ServerStores,
request: &ServiceRequest,
) -> Result<(gunnar_core::Repo, Arc<OpenRepo>), String> {
let path = request.repo.path();
let control = stores.ssh_control();
match control.resolve(&path) {
Ok(Some(existing)) => {
if existing.hash_kind != request.repo.hash_kind {
return Err(format!(
"{path} was created meanwhile as a {:?} repository, and this push was shown {:?}; nothing was written — push again",
existing.hash_kind, request.repo.hash_kind
));
}
return match control.push_create_spec(&request.principal, &path) {
Ok(Some(_)) => stores
.repo_store(&existing)
.map(|store| (existing, store))
.map_err(|err| {
format!("the repository {path} could not be opened on the server: {err:#}")
}),
Ok(None) => Err(format!(
"repository not found, or you do not have access: {path}"
)),
Err(err) => Err(format!(
"the gunnar control plane could not read {path}: {err}"
)),
};
}
Ok(None) => {}
Err(err) => {
return Err(format!(
"the gunnar control plane could not read {path}: {err}"
))
}
}
if let Err(err) = stores.refuse_stale_store(&path, false, request.repo.hash_kind) {
return Err(format!("{err:#}"));
}
let (repo, conjured) = match control.admit_push_create(&request.principal, &path) {
Ok(Some(pair)) => pair,
Ok(None) => {
return Err(format!(
"repository not found, or you do not have access: {path}"
))
}
Err(err) => {
return Err(format!(
"the gunnar control plane could not create {path}: {err}"
))
}
};
match stores.repo_store(&repo) {
Ok(store) => Ok((repo, store)),
Err(err) => {
stores.close_repo(&path);
if let Err(undo) = control.retract_push_create(&path, conjured) {
tracing::error!(
target: TARGET_GIT,
repo = %path,
error = %undo,
"retracting the catalog row of a push-create whose store failed"
);
}
if let Ok(dir) = stores.repo_dir(&path) {
let _ = std::fs::remove_dir_all(dir);
}
tracing::error!(
target: TARGET_GIT,
repo = %path,
error = format!("{err:#}"),
"the push-created repository's store could not be made; nothing was kept"
);
Err(format!("the repository {path} could not be created on the server; nothing was kept. The reason is in the server log"))
}
}
}
pub(crate) fn unchecked_encrypted_push(encrypted: bool, quarantine: bool) -> Option<String> {
(encrypted && !quarantine).then(|| {
"this repository is encrypted, and this Gunnar was built without the check that keeps \
plaintext out of it, so it takes no push into it: push to a Gunnar built with its \
standard storage engine. Nothing was received"
.to_string()
})
}
fn serve_receive_pack(
serving: &Serving<'_>,
request: &ServiceRequest,
store: &OpenRepo,
preamble: Option<gunnar_wire::Preamble>,
input: &mut dyn BufRead,
output: &mut dyn std::io::Write,
) -> Result<(), TransportError> {
let Serving {
stores,
bus,
attestations,
metrics,
wire,
} = *serving;
let started = std::time::Instant::now();
if let Some(line) =
unchecked_encrypted_push(request.repo.encrypted, cfg!(feature = "engine-znippy"))
{
return Err(TransportError::Refused(line));
}
#[cfg(feature = "engine-znippy")]
let _validation_reads = crate::storage::znippy_engine::validation_reads();
let git = store.serve();
let hash_kind = store.hash_kind();
let gate = PolicyGate::open(store.git_dir(), request, stores, Arc::clone(metrics));
let objects = if gate.engine.reads_new_objects() {
crate::storage::ObjectSet::Required
} else if wire.attest_objects {
crate::storage::ObjectSet::Wanted
} else {
crate::storage::ObjectSet::NotNeeded
};
let ingest = PushIngest {
git_dir: store.git_dir().to_path_buf(),
hash_kind,
engine: stores.engine(),
objects,
watching: None,
frame: Some(PackFrame::of(stores, &request.repo)),
encrypted: request.repo.encrypted.then_some(git),
space: stores.space_ledger(),
};
if gate.engine.is_empty() {
tracing::info!(
target: TARGET_POLICY,
event = "policy.gates.none",
policy = %store.git_dir().join(POLICY_FILE).display(),
"no policy gates are configured for this repository; every well-formed push is \
accepted. This is an empty policy, not a passing one."
);
} else {
tracing::info!(
target: TARGET_POLICY,
event = "policy.gates.live",
gates = ?gate.engine.gate_names(),
"policy gates live for this push"
);
}
let mut receive_config = wire.receive.clone();
#[cfg(feature = "metered")]
{
receive_config.notice = request.plan_notice.clone();
}
receive_config.pack_ceiling =
request
.pack_ceiling
.as_ref()
.map(|ceiling| gunnar_wire::PackCeiling {
bytes: ceiling.bytes,
refusal: ceiling.refusal.clone(),
ledger: request
.pack_mark
.clone()
.map(|mark| Arc::new(PackMark { mark }) as Arc<dyn gunnar_wire::PackLedger>),
});
let receive = ReceivePack::new(git, hash_kind).with_config(receive_config);
if request.delivery == Delivery::Advertisement && preamble.is_none() {
receive
.write_ref_advertisement(output)
.map_err(wire_error)?;
output.flush()?;
tracing::info!(
target: TARGET_GIT,
event = "git.receive_pack.advertised",
elapsed_ms = started.elapsed().as_millis() as u64,
"served the receive-pack advertisement"
);
return Ok(());
}
let outcome = match preamble {
Some(preamble) => receive
.serve_after_preamble(preamble, input, output, &ingest, &gate)
.map(Some),
None => receive.serve_session(wire_session(request), input, output, &ingest, &gate),
}
.map_err(wire_error)?;
output.flush()?;
let Some(outcome) = outcome else {
tracing::info!(
target: TARGET_GIT,
event = "git.receive_pack.advertisement_only",
elapsed_ms = started.elapsed().as_millis() as u64,
"git-receive-pack: the client took the advertisement and had nothing to push"
);
return Ok(());
};
let fingerprint = session_key_fingerprint(request);
let credential = session_credential(request);
let gates = gate.gate_that_refused();
if !gates.is_empty() {
serving.stores.flow().refuse(
&request.repo.namespace,
gunnar_core::now_ms(),
gunnar_core::Refusal::Policy,
);
}
let mut refs_ok = 0usize;
let mut refs_rejected = 0usize;
for (command, status) in outcome.commands.iter().zip(&outcome.statuses) {
match status {
RefStatus::Ok => {
refs_ok += 1;
if command.is_delete() {
metrics.add_refs_deleted(1);
} else {
metrics.add_refs_updated(1);
}
tracing::info!(
target: TARGET_GIT,
event = if command.is_delete() { "git.ref.deleted" } else { "git.ref.updated" },
ref_name = %command.name,
old = %command.old,
new = %command.new,
"ref updated"
)
}
RefStatus::Rejected(reason) => {
refs_rejected += 1;
metrics.add_refs_rejected(1);
tracing::warn!(
target: TARGET_GIT,
event = "git.ref.rejected",
ref_name = %command.name,
old = %command.old,
new = %command.new,
gate = gates.get(&command.name.to_string()).map(String::as_str).unwrap_or(""),
%reason,
"ref rejected"
)
}
}
if let Some(bus) = bus {
let forced = status.is_ok() && was_forced(git, command);
publish(
bus,
request,
command,
status,
outcome.atomic,
&fingerprint,
&credential,
&gates,
forced,
);
}
}
if refs_ok > 0 {
stores
.activity()
.operated(&request.principal.id, gunnar_core::now_ms());
}
let created: Vec<String> = outcome
.commands
.iter()
.zip(&outcome.statuses)
.filter(|(command, status)| status.is_ok() && !command.is_delete())
.map(|(command, _)| command.name.to_string())
.collect();
if !created.is_empty() {
if let Err(err) = stores.adopt_default_branch_if_unborn(&request.repo.path(), &created) {
tracing::warn!(
target: TARGET_GIT,
event = "git.default_branch.adoption_failed",
repo = %request.repo.key(),
error = format!("{err:#}"),
"the push landed, but this repository's HEAD names a branch that does not \
exist and adopting a pushed one failed; it cannot be cloned until \
`set-default-branch` is run against it"
);
}
}
let objects_pushed: &[ObjectId] = outcome.new_objects.as_deref().unwrap_or(&[]);
let objects_unknown = outcome.new_objects.as_ref().err().map(String::as_str);
let (wire_bytes, wire_bytes_unknown) = measured(
outcome.pack_bytes,
"the ingest did not report how many packfile bytes were read off the wire",
);
let (pack_bytes, pack_bytes_unknown) = measured_pack_bytes(&outcome);
if refs_rejected == 0 && outcome.unpack.is_ok() {
metrics.add_pushes_accepted(1);
} else {
metrics.add_pushes_rejected(1);
}
tracing::info!(
target: TARGET_GIT,
event = "git.receive_pack.served",
refs_ok,
refs_rejected,
atomic = outcome.atomic,
unpack_ok = outcome.unpack.is_ok(),
objects = objects_pushed.len(),
objects_unknown,
wire_bytes,
wire_bytes_unknown,
pack_bytes,
pack_bytes_unknown,
push_options = outcome.push_options.len(),
elapsed_ms = started.elapsed().as_millis() as u64,
"served git-receive-pack"
);
let attested_objects = outcome.new_objects.as_deref().map_err(String::as_str);
if let Some(attestation) = attestation_for(
request,
&outcome,
attested_objects,
&fingerprint,
&credential,
) {
tracing::info!(
target: TARGET_GIT,
event = "git.attestation",
refs = attestation.ref_updates.len(),
objects = attestation.new_object_count,
oids_unknown = (!attestation.new_object_oids_unknown.is_empty())
.then_some(attestation.new_object_oids_unknown.as_str()),
pack = %attestation.pack_hash,
key_fingerprint = %attestation.key_fingerprint,
credential = %attestation.credential,
"push attestation"
);
if let Err(err) = attestations.append(&attestation) {
tracing::error!(
target: TARGET_GIT,
path = %attestations.path().display(),
%err,
"the push attestation could not be appended to the log; the push itself stands"
);
}
if let Some(bus) = bus {
bus.publish(event::Payload::PushAttestation(attestation));
}
}
Ok(())
}
fn measured(value: Option<u64>, why: &str) -> (Option<u64>, Option<String>) {
match value {
Some(bytes) => (Some(bytes), None),
None => (None, Some(why.to_owned())),
}
}
fn measured_pack_bytes(outcome: &PushOutcome) -> (Option<u64>, Option<String>) {
let Some(pack) = outcome.pack.as_ref() else {
return (Some(0), None);
};
let Some(path) = pack.data_path.as_deref() else {
return match pack.stored_bytes {
Some(bytes) => (Some(bytes), None),
None => (
None,
Some(format!(
"pack {} was indexed without a directory and the store reported no size, so \
there is nothing to measure",
pack.pack_id
)),
),
};
};
match std::fs::metadata(path) {
Ok(meta) => (Some(meta.len()), None),
Err(err) => (
None,
Some(format!("{} could not be stat'ed: {err}", path.display())),
),
}
}
fn attested_object_count(
new_objects: std::result::Result<&[ObjectId], &str>,
outcome: &PushOutcome,
) -> u64 {
match new_objects {
Ok(ids) => ids.len() as u64,
Err(_) => outcome
.pack
.as_ref()
.map(|pack| u64::from(pack.num_objects))
.unwrap_or(0),
}
}
fn attestation_for(
request: &ServiceRequest,
outcome: &PushOutcome,
new_objects: std::result::Result<&[ObjectId], &str>,
fingerprint: &str,
credential: &str,
) -> Option<PushAttestation> {
let ref_updates: Vec<gunnar_proto::RefUpdate> = outcome
.commands
.iter()
.zip(&outcome.statuses)
.filter(|(_, status)| status.is_ok())
.map(|(command, _)| gunnar_proto::RefUpdate {
ref_name: command.name.to_string(),
old_oid: command.old.to_string(),
new_oid: command.new.to_string(),
})
.collect();
if ref_updates.is_empty() {
return None;
}
let (pack_hash, pack_bytes) = match &outcome.pack {
Some(pack) => {
let bytes = measured_pack_bytes(outcome)
.0
.or(outcome.pack_bytes)
.unwrap_or(0);
(pack.pack_id.to_string(), bytes)
}
None => (String::new(), 0),
};
let new_object_count = attested_object_count(new_objects, outcome);
let (new_object_oids, new_object_oids_unknown) = match new_objects {
Ok(ids) => (ids.iter().map(ObjectId::to_string).collect(), String::new()),
Err(reason) => (Vec::new(), reason.to_owned()),
};
let received_at_unix_ms = gunnar_grpc::bus::now_unix_ms();
Some(PushAttestation {
attestation_id: format!(
"{}@{received_at_unix_ms}/{}",
request.repo.key(),
if pack_hash.is_empty() {
"no-pack"
} else {
&pack_hash
}
),
repo: Some(RepoPath::new(&request.repo.namespace, &request.repo.name)),
principal_id: request.principal.id.clone(),
principal_name: request.principal.name.clone(),
key_fingerprint: fingerprint.to_owned(),
credential: credential.to_owned(),
hash_kind: crate::control_plane::hash_kind(request.repo.hash_kind) as i32,
ref_updates,
new_object_count,
new_object_oids,
new_object_oids_unknown,
new_object_bytes: pack_bytes,
pack_hash,
received_at_unix_ms,
server_agent: gunnar_proto::AGENT.to_owned(),
transport: "ssh".to_owned(),
})
}
fn session_key_fingerprint(request: &ServiceRequest) -> String {
match &request.key_fingerprint {
Some(fingerprint) => fingerprint.as_str().to_owned(),
None => {
tracing::debug!(
target: TARGET_GIT,
principal = %request.principal.id,
"no key fingerprint on this request: the session authenticated anonymously"
);
String::new()
}
}
}
fn session_credential(request: &ServiceRequest) -> String {
match (&request.key_fingerprint, &request.credential) {
(Some(fingerprint), _) => format!("key:{}", fingerprint.as_str()),
(None, Some(credential)) => credential.clone(),
(None, None) => String::new(),
}
}
#[derive(Clone)]
pub(crate) struct PackFrame {
log: Arc<gunnar_core::txlog::Log>,
repo: String,
hash: String,
}
impl PackFrame {
pub(crate) fn of(stores: &crate::stores::ServerStores, repo: &gunnar_core::Repo) -> PackFrame {
PackFrame {
log: Arc::clone(stores.txlog()),
repo: repo.key(),
hash: repo.hash_kind.to_string(),
}
}
fn append(&self, bytes: &[u8]) -> anyhow::Result<()> {
let meta = gunnar_core::txlog::Meta::Pack {
repo: self.repo.clone(),
hash: self.hash.clone(),
};
self.log
.append(gunnar_core::txlog::Kind::Pack, &meta, bytes)?;
Ok(())
}
}
pub(crate) struct PushIngest<'a> {
git_dir: PathBuf,
hash_kind: gix_hash::Kind,
engine: crate::storage::Engine,
objects: crate::storage::ObjectSet,
watching: Option<&'a dyn git_storage_trait::PackProgress>,
frame: Option<PackFrame>,
encrypted: Option<&'a dyn git_storage_trait::GitServe>,
space: Option<Arc<gunnar_ssh::SpaceLedger>>,
}
impl<'a> PushIngest<'a> {
pub(crate) fn new(
git_dir: PathBuf,
hash_kind: gix_hash::Kind,
engine: crate::storage::Engine,
objects: crate::storage::ObjectSet,
watching: Option<&'a dyn git_storage_trait::PackProgress>,
frame: Option<PackFrame>,
) -> Self {
PushIngest {
git_dir,
hash_kind,
engine,
objects,
watching,
frame,
encrypted: None,
space: None,
}
}
pub(crate) fn with_space(mut self, space: Option<Arc<gunnar_ssh::SpaceLedger>>) -> Self {
self.space = space;
self
}
fn ingest_engine(&self, pack: &mut dyn BufRead) -> gunnar_wire::Result<PackReceipt> {
let hold = self.space.as_ref().map(|ledger| {
ledger.hold(if self.frame.is_some() {
gunnar_ssh::PUSH_COPIES
} else {
gunnar_ssh::PUSH_COPIES - 1
})
});
let mut gated = None;
let reader: &mut dyn BufRead = match &hold {
Some(hold) => gated.insert(crate::push_space::Gated::new(
pack,
hold,
crate::storage::SPOOL_ABOVE as u64,
)),
None => pack,
};
let hook = |bytes: &[u8]| -> anyhow::Result<()> {
#[cfg(feature = "engine-znippy")]
if let Some(repo) = self.encrypted {
let checked = crate::encrypted_quarantine::check(bytes, self.hash_kind, repo)?;
tracing::info!(
target: TARGET_STORE,
event = "store.encrypted.quarantine_passed",
objects = checked,
"an encrypted repository's pushed pack passed its check in quarantine; it is framed and stored now"
);
}
match &self.frame {
Some(frame) => {
frame.append(bytes)?;
if let Some(hold) = &hold {
hold.realize(bytes.len() as u64);
}
Ok(())
}
None => Ok(()),
}
};
let hooked = self.frame.is_some() || self.encrypted.is_some();
let _framed = self.frame.as_ref().map(|f| f.log.mutation());
let stored = self.engine.ingest_push(
self.git_dir.as_path(),
self.hash_kind,
reader,
self.objects,
self.watching,
hooked.then_some(&hook as &dyn Fn(&[u8]) -> anyhow::Result<()>),
);
let consumed_by_cut = gated
.as_ref()
.map(|g| (g.cut().map(str::to_owned), g.consumed()));
drop(gated);
if let Some((Some(line), sent)) = consumed_by_cut.clone() {
tracing::warn!(
target: TARGET_STORE,
event = "store.push.no_room",
pack_bytes = sent,
refusal = %line,
"a pushed pack was refused for room mid-receive: it did not fit beside the \
pushes already arriving; its spool is deleted and nothing of it was kept"
);
return Err(gunnar_wire::Error::Refused(line));
}
let received = stored
.map_err(|err| {
if let Some(hold) = &hold {
let freed = consumed_by_cut.as_ref().map_or(0, |(_, sent)| *sent);
if crate::push_space::is_space_failure(&err, || hold.at_the_floor(freed)) {
let line = hold.full_now();
tracing::warn!(
target: TARGET_STORE,
event = "store.push.disk_full",
error = %format!("{err:#}"),
refusal = %line,
"the store failed a pushed pack because the data set filled; told by name"
);
return gunnar_wire::Error::Refused(line);
}
}
#[cfg(feature = "engine-znippy")]
if let Some(refused) = crate::encrypted_quarantine::refusal_in(&err) {
tracing::info!(
target: TARGET_STORE,
event = "store.encrypted.quarantine_refused",
reason = %refused,
"an encrypted repository's pushed pack was refused in quarantine: nothing was framed or stored"
);
return gunnar_wire::Error::Refused(refused);
}
gunnar_wire::Error::Store(gunnar_store::Error::backend(format!(
"storing the pushed pack: {err:#}"
)))
})?;
let pack_id = gix_hash::ObjectId::try_from(received.pack_id.as_slice()).map_err(|_| {
gunnar_wire::Error::Store(gunnar_store::Error::backend(format!(
"the stored pack's trailer is {} bytes, which is not a {} object id",
received.pack_id.len(),
self.hash_kind
)))
})?;
tracing::info!(
target: TARGET_STORE,
event = "store.znippy.ingested",
objects = received.num_objects,
bytes = received.pack_bytes,
elapsed_ms = received.elapsed_ms,
object_set = received.new_oids.is_some(),
"the push was stored in the znippy-git store; no packfile was written"
);
let new_objects = match received.new_oids {
Some(oids) => oids
.iter()
.map(|raw| {
gix_hash::ObjectId::try_from(raw.as_slice())
.map_err(|_| format!("the store returned a {}-byte object id", raw.len()))
})
.collect::<std::result::Result<Vec<_>, _>>(),
None => Err(
"the policy has no gate over objects, so the object set was not \
enumerated"
.to_owned(),
),
};
Ok(PackReceipt {
indexed: gunnar_wire::pack::IndexedPack {
num_objects: received.num_objects,
pack_id,
index_id: gix_hash::ObjectId::null(self.hash_kind),
pack_version: gunnar_wire::pack::PackVersion::from_u32(received.version),
hash_kind: self.hash_kind,
index_path: None,
data_path: None,
keep_path: None,
stored_bytes: Some(received.pack_bytes),
},
new_objects,
})
}
}
impl gunnar_wire::PackIngest for PushIngest<'_> {
fn ingest(&self, pack: &mut dyn BufRead) -> gunnar_wire::Result<PackReceipt> {
self.ingest_engine(pack)
}
}
fn was_forced(git: &dyn GitServe, command: &RefCommand) -> bool {
if command.is_create() || command.is_delete() {
return false;
}
match gunnar_policy::ancestry::is_descendant_of(
git,
&command.new,
&command.old,
gunnar_policy::ancestry::DEFAULT_ANCESTRY_BUDGET,
) {
Ok(descends) => !descends,
Err(err) => {
tracing::warn!(
target: TARGET_POLICY,
ref_name = %command.name,
old = %command.old,
new = %command.new,
%err,
"the ancestry walk behind RefUpdated.forced could not be completed; the update \
is published as forced, because `not forced` is a claim this could not make"
);
true
}
}
}
#[allow(clippy::too_many_arguments)]
fn publish(
bus: &EventBus,
request: &ServiceRequest,
command: &RefCommand,
status: &RefStatus,
atomic: bool,
fingerprint: &str,
credential: &str,
gates: &HashMap<String, String>,
forced: bool,
) {
let repo = Some(RepoPath::new(&request.repo.namespace, &request.repo.name));
let payload = match status {
RefStatus::Ok if command.is_delete() => event::Payload::RefDeleted(RefDeleted {
repo,
ref_name: command.name.to_string(),
old_oid: command.old.to_string(),
principal_id: request.principal.id.clone(),
key_fingerprint: fingerprint.to_owned(),
credential: credential.to_owned(),
}),
RefStatus::Ok => event::Payload::RefUpdated(RefUpdated {
repo,
ref_name: command.name.to_string(),
old_oid: command.old.to_string(),
new_oid: command.new.to_string(),
principal_id: request.principal.id.clone(),
key_fingerprint: fingerprint.to_owned(),
credential: credential.to_owned(),
forced,
}),
RefStatus::Rejected(reason) => event::Payload::PushRejected(PushRejected {
repo,
ref_name: command.name.to_string(),
gate: gates
.get(&command.name.to_string())
.cloned()
.unwrap_or_default(),
reason: reason.clone(),
principal_id: request.principal.id.clone(),
whole_push_rejected: atomic,
}),
};
bus.publish(payload);
}
struct PolicyGate {
engine: PolicyEngine,
unreadable: Option<String>,
principal: gunnar_policy::Principal,
repo: RepoId,
keys: Box<dyn KeyDirectory>,
metrics: Arc<ServerMetrics>,
policy_path: PathBuf,
refused_by: Mutex<HashMap<String, String>>,
}
pub(crate) fn policy_principal_kind(
kind: gunnar_core::PrincipalKind,
) -> gunnar_policy::PrincipalKind {
match kind {
gunnar_core::PrincipalKind::User => gunnar_policy::PrincipalKind::User,
gunnar_core::PrincipalKind::Machine => gunnar_policy::PrincipalKind::Machine,
gunnar_core::PrincipalKind::Deploy => gunnar_policy::PrincipalKind::Deploy,
gunnar_core::PrincipalKind::Group => {
tracing::error!(
target: gunnar_core::TARGET_AUDIT,
event = "policy.gate.group_push",
"a GROUP principal reached the pre-receive gate. A group holds no keys and \
cannot authenticate, so this cannot happen unless the grant check has been \
bypassed. Treating it as a machine identity for the gate; investigate \
immediately."
);
debug_assert!(
false,
"a group principal reached the pre-receive gate (punkt 9)"
);
gunnar_policy::PrincipalKind::Machine
}
gunnar_core::PrincipalKind::Anonymous => {
tracing::error!(
target: gunnar_core::TARGET_AUDIT,
event = "policy.gate.anonymous_push",
"an ANONYMOUS principal reached the pre-receive gate. Anonymous access is \
read-only and the ACL refuses it Write, so this cannot happen unless the \
grant check has been bypassed. Treating it as a machine identity for the \
gate; investigate immediately."
);
debug_assert!(false, "an anonymous principal reached the pre-receive gate");
gunnar_policy::PrincipalKind::Machine
}
}
}
impl PolicyGate {
fn open(
git_dir: &Path,
request: &ServiceRequest,
stores: &ServerStores,
metrics: Arc<ServerMetrics>,
) -> Self {
let policy_path = git_dir.join(POLICY_FILE);
let (config, unreadable) = match load_policy(stores, &request.repo, &policy_path) {
Ok(config) => (config, None),
Err(why) => (PolicyConfig::default(), Some(why)),
};
let mut gates = config.gates();
if request.repo.encrypted {
gates.push(Box::new(gunnar_policy::EncryptedGate::new()));
}
PolicyGate {
engine: PolicyEngine::new(gates),
unreadable,
principal: gunnar_policy::Principal {
id: request.principal.id.clone(),
name: request.principal.name.clone(),
kind: policy_principal_kind(request.principal.kind),
},
repo: RepoId::new(&request.repo.namespace, &request.repo.name),
keys: Box::new(key_directory(stores)),
metrics,
policy_path,
refused_by: Mutex::new(HashMap::new()),
}
}
fn gate_that_refused(&self) -> HashMap<String, String> {
self.refused_by
.lock()
.expect("the refusing-gate lock")
.clone()
}
}
impl PushGate for PolicyGate {
fn check(
&self,
command: &RefCommand,
facts: &PushFacts<'_>,
) -> std::result::Result<(), String> {
let span = tracing::debug_span!(
target: TARGET_POLICY,
"policy.gate",
ref_name = %command.name,
);
let _entered = span.enter();
let started = std::time::Instant::now();
if let Some(why) = &self.unreadable {
self.metrics.add_gate_refusals(1);
tracing::error!(
target: TARGET_POLICY,
event = "policy.gate.unreadable",
ref_name = %command.name,
%why,
"the policy for this repository could not be read, so every gate refuses"
);
return Err(format!(
"gunnar could not read the policy for this repository, so it refuses this push \
rather than serving it with no gates at all: {why}. Reads are unaffected; fix \
the document (or `gunnar control policy set-protection`) and push again."
));
}
if self.engine.is_empty() {
return Ok(());
}
let new_objects = match facts.new_objects {
Ok(ids) => ids,
Err(_) if !self.engine.reads_new_objects() => &[],
Err(reason) => {
self.metrics.add_gate_refusals(1);
tracing::error!(
target: TARGET_POLICY,
event = "policy.gate.undecidable",
%reason,
"the pushed objects could not be enumerated, so every configured gate refuses"
);
return Err(format!(
"gunnar could not enumerate the pushed objects, so the policy gates in {} \
could not decide: {reason}",
self.policy_path.display()
));
}
};
let updates = [RefUpdate::from_wire(
command.name.clone(),
command.old,
command.new,
)];
let mut ctx = PushContext::new(
&self.principal,
&self.repo,
&updates,
new_objects,
facts.objects,
self.keys.as_ref(),
);
if let Some(bytes) = facts.pack_bytes {
ctx = ctx.with_pack_bytes(bytes);
}
ctx = ctx.with_push_options(facts.push_options);
let decision = self.engine.evaluate(&ctx);
let elapsed_ms = started.elapsed().as_millis() as u64;
if decision.is_allowed() {
tracing::debug!(
target: TARGET_POLICY,
event = "policy.gate.allowed",
gates = self.engine.gate_names().len(),
new_objects = new_objects.len(),
elapsed_ms,
"every gate allowed this reference"
);
Ok(())
} else {
self.metrics.add_gate_refusals(1);
if let Some(first) = decision.rejections.first() {
tracing::warn!(
target: TARGET_POLICY,
event = "policy.gate.refused",
gate = %first.gate,
reason = %first.reason,
refused_by_gates = decision.rejections.len(),
elapsed_ms,
"a policy gate refused this reference"
);
self.refused_by
.lock()
.expect("the refusing-gate lock")
.insert(command.name.to_string(), first.gate.clone());
} else {
tracing::error!(
target: TARGET_POLICY,
event = "policy.gate.refused",
gate = "",
elapsed_ms,
"the policy engine refused a reference without naming a gate"
);
}
Err(decision.summary())
}
}
}
pub(crate) fn load_policy(
stores: &ServerStores,
repo: &gunnar_core::Repo,
path: &Path,
) -> std::result::Result<PolicyConfig, String> {
match stores.policies().get(&repo.path()) {
Ok(Some(document)) => match PolicyConfig::from_json(&document) {
Ok(config) => return Ok(config),
Err(err) => {
tracing::error!(
target: TARGET_POLICY,
repo = %repo.key(),
source = "control-store",
%err,
"the stored policy document did not parse; pushes to this repository are \
refused until it does"
);
return Err(format!("the stored policy document did not parse: {err}"));
}
},
Ok(None) => {}
Err(err) => {
tracing::error!(
target: TARGET_POLICY,
repo = %repo.key(),
%err,
"the policy store could not be read; pushes to this repository are refused \
rather than gated by a file that may be older than what an operator \
configured"
);
return Err(format!("the policy store could not be read: {err}"));
}
}
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(PolicyConfig::default())
}
Err(err) => {
tracing::error!(
target: TARGET_POLICY,
path = %path.display(),
%err,
"the policy document could not be read; pushes to this repository are refused"
);
return Err(format!(
"the policy document at {} could not be read: {err}",
path.display()
));
}
};
match PolicyConfig::from_json(&text) {
Ok(config) => Ok(config),
Err(err) => {
tracing::error!(
target: TARGET_POLICY,
path = %path.display(),
%err,
"the policy document did not parse; pushes to this repository are refused until \
it does"
);
Err(format!(
"the policy document at {} did not parse: {err}",
path.display()
))
}
}
}
pub(crate) fn key_directory(stores: &ServerStores) -> StaticKeyDirectory {
let mut directory = StaticKeyDirectory::new();
let keys = match stores.auth().signing_keys() {
Ok(keys) => keys,
Err(err) => {
tracing::error!(
target: TARGET_POLICY,
%err,
"the identity table could not be read for signature verification"
);
return directory;
}
};
for key in keys {
if !ssh_verifiable_kind(&key.algo) {
tracing::debug!(target: TARGET_POLICY, algo = %key.algo, fingerprint = %key.fingerprint_sha256, "not an ssh key kind; not a signature verifier");
continue;
}
match gunnar_core::ssh_key::PublicKey::from_openssh(&key.openssh) {
Ok(parsed) => {
directory.add(&key.principal_id, parsed);
}
Err(err) => tracing::warn!(
target: TARGET_POLICY,
fingerprint = %key.fingerprint_sha256,
%err,
"a registered key did not parse and cannot verify a signature"
),
}
}
directory
}
pub fn ssh_verifiable_kind(algo: &str) -> bool {
algo.starts_with("ssh-") || algo.starts_with("ecdsa-") || algo.starts_with("sk-")
}
#[cfg(test)]
mod tests {
#[test]
fn a_build_without_the_quarantine_refuses_an_encrypted_push_by_name() {
let line = super::unchecked_encrypted_push(true, false).expect("refused");
assert!(
line.contains("encrypted") && line.contains("this Gunnar"),
"{line}"
);
assert_eq!(
super::unchecked_encrypted_push(true, true),
None,
"the quarantine checks it"
);
assert_eq!(
super::unchecked_encrypted_push(false, false),
None,
"a plain repository"
);
}
use super::*;
use gunnar_wire::pack::{IndexedPack, PackVersion};
#[test]
fn the_signature_directory_takes_ssh_kinds_and_skips_the_x509_row_by_name() {
for ssh in [
"ssh-ed25519",
"ssh-rsa",
"ecdsa-sha2-nistp256",
"sk-ssh-ed25519@openssh.com",
"sk-ecdsa-sha2-nistp256@openssh.com",
] {
assert!(ssh_verifiable_kind(ssh), "{ssh}");
}
assert!(
!ssh_verifiable_kind("x509"),
"the bootstrap certificate is not tried as an ssh key"
);
assert!(!ssh_verifiable_kind(""));
}
fn outcome_with(pack: Option<IndexedPack>) -> PushOutcome {
PushOutcome {
commands: Vec::new(),
statuses: Vec::new(),
unnameable: Vec::new(),
push_options: Vec::new(),
pack,
pack_bytes: Some(0),
new_objects: Ok(Vec::new()),
atomic: false,
unpack: Ok(()),
}
}
fn indexed(data_path: Option<std::path::PathBuf>) -> IndexedPack {
indexed_with(data_path, None)
}
fn indexed_with(
data_path: Option<std::path::PathBuf>,
stored_bytes: Option<u64>,
) -> IndexedPack {
IndexedPack {
num_objects: 1,
pack_id: ObjectId::empty_blob(gix_hash::Kind::Sha1),
index_id: ObjectId::empty_blob(gix_hash::Kind::Sha1),
pack_version: PackVersion::V2,
hash_kind: gix_hash::Kind::Sha1,
index_path: None,
data_path,
keep_path: None,
stored_bytes,
}
}
#[test]
fn a_pack_with_no_file_is_measured_by_the_store_that_stored_it() {
let (bytes, why) = measured_pack_bytes(&outcome_with(Some(indexed_with(None, Some(4096)))));
assert_eq!(
bytes,
Some(4096),
"the store's own count is the measurement"
);
assert_eq!(
why, None,
"a measured pack carries no reason for not being measured"
);
let (bytes, why) = measured_pack_bytes(&outcome_with(Some(indexed_with(None, None))));
assert_eq!(
bytes, None,
"neither a file nor a count is genuinely unknown"
);
assert!(
why.as_deref().unwrap_or_default().contains("no size"),
"and the reason must say the store reported no size, not merely that there is no \
directory: {why:?}"
);
}
#[test]
fn a_pack_that_cannot_be_stated_reports_no_number_and_names_the_path() {
let missing = std::path::PathBuf::from("/nonexistent/gunnar/pack-deadbeef.pack");
let outcome = outcome_with(Some(indexed(Some(missing.clone()))));
let (bytes, why) = measured_pack_bytes(&outcome);
assert_eq!(
bytes, None,
"a pack whose bytes could not be read reported {bytes:?}; the only honest answer is \
no number at all, because 0 is a legal pack size for a deletions-only push"
);
let why = why.expect("an unreadable pack must say why it is unreadable");
assert!(
why.contains(missing.to_str().unwrap()),
"the reason must name the path an operator has to go and look at, got {why:?}"
);
}
#[test]
fn a_deletions_only_push_reports_a_measured_zero_and_no_reason() {
let (bytes, why) = measured_pack_bytes(&outcome_with(None));
assert_eq!(
bytes,
Some(0),
"a push with no pack carries zero pack bytes"
);
assert_eq!(why, None, "zero bytes is a measurement, not an absence");
}
#[test]
fn the_object_count_reads_the_reason_not_the_length() {
let mut pack = indexed(None);
pack.num_objects = 3;
let three = outcome_with(Some(pack));
let none = outcome_with(None);
let unknown = Err("the policy has no gate over objects");
assert_eq!(attested_object_count(unknown, &three), 3);
assert_eq!(attested_object_count(unknown, &none), 0);
assert_eq!(
attested_object_count(Ok(&[]), &three),
0,
"an enumerated empty set is a MEASURED zero and must not be overruled by the pack"
);
let oid = gix_hash::ObjectId::null(gix_hash::Kind::Sha1);
assert_eq!(attested_object_count(Ok(&[oid, oid]), &none), 2);
}
#[test]
fn a_pack_never_written_to_disk_reports_no_number() {
let (bytes, why) = measured_pack_bytes(&outcome_with(Some(indexed(None))));
assert_eq!(bytes, None);
assert!(
why.expect("must say why").contains("without a directory"),
"the reason must distinguish 'never written' from 'written and unreadable'"
);
}
#[test]
fn a_pack_on_disk_reports_the_bytes_the_file_actually_holds() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = dir.path().join("pack-x.pack");
std::fs::write(&path, b"1234567").expect("write");
let (bytes, why) = measured_pack_bytes(&outcome_with(Some(indexed(Some(path)))));
assert_eq!(bytes, Some(7), "the length must come off the filesystem");
assert_eq!(why, None);
}
#[test]
fn an_uncounted_wire_is_not_a_wire_that_carried_nothing() {
let (bytes, why) = measured(None, "the ingest did not report");
assert_eq!(bytes, None, "an uncounted wire must not render as 0 bytes");
assert_eq!(why.as_deref(), Some("the ingest did not report"));
let (bytes, why) = measured(Some(0), "unused");
assert_eq!(bytes, Some(0), "a counted zero stays a zero");
assert_eq!(why, None, "a counted zero carries no reason");
}
#[test]
fn the_signature_gate_is_handed_no_expired_key() {
const EXPIRED_KEY: &str = "ssh-ed25519 \
AAAAC3NzaC1lZDI1NTE5AAAAIMW4aqVr75w+3DWorOCbfwl5XCDCq1yCQ6puzU4qzol2 gunnar-kat";
let minted = gunnar_core::mint_ed25519().expect("mint a key");
let live_key = gunnar_core::ssh_key::PrivateKey::from_openssh(&minted)
.expect("parse the minted key")
.public_key()
.to_openssh()
.expect("render the public half");
let dir = tempfile::tempdir().expect("a temp dir");
let stores = ServerStores::open(dir.path()).expect("open the stores");
for (id, key, expires) in [
("stale", EXPIRED_KEY, Some(1_000)),
("live", live_key.as_str(), None),
] {
stores
.auth()
.add_principal(
gunnar_core::Principal::new(id, id, gunnar_core::PrincipalKind::Machine)
.expect("a valid id"),
)
.expect("add the principal");
stores
.auth()
.register_key(id, key, expires)
.expect("register the key");
}
let directory = key_directory(&stores);
assert!(
directory.keys_for("stale").is_empty(),
"an expired key must not be offered to the signature gate"
);
assert_eq!(
directory.keys_for("live").len(),
1,
"an unexpired key must still be offered, or this test proves nothing"
);
assert_eq!(
directory.all_keys().len(),
1,
"exactly the unexpired key, and nothing else, reaches the gate"
);
}
}