1use serde_json::Value;
48use vta_sdk::prelude::*;
49use vtc_client::VtcClient;
50pub use vtc_client::rooms::body::RecordBasis;
51use vtc_client::rooms::body::{
52 RecordLocation, SealedRecordBody, VerifiedRecordBody, verify_record_body,
53};
54use vtc_client::rooms::{CleartextContent, RoomSession, SealedContent, Visibility};
55use vti_specs::vault::credentials::get::v0_2 as vault_get;
56
57pub struct RoomTarget<'a> {
65 pub host_url: &'a str,
66 pub host_did: Option<&'a str>,
67 pub room_id: &'a str,
68}
69
70impl RoomTarget<'_> {
71 fn client(&self) -> VtcClient {
72 VtcClient::anonymous(self.host_url, self.host_did.unwrap_or("did:key:zHost"))
77 }
78}
79
80async fn present(
100 client: &VtaClient,
101 target: &RoomTarget<'_>,
102 action: &str,
103) -> Result<RoomSession, Box<dyn std::error::Error>> {
104 let minted = client.room_present(target.room_id, action).await?;
105 session_from_minted(target.room_id, &minted)
106}
107
108pub fn session_from_minted(
119 room_id: &str,
120 minted: &Value,
121) -> Result<RoomSession, Box<dyn std::error::Error>> {
122 let presentation = minted
123 .get("presentation")
124 .ok_or_else(|| format!("the VTA's reply carried no presentation: {minted}"))?;
125 let membership = presentation
126 .get("membership")
127 .and_then(Value::as_str)
128 .ok_or("the minted presentation carried no membership credential")?;
129 let authority_values = presentation
130 .get("authority")
131 .and_then(Value::as_array)
132 .ok_or("the minted presentation carried no authority chain")?;
133 let authority: Vec<String> = authority_values
134 .iter()
135 .filter_map(|v| v.as_str().map(str::to_string))
136 .collect();
137 if authority.len() != authority_values.len() {
141 return Err("the minted authority chain contained a non-string link".into());
142 }
143
144 let mut session = RoomSession::new(room_id, membership, authority)?;
145 if let Some(binding) = presentation.get("subjectBinding").and_then(Value::as_str) {
146 session = session.with_subject_binding(binding);
147 }
148 Ok(session)
149}
150
151async fn act_and_send(
157 client: &VtaClient,
158 target: &RoomTarget<'_>,
159 type_uri: &str,
160 payload: Value,
161) -> Result<(String, Value), Box<dyn std::error::Error>> {
162 act_and_send_as(client, target, type_uri, payload, None).await
163}
164
165async fn act_and_send_as(
169 client: &VtaClient,
170 target: &RoomTarget<'_>,
171 type_uri: &str,
172 payload: Value,
173 issuer: Option<&str>,
174) -> Result<(String, Value), Box<dyn std::error::Error>> {
175 let host_did = target.host_did.unwrap_or("did:key:zHost");
176 let document = vta_sdk::client::unsigned_room_request(type_uri, host_did, issuer, payload)?;
177 let completed = client.room_act(&document).await.map_err(|e| {
178 let text = e.to_string();
179 let hint = if text.contains("principalAmbiguous") {
180 "\n\nYou may act as more than one member of this room: act from the context of \
181 the one you mean."
182 } else if text.contains("notAMember") {
183 "\n\nYour VTA holds no credential this room issued to a persona you may act as. \
184 The owner issues them with `pnm rooms owner issue-membership` and \
185 `issue-authority --store`."
186 } else if text.contains("actionNotHeld") {
187 "\n\nYour own credentials for this room do not confer that action."
188 } else {
189 ""
190 };
191 format!("{e}{hint}")
192 })?;
193 let principal = completed.principal.to_string();
194 let document = serde_json::to_value(&completed.document)?;
195 let reply = target.client().send_signed_room_document(&document).await?;
196 Ok((principal, reply))
197}
198
199pub(crate) async fn act_and_send_parsed<T: serde::de::DeserializeOwned>(
201 client: &VtaClient,
202 target: &RoomTarget<'_>,
203 type_uri: &str,
204 payload: Value,
205) -> Result<T, Box<dyn std::error::Error>> {
206 let (_, reply) = act_and_send(client, target, type_uri, payload).await?;
207 parse_reply(type_uri, reply)
208}
209
210fn parse_reply<T: serde::de::DeserializeOwned>(
212 what: &str,
213 value: Value,
214) -> Result<T, Box<dyn std::error::Error>> {
215 serde_json::from_value(value)
216 .map_err(|e| format!("the host's {what} response is not one this client reads: {e}").into())
217}
218
219pub fn pinned_from_flags(pin: bool, unpin: bool) -> Option<bool> {
225 if pin {
226 Some(true)
227 } else if unpin {
228 Some(false)
229 } else {
230 None
231 }
232}
233
234pub struct RoomSigner<'a> {
241 pub did: &'a str,
242 pub key_multibase: &'a str,
243}
244
245pub async fn cmd_rooms_list(
247 client: &VtaClient,
248 target: RoomTarget<'_>,
249 prefix: Option<&str>,
250 since_version: Option<u64>,
251 limit: Option<usize>,
252) -> Result<(), Box<dyn std::error::Error>> {
253 let mut listing = list_page(client, &target, prefix, since_version, None).await?;
258 let mut cursor = listing.cursor.clone();
259 while let Some(c) = cursor {
260 let next = list_page(client, &target, prefix, since_version, Some(&c)).await?;
261 cursor = next.cursor.clone();
262 listing.records.extend(next.records);
263 }
264
265 if crate::render::is_json_output() {
266 crate::render::print_json(&listing.records)?;
267 return Ok(());
268 }
269 if listing.records.is_empty() {
270 println!(
271 "No records{}.",
272 prefix.map(|p| format!(" under `{p}`")).unwrap_or_default()
273 );
274 return Ok(());
275 }
276
277 let shown = limit.unwrap_or(usize::MAX).min(listing.records.len());
278 if shown < listing.records.len() {
279 println!(
280 "{} record(s), showing {shown} — pass a larger --limit for the rest:",
281 listing.records.len()
282 );
283 } else {
284 println!("{} record(s):", listing.records.len());
285 }
286 for r in listing.records.iter().take(shown) {
287 let key = r.get("key").and_then(Value::as_str).unwrap_or("?");
288 let version = r.get("version").and_then(Value::as_u64).unwrap_or(0);
289 let status = r.get("status").and_then(Value::as_str).unwrap_or("active");
290 let title = r
293 .get("title")
294 .or_else(|| r.get("cleartext").and_then(|c| c.get("title")))
295 .and_then(Value::as_str)
296 .map(inert)
297 .unwrap_or_else(|| "(sealed)".to_string());
298 println!(" {key} v{version} {status:<10} {title}");
299 }
300 Ok(())
301}
302
303async fn list_page(
305 client: &VtaClient,
306 target: &RoomTarget<'_>,
307 prefix: Option<&str>,
308 since_version: Option<u64>,
309 cursor: Option<&str>,
310) -> Result<vti_rooms::wire::ListRecordsResponse, Box<dyn std::error::Error>> {
311 let mut payload = serde_json::json!({ "roomId": target.room_id });
312 if let Some(p) = prefix {
313 payload["prefix"] = serde_json::json!(p);
314 }
315 if let Some(v) = since_version {
316 payload["sinceVersion"] = serde_json::json!(v);
317 }
318 if let Some(c) = cursor {
319 payload["cursor"] = serde_json::json!(c);
320 }
321 let (_, mut reply) = act_and_send(
322 client,
323 target,
324 vti_rooms::wire::ROOMS_RECORDS_LIST_V5_TYPE,
325 payload,
326 )
327 .await?;
328 if let Some(obj) = reply.as_object_mut()
330 && let Some(next) = obj.remove("nextCursor")
331 {
332 obj.insert("cursor".into(), next);
333 }
334 parse_reply("rooms/records/list", reply)
335}
336
337pub async fn cmd_rooms_get(
345 client: &VtaClient,
346 target: RoomTarget<'_>,
347 key: &str,
348) -> Result<(), Box<dyn std::error::Error>> {
349 let (_, record) = act_and_send(
350 client,
351 &target,
352 vti_rooms::wire::ROOMS_RECORDS_GET_V2_TYPE,
353 serde_json::json!({ "roomId": target.room_id, "key": key }),
354 )
355 .await?;
356
357 if let Some(cleartext) = record.get("cleartext") {
361 if crate::render::is_json_output() {
362 crate::render::print_json(cleartext)?;
363 } else {
364 if let Some(title) = cleartext.get("title").and_then(Value::as_str) {
365 println!("{}\n", inert(title));
366 }
367 println!(
368 "{}",
369 inert(cleartext.get("body").and_then(Value::as_str).unwrap_or(""))
370 );
371 if let Some(obj) = cleartext.as_object()
373 && vti_rooms::provenance::carries_any(obj)
374 {
375 println!("\n{} (unsigned on an open room)", provenance_line(obj));
376 }
377 }
378 return Ok(());
379 }
380
381 let sealed: SealedContent =
382 serde_json::from_value(record.get("sealed").cloned().ok_or(
383 "the record carried neither cleartext nor sealed content; it may be retracted",
384 )?)
385 .map_err(|e| format!("the host's sealed record is malformed: {e}"))?;
386 let version = record
387 .get("version")
388 .and_then(Value::as_u64)
389 .ok_or("the host's record carried no version")?;
390 let author = record.get("author").and_then(Value::as_str);
391
392 let body = open_and_verify(client, &target, key, version, &sealed, author).await?;
393 let body = body.body();
394 if crate::render::is_json_output() {
395 crate::render::print_json(body)?;
396 return Ok(());
397 }
398 if let Some(title) = &body.title {
399 println!("{}\n", inert(title));
400 }
401 if let Some(text) = &body.body {
402 println!("{}", inert(text));
403 }
404 if let Some(file) = &body.file {
405 println!(
406 "\nFile: {} ({} bytes). Fetch it with `pnm rooms file get {key}`.",
407 inert(&file.name),
408 file.size
409 );
410 }
411 println!("\nWritten by {} at version {version}.", body.author);
412 if let Ok(Value::Object(obj)) = serde_json::to_value(body) {
413 println!("{}", provenance_line(&obj));
414 }
415 Ok(())
416}
417
418pub async fn cmd_rooms_put(
425 client: &VtaClient,
426 target: RoomTarget<'_>,
427 key: &str,
428 title: Option<String>,
429 body: String,
430 expected_version: Option<u64>,
431 provenance: RecordProvenance,
432) -> Result<(), Box<dyn std::error::Error>> {
433 let (sealed_tier, member) = room_tier(client, &target).await?;
434
435 if !sealed_tier {
436 if provenance.is_stated() {
437 return Err(open_tier_provenance_refused(&target).into());
438 }
439 let mut payload = serde_json::json!({
440 "roomId": target.room_id,
441 "key": key,
442 "cleartext": CleartextContent { title, body, ..Default::default() },
443 });
444 if let Some(v) = expected_version {
445 payload["expectedVersion"] = serde_json::json!(v);
446 }
447 let (_, reply) = act_and_send(
448 client,
449 &target,
450 vti_rooms::wire::ROOMS_RECORDS_PUT_V3_TYPE,
451 payload,
452 )
453 .await?;
454 let put: vti_rooms::wire::PutRecordResponse = parse_reply("rooms/records/put", reply)?;
455 println!("Wrote {} at version {} as {member}", put.key, put.version);
456 return Ok(());
457 }
458
459 let mut content = SealedRecordBody::new(
460 RecordLocation {
461 room_id: target.room_id,
462 key,
463 version: 1,
464 },
465 &member,
466 );
467 content.title = title;
468 content.body = Some(body);
469 provenance.apply(&mut content)?;
470 let version = write_sealed(client, &target, content, expected_version).await?;
471 println!("Wrote {key} at version {version}, sealed and signed by {member}");
472 Ok(())
473}
474
475#[derive(Debug, Clone, Default)]
482pub struct RecordProvenance {
483 pub basis: Option<vtc_client::rooms::body::RecordBasis>,
485 pub basis_named: bool,
487 pub confidence: Option<f64>,
489 pub cite_records: Vec<String>,
491 pub cite_credentials: Vec<String>,
493 pub purposes: Vec<String>,
495 pub use_note: Option<String>,
497 pub valid_until: Option<String>,
499}
500
501impl RecordProvenance {
502 pub fn is_stated(&self) -> bool {
504 self.basis_named
505 || self.confidence.is_some()
506 || !self.cite_records.is_empty()
507 || !self.cite_credentials.is_empty()
508 || !self.purposes.is_empty()
509 || self.use_note.is_some()
510 || self.valid_until.is_some()
511 }
512
513 pub fn apply(&self, body: &mut SealedRecordBody) -> Result<(), String> {
516 body.basis = self.basis;
517 body.confidence = self.confidence.map(|c| serde_json::json!(c));
518 let mut evidence = Vec::new();
519 for cite in &self.cite_records {
520 let (key, version) = match cite.rsplit_once('@') {
521 Some((k, v)) => (
522 k,
523 Some(v.parse::<u64>().map_err(|_| {
524 format!("--cite-record {cite}: the version after `@` is not a number")
525 })?),
526 ),
527 None => (cite.as_str(), None),
528 };
529 let mut e = serde_json::json!({ "kind": "record", "key": key });
530 if let Some(v) = version {
531 e["version"] = serde_json::json!(v);
532 }
533 evidence.push(e);
534 }
535 for id in &self.cite_credentials {
536 evidence.push(serde_json::json!({ "kind": "credential", "credentialId": id }));
537 }
538 body.evidence = (!evidence.is_empty()).then_some(Value::Array(evidence));
539 let mut valid_for = serde_json::Map::new();
540 if !self.purposes.is_empty() {
541 valid_for.insert("purposes".into(), serde_json::json!(self.purposes));
542 }
543 if let Some(note) = &self.use_note {
544 valid_for.insert("note".into(), serde_json::json!(note));
545 }
546 body.valid_for = (!valid_for.is_empty()).then_some(Value::Object(valid_for));
547 body.valid_until = self.valid_until.clone();
548 if let Ok(Value::Object(obj)) = serde_json::to_value(&*body) {
549 vti_rooms::provenance::check(&obj)?;
550 }
551 Ok(())
552 }
553}
554
555fn open_tier_provenance_refused(target: &RoomTarget<'_>) -> String {
561 format!(
562 "room {} is `open`: a record's provenance there rides `rooms/records/put/0.3`'s \
563 cleartext, which pnm does not write yet. Write it without --basis, --confidence, --cite-record, --cite-credential, --purpose, \
564 --use-note and --valid-until, or in a sealed (`attributed` or `private`) room, where \
565 they travel inside the signed body.",
566 target.room_id
567 )
568}
569
570fn provenance_line(obj: &serde_json::Map<String, Value>) -> String {
574 let mut line = match obj.get("basis").and_then(Value::as_str) {
575 Some(b) => format!("Basis: {}", inert(b)),
576 None => "Basis: not stated".to_string(),
577 };
578 if let Some(c) = obj.get("confidence").and_then(Value::as_f64) {
579 line.push_str(&format!(" (author's confidence {c})"));
580 }
581 if obj.get("basis").and_then(Value::as_str) == Some("verified") && !obj.contains_key("evidence")
582 {
583 line.push_str(" — names no evidence, so the claim to have checked is unsupported");
584 }
585 if let Some(n) = obj.get("evidence").and_then(Value::as_array).map(Vec::len) {
586 line.push_str(&format!("; cites {n} item(s), not fetched"));
587 }
588 if let Some(use_for) = obj.get("validFor") {
589 let purposes = use_for
590 .get("purposes")
591 .and_then(Value::as_array)
592 .map(|p| {
593 p.iter()
594 .filter_map(Value::as_str)
595 .map(inert)
596 .collect::<Vec<_>>()
597 .join(", ")
598 })
599 .unwrap_or_default();
600 let note = use_for
601 .get("note")
602 .and_then(Value::as_str)
603 .map(|n| format!(" \"{}\"", inert(n)))
604 .unwrap_or_default();
605 line.push_str(&format!("\nFor use: {purposes}{note}"));
606 }
607 if let Some(until) = obj.get("validUntil").and_then(Value::as_str) {
608 if vti_rooms::provenance::is_expired(obj, chrono::Utc::now()) {
609 line.push_str(&format!(
610 "\nEXPIRED: its author stopped standing behind it at {}.",
611 inert(until)
612 ));
613 } else {
614 line.push_str(&format!("\nValid until {}.", inert(until)));
615 }
616 }
617 line
618}
619
620pub async fn cmd_rooms_curate(
622 client: &VtaClient,
623 target: RoomTarget<'_>,
624 key: &str,
625 status: Option<String>,
626 pinned: Option<bool>,
627 reason: Option<String>,
628) -> Result<(), Box<dyn std::error::Error>> {
629 if status.is_none() && pinned.is_none() {
630 return Err("nothing to change — pass --status and/or --pin/--unpin".into());
631 }
632 let mut payload = serde_json::json!({ "roomId": target.room_id, "key": key });
637 if let Some(s) = status {
638 payload["status"] = serde_json::json!(s);
639 }
640 if let Some(p) = pinned {
641 payload["pinned"] = serde_json::json!(p);
642 }
643 if let Some(r) = reason {
644 payload["reason"] = serde_json::json!(r);
645 }
646 let (_, reply) = act_and_send(
647 client,
648 &target,
649 vti_rooms::wire::ROOMS_RECORDS_CURATE_TYPE,
650 payload,
651 )
652 .await?;
653 let out: vti_rooms::wire::CurateRecordResponse = parse_reply("rooms/records/curate", reply)?;
654 println!(
655 "{} is now {} at version {}{}",
656 out.key,
657 serde_json::to_value(out.status)
658 .ok()
659 .and_then(|v| v.as_str().map(str::to_string))
660 .unwrap_or_else(|| "?".into()),
661 out.version,
662 if out.pinned { " (pinned)" } else { "" }
663 );
664 Ok(())
665}
666
667pub async fn cmd_rooms_renew(
677 client: &VtaClient,
678 target: RoomTarget<'_>,
679 signer: RoomSigner<'_>,
680 epoch: u32,
681 reason: Option<String>,
682) -> Result<(), Box<dyn std::error::Error>> {
683 let read = present(client, &target, "read").await?;
686 let info = target
687 .client()
688 .room_info(&read, signer.did, signer.key_multibase)
689 .await
690 .map_err(|e| format!("could not read the room's tier (rooms/info): {e}"))?;
691 refuse_sealed_renew(&wire_str(&info.visibility), &target)?;
692
693 let session = present(client, &target, "admin").await?;
694 let minted = target
695 .client()
696 .mint_epoch(
697 &session,
698 epoch,
699 reason.as_deref(),
700 signer.did,
701 signer.key_multibase,
702 )
703 .await?;
704 println!("Room {} is at epoch {}", minted.room_id, minted.epoch);
705 Ok(())
706}
707
708fn refuse_sealed_renew(visibility: &str, target: &RoomTarget<'_>) -> Result<(), String> {
713 if visibility == "open" {
714 return Ok(());
715 }
716 Err(format!(
717 "room {room} is `{visibility}`: its epoch advances only with a commit of its group, \
718 which this client cannot make, and a bare mint would leave every member unable to \
719 write. Renew it from the owner's VTA instead:\n\n pnm rooms owner group-renew \
720 --room {room} --host-did {host_did}",
721 room = target.room_id,
722 host_did = target.host_did.unwrap_or("<the host's DID>"),
723 ))
724}
725
726pub async fn cmd_rooms_prune(
740 client: &VtaClient,
741 target: RoomTarget<'_>,
742 signer: RoomSigner<'_>,
743 before_epoch: u32,
744 reason: String,
745 assume_yes: bool,
746) -> Result<(), Box<dyn std::error::Error>> {
747 let read = present(client, &target, "read").await?;
748 let info = target
749 .client()
750 .room_info(&read, signer.did, signer.key_multibase)
751 .await
752 .map_err(|e| format!("could not read the room's epoch (rooms/info): {e}"))?;
753 let current = u32::try_from(info.epoch.get()).unwrap_or(u32::MAX);
754 if before_epoch >= current {
755 return Err(not_ahead_message(&target, before_epoch, current).into());
756 }
757 let policy = info.retention_policy.as_ref().map(wire_str);
758
759 eprintln!(
760 "{}",
761 prune_warning(target.room_id, before_epoch, current, policy.as_deref())
762 );
763 match prune_guard(
764 assume_yes,
765 std::io::IsTerminal::is_terminal(&std::io::stdin()),
766 ) {
767 PruneGuard::Proceed => {}
768 PruneGuard::Refuse => {
769 return Err(format!(
770 "refusing to prune without confirmation: there is no terminal to ask. If you \
771 mean it, run it again with --yes:\n\n {}",
772 prune_command(&target, before_epoch, &reason)
773 )
774 .into());
775 }
776 PruneGuard::Ask => {
777 if !super::contexts::confirm_destructive("Prune the chain?")? {
778 println!("Not pruned.");
779 return Ok(());
780 }
781 }
782 }
783
784 let session = present(client, &target, "admin").await?;
787 let pruned = target
788 .client()
789 .prune_epoch_v2(
790 &session,
791 before_epoch,
792 Some(&reason),
793 signer.did,
794 signer.key_multibase,
795 )
796 .await
797 .map_err(|e| prune_refused(e, &target, before_epoch, current))?;
798 let floor = pruned.epoch_floor.0.get();
799 let outcome = vtc_client::rooms::PruneResponse {
800 room_id: pruned.room_id.to_string(),
801 pruned: u32::try_from(pruned.pruned).unwrap_or(u32::MAX),
802 earliest_rung: u32::try_from(pruned.earliest_rung.get()).unwrap_or(u32::MAX),
803 };
804 println!(
805 "{} The room's epoch floor is now {floor}: members' VTAs that learn it forget \
806 what they hold below it.",
807 prune_outcome(&outcome)
808 );
809 Ok(())
810}
811
812#[derive(Debug, PartialEq, Eq)]
815enum PruneGuard {
816 Proceed,
817 Ask,
818 Refuse,
819}
820
821fn prune_guard(assume_yes: bool, interactive: bool) -> PruneGuard {
822 match (assume_yes, interactive) {
823 (true, _) => PruneGuard::Proceed,
824 (false, true) => PruneGuard::Ask,
825 (false, false) => PruneGuard::Refuse,
826 }
827}
828
829fn prune_warning(room_id: &str, before_epoch: u32, current: u32, policy: Option<&str>) -> String {
832 let floor = before_epoch.saturating_sub(1).max(1);
836 let span = if floor <= 1 {
837 "no epoch: rung 2 survives and still yields epoch 1, so nothing becomes unreadable"
838 .to_string()
839 } else if floor == 2 {
840 "epoch 1".to_string()
841 } else {
842 format!("epochs 1 to {}", floor - 1)
843 };
844 let policy_line = match policy {
845 Some("fromJoin") => {
846 "\n This room's retention policy is `fromJoin`: it keeps no chain, so there may be \
847 nothing to drop."
848 }
849 _ => "",
850 };
851 format!(
852 "WARNING: this cannot be undone.\n\n\
853 Pruning room {room_id} (now at epoch {current}) below epoch {before_epoch} makes \
854 {span} unreadable for good:\n\
855 \n - The host erases the epoch key chain below epoch {before_epoch}. Nobody keeps a \
856 copy that can bring it back.\n\
857 \n - The records stay, and the host still serves them. What goes is the ability to \
858 derive the keys they were sealed under: a member who restarts, moves to a new \
859 device, or joins later can never open them again, whatever authority they hold.\n\
860 \n - It reaches members, as far as they conform: the host keeps the room's epoch \
861 floor ({floor}), and members' VTAs that learn it forget the rungs and keys they \
862 hold below it and never take them back. It is not a recall: anything already \
863 read, decrypted or exported stays where it is, and a VTA that does not conform \
864 keeps what it held.{policy_line}\n\
865 \n Pruning straight after a membership change tells the host why the change \
866 happened; wait if that matters."
867 )
868}
869
870fn not_ahead_message(target: &RoomTarget<'_>, before_epoch: u32, current: u32) -> String {
872 let mut msg = format!(
873 "--before-epoch {before_epoch} is at or above room {}'s current epoch {current}: \
874 pruning there would drop the whole chain, not shorten it.",
875 target.room_id
876 );
877 if current > 1 {
878 msg.push_str(&format!(
879 " The highest floor that keeps the current epoch readable is {}:\n\n {}",
880 current - 1,
881 prune_command(target, current - 1, "<why>")
882 ));
883 }
884 msg
885}
886
887fn prune_command(target: &RoomTarget<'_>, before_epoch: u32, reason: &str) -> String {
889 let host_did = target
890 .host_did
891 .map(|d| format!(" --host-did {d}"))
892 .unwrap_or_default();
893 format!(
894 "pnm rooms prune --room {} --host {}{host_did} --before-epoch {before_epoch} \
895 --reason {} --yes",
896 target.room_id,
897 target.host_url,
898 shell_word(reason)
899 )
900}
901
902fn prune_refused(
904 e: vtc_client::VtcError,
905 target: &RoomTarget<'_>,
906 before_epoch: u32,
907 current: u32,
908) -> String {
909 let code = vtc_client::git_ns::task_error(&e).map(|(code, _)| code);
910 match code.as_deref() {
911 Some(c) if c.ends_with("notAhead") => {
912 format!(
914 "{e}\n\n{}",
915 not_ahead_message(target, before_epoch, current)
916 )
917 }
918 Some(c) if c.ends_with("permissionDenied") => format!(
919 "{e}\n\nPruning needs `admin` in the room — not `curate`, which every writer holds. \
920 The room's owner grants it with `pnm rooms owner issue-authority --action admin \
921 --store`."
922 ),
923 _ => e.to_string(),
924 }
925}
926
927fn prune_outcome(r: &vtc_client::rooms::PruneResponse) -> String {
930 let dropped = match r.pruned {
931 0 => "nothing to drop — the chain already reached back no further".to_string(),
932 1 => "1 rung dropped".to_string(),
933 n => format!("{n} rungs dropped"),
934 };
935 format!(
936 "Pruned room {}: {dropped}. Its chain now reaches back to epoch {}.",
937 r.room_id, r.earliest_rung
938 )
939}
940
941#[allow(clippy::too_many_arguments)]
950pub async fn cmd_rooms_records_prune(
951 client: &VtaClient,
952 target: RoomTarget<'_>,
953 signer: RoomSigner<'_>,
954 kinds: Vec<String>,
955 reason: String,
956 dry_run: bool,
957 assume_yes: bool,
958) -> Result<(), Box<dyn std::error::Error>> {
959 let kinds = if kinds.is_empty() {
960 vec!["retracted".to_string(), "aged".to_string()]
961 } else {
962 kinds
963 };
964 if let Some(bad) = kinds.iter().find(|k| *k != "retracted" && *k != "aged") {
965 return Err(format!("--kind {bad}: a kind is `retracted` or `aged`").into());
966 }
967 let kinds: Vec<&str> = kinds.iter().map(String::as_str).collect();
968 let session = present(client, &target, "admin").await?;
969 let host = target.client();
970 let preview = host
971 .prune_records(
972 &session,
973 &kinds,
974 &reason,
975 true,
976 signer.did,
977 signer.key_multibase,
978 )
979 .await
980 .map_err(|e| records_prune_refused(e, &target))?;
981 let preview = serde_json::to_value(&preview)?;
982 eprintln!("{}", records_prune_preview(target.room_id, &preview));
983 if dry_run {
984 if crate::render::is_json_output() {
985 crate::render::print_json(&preview)?;
986 }
987 return Ok(());
988 }
989 if preview["removed"].as_u64() == Some(0) {
990 println!("Nothing is eligible now; nothing pruned.");
991 return Ok(());
992 }
993 match prune_guard(
994 assume_yes,
995 std::io::IsTerminal::is_terminal(&std::io::stdin()),
996 ) {
997 PruneGuard::Proceed => {}
998 PruneGuard::Refuse => {
999 return Err(format!(
1000 "refusing to prune without confirmation: there is no terminal to ask. If you \
1001 mean it, run it again with --yes:\n\n {}",
1002 records_prune_command(&target, &kinds, &reason)
1003 )
1004 .into());
1005 }
1006 PruneGuard::Ask => {
1007 if !super::contexts::confirm_destructive("Remove these records for good?")? {
1008 println!("Not pruned.");
1009 return Ok(());
1010 }
1011 }
1012 }
1013 let session = present(client, &target, "admin").await?;
1015 let done = host
1016 .prune_records(
1017 &session,
1018 &kinds,
1019 &reason,
1020 false,
1021 signer.did,
1022 signer.key_multibase,
1023 )
1024 .await
1025 .map_err(|e| records_prune_refused(e, &target))?;
1026 let done = serde_json::to_value(&done)?;
1027 if crate::render::is_json_output() {
1028 crate::render::print_json(&done)?;
1029 return Ok(());
1030 }
1031 let removed = done["removed"].as_u64().unwrap_or(0);
1032 match done.get("prune") {
1033 Some(mark) => println!(
1034 "Pruned room {}: {removed} record(s) removed at version {}. Members syncing from \
1035 below it rebuild their copy.",
1036 target.room_id, mark["version"]
1037 ),
1038 None => println!(
1039 "Nothing was eligible by the time of the prune; nothing removed in room {}.",
1040 target.room_id
1041 ),
1042 }
1043 Ok(())
1044}
1045
1046fn records_prune_preview(room_id: &str, preview: &Value) -> String {
1049 let removed = preview["removed"].as_u64().unwrap_or(0);
1050 let blobs = preview["blobsReleased"].as_u64().unwrap_or(0);
1051 let rule = &preview["recordRetention"];
1052 let mut rule_text = Vec::new();
1053 if let Some(d) = rule["tombstoneDays"].as_u64() {
1054 rule_text.push(format!("tombstones kept {d} days"));
1055 }
1056 if let Some(d) = rule["recordDays"].as_u64() {
1057 rule_text.push(format!("records kept {d} days unless pinned"));
1058 }
1059 format!(
1060 "Room {room_id} keeps its records by this rule: {}.\n\
1061 Under it, a prune now removes {removed} record(s){}.\n\n\
1062 WARNING: a prune cannot be undone. Removed records go outright — tombstones \
1063 included — for every member, and the room keeps only a count and a time. It removes \
1064 them from this host, not from the world: what a member already read, exported or \
1065 cached stays where it is, and a mirror is pruned separately.",
1066 rule_text.join(", "),
1067 if blobs > 0 {
1068 format!(", releasing {blobs} file(s)")
1069 } else {
1070 String::new()
1071 }
1072 )
1073}
1074
1075fn records_prune_command(target: &RoomTarget<'_>, kinds: &[&str], reason: &str) -> String {
1077 let host_did = target
1078 .host_did
1079 .map(|d| format!(" --host-did {d}"))
1080 .unwrap_or_default();
1081 let kinds: String = kinds.iter().map(|k| format!(" --kind {k}")).collect();
1082 format!(
1083 "pnm rooms records prune --room {} --host {}{host_did}{kinds} --reason {} --yes",
1084 target.room_id,
1085 target.host_url,
1086 shell_word(reason)
1087 )
1088}
1089
1090fn records_prune_refused(e: vtc_client::VtcError, target: &RoomTarget<'_>) -> String {
1092 let code = vtc_client::git_ns::task_error(&e).map(|(code, _)| code);
1093 let host_did = target
1094 .host_did
1095 .map(|d| format!(" --host-did {d}"))
1096 .unwrap_or_default();
1097 match code.as_deref() {
1098 Some(c) if c.ends_with("noRetention") => format!(
1099 "{e}\n\nThe room states no record retention for what you asked, so nothing is \
1100 eligible. Set one first, for example:\n\n pnm rooms records retention set \
1101 --room {} --host {}{host_did} --tombstone-days 30 --reason <why>",
1102 target.room_id, target.host_url
1103 ),
1104 Some(c) if c.ends_with("roomNotLive") => {
1105 format!("{e}\n\nThe room has lapsed and is read-only until its owner renews it.")
1106 }
1107 Some(c) if c.ends_with("permissionDenied") => format!(
1108 "{e}\n\nPruning records needs `admin` in the room — not `curate`, which every \
1109 writer holds. The room's owner grants it with `pnm rooms owner issue-authority \
1110 --action admin --store`."
1111 ),
1112 _ => e.to_string(),
1113 }
1114}
1115
1116pub async fn cmd_rooms_retention_show(
1119 client: &VtaClient,
1120 target: RoomTarget<'_>,
1121 signer: RoomSigner<'_>,
1122) -> Result<(), Box<dyn std::error::Error>> {
1123 let info = read_info(client, &target, &signer).await?;
1124 if crate::render::is_json_output() {
1125 crate::render::print_json(&serde_json::json!({
1126 "recordRetention": info.get("recordRetention"),
1127 "lastPrune": info.get("lastPrune"),
1128 }))?;
1129 return Ok(());
1130 }
1131 println!("{}", retention_lines(&info));
1132 Ok(())
1133}
1134
1135#[allow(clippy::too_many_arguments)]
1139pub async fn cmd_rooms_retention_set(
1140 client: &VtaClient,
1141 target: RoomTarget<'_>,
1142 signer: RoomSigner<'_>,
1143 tombstone_days: Option<u32>,
1144 record_days: Option<u32>,
1145 clear: bool,
1146 reason: String,
1147) -> Result<(), Box<dyn std::error::Error>> {
1148 if !clear && tombstone_days.is_none() && record_days.is_none() {
1149 return Err(
1150 "name --tombstone-days, --record-days, or --clear: the retention is \
1151 replaced whole, so naming nothing would clear it by accident"
1152 .into(),
1153 );
1154 }
1155 let info = read_info(client, &target, &signer).await?;
1156 let expected = info
1157 .get("recordRetention")
1158 .cloned()
1159 .unwrap_or_else(|| serde_json::json!({}));
1160 let mut retention = serde_json::Map::new();
1161 if let Some(d) = tombstone_days {
1162 retention.insert("tombstoneDays".into(), serde_json::json!(d));
1163 }
1164 if let Some(d) = record_days {
1165 retention.insert("recordDays".into(), serde_json::json!(d));
1166 }
1167 let session = present(client, &target, "admin").await?;
1168 let set = target
1169 .client()
1170 .set_record_retention(
1171 &session,
1172 Value::Object(retention),
1173 Some(expected),
1174 &reason,
1175 signer.did,
1176 signer.key_multibase,
1177 )
1178 .await
1179 .map_err(|e| {
1180 let code = vtc_client::git_ns::task_error(&e).map(|(code, _)| code);
1181 match code.as_deref() {
1182 Some(c) if c.ends_with("retentionConflict") => format!(
1183 "{e}\n\nAnother administrator changed the retention since this command \
1184 read it. Read it again (`pnm rooms records retention show --room {} \
1185 --host {}`) and decide again.",
1186 target.room_id, target.host_url
1187 ),
1188 _ => e.to_string(),
1189 }
1190 })?;
1191 let set = serde_json::to_value(&set)?;
1192 if crate::render::is_json_output() {
1193 crate::render::print_json(&set)?;
1194 return Ok(());
1195 }
1196 println!(
1197 "Room {} record retention: {} (was {}). Nothing is removed until somebody prunes.",
1198 target.room_id,
1199 retention_text(&set["recordRetention"]),
1200 retention_text(&set["previous"])
1201 );
1202 Ok(())
1203}
1204
1205async fn read_info(
1207 client: &VtaClient,
1208 target: &RoomTarget<'_>,
1209 signer: &RoomSigner<'_>,
1210) -> Result<Value, Box<dyn std::error::Error>> {
1211 let read = present(client, target, "read").await?;
1212 let info = target
1213 .client()
1214 .room_info(&read, signer.did, signer.key_multibase)
1215 .await
1216 .map_err(|e| format!("could not read the room (rooms/info/0.3): {e}"))?;
1217 Ok(serde_json::to_value(&info)?)
1218}
1219
1220fn retention_text(rule: &Value) -> String {
1222 let mut parts = Vec::new();
1223 if let Some(d) = rule.get("tombstoneDays").and_then(Value::as_u64) {
1224 parts.push(format!("tombstones {d} days"));
1225 }
1226 if let Some(d) = rule.get("recordDays").and_then(Value::as_u64) {
1227 parts.push(format!("records {d} days (pinned excepted)"));
1228 }
1229 if parts.is_empty() {
1230 "none — no record is eligible by age".to_string()
1231 } else {
1232 parts.join(", ")
1233 }
1234}
1235
1236fn retention_lines(info: &Value) -> String {
1238 let rule = info.get("recordRetention").cloned().unwrap_or(Value::Null);
1239 let mut out = format!("Record retention: {}", retention_text(&rule));
1240 match info.get("lastPrune") {
1241 Some(p) => out.push_str(&format!(
1242 "\nLast prune: {} record(s) at {}, version {}",
1243 p["removed"],
1244 p["prunedAt"].as_str().unwrap_or("?"),
1245 p["version"]
1246 )),
1247 None => out.push_str("\nLast prune: never"),
1248 }
1249 out
1250}
1251
1252pub async fn cmd_rooms_create(
1258 target: RoomTarget<'_>,
1259 signer: RoomSigner<'_>,
1260 visibility: &str,
1261 retention_days: Option<u32>,
1262) -> Result<(), Box<dyn std::error::Error>> {
1263 let visibility: Visibility = serde_json::from_value(Value::String(visibility.to_string()))
1264 .map_err(|_| "visibility must be one of: open, attributed, private")?;
1265
1266 target
1267 .client()
1268 .create_room(
1269 target.room_id,
1270 signer.did,
1271 visibility,
1272 retention_days,
1273 signer.did,
1274 signer.key_multibase,
1275 )
1276 .await
1277 .map_err(|e| {
1278 format!(
1279 "{e}\n\nA community host decides whose rooms it stores. If this says \
1280 `not-a-member`, you are not a member there; if it says \
1281 `private-tier-not-enabled`, that community has not turned the tier on."
1282 )
1283 })?;
1284 println!("Registered {} as {}", target.room_id, signer.did);
1285 println!(
1286 " Next: the room must issue you a membership and an authority credential before \
1287 you can act in it."
1288 );
1289 Ok(())
1290}
1291
1292const BLOB_MANIFEST: &str = "blob.json";
1305const FILE_MANIFEST: &str = "file.json";
1307const CHUNKS_DIR: &str = "chunks";
1309
1310fn chunk_path(dir: &std::path::Path, index: usize) -> std::path::PathBuf {
1311 dir.join(CHUNKS_DIR).join(format!("{index:06}.bin"))
1312}
1313
1314fn file_key_bytes(
1315 resp: &vti_specs::rooms::keys::file_key::v0_2::Response,
1316) -> Result<vti_rooms::files::FileKey, Box<dyn std::error::Error>> {
1317 use base64::Engine as _;
1318 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(resp.key.as_str())?;
1319 bytes
1320 .try_into()
1321 .map_err(|_| "the VTA returned a file key that is not 32 bytes".into())
1322}
1323
1324pub async fn cmd_rooms_file_seal(
1326 client: &VtaClient,
1327 room_id: &str,
1328 path: &std::path::Path,
1329 out: &std::path::Path,
1330 padme: bool,
1331) -> Result<(), Box<dyn std::error::Error>> {
1332 use vti_rooms::files::{self, FileBinding, Padding};
1333
1334 if out.exists() {
1335 return Err(format!("{} already exists; choose a new directory", out.display()).into());
1336 }
1337 let plaintext = std::fs::read(path)?;
1338 let name = path
1340 .file_name()
1341 .and_then(|n| n.to_str())
1342 .ok_or("the file has no usable name")?;
1343
1344 let file_id = files::new_file_id();
1347 let resp = client
1348 .room_file_key(room_id, &files::encode_file_id(&file_id), None)
1349 .await?;
1350 let key = file_key_bytes(&resp)?;
1351 let sealed = files::seal_file(
1352 &key,
1353 FileBinding {
1354 room_id: room_id.to_string(),
1355 file_id,
1356 epoch: resp.epoch.get(),
1357 segment_size: files::DEFAULT_SEGMENT_SIZE,
1358 },
1359 name,
1360 None,
1361 &plaintext,
1362 if padme { Padding::Padme } else { Padding::None },
1363 )?;
1364 std::fs::create_dir_all(out.join(CHUNKS_DIR))?;
1365 for (i, chunk) in sealed.chunks.iter().enumerate() {
1366 std::fs::write(chunk_path(out, i), chunk)?;
1367 }
1368 std::fs::write(
1369 out.join(BLOB_MANIFEST),
1370 serde_json::to_vec_pretty(&sealed.blob)?,
1371 )?;
1372 std::fs::write(
1373 out.join(FILE_MANIFEST),
1374 serde_json::to_vec_pretty(&sealed.file)?,
1375 )?;
1376 println!(
1377 "Sealed {} ({} bytes) under epoch {} into {}\n blobRef {}\n chunks {} of {} bytes",
1378 name,
1379 sealed.file.size,
1380 sealed.file.epoch,
1381 out.display(),
1382 sealed.file.blob_ref,
1383 sealed.blob.chunks.chunk_count,
1384 sealed.blob.chunks.chunk_size,
1385 );
1386 println!(
1387 " {FILE_MANIFEST} goes inside the sealed record body; it names the file and is never \
1388 sent to the host in the clear."
1389 );
1390 Ok(())
1391}
1392
1393pub async fn cmd_rooms_file_open(
1396 client: &VtaClient,
1397 room_id: &str,
1398 dir: &std::path::Path,
1399 out: &std::path::Path,
1400) -> Result<(), Box<dyn std::error::Error>> {
1401 use vti_rooms::files::{self, BlobManifest, FileManifest};
1402
1403 if out.exists() {
1404 return Err(format!("{} already exists; refusing to overwrite it", out.display()).into());
1405 }
1406 let file: FileManifest = serde_json::from_slice(&std::fs::read(dir.join(FILE_MANIFEST))?)?;
1407 let blob: BlobManifest = serde_json::from_slice(&std::fs::read(dir.join(BLOB_MANIFEST))?)?;
1408 file.check_blob(&blob)?;
1412 let mut chunks = Vec::with_capacity(blob.chunks.chunk_count as usize);
1413 for (i, expected) in blob.chunks.chunk_digests.iter().enumerate() {
1414 let chunk = std::fs::read(chunk_path(dir, i))?;
1415 if !files::same_digest(&files::digest_multibase(&chunk), expected) {
1416 return Err(format!("chunk {i} does not match the manifest").into());
1417 }
1418 chunks.push(chunk);
1419 }
1420
1421 let resp = client
1422 .room_file_key(room_id, &file.file_id, Some(file.epoch))
1423 .await?;
1424 let key = file_key_bytes(&resp)?;
1425 let plaintext = files::open_file(&key, room_id, &file, &blob, &chunks)?;
1426
1427 let tmp = out.with_extension("partial");
1430 std::fs::write(&tmp, &plaintext)?;
1431 std::fs::rename(&tmp, out)?;
1432 println!(
1433 "Opened {} ({} bytes) into {} — the digest its author signed matches.",
1434 file.name.escape_debug(),
1436 plaintext.len(),
1437 out.display()
1438 );
1439 Ok(())
1440}
1441
1442const SEALED_WRITE_ATTEMPTS: usize = 4;
1448
1449fn inert(text: &str) -> String {
1452 text.chars()
1453 .filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
1454 .collect()
1455}
1456
1457fn opaque_key() -> String {
1459 let bytes: [u8; 16] = rand::random();
1460 base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bytes)
1461}
1462
1463fn wire_str<T: serde::Serialize>(value: &T) -> String {
1465 serde_json::to_value(value)
1466 .ok()
1467 .and_then(|v| v.as_str().map(str::to_string))
1468 .unwrap_or_default()
1469}
1470
1471async fn room_tier(
1474 client: &VtaClient,
1475 target: &RoomTarget<'_>,
1476) -> Result<(bool, String), Box<dyn std::error::Error>> {
1477 use trust_tasks_rs::Payload as _;
1478 let (member, reply) = act_and_send(
1479 client,
1480 target,
1481 vti_specs::rooms::info::v0_3::Payload::TYPE_URI,
1482 serde_json::json!({ "roomId": target.room_id }),
1483 )
1484 .await
1485 .map_err(|e| format!("could not read the room's tier (rooms/info): {e}"))?;
1486 let visibility = reply
1487 .get("visibility")
1488 .and_then(Value::as_str)
1489 .ok_or("the host's rooms/info response names no visibility")?;
1490 Ok((visibility != "open", member))
1491}
1492
1493async fn write_sealed(
1502 client: &VtaClient,
1503 target: &RoomTarget<'_>,
1504 content: SealedRecordBody,
1505 expected_version: Option<u64>,
1506) -> Result<u64, Box<dyn std::error::Error>> {
1507 write_sealed_naming(client, target, content, expected_version, None).await
1508}
1509
1510async fn write_sealed_naming(
1512 client: &VtaClient,
1513 target: &RoomTarget<'_>,
1514 content: SealedRecordBody,
1515 expected_version: Option<u64>,
1516 blobs: Option<Vec<String>>,
1517) -> Result<u64, Box<dyn std::error::Error>> {
1518 let head = list_page(client, target, None, None, None)
1519 .await?
1520 .head_version
1521 .unwrap_or(0);
1522
1523 let key = content.key.clone();
1524 let mut intended = head + 1;
1525 let mut expected = expected_version;
1526 let mut landed = 0;
1527 for _ in 0..SEALED_WRITE_ATTEMPTS {
1528 let unsigned = serde_json::to_vec(&content.clone().at_version(intended))?;
1530 let sealed = client
1531 .room_seal_as_principal(target.room_id, &key, intended, &unsigned)
1532 .await?
1533 .sealed;
1534 let mut payload = serde_json::json!({
1535 "roomId": target.room_id,
1536 "key": key,
1537 "sealed": {
1538 "ciphertext": sealed.ciphertext,
1539 "nonce": sealed.nonce,
1540 "epoch": sealed.epoch.get(),
1541 },
1542 });
1543 if let Some(v) = expected {
1544 payload["expectedVersion"] = serde_json::json!(v);
1545 }
1546 if let Some(b) = &blobs {
1547 payload["blobs"] = serde_json::json!(b);
1548 }
1549 let put_type = vti_rooms::wire::ROOMS_RECORDS_PUT_V3_TYPE;
1550 let (_, reply) = act_and_send(client, target, put_type, payload)
1551 .await
1552 .map_err(|e| put_refused(e, target))?;
1553 let put: vti_rooms::wire::PutRecordResponse = parse_reply("rooms/records/put", reply)?;
1554 landed = put.version;
1555 if landed == intended {
1556 return Ok(landed);
1557 }
1558 expected = Some(landed);
1561 intended = landed + 1;
1562 }
1563 Err(format!(
1564 "{key} is stored at version {landed} but sealed for another version, so it does not \
1565 open: the room kept moving under {SEALED_WRITE_ATTEMPTS} attempts. Write it again \
1566 with --expected-version {landed}."
1567 )
1568 .into())
1569}
1570
1571fn keys_commit_command(target: &RoomTarget<'_>) -> String {
1575 let mut cmd = format!(
1576 "pnm rooms keys commit --room {} --host {}",
1577 target.room_id, target.host_url
1578 );
1579 if let Some(did) = target.host_did {
1580 cmd.push_str(&format!(" --host-did {did}"));
1581 }
1582 cmd
1583}
1584
1585fn put_refused(
1589 e: Box<dyn std::error::Error>,
1590 target: &RoomTarget<'_>,
1591) -> Box<dyn std::error::Error> {
1592 let refusal = e
1593 .downcast_ref::<vtc_client::VtcError>()
1594 .and_then(vtc_client::git_ns::task_error);
1595 match refusal {
1596 Some((code, message)) => match put_refusal_hint(&code, &message, target) {
1597 Some(hint) => hint.into(),
1598 None => e,
1599 },
1600 None => e,
1601 }
1602}
1603
1604fn put_refusal_hint(code: &str, message: &str, target: &RoomTarget<'_>) -> Option<String> {
1606 use vti_specs::rooms::records::put;
1607 let epoch_mismatch = [
1608 put::v0_1::error_codes::EPOCH_MISMATCH.code,
1609 put::v0_2::error_codes::EPOCH_MISMATCH.code,
1610 ];
1611 if !epoch_mismatch.contains(&code) {
1612 return None;
1613 }
1614 Some(format!(
1615 "the host refused the record: {message}\n\n\
1616 The room has moved on to a newer epoch — it was renewed, or its membership changed — \
1617 and this VTA has not applied the commit that moved it, so it sealed the record under \
1618 the old epoch. Nothing was written. Catch up, then write again:\n\n {}",
1619 keys_commit_command(target)
1620 ))
1621}
1622
1623fn open_refusal_hint(e: &VtaError, target: &RoomTarget<'_>) -> String {
1625 use vti_specs::rooms::keys::open::v0_2::error_codes::UNKNOWN_EPOCH;
1626 epoch_refusal_hint(e, target, UNKNOWN_EPOCH.code, Sealed::Record)
1627}
1628
1629fn file_key_refusal_hint(e: &VtaError, target: &RoomTarget<'_>) -> String {
1632 use vti_specs::rooms::keys::file_key::v0_2::error_codes::UNKNOWN_EPOCH;
1633 epoch_refusal_hint(e, target, UNKNOWN_EPOCH.code, Sealed::File)
1634}
1635
1636#[derive(Clone, Copy)]
1638enum Sealed {
1639 Record,
1640 File,
1641}
1642
1643impl Sealed {
1644 fn noun(self) -> &'static str {
1645 match self {
1646 Self::Record => "record",
1647 Self::File => "file",
1648 }
1649 }
1650}
1651
1652fn epoch_refusal_hint(e: &VtaError, target: &RoomTarget<'_>, code: &str, sealed: Sealed) -> String {
1660 let noun = sealed.noun();
1661 let text = e.to_string();
1662 let reason = (e.rejection_code() == Some(code))
1663 .then(|| e.rejection_details())
1664 .flatten()
1665 .and_then(|d| d.get("reason"))
1666 .and_then(Value::as_str);
1667 let backfill = || {
1668 format!(
1669 "The {noun} is older than any key this VTA holds — it was written before you \
1670 joined. Fetch the room's history: `pnm rooms keys backfill --room {} --host-did \
1671 {}`.",
1672 target.room_id,
1673 target.host_did.unwrap_or("<host-did>")
1674 )
1675 };
1676 let hint = match reason {
1677 Some("removed") => {
1678 let at = e
1679 .rejection_details()
1680 .and_then(|d| d.get("removedAtEpoch"))
1681 .and_then(Value::as_u64)
1682 .map(|n| format!(" at epoch {n}"))
1683 .unwrap_or_default();
1684 format!(
1685 "You were removed from this room's group{at}. {}s sealed since then will \
1686 never open for you, and no commit will change that; what was sealed before \
1687 your removal still opens. Rejoining takes a fresh invitation from the room's \
1688 owner.",
1689 capitalised(noun)
1690 )
1691 }
1692 Some("notDelivered") => format!(
1693 "The {noun} is newer than this VTA's group: a commit has not been applied here. \
1694 Catch up, then try again:\n\n {}",
1695 keys_commit_command(target),
1696 ),
1697 Some("beyondChain") => backfill(),
1698 _ if text.contains("cannot reach") => backfill(),
1699 _ if text.contains("has not been delivered") => format!(
1700 "The {noun} is newer than this VTA's group. Catch up with `{}`; if that says you \
1701 were removed, nothing sealed after your removal will open.",
1702 keys_commit_command(target)
1703 ),
1704 _ => format!("The {noun} did not open with any key this VTA holds."),
1705 };
1706 format!("{e}\n\n{hint}")
1707}
1708
1709fn capitalised(word: &str) -> String {
1710 let mut chars = word.chars();
1711 chars
1712 .next()
1713 .map(|c| c.to_uppercase().chain(chars).collect())
1714 .unwrap_or_default()
1715}
1716
1717async fn open_and_verify(
1724 client: &VtaClient,
1725 target: &RoomTarget<'_>,
1726 key: &str,
1727 version: u64,
1728 sealed: &SealedContent,
1729 host_author: Option<&str>,
1730) -> Result<VerifiedRecordBody, Box<dyn std::error::Error>> {
1731 let sealed_at = sealed
1732 .sealed_at(version)
1733 .map_err(|why| format!("refusing record {key}: the host says {why}"))?;
1734 let opened = client
1735 .room_open(
1736 target.room_id,
1737 key,
1738 sealed_at,
1739 &sealed.ciphertext,
1740 &sealed.nonce,
1741 sealed.epoch,
1742 )
1743 .await
1744 .map_err(|e| open_refusal_hint(&e, target))?;
1747 let plaintext = opened
1748 .get("plaintext")
1749 .and_then(Value::as_str)
1750 .ok_or("the VTA opened the record but returned no plaintext")?;
1751 let bytes =
1752 base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, plaintext)?;
1753
1754 let resolver = vta_sdk::trust_task_proof::TrustTaskVmResolver::from_optional(
1755 vta_sdk::resolver::shared_did_resolver_from_env().await.ok(),
1756 );
1757 let opened_at = opened
1759 .pointer("/ext/org.openvtc.rooms/sealedAt")
1760 .and_then(Value::as_u64)
1761 .unwrap_or(sealed_at);
1762 if opened_at > sealed_at {
1763 return Err(format!(
1764 "refusing record {key}: the VTA opened it at version {opened_at}, past the {sealed_at} it was asked for"
1765 )
1766 .into());
1767 }
1768 let location = RecordLocation {
1769 room_id: target.room_id,
1770 key,
1771 version: opened_at,
1772 };
1773 Ok(verify_record_body(&bytes, location, host_author, &resolver)
1774 .await
1775 .map_err(|e| format!("refusing record {key}: {e}"))?)
1776}
1777
1778const DIRECT_PART: usize = 8 * 1024 * 1024;
1781const SINGLE_PART_MAX: usize = 16 * 1024 * 1024;
1783const DIRECT_BEGIN_ATTEMPTS: usize = 64;
1785
1786fn direct_plan(ciphertext: &[u8]) -> (usize, Value) {
1788 use sha2::Digest as _;
1789 let part = if ciphertext.len() <= SINGLE_PART_MAX {
1790 ciphertext.len().max(1)
1791 } else {
1792 DIRECT_PART
1793 };
1794 let digests: Vec<String> = ciphertext
1795 .chunks(part)
1796 .map(|c| vti_common::blob_store::sha256_digest_multibase(&sha2::Sha256::digest(c).into()))
1797 .collect();
1798 (
1799 part,
1800 serde_json::json!({ "partSize": part, "partCount": digests.len(), "partDigests": digests }),
1801 )
1802}
1803
1804pub async fn cmd_rooms_file_put(
1814 client: &VtaClient,
1815 target: RoomTarget<'_>,
1816 path: &std::path::Path,
1817 title: Option<String>,
1818 description: Option<String>,
1819 tags: Vec<String>,
1820 padme: bool,
1821) -> Result<(), Box<dyn std::error::Error>> {
1822 use trust_tasks_rs::Payload as _;
1823 use vti_rooms::files::{self, FileBinding, Padding};
1824 use vti_specs::rooms::blobs::upload;
1825
1826 let (sealed_tier, member) = room_tier(client, &target).await?;
1827 if !sealed_tier {
1828 return Err(
1829 "this room is `open`: its records are cleartext, and a file's manifest \
1830 belongs inside a sealed body. Files go in `attributed` and `private` rooms."
1831 .into(),
1832 );
1833 }
1834
1835 let plaintext = std::fs::read(path)?;
1836 let name = path
1837 .file_name()
1838 .and_then(|n| n.to_str())
1839 .ok_or("the file has no usable name")?;
1840 let file_id = files::new_file_id();
1843 let resp = client
1844 .room_file_key(target.room_id, &files::encode_file_id(&file_id), None)
1845 .await?;
1846 let file_key = file_key_bytes(&resp)?;
1847 let sealed = files::seal_file(
1848 &file_key,
1849 FileBinding {
1850 room_id: target.room_id.to_string(),
1851 file_id,
1852 epoch: resp.epoch.get(),
1853 segment_size: files::DEFAULT_SEGMENT_SIZE,
1854 },
1855 name,
1856 None,
1857 &plaintext,
1858 if padme { Padding::Padme } else { Padding::None },
1859 )?;
1860 let ciphertext: Vec<u8> = sealed.chunks.concat();
1861
1862 let (key_did, key_secret) = crate::local_keygen::generate_unbound_admin_did_key();
1865 let key_secret = zeroize::Zeroizing::new(key_secret);
1866 let (part_size, plan) = direct_plan(&ciphertext);
1867 let begin_payload = serde_json::json!({
1868 "roomId": target.room_id,
1869 "manifest": sealed.blob,
1870 "uploadKey": key_did,
1871 "directParts": plan,
1872 });
1873 let begin = || async {
1874 let (_, reply) = act_and_send(
1875 client,
1876 &target,
1877 upload::begin::v0_2::Payload::TYPE_URI,
1878 begin_payload.clone(),
1879 )
1880 .await?;
1881 parse_reply::<upload::begin::v0_2::Response>("rooms/blobs/upload/begin", reply)
1882 };
1883 let mut begun = begin().await?;
1884 let direct = wire_str(&begun.mode) == "direct";
1885 let upload_id = begun.upload_id.to_string();
1886 let host = target.client();
1887
1888 let sent: Result<(), Box<dyn std::error::Error>> = async {
1889 if begun.already_committed == Some(true) {
1890 return Ok(());
1891 }
1892 if direct {
1893 for _ in 0..DIRECT_BEGIN_ATTEMPTS {
1897 if begun.missing.is_empty() {
1898 return Ok(());
1899 }
1900 if begun.targets.is_empty() {
1901 return Err("the host answered direct mode with no targets".into());
1902 }
1903 for t in &begun.targets {
1904 let i = usize::try_from(t.index)
1905 .ok()
1906 .filter(|i| i * part_size < ciphertext.len().max(1))
1907 .ok_or("the host issued a target for a part that does not exist")?;
1908 let part =
1909 &ciphertext[i * part_size..((i + 1) * part_size).min(ciphertext.len())];
1910 vtc_client::rooms::files::put_part(t, part).await?;
1911 }
1912 begun = begin().await?;
1913 }
1914 return Err("the upload did not finish: the host kept asking for parts".into());
1915 }
1916 for index in &begun.missing {
1917 let i = usize::try_from(index.0)
1918 .ok()
1919 .filter(|i| *i < sealed.chunks.len())
1920 .ok_or_else(|| {
1921 format!("the host asked for chunk {}, which does not exist", index.0)
1922 })?;
1923 host.upload_chunk_v2(
1924 &upload_id,
1925 i as u64,
1926 &sealed.chunks[i],
1927 &key_did,
1928 &key_secret,
1929 )
1930 .await?;
1931 }
1932 Ok(())
1933 }
1934 .await;
1935
1936 let finish = |type_uri: &'static str| {
1938 let (upload_id, key_did, key_secret, member) = (
1939 upload_id.clone(),
1940 key_did.clone(),
1941 key_secret.clone(),
1942 member.clone(),
1943 );
1944 let target = ⌖
1945 async move {
1946 let payload = serde_json::json!({ "uploadId": upload_id });
1947 if direct {
1948 act_and_send_as(client, target, type_uri, payload, Some(&member))
1949 .await
1950 .map(|(_, reply)| reply)
1951 } else {
1952 let doc = vta_sdk::trust_task_sign::build_signed(
1953 type_uri,
1954 payload,
1955 &key_did,
1956 &key_secret,
1957 target.host_did.unwrap_or("did:key:zHost"),
1958 )
1959 .await
1960 .map_err(|e| format!("could not sign with the upload key: {e}"))?;
1961 let doc: Value = serde_json::from_str(&doc)?;
1962 Ok(target.client().send_signed_room_document(&doc).await?)
1963 }
1964 }
1965 };
1966 if let Err(e) = sent {
1967 let _ = finish(upload::abort::v0_2::Payload::TYPE_URI).await;
1969 return Err(e);
1970 }
1971 let committed: upload::commit::v0_2::Response = parse_reply(
1972 "rooms/blobs/upload/commit",
1973 finish(upload::commit::v0_2::Payload::TYPE_URI).await?,
1974 )?;
1975 let blob_ref = wire_str(&committed.blob_ref);
1976 if !files::same_digest(&blob_ref, &sealed.file.blob_ref) {
1977 return Err(format!(
1978 "the host committed {blob_ref}, not the blob sealed here ({})",
1979 sealed.file.blob_ref
1980 )
1981 .into());
1982 }
1983
1984 let key = opaque_key();
1985 let mut content = SealedRecordBody::new(
1986 RecordLocation {
1987 room_id: target.room_id,
1988 key: &key,
1989 version: 1,
1990 },
1991 &member,
1992 );
1993 content.title = title;
1994 content.description = description;
1995 content.tags = tags;
1996 content.file = Some(sealed.file.clone());
1997 let version = write_sealed_naming(
1998 client,
1999 &target,
2000 content,
2001 Some(0),
2002 Some(vec![blob_ref.clone()]),
2003 )
2004 .await?;
2005
2006 if crate::render::is_json_output() {
2007 crate::render::print_json(&serde_json::json!({
2008 "key": key,
2009 "version": version,
2010 "blobRef": blob_ref,
2011 "size": sealed.file.size,
2012 "mode": if direct { "direct" } else { "proxied" },
2013 }))?;
2014 return Ok(());
2015 }
2016 println!(
2017 "Put {name} ({} bytes, {}) as record {key} at version {version}, as {member}\n blobRef {blob_ref}\n Members fetch it with `pnm rooms file get {key}`.",
2018 sealed.file.size,
2019 if direct {
2020 "straight to the room's storage"
2021 } else {
2022 "through the host"
2023 }
2024 );
2025 Ok(())
2026}
2027
2028fn local_name(untrusted: &str) -> String {
2031 let base = untrusted
2032 .rsplit(['/', '\\'])
2033 .next()
2034 .unwrap_or_default()
2035 .trim_start_matches('.');
2036 let clean = inert(base).replace(['\n', '\t'], " ");
2037 if clean.trim().is_empty() {
2038 "room-file".to_string()
2039 } else {
2040 clean
2041 }
2042}
2043
2044pub async fn cmd_rooms_file_get(
2054 client: &VtaClient,
2055 target: RoomTarget<'_>,
2056 key: &str,
2057 out: Option<&std::path::Path>,
2058) -> Result<(), Box<dyn std::error::Error>> {
2059 use trust_tasks_rs::Payload as _;
2060 use vti_rooms::files::{self, BlobManifest};
2061 use vti_specs::rooms::blobs::{chunk as blob_chunk, get as blob_get};
2062
2063 let (member, record) = act_and_send(
2064 client,
2065 &target,
2066 vti_rooms::wire::ROOMS_RECORDS_GET_V2_TYPE,
2067 serde_json::json!({ "roomId": target.room_id, "key": key }),
2068 )
2069 .await?;
2070 let record: vtc_client::rooms::files::GetRecordV2Response =
2071 parse_reply("rooms/records/get", record)?;
2072 let sealed = record.sealed.as_ref().ok_or(
2073 "the record has no sealed body: it is on an `open` room, or it has been retracted",
2074 )?;
2075 let sealed = SealedContent {
2076 ciphertext: sealed.ciphertext.clone(),
2077 nonce: sealed.nonce.clone(),
2078 epoch: u32::try_from(sealed.epoch.get())
2079 .map_err(|_| "the record's epoch is beyond what this client can represent")?,
2080 version: sealed.version.map(std::num::NonZeroU64::get),
2084 };
2085 let verified = open_and_verify(
2086 client,
2087 &target,
2088 key,
2089 record.version.get(),
2090 &sealed,
2091 record.author.as_deref(),
2092 )
2093 .await?;
2094 let file = verified
2095 .body()
2096 .file
2097 .clone()
2098 .ok_or("the record carries no file")?;
2099 let named = record
2100 .blobs
2101 .iter()
2102 .flatten()
2103 .any(|b| files::same_digest(&wire_str(b), &file.blob_ref));
2104 if !named {
2105 return Err(
2106 "the host does not name the file's blob on this record; it may have been \
2107 released"
2108 .into(),
2109 );
2110 }
2111
2112 let (_, started) = act_and_send(
2113 client,
2114 &target,
2115 blob_get::v0_2::Payload::TYPE_URI,
2116 serde_json::json!({ "roomId": target.room_id, "blobRef": file.blob_ref, "direct": true }),
2117 )
2118 .await?;
2119 let started: blob_get::v0_2::Response = parse_reply("rooms/blobs/get", started)?;
2120 let blob: BlobManifest = serde_json::from_value(serde_json::to_value(&started.manifest)?)?;
2121 file.check_blob(&blob)?;
2125 let size = blob.size;
2126 let chunk_size = blob.chunks.chunk_size;
2127 let mut chunks = Vec::with_capacity(blob.chunks.chunk_digests.len());
2128 for (i, expected) in blob.chunks.chunk_digests.iter().enumerate() {
2129 let chunk = match (&started.target, &started.download_id) {
2130 (Some(t), _) => {
2131 let start = i as u64 * chunk_size;
2132 vtc_client::rooms::files::get_range(t, start..(start + chunk_size).min(size))
2133 .await?
2134 }
2135 (None, Some(download_id)) => {
2136 let (_, reply) = act_and_send_as(
2137 client,
2138 &target,
2139 blob_chunk::v0_1::Payload::TYPE_URI,
2140 serde_json::json!({ "downloadId": download_id, "index": i }),
2141 Some(&member),
2142 )
2143 .await?;
2144 let c: blob_chunk::v0_1::Response = parse_reply("rooms/blobs/chunk", reply)?;
2145 base64::Engine::decode(
2146 &base64::engine::general_purpose::URL_SAFE_NO_PAD,
2147 c.data.as_str(),
2148 )?
2149 }
2150 (None, None) => {
2151 return Err("the host answered with neither a target nor a download".into());
2152 }
2153 };
2154 if !files::same_digest(&files::digest_multibase(&chunk), expected) {
2155 return Err(format!("chunk {i} does not match the manifest").into());
2156 }
2157 chunks.push(chunk);
2158 }
2159
2160 let resp = client
2161 .room_file_key(target.room_id, &file.file_id, Some(file.epoch))
2162 .await
2163 .map_err(|e| file_key_refusal_hint(&e, &target))?;
2164 let file_key = file_key_bytes(&resp)?;
2165 let plaintext = files::open_file(&file_key, target.room_id, &file, &blob, &chunks)?;
2166
2167 let out = match out {
2168 Some(p) => p.to_path_buf(),
2169 None => std::path::PathBuf::from(local_name(&file.name)),
2170 };
2171 if out.exists() {
2172 return Err(format!("{} already exists; refusing to overwrite it", out.display()).into());
2173 }
2174 let tmp = out.with_extension("partial");
2177 std::fs::write(&tmp, &plaintext)?;
2178 std::fs::rename(&tmp, &out)?;
2179 println!(
2180 "Fetched {} ({} bytes) into {}, written by {}; it matches the digest they signed.",
2181 inert(&file.name).escape_debug(),
2182 plaintext.len(),
2183 out.display(),
2184 verified.body().author
2185 );
2186 Ok(())
2187}
2188
2189pub async fn cmd_rooms_owner_group_create(
2192 client: &VtaClient,
2193 room_id: &str,
2194 signing_key_id: &str,
2195 member: Option<&str>,
2196) -> Result<(), Box<dyn std::error::Error>> {
2197 let resp = client
2198 .room_group_create(room_id, signing_key_id, member)
2199 .await
2200 .map_err(|e| {
2201 format!(
2202 "{e}\n\n`noSigningKey` means this VTA will not let you name `{signing_key_id}` — \
2203 check it with `pnm keys list --context <id>`. `alreadyExists` means it already \
2204 holds this room's group, which is never replaced."
2205 )
2206 })?;
2207 println!("Founded the group of {room_id} at epoch {}.", resp.epoch);
2208 println!(
2209 " Next: admit members with `pnm rooms owner invite` and `pnm rooms owner group-add`. \
2210 Writing needs your own membership and authority credentials in this VTA \
2211 (`pnm rooms owner issue-membership` / `issue-authority --store`), if not issued yet."
2212 );
2213 Ok(())
2214}
2215
2216fn earlier_removal_refused(
2226 e: &VtaError,
2227 room_id: &str,
2228 host_did: &str,
2229 signing_key_id: Option<&str>,
2230 this: &str,
2231) -> Option<String> {
2232 let VtaError::Rejected { code, details, .. } = e else {
2233 return None;
2234 };
2235 let task = code.strip_prefix("rooms/owner/group/")?;
2236 if !task.ends_with(":earlierRemovalRefused") {
2237 return None;
2238 }
2239 let d = details.as_ref()?;
2240 let earlier = d["member"].as_str().unwrap_or("an earlier member");
2241 let host_code = d["code"].as_str().unwrap_or("no code given");
2242 let kept = if d["listKept"].as_bool() == Some(true) {
2243 format!(
2244 "Their withdrawal is kept in the room's revocation list and carried by every later \
2245 one; the host enforces it only if it ever accepted that list — deliver it again \
2246 with\n pnm rooms owner revoke --room {room_id} --signing-key {} --host \
2247 {host_did}",
2248 signing_key_id.unwrap_or("<the room's key>")
2249 )
2250 } else {
2251 "Their withdrawal was discarded: no host accepted it.".to_string()
2252 };
2253 Some(format!(
2254 "Nothing was done: an earlier removal of {earlier} from {room_id} did not take effect \
2255 — {host_did} refused its revocation list when your VTA delivered it again \
2256 ({host_code}). {earlier} is still in the room's group. {kept}\n\nRun this command \
2257 again to {this}; then remove {earlier} again if that is still wanted."
2258 ))
2259}
2260
2261#[allow(clippy::too_many_arguments)]
2275pub async fn cmd_rooms_owner_group_remove(
2276 client: &VtaClient,
2277 room_id: &str,
2278 host_did: &str,
2279 member: &str,
2280 signing_key_id: Option<&str>,
2281 mirrors: &[String],
2282 until: Option<chrono::DateTime<chrono::Utc>>,
2283 reason: Option<&str>,
2284) -> Result<(), Box<dyn std::error::Error>> {
2285 let hint = |e: VtaError| {
2286 let fix = match &e {
2287 VtaError::Rejected { code, .. }
2288 if code == "rooms/owner/group/remove:noSigningKey" && signing_key_id.is_none() =>
2289 {
2290 format!(
2291 "\n\n{room_id} is not private: removing {member} withdraws their credentials \
2292 at the host, signed as the room. Run\n pnm rooms owner group-remove \
2293 --room {room_id} --host-did {host_did} --member {member} --signing-key \
2294 <the room's key>"
2295 )
2296 }
2297 _ => String::new(),
2298 };
2299 format!(
2300 "{e}{fix}\n\n`epochConflict` means another admin advanced the room: catch up and \
2301 retry. `hostUnreachable` leaves the outcome unknown; running this again \
2302 settles it with the host before anything else. `ownLeaf` means {member} is \
2303 you: hand the room on with rooms/owner/transfer instead. `noSigningKey` means \
2304 this VTA will not let you name the room's key — check `pnm keys list`."
2305 )
2306 };
2307 let resp = client
2310 .idempotent(|| {
2311 client.room_group_remove(
2312 room_id,
2313 host_did,
2314 member,
2315 signing_key_id,
2316 mirrors,
2317 until,
2318 reason,
2319 )
2320 })
2321 .await
2322 .map_err(|e| {
2323 earlier_removal_refused(
2326 &e,
2327 room_id,
2328 host_did,
2329 signing_key_id,
2330 &format!("remove {member}"),
2331 )
2332 .unwrap_or_else(|| hint(e))
2333 })?;
2334 if !resp.withdrawn {
2335 println!(
2337 "Removed {member} from {room_id}'s group; the room is now at epoch {}.",
2338 resp.epoch
2339 );
2340 println!(
2341 " Nothing sealed from now on is readable to them. {room_id} is private, so their \
2342 credentials are NOT withdrawn at the host: they stay valid until they expire. \
2343 To reset every credential the room has issued, run `pnm rooms owner revoke \
2344 --reset` and re-admit those who remain."
2345 );
2346 return Ok(());
2347 }
2348 let r = serde_json::to_value(&resp).unwrap_or_default();
2349 println!(
2350 "Removed {member} from {room_id}: withdrawn at the host from {} (revocation list {}), \
2351 and the room is now at epoch {}.",
2352 r["revokedAt"].as_str().unwrap_or("now"),
2353 r["revocation"]["sequence"],
2354 r["epoch"]
2355 );
2356 if !resp.mirrors_pending.is_empty() {
2357 let pending: Vec<&str> = resp.mirrors_pending.iter().map(|m| m.as_str()).collect();
2358 println!(
2359 " Not yet withdrawn at: {}. Deliver it with `pnm rooms owner revoke --room \
2360 {room_id} --signing-key {} --host <did>` (no withdrawal).",
2361 pending.join(", "),
2362 signing_key_id.unwrap_or("<the room's key>")
2363 );
2364 }
2365 println!(
2366 " The host refuses their credentials, and anything they delegated, from now on. \
2367 Nothing they already read can be recalled."
2368 );
2369 Ok(())
2370}
2371
2372#[allow(clippy::too_many_arguments)]
2376pub async fn cmd_rooms_owner_revoke(
2377 client: &VtaClient,
2378 room_id: &str,
2379 signing_key_id: &str,
2380 hosts: &[String],
2381 subjects: &[String],
2382 credentials: &[String],
2383 reset: bool,
2384 until: Option<chrono::DateTime<chrono::Utc>>,
2385 reason: Option<&str>,
2386) -> Result<(), Box<dyn std::error::Error>> {
2387 use vti_specs::rooms::owner::revoke::v0_1::Withdrawal;
2388 let until = until.map(|u| u.to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
2389 let mut raw: Vec<serde_json::Value> = Vec::new();
2390 for s in subjects {
2391 raw.push(serde_json::json!({ "kind": "subject", "subject": s }));
2392 }
2393 for c in credentials {
2394 raw.push(serde_json::json!({ "kind": "credential", "credentialId": c }));
2395 }
2396 if reset {
2397 raw.push(serde_json::json!({ "kind": "room" }));
2398 }
2399 if let Some(u) = &until {
2400 for w in &mut raw {
2401 w["until"] = serde_json::json!(u);
2402 }
2403 }
2404 let withdraw: Vec<Withdrawal> = raw
2405 .into_iter()
2406 .map(serde_json::from_value)
2407 .collect::<Result<_, _>>()
2408 .map_err(|e| format!("a withdrawal is not one rooms/owner/revoke accepts: {e}"))?;
2409 let resp = client
2410 .idempotent(|| client.room_owner_revoke(room_id, signing_key_id, hosts, &withdraw, reason))
2411 .await
2412 .map_err(|e| {
2413 format!(
2414 "{e}\n\n`noList` means there is nothing to re-deliver: name a withdrawal. \
2415 `entryNotPermitted` means the room is private: only --reset applies there. \
2416 `noSigningKey` means this VTA will not let you name `{signing_key_id}` — check \
2417 `pnm keys list`. `noAuthority` means it holds no `admin` grant of the room \
2418 for a persona you act as."
2419 )
2420 })?;
2421 let r = serde_json::to_value(&resp).unwrap_or_default();
2422 println!(
2423 "{room_id}'s revocation list is at sequence {} ({}).",
2424 r["head"]["sequence"],
2425 r["head"]["digest"].as_str().unwrap_or_default()
2426 );
2427 for d in r["deliveries"].as_array().into_iter().flatten() {
2428 let host = d["host"].as_str().unwrap_or_default();
2429 match d["outcome"].as_str().unwrap_or_default() {
2430 "accepted" => println!(
2431 " {host}: in force from {}",
2432 d["inForceAt"].as_str().unwrap_or_default()
2433 ),
2434 "refused" => {
2435 let code = d["code"].as_str().unwrap_or_default();
2436 println!(" {host}: REFUSED ({code})");
2437 if code == "rooms/revocations/put:stale" {
2438 println!(
2439 " The host holds a later list this VTA did not sign: the room's key \
2440 may be in more than one party's hands."
2441 );
2442 }
2443 }
2444 _ => println!(" {host}: no answer — run this again with no withdrawal to deliver it"),
2445 }
2446 }
2447 Ok(())
2448}
2449
2450pub async fn cmd_rooms_owner_group_renew(
2454 client: &VtaClient,
2455 room_id: &str,
2456 host_did: &str,
2457 reason: Option<&str>,
2458) -> Result<(), Box<dyn std::error::Error>> {
2459 let resp = client
2462 .idempotent(|| client.room_group_renew(room_id, host_did, reason))
2463 .await
2464 .map_err(|e| {
2465 earlier_removal_refused(&e, room_id, host_did, None, &format!("renew {room_id}"))
2466 .unwrap_or_else(|| {
2467 format!(
2468 "{e}\n\n`noGroup` means this VTA holds no group for {room_id}: an \
2469 `open` room renews with `pnm rooms renew`. `epochConflict` means \
2470 another admin advanced the room: catch up and retry. \
2471 `hostUnreachable` leaves the outcome unknown; running this again \
2472 settles it with the host before anything else."
2473 )
2474 })
2475 })?;
2476 println!(
2477 "Renewed {room_id}; the room is now at epoch {}.",
2478 resp.epoch
2479 );
2480 println!(
2481 " Nobody joined or left. Every remaining member's VTA must apply the commit before \
2482 it can write again: each runs `pnm rooms keys commit --room {room_id} --host <url> \
2483 --host-did {host_did}`."
2484 );
2485 Ok(())
2486}
2487
2488pub async fn cmd_rooms_owner_group_add(
2493 client: &VtaClient,
2494 room_id: &str,
2495 host_did: &str,
2496 member: &str,
2497 key_package_src: &str,
2498 reason: Option<&str>,
2499 out: &std::path::Path,
2500) -> Result<(), Box<dyn std::error::Error>> {
2501 if out.exists() {
2502 return Err(format!("{} already exists; refusing to overwrite it", out.display()).into());
2503 }
2504 let key_package = if key_package_src == "-" {
2505 let mut s = String::new();
2506 std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
2507 s
2508 } else {
2509 std::fs::read_to_string(key_package_src)?
2510 };
2511 let key_package = key_package.trim().to_string();
2512
2513 let resp = client
2516 .idempotent(|| client.room_group_add(room_id, host_did, member, &key_package, reason))
2517 .await
2518 .map_err(|e| {
2519 earlier_removal_refused(&e, room_id, host_did, None, &format!("admit {member}"))
2520 .unwrap_or_else(|| {
2521 format!(
2522 "{e}\n\n`epochConflict` means another admin advanced the room: catch \
2523 up and retry. `hostUnreachable` leaves the outcome unknown; running \
2524 this again settles it with the host before anything else."
2525 )
2526 })
2527 })?;
2528
2529 let delivery = serde_json::json!({
2530 "roomId": room_id,
2531 "member": resp.member.as_str(),
2532 "epoch": resp.epoch,
2533 "welcome": resp.welcome.as_str(),
2534 "ratchetTree": resp.ratchet_tree.as_ref().map(|t| t.as_str()),
2535 });
2536 std::fs::write(out, serde_json::to_vec_pretty(&delivery)?)?;
2537 println!(
2538 "Admitted {member} to {room_id}; the room is now at epoch {}.",
2539 resp.epoch
2540 );
2541 println!(
2542 " Deliver {} to the member's key holder (rooms/keys/welcome) directly — never \
2543 through the host.",
2544 out.display()
2545 );
2546 Ok(())
2547}
2548
2549pub struct OfferTarget<'a> {
2566 pub vtc_url: &'a str,
2568 pub vtc_did: &'a str,
2570 pub member: &'a str,
2572}
2573
2574enum OfferStepError {
2579 Act(VtaError),
2581 Community(vtc_client::VtcError),
2583 Local(Box<dyn std::error::Error>),
2585}
2586
2587impl OfferStepError {
2588 fn report(self, target: &OfferTarget<'_>) -> Box<dyn std::error::Error> {
2590 match self {
2591 Self::Act(e) => {
2592 let text = e.to_string();
2593 let hint = if matches!(e, VtaError::UnsupportedTaskType { .. }) {
2594 "\n\nYour VTA does not serve rooms/keys/act/0.8, which signs this request \
2595 as you: upgrade it."
2596 .to_string()
2597 } else if text.contains("issuerNotPrincipal") {
2598 format!(
2599 "\n\nYour VTA holds no `authentication` key of `{}` that you may act as. \
2600 Pass the persona DID the community knows you by as --member.",
2601 target.member
2602 )
2603 } else if text.contains("keyPackageNotHeld") {
2604 "\n\nThe KeyPackage was not minted by your VTA for this member and room, or \
2605 it has been used or has expired: run this again without --key-package to \
2606 mint one."
2607 .to_string()
2608 } else {
2609 String::new()
2610 };
2611 format!("{e}{hint}").into()
2612 }
2613 Self::Community(e) => format!("{}: {e}", target.vtc_did).into(),
2614 Self::Local(e) => e,
2615 }
2616 }
2617}
2618
2619async fn act_and_send_offer(
2624 client: &VtaClient,
2625 target: &OfferTarget<'_>,
2626 type_uri: &str,
2627 payload: Value,
2628) -> Result<Value, OfferStepError> {
2629 let document = vta_sdk::client::unsigned_community_request(
2630 type_uri,
2631 target.vtc_did,
2632 target.member,
2633 payload,
2634 )
2635 .map_err(|e| OfferStepError::Local(e.into()))?;
2636 let completed = client
2637 .room_act_community(&document)
2638 .await
2639 .map_err(OfferStepError::Act)?;
2640 let document =
2641 serde_json::to_value(&completed.document).map_err(|e| OfferStepError::Local(e.into()))?;
2642 VtcClient::anonymous(target.vtc_url, target.vtc_did)
2643 .send_signed_room_document(&document)
2644 .await
2645 .map_err(OfferStepError::Community)
2646}
2647
2648type CommunityOffer = vti_specs::vtc::rooms::offers::list::v0_4::OfferV2;
2651
2652type CommunityRenewal = vti_specs::vtc::rooms::offers::list::v0_4::Renewal;
2654
2655type OffersListing = (Vec<CommunityOffer>, Vec<CommunityRenewal>);
2657
2658#[allow(clippy::type_complexity)]
2661fn offers_page(
2662 reply: Value,
2663) -> Result<(Vec<CommunityOffer>, Option<String>, Vec<CommunityRenewal>), Box<dyn std::error::Error>>
2664{
2665 use vti_specs::vtc::rooms::offers::list::v0_4;
2666 let page: v0_4::Response = parse_reply("vtc/rooms/offers/list/0.4", reply)?;
2667 Ok((
2668 page.items,
2669 page.next_cursor.map(|c| c.to_string()),
2670 page.renewals,
2671 ))
2672}
2673
2674async fn list_offers(
2679 client: &VtaClient,
2680 target: &OfferTarget<'_>,
2681) -> Result<OffersListing, Box<dyn std::error::Error>> {
2682 use trust_tasks_rs::Payload as _;
2683 let type_uri = vti_specs::vtc::rooms::offers::list::v0_4::Payload::TYPE_URI;
2684 let mut items = Vec::new();
2685 let mut renewals = Vec::new();
2686 let mut cursor: Option<String> = None;
2687 loop {
2688 let payload = match &cursor {
2689 Some(c) => serde_json::json!({ "cursor": c }),
2690 None => serde_json::json!({}),
2691 };
2692 let reply = act_and_send_offer(client, target, type_uri, payload)
2693 .await
2694 .map_err(|e| e.report(target))?;
2695 let page = offers_page(reply)?;
2696 items.extend(page.0);
2697 renewals.extend(page.2);
2698 match page.1 {
2699 Some(next) => cursor = Some(next),
2700 None => return Ok((items, renewals)),
2701 }
2702 }
2703}
2704
2705pub async fn cmd_rooms_offers_list(
2708 client: &VtaClient,
2709 target: OfferTarget<'_>,
2710) -> Result<(), Box<dyn std::error::Error>> {
2711 let (offers, renewals) = list_offers(client, &target).await?;
2712 if offers.is_empty() && renewals.is_empty() {
2713 println!(
2714 "No offers from {} are addressed to {}.",
2715 target.vtc_did, target.member
2716 );
2717 return Ok(());
2718 }
2719 for o in &offers {
2720 print!("{}", offer_lines(o, &target));
2721 }
2722 for r in &renewals {
2723 print!("{}", renewal_lines(r, &target));
2724 }
2725 Ok(())
2726}
2727
2728fn renewal_lines(r: &CommunityRenewal, target: &OfferTarget<'_>) -> String {
2731 let actions: Vec<String> = r.actions.0.iter().map(ToString::to_string).collect();
2732 format!(
2733 "{} {} [{}] {} renewal until {} (renews {})\n renew: {}\n",
2734 r.room_id.as_str(),
2735 inert(r.label.as_str()),
2736 r.visibility,
2737 actions.join(","),
2738 r.valid_until.to_rfc3339(),
2739 r.renews_at.to_rfc3339(),
2740 renew_command(r.room_id.as_str(), target),
2741 )
2742}
2743
2744fn renew_command(room_id: &str, target: &OfferTarget<'_>) -> String {
2746 format!(
2747 "pnm rooms offers renew --room {} --vtc {} --vtc-did {} --member {}",
2748 shell_word(room_id),
2749 shell_word(target.vtc_url),
2750 shell_word(target.vtc_did),
2751 shell_word(target.member),
2752 )
2753}
2754
2755async fn held_room_credential(
2759 client: &VtaClient,
2760 room_id: &str,
2761 member: &str,
2762 type_tag: &str,
2763) -> Result<Option<String>, Box<dyn std::error::Error>> {
2764 let found = client
2765 .cred_vault_query(serde_json::json!({ "type": type_tag, "issuerDid": room_id }))
2766 .await
2767 .map_err(|e| format!("your VTA's credential vault did not answer a query: {e}"))?;
2768 let mut mine = Vec::new();
2769 for descriptor in found["credentials"].as_array().cloned().unwrap_or_default() {
2770 let Some(id) = descriptor["id"].as_str() else {
2771 continue;
2772 };
2773 let got = client
2774 .cred_vault_get(id)
2775 .await
2776 .map_err(|e| format!("your VTA's credential vault did not return {id}: {e}"))?;
2777 let subject = match &got {
2780 vault_get::Response::Variant0 { credential, .. } => credential
2781 .get("credentialSubject")
2782 .and_then(|s| s.get("id"))
2783 .and_then(Value::as_str),
2784 _ => None,
2785 };
2786 if subject == Some(member) {
2787 mine.push(id.to_string());
2788 }
2789 }
2790 match mine.len() {
2791 0 => Ok(None),
2792 1 => Ok(mine.pop()),
2793 n => Err(format!(
2794 "your VTA holds {n} {type_tag}s that {room_id} issued {member}, and acts on the \
2795 room with none of them until one is left: remove the older (`pnm cred-vault \
2796 delete`) and renew again"
2797 )
2798 .into()),
2799 }
2800}
2801
2802pub async fn cmd_rooms_offers_renew(
2807 client: &VtaClient,
2808 target: OfferTarget<'_>,
2809 room_id: &str,
2810) -> Result<(), Box<dyn std::error::Error>> {
2811 let (_, renewals) = list_offers(client, &target).await?;
2812 let renewal = renewals
2813 .into_iter()
2814 .find(|r| r.room_id.as_str() == room_id)
2815 .ok_or_else(|| {
2816 format!(
2817 "{} lists no renewal of {room_id} for {}: `pnm rooms offers list` shows what it \
2818 has for you",
2819 target.vtc_did, target.member
2820 )
2821 })?;
2822 for (what, type_tag, credential) in [
2823 (
2824 "membership",
2825 "MembershipCredential",
2826 renewal.credentials.membership.as_str(),
2827 ),
2828 (
2829 "authority",
2830 "AuthorityCredential",
2831 renewal.credentials.authority.as_str(),
2832 ),
2833 ] {
2834 let body: Value = serde_json::from_str(credential)
2835 .map_err(|e| format!("the renewal's {what} credential is not JSON: {e}"))?;
2836 let replaces = held_room_credential(client, room_id, target.member, type_tag).await?;
2837 client
2838 .cred_vault_receive(body, replaces.as_deref())
2839 .await
2840 .map_err(|e| {
2841 format!("your VTA's credential vault refused the renewed {what} credential: {e}")
2842 })?;
2843 }
2844 println!(
2845 "Renewed {room_id} for {}: valid until {} (it was until {}).",
2846 target.member,
2847 renewal.valid_until.to_rfc3339(),
2848 renewal.renews_at.to_rfc3339(),
2849 );
2850 Ok(())
2851}
2852
2853fn offer_lines(o: &CommunityOffer, target: &OfferTarget<'_>) -> String {
2857 use vti_specs::vtc::rooms::offers::list::v0_4::OfferV2State;
2858 let actions: Vec<String> = o.actions.0.iter().map(ToString::to_string).collect();
2859 let mut out = format!(
2860 "{} {} [{}] {} {} until {}\n",
2861 o.room_id.as_str(),
2862 inert(o.label.as_str()),
2863 o.visibility,
2864 actions.join(","),
2865 o.state,
2866 o.valid_until.to_rfc3339(),
2867 );
2868 if let Some(description) = &o.description {
2869 out.push_str(&format!(" about: {}\n", one_line(description.as_str())));
2870 }
2871 if o.state == OfferV2State::Open {
2872 out.push_str(&format!(
2873 " accept: {}\n",
2874 accept_command(o.room_id.as_str(), target)
2875 ));
2876 }
2877 out
2878}
2879
2880fn one_line(text: &str) -> String {
2884 text.chars()
2885 .map(|c| if c.is_control() { ' ' } else { c })
2886 .collect::<String>()
2887 .trim()
2888 .to_string()
2889}
2890
2891fn accept_command(room_id: &str, target: &OfferTarget<'_>) -> String {
2894 format!(
2895 "pnm rooms offers accept --room {} --vtc {} --vtc-did {} --member {}",
2896 shell_word(room_id),
2897 shell_word(target.vtc_url),
2898 shell_word(target.vtc_did),
2899 shell_word(target.member),
2900 )
2901}
2902
2903fn shell_word(value: &str) -> String {
2906 let plain = !value.is_empty()
2907 && value
2908 .chars()
2909 .all(|c| c.is_ascii_alphanumeric() || ":/._%@+=,-".contains(c));
2910 if plain {
2911 value.to_string()
2912 } else {
2913 format!("'{}'", inert(value).replace('\'', r"'\''"))
2914 }
2915}
2916
2917pub async fn cmd_rooms_offers_accept(
2924 client: &VtaClient,
2925 target: OfferTarget<'_>,
2926 room_id: &str,
2927 key_package: Option<&str>,
2928) -> Result<(), Box<dyn std::error::Error>> {
2929 use trust_tasks_rs::Payload as _;
2930 use vti_specs::vtc::rooms::offers::accept::v0_1 as accept;
2931
2932 let (offers, _) = list_offers(client, &target).await?;
2933 let offer = offers
2934 .into_iter()
2935 .find(|o| o.room_id.as_str() == room_id)
2936 .ok_or_else(|| {
2937 format!(
2938 "{} has no open offer to {room_id} addressed to {}: `pnm rooms offers list` \
2939 shows what it has offered you",
2940 target.vtc_did, target.member
2941 )
2942 })?;
2943 let sealed = offer.visibility.to_string() != "open";
2944
2945 for (what, credential) in [
2949 ("membership", offer.credentials.membership.as_str()),
2950 ("authority", offer.credentials.authority.as_str()),
2951 ] {
2952 let body: Value = serde_json::from_str(credential)
2953 .map_err(|e| format!("the offer's {what} credential is not JSON: {e}"))?;
2954 client.cred_vault_receive(body, None).await.map_err(|e| {
2955 format!("your VTA's credential vault refused the {what} credential: {e}")
2956 })?;
2957 }
2958 let invitation = offer.credentials.invitation.as_str();
2959
2960 if sealed && holds_group(client, room_id, target.member).await? {
2963 println!(
2964 "{room_id} is already joined as {} by your VTA — accepted here or from another \
2965 surface. Nothing to do.",
2966 target.member
2967 );
2968 return Ok(());
2969 }
2970
2971 let key_package = match (sealed, key_package) {
2972 (false, _) => None,
2973 (true, Some(src)) => Some(std::fs::read_to_string(src)?.trim().to_string()),
2974 (true, None) => Some(offered_key_package(client, room_id, invitation).await?),
2977 };
2978
2979 let mut payload = serde_json::json!({ "roomId": room_id });
2980 if let Some(kp) = &key_package {
2981 payload["keyPackage"] = Value::String(kp.clone());
2982 }
2983 let reply = act_and_send_offer(client, &target, accept::Payload::TYPE_URI, payload)
2984 .await
2985 .map_err(|e| e.report(&target))
2986 .map_err(|e| match &key_package {
2987 Some(kp) => format!(
2988 "{e}\n\nIf the community may have admitted you, retry the identical acceptance \
2989 with this KeyPackage (save it to a file and pass --key-package <file>):\n{kp}"
2990 ),
2991 None => e.to_string(),
2992 })?;
2993 let accepted: accept::Response = parse_reply("vtc/rooms/offers/accept", reply)?;
2994
2995 match &accepted.welcome {
2996 Some(welcome) => {
2997 let joined = client
2998 .idempotent(|| {
2999 client.room_welcome(
3000 room_id,
3001 invitation,
3002 welcome.as_str(),
3003 accepted.ratchet_tree.as_ref().map(|t| t.as_str()),
3004 )
3005 })
3006 .await;
3007 match joined {
3008 Ok(joined) => println!(
3009 "Accepted {room_id} as {} and joined its group at room epoch {}.",
3010 target.member,
3011 joined.epoch + 1
3012 ),
3013 Err(e) if e.to_string().contains("alreadyJoined") => println!(
3015 "Accepted {room_id} as {}; your VTA had already joined its group from \
3016 another surface.",
3017 target.member
3018 ),
3019 Err(e) => {
3020 return Err(format!(
3021 "admitted at room epoch {}, but your VTA did not join from the \
3022 Welcome: {e}",
3023 accepted.epoch
3024 )
3025 .into());
3026 }
3027 }
3028 }
3029 None if sealed => {
3030 return Err(format!(
3031 "the community admitted you to sealed room {room_id} but sent no Welcome"
3032 )
3033 .into());
3034 }
3035 None => println!(
3036 "Accepted {room_id} as {}; the room is open, at epoch {}.",
3037 target.member, accepted.epoch
3038 ),
3039 }
3040 println!(
3041 " Read it with `pnm rooms list --room {room_id} --host {} --host-did {}`.",
3042 target.vtc_url,
3043 offer.host.as_str()
3044 );
3045 Ok(())
3046}
3047
3048async fn holds_group(
3051 client: &VtaClient,
3052 room: &str,
3053 member: &str,
3054) -> Result<bool, Box<dyn std::error::Error>> {
3055 let held = client.room_list(false).await?;
3056 Ok(held
3057 .rooms
3058 .iter()
3059 .any(|r| r.room_id.as_str() == room && r.member.as_str() == member))
3060}
3061
3062async fn offered_key_package(
3066 client: &VtaClient,
3067 room: &str,
3068 invitation: &str,
3069) -> Result<String, Box<dyn std::error::Error>> {
3070 let kp = client.room_key_package(room, invitation).await?;
3071 if kp.already_minted {
3072 eprintln!(
3073 "Your VTA already held a KeyPackage for this offer — another surface may \
3074 have begun this acceptance; finishing it."
3075 );
3076 }
3077 Ok(kp.key_package.to_string())
3078}
3079
3080async fn list_memberships(
3083 client: &VtaClient,
3084 target: &OfferTarget<'_>,
3085) -> Result<
3086 Vec<vti_specs::vtc::rooms::memberships::list::v0_1::RoomMembership>,
3087 Box<dyn std::error::Error>,
3088> {
3089 use trust_tasks_rs::Payload as _;
3090 use vti_specs::vtc::rooms::memberships::list::v0_1 as list;
3091 let mut items = Vec::new();
3092 let mut cursor: Option<String> = None;
3093 loop {
3094 let payload = match &cursor {
3095 Some(c) => serde_json::json!({ "cursor": c }),
3096 None => serde_json::json!({}),
3097 };
3098 let reply = act_and_send_offer(client, target, list::Payload::TYPE_URI, payload)
3099 .await
3100 .map_err(|e| e.report(target))?;
3101 let page: list::Response = parse_reply("vtc/rooms/memberships/list", reply)?;
3102 items.extend(page.items);
3103 match page.next_cursor {
3104 Some(next) => cursor = Some(next.to_string()),
3105 None => return Ok(items),
3106 }
3107 }
3108}
3109
3110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3114pub enum RoomStanding {
3115 Offered,
3117 Joined,
3119 Unfinished,
3122 NeedsReadmission,
3125 Removed,
3127}
3128
3129impl RoomStanding {
3130 pub fn of(state: &str, sealed: bool, held: bool, welcome_kept: bool) -> Self {
3132 match state {
3133 "offered" => Self::Offered,
3134 "removed" => Self::Removed,
3135 _ if !sealed || held => Self::Joined,
3136 _ if welcome_kept => Self::Unfinished,
3137 _ => Self::NeedsReadmission,
3138 }
3139 }
3140
3141 fn label(self) -> &'static str {
3142 match self {
3143 Self::Offered => "offered",
3144 Self::Joined => "joined",
3145 Self::Unfinished => "accepted, not finished",
3146 Self::NeedsReadmission => "admitted, not openable here",
3147 Self::Removed => "removed",
3148 }
3149 }
3150}
3151
3152pub async fn cmd_rooms_memberships(
3156 client: &VtaClient,
3157 target: OfferTarget<'_>,
3158) -> Result<(), Box<dyn std::error::Error>> {
3159 let rooms = list_memberships(client, &target).await?;
3160 if rooms.is_empty() {
3161 println!(
3162 "{} has offered no rooms to {}.",
3163 target.vtc_did, target.member
3164 );
3165 return Ok(());
3166 }
3167 let held = client.room_list(false).await?;
3168 for r in &rooms {
3169 let room_id = r.room_id.as_str();
3170 let holds = held
3171 .rooms
3172 .iter()
3173 .any(|h| h.room_id.as_str() == room_id && h.member.as_str() == target.member);
3174 let standing = RoomStanding::of(
3175 &r.state.to_string(),
3176 r.visibility.to_string() != "open",
3177 holds,
3178 r.repeat_accept_until.is_some(),
3179 );
3180 let actions: Vec<String> = r.actions.0.iter().map(ToString::to_string).collect();
3181 println!(
3182 "{room_id} {} [{}] {} {}",
3183 inert(r.label.as_str()),
3184 r.visibility,
3185 actions.join(","),
3186 standing.label(),
3187 );
3188 match standing {
3189 RoomStanding::Offered | RoomStanding::Unfinished => {
3190 println!(" accept: {}", accept_command(room_id, &target));
3191 }
3192 RoomStanding::NeedsReadmission => println!(
3193 " Your VTA never joined this room's group and the community no longer keeps \
3194 the Welcome: ask an administrator to remove and re-admit you."
3195 ),
3196 RoomStanding::Joined | RoomStanding::Removed => {}
3197 }
3198 }
3199 Ok(())
3200}
3201
3202#[cfg(test)]
3203mod tests {
3204 use super::*;
3205 use serde_json::json;
3206
3207 fn prune_target() -> RoomTarget<'static> {
3208 RoomTarget {
3209 host_url: "https://host.example",
3210 host_did: Some("did:web:host.example"),
3211 room_id: "did:webvh:Qm:example.com:rooms:northwind",
3212 }
3213 }
3214
3215 #[test]
3218 fn a_prune_without_yes_asks_a_terminal_and_refuses_a_pipe() {
3219 assert_eq!(prune_guard(true, false), PruneGuard::Proceed);
3220 assert_eq!(prune_guard(true, true), PruneGuard::Proceed);
3221 assert_eq!(prune_guard(false, true), PruneGuard::Ask);
3222 assert_eq!(prune_guard(false, false), PruneGuard::Refuse);
3223 }
3224
3225 #[test]
3228 fn the_prune_warning_names_what_becomes_unreadable() {
3229 let w = prune_warning("did:example:room", 5, 7, Some("chained"));
3232 assert!(w.contains("cannot be undone"), "{w}");
3233 assert!(w.contains("epochs 1 to 3"), "{w}");
3234 assert!(w.contains("epoch floor (4)"), "{w}");
3235 assert!(w.contains("now at epoch 7"), "{w}");
3236 assert!(w.contains("The records stay"), "{w}");
3237 assert!(w.contains("members' VTAs"), "{w}");
3238 assert!(!w.contains("fromJoin"), "{w}");
3239
3240 assert!(prune_warning("r", 3, 4, None).contains("unreadable for good"));
3241 assert!(prune_warning("r", 3, 4, None).contains("epoch 1 "));
3242 assert!(prune_warning("r", 2, 3, None).contains("nothing becomes unreadable"));
3243 assert!(prune_warning("r", 3, 4, Some("fromJoin")).contains("keeps no chain"));
3244 }
3245
3246 #[test]
3248 fn a_floor_at_the_current_epoch_suggests_the_one_below() {
3249 let t = prune_target();
3250 let msg = not_ahead_message(&t, 7, 7);
3251 assert!(msg.contains("current epoch 7"), "{msg}");
3252 assert!(
3253 msg.contains(
3254 "pnm rooms prune --room did:webvh:Qm:example.com:rooms:northwind \
3255 --host https://host.example --host-did did:web:host.example \
3256 --before-epoch 6 --reason '<why>' --yes"
3257 ),
3258 "{msg}"
3259 );
3260 assert!(!not_ahead_message(&t, 1, 1).contains("pnm rooms prune"));
3262 }
3263
3264 #[test]
3266 fn a_hosts_prune_refusal_is_explained() {
3267 let t = prune_target();
3268 let refusal = |code: &str| vtc_client::VtcError::Http {
3269 status: 400,
3270 body: json!({
3271 "type": "https://trusttasks.org/spec/trust-task-error/0.1",
3272 "payload": { "code": code, "message": "no" },
3273 })
3274 .to_string(),
3275 };
3276 let msg = prune_refused(refusal("rooms/epoch/prune:notAhead"), &t, 7, 7);
3277 assert!(msg.contains("--before-epoch 6"), "{msg}");
3278 let msg = prune_refused(refusal("permissionDenied"), &t, 3, 7);
3279 assert!(msg.contains("needs `admin`"), "{msg}");
3280 assert!(msg.contains("issue-authority --action admin"), "{msg}");
3281 let msg = prune_refused(refusal("rooms/epoch/prune:chainTooDeep"), &t, 3, 7);
3282 assert!(!msg.contains("needs `admin`"), "{msg}");
3283 }
3284
3285 #[test]
3287 fn a_prune_outcome_reports_reach_not_the_request() {
3288 let r = |pruned, earliest_rung| vtc_client::rooms::PruneResponse {
3289 room_id: "did:example:room".into(),
3290 pruned,
3291 earliest_rung,
3292 };
3293 let none = prune_outcome(&r(0, 3));
3294 assert!(none.contains("nothing to drop"), "{none}");
3295 assert!(none.contains("reaches back to epoch 3"), "{none}");
3296 assert!(prune_outcome(&r(1, 5)).contains("1 rung dropped"));
3297 assert!(prune_outcome(&r(4, 5)).contains("4 rungs dropped"));
3298 }
3299
3300 fn rejected(code: &str, details: Value) -> VtaError {
3301 VtaError::Rejected {
3302 code: code.into(),
3303 message: "refused".into(),
3304 details: Some(details),
3305 }
3306 }
3307
3308 #[test]
3311 fn an_earlier_removal_is_explained_whichever_version_reported_it() {
3312 let d =
3313 json!({ "member": "did:key:zEarlier", "code": "permissionDenied", "listKept": true });
3314 for code in [
3315 "rooms/owner/group/add:earlierRemovalRefused",
3316 "rooms/owner/group/renew:earlierRemovalRefused",
3317 "rooms/owner/group/remove:earlierRemovalRefused",
3318 ] {
3319 let said = earlier_removal_refused(
3320 &rejected(code, d.clone()),
3321 "did:room",
3322 "did:host",
3323 None,
3324 "admit did:key:zNew",
3325 )
3326 .unwrap_or_else(|| panic!("{code} is an earlier removal"));
3327 assert!(said.starts_with("Nothing was done"), "{said}");
3328 assert!(said.contains("did:key:zEarlier"), "{said}");
3329 assert!(
3330 said.contains("pnm rooms owner revoke --room did:room"),
3331 "{said}"
3332 );
3333 assert!(said.contains("again to admit did:key:zNew"), "{said}");
3334 }
3335 let own = rejected(
3336 "rooms/owner/group/add:hostRefused",
3337 json!({ "code": "permissionDenied", "reason": "not today" }),
3338 );
3339 assert!(earlier_removal_refused(&own, "r", "h", None, "x").is_none());
3340 let kept_not = rejected(
3341 "rooms/owner/group/renew:earlierRemovalRefused",
3342 json!({ "member": "did:key:zEarlier", "code": "rooms/revocations/put:listInvalid",
3343 "listKept": false }),
3344 );
3345 let said = earlier_removal_refused(&kept_not, "r", "h", None, "x").unwrap();
3346 assert!(said.contains("discarded"), "{said}");
3347 }
3348
3349 #[test]
3351 fn a_rooms_standing_follows_the_one_record() {
3352 use RoomStanding::*;
3353 assert_eq!(RoomStanding::of("offered", true, false, false), Offered);
3354 assert_eq!(RoomStanding::of("admitted", false, false, false), Joined);
3355 assert_eq!(RoomStanding::of("admitted", true, true, true), Joined);
3356 assert_eq!(RoomStanding::of("admitted", true, false, true), Unfinished);
3357 assert_eq!(
3358 RoomStanding::of("admitted", true, false, false),
3359 NeedsReadmission
3360 );
3361 assert_eq!(RoomStanding::of("removed", true, true, false), Removed);
3362 }
3363
3364 fn minted(authority: Value) -> Value {
3365 json!({
3366 "presentation": {
3367 "membership": "vmc-blob",
3368 "authority": authority,
3369 },
3370 "expiresAt": "2026-09-07T16:00:00Z",
3371 })
3372 }
3373
3374 fn vtc_target() -> RoomTarget<'static> {
3375 RoomTarget {
3376 host_url: "https://community.example/v1",
3377 host_did: Some("did:webvh:community.example"),
3378 room_id: "did:webvh:example.com:rooms:northwind",
3379 }
3380 }
3381
3382 const CATCH_UP: &str = "pnm rooms keys commit --room did:webvh:example.com:rooms:northwind \
3383 --host https://community.example/v1 --host-did did:webvh:community.example";
3384
3385 #[test]
3387 fn an_offer_prints_its_accept_command_with_the_room_filled_in() {
3388 let target = OfferTarget {
3389 vtc_url: "https://vtc.example/v1",
3390 vtc_did: "did:webvh:Qm1:vtc.example",
3391 member: "did:webvh:Qm2:alice.example",
3392 };
3393 assert_eq!(
3394 accept_command("did:webvh:Qm3:vtc.example:room-dids:ab12", &target),
3395 "pnm rooms offers accept --room did:webvh:Qm3:vtc.example:room-dids:ab12 \
3396 --vtc https://vtc.example/v1 --vtc-did did:webvh:Qm1:vtc.example \
3397 --member did:webvh:Qm2:alice.example"
3398 );
3399 }
3400
3401 fn offer_v2(description: Option<&str>) -> Value {
3402 let mut o = json!({
3403 "roomId": "did:webvh:Qm3:vtc.example:room-dids:ab12",
3404 "host": "did:webvh:Qm1:vtc.example",
3405 "label": "Board\u{1b}[2J papers",
3406 "visibility": "private",
3407 "actions": ["read", "write"],
3408 "validUntil": "2026-12-01T00:00:00Z",
3409 "offeredAt": "2026-10-01T00:00:00Z",
3410 "state": "open",
3411 "credentials": {
3412 "membership": "{}",
3413 "authority": "{}",
3414 "invitation": "inv"
3415 }
3416 });
3417 if let Some(d) = description {
3418 o["description"] = json!(d);
3419 }
3420 o
3421 }
3422
3423 fn offer_target() -> OfferTarget<'static> {
3424 OfferTarget {
3425 vtc_url: "https://vtc.example/v1",
3426 vtc_did: "did:webvh:Qm1:vtc.example",
3427 member: "did:webvh:Qm2:alice.example",
3428 }
3429 }
3430
3431 #[test]
3435 fn an_offer_shows_its_description_on_one_inert_line() {
3436 let offer: CommunityOffer = serde_json::from_value(offer_v2(Some(
3437 "Papers for the board:\nagenda\tminutes\u{7}\r\n accept: rm -rf ~",
3438 )))
3439 .unwrap();
3440 let text = offer_lines(&offer, &offer_target());
3441 let lines: Vec<&str> = text.lines().collect();
3442 assert_eq!(lines.len(), 3, "{text}");
3443 assert!(lines[0].contains("Board[2J papers"), "{text}");
3444 assert!(
3445 !text.contains('\u{1b}') && !text.contains('\u{7}'),
3446 "{text}"
3447 );
3448 assert_eq!(
3449 lines[1],
3450 " about: Papers for the board: agenda minutes accept: rm -rf ~"
3451 );
3452 assert!(
3453 lines[2].starts_with(" accept: pnm rooms offers accept"),
3454 "{text}"
3455 );
3456
3457 let bare: CommunityOffer = serde_json::from_value(offer_v2(None)).unwrap();
3459 assert!(!offer_lines(&bare, &offer_target()).contains("about:"));
3460 }
3461
3462 #[test]
3465 fn an_offers_list_0_4_page_carries_renewals_and_their_renew_command() {
3466 let (items, _, renewals) = offers_page(json!({
3467 "items": [offer_v2(None)],
3468 "renewals": [{
3469 "roomId": "did:webvh:Qm:rooms.example:wg", "host": "did:example:community",
3470 "label": "Working group", "visibility": "attributed", "actions": ["read"],
3471 "validUntil": "2027-11-10T00:00:00Z", "renewsAt": "2026-11-10T00:00:00Z",
3472 "issuedAt": "2026-10-20T09:00:00Z",
3473 "credentials": { "membership": "{}", "authority": "{}" },
3474 }],
3475 }))
3476 .expect("a 0.4 page");
3477 assert_eq!(items.len(), 1);
3478 assert_eq!(renewals.len(), 1);
3479 let lines = renewal_lines(&renewals[0], &offer_target());
3480 assert!(lines.contains("renewal until 2027-11-10"), "{lines}");
3481 assert!(
3482 lines.contains("pnm rooms offers renew --room did:webvh:Qm:rooms.example:wg"),
3483 "{lines}"
3484 );
3485 }
3486
3487 #[test]
3489 fn a_room_id_a_shell_would_read_is_quoted() {
3490 assert_eq!(shell_word("did:web:x"), "did:web:x");
3491 assert_eq!(shell_word("a;rm -rf ~"), "'a;rm -rf ~'");
3492 assert_eq!(shell_word("it's"), r"'it'\''s'");
3493 assert_eq!(shell_word("$(x)"), "'$(x)'");
3494 assert_eq!(shell_word(""), "''");
3495 }
3496
3497 #[test]
3501 fn a_superseded_epoch_put_prints_keys_commit_with_the_real_values() {
3502 let body = json!({
3503 "type": "https://trusttasks.org/spec/rooms/records/put/0.1#error",
3504 "payload": {
3505 "code": "rooms/records/put:epochMismatch",
3506 "message": "record is sealed under epoch 2, room `r` is at 3",
3507 },
3508 });
3509 let err: Box<dyn std::error::Error> = Box::new(vtc_client::VtcError::Http {
3510 status: 400,
3511 body: body.to_string(),
3512 });
3513 let text = put_refused(err, &vtc_target()).to_string();
3514 assert!(text.contains(CATCH_UP), "{text}");
3515 assert!(text.contains("sealed under epoch 2"), "{text}");
3516 assert!(!text.contains("HTTP 400"), "{text}");
3517 }
3518
3519 #[test]
3522 fn other_put_refusals_pass_through_and_no_host_did_is_invented() {
3523 let t = vtc_target();
3524 assert!(put_refusal_hint("rooms/records/put:recordTooLarge", "", &t).is_none());
3525 let err: Box<dyn std::error::Error> = Box::new(vtc_client::VtcError::Http {
3526 status: 500,
3527 body: "not a document".into(),
3528 });
3529 assert_eq!(
3530 put_refused(err, &t).to_string(),
3531 "VTC returned HTTP 500: not a document"
3532 );
3533
3534 let bare = RoomTarget {
3535 host_did: None,
3536 ..vtc_target()
3537 };
3538 assert_eq!(
3539 keys_commit_command(&bare),
3540 "pnm rooms keys commit --room did:webvh:example.com:rooms:northwind \
3541 --host https://community.example/v1"
3542 );
3543 }
3544
3545 fn open_rejected(reason: &str, extra: Value) -> VtaError {
3546 let mut details = json!({ "reason": reason });
3547 details
3548 .as_object_mut()
3549 .unwrap()
3550 .extend(extra.as_object().unwrap().clone());
3551 VtaError::Rejected {
3552 code: "rooms/keys/open:unknownEpoch".into(),
3553 message: "record is sealed under epoch 4".into(),
3554 details: Some(details),
3555 }
3556 }
3557
3558 #[test]
3561 fn a_removed_member_is_told_it_was_removed_not_to_catch_up() {
3562 let text = open_refusal_hint(
3563 &open_rejected("removed", json!({ "removedAtEpoch": 3 })),
3564 &vtc_target(),
3565 );
3566 assert!(
3567 text.contains("You were removed from this room's group at epoch 3"),
3568 "{text}"
3569 );
3570 assert!(!text.contains("keys commit"), "{text}");
3571 }
3572
3573 fn file_key_rejected(reason: &str, message: &str) -> VtaError {
3574 VtaError::Rejected {
3575 code: "rooms/keys/file-key:unknownEpoch".into(),
3576 message: message.into(),
3577 details: Some(json!({ "reason": reason })),
3578 }
3579 }
3580
3581 #[test]
3585 fn a_file_key_refusal_names_the_remedy() {
3586 let removed = VtaError::Rejected {
3587 code: "rooms/keys/file-key:unknownEpoch".into(),
3588 message: "the file is sealed under epoch 4, and this member was removed".into(),
3589 details: Some(json!({ "reason": "removed", "removedAtEpoch": 3 })),
3590 };
3591 let text = file_key_refusal_hint(&removed, &vtc_target());
3592 assert!(
3593 text.contains("You were removed from this room's group at epoch 3"),
3594 "{text}"
3595 );
3596 assert!(text.contains("Files sealed since then"), "{text}");
3597 assert!(!text.contains("keys commit"), "{text}");
3598
3599 let behind_commit = file_key_refusal_hint(
3601 &file_key_rejected("notDelivered", "the file is sealed under epoch 4"),
3602 &vtc_target(),
3603 );
3604 assert!(behind_commit.contains(CATCH_UP), "{behind_commit}");
3605
3606 let behind = file_key_refusal_hint(
3607 &file_key_rejected("beyondChain", "sealed under epoch 1"),
3608 &vtc_target(),
3609 );
3610 assert!(behind.contains("pnm rooms keys backfill"), "{behind}");
3611 assert!(behind.contains("The file is older"), "{behind}");
3612
3613 let wrong_task = VtaError::Rejected {
3615 code: "rooms/keys/open:unknownEpoch".into(),
3616 message: "x".into(),
3617 details: Some(json!({ "reason": "beyondChain" })),
3618 };
3619 assert!(
3620 file_key_refusal_hint(&wrong_task, &vtc_target()).contains("did not open with any key")
3621 );
3622 }
3623
3624 #[test]
3626 fn a_member_behind_is_told_to_catch_up() {
3627 let text = open_refusal_hint(
3628 &open_rejected("notDelivered", json!({ "heldEpoch": 2 })),
3629 &vtc_target(),
3630 );
3631 assert!(text.contains(CATCH_UP), "{text}");
3632 }
3633
3634 #[test]
3637 fn renew_refuses_a_sealed_room_and_names_group_renew() {
3638 let target = RoomTarget {
3639 host_url: "https://community.example/v1",
3640 host_did: Some("did:webvh:community.example"),
3641 room_id: "did:webvh:example.com:rooms:northwind",
3642 };
3643 assert!(refuse_sealed_renew("open", &target).is_ok());
3644 for tier in ["attributed", "private"] {
3645 let e = refuse_sealed_renew(tier, &target).expect_err(tier);
3646 assert!(
3647 e.contains(
3648 "pnm rooms owner group-renew --room did:webvh:example.com:rooms:northwind \
3649 --host-did did:webvh:community.example"
3650 ),
3651 "{e}"
3652 );
3653 assert!(e.contains(tier), "{e}");
3654 }
3655 let no_did = RoomTarget {
3656 host_did: None,
3657 ..target
3658 };
3659 let e = refuse_sealed_renew("private", &no_did).unwrap_err();
3660 assert!(e.contains("--host-did <the host's DID>"), "{e}");
3661 }
3662
3663 #[test]
3664 fn a_minted_presentation_becomes_a_session() {
3665 let session = session_from_minted("did:webvh:room", &minted(json!(["leaf", "root"])))
3666 .expect("a well-formed reply rebuilds");
3667 assert_eq!(session.room_id(), "did:webvh:room");
3668 assert_eq!(session.chain_depth(), 2, "leaf first, root last");
3669 }
3670
3671 #[test]
3675 fn a_subject_binding_survives_the_rebuild() {
3676 let mut m = minted(json!(["leaf"]));
3677 m["presentation"]["subjectBinding"] = json!("zk-proof-blob");
3678 let session =
3679 session_from_minted("did:webvh:room", &m).expect("a private presentation rebuilds");
3680 assert_eq!(session.chain_depth(), 1);
3681 }
3682
3683 #[test]
3687 fn an_unreadable_reply_is_refused_rather_than_guessed() {
3688 assert!(
3689 session_from_minted("r", &json!({})).is_err(),
3690 "no presentation"
3691 );
3692 assert!(
3693 session_from_minted("r", &json!({ "presentation": { "authority": ["a"] } })).is_err(),
3694 "no membership"
3695 );
3696 assert!(
3697 session_from_minted("r", &json!({ "presentation": { "membership": "m" } })).is_err(),
3698 "no chain"
3699 );
3700 assert!(
3701 session_from_minted("r", &minted(json!([]))).is_err(),
3702 "an empty chain authorizes nothing and must not be sent"
3703 );
3704 }
3705
3706 #[test]
3710 fn a_malformed_link_does_not_silently_shorten_the_chain() {
3711 let err = session_from_minted("r", &minted(json!(["leaf", 7])))
3712 .expect_err("a non-string link is refused");
3713 assert!(
3714 err.to_string().contains("non-string"),
3715 "the error must name the cause: {err}"
3716 );
3717 }
3718
3719 #[test]
3721 fn untrusted_text_loses_its_control_characters_but_keeps_its_lines() {
3722 assert_eq!(inert("a\x1b[2Jb\nc\td\x07"), "a[2Jb\nc\td");
3723 }
3724
3725 #[test]
3727 fn a_file_name_cannot_leave_the_directory_or_hide() {
3728 assert_eq!(local_name("../../etc/passwd"), "passwd");
3729 assert_eq!(local_name("C:\\Users\\x\\evil.exe"), "evil.exe");
3730 assert_eq!(local_name(".bashrc"), "bashrc");
3731 assert_eq!(local_name("term sheet.pdf"), "term sheet.pdf");
3732 assert_eq!(local_name("../"), "room-file");
3733 assert_eq!(local_name("a\nb.txt"), "a b.txt");
3734 }
3735
3736 #[test]
3738 fn opaque_keys_are_fresh_and_uninformative() {
3739 let (a, b) = (opaque_key(), opaque_key());
3740 assert_ne!(a, b);
3741 assert_eq!(a.len(), 22, "16 random bytes, base64url");
3742 }
3743
3744 #[test]
3745 fn pin_flags_keep_unchanged_distinct_from_unpinned() {
3746 assert_eq!(pinned_from_flags(true, false), Some(true));
3747 assert_eq!(pinned_from_flags(false, true), Some(false));
3748 assert_eq!(
3749 pinned_from_flags(false, false),
3750 None,
3751 "neither flag must leave the pin alone, not clear it"
3752 );
3753 }
3754}