mirror of
https://github.com/block/buzz.git
synced 2026-11-12 04:12:21 +00:00
## What Two changes, both fallout/follow-up from #4289 landing: ### 1. Fix the Security job failing on main (lockfile-only) Eight RUSTSEC advisories published today against the nostr stack turned `cargo-deny check` advisories red on main ([failing run](https://github.com/block/buzz/actions/runs/30761611723/job/91533106673)). Not introduced by #4289 — the advisories landed upstream and any push to main today would have tripped them. - **RUSTSEC-2026-0225..0230** → `nostr` 0.44.6 → **0.44.7** (Debug output exposing NIP-46/NIP-60 credentials; wallet parsers accepting unauthenticated events; NIP-44/NIP-04/NIP-98 resource exhaustion; NIP-50 empty-filter panic) - **RUSTSEC-2026-0231..0232** → `nostr-relay-pool` 0.44.2 (root) / 0.44.1 (tauri) → **0.44.3** (auth-challenge memory exhaustion; processing of unverified relay events) Both workspace lockfiles bumped (`Cargo.lock`, `desktop/src-tauri/Cargo.lock`). No manifest changes. ### 2. Default the desktop GUI's sprig image to the published `ghcr.io/block/buzz-sprig` The first main-push after #4289 published the image publicly (package created 18:44Z, visibility `public`). The `config_schema()`'s `image` property now carries a `default`: ``` ghcr.io/block/buzz-sprig:sha-6530b58@sha256:17facfc7608d8ddb33bc056c9aaba1098f4ef6abe5655702fbfd7584d1f74d76 ``` **Why tag+digest, not tag:** the backend deliberately rejects tag-only references — the pod runs with the agent's nsec and tags are mutable pointers (`image.rs` §Image). The tag+digest form keeps the human-traceable `sha-6530b58` while the digest does the pinning; `image::parse` already normalizes it to the tagless canonical form, so create-intent fingerprints are identical to the bare-digest spelling. The digest is the **multi-arch manifest-list digest** (amd64+arm64), resolved via `docker buildx imagetools inspect`. **This is a UI prefill, not a baked fallback:** `image` stays in the schema's `required` list, an empty value still fails closed with a named field, and the desktop submits the value explicitly in `provider_config` (the `WhereToRunSection` probe seeds `providerConfig` from schema defaults) — so deploy fingerprints never depend on compiled-in provider state, and the spec's §K8s pod-reconciliation concern about baked-default divergence is not engaged. Module prose that said "no published image exists yet" is updated to match reality. No desktop code changes needed: the form already prefills from `properties[*].default` and submits seeded defaults. ## Testing - `cargo-deny check` at head: **advisories ok, bans ok, licenses ok, sources ok** (was: advisories FAILED) - `cargo test -p buzz-backend-kubernetes`: **158 passed** (154 lib + 4 wire), including new `schema_default_image_round_trips_through_parse` pinning the constant + its normalization, and the wire `info` test now asserting the default is present in the provider's real stdout response - Live provider probe: `{"op":"info"}` against the built binary returns the default in `config_schema.properties.image.default` with `required` unchanged (`["namespace","image"]`) - Full workspace test suite via pre-push hook: green (earlier direct `cargo test --workspace` run: sole failure was `api::mesh_demo::demo_join_forwarded_arm_round_trips_echo`, the documented pre-existing main flake — unrelated, fails on base) - Image existence verified against GHCR: `docker buildx imagetools inspect ghcr.io/block/buzz-sprig:sha-6530b58` resolves to the pinned manifest-list digest with linux/amd64 + linux/arm64 manifests --------- Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
185 lines
6.7 KiB
Rust
185 lines
6.7 KiB
Rust
//! Image reference validation (spec §Image).
|
|
//!
|
|
//! The object holding this reference runs with an nsec, so the reference must
|
|
//! be **immutable**. Registry tags are mutable pointers — Kubernetes itself
|
|
//! distinguishes them from digests for exactly this reason — so a tag-only
|
|
//! reference is rejected, not just `:latest`.
|
|
//!
|
|
//! There is no parse-time fallback: `image` is required, and its absence
|
|
//! fails closed with a named field. The published `ghcr.io/block/buzz-sprig`
|
|
//! digest is offered only as a schema `default` (a UI prefill the desktop
|
|
//! submits explicitly — see `config::DEFAULT_IMAGE`), so the create-intent
|
|
//! fingerprint never depends on compiled-in provider state.
|
|
|
|
/// A validated, digest-qualified image reference.
|
|
///
|
|
/// The inner string is always in canonical tagless form `name@sha256:<hex>`:
|
|
/// `name:tag@sha256:…` normalizes by dropping the tag, because the tag is
|
|
/// decorative once a digest pins the content, and leaving it in would make
|
|
/// two references to identical bytes produce different create-intent
|
|
/// fingerprints.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ImageRef(String);
|
|
|
|
impl ImageRef {
|
|
pub fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for ImageRef {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(&self.0)
|
|
}
|
|
}
|
|
|
|
/// Parse and normalize a user-supplied image reference.
|
|
pub fn parse(raw: &str) -> Result<ImageRef, String> {
|
|
let reference = raw.trim();
|
|
if reference.is_empty() {
|
|
return Err("provider_config.image is required: no image is assumed \
|
|
at parse time, so the digest-pinned image to run must be \
|
|
given explicitly"
|
|
.to_string());
|
|
}
|
|
|
|
let mut parts = reference.split('@');
|
|
let name_and_tag = parts.next().unwrap_or_default();
|
|
let digest = match (parts.next(), parts.next()) {
|
|
(Some(d), None) => d,
|
|
(None, _) => {
|
|
return Err(format!(
|
|
"provider_config.image {reference:?} is not digest-pinned: a \
|
|
tag is a mutable pointer, and this object runs with the \
|
|
agent's private key. Use name@sha256:<64 hex chars>"
|
|
))
|
|
}
|
|
(Some(_), Some(_)) => {
|
|
return Err(format!(
|
|
"provider_config.image {reference:?} contains more than one '@'"
|
|
))
|
|
}
|
|
};
|
|
|
|
let hex = digest.strip_prefix("sha256:").ok_or_else(|| {
|
|
format!("provider_config.image digest {digest:?} must start with 'sha256:'")
|
|
})?;
|
|
// Lowercase only: OCI canonicalizes digest hex, and accepting uppercase
|
|
// would let two spellings of one digest produce two fingerprints.
|
|
if hex.len() != 64
|
|
|| !hex
|
|
.chars()
|
|
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
|
|
{
|
|
return Err(format!(
|
|
"provider_config.image digest {digest:?} must be exactly 64 \
|
|
lowercase hex characters"
|
|
));
|
|
}
|
|
|
|
// Drop any tag: `name:tag@sha256:…` and `name@sha256:…` name the same
|
|
// bytes and must fingerprint identically. Only a *final* colon segment
|
|
// that isn't a port counts as a tag — `host:5000/name` has no tag.
|
|
let name = match name_and_tag.rfind(':') {
|
|
Some(colon) if !name_and_tag[colon + 1..].contains('/') => &name_and_tag[..colon],
|
|
_ => name_and_tag,
|
|
};
|
|
if name.is_empty() {
|
|
return Err(format!(
|
|
"provider_config.image {reference:?} has no repository name"
|
|
));
|
|
}
|
|
|
|
Ok(ImageRef(format!("{name}@sha256:{hex}")))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn accepts_digest_pinned_reference() {
|
|
let d = "a".repeat(64);
|
|
let r = parse(&format!("ghcr.io/block/buzz-sprig@sha256:{d}")).unwrap();
|
|
assert_eq!(r.as_str(), format!("ghcr.io/block/buzz-sprig@sha256:{d}"));
|
|
}
|
|
|
|
/// The normalization that keeps the fingerprint stable: two spellings of
|
|
/// the same bytes must produce one reference.
|
|
#[test]
|
|
fn strips_tag_from_tag_plus_digest_form() {
|
|
let d = "b".repeat(64);
|
|
let tagged = parse(&format!("ghcr.io/block/buzz-sprig:v1.2@sha256:{d}")).unwrap();
|
|
let plain = parse(&format!("ghcr.io/block/buzz-sprig@sha256:{d}")).unwrap();
|
|
assert_eq!(tagged, plain);
|
|
}
|
|
|
|
/// A registry port is not a tag. `host:5000/name` must keep its port.
|
|
#[test]
|
|
fn registry_port_is_not_mistaken_for_a_tag() {
|
|
let d = "c".repeat(64);
|
|
let r = parse(&format!("localhost:5000/buzz-sprig@sha256:{d}")).unwrap();
|
|
assert_eq!(r.as_str(), format!("localhost:5000/buzz-sprig@sha256:{d}"));
|
|
}
|
|
|
|
#[test]
|
|
fn port_and_tag_together_drops_only_the_tag() {
|
|
let d = "d".repeat(64);
|
|
let r = parse(&format!("localhost:5000/buzz-sprig:dev@sha256:{d}")).unwrap();
|
|
assert_eq!(r.as_str(), format!("localhost:5000/buzz-sprig@sha256:{d}"));
|
|
}
|
|
|
|
/// Wren's amendment: *every* tag-only reference is rejected, not just
|
|
/// `:latest`. A `sha-<gitsha>` tag is traceable but still movable.
|
|
#[test]
|
|
fn rejects_every_tag_only_reference() {
|
|
for bad in [
|
|
"ghcr.io/block/buzz-sprig:latest",
|
|
"ghcr.io/block/buzz-sprig:v1.2.3",
|
|
"ghcr.io/block/buzz-sprig:sha-abc1234",
|
|
"ghcr.io/block/buzz-sprig",
|
|
"localhost:5000/buzz-sprig",
|
|
] {
|
|
let err = parse(bad).unwrap_err();
|
|
assert!(err.contains("digest-pinned"), "for {bad:?} got: {err}");
|
|
}
|
|
}
|
|
|
|
/// Uppercase hex is a second spelling of one digest; accepting it would
|
|
/// let the same image fingerprint two ways.
|
|
#[test]
|
|
fn rejects_uppercase_digest_hex() {
|
|
let d = "A".repeat(64);
|
|
assert!(parse(&format!("img@sha256:{d}")).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_malformed_digests() {
|
|
let short = "a".repeat(63);
|
|
let long = "a".repeat(65);
|
|
let ok = "a".repeat(64);
|
|
for bad in [
|
|
format!("img@sha256:{short}"),
|
|
format!("img@sha256:{long}"),
|
|
format!("img@sha512:{ok}"),
|
|
format!("img@{ok}"),
|
|
format!("img@sha256:{}", "g".repeat(64)),
|
|
format!("img@sha256:{ok}@sha256:{ok}"),
|
|
format!("@sha256:{ok}"),
|
|
] {
|
|
assert!(parse(&bad).is_err(), "accepted {bad:?}");
|
|
}
|
|
}
|
|
|
|
/// Parsing has no fallback (the schema default is a UI prefill, not a
|
|
/// parse-time substitute), so an absent image is an error that names the
|
|
/// field rather than a silent fallback.
|
|
#[test]
|
|
fn empty_reference_names_the_field() {
|
|
for empty in ["", " "] {
|
|
let err = parse(empty).unwrap_err();
|
|
assert!(err.contains("provider_config.image"), "got: {err}");
|
|
}
|
|
}
|
|
}
|