eoka-server 0.2.3

Shared browser runtime and sidecar process for eoka browser automation
Documentation
use super::failure;
use eoka_protocol::ErrorDetail;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

pub struct Destination {
    pub output: PathBuf,
    pub staging: PathBuf,
}

impl Destination {
    pub fn prepare(output: &str, id: &str, expected: Option<u64>) -> Result<Self, ErrorDetail> {
        let path = Path::new(output);
        let parent = path
            .parent()
            .ok_or_else(|| failure("invalid_input", "Output needs a parent directory"))?;
        let name = path
            .file_name()
            .ok_or_else(|| failure("invalid_input", "Output needs a filename"))?;
        let parent = std::fs::canonicalize(parent).map_err(io_failure)?;
        let output = parent.join(name);
        match std::fs::symlink_metadata(&output) {
            Ok(_) => {
                return Err(failure(
                    "download_destination_exists",
                    "Output already exists",
                ))
            }
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
            Err(error) => return Err(io_failure(error)),
        }
        ensure_space(&parent, expected.unwrap_or(0))?;
        let staging = parent.join(format!(".eoka-download-{id}"));
        let mut builder = std::fs::DirBuilder::new();
        #[cfg(unix)]
        {
            use std::os::unix::fs::DirBuilderExt;
            builder.mode(0o700);
        }
        builder.create(&staging).map_err(io_failure)?;
        Ok(Self { output, staging })
    }

    pub fn publish(
        &self,
        guid: &str,
        received: u64,
        total: Option<u64>,
        expected: Option<u64>,
        gate: &Mutex<Publication>,
    ) -> Result<(), ErrorDetail> {
        if !safe_guid(guid) {
            return Err(failure(
                "download_invalid_event",
                "Invalid native download identifier",
            ));
        }
        let path = self.staging.join(guid);
        let mut options = std::fs::OpenOptions::new();
        options.read(true);
        #[cfg(unix)]
        {
            use std::os::unix::fs::OpenOptionsExt;
            options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
        }
        let file = options.open(&path).map_err(io_failure)?;
        let metadata = file.metadata().map_err(io_failure)?;
        if !metadata.is_file() {
            return Err(failure(
                "download_invalid_file",
                "Chrome did not produce a regular file",
            ));
        }
        let size = metadata.len();
        if size != received
            || total.is_some_and(|n| n != size)
            || expected.is_some_and(|n| n != size)
        {
            return Err(failure(
                "download_size_mismatch",
                "Saved file size does not match expected transfer size",
            ));
        }
        file.sync_all().map_err(io_failure)?;
        let mut state = gate.lock().unwrap_or_else(|e| e.into_inner());
        if *state == Publication::Canceled {
            return Err(failure(
                "download_canceled",
                "Download canceled before publication",
            ));
        }
        std::fs::hard_link(&path, &self.output).map_err(|error| {
            if error.kind() == std::io::ErrorKind::AlreadyExists {
                failure(
                    "download_destination_exists",
                    "Output appeared during transfer; staged file retained",
                )
            } else {
                io_failure(error)
            }
        })?;
        *state = Publication::Published;
        drop(state);
        let parent = self
            .output
            .parent()
            .ok_or_else(|| failure("download_io_error", "Output has no parent directory"))?;
        std::fs::File::open(parent)
            .and_then(|parent| parent.sync_all())
            .map_err(io_failure)?;
        std::fs::remove_file(path).map_err(io_failure)?;
        Ok(())
    }

    pub fn retained_path(&self) -> Option<String> {
        match std::fs::remove_dir(&self.staging) {
            Ok(()) => None,
            Err(_) if self.staging.exists() => Some(self.staging.to_string_lossy().into_owned()),
            Err(_) => None,
        }
    }
}

impl Drop for Destination {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir(&self.staging);
    }
}

#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Publication {
    Open,
    Canceled,
    Published,
}

pub fn safe_guid(guid: &str) -> bool {
    !guid.is_empty()
        && guid.len() <= 128
        && guid.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
}

pub fn io_failure(error: std::io::Error) -> ErrorDetail {
    failure(
        if error.raw_os_error() == Some(libc::ENOSPC) {
            "download_no_space"
        } else {
            "download_io_error"
        },
        error.to_string(),
    )
}

#[cfg(unix)]
pub fn ensure_space(path: &Path, needed: u64) -> Result<(), ErrorDetail> {
    use std::os::unix::ffi::OsStrExt;
    let path = std::ffi::CString::new(path.as_os_str().as_bytes())
        .map_err(|_| failure("invalid_input", "Invalid output path"))?;
    let mut result = std::mem::MaybeUninit::<libc::statvfs>::uninit();
    if unsafe { libc::statvfs(path.as_ptr(), result.as_mut_ptr()) } != 0 {
        return Err(io_failure(std::io::Error::last_os_error()));
    }
    let result = unsafe { result.assume_init() };
    let available = (result.f_bavail as u128) * (result.f_frsize as u128);
    if available < u128::from(needed.max(1024 * 1024)) {
        return Err(failure(
            "download_no_space",
            "Insufficient free space on destination filesystem",
        ));
    }
    Ok(())
}

#[cfg(not(unix))]
pub fn ensure_space(_path: &Path, _needed: u64) -> Result<(), ErrorDetail> {
    Err(failure(
        "download_unsupported",
        "Downloads currently require a Unix filesystem",
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn publication_verifies_size_and_never_replaces_existing_files() {
        let root = tempfile::tempdir().unwrap();
        let path = root.path().join("result.bin");
        let destination = Destination::prepare(path.to_str().unwrap(), "test", None).unwrap();
        let gate = Mutex::new(Publication::Open);
        std::fs::write(destination.staging.join("guid"), b"content").unwrap();
        assert_eq!(
            destination
                .publish("guid", 7, None, Some(8), &gate)
                .unwrap_err()
                .code,
            "download_size_mismatch"
        );
        assert!(!path.exists());
        std::fs::write(&path, b"existing").unwrap();
        assert_eq!(
            destination
                .publish("guid", 7, None, Some(7), &gate)
                .unwrap_err()
                .code,
            "download_destination_exists"
        );
        assert_eq!(std::fs::read(path).unwrap(), b"existing");
        assert!(destination.retained_path().is_some());
    }

    #[test]
    fn canceled_publication_retains_verified_bytes() {
        let root = tempfile::tempdir().unwrap();
        let path = root.path().join("result.bin");
        let destination = Destination::prepare(path.to_str().unwrap(), "cancel", None).unwrap();
        std::fs::write(destination.staging.join("guid"), b"content").unwrap();
        let gate = Mutex::new(Publication::Canceled);
        assert_eq!(
            destination
                .publish("guid", 7, None, None, &gate)
                .unwrap_err()
                .code,
            "download_canceled"
        );
        assert!(!path.exists());
        assert_eq!(
            std::fs::read(destination.staging.join("guid")).unwrap(),
            b"content"
        );
    }

    #[cfg(unix)]
    #[test]
    fn symlinks_and_nonregular_artifacts_are_rejected() {
        use std::os::unix::fs::symlink;
        let root = tempfile::tempdir().unwrap();
        let path = root.path().join("result.bin");
        symlink(root.path().join("missing"), &path).unwrap();
        assert!(Destination::prepare(path.to_str().unwrap(), "occupied", None).is_err());
        std::fs::remove_file(&path).unwrap();
        let destination = Destination::prepare(path.to_str().unwrap(), "safe", None).unwrap();
        let gate = Mutex::new(Publication::Open);
        std::fs::write(root.path().join("source"), b"content").unwrap();
        symlink(root.path().join("source"), destination.staging.join("guid")).unwrap();
        assert!(destination.publish("guid", 7, None, None, &gate).is_err());
        std::fs::remove_file(destination.staging.join("guid")).unwrap();
        std::fs::create_dir(destination.staging.join("guid")).unwrap();
        assert!(destination.publish("guid", 0, None, None, &gate).is_err());
        assert!(destination
            .publish("../source", 7, None, None, &gate)
            .is_err());
        assert!(!path.exists());
    }
}