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
45 changes: 8 additions & 37 deletions apps/native/src-tauri/src/evolve/ensure_secret.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::evolve::age::ensure_age_key;
use crate::evolve::file_ops::join_in_dir;
use crate::evolve::file_ops::{join_in_dir, relative_path_between, repo_relative_path};
use crate::evolve::nix_file_editor::{
apply_semantic_edit, nix_builtins_path_meta_value, nix_expr_meta_value,
};
Expand All @@ -9,9 +9,9 @@ use crate::evolve::sops::{
use crate::evolve::types::{FileEditAction, SemanticFileEdit};

use super::gitignore::GitignoreChecker;
use anyhow::{Result, anyhow};
use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use std::path::{Component, Path, PathBuf};
use std::path::Path;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -319,20 +319,20 @@ fn secret_path_relative_to_target_file(
})?;
let secret_abs = join_in_dir(base, secret_path)?;

let from = target_dir.strip_prefix(base).map_err(|_| {
anyhow!(
let from = repo_relative_path(base, target_dir).with_context(|| {
format!(
"ensure_secret: target file '{}' resolved outside config root",
target_file
)
})?;
let to = secret_abs.strip_prefix(base).map_err(|_| {
anyhow!(
let to = repo_relative_path(base, &secret_abs).with_context(|| {
format!(
"ensure_secret: secret path '{}' resolved outside config root",
secret_path
)
})?;

let relative = relative_path(from, to);
let relative = relative_path_between(&from, &to)?;
let rendered = relative.to_string_lossy().replace('\\', "/");

if rendered.starts_with("../") || rendered.starts_with("./") {
Expand All @@ -342,35 +342,6 @@ fn secret_path_relative_to_target_file(
}
}

fn relative_path(from: &Path, to: &Path) -> PathBuf {
let from_components: Vec<Component<'_>> = from.components().collect();
let to_components: Vec<Component<'_>> = to.components().collect();

let mut common_len = 0usize;
while common_len < from_components.len()
&& common_len < to_components.len()
&& from_components[common_len] == to_components[common_len]
{
common_len += 1;
}

let mut relative = PathBuf::new();

for _ in common_len..from_components.len() {
relative.push("..");
}

for component in &to_components[common_len..] {
relative.push(component.as_os_str());
}

if relative.as_os_str().is_empty() {
PathBuf::from(".")
} else {
relative
}
}

fn render_initial_secret_content(scaffold: Option<&SecretScaffold>) -> String {
let default = || "value: \"\"\n".to_string();
let Some(scaffold) = scaffold else {
Expand Down
98 changes: 96 additions & 2 deletions apps/native/src-tauri/src/evolve/file_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,64 @@ pub(crate) fn join_in_dir(base: &Path, rel: &str) -> anyhow::Result<PathBuf> {
Ok(base.join(normalized))
}

/// Return `path` relative to a repository root.
pub(crate) fn repo_relative_path(repo_root: &Path, path: &Path) -> anyhow::Result<PathBuf> {
path.strip_prefix(repo_root)
.map(Path::to_path_buf)
.with_context(|| {
format!(
"{} is outside repository {}",
path.display(),
repo_root.display()
)
})
}

/// Return a repository-relative path using `/` separators for Git and Nix APIs.
pub(crate) fn repo_relative_path_string(repo_root: &Path, path: &Path) -> anyhow::Result<String> {
Ok(repo_relative_path(repo_root, path)?
.to_string_lossy()
.replace('\\', "/"))
}

/// Compute a lexical path from directory `from` to `to`.
///
/// Both inputs must be either relative paths or absolute paths on the same
/// root. Parent components are rejected because callers should normalize
/// scoped paths before calculating a relative reference.
pub(crate) fn relative_path_between(from: &Path, to: &Path) -> anyhow::Result<PathBuf> {
if from.is_absolute() != to.is_absolute() {
anyhow::bail!("cannot relativize an absolute path against a relative path");
}

let from_components = from.components().collect::<Vec<_>>();
let to_components = to.components().collect::<Vec<_>>();
let common = from_components
.iter()
.zip(&to_components)
.take_while(|(left, right)| left == right)
.count();
if from_components[common..]
.iter()
.chain(&to_components[common..])
.any(|component| !matches!(component, std::path::Component::Normal(_)))
{
anyhow::bail!("cannot relativize paths with different roots or parent components");
}

let mut relative = PathBuf::new();
for _ in common..from_components.len() {
relative.push("..");
}
for component in &to_components[common..] {
relative.push(component.as_os_str());
}
if relative.as_os_str().is_empty() {
relative.push(".");
}
Ok(relative)
}

/// 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 @@ -465,10 +523,46 @@ fn reject_gitignored_edit_path(

#[cfg(test)]
mod tests {
use super::{apply_file_edits, rewrite_existing_file_in_dir};
use super::{
apply_file_edits, 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;
use std::fs;
use std::{fs, path::Path};

#[test]
fn repository_path_helpers_share_consistent_relative_semantics() {
let root = Path::new("/config");
let file = Path::new("/config/modules/darwin/sops-secrets.nix");
assert_eq!(
repo_relative_path(root, file).expect("make repository-relative"),
Path::new("modules/darwin/sops-secrets.nix")
);
assert_eq!(
repo_relative_path_string(root, file).expect("render repository-relative"),
"modules/darwin/sops-secrets.nix"
);
assert_eq!(
relative_path_between(
Path::new("modules/darwin"),
Path::new("secrets/secrets.yaml")
)
.expect("compute relative path"),
Path::new("../../secrets/secrets.yaml")
);
assert_eq!(
relative_path_between(Path::new("modules"), Path::new("modules"))
.expect("compute identity path"),
Path::new(".")
);
}

#[test]
fn repository_path_helpers_reject_incompatible_paths() {
assert!(repo_relative_path(Path::new("/config"), Path::new("/other/file.nix")).is_err());
assert!(relative_path_between(Path::new("relative"), Path::new("/absolute")).is_err());
}

#[test]
fn empty_search_overwrites_existing_file() {
Expand Down
2 changes: 2 additions & 0 deletions apps/native/src-tauri/src/evolve/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ mod context_budget;
mod ensure_secret;
pub(crate) mod file_ops;
mod gitignore;
pub(crate) use gitignore::GitignoreChecker;
pub(crate) mod isolation;
pub mod messages;
pub(crate) mod nix_file_editor;
mod nixmac_ignore;
pub(crate) use nixmac_ignore::NixmacIgnoreChecker;
pub mod providers;
mod search_code;
pub mod search_docs;
Expand Down
112 changes: 112 additions & 0 deletions apps/native/src-tauri/src/evolve/nix_file_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,83 @@ fn remove(content: &str, attrpath: &str, values: &[String]) -> Result<String> {
Ok(content.to_string())
}

/// Remove an attribute assignment and any flat descendant assignments from Nix source.
///
/// The attrpath is resolved structurally, so this handles flat declarations such as
/// `sops.secrets.foo = { ... };`, nested attrsets, and a mixture of both. Empty parent
/// 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> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove_attrpath splits the attrpath on ., so sops.secrets."my.secret" never matches — and the agent flow allows . in names (ensure_secret.rs:136-153). Delete of a dotted-name secret then always fails, after the encrypted file was already removed, relying on rollback.

let target = attrpath
.split('.')
.map(normalize_attrpath_for_match)
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
if target.is_empty() {
return Err(anyhow::anyhow!("Nix attrpath must not be empty"));
}

let root = Root::parse(content)
.ok()
.context("Failed to parse Nix content when removing an attrpath")?;
let top = AttrSet::cast(root.syntax().clone())
.or_else(|| root.syntax().descendants().find_map(AttrSet::cast))
.context("Cannot find top-level attribute set when removing an attrpath")?;
let mut ranges = Vec::new();
collect_attrpath_assignment_ranges(&top, &target, &mut ranges);
if ranges.is_empty() {
return Err(anyhow::anyhow!(
"Nix attrpath '{}' does not exist",
attrpath
));
}

ranges.sort_unstable_by_key(|range| range.start);
let mut updated = content.to_string();
for range in ranges.into_iter().rev() {
let mut start = range.start;
while start > 0 && matches!(updated.as_bytes()[start - 1], b' ' | b'\t') {
start -= 1;
}
let mut end = range.end;
while end < updated.len() && matches!(updated.as_bytes()[end], b' ' | b'\t') {
end += 1;
}
if end < updated.len() && updated.as_bytes()[end] == b'\n' {
end += 1;
}
updated.replace_range(start..end, "");
}
Ok(updated)
}

/// 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,
target: &[String],
ranges: &mut Vec<std::ops::Range<usize>>,
) {
for entry in attrset.attrpath_values() {
let Some(attrpath) = entry.attrpath() else {
continue;
};
let keys = attrpath
.attrs()
.map(|attr| normalize_attrpath_for_match(&attr.syntax().text().to_string()))
.collect::<Vec<_>>();

if target.len() <= keys.len() && keys[..target.len()] == target[..] {
ranges.push(text_range_to_usize_range(entry.syntax().text_range()));
} else if keys.len() < target.len()
&& keys[..] == target[..keys.len()]
&& let Some(value) = entry.value()
&& let Some(nested) = AttrSet::cast(value.syntax().clone())
{
collect_attrpath_assignment_ranges(&nested, &target[keys.len()..], ranges);
}
}
}

/// Reject string values that are Nix source in disguise. A value like
/// `"{ url = …; }"` would be spliced as a *quoted string literal* — observed
/// with gpt-oss-120b setting `inputs.home-manager` to a stringified attrset,
Expand Down Expand Up @@ -1381,6 +1458,41 @@ environment.systemPackages = with pkgs; [
);
}

#[test]
fn remove_attrpath_handles_flat_and_nested_assignments() {
let flat = r#"{
sops.secrets."github-token".sopsFile = ./secrets.yaml;
sops.secrets."github-token".key = "github-token";
sops.secrets.other = { key = "other"; };
}
"#;
let flat = remove_attrpath(flat, "sops.secrets.\"github-token\"")
.expect("remove flat descendant assignments");
assert!(!flat.contains("github-token"));
assert!(flat.contains("sops.secrets.other"));

let nested = r#"{
sops = {
secrets = {
"github-token" = { key = "github-token"; };
other = { key = "other"; };
};
};
}
"#;
let nested = remove_attrpath(nested, "sops.secrets.\"github-token\"")
.expect("remove nested assignment");
assert!(!nested.contains("github-token"));
assert!(nested.contains("other = { key = \"other\"; };"));
}

#[test]
fn remove_attrpath_errors_when_assignment_is_missing() {
let error = remove_attrpath("{ services.foo.enable = true; }", "sops.secrets.foo")
.expect_err("missing attrpath must not silently succeed");
assert!(error.to_string().contains("does not exist"));
}

#[test]
fn add_updates_existing_list_when_comments_precede_assignment() {
let edited = add(
Expand Down
Loading
Loading