use age::{secrecy::ExposeSecret, x25519};
use anyhow::{Context, Result, anyhow, bail};
use std::{io::Write, path::Path, sync::Arc};
use tracing::{info, warn};
use ulid::Ulid;
use crate::platform::config::SecretsConfig;
use crate::store::Db;
use crate::store::infra::model::{InfraState, ProjectInfra, SecretInfra, ServiceInfra};
use shared::{EnvVar, EnvVarValue};
use crate::platform::age_format;
pub use crate::platform::age_format::is_enc;
pub struct SecretsManager {
identity: x25519::Identity,
recovery: Option<x25519::Recipient>,
db: Arc<Db>,
}
impl SecretsManager {
pub fn open(
key_file: &Path,
db: Arc<Db>,
cfg: &SecretsConfig,
config_path: Option<&Path>,
) -> Result<Self> {
let identity = match cfg.age_identity.as_deref().filter(|s| !s.trim().is_empty()) {
Some(text) => parse_identity(text).context("[secrets] age_identity inválida")?,
None if key_file.exists() => parse_identity(&std::fs::read_to_string(key_file)?)
.with_context(|| format!("{} inválido", key_file.display()))?,
None => {
if has_enc_values(&db) {
bail!(
"há segredos cifrados (ENC[age:…]) mas nenhuma identidade age foi encontrada \
(nem em [secrets] age_identity, nem em {}). Restaure a identidade; gerar uma \
nova tornaria esses segredos ilegíveis.",
key_file.display()
);
}
let fresh = x25519::Identity::generate();
persist_identity(&fresh, config_path, key_file)?;
fresh
}
};
let recovery = cfg
.recovery_recipient
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| {
age_format::parse_recipient(s)
.map_err(|e| anyhow!("[secrets] recovery_recipient inválida: {e}"))
})
.transpose()?;
Ok(Self {
identity,
recovery,
db,
})
}
#[cfg(test)]
pub fn new(db: Arc<Db>) -> Result<Self> {
Ok(Self {
identity: x25519::Identity::generate(),
recovery: None,
db,
})
}
#[cfg(test)]
pub fn with_keys(
db: Arc<Db>,
identity: x25519::Identity,
recovery: Option<x25519::Recipient>,
) -> Result<Self> {
Ok(Self {
identity,
recovery,
db,
})
}
pub fn has_recovery(&self) -> bool {
self.recovery.is_some()
}
pub fn public_key(&self) -> String {
self.identity.to_public().to_string()
}
pub fn encrypt(&self, plaintext: &str) -> Result<String> {
let mut recipients = vec![self.identity.to_public()];
recipients.extend(self.recovery.clone());
age_format::encrypt(plaintext, &recipients)
}
pub fn decrypt(&self, value: &str) -> Result<String> {
if !is_enc(value) {
bail!("valor não está no formato ENC[age:…]");
}
age_format::decrypt(value, &self.identity).context("com a identidade deste servidor")
}
pub async fn get_raw(&self, project_id: &str, name: &str) -> Result<String> {
self.get_scoped(project_id, None, name).await
}
pub async fn get_scoped(
&self,
project_id: &str,
service_id: Option<&str>,
name: &str,
) -> Result<String> {
let st = self.db.infra().snapshot();
let project = st.projects.iter().find(|p| p.id == project_id);
let found = match service_id {
None => project.and_then(|p| p.secrets.iter().find(|s| s.key == name)),
Some(sid) => project
.and_then(|p| p.services.iter().find(|s| s.id == sid))
.and_then(|s| s.secrets.iter().find(|x| x.key == name)),
};
let encrypted = found
.map(|s| s.value.clone())
.ok_or_else(|| match service_id {
None => anyhow!("secret '{name}' not found in project '{project_id}'"),
Some(sid) => anyhow!("secret '{name}' not found in service '{sid}'"),
})?;
self.decrypt(&encrypted)
}
pub async fn resolve(
&self,
project_id: &str,
service_id: Option<&str>,
name: &str,
) -> Result<String> {
if let Some(sid) = service_id
&& let Ok(v) = self.get_scoped(project_id, Some(sid), name).await
{
return Ok(v);
}
self.get_scoped(project_id, None, name).await
}
pub async fn set(&self, project_id: &str, name: &str, value: &str) -> Result<()> {
self.set_scoped(project_id, None, name, value).await
}
pub async fn set_scoped(
&self,
project_id: &str,
service_id: Option<&str>,
name: &str,
value: &str,
) -> Result<()> {
let encrypted = self.encrypt(value)?;
self.db.infra().update(|st| {
let list = secret_list_mut(st, project_id, service_id)?;
match list.iter_mut().find(|s| s.key == name) {
Some(s) => s.value = encrypted,
None => list.push(new_secret(name, encrypted)),
}
Ok(())
})
}
pub async fn delete_scoped(
&self,
project_id: &str,
service_id: Option<&str>,
name: &str,
) -> Result<()> {
self.db.infra().update(|st| {
if let Ok(list) = secret_list_mut(st, project_id, service_id) {
list.retain(|s| s.key != name);
}
Ok(())
})
}
pub async fn list_names(&self, project_id: &str) -> Result<Vec<String>> {
self.list_names_scoped(project_id, None).await
}
pub async fn list_names_scoped(
&self,
project_id: &str,
service_id: Option<&str>,
) -> Result<Vec<String>> {
let st = self.db.infra().snapshot();
let project = st.projects.iter().find(|p| p.id == project_id);
let mut names: Vec<String> = match service_id {
None => project
.map(|p| p.secrets.iter().map(|s| s.key.clone()).collect())
.unwrap_or_default(),
Some(sid) => project
.and_then(|p| p.services.iter().find(|s| s.id == sid))
.map(|s| s.secrets.iter().map(|x| x.key.clone()).collect())
.unwrap_or_default(),
};
names.sort();
Ok(names)
}
pub async fn reveal(
&self,
project_id: &str,
service_id: Option<&str>,
name: &str,
) -> Result<String> {
let value = self.get_scoped(project_id, service_id, name).await?;
sqlx::query(
"INSERT INTO secret_audit (at, action, project_id, service_id, name) VALUES (?, 'reveal', ?, ?, ?)",
)
.bind(chrono::Utc::now())
.bind(project_id)
.bind(service_id)
.bind(name)
.execute(self.db.pool())
.await
.context("gravando a auditoria da revelação")?;
info!(target: "audit", project_id, service_id = ?service_id, secret = name, "secret revelado");
Ok(value)
}
pub async fn move_secret(
&self,
project_id: &str,
service_id: Option<&str>,
name: &str,
op: shared::SecretMoveOp,
) -> Result<()> {
use shared::SecretMoveOp::*;
self.db.infra().update(|st| {
let p = project_mut(st, project_id)?;
match (op, service_id) {
(EnvToSecret, None) => {
let value = take_plain_env(&mut p.env_vars, name, "o projeto")?;
if p.secrets.iter().any(|s| s.key == name) {
bail!("o projeto já tem um secret chamado '{name}'");
}
p.secrets.push(new_secret(name, self.encrypt(&value)?));
set_env(&mut p.env_vars, name, EnvVarValue::Secret(name.to_string()));
}
(EnvToSecret, Some(sid)) => {
let svc = service_mut(p, sid)?;
let value = take_plain_env(&mut svc.spec.env_vars, name, "o serviço")?;
if svc.secrets.iter().any(|s| s.key == name) {
bail!("o serviço já tem um secret chamado '{name}'");
}
svc.secrets.push(new_secret(name, self.encrypt(&value)?));
set_env(
&mut svc.spec.env_vars,
name,
EnvVarValue::Secret(name.to_string()),
);
}
(SecretToEnv, None) => {
let pos = p
.secrets
.iter()
.position(|s| s.key == name)
.ok_or_else(|| anyhow!("o projeto não tem um secret chamado '{name}'"))?;
let plain = self.decrypt(&p.secrets[pos].value)?;
secret_into_env(&mut p.env_vars, name, plain, "o projeto")?;
p.secrets.remove(pos);
}
(SecretToEnv, Some(sid)) => {
let svc = service_mut(p, sid)?;
let pos = svc
.secrets
.iter()
.position(|s| s.key == name)
.ok_or_else(|| anyhow!("o serviço não tem um secret chamado '{name}'"))?;
let plain = self.decrypt(&svc.secrets[pos].value)?;
secret_into_env(&mut svc.spec.env_vars, name, plain, "o serviço")?;
svc.secrets.remove(pos);
}
(ServiceSecretToProject, Some(sid)) => {
let ProjectInfra {
services, secrets, ..
} = p;
let svc = services
.iter_mut()
.find(|s| s.id == sid)
.ok_or_else(|| anyhow!("serviço {sid} não pertence ao projeto"))?;
let pos = svc
.secrets
.iter()
.position(|s| s.key == name)
.ok_or_else(|| anyhow!("o serviço não tem um secret chamado '{name}'"))?;
if secrets.iter().any(|s| s.key == name) {
bail!("o projeto já tem um secret chamado '{name}'");
}
secrets.push(svc.secrets.remove(pos));
}
(ProjectSecretToService, Some(sid)) => {
let ProjectInfra {
services, secrets, ..
} = p;
let svc = services
.iter_mut()
.find(|s| s.id == sid)
.ok_or_else(|| anyhow!("serviço {sid} não pertence ao projeto"))?;
let pos = secrets
.iter()
.position(|s| s.key == name)
.ok_or_else(|| anyhow!("o projeto não tem um secret chamado '{name}'"))?;
if svc.secrets.iter().any(|s| s.key == name) {
bail!("o serviço já tem um secret chamado '{name}'");
}
svc.secrets.push(secrets.remove(pos));
}
(EnvServiceToProject, Some(sid)) => {
let ProjectInfra {
services, env_vars, ..
} = p;
let svc = services
.iter_mut()
.find(|s| s.id == sid)
.ok_or_else(|| anyhow!("serviço {sid} não pertence ao projeto"))?;
let value = take_plain_env(&mut svc.spec.env_vars, name, "o serviço")?;
if env_vars.iter().any(|v| v.key == name) {
bail!("o projeto já tem uma env chamada '{name}'");
}
svc.spec.env_vars.retain(|v| v.key != name);
env_vars.push(EnvVar {
key: name.to_string(),
value: EnvVarValue::Plain(value),
});
}
(EnvProjectToService, Some(sid)) => {
let ProjectInfra {
services, env_vars, ..
} = p;
let svc = services
.iter_mut()
.find(|s| s.id == sid)
.ok_or_else(|| anyhow!("serviço {sid} não pertence ao projeto"))?;
let value = take_plain_env(env_vars, name, "o projeto")?;
if svc.spec.env_vars.iter().any(|v| v.key == name) {
bail!("o serviço já tem uma env chamada '{name}'");
}
env_vars.retain(|v| v.key != name);
svc.spec.env_vars.push(EnvVar {
key: name.to_string(),
value: EnvVarValue::Plain(value),
});
}
(
ServiceSecretToProject
| ProjectSecretToService
| EnvServiceToProject
| EnvProjectToService,
None,
) => {
bail!("esta operação precisa do id do serviço")
}
}
Ok(())
})
}
pub async fn rename_env_key(
&self,
project_id: &str,
service_id: Option<&str>,
from: &str,
to: &str,
) -> Result<()> {
valid_env_key(to)?;
self.db.infra().update(|st| {
let p = project_mut(st, project_id)?;
let (vars, comments, onde) = match service_id {
None => (&mut p.env_vars, &mut p.env_comments, "o projeto"),
Some(sid) => {
let svc = service_mut(p, sid)?;
(
&mut svc.spec.env_vars,
&mut svc.spec.env_comments,
"o serviço",
)
}
};
if from == to {
bail!("o nome novo é igual ao atual ('{from}')");
}
if vars.iter().any(|v| v.key == to) {
bail!("{onde} já tem uma env chamada '{to}'");
}
let var = vars
.iter_mut()
.find(|v| v.key == from)
.ok_or_else(|| anyhow!("{onde} não tem uma env chamada '{from}'"))?;
var.key = to.to_string();
for c in comments.iter_mut() {
if c.before_key.as_deref() == Some(from) {
c.before_key = Some(to.to_string());
}
}
Ok(())
})
}
pub async fn set_env_value(
&self,
project_id: &str,
service_id: Option<&str>,
key: &str,
value: EnvVarValue,
) -> Result<()> {
match &value {
EnvVarValue::Plain(v) if v == "***" => {
bail!("o valor `***` é a máscara de um segredo, não um valor: nada foi gravado")
}
EnvVarValue::Secret(name) => valid_secret_name(name)?,
EnvVarValue::Plain(_) => {}
}
self.db.infra().update(|st| {
let p = project_mut(st, project_id)?;
if let EnvVarValue::Secret(name) = &value {
let no_projeto = p.secrets.iter().any(|s| &s.key == name);
let existe = match service_id {
None => no_projeto,
Some(sid) => {
no_projeto || service_mut(p, sid)?.secrets.iter().any(|s| &s.key == name)
}
};
if !existe {
bail!("não existe um secret chamado '{name}' neste escopo");
}
}
let (vars, onde) = match service_id {
None => (&mut p.env_vars, "o projeto"),
Some(sid) => (&mut service_mut(p, sid)?.spec.env_vars, "o serviço"),
};
let var = vars
.iter_mut()
.find(|v| v.key == key)
.ok_or_else(|| anyhow!("{onde} não tem uma env chamada '{key}'"))?;
var.value = value;
Ok(())
})
}
pub async fn rename_secret(
&self,
project_id: &str,
service_id: Option<&str>,
from: &str,
to: &str,
) -> Result<()> {
valid_secret_name(to)?;
self.db.infra().update(|st| {
let p = project_mut(st, project_id)?;
if from == to {
bail!("o nome novo é igual ao atual ('{from}')");
}
match service_id {
Some(sid) => {
let svc = service_mut(p, sid)?;
rename_in_list(&mut svc.secrets, from, to, "o serviço")?;
repoint_secret_refs(&mut svc.spec.env_vars, from, to);
}
None => {
let ProjectInfra {
env_vars,
services,
jobs,
secrets,
..
} = p;
rename_in_list(secrets, from, to, "o projeto")?;
repoint_secret_refs(env_vars, from, to);
for svc in services.iter_mut() {
if !svc.secrets.iter().any(|s| s.key == from) {
repoint_secret_refs(&mut svc.spec.env_vars, from, to);
}
}
for job in jobs.iter_mut() {
let sombreado = job.trigger_service_id.as_deref().is_some_and(|tid| {
services
.iter()
.any(|s| s.id == tid && s.secrets.iter().any(|x| x.key == from))
});
if !sombreado {
repoint_secret_refs(&mut job.env_vars, from, to);
}
}
}
}
Ok(())
})
}
}
fn valid_env_key(name: &str) -> Result<()> {
let mut chars = name.chars();
let ok = chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
if !ok {
bail!(
"'{name}' não é um nome de variável válido (use letras, números e _, sem começar por número)"
);
}
Ok(())
}
fn valid_secret_name(name: &str) -> Result<()> {
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
{
bail!("'{name}' não é um nome de secret válido (use letras, números, _, . e -)");
}
Ok(())
}
fn rename_in_list(list: &mut [SecretInfra], from: &str, to: &str, onde: &str) -> Result<()> {
if list.iter().any(|s| s.key == to) {
bail!("{onde} já tem um secret chamado '{to}'");
}
let s = list
.iter_mut()
.find(|s| s.key == from)
.ok_or_else(|| anyhow!("{onde} não tem um secret chamado '{from}'"))?;
s.key = to.to_string();
Ok(())
}
fn repoint_secret_refs(vars: &mut [EnvVar], from: &str, to: &str) {
for v in vars.iter_mut() {
if v.value == EnvVarValue::Secret(from.to_string()) {
v.value = EnvVarValue::Secret(to.to_string());
}
}
}
fn new_secret(name: &str, encrypted: String) -> SecretInfra {
SecretInfra {
id: format!("sec_{}", Ulid::new()),
key: name.to_string(),
value: encrypted,
}
}
fn project_mut<'a>(st: &'a mut InfraState, project_id: &str) -> Result<&'a mut ProjectInfra> {
st.projects
.iter_mut()
.find(|p| p.id == project_id)
.ok_or_else(|| anyhow!("projeto {project_id} não encontrado"))
}
fn service_mut<'a>(p: &'a mut ProjectInfra, service_id: &str) -> Result<&'a mut ServiceInfra> {
p.services
.iter_mut()
.find(|s| s.id == service_id)
.ok_or_else(|| anyhow!("serviço {service_id} não pertence ao projeto"))
}
fn secret_list_mut<'a>(
st: &'a mut InfraState,
project_id: &str,
service_id: Option<&str>,
) -> Result<&'a mut Vec<SecretInfra>> {
let p = project_mut(st, project_id)?;
match service_id {
None => Ok(&mut p.secrets),
Some(sid) => Ok(&mut service_mut(p, sid)?.secrets),
}
}
fn take_plain_env(vars: &mut [EnvVar], key: &str, onde: &str) -> Result<String> {
match vars.iter().find(|v| v.key == key) {
None => bail!("{onde} não tem uma env chamada '{key}'"),
Some(EnvVar {
value: EnvVarValue::Secret(_),
..
}) => bail!("a env '{key}' já aponta para um secret"),
Some(EnvVar {
value: EnvVarValue::Plain(v),
..
}) => Ok(v.clone()),
}
}
fn set_env(vars: &mut [EnvVar], key: &str, value: EnvVarValue) {
if let Some(v) = vars.iter_mut().find(|v| v.key == key) {
v.value = value;
}
}
fn secret_into_env(vars: &mut Vec<EnvVar>, name: &str, plain: String, onde: &str) -> Result<()> {
let mut pointed = 0;
for v in vars.iter_mut() {
if v.value == EnvVarValue::Secret(name.to_string()) {
v.value = EnvVarValue::Plain(plain.clone());
pointed += 1;
}
}
if pointed == 0 {
if vars.iter().any(|v| v.key == name) {
bail!("{onde} já tem uma env chamada '{name}'");
}
vars.push(EnvVar {
key: name.to_string(),
value: EnvVarValue::Plain(plain),
});
}
Ok(())
}
fn write_private(path: &Path, contents: &str) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
f.write_all(contents.as_bytes())?;
Ok(())
}
#[cfg(not(unix))]
{
std::fs::write(path, contents)?;
Ok(())
}
}
fn parse_identity(text: &str) -> Result<x25519::Identity> {
age_format::parse_identity(text)
}
fn has_enc_values(db: &Db) -> bool {
let st = db.infra().snapshot();
st.projects
.iter()
.flat_map(|p| p.secrets.iter())
.any(|s| is_enc(&s.value))
|| st
.providers
.iter()
.filter_map(|p| p.oauth_client_secret_enc.as_deref())
.any(is_enc)
}
fn persist_identity(
identity: &x25519::Identity,
config_path: Option<&Path>,
key_file: &Path,
) -> Result<()> {
let secret = identity.to_string();
let line = format!("age_identity = \"{}\"", secret.expose_secret());
if let Some(path) = config_path {
match write_identity_into_config(path, &line) {
Ok(()) => {
info!(config = %path.display(), public_key = %identity.to_public(), "identidade age gerada e gravada no config.toml");
return Ok(());
}
Err(e) => {
warn!(config = %path.display(), error = %format!("{e:#}"), "não consegui gravar a identidade no config.toml; usando {}", key_file.display())
}
}
}
write_private(key_file, secret.expose_secret())?;
info!(file = %key_file.display(), public_key = %identity.to_public(), "identidade age gerada e gravada em arquivo");
Ok(())
}
fn write_identity_into_config(path: &Path, line: &str) -> Result<()> {
let text = std::fs::read_to_string(path)?;
let mut out = String::with_capacity(text.len() + line.len() + 64);
let mut inserted = false;
for l in text.lines() {
out.push_str(l);
out.push('\n');
if !inserted && l.trim() == "[secrets]" {
out.push_str(line);
out.push('\n');
inserted = true;
}
}
if !inserted {
out.push_str("\n[secrets]\n");
out.push_str(line);
out.push('\n');
}
toml::from_str::<crate::platform::config::RustployConfig>(&out)
.context("a config resultante não seria válida")?;
let tmp = path.with_extension("toml.tmp");
write_private(&tmp, &out)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn lazy_db() -> Arc<Db> {
Arc::new(Db::lazy_for_test())
}
fn mgr() -> SecretsManager {
SecretsManager::new(lazy_db()).unwrap()
}
#[tokio::test]
async fn cifra_no_formato_novo_e_volta() {
let m = mgr();
let c = m.encrypt("senha-super-secreta").unwrap();
assert!(is_enc(&c) && c.starts_with("ENC[age:") && c.ends_with(']'));
assert!(!c.contains("senha-super-secreta"));
assert_eq!(m.decrypt(&c).unwrap(), "senha-super-secreta");
assert_ne!(c, m.encrypt("senha-super-secreta").unwrap());
}
#[tokio::test]
async fn identidade_errada_nao_decifra() {
let a = mgr();
let b = SecretsManager::new(lazy_db()).unwrap();
let c = a.encrypt("x").unwrap();
assert!(b.decrypt(&c).is_err());
}
#[tokio::test]
async fn a_chave_de_recuperacao_tambem_decifra() {
let recuperacao = x25519::Identity::generate();
let servidor = x25519::Identity::generate();
let m =
SecretsManager::with_keys(lazy_db(), servidor, Some(recuperacao.to_public())).unwrap();
let c = m.encrypt("segredo").unwrap();
let novo = SecretsManager::with_keys(lazy_db(), recuperacao, None).unwrap();
assert_eq!(novo.decrypt(&c).unwrap(), "segredo");
}
#[tokio::test]
async fn secret_gravado_nao_vaza_o_valor_para_o_yaml() {
let db = Arc::new(Db::memory_for_test().await);
db.seed_project_for_test("p1");
let m = SecretsManager::new(db.clone()).unwrap();
m.set("p1", "DB_PASSWORD", "hunter2-muito-secreto")
.await
.unwrap();
let tree = db.infra().tree();
let tudo: String = tree
.list()
.unwrap()
.iter()
.map(|r| String::from_utf8_lossy(&tree.read(r).unwrap().unwrap()).into_owned())
.collect();
assert!(!tudo.contains("hunter2-muito-secreto"));
assert!(tudo.contains("ENC[age:"));
assert_eq!(
m.get_raw("p1", "DB_PASSWORD").await.unwrap(),
"hunter2-muito-secreto"
);
}
async fn cenario() -> (Arc<Db>, SecretsManager, tempfile::TempDir) {
let d = tempfile::tempdir().unwrap();
let db = Arc::new(Db::memory_for_test().await);
db.seed_project_for_test("p1");
db.seed_service_for_test("p1", "s1");
db.seed_service_for_test("p1", "s2");
let m = SecretsManager::new(db.clone()).unwrap();
(db, m, d)
}
fn env(db: &Db, service: Option<&str>) -> Vec<EnvVar> {
let st = db.infra().snapshot();
let p = &st.projects[0];
match service {
None => p.env_vars.clone(),
Some(sid) => p
.services
.iter()
.find(|s| s.id == sid)
.unwrap()
.spec
.env_vars
.clone(),
}
}
fn set_env_plain(db: &Db, service: Option<&str>, key: &str, value: &str) {
db.infra()
.update(|st| {
let p = &mut st.projects[0];
let vars = match service {
None => &mut p.env_vars,
Some(sid) => {
&mut p
.services
.iter_mut()
.find(|s| s.id == sid)
.unwrap()
.spec
.env_vars
}
};
vars.push(EnvVar {
key: key.into(),
value: EnvVarValue::Plain(value.into()),
});
Ok(())
})
.unwrap();
}
#[tokio::test]
async fn resolucao_servico_primeiro_depois_projeto() {
let (_db, m, _d) = cenario().await;
m.set_scoped("p1", None, "DB_PASS", "do-projeto")
.await
.unwrap();
assert_eq!(
m.resolve("p1", Some("s1"), "DB_PASS").await.unwrap(),
"do-projeto"
);
m.set_scoped("p1", Some("s1"), "DB_PASS", "do-servico")
.await
.unwrap();
assert_eq!(
m.resolve("p1", Some("s1"), "DB_PASS").await.unwrap(),
"do-servico"
);
assert_eq!(
m.resolve("p1", Some("s2"), "DB_PASS").await.unwrap(),
"do-projeto"
);
assert_eq!(
m.resolve("p1", None, "DB_PASS").await.unwrap(),
"do-projeto"
);
m.set_scoped("p1", Some("s1"), "SO_AQUI", "x")
.await
.unwrap();
assert!(m.resolve("p1", None, "SO_AQUI").await.is_err());
assert!(m.resolve("p1", Some("s2"), "SO_AQUI").await.is_err());
assert_eq!(m.list_names_scoped("p1", None).await.unwrap(), ["DB_PASS"]);
assert_eq!(
m.list_names_scoped("p1", Some("s1")).await.unwrap(),
["DB_PASS", "SO_AQUI"]
);
assert!(
m.set_scoped("p1", Some("fantasma"), "K", "v")
.await
.is_err()
);
m.delete_scoped("p1", Some("s1"), "DB_PASS").await.unwrap();
assert_eq!(
m.resolve("p1", Some("s1"), "DB_PASS").await.unwrap(),
"do-projeto"
);
}
#[tokio::test]
async fn env_para_secret_e_de_volta_no_servico() {
use shared::SecretMoveOp::*;
let (db, m, _d) = cenario().await;
set_env_plain(&db, Some("s1"), "DB_PASSWORD", "hunter2");
m.move_secret("p1", Some("s1"), "DB_PASSWORD", EnvToSecret)
.await
.unwrap();
assert_eq!(
env(&db, Some("s1"))[0].value,
EnvVarValue::Secret("DB_PASSWORD".into())
);
assert_eq!(
m.get_scoped("p1", Some("s1"), "DB_PASSWORD").await.unwrap(),
"hunter2"
);
let tree = db.infra().tree();
let tudo: String = tree
.list()
.unwrap()
.iter()
.map(|r| String::from_utf8_lossy(&tree.read(r).unwrap().unwrap()).into_owned())
.collect();
assert!(!tudo.contains("hunter2"));
m.move_secret("p1", Some("s1"), "DB_PASSWORD", SecretToEnv)
.await
.unwrap();
assert_eq!(
env(&db, Some("s1"))[0].value,
EnvVarValue::Plain("hunter2".into())
);
assert!(
m.list_names_scoped("p1", Some("s1"))
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn env_para_secret_no_projeto_e_conflitos() {
use shared::SecretMoveOp::*;
let (db, m, _d) = cenario().await;
set_env_plain(&db, None, "TOKEN", "abc");
m.set("p1", "TOKEN", "ja-existe").await.unwrap();
let e = m
.move_secret("p1", None, "TOKEN", EnvToSecret)
.await
.unwrap_err();
assert!(e.to_string().contains("já tem um secret"), "{e}");
assert_eq!(env(&db, None)[0].value, EnvVarValue::Plain("abc".into()));
assert_eq!(m.get_raw("p1", "TOKEN").await.unwrap(), "ja-existe");
assert!(
m.move_secret("p1", None, "NADA", EnvToSecret)
.await
.is_err()
);
}
#[tokio::test]
async fn secret_para_env_aponta_todas_as_envs_ou_cria_uma() {
use shared::SecretMoveOp::*;
let (db, m, _d) = cenario().await;
m.set_scoped("p1", Some("s1"), "SEGREDO", "v1")
.await
.unwrap();
db.infra()
.update(|st| {
let vars = &mut st.projects[0].services[0].spec.env_vars;
for k in ["A", "B"] {
vars.push(EnvVar {
key: k.into(),
value: EnvVarValue::Secret("SEGREDO".into()),
});
}
Ok(())
})
.unwrap();
m.move_secret("p1", Some("s1"), "SEGREDO", SecretToEnv)
.await
.unwrap();
let vars = env(&db, Some("s1"));
assert!(
vars.iter()
.all(|v| v.value == EnvVarValue::Plain("v1".into()))
);
assert_eq!(vars.len(), 2);
m.set_scoped("p1", Some("s2"), "NOVO", "v2").await.unwrap();
m.move_secret("p1", Some("s2"), "NOVO", SecretToEnv)
.await
.unwrap();
let vars = env(&db, Some("s2"));
assert_eq!(
(vars[0].key.as_str(), &vars[0].value),
("NOVO", &EnvVarValue::Plain("v2".into()))
);
m.set_scoped("p1", Some("s2"), "NOVO", "v3").await.unwrap();
let e = m
.move_secret("p1", Some("s2"), "NOVO", SecretToEnv)
.await
.unwrap_err();
assert!(e.to_string().contains("já tem uma env"), "{e}");
assert!(
m.get_scoped("p1", Some("s2"), "NOVO").await.is_ok(),
"o secret não pode ter sumido"
);
}
#[tokio::test]
async fn mover_entre_servico_e_projeto() {
use shared::SecretMoveOp::*;
let (_db, m, _d) = cenario().await;
m.set_scoped("p1", Some("s1"), "DB_PASSWORD", "pw")
.await
.unwrap();
m.move_secret("p1", Some("s1"), "DB_PASSWORD", ServiceSecretToProject)
.await
.unwrap();
assert!(
m.list_names_scoped("p1", Some("s1"))
.await
.unwrap()
.is_empty()
);
assert_eq!(
m.resolve("p1", Some("s2"), "DB_PASSWORD").await.unwrap(),
"pw"
);
m.move_secret("p1", Some("s2"), "DB_PASSWORD", ProjectSecretToService)
.await
.unwrap();
assert!(m.list_names("p1").await.unwrap().is_empty());
assert_eq!(
m.get_scoped("p1", Some("s2"), "DB_PASSWORD").await.unwrap(),
"pw"
);
assert!(m.resolve("p1", Some("s1"), "DB_PASSWORD").await.is_err());
m.set("p1", "DB_PASSWORD", "outro").await.unwrap();
let e = m
.move_secret("p1", Some("s2"), "DB_PASSWORD", ServiceSecretToProject)
.await
.unwrap_err();
assert!(e.to_string().contains("já tem um secret"), "{e}");
assert_eq!(
m.get_scoped("p1", Some("s2"), "DB_PASSWORD").await.unwrap(),
"pw"
);
assert_eq!(m.get_raw("p1", "DB_PASSWORD").await.unwrap(), "outro");
assert!(
m.move_secret("p1", None, "DB_PASSWORD", ProjectSecretToService)
.await
.is_err()
);
assert!(
m.move_secret(
"p1",
Some("fantasma"),
"DB_PASSWORD",
ProjectSecretToService
)
.await
.is_err()
);
}
#[tokio::test]
async fn mover_env_entre_servico_e_projeto() {
use shared::SecretMoveOp::*;
let (db, m, _d) = cenario().await;
set_env_plain(&db, Some("s1"), "LOG_LEVEL", "debug");
m.move_secret("p1", Some("s1"), "LOG_LEVEL", EnvServiceToProject)
.await
.unwrap();
assert!(env(&db, Some("s1")).iter().all(|v| v.key != "LOG_LEVEL"));
assert_eq!(
env(&db, None)
.iter()
.find(|v| v.key == "LOG_LEVEL")
.unwrap()
.value,
EnvVarValue::Plain("debug".into())
);
m.move_secret("p1", Some("s2"), "LOG_LEVEL", EnvProjectToService)
.await
.unwrap();
assert!(env(&db, None).iter().all(|v| v.key != "LOG_LEVEL"));
assert_eq!(
env(&db, Some("s2"))
.iter()
.find(|v| v.key == "LOG_LEVEL")
.unwrap()
.value,
EnvVarValue::Plain("debug".into())
);
set_env_plain(&db, None, "LOG_LEVEL", "info");
let e = m
.move_secret("p1", Some("s2"), "LOG_LEVEL", EnvServiceToProject)
.await
.unwrap_err();
assert!(e.to_string().contains("já tem uma env"), "{e}");
assert_eq!(env(&db, Some("s2")).len(), 1);
set_env_plain(&db, Some("s1"), "SENHA", "x");
m.move_secret("p1", Some("s1"), "SENHA", EnvToSecret)
.await
.unwrap();
let e = m
.move_secret("p1", Some("s1"), "SENHA", EnvServiceToProject)
.await
.unwrap_err();
assert!(e.to_string().contains("já aponta para um secret"), "{e}");
assert!(
m.move_secret("p1", None, "LOG_LEVEL", EnvProjectToService)
.await
.is_err()
);
}
#[tokio::test]
async fn renomear_chave_de_env_mantem_valor_e_acompanha_comentario() {
let (db, m, _d) = cenario().await;
set_env_plain(&db, Some("s1"), "RABBITMQ_DEFAULT_PASS", "valor");
db.infra()
.update(|st| {
st.projects[0].services[0]
.spec
.env_comments
.push(shared::EnvComment {
text: "# senha do broker".into(),
before_key: Some("RABBITMQ_DEFAULT_PASS".into()),
});
Ok(())
})
.unwrap();
m.rename_env_key("p1", Some("s1"), "RABBITMQ_DEFAULT_PASS", "RABBITMQ_PASS")
.await
.unwrap();
let envs = env(&db, Some("s1"));
assert_eq!(envs.len(), 1);
assert_eq!(envs[0].key, "RABBITMQ_PASS");
assert_eq!(envs[0].value, EnvVarValue::Plain("valor".into()));
let st = db.infra().snapshot();
assert_eq!(
st.projects[0].services[0].spec.env_comments[0]
.before_key
.as_deref(),
Some("RABBITMQ_PASS")
);
}
#[tokio::test]
async fn renomear_chave_de_env_secret_nao_mexe_no_secret() {
use shared::SecretMoveOp::EnvToSecret;
let (db, m, _d) = cenario().await;
set_env_plain(&db, Some("s1"), "DB_PASSWORD", "hunter2");
m.move_secret("p1", Some("s1"), "DB_PASSWORD", EnvToSecret)
.await
.unwrap();
m.rename_env_key("p1", Some("s1"), "DB_PASSWORD", "POSTGRES_PASSWORD")
.await
.unwrap();
assert_eq!(env(&db, Some("s1"))[0].key, "POSTGRES_PASSWORD");
assert_eq!(
env(&db, Some("s1"))[0].value,
EnvVarValue::Secret("DB_PASSWORD".into())
);
assert_eq!(
m.resolve("p1", Some("s1"), "DB_PASSWORD").await.unwrap(),
"hunter2"
);
}
#[tokio::test]
async fn trocar_valor_de_env_mantem_a_posicao_e_valida() {
let (db, m, _d) = cenario().await;
set_env_plain(&db, None, "A", "1");
set_env_plain(&db, None, "B", "2");
set_env_plain(&db, None, "C", "3");
m.set_env_value("p1", None, "B", EnvVarValue::Plain("novo".into()))
.await
.unwrap();
let envs = env(&db, None);
let keys: Vec<_> = envs.iter().map(|v| v.key.as_str()).collect();
assert_eq!(keys, ["A", "B", "C"], "a posição não muda");
assert_eq!(envs[1].value, EnvVarValue::Plain("novo".into()));
assert_eq!(envs[0].value, EnvVarValue::Plain("1".into()));
m.set_env_value("p1", None, "A", EnvVarValue::Plain(String::new()))
.await
.unwrap();
assert_eq!(env(&db, None)[0].value, EnvVarValue::Plain(String::new()));
assert!(
m.set_env_value("p1", None, "NADA", EnvVarValue::Plain("x".into()))
.await
.is_err()
);
assert!(
m.set_env_value("p1", None, "C", EnvVarValue::Plain("***".into()))
.await
.is_err()
);
assert!(
m.set_env_value("p1", None, "C", EnvVarValue::Secret("FANTASMA".into()))
.await
.is_err()
);
assert_eq!(env(&db, None)[2].value, EnvVarValue::Plain("3".into()));
}
#[tokio::test]
async fn trocar_valor_para_secret_existente_no_servico_e_no_projeto() {
use shared::SecretMoveOp::EnvToSecret;
let (db, m, _d) = cenario().await;
set_env_plain(&db, Some("s1"), "SENHA", "hunter2");
set_env_plain(&db, Some("s1"), "OUTRA", "x");
m.move_secret("p1", Some("s1"), "SENHA", EnvToSecret)
.await
.unwrap();
m.set_env_value(
"p1",
Some("s1"),
"OUTRA",
EnvVarValue::Secret("SENHA".into()),
)
.await
.unwrap();
assert_eq!(
env(&db, Some("s1"))[1].value,
EnvVarValue::Secret("SENHA".into())
);
set_env_plain(&db, None, "P", "1");
assert!(
m.set_env_value("p1", None, "P", EnvVarValue::Secret("SENHA".into()))
.await
.is_err()
);
}
#[tokio::test]
async fn renomear_chave_de_env_recusa_conflito_inexistente_e_nome_ruim() {
let (db, m, _d) = cenario().await;
set_env_plain(&db, None, "A", "1");
set_env_plain(&db, None, "B", "2");
assert!(
m.rename_env_key("p1", None, "A", "B").await.is_err(),
"destino existe"
);
assert!(
m.rename_env_key("p1", None, "NADA", "C").await.is_err(),
"origem não existe"
);
assert!(
m.rename_env_key("p1", None, "A", "1A").await.is_err(),
"começa por número"
);
assert!(
m.rename_env_key("p1", None, "A", "A B").await.is_err(),
"espaço"
);
assert!(
m.rename_env_key("p1", None, "A", "A").await.is_err(),
"mesmo nome"
);
let keys: Vec<_> = env(&db, None).into_iter().map(|v| v.key).collect();
assert_eq!(keys, ["A", "B"]);
}
#[tokio::test]
async fn renomear_secret_de_servico_reaponta_so_as_envs_dele() {
let (db, m, _d) = cenario().await;
m.set_scoped("p1", Some("s1"), "RABBITMQ_DEFAULT_PASS", "segredo-1")
.await
.unwrap();
for sid in ["s1", "s2"] {
db.infra()
.update(|st| {
let svc = st.projects[0]
.services
.iter_mut()
.find(|s| s.id == sid)
.unwrap();
svc.spec.env_vars.push(EnvVar {
key: "RABBITMQ_PASS".into(),
value: EnvVarValue::Secret("RABBITMQ_DEFAULT_PASS".into()),
});
Ok(())
})
.unwrap();
}
m.rename_secret("p1", Some("s1"), "RABBITMQ_DEFAULT_PASS", "RABBITMQ_PASS")
.await
.unwrap();
assert_eq!(
env(&db, Some("s1"))[0].value,
EnvVarValue::Secret("RABBITMQ_PASS".into())
);
assert_eq!(
env(&db, Some("s2"))[0].value,
EnvVarValue::Secret("RABBITMQ_DEFAULT_PASS".into())
);
assert_eq!(
m.get_scoped("p1", Some("s1"), "RABBITMQ_PASS")
.await
.unwrap(),
"segredo-1"
);
assert!(
m.get_scoped("p1", Some("s1"), "RABBITMQ_DEFAULT_PASS")
.await
.is_err()
);
}
#[tokio::test]
async fn renomear_secret_de_projeto_respeita_o_sombreamento_do_servico() {
let (db, m, _d) = cenario().await;
m.set_scoped("p1", None, "DB_PASS", "do-projeto")
.await
.unwrap();
m.set_scoped("p1", Some("s1"), "DB_PASS", "do-s1")
.await
.unwrap();
for sid in ["s1", "s2"] {
db.infra()
.update(|st| {
let svc = st.projects[0]
.services
.iter_mut()
.find(|s| s.id == sid)
.unwrap();
svc.spec.env_vars.push(EnvVar {
key: "PASS".into(),
value: EnvVarValue::Secret("DB_PASS".into()),
});
Ok(())
})
.unwrap();
}
db.infra()
.update(|st| {
st.projects[0].env_vars.push(EnvVar {
key: "PASS".into(),
value: EnvVarValue::Secret("DB_PASS".into()),
});
Ok(())
})
.unwrap();
m.rename_secret("p1", None, "DB_PASS", "DATABASE_PASSWORD")
.await
.unwrap();
assert_eq!(
env(&db, None)[0].value,
EnvVarValue::Secret("DATABASE_PASSWORD".into())
);
assert_eq!(
env(&db, Some("s2"))[0].value,
EnvVarValue::Secret("DATABASE_PASSWORD".into())
);
assert_eq!(
env(&db, Some("s1"))[0].value,
EnvVarValue::Secret("DB_PASS".into())
);
assert_eq!(
m.resolve("p1", Some("s1"), "DB_PASS").await.unwrap(),
"do-s1"
);
assert_eq!(
m.resolve("p1", Some("s2"), "DATABASE_PASSWORD")
.await
.unwrap(),
"do-projeto"
);
}
#[tokio::test]
async fn renomear_secret_recusa_conflito_inexistente_e_nome_ruim() {
let (_db, m, _d) = cenario().await;
m.set_scoped("p1", None, "A", "1").await.unwrap();
m.set_scoped("p1", None, "B", "2").await.unwrap();
assert!(
m.rename_secret("p1", None, "A", "B").await.is_err(),
"destino existe"
);
assert!(
m.rename_secret("p1", None, "NADA", "C").await.is_err(),
"origem não existe"
);
assert!(m.rename_secret("p1", None, "A", "").await.is_err(), "vazio");
assert!(
m.rename_secret("p1", None, "A", "a:b").await.is_err(),
"dois-pontos"
);
assert!(
m.rename_secret("p1", None, "A", "a b").await.is_err(),
"espaço"
);
assert_eq!(m.get_scoped("p1", None, "A").await.unwrap(), "1");
}
#[tokio::test]
async fn ver_valor_grava_auditoria_sem_o_valor() {
let (db, m, _d) = cenario().await;
m.set_scoped("p1", Some("s1"), "K", "valor-que-nao-pode-vazar")
.await
.unwrap();
assert_eq!(
m.reveal("p1", Some("s1"), "K").await.unwrap(),
"valor-que-nao-pode-vazar"
);
let linhas: Vec<(String, String, Option<String>, String)> =
sqlx::query_as("SELECT action, project_id, service_id, name FROM secret_audit")
.fetch_all(db.pool())
.await
.unwrap();
assert_eq!(
linhas,
vec![("reveal".into(), "p1".into(), Some("s1".into()), "K".into())]
);
assert!(m.reveal("p1", Some("s1"), "NADA").await.is_err());
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM secret_audit")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(n, 1);
}
fn config_completa(dir: &Path) -> PathBuf {
let defaults =
toml::to_string(&crate::platform::config::RustployConfig::default()).unwrap();
let path = dir.join("config.toml");
std::fs::write(
&path,
format!("# comentário que deve sobreviver\n{defaults}"),
)
.unwrap();
path
}
#[tokio::test]
async fn primeira_subida_gera_a_identidade_e_grava_no_config_toml() {
let d = tempfile::tempdir().unwrap();
let cfg_path = config_completa(d.path());
let cfg = crate::platform::config::RustployConfig::default().secrets;
let db = Arc::new(Db::memory_for_test().await);
let m1 = SecretsManager::open(&d.path().join("age.key"), db.clone(), &cfg, Some(&cfg_path))
.unwrap();
let texto = std::fs::read_to_string(&cfg_path).unwrap();
assert!(
texto.contains("age_identity = \"AGE-SECRET-KEY-1"),
"{texto}"
);
assert!(texto.contains("# comentário que deve sobreviver"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(&cfg_path).unwrap().permissions().mode() & 0o777,
0o600
);
}
let relida: crate::platform::config::RustployConfig = toml::from_str(&texto).unwrap();
assert!(relida.secrets.age_identity.is_some());
assert!(
!d.path().join("age.key").exists(),
"não precisa do arquivo de reserva"
);
let c = m1.encrypt("x").unwrap();
let m2 = SecretsManager::open(
&d.path().join("age.key"),
db,
&relida.secrets,
Some(&cfg_path),
)
.unwrap();
assert_eq!(m2.decrypt(&c).unwrap(), "x");
assert_eq!(m1.public_key(), m2.public_key());
}
#[test]
fn identidade_entra_numa_config_de_producao() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r####"[daemon]
# NOTA: em produção, db_path é GERIDO PELO systemd unit (rustployd.service),
# que o fixa via Environment=RUSTPLOY_DB_PATH apontando para /var/lib/rustploy
# (diretório criado por StateDirectory=). Como env vars têm prioridade sobre
# este arquivo, editar o campo abaixo NÃO terá efeito sob systemd — ajuste no
# unit ou em /etc/rustploy/environment (lido via EnvironmentFile=).
db_path = "/var/lib/rustploy/db"
log_level = "info"
# As demais seções abaixo VÊM deste arquivo normalmente — não são sobrescritas
# pelo unit.
# API HTTP/JSON + SSE — o canal do GUI. Serve TAMBÉM as rotas públicas sem token:
# os webhooks de CI/CD (POST /webhook/<service_id>/<token>) e o callback OAuth do
# Gitea (/oauth/gitea/callback), que antes tinham porta própria (8788). Ou seja:
# para o GitHub/Gitea alcançarem o webhook, esta porta precisa estar acessível.
# Defaults: bind 127.0.0.1, port 9797, sem token. Bind não-loopback EXIGE token.
[api]
bind_address = "0.0.0.0"
port = 9797
token = "tok"
connection_token = "abc="
domain = "rustploy.chiquitos.tech" # TLS automático (ACME) na própria porta
[ingress]
http_port = 80
https_port = 443
bind_address = "0.0.0.0"
[ingress.acme]
enabled = true
email = "a@b.c" # set your email to enable ACME/TLS
directory = "https://acme-v02.api.letsencrypt.org/directory"
[docker]
socket_path = "/run/podman/podman.sock"
[deploy]
drain_secs = 10
image_cache = 2
[metrics]
interval_secs = 2
history_points = 60
[secrets]
# Faixa de portas de host que o rustploy aloca automaticamente para exposição
# externa de serviços TCP (bancos, brokers…) — o "host_port" automático.
# Também é a ÚNICA faixa que o helper privilegiado de firewall (rustployd-fw)
# aceita liberar/bloquear no ufw; portas fora dela são recusadas.
[external_ports]
range_start = 20000
range_end = 20999
# Registry Docker OCI (Distribution API v2) embutido no daemon — Fase 1:
# escuta apenas em loopback (127.0.0.1), sem autenticação. Habilite e use
# docker push 127.0.0.1:5100/<repo>:<tag>.
[registry]
enabled = true
port = 5100
# storage_dir = "/var/lib/rustploy/db/registry" # default: <db_path>/registry/
# Env var backup — snapshots automáticos de todas as env vars de projetos e serviços.
# Restaurar: rustploy env-backup list | rustploy env-backup restore <snapshot>
# Docs: /usr/share/doc/rustploy/env-backup.md
[env_backup]
# dir = "/var/lib/rustploy/db/env_backups" # default: <db_path>/env_backups/
interval_secs = 60000
"####,
)
.unwrap();
let id = x25519::Identity::generate();
let line = format!("age_identity = \"{}\"", id.to_string().expose_secret());
write_identity_into_config(&path, &line).unwrap_or_else(|e| panic!("{e:#}"));
assert!(
std::fs::read_to_string(&path)
.unwrap()
.contains("age_identity")
);
}
#[tokio::test]
async fn sem_config_gravavel_a_identidade_vai_para_o_age_key() {
let d = tempfile::tempdir().unwrap();
let cfg = crate::platform::config::RustployConfig::default().secrets;
let db = Arc::new(Db::memory_for_test().await);
let m1 = SecretsManager::open(&d.path().join("age.key"), db.clone(), &cfg, None).unwrap();
assert!(d.path().join("age.key").exists());
let m2 = SecretsManager::open(&d.path().join("age.key"), db, &cfg, None).unwrap();
assert_eq!(m1.public_key(), m2.public_key());
}
#[tokio::test]
async fn recusa_gerar_identidade_nova_se_ja_ha_segredos_age() {
let d = tempfile::tempdir().unwrap();
let cfg = crate::platform::config::RustployConfig::default().secrets;
let db = Arc::new(Db::memory_for_test().await);
db.seed_project_for_test("p1");
let m = SecretsManager::new(db.clone()).unwrap();
m.set("p1", "K", "v").await.unwrap();
let e = SecretsManager::open(&d.path().join("age.key"), db, &cfg, None)
.err()
.expect("devia recusar");
assert!(e.to_string().contains("ilegíveis"), "{e}");
}
#[tokio::test]
async fn identidade_e_recuperacao_invalidas_dao_erro_claro() {
let d = tempfile::tempdir().unwrap();
let db = Arc::new(Db::memory_for_test().await);
let mut cfg = crate::platform::config::RustployConfig::default().secrets;
cfg.age_identity = Some("isto-nao-e-uma-chave".into());
let e = SecretsManager::open(&d.path().join("age.key"), db.clone(), &cfg, None)
.err()
.unwrap();
assert!(format!("{e:#}").contains("age_identity"), "{e:#}");
cfg.age_identity = Some(
x25519::Identity::generate()
.to_string()
.expose_secret()
.clone(),
);
cfg.recovery_recipient = Some("age1lixo".into());
let e = SecretsManager::open(&d.path().join("age.key"), db, &cfg, None)
.err()
.unwrap();
assert!(e.to_string().contains("recovery_recipient"), "{e}");
}
}