ic-host-fs 0.27.3

Local host filesystem reads, durable publication and file locking
Documentation
use super::*;
use crate::test_support::fixture;
use std::os::unix::fs::{MetadataExt as _, PermissionsExt, symlink};

#[test]
fn writes_in_place_and_preserves_unprotected_symlinks_and_permissions() {
    let fixture = fixture();
    let protected = fixture.path().join("protected");
    let output = fixture.path().join("exports/output");
    fs::write(&protected, b"retained").unwrap();
    write_non_aliasing(&output, b"long initial output", &[&protected], 64).unwrap();
    fs::set_permissions(&output, fs::Permissions::from_mode(0o640)).unwrap();
    let inode = fs::metadata(&output).unwrap().ino();
    let link = fixture.path().join("link");
    symlink("exports/output", &link).unwrap();
    write_non_aliasing(&link, b"short", &[&protected], 64).unwrap();
    assert_eq!(fs::read(&output).unwrap(), b"short");
    assert_eq!(fs::read(&protected).unwrap(), b"retained");
    assert!(fs::symlink_metadata(link).unwrap().is_symlink());
    let metadata = fs::metadata(output).unwrap();
    assert_eq!(metadata.ino(), inode);
    assert_eq!(metadata.mode() & 0o777, 0o640);
}

#[test]
fn rejects_existing_path_and_inode_aliases() {
    let fixture = fixture();
    fs::create_dir(fixture.path().join("cache")).unwrap();
    let protected = fixture.path().join("cache/catalog");
    fs::write(&protected, b"retained").unwrap();
    let link = fixture.path().join("link");
    symlink("cache/catalog", &link).unwrap();
    let parent_link = fixture.path().join("cache-link");
    symlink("cache", &parent_link).unwrap();
    let hard_link = fixture.path().join("hard-link");
    fs::hard_link(&protected, &hard_link).unwrap();
    let cwd = std::env::current_dir().unwrap().canonicalize().unwrap();
    let mut relative = PathBuf::new();
    for _ in cwd.components().skip(1) {
        relative.push("..");
    }
    relative.push(protected.strip_prefix("/").unwrap());
    for output in [
        protected.clone(),
        fixture.path().join("cache/../cache/catalog"),
        relative,
        link,
        parent_link.join("catalog"),
        hard_link,
    ] {
        let error = write_non_aliasing(&output, b"replacement", &[&protected], 64).unwrap_err();
        assert!(matches!(
            error,
            NonAliasingWriteError::Alias { protected: path, .. } if path == protected
        ));
        assert_eq!(fs::read(&protected).unwrap(), b"retained");
    }
}

#[test]
fn refuses_missing_aliases_before_parent_creation() {
    let fixture = fixture();
    let protected = fixture.path().join("cache/catalog");
    let link = fixture.path().join("link");
    symlink("cache/catalog", &link).unwrap();
    let parent_link = fixture.path().join("cache-link");
    symlink("cache", &parent_link).unwrap();
    for output in [link, parent_link.join("catalog"), protected.clone()] {
        assert!(matches!(
            write_non_aliasing(&output, b"replacement", &[&protected], 64),
            Err(NonAliasingWriteError::Alias { .. })
        ));
        assert!(!protected.parent().unwrap().exists());
    }
}

#[test]
fn enforces_symlink_expansion_limits_before_mutation() {
    let fixture = fixture();
    let protected = fixture.path().join("cache/catalog");
    for index in 0..64 {
        symlink(
            format!("missing/../link-{}", index + 1),
            fixture.path().join(format!("link-{index}")),
        )
        .unwrap();
    }
    symlink("cache/catalog", fixture.path().join("link-64")).unwrap();
    assert!(matches!(
        validate_non_aliasing(&fixture.path().join("link-1"), &[&protected], 64),
        Err(NonAliasingWriteError::Alias { .. })
    ));
    let error =
        write_non_aliasing(&fixture.path().join("link-0"), b"x", &[&protected], 64).unwrap_err();
    assert!(matches!(
        error,
        NonAliasingWriteError::Write { source, .. } if source.kind() == io::ErrorKind::InvalidInput
    ));
    assert!(!protected.parent().unwrap().exists());
}

#[test]
fn retains_traversal_errors_and_existing_bytes() {
    let fixture = fixture();
    let file = fixture.path().join("file");
    let protected = fixture.path().join("cache/catalog");
    fs::write(&file, b"retained").unwrap();
    for path in ["file/child", "missing/../file/child"] {
        let error =
            write_non_aliasing(&fixture.path().join(path), b"x", &[&protected], 64).unwrap_err();
        assert!(matches!(
            error,
            NonAliasingWriteError::Write { source, .. }
                if source.kind() == io::ErrorKind::NotADirectory
        ));
        assert_eq!(fs::read(&file).unwrap(), b"retained");
        assert!(!protected.parent().unwrap().exists());
    }
}

#[test]
fn opened_descriptor_is_checked_after_pathname_replacement() {
    let fixture = fixture();
    let output = fixture.path().join("output");
    let protected = fixture.path().join("protected");
    fs::write(&output, b"retained").unwrap();
    validate_non_aliasing(&output, &[&protected], 64).unwrap();
    let mut descriptor = fs::OpenOptions::new().write(true).open(&output).unwrap();
    fs::hard_link(&output, &protected).unwrap();
    fs::rename(&output, fixture.path().join("original")).unwrap();
    fs::write(&output, b"replacement pathname").unwrap();
    validate_non_aliasing(&output, &[&protected], 64).unwrap();
    assert!(matches!(
        write_opened(
            &mut descriptor,
            &output,
            b"destructive",
            &[&protected],
            fs::File::sync_all
        ),
        Err(NonAliasingWriteError::Alias { .. })
    ));
    assert_eq!(fs::read(protected).unwrap(), b"retained");
    assert_eq!(fs::read(output).unwrap(), b"replacement pathname");
}

#[test]
fn opened_write_failure_retains_its_stage_and_cause() {
    let fixture = fixture();
    let output = fixture.path().join("output");
    fs::write(&output, b"retained").unwrap();
    let mut read_only = fs::File::open(&output).unwrap();
    let error = write_opened(&mut read_only, &output, b"x", &[], fs::File::sync_all).unwrap_err();
    assert!(std::error::Error::source(&error).is_some());
    assert!(matches!(error, NonAliasingWriteError::Write { path, .. } if path == output));
    assert_eq!(fs::read(output).unwrap(), b"retained");
}

#[test]
fn synchronization_failure_retains_complete_output_and_original_cause() {
    let fixture = fixture();
    let output = fixture.path().join("output");
    fs::write(&output, b"original longer bytes").unwrap();
    let mut file = fs::OpenOptions::new().write(true).open(&output).unwrap();
    let error = write_opened(&mut file, &output, b"new", &[], |opened| {
        assert_eq!(opened.metadata().unwrap().len(), 3);
        assert_eq!(fs::read(&output).unwrap(), b"new");
        Err(io::Error::from_raw_os_error(5))
    })
    .unwrap_err();
    assert!(matches!(error, NonAliasingWriteError::Sync { ref path, .. } if path == &output));
    let source = std::error::Error::source(&error)
        .unwrap()
        .downcast_ref::<io::Error>()
        .unwrap();
    assert_eq!(source.raw_os_error(), Some(5));
    assert_eq!(fs::read(&output).unwrap(), b"new");
}