//! Package linking module
//!
//! This module provides functions for linking package files from the store to the environment,
//! supporting different link types (hardlink, symlink, move, runpath).
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use color_eyre::Result;
use color_eyre::eyre::{self, WrapErr};
use crate::models::{LinkType, PackageFormat, InstalledPackageInfo};
use crate::plan::InstallationPlan;
use crate::utils;
use crate::lfs;
use log;
/// File action types (simplified version for config file handling)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EtcFileAction {
Create,
Identical,
Backup,
AltName, // For NOREPLACE configs - creates .rpmnew file
}
/// Determine if a file path is a config file (in /etc/)
fn is_config_file_path(file_path: &Path) -> bool {
file_path.to_string_lossy().starts_with("etc/")
}
/// Get config file action based on existing file and flags
fn get_config_file_action(
target_path: &Path,
fs_file: &Path,
) -> EtcFileAction {
// Check if existing file exists
if !lfs::exists_in_env(target_path) {
return EtcFileAction::Create;
}
// Check if files are identical (simplified - just check if they exist and are same size)
// Use symlink_metadata to avoid following symlinks in env context
if let (Ok(meta1), Ok(meta2)) = (lfs::symlink_metadata(target_path), fs::metadata(fs_file)) {
#[cfg(unix)]
{
if meta1.len() == meta2.len() && meta1.mode() == meta2.mode() {
return EtcFileAction::Identical;
}
}
#[cfg(not(unix))]
{
if meta1.len() == meta2.len() {
return EtcFileAction::Identical;
}
}
}
// For config files, use transaction module to decide file action
// For now, we'll infer NOREPLACE from common patterns (can be enhanced later with RPM metadata)
let is_noreplace = target_path.file_name().and_then(|n| n.to_str())
.map(|s| s.contains("config") || s.contains("conf") || s.contains(".cfg"))
.unwrap_or(false);
// Files differ - handle based on flags
if is_noreplace {
EtcFileAction::AltName // Create .rpmnew file
} else {
EtcFileAction::Backup // Backup existing, then create new
}
}
/// Process config file actions (skip, create .rpmnew, backup, or continue)
fn process_config_file(
fs_file: &Path,
target_path: &Path,
can_reflink: bool,
) -> Result<EtcFileAction> {
// Use transaction module to decide config file fate
let action = get_config_file_action(target_path, fs_file);
match action {
EtcFileAction::Identical => {
log::debug!("Skipping config file {} (identical to existing)", target_path.display());
Ok(EtcFileAction::Identical)
}
EtcFileAction::AltName => {
// Create .rpmnew file for NOREPLACE configs
let rpmnew_path = target_path.with_extension(format!(
"{}.rpmnew",
target_path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
));
log::info!("Creating .rpmnew file for config {}: {}", target_path.display(), rpmnew_path.display());
lfs::reflink_or_copy(fs_file, &rpmnew_path, can_reflink)?;
Ok(EtcFileAction::AltName)
}
EtcFileAction::Backup => {
// Backup existing config file
let backup_path = target_path.with_extension(format!(
"{}.rpmsave",
target_path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
));
log::info!("Backing up config {} to {}", target_path.display(), backup_path.display());
lfs::copy(target_path, &backup_path)?;
Ok(EtcFileAction::Backup)
}
_ => {
// Create or overwrite (default behavior)
Ok(EtcFileAction::Create)
}
}
}
/// Handle config files (/etc/ files) with special processing
fn mirror_config_file(
fs_file: &Path,
target_path: &Path,
can_reflink: bool,
) -> Result<()> {
// Process config file actions
let config_result = process_config_file(fs_file, target_path, can_reflink)?;
match config_result {
EtcFileAction::Identical => return Ok(()),
EtcFileAction::AltName => return Ok(()),
_ => (), // Continue with linking
}
// Remove existing target file if present (already backed up if needed)
if lfs::exists_in_env(target_path) {
lfs::remove_file(target_path)?;
}
// Copy config file using reflink if supported, otherwise regular copy
lfs::reflink_or_copy(fs_file, target_path, can_reflink)?;
Ok(())
}
// link files from env_root to store_fs_dir
pub fn link_package(plan: &InstallationPlan, store_fs_dir: &PathBuf) -> Result<()> {
log::debug!("link_package: link={:?} package_format={:?} env_root={} store_fs_dir={}", plan.link, plan.package_format, plan.env_root.display(), store_fs_dir.display());
// Check if this is a conda package and use conda-specific linking
if plan.package_format == PackageFormat::Conda {
return crate::conda_link::link_conda_package(plan, store_fs_dir);
}
link_package_generic(plan, store_fs_dir)?;
// For brew packages, create Homebrew-style Cellar symlinks
#[cfg(unix)]
if plan.package_format == PackageFormat::Brew {
log::info!("Creating Cellar symlinks for brew package: {}", plan.env_root.display());
// Extract pkgline from store_fs_dir path: store_root/pkgline/fs
// Then convert to pkgkey
if let Some(pkgline) = store_fs_dir.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
{
if let Ok(pkgkey) = crate::package::pkgline2pkgkey(pkgline) {
// store_dir is store_fs_dir.parent() (contains info/brew/.brew/)
let store_dir = store_fs_dir.parent().unwrap();
if let Err(e) = crate::brew_pkg::create_cellar_symlinks(&plan.env_root, store_dir, &pkgkey) {
log::warn!("Failed to create Cellar symlinks: {}", e);
}
}
}
}
// NOTE: dylib/interpreter path rewriting is now handled at a higher level
// (after all packages are linked) to avoid multiple rewrites of the same files.
// See execute_installation_plan() in install.rs for the single call.
Ok(())
}
/// Link package files using generic (non-format-specific) linking
/// This handles standard file linking without format-specific metadata processing
pub fn link_package_generic(plan: &InstallationPlan, store_fs_dir: &PathBuf) -> Result<()> {
// For LinkType::Move, check if store is already consumed by another environment
if plan.link == LinkType::Move {
if let Some(store_path) = store_fs_dir.parent() {
if let Some(_fs_files) = handle_move_link_type(store_path, store_fs_dir, &plan.env_root)? {
return Ok(());
}
// Create consumed marker before moving files
crate::store::create_consumed_marker(store_path, &plan.env_root.display().to_string(), &plan.env_root)
.with_context(|| format!("Failed to create consumed marker for {}", store_path.display()))?;
}
}
// Standard linking for non-conda packages
let fs_files = utils::list_package_files_with_info(store_fs_dir.to_str().ok_or_else(|| eyre::eyre!("Invalid store_fs_dir path: {}", store_fs_dir.display()))?)
.with_context(|| format!("Failed to list package files in {}", store_fs_dir.display()))?;
mirror_dir(&plan.env_root, store_fs_dir, &fs_files, plan.link, plan.can_reflink)
.with_context(|| format!("Failed to mirror directory from {} to {}", store_fs_dir.display(), plan.env_root.display()))?;
// Note: We don't remove fs/ directory after Move because:
// 1. expose_package() needs to read filelist.txt from info/ and check fs/ structure
// 2. The consumed.json marker already indicates the store is consumed
// 3. Empty directories in fs/ don't take significant space
Ok(())
}
/// Handle LinkType::Move logic: check if store is consumed and copy files if needed.
/// Returns Some(fs_files) if files were copied from existing env, None otherwise.
pub fn handle_move_link_type(
store_path: &Path,
store_fs_dir: &PathBuf,
env_root: &Path,
) -> Result<Option<Vec<crate::mtree::MtreeFileInfo>>> {
if !crate::store::is_store_consumed(store_path) {
return Ok(None);
}
let marker_path = store_path.join("info/consumed.json");
let content = match std::fs::read_to_string(&marker_path) {
Ok(c) => c,
Err(_) => return Ok(None),
};
let marker: serde_json::Value = match serde_json::from_str(&content) {
Ok(m) => m,
Err(_) => return Ok(None),
};
let existing_env = match marker.get("env_root").and_then(|v| v.as_str()) {
Some(e) => e,
None => return Ok(None),
};
let existing_env_path = std::path::PathBuf::from(existing_env);
let same_env = existing_env_path == env_root;
let fs_files = utils::list_package_files_with_info(store_fs_dir.to_str()
.ok_or_else(|| eyre::eyre!("Invalid store_fs_dir path: {}", store_fs_dir.display()))?)
.with_context(|| format!("Failed to list package files in {}", store_fs_dir.display()))?;
let files_exist_in_env = check_files_exist_in_env(&existing_env_path, &fs_files);
if same_env && !files_exist_in_env {
log::warn!("Store {} has stale consumed.json pointing to {} (same env), files don't exist. Removing marker to trigger re-download.",
store_path.display(), existing_env);
lfs::remove_file(&marker_path)
.with_context(|| format!("Failed to remove stale consumed.json at {}", marker_path.display()))?;
return Err(eyre::eyre!("Store {} needs re-download (stale consumed.json removed)", store_path.display()));
}
log::info!("Store {} already consumed by environment {}, copying files instead of moving",
store_path.display(), existing_env);
// When same_env=true, files are already in target location, no need to copy
if same_env {
log::debug!("Files already in place in same env, skipping copy");
return Ok(Some(fs_files));
}
copy_files_from_env(env_root, &existing_env_path, &fs_files)?;
Ok(Some(fs_files))
}
/// Copy files from an existing environment to a new environment.
/// Used when the store is already consumed by another environment.
fn copy_files_from_env(new_env_root: &Path, existing_env_root: &Path, fs_files: &[crate::mtree::MtreeFileInfo]) -> Result<()> {
for fs_file_info in fs_files {
let fhs_file = &fs_file_info.path;
let rel_host = lfs::host_path_from_manifest_rel_path(fhs_file.trim_start_matches('/'));
let existing_file = existing_env_root.join(&rel_host);
let target_path = new_env_root.join(&rel_host);
// Skip directories
if fs_file_info.is_dir() {
// Check if target exists and is a real directory (not a symlink)
if let Ok(metadata) = lfs::symlink_metadata(&target_path) {
if metadata.file_type().is_symlink() {
// Existing path is a symlink, remove it and create real directory
// This can happen when Cellar symlinks were incorrectly created
log::debug!("Removing symlink to create real directory: {}", target_path.display());
lfs::remove_file(&target_path)?;
lfs::create_dir_all(&target_path)?;
} else if metadata.is_dir() {
// Already a real directory, skip
continue;
} else {
// It's a file, remove and create directory
lfs::remove_file(&target_path)?;
lfs::create_dir_all(&target_path)?;
}
} else {
// Path doesn't exist, create directory
lfs::create_dir_all(&target_path)?;
}
continue;
}
// Skip if existing file doesn't exist (may have been removed)
if !lfs::exists_on_host(&existing_file) {
log::debug!("Skipping missing file: {}", existing_file.display());
continue;
}
// Create parent directory if needed
if let Some(parent) = target_path.parent() {
if !lfs::exists_on_host(parent) {
lfs::create_dir_all(parent)?;
}
}
// Copy file or symlink
if fs_file_info.is_link() {
// Copy symlink target
let link_target = std::fs::read_link(&existing_file)
.with_context(|| format!("Failed to read symlink {}", existing_file.display()))?;
// Remove existing symlink if present (from partial previous install)
if lfs::symlink_metadata(&target_path).is_ok() {
lfs::remove_file(&target_path)
.with_context(|| format!("Failed to remove existing symlink {}", target_path.display()))?;
}
lfs::symlink_file_for_virtiofs(&link_target, &target_path)
.with_context(|| format!("Failed to create symlink {}", target_path.display()))?;
} else {
// Copy regular file - remove existing if present
if lfs::exists_on_host(&target_path) {
lfs::remove_file(&target_path)
.with_context(|| format!("Failed to remove existing file {}", target_path.display()))?;
}
lfs::copy(&existing_file, &target_path)
.with_context(|| format!("Failed to copy {} to {}", existing_file.display(), target_path.display()))?;
}
log::trace!("Copied: {} -> {}", existing_file.display(), target_path.display());
}
log::info!("Copied {} files from {} to {}", fs_files.len(), existing_env_root.display(), new_env_root.display());
Ok(())
}
/// Check if any files from the package actually exist in the environment.
/// Returns true if at least one regular file exists.
/// Used to detect stale consumed.json (package was removed but marker wasn't cleaned).
fn check_files_exist_in_env(env_root: &Path, fs_files: &[crate::mtree::MtreeFileInfo]) -> bool {
for fs_file_info in fs_files {
// Skip directories
if fs_file_info.is_dir() {
continue;
}
let fhs_file = &fs_file_info.path;
let rel_host = lfs::host_path_from_manifest_rel_path(fhs_file.trim_start_matches('/'));
let env_file = env_root.join(&rel_host);
// Check if file exists (from host POV, since we're outside env)
if lfs::exists_on_host(&env_file) {
// Found at least one file, files exist in env
log::debug!("Found existing file in env: {}", env_file.display());
return true;
}
}
// No files found in env
log::debug!("No package files found in env: {}", env_root.display());
false
}
/// Unlink files that are in old_package but not in new_package
/// This implements the Set(old_pkg - new_pkg) logic
/// If old_pkgkey or old_package_info is None, this is a no-op
pub fn unlink_package_diff(
old_pkgkey: Option<&str>,
old_package_info: Option<&std::sync::Arc<InstalledPackageInfo>>,
new_package_info: &InstalledPackageInfo,
store_root: &Path,
env_root: &Path,
new_files_union: &std::collections::HashSet<std::path::PathBuf>,
) -> Result<()> {
let (_old_key, old_info) = match (old_pkgkey, old_package_info) {
(Some(key), Some(info)) => (key, info),
_ => return Ok(()),
};
// Get file lists for both packages
let old_files = crate::package_cache::map_pkgline2filelist(store_root, &old_info.pkgline)?;
// Convert to sets of relative paths for comparison (already relative paths as strings)
let old_rel_paths: std::collections::HashSet<PathBuf> = old_files
.iter()
.map(|s| PathBuf::from(s))
.collect();
// Find files that are in old package but not in any new package in batch
log::debug!("Batch union contains {} files", new_files_union.len());
let files_to_remove: Vec<PathBuf> = old_rel_paths
.difference(new_files_union)
.cloned()
.collect();
log::debug!(
"Found {} files to remove during upgrade (batch union): old_pkg={}, new_pkg={}",
files_to_remove.len(),
old_info.pkgline,
new_package_info.pkgline
);
// Remove the files from environment
for rel_path in &files_to_remove {
let env_file_path = env_root.join(lfs::host_path_from_manifest_rel_path(
rel_path.to_string_lossy().as_ref(),
));
if lfs::exists_in_env(&env_file_path) {
if lfs::symlink_metadata(&env_file_path).map(|m| m.file_type().is_dir()).unwrap_or(false) {
// Only remove directory if it's empty
match std::fs::read_dir(&env_file_path) {
Ok(mut entries) => {
if entries.next().is_none() {
lfs::remove_dir(&env_file_path)?;
} else {
log::debug!("Directory not empty, skipping: {}", env_file_path.display());
}
}
Err(_) => {
log::debug!("Cannot read directory, skipping: {}", env_file_path.display());
}
}
} else {
lfs::remove_file(&env_file_path)?;
}
}
}
if !files_to_remove.is_empty() {
log::info!(
"Removed {} unique files from old package during upgrade",
files_to_remove.len()
);
}
Ok(())
}
/// Check if two paths are on the same filesystem by comparing filesystem IDs
/// Returns true if both filesystems have valid fsid (!= 0) and same fsid, false otherwise
pub fn same_filesystem(
fs1: &crate::plan::FilesystemInfo,
fs2: &crate::plan::FilesystemInfo,
) -> bool {
// Check if either filesystem info is invalid (fsid == 0)
// On Windows, fsid is always 0 (statvfs not available), so this is expected
if fs1.fsid == 0 && fs2.fsid == 0 {
log::warn!("Cannot determine filesystem ID for '{}' and '{}', assuming different filesystems",
fs1.path.display(), fs2.path.display());
return false;
}
if fs1.fsid == 0 || fs2.fsid == 0 {
log::warn!("Filesystem info incomplete: '{}' (fsid={}) vs '{}' (fsid={}), assuming different filesystems",
fs1.path.display(), fs1.fsid,
fs2.path.display(), fs2.fsid);
return false;
}
// Both have valid fsid, compare them
fs1.fsid == fs2.fsid
}
/// Compute link type and reflink support for installation plan
/// Sets plan.link, plan.can_reflink, plan.can_hardlink, and plan.can_symlink directly
/// Uses stored filesystem info from plan to avoid duplicate statvfs calls
pub fn compute_link_type_and_reflink(
plan: &mut InstallationPlan,
) -> Result<()> {
use crate::models::env_config;
let mut link_type = env_config().link;
let mut can_reflink = false;
// Brew packages require Move link type because dylib paths are rewritten
// to use absolute paths pointing to the specific environment.
// This makes store sharing impossible, so each env gets its own copy.
if plan.package_format == PackageFormat::Brew {
link_type = LinkType::Move;
log::debug!("Forcing LinkType::Move for Brew packages");
}
// Use stored filesystem info from plan
let same_fs = same_filesystem(
&plan.store_root_fs,
&plan.env_root_fs,
);
// can_hardlink: hardlinks only work on the same filesystem
let can_hardlink = same_fs;
// Check symlink capability on Windows
// Windows users need Admin or Developer Mode to create symlinks
let can_create_symlinks = lfs::can_create_symlinks();
// can_symlink: symlinks work across filesystems, but not if link type is Move
// On Windows, also check if user has symlink creation permission
let can_symlink = link_type != LinkType::Move && can_create_symlinks;
// Check reflink support only once when needed
let should_check_reflink = plan.package_format == PackageFormat::Conda
|| link_type == LinkType::Hardlink
|| link_type == LinkType::Reflink;
if should_check_reflink {
can_reflink = lfs::check_reflink_support(&plan.env_root, same_fs);
if can_reflink {
log::debug!("Reflink support detected on filesystem");
}
}
if link_type == LinkType::Hardlink {
if same_fs {
// Same filesystem, keep hardlink (reflink support already checked)
// can_reflink already set above
} else {
// Different filesystems, downgrade to symlink
log::debug!("Store root and env root are on different filesystems, downgrading hardlink to symlink");
link_type = LinkType::Symlink;
}
} else if link_type == LinkType::Reflink {
// Reflink can attempt reflink on same filesystem, fall back to copy otherwise
// can_reflink already set above if same_fs, otherwise remains false
// If not same_fs, can_reflink remains false, link_type stays Reflink (will fall back to copy)
} else if link_type == LinkType::Move || link_type == LinkType::Runpath {
if !same_fs {
// Different filesystems, rename() will fail, but we can fall back to copy+delete
log::debug!("Store root and env root are on different filesystems, {:?} will use copy+delete fallback (store: {}, env: {})",
link_type,
plan.store_root.display(),
plan.env_root.display());
}
// Continue with Move/Runpath, actual execution will use copy+delete fallback if needed
} else if link_type == LinkType::Symlink && !can_create_symlinks {
// On Windows without symlink permission, downgrade to hardlink if same filesystem
if same_fs {
log::info!("Windows symlink creation not available; using hardlinks instead");
link_type = LinkType::Hardlink;
}
// If different filesystems, keep symlink type - symlink_file_for_virtiofs
// handles the fallback (junction for dirs, hardlink/copy for files)
}
plan.link = link_type;
plan.can_reflink = can_reflink;
plan.can_hardlink = can_hardlink;
plan.can_symlink = can_symlink;
Ok(())
}
// Check if bin/<program> file exists (any file type, any target).
// We only care that a file exists at that path, regardless of whether it's a symlink
// or what it points to. When running from host, this file is inside the env mount;
// we don't need to validate that its target exists on the host filesystem.
#[cfg(unix)]
pub fn bin_file_exists(target_path: &Path, _fs_file: &Path) -> Result<bool> {
let target_path_str = target_path.to_string_lossy();
let bin_path = PathBuf::from(target_path_str.replace("/ebin/", "/bin/"));
// Use symlink_metadata to check existence without following symlinks
Ok(lfs::symlink_metadata(&bin_path).is_ok())
}
// Create symlink2: "{dirname(target_path)}/.{filename(target_path)}" -> fs_file
#[cfg(unix)]
pub fn create_symlink2(target_path: &Path, fs_file: &Path) -> Result<()> {
let target_filename = target_path.file_name()
.ok_or_else(|| eyre::eyre!("Failed to get filename from {}", target_path.display()))?
.to_string_lossy();
let target_dirname = target_path.parent()
.ok_or_else(|| eyre::eyre!("Failed to get parent directory from {}", target_path.display()))?;
let symlink2_path = target_dirname.join(format!(".{}", target_filename));
// Remove existing symlink2 if it exists
if lfs::symlink_metadata(&symlink2_path).is_ok() {
lfs::remove_file(&symlink2_path)?;
}
// Create symlink2 -> fs_file
lfs::symlink_file_for_virtiofs(fs_file, &symlink2_path)?;
log::debug!("Created symlink2: {} -> {}", symlink2_path.display(), fs_file.display());
Ok(())
}
fn mirror_dir(env_root: &Path, store_fs_dir: &Path, fs_files: &[crate::mtree::MtreeFileInfo], link_type: LinkType, can_reflink: bool) -> Result<()> {
for fs_file_info in fs_files {
let fhs_file = &fs_file_info.path;
let rel_host = lfs::host_path_from_manifest_rel_path(fhs_file.trim_start_matches('/'));
let fs_file = store_fs_dir.join(&rel_host);
let target_path = env_root.join(&rel_host);
log::trace!("mirror_dir: processing fhs_file={}, is_link={}, is_dir={}", fhs_file, fs_file_info.is_link(), fs_file_info.is_dir());
// No modify top-level directories/symlinks created by create_environment_dirs_early()
// NOTE: On macOS, usr/libexec is a symlink (only created for Brew packages), so we skip it.
// On Linux, usr/libexec is a real directory (RPM/Debian), so we DON'T skip it here.
// The usr/libexec skip on macOS only affects Brew environments because only Brew creates this symlink.
#[cfg(target_os = "macos")]
if matches!(fhs_file.trim_end_matches('/'), "sbin" | "bin" | "lib" | "lib64" | "share" | "include" | "usr/sbin" | "usr/lib64" | "usr/libexec") {
continue;
}
#[cfg(not(target_os = "macos"))]
if matches!(fhs_file.trim_end_matches('/'), "sbin" | "bin" | "lib" | "lib64" | "share" | "include" | "usr/sbin" | "usr/lib64") {
continue;
}
// On Windows, resolve ancestor directory symlinks (e.g., lib64 -> usr/lib64, Lib -> usr/lib)
// This handles cases where packages install files to lib64/ or Lib/ which are symlinks
#[cfg(windows)]
let target_path = lfs::resolve_ancestor_symlink(&target_path);
if fs_file_info.is_dir() {
// Check if target path exists
// Strip trailing slash to avoid symlink resolution issues on broken symlinks
// (On macOS, symlink_metadata("path/") fails if path is a broken symlink)
let path_str = target_path.as_os_str().to_string_lossy();
let check_path = if path_str.ends_with('/') {
// Use the path without trailing slash, NOT the parent directory
Path::new(path_str.trim_end_matches('/'))
} else {
target_path.as_path()
};
if let Ok(metadata) = lfs::symlink_metadata(check_path) {
// If it's a directory or a symlink to a directory, we're done
if metadata.file_type().is_dir() {
log::trace!("mirror_dir: directory exists, skipping: {}", target_path.display());
continue;
}
// If it's a symlink (to a directory), skip - the symlink target directory will be used
if metadata.file_type().is_symlink() {
log::debug!("mirror_dir: symlink exists, skipping directory creation: {}", target_path.display());
continue;
}
// It's a file, remove it
log::debug!("mirror_dir: removing existing file to create directory: {}", target_path.display());
lfs::remove_file(&target_path)?;
}
log::debug!("mirror_dir: creating directory: {}", target_path.display());
lfs::create_dir_all_with_case_sensitivity(&target_path)?;
continue;
}
// Create parent directory if it doesn't exist
// No longer necessary, since filelist.txt always show dir before files under it
// if let Some(parent) = target_path.parent() {
// fs::create_dir_all(parent)
// .with_context(|| format!("Failed to create parent directory {}", parent.display()))?;
// }
mirror_file(&fs_file, &target_path, Path::new(fhs_file), fs_file_info.is_link(), link_type, can_reflink)?
}
Ok(())
}
/// Handle a single file (symlink or regular) in mirror_dir function
///
/// This function decides whether to call mirror_symlink_file() or mirror_regular_file()
/// based on the is_link parameter.
///
/// Parameters:
/// - fs_file: Path to the file in the store
/// - target_path: Where to create the file/symlink in the environment
/// - fhs_file: Relative path from store_fs_dir (used to determine if file is in /etc/)
/// - is_link: Whether the file is a symlink (true) or regular file (false)
/// - link_type: Link type to use (hardlink, symlink, move, runpath)
/// - can_reflink: Whether reflink (copy-on-write) is supported
pub fn mirror_file(
fs_file: &Path,
target_path: &Path,
fhs_file: &Path,
is_link: bool,
link_type: LinkType,
can_reflink: bool,
) -> Result<()> {
if is_link {
mirror_symlink_file(fs_file, target_path, link_type)
.with_context(|| format!("Failed to handle symlink file {}", fs_file.display()))
} else if is_config_file_path(fhs_file) {
mirror_config_file(fs_file, target_path, can_reflink)
.with_context(|| format!("Failed to handle config file {}", fs_file.display()))
} else {
mirror_regular_file(fs_file, target_path, fhs_file, link_type, can_reflink)
.with_context(|| format!("Failed to handle regular file {}", fs_file.display()))
}
}
/// Try to create a hardlink from source to target.
/// If hardlink fails (cross-device or other error), fall back to copying the file.
/// This is useful when files need to be actual files (not symlinks) for tools to work properly.
///
/// Parameters:
/// - source: Path to the source file
/// - target: Path to the target file
/// - preserve_permissions: If true, preserve file permissions when copying (ignored for hardlinks)
pub fn hard_link_or_copy(source: &Path, target: &Path, preserve_permissions: bool) -> Result<()> {
match fs::hard_link(source, target) {
Ok(()) => {
log::trace!("Created hardlink from {} to {}", source.display(), target.display());
Ok(())
}
Err(hardlink_err) => {
// Check if it's a cross-device error (EXDEV = 18)
if hardlink_err.raw_os_error() == Some(18) {
log::debug!("Cross-device hardlink not work for {} -> {}, falling back to copy",
source.display(), target.display());
} else {
// Other error, try copy as fallback
log::info!("Hardlink failed for {} -> {}: {}, falling back to copy",
source.display(), target.display(), hardlink_err);
}
lfs::copy(source, target)?;
// Preserve permissions if requested
if preserve_permissions {
utils::preserve_file_permissions(source, target)?;
}
Ok(())
}
}
}
/// Handle symlink files in mirror_dir function
///
/// This function processes symlinks that may point to either files or directories.
/// For top-level directory symlinks (sbin, bin, lib, lib64, lib32), it skips them
/// as they are handled by the environment setup process.
/// For other symlinks pointing to files, it creates a shortcut symlink.
///
/// Note: Files in gconv-modules.d/ are handled as regular files (not symlinks) in
/// mirror_regular_file(), so symlinks in that directory won't appear in the file list.
///
/// Examples:
/// - sbin -> usr/sbin (top-level dir symlink): skipped (handled by env setup)
/// - bin -> usr/bin (top-level dir symlink): skipped (handled by env setup)
/// - python3 -> python3.11 (file symlink): creates shortcut symlink
/// - /usr/bin/python3 -> /usr/bin/python3.11 (absolute file symlink): creates shortcut symlink
///
/// Parameters:
/// - fs_file: Path to the symlink in the store
/// - target_path: Where to create the symlink in the environment
/// - link_type: Link type to use (for Move, symlink is moved instead of copied)
fn mirror_symlink_file(fs_file: &Path, target_path: &Path, link_type: LinkType) -> Result<()> {
utils::remove_any_existing_file(target_path, true)?;
log::trace!("mirror_symlink_file: fs_file={}, target_path={}, link_type={:?}", fs_file.display(), target_path.display(), link_type);
// For Move link type, rename the symlink instead of copying
if link_type == LinkType::Move {
// Ensure parent directory exists
if let Some(parent) = target_path.parent() {
lfs::create_dir_all_with_case_sensitivity(parent)?;
}
// Try rename first (works on same filesystem)
if lfs::rename(fs_file, target_path).is_ok() {
return Ok(());
}
// Rename failed (likely cross-device), fall back to copy_symlink + remove
log::debug!("rename failed for symlink (likely cross-device), falling back to copy+delete: {} -> {}", fs_file.display(), target_path.display());
copy_symlink(fs_file, target_path)
.with_context(|| format!("Failed to copy symlink from {} to {}", fs_file.display(), target_path.display()))?;
lfs::remove_file(fs_file)?;
return Ok(());
}
// Handle regular symlink (not pointing to directory)
copy_symlink(fs_file, target_path)
.with_context(|| format!("Failed to copy symlink from {} to {}", fs_file.display(), target_path.display()))?;
Ok(())
}
/// Handle regular files in mirror_dir function
///
/// This function processes regular files (not symlinks or directories).
/// Behavior depends on link_type:
/// - hardlink: prefer hardlink, fall back to symlink
/// - symlink: prefer symlink, use hard_link_or_copy for special files
/// - move: prefer mv (moves file from store to env)
/// - runpath: not supported for now
///
/// Examples:
/// - /etc/resolv.conf: copied to environment (preserves content)
/// - /usr/lib/gconv/gconv-modules.d/gconv-modules-extra.conf: hardlinked or copied
/// - /usr/bin/python3.11: symlinked to store location (or hardlinked/moved based on link_type)
/// - /usr/lib/libpython3.11.so: symlinked to store location (or hardlinked/moved based on link_type)
///
/// Parameters:
/// - fs_file: Path to the file in the store
/// - target_path: Where to create the file/symlink in the environment
/// - fhs_file: Relative path from store_fs_dir (used to determine if file is in /etc/)
/// - link_type: Link type to use (hardlink, symlink, move, runpath)
/// - can_reflink: Whether reflink (copy-on-write) is supported
/// Clean up existing target file or directory before linking
fn cleanup_existing_target(
fs_file: &Path,
target_path: &Path,
_fhs_file: &Path,
) -> Result<()> {
// Remove any existing file/dirs
if lfs::symlink_metadata(target_path).is_ok() {
// On upgrade, it's normal to overwrite old files from previous version
log::trace!("File already exists, overwriting {} with {}", target_path.display(), fs_file.display());
// Check if target path is a directory and handle accordingly
if lfs::symlink_metadata(target_path).map(|m| m.file_type().is_dir()).unwrap_or(false) {
lfs::remove_dir_all(target_path)?;
} else {
lfs::remove_file(target_path)?;
}
}
Ok(())
}
fn mirror_regular_file(fs_file: &Path, target_path: &Path, fhs_file: &Path, link_type: LinkType, can_reflink: bool) -> Result<()> {
log::trace!("mirror_regular_file: fs_file={}, target_path={}, link_type={:?}", fs_file.display(), target_path.display(), link_type);
// For Move/Runpath link type, check if source file exists
// If not, the store may have been consumed by another environment
if matches!(link_type, LinkType::Move | LinkType::Runpath) && !lfs::exists_on_host(fs_file) {
// Check if target already exists (file was moved from this store to another env)
// In this case, we need to copy from the existing location
if let Ok(metadata) = lfs::symlink_metadata(target_path) {
if metadata.is_file() || metadata.file_type().is_symlink() {
log::debug!("Source file {} doesn't exist but target {} already exists - skipping",
fs_file.display(), target_path.display());
return Ok(());
}
}
// Try to find the file in consumed store's env
if let Some(store_path) = fs_file.parent().and_then(|p| p.parent()) {
if let Some((_existing_env_path, existing_file_path)) = crate::store::find_consumed_store_file(store_path, fhs_file) {
log::info!("Store consumed, copying from existing env: {} -> {}", existing_file_path.display(), target_path.display());
// Copy from existing environment instead of moving from store
lfs::copy(&existing_file_path, target_path)?;
return Ok(());
}
}
return Err(eyre::eyre!(
"Source file {} doesn't exist for Move link type. The store may have been consumed by another environment.",
fs_file.display()
));
}
// Ensure parent directory exists before creating any links
if let Some(parent) = target_path.parent() {
if !lfs::exists_on_host(parent) {
lfs::create_dir_all(parent)?;
}
}
// Clean up existing target (remove old file/dir if needed)
cleanup_existing_target(fs_file, target_path, fhs_file)?;
// Apply link_type for regular files
match link_type {
LinkType::Hardlink => {
// Try hardlink first, fall back to symlink on failure
match lfs::hard_link(fs_file, target_path) {
Ok(()) => {
log::trace!("Created hardlink from {} to {}", fs_file.display(), target_path.display());
}
Err(e) => {
// Fall back to symlink if hardlink fails
log::debug!("Hardlink not work for {} -> {}: {}, falling back to symlink",
fs_file.display(), target_path.display(), e);
symlink_or_copy(fs_file, target_path, fhs_file)?;
}
}
}
LinkType::Symlink => {
// Current behavior: prefer symlink
symlink_or_copy(fs_file, target_path, fhs_file)?;
}
LinkType::Reflink => {
// Try reflink first, fall back to copy
lfs::reflink_or_copy(fs_file, target_path, can_reflink)?;
}
LinkType::Move => {
// Move file from store to env (will be removed from store later)
// Use rename_or_copy_delete to handle cross-filesystem case
lfs::rename_or_copy_delete(fs_file, target_path)?;
}
LinkType::Runpath => {
// Move file from store to env (will be removed from store later)
// Same as Move - use rename_or_copy_delete for cross-filesystem fallback
lfs::rename_or_copy_delete(fs_file, target_path)?;
}
}
Ok(())
}
pub fn symlink_or_copy(fs_file: &Path, target_path: &Path, fhs_file: &Path) -> Result<()> {
if let Some(preserve_permissions) = needs_hard_link_or_copy(fhs_file) {
// Certain paths (script-language trees, gconv modules, rustc driver) must be
// materialized as real files in env_root (via hardlink or copy) so that
// their consumers behave correctly when resolving modules or sysroots.
hard_link_or_copy(fs_file, target_path, preserve_permissions)?;
return Ok(());
}
lfs::symlink_file_for_virtiofs(fs_file, target_path)
}
/// Decide whether a regular file should be materialized via hardlink-or-copy
/// instead of left as a symlink into the store, and if so whether permissions
/// should be preserved.
///
/// Returns:
/// - Some(false) -> use hard_link_or_copy(..., preserve_permissions = false)
/// - Some(true) -> use hard_link_or_copy(..., preserve_permissions = true)
/// - None -> caller should fall back to symlink()
fn needs_hard_link_or_copy(fhs_file: &Path) -> Option<bool> {
let rel = fhs_file.to_string_lossy();
// Many script runtimes (Node.js, Python, Ruby, Perl, Lua, PHP, Tcl, Guile, OCaml,
// R, Haskell, Julia, Erlang, etc.) derive their
// module search paths from the real path of the interpreter or library tree. If
// these trees are left as symlinks into the store (whose paths may contain colons
// or other special characters), the runtime can end up resolving modules relative
// to the store path instead of env_root, and miss dependencies that are properly
// mirrored only under env_root.
//
// For known "well-known" library roots, we therefore materialize real files
// (via hardlink or copy) in env_root so that module resolution stays within
// env_root.
// Node.js global modules (various distro layouts)
if rel.starts_with("usr/lib/node_modules/")
|| rel.starts_with("usr/share/nodejs/")
// Python site/dist-packages under common prefixes
|| rel.contains("/site-packages/")
|| rel.contains("/dist-packages/")
// Ruby standard libs and gems
|| rel.starts_with("usr/lib/ruby/")
|| rel.contains("/gems/")
// Perl libs
|| rel.starts_with("usr/share/perl5/")
|| rel.starts_with("usr/lib/perl5/")
|| rel.contains("/site_perl/")
// Lua modules
|| rel.starts_with("usr/share/lua/")
|| rel.starts_with("usr/lib/lua/")
// PHP common library tree in distros
|| rel.starts_with("usr/share/php/")
// Tcl / Tk modules
|| rel.starts_with("usr/lib/tcl")
|| rel.starts_with("usr/share/tcltk/")
|| rel.starts_with("usr/lib/tk")
// Guile scheme modules
|| rel.starts_with("usr/share/guile/")
|| rel.starts_with("usr/lib/guile/")
// OCaml libraries (findlib / compiler libs)
|| rel.starts_with("usr/lib/ocaml/")
// R libraries
|| rel.starts_with("usr/lib/R/library/")
// Haskell / GHC libraries
|| rel.starts_with("usr/lib/ghc/")
|| rel.starts_with("usr/lib/haskell-packages/")
// Julia modules
|| rel.starts_with("usr/share/julia/")
|| rel.starts_with("usr/lib/julia/")
// Erlang / Elixir beam libraries
|| rel.starts_with("usr/lib/erlang/lib/")
{
return Some(true);
}
// rustc determines its sysroot based on the location of librustc_driver*.so.
// If this .so is a symlink into the store and that path contains a colon
// (e.g. "__rust__1:1.92.0-1__x86_64"), then Cargo will see a sysroot
// under that store path and construct LD_LIBRARY_PATH entries that
// include a colon *inside* a path component, which std::env::join_paths
// rejects with "path segment contains separator `:`".
//
// To avoid this, make librustc_driver*.so an actual file in the env_root
// (via hardlink or copy) instead of a symlink into the store, so that
// `rustc --print=sysroot` returns the env_root-based sysroot.
if rel.starts_with("usr/lib/librustc_driver") {
return Some(true);
}
// iconvconfig handles ONLY normal files, NOT symlinks
if rel.contains("/gconv-modules.d/") {
return Some(false);
}
None
}
/// Usr-merge symlinks that need relative path adjustment
/// Format: (symlink_name, symlink_target, path_prefix_to_strip)
/// When a relative symlink is placed under one of these directories,
/// the path needs adjustment because the directory is a symlink.
const USR_MERGE_SYMLINKS: &[(&str, &str)] = &[
("bin", "usr/bin"),
("sbin", "usr/sbin"),
("lib", "usr/lib"),
("lib64", "usr/lib64"),
];
/// Adjust relative symlink target for usr-merge layout.
///
/// When a package has a relative symlink like `bin/go -> ../go/bin/go`,
/// and the environment uses usr-merge layout where `bin -> usr/bin`,
/// the symlink would be created at `usr/bin/go -> ../go/bin/go`,
/// which resolves to `usr/go/bin/go` instead of `go/bin/go`.
///
/// This function detects this case and adjusts the path:
/// - `../go/bin/go` becomes `../../go/bin/go`
fn adjust_symlink_target_for_usr_merge(
link_target: &Path,
target_path: &Path,
env_root: &Path,
) -> PathBuf {
// Only adjust relative paths starting with ../
let link_target_str = match link_target.to_str() {
Some(s) if s.starts_with("../") => s,
_ => return link_target.to_path_buf(),
};
// Check if target_path is under a usr-merge symlink directory
// Get the relative path from env_root
let rel_path = match target_path.strip_prefix(env_root) {
Ok(p) => p,
Err(_) => return link_target.to_path_buf(),
};
// Get the first component (should be bin, sbin, lib, or lib64)
let first_component = match rel_path.components().next() {
Some(c) => c.as_os_str().to_string_lossy().to_string(),
None => return link_target.to_path_buf(),
};
// Check if this is a usr-merge symlink
for &(symlink_name, _symlink_target) in USR_MERGE_SYMLINKS {
if first_component == symlink_name {
// Check if the directory in env is actually a symlink/junction
let symlink_path = env_root.join(symlink_name);
if lfs::is_symlink_or_junction(&symlink_path) {
// The symlink is in a usr-merge directory
// Need to add one more "../" to account for the extra level
// For example: ../go/bin/go -> ../../go/bin/go
log::debug!(
"Adjusting symlink target for usr-merge: {} -> ../../{}",
link_target_str,
&link_target_str[3..] // Skip the "../" prefix
);
return PathBuf::from(format!("../../{}", &link_target_str[3..]));
}
}
}
link_target.to_path_buf()
}
// Copy symlink, adjusting relative paths for usr-merge layout
fn copy_symlink(fs_file: &Path, target_path: &Path) -> Result<()> {
let link_target = fs::read_link(fs_file)
.with_context(|| format!("Failed to read symlink target for {}", fs_file.display()))?;
log::trace!("copy_symlink: fs_file={}, link_target={:?}", fs_file.display(), link_target);
// Try to get env_root from target_path (assume it's 2 levels up from bin/ or similar)
// target_path is like /path/to/env/bin/go or /path/to/env/usr/bin/go
// We need to find the env_root to check for usr-merge symlinks
let adjusted_target = if link_target.is_relative() && link_target.starts_with("..") {
// Try to find env_root by looking for the bin/sbin/lib symlinks
if let Some(parent) = target_path.parent() {
if let Some(env_root) = find_env_root_from_path(parent) {
adjust_symlink_target_for_usr_merge(&link_target, target_path, &env_root)
} else {
link_target
}
} else {
link_target
}
} else {
link_target
};
// Determine symlink type by checking the existing symlink in store.
// On Windows, symlink_dir has FILE_ATTRIBUTE_DIRECTORY flag set.
// This is reliable for newly created symlinks.
// For legacy symlinks created before the fix, fall back to checking resolved target.
let mut is_dir = lfs::is_directory_symlink(fs_file);
// Fallback: if symlink type detection says file but target exists and is a directory,
// correct the type. This handles legacy symlinks created with wrong type.
if !is_dir {
// Resolve the symlink target relative to its parent directory
if let Some(parent) = fs_file.parent() {
let resolved_target = lfs::normalize_path_components(&parent.join(&adjusted_target));
if resolved_target.is_dir() {
log::debug!(
"copy_symlink: correcting symlink type for {} - target {} is a directory",
fs_file.display(),
resolved_target.display()
);
is_dir = true;
}
}
// Also check env_root for cross-package directory symlinks
if !is_dir {
if let Some(parent) = target_path.parent() {
if let Some(env_root) = find_env_root_from_path(parent) {
let env_target = if adjusted_target.is_absolute() {
let relative = adjusted_target.strip_prefix("/").unwrap_or(&adjusted_target);
env_root.join(relative)
} else {
parent.join(&adjusted_target)
};
if env_target.is_dir() {
log::debug!(
"copy_symlink: correcting symlink type for {} - target {} is a directory in env_root",
fs_file.display(),
env_target.display()
);
is_dir = true;
}
}
}
}
}
let adjusted_target = lfs::sanitize_path_for_windows(&adjusted_target);
log::debug!(
"copy_symlink: fs_file={}, adjusted_target={}, is_directory_symlink={}",
fs_file.display(),
adjusted_target.display(),
is_dir
);
// Use symlink_file_for_virtiofs or symlink_dir_for_virtiofs explicitly based on symlink type.
if is_dir {
lfs::symlink_dir_for_virtiofs(&adjusted_target, target_path)
} else {
lfs::symlink_file_for_virtiofs(&adjusted_target, target_path)
}
.with_context(|| format!("Failed to create symlink {} -> {}", target_path.display(), adjusted_target.display()))?;
Ok(())
}
/// Find env_root from a path by looking for usr-merge symlinks (bin, sbin, lib, lib64)
fn find_env_root_from_path(path: &Path) -> Option<PathBuf> {
// Walk up the path to find a directory containing usr-merge symlinks
let mut current = path;
while let Some(parent) = current.parent() {
// Check if this could be an env root by looking for typical structure
if parent.join("usr").is_dir() {
// Check for usr-merge symlinks/junctions
let has_bin_symlink = lfs::is_symlink_or_junction(&parent.join("bin"));
let has_usr_bin = crate::dirs::path_join(parent, &["usr", "bin"]).is_dir();
if has_bin_symlink && has_usr_bin {
return Some(parent.to_path_buf());
}
}
current = parent;
}
None
}