use serde::{Deserialize, Serialize};
use serde_json::Value;
use trust_tasks_rs::TrustTask;
use vti_common::acl::Capability;
use crate::audit;
use crate::auth::AuthClaims;
use crate::operations::{room_groups, room_invitation};
use crate::server::AppState;
use super::helpers::{
TRANSPORT_TRUST_TASK, TrustTaskOutcome, app_error_to_reject, parse_payload, success_response,
};
const KEY_PACKAGE_LIFETIME_SECS: u64 = 7 * 24 * 60 * 60;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KeyPackageResponse {
pub key_package: String,
pub expires_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WelcomeResponse {
pub epoch: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommitResponse {
pub epoch: u64,
}
pub(super) async fn handle_key_package(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: trust_tasks_rs::specs::rooms::keys::key_package::v0_1::Payload =
match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
if let Err(e) =
require_invitation(state, req.invitation.as_deref(), &req.room_id, &auth.did).await
{
return app_error_to_reject(&doc, e);
}
let minted = match room_groups::mint_key_package(
&state.room_groups_ks,
&req.room_id,
&auth.did,
KEY_PACKAGE_LIFETIME_SECS,
now(),
)
.await
{
Ok(m) => m,
Err(e) => return app_error_to_reject(&doc, e),
};
record(state, "rooms.keys.key-package", auth, &req.room_id).await;
success_response(
&doc,
KeyPackageResponse {
key_package: minted.key_package,
expires_at: rfc3339(minted.expires_at),
},
)
}
pub(super) async fn handle_welcome(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: trust_tasks_rs::specs::rooms::keys::welcome::v0_1::Payload = match parse_payload(&doc)
{
Ok(r) => r,
Err(resp) => return resp,
};
let invitation =
match require_invitation(state, req.invitation.as_deref(), &req.room_id, &auth.did).await {
Ok(i) => i,
Err(e) => return app_error_to_reject(&doc, e),
};
let welcome = match decode_b64(&req.welcome, "welcome") {
Ok(b) => b,
Err(e) => return app_error_to_reject(&doc, e),
};
let epoch = match room_groups::join(
&state.room_groups_ks,
&req.room_id,
invitation.subject(),
&welcome,
now(),
)
.await
{
Ok(e) => e,
Err(e) => return app_error_to_reject(&doc, e),
};
if let Err(e) = room_groups::consume_invitation(
&state.room_invitations_ks,
invitation.credential_id(),
&req.room_id,
now(),
)
.await
{
return app_error_to_reject(&doc, e);
}
record(state, "rooms.keys.welcome", auth, &req.room_id).await;
success_response(&doc, WelcomeResponse { epoch })
}
pub(super) async fn handle_commit(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let req: trust_tasks_rs::specs::rooms::keys::commit::v0_1::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let commit = match decode_b64(&req.commit, "commit") {
Ok(b) => b,
Err(e) => return app_error_to_reject(&doc, e),
};
let epoch = match room_groups::apply_commit(
&state.room_groups_ks,
&req.room_id,
&commit,
u64::from(req.epoch),
now(),
)
.await
{
Ok(e) => e,
Err(e) => return app_error_to_reject(&doc, e),
};
record(state, "rooms.keys.commit", auth, &req.room_id).await;
success_response(&doc, CommitResponse { epoch })
}
pub(super) async fn handle_seal(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = super::helpers::require_capability(
state,
auth,
&doc,
Capability::RoomOpen,
"sealing a room record",
)
.await
{
return r;
}
let req: trust_tasks_rs::specs::rooms::keys::seal::v0_1::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let plaintext = match base64::Engine::decode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
&req.plaintext,
) {
Ok(b) => b,
Err(e) => {
return app_error_to_reject(
&doc,
vti_common::error::AppError::Validation(format!("plaintext is not base64url: {e}")),
);
}
};
let sealed = match room_groups::seal_record(
&state.room_groups_ks,
&req.room_id,
&req.key,
req.version,
&plaintext,
)
.await
{
Ok(s) => s,
Err(e) => return app_error_to_reject(&doc, e),
};
record(state, "rooms.keys.seal", auth, &req.room_id).await;
success_response(
&doc,
serde_json::json!({
"sealed": {
"ciphertext": sealed.ciphertext,
"nonce": sealed.nonce,
"epoch": sealed.epoch,
}
}),
)
}
pub(super) async fn handle_list(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = super::helpers::require_capability(
state,
auth,
&doc,
Capability::RoomOpen,
"listing the rooms this VTA holds keys for",
)
.await
{
return r;
}
let rooms = match room_groups::list_rooms(&state.room_groups_ks).await {
Ok(r) => r,
Err(e) => return app_error_to_reject(&doc, e),
};
record(state, "rooms.keys.list", auth, "").await;
success_response(&doc, serde_json::json!({ "rooms": rooms }))
}
pub(super) async fn handle_chain(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = super::helpers::require_capability(
state,
auth,
&doc,
Capability::RoomOpen,
"extending a room's readable history",
)
.await
{
return r;
}
let req: trust_tasks_rs::specs::rooms::keys::chain::v0_1::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let links: Result<Vec<vti_rooms::wire::EpochLink>, _> = req
.links
.iter()
.map(|l| {
u32::try_from(l.epoch).map(|epoch| vti_rooms::wire::EpochLink {
epoch,
wrapped: l.wrapped.clone(),
nonce: l.nonce.clone(),
})
})
.collect();
let links = match links {
Ok(l) => l,
Err(_) => {
return app_error_to_reject(
&doc,
vti_common::error::AppError::Validation(
"an epoch link names an epoch outside the representable range".into(),
),
);
}
};
let (earliest, stored) =
match room_groups::store_links(&state.room_groups_ks, &req.room_id, links, now()).await {
Ok(r) => r,
Err(e) => return app_error_to_reject(&doc, e),
};
record(state, "rooms.keys.chain", auth, &req.room_id).await;
success_response(
&doc,
serde_json::json!({
"roomId": req.room_id,
"earliestReadableEpoch": earliest,
"stored": stored,
}),
)
}
fn signing_context<'a>(
state: &'a AppState,
auth: &'a AuthClaims,
) -> crate::operations::room_issuance::SigningContext<'a> {
crate::operations::room_issuance::SigningContext {
keys_ks: &state.keys_ks,
imported_ks: &state.imported_ks,
internal_ks: &state.internal_ks,
contexts_ks: &state.contexts_ks,
acl_ks: &state.acl_ks,
seed_store: &state.seed_store,
audit: &state.audit_sink,
auth,
}
}
pub(super) async fn handle_backfill(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = super::helpers::require_capability(
state,
auth,
&doc,
Capability::RoomOpen,
"fetching a room's readable history",
)
.await
{
return r;
}
let req: trust_tasks_rs::specs::rooms::keys::backfill::v0_1::Payload = match parse_payload(&doc)
{
Ok(r) => r,
Err(resp) => return resp,
};
let (vta_did, resolver) = match outbound_identity(state, &doc).await {
Ok(v) => v,
Err(resp) => return resp,
};
let minted =
match crate::operations::room_oracle::present(state, auth, &vta_did, &req.room_id, "read")
.await
{
Ok(m) => m,
Err(e) => return app_error_to_reject(&doc, e),
};
let mut payload = serde_json::json!({
"roomId": req.room_id,
"presentation": minted.presentation,
});
if let Some(from) = req.from_epoch {
payload["fromEpoch"] = serde_json::json!(u64::from(from));
}
if let Some(limit) = req.limit {
payload["limit"] = serde_json::json!(u64::from(limit));
}
let key = format!("{vta_did}#key-0");
let reply = match crate::operations::room_host::send_room_task(
signing_context(state, auth),
&state.room_groups_ks,
&req.room_id,
&crate::operations::outbound::Outbound::from_app_state(state, &resolver),
&req.host,
&key,
&vta_did,
&key,
vti_rooms::wire::ROOMS_EPOCH_CHAIN_TYPE,
&format!("{}#response", vti_rooms::wire::ROOMS_EPOCH_CHAIN_TYPE),
payload,
)
.await
{
Ok(v) => v,
Err(e) => return app_error_to_reject(&doc, e),
};
let links: Vec<vti_rooms::wire::EpochLink> =
match serde_json::from_value(reply.get("links").cloned().unwrap_or(Value::Array(vec![]))) {
Ok(l) => l,
Err(e) => {
return app_error_to_reject(
&doc,
vti_common::error::AppError::Internal(format!(
"room host `{}` served rungs this agent cannot read: {e}",
req.host
)),
);
}
};
let fetched = links.len();
let (earliest, stored) =
match room_groups::store_links(&state.room_groups_ks, &req.room_id, links, now()).await {
Ok(r) => r,
Err(e) => return app_error_to_reject(&doc, e),
};
record(state, "rooms.keys.backfill", auth, &req.room_id).await;
success_response(
&doc,
serde_json::json!({
"roomId": req.room_id,
"earliestReadableEpoch": earliest,
"fetched": fetched,
"stored": stored,
}),
)
}
pub(super) async fn outbound_identity(
state: &AppState,
doc: &TrustTask<Value>,
) -> Result<(String, affinidi_did_resolver_cache_sdk::DIDCacheClient), TrustTaskOutcome> {
let Some(vta_did) = state.config.read().await.vta_did.clone() else {
return Err(app_error_to_reject(
doc,
vti_common::error::AppError::Validation(
"this agent has no DID of its own, so it cannot present to a host as itself".into(),
),
));
};
let Some(resolver) = state.did_resolver.clone() else {
return Err(app_error_to_reject(
doc,
vti_common::error::AppError::Validation(
"this agent has no DID resolver configured, so it cannot find the host".into(),
),
));
};
Ok((vta_did, resolver))
}
fn head_of(
commitment: Option<&String>,
record_count: Option<u64>,
head_version: Option<u64>,
) -> Option<Value> {
match (commitment, record_count, head_version) {
(Some(c), Some(n), Some(v)) => Some(serde_json::json!({
"dataCommitment": c,
"recordCount": n,
"headVersion": v,
})),
_ => None,
}
}
async fn compare_anchor(state: &AppState, room_id: &str, head: Option<&Value>) -> &'static str {
use crate::operations::room_anchor::{AnchorVerdict, compare_to_anchor};
let Some(head) = head else {
return AnchorVerdict::NotChecked.as_wire();
};
let Some(version) = head["headVersion"].as_u64() else {
return AnchorVerdict::NotChecked.as_wire();
};
let Some(resolver) = state.did_resolver.as_ref() else {
return AnchorVerdict::NotChecked.as_wire();
};
let Ok(resolved) = resolver.resolve(room_id).await else {
return AnchorVerdict::NotChecked.as_wire();
};
let Ok(document) = serde_json::to_value(&resolved.doc) else {
return AnchorVerdict::NotChecked.as_wire();
};
compare_to_anchor(&document, version, head["dataCommitment"].as_str()).as_wire()
}
async fn compare_head(state: &AppState, room_id: &str, head: Option<&Value>) -> &'static str {
let Some(head) = head else {
return "notChecked";
};
let (Some(version), Some(root)) = (
head["headVersion"].as_u64(),
head["dataCommitment"].as_str(),
) else {
return "notChecked";
};
match room_groups::observe_head(&state.room_groups_ks, room_id, version, root).await {
Ok(room_groups::RootVerdict::Agree) => "agree",
Ok(room_groups::RootVerdict::Conflict) => "conflict",
Ok(room_groups::RootVerdict::NoneHeld) => "noneHeld",
Err(e) => {
tracing::error!(
room = %room_id,
error = %e,
"could not read this agent's root history; answering without a comparison"
);
"notChecked"
}
}
}
fn verify_trace(reply: &vti_rooms::wire::GetRecordResponse) -> &'static str {
let (Some(commitment), Some(trace)) = (&reply.data_commitment, &reply.trace) else {
return "notOffered";
};
let Ok(root) = vti_rooms::merkle::from_multibase(commitment) else {
return "failed";
};
let Ok(leaf) = vti_rooms::merkle::leaf_hash(&reply.record) else {
return "failed";
};
if vti_rooms::merkle::verify_inclusion(&root, &leaf, trace) {
"verified"
} else {
"failed"
}
}
pub(super) async fn handle_read(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = super::helpers::require_capability(
state,
auth,
&doc,
Capability::RoomOpen,
"reading a room record",
)
.await
{
return r;
}
let req: trust_tasks_rs::specs::rooms::keys::read::v0_1::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let (vta_did, resolver) = match outbound_identity(state, &doc).await {
Ok(v) => v,
Err(resp) => return resp,
};
let minted =
match crate::operations::room_oracle::present(state, auth, &vta_did, &req.room_id, "read")
.await
{
Ok(m) => m,
Err(e) => return app_error_to_reject(&doc, e),
};
let key = format!("{vta_did}#key-0");
let reply = match crate::operations::room_host::send_room_task(
signing_context(state, auth),
&state.room_groups_ks,
&req.room_id,
&crate::operations::outbound::Outbound::from_app_state(state, &resolver),
&req.host,
&key,
&vta_did,
&key,
vti_rooms::wire::ROOMS_RECORDS_GET_TYPE,
&format!("{}#response", vti_rooms::wire::ROOMS_RECORDS_GET_TYPE),
serde_json::json!({
"roomId": req.room_id,
"key": req.key,
"presentation": minted.presentation,
}),
)
.await
{
Ok(v) => v,
Err(e) => return app_error_to_reject(&doc, e),
};
let served: vti_rooms::wire::GetRecordResponse = match serde_json::from_value(reply) {
Ok(r) => r,
Err(e) => {
return app_error_to_reject(
&doc,
vti_common::error::AppError::Internal(format!(
"room host `{}` served a record this agent cannot read: {e}",
req.host
)),
);
}
};
let trace = verify_trace(&served);
let mut payload = serde_json::json!({
"roomId": req.room_id,
"key": served.record.key,
"version": served.record.version,
"status": served.record.status,
"updatedAt": served.record.updated_at,
"verification": { "trace": trace },
});
if let Some(author) = &served.record.author {
payload["author"] = serde_json::json!(author);
}
let head = head_of(
served.data_commitment.as_ref(),
served.record_count,
served.head_version,
);
payload["verification"]["priorRoots"] =
serde_json::json!(compare_head(state, &req.room_id, head.as_ref()).await);
payload["verification"]["anchor"] =
serde_json::json!(compare_anchor(state, &req.room_id, head.as_ref()).await);
if let Some(head) = head {
payload["verification"]["head"] = head;
}
if let Some(sealed) = &served.record.sealed {
let plaintext = match room_groups::open_record(
&state.room_groups_ks,
&req.room_id,
&served.record.key,
served.record.version,
&sealed.ciphertext,
&sealed.nonce,
sealed.epoch,
)
.await
{
Ok(p) => p,
Err(e) => return app_error_to_reject(&doc, e),
};
payload["plaintext"] = serde_json::json!(plaintext);
} else if let Some(cleartext) = &served.record.cleartext {
payload["cleartext"] = cleartext.clone();
}
record(state, "rooms.keys.read", auth, &req.room_id).await;
success_response(&doc, payload)
}
pub(super) async fn handle_browse(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = super::helpers::require_capability(
state,
auth,
&doc,
Capability::RoomOpen,
"listing a room's records",
)
.await
{
return r;
}
let req: trust_tasks_rs::specs::rooms::keys::browse::v0_1::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let (vta_did, resolver) = match outbound_identity(state, &doc).await {
Ok(v) => v,
Err(resp) => return resp,
};
let minted =
match crate::operations::room_oracle::present(state, auth, &vta_did, &req.room_id, "read")
.await
{
Ok(m) => m,
Err(e) => return app_error_to_reject(&doc, e),
};
let key = format!("{vta_did}#key-0");
let mut records: Vec<Value> = Vec::new();
let mut cursor: Option<String> = None;
let mut head: Option<Value> = None;
let mut complete = false;
const MAX_PAGES: usize = 64;
for page in 0..MAX_PAGES {
let mut payload = serde_json::json!({
"roomId": req.room_id,
"presentation": minted.presentation,
});
if let Some(p) = &req.prefix {
payload["prefix"] = serde_json::json!(p);
}
if let Some(v) = req.since_version {
payload["sinceVersion"] = serde_json::json!(v);
}
if let Some(l) = req.limit {
payload["limit"] = serde_json::json!(u64::from(l));
}
if let Some(c) = &cursor {
payload["cursor"] = serde_json::json!(c);
}
let reply = match crate::operations::room_host::send_room_task(
signing_context(state, auth),
&state.room_groups_ks,
&req.room_id,
&crate::operations::outbound::Outbound::from_app_state(state, &resolver),
&req.host,
&key,
&vta_did,
&key,
vti_rooms::wire::ROOMS_RECORDS_LIST_TYPE,
&format!("{}#response", vti_rooms::wire::ROOMS_RECORDS_LIST_TYPE),
payload,
)
.await
{
Ok(v) => v,
Err(e) => return app_error_to_reject(&doc, e),
};
let listing: vti_rooms::wire::ListRecordsResponse = match serde_json::from_value(reply) {
Ok(l) => l,
Err(e) => {
return app_error_to_reject(
&doc,
vti_common::error::AppError::Internal(format!(
"room host `{}` served a listing this agent cannot read: {e}",
req.host
)),
);
}
};
head = head_of(
listing.data_commitment.as_ref(),
listing.record_count,
listing.head_version,
);
records.extend(listing.records);
match listing.cursor {
Some(next) => cursor = Some(next),
None => {
complete = true;
break;
}
}
if page + 1 == MAX_PAGES {
break;
}
}
let filtered = req.prefix.is_some() || req.since_version.is_some();
let count = match (&head, complete, filtered) {
(None, _, _) => "notOffered",
(Some(_), false, _) | (Some(_), _, true) => "notComparable",
(Some(h), true, false) => {
let committed = h["recordCount"].as_u64().unwrap_or_default();
if records.len() as u64 == committed {
"agrees"
} else {
"short"
}
}
};
let mut verification = serde_json::json!({
"priorRoots": compare_head(state, &req.room_id, head.as_ref()).await,
"anchor": compare_anchor(state, &req.room_id, head.as_ref()).await,
"count": count,
});
if let Some(h) = head {
verification["head"] = h;
}
record(state, "rooms.keys.browse", auth, &req.room_id).await;
success_response(
&doc,
serde_json::json!({
"roomId": req.room_id,
"records": records,
"complete": complete,
"verification": verification,
}),
)
}
pub(super) async fn handle_open(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = super::helpers::require_capability(
state,
auth,
&doc,
Capability::RoomOpen,
"opening a room record",
)
.await
{
return r;
}
let req: trust_tasks_rs::specs::rooms::keys::open::v0_1::Payload = match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let plaintext = match room_groups::open_record(
&state.room_groups_ks,
&req.room_id,
&req.key,
u64::from(req.version),
&req.sealed.ciphertext,
&req.sealed.nonce,
u32::try_from(u64::from(req.sealed.epoch)).unwrap_or(u32::MAX),
)
.await
{
Ok(p) => p,
Err(e) => return app_error_to_reject(&doc, e),
};
record(state, "rooms.keys.open", auth, &req.room_id).await;
success_response(
&doc,
serde_json::json!({
"plaintext": base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
&plaintext,
)
}),
)
}
async fn require_invitation(
state: &AppState,
encoded: Option<&str>,
room_id: &str,
member_did: &str,
) -> Result<room_invitation::VerifiedInvitation, vti_common::error::AppError> {
let encoded = encoded.ok_or_else(|| {
vti_common::error::AppError::Validation(format!(
"no invitation presented for room `{room_id}`; joining a room is a two-party \
act and the invitation is the other party's half"
))
})?;
let keys = vti_rooms_dtg::DataIntegrityKeys(state.trust_task_vm_resolver());
let invitation = room_invitation::verify(encoded, room_id, member_did, &keys).await?;
if room_invitation::is_consumed(&state.room_invitations_ks, invitation.credential_id()).await? {
return Err(vti_common::error::AppError::Conflict(format!(
"invitation `{}` has already been used",
invitation.credential_id()
)));
}
Ok(invitation)
}
fn decode_b64(s: &str, what: &str) -> Result<Vec<u8>, vti_common::error::AppError> {
use base64::Engine as _;
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(s.trim())
.map_err(|e| vti_common::error::AppError::Validation(format!("decode the {what}: {e}")))
}
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn rfc3339(unix_seconds: u64) -> String {
chrono::DateTime::from_timestamp(unix_seconds as i64, 0)
.unwrap_or_else(|| chrono::DateTime::from_timestamp(0, 0).expect("epoch is in range"))
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
pub(super) async fn record(state: &AppState, action: &str, auth: &AuthClaims, room_id: &str) {
if let Err(e) = audit::record(
&state.audit_sink,
action,
&auth.did,
Some(room_id),
"success",
Some(TRANSPORT_TRUST_TASK),
None,
)
.await
{
tracing::error!(error = %e, action, "failed to record a room-group audit entry");
}
}
#[cfg(test)]
mod verification_tests {
use super::*;
use vti_rooms::{Record, RecordStatus};
fn room() -> Vec<Record> {
["a", "b", "c"]
.iter()
.enumerate()
.map(|(i, key)| Record {
key: (*key).into(),
version: i as u64 + 1,
epoch: Some(2),
status: RecordStatus::Active,
pinned: false,
sealed: Some("c2VhbGVkLWJvZHk".into()),
nonce: Some("bm9uY2UtMTI".into()),
cleartext: None,
author: None,
updated_at: 1_756_000_000,
})
.collect()
}
#[test]
fn a_trace_is_replayed_against_the_root_served_beside_it() {
let mut records = room();
let head = vti_rooms::merkle::tree_head(&mut records).expect("commits");
let leaves: Vec<_> = records
.iter()
.map(|r| vti_rooms::merkle::leaf_hash(&r.committed()).expect("hashes"))
.collect();
let trace = vti_rooms::merkle::inclusion_proof(&leaves, 1).expect("a trace");
let good = vti_rooms::wire::GetRecordResponse::of(&records[1], Some(&head), Some(trace));
assert_eq!(verify_trace(&good), "verified");
let wrong = vti_rooms::wire::GetRecordResponse {
record: records[0].committed(),
..good.clone()
};
assert_eq!(verify_trace(&wrong), "failed");
}
#[test]
fn a_host_that_offers_no_tree_is_not_a_host_that_failed() {
let records = room();
let bare = vti_rooms::wire::GetRecordResponse::of(&records[0], None, None);
assert_eq!(verify_trace(&bare), "notOffered");
let mut records = room();
let head = vti_rooms::merkle::tree_head(&mut records).expect("commits");
let rootless = vti_rooms::wire::GetRecordResponse::of(&records[0], Some(&head), None);
assert_eq!(verify_trace(&rootless), "notOffered");
}
#[test]
fn a_partial_head_is_no_head() {
assert!(head_of(Some(&"zQm…".to_string()), Some(118), Some(412)).is_some());
assert!(head_of(Some(&"zQm…".to_string()), None, Some(412)).is_none());
assert!(head_of(Some(&"zQm…".to_string()), Some(118), None).is_none());
assert!(head_of(None, Some(118), Some(412)).is_none());
}
}