| 1 | use crate::*; |
| 2 | use std::{ |
| 3 | collections::HashSet, |
| 4 | io::Read, |
| 5 | os::unix::fs::MetadataExt, |
| 6 | path::{Component, Path}, |
| 7 | }; |
| 8 | |
| 9 | pub fn relative(rel: &str, item: bool) -> Result<String> { |
| 10 | if rel.len() > 4096 || rel.contains('\0') || Path::new(rel).is_absolute() { |
| 11 | return Err(Error::new( |
| 12 | 400, |
| 13 | "Paths start from the library, so they can't start with /.", |
| 14 | )); |
| 15 | } |
| 16 | let mut path = PathBuf::new(); |
| 17 | for component in Path::new(rel).components() { |
| 18 | match component { |
| 19 | Component::CurDir => (), |
| 20 | Component::ParentDir => { |
| 21 | if path.file_name().is_some_and(|s| s != "..") { |
| 22 | path.pop(); |
| 23 | } else { |
| 24 | path.push(".."); |
| 25 | } |
| 26 | } |
| 27 | _ => path.push(component.as_os_str()), |
| 28 | } |
| 29 | } |
| 30 | let value = path.to_string_lossy().into_owned(); |
| 31 | if item && value.is_empty() { |
| 32 | return Err(Error::new( |
| 33 | 400, |
| 34 | "Choose something inside the folder, not the folder itself.", |
| 35 | )); |
| 36 | } |
| 37 | Ok(value) |
| 38 | } |
| 39 | fn name(value: &Value) -> Result<&str> { |
| 40 | value |
| 41 | .as_str() |
| 42 | .filter(|s| { |
| 43 | !s.is_empty() && s.len() <= 255 && !s.contains(['/', '\0']) && *s != "." && *s != ".." |
| 44 | }) |
| 45 | .ok_or_else(|| Error::new(400, "Names can't contain / or be . or ..")) |
| 46 | } |
| 47 | #[derive(Clone)] |
| 48 | struct Explorer { |
| 49 | root: PathBuf, |
| 50 | base: PathBuf, |
| 51 | scope: PathBuf, |
| 52 | } |
| 53 | impl Explorer { |
| 54 | async fn new(root: PathBuf) -> Result<Self> { |
| 55 | let store = PathBuf::from(env("STUDIO_STORE_ROOT", "/srv")); |
| 56 | let scope = root |
| 57 | .strip_prefix(&store) |
| 58 | .map_err(|_| Error::new(500, "Library root is outside the store."))? |
| 59 | .to_path_buf(); |
| 60 | let real = tokio::fs::canonicalize(store).await?; |
| 61 | Ok(Self { |
| 62 | root, |
| 63 | base: real.join(&scope), |
| 64 | scope, |
| 65 | }) |
| 66 | } |
| 67 | async fn resolve(&self, rel: &str) -> Result<PathBuf> { |
| 68 | let rel = relative(rel, false)?; |
| 69 | if rel == ".." || rel.starts_with("../") { |
| 70 | return Err(Error::new( |
| 71 | 403, |
| 72 | format!("That path is outside {}.", self.root.display()), |
| 73 | )); |
| 74 | } |
| 75 | let abs = self.base.join(&rel); |
| 76 | let mut ancestor = abs.clone(); |
| 77 | loop { |
| 78 | match tokio::fs::canonicalize(&ancestor).await { |
| 79 | Ok(real) => { |
| 80 | if !real.starts_with(&self.base) { |
| 81 | return Err(Error::new( |
| 82 | 403, |
| 83 | format!("That path is outside {}.", self.root.display()), |
| 84 | )); |
| 85 | } |
| 86 | return Ok(abs); |
| 87 | } |
| 88 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => { |
| 89 | if !ancestor.pop() { |
| 90 | return Err(e.into()); |
| 91 | } |
| 92 | } |
| 93 | Err(e) => return Err(e.into()), |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | fn store(&self, rel: &str) -> String { |
| 98 | self.scope.join(rel).to_string_lossy().into_owned() |
| 99 | } |
| 100 | fn local(&self, rel: &str) -> Result<String> { |
| 101 | Path::new(rel) |
| 102 | .strip_prefix(&self.scope) |
| 103 | .map(|p| p.to_string_lossy().into_owned()) |
| 104 | .map_err(|_| { |
| 105 | Error::new( |
| 106 | 403, |
| 107 | format!("That change touched files outside {}.", self.root.display()), |
| 108 | ) |
| 109 | }) |
| 110 | } |
| 111 | async fn entries(&self, rel: &str, mounts: &Value) -> Result<Vec<Value>> { |
| 112 | let mut dir = tokio::fs::read_dir(self.resolve(rel).await?) |
| 113 | .await |
| 114 | .map_err(file_error)?; |
| 115 | let mut found = Vec::new(); |
| 116 | while let Some(entry) = dir.next_entry().await? { |
| 117 | let name = entry.file_name().to_string_lossy().into_owned(); |
| 118 | let child = Path::new(rel).join(&name).to_string_lossy().into_owned(); |
| 119 | let Ok(abs) = self.resolve(&child).await else { |
| 120 | continue; |
| 121 | }; |
| 122 | let Ok(info) = tokio::fs::metadata(&abs).await else { |
| 123 | continue; |
| 124 | }; |
| 125 | let is_dir = info.is_dir(); |
| 126 | let items = if is_dir { |
| 127 | tokio::time::timeout(Duration::from_millis(200), async { |
| 128 | let mut dir = tokio::fs::read_dir(&abs).await?; |
| 129 | let mut count = 0; |
| 130 | while dir.next_entry().await?.is_some() { |
| 131 | count += 1; |
| 132 | } |
| 133 | Ok::<_, std::io::Error>(count) |
| 134 | }) |
| 135 | .await |
| 136 | .ok() |
| 137 | .and_then(std::result::Result::ok) |
| 138 | } else { |
| 139 | None |
| 140 | }; |
| 141 | let dataset = if is_dir { |
| 142 | array(mounts) |
| 143 | .iter() |
| 144 | .find(|m| m["target"] == abs.to_string_lossy().as_ref()) |
| 145 | .map(|m| m["source"].clone()) |
| 146 | } else { |
| 147 | None |
| 148 | }; |
| 149 | found.push(json!({"name":name,"dir":is_dir,"size":if is_dir {None}else{Some(info.len())},"alloc":if is_dir {None}else{Some(info.blocks()*512)},"items":items,"modified":info.mtime(),"inode":info.ino(),"dataset":dataset,"download":apps::file_link(&self.root.join(child).to_string_lossy(),is_dir)})); |
| 150 | } |
| 151 | Ok(found) |
| 152 | } |
| 153 | async fn not_dataset(&self, rels: &[String], mounts: &Value) -> Result<()> { |
| 154 | for rel in rels { |
| 155 | let abs = self.resolve(rel).await?; |
| 156 | if let Some(mount) = array(mounts) |
| 157 | .iter() |
| 158 | .find(|m| Path::new(string(&m["target"])).starts_with(&abs)) |
| 159 | { |
| 160 | return Err(Error::new( |
| 161 | 409, |
| 162 | if mount["target"] == abs.to_string_lossy().as_ref() { |
| 163 | format!( |
| 164 | "{rel} is the {} dataset. Rename or destroy it with zfs instead.", |
| 165 | string(&mount["source"]) |
| 166 | ) |
| 167 | } else { |
| 168 | format!( |
| 169 | "{rel} holds the {} dataset. Move that dataset out with zfs first.", |
| 170 | string(&mount["source"]) |
| 171 | ) |
| 172 | }, |
| 173 | )); |
| 174 | } |
| 175 | } |
| 176 | Ok(()) |
| 177 | } |
| 178 | async fn check_moves(&self, pairs: &[(String, String)], mounts: &Value) -> Result<()> { |
| 179 | self.not_dataset( |
| 180 | &pairs |
| 181 | .iter() |
| 182 | .map(|(from, _)| from.clone()) |
| 183 | .collect::<Vec<_>>(), |
| 184 | mounts, |
| 185 | ) |
| 186 | .await?; |
| 187 | let mut sources = HashSet::new(); |
| 188 | let mut targets = HashSet::new(); |
| 189 | for (from, to) in pairs { |
| 190 | if to.starts_with(&format!("{from}/")) { |
| 191 | return Err(Error::new(400, format!("Can't move {from} into itself."))); |
| 192 | } |
| 193 | if !sources.insert(from) { |
| 194 | return Err(Error::new( |
| 195 | 400, |
| 196 | format!("{from} is listed twice. Pick one place for it."), |
| 197 | )); |
| 198 | } |
| 199 | if !targets.insert(to) { |
| 200 | return Err(Error::new( |
| 201 | 409, |
| 202 | format!("More than one item would land at {to}. Rename one first."), |
| 203 | )); |
| 204 | } |
| 205 | let a = self.resolve(from).await?; |
| 206 | let b = self.resolve(to).await?; |
| 207 | if !tokio::fs::try_exists(&a).await? { |
| 208 | return Err(Error::new( |
| 209 | 409, |
| 210 | format!("{from} isn't there anymore. Reload the folder."), |
| 211 | )); |
| 212 | } |
| 213 | if tokio::fs::try_exists(&b).await? { |
| 214 | return Err(Error::new( |
| 215 | 409, |
| 216 | format!("{to} already exists. Move or rename it first."), |
| 217 | )); |
| 218 | } |
| 219 | let source = holder(mounts, &a); |
| 220 | let target = holder(mounts, b.parent().unwrap()); |
| 221 | if source.map(|m| &m["source"]) != target.map(|m| &m["source"]) { |
| 222 | return Err(Error::new( |
| 223 | 409, |
| 224 | format!( |
| 225 | "{from} is on {} and {} is on {}. Moves can't cross datasets, so copy it from a shell instead.", |
| 226 | source.map(|m| string(&m["source"])).unwrap_or("no dataset"), |
| 227 | b.parent() |
| 228 | .and_then(Path::file_name) |
| 229 | .unwrap_or_default() |
| 230 | .to_string_lossy(), |
| 231 | target.map(|m| string(&m["source"])).unwrap_or("no dataset") |
| 232 | ), |
| 233 | )); |
| 234 | } |
| 235 | } |
| 236 | Ok(()) |
| 237 | } |
| 238 | async fn move_one(&self, from: &str, to: &str) -> Result<Option<String>> { |
| 239 | let target = self.resolve(to).await?; |
| 240 | let mut made = target.parent().unwrap().to_path_buf(); |
| 241 | let mut first = None; |
| 242 | while !tokio::fs::try_exists(&made).await? { |
| 243 | first = Some(made.clone()); |
| 244 | made.pop(); |
| 245 | } |
| 246 | tokio::fs::create_dir_all(target.parent().unwrap()).await?; |
| 247 | if let Err(error) = tokio::fs::rename(self.resolve(from).await?, &target).await { |
| 248 | if let Some(first) = &first { |
| 249 | remove_empty(first.clone()).await?; |
| 250 | } |
| 251 | return Err(file_error(error)); |
| 252 | } |
| 253 | Ok(first.map(|p| self.store(&p.strip_prefix(&self.base).unwrap().to_string_lossy()))) |
| 254 | } |
| 255 | async fn folder(&self, rel: &str) -> Result<()> { |
| 256 | let abs = self.resolve(rel).await?; |
| 257 | if !tokio::fs::metadata(abs).await.is_ok_and(|m| m.is_dir()) { |
| 258 | return Err(Error::new( |
| 259 | 400, |
| 260 | format!( |
| 261 | "{} isn't a folder. Create it first, or pick an existing folder.", |
| 262 | self.root.join(rel).display() |
| 263 | ), |
| 264 | )); |
| 265 | } |
| 266 | Ok(()) |
| 267 | } |
| 268 | async fn matches(&self, folder: &str, pattern: &str, app: Arc<App>) -> Result<Vec<String>> { |
| 269 | if pattern.is_empty() |
| 270 | || pattern.len() > 1024 |
| 271 | || pattern.starts_with('/') |
| 272 | || pattern.split('/').any(|s| s == "..") |
| 273 | { |
| 274 | return Err(Error::new(400, "Patterns can't start with / or contain ..")); |
| 275 | } |
| 276 | let base = self.resolve(folder).await?; |
| 277 | let pattern = pattern.to_owned(); |
| 278 | let _slot = app.heavy.acquire().await?; |
| 279 | let paths = tokio::task::spawn_blocking(move || { |
| 280 | let matcher = globset::GlobBuilder::new(&pattern) |
| 281 | .literal_separator(true) |
| 282 | .build() |
| 283 | .map_err(|e| Error::new(400, e.to_string()))? |
| 284 | .compile_matcher(); |
| 285 | Ok::<_, Error>( |
| 286 | walkdir::WalkDir::new(&base) |
| 287 | .min_depth(1) |
| 288 | .follow_links(false) |
| 289 | .into_iter() |
| 290 | .filter_map(|e| e.ok()) |
| 291 | .filter_map(|e| { |
| 292 | let rel = e.path().strip_prefix(&base).ok()?; |
| 293 | matcher |
| 294 | .is_match(rel) |
| 295 | .then(|| rel.to_string_lossy().into_owned()) |
| 296 | }) |
| 297 | .collect::<Vec<_>>(), |
| 298 | ) |
| 299 | }) |
| 300 | .await??; |
| 301 | let mut paths = paths |
| 302 | .into_iter() |
| 303 | .map(|p| Path::new(folder).join(p).to_string_lossy().into_owned()) |
| 304 | .collect::<Vec<_>>(); |
| 305 | paths.sort(); |
| 306 | let mut valid = Vec::new(); |
| 307 | for path in paths { |
| 308 | if self.resolve(&path).await.is_ok() |
| 309 | && !valid |
| 310 | .iter() |
| 311 | .any(|p: &String| path.starts_with(&format!("{p}/"))) |
| 312 | { |
| 313 | valid.push(path); |
| 314 | } |
| 315 | } |
| 316 | Ok(valid) |
| 317 | } |
| 318 | } |
| 319 | fn file_error(error: std::io::Error) -> Error { |
| 320 | match error.kind() { |
| 321 | std::io::ErrorKind::NotFound => Error::new( |
| 322 | 404, |
| 323 | "That file or folder no longer exists. Reload the folder.", |
| 324 | ), |
| 325 | std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::DirectoryNotEmpty => { |
| 326 | Error::new(409, "Something with that name already exists.") |
| 327 | } |
| 328 | _ if error.raw_os_error() == Some(18) => Error::new( |
| 329 | 409, |
| 330 | "Moves can't cross ZFS datasets. Copy it from a shell instead.", |
| 331 | ), |
| 332 | _ => error.into(), |
| 333 | } |
| 334 | } |
| 335 | async fn mounts() -> Result<Value> { |
| 336 | let value = host::call(json!({"operation":"storage.mounts"})).await?; |
| 337 | let mut values = array(&value["filesystems"]).to_vec(); |
| 338 | values.sort_by_key(|m| std::cmp::Reverse(string(&m["target"]).len())); |
| 339 | Ok(json!(values)) |
| 340 | } |
| 341 | fn holder<'a>(mounts: &'a Value, abs: &Path) -> Option<&'a Value> { |
| 342 | array(mounts) |
| 343 | .iter() |
| 344 | .find(|m| abs.starts_with(string(&m["target"]))) |
| 345 | } |
| 346 | fn selection(value: &Value) -> Result<Vec<String>> { |
| 347 | if !value.is_array() || array(value).is_empty() || array(value).len() > 10000 { |
| 348 | return Err(Error::new(400, "Choose files or folders.")); |
| 349 | } |
| 350 | let paths = array(value) |
| 351 | .iter() |
| 352 | .map(|p| { |
| 353 | p.as_str() |
| 354 | .ok_or_else(|| Error::new(400, "Invalid path.")) |
| 355 | .and_then(|p| relative(p, true)) |
| 356 | }) |
| 357 | .collect::<Result<Vec<_>>>()?; |
| 358 | let mut seen = HashSet::new(); |
| 359 | Ok(paths |
| 360 | .iter() |
| 361 | .filter(|p| { |
| 362 | seen.insert((*p).clone()) |
| 363 | && !Path::new(p) |
| 364 | .ancestors() |
| 365 | .skip(1) |
| 366 | .any(|a| paths.iter().any(|p| p == a.to_string_lossy().as_ref())) |
| 367 | }) |
| 368 | .cloned() |
| 369 | .collect()) |
| 370 | } |
| 371 | fn item_count(count: usize) -> String { |
| 372 | format!("{count} {}", if count == 1 { "item" } else { "items" }) |
| 373 | } |
| 374 | async fn remove_empty(abs: PathBuf) -> Result<bool> { |
| 375 | tokio::task::spawn_blocking(move || { |
| 376 | fn remove(abs: &Path) -> std::io::Result<bool> { |
| 377 | if !std::fs::symlink_metadata(abs).is_ok_and(|m| m.is_dir()) { |
| 378 | return Ok(false); |
| 379 | } |
| 380 | let mut empty = true; |
| 381 | for entry in std::fs::read_dir(abs)? { |
| 382 | if !remove(&entry?.path())? { |
| 383 | empty = false; |
| 384 | } |
| 385 | } |
| 386 | if empty { |
| 387 | std::fs::remove_dir(abs)?; |
| 388 | } |
| 389 | Ok(empty) |
| 390 | } |
| 391 | Ok::<_, Error>(remove(&abs)?) |
| 392 | }) |
| 393 | .await? |
| 394 | } |
| 395 | fn walk_total(abs: &Path, budget: &mut usize) -> Option<Value> { |
| 396 | if *budget == 0 { |
| 397 | return None; |
| 398 | } |
| 399 | *budget -= 1; |
| 400 | let info = std::fs::symlink_metadata(abs).ok(); |
| 401 | let Some(info) = info else { |
| 402 | return Some(json!({"size":0,"alloc":0,"files":0})); |
| 403 | }; |
| 404 | if !info.is_dir() { |
| 405 | return Some(json!({"size":info.len(),"alloc":info.blocks()*512,"files":1})); |
| 406 | } |
| 407 | let mut total = json!({"size":0,"alloc":0,"files":0}); |
| 408 | if let Ok(dir) = std::fs::read_dir(abs) { |
| 409 | for entry in dir.flatten() { |
| 410 | let child = walk_total(&entry.path(), budget)?; |
| 411 | for key in ["size", "alloc", "files"] { |
| 412 | total[key] = json!(number(&total[key]) + number(&child[key])); |
| 413 | } |
| 414 | } |
| 415 | } |
| 416 | Some(total) |
| 417 | } |
| 418 | async fn tree(app: Arc<App>, explorer: &Explorer, body: &Value, mounts: &Value) -> Result<Value> { |
| 419 | let top = relative(string(&body["path"]), false)?; |
| 420 | let mut folders = serde_json::Map::new(); |
| 421 | let first = explorer.entries(&top, mounts).await?; |
| 422 | let mut listed = first.len(); |
| 423 | folders.insert(top.clone(), json!(first)); |
| 424 | let mut complete = true; |
| 425 | if body["expanded"] == "all" { |
| 426 | let mut level = vec![top.clone()]; |
| 427 | while !level.is_empty() { |
| 428 | let next: Vec<_> = level |
| 429 | .iter() |
| 430 | .flat_map(|rel| { |
| 431 | array(&folders[rel]) |
| 432 | .iter() |
| 433 | .filter(|e| e["dir"] == true) |
| 434 | .map(|e| { |
| 435 | ( |
| 436 | Path::new(rel) |
| 437 | .join(string(&e["name"])) |
| 438 | .to_string_lossy() |
| 439 | .into_owned(), |
| 440 | e["items"].as_u64().map(|n| n as usize), |
| 441 | ) |
| 442 | }) |
| 443 | }) |
| 444 | .collect(); |
| 445 | let Some(total) = next |
| 446 | .iter() |
| 447 | .try_fold(listed, |total, (_, count)| count.map(|n| total + n)) |
| 448 | else { |
| 449 | complete = false; |
| 450 | break; |
| 451 | }; |
| 452 | listed = total; |
| 453 | if listed > 3000 { |
| 454 | complete = false; |
| 455 | break; |
| 456 | } |
| 457 | level.clear(); |
| 458 | for (rel, _) in next { |
| 459 | let entries = explorer.entries(&rel, mounts).await?; |
| 460 | folders.insert(rel.clone(), json!(entries)); |
| 461 | level.push(rel); |
| 462 | } |
| 463 | } |
| 464 | } else { |
| 465 | if !body["expanded"].is_array() || array(&body["expanded"]).len() > 3000 { |
| 466 | return Err(Error::new(400, "Choose expanded folders.")); |
| 467 | } |
| 468 | for rel in array(&body["expanded"]) { |
| 469 | let rel = relative(string(rel), true)?; |
| 470 | if (!top.is_empty() && !rel.starts_with(&format!("{top}/"))) |
| 471 | || folders.contains_key(&rel) |
| 472 | { |
| 473 | continue; |
| 474 | } |
| 475 | if let Ok(entries) = explorer.entries(&rel, mounts).await { |
| 476 | folders.insert(rel, json!(entries)); |
| 477 | } |
| 478 | } |
| 479 | } |
| 480 | let index = app.index.clone(); |
| 481 | let explorer = explorer.clone(); |
| 482 | let _slot = app.heavy.acquire().await?; |
| 483 | tokio::task::spawn_blocking(move || { |
| 484 | fn measure( |
| 485 | rel: &str, |
| 486 | explorer: &Explorer, |
| 487 | index: Option<&crate::index::Index>, |
| 488 | folders: &serde_json::Map<String, Value>, |
| 489 | sizes: &mut serde_json::Map<String, Value>, |
| 490 | budget: &mut usize, |
| 491 | from_index: &mut bool, |
| 492 | ) -> Result<Option<Value>> { |
| 493 | if let Some(entries) = folders.get(rel) { |
| 494 | let mut total = json!({"size":0,"alloc":0,"files":0}); |
| 495 | let mut known = true; |
| 496 | for entry in array(entries) { |
| 497 | let child = if entry["dir"] == true { |
| 498 | let rel = Path::new(rel) |
| 499 | .join(string(&entry["name"])) |
| 500 | .to_string_lossy() |
| 501 | .into_owned(); |
| 502 | let value = |
| 503 | measure(&rel, explorer, index, folders, sizes, budget, from_index)?; |
| 504 | if let Some(value) = &value { |
| 505 | sizes.insert(rel, value.clone()); |
| 506 | } |
| 507 | value |
| 508 | } else { |
| 509 | Some(json!({"size":entry["size"],"alloc":entry["alloc"],"files":1})) |
| 510 | }; |
| 511 | if let Some(child) = child { |
| 512 | for key in ["size", "alloc", "files"] { |
| 513 | total[key] = json!(number(&total[key]) + number(&child[key])); |
| 514 | } |
| 515 | } else { |
| 516 | known = false; |
| 517 | } |
| 518 | } |
| 519 | return Ok(known.then_some(total)); |
| 520 | } |
| 521 | if let Some(index) = index |
| 522 | && index.updated()?.is_some() |
| 523 | && let Some(total) = |
| 524 | index.children(&explorer.scope.join(rel).to_string_lossy(), 0)? |
| 525 | { |
| 526 | *from_index = true; |
| 527 | return Ok(Some( |
| 528 | json!({"size":total["size"],"alloc":total["alloc"],"files":total["files"]}), |
| 529 | )); |
| 530 | } |
| 531 | Ok(walk_total(&explorer.base.join(rel), budget)) |
| 532 | } |
| 533 | let mut budget = 200000; |
| 534 | let mut sizes = serde_json::Map::new(); |
| 535 | let mut from_index = false; |
| 536 | if let Some(total) = measure( |
| 537 | &top, |
| 538 | &explorer, |
| 539 | index.as_deref(), |
| 540 | &folders, |
| 541 | &mut sizes, |
| 542 | &mut budget, |
| 543 | &mut from_index, |
| 544 | )? { |
| 545 | sizes.insert(top, total); |
| 546 | } |
| 547 | let updated = if from_index { |
| 548 | index.as_ref().map(|i| i.updated()).transpose()?.flatten() |
| 549 | } else { |
| 550 | None |
| 551 | }; |
| 552 | Ok(json!({"folders":folders,"sizes":sizes,"complete":complete,"updated":updated})) |
| 553 | }) |
| 554 | .await? |
| 555 | } |
| 556 | |
| 557 | async fn journal(app: &App) -> Result<Value> { |
| 558 | core::read_json(&app.data.join("file-ops.json"), json!([])).await |
| 559 | } |
| 560 | fn mine(explorer: &Explorer, op: &Value) -> bool { |
| 561 | array(&op["done"]).iter().all(|step| { |
| 562 | if step.is_array() { |
| 563 | array(step) |
| 564 | .iter() |
| 565 | .all(|p| explorer.local(string(p)).is_ok()) |
| 566 | } else { |
| 567 | explorer.local(string(step)).is_ok() |
| 568 | } |
| 569 | }) |
| 570 | } |
| 571 | async fn prune(ops: &Value) -> Result<()> { |
| 572 | let keep: HashSet<_> = array(ops) |
| 573 | .iter() |
| 574 | .filter(|op| op["kind"] == "delete" && op["undone"] == false) |
| 575 | .map(|op| string(&op["snapshot"])) |
| 576 | .collect(); |
| 577 | let mounts = mounts().await?; |
| 578 | let datasets: HashSet<_> = array(&mounts) |
| 579 | .iter() |
| 580 | .filter(|m| Path::new(string(&m["target"])).starts_with(env("STUDIO_STORE_ROOT", "/srv"))) |
| 581 | .map(|m| string(&m["source"])) |
| 582 | .collect(); |
| 583 | if datasets.is_empty() { |
| 584 | return Ok(()); |
| 585 | } |
| 586 | let listed = host::call(json!({"operation":"files.snapshots","datasets":datasets})).await?; |
| 587 | for snapshot in array(&listed).iter().map(string) { |
| 588 | if let Some((dataset, name)) = snapshot.split_once('@') |
| 589 | && !keep.contains(name) |
| 590 | { |
| 591 | host::call(json!({"operation":"files.discard","dataset":dataset,"snapshot":name})) |
| 592 | .await?; |
| 593 | } |
| 594 | } |
| 595 | Ok(()) |
| 596 | } |
| 597 | |
| 598 | pub async fn route( |
| 599 | app: Arc<App>, |
| 600 | media: bool, |
| 601 | method: &Method, |
| 602 | parts: &[&str], |
| 603 | query: &HashMap<String, String>, |
| 604 | body: Value, |
| 605 | ) -> Result<Response> { |
| 606 | let root = if media { |
| 607 | env( |
| 608 | "STUDIO_MEDIA_ROOT", |
| 609 | &format!("{}/clover/Media", env("STUDIO_STORE_ROOT", "/srv")), |
| 610 | ) |
| 611 | } else { |
| 612 | env("STUDIO_STORE_ROOT", "/srv") |
| 613 | }; |
| 614 | let explorer = Explorer::new(root.into()).await?; |
| 615 | if parts == ["refresh"] && media && method == Method::POST { |
| 616 | return refresh(app).await; |
| 617 | } |
| 618 | let value = match parts { |
| 619 | ["list"] if method == Method::GET => { |
| 620 | let rel = relative( |
| 621 | query.get("path").map(String::as_str).unwrap_or_default(), |
| 622 | false, |
| 623 | )?; |
| 624 | let key = format!("files:{}", explorer.root.join(&rel).display()); |
| 625 | let listing = app |
| 626 | .cache |
| 627 | .get(key, Duration::from_secs(2), move || async move { |
| 628 | let served = explorer.root.join(&rel); |
| 629 | let entries = explorer.entries(&rel, &mounts().await?).await?; |
| 630 | Ok(json!({"host":served,"link":apps::file_link(&served.to_string_lossy(),false),"zip":apps::file_link(&served.to_string_lossy(),true),"entries":entries})) |
| 631 | }) |
| 632 | .await?; |
| 633 | return Ok(listing.response()); |
| 634 | } |
| 635 | ["tree"] if method == Method::POST => tree(app, &explorer, &body, &mounts().await?).await?, |
| 636 | ["peek"] if method == Method::GET => { |
| 637 | let rel = relative( |
| 638 | query.get("path").map(String::as_str).unwrap_or_default(), |
| 639 | true, |
| 640 | )?; |
| 641 | let abs = explorer.resolve(&rel).await?; |
| 642 | let _slot = app.heavy.acquire().await?; |
| 643 | tokio::task::spawn_blocking(move || { |
| 644 | let file = std::fs::File::open(abs).map_err(file_error)?; |
| 645 | let size = file.metadata()?.len(); |
| 646 | let mut bytes = Vec::new(); |
| 647 | file.take(65536).read_to_end(&mut bytes)?; |
| 648 | if bytes.contains(&0) { |
| 649 | return Err(Error::new( |
| 650 | 415, |
| 651 | "This file isn't text. Open it in copyparty instead.", |
| 652 | )); |
| 653 | } |
| 654 | Ok::<_, Error>( |
| 655 | json!({"text":String::from_utf8_lossy(&bytes),"more":size>bytes.len() as u64}), |
| 656 | ) |
| 657 | }) |
| 658 | .await?? |
| 659 | } |
| 660 | ["match"] if method == Method::GET => { |
| 661 | let folder = relative( |
| 662 | query.get("path").map(String::as_str).unwrap_or_default(), |
| 663 | false, |
| 664 | )?; |
| 665 | let pattern = query |
| 666 | .get("pattern") |
| 667 | .ok_or_else(|| Error::new(400, "Enter a pattern."))?; |
| 668 | let found = explorer.matches(&folder, pattern, app.clone()).await?; |
| 669 | let paths: Vec<_> = found.iter().map(|p| explorer.base.join(p)).collect(); |
| 670 | let _slot = app.heavy.acquire().await?; |
| 671 | let size = tokio::task::spawn_blocking(move || { |
| 672 | let mut budget = 200000; |
| 673 | let mut size = Some(0.0); |
| 674 | for path in paths { |
| 675 | let total = walk_total(&path, &mut budget); |
| 676 | size = match (size, total) { |
| 677 | (Some(size), Some(total)) => Some(size + number(&total["size"])), |
| 678 | _ => None, |
| 679 | }; |
| 680 | } |
| 681 | size |
| 682 | }) |
| 683 | .await?; |
| 684 | let mut rows = serde_json::Map::new(); |
| 685 | for rel in &found { |
| 686 | let inside = Path::new(rel) |
| 687 | .strip_prefix(&folder) |
| 688 | .unwrap_or(Path::new(rel)); |
| 689 | rows.insert(inside.to_string_lossy().into_owned(), json!("self")); |
| 690 | for parent in inside |
| 691 | .ancestors() |
| 692 | .skip(1) |
| 693 | .filter(|p| !p.as_os_str().is_empty()) |
| 694 | { |
| 695 | let key = parent.to_string_lossy().into_owned(); |
| 696 | let count = rows.get(&key).map(number).unwrap_or(0.0) + 1.0; |
| 697 | rows.insert(key, json!(count)); |
| 698 | } |
| 699 | } |
| 700 | json!({"count":found.len(),"size":size,"sample":found.iter().take(100).map(|p| Path::new(p).strip_prefix(&folder).unwrap_or(Path::new(p)).to_string_lossy().into_owned()).collect::<Vec<_>>(),"rows":rows}) |
| 701 | } |
| 702 | ["ops"] if method == Method::GET => { |
| 703 | let ops = journal(&app).await?; |
| 704 | json!(array(&ops).iter().filter(|op| mine(&explorer,op)).map(|op| json!({"id":op["id"],"label":op["label"],"at":op["at"],"undone":op["undone"],"sample":array(&op["done"]).iter().take(3).map(|step| explorer.local(if step.is_array(){string(&step[0])}else{string(step)}).unwrap()).collect::<Vec<_>>(),"count":array(&op["done"]).len()})).collect::<Vec<_>>()) |
| 705 | } |
| 706 | _ if method == Method::POST => { |
| 707 | let result = change(app.clone(), &explorer, parts, body).await; |
| 708 | app.cache.invalidate_prefix("files:"); |
| 709 | return result; |
| 710 | } |
| 711 | _ => return Err(Error::new(404, "Not Found")), |
| 712 | }; |
| 713 | Ok(Document::new(value).response()) |
| 714 | } |
| 715 | |
| 716 | async fn change( |
| 717 | app: Arc<App>, |
| 718 | explorer: &Explorer, |
| 719 | parts: &[&str], |
| 720 | body: Value, |
| 721 | ) -> Result<Response> { |
| 722 | if env("STUDIO_FILES_WRITABLE", "") != "1" { |
| 723 | return Err(Error::new( |
| 724 | 501, |
| 725 | "The file browser can't change files yet. Use copyparty instead.", |
| 726 | )); |
| 727 | } |
| 728 | let _guard = app.file_changes.lock().await; |
| 729 | let mut ops = journal(&app).await?; |
| 730 | let mounts = mounts().await?; |
| 731 | if let ["ops", id, "undo"] = parts { |
| 732 | let position = array(&ops) |
| 733 | .iter() |
| 734 | .position(|op| op["id"] == *id && mine(explorer, op)) |
| 735 | .ok_or_else(|| Error::new(404, "That change is too old to undo."))?; |
| 736 | let op = ops[position].clone(); |
| 737 | if op["undone"] == true { |
| 738 | return Err(Error::new(409, "That change is already undone.")); |
| 739 | } |
| 740 | revert(explorer, &op, &mounts).await?; |
| 741 | ops[position]["undone"] = json!(true); |
| 742 | core::write_json(&app.data.join("file-ops.json"), &ops).await?; |
| 743 | if let Err(e) = prune(&ops).await { |
| 744 | eprintln!("snapshot prune: {}", e.message); |
| 745 | } |
| 746 | if let Some(index) = &app.index { |
| 747 | index.changed(); |
| 748 | } |
| 749 | return Ok(StatusCode::NO_CONTENT.into_response()); |
| 750 | } |
| 751 | let mut pairs = Vec::new(); |
| 752 | let mut paths = Vec::new(); |
| 753 | let mut folder = None; |
| 754 | let label; |
| 755 | match parts { |
| 756 | ["folder"] => { |
| 757 | let parent = relative(string(&body["path"]), false)?; |
| 758 | let name = name(&body["name"])?; |
| 759 | explorer.folder(&parent).await?; |
| 760 | folder = Some(Path::new(&parent).join(name).to_string_lossy().into_owned()); |
| 761 | label = format!("Created {name}"); |
| 762 | } |
| 763 | ["rename"] => { |
| 764 | if !body["renames"].is_array() |
| 765 | || array(&body["renames"]).is_empty() |
| 766 | || array(&body["renames"]).len() > 10000 |
| 767 | { |
| 768 | return Err(Error::new(400, "Choose names to change.")); |
| 769 | } |
| 770 | for rename in array(&body["renames"]) { |
| 771 | let from = relative(string(&rename["path"]), true)?; |
| 772 | let name = name(&rename["name"])?; |
| 773 | let to = Path::new(&from) |
| 774 | .parent() |
| 775 | .unwrap() |
| 776 | .join(name) |
| 777 | .to_string_lossy() |
| 778 | .into_owned(); |
| 779 | if from != to { |
| 780 | pairs.push((from, to)); |
| 781 | } |
| 782 | } |
| 783 | if pairs.is_empty() { |
| 784 | return Err(Error::new( |
| 785 | 400, |
| 786 | "Every name is unchanged. Edit a name, then rename.", |
| 787 | )); |
| 788 | } |
| 789 | label = if pairs.len() == 1 { |
| 790 | format!( |
| 791 | "Renamed {} to {}", |
| 792 | Path::new(&pairs[0].0) |
| 793 | .file_name() |
| 794 | .unwrap() |
| 795 | .to_string_lossy(), |
| 796 | Path::new(&pairs[0].1) |
| 797 | .file_name() |
| 798 | .unwrap() |
| 799 | .to_string_lossy() |
| 800 | ) |
| 801 | } else { |
| 802 | format!("Renamed {}", item_count(pairs.len())) |
| 803 | }; |
| 804 | } |
| 805 | ["move"] => { |
| 806 | let selected = selection(&body["paths"])?; |
| 807 | let to = relative(string(&body["to"]), false)?; |
| 808 | explorer.folder(&to).await?; |
| 809 | pairs = selected |
| 810 | .iter() |
| 811 | .map(|from| { |
| 812 | ( |
| 813 | from.clone(), |
| 814 | Path::new(&to) |
| 815 | .join(Path::new(from).file_name().unwrap()) |
| 816 | .to_string_lossy() |
| 817 | .into_owned(), |
| 818 | ) |
| 819 | }) |
| 820 | .collect(); |
| 821 | label = format!( |
| 822 | "Moved {} to {}", |
| 823 | item_count(selected.len()), |
| 824 | explorer |
| 825 | .root |
| 826 | .join(&to) |
| 827 | .file_name() |
| 828 | .unwrap_or_default() |
| 829 | .to_string_lossy() |
| 830 | ); |
| 831 | } |
| 832 | ["delete"] => { |
| 833 | paths = selection(&body["paths"])?; |
| 834 | label = if paths.len() == 1 { |
| 835 | format!( |
| 836 | "Deleted {}", |
| 837 | Path::new(&paths[0]).file_name().unwrap().to_string_lossy() |
| 838 | ) |
| 839 | } else { |
| 840 | format!("Deleted {}", item_count(paths.len())) |
| 841 | }; |
| 842 | } |
| 843 | ["bulk"] => { |
| 844 | let rel = relative(string(&body["path"]), false)?; |
| 845 | let pattern = string(&body["pattern"]); |
| 846 | let found = explorer.matches(&rel, pattern, app.clone()).await?; |
| 847 | let count = number(&body["count"]) as usize; |
| 848 | if count == 0 { |
| 849 | return Err(Error::new(400, "Choose at least one item.")); |
| 850 | } |
| 851 | if found.len() != count { |
| 852 | return Err(Error::new( |
| 853 | 409, |
| 854 | format!( |
| 855 | "The pattern matches {} now, not {count}. Go back and check the new matches.", |
| 856 | found.len() |
| 857 | ), |
| 858 | )); |
| 859 | } |
| 860 | if body["action"] == "delete" { |
| 861 | paths = found; |
| 862 | label = format!("Deleted {} matching {pattern}", item_count(count)); |
| 863 | } else if body["action"] == "move" { |
| 864 | let to = body["to"] |
| 865 | .as_str() |
| 866 | .ok_or_else(|| Error::new(400, "Choose a folder to move into."))?; |
| 867 | let to = relative(to, false)?; |
| 868 | explorer.folder(&to).await?; |
| 869 | pairs = found |
| 870 | .iter() |
| 871 | .map(|from| { |
| 872 | ( |
| 873 | from.clone(), |
| 874 | Path::new(&to) |
| 875 | .join(Path::new(from).strip_prefix(&rel).unwrap()) |
| 876 | .to_string_lossy() |
| 877 | .into_owned(), |
| 878 | ) |
| 879 | }) |
| 880 | .collect(); |
| 881 | label = format!( |
| 882 | "Moved {} matching {pattern} to {}", |
| 883 | item_count(count), |
| 884 | explorer |
| 885 | .root |
| 886 | .join(&to) |
| 887 | .file_name() |
| 888 | .unwrap_or_default() |
| 889 | .to_string_lossy() |
| 890 | ); |
| 891 | } else { |
| 892 | return Err(Error::new(400, "Choose move or delete.")); |
| 893 | } |
| 894 | } |
| 895 | _ => return Err(Error::new(404, "Not Found")), |
| 896 | } |
| 897 | if !pairs.is_empty() { |
| 898 | explorer.check_moves(&pairs, &mounts).await?; |
| 899 | } |
| 900 | let mut snapshot = None; |
| 901 | if !paths.is_empty() { |
| 902 | explorer.not_dataset(&paths, &mounts).await?; |
| 903 | let mut datasets = HashSet::new(); |
| 904 | for rel in &paths { |
| 905 | let abs = explorer.resolve(rel).await?; |
| 906 | tokio::fs::symlink_metadata(&abs) |
| 907 | .await |
| 908 | .map_err(file_error)?; |
| 909 | let source = holder(&mounts, &abs).ok_or_else(|| { |
| 910 | Error::new( |
| 911 | 409, |
| 912 | format!( |
| 913 | "{} isn't on a ZFS dataset, so deleting it couldn't be undone.", |
| 914 | abs.display() |
| 915 | ), |
| 916 | ) |
| 917 | })?; |
| 918 | if datasets.insert(string(&source["source"]).to_owned()) { |
| 919 | drop( |
| 920 | tokio::fs::read_dir(Path::new(string(&source["target"])).join(".zfs/snapshot")) |
| 921 | .await |
| 922 | .map_err(|_| { |
| 923 | Error::new( |
| 924 | 409, |
| 925 | format!( |
| 926 | "Can't delete {rel}. Its undo history isn't accessible here." |
| 927 | ), |
| 928 | ) |
| 929 | })?, |
| 930 | ); |
| 931 | } |
| 932 | } |
| 933 | let name = host::call(json!({"operation":"files.snapshot","datasets":datasets})).await?; |
| 934 | snapshot = Some(string(&name).to_owned()); |
| 935 | } |
| 936 | let id = uuid::Uuid::new_v4().to_string(); |
| 937 | let mut done = Vec::new(); |
| 938 | let mut created = Vec::new(); |
| 939 | let result = async { |
| 940 | if let Some(folder) = &folder { |
| 941 | tokio::fs::create_dir(explorer.resolve(folder).await?) |
| 942 | .await |
| 943 | .map_err(file_error)?; |
| 944 | done.push(json!(explorer.store(folder))); |
| 945 | } |
| 946 | for (from, to) in &pairs { |
| 947 | if let Some(made) = explorer.move_one(from, to).await? { |
| 948 | created.push(made); |
| 949 | } |
| 950 | done.push(json!([explorer.store(from), explorer.store(to)])); |
| 951 | } |
| 952 | for rel in &paths { |
| 953 | let abs = explorer.resolve(rel).await?; |
| 954 | let info = tokio::fs::symlink_metadata(&abs) |
| 955 | .await |
| 956 | .map_err(file_error)?; |
| 957 | if info.is_dir() { |
| 958 | tokio::fs::remove_dir_all(abs).await.map_err(file_error)?; |
| 959 | } else { |
| 960 | tokio::fs::remove_file(abs).await.map_err(file_error)?; |
| 961 | } |
| 962 | done.push(json!(explorer.store(rel))); |
| 963 | } |
| 964 | Ok::<_, Error>(()) |
| 965 | } |
| 966 | .await; |
| 967 | if !done.is_empty() { |
| 968 | let mut op = json!({"id":id,"label":label,"at":now().floor(),"undone":false,"done":done,"kind":if folder.is_some(){"folder"}else if snapshot.is_some(){"delete"}else{"move"}}); |
| 969 | if let Some(snapshot) = snapshot { |
| 970 | op["snapshot"] = json!(snapshot); |
| 971 | } else if folder.is_none() { |
| 972 | op["created"] = json!(created); |
| 973 | } |
| 974 | ops.as_array_mut().unwrap().insert(0, op); |
| 975 | ops.as_array_mut().unwrap().truncate(20); |
| 976 | core::write_json(&app.data.join("file-ops.json"), &ops).await?; |
| 977 | } |
| 978 | if let Err(e) = prune(&ops).await { |
| 979 | eprintln!("snapshot prune: {}", e.message); |
| 980 | } |
| 981 | if let Some(index) = &app.index { |
| 982 | index.changed(); |
| 983 | } |
| 984 | result?; |
| 985 | Ok(Document::new(json!({"id":id,"label":label})).response()) |
| 986 | } |
| 987 | async fn revert(explorer: &Explorer, op: &Value, mounts: &Value) -> Result<()> { |
| 988 | let paths = array(&op["done"]); |
| 989 | match string(&op["kind"]) { |
| 990 | "folder" => { |
| 991 | let rel = explorer.local(string(&paths[0]))?; |
| 992 | let abs = explorer.resolve(&rel).await?; |
| 993 | let mut dir = tokio::fs::read_dir(&abs).await?; |
| 994 | if dir.next_entry().await?.is_some() { |
| 995 | return Err(Error::new( |
| 996 | 409, |
| 997 | format!("{rel} has something in it now. Empty it, then undo."), |
| 998 | )); |
| 999 | } |
| 1000 | tokio::fs::remove_dir(abs).await?; |
| 1001 | } |
| 1002 | "delete" => { |
| 1003 | let mut copies = Vec::new(); |
| 1004 | for path in paths { |
| 1005 | let rel = explorer.local(string(path))?; |
| 1006 | let abs = explorer.resolve(&rel).await?; |
| 1007 | if tokio::fs::try_exists(&abs).await? { |
| 1008 | return Err(Error::new( |
| 1009 | 409, |
| 1010 | format!("{rel} exists again. Move or rename it, then undo."), |
| 1011 | )); |
| 1012 | } |
| 1013 | let target = holder(mounts, &abs) |
| 1014 | .map(|m| PathBuf::from(string(&m["target"]))) |
| 1015 | .unwrap_or(abs.clone()); |
| 1016 | let saved = target |
| 1017 | .join(".zfs/snapshot") |
| 1018 | .join(string(&op["snapshot"])) |
| 1019 | .join(abs.strip_prefix(&target).unwrap()); |
| 1020 | if !tokio::fs::try_exists(&saved).await? { |
| 1021 | return Err(Error::new( |
| 1022 | 409, |
| 1023 | format!("Can't put {} back. Its snapshot is gone.", abs.display()), |
| 1024 | )); |
| 1025 | } |
| 1026 | copies.push((abs, saved)); |
| 1027 | } |
| 1028 | for (abs, saved) in copies { |
| 1029 | tokio::fs::create_dir_all(abs.parent().unwrap()).await?; |
| 1030 | command( |
| 1031 | "cp", |
| 1032 | &[ |
| 1033 | "-a", |
| 1034 | "--reflink=auto", |
| 1035 | &saved.to_string_lossy(), |
| 1036 | &abs.to_string_lossy(), |
| 1037 | ], |
| 1038 | None, |
| 1039 | ) |
| 1040 | .await?; |
| 1041 | } |
| 1042 | } |
| 1043 | "move" => { |
| 1044 | let pairs = paths |
| 1045 | .iter() |
| 1046 | .rev() |
| 1047 | .map(|p| { |
| 1048 | Ok(( |
| 1049 | explorer.local(string(&p[1]))?, |
| 1050 | explorer.local(string(&p[0]))?, |
| 1051 | )) |
| 1052 | }) |
| 1053 | .collect::<Result<Vec<_>>>()?; |
| 1054 | explorer.check_moves(&pairs, mounts).await?; |
| 1055 | for (from, to) in pairs { |
| 1056 | explorer.move_one(&from, &to).await?; |
| 1057 | } |
| 1058 | for rel in array(&op["created"]).iter().rev() { |
| 1059 | remove_empty(explorer.resolve(&explorer.local(string(rel))?).await?).await?; |
| 1060 | } |
| 1061 | } |
| 1062 | _ => return Err(Error::new(409, "That change can't be undone.")), |
| 1063 | } |
| 1064 | Ok(()) |
| 1065 | } |
| 1066 | async fn refresh(app: Arc<App>) -> Result<Response> { |
| 1067 | let base = std::env::var("STUDIO_JELLYFIN_URL") |
| 1068 | .map_err(|_| Error::new(501, "Jellyfin isn't connected to the dashboard."))?; |
| 1069 | let password = host::call( |
| 1070 | json!({"operation":"deploy.secret.get","service":"jellyfin","key":"admin_password"}), |
| 1071 | ) |
| 1072 | .await?; |
| 1073 | let password = password |
| 1074 | .as_str() |
| 1075 | .ok_or_else(|| Error::new(409, "Jellyfin has no generated admin password."))?; |
| 1076 | let response = app |
| 1077 | .http |
| 1078 | .post(format!("{base}/Users/AuthenticateByName")) |
| 1079 | .header( |
| 1080 | "Authorization", |
| 1081 | "MediaBrowser Client=\"home server\", Device=\"server\", DeviceId=\"studio\", Version=\"1\"", |
| 1082 | ) |
| 1083 | .json(&json!({"Username":"snow","Pw":password})) |
| 1084 | .send() |
| 1085 | .await?; |
| 1086 | if !response.status().is_success() { |
| 1087 | return Err(Error::new( |
| 1088 | 502, |
| 1089 | format!("Jellyfin sign-in failed ({}).", response.status()), |
| 1090 | )); |
| 1091 | } |
| 1092 | let value: Value = response.json().await?; |
| 1093 | let token = value["AccessToken"] |
| 1094 | .as_str() |
| 1095 | .ok_or_else(|| Error::new(502, "Jellyfin didn't return an access token."))?; |
| 1096 | let response = app |
| 1097 | .http |
| 1098 | .post(format!("{base}/Library/Refresh")) |
| 1099 | .header("X-Emby-Token", token) |
| 1100 | .send() |
| 1101 | .await?; |
| 1102 | if !response.status().is_success() { |
| 1103 | return Err(Error::new( |
| 1104 | 502, |
| 1105 | format!( |
| 1106 | "Jellyfin couldn't start a library scan ({}).", |
| 1107 | response.status() |
| 1108 | ), |
| 1109 | )); |
| 1110 | } |
| 1111 | Ok(StatusCode::ACCEPTED.into_response()) |
| 1112 | } |
| 1113 | |
| 1114 | #[cfg(test)] |
| 1115 | mod tests { |
| 1116 | use super::*; |
| 1117 | #[test] |
| 1118 | fn selection_keeps_order_and_collapses_descendants() { |
| 1119 | assert_eq!( |
| 1120 | selection(&json!(["b/x", "a", "b", "a", "c/y"])).unwrap(), |
| 1121 | vec!["a", "b", "c/y"] |
| 1122 | ); |
| 1123 | assert!(selection(&json!([""])).is_err()); |
| 1124 | assert!(relative("/absolute", true).is_err()); |
| 1125 | assert!(relative("a\0b", true).is_err()); |
| 1126 | assert_eq!(relative("a/../b", true).unwrap(), "b"); |
| 1127 | } |
| 1128 | #[tokio::test] |
| 1129 | async fn existing_and_missing_paths_cannot_escape_through_symlinks() { |
| 1130 | let dir = std::env::temp_dir().join(format!("snowglobe-files-{}", uuid::Uuid::new_v4())); |
| 1131 | let root = dir.join("root"); |
| 1132 | let outside = dir.join("outside"); |
| 1133 | std::fs::create_dir_all(&root).unwrap(); |
| 1134 | std::fs::create_dir_all(&outside).unwrap(); |
| 1135 | std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap(); |
| 1136 | let root = std::fs::canonicalize(root).unwrap(); |
| 1137 | let explorer = Explorer { |
| 1138 | root: root.clone(), |
| 1139 | base: root, |
| 1140 | scope: PathBuf::new(), |
| 1141 | }; |
| 1142 | assert_eq!( |
| 1143 | explorer.resolve("../outside").await.unwrap_err().status, |
| 1144 | 403 |
| 1145 | ); |
| 1146 | assert_eq!( |
| 1147 | explorer |
| 1148 | .resolve("escape/new/file") |
| 1149 | .await |
| 1150 | .unwrap_err() |
| 1151 | .status, |
| 1152 | 403 |
| 1153 | ); |
| 1154 | assert!(explorer.resolve("new/file").await.is_ok()); |
| 1155 | std::fs::remove_dir_all(dir).unwrap(); |
| 1156 | } |
| 1157 | } |