diff --git a/src/lockfile.rs b/src/lockfile.rs index 9c57260..ae0acc2 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use std::fs::File; -use std::io::{BufReader, Read, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; @@ -19,6 +19,9 @@ use crate::filter::Filter; const CURRENT_SCHEMA: u32 = 1; const HASH_BUFFER_SIZE: usize = 64 * 1024; +/// Bytes inspected at the start of a file to decide whether it's binary, +/// matching the heuristic git itself uses for `core.autocrlf`/diffing. +const BINARY_PROBE_SIZE: usize = 8000; const FILE_COMMENTS: &str = "## This file is automatically generated by embd. # It is not intended for manual editing. "; @@ -186,11 +189,23 @@ fn walk_files_inner( /// Hash a file's contents with SHA-256, streamed in 64 KiB chunks so multi-GiB /// vendored blobs don't blow up memory. +/// +/// Text files are hashed with CRLF normalized to LF so that `status` doesn't report +/// false drift purely because Windows checkouts convert line endings and other platforms +/// don't. Binary files are hashed byte-for-byte, untouched. pub(crate) fn hash_file(path: &Path) -> Result { let file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?; - let mut reader = BufReader::new(file); + let mut reader = BufReader::with_capacity(HASH_BUFFER_SIZE, file); + let is_binary = { + let probe = reader + .fill_buf() + .with_context(|| format!("failed to read {}", path.display()))?; + probe[..probe.len().min(BINARY_PROBE_SIZE)].contains(&0) + }; + let mut hasher = Sha256::new(); let mut buf = [0u8; HASH_BUFFER_SIZE]; + let mut pending_cr = false; loop { let n = reader .read(&mut buf) @@ -198,12 +213,47 @@ pub(crate) fn hash_file(path: &Path) -> Result { if n == 0 { break; } - hasher.update(&buf[..n]); + if is_binary { + hasher.update(&buf[..n]); + continue; + } + + let mut chunk = &buf[..n]; + if pending_cr { + if chunk.first() != Some(&b'\n') { + hasher.update(b"\r"); + } + pending_cr = false; + } + if chunk.last() == Some(&b'\r') { + pending_cr = true; + chunk = &chunk[..chunk.len() - 1]; + } + hasher.update(normalize_crlf(chunk)); + } + if pending_cr { + hasher.update(b"\r"); } let digest = hasher.finalize(); Ok(format!("sha256:{:x}", base16ct::HexDisplay(&digest))) } +/// Replace every `\r\n` pair with `\n`. Lone `\r` bytes (old Mac-style line +/// endings) are left untouched, matching git's own CRLF normalization. +fn normalize_crlf(chunk: &[u8]) -> Vec { + let mut out = Vec::with_capacity(chunk.len()); + let mut i = 0; + while i < chunk.len() { + if chunk[i] == b'\r' && chunk.get(i + 1) == Some(&b'\n') { + i += 1; + continue; + } + out.push(chunk[i]); + i += 1; + } + out +} + /// Convert a relative path to the string key used in the manifest. Paths are /// stored with forward slashes regardless of host OS so manifests stay portable. pub(crate) fn path_to_key(relative: &Path) -> String { @@ -524,6 +574,19 @@ mod tests { ); } + #[test] + fn crlf_checkout_of_lf_lockfile_is_not_drift() { + // Simulates a lock file built from an LF checkout (e.g. on Linux/macOS) + // being checked with a CRLF-converted working tree (e.g. Windows' + // default core.autocrlf checkout behavior). + let (_dir, root, name, lock) = fixture("abc123", &[("a.txt", "alpha\nbeta\n")]); + let entry = make_entry("vendor/foo", "abc123", false); + fs::write(root.join("vendor/foo/a.txt"), "alpha\r\nbeta\r\n").unwrap(); + let report = inspect_entry(&root, &name, &entry, lock.get(&name)); + assert!(report.changes.is_empty(), "{:?}", report.changes); + assert!(!report.has_drift()); + } + #[test] fn stale_when_config_commit_differs() { let (_dir, root, name, lock) = fixture("abc123", &[("a.txt", "alpha")]); @@ -608,6 +671,68 @@ mod tests { assert_eq!(hash.len(), "sha256:".len() + 64); } + #[test] + fn hash_file_normalizes_crlf_to_lf() { + let dir = tempdir().unwrap(); + let crlf_path = dir.path().join("crlf.txt"); + let lf_path = dir.path().join("lf.txt"); + std::fs::write(&crlf_path, b"alpha\r\nbeta\r\ngamma").unwrap(); + std::fs::write(&lf_path, b"alpha\nbeta\ngamma").unwrap(); + assert_eq!(hash_file(&crlf_path).unwrap(), hash_file(&lf_path).unwrap()); + } + + #[test] + fn hash_file_preserves_lone_cr() { + // Bare CR (old Mac-style line endings) is not a CRLF pair and must be + // left untouched, matching git's own autocrlf semantics. + let dir = tempdir().unwrap(); + let path = dir.path().join("data"); + std::fs::write(&path, b"alpha\rbeta").unwrap(); + let hash = hash_file(&path).unwrap(); + let mut hasher = Sha256::new(); + hasher.update(b"alpha\rbeta"); + let expected = format!("sha256:{:x}", base16ct::HexDisplay(&hasher.finalize())); + assert_eq!(hash, expected); + } + + #[test] + fn hash_file_does_not_normalize_binary_content() { + // A NUL byte marks the file as binary; a \r\n later in the same file + // must survive untouched even though it would be normalized in text. + let dir = tempdir().unwrap(); + let path = dir.path().join("data.bin"); + let mut data = vec![0u8, 1, 2]; + data.extend_from_slice(b"\r\n"); + std::fs::write(&path, &data).unwrap(); + let hash = hash_file(&path).unwrap(); + let mut hasher = Sha256::new(); + hasher.update(&data); + let expected = format!("sha256:{:x}", base16ct::HexDisplay(&hasher.finalize())); + assert_eq!(hash, expected); + } + + #[test] + fn hash_file_normalizes_crlf_split_across_chunk_boundary() { + // Places a \r\n exactly on the HASH_BUFFER_SIZE chunk boundary to + // exercise the pending_cr carry-over logic. + let dir = tempdir().unwrap(); + let crlf_path = dir.path().join("crlf.txt"); + let lf_path = dir.path().join("lf.txt"); + + let mut crlf_data = vec![b'a'; HASH_BUFFER_SIZE - 1]; + crlf_data.push(b'\r'); + crlf_data.push(b'\n'); + crlf_data.extend_from_slice(b"tail"); + + let mut lf_data = vec![b'a'; HASH_BUFFER_SIZE - 1]; + lf_data.push(b'\n'); + lf_data.extend_from_slice(b"tail"); + + std::fs::write(&crlf_path, &crlf_data).unwrap(); + std::fs::write(&lf_path, &lf_data).unwrap(); + assert_eq!(hash_file(&crlf_path).unwrap(), hash_file(&lf_path).unwrap()); + } + #[test] fn walk_files_returns_sorted_relative_paths() { let dir = tempdir().unwrap();