use std::fs;
use std::path::{Path, PathBuf};
use path_clean::PathClean;
use rolldown::BundleOutput;
use rolldown_common::Output;
use crate::{Error, Result};
pub fn write_bundle_to(output: &BundleOutput, dir: &Path, overwrite: bool) -> Result<()> {
let dir = validate_and_normalize_dir(dir)?;
fs::create_dir_all(&dir).map_err(|e| {
Error::WriteFailure(format!(
"Failed to create output directory '{}': {}",
dir.display(),
e
))
})?;
let mut operations = Vec::new();
for output_item in output.assets.iter() {
if let Output::Asset(asset) = output_item {
let filename = asset.filename.as_str();
let target_path = validate_output_path(&dir, filename)?;
if !overwrite && target_path.exists() {
return Err(Error::OutputExists(format!(
"File already exists: '{}'. Use overwrite=true to replace.",
target_path.display()
)));
}
operations.push((target_path, asset.source.as_bytes()));
} else if let Output::Chunk(chunk) = output_item {
let filename = chunk.filename.as_str();
let target_path = validate_output_path(&dir, filename)?;
if !overwrite && target_path.exists() {
return Err(Error::OutputExists(format!(
"File already exists: '{}'. Use overwrite=true to replace.",
target_path.display()
)));
}
operations.push((target_path, chunk.code.as_bytes()));
}
}
write_files_atomic(&operations)?;
Ok(())
}
fn validate_and_normalize_dir(dir: &Path) -> Result<PathBuf> {
let cleaned = dir.clean();
let absolute = if cleaned.is_absolute() {
cleaned
} else {
std::env::current_dir()
.map_err(|e| {
Error::InvalidOutputPath(format!("Failed to get current directory: {}", e))
})?
.join(&cleaned)
.clean()
};
Ok(absolute)
}
fn validate_output_path(base_dir: &Path, filename: &str) -> Result<PathBuf> {
if filename.contains('\0') {
return Err(Error::InvalidOutputPath(
"Filename contains null byte".to_string(),
));
}
#[cfg(target_os = "windows")]
{
let upper = filename.to_uppercase();
let device_names = [
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
"COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
for device in &device_names {
if upper == *device || upper.starts_with(&format!("{}.", device)) {
return Err(Error::InvalidOutputPath(format!(
"Filename is a reserved device name: {}",
filename
)));
}
}
}
let filename_path = Path::new(filename).clean();
let full_path = base_dir.join(&filename_path).clean();
if !full_path.starts_with(base_dir) {
return Err(Error::InvalidOutputPath(format!(
"Path '{}' escapes output directory '{}' (resolved to '{}')",
filename,
base_dir.display(),
full_path.display()
)));
}
Ok(full_path)
}
fn write_files_atomic(operations: &[(PathBuf, &[u8])]) -> Result<()> {
let mut temp_files = Vec::new();
for (target_path, content) in operations.iter() {
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
cleanup_temp_files(&temp_files);
Error::WriteFailure(format!(
"Failed to create directory '{}': {}",
parent.display(),
e
))
})?;
}
let temp_path = target_path.with_extension("tmp");
fs::write(&temp_path, content).map_err(|e| {
cleanup_temp_files(&temp_files);
Error::WriteFailure(format!(
"Failed to write temporary file '{}': {}",
temp_path.display(),
e
))
})?;
temp_files.push((temp_path, target_path.clone()));
}
for (temp_path, target_path) in temp_files.iter() {
fs::rename(temp_path, target_path).map_err(|e| {
cleanup_temp_files(&temp_files);
Error::WriteFailure(format!(
"Failed to rename '{}' to '{}': {}",
temp_path.display(),
target_path.display(),
e
))
})?;
}
Ok(())
}
fn cleanup_temp_files(temp_files: &[(PathBuf, PathBuf)]) {
for (temp_path, _) in temp_files {
if temp_path.exists() {
if let Err(e) = fs::remove_file(temp_path) {
eprintln!(
"Warning: Failed to clean up temporary file '{}': {}",
temp_path.display(),
e
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_output_path_normal() {
let base = Path::new("/tmp/output");
let result = validate_output_path(base, "index.js");
assert!(result.is_ok());
assert_eq!(result.unwrap(), Path::new("/tmp/output/index.js"));
}
#[test]
fn test_validate_output_path_nested() {
let base = Path::new("/tmp/output");
let result = validate_output_path(base, "dist/bundle.js");
assert!(result.is_ok());
assert_eq!(result.unwrap(), Path::new("/tmp/output/dist/bundle.js"));
}
#[test]
fn test_validate_output_path_traversal_simple() {
let base = Path::new("/tmp/output");
let result = validate_output_path(base, "../etc/passwd");
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::InvalidOutputPath(_)));
}
#[test]
fn test_validate_output_path_traversal_complex() {
let base = Path::new("/tmp/output");
let result = validate_output_path(base, "safe/../../../../../../etc/passwd");
assert!(result.is_err());
}
#[test]
fn test_validate_output_path_null_byte() {
let base = Path::new("/tmp/output");
let result = validate_output_path(base, "file\0name.js");
assert!(result.is_err());
}
#[test]
fn test_validate_output_path_current_dir() {
let base = Path::new("/tmp/output");
let result = validate_output_path(base, "./index.js");
assert!(result.is_ok());
assert_eq!(result.unwrap(), Path::new("/tmp/output/index.js"));
}
}