Skip to content
2 changes: 1 addition & 1 deletion src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ for the npins wrapper."
println!(
"Generated a {} nix expression at {}",
&info.template,
&path.canonicalize().unwrap().display()
&output::display_path_pub(path).display()
);

// Write overlay.nix (structured layout only). Done before
Expand Down
2 changes: 2 additions & 0 deletions src/deps/go.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ fn walk(root: &Path, dir: &Path, acc: &mut CgoDirectives) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
eprintln!("Warning: cannot read directory {}: {}", dir.display(), e);
debug!(target: LOG_TARGET, "cannot read {}: {}", dir.display(), e);
return;
}
Expand Down Expand Up @@ -336,6 +337,7 @@ fn walk(root: &Path, dir: &Path, acc: &mut CgoDirectives) {
acc.ld_libs.extend(directives.ld_libs);
}
Err(e) => {
eprintln!("Warning: cannot read file {}: {}", path.display(), e);
debug!(target: LOG_TARGET, "cannot read {}: {}", path.display(), e);
}
}
Expand Down
11 changes: 8 additions & 3 deletions src/file_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,14 @@ pub fn nix_file_paths(

// if it's a directory, we need to default to using the default.nix which `import` expects
// Path.is_dir() appears to return false if the directory doesn't exist, so stringify and assert if path ends in '/'
if path.to_str().unwrap().ends_with("/") {
path_buf.push("default.nix");
eprintln!("Directory was passed as [PATH], defaulting to {:?}", path_buf.display());
if path.as_os_str().to_string_lossy().ends_with("/") {
// Validate that the path doesn't contain parent directory components (..)
// This prevents path traversal attacks
if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
eprintln!("Warning: Path contains '..' components, which may be unsafe");
}
path_buf.push("default.nix");
eprintln!("Directory was passed as [PATH], defaulting to {:?}", path_buf.display());
}

(path_buf, PathBuf::from(""))
Expand Down
58 changes: 48 additions & 10 deletions src/interactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -732,11 +732,37 @@ fn prompt_version_manual(default: &str) -> Result<String> {

/// Prompt for package name
pub fn prompt_pname(default: &str) -> Result<String> {
let pname = Text::new("Package name (pname):")
.with_default(default)
.with_help_message("The name attribute for the package")
.prompt()?;
Ok(pname)
loop {
let pname = Text::new("Package name (pname):")
.with_default(default)
.with_help_message("The name attribute for the package")
.prompt()?;

// Validate length (max 255 characters for filesystem compatibility)
if pname.len() > 255 {
eprintln!("Error: Package name too long (max 255 characters, got {})", pname.len());
continue;
}

// Validate characters (no control characters or path separators)
if pname.chars().any(|c| c.is_control()) {
eprintln!("Error: Package name contains control characters");
continue;
}

if pname.contains('/') || pname.contains('\\') {
eprintln!("Error: Package name cannot contain path separators (/ or \\)");
continue;
}

// Validate not empty (after trimming)
if pname.trim().is_empty() {
eprintln!("Error: Package name cannot be empty");
continue;
}

return Ok(pname);
}
}

/// Prompt for license
Expand Down Expand Up @@ -840,11 +866,23 @@ pub fn prompt_output_path(_template: &Template, default: &str) -> Result<String>

/// Prompt for description
pub fn prompt_description(default: &str) -> Result<String> {
let description = Text::new("Description:")
.with_default(default)
.with_help_message("Brief description of the package")
.prompt()?;
Ok(description)
loop {
let description = Text::new("Description:")
.with_default(default)
.with_help_message("Brief description of the package")
.prompt()?;

// Validate length (reasonable limit for descriptions)
if description.len() > 1000 {
eprintln!("Error: Description too long (max 1000 characters, got {})", description.len());
continue;
}

// Allow empty descriptions (optional field)
// No control character validation needed for descriptions as they're for human reading

return Ok(description);
}
}

/// Prompt for homepage
Expand Down
29 changes: 27 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,36 @@ use types::UserConfig;
fn main() {
env_logger::init();

let xdg_dirs = xdg::BaseDirectories::with_prefix("nix-template").unwrap();
// Attempt to set up XDG directories; warn and continue if it fails
let xdg_dirs = match xdg::BaseDirectories::with_prefix("nix-template") {
Ok(dirs) => dirs,
Err(e) => {
eprintln!("Warning: Unable to access config directory: {}", e);
eprintln!("Continuing without user configuration...");
// Create a fallback with current directory to allow the program to run
xdg::BaseDirectories::new().unwrap_or_else(|err| {
eprintln!("Error: Cannot initialize XDG directories: {}", err);
std::process::exit(1);
})
}
};

// Attempt to load user config; warn and continue if it fails
let user_config: Option<UserConfig> =
if let Some(file) = xdg_dirs.find_config_file("config.toml") {
toml::from_str(&std::fs::read_to_string(file).unwrap()).ok()
match std::fs::read_to_string(&file) {
Ok(contents) => {
toml::from_str(&contents).map_err(|e| {
eprintln!("Warning: Could not parse config file {:?}: {}", file, e);
eprintln!("Continuing without user configuration...");
}).ok()
}
Err(e) => {
eprintln!("Warning: Could not read config file {:?}: {}", file, e);
eprintln!("Continuing without user configuration...");
None
}
}
} else {
None
};
Expand Down
66 changes: 55 additions & 11 deletions src/output.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,37 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;

/// Attempt to canonicalize a path for display, falling back to the original
/// path if canonicalization fails due to permissions or filesystem limitations.
///
/// This ensures users always see a valid path in success messages, even when
/// canonicalize fails after successfully writing a file.
fn display_path(path: &Path) -> std::path::PathBuf {
match path.canonicalize() {
Ok(canonical) => canonical,
Err(e) => {
log::debug!(
"Could not canonicalize path '{}': {}. Using original path.",
path.display(),
e
);
path.to_path_buf()
}
}
}

/// Public wrapper for display_path, for use outside this module.
pub fn display_path_pub(path: &Path) -> std::path::PathBuf {
display_path(path)
}

/// Helper to write a generated artifact, refusing to clobber any pre-existing file.
/// Creates parent directories as needed.
///
/// Uses atomic create_new to prevent TOCTOU race conditions and symlink attacks.
pub fn write_new(path: &Path, content: &str, label: &str) {
if path.exists() {
eprintln!(
"Refusing to overwrite existing file: {}",
path.display()
);
std::process::exit(1);
}
// Create parent directories first
if let Some(parent) = path.parent() {
if parent.to_str() != Some("") && !parent.exists() {
println!("Creating directory: {}", parent.display());
Expand All @@ -18,13 +40,35 @@ pub fn write_new(path: &Path, content: &str, label: &str) {
});
}
}
std::fs::write(path, content).unwrap_or_else(|_| {
panic!("Was unable to write to file: {}", path.display())
});

// Use create_new for atomic check-and-create operation
// This prevents TOCTOU race conditions and symlink attacks
match OpenOptions::new()
.write(true)
.create_new(true) // Atomic: fails if file exists
.open(path)
{
Ok(mut file) => {
file.write_all(content.as_bytes()).unwrap_or_else(|e| {
panic!("Was unable to write to file '{}': {}", path.display(), e)
});
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
eprintln!(
"Refusing to overwrite existing file: {}",
path.display()
);
std::process::exit(1);
}
Err(e) => {
panic!("Was unable to create file '{}': {}", path.display(), e);
}
}

println!(
"Generated {} at {}",
label,
path.canonicalize().unwrap().display()
display_path(path).display()
);
}

Expand Down
30 changes: 16 additions & 14 deletions src/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ lazy_static! {
);
m
};

// Regex for removing documentation placeholder markers
static ref DOC_REGEX: Regex = Regex::new(r"@doc:.*@").unwrap();

// Regex for extracting documentation link keys
static ref DOC_LINKS_REGEX: Regex = Regex::new(r"@doc:(\w+)@").unwrap();
}

// `stdenvNoCC` is the C-compiler-less variant of `stdenv`. It follows the
Expand Down Expand Up @@ -244,24 +250,20 @@ impl ExpressionInfo {
if self.include_documentation_links {
Self::insert_documentation_links(result)
} else {
Regex::new(r"@doc:.*@")
.unwrap()
.replace_all(&result, "")
.to_string()
DOC_REGEX.replace_all(&result, "").to_string()
}
}

fn insert_documentation_links(s: String) -> String {
let re = Regex::new(r"@doc:(\w+)@").unwrap();

re.replace_all(&s, |caps: &Captures| {
let key = &caps[1];
format!(
"# See the guide for more information: {}",
DOCUMENTATION_LINKS.get(key).unwrap_or(&"").to_string()
)
})
.to_string()
DOC_LINKS_REGEX
.replace_all(&s, |caps: &Captures| {
let key = &caps[1];
format!(
"# See the guide for more information: {}",
DOCUMENTATION_LINKS.get(key).unwrap_or(&"").to_string()
)
})
.to_string()
}
}

Expand Down
Loading
Loading