mirror of
				https://github.com/Dummi26/rembackup.git
				synced 2025-10-31 19:36:15 +01:00 
			
		
		
		
	initial commit
This commit is contained in:
		
						commit
						df15ef85bd
					
				
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Executable file
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Executable file
									
								
							| @ -0,0 +1,2 @@ | ||||
| /target | ||||
| Cargo.lock | ||||
							
								
								
									
										9
									
								
								Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								Cargo.toml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | ||||
| [package] | ||||
| name = "rembackup" | ||||
| version = "0.1.0" | ||||
| edition = "2021" | ||||
| 
 | ||||
| # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||||
| 
 | ||||
| [dependencies] | ||||
| clap = { version = "4.4.3", features = ["derive"] } | ||||
							
								
								
									
										55
									
								
								README.md
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										55
									
								
								README.md
									
									
									
									
									
										Executable file
									
								
							| @ -0,0 +1,55 @@ | ||||
| # rembackup | ||||
| 
 | ||||
| A super simple yet fast backup solution, designed with slow connections in mind. | ||||
| 
 | ||||
| ## How it works | ||||
| 
 | ||||
| Rembackup uses 3 directories: `source`, `index`, and `target`. | ||||
| 
 | ||||
| ```sh | ||||
| rembackup $SOURCE $INDEX $TARGET | ||||
| ``` | ||||
| 
 | ||||
| In *Step 1*, Rembackup recursively walks the `source` directory, comparing all entries with `index`. | ||||
| It then shows a list of changes that would make `target` contain the same files as `source`. | ||||
| 
 | ||||
| If you accept, it will then move on to *Step 2* and apply these changes. | ||||
| 
 | ||||
| If you didn't get any warnings, `target` is now a backup of `source`. | ||||
| 
 | ||||
| If you *did* get one or more warnings - don't worry! | ||||
| You can just rerun the backup and the failed operations will be retried. | ||||
| 
 | ||||
| ## What makes it special | ||||
| 
 | ||||
| If you want to back up your data to an external disk, you probably bought a large HDD. | ||||
| If you want a remote backup, you may want to self-host something. | ||||
| In both of these situations, the filesystem containing `target` is horribly slow. | ||||
| 
 | ||||
| If a backup tool tries to compare `source` to `target` to figure out which files have changed, | ||||
| it will always be affected by this slowness - even when working on unchanged files. | ||||
| 
 | ||||
| Rembackup only performs read operations on `source` and `index` in *Step 1*. | ||||
| Because of this, it can be surprisingly fast even when backing up large disks. | ||||
| 
 | ||||
| In *Step 2*, where files are actually being copied to `target`, the slowness will still be noticeable, | ||||
| but since only modified files are being copied, this usually takes a somewhat reasonable amount of time. | ||||
| 
 | ||||
| ## Usage | ||||
| 
 | ||||
| To create a backup of your home directory `~` to `/mnt/backup`: | ||||
| 
 | ||||
| ```sh | ||||
| rembackup ~ ~/index /mnt/backup | ||||
| ``` | ||||
| 
 | ||||
| Note: `index` (`~/index`) doesn't need to be a subdirectory of `source` (`~`), but if it is, it will not be part of the backup to avoid problems. | ||||
| Note 2: `~/index` and `/mnt/backup` don't need to exist yet - they will be created if their parent directories exist. | ||||
| 
 | ||||
| If this is the first backup, you can try to maximize the speed of `/mnt/backup`. | ||||
| If you want remote backups, you should probably connect the server's disk directly to your computer. | ||||
| The backups after the initial one will be a lot faster, so you can switch to remote backups after this. | ||||
| 
 | ||||
| ## TODO | ||||
| 
 | ||||
| detect files that have been removed | ||||
							
								
								
									
										67
									
								
								src/apply_indexchanges.rs
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										67
									
								
								src/apply_indexchanges.rs
									
									
									
									
									
										Executable file
									
								
							| @ -0,0 +1,67 @@ | ||||
| use std::{fs, io, path::Path}; | ||||
| 
 | ||||
| use crate::{indexchanges::IndexChange, repr_file::ReprFile}; | ||||
| 
 | ||||
| /// Only errors that happen when writing to the index are immediately returned.
 | ||||
| /// Other errors are logged to stderr and the failed change will not be saved to the index,
 | ||||
| /// so the next backup will try again.
 | ||||
| pub fn apply_indexchanges( | ||||
|     source: &Path, | ||||
|     index: &Path, | ||||
|     target: &Path, | ||||
|     changes: &Vec<IndexChange>, | ||||
| ) -> io::Result<()> { | ||||
|     let o = apply_indexchanges_int(source, index, target, changes); | ||||
|     eprintln!(); | ||||
|     o | ||||
| } | ||||
| pub fn apply_indexchanges_int( | ||||
|     source: &Path, | ||||
|     index: &Path, | ||||
|     target: &Path, | ||||
|     changes: &Vec<IndexChange>, | ||||
| ) -> io::Result<()> { | ||||
|     let len_width = changes.len().to_string().len(); | ||||
|     let width = 80 - 3 - 2 - len_width - len_width; | ||||
|     eprint!( | ||||
|         "{}0/{} [>{}]", | ||||
|         " ".repeat(len_width - 1), | ||||
|         changes.len(), | ||||
|         " ".repeat(width) | ||||
|     ); | ||||
|     for (i, change) in changes.iter().enumerate() { | ||||
|         match change { | ||||
|             IndexChange::AddDir(dir) => { | ||||
|                 let t = target.join(dir); | ||||
|                 if let Err(e) = fs::create_dir(&t) { | ||||
|                     eprintln!("\n[warn] couldn't create directory {t:?}: {e}"); | ||||
|                 } else { | ||||
|                     fs::create_dir(&index.join(dir))?; | ||||
|                 } | ||||
|             } | ||||
|             IndexChange::AddFile(file, index_file) => { | ||||
|                 let s = source.join(file); | ||||
|                 let t = target.join(file); | ||||
|                 if let Err(e) = fs::copy(&s, &t) { | ||||
|                     eprintln!("\n[warn] couldn't copy file from {s:?} to {t:?}: {e}"); | ||||
|                 } | ||||
|                 fs::write(&index.join(file), index_file.save())?; | ||||
|             } | ||||
|         } | ||||
|         { | ||||
|             let i = i + 1; | ||||
|             let leftpad = width * i / changes.len(); | ||||
|             let rightpad = width - leftpad; | ||||
|             let prognum = i.to_string(); | ||||
|             eprint!( | ||||
|                 "\r{}{}/{} [{}>{}]", | ||||
|                 " ".repeat(len_width - prognum.len()), | ||||
|                 prognum, | ||||
|                 changes.len(), | ||||
|                 "-".repeat(leftpad), | ||||
|                 " ".repeat(rightpad) | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|     Ok(()) | ||||
| } | ||||
							
								
								
									
										14
									
								
								src/args.rs
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										14
									
								
								src/args.rs
									
									
									
									
									
										Executable file
									
								
							| @ -0,0 +1,14 @@ | ||||
| use std::path::PathBuf; | ||||
| 
 | ||||
| use clap::Parser; | ||||
| 
 | ||||
| #[derive(Parser)] | ||||
| #[command(author, version)] | ||||
| pub struct Args { | ||||
|     #[arg()] | ||||
|     pub source: PathBuf, | ||||
|     #[arg()] | ||||
|     pub index: PathBuf, | ||||
|     #[arg()] | ||||
|     pub target: PathBuf, | ||||
| } | ||||
							
								
								
									
										11
									
								
								src/indexchanges.rs
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										11
									
								
								src/indexchanges.rs
									
									
									
									
									
										Executable file
									
								
							| @ -0,0 +1,11 @@ | ||||
| use std::path::PathBuf; | ||||
| 
 | ||||
| use crate::indexfile::IndexFile; | ||||
| 
 | ||||
| #[derive(Debug)] | ||||
| pub enum IndexChange { | ||||
|     /// Ensure a directory with this path exists (at least if all its parent directories exist).
 | ||||
|     AddDir(PathBuf), | ||||
|     /// Add or update a file
 | ||||
|     AddFile(PathBuf, IndexFile), | ||||
| } | ||||
							
								
								
									
										53
									
								
								src/indexfile.rs
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										53
									
								
								src/indexfile.rs
									
									
									
									
									
										Executable file
									
								
							| @ -0,0 +1,53 @@ | ||||
| use std::{ | ||||
|     collections::HashMap, | ||||
|     fs::{self, Metadata}, | ||||
|     io, | ||||
|     path::Path, | ||||
|     time::SystemTime, | ||||
| }; | ||||
| 
 | ||||
| use crate::repr_file::ReprFile; | ||||
| 
 | ||||
| #[derive(Debug, PartialEq, Eq)] | ||||
| pub struct IndexFile { | ||||
|     size: u64, | ||||
|     last_modified: Option<u64>, | ||||
| } | ||||
| 
 | ||||
| impl IndexFile { | ||||
|     pub fn new_from_metadata(metadata: &Metadata) -> Self { | ||||
|         Self { | ||||
|             size: metadata.len(), | ||||
|             last_modified: metadata | ||||
|                 .modified() | ||||
|                 .ok() | ||||
|                 .and_then(|v| v.duration_since(SystemTime::UNIX_EPOCH).ok()) | ||||
|                 .map(|v| v.as_secs()), | ||||
|         } | ||||
|     } | ||||
|     pub fn from_path(path: &Path) -> io::Result<Result<Self, String>> { | ||||
|         Ok(Self::load(&fs::read_to_string(path)?)) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl ReprFile for IndexFile { | ||||
|     fn save(&self) -> String { | ||||
|         let mut o = format!("Len={}\n", self.size); | ||||
|         if let Some(age) = self.last_modified { | ||||
|             o.push_str(&format!("Age={}\n", age)); | ||||
|         } | ||||
|         o | ||||
|     } | ||||
|     fn load(src: &str) -> Result<Self, String> { | ||||
|         let hm = HashMap::load(src)?; | ||||
|         if let Some(len) = hm.get("Len").and_then(|len_str| len_str.parse().ok()) { | ||||
|             let age = hm.get("Age").and_then(|lm_str| lm_str.parse().ok()); | ||||
|             Ok(Self { | ||||
|                 size: len, | ||||
|                 last_modified: age, | ||||
|             }) | ||||
|         } else { | ||||
|             return Err(format!("no Len in IndexFile!")); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										56
									
								
								src/main.rs
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										56
									
								
								src/main.rs
									
									
									
									
									
										Executable file
									
								
							| @ -0,0 +1,56 @@ | ||||
| use std::process::exit; | ||||
| 
 | ||||
| use clap::Parser; | ||||
| 
 | ||||
| use crate::{ | ||||
|     apply_indexchanges::apply_indexchanges, indexchanges::IndexChange, | ||||
|     update_index::perform_index_diff, | ||||
| }; | ||||
| 
 | ||||
| mod apply_indexchanges; | ||||
| mod args; | ||||
| mod indexchanges; | ||||
| mod indexfile; | ||||
| mod repr_file; | ||||
| mod update_index; | ||||
| 
 | ||||
| fn main() { | ||||
|     // get args
 | ||||
|     let args = args::Args::parse(); | ||||
|     // index diff
 | ||||
|     eprintln!("performing index diff..."); | ||||
|     let changes = match perform_index_diff(&args.source, &args.index) { | ||||
|         Ok(c) => c, | ||||
|         Err(e) => { | ||||
|             eprintln!("Failed to generate index diff:\n    {e}"); | ||||
|             exit(20); | ||||
|         } | ||||
|     }; | ||||
|     if changes.is_empty() { | ||||
|         eprintln!("done! found no changes."); | ||||
|     } else { | ||||
|         eprintln!("done! found {} changes.", changes.len()); | ||||
|         // display the changes
 | ||||
|         eprintln!(" - - - - -"); | ||||
|         for change in &changes { | ||||
|             match change { | ||||
|                 IndexChange::AddDir(v) => eprintln!(" - Add the directory {v:?}"), | ||||
|                 IndexChange::AddFile(v, _) => eprintln!(" - Add the file {v:?}"), | ||||
|             } | ||||
|         } | ||||
|         eprintln!( | ||||
|             "Press Enter to add these {} changes to the backup.", | ||||
|             changes.len() | ||||
|         ); | ||||
|         // apply changes
 | ||||
|         if std::io::stdin().read_line(&mut String::new()).is_ok() { | ||||
|             match apply_indexchanges(&args.source, &args.index, &args.target, &changes) { | ||||
|                 Ok(()) => {} | ||||
|                 Err(e) => { | ||||
|                     eprintln!("Failed to apply index changes: {e}"); | ||||
|                     exit(30); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										46
									
								
								src/repr_file.rs
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										46
									
								
								src/repr_file.rs
									
									
									
									
									
										Executable file
									
								
							| @ -0,0 +1,46 @@ | ||||
| use std::collections::HashMap; | ||||
| 
 | ||||
| pub trait ReprFile: Sized { | ||||
|     fn save(&self) -> String; | ||||
|     fn load(src: &str) -> Result<Self, String>; | ||||
| } | ||||
| 
 | ||||
| impl ReprFile for Vec<String> { | ||||
|     fn save(&self) -> String { | ||||
|         let mut o = String::new(); | ||||
|         for line in self { | ||||
|             o.push_str(line); | ||||
|         } | ||||
|         o | ||||
|     } | ||||
|     fn load(src: &str) -> Result<Self, String> { | ||||
|         Ok(src.lines().map(|v| v.to_owned()).collect()) | ||||
|     } | ||||
| } | ||||
| impl ReprFile for HashMap<String, String> { | ||||
|     fn save(&self) -> String { | ||||
|         let mut o = String::new(); | ||||
|         for (key, value) in self { | ||||
|             o.push_str(key); | ||||
|             o.push('='); | ||||
|             o.push_str(value); | ||||
|             o.push('\n'); | ||||
|         } | ||||
|         o | ||||
|     } | ||||
|     fn load(src: &str) -> Result<Self, String> { | ||||
|         let mut o = HashMap::new(); | ||||
|         for line in src.lines() { | ||||
|             if !line.is_empty() { | ||||
|                 if let Some((key, value)) = line.split_once('=') { | ||||
|                     o.insert(key.to_owned(), value.to_owned()); | ||||
|                 } else { | ||||
|                     return Err(format!( | ||||
|                         "Nonempty line didn't contain the required = char! (line: {line:?})" | ||||
|                     )); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         Ok(o) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										57
									
								
								src/update_index.rs
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										57
									
								
								src/update_index.rs
									
									
									
									
									
										Executable file
									
								
							| @ -0,0 +1,57 @@ | ||||
| use std::{fs, io, path::Path}; | ||||
| 
 | ||||
| use crate::{indexchanges::IndexChange, indexfile::IndexFile}; | ||||
| 
 | ||||
| pub fn perform_index_diff(source: &Path, index: &Path) -> io::Result<Vec<IndexChange>> { | ||||
|     let mut changes = Vec::new(); | ||||
|     rec( | ||||
|         source.as_ref(), | ||||
|         Path::new(""), | ||||
|         index, | ||||
|         &mut changes, | ||||
|         index.strip_prefix(source).ok(), | ||||
|     )?; | ||||
|     Ok(changes) | ||||
| } | ||||
| fn rec( | ||||
|     source: &Path, | ||||
|     rel_path: &Path, | ||||
|     index_files: &Path, | ||||
|     changes: &mut Vec<IndexChange>, | ||||
|     inner_index: Option<&Path>, | ||||
| ) -> Result<(), io::Error> { | ||||
|     if let Some(ii) = &inner_index { | ||||
|         if rel_path.starts_with(ii) { | ||||
|             eprintln!("[info] source contains index, but index will not be part of the backup."); | ||||
|             return Ok(()); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     if !index_files.join(rel_path).try_exists()? { | ||||
|         changes.push(IndexChange::AddDir(rel_path.to_path_buf())); | ||||
|     } | ||||
|     for entry in fs::read_dir(source.join(rel_path))? { | ||||
|         let entry = entry?; | ||||
|         let metadata = entry.metadata()?; | ||||
|         if metadata.is_dir() { | ||||
|             rec( | ||||
|                 source, | ||||
|                 &rel_path.join(entry.file_name()), | ||||
|                 index_files, | ||||
|                 changes, | ||||
|                 inner_index, | ||||
|             )?; | ||||
|         } else { | ||||
|             let newif = IndexFile::new_from_metadata(&metadata); | ||||
|             let oldif = IndexFile::from_path(&index_files.join(rel_path).join(entry.file_name())); | ||||
|             match oldif { | ||||
|                 Ok(Ok(oldif)) if oldif == newif => {} | ||||
|                 _ => changes.push(IndexChange::AddFile( | ||||
|                     rel_path.join(entry.file_name()), | ||||
|                     newif, | ||||
|                 )), | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     Ok(()) | ||||
| } | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Mark
						Mark