use fs_err as fs;
use memmap2::Mmap;
use once_cell::sync::Lazy;
use rattler_conda_types::package::{FileMode, PathType, PathsEntry, PrefixPlaceholder};
use rattler_conda_types::Platform;
use rattler_digest::Sha256;
use rattler_digest::{HashingWriter, Sha256Hash};
use reflink_copy::reflink;
use regex::Regex;
use std::borrow::Cow;
use std::fmt;
use std::fmt::Formatter;
use std::fs::Permissions;
use std::io::{BufWriter, ErrorKind, Read, Seek, Write};
use std::path::{Path, PathBuf};
use super::apple_codesign::{codesign, AppleCodeSignBehavior};
use super::{ExternalSymlinkPolicy, Prefix};
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub enum LinkMethod {
Reflink,
Hardlink,
Softlink,
Copy,
Patched(FileMode),
}
impl fmt::Display for LinkMethod {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
LinkMethod::Hardlink => write!(f, "hardlink"),
LinkMethod::Softlink => write!(f, "softlink"),
LinkMethod::Reflink => write!(f, "reflink"),
LinkMethod::Copy => write!(f, "copy"),
LinkMethod::Patched(FileMode::Binary) => write!(f, "binary patched"),
LinkMethod::Patched(FileMode::Text) => write!(f, "text patched"),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum LinkFileError {
#[error("unexpected io operation while {0}")]
IoError(String, #[source] std::io::Error),
#[error("could not open source file for reading")]
FailedToOpenSourceFile(#[source] std::io::Error),
#[error("failed to read the source file")]
FailedToReadSourceFile(#[source] std::io::Error),
#[error("could not open source file")]
FailedToReadSymlink(#[source] std::io::Error),
#[error("failed to {0} file to destination")]
FailedToLink(LinkMethod, #[source] std::io::Error),
#[error("could not source file metadata")]
FailedToReadSourceFileMetadata(#[source] std::io::Error),
#[error("could not open destination file for writing")]
FailedToOpenDestinationFile(#[source] std::io::Error),
#[error("could not update destination file permissions")]
FailedToUpdateDestinationFilePermissions(#[source] std::io::Error),
#[error("could not update file modification and access time")]
FailedToUpdateDestinationFileTimestamps(#[source] std::io::Error),
#[error("failed to sign Apple binary")]
FailedToSignAppleBinary,
#[error("symlink target escapes the target prefix")]
SymlinkTargetEscapesPrefix,
#[error("cannot install noarch python files because there is no python version specified ")]
MissingPythonInfo,
#[error("failed to compute the sha256 hash of the file")]
FailedToComputeSha(#[source] std::io::Error),
}
#[derive(Debug)]
pub struct LinkedFile {
pub clobbered: bool,
pub sha256: rattler_digest::Sha256Hash,
pub file_size: u64,
pub relative_path: PathBuf,
pub method: LinkMethod,
pub prefix_placeholder: Option<String>,
}
#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
pub fn link_file(
path_json_entry: &PathsEntry,
destination_relative_path: PathBuf,
package_dir: &Path,
target_dir: &Prefix,
target_prefix: &str,
allow_symbolic_links: bool,
allow_hard_links: bool,
allow_ref_links: bool,
target_platform: Platform,
apple_codesign_behavior: AppleCodeSignBehavior,
modification_time: filetime::FileTime,
external_symlink_policy: ExternalSymlinkPolicy,
) -> Result<LinkedFile, LinkFileError> {
let source_path = package_dir.join(&path_json_entry.relative_path);
let destination_path = target_dir.path().join(&destination_relative_path);
let mut sha256 = None;
let mut file_size = path_json_entry.size_in_bytes;
let link_method = if let Some(PrefixPlaceholder {
file_mode,
placeholder,
}) = path_json_entry.prefix_placeholder.as_ref()
{
let source = map_or_read_source_file(&source_path)?;
let file_type = FileType::detect(source.as_ref());
let destination = BufWriter::with_capacity(
50 * 1024,
fs::File::create(&destination_path)
.map_err(LinkFileError::FailedToOpenDestinationFile)?,
);
let mut destination_writer = HashingWriter::<_, rattler_digest::Sha256>::new(destination);
let target_prefix = if target_platform.is_windows() {
Cow::Owned(target_prefix.replace('\\', "/"))
} else {
Cow::Borrowed(target_prefix)
};
copy_and_replace_placeholders(
source.as_ref(),
&mut destination_writer,
placeholder,
&target_prefix,
&target_platform,
*file_mode,
)
.map_err(|err| LinkFileError::IoError(String::from("replacing placeholders"), err))?;
let (mut file, current_hash) = destination_writer.finalize();
sha256 = Some(current_hash);
file_size = file.stream_position().ok();
drop(file);
let metadata = fs::symlink_metadata(&source_path)
.map_err(LinkFileError::FailedToReadSourceFileMetadata)?;
if (has_executable_permissions(&metadata.permissions())
|| file_type == Some(FileType::MachO))
&& target_platform.is_osx()
&& *file_mode == FileMode::Binary
{
let mut content_changed = false;
if let Some(original_hash) = &path_json_entry.sha256 {
content_changed = original_hash != ¤t_hash;
}
if content_changed && apple_codesign_behavior != AppleCodeSignBehavior::DoNothing {
match codesign(&destination_path) {
Ok(_) => {}
Err(e) => {
if apple_codesign_behavior == AppleCodeSignBehavior::Fail {
return Err(e);
}
}
}
sha256 = Some(
rattler_digest::compute_file_digest::<Sha256>(&destination_path)
.map_err(LinkFileError::FailedToComputeSha)?,
);
file_size = Some(
fs::symlink_metadata(&destination_path)
.map_err(LinkFileError::FailedToOpenDestinationFile)?
.len(),
);
}
}
fs::set_permissions(&destination_path, metadata.permissions())
.map_err(LinkFileError::FailedToUpdateDestinationFilePermissions)?;
filetime::set_file_times(&destination_path, modification_time, modification_time)
.map_err(LinkFileError::FailedToUpdateDestinationFileTimestamps)?;
LinkMethod::Patched(*file_mode)
} else if path_json_entry.path_type == PathType::HardLink && allow_ref_links {
reflink_to_destination(&source_path, &destination_path, allow_hard_links)?
} else if path_json_entry.path_type == PathType::HardLink && allow_hard_links {
hardlink_to_destination(&source_path, &destination_path)?
} else if path_json_entry.path_type == PathType::SoftLink && allow_symbolic_links {
symlink_to_destination(
&source_path,
&destination_path,
target_dir.path(),
external_symlink_policy,
)?
} else {
copy_to_destination(&source_path, &destination_path)?
};
let sha256 = if let Some(sha256) = sha256 {
sha256
} else if link_method == LinkMethod::Softlink {
let linked_path = destination_path
.read_link()
.map_err(LinkFileError::FailedToReadSymlink)?;
rattler_digest::compute_bytes_digest::<Sha256>(
linked_path.as_os_str().to_string_lossy().as_bytes(),
)
} else if let Some(sha256) = path_json_entry.sha256 {
sha256
} else if path_json_entry.path_type == PathType::HardLink {
rattler_digest::compute_file_digest::<Sha256>(&destination_path)
.map_err(LinkFileError::FailedToComputeSha)?
} else {
Sha256Hash::default()
};
let file_size = if let Some(file_size) = file_size {
file_size
} else if let Some(size_in_bytes) = path_json_entry.size_in_bytes {
size_in_bytes
} else {
let metadata = fs::symlink_metadata(&destination_path)
.map_err(LinkFileError::FailedToOpenDestinationFile)?;
metadata.len()
};
let prefix_placeholder: Option<String> = path_json_entry
.prefix_placeholder
.as_ref()
.map(|p| p.placeholder.clone());
Ok(LinkedFile {
clobbered: false,
sha256,
file_size,
relative_path: destination_relative_path,
method: link_method,
prefix_placeholder,
})
}
enum MmapOrBytes {
Mmap(Mmap),
Bytes(Vec<u8>),
}
impl AsRef<[u8]> for MmapOrBytes {
fn as_ref(&self) -> &[u8] {
match &self {
MmapOrBytes::Mmap(mmap) => mmap.as_ref(),
MmapOrBytes::Bytes(bytes) => bytes.as_slice(),
}
}
}
#[allow(clippy::verbose_file_reads)]
fn map_or_read_source_file(source_path: &Path) -> Result<MmapOrBytes, LinkFileError> {
let mut file = fs::File::open(source_path).map_err(LinkFileError::FailedToOpenSourceFile)?;
let mmap = unsafe { Mmap::map(&file) };
Ok(match mmap {
Ok(memory) => MmapOrBytes::Mmap(memory),
Err(err) => {
tracing::warn!(
"failed to memory map {}: {err}. Reading the file to memory instead.",
source_path.display()
);
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.map_err(LinkFileError::FailedToReadSourceFile)?;
MmapOrBytes::Bytes(bytes)
}
})
}
fn reflink_to_destination(
source_path: &Path,
destination_path: &Path,
allow_hard_links: bool,
) -> Result<LinkMethod, LinkFileError> {
loop {
match reflink(source_path, destination_path) {
Ok(_) => {
#[cfg(not(target_os = "macos"))]
{
let metadata = fs::symlink_metadata(source_path)
.map_err(LinkFileError::FailedToReadSourceFileMetadata)?;
fs::set_permissions(destination_path, metadata.permissions())
.map_err(LinkFileError::FailedToUpdateDestinationFilePermissions)?;
let file_time = filetime::FileTime::from_last_modification_time(&metadata);
filetime::set_file_times(destination_path, file_time, file_time)
.map_err(LinkFileError::FailedToUpdateDestinationFileTimestamps)?;
}
return Ok(LinkMethod::Reflink);
}
Err(e) if e.kind() == ErrorKind::AlreadyExists => {
fs::remove_file(destination_path).map_err(|err| {
LinkFileError::IoError(String::from("removing clobbered file"), err)
})?;
}
Err(e) if e.kind() == ErrorKind::Unsupported && allow_hard_links => {
return hardlink_to_destination(source_path, destination_path);
}
Err(e) if e.kind() == ErrorKind::Unsupported && !allow_hard_links => {
return copy_to_destination(source_path, destination_path);
}
Err(_) => {
return if allow_hard_links {
hardlink_to_destination(source_path, destination_path)
} else {
copy_to_destination(source_path, destination_path)
};
}
}
}
}
fn hardlink_to_destination(
source_path: &Path,
destination_path: &Path,
) -> Result<LinkMethod, LinkFileError> {
loop {
match fs::hard_link(source_path, destination_path) {
Ok(_) => {
return Ok(LinkMethod::Hardlink);
}
Err(e) if e.kind() == ErrorKind::AlreadyExists => {
fs::remove_file(destination_path).map_err(|err| {
LinkFileError::IoError(String::from("removing clobbered file"), err)
})?;
}
Err(e) => {
tracing::debug!(
"failed to hardlink {}: {e}, falling back to copying.",
destination_path.display()
);
return copy_to_destination(source_path, destination_path);
}
}
}
}
fn symlink_to_destination(
source_path: &Path,
destination_path: &Path,
target_prefix: &Path,
external_symlink_policy: ExternalSymlinkPolicy,
) -> Result<LinkMethod, LinkFileError> {
let linked_path = source_path
.read_link()
.map_err(LinkFileError::FailedToReadSymlink)?;
let resolved = destination_path
.parent()
.unwrap_or(destination_path)
.join(&linked_path);
let mut normalized = PathBuf::new();
for component in resolved.components() {
match component {
std::path::Component::ParentDir => {
normalized.pop();
}
std::path::Component::CurDir => {}
other => normalized.push(other),
}
}
if !normalized.starts_with(target_prefix) {
match external_symlink_policy {
ExternalSymlinkPolicy::Allow => {}
ExternalSymlinkPolicy::Warn => {
tracing::warn!(
"symlink {} points outside the target prefix: {}",
destination_path.display(),
linked_path.display()
);
}
ExternalSymlinkPolicy::Deny => {
return Err(LinkFileError::SymlinkTargetEscapesPrefix);
}
}
}
loop {
match symlink(&linked_path, destination_path) {
Ok(_) => {
let metadata = fs::symlink_metadata(source_path)
.map_err(LinkFileError::FailedToReadSourceFileMetadata)?;
let file_time = filetime::FileTime::from_last_modification_time(&metadata);
filetime::set_symlink_file_times(destination_path, file_time, file_time)
.map_err(LinkFileError::FailedToUpdateDestinationFileTimestamps)?;
return Ok(LinkMethod::Softlink);
}
Err(e) if e.kind() == ErrorKind::AlreadyExists => {
fs::remove_file(destination_path).map_err(|err| {
LinkFileError::IoError(String::from("removing clobbered file"), err)
})?;
}
Err(e) => {
tracing::debug!(
"failed to symlink {}: {e}, falling back to copying.",
destination_path.display()
);
return copy_to_destination(source_path, destination_path);
}
}
}
}
fn copy_to_destination(
source_path: &Path,
destination_path: &Path,
) -> Result<LinkMethod, LinkFileError> {
loop {
match fs::copy(source_path, destination_path) {
Err(e) if e.kind() == ErrorKind::AlreadyExists => {
fs::remove_file(destination_path).map_err(|err| {
LinkFileError::IoError(String::from("removing clobbered file"), err)
})?;
}
Ok(_) => {
let metadata = fs::symlink_metadata(source_path)
.map_err(LinkFileError::FailedToReadSourceFileMetadata)?;
let file_time = filetime::FileTime::from_last_modification_time(&metadata);
filetime::set_file_times(destination_path, file_time, file_time)
.map_err(LinkFileError::FailedToUpdateDestinationFileTimestamps)?;
return Ok(LinkMethod::Copy);
}
Err(e) => return Err(LinkFileError::FailedToLink(LinkMethod::Copy, e)),
}
}
}
pub fn copy_and_replace_placeholders(
source_bytes: &[u8],
mut destination: impl Write,
prefix_placeholder: &str,
target_prefix: &str,
target_platform: &Platform,
file_mode: FileMode,
) -> Result<(), std::io::Error> {
match file_mode {
FileMode::Text => {
copy_and_replace_textual_placeholder(
source_bytes,
destination,
prefix_placeholder,
target_prefix,
target_platform,
)?;
}
FileMode::Binary => {
if target_platform.is_windows() {
destination.write_all(source_bytes)?;
} else {
copy_and_replace_cstring_placeholder(
source_bytes,
destination,
prefix_placeholder,
target_prefix,
)?;
}
}
}
Ok(())
}
static SHEBANG_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^(#!(?:[ ]*)(/(?:\\ |[^ \n\r\t])*)(.*))$").unwrap()
});
static PYTHON_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^python(?:\d+(?:\.\d+)?)?$").unwrap()
});
fn is_valid_shebang_length(shebang: &str, platform: &Platform) -> bool {
const MAX_SHEBANG_LENGTH_LINUX: usize = 127;
const MAX_SHEBANG_LENGTH_MACOS: usize = 512;
if platform.is_linux() {
shebang.len() <= MAX_SHEBANG_LENGTH_LINUX
} else if platform.is_osx() {
shebang.len() <= MAX_SHEBANG_LENGTH_MACOS
} else {
true
}
}
fn convert_shebang_to_env(shebang: Cow<'_, str>) -> Cow<'_, str> {
if let Some(captures) = SHEBANG_REGEX.captures(&shebang) {
let path = &captures[2];
let exe_name = path.rsplit_once('/').map_or(path, |(_, f)| f);
if PYTHON_REGEX.is_match(exe_name) {
Cow::Owned(format!(
"#!/bin/sh\n'''exec' \"{}\"{} \"$0\" \"$@\" #'''",
path, &captures[3]
))
} else {
Cow::Owned(format!("#!/usr/bin/env {}{}", exe_name, &captures[3]))
}
} else {
shebang
}
}
fn replace_shebang<'a>(
shebang: Cow<'a, str>,
old_new: (&str, &str),
platform: &Platform,
) -> Cow<'a, str> {
assert!(
shebang.starts_with("#!"),
"Shebang does not start with #! ({shebang})",
);
if old_new.1.contains(' ') {
if !shebang.contains(old_new.0) {
return shebang;
}
let new_shebang = convert_shebang_to_env(shebang).replace(old_new.0, old_new.1);
return new_shebang.into();
}
let shebang: Cow<'_, str> = shebang.replace(old_new.0, old_new.1).into();
if !shebang.starts_with("#!") {
tracing::warn!("Shebang does not start with #! ({})", shebang);
return shebang;
}
if is_valid_shebang_length(&shebang, platform) {
shebang
} else {
convert_shebang_to_env(shebang)
}
}
pub fn copy_and_replace_textual_placeholder(
mut source_bytes: &[u8],
mut destination: impl Write,
prefix_placeholder: &str,
target_prefix: &str,
target_platform: &Platform,
) -> Result<(), std::io::Error> {
let old_prefix = prefix_placeholder.as_bytes();
let new_prefix = target_prefix.as_bytes();
if target_platform.is_unix() && source_bytes.starts_with(b"#!") {
let (first, rest) =
source_bytes.split_at(source_bytes.iter().position(|&c| c == b'\n').unwrap_or(0));
let first_line = String::from_utf8_lossy(first);
let new_shebang = replace_shebang(
first_line,
(prefix_placeholder, target_prefix),
target_platform,
);
destination.write_all(new_shebang.as_bytes())?;
source_bytes = rest;
}
let mut last_match = 0;
for index in memchr::memmem::find_iter(source_bytes, old_prefix) {
destination.write_all(&source_bytes[last_match..index])?;
destination.write_all(new_prefix)?;
last_match = index + old_prefix.len();
}
if last_match < source_bytes.len() {
destination.write_all(&source_bytes[last_match..])?;
}
Ok(())
}
pub fn copy_and_replace_cstring_placeholder(
mut source_bytes: &[u8],
mut destination: impl Write,
prefix_placeholder: &str,
target_prefix: &str,
) -> Result<(), std::io::Error> {
let old_prefix = prefix_placeholder.as_bytes();
let new_prefix = target_prefix.as_bytes();
if new_prefix.len() > old_prefix.len() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"target prefix cannot be longer than the placeholder prefix",
));
}
let finder = memchr::memmem::Finder::new(old_prefix);
loop {
if let Some(index) = finder.find(source_bytes) {
destination.write_all(&source_bytes[..index])?;
let mut end = index + old_prefix.len();
while end < source_bytes.len() && source_bytes[end] != b'\0' {
end += 1;
}
let mut out = Vec::new();
let mut old_bytes = &source_bytes[index..end];
let old_len = old_bytes.len();
while let Some(index) = finder.find(old_bytes) {
out.write_all(&old_bytes[..index])?;
out.write_all(new_prefix)?;
old_bytes = &old_bytes[index + old_prefix.len()..];
}
out.write_all(old_bytes)?;
if out.len() > old_len {
destination.write_all(&out[..old_len])?;
} else {
destination.write_all(&out)?;
}
let padding = old_len.saturating_sub(out.len());
destination.write_all(&vec![0; padding])?;
source_bytes = &source_bytes[end..];
} else {
destination.write_all(source_bytes)?;
return Ok(());
}
}
}
fn symlink(source_path: &Path, destination_path: &Path) -> std::io::Result<()> {
#[cfg(windows)]
return fs_err::os::windows::fs::symlink_file(source_path, destination_path);
#[cfg(unix)]
return fs_err::os::unix::fs::symlink(source_path, destination_path);
}
#[allow(unused_variables)]
fn has_executable_permissions(permissions: &Permissions) -> bool {
#[cfg(windows)]
return false;
#[cfg(unix)]
return std::os::unix::fs::PermissionsExt::mode(permissions) & 0o111 != 0;
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum FileType {
MachO,
}
impl FileType {
const MACHO_FAT_MAGIC: u32 = 0xcafebabe; const MACHO_FAT_CIGAM: u32 = 0xbebafeca; const MACHO_MAGIC_32: u32 = 0xfeedface; const MACHO_CIGAM_32: u32 = 0xcefaedfe; const MACHO_MAGIC_64: u32 = 0xfeedfacf; const MACHO_CIGAM_64: u32 = 0xcffaedfe;
fn detect(bytes: &[u8]) -> Option<Self> {
if bytes.len() < 4 {
return None;
}
let magic = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
match magic {
Self::MACHO_FAT_MAGIC
| Self::MACHO_FAT_CIGAM
| Self::MACHO_MAGIC_32
| Self::MACHO_CIGAM_32
| Self::MACHO_MAGIC_64
| Self::MACHO_CIGAM_64 => Some(FileType::MachO),
_ => None,
}
}
}
#[cfg(test)]
mod test {
use super::ExternalSymlinkPolicy;
use super::PYTHON_REGEX;
use fs_err as fs;
use rattler_conda_types::Platform;
use rstest::rstest;
use std::io::Cursor;
#[test]
fn test_patched_file_receives_modification_time() {
use super::AppleCodeSignBehavior;
use rattler_conda_types::package::{FileMode, PathType, PathsEntry, PrefixPlaceholder};
use rattler_conda_types::prefix::Prefix;
use std::path::PathBuf;
let temp_dir = tempfile::tempdir().unwrap();
let package_dir = temp_dir.path().join("package");
fs::create_dir_all(&package_dir).unwrap();
fs::write(
package_dir.join("config.py"),
"prefix = '/old/placeholder/path'\n",
)
.unwrap();
let source_time = filetime::FileTime::from_unix_time(1_000_000, 0);
filetime::set_file_times(package_dir.join("config.py"), source_time, source_time).unwrap();
let target_dir = Prefix::create(temp_dir.path().join("target")).unwrap();
let modification_time = filetime::FileTime::from_unix_time(2_000_000, 0);
let entry = PathsEntry {
relative_path: PathBuf::from("config.py"),
no_link: false,
path_type: PathType::HardLink,
prefix_placeholder: Some(PrefixPlaceholder {
file_mode: FileMode::Text,
placeholder: "/old/placeholder/path".to_string(),
}),
sha256: None,
size_in_bytes: None,
};
let result = super::link_file(
&entry,
PathBuf::from("config.py"),
&package_dir,
&target_dir,
target_dir.path().to_str().unwrap(),
true,
true,
true,
Platform::Linux64,
AppleCodeSignBehavior::DoNothing,
modification_time,
ExternalSymlinkPolicy::Deny,
)
.unwrap();
assert_eq!(result.method, super::LinkMethod::Patched(FileMode::Text));
let content = fs::read_to_string(target_dir.path().join("config.py")).unwrap();
assert!(content.contains(target_dir.path().to_str().unwrap()));
assert!(!content.contains("/old/placeholder/path"));
let dest_metadata = fs::metadata(target_dir.path().join("config.py")).unwrap();
let dest_mtime = filetime::FileTime::from_last_modification_time(&dest_metadata);
assert_eq!(
dest_mtime, modification_time,
"patched file should have modification_time ({modification_time}), not source mtime ({source_time})",
);
}
#[test]
fn test_unpatched_file_keeps_source_mtime() {
use super::AppleCodeSignBehavior;
use rattler_conda_types::package::{PathType, PathsEntry};
use rattler_conda_types::prefix::Prefix;
use std::path::PathBuf;
let temp_dir = tempfile::tempdir().unwrap();
let package_dir = temp_dir.path().join("package");
fs::create_dir_all(&package_dir).unwrap();
fs::write(package_dir.join("data.txt"), "no prefix here\n").unwrap();
let source_time = filetime::FileTime::from_unix_time(1_000_000, 0);
filetime::set_file_times(package_dir.join("data.txt"), source_time, source_time).unwrap();
let target_dir = Prefix::create(temp_dir.path().join("target")).unwrap();
let modification_time = filetime::FileTime::from_unix_time(2_000_000, 0);
let entry = PathsEntry {
relative_path: PathBuf::from("data.txt"),
no_link: false,
path_type: PathType::HardLink,
prefix_placeholder: None,
sha256: None,
size_in_bytes: None,
};
let result = super::link_file(
&entry,
PathBuf::from("data.txt"),
&package_dir,
&target_dir,
target_dir.path().to_str().unwrap(),
true,
true,
true,
Platform::Linux64,
AppleCodeSignBehavior::DoNothing,
modification_time,
ExternalSymlinkPolicy::Deny,
)
.unwrap();
assert_ne!(
result.method,
super::LinkMethod::Patched(rattler_conda_types::package::FileMode::Text)
);
assert_ne!(
result.method,
super::LinkMethod::Patched(rattler_conda_types::package::FileMode::Binary)
);
let dest_metadata = fs::metadata(target_dir.path().join("data.txt")).unwrap();
let dest_mtime = filetime::FileTime::from_last_modification_time(&dest_metadata);
assert_eq!(
dest_mtime, source_time,
"unpatched file should keep source mtime ({source_time}), not modification_time ({modification_time})",
);
}
#[rstest]
#[case("Hello, cruel world!", "cruel", "fabulous", "Hello, fabulous world!")]
#[case(
"prefix_placeholder",
"prefix_placeholder",
"target_prefix",
"target_prefix"
)]
pub fn test_copy_and_replace_textual_placeholder(
#[case] input: &str,
#[case] prefix_placeholder: &str,
#[case] target_prefix: &str,
#[case] expected_output: &str,
) {
let mut output = Cursor::new(Vec::new());
super::copy_and_replace_textual_placeholder(
input.as_bytes(),
&mut output,
prefix_placeholder,
target_prefix,
&Platform::Linux64,
)
.unwrap();
assert_eq!(
&String::from_utf8_lossy(&output.into_inner()),
expected_output
);
}
#[rstest]
#[case(
b"12345Hello, fabulous world!\x006789",
"fabulous",
"cruel",
b"12345Hello, cruel world!\x00\x00\x00\x006789"
)]
pub fn test_copy_and_replace_binary_placeholder(
#[case] input: &[u8],
#[case] prefix_placeholder: &str,
#[case] target_prefix: &str,
#[case] expected_output: &[u8],
) {
assert_eq!(
expected_output.len(),
input.len(),
"input and expected output must have the same length"
);
let mut output = Cursor::new(Vec::new());
super::copy_and_replace_cstring_placeholder(
input,
&mut output,
prefix_placeholder,
target_prefix,
)
.unwrap();
assert_eq!(&output.into_inner(), expected_output);
}
#[rstest]
#[case(b"short\x00", "short", "verylong")]
#[case(b"short1234\x00", "short", "verylong")]
pub fn test_shorter_binary_placeholder(
#[case] input: &[u8],
#[case] prefix_placeholder: &str,
#[case] target_prefix: &str,
) {
assert!(target_prefix.len() > prefix_placeholder.len());
let mut output = Cursor::new(Vec::new());
let result = super::copy_and_replace_cstring_placeholder(
input,
&mut output,
prefix_placeholder,
target_prefix,
);
assert!(result.is_err());
}
#[test]
fn replace_binary_path_var() {
let input =
b"beginrandomdataPATH=/placeholder/etc/share:/placeholder/bin/:\x00somemoretext";
let mut output = Cursor::new(Vec::new());
super::copy_and_replace_cstring_placeholder(input, &mut output, "/placeholder", "/target")
.unwrap();
let out = &output.into_inner();
assert_eq!(out, b"beginrandomdataPATH=/target/etc/share:/target/bin/:\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00somemoretext");
assert_eq!(out.len(), input.len());
}
#[test]
fn test_replace_shebang() {
let shebang_with_spaces = "#!/path/placeholder/executable -o test -x".into();
let replaced = super::replace_shebang(
shebang_with_spaces,
("placeholder", "with space"),
&Platform::Linux64,
);
assert_eq!(replaced, "#!/usr/bin/env executable -o test -x");
}
#[test]
fn test_replace_long_shebang() {
let short_shebang = "#!/path/to/executable -x 123".into();
let replaced = super::replace_shebang(short_shebang, ("", ""), &Platform::Linux64);
assert_eq!(replaced, "#!/path/to/executable -x 123");
let shebang = "#!/this/is/loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong/executable -o test -x";
let replaced = super::replace_shebang(shebang.into(), ("", ""), &Platform::Linux64);
assert_eq!(replaced, "#!/usr/bin/env executable -o test -x");
let replaced = super::replace_shebang(shebang.into(), ("", ""), &Platform::Osx64);
assert_eq!(replaced, shebang);
let shebang_with_escapes = "#!/this/is/loooooooooooooooooooooooooooooooooooooooooooooooooooo\\ oooooo\\ oooooo\\ oooooooooooooooooooooooooooooooooooong/exe\\ cutable -o test -x";
let replaced =
super::replace_shebang(shebang_with_escapes.into(), ("", ""), &Platform::Linux64);
assert_eq!(replaced, "#!/usr/bin/env exe\\ cutable -o test -x");
let shebang = "#! /this/is/looooooooooooooooooooooooooooooooooooooooooooo\\ \\ ooooooo\\ oooooo\\ oooooo\\ ooooooooooooooooo\\ ooooooooooooooooooong/exe\\ cutable -o \"te st\" -x";
let replaced = super::replace_shebang(shebang.into(), ("", ""), &Platform::Linux64);
assert_eq!(replaced, "#!/usr/bin/env exe\\ cutable -o \"te st\" -x");
let shebang = "#!/usr/bin/env perl";
let replaced = super::replace_shebang(
shebang.into(),
("/placeholder", "/with space"),
&Platform::Linux64,
);
assert_eq!(replaced, shebang);
let shebang = "#!/placeholder/perl";
let replaced = super::replace_shebang(
shebang.into(),
("/placeholder", "/with space"),
&Platform::Linux64,
);
assert_eq!(replaced, "#!/usr/bin/env perl");
}
#[test]
fn replace_python_shebang() {
let short_shebang = "#!/path/to/python3.12".into();
let replaced = super::replace_shebang(
short_shebang,
("/path/to", "/new/prefix/with spaces/bin"),
&Platform::Linux64,
);
insta::assert_snapshot!(replaced);
let short_shebang = "#!/path/to/python3.12 -x 123".into();
let replaced = super::replace_shebang(
short_shebang,
("/path/to", "/new/prefix/with spaces/bin"),
&Platform::Linux64,
);
insta::assert_snapshot!(replaced);
}
#[test]
fn test_replace_long_prefix_in_text_file() {
let test_data_dir =
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../test-data");
let test_file = test_data_dir.join("shebang_test.txt");
let prefix_placeholder = "/this/is/placeholder";
let mut target_prefix = "/super/long/".to_string();
for _ in 0..15 {
target_prefix.push_str("verylongstring/");
}
let input = fs::read(test_file).unwrap();
let mut output = Cursor::new(Vec::new());
super::copy_and_replace_textual_placeholder(
&input,
&mut output,
prefix_placeholder,
&target_prefix,
&Platform::Linux64,
)
.unwrap();
let output = output.into_inner();
let replaced = String::from_utf8_lossy(&output);
insta::assert_snapshot!(replaced);
}
#[test]
fn test_python_regex() {
let test_strings = vec!["python", "python3", "python3.12", "python2.7"];
for s in test_strings {
assert!(PYTHON_REGEX.is_match(s));
}
let no_match_strings = vec![
"python3.12.1",
"python3.12.1.1",
"foo",
"foo3.2",
"pythondoc",
];
for s in no_match_strings {
assert!(!PYTHON_REGEX.is_match(s));
}
}
#[test]
fn test_detect_file_type() {
use super::FileType;
let macho_64_be = [0xfe, 0xed, 0xfa, 0xcf, 0x00, 0x00];
assert_eq!(FileType::detect(&macho_64_be), Some(FileType::MachO));
let macho_64_le = [0xcf, 0xfa, 0xed, 0xfe, 0x00, 0x00];
assert_eq!(FileType::detect(&macho_64_le), Some(FileType::MachO));
let macho_32_be = [0xfe, 0xed, 0xfa, 0xce, 0x00, 0x00];
assert_eq!(FileType::detect(&macho_32_be), Some(FileType::MachO));
let macho_32_le = [0xce, 0xfa, 0xed, 0xfe, 0x00, 0x00];
assert_eq!(FileType::detect(&macho_32_le), Some(FileType::MachO));
let fat_be = [0xca, 0xfe, 0xba, 0xbe, 0x00, 0x00];
assert_eq!(FileType::detect(&fat_be), Some(FileType::MachO));
let fat_le = [0xbe, 0xba, 0xfe, 0xca, 0x00, 0x00];
assert_eq!(FileType::detect(&fat_le), Some(FileType::MachO));
let not_macho = [0x00, 0x01, 0x02, 0x03, 0x04, 0x05];
assert_eq!(FileType::detect(¬_macho), None);
let short = [0xfe, 0xed];
assert_eq!(FileType::detect(&short), None);
let empty: [u8; 0] = [];
assert_eq!(FileType::detect(&empty), None);
}
#[test]
fn test_symlink_escape_rejected() {
use super::{symlink_to_destination, LinkFileError};
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
let cache = tmp.path().join("cache");
fs::create_dir_all(prefix.join("lib")).unwrap();
fs::create_dir_all(cache.join("lib")).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink("../../../../escape_target", cache.join("lib/sneaky-link"))
.unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(
"..\\..\\..\\..\\escape_target",
cache.join("lib\\sneaky-link"),
)
.unwrap();
let result = symlink_to_destination(
&cache.join("lib/sneaky-link"),
&prefix.join("lib/sneaky-link"),
&prefix,
ExternalSymlinkPolicy::Deny,
);
assert!(matches!(
result.unwrap_err(),
LinkFileError::SymlinkTargetEscapesPrefix
));
}
#[test]
fn test_symlink_within_prefix_allowed() {
let tmp = tempfile::tempdir().unwrap();
let prefix = tmp.path().join("prefix");
let cache = tmp.path().join("cache");
fs::create_dir_all(prefix.join("lib")).unwrap();
fs::create_dir_all(cache.join("lib")).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink("../bin/real_file", cache.join("lib/safe-link")).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file("..\\bin\\real_file", cache.join("lib\\safe-link"))
.unwrap();
let result = super::symlink_to_destination(
&cache.join("lib/safe-link"),
&prefix.join("lib/safe-link"),
&prefix,
ExternalSymlinkPolicy::Deny,
);
assert!(result.is_ok());
}
}