use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum Enforcement {
Enforced,
Mediated,
Unsupported,
}
impl Enforcement {
#[must_use]
pub fn meet(self, other: Self) -> Self {
match (self, other) {
(Self::Unsupported, _) | (_, Self::Unsupported) => Self::Unsupported,
(Self::Mediated, _) | (_, Self::Mediated) => Self::Mediated,
(Self::Enforced, Self::Enforced) => Self::Enforced,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum EvidenceClaim {
TerminalOutcome,
CapturedStreams,
ResourceUsage,
AllowedActions,
DeniedAttempts,
FilesystemDelta,
ProcessTree,
NetworkActivity,
ArtifactLineage,
MechanismAttestation,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct EvidenceSet(BTreeSet<EvidenceClaim>);
impl EvidenceSet {
#[must_use]
pub fn new() -> Self {
Self(BTreeSet::new())
}
pub fn insert(&mut self, claim: EvidenceClaim) -> bool {
self.0.insert(claim)
}
#[must_use]
pub fn contains(&self, claim: EvidenceClaim) -> bool {
self.0.contains(&claim)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn is_subset(&self, other: &Self) -> bool {
self.0.is_subset(&other.0)
}
#[must_use]
pub fn intersection(&self, other: &Self) -> Self {
Self(self.0.intersection(&other.0).copied().collect())
}
#[must_use]
pub fn union(&self, other: &Self) -> Self {
Self(self.0.union(&other.0).copied().collect())
}
pub fn extend_from(&mut self, other: &Self) {
self.0.extend(other.0.iter().copied());
}
pub fn iter(&self) -> impl Iterator<Item = EvidenceClaim> + '_ {
self.0.iter().copied()
}
}
impl FromIterator<EvidenceClaim> for EvidenceSet {
fn from_iter<I: IntoIterator<Item = EvidenceClaim>>(iter: I) -> Self {
Self(iter.into_iter().collect())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SupportVerdict {
pub enforcement: Enforcement,
pub evidence: EvidenceSet,
}
impl SupportVerdict {
#[must_use]
pub fn new(enforcement: Enforcement, evidence: EvidenceSet) -> Self {
Self {
enforcement,
evidence,
}
}
#[must_use]
pub fn unsupported() -> Self {
Self {
enforcement: Enforcement::Unsupported,
evidence: EvidenceSet::new(),
}
}
#[must_use]
pub fn meet(&self, other: &Self) -> Self {
Self {
enforcement: self.enforcement.meet(other.enforcement),
evidence: self.evidence.intersection(&other.evidence),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Capability {
Filesystem {
access: FsAccess,
scope: PathSet,
recursive: bool,
confinement: FsConfinement,
},
Network {
policy: NetPolicy,
},
ChildSpawn {
policy: SpawnPolicy,
},
Environment {
policy: EnvPolicy,
},
InheritedFds {
policy: FdPolicy,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum FsAccess {
Read,
Write,
ReadWrite,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum FsConfinement {
DeclaredRootsOnly,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum NetPolicy {
DenyAll,
AllowList(Vec<NetDest>),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SpawnPolicy {
DenyNewTasks,
AllowThreadsWithinBoundary,
AllowDescendantsWithinBoundary,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum EnvPolicy {
Exact(Vec<EnvEntry>),
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct EnvEntry {
pub name: String,
pub source: EnvSource,
}
impl EnvEntry {
#[must_use]
pub fn literal(name: impl Into<String>, value: impl Into<String>) -> Self {
Self {
name: name.into(),
source: EnvSource::Literal(value.into()),
}
}
#[must_use]
pub fn lease(name: impl Into<String>, lease: SecretRef) -> Self {
Self {
name: name.into(),
source: EnvSource::SecretLease(lease),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum EnvSource {
Literal(String),
SecretLease(SecretRef),
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct SecretRef(pub String);
impl SecretRef {
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
#[must_use]
pub fn id(&self) -> &str {
&self.0
}
}
pub const MAX_ENV_ENTRIES: usize = 256;
pub const MAX_ENV_TOTAL_BYTES: usize = 128 * 1024;
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum EnvPolicyError {
EmptyName,
NameHasReservedByte {
name: String,
byte: u8,
},
ValueHasNul {
name: String,
},
DuplicateName {
name: String,
},
TooManyEntries {
found: usize,
},
TooManyBytes {
found: usize,
},
}
impl std::fmt::Display for EnvPolicyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyName => f.write_str("an environment variable name is empty"),
Self::NameHasReservedByte { name, byte } => write!(
f,
"environment name {name:?} contains the reserved byte {byte:#04x} (= or NUL)"
),
Self::ValueHasNul { name } => {
write!(f, "environment value for {name:?} contains a NUL byte")
}
Self::DuplicateName { name } => {
write!(f, "environment name {name:?} is declared more than once")
}
Self::TooManyEntries { found } => write!(
f,
"environment declares {found} entries, exceeding the cap {MAX_ENV_ENTRIES}"
),
Self::TooManyBytes { found } => write!(
f,
"environment totals {found} bytes, exceeding the cap {MAX_ENV_TOTAL_BYTES}"
),
}
}
}
impl std::error::Error for EnvPolicyError {}
impl EnvPolicy {
pub fn validate(&self) -> Result<(), EnvPolicyError> {
let Self::Exact(entries) = self;
if entries.len() > MAX_ENV_ENTRIES {
return Err(EnvPolicyError::TooManyEntries {
found: entries.len(),
});
}
let mut total: usize = 0;
let mut seen: BTreeSet<&str> = BTreeSet::new();
for entry in entries {
if entry.name.is_empty() {
return Err(EnvPolicyError::EmptyName);
}
for byte in entry.name.bytes() {
if byte == b'=' || byte == 0 {
return Err(EnvPolicyError::NameHasReservedByte {
name: entry.name.clone(),
byte,
});
}
}
if !seen.insert(entry.name.as_str()) {
return Err(EnvPolicyError::DuplicateName {
name: entry.name.clone(),
});
}
total = total.saturating_add(entry.name.len());
match &entry.source {
EnvSource::Literal(value) => {
if value.bytes().any(|b| b == 0) {
return Err(EnvPolicyError::ValueHasNul {
name: entry.name.clone(),
});
}
total = total.saturating_add(value.len());
}
EnvSource::SecretLease(lease) => {
total = total.saturating_add(lease.id().len());
}
}
}
if total > MAX_ENV_TOTAL_BYTES {
return Err(EnvPolicyError::TooManyBytes { found: total });
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum FdPolicy {
None,
Only(Vec<u32>),
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct PathSet {
pub roots: Vec<String>,
}
impl PathSet {
#[must_use]
pub fn empty() -> Self {
Self::default()
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct NetDest {
pub host: String,
pub port: u16,
}
#[cfg(test)]
mod lattice_laws {
use super::{Enforcement, EvidenceClaim, EvidenceSet, SupportVerdict};
const ENFORCEMENTS: [Enforcement; 3] = [
Enforcement::Enforced,
Enforcement::Mediated,
Enforcement::Unsupported,
];
fn enforcement_strength(e: Enforcement) -> u8 {
match e {
Enforcement::Enforced => 2,
Enforcement::Mediated => 1,
Enforcement::Unsupported => 0,
}
}
fn full_evidence() -> EvidenceSet {
[
EvidenceClaim::TerminalOutcome,
EvidenceClaim::CapturedStreams,
EvidenceClaim::ResourceUsage,
EvidenceClaim::AllowedActions,
EvidenceClaim::DeniedAttempts,
EvidenceClaim::FilesystemDelta,
EvidenceClaim::ProcessTree,
EvidenceClaim::NetworkActivity,
EvidenceClaim::ArtifactLineage,
EvidenceClaim::MechanismAttestation,
]
.into_iter()
.collect()
}
fn evidence_samples() -> Vec<EvidenceSet> {
vec![
EvidenceSet::new(),
[EvidenceClaim::TerminalOutcome].into_iter().collect(),
[
EvidenceClaim::TerminalOutcome,
EvidenceClaim::CapturedStreams,
]
.into_iter()
.collect(),
[
EvidenceClaim::CapturedStreams,
EvidenceClaim::NetworkActivity,
]
.into_iter()
.collect(),
full_evidence(),
]
}
#[test]
fn enforcement_meet_is_commutative_associative_idempotent() {
for a in ENFORCEMENTS {
assert_eq!(a.meet(a), a, "idempotent at {a:?}");
for b in ENFORCEMENTS {
assert_eq!(a.meet(b), b.meet(a), "commutative at ({a:?},{b:?})");
for c in ENFORCEMENTS {
assert_eq!(
a.meet(b).meet(c),
a.meet(b.meet(c)),
"associative at ({a:?},{b:?},{c:?})",
);
}
}
}
}
#[test]
fn enforcement_unsupported_absorbs_and_enforced_is_identity() {
for a in ENFORCEMENTS {
assert_eq!(Enforcement::Unsupported.meet(a), Enforcement::Unsupported);
assert_eq!(a.meet(Enforcement::Unsupported), Enforcement::Unsupported);
assert_eq!(Enforcement::Enforced.meet(a), a);
assert_eq!(a.meet(Enforcement::Enforced), a);
}
}
#[test]
fn enforcement_meet_is_the_glb_in_security_order() {
for a in ENFORCEMENTS {
for b in ENFORCEMENTS {
let m = a.meet(b);
assert_eq!(
enforcement_strength(m),
enforcement_strength(a).min(enforcement_strength(b)),
"meet is the GLB at ({a:?},{b:?})",
);
}
}
}
#[test]
fn evidence_intersection_is_commutative_associative_idempotent() {
for a in &evidence_samples() {
assert_eq!(&a.intersection(a), a, "idempotent");
for b in &evidence_samples() {
assert_eq!(a.intersection(b), b.intersection(a), "commutative");
for c in &evidence_samples() {
assert_eq!(
a.intersection(b).intersection(c),
a.intersection(&b.intersection(c)),
"associative",
);
}
}
}
}
#[test]
fn evidence_empty_absorbs_and_full_is_identity() {
let empty = EvidenceSet::new();
let full = full_evidence();
for a in &evidence_samples() {
assert_eq!(
a.intersection(&empty),
empty,
"empty is the absorbing bottom"
);
assert_eq!(&a.intersection(&full), a, "full is the identity");
}
}
#[test]
fn evidence_intersection_is_a_lower_bound() {
for a in &evidence_samples() {
for b in &evidence_samples() {
let m = a.intersection(b);
assert!(m.is_subset(a) && m.is_subset(b), "meet ⊆ both inputs");
}
}
}
#[test]
fn verdict_meet_is_commutative_associative_idempotent() {
let verdicts: Vec<SupportVerdict> = ENFORCEMENTS
.iter()
.zip(evidence_samples())
.map(|(&e, ev)| SupportVerdict::new(e, ev))
.collect();
for a in &verdicts {
assert_eq!(&a.meet(a), a, "idempotent");
for b in &verdicts {
assert_eq!(a.meet(b), b.meet(a), "commutative");
for c in &verdicts {
assert_eq!(a.meet(b).meet(c), a.meet(&b.meet(c)), "associative",);
}
}
}
}
#[test]
fn verdict_meet_floors_both_axes() {
let a = SupportVerdict::new(
Enforcement::Enforced,
[
EvidenceClaim::TerminalOutcome,
EvidenceClaim::CapturedStreams,
]
.into_iter()
.collect(),
);
let b = SupportVerdict::new(
Enforcement::Mediated,
[
EvidenceClaim::CapturedStreams,
EvidenceClaim::NetworkActivity,
]
.into_iter()
.collect(),
);
let m = a.meet(&b);
assert_eq!(m.enforcement, Enforcement::Mediated);
assert_eq!(
m.evidence,
[EvidenceClaim::CapturedStreams].into_iter().collect()
);
}
}
#[cfg(test)]
#[path = "capability_env_tests.rs"]
mod capability_env_tests;