use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GrantSource {
Env,
SecretStore,
}
impl GrantSource {
pub fn as_str(self) -> &'static str {
match self {
GrantSource::Env => "env",
GrantSource::SecretStore => "secret_store",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GrantSourceSpec {
Env { var: String },
SecretStore { account: String, key: String },
}
impl GrantSourceSpec {
fn kind(&self) -> GrantSource {
match self {
GrantSourceSpec::Env { .. } => GrantSource::Env,
GrantSourceSpec::SecretStore { .. } => GrantSource::SecretStore,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GrantSpec {
pub name: String,
pub source: GrantSourceSpec,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expose_as_env: Option<String>,
}
impl GrantSpec {
fn resolve(
self,
env_lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<SessionGrant, GrantError> {
let name = self.name.trim();
if name.is_empty() {
return Err(GrantError::EmptyName);
}
if let Some(var) = self.expose_as_env.as_deref() {
if var.trim().is_empty() {
return Err(GrantError::EmptyExposeVar {
name: name.to_string(),
});
}
}
let source_kind = self.source.kind();
let resolved_ref = match self.source {
GrantSourceSpec::Env { var } => {
let var = var.trim();
if var.is_empty() {
return Err(GrantError::EmptyEnvVar {
name: name.to_string(),
});
}
let value = env_lookup(var).ok_or_else(|| GrantError::MissingEnv {
name: name.to_string(),
var: var.to_string(),
})?;
ResolvedRef::EnvSnapshot(value)
}
GrantSourceSpec::SecretStore { account, key } => {
let (account, key) = (account.trim(), key.trim());
if account.is_empty() || key.is_empty() {
return Err(GrantError::EmptySecretRef {
name: name.to_string(),
});
}
ResolvedRef::SecretStore {
account: account.to_string(),
key: key.to_string(),
}
}
};
Ok(SessionGrant {
name: name.to_string(),
source_kind,
expose_as_env: self.expose_as_env.map(|var| var.trim().to_string()),
resolved_ref,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum ResolvedRef {
EnvSnapshot(String),
SecretStore { account: String, key: String },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SessionGrant {
name: String,
source_kind: GrantSource,
expose_as_env: Option<String>,
resolved_ref: ResolvedRef,
}
impl SessionGrant {
pub fn name(&self) -> &str {
&self.name
}
pub fn source_kind(&self) -> GrantSource {
self.source_kind
}
pub fn exposed_env_var(&self) -> Option<&str> {
self.expose_as_env.as_deref()
}
pub fn receipt(&self) -> GrantReceipt {
GrantReceipt {
name: self.name.clone(),
source_kind: self.source_kind.as_str().to_string(),
exposed_as_env: self.expose_as_env.is_some(),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionProfileKind {
#[default]
Hermetic,
Lane,
}
impl SessionProfileKind {
pub fn as_str(self) -> &'static str {
match self {
SessionProfileKind::Hermetic => "hermetic",
SessionProfileKind::Lane => "lane",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SessionProfile {
kind: SessionProfileKind,
grants: Vec<SessionGrant>,
}
impl SessionProfile {
pub fn launch(
kind: SessionProfileKind,
specs: Vec<GrantSpec>,
env_lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<Self, GrantError> {
if matches!(kind, SessionProfileKind::Hermetic) && !specs.is_empty() {
return Err(GrantError::HermeticForbidsGrants {
attempted: specs.len(),
});
}
let grants = specs
.into_iter()
.map(|spec| spec.resolve(env_lookup))
.collect::<Result<Vec<_>, _>>()?;
Ok(SessionProfile { kind, grants })
}
pub fn hermetic() -> Self {
SessionProfile {
kind: SessionProfileKind::Hermetic,
grants: Vec::new(),
}
}
pub fn kind(&self) -> SessionProfileKind {
self.kind
}
pub fn is_hermetic(&self) -> bool {
matches!(self.kind, SessionProfileKind::Hermetic)
}
pub fn grants(&self) -> &[SessionGrant] {
&self.grants
}
pub fn receipts(&self) -> Vec<GrantReceipt> {
self.grants.iter().map(SessionGrant::receipt).collect()
}
pub fn env_exposure(
&self,
resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
) -> Result<Vec<(String, String)>, GrantError> {
let mut pairs = Vec::new();
for grant in &self.grants {
let Some(var) = grant.expose_as_env.as_ref() else {
continue;
};
let value = match &grant.resolved_ref {
ResolvedRef::EnvSnapshot(value) => value.clone(),
ResolvedRef::SecretStore { account, key } => resolve_secret(account, key)
.ok_or_else(|| GrantError::MissingSecret {
name: grant.name.clone(),
})?,
};
pairs.push((var.clone(), value));
}
Ok(pairs)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GrantReceipt {
pub name: String,
pub source_kind: String,
pub exposed_as_env: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GrantError {
EmptyName,
EmptyEnvVar { name: String },
EmptySecretRef { name: String },
EmptyExposeVar { name: String },
MissingEnv { name: String, var: String },
HermeticForbidsGrants { attempted: usize },
MissingSecret { name: String },
}
impl fmt::Display for GrantError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GrantError::EmptyName => write!(f, "grant spec has an empty name"),
GrantError::EmptyEnvVar { name } => {
write!(f, "grant '{name}' env source names an empty variable")
}
GrantError::EmptySecretRef { name } => {
write!(f, "grant '{name}' secret source names an empty account/key")
}
GrantError::EmptyExposeVar { name } => {
write!(f, "grant '{name}' expose target is an empty variable")
}
GrantError::MissingEnv { name, var } => write!(
f,
"grant '{name}' env source variable '{var}' is not set in the launcher environment"
),
GrantError::HermeticForbidsGrants { attempted } => write!(
f,
"hermetic profile forbids grants, but {attempted} were declared"
),
GrantError::MissingSecret { name } => {
write!(
f,
"grant '{name}' could not be resolved from the secret store"
)
}
}
}
}
impl std::error::Error for GrantError {}
#[cfg(test)]
mod tests {
use super::*;
fn no_env(_: &str) -> Option<String> {
None
}
fn env_from(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
move |var: &str| {
pairs
.iter()
.find(|(name, _)| *name == var)
.map(|(_, value)| value.to_string())
}
}
fn env_grant(name: &str, var: &str, expose: Option<&str>) -> GrantSpec {
GrantSpec {
name: name.to_string(),
source: GrantSourceSpec::Env {
var: var.to_string(),
},
expose_as_env: expose.map(str::to_string),
}
}
fn secret_grant(name: &str, account: &str, key: &str, expose: Option<&str>) -> GrantSpec {
GrantSpec {
name: name.to_string(),
source: GrantSourceSpec::SecretStore {
account: account.to_string(),
key: key.to_string(),
},
expose_as_env: expose.map(str::to_string),
}
}
#[test]
fn hermetic_rejects_any_grant_at_launch() {
let specs = vec![secret_grant("gh_token", "gh", "token", None)];
let err = SessionProfile::launch(SessionProfileKind::Hermetic, specs, &no_env)
.expect_err("hermetic must reject grants");
assert_eq!(err, GrantError::HermeticForbidsGrants { attempted: 1 });
let profile =
SessionProfile::launch(SessionProfileKind::Hermetic, vec![], &no_env).unwrap();
assert!(profile.is_hermetic());
assert!(profile.grants().is_empty());
assert!(profile.receipts().is_empty());
assert!(SessionProfile::hermetic().grants().is_empty());
assert_eq!(SessionProfileKind::default(), SessionProfileKind::Hermetic);
}
#[test]
fn lane_grant_resolves_once_into_typed_record() {
let env = env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]);
let specs = vec![
env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
secret_grant("gh_token", "gh", "token", Some("GH_TOKEN")),
];
let profile = SessionProfile::launch(SessionProfileKind::Lane, specs, &env).unwrap();
let grants = profile.grants();
assert_eq!(grants.len(), 2);
assert_eq!(grants[0].name(), "fireworks");
assert_eq!(grants[0].source_kind(), GrantSource::Env);
assert_eq!(grants[0].exposed_env_var(), Some("FIREWORKS_API_KEY"));
assert_eq!(grants[1].name(), "gh_token");
assert_eq!(grants[1].source_kind(), GrantSource::SecretStore);
assert_eq!(grants[1].exposed_env_var(), Some("GH_TOKEN"));
let resolve_secret = |account: &str, key: &str| -> Option<String> {
(account == "gh" && key == "token").then(|| "ghp-secret-token".to_string())
};
let mut pairs = profile.env_exposure(&resolve_secret).unwrap();
pairs.sort();
assert_eq!(
pairs,
vec![
(
"FIREWORKS_API_KEY".to_string(),
"fw-secret-value".to_string()
),
("GH_TOKEN".to_string(), "ghp-secret-token".to_string()),
]
);
}
#[test]
fn secret_pointer_is_not_resolved_at_launch() {
let specs = vec![secret_grant("gh_token", "gh", "token", None)];
let profile = SessionProfile::launch(SessionProfileKind::Lane, specs, &no_env).unwrap();
let never = |_: &str, _: &str| -> Option<String> {
panic!("secret resolver must not run for an unexposed grant")
};
assert!(profile.env_exposure(&never).unwrap().is_empty());
}
#[test]
fn env_grant_snapshots_value_at_launch() {
let at_launch = env_from(&[("TOKEN", "live-at-launch")]);
let specs = vec![env_grant("t", "TOKEN", Some("TOKEN"))];
let profile = SessionProfile::launch(SessionProfileKind::Lane, specs, &at_launch).unwrap();
let never_secret = |_: &str, _: &str| -> Option<String> { None };
let pairs = profile.env_exposure(&never_secret).unwrap();
assert_eq!(
pairs,
vec![("TOKEN".to_string(), "live-at-launch".to_string())]
);
assert_eq!(
profile.env_exposure(&never_secret).unwrap(),
vec![("TOKEN".to_string(), "live-at-launch".to_string())]
);
}
#[test]
fn receipts_record_shape_and_never_the_value() {
let env = env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]);
let specs = vec![
env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
secret_grant("gh_token", "gh", "token", None),
];
let profile = SessionProfile::launch(SessionProfileKind::Lane, specs, &env).unwrap();
let receipts = profile.receipts();
assert_eq!(
receipts,
vec![
GrantReceipt {
name: "fireworks".to_string(),
source_kind: "env".to_string(),
exposed_as_env: true,
},
GrantReceipt {
name: "gh_token".to_string(),
source_kind: "secret_store".to_string(),
exposed_as_env: false,
},
]
);
let json = serde_json::to_string(&receipts).unwrap();
assert!(
!json.contains("fw-secret-value"),
"receipt leaked env value"
);
assert!(!json.contains("gh/token"), "receipt leaked secret pointer");
assert!(json.contains("\"source_kind\":\"env\""));
assert!(json.contains("\"source_kind\":\"secret_store\""));
}
#[test]
fn grant_spec_is_value_free_over_the_wire() {
let spec = env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY"));
let json = serde_json::to_string(&spec).unwrap();
let round: GrantSpec = serde_json::from_str(&json).unwrap();
assert_eq!(round, spec);
assert!(json.contains("\"env\""));
assert!(json.contains("FIREWORKS_API_KEY"));
assert_eq!(
serde_json::from_str::<SessionProfileKind>("\"lane\"").unwrap(),
SessionProfileKind::Lane
);
}
#[test]
fn missing_env_source_fails_at_launch() {
let specs = vec![env_grant("t", "ABSENT_VAR", None)];
let err = SessionProfile::launch(SessionProfileKind::Lane, specs, &no_env)
.expect_err("absent env var must fail resolution");
assert_eq!(
err,
GrantError::MissingEnv {
name: "t".to_string(),
var: "ABSENT_VAR".to_string(),
}
);
}
#[test]
fn resolve_rejects_empty_fields() {
let env = env_from(&[("X", "v")]);
assert_eq!(
SessionProfile::launch(
SessionProfileKind::Lane,
vec![env_grant("", "X", None)],
&env
),
Err(GrantError::EmptyName)
);
assert_eq!(
SessionProfile::launch(
SessionProfileKind::Lane,
vec![env_grant("t", "", None)],
&env
),
Err(GrantError::EmptyEnvVar {
name: "t".to_string()
})
);
assert_eq!(
SessionProfile::launch(
SessionProfileKind::Lane,
vec![secret_grant("t", "acct", "", None)],
&env
),
Err(GrantError::EmptySecretRef {
name: "t".to_string()
})
);
assert_eq!(
SessionProfile::launch(
SessionProfileKind::Lane,
vec![env_grant("t", "X", Some(" "))],
&env
),
Err(GrantError::EmptyExposeVar {
name: "t".to_string()
})
);
}
}