1use std::collections::{HashMap, HashSet};
24use std::path::{Path, PathBuf};
25use std::sync::{Arc, Mutex};
26
27use act_credentials::backend::BackendChoice;
28use act_credentials::record::{Secret, SecretInfo};
29use act_credentials::store::CredentialStore;
30use act_policy::providers::credentials::CAP_CREDENTIALS;
31use wasmtime::component::{HasSelf, Linker};
32
33use super::HostState;
34use super::bindings::act::credentials::{store, types};
35use crate::consent::{sanitize_hint, truncate_field};
36
37#[derive(Debug, PartialEq, Eq)]
41pub enum HostError {
42 NotFound,
43 Denied,
44 InvalidSession,
45 Unavailable(String),
46}
47
48const STORE_UNREADABLE: &str = "the credential store could not be read";
59
60impl HostError {
61 fn to_wit(&self) -> store::SecretError {
62 match self {
63 HostError::NotFound => store::SecretError::NotFound,
64 HostError::Denied => store::SecretError::Denied,
65 HostError::InvalidSession => store::SecretError::InvalidSession,
66 HostError::Unavailable(d) => store::SecretError::Unavailable(d.clone()),
67 }
68 }
69}
70
71#[async_trait::async_trait]
84pub trait CredentialRefresher: Send + Sync {
85 async fn refresh(&self, req: RefreshRequest<'_>) -> Result<Refreshed, String>;
88}
89
90pub struct RefreshRequest<'a> {
92 pub issuer: &'a str,
95 pub refresh_token: &'a str,
97 pub now: u64,
100}
101
102pub struct Refreshed {
104 pub access_token: String,
105 pub expires_at: Option<u64>,
106 pub scopes: Vec<String>,
107 pub refresh_token: Option<String>,
112}
113
114fn now_unix() -> u64 {
118 std::time::SystemTime::now()
119 .duration_since(std::time::UNIX_EPOCH)
120 .map_or(0, |d| d.as_secs())
121}
122
123fn due_fields(rec: &act_credentials::record::SecretRecord, now: u64) -> Vec<String> {
129 rec.fields
130 .iter()
131 .filter(|(_, v)| act_credentials::expiry::needs_refresh(v.expose(), now))
132 .map(|(k, _)| k.clone())
133 .collect()
134}
135
136fn apply_refresh(rec: &mut act_credentials::record::SecretRecord, field: &str, r: &Refreshed) {
142 let mut value = serde_json::Map::new();
143 value.insert(
144 "std:access-token".into(),
145 serde_json::Value::String(r.access_token.clone()),
146 );
147 if let Some(exp) = r.expires_at {
148 value.insert("std:expires-at".into(), serde_json::Value::from(exp));
149 }
150 if !r.scopes.is_empty() {
151 value.insert(
152 "std:scopes".into(),
153 serde_json::Value::from(r.scopes.clone()),
154 );
155 }
156 rec.fields.insert(
157 field.to_string(),
158 act_credentials::record::SecretValue::new(serde_json::Value::Object(value)),
159 );
160
161 if let Some(new_refresh) = &r.refresh_token {
165 rec.host_only.insert(
166 refresh_token_slot(field),
167 act_credentials::record::SecretValue::new(new_refresh.clone()),
168 );
169 }
170 rec.expires_at = r.expires_at.map(|e| i64::try_from(e).unwrap_or(i64::MAX));
171}
172
173pub fn issuer_slot(field: &str) -> String {
180 format!("{field}:std:issuer")
181}
182
183pub fn refresh_token_slot(field: &str) -> String {
184 format!("{field}:std:refresh-token")
185}
186
187pub struct CredentialHost {
188 store: Arc<dyn CredentialStore>,
189 component: String,
190 live_sessions: Mutex<HashSet<String>>,
191 refresher: Option<Arc<dyn CredentialRefresher>>,
192 refresh_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
198}
199
200impl CredentialHost {
201 pub fn new(store: Arc<dyn CredentialStore>, component: String) -> Self {
202 Self {
203 store,
204 component,
205 live_sessions: Mutex::new(HashSet::new()),
206 refresher: None,
207 refresh_locks: Mutex::new(HashMap::new()),
208 }
209 }
210
211 pub fn with_refresher(mut self, refresher: Arc<dyn CredentialRefresher>) -> Self {
216 self.refresher = Some(refresher);
217 self
218 }
219
220 pub fn component(&self) -> &str {
225 &self.component
226 }
227
228 pub fn note_session_opened(&self, id: &str) {
229 self.live_sessions
230 .lock()
231 .unwrap_or_else(std::sync::PoisonError::into_inner)
232 .insert(id.to_string());
233 }
234
235 pub fn note_session_closed(&self, id: &str) {
236 self.live_sessions
237 .lock()
238 .unwrap_or_else(std::sync::PoisonError::into_inner)
239 .remove(id);
240 }
241
242 fn live(&self, id: &str) -> bool {
243 self.live_sessions
244 .lock()
245 .unwrap_or_else(std::sync::PoisonError::into_inner)
246 .contains(id)
247 }
248
249 pub fn get_secret(&self, session: &str, key: &str) -> Result<Secret, HostError> {
256 if !self.live(session) {
257 return Err(HostError::InvalidSession);
258 }
259 match self.store.get(&self.component, key) {
260 Ok(Some(rec)) => {
261 crate::audit::emit_credential_issue(&crate::audit::CredentialIssueRecord {
264 component_ref: self.component.clone(),
265 session_id: session.to_string(),
266 key: key.to_string(),
267 kind: rec.kind.clone(),
268 });
269 Ok(rec.project())
270 }
271 Ok(None) => Err(HostError::NotFound),
272 Err(e) => {
273 tracing::warn!(error = %e, "credential store read failed");
274 Err(HostError::Unavailable(STORE_UNREADABLE.into()))
275 }
276 }
277 }
278
279 pub async fn refresh_if_due(&self, key: &str, now: u64) {
295 let Some(refresher) = self.refresher.clone() else {
296 return;
297 };
298 let Ok(Some(rec)) = self.store.get(&self.component, key) else {
301 return;
302 };
303 if due_fields(&rec, now).is_empty() {
304 return;
305 }
306
307 let lock = self.lock_for(key);
308 let _held = lock.lock().await;
309
310 let Ok(Some(rec)) = self.store.get(&self.component, key) else {
314 return;
315 };
316 for field in due_fields(&rec, now) {
317 let (Some(issuer), Some(refresh_token)) = (
318 rec.host_only
319 .get(&issuer_slot(&field))
320 .and_then(|v| v.expose_str())
321 .map(str::to_string),
322 rec.host_only
323 .get(&refresh_token_slot(&field))
324 .and_then(|v| v.expose_str())
325 .map(str::to_string),
326 ) else {
327 tracing::debug!(
331 field = %field,
332 "credential is near expiry but carries no issuer and refresh token"
333 );
334 continue;
335 };
336
337 let refreshed = match refresher
338 .refresh(RefreshRequest {
339 issuer: &issuer,
340 refresh_token: &refresh_token,
341 now,
342 })
343 .await
344 {
345 Ok(r) => r,
346 Err(e) => {
347 tracing::warn!(field = %field, error = %e, "credential refresh failed");
348 continue;
349 }
350 };
351
352 let applied = self.store.update(&self.component, key, &mut |rec| {
353 apply_refresh(rec, &field, &refreshed);
354 });
355 match applied {
356 Ok(_) => tracing::info!(
357 component = %self.component,
358 key = %key,
359 field = %field,
360 "credential refreshed"
361 ),
362 Err(e) => tracing::warn!(error = %e, "storing a refreshed credential failed"),
363 }
364 }
365 }
366
367 fn lock_for(&self, key: &str) -> Arc<tokio::sync::Mutex<()>> {
368 self.refresh_locks
369 .lock()
370 .unwrap_or_else(std::sync::PoisonError::into_inner)
371 .entry(key.to_string())
372 .or_default()
373 .clone()
374 }
375
376 pub fn list_secrets(&self, session: Option<&str>) -> Result<Vec<SecretInfo>, HostError> {
381 if let Some(id) = session
382 && !self.live(id)
383 {
384 return Err(HostError::InvalidSession);
385 }
386 self.store.list(Some(&self.component)).map_err(|e| {
387 tracing::warn!(error = %e, "credential store list failed");
388 HostError::Unavailable(STORE_UNREADABLE.into())
389 })
390 }
391}
392
393pub fn default_store_root() -> Option<PathBuf> {
400 dirs::data_dir().map(|d| d.join("act").join("credentials"))
401}
402
403pub fn resolve_backend(explicit: Option<&str>) -> anyhow::Result<Option<BackendChoice>> {
417 match explicit {
418 Some(s) => {
419 let path = s.strip_prefix("file:").ok_or_else(|| {
420 anyhow::anyhow!("unknown --credentials-backend '{s}'; expected file:<path>")
421 })?;
422 anyhow::ensure!(
423 !path.is_empty(),
424 "--credentials-backend 'file:' needs a path, e.g. file:/path/to/store"
425 );
426 Ok(Some(BackendChoice::File(PathBuf::from(path))))
427 }
428 None => Ok(default_store_root().map(BackendChoice::File)),
429 }
430}
431
432pub fn backend_root(choice: &BackendChoice) -> &Path {
436 match choice {
437 BackendChoice::File(p) => p,
438 }
439}
440
441impl store::Host for HostState {}
449impl store::Host for &mut HostState {}
450impl types::Host for &mut HostState {}
451
452pub fn add_to_linker(linker: &mut Linker<HostState>) -> anyhow::Result<()> {
459 types::add_to_linker::<HostState, HasSelf<HostState>>(linker, |s| s)
460 .map_err(|e| anyhow::anyhow!("failed to add act:credentials/types to linker: {e}"))?;
461 store::add_to_linker::<HostState, HasSelf<HostState>>(linker, |s| s)
462 .map_err(|e| anyhow::anyhow!("failed to add act:credentials/store to linker: {e}"))?;
463 Ok(())
464}
465
466struct GateContext {
469 host: Option<Arc<CredentialHost>>,
470 ceiling: Arc<dyn act_policy::provider::CompiledCeiling>,
471 prompter: Arc<dyn act_policy::consent::ConsentPrompter>,
472 cache: Arc<act_policy::consent::DecisionCache>,
473}
474
475impl GateContext {
476 fn from(accessor: &wasmtime::component::Accessor<HostState, HasSelf<HostState>>) -> Self {
477 accessor.with(|mut access| {
478 let state: &mut HostState = access.get();
479 Self {
480 host: state.credentials.clone(),
481 ceiling: state.credentials_ceiling.clone(),
482 prompter: state.consent_prompter.clone(),
483 cache: state.consent_cache.clone(),
484 }
485 })
486 }
487
488 async fn allows(&self, key: &str, action: &str, hint: Option<&str>) -> bool {
496 let component = self.host.as_ref().map(|h| h.component().to_string());
497 use crate::audit::{CapDecisionRecord, Decision4, emit_cap_decision};
498
499 let op = act_policy::provider::ResourceOp {
500 cap_id: CAP_CREDENTIALS.to_string(),
501 key: key.to_string(),
502 action: action.to_string(),
503 attrs: serde_json::Value::Null,
504 };
505 let explained = self.ceiling.classify_explained(&op);
506 let mode = self.ceiling.effective_mode().to_string();
507 match explained.decision {
508 act_policy::Decision::Allow => {
509 emit_cap_decision(&CapDecisionRecord::statik(
510 CAP_CREDENTIALS,
511 key,
512 action,
513 Decision4::Allow,
514 &mode,
515 explained.rule,
516 ));
517 true
518 }
519 act_policy::Decision::Deny => {
520 emit_cap_decision(&CapDecisionRecord::statik(
521 CAP_CREDENTIALS,
522 key,
523 action,
524 Decision4::Deny,
525 &mode,
526 explained.rule,
527 ));
528 false
529 }
530 act_policy::Decision::Ask => {
533 let has_channel = self.prompter.has_channel();
534 let allowed = self
535 .cache
536 .decide_cached(
537 &*self.prompter,
538 act_policy::consent::ConsentAsk {
539 cap_id: CAP_CREDENTIALS.to_string(),
540 key: key.to_string(),
541 summary: consent_summary(component.as_deref(), action, key, hint),
542 },
543 )
544 .await;
545 emit_cap_decision(&CapDecisionRecord::answered(
546 CAP_CREDENTIALS,
547 key,
548 allowed,
549 has_channel,
550 ));
551 allowed
552 }
553 }
554 }
555}
556
557fn consent_summary(component: Option<&str>, action: &str, key: &str, hint: Option<&str>) -> String {
580 let key = truncate_field(key);
584 let base = match component {
585 Some(c) => format!("{c} requests credential {action}: {key}"),
586 None => format!("credential {action}: {key}"),
587 };
588 match hint.map(sanitize_hint) {
589 Some(h) if !h.is_empty() => format!("{base} — component says: \"{h}\""),
590 _ => base,
591 }
592}
593
594fn to_wit_secret(secret: Secret) -> Result<store::Secret, HostError> {
610 let mut fields = Vec::with_capacity(secret.fields.len());
611 for (name, value) in secret.fields {
612 let json = value.expose();
613 if !(json.is_string() || json.is_object()) {
614 tracing::warn!(field = %name, "credential field is neither string- nor object-shaped");
615 return Err(HostError::Unavailable(STORE_UNREADABLE.into()));
616 }
617 fields.push((name, act_types::cbor::to_cbor(json)));
618 }
619 Ok(store::Secret {
620 kind: secret.kind,
621 fields,
622 })
623}
624
625fn to_wit_info(info: SecretInfo) -> store::SecretInfo {
626 store::SecretInfo {
627 key: info.key,
628 kind: info.kind,
629 description: info.description,
630 expires_at: info.expires_at.and_then(|e| u64::try_from(e).ok()),
634 }
635}
636
637const NO_STORE: &str = "no credential store is configured for this run";
640
641async fn serve_list(
648 ctx: &GateContext,
649 session: Option<&str>,
650) -> Result<Vec<store::SecretInfo>, store::SecretError> {
651 if !ctx.allows("*", "list", None).await {
654 return Err(HostError::Denied.to_wit());
655 }
656 let Some(host) = &ctx.host else {
657 return Err(store::SecretError::Unavailable(NO_STORE.into()));
658 };
659 host.list_secrets(session)
660 .map(|infos| infos.into_iter().map(to_wit_info).collect())
661 .map_err(|e| e.to_wit())
662}
663
664async fn serve_get(
671 ctx: &GateContext,
672 session: &str,
673 want: &store::SecretRequest,
674) -> Result<store::Secret, store::SecretError> {
675 if !ctx.allows(&want.key, "get", want.hint.as_deref()).await {
676 return Err(HostError::Denied.to_wit());
677 }
678 let Some(host) = &ctx.host else {
679 return Err(store::SecretError::Unavailable(NO_STORE.into()));
680 };
681 host.refresh_if_due(&want.key, now_unix()).await;
685
686 let secret = host
690 .get_secret(session, &want.key)
691 .map_err(|e| e.to_wit())?;
692 to_wit_secret(secret).map_err(|e| e.to_wit())
693}
694
695impl store::HostWithStore<HostState> for HasSelf<HostState> {
696 async fn list_secrets(
697 accessor: &wasmtime::component::Accessor<HostState, Self>,
698 session: Option<String>,
699 ) -> Result<Vec<store::SecretInfo>, store::SecretError> {
700 let ctx = GateContext::from(accessor);
701 serve_list(&ctx, session.as_deref()).await
702 }
703
704 async fn get_secret(
705 accessor: &wasmtime::component::Accessor<HostState, Self>,
706 session: String,
707 want: store::SecretRequest,
708 ) -> Result<store::Secret, store::SecretError> {
709 let ctx = GateContext::from(accessor);
710 serve_get(&ctx, &session, &want).await
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717 use act_credentials::backend::file::FileStore;
718 use act_credentials::record::{SecretRecord, SecretValue};
719 use act_credentials::store::CredentialStore;
720 use std::collections::BTreeMap;
721
722 fn host(dir: &std::path::Path) -> CredentialHost {
723 let store = FileStore::new(dir.to_path_buf());
724 let mut fields = BTreeMap::new();
725 fields.insert("acme:token".to_string(), SecretValue::new("tok"));
726 let mut host_only = BTreeMap::new();
727 host_only.insert("std:refresh-token".to_string(), SecretValue::new("rt"));
728 store
729 .put(
730 "comp",
731 "notion",
732 &SecretRecord {
733 kind: "std:fields".into(),
734 fields,
735 host_only,
736 description: None,
737 expires_at: None,
738 },
739 )
740 .unwrap();
741 CredentialHost::new(Arc::new(store), "comp".to_string())
742 }
743
744 use act_credentials::store::StoreError;
752 use act_policy::consent::{ConsentAsk, ConsentPrompter, DecisionCache, DenyPrompter};
753 use act_policy::grant::{CapabilityGrant, PolicyMode};
754 use act_policy::provider::CapabilityProvider;
755 use act_policy::providers::credentials::CredentialsProvider;
756 use std::sync::atomic::{AtomicUsize, Ordering};
757
758 struct CountingStore {
761 inner: FileStore,
762 gets: AtomicUsize,
763 lists: AtomicUsize,
764 }
765
766 impl CredentialStore for CountingStore {
767 fn get(&self, component: &str, key: &str) -> Result<Option<SecretRecord>, StoreError> {
768 self.gets.fetch_add(1, Ordering::SeqCst);
769 self.inner.get(component, key)
770 }
771 fn put(&self, component: &str, key: &str, rec: &SecretRecord) -> Result<(), StoreError> {
772 self.inner.put(component, key, rec)
773 }
774 fn erase(&self, component: &str, key: &str) -> Result<(), StoreError> {
775 self.inner.erase(component, key)
776 }
777 fn list(&self, component: Option<&str>) -> Result<Vec<SecretInfo>, StoreError> {
778 self.lists.fetch_add(1, Ordering::SeqCst);
779 self.inner.list(component)
780 }
781 fn components(&self) -> Result<Vec<String>, StoreError> {
782 self.inner.components()
783 }
784 fn update(
785 &self,
786 component: &str,
787 key: &str,
788 mutate: &mut dyn FnMut(&mut act_credentials::record::SecretRecord),
789 ) -> Result<Option<act_credentials::record::SecretRecord>, StoreError> {
790 self.inner.update(component, key, mutate)
791 }
792 }
793
794 struct AllowPrompter(AtomicUsize);
797
798 #[async_trait::async_trait]
799 impl ConsentPrompter for AllowPrompter {
800 async fn decide(&self, _ask: &ConsentAsk) -> bool {
801 self.0.fetch_add(1, Ordering::SeqCst);
802 true
803 }
804 }
805
806 async fn ceiling(
807 declared: bool,
808 mode: PolicyMode,
809 ) -> Arc<dyn act_policy::provider::CompiledCeiling> {
810 let declared: Option<Vec<serde_json::Value>> =
814 if declared { Some(Vec::new()) } else { None };
815 Arc::from(
816 CredentialsProvider
817 .resolve(
818 CAP_CREDENTIALS,
819 declared.as_deref(),
820 &CapabilityGrant {
821 mode,
822 allow: vec![],
823 deny: vec![],
824 },
825 )
826 .await
827 .expect("resolve"),
828 )
829 }
830
831 fn gate_ctx(
834 dir: &std::path::Path,
835 ceiling: Arc<dyn act_policy::provider::CompiledCeiling>,
836 prompter: Arc<dyn ConsentPrompter>,
837 ) -> (GateContext, Arc<CountingStore>) {
838 let seeded = host(dir); drop(seeded);
840 let store = Arc::new(CountingStore {
841 inner: FileStore::new(dir.to_path_buf()),
842 gets: AtomicUsize::new(0),
843 lists: AtomicUsize::new(0),
844 });
845 let h = Arc::new(CredentialHost::new(store.clone(), "comp".to_string()));
846 h.note_session_opened("s1");
847 (
848 GateContext {
849 host: Some(h),
850 ceiling,
851 prompter,
852 cache: Arc::new(DecisionCache::new()),
853 },
854 store,
855 )
856 }
857
858 fn want(key: &str) -> store::SecretRequest {
859 store::SecretRequest {
860 key: key.to_string(),
861 kind: None,
862 resource: None,
863 scopes: vec![],
864 hint: None,
865 }
866 }
867
868 #[tokio::test(flavor = "current_thread")]
869 async fn an_undeclared_class_is_refused_no_matter_what_was_granted() {
870 for mode in [PolicyMode::Open, PolicyMode::Allowlist, PolicyMode::Ask] {
873 let c = ceiling(false, mode).await;
874 let dir = tempfile::tempdir().unwrap();
875 let (ctx, store) = gate_ctx(dir.path(), c, Arc::new(DenyPrompter));
876 assert!(!ctx.allows("notion", "get", None).await, "mode {mode:?}");
877 assert!(
878 matches!(
879 serve_get(&ctx, "s1", &want("notion")).await,
880 Err(store::SecretError::Denied)
881 ),
882 "mode {mode:?}"
883 );
884 assert_eq!(
885 store.gets.load(Ordering::SeqCst),
886 0,
887 "a refusal must not reach the store — otherwise `denied` timing \
888 leaks whether the key exists (design §3.4), mode {mode:?}"
889 );
890 }
891 }
892
893 #[tokio::test(flavor = "current_thread")]
894 async fn a_denied_grant_is_refused_even_though_the_class_was_declared() {
895 let dir = tempfile::tempdir().unwrap();
896 let (ctx, store) = gate_ctx(
897 dir.path(),
898 ceiling(true, PolicyMode::Deny).await,
899 Arc::new(DenyPrompter),
900 );
901 assert!(!ctx.allows("notion", "get", None).await);
902 assert!(matches!(
903 serve_get(&ctx, "s1", &want("notion")).await,
904 Err(store::SecretError::Denied)
905 ));
906 assert_eq!(store.gets.load(Ordering::SeqCst), 0);
907 }
908
909 #[tokio::test(flavor = "current_thread")]
910 async fn ask_with_no_prompt_channel_degrades_to_deny() {
911 let dir = tempfile::tempdir().unwrap();
914 let (ctx, store) = gate_ctx(
915 dir.path(),
916 ceiling(true, PolicyMode::Ask).await,
917 Arc::new(DenyPrompter),
918 );
919 assert!(!ctx.allows("notion", "get", None).await);
920 assert!(matches!(
921 serve_get(&ctx, "s1", &want("notion")).await,
922 Err(store::SecretError::Denied)
923 ));
924 assert_eq!(store.gets.load(Ordering::SeqCst), 0);
925 }
926
927 #[tokio::test(flavor = "current_thread")]
928 async fn an_approved_ask_serves_the_credential_and_is_not_asked_twice() {
929 let dir = tempfile::tempdir().unwrap();
930 let prompter = Arc::new(AllowPrompter(AtomicUsize::new(0)));
931 let (ctx, store) = gate_ctx(
932 dir.path(),
933 ceiling(true, PolicyMode::Ask).await,
934 prompter.clone(),
935 );
936
937 let got = serve_get(&ctx, "s1", &want("notion"))
938 .await
939 .expect("served");
940 assert_eq!(got.kind, "std:fields");
941 assert_eq!(store.gets.load(Ordering::SeqCst), 1);
942
943 assert!(serve_get(&ctx, "s1", &want("notion")).await.is_ok());
946 assert_eq!(
947 prompter.0.load(Ordering::SeqCst),
948 1,
949 "one prompt per (class, key) per run"
950 );
951 }
952
953 #[tokio::test(flavor = "current_thread")]
954 async fn an_open_grant_on_a_declared_class_needs_no_prompt_at_all() {
955 let dir = tempfile::tempdir().unwrap();
956 let prompter = Arc::new(AllowPrompter(AtomicUsize::new(0)));
957 let (ctx, _store) = gate_ctx(
958 dir.path(),
959 ceiling(true, PolicyMode::Open).await,
960 prompter.clone(),
961 );
962 assert!(serve_get(&ctx, "s1", &want("notion")).await.is_ok());
963 assert_eq!(prompter.0.load(Ordering::SeqCst), 0, "static allow");
964 }
965
966 #[tokio::test(flavor = "current_thread")]
967 async fn a_listing_is_gated_too_and_a_refusal_never_reaches_the_index() {
968 let dir = tempfile::tempdir().unwrap();
969 let (ctx, store) = gate_ctx(
970 dir.path(),
971 ceiling(false, PolicyMode::Open).await,
972 Arc::new(DenyPrompter),
973 );
974 assert!(matches!(
975 serve_list(&ctx, Some("s1")).await,
976 Err(store::SecretError::Denied)
977 ));
978 assert_eq!(store.lists.load(Ordering::SeqCst), 0);
979 }
980
981 #[tokio::test(flavor = "current_thread")]
982 async fn a_run_with_no_store_reports_unavailable_rather_than_denied() {
983 let ctx = GateContext {
986 host: None,
987 ceiling: ceiling(true, PolicyMode::Open).await,
988 prompter: Arc::new(DenyPrompter),
989 cache: Arc::new(DecisionCache::new()),
990 };
991 assert!(matches!(
992 serve_get(&ctx, "s1", &want("notion")).await,
993 Err(store::SecretError::Unavailable(_))
994 ));
995 }
996
997 const MATERIAL: &str = "987654321";
999
1000 fn seed_numeric_value(dir: &std::path::Path) {
1005 std::fs::create_dir_all(dir).unwrap();
1006 std::fs::write(
1007 act_credentials::backend::file::secrets_path(dir),
1008 format!(
1009 r#"{{"entries":{{"comp":{{"notion":{{"kind":"std:fields","fields":{{"acme:token":{MATERIAL}}},"host_only":{{}},"description":null,"expires_at":null}}}}}}}}"#
1010 ),
1011 )
1012 .unwrap();
1013 }
1014
1015 fn ctx_over(
1016 dir: &std::path::Path,
1017 ceiling: Arc<dyn act_policy::provider::CompiledCeiling>,
1018 ) -> GateContext {
1019 let h = Arc::new(CredentialHost::new(
1020 Arc::new(FileStore::new(dir.to_path_buf())),
1021 "comp".to_string(),
1022 ));
1023 h.note_session_opened("s1");
1024 GateContext {
1025 host: Some(h),
1026 ceiling,
1027 prompter: Arc::new(DenyPrompter),
1028 cache: Arc::new(DecisionCache::new()),
1029 }
1030 }
1031
1032 #[test]
1049 fn an_oauth2_field_encodes_to_the_map_the_sdk_reads() {
1050 use ciborium::Value;
1051
1052 let secret = Secret {
1053 kind: "std:oauth2".into(),
1054 fields: BTreeMap::from([(
1055 "std:token".to_string(),
1056 SecretValue::new(serde_json::json!({
1057 "std:access-token": "at",
1058 "std:expires-at": 1_760_000_000u64,
1059 "std:scopes": ["repo", "read:org"],
1060 })),
1061 )]),
1062 };
1063
1064 let wit = to_wit_secret(secret).expect("an object field is encodable");
1065 let (name, bytes) = &wit.fields[0];
1066 assert_eq!(name, "std:token");
1067
1068 let decoded: Value = ciborium::from_reader(bytes.as_slice()).expect("valid CBOR");
1069 let Value::Map(members) = decoded else {
1070 panic!("ACT-CONSTANTS 8.1: a std:oauth2 value is a CBOR map, got {decoded:?}");
1071 };
1072 let member = |want: &str| {
1073 members
1074 .iter()
1075 .find(|(k, _)| matches!(k, Value::Text(s) if s == want))
1076 .map_or_else(|| panic!("8.3 registers {want}"), |(_, v)| v.clone())
1077 };
1078
1079 assert!(
1080 matches!(member("std:access-token"), Value::Text(s) if s == "at"),
1081 "8.3: std:access-token is CBOR text"
1082 );
1083 assert!(
1084 matches!(member("std:expires-at"), Value::Integer(i) if u64::try_from(i) == Ok(1_760_000_000)),
1085 "8.3: std:expires-at is a CBOR unsigned integer — a float here reads as 'never expires'"
1086 );
1087 let Value::Array(scopes) = member("std:scopes") else {
1088 panic!("8.3: std:scopes is a CBOR array — anything else reads as 'grants nothing'");
1089 };
1090 assert!(
1091 scopes
1092 .iter()
1093 .all(|s| matches!(s, Value::Text(t) if t == "repo" || t == "read:org")),
1094 "8.3: std:scopes members are CBOR text"
1095 );
1096 }
1097
1098 #[tokio::test(flavor = "current_thread")]
1099 async fn a_store_decode_error_does_not_carry_stored_material_to_the_guest() {
1100 let dir = tempfile::tempdir().unwrap();
1108 seed_numeric_value(dir.path());
1109
1110 let ctx = ctx_over(dir.path(), ceiling(true, PolicyMode::Open).await);
1111 let Err(store::SecretError::Unavailable(msg)) =
1112 serve_get(&ctx, "s1", &want("notion")).await
1113 else {
1114 panic!("a store that cannot be decoded must report `unavailable`");
1115 };
1116
1117 assert!(
1118 !msg.contains(MATERIAL),
1119 "stored material reached the guest inside the error: {msg}"
1120 );
1121 assert_eq!(
1122 msg, STORE_UNREADABLE,
1123 "the guest gets a host-authored constant, never the store's own words"
1124 );
1125 }
1126
1127 #[tokio::test(flavor = "current_thread")]
1128 async fn a_listing_over_an_undecodable_store_is_host_authored_too() {
1129 let dir = tempfile::tempdir().unwrap();
1134 std::fs::create_dir_all(dir.path()).unwrap();
1135 std::fs::write(dir.path().join("index.json"), r#"{"version":"one"}"#).unwrap();
1136
1137 let ctx = ctx_over(dir.path(), ceiling(true, PolicyMode::Open).await);
1138 let Err(store::SecretError::Unavailable(msg)) = serve_list(&ctx, Some("s1")).await else {
1139 panic!("an index that cannot be decoded must report `unavailable`");
1140 };
1141 assert_eq!(msg, STORE_UNREADABLE);
1142 }
1143
1144 #[test]
1145 fn a_hit_returns_only_the_revealable_compartment() {
1146 let dir = tempfile::tempdir().unwrap();
1147 let h = host(dir.path());
1148 h.note_session_opened("s1");
1149
1150 let got = h.get_secret("s1", "notion").expect("found");
1151 assert_eq!(got.kind, "std:fields");
1152 let keys: Vec<&String> = got.fields.keys().collect();
1153 assert_eq!(keys, vec!["acme:token"]);
1154 }
1155
1156 #[test]
1157 fn a_miss_is_not_found() {
1158 let dir = tempfile::tempdir().unwrap();
1159 let h = host(dir.path());
1160 h.note_session_opened("s1");
1161 assert!(matches!(
1162 h.get_secret("s1", "absent"),
1163 Err(HostError::NotFound)
1164 ));
1165 }
1166
1167 #[test]
1168 fn a_closed_session_stops_being_served() {
1169 let dir = tempfile::tempdir().unwrap();
1170 let h = host(dir.path());
1171 h.note_session_opened("s1");
1172 h.note_session_closed("s1");
1173 assert!(matches!(
1174 h.get_secret("s1", "notion"),
1175 Err(HostError::InvalidSession)
1176 ));
1177 }
1178
1179 #[test]
1180 fn an_unknown_session_is_rejected() {
1181 let dir = tempfile::tempdir().unwrap();
1182 let h = host(dir.path());
1183 assert!(matches!(
1184 h.get_secret("nope", "notion"),
1185 Err(HostError::InvalidSession)
1186 ));
1187 }
1188
1189 #[test]
1190 fn closing_one_session_does_not_close_another() {
1191 let dir = tempfile::tempdir().unwrap();
1194 let h = host(dir.path());
1195 h.note_session_opened("s1");
1196 h.note_session_opened("s2");
1197 h.note_session_closed("s1");
1198 assert!(h.get_secret("s2", "notion").is_ok());
1199 assert!(matches!(
1200 h.get_secret("s1", "notion"),
1201 Err(HostError::InvalidSession)
1202 ));
1203 }
1204
1205 #[test]
1206 fn a_listing_carries_metadata_and_has_no_field_that_could_hold_a_value() {
1207 let dir = tempfile::tempdir().unwrap();
1208 let h = host(dir.path());
1209 h.note_session_opened("s1");
1210
1211 let listed = h.list_secrets(Some("s1")).expect("listed");
1212 assert_eq!(listed.len(), 1);
1213 assert_eq!(listed[0].key, "notion");
1214 assert_eq!(listed[0].kind, "std:fields");
1215 assert!(!format!("{listed:?}").contains("tok"));
1218 }
1219
1220 #[test]
1221 fn a_listing_outside_any_session_is_allowed() {
1222 let dir = tempfile::tempdir().unwrap();
1225 let h = host(dir.path());
1226 assert_eq!(h.list_secrets(None).expect("listed").len(), 1);
1227 }
1228
1229 #[test]
1230 fn a_listing_under_a_dead_session_is_rejected() {
1231 let dir = tempfile::tempdir().unwrap();
1232 let h = host(dir.path());
1233 assert!(matches!(
1234 h.list_secrets(Some("nope")),
1235 Err(HostError::InvalidSession)
1236 ));
1237 }
1238
1239 #[test]
1240 fn another_components_profile_is_not_visible() {
1241 let dir = tempfile::tempdir().unwrap();
1244 let h = host(dir.path());
1245 let mut fields = BTreeMap::new();
1246 fields.insert("acme:token".to_string(), SecretValue::new("other"));
1247 FileStore::new(dir.path().to_path_buf())
1248 .put(
1249 "someone-else",
1250 "notion",
1251 &SecretRecord {
1252 kind: "std:fields".into(),
1253 fields,
1254 host_only: BTreeMap::new(),
1255 description: None,
1256 expires_at: None,
1257 },
1258 )
1259 .unwrap();
1260
1261 h.note_session_opened("s1");
1262 let got = h.get_secret("s1", "notion").expect("own key still found");
1263 assert_eq!(got.fields["acme:token"].expose_str(), Some("tok"));
1264 assert_eq!(h.list_secrets(Some("s1")).unwrap().len(), 1);
1265 }
1266
1267 #[test]
1268 fn a_hint_cannot_forge_a_second_prompt_line() {
1269 let s = consent_summary(
1272 Some("comp"),
1273 "get",
1274 "notion",
1275 Some("looks fine\nAllow? [y/N] y"),
1276 );
1277 assert!(!s.contains('\n'), "got {s}");
1278 assert!(
1279 s.contains("component says"),
1280 "the guest's words must be attributed, got {s}"
1281 );
1282 }
1283
1284 #[test]
1285 fn a_bidi_override_in_a_hint_is_blanked_not_merely_control_stripped() {
1286 for sneaky in ['\u{202e}', '\u{2066}', '\u{200f}', '\u{2028}'] {
1290 let s = consent_summary(
1291 Some("comp"),
1292 "get",
1293 "notion",
1294 Some(&format!("ok{sneaky}reversed")),
1295 );
1296 assert!(!s.contains(sneaky), "U+{:04X} survived: {s}", sneaky as u32);
1297 }
1298 }
1299
1300 #[test]
1301 fn a_long_hint_is_truncated_rather_than_flooding_the_prompt() {
1302 let s = consent_summary(Some("comp"), "get", "notion", Some(&"a".repeat(500)));
1303 assert!(s.chars().count() < 220, "got {} chars", s.chars().count());
1304 assert!(s.contains('…'));
1305 }
1306
1307 #[test]
1308 fn the_prompt_names_the_component_asking_not_only_the_key() {
1309 let s = consent_summary(
1312 Some("ghcr.io/actpkg/notion@0.1.0"),
1313 "get",
1314 "notion-work",
1315 None,
1316 );
1317 assert!(s.starts_with("ghcr.io/actpkg/notion@0.1.0"), "got {s}");
1318 assert!(s.contains("notion-work"), "got {s}");
1319 }
1320
1321 #[test]
1322 fn no_hint_leaves_the_prompt_host_authored_end_to_end() {
1323 let s = consent_summary(None, "get", "notion", None);
1324 assert_eq!(s, "credential get: notion");
1325 }
1326
1327 #[test]
1328 fn a_megabyte_long_key_is_truncated_rather_than_flooding_the_prompt() {
1329 let huge_key = "x".repeat(1_000_000);
1333 let s = consent_summary(Some("comp"), "get", &huge_key, None);
1334 assert!(
1335 s.chars().count() < 200,
1336 "expected the key to be truncated, got {} chars",
1337 s.chars().count()
1338 );
1339 assert!(s.contains('…'), "got {s}");
1340 assert!(
1341 s.contains("comp requests credential get:"),
1342 "the rest of the line must still render normally, got {s}"
1343 );
1344 }
1345
1346 #[test]
1347 fn a_value_crosses_the_boundary_as_cbor_not_as_a_bare_string() {
1348 let dir = tempfile::tempdir().unwrap();
1351 let h = host(dir.path());
1352 h.note_session_opened("s1");
1353 let wit = to_wit_secret(h.get_secret("s1", "notion").unwrap()).unwrap();
1354
1355 assert_eq!(wit.kind, "std:fields");
1356 assert_eq!(wit.fields.len(), 1);
1357 let (name, bytes) = &wit.fields[0];
1358 assert_eq!(name, "acme:token");
1359 let decoded: String = act_types::cbor::from_cbor(bytes).expect("dCBOR text string");
1360 assert_eq!(decoded, "tok");
1361 }
1362
1363 #[test]
1364 fn an_object_field_crosses_the_boundary_as_a_cbor_map() {
1365 let dir = tempfile::tempdir().unwrap();
1369 let store = FileStore::new(dir.path().to_path_buf());
1370 let mut fields = BTreeMap::new();
1371 fields.insert(
1372 "std:token".to_string(),
1373 SecretValue::new(serde_json::json!({
1374 "std:access-token": "at",
1375 "std:scopes": ["repo"],
1376 })),
1377 );
1378 store
1379 .put(
1380 "comp",
1381 "gh",
1382 &SecretRecord {
1383 kind: "std:oauth2".into(),
1384 fields,
1385 host_only: BTreeMap::new(),
1386 description: None,
1387 expires_at: None,
1388 },
1389 )
1390 .unwrap();
1391 let h = CredentialHost::new(Arc::new(store), "comp".to_string());
1392 h.note_session_opened("s1");
1393
1394 let wit = to_wit_secret(h.get_secret("s1", "gh").unwrap()).unwrap();
1395 assert_eq!(wit.kind, "std:oauth2");
1396 assert_eq!(wit.fields.len(), 1);
1397 let (name, bytes) = &wit.fields[0];
1398 assert_eq!(name, "std:token");
1399
1400 let decoded = act_types::cbor::cbor_to_json(bytes).expect("dCBOR map");
1401 assert!(decoded.is_object(), "expected a CBOR map, got {decoded:?}");
1402 assert_eq!(decoded["std:access-token"], "at");
1403 }
1404
1405 #[test]
1406 fn a_field_that_is_neither_string_nor_object_is_refused_not_encoded() {
1407 let mut fields = BTreeMap::new();
1412 fields.insert("acme:token".to_string(), SecretValue::new(987654321));
1413 let secret = Secret {
1414 kind: "std:string".into(),
1415 fields,
1416 };
1417 let Err(HostError::Unavailable(msg)) = to_wit_secret(secret) else {
1418 panic!("a non-string, non-object field must be refused, not encoded");
1419 };
1420 assert_eq!(msg, STORE_UNREADABLE);
1421 }
1422
1423 #[test]
1424 fn every_host_error_has_a_distinct_wit_variant() {
1425 assert!(matches!(
1428 HostError::NotFound.to_wit(),
1429 store::SecretError::NotFound
1430 ));
1431 assert!(matches!(
1432 HostError::Denied.to_wit(),
1433 store::SecretError::Denied
1434 ));
1435 assert!(matches!(
1436 HostError::InvalidSession.to_wit(),
1437 store::SecretError::InvalidSession
1438 ));
1439 match HostError::Unavailable("disk gone".into()).to_wit() {
1440 store::SecretError::Unavailable(d) => assert_eq!(d, "disk gone"),
1441 other => panic!("got {other:?}"),
1442 }
1443 }
1444
1445 #[test]
1446 fn a_negative_expiry_reads_as_no_expiry_rather_than_a_far_future_date() {
1447 let info = to_wit_info(SecretInfo {
1448 key: "k".into(),
1449 kind: "std:fields".into(),
1450 description: None,
1451 expires_at: Some(-1),
1452 });
1453 assert_eq!(info.expires_at, None);
1454
1455 let ok = to_wit_info(SecretInfo {
1456 key: "k".into(),
1457 kind: "std:fields".into(),
1458 description: Some("note".into()),
1459 expires_at: Some(1_800_000_000),
1460 });
1461 assert_eq!(ok.expires_at, Some(1_800_000_000));
1462 assert_eq!(ok.description.as_deref(), Some("note"));
1463 }
1464}
1465
1466#[cfg(test)]
1474mod refresh_tests {
1475 use super::*;
1476 use act_credentials::backend::file::FileStore;
1477 use act_credentials::record::{SecretRecord, SecretValue};
1478 use std::sync::atomic::{AtomicUsize, Ordering};
1479
1480 const NOW: u64 = 1_700_000_000;
1481
1482 struct Canned {
1484 calls: AtomicUsize,
1485 rotates: bool,
1486 }
1487
1488 #[async_trait::async_trait]
1489 impl CredentialRefresher for Canned {
1490 async fn refresh(&self, req: RefreshRequest<'_>) -> Result<Refreshed, String> {
1491 tokio::task::yield_now().await;
1496 self.calls.fetch_add(1, Ordering::SeqCst);
1497 assert_eq!(req.issuer, "https://as.example.com");
1498 assert_eq!(req.refresh_token, "old-refresh");
1499 Ok(Refreshed {
1500 access_token: "new-access".into(),
1501 expires_at: Some(req.now + 3600),
1502 scopes: vec!["read".into()],
1503 refresh_token: self.rotates.then(|| "new-refresh".to_string()),
1504 })
1505 }
1506 }
1507
1508 struct Refusing;
1509
1510 #[async_trait::async_trait]
1511 impl CredentialRefresher for Refusing {
1512 async fn refresh(&self, _: RefreshRequest<'_>) -> Result<Refreshed, String> {
1513 Err("the authorization server refused".into())
1514 }
1515 }
1516
1517 fn record(expires_at: u64, with_host_only: bool) -> SecretRecord {
1518 let mut fields = std::collections::BTreeMap::new();
1519 fields.insert(
1520 "acme:token".to_string(),
1521 SecretValue::new(serde_json::json!({
1522 "std:access-token": "old-access",
1523 "std:expires-at": expires_at,
1524 "std:scopes": ["read"],
1525 })),
1526 );
1527 fields.insert("acme:tenant".to_string(), SecretValue::new("tenant-42"));
1530 let mut host_only = std::collections::BTreeMap::new();
1531 if with_host_only {
1532 host_only.insert(
1533 issuer_slot("acme:token"),
1534 SecretValue::new("https://as.example.com"),
1535 );
1536 host_only.insert(
1537 refresh_token_slot("acme:token"),
1538 SecretValue::new("old-refresh"),
1539 );
1540 }
1541 SecretRecord {
1542 kind: "std:fields".into(),
1543 fields,
1544 host_only,
1545 description: None,
1546 expires_at: Some(expires_at as i64),
1547 }
1548 }
1549
1550 fn host_with(
1551 dir: &std::path::Path,
1552 rec: SecretRecord,
1553 refresher: Arc<dyn CredentialRefresher>,
1554 ) -> CredentialHost {
1555 let store = FileStore::new(dir.to_path_buf());
1556 store.put("comp", "default", &rec).unwrap();
1557 CredentialHost::new(Arc::new(store), "comp".to_string()).with_refresher(refresher)
1558 }
1559
1560 #[tokio::test]
1561 async fn a_near_expiry_field_is_renewed_and_its_siblings_are_not() {
1562 let dir = tempfile::tempdir().unwrap();
1563 let canned = Arc::new(Canned {
1564 calls: AtomicUsize::new(0),
1565 rotates: true,
1566 });
1567 let host = host_with(dir.path(), record(NOW + 10, true), canned.clone());
1568
1569 host.refresh_if_due("default", NOW).await;
1570
1571 host.note_session_opened("s1");
1572 let served = host.get_secret("s1", "default").unwrap();
1573 let token = served.fields["acme:token"].expose().clone();
1574 assert_eq!(token["std:access-token"], "new-access");
1575 assert_eq!(token["std:expires-at"], serde_json::json!(NOW + 3600));
1576 assert_eq!(
1577 served.fields["acme:tenant"].expose_str(),
1578 Some("tenant-42"),
1579 "a sibling was never in scope"
1580 );
1581 assert_eq!(canned.calls.load(Ordering::SeqCst), 1);
1582 }
1583
1584 #[tokio::test]
1585 async fn a_rotated_refresh_token_replaces_the_stored_one_and_never_leaves() {
1586 let dir = tempfile::tempdir().unwrap();
1587 let host = host_with(
1588 dir.path(),
1589 record(NOW + 10, true),
1590 Arc::new(Canned {
1591 calls: AtomicUsize::new(0),
1592 rotates: true,
1593 }),
1594 );
1595
1596 host.refresh_if_due("default", NOW).await;
1597
1598 let stored = FileStore::new(dir.path().to_path_buf())
1599 .get("comp", "default")
1600 .unwrap()
1601 .unwrap();
1602 assert_eq!(
1603 stored.host_only[&refresh_token_slot("acme:token")].expose_str(),
1604 Some("new-refresh"),
1605 "a rotating server invalidates the old; keeping it kills the next refresh"
1606 );
1607 let projected = serde_json::to_string(
1609 &stored
1610 .project()
1611 .fields
1612 .iter()
1613 .map(|(k, v)| (k.clone(), v.expose().clone()))
1614 .collect::<std::collections::BTreeMap<_, _>>(),
1615 )
1616 .unwrap();
1617 assert!(!projected.contains("new-refresh"), "{projected}");
1618 assert!(!projected.contains("old-refresh"), "{projected}");
1619 }
1620
1621 #[tokio::test]
1622 async fn a_server_that_rotates_nothing_leaves_the_stored_refresh_token() {
1623 let dir = tempfile::tempdir().unwrap();
1624 let host = host_with(
1625 dir.path(),
1626 record(NOW + 10, true),
1627 Arc::new(Canned {
1628 calls: AtomicUsize::new(0),
1629 rotates: false,
1630 }),
1631 );
1632 host.refresh_if_due("default", NOW).await;
1633
1634 let stored = FileStore::new(dir.path().to_path_buf())
1635 .get("comp", "default")
1636 .unwrap()
1637 .unwrap();
1638 assert_eq!(
1639 stored.host_only[&refresh_token_slot("acme:token")].expose_str(),
1640 Some("old-refresh"),
1641 "absent means keep, not clear"
1642 );
1643 }
1644
1645 #[tokio::test]
1646 async fn a_credential_with_life_left_is_not_touched() {
1647 let dir = tempfile::tempdir().unwrap();
1648 let canned = Arc::new(Canned {
1649 calls: AtomicUsize::new(0),
1650 rotates: true,
1651 });
1652 let host = host_with(dir.path(), record(NOW + 86_400, true), canned.clone());
1653
1654 host.refresh_if_due("default", NOW).await;
1655
1656 assert_eq!(
1657 canned.calls.load(Ordering::SeqCst),
1658 0,
1659 "renewing a healthy token spends a rotation for nothing"
1660 );
1661 host.note_session_opened("s1");
1662 let served = host.get_secret("s1", "default").unwrap();
1663 assert_eq!(
1664 served.fields["acme:token"].expose()["std:access-token"],
1665 "old-access"
1666 );
1667 }
1668
1669 #[tokio::test]
1670 async fn a_refusal_leaves_the_credential_and_serves_it() {
1671 let dir = tempfile::tempdir().unwrap();
1674 let host = host_with(dir.path(), record(NOW + 10, true), Arc::new(Refusing));
1675
1676 host.refresh_if_due("default", NOW).await;
1677
1678 host.note_session_opened("s1");
1679 let served = host.get_secret("s1", "default").unwrap();
1680 assert_eq!(
1681 served.fields["acme:token"].expose()["std:access-token"],
1682 "old-access",
1683 "the stored value stands"
1684 );
1685 }
1686
1687 #[tokio::test]
1688 async fn a_credential_with_no_issuer_recorded_is_served_as_it_is() {
1689 let dir = tempfile::tempdir().unwrap();
1692 let canned = Arc::new(Canned {
1693 calls: AtomicUsize::new(0),
1694 rotates: true,
1695 });
1696 let host = host_with(dir.path(), record(NOW + 10, false), canned.clone());
1697
1698 host.refresh_if_due("default", NOW).await;
1699
1700 assert_eq!(canned.calls.load(Ordering::SeqCst), 0);
1701 host.note_session_opened("s1");
1702 assert!(host.get_secret("s1", "default").is_ok());
1703 }
1704
1705 #[tokio::test]
1706 async fn concurrent_calls_renew_once() {
1707 let dir = tempfile::tempdir().unwrap();
1712 let canned = Arc::new(Canned {
1713 calls: AtomicUsize::new(0),
1714 rotates: true,
1715 });
1716 let host = Arc::new(host_with(
1717 dir.path(),
1718 record(NOW + 10, true),
1719 canned.clone(),
1720 ));
1721
1722 let mut tasks = Vec::new();
1723 for _ in 0..6 {
1724 let host = host.clone();
1725 tasks.push(tokio::spawn(async move {
1726 host.refresh_if_due("default", NOW).await;
1727 }));
1728 }
1729 for t in tasks {
1730 t.await.unwrap();
1731 }
1732
1733 assert_eq!(
1734 canned.calls.load(Ordering::SeqCst),
1735 1,
1736 "every task passed the cheap check before the first write landed, so \
1737 it is the re-read after the lock that makes the rest no-ops"
1738 );
1739 }
1740}