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