]> git.proxmox.com Git - proxmox-backup.git/blob - src/backup/catalog_shell.rs
pxar::decoder::Decoder: include xattrs and payload size in `DirectoryEntry`.
[proxmox-backup.git] / src / backup / catalog_shell.rs
1 use std::cell::RefCell;
2 use std::collections::HashMap;
3 use std::ffi::{CString, OsStr};
4 use std::io::Write;
5 use std::os::unix::ffi::OsStrExt;
6 use std::path::Path;
7
8 use failure::*;
9
10 use super::catalog::{CatalogReader, DirEntry};
11 use crate::pxar::*;
12 use crate::tools;
13
14 use proxmox::api::{cli::*, *};
15
16 const PROMPT_PREFIX: &str = "pxar:";
17 const PROMPT: &str = ">";
18
19 /// Interactive shell for interacton with the catalog.
20 pub struct Shell {
21 /// Readline instance handling input and callbacks
22 rl: rustyline::Editor<CliHelper>,
23 prompt: String,
24 }
25
26 /// This list defines all the shell commands and their properties
27 /// using the api schema
28 pub fn catalog_shell_cli() -> CommandLineInterface {
29
30 let map = CliCommandMap::new()
31 .insert("pwd", CliCommand::new(&API_METHOD_PWD_COMMAND))
32 .insert(
33 "cd",
34 CliCommand::new(&API_METHOD_CD_COMMAND)
35 .arg_param(&["path"])
36 .completion_cb("path", Shell::complete_path)
37 )
38 .insert(
39 "ls",
40 CliCommand::new(&API_METHOD_LS_COMMAND)
41 .arg_param(&["path"])
42 .completion_cb("path", Shell::complete_path)
43 )
44 .insert(
45 "stat",
46 CliCommand::new(&API_METHOD_STAT_COMMAND)
47 .arg_param(&["path"])
48 .completion_cb("path", Shell::complete_path)
49 )
50 .insert(
51 "select",
52 CliCommand::new(&API_METHOD_SELECT_COMMAND)
53 .arg_param(&["path"])
54 .completion_cb("path", Shell::complete_path)
55 )
56 .insert(
57 "deselect",
58 CliCommand::new(&API_METHOD_DESELECT_COMMAND)
59 .arg_param(&["path"])
60 .completion_cb("path", Shell::complete_path)
61 )
62 .insert(
63 "restore-selected",
64 CliCommand::new(&API_METHOD_RESTORE_SELECTED_COMMAND)
65 .arg_param(&["target"])
66 .completion_cb("target", tools::complete_file_name)
67 )
68 .insert(
69 "list-selected",
70 CliCommand::new(&API_METHOD_LIST_SELECTED_COMMAND),
71 )
72 .insert(
73 "restore",
74 CliCommand::new(&API_METHOD_RESTORE_COMMAND)
75 .arg_param(&["target"])
76 .completion_cb("target", tools::complete_file_name)
77 )
78 .insert(
79 "find",
80 CliCommand::new(&API_METHOD_FIND_COMMAND)
81 .arg_param(&["path", "pattern"])
82 .completion_cb("path", Shell::complete_path)
83 )
84 .insert_help();
85
86 CommandLineInterface::Nested(map)
87 }
88
89 impl Shell {
90 /// Create a new shell for the given catalog and pxar archive.
91 pub fn new(
92 mut catalog: CatalogReader<std::fs::File>,
93 archive_name: &str,
94 decoder: Decoder,
95 ) -> Result<Self, Error> {
96 let catalog_root = catalog.root()?;
97 // The root for the given archive as stored in the catalog
98 let archive_root = catalog.lookup(&catalog_root, archive_name.as_bytes())?;
99 let root = vec![archive_root];
100
101 CONTEXT.with(|handle| {
102 let mut ctx = handle.borrow_mut();
103 *ctx = Some(Context {
104 catalog,
105 selected: Vec::new(),
106 decoder,
107 root: root.clone(),
108 current: root,
109 });
110 });
111
112 let cli_helper = CliHelper::new(catalog_shell_cli());
113 let mut rl = rustyline::Editor::<CliHelper>::new();
114 rl.set_helper(Some(cli_helper));
115
116 Context::with(|ctx| {
117 Ok(Self {
118 rl,
119 prompt: ctx.generate_prompt()?,
120 })
121 })
122 }
123
124 /// Start the interactive shell loop
125 pub fn shell(mut self) -> Result<(), Error> {
126 while let Ok(line) = self.rl.readline(&self.prompt) {
127 let helper = self.rl.helper().unwrap();
128 let args = match shellword_split(&line) {
129 Ok(args) => args,
130 Err(err) => {
131 println!("Error: {}", err);
132 continue;
133 }
134 };
135 let _ = handle_command(helper.cmd_def(), "", args);
136 self.rl.add_history_entry(line);
137 self.update_prompt()?;
138 }
139 Ok(())
140 }
141
142 /// Update the prompt to the new working directory
143 fn update_prompt(&mut self) -> Result<(), Error> {
144 Context::with(|ctx| {
145 self.prompt = ctx.generate_prompt()?;
146 Ok(())
147 })
148 }
149
150 /// Completions for paths by lookup in the catalog
151 fn complete_path(complete_me: &str, _map: &HashMap<String, String>) -> Vec<String> {
152 Context::with(|ctx| {
153 let (base, to_complete) = match complete_me.rfind('/') {
154 // Split at ind + 1 so the slash remains on base, ok also if
155 // ends in slash as split_at accepts up to length as index.
156 Some(ind) => complete_me.split_at(ind + 1),
157 None => ("", complete_me),
158 };
159
160 let current = if base.is_empty() {
161 ctx.current.clone()
162 } else {
163 ctx.canonical_path(base)?
164 };
165
166 let entries = match ctx.catalog.read_dir(&current.last().unwrap()) {
167 Ok(entries) => entries,
168 Err(_) => return Ok(Vec::new()),
169 };
170
171 let mut list = Vec::new();
172 for entry in &entries {
173 let mut name = String::from(base);
174 if entry.name.starts_with(to_complete.as_bytes()) {
175 name.push_str(std::str::from_utf8(&entry.name)?);
176 if entry.is_directory() {
177 name.push('/');
178 }
179 list.push(name);
180 }
181 }
182 Ok(list)
183 })
184 .unwrap_or_default()
185 }
186 }
187
188 #[api(input: { properties: {} })]
189 /// List the current working directory.
190 fn pwd_command() -> Result<(), Error> {
191 Context::with(|ctx| {
192 let path = Context::generate_cstring(&ctx.current)?;
193 let mut out = std::io::stdout();
194 out.write_all(&path.as_bytes())?;
195 out.write_all(&[b'\n'])?;
196 out.flush()?;
197 Ok(())
198 })
199 }
200
201 #[api(
202 input: {
203 properties: {
204 path: {
205 type: String,
206 optional: true,
207 description: "target path."
208 }
209 }
210 }
211 )]
212 /// Change the current working directory to the new directory
213 fn cd_command(path: Option<String>) -> Result<(), Error> {
214 Context::with(|ctx| {
215 let path = path.unwrap_or_default();
216 let mut path = ctx.canonical_path(&path)?;
217 if !path
218 .last()
219 .ok_or_else(|| format_err!("invalid path component"))?
220 .is_directory()
221 {
222 // Change to the parent dir of the file instead
223 path.pop();
224 eprintln!("not a directory, fallback to parent directory");
225 }
226 ctx.current = path;
227 Ok(())
228 })
229 }
230
231 #[api(
232 input: {
233 properties: {
234 path: {
235 type: String,
236 optional: true,
237 description: "target path."
238 }
239 }
240 }
241 )]
242 /// List the content of working directory or given path.
243 fn ls_command(path: Option<String>) -> Result<(), Error> {
244 Context::with(|ctx| {
245 let parent = if let Some(path) = path {
246 ctx.canonical_path(&path)?
247 .last()
248 .ok_or_else(|| format_err!("invalid path component"))?
249 .clone()
250 } else {
251 ctx.current.last().unwrap().clone()
252 };
253
254 let list = if parent.is_directory() {
255 ctx.catalog.read_dir(&parent)?
256 } else {
257 vec![parent]
258 };
259
260 if list.is_empty() {
261 return Ok(());
262 }
263 let max = list.iter().max_by(|x, y| x.name.len().cmp(&y.name.len()));
264 let max = match max {
265 Some(dir_entry) => dir_entry.name.len() + 1,
266 None => 0,
267 };
268
269 let (_rows, mut cols) = Context::get_terminal_size();
270 cols /= max;
271
272 let mut out = std::io::stdout();
273 for (index, item) in list.iter().enumerate() {
274 out.write_all(&item.name)?;
275 // Fill with whitespaces
276 out.write_all(&vec![b' '; max - item.name.len()])?;
277 if index % cols == (cols - 1) {
278 out.write_all(&[b'\n'])?;
279 }
280 }
281 // If the last line is not complete, add the newline
282 if list.len() % cols != cols - 1 {
283 out.write_all(&[b'\n'])?;
284 }
285 out.flush()?;
286 Ok(())
287 })
288 }
289
290 #[api(
291 input: {
292 properties: {
293 path: {
294 type: String,
295 description: "target path."
296 }
297 }
298 }
299 )]
300 /// Read the metadata for a given directory entry.
301 ///
302 /// This is expensive because the data has to be read from the pxar `Decoder`,
303 /// which means reading over the network.
304 fn stat_command(path: String) -> Result<(), Error> {
305 Context::with(|ctx| {
306 // First check if the file exists in the catalog, therefore avoiding
307 // expensive calls to the decoder just to find out that there maybe is
308 // no such entry.
309 // This is done by calling canonical_path(), which returns the full path
310 // if it exists, error otherwise.
311 let path = ctx.canonical_path(&path)?;
312 let item = ctx.lookup(&path)?;
313 let mut out = std::io::stdout();
314 out.write_all(b"File: ")?;
315 out.write_all(item.filename.as_bytes())?;
316 out.write_all(&[b'\n'])?;
317 out.write_all(format!("Size: {}\n", item.size).as_bytes())?;
318 out.write_all(b"Type: ")?;
319 match item.entry.mode as u32 & libc::S_IFMT {
320 libc::S_IFDIR => out.write_all(b"directory\n")?,
321 libc::S_IFREG => out.write_all(b"regular file\n")?,
322 libc::S_IFLNK => out.write_all(b"symbolic link\n")?,
323 libc::S_IFBLK => out.write_all(b"block special file\n")?,
324 libc::S_IFCHR => out.write_all(b"character special file\n")?,
325 _ => out.write_all(b"unknown\n")?,
326 };
327 out.write_all(format!("Uid: {}\n", item.entry.uid).as_bytes())?;
328 out.write_all(format!("Gid: {}\n", item.entry.gid).as_bytes())?;
329 out.flush()?;
330 Ok(())
331 })
332 }
333
334 #[api(
335 input: {
336 properties: {
337 path: {
338 type: String,
339 description: "target path."
340 }
341 }
342 }
343 )]
344 /// Select an entry for restore.
345 ///
346 /// This will return an error if the entry is already present in the list or
347 /// if an invalid path was provided.
348 fn select_command(path: String) -> Result<(), Error> {
349 Context::with(|ctx| {
350 // Calling canonical_path() makes sure the provided path is valid and
351 // actually contained within the catalog and therefore also the archive.
352 let path = ctx.canonical_path(&path)?;
353 let pattern = MatchPattern::from_line(Context::generate_cstring(&path)?.as_bytes())?
354 .ok_or_else(|| format_err!("encountered invalid match pattern"))?;
355 if ctx.selected.iter().find(|p| **p == pattern).is_none() {
356 ctx.selected.push(pattern);
357 }
358 Ok(())
359 })
360 }
361
362 #[api(
363 input: {
364 properties: {
365 path: {
366 type: String,
367 description: "path to entry to remove from list."
368 }
369 }
370 }
371 )]
372 /// Deselect an entry for restore.
373 ///
374 /// This will return an error if the entry was not found in the list of entries
375 /// selected for restore.
376 fn deselect_command(path: String) -> Result<(), Error> {
377 Context::with(|ctx| {
378 let path = ctx.canonical_path(&path)?;
379 let mut pattern = MatchPattern::from_line(Context::generate_cstring(&path)?.as_bytes())?
380 .ok_or_else(|| format_err!("encountered invalid match pattern"))?;
381 if let Some(last) = ctx.selected.last() {
382 if last == &pattern {
383 ctx.selected.pop();
384 return Ok(());
385 }
386 }
387 pattern.invert();
388 ctx.selected.push(pattern);
389 Ok(())
390 })
391 }
392
393 #[api(
394 input: {
395 properties: {
396 target: {
397 type: String,
398 description: "target path for restore on local filesystem."
399 }
400 }
401 }
402 )]
403 /// Restore the selected entries to the given target path.
404 ///
405 /// Target must not exist on the clients filesystem.
406 fn restore_selected_command(target: String) -> Result<(), Error> {
407 Context::with(|ctx| {
408 if ctx.selected.is_empty() {
409 bail!("no entries selected for restore");
410 }
411
412 // Entry point for the restore is always root here as the provided match
413 // patterns are relative to root as well.
414 let start_dir = ctx.decoder.root()?;
415 ctx.decoder
416 .restore(&start_dir, &Path::new(&target), &ctx.selected)?;
417 Ok(())
418 })
419 }
420
421 #[api( input: { properties: {} })]
422 /// List entries currently selected for restore.
423 fn list_selected_command() -> Result<(), Error> {
424 Context::with(|ctx| {
425 let mut out = std::io::stdout();
426 out.write_all(&MatchPattern::to_bytes(ctx.selected.as_slice()))?;
427 out.flush()?;
428 Ok(())
429 })
430 }
431
432 #[api(
433 input: {
434 properties: {
435 target: {
436 type: String,
437 description: "target path for restore on local filesystem."
438 },
439 pattern: {
440 type: String,
441 optional: true,
442 description: "match pattern to limit files for restore."
443 }
444 }
445 }
446 )]
447 /// Restore the sub-archive given by the current working directory to target.
448 ///
449 /// By further providing a pattern, the restore can be limited to a narrower
450 /// subset of this sub-archive.
451 /// If pattern is not present or empty, the full archive is restored to target.
452 fn restore_command(target: String, pattern: Option<String>) -> Result<(), Error> {
453 Context::with(|ctx| {
454 let pattern = pattern.unwrap_or_default();
455 let match_pattern = match pattern.as_str() {
456 "" | "/" | "." => Vec::new(),
457 _ => vec![MatchPattern::from_line(pattern.as_bytes())?.unwrap()],
458 };
459 // Decoder entry point for the restore.
460 let start_dir = if pattern.starts_with("/") {
461 ctx.decoder.root()?
462 } else {
463 // Get the directory corresponding to the working directory from the
464 // archive.
465 let cwd = ctx.current.clone();
466 ctx.lookup(&cwd)?
467 };
468
469 ctx.decoder
470 .restore(&start_dir, &Path::new(&target), &match_pattern)?;
471 Ok(())
472 })
473 }
474
475 #[api(
476 input: {
477 properties: {
478 path: {
479 type: String,
480 description: "Path to node from where to start the search."
481 },
482 pattern: {
483 type: String,
484 description: "Match pattern for matching files in the catalog."
485 },
486 select: {
487 type: bool,
488 optional: true,
489 description: "Add matching filenames to list for restore."
490 }
491 }
492 }
493 )]
494 /// Find entries in the catalog matching the given match pattern.
495 fn find_command(path: String, pattern: String, select: Option<bool>) -> Result<(), Error> {
496 Context::with(|ctx| {
497 let path = ctx.canonical_path(&path)?;
498 if !path.last().unwrap().is_directory() {
499 bail!("path should be a directory, not a file!");
500 }
501 let select = select.unwrap_or(false);
502
503 let cpath = Context::generate_cstring(&path).unwrap();
504 let pattern = if pattern.starts_with("!") {
505 let mut buffer = vec![b'!'];
506 buffer.extend_from_slice(cpath.as_bytes());
507 buffer.extend_from_slice(pattern[1..pattern.len()].as_bytes());
508 buffer
509 } else {
510 let mut buffer = cpath.as_bytes().to_vec();
511 buffer.extend_from_slice(pattern.as_bytes());
512 buffer
513 };
514
515 let pattern = MatchPattern::from_line(&pattern)?
516 .ok_or_else(|| format_err!("invalid match pattern"))?;
517 let slice = vec![pattern.as_slice()];
518
519 // The match pattern all contain the prefix of the entry path in order to
520 // store them if selected, so the entry point for find is always the root
521 // directory.
522 let mut dir_stack = ctx.root.clone();
523 ctx.catalog.find(
524 &mut dir_stack,
525 &slice,
526 &Box::new(|path: &[DirEntry]| println!("{:?}", Context::generate_cstring(path).unwrap()))
527 )?;
528
529 // Insert if matches should be selected.
530 // Avoid duplicate entries of the same match pattern.
531 if select && ctx.selected.iter().find(|p| **p == pattern).is_none() {
532 ctx.selected.push(pattern);
533 }
534
535 Ok(())
536 })
537 }
538
539 std::thread_local! {
540 static CONTEXT: RefCell<Option<Context>> = RefCell::new(None);
541 }
542
543 /// Holds the context needed for access to catalog and decoder
544 struct Context {
545 /// Calalog reader instance to navigate
546 catalog: CatalogReader<std::fs::File>,
547 /// List of selected paths for restore
548 selected: Vec<MatchPattern>,
549 /// Decoder instance for the current pxar archive
550 decoder: Decoder,
551 /// Root directory for the give archive as stored in the catalog
552 root: Vec<DirEntry>,
553 /// Stack of directories up to the current working directory
554 /// used for navigation and path completion.
555 current: Vec<DirEntry>,
556 }
557
558 impl Context {
559 /// Execute `call` within a context providing a mut ref to `Context` instance.
560 fn with<T, F>(call: F) -> Result<T, Error>
561 where
562 F: FnOnce(&mut Context) -> Result<T, Error>,
563 {
564 CONTEXT.with(|cell| {
565 let mut ctx = cell.borrow_mut();
566 call(&mut ctx.as_mut().unwrap())
567 })
568 }
569
570 /// Generate CString from provided stack of `DirEntry`s.
571 fn generate_cstring(dir_stack: &[DirEntry]) -> Result<CString, Error> {
572 let mut path = vec![b'/'];
573 // Skip the archive root, the '/' is displayed for it instead
574 for component in dir_stack.iter().skip(1) {
575 path.extend_from_slice(&component.name);
576 if component.is_directory() {
577 path.push(b'/');
578 }
579 }
580 Ok(unsafe { CString::from_vec_unchecked(path) })
581 }
582
583 /// Resolve the indirect path components and return an absolute path.
584 ///
585 /// This will actually navigate the filesystem tree to check that the
586 /// path is vaild and exists.
587 /// This does not include following symbolic links.
588 /// If None is given as path, only the root directory is returned.
589 fn canonical_path(&mut self, path: &str) -> Result<Vec<DirEntry>, Error> {
590 if path == "/" {
591 return Ok(self.root.clone());
592 }
593
594 let mut path_slice = if path.is_empty() {
595 // Fallback to root if no path was provided
596 return Ok(self.root.clone());
597 } else {
598 path
599 };
600
601 let mut dir_stack = if path_slice.starts_with("/") {
602 // Absolute path, reduce view of slice and start from root
603 path_slice = &path_slice[1..];
604 self.root.clone()
605 } else {
606 // Relative path, start from current working directory
607 self.current.clone()
608 };
609 let should_end_dir = if path_slice.ends_with("/") {
610 path_slice = &path_slice[0..path_slice.len() - 1];
611 true
612 } else {
613 false
614 };
615 for name in path_slice.split('/') {
616 match name {
617 "." => continue,
618 ".." => {
619 // Never pop archive root from stack
620 if dir_stack.len() > 1 {
621 dir_stack.pop();
622 }
623 }
624 _ => {
625 let entry = self.catalog.lookup(dir_stack.last().unwrap(), name.as_bytes())?;
626 dir_stack.push(entry);
627 }
628 }
629 }
630 if should_end_dir
631 && !dir_stack
632 .last()
633 .ok_or_else(|| format_err!("invalid path component"))?
634 .is_directory()
635 {
636 bail!("entry is not a directory");
637 }
638
639 Ok(dir_stack)
640 }
641
642 /// Generate the CString to display by readline based on
643 /// PROMPT_PREFIX, PROMPT and the current working directory.
644 fn generate_prompt(&self) -> Result<String, Error> {
645 let prompt = format!(
646 "{}{} {} ",
647 PROMPT_PREFIX,
648 Self::generate_cstring(&self.current)?.to_string_lossy(),
649 PROMPT,
650 );
651 Ok(prompt)
652 }
653
654 /// Get the current size of the terminal
655 /// # Safety
656 ///
657 /// uses unsafe call to tty_ioctl, see man tty_ioctl(2)
658 fn get_terminal_size() -> (usize, usize) {
659 const TIOCGWINSZ: libc::c_ulong = 0x5413;
660
661 #[repr(C)]
662 struct WinSize {
663 ws_row: libc::c_ushort,
664 ws_col: libc::c_ushort,
665 _ws_xpixel: libc::c_ushort, // unused
666 _ws_ypixel: libc::c_ushort, // unused
667 }
668
669 let mut winsize = WinSize {
670 ws_row: 0,
671 ws_col: 0,
672 _ws_xpixel: 0,
673 _ws_ypixel: 0,
674 };
675 unsafe { libc::ioctl(libc::STDOUT_FILENO, TIOCGWINSZ, &mut winsize) };
676 (winsize.ws_row as usize, winsize.ws_col as usize)
677 }
678
679 /// Look up the entry given by a canonical absolute `path` in the archive.
680 ///
681 /// This will actively navigate the archive by calling the corresponding
682 /// decoder functionalities and is therefore very expensive.
683 fn lookup(&mut self, absolute_path: &[DirEntry]) -> Result<DirectoryEntry, Error> {
684 let mut current = self.decoder.root()?;
685 // Ignore the archive root, don't need it.
686 for item in absolute_path.iter().skip(1) {
687 match self
688 .decoder
689 .lookup(&current, &OsStr::from_bytes(&item.name))?
690 {
691 Some(item) => current = item,
692 // This should not happen if catalog an archive are consistent.
693 None => bail!("no such file or directory in archive - inconsistent catalog"),
694 }
695 }
696 Ok(current)
697 }
698 }