server/lib/src/promote/meter_gate.rs
//! **`WasmGate` — the whole join between a loaded meter guest and holger's gate
//! seam.**
//!
//! `gate.rs` says it in one line: *"`struct WasmGate(Arc<dyn MeterRuntime>)` with
//! `impl Gate for WasmGate` is the whole join needed to enforce a quota cap from
//! a loaded plugin."* This is that impl, and it is deliberately the only place
//! the two planes meet.
//!
//! # What it does and does not decide
//!
//! The guest produces a verdict; it does not apply one. Applying is the gate's
//! job, and the gate's only job here is translation: a [`PromotionContext`] into
//! a [`WireCallContext`], and a [`WireVerdict`] back into
//! [`nornir_gate::Verdict`]. Nothing is re-derived and nothing is softened.
//!
//! # Err is a refusal, never an allow
//!
//! gunnar's rule, copied without variation. A guest that traps, exhausts its
//! fuel, exceeds its deadline or answers undecodable bytes produces an `Err`
//! here, and `nornir_gate::evaluate` records that as `undecided: true` and
//! refuses the hop. A metering plugin that cannot answer must not be a metering
//! plugin that waves everything through.
//!
//! # It runs on PUT as well as on promote
//!
//! `PromotionContext::ingest` is the upload boundary, and a cap enforced after
//! the upload is a cap that already cost the disk. The two cases differ here by
//! one field — the verb — because everything a guest actually reads (the
//! principal, the destination repository, the digest, the size) is identical.
use std::sync::Arc;
use holger_plugin_host::abi::v2::{
DigestAlgo, DigestState, WireArtifactRef, WireCallContext, WireDigest, WirePrincipal, WireVerb,
WireVerdict,
};
use holger_plugin_host::MeterRuntime;
use nornir_gate::Verdict;
use super::gate::{Gate, PromotedArtifact, PromotionContext};
/// A meter guest, standing as a promotion gate.
pub struct WasmGate {
name: String,
meter: Arc<dyn MeterRuntime>,
}
impl WasmGate {
/// Name it after the guest's own manifest, so a refusal names the module an
/// operator installed rather than the word "wasm".
pub fn new(meter: Arc<dyn MeterRuntime>) -> Self {
let name = meter.manifest().name.clone();
Self { name, meter }
}
/// Translate one member of the promotion.
///
/// The host fills `digest_state`, not the guest: `Present` when the promotion
/// carried a digest this ABI can spell, and `NotKnown` when it carried one
/// in an algorithm the wire has no variant for. It is never `NotGranted`
/// here — that state is set by `redact_context` when the module was not
/// granted `digest.read`, and the runtime applies it after this function has
/// handed over the digest it was entitled to see.
fn artifact_ref(a: &PromotedArtifact<'_>) -> WireArtifactRef {
let algo = match a.digest.alg() {
"blake3" => Some(DigestAlgo::Blake3_256),
"sha256" => Some(DigestAlgo::Sha256),
// sha512 has no wire variant. Saying NotKnown is honest; inventing a
// variant, or silently sending the bytes under the wrong algorithm
// name, is how a guest ends up comparing two different hashes.
_ => None,
};
let digest = algo
.and_then(|algo| decode_hex(a.digest.hex()).map(|bytes| WireDigest { algo, bytes }));
WireArtifactRef {
id: holger_plugin_host::abi::wire::WireArtifactId {
namespace: a.id.namespace.clone(),
name: a.id.name.clone(),
version: a.id.version.clone(),
},
digest_state: if digest.is_some() {
DigestState::Present
} else {
DigestState::NotKnown
},
digest,
size_bytes: a.bytes.len() as u64,
}
}
}
fn decode_hex(hex: &str) -> Option<Vec<u8>> {
if !hex.len().is_multiple_of(2) {
return None;
}
(0..hex.len() / 2)
.map(|i| u8::from_str_radix(hex.get(i * 2..i * 2 + 2)?, 16).ok())
.collect()
}
impl Gate for WasmGate {
fn name(&self) -> &str {
&self.name
}
fn check(&self, ctx: &PromotionContext<'_>) -> anyhow::Result<Verdict> {
// An ingest has no source repository; a promotion does. That is the one
// difference the guest sees between the two boundaries.
let verb = if ctx.from_repo.is_empty() {
WireVerb::Put
} else {
WireVerb::Promote
};
// The call a record written from inside this gate is stamped with. The
// guest supplies neither the identity nor the address; the host does,
// from the hop it is already inside. `crate::meter_services` explains
// why the stamp is thread-scoped and not a field.
let stamp = crate::meter_services::CallStamp {
ident: ctx.principal.to_string(),
source_ip: String::new(),
};
crate::meter_services::in_call(stamp, || self.check_inner(ctx, verb))
}
}
impl WasmGate {
fn check_inner(&self, ctx: &PromotionContext<'_>, verb: WireVerb) -> anyhow::Result<Verdict> {
for a in ctx.set {
let call = WireCallContext {
// Replaced by the runtime with the number IT negotiated with
// this module; a caller never guesses the ABI.
abi: 0,
repository: ctx.to_repo.to_string(),
verb,
principal: Some(WirePrincipal {
// ONE spelling of identity: this is `AuditEvent::ident`, the
// same string the audit chain records.
ident: ctx.principal.to_string(),
// The promotion context does not carry HOW the principal
// authenticated, and this gate will not invent it. Empty is
// the honest answer and a guest can tell it from a method.
method: String::new(),
source_ip: String::new(),
}),
artifact: Some(Self::artifact_ref(a)),
now: None,
};
// `?` on purpose: an Err here is a refusal, never an allow.
match self.meter.evaluate(&call).map_err(|e| anyhow::anyhow!(e))? {
WireVerdict::Allow => {}
// A notice is not a refusal. Never hold data hostage; warn on the
// way through. The line is carried into the audit detail by the
// engine's own reason field rather than being dropped here.
WireVerdict::AllowWithNotice { .. } => {}
WireVerdict::Refuse { code, reason, url } => {
let line = match url {
Some(u) => format!("{code}: {reason} ({u})"),
None => format!("{code}: {reason}"),
};
return Ok(Verdict::reject(a.label(), line));
}
}
}
Ok(Verdict::Allow)
}
}
#[cfg(test)]
mod tests {
use super::*;
use holger_plugin_host::abi::v2::{
Capability, WireCapacity, WireFactSummary, WireManifest2, WireMeterDecl, WireMeterReading,
WireSelftest,
};
use traits::ArtifactId;
/// A meter whose verdict the test chooses.
struct Fixed(Result<WireVerdict, String>);
impl MeterRuntime for Fixed {
fn manifest(&self) -> &WireManifest2 {
// A leaked manifest keeps the signature `&WireManifest2` without a
// field on the fixture; this is a test and it runs once.
Box::leak(Box::new(WireManifest2 {
abi: holger_plugin_host::abi::v2::AbiRange::new(2, 2),
kind: holger_plugin_host::abi::v2::GuestKind::Meter,
name: "fixture".into(),
requires: vec![Capability::UsageRead],
handler: None,
meter: Some(holger_plugin_host::abi::v2::MeterFacts {
accepts_facts: false,
}),
identity: None,
unknown_optional: Vec::new(),
}))
}
fn granted(&self) -> &[Capability] {
&[]
}
fn meters(&self) -> Result<Vec<WireMeterDecl>, String> {
Ok(Vec::new())
}
fn read(&self, _r: &str) -> Result<Vec<WireMeterReading>, String> {
Ok(Vec::new())
}
fn capacity(&self) -> Result<WireCapacity, String> {
Err("fixture".into())
}
fn selftest(&self) -> Result<WireSelftest, String> {
Err("fixture".into())
}
fn evaluate(&self, _c: &WireCallContext) -> Result<WireVerdict, String> {
self.0.clone()
}
fn accept_fact(&self, _s: &[u8]) -> Result<WireFactSummary, String> {
Err("fixture".into())
}
fn describe_fact(&self, _r: &str) -> Result<WireFactSummary, String> {
Err("fixture".into())
}
}
fn gate(v: Result<WireVerdict, String>) -> WasmGate {
WasmGate::new(Arc::new(Fixed(v)))
}
fn id() -> ArtifactId {
ArtifactId {
namespace: None,
name: "widget".into(),
version: "1.0.0".into(),
}
}
fn with_context<R>(from_repo: &str, f: impl FnOnce(&PromotionContext<'_>) -> R) -> R {
let id = id();
let digest = crate::attest::ArtifactDigest::parse("sha256", &"ab".repeat(32)).unwrap();
let bytes = vec![7u8; 11];
let set = [PromotedArtifact {
id: &id,
ecosystem: "raw",
digest,
bytes: &bytes,
attestations: &[],
}];
let ctx = PromotionContext {
principal: "sub-123",
from_repo,
to_repo: "releases",
hop: None,
set: &set,
};
f(&ctx)
}
#[test]
fn allow_passes_through() {
with_context("dev", |ctx| {
assert!(matches!(
gate(Ok(WireVerdict::Allow)).check(ctx).unwrap(),
Verdict::Allow
));
});
}
#[test]
fn a_notice_is_not_a_refusal_because_data_is_never_held_hostage() {
with_context("dev", |ctx| {
let v = gate(Ok(WireVerdict::AllowWithNotice {
line: "grace".into(),
}))
.check(ctx)
.unwrap();
assert!(matches!(v, Verdict::Allow));
});
}
#[test]
fn a_refusal_carries_the_code_the_reason_and_the_url_and_names_the_artifact() {
with_context("dev", |ctx| {
let v = gate(Ok(WireVerdict::Refuse {
code: "holger-metered-6".into(),
reason: "the ceiling is reached".into(),
url: Some("https://example.invalid/renew".into()),
}))
.check(ctx)
.unwrap();
match v {
Verdict::Reject(r) => {
assert!(r.reason.contains("holger-metered-6"), "{}", r.reason);
assert!(r.reason.contains("the ceiling is reached"), "{}", r.reason);
assert!(r.reason.contains("example.invalid"), "{}", r.reason);
assert!(r.subject.unwrap().contains("widget"));
}
Verdict::Allow => panic!("a refusal became an allow"),
}
});
}
#[test]
fn err_is_a_refusal_never_an_allow() {
with_context("dev", |ctx| {
// The engine treats an Err as undecided and refuses the hop, so the
// only thing this gate must guarantee is that it does not swallow it.
assert!(gate(Err("the guest trapped".into())).check(ctx).is_err());
});
}
#[test]
fn the_gate_is_named_after_the_module_not_after_the_mechanism() {
assert_eq!(gate(Ok(WireVerdict::Allow)).name(), "fixture");
}
#[test]
fn an_ingest_is_a_put_and_a_promotion_is_a_promote() {
// The verb is the ONE field that differs between the two boundaries, and
// a guest that caps writes has to be able to tell them apart.
struct Verb(std::sync::Mutex<Option<WireVerb>>);
impl MeterRuntime for Verb {
fn manifest(&self) -> &WireManifest2 {
Box::leak(Box::new(WireManifest2 {
abi: holger_plugin_host::abi::v2::AbiRange::new(2, 2),
kind: holger_plugin_host::abi::v2::GuestKind::Meter,
name: "verb".into(),
requires: vec![Capability::UsageRead],
handler: None,
meter: Some(holger_plugin_host::abi::v2::MeterFacts {
accepts_facts: false,
}),
identity: None,
unknown_optional: Vec::new(),
}))
}
fn granted(&self) -> &[Capability] {
&[]
}
fn meters(&self) -> Result<Vec<WireMeterDecl>, String> {
Ok(Vec::new())
}
fn read(&self, _r: &str) -> Result<Vec<WireMeterReading>, String> {
Ok(Vec::new())
}
fn capacity(&self) -> Result<WireCapacity, String> {
Err("verb".into())
}
fn selftest(&self) -> Result<WireSelftest, String> {
Err("verb".into())
}
fn evaluate(&self, c: &WireCallContext) -> Result<WireVerdict, String> {
*self.0.lock().unwrap() = Some(c.verb);
Ok(WireVerdict::Allow)
}
fn accept_fact(&self, _s: &[u8]) -> Result<WireFactSummary, String> {
Err("verb".into())
}
fn describe_fact(&self, _r: &str) -> Result<WireFactSummary, String> {
Err("verb".into())
}
}
let m = Arc::new(Verb(std::sync::Mutex::new(None)));
let g = WasmGate::new(m.clone());
with_context("", |ctx| g.check(ctx).unwrap());
assert_eq!(*m.0.lock().unwrap(), Some(WireVerb::Put));
with_context("dev", |ctx| g.check(ctx).unwrap());
assert_eq!(*m.0.lock().unwrap(), Some(WireVerb::Promote));
}
#[test]
fn a_digest_algorithm_the_wire_cannot_spell_is_not_known_rather_than_wrong() {
let id = id();
let digest = crate::attest::ArtifactDigest::parse("sha512", &"cd".repeat(64)).unwrap();
let bytes = vec![0u8; 3];
let a = PromotedArtifact {
id: &id,
ecosystem: "raw",
digest,
bytes: &bytes,
attestations: &[],
};
let r = WasmGate::artifact_ref(&a);
assert_eq!(r.digest_state, DigestState::NotKnown);
assert!(r.digest.is_none());
// NotKnown, never NotGranted: the guest must be able to tell "the
// backend cannot produce one" from "you may not see it".
assert_ne!(r.digest_state, DigestState::NotGranted);
}
#[test]
fn a_digest_the_wire_can_spell_arrives_whole() {
let id = id();
let digest = crate::attest::ArtifactDigest::parse("blake3", &"0f".repeat(32)).unwrap();
let bytes = vec![0u8; 5];
let a = PromotedArtifact {
id: &id,
ecosystem: "raw",
digest,
bytes: &bytes,
attestations: &[],
};
let r = WasmGate::artifact_ref(&a);
assert_eq!(r.digest_state, DigestState::Present);
let d = r.digest.unwrap();
assert_eq!(d.algo, DigestAlgo::Blake3_256);
assert_eq!(d.bytes, vec![0x0fu8; 32]);
assert_eq!(r.size_bytes, 5);
}
}