CH-J Toolsspráva serverov nad SSH
Menu
Uverejnený zdroj

CH-J Tools

Prechádzať adresáre a súbory pre konkrétne vydanie aplikácie.

Stiahnuť zdroj ZIP
CH-J / Server Manager 1704 súbory
0.1.2-beta Pridané: · Postavený:
service.rs Pridané: Dátum nezaznamenaný 54,7 KB · 1161 riadky
Kód Stiahnuť súbor
1//! Local Hash & Checksum service (port of `hashing/localHashService.js`).
2//!
3//! Modules never see local paths: Core dialogs return opaque, per-module selection tokens
4//! (30 min, max 64 per module). Jobs run on a dedicated thread, report progress to the owning
5//! module window and can be cancelled. Manifest verification rejects absolute paths,
6//! traversal and symbolic links and never leaves the selected root directory.
8use super::algorithms::{self, normalize_requests, typed, Request};
9use super::engine::{self, Identity};
10use crate::core::error::{CoreError, CoreResult};
11use crate::core::logging::Logger;
12use base64::engine::general_purpose::{STANDARD as B64, URL_SAFE_NO_PAD};
13use base64::Engine;
14use parking_lot::Mutex;
15use rand::RngCore;
16use serde_json::{json, Map, Value};
17use std::collections::HashMap;
18use std::path::{Component, Path, PathBuf};
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::Arc;
21use std::time::{Duration, Instant};
23pub const MAX_SELECTIONS_PER_MODULE: usize = 64;
24pub const MAX_FILES_PER_JOB: usize = 10_000;
25pub const MAX_MANIFEST_BYTES: u64 = 10 * 1024 * 1024;
26const TOKEN_TTL: Duration = Duration::from_secs(30 * 60);
27const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1;
29fn random_id(prefix: &str) -> String {
30 let mut bytes = [0u8; 24];
31 rand::rngs::OsRng.fill_bytes(&mut bytes);
32 format!("{prefix}_{}", URL_SAFE_NO_PAD.encode(bytes))
35fn now_ms() -> i64 {
36 chrono::Utc::now().timestamp_millis()
39fn clean_name(value: &str) -> String {
40 value.replace(['\r', '\n', '\0'], "_")
43/// Maps I/O failures to the Node error codes the 0.1.0 UI and logs used.
44pub fn io_error(error: std::io::Error) -> CoreError {
45 match error.kind() {
46 std::io::ErrorKind::NotFound => CoreError::new("ENOENT", "ENOENT: no such file or directory"),
47 std::io::ErrorKind::PermissionDenied => CoreError::new("EACCES", "EACCES: permission denied"),
48 _ => CoreError::new("EIO", error.to_string()),
49 }
52const 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"];
54pub fn safe_error(error: &CoreError) -> Value {
55 if KNOWN_CODES.contains(&error.code.as_str()) {
56 json!({ "code": error.code, "message": if error.message.is_empty() { "Hash operation failed." } else { error.message.as_str() } })
57 } else {
58 json!({ "code": "HASH_INTERNAL_ERROR", "message": "An internal hashing error occurred." })
59 }
62fn contains_path(root: &Path, candidate: &Path) -> bool {
63 candidate.starts_with(root)
66fn base_name(path: &Path) -> String {
67 path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string_lossy().to_string())
70#[derive(Debug, Clone)]
71struct FileEntry {
72 id: String,
73 path: PathBuf,
74 name: String,
75 size: u64,
76 identity: Option<Identity>,
77 relative_path: Option<String>,
78 algorithms: Option<Vec<Request>>,
79 expected: Option<String>,
80 preflight_error: Option<Value>,
83impl FileEntry {
84 fn public(&self) -> Value {
85 json!({ "fileId": self.id, "name": self.name, "relativePath": self.relative_path.clone().unwrap_or_else(|| self.name.clone()), "size": self.size })
86 }
89#[derive(Debug, Clone)]
90enum Selection {
91 Files(Vec<FileEntry>),
92 Directory(PathBuf),
93 Manifest(FileEntry),
94 Destination(PathBuf),
97impl Selection {
98 fn kind(&self) -> &'static str {
99 match self {
100 Selection::Files(_) => "files",
101 Selection::Directory(_) => "directory",
102 Selection::Manifest(_) => "manifest",
103 Selection::Destination(_) => "destination",
104 }
105 }
108struct StoredSelection {
109 id: String,
110 created: Instant,
111 selection: Selection,
114struct ManifestTarget {
115 format: String,
116 destination: PathBuf,
119struct Job {
120 id: String,
121 module_id: String,
122 state: &'static str,
123 mode: &'static str,
124 algorithms: Vec<Request>,
125 output: String,
126 files: Vec<FileEntry>,
127 expected: Option<String>,
128 manifest: Option<ManifestTarget>,
129 results: Vec<Value>,
130 current_index: i64,
131 bytes: u64,
132 total_bytes: u64,
133 started_at: i64,
134 completed_at: Option<i64>,
135 error: Option<Value>,
136 comparison: Option<Value>,
137 manifest_result: Option<Value>,
140impl Job {
141 fn terminal(&self) -> bool {
142 matches!(self.state, "completed" | "failed" | "cancelled")
143 }
145 fn public(&self) -> Value {
146 let elapsed = (self.completed_at.unwrap_or_else(now_ms) - self.started_at).max(0);
147 let throughput = if elapsed > 0 { self.bytes as f64 / (elapsed as f64 / 1000.0) } else { 0.0 };
148 let remaining = self.total_bytes.saturating_sub(self.bytes);
149 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 };
150 let current = usize::try_from(self.current_index).ok().and_then(|i| self.files.get(i)).map(FileEntry::public);
151 json!({
152 "jobId": self.id,
153 "mode": self.mode,
154 "state": self.state,
155 "algorithms": self.algorithms.iter().map(Request::public).collect::<Vec<_>>(),
156 "filesCompleted": self.results.len(),
157 "filesTotal": self.files.len(),
158 "currentFile": current,
159 "bytes": self.bytes,
160 "totalBytes": self.total_bytes,
161 "percent": percent,
162 "throughputBytesPerSecond": throughput,
163 "elapsedMs": elapsed,
164 "etaMs": if throughput > 0.0 { json!(remaining as f64 / throughput * 1000.0) } else { Value::Null },
165 "results": self.results,
166 "comparison": self.comparison,
167 "manifest": self.manifest_result,
168 "error": self.error,
169 })
170 }
173struct JobHandle {
174 job: Mutex<Job>,
175 cancel: AtomicBool,
178pub type ProgressEmitter = Arc<dyn Fn(&str, Value) + Send + Sync>;
180pub struct LocalHashService {
181 selections: Mutex<HashMap<String, Vec<StoredSelection>>>,
182 jobs: Mutex<HashMap<String, Arc<JobHandle>>>,
183 emitter: ProgressEmitter,
184 logger: Option<Arc<Logger>>,
187fn format_result(id: &str, digest: &[u8], output: &str) -> Value {
188 let hex = hex::encode(digest);
189 let value = match output {
190 "base64" => B64.encode(digest),
191 "hex-upper" => hex.to_uppercase(),
192 _ => hex.clone(),
193 };
194 json!({ "id": id, "hex": hex, "value": value, "encoding": output })
197fn safe_hex_equal(left: &str, right: &str) -> bool {
198 match (hex::decode(left), hex::decode(right)) {
199 (Ok(a), Ok(b)) => a.len() == b.len() && subtle::ConstantTimeEq::ct_eq(a.as_slice(), b.as_slice()).into(),
200 _ => false,
201 }
204impl LocalHashService {
205 pub fn new(emitter: ProgressEmitter, logger: Option<Arc<Logger>>) -> Self {
206 Self { selections: Mutex::new(HashMap::new()), jobs: Mutex::new(HashMap::new()), emitter, logger }
207 }
209 pub fn algorithms(&self) -> Value {
210 algorithms::public_list()
211 }
213 // --- selections (paths come from Core dialogs only) ---------------------------------------
215 pub fn select_files(&self, module_id: &str, multiple: bool, picked: Option<Vec<PathBuf>>) -> CoreResult<Value> {
216 let Some(sources) = picked else { return Ok(json!({ "canceled": true, "selectionId": null, "files": [] })) };
217 if sources.is_empty() || (!multiple && sources.len() != 1) {
218 return Err(typed("HASH_INVALID_SELECTION", "The file picker returned an invalid selection."));
219 }
220 let mut files = sources.iter().map(|s| authorize_regular_file(s, MAX_SAFE_INTEGER)).collect::<CoreResult<Vec<_>>>()?;
221 let parents: Vec<PathBuf> = files.iter().map(|f| f.path.parent().map(Path::to_path_buf).unwrap_or_default()).collect();
222 let root = if files.len() > 1 { common_directory(&parents) } else { parents[0].clone() };
223 for file in files.iter_mut() {
224 file.relative_path = Some(relative_slash(&root, &file.path));
225 }
226 let public: Vec<Value> = files.iter().map(FileEntry::public).collect();
227 let id = self.store(module_id, Selection::Files(files));
228 Ok(json!({ "canceled": false, "selectionId": id, "files": public }))
229 }
231 pub fn select_directory(&self, module_id: &str, picked: Option<PathBuf>) -> CoreResult<Value> {
232 let Some(source) = picked else { return Ok(json!({ "canceled": true, "selectionId": null })) };
233 let directory = authorize_directory(&source)?;
234 let name = base_name(&directory);
235 let id = self.store(module_id, Selection::Directory(directory));
236 Ok(json!({ "canceled": false, "selectionId": id, "name": name }))
237 }
239 pub fn select_manifest(&self, module_id: &str, picked: Option<PathBuf>) -> CoreResult<Value> {
240 let Some(source) = picked else { return Ok(json!({ "canceled": true, "selectionId": null })) };
241 let file = authorize_regular_file(&source, MAX_MANIFEST_BYTES)?;
242 let public = file.public();
243 let id = self.store(module_id, Selection::Manifest(file));
244 Ok(json!({ "canceled": false, "selectionId": id, "file": public }))
245 }
247 /// Signature or public key chosen in a Core dialog: small files only, never read before verification.
248 pub fn select_pgp_file(&self, module_id: &str, kind: &str, picked: Option<PathBuf>) -> CoreResult<Value> {
249 let Some(source) = picked else { return Ok(json!({ "canceled": true, "selectionId": null })) };
250 let limit = if kind == "key" { super::pgp::MAX_KEY_BYTES } else { super::pgp::MAX_SIGNATURE_BYTES };
251 let file = authorize_regular_file(&source, limit)?;
252 let public = file.public();
253 let id = self.store(module_id, Selection::Manifest(file));
254 Ok(json!({ "canceled": false, "selectionId": id, "file": public }))
255 }
257 fn selected_small_file(&self, module_id: &str, id: &Value, limit: u64) -> CoreResult<Vec<u8>> {
258 let Selection::Manifest(file) = self.selection(module_id, id, &["manifest"])? else {
259 return Err(typed("HASH_INVALID_SELECTION_TOKEN", "Unknown, expired, or unauthorized selection token."));
260 };
261 let bytes = std::fs::read(&file.path).map_err(io_error)?;
262 if bytes.len() as u64 > limit {
263 return Err(typed("HASH_INVALID_SELECTION", "The selected file is too large."));
264 }
265 Ok(bytes)
266 }
268 /// Collects the chosen inputs; the check itself reads the whole file, so it runs on a blocking thread.
269 pub async fn pgp_verify(&self, module_id: &str, payload: &Value, keyring: PathBuf) -> CoreResult<Value> {
270 let file = self.files_of(self.selection(module_id, &payload["fileSelectionId"], &["files"])?, false)?.into_iter().next().ok_or_else(|| typed("HASH_INVALID_SELECTION", "Choose a local file first."))?;
271 let signature = self.selected_small_file(module_id, &payload["signatureSelectionId"], super::pgp::MAX_SIGNATURE_BYTES)?;
272 let text = payload["keyText"].as_str().unwrap_or("").trim();
273 if text.len() as u64 > super::pgp::MAX_KEY_BYTES {
274 return Err(typed("HASH_PGP_KEY_MALFORMED", "The file does not contain an OpenPGP public key."));
275 }
276 let key = if !payload["keySelectionId"].is_null() {
277 Some(self.selected_small_file(module_id, &payload["keySelectionId"], super::pgp::MAX_KEY_BYTES)?)
278 } else if !text.is_empty() {
279 Some(text.as_bytes().to_vec())
280 } else {
281 None
282 };
283 let fingerprint = payload["fingerprint"].as_str().unwrap_or("").to_string();
284 let save = payload["saveKey"] == true;
285 tokio::task::spawn_blocking(move || super::pgp::verify_file(&file.path, &signature, key.as_deref(), &keyring, &fingerprint, save))
286 .await
287 .map_err(|_| typed("HASH_PGP_FILE_UNREADABLE", "The file could not be read."))?
288 }
290 pub fn suggested_manifest_name(payload: &Value) -> String {
291 clean_name(payload.get("suggestedName").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("checksums.sha256"))
292 }
294 pub fn select_manifest_destination(&self, module_id: &str, picked: Option<PathBuf>) -> CoreResult<Value> {
295 let Some(chosen) = picked else { return Ok(json!({ "canceled": true, "selectionId": null })) };
296 let invalid = || typed("HASH_INVALID_DESTINATION", "Manifest destination directory is invalid.");
297 let file_name = chosen.file_name().ok_or_else(invalid)?.to_owned();
298 let parent = std::fs::canonicalize(chosen.parent().ok_or_else(invalid)?).map_err(io_error)?;
299 if !std::fs::metadata(&parent).map_err(io_error)?.is_dir() {
300 return Err(invalid());
301 }
302 let destination = parent.join(&file_name);
303 match std::fs::symlink_metadata(&destination) {
304 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.")),
305 Ok(_) => {}
306 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
307 Err(e) => return Err(io_error(e)),
308 }
309 let name = base_name(&destination);
310 let id = self.store(module_id, Selection::Destination(destination));
311 Ok(json!({ "canceled": false, "selectionId": id, "name": name }))
312 }
314 fn store(&self, module_id: &str, selection: Selection) -> String {
315 let mut all = self.selections.lock();
316 let values = all.entry(module_id.to_string()).or_default();
317 values.retain(|s| s.created.elapsed() <= TOKEN_TTL);
318 while values.len() >= MAX_SELECTIONS_PER_MODULE {
319 values.remove(0);
320 }
321 let id = random_id("selection");
322 values.push(StoredSelection { id: id.clone(), created: Instant::now(), selection });
323 id
324 }
326 fn selection(&self, module_id: &str, id: &Value, kinds: &[&str]) -> CoreResult<Selection> {
327 let id = id.as_str().unwrap_or("");
328 let all = self.selections.lock();
329 all.get(module_id)
330 .and_then(|values| values.iter().find(|s| s.id == id))
331 .filter(|s| s.created.elapsed() <= TOKEN_TTL && kinds.contains(&s.selection.kind()))
332 .map(|s| s.selection.clone())
333 .ok_or_else(|| typed("HASH_INVALID_SELECTION_TOKEN", "Unknown, expired, or unauthorized selection token."))
334 }
336 fn files_of(&self, selection: Selection, recursive: bool) -> CoreResult<Vec<FileEntry>> {
337 match selection {
338 Selection::Files(files) => Ok(files),
339 Selection::Directory(root) => enumerate_directory(&root, recursive),
340 _ => Err(typed("HASH_INVALID_SELECTION_TOKEN", "Unknown, expired, or unauthorized selection token.")),
341 }
342 }
344 // --- jobs ---------------------------------------------------------------------------------
346 pub fn start(&self, module_id: &str, payload: &Value) -> CoreResult<Value> {
347 let algorithms = normalize_requests(payload.get("algorithms").unwrap_or(&Value::Null))?;
348 let output = payload.get("output").and_then(Value::as_str).filter(|o| ["hex-lower", "hex-upper", "base64"].contains(o)).unwrap_or("hex-lower");
349 let selection = self.selection(module_id, &payload["selectionId"], &["files", "directory"])?;
350 let files = self.files_of(selection, payload["recursive"] == true)?;
351 self.start_job(module_id, "calculate", algorithms, output, files, None, None)
352 }
354 pub fn verify(&self, module_id: &str, payload: &Value) -> CoreResult<Value> {
355 let algorithms = normalize_requests(&json!([payload.get("algorithm").cloned().unwrap_or(Value::Null)]))?;
356 let Selection::Files(files) = self.selection(module_id, &payload["selectionId"], &["files"])? else { unreachable!() };
357 if files.len() != 1 {
358 return Err(typed("HASH_INVALID_SELECTION", "Verify requires exactly one selected file."));
359 }
360 let expected = normalize_expected(&payload["expected"], &algorithms[0], payload.get("expectedEncoding").and_then(Value::as_str).unwrap_or("hex"))?;
361 self.start_job(module_id, "verify", algorithms, "hex-lower", files, Some(expected), None)
362 }
364 pub fn compare(&self, module_id: &str, payload: &Value) -> CoreResult<Value> {
365 let requested = payload.get("algorithms").filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json!(["sha256"]));
366 let algorithms = normalize_requests(&requested)?;
367 let Selection::Files(left) = self.selection(module_id, &payload["leftSelectionId"], &["files"])? else { unreachable!() };
368 let Selection::Files(right) = self.selection(module_id, &payload["rightSelectionId"], &["files"])? else { unreachable!() };
369 if left.len() != 1 || right.len() != 1 {
370 return Err(typed("HASH_INVALID_SELECTION", "Compare requires one file in each selection."));
371 }
372 self.start_job(module_id, "compare", algorithms, "hex-lower", vec![left[0].clone(), right[0].clone()], None, None)
373 }
375 pub fn generate_manifest(&self, module_id: &str, payload: &Value) -> CoreResult<Value> {
376 let algorithms = normalize_requests(&json!([payload.get("algorithm").cloned().unwrap_or(Value::Null)]))?;
377 let algorithm = algorithms[0].algorithm();
378 if algorithm.xof.is_some() || algorithm.key_bytes.is_some() || algorithm.seed_bits.is_some() {
379 return Err(typed("HASH_INVALID_MANIFEST", "Checksum manifests require a fixed, unkeyed, unseeded algorithm."));
380 }
381 let format = payload.get("format").and_then(Value::as_str).filter(|f| ["gnu", "bsd", "sfv"].contains(f)).unwrap_or("gnu");
382 if format == "sfv" && algorithm.id != "crc32" {
383 return Err(typed("HASH_INVALID_MANIFEST", "SFV manifests require CRC-32/ISO-HDLC."));
384 }
385 let source = self.selection(module_id, &payload["selectionId"], &["files", "directory"])?;
386 let Selection::Destination(destination) = self.selection(module_id, &payload["destinationSelectionId"], &["destination"])? else { unreachable!() };
387 let files = self.files_of(source, payload["recursive"] == true)?;
388 self.start_job(module_id, "generate-manifest", algorithms, "hex-lower", files, None, Some(ManifestTarget { format: format.into(), destination }))
389 }
391 pub fn verify_manifest(&self, module_id: &str, payload: &Value) -> CoreResult<Value> {
392 let Selection::Manifest(manifest) = self.selection(module_id, &payload["manifestSelectionId"], &["manifest"])? else { unreachable!() };
393 let Selection::Directory(root) = self.selection(module_id, &payload["rootSelectionId"], &["directory"])? else { unreachable!() };
394 let requested = payload.get("algorithmId").and_then(Value::as_str).filter(|s| !s.is_empty());
395 let (entries, algorithms) = parse_manifest(&manifest.path, requested)?;
396 let mut files = Vec::new();
397 for entry in entries {
398 let failed = |error: Value, entry: &ManifestEntry| FileEntry {
399 id: random_id("file"),
400 path: PathBuf::new(),
401 name: entry.filename.rsplit(['/', '\\']).next().unwrap_or("").to_string(),
402 size: 0,
403 identity: None,
404 relative_path: Some(entry.filename.clone()),
405 algorithms: Some(vec![Request::simple(algorithms::by_id(&entry.algorithm_id).map(|a| a.id).unwrap_or("sha256"))]),
406 expected: Some(entry.expected.clone()),
407 preflight_error: Some(error),
408 };
409 if entry.invalid {
410 files.push(failed(json!({ "code": "HASH_INVALID_MANIFEST", "message": "Invalid checksum manifest entry." }), &entry));
411 continue;
412 }
413 let resolved = (|| -> CoreResult<FileEntry> {
414 let relative = safe_manifest_path(&entry.filename)?;
415 let candidate = relative.split('/').fold(root.clone(), |p, s| p.join(s));
416 if !contains_path(&root, &candidate) {
417 return Err(typed("HASH_PATH_ESCAPE", "Manifest path escapes the selected directory."));
418 }
419 assert_no_symlink_components(&root, &relative)?;
420 let mut file = authorize_regular_file(&candidate, MAX_SAFE_INTEGER)?;
421 if !contains_path(&root, &file.path) {
422 return Err(typed("HASH_PATH_ESCAPE", "Manifest path resolves outside the selected directory."));
423 }
424 file.relative_path = Some(relative);
425 file.algorithms = Some(normalize_requests(&json!([{ "id": entry.algorithm_id }]))?);
426 file.expected = Some(entry.expected.clone());
427 Ok(file)
428 })();
429 match resolved {
430 Ok(file) => files.push(file),
431 Err(error) => files.push(failed(safe_error(&error), &entry)),
432 }
433 }
434 let job = self.start_job(module_id, "verify-manifest", algorithms, "hex-lower", files, None, None)?;
435 // 0.1.0 stored the source name on the job without exposing it; nothing else to keep.
436 let _ = manifest.name;
437 Ok(job)
438 }
440 #[allow(clippy::too_many_arguments)]
441 fn start_job(&self, module_id: &str, mode: &'static str, algorithms: Vec<Request>, output: &str, files: Vec<FileEntry>, expected: Option<String>, manifest: Option<ManifestTarget>) -> CoreResult<Value> {
442 if files.is_empty() {
443 return Err(typed("HASH_EMPTY_SELECTION", "No regular files were selected."));
444 }
445 if files.len() > MAX_FILES_PER_JOB {
446 return Err(typed("HASH_TOO_MANY_FILES", format!("A job can contain at most {MAX_FILES_PER_JOB} files.")));
447 }
448 let total_bytes = files.iter().map(|f| f.size).sum();
449 let job = Job {
450 id: random_id("job"),
451 module_id: module_id.to_string(),
452 state: "queued",
453 mode,
454 algorithms,
455 output: output.to_string(),
456 files,
457 expected,
458 manifest,
459 results: Vec::new(),
460 current_index: -1,
461 bytes: 0,
462 total_bytes,
463 started_at: now_ms(),
464 completed_at: None,
465 error: None,
466 comparison: None,
467 manifest_result: None,
468 };
469 let id = job.id.clone();
470 let handle = Arc::new(JobHandle { job: Mutex::new(job), cancel: AtomicBool::new(false) });
471 self.jobs.lock().insert(id, handle.clone());
472 let public = handle.job.lock().public();
473 self.emit(&handle);
474 let emitter = self.emitter.clone();
475 let logger = self.logger.clone();
476 std::thread::Builder::new()
477 .name("chj-hash-job".into())
478 .spawn(move || run_job(handle, emitter, logger))
479 .map_err(|_| CoreError::new("HASH_INTERNAL_ERROR", "An internal hashing error occurred."))?;
480 Ok(public)
481 }
483 fn emit(&self, handle: &JobHandle) {
484 let (module, public) = {
485 let job = handle.job.lock();
486 (job.module_id.clone(), job.public())
487 };
488 (self.emitter)(&module, public);
489 }
491 fn job(&self, module_id: &str, job_id: &Value) -> CoreResult<Arc<JobHandle>> {
492 let id = job_id.as_str().unwrap_or("");
493 self.jobs
494 .lock()
495 .get(id)
496 .filter(|h| h.job.lock().module_id == module_id)
497 .cloned()
498 .ok_or_else(|| typed("HASH_INVALID_JOB", "Unknown or unauthorized hash job."))
499 }
501 pub fn status(&self, module_id: &str, job_id: &Value) -> CoreResult<Value> {
502 Ok(self.job(module_id, job_id)?.job.lock().public())
503 }
505 pub fn cancel(&self, module_id: &str, job_id: &Value) -> CoreResult<Value> {
506 let handle = self.job(module_id, job_id)?;
507 {
508 let mut job = handle.job.lock();
509 if job.terminal() {
510 return Ok(job.public());
511 }
512 handle.cancel.store(true, Ordering::SeqCst);
513 job.state = "cancelled";
514 job.error = Some(json!({ "code": "HASH_CANCELLED", "message": "Hash job was cancelled." }));
515 job.completed_at = Some(now_ms());
516 }
517 self.emit(&handle);
518 let public = handle.job.lock().public();
519 Ok(public)
520 }
522 pub fn export_results(&self, module_id: &str, payload: &Value) -> CoreResult<Value> {
523 let handle = self.job(module_id, &payload["jobId"])?;
524 let Selection::Destination(destination) = self.selection(module_id, &payload["destinationSelectionId"], &["destination"])? else { unreachable!() };
525 let job = handle.job.lock();
526 if job.state != "completed" {
527 return Err(typed("HASH_JOB_NOT_COMPLETED", "Only completed hash results can be exported."));
528 }
529 let format = if payload["format"] == "json" { "json" } else { "text" };
530 let serializable: Vec<Value> = job
531 .results
532 .iter()
533 .map(|r| {
534 let mut hashes = Map::new();
535 for hash in r["hashes"].as_array().into_iter().flatten() {
536 hashes.insert(hash["id"].as_str().unwrap_or("").to_string(), hash["value"].clone());
537 }
538 json!({ "file": r["file"]["relativePath"], "size": r["file"]["size"], "status": r["status"], "hashes": hashes })
539 })
540 .collect();
541 drop(job);
542 let text = if format == "json" {
543 format!("{}\n", serde_json::to_string_pretty(&json!({ "generatedBy": "CH-J Tools Hash & Checksum", "results": serializable })).unwrap_or_default())
544 } else {
545 let lines: Vec<String> = serializable
546 .iter()
547 .flat_map(|r| {
548 let file = r["file"].as_str().unwrap_or("");
549 let size = &r["size"];
550 let status = r["status"].as_str().unwrap_or("");
551 let hashes = r["hashes"].as_object().cloned().unwrap_or_default();
552 if hashes.is_empty() {
553 vec![format!("{file}\t{size}\t\t\t{status}")]
554 } else {
555 hashes.iter().map(|(id, value)| format!("{file}\t{size}\t{id}\t{}\t{status}", value.as_str().unwrap_or(""))).collect()
556 }
557 })
558 .collect();
559 format!("{}\n", lines.join("\n"))
560 };
561 write_authorized(&destination, &text)?;
562 Ok(json!({ "ok": true, "name": base_name(&destination), "format": format }))
563 }
565 /// Text for the clipboard (the command writes it; modules have no clipboard access).
566 pub fn copy_text(&self, module_id: &str, payload: &Value) -> CoreResult<(String, usize)> {
567 let handle = self.job(module_id, &payload["jobId"])?;
568 let job = handle.job.lock();
569 let file_id = payload.get("fileId").and_then(Value::as_str).filter(|s| !s.is_empty());
570 let algorithm_id = payload.get("algorithmId").and_then(Value::as_str).filter(|s| !s.is_empty());
571 let compact = payload["compact"] == true;
572 let mut lines = Vec::new();
573 for result in &job.results {
574 if file_id.is_some_and(|id| result["file"]["fileId"] != id) {
575 continue;
576 }
577 for hash in result["hashes"].as_array().into_iter().flatten() {
578 if algorithm_id.is_some_and(|id| hash["id"] != id) {
579 continue;
580 }
581 let value = hash["value"].as_str().unwrap_or("");
582 lines.push(if compact { value.to_string() } else { format!("{value} {} ({})", result["file"]["relativePath"].as_str().unwrap_or(""), hash["id"].as_str().unwrap_or("")) });
583 }
584 }
585 if lines.is_empty() {
586 return Err(typed("HASH_RESULT_NOT_FOUND", "No matching completed result is available to copy."));
587 }
588 let count = lines.len();
589 Ok((lines.join("\n"), count))
590 }
592 /// Module window closed: forget its selections, cancel and drop its jobs.
593 pub fn cleanup_module(&self, module_id: &str) {
594 self.selections.lock().remove(module_id);
595 let mut jobs = self.jobs.lock();
596 jobs.retain(|_, handle| {
597 let mut job = handle.job.lock();
598 if job.module_id != module_id {
599 return true;
600 }
601 if !job.terminal() {
602 handle.cancel.store(true, Ordering::SeqCst);
603 job.state = "cancelled";
604 job.error = Some(json!({ "code": "HASH_CANCELLED", "message": "Hash job was cancelled." }));
605 job.completed_at = Some(now_ms());
606 }
607 false
608 });
609 }
612fn run_job(handle: Arc<JobHandle>, emitter: ProgressEmitter, logger: Option<Arc<Logger>>) {
613 let emit = |handle: &JobHandle| {
614 let (module, public) = {
615 let job = handle.job.lock();
616 (job.module_id.clone(), job.public())
617 };
618 emitter(&module, public);
619 };
620 {
621 let mut job = handle.job.lock();
622 if job.state == "cancelled" {
623 return;
624 }
625 job.state = "running";
626 }
627 emit(&handle);
628 let outcome = (|| -> CoreResult<()> {
629 let (files, algorithms, mode, output, expected) = {
630 let job = handle.job.lock();
631 (job.files.clone(), job.algorithms.clone(), job.mode, job.output.clone(), job.expected.clone())
632 };
633 let mut completed: u64 = 0;
634 for (index, file) in files.iter().enumerate() {
635 if handle.cancel.load(Ordering::SeqCst) {
636 return Err(engine::cancelled());
637 }
638 {
639 let mut job = handle.job.lock();
640 job.current_index = index as i64;
641 job.bytes = completed;
642 }
643 emit(&handle);
644 let mut result = Map::new();
645 result.insert("file".into(), file.public());
646 if let Some(error) = &file.preflight_error {
647 result.insert("status".into(), json!(if error["code"] == "ENOENT" { "MISSING" } else { "INVALID ENTRY" }));
648 result.insert("error".into(), error.clone());
649 handle.job.lock().results.push(Value::Object(result));
650 continue;
651 }
652 let requests = file.algorithms.clone().unwrap_or_else(|| algorithms.clone());
653 let identity = file.identity.clone().unwrap_or(Identity { size: 0, modified_ns: 0, device: 0, inode: 0 });
654 let mut last_emit = Instant::now();
655 let hashed = engine::hash_file(&file.path, &identity, &requests, &handle.cancel, |bytes| {
656 handle.job.lock().bytes = completed + bytes;
657 if last_emit.elapsed() >= Duration::from_millis(100) {
658 last_emit = Instant::now();
659 emit(&handle);
660 }
661 });
662 match hashed {
663 Ok(digests) => {
664 result.insert("status".into(), json!("COMPLETED"));
665 result.insert("hashes".into(), Value::Array(digests.iter().map(|(id, d)| format_result(id, d, &output)).collect()));
666 let actual = digests.first().map(|(_, d)| hex::encode(d)).unwrap_or_default();
667 if mode == "verify" {
668 let wanted = expected.clone().unwrap_or_default();
669 result.insert("status".into(), json!(if safe_hex_equal(&wanted, &actual) { "MATCH" } else { "MISMATCH" }));
670 result.insert("expected".into(), json!(wanted));
671 result.insert("actual".into(), json!(actual));
672 } else if mode == "verify-manifest" {
673 let wanted = file.expected.clone().unwrap_or_default();
674 result.insert("status".into(), json!(if safe_hex_equal(&wanted, &actual) { "MATCH" } else { "MISMATCH" }));
675 result.insert("expected".into(), json!(wanted));
676 result.insert("actual".into(), json!(actual));
677 }
678 }
679 Err(error) if error.code == "HASH_CANCELLED" => return Err(error),
680 Err(error) => {
681 result.insert("status".into(), json!("ERROR"));
682 result.insert("error".into(), safe_error(&error));
683 }
684 }
685 completed += file.size;
686 {
687 let mut job = handle.job.lock();
688 if job.state == "cancelled" {
689 return Err(engine::cancelled());
690 }
691 job.results.push(Value::Object(result));
692 job.bytes = completed;
693 }
694 emit(&handle);
695 }
696 let mut job = handle.job.lock();
697 if job.state == "cancelled" {
698 return Err(engine::cancelled());
699 }
700 if job.mode == "compare" {
701 let complete = job.results.len() == 2 && job.results.iter().all(|r| r["status"] == "COMPLETED");
702 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());
703 let identical = complete && job.algorithms.iter().all(|a| hex_of(&job.results[0], a.id) == hex_of(&job.results[1], a.id));
704 job.comparison = Some(json!({ "digestBased": true, "identical": identical }));
705 }
706 if job.mode == "generate-manifest" {
707 write_manifest(&mut job)?;
708 }
709 job.state = "completed";
710 job.completed_at = Some(now_ms());
711 job.current_index = -1;
712 Ok(())
713 })();
714 match outcome {
715 Ok(()) => {
716 emit(&handle);
717 if let Some(logger) = &logger {
718 let job = handle.job.lock();
719 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::<Vec<_>>() }));
720 }
721 }
722 Err(error) => {
723 {
724 let mut job = handle.job.lock();
725 if job.state == "cancelled" {
726 return;
727 }
728 job.state = if error.code == "HASH_CANCELLED" { "cancelled" } else { "failed" };
729 job.error = Some(safe_error(&error));
730 job.completed_at = Some(now_ms());
731 if let Some(logger) = &logger {
732 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 }));
733 }
734 }
735 emit(&handle);
736 }
737 }
740fn write_manifest(job: &mut Job) -> CoreResult<()> {
741 let target = job.manifest.as_ref().ok_or_else(|| typed("HASH_INVALID_MANIFEST", "Missing manifest destination."))?;
742 let algorithm = job.algorithms[0].algorithm();
743 let mut lines = Vec::new();
744 if target.format != "sfv" {
745 lines.push(format!("# Algorithm: {}", algorithm.id));
746 }
747 for result in &job.results {
748 if result["status"] != "COMPLETED" {
749 continue;
750 }
751 let filename = clean_name(result["file"]["relativePath"].as_str().unwrap_or("")).replace('\\', "/");
752 let digest = result["hashes"][0]["hex"].as_str().unwrap_or("");
753 lines.push(match target.format.as_str() {
754 "bsd" => format!("{} ({filename}) = {digest}", algorithm.name),
755 "sfv" => format!("{filename} {}", digest.to_uppercase()),
756 _ => format!("{digest} {filename}"),
757 });
758 }
759 write_authorized(&target.destination, &format!("{}\n", lines.join("\n")))?;
760 let entries = lines.len() - if target.format == "sfv" { 0 } else { 1 };
761 job.manifest_result = Some(json!({ "name": base_name(&target.destination), "entries": entries, "format": target.format }));
762 Ok(())
765fn write_authorized(destination: &Path, text: &str) -> CoreResult<()> {
766 use std::io::Write;
767 let mut options = std::fs::OpenOptions::new();
768 options.write(true).create(true).truncate(true);
769 #[cfg(unix)]
770 {
771 use std::os::unix::fs::OpenOptionsExt;
772 options.custom_flags(libc::O_NOFOLLOW).mode(0o600);
773 }
774 #[cfg(windows)]
775 if std::fs::symlink_metadata(destination).map(|m| m.file_type().is_symlink()).unwrap_or(false) {
776 return Err(typed("HASH_SYMLINK_REJECTED", "Manifest destination must be a regular file and cannot be a symbolic link."));
777 }
778 let mut file = options.open(destination).map_err(io_error)?;
779 file.write_all(text.as_bytes()).map_err(io_error)?;
780 file.sync_all().map_err(io_error)?;
781 Ok(())
784// --- path authorization ---------------------------------------------------------------------------
786fn authorize_regular_file(source: &Path, max_bytes: u64) -> CoreResult<FileEntry> {
787 let original = std::path::absolute(source).map_err(io_error)?;
788 let lstat = std::fs::symlink_metadata(&original).map_err(io_error)?;
789 if lstat.file_type().is_symlink() {
790 return Err(typed("HASH_SYMLINK_REJECTED", "Symbolic links are not accepted as file selections."));
791 }
792 let canonical = std::fs::canonicalize(&original).map_err(io_error)?;
793 let stat = std::fs::metadata(&canonical).map_err(io_error)?;
794 if !stat.is_file() {
795 return Err(typed("HASH_NOT_REGULAR_FILE", "Only regular files can be selected."));
796 }
797 if stat.len() > max_bytes || stat.len() > MAX_SAFE_INTEGER {
798 return Err(typed("HASH_FILE_TOO_LARGE", "Selected file exceeds the supported size."));
799 }
800 Ok(FileEntry {
801 id: random_id("file"),
802 name: base_name(&canonical),
803 size: stat.len(),
804 identity: Some(Identity::of(&stat)),
805 path: canonical,
806 relative_path: None,
807 algorithms: None,
808 expected: None,
809 preflight_error: None,
810 })
813fn authorize_directory(source: &Path) -> CoreResult<PathBuf> {
814 let original = std::path::absolute(source).map_err(io_error)?;
815 let lstat = std::fs::symlink_metadata(&original).map_err(io_error)?;
816 if lstat.file_type().is_symlink() {
817 return Err(typed("HASH_SYMLINK_REJECTED", "Symbolic links are not accepted as directory selections."));
818 }
819 let canonical = std::fs::canonicalize(&original).map_err(io_error)?;
820 if !std::fs::metadata(&canonical).map_err(io_error)?.is_dir() {
821 return Err(typed("HASH_INVALID_SELECTION", "The selection is not a directory."));
822 }
823 Ok(canonical)
826fn relative_slash(root: &Path, path: &Path) -> String {
827 path.strip_prefix(root).unwrap_or(path).components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/")
830fn common_directory(directories: &[PathBuf]) -> PathBuf {
831 let mut candidate = directories[0].clone();
832 while !directories.iter().all(|d| contains_path(&candidate, d)) {
833 match candidate.parent() {
834 Some(parent) => candidate = parent.to_path_buf(),
835 None => return candidate,
836 }
837 }
838 candidate
841fn enumerate_directory(root: &Path, recursive: bool) -> CoreResult<Vec<FileEntry>> {
842 let mut files = Vec::new();
843 visit(root, root, "", recursive, &mut files)?;
844 Ok(files)
847fn visit(root: &Path, current: &Path, relative_root: &str, recursive: bool, files: &mut Vec<FileEntry>) -> CoreResult<()> {
848 if std::fs::symlink_metadata(current).map_err(io_error)?.file_type().is_symlink() {
849 return Ok(());
850 }
851 let canonical = std::fs::canonicalize(current).map_err(io_error)?;
852 if !contains_path(root, &canonical) || !std::fs::metadata(&canonical).map_err(io_error)?.is_dir() {
853 return Err(typed("HASH_PATH_ESCAPE", "Directory traversal left the selected root."));
854 }
855 let mut entries: Vec<std::fs::DirEntry> = std::fs::read_dir(&canonical).map_err(io_error)?.filter_map(Result::ok).collect();
856 entries.sort_by_key(|e| {
857 let name = e.file_name().to_string_lossy().to_string();
858 (name.to_lowercase(), name)
859 });
860 for entry in entries {
861 if files.len() >= MAX_FILES_PER_JOB {
862 return Err(typed("HASH_TOO_MANY_FILES", format!("A job can contain at most {MAX_FILES_PER_JOB} files.")));
863 }
864 let Ok(kind) = entry.file_type() else { continue };
865 if kind.is_symlink() {
866 continue;
867 }
868 let name = entry.file_name().to_string_lossy().to_string();
869 let full = canonical.join(entry.file_name());
870 let relative = if relative_root.is_empty() { name.clone() } else { format!("{relative_root}/{name}") };
871 if kind.is_dir() {
872 if recursive {
873 visit(root, &full, &relative, recursive, files)?;
874 }
875 continue;
876 }
877 if !kind.is_file() {
878 continue;
879 }
880 let mut file = authorize_regular_file(&full, MAX_SAFE_INTEGER)?;
881 if !contains_path(root, &file.path) {
882 return Err(typed("HASH_PATH_ESCAPE", "Directory traversal left the selected root."));
883 }
884 file.relative_path = Some(relative);
885 files.push(file);
886 }
887 Ok(())
890fn normalize_expected(value: &Value, request: &Request, encoding: &str) -> CoreResult<String> {
891 let source = value.as_str().map(str::to_string).unwrap_or_else(|| if value.is_null() { String::new() } else { value.to_string() });
892 let source = source.trim();
893 let bytes = if encoding == "base64" {
894 let valid = regex::Regex::new(r"^[A-Za-z0-9+/]*={0,2}$").unwrap().is_match(source);
895 if !valid {
896 return Err(typed("HASH_INVALID_EXPECTED", "Expected digest is not valid Base64."));
897 }
898 // Node decoded leniently (missing padding accepted).
899 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))
900 .decode(source)
901 .map_err(|_| typed("HASH_INVALID_EXPECTED", "Expected digest is not valid Base64."))?
902 } else {
903 if source.is_empty() || source.len() % 2 != 0 || !source.chars().all(|c| c.is_ascii_hexdigit()) {
904 return Err(typed("HASH_INVALID_EXPECTED", "Expected digest must be hexadecimal."));
905 }
906 hex::decode(source).map_err(|_| typed("HASH_INVALID_EXPECTED", "Expected digest must be hexadecimal."))?
907 };
908 let wanted = request.algorithm().output_bytes(request);
909 if bytes.len() != wanted {
910 return Err(typed("HASH_INVALID_EXPECTED", format!("Expected digest must be exactly {wanted} bytes.")));
911 }
912 Ok(hex::encode(bytes))
915// --- manifests ------------------------------------------------------------------------------------
917#[derive(Debug, Clone)]
918struct ManifestEntry {
919 algorithm_id: String,
920 filename: String,
921 expected: String,
922 invalid: bool,
925fn safe_manifest_path(value: &str) -> CoreResult<String> {
926 let source = value.replace('\\', "/");
927 let drive = source.len() >= 3 && source.as_bytes()[0].is_ascii_alphabetic() && &source[1..3] == ":/";
928 if source.is_empty() || source.contains('\0') || source.starts_with('/') || drive {
929 return Err(typed("HASH_PATH_ESCAPE", "Manifest contains an absolute or invalid path."));
930 }
931 let segments: Vec<&str> = source.split('/').collect();
932 if segments.iter().any(|s| s.is_empty() || *s == "." || *s == "..") {
933 return Err(typed("HASH_PATH_ESCAPE", "Manifest contains path traversal."));
934 }
935 // Windows: a segment like "C:" or a prefix component would escape the root.
936 if Path::new(&source).components().any(|c| !matches!(c, Component::Normal(_))) {
937 return Err(typed("HASH_PATH_ESCAPE", "Manifest contains an absolute or invalid path."));
938 }
939 Ok(segments.join("/"))
942fn assert_no_symlink_components(root: &Path, relative: &str) -> CoreResult<()> {
943 let mut current = root.to_path_buf();
944 for segment in relative.split('/') {
945 current = current.join(segment);
946 let meta = std::fs::symlink_metadata(&current).map_err(io_error)?;
947 if meta.file_type().is_symlink() {
948 return Err(typed("HASH_SYMLINK_REJECTED", "Manifest path contains a symbolic link."));
949 }
950 }
951 Ok(())
954fn algorithm_from_extension(path: &Path) -> Option<&'static str> {
955 let ext = path.extension()?.to_string_lossy().to_lowercase();
956 Some(match ext.as_str() {
957 "sha224" => "sha224",
958 "sha256" => "sha256",
959 "sha384" => "sha384",
960 "sha512" => "sha512",
961 "sha3" => "sha3-256",
962 "blake3" => "blake3",
963 "md5" => "md5",
964 "sha1" => "sha1",
965 "sfv" => "crc32",
966 _ => return None,
967 })
970fn parse_manifest(path: &Path, requested: Option<&str>) -> CoreResult<(Vec<ManifestEntry>, Vec<Request>)> {
971 let raw = std::fs::read(path).map_err(io_error)?;
972 if raw.len() as u64 > MAX_MANIFEST_BYTES {
973 return Err(typed("HASH_INVALID_MANIFEST", "Checksum manifest is too large."));
974 }
975 let text = String::from_utf8_lossy(&raw);
976 let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
977 let squash = |v: &str| v.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_uppercase();
978 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);
979 let header = regex::Regex::new(r"(?i)^#\s*Algorithm:\s*([a-z0-9-]+)\s*$").unwrap();
980 let bsd = regex::Regex::new(r"^(.+?) \((.*)\) = ([0-9a-fA-F]+)$").unwrap();
981 let gnu = regex::Regex::new(r"^([0-9a-fA-F]+) [ *](.+)$").unwrap();
982 let sfv = regex::Regex::new(r"^(.+?)\s+([0-9a-fA-F]{8})$").unwrap();
983 let mut entries: Vec<ManifestEntry> = Vec::new();
984 let mut declared: Option<String> = None;
985 for line in text.split('\n') {
986 let line = line.strip_suffix('\r').unwrap_or(line);
987 if line.trim().is_empty() {
988 continue;
989 }
990 if let Some(c) = header.captures(line) {
991 declared = Some(c[1].to_lowercase());
992 continue;
993 }
994 if line.starts_with('#') || line.starts_with(';') {
995 continue;
996 }
997 if let Some(c) = bsd.captures(line) {
998 let id = bsd_name(&c[1]).ok_or_else(|| typed("HASH_INVALID_MANIFEST", format!("Unsupported BSD manifest algorithm: {}", &c[1])))?;
999 entries.push(ManifestEntry { algorithm_id: id.into(), filename: c[2].into(), expected: c[3].to_lowercase(), invalid: false });
1000 continue;
1002 if let Some(c) = gnu.captures(line) {
1003 entries.push(ManifestEntry { algorithm_id: String::new(), filename: c[2].into(), expected: c[1].to_lowercase(), invalid: false });
1004 continue;
1006 if let Some(c) = sfv.captures(line) {
1007 entries.push(ManifestEntry { algorithm_id: "crc32".into(), filename: c[1].into(), expected: c[2].to_lowercase(), invalid: false });
1008 continue;
1010 entries.push(ManifestEntry { algorithm_id: String::new(), filename: line.into(), expected: String::new(), invalid: true });
1012 if entries.is_empty() {
1013 return Err(typed("HASH_INVALID_MANIFEST", "Checksum manifest contains no entries."));
1015 let fallback: Option<String> = requested.map(str::to_string).or(declared).or_else(|| algorithm_from_extension(path).map(str::to_string));
1016 for entry in entries.iter_mut() {
1017 if entry.invalid {
1018 entry.algorithm_id = fallback.clone().unwrap_or_else(|| "sha256".into());
1019 continue;
1021 if entry.algorithm_id.is_empty() {
1022 if let Some(f) = &fallback {
1023 entry.algorithm_id = f.clone();
1026 if entry.algorithm_id.is_empty() {
1027 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();
1028 if candidates.len() != 1 {
1029 return Err(typed("HASH_AMBIGUOUS_ALGORITHM", "Manifest digest length is ambiguous; select the algorithm explicitly."));
1031 entry.algorithm_id = candidates[0].into();
1033 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())));
1034 if !valid {
1035 entry.invalid = true;
1038 let mut unique: Vec<Value> = Vec::new();
1039 for entry in &entries {
1040 let id = json!(entry.algorithm_id);
1041 if !unique.contains(&id) {
1042 unique.push(id);
1045 let requests = normalize_requests(&Value::Array(unique))?;
1046 Ok((entries, requests))
1049#[cfg(test)]
1050mod tests {
1051 use super::*;
1053 fn service() -> (Arc<LocalHashService>, Arc<Mutex<Vec<(String, Value)>>>) {
1054 let events = Arc::new(Mutex::new(Vec::new()));
1055 let sink = events.clone();
1056 (Arc::new(LocalHashService::new(Arc::new(move |m: &str, v: Value| sink.lock().push((m.to_string(), v))), None)), events)
1059 fn wait(service: &LocalHashService, module: &str, job: &Value) -> Value {
1060 for _ in 0..500 {
1061 let status = service.status(module, &job["jobId"]).unwrap();
1062 if ["completed", "failed", "cancelled"].contains(&status["state"].as_str().unwrap()) {
1063 return status;
1065 std::thread::sleep(Duration::from_millis(10));
1067 panic!("job did not finish");
1070 #[test]
1071 fn calculate_verify_compare_and_isolation() {
1072 let dir = tempfile::tempdir().unwrap();
1073 let a = dir.path().join("a.txt");
1074 let b = dir.path().join("b.txt");
1075 std::fs::write(&a, "abc").unwrap();
1076 std::fs::write(&b, "abc").unwrap();
1077 let (svc, events) = service();
1078 let sel = svc.select_files("m1", true, Some(vec![a.clone(), b.clone()])).unwrap();
1079 assert_eq!(sel["files"][0]["relativePath"], "a.txt");
1080 let job = svc.start("m1", &json!({ "selectionId": sel["selectionId"], "algorithms": ["sha256", "crc32"], "output": "hex-upper" })).unwrap();
1081 let done = wait(&svc, "m1", &job);
1082 assert_eq!(done["state"], "completed");
1083 assert_eq!(done["results"][0]["hashes"][0]["value"], "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD");
1084 assert_eq!(done["percent"], 100.0);
1085 assert!(events.lock().iter().all(|(m, _)| m == "m1"));
1086 // Another module can neither use the token nor read the job.
1087 assert_eq!(svc.start("m2", &json!({ "selectionId": sel["selectionId"], "algorithms": ["sha256"] })).unwrap_err().code, "HASH_INVALID_SELECTION_TOKEN");
1088 assert_eq!(svc.status("m2", &job["jobId"]).unwrap_err().code, "HASH_INVALID_JOB");
1089 let one = svc.select_files("m1", false, Some(vec![a.clone()])).unwrap();
1090 let verify = svc.verify("m1", &json!({ "selectionId": one["selectionId"], "algorithm": "md5", "expected": "900150983cd24fb0d6963f7d28e17f72" })).unwrap();
1091 assert_eq!(wait(&svc, "m1", &verify)["results"][0]["status"], "MATCH");
1092 let bad = svc.verify("m1", &json!({ "selectionId": one["selectionId"], "algorithm": "md5", "expected": "kAFQmDzST7DWlj99KOF/cg==", "expectedEncoding": "base64" })).unwrap();
1093 assert_eq!(wait(&svc, "m1", &bad)["results"][0]["status"], "MATCH");
1094 let other = svc.select_files("m1", false, Some(vec![b.clone()])).unwrap();
1095 let cmp = svc.compare("m1", &json!({ "leftSelectionId": one["selectionId"], "rightSelectionId": other["selectionId"] })).unwrap();
1096 assert_eq!(wait(&svc, "m1", &cmp)["comparison"]["identical"], true);
1097 let (text, count) = svc.copy_text("m1", &json!({ "jobId": job["jobId"], "algorithmId": "crc32", "compact": true })).unwrap();
1098 assert_eq!(count, 2);
1099 assert_eq!(text, "352441C2\n352441C2");
1100 svc.cleanup_module("m1");
1101 assert_eq!(svc.status("m1", &job["jobId"]).unwrap_err().code, "HASH_INVALID_JOB");
1104 #[test]
1105 fn manifest_roundtrip_and_traversal_protection() {
1106 let dir = tempfile::tempdir().unwrap();
1107 let root = dir.path().join("root");
1108 std::fs::create_dir_all(root.join("sub")).unwrap();
1109 std::fs::write(root.join("one.txt"), "1").unwrap();
1110 std::fs::write(root.join("sub/two.txt"), "2").unwrap();
1111 std::fs::write(dir.path().join("secret.txt"), "s").unwrap();
1112 let (svc, _) = service();
1113 let directory = svc.select_directory("m", Some(root.clone())).unwrap();
1114 let destination = svc.select_manifest_destination("m", Some(dir.path().join("sums.sha256"))).unwrap();
1115 let job = svc.generate_manifest("m", &json!({ "algorithm": "sha256", "selectionId": directory["selectionId"], "destinationSelectionId": destination["selectionId"], "recursive": true })).unwrap();
1116 let done = wait(&svc, "m", &job);
1117 assert_eq!(done["manifest"]["entries"], 2, "{done}");
1118 let written = std::fs::read_to_string(dir.path().join("sums.sha256")).unwrap();
1119 assert!(written.starts_with("# Algorithm: sha256\n"));
1120 assert!(written.contains(" sub/two.txt\n"));
1121 let mut tampered = written.clone();
1122 tampered.push_str(&format!("{} ../secret.txt\n", "0".repeat(64)));
1123 tampered.push_str(&format!("{} /etc/passwd\n", "0".repeat(64)));
1124 tampered.push_str(&format!("{} missing.txt\n", "0".repeat(64)));
1125 tampered.push_str("garbage line\n");
1126 std::fs::write(dir.path().join("check.sha256"), tampered).unwrap();
1127 #[cfg(unix)]
1129 std::os::unix::fs::symlink(dir.path().join("secret.txt"), root.join("link.txt")).unwrap();
1130 let mut extra = std::fs::read_to_string(dir.path().join("check.sha256")).unwrap();
1131 extra.push_str(&format!("{} link.txt\n", "0".repeat(64)));
1132 std::fs::write(dir.path().join("check.sha256"), extra).unwrap();
1134 let manifest = svc.select_manifest("m", Some(dir.path().join("check.sha256"))).unwrap();
1135 let verify = svc.verify_manifest("m", &json!({ "manifestSelectionId": manifest["selectionId"], "rootSelectionId": directory["selectionId"] })).unwrap();
1136 let done = wait(&svc, "m", &verify);
1137 let statuses: Vec<String> = done["results"].as_array().unwrap().iter().map(|r| format!("{}:{}", r["file"]["relativePath"].as_str().unwrap(), r["status"].as_str().unwrap())).collect();
1138 assert!(statuses.contains(&"one.txt:MATCH".to_string()), "{statuses:?}");
1139 assert!(statuses.contains(&"sub/two.txt:MATCH".to_string()));
1140 assert!(statuses.contains(&"../secret.txt:INVALID ENTRY".to_string()));
1141 assert!(statuses.contains(&"/etc/passwd:INVALID ENTRY".to_string()));
1142 assert!(statuses.contains(&"missing.txt:MISSING".to_string()));
1143 assert!(statuses.contains(&"garbage line:INVALID ENTRY".to_string()));
1144 #[cfg(unix)]
1145 assert!(statuses.contains(&"link.txt:INVALID ENTRY".to_string()));
1148 #[test]
1149 fn cancel_large_job() {
1150 let dir = tempfile::tempdir().unwrap();
1151 let big = dir.path().join("big.bin");
1152 std::fs::write(&big, vec![7u8; 64 * 1024 * 1024]).unwrap();
1153 let (svc, _) = service();
1154 let sel = svc.select_files("m", false, Some(vec![big])).unwrap();
1155 let job = svc.start("m", &json!({ "selectionId": sel["selectionId"], "algorithms": ["sha512", "whirlpool", "sha3-512"] })).unwrap();
1156 let cancelled = svc.cancel("m", &job["jobId"]).unwrap();
1157 assert_eq!(cancelled["state"], "cancelled");
1158 std::thread::sleep(Duration::from_millis(200));
1159 assert_eq!(svc.status("m", &job["jobId"]).unwrap()["state"], "cancelled");
SHA-256d9a6377766c02d809e0c0b7f91c6df7274e73893e7d2022f2a5b07c964082eea

CH-J Proprietary Software License 1.14 · Zdroj sa poskytuje v rámci CH-J Proprietary Software License 1.14. Jeho dostupnosť nemení licenčné podmienky ani neposkytuje dodatočné práva.

Archív SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Súbory