use std::collections::BTreeMap;
use serde_json::json;
use crate::{
error::Result,
forge::request::{FileChange, FileUpdateType},
updater::manager::ManifestFile,
};
fn load_doc(content: &str) -> Result<Option<serde_json::Value>> {
let doc: serde_json::Value = serde_json::from_str(content)?;
Ok(Some(doc))
}
fn escape_forward_slashes(json: &str) -> String {
json.replace("/", "\\/")
}
const RELEVANT_KEYS: [&str; 11] = [
"name",
"version",
"require",
"require-dev",
"conflict",
"replace",
"provide",
"minimum-stability",
"prefer-stable",
"repositories",
"extra",
];
pub struct ComposerLock {}
impl ComposerLock {
pub fn new() -> Self {
Self {}
}
pub fn get_lock_change(
&self,
manifest: &ManifestFile,
new_composer_json: &str,
) -> Result<Option<FileChange>> {
let mut relevant_data: BTreeMap<String, serde_json::Value> =
BTreeMap::new();
let json_doc = load_doc(new_composer_json)?;
if let Some(doc) = json_doc
&& let Some(obj) = doc.as_object()
{
for key in RELEVANT_KEYS {
if let Some(value) = obj.get(key) {
relevant_data.insert(key.to_string(), value.clone());
}
}
if let Some(config) = obj.get("config")
&& let Some(config_obj) = config.as_object()
&& let Some(platform) = config_obj.get("platform")
{
let mut config_map = serde_json::Map::new();
config_map.insert("platform".to_string(), platform.clone());
relevant_data.insert(
"config".to_string(),
serde_json::Value::Object(config_map),
);
}
}
let serialized = serde_json::to_string(&relevant_data)?;
let serialized = escape_forward_slashes(&serialized);
let digest = md5::compute(&serialized);
let new_content_hash = format!("{:x}", digest);
let lock_doc = load_doc(&manifest.content)?;
if let Some(mut doc) = lock_doc {
doc["content-hash"] = json!(new_content_hash);
let formatted_json = serde_json::to_string_pretty(&doc)?;
return Ok(Some(FileChange {
path: manifest.path.to_string_lossy().into(),
content: formatted_json,
update_type: FileUpdateType::Replace,
}));
}
Ok(None)
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
fn make_manifest(path: &str, content: &str) -> ManifestFile {
ManifestFile {
path: PathBuf::from(path),
basename: "composer.lock".to_string(),
content: content.to_string(),
}
}
fn make_lock_content(content_hash: &str) -> String {
format!(
r#"{{"content-hash":"{}","packages":[],"packages-dev":[]}}"#,
content_hash
)
}
#[test]
fn updates_content_hash_in_lock_file() {
let lock = ComposerLock::new();
let manifest =
make_manifest("composer.lock", &make_lock_content("old"));
let composer_json = r#"{"name":"vendor/pkg","version":"2.0.0"}"#;
let result = lock.get_lock_change(&manifest, composer_json).unwrap();
let change = result.unwrap();
assert!(change.content.contains("content-hash"));
assert!(!change.content.contains("\"old\""));
}
#[test]
fn computes_deterministic_hash() {
let lock = ComposerLock::new();
let manifest = make_manifest("composer.lock", &make_lock_content("x"));
let composer_json = r#"{"name":"vendor/pkg","version":"1.0.0"}"#;
let result1 = lock.get_lock_change(&manifest, composer_json).unwrap();
let result2 = lock.get_lock_change(&manifest, composer_json).unwrap();
assert_eq!(result1.unwrap().content, result2.unwrap().content);
}
#[test]
fn hash_changes_when_relevant_keys_change() {
let lock = ComposerLock::new();
let manifest = make_manifest("composer.lock", &make_lock_content("x"));
let json_v1 = r#"{"name":"vendor/pkg","version":"1.0.0"}"#;
let json_v2 = r#"{"name":"vendor/pkg","version":"2.0.0"}"#;
let result1 = lock.get_lock_change(&manifest, json_v1).unwrap();
let result2 = lock.get_lock_change(&manifest, json_v2).unwrap();
assert_ne!(result1.unwrap().content, result2.unwrap().content);
}
#[test]
fn ignores_irrelevant_keys() {
let lock = ComposerLock::new();
let manifest = make_manifest("composer.lock", &make_lock_content("x"));
let json1 = r#"{"name":"vendor/pkg","description":"A"}"#;
let json2 = r#"{"name":"vendor/pkg","description":"B"}"#;
let result1 = lock.get_lock_change(&manifest, json1).unwrap();
let result2 = lock.get_lock_change(&manifest, json2).unwrap();
assert_eq!(result1.unwrap().content, result2.unwrap().content);
}
#[test]
fn includes_config_platform_in_hash() {
let lock = ComposerLock::new();
let manifest = make_manifest("composer.lock", &make_lock_content("x"));
let json_no_platform = r#"{"name":"vendor/pkg"}"#;
let json_with_platform =
r#"{"name":"vendor/pkg","config":{"platform":{"php":"8.1"}}}"#;
let result1 =
lock.get_lock_change(&manifest, json_no_platform).unwrap();
let result2 =
lock.get_lock_change(&manifest, json_with_platform).unwrap();
assert_ne!(result1.unwrap().content, result2.unwrap().content);
}
#[test]
fn escapes_forward_slashes_for_php_compatibility() {
assert_eq!(escape_forward_slashes("a/b"), r"a\/b");
assert_eq!(
escape_forward_slashes("https://example.com"),
r"https:\/\/example.com"
);
}
#[test]
fn preserves_existing_lock_file_structure() {
let lock = ComposerLock::new();
let lock_content = r#"{
"content-hash": "old",
"packages": [{"name": "dep/pkg", "version": "1.0.0"}],
"packages-dev": []
}"#;
let expected_content = r#"{
"content-hash": "252fbc3a285e5be4bd945c007cbcfc9c",
"packages": [
{
"name": "dep/pkg",
"version": "1.0.0"
}
],
"packages-dev": []
}"#;
let manifest = make_manifest("composer.lock", lock_content);
let composer_json = r#"{"name":"vendor/pkg"}"#;
let result = lock.get_lock_change(&manifest, composer_json).unwrap();
let content = result.unwrap().content;
assert_eq!(content, expected_content);
}
}