aion-package 0.30.0

Archive validation, content hashing, and namespacing for Aion workflow packages.
Documentation
//! The RETAINED prior archive form of a declared command, for identity only.
//!
//! # Why this exists
//!
//! v0.27.0 reshaped [`super::contract::DeclaredCommandContract`] around body
//! lines without moving the package identity domain. Archives minted under
//! the prior form — `program` words plus `args` slots — recorded a content
//! hash computed over the PRIOR canonical command encoding, under the same
//! domain constants this build still uses. Re-encoding the translated shape
//! (what [`super::compat`] yields) produces different bytes, so a verifier
//! that only knows the current encoder would refuse every prior-form archive
//! as an integrity mismatch — bricking deployed stores on upgrade.
//!
//! So the prior encoding is retained here, verbatim as v0.26.0 released it,
//! exactly as [`crate::PackageContract::legacy_v5_canonical_bytes`] retains
//! the superseded `.v5` contract encoding: a VERIFICATION-ONLY migration
//! surface. The verifier hands each prior-form command's RAW archive JSON to
//! this module, which decodes it into the prior field set — independent of
//! the compat translation entirely, so no translation choice can leak into
//! identity — and re-encodes it exactly as the minting release did. Nothing
//! ever MINTS these bytes again: the emitter produces only the current form,
//! and calling this module anywhere except the verification path is a defect.
//!
//! The prior-form marker the translation stamps
//! ([`super::contract::DeclaredCommandContract::prior_form_refusal`]) plays
//! no part here: a prior-form archive's identity is the prior record
//! wholesale, computed from the raw bytes the minting release hashed —
//! identity never varies with the vintage of the reader.

use std::collections::BTreeMap;

use serde::Deserialize;

use super::contract::ArgvSlot;
use super::template::{FillPiece, FillTemplate};

/// The prior canonical command identity records of one archive's contract,
/// keyed by task queue and then by action name.
///
/// Empty for every current-form archive. The double keying follows the
/// contract's own structure (a declared command body belongs to exactly one
/// action on exactly one queue), so substitution during canonical encoding
/// needs no assumption about command-name uniqueness.
pub(crate) type PriorCommandIdentities = BTreeMap<String, BTreeMap<String, Vec<u8>>>;

/// A failure reading the prior command form out of raw contract JSON.
#[derive(Debug, thiserror::Error)]
pub(crate) enum PriorFormError {
    /// The contract entry is not valid JSON. The typed decode path reports
    /// its own parse failure first, so reaching this arm means the two
    /// readers disagree about the same bytes — still a refusal, never a
    /// fallback.
    #[error("contract entry is not valid JSON: {source}")]
    Json {
        /// JSON parsing failure reported by `serde_json`.
        source: serde_json::Error,
    },
    /// One declared command carries both the prior form's `program` and the
    /// current form's `lines`. No release ever minted such a contract, so the
    /// bytes were edited.
    #[error(
        "declared command `{command}` on queue `{task_queue}` carries both the prior archive \
         form (`program`) and the current form (`lines`); no release mints this, so the \
         contract bytes were edited"
    )]
    AmbiguousForm {
        /// The declaring task queue.
        task_queue: String,
        /// The command's declared name.
        command: String,
    },
    /// An archive mixes prior-form and current-form declared commands. An
    /// archive is minted whole by one encoder, so a mixture cannot be a
    /// released artifact.
    #[error(
        "the contract mixes prior-form and current-form declared commands (queue `{task_queue}`, \
         action `{action}` differs from the rest); an archive is minted whole by one encoder, \
         so the contract bytes were edited"
    )]
    MixedForms {
        /// The declaring task queue of the odd command out.
        task_queue: String,
        /// The action whose command's form differs.
        action: String,
    },
    /// A prior-form declared command does not decode as the prior wire shape.
    #[error(
        "declared command on queue `{task_queue}`, action `{action}` carries the prior archive \
         form but does not decode as it: {source}"
    )]
    Undecodable {
        /// The declaring task queue.
        task_queue: String,
        /// The action carrying the command.
        action: String,
        /// JSON decoding failure reported by `serde_json`.
        source: serde_json::Error,
    },
}

/// One declared parameter, as the prior form declared it.
#[derive(Deserialize)]
struct PriorParameter {
    name: String,
    list: bool,
    #[serde(default)]
    default: Option<FillTemplate>,
}

/// One declared environment binding, as the prior form declared it: the
/// value is a fill template.
#[derive(Deserialize)]
struct PriorEnvBinding {
    name: String,
    value: FillTemplate,
}

/// The prior wire shape of one declared command, exactly as the v0.26.0
/// serializer wrote it. `ArgvSlot` is reused directly: its wire shape did
/// not change in the reshape.
#[derive(Deserialize)]
struct PriorDeclaredCommand {
    name: String,
    #[serde(default)]
    parameters: Vec<PriorParameter>,
    program: Vec<String>,
    #[serde(default)]
    args: Vec<ArgvSlot>,
    #[serde(default)]
    env: Vec<PriorEnvBinding>,
    #[serde(default)]
    cwd: Option<String>,
    #[serde(default)]
    hardened_path: Option<FillTemplate>,
    #[serde(default)]
    timeout_ms: Option<i64>,
    #[serde(default)]
    timeout_owner: Option<String>,
}

/// Extracts the prior canonical identity record of every prior-form declared
/// command in `contract_json` (the raw `contract.json` archive entry).
///
/// Returns an empty map when the contract carries no prior-form command —
/// the ordinary case for every freshly minted archive.
///
/// # Errors
///
/// Refuses a contract that mixes the two forms, a command carrying both
/// forms' discriminating fields, or a prior-form command that does not
/// decode as the prior wire shape. Each is bytes no release ever minted.
pub(crate) fn prior_command_identities(
    contract_json: &[u8],
) -> Result<PriorCommandIdentities, PriorFormError> {
    let value: serde_json::Value =
        serde_json::from_slice(contract_json).map_err(|source| PriorFormError::Json { source })?;

    let mut identities = PriorCommandIdentities::new();
    let mut current_form_seen: Option<(String, String)> = None;

    let workers = value
        .get("workers")
        .and_then(serde_json::Value::as_array)
        .map(Vec::as_slice)
        .unwrap_or_default();
    for worker in workers {
        let task_queue = worker
            .get("task_queue")
            .and_then(serde_json::Value::as_str)
            .unwrap_or_default()
            .to_owned();
        let actions = worker
            .get("actions")
            .and_then(serde_json::Value::as_array)
            .map(Vec::as_slice)
            .unwrap_or_default();
        for action in actions {
            let action_name = action
                .get("name")
                .and_then(serde_json::Value::as_str)
                .unwrap_or_default()
                .to_owned();
            let Some(body) = action.get("body") else {
                continue;
            };
            if body.get("kind").and_then(serde_json::Value::as_str) != Some("command") {
                continue;
            }
            let Some(command) = body.get("command") else {
                continue;
            };

            let prior = command.get("program").is_some();
            let current = command.get("lines").is_some();
            if prior && current {
                return Err(PriorFormError::AmbiguousForm {
                    task_queue,
                    command: command
                        .get("name")
                        .and_then(serde_json::Value::as_str)
                        .unwrap_or_default()
                        .to_owned(),
                });
            }
            if !prior {
                if identities.is_empty() {
                    if current_form_seen.is_none() {
                        current_form_seen = Some((task_queue.clone(), action_name));
                    }
                    continue;
                }
                return Err(PriorFormError::MixedForms {
                    task_queue,
                    action: action_name,
                });
            }
            if let Some((seen_queue, seen_action)) = current_form_seen.take() {
                return Err(PriorFormError::MixedForms {
                    task_queue: seen_queue,
                    action: seen_action,
                });
            }

            let decoded = PriorDeclaredCommand::deserialize(command).map_err(|source| {
                PriorFormError::Undecodable {
                    task_queue: task_queue.clone(),
                    action: action_name.clone(),
                    source,
                }
            })?;
            let mut record = Vec::new();
            encode(&mut record, &decoded);
            identities
                .entry(task_queue.clone())
                .or_default()
                .insert(action_name, record);
        }
    }

    Ok(identities)
}

/// Append `command`'s prior canonical identity record to `bytes` — the
/// v0.26.0 encoding, verbatim.
fn encode(bytes: &mut Vec<u8>, command: &PriorDeclaredCommand) {
    text(bytes, &command.name);
    len(bytes, command.parameters.len());
    for parameter in &command.parameters {
        parameter_bytes(bytes, parameter);
    }
    len(bytes, command.program.len());
    for word in &command.program {
        text(bytes, word);
    }
    len(bytes, command.args.len());
    for slot in &command.args {
        slot_bytes(bytes, slot);
    }
    len(bytes, command.env.len());
    for binding in &command.env {
        env_bytes(bytes, binding);
    }
    optional_text(bytes, command.cwd.as_deref());
    optional_fill(bytes, command.hardened_path.as_ref());
    match command.timeout_ms {
        Some(value) => {
            bytes.push(1);
            bytes.extend_from_slice(&value.to_be_bytes());
        }
        None => bytes.push(0),
    }
    optional_text(bytes, command.timeout_owner.as_deref());
}

fn parameter_bytes(bytes: &mut Vec<u8>, parameter: &PriorParameter) {
    text(bytes, &parameter.name);
    bytes.push(u8::from(parameter.list));
    optional_fill(bytes, parameter.default.as_ref());
}

fn slot_bytes(bytes: &mut Vec<u8>, slot: &ArgvSlot) {
    fill(bytes, &slot.fill);
    text(bytes, &slot.label);
    bytes.push(u8::from(slot.admits_leading_dash));
}

fn env_bytes(bytes: &mut Vec<u8>, binding: &PriorEnvBinding) {
    text(bytes, &binding.name);
    fill(bytes, &binding.value);
}

fn fill(bytes: &mut Vec<u8>, template: &FillTemplate) {
    len(bytes, template.pieces.len());
    for piece in &template.pieces {
        match piece {
            FillPiece::Literal { text: value } => {
                bytes.push(0);
                text(bytes, value);
            }
            FillPiece::Hole { parameter } => {
                bytes.push(1);
                text(bytes, parameter);
            }
        }
    }
}

fn optional_fill(bytes: &mut Vec<u8>, template: Option<&FillTemplate>) {
    match template {
        Some(template) => {
            bytes.push(1);
            fill(bytes, template);
        }
        None => bytes.push(0),
    }
}

fn optional_text(bytes: &mut Vec<u8>, value: Option<&str>) {
    match value {
        Some(value) => {
            bytes.push(1);
            text(bytes, value);
        }
        None => bytes.push(0),
    }
}

fn text(bytes: &mut Vec<u8>, value: &str) {
    len(bytes, value.len());
    bytes.extend_from_slice(value.as_bytes());
}

fn len(bytes: &mut Vec<u8>, value: usize) {
    bytes.extend_from_slice(&(value as u64).to_be_bytes());
}