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
102 changes: 97 additions & 5 deletions src/fix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use std::collections::HashSet;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use std::time::SystemTime;

use crate::manager::{self, ManagerKind, Recommendation};
Expand Down Expand Up @@ -97,6 +97,66 @@ fn hex_val(b: u8) -> Option<u8> {
}
}

fn restore_path_is_within_home(path: &Path) -> bool {
restore_path_is_within_home_for(path, &manager::home_dir())
}

fn restore_path_is_within_home_for(path: &Path, home: &Path) -> bool {
if path
.components()
.any(|component| matches!(component, Component::ParentDir))
{
return false;
}
path.starts_with(home)
}

fn nearest_existing_ancestor(path: &Path) -> Option<PathBuf> {
let mut current = Some(path);
while let Some(candidate) = current {
if candidate.exists() {
return Some(candidate.to_path_buf());
}
current = candidate.parent();
}
None
}

fn validate_restore_destination(original: &Path) -> io::Result<()> {
if !restore_path_is_within_home(original) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"restore target is outside the home directory",
));
}

if fs::symlink_metadata(original)
.map(|meta| meta.file_type().is_symlink())
.unwrap_or(false)
{
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"restore target is a symlink",
));
}

let home = manager::home_dir();
if let (Ok(home_real), Some(existing)) = (
fs::canonicalize(&home),
original.parent().and_then(nearest_existing_ancestor),
) {
let existing_real = fs::canonicalize(existing)?;
if !existing_real.starts_with(home_real) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"restore target parent escapes the home directory",
));
}
}

Ok(())
}

/// Backup a config file before modifying it. Only backs up once per path per session.
/// Backups are stored in `~/.depsguard/backups/` with the original path encoded in the filename.
pub fn backup_file(path: &Path, backed_up: &mut HashSet<PathBuf>) -> io::Result<()> {
Expand Down Expand Up @@ -149,8 +209,7 @@ pub fn list_backups() -> (Vec<(PathBuf, PathBuf)>, usize) {
.all(|c| c.is_ascii_digit() || c == 'T' || c == '-')
{
let original = decode_path(encoded);
// Agentic Rule (ARNIE_PATH_BOUNDARY_CHECKING): reject paths outside home directory
if original.starts_with(manager::home_dir()) {
if restore_path_is_within_home(&original) {
results.push((original, p));
} else {
stale += 1;
Expand All @@ -177,6 +236,7 @@ pub fn list_backups() -> (Vec<(PathBuf, PathBuf)>, usize) {

/// Restore a single backup file to its original location.
pub fn restore_backup(backup: &Path, original: &Path) -> io::Result<()> {
validate_restore_destination(original)?;
if let Some(parent) = original.parent() {
let _ = fs::create_dir_all(parent);
}
Expand Down Expand Up @@ -373,7 +433,6 @@ fn apply_yaml_fix(path: &Path, key: &str, value: &str, quote: bool) -> io::Resul
if k.trim() == key {
*line = target_line.clone();
found = true;
break;
}
}
}
Expand Down Expand Up @@ -448,7 +507,6 @@ fn apply_json_fix(path: &Path, key: &str, value: &str) -> io::Result<String> {
let comma = if needs_comma { "," } else { "" };
*line = format!("{indent}\"{key}\": {target_value}{comma}");
found = true;
break;
}
}
}
Expand Down Expand Up @@ -895,6 +953,15 @@ mod tests {
assert!(!content.contains("100"));
}

#[test]
fn yaml_fix_updates_duplicate_target_keys() {
let f = tmp_file("ignoreScripts: false\nignoreScripts: false\n");
apply_yaml_fix(f.path(), "ignoreScripts", "true", false).unwrap();
let content = f.read();
assert_eq!(content.matches("ignoreScripts: true").count(), 2);
assert!(!content.contains("ignoreScripts: false"));
}

#[test]
fn yaml_fix_quoted_value() {
let f = tmp_file("");
Expand Down Expand Up @@ -1071,6 +1138,21 @@ mod tests {
assert!(!content.contains("3 days"));
}

#[test]
fn json_fix_updates_duplicate_target_keys() {
let f = tmp_file(
"{\n \"minimumReleaseAge\": \"0 days\",\n \"minimumReleaseAge\": \"1 day\"\n}\n",
);
apply_json_fix(f.path(), "minimumReleaseAge", "7 days").unwrap();
let content = f.read();
assert_eq!(
content.matches("\"minimumReleaseAge\": \"7 days\"").count(),
2
);
assert!(!content.contains("0 days"));
assert!(!content.contains("1 day"));
}

#[test]
fn json_fix_creates_empty_file() {
let f = tmp_file("");
Expand Down Expand Up @@ -1199,4 +1281,14 @@ mod tests {
let decoded = super::decode_path(&encoded);
assert_eq!(decoded, path);
}

#[test]
fn restore_path_within_home_rejects_parent_dir_escape() {
let home = Path::new("/tmp/depsguard-home");
let escaped = home.join("../outside/.npmrc");
let inside = home.join(".npmrc");

assert!(!super::restore_path_is_within_home_for(&escaped, home));
assert!(super::restore_path_is_within_home_for(&inside, home));
}
}
4 changes: 2 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -727,7 +727,7 @@ mod tests {
#[test]
fn apply_selected_applies_selected() {
let path = std::env::temp_dir().join(format!("depsguard_main_test_{}", std::process::id()));
std::fs::write(&path, "").unwrap();
let _ = std::fs::remove_file(&path);

let managers = vec![ManagerInfo {
kind: ManagerKind::Npm,
Expand All @@ -751,7 +751,7 @@ mod tests {
}];
let results = apply_selected(&items, &managers);
assert_eq!(results.len(), 1);
assert!(results[0].1.is_ok());
assert!(results[0].1.is_ok(), "{:?}", results[0].1);
// Clean up: remove original and any .bak files
let _ = std::fs::remove_file(&path);
if let Some(parent) = path.parent() {
Expand Down
10 changes: 6 additions & 4 deletions src/manager/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ pub fn read_ini_value(path: &Path, dotted_key: &str) -> Option<String> {
/// Read a top-level key from a simple YAML file.
pub fn read_yaml_value(path: &Path, key: &str) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
let mut found = None;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') || trimmed.is_empty() {
Expand All @@ -183,11 +184,11 @@ pub fn read_yaml_value(path: &Path, key: &str) -> Option<String> {
v
};
let v = v.trim().trim_matches('"').trim_matches('\'');
return Some(v.to_string());
found = Some(v.to_string());
}
}
}
None
found
}

/// Check mode for YAML values.
Expand Down Expand Up @@ -228,6 +229,7 @@ pub fn check_yaml(
pub fn read_json_string_value(path: &Path, key: &str) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
let needle = format!("\"{}\"", key);
let mut found = None;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("//") {
Expand All @@ -240,9 +242,9 @@ pub fn read_json_string_value(path: &Path, key: &str) -> Option<String> {
let after = after.strip_prefix(':')?;
let after = after.trim().trim_end_matches(',');
let val = after.trim().trim_matches('"');
return Some(val.to_string());
found = Some(val.to_string());
}
None
found
}

// ── Dependabot YAML ──────────────────────────────────────────────────
Expand Down
18 changes: 18 additions & 0 deletions src/manager/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2175,6 +2175,15 @@ mod tests {
);
}

#[test]
fn read_yaml_value_uses_last_duplicate_key() {
let f = tmp_file("ignoreScripts: true\nignoreScripts: false\n");
assert_eq!(
read_yaml_value(f.path(), "ignoreScripts"),
Some("false".into())
);
}

// ── pnpm-workspace scanning tests ───────────────────────────────

#[test]
Expand Down Expand Up @@ -2496,6 +2505,15 @@ mod tests {
assert_eq!(val, None);
}

#[test]
fn read_json_uses_last_duplicate_key() {
let f = tmp_file(
"{\n \"minimumReleaseAge\": \"7 days\",\n \"minimumReleaseAge\": \"0 days\"\n}\n",
);
let val = read_json_string_value(f.path(), "minimumReleaseAge");
assert_eq!(val, Some("0 days".into()));
}

// ── dependabot entries tests ────────────────────────────────────

#[test]
Expand Down
Loading