use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::time::Duration;
use shep_core::config::DaemonConfig;
use shep_core::paths::ShepPaths;
use shep_core::protocol::ProcessInfo;
use shep_core::secrets::{self, ALL_ENVIRONMENTS};
use crate::commands::query::read_roll;
use crate::secret_readers::{self, Reader};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Source {
Operator,
Namespace(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SecretRow {
pub key: String,
pub source: Source,
pub in_force: Option<String>,
pub set_in: Vec<String>,
pub byte_len: Option<usize>,
pub readers: Vec<Reader>,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct SecretsModel {
pub environments: Vec<String>,
pub rows: Vec<SecretRow>,
pub unreadable: Option<String>,
pub roll_age: Option<Duration>,
pub allow_read: bool,
pub store: PathBuf,
pub provider_cache: PathBuf,
}
impl SecretsModel {
pub fn rows_for<'a>(&'a self, source: &'a Source) -> impl Iterator<Item = &'a SecretRow> {
self.rows.iter().filter(move |row| &row.source == source)
}
}
pub(crate) fn model(paths: &ShepPaths, procs: &[ProcessInfo], environment: &str) -> SecretsModel {
let (store, unreadable) = match secrets::all(&paths.secrets) {
Ok(store) => (store, None),
Err(error) => (BTreeMap::new(), Some(error.to_string())),
};
let providers = secrets::provider_cache_on_disk(&paths.secrets_cache);
let roll = read_roll(paths);
let readers = secret_readers::by_reference(paths, roll.as_ref(), procs);
let mut environments: BTreeSet<String> = BTreeSet::new();
environments.insert(ALL_ENVIRONMENTS.to_string());
for slots in store.values() {
environments.extend(slots.keys().cloned());
}
for (_, keys) in providers.namespaces() {
for slots in keys.values() {
environments.extend(slots.keys().cloned());
}
}
let mut rows = Vec::new();
for (key, slots) in &store {
rows.push(row(
key.clone(),
Source::Operator,
slots,
environment,
&readers,
));
}
for (namespace, keys) in providers.namespaces() {
for (key, slots) in keys {
let qualified = format!("{namespace}/{key}");
rows.push(row(
qualified,
Source::Namespace(namespace.clone()),
slots,
environment,
&readers,
));
}
}
let shep_toml = std::fs::read_to_string(&paths.daemon_config).ok();
let allow_read = DaemonConfig::load(shep_toml.as_deref(), &|_| None)
.is_ok_and(|config| config.secrets.allow_read);
SecretsModel {
environments: environments.into_iter().collect(),
rows,
unreadable,
roll_age: roll.as_ref().map(secret_readers::roll_age),
allow_read,
store: paths.secrets.clone(),
provider_cache: paths.secrets_cache.clone(),
}
}
pub(crate) fn stored_value(store: &Path, provider_cache: &Path, row: &SecretRow) -> Option<String> {
let environment = row.in_force.as_deref()?;
match &row.source {
Source::Operator => secrets::get(store, &row.key, environment).ok().flatten(),
Source::Namespace(namespace) => {
let bare = row.key.strip_prefix(namespace)?.strip_prefix('/')?;
secrets::provider_cache_on_disk(provider_cache)
.namespace(namespace)?
.get(bare)?
.get(environment)
.cloned()
}
}
}
fn row(
key: String,
source: Source,
slots: &BTreeMap<String, String>,
environment: &str,
readers: &BTreeMap<String, Vec<Reader>>,
) -> SecretRow {
let in_force = if slots.contains_key(environment) {
Some(environment.to_string())
} else if slots.contains_key(ALL_ENVIRONMENTS) {
Some(ALL_ENVIRONMENTS.to_string())
} else {
None
};
let byte_len = in_force
.as_ref()
.and_then(|slot| slots.get(slot))
.map(String::len);
SecretRow {
byte_len,
in_force,
set_in: slots.keys().cloned().collect(),
readers: readers.get(&key).cloned().unwrap_or_default(),
key,
source,
}
}
#[cfg(test)]
mod tests {
use shep_core::config::AppConfig;
use shep_core::secrets::{PROVIDER_CACHE_VERSION, Resolution, SecretRef, SecretView};
use crate::secret_readers::test_support::{online, paths_under, write_roll};
use super::*;
#[test]
fn in_force_agrees_with_secret_view_resolve() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
secrets::set(&paths.secrets, "EXACT", "production", "a").unwrap();
secrets::set(&paths.secrets, "FALLBACK", ALL_ENVIRONMENTS, "b").unwrap();
secrets::set(&paths.secrets, "ELSEWHERE", "ci", "c").unwrap();
secrets::set(&paths.secrets, "BOTH", "production", "exact").unwrap();
secrets::set(&paths.secrets, "BOTH", ALL_ENVIRONMENTS, "fallback").unwrap();
let built = model(&paths, &[], "production");
let store = secrets::all(&paths.secrets).unwrap();
let view = SecretView::new(
"production".to_string(),
store,
secrets::provider_cache_on_disk(&paths.secrets_cache),
);
for row in &built.rows {
let reference = SecretRef::parse(&row.key).unwrap();
let resolved = matches!(view.resolve(&reference), Resolution::Found(_));
assert_eq!(
row.in_force.is_some(),
resolved,
"{} disagreed with resolve",
row.key
);
}
let by_key = |key: &str| {
built
.rows
.iter()
.find(|row| row.key == key)
.unwrap_or_else(|| panic!("{key} missing"))
};
assert_eq!(by_key("EXACT").in_force.as_deref(), Some("production"));
assert_eq!(
by_key("FALLBACK").in_force.as_deref(),
Some(ALL_ENVIRONMENTS)
);
assert_eq!(by_key("ELSEWHERE").in_force, None);
assert_eq!(
by_key("BOTH").in_force.as_deref(),
Some("production"),
"a key with both slots takes the exact environment, never `all`"
);
assert_eq!(by_key("BOTH").byte_len, Some("exact".len()));
}
#[test]
fn a_named_environment_never_falls_back_to_a_sibling() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
secrets::set(&paths.secrets, "K", "production", "live").unwrap();
let built = model(&paths, &[], "staging");
let row = built.rows.iter().find(|row| row.key == "K").unwrap();
assert_eq!(row.in_force, None, "staging must not borrow production's");
assert_eq!(row.set_in, ["production"]);
}
#[test]
fn set_in_lists_every_environment_the_key_has_a_slot_for() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
secrets::set(&paths.secrets, "K", ALL_ENVIRONMENTS, "x").unwrap();
secrets::set(&paths.secrets, "K", "ci", "y").unwrap();
let built = model(&paths, &[], "production");
let row = built.rows.iter().find(|row| row.key == "K").unwrap();
assert_eq!(row.set_in, [ALL_ENVIRONMENTS, "ci"]);
}
#[test]
fn environments_are_the_union_of_the_store_plus_all() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
secrets::set(&paths.secrets, "A", "production", "x").unwrap();
secrets::set(&paths.secrets, "B", "ci", "y").unwrap();
let built = model(&paths, &[], "production");
assert_eq!(built.environments, [ALL_ENVIRONMENTS, "ci", "production"]);
}
#[test]
fn a_pushed_environment_the_store_does_not_name_is_still_an_environment() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
secrets::set(&paths.secrets, "K", "production", "x").unwrap();
std::fs::write(
&paths.secrets_cache,
format!(
r#"{{"version":{PROVIDER_CACHE_VERSION},"namespaces":{{"vercel":{{"API_TOKEN":{{"preview":"tok","production":"tok","staging":"tok"}}}}}},"pushed":{{"vercel":["preview","production","staging"]}}}}"#
),
)
.unwrap();
let built = model(&paths, &[], "production");
assert_eq!(
built.environments,
[ALL_ENVIRONMENTS, "preview", "production", "staging"]
);
let row = built
.rows
.iter()
.find(|row| row.key == "vercel/API_TOKEN")
.unwrap();
assert!(
row.set_in.len() <= built.environments.len(),
"SET IN would print `{} of {}`",
row.set_in.len(),
built.environments.len()
);
}
#[test]
fn an_unreadable_store_reports_rather_than_reading_as_empty() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
std::fs::write(&paths.secrets, "{\"version\":9999,\"entries\":{}}").unwrap();
let built = model(&paths, &[], "production");
assert!(built.rows.is_empty());
assert!(
built
.unreadable
.as_deref()
.is_some_and(|m| m.contains("9999")),
"got {:?}",
built.unreadable
);
}
#[test]
fn byte_len_is_the_values_length_and_never_the_value() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
secrets::set(&paths.secrets, "K", "production", "hunter2").unwrap();
let built = model(&paths, &[], "production");
let row = built.rows.iter().find(|row| row.key == "K").unwrap();
assert_eq!(row.byte_len, Some(7));
assert!(
!format!("{row:?}").contains("hunter2"),
"the row must not carry the value: {row:?}"
);
}
#[test]
fn a_provider_row_is_qualified_and_sourced_by_its_namespace() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
std::fs::write(
&paths.secrets_cache,
format!(
r#"{{"version":{PROVIDER_CACHE_VERSION},"namespaces":{{"vercel":{{"API_TOKEN":{{"production":"tok"}}}}}},"pushed":{{"vercel":["production"]}}}}"#
),
)
.unwrap();
let built = model(&paths, &[], "production");
let row = built
.rows
.iter()
.find(|row| row.key == "vercel/API_TOKEN")
.unwrap_or_else(|| panic!("no qualified row among {:?}", built.rows));
assert_eq!(row.source, Source::Namespace("vercel".to_string()));
}
#[test]
fn stored_value_reads_an_operator_row_and_a_provider_row_out_of_their_own_stores() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
secrets::set(&paths.secrets, "PLAIN", "production", "hunter2").unwrap();
std::fs::write(
&paths.secrets_cache,
format!(
r#"{{"version":{PROVIDER_CACHE_VERSION},"namespaces":{{"vercel":{{"API_TOKEN":{{"production":"tok"}}}}}},"pushed":{{"vercel":["production"]}}}}"#
),
)
.unwrap();
let built = model(&paths, &[], "production");
let value_of = |key: &str| {
let row = built
.rows
.iter()
.find(|row| row.key == key)
.unwrap_or_else(|| panic!("no {key} among {:?}", built.rows));
stored_value(&built.store, &built.provider_cache, row)
};
assert_eq!(value_of("PLAIN").as_deref(), Some("hunter2"));
assert_eq!(value_of("vercel/API_TOKEN").as_deref(), Some("tok"));
}
#[test]
fn stored_value_is_none_when_nothing_resolves_in_this_tab() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
secrets::set(&paths.secrets, "PLAIN", "ci", "hunter2").unwrap();
let built = model(&paths, &[], "production");
let row = built.rows.iter().find(|row| row.key == "PLAIN").unwrap();
assert_eq!(row.in_force, None);
assert_eq!(stored_value(&built.store, &built.provider_cache, row), None);
}
#[test]
fn a_readers_key_must_match_the_row_key_operator_and_provider_alike() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
secrets::set(&paths.secrets, "PLAIN", ALL_ENVIRONMENTS, "a").unwrap();
std::fs::write(
&paths.secrets_cache,
format!(
r#"{{"version":{PROVIDER_CACHE_VERSION},"namespaces":{{"vercel":{{"API_TOKEN":{{"all":"tok"}}}}}},"pushed":{{"vercel":["all"]}}}}"#
),
)
.unwrap();
let mut operator_app = AppConfig::minimal("operator-app", "./srv");
operator_app
.env
.insert("A".into(), "{{secret:PLAIN}}".into());
let mut provider_app = AppConfig::minimal("provider-app", "./srv");
provider_app
.env
.insert("B".into(), "{{secret:vercel/API_TOKEN}}".into());
write_roll(&paths, &[operator_app, provider_app]);
let procs = [online("operator-app"), online("provider-app")];
let built = model(&paths, &procs, "production");
let row_named = |key: &str| built.rows.iter().find(|row| row.key == key).unwrap();
assert_eq!(row_named("PLAIN").readers.len(), 1);
assert_eq!(
row_named("vercel/API_TOKEN").readers.len(),
1,
"a provider row's key must match the reference by_reference stored, or \
every provider row silently shows no readers"
);
}
#[test]
fn the_model_carries_the_roll_age_from_the_same_read_as_the_readers() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
secrets::set(&paths.secrets, "K", ALL_ENVIRONMENTS, "v").unwrap();
let mut app = AppConfig::minimal("web", "./srv");
app.env.insert("PW".into(), "{{secret:K}}".into());
write_roll(&paths, &[app]);
let with_roll = model(&paths, &[online("web")], ALL_ENVIRONMENTS);
assert!(
with_roll.roll_age.is_some(),
"a roll on disk must still produce an age now that it arrives by the readers' read"
);
assert_eq!(
with_roll
.rows
.iter()
.find(|row| row.key == "K")
.map(|row| row.readers.len()),
Some(1),
"the one read must serve the readers too"
);
std::fs::remove_file(&paths.snapshot).unwrap();
let without_roll = model(&paths, &[online("web")], ALL_ENVIRONMENTS);
assert!(
without_roll.roll_age.is_none(),
"no roll is no age, not a stale one"
);
}
#[test]
fn a_shep_toml_that_allows_reads_opens_the_reveal_gate_in_the_model() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
std::fs::write(&paths.daemon_config, "[secrets]\nallow_read = true\n").unwrap();
let built = model(&paths, &[], "production");
assert!(
built.allow_read,
"`allow_read = true` in $SHEP_HOME/shep.toml must reach the pane, which is \
what a reveal asks before showing a value"
);
}
#[test]
fn no_shep_toml_leaves_the_reveal_gate_shut() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
assert!(
!paths.daemon_config.exists(),
"this test is about the operator who never wrote a shep.toml"
);
let built = model(&paths, &[], "production");
assert!(
!built.allow_read,
"a missing shep.toml must read as a shut gate, not as the default the \
config happens to start with"
);
}
#[test]
fn an_unparsable_shep_toml_leaves_the_reveal_gate_shut() {
let dir = tempfile::tempdir().unwrap();
let paths = paths_under(dir.path());
std::fs::write(&paths.daemon_config, "[secrets\nallow_read = true\n").unwrap();
let built = model(&paths, &[], "production");
assert!(
!built.allow_read,
"a shep.toml the model cannot parse must shut the gate, got {:?}",
built.allow_read
);
}
}