use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use act_credentials::backend::BackendChoice;
use act_credentials::record::{Secret, SecretInfo};
use act_credentials::store::CredentialStore;
use act_policy::providers::credentials::CAP_CREDENTIALS;
use wasmtime::component::{HasSelf, Linker};
use super::HostState;
use super::bindings::act::credentials::{store, types};
use crate::consent::{sanitize_hint, truncate_field};
#[derive(Debug, PartialEq, Eq)]
pub enum HostError {
NotFound,
Denied,
InvalidSession,
Unavailable(String),
}
const STORE_UNREADABLE: &str = "the credential store could not be read";
impl HostError {
fn to_wit(&self) -> store::SecretError {
match self {
HostError::NotFound => store::SecretError::NotFound,
HostError::Denied => store::SecretError::Denied,
HostError::InvalidSession => store::SecretError::InvalidSession,
HostError::Unavailable(d) => store::SecretError::Unavailable(d.clone()),
}
}
}
#[async_trait::async_trait]
pub trait CredentialRefresher: Send + Sync {
async fn refresh(&self, req: RefreshRequest<'_>) -> Result<Refreshed, String>;
}
pub struct RefreshRequest<'a> {
pub issuer: &'a str,
pub refresh_token: &'a str,
pub now: u64,
}
pub struct Refreshed {
pub access_token: String,
pub expires_at: Option<u64>,
pub scopes: Vec<String>,
pub refresh_token: Option<String>,
}
fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn due_fields(rec: &act_credentials::record::SecretRecord, now: u64) -> Vec<String> {
rec.fields
.iter()
.filter(|(_, v)| act_credentials::expiry::needs_refresh(v.expose(), now))
.map(|(k, _)| k.clone())
.collect()
}
fn apply_refresh(rec: &mut act_credentials::record::SecretRecord, field: &str, r: &Refreshed) {
let mut value = serde_json::Map::new();
value.insert(
"std:access-token".into(),
serde_json::Value::String(r.access_token.clone()),
);
if let Some(exp) = r.expires_at {
value.insert("std:expires-at".into(), serde_json::Value::from(exp));
}
if !r.scopes.is_empty() {
value.insert(
"std:scopes".into(),
serde_json::Value::from(r.scopes.clone()),
);
}
rec.fields.insert(
field.to_string(),
act_credentials::record::SecretValue::new(serde_json::Value::Object(value)),
);
if let Some(new_refresh) = &r.refresh_token {
rec.host_only.insert(
refresh_token_slot(field),
act_credentials::record::SecretValue::new(new_refresh.clone()),
);
}
rec.expires_at = r.expires_at.map(|e| i64::try_from(e).unwrap_or(i64::MAX));
}
pub fn issuer_slot(field: &str) -> String {
format!("{field}:std:issuer")
}
pub fn refresh_token_slot(field: &str) -> String {
format!("{field}:std:refresh-token")
}
pub struct CredentialHost {
store: Arc<dyn CredentialStore>,
component: String,
live_sessions: Mutex<HashSet<String>>,
refresher: Option<Arc<dyn CredentialRefresher>>,
refresh_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
}
impl CredentialHost {
pub fn new(store: Arc<dyn CredentialStore>, component: String) -> Self {
Self {
store,
component,
live_sessions: Mutex::new(HashSet::new()),
refresher: None,
refresh_locks: Mutex::new(HashMap::new()),
}
}
pub fn with_refresher(mut self, refresher: Arc<dyn CredentialRefresher>) -> Self {
self.refresher = Some(refresher);
self
}
pub fn component(&self) -> &str {
&self.component
}
pub fn note_session_opened(&self, id: &str) {
self.live_sessions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(id.to_string());
}
pub fn note_session_closed(&self, id: &str) {
self.live_sessions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(id);
}
fn live(&self, id: &str) -> bool {
self.live_sessions
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains(id)
}
pub fn get_secret(&self, session: &str, key: &str) -> Result<Secret, HostError> {
if !self.live(session) {
return Err(HostError::InvalidSession);
}
match self.store.get(&self.component, key) {
Ok(Some(rec)) => {
crate::audit::emit_credential_issue(&crate::audit::CredentialIssueRecord {
component_ref: self.component.clone(),
session_id: session.to_string(),
key: key.to_string(),
kind: rec.kind.clone(),
});
Ok(rec.project())
}
Ok(None) => Err(HostError::NotFound),
Err(e) => {
tracing::warn!(error = %e, "credential store read failed");
Err(HostError::Unavailable(STORE_UNREADABLE.into()))
}
}
}
pub async fn refresh_if_due(&self, key: &str, now: u64) {
let Some(refresher) = self.refresher.clone() else {
return;
};
let Ok(Some(rec)) = self.store.get(&self.component, key) else {
return;
};
if due_fields(&rec, now).is_empty() {
return;
}
let lock = self.lock_for(key);
let _held = lock.lock().await;
let Ok(Some(rec)) = self.store.get(&self.component, key) else {
return;
};
for field in due_fields(&rec, now) {
let (Some(issuer), Some(refresh_token)) = (
rec.host_only
.get(&issuer_slot(&field))
.and_then(|v| v.expose_str())
.map(str::to_string),
rec.host_only
.get(&refresh_token_slot(&field))
.and_then(|v| v.expose_str())
.map(str::to_string),
) else {
tracing::debug!(
field = %field,
"credential is near expiry but carries no issuer and refresh token"
);
continue;
};
let refreshed = match refresher
.refresh(RefreshRequest {
issuer: &issuer,
refresh_token: &refresh_token,
now,
})
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!(field = %field, error = %e, "credential refresh failed");
continue;
}
};
let applied = self.store.update(&self.component, key, &mut |rec| {
apply_refresh(rec, &field, &refreshed);
});
match applied {
Ok(_) => tracing::info!(
component = %self.component,
key = %key,
field = %field,
"credential refreshed"
),
Err(e) => tracing::warn!(error = %e, "storing a refreshed credential failed"),
}
}
}
fn lock_for(&self, key: &str) -> Arc<tokio::sync::Mutex<()>> {
self.refresh_locks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.entry(key.to_string())
.or_default()
.clone()
}
pub fn list_secrets(&self, session: Option<&str>) -> Result<Vec<SecretInfo>, HostError> {
if let Some(id) = session
&& !self.live(id)
{
return Err(HostError::InvalidSession);
}
self.store.list(Some(&self.component)).map_err(|e| {
tracing::warn!(error = %e, "credential store list failed");
HostError::Unavailable(STORE_UNREADABLE.into())
})
}
}
pub fn default_store_root() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join("act").join("credentials"))
}
pub fn resolve_backend(explicit: Option<&str>) -> anyhow::Result<Option<BackendChoice>> {
match explicit {
Some(s) => {
let path = s.strip_prefix("file:").ok_or_else(|| {
anyhow::anyhow!("unknown --credentials-backend '{s}'; expected file:<path>")
})?;
anyhow::ensure!(
!path.is_empty(),
"--credentials-backend 'file:' needs a path, e.g. file:/path/to/store"
);
Ok(Some(BackendChoice::File(PathBuf::from(path))))
}
None => Ok(default_store_root().map(BackendChoice::File)),
}
}
pub fn backend_root(choice: &BackendChoice) -> &Path {
match choice {
BackendChoice::File(p) => p,
}
}
impl store::Host for HostState {}
impl store::Host for &mut HostState {}
impl types::Host for &mut HostState {}
pub fn add_to_linker(linker: &mut Linker<HostState>) -> anyhow::Result<()> {
types::add_to_linker::<HostState, HasSelf<HostState>>(linker, |s| s)
.map_err(|e| anyhow::anyhow!("failed to add act:credentials/types to linker: {e}"))?;
store::add_to_linker::<HostState, HasSelf<HostState>>(linker, |s| s)
.map_err(|e| anyhow::anyhow!("failed to add act:credentials/store to linker: {e}"))?;
Ok(())
}
struct GateContext {
host: Option<Arc<CredentialHost>>,
ceiling: Arc<dyn act_policy::provider::CompiledCeiling>,
prompter: Arc<dyn act_policy::consent::ConsentPrompter>,
cache: Arc<act_policy::consent::DecisionCache>,
}
impl GateContext {
fn from(accessor: &wasmtime::component::Accessor<HostState, HasSelf<HostState>>) -> Self {
accessor.with(|mut access| {
let state: &mut HostState = access.get();
Self {
host: state.credentials.clone(),
ceiling: state.credentials_ceiling.clone(),
prompter: state.consent_prompter.clone(),
cache: state.consent_cache.clone(),
}
})
}
async fn allows(&self, key: &str, action: &str, hint: Option<&str>) -> bool {
let component = self.host.as_ref().map(|h| h.component().to_string());
use crate::audit::{CapDecisionRecord, Decision4, emit_cap_decision};
let op = act_policy::provider::ResourceOp {
cap_id: CAP_CREDENTIALS.to_string(),
key: key.to_string(),
action: action.to_string(),
attrs: serde_json::Value::Null,
};
let explained = self.ceiling.classify_explained(&op);
let mode = self.ceiling.effective_mode().to_string();
match explained.decision {
act_policy::Decision::Allow => {
emit_cap_decision(&CapDecisionRecord::statik(
CAP_CREDENTIALS,
key,
action,
Decision4::Allow,
&mode,
explained.rule,
));
true
}
act_policy::Decision::Deny => {
emit_cap_decision(&CapDecisionRecord::statik(
CAP_CREDENTIALS,
key,
action,
Decision4::Deny,
&mode,
explained.rule,
));
false
}
act_policy::Decision::Ask => {
let has_channel = self.prompter.has_channel();
let allowed = self
.cache
.decide_cached(
&*self.prompter,
act_policy::consent::ConsentAsk {
cap_id: CAP_CREDENTIALS.to_string(),
key: key.to_string(),
summary: consent_summary(component.as_deref(), action, key, hint),
},
)
.await;
emit_cap_decision(&CapDecisionRecord::answered(
CAP_CREDENTIALS,
key,
allowed,
has_channel,
));
allowed
}
}
}
}
fn consent_summary(component: Option<&str>, action: &str, key: &str, hint: Option<&str>) -> String {
let key = truncate_field(key);
let base = match component {
Some(c) => format!("{c} requests credential {action}: {key}"),
None => format!("credential {action}: {key}"),
};
match hint.map(sanitize_hint) {
Some(h) if !h.is_empty() => format!("{base} — component says: \"{h}\""),
_ => base,
}
}
fn to_wit_secret(secret: Secret) -> Result<store::Secret, HostError> {
let mut fields = Vec::with_capacity(secret.fields.len());
for (name, value) in secret.fields {
let json = value.expose();
if !(json.is_string() || json.is_object()) {
tracing::warn!(field = %name, "credential field is neither string- nor object-shaped");
return Err(HostError::Unavailable(STORE_UNREADABLE.into()));
}
fields.push((name, act_types::cbor::to_cbor(json)));
}
Ok(store::Secret {
kind: secret.kind,
fields,
})
}
fn to_wit_info(info: SecretInfo) -> store::SecretInfo {
store::SecretInfo {
key: info.key,
kind: info.kind,
description: info.description,
expires_at: info.expires_at.and_then(|e| u64::try_from(e).ok()),
}
}
const NO_STORE: &str = "no credential store is configured for this run";
async fn serve_list(
ctx: &GateContext,
session: Option<&str>,
) -> Result<Vec<store::SecretInfo>, store::SecretError> {
if !ctx.allows("*", "list", None).await {
return Err(HostError::Denied.to_wit());
}
let Some(host) = &ctx.host else {
return Err(store::SecretError::Unavailable(NO_STORE.into()));
};
host.list_secrets(session)
.map(|infos| infos.into_iter().map(to_wit_info).collect())
.map_err(|e| e.to_wit())
}
async fn serve_get(
ctx: &GateContext,
session: &str,
want: &store::SecretRequest,
) -> Result<store::Secret, store::SecretError> {
if !ctx.allows(&want.key, "get", want.hint.as_deref()).await {
return Err(HostError::Denied.to_wit());
}
let Some(host) = &ctx.host else {
return Err(store::SecretError::Unavailable(NO_STORE.into()));
};
host.refresh_if_due(&want.key, now_unix()).await;
let secret = host
.get_secret(session, &want.key)
.map_err(|e| e.to_wit())?;
to_wit_secret(secret).map_err(|e| e.to_wit())
}
impl store::HostWithStore<HostState> for HasSelf<HostState> {
async fn list_secrets(
accessor: &wasmtime::component::Accessor<HostState, Self>,
session: Option<String>,
) -> Result<Vec<store::SecretInfo>, store::SecretError> {
let ctx = GateContext::from(accessor);
serve_list(&ctx, session.as_deref()).await
}
async fn get_secret(
accessor: &wasmtime::component::Accessor<HostState, Self>,
session: String,
want: store::SecretRequest,
) -> Result<store::Secret, store::SecretError> {
let ctx = GateContext::from(accessor);
serve_get(&ctx, &session, &want).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use act_credentials::backend::file::FileStore;
use act_credentials::record::{SecretRecord, SecretValue};
use act_credentials::store::CredentialStore;
use std::collections::BTreeMap;
fn host(dir: &std::path::Path) -> CredentialHost {
let store = FileStore::new(dir.to_path_buf());
let mut fields = BTreeMap::new();
fields.insert("acme:token".to_string(), SecretValue::new("tok"));
let mut host_only = BTreeMap::new();
host_only.insert("std:refresh-token".to_string(), SecretValue::new("rt"));
store
.put(
"comp",
"notion",
&SecretRecord {
kind: "std:fields".into(),
fields,
host_only,
description: None,
expires_at: None,
},
)
.unwrap();
CredentialHost::new(Arc::new(store), "comp".to_string())
}
use act_credentials::store::StoreError;
use act_policy::consent::{ConsentAsk, ConsentPrompter, DecisionCache, DenyPrompter};
use act_policy::grant::{CapabilityGrant, PolicyMode};
use act_policy::provider::CapabilityProvider;
use act_policy::providers::credentials::CredentialsProvider;
use std::sync::atomic::{AtomicUsize, Ordering};
struct CountingStore {
inner: FileStore,
gets: AtomicUsize,
lists: AtomicUsize,
}
impl CredentialStore for CountingStore {
fn get(&self, component: &str, key: &str) -> Result<Option<SecretRecord>, StoreError> {
self.gets.fetch_add(1, Ordering::SeqCst);
self.inner.get(component, key)
}
fn put(&self, component: &str, key: &str, rec: &SecretRecord) -> Result<(), StoreError> {
self.inner.put(component, key, rec)
}
fn erase(&self, component: &str, key: &str) -> Result<(), StoreError> {
self.inner.erase(component, key)
}
fn list(&self, component: Option<&str>) -> Result<Vec<SecretInfo>, StoreError> {
self.lists.fetch_add(1, Ordering::SeqCst);
self.inner.list(component)
}
fn components(&self) -> Result<Vec<String>, StoreError> {
self.inner.components()
}
fn update(
&self,
component: &str,
key: &str,
mutate: &mut dyn FnMut(&mut act_credentials::record::SecretRecord),
) -> Result<Option<act_credentials::record::SecretRecord>, StoreError> {
self.inner.update(component, key, mutate)
}
}
struct AllowPrompter(AtomicUsize);
#[async_trait::async_trait]
impl ConsentPrompter for AllowPrompter {
async fn decide(&self, _ask: &ConsentAsk) -> bool {
self.0.fetch_add(1, Ordering::SeqCst);
true
}
}
async fn ceiling(
declared: bool,
mode: PolicyMode,
) -> Arc<dyn act_policy::provider::CompiledCeiling> {
let declared: Option<Vec<serde_json::Value>> =
if declared { Some(Vec::new()) } else { None };
Arc::from(
CredentialsProvider
.resolve(
CAP_CREDENTIALS,
declared.as_deref(),
&CapabilityGrant {
mode,
allow: vec![],
deny: vec![],
},
)
.await
.expect("resolve"),
)
}
fn gate_ctx(
dir: &std::path::Path,
ceiling: Arc<dyn act_policy::provider::CompiledCeiling>,
prompter: Arc<dyn ConsentPrompter>,
) -> (GateContext, Arc<CountingStore>) {
let seeded = host(dir); drop(seeded);
let store = Arc::new(CountingStore {
inner: FileStore::new(dir.to_path_buf()),
gets: AtomicUsize::new(0),
lists: AtomicUsize::new(0),
});
let h = Arc::new(CredentialHost::new(store.clone(), "comp".to_string()));
h.note_session_opened("s1");
(
GateContext {
host: Some(h),
ceiling,
prompter,
cache: Arc::new(DecisionCache::new()),
},
store,
)
}
fn want(key: &str) -> store::SecretRequest {
store::SecretRequest {
key: key.to_string(),
kind: None,
resource: None,
scopes: vec![],
hint: None,
}
}
#[tokio::test(flavor = "current_thread")]
async fn an_undeclared_class_is_refused_no_matter_what_was_granted() {
for mode in [PolicyMode::Open, PolicyMode::Allowlist, PolicyMode::Ask] {
let c = ceiling(false, mode).await;
let dir = tempfile::tempdir().unwrap();
let (ctx, store) = gate_ctx(dir.path(), c, Arc::new(DenyPrompter));
assert!(!ctx.allows("notion", "get", None).await, "mode {mode:?}");
assert!(
matches!(
serve_get(&ctx, "s1", &want("notion")).await,
Err(store::SecretError::Denied)
),
"mode {mode:?}"
);
assert_eq!(
store.gets.load(Ordering::SeqCst),
0,
"a refusal must not reach the store — otherwise `denied` timing \
leaks whether the key exists (design §3.4), mode {mode:?}"
);
}
}
#[tokio::test(flavor = "current_thread")]
async fn a_denied_grant_is_refused_even_though_the_class_was_declared() {
let dir = tempfile::tempdir().unwrap();
let (ctx, store) = gate_ctx(
dir.path(),
ceiling(true, PolicyMode::Deny).await,
Arc::new(DenyPrompter),
);
assert!(!ctx.allows("notion", "get", None).await);
assert!(matches!(
serve_get(&ctx, "s1", &want("notion")).await,
Err(store::SecretError::Denied)
));
assert_eq!(store.gets.load(Ordering::SeqCst), 0);
}
#[tokio::test(flavor = "current_thread")]
async fn ask_with_no_prompt_channel_degrades_to_deny() {
let dir = tempfile::tempdir().unwrap();
let (ctx, store) = gate_ctx(
dir.path(),
ceiling(true, PolicyMode::Ask).await,
Arc::new(DenyPrompter),
);
assert!(!ctx.allows("notion", "get", None).await);
assert!(matches!(
serve_get(&ctx, "s1", &want("notion")).await,
Err(store::SecretError::Denied)
));
assert_eq!(store.gets.load(Ordering::SeqCst), 0);
}
#[tokio::test(flavor = "current_thread")]
async fn an_approved_ask_serves_the_credential_and_is_not_asked_twice() {
let dir = tempfile::tempdir().unwrap();
let prompter = Arc::new(AllowPrompter(AtomicUsize::new(0)));
let (ctx, store) = gate_ctx(
dir.path(),
ceiling(true, PolicyMode::Ask).await,
prompter.clone(),
);
let got = serve_get(&ctx, "s1", &want("notion"))
.await
.expect("served");
assert_eq!(got.kind, "std:fields");
assert_eq!(store.gets.load(Ordering::SeqCst), 1);
assert!(serve_get(&ctx, "s1", &want("notion")).await.is_ok());
assert_eq!(
prompter.0.load(Ordering::SeqCst),
1,
"one prompt per (class, key) per run"
);
}
#[tokio::test(flavor = "current_thread")]
async fn an_open_grant_on_a_declared_class_needs_no_prompt_at_all() {
let dir = tempfile::tempdir().unwrap();
let prompter = Arc::new(AllowPrompter(AtomicUsize::new(0)));
let (ctx, _store) = gate_ctx(
dir.path(),
ceiling(true, PolicyMode::Open).await,
prompter.clone(),
);
assert!(serve_get(&ctx, "s1", &want("notion")).await.is_ok());
assert_eq!(prompter.0.load(Ordering::SeqCst), 0, "static allow");
}
#[tokio::test(flavor = "current_thread")]
async fn a_listing_is_gated_too_and_a_refusal_never_reaches_the_index() {
let dir = tempfile::tempdir().unwrap();
let (ctx, store) = gate_ctx(
dir.path(),
ceiling(false, PolicyMode::Open).await,
Arc::new(DenyPrompter),
);
assert!(matches!(
serve_list(&ctx, Some("s1")).await,
Err(store::SecretError::Denied)
));
assert_eq!(store.lists.load(Ordering::SeqCst), 0);
}
#[tokio::test(flavor = "current_thread")]
async fn a_run_with_no_store_reports_unavailable_rather_than_denied() {
let ctx = GateContext {
host: None,
ceiling: ceiling(true, PolicyMode::Open).await,
prompter: Arc::new(DenyPrompter),
cache: Arc::new(DecisionCache::new()),
};
assert!(matches!(
serve_get(&ctx, "s1", &want("notion")).await,
Err(store::SecretError::Unavailable(_))
));
}
const MATERIAL: &str = "987654321";
fn seed_numeric_value(dir: &std::path::Path) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(
act_credentials::backend::file::secrets_path(dir),
format!(
r#"{{"entries":{{"comp":{{"notion":{{"kind":"std:fields","fields":{{"acme:token":{MATERIAL}}},"host_only":{{}},"description":null,"expires_at":null}}}}}}}}"#
),
)
.unwrap();
}
fn ctx_over(
dir: &std::path::Path,
ceiling: Arc<dyn act_policy::provider::CompiledCeiling>,
) -> GateContext {
let h = Arc::new(CredentialHost::new(
Arc::new(FileStore::new(dir.to_path_buf())),
"comp".to_string(),
));
h.note_session_opened("s1");
GateContext {
host: Some(h),
ceiling,
prompter: Arc::new(DenyPrompter),
cache: Arc::new(DecisionCache::new()),
}
}
#[test]
fn an_oauth2_field_encodes_to_the_map_the_sdk_reads() {
use ciborium::Value;
let secret = Secret {
kind: "std:oauth2".into(),
fields: BTreeMap::from([(
"std:token".to_string(),
SecretValue::new(serde_json::json!({
"std:access-token": "at",
"std:expires-at": 1_760_000_000u64,
"std:scopes": ["repo", "read:org"],
})),
)]),
};
let wit = to_wit_secret(secret).expect("an object field is encodable");
let (name, bytes) = &wit.fields[0];
assert_eq!(name, "std:token");
let decoded: Value = ciborium::from_reader(bytes.as_slice()).expect("valid CBOR");
let Value::Map(members) = decoded else {
panic!("ACT-CONSTANTS 8.1: a std:oauth2 value is a CBOR map, got {decoded:?}");
};
let member = |want: &str| {
members
.iter()
.find(|(k, _)| matches!(k, Value::Text(s) if s == want))
.map_or_else(|| panic!("8.3 registers {want}"), |(_, v)| v.clone())
};
assert!(
matches!(member("std:access-token"), Value::Text(s) if s == "at"),
"8.3: std:access-token is CBOR text"
);
assert!(
matches!(member("std:expires-at"), Value::Integer(i) if u64::try_from(i) == Ok(1_760_000_000)),
"8.3: std:expires-at is a CBOR unsigned integer — a float here reads as 'never expires'"
);
let Value::Array(scopes) = member("std:scopes") else {
panic!("8.3: std:scopes is a CBOR array — anything else reads as 'grants nothing'");
};
assert!(
scopes
.iter()
.all(|s| matches!(s, Value::Text(t) if t == "repo" || t == "read:org")),
"8.3: std:scopes members are CBOR text"
);
}
#[tokio::test(flavor = "current_thread")]
async fn a_store_decode_error_does_not_carry_stored_material_to_the_guest() {
let dir = tempfile::tempdir().unwrap();
seed_numeric_value(dir.path());
let ctx = ctx_over(dir.path(), ceiling(true, PolicyMode::Open).await);
let Err(store::SecretError::Unavailable(msg)) =
serve_get(&ctx, "s1", &want("notion")).await
else {
panic!("a store that cannot be decoded must report `unavailable`");
};
assert!(
!msg.contains(MATERIAL),
"stored material reached the guest inside the error: {msg}"
);
assert_eq!(
msg, STORE_UNREADABLE,
"the guest gets a host-authored constant, never the store's own words"
);
}
#[tokio::test(flavor = "current_thread")]
async fn a_listing_over_an_undecodable_store_is_host_authored_too() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path()).unwrap();
std::fs::write(dir.path().join("index.json"), r#"{"version":"one"}"#).unwrap();
let ctx = ctx_over(dir.path(), ceiling(true, PolicyMode::Open).await);
let Err(store::SecretError::Unavailable(msg)) = serve_list(&ctx, Some("s1")).await else {
panic!("an index that cannot be decoded must report `unavailable`");
};
assert_eq!(msg, STORE_UNREADABLE);
}
#[test]
fn a_hit_returns_only_the_revealable_compartment() {
let dir = tempfile::tempdir().unwrap();
let h = host(dir.path());
h.note_session_opened("s1");
let got = h.get_secret("s1", "notion").expect("found");
assert_eq!(got.kind, "std:fields");
let keys: Vec<&String> = got.fields.keys().collect();
assert_eq!(keys, vec!["acme:token"]);
}
#[test]
fn a_miss_is_not_found() {
let dir = tempfile::tempdir().unwrap();
let h = host(dir.path());
h.note_session_opened("s1");
assert!(matches!(
h.get_secret("s1", "absent"),
Err(HostError::NotFound)
));
}
#[test]
fn a_closed_session_stops_being_served() {
let dir = tempfile::tempdir().unwrap();
let h = host(dir.path());
h.note_session_opened("s1");
h.note_session_closed("s1");
assert!(matches!(
h.get_secret("s1", "notion"),
Err(HostError::InvalidSession)
));
}
#[test]
fn an_unknown_session_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let h = host(dir.path());
assert!(matches!(
h.get_secret("nope", "notion"),
Err(HostError::InvalidSession)
));
}
#[test]
fn closing_one_session_does_not_close_another() {
let dir = tempfile::tempdir().unwrap();
let h = host(dir.path());
h.note_session_opened("s1");
h.note_session_opened("s2");
h.note_session_closed("s1");
assert!(h.get_secret("s2", "notion").is_ok());
assert!(matches!(
h.get_secret("s1", "notion"),
Err(HostError::InvalidSession)
));
}
#[test]
fn a_listing_carries_metadata_and_has_no_field_that_could_hold_a_value() {
let dir = tempfile::tempdir().unwrap();
let h = host(dir.path());
h.note_session_opened("s1");
let listed = h.list_secrets(Some("s1")).expect("listed");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].key, "notion");
assert_eq!(listed[0].kind, "std:fields");
assert!(!format!("{listed:?}").contains("tok"));
}
#[test]
fn a_listing_outside_any_session_is_allowed() {
let dir = tempfile::tempdir().unwrap();
let h = host(dir.path());
assert_eq!(h.list_secrets(None).expect("listed").len(), 1);
}
#[test]
fn a_listing_under_a_dead_session_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let h = host(dir.path());
assert!(matches!(
h.list_secrets(Some("nope")),
Err(HostError::InvalidSession)
));
}
#[test]
fn another_components_profile_is_not_visible() {
let dir = tempfile::tempdir().unwrap();
let h = host(dir.path());
let mut fields = BTreeMap::new();
fields.insert("acme:token".to_string(), SecretValue::new("other"));
FileStore::new(dir.path().to_path_buf())
.put(
"someone-else",
"notion",
&SecretRecord {
kind: "std:fields".into(),
fields,
host_only: BTreeMap::new(),
description: None,
expires_at: None,
},
)
.unwrap();
h.note_session_opened("s1");
let got = h.get_secret("s1", "notion").expect("own key still found");
assert_eq!(got.fields["acme:token"].expose_str(), Some("tok"));
assert_eq!(h.list_secrets(Some("s1")).unwrap().len(), 1);
}
#[test]
fn a_hint_cannot_forge_a_second_prompt_line() {
let s = consent_summary(
Some("comp"),
"get",
"notion",
Some("looks fine\nAllow? [y/N] y"),
);
assert!(!s.contains('\n'), "got {s}");
assert!(
s.contains("component says"),
"the guest's words must be attributed, got {s}"
);
}
#[test]
fn a_bidi_override_in_a_hint_is_blanked_not_merely_control_stripped() {
for sneaky in ['\u{202e}', '\u{2066}', '\u{200f}', '\u{2028}'] {
let s = consent_summary(
Some("comp"),
"get",
"notion",
Some(&format!("ok{sneaky}reversed")),
);
assert!(!s.contains(sneaky), "U+{:04X} survived: {s}", sneaky as u32);
}
}
#[test]
fn a_long_hint_is_truncated_rather_than_flooding_the_prompt() {
let s = consent_summary(Some("comp"), "get", "notion", Some(&"a".repeat(500)));
assert!(s.chars().count() < 220, "got {} chars", s.chars().count());
assert!(s.contains('…'));
}
#[test]
fn the_prompt_names_the_component_asking_not_only_the_key() {
let s = consent_summary(
Some("ghcr.io/actpkg/notion@0.1.0"),
"get",
"notion-work",
None,
);
assert!(s.starts_with("ghcr.io/actpkg/notion@0.1.0"), "got {s}");
assert!(s.contains("notion-work"), "got {s}");
}
#[test]
fn no_hint_leaves_the_prompt_host_authored_end_to_end() {
let s = consent_summary(None, "get", "notion", None);
assert_eq!(s, "credential get: notion");
}
#[test]
fn a_megabyte_long_key_is_truncated_rather_than_flooding_the_prompt() {
let huge_key = "x".repeat(1_000_000);
let s = consent_summary(Some("comp"), "get", &huge_key, None);
assert!(
s.chars().count() < 200,
"expected the key to be truncated, got {} chars",
s.chars().count()
);
assert!(s.contains('…'), "got {s}");
assert!(
s.contains("comp requests credential get:"),
"the rest of the line must still render normally, got {s}"
);
}
#[test]
fn a_value_crosses_the_boundary_as_cbor_not_as_a_bare_string() {
let dir = tempfile::tempdir().unwrap();
let h = host(dir.path());
h.note_session_opened("s1");
let wit = to_wit_secret(h.get_secret("s1", "notion").unwrap()).unwrap();
assert_eq!(wit.kind, "std:fields");
assert_eq!(wit.fields.len(), 1);
let (name, bytes) = &wit.fields[0];
assert_eq!(name, "acme:token");
let decoded: String = act_types::cbor::from_cbor(bytes).expect("dCBOR text string");
assert_eq!(decoded, "tok");
}
#[test]
fn an_object_field_crosses_the_boundary_as_a_cbor_map() {
let dir = tempfile::tempdir().unwrap();
let store = FileStore::new(dir.path().to_path_buf());
let mut fields = BTreeMap::new();
fields.insert(
"std:token".to_string(),
SecretValue::new(serde_json::json!({
"std:access-token": "at",
"std:scopes": ["repo"],
})),
);
store
.put(
"comp",
"gh",
&SecretRecord {
kind: "std:oauth2".into(),
fields,
host_only: BTreeMap::new(),
description: None,
expires_at: None,
},
)
.unwrap();
let h = CredentialHost::new(Arc::new(store), "comp".to_string());
h.note_session_opened("s1");
let wit = to_wit_secret(h.get_secret("s1", "gh").unwrap()).unwrap();
assert_eq!(wit.kind, "std:oauth2");
assert_eq!(wit.fields.len(), 1);
let (name, bytes) = &wit.fields[0];
assert_eq!(name, "std:token");
let decoded = act_types::cbor::cbor_to_json(bytes).expect("dCBOR map");
assert!(decoded.is_object(), "expected a CBOR map, got {decoded:?}");
assert_eq!(decoded["std:access-token"], "at");
}
#[test]
fn a_field_that_is_neither_string_nor_object_is_refused_not_encoded() {
let mut fields = BTreeMap::new();
fields.insert("acme:token".to_string(), SecretValue::new(987654321));
let secret = Secret {
kind: "std:string".into(),
fields,
};
let Err(HostError::Unavailable(msg)) = to_wit_secret(secret) else {
panic!("a non-string, non-object field must be refused, not encoded");
};
assert_eq!(msg, STORE_UNREADABLE);
}
#[test]
fn every_host_error_has_a_distinct_wit_variant() {
assert!(matches!(
HostError::NotFound.to_wit(),
store::SecretError::NotFound
));
assert!(matches!(
HostError::Denied.to_wit(),
store::SecretError::Denied
));
assert!(matches!(
HostError::InvalidSession.to_wit(),
store::SecretError::InvalidSession
));
match HostError::Unavailable("disk gone".into()).to_wit() {
store::SecretError::Unavailable(d) => assert_eq!(d, "disk gone"),
other => panic!("got {other:?}"),
}
}
#[test]
fn a_negative_expiry_reads_as_no_expiry_rather_than_a_far_future_date() {
let info = to_wit_info(SecretInfo {
key: "k".into(),
kind: "std:fields".into(),
description: None,
expires_at: Some(-1),
});
assert_eq!(info.expires_at, None);
let ok = to_wit_info(SecretInfo {
key: "k".into(),
kind: "std:fields".into(),
description: Some("note".into()),
expires_at: Some(1_800_000_000),
});
assert_eq!(ok.expires_at, Some(1_800_000_000));
assert_eq!(ok.description.as_deref(), Some("note"));
}
}
#[cfg(test)]
mod refresh_tests {
use super::*;
use act_credentials::backend::file::FileStore;
use act_credentials::record::{SecretRecord, SecretValue};
use std::sync::atomic::{AtomicUsize, Ordering};
const NOW: u64 = 1_700_000_000;
struct Canned {
calls: AtomicUsize,
rotates: bool,
}
#[async_trait::async_trait]
impl CredentialRefresher for Canned {
async fn refresh(&self, req: RefreshRequest<'_>) -> Result<Refreshed, String> {
tokio::task::yield_now().await;
self.calls.fetch_add(1, Ordering::SeqCst);
assert_eq!(req.issuer, "https://as.example.com");
assert_eq!(req.refresh_token, "old-refresh");
Ok(Refreshed {
access_token: "new-access".into(),
expires_at: Some(req.now + 3600),
scopes: vec!["read".into()],
refresh_token: self.rotates.then(|| "new-refresh".to_string()),
})
}
}
struct Refusing;
#[async_trait::async_trait]
impl CredentialRefresher for Refusing {
async fn refresh(&self, _: RefreshRequest<'_>) -> Result<Refreshed, String> {
Err("the authorization server refused".into())
}
}
fn record(expires_at: u64, with_host_only: bool) -> SecretRecord {
let mut fields = std::collections::BTreeMap::new();
fields.insert(
"acme:token".to_string(),
SecretValue::new(serde_json::json!({
"std:access-token": "old-access",
"std:expires-at": expires_at,
"std:scopes": ["read"],
})),
);
fields.insert("acme:tenant".to_string(), SecretValue::new("tenant-42"));
let mut host_only = std::collections::BTreeMap::new();
if with_host_only {
host_only.insert(
issuer_slot("acme:token"),
SecretValue::new("https://as.example.com"),
);
host_only.insert(
refresh_token_slot("acme:token"),
SecretValue::new("old-refresh"),
);
}
SecretRecord {
kind: "std:fields".into(),
fields,
host_only,
description: None,
expires_at: Some(expires_at as i64),
}
}
fn host_with(
dir: &std::path::Path,
rec: SecretRecord,
refresher: Arc<dyn CredentialRefresher>,
) -> CredentialHost {
let store = FileStore::new(dir.to_path_buf());
store.put("comp", "default", &rec).unwrap();
CredentialHost::new(Arc::new(store), "comp".to_string()).with_refresher(refresher)
}
#[tokio::test]
async fn a_near_expiry_field_is_renewed_and_its_siblings_are_not() {
let dir = tempfile::tempdir().unwrap();
let canned = Arc::new(Canned {
calls: AtomicUsize::new(0),
rotates: true,
});
let host = host_with(dir.path(), record(NOW + 10, true), canned.clone());
host.refresh_if_due("default", NOW).await;
host.note_session_opened("s1");
let served = host.get_secret("s1", "default").unwrap();
let token = served.fields["acme:token"].expose().clone();
assert_eq!(token["std:access-token"], "new-access");
assert_eq!(token["std:expires-at"], serde_json::json!(NOW + 3600));
assert_eq!(
served.fields["acme:tenant"].expose_str(),
Some("tenant-42"),
"a sibling was never in scope"
);
assert_eq!(canned.calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn a_rotated_refresh_token_replaces_the_stored_one_and_never_leaves() {
let dir = tempfile::tempdir().unwrap();
let host = host_with(
dir.path(),
record(NOW + 10, true),
Arc::new(Canned {
calls: AtomicUsize::new(0),
rotates: true,
}),
);
host.refresh_if_due("default", NOW).await;
let stored = FileStore::new(dir.path().to_path_buf())
.get("comp", "default")
.unwrap()
.unwrap();
assert_eq!(
stored.host_only[&refresh_token_slot("acme:token")].expose_str(),
Some("new-refresh"),
"a rotating server invalidates the old; keeping it kills the next refresh"
);
let projected = serde_json::to_string(
&stored
.project()
.fields
.iter()
.map(|(k, v)| (k.clone(), v.expose().clone()))
.collect::<std::collections::BTreeMap<_, _>>(),
)
.unwrap();
assert!(!projected.contains("new-refresh"), "{projected}");
assert!(!projected.contains("old-refresh"), "{projected}");
}
#[tokio::test]
async fn a_server_that_rotates_nothing_leaves_the_stored_refresh_token() {
let dir = tempfile::tempdir().unwrap();
let host = host_with(
dir.path(),
record(NOW + 10, true),
Arc::new(Canned {
calls: AtomicUsize::new(0),
rotates: false,
}),
);
host.refresh_if_due("default", NOW).await;
let stored = FileStore::new(dir.path().to_path_buf())
.get("comp", "default")
.unwrap()
.unwrap();
assert_eq!(
stored.host_only[&refresh_token_slot("acme:token")].expose_str(),
Some("old-refresh"),
"absent means keep, not clear"
);
}
#[tokio::test]
async fn a_credential_with_life_left_is_not_touched() {
let dir = tempfile::tempdir().unwrap();
let canned = Arc::new(Canned {
calls: AtomicUsize::new(0),
rotates: true,
});
let host = host_with(dir.path(), record(NOW + 86_400, true), canned.clone());
host.refresh_if_due("default", NOW).await;
assert_eq!(
canned.calls.load(Ordering::SeqCst),
0,
"renewing a healthy token spends a rotation for nothing"
);
host.note_session_opened("s1");
let served = host.get_secret("s1", "default").unwrap();
assert_eq!(
served.fields["acme:token"].expose()["std:access-token"],
"old-access"
);
}
#[tokio::test]
async fn a_refusal_leaves_the_credential_and_serves_it() {
let dir = tempfile::tempdir().unwrap();
let host = host_with(dir.path(), record(NOW + 10, true), Arc::new(Refusing));
host.refresh_if_due("default", NOW).await;
host.note_session_opened("s1");
let served = host.get_secret("s1", "default").unwrap();
assert_eq!(
served.fields["acme:token"].expose()["std:access-token"],
"old-access",
"the stored value stands"
);
}
#[tokio::test]
async fn a_credential_with_no_issuer_recorded_is_served_as_it_is() {
let dir = tempfile::tempdir().unwrap();
let canned = Arc::new(Canned {
calls: AtomicUsize::new(0),
rotates: true,
});
let host = host_with(dir.path(), record(NOW + 10, false), canned.clone());
host.refresh_if_due("default", NOW).await;
assert_eq!(canned.calls.load(Ordering::SeqCst), 0);
host.note_session_opened("s1");
assert!(host.get_secret("s1", "default").is_ok());
}
#[tokio::test]
async fn concurrent_calls_renew_once() {
let dir = tempfile::tempdir().unwrap();
let canned = Arc::new(Canned {
calls: AtomicUsize::new(0),
rotates: true,
});
let host = Arc::new(host_with(
dir.path(),
record(NOW + 10, true),
canned.clone(),
));
let mut tasks = Vec::new();
for _ in 0..6 {
let host = host.clone();
tasks.push(tokio::spawn(async move {
host.refresh_if_due("default", NOW).await;
}));
}
for t in tasks {
t.await.unwrap();
}
assert_eq!(
canned.calls.load(Ordering::SeqCst),
1,
"every task passed the cheap check before the first write landed, so \
it is the re-read after the lock that makes the rest no-ops"
);
}
}