vivacity-core 0.11.1

Manifests, content hash, platform checks, dist fetching, content-addressed store and installation for vivacity
Documentation
//! Port of `Composer\Package\Locker::getContentHash` (2.10.3, see
//! docs/reference/Locker.php): md5 of a subset of composer.json
//! re-encoded through `JsonFile::encode($relevantContent, 0)`.

use crate::error::{Error, Result};
use crate::phpjson::php_json_encode;
use md5::{Digest, Md5};
use serde_json::{Map, Value};

/// Canonical order of $relevantKeys in Locker::getContentHash. Insertion
/// order hardly matters (ksort follows) but we preserve it for fidelity.
const RELEVANT_KEYS: [&str; 11] = [
    "name",
    "version",
    "require",
    "require-dev",
    "conflict",
    "replace",
    "provide",
    "minimum-stability",
    "prefer-stable",
    "repositories",
    "extra",
];

pub fn content_hash(composer_json_text: &str) -> Result<String> {
    let content: Value =
        serde_json::from_str(composer_json_text).map_err(|source| Error::Json {
            context: "composer.json".to_owned(),
            source,
        })?;

    let mut relevant = Map::new();
    if let Value::Object(obj) = &content {
        // `isset($content[$key])`: a null value counts as absent.
        for key in RELEVANT_KEYS {
            if let Some(v) = obj.get(key).filter(|v| !v.is_null()) {
                relevant.insert(key.to_owned(), v.clone());
            }
        }
        if let Some(platform) = obj
            .get("config")
            .and_then(|c| c.get("platform"))
            .filter(|v| !v.is_null())
        {
            let mut config = Map::new();
            config.insert("platform".to_owned(), platform.clone());
            relevant.insert("config".to_owned(), Value::Object(config));
        }
    }

    // ksort($relevantContent): sort of the top-level keys. All the keys possible
    // here are non-numeric, so PHP's byte-wise lexicographic order (strcmp) is
    // the same as Rust's.
    let mut entries: Vec<(String, Value)> = relevant.into_iter().collect();
    entries.sort_by(|(a, _), (b, _)| a.cmp(b));
    let sorted: Map<String, Value> = entries.into_iter().collect();

    let encoded = php_json_encode(&Value::Object(sorted))?;
    let mut hasher = Md5::new();
    hasher.update(encoded.as_bytes());
    Ok(format!("{:x}", hasher.finalize()))
}

/// `hash('md5', $s)` in hexadecimal.
pub fn md5_hex(data: &[u8]) -> String {
    let mut h = Md5::new();
    h.update(data);
    h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}

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

    #[test]
    fn minimal_manifest_is_stable() {
        // Auto-generated vector, frozen after validation against the PHP oracle
        // (tests/oracle_content_hash.rs does the live validation).
        let h = content_hash(r#"{"require":{"php":">=8.1"}}"#).expect("hash");
        assert_eq!(h.len(), 32);
        // Keys outside the list do not take part in the hash.
        let h2 = content_hash(r#"{"require":{"php":">=8.1"},"description":"x"}"#).expect("hash");
        assert_eq!(h, h2);
        // Relevant keys do.
        let h3 = content_hash(r#"{"require":{"php":">=8.2"}}"#).expect("hash");
        assert_ne!(h, h3);
    }

    #[test]
    fn config_platform_is_renested() {
        let a =
            content_hash(r#"{"require":{},"config":{"platform":{"php":"8.2.0"}}}"#).expect("hash");
        let b = content_hash(r#"{"require":{},"config":{"sort-packages":true}}"#).expect("hash");
        let c = content_hash(r#"{"require":{}}"#).expect("hash");
        assert_ne!(a, c); // config.platform counts
        assert_eq!(b, c); // the rest of config does not
    }
}