1use std::fmt;
50
51use serde::{Deserialize, Serialize};
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum GrantSource {
57 Env,
59 SecretStore,
61}
62
63impl GrantSource {
64 pub fn as_str(self) -> &'static str {
66 match self {
67 GrantSource::Env => "env",
68 GrantSource::SecretStore => "secret_store",
69 }
70 }
71}
72
73#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum GrantSourceSpec {
79 Env { var: String },
81 SecretStore { account: String, key: String },
83}
84
85impl GrantSourceSpec {
86 fn kind(&self) -> GrantSource {
87 match self {
88 GrantSourceSpec::Env { .. } => GrantSource::Env,
89 GrantSourceSpec::SecretStore { .. } => GrantSource::SecretStore,
90 }
91 }
92}
93
94#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
98pub struct GrantSpec {
99 pub name: String,
101 pub source: GrantSourceSpec,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub expose_as_env: Option<String>,
108}
109
110impl GrantSpec {
111 fn resolve(
116 self,
117 env_lookup: &dyn Fn(&str) -> Option<String>,
118 ) -> Result<SessionGrant, GrantError> {
119 let name = self.name.trim();
120 if name.is_empty() {
121 return Err(GrantError::EmptyName);
122 }
123 if let Some(var) = self.expose_as_env.as_deref() {
124 if var.trim().is_empty() {
125 return Err(GrantError::EmptyExposeVar {
126 name: name.to_string(),
127 });
128 }
129 }
130 let source_kind = self.source.kind();
131 let resolved_ref = match self.source {
132 GrantSourceSpec::Env { var } => {
133 let var = var.trim();
134 if var.is_empty() {
135 return Err(GrantError::EmptyEnvVar {
136 name: name.to_string(),
137 });
138 }
139 let value = env_lookup(var).ok_or_else(|| GrantError::MissingEnv {
140 name: name.to_string(),
141 var: var.to_string(),
142 })?;
143 ResolvedRef::EnvSnapshot(value)
144 }
145 GrantSourceSpec::SecretStore { account, key } => {
146 let (account, key) = (account.trim(), key.trim());
147 if account.is_empty() || key.is_empty() {
148 return Err(GrantError::EmptySecretRef {
149 name: name.to_string(),
150 });
151 }
152 ResolvedRef::SecretStore {
153 account: account.to_string(),
154 key: key.to_string(),
155 }
156 }
157 };
158 Ok(SessionGrant {
159 name: name.to_string(),
160 source_kind,
161 expose_as_env: self.expose_as_env.map(|var| var.trim().to_string()),
162 resolved_ref,
163 })
164 }
165}
166
167#[derive(Clone, Debug, PartialEq, Eq)]
171enum ResolvedRef {
172 EnvSnapshot(String),
175 SecretStore { account: String, key: String },
179}
180
181#[derive(Clone, Debug, PartialEq, Eq)]
187pub struct SessionGrant {
188 name: String,
189 source_kind: GrantSource,
190 expose_as_env: Option<String>,
191 resolved_ref: ResolvedRef,
192}
193
194impl SessionGrant {
195 pub fn name(&self) -> &str {
197 &self.name
198 }
199
200 pub fn source_kind(&self) -> GrantSource {
202 self.source_kind
203 }
204
205 pub fn exposed_env_var(&self) -> Option<&str> {
207 self.expose_as_env.as_deref()
208 }
209
210 pub fn receipt(&self) -> GrantReceipt {
212 GrantReceipt {
213 name: self.name.clone(),
214 source_kind: self.source_kind.as_str().to_string(),
215 exposed_as_env: self.expose_as_env.is_some(),
216 }
217 }
218}
219
220#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum SessionProfileKind {
225 #[default]
228 Hermetic,
229 Lane,
231}
232
233impl SessionProfileKind {
234 pub fn as_str(self) -> &'static str {
235 match self {
236 SessionProfileKind::Hermetic => "hermetic",
237 SessionProfileKind::Lane => "lane",
238 }
239 }
240}
241
242#[derive(Clone, Debug, PartialEq, Eq)]
247pub struct SessionProfile {
248 kind: SessionProfileKind,
249 grants: Vec<SessionGrant>,
250}
251
252impl SessionProfile {
253 pub fn launch(
260 kind: SessionProfileKind,
261 specs: Vec<GrantSpec>,
262 env_lookup: &dyn Fn(&str) -> Option<String>,
263 ) -> Result<Self, GrantError> {
264 if matches!(kind, SessionProfileKind::Hermetic) && !specs.is_empty() {
265 return Err(GrantError::HermeticForbidsGrants {
266 attempted: specs.len(),
267 });
268 }
269 let grants = specs
270 .into_iter()
271 .map(|spec| spec.resolve(env_lookup))
272 .collect::<Result<Vec<_>, _>>()?;
273 Ok(SessionProfile { kind, grants })
274 }
275
276 pub fn hermetic() -> Self {
278 SessionProfile {
279 kind: SessionProfileKind::Hermetic,
280 grants: Vec::new(),
281 }
282 }
283
284 pub fn kind(&self) -> SessionProfileKind {
285 self.kind
286 }
287
288 pub fn is_hermetic(&self) -> bool {
289 matches!(self.kind, SessionProfileKind::Hermetic)
290 }
291
292 pub fn grants(&self) -> &[SessionGrant] {
294 &self.grants
295 }
296
297 pub fn receipts(&self) -> Vec<GrantReceipt> {
300 self.grants.iter().map(SessionGrant::receipt).collect()
301 }
302
303 pub fn env_exposure(
320 &self,
321 resolve_secret: &dyn Fn(&str, &str) -> Option<String>,
322 ) -> Result<Vec<(String, String)>, GrantError> {
323 let mut pairs = Vec::new();
324 for grant in &self.grants {
325 let Some(var) = grant.expose_as_env.as_ref() else {
326 continue;
327 };
328 let value = match &grant.resolved_ref {
329 ResolvedRef::EnvSnapshot(value) => value.clone(),
330 ResolvedRef::SecretStore { account, key } => resolve_secret(account, key)
331 .ok_or_else(|| GrantError::MissingSecret {
332 name: grant.name.clone(),
333 })?,
334 };
335 pairs.push((var.clone(), value));
336 }
337 Ok(pairs)
338 }
339}
340
341#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
347pub struct GrantReceipt {
348 pub name: String,
349 pub source_kind: String,
350 pub exposed_as_env: bool,
351}
352
353#[derive(Clone, Debug, PartialEq, Eq)]
356pub enum GrantError {
357 EmptyName,
359 EmptyEnvVar { name: String },
361 EmptySecretRef { name: String },
363 EmptyExposeVar { name: String },
365 MissingEnv { name: String, var: String },
367 HermeticForbidsGrants { attempted: usize },
369 MissingSecret { name: String },
371}
372
373impl fmt::Display for GrantError {
374 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
375 match self {
376 GrantError::EmptyName => write!(f, "grant spec has an empty name"),
377 GrantError::EmptyEnvVar { name } => {
378 write!(f, "grant '{name}' env source names an empty variable")
379 }
380 GrantError::EmptySecretRef { name } => {
381 write!(f, "grant '{name}' secret source names an empty account/key")
382 }
383 GrantError::EmptyExposeVar { name } => {
384 write!(f, "grant '{name}' expose target is an empty variable")
385 }
386 GrantError::MissingEnv { name, var } => write!(
387 f,
388 "grant '{name}' env source variable '{var}' is not set in the launcher environment"
389 ),
390 GrantError::HermeticForbidsGrants { attempted } => write!(
391 f,
392 "hermetic profile forbids grants, but {attempted} were declared"
393 ),
394 GrantError::MissingSecret { name } => {
395 write!(
396 f,
397 "grant '{name}' could not be resolved from the secret store"
398 )
399 }
400 }
401 }
402}
403
404impl std::error::Error for GrantError {}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 fn no_env(_: &str) -> Option<String> {
411 None
412 }
413
414 fn env_from(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
415 move |var: &str| {
416 pairs
417 .iter()
418 .find(|(name, _)| *name == var)
419 .map(|(_, value)| value.to_string())
420 }
421 }
422
423 fn env_grant(name: &str, var: &str, expose: Option<&str>) -> GrantSpec {
424 GrantSpec {
425 name: name.to_string(),
426 source: GrantSourceSpec::Env {
427 var: var.to_string(),
428 },
429 expose_as_env: expose.map(str::to_string),
430 }
431 }
432
433 fn secret_grant(name: &str, account: &str, key: &str, expose: Option<&str>) -> GrantSpec {
434 GrantSpec {
435 name: name.to_string(),
436 source: GrantSourceSpec::SecretStore {
437 account: account.to_string(),
438 key: key.to_string(),
439 },
440 expose_as_env: expose.map(str::to_string),
441 }
442 }
443
444 #[test]
445 fn hermetic_rejects_any_grant_at_launch() {
446 let specs = vec![secret_grant("gh_token", "gh", "token", None)];
447 let err = SessionProfile::launch(SessionProfileKind::Hermetic, specs, &no_env)
448 .expect_err("hermetic must reject grants");
449 assert_eq!(err, GrantError::HermeticForbidsGrants { attempted: 1 });
450
451 let profile =
454 SessionProfile::launch(SessionProfileKind::Hermetic, vec![], &no_env).unwrap();
455 assert!(profile.is_hermetic());
456 assert!(profile.grants().is_empty());
457 assert!(profile.receipts().is_empty());
458 assert!(SessionProfile::hermetic().grants().is_empty());
459 assert_eq!(SessionProfileKind::default(), SessionProfileKind::Hermetic);
460 }
461
462 #[test]
463 fn lane_grant_resolves_once_into_typed_record() {
464 let env = env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]);
465 let specs = vec![
466 env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
467 secret_grant("gh_token", "gh", "token", Some("GH_TOKEN")),
468 ];
469 let profile = SessionProfile::launch(SessionProfileKind::Lane, specs, &env).unwrap();
470
471 let grants = profile.grants();
472 assert_eq!(grants.len(), 2);
473 assert_eq!(grants[0].name(), "fireworks");
475 assert_eq!(grants[0].source_kind(), GrantSource::Env);
476 assert_eq!(grants[0].exposed_env_var(), Some("FIREWORKS_API_KEY"));
477 assert_eq!(grants[1].name(), "gh_token");
478 assert_eq!(grants[1].source_kind(), GrantSource::SecretStore);
479 assert_eq!(grants[1].exposed_env_var(), Some("GH_TOKEN"));
480
481 let resolve_secret = |account: &str, key: &str| -> Option<String> {
484 (account == "gh" && key == "token").then(|| "ghp-secret-token".to_string())
485 };
486 let mut pairs = profile.env_exposure(&resolve_secret).unwrap();
487 pairs.sort();
488 assert_eq!(
489 pairs,
490 vec![
491 (
492 "FIREWORKS_API_KEY".to_string(),
493 "fw-secret-value".to_string()
494 ),
495 ("GH_TOKEN".to_string(), "ghp-secret-token".to_string()),
496 ]
497 );
498 }
499
500 #[test]
501 fn secret_pointer_is_not_resolved_at_launch() {
502 let specs = vec![secret_grant("gh_token", "gh", "token", None)];
506 let profile = SessionProfile::launch(SessionProfileKind::Lane, specs, &no_env).unwrap();
507 let never = |_: &str, _: &str| -> Option<String> {
508 panic!("secret resolver must not run for an unexposed grant")
509 };
510 assert!(profile.env_exposure(&never).unwrap().is_empty());
511 }
512
513 #[test]
514 fn env_grant_snapshots_value_at_launch() {
515 let at_launch = env_from(&[("TOKEN", "live-at-launch")]);
518 let specs = vec![env_grant("t", "TOKEN", Some("TOKEN"))];
519 let profile = SessionProfile::launch(SessionProfileKind::Lane, specs, &at_launch).unwrap();
520
521 let never_secret = |_: &str, _: &str| -> Option<String> { None };
522 let pairs = profile.env_exposure(&never_secret).unwrap();
523 assert_eq!(
524 pairs,
525 vec![("TOKEN".to_string(), "live-at-launch".to_string())]
526 );
527 assert_eq!(
530 profile.env_exposure(&never_secret).unwrap(),
531 vec![("TOKEN".to_string(), "live-at-launch".to_string())]
532 );
533 }
534
535 #[test]
536 fn receipts_record_shape_and_never_the_value() {
537 let env = env_from(&[("FIREWORKS_API_KEY", "fw-secret-value")]);
538 let specs = vec![
539 env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY")),
540 secret_grant("gh_token", "gh", "token", None),
541 ];
542 let profile = SessionProfile::launch(SessionProfileKind::Lane, specs, &env).unwrap();
543
544 let receipts = profile.receipts();
545 assert_eq!(
546 receipts,
547 vec![
548 GrantReceipt {
549 name: "fireworks".to_string(),
550 source_kind: "env".to_string(),
551 exposed_as_env: true,
552 },
553 GrantReceipt {
554 name: "gh_token".to_string(),
555 source_kind: "secret_store".to_string(),
556 exposed_as_env: false,
557 },
558 ]
559 );
560
561 let json = serde_json::to_string(&receipts).unwrap();
565 assert!(
566 !json.contains("fw-secret-value"),
567 "receipt leaked env value"
568 );
569 assert!(!json.contains("gh/token"), "receipt leaked secret pointer");
570 assert!(json.contains("\"source_kind\":\"env\""));
571 assert!(json.contains("\"source_kind\":\"secret_store\""));
572 }
573
574 #[test]
575 fn grant_spec_is_value_free_over_the_wire() {
576 let spec = env_grant("fireworks", "FIREWORKS_API_KEY", Some("FIREWORKS_API_KEY"));
579 let json = serde_json::to_string(&spec).unwrap();
580 let round: GrantSpec = serde_json::from_str(&json).unwrap();
581 assert_eq!(round, spec);
582 assert!(json.contains("\"env\""));
583 assert!(json.contains("FIREWORKS_API_KEY"));
584
585 assert_eq!(
587 serde_json::from_str::<SessionProfileKind>("\"lane\"").unwrap(),
588 SessionProfileKind::Lane
589 );
590 }
591
592 #[test]
593 fn missing_env_source_fails_at_launch() {
594 let specs = vec![env_grant("t", "ABSENT_VAR", None)];
595 let err = SessionProfile::launch(SessionProfileKind::Lane, specs, &no_env)
596 .expect_err("absent env var must fail resolution");
597 assert_eq!(
598 err,
599 GrantError::MissingEnv {
600 name: "t".to_string(),
601 var: "ABSENT_VAR".to_string(),
602 }
603 );
604 }
605
606 #[test]
607 fn resolve_rejects_empty_fields() {
608 let env = env_from(&[("X", "v")]);
609 assert_eq!(
610 SessionProfile::launch(
611 SessionProfileKind::Lane,
612 vec![env_grant("", "X", None)],
613 &env
614 ),
615 Err(GrantError::EmptyName)
616 );
617 assert_eq!(
618 SessionProfile::launch(
619 SessionProfileKind::Lane,
620 vec![env_grant("t", "", None)],
621 &env
622 ),
623 Err(GrantError::EmptyEnvVar {
624 name: "t".to_string()
625 })
626 );
627 assert_eq!(
628 SessionProfile::launch(
629 SessionProfileKind::Lane,
630 vec![secret_grant("t", "acct", "", None)],
631 &env
632 ),
633 Err(GrantError::EmptySecretRef {
634 name: "t".to_string()
635 })
636 );
637 assert_eq!(
638 SessionProfile::launch(
639 SessionProfileKind::Lane,
640 vec![env_grant("t", "X", Some(" "))],
641 &env
642 ),
643 Err(GrantError::EmptyExposeVar {
644 name: "t".to_string()
645 })
646 );
647 }
648}