From dbb2ca43936a60f65b0a62cdf36dc7fa4a9d20cb Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 23 May 2026 01:05:58 +0200 Subject: [PATCH 1/2] forge: upload hyperhive logo as the core user's avatar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'core' Forgejo user (hive-c0re's identity for commits in core/meta + agent-configs/*) was showing the default hash identicon. Adds a one-shot ensure_core_avatar in the ensure_all bootstrap that POSTs the branding PNG to the admin avatar API and writes a marker file (CORE_AVATAR_MARKER) so subsequent startups skip the call (delete the marker to re-upload). Best-effort: a non-2xx is logged and swallowed, doesn't gate startup. PNG bytes baked in via include_bytes! from branding/hyperhive.png. Base64 is hand-rolled (one small image in one cold path, not worth a new workspace dep) with RFC 4648 §10 test vectors. Closes #320. --- hive-c0re/src/forge.rs | 96 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 974263b..51cd6c0 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -32,6 +32,16 @@ const TOKEN_NAME_PREFIX: &str = "hyperhive"; /// itself to push the meta repo + drive admin API calls (org /// creation, future webhook setup, etc.). Root-only. const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token"; +/// Marker that records whether `ensure_core_avatar` has successfully +/// uploaded the hyperhive logo as `core`'s avatar (issue #320). One-shot: +/// the upload runs once, the marker is written, subsequent startups skip +/// the call. Delete to force re-upload. +const CORE_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-core-avatar-set"; +/// Hyperhive logo bytes, baked into the daemon. Uploaded once via the +/// admin avatar API so the `core` Forgejo user shows the project mark +/// next to commits in `agent-configs/*`, `core/meta`, etc. instead of +/// the default hash identicon. +const CORE_AVATAR_PNG: &[u8] = include_bytes!("../../branding/hyperhive.png"); /// Forgejo org grouping every agent's applied config repo. Core is a /// site admin and reads + writes every repo here; agents are NOT /// members and the repos are private, so no agent — not even the one @@ -266,6 +276,89 @@ pub async fn ensure_user_for(name: &str) -> Result<()> { mint_and_persist_token(name, &token_path(name)).await } +/// Set `core`'s Forgejo avatar to the hyperhive logo once, then +/// remember it so subsequent startups don't re-upload (issue #320). +/// Best-effort — any non-2xx is logged at the caller; the project +/// runs fine with the default hash identicon. +async fn ensure_core_avatar(token: &str) -> Result<()> { + let marker = std::path::Path::new(CORE_AVATAR_MARKER); + if marker.exists() { + return Ok(()); + } + let body = format!( + r#"{{"image":"{}"}}"#, + base64_encode(CORE_AVATAR_PNG), + ); + let url = format!("{FORGE_HTTP}/api/v1/admin/users/core/avatar"); + let status = forge_http(reqwest::Method::POST, &url, token, &body).await?; + if !status.is_success() { + anyhow::bail!("set core avatar: HTTP {status}"); + } + if let Some(parent) = marker.parent() { + std::fs::create_dir_all(parent).ok(); + } + std::fs::write(marker, "").ok(); + tracing::info!("forge: set core user avatar to hyperhive logo"); + Ok(()) +} + +/// Standard base64 encode (RFC 4648 §4, with `=` padding). Used by +/// `ensure_core_avatar` to embed the hyperhive logo PNG in the +/// Forgejo admin avatar JSON body — the payload is one small image +/// in one cold path, so pulling in a base64 crate isn't worth the +/// dep churn. +fn base64_encode(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b0 = chunk[0]; + let b1 = chunk.get(1).copied().unwrap_or(0); + let b2 = chunk.get(2).copied().unwrap_or(0); + out.push(ALPHABET[(b0 >> 2) as usize] as char); + out.push(ALPHABET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); + if chunk.len() > 1 { + out.push(ALPHABET[(((b1 & 0x0F) << 2) | (b2 >> 6)) as usize] as char); + } else { + out.push('='); + } + if chunk.len() > 2 { + out.push(ALPHABET[(b2 & 0x3F) as usize] as char); + } else { + out.push('='); + } + } + out +} + +#[cfg(test)] +mod base64_tests { + use super::base64_encode; + + // RFC 4648 §10 test vectors. + #[test] + fn rfc4648_vectors() { + assert_eq!(base64_encode(b""), ""); + assert_eq!(base64_encode(b"f"), "Zg=="); + assert_eq!(base64_encode(b"fo"), "Zm8="); + assert_eq!(base64_encode(b"foo"), "Zm9v"); + assert_eq!(base64_encode(b"foob"), "Zm9vYg=="); + assert_eq!(base64_encode(b"fooba"), "Zm9vYmE="); + assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); + } + + #[test] + fn full_byte_range_roundtrip_length() { + // 256 bytes → 256 / 3 = 85 chunks + 1 leftover (chunks_of_3 walks 85 + // full + 1 padded), 86 chunks * 4 chars = 344. Cheap sanity check + // that the size calc + padding don't drift on a non-multiple input. + let bytes: Vec = (0..=255u8).collect(); + let encoded = base64_encode(&bytes); + assert_eq!(encoded.len(), 344); + assert!(encoded.ends_with('=')); + } +} + /// Ensure the bootstrap `core` admin user + a token at /// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo /// API calls (org creation now, meta-repo push later). Returns the @@ -591,6 +684,9 @@ pub async fn ensure_all() { if let Err(e) = ensure_repo("meta", token).await { tracing::warn!(error = ?e, "forge: ensure_repo core/meta failed"); } + if let Err(e) = ensure_core_avatar(token).await { + tracing::warn!(error = ?e, "forge: ensure_core_avatar failed"); + } } let Ok(containers) = crate::lifecycle::list().await else { tracing::warn!("forge: nixos-container list failed; skipping user sweep"); From ce539559d55350cb36cea464141ef5510c806476 Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 23 May 2026 01:15:16 +0200 Subject: [PATCH 2/2] forge: use base64 crate for avatar payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @mara on #328: the hand-rolled encoder was over-cautious. Swap for base64 = 0.22 from crates.io — a standard, widely-trusted dep, no maintenance surface to carry. Drops the 15-line encoder and its two RFC 4648 unit tests. --- Cargo.lock | 1 + Cargo.toml | 1 + hive-c0re/Cargo.toml | 1 + hive-c0re/src/forge.rs | 60 ++---------------------------------------- 4 files changed, 5 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1da33aa..3083bdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -585,6 +585,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "base64", "clap", "hive-fr0nt", "hive-sh4re", diff --git a/Cargo.toml b/Cargo.toml index f41eac0..a53b5b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ must_use_candidate = "allow" [workspace.dependencies] anyhow = "1" axum = { version = "0.8", features = ["ws"] } +base64 = "0.22" clap = { version = "4", features = ["derive"] } hive-fr0nt = { path = "hive-fr0nt" } hive-sh4re = { path = "hive-sh4re" } diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index 50afeb2..169a50e 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] anyhow.workspace = true axum.workspace = true +base64.workspace = true reqwest.workspace = true clap.workspace = true hive-fr0nt.workspace = true diff --git a/hive-c0re/src/forge.rs b/hive-c0re/src/forge.rs index 51cd6c0..392f5d1 100644 --- a/hive-c0re/src/forge.rs +++ b/hive-c0re/src/forge.rs @@ -20,6 +20,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; +use base64::Engine; use reqwest::StatusCode; use tokio::process::Command; @@ -287,7 +288,7 @@ async fn ensure_core_avatar(token: &str) -> Result<()> { } let body = format!( r#"{{"image":"{}"}}"#, - base64_encode(CORE_AVATAR_PNG), + base64::engine::general_purpose::STANDARD.encode(CORE_AVATAR_PNG), ); let url = format!("{FORGE_HTTP}/api/v1/admin/users/core/avatar"); let status = forge_http(reqwest::Method::POST, &url, token, &body).await?; @@ -302,63 +303,6 @@ async fn ensure_core_avatar(token: &str) -> Result<()> { Ok(()) } -/// Standard base64 encode (RFC 4648 §4, with `=` padding). Used by -/// `ensure_core_avatar` to embed the hyperhive logo PNG in the -/// Forgejo admin avatar JSON body — the payload is one small image -/// in one cold path, so pulling in a base64 crate isn't worth the -/// dep churn. -fn base64_encode(bytes: &[u8]) -> String { - const ALPHABET: &[u8; 64] = - b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); - for chunk in bytes.chunks(3) { - let b0 = chunk[0]; - let b1 = chunk.get(1).copied().unwrap_or(0); - let b2 = chunk.get(2).copied().unwrap_or(0); - out.push(ALPHABET[(b0 >> 2) as usize] as char); - out.push(ALPHABET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); - if chunk.len() > 1 { - out.push(ALPHABET[(((b1 & 0x0F) << 2) | (b2 >> 6)) as usize] as char); - } else { - out.push('='); - } - if chunk.len() > 2 { - out.push(ALPHABET[(b2 & 0x3F) as usize] as char); - } else { - out.push('='); - } - } - out -} - -#[cfg(test)] -mod base64_tests { - use super::base64_encode; - - // RFC 4648 §10 test vectors. - #[test] - fn rfc4648_vectors() { - assert_eq!(base64_encode(b""), ""); - assert_eq!(base64_encode(b"f"), "Zg=="); - assert_eq!(base64_encode(b"fo"), "Zm8="); - assert_eq!(base64_encode(b"foo"), "Zm9v"); - assert_eq!(base64_encode(b"foob"), "Zm9vYg=="); - assert_eq!(base64_encode(b"fooba"), "Zm9vYmE="); - assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); - } - - #[test] - fn full_byte_range_roundtrip_length() { - // 256 bytes → 256 / 3 = 85 chunks + 1 leftover (chunks_of_3 walks 85 - // full + 1 padded), 86 chunks * 4 chars = 344. Cheap sanity check - // that the size calc + padding don't drift on a non-multiple input. - let bytes: Vec = (0..=255u8).collect(); - let encoded = base64_encode(&bytes); - assert_eq!(encoded.len(), 344); - assert!(encoded.ends_with('=')); - } -} - /// Ensure the bootstrap `core` admin user + a token at /// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo /// API calls (org creation now, meta-repo push later). Returns the