Julkaistu lähde
Lataa lähde ZIP
CH-J Tools
Selaile hakemistoja ja tiedostoja tietyn sovelluksen julkaisua varten.
CH-J / Server Manager
1704 tiedostot
0.1.2-beta
Lisätty: · Rakennettu:
1
//! Remote file operations for the File Manager module (port of `files/remoteFileService.js`).2
//!3
//! Plain operations use an SFTP channel; operations with `sudo: true` go through the fixed4
//! privileged helper. The text editor keeps a baseline per opened document (conflict5
//! detection) and saves through prepare → upload temp copy → finalize → cleanup with6
//! recovery copies, exactly like 0.1.0.8
use super::editor::{remote_editor, Authorization};9
use super::pipeline::{pump, Consumer, LocalFile, Producer, PumpStop};10
use super::posix;11
use crate::core::error::{CoreError, CoreResult};12
use crate::core::logging::Logger;13
use crate::core::ssh::privileged::PrivilegedExecutor;14
use crate::core::ssh::session::SessionManager;15
use crate::core::ssh::sftp::{FileHandle, Sftp, SftpError, Stat};16
use parking_lot::Mutex;17
use rand::RngCore;18
use serde_json::{json, Value};19
use sha2::{Digest, Sha256};20
use std::collections::HashMap;21
use std::path::{Path, PathBuf};22
use std::sync::Arc;23
use std::time::Duration;25
pub const MAX_TEXT_BYTES: usize = 25 * 1024 * 1024;26
pub const MAX_TRANSFER_BYTES: u64 = 16 * 1024 * 1024 * 1024;27
pub const MAX_BATCH_ENTRIES: u64 = 10_000;28
pub const MAX_SELECTED_ENTRIES: usize = 100;29
const DEFINITE_EDITOR_FAILURES: [&str; 31] = [30
"FILE_CONFLICT", "FILE_PERMISSION_DENIED", "FILE_NOT_FOUND", "FILE_UNSAFE_PATH", "FILE_UNSAFE_TYPE_OR_LINK", "FILE_UNSAFE_WORKSPACE",31
"FILE_UNSAFE_PRIVILEGED_DIRECTORY", "FILE_UPLOAD_INCOMPLETE", "FILE_TOO_LARGE", "FILE_METADATA_UNSUPPORTED", "FILE_REMOTE_IO_FAILED",32
"FILE_SUDO_FAILED", "FILE_DEPENDENCY_UNAVAILABLE", "FILE_SUDO_PASSWORD_INVALID", "FILE_SUDO_AUTHORIZATION_REQUIRED", "FILE_LINUX_REQUIRED",33
"FILE_HARDLINK_UNSUPPORTED", "FILE_METADATA_PERMISSION", "FILE_NO_SPACE", "FILE_READ_ONLY_FILESYSTEM", "FILE_SYMLINK_TARGET_MISSING",34
"FILE_SYMLINK_LOOP", "FILE_BUSY", "SUDO_PASSWORD_REQUIRED", "SUDO_PASSWORD_INCORRECT", "SUDO_NOT_PERMITTED", "SUDO_TTY_REQUIRED",35
"SUDO_AUTH_FAILED", "SUDO_UNAVAILABLE", "SUDO_FAILED", "SUDO_PASSWORD_INVALID",36
];37
/// Removing these directly (even with sudo) is refused; their content can still be managed.38
pub const PROTECTED_PATHS: [&str; 23] = [39
"/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64", "/libx32", "/media", "/mnt", "/opt", "/proc", "/root", "/run",40
"/sbin", "/snap", "/srv", "/sys", "/tmp", "/usr", "/var",41
];43
pub fn file_error(code: &str, message: &str) -> CoreError {44
CoreError::new(code, format!("{code}: {message}"))45
}47
fn random_hex(bytes: usize) -> String {48
let mut buffer = vec![0u8; bytes];49
rand::rngs::OsRng.fill_bytes(&mut buffer);50
hex::encode(buffer)51
}53
pub fn normalize_remote_path(value: &Value) -> CoreResult<String> {54
let raw = value.as_str().map(str::to_string).unwrap_or_else(|| if value.is_null() { String::new() } else { value.to_string() });55
let raw = raw.trim();56
if raw.is_empty() || raw.chars().count() > 4096 || !raw.starts_with('/') || raw.contains(['\0', '\r', '\n']) {57
return Err(CoreError::plain("Remote path must be an absolute path."));58
}59
Ok(posix::normalize(raw))60
}62
pub fn safe_entry_name(value: &str) -> CoreResult<String> {63
if value.is_empty() || value == "." || value == ".." || value.contains(['/', '\\', '\0']) {64
return Err(CoreError::plain("Remote entry name cannot be represented safely on the local filesystem."));65
}66
Ok(value.to_string())67
}69
/// `remotePath || "/"`.70
pub fn or_root(value: &Value) -> Value {71
match value {72
Value::Null => json!("/"),73
Value::String(s) if s.is_empty() => json!("/"),74
Value::Bool(false) => json!("/"),75
other => other.clone(),76
}77
}79
fn iso_from_secs(seconds: u64) -> Value {80
if seconds == 0 {81
return Value::Null;82
}83
chrono::DateTime::<chrono::Utc>::from_timestamp(seconds as i64, 0).map(|t| json!(t.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))).unwrap_or(Value::Null)84
}86
fn sort_entries(entries: &mut [Value]) {87
let order = |t: &Value| match t.as_str() {88
Some("directory") => 0,89
Some("file") => 1,90
_ => 2,91
};92
entries.sort_by(|a, b| {93
order(&a["type"]).cmp(&order(&b["type"])).then_with(|| {94
let (x, y) = (a["name"].as_str().unwrap_or(""), b["name"].as_str().unwrap_or(""));95
x.to_lowercase().cmp(&y.to_lowercase()).then_with(|| x.cmp(y))96
})97
});98
}100
#[derive(Debug, Clone)]101
struct Edit {102
target: String,103
identity: String,104
owner: String,105
baseline: Value,106
resolved_path: Option<String>,107
busy: bool,108
uncertain: bool,109
dispose: bool,110
}112
pub struct RemoteFileService {113
sessions: Arc<SessionManager>,114
privileged: Arc<PrivilegedExecutor>,115
logger: Arc<Logger>,116
edits: Mutex<HashMap<String, Edit>>,117
legacy_edits: Mutex<HashMap<String, Option<String>>>,118
}120
#[derive(Default)]121
struct BatchState {122
entries: u64,123
bytes: u64,124
}126
impl BatchState {127
fn count(&mut self, bytes: u64) -> CoreResult<()> {128
self.entries += 1;129
self.bytes += bytes;130
if self.entries > MAX_BATCH_ENTRIES {131
return Err(CoreError::plain(format!("Transfer exceeds the {MAX_BATCH_ENTRIES} entry limit.")));132
}133
if self.bytes > MAX_TRANSFER_BYTES {134
return Err(CoreError::plain("Transfer exceeds the aggregate size limit."));135
}136
Ok(())137
}138
}140
fn sftp_plain(error: SftpError) -> CoreError {141
error.file_error("")142
}144
fn io_plain(error: std::io::Error) -> CoreError {145
CoreError::plain(error.to_string())146
}148
impl RemoteFileService {149
pub fn new(sessions: Arc<SessionManager>, privileged: Arc<PrivilegedExecutor>, logger: Arc<Logger>) -> Self {150
Self { sessions, privileged, logger, edits: Mutex::new(HashMap::new()), legacy_edits: Mutex::new(HashMap::new()) }151
}153
pub async fn sftp(&self, session_id: &str) -> CoreResult<Arc<Sftp>> {154
Sftp::open(&self.sessions, session_id).await155
}157
/// Shared fixed helper (remoteEditor.py) for editor, privileged file operations and transfers.158
pub async fn helper(&self, session_id: &str, request: Value, auth: &Authorization) -> CoreResult<Value> {159
remote_editor(&self.sessions, &self.privileged, session_id, &request, auth).await160
}162
// --- listing --------------------------------------------------------------------------------164
pub async fn list(&self, session_id: &str, path: &Value, options: &Value) -> CoreResult<Value> {165
let target = normalize_remote_path(&or_root(path))?;166
if options["sudo"] == true {167
return self.privileged_list(session_id, &target, options).await;168
}169
let sftp = self.sftp(session_id).await?;170
let entries = sftp.read_dir(&target).await.map_err(|e| e.file_error(&format!("Directory {target} cannot be listed.")))?;171
let mut result: Vec<Value> = entries172
.into_iter()173
.filter(|e| !e.name.is_empty() && e.name != "." && e.name != "..")174
.map(|e| {175
json!({176
"name": e.name,177
"path": posix::join(&target, &e.name),178
"type": if e.stat.is_dir() { "directory" } else if e.stat.is_symlink() { "symlink" } else { "file" },179
"size": e.stat.size,180
"modifiedAt": iso_from_secs(e.stat.mtime as u64),181
"mode": e.stat.mode182
})183
})184
.collect();185
// A link to a directory must be navigable, a link to a file editable (the target is edited).186
let links: Vec<usize> = result.iter().enumerate().filter(|(_, e)| e["type"] == "symlink").map(|(i, _)| i).take(500).collect();187
let stats = futures::future::join_all(links.iter().map(|i| {188
let path = result[*i]["path"].as_str().unwrap_or("").to_string();189
let sftp = sftp.clone();190
async move { sftp.stat(&path).await.ok() }191
}))192
.await;193
for (i, stat) in links.into_iter().zip(stats) {194
let entry = &mut result[i];195
match stat {196
None => entry["targetType"] = json!("missing"),197
Some(s) if s.is_dir() => entry["targetType"] = json!("directory"),198
Some(s) => {199
entry["targetType"] = json!("file");200
entry["size"] = json!(s.size);201
}202
}203
}204
sort_entries(&mut result);205
Ok(Value::Array(result))206
}208
async fn privileged_list(&self, session_id: &str, target: &str, options: &Value) -> CoreResult<Value> {209
let value = self.helper(session_id, json!({ "operation": "listdir", "path": target }), &Authorization::from_options(options).sudo()).await?;210
let mut entries: Vec<Value> = value["entries"]211
.as_array()212
.cloned()213
.unwrap_or_default()214
.into_iter()215
.map(|entry| {216
let name = entry["name"].as_str().map(str::to_string).unwrap_or_else(|| entry["name"].to_string());217
let kind = match entry["type"].as_str() {218
Some("directory") => "directory",219
Some("symlink") => "symlink",220
_ => "file",221
};222
let mut out = json!({223
"name": name,224
"path": posix::join(target, &name),225
"type": kind,226
"size": entry["size"].as_f64().unwrap_or(0.0).max(0.0) as u64,227
"modifiedAt": iso_from_secs(entry["mtime"].as_f64().unwrap_or(0.0).max(0.0) as u64),228
"mode": entry["mode"].as_u64().unwrap_or(0),229
"privileged": true230
});231
if !entry["targetType"].is_null() {232
out["targetType"] = entry["targetType"].clone();233
}234
out235
})236
.collect();237
sort_entries(&mut entries);238
Ok(Value::Array(entries))239
}241
pub async fn privileges(&self, session_id: &str) -> CoreResult<Value> {242
let mut privilege = self.privileged.probe(session_id, false).await?;243
privilege["sudoCached"] = json!(self.privileged.has_cached_password(session_id));244
Ok(privilege)245
}247
// --- text editor ----------------------------------------------------------------------------249
fn editor_path(value: &Value) -> CoreResult<String> {250
let raw = value.as_str().unwrap_or("");251
if raw.is_empty() || raw.chars().count() > 4096 || !raw.starts_with('/') || raw.contains(['\0', '\r', '\n']) {252
return Err(CoreError::plain("Remote path must be an absolute path."));253
}254
if raw.split('/').any(|p| p == "." || p == "..") {255
return Err(CoreError::plain("Editor paths cannot contain traversal components."));256
}257
Ok(posix::normalize(raw))258
}260
fn identity(&self, session_id: &str) -> CoreResult<String> {261
let session = self.sessions.list().into_iter().find(|s| s["sessionId"] == session_id && s["state"] == "connected").ok_or_else(|| CoreError::plain("The editor session is not connected."))?;262
Ok(serde_json::to_string(&json!([session_id, session["profileId"], session["host"], session["port"], session["username"]])).unwrap_or_default())263
}265
fn legacy_key(owner: &str, identity: &str, target: &str) -> String {266
serde_json::to_string(&json!([owner, identity, target])).unwrap_or_default()267
}269
pub async fn read_text(&self, session_id: &str, path: &Value, options: &Value, owner: &str) -> CoreResult<Value> {270
let target = Self::editor_path(path)?;271
let identity = self.identity(session_id)?;272
let value = self.helper(session_id, json!({ "operation": "read", "path": target }), &Authorization::from_options(options)).await?;273
let data = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, value["data"].as_str().unwrap_or("")).map_err(|_| CoreError::plain("Remote editor content verification failed."))?;274
if data.len() > MAX_TEXT_BYTES {275
return Err(CoreError::plain(format!("Text editor limit is {MAX_TEXT_BYTES} bytes.")));276
}277
let text = String::from_utf8(data.clone()).map_err(|_| CoreError::plain("The selected file is not valid UTF-8 text."))?;278
if hex::encode(Sha256::digest(&data)) != value["baseline"]["hash"].as_str().unwrap_or("") {279
return Err(CoreError::plain("Remote editor content verification failed."));280
}281
let edit_id = random_hex(16);282
{283
let mut edits = self.edits.lock();284
if edits.len() >= 1000 {285
return Err(CoreError::plain("Close unused editor documents before opening more files."));286
}287
edits.insert(288
edit_id.clone(),289
Edit {290
target: target.clone(),291
identity: identity.clone(),292
owner: owner.to_string(),293
baseline: value["baseline"].clone(),294
resolved_path: value["resolvedPath"].as_str().map(str::to_string),295
busy: false,296
uncertain: false,297
dispose: false,298
},299
);300
}301
// A legacy call has no document identifier. Once two documents are open,302
// choosing either baseline would silently defeat conflict detection.303
{304
let key = Self::legacy_key(owner, &identity, &target);305
let mut legacy = self.legacy_edits.lock();306
let next = if legacy.contains_key(&key) { None } else { Some(edit_id.clone()) };307
legacy.insert(key, next);308
}309
let mtime_ns: i128 = match &value["baseline"]["mtimeNs"] {310
Value::String(s) => s.parse().unwrap_or(0),311
Value::Number(n) => n.as_f64().unwrap_or(0.0) as i128,312
_ => 0,313
};314
let modified = chrono::DateTime::<chrono::Utc>::from_timestamp_millis((mtime_ns / 1_000_000) as i64).map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));315
Ok(json!({316
"path": target,317
"text": text,318
"size": data.len(),319
"editId": edit_id,320
"baselineHash": value["baseline"]["hash"],321
"metadata": value["baseline"],322
"resolvedPath": value["resolvedPath"].as_str().unwrap_or(&target),323
"link": value["link"] == true,324
"directSave": value["directSave"] != false,325
"privileged": options["sudo"] == true,326
"modifiedAt": modified327
}))328
}330
pub async fn write_text(&self, session_id: &str, path: &Value, text: &Value, options: &Value, owner: &str) -> CoreResult<Value> {331
let target = Self::editor_path(path)?;332
let data = match text {333
Value::String(s) => s.as_bytes().to_vec(),334
Value::Null => Vec::new(),335
other => other.to_string().into_bytes(),336
};337
if data.len() > MAX_TEXT_BYTES {338
return Err(CoreError::plain(format!("Text editor limit is {MAX_TEXT_BYTES} bytes.")));339
}340
let identity = self.identity(session_id)?;341
let requested = options["editId"].as_str().filter(|s| !s.is_empty()).map(str::to_string);342
let legacy = self.legacy_edits.lock().get(&Self::legacy_key(owner, &identity, &target)).cloned();343
if requested.is_none() && matches!(legacy, Some(None)) {344
return Err(CoreError::new("FILE_EDIT_ID_REQUIRED", "FILE_EDIT_ID_REQUIRED: Multiple documents are open for this path; pass the editId returned by readText."));345
}346
let edit_id = requested.or(legacy.flatten()).unwrap_or_default();347
let edit = {348
let mut edits = self.edits.lock();349
let edit = edits.get_mut(&edit_id).filter(|e| e.target == target && e.identity == identity && e.owner == owner).ok_or_else(|| CoreError::plain("Reopen this file to capture an editor baseline before saving."))?;350
if edit.busy || edit.uncertain {351
return Err(CoreError::new("FILE_RESULT_UNKNOWN", "FILE_RESULT_UNKNOWN: Inspect recovery before starting another save."));352
}353
edit.busy = true;354
edit.clone()355
};356
let auth = Authorization::from_options(options);357
let hash = hex::encode(Sha256::digest(&data));358
let id = random_hex(16);359
let mut recovery: Option<Value> = None;360
let mut submitted = false;361
let outcome = async {362
let mut request = json!({ "operation": "prepare", "id": id, "path": target, "baseline": edit.baseline, "hash": hash });363
if let Some(resolved) = &edit.resolved_path {364
request["resolvedPath"] = json!(resolved);365
}366
let prepared = self.helper(session_id, request, &Authorization::default()).await?;367
recovery = Some(prepared.clone());368
self.upload_editor_copy(session_id, prepared["temporary"].as_str().unwrap_or(""), &data).await?;369
submitted = true;370
let mut finalize = json!({ "operation": "finalize", "id": id, "uid": prepared["uid"], "path": target, "baseline": edit.baseline, "hash": hash });371
if let Some(resolved) = &edit.resolved_path {372
finalize["resolvedPath"] = json!(resolved);373
}374
if options["backup"] == true {375
finalize["backup"] = json!(true);376
}377
let saved = self.helper(session_id, finalize, &auth).await?;378
if saved["status"] != "saved" || saved["hash"] != hash.as_str() || saved["baseline"]["hash"] != hash.as_str() {379
return Err(CoreError::new("FILE_RESULT_UNKNOWN", "Remote save could not be confirmed."));380
}381
if let Some(e) = self.edits.lock().get_mut(&edit_id) {382
e.baseline = saved["baseline"].clone();383
}384
let recovery_available = self.helper(session_id, json!({ "operation": "cleanup", "id": id, "uid": prepared["uid"] }), &auth).await.is_err();385
self.logger.info("Remote editor save confirmed.", json!({ "sessionId": session_id, "path": target, "resolvedPath": saved["resolvedPath"], "privileged": auth.sudo, "bytes": data.len() }));386
Ok(json!({387
"path": target,388
"size": data.len(),389
"status": "saved",390
"editId": edit_id,391
"baselineHash": hash,392
"recoveryId": id,393
"recoveryAvailable": recovery_available,394
"resolvedPath": saved["resolvedPath"].as_str().unwrap_or(&target),395
"link": saved["link"] == true,396
"backupPath": saved.get("backupPath").cloned().filter(|v| !v.is_null()).unwrap_or(Value::Null)397
}))398
}399
.await;400
let dispose = {401
let mut edits = self.edits.lock();402
match edits.get_mut(&edit_id) {403
Some(e) => {404
e.busy = false;405
if let Err(error) = &outcome {406
e.uncertain = submitted && !DEFINITE_EDITOR_FAILURES.contains(&error.code.as_str());407
}408
e.dispose409
}410
None => false,411
}412
};413
if dispose {414
self.close_text(&edit_id, owner);415
}416
outcome.map_err(|error| {417
let uncertain = submitted && !DEFINITE_EDITOR_FAILURES.contains(&error.code.as_str());418
let code = if uncertain { "FILE_RESULT_UNKNOWN".to_string() } else if error.code.is_empty() { "FILE_UPLOAD_FAILED".to_string() } else { error.code.clone() };419
let recovery_id = recovery.as_ref().and_then(|r| r["recoveryId"].as_str().map(str::to_string));420
let message = format!("{code}: Save was not confirmed. Keep editor content and inspect recovery{}.", recovery_id.as_ref().map(|r| format!(" ({r})")).unwrap_or_default());421
self.logger.warn("Remote editor save not confirmed.", json!({ "sessionId": session_id, "path": target, "code": code, "status": if uncertain { "unknown" } else { "failed" }, "privileged": auth.sudo }));422
CoreError::new(&code, message)423
.with("recoveryId", recovery_id.map(Value::from).unwrap_or(Value::Null))424
.with("recoveryPath", recovery.as_ref().map(|r| r["temporary"].clone()).unwrap_or(Value::Null))425
.with("status", if uncertain { "unknown" } else { "failed" })426
})427
}429
pub async fn save_text(&self, session_id: &str, path: &Value, text: &Value, options: &Value, owner: &str) -> Value {430
match self.write_text(session_id, path, text, options, owner).await {431
Ok(value) => json!({ "ok": true, "value": value }),432
Err(error) => json!({ "ok": false, "error": {433
"code": if error.code.is_empty() { "FILE_SAVE_FAILED" } else { error.code.as_str() },434
"message": error.message,435
"status": error.details.get("status").cloned().unwrap_or(json!("failed")),436
"recoveryId": error.details.get("recoveryId").cloned().unwrap_or(Value::Null),437
"recoveryPath": error.details.get("recoveryPath").cloned().unwrap_or(Value::Null)438
} }),439
}440
}442
async fn upload_editor_copy(&self, session_id: &str, temporary: &str, data: &[u8]) -> CoreResult<()> {443
let work = async {444
let sftp = self.sftp(session_id).await?;445
sftp.write_new_file(temporary, data, 0o600).await.map_err(sftp_plain)446
};447
tokio::time::timeout(Duration::from_secs(60), work).await.map_err(|_| CoreError::new("FILE_UPLOAD_TIMEOUT", "Editor upload timed out."))?448
}450
async fn recovery(&self, session_id: &str, operation: &str, recovery_id: Option<&Value>, options: &Value) -> CoreResult<Value> {451
let mut request = json!({ "operation": operation });452
if let Some(id) = recovery_id.filter(|v| !v.is_null()) {453
request["id"] = id.clone();454
}455
if options["sudo"] == true {456
let identity = self.helper(session_id, json!({ "operation": "identity" }), &Authorization::default()).await?;457
request["uid"] = identity["uid"].clone();458
}459
self.helper(session_id, request, &Authorization::from_options(options)).await460
}462
pub async fn list_recovery(&self, session_id: &str, options: &Value) -> CoreResult<Value> {463
self.recovery(session_id, "list", None, options).await464
}466
pub async fn read_recovery(&self, session_id: &str, recovery_id: &Value, options: &Value) -> CoreResult<Value> {467
let result = self.recovery(session_id, "inspect", Some(recovery_id), options).await?;468
let unverified = || CoreError::plain("FILE_RECOVERY_UNVERIFIED: Recovery content could not be verified.");469
let data = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, result["data"].as_str().unwrap_or("")).map_err(|_| unverified())?;470
if result["complete"] != true || data.len() > MAX_TEXT_BYTES || hex::encode(Sha256::digest(&data)) != result["hash"].as_str().unwrap_or("") {471
return Err(unverified());472
}473
let text = String::from_utf8(data).map_err(|_| CoreError::plain("Recovery file is not valid UTF-8 text."))?;474
let mut summary = result;475
if let Some(map) = summary.as_object_mut() {476
map.remove("data");477
map.insert("text".into(), json!(text));478
}479
Ok(summary)480
}482
pub async fn cleanup_recovery(&self, session_id: &str, recovery_id: &Value, options: &Value) -> CoreResult<Value> {483
self.recovery(session_id, "cleanup", Some(recovery_id), options).await484
}486
pub fn close_text(&self, edit_id: &str, owner: &str) -> Value {487
let mut edits = self.edits.lock();488
let Some(edit) = edits.get(edit_id).filter(|e| e.owner == owner && !e.busy).cloned() else { return json!({ "closed": false }) };489
edits.remove(edit_id);490
let key = Self::legacy_key(owner, &edit.identity, &edit.target);491
let remaining: Vec<String> = edits.iter().filter(|(_, o)| o.owner == owner && o.identity == edit.identity && o.target == edit.target).map(|(id, _)| id.clone()).collect();492
let mut legacy = self.legacy_edits.lock();493
if remaining.is_empty() {494
legacy.remove(&key);495
} else {496
legacy.insert(key, if remaining.len() == 1 { Some(remaining[0].clone()) } else { None });497
}498
json!({ "closed": true })499
}501
pub fn cleanup_module(&self, owner: &str) {502
let ids: Vec<(String, bool)> = self.edits.lock().iter().filter(|(_, e)| e.owner == owner).map(|(id, e)| (id.clone(), e.busy)).collect();503
for (id, busy) in ids {504
if busy {505
if let Some(e) = self.edits.lock().get_mut(&id) {506
e.dispose = true;507
}508
} else {509
self.close_text(&id, owner);510
}511
}512
}514
// --- mutations ------------------------------------------------------------------------------516
async fn privileged_mutation(&self, session_id: &str, request: Value, options: &Value, log: &str) -> CoreResult<Value> {517
let value = self.helper(session_id, request.clone(), &Authorization::from_options(options).sudo()).await?;518
self.logger.warn(log, json!({ "sessionId": session_id, "operation": request["operation"], "path": request.get("path").or(request.get("from")), "to": request["to"] }));519
Ok(value)520
}522
/// OpenSSH reports an existing target as a generic failure; tell the user what actually happened.523
async fn mutation_error(&self, sftp: &Sftp, error: SftpError, target: &str, message: &str) -> CoreError {524
if matches!(error.status(), Some(4) | Some(11)) && sftp.lstat(target).await.is_ok() {525
return file_error("FILE_EXISTS", &format!("{target} already exists."));526
}527
error.file_error(message)528
}530
pub async fn create_file(&self, session_id: &str, path: &Value, options: &Value) -> CoreResult<Value> {531
let target = normalize_remote_path(path)?;532
if target == "/" {533
return Err(CoreError::plain("The remote root cannot be a file."));534
}535
if options["sudo"] == true {536
self.privileged_mutation(session_id, json!({ "operation": "create", "path": target }), options, "Remote file created with privileges.").await?;537
return Ok(json!({ "path": target, "size": 0 }));538
}539
let sftp = self.sftp(session_id).await?;540
if let Err(error) = sftp.write_new_file(&target, &[], 0o644).await {541
return Err(self.mutation_error(&sftp, error, &target, &format!("File {target} could not be created.")).await);542
}543
self.logger.warn("Remote file created.", json!({ "sessionId": session_id, "path": target }));544
Ok(json!({ "path": target, "size": 0 }))545
}547
pub async fn mkdir(&self, session_id: &str, path: &Value, options: &Value) -> CoreResult<Value> {548
let target = normalize_remote_path(path)?;549
if target == "/" {550
return Err(CoreError::plain("The remote root already exists."));551
}552
if options["sudo"] == true {553
self.privileged_mutation(session_id, json!({ "operation": "mkdir", "path": target }), options, "Remote directory created with privileges.").await?;554
return Ok(json!({ "path": target }));555
}556
let sftp = self.sftp(session_id).await?;557
if let Err(error) = sftp.mkdir(&target, 0o755).await {558
return Err(self.mutation_error(&sftp, error, &target, &format!("Directory {target} could not be created.")).await);559
}560
self.logger.warn("Remote directory created.", json!({ "sessionId": session_id, "path": target }));561
Ok(json!({ "path": target }))562
}564
pub async fn rename(&self, session_id: &str, from: &Value, to: &Value, options: &Value) -> CoreResult<Value> {565
let (from, to) = (normalize_remote_path(from)?, normalize_remote_path(to)?);566
if from == "/" || to == "/" {567
return Err(CoreError::plain("The remote root cannot be renamed."));568
}569
if PROTECTED_PATHS.contains(&from.as_str()) {570
return Err(file_error("FILE_PROTECTED_PATH", &format!("{from} is a protected system directory.")));571
}572
if options["sudo"] == true {573
self.privileged_mutation(session_id, json!({ "operation": "rename", "from": from, "to": to }), options, "Remote entry renamed with privileges.").await?;574
return Ok(json!({ "from": from, "to": to }));575
}576
let sftp = self.sftp(session_id).await?;577
if let Err(error) = sftp.rename(&from, &to).await {578
return Err(self.mutation_error(&sftp, error, &to, &format!("{from} could not be renamed to {to}.")).await);579
}580
self.logger.warn("Remote entry renamed.", json!({ "sessionId": session_id, "from": from, "to": to }));581
Ok(json!({ "from": from, "to": to }))582
}584
pub async fn remove(&self, session_id: &str, path: &Value, options: &Value) -> CoreResult<Value> {585
let target = normalize_remote_path(path)?;586
if target == "/" {587
return Err(CoreError::plain("The remote root cannot be removed."));588
}589
if PROTECTED_PATHS.contains(&target.as_str()) {590
return Err(file_error("FILE_PROTECTED_PATH", &format!("{target} is a protected system directory.")));591
}592
if options["sudo"] == true {593
let value = self.privileged_mutation(session_id, json!({ "operation": "remove", "path": target }), options, "Remote entry removed with privileges.").await?;594
return Ok(json!({ "path": target, "type": if value["type"] == "directory" { "directory" } else { "file" } }));595
}596
let sftp = self.sftp(session_id).await?;597
let failed = |e: SftpError| e.file_error(&format!("{target} could not be removed."));598
let stat = sftp.lstat(&target).await.map_err(failed)?;599
if stat.is_dir() { sftp.rmdir(&target).await } else { sftp.unlink(&target).await }.map_err(failed)?;600
let kind = if stat.is_dir() { "directory" } else { "file" };601
self.logger.warn("Remote entry removed.", json!({ "sessionId": session_id, "path": target, "type": kind }));602
Ok(json!({ "path": target, "type": kind }))603
}605
pub fn normalize_selection(paths: &Value) -> CoreResult<Vec<String>> {606
let values: Vec<Value> = match paths {607
Value::Array(list) => list.clone(),608
other => vec![other.clone()],609
};610
let mut targets: Vec<String> = Vec::new();611
for value in values.into_iter().filter(|v| !v.is_null()) {612
let target = normalize_remote_path(&value)?;613
if !targets.contains(&target) {614
targets.push(target);615
}616
}617
if targets.is_empty() || targets.len() > MAX_SELECTED_ENTRIES {618
return Err(CoreError::plain(format!("Select between 1 and {MAX_SELECTED_ENTRIES} remote entries.")));619
}620
Ok(targets)621
}623
pub async fn remove_many(&self, session_id: &str, paths: &Value, recursive: bool, options: &Value) -> CoreResult<Value> {624
let targets = Self::normalize_selection(paths)?;625
if targets.iter().any(|t| t == "/") {626
return Err(CoreError::plain("The remote root cannot be removed."));627
}628
if let Some(protected) = targets.iter().find(|t| PROTECTED_PATHS.contains(&t.as_str())) {629
return Err(file_error("FILE_PROTECTED_PATH", &format!("{protected} is a protected system directory.")));630
}631
let mut ordered = targets.clone();632
ordered.sort_by_key(|t| std::cmp::Reverse(t.len()));633
if options["sudo"] == true {634
let mut entries = 0u64;635
for target in &ordered {636
let value = self.helper(session_id, json!({ "operation": "remove", "path": target, "recursive": recursive }), &Authorization::from_options(options).sudo()).await?;637
entries += value["entries"].as_u64().filter(|n| *n > 0).unwrap_or(1);638
}639
self.logger.warn("Remote entries removed with privileges.", json!({ "sessionId": session_id, "selected": targets.len(), "entries": entries, "recursive": recursive }));640
return Ok(json!({ "selected": targets.len(), "entries": entries }));641
}642
let sftp = self.sftp(session_id).await?;643
let mut state = BatchState::default();644
for target in &ordered {645
remove_remote_entry(&sftp, target, recursive, &mut state).await?;646
}647
self.logger.warn("Remote entries removed.", json!({ "sessionId": session_id, "selected": targets.len(), "entries": state.entries, "recursive": recursive }));648
Ok(json!({ "selected": targets.len(), "entries": state.entries }))649
}651
// --- simple (non-queued) transfers ------------------------------------------------------------653
pub async fn upload(&self, session_id: &str, directory: &Value, picked: Vec<PathBuf>) -> CoreResult<Value> {654
let directory = normalize_remote_path(&or_root(directory))?;655
let mut local_paths: Vec<PathBuf> = Vec::new();656
for p in picked {657
if !local_paths.contains(&p) {658
local_paths.push(p);659
}660
}661
if local_paths.is_empty() {662
return Ok(json!({ "canceled": true, "items": [] }));663
}664
if local_paths.len() > MAX_SELECTED_ENTRIES {665
return Err(CoreError::plain(format!("At most {MAX_SELECTED_ENTRIES} entries can be uploaded at once.")));666
}667
let sftp = self.sftp(session_id).await?;668
let mut state = BatchState::default();669
let mut items = Vec::new();670
for local in &local_paths {671
let name = local.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();672
let target = posix::join(&directory, &name);673
let (kind, size) = Box::pin(upload_local_entry(&sftp, local, &target, &mut state)).await?;674
items.push(json!({ "path": target, "type": kind, "size": size }));675
}676
self.logger.info("Entries uploaded through Core.", json!({ "sessionId": session_id, "directory": directory, "entries": state.entries, "bytes": state.bytes }));677
Ok(json!({ "canceled": false, "items": items, "entries": state.entries, "size": state.bytes }))678
}680
/// Download step 1 (before the save dialog): the file must be a regular file within limits.681
pub async fn download_check(&self, session_id: &str, path: &Value) -> CoreResult<(String, u64)> {682
let target = normalize_remote_path(path)?;683
let sftp = self.sftp(session_id).await?;684
let stat = sftp.stat(&target).await.map_err(sftp_plain)?;685
if stat.is_dir() {686
return Err(CoreError::plain("Directory download is not available in this first File Manager version."));687
}688
if stat.size > MAX_TRANSFER_BYTES {689
return Err(CoreError::plain("Remote file exceeds the transfer size limit."));690
}691
Ok((target, stat.size))692
}694
pub async fn download_to(&self, session_id: &str, target: &str, size: u64, local: &Path) -> CoreResult<Value> {695
let sftp = self.sftp(session_id).await?;696
get_file(&sftp, target, local).await?;697
self.logger.info("File downloaded through Core.", json!({ "sessionId": session_id, "path": target, "bytes": size }));698
Ok(json!({ "canceled": false, "path": target, "size": size }))699
}701
pub async fn download_many(&self, session_id: &str, targets: &[String], directory: &Path) -> CoreResult<Value> {702
let sftp = self.sftp(session_id).await?;703
let mut state = BatchState::default();704
let mut items = Vec::new();705
for target in targets {706
if target == "/" {707
return Err(CoreError::plain("Downloading the entire remote root is not allowed in one batch."));708
}709
let local = directory.join(safe_entry_name(&posix::basename(target))?);710
let (kind, size) = Box::pin(download_remote_entry(&sftp, target, &local, &mut state)).await?;711
items.push(json!({ "path": target, "type": kind, "size": size }));712
}713
self.logger.info("Entries downloaded through Core.", json!({ "sessionId": session_id, "entries": state.entries, "bytes": state.bytes }));714
Ok(json!({ "canceled": false, "items": items, "entries": state.entries, "size": state.bytes }))715
}717
pub fn archive_format(requested: &Value) -> CoreResult<String> {718
let raw = requested.as_str().filter(|s| !s.is_empty()).unwrap_or("zip").to_lowercase();719
let format = if raw == "tgz" { "tar.gz".to_string() } else { raw };720
if !["zip", "tar", "tar.gz"].contains(&format.as_str()) {721
return Err(CoreError::plain("Supported archive formats are zip, tar and tar.gz."));722
}723
Ok(format)724
}726
pub async fn download_archive(&self, session_id: &str, targets: &[String], format: &str, destination: &Path) -> CoreResult<Value> {727
let root = std::env::temp_dir().join(format!("chj-file-archive-{}", random_hex(6)));728
std::fs::create_dir(&root).map_err(io_plain)?;729
#[cfg(unix)]730
{731
use std::os::unix::fs::PermissionsExt;732
let _ = std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700));733
}734
let result = async {735
let sftp = self.sftp(session_id).await?;736
let mut state = BatchState::default();737
for target in targets {738
let local = root.join(safe_entry_name(&posix::basename(target))?);739
Box::pin(download_remote_entry(&sftp, target, &local, &mut state)).await?;740
}741
let (source, dest, fmt) = (root.clone(), destination.to_path_buf(), format.to_string());742
let size = tokio::task::spawn_blocking(move || super::archive::create(&source, &dest, &fmt)).await.map_err(|_| CoreError::plain("Archive creation failed."))??;743
self.logger.info("Remote selection archived through Core.", json!({ "sessionId": session_id, "format": format, "entries": state.entries, "sourceBytes": state.bytes, "archiveBytes": size }));744
Ok(json!({ "canceled": false, "path": destination.to_string_lossy(), "format": format, "entries": state.entries, "sourceSize": state.bytes, "size": size }))745
}746
.await;747
let _ = std::fs::remove_dir_all(&root);748
result749
}750
}752
async fn remove_remote_entry(sftp: &Sftp, path: &str, recursive: bool, state: &mut BatchState) -> CoreResult<()> {753
let failed = |e: SftpError| e.file_error("Remote entries could not be removed.");754
let stat = sftp.lstat(path).await.map_err(failed)?;755
state.count(stat.size)?;756
if stat.is_dir() {757
if recursive {758
for entry in sftp.read_dir(path).await.map_err(failed)? {759
if entry.name == "." || entry.name == ".." {760
continue;761
}762
let child = posix::join(path, &safe_entry_name(&entry.name)?);763
Box::pin(remove_remote_entry(sftp, &child, true, state)).await?;764
}765
}766
sftp.rmdir(path).await.map_err(failed)767
} else {768
sftp.unlink(path).await.map_err(failed)769
}770
}772
async fn upload_local_entry(sftp: &Arc<Sftp>, local: &Path, remote: &str, state: &mut BatchState) -> CoreResult<(&'static str, u64)> {773
let meta = std::fs::symlink_metadata(local).map_err(io_plain)?;774
if meta.file_type().is_symlink() {775
return Err(CoreError::plain("Uploading symbolic links is not supported."));776
}777
if meta.is_dir() {778
state.count(0)?;779
if let Err(error) = sftp.mkdir(remote, 0o755).await {780
if !sftp.stat(remote).await.map(|s| s.is_dir()).unwrap_or(false) {781
return Err(sftp_plain(error));782
}783
}784
for entry in std::fs::read_dir(local).map_err(io_plain)? {785
let entry = entry.map_err(io_plain)?;786
let name = safe_entry_name(&entry.file_name().to_string_lossy())?;787
Box::pin(upload_local_entry(sftp, &local.join(&name), &posix::join(remote, &name), state)).await?;788
}789
return Ok(("directory", 0));790
}791
if !meta.is_file() {792
return Err(CoreError::plain("Only regular files and directories can be uploaded."));793
}794
state.count(meta.len())?;795
let temporary = posix::join(&posix::dirname(remote), &format!(".{}.chj-upload-{}.tmp", posix::basename(remote), random_hex(6)));796
let result = async {797
put_file(sftp, local, &temporary).await?;798
sftp.rename(&temporary, remote).await.map_err(sftp_plain)799
}800
.await;801
if result.is_err() {802
let _ = sftp.unlink(&temporary).await;803
}804
result.map(|_| ("file", meta.len()))805
}807
async fn download_remote_entry(sftp: &Arc<Sftp>, remote: &str, local: &Path, state: &mut BatchState) -> CoreResult<(&'static str, u64)> {808
let stat = sftp.lstat(remote).await.map_err(sftp_plain)?;809
if stat.is_symlink() {810
return Err(CoreError::plain(format!("Symbolic links are not included in recursive downloads: {remote}")));811
}812
if stat.is_dir() {813
state.count(0)?;814
match std::fs::create_dir(local) {815
Ok(()) => {816
#[cfg(unix)]817
{818
use std::os::unix::fs::PermissionsExt;819
let _ = std::fs::set_permissions(local, std::fs::Permissions::from_mode(0o700));820
}821
}822
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}823
Err(e) => return Err(io_plain(e)),824
}825
for entry in sftp.read_dir(remote).await.map_err(sftp_plain)? {826
if entry.name == "." || entry.name == ".." {827
continue;828
}829
let name = safe_entry_name(&entry.name)?;830
Box::pin(download_remote_entry(sftp, &posix::join(remote, &name), &local.join(&name), state)).await?;831
}832
return Ok(("directory", 0));833
}834
state.count(stat.size)?;835
if let Some(parent) = local.parent() {836
std::fs::create_dir_all(parent).map_err(io_plain)?;837
}838
let file_name = local.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();839
let temporary = local.with_file_name(format!(".{file_name}.chj-download-{}.tmp", random_hex(6)));840
let result = async {841
get_file(sftp, remote, &temporary).await?;842
std::fs::rename(&temporary, local).map_err(io_plain)843
}844
.await;845
if result.is_err() {846
let _ = std::fs::remove_file(&temporary);847
}848
result.map(|_| ("file", stat.size))849
}851
fn pump_error(stop: PumpStop) -> CoreError {852
match stop {853
PumpStop::Failed(error) => error,854
PumpStop::Stopped(kind) => CoreError::plain(format!("Transfer {kind}")),855
}856
}858
/// ssh2 `fastPut` equivalent (pipelined).859
pub async fn put_file(sftp: &Arc<Sftp>, local: &Path, remote: &str) -> CoreResult<()> {860
let file = LocalFile::open(local, "r").map_err(io_plain)?;861
let size = file.metadata().map_err(io_plain)?.len();862
let handle = sftp.open_file(remote, "w", None).await.map_err(sftp_plain)?;863
let produce: Producer = {864
let file = file.clone();865
Arc::new(move |pos, len| {866
let file = file.clone();867
Box::pin(async move {868
let data = file.read_at(pos, len).await.map_err(io_plain)?;869
if data.len() != len {870
return Err(file_error("FILE_SOURCE_CHANGED", "The local file shrank during the transfer."));871
}872
Ok(data)873
})874
})875
};876
let consume: Consumer = {877
let (sftp, handle) = (sftp.clone(), handle.clone());878
Arc::new(move |buf, pos| {879
let (sftp, handle) = (sftp.clone(), handle.clone());880
Box::pin(async move { sftp.write_at(&handle, pos, &buf).await.map_err(sftp_plain) })881
})882
};883
let (_keep, stop) = tokio::sync::watch::channel(String::new());884
let result = pump(0, size, produce, consume, stop, |_| {}).await.map_err(pump_error);885
let closed = sftp.close_file(&handle).await.map_err(sftp_plain);886
result.and(closed)887
}889
/// ssh2 `fastGet` equivalent (pipelined).890
pub async fn get_file(sftp: &Arc<Sftp>, remote: &str, local: &Path) -> CoreResult<()> {891
let handle: FileHandle = sftp.open_file(remote, "r", None).await.map_err(sftp_plain)?;892
let stat: Stat = sftp.fstat(&handle).await.map_err(sftp_plain)?;893
let _ = std::fs::remove_file(local);894
let file = LocalFile::open(local, "wx").map_err(io_plain)?;895
let produce: Producer = {896
let (sftp, handle) = (sftp.clone(), handle.clone());897
Arc::new(move |pos, len| {898
let (sftp, handle) = (sftp.clone(), handle.clone());899
Box::pin(async move {900
let data = sftp.read_full(&handle, pos, len).await.map_err(sftp_plain)?;901
if data.len() != len {902
return Err(file_error("FILE_SOURCE_CHANGED", "The remote file shrank during the transfer."));903
}904
Ok(data)905
})906
})907
};908
let consume: Consumer = {909
let file = file.clone();910
Arc::new(move |buf, pos| {911
let file = file.clone();912
Box::pin(async move { file.write_at(pos, buf).await.map_err(io_plain) })913
})914
};915
let (_keep, stop) = tokio::sync::watch::channel(String::new());916
let result = pump(0, stat.size, produce, consume, stop, |_| {}).await.map_err(pump_error);917
let _ = sftp.close_file(&handle).await;918
result?;919
file.finish(stat.size).await.map_err(io_plain)920
}SHA-256
dae8f3cb8c2d4a0e3311c2d19e5fe39a59197deb07fd89d3a3d6b1112bfa8de4CH-J Proprietary Software License 1.14 · Lähde ilmoitetaan CH-J Proprietary Software License 1.14-koodilla. Sen saatavuus ei muuta lisenssiehtoja eikä myönnä lisäoikeuksia.
Arkisto SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f