use std::path::{Path, PathBuf};
use chrono::Utc;
use greentic_deploy_spec::{CapabilitySlot, EnvId, EnvPackBinding, SecretRef};
use greentic_secrets_lib::{DevStore, SecretFormat, SecretsStore};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::environment::{EnvFlock, EnvironmentStore, LocalFsStore};
use super::{AuditCtx, AuditGens, OpError, OpFlags, OpOutcome, audit_and_record};
const NOUN: &str = "secrets";
const DEV_STORE_KIND_PATH: &str = "greentic.secrets.dev-store";
const DEV_SECRETS_PATH_ENV: &str = "GREENTIC_DEV_SECRETS_PATH";
const DEV_STORE_RELATIVE: &str = ".greentic/dev/.dev.secrets.env";
const DEV_STORE_STATE_RELATIVE: &str = ".greentic/state/dev/.dev.secrets.env";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretsListPayload {
pub environment_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretsPutPayload {
pub environment_id: String,
pub path: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretsGetPayload {
pub environment_id: String,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretsRotatePayload {
pub environment_id: String,
pub path: String,
}
pub fn list(
store: &LocalFsStore,
flags: &OpFlags,
payload: Option<SecretsListPayload>,
) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(NOUN, "list", list_schema()));
}
let payload = resolve_payload::<SecretsListPayload>(flags, payload)?;
let env_id = parse_env_id(&payload.environment_id)?;
let env = store.load(&env_id)?;
let secrets = require_secrets_pack(&env, &env_id)?;
let mut known_refs: Vec<String> = env
.credentials_ref
.as_ref()
.map(|c| c.as_str().to_string())
.into_iter()
.collect();
if let Some(bs) = env
.bundles
.iter()
.map(|b| b.authorization_ref.to_string_lossy().into_owned())
.next()
{
known_refs.push(format!("auth://{bs}"));
}
Ok(OpOutcome::new(
NOUN,
"list",
json!({
"environment_id": env_id.as_str(),
"secrets_kind": secrets.kind.to_string(),
"namespace": format!("secret://{}/", env_id.as_str()),
"known_refs": known_refs,
"snapshot_at": Utc::now(),
"note": "Phase A: namespace + known-refs only; live backend enumeration lands in A9.",
}),
))
}
pub fn put(
store: &LocalFsStore,
flags: &OpFlags,
payload: Option<SecretsPutPayload>,
) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(NOUN, "put", put_schema()));
}
let payload = resolve_payload::<SecretsPutPayload>(flags, payload)?;
let env_id = parse_env_id(&payload.environment_id)?;
let ctx = AuditCtx {
env_id: env_id.clone(),
noun: NOUN,
verb: "put",
target: json!({"path": payload.path}),
idempotency_key: None,
};
audit_and_record(store, ctx, |_committed| {
let env = store.load(&env_id)?;
let secrets = require_secrets_pack(&env, &env_id)?;
let rel_path = payload.path.trim_start_matches('/');
let secret_uri = format!("secret://{}/{rel_path}", env_id.as_str());
SecretRef::try_new(secret_uri.clone())
.map_err(|e| OpError::InvalidArgument(format!("secret path: {e}")))?;
if payload.value.is_empty() {
return Err(OpError::InvalidArgument(
"value must not be empty".to_string(),
));
}
if secrets.kind.path() != DEV_STORE_KIND_PATH {
return Err(OpError::NotYetImplemented(
"secrets backend dispatch beyond the dev-store lands in A9 (env-pack registry)",
));
}
let mut segs = rel_path.splitn(5, '/');
let (Some(tenant), Some(team), Some(pack), Some(name), None) = (
segs.next(),
segs.next(),
segs.next(),
segs.next(),
segs.next(),
) else {
return Err(OpError::InvalidArgument(format!(
"dev-store secret path must be `<tenant>/<team>/<pack>/<name>` \
(e.g. `default/_/messaging-telegram/telegram_bot_token`); \
got `{rel_path}`"
)));
};
if [tenant, team, pack, name].iter().any(|s| s.is_empty()) {
return Err(OpError::InvalidArgument(format!(
"dev-store secret path must be `<tenant>/<team>/<pack>/<name>` \
(e.g. `default/_/messaging-telegram/telegram_bot_token`); \
got `{rel_path}`"
)));
}
if !is_canonical_team(team) {
return Err(OpError::InvalidArgument(format!(
"team segment `{team}` is not store-canonical: the runtime \
reads the default team as `_` — pass `_` (or a real team \
name without surrounding whitespace)"
)));
}
if !is_canonical_secret_name(name) {
return Err(OpError::InvalidArgument(format!(
"secret name `{name}` is not store-canonical: use lowercase \
a-z, 0-9 and single `_` separators (no leading/trailing `_`)"
)));
}
let store_uri = format!("secrets://{}/{rel_path}", env_id.as_str());
let dev_path = resolve_dev_store_path(
&store.env_dir(&env_id)?,
std::env::var_os(DEV_SECRETS_PATH_ENV).map(PathBuf::from),
);
dev_store_put(&dev_path, &store_uri, &payload.value)?;
Ok((
OpOutcome::new(
NOUN,
"put",
json!({
"environment_id": env_id.as_str(),
"secret_ref": secret_uri,
"store_uri": store_uri,
"secrets_kind": secrets.kind.to_string(),
"store_path": dev_path.display().to_string(),
"written": true,
}),
),
AuditGens::NONE,
))
})
}
pub fn get(
store: &LocalFsStore,
flags: &OpFlags,
payload: Option<SecretsGetPayload>,
) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(NOUN, "get", get_schema()));
}
let payload = resolve_payload::<SecretsGetPayload>(flags, payload)?;
let env_id = parse_env_id(&payload.environment_id)?;
let env = store.load(&env_id)?;
let _secrets = require_secrets_pack(&env, &env_id)?;
SecretRef::try_new(format!(
"secret://{}/{}",
env_id.as_str(),
payload.path.trim_start_matches('/')
))
.map_err(|e| OpError::InvalidArgument(format!("secret path: {e}")))?;
Err(OpError::NotYetImplemented(
"secrets backend dispatch lands in A9 (env-pack registry); A3 wires the surface only",
))
}
pub fn rotate(
store: &LocalFsStore,
flags: &OpFlags,
payload: Option<SecretsRotatePayload>,
) -> Result<OpOutcome, OpError> {
if flags.schema_only {
return Ok(OpOutcome::new(NOUN, "rotate", rotate_schema()));
}
let payload = resolve_payload::<SecretsRotatePayload>(flags, payload)?;
let env_id = parse_env_id(&payload.environment_id)?;
let ctx = AuditCtx {
env_id: env_id.clone(),
noun: NOUN,
verb: "rotate",
target: json!({"path": payload.path}),
idempotency_key: None,
};
audit_and_record(store, ctx, |_committed| {
let env = store.load(&env_id)?;
let _secrets = require_secrets_pack(&env, &env_id)?;
SecretRef::try_new(format!(
"secret://{}/{}",
env_id.as_str(),
payload.path.trim_start_matches('/')
))
.map_err(|e| OpError::InvalidArgument(format!("secret path: {e}")))?;
Err(OpError::NotYetImplemented(
"secret rotation depends on backend-specific rotate hooks; lands in A9",
))
})
}
fn resolve_dev_store_path(env_dir: &Path, override_path: Option<PathBuf>) -> PathBuf {
if let Some(path) = override_path {
return path;
}
let primary = env_dir.join(DEV_STORE_RELATIVE);
if primary.exists() {
return primary;
}
let fallback = env_dir.join(DEV_STORE_STATE_RELATIVE);
if fallback.exists() {
return fallback;
}
primary
}
fn is_canonical_team(team: &str) -> bool {
crate::runtime_secrets::canonical_team(Some(team)) == team
}
fn is_canonical_secret_name(name: &str) -> bool {
crate::runtime_secrets::canonical_secret_name(name) == name
}
fn dev_store_put(path: &Path, uri: &str, value: &str) -> Result<(), OpError> {
let io_err = |message: String| OpError::Io {
path: path.to_path_buf(),
source: std::io::Error::other(message),
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| OpError::Io {
path: parent.to_path_buf(),
source,
})?;
}
let _write_lock = EnvFlock::acquire(&dev_store_lock_path(path))
.map_err(|source| OpError::Store(source.into()))?;
let store = DevStore::with_path(path.to_path_buf())
.map_err(|e| io_err(format!("open dev store: {e}")))?;
std::thread::scope(|scope| {
scope
.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| io_err(format!("build runtime: {e}")))?
.block_on(store.put(uri, SecretFormat::Text, value.as_bytes()))
.map_err(|e| io_err(format!("dev store write: {e}")))
})
.join()
.expect("dev-store write thread panicked")
})
}
fn dev_store_lock_path(store_path: &Path) -> PathBuf {
let mut lock = store_path.as_os_str().to_os_string();
lock.push(".lock");
PathBuf::from(lock)
}
fn resolve_payload<T: serde::de::DeserializeOwned>(
flags: &OpFlags,
payload: Option<T>,
) -> Result<T, OpError> {
if let Some(p) = payload {
return Ok(p);
}
if let Some(path) = &flags.answers {
return super::load_answers::<T>(path);
}
Err(OpError::InvalidArgument(
"no payload provided: pass --answers <path> or supply the payload directly".to_string(),
))
}
fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
}
fn require_secrets_pack<'a>(
env: &'a greentic_deploy_spec::Environment,
env_id: &EnvId,
) -> Result<&'a EnvPackBinding, OpError> {
env.pack_for_slot(CapabilitySlot::Secrets).ok_or_else(|| {
OpError::Conflict(format!(
"env `{env_id}` has no secrets env-pack bound; bind one with `op env-packs add` first"
))
})
}
fn list_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SecretsListPayload",
"type": "object",
"required": ["environment_id"],
"additionalProperties": false,
"properties": {"environment_id": {"type": "string"}}
})
}
fn put_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SecretsPutPayload",
"type": "object",
"required": ["environment_id", "path", "value"],
"additionalProperties": false,
"properties": {
"environment_id": {"type": "string"},
"path": {"type": "string", "description": "Relative path under secret://<env>/. For the dev-store backend: <tenant>/<team>/<pack>/<name> (e.g. default/_/messaging-telegram/telegram_bot_token). Use `_` for the default team — a literal `default` team is rejected (the runtime reads the default team as `_`)."},
"value": {"type": "string"}
}
})
}
fn get_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SecretsGetPayload",
"type": "object",
"required": ["environment_id", "path"],
"additionalProperties": false,
"properties": {
"environment_id": {"type": "string"},
"path": {"type": "string"}
}
})
}
fn rotate_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SecretsRotatePayload",
"type": "object",
"required": ["environment_id", "path"],
"additionalProperties": false,
"properties": {
"environment_id": {"type": "string"},
"path": {"type": "string"}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::tests_common::{make_binding, make_env};
use tempfile::tempdir;
fn env_with_secrets() -> greentic_deploy_spec::Environment {
env_with_secrets_kind("greentic.secrets.dev-store@1.0.0")
}
#[test]
fn list_reports_namespace_and_kind() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&env_with_secrets()).unwrap();
let outcome = list(
&store,
&OpFlags::default(),
Some(SecretsListPayload {
environment_id: "local".to_string(),
}),
)
.unwrap();
assert_eq!(
outcome.result.get("secrets_kind").and_then(|v| v.as_str()),
Some("greentic.secrets.dev-store@1.0.0")
);
assert_eq!(
outcome.result.get("namespace").and_then(|v| v.as_str()),
Some("secret://local/")
);
}
#[test]
fn list_rejects_env_without_secrets_pack() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&make_env("local")).unwrap();
let err = list(
&store,
&OpFlags::default(),
Some(SecretsListPayload {
environment_id: "local".to_string(),
}),
)
.unwrap_err();
assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
}
fn env_with_secrets_kind(kind: &str) -> greentic_deploy_spec::Environment {
let mut env = make_env("local");
env.packs.push(make_binding(CapabilitySlot::Secrets, kind));
env
}
fn read_back(store_path: &str, uri: &str) -> Vec<u8> {
let dev = DevStore::with_path(PathBuf::from(store_path)).unwrap();
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async { dev.get(uri).await.unwrap() })
}
#[test]
fn put_non_dev_store_backend_returns_not_yet_implemented() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store
.save(&env_with_secrets_kind("greentic.secrets.aws-sm@1.0.0"))
.unwrap();
let err = put(
&store,
&OpFlags::default(),
Some(SecretsPutPayload {
environment_id: "local".to_string(),
path: "credentials/aws".to_string(),
value: "secret-material".to_string(),
}),
)
.unwrap_err();
assert!(matches!(err, OpError::NotYetImplemented(_)), "got {err:?}");
}
#[test]
fn put_writes_through_to_env_dev_store() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&env_with_secrets()).unwrap();
let outcome = put(
&store,
&OpFlags::default(),
Some(SecretsPutPayload {
environment_id: "local".to_string(),
path: "default/_/messaging-telegram/telegram_bot_token".to_string(),
value: "tok-dummy-123".to_string(),
}),
)
.unwrap();
let result = &outcome.result;
assert_eq!(
result.get("store_uri").and_then(|v| v.as_str()),
Some("secrets://local/default/_/messaging-telegram/telegram_bot_token")
);
assert_eq!(result.get("written").and_then(|v| v.as_bool()), Some(true));
let envelope = serde_json::to_string(&outcome).unwrap();
assert!(!envelope.contains("tok-dummy-123"));
let store_path = result
.get("store_path")
.and_then(|v| v.as_str())
.expect("store_path in outcome");
let bytes = read_back(
store_path,
"secrets://local/default/_/messaging-telegram/telegram_bot_token",
);
assert_eq!(bytes, b"tok-dummy-123".to_vec());
}
#[test]
fn put_rejects_default_team_segment() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&env_with_secrets()).unwrap();
for team in ["default", "Default", "DEFAULT"] {
let err = put(
&store,
&OpFlags::default(),
Some(SecretsPutPayload {
environment_id: "local".to_string(),
path: format!("acme/{team}/messaging-telegram/telegram_bot_token"),
value: "tok-dummy".to_string(),
}),
)
.unwrap_err();
assert!(
matches!(&err, OpError::InvalidArgument(msg) if msg.contains('_')),
"team `{team}` got {err:?}"
);
}
}
#[test]
fn concurrent_puts_do_not_lose_writes() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&env_with_secrets()).unwrap();
let names: Vec<String> = (0..8).map(|i| format!("concurrent_key_{i}")).collect();
let store = &store;
std::thread::scope(|scope| {
for name in &names {
scope.spawn(move || {
let outcome = put(
store,
&OpFlags::default(),
Some(SecretsPutPayload {
environment_id: "local".to_string(),
path: format!("default/_/demo-pack/{name}"),
value: format!("value-{name}"),
}),
)
.unwrap();
assert_eq!(
outcome.result.get("written").and_then(|v| v.as_bool()),
Some(true)
);
});
}
});
let store_path = dir
.path()
.join("local")
.join(DEV_STORE_RELATIVE)
.display()
.to_string();
for name in &names {
let bytes = read_back(
&store_path,
&format!("secrets://local/default/_/demo-pack/{name}"),
);
assert_eq!(bytes, format!("value-{name}").into_bytes());
}
}
#[test]
fn dev_store_lock_path_is_sidecar() {
assert_eq!(
dev_store_lock_path(Path::new("/x/.greentic/dev/.dev.secrets.env")),
Path::new("/x/.greentic/dev/.dev.secrets.env.lock")
);
assert_eq!(
dev_store_lock_path(Path::new("state/dev-store.dat")),
Path::new("state/dev-store.dat.lock")
);
}
#[test]
fn put_rejects_non_canonical_name_segment() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&env_with_secrets()).unwrap();
let err = put(
&store,
&OpFlags::default(),
Some(SecretsPutPayload {
environment_id: "local".to_string(),
path: "default/_/messaging-telegram/TELEGRAM-BOT-TOKEN".to_string(),
value: "tok-dummy".to_string(),
}),
)
.unwrap_err();
assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
}
#[test]
fn put_rejects_wrong_depth_path() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&env_with_secrets()).unwrap();
for path in ["credentials/aws", "default/_/pack/extra/name", "a//b/c"] {
let err = put(
&store,
&OpFlags::default(),
Some(SecretsPutPayload {
environment_id: "local".to_string(),
path: path.to_string(),
value: "v".to_string(),
}),
)
.unwrap_err();
assert!(
matches!(&err, OpError::InvalidArgument(msg) if msg.contains("<tenant>/<team>/<pack>/<name>")),
"path `{path}` got {err:?}"
);
}
}
#[test]
fn resolve_dev_store_path_override_wins() {
let dir = tempdir().unwrap();
let override_path = dir.path().join("custom.dat");
assert_eq!(
resolve_dev_store_path(dir.path(), Some(override_path.clone())),
override_path
);
}
#[test]
fn resolve_dev_store_path_prefers_existing_candidate() {
let dir = tempdir().unwrap();
let fallback = dir.path().join(DEV_STORE_STATE_RELATIVE);
std::fs::create_dir_all(fallback.parent().unwrap()).unwrap();
std::fs::write(&fallback, b"").unwrap();
assert_eq!(resolve_dev_store_path(dir.path(), None), fallback);
let primary = dir.path().join(DEV_STORE_RELATIVE);
std::fs::create_dir_all(primary.parent().unwrap()).unwrap();
std::fs::write(&primary, b"").unwrap();
assert_eq!(resolve_dev_store_path(dir.path(), None), primary);
}
#[test]
fn resolve_dev_store_path_defaults_to_primary() {
let dir = tempdir().unwrap();
assert_eq!(
resolve_dev_store_path(dir.path(), None),
dir.path().join(DEV_STORE_RELATIVE)
);
}
#[test]
fn canonical_name_fixed_points() {
assert!(is_canonical_secret_name("telegram_bot_token"));
assert!(is_canonical_secret_name("a1"));
assert!(!is_canonical_secret_name(""));
assert!(!is_canonical_secret_name("TELEGRAM_BOT_TOKEN"));
assert!(!is_canonical_secret_name("bot-token"));
assert!(!is_canonical_secret_name("_leading"));
assert!(!is_canonical_secret_name("trailing_"));
assert!(!is_canonical_secret_name("double__underscore"));
}
#[test]
fn put_rejects_empty_value() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&env_with_secrets()).unwrap();
let err = put(
&store,
&OpFlags::default(),
Some(SecretsPutPayload {
environment_id: "local".to_string(),
path: "x".to_string(),
value: "".to_string(),
}),
)
.unwrap_err();
assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
}
#[test]
fn get_yields_not_yet_implemented_after_path_validation() {
let dir = tempdir().unwrap();
let store = LocalFsStore::new(dir.path());
store.save(&env_with_secrets()).unwrap();
let err = get(
&store,
&OpFlags::default(),
Some(SecretsGetPayload {
environment_id: "local".to_string(),
path: "credentials/aws".to_string(),
}),
)
.unwrap_err();
assert!(matches!(err, OpError::NotYetImplemented(_)), "got {err:?}");
}
}