diff --git a/Cargo.toml b/Cargo.toml index d9d9d0a..32a1169 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,12 +40,12 @@ tracing-subscriber = "0.3" uuid = { version = "1", features = ["v4"] } rmp-serde = "1" globset = "0.4" +tempfile = "3" [dev-dependencies] proptest = "1" assert_cmd = "2" predicates = "3" -tempfile = "3" pretty_assertions = "1" [package.metadata.docs.rs] diff --git a/src/output.rs b/src/output.rs index 9ed467d..de58e45 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,4 +1,5 @@ use std::fs; +use std::io::Write; use std::path::Path; use crate::error::Error; @@ -58,11 +59,28 @@ pub fn templates_to_yaml(templates: &[String]) -> Result { } /// Write a YAML string to a file, creating parent directories if needed. +/// +/// Uses an atomic write strategy: content is first written to a temporary file +/// in the same directory, then renamed into place. If the write fails (e.g. +/// disk full, permission denied), the original target file is left unchanged. pub fn write_yaml(content: &str, path: &Path) -> Result<(), Error> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(path, content)?; + let parent = path.parent().ok_or_else(|| { + Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "output path has no parent directory", + )) + })?; + // Handle empty parent (relative path like "spec.yaml" → parent is "") + let parent = if parent.as_os_str().is_empty() { + Path::new(".") + } else { + parent + }; + fs::create_dir_all(parent)?; + + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + tmp.write_all(content.as_bytes())?; + tmp.persist(path).map_err(|e| Error::Io(e.error))?; Ok(()) } diff --git a/tests/output_atomic.rs b/tests/output_atomic.rs new file mode 100644 index 0000000..89a8280 --- /dev/null +++ b/tests/output_atomic.rs @@ -0,0 +1,67 @@ +use std::path::Path; + +#[test] +fn successful_write_creates_file() { + let dir = tempfile::TempDir::new().unwrap(); + let output = dir.path().join("spec.yaml"); + mitm2openapi::output::write_yaml("hello: world\n", &output).unwrap(); + assert_eq!(std::fs::read_to_string(&output).unwrap(), "hello: world\n"); +} + +#[test] +fn overwrite_existing_file() { + let dir = tempfile::TempDir::new().unwrap(); + let output = dir.path().join("spec.yaml"); + mitm2openapi::output::write_yaml("v1: yes\n", &output).unwrap(); + mitm2openapi::output::write_yaml("v2: yes\n", &output).unwrap(); + assert_eq!(std::fs::read_to_string(&output).unwrap(), "v2: yes\n"); +} + +#[test] +fn creates_parent_directories() { + let dir = tempfile::TempDir::new().unwrap(); + let output = dir.path().join("nested").join("dir").join("spec.yaml"); + mitm2openapi::output::write_yaml("nested: yes\n", &output).unwrap(); + assert_eq!(std::fs::read_to_string(&output).unwrap(), "nested: yes\n"); +} + +#[cfg(target_os = "linux")] +#[test] +fn partial_write_preserves_target() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::TempDir::new().unwrap(); + let output = dir.path().join("spec.yaml"); + + mitm2openapi::output::write_yaml("original: yes\n", &output).unwrap(); + let original = std::fs::read_to_string(&output).unwrap(); + assert_eq!(original, "original: yes\n"); + + let mut perms = std::fs::metadata(dir.path()).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(dir.path(), perms).unwrap(); + + let result = mitm2openapi::output::write_yaml("new: content\n", &output); + assert!(result.is_err(), "write to read-only dir should fail"); + + let mut perms = std::fs::metadata(dir.path()).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(dir.path(), perms).unwrap(); + + let after = std::fs::read_to_string(&output).unwrap(); + assert_eq!( + after, original, + "original file must remain untouched on write failure" + ); +} + +#[cfg(unix)] +#[test] +fn write_to_nonexistent_parent_fails_gracefully() { + // /nonexistent at filesystem root requires privilege on Unix; on Windows a + // leading slash without a drive letter is interpreted relative to the CWD, + // which would succeed and invert the assertion. Gate to Unix. + let result = + mitm2openapi::output::write_yaml("test\n", Path::new("/nonexistent/dir/spec.yaml")); + assert!(result.is_err()); +}