├── coverage └── .gitkeep ├── tests ├── generated │ └── .gitkeep ├── repository-fixtures │ ├── COPY.tpl │ ├── restic-repo.tar.gz │ ├── rustic-repo.tar.gz │ ├── src-snapshot.tar.gz │ ├── rustic-copy-repo.tar.gz │ └── README.md ├── snapshots │ ├── hooks__backup_hooks_failure.snap │ ├── hooks__backup_hooks_success.snap │ ├── hooks__global_hooks_success.snap │ ├── hooks__completions_hooks_access_success.snap │ ├── hooks__self-update_hooks_access_success.snap │ ├── hooks__show-config_hooks_access_success.snap │ ├── hooks__repository_hooks_success.snap │ ├── hooks__cat_hooks_access_success.snap │ ├── hooks__ls_hooks_access_success.snap │ ├── hooks__tag_hooks_access_success.snap │ ├── hooks__check_hooks_access_success.snap │ ├── hooks__config_hooks_access_success.snap │ ├── hooks__dump_hooks_access_success.snap │ ├── hooks__find_hooks_access_success.snap │ ├── hooks__forget_hooks_access_success.snap │ ├── hooks__list_hooks_access_success.snap │ ├── hooks__merge_hooks_access_success.snap │ ├── hooks__prune_hooks_access_success.snap │ ├── hooks__repair_hooks_access_success.snap │ ├── hooks__repoinfo_hooks_access_success.snap │ ├── hooks__restore_hooks_access_success.snap │ ├── hooks__check_not_backup_hooks_success.snap │ ├── hooks__full_hooks_before_repo_failure.snap │ ├── hooks__snapshots_hooks_access_success.snap │ ├── hooks__full_hooks_success.snap │ ├── hooks__backup_hooks_access_success.snap │ ├── hooks__full_hooks_before_backup_failure.snap │ └── show_config__show_config_passes.snap ├── hooks-fixtures │ ├── empty_hooks_success.toml │ ├── backup_hooks_failure.toml │ ├── backup_hooks_success.toml │ ├── global_hooks_success.toml │ ├── repository_hooks_success.toml │ ├── commands_hooks_access_success.tpl │ ├── full_hooks_success.toml │ ├── check_not_backup_hooks_success.toml │ ├── full_hooks_before_repo_failure.toml │ └── full_hooks_before_backup_failure.toml ├── config.rs ├── show-config.rs ├── completions.rs ├── backup_restore.rs └── repositories.rs ├── typos.toml ├── config ├── simple.toml ├── services │ ├── sftp_hetzner_sbox.toml │ ├── s3_idrive.toml │ ├── sftp.toml │ ├── webdav_owncloud_nextcloud.toml │ ├── s3_aws.toml │ ├── b2.toml │ └── rclone_ovh-hot-cold.toml ├── copy_example.toml ├── par2.toml ├── local.toml ├── hooks.toml └── rustic.toml ├── scripts └── build.sh ├── docs └── Readme.md ├── .cargo ├── config.toml └── audit.toml ├── .github ├── renovate.json └── workflows │ ├── triage.yml │ ├── lint-docs.yml │ ├── release-image.yml │ ├── release-ci.yml │ ├── release-plz.yml │ ├── audit.yml │ ├── compat.yml │ ├── cross-ci.yml │ ├── prebuilt-pr.yml │ ├── ci.yml │ └── release-cd.yml ├── util └── systemd │ ├── rustic-forget@.service │ ├── rustic-backup@.service │ ├── rustic-backup@.timer │ └── rustic-forget@.timer ├── platform-settings.toml ├── .gitignore ├── CONTRIBUTING.md ├── src ├── error.rs ├── metrics.rs ├── bin │ └── rustic.rs ├── commands │ ├── show_config.rs │ ├── tui │ │ ├── widgets │ │ │ ├── sized_paragraph.rs │ │ │ ├── sized_gauge.rs │ │ │ ├── prompt.rs │ │ │ ├── popup.rs │ │ │ ├── with_block.rs │ │ │ ├── sized_table.rs │ │ │ └── text_input.rs │ │ ├── widgets.rs │ │ ├── tree.rs │ │ ├── summary.rs │ │ ├── progress.rs │ │ └── restore.rs │ ├── config.rs │ ├── check.rs │ ├── tag.rs │ ├── completions.rs │ ├── docs.rs │ ├── self_update.rs │ ├── repair.rs │ ├── cat.rs │ ├── merge.rs │ ├── tui.rs │ ├── restore.rs │ ├── list.rs │ ├── init.rs │ ├── prune.rs │ ├── find.rs │ ├── webdav.rs │ └── copy.rs ├── helpers.rs ├── snapshots │ ├── rustic_rs__config__tests__default_config_display_passes.snap │ ├── rustic_rs__config__tests__global_env_roundtrip_passes-2.snap │ └── rustic_rs__config__tests__global_env_roundtrip_passes.snap ├── lib.rs ├── metrics │ ├── opentelemetry.rs │ └── prometheus.rs ├── config │ ├── hooks.rs │ └── progress_options.rs └── application.rs ├── release-plz.toml ├── Dockerfile ├── dprint.json ├── LICENSE-MIT ├── ECOSYSTEM.md └── cliff.toml /coverage/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/generated/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /typos.toml: -------------------------------------------------------------------------------- 1 | [default.extend-words] 2 | ratatui = "ratatui" 3 | tpe = "tpe" 4 | -------------------------------------------------------------------------------- /config/simple.toml: -------------------------------------------------------------------------------- 1 | [repository] 2 | repository = "/tmp/repo" 3 | password = "test" 4 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | PROJECT_VERSION=$(git describe --tags) cargo build -r $@ 3 | -------------------------------------------------------------------------------- /docs/Readme.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | Our documentation can be found at: 4 | -------------------------------------------------------------------------------- /tests/repository-fixtures/COPY.tpl: -------------------------------------------------------------------------------- 1 | [repository] 2 | repository = "${{REPOSITORY}}" 3 | password = "${{PASSWORD}}" 4 | -------------------------------------------------------------------------------- /tests/repository-fixtures/restic-repo.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rustic-rs/rustic/HEAD/tests/repository-fixtures/restic-repo.tar.gz -------------------------------------------------------------------------------- /tests/repository-fixtures/rustic-repo.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rustic-rs/rustic/HEAD/tests/repository-fixtures/rustic-repo.tar.gz -------------------------------------------------------------------------------- /tests/repository-fixtures/src-snapshot.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rustic-rs/rustic/HEAD/tests/repository-fixtures/src-snapshot.tar.gz -------------------------------------------------------------------------------- /tests/repository-fixtures/rustic-copy-repo.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rustic-rs/rustic/HEAD/tests/repository-fixtures/rustic-copy-repo.tar.gz -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | rustdocflags = ["--document-private-items"] 3 | # rustflags = "-C target-cpu=native -D warnings" 4 | # incremental = true 5 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["local>rustic-rs/.github:renovate-config"] 4 | } 5 | -------------------------------------------------------------------------------- /util/systemd/rustic-forget@.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=rustic --use-profile %I forget 3 | 4 | [Service] 5 | KillSignal=SIGINT 6 | ExecStart=/usr/bin/rustic --use-profile %I forget 7 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__backup_hooks_failure.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running backup hooks before\nRunning backup hooks failed\nRunning backup hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__backup_hooks_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running backup hooks before\nRunning backup hooks after\nRunning backup hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__global_hooks_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__completions_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__self-update_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__show-config_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__repository_hooks_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running repository hooks before\nRunning repository hooks after\nRunning repository hooks finally\n" 6 | -------------------------------------------------------------------------------- /util/systemd/rustic-backup@.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=rustic --use-profile %I backup 3 | 4 | [Service] 5 | Nice=19 6 | IOSchedulingClass=idle 7 | KillSignal=SIGINT 8 | ExecStart=/usr/bin/rustic --use-profile %I backup 9 | -------------------------------------------------------------------------------- /config/services/sftp_hetzner_sbox.toml: -------------------------------------------------------------------------------- 1 | [repository] 2 | password = "XXXXXX" 3 | repository = "opendal:sftp" 4 | 5 | [repository.options] 6 | endpoint = "ssh://XXXXX.your-storagebox.de:23" 7 | user = "XXXXX" 8 | key = "/root/.ssh/id_XXXXX_ed25519" 9 | -------------------------------------------------------------------------------- /platform-settings.toml: -------------------------------------------------------------------------------- 1 | [platforms.defaults] 2 | release-features = [ 3 | "release", 4 | ] 5 | 6 | # Check if 'build-dependencies.just' needs to be updated 7 | [platforms.x86_64-unknown-linux-gnu] 8 | additional-features = [ 9 | "mount", 10 | ] 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | .vscode 4 | mutants.out 5 | cargo-test* 6 | coverage/*lcov 7 | .testscompletions-* 8 | .DS_Store 9 | 10 | # Ignore generated test files 11 | /tests/generated/*.toml 12 | /tests/generated/*.log 13 | /tests/generated/test-restore 14 | -------------------------------------------------------------------------------- /util/systemd/rustic-backup@.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Daily rustic --use-profile %I backup 3 | Wants=rustic-forget@%i.timer 4 | 5 | [Timer] 6 | OnCalendar=daily 7 | AccuracySec=1m 8 | RandomizedDelaySec=1h 9 | Persistent=true 10 | 11 | [Install] 12 | WantedBy=timers.target 13 | -------------------------------------------------------------------------------- /util/systemd/rustic-forget@.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Monthly rustic --use-profile %I forget 3 | PartOf=rustic-backup@%i.timer 4 | 5 | [Timer] 6 | OnCalendar=monthly 7 | AccuracySec=1m 8 | RandomizedDelaySec=1h 9 | Persistent=true 10 | 11 | [Install] 12 | WantedBy=timers.target 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `rustic` 2 | 3 | Thank you for your interest in contributing to `rustic`! 4 | 5 | We appreciate your help in making this project better. 6 | 7 | Please read the 8 | [contribution guide](https://rustic.cli.rs/docs/contributing-to-rustic.html) to 9 | get started. 10 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__cat_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning repository hooks after\nRunning repository hooks finally\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__ls_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning repository hooks after\nRunning repository hooks finally\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__tag_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning repository hooks after\nRunning repository hooks finally\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__check_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning repository hooks after\nRunning repository hooks finally\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__config_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning repository hooks after\nRunning repository hooks finally\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__dump_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning repository hooks after\nRunning repository hooks finally\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__find_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning repository hooks after\nRunning repository hooks finally\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__forget_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning repository hooks after\nRunning repository hooks finally\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__list_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning repository hooks after\nRunning repository hooks finally\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__merge_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning repository hooks after\nRunning repository hooks finally\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__prune_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning repository hooks after\nRunning repository hooks finally\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__repair_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning repository hooks after\nRunning repository hooks finally\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__repoinfo_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning repository hooks after\nRunning repository hooks finally\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__restore_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning repository hooks after\nRunning repository hooks finally\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__check_not_backup_hooks_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning repository hooks after\nRunning repository hooks finally\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__full_hooks_before_repo_failure.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning repository hooks failed\nRunning repository hooks finally\nRunning global hooks failed\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__snapshots_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning repository hooks after\nRunning repository hooks finally\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /.cargo/audit.toml: -------------------------------------------------------------------------------- 1 | [advisories] 2 | ignore = [ 3 | # FIXME!: See https://github.com/RustCrypto/RSA/issues/19#issuecomment-1822995643. 4 | # There is no workaround available yet. 5 | "RUSTSEC-2023-0071", 6 | # FIXME!: Will be fixed when using ratatui>=0.30 which no longer depends on paste 7 | "RUSTSEC-2024-0436", 8 | ] 9 | -------------------------------------------------------------------------------- /tests/hooks-fixtures/empty_hooks_success.toml: -------------------------------------------------------------------------------- 1 | [global.hooks] 2 | run-before = [] 3 | run-after = [] 4 | run-failed = [] 5 | run-finally = [] 6 | 7 | [repository.hooks] 8 | run-before = [] 9 | run-after = [] 10 | run-failed = [] 11 | run-finally = [] 12 | 13 | [backup.hooks] 14 | run-before = [] 15 | run-after = [] 16 | run-failed = [] 17 | run-finally = [] 18 | -------------------------------------------------------------------------------- /config/services/s3_idrive.toml: -------------------------------------------------------------------------------- 1 | [repository] 2 | repository = "opendal:s3" 3 | password = "password" 4 | 5 | [repository.options] 6 | root = "/" 7 | bucket = "bucket_name" 8 | endpoint = "https://p7v1.ldn.idrivee2-40.com" 9 | region = "auto" # Explicit region is better, else requests are delayed to determine correct region. 10 | access_key_id = "xxx" 11 | secret_access_key = "xxx" 12 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__full_hooks_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning backup hooks before\nRunning backup hooks after\nRunning backup hooks finally\nRunning repository hooks after\nRunning repository hooks finally\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__backup_hooks_access_success.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning backup hooks before\nRunning backup hooks after\nRunning backup hooks finally\nRunning repository hooks after\nRunning repository hooks finally\nRunning global hooks after\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /tests/snapshots/hooks__full_hooks_before_backup_failure.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/hooks.rs 3 | expression: log_live 4 | --- 5 | "Running global hooks before\nRunning repository hooks before\nRunning backup hooks before\nRunning backup hooks failed\nRunning backup hooks finally\nRunning repository hooks failed\nRunning repository hooks finally\nRunning global hooks failed\nRunning global hooks finally\n" 6 | -------------------------------------------------------------------------------- /config/services/sftp.toml: -------------------------------------------------------------------------------- 1 | # rustic config file to use sftp storage 2 | # Note: 3 | # - currently sftp only works on unix 4 | # - Using sftp with password is not supported yet, use key authentication, e.g. use 5 | # ssh-copy-id user@host 6 | [repository] 7 | repository = "opendal:sftp" 8 | password = "mypassword" 9 | 10 | [repository.options] 11 | user = "myuser" 12 | endpoint = "host:port" 13 | root = "path/to/repo" 14 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | //! Error types 2 | 3 | #[cfg(feature = "rhai")] 4 | use rhai::EvalAltResult; 5 | #[cfg(feature = "rhai")] 6 | use thiserror::Error; 7 | 8 | /// Kinds of [`rhai`] errors 9 | #[cfg(feature = "rhai")] 10 | #[derive(Debug, Error)] 11 | pub(crate) enum RhaiErrorKinds { 12 | #[error(transparent)] 13 | RhaiParse(#[from] rhai::ParseError), 14 | #[error(transparent)] 15 | RhaiEval(#[from] Box), 16 | } 17 | -------------------------------------------------------------------------------- /config/services/webdav_owncloud_nextcloud.toml: -------------------------------------------------------------------------------- 1 | [repository] 2 | repository = "opendal:webdav" 3 | password = "my-backup-password" 4 | 5 | [repository.options] 6 | endpoint = "https://my-owncloud-or-nextcloud-server.com" 7 | # root = "remote.php/webdav/my-folder" # for owncloud 8 | # root = "remote.php/dav/files/" # for nextcloud 9 | username = "user" 10 | # In `Settings -> Security -> App passwords / tokens` you should create a token to be used here. 11 | password = "token" 12 | -------------------------------------------------------------------------------- /src/metrics.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | pub enum MetricValue { 4 | Int(u64), 5 | Float(f64), 6 | } 7 | 8 | pub struct Metric { 9 | pub name: &'static str, 10 | pub description: &'static str, 11 | pub value: MetricValue, 12 | } 13 | 14 | pub trait MetricsExporter { 15 | fn push_metrics(&self, metrics: &[Metric]) -> Result<()>; 16 | } 17 | 18 | #[cfg(feature = "prometheus")] 19 | pub mod prometheus; 20 | 21 | #[cfg(feature = "opentelemetry")] 22 | pub mod opentelemetry; 23 | -------------------------------------------------------------------------------- /tests/config.rs: -------------------------------------------------------------------------------- 1 | //! Configuration file tests 2 | 3 | use anyhow::Result; 4 | use rstest::*; 5 | use rustic_rs::RusticConfig; 6 | use std::{fs, path::PathBuf}; 7 | 8 | /// Ensure all `configs` parse as a valid config files 9 | #[rstest] 10 | fn test_parse_rustic_configs_is_ok( 11 | #[files("config/**/*.toml")] config_path: PathBuf, 12 | ) -> Result<()> { 13 | let toml_string = fs::read_to_string(config_path)?; 14 | let _ = toml::from_str::(&toml_string)?; 15 | 16 | Ok(()) 17 | } 18 | -------------------------------------------------------------------------------- /release-plz.toml: -------------------------------------------------------------------------------- 1 | # configuration spec can be found here https://release-plz.ieni.dev/docs/config 2 | 3 | [workspace] 4 | git_release_enable = false # we currently use our own release process 5 | pr_draft = true 6 | # dependencies_update = true # We don't want to update dependencies automatically, as currently our dependencies tree is broken somewhere 7 | # changelog_config = "cliff.toml" # Don't use this for now, as it will override the default changelog config 8 | 9 | [changelog] 10 | protect_breaking_commits = true 11 | -------------------------------------------------------------------------------- /tests/hooks-fixtures/backup_hooks_failure.toml: -------------------------------------------------------------------------------- 1 | [backup.hooks] 2 | run-before = [ 3 | "sh -c 'echo Running backup hooks before > tests/generated/backup_hooks_failure.log'", 4 | "false", 5 | "sh -c 'echo MUST NOT SHOW UP >> tests/generated/backup_hooks_failure.log'", 6 | ] 7 | run-failed = [ 8 | "sh -c 'echo Running backup hooks failed >> tests/generated/backup_hooks_failure.log'", 9 | ] 10 | run-finally = [ 11 | "sh -c 'echo Running backup hooks finally >> tests/generated/backup_hooks_failure.log'", 12 | ] 13 | -------------------------------------------------------------------------------- /tests/hooks-fixtures/backup_hooks_success.toml: -------------------------------------------------------------------------------- 1 | [backup.hooks] 2 | run-before = [ 3 | "sh -c 'echo Running backup hooks before > tests/generated/backup_hooks_success.log'", 4 | ] 5 | run-after = [ 6 | "sh -c 'echo Running backup hooks after >> tests/generated/backup_hooks_success.log'", 7 | ] 8 | run-failed = [ 9 | "sh -c 'echo Running backup hooks failed >> tests/generated/backup_hooks_success.log'", 10 | ] 11 | run-finally = [ 12 | "sh -c 'echo Running backup hooks finally >> tests/generated/backup_hooks_success.log'", 13 | ] 14 | -------------------------------------------------------------------------------- /tests/hooks-fixtures/global_hooks_success.toml: -------------------------------------------------------------------------------- 1 | [global.hooks] 2 | run-before = [ 3 | "sh -c 'echo Running global hooks before > tests/generated/global_hooks_success.log'", 4 | ] 5 | run-after = [ 6 | "sh -c 'echo Running global hooks after >> tests/generated/global_hooks_success.log'", 7 | ] 8 | run-failed = [ 9 | "sh -c 'echo Running global hooks failed >> tests/generated/global_hooks_success.log'", 10 | ] 11 | run-finally = [ 12 | "sh -c 'echo Running global hooks finally >> tests/generated/global_hooks_success.log'", 13 | ] 14 | -------------------------------------------------------------------------------- /tests/hooks-fixtures/repository_hooks_success.toml: -------------------------------------------------------------------------------- 1 | [repository.hooks] 2 | run-before = [ 3 | "sh -c 'echo Running repository hooks before > tests/generated/repository_hooks_success.log'", 4 | ] 5 | run-after = [ 6 | "sh -c 'echo Running repository hooks after >> tests/generated/repository_hooks_success.log'", 7 | ] 8 | run-failed = [ 9 | "sh -c 'echo Running repository hooks failed >> tests/generated/repository_hooks_success.log'", 10 | ] 11 | run-finally = [ 12 | "sh -c 'echo Running repository hooks finally >> tests/generated/repository_hooks_success.log'", 13 | ] 14 | -------------------------------------------------------------------------------- /.github/workflows/triage.yml: -------------------------------------------------------------------------------- 1 | on: 2 | issues: 3 | types: 4 | - opened 5 | 6 | jobs: 7 | label_issue: 8 | if: ${{ github.repository_owner == 'rustic-rs' }} 9 | name: Label issue 10 | runs-on: ubuntu-latest 11 | steps: 12 | - env: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | ISSUE_URL: ${{ github.event.issue.html_url }} 15 | run: | 16 | # check if issue doesn't have any labels 17 | if [[ $(gh issue view $ISSUE_URL --json labels -q '.labels | length') -eq 0 ]]; then 18 | # add S-triage label 19 | gh issue edit $ISSUE_URL --add-label "S-triage" 20 | fi 21 | -------------------------------------------------------------------------------- /src/bin/rustic.rs: -------------------------------------------------------------------------------- 1 | //! Main entry point for Rustic 2 | 3 | #![deny(warnings, missing_docs, trivial_casts, unused_qualifications)] 4 | #![allow(unsafe_code)] 5 | 6 | #[cfg(all(feature = "mimalloc", feature = "jemallocator"))] 7 | compile_error!( 8 | "feature \"mimalloc\" and feature \"jemallocator\" cannot be enabled at the same time. Please disable one of them." 9 | ); 10 | 11 | #[cfg(feature = "mimalloc")] 12 | use mimalloc::MiMalloc; 13 | 14 | #[cfg(feature = "mimalloc")] 15 | #[global_allocator] 16 | static GLOBAL: MiMalloc = MiMalloc; 17 | 18 | use rustic_rs::application::RUSTIC_APP; 19 | 20 | /// Boot Rustic 21 | fn main() { 22 | abscissa_core::boot(&RUSTIC_APP); 23 | } 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine AS builder 2 | ARG RUSTIC_VERSION 3 | ARG TARGETARCH 4 | RUN if [ "$TARGETARCH" = "amd64" ]; then \ 5 | ASSET="rustic-${RUSTIC_VERSION}-x86_64-unknown-linux-musl.tar.gz";\ 6 | elif [ "$TARGETARCH" = "arm64" ]; then \ 7 | ASSET="rustic-${RUSTIC_VERSION}-aarch64-unknown-linux-musl.tar.gz"; \ 8 | fi; \ 9 | wget https://github.com/rustic-rs/rustic/releases/download/${RUSTIC_VERSION}/${ASSET} && \ 10 | tar -xzf ${ASSET} && \ 11 | mkdir /etc_files && \ 12 | touch /etc_files/passwd && \ 13 | touch /etc_files/group 14 | 15 | FROM scratch 16 | COPY --from=builder /rustic / 17 | COPY --from=builder /etc_files/ /etc/ 18 | ENTRYPOINT ["/rustic"] 19 | -------------------------------------------------------------------------------- /config/copy_example.toml: -------------------------------------------------------------------------------- 1 | # This is an example how to configure the copy command to copy snapshots from one repository to another 2 | # The targets of the copy command cannot be specified on the command line, but must be in a config file like this. 3 | # If the config file is named "copy_example.toml", run "rustic -P copy_example copy" to copy all snapshots. 4 | # See "rustic copy --help" for options how to select or filter snapshots to copy. 5 | 6 | # [repository] specified the source repository 7 | [repository] 8 | repository = "/tmp/repo" 9 | password = "test" 10 | 11 | # you can specify multiple targets. Note that each target must be configured via a config profile file 12 | [copy] 13 | targets = ["full", "rustic"] 14 | -------------------------------------------------------------------------------- /config/services/s3_aws.toml: -------------------------------------------------------------------------------- 1 | # rustic config file to use s3 storage 2 | # Note that this internally uses opendal S3 service, see https://opendal.apache.org/docs/rust/opendal/services/struct.S3.html 3 | # where endpoint, bucket and root are extracted from the repository URL. 4 | [repository] 5 | repository = "opendal:s3" 6 | password = "password" 7 | 8 | # Other options can be given here - note that opendal also support reading config from env files or AWS config dirs, see the opendal S3 docu 9 | [repository.options] 10 | access_key_id = "xxx" # this can be omitted, when AWS config is used 11 | secret_access_key = "xxx" # this can be omitted, when AWS config is used 12 | bucket = "bucket_name" 13 | root = "/path/to/repo" 14 | -------------------------------------------------------------------------------- /dprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "lineWidth": 80, 3 | "markdown": { 4 | "lineWidth": 80, 5 | "emphasisKind": "asterisks", 6 | "strongKind": "asterisks", 7 | "textWrap": "always" 8 | }, 9 | "toml": { 10 | "lineWidth": 80 11 | }, 12 | "json": { 13 | "lineWidth": 80, 14 | "indentWidth": 4 15 | }, 16 | "includes": [ 17 | "**/*.{md}", 18 | "**/*.{toml}", 19 | "**/*.{json}" 20 | ], 21 | "excludes": [ 22 | "target/**/*", 23 | "CHANGELOG.md" 24 | ], 25 | "plugins": [ 26 | "https://plugins.dprint.dev/markdown-0.17.8.wasm", 27 | "https://plugins.dprint.dev/toml-0.6.3.wasm", 28 | "https://plugins.dprint.dev/json-0.19.3.wasm" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /config/par2.toml: -------------------------------------------------------------------------------- 1 | # This is an example how to use the post-create-command and post-delete-command hooks to add 2 | # error correction files using par2create to a local repository. 3 | # The commands can use the variable %file, %type and %id which are replaced by the filename, the 4 | # file type and the file id before calling the command. 5 | [repository] 6 | repository = "/tmp/repo" 7 | password = "test" 8 | 9 | [repository.options] 10 | # after saving a file in the repo, this command is called 11 | post-create-command = "par2create -qq -n1 -r5 %file" 12 | 13 | # after removing a file from the repo, this command is called. 14 | # Note that we want to use a "*" in the rm command, hence we have to call sh to resolve the wildcard! 15 | post-delete-command = "sh -c \"rm -f %file*.par2\"" 16 | -------------------------------------------------------------------------------- /config/services/b2.toml: -------------------------------------------------------------------------------- 1 | # rustic config file to use B2 storage via Apache OpenDAL 2 | [repository] 3 | repository = "opendal:b2" # just specify the opendal service here 4 | password = "" 5 | # or 6 | # password-file = "/home//etc/secure/rustic_passwd" 7 | 8 | # B2 specific options 9 | [repository.options] 10 | # Here, we give the required b2 options, see https://opendal.apache.org/docs/rust/opendal/services/struct.B2.html 11 | application_key_id = "my_id" # B2 application key ID 12 | application_key = "my_key" # B2 application key secret. Can be also set using OPENDAL_APPLICATION_KEY 13 | bucket = "bucket_name" # B2 bucket name 14 | bucket_id = "bucket_id" # B2 bucket ID 15 | # root = "/" # Set a repository root directory if not using the root directory of the bucket 16 | -------------------------------------------------------------------------------- /config/local.toml: -------------------------------------------------------------------------------- 1 | # rustic config file to backup /home, /etc and /root to a local repository 2 | # 3 | # backup usage: "rustic -P local backup 4 | # cleanup: "rustic -P local forget --prune 5 | # 6 | [repository] 7 | repository = "/backup/rustic" 8 | password-file = "/root/key-rustic" 9 | no-cache = true # no cache needed for local repository 10 | 11 | [forget] 12 | keep-hourly = 20 13 | keep-daily = 14 14 | keep-weekly = 8 15 | keep-monthly = 24 16 | keep-yearly = 10 17 | 18 | [backup] 19 | exclude-if-present = [".nobackup", "CACHEDIR.TAG"] 20 | glob-files = ["/root/rustic-local.glob"] 21 | one-file-system = true 22 | 23 | [[backup.snapshots]] 24 | sources = ["/home"] 25 | git-ignore = true 26 | 27 | [[backup.snapshots]] 28 | sources = ["/etc"] 29 | 30 | [[backup.snapshots]] 31 | sources = ["/root"] 32 | -------------------------------------------------------------------------------- /src/commands/show_config.rs: -------------------------------------------------------------------------------- 1 | //! `show-config` subcommand 2 | 3 | use crate::{Application, RUSTIC_APP, status_err}; 4 | 5 | use abscissa_core::{Command, Runnable, Shutdown}; 6 | use anyhow::Result; 7 | use toml::to_string_pretty; 8 | 9 | /// `show-config` subcommand 10 | #[derive(clap::Parser, Command, Debug)] 11 | pub(crate) struct ShowConfigCmd {} 12 | 13 | impl Runnable for ShowConfigCmd { 14 | fn run(&self) { 15 | if let Err(err) = self.inner_run() { 16 | status_err!("{}", err); 17 | RUSTIC_APP.shutdown(Shutdown::Crash); 18 | }; 19 | } 20 | } 21 | 22 | impl ShowConfigCmd { 23 | fn inner_run(&self) -> Result<()> { 24 | let config = to_string_pretty(RUSTIC_APP.config().as_ref())?; 25 | println!("{config}"); 26 | Ok(()) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/lint-docs.yml: -------------------------------------------------------------------------------- 1 | name: Lint Markdown / Toml 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | merge_group: 8 | types: [checks_requested] 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | style: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 19 | 20 | - uses: dprint/check@9cb3a2b17a8e606d37aae341e49df3654933fc23 # v2.3 21 | 22 | result: 23 | name: Result (Style) 24 | runs-on: ubuntu-latest 25 | needs: 26 | - style 27 | steps: 28 | - name: Mark the job as successful 29 | run: exit 0 30 | if: success() 31 | - name: Mark the job as unsuccessful 32 | run: exit 1 33 | if: "!success()" 34 | -------------------------------------------------------------------------------- /src/commands/tui/widgets/sized_paragraph.rs: -------------------------------------------------------------------------------- 1 | use super::{Draw, Frame, Paragraph, Rect, SizedWidget, Text}; 2 | 3 | pub struct SizedParagraph { 4 | p: Paragraph<'static>, 5 | height: Option, 6 | width: Option, 7 | } 8 | 9 | impl SizedParagraph { 10 | pub fn new(text: Text<'static>) -> Self { 11 | let height = text.height().try_into().ok(); 12 | let width = text.width().try_into().ok(); 13 | let p = Paragraph::new(text); 14 | Self { p, height, width } 15 | } 16 | } 17 | 18 | impl SizedWidget for SizedParagraph { 19 | fn width(&self) -> Option { 20 | self.width 21 | } 22 | fn height(&self) -> Option { 23 | self.height 24 | } 25 | } 26 | 27 | impl Draw for SizedParagraph { 28 | fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { 29 | f.render_widget(&self.p, area); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/release-image.yml: -------------------------------------------------------------------------------- 1 | name: Release Docker Image 2 | 3 | on: [release] 4 | 5 | jobs: 6 | docker: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Set up Docker Buildx 10 | uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 11 | 12 | - name: Login to Docker Hub 13 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 14 | with: 15 | registry: ghcr.io 16 | username: ${{ github.actor }} 17 | password: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | - name: Build and push 20 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 21 | with: 22 | push: true 23 | platforms: linux/amd64,linux/arm64 24 | tags: ghcr.io/rustic-rs/rustic:latest,ghcr.io/rustic-rs/rustic:${{ github.ref_name }} 25 | build-args: RUSTIC_VERSION=${{ github.ref_name }} 26 | -------------------------------------------------------------------------------- /src/commands/tui/widgets/sized_gauge.rs: -------------------------------------------------------------------------------- 1 | use super::{Color, Draw, Frame, Gauge, Rect, SizedWidget, Span, Style}; 2 | 3 | pub struct SizedGauge { 4 | p: Gauge<'static>, 5 | width: Option, 6 | } 7 | 8 | impl SizedGauge { 9 | pub fn new(text: Span<'static>, ratio: f64) -> Self { 10 | let width = text.width().try_into().ok(); 11 | let p = Gauge::default() 12 | .gauge_style(Style::default().fg(Color::Blue)) 13 | .use_unicode(true) 14 | .label(text) 15 | .ratio(ratio); 16 | Self { p, width } 17 | } 18 | } 19 | 20 | impl SizedWidget for SizedGauge { 21 | fn width(&self) -> Option { 22 | self.width.map(|w| w + 10) 23 | } 24 | fn height(&self) -> Option { 25 | Some(1) 26 | } 27 | } 28 | 29 | impl Draw for SizedGauge { 30 | fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { 31 | f.render_widget(&self.p, area); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/release-ci.yml: -------------------------------------------------------------------------------- 1 | # ! TODO: Is this reasonable? 2 | # name: Check release 3 | 4 | # on: 5 | # workflow_dispatch: 6 | # push: 7 | # branches: 8 | # - "release-plz-**" 9 | 10 | 11 | # concurrency: 12 | # group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 13 | # cancel-in-progress: true 14 | 15 | # jobs: 16 | # breaking-cli: 17 | # name: Check breaking CLI changes 18 | # if: ${{ github.repository_owner == 'rustic-rs' }} 19 | # runs-on: ubuntu-latest 20 | 21 | # steps: 22 | # - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 23 | # - name: Install Rust toolchain 24 | # uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1 25 | # with: 26 | # toolchain: stable 27 | # - uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2 28 | # - name: Run Cargo Test 29 | # run: cargo test -F release -p rustic-rs --test completions -- --ignored 30 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Alexander Weiss 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /config/hooks.toml: -------------------------------------------------------------------------------- 1 | # Hooks configuration 2 | # 3 | # Hooks are commands that are run during certain events in the application lifecycle. 4 | # They can be used to run custom scripts or commands before or after certain actions. 5 | # The hooks are run in the order they are defined in the configuration file. 6 | # The hooks are divided into 4 categories: global, repository, backup, 7 | # and specific backup sources. 8 | # 9 | # You can also read a more detailed explanation of the hooks in the documentation: 10 | # https://rustic.cli.rs/docs/commands/misc/hooks.html 11 | # 12 | # Please make sure to check the in-repository documentation for the config files 13 | # available at: https://github.com/rustic-rs/rustic/blob/main/config/README.md 14 | # 15 | [global.hooks] 16 | run-before = [] 17 | run-after = [] 18 | run-failed = [] 19 | run-finally = [] 20 | 21 | [repository.hooks] 22 | run-before = [] 23 | run-after = [] 24 | run-failed = [] 25 | run-finally = [] 26 | 27 | [backup.hooks] 28 | run-before = [] 29 | run-after = [] 30 | run-failed = [] 31 | run-finally = [] 32 | 33 | [[backup.snapshots]] 34 | sources = [] 35 | hooks = { run-before = [], run-after = [], run-failed = [], run-finally = [] } 36 | -------------------------------------------------------------------------------- /src/commands/tui/widgets/prompt.rs: -------------------------------------------------------------------------------- 1 | use super::{Draw, Event, Frame, KeyCode, KeyEventKind, ProcessEvent, Rect, SizedWidget}; 2 | 3 | pub struct Prompt(pub T); 4 | 5 | pub enum PromptResult { 6 | Ok, 7 | Cancel, 8 | None, 9 | } 10 | 11 | impl SizedWidget for Prompt { 12 | fn height(&self) -> Option { 13 | self.0.height() 14 | } 15 | fn width(&self) -> Option { 16 | self.0.width() 17 | } 18 | } 19 | 20 | impl Draw for Prompt { 21 | fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { 22 | self.0.draw(area, f); 23 | } 24 | } 25 | 26 | impl ProcessEvent for Prompt { 27 | type Result = PromptResult; 28 | fn input(&mut self, event: Event) -> PromptResult { 29 | use KeyCode::{Char, Enter, Esc}; 30 | match event { 31 | Event::Key(key) if key.kind == KeyEventKind::Press => match key.code { 32 | Char('q' | 'n' | 'c') | Esc => PromptResult::Cancel, 33 | Enter | Char('y' | 'j' | ' ') => PromptResult::Ok, 34 | _ => PromptResult::None, 35 | }, 36 | _ => PromptResult::None, 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /config/services/rclone_ovh-hot-cold.toml: -------------------------------------------------------------------------------- 1 | # rustic config file to backup /home, /etc and /root to a hot/cold repository hosted by OVH 2 | # using OVH cloud archive and OVH object storage 3 | # 4 | # backup usage: "rustic --use-profile ovh-hot-cold backup 5 | # cleanup: "rustic --use-profile ovh-hot-cold forget --prune 6 | 7 | [repository] 8 | repository = "rclone:ovh:backup-home" 9 | repo-hot = "rclone:ovh:backup-home-hot" 10 | password-file = "/root/key-rustic-ovh" 11 | cache-dir = "/var/lib/cache/rustic" # explicitly specify cache dir for remote repository 12 | warm-up = true # cold storage needs warm-up, just trying to access a file is sufficient to start the warm-up 13 | warm-up-wait = "10m" # in my examples, 10 minutes wait-time was sufficient, according to docu it can be up to 12h 14 | 15 | [forget] 16 | keep-daily = 8 17 | keep-weekly = 5 18 | keep-monthly = 13 19 | keep-yearly = 10 20 | 21 | [backup] 22 | exclude-if-present = [".nobackup", "CACHEDIR.TAG"] 23 | glob-files = ["/root/rustic-ovh.glob"] 24 | one-file-system = true 25 | 26 | [[backup.snapshots]] 27 | sources = ["/home"] 28 | git-ignore = true 29 | 30 | [[backup.snapshots]] 31 | sources = ["/etc"] 32 | 33 | [[backup.snapshots]] 34 | sources = ["/root"] 35 | -------------------------------------------------------------------------------- /.github/workflows/release-plz.yml: -------------------------------------------------------------------------------- 1 | name: Release-plz 2 | 3 | permissions: 4 | pull-requests: write 5 | contents: write 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | release-plz: 14 | name: Release-plz 15 | if: ${{ github.repository_owner == 'rustic-rs' }} 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Generate GitHub token 19 | uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2 20 | id: generate-token 21 | with: 22 | app-id: ${{ secrets.RELEASE_PLZ_APP_ID }} 23 | private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} 24 | - name: Checkout repository 25 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 26 | with: 27 | fetch-depth: 0 28 | token: ${{ steps.generate-token.outputs.token }} 29 | - name: Install Rust toolchain 30 | uses: dtolnay/rust-toolchain@stable 31 | - name: Run release-plz 32 | uses: release-plz/action@d529f731ae3e89610ada96eda34e5c6ba3b12214 # v0.5 33 | env: 34 | GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} 35 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 36 | -------------------------------------------------------------------------------- /src/commands/tui/widgets/popup.rs: -------------------------------------------------------------------------------- 1 | use super::{Clear, Constraint, Draw, Event, Frame, Layout, ProcessEvent, Rect, SizedWidget}; 2 | 3 | // Make a popup from a SizedWidget 4 | pub struct PopUp(pub T); 5 | 6 | impl ProcessEvent for PopUp { 7 | type Result = T::Result; 8 | fn input(&mut self, event: Event) -> Self::Result { 9 | self.0.input(event) 10 | } 11 | } 12 | 13 | impl Draw for PopUp { 14 | fn draw(&mut self, mut area: Rect, f: &mut Frame<'_>) { 15 | // center vertically 16 | if let Some(h) = self.0.height() { 17 | let layout = Layout::vertical([ 18 | Constraint::Min(1), 19 | Constraint::Length(h), 20 | Constraint::Min(1), 21 | ]); 22 | area = layout.split(area)[1]; 23 | } 24 | 25 | // center horizontally 26 | if let Some(w) = self.0.width() { 27 | let layout = Layout::horizontal([ 28 | Constraint::Min(1), 29 | Constraint::Length(w), 30 | Constraint::Min(1), 31 | ]); 32 | area = layout.split(area)[1]; 33 | } 34 | 35 | f.render_widget(Clear, area); 36 | self.0.draw(area, f); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/commands/config.rs: -------------------------------------------------------------------------------- 1 | //! `config` subcommand 2 | 3 | use crate::{Application, RUSTIC_APP, status_err}; 4 | 5 | use abscissa_core::{Command, Runnable, Shutdown}; 6 | 7 | use anyhow::{Result, bail}; 8 | 9 | use rustic_core::ConfigOptions; 10 | 11 | /// `config` subcommand 12 | #[derive(clap::Parser, Command, Debug)] 13 | pub(crate) struct ConfigCmd { 14 | /// Config options 15 | #[clap(flatten)] 16 | config_opts: ConfigOptions, 17 | } 18 | 19 | impl Runnable for ConfigCmd { 20 | fn run(&self) { 21 | if let Err(err) = self.inner_run() { 22 | status_err!("{}", err); 23 | RUSTIC_APP.shutdown(Shutdown::Crash); 24 | }; 25 | } 26 | } 27 | 28 | impl ConfigCmd { 29 | fn inner_run(&self) -> Result<()> { 30 | let config = RUSTIC_APP.config(); 31 | 32 | // Handle dry-run mode 33 | if config.global.dry_run { 34 | bail!("cannot modify config in dry-run mode!",); 35 | } 36 | 37 | let changed = config 38 | .repository 39 | .run_open(|mut repo| Ok(repo.apply_config(&self.config_opts)?))?; 40 | 41 | if changed { 42 | println!("saved new config"); 43 | } else { 44 | println!("config is unchanged"); 45 | } 46 | 47 | Ok(()) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/show-config.rs: -------------------------------------------------------------------------------- 1 | //! Config profile test: runs the application as a subprocess and asserts its 2 | //! output for the `show-config` command 3 | 4 | use std::{io::Read, sync::LazyLock}; 5 | 6 | use abscissa_core::testing::prelude::*; 7 | use insta::assert_snapshot; 8 | use rustic_testing::TestResult; 9 | 10 | // Storing this value as a [`Lazy`] static ensures that all instances of 11 | // the runner acquire a mutex when executing commands and inspecting 12 | // exit statuses, serializing what would otherwise be multithreaded 13 | // invocations as `cargo test` executes tests in parallel by default. 14 | pub static LAZY_RUNNER: LazyLock = LazyLock::new(|| { 15 | let mut runner = CmdRunner::new(env!("CARGO_BIN_EXE_rustic")); 16 | runner.exclusive().capture_stdout(); 17 | runner 18 | }); 19 | 20 | fn cmd_runner() -> CmdRunner { 21 | LAZY_RUNNER.clone() 22 | } 23 | 24 | #[test] 25 | fn test_show_config_passes() -> TestResult<()> { 26 | { 27 | let mut runner = cmd_runner(); 28 | 29 | let mut cmd = runner.args(["show-config"]).run(); 30 | 31 | let mut output = String::new(); 32 | 33 | cmd.stdout().read_to_string(&mut output)?; 34 | 35 | assert_snapshot!(output); 36 | 37 | cmd.wait()?.expect_success(); 38 | } 39 | 40 | Ok(()) 41 | } 42 | -------------------------------------------------------------------------------- /src/commands/check.rs: -------------------------------------------------------------------------------- 1 | //! `check` subcommand 2 | 3 | use crate::{ 4 | Application, RUSTIC_APP, 5 | repository::{CliOpenRepo, get_global_grouped_snapshots}, 6 | status_err, 7 | }; 8 | 9 | use abscissa_core::{Command, Runnable, Shutdown}; 10 | use anyhow::Result; 11 | use rustic_core::CheckOptions; 12 | 13 | /// `check` subcommand 14 | #[derive(clap::Parser, Command, Debug)] 15 | pub(crate) struct CheckCmd { 16 | /// Snapshots to check. If none is given, use filter options to filter from all snapshots 17 | #[clap(value_name = "ID")] 18 | ids: Vec, 19 | 20 | /// Check options 21 | #[clap(flatten)] 22 | opts: CheckOptions, 23 | } 24 | 25 | impl Runnable for CheckCmd { 26 | fn run(&self) { 27 | if let Err(err) = RUSTIC_APP 28 | .config() 29 | .repository 30 | .run_open(|repo| self.inner_run(repo)) 31 | { 32 | status_err!("{}", err); 33 | RUSTIC_APP.shutdown(Shutdown::Crash); 34 | }; 35 | } 36 | } 37 | 38 | impl CheckCmd { 39 | fn inner_run(&self, repo: CliOpenRepo) -> Result<()> { 40 | let groups = get_global_grouped_snapshots(&repo, &self.ids)?; 41 | let trees = groups 42 | .into_iter() 43 | .flat_map(|(_, snaps)| snaps) 44 | .map(|snap| snap.tree) 45 | .collect(); 46 | repo.check_with_trees(self.opts, trees)?.is_ok()?; 47 | Ok(()) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/hooks-fixtures/commands_hooks_access_success.tpl: -------------------------------------------------------------------------------- 1 | [global.hooks] 2 | run-before = [ 3 | "sh -c 'echo Running global hooks before > tests/generated/${{filename}}.log'", 4 | ] 5 | run-after = [ 6 | "sh -c 'echo Running global hooks after >> tests/generated/${{filename}}.log'", 7 | ] 8 | run-failed = [ 9 | "sh -c 'echo Running global hooks failed >> tests/generated/${{filename}}.log'", 10 | ] 11 | run-finally = [ 12 | "sh -c 'echo Running global hooks finally >> tests/generated/${{filename}}.log'", 13 | ] 14 | 15 | [repository.hooks] 16 | run-before = [ 17 | "sh -c 'echo Running repository hooks before >> tests/generated/${{filename}}.log'", 18 | ] 19 | run-after = [ 20 | "sh -c 'echo Running repository hooks after >> tests/generated/${{filename}}.log'", 21 | ] 22 | run-failed = [ 23 | "sh -c 'echo Running repository hooks failed >> tests/generated/${{filename}}.log'", 24 | ] 25 | run-finally = [ 26 | "sh -c 'echo Running repository hooks finally >> tests/generated/${{filename}}.log'", 27 | ] 28 | 29 | [backup.hooks] 30 | run-before = [ 31 | "sh -c 'echo Running backup hooks before >> tests/generated/${{filename}}.log'", 32 | ] 33 | run-after = [ 34 | "sh -c 'echo Running backup hooks after >> tests/generated/${{filename}}.log'", 35 | ] 36 | run-failed = [ 37 | "sh -c 'echo Running backup hooks failed >> tests/generated/${{filename}}.log'", 38 | ] 39 | run-finally = [ 40 | "sh -c 'echo Running backup hooks finally >> tests/generated/${{filename}}.log'", 41 | ] 42 | -------------------------------------------------------------------------------- /src/commands/tag.rs: -------------------------------------------------------------------------------- 1 | //! `tag` subcommand 2 | use abscissa_core::{Command, Runnable}; 3 | use rustic_core::StringList; 4 | 5 | use crate::commands::rewrite::RewriteCmd; 6 | 7 | /// `tag` subcommand 8 | #[derive(clap::Parser, Command, Debug)] 9 | pub(crate) struct TagCmd { 10 | /// Snapshots to change tags. If none is given, use filter to filter from all 11 | /// snapshots. 12 | #[clap(value_name = "ID")] 13 | ids: Vec, 14 | 15 | /// Tags to add (can be specified multiple times) 16 | #[clap( 17 | long, 18 | value_name = "TAG[,TAG,..]", 19 | conflicts_with = "remove", 20 | help_heading = "Tag options" 21 | )] 22 | add: Vec, 23 | 24 | /// Tags to remove (can be specified multiple times) 25 | #[clap(long, value_name = "TAG[,TAG,..]", help_heading = "Tag options")] 26 | remove: Vec, 27 | 28 | /// Tag list to set (can be specified multiple times) 29 | #[clap( 30 | long, 31 | value_name = "TAG[,TAG,..]", 32 | conflicts_with = "remove", 33 | help_heading = "Tag options" 34 | )] 35 | set: Vec, 36 | } 37 | 38 | impl Runnable for TagCmd { 39 | fn run(&self) { 40 | let rewrite = RewriteCmd { 41 | ids: self.ids.clone(), 42 | add_tags: self.add.clone(), 43 | remove_tags: self.remove.clone(), 44 | set_tags: self.set.clone(), 45 | ..Default::default() 46 | }; 47 | rewrite.run(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/hooks-fixtures/full_hooks_success.toml: -------------------------------------------------------------------------------- 1 | [global.hooks] 2 | run-before = [ 3 | "sh -c 'echo Running global hooks before > tests/generated/full_hooks_success.log'", 4 | ] 5 | run-after = [ 6 | "sh -c 'echo Running global hooks after >> tests/generated/full_hooks_success.log'", 7 | ] 8 | run-failed = [ 9 | "sh -c 'echo Running global hooks failed >> tests/generated/full_hooks_success.log'", 10 | ] 11 | run-finally = [ 12 | "sh -c 'echo Running global hooks finally >> tests/generated/full_hooks_success.log'", 13 | ] 14 | 15 | [repository.hooks] 16 | run-before = [ 17 | "sh -c 'echo Running repository hooks before >> tests/generated/full_hooks_success.log'", 18 | ] 19 | run-after = [ 20 | "sh -c 'echo Running repository hooks after >> tests/generated/full_hooks_success.log'", 21 | ] 22 | run-failed = [ 23 | "sh -c 'echo Running repository hooks failed >> tests/generated/full_hooks_success.log'", 24 | ] 25 | run-finally = [ 26 | "sh -c 'echo Running repository hooks finally >> tests/generated/full_hooks_success.log'", 27 | ] 28 | 29 | [backup.hooks] 30 | run-before = [ 31 | "sh -c 'echo Running backup hooks before >> tests/generated/full_hooks_success.log'", 32 | ] 33 | run-after = [ 34 | "sh -c 'echo Running backup hooks after >> tests/generated/full_hooks_success.log'", 35 | ] 36 | run-failed = [ 37 | "sh -c 'echo Running backup hooks failed >> tests/generated/full_hooks_success.log'", 38 | ] 39 | run-finally = [ 40 | "sh -c 'echo Running backup hooks finally >> tests/generated/full_hooks_success.log'", 41 | ] 42 | -------------------------------------------------------------------------------- /src/helpers.rs: -------------------------------------------------------------------------------- 1 | use bytesize::ByteSize; 2 | use comfy_table::{ 3 | Attribute, Cell, CellAlignment, ContentArrangement, Table, presets::ASCII_MARKDOWN, 4 | }; 5 | 6 | /// Helpers for table output 7 | /// Create a new bold cell 8 | pub fn bold_cell(s: T) -> Cell { 9 | Cell::new(s).add_attribute(Attribute::Bold) 10 | } 11 | 12 | /// Create a new table with default settings 13 | #[must_use] 14 | pub fn table() -> Table { 15 | let mut table = Table::new(); 16 | _ = table 17 | .load_preset(ASCII_MARKDOWN) 18 | .set_content_arrangement(ContentArrangement::Dynamic); 19 | table 20 | } 21 | 22 | /// Create a new table with titles 23 | /// 24 | /// The first row will be bold 25 | pub fn table_with_titles, T: ToString>(titles: I) -> Table { 26 | let mut table = table(); 27 | _ = table.set_header(titles.into_iter().map(bold_cell)); 28 | table 29 | } 30 | 31 | /// Create a new table with titles and right aligned columns 32 | pub fn table_right_from, T: ToString>(start: usize, titles: I) -> Table { 33 | let mut table = table_with_titles(titles); 34 | // set alignment of all rows except first start row 35 | table 36 | .column_iter_mut() 37 | .skip(start) 38 | .for_each(|c| c.set_cell_alignment(CellAlignment::Right)); 39 | 40 | table 41 | } 42 | 43 | /// Convert a [`ByteSize`] to a human readable string 44 | #[must_use] 45 | pub fn bytes_size_to_string(b: u64) -> String { 46 | ByteSize(b).display().to_string() 47 | } 48 | -------------------------------------------------------------------------------- /tests/hooks-fixtures/check_not_backup_hooks_success.toml: -------------------------------------------------------------------------------- 1 | [global.hooks] 2 | run-before = [ 3 | "sh -c 'echo Running global hooks before > tests/generated/check_not_backup_hooks_success.log'", 4 | ] 5 | run-after = [ 6 | "sh -c 'echo Running global hooks after >> tests/generated/check_not_backup_hooks_success.log'", 7 | ] 8 | run-failed = [ 9 | "sh -c 'echo Running global hooks failed >> tests/generated/check_not_backup_hooks_success.log'", 10 | ] 11 | run-finally = [ 12 | "sh -c 'echo Running global hooks finally >> tests/generated/check_not_backup_hooks_success.log'", 13 | ] 14 | 15 | [repository.hooks] 16 | run-before = [ 17 | "sh -c 'echo Running repository hooks before >> tests/generated/check_not_backup_hooks_success.log'", 18 | ] 19 | run-after = [ 20 | "sh -c 'echo Running repository hooks after >> tests/generated/check_not_backup_hooks_success.log'", 21 | ] 22 | run-failed = [ 23 | "sh -c 'echo Running repository hooks failed >> tests/generated/check_not_backup_hooks_success.log'", 24 | ] 25 | run-finally = [ 26 | "sh -c 'echo Running repository hooks finally >> tests/generated/check_not_backup_hooks_success.log'", 27 | ] 28 | 29 | [backup.hooks] 30 | run-before = [ 31 | "sh -c 'echo MUST NOT SHOW UP >> tests/generated/check_not_backup_hooks_success.log'", 32 | ] 33 | run-after = [ 34 | "sh -c 'echo MUST NOT SHOW UP >> tests/generated/check_not_backup_hooks_success.log'", 35 | ] 36 | run-failed = [ 37 | "sh -c 'echo MUST NOT SHOW UP >> tests/generated/check_not_backup_hooks_success.log'", 38 | ] 39 | run-finally = [ 40 | "sh -c 'echo MUST NOT SHOW UP >> tests/generated/check_not_backup_hooks_success.log'", 41 | ] 42 | -------------------------------------------------------------------------------- /config/rustic.toml: -------------------------------------------------------------------------------- 1 | # Example rustic config file. 2 | # 3 | # This file should be placed in the user's local config dir (~/.config/rustic/) 4 | # If you save it under NAME.toml, use "rustic -P NAME" to access this profile. 5 | # 6 | # Note that most options can be overwritten by the corresponding command line option. 7 | 8 | # global options: These options are used for all commands. 9 | [global] 10 | log-level = "debug" 11 | log-file = "/log/rustic.log" 12 | 13 | # repository options: These options define which backend to use and which password to use. 14 | [repository] 15 | repository = "/tmp/rustic" 16 | password = "mySecretPassword" 17 | 18 | # snapshot-filter options: These options apply to all commands that use snapshot filters 19 | [snapshot-filter] 20 | filter-hosts = ["myhost"] 21 | 22 | # backup options: These options are used for all sources when calling the backup command. 23 | # They can be overwritten by source-specific options (see below) or command line options. 24 | [backup] 25 | git-ignore = true 26 | 27 | # backup options can be given for specific sources. These options only apply 28 | # when calling "rustic backup SOURCE". 29 | # 30 | # Note that if you call "rustic backup" without any source, all sources from this config 31 | # file will be processed. 32 | [[backup.snapshots]] 33 | sources = ["/data/dir"] 34 | 35 | [[backup.snapshots]] 36 | sources = ["/home"] 37 | globs = ["!/home/*/Downloads/*"] 38 | 39 | # forget options 40 | [forget] 41 | filter-hosts = [ 42 | "forgethost", 43 | ] # <- this overwrites the snapshot-filter option defined above 44 | keep-tags = ["mytag"] 45 | keep-within-daily = "7 days" 46 | keep-monthly = 5 47 | keep-yearly = 2 48 | -------------------------------------------------------------------------------- /tests/hooks-fixtures/full_hooks_before_repo_failure.toml: -------------------------------------------------------------------------------- 1 | [global.hooks] 2 | run-before = [ 3 | "sh -c 'echo Running global hooks before > tests/generated/full_hooks_before_repo_failure.log'", 4 | ] 5 | run-after = [ 6 | "sh -c 'echo Running global hooks after >> tests/generated/full_hooks_before_repo_failure.log'", 7 | ] 8 | run-failed = [ 9 | "sh -c 'echo Running global hooks failed >> tests/generated/full_hooks_before_repo_failure.log'", 10 | ] 11 | run-finally = [ 12 | "sh -c 'echo Running global hooks finally >> tests/generated/full_hooks_before_repo_failure.log'", 13 | ] 14 | 15 | [repository.hooks] 16 | run-before = [ 17 | "sh -c 'echo Running repository hooks before >> tests/generated/full_hooks_before_repo_failure.log'", 18 | "false", 19 | "sh -c 'echo MUST NOT SHOW UP >> tests/generated/full_hooks_before_repo_failure.log'", 20 | ] 21 | run-after = [ 22 | "sh -c 'echo Running repository hooks after >> tests/generated/full_hooks_before_repo_failure.log'", 23 | ] 24 | run-failed = [ 25 | "sh -c 'echo Running repository hooks failed >> tests/generated/full_hooks_before_repo_failure.log'", 26 | ] 27 | run-finally = [ 28 | "sh -c 'echo Running repository hooks finally >> tests/generated/full_hooks_before_repo_failure.log'", 29 | ] 30 | 31 | [backup.hooks] 32 | run-before = [ 33 | "sh -c 'echo Running backup hooks before >> tests/generated/full_hooks_before_repo_failure.log'", 34 | ] 35 | run-after = [ 36 | "sh -c 'echo Running backup hooks after >> tests/generated/full_hooks_before_repo_failure.log'", 37 | ] 38 | run-failed = [ 39 | "sh -c 'echo Running backup hooks failed >> tests/generated/full_hooks_before_repo_failure.log'", 40 | ] 41 | run-finally = [ 42 | "sh -c 'echo Running backup hooks finally >> tests/generated/full_hooks_before_repo_failure.log'", 43 | ] 44 | -------------------------------------------------------------------------------- /tests/hooks-fixtures/full_hooks_before_backup_failure.toml: -------------------------------------------------------------------------------- 1 | [global.hooks] 2 | run-before = [ 3 | "sh -c 'echo Running global hooks before > tests/generated/full_hooks_before_backup_failure.log'", 4 | ] 5 | run-after = [ 6 | "sh -c 'echo Running global hooks after >> tests/generated/full_hooks_before_backup_failure.log'", 7 | ] 8 | run-failed = [ 9 | "sh -c 'echo Running global hooks failed >> tests/generated/full_hooks_before_backup_failure.log'", 10 | ] 11 | run-finally = [ 12 | "sh -c 'echo Running global hooks finally >> tests/generated/full_hooks_before_backup_failure.log'", 13 | ] 14 | 15 | [repository.hooks] 16 | run-before = [ 17 | "sh -c 'echo Running repository hooks before >> tests/generated/full_hooks_before_backup_failure.log'", 18 | ] 19 | run-after = [ 20 | "sh -c 'echo Running repository hooks after >> tests/generated/full_hooks_before_backup_failure.log'", 21 | ] 22 | run-failed = [ 23 | "sh -c 'echo Running repository hooks failed >> tests/generated/full_hooks_before_backup_failure.log'", 24 | ] 25 | run-finally = [ 26 | "sh -c 'echo Running repository hooks finally >> tests/generated/full_hooks_before_backup_failure.log'", 27 | ] 28 | 29 | [backup.hooks] 30 | run-before = [ 31 | "sh -c 'echo Running backup hooks before >> tests/generated/full_hooks_before_backup_failure.log'", 32 | "false", 33 | "sh -c 'echo MUST NOT SHOW UP >> tests/generated/full_hooks_before_backup_failure.log'", 34 | ] 35 | run-after = [ 36 | "sh -c 'echo Running backup hooks after >> tests/generated/full_hooks_before_backup_failure.log'", 37 | ] 38 | run-failed = [ 39 | "sh -c 'echo Running backup hooks failed >> tests/generated/full_hooks_before_backup_failure.log'", 40 | ] 41 | run-finally = [ 42 | "sh -c 'echo Running backup hooks finally >> tests/generated/full_hooks_before_backup_failure.log'", 43 | ] 44 | -------------------------------------------------------------------------------- /src/commands/completions.rs: -------------------------------------------------------------------------------- 1 | //! `completions` subcommand 2 | 3 | use abscissa_core::{Command, Runnable}; 4 | 5 | use std::io::Write; 6 | 7 | use clap::CommandFactory; 8 | 9 | use clap_complete::{Generator, generate, shells}; 10 | 11 | /// `completions` subcommand 12 | #[derive(clap::Parser, Command, Debug)] 13 | pub(crate) struct CompletionsCmd { 14 | /// Shell to generate completions for 15 | #[clap(value_enum)] 16 | sh: Variant, 17 | } 18 | 19 | #[derive(Clone, Debug, clap::ValueEnum)] 20 | pub(super) enum Variant { 21 | Bash, 22 | Fish, 23 | Zsh, 24 | Powershell, 25 | } 26 | 27 | impl Runnable for CompletionsCmd { 28 | fn run(&self) { 29 | match self.sh { 30 | Variant::Bash => generate_completion(shells::Bash, &mut std::io::stdout()), 31 | Variant::Fish => generate_completion(shells::Fish, &mut std::io::stdout()), 32 | Variant::Zsh => generate_completion(shells::Zsh, &mut std::io::stdout()), 33 | Variant::Powershell => generate_completion(shells::PowerShell, &mut std::io::stdout()), 34 | } 35 | } 36 | } 37 | 38 | pub fn generate_completion(shell: G, buf: &mut dyn Write) { 39 | let mut command = crate::commands::EntryPoint::command(); 40 | generate( 41 | shell, 42 | &mut command, 43 | option_env!("CARGO_BIN_NAME").unwrap_or("rustic"), 44 | buf, 45 | ); 46 | } 47 | 48 | #[cfg(test)] 49 | mod tests { 50 | use super::*; 51 | 52 | #[test] 53 | fn test_completions() { 54 | generate_completion(shells::Bash, &mut std::io::sink()); 55 | generate_completion(shells::Fish, &mut std::io::sink()); 56 | generate_completion(shells::PowerShell, &mut std::io::sink()); 57 | generate_completion(shells::Zsh, &mut std::io::sink()); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/snapshots/show_config__show_config_passes.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/show-config.rs 3 | expression: output 4 | --- 5 | [global] 6 | profile-substitute-env = false 7 | use-profiles = [] 8 | dry-run = false 9 | check-index = false 10 | no-progress = false 11 | 12 | [global.hooks] 13 | run-before = [] 14 | run-after = [] 15 | run-failed = [] 16 | run-finally = [] 17 | 18 | [global.env] 19 | 20 | [global.metrics-labels] 21 | 22 | [repository] 23 | no-cache = false 24 | warm-up = false 25 | 26 | [repository.options] 27 | 28 | [repository.options-hot] 29 | 30 | [repository.options-cold] 31 | 32 | [repository.hooks] 33 | run-before = [] 34 | run-after = [] 35 | run-failed = [] 36 | run-finally = [] 37 | 38 | [snapshot-filter] 39 | filter-hosts = [] 40 | filter-labels = [] 41 | filter-paths = [] 42 | filter-paths-exact = [] 43 | filter-tags = [] 44 | filter-tags-exact = [] 45 | 46 | [backup] 47 | stdin-filename = "" 48 | with-atime = false 49 | ignore-devid = false 50 | no-scan = false 51 | json = false 52 | long = false 53 | quiet = false 54 | init = false 55 | skip-if-unchanged = false 56 | force = false 57 | ignore-ctime = false 58 | ignore-inode = false 59 | globs = [] 60 | iglobs = [] 61 | glob-files = [] 62 | iglob-files = [] 63 | git-ignore = false 64 | no-require-git = false 65 | custom-ignorefiles = [] 66 | exclude-if-present = [] 67 | one-file-system = false 68 | tags = [] 69 | delete-never = false 70 | snapshots = [] 71 | sources = [] 72 | 73 | [backup.hooks] 74 | run-before = [] 75 | run-after = [] 76 | run-failed = [] 77 | run-finally = [] 78 | 79 | [backup.metrics-labels] 80 | 81 | [copy] 82 | targets = [] 83 | 84 | [forget] 85 | prune = false 86 | filter-hosts = [] 87 | filter-labels = [] 88 | filter-paths = [] 89 | filter-paths-exact = [] 90 | filter-tags = [] 91 | filter-tags-exact = [] 92 | 93 | [webdav] 94 | symlinks = false 95 | -------------------------------------------------------------------------------- /src/commands/tui/widgets/with_block.rs: -------------------------------------------------------------------------------- 1 | use super::{Block, Draw, Event, Frame, ProcessEvent, Rect, SizedWidget, layout}; 2 | use layout::Size; 3 | 4 | pub struct WithBlock { 5 | pub block: Block<'static>, 6 | pub widget: T, 7 | } 8 | 9 | impl WithBlock { 10 | pub fn new(widget: T, block: Block<'static>) -> Self { 11 | Self { block, widget } 12 | } 13 | 14 | // Note: this could be a method of self.block, but is unfortunately not present 15 | // So we compute ourselves using self.block.inner() on an artificial Rect. 16 | fn size_diff(&self) -> Size { 17 | let rect = Rect { 18 | x: 0, 19 | y: 0, 20 | width: u16::MAX, 21 | height: u16::MAX, 22 | }; 23 | let inner = self.block.inner(rect); 24 | Size { 25 | width: rect.as_size().width - inner.as_size().width, 26 | height: rect.as_size().height - inner.as_size().height, 27 | } 28 | } 29 | } 30 | 31 | impl ProcessEvent for WithBlock { 32 | type Result = T::Result; 33 | fn input(&mut self, event: Event) -> Self::Result { 34 | self.widget.input(event) 35 | } 36 | } 37 | 38 | impl SizedWidget for WithBlock { 39 | fn height(&self) -> Option { 40 | self.widget 41 | .height() 42 | .map(|h| h.saturating_add(self.size_diff().height)) 43 | } 44 | 45 | fn width(&self) -> Option { 46 | self.widget 47 | .width() 48 | .map(|w| w.saturating_add(self.size_diff().width)) 49 | } 50 | } 51 | 52 | impl Draw for WithBlock { 53 | fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { 54 | f.render_widget(self.block.clone(), area); 55 | self.widget.draw(self.block.inner(area), f); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/snapshots/rustic_rs__config__tests__default_config_display_passes.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config.rs 3 | expression: config 4 | --- 5 | [global] 6 | profile-substitute-env = false 7 | use-profiles = [] 8 | dry-run = false 9 | check-index = false 10 | no-progress = false 11 | 12 | [global.hooks] 13 | run-before = [] 14 | run-after = [] 15 | run-failed = [] 16 | run-finally = [] 17 | 18 | [global.env] 19 | 20 | [global.metrics-labels] 21 | 22 | [repository] 23 | no-cache = false 24 | warm-up = false 25 | 26 | [repository.options] 27 | 28 | [repository.options-hot] 29 | 30 | [repository.options-cold] 31 | 32 | [repository.hooks] 33 | run-before = [] 34 | run-after = [] 35 | run-failed = [] 36 | run-finally = [] 37 | 38 | [snapshot-filter] 39 | filter-hosts = [] 40 | filter-labels = [] 41 | filter-paths = [] 42 | filter-paths-exact = [] 43 | filter-tags = [] 44 | filter-tags-exact = [] 45 | 46 | [backup] 47 | stdin-filename = "" 48 | with-atime = false 49 | ignore-devid = false 50 | no-scan = false 51 | json = false 52 | long = false 53 | quiet = false 54 | init = false 55 | skip-if-unchanged = false 56 | force = false 57 | ignore-ctime = false 58 | ignore-inode = false 59 | globs = [] 60 | iglobs = [] 61 | glob-files = [] 62 | iglob-files = [] 63 | git-ignore = false 64 | no-require-git = false 65 | custom-ignorefiles = [] 66 | exclude-if-present = [] 67 | one-file-system = false 68 | tags = [] 69 | delete-never = false 70 | snapshots = [] 71 | sources = [] 72 | 73 | [backup.hooks] 74 | run-before = [] 75 | run-after = [] 76 | run-failed = [] 77 | run-finally = [] 78 | 79 | [backup.metrics-labels] 80 | 81 | [copy] 82 | targets = [] 83 | 84 | [forget] 85 | prune = false 86 | filter-hosts = [] 87 | filter-labels = [] 88 | filter-paths = [] 89 | filter-paths-exact = [] 90 | filter-tags = [] 91 | filter-tags-exact = [] 92 | 93 | [webdav] 94 | symlinks = false 95 | -------------------------------------------------------------------------------- /src/commands/tui/widgets/sized_table.rs: -------------------------------------------------------------------------------- 1 | use super::{Constraint, Draw, Frame, Rect, Row, SizedWidget, Table, Text}; 2 | 3 | pub struct SizedTable { 4 | table: Table<'static>, 5 | height: usize, 6 | width: usize, 7 | } 8 | 9 | impl SizedTable { 10 | pub fn new(content: Vec>>) -> Self { 11 | let height = content 12 | .iter() 13 | .map(|row| row.iter().map(Text::height).max().unwrap_or_default()) 14 | .sum::(); 15 | 16 | let widths = content 17 | .iter() 18 | .map(|row| row.iter().map(Text::width).collect()) 19 | .reduce(|widths: Vec, row| { 20 | row.iter() 21 | .zip(widths.iter()) 22 | .map(|(r, w)| r.max(w)) 23 | .copied() 24 | .collect() 25 | }) 26 | .unwrap_or_default(); 27 | 28 | let width = widths 29 | .iter() 30 | .copied() 31 | .reduce(|width, w| width + w + 1) // +1 because of space between entries 32 | .unwrap_or_default(); 33 | 34 | let rows = content.into_iter().map(Row::new); 35 | let table = Table::default() 36 | .widths(widths.iter().map(|w| { 37 | (*w).try_into() 38 | .ok() 39 | .map_or(Constraint::Min(0), Constraint::Length) 40 | })) 41 | .rows(rows); 42 | Self { 43 | table, 44 | height, 45 | width, 46 | } 47 | } 48 | } 49 | 50 | impl SizedWidget for SizedTable { 51 | fn height(&self) -> Option { 52 | self.height.try_into().ok() 53 | } 54 | fn width(&self) -> Option { 55 | self.width.try_into().ok() 56 | } 57 | } 58 | 59 | impl Draw for SizedTable { 60 | fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { 61 | f.render_widget(&self.table, area); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/commands/docs.rs: -------------------------------------------------------------------------------- 1 | //! `docs` subcommand 2 | 3 | use abscissa_core::{Application, Command, Runnable, Shutdown, status_err}; 4 | use anyhow::Result; 5 | use clap::Subcommand; 6 | 7 | use crate::{ 8 | RUSTIC_APP, 9 | application::constants::{RUSTIC_CONFIG_DOCS_URL, RUSTIC_DEV_DOCS_URL, RUSTIC_DOCS_URL}, 10 | }; 11 | 12 | #[derive(Command, Debug, Clone, Copy, Default, Subcommand, Runnable)] 13 | enum DocsTypeSubcommand { 14 | #[default] 15 | /// Show the user documentation 16 | User, 17 | /// Show the development documentation 18 | Dev, 19 | /// Show the configuration documentation 20 | Config, 21 | } 22 | 23 | /// Opens the documentation in the default browser. 24 | #[derive(Clone, Command, Default, Debug, clap::Parser)] 25 | pub struct DocsCmd { 26 | #[clap(subcommand)] 27 | cmd: Option, 28 | } 29 | 30 | impl Runnable for DocsCmd { 31 | fn run(&self) { 32 | if let Err(err) = self.inner_run() { 33 | status_err!("{}", err); 34 | RUSTIC_APP.shutdown(Shutdown::Crash); 35 | }; 36 | } 37 | } 38 | 39 | impl DocsCmd { 40 | fn inner_run(&self) -> Result<()> { 41 | let user_string = match self.cmd { 42 | // Default to user docs if no subcommand is provided 43 | Some(DocsTypeSubcommand::User) | None => { 44 | open::that(RUSTIC_DOCS_URL)?; 45 | format!("Opening the user documentation at {RUSTIC_DOCS_URL}") 46 | } 47 | Some(DocsTypeSubcommand::Dev) => { 48 | open::that(RUSTIC_DEV_DOCS_URL)?; 49 | format!("Opening the development documentation at {RUSTIC_DEV_DOCS_URL}") 50 | } 51 | Some(DocsTypeSubcommand::Config) => { 52 | open::that(RUSTIC_CONFIG_DOCS_URL)?; 53 | format!("Opening the configuration documentation at {RUSTIC_CONFIG_DOCS_URL}") 54 | } 55 | }; 56 | 57 | println!("{user_string}"); 58 | 59 | Ok(()) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | 3 | on: 4 | pull_request: 5 | schedule: 6 | # Runs at 00:00 UTC everyday 7 | - cron: "0 0 * * *" 8 | push: 9 | paths: 10 | - "**/Cargo.toml" 11 | - "**/Cargo.lock" 12 | - "crates/**/Cargo.toml" 13 | - "crates/**/Cargo.lock" 14 | merge_group: 15 | types: [checks_requested] 16 | 17 | concurrency: 18 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | audit: 23 | if: ${{ github.repository_owner == 'rustic-rs' }} 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 28 | # Ensure that the latest version of Cargo is installed 29 | - name: Install Rust toolchain 30 | uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # v1 31 | with: 32 | toolchain: stable 33 | - uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2 34 | - uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0 35 | with: 36 | token: ${{ secrets.GITHUB_TOKEN }} 37 | ignore: RUSTSEC-2023-0071 # rsa thingy, ignored for now 38 | 39 | cargo-deny: 40 | name: Run cargo-deny 41 | if: ${{ github.repository_owner == 'rustic-rs' }} 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 45 | 46 | - uses: EmbarkStudios/cargo-deny-action@30f817c6f72275c6d54dc744fbca09ebc958599f # v2 47 | with: 48 | command: check bans licenses sources 49 | 50 | result: 51 | if: ${{ github.repository_owner == 'rustic-rs' }} 52 | name: Result (Audit) 53 | runs-on: ubuntu-latest 54 | needs: 55 | - audit 56 | - cargo-deny 57 | steps: 58 | - name: Mark the job as successful 59 | run: exit 0 60 | if: success() 61 | - name: Mark the job as unsuccessful 62 | run: exit 1 63 | if: "!success()" 64 | -------------------------------------------------------------------------------- /tests/completions.rs: -------------------------------------------------------------------------------- 1 | //! Completions test: runs the application as a subprocess and asserts its 2 | //! output for the `completions` command 3 | 4 | // #![forbid(unsafe_code)] 5 | // #![warn( 6 | // missing_docs, 7 | // rust_2018_idioms, 8 | // trivial_casts, 9 | // unused_lifetimes, 10 | // unused_qualifications 11 | // )] 12 | 13 | use std::{io::Read, sync::LazyLock}; 14 | 15 | use abscissa_core::testing::prelude::*; 16 | use insta::assert_snapshot; 17 | use rstest::rstest; 18 | 19 | use rustic_testing::TestResult; 20 | 21 | // Storing this value as a [`Lazy`] static ensures that all instances of 22 | /// the runner acquire a mutex when executing commands and inspecting 23 | /// exit statuses, serializing what would otherwise be multithreaded 24 | /// invocations as `cargo test` executes tests in parallel by default. 25 | pub static LAZY_RUNNER: LazyLock = LazyLock::new(|| { 26 | let mut runner = CmdRunner::new(env!("CARGO_BIN_EXE_rustic")); 27 | runner.exclusive().capture_stdout(); 28 | runner 29 | }); 30 | 31 | fn cmd_runner() -> CmdRunner { 32 | LAZY_RUNNER.clone() 33 | } 34 | 35 | #[rstest] 36 | #[case("bash")] 37 | #[case("fish")] 38 | #[case("zsh")] 39 | #[case("powershell")] 40 | #[ignore = "This test is only being run during release process"] 41 | fn test_completions_passes(#[case] shell: &str) -> TestResult<()> { 42 | let mut runner = cmd_runner(); 43 | 44 | let mut cmd = runner.args(["completions", shell]).run(); 45 | 46 | let mut output = String::new(); 47 | 48 | cmd.stdout().read_to_string(&mut output)?; 49 | 50 | cfg_if::cfg_if! { 51 | if #[cfg(target_os = "windows")] { 52 | let os = "windows"; 53 | } else if #[cfg(target_os = "linux")] { 54 | let os = "linux"; 55 | } else if #[cfg(target_os = "macos")] { 56 | let os = "macos"; 57 | } else { 58 | let os = "generic"; 59 | } 60 | } 61 | 62 | let name = format!("completions-{shell}-{os}"); 63 | 64 | assert_snapshot!(name, output); 65 | 66 | cmd.wait()?.expect_success(); 67 | 68 | Ok(()) 69 | } 70 | -------------------------------------------------------------------------------- /tests/repository-fixtures/README.md: -------------------------------------------------------------------------------- 1 | # Repository Fixtures 2 | 3 | This directory contains fixtures for testing the `rustic` and `restic` 4 | repositories. 5 | 6 | The `rustic` repository is used to test the `rustic` binary. The `restic` 7 | repository is a repository created `restic`. The latter is used to ensure that 8 | `rustic` can read and write to a repository created by `restic`. The 9 | `rustic-copy-repo` repository is used to test the copying of snapshots between 10 | repositories. 11 | 12 | ## Accessing the Repositories 13 | 14 | The `rustic` repository is located at `./rustic-repo`. The `restic` repository 15 | is located at `./restic-repo`. There is an empty repository located at 16 | `./rustic-copy-repo` that can be used to test the copying of snapshots between 17 | repositories. 18 | 19 | ## Repository Layout 20 | 21 | The `rustic` repository contains the following snapshots: 22 | 23 | ```console 24 | | ID | Time | Host | Label | Tags | Paths | Files | Dirs | Size | 25 | |----------|---------------------|---------|-------|------|-------|-------|------|-----------| 26 | | 31d477a2 | 2024-10-08 08:11:00 | TowerPC | | | src | 51 | 7 | 240.5 kiB | 27 | | 86371783 | 2024-10-08 08:13:12 | TowerPC | | | src | 50 | 7 | 238.6 kiB | 28 | ``` 29 | 30 | The `restic` repository contains the following snapshots: 31 | 32 | ```console 33 | ID Time Host Tags Paths 34 | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- 35 | 9305509c 2024-10-08 08:14:50 TowerPC src 36 | af05ecb6 2024-10-08 08:15:05 TowerPC src 37 | ``` 38 | 39 | The difference between the two snapshots is that the `lib.rs` file in the `src` 40 | directory was removed between the two snapshots. 41 | 42 | The `rustic-copy-repo` repository is empty and contains no snapshots. 43 | 44 | ### Passwords 45 | 46 | The `rustic` repository is encrypted with the password `rustic`. The `restic` 47 | repository is encrypted with the password `restic`. 48 | -------------------------------------------------------------------------------- /src/snapshots/rustic_rs__config__tests__global_env_roundtrip_passes-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config.rs 3 | expression: deserialized 4 | --- 5 | [global] 6 | profile-substitute-env = false 7 | use-profiles = [] 8 | dry-run = false 9 | check-index = false 10 | no-progress = false 11 | 12 | [global.hooks] 13 | run-before = [] 14 | run-after = [] 15 | run-failed = [] 16 | run-finally = [] 17 | 18 | [global.env] 19 | KEY0 = "VALUE0" 20 | KEY1 = "VALUE1" 21 | KEY2 = "VALUE2" 22 | KEY3 = "VALUE3" 23 | KEY4 = "VALUE4" 24 | KEY5 = "VALUE5" 25 | KEY6 = "VALUE6" 26 | KEY7 = "VALUE7" 27 | KEY8 = "VALUE8" 28 | KEY9 = "VALUE9" 29 | 30 | [global.metrics-labels] 31 | 32 | [repository] 33 | no-cache = false 34 | warm-up = false 35 | 36 | [repository.options] 37 | 38 | [repository.options-hot] 39 | 40 | [repository.options-cold] 41 | 42 | [repository.hooks] 43 | run-before = [] 44 | run-after = [] 45 | run-failed = [] 46 | run-finally = [] 47 | 48 | [snapshot-filter] 49 | filter-hosts = [] 50 | filter-labels = [] 51 | filter-paths = [] 52 | filter-paths-exact = [] 53 | filter-tags = [] 54 | filter-tags-exact = [] 55 | 56 | [backup] 57 | stdin-filename = "" 58 | with-atime = false 59 | ignore-devid = false 60 | no-scan = false 61 | json = false 62 | long = false 63 | quiet = false 64 | init = false 65 | skip-if-unchanged = false 66 | force = false 67 | ignore-ctime = false 68 | ignore-inode = false 69 | globs = [] 70 | iglobs = [] 71 | glob-files = [] 72 | iglob-files = [] 73 | git-ignore = false 74 | no-require-git = false 75 | custom-ignorefiles = [] 76 | exclude-if-present = [] 77 | one-file-system = false 78 | tags = [] 79 | delete-never = false 80 | snapshots = [] 81 | sources = [] 82 | 83 | [backup.hooks] 84 | run-before = [] 85 | run-after = [] 86 | run-failed = [] 87 | run-finally = [] 88 | 89 | [backup.metrics-labels] 90 | 91 | [copy] 92 | targets = [] 93 | 94 | [forget] 95 | prune = false 96 | filter-hosts = [] 97 | filter-labels = [] 98 | filter-paths = [] 99 | filter-paths-exact = [] 100 | filter-tags = [] 101 | filter-tags-exact = [] 102 | 103 | [webdav] 104 | symlinks = false 105 | -------------------------------------------------------------------------------- /src/snapshots/rustic_rs__config__tests__global_env_roundtrip_passes.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config.rs 3 | expression: serialized 4 | --- 5 | [global] 6 | profile-substitute-env = false 7 | use-profiles = [] 8 | dry-run = false 9 | check-index = false 10 | no-progress = false 11 | 12 | [global.hooks] 13 | run-before = [] 14 | run-after = [] 15 | run-failed = [] 16 | run-finally = [] 17 | 18 | [global.env] 19 | KEY0 = "VALUE0" 20 | KEY1 = "VALUE1" 21 | KEY2 = "VALUE2" 22 | KEY3 = "VALUE3" 23 | KEY4 = "VALUE4" 24 | KEY5 = "VALUE5" 25 | KEY6 = "VALUE6" 26 | KEY7 = "VALUE7" 27 | KEY8 = "VALUE8" 28 | KEY9 = "VALUE9" 29 | 30 | [global.metrics-labels] 31 | 32 | [repository] 33 | no-cache = false 34 | warm-up = false 35 | 36 | [repository.options] 37 | 38 | [repository.options-hot] 39 | 40 | [repository.options-cold] 41 | 42 | [repository.hooks] 43 | run-before = [] 44 | run-after = [] 45 | run-failed = [] 46 | run-finally = [] 47 | 48 | [snapshot-filter] 49 | filter-hosts = [] 50 | filter-labels = [] 51 | filter-paths = [] 52 | filter-paths-exact = [] 53 | filter-tags = [] 54 | filter-tags-exact = [] 55 | 56 | [backup] 57 | stdin-filename = "" 58 | with-atime = false 59 | ignore-devid = false 60 | no-scan = false 61 | json = false 62 | long = false 63 | quiet = false 64 | init = false 65 | skip-if-unchanged = false 66 | force = false 67 | ignore-ctime = false 68 | ignore-inode = false 69 | globs = [] 70 | iglobs = [] 71 | glob-files = [] 72 | iglob-files = [] 73 | git-ignore = false 74 | no-require-git = false 75 | custom-ignorefiles = [] 76 | exclude-if-present = [] 77 | one-file-system = false 78 | tags = [] 79 | delete-never = false 80 | snapshots = [] 81 | sources = [] 82 | 83 | [backup.hooks] 84 | run-before = [] 85 | run-after = [] 86 | run-failed = [] 87 | run-finally = [] 88 | 89 | [backup.metrics-labels] 90 | 91 | [copy] 92 | targets = [] 93 | 94 | [forget] 95 | prune = false 96 | filter-hosts = [] 97 | filter-labels = [] 98 | filter-paths = [] 99 | filter-paths-exact = [] 100 | filter-tags = [] 101 | filter-tags-exact = [] 102 | 103 | [webdav] 104 | symlinks = false 105 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | /*! 2 | rustic 3 | 4 | Application based on the [Abscissa] framework. 5 | 6 | [Abscissa]: https://github.com/iqlusioninc/abscissa 7 | */ 8 | 9 | #![warn( 10 | // unreachable_pub, // frequently check 11 | // TODO: Activate and create better docs 12 | // missing_docs, 13 | rust_2018_idioms, 14 | trivial_casts, 15 | unused_lifetimes, 16 | unused_qualifications, 17 | // TODO: Activate if you're feeling like fixing stuff 18 | // clippy::pedantic, 19 | // clippy::correctness, 20 | // clippy::suspicious, 21 | // clippy::complexity, 22 | // clippy::perf, 23 | clippy::nursery, 24 | bad_style, 25 | dead_code, 26 | improper_ctypes, 27 | missing_copy_implementations, 28 | missing_debug_implementations, 29 | non_shorthand_field_patterns, 30 | no_mangle_generic_items, 31 | overflowing_literals, 32 | path_statements, 33 | patterns_in_fns_without_body, 34 | trivial_numeric_casts, 35 | unused_results, 36 | unused_extern_crates, 37 | unused_import_braces, 38 | unconditional_recursion, 39 | unused, 40 | unused_allocation, 41 | unused_comparisons, 42 | unused_parens, 43 | while_true, 44 | clippy::cast_lossless, 45 | clippy::default_trait_access, 46 | clippy::doc_markdown, 47 | clippy::manual_string_new, 48 | clippy::match_same_arms, 49 | clippy::semicolon_if_nothing_returned, 50 | clippy::trivially_copy_pass_by_ref 51 | )] 52 | #![allow( 53 | // Popped up in 1.83.0 54 | non_local_definitions, 55 | // False-positive in WebDavFs 56 | mismatched_lifetime_syntaxes, 57 | clippy::module_name_repetitions, 58 | clippy::redundant_pub_crate, 59 | clippy::missing_const_for_fn 60 | )] 61 | 62 | pub mod application; 63 | pub(crate) mod commands; 64 | pub(crate) mod config; 65 | pub(crate) mod error; 66 | pub(crate) mod filtering; 67 | pub(crate) mod helpers; 68 | #[cfg(any(feature = "prometheus", feature = "opentelemetry"))] 69 | pub(crate) mod metrics; 70 | pub(crate) mod repository; 71 | 72 | // rustic_cli Public API 73 | 74 | /// Abscissa core prelude 75 | pub use abscissa_core::prelude::*; 76 | 77 | /// Application state 78 | pub use crate::application::RUSTIC_APP; 79 | 80 | /// Rustic config 81 | pub use crate::config::RusticConfig; 82 | 83 | /// Completions 84 | pub use crate::commands::completions::generate_completion; 85 | -------------------------------------------------------------------------------- /ECOSYSTEM.md: -------------------------------------------------------------------------------- 1 | # Ecosystem 2 | 3 | ## Crates 4 | 5 | ### rustic_backend - [Link](https://crates.io/crates/rustic_backend) 6 | 7 | A library for supporting various backends in `rustic` and `rustic_core`. 8 | 9 | ### rustic_core - [Link](https://crates.io/crates/rustic_core) 10 | 11 | Core functionality for the `rustic` ecosystem. Can be found 12 | [here](https://github.com/rustic-rs/rustic_core). 13 | 14 | ### rustic_scheduler - [Link](https://crates.io/crates/rustic_scheduler) 15 | 16 | Scheduling functionality for the `rustic` ecosystem. 17 | 18 | ### rustic_server - [Link](https://crates.io/crates/rustic_server) 19 | 20 | A possible server implementation for `rustic` to support multiple clients when 21 | backing up. 22 | 23 | ### rustic_testing (not published) - [Link](https://github.com/rustic-rs/rustic_core/tree/main/crates/testing) 24 | 25 | Testing functionality for the `rustic` ecosystem. 26 | 27 | 30 | 31 | 34 | 35 | 38 | 39 | 42 | 43 | 46 | 47 | 50 | 51 | 54 | 55 | 59 | 60 | 63 | 64 | 67 | -------------------------------------------------------------------------------- /src/commands/self_update.rs: -------------------------------------------------------------------------------- 1 | //! `self-update` subcommand 2 | 3 | use crate::{Application, RUSTIC_APP}; 4 | 5 | use abscissa_core::{Command, Runnable, Shutdown, status_err}; 6 | 7 | use anyhow::Result; 8 | 9 | /// `self-update` subcommand 10 | #[derive(clap::Parser, Command, Debug)] 11 | pub(crate) struct SelfUpdateCmd { 12 | /// Do not ask before processing the self-update 13 | #[clap(long, conflicts_with = "dry_run")] 14 | force: bool, 15 | } 16 | 17 | impl Runnable for SelfUpdateCmd { 18 | fn run(&self) { 19 | if let Err(err) = self.inner_run() { 20 | status_err!("{}", err); 21 | RUSTIC_APP.shutdown(Shutdown::Crash); 22 | }; 23 | } 24 | } 25 | 26 | impl SelfUpdateCmd { 27 | #[cfg(feature = "self-update")] 28 | fn inner_run(&self) -> Result<()> { 29 | let current_version = semver::Version::parse(self_update::cargo_crate_version!())?; 30 | 31 | let release = self_update::backends::github::Update::configure() 32 | .repo_owner("rustic-rs") 33 | .repo_name("rustic") 34 | .bin_name("rustic") 35 | .show_download_progress(true) 36 | .current_version(current_version.to_string().as_str()) 37 | .no_confirm(self.force) 38 | .build()?; 39 | 40 | let latest_release = release.get_latest_release()?; 41 | 42 | let upstream_version = semver::Version::parse(&latest_release.version)?; 43 | 44 | match current_version.cmp(&upstream_version) { 45 | std::cmp::Ordering::Greater => { 46 | println!( 47 | "Your rustic version {current_version} is newer than the stable version {upstream_version} on upstream!" 48 | ); 49 | } 50 | std::cmp::Ordering::Equal => { 51 | println!("rustic version {current_version} is up-to-date!"); 52 | } 53 | std::cmp::Ordering::Less => { 54 | let status = release.update()?; 55 | 56 | if let self_update::Status::Updated(str) = status { 57 | println!("rustic version has been updated to: {str}"); 58 | } 59 | } 60 | } 61 | 62 | Ok(()) 63 | } 64 | #[cfg(not(feature = "self-update"))] 65 | fn inner_run(&self) -> Result<()> { 66 | anyhow::bail!( 67 | "This version of rustic was built without the \"self-update\" feature. Please use your system package manager to update it." 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.github/workflows/compat.yml: -------------------------------------------------------------------------------- 1 | name: Compatibility 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - "**/*.md" 7 | push: 8 | branches: 9 | - main 10 | - "renovate/**" 11 | paths-ignore: 12 | - "**/*.md" 13 | schedule: 14 | - cron: "0 0 * * 0" 15 | merge_group: 16 | types: [checks_requested] 17 | 18 | concurrency: 19 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | test: 24 | name: Test 25 | runs-on: ${{ matrix.job.os }} 26 | strategy: 27 | matrix: 28 | rust: [stable] 29 | feature: [release] 30 | job: 31 | - os: macos-latest 32 | - os: ubuntu-latest 33 | # FIXME: windows compat tests temporarily not working 34 | # - os: windows-latest 35 | steps: 36 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 37 | if: github.event_name != 'pull_request' 38 | with: 39 | fetch-depth: 0 40 | 41 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 42 | if: github.event_name == 'pull_request' 43 | with: 44 | ref: ${{ github.event.pull_request.head.sha }} 45 | fetch-depth: 0 46 | 47 | - name: Setup Restic 48 | uses: rustic-rs/setup-restic@main 49 | 50 | - name: Install Rust toolchain 51 | uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # v1 52 | with: 53 | toolchain: stable 54 | 55 | - name: Create fixtures 56 | shell: bash 57 | run: | 58 | restic init 59 | restic backup src 60 | mv src/lib.rs lib.rs 61 | restic backup src 62 | mv lib.rs src/lib.rs 63 | env: 64 | RESTIC_REPOSITORY: ./tests/repository-fixtures/repo 65 | RESTIC_PASSWORD: restic 66 | 67 | - uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2 68 | 69 | - name: Run Cargo Test 70 | run: cargo test -r --test repositories --features ${{ matrix.feature }} -- test_restic_latest_repo_with_rustic_passes --exact --show-output --ignored 71 | 72 | result: 73 | name: Result (Compat) 74 | runs-on: ubuntu-latest 75 | needs: 76 | - test 77 | steps: 78 | - name: Mark the job as successful 79 | run: exit 0 80 | if: success() 81 | - name: Mark the job as unsuccessful 82 | run: exit 1 83 | if: "!success()" 84 | -------------------------------------------------------------------------------- /src/metrics/opentelemetry.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::BTreeMap, time::Duration}; 2 | 3 | use opentelemetry_otlp::{MetricExporter, Protocol, WithExportConfig}; 4 | use opentelemetry_sdk::{ 5 | Resource, 6 | metrics::{PeriodicReader, SdkMeterProvider}, 7 | }; 8 | 9 | use anyhow::Result; 10 | use opentelemetry::{KeyValue, metrics::MeterProvider}; 11 | use reqwest::Url; 12 | 13 | use super::{Metric, MetricValue, MetricsExporter}; 14 | 15 | pub struct OpentelemetryExporter { 16 | pub endpoint: Url, 17 | pub service_name: String, 18 | pub labels: BTreeMap, 19 | } 20 | 21 | impl MetricsExporter for OpentelemetryExporter { 22 | fn push_metrics(&self, metrics: &[Metric]) -> Result<()> { 23 | let exporter = MetricExporter::builder() 24 | .with_http() 25 | .with_protocol(Protocol::HttpBinary) 26 | .with_endpoint(self.endpoint.to_string()) 27 | .build()?; 28 | 29 | // ManualReader is not stable yet, so we use PeriodicReader 30 | let reader = PeriodicReader::builder(exporter) 31 | .with_interval(Duration::from_secs(u64::MAX)) 32 | .build(); 33 | 34 | let attributes = self 35 | .labels 36 | .iter() 37 | .map(|(k, v)| KeyValue::new(k.clone(), v.clone())); 38 | 39 | let resource = Resource::builder() 40 | .with_service_name(self.service_name.clone()) 41 | .with_attributes(attributes) 42 | .build(); 43 | 44 | let meter_provider = SdkMeterProvider::builder() 45 | .with_reader(reader) 46 | .with_resource(resource) 47 | .build(); 48 | 49 | let meter = meter_provider.meter("rustic"); 50 | 51 | for metric in metrics { 52 | match metric.value { 53 | MetricValue::Int(value) => { 54 | let gauge = &meter 55 | .u64_gauge(metric.name) 56 | .with_description(metric.description) 57 | .build(); 58 | 59 | gauge.record(value, &[]); 60 | } 61 | MetricValue::Float(value) => { 62 | let gauge = &meter 63 | .f64_gauge(metric.name) 64 | .with_description(metric.description) 65 | .build(); 66 | 67 | gauge.record(value, &[]); 68 | } 69 | }; 70 | } 71 | 72 | meter_provider.shutdown()?; 73 | Ok(()) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/commands/repair.rs: -------------------------------------------------------------------------------- 1 | //! `repair` subcommand 2 | 3 | use crate::{ 4 | Application, RUSTIC_APP, 5 | repository::{CliIndexedRepo, CliOpenRepo}, 6 | status_err, 7 | }; 8 | use abscissa_core::{Command, Runnable, Shutdown}; 9 | 10 | use anyhow::Result; 11 | 12 | use rustic_core::{RepairIndexOptions, RepairSnapshotsOptions}; 13 | 14 | /// `repair` subcommand 15 | #[derive(clap::Parser, Command, Debug)] 16 | pub(crate) struct RepairCmd { 17 | /// Subcommand to run 18 | #[clap(subcommand)] 19 | cmd: RepairSubCmd, 20 | } 21 | 22 | #[derive(clap::Subcommand, Debug, Runnable)] 23 | enum RepairSubCmd { 24 | /// Repair the repository index 25 | Index(IndexSubCmd), 26 | /// Repair snapshots 27 | Snapshots(SnapSubCmd), 28 | } 29 | 30 | #[derive(Default, Debug, clap::Parser, Command)] 31 | struct IndexSubCmd { 32 | /// Index repair options 33 | #[clap(flatten)] 34 | opts: RepairIndexOptions, 35 | } 36 | 37 | /// `repair snapshots` subcommand 38 | #[derive(Default, Debug, clap::Parser, Command)] 39 | struct SnapSubCmd { 40 | /// Snapshot repair options 41 | #[clap(flatten)] 42 | opts: RepairSnapshotsOptions, 43 | 44 | /// Snapshots to repair. If none is given, use filter to filter from all snapshots. 45 | #[clap(value_name = "ID")] 46 | ids: Vec, 47 | } 48 | 49 | impl Runnable for RepairCmd { 50 | fn run(&self) { 51 | self.cmd.run(); 52 | } 53 | } 54 | 55 | impl Runnable for IndexSubCmd { 56 | fn run(&self) { 57 | let config = RUSTIC_APP.config(); 58 | if let Err(err) = config.repository.run_open(|repo| self.inner_run(repo)) { 59 | status_err!("{}", err); 60 | RUSTIC_APP.shutdown(Shutdown::Crash); 61 | }; 62 | } 63 | } 64 | 65 | impl IndexSubCmd { 66 | fn inner_run(&self, repo: CliOpenRepo) -> Result<()> { 67 | let config = RUSTIC_APP.config(); 68 | repo.repair_index(&self.opts, config.global.dry_run)?; 69 | Ok(()) 70 | } 71 | } 72 | 73 | impl Runnable for SnapSubCmd { 74 | fn run(&self) { 75 | let config = RUSTIC_APP.config(); 76 | if let Err(err) = config.repository.run_indexed(|repo| self.inner_run(repo)) { 77 | status_err!("{}", err); 78 | RUSTIC_APP.shutdown(Shutdown::Crash); 79 | }; 80 | } 81 | } 82 | 83 | impl SnapSubCmd { 84 | fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> { 85 | let config = RUSTIC_APP.config(); 86 | let snaps = if self.ids.is_empty() { 87 | repo.get_all_snapshots()? 88 | } else { 89 | repo.get_snapshots(&self.ids)? 90 | }; 91 | repo.repair_snapshots(&self.opts, snaps, config.global.dry_run)?; 92 | Ok(()) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/commands/cat.rs: -------------------------------------------------------------------------------- 1 | //! `cat` subcommand 2 | 3 | use crate::{Application, RUSTIC_APP, status_err}; 4 | 5 | use abscissa_core::{Command, Runnable, Shutdown}; 6 | 7 | use anyhow::Result; 8 | 9 | use rustic_core::repofile::{BlobType, FileType}; 10 | 11 | /// `cat` subcommand 12 | /// 13 | /// Output the contents of a file or blob 14 | #[derive(clap::Parser, Command, Debug)] 15 | pub(crate) struct CatCmd { 16 | #[clap(subcommand)] 17 | cmd: CatSubCmd, 18 | } 19 | 20 | /// `cat` subcommands 21 | #[derive(clap::Subcommand, Debug)] 22 | enum CatSubCmd { 23 | /// Display a tree blob 24 | TreeBlob(IdOpt), 25 | /// Display a data blob 26 | DataBlob(IdOpt), 27 | /// Display the config file 28 | Config, 29 | /// Display an index file 30 | Index(IdOpt), 31 | /// Display a snapshot file 32 | Snapshot(IdOpt), 33 | /// Display a tree within a snapshot 34 | Tree(TreeOpts), 35 | } 36 | 37 | #[derive(Default, clap::Parser, Debug)] 38 | struct IdOpt { 39 | /// Id to display 40 | id: String, 41 | } 42 | 43 | #[derive(clap::Parser, Debug)] 44 | struct TreeOpts { 45 | /// Snapshot/path of the tree to display 46 | #[clap(value_name = "SNAPSHOT[:PATH]")] 47 | snap: String, 48 | } 49 | 50 | impl Runnable for CatCmd { 51 | fn run(&self) { 52 | if let Err(err) = self.inner_run() { 53 | status_err!("{}", err); 54 | RUSTIC_APP.shutdown(Shutdown::Crash); 55 | }; 56 | } 57 | } 58 | 59 | impl CatCmd { 60 | fn inner_run(&self) -> Result<()> { 61 | let config = RUSTIC_APP.config(); 62 | let data = match &self.cmd { 63 | CatSubCmd::Config => config 64 | .repository 65 | .run_open(|repo| Ok(repo.cat_file(FileType::Config, "")?))?, 66 | CatSubCmd::Index(opt) => config 67 | .repository 68 | .run_open(|repo| Ok(repo.cat_file(FileType::Index, &opt.id)?))?, 69 | CatSubCmd::Snapshot(opt) => config 70 | .repository 71 | .run_open(|repo| Ok(repo.cat_file(FileType::Snapshot, &opt.id)?))?, 72 | CatSubCmd::TreeBlob(opt) => config 73 | .repository 74 | .run_indexed(|repo| Ok(repo.cat_blob(BlobType::Tree, &opt.id)?))?, 75 | CatSubCmd::DataBlob(opt) => config 76 | .repository 77 | .run_indexed(|repo| Ok(repo.cat_blob(BlobType::Data, &opt.id)?))?, 78 | CatSubCmd::Tree(opt) => config.repository.run_indexed(|repo| { 79 | Ok(repo.cat_tree(&opt.snap, |sn| config.snapshot_filter.matches(sn))?) 80 | })?, 81 | }; 82 | println!("{}", String::from_utf8(data.to_vec())?); 83 | 84 | Ok(()) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/commands/merge.rs: -------------------------------------------------------------------------------- 1 | //! `merge` subcommand 2 | 3 | use crate::{ 4 | Application, RUSTIC_APP, 5 | repository::{CliOpenRepo, get_filtered_snapshots}, 6 | status_err, 7 | }; 8 | use abscissa_core::{Command, Runnable, Shutdown}; 9 | use anyhow::Result; 10 | use log::info; 11 | 12 | use chrono::Local; 13 | 14 | use rustic_core::{SnapshotOptions, last_modified_node, repofile::SnapshotFile}; 15 | 16 | /// `merge` subcommand 17 | #[derive(clap::Parser, Default, Command, Debug)] 18 | pub(super) struct MergeCmd { 19 | /// Snapshots to merge. If none is given, use filter options to filter from all snapshots. 20 | #[clap(value_name = "ID")] 21 | ids: Vec, 22 | 23 | /// Output generated snapshot in json format 24 | #[clap(long)] 25 | json: bool, 26 | 27 | /// Remove input snapshots after merging 28 | #[clap(long)] 29 | delete: bool, 30 | 31 | /// Snapshot options 32 | #[clap(flatten, next_help_heading = "Snapshot options")] 33 | snap_opts: SnapshotOptions, 34 | } 35 | 36 | impl Runnable for MergeCmd { 37 | fn run(&self) { 38 | if let Err(err) = RUSTIC_APP 39 | .config() 40 | .repository 41 | .run_open(|repo| self.inner_run(repo)) 42 | { 43 | status_err!("{}", err); 44 | RUSTIC_APP.shutdown(Shutdown::Crash); 45 | }; 46 | } 47 | } 48 | 49 | impl MergeCmd { 50 | fn inner_run(&self, repo: CliOpenRepo) -> Result<()> { 51 | let config = RUSTIC_APP.config(); 52 | let repo = repo.to_indexed_ids()?; 53 | 54 | let snapshots = if self.ids.is_empty() { 55 | get_filtered_snapshots(&repo)? 56 | } else { 57 | repo.get_snapshots(&self.ids)? 58 | }; 59 | 60 | // Handle dry-run mode 61 | if config.global.dry_run { 62 | println!("would have modified the following snapshots:\n {snapshots:?}"); 63 | return Ok(()); 64 | } 65 | 66 | let snap = SnapshotFile::from_options(&self.snap_opts)?; 67 | let snap = repo.merge_snapshots(&snapshots, &last_modified_node, snap)?; 68 | 69 | if self.json { 70 | let mut stdout = std::io::stdout(); 71 | serde_json::to_writer_pretty(&mut stdout, &snap)?; 72 | } 73 | info!("saved new snapshot as {}.", snap.id); 74 | 75 | if self.delete { 76 | let now = Local::now(); 77 | // TODO: Maybe use this check in repo.delete_snapshots? 78 | let snap_ids: Vec<_> = snapshots 79 | .iter() 80 | .filter(|sn| !sn.must_keep(now)) 81 | .map(|sn| sn.id) 82 | .collect(); 83 | repo.delete_snapshots(&snap_ids)?; 84 | } 85 | 86 | Ok(()) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/commands/tui.rs: -------------------------------------------------------------------------------- 1 | //! `tui` subcommand 2 | mod diff; 3 | mod ls; 4 | mod progress; 5 | mod restore; 6 | mod snapshots; 7 | pub mod summary; 8 | mod tree; 9 | mod widgets; 10 | 11 | pub use diff::Diff; 12 | pub use ls::Ls; 13 | pub use snapshots::Snapshots; 14 | 15 | use std::io; 16 | use std::sync::{Arc, RwLock}; 17 | 18 | use anyhow::Result; 19 | use crossterm::event::{KeyEvent, KeyModifiers}; 20 | use crossterm::{ 21 | event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, 22 | execute, 23 | terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, 24 | }; 25 | use progress::TuiProgressBars; 26 | use ratatui::prelude::*; 27 | use scopeguard::defer; 28 | use widgets::{Draw, ProcessEvent}; 29 | 30 | pub trait TuiResult { 31 | fn exit(&self) -> bool; 32 | } 33 | 34 | impl TuiResult for bool { 35 | fn exit(&self) -> bool { 36 | *self 37 | } 38 | } 39 | 40 | pub fn run(f: impl FnOnce(TuiProgressBars) -> Result<()>) -> Result<()> { 41 | // setup terminal 42 | let terminal = init_terminal()?; 43 | let terminal = Arc::new(RwLock::new(terminal)); 44 | 45 | // restore terminal (even when leaving through ?, early return, or panic) 46 | defer! { 47 | reset_terminal().unwrap(); 48 | } 49 | 50 | let progress = TuiProgressBars { terminal }; 51 | 52 | if let Err(err) = f(progress) { 53 | println!("{err:?}"); 54 | } 55 | 56 | Ok(()) 57 | } 58 | 59 | /// Initializes the terminal. 60 | fn init_terminal() -> Result>> { 61 | execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; 62 | enable_raw_mode()?; 63 | 64 | let backend = CrosstermBackend::new(io::stdout()); 65 | 66 | let mut terminal = Terminal::new(backend)?; 67 | terminal.hide_cursor()?; 68 | 69 | Ok(terminal) 70 | } 71 | 72 | /// Resets the terminal. 73 | fn reset_terminal() -> Result<()> { 74 | disable_raw_mode()?; 75 | execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?; 76 | Ok(()) 77 | } 78 | 79 | pub fn run_app>, B: Backend>( 80 | terminal: Arc>>, 81 | mut app: A, 82 | ) -> Result<()> { 83 | loop { 84 | _ = terminal.write().unwrap().draw(|f| ui(f, &mut app))?; 85 | let event = event::read()?; 86 | 87 | if let Event::Key(KeyEvent { 88 | code: KeyCode::Char('c'), 89 | modifiers: KeyModifiers::CONTROL, 90 | kind: KeyEventKind::Press, 91 | .. 92 | }) = event 93 | { 94 | return Ok(()); 95 | } 96 | if app.input(event)?.exit() { 97 | return Ok(()); 98 | } 99 | } 100 | } 101 | 102 | fn ui(f: &mut Frame<'_>, app: &mut A) { 103 | let area = f.area(); 104 | app.draw(area, f); 105 | } 106 | -------------------------------------------------------------------------------- /src/commands/tui/widgets.rs: -------------------------------------------------------------------------------- 1 | mod popup; 2 | mod prompt; 3 | mod select_table; 4 | mod sized_gauge; 5 | mod sized_paragraph; 6 | mod sized_table; 7 | mod text_input; 8 | mod with_block; 9 | 10 | pub use popup::*; 11 | pub use prompt::*; 12 | use ratatui::widgets::block::Title; 13 | pub use select_table::*; 14 | pub use sized_gauge::*; 15 | pub use sized_paragraph::*; 16 | pub use sized_table::*; 17 | pub use text_input::*; 18 | pub use with_block::*; 19 | 20 | use crossterm::event::Event; 21 | use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; 22 | use ratatui::prelude::*; 23 | use ratatui::widgets::{ 24 | Block, Clear, Gauge, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, 25 | TableState, 26 | }; 27 | 28 | pub trait ProcessEvent { 29 | type Result; 30 | fn input(&mut self, event: Event) -> Self::Result; 31 | } 32 | 33 | pub trait SizedWidget { 34 | fn height(&self) -> Option { 35 | None 36 | } 37 | fn width(&self) -> Option { 38 | None 39 | } 40 | } 41 | 42 | pub trait Draw { 43 | fn draw(&mut self, area: Rect, f: &mut Frame<'_>); 44 | } 45 | 46 | // the widgets we are using and convenience builders 47 | pub type PopUpInput = PopUp>; 48 | pub fn popup_input( 49 | title: impl Into>, 50 | text: &str, 51 | initial: &str, 52 | lines: u16, 53 | ) -> PopUpInput { 54 | PopUp(WithBlock::new( 55 | TextInput::new(Some(text), initial, lines, true), 56 | Block::bordered().title(title), 57 | )) 58 | } 59 | 60 | pub fn popup_scrollable_text( 61 | title: impl Into>, 62 | text: &str, 63 | lines: u16, 64 | ) -> PopUpInput { 65 | PopUp(WithBlock::new( 66 | TextInput::new(None, text, lines, false), 67 | Block::bordered().title(title), 68 | )) 69 | } 70 | 71 | pub type PopUpText = PopUp>; 72 | pub fn popup_text(title: impl Into>, text: Text<'static>) -> PopUpText { 73 | PopUp(WithBlock::new( 74 | SizedParagraph::new(text), 75 | Block::bordered().title(title), 76 | )) 77 | } 78 | 79 | pub type PopUpTable = PopUp>; 80 | pub fn popup_table( 81 | title: impl Into>, 82 | content: Vec>>, 83 | ) -> PopUpTable { 84 | PopUp(WithBlock::new( 85 | SizedTable::new(content), 86 | Block::bordered().title(title), 87 | )) 88 | } 89 | 90 | pub type PopUpPrompt = Prompt; 91 | pub fn popup_prompt(title: &'static str, text: Text<'static>) -> PopUpPrompt { 92 | Prompt(popup_text(title, text)) 93 | } 94 | 95 | pub type PopUpGauge = PopUp>; 96 | pub fn popup_gauge( 97 | title: impl Into>, 98 | text: Span<'static>, 99 | ratio: f64, 100 | ) -> PopUpGauge { 101 | PopUp(WithBlock::new( 102 | SizedGauge::new(text, ratio), 103 | Block::bordered().title(title), 104 | )) 105 | } 106 | -------------------------------------------------------------------------------- /src/commands/tui/widgets/text_input.rs: -------------------------------------------------------------------------------- 1 | use super::{Draw, Event, Frame, KeyCode, KeyEvent, ProcessEvent, Rect, SizedWidget, Style}; 2 | 3 | use crossterm::event::KeyModifiers; 4 | use tui_textarea::{CursorMove, TextArea}; 5 | 6 | pub struct TextInput { 7 | textarea: TextArea<'static>, 8 | lines: u16, 9 | changeable: bool, 10 | } 11 | 12 | pub enum TextInputResult { 13 | Cancel, 14 | Input(String), 15 | None, 16 | } 17 | 18 | impl TextInput { 19 | pub fn new(text: Option<&str>, initial: &str, lines: u16, changeable: bool) -> Self { 20 | let mut textarea = TextArea::default(); 21 | textarea.set_style(Style::default()); 22 | if let Some(text) = text { 23 | textarea.set_placeholder_text(text); 24 | } 25 | _ = textarea.insert_str(initial); 26 | if !changeable { 27 | textarea.move_cursor(CursorMove::Top); 28 | } 29 | Self { 30 | textarea, 31 | lines, 32 | changeable, 33 | } 34 | } 35 | } 36 | 37 | impl SizedWidget for TextInput { 38 | fn height(&self) -> Option { 39 | Some(self.lines) 40 | } 41 | } 42 | 43 | impl Draw for TextInput { 44 | fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { 45 | f.render_widget(&self.textarea, area); 46 | } 47 | } 48 | 49 | impl ProcessEvent for TextInput { 50 | type Result = TextInputResult; 51 | fn input(&mut self, event: Event) -> TextInputResult { 52 | if let Event::Key(key) = event { 53 | let KeyEvent { 54 | code, modifiers, .. 55 | } = key; 56 | if self.changeable { 57 | match (code, modifiers) { 58 | (KeyCode::Esc, _) => return TextInputResult::Cancel, 59 | (KeyCode::Enter, _) if self.lines == 1 => { 60 | return TextInputResult::Input(self.textarea.lines().join("\n")); 61 | } 62 | (KeyCode::Char('s'), KeyModifiers::CONTROL) => { 63 | return TextInputResult::Input(self.textarea.lines().join("\n")); 64 | } 65 | _ => { 66 | _ = self.textarea.input(event); 67 | } 68 | } 69 | } else { 70 | match (code, modifiers) { 71 | (KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q' | 'x'), _) => { 72 | return TextInputResult::Cancel; 73 | } 74 | (KeyCode::Home, _) => { 75 | self.textarea.move_cursor(CursorMove::Top); 76 | } 77 | (KeyCode::End, _) => { 78 | self.textarea.move_cursor(CursorMove::Bottom); 79 | } 80 | (KeyCode::PageDown | KeyCode::PageUp | KeyCode::Up | KeyCode::Down, _) => { 81 | _ = self.textarea.input(key); 82 | } 83 | _ => {} 84 | } 85 | } 86 | } 87 | TextInputResult::None 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ default configuration file 2 | # https://git-cliff.org/docs/configuration 3 | # 4 | # Lines starting with "#" are comments. 5 | # Configuration options are organized into tables and keys. 6 | # See documentation for more information on available options. 7 | 8 | [changelog] 9 | # changelog header 10 | header = """ 11 | # Changelog\n 12 | All notable changes to this project will be documented in this file.\n 13 | """ 14 | # template for the changelog body 15 | # https://tera.netlify.app/docs 16 | body = """ 17 | {% if version %}\ 18 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 19 | {% else %}\ 20 | ## [unreleased] 21 | {% endif %}\ 22 | {% for group, commits in commits | group_by(attribute="group") %} 23 | ### {{ group | upper_first }} 24 | {% for commit in commits %} 25 | - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\ 26 | {% endfor %} 27 | {% endfor %}\n 28 | """ 29 | # remove the leading and trailing whitespace from the template 30 | trim = true 31 | # changelog footer 32 | footer = """ 33 | 34 | """ 35 | # postprocessors 36 | postprocessors = [ 37 | { pattern = '', replace = "https://github.com/rustic-rs/rustic" }, 38 | ] 39 | [git] 40 | # parse the commits based on https://www.conventionalcommits.org 41 | conventional_commits = true 42 | # filter out the commits that are not conventional 43 | filter_unconventional = true 44 | # process each line of a commit as an individual commit 45 | split_commits = false 46 | # regex for preprocessing the commit messages 47 | commit_preprocessors = [ 48 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))" }, # replace issue numbers 49 | ] 50 | # regex for parsing and grouping commits 51 | commit_parsers = [ 52 | { message = "^feat", group = "Features" }, 53 | { message = "^fix", group = "Bug Fixes" }, 54 | { message = "^doc", group = "Documentation" }, 55 | { message = "^perf", group = "Performance" }, 56 | { message = "^refactor", group = "Refactor" }, 57 | { message = "^style", group = "Styling", skip = true }, # we ignore styling in the changelog 58 | { message = "^test", group = "Testing" }, 59 | { message = "^chore\\(release\\): prepare for", skip = true }, 60 | { message = "^chore\\(deps\\)", skip = true }, 61 | { message = "^chore\\(pr\\)", skip = true }, 62 | { message = "^chore\\(pull\\)", skip = true }, 63 | { message = "^chore|ci", group = "Miscellaneous Tasks" }, 64 | { body = ".*security", group = "Security" }, 65 | { message = "^revert", group = "Revert" }, 66 | ] 67 | # protect breaking changes from being skipped due to matching a skipping commit_parser 68 | protect_breaking_commits = false 69 | # filter out the commits that are not matched by commit parsers 70 | filter_commits = false 71 | # glob pattern for matching git tags 72 | tag_pattern = "v[0-9]*" 73 | # regex for skipping tags 74 | skip_tags = "v0.1.0-beta.1" 75 | # regex for ignoring tags 76 | ignore_tags = "" 77 | # sort the tags topologically 78 | topo_order = false 79 | # sort the commits inside sections by oldest/newest order 80 | sort_commits = "oldest" 81 | # limit the number of commits included in the changelog. 82 | # limit_commits = 42 83 | -------------------------------------------------------------------------------- /.github/workflows/cross-ci.yml: -------------------------------------------------------------------------------- 1 | name: Cross CI 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - "**/*.md" 7 | push: 8 | branches: 9 | - main 10 | paths-ignore: 11 | - "**/*.md" 12 | merge_group: 13 | types: [checks_requested] 14 | 15 | defaults: 16 | run: 17 | shell: bash 18 | 19 | concurrency: 20 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | cross-check: 25 | name: Cross checking ${{ matrix.job.target }} on ${{ matrix.rust }} 26 | runs-on: ${{ matrix.job.os }} 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | rust: [stable] 31 | feature: [release] 32 | job: 33 | - os: windows-latest 34 | os-name: windows 35 | target: x86_64-pc-windows-msvc 36 | architecture: x86_64 37 | use-cross: false 38 | - os: windows-latest 39 | os-name: windows 40 | target: x86_64-pc-windows-gnu 41 | architecture: x86_64 42 | use-cross: false 43 | - os: macos-latest 44 | os-name: macos 45 | target: x86_64-apple-darwin 46 | architecture: x86_64 47 | use-cross: false 48 | - os: macos-latest 49 | os-name: macos 50 | target: aarch64-apple-darwin 51 | architecture: arm64 52 | use-cross: true 53 | - os: ubuntu-latest 54 | os-name: linux 55 | target: x86_64-unknown-linux-gnu 56 | architecture: x86_64 57 | use-cross: false 58 | - os: ubuntu-latest 59 | os-name: linux 60 | target: x86_64-unknown-linux-musl 61 | architecture: x86_64 62 | use-cross: false 63 | - os: ubuntu-latest 64 | os-name: linux 65 | target: aarch64-unknown-linux-gnu 66 | architecture: arm64 67 | use-cross: true 68 | - os: ubuntu-latest 69 | os-name: linux 70 | target: aarch64-unknown-linux-musl 71 | architecture: arm64 72 | use-cross: true 73 | - os: ubuntu-latest 74 | os-name: linux 75 | target: i686-unknown-linux-gnu 76 | architecture: i686 77 | use-cross: true 78 | - os: ubuntu-latest 79 | os-name: netbsd 80 | target: x86_64-unknown-netbsd 81 | architecture: x86_64 82 | use-cross: true 83 | - os: ubuntu-latest 84 | os-name: linux 85 | target: armv7-unknown-linux-gnueabihf 86 | architecture: armv7 87 | use-cross: true 88 | 89 | steps: 90 | - name: Checkout repository 91 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 92 | 93 | - name: Run Cross-CI action 94 | uses: rustic-rs/cross-ci-action@main 95 | with: 96 | toolchain: ${{ matrix.rust }} 97 | target: ${{ matrix.job.target }} 98 | use-cross: ${{ matrix.job.use-cross }} 99 | all-features: "false" 100 | feature: ${{ matrix.feature }} 101 | 102 | result: 103 | name: Result (Cross-CI) 104 | runs-on: ubuntu-latest 105 | needs: cross-check 106 | steps: 107 | - name: Mark the job as successful 108 | run: exit 0 109 | if: success() 110 | - name: Mark the job as unsuccessful 111 | run: exit 1 112 | if: "!success()" 113 | -------------------------------------------------------------------------------- /src/commands/restore.rs: -------------------------------------------------------------------------------- 1 | //! `restore` subcommand 2 | 3 | use crate::{ 4 | Application, RUSTIC_APP, helpers::bytes_size_to_string, repository::CliIndexedRepo, status_err, 5 | }; 6 | 7 | use abscissa_core::{Command, Runnable, Shutdown}; 8 | use anyhow::Result; 9 | use log::info; 10 | 11 | use rustic_core::{LocalDestination, LsOptions, RestoreOptions}; 12 | 13 | use crate::filtering::SnapshotFilter; 14 | 15 | /// `restore` subcommand 16 | #[allow(clippy::struct_excessive_bools)] 17 | #[derive(clap::Parser, Command, Debug)] 18 | pub(crate) struct RestoreCmd { 19 | /// Snapshot/path to restore 20 | #[clap(value_name = "SNAPSHOT[:PATH]")] 21 | snap: String, 22 | 23 | /// Restore destination 24 | #[clap(value_name = "DESTINATION")] 25 | dest: String, 26 | 27 | /// Restore options 28 | #[clap(flatten)] 29 | opts: RestoreOptions, 30 | 31 | /// List options 32 | #[clap(flatten)] 33 | ls_opts: LsOptions, 34 | 35 | /// Snapshot filter options (when using latest) 36 | #[clap( 37 | flatten, 38 | next_help_heading = "Snapshot filter options (when using latest)" 39 | )] 40 | filter: SnapshotFilter, 41 | } 42 | impl Runnable for RestoreCmd { 43 | fn run(&self) { 44 | if let Err(err) = RUSTIC_APP 45 | .config() 46 | .repository 47 | .run_indexed(|repo| self.inner_run(repo)) 48 | { 49 | status_err!("{}", err); 50 | RUSTIC_APP.shutdown(Shutdown::Crash); 51 | }; 52 | } 53 | } 54 | 55 | impl RestoreCmd { 56 | fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> { 57 | let config = RUSTIC_APP.config(); 58 | let dry_run = config.global.dry_run; 59 | 60 | let node = 61 | repo.node_from_snapshot_path(&self.snap, |sn| config.snapshot_filter.matches(sn))?; 62 | 63 | // for restore, always recurse into tree 64 | let mut ls_opts = self.ls_opts.clone(); 65 | ls_opts.recursive = true; 66 | let ls = repo.ls(&node, &ls_opts)?; 67 | 68 | let dest = LocalDestination::new(&self.dest, true, !node.is_dir())?; 69 | 70 | let restore_infos = repo.prepare_restore(&self.opts, ls, &dest, dry_run)?; 71 | 72 | let fs = restore_infos.stats.files; 73 | println!( 74 | "Files: {} to restore, {} unchanged, {} verified, {} to modify, {} additional", 75 | fs.restore, fs.unchanged, fs.verified, fs.modify, fs.additional 76 | ); 77 | let ds = restore_infos.stats.dirs; 78 | println!( 79 | "Dirs: {} to restore, {} to modify, {} additional", 80 | ds.restore, ds.modify, ds.additional 81 | ); 82 | 83 | info!( 84 | "total restore size: {}", 85 | bytes_size_to_string(restore_infos.restore_size) 86 | ); 87 | if restore_infos.matched_size > 0 { 88 | info!( 89 | "using {} of existing file contents.", 90 | bytes_size_to_string(restore_infos.matched_size) 91 | ); 92 | } 93 | if restore_infos.restore_size == 0 { 94 | info!("all file contents are fine."); 95 | } 96 | 97 | if dry_run { 98 | repo.warm_up(restore_infos.to_packs().into_iter())?; 99 | } else { 100 | // save some memory 101 | let repo = repo.drop_data_from_index(); 102 | 103 | let ls = repo.ls(&node, &ls_opts)?; 104 | repo.restore(restore_infos, &self.opts, ls, &dest)?; 105 | println!("restore done."); 106 | } 107 | 108 | Ok(()) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/commands/list.rs: -------------------------------------------------------------------------------- 1 | //! `list` subcommand 2 | 3 | use std::num::NonZero; 4 | 5 | use crate::{Application, RUSTIC_APP, repository::CliOpenRepo, status_err}; 6 | 7 | use abscissa_core::{Command, Runnable, Shutdown}; 8 | use anyhow::{Result, bail}; 9 | 10 | use rustic_core::repofile::{IndexFile, IndexId, KeyId, PackId, SnapshotId}; 11 | 12 | /// `list` subcommand 13 | #[derive(clap::Parser, Command, Debug)] 14 | pub(crate) struct ListCmd { 15 | /// File types to list 16 | #[clap(value_parser=["blobs", "indexpacks", "indexcontent", "index", "packs", "snapshots", "keys"])] 17 | tpe: String, 18 | } 19 | 20 | impl Runnable for ListCmd { 21 | fn run(&self) { 22 | if let Err(err) = RUSTIC_APP 23 | .config() 24 | .repository 25 | .run_open(|repo| self.inner_run(repo)) 26 | { 27 | status_err!("{}", err); 28 | RUSTIC_APP.shutdown(Shutdown::Crash); 29 | }; 30 | } 31 | } 32 | 33 | impl ListCmd { 34 | fn inner_run(&self, repo: CliOpenRepo) -> Result<()> { 35 | match self.tpe.as_str() { 36 | // special treatment for listing blobs: read the index and display it 37 | "blobs" | "indexpacks" | "indexcontent" => { 38 | for item in repo.stream_files::()? { 39 | let (_, index) = item?; 40 | for pack in index.packs { 41 | match self.tpe.as_str() { 42 | "blobs" => { 43 | for blob in pack.blobs { 44 | println!("{:?} {:?}", blob.tpe, blob.id); 45 | } 46 | } 47 | "indexcontent" => { 48 | for blob in pack.blobs { 49 | println!( 50 | "{:?} {:?} {:?} {} {}", 51 | blob.tpe, 52 | blob.id, 53 | pack.id, 54 | blob.length, 55 | blob.uncompressed_length.map_or(0, NonZero::get) 56 | ); 57 | } 58 | } 59 | "indexpacks" => println!( 60 | "{:?} {:?} {} {}", 61 | pack.blob_type(), 62 | pack.id, 63 | pack.pack_size(), 64 | pack.time.map_or_else(String::new, |time| format!( 65 | "{}", 66 | time.format("%Y-%m-%d %H:%M:%S") 67 | )) 68 | ), 69 | t => { 70 | bail!("invalid type: {}", t); 71 | } 72 | } 73 | } 74 | } 75 | } 76 | "index" => { 77 | for id in repo.list::()? { 78 | println!("{id:?}"); 79 | } 80 | } 81 | "packs" => { 82 | for id in repo.list::()? { 83 | println!("{id:?}"); 84 | } 85 | } 86 | "snapshots" => { 87 | for id in repo.list::()? { 88 | println!("{id:?}"); 89 | } 90 | } 91 | "keys" => { 92 | for id in repo.list::()? { 93 | println!("{id:?}"); 94 | } 95 | } 96 | t => { 97 | bail!("invalid type: {}", t); 98 | } 99 | }; 100 | 101 | Ok(()) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/commands/init.rs: -------------------------------------------------------------------------------- 1 | //! `init` subcommand 2 | 3 | use abscissa_core::{Command, Runnable, Shutdown, status_err}; 4 | use anyhow::{Result, bail}; 5 | use dialoguer::Password; 6 | 7 | use crate::{Application, RUSTIC_APP, repository::CliRepo}; 8 | 9 | use rustic_core::{ConfigOptions, KeyOptions, OpenStatus, Repository}; 10 | 11 | /// `init` subcommand 12 | #[derive(clap::Parser, Command, Debug)] 13 | pub(crate) struct InitCmd { 14 | /// Key options 15 | #[clap(flatten, next_help_heading = "Key options")] 16 | key_opts: KeyOptions, 17 | 18 | /// Config options 19 | #[clap(flatten, next_help_heading = "Config options")] 20 | config_opts: ConfigOptions, 21 | } 22 | 23 | impl Runnable for InitCmd { 24 | fn run(&self) { 25 | if let Err(err) = RUSTIC_APP 26 | .config() 27 | .repository 28 | .run(|repo| self.inner_run(repo)) 29 | { 30 | status_err!("{}", err); 31 | RUSTIC_APP.shutdown(Shutdown::Crash); 32 | }; 33 | } 34 | } 35 | 36 | impl InitCmd { 37 | fn inner_run(&self, repo: CliRepo) -> Result<()> { 38 | let config = RUSTIC_APP.config(); 39 | 40 | // Note: This is again checked in repo.init_with_password(), however we want to inform 41 | // users before they are prompted to enter a password 42 | if repo.config_id()?.is_some() { 43 | bail!("Config file already exists. Aborting."); 44 | } 45 | 46 | // Handle dry-run mode 47 | if config.global.dry_run { 48 | bail!( 49 | "cannot initialize repository {} in dry-run mode!", 50 | repo.name 51 | ); 52 | } 53 | 54 | let _ = init(repo.0, &self.key_opts, &self.config_opts)?; 55 | Ok(()) 56 | } 57 | } 58 | 59 | /// Initialize repository 60 | /// 61 | /// # Arguments 62 | /// 63 | /// * `repo` - Repository to initialize 64 | /// * `key_opts` - Key options 65 | /// * `config_opts` - Config options 66 | /// 67 | /// # Errors 68 | /// 69 | /// * [`RepositoryErrorKind::OpeningPasswordFileFailed`] - If opening the password file failed 70 | /// * [`RepositoryErrorKind::ReadingPasswordFromReaderFailed`] - If reading the password failed 71 | /// * [`RepositoryErrorKind::FromSplitError`] - If splitting the password command failed 72 | /// * [`RepositoryErrorKind::PasswordCommandExecutionFailed`] - If executing the password command failed 73 | /// * [`RepositoryErrorKind::ReadingPasswordFromCommandFailed`] - If reading the password from the command failed 74 | /// 75 | /// # Returns 76 | /// 77 | /// Returns the initialized repository 78 | /// 79 | /// [`RepositoryErrorKind::OpeningPasswordFileFailed`]: rustic_core::error::RepositoryErrorKind::OpeningPasswordFileFailed 80 | /// [`RepositoryErrorKind::ReadingPasswordFromReaderFailed`]: rustic_core::error::RepositoryErrorKind::ReadingPasswordFromReaderFailed 81 | /// [`RepositoryErrorKind::FromSplitError`]: rustic_core::error::RepositoryErrorKind::FromSplitError 82 | /// [`RepositoryErrorKind::PasswordCommandExecutionFailed`]: rustic_core::error::RepositoryErrorKind::PasswordCommandExecutionFailed 83 | /// [`RepositoryErrorKind::ReadingPasswordFromCommandFailed`]: rustic_core::error::RepositoryErrorKind::ReadingPasswordFromCommandFailed 84 | pub(crate) fn init( 85 | repo: Repository, 86 | key_opts: &KeyOptions, 87 | config_opts: &ConfigOptions, 88 | ) -> Result> { 89 | let pass = init_password(&repo)?; 90 | Ok(repo.init_with_password(&pass, key_opts, config_opts)?) 91 | } 92 | 93 | pub(crate) fn init_password(repo: &Repository) -> Result { 94 | let pass = repo.password()?.unwrap_or_else(|| { 95 | match Password::new() 96 | .with_prompt("enter password for new key") 97 | .allow_empty_password(true) 98 | .with_confirmation("confirm password", "passwords do not match") 99 | .interact() 100 | { 101 | Ok(it) => it, 102 | Err(err) => { 103 | status_err!("{}", err); 104 | RUSTIC_APP.shutdown(Shutdown::Crash); 105 | } 106 | } 107 | }); 108 | 109 | Ok(pass) 110 | } 111 | -------------------------------------------------------------------------------- /.github/workflows/prebuilt-pr.yml: -------------------------------------------------------------------------------- 1 | name: Create PR artifacts 2 | 3 | on: 4 | pull_request: 5 | types: [labeled] 6 | branches: 7 | - main 8 | paths-ignore: 9 | - "**/*.md" 10 | - "docs/**/*" 11 | workflow_dispatch: 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 15 | cancel-in-progress: true 16 | 17 | env: 18 | BINARY_NAME: rustic 19 | 20 | jobs: 21 | pr-build: 22 | if: ${{ github.event.label.name == 'S-build' && github.repository_owner == 'rustic-rs' }} 23 | name: Build PR on ${{ matrix.job.target }} 24 | runs-on: ${{ matrix.job.os }} 25 | strategy: 26 | matrix: 27 | rust: [stable] 28 | job: 29 | - os: windows-latest 30 | os-name: windows 31 | target: x86_64-pc-windows-msvc 32 | architecture: x86_64 33 | binary-postfix: ".exe" 34 | use-cross: false 35 | - os: macos-latest 36 | os-name: macos 37 | target: x86_64-apple-darwin 38 | architecture: x86_64 39 | binary-postfix: "" 40 | use-cross: false 41 | - os: macos-latest 42 | os-name: macos 43 | target: aarch64-apple-darwin 44 | architecture: arm64 45 | binary-postfix: "" 46 | use-cross: true 47 | - os: ubuntu-latest 48 | os-name: linux 49 | target: x86_64-unknown-linux-gnu 50 | architecture: x86_64 51 | binary-postfix: "" 52 | use-cross: false 53 | - os: ubuntu-latest 54 | os-name: linux 55 | target: x86_64-unknown-linux-musl 56 | architecture: x86_64 57 | binary-postfix: "" 58 | use-cross: false 59 | - os: ubuntu-latest 60 | os-name: linux 61 | target: aarch64-unknown-linux-gnu 62 | architecture: arm64 63 | binary-postfix: "" 64 | use-cross: true 65 | - os: ubuntu-latest 66 | os-name: linux 67 | target: i686-unknown-linux-gnu 68 | architecture: i686 69 | binary-postfix: "" 70 | use-cross: true 71 | - os: ubuntu-latest 72 | os-name: netbsd 73 | target: x86_64-unknown-netbsd 74 | architecture: x86_64 75 | binary-postfix: "" 76 | use-cross: true 77 | - os: ubuntu-latest 78 | os-name: linux 79 | target: armv7-unknown-linux-gnueabihf 80 | architecture: armv7 81 | binary-postfix: "" 82 | use-cross: true 83 | 84 | steps: 85 | - name: Checkout repository 86 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 87 | with: 88 | fetch-depth: 0 # fetch all history so that git describe works 89 | - name: Create binary artifact 90 | uses: rustic-rs/create-binary-artifact-action@main # dev 91 | with: 92 | toolchain: ${{ matrix.rust }} 93 | target: ${{ matrix.job.target }} 94 | use-cross: ${{ matrix.job.use-cross }} 95 | describe-tag-suffix: -${{ github.run_id }}-${{ github.run_attempt }} 96 | binary-postfix: ${{ matrix.job.binary-postfix }} 97 | os: ${{ runner.os }} 98 | binary-name: ${{ env.BINARY_NAME }} 99 | package-secondary-name: ${{ matrix.job.target}} 100 | github-token: ${{ secrets.GITHUB_TOKEN }} 101 | github-ref: ${{ github.ref }} 102 | sign-release: false 103 | hash-release: true 104 | use-project-version: true 105 | 106 | remove-build-label: 107 | name: Remove build label 108 | needs: pr-build 109 | permissions: 110 | contents: read 111 | issues: write 112 | pull-requests: write 113 | runs-on: ubuntu-latest 114 | if: | 115 | always() && 116 | ! contains(needs.*.result, 'skipped') && 117 | github.repository_owner == 'rustic-rs' 118 | steps: 119 | - name: Remove label 120 | env: 121 | GH_TOKEN: ${{ github.token }} 122 | run: | 123 | gh api \ 124 | --method DELETE \ 125 | -H "Accept: application/vnd.github+json" \ 126 | -H "X-GitHub-Api-Version: 2022-11-28" \ 127 | /repos/${{ github.repository }}/issues/${{ github.event.number }}/labels/S-build 128 | -------------------------------------------------------------------------------- /src/commands/tui/tree.rs: -------------------------------------------------------------------------------- 1 | #[derive(PartialEq, Eq)] 2 | pub struct TreeNode { 3 | pub data: Data, 4 | pub open: bool, 5 | pub children: Vec>, 6 | } 7 | 8 | #[derive(PartialEq, Eq)] 9 | pub enum Tree { 10 | Node(TreeNode), 11 | Leaf(LeafData), 12 | } 13 | 14 | impl Tree { 15 | pub fn leaf(data: LeafData) -> Self { 16 | Self::Leaf(data) 17 | } 18 | pub fn node(data: Data, open: bool, children: Vec) -> Self { 19 | Self::Node(TreeNode { 20 | data, 21 | open, 22 | children, 23 | }) 24 | } 25 | 26 | pub fn child_count(&self) -> usize { 27 | match self { 28 | Self::Leaf(_) => 0, 29 | Self::Node(TreeNode { children, .. }) => { 30 | children.len() + children.iter().map(Self::child_count).sum::() 31 | } 32 | } 33 | } 34 | 35 | pub fn leaf_data(&self) -> Option<&LeafData> { 36 | match self { 37 | Self::Node(_) => None, 38 | Self::Leaf(data) => Some(data), 39 | } 40 | } 41 | 42 | pub fn openable(&self) -> bool { 43 | matches!(self, Self::Node(node) if !node.open) 44 | } 45 | 46 | pub fn open(&mut self) { 47 | if let Self::Node(node) = self { 48 | node.open = true; 49 | } 50 | } 51 | pub fn close(&mut self) { 52 | if let Self::Node(node) = self { 53 | node.open = false; 54 | } 55 | } 56 | 57 | pub fn iter(&self) -> impl Iterator> { 58 | TreeIter { 59 | tree: Some(self), 60 | iter_stack: Vec::new(), 61 | only_open: false, 62 | } 63 | } 64 | 65 | // iter open tree descending only into open nodes. 66 | // Note: This iterator skips the root node! 67 | pub fn iter_open(&self) -> impl Iterator> { 68 | TreeIter { 69 | tree: Some(self), 70 | iter_stack: Vec::new(), 71 | only_open: true, 72 | } 73 | .skip(1) 74 | } 75 | 76 | pub fn nth_mut(&mut self, n: usize) -> Option<&mut Self> { 77 | let mut count = 0; 78 | let mut tree = Some(self); 79 | let mut iter_stack = Vec::new(); 80 | loop { 81 | if count == n + 1 { 82 | return tree; 83 | } 84 | let item = tree?; 85 | if let Self::Node(node) = item { 86 | if node.open { 87 | iter_stack.push(node.children.iter_mut()); 88 | } 89 | } 90 | tree = next_from_iter_stack(&mut iter_stack); 91 | count += 1; 92 | } 93 | } 94 | } 95 | 96 | pub struct TreeIterItem<'a, Data, LeadData> { 97 | pub depth: usize, 98 | pub tree: &'a Tree, 99 | } 100 | 101 | impl TreeIterItem<'_, Data, LeafData> { 102 | pub fn leaf_data(&self) -> Option<&LeafData> { 103 | self.tree.leaf_data() 104 | } 105 | } 106 | 107 | pub struct TreeIter<'a, Data, LeafData> { 108 | tree: Option<&'a Tree>, 109 | iter_stack: Vec>>, 110 | only_open: bool, 111 | } 112 | 113 | impl<'a, Data, LeafData> Iterator for TreeIter<'a, Data, LeafData> { 114 | type Item = TreeIterItem<'a, Data, LeafData>; 115 | fn next(&mut self) -> Option { 116 | let item = self.tree?; 117 | let depth = self.iter_stack.len(); 118 | if let Tree::Node(node) = item { 119 | if !self.only_open || node.open { 120 | self.iter_stack.push(node.children.iter()); 121 | } 122 | } 123 | 124 | self.tree = next_from_iter_stack(&mut self.iter_stack); 125 | Some(TreeIterItem { depth, tree: item }) 126 | } 127 | } 128 | 129 | // helper function to get next item from iteration stack when iterating over a Tree 130 | fn next_from_iter_stack(stack: &mut Vec>) -> Option { 131 | loop { 132 | match stack.pop() { 133 | None => { 134 | break None; 135 | } 136 | Some(mut iter) => { 137 | if let Some(next) = iter.next() { 138 | stack.push(iter); 139 | break Some(next); 140 | } 141 | } 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/config/hooks.rs: -------------------------------------------------------------------------------- 1 | //! rustic hooks configuration 2 | //! 3 | //! Hooks are commands that are executed before and after every rustic operation. 4 | //! They can be used to run custom scripts or commands before and after a backup, 5 | //! copy, forget, prune or other operation. 6 | //! 7 | //! Depending on the hook type, the command is being executed at a different point 8 | //! in the lifecycle of the program. The following hooks are available: 9 | //! 10 | //! - global hooks 11 | //! - repository hooks 12 | //! - backup hooks 13 | //! - specific source-related hooks 14 | 15 | use std::collections::HashMap; 16 | 17 | use anyhow::Result; 18 | use conflate::Merge; 19 | use serde::{Deserialize, Serialize}; 20 | 21 | use rustic_core::CommandInput; 22 | 23 | #[derive(Debug, Default, Clone, Serialize, Deserialize, Merge)] 24 | #[serde(default, rename_all = "kebab-case", deny_unknown_fields)] 25 | pub struct Hooks { 26 | /// Call this command before every rustic operation 27 | #[merge(strategy = conflate::vec::append)] 28 | pub run_before: Vec, 29 | 30 | /// Call this command after every successful rustic operation 31 | #[merge(strategy = conflate::vec::append)] 32 | pub run_after: Vec, 33 | 34 | /// Call this command after every failed rustic operation 35 | #[merge(strategy = conflate::vec::append)] 36 | pub run_failed: Vec, 37 | 38 | /// Call this command after every rustic operation 39 | #[merge(strategy = conflate::vec::append)] 40 | pub run_finally: Vec, 41 | 42 | #[serde(skip)] 43 | #[merge(skip)] 44 | pub context: String, 45 | 46 | #[serde(skip)] 47 | #[merge(skip)] 48 | pub env: HashMap, 49 | } 50 | 51 | impl Hooks { 52 | pub fn with_context(&self, context: &str) -> Self { 53 | let mut hooks = self.clone(); 54 | hooks.context = context.to_string(); 55 | hooks 56 | } 57 | 58 | pub fn with_env(&self, env: &HashMap) -> Self { 59 | let mut hooks = self.clone(); 60 | hooks.env = HashMap::::new(); 61 | for (key, val) in env.iter() { 62 | _ = hooks.env.insert(key.clone(), val.clone()); 63 | } 64 | hooks 65 | } 66 | 67 | fn run_all( 68 | cmds: &[CommandInput], 69 | context: &str, 70 | what: &str, 71 | env: &HashMap, 72 | ) -> Result<()> { 73 | let mut env = env.clone(); 74 | 75 | let _ = env.insert("RUSTIC_HOOK_TYPE".to_string(), what.to_string()); 76 | 77 | for cmd in cmds { 78 | cmd.run(context, what, &env)?; 79 | } 80 | 81 | Ok(()) 82 | } 83 | 84 | pub fn run_before(&self) -> Result<()> { 85 | Self::run_all(&self.run_before, &self.context, "run-before", &self.env) 86 | } 87 | 88 | pub fn run_after(&self) -> Result<()> { 89 | Self::run_all(&self.run_after, &self.context, "run-after", &self.env) 90 | } 91 | 92 | pub fn run_failed(&self) -> Result<()> { 93 | Self::run_all(&self.run_failed, &self.context, "run-failed", &self.env) 94 | } 95 | 96 | pub fn run_finally(&self) -> Result<()> { 97 | Self::run_all(&self.run_finally, &self.context, "run-finally", &self.env) 98 | } 99 | 100 | /// Run the given closure using the specified hooks. 101 | /// 102 | /// Note: after a failure no error handling is done for the hooks `run_failed` 103 | /// and `run_finally` which must run after. However, they already log a warning 104 | /// or error depending on the `on_failure` setting. 105 | pub fn use_with(&self, f: impl FnOnce() -> Result) -> Result { 106 | match self.run_before() { 107 | Ok(()) => match f() { 108 | Ok(result) => match self.run_after() { 109 | Ok(()) => { 110 | self.run_finally()?; 111 | Ok(result) 112 | } 113 | Err(err_after) => { 114 | _ = self.run_finally(); 115 | Err(err_after) 116 | } 117 | }, 118 | Err(err_f) => { 119 | _ = self.run_failed(); 120 | _ = self.run_finally(); 121 | Err(err_f) 122 | } 123 | }, 124 | Err(err_before) => { 125 | _ = self.run_failed(); 126 | _ = self.run_finally(); 127 | Err(err_before) 128 | } 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/metrics/prometheus.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result, bail}; 2 | use log::debug; 3 | use prometheus::{Registry, register_gauge_with_registry}; 4 | use reqwest::Url; 5 | use std::collections::BTreeMap; 6 | 7 | use crate::metrics::MetricValue::*; 8 | 9 | use super::{Metric, MetricsExporter}; 10 | 11 | pub struct PrometheusExporter { 12 | pub endpoint: Url, 13 | pub job_name: String, 14 | pub grouping: BTreeMap, 15 | pub prometheus_user: Option, 16 | pub prometheus_pass: Option, 17 | } 18 | 19 | impl MetricsExporter for PrometheusExporter { 20 | fn push_metrics(&self, metrics: &[Metric]) -> Result<()> { 21 | use prometheus::{Encoder, ProtobufEncoder}; 22 | use reqwest::{StatusCode, blocking::Client, header::CONTENT_TYPE}; 23 | 24 | let registry = Registry::new(); 25 | 26 | for metric in metrics { 27 | let gauge = register_gauge_with_registry!(metric.name, metric.description, registry) 28 | .context("registering prometheus gauge")?; 29 | 30 | gauge.set(match metric.value { 31 | Int(i) => i as f64, 32 | Float(f) => f, 33 | }); 34 | } 35 | 36 | let (full_url, encoded_metrics) = self.make_url_and_encoded_metrics(®istry)?; 37 | 38 | debug!("using url: {full_url}"); 39 | 40 | let mut builder = Client::new() 41 | .post(full_url) 42 | .header(CONTENT_TYPE, ProtobufEncoder::new().format_type()) 43 | .body(encoded_metrics); 44 | 45 | if let Some(username) = &self.prometheus_user { 46 | debug!( 47 | "using auth {} {}", 48 | username, 49 | self.prometheus_pass.as_deref().unwrap_or("[NOT SET]") 50 | ); 51 | builder = builder.basic_auth(username, self.prometheus_pass.as_ref()); 52 | } 53 | 54 | let response = builder.send()?; 55 | 56 | match response.status() { 57 | StatusCode::ACCEPTED | StatusCode::OK => Ok(()), 58 | _ => bail!( 59 | "unexpected status code {} while pushing to {}", 60 | response.status(), 61 | self.endpoint 62 | ), 63 | } 64 | } 65 | } 66 | 67 | impl PrometheusExporter { 68 | // TODO: This should be actually part of the prometheus crate, see https://github.com/tikv/rust-prometheus/issues/536 69 | fn make_url_and_encoded_metrics(&self, registry: &Registry) -> Result<(Url, Vec)> { 70 | use base64::prelude::*; 71 | use prometheus::{Encoder, ProtobufEncoder}; 72 | 73 | let mut url_components = vec![ 74 | "metrics".to_string(), 75 | "job@base64".to_string(), 76 | BASE64_URL_SAFE_NO_PAD.encode(&self.job_name), 77 | ]; 78 | 79 | for (ln, lv) in &self.grouping { 80 | // See https://github.com/tikv/rust-prometheus/issues/535 81 | if !lv.is_empty() { 82 | // TODO: check label name 83 | let name = ln.to_string() + "@base64"; 84 | url_components.push(name); 85 | url_components.push(BASE64_URL_SAFE_NO_PAD.encode(lv)); 86 | } 87 | } 88 | let url = self.endpoint.join(&url_components.join("/"))?; 89 | 90 | let encoder = ProtobufEncoder::new(); 91 | let mut buf = Vec::new(); 92 | for mf in registry.gather() { 93 | // Note: We don't check here for pre-existing grouping labels, as we don't set them 94 | 95 | // Ignore error, `no metrics` and `no name`. 96 | let _ = encoder.encode(&[mf], &mut buf); 97 | } 98 | 99 | Ok((url, buf)) 100 | } 101 | } 102 | 103 | #[cfg(feature = "prometheus")] 104 | #[test] 105 | fn test_make_url_and_encoded_metrics() -> Result<()> { 106 | use std::str::FromStr; 107 | 108 | let grouping = [ 109 | ("abc", "xyz"), 110 | ("path", "/my/path"), 111 | ("tags", "a,b,cde"), 112 | ("nogroup", ""), 113 | ] 114 | .into_iter() 115 | .map(|(a, b)| (a.to_string(), b.to_string())) 116 | .collect(); 117 | 118 | let exporter = PrometheusExporter { 119 | endpoint: Url::from_str("http://host")?, 120 | job_name: "test_job".to_string(), 121 | grouping, 122 | prometheus_user: None, 123 | prometheus_pass: None, 124 | }; 125 | 126 | let (url, _) = exporter.make_url_and_encoded_metrics(&Registry::new())?; 127 | assert_eq!( 128 | url.to_string(), 129 | "http://host/metrics/job@base64/dGVzdF9qb2I/abc@base64/eHl6/path@base64/L215L3BhdGg/tags@base64/YSxiLGNkZQ" 130 | ); 131 | Ok(()) 132 | } 133 | -------------------------------------------------------------------------------- /tests/backup_restore.rs: -------------------------------------------------------------------------------- 1 | //! Rustic Integration Test for Backups and Restore 2 | //! 3 | //! Runs the application as a subprocess and asserts its 4 | //! output for the `init`, `backup`, `restore`, `check`, 5 | //! and `snapshots` command 6 | //! 7 | //! You can run them with 'nextest': 8 | //! `cargo nextest run -E 'test(backup)'`. 9 | 10 | use dircmp::Comparison; 11 | use tempfile::{TempDir, tempdir}; 12 | 13 | use assert_cmd::Command; 14 | use predicates::prelude::{PredicateBooleanExt, predicate}; 15 | 16 | mod repositories; 17 | use repositories::src_snapshot; 18 | 19 | use rustic_testing::TestResult; 20 | 21 | pub fn rustic_runner(temp_dir: &TempDir) -> TestResult { 22 | let password = "test"; 23 | let repo_dir = temp_dir.path().join("repo"); 24 | 25 | let mut runner = Command::new(env!("CARGO_BIN_EXE_rustic")); 26 | 27 | runner 28 | .arg("-r") 29 | .arg(repo_dir) 30 | .arg("--password") 31 | .arg(password) 32 | .arg("--no-progress"); 33 | 34 | Ok(runner) 35 | } 36 | 37 | fn setup() -> TestResult { 38 | let temp_dir = tempdir()?; 39 | rustic_runner(&temp_dir)? 40 | .args(["init"]) 41 | .assert() 42 | .success() 43 | .stderr(predicate::str::contains("successfully created.")) 44 | .stderr(predicate::str::contains("successfully added.")); 45 | 46 | Ok(temp_dir) 47 | } 48 | 49 | #[test] 50 | fn test_backup_and_check_passes() -> TestResult<()> { 51 | let temp_dir = setup()?; 52 | let backup = src_snapshot()?.into_path(); 53 | 54 | { 55 | // Run `backup` for the first time 56 | rustic_runner(&temp_dir)? 57 | .arg("backup") 58 | .arg(backup.path()) 59 | .assert() 60 | .success() 61 | .stderr(predicate::str::contains("successfully saved.")); 62 | } 63 | 64 | { 65 | // Run `snapshots` 66 | rustic_runner(&temp_dir)? 67 | .arg("snapshots") 68 | .assert() 69 | .success() 70 | .stdout(predicate::str::contains("total: 1 snapshot(s)")); 71 | } 72 | 73 | { 74 | // Run `backup` a second time 75 | rustic_runner(&temp_dir)? 76 | .arg("backup") 77 | .arg(backup.path()) 78 | .assert() 79 | .success() 80 | .stderr(predicate::str::contains("Added to the repo: 0 B")) 81 | .stderr(predicate::str::contains("successfully saved.")); 82 | } 83 | 84 | { 85 | // Run `snapshots` a second time 86 | rustic_runner(&temp_dir)? 87 | .arg("snapshots") 88 | .assert() 89 | .success() 90 | .stdout(predicate::str::contains("total: 2 snapshot(s)")); 91 | } 92 | 93 | { 94 | // Run `check --read-data` 95 | rustic_runner(&temp_dir)? 96 | .args(["check", "--read-data"]) 97 | .assert() 98 | .success() 99 | .stderr(predicate::str::contains("WARN").not()) 100 | .stderr(predicate::str::contains("ERROR").not()); 101 | } 102 | 103 | Ok(()) 104 | } 105 | 106 | #[test] 107 | fn test_backup_and_restore_passes() -> TestResult<()> { 108 | let temp_dir = setup()?; 109 | let restore_dir = temp_dir.path().join("restore"); 110 | let backup_files = src_snapshot()?.into_path(); 111 | 112 | { 113 | // Run `backup` for the first time 114 | rustic_runner(&temp_dir)? 115 | .arg("backup") 116 | .arg(backup_files.path()) 117 | .arg("--as-path") 118 | .arg("/") 119 | .assert() 120 | .success() 121 | .stderr(predicate::str::contains("successfully saved.")); 122 | } 123 | { 124 | // Run `restore` 125 | rustic_runner(&temp_dir)? 126 | .arg("restore") 127 | .arg("latest") 128 | .arg(&restore_dir) 129 | .assert() 130 | .success() 131 | .stdout(predicate::str::contains("restore done")); 132 | } 133 | 134 | // Compare the backup and the restored directory 135 | let compare_result = Comparison::default().compare(backup_files.path(), &restore_dir)?; 136 | 137 | // no differences 138 | assert!(compare_result.is_empty()); 139 | 140 | let dump_tar_file = restore_dir.join("test.tar"); 141 | { 142 | // Run `dump` 143 | rustic_runner(&temp_dir)? 144 | .arg("dump") 145 | .arg("latest") 146 | .arg("--file") 147 | .arg(&dump_tar_file) 148 | .assert() 149 | .success(); 150 | } 151 | // TODO: compare dump output with fixture 152 | 153 | Ok(()) 154 | } 155 | -------------------------------------------------------------------------------- /src/commands/tui/summary.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{BTreeMap, BTreeSet}; 2 | 3 | use anyhow::Result; 4 | use rustic_core::{ 5 | DataId, IndexedFull, Progress, Repository, TreeId, 6 | repofile::{Metadata, Node, Tree}, 7 | }; 8 | 9 | use crate::{commands::ls::Summary, helpers::bytes_size_to_string}; 10 | 11 | #[derive(Default)] 12 | pub struct SummaryMap(BTreeMap); 13 | 14 | impl SummaryMap { 15 | pub fn get(&self, id: &TreeId) -> Option<&TreeSummary> { 16 | self.0.get(id) 17 | } 18 | 19 | pub fn compute( 20 | &mut self, 21 | repo: &Repository, 22 | id: TreeId, 23 | p: &impl Progress, 24 | ) -> Result<()> { 25 | let _ = TreeSummary::from_tree(repo, id, &mut self.0, p)?; 26 | Ok(()) 27 | } 28 | } 29 | 30 | #[derive(Default, Clone)] 31 | pub struct TreeSummary { 32 | pub id_without_meta: TreeId, 33 | pub summary: Summary, 34 | blobs: BlobInfo, 35 | subtrees: Vec, 36 | } 37 | 38 | impl TreeSummary { 39 | fn update(&mut self, other: Self) { 40 | self.summary += other.summary; 41 | } 42 | 43 | fn update_from_node(&mut self, node: &Node) { 44 | for id in node.content.iter().flatten() { 45 | _ = self.blobs.0.insert(*id); 46 | } 47 | self.summary.update(node); 48 | } 49 | 50 | pub fn from_tree( 51 | repo: &'_ Repository, 52 | id: TreeId, 53 | summary_map: &mut BTreeMap, 54 | p: &impl Progress, 55 | ) -> Result 56 | where 57 | S: IndexedFull, 58 | { 59 | if let Some(summary) = summary_map.get(&id) { 60 | return Ok(summary.clone()); 61 | } 62 | 63 | let mut summary = Self::default(); 64 | 65 | let tree = repo.get_tree(&id)?; 66 | let mut tree_without_meta = Tree::default(); 67 | p.inc(1); 68 | for node in &tree.nodes { 69 | let mut node_without_meta = Node::new_node( 70 | node.name().as_os_str(), 71 | node.node_type.clone(), 72 | Metadata::default(), 73 | ); 74 | node_without_meta.content = node.content.clone(); 75 | summary.update_from_node(node); 76 | if let Some(id) = node.subtree { 77 | let subtree_summary = Self::from_tree(repo, id, summary_map, p)?; 78 | node_without_meta.subtree = Some(subtree_summary.id_without_meta); 79 | summary.update(subtree_summary); 80 | summary.subtrees.push(id); 81 | } 82 | tree_without_meta.nodes.push(node_without_meta); 83 | } 84 | let (_, id_without_meta) = tree_without_meta.serialize()?; 85 | summary.id_without_meta = id_without_meta; 86 | 87 | _ = summary_map.insert(id, summary.clone()); 88 | Ok(summary) 89 | } 90 | } 91 | 92 | #[derive(Default, Clone)] 93 | pub struct BlobInfo(BTreeSet); 94 | 95 | impl BlobInfo { 96 | pub fn as_ref(&self) -> BlobInfoRef<'_> { 97 | BlobInfoRef(self.0.iter().collect()) 98 | } 99 | } 100 | 101 | #[derive(Default, Clone)] 102 | pub struct BlobInfoRef<'a>(BTreeSet<&'a DataId>); 103 | 104 | impl<'a> BlobInfoRef<'a> { 105 | pub fn from_node_or_map(node: &'a Node, summary_map: &'a SummaryMap) -> Self { 106 | node.subtree.as_ref().map_or_else( 107 | || Self::from_node(node), 108 | |id| Self::from_id(id, summary_map), 109 | ) 110 | } 111 | fn from_id(id: &'a TreeId, summary_map: &'a SummaryMap) -> Self { 112 | summary_map.get(id).map_or_else(Self::default, |summary| { 113 | let mut blobs = summary.blobs.as_ref(); 114 | for id in &summary.subtrees { 115 | blobs.0.append(&mut Self::from_id(id, summary_map).0); 116 | } 117 | blobs 118 | }) 119 | } 120 | fn from_node(node: &'a Node) -> Self { 121 | Self(node.content.iter().flatten().collect()) 122 | } 123 | 124 | pub fn text_diff( 125 | blobs1: &Option, 126 | blobs2: &Option, 127 | repo: &'a Repository, 128 | ) -> String { 129 | if let (Some(blobs1), Some(blobs2)) = (blobs1, blobs2) { 130 | blobs1 131 | .0 132 | .difference(&blobs2.0) 133 | .map(|id| repo.get_index_entry(*id)) 134 | .try_fold(0u64, |sum, b| -> Result<_> { 135 | Ok(sum + u64::from(b?.length)) 136 | }) 137 | .ok() 138 | .map_or_else(|| "?".to_string(), bytes_size_to_string) 139 | } else { 140 | String::new() 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/commands/prune.rs: -------------------------------------------------------------------------------- 1 | //! `prune` subcommand 2 | 3 | use crate::{ 4 | Application, RUSTIC_APP, helpers::bytes_size_to_string, repository::CliOpenRepo, status_err, 5 | }; 6 | use abscissa_core::{Command, Runnable, Shutdown}; 7 | use log::{debug, info}; 8 | 9 | use anyhow::Result; 10 | 11 | use rustic_core::{PruneOptions, PruneStats}; 12 | 13 | /// `prune` subcommand 14 | #[allow(clippy::struct_excessive_bools)] 15 | #[derive(clap::Parser, Command, Debug, Clone)] 16 | pub(crate) struct PruneCmd { 17 | /// Prune options 18 | #[clap(flatten)] 19 | pub(crate) opts: PruneOptions, 20 | } 21 | 22 | impl Runnable for PruneCmd { 23 | fn run(&self) { 24 | if let Err(err) = RUSTIC_APP 25 | .config() 26 | .repository 27 | .run_open(|repo| self.inner_run(repo)) 28 | { 29 | status_err!("{}", err); 30 | RUSTIC_APP.shutdown(Shutdown::Crash); 31 | }; 32 | } 33 | } 34 | 35 | impl PruneCmd { 36 | fn inner_run(&self, repo: CliOpenRepo) -> Result<()> { 37 | let config = RUSTIC_APP.config(); 38 | 39 | let prune_plan = repo.prune_plan(&self.opts)?; 40 | 41 | print_stats(&prune_plan.stats); 42 | 43 | if config.global.dry_run { 44 | repo.warm_up(prune_plan.repack_packs().into_iter())?; 45 | } else { 46 | repo.prune(&self.opts, prune_plan)?; 47 | } 48 | 49 | Ok(()) 50 | } 51 | } 52 | 53 | /// Print statistics about the prune operation 54 | /// 55 | /// # Arguments 56 | /// 57 | /// * `stats` - Statistics about the prune operation 58 | #[allow(clippy::cast_precision_loss)] 59 | fn print_stats(stats: &PruneStats) { 60 | let pack_stat = &stats.packs; 61 | let blob_stat = stats.blobs_sum(); 62 | let size_stat = stats.size_sum(); 63 | 64 | debug!("statistics:"); 65 | debug!("{:#?}", stats.debug); 66 | 67 | debug!( 68 | "used: {:>10} blobs, {:>10}", 69 | blob_stat.used, 70 | bytes_size_to_string(size_stat.used) 71 | ); 72 | 73 | debug!( 74 | "unused: {:>10} blobs, {:>10}", 75 | blob_stat.unused, 76 | bytes_size_to_string(size_stat.unused) 77 | ); 78 | debug!( 79 | "total: {:>10} blobs, {:>10}", 80 | blob_stat.total(), 81 | bytes_size_to_string(size_stat.total()) 82 | ); 83 | 84 | info!( 85 | "to repack: {:>10} packs, {:>10} blobs, {:>10}", 86 | pack_stat.repack, 87 | blob_stat.repack, 88 | bytes_size_to_string(size_stat.repack) 89 | ); 90 | info!( 91 | "this removes: {:>10} blobs, {:>10}", 92 | blob_stat.repackrm, 93 | bytes_size_to_string(size_stat.repackrm) 94 | ); 95 | info!( 96 | "to delete: {:>10} packs, {:>10} blobs, {:>10}", 97 | pack_stat.unused, 98 | blob_stat.remove, 99 | bytes_size_to_string(size_stat.remove) 100 | ); 101 | if stats.packs_unref > 0 { 102 | info!( 103 | "unindexed: {:>10} packs, ?? blobs, {:>10}", 104 | stats.packs_unref, 105 | bytes_size_to_string(stats.size_unref) 106 | ); 107 | } 108 | 109 | info!( 110 | "total prune: {:>10} blobs, {:>10}", 111 | blob_stat.repackrm + blob_stat.remove, 112 | bytes_size_to_string(size_stat.repackrm + size_stat.remove + stats.size_unref) 113 | ); 114 | info!( 115 | "remaining: {:>10} blobs, {:>10}", 116 | blob_stat.total_after_prune(), 117 | bytes_size_to_string(size_stat.total_after_prune()) 118 | ); 119 | info!( 120 | "unused size after prune: {:>10} ({:.2}% of remaining size)", 121 | bytes_size_to_string(size_stat.unused_after_prune()), 122 | size_stat.unused_after_prune() as f64 / size_stat.total_after_prune() as f64 * 100.0 123 | ); 124 | 125 | info!( 126 | "packs marked for deletion: {:>10}, {:>10}", 127 | stats.packs_to_delete.total(), 128 | bytes_size_to_string(stats.size_to_delete.total()), 129 | ); 130 | info!( 131 | " - complete deletion: {:>10}, {:>10}", 132 | stats.packs_to_delete.remove, 133 | bytes_size_to_string(stats.size_to_delete.remove), 134 | ); 135 | info!( 136 | " - keep marked: {:>10}, {:>10}", 137 | stats.packs_to_delete.keep, 138 | bytes_size_to_string(stats.size_to_delete.keep), 139 | ); 140 | info!( 141 | " - recover: {:>10}, {:>10}", 142 | stats.packs_to_delete.recover, 143 | bytes_size_to_string(stats.size_to_delete.recover), 144 | ); 145 | 146 | debug!( 147 | "index files to rebuild: {} / {}", 148 | stats.index_files_rebuild, stats.index_files 149 | ); 150 | } 151 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - "**/*.md" 7 | push: 8 | branches: 9 | - main 10 | - "renovate/**" 11 | paths-ignore: 12 | - "**/*.md" 13 | schedule: 14 | - cron: "0 0 * * 0" 15 | merge_group: 16 | types: [checks_requested] 17 | 18 | concurrency: 19 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | fmt: 24 | name: Rustfmt 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 28 | - name: Install Rust toolchain 29 | uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # v1 30 | with: 31 | toolchain: stable 32 | components: rustfmt 33 | - name: Run Cargo Fmt 34 | run: cargo fmt --all -- --check 35 | 36 | typos: 37 | name: Typos 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 41 | - name: Check for typos 42 | uses: crate-ci/typos@80c8a4945eec0f6d464eaf9e65ed98ef085283d1 # v1.38.1 43 | 44 | clippy: 45 | name: Clippy 46 | runs-on: ubuntu-latest 47 | strategy: 48 | matrix: 49 | feature: [release] 50 | steps: 51 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 52 | - name: Install Rust toolchain 53 | uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # v1 54 | with: 55 | toolchain: stable 56 | components: clippy 57 | - uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2 58 | - name: Run clippy 59 | run: cargo clippy --locked --all-targets --features ${{ matrix.feature }} -- -D warnings 60 | 61 | test: 62 | name: Test 63 | runs-on: ${{ matrix.job.os }} 64 | strategy: 65 | # Don't fail fast, so we can actually see all the results 66 | fail-fast: false 67 | matrix: 68 | rust: [stable] 69 | feature: [release] 70 | job: 71 | - os: macos-latest 72 | - os: ubuntu-latest 73 | - os: windows-latest 74 | steps: 75 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 76 | if: github.event_name != 'pull_request' 77 | with: 78 | fetch-depth: 0 79 | 80 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 81 | if: github.event_name == 'pull_request' 82 | with: 83 | ref: ${{ github.event.pull_request.head.sha }} 84 | fetch-depth: 0 85 | 86 | - name: Install Rust toolchain 87 | uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # v1 88 | with: 89 | toolchain: stable 90 | - uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2 91 | - name: Run Cargo Test 92 | run: cargo test -r --all-targets --features ${{ matrix.feature }} --workspace 93 | id: run_tests 94 | env: 95 | INSTA_UPDATE: new 96 | 97 | - name: Upload snapshots of failed tests 98 | if: ${{ failure() && steps.run_tests.outcome == 'failure' }} 99 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 100 | with: 101 | name: failed-snapshots-${{ matrix.job.os }} 102 | path: "**/snapshots/*.snap.new" 103 | docs: 104 | name: Build docs 105 | runs-on: ${{ matrix.job.os }} 106 | strategy: 107 | matrix: 108 | rust: [stable] 109 | job: 110 | - os: macos-latest 111 | - os: ubuntu-latest 112 | - os: windows-latest 113 | steps: 114 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 115 | if: github.event_name != 'pull_request' 116 | with: 117 | fetch-depth: 0 118 | 119 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 120 | if: github.event_name == 'pull_request' 121 | with: 122 | ref: ${{ github.event.pull_request.head.sha }} 123 | fetch-depth: 0 124 | 125 | - name: Install Rust toolchain 126 | uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # v1 127 | with: 128 | toolchain: stable 129 | - uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2 130 | - name: Run Cargo Doc 131 | run: cargo doc --no-deps --all-features --workspace --examples 132 | 133 | result: 134 | name: Result (CI) 135 | runs-on: ubuntu-latest 136 | needs: 137 | - fmt 138 | - clippy 139 | - test 140 | - docs 141 | steps: 142 | - name: Mark the job as successful 143 | run: exit 0 144 | if: success() 145 | - name: Mark the job as unsuccessful 146 | run: exit 1 147 | if: "!success()" 148 | -------------------------------------------------------------------------------- /src/application.rs: -------------------------------------------------------------------------------- 1 | //! Rustic Abscissa Application 2 | use std::{env, process}; 3 | 4 | use abscissa_core::{ 5 | Application, Component, FrameworkError, FrameworkErrorKind, Shutdown, StandardPaths, 6 | application::{self, AppCell, fatal_error}, 7 | config::{self, CfgCell}, 8 | terminal::component::Terminal, 9 | }; 10 | 11 | use anyhow::Result; 12 | 13 | // use crate::helpers::*; 14 | use crate::{commands::EntryPoint, config::RusticConfig}; 15 | 16 | /// Application state 17 | pub static RUSTIC_APP: AppCell = AppCell::new(); 18 | 19 | // Constants 20 | pub mod constants { 21 | pub const RUSTIC_DOCS_URL: &str = "https://rustic.cli.rs/docs"; 22 | pub const RUSTIC_DEV_DOCS_URL: &str = "https://rustic.cli.rs/dev-docs"; 23 | pub const RUSTIC_CONFIG_DOCS_URL: &str = 24 | "https://github.com/rustic-rs/rustic/blob/main/config/README.md"; 25 | } 26 | 27 | /// Rustic Application 28 | #[derive(Debug)] 29 | pub struct RusticApp { 30 | /// Application configuration. 31 | config: CfgCell, 32 | 33 | /// Application state. 34 | state: application::State, 35 | } 36 | 37 | /// Initialize a new application instance. 38 | /// 39 | /// By default no configuration is loaded, and the framework state is 40 | /// initialized to a default, empty state (no components, threads, etc). 41 | impl Default for RusticApp { 42 | fn default() -> Self { 43 | Self { 44 | config: CfgCell::default(), 45 | state: application::State::default(), 46 | } 47 | } 48 | } 49 | 50 | impl Application for RusticApp { 51 | /// Entrypoint command for this application. 52 | type Cmd = EntryPoint; 53 | 54 | /// Application configuration. 55 | type Cfg = RusticConfig; 56 | 57 | /// Paths to resources within the application. 58 | type Paths = StandardPaths; 59 | 60 | /// Accessor for application configuration. 61 | fn config(&self) -> config::Reader { 62 | self.config.read() 63 | } 64 | 65 | /// Borrow the application state immutably. 66 | fn state(&self) -> &application::State { 67 | &self.state 68 | } 69 | 70 | /// Returns the framework components used by this application. 71 | fn framework_components( 72 | &mut self, 73 | command: &Self::Cmd, 74 | ) -> Result>>, FrameworkError> { 75 | // we only use the terminal component 76 | let terminal = Terminal::new(self.term_colors(command)); 77 | 78 | Ok(vec![Box::new(terminal)]) 79 | } 80 | 81 | /// Register all components used by this application. 82 | /// 83 | /// If you would like to add additional components to your application 84 | /// beyond the default ones provided by the framework, this is the place 85 | /// to do so. 86 | fn register_components(&mut self, command: &Self::Cmd) -> Result<(), FrameworkError> { 87 | let framework_components = self.framework_components(command)?; 88 | let mut app_components = self.state.components_mut(); 89 | app_components.register(framework_components) 90 | } 91 | 92 | /// Post-configuration lifecycle callback. 93 | /// 94 | /// Called regardless of whether config is loaded to indicate this is the 95 | /// time in app lifecycle when configuration would be loaded if 96 | /// possible. 97 | fn after_config(&mut self, config: Self::Cfg) -> Result<(), FrameworkError> { 98 | // Configure components 99 | self.state.components_mut().after_config(&config)?; 100 | 101 | // set all given environment variables 102 | for (env, value) in &config.global.env { 103 | unsafe { 104 | env::set_var(env, value); 105 | } 106 | } 107 | 108 | let global_hooks = config.global.hooks.clone(); 109 | self.config.set_once(config); 110 | 111 | global_hooks.run_before().map_err(|err| -> FrameworkError { 112 | FrameworkErrorKind::ProcessError.context(err).into() 113 | })?; 114 | 115 | Ok(()) 116 | } 117 | 118 | /// Shut down this application gracefully 119 | fn shutdown(&self, shutdown: Shutdown) -> ! { 120 | let exit_code = match shutdown { 121 | Shutdown::Crash => 1, 122 | _ => 0, 123 | }; 124 | self.shutdown_with_exitcode(shutdown, exit_code) 125 | } 126 | 127 | /// Shut down this application gracefully, exiting with given exit code. 128 | fn shutdown_with_exitcode(&self, shutdown: Shutdown, exit_code: i32) -> ! { 129 | let hooks = &RUSTIC_APP.config().global.hooks; 130 | match shutdown { 131 | Shutdown::Crash => _ = hooks.run_failed(), 132 | _ => _ = hooks.run_after(), 133 | }; 134 | _ = hooks.run_finally(); 135 | let result = self.state().components().shutdown(self, shutdown); 136 | if let Err(e) = result { 137 | fatal_error(self, &e) 138 | } 139 | 140 | process::exit(exit_code); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /tests/repositories.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use assert_cmd::Command; 3 | use flate2::read::GzDecoder; 4 | use rstest::{fixture, rstest}; 5 | use rustic_testing::TestResult; 6 | use std::{fs::File, path::Path}; 7 | use tar::Archive; 8 | use tempfile::{TempDir, tempdir}; 9 | 10 | #[derive(Debug)] 11 | pub struct TestSource(TempDir); 12 | 13 | impl TestSource { 14 | pub fn new(tmp: TempDir) -> Self { 15 | Self(tmp) 16 | } 17 | 18 | pub fn into_path(self) -> TempDir { 19 | self.0 20 | } 21 | } 22 | 23 | fn open_and_unpack(open_path: &'static str, unpack_dir: &TempDir) -> Result<()> { 24 | let path = Path::new(open_path).canonicalize()?; 25 | let tar_gz = File::open(path)?; 26 | let tar = GzDecoder::new(tar_gz); 27 | let mut archive = Archive::new(tar); 28 | archive.set_preserve_permissions(true); 29 | archive.set_preserve_mtime(true); 30 | archive.unpack(unpack_dir)?; 31 | Ok(()) 32 | } 33 | 34 | #[fixture] 35 | fn rustic_repo() -> Result { 36 | let dir = tempdir()?; 37 | let path = "tests/repository-fixtures/rustic-repo.tar.gz"; 38 | open_and_unpack(path, &dir)?; 39 | Ok(TestSource::new(dir)) 40 | } 41 | 42 | #[fixture] 43 | fn restic_repo() -> Result { 44 | let dir = tempdir()?; 45 | let path = "tests/repository-fixtures/restic-repo.tar.gz"; 46 | open_and_unpack(path, &dir)?; 47 | Ok(TestSource::new(dir)) 48 | } 49 | 50 | #[fixture] 51 | fn rustic_copy_repo() -> Result { 52 | let dir = tempdir()?; 53 | let path = "tests/repository-fixtures/rustic-copy-repo.tar.gz"; 54 | open_and_unpack(path, &dir)?; 55 | 56 | Ok(TestSource::new(dir)) 57 | } 58 | 59 | #[fixture] 60 | pub fn src_snapshot() -> Result { 61 | let dir = tempdir()?; 62 | let path = "tests/repository-fixtures/src-snapshot.tar.gz"; 63 | open_and_unpack(path, &dir)?; 64 | Ok(TestSource::new(dir)) 65 | } 66 | 67 | pub fn rustic_runner(temp_dir: &Path, password: &'static str) -> TestResult { 68 | let repo_dir = temp_dir.join("repo"); 69 | let mut runner = Command::new(env!("CARGO_BIN_EXE_rustic")); 70 | 71 | runner 72 | .arg("-r") 73 | .arg(repo_dir) 74 | .arg("--password") 75 | .arg(password) 76 | .arg("--no-progress"); 77 | 78 | Ok(runner) 79 | } 80 | 81 | #[rstest] 82 | fn test_rustic_repo_passes(rustic_repo: Result) -> TestResult<()> { 83 | let rustic_repo = rustic_repo?; 84 | let repo_password = "rustic"; 85 | let rustic_repo_path = rustic_repo.into_path(); 86 | let rustic_repo_path = rustic_repo_path.path(); 87 | 88 | { 89 | let mut runner = rustic_runner(rustic_repo_path, repo_password)?; 90 | runner.args(["check", "--read-data"]).assert().success(); 91 | } 92 | 93 | { 94 | let mut runner = rustic_runner(rustic_repo_path, repo_password)?; 95 | runner 96 | .arg("snapshots") 97 | .assert() 98 | .success() 99 | .stdout(predicates::str::contains("2 snapshot(s)")); 100 | } 101 | 102 | { 103 | let mut runner = rustic_runner(rustic_repo_path, repo_password)?; 104 | runner 105 | .arg("diff") 106 | .arg("31d477a2") 107 | .arg("86371783") 108 | .assert() 109 | .success() 110 | .stdout(predicates::str::contains("1 -")); 111 | } 112 | 113 | Ok(()) 114 | } 115 | 116 | #[rstest] 117 | fn test_restic_repo_with_rustic_passes(restic_repo: Result) -> TestResult<()> { 118 | let restic_repo = restic_repo?; 119 | let repo_password = "restic"; 120 | let restic_repo_path = restic_repo.into_path(); 121 | let restic_repo_path = restic_repo_path.path(); 122 | 123 | { 124 | let mut runner = rustic_runner(restic_repo_path, repo_password)?; 125 | runner.args(["check", "--read-data"]).assert().success(); 126 | } 127 | 128 | { 129 | let mut runner = rustic_runner(restic_repo_path, repo_password)?; 130 | runner 131 | .arg("snapshots") 132 | .assert() 133 | .success() 134 | .stdout(predicates::str::contains("2 snapshot(s)")); 135 | } 136 | 137 | { 138 | let mut runner = rustic_runner(restic_repo_path, repo_password)?; 139 | runner 140 | .arg("diff") 141 | .arg("9305509c") 142 | .arg("af05ecb6") 143 | .assert() 144 | .success() 145 | .stdout(predicates::str::contains("1 -")); 146 | } 147 | 148 | Ok(()) 149 | } 150 | 151 | #[rstest] 152 | #[ignore = "requires live fixture, run manually in CI"] 153 | fn test_restic_latest_repo_with_rustic_passes() -> TestResult<()> { 154 | let path = "tests/repository-fixtures/"; 155 | let repo_password = "restic"; 156 | let restic_repo_path = Path::new(path).canonicalize()?; 157 | let restic_repo_path = restic_repo_path.as_path(); 158 | 159 | { 160 | let mut runner = rustic_runner(restic_repo_path, repo_password)?; 161 | runner.args(["check", "--read-data"]).assert().success(); 162 | } 163 | 164 | { 165 | let mut runner = rustic_runner(restic_repo_path, repo_password)?; 166 | runner 167 | .arg("snapshots") 168 | .assert() 169 | .success() 170 | .stdout(predicates::str::contains("2 snapshot(s)")); 171 | } 172 | 173 | Ok(()) 174 | } 175 | -------------------------------------------------------------------------------- /src/commands/find.rs: -------------------------------------------------------------------------------- 1 | //! `find` subcommand 2 | 3 | use std::path::{Path, PathBuf}; 4 | 5 | use crate::{ 6 | Application, RUSTIC_APP, 7 | repository::{CliIndexedRepo, get_global_grouped_snapshots}, 8 | status_err, 9 | }; 10 | 11 | use abscissa_core::{Command, Runnable, Shutdown}; 12 | use anyhow::Result; 13 | use clap::ValueHint; 14 | use globset::{Glob, GlobBuilder, GlobSetBuilder}; 15 | use itertools::Itertools; 16 | 17 | use rustic_core::{ 18 | FindMatches, FindNode, 19 | repofile::{Node, SnapshotFile}, 20 | }; 21 | 22 | use super::ls::print_node; 23 | 24 | /// `find` subcommand 25 | #[derive(clap::Parser, Command, Debug)] 26 | pub(crate) struct FindCmd { 27 | /// pattern to find (can be specified multiple times) 28 | #[clap(long, value_name = "PATTERN", conflicts_with = "path")] 29 | glob: Vec, 30 | 31 | /// pattern to find case-insensitive (can be specified multiple times) 32 | #[clap(long, value_name = "PATTERN", conflicts_with = "path")] 33 | iglob: Vec, 34 | 35 | /// exact path to find 36 | #[clap(long, value_name = "PATH", value_hint = ValueHint::AnyPath)] 37 | path: Option, 38 | 39 | /// Snapshots to search in. If none is given, use filter options to filter from all snapshots 40 | #[clap(value_name = "ID")] 41 | ids: Vec, 42 | 43 | /// Show all snapshots instead of summarizing snapshots with identical search results 44 | #[clap(long)] 45 | all: bool, 46 | 47 | /// Also show snapshots which don't contain a search result. 48 | #[clap(long)] 49 | show_misses: bool, 50 | 51 | /// Show uid/gid instead of user/group 52 | #[clap(long, long("numeric-uid-gid"))] 53 | numeric_id: bool, 54 | } 55 | 56 | impl Runnable for FindCmd { 57 | fn run(&self) { 58 | if let Err(err) = RUSTIC_APP 59 | .config() 60 | .repository 61 | .run_indexed(|repo| self.inner_run(repo)) 62 | { 63 | status_err!("{}", err); 64 | RUSTIC_APP.shutdown(Shutdown::Crash); 65 | }; 66 | } 67 | } 68 | 69 | impl FindCmd { 70 | fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> { 71 | let groups = get_global_grouped_snapshots(&repo, &self.ids)?; 72 | for (group, mut snapshots) in groups { 73 | snapshots.sort_unstable(); 74 | if !group.is_empty() { 75 | println!("\nsearching in snapshots group {group}..."); 76 | } 77 | let ids = snapshots.iter().map(|sn| sn.tree); 78 | if let Some(path) = &self.path { 79 | let FindNode { nodes, matches } = repo.find_nodes_from_path(ids, path)?; 80 | for (idx, g) in &matches 81 | .iter() 82 | .zip(snapshots.iter()) 83 | .chunk_by(|(idx, _)| *idx) 84 | { 85 | self.print_identical_snapshots(idx.iter(), g.into_iter().map(|(_, sn)| sn)); 86 | if let Some(idx) = idx { 87 | print_node(&nodes[*idx], path, self.numeric_id); 88 | } 89 | } 90 | } else { 91 | let mut builder = GlobSetBuilder::new(); 92 | for glob in &self.glob { 93 | _ = builder.add(Glob::new(glob)?); 94 | } 95 | for glob in &self.iglob { 96 | _ = builder.add(GlobBuilder::new(glob).case_insensitive(true).build()?); 97 | } 98 | let globset = builder.build()?; 99 | let matches = |path: &Path, _: &Node| { 100 | globset.is_match(path) || path.file_name().is_some_and(|f| globset.is_match(f)) 101 | }; 102 | let FindMatches { 103 | paths, 104 | nodes, 105 | matches, 106 | } = repo.find_matching_nodes(ids, &matches)?; 107 | for (idx, g) in &matches 108 | .iter() 109 | .zip(snapshots.iter()) 110 | .chunk_by(|(idx, _)| *idx) 111 | { 112 | self.print_identical_snapshots(idx.iter(), g.into_iter().map(|(_, sn)| sn)); 113 | for (path_idx, node_idx) in idx { 114 | print_node(&nodes[*node_idx], &paths[*path_idx], self.numeric_id); 115 | } 116 | } 117 | } 118 | } 119 | Ok(()) 120 | } 121 | 122 | fn print_identical_snapshots<'a>( 123 | &self, 124 | mut idx: impl Iterator, 125 | mut g: impl Iterator, 126 | ) { 127 | let empty_result = idx.next().is_none(); 128 | let not = if empty_result { "not " } else { "" }; 129 | if self.show_misses || !empty_result { 130 | if self.all { 131 | for sn in g { 132 | let time = sn.time.format("%Y-%m-%d %H:%M:%S"); 133 | println!("{not}found in {} from {time}", sn.id); 134 | } 135 | } else { 136 | let sn = g.next().unwrap(); 137 | let count = g.count(); 138 | let time = sn.time.format("%Y-%m-%d %H:%M:%S"); 139 | match count { 140 | 0 => println!("{not}found in {} from {time}", sn.id), 141 | count => println!("{not}found in {} from {time} (+{count})", sn.id), 142 | }; 143 | } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/commands/webdav.rs: -------------------------------------------------------------------------------- 1 | //! `webdav` subcommand 2 | 3 | // ignore markdown clippy lints as we use doc-comments to generate clap help texts 4 | #![allow(clippy::doc_markdown)] 5 | 6 | use std::net::ToSocketAddrs; 7 | 8 | use crate::{ 9 | Application, RUSTIC_APP, RusticConfig, 10 | repository::{CliIndexedRepo, get_filtered_snapshots}, 11 | status_err, 12 | }; 13 | use abscissa_core::{Command, FrameworkError, Runnable, Shutdown, config::Override}; 14 | use anyhow::{Result, anyhow}; 15 | use conflate::Merge; 16 | use dav_server::{DavHandler, warp::dav_handler}; 17 | use serde::{Deserialize, Serialize}; 18 | 19 | use rustic_core::vfs::{FilePolicy, IdenticalSnapshot, Latest, Vfs}; 20 | use webdavfs::WebDavFS; 21 | 22 | mod webdavfs; 23 | 24 | #[derive(Clone, Command, Default, Debug, clap::Parser, Serialize, Deserialize, Merge)] 25 | #[serde(default, rename_all = "kebab-case", deny_unknown_fields)] 26 | pub struct WebDavCmd { 27 | /// Address to bind the webdav server to. [default: "localhost:8000"] 28 | #[clap(long, value_name = "ADDRESS")] 29 | #[merge(strategy=conflate::option::overwrite_none)] 30 | address: Option, 31 | 32 | /// The path template to use for snapshots. {id}, {id_long}, {time}, {username}, {hostname}, {label}, {tags}, {backup_start}, {backup_end} are replaced. [default: "[{hostname}]/[{label}]/{time}"] 33 | #[clap(long)] 34 | #[merge(strategy=conflate::option::overwrite_none)] 35 | path_template: Option, 36 | 37 | /// The time template to use to display times in the path template. See https://docs.rs/chrono/latest/chrono/format/strftime/index.html for format options. [default: "%Y-%m-%d_%H-%M-%S"] 38 | #[clap(long)] 39 | #[merge(strategy=conflate::option::overwrite_none)] 40 | time_template: Option, 41 | 42 | /// Use symlinks. This may not be supported by all WebDAV clients 43 | #[clap(long)] 44 | #[merge(strategy=conflate::bool::overwrite_false)] 45 | symlinks: bool, 46 | 47 | /// How to handle access to files. [default: "forbidden" for hot/cold repositories, else "read"] 48 | #[clap(long)] 49 | #[merge(strategy=conflate::option::overwrite_none)] 50 | file_access: Option, 51 | 52 | /// Specify directly which snapshot/path to serve 53 | #[clap(value_name = "SNAPSHOT[:PATH]")] 54 | #[merge(strategy=conflate::option::overwrite_none)] 55 | snapshot_path: Option, 56 | } 57 | 58 | impl Override for WebDavCmd { 59 | // Process the given command line options, overriding settings from 60 | // a configuration file using explicit flags taken from command-line 61 | // arguments. 62 | fn override_config(&self, mut config: RusticConfig) -> Result { 63 | let mut self_config = self.clone(); 64 | // merge "webdav" section from config file, if given 65 | self_config.merge(config.webdav); 66 | config.webdav = self_config; 67 | Ok(config) 68 | } 69 | } 70 | 71 | impl Runnable for WebDavCmd { 72 | fn run(&self) { 73 | if let Err(err) = RUSTIC_APP 74 | .config() 75 | .repository 76 | .run_indexed(|repo| self.inner_run(repo)) 77 | { 78 | status_err!("{}", err); 79 | RUSTIC_APP.shutdown(Shutdown::Crash); 80 | }; 81 | } 82 | } 83 | 84 | impl WebDavCmd { 85 | /// be careful about self VS RUSTIC_APP.config() usage 86 | /// only the RUSTIC_APP.config() involves the TOML and ENV merged configurations 87 | /// see https://github.com/rustic-rs/rustic/issues/1242 88 | fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> { 89 | let config = RUSTIC_APP.config(); 90 | 91 | let path_template = config 92 | .webdav 93 | .path_template 94 | .clone() 95 | .unwrap_or_else(|| "[{hostname}]/[{label}]/{time}".to_string()); 96 | let time_template = config 97 | .webdav 98 | .time_template 99 | .clone() 100 | .unwrap_or_else(|| "%Y-%m-%d_%H-%M-%S".to_string()); 101 | 102 | let vfs = if let Some(snap) = &config.webdav.snapshot_path { 103 | let node = 104 | repo.node_from_snapshot_path(snap, |sn| config.snapshot_filter.matches(sn))?; 105 | Vfs::from_dir_node(&node) 106 | } else { 107 | let snapshots = get_filtered_snapshots(&repo)?; 108 | let (latest, identical) = if config.webdav.symlinks { 109 | (Latest::AsLink, IdenticalSnapshot::AsLink) 110 | } else { 111 | (Latest::AsDir, IdenticalSnapshot::AsDir) 112 | }; 113 | Vfs::from_snapshots(snapshots, &path_template, &time_template, latest, identical)? 114 | }; 115 | 116 | let addr = config 117 | .webdav 118 | .address 119 | .clone() 120 | .unwrap_or_else(|| "localhost:8000".to_string()) 121 | .to_socket_addrs()? 122 | .next() 123 | .ok_or_else(|| anyhow!("no address given"))?; 124 | 125 | let file_access = config.webdav.file_access.as_ref().map_or_else( 126 | || { 127 | if repo.config().is_hot == Some(true) { 128 | Ok(FilePolicy::Forbidden) 129 | } else { 130 | Ok(FilePolicy::Read) 131 | } 132 | }, 133 | |s| s.parse(), 134 | )?; 135 | 136 | let webdavfs = WebDavFS::new(repo, vfs, file_access); 137 | let dav_server = DavHandler::builder() 138 | .filesystem(Box::new(webdavfs)) 139 | .build_handler(); 140 | 141 | tokio::runtime::Builder::new_current_thread() 142 | .enable_all() 143 | .build()? 144 | .block_on(async { 145 | warp::serve(dav_handler(dav_server)).run(addr).await; 146 | }); 147 | 148 | Ok(()) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/commands/tui/progress.rs: -------------------------------------------------------------------------------- 1 | use std::io::Stdout; 2 | use std::sync::{Arc, RwLock}; 3 | use std::time::{Duration, SystemTime}; 4 | 5 | use bytesize::ByteSize; 6 | use ratatui::{Terminal, backend::CrosstermBackend}; 7 | use rustic_core::{Progress, ProgressBars}; 8 | 9 | use super::widgets::{Draw, popup_gauge, popup_text}; 10 | 11 | #[derive(Clone)] 12 | pub struct TuiProgressBars { 13 | pub terminal: Arc>>>, 14 | } 15 | 16 | impl TuiProgressBars { 17 | fn as_progress(&self, progress_type: TuiProgressType, prefix: String) -> TuiProgress { 18 | TuiProgress { 19 | terminal: self.terminal.clone(), 20 | data: Arc::new(RwLock::new(CounterData::new(prefix))), 21 | progress_type, 22 | } 23 | } 24 | } 25 | 26 | impl ProgressBars for TuiProgressBars { 27 | type P = TuiProgress; 28 | fn progress_hidden(&self) -> Self::P { 29 | self.as_progress(TuiProgressType::Hidden, String::new()) 30 | } 31 | fn progress_spinner(&self, prefix: impl Into>) -> Self::P { 32 | let progress = self.as_progress(TuiProgressType::Spinner, String::from(prefix.into())); 33 | progress.popup(); 34 | progress 35 | } 36 | fn progress_counter(&self, prefix: impl Into>) -> Self::P { 37 | let progress = self.as_progress(TuiProgressType::Counter, String::from(prefix.into())); 38 | progress.popup(); 39 | progress 40 | } 41 | fn progress_bytes(&self, prefix: impl Into>) -> Self::P { 42 | let progress = self.as_progress(TuiProgressType::Bytes, String::from(prefix.into())); 43 | progress.popup(); 44 | progress 45 | } 46 | } 47 | 48 | struct CounterData { 49 | prefix: String, 50 | begin: SystemTime, 51 | length: Option, 52 | count: u64, 53 | } 54 | 55 | impl CounterData { 56 | fn new(prefix: String) -> Self { 57 | Self { 58 | prefix, 59 | begin: SystemTime::now(), 60 | length: None, 61 | count: 0, 62 | } 63 | } 64 | } 65 | 66 | #[derive(Clone)] 67 | enum TuiProgressType { 68 | Hidden, 69 | Spinner, 70 | Counter, 71 | Bytes, 72 | } 73 | 74 | #[derive(Clone)] 75 | pub struct TuiProgress { 76 | terminal: Arc>>>, 77 | data: Arc>, 78 | progress_type: TuiProgressType, 79 | } 80 | 81 | fn fmt_duration(d: Duration) -> String { 82 | let seconds = d.as_secs(); 83 | let (minutes, seconds) = (seconds / 60, seconds % 60); 84 | let (hours, minutes) = (minutes / 60, minutes % 60); 85 | format!("[{hours:02}:{minutes:02}:{seconds:02}]") 86 | } 87 | 88 | impl TuiProgress { 89 | fn popup(&self) { 90 | let data = self.data.read().unwrap(); 91 | let elapsed = data.begin.elapsed().unwrap(); 92 | let length = data.length; 93 | let count = data.count; 94 | let ratio = match length { 95 | None | Some(0) => 0.0, 96 | Some(l) => count as f64 / l as f64, 97 | }; 98 | let eta = match ratio { 99 | r if r < 0.01 => " ETA: -".to_string(), 100 | r if r > 0.999_999 => String::new(), 101 | r => { 102 | format!( 103 | " ETA: {}", 104 | fmt_duration(Duration::from_secs(1) + elapsed.div_f64(r / (1.0 - r))) 105 | ) 106 | } 107 | }; 108 | let prefix = &data.prefix; 109 | let message = match self.progress_type { 110 | TuiProgressType::Spinner => { 111 | format!("{} {prefix}", fmt_duration(elapsed)) 112 | } 113 | TuiProgressType::Counter => { 114 | format!( 115 | "{} {prefix} {}{}{eta}", 116 | fmt_duration(elapsed), 117 | count, 118 | length.map_or(String::new(), |l| format!("/{l}")) 119 | ) 120 | } 121 | TuiProgressType::Bytes => { 122 | format!( 123 | "{} {prefix} {}{}{eta}", 124 | fmt_duration(elapsed), 125 | ByteSize(count).display(), 126 | length.map_or(String::new(), |l| format!("/{}", ByteSize(l).display())) 127 | ) 128 | } 129 | TuiProgressType::Hidden => String::new(), 130 | }; 131 | drop(data); 132 | 133 | let mut terminal = self.terminal.write().unwrap(); 134 | _ = terminal 135 | .draw(|f| { 136 | let area = f.area(); 137 | match self.progress_type { 138 | TuiProgressType::Hidden => {} 139 | TuiProgressType::Spinner => { 140 | let mut popup = popup_text("progress", message.into()); 141 | popup.draw(area, f); 142 | } 143 | TuiProgressType::Counter | TuiProgressType::Bytes => { 144 | let mut popup = popup_gauge("progress", message.into(), ratio); 145 | popup.draw(area, f); 146 | } 147 | } 148 | }) 149 | .unwrap(); 150 | } 151 | } 152 | 153 | impl Progress for TuiProgress { 154 | fn is_hidden(&self) -> bool { 155 | matches!(self.progress_type, TuiProgressType::Hidden) 156 | } 157 | fn set_length(&self, len: u64) { 158 | self.data.write().unwrap().length = Some(len); 159 | self.popup(); 160 | } 161 | fn set_title(&self, title: &'static str) { 162 | self.data.write().unwrap().prefix = String::from(title); 163 | self.popup(); 164 | } 165 | 166 | fn inc(&self, inc: u64) { 167 | self.data.write().unwrap().count += inc; 168 | self.popup(); 169 | } 170 | fn finish(&self) {} 171 | } 172 | -------------------------------------------------------------------------------- /src/config/progress_options.rs: -------------------------------------------------------------------------------- 1 | //! Progress Bar Config 2 | 3 | use std::{borrow::Cow, fmt::Write, time::Duration}; 4 | 5 | use indicatif::{HumanDuration, ProgressBar, ProgressState, ProgressStyle}; 6 | 7 | use clap::Parser; 8 | use conflate::Merge; 9 | 10 | use serde::{Deserialize, Serialize}; 11 | use serde_with::{DisplayFromStr, serde_as}; 12 | 13 | use rustic_core::{Progress, ProgressBars}; 14 | 15 | mod constants { 16 | use std::time::Duration; 17 | 18 | pub(super) const DEFAULT_INTERVAL: Duration = Duration::from_millis(100); 19 | } 20 | 21 | /// Progress Bar Config 22 | #[serde_as] 23 | #[derive(Default, Debug, Parser, Clone, Copy, Deserialize, Serialize, Merge)] 24 | #[serde(default, rename_all = "kebab-case", deny_unknown_fields)] 25 | pub struct ProgressOptions { 26 | /// Don't show any progress bar 27 | #[clap(long, global = true, env = "RUSTIC_NO_PROGRESS")] 28 | #[merge(strategy=conflate::bool::overwrite_false)] 29 | pub no_progress: bool, 30 | 31 | /// Interval to update progress bars (default: 100ms) 32 | #[clap( 33 | long, 34 | global = true, 35 | env = "RUSTIC_PROGRESS_INTERVAL", 36 | value_name = "DURATION", 37 | conflicts_with = "no_progress" 38 | )] 39 | #[serde_as(as = "Option")] 40 | #[merge(strategy=conflate::option::overwrite_none)] 41 | pub progress_interval: Option, 42 | } 43 | 44 | impl ProgressOptions { 45 | /// Get the progress interval 46 | fn progress_interval(&self) -> Duration { 47 | self.progress_interval 48 | .map_or(constants::DEFAULT_INTERVAL, |i| *i) 49 | } 50 | 51 | /// Create a hidden progress bar 52 | pub fn no_progress() -> RusticProgress { 53 | RusticProgress(ProgressBar::hidden(), ProgressType::Hidden) 54 | } 55 | } 56 | 57 | #[allow(clippy::literal_string_with_formatting_args)] 58 | impl ProgressBars for ProgressOptions { 59 | type P = RusticProgress; 60 | 61 | fn progress_spinner(&self, prefix: impl Into>) -> RusticProgress { 62 | if self.no_progress { 63 | return Self::no_progress(); 64 | } 65 | let p = ProgressBar::new(0).with_style( 66 | ProgressStyle::default_bar() 67 | .template("[{elapsed_precise}] {prefix:30} {spinner}") 68 | .unwrap(), 69 | ); 70 | p.set_prefix(prefix); 71 | p.enable_steady_tick(self.progress_interval()); 72 | RusticProgress(p, ProgressType::Spinner) 73 | } 74 | 75 | fn progress_counter(&self, prefix: impl Into>) -> RusticProgress { 76 | if self.no_progress { 77 | return Self::no_progress(); 78 | } 79 | let p = ProgressBar::new(0).with_style( 80 | ProgressStyle::default_bar() 81 | .template("[{elapsed_precise}] {prefix:30} {bar:40.cyan/blue} {pos:>10}") 82 | .unwrap(), 83 | ); 84 | p.set_prefix(prefix); 85 | p.enable_steady_tick(self.progress_interval()); 86 | RusticProgress(p, ProgressType::Counter) 87 | } 88 | 89 | fn progress_hidden(&self) -> RusticProgress { 90 | Self::no_progress() 91 | } 92 | 93 | fn progress_bytes(&self, prefix: impl Into>) -> RusticProgress { 94 | if self.no_progress { 95 | return Self::no_progress(); 96 | } 97 | let p = ProgressBar::new(0).with_style( 98 | ProgressStyle::default_bar() 99 | .template("[{elapsed_precise}] {prefix:30} {bar:40.cyan/blue} {bytes:>10} {bytes_per_sec:12}") 100 | .unwrap() 101 | ); 102 | p.set_prefix(prefix); 103 | p.enable_steady_tick(self.progress_interval()); 104 | RusticProgress(p, ProgressType::Bytes) 105 | } 106 | } 107 | 108 | #[derive(Debug, Clone)] 109 | enum ProgressType { 110 | Hidden, 111 | Spinner, 112 | Counter, 113 | Bytes, 114 | } 115 | 116 | /// A default progress bar 117 | #[derive(Debug, Clone)] 118 | pub struct RusticProgress(ProgressBar, ProgressType); 119 | 120 | #[allow(clippy::literal_string_with_formatting_args)] 121 | impl Progress for RusticProgress { 122 | fn is_hidden(&self) -> bool { 123 | self.0.is_hidden() 124 | } 125 | 126 | fn set_length(&self, len: u64) { 127 | match self.1 { 128 | ProgressType::Counter => { 129 | self.0.set_style( 130 | ProgressStyle::default_bar() 131 | .template( 132 | "[{elapsed_precise}] {prefix:30} {bar:40.cyan/blue} {pos:>10}/{len:10}", 133 | ) 134 | .unwrap(), 135 | ); 136 | } 137 | ProgressType::Bytes => { 138 | self.0.set_style( 139 | ProgressStyle::default_bar() 140 | .with_key("my_eta", |s: &ProgressState, w: &mut dyn Write| { 141 | let _ = match (s.pos(), s.len()){ 142 | // Extra checks to prevent panics from dividing by zero or subtract overflow 143 | (pos,Some(len)) if pos != 0 && len > pos => write!(w,"{:#}", HumanDuration(Duration::from_secs(s.elapsed().as_secs() * (len-pos)/pos))), 144 | (_, _) => write!(w,"-"), 145 | }; 146 | }) 147 | .template("[{elapsed_precise}] {prefix:30} {bar:40.cyan/blue} {bytes:>10}/{total_bytes:10} {bytes_per_sec:12} (ETA {my_eta})") 148 | .unwrap() 149 | ); 150 | } 151 | _ => {} 152 | } 153 | self.0.set_length(len); 154 | } 155 | 156 | fn set_title(&self, title: &'static str) { 157 | self.0.set_prefix(title); 158 | } 159 | 160 | fn inc(&self, inc: u64) { 161 | self.0.inc(inc); 162 | } 163 | 164 | fn finish(&self) { 165 | self.0.finish_with_message("done"); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /.github/workflows/release-cd.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Deployment (Release) 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | # Run on stable releases 8 | - "v*.*.*" 9 | # Run on release candidates 10 | - "v*.*.*-rc*" 11 | 12 | defaults: 13 | run: 14 | shell: bash 15 | 16 | permissions: 17 | contents: write 18 | discussions: write 19 | 20 | env: 21 | BINARY_NAME: rustic 22 | BINARY_NIGHTLY_DIR: rustic 23 | 24 | jobs: 25 | publish: 26 | if: ${{ github.repository_owner == 'rustic-rs' }} 27 | name: Publishing ${{ matrix.job.target }} 28 | runs-on: ${{ matrix.job.os }} 29 | strategy: 30 | fail-fast: false # so we upload the artifacts even if one of the jobs fails 31 | matrix: 32 | rust: [stable] 33 | job: 34 | - os: windows-latest 35 | os-name: windows 36 | target: x86_64-pc-windows-msvc 37 | architecture: x86_64 38 | binary-postfix: ".exe" 39 | use-cross: false 40 | - os: windows-latest 41 | os-name: windows 42 | target: x86_64-pc-windows-gnu 43 | architecture: x86_64 44 | binary-postfix: ".exe" 45 | use-cross: false 46 | - os: macos-latest 47 | os-name: macos 48 | target: x86_64-apple-darwin 49 | architecture: x86_64 50 | binary-postfix: "" 51 | use-cross: false 52 | - os: macos-latest 53 | os-name: macos 54 | target: aarch64-apple-darwin 55 | architecture: arm64 56 | binary-postfix: "" 57 | use-cross: true 58 | - os: ubuntu-latest 59 | os-name: linux 60 | target: x86_64-unknown-linux-gnu 61 | architecture: x86_64 62 | binary-postfix: "" 63 | use-cross: false 64 | - os: ubuntu-latest 65 | os-name: linux 66 | target: x86_64-unknown-linux-musl 67 | architecture: x86_64 68 | binary-postfix: "" 69 | use-cross: false 70 | - os: ubuntu-latest 71 | os-name: linux 72 | target: aarch64-unknown-linux-gnu 73 | architecture: arm64 74 | binary-postfix: "" 75 | use-cross: true 76 | - os: ubuntu-latest 77 | os-name: linux 78 | target: aarch64-unknown-linux-musl 79 | architecture: arm64 80 | binary-postfix: "" 81 | use-cross: true 82 | - os: ubuntu-latest 83 | os-name: linux 84 | target: i686-unknown-linux-gnu 85 | architecture: i686 86 | binary-postfix: "" 87 | use-cross: true 88 | # - os: ubuntu-latest 89 | # os-name: netbsd 90 | # target: x86_64-unknown-netbsd 91 | # architecture: x86_64 92 | # binary-postfix: "" 93 | # use-cross: true 94 | - os: ubuntu-latest 95 | os-name: linux 96 | target: armv7-unknown-linux-gnueabihf 97 | architecture: armv7 98 | binary-postfix: "" 99 | use-cross: true 100 | steps: 101 | - name: Checkout repository 102 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 103 | with: 104 | fetch-depth: 0 # fetch all history so that git describe works 105 | - name: Create binary artifact 106 | uses: rustic-rs/create-binary-artifact-action@main # dev 107 | with: 108 | toolchain: ${{ matrix.rust }} 109 | target: ${{ matrix.job.target }} 110 | use-cross: ${{ matrix.job.use-cross }} 111 | binary-postfix: ${{ matrix.job.binary-postfix }} 112 | os: ${{ runner.os }} 113 | binary-name: ${{ env.BINARY_NAME }} 114 | package-secondary-name: ${{ matrix.job.target}} 115 | github-token: ${{ secrets.GITHUB_TOKEN }} 116 | gpg-release-private-key: ${{ secrets.GPG_RELEASE_PRIVATE_KEY }} 117 | gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }} 118 | rsign-release-private-key: ${{ secrets.RSIGN_RELEASE_PRIVATE_KEY }} 119 | rsign-passphrase: ${{ secrets.RSIGN_PASSPHRASE }} 120 | github-ref: ${{ github.ref }} 121 | sign-release: true 122 | hash-release: true 123 | use-project-version: true 124 | use-tag-version: true # IMPORTANT: this is being used to make sure the tag that is built is in the archive filename, so automation can download the correct version 125 | 126 | create-release: 127 | name: Creating release with artifacts 128 | needs: publish 129 | runs-on: ubuntu-latest 130 | steps: 131 | # Need to clone the repo again for the CHANGELOG.md 132 | - name: Checkout repository 133 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 134 | with: 135 | fetch-depth: 0 # fetch all history so that git describe works 136 | 137 | - name: Download all workflow run artifacts 138 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 139 | 140 | - name: Creating Release 141 | uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2 142 | with: 143 | discussion_category_name: "Announcements" 144 | draft: true 145 | body_path: ${{ github.workspace }}/CHANGELOG.md 146 | fail_on_unmatched_files: true 147 | files: | 148 | binary-*/${{ env.BINARY_NAME }}-*.tar.gz* 149 | env: 150 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 151 | 152 | result: 153 | if: ${{ github.repository_owner == 'rustic-rs' }} 154 | name: Result (Release CD) 155 | runs-on: ubuntu-latest 156 | needs: 157 | - publish 158 | - create-release 159 | steps: 160 | - name: Mark the job as successful 161 | run: exit 0 162 | if: success() 163 | - name: Mark the job as unsuccessful 164 | run: exit 1 165 | if: "!success()" 166 | -------------------------------------------------------------------------------- /src/commands/tui/restore.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use crossterm::event::{Event, KeyCode, KeyEventKind}; 3 | use ratatui::prelude::*; 4 | use rustic_core::{ 5 | IndexedFull, LocalDestination, LsOptions, ProgressBars, Repository, RestoreOptions, 6 | RestorePlan, repofile::Node, 7 | }; 8 | 9 | use crate::{ 10 | commands::tui::widgets::{ 11 | Draw, PopUpInput, PopUpPrompt, PopUpText, ProcessEvent, PromptResult, TextInputResult, 12 | popup_input, popup_prompt, 13 | }, 14 | helpers::bytes_size_to_string, 15 | }; 16 | 17 | use super::widgets::popup_text; 18 | 19 | // the states this screen can be in 20 | enum CurrentScreen { 21 | GetDestination(PopUpInput), 22 | PromptRestore(PopUpPrompt, Option), 23 | RestoreDone(PopUpText), 24 | } 25 | 26 | pub(crate) struct Restore<'a, P, S> { 27 | current_screen: CurrentScreen, 28 | repo: &'a Repository, 29 | opts: RestoreOptions, 30 | node: Node, 31 | source: String, 32 | dest: String, 33 | } 34 | 35 | impl<'a, P: ProgressBars, S: IndexedFull> Restore<'a, P, S> { 36 | pub fn new(repo: &'a Repository, node: Node, source: String, path: &str) -> Self { 37 | let opts = RestoreOptions::default(); 38 | let title = format!("restore {source} to:"); 39 | let popup = popup_input(title, "enter restore destination", path, 1); 40 | Self { 41 | current_screen: CurrentScreen::GetDestination(popup), 42 | node, 43 | repo, 44 | opts, 45 | source, 46 | dest: String::new(), 47 | } 48 | } 49 | 50 | pub fn compute_plan(&mut self, mut dest: String, dry_run: bool) -> Result { 51 | if dest.is_empty() { 52 | dest = ".".to_string(); 53 | } 54 | self.dest = dest; 55 | let dest = LocalDestination::new(&self.dest, true, !self.node.is_dir())?; 56 | 57 | // for restore, always recurse into tree 58 | let mut ls_opts = LsOptions::default(); 59 | ls_opts.recursive = true; 60 | 61 | let ls = self.repo.ls(&self.node, &ls_opts)?; 62 | 63 | let plan = self.repo.prepare_restore(&self.opts, ls, &dest, dry_run)?; 64 | 65 | Ok(plan) 66 | } 67 | 68 | // restore using the plan 69 | // 70 | // Note: This currently runs `prepare_restore` again and doesn't use `plan` 71 | // TODO: Fix when restore is changed such that `prepare_restore` is always dry_run and all modification is done in `restore` 72 | fn restore(&self, _plan: RestorePlan) -> Result<()> { 73 | let dest = LocalDestination::new(&self.dest, true, !self.node.is_dir())?; 74 | 75 | // for restore, always recurse into tree 76 | let mut ls_opts = LsOptions::default(); 77 | ls_opts.recursive = true; 78 | 79 | let ls = self.repo.ls(&self.node, &ls_opts)?; 80 | let plan = self 81 | .repo 82 | .prepare_restore(&self.opts, ls.clone(), &dest, false)?; 83 | 84 | // the actual restore 85 | self.repo.restore(plan, &self.opts, ls, &dest)?; 86 | Ok(()) 87 | } 88 | 89 | pub fn input(&mut self, event: Event) -> Result { 90 | use KeyCode::{Char, Enter, Esc}; 91 | match &mut self.current_screen { 92 | CurrentScreen::GetDestination(prompt) => match prompt.input(event) { 93 | TextInputResult::Cancel => return Ok(true), 94 | TextInputResult::Input(input) => { 95 | let plan = self.compute_plan(input, true)?; 96 | let fs = plan.stats.files; 97 | let ds = plan.stats.dirs; 98 | let popup = popup_prompt( 99 | "restore information", 100 | Text::from(format!( 101 | r#" 102 | restoring from: {} 103 | restoring to: {} 104 | 105 | Files: {} to restore, {} unchanged, {} verified, {} to modify, {} additional 106 | Dirs: {} to restore, {} to modify, {} additional 107 | Total restore size: {} 108 | 109 | Do you want to proceed (y/n)? 110 | "#, 111 | self.source, 112 | self.dest, 113 | fs.restore, 114 | fs.unchanged, 115 | fs.verified, 116 | fs.modify, 117 | fs.additional, 118 | ds.restore, 119 | ds.modify, 120 | ds.additional, 121 | bytes_size_to_string(plan.restore_size) 122 | )), 123 | ); 124 | self.current_screen = CurrentScreen::PromptRestore(popup, Some(plan)); 125 | } 126 | TextInputResult::None => {} 127 | }, 128 | CurrentScreen::PromptRestore(prompt, plan) => match prompt.input(event) { 129 | PromptResult::Ok => { 130 | let plan = plan.take().unwrap(); 131 | self.restore(plan)?; 132 | self.current_screen = CurrentScreen::RestoreDone(popup_text( 133 | "restore done", 134 | format!("restored {} successfully to {}", self.source, self.dest).into(), 135 | )); 136 | } 137 | PromptResult::Cancel => return Ok(true), 138 | PromptResult::None => {} 139 | }, 140 | CurrentScreen::RestoreDone(_) => match event { 141 | Event::Key(key) if key.kind == KeyEventKind::Press => { 142 | if matches!(key.code, Char('q' | ' ') | Esc | Enter) { 143 | return Ok(true); 144 | } 145 | } 146 | _ => {} 147 | }, 148 | } 149 | Ok(false) 150 | } 151 | 152 | pub fn draw(&mut self, area: Rect, f: &mut Frame<'_>) { 153 | // draw popups 154 | match &mut self.current_screen { 155 | CurrentScreen::GetDestination(popup) => popup.draw(area, f), 156 | CurrentScreen::PromptRestore(popup, _) => popup.draw(area, f), 157 | CurrentScreen::RestoreDone(popup) => popup.draw(area, f), 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/commands/copy.rs: -------------------------------------------------------------------------------- 1 | //! `copy` subcommand 2 | 3 | use crate::{ 4 | Application, RUSTIC_APP, RusticConfig, 5 | commands::init::init_password, 6 | helpers::table_with_titles, 7 | repository::{CliIndexedRepo, CliRepo, get_filtered_snapshots}, 8 | status_err, 9 | }; 10 | use abscissa_core::{Command, FrameworkError, Runnable, Shutdown, config::Override}; 11 | use anyhow::{Result, bail}; 12 | use conflate::Merge; 13 | use log::{Level, error, info, log}; 14 | use serde::{Deserialize, Serialize}; 15 | 16 | use rustic_core::{CopySnapshot, Id, KeyOptions, repofile::SnapshotFile}; 17 | 18 | /// `copy` subcommand 19 | #[derive(clap::Parser, Command, Default, Clone, Debug, Serialize, Deserialize, Merge)] 20 | pub struct CopyCmd { 21 | /// Snapshots to copy. If none is given, use filter options to filter from all snapshots. 22 | #[clap(value_name = "ID")] 23 | #[serde(skip)] 24 | #[merge(skip)] 25 | ids: Vec, 26 | 27 | /// Initialize non-existing target repositories 28 | #[clap(long)] 29 | #[serde(skip)] 30 | #[merge(skip)] 31 | init: bool, 32 | 33 | /// Target repository (can be specified multiple times) 34 | #[clap(long = "target", value_name = "TARGET")] 35 | #[merge(strategy=conflate::vec::overwrite_empty)] 36 | targets: Vec, 37 | 38 | /// Key options (when using --init) 39 | #[clap(flatten, next_help_heading = "Key options (when using --init)")] 40 | #[serde(skip)] 41 | #[merge(skip)] 42 | key_opts: KeyOptions, 43 | } 44 | 45 | impl Override for CopyCmd { 46 | // Process the given command line options, overriding settings from 47 | // a configuration file using explicit flags taken from command-line 48 | // arguments. 49 | fn override_config(&self, mut config: RusticConfig) -> Result { 50 | let mut self_config = self.clone(); 51 | // merge "copy" section from config file, if given 52 | self_config.merge(config.copy); 53 | config.copy = self_config; 54 | Ok(config) 55 | } 56 | } 57 | 58 | impl Runnable for CopyCmd { 59 | fn run(&self) { 60 | let config = RUSTIC_APP.config(); 61 | if config.copy.targets.is_empty() { 62 | status_err!( 63 | "No target given. Please specify at least 1 target either in the profile or using --target!" 64 | ); 65 | RUSTIC_APP.shutdown(Shutdown::Crash); 66 | } 67 | if let Err(err) = config.repository.run_indexed(|repo| self.inner_run(repo)) { 68 | status_err!("{}", err); 69 | RUSTIC_APP.shutdown(Shutdown::Crash); 70 | }; 71 | } 72 | } 73 | 74 | impl CopyCmd { 75 | fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> { 76 | let config = RUSTIC_APP.config(); 77 | let mut snapshots = if self.ids.is_empty() { 78 | get_filtered_snapshots(&repo)? 79 | } else { 80 | repo.get_snapshots(&self.ids)? 81 | }; 82 | // sort for nicer output 83 | snapshots.sort_unstable(); 84 | 85 | for target in &config.copy.targets { 86 | let mut merge_logs = Vec::new(); 87 | let mut target_config = RusticConfig::default(); 88 | target_config.merge_profile(target, &mut merge_logs, Level::Error)?; 89 | // display logs from merging 90 | for (level, merge_log) in merge_logs { 91 | log!(level, "{merge_log}"); 92 | } 93 | let target_opt = &target_config.repository; 94 | if let Err(err) = 95 | target_opt.run(|target_repo| self.copy(&repo, target_repo, &snapshots)) 96 | { 97 | error!("error copying to target: {err}"); 98 | } 99 | } 100 | Ok(()) 101 | } 102 | 103 | fn copy( 104 | &self, 105 | repo: &CliIndexedRepo, 106 | target_repo: CliRepo, 107 | snapshots: &[SnapshotFile], 108 | ) -> Result<()> { 109 | let config = RUSTIC_APP.config(); 110 | 111 | info!("copying to target {}...", target_repo.name); 112 | let target_repo = if self.init && target_repo.config_id()?.is_none() { 113 | let mut config_dest = repo.config().clone(); 114 | config_dest.id = Id::random().into(); 115 | let pass = init_password(&target_repo)?; 116 | target_repo 117 | .0 118 | .init_with_config(&pass, &self.key_opts, config_dest)? 119 | } else { 120 | target_repo.open()? 121 | }; 122 | 123 | if !repo.config().has_same_chunker(target_repo.config()) { 124 | bail!( 125 | "cannot copy to repository with different chunker parameter (re-chunking not implemented)!" 126 | ); 127 | } 128 | 129 | let snaps = target_repo.relevant_copy_snapshots( 130 | |sn| !self.ids.is_empty() || config.snapshot_filter.matches(sn), 131 | snapshots, 132 | )?; 133 | 134 | let mut table = 135 | table_with_titles(["ID", "Time", "Host", "Label", "Tags", "Paths", "Status"]); 136 | for CopySnapshot { relevant, sn } in &snaps { 137 | let tags = sn.tags.formatln(); 138 | let paths = sn.paths.formatln(); 139 | let time = sn.time.format("%Y-%m-%d %H:%M:%S").to_string(); 140 | _ = table.add_row([ 141 | &sn.id.to_string(), 142 | &time, 143 | &sn.hostname, 144 | &sn.label, 145 | &tags, 146 | &paths, 147 | &(if *relevant { "to copy" } else { "existing" }).to_string(), 148 | ]); 149 | } 150 | println!("{table}"); 151 | 152 | let count = snaps.iter().filter(|sn| sn.relevant).count(); 153 | if count > 0 { 154 | if config.global.dry_run { 155 | info!("would have copied {count} snapshots."); 156 | } else { 157 | repo.copy( 158 | &target_repo.to_indexed_ids()?, 159 | snaps 160 | .iter() 161 | .filter_map(|CopySnapshot { relevant, sn }| relevant.then_some(sn)), 162 | )?; 163 | } 164 | } else { 165 | info!("nothing to copy."); 166 | } 167 | Ok(()) 168 | } 169 | } 170 | --------------------------------------------------------------------------------