Veröffentlichte Quellen
Quellen als ZIP herunterladen
CH-J Tools
Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.
CH-J / Server Manager
872 Dateien
core-20261030T220507Z-61e4558d889f
Hinzugefügt: · Erstellt:
1
//! Local Hash & Checksum service (port of `hashing/localHashService.js`).2
//!3
//! Modules never see local paths: Core dialogs return opaque, per-module selection tokens4
//! (30 min, max 64 per module). Jobs run on a dedicated thread, report progress to the owning5
//! module window and can be cancelled. Manifest verification rejects absolute paths,6
//! traversal and symbolic links and never leaves the selected root directory.8
use super::algorithms::{self, normalize_requests, typed, Request};9
use super::engine::{self, Identity};10
use crate::core::error::{CoreError, CoreResult};11
use crate::core::logging::Logger;12
use base64::engine::general_purpose::{STANDARD as B64, URL_SAFE_NO_PAD};13
use base64::Engine;14
use parking_lot::Mutex;15
use rand::RngCore;16
use serde_json::{json, Map, Value};17
use std::collections::HashMap;18
use std::path::{Component, Path, PathBuf};19
use std::sync::atomic::{AtomicBool, Ordering};20
use std::sync::Arc;21
use std::time::{Duration, Instant};23
pub const MAX_SELECTIONS_PER_MODULE: usize = 64;24
pub const MAX_FILES_PER_JOB: usize = 10_000;25
pub const MAX_MANIFEST_BYTES: u64 = 10 * 1024 * 1024;26
const TOKEN_TTL: Duration = Duration::from_secs(30 * 60);27
const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1;29
fn 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))33
}35
fn now_ms() -> i64 {36
chrono::Utc::now().timestamp_millis()37
}39
fn clean_name(value: &str) -> String {40
value.replace(['\r', '\n', '\0'], "_")41
}43
/// Maps I/O failures to the Node error codes the 0.1.0 UI and logs used.44
pub 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
}50
}52
const KNOWN_CODES: [&str; 10] = ["ENOENT", "EACCES", "EPERM", "HASH_CANCELLED", "HASH_FILE_CHANGED", "HASH_NOT_REGULAR_FILE", "HASH_INVALID_MANIFEST", "HASH_PATH_ESCAPE", "HASH_SYMLINK_REJECTED", "HASH_UNSUPPORTED_ALGORITHM"];54
pub 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
}60
}62
fn contains_path(root: &Path, candidate: &Path) -> bool {63
candidate.starts_with(root)64
}66
fn 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())68
}70
#[derive(Debug, Clone)]71
struct 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>,81
}83
impl 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
}87
}89
#[derive(Debug, Clone)]90
enum Selection {91
Files(Vec<FileEntry>),92
Directory(PathBuf),93
Manifest(FileEntry),94
Destination(PathBuf),95
}97
impl 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
}106
}108
struct StoredSelection {109
id: String,110
created: Instant,111
selection: Selection,112
}114
struct ManifestTarget {115
format: String,116
destination: PathBuf,117
}119
struct 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>,138
}140
impl 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
}171
}173
struct JobHandle {174
job: Mutex<Job>,175
cancel: AtomicBool,176
}178
pub type ProgressEmitter = Arc<dyn Fn(&str, Value) + Send + Sync>;180
pub struct LocalHashService {181
selections: Mutex<HashMap<String, Vec<StoredSelection>>>,182
jobs: Mutex<HashMap<String, Arc<JobHandle>>>,183
emitter: ProgressEmitter,184
logger: Option<Arc<Logger>>,185
}187
fn 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 })195
}197
fn 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
}202
}204
impl 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
id281
}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.jobs451
.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> = job488
.results489
.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> = serializable503
.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
false565
});566
}567
}569
fn 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
}695
}697
fn 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(())720
}722
fn 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(())739
}741
// --- path authorization ---------------------------------------------------------------------------743
fn 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
})768
}770
fn 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)781
}783
fn 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("/")785
}787
fn 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
candidate796
}798
fn 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)802
}804
fn 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(())845
}847
fn 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))870
}872
// --- manifests ------------------------------------------------------------------------------------874
#[derive(Debug, Clone)]875
struct ManifestEntry {876
algorithm_id: String,877
filename: String,878
expected: String,879
invalid: bool,880
}882
fn 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("/"))897
}899
fn 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(¤t).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(())909
}911
fn 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
})925
}927
fn 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);1000
}1001
}1002
let requests = normalize_requests(&Value::Array(unique))?;1003
Ok((entries, requests))1004
}1006
#[cfg(test)]1007
mod 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)1014
}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;1021
}1022
std::thread::sleep(Duration::from_millis(10));1023
}1024
panic!("job did not finish");1025
}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");1059
}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)]1085
{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();1090
}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()));1103
}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");1117
}1118
}SHA-256
b9a976da0d143d5987e8b7e517b9a1c020e87fa06244f15defeebe47a6482d9bCH-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 Archivs
77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df