use std::path::{Path, PathBuf};
use crate::ops::replace::preferred_line_ending;
pub fn append_content(existing: &str, append: &str) -> String {
if append.is_empty() {
return existing.to_string();
}
let mut combined = existing.to_string();
if !combined.is_empty() && !ends_with_line_ending(&combined) {
combined.push_str(preferred_line_ending(existing));
}
combined.push_str(append);
combined
}
pub fn strip_utf8_bom(s: &str) -> &str {
s.strip_prefix('\u{feff}').unwrap_or(s)
}
pub fn split_utf8_bom(s: &str) -> (&str, &str) {
match s.strip_prefix('\u{feff}') {
Some(rest) => ("\u{feff}", rest),
None => ("", s),
}
}
pub fn text_lines(s: &str) -> TextLines<'_> {
TextLines { rest: s }
}
pub struct TextLines<'a> {
rest: &'a str,
}
impl<'a> Iterator for TextLines<'a> {
type Item = &'a str;
fn next(&mut self) -> Option<&'a str> {
if self.rest.is_empty() {
return None;
}
let bytes = self.rest.as_bytes();
let Some(pos) = bytes.iter().position(|&b| b == b'\n' || b == b'\r') else {
let line = self.rest;
self.rest = "";
return Some(line);
};
let line = &self.rest[..pos];
let adv = if bytes[pos] == b'\n' {
pos + 1
} else if pos + 1 < bytes.len() && bytes[pos + 1] == b'\n' {
pos + 2
} else {
pos + 1
};
self.rest = &self.rest[adv..];
Some(line)
}
}
pub fn text_lines_with_endings(s: &str) -> TextLinesWithEndings<'_> {
TextLinesWithEndings { rest: s }
}
pub struct TextLinesWithEndings<'a> {
rest: &'a str,
}
impl<'a> Iterator for TextLinesWithEndings<'a> {
type Item = (&'a str, &'a str);
fn next(&mut self) -> Option<Self::Item> {
if self.rest.is_empty() {
return None;
}
let bytes = self.rest.as_bytes();
let Some(pos) = bytes.iter().position(|&b| b == b'\n' || b == b'\r') else {
let line = self.rest;
self.rest = "";
return Some((line, ""));
};
let line = &self.rest[..pos];
let (ending, adv) = if bytes[pos] == b'\n' {
(&self.rest[pos..pos + 1], pos + 1)
} else if pos + 1 < bytes.len() && bytes[pos + 1] == b'\n' {
(&self.rest[pos..pos + 2], pos + 2)
} else {
(&self.rest[pos..pos + 1], pos + 1)
};
self.rest = &self.rest[adv..];
Some((line, ending))
}
}
pub fn text_line_index(content: &str, offset: usize) -> usize {
let offset = offset.min(content.len());
let mut idx = 0;
let bytes = content.as_bytes();
let mut i = 0;
while i < offset {
if bytes[i] == b'\n' {
idx += 1;
i += 1;
} else if bytes[i] == b'\r' {
idx += 1;
if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
i += 2;
} else {
i += 1;
}
} else {
i += 1;
}
}
idx
}
pub fn text_line_column(content: &str, offset: usize) -> (usize, usize) {
let offset = offset.min(content.len());
let line = text_line_index(content, offset) + 1;
let line_start = content[..offset]
.rfind(['\n', '\r'])
.map(|i| i + 1)
.unwrap_or(0);
(line, offset - line_start + 1)
}
pub fn prepend_content(existing: &str, prepend: &str) -> String {
if prepend.is_empty() {
return existing.to_string();
}
let (bom, rest) = split_utf8_bom(existing);
let mut combined = prepend.to_string();
if !ends_with_line_ending(&combined) && !rest.is_empty() {
combined.push_str(preferred_line_ending(rest));
}
combined.push_str(rest);
if bom.is_empty() {
combined
} else {
let mut out = String::with_capacity(bom.len() + combined.len());
out.push_str(bom);
out.push_str(&combined);
out
}
}
#[inline]
fn ends_with_line_ending(s: &str) -> bool {
s.ends_with("\r\n") || s.ends_with('\n') || s.ends_with('\r')
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathEntryKind {
Missing,
RegularFile,
RealDirectory,
Special,
}
impl PathEntryKind {
#[inline]
pub fn exists(self) -> bool {
!matches!(self, Self::Missing)
}
#[inline]
pub fn is_regular_file(self) -> bool {
matches!(self, Self::RegularFile)
}
#[inline]
pub fn is_real_directory(self) -> bool {
matches!(self, Self::RealDirectory)
}
}
pub fn classify_path_entry(path: &Path) -> PathEntryKind {
#[cfg(windows)]
let collapsed = windows_collapse_dest_path(path);
#[cfg(windows)]
let path = collapsed.as_path();
match std::fs::symlink_metadata(path) {
Err(_) => PathEntryKind::Missing,
Ok(meta) => {
let ft = meta.file_type();
if ft.is_dir() && !ft.is_symlink() && !is_windows_reparse_point(&meta) {
PathEntryKind::RealDirectory
} else if ft.is_file() && !ft.is_symlink() && !is_windows_reparse_point(&meta) {
PathEntryKind::RegularFile
} else {
PathEntryKind::Special
}
}
}
}
pub fn path_entry_exists(path: &Path) -> bool {
classify_path_entry(path).exists()
}
pub fn is_real_directory(path: &Path) -> bool {
classify_path_entry(path).is_real_directory()
}
pub fn ensure_unlinkable_not_directory(
path: &Path,
display: &str,
) -> Result<(), crate::exit::InvalidInputError> {
if classify_path_entry(path).is_real_directory() {
return Err(crate::exit::InvalidInputError {
msg: format!("target is not a file: {display}"),
});
}
Ok(())
}
#[cfg(windows)]
fn is_windows_reparse_point(meta: &std::fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
fn is_windows_reparse_point(_meta: &std::fs::Metadata) -> bool {
false
}
pub fn is_windows_ads_path(path: &Path) -> bool {
#[cfg(windows)]
{
let raw = path.to_string_lossy();
let s = raw
.strip_prefix(r"\\?\")
.or_else(|| raw.strip_prefix(r"//?/"))
.or_else(|| raw.strip_prefix(r"\\.\"))
.or_else(|| raw.strip_prefix("//./"))
.unwrap_or(raw.as_ref());
let rest = if let Some(after_host) = skip_windows_unc_host(s) {
after_host
} else if s.len() >= 2 && s.as_bytes()[1] == b':' && s.as_bytes()[0].is_ascii_alphabetic() {
&s[2..]
} else {
s
};
rest.contains(':')
}
#[cfg(not(windows))]
{
let _ = path;
false
}
}
#[cfg(windows)]
fn skip_windows_unc_host(s: &str) -> Option<&str> {
let s = s
.strip_prefix(r"UNC\")
.or_else(|| s.strip_prefix(r"unc\"))
.or_else(|| s.strip_prefix(r"UNC/"))
.or_else(|| s.strip_prefix(r"unc/"))
.or_else(|| {
if s.starts_with(r"\\") || s.starts_with("//") {
Some(s.trim_start_matches(['\\', '/']))
} else {
None
}
})?;
if let Some(rest) = s.strip_prefix('[') {
let end = rest.find(']')?;
let after = &rest[end + 1..];
return Some(after.trim_start_matches(['\\', '/']));
}
let is_sep = |c: u8| c == b'\\' || c == b'/';
let host_end = s.as_bytes().iter().position(|&c| is_sep(c))?;
Some(&s[host_end + 1..])
}
pub fn ensure_not_windows_ads_path(
path: &Path,
display: &str,
) -> Result<(), crate::exit::InvalidInputError> {
if is_windows_ads_path(path) {
return Err(crate::exit::InvalidInputError {
msg: format!("refusing Windows alternate data stream path: {display}"),
});
}
Ok(())
}
pub(crate) fn windows_path_is_drive_or_root_relative(raw: &str) -> bool {
let s = raw
.strip_prefix(r"\\?\")
.or_else(|| raw.strip_prefix(r"//?/"))
.or_else(|| raw.strip_prefix(r"\\.\"))
.or_else(|| raw.strip_prefix("//./"))
.unwrap_or(raw);
if s.starts_with(r"\\") || s.starts_with("//") {
return false;
}
let head = s.get(..4).unwrap_or(s);
if head.eq_ignore_ascii_case(r"unc\") || head.eq_ignore_ascii_case("unc/") {
return false;
}
let b = s.as_bytes();
if b.len() >= 2 && b[1] == b':' && b[0].is_ascii_alphabetic() {
return b.len() == 2 || (b[2] != b'\\' && b[2] != b'/');
}
!b.is_empty() && (b[0] == b'\\' || b[0] == b'/')
}
fn windows_is_drive_letter_colon(s: &str) -> bool {
let s = s
.strip_prefix(r"\\?\")
.or_else(|| s.strip_prefix(r"//?/"))
.or_else(|| s.strip_prefix(r"\\.\"))
.or_else(|| s.strip_prefix("//./"))
.unwrap_or(s);
let b = s.as_bytes();
b.len() == 2 && b[1] == b':' && b[0].is_ascii_alphabetic()
}
pub fn windows_collapse_trailing_separators(raw: &str) -> &str {
let mut t = raw;
loop {
let next = t.trim_end_matches(['\\', '/']);
if next.len() == t.len() {
return t;
}
if next.is_empty() {
return t;
}
if windows_is_drive_letter_colon(next) {
return t;
}
t = next;
}
}
pub(crate) fn windows_collapse_dest_path(path: &Path) -> PathBuf {
let raw = path.to_string_lossy();
let collapsed = windows_collapse_trailing_separators(raw.as_ref());
if collapsed == raw.as_ref() {
path.to_path_buf()
} else {
PathBuf::from(collapsed)
}
}
pub fn is_windows_illegal_dest_path(path: &Path) -> bool {
#[cfg(windows)]
{
let raw = path.to_string_lossy();
if windows_path_is_drive_or_root_relative(raw.as_ref()) {
return true;
}
let s = raw
.strip_prefix(r"\\?\")
.or_else(|| raw.strip_prefix(r"//?/"))
.or_else(|| raw.strip_prefix(r"\\.\"))
.or_else(|| raw.strip_prefix("//./"))
.unwrap_or(raw.as_ref());
if raw.contains(r"\\.\") || raw.contains("//./") {
let drive_form = s.len() >= 3
&& s.as_bytes()[1] == b':'
&& (s.as_bytes()[2] == b'/' || s.as_bytes()[2] == b'\\');
if !drive_form {
return true;
}
}
for comp in std::path::Path::new(s).components() {
let std::path::Component::Normal(name) = comp else {
continue;
};
let bytes = name.as_encoded_bytes();
if bytes.last().is_some_and(|b| *b == b' ' || *b == b'.') {
return true;
}
if bytes
.iter()
.any(|b| matches!(*b, 0x00..=0x1F | b'<' | b'>' | b'"' | b'|' | b'?' | b'*'))
{
return true;
}
if bytes.eq_ignore_ascii_case(b"NUL") {
return true;
}
}
false
}
#[cfg(not(windows))]
{
let _ = path;
false
}
}
pub fn ensure_not_windows_illegal_dest(
path: &Path,
display: &str,
) -> Result<(), crate::exit::InvalidInputError> {
if is_windows_illegal_dest_path(path) {
let raw = path.to_string_lossy();
let msg = if windows_path_is_drive_or_root_relative(raw.as_ref()) {
format!(
"refusing Windows dest that ignores --cwd (drive-relative or root-relative): {display}"
)
} else {
format!("refusing Windows dest that is not a file name: {display}")
};
return Err(crate::exit::InvalidInputError { msg });
}
Ok(())
}
pub fn unlink_path_entry(path: &Path) -> std::io::Result<()> {
let meta = std::fs::symlink_metadata(path)?;
#[cfg(windows)]
if is_windows_reparse_point(&meta) && is_windows_directory_entry(&meta) {
return unlink_windows_reparse_dir(path);
}
if meta.file_type().is_dir() {
std::fs::remove_dir(path)
} else {
std::fs::remove_file(path)
}
}
#[cfg(windows)]
fn is_windows_directory_entry(meta: &std::fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
meta.file_attributes() & FILE_ATTRIBUTE_DIRECTORY != 0
}
#[cfg(windows)]
fn unlink_windows_reparse_dir(path: &Path) -> std::io::Result<()> {
let output = std::process::Command::new("powershell")
.env("PATCHLOOM_UNLINK", path)
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
"$ErrorActionPreference='Stop'; [System.IO.Directory]::Delete($env:PATCHLOOM_UNLINK, $false)",
])
.output()?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(std::io::Error::other(format!(
"failed to unlink directory reparse point {}: {stderr}",
path.display()
)))
}
}
pub fn is_regular_file_for_backup(path: &Path) -> bool {
classify_path_entry(path).is_regular_file()
}
pub fn rename_or_copy(src: &Path, dst: &Path) -> anyhow::Result<()> {
use anyhow::Context;
refuse_non_regular_destination(dst, &dst.to_string_lossy())?;
match std::fs::rename(src, dst) {
Ok(()) => Ok(()),
Err(e) if is_cross_device_rename_error(&e) => {
if !is_regular_file_for_backup(src) {
anyhow::bail!(
"cannot cross-device rename special node {}: copy would open a non-regular file",
src.display()
);
}
let dest_existed = path_entry_exists(dst);
std::fs::copy(src, dst).with_context(|| {
format!("cross-device copy {} -> {}", src.display(), dst.display())
})?;
if let Err(remove_err) = std::fs::remove_file(src) {
if !dest_existed {
let _ = std::fs::remove_file(dst);
}
return Err(remove_err).with_context(|| {
format!(
"removing source after cross-device copy: {} -> {}",
src.display(),
dst.display()
)
});
}
Ok(())
}
Err(e) => Err(e.into()),
}
}
fn is_cross_device_rename_error(e: &std::io::Error) -> bool {
#[cfg(unix)]
{
e.raw_os_error() == Some(libc::EXDEV)
}
#[cfg(windows)]
{
e.raw_os_error() == Some(17)
}
#[cfg(not(any(unix, windows)))]
{
let _ = e;
false
}
}
pub fn refuse_symlink_destination(
path: &Path,
display: &str,
) -> Result<(), crate::exit::InvalidInputError> {
let collapsed = windows_collapse_dest_path(path);
let path = collapsed.as_path();
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.file_type().is_symlink() => Err(crate::exit::InvalidInputError {
msg: format!("refusing to write through symlink destination: {display}"),
}),
_ => Ok(()),
}
}
pub fn refuse_non_regular_destination(
path: &Path,
display: &str,
) -> Result<(), crate::exit::InvalidInputError> {
match classify_path_entry(path) {
PathEntryKind::Special => Err(crate::exit::InvalidInputError {
msg: format!("refusing to write through symlink destination: {display}"),
}),
_ => Ok(()),
}
}
pub fn ensure_parent_components_are_directories(
path: &Path,
) -> Result<(), crate::exit::InvalidInputError> {
let collapsed = windows_collapse_dest_path(path);
let path = collapsed.as_path();
let mut current = path.parent();
while let Some(p) = current {
if p.as_os_str().is_empty() {
break;
}
match classify_path_entry(p) {
PathEntryKind::RealDirectory => {
break;
}
PathEntryKind::Missing => {
current = p.parent();
}
PathEntryKind::Special if p.is_dir() => {
break;
}
_ => {
return Err(crate::exit::InvalidInputError {
msg: format!("parent path is not a directory: {}", p.display()),
});
}
}
}
Ok(())
}
pub fn ensure_not_binary_file(path: &Path, display: &str) -> Result<(), crate::exit::BinaryError> {
use std::io::Read;
if !path.exists() {
return Ok(());
}
if !is_regular_file_for_backup(path) {
return Ok(());
}
let mut file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return Ok(()),
};
let mut buf = [0u8; 8192];
let n = match file.read(&mut buf) {
Ok(n) => n,
Err(_) => return Ok(()),
};
if crate::files::is_binary(&buf[..n]) {
return Err(crate::exit::BinaryError {
msg: format!("target is a binary file: {display}"),
});
}
Ok(())
}
pub fn sole_explicit_non_text(paths: &[String], cwd: &Path) -> Option<anyhow::Error> {
if paths.len() != 1 {
return None;
}
let display = paths[0].as_str();
if display.is_empty() {
return None;
}
let path = {
let p = Path::new(display);
if p.is_absolute() {
p.to_path_buf()
} else {
cwd.join(p)
}
};
match classify_path_entry(&path) {
PathEntryKind::Missing | PathEntryKind::RealDirectory => {
return None;
}
PathEntryKind::RegularFile => {}
PathEntryKind::Special => {
if path.is_file() {
} else if path.is_dir() {
return None;
} else {
return Some(
crate::exit::InvalidInputError {
msg: format!("target is not a file: {display}"),
}
.into(),
);
}
}
}
match crate::files::load_text_strict(&path, display) {
Ok(_) => None,
Err(e) => {
if crate::exit::is_load_text_strict_fail(&e) {
Some(e)
} else {
Some(
crate::exit::InvalidInputError {
msg: crate::exit::agent_error_message(&e),
}
.into(),
)
}
}
}
}
pub fn sole_explicit_non_text_for_scan(
paths: &[String],
files_from: Option<&[String]>,
cwd: &Path,
) -> Option<anyhow::Error> {
match files_from {
Some(list) => sole_explicit_non_text(list, cwd),
None => sole_explicit_non_text(paths, cwd),
}
}
pub fn empty_scan_masked_by_unreadable(
file_paths: &[PathBuf],
cwd: &Path,
) -> Option<crate::exit::InvalidInputError> {
const SAMPLE_LIMIT: usize = 8;
let mut sample = Vec::new();
let mut count = 0usize;
for path in file_paths {
if !path.is_file() {
continue;
}
if matches!(
crate::files::try_read_text_file(path),
Err(crate::files::SoftTextSkip::Unreadable)
) {
count += 1;
if sample.len() < SAMPLE_LIMIT {
#[cfg(any(feature = "cli", feature = "files"))]
let display = crate::files::relative_display(path, cwd)
.to_string_lossy()
.into_owned();
#[cfg(not(any(feature = "cli", feature = "files")))]
let display = path
.strip_prefix(cwd)
.unwrap_or(path)
.to_string_lossy()
.into_owned();
sample.push(display);
}
}
}
if count == 0 {
return None;
}
let sample_s = sample.join(", ");
Some(crate::exit::InvalidInputError {
msg: format!(
"could not read {count} path(s) while scanning (e.g. {sample_s}); \
not reporting as no matches / clean"
),
})
}
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
pub struct PathRefused {
pub path: String,
pub reason: &'static str,
}
pub fn explicit_multi_path_non_text_refused(
paths: &[String],
cwd: &Path,
) -> Option<Vec<PathRefused>> {
if paths.len() < 2 {
return None;
}
if paths.iter().any(|p| {
let path = {
let raw = Path::new(p);
if raw.is_absolute() {
raw.to_path_buf()
} else {
cwd.join(raw)
}
};
path.is_dir()
}) {
return None;
}
let mut refused = Vec::new();
for p in paths {
let path = {
let raw = Path::new(p);
if raw.is_absolute() {
raw.to_path_buf()
} else {
cwd.join(raw)
}
};
if !path_entry_exists(&path) {
continue;
}
match crate::files::try_read_text_file(&path) {
Ok(_) => {}
Err(skip) => refused.push(PathRefused {
path: p.clone(),
reason: skip.as_reason(),
}),
}
}
if refused.is_empty() {
None
} else {
refused.sort_by(|a, b| a.path.cmp(&b.path));
Some(refused)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn append_adds_newline_separator() {
assert_eq!(append_content("existing", "new"), "existing\nnew");
}
#[test]
fn append_no_double_newline() {
assert_eq!(append_content("existing\n", "new"), "existing\nnew");
}
#[test]
fn append_empty_existing() {
assert_eq!(append_content("", "new"), "new");
}
#[test]
fn prepend_adds_newline_separator() {
assert_eq!(prepend_content("existing", "new"), "new\nexisting");
}
#[test]
fn prepend_no_double_newline() {
assert_eq!(prepend_content("existing", "new\n"), "new\nexisting");
}
#[test]
fn prepend_empty_existing() {
assert_eq!(prepend_content("", "new"), "new");
}
#[test]
fn prepend_empty_prepend() {
assert_eq!(prepend_content("existing", ""), "existing");
}
#[test]
fn append_empty_append() {
assert_eq!(append_content("existing", ""), "existing");
}
#[test]
fn append_empty_both() {
assert_eq!(append_content("", ""), "");
}
#[test]
fn prepend_empty_both() {
assert_eq!(prepend_content("", ""), "");
}
#[test]
fn prepend_keeps_utf8_bom_at_start() {
let existing = "\u{feff}body\n";
let out = prepend_content(existing, "HEAD\n");
assert_eq!(out, "\u{feff}HEAD\nbody\n");
}
#[test]
fn split_utf8_bom_peels_leading_mark() {
assert_eq!(split_utf8_bom("\u{feff}end"), ("\u{feff}", "end"));
assert_eq!(split_utf8_bom("end"), ("", "end"));
}
#[test]
fn text_lines_splits_cr_crlf_and_lf() {
assert_eq!(
text_lines("end\rnext\r").collect::<Vec<_>>(),
["end", "next"]
);
assert_eq!(
text_lines("end\r\nnext\r\n").collect::<Vec<_>>(),
["end", "next"]
);
assert_eq!(
text_lines("end\nnext\n").collect::<Vec<_>>(),
["end", "next"]
);
assert_eq!(
text_lines("# Head\rbody\r").collect::<Vec<_>>(),
["# Head", "body"]
);
}
#[test]
fn text_lines_matches_str_lines_on_lf() {
for s in ["", "a", "a\n", "a\n\n", "a\nb", "a\nb\n"] {
assert_eq!(
text_lines(s).collect::<Vec<_>>(),
s.lines().collect::<Vec<_>>(),
"{s:?}"
);
}
}
#[test]
fn text_line_index_and_column_cr_only() {
let s = "end\rnext\r";
assert_eq!(text_line_index(s, 0), 0);
assert_eq!(text_line_index(s, 4), 1);
assert_eq!(text_line_column(s, 0), (1, 1));
assert_eq!(text_line_column(s, 4), (2, 1));
assert_eq!(text_line_column("end\r\nnext", 5), (2, 1));
assert_eq!(text_line_column("end\nnext", 4), (2, 1));
}
#[test]
fn text_lines_with_endings_keeps_cr() {
let parts: Vec<_> = text_lines_with_endings("end\rnext\r").collect();
assert_eq!(parts, vec![("end", "\r"), ("next", "\r")]);
let crlf: Vec<_> = text_lines_with_endings("end\r\nnext\r\n").collect();
assert_eq!(crlf, vec![("end", "\r\n"), ("next", "\r\n")]);
}
#[test]
fn prepend_symmetry_with_append() {
let a = append_content("base", "added");
assert!(a.contains('\n'));
let p = prepend_content("base", "added");
assert!(p.contains('\n'));
}
#[test]
fn real_directory_detected_not_file() {
let dir = TempDir::new().unwrap();
assert!(is_real_directory(dir.path()));
assert_eq!(
classify_path_entry(dir.path()),
PathEntryKind::RealDirectory
);
let f = dir.path().join("f.txt");
fs::write(&f, "x").unwrap();
assert!(!is_real_directory(&f));
assert!(is_regular_file_for_backup(&f));
assert_eq!(classify_path_entry(&f), PathEntryKind::RegularFile);
assert_eq!(
classify_path_entry(&dir.path().join("missing")),
PathEntryKind::Missing
);
}
#[cfg(unix)]
#[test]
fn symlink_is_unlinkable_not_real_directory() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("t");
let link = dir.path().join("l");
fs::write(&target, "x").unwrap();
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(!is_real_directory(&link));
assert!(!is_regular_file_for_backup(&link));
assert_eq!(classify_path_entry(&link), PathEntryKind::Special);
ensure_unlinkable_not_directory(&link, "l").unwrap();
}
#[test]
fn rename_or_copy_moves_regular_file() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("a.txt");
let dst = dir.path().join("b.txt");
fs::write(&src, "payload\n").unwrap();
rename_or_copy(&src, &dst).unwrap();
assert!(!src.exists());
assert_eq!(fs::read_to_string(&dst).unwrap(), "payload\n");
}
#[cfg(unix)]
#[test]
fn rename_or_copy_refuses_dest_symlink() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("src.txt");
let dest = dir.path().join("dest.txt");
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("secret");
fs::write(&src, "payload\n").unwrap();
fs::write(&outside_file, "do not overwrite").unwrap();
std::os::unix::fs::symlink(&outside_file, &dest).unwrap();
let err = rename_or_copy(&src, &dest).unwrap_err();
assert!(
crate::exit::is_invalid_input(&err),
"dest symlink must be invalid_input, got: {err:#}"
);
assert_eq!(
fs::read_to_string(&outside_file).unwrap(),
"do not overwrite"
);
assert!(src.exists(), "source must remain after dest-symlink refuse");
assert!(
dest.symlink_metadata().unwrap().file_type().is_symlink(),
"dest must remain a symlink"
);
}
#[cfg(windows)]
#[test]
fn dest_file_symlink_is_special_and_rename_refuses() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("src.txt");
let dest = dir.path().join("dest.txt");
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("secret");
fs::write(&src, "payload\n").unwrap();
fs::write(&outside_file, "do not overwrite").unwrap();
if let Err(e) = std::os::windows::fs::symlink_file(&outside_file, &dest) {
eprintln!("skip dest file symlink test: {e}");
return;
}
assert_eq!(
classify_path_entry(&dest),
PathEntryKind::Special,
"Windows dest file symlink must not classify as RegularFile"
);
assert!(!is_regular_file_for_backup(&dest));
let err = refuse_non_regular_destination(&dest, "dest.txt").unwrap_err();
assert!(
err.msg.contains("symlink destination"),
"unexpected refuse message: {}",
err.msg
);
let err = rename_or_copy(&src, &dest).unwrap_err();
assert!(
crate::exit::is_invalid_input(&err),
"dest file symlink must be invalid_input, got: {err:#}"
);
assert_eq!(
fs::read_to_string(&outside_file).unwrap(),
"do not overwrite"
);
assert!(src.exists(), "source must remain after dest-symlink refuse");
assert!(
dest.symlink_metadata().unwrap().file_type().is_symlink(),
"dest must remain a symlink"
);
}
#[cfg(windows)]
fn try_create_junction(target: &std::path::Path, link: &std::path::Path) -> bool {
let status = std::process::Command::new("cmd")
.args([
"/C",
"mklink",
"/J",
&link.to_string_lossy(),
&target.to_string_lossy(),
])
.status();
matches!(status, Ok(s) if s.success()) && link.exists()
}
#[cfg(windows)]
fn try_create_junction_literal(target: &std::path::Path, link: &std::path::Path) -> bool {
let status = std::process::Command::new("powershell")
.env("PATCHLOOM_LINK", link)
.env("PATCHLOOM_TARGET", target)
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
"$ErrorActionPreference='Stop'; New-Item -ItemType Junction -Path $env:PATCHLOOM_LINK -Value $env:PATCHLOOM_TARGET",
])
.status();
matches!(status, Ok(s) if s.success()) && link.exists()
}
#[cfg(windows)]
#[test]
fn directory_junction_is_special_not_real_directory() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("real");
fs::create_dir(&target).unwrap();
fs::write(target.join("keep.txt"), "keep\n").unwrap();
let link = dir.path().join("alias");
if !try_create_junction(&target, &link) {
eprintln!("skip junction classify: mklink /J failed");
return;
}
assert_eq!(
classify_path_entry(&link),
PathEntryKind::Special,
"junction must not classify as RealDirectory"
);
assert!(!is_real_directory(&link));
assert!(!is_regular_file_for_backup(&link));
ensure_unlinkable_not_directory(&link, "alias").unwrap();
}
#[cfg(windows)]
#[test]
fn windows_ads_path_detects_stream_not_drive() {
assert!(is_windows_ads_path(std::path::Path::new(
"notes.txt:secret"
)));
assert!(is_windows_ads_path(std::path::Path::new(
r"src\lib.rs:stream"
)));
assert!(!is_windows_ads_path(std::path::Path::new(
r"C:\Users\name\file.txt"
)));
assert!(
!is_windows_ads_path(std::path::Path::new(r"\\.\C:\Users\name\file.txt")),
r"\\.\C:\file is a drive dest, not ADS host '.'"
);
assert!(!is_windows_ads_path(std::path::Path::new("notes.txt")));
assert!(ensure_not_windows_ads_path(std::path::Path::new("a.txt:s"), "a.txt:s").is_err());
assert!(
!is_windows_ads_path(std::path::Path::new(r"\\[::1]\C$\Users\name\file.txt")),
"IPv6 loopback UNC host colons are not ADS"
);
assert!(
!is_windows_ads_path(std::path::Path::new(r"\\::1\C$\Users\name\file.txt")),
"bare IPv6 loopback UNC host colons are not ADS"
);
assert!(
!is_windows_ads_path(std::path::Path::new(
r"\\?\UNC\[::1]\C$\Users\name\file.txt"
)),
"verbatim IPv6 UNC is not ADS"
);
assert!(
is_windows_ads_path(std::path::Path::new(
r"\\[::1]\C$\Users\name\file.txt:secret"
)),
"stream after an IPv6 UNC dest is still ADS"
);
assert!(
is_windows_ads_path(std::path::Path::new(
r"\\localhost\C$\Users\name\file.txt:s"
)),
"stream after a named UNC dest is still ADS"
);
}
#[cfg(windows)]
#[test]
fn windows_illegal_dest_detects_angle_and_device() {
assert!(is_windows_illegal_dest_path(std::path::Path::new(
"bad<name.txt"
)));
assert!(is_windows_illegal_dest_path(std::path::Path::new(
r"dir\foo|bar.txt"
)));
assert!(is_windows_illegal_dest_path(std::path::Path::new(
r"\\.\NUL"
)));
assert!(is_windows_illegal_dest_path(std::path::Path::new(
"//./NUL"
)));
assert!(!is_windows_illegal_dest_path(std::path::Path::new(
r"C:\Users\name\file.txt"
)));
assert!(!is_windows_illegal_dest_path(std::path::Path::new(
"notes.txt"
)));
assert!(
is_windows_illegal_dest_path(std::path::Path::new("file.txt ")),
"Win32 strips a trailing space so dest collapses to file.txt"
);
assert!(
is_windows_illegal_dest_path(std::path::Path::new("file.txt.")),
"Win32 strips a trailing dot so dest collapses to file.txt"
);
assert!(
is_windows_illegal_dest_path(std::path::Path::new(r"dir\file.txt...")),
"repeated trailing dots also collapse"
);
assert!(
!is_windows_illegal_dest_path(std::path::Path::new(".gitignore")),
"leading dot is a real name"
);
assert!(
!is_windows_illegal_dest_path(std::path::Path::new("my file.txt")),
"interior space is a real name"
);
assert!(
!is_windows_illegal_dest_path(std::path::Path::new(r"\\?\C:\Users\name\file.txt")),
"extended prefix is not illegal"
);
assert!(
!is_windows_illegal_dest_path(std::path::Path::new(r"\\.\C:\Users\name\file.txt")),
r"\\.\C:\file is a drive dest, not NUL/pipe"
);
assert!(
is_windows_illegal_dest_path(std::path::Path::new(r"\\.\pipe\pl-test")),
r"\\.\pipe stays illegal"
);
assert!(
is_windows_illegal_dest_path(std::path::Path::new(r"\\.\CON")),
r"\\.\CON is the console device, not a file"
);
assert!(
is_windows_illegal_dest_path(std::path::Path::new("NUL")),
"bare NUL is the null device"
);
assert!(
is_windows_illegal_dest_path(std::path::Path::new("nul")),
"NUL match is case-insensitive"
);
assert!(
is_windows_illegal_dest_path(std::path::Path::new(r"nested\NUL")),
"NUL as a path component is still the device"
);
assert!(
!is_windows_illegal_dest_path(std::path::Path::new("NUL.txt")),
"NUL.txt is a real file on Win11"
);
assert!(
!is_windows_illegal_dest_path(std::path::Path::new("CON")),
"Win11 can persist a real CON file"
);
assert!(ensure_not_windows_illegal_dest(std::path::Path::new("a<b"), "a<b").is_err());
assert!(ensure_not_windows_illegal_dest(std::path::Path::new("NUL"), "NUL").is_err());
assert!(
is_windows_illegal_dest_path(std::path::Path::new("C:foo.txt")),
"drive-relative dest ignores --cwd"
);
assert!(
is_windows_illegal_dest_path(std::path::Path::new(r"\foo.txt")),
"root-relative dest writes the drive root"
);
assert!(
!is_windows_illegal_dest_path(std::path::Path::new(r"C:\Users\name\file.txt")),
"drive+root remains a normal dest"
);
let err = ensure_not_windows_illegal_dest(std::path::Path::new("C:foo.txt"), "C:foo.txt")
.expect_err("drive-relative");
assert!(
err.msg.contains("ignores --cwd"),
"drive-relative message: {}",
err.msg
);
}
#[test]
fn windows_path_drive_or_root_relative_table() {
assert!(windows_path_is_drive_or_root_relative("C:foo.txt"));
assert!(windows_path_is_drive_or_root_relative("C:"));
assert!(windows_path_is_drive_or_root_relative(r"d:..\out.txt"));
assert!(windows_path_is_drive_or_root_relative(r"\foo.txt"));
assert!(windows_path_is_drive_or_root_relative("/foo.txt"));
assert!(
windows_path_is_drive_or_root_relative("/tmp/nonexistent.txt"),
"Unix /tmp dests are root-relative on Windows"
);
assert!(windows_path_is_drive_or_root_relative(r"\\?\C:foo.txt"));
assert!(!windows_path_is_drive_or_root_relative(
r"C:\Users\name\file.txt"
));
assert!(!windows_path_is_drive_or_root_relative(
"C:/Users/name/file.txt"
));
assert!(!windows_path_is_drive_or_root_relative(
r"\\?\C:\Users\name\file.txt"
));
assert!(!windows_path_is_drive_or_root_relative(
"//?/C:/Users/name/file.txt"
));
assert!(!windows_path_is_drive_or_root_relative(
r"\\localhost\C$\Users\name\file.txt"
));
assert!(!windows_path_is_drive_or_root_relative("notes.txt"));
assert!(!windows_path_is_drive_or_root_relative(r"sub\file.txt"));
}
#[test]
fn windows_collapse_trailing_separators_table() {
assert_eq!(
windows_collapse_trailing_separators(r"keep.txt\\"),
"keep.txt"
);
assert_eq!(
windows_collapse_trailing_separators("keep.txt/"),
"keep.txt"
);
assert_eq!(
windows_collapse_trailing_separators(r"keep.txt\\\\"),
"keep.txt"
);
assert_eq!(windows_collapse_trailing_separators(r"C:\"), r"C:\");
assert_eq!(windows_collapse_trailing_separators("C:/"), "C:/");
assert_eq!(windows_collapse_trailing_separators(r"\\?\C:\"), r"\\?\C:\");
assert_eq!(windows_collapse_trailing_separators("//?/C:/"), "//?/C:/");
assert_eq!(windows_collapse_trailing_separators(r"\\.\C:\"), r"\\.\C:\");
assert_eq!(
windows_collapse_trailing_separators(r"\\server\share\"),
r"\\server\share"
);
assert_eq!(
windows_collapse_trailing_separators(r"\\?\C:\Users\"),
r"\\?\C:\Users"
);
assert_eq!(
windows_collapse_trailing_separators(r"C:\Users\"),
r"C:\Users"
);
assert_eq!(
windows_collapse_trailing_separators("notes.txt"),
"notes.txt"
);
assert_eq!(windows_collapse_trailing_separators(r"\"), r"\");
assert_eq!(windows_collapse_trailing_separators("/"), "/");
}
#[cfg(windows)]
#[test]
fn path_entry_exists_collapses_trailing_separators() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("keep.txt");
fs::write(&file, "KEEP\n").unwrap();
let slashed = PathBuf::from(format!("{}\\", file.display()));
assert!(
path_entry_exists(&slashed),
"Win32 dest keep.txt\\ must exist as keep.txt"
);
assert_eq!(classify_path_entry(&slashed), PathEntryKind::RegularFile);
}
#[cfg(windows)]
#[test]
fn unlink_path_entry_removes_junction_not_target() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("real");
fs::create_dir(&target).unwrap();
fs::write(target.join("keep.txt"), "keep\n").unwrap();
let link = dir.path().join("alias");
if !try_create_junction(&target, &link) {
eprintln!("skip junction unlink: mklink /J failed");
return;
}
unlink_path_entry(&link).expect("unlink junction");
assert!(!path_entry_exists(&link), "junction entry must be gone");
assert!(target.is_dir(), "target dir must remain");
assert_eq!(
fs::read_to_string(target.join("keep.txt")).unwrap(),
"keep\n"
);
}
#[cfg(windows)]
#[test]
fn unlink_path_entry_removes_junction_with_ampersand_name() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("real");
fs::create_dir(&target).unwrap();
fs::write(target.join("keep.txt"), "keep\n").unwrap();
let link = dir.path().join("alias&echo injected");
if !try_create_junction_literal(&target, &link) {
eprintln!("skip junction unlink ampersand: create failed");
return;
}
unlink_path_entry(&link).expect("unlink junction with &");
assert!(!path_entry_exists(&link), "junction entry must be gone");
assert!(target.is_dir(), "target dir must remain");
assert_eq!(
fs::read_to_string(target.join("keep.txt")).unwrap(),
"keep\n"
);
}
#[cfg(windows)]
#[test]
fn unlink_path_entry_removes_file_symlink_not_target() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("real.txt");
fs::write(&target, "keep\n").unwrap();
let link = dir.path().join("alias.txt");
if let Err(e) = std::os::windows::fs::symlink_file(&target, &link) {
eprintln!("skip file symlink unlink: {e}");
return;
}
unlink_path_entry(&link).expect("unlink file symlink");
assert!(!path_entry_exists(&link), "symlink entry must be gone");
assert_eq!(fs::read_to_string(&target).unwrap(), "keep\n");
}
#[test]
fn append_crlf_file_without_final_newline_uses_crlf_separator() {
let out = append_content("line1\r\nline2", "line3");
assert_eq!(out, "line1\r\nline2\r\nline3");
}
#[test]
fn append_crlf_file_with_final_newline_no_extra_separator() {
let out = append_content("line1\r\nline2\r\n", "line3");
assert_eq!(out, "line1\r\nline2\r\nline3");
}
#[test]
fn prepend_crlf_payload_uses_file_eol_when_payload_lacks_eol() {
let out = prepend_content("body\r\n", "head");
assert_eq!(out, "head\r\nbody\r\n");
}
#[test]
fn ensure_parents_ok_when_missing() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("a").join("b").join("c.txt");
ensure_parent_components_are_directories(&path).unwrap();
}
#[test]
fn ensure_parents_ok_when_dirs_exist() {
let dir = TempDir::new().unwrap();
let nested = dir.path().join("a").join("b");
fs::create_dir_all(&nested).unwrap();
let path = nested.join("c.txt");
ensure_parent_components_are_directories(&path).unwrap();
}
#[cfg(windows)]
#[test]
fn ensure_parents_collapses_trailing_separators_on_existing_file() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("keep.txt");
fs::write(&file, "KEEP\n").unwrap();
let slashed = PathBuf::from(format!("{}\\", file.display()));
ensure_parent_components_are_directories(&slashed)
.expect("keep.txt\\ parent is the dir, not keep.txt");
}
#[test]
fn ensure_parents_rejects_file_as_parent() {
let dir = TempDir::new().unwrap();
let blocking = dir.path().join("notdir");
fs::write(&blocking, "file\n").unwrap();
let path = blocking.join("child.txt");
let err = ensure_parent_components_are_directories(&path).unwrap_err();
assert!(
err.msg.contains("not a directory"),
"unexpected message: {}",
err.msg
);
assert!(err.msg.contains("notdir"), "message should name the path");
}
#[test]
fn ensure_parents_rejects_file_as_intermediate() {
let dir = TempDir::new().unwrap();
let blocking = dir.path().join("a");
fs::write(&blocking, "file\n").unwrap();
let path = blocking.join("b").join("c.txt");
let err = ensure_parent_components_are_directories(&path).unwrap_err();
assert!(err.msg.contains("not a directory"), "got: {}", err.msg);
}
#[cfg(unix)]
#[test]
fn ensure_parents_rejects_dangling_symlink_parent() {
let dir = TempDir::new().unwrap();
let broken = dir.path().join("broken");
std::os::unix::fs::symlink(dir.path().join("missing"), &broken).unwrap();
let path = broken.join("new.txt");
let err = ensure_parent_components_are_directories(&path).unwrap_err();
assert!(
err.msg.contains("not a directory"),
"dangling parent must refuse: {}",
err.msg
);
}
#[cfg(unix)]
#[test]
fn ensure_parents_ok_when_parent_is_symlink_to_dir() {
let dir = TempDir::new().unwrap();
let real = dir.path().join("real");
fs::create_dir(&real).unwrap();
let link = dir.path().join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let path = link.join("new.txt");
ensure_parent_components_are_directories(&path).unwrap();
}
#[cfg(unix)]
#[test]
fn ensure_parents_rejects_symlink_to_file_parent() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("f.txt");
fs::write(&file, "x\n").unwrap();
let link = dir.path().join("link");
std::os::unix::fs::symlink(&file, &link).unwrap();
let path = link.join("new.txt");
let err = ensure_parent_components_are_directories(&path).unwrap_err();
assert!(
err.msg.contains("not a directory"),
"symlink-to-file parent must refuse: {}",
err.msg
);
}
#[test]
fn ensure_not_binary_ok_for_text() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("t.txt");
fs::write(&path, "hello\n").unwrap();
ensure_not_binary_file(&path, "t.txt").unwrap();
}
#[test]
fn ensure_not_binary_rejects_nul() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("b.bin");
fs::write(&path, b"hello\x00world").unwrap();
let err = ensure_not_binary_file(&path, "b.bin").unwrap_err();
assert!(err.msg.contains("binary file"), "got: {}", err.msg);
assert!(err.msg.contains("b.bin"));
let ae: anyhow::Error = err.into();
assert_eq!(
crate::fallback::edit_error_kind(&ae),
Some(crate::fallback::EditErrorKind::Binary)
);
}
#[test]
fn ensure_not_binary_missing_path_ok() {
let dir = TempDir::new().unwrap();
ensure_not_binary_file(&dir.path().join("nope"), "nope").unwrap();
}
#[cfg(unix)]
#[test]
fn ensure_not_binary_fifo_no_hang() {
use std::process::Command as StdCommand;
use std::time::{Duration, Instant};
let dir = TempDir::new().unwrap();
let fifo = dir.path().join("p.fifo");
assert!(
StdCommand::new("mkfifo")
.arg(&fifo)
.status()
.unwrap()
.success()
);
let start = Instant::now();
ensure_not_binary_file(&fifo, "p.fifo").unwrap();
assert!(
start.elapsed() < Duration::from_secs(2),
"ensure_not_binary_file on FIFO took {:?}",
start.elapsed()
);
}
#[test]
fn sole_explicit_non_text_detects_sole_binary() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("b.bin");
fs::write(&path, b"x\x00y").unwrap();
let err = sole_explicit_non_text(&["b.bin".into()], dir.path()).unwrap();
assert!(err.to_string().contains("binary"));
assert_eq!(
crate::fallback::error_kind_str(&err),
Some("binary"),
"sole binary must peel to error_kind binary (#1963)"
);
}
#[test]
fn sole_explicit_non_text_none_for_text_or_multi() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("t.txt"), "hi\n").unwrap();
fs::write(dir.path().join("b.bin"), b"x\x00y").unwrap();
assert!(sole_explicit_non_text(&["t.txt".into()], dir.path()).is_none());
assert!(sole_explicit_non_text(&["t.txt".into(), "b.bin".into()], dir.path()).is_none());
assert!(sole_explicit_non_text(&[".".into()], dir.path()).is_none());
}
#[cfg(unix)]
#[test]
fn sole_explicit_non_text_rejects_dangling_symlink() {
let dir = TempDir::new().unwrap();
let link = dir.path().join("dangling.txt");
std::os::unix::fs::symlink(dir.path().join("missing-target"), &link).unwrap();
let err = sole_explicit_non_text(&["dangling.txt".into()], dir.path()).unwrap();
assert!(
crate::exit::is_invalid_input(&err),
"dangling sole path must be invalid_input not not_found: {err}"
);
assert!(err.to_string().contains("not a file"), "got: {err}");
assert_eq!(crate::fallback::error_kind_str(&err), Some("invalid_input"));
}
#[cfg(unix)]
#[test]
fn sole_explicit_non_text_allows_symlink_to_text_file() {
let dir = TempDir::new().unwrap();
let real = dir.path().join("real.txt");
fs::write(&real, "hi\n").unwrap();
let link = dir.path().join("link.txt");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert!(
sole_explicit_non_text(&["link.txt".into()], dir.path()).is_none(),
"symlink-to-file must still load as sole text path"
);
}
#[cfg(unix)]
#[test]
fn sole_explicit_non_text_allows_symlink_to_directory() {
let dir = TempDir::new().unwrap();
let nested = dir.path().join("nested");
fs::create_dir(&nested).unwrap();
fs::write(nested.join("a.txt"), "x\n").unwrap();
let link = dir.path().join("link_dir");
std::os::unix::fs::symlink(&nested, &link).unwrap();
assert!(
sole_explicit_non_text(&["link_dir".into()], dir.path()).is_none(),
"symlink-to-dir must allow multi-file scan"
);
}
#[test]
fn sole_explicit_non_text_rejects_invalid_utf8() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("bad.txt");
fs::write(&path, b"hello \xff world\n").unwrap();
let err = sole_explicit_non_text(&["bad.txt".into()], dir.path()).unwrap();
let msg = err.to_string();
assert!(msg.contains("UTF-8") || msg.contains("utf"), "got: {msg}");
assert!(msg.contains("bad.txt"), "got: {msg}");
assert_eq!(
crate::fallback::error_kind_str(&err),
Some("invalid_encoding"),
"sole invalid UTF-8 must peel to invalid_encoding (#1963)"
);
}
#[test]
fn sole_explicit_non_text_for_scan_uses_files_from_list() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("b.bin"), b"x\x00y").unwrap();
let list = vec!["b.bin".into()];
assert!(sole_explicit_non_text(&[], dir.path()).is_none());
let err = sole_explicit_non_text_for_scan(&[], Some(&list), dir.path()).unwrap();
assert!(err.to_string().contains("binary"), "got: {err}");
assert_eq!(crate::fallback::error_kind_str(&err), Some("binary"));
}
#[test]
fn empty_scan_masked_by_unreadable_samples() {
let dir = TempDir::new().unwrap();
let locked = dir.path().join("locked.txt");
fs::write(&locked, "x\n").unwrap();
fs::write(dir.path().join("ok.txt"), "y\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
if fs::read_to_string(&locked).is_ok() {
fs::set_permissions(&locked, fs::Permissions::from_mode(0o644)).unwrap();
return;
}
let paths = vec![locked.clone(), dir.path().join("ok.txt")];
let only_locked = vec![locked.clone()];
let err = empty_scan_masked_by_unreadable(&only_locked, dir.path()).unwrap();
assert!(err.msg.contains("could not read"), "got: {}", err.msg);
assert!(err.msg.contains("locked.txt"), "got: {}", err.msg);
let err2 = empty_scan_masked_by_unreadable(&paths, dir.path()).unwrap();
assert!(err2.msg.contains("could not read"), "got: {}", err2.msg);
assert!(
empty_scan_masked_by_unreadable(&[dir.path().join("ok.txt")], dir.path()).is_none()
);
fs::set_permissions(&locked, fs::Permissions::from_mode(0o644)).unwrap();
}
}
#[test]
fn sole_explicit_non_text_rejects_unreadable() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("locked.txt");
fs::write(&path, "hello\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).unwrap();
if fs::read_to_string(&path).is_ok() {
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
return;
}
let err = sole_explicit_non_text(&["locked.txt".into()], dir.path()).unwrap();
let msg = err.to_string();
assert!(msg.contains("failed to read"), "got: {msg}");
assert!(
msg.contains("Permission denied")
|| msg.contains("PermissionDenied")
|| msg.contains("os error"),
"OS detail missing: {msg}"
);
let failed_count = msg.matches("failed to read").count();
assert_eq!(
failed_count, 1,
"unreadable error must not double-wrap load_text_strict context: {msg}"
);
assert!(
msg.contains("locked.txt"),
"path should appear once in message: {msg}"
);
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
}
}
#[test]
fn multi_path_non_text_refused_lists_binary_and_utf8() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("t.txt"), "hi\n").unwrap();
fs::write(dir.path().join("b.bin"), b"x\x00y").unwrap();
fs::write(dir.path().join("bad.txt"), b"hi \xff\n").unwrap();
let refused = explicit_multi_path_non_text_refused(
&["t.txt".into(), "b.bin".into(), "bad.txt".into()],
dir.path(),
)
.expect("non-text co-paths");
assert_eq!(refused.len(), 2);
let reasons: Vec<_> = refused.iter().map(|r| r.reason).collect();
assert!(reasons.contains(&"binary"), "{refused:?}");
assert!(reasons.contains(&"invalid_utf8"), "{refused:?}");
}
#[test]
fn multi_path_non_text_refused_none_for_dir_walk_or_sole() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("b.bin"), b"x\x00y").unwrap();
assert!(explicit_multi_path_non_text_refused(&["b.bin".into()], dir.path()).is_none());
assert!(explicit_multi_path_non_text_refused(&[".".into()], dir.path()).is_none());
}
#[cfg(unix)]
#[test]
fn multi_path_non_text_refused_lists_fifo_not_regular_file() {
use std::process::Command as StdCommand;
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("t.txt"), "hi\n").unwrap();
let fifo = dir.path().join("p.fifo");
StdCommand::new("mkfifo")
.arg(&fifo)
.status()
.expect("mkfifo");
let refused =
explicit_multi_path_non_text_refused(&["t.txt".into(), "p.fifo".into()], dir.path())
.expect("FIFO co-path must be refused");
assert_eq!(refused.len(), 1, "{refused:?}");
assert_eq!(refused[0].path, "p.fifo");
assert_eq!(refused[0].reason, "not_regular_file");
}
}