From 419217fa4e6beeb434347f779569c0af496601b3 Mon Sep 17 00:00:00 2001 From: bfjelds Date: Thu, 8 Oct 2026 15:13:32 +0000 Subject: [PATCH 1/2] acl-agent: 3-tier COSI SHA-384 source (labels, then legacy hash) Nebraska manifests can now report the COSI metadata SHA-384 via two new label-based mechanisms, each falling back to the next: 1. A child element on . 2. A labels="{...}" JSON attribute on with the same key. 3. Fallback: the legacy hash attribute (current behavior, Omaha's sha1 field repurposed to carry this value). Adds wire::Package::cosi_sha384_base64() to resolve the value with this precedence, used when building PackageFile/PackageHash. A malformed labels JSON attribute falls through to the next tier instead of erroring. Each tier has a dedicated test asserting the real, decoded SHA-384 hex digest (not just the intermediate base64 string), confirming correctness end-to-end rather than just precedence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e5c68000-9b71-4be4-8f78-f1da0b1aaae9 --- .../src/core/nebraska/client.rs | 149 +++++++++++++++++- .../src/core/nebraska/wire.rs | 74 +++++++++ 2 files changed, 215 insertions(+), 8 deletions(-) diff --git a/crates/trident-acl-agent/src/core/nebraska/client.rs b/crates/trident-acl-agent/src/core/nebraska/client.rs index f54146baf..1af49aca7 100644 --- a/crates/trident-acl-agent/src/core/nebraska/client.rs +++ b/crates/trident-acl-agent/src/core/nebraska/client.rs @@ -101,12 +101,16 @@ pub struct PackageFile { /// registered with one. /// /// **Our Nebraska deployment does not follow that naming.** By internal -/// convention, our server puts a base64-encoded **SHA-384** of the COSI -/// image's metadata section into the `sha1` field instead of a real SHA-1 - -/// the same value Trident itself computes and validates as `image.sha384` -/// (see `crates/trident/src/osimage/cosi/mod.rs`). So despite the field's -/// Omaha-inherited name, treat [`sha1`](PackageHash::sha1) as "the value our -/// Nebraska calls `hash`", not as an actual SHA-1 digest - use +/// convention, our server reports a base64-encoded **SHA-384** of the COSI +/// image's metadata section (the same value Trident itself computes and +/// validates as `image.sha384`, see `crates/trident/src/osimage/cosi/mod.rs`) +/// in [`sha1`](PackageHash::sha1), via a 3-tier precedence (see +/// `wire::Package::cosi_sha384_base64`): a `` child element on the +/// package, a `labels` JSON attribute on the package, and, as a fallback for +/// older manifests, the legacy `hash` attribute (Omaha's `sha1` field). So +/// despite the field's Omaha-inherited name, treat +/// [`sha1`](PackageHash::sha1) as "the value our Nebraska calls `hash`", not +/// as an actual SHA-1 digest; use /// [`to_cosi_sha384`](PackageHash::to_cosi_sha384) to get the value in the /// form Trident's gRPC API expects, rather than forwarding this field as-is. #[derive(Debug, Clone, PartialEq, Eq)] @@ -822,8 +826,8 @@ impl Client { ))); } - let hash = package.hash.as_ref().map(|sha1| PackageHash { - sha1: sha1.clone(), + let hash = package.cosi_sha384_base64().map(|sha1| PackageHash { + sha1, sha256: package.hash_sha256.clone(), }); @@ -1085,6 +1089,135 @@ mod tests { } } + #[test] + fn check_offer_prefers_labels_child_element_over_legacy_hash() { + // Tier 1 ( child element) takes precedence over the tier 3 + // fallback (the legacy `hash` attribute), even when both are present, + // and resolves to the correct SHA-384 once decoded. + // + // Digest generated with: + // echo -n "trident-cosi-metadata-round-trip-test" | openssl dgst -sha384 -binary | openssl base64 -A + // echo -n "trident-cosi-metadata-round-trip-test" | openssl dgst -sha384 + const BASE64_DIGEST: &str = + "8e1AenlmEPzn7npBv5uxbUi2OO2frCiT52sDgbw/RM077QgziRyh7wCIy2YcHvRx"; + const HEX_DIGEST: &str = + "f1ed407a796610fce7ee7a41bf9bb16d48b638ed9fac2893e76b0381bc3f44cd3bed0833891ca1ef0088cb661c1ef471"; + let client = client_with(&format!( + r#" + + + + + "# + )); + match client.check_for_update(&Version::new(1, 0, 0)).unwrap() { + CheckOutcome::UpdateAvailable(offer) => { + assert_eq!( + offer.primary.hash, + Some(PackageHash { + sha1: BASE64_DIGEST.to_string(), + sha256: None, + }) + ); + assert_eq!( + offer.primary.hash.unwrap().to_cosi_sha384().unwrap(), + HEX_DIGEST + ); + } + other => panic!("expected an offer, got {other:?}"), + } + } + + #[test] + fn check_offer_prefers_labels_json_attribute_over_legacy_hash() { + // Tier 2 (labels JSON attribute) is used when no child + // element is present, taking precedence over the tier 3 fallback, + // and resolves to the correct SHA-384 once decoded (same digest as + // used in the tier-1 test above). + const BASE64_DIGEST: &str = + "8e1AenlmEPzn7npBv5uxbUi2OO2frCiT52sDgbw/RM077QgziRyh7wCIy2YcHvRx"; + const HEX_DIGEST: &str = + "f1ed407a796610fce7ee7a41bf9bb16d48b638ed9fac2893e76b0381bc3f44cd3bed0833891ca1ef0088cb661c1ef471"; + let client = client_with(&format!( + r#" + + "# + )); + match client.check_for_update(&Version::new(1, 0, 0)).unwrap() { + CheckOutcome::UpdateAvailable(offer) => { + assert_eq!( + offer.primary.hash, + Some(PackageHash { + sha1: BASE64_DIGEST.to_string(), + sha256: None, + }) + ); + assert_eq!( + offer.primary.hash.unwrap().to_cosi_sha384().unwrap(), + HEX_DIGEST + ); + } + other => panic!("expected an offer, got {other:?}"), + } + } + + #[test] + fn check_offer_falls_back_to_legacy_hash_when_no_labels_present() { + // Tier 3: with neither labels source present, the legacy `hash` + // attribute (current/historical behavior) is used unchanged, and + // resolves to the correct SHA-384 once decoded (same digest as used + // in the tier-1/tier-2 tests above). + const BASE64_DIGEST: &str = + "8e1AenlmEPzn7npBv5uxbUi2OO2frCiT52sDgbw/RM077QgziRyh7wCIy2YcHvRx"; + const HEX_DIGEST: &str = + "f1ed407a796610fce7ee7a41bf9bb16d48b638ed9fac2893e76b0381bc3f44cd3bed0833891ca1ef0088cb661c1ef471"; + let client = client_with(&format!( + r#" + + "# + )); + match client.check_for_update(&Version::new(1, 0, 0)).unwrap() { + CheckOutcome::UpdateAvailable(offer) => { + assert_eq!( + offer.primary.hash, + Some(PackageHash { + sha1: BASE64_DIGEST.to_string(), + sha256: None, + }) + ); + assert_eq!( + offer.primary.hash.unwrap().to_cosi_sha384().unwrap(), + HEX_DIGEST + ); + } + other => panic!("expected an offer, got {other:?}"), + } + } + + #[test] + fn check_offer_falls_back_to_legacy_hash_when_labels_json_is_malformed() { + // A malformed labels JSON attribute (tier 2) must not be a hard + // error; it falls through to the tier 3 fallback. + let client = client_with( + r#" + + "#, + ); + match client.check_for_update(&Version::new(1, 0, 0)).unwrap() { + CheckOutcome::UpdateAvailable(offer) => { + assert_eq!( + offer.primary.hash, + Some(PackageHash { + sha1: "LEGACY".to_string(), + sha256: None, + }) + ); + } + other => panic!("expected an offer, got {other:?}"), + } + } + #[test] fn check_reports_no_update() { let client = client_with( diff --git a/crates/trident-acl-agent/src/core/nebraska/wire.rs b/crates/trident-acl-agent/src/core/nebraska/wire.rs index 6a8fb1d17..bf03654f7 100644 --- a/crates/trident-acl-agent/src/core/nebraska/wire.rs +++ b/crates/trident-acl-agent/src/core/nebraska/wire.rs @@ -325,6 +325,9 @@ pub(super) struct Package { /// The package hash as sent by Nebraska: base64-encoded SHA-1 of the package /// *file*. Optional because Nebraska omits the attribute when a package has /// no hash. + /// + /// This is the fallback (tier 3) source for the COSI metadata SHA-384: see + /// [`Package::cosi_sha384_base64`]. #[serde(default, rename = "@hash")] pub(super) hash: Option, @@ -340,6 +343,77 @@ pub(super) struct Package { /// when the attribute is absent. #[serde(default, rename = "@required")] pub(super) required: bool, + + /// Tier 2 source for the COSI metadata SHA-384: a `labels` attribute on + /// the `` element holding a JSON object string, e.g. + /// `{"cosi.metadata.sha384":"..."}`. See + /// [`Package::cosi_sha384_base64`]. + #[serde(default, rename = "@labels")] + pub(super) labels_json: Option, + + /// Tier 1 source for the COSI metadata SHA-384: a `` child + /// element holding one or more `