artifactize-core 0.13.0

The library behind artifactize: Artifacts, evals, reviews, the monitor and review models, and the shared operations every interface calls.
Documentation
use std::collections::BTreeSet;

use serde::{Deserialize, Deserializer, de::Error};
use serde_json::Number;

// Optional fields may be absent, but explicit JSON null is not an omission.
pub(super) fn present<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
    D: Deserializer<'de>,
    T: Deserialize<'de>,
{
    T::deserialize(deserializer).map(Some)
}

use crate::types::MAX_SAFE_JSON_INTEGER as MAX_SAFE_INTEGER;
/// Bound declared-input fan-out and validation work during discovery.
const MAX_INPUTS: usize = 64;
/// Keep Artifact/eval/variant names bounded in qualified IDs and graph/audit output.
const MAX_IDENTIFIER_BYTES: usize = 64;
/// Bound declared execution paths in JSON-client UTF-16 units before scoped resolution.
const MAX_PATH_UNITS: usize = 1024;

fn integer<'de, D>(deserializer: D, max: u64, message: &str) -> Result<u64, D::Error>
where
    D: Deserializer<'de>,
{
    let number = Number::deserialize(deserializer)?;
    // Integral JSON numbers are accepted even when written as 1.0 or 1e3.
    let value = number.as_f64().ok_or_else(|| D::Error::custom(message))?;
    if value < 1.0 || value > max as f64 || value.fract() != 0.0 {
        return Err(D::Error::custom(message));
    }
    Ok(value as u64)
}

pub(super) fn positive_integer<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
    D: Deserializer<'de>,
{
    integer(
        deserializer,
        MAX_SAFE_INTEGER,
        "Expected a positive safe integer (1–9007199254740991).",
    )
    .map(Some)
}

/// Millisecond timeouts retain the signed 32-bit external protocol bound.
pub const MAX_TIMEOUT_MS: u64 = 2_147_483_647;

pub(super) fn timeout_number<E: Error>(number: Number) -> Result<std::time::Duration, E> {
    let value = number
        .as_f64()
        .ok_or_else(|| E::custom("Invalid timeout_ms."))?;
    if value < 1.0 || value > MAX_TIMEOUT_MS as f64 || value.fract() != 0.0 {
        return Err(E::custom(
            "timeout_ms must be an integer from 1 through 2147483647.",
        ));
    }
    Ok(std::time::Duration::from_millis(value as u64))
}

pub(super) fn timeout<'de, D>(deserializer: D) -> Result<Option<std::time::Duration>, D::Error>
where
    D: Deserializer<'de>,
{
    timeout_number::<D::Error>(Number::deserialize(deserializer)?).map(Some)
}

pub(crate) mod milliseconds {
    use serde::{Deserialize, Deserializer, Serializer};
    use std::time::Duration;
    pub fn serialize<S: Serializer>(
        value: &Option<Duration>,
        serializer: S,
    ) -> Result<S::Ok, S::Error> {
        match value {
            Some(value) => serializer.serialize_u128(value.as_millis()),
            None => serializer.serialize_none(),
        }
    }
    pub fn deserialize<'de, D: Deserializer<'de>>(
        deserializer: D,
    ) -> Result<Option<Duration>, D::Error> {
        Option::<serde_json::Number>::deserialize(deserializer)?
            .map(super::timeout_number::<D::Error>)
            .transpose()
    }
}

pub(crate) fn identifier(value: &str, label: &str) -> Result<(), String> {
    if value.is_empty()
        || value.len() > MAX_IDENTIFIER_BYTES
        || !value.as_bytes()[0].is_ascii_alphanumeric()
        || !value
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
    {
        return Err(format!(
            "{label} must match [A-Za-z0-9][A-Za-z0-9_-]{{0,63}}."
        ));
    }
    Ok(())
}

pub(super) fn text(value: &str, label: &str) -> Result<(), String> {
    if value.trim().is_empty() {
        return Err(format!("{label} must be nonblank text."));
    }
    Ok(())
}

pub(super) fn tags(values: &[String]) -> Result<(), String> {
    if values.len() > MAX_INPUTS || values.iter().collect::<BTreeSet<_>>().len() != values.len() {
        return Err("tags must contain at most 64 unique strings.".into());
    }
    for value in values {
        text(value, "Artifact tag")?;
        if value.bytes().any(|byte| byte.is_ascii_control()) {
            return Err("Artifact tags must not contain ASCII control characters.".into());
        }
    }
    Ok(())
}

pub(super) fn script(command: &str, args: &[crate::scope::Argument]) -> Result<(), String> {
    text(command, "Script command")?;
    if command.bytes().any(|byte| byte.is_ascii_control())
        || args.iter().any(|arg| arg.to_string().contains('\0'))
    {
        return Err(
            "A script requires a fixed command without control characters and string args without NUL."
                .into(),
        );
    }
    Ok(())
}

pub(crate) fn path(value: &str) -> Result<(), String> {
    if value.encode_utf16().count() > MAX_PATH_UNITS
        || crate::platform::paths::drive_rooted(value)
        || value
            .bytes()
            .any(|byte| byte.is_ascii_control() || byte == b'\\')
        || value
            .split('/')
            .any(|part| part.is_empty() || part == "." || part == "..")
    {
        return Err("Execution input must be a safe project-relative path.".into());
    }
    Ok(())
}

pub(super) fn paths(values: &[crate::config::LogicalPath], label: &str) -> Result<(), String> {
    if values.len() > MAX_INPUTS || values.iter().collect::<BTreeSet<_>>().len() != values.len() {
        return Err(format!(
            "{label} must contain at most 64 unique project-relative paths."
        ));
    }
    for value in values {
        path(value).map_err(|message| format!("{label}: {message}"))?;
    }
    Ok(())
}