//! Local Hash & Checksum service (port of `hashing/localHashService.js`). //! //! Modules never see local paths: Core dialogs return opaque, per-module selection tokens //! (30 min, max 64 per module). Jobs run on a dedicated thread, report progress to the owning //! module window and can be cancelled. Manifest verification rejects absolute paths, //! traversal and symbolic links and never leaves the selected root directory. use super::algorithms::{self, normalize_requests, typed, Request}; use super::engine::{self, Identity}; use crate::core::error::{CoreError, CoreResult}; use crate::core::logging::Logger; use base64::engine::general_purpose::{STANDARD as B64, URL_SAFE_NO_PAD}; use base64::Engine; use parking_lot::Mutex; use rand::RngCore; use serde_json::{json, Map, Value}; use std::collections::HashMap; use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; pub const MAX_SELECTIONS_PER_MODULE: usize = 64; pub const MAX_FILES_PER_JOB: usize = 10_000; pub const MAX_MANIFEST_BYTES: u64 = 10 * 1024 * 1024; const TOKEN_TTL: Duration = Duration::from_secs(30 * 60); const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1; fn random_id(prefix: &str) -> String { let mut bytes = [0u8; 24]; rand::rngs::OsRng.fill_bytes(&mut bytes); format!("{prefix}_{}", URL_SAFE_NO_PAD.encode(bytes)) } fn now_ms() -> i64 { chrono::Utc::now().timestamp_millis() } fn clean_name(value: &str) -> String { value.replace(['\r', '\n', '\0'], "_") } /// Maps I/O failures to the Node error codes the 0.1.0 UI and logs used. pub fn io_error(error: std::io::Error) -> CoreError { match error.kind() { std::io::ErrorKind::NotFound => CoreError::new("ENOENT", "ENOENT: no such file or directory"), std::io::ErrorKind::PermissionDenied => CoreError::new("EACCES", "EACCES: permission denied"), _ => CoreError::new("EIO", error.to_string()), } } const KNOWN_CODES: [&str; 10] = ["ENOENT", "EACCES", "EPERM", "HASH_CANCELLED", "HASH_FILE_CHANGED", "HASH_NOT_REGULAR_FILE", "HASH_INVALID_MANIFEST", "HASH_PATH_ESCAPE", "HASH_SYMLINK_REJECTED", "HASH_UNSUPPORTED_ALGORITHM"]; pub fn safe_error(error: &CoreError) -> Value { if KNOWN_CODES.contains(&error.code.as_str()) { json!({ "code": error.code, "message": if error.message.is_empty() { "Hash operation failed." } else { error.message.as_str() } }) } else { json!({ "code": "HASH_INTERNAL_ERROR", "message": "An internal hashing error occurred." }) } } fn contains_path(root: &Path, candidate: &Path) -> bool { candidate.starts_with(root) } fn base_name(path: &Path) -> String { path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string_lossy().to_string()) } #[derive(Debug, Clone)] struct FileEntry { id: String, path: PathBuf, name: String, size: u64, identity: Option, relative_path: Option, algorithms: Option>, expected: Option, preflight_error: Option, } impl FileEntry { fn public(&self) -> Value { json!({ "fileId": self.id, "name": self.name, "relativePath": self.relative_path.clone().unwrap_or_else(|| self.name.clone()), "size": self.size }) } } #[derive(Debug, Clone)] enum Selection { Files(Vec), Directory(PathBuf), Manifest(FileEntry), Destination(PathBuf), } impl Selection { fn kind(&self) -> &'static str { match self { Selection::Files(_) => "files", Selection::Directory(_) => "directory", Selection::Manifest(_) => "manifest", Selection::Destination(_) => "destination", } } } struct StoredSelection { id: String, created: Instant, selection: Selection, } struct ManifestTarget { format: String, destination: PathBuf, } struct Job { id: String, module_id: String, state: &'static str, mode: &'static str, algorithms: Vec, output: String, files: Vec, expected: Option, manifest: Option, results: Vec, current_index: i64, bytes: u64, total_bytes: u64, started_at: i64, completed_at: Option, error: Option, comparison: Option, manifest_result: Option, } impl Job { fn terminal(&self) -> bool { matches!(self.state, "completed" | "failed" | "cancelled") } fn public(&self) -> Value { let elapsed = (self.completed_at.unwrap_or_else(now_ms) - self.started_at).max(0); let throughput = if elapsed > 0 { self.bytes as f64 / (elapsed as f64 / 1000.0) } else { 0.0 }; let remaining = self.total_bytes.saturating_sub(self.bytes); let percent = if self.total_bytes > 0 { (self.bytes as f64 / self.total_bytes as f64 * 100.0).min(100.0) } else if self.state == "completed" { 100.0 } else { 0.0 }; let current = usize::try_from(self.current_index).ok().and_then(|i| self.files.get(i)).map(FileEntry::public); json!({ "jobId": self.id, "mode": self.mode, "state": self.state, "algorithms": self.algorithms.iter().map(Request::public).collect::>(), "filesCompleted": self.results.len(), "filesTotal": self.files.len(), "currentFile": current, "bytes": self.bytes, "totalBytes": self.total_bytes, "percent": percent, "throughputBytesPerSecond": throughput, "elapsedMs": elapsed, "etaMs": if throughput > 0.0 { json!(remaining as f64 / throughput * 1000.0) } else { Value::Null }, "results": self.results, "comparison": self.comparison, "manifest": self.manifest_result, "error": self.error, }) } } struct JobHandle { job: Mutex, cancel: AtomicBool, } pub type ProgressEmitter = Arc; pub struct LocalHashService { selections: Mutex>>, jobs: Mutex>>, emitter: ProgressEmitter, logger: Option>, } fn format_result(id: &str, digest: &[u8], output: &str) -> Value { let hex = hex::encode(digest); let value = match output { "base64" => B64.encode(digest), "hex-upper" => hex.to_uppercase(), _ => hex.clone(), }; json!({ "id": id, "hex": hex, "value": value, "encoding": output }) } fn safe_hex_equal(left: &str, right: &str) -> bool { match (hex::decode(left), hex::decode(right)) { (Ok(a), Ok(b)) => a.len() == b.len() && subtle::ConstantTimeEq::ct_eq(a.as_slice(), b.as_slice()).into(), _ => false, } } impl LocalHashService { pub fn new(emitter: ProgressEmitter, logger: Option>) -> Self { Self { selections: Mutex::new(HashMap::new()), jobs: Mutex::new(HashMap::new()), emitter, logger } } pub fn algorithms(&self) -> Value { algorithms::public_list() } // --- selections (paths come from Core dialogs only) --------------------------------------- pub fn select_files(&self, module_id: &str, multiple: bool, picked: Option>) -> CoreResult { let Some(sources) = picked else { return Ok(json!({ "canceled": true, "selectionId": null, "files": [] })) }; if sources.is_empty() || (!multiple && sources.len() != 1) { return Err(typed("HASH_INVALID_SELECTION", "The file picker returned an invalid selection.")); } let mut files = sources.iter().map(|s| authorize_regular_file(s, MAX_SAFE_INTEGER)).collect::>>()?; let parents: Vec = files.iter().map(|f| f.path.parent().map(Path::to_path_buf).unwrap_or_default()).collect(); let root = if files.len() > 1 { common_directory(&parents) } else { parents[0].clone() }; for file in files.iter_mut() { file.relative_path = Some(relative_slash(&root, &file.path)); } let public: Vec = files.iter().map(FileEntry::public).collect(); let id = self.store(module_id, Selection::Files(files)); Ok(json!({ "canceled": false, "selectionId": id, "files": public })) } pub fn select_directory(&self, module_id: &str, picked: Option) -> CoreResult { let Some(source) = picked else { return Ok(json!({ "canceled": true, "selectionId": null })) }; let directory = authorize_directory(&source)?; let name = base_name(&directory); let id = self.store(module_id, Selection::Directory(directory)); Ok(json!({ "canceled": false, "selectionId": id, "name": name })) } pub fn select_manifest(&self, module_id: &str, picked: Option) -> CoreResult { let Some(source) = picked else { return Ok(json!({ "canceled": true, "selectionId": null })) }; let file = authorize_regular_file(&source, MAX_MANIFEST_BYTES)?; let public = file.public(); let id = self.store(module_id, Selection::Manifest(file)); Ok(json!({ "canceled": false, "selectionId": id, "file": public })) } pub fn suggested_manifest_name(payload: &Value) -> String { clean_name(payload.get("suggestedName").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("checksums.sha256")) } pub fn select_manifest_destination(&self, module_id: &str, picked: Option) -> CoreResult { let Some(chosen) = picked else { return Ok(json!({ "canceled": true, "selectionId": null })) }; let invalid = || typed("HASH_INVALID_DESTINATION", "Manifest destination directory is invalid."); let file_name = chosen.file_name().ok_or_else(invalid)?.to_owned(); let parent = std::fs::canonicalize(chosen.parent().ok_or_else(invalid)?).map_err(io_error)?; if !std::fs::metadata(&parent).map_err(io_error)?.is_dir() { return Err(invalid()); } let destination = parent.join(&file_name); match std::fs::symlink_metadata(&destination) { Ok(meta) if meta.file_type().is_symlink() || !meta.is_file() => return Err(typed("HASH_SYMLINK_REJECTED", "Manifest destination must be a regular file and cannot be a symbolic link.")), Ok(_) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(io_error(e)), } let name = base_name(&destination); let id = self.store(module_id, Selection::Destination(destination)); Ok(json!({ "canceled": false, "selectionId": id, "name": name })) } fn store(&self, module_id: &str, selection: Selection) -> String { let mut all = self.selections.lock(); let values = all.entry(module_id.to_string()).or_default(); values.retain(|s| s.created.elapsed() <= TOKEN_TTL); while values.len() >= MAX_SELECTIONS_PER_MODULE { values.remove(0); } let id = random_id("selection"); values.push(StoredSelection { id: id.clone(), created: Instant::now(), selection }); id } fn selection(&self, module_id: &str, id: &Value, kinds: &[&str]) -> CoreResult { let id = id.as_str().unwrap_or(""); let all = self.selections.lock(); all.get(module_id) .and_then(|values| values.iter().find(|s| s.id == id)) .filter(|s| s.created.elapsed() <= TOKEN_TTL && kinds.contains(&s.selection.kind())) .map(|s| s.selection.clone()) .ok_or_else(|| typed("HASH_INVALID_SELECTION_TOKEN", "Unknown, expired, or unauthorized selection token.")) } fn files_of(&self, selection: Selection, recursive: bool) -> CoreResult> { match selection { Selection::Files(files) => Ok(files), Selection::Directory(root) => enumerate_directory(&root, recursive), _ => Err(typed("HASH_INVALID_SELECTION_TOKEN", "Unknown, expired, or unauthorized selection token.")), } } // --- jobs --------------------------------------------------------------------------------- pub fn start(&self, module_id: &str, payload: &Value) -> CoreResult { let algorithms = normalize_requests(payload.get("algorithms").unwrap_or(&Value::Null))?; let output = payload.get("output").and_then(Value::as_str).filter(|o| ["hex-lower", "hex-upper", "base64"].contains(o)).unwrap_or("hex-lower"); let selection = self.selection(module_id, &payload["selectionId"], &["files", "directory"])?; let files = self.files_of(selection, payload["recursive"] == true)?; self.start_job(module_id, "calculate", algorithms, output, files, None, None) } pub fn verify(&self, module_id: &str, payload: &Value) -> CoreResult { let algorithms = normalize_requests(&json!([payload.get("algorithm").cloned().unwrap_or(Value::Null)]))?; let Selection::Files(files) = self.selection(module_id, &payload["selectionId"], &["files"])? else { unreachable!() }; if files.len() != 1 { return Err(typed("HASH_INVALID_SELECTION", "Verify requires exactly one selected file.")); } let expected = normalize_expected(&payload["expected"], &algorithms[0], payload.get("expectedEncoding").and_then(Value::as_str).unwrap_or("hex"))?; self.start_job(module_id, "verify", algorithms, "hex-lower", files, Some(expected), None) } pub fn compare(&self, module_id: &str, payload: &Value) -> CoreResult { let requested = payload.get("algorithms").filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json!(["sha256"])); let algorithms = normalize_requests(&requested)?; let Selection::Files(left) = self.selection(module_id, &payload["leftSelectionId"], &["files"])? else { unreachable!() }; let Selection::Files(right) = self.selection(module_id, &payload["rightSelectionId"], &["files"])? else { unreachable!() }; if left.len() != 1 || right.len() != 1 { return Err(typed("HASH_INVALID_SELECTION", "Compare requires one file in each selection.")); } self.start_job(module_id, "compare", algorithms, "hex-lower", vec![left[0].clone(), right[0].clone()], None, None) } pub fn generate_manifest(&self, module_id: &str, payload: &Value) -> CoreResult { let algorithms = normalize_requests(&json!([payload.get("algorithm").cloned().unwrap_or(Value::Null)]))?; let algorithm = algorithms[0].algorithm(); if algorithm.xof.is_some() || algorithm.key_bytes.is_some() || algorithm.seed_bits.is_some() { return Err(typed("HASH_INVALID_MANIFEST", "Checksum manifests require a fixed, unkeyed, unseeded algorithm.")); } let format = payload.get("format").and_then(Value::as_str).filter(|f| ["gnu", "bsd", "sfv"].contains(f)).unwrap_or("gnu"); if format == "sfv" && algorithm.id != "crc32" { return Err(typed("HASH_INVALID_MANIFEST", "SFV manifests require CRC-32/ISO-HDLC.")); } let source = self.selection(module_id, &payload["selectionId"], &["files", "directory"])?; let Selection::Destination(destination) = self.selection(module_id, &payload["destinationSelectionId"], &["destination"])? else { unreachable!() }; let files = self.files_of(source, payload["recursive"] == true)?; self.start_job(module_id, "generate-manifest", algorithms, "hex-lower", files, None, Some(ManifestTarget { format: format.into(), destination })) } pub fn verify_manifest(&self, module_id: &str, payload: &Value) -> CoreResult { let Selection::Manifest(manifest) = self.selection(module_id, &payload["manifestSelectionId"], &["manifest"])? else { unreachable!() }; let Selection::Directory(root) = self.selection(module_id, &payload["rootSelectionId"], &["directory"])? else { unreachable!() }; let requested = payload.get("algorithmId").and_then(Value::as_str).filter(|s| !s.is_empty()); let (entries, algorithms) = parse_manifest(&manifest.path, requested)?; let mut files = Vec::new(); for entry in entries { let failed = |error: Value, entry: &ManifestEntry| FileEntry { id: random_id("file"), path: PathBuf::new(), name: entry.filename.rsplit(['/', '\\']).next().unwrap_or("").to_string(), size: 0, identity: None, relative_path: Some(entry.filename.clone()), algorithms: Some(vec![Request::simple(algorithms::by_id(&entry.algorithm_id).map(|a| a.id).unwrap_or("sha256"))]), expected: Some(entry.expected.clone()), preflight_error: Some(error), }; if entry.invalid { files.push(failed(json!({ "code": "HASH_INVALID_MANIFEST", "message": "Invalid checksum manifest entry." }), &entry)); continue; } let resolved = (|| -> CoreResult { let relative = safe_manifest_path(&entry.filename)?; let candidate = relative.split('/').fold(root.clone(), |p, s| p.join(s)); if !contains_path(&root, &candidate) { return Err(typed("HASH_PATH_ESCAPE", "Manifest path escapes the selected directory.")); } assert_no_symlink_components(&root, &relative)?; let mut file = authorize_regular_file(&candidate, MAX_SAFE_INTEGER)?; if !contains_path(&root, &file.path) { return Err(typed("HASH_PATH_ESCAPE", "Manifest path resolves outside the selected directory.")); } file.relative_path = Some(relative); file.algorithms = Some(normalize_requests(&json!([{ "id": entry.algorithm_id }]))?); file.expected = Some(entry.expected.clone()); Ok(file) })(); match resolved { Ok(file) => files.push(file), Err(error) => files.push(failed(safe_error(&error), &entry)), } } let job = self.start_job(module_id, "verify-manifest", algorithms, "hex-lower", files, None, None)?; // 0.1.0 stored the source name on the job without exposing it; nothing else to keep. let _ = manifest.name; Ok(job) } #[allow(clippy::too_many_arguments)] fn start_job(&self, module_id: &str, mode: &'static str, algorithms: Vec, output: &str, files: Vec, expected: Option, manifest: Option) -> CoreResult { if files.is_empty() { return Err(typed("HASH_EMPTY_SELECTION", "No regular files were selected.")); } if files.len() > MAX_FILES_PER_JOB { return Err(typed("HASH_TOO_MANY_FILES", format!("A job can contain at most {MAX_FILES_PER_JOB} files."))); } let total_bytes = files.iter().map(|f| f.size).sum(); let job = Job { id: random_id("job"), module_id: module_id.to_string(), state: "queued", mode, algorithms, output: output.to_string(), files, expected, manifest, results: Vec::new(), current_index: -1, bytes: 0, total_bytes, started_at: now_ms(), completed_at: None, error: None, comparison: None, manifest_result: None, }; let id = job.id.clone(); let handle = Arc::new(JobHandle { job: Mutex::new(job), cancel: AtomicBool::new(false) }); self.jobs.lock().insert(id, handle.clone()); let public = handle.job.lock().public(); self.emit(&handle); let emitter = self.emitter.clone(); let logger = self.logger.clone(); std::thread::Builder::new() .name("chj-hash-job".into()) .spawn(move || run_job(handle, emitter, logger)) .map_err(|_| CoreError::new("HASH_INTERNAL_ERROR", "An internal hashing error occurred."))?; Ok(public) } fn emit(&self, handle: &JobHandle) { let (module, public) = { let job = handle.job.lock(); (job.module_id.clone(), job.public()) }; (self.emitter)(&module, public); } fn job(&self, module_id: &str, job_id: &Value) -> CoreResult> { let id = job_id.as_str().unwrap_or(""); self.jobs .lock() .get(id) .filter(|h| h.job.lock().module_id == module_id) .cloned() .ok_or_else(|| typed("HASH_INVALID_JOB", "Unknown or unauthorized hash job.")) } pub fn status(&self, module_id: &str, job_id: &Value) -> CoreResult { Ok(self.job(module_id, job_id)?.job.lock().public()) } pub fn cancel(&self, module_id: &str, job_id: &Value) -> CoreResult { let handle = self.job(module_id, job_id)?; { let mut job = handle.job.lock(); if job.terminal() { return Ok(job.public()); } handle.cancel.store(true, Ordering::SeqCst); job.state = "cancelled"; job.error = Some(json!({ "code": "HASH_CANCELLED", "message": "Hash job was cancelled." })); job.completed_at = Some(now_ms()); } self.emit(&handle); let public = handle.job.lock().public(); Ok(public) } pub fn export_results(&self, module_id: &str, payload: &Value) -> CoreResult { let handle = self.job(module_id, &payload["jobId"])?; let Selection::Destination(destination) = self.selection(module_id, &payload["destinationSelectionId"], &["destination"])? else { unreachable!() }; let job = handle.job.lock(); if job.state != "completed" { return Err(typed("HASH_JOB_NOT_COMPLETED", "Only completed hash results can be exported.")); } let format = if payload["format"] == "json" { "json" } else { "text" }; let serializable: Vec = job .results .iter() .map(|r| { let mut hashes = Map::new(); for hash in r["hashes"].as_array().into_iter().flatten() { hashes.insert(hash["id"].as_str().unwrap_or("").to_string(), hash["value"].clone()); } json!({ "file": r["file"]["relativePath"], "size": r["file"]["size"], "status": r["status"], "hashes": hashes }) }) .collect(); drop(job); let text = if format == "json" { format!("{}\n", serde_json::to_string_pretty(&json!({ "generatedBy": "CH-J Server Manager Hash & Checksum", "results": serializable })).unwrap_or_default()) } else { let lines: Vec = serializable .iter() .flat_map(|r| { let file = r["file"].as_str().unwrap_or(""); let size = &r["size"]; let status = r["status"].as_str().unwrap_or(""); let hashes = r["hashes"].as_object().cloned().unwrap_or_default(); if hashes.is_empty() { vec![format!("{file}\t{size}\t\t\t{status}")] } else { hashes.iter().map(|(id, value)| format!("{file}\t{size}\t{id}\t{}\t{status}", value.as_str().unwrap_or(""))).collect() } }) .collect(); format!("{}\n", lines.join("\n")) }; write_authorized(&destination, &text)?; Ok(json!({ "ok": true, "name": base_name(&destination), "format": format })) } /// Text for the clipboard (the command writes it; modules have no clipboard access). pub fn copy_text(&self, module_id: &str, payload: &Value) -> CoreResult<(String, usize)> { let handle = self.job(module_id, &payload["jobId"])?; let job = handle.job.lock(); let file_id = payload.get("fileId").and_then(Value::as_str).filter(|s| !s.is_empty()); let algorithm_id = payload.get("algorithmId").and_then(Value::as_str).filter(|s| !s.is_empty()); let compact = payload["compact"] == true; let mut lines = Vec::new(); for result in &job.results { if file_id.is_some_and(|id| result["file"]["fileId"] != id) { continue; } for hash in result["hashes"].as_array().into_iter().flatten() { if algorithm_id.is_some_and(|id| hash["id"] != id) { continue; } let value = hash["value"].as_str().unwrap_or(""); lines.push(if compact { value.to_string() } else { format!("{value} {} ({})", result["file"]["relativePath"].as_str().unwrap_or(""), hash["id"].as_str().unwrap_or("")) }); } } if lines.is_empty() { return Err(typed("HASH_RESULT_NOT_FOUND", "No matching completed result is available to copy.")); } let count = lines.len(); Ok((lines.join("\n"), count)) } /// Module window closed: forget its selections, cancel and drop its jobs. pub fn cleanup_module(&self, module_id: &str) { self.selections.lock().remove(module_id); let mut jobs = self.jobs.lock(); jobs.retain(|_, handle| { let mut job = handle.job.lock(); if job.module_id != module_id { return true; } if !job.terminal() { handle.cancel.store(true, Ordering::SeqCst); job.state = "cancelled"; job.error = Some(json!({ "code": "HASH_CANCELLED", "message": "Hash job was cancelled." })); job.completed_at = Some(now_ms()); } false }); } } fn run_job(handle: Arc, emitter: ProgressEmitter, logger: Option>) { let emit = |handle: &JobHandle| { let (module, public) = { let job = handle.job.lock(); (job.module_id.clone(), job.public()) }; emitter(&module, public); }; { let mut job = handle.job.lock(); if job.state == "cancelled" { return; } job.state = "running"; } emit(&handle); let outcome = (|| -> CoreResult<()> { let (files, algorithms, mode, output, expected) = { let job = handle.job.lock(); (job.files.clone(), job.algorithms.clone(), job.mode, job.output.clone(), job.expected.clone()) }; let mut completed: u64 = 0; for (index, file) in files.iter().enumerate() { if handle.cancel.load(Ordering::SeqCst) { return Err(engine::cancelled()); } { let mut job = handle.job.lock(); job.current_index = index as i64; job.bytes = completed; } emit(&handle); let mut result = Map::new(); result.insert("file".into(), file.public()); if let Some(error) = &file.preflight_error { result.insert("status".into(), json!(if error["code"] == "ENOENT" { "MISSING" } else { "INVALID ENTRY" })); result.insert("error".into(), error.clone()); handle.job.lock().results.push(Value::Object(result)); continue; } let requests = file.algorithms.clone().unwrap_or_else(|| algorithms.clone()); let identity = file.identity.clone().unwrap_or(Identity { size: 0, modified_ns: 0, device: 0, inode: 0 }); let mut last_emit = Instant::now(); let hashed = engine::hash_file(&file.path, &identity, &requests, &handle.cancel, |bytes| { handle.job.lock().bytes = completed + bytes; if last_emit.elapsed() >= Duration::from_millis(100) { last_emit = Instant::now(); emit(&handle); } }); match hashed { Ok(digests) => { result.insert("status".into(), json!("COMPLETED")); result.insert("hashes".into(), Value::Array(digests.iter().map(|(id, d)| format_result(id, d, &output)).collect())); let actual = digests.first().map(|(_, d)| hex::encode(d)).unwrap_or_default(); if mode == "verify" { let wanted = expected.clone().unwrap_or_default(); result.insert("status".into(), json!(if safe_hex_equal(&wanted, &actual) { "MATCH" } else { "MISMATCH" })); result.insert("expected".into(), json!(wanted)); result.insert("actual".into(), json!(actual)); } else if mode == "verify-manifest" { let wanted = file.expected.clone().unwrap_or_default(); result.insert("status".into(), json!(if safe_hex_equal(&wanted, &actual) { "MATCH" } else { "MISMATCH" })); result.insert("expected".into(), json!(wanted)); result.insert("actual".into(), json!(actual)); } } Err(error) if error.code == "HASH_CANCELLED" => return Err(error), Err(error) => { result.insert("status".into(), json!("ERROR")); result.insert("error".into(), safe_error(&error)); } } completed += file.size; { let mut job = handle.job.lock(); if job.state == "cancelled" { return Err(engine::cancelled()); } job.results.push(Value::Object(result)); job.bytes = completed; } emit(&handle); } let mut job = handle.job.lock(); if job.state == "cancelled" { return Err(engine::cancelled()); } if job.mode == "compare" { let complete = job.results.len() == 2 && job.results.iter().all(|r| r["status"] == "COMPLETED"); let hex_of = |r: &Value, id: &str| r["hashes"].as_array().and_then(|h| h.iter().find(|x| x["id"] == id)).map(|x| x["hex"].clone()); let identical = complete && job.algorithms.iter().all(|a| hex_of(&job.results[0], a.id) == hex_of(&job.results[1], a.id)); job.comparison = Some(json!({ "digestBased": true, "identical": identical })); } if job.mode == "generate-manifest" { write_manifest(&mut job)?; } job.state = "completed"; job.completed_at = Some(now_ms()); job.current_index = -1; Ok(()) })(); match outcome { Ok(()) => { emit(&handle); if let Some(logger) = &logger { let job = handle.job.lock(); logger.info("Local hash job completed.", json!({ "pluginId": job.module_id, "jobId": job.id, "mode": job.mode, "files": job.files.len(), "algorithms": job.algorithms.iter().map(|a| a.id).collect::>() })); } } Err(error) => { { let mut job = handle.job.lock(); if job.state == "cancelled" { return; } job.state = if error.code == "HASH_CANCELLED" { "cancelled" } else { "failed" }; job.error = Some(safe_error(&error)); job.completed_at = Some(now_ms()); if let Some(logger) = &logger { logger.warn("Local hash job failed.", json!({ "pluginId": job.module_id, "jobId": job.id, "code": job.error.as_ref().map(|e| e["code"].clone()), "message": error.message })); } } emit(&handle); } } } fn write_manifest(job: &mut Job) -> CoreResult<()> { let target = job.manifest.as_ref().ok_or_else(|| typed("HASH_INVALID_MANIFEST", "Missing manifest destination."))?; let algorithm = job.algorithms[0].algorithm(); let mut lines = Vec::new(); if target.format != "sfv" { lines.push(format!("# Algorithm: {}", algorithm.id)); } for result in &job.results { if result["status"] != "COMPLETED" { continue; } let filename = clean_name(result["file"]["relativePath"].as_str().unwrap_or("")).replace('\\', "/"); let digest = result["hashes"][0]["hex"].as_str().unwrap_or(""); lines.push(match target.format.as_str() { "bsd" => format!("{} ({filename}) = {digest}", algorithm.name), "sfv" => format!("{filename} {}", digest.to_uppercase()), _ => format!("{digest} {filename}"), }); } write_authorized(&target.destination, &format!("{}\n", lines.join("\n")))?; let entries = lines.len() - if target.format == "sfv" { 0 } else { 1 }; job.manifest_result = Some(json!({ "name": base_name(&target.destination), "entries": entries, "format": target.format })); Ok(()) } fn write_authorized(destination: &Path, text: &str) -> CoreResult<()> { use std::io::Write; let mut options = std::fs::OpenOptions::new(); options.write(true).create(true).truncate(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.custom_flags(libc::O_NOFOLLOW).mode(0o600); } #[cfg(windows)] if std::fs::symlink_metadata(destination).map(|m| m.file_type().is_symlink()).unwrap_or(false) { return Err(typed("HASH_SYMLINK_REJECTED", "Manifest destination must be a regular file and cannot be a symbolic link.")); } let mut file = options.open(destination).map_err(io_error)?; file.write_all(text.as_bytes()).map_err(io_error)?; file.sync_all().map_err(io_error)?; Ok(()) } // --- path authorization --------------------------------------------------------------------------- fn authorize_regular_file(source: &Path, max_bytes: u64) -> CoreResult { let original = std::path::absolute(source).map_err(io_error)?; let lstat = std::fs::symlink_metadata(&original).map_err(io_error)?; if lstat.file_type().is_symlink() { return Err(typed("HASH_SYMLINK_REJECTED", "Symbolic links are not accepted as file selections.")); } let canonical = std::fs::canonicalize(&original).map_err(io_error)?; let stat = std::fs::metadata(&canonical).map_err(io_error)?; if !stat.is_file() { return Err(typed("HASH_NOT_REGULAR_FILE", "Only regular files can be selected.")); } if stat.len() > max_bytes || stat.len() > MAX_SAFE_INTEGER { return Err(typed("HASH_FILE_TOO_LARGE", "Selected file exceeds the supported size.")); } Ok(FileEntry { id: random_id("file"), name: base_name(&canonical), size: stat.len(), identity: Some(Identity::of(&stat)), path: canonical, relative_path: None, algorithms: None, expected: None, preflight_error: None, }) } fn authorize_directory(source: &Path) -> CoreResult { let original = std::path::absolute(source).map_err(io_error)?; let lstat = std::fs::symlink_metadata(&original).map_err(io_error)?; if lstat.file_type().is_symlink() { return Err(typed("HASH_SYMLINK_REJECTED", "Symbolic links are not accepted as directory selections.")); } let canonical = std::fs::canonicalize(&original).map_err(io_error)?; if !std::fs::metadata(&canonical).map_err(io_error)?.is_dir() { return Err(typed("HASH_INVALID_SELECTION", "The selection is not a directory.")); } Ok(canonical) } fn relative_slash(root: &Path, path: &Path) -> String { path.strip_prefix(root).unwrap_or(path).components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::>().join("/") } fn common_directory(directories: &[PathBuf]) -> PathBuf { let mut candidate = directories[0].clone(); while !directories.iter().all(|d| contains_path(&candidate, d)) { match candidate.parent() { Some(parent) => candidate = parent.to_path_buf(), None => return candidate, } } candidate } fn enumerate_directory(root: &Path, recursive: bool) -> CoreResult> { let mut files = Vec::new(); visit(root, root, "", recursive, &mut files)?; Ok(files) } fn visit(root: &Path, current: &Path, relative_root: &str, recursive: bool, files: &mut Vec) -> CoreResult<()> { if std::fs::symlink_metadata(current).map_err(io_error)?.file_type().is_symlink() { return Ok(()); } let canonical = std::fs::canonicalize(current).map_err(io_error)?; if !contains_path(root, &canonical) || !std::fs::metadata(&canonical).map_err(io_error)?.is_dir() { return Err(typed("HASH_PATH_ESCAPE", "Directory traversal left the selected root.")); } let mut entries: Vec = std::fs::read_dir(&canonical).map_err(io_error)?.filter_map(Result::ok).collect(); entries.sort_by_key(|e| { let name = e.file_name().to_string_lossy().to_string(); (name.to_lowercase(), name) }); for entry in entries { if files.len() >= MAX_FILES_PER_JOB { return Err(typed("HASH_TOO_MANY_FILES", format!("A job can contain at most {MAX_FILES_PER_JOB} files."))); } let Ok(kind) = entry.file_type() else { continue }; if kind.is_symlink() { continue; } let name = entry.file_name().to_string_lossy().to_string(); let full = canonical.join(entry.file_name()); let relative = if relative_root.is_empty() { name.clone() } else { format!("{relative_root}/{name}") }; if kind.is_dir() { if recursive { visit(root, &full, &relative, recursive, files)?; } continue; } if !kind.is_file() { continue; } let mut file = authorize_regular_file(&full, MAX_SAFE_INTEGER)?; if !contains_path(root, &file.path) { return Err(typed("HASH_PATH_ESCAPE", "Directory traversal left the selected root.")); } file.relative_path = Some(relative); files.push(file); } Ok(()) } fn normalize_expected(value: &Value, request: &Request, encoding: &str) -> CoreResult { let source = value.as_str().map(str::to_string).unwrap_or_else(|| if value.is_null() { String::new() } else { value.to_string() }); let source = source.trim(); let bytes = if encoding == "base64" { let valid = regex::Regex::new(r"^[A-Za-z0-9+/]*={0,2}$").unwrap().is_match(source); if !valid { return Err(typed("HASH_INVALID_EXPECTED", "Expected digest is not valid Base64.")); } // Node decoded leniently (missing padding accepted). base64::engine::general_purpose::GeneralPurpose::new(&base64::alphabet::STANDARD, base64::engine::GeneralPurposeConfig::new().with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent).with_decode_allow_trailing_bits(true)) .decode(source) .map_err(|_| typed("HASH_INVALID_EXPECTED", "Expected digest is not valid Base64."))? } else { if source.is_empty() || source.len() % 2 != 0 || !source.chars().all(|c| c.is_ascii_hexdigit()) { return Err(typed("HASH_INVALID_EXPECTED", "Expected digest must be hexadecimal.")); } hex::decode(source).map_err(|_| typed("HASH_INVALID_EXPECTED", "Expected digest must be hexadecimal."))? }; let wanted = request.algorithm().output_bytes(request); if bytes.len() != wanted { return Err(typed("HASH_INVALID_EXPECTED", format!("Expected digest must be exactly {wanted} bytes."))); } Ok(hex::encode(bytes)) } // --- manifests ------------------------------------------------------------------------------------ #[derive(Debug, Clone)] struct ManifestEntry { algorithm_id: String, filename: String, expected: String, invalid: bool, } fn safe_manifest_path(value: &str) -> CoreResult { let source = value.replace('\\', "/"); let drive = source.len() >= 3 && source.as_bytes()[0].is_ascii_alphabetic() && &source[1..3] == ":/"; if source.is_empty() || source.contains('\0') || source.starts_with('/') || drive { return Err(typed("HASH_PATH_ESCAPE", "Manifest contains an absolute or invalid path.")); } let segments: Vec<&str> = source.split('/').collect(); if segments.iter().any(|s| s.is_empty() || *s == "." || *s == "..") { return Err(typed("HASH_PATH_ESCAPE", "Manifest contains path traversal.")); } // Windows: a segment like "C:" or a prefix component would escape the root. if Path::new(&source).components().any(|c| !matches!(c, Component::Normal(_))) { return Err(typed("HASH_PATH_ESCAPE", "Manifest contains an absolute or invalid path.")); } Ok(segments.join("/")) } fn assert_no_symlink_components(root: &Path, relative: &str) -> CoreResult<()> { let mut current = root.to_path_buf(); for segment in relative.split('/') { current = current.join(segment); let meta = std::fs::symlink_metadata(¤t).map_err(io_error)?; if meta.file_type().is_symlink() { return Err(typed("HASH_SYMLINK_REJECTED", "Manifest path contains a symbolic link.")); } } Ok(()) } fn algorithm_from_extension(path: &Path) -> Option<&'static str> { let ext = path.extension()?.to_string_lossy().to_lowercase(); Some(match ext.as_str() { "sha224" => "sha224", "sha256" => "sha256", "sha384" => "sha384", "sha512" => "sha512", "sha3" => "sha3-256", "blake3" => "blake3", "md5" => "md5", "sha1" => "sha1", "sfv" => "crc32", _ => return None, }) } fn parse_manifest(path: &Path, requested: Option<&str>) -> CoreResult<(Vec, Vec)> { let raw = std::fs::read(path).map_err(io_error)?; if raw.len() as u64 > MAX_MANIFEST_BYTES { return Err(typed("HASH_INVALID_MANIFEST", "Checksum manifest is too large.")); } let text = String::from_utf8_lossy(&raw); let text = text.strip_prefix('\u{feff}').unwrap_or(&text); let squash = |v: &str| v.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_uppercase(); let bsd_name = |name: &str| algorithms::ALGORITHMS.iter().filter(|a| a.xof.is_none()).find(|a| squash(a.name) == squash(name)).map(|a| a.id); let header = regex::Regex::new(r"(?i)^#\s*Algorithm:\s*([a-z0-9-]+)\s*$").unwrap(); let bsd = regex::Regex::new(r"^(.+?) \((.*)\) = ([0-9a-fA-F]+)$").unwrap(); let gnu = regex::Regex::new(r"^([0-9a-fA-F]+) [ *](.+)$").unwrap(); let sfv = regex::Regex::new(r"^(.+?)\s+([0-9a-fA-F]{8})$").unwrap(); let mut entries: Vec = Vec::new(); let mut declared: Option = None; for line in text.split('\n') { let line = line.strip_suffix('\r').unwrap_or(line); if line.trim().is_empty() { continue; } if let Some(c) = header.captures(line) { declared = Some(c[1].to_lowercase()); continue; } if line.starts_with('#') || line.starts_with(';') { continue; } if let Some(c) = bsd.captures(line) { let id = bsd_name(&c[1]).ok_or_else(|| typed("HASH_INVALID_MANIFEST", format!("Unsupported BSD manifest algorithm: {}", &c[1])))?; entries.push(ManifestEntry { algorithm_id: id.into(), filename: c[2].into(), expected: c[3].to_lowercase(), invalid: false }); continue; } if let Some(c) = gnu.captures(line) { entries.push(ManifestEntry { algorithm_id: String::new(), filename: c[2].into(), expected: c[1].to_lowercase(), invalid: false }); continue; } if let Some(c) = sfv.captures(line) { entries.push(ManifestEntry { algorithm_id: "crc32".into(), filename: c[1].into(), expected: c[2].to_lowercase(), invalid: false }); continue; } entries.push(ManifestEntry { algorithm_id: String::new(), filename: line.into(), expected: String::new(), invalid: true }); } if entries.is_empty() { return Err(typed("HASH_INVALID_MANIFEST", "Checksum manifest contains no entries.")); } let fallback: Option = requested.map(str::to_string).or(declared).or_else(|| algorithm_from_extension(path).map(str::to_string)); for entry in entries.iter_mut() { if entry.invalid { entry.algorithm_id = fallback.clone().unwrap_or_else(|| "sha256".into()); continue; } if entry.algorithm_id.is_empty() { if let Some(f) = &fallback { entry.algorithm_id = f.clone(); } } if entry.algorithm_id.is_empty() { let candidates: Vec<&str> = algorithms::ALGORITHMS.iter().filter(|a| a.digest_bits.is_some_and(|b| (b / 4) as usize == entry.expected.len())).map(|a| a.id).collect(); if candidates.len() != 1 { return Err(typed("HASH_AMBIGUOUS_ALGORITHM", "Manifest digest length is ambiguous; select the algorithm explicitly.")); } entry.algorithm_id = candidates[0].into(); } let valid = algorithms::by_id(&entry.algorithm_id).is_some_and(|a| a.xof.is_none() && a.digest_bits.is_some_and(|b| entry.expected.len() == (b / 4) as usize && entry.expected.chars().all(|c| c.is_ascii_hexdigit()))); if !valid { entry.invalid = true; } } let mut unique: Vec = Vec::new(); for entry in &entries { let id = json!(entry.algorithm_id); if !unique.contains(&id) { unique.push(id); } } let requests = normalize_requests(&Value::Array(unique))?; Ok((entries, requests)) } #[cfg(test)] mod tests { use super::*; fn service() -> (Arc, Arc>>) { let events = Arc::new(Mutex::new(Vec::new())); let sink = events.clone(); (Arc::new(LocalHashService::new(Arc::new(move |m: &str, v: Value| sink.lock().push((m.to_string(), v))), None)), events) } fn wait(service: &LocalHashService, module: &str, job: &Value) -> Value { for _ in 0..500 { let status = service.status(module, &job["jobId"]).unwrap(); if ["completed", "failed", "cancelled"].contains(&status["state"].as_str().unwrap()) { return status; } std::thread::sleep(Duration::from_millis(10)); } panic!("job did not finish"); } #[test] fn calculate_verify_compare_and_isolation() { let dir = tempfile::tempdir().unwrap(); let a = dir.path().join("a.txt"); let b = dir.path().join("b.txt"); std::fs::write(&a, "abc").unwrap(); std::fs::write(&b, "abc").unwrap(); let (svc, events) = service(); let sel = svc.select_files("m1", true, Some(vec![a.clone(), b.clone()])).unwrap(); assert_eq!(sel["files"][0]["relativePath"], "a.txt"); let job = svc.start("m1", &json!({ "selectionId": sel["selectionId"], "algorithms": ["sha256", "crc32"], "output": "hex-upper" })).unwrap(); let done = wait(&svc, "m1", &job); assert_eq!(done["state"], "completed"); assert_eq!(done["results"][0]["hashes"][0]["value"], "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD"); assert_eq!(done["percent"], 100.0); assert!(events.lock().iter().all(|(m, _)| m == "m1")); // Another module can neither use the token nor read the job. assert_eq!(svc.start("m2", &json!({ "selectionId": sel["selectionId"], "algorithms": ["sha256"] })).unwrap_err().code, "HASH_INVALID_SELECTION_TOKEN"); assert_eq!(svc.status("m2", &job["jobId"]).unwrap_err().code, "HASH_INVALID_JOB"); let one = svc.select_files("m1", false, Some(vec![a.clone()])).unwrap(); let verify = svc.verify("m1", &json!({ "selectionId": one["selectionId"], "algorithm": "md5", "expected": "900150983cd24fb0d6963f7d28e17f72" })).unwrap(); assert_eq!(wait(&svc, "m1", &verify)["results"][0]["status"], "MATCH"); let bad = svc.verify("m1", &json!({ "selectionId": one["selectionId"], "algorithm": "md5", "expected": "kAFQmDzST7DWlj99KOF/cg==", "expectedEncoding": "base64" })).unwrap(); assert_eq!(wait(&svc, "m1", &bad)["results"][0]["status"], "MATCH"); let other = svc.select_files("m1", false, Some(vec![b.clone()])).unwrap(); let cmp = svc.compare("m1", &json!({ "leftSelectionId": one["selectionId"], "rightSelectionId": other["selectionId"] })).unwrap(); assert_eq!(wait(&svc, "m1", &cmp)["comparison"]["identical"], true); let (text, count) = svc.copy_text("m1", &json!({ "jobId": job["jobId"], "algorithmId": "crc32", "compact": true })).unwrap(); assert_eq!(count, 2); assert_eq!(text, "352441C2\n352441C2"); svc.cleanup_module("m1"); assert_eq!(svc.status("m1", &job["jobId"]).unwrap_err().code, "HASH_INVALID_JOB"); } #[test] fn manifest_roundtrip_and_traversal_protection() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().join("root"); std::fs::create_dir_all(root.join("sub")).unwrap(); std::fs::write(root.join("one.txt"), "1").unwrap(); std::fs::write(root.join("sub/two.txt"), "2").unwrap(); std::fs::write(dir.path().join("secret.txt"), "s").unwrap(); let (svc, _) = service(); let directory = svc.select_directory("m", Some(root.clone())).unwrap(); let destination = svc.select_manifest_destination("m", Some(dir.path().join("sums.sha256"))).unwrap(); let job = svc.generate_manifest("m", &json!({ "algorithm": "sha256", "selectionId": directory["selectionId"], "destinationSelectionId": destination["selectionId"], "recursive": true })).unwrap(); let done = wait(&svc, "m", &job); assert_eq!(done["manifest"]["entries"], 2, "{done}"); let written = std::fs::read_to_string(dir.path().join("sums.sha256")).unwrap(); assert!(written.starts_with("# Algorithm: sha256\n")); assert!(written.contains(" sub/two.txt\n")); let mut tampered = written.clone(); tampered.push_str(&format!("{} ../secret.txt\n", "0".repeat(64))); tampered.push_str(&format!("{} /etc/passwd\n", "0".repeat(64))); tampered.push_str(&format!("{} missing.txt\n", "0".repeat(64))); tampered.push_str("garbage line\n"); std::fs::write(dir.path().join("check.sha256"), tampered).unwrap(); #[cfg(unix)] { std::os::unix::fs::symlink(dir.path().join("secret.txt"), root.join("link.txt")).unwrap(); let mut extra = std::fs::read_to_string(dir.path().join("check.sha256")).unwrap(); extra.push_str(&format!("{} link.txt\n", "0".repeat(64))); std::fs::write(dir.path().join("check.sha256"), extra).unwrap(); } let manifest = svc.select_manifest("m", Some(dir.path().join("check.sha256"))).unwrap(); let verify = svc.verify_manifest("m", &json!({ "manifestSelectionId": manifest["selectionId"], "rootSelectionId": directory["selectionId"] })).unwrap(); let done = wait(&svc, "m", &verify); let statuses: Vec = done["results"].as_array().unwrap().iter().map(|r| format!("{}:{}", r["file"]["relativePath"].as_str().unwrap(), r["status"].as_str().unwrap())).collect(); assert!(statuses.contains(&"one.txt:MATCH".to_string()), "{statuses:?}"); assert!(statuses.contains(&"sub/two.txt:MATCH".to_string())); assert!(statuses.contains(&"../secret.txt:INVALID ENTRY".to_string())); assert!(statuses.contains(&"/etc/passwd:INVALID ENTRY".to_string())); assert!(statuses.contains(&"missing.txt:MISSING".to_string())); assert!(statuses.contains(&"garbage line:INVALID ENTRY".to_string())); #[cfg(unix)] assert!(statuses.contains(&"link.txt:INVALID ENTRY".to_string())); } #[test] fn cancel_large_job() { let dir = tempfile::tempdir().unwrap(); let big = dir.path().join("big.bin"); std::fs::write(&big, vec![7u8; 64 * 1024 * 1024]).unwrap(); let (svc, _) = service(); let sel = svc.select_files("m", false, Some(vec![big])).unwrap(); let job = svc.start("m", &json!({ "selectionId": sel["selectionId"], "algorithms": ["sha512", "whirlpool", "sha3-512"] })).unwrap(); let cancelled = svc.cancel("m", &job["jobId"]).unwrap(); assert_eq!(cancelled["state"], "cancelled"); std::thread::sleep(Duration::from_millis(200)); assert_eq!(svc.status("m", &job["jobId"]).unwrap()["state"], "cancelled"); } }