ipc: support pakker.json; fall back to dir-path hash when parentLockHash absent

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I48d58165d306901dfaeb233310ae93846a6a6964
This commit is contained in:
raf 2026-04-21 23:34:25 +03:00
commit 86b598b431
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF

View file

@ -106,42 +106,51 @@ impl IpcCoordinator {
}
/// Extract modpack hash from pakku.json's parentLockHash field.
/// This is the authoritative content hash for the modpack (Nix-style).
/// This is the authoritative content hash for the modpack. If you've used Nix
/// the model might seem familiar.
fn get_modpack_hash(working_dir: &Path) -> Result<String, IpcError> {
use sha2::{Digest, Sha256};
let pakker_path = working_dir.join("pakker.json");
let pakku_path = working_dir.join("pakku.json");
if !pakku_path.exists() {
let config_path = if pakker_path.exists() {
pakker_path
} else if pakku_path.exists() {
pakku_path
} else {
return Err(IpcError::PakkuJsonReadFailed(
"pakku.json not found in working directory".to_string(),
));
}
};
let content = fs::read_to_string(&pakku_path)
let content = fs::read_to_string(&config_path)
.map_err(|e| IpcError::PakkuJsonReadFailed(e.to_string()))?;
// Parse pakku.json and extract parentLockHash
// Parse config and try to extract parentLockHash (fork modpacks have this)
let pakku: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| IpcError::PakkuJsonReadFailed(e.to_string()))?;
let hash = pakku
let candidate = pakku
.get("pakku")
.and_then(|p| p.get("parentLockHash"))
.and_then(|v| v.as_str())
.ok_or_else(|| {
IpcError::PakkuJsonReadFailed(
"parentLockHash not found in pakku.json".to_string(),
)
})?
.to_string();
.map(std::string::ToString::to_string);
// Validate it's a valid hex string (SHA256 = 64 chars)
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(IpcError::PakkuJsonReadFailed(
"parentLockHash is not a valid SHA256 hash".to_string(),
));
if let Some(hash) = candidate {
// Validate it's a valid hex string (SHA256 = 64 chars)
if hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Ok(hash);
}
}
Ok(hash)
// Hash the working directory path for non-fork modpacks as a fallback.
let dir_str = working_dir.to_string_lossy();
let mut hasher = Sha256::new();
hasher.update(dir_str.as_bytes());
Ok(crate::utils::hash::hash_to_hex(
hasher.finalize().as_slice(),
))
}
/// Create a new IPC coordinator for the given modpack directory.
@ -470,6 +479,7 @@ impl Drop for OperationGuard {
#[cfg(test)]
mod tests {
#![expect(clippy::expect_used, reason = "Fine in tests")]
use std::fs;
use tempfile::TempDir;
@ -495,7 +505,7 @@ mod tests {
// Write pakku.json with unique parentLockHash (nested under "pakku" key)
let pakku_content =
format!(r#"{{"pakku": {{"parentLockHash": "{}"}}}}"#, unique_hash);
format!(r#"{{"pakku": {{"parentLockHash": "{unique_hash}"}}}}"#);
fs::write(temp_dir.path().join("pakku.json"), pakku_content).unwrap();
temp_dir
@ -520,7 +530,7 @@ mod tests {
// Write pakku.json with specified parentLockHash (nested under "pakku" key)
let pakku_content =
format!(r#"{{"pakku": {{"parentLockHash": "{}"}}}}"#, hash);
format!(r#"{{"pakku": {{"parentLockHash": "{hash}"}}}}"#);
fs::write(temp_dir.path().join("pakku.json"), pakku_content).unwrap();
temp_dir
@ -539,8 +549,7 @@ mod tests {
"cfe85e0e7e7aa0922d30d8faad071e3a4126cb78b5f0f792f191e90a295aa2c7",
);
let hash =
IpcCoordinator::get_modpack_hash(&temp_dir.path().to_path_buf()).unwrap();
let hash = IpcCoordinator::get_modpack_hash(temp_dir.path()).unwrap();
assert_eq!(
hash,
"cfe85e0e7e7aa0922d30d8faad071e3a4126cb78b5f0f792f191e90a295aa2c7"
@ -554,13 +563,13 @@ mod tests {
.tempdir()
.unwrap();
let result =
IpcCoordinator::get_modpack_hash(&temp_dir.path().to_path_buf());
let result = IpcCoordinator::get_modpack_hash(temp_dir.path());
assert!(matches!(result, Err(IpcError::PakkuJsonReadFailed(_))));
}
#[test]
fn test_get_modpack_hash_missing_parent_lock_hash() {
// Non-fork modpacks have no parentLockHash; should fall back to dir hash
let temp_dir = tempfile::Builder::new()
.prefix("pakker-ipc-test-")
.tempdir()
@ -569,13 +578,15 @@ mod tests {
fs::write(temp_dir.path().join("pakku.json"), r#"{"other": "field"}"#)
.unwrap();
let result =
IpcCoordinator::get_modpack_hash(&temp_dir.path().to_path_buf());
assert!(matches!(result, Err(IpcError::PakkuJsonReadFailed(_))));
let result = IpcCoordinator::get_modpack_hash(temp_dir.path());
let hash = result.unwrap();
assert_eq!(hash.len(), 64);
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_get_modpack_hash_invalid_hash() {
// Root-level parentLockHash (wrong location) falls back to dir hash
let temp_dir = tempfile::Builder::new()
.prefix("pakker-ipc-test-")
.tempdir()
@ -587,9 +598,10 @@ mod tests {
)
.unwrap();
let result =
IpcCoordinator::get_modpack_hash(&temp_dir.path().to_path_buf());
assert!(matches!(result, Err(IpcError::PakkuJsonReadFailed(_))));
let result = IpcCoordinator::get_modpack_hash(temp_dir.path());
let hash = result.unwrap();
assert_eq!(hash.len(), 64);
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
@ -607,8 +619,8 @@ mod tests {
shared_hash,
);
let coord1 = IpcCoordinator::new(&temp_dir1.path().to_path_buf()).unwrap();
let coord2 = IpcCoordinator::new(&temp_dir2.path().to_path_buf()).unwrap();
let coord1 = IpcCoordinator::new(temp_dir1.path()).unwrap();
let coord2 = IpcCoordinator::new(temp_dir2.path()).unwrap();
assert_eq!(
coord1.ops_file, coord2.ops_file,
@ -624,8 +636,8 @@ mod tests {
let temp_dir1 = create_test_modpack(&[("mod1.jar", "content")]);
let temp_dir2 = create_test_modpack(&[("mod1.jar", "content")]);
let coord1 = IpcCoordinator::new(&temp_dir1.path().to_path_buf()).unwrap();
let coord2 = IpcCoordinator::new(&temp_dir2.path().to_path_buf()).unwrap();
let coord1 = IpcCoordinator::new(temp_dir1.path()).unwrap();
let coord2 = IpcCoordinator::new(temp_dir2.path()).unwrap();
assert_ne!(
coord1.ops_file, coord2.ops_file,
@ -636,8 +648,7 @@ mod tests {
#[test]
fn test_ipc_coordinator_new_creates_dir() {
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
// Check that the parent directory (ipc_dir) exists
assert!(coordinator.ops_file.parent().unwrap().exists());
@ -646,8 +657,7 @@ mod tests {
#[test]
fn test_load_operations_empty() {
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
let operations = coordinator.load_operations().unwrap();
assert!(operations.is_empty());
@ -656,8 +666,7 @@ mod tests {
#[test]
fn test_register_operation() {
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
let id = coordinator
.register_operation(OperationType::Fetch)
@ -670,8 +679,7 @@ mod tests {
#[test]
fn test_register_multiple_operations_different_types() {
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
let fetch_id = coordinator
.register_operation(OperationType::Fetch)
@ -689,8 +697,7 @@ mod tests {
#[test]
fn test_register_conflicting_operation_same_type() {
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
let _id1 = coordinator
.register_operation(OperationType::Fetch)
@ -703,8 +710,7 @@ mod tests {
#[test]
fn test_complete_operation() {
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
let id = coordinator
.register_operation(OperationType::Fetch)
@ -719,8 +725,7 @@ mod tests {
#[test]
fn test_complete_nonexistent_operation() {
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
let result = coordinator.complete_operation("nonexistent-id");
assert!(matches!(result, Err(IpcError::OperationNotFound(_))));
@ -729,8 +734,7 @@ mod tests {
#[test]
fn test_has_running_operation() {
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
assert!(!coordinator.has_running_operation(OperationType::Fetch));
@ -747,8 +751,7 @@ mod tests {
#[test]
fn test_get_running_operations() {
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
let operations = coordinator.get_running_operations(OperationType::Fetch);
assert!(operations.is_empty());
@ -764,8 +767,7 @@ mod tests {
#[tokio::test]
async fn test_wait_for_conflicts_no_conflicts() {
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
let result = coordinator
.wait_for_conflicts(OperationType::Fetch, Duration::from_secs(1))
@ -776,8 +778,7 @@ mod tests {
#[tokio::test]
async fn test_wait_for_conflicts_with_completion() {
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
// Register an operation
let id = coordinator
@ -801,8 +802,7 @@ mod tests {
#[tokio::test]
async fn test_wait_for_conflicts_timeout() {
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
// Register a long-running operation (we won't complete it)
let _id = coordinator
@ -819,8 +819,7 @@ mod tests {
#[test]
fn test_operation_guard_completes_on_drop() {
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
let id = coordinator
.register_operation(OperationType::Fetch)
@ -842,8 +841,7 @@ mod tests {
#[test]
fn test_operation_guard_manual_complete() {
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
let id = coordinator
.register_operation(OperationType::Export)
@ -862,8 +860,7 @@ mod tests {
#[test]
fn test_stale_operation_cleanup() {
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
// Manually add a stale operation (started 15 minutes ago)
let now = SystemTime::now()
@ -896,8 +893,7 @@ mod tests {
#[test]
fn test_multiple_operations_same_process() {
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
let id1 = coordinator
.register_operation(OperationType::Fetch)
@ -929,8 +925,7 @@ mod tests {
#[test]
fn test_operation_id_format() {
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
let id = coordinator
.register_operation(OperationType::Export)
@ -971,8 +966,8 @@ mod tests {
fs::write(temp_dir1.path().join("file1.txt"), "content1").unwrap();
fs::write(temp_dir2.path().join("file2.txt"), "content2").unwrap();
let coord1 = IpcCoordinator::new(&temp_dir1.path().to_path_buf()).unwrap();
let coord2 = IpcCoordinator::new(&temp_dir2.path().to_path_buf()).unwrap();
let coord1 = IpcCoordinator::new(temp_dir1.path()).unwrap();
let coord2 = IpcCoordinator::new(temp_dir2.path()).unwrap();
// Both should point to SAME ops file despite different paths
assert_eq!(coord1.ops_file, coord2.ops_file);
@ -982,8 +977,7 @@ mod tests {
fn test_corrupted_ops_json_trailing_bracket() {
// Test handling of corrupted ops.json with trailing characters
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
// Register an operation to create ops.json
let _id = coordinator
@ -992,7 +986,7 @@ mod tests {
// Manually corrupt the ops.json by appending extra bracket
let ops_content = fs::read_to_string(&coordinator.ops_file).unwrap();
fs::write(&coordinator.ops_file, format!("{}]", ops_content)).unwrap();
fs::write(&coordinator.ops_file, format!("{ops_content}]")).unwrap();
// Loading should fail with InvalidFormat error
let result = coordinator.load_operations();
@ -1003,8 +997,7 @@ mod tests {
fn test_corrupted_ops_json_invalid_json() {
// Test handling of completely invalid JSON
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
// Write invalid JSON to ops.json
fs::write(&coordinator.ops_file, "not valid json {[}").unwrap();
@ -1017,8 +1010,7 @@ mod tests {
fn test_corrupted_ops_json_missing_fields() {
// Test handling of JSON with missing required fields
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
// Write JSON with missing fields
fs::write(&coordinator.ops_file, r#"[{"id": "test"}]"#).unwrap();
@ -1031,8 +1023,7 @@ mod tests {
fn test_empty_ops_file() {
// Test handling of empty ops.json file
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
// Create empty ops.json
fs::write(&coordinator.ops_file, "").unwrap();
@ -1045,8 +1036,7 @@ mod tests {
fn test_whitespace_only_ops_file() {
// Test handling of whitespace-only ops.json
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
fs::write(&coordinator.ops_file, " \n\t \n ").unwrap();
@ -1074,8 +1064,7 @@ mod tests {
}"#;
fs::write(temp_dir.path().join("pakku.json"), pakku_content).unwrap();
let hash =
IpcCoordinator::get_modpack_hash(&temp_dir.path().to_path_buf()).unwrap();
let hash = IpcCoordinator::get_modpack_hash(temp_dir.path()).unwrap();
assert_eq!(
hash,
"abc123def456789012345678901234567890abcd123456789012345678901234"
@ -1096,12 +1085,11 @@ mod tests {
}"#;
fs::write(temp_dir.path().join("pakku.json"), old_format).unwrap();
let result =
IpcCoordinator::get_modpack_hash(&temp_dir.path().to_path_buf());
assert!(
matches!(result, Err(IpcError::PakkuJsonReadFailed(_))),
"Old format should be rejected"
);
// Old format (root-level parentLockHash, not in pakku section) falls back
// to dir-path hash
let hash = IpcCoordinator::get_modpack_hash(temp_dir.path()).unwrap();
assert_eq!(hash.len(), 64);
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
@ -1111,12 +1099,13 @@ mod tests {
.tempdir()
.unwrap();
// Too-short hash falls back to dir-path hash
let pakku_content = r#"{"pakku": {"parentLockHash": "tooshort"}}"#;
fs::write(temp_dir.path().join("pakku.json"), pakku_content).unwrap();
let result =
IpcCoordinator::get_modpack_hash(&temp_dir.path().to_path_buf());
assert!(matches!(result, Err(IpcError::PakkuJsonReadFailed(_))));
let hash = IpcCoordinator::get_modpack_hash(temp_dir.path()).unwrap();
assert_eq!(hash.len(), 64);
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
@ -1126,13 +1115,13 @@ mod tests {
.tempdir()
.unwrap();
// 64 chars but not all hex
// 64 chars but not all hex falls back to dir-path hash
let pakku_content = r#"{"pakku": {"parentLockHash": "gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg"}}"#;
fs::write(temp_dir.path().join("pakku.json"), pakku_content).unwrap();
let result =
IpcCoordinator::get_modpack_hash(&temp_dir.path().to_path_buf());
assert!(matches!(result, Err(IpcError::PakkuJsonReadFailed(_))));
let hash = IpcCoordinator::get_modpack_hash(temp_dir.path()).unwrap();
assert_eq!(hash.len(), 64);
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
@ -1146,8 +1135,7 @@ mod tests {
let pakku_content = r#"{"pakku": {"parentLockHash": "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"}}"#;
fs::write(temp_dir.path().join("pakku.json"), pakku_content).unwrap();
let hash =
IpcCoordinator::get_modpack_hash(&temp_dir.path().to_path_buf()).unwrap();
let hash = IpcCoordinator::get_modpack_hash(temp_dir.path()).unwrap();
assert_eq!(
hash,
"ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"
@ -1158,8 +1146,7 @@ mod tests {
fn test_concurrent_registration_race_condition() {
// Test that file locking prevents race conditions
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
// First registration should succeed
let id1 = coordinator
@ -1182,8 +1169,7 @@ mod tests {
fn test_file_permissions_ipc_dir() {
// Test that IPC directory has correct permissions (700 - owner only)
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
let ipc_dir = coordinator.ops_file.parent().unwrap();
let metadata = fs::metadata(ipc_dir).unwrap();
@ -1195,8 +1181,7 @@ mod tests {
fn test_file_permissions_ops_file() {
// Test that ops.json has correct permissions (600 - owner read/write only)
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
// Register operation to create ops.json
let _id = coordinator
@ -1215,21 +1200,21 @@ mod tests {
let unique_hash = format!(
"{:064x}",
rand::random::<u128>()
^ (std::time::SystemTime::now()
^ std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos() as u128)
.as_nanos()
);
let temp_dir = create_test_modpack_with_hash(
&[("test.txt", "test")],
&unique_hash[..64],
);
let coord1 = IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coord1 = IpcCoordinator::new(temp_dir.path()).unwrap();
let id = coord1.register_operation(OperationType::Fetch).unwrap();
// Create new coordinator instance
let coord2 = IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coord2 = IpcCoordinator::new(temp_dir.path()).unwrap();
let operations = coord2.load_operations().unwrap();
assert_eq!(operations.len(), 1);
@ -1245,8 +1230,7 @@ mod tests {
// Test that stale cleanup only removes running operations, not completed
// ones
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
@ -1292,8 +1276,7 @@ mod tests {
async fn test_wait_for_conflicts_multiple_types() {
// Test that wait_for_conflicts only waits for matching operation types
let temp_dir = create_test_modpack(&[("test.txt", "test")]);
let coordinator =
IpcCoordinator::new(&temp_dir.path().to_path_buf()).unwrap();
let coordinator = IpcCoordinator::new(temp_dir.path()).unwrap();
// Register Export operation (different type)
let _export_id = coordinator