cargo-hold 1.3.4

cargo-hold: A CI tool to ensure Cargo's incremental compilation is reliable by managing your caches intelligently
Documentation
use std::cmp::max;
use std::fs::OpenOptions;
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

#[cfg(test)]
mod tests;

const NANOS_PER_SECOND: u128 = 1_000_000_000;

/// Compute a duration from nanoseconds with saturation at [`Duration::MAX`].
///
/// Returns the saturated duration along with a flag indicating whether the
/// input exceeded the representable range.
pub fn saturating_duration_from_nanos(nanos: u128) -> (Duration, bool) {
    let seconds = nanos / NANOS_PER_SECOND;
    if seconds > u64::MAX as u128 {
        return (Duration::MAX, true);
    }

    let nanos_remainder = (nanos % NANOS_PER_SECOND) as u32;
    (Duration::new(seconds as u64, nanos_remainder), false)
}

/// Compute a [`SystemTime`] from nanoseconds with saturation at
/// `UNIX_EPOCH + Duration::MAX`.
///
/// Returns the saturated timestamp along with a flag indicating whether the
/// input exceeded the representable range.
pub fn saturating_system_time_from_nanos(nanos: u128) -> (SystemTime, bool) {
    let (duration, saturated) = saturating_duration_from_nanos(nanos);
    (UNIX_EPOCH + duration, saturated)
}

use crate::error::{HoldError, Result};
use crate::state::{FileState, StateMetadata};

/// Reject paths that escape the repository root via `..` or absolute segments.
pub fn validate_repo_relative_path(path: &Path) -> Result<()> {
    if path.is_absolute() {
        return Err(HoldError::InvalidPath {
            message: format!("absolute path not allowed in metadata: {}", path.display()),
        });
    }

    if path
        .components()
        .any(|c| matches!(c, std::path::Component::ParentDir))
    {
        return Err(HoldError::InvalidPath {
            message: format!("path escapes repository root: {}", path.display()),
        });
    }

    Ok(())
}

/// Join a repository-relative path to the repo root after validation.
pub fn repo_relative_path(repo_root: &Path, relative: &Path) -> Result<std::path::PathBuf> {
    validate_repo_relative_path(relative)?;
    Ok(repo_root.join(relative))
}

/// Convert nanoseconds since UNIX_EPOCH to SystemTime
fn nanos_to_system_time(nanos: u128) -> SystemTime {
    saturating_system_time_from_nanos(nanos).0
}

/// Convert SystemTime to nanoseconds since UNIX_EPOCH
fn system_time_to_nanos(time: SystemTime) -> u128 {
    time.duration_since(UNIX_EPOCH)
        .unwrap_or(Duration::ZERO)
        .as_nanos()
}

/// Generates a monotonic timestamp that is guaranteed to be newer than any
/// timestamp in the metadata.
///
/// This function ensures that timestamps only move forward, even if the system
/// clock goes backwards (e.g., due to NTP adjustments or clock skew in CI
/// environments).
///
/// # Arguments
///
/// * `metadata` - The current state metadata to check for the maximum existing
///   timestamp
///
/// # Returns
///
/// A `SystemTime` that is guaranteed to be at least 1 nanosecond newer than any
/// timestamp in the metadata, or the current system time, whichever is later.
pub fn generate_monotonic_timestamp(metadata: &StateMetadata) -> SystemTime {
    // Get the maximum timestamp from metadata in nanos
    let max_metadata_nanos = metadata.max_mtime_nanos().unwrap_or(0);

    // Get the current system time in nanos
    let now_nanos = system_time_to_nanos(SystemTime::now());

    // Return the maximum of now and max_metadata_nanos + 1
    let monotonic_nanos = max(now_nanos, max_metadata_nanos + 1);

    nanos_to_system_time(monotonic_nanos)
}

/// Sets the modification time of a file.
///
/// This function checks for symbolic links before opening the file and rejects
/// them for security reasons.
///
/// # Arguments
///
/// * `path` - Path to the file
/// * `mtime` - The new modification time to set
///
/// # Errors
///
/// Returns an error if:
/// - The file cannot be opened for writing
/// - The path points to a symbolic link
/// - The timestamp cannot be set (e.g., permission denied)
pub fn set_file_mtime(path: &Path, mtime: SystemTime) -> Result<()> {
    // Check for symlinks before opening
    let metadata = std::fs::symlink_metadata(path).map_err(|source| HoldError::IoError {
        path: path.to_path_buf(),
        source,
    })?;

    // Reject symlinks
    if metadata.is_symlink() {
        return Err(HoldError::InvalidFileType(
            path.to_path_buf(),
            "Cannot set timestamp on symbolic links".to_string(),
        ));
    }

    // Reject directories
    if metadata.is_dir() {
        return Err(HoldError::InvalidFileType(
            path.to_path_buf(),
            "Cannot set timestamp on directories".to_string(),
        ));
    }

    // Open the file to get a handle
    let file = OpenOptions::new()
        .write(true)
        .open(path)
        .map_err(|source| HoldError::SetTimestampError(path.to_path_buf(), source))?;

    // Set the modification time
    file.set_modified(mtime)
        .map_err(|source| HoldError::SetTimestampError(path.to_path_buf(), source))?;

    Ok(())
}

/// Restores timestamps for a set of files based on their change status.
///
/// This is the core logic that enables Cargo's incremental compilation to work
/// correctly. Unchanged files get their original timestamps restored, while
/// modified and added files get a new monotonic timestamp.
///
/// # Arguments
///
/// * `repo_root` - The repository root path
/// * `unchanged_files` - Files that haven't changed (restore original
///   timestamps)
/// * `modified_files` - Files that have been modified (set new timestamp)
/// * `added_files` - Files that are newly tracked (set new timestamp)
/// * `new_mtime` - The new monotonic timestamp for modified/added files
///
/// # Errors
///
/// Returns an error if any file's timestamp cannot be set.
pub fn restore_timestamps(
    repo_root: &Path,
    unchanged_files: &[&FileState],
    modified_files: &[&Path],
    added_files: &[&Path],
    new_mtime: SystemTime,
) -> Result<()> {
    // Restore original timestamps for unchanged files
    for file_state in unchanged_files {
        validate_repo_relative_path(&file_state.path)?;
        let mtime = nanos_to_system_time(file_state.mtime_nanos);
        let full_path = repo_root.join(&file_state.path);
        set_file_mtime(&full_path, mtime)?;
    }

    // Set new timestamp for modified files
    for path in modified_files {
        let full_path = repo_relative_path(repo_root, path)?;
        set_file_mtime(&full_path, new_mtime)?;
    }

    // Set new timestamp for added files
    for path in added_files {
        let full_path = repo_relative_path(repo_root, path)?;
        set_file_mtime(&full_path, new_mtime)?;
    }

    Ok(())
}