#![doc(html_logo_url = "https://gunnar.rs/download/gunnar.webp", html_favicon_url = "https://gunnar.rs/download/favicon.svg")]
#[allow(clippy::doc_overindented_list_items, clippy::doc_lazy_continuation)]
mod generated {
tonic::include_proto!("gunnar.v1");
}
pub use generated::*;
pub const AGENT: &str = concat!("gunnar/", env!("CARGO_PKG_VERSION"));
pub const HEALTH_SERVING: &str = "serving";
pub const HEALTH_MAINTENANCE: &str = "maintenance";
pub fn null_oid(kind: HashKind) -> &'static str {
match kind {
HashKind::Sha256 => "0000000000000000000000000000000000000000000000000000000000000000",
HashKind::Sha1 | HashKind::Unspecified => "0000000000000000000000000000000000000000",
}
}
pub const MAX_COMPONENTS: usize = 4;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RepoPathError {
Empty,
NotTwoSegments,
EmptyNamespace,
EmptyRepo,
IllegalCharacter(&'static str),
TooManyComponents,
}
impl std::fmt::Display for RepoPathError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => f.write_str("repository path is empty"),
Self::NotTwoSegments => {
f.write_str("repository path must be exactly `<namespace>/<repo>`")
}
Self::EmptyNamespace => f.write_str("namespace is empty"),
Self::EmptyRepo => f.write_str("repository name is empty"),
Self::IllegalCharacter(what) => write!(f, "illegal in a repository path: {what}"),
Self::TooManyComponents => write!(
f,
"repository path has more than {MAX_COMPONENTS} components"
),
}
}
}
impl std::error::Error for RepoPathError {}
impl IssuerInfoResponse {
pub fn render(&self, pem: bool) -> String {
let mut out = String::new();
if self.has_issuer {
out.push_str(&format!(
"issuer\t{}\t{}\t{}\n",
self.subject, self.fingerprint, self.origin
));
if pem {
out.push_str(&self.cert_pem);
if !self.cert_pem.ends_with('\n') {
out.push('\n');
}
}
} else {
out.push_str("no issuer. `gunnar issuer create` mints one\n");
}
for ca in &self.trusted {
out.push_str(&format!(
"trusted\t{}\t{}\t{}\n",
ca.fingerprint,
if ca.is_issuer { "issuer" } else { "ca" },
ca.label
));
}
out
}
}
impl RepoPath {
pub fn new(namespace: impl Into<String>, repo: impl Into<String>) -> Self {
Self {
namespace: namespace.into(),
repo: repo.into(),
}
}
pub fn parse(s: &str) -> Result<Self, RepoPathError> {
let s = s.strip_suffix(".git").unwrap_or(s);
if s.is_empty() {
return Err(RepoPathError::Empty);
}
let Some((namespace, repo)) = s.rsplit_once('/') else {
return Err(RepoPathError::NotTwoSegments);
};
if namespace.is_empty() {
return Err(RepoPathError::EmptyNamespace);
}
if repo.is_empty() {
return Err(RepoPathError::EmptyRepo);
}
let path = Self::new(namespace, repo);
path.validate()?;
Ok(path)
}
pub fn validate(&self) -> Result<(), RepoPathError> {
if self.namespace.is_empty() {
return Err(RepoPathError::EmptyNamespace);
}
if self.repo.is_empty() {
return Err(RepoPathError::EmptyRepo);
}
if self.repo.contains('/') {
return Err(RepoPathError::NotTwoSegments);
}
let mut components = 0usize;
for segment in self.namespace.split('/').chain([self.repo.as_str()]) {
components += 1;
if segment.is_empty() {
return Err(RepoPathError::EmptyNamespace);
}
if segment == "." || segment == ".." {
return Err(RepoPathError::IllegalCharacter("`.` or `..`"));
}
if segment.contains('\0') {
return Err(RepoPathError::IllegalCharacter("a NUL byte"));
}
if segment.chars().any(char::is_control) {
return Err(RepoPathError::IllegalCharacter("a control character"));
}
if segment.contains('\\') {
return Err(RepoPathError::IllegalCharacter("a backslash"));
}
if segment.contains(':') {
return Err(RepoPathError::IllegalCharacter("a colon"));
}
}
if components > MAX_COMPONENTS {
return Err(RepoPathError::TooManyComponents);
}
Ok(())
}
}
impl std::fmt::Display for RepoPath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.namespace, self.repo)
}
}
impl Event {
pub fn kind_of(payload: &event::Payload) -> EventKind {
match payload {
event::Payload::RefUpdated(_) => EventKind::RefUpdated,
event::Payload::RefDeleted(_) => EventKind::RefDeleted,
event::Payload::PushRejected(_) => EventKind::PushRejected,
event::Payload::RepoCreated(_) => EventKind::RepoCreated,
event::Payload::RepoDeleted(_) => EventKind::RepoDeleted,
event::Payload::MirrorSynced(_) => EventKind::MirrorSynced,
event::Payload::KeyUsed(_) => EventKind::KeyUsed,
event::Payload::PushAttestation(_) => EventKind::PushAttestation,
event::Payload::Gap(_) => EventKind::Gap,
event::Payload::ReplicationFailed(_) => EventKind::ReplicationFailed,
event::Payload::PullRequestOpened(_) => EventKind::PullRequestOpened,
event::Payload::PullRequestMerged(_) => EventKind::PullRequestMerged,
event::Payload::PullRequestClosed(_) => EventKind::PullRequestClosed,
event::Payload::StreamOpened(_) => EventKind::StreamOpened,
event::Payload::VisibilityChanged(_) => EventKind::VisibilityChanged,
event::Payload::EncryptedChanged(_) => EventKind::EncryptedChanged,
}
}
pub fn new(seq: u64, at_unix_ms: i64, payload: event::Payload) -> Self {
Self {
seq,
at_unix_ms,
kind: Self::kind_of(&payload) as i32,
payload: Some(payload),
}
}
pub fn kind_matches_payload(&self) -> bool {
match &self.payload {
Some(payload) => self.kind == Self::kind_of(payload) as i32,
None => false,
}
}
pub fn repo_path(&self) -> Option<&RepoPath> {
match self.payload.as_ref()? {
event::Payload::RefUpdated(e) => e.repo.as_ref(),
event::Payload::RefDeleted(e) => e.repo.as_ref(),
event::Payload::PushRejected(e) => e.repo.as_ref(),
event::Payload::RepoCreated(e) => e.repo.as_ref()?.path.as_ref(),
event::Payload::RepoDeleted(e) => e.repo.as_ref(),
event::Payload::MirrorSynced(e) => e.repo.as_ref(),
event::Payload::KeyUsed(e) => e.repo.as_ref(),
event::Payload::PushAttestation(e) => e.repo.as_ref(),
event::Payload::Gap(_) => None,
event::Payload::ReplicationFailed(e) => e.repo.as_ref(),
event::Payload::PullRequestOpened(e) => e.repo.as_ref(),
event::Payload::PullRequestMerged(e) => e.repo.as_ref(),
event::Payload::PullRequestClosed(e) => e.repo.as_ref(),
event::Payload::StreamOpened(_) => None,
event::Payload::VisibilityChanged(e) => e.repo.as_ref(),
event::Payload::EncryptedChanged(e) => e.repo.as_ref(),
}
}
}
pub const TRUSTED_PREALLOC_BYTES: u64 = 8 << 20;
pub fn render_tunnel_answer(a: &AssignTunnelAddressResponse) -> String {
format!(
"wg_public_key {}\naddress {}\nchanged {}\ntunnel_up {}\ndetail {}\n",
a.wg_public_key,
if a.address.is_empty() { "-" } else { &a.address },
a.changed,
a.tunnel_up,
a.detail.replace('\n', " ")
)
}
pub fn parse_tunnel_answer(text: &str) -> std::result::Result<AssignTunnelAddressResponse, String> {
if let Some(why) = text.lines().find_map(|l| l.strip_prefix("error: ")) {
return Err(why.to_string());
}
let mut a = AssignTunnelAddressResponse::default();
for line in text.lines() {
let (k, v) = line.split_once(' ').unwrap_or((line, ""));
match k {
"wg_public_key" => a.wg_public_key = v.to_string(),
"address" => a.address = if v == "-" { String::new() } else { v.to_string() },
"changed" => a.changed = v == "true",
"tunnel_up" => a.tunnel_up = v == "true",
"detail" => a.detail = v.to_string(),
_ => {}
}
}
if a.wg_public_key.is_empty() {
return Err(format!("not a tunnel answer: {text:?}"));
}
Ok(a)
}
pub fn reserve_for(claimed: u64) -> usize {
claimed.min(TRUSTED_PREALLOC_BYTES) as usize
}
pub fn fmt_rate_bps(bps: u64) -> String {
let v = bps as f64;
if v >= 1e9 {
format!("{:.1} GB/s", v / 1e9)
} else if v >= 1e6 {
format!("{:.0} MB/s", v / 1e6)
} else {
format!("{:.0} kB/s", v / 1e3)
}
}
#[cfg(test)]
mod rate_tests {
#[test]
fn a_rate_reads_in_the_units_disks_are_sold_in() {
assert_eq!(super::fmt_rate_bps(812_000_000), "812 MB/s");
assert_eq!(super::fmt_rate_bps(1_900_000_000), "1.9 GB/s");
assert_eq!(super::fmt_rate_bps(512_000), "512 kB/s");
}
}
#[cfg(test)]
mod prealloc_tests {
use super::*;
#[test]
fn a_claimed_length_never_becomes_a_reservation_on_its_own() {
for claimed in [u64::MAX, u64::MAX / 2, 1 << 40, (8 << 20) + 1] {
let reserved = reserve_for(claimed);
assert!(reserved as u64 <= TRUSTED_PREALLOC_BYTES, "{claimed} reserved {reserved}");
let v: Vec<u8> = Vec::with_capacity(reserved);
assert!(v.capacity() >= reserved);
}
for claimed in [0u64, 1, 4096, 8 << 20] {
assert_eq!(reserve_for(claimed) as u64, claimed);
}
}
}