Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 141 additions & 8 deletions crates/trident-acl-agent/src/core/nebraska/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<labels>` 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 resolved COSI metadata SHA-384, 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)]
Expand Down Expand Up @@ -822,8 +826,8 @@ impl<T: Transport> Client<T> {
)));
}

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(),
});

Expand Down Expand Up @@ -1085,6 +1089,135 @@ mod tests {
}
}

#[test]
fn check_offer_prefers_labels_child_element_over_legacy_hash() {
// Tier 1 (<labels> 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#"<response protocol="3.0" server="n"><app appid="app-1" status="ok"><updatecheck status="ok"><urls><url codebase="https://updates.example.com/"/></urls><manifest version="2.0.0"><packages>
<package name="os.cosi" hash="LEGACY" size="10" required="true">
<labels>
<label key="cosi.metadata.sha384" value="{BASE64_DIGEST}"/>
</labels>
</package>
</packages></manifest></updatecheck></app></response>"#
));
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 <labels> 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#"<response protocol="3.0" server="n"><app appid="app-1" status="ok"><updatecheck status="ok"><urls><url codebase="https://updates.example.com/"/></urls><manifest version="2.0.0"><packages>
<package name="os.cosi" hash="LEGACY" size="10" required="true" labels="{{&quot;cosi.metadata.sha384&quot;:&quot;{BASE64_DIGEST}&quot;}}"/>
</packages></manifest></updatecheck></app></response>"#
));
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#"<response protocol="3.0" server="n"><app appid="app-1" status="ok"><updatecheck status="ok"><urls><url codebase="https://updates.example.com/"/></urls><manifest version="2.0.0"><packages>
<package name="os.cosi" hash="{BASE64_DIGEST}" size="10" required="true"/>
</packages></manifest></updatecheck></app></response>"#
));
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#"<response protocol="3.0" server="n"><app appid="app-1" status="ok"><updatecheck status="ok"><urls><url codebase="https://updates.example.com/"/></urls><manifest version="2.0.0"><packages>
<package name="os.cosi" hash="LEGACY" size="10" required="true" labels="not-json"/>
</packages></manifest></updatecheck></app></response>"#,
);
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(
Expand Down
74 changes: 74 additions & 0 deletions crates/trident-acl-agent/src/core/nebraska/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

Expand All @@ -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 `<package>` 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<String>,

/// Tier 1 source for the COSI metadata SHA-384: a `<labels>` child
/// element holding one or more `<label key="..." value="..."/>`
/// children. See [`Package::cosi_sha384_base64`].
#[serde(default, rename = "labels")]
pub(super) labels: Option<Labels>,
}

impl Package {
/// The label key under which the COSI metadata SHA-384 is reported, for
/// both the `<labels>` child element (tier 1) and the `labels` JSON
/// attribute (tier 2).
const COSI_SHA384_LABEL_KEY: &'static str = "cosi.metadata.sha384";

/// Resolves this package's base64-encoded COSI metadata SHA-384 using a
/// 3-tier precedence, each one a fallback for the next:
///
/// 1. The `cosi.metadata.sha384` key in the `<labels>` child element.
/// 2. The `cosi.metadata.sha384` key in the `labels` JSON attribute.
/// 3. The legacy `@hash` attribute (Nebraska's `sha1` field, repurposed
/// by our deployment to carry this same base64 SHA-384 value).
///
/// Returns `None` only when none of the three sources is present; a
/// malformed `labels` JSON attribute (tier 2) is treated as absent rather
/// than an error, so parsing falls through to the next tier.
pub(super) fn cosi_sha384_base64(&self) -> Option<String> {
if let Some(value) = self.labels.as_ref().and_then(|labels| {
labels
.labels
.iter()
.find(|label| label.key == Self::COSI_SHA384_LABEL_KEY)
.map(|label| label.value.clone())
Comment thread
bfjelds marked this conversation as resolved.
}) {
return Some(value);
}

if let Some(value) = self.labels_json.as_ref().and_then(|json| {
serde_json::from_str::<std::collections::HashMap<String, String>>(json)
.ok()
.and_then(|map| map.get(Self::COSI_SHA384_LABEL_KEY).cloned())
}) {
return Some(value);
}

self.hash.clone()
}
}

/// A `<labels>` child element of a `<package>`, holding key/value `<label>`
/// children (tier 1 COSI metadata SHA-384 source).
#[derive(Debug, Deserialize)]
pub(super) struct Labels {
#[serde(default, rename = "label")]
pub(super) labels: Vec<Label>,
}

/// A single `<label key="..." value="..."/>` child of `<labels>`.
#[derive(Debug, Deserialize)]
pub(super) struct Label {
#[serde(rename = "@key")]
pub(super) key: String,

#[serde(rename = "@value")]
pub(super) value: String,
}

/// Parses a Nebraska response body.
Expand Down
Loading