use bcode_provider_auth_models::{AuthCredentialSource, AuthSecretField};
use std::io::Read as _;
#[cfg(unix)]
use std::path::Component;
use std::path::Path;
use zeroize::Zeroizing;
const MAX_SOURCE_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CredentialSourceStatus {
Available,
Missing,
Unavailable,
}
#[must_use]
pub fn discover(
enabled: bool,
home: &Path,
field: &AuthSecretField,
) -> Vec<(usize, CredentialSourceStatus)> {
if !enabled || field.validate().is_err() {
return Vec::new();
}
field
.discovery_sources
.iter()
.enumerate()
.map(|(index, _)| {
let status = match read_selected(home, field, index) {
Ok(Some(_)) => CredentialSourceStatus::Available,
Ok(None) => CredentialSourceStatus::Missing,
Err(error) => error,
};
(index, status)
})
.collect()
}
pub fn read_selected(
home: &Path,
field: &AuthSecretField,
index: usize,
) -> Result<Option<Zeroizing<String>>, CredentialSourceStatus> {
field
.validate()
.map_err(|_| CredentialSourceStatus::Unavailable)?;
let source = field
.discovery_sources
.get(index)
.ok_or(CredentialSourceStatus::Unavailable)?;
let value = match source {
AuthCredentialSource::Environment { name } => match std::env::var(name) {
Ok(value) => Some(Zeroizing::new(value)),
Err(std::env::VarError::NotPresent) => None,
Err(std::env::VarError::NotUnicode(_)) => {
return Err(CredentialSourceStatus::Unavailable);
}
},
AuthCredentialSource::JsonFile {
relative_path,
pointer,
discriminator,
..
} => read_json_source(home, relative_path, pointer, discriminator.as_ref())?,
};
if let Some(value) = &value {
if value.is_empty() {
return Ok(None);
}
field
.validation
.validate_secret(value)
.map_err(|_| CredentialSourceStatus::Unavailable)?;
}
Ok(value)
}
fn read_json_source(
home: &Path,
relative_path: &str,
pointer: &str,
discriminator: Option<&(String, String)>,
) -> Result<Option<Zeroizing<String>>, CredentialSourceStatus> {
let unavailable = |_| CredentialSourceStatus::Unavailable;
let file = open_confined(home, relative_path)?;
let Some(file) = file else {
return Ok(None);
};
let metadata = file.metadata().map_err(unavailable)?;
if !metadata.is_file() || metadata.len() > MAX_SOURCE_BYTES {
return Err(CredentialSourceStatus::Unavailable);
}
let mut bytes = Zeroizing::new(Vec::new());
file.take(MAX_SOURCE_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(unavailable)?;
if bytes.len() as u64 > MAX_SOURCE_BYTES {
return Err(CredentialSourceStatus::Unavailable);
}
let mut document: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|_| CredentialSourceStatus::Unavailable)?;
let matches = discriminator.is_none_or(|(key, expected)| {
document.pointer(key).and_then(serde_json::Value::as_str) == Some(expected.as_str())
});
let value = if matches {
document
.pointer(pointer)
.and_then(serde_json::Value::as_str)
.map(|value| Zeroizing::new(value.to_owned()))
} else {
None
};
clear_json_strings(&mut document);
Ok(value)
}
#[cfg(unix)]
fn open_confined(
home: &Path,
relative: &str,
) -> Result<Option<std::fs::File>, CredentialSourceStatus> {
use std::os::fd::{AsRawFd as _, FromRawFd as _};
use std::os::unix::ffi::OsStrExt as _;
let parts = Path::new(relative).components().collect::<Vec<_>>();
if parts.is_empty()
|| parts
.iter()
.any(|part| !matches!(part, Component::Normal(_)))
{
return Err(CredentialSourceStatus::Unavailable);
}
let mut directory =
std::fs::File::open(home).map_err(|_| CredentialSourceStatus::Unavailable)?;
for (index, part) in parts.iter().enumerate() {
let name = std::ffi::CString::new(part.as_os_str().as_bytes())
.map_err(|_| CredentialSourceStatus::Unavailable)?;
let mut flags = libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK;
if index + 1 < parts.len() {
flags |= libc::O_DIRECTORY;
}
let fd = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) };
if fd < 0 {
return if std::io::Error::last_os_error().kind() == std::io::ErrorKind::NotFound {
Ok(None)
} else {
Err(CredentialSourceStatus::Unavailable)
};
}
directory = unsafe { std::fs::File::from_raw_fd(fd) };
}
Ok(Some(directory))
}
#[cfg(not(unix))]
const fn open_confined(
_home: &Path,
_relative: &str,
) -> Result<Option<std::fs::File>, CredentialSourceStatus> {
Err(CredentialSourceStatus::Unavailable)
}
fn clear_json_strings(value: &mut serde_json::Value) {
use zeroize::Zeroize as _;
match value {
serde_json::Value::String(text) => text.zeroize(),
serde_json::Value::Array(values) => values.iter_mut().for_each(clear_json_strings),
serde_json::Value::Object(values) => values.values_mut().for_each(clear_json_strings),
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn field() -> AuthSecretField {
AuthSecretField {
credential_id: "api_key".to_owned(),
storage_key: "API_KEY".to_owned(),
prompt: "API key".to_owned(),
optional: false,
validation: bcode_provider_auth_models::AuthSecretValidation::default(),
invocation_env: Vec::new(),
discovery_sources: vec![AuthCredentialSource::JsonFile {
application: "Test".to_owned(),
relative_path: "auth.json".to_owned(),
pointer: "/provider/key".to_owned(),
discriminator: Some(("/provider/type".to_owned(), "api".to_owned())),
}],
}
}
#[test]
fn disabled_discovery_does_not_access_missing_root() {
assert!(discover(false, Path::new("/nonexistent/bcode-test"), &field()).is_empty());
}
#[test]
#[cfg(not(unix))]
fn unsupported_confinement_preserves_source_and_refuses_discovery() {
let home = tempfile::tempdir().unwrap();
let path = home.path().join("auth.json");
let contents = r#"{"provider":{"type":"api","key":"test-value"}}"#;
std::fs::write(&path, contents).unwrap();
assert_eq!(
discover(true, home.path(), &field()),
vec![(0, CredentialSourceStatus::Unavailable)]
);
assert!(read_selected(home.path(), &field(), 0).is_err());
assert_eq!(std::fs::read_to_string(path).unwrap(), contents);
}
#[test]
#[cfg(unix)]
fn reads_only_compatible_static_keys_and_preserves_source() {
let home = tempfile::tempdir().unwrap();
let path = home.path().join("auth.json");
let contents = r#"{"provider":{"type":"api","key":"test-value"}}"#;
std::fs::write(&path, contents).unwrap();
assert_eq!(
discover(true, home.path(), &field()),
vec![(0, CredentialSourceStatus::Available)]
);
assert_eq!(
read_selected(home.path(), &field(), 0)
.unwrap()
.unwrap()
.as_str(),
"test-value"
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), contents);
std::fs::write(&path, r#"{"provider":{"type":"oauth","key":"test-value"}}"#).unwrap();
assert!(read_selected(home.path(), &field(), 0).unwrap().is_none());
}
#[test]
fn rejects_corrupt_and_oversized_documents() {
let home = tempfile::tempdir().unwrap();
let path = home.path().join("auth.json");
std::fs::write(&path, "{").unwrap();
assert_eq!(
discover(true, home.path(), &field()),
vec![(0, CredentialSourceStatus::Unavailable)]
);
let file = std::fs::File::create(path).unwrap();
file.set_len(MAX_SOURCE_BYTES + 1).unwrap();
assert!(read_selected(home.path(), &field(), 0).is_err());
}
#[cfg(unix)]
#[test]
fn rejects_symlink_outside_authorized_root() {
let home = tempfile::tempdir().unwrap();
let outside = tempfile::NamedTempFile::new().unwrap();
std::os::unix::fs::symlink(outside.path(), home.path().join("auth.json")).unwrap();
assert!(read_selected(home.path(), &field(), 0).is_err());
}
}