Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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