jcp-wire: the endpoint's spend, revocations and memory of decided requests survive a restart #326
4 changed files with 313 additions and 1 deletions
|
|
@ -1265,6 +1265,14 @@ action: the grant's holder is the enforcement point that ran it, as a steward is
|
||||||
in Mesh, rather than the agent the grant governs reporting its own consumption.
|
in Mesh, rather than the agent the grant governs reporting its own consumption.
|
||||||
The endpoint behind the enforcement point is invoked only on Allow.
|
The endpoint behind the enforcement point is invoked only on Allow.
|
||||||
|
|
||||||
|
**What the endpoint holds survives it.** An endpoint that can restart is bound
|
||||||
|
by §4.11: it MUST record each request it admits before deciding it, and each
|
||||||
|
revocation it honours before applying it, and on restart MUST replay them
|
||||||
|
through the gate, each request at the time it was decided, before deciding
|
||||||
|
anything new. That re-establishes the spend, the revocations, and the memory of
|
||||||
|
requests still inside the window. A request it cannot record is denied
|
||||||
|
`unrecorded`.
|
||||||
|
|
||||||
## 12. Non-goals
|
## 12. Non-goals
|
||||||
|
|
||||||
JCP does not define a model or a tool registry. It does
|
JCP does not define a model or a tool registry. It does
|
||||||
|
|
|
||||||
|
|
@ -609,6 +609,10 @@ pub enum DenyReason {
|
||||||
/// A wire request the enforcement point has already decided, presented
|
/// A wire request the enforcement point has already decided, presented
|
||||||
/// again (SPEC §11). It is decided once, and debited once.
|
/// again (SPEC §11). It is decided once, and debited once.
|
||||||
Replayed,
|
Replayed,
|
||||||
|
/// The enforcement point could not record the request before deciding it
|
||||||
|
/// (SPEC §4.11), so it does not decide it: a decision it could not record
|
||||||
|
/// would be lost, with its debit, on a restart.
|
||||||
|
Unrecorded,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DenyReason {
|
impl DenyReason {
|
||||||
|
|
@ -632,6 +636,7 @@ impl DenyReason {
|
||||||
DenyReason::TaintBudgetExceeded => "taint-budget-exceeded",
|
DenyReason::TaintBudgetExceeded => "taint-budget-exceeded",
|
||||||
DenyReason::StaleRequest => "stale-request",
|
DenyReason::StaleRequest => "stale-request",
|
||||||
DenyReason::Replayed => "replayed",
|
DenyReason::Replayed => "replayed",
|
||||||
|
DenyReason::Unrecorded => "unrecorded",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
108
jcp/jcp-rs/crates/jcp-wire/src/journal.rs
Normal file
108
jcp/jcp-rs/crates/jcp-wire/src/journal.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
//! The endpoint's journal (SPEC §4.11): every request it decided and every
|
||||||
|
//! revocation it honoured, recorded before it acts, and replayed through the
|
||||||
|
//! gate when it restarts, so a restart gives no budget back, unrevokes
|
||||||
|
//! nothing, and forgets no request it may still be shown again.
|
||||||
|
|
||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::io::{self, Read, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use jcp_schema::{canonical_cbor, from_canonical_cbor, Grant, SignedRevocation};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::WireRequest;
|
||||||
|
|
||||||
|
/// One record in an endpoint's journal.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub enum JournalRecord {
|
||||||
|
/// A request the endpoint admitted, and the time it decided it at.
|
||||||
|
/// Written before the decision is made, so a decision the endpoint was
|
||||||
|
/// interrupted in is still on record and is debited on replay.
|
||||||
|
Decided { at: u64, request: WireRequest },
|
||||||
|
/// A revocation the endpoint honoured, with the grant it revokes.
|
||||||
|
Revoked { revocation: SignedRevocation, grant: Grant },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where an endpoint records what it decided and revoked.
|
||||||
|
pub trait Journal {
|
||||||
|
/// Record one record durably. An error refuses what it was for: a request
|
||||||
|
/// that could not be recorded is denied `unrecorded`, and a revocation
|
||||||
|
/// that could not be recorded is not applied.
|
||||||
|
fn append(&mut self, record: &JournalRecord) -> io::Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A journal in memory, and the default. It is enough for an endpoint that
|
||||||
|
/// never restarts; one that can restart MUST use a durable journal such as
|
||||||
|
/// [`FileJournal`] (SPEC §4.11).
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct MemoryJournal {
|
||||||
|
records: Vec<JournalRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryJournal {
|
||||||
|
/// The records so far, in order.
|
||||||
|
pub fn records(&self) -> &[JournalRecord] {
|
||||||
|
&self.records
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Journal for MemoryJournal {
|
||||||
|
fn append(&mut self, record: &JournalRecord) -> io::Result<()> {
|
||||||
|
self.records.push(record.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A journal in an append-only file: records framed as on the wire, a 4-byte
|
||||||
|
/// big-endian length then canonical det-CBOR, each synced to disk before
|
||||||
|
/// [`append`](Journal::append) returns.
|
||||||
|
pub struct FileJournal {
|
||||||
|
file: File,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileJournal {
|
||||||
|
/// Open the journal at `path`, creating it if absent. Returns it with the
|
||||||
|
/// records it already holds, for [`Endpoint::with_journal`](crate::Endpoint::with_journal)
|
||||||
|
/// to replay.
|
||||||
|
///
|
||||||
|
/// A final record cut short is an append the endpoint was interrupted in.
|
||||||
|
/// It is dropped and cut from the file, which is safe for both kinds: a
|
||||||
|
/// request never recorded was never decided, and a revocation never
|
||||||
|
/// recorded was never applied. A complete record that does not decode is an
|
||||||
|
/// error, because a journal that cannot be read cannot re-establish state.
|
||||||
|
pub fn open(path: impl AsRef<Path>) -> io::Result<(Self, Vec<JournalRecord>)> {
|
||||||
|
let mut file = OpenOptions::new().read(true).append(true).create(true).open(path)?;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
file.read_to_end(&mut bytes)?;
|
||||||
|
let mut records = Vec::new();
|
||||||
|
let mut at = 0usize;
|
||||||
|
while bytes.len() - at >= 4 {
|
||||||
|
let len = u32::from_be_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]) as usize;
|
||||||
|
if bytes.len() - at - 4 < len {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let body = &bytes[at + 4..at + 4 + len];
|
||||||
|
let record = from_canonical_cbor(body).map_err(|e| {
|
||||||
|
io::Error::new(io::ErrorKind::InvalidData, format!("journal record {}: {e}", records.len()))
|
||||||
|
})?;
|
||||||
|
records.push(record);
|
||||||
|
at += 4 + len;
|
||||||
|
}
|
||||||
|
if at < bytes.len() {
|
||||||
|
file.set_len(at as u64)?;
|
||||||
|
file.sync_data()?;
|
||||||
|
}
|
||||||
|
Ok((FileJournal { file }, records))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Journal for FileJournal {
|
||||||
|
fn append(&mut self, record: &JournalRecord) -> io::Result<()> {
|
||||||
|
let body = canonical_cbor(record).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
|
||||||
|
let mut framed = Vec::with_capacity(4 + body.len());
|
||||||
|
framed.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||||
|
framed.extend_from_slice(&body);
|
||||||
|
self.file.write_all(&framed)?;
|
||||||
|
self.file.sync_data()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,15 @@
|
||||||
//! requester is the grant's holder and the party that metered the action
|
//! requester is the grant's holder and the party that metered the action
|
||||||
//! (SPEC §11): an endpoint takes no figure from a party it does not hold to
|
//! (SPEC §11): an endpoint takes no figure from a party it does not hold to
|
||||||
//! account for it.
|
//! account for it.
|
||||||
|
//!
|
||||||
|
//! An endpoint that can restart keeps a [`Journal`] (SPEC §4.11): each admitted
|
||||||
|
//! request is recorded before it is decided, each revocation before it is
|
||||||
|
//! applied, and [`Endpoint::with_journal`] replays them through the gate at the
|
||||||
|
//! times they were decided, so a restart gives no budget back, unrevokes
|
||||||
|
//! nothing, and forgets no request that may still be shown again.
|
||||||
|
|
||||||
|
mod journal;
|
||||||
|
pub use journal::{FileJournal, Journal, JournalRecord, MemoryJournal};
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
|
|
@ -53,6 +62,21 @@ pub enum WireError {
|
||||||
Closed,
|
Closed,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Errors from an endpoint's journal: an append it could not make, a
|
||||||
|
/// revocation it refused, or a journal that does not replay.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum EndpointError {
|
||||||
|
#[error("journal: {0}")]
|
||||||
|
Journal(#[from] std::io::Error),
|
||||||
|
#[error("revocation: {0}")]
|
||||||
|
Revocation(#[from] jcp_grant::GrantError),
|
||||||
|
/// A replayed record the gate would not have produced: a request whose
|
||||||
|
/// caller signature fails, or that was decided twice, or a revocation that
|
||||||
|
/// does not verify.
|
||||||
|
#[error("journal record {record} is inconsistent: {reason}")]
|
||||||
|
Inconsistent { record: usize, reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
/// One action over the wire: decide `frame` against `chain` (root first, leaf
|
/// One action over the wire: decide `frame` against `chain` (root first, leaf
|
||||||
/// last) for `caller` at metered `cost`. A single un-delegated grant is a
|
/// last) for `caller` at metered `cost`. A single un-delegated grant is a
|
||||||
/// one-link chain.
|
/// one-link chain.
|
||||||
|
|
@ -201,6 +225,8 @@ pub struct Endpoint {
|
||||||
skew_secs: u64,
|
skew_secs: u64,
|
||||||
/// The requests decided within the window, by content id, with their stamp.
|
/// The requests decided within the window, by content id, with their stamp.
|
||||||
decided: HashMap<ContentId, u64>,
|
decided: HashMap<ContentId, u64>,
|
||||||
|
/// Where each admitted request and each revocation is recorded first.
|
||||||
|
journal: Box<dyn Journal + Send>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Endpoint {
|
impl Endpoint {
|
||||||
|
|
@ -214,9 +240,54 @@ impl Endpoint {
|
||||||
clock: system_clock,
|
clock: system_clock,
|
||||||
skew_secs: DEFAULT_SKEW_SECS,
|
skew_secs: DEFAULT_SKEW_SECS,
|
||||||
decided: HashMap::new(),
|
decided: HashMap::new(),
|
||||||
|
journal: Box::new(MemoryJournal::default()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record in `journal`, first replaying `recorded`, the records it already
|
||||||
|
/// holds, through the gate (SPEC §4.11). Each request is decided again at
|
||||||
|
/// the time it was decided, which re-establishes the spend and the memory
|
||||||
|
/// of requests still inside the window; each revocation is applied again.
|
||||||
|
/// Configure the runtime (trust roots, taint floors) as it was before
|
||||||
|
/// calling this, since the replay decides under it.
|
||||||
|
pub fn with_journal(mut self, journal: impl Journal + Send + 'static, recorded: &[JournalRecord]) -> Result<Self, EndpointError> {
|
||||||
|
let inconsistent = |record: usize, reason: &str| EndpointError::Inconsistent { record, reason: reason.into() };
|
||||||
|
for (i, record) in recorded.iter().enumerate() {
|
||||||
|
match record {
|
||||||
|
JournalRecord::Decided { at, request } => {
|
||||||
|
if !request.verify_caller() {
|
||||||
|
return Err(inconsistent(i, "the caller's signature does not verify"));
|
||||||
|
}
|
||||||
|
let id = ContentId::of(request).map_err(|e| inconsistent(i, &e.to_string()))?;
|
||||||
|
if self.decided.insert(id, request.now).is_some() {
|
||||||
|
return Err(inconsistent(i, "the request was decided twice"));
|
||||||
|
}
|
||||||
|
self.runtime.decide_chained(&request.frame, &request.chain, request.caller, request.cost, *at);
|
||||||
|
}
|
||||||
|
JournalRecord::Revoked { revocation, grant } => {
|
||||||
|
self.runtime
|
||||||
|
.apply_revocation(revocation, grant)
|
||||||
|
.map_err(|e| inconsistent(i, &e.to_string()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let now = (self.clock)();
|
||||||
|
let skew = self.skew_secs;
|
||||||
|
self.decided.retain(|_, stamp| stamp.saturating_add(skew) >= now);
|
||||||
|
self.journal = Box::new(journal);
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Honour a revocation: verify it, record it, then apply it, so it
|
||||||
|
/// survives a restart. Applying one to `runtime` directly works until the
|
||||||
|
/// endpoint restarts.
|
||||||
|
pub fn revoke(&mut self, revocation: &jcp_schema::SignedRevocation, grant: &jcp_schema::Grant) -> Result<(), EndpointError> {
|
||||||
|
jcp_grant::verify_revocation(revocation, grant)?;
|
||||||
|
self.journal.append(&JournalRecord::Revoked { revocation: revocation.clone(), grant: grant.clone() })?;
|
||||||
|
self.runtime.apply_revocation(revocation, grant)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Decide at `clock` (unix seconds) instead of the system clock.
|
/// Decide at `clock` (unix seconds) instead of the system clock.
|
||||||
pub fn with_clock(mut self, clock: fn() -> u64) -> Self {
|
pub fn with_clock(mut self, clock: fn() -> u64) -> Self {
|
||||||
self.clock = clock;
|
self.clock = clock;
|
||||||
|
|
@ -251,7 +322,15 @@ impl Endpoint {
|
||||||
/// other decision — the refused impersonation is evidence too.
|
/// other decision — the refused impersonation is evidence too.
|
||||||
pub fn handle(&mut self, req: &WireRequest) -> WireResponse {
|
pub fn handle(&mut self, req: &WireRequest) -> WireResponse {
|
||||||
let now = (self.clock)();
|
let now = (self.clock)();
|
||||||
let refused = if req.verify_caller() { self.admit(req, now).err() } else { Some(jcp_schema::DenyReason::BadSignature) };
|
let mut refused = if req.verify_caller() { self.admit(req, now).err() } else { Some(jcp_schema::DenyReason::BadSignature) };
|
||||||
|
// Recorded before it is decided: a decision that is lost on a restart
|
||||||
|
// would take its debit with it (SPEC §4.11).
|
||||||
|
if refused.is_none() && self.journal.append(&JournalRecord::Decided { at: now, request: req.clone() }).is_err() {
|
||||||
|
if let Ok(id) = ContentId::of(req) {
|
||||||
|
self.decided.remove(&id);
|
||||||
|
}
|
||||||
|
refused = Some(jcp_schema::DenyReason::Unrecorded);
|
||||||
|
}
|
||||||
let decision = match refused {
|
let decision = match refused {
|
||||||
None => self.runtime.decide_chained(&req.frame, &req.chain, req.caller, req.cost, now),
|
None => self.runtime.decide_chained(&req.frame, &req.chain, req.caller, req.cost, now),
|
||||||
Some(reason) => Decision {
|
Some(reason) => Decision {
|
||||||
|
|
@ -460,6 +539,118 @@ mod tests {
|
||||||
assert!(ep.decided.is_empty(), "nothing outside the window is remembered");
|
assert!(ep.decided.is_empty(), "nothing outside the window is remembered");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A journal the test can read after the endpoint is gone.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct Shared(std::sync::Arc<std::sync::Mutex<Vec<JournalRecord>>>);
|
||||||
|
impl Journal for Shared {
|
||||||
|
fn append(&mut self, record: &JournalRecord) -> std::io::Result<()> {
|
||||||
|
self.0.lock().unwrap().push(record.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A journal that cannot be written.
|
||||||
|
struct Broken;
|
||||||
|
impl Journal for Broken {
|
||||||
|
fn append(&mut self, _: &JournalRecord) -> std::io::Result<()> {
|
||||||
|
Err(std::io::Error::other("disk full"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endpoint(clock: fn() -> u64) -> Endpoint {
|
||||||
|
Endpoint::new(Runtime::new(), sk(7), "did:example:steward").with_clock(clock)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A restart is not a fresh budget: the endpoint replays what it decided,
|
||||||
|
/// so the leaf has what it had left.
|
||||||
|
#[test]
|
||||||
|
fn a_restart_keeps_the_spend() {
|
||||||
|
let (chain, _, r) = fixture();
|
||||||
|
let journal = Shared::default();
|
||||||
|
let mut before = endpoint(at_10).with_journal(journal.clone(), &[]).unwrap();
|
||||||
|
assert!(before.handle(&stamped(&chain, r, 40_000, 10)).decision.is_allow());
|
||||||
|
drop(before);
|
||||||
|
let recorded = journal.0.lock().unwrap().clone();
|
||||||
|
let mut after = endpoint(at_10).with_journal(MemoryJournal::default(), &recorded).unwrap();
|
||||||
|
assert_eq!(after.runtime.spent(&chain[1].grant.id()), 40_000, "the restart gave nothing back");
|
||||||
|
let d = after.handle(&stamped(&chain, r, 20_000, 9));
|
||||||
|
assert_eq!(d.decision.verdict, Verdict::Deny(DenyReason::BudgetExceeded));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A revocation honoured through the endpoint is recorded, and still
|
||||||
|
/// stands after a restart.
|
||||||
|
#[test]
|
||||||
|
fn a_revocation_survives_a_restart() {
|
||||||
|
let (chain, _, r) = fixture();
|
||||||
|
let journal = Shared::default();
|
||||||
|
let mut before = endpoint(at_10).with_journal(journal.clone(), &[]).unwrap();
|
||||||
|
let of_root = revoke(&sk(1), &chain[0].grant, [9; 16]).unwrap();
|
||||||
|
assert!(before.revoke(&of_root, &chain[1].grant).is_err(), "a revocation of another grant");
|
||||||
|
before.revoke(&of_root, &chain[0].grant).unwrap();
|
||||||
|
drop(before);
|
||||||
|
let recorded = journal.0.lock().unwrap().clone();
|
||||||
|
assert_eq!(recorded.len(), 1, "a refused revocation is not recorded");
|
||||||
|
let mut after = endpoint(at_10).with_journal(MemoryJournal::default(), &recorded).unwrap();
|
||||||
|
assert_eq!(after.handle(&stamped(&chain, r, 1, 10)).decision.verdict, Verdict::Deny(DenyReason::Revoked));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The endpoint remembers what it decided across a restart, so a request
|
||||||
|
/// still inside the window is refused, not decided a second time.
|
||||||
|
#[test]
|
||||||
|
fn a_request_decided_before_a_restart_is_still_refused() {
|
||||||
|
let (chain, _, r) = fixture();
|
||||||
|
let journal = Shared::default();
|
||||||
|
let req = stamped(&chain, r, 10_000, 10);
|
||||||
|
let mut before = endpoint(at_10).with_journal(journal.clone(), &[]).unwrap();
|
||||||
|
assert!(before.handle(&req).decision.is_allow());
|
||||||
|
drop(before);
|
||||||
|
let recorded = journal.0.lock().unwrap().clone();
|
||||||
|
let mut after = endpoint(at_10).with_journal(MemoryJournal::default(), &recorded).unwrap();
|
||||||
|
assert_eq!(after.handle(&req).decision.verdict, Verdict::Deny(DenyReason::Replayed));
|
||||||
|
assert_eq!(after.runtime.spent(&chain[1].grant.id()), 10_000);
|
||||||
|
|
||||||
|
let twice = [recorded[0].clone(), recorded[0].clone()];
|
||||||
|
assert!(matches!(endpoint(at_10).with_journal(MemoryJournal::default(), &twice), Err(EndpointError::Inconsistent { record: 1, .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A request that cannot be recorded is not decided, and debits nothing.
|
||||||
|
#[test]
|
||||||
|
fn an_unrecordable_request_is_not_decided() {
|
||||||
|
let (chain, _, r) = fixture();
|
||||||
|
let mut ep = endpoint(at_10).with_journal(Broken, &[]).unwrap();
|
||||||
|
let req = stamped(&chain, r, 10_000, 10);
|
||||||
|
assert_eq!(ep.handle(&req).decision.verdict, Verdict::Deny(DenyReason::Unrecorded));
|
||||||
|
assert_eq!(ep.runtime.spent(&chain[1].grant.id()), 0);
|
||||||
|
assert!(ep.decided.is_empty(), "an undecided request is not remembered as decided");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file journal round-trips, and a record a crash cut short is dropped
|
||||||
|
/// and cut from the file rather than failing the restart.
|
||||||
|
#[test]
|
||||||
|
fn the_file_journal_drops_a_torn_record() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("jcp-wire-journal-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("journal.cbor");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let (chain, _, r) = fixture();
|
||||||
|
{
|
||||||
|
let (journal, recorded) = FileJournal::open(&path).unwrap();
|
||||||
|
assert!(recorded.is_empty());
|
||||||
|
let mut ep = endpoint(at_10).with_journal(journal, &recorded).unwrap();
|
||||||
|
assert!(ep.handle(&stamped(&chain, r, 30_000, 10)).decision.is_allow());
|
||||||
|
}
|
||||||
|
let whole = std::fs::metadata(&path).unwrap().len();
|
||||||
|
let mut f = std::fs::OpenOptions::new().append(true).open(&path).unwrap();
|
||||||
|
f.write_all(&[0, 0, 1, 0, 7, 7]).unwrap();
|
||||||
|
drop(f);
|
||||||
|
let (journal, recorded) = FileJournal::open(&path).unwrap();
|
||||||
|
assert_eq!(recorded.len(), 1);
|
||||||
|
assert_eq!(std::fs::metadata(&path).unwrap().len(), whole, "the torn record was cut");
|
||||||
|
let after = endpoint(at_10).with_journal(journal, &recorded).unwrap();
|
||||||
|
assert_eq!(after.runtime.spent(&chain[1].grant.id()), 30_000);
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
// the fixture's caller is sk(3) — sign as them
|
// the fixture's caller is sk(3) — sign as them
|
||||||
fn request(chain: &[SignedGrant], _caller: Did, r: ContentId, trust: Trust, cost: u64) -> WireRequest {
|
fn request(chain: &[SignedGrant], _caller: Did, r: ContentId, trust: Trust, cost: u64) -> WireRequest {
|
||||||
let frame = Frame {
|
let frame = Frame {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue