use std::collections::HashMap;
use arrow_array::cast::AsArray;
use arrow_array::{Array, StructArray};
use vgi_rpc::Result;
use crate::ipc;
#[derive(Clone, Debug)]
pub struct SecretLookup {
pub secret_type: String,
pub scope: Option<String>,
pub name: Option<String>,
}
#[derive(Clone, Default)]
pub struct Secrets {
pub by_name: HashMap<String, HashMap<String, String>>,
}
impl Secrets {
pub fn parse(bytes: &[u8]) -> Result<Secrets> {
if bytes.is_empty() {
return Ok(Secrets::default());
}
let batch = ipc::read_batch(bytes)?;
let mut by_name = HashMap::new();
for (i, field) in batch.schema().fields().iter().enumerate() {
let col = batch.column(i);
let mut fields = HashMap::new();
if let Some(sa) = col.as_any().downcast_ref::<StructArray>() {
for (j, sf) in sa.fields().iter().enumerate() {
fields.insert(sf.name().clone(), render(sa.column(j), 0));
}
} else {
fields.insert(field.name().clone(), render(col, 0));
}
by_name.insert(field.name().clone(), fields);
}
Ok(Secrets { by_name })
}
pub fn field(&self, field: &str) -> Option<String> {
self.by_name.values().find_map(|m| m.get(field).cloned())
}
pub fn named_field(&self, name: &str, field: &str) -> Option<String> {
self.by_name.get(name).and_then(|m| m.get(field).cloned())
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &HashMap<String, String>)> {
self.by_name.iter().map(|(k, v)| (k.as_str(), v))
}
pub fn secret_type(&self, name: &str) -> Option<String> {
self.named_field(name, "type")
}
pub fn of_type<'a>(
&'a self,
secret_type: &'a str,
) -> impl Iterator<Item = &'a HashMap<String, String>> + 'a {
self.by_name
.values()
.filter(move |m| m.get("type").map(String::as_str) == Some(secret_type))
}
pub fn for_scope(&self, path: &str) -> Option<&HashMap<String, String>> {
self.select_for_scope(path, None)
}
pub fn for_scope_of_type(
&self,
path: &str,
secret_type: &str,
) -> Option<&HashMap<String, String>> {
self.select_for_scope(path, Some(secret_type))
}
fn select_for_scope(
&self,
path: &str,
secret_type: Option<&str>,
) -> Option<&HashMap<String, String>> {
let typed = |m: &HashMap<String, String>| {
secret_type.is_none_or(|t| m.get("type").map(String::as_str) == Some(t))
};
let mut best: Option<(usize, &HashMap<String, String>)> = None;
let mut fallback: Option<&HashMap<String, String>> = None;
for fields in self.by_name.values().filter(|m| typed(m)) {
match fields.get("scope") {
Some(scope) if !scope.is_empty() => {
for prefix in scope.split('\n').filter(|p| !p.is_empty()) {
if path.starts_with(prefix)
&& best.is_none_or(|(blen, _)| prefix.len() > blen)
{
best = Some((prefix.len(), fields));
}
}
}
_ => {
if fallback.is_none() {
fallback = Some(fields);
}
}
}
}
best.map(|(_, f)| f).or(fallback)
}
pub fn field_for(&self, path: &str, field: &str) -> Option<String> {
self.for_scope(path).and_then(|m| m.get(field).cloned())
}
}
fn render(arr: &dyn Array, i: usize) -> String {
if arr.is_null(i) {
return String::new();
}
if let Some(s) = arr.as_string_opt::<i32>() {
return s.value(i).to_string();
}
if let Some(s) = arr.as_string_opt::<i64>() {
return s.value(i).to_string();
}
if let Some(b) = arr.as_boolean_opt() {
return b.value(i).to_string();
}
if let Some(v) = crate::numeric::array_value_i64(&arr_to_ref(arr), i) {
return v.to_string();
}
String::new()
}
fn arr_to_ref(arr: &dyn Array) -> arrow_array::ArrayRef {
arrow_array::make_array(arr.to_data())
}
#[cfg(test)]
mod tests {
use super::*;
fn secret(fields: &[(&str, &str)]) -> HashMap<String, String> {
fields
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn secrets(entries: &[(&str, HashMap<String, String>)]) -> Secrets {
Secrets {
by_name: entries
.iter()
.map(|(n, m)| (n.to_string(), m.clone()))
.collect(),
}
}
#[test]
fn for_scope_picks_longest_prefix_match() {
let s = secrets(&[
("s3", secret(&[("key_id", "A"), ("scope", "s3://bucket-a")])),
(
"s3:other",
secret(&[("key_id", "B"), ("scope", "s3://bucket-b\ns3://bucket-b2")]),
),
]);
assert_eq!(
s.field_for("s3://bucket-a/data/x.dat", "key_id").as_deref(),
Some("A")
);
assert_eq!(
s.field_for("s3://bucket-b/x.dat", "key_id").as_deref(),
Some("B")
);
assert_eq!(
s.field_for("s3://bucket-b2/y.dat", "key_id").as_deref(),
Some("B")
);
}
#[test]
fn for_scope_prefers_more_specific_scope() {
let s = secrets(&[
(
"s3",
secret(&[("key_id", "broad"), ("scope", "s3://bucket")]),
),
(
"s3:narrow",
secret(&[("key_id", "narrow"), ("scope", "s3://bucket/data")]),
),
]);
assert_eq!(
s.field_for("s3://bucket/data/x.dat", "key_id").as_deref(),
Some("narrow")
);
assert_eq!(
s.field_for("s3://bucket/other/x.dat", "key_id").as_deref(),
Some("broad")
);
}
#[test]
fn for_scope_falls_back_to_unscoped() {
let s = secrets(&[("s3", secret(&[("key_id", "only")]))]);
assert_eq!(
s.field_for("s3://any/x.dat", "key_id").as_deref(),
Some("only")
);
let s2 = secrets(&[("s3", secret(&[("key_id", "u"), ("scope", "")]))]);
assert_eq!(
s2.field_for("s3://any/x.dat", "key_id").as_deref(),
Some("u")
);
}
#[test]
fn for_scope_none_when_no_match_and_no_fallback() {
let s = secrets(&[("s3", secret(&[("key_id", "A"), ("scope", "s3://bucket-a")]))]);
assert!(s.for_scope("s3://bucket-z/x.dat").is_none());
}
#[test]
fn for_scope_empty_secrets_is_none() {
assert!(Secrets::default().for_scope("s3://b/x").is_none());
}
#[test]
fn type_aware_accessors() {
let s = secrets(&[
(
"my_s3",
secret(&[("type", "s3"), ("key_id", "A"), ("scope", "s3://a")]),
),
(
"my_s3_b",
secret(&[("type", "s3"), ("key_id", "B"), ("scope", "s3://b")]),
),
("my_gcs", secret(&[("type", "gcs"), ("key_id", "G")])),
]);
assert_eq!(s.secret_type("my_s3").as_deref(), Some("s3"));
assert_eq!(s.secret_type("my_gcs").as_deref(), Some("gcs"));
assert_eq!(s.of_type("s3").count(), 2);
assert_eq!(s.of_type("gcs").count(), 1);
assert_eq!(s.of_type("azure").count(), 0);
assert_eq!(
s.for_scope_of_type("s3://b/x.dat", "s3")
.and_then(|m| m.get("key_id"))
.map(String::as_str),
Some("B")
);
let mut names: Vec<&str> = s.iter().map(|(n, _)| n).collect();
names.sort();
assert_eq!(names, vec!["my_gcs", "my_s3", "my_s3_b"]);
}
}