공개된 소스
소스 ZIP 다운로드
CH-J Tools
특정 애플리케이션 릴리스의 디렉터리와 파일을 살펴보세요.
CH-J / Server Manager
1704 개 파일
0.1.2-beta
추가됨: · 빌드됨:
1
//! Local disk for the File Manager panels. The UI never gets a generic file API: only listing and2
//! the few operations below, on absolute paths, never inside the application's data directory3
//! (vault, logs) and never following a symbolic link when deleting.5
use super::service::{file_error, safe_entry_name};6
use crate::core::error::CoreResult;7
use chrono::{DateTime, Utc};8
use serde_json::{json, Value};9
use std::path::{Component, Path, PathBuf};11
/// Absolute, without `.` and `..`, outside `protected`.12
pub fn checked_path(value: &str, protected: &Path) -> CoreResult<PathBuf> {13
let path = PathBuf::from(value);14
if !path.is_absolute() || path.components().any(|c| matches!(c, Component::ParentDir | Component::CurDir)) {15
return Err(file_error("FILE_UNSAFE_PATH", "The local path must be absolute."));16
}17
// A symlink can point into the data directory, so the real path of the nearest existing parent counts too.18
let protected_real = std::fs::canonicalize(protected).unwrap_or_else(|_| protected.to_path_buf());19
let mut existing = path.as_path();20
let real = loop {21
match std::fs::canonicalize(existing) {22
Ok(real) => break real,23
Err(_) => match existing.parent() {24
Some(parent) => existing = parent,25
None => break path.clone(),26
},27
}28
};29
if path.starts_with(protected) || real.starts_with(&protected_real) {30
return Err(file_error("FILE_PROTECTED_PATH", "The application's data directory cannot be used here."));31
}32
Ok(path)33
}35
fn io_error(error: std::io::Error, path: &Path) -> crate::core::error::CoreError {36
let code = match error.kind() {37
std::io::ErrorKind::NotFound => "FILE_NOT_FOUND",38
std::io::ErrorKind::PermissionDenied => "LOCAL_PERMISSION_DENIED",39
std::io::ErrorKind::AlreadyExists => "FILE_EXISTS",40
std::io::ErrorKind::DirectoryNotEmpty => "FILE_DIRECTORY_NOT_EMPTY",41
_ => "LOCAL_OPERATION_FAILED",42
};43
file_error(code, &format!("{}: {error}", path.display()))44
}46
/// Home directory and, on Windows, the drive roots.47
pub fn places() -> Value {48
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/"));49
let mut roots: Vec<String> = Vec::new();50
if cfg!(windows) {51
for letter in b'A'..=b'Z' {52
let root = format!("{}:\\", letter as char);53
if Path::new(&root).exists() {54
roots.push(root);55
}56
}57
} else {58
roots.push("/".into());59
}60
json!({ "home": home.to_string_lossy(), "roots": roots, "separator": std::path::MAIN_SEPARATOR.to_string() })61
}63
pub fn list(value: &str, protected: &Path) -> CoreResult<Value> {64
let directory = checked_path(value, protected)?;65
let mut entries = Vec::new();66
for entry in std::fs::read_dir(&directory).map_err(|e| io_error(e, &directory))? {67
let Ok(entry) = entry else { continue };68
let path = entry.path();69
let Ok(meta) = std::fs::symlink_metadata(&path) else { continue };70
let link = meta.file_type().is_symlink();71
let target = if link { std::fs::metadata(&path).ok() } else { None };72
let kind = if link { "symlink" } else if meta.is_dir() { "directory" } else if meta.is_file() { "file" } else { "other" };73
entries.push(json!({74
"name": entry.file_name().to_string_lossy(),75
"path": path.to_string_lossy(),76
"type": kind,77
"targetType": if !link { Value::Null } else { match &target { Some(t) if t.is_dir() => json!("directory"), Some(_) => json!("file"), None => json!("missing") } },78
"size": meta.len(),79
"modifiedAt": meta.modified().ok().map(|m| DateTime::<Utc>::from(m).to_rfc3339())80
}));81
}82
Ok(Value::Array(entries))83
}85
pub fn mkdir(parent: &str, name: &str, protected: &Path) -> CoreResult<Value> {86
let path = checked_path(parent, protected)?.join(safe_entry_name(name)?);87
std::fs::create_dir(&path).map_err(|e| io_error(e, &path))?;88
Ok(json!({ "path": path.to_string_lossy() }))89
}91
/// Never replaces anything: an existing name is an error.92
pub fn create_file(parent: &str, name: &str, protected: &Path) -> CoreResult<Value> {93
let path = checked_path(parent, protected)?.join(safe_entry_name(name)?);94
std::fs::OpenOptions::new().write(true).create_new(true).open(&path).map_err(|e| io_error(e, &path))?;95
Ok(json!({ "path": path.to_string_lossy() }))96
}98
pub fn rename(from: &str, name: &str, protected: &Path) -> CoreResult<Value> {99
let source = checked_path(from, protected)?;100
let parent = source.parent().ok_or_else(|| file_error("FILE_PROTECTED_PATH", "A root cannot be renamed."))?;101
let target = parent.join(safe_entry_name(name)?);102
if std::fs::symlink_metadata(&target).is_ok() {103
return Err(file_error("FILE_EXISTS", &format!("{} already exists.", target.display())));104
}105
std::fs::rename(&source, &target).map_err(|e| io_error(e, &source))?;106
Ok(json!({ "path": target.to_string_lossy() }))107
}109
/// Roots, the home directory and its ancestors are refused. A link is removed, not what it points to.110
pub fn remove(paths: &[String], protected: &Path) -> CoreResult<Value> {111
let home = places()["home"].as_str().map(PathBuf::from);112
let mut checked = Vec::new();113
for value in paths {114
let path = checked_path(value, protected)?;115
if path.parent().is_none() || home.as_ref().is_some_and(|h| h.starts_with(&path)) || protected.starts_with(&path) {116
return Err(file_error("FILE_PROTECTED_PATH", &format!("{} cannot be deleted.", path.display())));117
}118
checked.push(path);119
}120
for path in &checked {121
let meta = std::fs::symlink_metadata(path).map_err(|e| io_error(e, path))?;122
if meta.is_dir() { std::fs::remove_dir_all(path) } else { std::fs::remove_file(path) }.map_err(|e| io_error(e, path))?;123
}124
Ok(json!({ "removed": checked.len() }))125
}127
#[cfg(test)]128
mod tests {129
use super::*;131
#[test]132
fn paths_are_absolute_and_stay_out_of_the_data_directory() {133
let dir = tempfile::tempdir().unwrap();134
let data = dir.path().join("data");135
std::fs::create_dir(&data).unwrap();136
std::fs::write(data.join("vault"), "secret").unwrap();137
assert_eq!(checked_path("relative/file", &data).unwrap_err().code, "FILE_UNSAFE_PATH");138
let dotted = format!("{}/x/../data", dir.path().display());139
assert_eq!(checked_path(&dotted, &data).unwrap_err().code, "FILE_UNSAFE_PATH");140
assert_eq!(checked_path(data.join("vault").to_str().unwrap(), &data).unwrap_err().code, "FILE_PROTECTED_PATH");141
#[cfg(unix)]142
{143
let link = dir.path().join("link");144
std::os::unix::fs::symlink(&data, &link).unwrap();145
assert_eq!(checked_path(link.join("vault").to_str().unwrap(), &data).unwrap_err().code, "FILE_PROTECTED_PATH", "a link into the data directory");146
assert_eq!(list(link.to_str().unwrap(), &data).unwrap_err().code, "FILE_PROTECTED_PATH");147
}148
}150
#[test]151
fn names_cannot_leave_their_folder() {152
let dir = tempfile::tempdir().unwrap();153
let (base, data) = (dir.path().join("work"), dir.path().join("data"));154
std::fs::create_dir_all(&base).unwrap();155
for bad in ["../evil", "a/b", "..", ""] {156
assert!(mkdir(base.to_str().unwrap(), bad, &data).is_err(), "{bad}");157
}158
mkdir(base.to_str().unwrap(), "ok", &data).unwrap();159
for good in ["a.txt", "b.htm", "c.conf", "bez-pripony", ".skryty"] {160
create_file(base.to_str().unwrap(), good, &data).unwrap();161
}162
assert_eq!(create_file(base.to_str().unwrap(), "a.txt", &data).unwrap_err().code, "FILE_EXISTS", "an existing file stays");163
assert!(create_file(base.to_str().unwrap(), "../evil.txt", &data).is_err());164
for good in ["a.txt", "b.htm", "c.conf", "bez-pripony", ".skryty"] {165
std::fs::remove_file(base.join(good)).unwrap();166
}167
std::fs::write(base.join("file"), "x").unwrap();168
assert!(rename(base.join("file").to_str().unwrap(), "../evil", &data).is_err());169
assert_eq!(rename(base.join("file").to_str().unwrap(), "ok", &data).unwrap_err().code, "FILE_EXISTS");170
rename(base.join("file").to_str().unwrap(), "renamed", &data).unwrap();171
let names: Vec<String> = list(base.to_str().unwrap(), &data).unwrap().as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap().to_string()).collect();172
assert!(names.contains(&"renamed".to_string()) && names.contains(&"ok".to_string()));173
}175
#[cfg(unix)]176
#[test]177
fn delete_removes_a_link_not_its_target_and_refuses_home_and_roots() {178
let dir = tempfile::tempdir().unwrap();179
let (kept, doomed, data) = (dir.path().join("kept"), dir.path().join("doomed"), dir.path().join("data"));180
std::fs::create_dir_all(&kept).unwrap();181
std::fs::create_dir_all(&doomed).unwrap();182
std::fs::write(kept.join("file"), "x").unwrap();183
std::os::unix::fs::symlink(&kept, doomed.join("link")).unwrap();184
remove(&[doomed.to_string_lossy().to_string()], &data).unwrap();185
assert!(!doomed.exists());186
assert!(kept.join("file").exists(), "the link's target survives");187
assert_eq!(remove(&["/".to_string()], &data).unwrap_err().code, "FILE_PROTECTED_PATH");188
let home = places()["home"].as_str().unwrap().to_string();189
assert_eq!(remove(&[home], &data).unwrap_err().code, "FILE_PROTECTED_PATH");190
}191
}SHA-256
d35065485c0fc1f73d8db45350b391594b41f668caa4752a6bef725e6cff837fCH-J Proprietary Software License 1.14 · 소스는 CH-J Proprietary Software License 1.14에 따라 제공됩니다. 소스를 공개한다고 해서 라이선스 조항이 바뀌거나 추가 권리가 부여되지는 않습니다.
압축 파일 SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f