use async_trait::async_trait;
use nexo_tool_meta::admin::secrets::{SecretsWriteInput, SecretsWriteResponse};
use serde_json::Value;
use crate::agent::admin_rpc::dispatcher::{AdminRpcError, AdminRpcResult};
#[async_trait]
pub trait SecretsStore: Send + Sync {
async fn write(&self, name: &str, value: &str) -> Result<SecretsWriteResponse, AdminRpcError>;
}
const NAME_RE: &str = r"^[A-Z][A-Z0-9_]{1,63}$";
const MIN_VALUE_BYTES: usize = 1;
const MAX_VALUE_BYTES: usize = 8192;
pub async fn write(store: &dyn SecretsStore, raw_params: Value) -> AdminRpcResult {
match try_write(store, raw_params).await {
Ok(v) => AdminRpcResult::ok(v),
Err(e) => AdminRpcResult::err(e),
}
}
async fn try_write(store: &dyn SecretsStore, raw_params: Value) -> Result<Value, AdminRpcError> {
let input: SecretsWriteInput = serde_json::from_value(raw_params)
.map_err(|e| AdminRpcError::InvalidParams(e.to_string()))?;
validate_name(&input.name)?;
validate_value(&input.value)?;
let response = store.write(&input.name, &input.value).await?;
serde_json::to_value(response).map_err(|e| AdminRpcError::Internal(e.to_string()))
}
fn validate_name(name: &str) -> Result<(), AdminRpcError> {
use std::sync::OnceLock;
static RE: OnceLock<regex::Regex> = OnceLock::new();
let re = RE.get_or_init(|| regex::Regex::new(NAME_RE).expect("static regex compiles"));
if !re.is_match(name) {
return Err(AdminRpcError::InvalidParams(format!(
"name {name:?} must match {NAME_RE}"
)));
}
Ok(())
}
fn validate_value(value: &str) -> Result<(), AdminRpcError> {
let n = value.as_bytes().len();
if n < MIN_VALUE_BYTES {
return Err(AdminRpcError::InvalidParams("value cannot be empty".into()));
}
if n > MAX_VALUE_BYTES {
return Err(AdminRpcError::InvalidParams(format!(
"value length {n} exceeds {MAX_VALUE_BYTES}"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tokio::sync::Mutex as TokioMutex;
struct MockSecretsStore {
calls: TokioMutex<Vec<(String, String)>>,
}
impl MockSecretsStore {
fn new() -> Self {
Self {
calls: TokioMutex::new(Vec::new()),
}
}
async fn calls(&self) -> Vec<(String, String)> {
self.calls.lock().await.clone()
}
}
#[async_trait]
impl SecretsStore for MockSecretsStore {
async fn write(
&self,
name: &str,
value: &str,
) -> Result<SecretsWriteResponse, AdminRpcError> {
self.calls
.lock()
.await
.push((name.to_string(), value.to_string()));
Ok(SecretsWriteResponse {
path: PathBuf::from(format!("/test/secrets/{name}.txt")),
overwrote_env: false,
})
}
}
fn ok_input(name: &str, value: &str) -> Value {
serde_json::json!({
"name": name,
"value": value,
})
}
fn expect_invalid_params(result: AdminRpcResult, needle: &str) {
let err = result.error.expect("expected error result");
match err {
AdminRpcError::InvalidParams(msg) => assert!(
msg.contains(needle),
"expected error to mention {needle:?}, got {msg:?}"
),
other => panic!("expected InvalidParams, got {other:?}"),
}
}
#[tokio::test]
async fn write_validates_name_regex_lowercase() {
let store = MockSecretsStore::new();
let result = write(&store, ok_input("lowercase_key", "v")).await;
expect_invalid_params(result, "must match");
assert!(store.calls().await.is_empty(), "store must not be called");
}
#[tokio::test]
async fn write_validates_name_regex_traversal_blocked() {
let store = MockSecretsStore::new();
let result = write(&store, ok_input("../etc/passwd", "v")).await;
expect_invalid_params(result, "must match");
assert!(store.calls().await.is_empty());
}
#[tokio::test]
async fn write_rejects_empty_value() {
let store = MockSecretsStore::new();
let result = write(&store, ok_input("FOO_KEY", "")).await;
expect_invalid_params(result, "empty");
assert!(store.calls().await.is_empty());
}
#[tokio::test]
async fn write_rejects_oversized_value() {
let store = MockSecretsStore::new();
let too_big = "x".repeat(MAX_VALUE_BYTES + 1);
let result = write(&store, ok_input("FOO_KEY", &too_big)).await;
expect_invalid_params(result, "exceeds");
assert!(store.calls().await.is_empty());
}
#[tokio::test]
async fn write_calls_store_with_validated_input() {
let store = MockSecretsStore::new();
let result = write(&store, ok_input("MINIMAX_API_KEY", "sk-test")).await;
let value = result.result.expect("expected success");
assert!(value.is_object());
assert!(value["path"].is_string());
let calls = store.calls().await;
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "MINIMAX_API_KEY");
assert_eq!(calls[0].1, "sk-test");
}
}