CH-J ToolsPalvelimen hallinta yli SSH
Valikko
Julkaistu lähde

CH-J Tools

Selaile hakemistoja ja tiedostoja tietyn sovelluksen julkaisua varten.

Lataa lähde ZIP
CH-J / Server Manager 1704 tiedostot
0.1.2-beta Lisätty: · Rakennettu:
service.rs Lisätty: Kirjaamaton päivä 44,7 KB · 920 rivit
Koodi Lataa tiedosto
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 fixed
4//! privileged helper. The text editor keeps a baseline per opened document (conflict
5//! detection) and saves through prepare → upload temp copy → finalize → cleanup with
6//! recovery copies, exactly like 0.1.0.
8use super::editor::{remote_editor, Authorization};
9use super::pipeline::{pump, Consumer, LocalFile, Producer, PumpStop};
10use super::posix;
11use crate::core::error::{CoreError, CoreResult};
12use crate::core::logging::Logger;
13use crate::core::ssh::privileged::PrivilegedExecutor;
14use crate::core::ssh::session::SessionManager;
15use crate::core::ssh::sftp::{FileHandle, Sftp, SftpError, Stat};
16use parking_lot::Mutex;
17use rand::RngCore;
18use serde_json::{json, Value};
19use sha2::{Digest, Sha256};
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23use std::time::Duration;
25pub const MAX_TEXT_BYTES: usize = 25 * 1024 * 1024;
26pub const MAX_TRANSFER_BYTES: u64 = 16 * 1024 * 1024 * 1024;
27pub const MAX_BATCH_ENTRIES: u64 = 10_000;
28pub const MAX_SELECTED_ENTRIES: usize = 100;
29const 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.
38pub 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];
43pub fn file_error(code: &str, message: &str) -> CoreError {
44 CoreError::new(code, format!("{code}: {message}"))
47fn random_hex(bytes: usize) -> String {
48 let mut buffer = vec![0u8; bytes];
49 rand::rngs::OsRng.fill_bytes(&mut buffer);
50 hex::encode(buffer)
53pub 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))
62pub 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())
69/// `remotePath || "/"`.
70pub 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 }
79fn 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)
86fn 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 });
100#[derive(Debug, Clone)]
101struct 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,
112pub 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>>>,
120#[derive(Default)]
121struct BatchState {
122 entries: u64,
123 bytes: u64,
126impl 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 }
140fn sftp_plain(error: SftpError) -> CoreError {
141 error.file_error("")
144fn io_plain(error: std::io::Error) -> CoreError {
145 CoreError::plain(error.to_string())
148impl 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).await
155 }
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).await
160 }
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> = entries
172 .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.mode
182 })
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": true
230 });
231 if !entry["targetType"].is_null() {
232 out["targetType"] = entry["targetType"].clone();
233 }
234 out
235 })
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": modified
327 }))
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.dispose
409 }
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)).await
460 }
462 pub async fn list_recovery(&self, session_id: &str, options: &Value) -> CoreResult<Value> {
463 self.recovery(session_id, "list", None, options).await
464 }
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).await
484 }
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 result
749 }
752async 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 }
772async 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()))
807async 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))
851fn pump_error(stop: PumpStop) -> CoreError {
852 match stop {
853 PumpStop::Failed(error) => error,
854 PumpStop::Stopped(kind) => CoreError::plain(format!("Transfer {kind}")),
855 }
858/// ssh2 `fastPut` equivalent (pipelined).
859pub 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)
889/// ssh2 `fastGet` equivalent (pipelined).
890pub 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)
SHA-256dae8f3cb8c2d4a0e3311c2d19e5fe39a59197deb07fd89d3a3d6b1112bfa8de4

CH-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-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Tiedostot