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