Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 128 additions & 3 deletions src/lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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.
";
Expand Down Expand Up @@ -186,24 +189,71 @@ 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<String> {
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)
.with_context(|| format!("failed to read {}", path.display()))?;
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<u8> {
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 {
Expand Down Expand Up @@ -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")]);
Expand Down Expand Up @@ -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();
Expand Down