diff --git a/apps/native/src-tauri/src/evolve/file_ops.rs b/apps/native/src-tauri/src/evolve/file_ops.rs index 6efa066ed..a1314aa74 100644 --- a/apps/native/src-tauri/src/evolve/file_ops.rs +++ b/apps/native/src-tauri/src/evolve/file_ops.rs @@ -6,6 +6,7 @@ //! Uses OpenAI function calling to generate structured file edits. use crate::shared_types::FileEdit; +use crate::yaml_utils::validate_yaml_syntax; use super::gitignore::GitignoreChecker; use super::gitignore::is_path_ignored; @@ -410,16 +411,6 @@ fn validate_nix_syntax(content: &str, file_path: &str) -> anyhow::Result<()> { Ok(()) } -/// Validate basic syntax of a .yaml or .yml file using serde_yaml. -fn validate_yaml_syntax(content: &str, file_path: &str) -> anyhow::Result<()> { - // Try to parse the YAML content. serde_yaml will catch syntax errors - // like unmatched quotes, braces, brackets, etc. - serde_yaml::from_str::(content) - .map_err(|e| anyhow::anyhow!("Syntax error in {}: {}", file_path, e))?; - - Ok(()) -} - /// Validate file content based on extension before writing. pub(crate) fn validate_file_content(file_path: &str, content: &str) -> anyhow::Result<()> { if file_path.ends_with(".nix") { @@ -850,43 +841,6 @@ mod tests { assert!(err.to_string().contains("Syntax error")); } - #[test] - fn validate_yaml_syntax_accepts_valid_yaml() { - let valid_yaml = r#" -name: test -config: - enable: true - items: - - first - - second -"#; - - super::validate_yaml_syntax(valid_yaml, "test.yaml") - .expect("should parse valid yaml syntax"); - } - - #[test] - fn validate_yaml_syntax_rejects_unmatched_braces() { - let invalid_yaml = r#" -config: { unclosed: value -"#; - - let err = super::validate_yaml_syntax(invalid_yaml, "test.yaml") - .expect_err("should reject unmatched braces"); - assert!(err.to_string().contains("Syntax error")); - } - - #[test] - fn validate_yaml_syntax_rejects_unclosed_string() { - let invalid_yaml = r#" -key: "unclosed string value -"#; - - let err = super::validate_yaml_syntax(invalid_yaml, "test.yaml") - .expect_err("should reject unclosed string"); - assert!(err.to_string().contains("Syntax error")); - } - #[test] fn validate_file_content_delegates_by_extension() { // Test .nix file diff --git a/apps/native/src-tauri/src/main.rs b/apps/native/src-tauri/src/main.rs index 3eed3a65f..19a6839d0 100644 --- a/apps/native/src-tauri/src/main.rs +++ b/apps/native/src-tauri/src/main.rs @@ -47,6 +47,7 @@ mod tray_icon; mod types; mod updater_pin; mod utils; +mod yaml_utils; use state::watcher; use storage::store; diff --git a/apps/native/src-tauri/src/orpc/secrets.rs b/apps/native/src-tauri/src/orpc/secrets.rs index 947fb3b76..cd6ed2128 100644 --- a/apps/native/src-tauri/src/orpc/secrets.rs +++ b/apps/native/src-tauri/src/orpc/secrets.rs @@ -1,7 +1,9 @@ //! Secrets management procedures. use super::{OrpcCtx, helpers::internal_err}; -use crate::shared_types::{AddSecretResult, DeleteSecretResult, SecretsVaultState}; +use crate::shared_types::{ + AddSecretResult, DeleteSecretResult, EditSecretResult, SecretsVaultState, +}; use crate::state::secrets_vault; use crate::{commands::helpers::get_hostname_and_config_dir, shared_types::SecretBackend}; use orpc::*; @@ -23,6 +25,14 @@ struct AddSecretInput { backend: SecretBackend, } +#[derive(Debug, Deserialize, Serialize, Type)] +#[serde(rename_all = "camelCase")] +struct EditSecretInput { + secret_id: String, + value: String, + backend: SecretBackend, +} + #[derive(Debug, Deserialize, Serialize, Type)] #[serde(rename_all = "camelCase")] struct DeleteSecretInput { @@ -77,6 +87,21 @@ async fn add_secret(ctx: OrpcCtx, input: AddSecretInput) -> Result Result { + let (host_attr, config_dir) = get_hostname_and_config_dir(&ctx.app, "secrets.editSecret") + .map_err(|error| internal_err("secrets.editSecret", error))?; + let result = crate::secrets::secrets_management::edit_secret( + &host_attr, + &config_dir, + &input.secret_id, + &input.value, + input.backend, + ) + .map_err(|error| internal_err("secrets.editSecret", error))?; + refresh_state_after_mutation(&ctx, &config_dir, "secrets.editSecret"); + Ok(result) +} + async fn delete_secret( ctx: OrpcCtx, input: DeleteSecretInput, @@ -124,6 +149,10 @@ pub fn routes() -> Router { .input(orpc_specta::specta::()) .output(orpc_specta::specta::()) .handler(add_secret), + "editSecret" => os::() + .input(orpc_specta::specta::()) + .output(orpc_specta::specta::()) + .handler(edit_secret), "deleteSecret" => os::() .input(orpc_specta::specta::()) .output(orpc_specta::specta::()) diff --git a/apps/native/src-tauri/src/secrets/secrets_management.rs b/apps/native/src-tauri/src/secrets/secrets_management.rs index add621858..e656eb023 100644 --- a/apps/native/src-tauri/src/secrets/secrets_management.rs +++ b/apps/native/src-tauri/src/secrets/secrets_management.rs @@ -15,11 +15,12 @@ use crate::{ resolve_secret_file_path, }, shared_types::{ - AddSecretResult, DecryptionIdentity, DecryptionIdentityKind, FileEditAction, SecretBackend, - SecretEntry, SecretsVault, SemanticFileEdit, + AddSecretResult, DecryptionIdentity, DecryptionIdentityKind, EditSecretResult, + FileEditAction, SecretBackend, SecretEntry, SecretsVault, SemanticFileEdit, }, system::nix::nix_command, utils::nix_string_literal, + yaml_utils::{remove_yaml_path, replace_yaml_path}, }; use anyhow::{Context, anyhow}; use std::{ @@ -113,6 +114,219 @@ pub fn add_secret( } } +/// Replace an existing secret's value without changing its declaration, +/// location, backend, or recipients. +pub fn edit_secret( + host_attr: &str, + config_dir: &str, + secret_id: &str, + value: &str, + backend: SecretBackend, +) -> Result { + match backend { + SecretBackend::Sops => edit_sops_secret(host_attr, config_dir, secret_id, value), + SecretBackend::Agenix => edit_age_secret(host_attr, config_dir, secret_id, value), + } + .map_err(|error| error.to_string()) +} + +/// Edit an agenix secret in place, preserving its recipients and encrypted file location. +fn edit_age_secret( + host_attr: &str, + config_dir: &str, + secret_id: &str, + value: &str, +) -> anyhow::Result { + validate_new_secret(secret_id, value)?; + ensure_clean_repo(config_dir)?; + + let base = Path::new(config_dir); + let secret = load_secrets_vault(host_attr, config_dir) + .map_err(|error| anyhow!(error))? + .entries + .into_iter() + .find(|secret| secret.backend == SecretBackend::Agenix && secret.id == secret_id) + .ok_or_else(|| anyhow!("Secret declaration '{secret_id}' does not exist"))?; + let recipients = agenix_edit_recipients(&secret)?; + + let rules_file = find_agenix_rules_file(base)?; + let (_, encrypted_path) = resolve_agenix_rule_file(config_dir, &rules_file, &secret)?; + let encrypted_rel = repo_relative_path_string(base, &encrypted_path)?; + let operation = (|| -> anyhow::Result { + encrypt_age_secret(config_dir, &encrypted_rel, value.as_bytes(), recipients)?; + verify_dry_build_for_secret_edit(config_dir, host_attr, "editing the secret")?; + let commit = crate::git::commit_files( + config_dir, + &[&encrypted_rel], + &format!("secrets: edit {secret_id} (agenix)"), + ) + .context("commit edited agenix secret")?; + Ok(EditSecretResult { + secret_id: secret_id.to_string(), + encrypted_file: encrypted_rel.clone(), + commit_hash: commit.hash, + }) + })(); + if operation.is_err() { + restore_repo_files_on_failure(config_dir, &[&encrypted_rel]); + } + operation +} + +/// Editing an agenix value must preserve the recipients on that particular +/// encrypted file. Falling back to every currently registered recipient would +/// silently grant access to identities that could not previously decrypt it. +fn agenix_edit_recipients(secret: &SecretEntry) -> anyhow::Result<&[String]> { + if !secret.public_recipients_resolved || secret.public_recipients.is_empty() { + anyhow::bail!( + "Agenix recipients for '{}' could not be resolved; refusing to change its encryption", + secret.id + ); + } + Ok(&secret.public_recipients) +} + +/// Edit a SOPS secret in place, preserving its key and encrypted file location. +fn edit_sops_secret( + host_attr: &str, + config_dir: &str, + secret_id: &str, + value: &str, +) -> anyhow::Result { + validate_new_secret(secret_id, value)?; + ensure_clean_repo(config_dir)?; + + let base = Path::new(config_dir); + let secret = load_sops_secrets(host_attr, config_dir) + .map_err(|error| anyhow!(error))? + .into_iter() + .find(|secret| secret.id == secret_id) + .ok_or_else(|| anyhow!("Secret declaration '{secret_id}' does not exist"))?; + let sops_key = secret + .sops_key + .as_deref() + .ok_or_else(|| anyhow!("SOPS secret '{secret_id}' has no key"))?; + let encrypted_path = resolve_sops_source_file(base, &secret.file)?; + let encrypted_rel = repo_relative_path_string(base, &encrypted_path)?; + let plaintext = decrypt_sops_file(host_attr, config_dir, &encrypted_path)?; + let updated = replace_sops_value(&plaintext, sops_key, value)?; + + let operation = (|| -> anyhow::Result { + encrypt_sops_yaml(config_dir, &encrypted_rel, updated.as_bytes())?; + verify_dry_build_for_secret_edit(config_dir, host_attr, "editing the secret")?; + let commit = crate::git::commit_files( + config_dir, + &[&encrypted_rel], + &format!("secrets: edit {secret_id} (sops)"), + ) + .context("commit edited SOPS secret")?; + Ok(EditSecretResult { + secret_id: secret_id.to_string(), + encrypted_file: encrypted_rel.clone(), + commit_hash: commit.hash, + }) + })(); + if operation.is_err() { + restore_repo_files_on_failure(config_dir, &[&encrypted_rel]); + } + operation +} + +/// Resolve an evaluated SOPS path back to the repository file that owns it. +/// +/// This is a little tricky: +/// +/// `load_sops_secrets` reads `toString secret.sopsFile` from the evaluated host +/// configuration. When `sopsFile` was declared with a Nix path value (including +/// `builtins.path`), that string is the immutable `/nix/store/-` +/// artifact, NOT the path in the user's checkout that produced it. Writing the +/// store artifact won't work because the next evaluation would just recreate +/// it from the old unchanged repository source. +/// +/// Sadly, Nix does not retain the original checkout path in this value. +/// So we need to recover it from the repository. This recovery is conservative. +/// The store name supplies the original basename, and the encrypted bytes must match +/// exactly one repo file with that basename. +/// - Zero matches means the source cannot be established. +/// - Multiple matches are ambiguous. +/// +/// In either case we refuse the edit instead of weakening the repository-boundary edit guardrail +/// or guessing which secret file to overwrite. +fn resolve_sops_source_file(base: &Path, evaluated_file: &str) -> anyhow::Result { + let evaluated_path = Path::new(evaluated_file); + if !evaluated_path.is_absolute() { + return resolve_existing_path_in_dir(base, evaluated_file) + .with_context(|| format!("resolve SOPS file {evaluated_file}")); + } + + let base_resolved = base.canonicalize().context("resolve repository")?; + let evaluated_resolved = evaluated_path + .canonicalize() + .with_context(|| format!("resolve evaluated SOPS file {evaluated_file}"))?; + if let Ok(relative) = repo_relative_path(&base_resolved, &evaluated_resolved) { + return Ok(base.join(relative)); + } + + if !evaluated_resolved.starts_with("/nix/store") { + anyhow::bail!( + "SOPS file {} is outside the repository and is not a Nix store path", + evaluated_resolved.display() + ); + } + let store_name = evaluated_resolved + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow!("Evaluated SOPS file has no valid filename"))?; + let source_name = nix_store_source_name(store_name) + .ok_or_else(|| anyhow!("Could not derive a source filename from Nix store path"))?; + + match_repo_file_by_name_and_contents(base, &evaluated_resolved, source_name) +} + +/// Derive the original source filename from a Nix store path. The store path is +/// of the form `/nix/store/-`. +/// This function extracts `` if the format is valid. +fn nix_store_source_name(store_name: &str) -> Option<&str> { + store_name + .split_once('-') + .filter(|(hash, name)| hash.len() == 32 && !name.is_empty()) + .map(|(_, name)| name) +} + +/// Find a file in the repository that matches the given name and has the same contents as the evaluated file. +fn match_repo_file_by_name_and_contents( + base: &Path, + evaluated_file: &Path, + source_name: &str, +) -> anyhow::Result { + let evaluated_contents = std::fs::read(evaluated_file) + .with_context(|| format!("read evaluated SOPS file {}", evaluated_file.display()))?; + let mut matches = walkdir::WalkDir::new(base) + .into_iter() + .filter_entry(|entry| entry.depth() == 0 || entry.file_name() != ".git") + .filter_map(Result::ok) + .filter(|entry| entry.file_type().is_file() && entry.file_name() == source_name) + .filter_map(|entry| { + std::fs::read(entry.path()) + .ok() + .filter(|contents| contents == &evaluated_contents) + .map(|_| entry.into_path()) + }) + .collect::>(); + matches.sort(); + match matches.as_slice() { + [source] => Ok(source.clone()), + [] => anyhow::bail!( + "Could not find repository source '{source_name}' matching evaluated SOPS file {}", + evaluated_file.display() + ), + _ => anyhow::bail!( + "Multiple repository files named '{source_name}' match evaluated SOPS file {}; refusing to guess", + evaluated_file.display() + ), + } +} + /// Deletes an agenix secret from the configured repo, returning the result. /// Requires that the repository is clean and that the secret exists. /// If the operation fails, it attempts to restore the repository to its original state. @@ -397,36 +611,13 @@ fn remove_sops_key(plaintext: &str, sops_key: &str) -> anyhow::Result { } } -/// Recursively removes a path from a YAML document, returning true if the path was found and removed. -fn remove_yaml_path<'a, I>( - value: &mut serde_yaml::Value, - parts: &mut std::iter::Peekable, -) -> anyhow::Result -where - I: Iterator, -{ - let part = parts - .next() - .ok_or_else(|| anyhow!("SOPS key must not be empty"))?; - let mapping = value - .as_mapping_mut() - .ok_or_else(|| anyhow!("SOPS key path '{part}' does not refer to a YAML mapping"))?; - let key = serde_yaml::Value::String(part.to_string()); - if parts.peek().is_none() { - return Ok(mapping.remove(&key).is_some()); - } - let Some(child) = mapping.get_mut(&key) else { - return Ok(false); - }; - let removed = remove_yaml_path(child, parts)?; - if removed - && child - .as_mapping() - .is_some_and(serde_yaml::Mapping::is_empty) - { - mapping.remove(&key); - } - Ok(removed) +/// Replace an existing scalar at a slash-delimited SOPS key path. +fn replace_sops_value(plaintext: &str, sops_key: &str, value: &str) -> anyhow::Result { + let mut document: serde_yaml::Value = + serde_yaml::from_str(plaintext).context("parse decrypted secrets YAML")?; + let mut parts = sops_key.split('/').peekable(); + replace_yaml_path(&mut document, &mut parts, value)?; + serde_yaml::to_string(&document).context("serialize secrets YAML") } /// Removes a SOPS secret declaration from the nix-darwin module, returning an error if the declaration was not found. @@ -1431,11 +1622,13 @@ fn secret( #[cfg(test)] mod tests { use super::{ - age_decrypt_command, age_encrypt_command, find_agenix_declaration_file, - find_agenix_rules_file, find_sops_declaration_file, matching_agenix_rule_key, + age_decrypt_command, age_encrypt_command, agenix_edit_recipients, + find_agenix_declaration_file, find_agenix_rules_file, find_sops_declaration_file, + match_repo_file_by_name_and_contents, matching_agenix_rule_key, nix_store_source_name, readable_agenix_identity_paths, relative_nix_path_between, relative_path_between, - repo_relative_path_string, resolve_secret_file_path, secret, sops_decrypt_command, - sops_extract_path, updated_sops_plaintext, validate_new_secret, + replace_sops_value, repo_relative_path_string, resolve_secret_file_path, + resolve_sops_source_file, secret, sops_decrypt_command, sops_extract_path, + updated_sops_plaintext, validate_new_secret, }; use crate::shared_types::{ DecryptionIdentity, DecryptionIdentityKind, DecryptionIdentityLocality, @@ -1624,6 +1817,41 @@ mod tests { ); } + #[test] + fn agenix_edit_preserves_only_the_secret_recorded_recipients() { + let mut entry = secret( + "api-token", + "api-token", + crate::shared_types::SecretBackend::Agenix, + "secrets/api-token.age", + None, + ); + entry.public_recipients_resolved = true; + entry.public_recipients = vec!["age1existing".into(), "ssh-ed25519 AAAAexisting".into()]; + + assert_eq!( + agenix_edit_recipients(&entry).expect("resolved recipients"), + ["age1existing", "ssh-ed25519 AAAAexisting"] + ); + } + + #[test] + fn agenix_edit_refuses_missing_or_unresolved_recipients() { + let mut entry = secret( + "api-token", + "api-token", + crate::shared_types::SecretBackend::Agenix, + "secrets/api-token.age", + None, + ); + entry.public_recipients = vec!["age1existing".into()]; + assert!(agenix_edit_recipients(&entry).is_err()); + + entry.public_recipients_resolved = true; + entry.public_recipients.clear(); + assert!(agenix_edit_recipients(&entry).is_err()); + } + #[test] fn sops_extract_path_escapes_key_segments() { assert_eq!( @@ -1649,6 +1877,142 @@ mod tests { assert!(!encrypted_path.exists(), "plaintext must never touch disk"); } + #[test] + fn editing_sops_plaintext_replaces_only_the_requested_nested_value() { + let rendered = replace_sops_value( + "github:\n token: old\n user: octocat\nother: preserved\n", + "github/token", + "line one\nline two", + ) + .expect("replace nested value"); + let parsed: serde_yaml::Value = serde_yaml::from_str(&rendered).expect("parse YAML"); + + assert_eq!(parsed["github"]["token"], "line one\nline two"); + assert_eq!(parsed["github"]["user"], "octocat"); + assert_eq!(parsed["other"], "preserved"); + } + + #[test] + fn editing_sops_plaintext_requires_an_existing_key() { + let error = replace_sops_value("github:\n token: old\n", "github/missing", "new") + .expect_err("missing key must fail"); + + assert!(error.to_string().contains("does not exist")); + } + + #[test] + fn editing_sops_plaintext_replaces_a_top_level_non_string_value() { + let rendered = replace_sops_value("enabled: true\nother: 42\n", "enabled", "new value") + .expect("replace top-level value"); + let parsed: serde_yaml::Value = serde_yaml::from_str(&rendered).expect("parse YAML"); + + assert_eq!(parsed["enabled"], "new value"); + assert_eq!(parsed["other"], 42); + } + + #[test] + fn editing_sops_plaintext_refuses_to_traverse_a_scalar() { + let error = replace_sops_value("github: scalar\n", "github/token", "new") + .expect_err("scalar traversal must fail"); + + assert!( + error + .to_string() + .contains("does not refer to a YAML mapping") + ); + } + + #[test] + fn sops_source_resolution_accepts_repository_paths() { + let config_dir = TempDir::new().expect("create config dir"); + let source = config_dir.path().join("secrets/api-key.yaml"); + fs::create_dir_all(source.parent().unwrap()).expect("create secrets dir"); + fs::write(&source, "encrypted contents").expect("write source secret"); + + assert_eq!( + resolve_sops_source_file(config_dir.path(), "secrets/api-key.yaml") + .expect("resolve relative source"), + source.canonicalize().unwrap() + ); + assert_eq!( + resolve_sops_source_file(config_dir.path(), source.to_str().unwrap()) + .expect("resolve absolute source"), + source + ); + } + + #[test] + fn nix_store_source_name_preserves_hyphens_in_the_original_name() { + assert_eq!( + nix_store_source_name("qvig1r3ycb2y3jrhwj287fi5q9i1dasa-my-app-api-key.yaml"), + Some("my-app-api-key.yaml") + ); + assert_eq!(nix_store_source_name("short-hash-secret.yaml"), None); + assert_eq!( + nix_store_source_name("qvig1r3ycb2y3jrhwj287fi5q9i1dasa-"), + None + ); + } + + #[test] + fn evaluated_sops_file_matches_its_unique_repository_source() { + let config_dir = TempDir::new().expect("create config dir"); + let evaluated_dir = TempDir::new().expect("create evaluated dir"); + let source = config_dir.path().join("secrets/my-app-api-key.yaml"); + fs::create_dir_all(source.parent().unwrap()).expect("create secrets dir"); + fs::write(&source, "encrypted contents").expect("write source secret"); + let evaluated = evaluated_dir.path().join("store-copy.yaml"); + fs::write(&evaluated, "encrypted contents").expect("write evaluated secret"); + + assert_eq!( + match_repo_file_by_name_and_contents( + config_dir.path(), + &evaluated, + "my-app-api-key.yaml", + ) + .expect("match repository source"), + source + ); + } + + #[test] + fn evaluated_sops_file_refuses_ambiguous_repository_sources() { + let config_dir = TempDir::new().expect("create config dir"); + let evaluated_dir = TempDir::new().expect("create evaluated dir"); + for directory in ["secrets", "other"] { + let source = config_dir.path().join(directory).join("shared.yaml"); + fs::create_dir_all(source.parent().unwrap()).expect("create source dir"); + fs::write(source, "same encrypted contents").expect("write source secret"); + } + let evaluated = evaluated_dir.path().join("store-copy.yaml"); + fs::write(&evaluated, "same encrypted contents").expect("write evaluated secret"); + + let error = + match_repo_file_by_name_and_contents(config_dir.path(), &evaluated, "shared.yaml") + .expect_err("ambiguous sources must fail"); + assert!(error.to_string().contains("Multiple repository files")); + } + + #[test] + fn evaluated_sops_file_refuses_same_name_with_different_contents() { + let config_dir = TempDir::new().expect("create config dir"); + let evaluated_dir = TempDir::new().expect("create evaluated dir"); + let source = config_dir.path().join("secrets/shared.yaml"); + fs::create_dir_all(source.parent().unwrap()).expect("create source dir"); + fs::write(source, "old encrypted contents").expect("write source secret"); + let evaluated = evaluated_dir.path().join("store-copy.yaml"); + fs::write(&evaluated, "different encrypted contents").expect("write evaluated secret"); + + let error = + match_repo_file_by_name_and_contents(config_dir.path(), &evaluated, "shared.yaml") + .expect_err("different contents must not match"); + assert!( + error + .to_string() + .contains("Could not find repository source") + ); + } + #[test] fn add_secret_validation_accepts_only_lowercase_slugs() { assert!(validate_new_secret("github-token-2", "value").is_ok()); diff --git a/apps/native/src-tauri/src/shared_types/secrets_management.rs b/apps/native/src-tauri/src/shared_types/secrets_management.rs index e3c84bf39..db7f67323 100644 --- a/apps/native/src-tauri/src/shared_types/secrets_management.rs +++ b/apps/native/src-tauri/src/shared_types/secrets_management.rs @@ -157,6 +157,15 @@ pub struct AddSecretResult { pub commit_hash: String, } +/// Result of replacing and re-encrypting a secret value. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct EditSecretResult { + pub secret_id: String, + pub encrypted_file: String, + pub commit_hash: String, +} + /// Result of removing a secret from the repo and committing the change. #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] diff --git a/apps/native/src-tauri/src/yaml_utils.rs b/apps/native/src-tauri/src/yaml_utils.rs new file mode 100644 index 000000000..bdd46d6df --- /dev/null +++ b/apps/native/src-tauri/src/yaml_utils.rs @@ -0,0 +1,249 @@ +//! Utilities for working with YAML documents. + +use anyhow::anyhow; + +/// Recursively replaces a scalar value at a slash-delimited path in a YAML document. +pub fn replace_yaml_path<'a, I>( + current: &mut serde_yaml::Value, + parts: &mut std::iter::Peekable, + replacement: &str, +) -> anyhow::Result<()> +where + I: Iterator, +{ + let part = parts + .next() + .filter(|part| !part.is_empty()) + .ok_or_else(|| anyhow!("SOPS key must not be empty"))?; + let mapping = current + .as_mapping_mut() + .ok_or_else(|| anyhow!("SOPS key path '{part}' does not refer to a YAML mapping"))?; + let key = serde_yaml::Value::String(part.to_string()); + let child = mapping + .get_mut(&key) + .ok_or_else(|| anyhow!("SOPS key path '{part}' does not exist"))?; + if parts.peek().is_none() { + *child = serde_yaml::Value::String(replacement.to_string()); + return Ok(()); + } + replace_yaml_path(child, parts, replacement) +} + +/// Recursively removes a path from a YAML document, returning true if the path was found and removed. +pub fn remove_yaml_path<'a, I>( + value: &mut serde_yaml::Value, + parts: &mut std::iter::Peekable, +) -> anyhow::Result +where + I: Iterator, +{ + let part = parts + .next() + .filter(|part| !part.is_empty()) + .ok_or_else(|| anyhow!("SOPS key must not be empty"))?; + let mapping = value + .as_mapping_mut() + .ok_or_else(|| anyhow!("SOPS key path '{part}' does not refer to a YAML mapping"))?; + let key = serde_yaml::Value::String(part.to_string()); + if parts.peek().is_none() { + return Ok(mapping.remove(&key).is_some()); + } + let Some(child) = mapping.get_mut(&key) else { + return Ok(false); + }; + let removed = remove_yaml_path(child, parts)?; + if removed + && child + .as_mapping() + .is_some_and(serde_yaml::Mapping::is_empty) + { + mapping.remove(&key); + } + Ok(removed) +} + +/// Validate basic syntax of a .yaml or .yml file using serde_yaml. +pub fn validate_yaml_syntax(content: &str, file_path: &str) -> anyhow::Result<()> { + // Try to parse the YAML content. serde_yaml will catch syntax errors + // like unmatched quotes, braces, brackets, etc. + serde_yaml::from_str::(content) + .map_err(|e| anyhow::anyhow!("Syntax error in {}: {}", file_path, e))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn yaml(source: &str) -> anyhow::Result { + Ok(serde_yaml::from_str(source)?) + } + + #[test] + fn happy_replace_yaml_path() -> anyhow::Result<()> { + let mut doc = yaml("a:\n b:\n c: old_value\n sibling: preserved\n")?; + + replace_yaml_path(&mut doc, &mut "a/b/c".split('/').peekable(), "new_value")?; + + assert_eq!(doc["a"]["b"]["c"], "new_value"); + assert_eq!(doc["a"]["b"]["sibling"], "preserved"); + Ok(()) + } + + #[test] + fn happy_remove_yaml_path() -> anyhow::Result<()> { + let mut doc = yaml("a:\n b:\n c: value\n")?; + + let removed = remove_yaml_path(&mut doc, &mut "a/b/c".split('/').peekable())?; + + assert!(removed); + assert_eq!(doc, yaml("{}")?); + Ok(()) + } + + #[test] + fn replace_yaml_path_replaces_a_top_level_value() -> anyhow::Result<()> { + let mut doc = yaml("secret: old\nother: preserved\n")?; + + replace_yaml_path(&mut doc, &mut "secret".split('/').peekable(), "new")?; + + assert_eq!(doc, yaml("secret: new\nother: preserved\n")?); + Ok(()) + } + + #[test] + fn replace_yaml_path_rejects_an_empty_path() -> anyhow::Result<()> { + let mut doc = yaml("secret: value\n")?; + + let error = replace_yaml_path(&mut doc, &mut "".split('/').peekable(), "new") + .expect_err("an empty path should fail"); + + assert_eq!(error.to_string(), "SOPS key must not be empty"); + Ok(()) + } + + #[test] + fn replace_yaml_path_reports_a_missing_key() -> anyhow::Result<()> { + let mut doc = yaml("a:\n b: value\n")?; + + let error = replace_yaml_path(&mut doc, &mut "a/missing".split('/').peekable(), "new") + .expect_err("a missing key should fail"); + + assert_eq!(error.to_string(), "SOPS key path 'missing' does not exist"); + Ok(()) + } + + #[test] + fn replace_yaml_path_rejects_traversal_through_a_scalar() -> anyhow::Result<()> { + let mut doc = yaml("a: value\n")?; + + let error = replace_yaml_path(&mut doc, &mut "a/b".split('/').peekable(), "new") + .expect_err("a scalar cannot contain another key"); + + assert_eq!( + error.to_string(), + "SOPS key path 'b' does not refer to a YAML mapping" + ); + Ok(()) + } + + #[test] + fn remove_yaml_path_preserves_siblings() -> anyhow::Result<()> { + let mut doc = yaml("a:\n b:\n remove: value\n keep: sibling\nother: top-level\n")?; + + let removed = remove_yaml_path(&mut doc, &mut "a/b/remove".split('/').peekable())?; + + assert!(removed); + assert_eq!( + doc, + yaml("a:\n b:\n keep: sibling\nother: top-level\n")? + ); + Ok(()) + } + + #[test] + fn remove_yaml_path_returns_false_without_modifying_a_missing_path() -> anyhow::Result<()> { + let mut doc = yaml("a:\n b: value\n")?; + let original = doc.clone(); + + let removed = remove_yaml_path(&mut doc, &mut "a/missing".split('/').peekable())?; + + assert!(!removed); + assert_eq!(doc, original); + Ok(()) + } + + #[test] + fn remove_yaml_path_removes_a_top_level_value() -> anyhow::Result<()> { + let mut doc = yaml("remove: value\nkeep: sibling\n")?; + + let removed = remove_yaml_path(&mut doc, &mut "remove".split('/').peekable())?; + + assert!(removed); + assert_eq!(doc, yaml("keep: sibling\n")?); + Ok(()) + } + + #[test] + fn remove_yaml_path_rejects_an_empty_path_component() -> anyhow::Result<()> { + let mut doc = yaml("a:\n b: value\n")?; + + let error = remove_yaml_path(&mut doc, &mut "a//b".split('/').peekable()) + .expect_err("an empty path component should fail"); + + assert_eq!(error.to_string(), "SOPS key must not be empty"); + Ok(()) + } + + #[test] + fn remove_yaml_path_rejects_traversal_through_a_scalar() -> anyhow::Result<()> { + let mut doc = yaml("a: value\n")?; + + let error = remove_yaml_path(&mut doc, &mut "a/b".split('/').peekable()) + .expect_err("a scalar cannot contain another key"); + + assert_eq!( + error.to_string(), + "SOPS key path 'b' does not refer to a YAML mapping" + ); + Ok(()) + } + + #[test] + fn validate_yaml_syntax_accepts_valid_yaml() { + let valid_yaml = r#" +name: test +config: + enable: true + items: + - first + - second +"#; + + super::validate_yaml_syntax(valid_yaml, "test.yaml") + .expect("should parse valid yaml syntax"); + } + + #[test] + fn validate_yaml_syntax_rejects_unmatched_braces() { + let invalid_yaml = r#" +config: { unclosed: value +"#; + + let err = super::validate_yaml_syntax(invalid_yaml, "test.yaml") + .expect_err("should reject unmatched braces"); + assert!(err.to_string().contains("Syntax error")); + } + + #[test] + fn validate_yaml_syntax_rejects_unclosed_string() { + let invalid_yaml = r#" +key: "unclosed string value +"#; + + let err = super::validate_yaml_syntax(invalid_yaml, "test.yaml") + .expect_err("should reject unclosed string"); + assert!(err.to_string().contains("Syntax error")); + } +} diff --git a/apps/native/src/components/widget/secrets/__snapshots__/secrets-management.stories.tsx.snap b/apps/native/src/components/widget/secrets/__snapshots__/secrets-management.stories.tsx.snap index 73d122d6a..46a71fa4a 100644 --- a/apps/native/src/components/widget/secrets/__snapshots__/secrets-management.stories.tsx.snap +++ b/apps/native/src/components/widget/secrets/__snapshots__/secrets-management.stories.tsx.snap @@ -1,17 +1,17 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Add Recipient 1`] = `"

nixmac — secrets

Keys & recipients

Public recipients describe who encrypted files are addressed to. Local decryption identities are private-key sources available through configuration, this process, or this machine; recipient membership alone does not prove decryption capability.

Local decryption identities

configuration-local/etc/ssh/ssh_host_ed25519_keySSH key path
process-local/Users/demo/.config/sops/age/keys.txtage key file

Public recipients

Demo-MacBook-Pro
Local identityHost decryption identity
age1qy8x0v4k2n7pq3wl9d0m5s8t1r6c4h2j
Opens 4 secretsIn repo
work-mac-mini
Host decryption identity
age1ld0k2r7m4s9pqx3v6n8t1w5c2h4j7q0
Opens 3 secretsIn repo
yubikey-personal
User decryption identity
age1yubikey1qw8x3v0k2m7n4p9s6t1r5c2
Opens 1 secretIn repo
framework-13
Host decryption identity
age1f3w9d2z0k8rql5m7n4p6s1t3v2c9h0j
Opens 0 secretsNot committed
"`; +exports[`Add Recipient 1`] = `"

nixmac — secrets

Keys & recipients

Public recipients describe who encrypted files are addressed to. Local decryption identities are private-key sources available through configuration, this process, or this machine; recipient membership alone does not prove decryption capability.

Local decryption identities

configuration-local/etc/ssh/ssh_host_ed25519_keySSH key path
process-local/Users/demo/.config/sops/age/keys.txtage key file

Public recipients

Demo-MacBook-Pro
Local identityHost decryption identity
age1qy8x0v4k2n7pq3wl9d0m5s8t1r6c4h2j
Opens 4 secretsIn repo
work-mac-mini
Host decryption identity
age1ld0k2r7m4s9pqx3v6n8t1w5c2h4j7q0
Opens 3 secretsIn repo
yubikey-personal
User decryption identity
age1yubikey1qw8x3v0k2m7n4p9s6t1r5c2
Opens 1 secretIn repo
framework-13
Host decryption identity
age1f3w9d2z0k8rql5m7n4p6s1t3v2c9h0j
Opens 0 secretsNot committed
"`; -exports[`Add Secret 1`] = `"

nixmac — secrets

Add a secret

Backend

Recommended — YAML file with .sops.yaml rules; decrypts to a runtime path and can be exposed to the agent.

Encrypts to secrets/secrets.yaml › new-secret

Runtime path
/run/secrets/new-secret

Decrypted to this path at activation so programs can read the plaintext. Set owner/mode to scope who can read it.

Recipients — who can decrypt
"`; +exports[`Add Secret 1`] = `"

nixmac — secrets

Add a secret

Backend

YAML encrypted with the repository's SOPS creation rules.

Encrypts to secrets/secrets.yaml › new-secret

Runtime path
/run/secrets/new-secret

Decrypted to this path at activation so programs can read the plaintext. Set owner/mode to scope who can read it.

Recipients — who can decrypt

Encryption uses the recipients registered in the repository's .sops.yaml.

Demo-MacBook-ProHost decryption identity
work-mac-miniHost decryption identity
"`; -exports[`Keys And Recipients 1`] = `"

nixmac — secrets

Keys & recipients

Public recipients describe who encrypted files are addressed to. Local decryption identities are private-key sources available through configuration, this process, or this machine; recipient membership alone does not prove decryption capability.

Local decryption identities

configuration-local/etc/ssh/ssh_host_ed25519_keySSH key path
process-local/Users/demo/.config/sops/age/keys.txtage key file

Public recipients

Demo-MacBook-Pro
Local identityHost decryption identity
age1qy8x0v4k2n7pq3wl9d0m5s8t1r6c4h2j
Opens 4 secretsIn repo
work-mac-mini
Host decryption identity
age1ld0k2r7m4s9pqx3v6n8t1w5c2h4j7q0
Opens 3 secretsIn repo
yubikey-personal
User decryption identity
age1yubikey1qw8x3v0k2m7n4p9s6t1r5c2
Opens 1 secretIn repo
framework-13
Host decryption identity
age1f3w9d2z0k8rql5m7n4p6s1t3v2c9h0j
Opens 0 secretsNot committed
"`; +exports[`Keys And Recipients 1`] = `"

nixmac — secrets

Keys & recipients

Public recipients describe who encrypted files are addressed to. Local decryption identities are private-key sources available through configuration, this process, or this machine; recipient membership alone does not prove decryption capability.

Local decryption identities

configuration-local/etc/ssh/ssh_host_ed25519_keySSH key path
process-local/Users/demo/.config/sops/age/keys.txtage key file

Public recipients

Demo-MacBook-Pro
Local identityHost decryption identity
age1qy8x0v4k2n7pq3wl9d0m5s8t1r6c4h2j
Opens 4 secretsIn repo
work-mac-mini
Host decryption identity
age1ld0k2r7m4s9pqx3v6n8t1w5c2h4j7q0
Opens 3 secretsIn repo
yubikey-personal
User decryption identity
age1yubikey1qw8x3v0k2m7n4p9s6t1r5c2
Opens 1 secretIn repo
framework-13
Host decryption identity
age1f3w9d2z0k8rql5m7n4p6s1t3v2c9h0j
Opens 0 secretsNot committed
"`; -exports[`Rotate And Rekey 1`] = `"

nixmac — secrets

Rotate & re-key

Re-encrypt secrets to their current recipient list — run this after adding or removing a key so every selected file can be decrypted by the right hosts.

Also generate a fresh value
Rotate the underlying secret, not just its encryption
"`; +exports[`Rotate And Rekey 1`] = `"

nixmac — secrets

Rotate & re-key

Re-encrypt secrets to their current recipient list — run this after adding or removing a key so every selected file can be decrypted by the right hosts.

Also generate a fresh value
Rotate the underlying secret, not just its encryption
"`; -exports[`Secret Detail 1`] = `"

nixmac — secrets

github-token

agenix
File
secrets/github-token.age
Decrypted value
****************
Public recipients recorded for this secret
Demo-MacBook-Prolocal identityrecorded recipient
work-mac-minirecorded recipient
"`; +exports[`Secret Detail 1`] = `"

nixmac — secrets

github-token

agenix
File
secrets/github-token.age
Decrypted value
****************
Public recipients recorded for this secret
Demo-MacBook-Prolocal identityrecorded recipient
work-mac-minirecorded recipient
"`; -exports[`Secret Detail No Access 1`] = `"

nixmac — secrets

cachix_signing_key

sops-nix
File
secrets/cachix.yaml
SOPS key
signing_key
Decrypted value
Capability is unknown; revealing will ask SOPS to try the identities available to this process.
****************
Public recipients recorded for this secret
work-mac-minirecorded recipient
"`; +exports[`Secret Detail No Access 1`] = `"

nixmac — secrets

cachix_signing_key

sops-nix
File
secrets/cachix.yaml
SOPS key
signing_key
Decrypted value
Capability is unknown; revealing will ask SOPS to try the identities available to this process.
****************
Public recipients recorded for this secret
work-mac-minirecorded recipient
"`; -exports[`Vault 1`] = `"

nixmac — secrets

Demo-MacBook-ProLocal identity
This Mac
SSH host identity → age
Primary identity's public recipient
age1qy8x0v4k2n7pq3wl9d0m5s8t1r6c4h2j
SHA256:0f2a b7e4 … 9c1d
Registered in repo
Yes
secrets/secrets.nix.sops.yaml
Known available here
4/ 5
secrets in this repo
1 more unknown
Local decryption identity sources
configuration · SSH key pathprocess · age key file
SecretBackendFileRecipientsLocal capability
"`; +exports[`Vault 1`] = `"

nixmac — secrets

Demo-MacBook-ProLocal identity
This Mac
SSH host identity → age
Primary identity's public recipient
age1qy8x0v4k2n7pq3wl9d0m5s8t1r6c4h2j
SHA256:0f2a b7e4 … 9c1d
Registered in repo
Yes
secrets/secrets.nix.sops.yaml
Known available here
4/ 5
secrets in this repo
1 more unknown
Local decryption identity sources
configuration · SSH key pathprocess · age key file
SecretBackendFileRecipientsLocal capability
"`; -exports[`Without Prompt Bar 1`] = `"

nixmac — secrets

Demo-MacBook-ProLocal identity
This Mac
SSH host identity → age
Primary identity's public recipient
age1qy8x0v4k2n7pq3wl9d0m5s8t1r6c4h2j
SHA256:0f2a b7e4 … 9c1d
Registered in repo
Yes
secrets/secrets.nix.sops.yaml
Known available here
4/ 5
secrets in this repo
1 more unknown
Local decryption identity sources
configuration · SSH key pathprocess · age key file
SecretBackendFileRecipientsLocal capability
"`; +exports[`Without Prompt Bar 1`] = `"

nixmac — secrets

Demo-MacBook-ProLocal identity
This Mac
SSH host identity → age
Primary identity's public recipient
age1qy8x0v4k2n7pq3wl9d0m5s8t1r6c4h2j
SHA256:0f2a b7e4 … 9c1d
Registered in repo
Yes
secrets/secrets.nix.sops.yaml
Known available here
4/ 5
secrets in this repo
1 more unknown
Local decryption identity sources
configuration · SSH key pathprocess · age key file
SecretBackendFileRecipientsLocal capability
"`; diff --git a/apps/native/src/components/widget/secrets/add-secret-view.test.ts b/apps/native/src/components/widget/secrets/add-secret-view.test.ts index caecdce4c..e04f78eee 100644 --- a/apps/native/src/components/widget/secrets/add-secret-view.test.ts +++ b/apps/native/src/components/widget/secrets/add-secret-view.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import type { SecretsVault } from "@/ipc/orpc-bindings"; -import { buildAddRequest } from "./add-secret-view"; +import type { SecretEntry, SecretsVault } from "@/ipc/orpc-bindings"; +import { buildAddRequest, buildEditRequest } from "./add-secret-view"; const vault = (declarationFile: string, encryptedDirectory: string): SecretsVault => ({ @@ -47,3 +47,39 @@ describe("agenix add preview", () => { }); }); }); + +describe("secret edit preview", () => { + it("touches only the existing encrypted file", () => { + const request = buildEditRequest({ + id: "github-token", + name: "github-token", + backend: "sops", + file: "secrets/team.yaml", + sopsKey: "github/token", + } as SecretEntry); + + expect(request.origin).toBe("edit"); + expect(request.files).toEqual([ + { path: "secrets/team.yaml", note: "· encrypted update", mark: "~" }, + ]); + expect(request.commitMsg).toBe("secrets: edit github-token (sops)"); + }); + + it("reviews an agenix edit as an update to only its existing encrypted file", () => { + const request = buildEditRequest({ + id: "api-token", + name: "api-token", + backend: "agenix", + file: "secrets/api-token.age", + } as SecretEntry); + + expect(request).toMatchObject({ + origin: "edit", + backend: "agenix", + diffFile: "secrets/api-token.age", + files: [{ path: "secrets/api-token.age", note: "· encrypted update", mark: "~" }], + commitMsg: "secrets: edit api-token (agenix)", + }); + expect(request.diff.some((line) => line.text.includes("agenix"))).toBe(true); + }); +}); diff --git a/apps/native/src/components/widget/secrets/add-secret-view.tsx b/apps/native/src/components/widget/secrets/add-secret-view.tsx index d077a5145..b86f15dee 100644 --- a/apps/native/src/components/widget/secrets/add-secret-view.tsx +++ b/apps/native/src/components/widget/secrets/add-secret-view.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import type { SecretBackend, SecretsVault } from "@/ipc/orpc-bindings"; +import type { SecretBackend, SecretEntry, SecretsVault } from "@/ipc/orpc-bindings"; import { cn } from "@/lib/utils"; import { recipientKindLabel, RecipientKindIcon, ViewHeader } from "./shared"; import { type ApplyRequest, slugifySecretName } from "./types"; @@ -70,6 +70,25 @@ export function buildAddRequest( }; } +export function buildEditRequest(secret: SecretEntry): ApplyRequest { + return { + origin: "edit", + backend: secret.backend, + title: "Encrypt & commit", + subtitle: `Edit secret · ${secret.id}`, + files: [{ path: secret.file, note: "· encrypted update", mark: "~" }], + diffFile: secret.file, + diff: [ + { kind: "meta", text: `@@ ${secret.backend === "agenix" ? "agenix" : "sops-nix"} @@` }, + { kind: "context", text: " # plaintext replaced and re-encrypted locally" }, + { kind: "removed", text: `- ${secret.id}: ENC[…previous value…]` }, + { kind: "added", text: `+ ${secret.id}: ENC[…new value…]` }, + ], + commit: "", + commitMsg: `secrets: edit ${secret.id} (${secret.backend})`, + }; +} + /** * The add-secret form: backend, name, value, runtime path preview, and the * recipients derived from repository configuration. Submitting hands a @@ -77,24 +96,25 @@ export function buildAddRequest( */ export function AddSecretView({ vault, + secret, onSubmit, onBack, }: { vault: SecretsVault; + secret?: SecretEntry; onSubmit: ( request: ApplyRequest, secret: { secretId: string; value: string; backend: SecretBackend }, ) => void; onBack: () => void; }) { - const [name, setName] = useState(""); + const editing = secret !== undefined; + const [name, setName] = useState(secret?.name ?? ""); const [value, setValue] = useState(""); const [hidden, setHidden] = useState(true); - const [backend, setBackend] = useState("sops"); + const [backend, setBackend] = useState(secret?.backend ?? "sops"); - const slug = slugifySecretName(name); - const encryptTarget = - backend === "agenix" ? `secrets/${slug}.age` : `secrets/secrets.yaml › ${slug}`; + const slug = secret?.id ?? slugifySecretName(name); const runtimePath = backend === "agenix" ? `/run/agenix/${slug}` : `/run/secrets/${slug}`; const agenixTargetsAvailable = Boolean( vault.agenixRulesFile && @@ -102,65 +122,83 @@ export function AddSecretView({ vault.agenixEncryptedDirectoryFromDeclaration, ); const invalid = - !name.trim() || !value.trim() || (backend === "agenix" && !agenixTargetsAvailable); - const committedRecipients = vault.recipients.filter((recipient) => - recipient.registrations.some((registration) => registration.backend === backend), - ); + !name.trim() || + !value.trim() || + (!editing && backend === "agenix" && !agenixTargetsAvailable); + const encryptionRecipients = + editing && backend === "agenix" + ? secret.publicRecipients.map((publicKey) => ({ + publicKey, + recipient: vault.recipients.find((recipient) => recipient.publicKey === publicKey), + })) + : vault.recipients + .filter((recipient) => + recipient.registrations.some((registration) => registration.backend === backend), + ) + .map((recipient) => ({ publicKey: recipient.publicKey, recipient })); const submit = () => { if (invalid) return; - onSubmit(buildAddRequest(slug, backend, vault), { secretId: slug, value, backend }); + onSubmit(secret ? buildEditRequest(secret) : buildAddRequest(slug, backend, vault), { + secretId: slug, + value, + backend, + }); }; return (
- + -
- - setName(e.target.value)} - placeholder="e.g. github-token" - /> -
+ {!editing && ( +
+ + setName(e.target.value)} + placeholder="e.g. github-token" + /> +
+ )} -
-
- Backend -
- {(["sops", "agenix"] as const).map((option) => ( - - ))} + {!editing && ( +
+
+ Backend +
+ {(["sops", "agenix"] as const).map((option) => ( + + ))} +
-
-

- {backend === "agenix" - ? "One age-encrypted file, using the recipients in the repository's agenix rules." - : "YAML encrypted with the repository's SOPS creation rules."} -

- {backend === "agenix" && !agenixTargetsAvailable && ( -

- Could not find both the agenix rules file and the module containing age.secrets. +

+ {backend === "agenix" + ? "One age-encrypted file, using the recipients in the repository's agenix rules." + : "YAML encrypted with the repository's SOPS creation rules."}

- )} -
+ {backend === "agenix" && !agenixTargetsAvailable && ( +

+ Could not find both the agenix rules file and the module containing age.secrets. +

+ )} +
+ )}
-
-
-
+ )}
Recipients — who can decrypt

- Encryption uses the recipients registered in the repository's{" "} - {backend === "agenix" ? "agenix rules" : .sops.yaml}. + {editing && backend === "agenix" ? ( + "The updated value keeps the recipients recorded for this secret." + ) : ( + <> + {editing ? "The updated value uses" : "Encryption uses"} the recipients registered in + the repository's{" "} + {backend === "agenix" ? ( + "agenix rules" + ) : ( + .sops.yaml + )} + . + + )}

- {committedRecipients.length === 0 && ( + {encryptionRecipients.length === 0 && (

- No recipients are registered for this backend. Adding the secret will fail until one - is configured. + {editing && backend === "agenix" + ? "No recipients are recorded for this secret. Encrypting the update will fail until its recipients can be resolved." + : "No recipients are registered for this backend. Encrypting the secret will fail until one is configured."}

)} - {committedRecipients.map((recipient) => { + {encryptionRecipients.map(({ publicKey, recipient }) => { + const label = recipient?.label ?? publicKey; return (
- - {recipient.label} + + {label} - {recipientKindLabel(recipient.kind)} + {recipientKindLabel(recipient?.kind ?? "unknown")}
); @@ -236,7 +298,7 @@ export function AddSecretView({
)} - { canRotate && ( + {canRotate && (