Files
buzz/crates/buzz-backend-kubernetes/src/image.rs
Tyler 318fbf896e fix(security): bump nostr crates for RUSTSEC-2026-0225..0232 + default sprig image to published digest (#4392)
## 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>
2026-08-02 16:17:55 -04:00

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}");
}
}
}