1pub mod canon;
24#[cfg(target_family = "unix")]
25pub mod client;
26
27use std::fmt;
28
29use serde::{Deserialize, Serialize};
30
31pub const CUSTODY_PROTOCOL: &str = "whipplescript.custody.v1";
34
35pub const CUSTODIAN_SOCKET_ENV: &str = "WHIPPLESCRIPT_CUSTODIAN_SOCKET";
44
45#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
53#[serde(transparent)]
54pub struct CredentialName(String);
55
56impl CredentialName {
57 pub fn new(name: &str) -> Result<Self, String> {
60 if name.is_empty() {
61 return Err("credential name is empty".to_string());
62 }
63 let ok_segment = |s: &str| {
64 !s.is_empty()
65 && s.chars()
66 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
67 };
68 if !name.split('/').all(ok_segment) {
69 return Err(format!(
70 "invalid credential name {name:?}: segments must be non-empty [a-z0-9_-]"
71 ));
72 }
73 Ok(Self(name.to_string()))
74 }
75
76 pub fn as_str(&self) -> &str {
77 &self.0
78 }
79
80 pub fn resource_id(&self) -> String {
82 format!("credential:{}", self.0)
83 }
84
85 pub fn from_resource_id(id: &str) -> Result<Self, String> {
87 match id.strip_prefix("credential:") {
88 Some(rest) => Self::new(rest),
89 None => Err(format!("not a credential resource id: {id:?}")),
90 }
91 }
92}
93
94impl fmt::Display for CredentialName {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 f.write_str(&self.0)
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum CredentialRef {
110 Custodian(CredentialName),
113 LegacyEnv { var: String },
116 LegacyTag { tag: String },
120}
121
122impl CredentialRef {
123 pub fn parse(raw: &str) -> Result<Self, String> {
124 if raw.trim().is_empty() {
125 return Err("empty credential reference".to_string());
126 }
127 if let Some(var) = raw.strip_prefix("env:") {
128 if var.is_empty() {
129 return Err("env: credential reference names no variable".to_string());
130 }
131 return Ok(CredentialRef::LegacyEnv {
132 var: var.to_string(),
133 });
134 }
135 if let Some(rest) = raw.strip_prefix("credential:") {
136 if let Ok(name) = CredentialName::new(rest) {
140 return Ok(CredentialRef::Custodian(name));
141 }
142 return Ok(CredentialRef::LegacyTag {
143 tag: raw.to_string(),
144 });
145 }
146 if raw.starts_with("secret:") {
147 return Ok(CredentialRef::LegacyTag {
148 tag: raw.to_string(),
149 });
150 }
151 Err(format!(
152 "unrecognized credential reference {raw:?}: use `credential:<name>` (custodian \
153 entry) or the legacy `env:<VAR>` shim"
154 ))
155 }
156
157 pub fn shim_rung(&self) -> (Rung, bool) {
164 (Rung::Process, true)
165 }
166
167 pub fn is_legacy(&self) -> bool {
170 !matches!(self, CredentialRef::Custodian(_))
171 }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
178#[serde(rename_all = "kebab-case")]
179pub enum CredentialKind {
180 Bearer,
182 Basic,
184 Raw,
186 HmacSha256,
188 Ed25519,
190 AwsSigv4,
192 JwtRs256,
194}
195
196impl CredentialKind {
197 pub fn as_str(&self) -> &'static str {
198 match self {
199 CredentialKind::Bearer => "bearer",
200 CredentialKind::Basic => "basic",
201 CredentialKind::Raw => "raw",
202 CredentialKind::HmacSha256 => "hmac-sha256",
203 CredentialKind::Ed25519 => "ed25519",
204 CredentialKind::AwsSigv4 => "aws-sigv4",
205 CredentialKind::JwtRs256 => "jwt-rs256",
206 }
207 }
208
209 pub fn parse(s: &str) -> Result<Self, String> {
210 match s {
211 "bearer" => Ok(CredentialKind::Bearer),
212 "basic" => Ok(CredentialKind::Basic),
213 "raw" => Ok(CredentialKind::Raw),
214 "hmac-sha256" => Ok(CredentialKind::HmacSha256),
215 "ed25519" => Ok(CredentialKind::Ed25519),
216 "aws-sigv4" => Ok(CredentialKind::AwsSigv4),
217 "jwt-rs256" => Ok(CredentialKind::JwtRs256),
218 other => Err(format!("unknown credential kind {other:?}")),
219 }
220 }
221
222 pub fn supports(&self, op: Operation) -> bool {
225 match op {
226 Operation::Request => matches!(
227 self,
228 CredentialKind::Bearer
229 | CredentialKind::Basic
230 | CredentialKind::Raw
231 | CredentialKind::AwsSigv4
232 ),
233 Operation::Sign | Operation::Verify => matches!(
234 self,
235 CredentialKind::HmacSha256
236 | CredentialKind::Ed25519
237 | CredentialKind::AwsSigv4
238 | CredentialKind::JwtRs256
239 ),
240 Operation::Derive => matches!(
241 self,
242 CredentialKind::HmacSha256 | CredentialKind::AwsSigv4 | CredentialKind::Raw
243 ),
244 Operation::Wrap | Operation::Unwrap => {
245 matches!(self, CredentialKind::Raw | CredentialKind::HmacSha256)
246 }
247 Operation::Mint => matches!(
248 self,
249 CredentialKind::Bearer | CredentialKind::Basic | CredentialKind::Raw
250 ),
251 }
252 }
253}
254
255impl fmt::Display for CredentialKind {
256 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257 f.write_str(self.as_str())
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
271#[serde(rename_all = "kebab-case")]
272pub enum Rung {
273 Process,
276 OsKeyring,
279 Hardware,
282 Remote,
285}
286
287impl Rung {
288 pub fn as_str(&self) -> &'static str {
289 match self {
290 Rung::Process => "process",
291 Rung::OsKeyring => "os-keyring",
292 Rung::Hardware => "hardware",
293 Rung::Remote => "remote",
294 }
295 }
296
297 pub fn parse(s: &str) -> Result<Self, String> {
298 match s {
299 "process" | "r0" => Ok(Rung::Process),
300 "os-keyring" | "r1" => Ok(Rung::OsKeyring),
301 "hardware" | "r2" => Ok(Rung::Hardware),
302 "remote" | "r3" => Ok(Rung::Remote),
303 other => Err(format!("unknown sealing rung {other:?}")),
304 }
305 }
306
307 pub fn ladder_label(&self) -> &'static str {
309 match self {
310 Rung::Process => "r0",
311 Rung::OsKeyring => "r1",
312 Rung::Hardware => "r2",
313 Rung::Remote => "r3",
314 }
315 }
316}
317
318impl fmt::Display for Rung {
319 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
320 f.write_str(self.as_str())
321 }
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
332#[serde(rename_all = "lowercase")]
333pub enum Operation {
334 Request,
335 Sign,
336 Verify,
337 Derive,
338 Wrap,
339 Unwrap,
340 Mint,
341}
342
343impl Operation {
344 pub const ALL: [Operation; 7] = [
345 Operation::Request,
346 Operation::Sign,
347 Operation::Verify,
348 Operation::Derive,
349 Operation::Wrap,
350 Operation::Unwrap,
351 Operation::Mint,
352 ];
353
354 pub fn as_str(&self) -> &'static str {
355 match self {
356 Operation::Request => "request",
357 Operation::Sign => "sign",
358 Operation::Verify => "verify",
359 Operation::Derive => "derive",
360 Operation::Wrap => "wrap",
361 Operation::Unwrap => "unwrap",
362 Operation::Mint => "mint",
363 }
364 }
365
366 pub fn parse(s: &str) -> Result<Self, String> {
367 match s {
368 "request" => Ok(Operation::Request),
369 "sign" => Ok(Operation::Sign),
370 "verify" => Ok(Operation::Verify),
371 "derive" => Ok(Operation::Derive),
372 "wrap" => Ok(Operation::Wrap),
373 "unwrap" => Ok(Operation::Unwrap),
374 "mint" => Ok(Operation::Mint),
375 other => Err(format!("unknown custody operation {other:?}")),
376 }
377 }
378
379 pub fn narrowable(&self) -> bool {
384 matches!(self, Operation::Request | Operation::Mint)
385 }
386}
387
388impl fmt::Display for Operation {
389 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390 f.write_str(self.as_str())
391 }
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
401#[serde(rename_all = "lowercase")]
402pub enum PresentationForm {
403 Bearer,
405 Basic,
408 Raw,
410}
411
412impl PresentationForm {
413 pub fn as_str(&self) -> &'static str {
414 match self {
415 PresentationForm::Bearer => "bearer",
416 PresentationForm::Basic => "basic",
417 PresentationForm::Raw => "raw",
418 }
419 }
420
421 pub fn parse(s: &str) -> Result<Self, String> {
422 match s {
423 "bearer" => Ok(PresentationForm::Bearer),
424 "basic" => Ok(PresentationForm::Basic),
425 "raw" => Ok(PresentationForm::Raw),
426 other => Err(format!("unknown presentation form {other:?}")),
427 }
428 }
429}
430
431impl fmt::Display for PresentationForm {
432 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
433 f.write_str(self.as_str())
434 }
435}
436
437const SENTINEL_OPEN: &str = "{{whipplescript-credential:";
438const SENTINEL_CLOSE: &str = "}}";
439
440#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
448pub struct Sentinel {
449 pub credential: CredentialName,
450 pub form: PresentationForm,
451}
452
453impl Sentinel {
454 pub fn new(credential: CredentialName, form: PresentationForm) -> Self {
455 Self { credential, form }
456 }
457
458 pub fn render(&self) -> String {
460 format!(
461 "{SENTINEL_OPEN}{}:{}{SENTINEL_CLOSE}",
462 self.credential, self.form
463 )
464 }
465
466 pub fn parse(text: &str) -> Result<Self, String> {
468 let inner = text
469 .strip_prefix(SENTINEL_OPEN)
470 .and_then(|t| t.strip_suffix(SENTINEL_CLOSE))
471 .ok_or_else(|| format!("not a credential sentinel: {text:?}"))?;
472 let (name, form) = inner
473 .rsplit_once(':')
474 .ok_or_else(|| format!("malformed credential sentinel: {text:?}"))?;
475 Ok(Self {
476 credential: CredentialName::new(name)?,
477 form: PresentationForm::parse(form)?,
478 })
479 }
480
481 pub fn find_all(text: &str) -> Result<Vec<(std::ops::Range<usize>, Sentinel)>, String> {
487 let mut out = Vec::new();
488 let mut at = 0usize;
489 while let Some(rel) = text[at..].find(SENTINEL_OPEN) {
490 let start = at + rel;
491 let close_rel = text[start..]
492 .find(SENTINEL_CLOSE)
493 .ok_or_else(|| "unterminated credential sentinel".to_string())?;
494 let end = start + close_rel + SENTINEL_CLOSE.len();
495 let sentinel = Sentinel::parse(&text[start..end])?;
496 out.push((start..end, sentinel));
497 at = end;
498 }
499 Ok(out)
500 }
501}
502
503impl fmt::Display for Sentinel {
504 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
505 f.write_str(&self.render())
506 }
507}
508
509#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
517pub struct EgressRequest {
518 pub method: String,
519 pub url: String,
520 pub headers: Vec<(String, String)>,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub body_b64: Option<String>,
526}
527
528#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
529pub struct EgressResponse {
530 pub status: u16,
531 pub headers: Vec<(String, String)>,
532 #[serde(default, skip_serializing_if = "Option::is_none")]
533 pub body_b64: Option<String>,
534}
535
536#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
540#[serde(rename_all = "kebab-case")]
541pub enum SignatureAlg {
542 HmacSha256,
543 Ed25519,
544 RsaSha256,
546}
547
548#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
555pub struct Envelope {
556 pub credential: CredentialName,
559 pub context: String,
562 pub label: serde_json::Value,
565 pub nonce_b64: String,
566 pub ciphertext_b64: String,
567}
568
569#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
575pub struct MintExtraction {
576 pub token_path: String,
578 #[serde(default)]
581 pub public_paths: Vec<String>,
582}
583
584#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
588#[serde(tag = "op", rename_all = "lowercase")]
589pub enum CustodyOp {
590 Request {
592 credential: CredentialName,
593 request: EgressRequest,
594 slots: usize,
605 },
606 Sign {
612 credential: CredentialName,
613 alg: SignatureAlg,
614 #[serde(default)]
615 derivation: Vec<String>,
616 payload_b64: String,
617 },
618 Verify {
621 credential: CredentialName,
622 alg: SignatureAlg,
623 payload_b64: String,
624 signature_b64: String,
625 },
626 Derive {
628 credential: CredentialName,
629 context: String,
630 },
631 Wrap {
634 credential: CredentialName,
635 plaintext_b64: String,
636 label: serde_json::Value,
637 context: String,
638 },
639 Unwrap {
643 credential: CredentialName,
644 envelope: Envelope,
645 context: String,
646 },
647 Mint {
650 credential: CredentialName,
651 scope: Vec<String>,
652 ttl_secs: u64,
653 exchange: EgressRequest,
654 extraction: MintExtraction,
655 exchange_slots: usize,
661 },
662}
663
664impl CustodyOp {
665 pub fn operation(&self) -> Operation {
666 match self {
667 CustodyOp::Request { .. } => Operation::Request,
668 CustodyOp::Sign { .. } => Operation::Sign,
669 CustodyOp::Verify { .. } => Operation::Verify,
670 CustodyOp::Derive { .. } => Operation::Derive,
671 CustodyOp::Wrap { .. } => Operation::Wrap,
672 CustodyOp::Unwrap { .. } => Operation::Unwrap,
673 CustodyOp::Mint { .. } => Operation::Mint,
674 }
675 }
676
677 pub fn credential(&self) -> &CredentialName {
678 match self {
679 CustodyOp::Request { credential, .. }
680 | CustodyOp::Sign { credential, .. }
681 | CustodyOp::Verify { credential, .. }
682 | CustodyOp::Derive { credential, .. }
683 | CustodyOp::Wrap { credential, .. }
684 | CustodyOp::Unwrap { credential, .. }
685 | CustodyOp::Mint { credential, .. } => credential,
686 }
687 }
688}
689
690#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
698pub struct UseAttribution {
699 pub run_id: String,
701 #[serde(default, skip_serializing_if = "Option::is_none")]
703 pub actor: Option<String>,
704 #[serde(default, skip_serializing_if = "Option::is_none")]
706 pub effect_key: Option<String>,
707}
708
709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
711pub struct CustodyCall {
712 pub protocol: String,
714 pub attribution: UseAttribution,
715 #[serde(flatten)]
716 pub op: CustodyOp,
717}
718
719impl CustodyCall {
720 pub fn new(attribution: UseAttribution, op: CustodyOp) -> Self {
721 Self {
722 protocol: CUSTODY_PROTOCOL.to_string(),
723 attribution,
724 op,
725 }
726 }
727}
728
729#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
733#[serde(tag = "result", rename_all = "lowercase")]
734pub enum CustodyOk {
735 Requested {
736 response: EgressResponse,
737 },
738 Signed {
739 signature_b64: String,
740 },
741 Verified {
742 valid: bool,
746 },
747 Derived {
748 credential: CredentialName,
749 },
750 Wrapped {
751 envelope: Envelope,
752 },
753 Unwrapped {
754 plaintext_b64: String,
755 label: serde_json::Value,
758 },
759 Minted {
760 credential: CredentialName,
761 fingerprint: String,
763 public: serde_json::Value,
765 },
766}
767
768#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
771#[serde(tag = "error", rename_all = "kebab-case")]
772pub enum CustodyError {
773 UnknownCredential {
774 credential: CredentialName,
775 },
776 KindMismatch {
779 credential: CredentialName,
780 kind: CredentialKind,
781 operation: Operation,
782 },
783 OperationNotGranted {
785 credential: CredentialName,
786 operation: Operation,
787 },
788 ScopeRefused {
790 credential: CredentialName,
791 detail: String,
792 },
793 RungBelowFloor {
796 required: Rung,
797 actual: Rung,
798 },
799 Revoked {
800 credential: CredentialName,
801 },
802 BudgetExhausted {
804 credential: CredentialName,
805 },
806 EnvelopeRefused,
809 EgressFailed {
811 detail: String,
812 },
813 Backend {
815 detail: String,
816 },
817}
818
819impl fmt::Display for CustodyError {
820 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
821 match self {
822 CustodyError::UnknownCredential { credential } => {
823 write!(f, "unknown credential {credential}")
824 }
825 CustodyError::KindMismatch {
826 credential,
827 kind,
828 operation,
829 } => write!(
830 f,
831 "credential {credential} has kind {kind}, which does not support {operation}"
832 ),
833 CustodyError::OperationNotGranted {
834 credential,
835 operation,
836 } => write!(f, "{operation} on {credential} is not granted"),
837 CustodyError::ScopeRefused { credential, detail } => {
838 write!(f, "scope refused for {credential}: {detail}")
839 }
840 CustodyError::RungBelowFloor { required, actual } => write!(
841 f,
842 "sealing rung {} is below the required floor {}",
843 actual.ladder_label(),
844 required.ladder_label()
845 ),
846 CustodyError::Revoked { credential } => write!(f, "credential {credential} is revoked"),
847 CustodyError::BudgetExhausted { credential } => {
848 write!(f, "use budget exhausted for {credential}")
849 }
850 CustodyError::EnvelopeRefused => f.write_str("envelope refused"),
851 CustodyError::EgressFailed { detail } => write!(f, "egress failed: {detail}"),
852 CustodyError::Backend { detail } => write!(f, "custodian backend fault: {detail}"),
853 }
854 }
855}
856
857#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
862pub struct CustodyReply {
863 pub use_id: String,
864 pub rung: Rung,
865 pub degraded: bool,
866 pub outcome: Result<CustodyOk, CustodyError>,
867}
868
869#[derive(Debug, Clone, PartialEq, Eq)]
872pub enum TransportError {
873 Unavailable(String),
874 Protocol(String),
875}
876
877impl fmt::Display for TransportError {
878 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
879 match self {
880 TransportError::Unavailable(d) => write!(f, "custodian unavailable: {d}"),
881 TransportError::Protocol(d) => write!(f, "custody protocol error: {d}"),
882 }
883 }
884}
885
886impl std::error::Error for TransportError {}
887
888pub trait CustodyTransport: Send + Sync {
893 fn call(&self, call: CustodyCall) -> Result<CustodyReply, TransportError>;
894}
895
896#[cfg(test)]
897mod tests {
898 use super::*;
899
900 fn name(s: &str) -> CredentialName {
901 CredentialName::new(s).expect("valid name")
902 }
903
904 #[test]
905 fn rungs_are_ordered() {
906 assert!(Rung::Process < Rung::OsKeyring);
907 assert!(Rung::OsKeyring < Rung::Hardware);
908 assert!(Rung::Hardware < Rung::Remote);
909 assert_eq!(Rung::parse("r2").expect("parse"), Rung::Hardware);
910 assert_eq!(Rung::parse("hardware").expect("parse"), Rung::Hardware);
911 }
912
913 #[test]
914 fn resource_identity_is_backend_free() {
915 let n = name("acme/stripe-live");
916 assert_eq!(n.resource_id(), "credential:acme/stripe-live");
917 assert_eq!(
918 CredentialName::from_resource_id("credential:acme/stripe-live").expect("roundtrip"),
919 n
920 );
921 assert!(CredentialName::from_resource_id("vault:acme/stripe-live").is_err());
922 assert!(CredentialName::new("Bad Name").is_err());
923 assert!(CredentialName::new("trailing/").is_err());
924 }
925
926 #[test]
927 fn sentinel_roundtrip_and_scan() {
928 let s = Sentinel::new(name("stripe_api"), PresentationForm::Bearer);
929 assert_eq!(s.render(), "{{whipplescript-credential:stripe_api:bearer}}");
930 assert_eq!(Sentinel::parse(&s.render()).expect("parse"), s);
931
932 let header = format!("Bearer {}", s.render());
933 let found = Sentinel::find_all(&header).expect("scan");
934 assert_eq!(found.len(), 1);
935 assert_eq!(found[0].1, s);
936 assert_eq!(&header[found[0].0.clone()], s.render());
937
938 let two = format!(
939 "{} and {}",
940 Sentinel::new(name("a"), PresentationForm::Raw).render(),
941 Sentinel::new(name("b"), PresentationForm::Basic).render()
942 );
943 assert_eq!(Sentinel::find_all(&two).expect("scan").len(), 2);
944
945 assert!(Sentinel::find_all("{{whipplescript-credential:oops").is_err());
946 assert!(Sentinel::find_all("{{whipplescript-credential:UPPER:bearer}}").is_err());
947 assert!(Sentinel::find_all("no sentinels here")
948 .expect("scan")
949 .is_empty());
950 }
951
952 #[test]
953 fn operation_grant_classes_match_dr0053_s14() {
954 let narrowable: Vec<Operation> = Operation::ALL
955 .iter()
956 .copied()
957 .filter(Operation::narrowable)
958 .collect();
959 assert_eq!(narrowable, vec![Operation::Request, Operation::Mint]);
960 }
961
962 #[test]
963 fn credential_refs_unify_with_legacy_spellings_tagged_degraded() {
964 assert_eq!(
966 CredentialRef::parse("credential:acme/stripe-live").expect("parse"),
967 CredentialRef::Custodian(name("acme/stripe-live"))
968 );
969 for legacy in [
971 "env:OPENAI_API_KEY",
972 "secret:claude",
973 "credential:account:openai",
974 ] {
975 let parsed = CredentialRef::parse(legacy).expect("legacy parses");
976 assert!(parsed.is_legacy(), "{legacy} must be legacy");
977 assert_eq!(parsed.shim_rung(), (Rung::Process, true));
978 }
979 assert!(!CredentialRef::parse("credential:model")
980 .expect("parse")
981 .is_legacy());
982 assert!(CredentialRef::parse("sk_live_plaintext").is_err());
986 assert!(CredentialRef::parse("env:").is_err());
987 }
988
989 #[test]
990 fn there_is_no_get_on_the_wire() {
991 let get = serde_json::json!({
994 "protocol": CUSTODY_PROTOCOL,
995 "attribution": { "run_id": "r1" },
996 "op": "get",
997 "credential": "stripe_api",
998 });
999 assert!(serde_json::from_value::<CustodyCall>(get).is_err());
1000 }
1001
1002 #[test]
1003 fn calls_roundtrip_on_the_wire() {
1004 let call = CustodyCall::new(
1005 UseAttribution {
1006 run_id: "run-1".into(),
1007 actor: Some("deployer".into()),
1008 effect_key: None,
1009 },
1010 CustodyOp::Sign {
1011 credential: name("release_signing"),
1012 alg: SignatureAlg::Ed25519,
1013 derivation: vec![],
1014 payload_b64: "cGF5bG9hZA==".into(),
1015 },
1016 );
1017 let wire = serde_json::to_string(&call).expect("serialize");
1018 let back: CustodyCall = serde_json::from_str(&wire).expect("deserialize");
1019 assert_eq!(back, call);
1020 assert_eq!(back.op.operation(), Operation::Sign);
1021
1022 let reply = CustodyReply {
1023 use_id: "use-1".into(),
1024 rung: Rung::Process,
1025 degraded: true,
1026 outcome: Err(CustodyError::RungBelowFloor {
1027 required: Rung::Hardware,
1028 actual: Rung::Process,
1029 }),
1030 };
1031 let wire = serde_json::to_string(&reply).expect("serialize");
1032 let back: CustodyReply = serde_json::from_str(&wire).expect("deserialize");
1033 assert_eq!(back, reply);
1034 }
1035
1036 #[test]
1037 fn kind_operation_support_is_static() {
1038 assert!(CredentialKind::Bearer.supports(Operation::Request));
1039 assert!(!CredentialKind::Bearer.supports(Operation::Sign));
1041 assert!(CredentialKind::Ed25519.supports(Operation::Sign));
1042 assert!(!CredentialKind::Ed25519.supports(Operation::Request));
1043 assert!(CredentialKind::AwsSigv4.supports(Operation::Request));
1044 assert!(CredentialKind::AwsSigv4.supports(Operation::Sign));
1045 }
1046}