CH-J ToolsSerververwaltung über SSH
Menü
Veröffentlichte Quellen

CH-J Tools

Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.

Quellen als ZIP herunterladen
CH-J / Server Manager 872 Dateien
core-20261030T220507Z-61e4558d889f Hinzugefügt: · Erstellt:
service.rs Hinzugefügt: Datum nicht erfasst 52,1 KB · 1118 Zeilen
Code Datei herunterladen
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 pub fn suggested_manifest_name(payload: &Value) -> String {
248 clean_name(payload.get("suggestedName").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("checksums.sha256"))
249 }
251 pub fn select_manifest_destination(&self, module_id: &str, picked: Option<PathBuf>) -> CoreResult<Value> {
252 let Some(chosen) = picked else { return Ok(json!({ "canceled": true, "selectionId": null })) };
253 let invalid = || typed("HASH_INVALID_DESTINATION", "Manifest destination directory is invalid.");
254 let file_name = chosen.file_name().ok_or_else(invalid)?.to_owned();
255 let parent = std::fs::canonicalize(chosen.parent().ok_or_else(invalid)?).map_err(io_error)?;
256 if !std::fs::metadata(&parent).map_err(io_error)?.is_dir() {
257 return Err(invalid());
258 }
259 let destination = parent.join(&file_name);
260 match std::fs::symlink_metadata(&destination) {
261 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.")),
262 Ok(_) => {}
263 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
264 Err(e) => return Err(io_error(e)),
265 }
266 let name = base_name(&destination);
267 let id = self.store(module_id, Selection::Destination(destination));
268 Ok(json!({ "canceled": false, "selectionId": id, "name": name }))
269 }
271 fn store(&self, module_id: &str, selection: Selection) -> String {
272 let mut all = self.selections.lock();
273 let values = all.entry(module_id.to_string()).or_default();
274 values.retain(|s| s.created.elapsed() <= TOKEN_TTL);
275 while values.len() >= MAX_SELECTIONS_PER_MODULE {
276 values.remove(0);
277 }
278 let id = random_id("selection");
279 values.push(StoredSelection { id: id.clone(), created: Instant::now(), selection });
280 id
281 }
283 fn selection(&self, module_id: &str, id: &Value, kinds: &[&str]) -> CoreResult<Selection> {
284 let id = id.as_str().unwrap_or("");
285 let all = self.selections.lock();
286 all.get(module_id)
287 .and_then(|values| values.iter().find(|s| s.id == id))
288 .filter(|s| s.created.elapsed() <= TOKEN_TTL && kinds.contains(&s.selection.kind()))
289 .map(|s| s.selection.clone())
290 .ok_or_else(|| typed("HASH_INVALID_SELECTION_TOKEN", "Unknown, expired, or unauthorized selection token."))
291 }
293 fn files_of(&self, selection: Selection, recursive: bool) -> CoreResult<Vec<FileEntry>> {
294 match selection {
295 Selection::Files(files) => Ok(files),
296 Selection::Directory(root) => enumerate_directory(&root, recursive),
297 _ => Err(typed("HASH_INVALID_SELECTION_TOKEN", "Unknown, expired, or unauthorized selection token.")),
298 }
299 }
301 // --- jobs ---------------------------------------------------------------------------------
303 pub fn start(&self, module_id: &str, payload: &Value) -> CoreResult<Value> {
304 let algorithms = normalize_requests(payload.get("algorithms").unwrap_or(&Value::Null))?;
305 let output = payload.get("output").and_then(Value::as_str).filter(|o| ["hex-lower", "hex-upper", "base64"].contains(o)).unwrap_or("hex-lower");
306 let selection = self.selection(module_id, &payload["selectionId"], &["files", "directory"])?;
307 let files = self.files_of(selection, payload["recursive"] == true)?;
308 self.start_job(module_id, "calculate", algorithms, output, files, None, None)
309 }
311 pub fn verify(&self, module_id: &str, payload: &Value) -> CoreResult<Value> {
312 let algorithms = normalize_requests(&json!([payload.get("algorithm").cloned().unwrap_or(Value::Null)]))?;
313 let Selection::Files(files) = self.selection(module_id, &payload["selectionId"], &["files"])? else { unreachable!() };
314 if files.len() != 1 {
315 return Err(typed("HASH_INVALID_SELECTION", "Verify requires exactly one selected file."));
316 }
317 let expected = normalize_expected(&payload["expected"], &algorithms[0], payload.get("expectedEncoding").and_then(Value::as_str).unwrap_or("hex"))?;
318 self.start_job(module_id, "verify", algorithms, "hex-lower", files, Some(expected), None)
319 }
321 pub fn compare(&self, module_id: &str, payload: &Value) -> CoreResult<Value> {
322 let requested = payload.get("algorithms").filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json!(["sha256"]));
323 let algorithms = normalize_requests(&requested)?;
324 let Selection::Files(left) = self.selection(module_id, &payload["leftSelectionId"], &["files"])? else { unreachable!() };
325 let Selection::Files(right) = self.selection(module_id, &payload["rightSelectionId"], &["files"])? else { unreachable!() };
326 if left.len() != 1 || right.len() != 1 {
327 return Err(typed("HASH_INVALID_SELECTION", "Compare requires one file in each selection."));
328 }
329 self.start_job(module_id, "compare", algorithms, "hex-lower", vec![left[0].clone(), right[0].clone()], None, None)
330 }
332 pub fn generate_manifest(&self, module_id: &str, payload: &Value) -> CoreResult<Value> {
333 let algorithms = normalize_requests(&json!([payload.get("algorithm").cloned().unwrap_or(Value::Null)]))?;
334 let algorithm = algorithms[0].algorithm();
335 if algorithm.xof.is_some() || algorithm.key_bytes.is_some() || algorithm.seed_bits.is_some() {
336 return Err(typed("HASH_INVALID_MANIFEST", "Checksum manifests require a fixed, unkeyed, unseeded algorithm."));
337 }
338 let format = payload.get("format").and_then(Value::as_str).filter(|f| ["gnu", "bsd", "sfv"].contains(f)).unwrap_or("gnu");
339 if format == "sfv" && algorithm.id != "crc32" {
340 return Err(typed("HASH_INVALID_MANIFEST", "SFV manifests require CRC-32/ISO-HDLC."));
341 }
342 let source = self.selection(module_id, &payload["selectionId"], &["files", "directory"])?;
343 let Selection::Destination(destination) = self.selection(module_id, &payload["destinationSelectionId"], &["destination"])? else { unreachable!() };
344 let files = self.files_of(source, payload["recursive"] == true)?;
345 self.start_job(module_id, "generate-manifest", algorithms, "hex-lower", files, None, Some(ManifestTarget { format: format.into(), destination }))
346 }
348 pub fn verify_manifest(&self, module_id: &str, payload: &Value) -> CoreResult<Value> {
349 let Selection::Manifest(manifest) = self.selection(module_id, &payload["manifestSelectionId"], &["manifest"])? else { unreachable!() };
350 let Selection::Directory(root) = self.selection(module_id, &payload["rootSelectionId"], &["directory"])? else { unreachable!() };
351 let requested = payload.get("algorithmId").and_then(Value::as_str).filter(|s| !s.is_empty());
352 let (entries, algorithms) = parse_manifest(&manifest.path, requested)?;
353 let mut files = Vec::new();
354 for entry in entries {
355 let failed = |error: Value, entry: &ManifestEntry| FileEntry {
356 id: random_id("file"),
357 path: PathBuf::new(),
358 name: entry.filename.rsplit(['/', '\\']).next().unwrap_or("").to_string(),
359 size: 0,
360 identity: None,
361 relative_path: Some(entry.filename.clone()),
362 algorithms: Some(vec![Request::simple(algorithms::by_id(&entry.algorithm_id).map(|a| a.id).unwrap_or("sha256"))]),
363 expected: Some(entry.expected.clone()),
364 preflight_error: Some(error),
365 };
366 if entry.invalid {
367 files.push(failed(json!({ "code": "HASH_INVALID_MANIFEST", "message": "Invalid checksum manifest entry." }), &entry));
368 continue;
369 }
370 let resolved = (|| -> CoreResult<FileEntry> {
371 let relative = safe_manifest_path(&entry.filename)?;
372 let candidate = relative.split('/').fold(root.clone(), |p, s| p.join(s));
373 if !contains_path(&root, &candidate) {
374 return Err(typed("HASH_PATH_ESCAPE", "Manifest path escapes the selected directory."));
375 }
376 assert_no_symlink_components(&root, &relative)?;
377 let mut file = authorize_regular_file(&candidate, MAX_SAFE_INTEGER)?;
378 if !contains_path(&root, &file.path) {
379 return Err(typed("HASH_PATH_ESCAPE", "Manifest path resolves outside the selected directory."));
380 }
381 file.relative_path = Some(relative);
382 file.algorithms = Some(normalize_requests(&json!([{ "id": entry.algorithm_id }]))?);
383 file.expected = Some(entry.expected.clone());
384 Ok(file)
385 })();
386 match resolved {
387 Ok(file) => files.push(file),
388 Err(error) => files.push(failed(safe_error(&error), &entry)),
389 }
390 }
391 let job = self.start_job(module_id, "verify-manifest", algorithms, "hex-lower", files, None, None)?;
392 // 0.1.0 stored the source name on the job without exposing it; nothing else to keep.
393 let _ = manifest.name;
394 Ok(job)
395 }
397 #[allow(clippy::too_many_arguments)]
398 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> {
399 if files.is_empty() {
400 return Err(typed("HASH_EMPTY_SELECTION", "No regular files were selected."));
401 }
402 if files.len() > MAX_FILES_PER_JOB {
403 return Err(typed("HASH_TOO_MANY_FILES", format!("A job can contain at most {MAX_FILES_PER_JOB} files.")));
404 }
405 let total_bytes = files.iter().map(|f| f.size).sum();
406 let job = Job {
407 id: random_id("job"),
408 module_id: module_id.to_string(),
409 state: "queued",
410 mode,
411 algorithms,
412 output: output.to_string(),
413 files,
414 expected,
415 manifest,
416 results: Vec::new(),
417 current_index: -1,
418 bytes: 0,
419 total_bytes,
420 started_at: now_ms(),
421 completed_at: None,
422 error: None,
423 comparison: None,
424 manifest_result: None,
425 };
426 let id = job.id.clone();
427 let handle = Arc::new(JobHandle { job: Mutex::new(job), cancel: AtomicBool::new(false) });
428 self.jobs.lock().insert(id, handle.clone());
429 let public = handle.job.lock().public();
430 self.emit(&handle);
431 let emitter = self.emitter.clone();
432 let logger = self.logger.clone();
433 std::thread::Builder::new()
434 .name("chj-hash-job".into())
435 .spawn(move || run_job(handle, emitter, logger))
436 .map_err(|_| CoreError::new("HASH_INTERNAL_ERROR", "An internal hashing error occurred."))?;
437 Ok(public)
438 }
440 fn emit(&self, handle: &JobHandle) {
441 let (module, public) = {
442 let job = handle.job.lock();
443 (job.module_id.clone(), job.public())
444 };
445 (self.emitter)(&module, public);
446 }
448 fn job(&self, module_id: &str, job_id: &Value) -> CoreResult<Arc<JobHandle>> {
449 let id = job_id.as_str().unwrap_or("");
450 self.jobs
451 .lock()
452 .get(id)
453 .filter(|h| h.job.lock().module_id == module_id)
454 .cloned()
455 .ok_or_else(|| typed("HASH_INVALID_JOB", "Unknown or unauthorized hash job."))
456 }
458 pub fn status(&self, module_id: &str, job_id: &Value) -> CoreResult<Value> {
459 Ok(self.job(module_id, job_id)?.job.lock().public())
460 }
462 pub fn cancel(&self, module_id: &str, job_id: &Value) -> CoreResult<Value> {
463 let handle = self.job(module_id, job_id)?;
464 {
465 let mut job = handle.job.lock();
466 if job.terminal() {
467 return Ok(job.public());
468 }
469 handle.cancel.store(true, Ordering::SeqCst);
470 job.state = "cancelled";
471 job.error = Some(json!({ "code": "HASH_CANCELLED", "message": "Hash job was cancelled." }));
472 job.completed_at = Some(now_ms());
473 }
474 self.emit(&handle);
475 let public = handle.job.lock().public();
476 Ok(public)
477 }
479 pub fn export_results(&self, module_id: &str, payload: &Value) -> CoreResult<Value> {
480 let handle = self.job(module_id, &payload["jobId"])?;
481 let Selection::Destination(destination) = self.selection(module_id, &payload["destinationSelectionId"], &["destination"])? else { unreachable!() };
482 let job = handle.job.lock();
483 if job.state != "completed" {
484 return Err(typed("HASH_JOB_NOT_COMPLETED", "Only completed hash results can be exported."));
485 }
486 let format = if payload["format"] == "json" { "json" } else { "text" };
487 let serializable: Vec<Value> = job
488 .results
489 .iter()
490 .map(|r| {
491 let mut hashes = Map::new();
492 for hash in r["hashes"].as_array().into_iter().flatten() {
493 hashes.insert(hash["id"].as_str().unwrap_or("").to_string(), hash["value"].clone());
494 }
495 json!({ "file": r["file"]["relativePath"], "size": r["file"]["size"], "status": r["status"], "hashes": hashes })
496 })
497 .collect();
498 drop(job);
499 let text = if format == "json" {
500 format!("{}\n", serde_json::to_string_pretty(&json!({ "generatedBy": "CH-J Server Manager Hash & Checksum", "results": serializable })).unwrap_or_default())
501 } else {
502 let lines: Vec<String> = serializable
503 .iter()
504 .flat_map(|r| {
505 let file = r["file"].as_str().unwrap_or("");
506 let size = &r["size"];
507 let status = r["status"].as_str().unwrap_or("");
508 let hashes = r["hashes"].as_object().cloned().unwrap_or_default();
509 if hashes.is_empty() {
510 vec![format!("{file}\t{size}\t\t\t{status}")]
511 } else {
512 hashes.iter().map(|(id, value)| format!("{file}\t{size}\t{id}\t{}\t{status}", value.as_str().unwrap_or(""))).collect()
513 }
514 })
515 .collect();
516 format!("{}\n", lines.join("\n"))
517 };
518 write_authorized(&destination, &text)?;
519 Ok(json!({ "ok": true, "name": base_name(&destination), "format": format }))
520 }
522 /// Text for the clipboard (the command writes it; modules have no clipboard access).
523 pub fn copy_text(&self, module_id: &str, payload: &Value) -> CoreResult<(String, usize)> {
524 let handle = self.job(module_id, &payload["jobId"])?;
525 let job = handle.job.lock();
526 let file_id = payload.get("fileId").and_then(Value::as_str).filter(|s| !s.is_empty());
527 let algorithm_id = payload.get("algorithmId").and_then(Value::as_str).filter(|s| !s.is_empty());
528 let compact = payload["compact"] == true;
529 let mut lines = Vec::new();
530 for result in &job.results {
531 if file_id.is_some_and(|id| result["file"]["fileId"] != id) {
532 continue;
533 }
534 for hash in result["hashes"].as_array().into_iter().flatten() {
535 if algorithm_id.is_some_and(|id| hash["id"] != id) {
536 continue;
537 }
538 let value = hash["value"].as_str().unwrap_or("");
539 lines.push(if compact { value.to_string() } else { format!("{value} {} ({})", result["file"]["relativePath"].as_str().unwrap_or(""), hash["id"].as_str().unwrap_or("")) });
540 }
541 }
542 if lines.is_empty() {
543 return Err(typed("HASH_RESULT_NOT_FOUND", "No matching completed result is available to copy."));
544 }
545 let count = lines.len();
546 Ok((lines.join("\n"), count))
547 }
549 /// Module window closed: forget its selections, cancel and drop its jobs.
550 pub fn cleanup_module(&self, module_id: &str) {
551 self.selections.lock().remove(module_id);
552 let mut jobs = self.jobs.lock();
553 jobs.retain(|_, handle| {
554 let mut job = handle.job.lock();
555 if job.module_id != module_id {
556 return true;
557 }
558 if !job.terminal() {
559 handle.cancel.store(true, Ordering::SeqCst);
560 job.state = "cancelled";
561 job.error = Some(json!({ "code": "HASH_CANCELLED", "message": "Hash job was cancelled." }));
562 job.completed_at = Some(now_ms());
563 }
564 false
565 });
566 }
569fn run_job(handle: Arc<JobHandle>, emitter: ProgressEmitter, logger: Option<Arc<Logger>>) {
570 let emit = |handle: &JobHandle| {
571 let (module, public) = {
572 let job = handle.job.lock();
573 (job.module_id.clone(), job.public())
574 };
575 emitter(&module, public);
576 };
577 {
578 let mut job = handle.job.lock();
579 if job.state == "cancelled" {
580 return;
581 }
582 job.state = "running";
583 }
584 emit(&handle);
585 let outcome = (|| -> CoreResult<()> {
586 let (files, algorithms, mode, output, expected) = {
587 let job = handle.job.lock();
588 (job.files.clone(), job.algorithms.clone(), job.mode, job.output.clone(), job.expected.clone())
589 };
590 let mut completed: u64 = 0;
591 for (index, file) in files.iter().enumerate() {
592 if handle.cancel.load(Ordering::SeqCst) {
593 return Err(engine::cancelled());
594 }
595 {
596 let mut job = handle.job.lock();
597 job.current_index = index as i64;
598 job.bytes = completed;
599 }
600 emit(&handle);
601 let mut result = Map::new();
602 result.insert("file".into(), file.public());
603 if let Some(error) = &file.preflight_error {
604 result.insert("status".into(), json!(if error["code"] == "ENOENT" { "MISSING" } else { "INVALID ENTRY" }));
605 result.insert("error".into(), error.clone());
606 handle.job.lock().results.push(Value::Object(result));
607 continue;
608 }
609 let requests = file.algorithms.clone().unwrap_or_else(|| algorithms.clone());
610 let identity = file.identity.clone().unwrap_or(Identity { size: 0, modified_ns: 0, device: 0, inode: 0 });
611 let mut last_emit = Instant::now();
612 let hashed = engine::hash_file(&file.path, &identity, &requests, &handle.cancel, |bytes| {
613 handle.job.lock().bytes = completed + bytes;
614 if last_emit.elapsed() >= Duration::from_millis(100) {
615 last_emit = Instant::now();
616 emit(&handle);
617 }
618 });
619 match hashed {
620 Ok(digests) => {
621 result.insert("status".into(), json!("COMPLETED"));
622 result.insert("hashes".into(), Value::Array(digests.iter().map(|(id, d)| format_result(id, d, &output)).collect()));
623 let actual = digests.first().map(|(_, d)| hex::encode(d)).unwrap_or_default();
624 if mode == "verify" {
625 let wanted = expected.clone().unwrap_or_default();
626 result.insert("status".into(), json!(if safe_hex_equal(&wanted, &actual) { "MATCH" } else { "MISMATCH" }));
627 result.insert("expected".into(), json!(wanted));
628 result.insert("actual".into(), json!(actual));
629 } else if mode == "verify-manifest" {
630 let wanted = file.expected.clone().unwrap_or_default();
631 result.insert("status".into(), json!(if safe_hex_equal(&wanted, &actual) { "MATCH" } else { "MISMATCH" }));
632 result.insert("expected".into(), json!(wanted));
633 result.insert("actual".into(), json!(actual));
634 }
635 }
636 Err(error) if error.code == "HASH_CANCELLED" => return Err(error),
637 Err(error) => {
638 result.insert("status".into(), json!("ERROR"));
639 result.insert("error".into(), safe_error(&error));
640 }
641 }
642 completed += file.size;
643 {
644 let mut job = handle.job.lock();
645 if job.state == "cancelled" {
646 return Err(engine::cancelled());
647 }
648 job.results.push(Value::Object(result));
649 job.bytes = completed;
650 }
651 emit(&handle);
652 }
653 let mut job = handle.job.lock();
654 if job.state == "cancelled" {
655 return Err(engine::cancelled());
656 }
657 if job.mode == "compare" {
658 let complete = job.results.len() == 2 && job.results.iter().all(|r| r["status"] == "COMPLETED");
659 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());
660 let identical = complete && job.algorithms.iter().all(|a| hex_of(&job.results[0], a.id) == hex_of(&job.results[1], a.id));
661 job.comparison = Some(json!({ "digestBased": true, "identical": identical }));
662 }
663 if job.mode == "generate-manifest" {
664 write_manifest(&mut job)?;
665 }
666 job.state = "completed";
667 job.completed_at = Some(now_ms());
668 job.current_index = -1;
669 Ok(())
670 })();
671 match outcome {
672 Ok(()) => {
673 emit(&handle);
674 if let Some(logger) = &logger {
675 let job = handle.job.lock();
676 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<_>>() }));
677 }
678 }
679 Err(error) => {
680 {
681 let mut job = handle.job.lock();
682 if job.state == "cancelled" {
683 return;
684 }
685 job.state = if error.code == "HASH_CANCELLED" { "cancelled" } else { "failed" };
686 job.error = Some(safe_error(&error));
687 job.completed_at = Some(now_ms());
688 if let Some(logger) = &logger {
689 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 }));
690 }
691 }
692 emit(&handle);
693 }
694 }
697fn write_manifest(job: &mut Job) -> CoreResult<()> {
698 let target = job.manifest.as_ref().ok_or_else(|| typed("HASH_INVALID_MANIFEST", "Missing manifest destination."))?;
699 let algorithm = job.algorithms[0].algorithm();
700 let mut lines = Vec::new();
701 if target.format != "sfv" {
702 lines.push(format!("# Algorithm: {}", algorithm.id));
703 }
704 for result in &job.results {
705 if result["status"] != "COMPLETED" {
706 continue;
707 }
708 let filename = clean_name(result["file"]["relativePath"].as_str().unwrap_or("")).replace('\\', "/");
709 let digest = result["hashes"][0]["hex"].as_str().unwrap_or("");
710 lines.push(match target.format.as_str() {
711 "bsd" => format!("{} ({filename}) = {digest}", algorithm.name),
712 "sfv" => format!("{filename} {}", digest.to_uppercase()),
713 _ => format!("{digest} {filename}"),
714 });
715 }
716 write_authorized(&target.destination, &format!("{}\n", lines.join("\n")))?;
717 let entries = lines.len() - if target.format == "sfv" { 0 } else { 1 };
718 job.manifest_result = Some(json!({ "name": base_name(&target.destination), "entries": entries, "format": target.format }));
719 Ok(())
722fn write_authorized(destination: &Path, text: &str) -> CoreResult<()> {
723 use std::io::Write;
724 let mut options = std::fs::OpenOptions::new();
725 options.write(true).create(true).truncate(true);
726 #[cfg(unix)]
727 {
728 use std::os::unix::fs::OpenOptionsExt;
729 options.custom_flags(libc::O_NOFOLLOW).mode(0o600);
730 }
731 #[cfg(windows)]
732 if std::fs::symlink_metadata(destination).map(|m| m.file_type().is_symlink()).unwrap_or(false) {
733 return Err(typed("HASH_SYMLINK_REJECTED", "Manifest destination must be a regular file and cannot be a symbolic link."));
734 }
735 let mut file = options.open(destination).map_err(io_error)?;
736 file.write_all(text.as_bytes()).map_err(io_error)?;
737 file.sync_all().map_err(io_error)?;
738 Ok(())
741// --- path authorization ---------------------------------------------------------------------------
743fn authorize_regular_file(source: &Path, max_bytes: u64) -> CoreResult<FileEntry> {
744 let original = std::path::absolute(source).map_err(io_error)?;
745 let lstat = std::fs::symlink_metadata(&original).map_err(io_error)?;
746 if lstat.file_type().is_symlink() {
747 return Err(typed("HASH_SYMLINK_REJECTED", "Symbolic links are not accepted as file selections."));
748 }
749 let canonical = std::fs::canonicalize(&original).map_err(io_error)?;
750 let stat = std::fs::metadata(&canonical).map_err(io_error)?;
751 if !stat.is_file() {
752 return Err(typed("HASH_NOT_REGULAR_FILE", "Only regular files can be selected."));
753 }
754 if stat.len() > max_bytes || stat.len() > MAX_SAFE_INTEGER {
755 return Err(typed("HASH_FILE_TOO_LARGE", "Selected file exceeds the supported size."));
756 }
757 Ok(FileEntry {
758 id: random_id("file"),
759 name: base_name(&canonical),
760 size: stat.len(),
761 identity: Some(Identity::of(&stat)),
762 path: canonical,
763 relative_path: None,
764 algorithms: None,
765 expected: None,
766 preflight_error: None,
767 })
770fn authorize_directory(source: &Path) -> CoreResult<PathBuf> {
771 let original = std::path::absolute(source).map_err(io_error)?;
772 let lstat = std::fs::symlink_metadata(&original).map_err(io_error)?;
773 if lstat.file_type().is_symlink() {
774 return Err(typed("HASH_SYMLINK_REJECTED", "Symbolic links are not accepted as directory selections."));
775 }
776 let canonical = std::fs::canonicalize(&original).map_err(io_error)?;
777 if !std::fs::metadata(&canonical).map_err(io_error)?.is_dir() {
778 return Err(typed("HASH_INVALID_SELECTION", "The selection is not a directory."));
779 }
780 Ok(canonical)
783fn relative_slash(root: &Path, path: &Path) -> String {
784 path.strip_prefix(root).unwrap_or(path).components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/")
787fn common_directory(directories: &[PathBuf]) -> PathBuf {
788 let mut candidate = directories[0].clone();
789 while !directories.iter().all(|d| contains_path(&candidate, d)) {
790 match candidate.parent() {
791 Some(parent) => candidate = parent.to_path_buf(),
792 None => return candidate,
793 }
794 }
795 candidate
798fn enumerate_directory(root: &Path, recursive: bool) -> CoreResult<Vec<FileEntry>> {
799 let mut files = Vec::new();
800 visit(root, root, "", recursive, &mut files)?;
801 Ok(files)
804fn visit(root: &Path, current: &Path, relative_root: &str, recursive: bool, files: &mut Vec<FileEntry>) -> CoreResult<()> {
805 if std::fs::symlink_metadata(current).map_err(io_error)?.file_type().is_symlink() {
806 return Ok(());
807 }
808 let canonical = std::fs::canonicalize(current).map_err(io_error)?;
809 if !contains_path(root, &canonical) || !std::fs::metadata(&canonical).map_err(io_error)?.is_dir() {
810 return Err(typed("HASH_PATH_ESCAPE", "Directory traversal left the selected root."));
811 }
812 let mut entries: Vec<std::fs::DirEntry> = std::fs::read_dir(&canonical).map_err(io_error)?.filter_map(Result::ok).collect();
813 entries.sort_by_key(|e| {
814 let name = e.file_name().to_string_lossy().to_string();
815 (name.to_lowercase(), name)
816 });
817 for entry in entries {
818 if files.len() >= MAX_FILES_PER_JOB {
819 return Err(typed("HASH_TOO_MANY_FILES", format!("A job can contain at most {MAX_FILES_PER_JOB} files.")));
820 }
821 let Ok(kind) = entry.file_type() else { continue };
822 if kind.is_symlink() {
823 continue;
824 }
825 let name = entry.file_name().to_string_lossy().to_string();
826 let full = canonical.join(entry.file_name());
827 let relative = if relative_root.is_empty() { name.clone() } else { format!("{relative_root}/{name}") };
828 if kind.is_dir() {
829 if recursive {
830 visit(root, &full, &relative, recursive, files)?;
831 }
832 continue;
833 }
834 if !kind.is_file() {
835 continue;
836 }
837 let mut file = authorize_regular_file(&full, MAX_SAFE_INTEGER)?;
838 if !contains_path(root, &file.path) {
839 return Err(typed("HASH_PATH_ESCAPE", "Directory traversal left the selected root."));
840 }
841 file.relative_path = Some(relative);
842 files.push(file);
843 }
844 Ok(())
847fn normalize_expected(value: &Value, request: &Request, encoding: &str) -> CoreResult<String> {
848 let source = value.as_str().map(str::to_string).unwrap_or_else(|| if value.is_null() { String::new() } else { value.to_string() });
849 let source = source.trim();
850 let bytes = if encoding == "base64" {
851 let valid = regex::Regex::new(r"^[A-Za-z0-9+/]*={0,2}$").unwrap().is_match(source);
852 if !valid {
853 return Err(typed("HASH_INVALID_EXPECTED", "Expected digest is not valid Base64."));
854 }
855 // Node decoded leniently (missing padding accepted).
856 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))
857 .decode(source)
858 .map_err(|_| typed("HASH_INVALID_EXPECTED", "Expected digest is not valid Base64."))?
859 } else {
860 if source.is_empty() || source.len() % 2 != 0 || !source.chars().all(|c| c.is_ascii_hexdigit()) {
861 return Err(typed("HASH_INVALID_EXPECTED", "Expected digest must be hexadecimal."));
862 }
863 hex::decode(source).map_err(|_| typed("HASH_INVALID_EXPECTED", "Expected digest must be hexadecimal."))?
864 };
865 let wanted = request.algorithm().output_bytes(request);
866 if bytes.len() != wanted {
867 return Err(typed("HASH_INVALID_EXPECTED", format!("Expected digest must be exactly {wanted} bytes.")));
868 }
869 Ok(hex::encode(bytes))
872// --- manifests ------------------------------------------------------------------------------------
874#[derive(Debug, Clone)]
875struct ManifestEntry {
876 algorithm_id: String,
877 filename: String,
878 expected: String,
879 invalid: bool,
882fn safe_manifest_path(value: &str) -> CoreResult<String> {
883 let source = value.replace('\\', "/");
884 let drive = source.len() >= 3 && source.as_bytes()[0].is_ascii_alphabetic() && &source[1..3] == ":/";
885 if source.is_empty() || source.contains('\0') || source.starts_with('/') || drive {
886 return Err(typed("HASH_PATH_ESCAPE", "Manifest contains an absolute or invalid path."));
887 }
888 let segments: Vec<&str> = source.split('/').collect();
889 if segments.iter().any(|s| s.is_empty() || *s == "." || *s == "..") {
890 return Err(typed("HASH_PATH_ESCAPE", "Manifest contains path traversal."));
891 }
892 // Windows: a segment like "C:" or a prefix component would escape the root.
893 if Path::new(&source).components().any(|c| !matches!(c, Component::Normal(_))) {
894 return Err(typed("HASH_PATH_ESCAPE", "Manifest contains an absolute or invalid path."));
895 }
896 Ok(segments.join("/"))
899fn assert_no_symlink_components(root: &Path, relative: &str) -> CoreResult<()> {
900 let mut current = root.to_path_buf();
901 for segment in relative.split('/') {
902 current = current.join(segment);
903 let meta = std::fs::symlink_metadata(&current).map_err(io_error)?;
904 if meta.file_type().is_symlink() {
905 return Err(typed("HASH_SYMLINK_REJECTED", "Manifest path contains a symbolic link."));
906 }
907 }
908 Ok(())
911fn algorithm_from_extension(path: &Path) -> Option<&'static str> {
912 let ext = path.extension()?.to_string_lossy().to_lowercase();
913 Some(match ext.as_str() {
914 "sha224" => "sha224",
915 "sha256" => "sha256",
916 "sha384" => "sha384",
917 "sha512" => "sha512",
918 "sha3" => "sha3-256",
919 "blake3" => "blake3",
920 "md5" => "md5",
921 "sha1" => "sha1",
922 "sfv" => "crc32",
923 _ => return None,
924 })
927fn parse_manifest(path: &Path, requested: Option<&str>) -> CoreResult<(Vec<ManifestEntry>, Vec<Request>)> {
928 let raw = std::fs::read(path).map_err(io_error)?;
929 if raw.len() as u64 > MAX_MANIFEST_BYTES {
930 return Err(typed("HASH_INVALID_MANIFEST", "Checksum manifest is too large."));
931 }
932 let text = String::from_utf8_lossy(&raw);
933 let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
934 let squash = |v: &str| v.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_uppercase();
935 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);
936 let header = regex::Regex::new(r"(?i)^#\s*Algorithm:\s*([a-z0-9-]+)\s*$").unwrap();
937 let bsd = regex::Regex::new(r"^(.+?) \((.*)\) = ([0-9a-fA-F]+)$").unwrap();
938 let gnu = regex::Regex::new(r"^([0-9a-fA-F]+) [ *](.+)$").unwrap();
939 let sfv = regex::Regex::new(r"^(.+?)\s+([0-9a-fA-F]{8})$").unwrap();
940 let mut entries: Vec<ManifestEntry> = Vec::new();
941 let mut declared: Option<String> = None;
942 for line in text.split('\n') {
943 let line = line.strip_suffix('\r').unwrap_or(line);
944 if line.trim().is_empty() {
945 continue;
946 }
947 if let Some(c) = header.captures(line) {
948 declared = Some(c[1].to_lowercase());
949 continue;
950 }
951 if line.starts_with('#') || line.starts_with(';') {
952 continue;
953 }
954 if let Some(c) = bsd.captures(line) {
955 let id = bsd_name(&c[1]).ok_or_else(|| typed("HASH_INVALID_MANIFEST", format!("Unsupported BSD manifest algorithm: {}", &c[1])))?;
956 entries.push(ManifestEntry { algorithm_id: id.into(), filename: c[2].into(), expected: c[3].to_lowercase(), invalid: false });
957 continue;
958 }
959 if let Some(c) = gnu.captures(line) {
960 entries.push(ManifestEntry { algorithm_id: String::new(), filename: c[2].into(), expected: c[1].to_lowercase(), invalid: false });
961 continue;
962 }
963 if let Some(c) = sfv.captures(line) {
964 entries.push(ManifestEntry { algorithm_id: "crc32".into(), filename: c[1].into(), expected: c[2].to_lowercase(), invalid: false });
965 continue;
966 }
967 entries.push(ManifestEntry { algorithm_id: String::new(), filename: line.into(), expected: String::new(), invalid: true });
968 }
969 if entries.is_empty() {
970 return Err(typed("HASH_INVALID_MANIFEST", "Checksum manifest contains no entries."));
971 }
972 let fallback: Option<String> = requested.map(str::to_string).or(declared).or_else(|| algorithm_from_extension(path).map(str::to_string));
973 for entry in entries.iter_mut() {
974 if entry.invalid {
975 entry.algorithm_id = fallback.clone().unwrap_or_else(|| "sha256".into());
976 continue;
977 }
978 if entry.algorithm_id.is_empty() {
979 if let Some(f) = &fallback {
980 entry.algorithm_id = f.clone();
981 }
982 }
983 if entry.algorithm_id.is_empty() {
984 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();
985 if candidates.len() != 1 {
986 return Err(typed("HASH_AMBIGUOUS_ALGORITHM", "Manifest digest length is ambiguous; select the algorithm explicitly."));
987 }
988 entry.algorithm_id = candidates[0].into();
989 }
990 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())));
991 if !valid {
992 entry.invalid = true;
993 }
994 }
995 let mut unique: Vec<Value> = Vec::new();
996 for entry in &entries {
997 let id = json!(entry.algorithm_id);
998 if !unique.contains(&id) {
999 unique.push(id);
1002 let requests = normalize_requests(&Value::Array(unique))?;
1003 Ok((entries, requests))
1006#[cfg(test)]
1007mod tests {
1008 use super::*;
1010 fn service() -> (Arc<LocalHashService>, Arc<Mutex<Vec<(String, Value)>>>) {
1011 let events = Arc::new(Mutex::new(Vec::new()));
1012 let sink = events.clone();
1013 (Arc::new(LocalHashService::new(Arc::new(move |m: &str, v: Value| sink.lock().push((m.to_string(), v))), None)), events)
1016 fn wait(service: &LocalHashService, module: &str, job: &Value) -> Value {
1017 for _ in 0..500 {
1018 let status = service.status(module, &job["jobId"]).unwrap();
1019 if ["completed", "failed", "cancelled"].contains(&status["state"].as_str().unwrap()) {
1020 return status;
1022 std::thread::sleep(Duration::from_millis(10));
1024 panic!("job did not finish");
1027 #[test]
1028 fn calculate_verify_compare_and_isolation() {
1029 let dir = tempfile::tempdir().unwrap();
1030 let a = dir.path().join("a.txt");
1031 let b = dir.path().join("b.txt");
1032 std::fs::write(&a, "abc").unwrap();
1033 std::fs::write(&b, "abc").unwrap();
1034 let (svc, events) = service();
1035 let sel = svc.select_files("m1", true, Some(vec![a.clone(), b.clone()])).unwrap();
1036 assert_eq!(sel["files"][0]["relativePath"], "a.txt");
1037 let job = svc.start("m1", &json!({ "selectionId": sel["selectionId"], "algorithms": ["sha256", "crc32"], "output": "hex-upper" })).unwrap();
1038 let done = wait(&svc, "m1", &job);
1039 assert_eq!(done["state"], "completed");
1040 assert_eq!(done["results"][0]["hashes"][0]["value"], "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD");
1041 assert_eq!(done["percent"], 100.0);
1042 assert!(events.lock().iter().all(|(m, _)| m == "m1"));
1043 // Another module can neither use the token nor read the job.
1044 assert_eq!(svc.start("m2", &json!({ "selectionId": sel["selectionId"], "algorithms": ["sha256"] })).unwrap_err().code, "HASH_INVALID_SELECTION_TOKEN");
1045 assert_eq!(svc.status("m2", &job["jobId"]).unwrap_err().code, "HASH_INVALID_JOB");
1046 let one = svc.select_files("m1", false, Some(vec![a.clone()])).unwrap();
1047 let verify = svc.verify("m1", &json!({ "selectionId": one["selectionId"], "algorithm": "md5", "expected": "900150983cd24fb0d6963f7d28e17f72" })).unwrap();
1048 assert_eq!(wait(&svc, "m1", &verify)["results"][0]["status"], "MATCH");
1049 let bad = svc.verify("m1", &json!({ "selectionId": one["selectionId"], "algorithm": "md5", "expected": "kAFQmDzST7DWlj99KOF/cg==", "expectedEncoding": "base64" })).unwrap();
1050 assert_eq!(wait(&svc, "m1", &bad)["results"][0]["status"], "MATCH");
1051 let other = svc.select_files("m1", false, Some(vec![b.clone()])).unwrap();
1052 let cmp = svc.compare("m1", &json!({ "leftSelectionId": one["selectionId"], "rightSelectionId": other["selectionId"] })).unwrap();
1053 assert_eq!(wait(&svc, "m1", &cmp)["comparison"]["identical"], true);
1054 let (text, count) = svc.copy_text("m1", &json!({ "jobId": job["jobId"], "algorithmId": "crc32", "compact": true })).unwrap();
1055 assert_eq!(count, 2);
1056 assert_eq!(text, "352441C2\n352441C2");
1057 svc.cleanup_module("m1");
1058 assert_eq!(svc.status("m1", &job["jobId"]).unwrap_err().code, "HASH_INVALID_JOB");
1061 #[test]
1062 fn manifest_roundtrip_and_traversal_protection() {
1063 let dir = tempfile::tempdir().unwrap();
1064 let root = dir.path().join("root");
1065 std::fs::create_dir_all(root.join("sub")).unwrap();
1066 std::fs::write(root.join("one.txt"), "1").unwrap();
1067 std::fs::write(root.join("sub/two.txt"), "2").unwrap();
1068 std::fs::write(dir.path().join("secret.txt"), "s").unwrap();
1069 let (svc, _) = service();
1070 let directory = svc.select_directory("m", Some(root.clone())).unwrap();
1071 let destination = svc.select_manifest_destination("m", Some(dir.path().join("sums.sha256"))).unwrap();
1072 let job = svc.generate_manifest("m", &json!({ "algorithm": "sha256", "selectionId": directory["selectionId"], "destinationSelectionId": destination["selectionId"], "recursive": true })).unwrap();
1073 let done = wait(&svc, "m", &job);
1074 assert_eq!(done["manifest"]["entries"], 2, "{done}");
1075 let written = std::fs::read_to_string(dir.path().join("sums.sha256")).unwrap();
1076 assert!(written.starts_with("# Algorithm: sha256\n"));
1077 assert!(written.contains(" sub/two.txt\n"));
1078 let mut tampered = written.clone();
1079 tampered.push_str(&format!("{} ../secret.txt\n", "0".repeat(64)));
1080 tampered.push_str(&format!("{} /etc/passwd\n", "0".repeat(64)));
1081 tampered.push_str(&format!("{} missing.txt\n", "0".repeat(64)));
1082 tampered.push_str("garbage line\n");
1083 std::fs::write(dir.path().join("check.sha256"), tampered).unwrap();
1084 #[cfg(unix)]
1086 std::os::unix::fs::symlink(dir.path().join("secret.txt"), root.join("link.txt")).unwrap();
1087 let mut extra = std::fs::read_to_string(dir.path().join("check.sha256")).unwrap();
1088 extra.push_str(&format!("{} link.txt\n", "0".repeat(64)));
1089 std::fs::write(dir.path().join("check.sha256"), extra).unwrap();
1091 let manifest = svc.select_manifest("m", Some(dir.path().join("check.sha256"))).unwrap();
1092 let verify = svc.verify_manifest("m", &json!({ "manifestSelectionId": manifest["selectionId"], "rootSelectionId": directory["selectionId"] })).unwrap();
1093 let done = wait(&svc, "m", &verify);
1094 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();
1095 assert!(statuses.contains(&"one.txt:MATCH".to_string()), "{statuses:?}");
1096 assert!(statuses.contains(&"sub/two.txt:MATCH".to_string()));
1097 assert!(statuses.contains(&"../secret.txt:INVALID ENTRY".to_string()));
1098 assert!(statuses.contains(&"/etc/passwd:INVALID ENTRY".to_string()));
1099 assert!(statuses.contains(&"missing.txt:MISSING".to_string()));
1100 assert!(statuses.contains(&"garbage line:INVALID ENTRY".to_string()));
1101 #[cfg(unix)]
1102 assert!(statuses.contains(&"link.txt:INVALID ENTRY".to_string()));
1105 #[test]
1106 fn cancel_large_job() {
1107 let dir = tempfile::tempdir().unwrap();
1108 let big = dir.path().join("big.bin");
1109 std::fs::write(&big, vec![7u8; 64 * 1024 * 1024]).unwrap();
1110 let (svc, _) = service();
1111 let sel = svc.select_files("m", false, Some(vec![big])).unwrap();
1112 let job = svc.start("m", &json!({ "selectionId": sel["selectionId"], "algorithms": ["sha512", "whirlpool", "sha3-512"] })).unwrap();
1113 let cancelled = svc.cancel("m", &job["jobId"]).unwrap();
1114 assert_eq!(cancelled["state"], "cancelled");
1115 std::thread::sleep(Duration::from_millis(200));
1116 assert_eq!(svc.status("m", &job["jobId"]).unwrap()["state"], "cancelled");
SHA-256b9a976da0d143d5987e8b7e517b9a1c020e87fa06244f15defeebe47a6482d9b

CH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.

SHA-256 des Archivs77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df
Dateien