eh: don't load entire files into memory for hash replace; argchecking
Some checks are pending
Rust / build (push) Waiting to run
Some checks are pending
Rust / build (push) Waiting to run
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ie3385f68e70ee7848272010fbd41845e6a6a6964
This commit is contained in:
parent
7498902d46
commit
5fbc0f45bd
5 changed files with 254 additions and 21 deletions
|
|
@ -15,6 +15,7 @@ version = "0.1.2"
|
||||||
clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.5.51" }
|
clap = { default-features = false, features = [ "std", "help", "derive" ], version = "4.5.51" }
|
||||||
clap_complete = "4.5.60"
|
clap_complete = "4.5.60"
|
||||||
regex = "1.12.2"
|
regex = "1.12.2"
|
||||||
|
tempfile = "3.23.0"
|
||||||
thiserror = "2.0.17"
|
thiserror = "2.0.17"
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = "0.3.20"
|
tracing-subscriber = "0.3.20"
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,9 @@ name = "eh"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
regex.workspace = true
|
regex.workspace = true
|
||||||
|
tempfile.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
walkdir.workspace = true
|
walkdir.workspace = true
|
||||||
yansi.workspace = true
|
yansi.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile = "3.0"
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ impl NixCommand {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code, reason = "FIXME")]
|
||||||
pub fn args<I, S>(mut self, args: I) -> Self
|
pub fn args<I, S>(mut self, args: I) -> Self
|
||||||
where
|
where
|
||||||
I: IntoIterator<Item = S>,
|
I: IntoIterator<Item = S>,
|
||||||
|
|
@ -63,6 +64,7 @@ impl NixCommand {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub fn args_ref(mut self, args: &[String]) -> Self {
|
pub fn args_ref(mut self, args: &[String]) -> Self {
|
||||||
self.args.extend(args.iter().cloned());
|
self.args.extend(args.iter().cloned());
|
||||||
self
|
self
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ pub enum EhError {
|
||||||
|
|
||||||
#[error("Command execution failed: {command}")]
|
#[error("Command execution failed: {command}")]
|
||||||
CommandFailed { command: String },
|
CommandFailed { command: String },
|
||||||
|
|
||||||
|
#[error("Invalid input: {input} - {reason}")]
|
||||||
|
InvalidInput { input: String, reason: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, EhError>;
|
pub type Result<T> = std::result::Result<T, EhError>;
|
||||||
|
|
|
||||||
265
eh/src/util.rs
265
eh/src/util.rs
|
|
@ -1,10 +1,10 @@
|
||||||
use std::{
|
use std::{
|
||||||
fs,
|
io::{BufWriter, Write},
|
||||||
io::Write,
|
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
use yansi::Paint;
|
use yansi::Paint;
|
||||||
|
|
@ -63,14 +63,13 @@ impl NixFileFixer for DefaultNixFileFixer {
|
||||||
fn find_nix_files(&self) -> Result<Vec<PathBuf>> {
|
fn find_nix_files(&self) -> Result<Vec<PathBuf>> {
|
||||||
let files: Vec<PathBuf> = WalkDir::new(".")
|
let files: Vec<PathBuf> = WalkDir::new(".")
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|entry| entry.ok())
|
.filter_map(std::result::Result::ok)
|
||||||
.filter(|entry| {
|
.filter(|entry| {
|
||||||
entry.file_type().is_file()
|
entry.file_type().is_file()
|
||||||
&& entry
|
&& entry
|
||||||
.path()
|
.path()
|
||||||
.extension()
|
.extension()
|
||||||
.map(|ext| ext.eq_ignore_ascii_case("nix"))
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("nix"))
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
})
|
||||||
.map(|entry| entry.path().to_path_buf())
|
.map(|entry| entry.path().to_path_buf())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -82,8 +81,8 @@ impl NixFileFixer for DefaultNixFileFixer {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fix_hash_in_file(&self, file_path: &Path, new_hash: &str) -> Result<bool> {
|
fn fix_hash_in_file(&self, file_path: &Path, new_hash: &str) -> Result<bool> {
|
||||||
let content = fs::read_to_string(file_path)?;
|
// Pre-compile regex patterns once to avoid repeated compilation
|
||||||
let patterns = [
|
let patterns: Vec<(Regex, String)> = [
|
||||||
(r#"hash\s*=\s*"[^"]*""#, format!(r#"hash = "{new_hash}""#)),
|
(r#"hash\s*=\s*"[^"]*""#, format!(r#"hash = "{new_hash}""#)),
|
||||||
(
|
(
|
||||||
r#"sha256\s*=\s*"[^"]*""#,
|
r#"sha256\s*=\s*"[^"]*""#,
|
||||||
|
|
@ -93,28 +92,47 @@ impl NixFileFixer for DefaultNixFileFixer {
|
||||||
r#"outputHash\s*=\s*"[^"]*""#,
|
r#"outputHash\s*=\s*"[^"]*""#,
|
||||||
format!(r#"outputHash = "{new_hash}""#),
|
format!(r#"outputHash = "{new_hash}""#),
|
||||||
),
|
),
|
||||||
];
|
]
|
||||||
let mut new_content = content;
|
.into_iter()
|
||||||
|
.map(|(pattern, replacement)| {
|
||||||
|
Regex::new(pattern)
|
||||||
|
.map(|re| (re, replacement))
|
||||||
|
.map_err(EhError::Regex)
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
// Read the entire file content
|
||||||
|
let content = std::fs::read_to_string(file_path)?;
|
||||||
let mut replaced = false;
|
let mut replaced = false;
|
||||||
for (pattern, replacement) in &patterns {
|
let mut result_content = content;
|
||||||
let re = Regex::new(pattern)?;
|
|
||||||
if re.is_match(&new_content) {
|
// Apply replacements
|
||||||
new_content = re
|
for (re, replacement) in &patterns {
|
||||||
.replace_all(&new_content, replacement.as_str())
|
if re.is_match(&result_content) {
|
||||||
|
result_content = re
|
||||||
|
.replace_all(&result_content, replacement.as_str())
|
||||||
.into_owned();
|
.into_owned();
|
||||||
replaced = true;
|
replaced = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write back to file atomically
|
||||||
if replaced {
|
if replaced {
|
||||||
fs::write(file_path, new_content).map_err(|_| {
|
let temp_file =
|
||||||
|
NamedTempFile::new_in(file_path.parent().unwrap_or(Path::new(".")))?;
|
||||||
|
{
|
||||||
|
let mut writer = BufWriter::new(temp_file.as_file());
|
||||||
|
writer.write_all(result_content.as_bytes())?;
|
||||||
|
writer.flush()?;
|
||||||
|
}
|
||||||
|
temp_file.persist(file_path).map_err(|_e| {
|
||||||
EhError::HashFixFailed {
|
EhError::HashFixFailed {
|
||||||
path: file_path.to_string_lossy().to_string(),
|
path: file_path.to_string_lossy().to_string(),
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
Ok(true)
|
|
||||||
} else {
|
|
||||||
Ok(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(replaced)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,6 +174,24 @@ fn pre_evaluate(_subcommand: &str, args: &[String]) -> Result<bool> {
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_nix_args(args: &[String]) -> Result<()> {
|
||||||
|
const DANGEROUS_PATTERNS: &[&str] = &[
|
||||||
|
";", "&&", "||", "|", "`", "$(", "${", ">", "<", ">>", "<<", "2>", "2>>",
|
||||||
|
];
|
||||||
|
|
||||||
|
for arg in args {
|
||||||
|
for pattern in DANGEROUS_PATTERNS {
|
||||||
|
if arg.contains(pattern) {
|
||||||
|
return Err(EhError::InvalidInput {
|
||||||
|
input: arg.clone(),
|
||||||
|
reason: format!("contains potentially dangerous pattern: {pattern}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared retry logic for nix commands (build/run/shell).
|
/// Shared retry logic for nix commands (build/run/shell).
|
||||||
pub fn handle_nix_with_retry(
|
pub fn handle_nix_with_retry(
|
||||||
subcommand: &str,
|
subcommand: &str,
|
||||||
|
|
@ -165,6 +201,7 @@ pub fn handle_nix_with_retry(
|
||||||
classifier: &dyn NixErrorClassifier,
|
classifier: &dyn NixErrorClassifier,
|
||||||
interactive: bool,
|
interactive: bool,
|
||||||
) -> Result<i32> {
|
) -> Result<i32> {
|
||||||
|
validate_nix_args(args)?;
|
||||||
// Pre-evaluate for build commands to catch errors early
|
// Pre-evaluate for build commands to catch errors early
|
||||||
if !pre_evaluate(subcommand, args)? {
|
if !pre_evaluate(subcommand, args)? {
|
||||||
return Err(EhError::NixCommandFailed(
|
return Err(EhError::NixCommandFailed(
|
||||||
|
|
@ -297,6 +334,7 @@ pub fn handle_nix_with_retry(
|
||||||
code: output.status.code().unwrap_or(1),
|
code: output.status.code().unwrap_or(1),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DefaultNixErrorClassifier;
|
pub struct DefaultNixErrorClassifier;
|
||||||
|
|
||||||
impl NixErrorClassifier for DefaultNixErrorClassifier {
|
impl NixErrorClassifier for DefaultNixErrorClassifier {
|
||||||
|
|
@ -310,3 +348,194 @@ impl NixErrorClassifier for DefaultNixErrorClassifier {
|
||||||
&& stderr.contains("refusing"))
|
&& stderr.contains("refusing"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_streaming_hash_replacement() {
|
||||||
|
let temp_file = NamedTempFile::new().unwrap();
|
||||||
|
let file_path = temp_file.path();
|
||||||
|
|
||||||
|
// Write test content with multiple hash patterns
|
||||||
|
let test_content = r#"stdenv.mkDerivation {
|
||||||
|
name = "test-package";
|
||||||
|
src = fetchurl {
|
||||||
|
url = "https://example.com.tar.gz";
|
||||||
|
hash = "sha256-oldhash123";
|
||||||
|
sha256 = "sha256-oldhash456";
|
||||||
|
outputHash = "sha256-oldhash789";
|
||||||
|
};
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let mut file = std::fs::File::create(file_path).unwrap();
|
||||||
|
file.write_all(test_content.as_bytes()).unwrap();
|
||||||
|
file.flush().unwrap();
|
||||||
|
|
||||||
|
let fixer = DefaultNixFileFixer;
|
||||||
|
let result = fixer
|
||||||
|
.fix_hash_in_file(file_path, "sha256-newhash999")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result, "Hash replacement should return true");
|
||||||
|
|
||||||
|
// Verify the content was updated
|
||||||
|
let updated_content = std::fs::read_to_string(file_path).unwrap();
|
||||||
|
assert!(updated_content.contains("sha256-newhash999"));
|
||||||
|
assert!(!updated_content.contains("sha256-oldhash123"));
|
||||||
|
assert!(!updated_content.contains("sha256-oldhash456"));
|
||||||
|
assert!(!updated_content.contains("sha256-oldhash789"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_streaming_no_replacement_needed() {
|
||||||
|
let temp_file = NamedTempFile::new().unwrap();
|
||||||
|
let file_path = temp_file.path().to_path_buf();
|
||||||
|
|
||||||
|
let test_content = r#"stdenv.mkDerivation {
|
||||||
|
name = "test-package";
|
||||||
|
src = fetchurl {
|
||||||
|
url = "https://example.com.tar.gz";
|
||||||
|
};
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut file = std::fs::File::create(&file_path).unwrap();
|
||||||
|
file.write_all(test_content.as_bytes()).unwrap();
|
||||||
|
file.flush().unwrap();
|
||||||
|
} // File is closed here
|
||||||
|
|
||||||
|
// Test hash replacement
|
||||||
|
let fixer = DefaultNixFileFixer;
|
||||||
|
let result = fixer
|
||||||
|
.fix_hash_in_file(&file_path, "sha256-newhash999")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!result,
|
||||||
|
"Hash replacement should return false when no patterns found"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the content was unchanged, ignoring trailing newline differences
|
||||||
|
let updated_content = std::fs::read_to_string(&file_path).unwrap();
|
||||||
|
let normalized_original = test_content.trim_end();
|
||||||
|
let normalized_updated = updated_content.trim_end();
|
||||||
|
assert_eq!(normalized_updated, normalized_original);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: this is a little stupid, but it works
|
||||||
|
#[test]
|
||||||
|
fn test_streaming_large_file_handling() {
|
||||||
|
let temp_file = NamedTempFile::new().unwrap();
|
||||||
|
let file_path = temp_file.path();
|
||||||
|
let mut file = std::fs::File::create(file_path).unwrap();
|
||||||
|
|
||||||
|
// Write header with hash
|
||||||
|
file.write_all(b"stdenv.mkDerivation {\n name = \"large-package\";\n src = fetchurl {\n url = \"https://example.com/large.tar.gz\";\n hash = \"sha256-oldhash\";\n };\n").unwrap();
|
||||||
|
|
||||||
|
for i in 0..10000 {
|
||||||
|
writeln!(file, " # Large comment line {} to simulate file size", i)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
file.flush().unwrap();
|
||||||
|
|
||||||
|
// Test that streaming can handle large files without memory issues
|
||||||
|
let fixer = DefaultNixFileFixer;
|
||||||
|
let result = fixer
|
||||||
|
.fix_hash_in_file(file_path, "sha256-newhash999")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result, "Hash replacement should work for large files");
|
||||||
|
|
||||||
|
// Verify the hash was replaced
|
||||||
|
let updated_content = std::fs::read_to_string(file_path).unwrap();
|
||||||
|
assert!(updated_content.contains("sha256-newhash999"));
|
||||||
|
assert!(!updated_content.contains("sha256-oldhash"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_streaming_file_permissions_preserved() {
|
||||||
|
let temp_file = NamedTempFile::new().unwrap();
|
||||||
|
let file_path = temp_file.path();
|
||||||
|
|
||||||
|
// Write test content
|
||||||
|
let test_content = r#"stdenv.mkDerivation {
|
||||||
|
name = "test";
|
||||||
|
src = fetchurl {
|
||||||
|
url = "https://example.com";
|
||||||
|
hash = "sha256-oldhash";
|
||||||
|
};
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let mut file = std::fs::File::create(file_path).unwrap();
|
||||||
|
file.write_all(test_content.as_bytes()).unwrap();
|
||||||
|
file.flush().unwrap();
|
||||||
|
|
||||||
|
// Get original permissions
|
||||||
|
let original_metadata = std::fs::metadata(file_path).unwrap();
|
||||||
|
let _original_permissions = original_metadata.permissions();
|
||||||
|
|
||||||
|
// Test hash replacement
|
||||||
|
let fixer = DefaultNixFileFixer;
|
||||||
|
let result = fixer.fix_hash_in_file(file_path, "sha256-newhash").unwrap();
|
||||||
|
|
||||||
|
assert!(result, "Hash replacement should succeed");
|
||||||
|
|
||||||
|
// Verify file still exists and has reasonable permissions
|
||||||
|
let new_metadata = std::fs::metadata(file_path).unwrap();
|
||||||
|
assert!(
|
||||||
|
new_metadata.is_file(),
|
||||||
|
"File should still exist after replacement"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_input_validation_blocks_dangerous_patterns() {
|
||||||
|
let dangerous_args = vec![
|
||||||
|
"package; rm -rf /".to_string(),
|
||||||
|
"package && echo hacked".to_string(),
|
||||||
|
"package || echo hacked".to_string(),
|
||||||
|
"package | cat /etc/passwd".to_string(),
|
||||||
|
"package `whoami`".to_string(),
|
||||||
|
"package $(echo hacked lol!)".to_string(),
|
||||||
|
"package ${HOME}/file".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
for arg in dangerous_args {
|
||||||
|
let result = validate_nix_args(std::slice::from_ref(&arg));
|
||||||
|
assert!(result.is_err(), "Should reject dangerous argument: {}", arg);
|
||||||
|
|
||||||
|
match result.unwrap_err() {
|
||||||
|
EhError::InvalidInput { input, reason } => {
|
||||||
|
assert_eq!(input, arg);
|
||||||
|
assert!(reason.contains("dangerous pattern"));
|
||||||
|
},
|
||||||
|
_ => panic!("Expected InvalidInput error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_input_validation_allows_safe_args() {
|
||||||
|
let safe_args = vec![
|
||||||
|
"nixpkgs#hello".to_string(),
|
||||||
|
"--impure".to_string(),
|
||||||
|
"--print-build-logs".to_string(),
|
||||||
|
"/path/to/flake".to_string(),
|
||||||
|
".#default".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let result = validate_nix_args(&safe_args);
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"Should allow safe arguments: {:?}",
|
||||||
|
safe_args
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue