use std::collections::BTreeMap;
use crate::broker::{Broker, CallerGrant, CredentialSource, EgressGrants};
use crate::egress::EgressPolicy;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EgressSpec {
pub credentials: Option<String>,
pub allow_hosts: Option<String>,
pub tool_egress: Option<String>,
pub credential_ttl_secs: Option<u64>,
pub resolver_env: Option<String>,
}
pub const ENV_CREDENTIAL: &str = "AREEV_RUN_CREDENTIAL";
pub const ENV_ALLOW_HOST: &str = "AREEV_RUN_ALLOW_HOST";
pub const ENV_TOOL_EGRESS: &str = "AREEV_RUN_TOOL_EGRESS";
pub const ENV_CREDENTIAL_TTL: &str = "AREEV_RUN_CREDENTIAL_TTL";
pub const ENV_RESOLVER_ENV: &str = "AREEV_RUN_RESOLVER_ENV";
fn env_nonempty(var: &str) -> Option<String> {
std::env::var(var).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
}
impl EgressSpec {
pub fn configures_broker(&self) -> bool {
self.credentials.is_some() || self.allow_hosts.is_some() || self.tool_egress.is_some()
}
pub fn from_env() -> Result<Self, String> {
Self::default().with_env_fallback()
}
pub fn with_env_fallback(mut self) -> Result<Self, String> {
self.credentials = self.credentials.or_else(|| env_nonempty(ENV_CREDENTIAL));
self.allow_hosts = self.allow_hosts.or_else(|| env_nonempty(ENV_ALLOW_HOST));
self.tool_egress = self.tool_egress.or_else(|| env_nonempty(ENV_TOOL_EGRESS));
self.resolver_env = self.resolver_env.or_else(|| env_nonempty(ENV_RESOLVER_ENV));
if self.credential_ttl_secs.is_none() {
if let Some(v) = env_nonempty(ENV_CREDENTIAL_TTL) {
self.credential_ttl_secs = Some(v.parse::<u64>().map_err(|_| {
format!("${ENV_CREDENTIAL_TTL}: expected whole seconds, got {v:?}")
})?);
}
}
Ok(self)
}
fn resolver_env_list(&self) -> Vec<String> {
self.resolver_env
.iter()
.flat_map(|v| v.split(','))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
pub fn credential_sources(
&self,
) -> Result<Vec<(String, CredentialSource, Option<String>)>, String> {
let ttl_secs = self.credential_ttl_secs;
let resolver_env = self.resolver_env_list();
let mut out = Vec::new();
for pair in self.credentials.iter().flat_map(|v| v.split(',')) {
let pair = pair.trim();
if pair.is_empty() {
continue;
}
let (lhs, spec) = pair.split_once('=').ok_or_else(|| {
format!(
"--credential: expected name=ENV_VAR[@principal], name[@principal]=cmd:COMMAND, \
or name[@principal]=vault:PATH#FIELD, got {pair:?} — note that a comma \
separates credentials, so a resolver command containing one belongs in a script"
)
})?;
let (name, name_owner) = match lhs.trim().split_once('@') {
Some((n, p)) if !p.trim().is_empty() => (n.trim(), Some(p.trim().to_string())),
Some(_) => {
return Err(format!(
"--credential {pair:?}: empty principal after '@' — write name=SOURCE for an \
unbound credential, or name@principal=SOURCE to bind one"
))
}
None => (lhs.trim(), None),
};
if name.is_empty() {
return Err(format!("--credential {pair:?}: the credential has no name"));
}
let (source, spec_owner) = CredentialSource::from_spec(spec.trim())?;
let owner = match (name_owner, spec_owner) {
(Some(_), Some(_)) => {
return Err(format!(
"--credential {pair:?}: names a principal on both sides of '=' — pick one"
))
}
(a, b) => a.or(b),
};
out.push((name.to_string(), source.with_resolver_config(ttl_secs, &resolver_env), owner));
}
Ok(out)
}
pub fn unowned_credentials(&self) -> Result<BTreeMap<String, CredentialSource>, String> {
Ok(self
.credential_sources()?
.into_iter()
.map(|(name, source, _owner)| (name, source))
.collect())
}
pub fn policy(&self) -> Result<EgressPolicy, String> {
match &self.allow_hosts {
None => Ok(EgressPolicy::unrestricted()),
Some(list) => {
let entries: Vec<serde_json::Value> = list
.split(',')
.map(str::trim)
.filter(|h| !h.is_empty())
.map(|h| serde_json::json!(h))
.collect();
EgressPolicy::from_config(Some(&serde_json::json!({
"int:allowed_outbound_hosts": entries
})))
}
}
}
pub fn grants(&self) -> Result<EgressGrants, String> {
let mut grants = EgressGrants::new();
for spec in self.tool_egress.iter().flat_map(|v| v.split(',')) {
let spec = spec.trim();
if spec.is_empty() {
continue;
}
if spec.contains("://") {
return Err(format!(
"--tool-egress {spec:?}: pair a credential with a BARE hostname \
(cred@api.example.com), not a URL — this spec is colon-delimited, so a scheme \
or port would tear it apart; scheme and port are narrowed by --allow-host"
));
}
let mut parts = spec.split(':');
let tool = parts.next().unwrap_or("").trim();
if tool.is_empty() {
return Err(format!(
"--tool-egress: expected tool:cred[@host]+cred[@host]:METHOD+METHOD, got {spec:?}"
));
}
let mut g = CallerGrant::new();
for c in parts.next().unwrap_or("").split('+').map(str::trim) {
if c.is_empty() {
continue;
}
g = match c.split_once('@') {
Some((name, host)) => {
let name = name.trim();
if name.is_empty() {
return Err(format!(
"--tool-egress {spec:?}: {c:?} has no credential name before '@'"
));
}
let host = crate::AllowedHost::parse_host_pattern(host, "--tool-egress")?;
g.credential_for(name, vec![host])
}
None => g.credential(c),
};
}
for m in parts.next().unwrap_or("").split('+').map(str::trim) {
if m.is_empty() {
continue;
}
let upper = m.to_ascii_uppercase();
if !matches!(upper.as_str(), "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE") {
let hint = if m.chars().all(|c| c.is_ascii_digit()) {
" — if you meant a port, drop it: a credential↔host pairing \
names the host, and the port is narrowed by --allow-host"
} else {
""
};
return Err(format!(
"--tool-egress {spec:?}: {m:?} is not an HTTP method; accepted: \
GET, HEAD, POST, PUT, PATCH, DELETE{hint}"
));
}
g = g.method(&upper);
}
grants = grants.grant(tool, g);
}
Ok(grants)
}
pub fn build(&self) -> Result<Option<Broker>, String> {
if !self.configures_broker() {
return Ok(None);
}
let mut credentials = BTreeMap::new();
let mut owners: Vec<(String, String)> = Vec::new();
for (name, source, owner) in self.credential_sources()? {
if let Some(o) = owner {
owners.push((name.clone(), o));
}
credentials.insert(name, source);
}
let broker = Broker::start(self.policy()?, credentials, self.grants()?, "RUN-E022")?;
for (name, owner) in owners {
broker.bind_credential_owner(&name, &owner);
}
Ok(Some(broker))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(credentials: &str, hosts: &str, egress: &str) -> EgressSpec {
let some = |s: &str| (!s.is_empty()).then(|| s.to_string());
EgressSpec {
credentials: some(credentials),
allow_hosts: some(hosts),
tool_egress: some(egress),
..Default::default()
}
}
#[test]
fn nothing_set_builds_no_broker() {
assert!(EgressSpec::default().build().unwrap().is_none());
assert!(!EgressSpec::default().configures_broker());
}
#[test]
fn the_cli_grammar_parses_verbatim() {
std::env::set_var("AREEV_TEST_SPEC_TOK", "s3cret");
let s = spec(
"zoho=AREEV_TEST_SPEC_TOK@user:a,sheets@user:b=cmd:echo t",
"https://books.zoho.com,https://sheets.googleapis.com",
"sync:zoho@books.zoho.com+sheets:POST+GET,parse::",
);
let creds = s.credential_sources().unwrap();
assert_eq!(creds.len(), 2);
assert_eq!(creds[0].0, "zoho");
assert_eq!(creds[0].2.as_deref(), Some("user:a"));
assert_eq!(creds[1].0, "sheets");
assert_eq!(creds[1].2.as_deref(), Some("user:b"));
assert!(s.unowned_credentials().unwrap().contains_key("sheets"));
let broker = s.build().unwrap().expect("a broker");
assert!(broker.token_for("sync").is_some());
assert!(broker.token_for("parse").is_some(), "a grant naming nothing still mints a token");
assert!(broker.token_for("stranger").is_none());
std::env::remove_var("AREEV_TEST_SPEC_TOK");
}
#[test]
fn the_cli_refusals_are_the_same_refusals() {
let e = spec("zoho", "", "").build().err().expect("a refusal");
assert!(e.contains("--credential: expected name=ENV_VAR"), "{e}");
let e = spec("zoho=AREEV_UNSET_VAR_FOR_TEST", "", "").build().err().expect("a refusal");
assert!(e.contains("AREEV_UNSET_VAR_FOR_TEST"), "{e}");
let e = spec("", "", "sync:zoho@https://x.com:POST").build().err().expect("a refusal");
assert!(e.contains("BARE hostname"), "{e}");
let e = spec("", "", "sync:zoho:8443").build().err().expect("a refusal");
assert!(e.contains("if you meant a port"), "{e}");
let e = spec("", "", ":zoho:POST").build().err().expect("a refusal");
assert!(e.contains("expected tool:cred"), "{e}");
}
#[test]
fn the_env_fallback_fills_only_what_is_unset() {
std::env::set_var("AREEV_RUN_TOOL_EGRESS", "x::");
std::env::set_var("AREEV_RUN_CREDENTIAL_TTL", " ");
let s = EgressSpec { tool_egress: Some("y::".into()), ..Default::default() }
.with_env_fallback()
.unwrap();
assert_eq!(s.tool_egress.as_deref(), Some("y::"), "the set value wins");
assert_eq!(s.credential_ttl_secs, None, "an empty variable is unset");
let s = EgressSpec::from_env().unwrap();
assert_eq!(s.tool_egress.as_deref(), Some("x::"));
std::env::set_var("AREEV_RUN_CREDENTIAL_TTL", "soon");
let e = EgressSpec::from_env().unwrap_err();
assert!(e.contains("AREEV_RUN_CREDENTIAL_TTL"), "{e}");
std::env::remove_var("AREEV_RUN_TOOL_EGRESS");
std::env::remove_var("AREEV_RUN_CREDENTIAL_TTL");
}
}