use std::io::Read;
use std::ptr;
use std::sync::Arc;
use crate::error::SandboxError;
use crate::http::HttpRule;
pub struct SecretString(Vec<u8>);
impl SecretString {
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
fn expose(&self) -> &[u8] {
&self.0
}
}
impl Drop for SecretString {
fn drop(&mut self) {
for b in self.0.iter_mut() {
unsafe { ptr::write_volatile(b as *mut u8, 0) };
}
}
}
impl std::fmt::Debug for SecretString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SecretString(<redacted>)")
}
}
pub fn load_secret(source: &str) -> Result<SecretString, SandboxError> {
let (kind, val) = source.split_once(':').ok_or_else(|| {
SandboxError::Invalid(format!(
"credential source must be env:/file:/fd:<...>, got {source:?}"
))
})?;
let mut bytes = match kind {
"env" => std::env::var_os(val)
.ok_or_else(|| SandboxError::Invalid(format!("credential env var {val} is not set")))?
.into_encoded_bytes(),
"file" => read_capped(
&mut std::fs::File::open(val)
.map_err(|e| SandboxError::Invalid(format!("credential file {val}: {e}")))?,
&format!("file {val}"),
)?,
"fd" => {
let n: i32 = val
.parse()
.map_err(|_| SandboxError::Invalid(format!("credential fd must be an integer, got {val:?}")))?;
read_fd(n)?
}
"literal" => {
return Err(SandboxError::Invalid(
"credential source 'literal:' is unsupported (it leaks via ps / shell history); \
use env:/file:/fd:"
.into(),
))
}
other => return Err(SandboxError::Invalid(format!("unknown credential source {other:?}"))),
};
if bytes.last() == Some(&b'\n') {
bytes.pop();
if bytes.last() == Some(&b'\r') {
bytes.pop();
}
}
if bytes.is_empty() {
return Err(SandboxError::Invalid(format!("credential from {source:?} is empty")));
}
Ok(SecretString::new(bytes))
}
const MAX_SECRET_BYTES: u64 = 64 << 10;
fn read_capped<R: std::io::Read>(r: &mut R, what: &str) -> Result<Vec<u8>, SandboxError> {
let mut buf = Vec::new();
r.take(MAX_SECRET_BYTES + 1)
.read_to_end(&mut buf)
.map_err(|e| SandboxError::Invalid(format!("credential {what}: {e}")))?;
if buf.len() as u64 > MAX_SECRET_BYTES {
return Err(SandboxError::Invalid(format!(
"credential {what} exceeds {MAX_SECRET_BYTES} bytes"
)));
}
Ok(buf)
}
fn read_fd(n: i32) -> Result<Vec<u8>, SandboxError> {
use std::os::fd::FromRawFd;
if n == 1 || n == 2 {
return Err(SandboxError::Invalid(format!(
"credential fd {n} refers to stdout/stderr; pass a dedicated fd (or fd:0 for stdin)"
)));
}
let dup = unsafe { libc::dup(n) };
if dup < 0 {
return Err(SandboxError::Invalid(format!(
"credential fd {n}: {}",
std::io::Error::last_os_error()
)));
}
let mut f = unsafe { std::fs::File::from_raw_fd(dup) };
if n == 0 && fd_is_rewindable(&mut f) {
return Err(SandboxError::Invalid(
"credential fd 0 (stdin) is seekable, so the sandboxed child could rewind \
it and re-read the secret; pipe it instead (printf %s \"$SECRET\" | sandlock …)"
.into(),
));
}
read_capped(&mut f, &format!("fd {n}"))
}
fn fd_is_rewindable(f: &mut std::fs::File) -> bool {
use std::io::Seek;
f.stream_position().is_ok()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthShape {
Bearer,
Basic { username: String },
Header { name: String },
Query { param: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OnExistingHeader {
#[default]
Replace,
AddOnly,
}
pub struct InjectRule {
pub name: String,
pub matcher: HttpRule,
pub auth: AuthShape,
pub secret: Arc<SecretString>,
pub on_existing: OnExistingHeader,
}
impl std::fmt::Debug for InjectRule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InjectRule")
.field("name", &self.name)
.field("matcher", &self.matcher)
.field("auth", &self.auth)
.field("on_existing", &self.on_existing)
.field("secret", &self.secret) .finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Applied {
Injected,
Skipped,
}
impl InjectRule {
pub fn matches(&self, method: &str, host: &str, path: &str) -> bool {
self.matcher.matches(method, host, path)
}
pub fn apply(&self, parts: &mut hyper::http::request::Parts) -> Result<Applied, ()> {
match &self.auth {
AuthShape::Bearer => {
let mut v = b"Bearer ".to_vec();
v.extend_from_slice(self.secret.expose());
self.set_header(parts, "authorization", &v)
}
AuthShape::Basic { username } => {
let mut raw = username.clone().into_bytes();
raw.push(b':');
raw.extend_from_slice(self.secret.expose());
let mut v = b"Basic ".to_vec();
v.extend_from_slice(base64_encode(&raw).as_bytes());
self.set_header(parts, "authorization", &v)
}
AuthShape::Header { name } => self.set_header(parts, name, self.secret.expose()),
AuthShape::Query { param } => self.set_query(parts, param),
}
}
fn set_header(
&self,
parts: &mut hyper::http::request::Parts,
name: &str,
value: &[u8],
) -> Result<Applied, ()> {
let hn = hyper::header::HeaderName::from_bytes(name.as_bytes()).map_err(|_| ())?;
if self.on_existing == OnExistingHeader::AddOnly && parts.headers.contains_key(&hn) {
return Ok(Applied::Skipped); }
let mut hv = hyper::header::HeaderValue::from_bytes(value).map_err(|_| ())?;
hv.set_sensitive(true);
parts.headers.insert(hn, hv);
Ok(Applied::Injected)
}
fn set_query(&self, parts: &mut hyper::http::request::Parts, param: &str) -> Result<Applied, ()> {
let uri = &parts.uri;
let path = uri.path();
let existing = uri.query();
let enc = urlencode_bytes(param.as_bytes());
let is_target = |kv: &str| {
percent_decode_lossy(kv.split('=').next().unwrap_or(kv)) == param.as_bytes()
};
if self.on_existing == OnExistingHeader::AddOnly {
if let Some(q) = existing {
if q.split('&').any(is_target) {
return Ok(Applied::Skipped);
}
}
}
let pair = format!("{}={}", enc, urlencode_bytes(self.secret.expose()));
let kept: Option<String> = existing.map(|q| {
q.split('&')
.filter(|kv| !kv.is_empty() && !is_target(kv))
.collect::<Vec<_>>()
.join("&")
});
let new_pq = match kept {
Some(ref k) if !k.is_empty() => format!("{path}?{k}&{pair}"),
_ => format!("{path}?{pair}"),
};
let mut b = hyper::http::uri::Builder::new();
if let Some(s) = uri.scheme() {
b = b.scheme(s.clone());
}
if let Some(a) = uri.authority() {
b = b.authority(a.clone());
}
parts.uri = b.path_and_query(new_pq).build().map_err(|_| ())?;
Ok(Applied::Injected)
}
}
pub fn parse_auth(spec: &str, credential: &str) -> Result<AuthShape, SandboxError> {
let (kind, arg) = match spec.split_once(':') {
Some((k, a)) => (k, Some(a)),
None => (spec, None),
};
let shape = match (kind, arg) {
("bearer", None) => AuthShape::Bearer,
("basic", Some(user)) if user.contains(':') => {
return Err(SandboxError::Invalid(format!(
"basic auth user-id must not contain ':' (RFC 7617), got {user:?}"
)))
}
("basic", Some(user)) if !user.is_empty() => AuthShape::Basic { username: user.to_string() },
("header" | "apikey", Some(name)) if !name.is_empty() => {
if hyper::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
return Err(SandboxError::Invalid(format!(
"invalid header name {name:?} in auth shape {spec:?} for credential {credential:?}"
)));
}
AuthShape::Header { name: name.to_string() }
}
("query", Some(param)) if !param.is_empty() => AuthShape::Query { param: param.to_string() },
_ => {
return Err(SandboxError::Invalid(format!(
"invalid auth shape {spec:?} for credential {credential:?} \
(expected bearer | basic:<user> | header:<name> | apikey:<name> | query:<param>)"
)))
}
};
Ok(shape)
}
pub fn resolve_inject_rules(
credentials: &[String],
inject: &[String],
) -> Result<(Vec<InjectRule>, Vec<String>), SandboxError> {
use std::collections::HashMap;
let mut sources: HashMap<&str, &str> = HashMap::new();
for c in credentials {
let (name, source) = c.split_once('=').ok_or_else(|| {
SandboxError::Invalid(format!("--credential must be NAME=SOURCE, got {c:?}"))
})?;
if name.is_empty() {
return Err(SandboxError::Invalid(format!("--credential has empty name: {c:?}")));
}
if sources.insert(name, source).is_some() {
return Err(SandboxError::Invalid(format!("--credential {name:?} declared twice")));
}
}
struct Parsed<'a> {
name: &'a str,
matcher: HttpRule,
auth: AuthShape,
on_existing: OnExistingHeader,
source: &'a str,
}
let mut parsed: Vec<Parsed> = Vec::with_capacity(inject.len());
for spec in inject {
let toks: Vec<&str> = spec.split_whitespace().collect();
if toks.len() < 4 || toks.len() > 5 {
return Err(SandboxError::Invalid(format!(
"--http-auth must be 'METHOD HOST/PATH AUTHSPEC CREDNAME [replace|add-only]', got {spec:?}"
)));
}
let matcher = HttpRule::parse(&format!("{} {}", toks[0], toks[1]))?;
let cred = toks[3];
let auth = parse_auth(toks[2], cred)?;
let on_existing = match toks.get(4) {
None | Some(&"replace") => OnExistingHeader::Replace,
Some(&"add-only") => OnExistingHeader::AddOnly,
Some(other) => {
return Err(SandboxError::Invalid(format!(
"--http-auth trailing token must be 'replace', 'add-only', or absent, got {other:?}"
)))
}
};
let source = *sources.get(cred).ok_or_else(|| {
SandboxError::Invalid(format!("--http-auth references undeclared credential {cred:?}"))
})?;
parsed.push(Parsed { name: cred, matcher, auth, on_existing, source });
}
let mut loaded: HashMap<&str, Arc<SecretString>> = HashMap::new();
for p in &parsed {
if !loaded.contains_key(p.name) {
loaded.insert(p.name, Arc::new(load_secret(p.source)?));
}
}
let mut env_strip: Vec<String> = Vec::new();
for source in sources.values() {
if let Some(var) = source.strip_prefix("env:") {
if !env_strip.iter().any(|v| v == var) {
env_strip.push(var.to_string());
}
}
}
let rules = parsed
.into_iter()
.map(|p| InjectRule {
name: p.name.to_string(),
matcher: p.matcher,
auth: p.auth,
secret: Arc::clone(&loaded[p.name]),
on_existing: p.on_existing,
})
.collect();
Ok((rules, env_strip))
}
fn base64_encode(input: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
for chunk in input.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = *chunk.get(1).unwrap_or(&0) as u32;
let b2 = *chunk.get(2).unwrap_or(&0) as u32;
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(T[((n >> 18) & 63) as usize] as char);
out.push(T[((n >> 12) & 63) as usize] as char);
out.push(if chunk.len() > 1 { T[((n >> 6) & 63) as usize] as char } else { '=' });
out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
}
out
}
fn percent_decode_lossy(s: &str) -> Vec<u8> {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%' && i + 2 < b.len() {
let hi = (b[i + 1] as char).to_digit(16);
let lo = (b[i + 2] as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push((hi as u8) << 4 | lo as u8);
i += 3;
continue;
}
}
out.push(b[i]);
i += 1;
}
out
}
fn urlencode_bytes(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len());
for &b in bytes {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn parts_of(uri: &str, headers: &[(&str, &str)]) -> hyper::http::request::Parts {
let mut b = hyper::Request::builder().uri(uri);
for (k, v) in headers {
b = b.header(*k, *v);
}
b.body(()).unwrap().into_parts().0
}
fn rule(auth: AuthShape, secret: &str, on_existing: OnExistingHeader) -> InjectRule {
InjectRule {
name: "test".into(),
matcher: HttpRule::parse("* api.example.com/*").unwrap(),
auth,
secret: Arc::new(SecretString::new(secret.as_bytes().to_vec())),
on_existing,
}
}
#[test]
fn secret_debug_is_redacted() {
let s = SecretString::new(b"sk-supersecret".to_vec());
assert_eq!(format!("{s:?}"), "SecretString(<redacted>)");
let r = rule(AuthShape::Bearer, "sk-supersecret", OnExistingHeader::AddOnly);
assert!(!format!("{r:?}").contains("supersecret"));
}
#[test]
fn load_secret_rejects_literal_and_unknown() {
assert!(load_secret("literal:sk-x").is_err());
assert!(load_secret("weird:x").is_err());
assert!(load_secret("no-colon").is_err());
}
#[test]
fn load_secret_env_strips_newline() {
std::env::set_var("SANDLOCK_TEST_CRED", "sk-abc\n");
let s = load_secret("env:SANDLOCK_TEST_CRED").unwrap();
assert_eq!(s.expose(), b"sk-abc");
std::env::remove_var("SANDLOCK_TEST_CRED");
assert!(load_secret("env:SANDLOCK_TEST_CRED").is_err());
}
#[test]
fn bearer_injects_authorization() {
let mut p = parts_of("https://api.example.com/v1/x", &[]);
rule(AuthShape::Bearer, "sk-abc", OnExistingHeader::AddOnly).apply(&mut p).unwrap();
assert_eq!(p.headers.get("authorization").unwrap(), "Bearer sk-abc");
assert!(p.headers.get("authorization").unwrap().is_sensitive());
}
#[test]
fn basic_injects_base64() {
let mut p = parts_of("https://api.example.com/x", &[]);
rule(AuthShape::Basic { username: "user".into() }, "pass", OnExistingHeader::AddOnly).apply(&mut p).unwrap();
assert_eq!(p.headers.get("authorization").unwrap(), "Basic dXNlcjpwYXNz");
}
#[test]
fn header_shape_sets_named_header() {
let mut p = parts_of("https://api.example.com/x", &[]);
rule(AuthShape::Header { name: "x-api-key".into() }, "k123", OnExistingHeader::AddOnly).apply(&mut p).unwrap();
assert_eq!(p.headers.get("x-api-key").unwrap(), "k123");
}
#[test]
fn add_only_does_not_overwrite_but_replace_does() {
let mut p = parts_of("https://api.example.com/x", &[("authorization", "Bearer child-set")]);
rule(AuthShape::Bearer, "sk-real", OnExistingHeader::AddOnly).apply(&mut p).unwrap();
assert_eq!(p.headers.get("authorization").unwrap(), "Bearer child-set");
let mut p2 = parts_of("https://api.example.com/x", &[("authorization", "Bearer child-set")]);
rule(AuthShape::Bearer, "sk-real", OnExistingHeader::Replace).apply(&mut p2).unwrap();
assert_eq!(p2.headers.get("authorization").unwrap(), "Bearer sk-real");
}
#[test]
fn query_shape_appends_param() {
let mut p = parts_of("https://api.example.com/v1/x?a=1", &[]);
rule(AuthShape::Query { param: "key".into() }, "s e/cret", OnExistingHeader::AddOnly).apply(&mut p).unwrap();
assert_eq!(p.uri.query().unwrap(), "a=1&key=s%20e%2Fcret");
let mut p2 = parts_of("https://api.example.com/v1/x", &[]);
rule(AuthShape::Query { param: "key".into() }, "abc", OnExistingHeader::AddOnly).apply(&mut p2).unwrap();
assert_eq!(p2.uri.query().unwrap(), "key=abc");
}
#[test]
fn parse_auth_shapes() {
assert_eq!(parse_auth("bearer", "c").unwrap(), AuthShape::Bearer);
assert_eq!(parse_auth("basic:user", "c").unwrap(), AuthShape::Basic { username: "user".into() });
assert_eq!(parse_auth("header:x-api-key", "c").unwrap(), AuthShape::Header { name: "x-api-key".into() });
assert_eq!(parse_auth("apikey:x-key", "c").unwrap(), AuthShape::Header { name: "x-key".into() });
assert_eq!(parse_auth("query:token", "c").unwrap(), AuthShape::Query { param: "token".into() });
assert!(parse_auth("basic:", "c").is_err());
assert!(parse_auth("bogus", "c").is_err());
assert!(parse_auth("basic:a:b", "c").is_err());
assert!(matches!(parse_auth("basic:alice", "c"), Ok(AuthShape::Basic { username }) if username == "alice"));
}
#[test]
fn apply_fails_on_secret_with_illegal_header_bytes() {
let mut p = parts_of("https://api.example.com/x", &[]);
let r = rule(AuthShape::Bearer, "sk\r\nx-evil: 1", OnExistingHeader::AddOnly);
assert!(r.apply(&mut p).is_err());
assert!(p.headers.get("authorization").is_none());
assert!(p.headers.get("x-evil").is_none()); }
#[test]
fn query_add_only_keeps_child_replace_overwrites() {
let mut p = parts_of("https://api.example.com/x?key=child", &[]);
rule(AuthShape::Query { param: "key".into() }, "sk-real", OnExistingHeader::AddOnly)
.apply(&mut p).unwrap();
assert_eq!(p.uri.query().unwrap(), "key=child");
let mut p2 = parts_of("https://api.example.com/x?key=child", &[]);
rule(AuthShape::Query { param: "key".into() }, "sk-real", OnExistingHeader::Replace)
.apply(&mut p2).unwrap();
assert_eq!(p2.uri.query().unwrap(), "key=sk-real");
let mut p3 = parts_of("https://api.example.com/x?a=1&key=old&b=2", &[]);
rule(AuthShape::Query { param: "key".into() }, "sk-real", OnExistingHeader::Replace)
.apply(&mut p3).unwrap();
assert_eq!(p3.uri.query().unwrap(), "a=1&b=2&key=sk-real");
let mut p4 = parts_of("https://api.example.com/x", &[]);
rule(AuthShape::Query { param: "key".into() }, "sk-real", OnExistingHeader::Replace)
.apply(&mut p4).unwrap();
assert_eq!(p4.uri.query().unwrap(), "key=sk-real");
}
#[test]
fn query_targets_value_less_param() {
let mut p = parts_of("https://api.example.com/x?key", &[]);
rule(AuthShape::Query { param: "key".into() }, "sk-real", OnExistingHeader::Replace)
.apply(&mut p).unwrap();
assert_eq!(p.uri.query().unwrap(), "key=sk-real");
let mut p2 = parts_of("https://api.example.com/x?key", &[]);
let r = rule(AuthShape::Query { param: "key".into() }, "sk-real", OnExistingHeader::AddOnly);
assert!(matches!(r.apply(&mut p2), Ok(Applied::Skipped)));
assert_eq!(p2.uri.query().unwrap(), "key");
}
#[test]
fn resolve_dedups_credentials_and_collects_env_strip() {
std::env::set_var("SANDLOCK_TEST_RESOLVE", "sk-resolve");
let creds = vec!["a=env:SANDLOCK_TEST_RESOLVE".to_string()];
let inject = vec![
"GET x.com/* bearer a".to_string(),
"POST x.com/* header:x-key a".to_string(), ];
let (rules, env_strip) = resolve_inject_rules(&creds, &inject).unwrap();
assert_eq!(rules.len(), 2);
assert!(Arc::ptr_eq(&rules[0].secret, &rules[1].secret));
assert_eq!(env_strip, vec!["SANDLOCK_TEST_RESOLVE".to_string()]);
std::env::remove_var("SANDLOCK_TEST_RESOLVE");
}
#[test]
fn resolve_strips_declared_but_unreferenced_env_credential() {
let creds = vec!["unused=env:SANDLOCK_TEST_UNREF".to_string()];
let (rules, env_strip) = resolve_inject_rules(&creds, &[]).unwrap();
assert!(rules.is_empty(), "no rule references the credential");
assert_eq!(
env_strip,
vec!["SANDLOCK_TEST_UNREF".to_string()],
"the declared env var must be stripped even though it is unused"
);
}
#[test]
fn resolve_rejects_undeclared_and_std_fd() {
assert!(resolve_inject_rules(&[], &["GET x/* bearer missing".to_string()]).is_err());
assert!(load_secret("fd:1").is_err()); assert!(load_secret("fd:2").is_err()); }
#[test]
fn fd0_rejects_seekable_stdin_but_allows_pipe() {
use std::os::fd::IntoRawFd;
let path = std::env::temp_dir()
.join(format!("sandlock-cred-fd0-{}", std::process::id()));
std::fs::write(&path, "sk-file\n").unwrap();
let file_fd = std::fs::File::open(&path).unwrap().into_raw_fd();
let saved = unsafe { libc::dup(0) };
assert!(saved >= 0);
assert_eq!(unsafe { libc::dup2(file_fd, 0) }, 0);
let res_file = load_secret("fd:0");
let mut fds = [0i32; 2];
assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
unsafe {
assert_eq!(libc::write(fds[1], b"sk-pipe\n".as_ptr().cast(), 8), 8);
libc::close(fds[1]);
libc::dup2(fds[0], 0);
libc::close(fds[0]);
}
let res_pipe = load_secret("fd:0");
unsafe {
libc::dup2(saved, 0); libc::close(saved);
libc::close(file_fd);
}
let _ = std::fs::remove_file(&path);
let err = res_file.expect_err("seekable stdin must be rejected");
assert!(err.to_string().contains("pipe it instead"), "got: {err}");
assert_eq!(res_pipe.unwrap().expose(), b"sk-pipe");
}
#[test]
fn fd_above_two_may_be_seekable() {
use std::os::fd::IntoRawFd;
let path = std::env::temp_dir()
.join(format!("sandlock-cred-fdn-{}", std::process::id()));
std::fs::write(&path, "sk-n").unwrap();
let fd = std::fs::File::open(&path).unwrap().into_raw_fd();
let s = load_secret(&format!("fd:{fd}")).unwrap();
assert_eq!(s.expose(), b"sk-n");
unsafe { libc::close(fd) };
let _ = std::fs::remove_file(&path);
}
#[test]
fn parse_auth_rejects_invalid_header_name_at_build_time() {
assert!(parse_auth("header:bad name", "c").is_err());
assert!(parse_auth("apikey:x:y", "c").is_err());
assert!(parse_auth("header:x-ok", "c").is_ok());
}
#[test]
fn query_encoded_spelling_of_param_is_still_target() {
let mut p = parts_of("https://api.example.com/x?%6Bey=child&a=1", &[]);
rule(AuthShape::Query { param: "key".into() }, "sk-real", OnExistingHeader::Replace)
.apply(&mut p).unwrap();
assert_eq!(p.uri.query().unwrap(), "a=1&key=sk-real");
let mut p2 = parts_of("https://api.example.com/x?%6Bey=child", &[]);
let r = rule(AuthShape::Query { param: "key".into() }, "sk-real", OnExistingHeader::AddOnly);
assert!(matches!(r.apply(&mut p2), Ok(Applied::Skipped)));
assert_eq!(p2.uri.query().unwrap(), "%6Bey=child");
}
#[test]
fn resolve_default_is_replace_add_only_is_opt_in() {
std::env::set_var("SANDLOCK_TEST_ONEX", "sk-x");
let creds = vec!["a=env:SANDLOCK_TEST_ONEX".to_string()];
let (r, _) = resolve_inject_rules(&creds, &["* x.com/* bearer a".to_string()]).unwrap();
assert_eq!(r[0].on_existing, OnExistingHeader::Replace);
let mut p = parts_of("https://x.com/v1", &[("authorization", "Bearer sk-placeholder")]);
r[0].apply(&mut p).unwrap();
assert_eq!(p.headers.get("authorization").unwrap(), "Bearer sk-x");
let (r2, _) = resolve_inject_rules(&creds, &["* x.com/* bearer a add-only".to_string()]).unwrap();
assert_eq!(r2[0].on_existing, OnExistingHeader::AddOnly);
assert!(resolve_inject_rules(&creds, &["* x.com/* bearer a keep".to_string()]).is_err());
std::env::remove_var("SANDLOCK_TEST_ONEX");
}
}