Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions apps/native/src-tauri/src/evolve/file_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,21 @@ pub(crate) fn relative_path_between(from: &Path, to: &Path) -> anyhow::Result<Pa
Ok(relative)
}

/// Compute a lexical relative path rendered as a portable Nix path literal.
///
/// Unlike a plain filesystem path, a same-directory or child Nix path must
/// start with `./` so it is parsed as a path rather than an identifier.
pub(crate) fn relative_nix_path_between(from: &Path, to: &Path) -> anyhow::Result<String> {
let rendered = relative_path_between(from, to)?
.to_string_lossy()
.replace('\\', "/");
Ok(if rendered.starts_with('.') {
rendered
} else {
format!("./{rendered}")
})
}

/// Canonicalize and validate a path exists under `base`.
pub(crate) fn resolve_existing_path_in_dir(base: &Path, rel: &str) -> anyhow::Result<PathBuf> {
let full_path = join_in_dir(base, rel)?;
Expand Down Expand Up @@ -524,8 +539,8 @@ fn reject_gitignored_edit_path(
#[cfg(test)]
mod tests {
use super::{
apply_file_edits, relative_path_between, repo_relative_path, repo_relative_path_string,
rewrite_existing_file_in_dir,
apply_file_edits, relative_nix_path_between, relative_path_between, repo_relative_path,
repo_relative_path_string, rewrite_existing_file_in_dir,
};
use crate::evolve::gitignore::GitignoreChecker;
use crate::shared_types::FileEdit;
Expand Down Expand Up @@ -556,6 +571,16 @@ mod tests {
.expect("compute identity path"),
Path::new(".")
);
assert_eq!(
relative_nix_path_between(Path::new("modules"), Path::new("secrets/token.age"))
.expect("render parent Nix path"),
"../secrets/token.age"
);
assert_eq!(
relative_nix_path_between(Path::new(""), Path::new("secrets/token.age"))
.expect("render child Nix path"),
"./secrets/token.age"
);
}

#[test]
Expand Down
127 changes: 112 additions & 15 deletions apps/native/src-tauri/src/evolve/nix_file_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,46 @@ fn normalize_attrpath_for_match(input: &str) -> String {
.collect()
}

/// Split a Nix attribute path without treating dots inside quoted attribute
/// names as separators (for example `age.secrets."api.token"`).
fn split_attrpath_for_match(attrpath: &str) -> Vec<String> {
let mut segments = Vec::new();
let mut current = String::new();
let mut quoted = false;
let mut escaped = false;
for ch in attrpath.chars() {
if escaped {
current.push(ch);
escaped = false;
continue;
}
if quoted && ch == '\\' {
current.push(ch);
escaped = true;
continue;
}
if ch == '"' {
quoted = !quoted;
current.push(ch);
continue;
}
if ch == '.' && !quoted {
let normalized = normalize_attrpath_for_match(&current);
if !normalized.is_empty() {
segments.push(normalized);
}
current.clear();
} else {
current.push(ch);
}
}
let normalized = normalize_attrpath_for_match(&current);
if !normalized.is_empty() {
segments.push(normalized);
}
segments
}

fn render_nix_string(value: &str) -> String {
let mut rendered = String::from("\"");
for ch in value.chars() {
Expand Down Expand Up @@ -631,17 +671,8 @@ pub(crate) fn infer_single_list_attrpath(content: &str) -> Result<Option<String>
/// This is the structural counterpart to `find_assignment_value_range`, which
/// matches only a flat dotted LHS and so cannot see a leaf nested inside an
/// attrset literal — the case that let `add()` insert a duplicate assignment.
///
/// Known limitation: a quoted key containing dots (e.g.
/// `NSGlobalDomain."com.apple.sound.beep.feedback"`) is one AST segment but
/// splits into several here, so such paths won't resolve. That matches the
/// existing dot-splitting behaviour elsewhere in this module.
fn find_attrpath_value_node(root: &SyntaxNode, attrpath: &str) -> Option<SyntaxNode> {
let target: Vec<String> = attrpath
.split('.')
.map(normalize_attrpath_for_match)
.filter(|segment| !segment.is_empty())
.collect();
let target = split_attrpath_for_match(attrpath);
if target.is_empty() {
return None;
}
Expand Down Expand Up @@ -912,11 +943,7 @@ fn remove(content: &str, attrpath: &str, values: &[String]) -> Result<String> {
/// attrsets are intentionally preserved. An absent attrpath is reported as an error so
/// callers performing destructive operations do not silently succeed.
pub(crate) fn remove_attrpath(content: &str, attrpath: &str) -> Result<String> {
let target = attrpath
.split('.')
.map(normalize_attrpath_for_match)
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
let target = split_attrpath_for_match(attrpath);
if target.is_empty() {
return Err(anyhow::anyhow!("Nix attrpath must not be empty"));
}
Expand Down Expand Up @@ -955,6 +982,37 @@ pub(crate) fn remove_attrpath(content: &str, attrpath: &str) -> Result<String> {
Ok(updated)
}

/// Remove an attribute path from an existing Nix file and optionally format it.
///
/// This is the filesystem counterpart to [`remove_attrpath`]. Keeping it here
/// ensures callers share the same path-safety, read-modify-write, and formatting
/// behavior instead of implementing their own Nix file mutation wrappers.
pub(crate) fn remove_attrpath_in_file(
base: &Path,
relative_file: &str,
attrpath: &str,
auto_format: bool,
) -> Result<()> {
rewrite_existing_file_in_dir(
base,
relative_file,
"remove Nix attribute path",
None,
|content| remove_attrpath(content, attrpath),
)?;

if auto_format {
let config_dir = base.to_string_lossy();
if let Err(error) = nix_format(&config_dir, relative_file) {
log::warn!(
"Removed Nix attrpath '{attrpath}', but formatting {relative_file} failed: {error}"
);
}
}

Ok(())
}

/// Helper to recursively collect the byte ranges of all assignments that match a given attrpath prefix, including nested attrsets.
fn collect_attrpath_assignment_ranges(
attrset: &AttrSet,
Expand Down Expand Up @@ -1493,6 +1551,45 @@ environment.systemPackages = with pkgs; [
assert!(error.to_string().contains("does not exist"));
}

#[test]
fn remove_attrpath_preserves_dots_inside_quoted_segments() {
let rules = r#"{
"api.token.age".publicKeys = [ "age1example" ];
"other.age".publicKeys = [ "age1example" ];
}
"#;
let updated =
remove_attrpath(rules, "\"api.token.age\"").expect("remove quoted agenix rule");

assert!(!updated.contains("api.token.age"));
assert!(updated.contains("other.age"));
}

#[test]
fn remove_attrpath_in_file_rewrites_only_the_target_assignment() {
let repo = tempfile::tempdir().expect("create temporary repository");
let relative_file = "secrets/secrets.nix";
let directory = repo.path().join("secrets");
std::fs::create_dir(&directory).expect("create secrets directory");
let file = directory.join("secrets.nix");
std::fs::write(
&file,
r#"{
"api.token.age".publicKeys = [ "age1example" ];
"other.age".publicKeys = [ "age1example" ];
}
"#,
)
.expect("write Nix fixture");

remove_attrpath_in_file(repo.path(), relative_file, "\"api.token.age\"", false)
.expect("remove attrpath from file");

let updated = std::fs::read_to_string(file).expect("read updated Nix file");
assert!(!updated.contains("api.token.age"));
assert!(updated.contains("other.age"));
}

#[test]
fn add_updates_existing_list_when_comments_precede_assignment() {
let edited = add(
Expand Down
127 changes: 68 additions & 59 deletions apps/native/src-tauri/src/secrets/recipients.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,19 @@ pub(crate) fn load_recipients(
))
}

/// Resolve the repository's active SOPS config file. The repo root is treated as
/// the source of truth, so a single helper keeps discovery stable for both the
/// `.sops.yaml` and `sops.yaml` conventions.
fn sops_config_path(config_dir: &Path) -> Option<PathBuf> {
if config_dir.join(".sops.yaml").exists() {
Some(config_dir.join(".sops.yaml"))
} else if config_dir.join("sops.yaml").exists() {
Some(config_dir.join("sops.yaml"))
} else {
None
}
}

/// Load the public recipients declared by the repository's SOPS config.
///
/// YAML aliases resolve to their scalar values during deserialization, but their
Expand All @@ -410,11 +423,7 @@ pub(crate) fn load_recipients(
/// source scan recovers optional friendly names such as `&build-server`.
/// If this doesn't make sense, see the unit tests.
fn load_sops_config_recipients(config_dir: &Path) -> Result<Vec<ConfigRecipient>, String> {
let config_path = if config_dir.join(".sops.yaml").exists() {
config_dir.join(".sops.yaml")
} else if config_dir.join("sops.yaml").exists() {
config_dir.join("sops.yaml")
} else {
let Some(config_path) = sops_config_path(config_dir) else {
return Ok(Vec::new());
};
let source = fs::read_to_string(&config_path)
Expand Down Expand Up @@ -553,19 +562,14 @@ fn load_agenix_rules(
config_dir.display(),
rules_override.is_some()
);
let rules_path = if let Some(rules_override) = rules_override {
let configured_path = PathBuf::from(rules_override);
match resolve_agenix_rules_path(config_dir, &configured_path) {
Ok(path) => Some(path),
Err(error) => {
return handle_agenix_rules_result(&configured_path, true, Err(error));
}
let rules_path = match discover_agenix_rules_path(config_dir) {
Ok(path) => path,
Err(error) => {
let configured_path = rules_override
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("$RULES"));
return handle_agenix_rules_result(&configured_path, true, Err(error));
}
} else {
["secrets.nix", "secrets/secrets.nix"]
.into_iter()
.map(|path| config_dir.join(path))
.find(|path| path.is_file())
};
let Some(rules_path) = rules_path else {
log::debug!(
Expand All @@ -577,6 +581,19 @@ fn load_agenix_rules(
handle_agenix_rules_result(&rules_path, explicitly_configured, result)
}

/// Discover the classic agenix rules path using the same precedence as agenix:
/// `$RULES`, `secrets.nix`, then `secrets/secrets.nix`.
pub(crate) fn discover_agenix_rules_path(config_dir: &Path) -> Result<Option<PathBuf>, String> {
if let Some(rules_override) = std::env::var_os("RULES").filter(|value| !value.is_empty()) {
return resolve_agenix_rules_path(config_dir, &PathBuf::from(rules_override)).map(Some);
}

Ok(["secrets.nix", "secrets/secrets.nix"]
.into_iter()
.map(|path| config_dir.join(path))
.find(|path| path.is_file()))
}

/// Resolve an inherited rules override without allowing a relative path or
/// symlink to escape the selected repository. Absolute paths remain supported
/// because Nix configurations may intentionally keep their rules elsewhere.
Expand Down Expand Up @@ -688,6 +705,38 @@ fn handle_agenix_rules_result(
}
}

/// Resolve which evaluated agenix secret declarations match a rule entry.
/// We prefer an exact path match and fall back to a unique basename match only
/// when the rules file cannot preserve the directory layout (for example when
/// builtins.path expands to a store path).
fn match_agenix_secret_entries<'a>(
secret_file: &str,
secret_entries: &'a [SecretEntry],
) -> Vec<&'a SecretEntry> {
let exact_matching_entries: Vec<&SecretEntry> = secret_entries
.iter()
.filter(|entry| {
entry.backend == SecretBackend::Agenix && Path::new(&entry.file).ends_with(secret_file)
})
.collect();
if !exact_matching_entries.is_empty() {
return exact_matching_entries;
}

let Some(basename) = Path::new(secret_file)
.file_name()
.and_then(|name| name.to_str())
else {
return Vec::new();
};
secret_entries
.iter()
.filter(|entry| {
entry.backend == SecretBackend::Agenix && agenix_filename_matches(&entry.file, basename)
})
.collect()
}

/// Build the per-secret recipient inventory and repository-level recipient registrations from the evaluated agenix rules.
fn build_agenix_rules(
config_dir: &Path,
Expand Down Expand Up @@ -765,56 +814,16 @@ fn build_agenix_rules(
.collect()
};
let mut matched = false;
let mut direct_match_count = 0;
for path in candidates.into_iter().flatten() {
if path.is_file() {
inventory
.entry(path)
.or_default()
.extend(encrypted_for.iter().cloned());
matched = true;
direct_match_count += 1;
}
}
let exact_matching_entries: Vec<&SecretEntry> = secret_entries
.iter()
.filter(|entry| {
entry.backend == SecretBackend::Agenix
&& Path::new(&entry.file).ends_with(&secret_file)
})
.collect();
// builtins.path commonly produces /nix/store/<hash>-<basename>, which
// cannot retain the rules file's directory suffix. Fall back to a
// unique filename match, allowing that Nix store hash prefix, but
// never guess when multiple agenix declarations share the basename.
let basename = Path::new(&secret_file)
.file_name()
.and_then(|name| name.to_str());
let basename_matching_entries: Vec<&SecretEntry> = if exact_matching_entries.is_empty() {
basename
.map(|basename| {
secret_entries
.iter()
.filter(|entry| {
entry.backend == SecretBackend::Agenix
&& agenix_filename_matches(&entry.file, basename)
})
.collect()
})
.unwrap_or_default()
} else {
Vec::new()
};
let matching_entries = if exact_matching_entries.is_empty() {
&basename_matching_entries
} else {
&exact_matching_entries
};
log::debug!(
"Agenix rule '{secret_file}': repository_path_matches={direct_match_count}, evaluated_exact_matches={}, evaluated_basename_matches={}",
exact_matching_entries.len(),
basename_matching_entries.len()
);
let matching_entries = match_agenix_secret_entries(&secret_file, secret_entries);
if matching_entries.len() == 1
&& let Ok(path) = resolve_secret_file_path(
config_dir.to_string_lossy().as_ref(),
Expand Down Expand Up @@ -845,7 +854,7 @@ fn build_agenix_rules(

/// Check if the given entry file name matches the expected basename according to the Agenix naming convention.
/// This includes the convention where the filename may have a 32-character store hash prefix followed by a hyphen and the basename.
fn agenix_filename_matches(entry_file: &str, basename: &str) -> bool {
pub(super) fn agenix_filename_matches(entry_file: &str, basename: &str) -> bool {
let Some(filename) = Path::new(entry_file)
.file_name()
.and_then(|name| name.to_str())
Expand Down
Loading
Loading