use std::borrow::Cow;
use std::ffi::OsString;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use crate::config::RailConfig;
use crate::error::{RailError, RailResult};
pub(crate) fn is_symlink_or_reparse(metadata: &fs::Metadata) -> bool {
if metadata.file_type().is_symlink() {
return true;
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt as _;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
{
false
}
}
pub(crate) fn open_cache_lock_file(path: &Path, create: bool) -> io::Result<fs::File> {
let mut options = fs::OpenOptions::new();
options.read(true).write(true).create(create).truncate(false);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt as _;
const FILE_SHARE_READ: u32 = 0x0000_0001;
const FILE_SHARE_WRITE: u32 = 0x0000_0002;
options.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE);
}
options.open(path)
}
pub(crate) fn private_file_matches_path(opened: &fs::File, path: &Path, expected_len: u64) -> io::Result<bool> {
let opened_metadata = opened.metadata()?;
let named_metadata = fs::symlink_metadata(path)?;
if !opened_metadata.is_file()
|| !named_metadata.is_file()
|| is_symlink_or_reparse(&named_metadata)
|| opened_metadata.len() != expected_len
|| named_metadata.len() != expected_len
{
return Ok(false);
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
Ok(opened_metadata.dev() == named_metadata.dev()
&& opened_metadata.ino() == named_metadata.ino()
&& opened_metadata.nlink() == 1
&& named_metadata.nlink() == 1)
}
#[cfg(windows)]
{
let named = crate::windows_fs::open_for_observation(path)?;
let opened_observation = crate::windows_fs::observe_file(opened)?;
let named_observation = crate::windows_fs::observe_file(&named)?;
crate::windows_fs::prove_local_ntfs(opened, opened_observation.volume_serial_number)?;
crate::windows_fs::prove_local_ntfs(&named, named_observation.volume_serial_number)?;
Ok(opened_observation == named_observation
&& opened_observation.number_of_links == 1
&& opened_observation.size == expected_len)
}
#[cfg(not(any(unix, windows)))]
{
Ok(true)
}
}
#[cfg(target_vendor = "apple")]
pub(crate) fn try_clone_regular_file(source: &fs::File, destination: &Path) -> Option<fs::File> {
let parent = fs::File::open(destination.parent()?).ok()?;
let name = destination.file_name()?;
if rustix::fs::fclonefileat(
source,
&parent,
name,
rustix::fs::CloneFlags::NOFOLLOW | rustix::fs::CloneFlags::NOOWNERCOPY,
)
.is_ok()
{
match fs::OpenOptions::new().read(true).write(true).open(destination) {
Ok(output) => Some(output),
Err(_) => {
drop(fs::remove_file(destination));
None
}
}
} else {
drop(fs::remove_file(destination));
None
}
}
#[cfg(all(target_os = "linux", not(any(target_arch = "sparc", target_arch = "sparc64"))))]
pub(crate) fn try_clone_regular_file(source: &fs::File, destination: &Path) -> Option<fs::File> {
let output = fs::OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.open(destination)
.ok()?;
match rustix::fs::ioctl_ficlone(&output, source) {
Ok(()) => Some(output),
Err(_) => {
drop(output);
drop(fs::remove_file(destination));
None
}
}
}
#[cfg(not(any(
target_vendor = "apple",
all(target_os = "linux", not(any(target_arch = "sparc", target_arch = "sparc64")))
)))]
pub(crate) fn try_clone_regular_file(_source: &fs::File, _destination: &Path) -> Option<fs::File> {
None
}
#[cfg(target_os = "macos")]
pub(crate) fn stable_file_generation(path: &Path) -> Option<Vec<u8>> {
let metadata = fs::symlink_metadata(path).ok()?;
if !metadata.is_file() || is_symlink_or_reparse(&metadata) {
return None;
}
macos_file_generation(&metadata)
}
#[cfg(target_os = "macos")]
fn macos_file_generation(metadata: &fs::Metadata) -> Option<Vec<u8>> {
use std::os::macos::fs::MetadataExt as _;
let mut generation = Vec::from(&b"macos-file-generation-v1\0"[..]);
for value in [
metadata.st_dev(),
metadata.st_ino(),
u64::from(metadata.st_mode()),
metadata.st_nlink(),
metadata.st_size(),
u64::from_ne_bytes(metadata.st_mtime().to_ne_bytes()),
u64::from_ne_bytes(metadata.st_mtime_nsec().to_ne_bytes()),
u64::from_ne_bytes(metadata.st_ctime().to_ne_bytes()),
u64::from_ne_bytes(metadata.st_ctime_nsec().to_ne_bytes()),
u64::from_ne_bytes(metadata.st_birthtime().to_ne_bytes()),
u64::from_ne_bytes(metadata.st_birthtime_nsec().to_ne_bytes()),
u64::from(metadata.st_gen()),
] {
generation.extend_from_slice(&value.to_le_bytes());
}
Some(generation)
}
#[cfg(target_os = "macos")]
pub(crate) fn stable_open_file_generation(file: &fs::File) -> Option<Vec<u8>> {
let metadata = file.metadata().ok()?;
metadata.is_file().then_some(())?;
macos_file_generation(&metadata)
}
#[cfg(target_os = "linux")]
pub(crate) fn stable_file_generation(path: &Path) -> Option<Vec<u8>> {
let metadata = fs::symlink_metadata(path).ok()?;
if !metadata.is_file() || is_symlink_or_reparse(&metadata) {
return None;
}
linux_file_generation(&metadata)
}
#[cfg(target_os = "linux")]
fn linux_file_generation(metadata: &fs::Metadata) -> Option<Vec<u8>> {
use std::os::unix::fs::MetadataExt as _;
let mut generation = Vec::from(&b"linux-file-generation-v1\0"[..]);
for value in [
metadata.dev(),
metadata.ino(),
metadata.mode() as u64,
metadata.nlink(),
metadata.size(),
u64::from_ne_bytes(metadata.mtime().to_ne_bytes()),
u64::from_ne_bytes(metadata.mtime_nsec().to_ne_bytes()),
u64::from_ne_bytes(metadata.ctime().to_ne_bytes()),
u64::from_ne_bytes(metadata.ctime_nsec().to_ne_bytes()),
] {
generation.extend_from_slice(&value.to_le_bytes());
}
Some(generation)
}
#[cfg(target_os = "linux")]
pub(crate) fn stable_open_file_generation(file: &fs::File) -> Option<Vec<u8>> {
let metadata = file.metadata().ok()?;
metadata.is_file().then_some(())?;
linux_file_generation(&metadata)
}
#[cfg(target_os = "linux")]
pub(crate) fn stable_open_directory_generation(directory: &fs::File) -> Option<Vec<u8>> {
let metadata = directory.metadata().ok()?;
metadata.is_dir().then_some(())?;
linux_file_generation(&metadata)
}
#[cfg(windows)]
pub(crate) fn stable_file_generation(path: &Path) -> Option<Vec<u8>> {
let file = crate::windows_fs::open_for_observation(path).ok()?;
let observation = crate::windows_fs::observe_file(&file).ok()?;
crate::windows_fs::prove_local_ntfs(&file, observation.volume_serial_number).ok()?;
let repeated = crate::windows_fs::open_for_observation(path).ok()?;
let repeated = crate::windows_fs::observe_file(&repeated).ok()?;
if observation != repeated || observation.file_attributes & 0x10 != 0 {
return None;
}
windows_file_generation(observation)
}
#[cfg(windows)]
fn windows_file_generation(observation: crate::windows_fs::FileObservation) -> Option<Vec<u8>> {
let mut generation = Vec::from(&b"windows-file-generation-v2\0"[..]);
for value in [
observation.volume_serial_number,
observation.file_id,
observation.creation_time,
observation.last_write_time,
u64::from(observation.file_attributes),
observation.size,
observation.number_of_links,
] {
generation.extend_from_slice(&value.to_le_bytes());
}
Some(generation)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
pub(crate) fn stable_file_generation(_path: &Path) -> Option<Vec<u8>> {
None
}
#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
pub(crate) fn stable_open_file_generation(_file: &fs::File) -> Option<Vec<u8>> {
None
}
pub(crate) fn opened_file_matches_path(opened: &fs::File, path: &Path, expected_len: u64) -> io::Result<bool> {
let opened_metadata = opened.metadata()?;
let named_metadata = fs::symlink_metadata(path)?;
if !opened_metadata.is_file()
|| !named_metadata.is_file()
|| is_symlink_or_reparse(&named_metadata)
|| opened_metadata.len() != expected_len
|| named_metadata.len() != expected_len
{
return Ok(false);
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
Ok(opened_metadata.dev() == named_metadata.dev() && opened_metadata.ino() == named_metadata.ino())
}
#[cfg(windows)]
{
let named = crate::windows_fs::open_for_observation(path)?;
let opened_observation = crate::windows_fs::observe_file(opened)?;
let named_observation = crate::windows_fs::observe_file(&named)?;
crate::windows_fs::prove_local_ntfs(opened, opened_observation.volume_serial_number)?;
crate::windows_fs::prove_local_ntfs(&named, named_observation.volume_serial_number)?;
Ok(opened_observation == named_observation && opened_observation.size == expected_len)
}
#[cfg(not(any(unix, windows)))]
{
Ok(opened_metadata.modified()? == named_metadata.modified()?)
}
}
#[inline]
pub fn fnv1a64(bytes: &[u8]) -> u64 {
const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
const FNV_PRIME: u64 = 0x100000001b3;
crate::instrumentation::record_hash(bytes.len());
let mut hash = FNV_OFFSET_BASIS;
for byte in bytes {
hash ^= *byte as u64;
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
pub fn file_fingerprint(path: &Path) -> String {
match fs::read(path) {
Ok(bytes) => {
crate::instrumentation::record_hashed_file_bytes_read(bytes.len());
format!("fnv1a64:{:016x}", fnv1a64(&bytes))
}
Err(_) => "none".to_string(),
}
}
fn text_file_fingerprint(path: &Path) -> String {
match fs::read(path) {
Ok(bytes) => {
crate::instrumentation::record_hashed_file_bytes_read(bytes.len());
let normalized = normalize_line_endings(&bytes);
format!("fnv1a64:{:016x}", fnv1a64(&normalized))
}
Err(_) => "none".to_string(),
}
}
pub(crate) fn normalize_line_endings(bytes: &[u8]) -> Cow<'_, [u8]> {
if !bytes.contains(&b'\r') {
return Cow::Borrowed(bytes);
}
let mut normalized = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'\r' {
normalized.push(b'\n');
index += usize::from(bytes.get(index + 1) == Some(&b'\n'));
} else {
normalized.push(bytes[index]);
}
index += 1;
}
Cow::Owned(normalized)
}
pub(crate) fn write_file_atomic(path: &Path, contents: &[u8]) -> RailResult<()> {
let parent = destination_parent(path);
let mut temporary = tempfile::Builder::new()
.prefix(".cargo-rail-")
.suffix(".tmp")
.tempfile_in(parent)
.map_err(|error| {
RailError::message(format!(
"failed to create temporary file in {}: {error}",
parent.display()
))
})?;
if let Ok(metadata) = fs::symlink_metadata(path)
&& metadata.file_type().is_file()
{
temporary
.as_file()
.set_permissions(metadata.permissions())
.map_err(|error| {
RailError::message(format!(
"failed to preserve permissions for {}: {error}",
path.display()
))
})?;
}
temporary
.write_all(contents)
.and_then(|()| temporary.as_file().sync_all())
.map_err(|error| {
RailError::message(format!(
"failed to write temporary file for {}: {error}",
path.display()
))
})?;
persist_file_atomic(temporary, path)?;
Ok(())
}
pub(crate) fn persist_file_atomic(temporary: tempfile::NamedTempFile, path: &Path) -> RailResult<fs::File> {
let parent = destination_parent(path);
let file = persist_regenerable_file_atomic(temporary, path)?;
sync_parent_directory(parent).map_err(|error| {
RailError::message(format!(
"failed to persist the atomic replacement for {}: {error}",
path.display()
))
})?;
Ok(file)
}
pub(crate) fn persist_regenerable_file_atomic(temporary: tempfile::NamedTempFile, path: &Path) -> RailResult<fs::File> {
let persisted = {
#[cfg(windows)]
{
persist_atomic_replacement(temporary, path)
}
#[cfg(not(windows))]
{
temporary.persist(path).map_err(|error| error.error)
}
};
persisted.map_err(|error| RailError::message(format!("failed to atomically replace {}: {}", path.display(), error)))
}
#[cfg(windows)]
fn persist_atomic_replacement(temporary: tempfile::NamedTempFile, path: &Path) -> io::Result<fs::File> {
const MAX_ATTEMPTS: usize = 50;
let (file, temporary_path) = temporary.keep().map_err(|error| error.error)?;
let mut attempts = 0;
loop {
match crate::windows_fs::rename_write_through(&temporary_path, path, true) {
Ok(()) => return Ok(file),
Err(error) => {
attempts += 1;
let transient_reader_conflict = matches!(error.raw_os_error(), Some(5 | 32 | 33));
if !transient_reader_conflict || attempts >= MAX_ATTEMPTS {
drop(file);
return match fs::remove_file(&temporary_path) {
Ok(()) => Err(error),
Err(cleanup) => Err(io::Error::new(
error.kind(),
format!(
"{error}; failed to remove retained temporary file '{}': {cleanup}",
temporary_path.display()
),
)),
};
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
}
}
fn destination_parent(path: &Path) -> &Path {
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
}
#[cfg(unix)]
pub(crate) fn sync_parent_directory(path: &Path) -> io::Result<()> {
fs::File::open(path)?.sync_all()
}
#[cfg(not(unix))]
pub(crate) fn sync_parent_directory(_path: &Path) -> io::Result<()> {
Ok(())
}
pub fn config_fingerprint(workspace_root: &Path) -> String {
RailConfig::find_config_path(workspace_root)
.map(|p| text_file_fingerprint(&p))
.unwrap_or_else(|| "none".to_string())
}
pub fn toolchain_fingerprint(workspace_root: &Path) -> String {
["rust-toolchain.toml", "rust-toolchain"]
.iter()
.map(|name| workspace_root.join(name))
.find(|p| p.exists())
.map(|p| text_file_fingerprint(&p))
.unwrap_or_else(|| "none".to_string())
}
pub fn canonicalize_existing(path: &Path) -> io::Result<PathBuf> {
fs::canonicalize(path).map(simplify_canonical_path)
}
pub fn canonicalize_allow_missing(path: &Path) -> io::Result<PathBuf> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
};
let mut ancestor = absolute.as_path();
let mut suffix = Vec::<OsString>::new();
loop {
match fs::symlink_metadata(ancestor) {
Ok(_) => break,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
let name = ancestor.file_name().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("path '{}' has no existing ancestor", path.display()),
)
})?;
suffix.push(name.to_os_string());
ancestor = ancestor.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("path '{}' has no existing ancestor", path.display()),
)
})?;
}
Err(error) => return Err(error),
}
}
let mut resolved = canonicalize_existing(ancestor)?;
for component in suffix.iter().rev() {
resolved.push(component);
}
Ok(resolved)
}
pub fn path_relative_to(root: &Path, path: &Path) -> io::Result<PathBuf> {
let root = canonicalize_existing(root)?;
let path = canonicalize_allow_missing(path)?;
path.strip_prefix(&root).map(Path::to_path_buf).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("path '{}' is outside root '{}'", path.display(), root.display()),
)
})
}
#[cfg(not(windows))]
fn simplify_canonical_path(path: PathBuf) -> PathBuf {
path
}
#[cfg(windows)]
fn simplify_canonical_path(path: PathBuf) -> PathBuf {
use std::path::{Component, Prefix};
let mut components = path.components();
let Some(Component::Prefix(prefix)) = components.next() else {
return path;
};
let mut simplified = match prefix.kind() {
Prefix::VerbatimDisk(drive) => PathBuf::from(format!("{}:", char::from(drive).to_ascii_uppercase())),
Prefix::VerbatimUNC(server, share) => {
let mut raw = OsString::from(r"\\");
raw.push(server);
raw.push(r"\");
raw.push(share);
PathBuf::from(raw)
}
_ => return path,
};
for component in components {
simplified.push(component.as_os_str());
}
simplified
}
pub fn is_local_path(path: &str) -> bool {
let p = Path::new(path);
if path.starts_with("./") || path.starts_with("../") {
return true;
}
if path.len() >= 3 {
let bytes = path.as_bytes();
if bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/') {
return true;
}
}
if path.starts_with("\\\\") {
return true;
}
if path.starts_with('/') && !path.contains("://") && !path.contains('@') {
return true;
}
p.is_absolute()
}
pub fn prompt_for_confirmation() -> RailResult<bool> {
prompt_for_confirmation_from(&mut io::stdin().lock(), &mut io::stderr().lock())
}
fn prompt_for_confirmation_from(reader: &mut impl io::BufRead, writer: &mut impl io::Write) -> RailResult<bool> {
writer.write_all(b"Proceed? [y/N] ")?;
writer.flush()?;
let mut input = String::new();
let bytes = match reader.read_line(&mut input) {
Ok(bytes) => bytes,
Err(error) if error.kind() == io::ErrorKind::Interrupted => return Ok(false),
Err(error) => return Err(error.into()),
};
if bytes == 0 {
return Ok(false);
}
let answer = input.trim_end_matches(['\r', '\n']);
Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
}
pub fn detect_crate_changelog(crate_dir: &cargo_metadata::camino::Utf8Path) -> Option<std::path::PathBuf> {
let changelog_patterns = [
"CHANGELOG.md",
"CHANGELOG.txt",
"CHANGELOG",
"Changelog.md",
"changelog.md",
"CHANGES.md",
"CHANGES.txt",
"CHANGES",
"Changes.md",
"changes.md",
];
for pattern in &changelog_patterns {
let changelog = crate_dir.join(pattern);
if changelog.exists() {
return Some(std::path::PathBuf::from(pattern));
}
}
None
}
pub fn path_to_git_format(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
#[cfg(test)]
mod tests {
use std::io::Cursor;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Barrier};
use super::*;
struct InterruptedReader;
impl io::Read for InterruptedReader {
fn read(&mut self, _buffer: &mut [u8]) -> io::Result<usize> {
Err(io::Error::from(io::ErrorKind::Interrupted))
}
}
impl io::BufRead for InterruptedReader {
fn fill_buf(&mut self) -> io::Result<&[u8]> {
Err(io::Error::from(io::ErrorKind::Interrupted))
}
fn consume(&mut self, _amount: usize) {}
fn read_line(&mut self, _buffer: &mut String) -> io::Result<usize> {
Err(io::Error::from(io::ErrorKind::Interrupted))
}
}
#[test]
fn confirmation_accepts_only_an_explicit_yes_and_uses_the_exact_prompt() {
for answer in ["y\n", "Y\n", "yes\n", "YeS\r\n"] {
let mut reader = Cursor::new(answer.as_bytes());
let mut prompt = Vec::new();
assert!(prompt_for_confirmation_from(&mut reader, &mut prompt).unwrap());
assert_eq!(prompt, b"Proceed? [y/N] ");
}
for answer in ["\n", "n\n", "no\n", " y\n", "yes \n", "anything\n", ""] {
let mut reader = Cursor::new(answer.as_bytes());
let mut prompt = Vec::new();
assert!(!prompt_for_confirmation_from(&mut reader, &mut prompt).unwrap());
assert_eq!(prompt, b"Proceed? [y/N] ");
}
let mut reader = InterruptedReader;
let mut prompt = Vec::new();
assert!(!prompt_for_confirmation_from(&mut reader, &mut prompt).unwrap());
assert_eq!(prompt, b"Proceed? [y/N] ");
}
#[cfg(windows)]
#[test]
fn windows_file_generation_ignores_benign_change_time_but_binds_byte_writes() {
let observation = crate::windows_fs::FileObservation {
volume_serial_number: 1,
file_id: 2,
creation_time: 3,
last_write_time: 4,
change_time: 5,
file_attributes: 6,
size: 7,
number_of_links: 1,
};
let mut accessed = observation;
accessed.change_time = 8;
assert_eq!(windows_file_generation(observation), windows_file_generation(accessed));
let mut written = observation;
written.last_write_time = 9;
assert_ne!(windows_file_generation(observation), windows_file_generation(written));
}
#[test]
fn test_fnv1a64_empty() {
assert_eq!(fnv1a64(b""), 0xcbf29ce484222325);
}
#[test]
fn test_fnv1a64_known_values() {
assert_eq!(fnv1a64(b"a"), 0xaf63dc4c8601ec8c);
assert_eq!(fnv1a64(b"foobar"), 0x85944171f73967e8);
}
#[test]
fn test_file_fingerprint_missing_file() {
let result = file_fingerprint(Path::new("/nonexistent/path/to/file"));
assert_eq!(result, "none");
}
#[test]
fn atomic_writes_use_current_directory_for_bare_filenames() {
assert_eq!(destination_parent(Path::new("rail.toml")), Path::new("."));
assert_eq!(destination_parent(Path::new("config/rail.toml")), Path::new("config"));
}
#[test]
fn atomic_replacement_never_exposes_partial_bytes_to_concurrent_readers() {
let directory = tempfile::tempdir().unwrap();
let destination = directory.path().join("state");
let first = Arc::new(vec![b'a'; 256 * 1024]);
let second = Arc::new(vec![b'b'; 256 * 1024]);
write_file_atomic(&destination, &first).unwrap();
let stop = Arc::new(AtomicBool::new(false));
let reads = Arc::new(AtomicUsize::new(0));
let ready = Arc::new(Barrier::new(2));
std::thread::scope(|scope| {
let reader_stop = Arc::clone(&stop);
let reader_reads = Arc::clone(&reads);
let reader_ready = Arc::clone(&ready);
let reader_first = Arc::clone(&first);
let reader_second = Arc::clone(&second);
let destination = &destination;
let reader = scope.spawn(move || {
let observed = fs::read(destination).expect("the destination must remain visible");
assert!(
observed == *reader_first || observed == *reader_second,
"a reader observed a partial atomic replacement"
);
reader_reads.fetch_add(1, Ordering::Relaxed);
reader_ready.wait();
while !reader_stop.load(Ordering::Acquire) {
let observed = fs::read(destination).expect("the destination must remain visible");
assert!(
observed == *reader_first || observed == *reader_second,
"a reader observed a partial atomic replacement"
);
reader_reads.fetch_add(1, Ordering::Relaxed);
if cfg!(windows) {
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
});
ready.wait();
let replacements = if cfg!(windows) { 1 } else { 64 };
let replacement = (|| {
for iteration in 0..replacements {
let contents = if iteration % 2 == 0 { &second } else { &first };
write_file_atomic(destination, contents)?;
}
Ok::<(), RailError>(())
})();
stop.store(true, Ordering::Release);
reader.join().unwrap();
replacement.unwrap();
});
assert!(reads.load(Ordering::Relaxed) > 0, "the concurrent reader did not run");
}
#[cfg(unix)]
#[test]
fn atomic_replacement_preserves_mode_and_replaces_symlinks_without_following_them() {
use std::os::unix::fs::{PermissionsExt as _, symlink};
let directory = tempfile::tempdir().unwrap();
let destination = directory.path().join("state");
fs::write(&destination, b"old").unwrap();
fs::set_permissions(&destination, fs::Permissions::from_mode(0o600)).unwrap();
write_file_atomic(&destination, b"new").unwrap();
assert_eq!(fs::read(&destination).unwrap(), b"new");
assert_eq!(fs::metadata(&destination).unwrap().permissions().mode() & 0o777, 0o600);
let victim = directory.path().join("victim");
fs::write(&victim, b"keep").unwrap();
fs::remove_file(&destination).unwrap();
symlink(&victim, &destination).unwrap();
write_file_atomic(&destination, b"replacement").unwrap();
assert_eq!(fs::read(&victim).unwrap(), b"keep");
assert_eq!(fs::read(&destination).unwrap(), b"replacement");
assert!(!fs::symlink_metadata(&destination).unwrap().file_type().is_symlink());
}
#[test]
fn atomic_replacement_preserves_the_old_file_when_the_filesystem_is_full() {
const ROOT_ENV: &str = "CARGO_RAIL_TEST_ENOSPC_ROOT";
const MAX_BYTES_ENV: &str = "CARGO_RAIL_TEST_ENOSPC_MAX_BYTES";
let (Some(root), Some(maximum)) = (std::env::var_os(ROOT_ENV), std::env::var_os(MAX_BYTES_ENV)) else {
return;
};
let maximum = maximum
.to_str()
.and_then(|value| value.parse::<u64>().ok())
.expect("the ENOSPC byte bound must be an unsigned integer");
assert!(
(1024 * 1024..=512 * 1024 * 1024).contains(&maximum),
"the ENOSPC byte bound must be between 1 MiB and 512 MiB"
);
let directory = tempfile::Builder::new()
.prefix("cargo-rail-enospc-")
.tempdir_in(PathBuf::from(root))
.expect("isolated ENOSPC test directory");
let destination = directory.path().join("state");
fs::write(&destination, b"old").expect("initial state");
let filler_path = directory.path().join("filler");
let mut filler = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&filler_path)
.expect("filler file");
let mut block = vec![0_u8; 1024 * 1024];
let mut state = 0x9e37_79b9_7f4a_7c15_u64;
for chunk in block.as_chunks_mut::<8>().0 {
state ^= state << 7;
state ^= state >> 9;
state ^= state << 8;
chunk.copy_from_slice(&state.to_le_bytes());
}
let mut written = 0_u64;
let exhausted = loop {
match filler.write_all(&block).and_then(|()| filler.sync_data()) {
Ok(()) => {
written = written.saturating_add(block.len() as u64);
if written >= maximum {
break None;
}
}
Err(error) if error.kind() == io::ErrorKind::StorageFull => break Some(error),
Err(error) => panic!("isolated filesystem failed before ENOSPC: {error}"),
}
};
if exhausted.is_none() {
drop(filler);
fs::remove_file(&filler_path).expect("remove bounded filler");
panic!("isolated filesystem did not report ENOSPC within {maximum} bytes");
}
let replacement = vec![b'n'; 1024 * 1024];
let error =
write_file_atomic(&destination, &replacement).expect_err("replacement must fail on a full filesystem");
assert!(
error.to_string().contains("temporary file") || error.to_string().contains("atomically replace"),
"{error}"
);
assert_eq!(fs::read(&destination).expect("original state"), b"old");
assert_eq!(
fs::read_dir(directory.path())
.expect("test directory")
.filter_map(Result::ok)
.filter(|entry| entry.file_name().to_string_lossy().starts_with(".cargo-rail-"))
.count(),
0,
"a failed atomic replacement must clean its private temporary file"
);
drop(filler);
fs::remove_file(&filler_path).expect("free isolated filesystem capacity");
write_file_atomic(&destination, b"new").expect("replacement should recover after capacity is available");
assert_eq!(fs::read(&destination).expect("recovered state"), b"new");
}
#[test]
fn test_text_fingerprint_ignores_checkout_line_endings() {
let temp = tempfile::tempdir().unwrap();
let lf = temp.path().join("lf.toml");
let crlf = temp.path().join("crlf.toml");
fs::write(&lf, b"[workspace]\nroot = \".\"\n").unwrap();
fs::write(&crlf, b"[workspace]\r\nroot = \".\"\r\n").unwrap();
assert_eq!(text_file_fingerprint(&lf), text_file_fingerprint(&crlf));
assert_ne!(file_fingerprint(&lf), file_fingerprint(&crlf));
}
#[test]
#[cfg(unix)]
fn missing_path_resolution_preserves_symlink_parent_traversal() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("root");
let outside = temp.path().join("outside");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(outside.join("child")).unwrap();
std::os::unix::fs::symlink(outside.join("child"), root.join("alias")).unwrap();
let spelling = root.join("alias/../new/output");
assert_eq!(
canonicalize_allow_missing(&spelling).unwrap(),
canonicalize_existing(&outside).unwrap().join("new/output")
);
path_relative_to(&root, &spelling).unwrap_err();
fs::write(outside.join("existing"), b"outside").unwrap();
assert_eq!(
canonicalize_allow_missing(&root.join("alias/../existing")).unwrap(),
canonicalize_existing(&outside.join("existing")).unwrap()
);
canonicalize_allow_missing(&root.join("missing/../output")).unwrap_err();
}
#[test]
fn test_path_relative_to_resolves_existing_and_missing_paths() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("root");
fs::create_dir(&root).unwrap();
fs::write(root.join("existing.txt"), b"content").unwrap();
assert_eq!(
path_relative_to(&root, &root.join("existing.txt")).unwrap(),
PathBuf::from("existing.txt")
);
assert_eq!(
path_relative_to(&root, &root.join("new/child.txt")).unwrap(),
PathBuf::from("new/child.txt")
);
path_relative_to(&root, temp.path()).unwrap_err();
}
#[cfg(unix)]
#[test]
fn test_path_relative_to_rejects_symlink_escape() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("root");
let outside = temp.path().join("outside");
fs::create_dir(&root).unwrap();
fs::create_dir(&outside).unwrap();
fs::write(outside.join("file.txt"), b"outside").unwrap();
symlink(&outside, root.join("escape")).unwrap();
path_relative_to(&root, &root.join("escape/file.txt")).unwrap_err();
}
#[cfg(windows)]
#[test]
fn test_canonical_paths_are_external_tool_compatible_on_windows() {
let temp = tempfile::tempdir().unwrap();
let canonical = canonicalize_existing(temp.path()).unwrap();
assert!(!canonical.to_string_lossy().starts_with(r"\\?\"));
}
#[test]
fn test_is_local_path_classification() {
assert!(is_local_path("/home/user/repo"));
assert!(is_local_path("C:\\Users\\test\\repo"));
assert!(is_local_path("C:/Users/test/repo"));
assert!(is_local_path("./repo"));
assert!(is_local_path("../path/to/repo"));
#[cfg(target_os = "windows")]
assert!(is_local_path("\\\\server\\share\\repo"));
assert!(!is_local_path("git@github.com:user/repo.git"));
assert!(!is_local_path("https://github.com/user/repo.git"));
assert!(!is_local_path("ssh://git@github.com/user/repo.git"));
assert!(!is_local_path("repo"));
assert!(!is_local_path(""));
}
#[test]
fn test_path_to_git_format_preserves_forward_slashes() {
let path = PathBuf::from("/home/user/repo/src/main.rs");
assert_eq!(path_to_git_format(&path), "/home/user/repo/src/main.rs");
let path = PathBuf::from("./relative/path.rs");
assert_eq!(path_to_git_format(&path), "./relative/path.rs");
}
#[test]
fn test_path_to_git_format_normalizes_backslashes_on_every_host() {
let path = PathBuf::from("C:\\Users\\test\\repo\\src\\main.rs");
assert_eq!(path_to_git_format(&path), "C:/Users/test/repo/src/main.rs");
let path = PathBuf::from("..\\relative\\path.rs");
assert_eq!(path_to_git_format(&path), "../relative/path.rs");
}
}