artifactize 0.5.2

Declare Artifacts and the evals that review them (tests, LLM reviews, human sign-offs), and reuse every verdict while its fingerprints are unchanged.
Documentation
use std::{
    fs::{self, File, OpenOptions, TryLockError},
    io::{Read, Write},
    path::{Path, PathBuf},
    time::Duration,
};

use serde::{Serialize, de::DeserializeOwned};

use crate::platform;

/// An owner-only directory under `$STATE/auth/` of 0600, single-link, no-follow files,
/// replaced atomically. On Windows, owner-only means a protected DACL for the current user.
pub(super) struct Storage {
    pub directory: PathBuf,
}

impl Storage {
    pub fn new(state: Option<&Path>, repo: Option<&Path>) -> Result<Self, String> {
        Self::open(state, repo, true)
    }

    pub fn inspect(state: Option<&Path>, repo: Option<&Path>) -> Result<Self, String> {
        Self::open(state, repo, false)
    }

    fn open(state: Option<&Path>, repo: Option<&Path>, create: bool) -> Result<Self, String> {
        let directory = crate::store::state_dir(state)?.join("auth");
        let directory =
            crate::workspace::canonical_target(&directory).map_err(|e| e.to_string())?;
        let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
        let input = platform::canonicalize(repo.unwrap_or(&cwd)).map_err(|e| e.to_string())?;
        let input = if input.is_file() {
            input.parent().unwrap()
        } else {
            &input
        };
        if repo.is_some() {
            crate::workspace::outside_workspace(input, &directory).map_err(|e| e.to_string())?;
        }
        for ancestor in input.ancestors().chain(directory.ancestors()) {
            if ancestor.join(".git").exists() || ancestor.join("artifactize.json").exists() {
                crate::workspace::outside_workspace(ancestor, &directory)
                    .map_err(|e| e.to_string())?;
            }
        }
        if create {
            platform::create_private_dir_all(&directory).map_err(|e| e.to_string())?;
        }
        let private = match platform::is_private_dir(&directory) {
            Ok(private) => private,
            Err(error) if !create && error.kind() == std::io::ErrorKind::NotFound => {
                return Ok(Self { directory });
            }
            Err(error) => return Err(error.to_string()),
        };
        // This is an application-owned directory, not the user's state root.
        if !private {
            return Err("Auth directory must have owner-only permissions (0700).".into());
        }
        Ok(Self { directory })
    }

    /// Hold `<name>.lock` until the returned file drops, serializing credential
    /// refreshes across processes.
    pub async fn lock(&self, name: &str) -> Result<File, String> {
        let file = platform::open_no_follow(
            platform::private_options()
                .read(true)
                .write(true)
                .create(true)
                .truncate(false),
            &self.directory.join(format!("{name}.lock")),
        )
        .map_err(|e| e.to_string())?;
        check_private_file(&file)?;
        loop {
            match file.try_lock() {
                Ok(()) => return Ok(file),
                Err(TryLockError::WouldBlock) => {
                    tokio::time::sleep(Duration::from_millis(25)).await
                }
                Err(TryLockError::Error(error)) => return Err(error.to_string()),
            }
        }
    }

    pub fn read<T: DeserializeOwned>(&self, name: &str) -> Result<Option<T>, String> {
        let file = match platform::open_no_follow(
            OpenOptions::new().read(true),
            &self.directory.join(name),
        ) {
            Ok(file) => file,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(error) => return Err(error.to_string()),
        };
        check_private_file(&file)?;
        let mut data = Vec::new();
        file.take(1024 * 1024 + 1)
            .read_to_end(&mut data)
            .map_err(|e| e.to_string())?;
        if data.len() > 1024 * 1024 {
            return Err("Credential file is too large.".into());
        }
        serde_json::from_slice(&data)
            .map(Some)
            .map_err(|_| format!("Invalid credential file {name}; sign in again."))
    }

    pub fn save(&self, name: &str, value: &impl Serialize) -> Result<(), String> {
        let mut file =
            tempfile::NamedTempFile::new_in(&self.directory).map_err(|e| e.to_string())?;
        platform::restrict_file(file.as_file()).map_err(|e| e.to_string())?;
        serde_json::to_writer(&mut file, value)
            .map_err(|_| "Cannot encode credentials.".to_owned())?;
        file.flush().map_err(|e| e.to_string())?;
        file.as_file().sync_all().map_err(|e| e.to_string())?;
        // std's rename also replaces a file that another process is still reading, which a
        // plain MoveFileEx, as `persist` uses on Windows, refuses.
        let temporary = file.into_temp_path().keep().map_err(|e| e.to_string())?;
        if let Err(error) = fs::rename(&temporary, self.directory.join(name)) {
            let _ = fs::remove_file(&temporary);
            return Err(error.to_string());
        }
        self.sync()
    }

    pub fn remove(&self, name: &str) -> Result<(), String> {
        match fs::remove_file(self.directory.join(name)) {
            Ok(()) => self.sync(),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(error) => Err(error.to_string()),
        }
    }

    fn sync(&self) -> Result<(), String> {
        platform::sync_dir(&self.directory).map_err(|e| e.to_string())
    }
}

fn check_private_file(file: &File) -> Result<(), String> {
    if !platform::is_private_file(file).map_err(|e| e.to_string())? {
        return Err("Auth files must be regular, single-link, owner-only files (0600).".into());
    }
    Ok(())
}