use std::path::PathBuf; use clap::{Args, Parser, Subcommand}; const CONFIGURATION_HELP: &str = "\ Configuration file: ~/.config/beeping/config.toml is read if present. Everything in it can also be given on the command line, which takes precedence. All keys are optional: # Where backups go: a path or URL (see \"Repository locations\") repository = \"sftp://user@host/backups/repo\" # Glob patterns excluded from every backup (--exclude adds to # this list; see \"Exclude patterns\" below for the rules) exclude = [\"*.tmp\", \"/nobackup\"] # Follow directories onto other filesystems. Off by default: # mount points are recorded but never entered without permission cross_mount_points = false # Room to leave on the store. Once it has less than this left, a # backup finishes early (see \"Finishing early\" below) instead of # filling it up. A size, or a plain number of bytes; K, M, G, T # and P count in powers of 1024 minimum_free_space = \"20 GiB\" # A size for the repository to stay under, which is the same # limit from the other end — for a store that can say what it # holds but not what it has left, such as an FTP account with a # quota maximum_store_size = \"500 GiB\" Exclude patterns: Patterns are matched against paths relative to the backup root, case-sensitively, against both files and directories. - Without a leading /, a pattern applies at every depth: `cache` excludes anything named cache anywhere in the tree, and `*.log` excludes .log files in every directory. - A leading / anchors the pattern to the backup root: `/cache` excludes only the top-level cache, not sub/cache. - Excluding a directory excludes everything beneath it — its contents are never even scanned. - Because of that, `cache` and `cache/**` differ: `cache` removes the directory entirely, while `cache/**` excludes only its contents, leaving an empty cache directory in the snapshot. - * and ? stay within one path component; ** crosses directories. So `/build/*.o` matches build/x.o but not build/sub/x.o, and `/build/**/*.o` matches both. - {a,b} alternation and [ab] / [!ab] character classes work; a trailing / is accepted and ignored. - Excludes may be changed between suspending a backup and resuming it: the new rules apply to everything not yet processed, while anything already stored stays in the snapshot. The same pattern rules drive cherry-picked restores (beeping restore SNAPSHOT TARGET PATTERN...), where a match selects instead of excludes: a selected directory is restored with its whole subtree, and the directories above every selection are recreated with their recorded permissions and timestamps. Use beeping list to see a snapshot's contents. Finishing early: A backup that finishes early ends as though the files it had left to scan and chunk did not exist: everything it did store is published as its snapshot — a finished one, not a partial — the run is over, and its working state is gone, so the same command afterwards starts a fresh backup rather than resuming. Three things ask for it: --early-finish, a store with less than `minimum_free_space` left, and a repository grown to `maximum_store_size`. Which of the two limits to set depends on what the store can say about itself. A local disk or an SFTP server reports its free space, so a floor under that is the natural limit; an FTP account or an object store cannot, but can be listed, so a size for the repository is. A store that can answer neither is held to neither, and says so as the backup starts. A store too small for the whole tree therefore takes two runs: 1. the first fills it and stops, publishing what fits 2. exclude (or delete) what it did not reach, and run again with --ignore-store-limits. Almost nothing is stored twice — the chunks are already there — so the second run is quick, and its snapshot covers everything you chose to keep 3. prune the first run's snapshot, which frees whatever only it was holding Step 2 has to ignore the limits because the store is still at the one that stopped the first run; without that, the second backup would stop at once, just as the first one did. Removing things: Two commands take things out of a repository, and neither can be undone. beeping prune SNAPSHOT... Removes whole snapshots. What is left of the repository is everything the remaining snapshots need, and nothing else. beeping excise ROOT PATTERN... Removes files and directories from every snapshot of one tree, rewriting each of their manifests without the selected paths. For something that should never have been backed up — a directory of build output, a file with a password in it — and for getting the room it occupies back. Both sweep the chunks nothing needs afterwards, and both refuse while a backup holds the repository. An excision refuses more besides: a backup of that tree that was killed rather than suspended is holding content no snapshot names, and sweeping around it would break it, so it must be finished, suspended, or abandoned first. An excision reaches into a suspended backup of the tree as well, dropping the selected paths from the work it still has queued — but a backup rediscovers files by walking the tree, so anything still on disk under a selected path will be backed up again by the next run unless it is excluded too. Repository locations: /path/to/repo or file://… a local directory ftp://user:pass@host:port/path?connections=N sftp://user@host:port/path?connections=N (ssh:// is an alias) at most N connections are held (default 4), for servers with login limits s3://bucket/prefix?region=…&endpoint=…&style=path|vhost endpoint/style are for S3-compatible stores (Linode, MinIO, …); both fall back to $AWS_REGION / $AWS_ENDPOINT_URL lftp:name/subpath whatever the lftp bookmark `name` points at, with the subpath appended FTP and SFTP URLs given without credentials look them up in the lftp bookmarks file ($LFTP_HOME/bookmarks, ~/.lftp/bookmarks, or ~/.local/share/lftp/bookmarks), matched by scheme, host, and port. With nothing found anywhere, FTP logs in as `anonymous` with an empty password; write ftp://anonymous@host/… to force that even when a bookmark holds named credentials for the host. Environment: BEEPING_PASSWORD repository passphrase (prompted if unset) BEEPING_FTP_PASSWORD FTP password, when URL and bookmarks have none BEEPING_SSH_KEY private key file tried first for SFTP BEEPING_SSH_PASSWORD SFTP password authentication fallback BEEPING_SSH_KNOWN_HOSTS known_hosts file (default ~/.ssh/known_hosts) AWS_* standard S3 credential and region sources The passphrase is the encryption key: it is never stored, it cannot be changed, and losing it makes the backups unrecoverable."; /// An encrypted, deduplicating backup tool that can be suspended and /// resumed at any time — even if the filesystem changes in between. /// /// Every backup and restore can be interrupted — by a keypress, a /// signal, or an outright kill — and resumed by rerunning the same /// command. #[derive(Debug, Parser)] #[command(name = "beeping", version, after_long_help = CONFIGURATION_HELP)] pub struct Cli { #[command(subcommand)] pub command: Command, } #[derive(Debug, Subcommand)] pub enum Command { /// Create a new repository. Init(RepoArgs), /// Back up a directory tree into the repository. /// /// If a previous backup of the same tree was suspended or killed, /// this resumes it. Backup(BackupArgs), /// Restore a snapshot into a directory. /// /// If a previous restore of the same snapshot was suspended or /// killed, this resumes it. Restore(RestoreArgs), /// List the snapshots in the repository. Snapshots(RepoArgs), /// List the files, directories, and symlinks inside a snapshot. List(ListArgs), /// Remove snapshots, and with them the chunks nothing else needs. /// /// Refuses while a backup holds the repository, since a running or /// killed backup has chunks that no snapshot names yet. Prune(PruneArgs), /// Remove files and directories from every snapshot of one tree. /// /// For what should never have been backed up, and for getting the /// room it takes back. Each snapshot of the tree is rewritten /// without the selected paths, and whatever chunks that leaves /// unreferenced are swept. This cannot be undone: --dry-run first. Excise(ExciseArgs), } #[derive(Debug, Args)] pub struct ExciseArgs { #[command(flatten)] pub repo: RepoArgs, /// The backup root whose snapshots to edit (as printed by /// `beeping snapshots`). pub root: PathBuf, /// Patterns (relative to ROOT) selecting what to remove; the same /// rules as exclude patterns, so selecting a directory selects /// everything beneath it. #[arg(required = true, value_name = "PATTERN")] pub select: Vec, /// Report what would be removed without removing anything. #[arg(long)] pub dry_run: bool, /// Where a suspended backup of ROOT keeps its working state /// (defaults to the same per-backup directory that backup uses). #[arg(long)] pub state_dir: Option, /// Go ahead even though a backup holds the repository. /// /// Only for a run that is genuinely gone. A run whose working state /// is still here is refused whatever this says, since what it has /// recorded cannot be swept without breaking it. #[arg(long)] pub break_lock: bool, } #[derive(Debug, Args)] pub struct PruneArgs { #[command(flatten)] pub repo: RepoArgs, /// The snapshots to remove (as printed by `beeping snapshots`). #[arg(required = true)] pub snapshots: Vec, /// Report what would be removed without removing anything. #[arg(long)] pub dry_run: bool, /// Prune even though a backup holds the repository. /// /// Only for a run that is genuinely gone. The run it belonged to /// will notice on resuming and redo whatever it had recorded but not /// yet published. #[arg(long)] pub break_lock: bool, } #[derive(Debug, Args)] pub struct RepoArgs { /// Repository location (overrides the config file). #[arg(long, short)] pub repository: Option, } #[derive(Debug, Args)] pub struct BackupArgs { #[command(flatten)] pub repo: RepoArgs, /// The directory to back up. pub root: PathBuf, /// Glob patterns (relative to ROOT) to exclude; adds to the config /// file's list. #[arg(long, short)] pub exclude: Vec, /// Descend into directories on other filesystems. Without this, /// mount points are recorded but never entered. #[arg(long)] pub cross_mount_points: bool, /// Leave at least this much room on the store (overrides the config /// file). Once it has less than this left, the backup finishes /// early: what it has stored is published as its snapshot and the /// run ends. Sizes count in powers of 1024, so 20G is 20 GiB. #[arg(long, value_name = "SIZE", value_parser = crate::config::parse_size)] pub minimum_free_space: Option, /// Keep the repository under this size (overrides the config file). /// The same limit from the other end, for a store that can say what /// it holds but not what it has left. #[arg(long, value_name = "SIZE", value_parser = crate::config::parse_size)] pub maximum_store_size: Option, /// Fill the store: ignore both the free-space floor and the maximum /// repository size, wherever they are set. /// /// For the run that follows one which finished early, whose store is /// still at the limit that stopped it and which has almost nothing /// left to write. #[arg(long)] pub ignore_store_limits: bool, /// Publish what has been backed up so far and stop, as if the files /// left to scan and chunk did not exist. /// /// This ends the run: its working state is removed, so running the /// command again starts a fresh backup rather than resuming. #[arg(long)] pub early_finish: bool, /// Where to keep the suspendable working state (defaults to a /// per-backup directory under the user state directory). #[arg(long)] pub state_dir: Option, /// Line-oriented progress output instead of the full-screen UI. #[arg(long)] pub plain: bool, /// Threads cataloguing directories. #[arg(long, value_name = "N")] pub scanner_threads: Option, /// Threads reading and sealing file content. Defaults to the /// machine's parallelism. #[arg(long, value_name = "N")] pub chunker_threads: Option, /// Threads writing to the repository — in effect, how many /// concurrent transfers a remote backend sees. #[arg(long, value_name = "N")] pub uploader_threads: Option, /// How many large files may be read at once. Concurrency buys /// nothing once the backend is the bottleneck, and every part-read /// file is work that suspending throws away, so the default keeps /// only one going per eight chunker threads. #[arg(long, value_name = "N")] pub concurrent_large_files: Option, /// The size, in MiB, at which a file counts as large and is read /// under that limit. #[arg(long, value_name = "MIB")] pub large_file_threshold: Option, /// Ask the repository about every chunk instead of remembering /// which ones it already holds. /// /// The cache lives under the user cache directory, one file per /// repository, and costs 32 bytes per stored chunk in memory and on /// disk. Deleting it is always safe; it is only ever a way to avoid /// round trips. #[arg(long)] pub no_chunk_cache: bool, } #[derive(Debug, Args)] pub struct ListArgs { #[command(flatten)] pub repo: RepoArgs, /// The snapshot to list (as printed by `beeping snapshots`). pub snapshot: String, } #[derive(Debug, Args)] pub struct RestoreArgs { #[command(flatten)] pub repo: RepoArgs, /// The snapshot to restore (as printed by `beeping snapshots`). pub snapshot: String, /// The directory to restore into; created if absent, expected to be /// fresh. pub target: PathBuf, /// Restore only entries matching these patterns (same rules as /// exclude patterns; selecting a directory restores its whole /// subtree, and parent directories come along with their /// metadata). With none given, the entire snapshot is restored. #[arg(value_name = "PATTERN")] pub select: Vec, /// Where to keep the suspendable working state (defaults to a /// per-restore directory under the user state directory). #[arg(long)] pub state_dir: Option, /// Line-oriented progress output instead of the full-screen UI. #[arg(long)] pub plain: bool, }