ic-host-fs 0.12.7

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

#[test]
fn absolute_spaced_stage_publishes_validated_output_and_returns_value() {
    let fixture = Fixture::new();
    let output = fixture.root.join("parent with spaces/output file");
    let value = write_named_with(&output, |stage| -> io::Result<_> {
        assert!(stage.is_absolute());
        assert_eq!(stage.parent(), output.parent());
        let initial = fs::metadata(stage)?;
        assert_eq!(initial.len(), 0);
        fs::write(stage, b"validated output")?;
        assert_eq!(initial.ino(), fs::metadata(stage)?.ino());
        assert!(!output.exists());
        Ok(42)
    })
    .unwrap();
    assert_eq!(value, 42);
    assert_eq!(fs::read(&output).unwrap(), b"validated output");
    assert_eq!(fs::read_dir(output.parent().unwrap()).unwrap().count(), 1);
}

#[test]
fn producer_and_validation_errors_preserve_original_and_remove_owned_stage() {
    let fixture = Fixture::new();
    let output = fixture.root.join("output");
    fs::write(&output, b"old").unwrap();
    for bytes in [b"".as_slice(), b"invalid", b"partial"] {
        let error = write_named_with(&output, |stage| {
            fs::write(stage, bytes).unwrap();
            Err::<(), _>(vec![1, 2, 3])
        })
        .unwrap_err();
        assert!(
            matches!(error, NamedWriteError::Producer { source, cleanup_error: None } if source == [1, 2, 3])
        );
        assert_eq!(fs::read(&output).unwrap(), b"old");
        assert_eq!(fs::read_dir(&fixture.root).unwrap().count(), 1);
    }
}

#[test]
fn replaced_missing_or_linked_staging_is_never_published_or_blindly_removed() {
    let fixture = Fixture::new();
    for substitution in [
        "missing",
        "file",
        "symlink",
        "directory",
        "fifo",
        "hardlink",
    ] {
        let parent = fixture.root.join(substitution);
        fs::create_dir(&parent).unwrap();
        let output = parent.join("output");
        let other = parent.join("other");
        fs::write(&output, b"old").unwrap();
        fs::write(&other, b"unrelated").unwrap();
        let mut staged_path = None;
        let result = write_named_with(&output, |stage| -> io::Result<()> {
            staged_path = Some(stage.to_path_buf());
            fs::write(stage, b"produced")?;
            if substitution == "hardlink" {
                fs::hard_link(stage, parent.join("extra-link"))?;
            } else {
                fs::remove_file(stage)?;
                match substitution {
                    "missing" => {}
                    "file" => fs::write(stage, b"foreign")?,
                    "symlink" => symlink(&other, stage)?,
                    "directory" => fs::create_dir(stage)?,
                    "fifo" => assert!(
                        std::process::Command::new("/usr/bin/mkfifo")
                            .arg(stage)
                            .status()?
                            .success()
                    ),
                    _ => unreachable!(),
                }
            }
            Ok(())
        });
        let NamedWriteError::BeforePublication {
            source,
            cleanup_error,
        } = result.unwrap_err()
        else {
            panic!("identity drift must stop publication");
        };
        assert!(matches!(
            source.kind(),
            io::ErrorKind::InvalidData | io::ErrorKind::NotFound
        ));
        let stage = staged_path.unwrap();
        if matches!(substitution, "missing" | "hardlink") {
            assert!(cleanup_error.is_none());
            assert!(!stage.exists());
        } else {
            assert_eq!(cleanup_error.unwrap().kind(), io::ErrorKind::InvalidData);
            assert!(fs::symlink_metadata(&stage).is_ok());
        }
        assert_eq!(fs::read(&output).unwrap(), b"old");
        assert_eq!(fs::read(&other).unwrap(), b"unrelated");
    }
}

#[test]
fn parent_replacement_is_rejected_and_cleanup_uses_original_directory() {
    let fixture = Fixture::new();
    let parent = fixture.root.join("parent");
    let moved = fixture.root.join("moved");
    fs::create_dir(&parent).unwrap();
    let output = parent.join("output");
    fs::write(&output, b"old").unwrap();
    let result = write_named_with(&output, |stage| -> io::Result<()> {
        fs::write(stage, b"produced")?;
        fs::rename(&parent, &moved)?;
        fs::create_dir(&parent)?;
        fs::write(&output, b"foreign output")?;
        Ok(())
    });
    assert!(matches!(
        result,
        Err(NamedWriteError::BeforePublication {
            cleanup_error: None,
            ..
        })
    ));
    assert_eq!(fs::read(&output).unwrap(), b"foreign output");
    assert_eq!(fs::read(moved.join("output")).unwrap(), b"old");
    assert_eq!(fs::read_dir(moved).unwrap().count(), 1);
}

#[test]
fn sync_errors_have_correct_publication_state() {
    let fixture = Fixture::new();
    let output = fixture.root.join("output");
    fs::write(&output, b"old").unwrap();
    let result = commit_path_with_options(
        &output,
        REPLACE_OPTIONS,
        |_, file| {
            use std::io::Write as _;
            file.write_all(b"new")
        },
        None::<fn(&Path, &()) -> io::Result<()>>,
        |step, _| {
            if step == FileCommitStep::TemporaryFileSync {
                Err(io::Error::from_raw_os_error(5))
            } else {
                Ok(())
            }
        },
    );
    assert!(
        matches!(result, Err(NamedWriteError::BeforePublication { source, cleanup_error: None }) if source.raw_os_error() == Some(5))
    );
    assert_eq!(fs::read(&output).unwrap(), b"old");
    assert_eq!(fs::read_dir(&fixture.root).unwrap().count(), 1);

    let result = commit_path_with_options(
        &output,
        REPLACE_OPTIONS,
        |_, file| {
            use std::io::Write as _;
            file.write_all(b"new")
        },
        None::<fn(&Path, &()) -> io::Result<()>>,
        |step, _| {
            if step == FileCommitStep::FinalParentSync {
                Err(io::Error::from_raw_os_error(5))
            } else {
                Ok(())
            }
        },
    );
    assert!(
        matches!(result, Err(NamedWriteError::AfterPublication { source }) if source.raw_os_error() == Some(5))
    );
    assert_eq!(fs::read(&output).unwrap(), b"new");
    assert_eq!(fs::read_dir(&fixture.root).unwrap().count(), 1);
}

#[test]
fn replacement_after_sync_is_rejected_before_rename() {
    use std::cell::RefCell;
    let fixture = Fixture::new();
    let output = fixture.root.join("output");
    fs::write(&output, b"old").unwrap();
    let staged_path = RefCell::new(None);
    let result = commit_path_with_options(
        &output,
        REPLACE_OPTIONS,
        |stage, file| {
            use std::io::Write as _;
            staged_path.replace(Some(stage.to_path_buf()));
            file.write_all(b"produced")
        },
        None::<fn(&Path, &()) -> io::Result<()>>,
        |step, _| {
            if step == FileCommitStep::Publication {
                let borrowed = staged_path.borrow();
                let stage = borrowed.as_ref().unwrap();
                fs::remove_file(stage)?;
                fs::write(stage, b"foreign")?;
            }
            Ok(())
        },
    );
    assert!(matches!(
        result,
        Err(NamedWriteError::BeforePublication {
            cleanup_error: Some(_),
            ..
        })
    ));
    assert_eq!(fs::read(&output).unwrap(), b"old");
    assert_eq!(
        fs::read(staged_path.into_inner().unwrap()).unwrap(),
        b"foreign"
    );
}

#[test]
fn producer_error_retains_cleanup_rejection_and_error_source() {
    use std::error::Error as _;
    let fixture = Fixture::new();
    let output = fixture.root.join("output");
    let error = write_named_with(&output, |stage| {
        fs::remove_file(stage).unwrap();
        fs::write(stage, b"foreign").unwrap();
        Err::<(), _>(io::Error::from_raw_os_error(13))
    })
    .unwrap_err();
    assert_eq!(
        error
            .source()
            .unwrap()
            .downcast_ref::<io::Error>()
            .unwrap()
            .raw_os_error(),
        Some(13)
    );
    assert!(
        matches!(error, NamedWriteError::Producer { source, cleanup_error: Some(cleanup) }
        if source.raw_os_error() == Some(13) && cleanup.kind() == io::ErrorKind::InvalidData)
    );
    assert!(!output.exists());
    assert_eq!(fs::read_dir(&fixture.root).unwrap().count(), 1);
}

#[test]
fn display_keeps_phase_primary_cause_and_secondary_cleanup() {
    use std::error::Error as _;
    for error in [
        NamedWriteError::Producer {
            source: io::Error::other("producer failure"),
            cleanup_error: Some(io::Error::other("cleanup failure")),
        },
        NamedWriteError::BeforePublication {
            source: io::Error::other("filesystem failure"),
            cleanup_error: Some(io::Error::other("cleanup failure")),
        },
    ] {
        let text = error.to_string();
        assert!(text.contains(&error.source().unwrap().to_string()));
        assert!(text.contains("staging cleanup failed: cleanup failure"));
        match error {
            NamedWriteError::Producer { .. } => {
                assert!(text.starts_with("staged producer failed:"));
            }
            NamedWriteError::BeforePublication { .. } => {
                assert!(text.starts_with("publication failed before rename:"));
            }
            NamedWriteError::AfterPublication { .. } => unreachable!(),
        }
    }
}