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<()> {
eprintln!("[DEBUG] write_bundle_to called with dir: {}", dir.display());
eprintln!("[DEBUG] output.assets.len(): {}", output.assets.len());
let dir = validate_and_normalize_dir(dir)?;
eprintln!("[DEBUG] Normalized dir: {}", dir.display());
fs::create_dir_all(&dir).map_err(|e| {
Error::WriteFailure(format!(
"Failed to create output directory '{}': {}",
dir.display(),
e
))
})?;
let mut operations = Vec::new();
eprintln!(
"[DEBUG] Starting to iterate over {} assets",
output.assets.len()
);
for (idx, output_item) in output.assets.iter().enumerate() {
eprintln!(
"[DEBUG] Processing asset[{}]: type={:?}",
idx,
std::mem::discriminant(output_item)
);
if let Output::Asset(asset) = output_item {
eprintln!("[DEBUG] Found Asset: filename='{}'", asset.filename);
let filename = asset.filename.as_str();
let target_path = validate_output_path(&dir, filename)?;
eprintln!("[DEBUG] Asset target_path: {}", target_path.display());
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 {
eprintln!(
"[DEBUG] Found Chunk: filename='{}', code_len={}",
chunk.filename,
chunk.code.len()
);
let filename = chunk.filename.as_str();
let target_path = validate_output_path(&dir, filename)?;
eprintln!("[DEBUG] Chunk target_path: {}", target_path.display());
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()));
} else {
eprintln!("[DEBUG] WARNING: Unknown output type!");
}
}
eprintln!("[DEBUG] Collected {} operations to write", operations.len());
for (i, (path, content)) in operations.iter().enumerate() {
eprintln!(
"[DEBUG] Operation[{}]: {} ({} bytes)",
i,
path.display(),
content.len()
);
}
eprintln!(
"[DEBUG] About to call write_files_atomic with {} operations",
operations.len()
);
write_files_atomic(&operations)?;
eprintln!("[DEBUG] write_files_atomic completed successfully");
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<()> {
eprintln!(
"[DEBUG] write_files_atomic: {} operations",
operations.len()
);
let mut temp_files = Vec::new();
for (idx, (target_path, content)) in operations.iter().enumerate() {
eprintln!(
"[DEBUG] Phase 1[{}/{}]: Writing temp file for {}",
idx + 1,
operations.len(),
target_path.display()
);
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");
eprintln!(
"[DEBUG] Writing temp file: {} ({} bytes)",
temp_path.display(),
content.len()
);
fs::write(&temp_path, content).map_err(|e| {
eprintln!(
"[DEBUG] ERROR writing temp file {}: {}",
temp_path.display(),
e
);
cleanup_temp_files(&temp_files);
Error::WriteFailure(format!(
"Failed to write temporary file '{}': {}",
temp_path.display(),
e
))
})?;
eprintln!(
"[DEBUG] Temp file written successfully: {}",
temp_path.display()
);
temp_files.push((temp_path, target_path.clone()));
}
eprintln!("[DEBUG] Phase 2: Renaming {} temp files", temp_files.len());
for (idx, (temp_path, target_path)) in temp_files.iter().enumerate() {
eprintln!(
"[DEBUG] Renaming[{}/{}]: {} -> {}",
idx + 1,
temp_files.len(),
temp_path.display(),
target_path.display()
);
fs::rename(temp_path, target_path).map_err(|e| {
eprintln!(
"[DEBUG] ERROR renaming {} to {}: {}",
temp_path.display(),
target_path.display(),
e
);
cleanup_temp_files(&temp_files);
Error::WriteFailure(format!(
"Failed to rename '{}' to '{}': {}",
temp_path.display(),
target_path.display(),
e
))
})?;
eprintln!("[DEBUG] Successfully renamed to: {}", target_path.display());
}
eprintln!(
"[DEBUG] write_files_atomic completed: {} files written",
operations.len()
);
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"));
}
}