use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
use super::{ClientError, files::atomic_write};
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TokenSource {
Minted,
Supplied,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ManagedCredential {
pub client: String,
pub source: TokenSource,
#[serde(default)]
pub token_id: Option<String>,
#[serde(default)]
pub label: Option<String>,
#[serde(default)]
pub issued_at: Option<i64>,
}
impl ManagedCredential {
#[must_use]
pub fn revocable_by_default(&self) -> bool {
self.source == TokenSource::Minted && self.token_id.is_some()
}
}
pub fn write(path: &Path, credential: &ManagedCredential) -> Result<(), ClientError> {
let parent = path
.parent()
.ok_or_else(|| ClientError::message("credential metadata has no parent directory"))?;
fs::create_dir_all(parent)?;
let rendered = format!("{}\n", serde_json::to_string_pretty(credential)?);
atomic_write(path, rendered.as_bytes())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
pub fn read(path: &Path) -> Result<Option<ManagedCredential>, ClientError> {
let source = super::files::read_or_empty(path)?;
if source.trim().is_empty() {
return Ok(None);
}
let credential: ManagedCredential = serde_json::from_str(&source).map_err(|error| {
ClientError::message(format!(
"could not parse managed credential metadata {}: {error}",
path.display()
))
})?;
Ok(Some(credential))
}
pub fn remove(path: &Path) -> Result<(), ClientError> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(ClientError::message(format!(
"could not remove {}: {error}",
path.display()
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn minted_records_are_revocable_and_supplied_ones_are_not() {
let minted = ManagedCredential {
client: "codex".into(),
source: TokenSource::Minted,
token_id: Some("id".into()),
label: None,
issued_at: None,
};
assert!(minted.revocable_by_default());
let supplied = ManagedCredential {
source: TokenSource::Supplied,
..minted
};
assert!(!supplied.revocable_by_default());
}
#[test]
fn records_round_trip_through_disk_without_the_token() {
let directory = tempfile::tempdir().expect("temp dir");
let path = directory.path().join("codex.json");
let credential = ManagedCredential {
client: "codex".into(),
source: TokenSource::Minted,
token_id: Some("token-id".into()),
label: Some("client-codex".into()),
issued_at: Some(7),
};
write(&path, &credential).expect("write metadata");
let contents = fs::read_to_string(&path).expect("read metadata");
assert!(!contents.contains("la_sk_"));
let read_back = read(&path).expect("read metadata").expect("record exists");
assert_eq!(read_back.token_id.as_deref(), Some("token-id"));
assert_eq!(read_back.source, TokenSource::Minted);
remove(&path).expect("remove metadata");
assert!(read(&path).expect("missing metadata is fine").is_none());
}
}