Skip to main content

vivacity_core/
content_hash.rs

1//! Port of `Composer\Package\Locker::getContentHash` (2.10.3, see
2//! docs/reference/Locker.php): md5 of a subset of composer.json
3//! re-encoded through `JsonFile::encode($relevantContent, 0)`.
4
5use crate::error::{Error, Result};
6use crate::phpjson::php_json_encode;
7use md5::{Digest, Md5};
8use serde_json::{Map, Value};
9
10/// Canonical order of $relevantKeys in Locker::getContentHash. Insertion
11/// order hardly matters (ksort follows) but we preserve it for fidelity.
12const RELEVANT_KEYS: [&str; 11] = [
13    "name",
14    "version",
15    "require",
16    "require-dev",
17    "conflict",
18    "replace",
19    "provide",
20    "minimum-stability",
21    "prefer-stable",
22    "repositories",
23    "extra",
24];
25
26pub fn content_hash(composer_json_text: &str) -> Result<String> {
27    let content: Value =
28        serde_json::from_str(composer_json_text).map_err(|source| Error::Json {
29            context: "composer.json".to_owned(),
30            source,
31        })?;
32
33    let mut relevant = Map::new();
34    if let Value::Object(obj) = &content {
35        // `isset($content[$key])`: a null value counts as absent.
36        for key in RELEVANT_KEYS {
37            if let Some(v) = obj.get(key).filter(|v| !v.is_null()) {
38                relevant.insert(key.to_owned(), v.clone());
39            }
40        }
41        if let Some(platform) = obj
42            .get("config")
43            .and_then(|c| c.get("platform"))
44            .filter(|v| !v.is_null())
45        {
46            let mut config = Map::new();
47            config.insert("platform".to_owned(), platform.clone());
48            relevant.insert("config".to_owned(), Value::Object(config));
49        }
50    }
51
52    // ksort($relevantContent): sort of the top-level keys. All the keys possible
53    // here are non-numeric, so PHP's byte-wise lexicographic order (strcmp) is
54    // the same as Rust's.
55    let mut entries: Vec<(String, Value)> = relevant.into_iter().collect();
56    entries.sort_by(|(a, _), (b, _)| a.cmp(b));
57    let sorted: Map<String, Value> = entries.into_iter().collect();
58
59    let encoded = php_json_encode(&Value::Object(sorted))?;
60    let mut hasher = Md5::new();
61    hasher.update(encoded.as_bytes());
62    Ok(format!("{:x}", hasher.finalize()))
63}
64
65/// `hash('md5', $s)` in hexadecimal.
66pub fn md5_hex(data: &[u8]) -> String {
67    let mut h = Md5::new();
68    h.update(data);
69    h.finalize().iter().map(|b| format!("{b:02x}")).collect()
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    #[test]
77    fn minimal_manifest_is_stable() {
78        // Auto-generated vector, frozen after validation against the PHP oracle
79        // (tests/oracle_content_hash.rs does the live validation).
80        let h = content_hash(r#"{"require":{"php":">=8.1"}}"#).expect("hash");
81        assert_eq!(h.len(), 32);
82        // Keys outside the list do not take part in the hash.
83        let h2 = content_hash(r#"{"require":{"php":">=8.1"},"description":"x"}"#).expect("hash");
84        assert_eq!(h, h2);
85        // Relevant keys do.
86        let h3 = content_hash(r#"{"require":{"php":">=8.2"}}"#).expect("hash");
87        assert_ne!(h, h3);
88    }
89
90    #[test]
91    fn config_platform_is_renested() {
92        let a =
93            content_hash(r#"{"require":{},"config":{"platform":{"php":"8.2.0"}}}"#).expect("hash");
94        let b = content_hash(r#"{"require":{},"config":{"sort-packages":true}}"#).expect("hash");
95        let c = content_hash(r#"{"require":{}}"#).expect("hash");
96        assert_ne!(a, c); // config.platform counts
97        assert_eq!(b, c); // the rest of config does not
98    }
99}