use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::canonical::{Canonical, CanonicalError};
use crate::digest::Digest;
use crate::evidence::Evidence;
use crate::grant::GrantSet;
use crate::id::{RunId, StepId};
use crate::isolation::Isolation;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GrantDelta {
pub requested: GrantSet,
pub granted: GrantSet,
pub attenuated: GrantSet,
}
impl GrantDelta {
pub fn new(requested: GrantSet, granted: GrantSet, attenuated: GrantSet) -> Self {
Self {
requested,
granted,
attenuated,
}
}
pub fn was_attenuated(&self) -> bool {
!self.attenuated.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct CostRecord {
pub tokens_in: u64,
pub tokens_out: u64,
pub usd: Decimal,
pub wall_ms: u64,
pub tool_ms: u64,
}
impl CostRecord {
pub fn total_tokens(&self) -> u64 {
self.tokens_in.saturating_add(self.tokens_out)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JournalRef {
pub digest: Digest,
pub entries: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LedgerEntry {
pub seq: u64,
pub prev: Digest,
pub run: RunId,
pub step: StepId,
pub grant: GrantDelta,
pub cost: CostRecord,
pub evidence: Option<Evidence>,
pub journal: Option<JournalRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isolation: Option<Isolation>,
}
pub const GENESIS_PREV: Digest = Digest::from_bytes([0u8; 32]);
impl LedgerEntry {
pub fn genesis(
run: RunId,
step: StepId,
grant: GrantDelta,
cost: CostRecord,
evidence: Option<Evidence>,
journal: Option<JournalRef>,
) -> Self {
Self {
seq: 0,
prev: GENESIS_PREV,
run,
step,
grant,
cost,
evidence,
journal,
isolation: None,
}
}
pub fn following(
previous: &Self,
step: StepId,
grant: GrantDelta,
cost: CostRecord,
evidence: Option<Evidence>,
journal: Option<JournalRef>,
) -> Result<Self, CanonicalError> {
Ok(Self {
seq: previous.seq + 1,
prev: previous.digest()?,
run: previous.run,
step,
grant,
cost,
evidence,
journal,
isolation: None,
})
}
#[must_use]
pub fn confined_by(mut self, isolation: Isolation) -> Self {
self.isolation = Some(isolation);
self
}
pub const fn confinement(&self) -> Option<Isolation> {
self.isolation
}
pub const fn is_confinement_recorded(&self) -> bool {
self.isolation.is_some()
}
pub const fn ran_virtualised(&self) -> bool {
match self.isolation {
Some(isolation) => isolation.is_virtualising(),
None => false,
}
}
pub fn follows(&self, previous: &Self) -> Result<bool, CanonicalError> {
Ok(self.seq == previous.seq + 1 && self.prev == previous.digest()?)
}
pub fn is_genesis(&self) -> bool {
self.seq == 0 && self.prev == GENESIS_PREV
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::evidence::{CheckerId, EvidenceKind, Signature, SigningKeyId, Verdict};
fn delta() -> GrantDelta {
GrantDelta::new(GrantSet::EMPTY, GrantSet::EMPTY, GrantSet::EMPTY)
}
fn step(tag: &[u8]) -> StepId {
StepId::new(Digest::of(tag))
}
fn chain(len: usize) -> Vec<LedgerEntry> {
let run = RunId::new();
let mut out = vec![LedgerEntry::genesis(
run,
step(b"0"),
delta(),
CostRecord::default(),
None,
None,
)];
for i in 1..len {
let prev = out.last().unwrap();
out.push(
LedgerEntry::following(
prev,
step(format!("{i}").as_bytes()),
delta(),
CostRecord::default(),
None,
None,
)
.unwrap(),
);
}
out
}
#[test]
fn carries_every_documented_field() {
let e = LedgerEntry::genesis(
RunId::new(),
step(b"s"),
delta(),
CostRecord {
tokens_in: 100,
tokens_out: 20,
usd: Decimal::new(41, 2),
wall_ms: 1_200,
tool_ms: 300,
},
Some(Evidence::new(
Verdict::Pass,
CheckerId::new("c", 1),
[Digest::of(b"x")],
Signature::from_bytes([0u8; 64]),
SigningKeyId("k".into()),
EvidenceKind::Deterministic,
)),
Some(JournalRef {
digest: Digest::of(b"j"),
entries: 3,
}),
);
assert_eq!(e.seq, 0);
assert_eq!(e.cost.total_tokens(), 120);
assert_eq!(e.cost.usd, Decimal::new(41, 2));
assert!(e.evidence.is_some());
assert!(e.journal.is_some());
}
#[test]
fn genesis_links_to_nothing() {
let g = &chain(1)[0];
assert!(g.is_genesis());
assert_eq!(g.prev, GENESIS_PREV);
}
#[test]
fn each_entry_commits_to_its_predecessor() {
let c = chain(5);
for pair in c.windows(2) {
assert!(pair[1].follows(&pair[0]).unwrap());
}
}
#[test]
fn sequence_numbers_are_gapless() {
let c = chain(5);
for (i, e) in c.iter().enumerate() {
assert_eq!(e.seq, i as u64);
}
}
#[test]
fn altering_an_entry_breaks_every_link_after_it() {
let mut c = chain(5);
c[2].cost.tokens_in = 999_999;
assert!(c[2].follows(&c[1]).unwrap());
assert!(
!c[3].follows(&c[2]).unwrap(),
"tampering must invalidate the rest of the chain"
);
}
#[test]
fn a_reordered_chain_does_not_verify() {
let c = chain(4);
assert!(
!c[3].follows(&c[1]).unwrap(),
"entries cannot be resequenced"
);
}
#[test]
fn grant_delta_records_attenuation() {
use crate::grant::GrantAtom;
let d = GrantDelta::new(
GrantSet::new([GrantAtom::new("fs", "write", "/**")]),
GrantSet::new([GrantAtom::new("fs", "write", "/build/**")]),
GrantSet::new([GrantAtom::new("fs", "write", "/**")]),
);
assert!(d.was_attenuated());
assert!(!delta().was_attenuated());
}
#[test]
fn cost_is_decimal_not_float() {
let a = Decimal::new(1, 1);
let b = Decimal::new(2, 1);
assert_eq!(a + b, Decimal::new(3, 1));
}
#[test]
fn round_trips_through_canonical_cbor() {
let e = chain(2).pop().unwrap();
let bytes = crate::to_canonical_cbor(&e).unwrap();
let back: LedgerEntry = crate::from_canonical_cbor(&bytes).unwrap();
assert_eq!(e, back);
}
}