├── .cargo ├── audit.toml └── config.toml ├── .github ├── renovate.json └── workflows │ ├── audit.yml │ ├── ci.yml │ ├── compat.yml │ ├── cross-ci.yml │ ├── lint-docs.yml │ ├── nightly.yml │ ├── prebuilt-pr.yml │ ├── release-cd.yml │ ├── release-ci.yml │ ├── release-image.yml │ ├── release-plz.yml │ └── triage.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── ECOSYSTEM.md ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── build-dependencies.just ├── build.sh ├── cliff.toml ├── config ├── README.md ├── copy_example.toml ├── full.toml ├── hooks.toml ├── local.toml ├── par2.toml ├── rustic.toml ├── services │ ├── b2.toml │ ├── rclone_ovh-hot-cold.toml │ ├── s3_aws.toml │ ├── s3_idrive.toml │ ├── sftp.toml │ ├── sftp_hetzner_sbox.toml │ └── webdav_owncloud_nextcloud.toml └── simple.toml ├── coverage └── .gitkeep ├── deny.toml ├── docs └── Readme.md ├── dprint.json ├── maskfile.md ├── platform-settings.toml ├── release-plz.toml ├── scripts └── build.sh ├── src ├── application.rs ├── bin │ └── rustic.rs ├── commands.rs ├── commands │ ├── backup.rs │ ├── cat.rs │ ├── check.rs │ ├── completions.rs │ ├── config.rs │ ├── copy.rs │ ├── diff.rs │ ├── docs.rs │ ├── dump.rs │ ├── find.rs │ ├── forget.rs │ ├── init.rs │ ├── key.rs │ ├── list.rs │ ├── ls.rs │ ├── merge.rs │ ├── mount.rs │ ├── mount │ │ └── fusefs.rs │ ├── prune.rs │ ├── repair.rs │ ├── repoinfo.rs │ ├── restore.rs │ ├── self_update.rs │ ├── show_config.rs │ ├── snapshots.rs │ ├── tag.rs │ ├── tui.rs │ ├── tui │ │ ├── ls.rs │ │ ├── progress.rs │ │ ├── restore.rs │ │ ├── snapshots.rs │ │ ├── tree.rs │ │ ├── widgets.rs │ │ └── widgets │ │ │ ├── popup.rs │ │ │ ├── prompt.rs │ │ │ ├── select_table.rs │ │ │ ├── sized_gauge.rs │ │ │ ├── sized_paragraph.rs │ │ │ ├── sized_table.rs │ │ │ ├── text_input.rs │ │ │ └── with_block.rs │ ├── webdav.rs │ └── webdav │ │ └── webdavfs.rs ├── config.rs ├── config │ ├── hooks.rs │ └── progress_options.rs ├── error.rs ├── filtering.rs ├── helpers.rs ├── lib.rs ├── repository.rs └── snapshots │ ├── rustic_rs__config__tests__default_config_display_passes.snap │ ├── rustic_rs__config__tests__default_config_passes.snap │ ├── rustic_rs__config__tests__global_env_roundtrip_passes-2.snap │ ├── rustic_rs__config__tests__global_env_roundtrip_passes-3.snap │ └── rustic_rs__config__tests__global_env_roundtrip_passes.snap ├── tests ├── backup_restore.rs ├── completions.rs ├── config.rs ├── generated │ └── .gitkeep ├── hooks-fixtures │ ├── backup_hooks_failure.toml │ ├── backup_hooks_success.toml │ ├── check_not_backup_hooks_success.toml │ ├── commands_hooks_access_success.tpl │ ├── empty_hooks_success.toml │ ├── full_hooks_before_backup_failure.toml │ ├── full_hooks_before_repo_failure.toml │ ├── full_hooks_success.toml │ ├── global_hooks_success.toml │ └── repository_hooks_success.toml ├── hooks.rs ├── repositories.rs ├── repository-fixtures │ ├── COPY.tpl │ ├── README.md │ ├── restic-repo.tar.gz │ ├── rustic-copy-repo.tar.gz │ ├── rustic-repo.tar.gz │ └── src-snapshot.tar.gz ├── show-config.rs └── snapshots │ ├── completions__bash.snap │ ├── completions__completions-bash-linux.snap │ ├── completions__completions-bash-macos.snap │ ├── completions__completions-bash-windows.snap │ ├── completions__completions-fish-linux.snap │ ├── completions__completions-fish-macos.snap │ ├── completions__completions-fish-windows.snap │ ├── completions__completions-powershell-linux.snap │ ├── completions__completions-powershell-macos.snap │ ├── completions__completions-powershell-windows.snap │ ├── completions__completions-zsh-linux.snap │ ├── completions__completions-zsh-macos.snap │ ├── completions__completions-zsh-windows.snap │ ├── hooks__backup_hooks_access_success.snap │ ├── hooks__backup_hooks_failure.snap │ ├── hooks__backup_hooks_success.snap │ ├── hooks__cat_hooks_access_success.snap │ ├── hooks__check_hooks_access_success.snap │ ├── hooks__check_not_backup_hooks_success.snap │ ├── hooks__completions_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__full_hooks_before_backup_failure.snap │ ├── hooks__full_hooks_before_repo_failure.snap │ ├── hooks__full_hooks_success.snap │ ├── hooks__global_hooks_success.snap │ ├── hooks__list_hooks_access_success.snap │ ├── hooks__ls_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__repository_hooks_success.snap │ ├── hooks__restore_hooks_access_success.snap │ ├── hooks__self-update_hooks_access_success.snap │ ├── hooks__show-config_hooks_access_success.snap │ ├── hooks__snapshots_hooks_access_success.snap │ ├── hooks__tag_hooks_access_success.snap │ ├── show_config__show_config_passes.snap │ └── show_config__show_config_passes.snap.new └── util └── systemd ├── rustic-backup@.service ├── rustic-backup@.timer ├── rustic-forget@.service └── rustic-forget@.timer /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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@a54c7afa936fefeb4456b2dd8068152669aa8203 # v1 31 | with: 32 | toolchain: stable 33 | - uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # 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@34899fc7ba81ca6268d5947a7a16b4649013fea1 # 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 | -------------------------------------------------------------------------------- /.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@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1 30 | with: 31 | toolchain: stable 32 | components: rustfmt 33 | - name: Run Cargo Fmt 34 | run: cargo fmt --all -- --check 35 | 36 | clippy: 37 | name: Clippy 38 | runs-on: ubuntu-latest 39 | strategy: 40 | matrix: 41 | feature: [release] 42 | steps: 43 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 44 | - name: Install Rust toolchain 45 | uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1 46 | with: 47 | toolchain: stable 48 | components: clippy 49 | - uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2 50 | - name: Run clippy 51 | run: cargo clippy --locked --all-targets --features ${{ matrix.feature }} -- -D warnings 52 | 53 | test: 54 | name: Test 55 | runs-on: ${{ matrix.job.os }} 56 | strategy: 57 | # Don't fail fast, so we can actually see all the results 58 | fail-fast: false 59 | matrix: 60 | rust: [stable] 61 | feature: [release] 62 | job: 63 | - os: macos-latest 64 | - os: ubuntu-latest 65 | - os: windows-latest 66 | steps: 67 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 68 | if: github.event_name != 'pull_request' 69 | with: 70 | fetch-depth: 0 71 | 72 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 73 | if: github.event_name == 'pull_request' 74 | with: 75 | ref: ${{ github.event.pull_request.head.sha }} 76 | fetch-depth: 0 77 | 78 | - name: Install Rust toolchain 79 | uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1 80 | with: 81 | toolchain: stable 82 | - uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2 83 | - name: Run Cargo Test 84 | run: cargo test -r --all-targets --features ${{ matrix.feature }} --workspace 85 | id: run_tests 86 | env: 87 | INSTA_UPDATE: new 88 | 89 | - name: Upload snapshots of failed tests 90 | if: ${{ failure() && steps.run_tests.outcome == 'failure' }} 91 | uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4 92 | with: 93 | name: failed-snapshots-${{ matrix.job.os }} 94 | path: "**/snapshots/*.snap.new" 95 | docs: 96 | name: Build docs 97 | runs-on: ${{ matrix.job.os }} 98 | strategy: 99 | matrix: 100 | rust: [stable] 101 | job: 102 | - os: macos-latest 103 | - os: ubuntu-latest 104 | - os: windows-latest 105 | steps: 106 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 107 | if: github.event_name != 'pull_request' 108 | with: 109 | fetch-depth: 0 110 | 111 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 112 | if: github.event_name == 'pull_request' 113 | with: 114 | ref: ${{ github.event.pull_request.head.sha }} 115 | fetch-depth: 0 116 | 117 | - name: Install Rust toolchain 118 | uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1 119 | with: 120 | toolchain: stable 121 | - uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2 122 | - name: Run Cargo Doc 123 | run: cargo doc --no-deps --all-features --workspace --examples 124 | 125 | result: 126 | name: Result (CI) 127 | runs-on: ubuntu-latest 128 | needs: 129 | - fmt 130 | - clippy 131 | - test 132 | - docs 133 | steps: 134 | - name: Mark the job as successful 135 | run: exit 0 136 | if: success() 137 | - name: Mark the job as unsuccessful 138 | run: exit 1 139 | if: "!success()" 140 | -------------------------------------------------------------------------------- /.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 | - os: windows-latest 34 | steps: 35 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 36 | if: github.event_name != 'pull_request' 37 | with: 38 | fetch-depth: 0 39 | 40 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 41 | if: github.event_name == 'pull_request' 42 | with: 43 | ref: ${{ github.event.pull_request.head.sha }} 44 | fetch-depth: 0 45 | 46 | - name: Setup Restic 47 | uses: rustic-rs/setup-restic@main 48 | 49 | - name: Install Rust toolchain 50 | uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1 51 | with: 52 | toolchain: stable 53 | 54 | - name: Create fixtures 55 | shell: bash 56 | run: | 57 | restic init 58 | restic backup src 59 | mv src/lib.rs lib.rs 60 | restic backup src 61 | mv lib.rs src/lib.rs 62 | env: 63 | RESTIC_REPOSITORY: ./tests/repository-fixtures/repo 64 | RESTIC_PASSWORD: restic 65 | 66 | - uses: Swatinem/rust-cache@23bce251a8cd2ffc3c1075eaa2367cf899916d84 # v2 67 | 68 | - name: Run Cargo Test 69 | run: cargo test -r --test repositories --features ${{ matrix.feature }} -- test_restic_latest_repo_with_rustic_passes --exact --show-output --ignored 70 | 71 | result: 72 | name: Result (Compat) 73 | runs-on: ubuntu-latest 74 | needs: 75 | - test 76 | steps: 77 | - name: Mark the job as successful 78 | run: exit 0 79 | if: success() 80 | - name: Mark the job as unsuccessful 81 | run: exit 1 82 | if: "!success()" 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-13 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 | -------------------------------------------------------------------------------- /.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@2f1cf31537886c3bfb05591c031f7744e48ba8a1 # v2.2 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 | -------------------------------------------------------------------------------- /.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 | # FIXME: `aws-lc-sys` doesn't cross compile 72 | # - os: ubuntu-latest 73 | # os-name: netbsd 74 | # target: x86_64-unknown-netbsd 75 | # architecture: x86_64 76 | # binary-postfix: "" 77 | # use-cross: true 78 | # FIXME: `aws-lc-sys` doesn't cross compile 79 | # - os: ubuntu-latest 80 | # os-name: linux 81 | # target: armv7-unknown-linux-gnueabihf 82 | # architecture: armv7 83 | # binary-postfix: "" 84 | use-cross: true 85 | 86 | steps: 87 | - name: Checkout repository 88 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 89 | with: 90 | fetch-depth: 0 # fetch all history so that git describe works 91 | - name: Create binary artifact 92 | uses: rustic-rs/create-binary-artifact-action@main # dev 93 | with: 94 | toolchain: ${{ matrix.rust }} 95 | target: ${{ matrix.job.target }} 96 | use-cross: ${{ matrix.job.use-cross }} 97 | describe-tag-suffix: -${{ github.run_id }}-${{ github.run_attempt }} 98 | binary-postfix: ${{ matrix.job.binary-postfix }} 99 | os: ${{ runner.os }} 100 | binary-name: ${{ env.BINARY_NAME }} 101 | package-secondary-name: ${{ matrix.job.target}} 102 | github-token: ${{ secrets.GITHUB_TOKEN }} 103 | github-ref: ${{ github.ref }} 104 | sign-release: false 105 | hash-release: true 106 | use-project-version: true 107 | 108 | remove-build-label: 109 | name: Remove build label 110 | needs: pr-build 111 | permissions: 112 | contents: read 113 | issues: write 114 | pull-requests: write 115 | runs-on: ubuntu-latest 116 | if: | 117 | always() && 118 | ! contains(needs.*.result, 'skipped') && 119 | github.repository_owner == 'rustic-rs' 120 | steps: 121 | - name: Remove label 122 | env: 123 | GH_TOKEN: ${{ github.token }} 124 | run: | 125 | gh api \ 126 | --method DELETE \ 127 | -H "Accept: application/vnd.github+json" \ 128 | -H "X-GitHub-Api-Version: 2022-11-28" \ 129 | /repos/${{ github.repository }}/issues/${{ github.event.number }}/labels/S-build 130 | -------------------------------------------------------------------------------- /.github/workflows/release-cd.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Deployment (Release) 2 | 3 | on: 4 | push: 5 | tags: 6 | # Run on stable releases 7 | - "v*.*.*" 8 | # Run on release candidates 9 | - "v*.*.*-rc*" 10 | 11 | defaults: 12 | run: 13 | shell: bash 14 | 15 | permissions: 16 | contents: write 17 | discussions: write 18 | 19 | env: 20 | BINARY_NAME: rustic 21 | BINARY_NIGHTLY_DIR: rustic 22 | 23 | jobs: 24 | publish: 25 | if: ${{ github.repository_owner == 'rustic-rs' }} 26 | name: Publishing ${{ matrix.job.target }} 27 | runs-on: ${{ matrix.job.os }} 28 | strategy: 29 | fail-fast: false # so we upload the artifacts even if one of the jobs fails 30 | matrix: 31 | rust: [stable] 32 | job: 33 | - os: windows-latest 34 | os-name: windows 35 | target: x86_64-pc-windows-msvc 36 | architecture: x86_64 37 | binary-postfix: ".exe" 38 | use-cross: false 39 | # FIXME: `aws-lc-sys` doesn't cross compile 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-13 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 | # FIXME: `aws-lc-sys` doesn't cross compile 89 | # - os: ubuntu-latest 90 | # os-name: netbsd 91 | # target: x86_64-unknown-netbsd 92 | # architecture: x86_64 93 | # binary-postfix: "" 94 | # use-cross: true 95 | # FIXME: `aws-lc-sys` doesn't cross compile 96 | # - os: ubuntu-latest 97 | # os-name: linux 98 | # target: armv7-unknown-linux-gnueabihf 99 | # architecture: armv7 100 | # binary-postfix: "" 101 | # use-cross: true 102 | steps: 103 | - name: Checkout repository 104 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 105 | with: 106 | fetch-depth: 0 # fetch all history so that git describe works 107 | - name: Create binary artifact 108 | uses: rustic-rs/create-binary-artifact-action@main # dev 109 | with: 110 | toolchain: ${{ matrix.rust }} 111 | target: ${{ matrix.job.target }} 112 | use-cross: ${{ matrix.job.use-cross }} 113 | binary-postfix: ${{ matrix.job.binary-postfix }} 114 | os: ${{ runner.os }} 115 | binary-name: ${{ env.BINARY_NAME }} 116 | package-secondary-name: ${{ matrix.job.target}} 117 | github-token: ${{ secrets.GITHUB_TOKEN }} 118 | gpg-release-private-key: ${{ secrets.GPG_RELEASE_PRIVATE_KEY }} 119 | gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }} 120 | rsign-release-private-key: ${{ secrets.RSIGN_RELEASE_PRIVATE_KEY }} 121 | rsign-passphrase: ${{ secrets.RSIGN_PASSPHRASE }} 122 | github-ref: ${{ github.ref }} 123 | sign-release: true 124 | hash-release: true 125 | use-project-version: true 126 | 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 127 | 128 | create-release: 129 | name: Creating release with artifacts 130 | needs: publish 131 | runs-on: ubuntu-latest 132 | steps: 133 | # Need to clone the repo again for the CHANGELOG.md 134 | - name: Checkout repository 135 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 136 | with: 137 | fetch-depth: 0 # fetch all history so that git describe works 138 | 139 | - name: Download all workflow run artifacts 140 | uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4 141 | 142 | - name: Creating Release 143 | uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191 # v2 144 | with: 145 | discussion_category_name: "Announcements" 146 | draft: true 147 | body_path: ${{ github.workspace }}/CHANGELOG.md 148 | fail_on_unmatched_files: true 149 | files: | 150 | binary-*/${{ env.BINARY_NAME }}-*.tar.gz* 151 | env: 152 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 153 | 154 | result: 155 | if: ${{ github.repository_owner == 'rustic-rs' }} 156 | name: Result (Release CD) 157 | runs-on: ubuntu-latest 158 | needs: 159 | - publish 160 | - create-release 161 | steps: 162 | - name: Mark the job as successful 163 | run: exit 0 164 | if: success() 165 | - name: Mark the job as unsuccessful 166 | run: exit 1 167 | if: "!success()" 168 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3 11 | 12 | - name: Login to Docker Hub 13 | uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # 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@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # 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 | -------------------------------------------------------------------------------- /.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@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1 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 | 32 | - name: Run release-plz 33 | uses: MarcoIeni/release-plz-action@394e0e463367550953346be95d427f80f4f7ae30 # v0.5 34 | env: 35 | GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} 36 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | .vscode 4 | mutants.out 5 | cargo-test* 6 | coverage/*lcov 7 | .testscompletions-* 8 | 9 | # Ignore generated test files 10 | /tests/generated/*.toml 11 | /tests/generated/*.log 12 | /tests/generated/test-restore 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /build-dependencies.just: -------------------------------------------------------------------------------- 1 | ### DEFAULT ### 2 | 3 | # Install dependencies for the default feature on x86_64-unknown-linux-musl 4 | install-default-x86_64-unknown-linux-musl: 5 | sudo apt-get update 6 | sudo apt-get install -y musl-tools 7 | 8 | # Install dependencies for the default feature on aarch64-unknown-linux-musl 9 | install-default-aarch64-unknown-linux-musl: 10 | sudo apt-get update 11 | sudo apt-get install -y musl-tools 12 | 13 | ### MOUNT ### 14 | 15 | # Install dependencies for the mount feature on x86_64-unknown-linux-gnu 16 | install-mount-x86_64-unknown-linux-gnu: 17 | sudo apt-get update 18 | sudo apt-get install -y libfuse-dev pkg-config 19 | 20 | # Install dependencies for the mount feature on aarch64-unknown-linux-gnu 21 | install-mount-aarch64-unknown-linux-gnu: 22 | sudo apt-get update 23 | sudo apt-get install -y libfuse-dev pkg-config 24 | 25 | # Install dependencies for the mount feature on i686-unknown-linux-gnu 26 | install-mount-i686-unknown-linux-gnu: 27 | sudo apt-get update 28 | sudo apt-get install -y libfuse-dev pkg-config 29 | 30 | # Install dependencies for the mount feature on x86_64-apple-darwin 31 | install-mount-x86_64-apple-darwin: 32 | brew install macfuse 33 | 34 | # Install dependencies for the mount feature on aarch64-apple-darwin 35 | install-mount-aarch64-apple-darwin: 36 | brew install macfuse 37 | 38 | # Install dependencies for the mount feature on x86_64-pc-windows-msvc 39 | install-mount-x86_64-pc-windows-msvc: 40 | winget install winfsp 41 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | PROJECT_VERSION=$(git describe --tags) cargo build -r $@ 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /config/simple.toml: -------------------------------------------------------------------------------- 1 | [repository] 2 | repository = "/tmp/repo" 3 | password = "test" 4 | -------------------------------------------------------------------------------- /coverage/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rustic-rs/rustic/2d4e0990ee8652412b3ff05bfdcf1ff24e465e89/coverage/.gitkeep -------------------------------------------------------------------------------- /docs/Readme.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | Our documentation can be found at: 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | PROJECT_VERSION=$(git describe --tags) cargo build -r $@ 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/check.rs: -------------------------------------------------------------------------------- 1 | //! `check` subcommand 2 | 3 | use crate::{Application, RUSTIC_APP, repository::CliOpenRepo, status_err}; 4 | 5 | use abscissa_core::{Command, Runnable, Shutdown}; 6 | use anyhow::Result; 7 | use rustic_core::{CheckOptions, SnapshotGroupCriterion}; 8 | 9 | /// `check` subcommand 10 | #[derive(clap::Parser, Command, Debug)] 11 | pub(crate) struct CheckCmd { 12 | /// Snapshots to check. If none is given, use filter options to filter from all snapshots 13 | #[clap(value_name = "ID")] 14 | ids: Vec, 15 | 16 | /// Check options 17 | #[clap(flatten)] 18 | opts: CheckOptions, 19 | } 20 | 21 | impl Runnable for CheckCmd { 22 | fn run(&self) { 23 | if let Err(err) = RUSTIC_APP 24 | .config() 25 | .repository 26 | .run_open(|repo| self.inner_run(repo)) 27 | { 28 | status_err!("{}", err); 29 | RUSTIC_APP.shutdown(Shutdown::Crash); 30 | }; 31 | } 32 | } 33 | 34 | impl CheckCmd { 35 | fn inner_run(&self, repo: CliOpenRepo) -> Result<()> { 36 | let config = RUSTIC_APP.config(); 37 | 38 | let groups = repo.get_snapshot_group(&self.ids, SnapshotGroupCriterion::new(), |sn| { 39 | config.snapshot_filter.matches(sn) 40 | })?; 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)?; 47 | Ok(()) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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; 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 | let changed = config 33 | .repository 34 | .run_open(|repo| Ok(repo.apply_config(&self.config_opts)?))?; 35 | 36 | if changed { 37 | println!("saved new config"); 38 | } else { 39 | println!("config is unchanged"); 40 | } 41 | 42 | Ok(()) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/commands/find.rs: -------------------------------------------------------------------------------- 1 | //! `find` subcommand 2 | 3 | use std::path::{Path, PathBuf}; 4 | 5 | use crate::{Application, RUSTIC_APP, repository::CliIndexedRepo, status_err}; 6 | 7 | use abscissa_core::{Command, Runnable, Shutdown}; 8 | use anyhow::Result; 9 | use clap::ValueHint; 10 | use globset::{Glob, GlobBuilder, GlobSetBuilder}; 11 | use itertools::Itertools; 12 | 13 | use rustic_core::{ 14 | FindMatches, FindNode, SnapshotGroupCriterion, 15 | repofile::{Node, SnapshotFile}, 16 | }; 17 | 18 | use super::ls::print_node; 19 | 20 | /// `find` subcommand 21 | #[derive(clap::Parser, Command, Debug)] 22 | pub(crate) struct FindCmd { 23 | /// pattern to find (can be specified multiple times) 24 | #[clap(long, value_name = "PATTERN", conflicts_with = "path")] 25 | glob: Vec, 26 | 27 | /// pattern to find case-insensitive (can be specified multiple times) 28 | #[clap(long, value_name = "PATTERN", conflicts_with = "path")] 29 | iglob: Vec, 30 | 31 | /// exact path to find 32 | #[clap(long, value_name = "PATH", value_hint = ValueHint::AnyPath)] 33 | path: Option, 34 | 35 | /// Snapshots to search in. If none is given, use filter options to filter from all snapshots 36 | #[clap(value_name = "ID")] 37 | ids: Vec, 38 | 39 | /// Group snapshots by any combination of host,label,paths,tags 40 | #[clap( 41 | long, 42 | short = 'g', 43 | value_name = "CRITERION", 44 | default_value = "host,label,paths" 45 | )] 46 | group_by: SnapshotGroupCriterion, 47 | 48 | /// Show all snapshots instead of summarizing snapshots with identical search results 49 | #[clap(long)] 50 | all: bool, 51 | 52 | /// Also show snapshots which don't contain a search result. 53 | #[clap(long)] 54 | show_misses: bool, 55 | 56 | /// Show uid/gid instead of user/group 57 | #[clap(long, long("numeric-uid-gid"))] 58 | numeric_id: bool, 59 | } 60 | 61 | impl Runnable for FindCmd { 62 | fn run(&self) { 63 | if let Err(err) = RUSTIC_APP 64 | .config() 65 | .repository 66 | .run_indexed(|repo| self.inner_run(repo)) 67 | { 68 | status_err!("{}", err); 69 | RUSTIC_APP.shutdown(Shutdown::Crash); 70 | }; 71 | } 72 | } 73 | 74 | impl FindCmd { 75 | fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> { 76 | let config = RUSTIC_APP.config(); 77 | 78 | let groups = repo.get_snapshot_group(&self.ids, self.group_by, |sn| { 79 | config.snapshot_filter.matches(sn) 80 | })?; 81 | for (group, mut snapshots) in groups { 82 | snapshots.sort_unstable(); 83 | if !group.is_empty() { 84 | println!("\nsearching in snapshots group {group}..."); 85 | } 86 | let ids = snapshots.iter().map(|sn| sn.tree); 87 | if let Some(path) = &self.path { 88 | let FindNode { nodes, matches } = repo.find_nodes_from_path(ids, path)?; 89 | for (idx, g) in &matches 90 | .iter() 91 | .zip(snapshots.iter()) 92 | .chunk_by(|(idx, _)| *idx) 93 | { 94 | self.print_identical_snapshots(idx.iter(), g.into_iter().map(|(_, sn)| sn)); 95 | if let Some(idx) = idx { 96 | print_node(&nodes[*idx], path, self.numeric_id); 97 | } 98 | } 99 | } else { 100 | let mut builder = GlobSetBuilder::new(); 101 | for glob in &self.glob { 102 | _ = builder.add(Glob::new(glob)?); 103 | } 104 | for glob in &self.iglob { 105 | _ = builder.add(GlobBuilder::new(glob).case_insensitive(true).build()?); 106 | } 107 | let globset = builder.build()?; 108 | let matches = |path: &Path, _: &Node| { 109 | globset.is_match(path) || path.file_name().is_some_and(|f| globset.is_match(f)) 110 | }; 111 | let FindMatches { 112 | paths, 113 | nodes, 114 | matches, 115 | } = repo.find_matching_nodes(ids, &matches)?; 116 | for (idx, g) in &matches 117 | .iter() 118 | .zip(snapshots.iter()) 119 | .chunk_by(|(idx, _)| *idx) 120 | { 121 | self.print_identical_snapshots(idx.iter(), g.into_iter().map(|(_, sn)| sn)); 122 | for (path_idx, node_idx) in idx { 123 | print_node(&nodes[*node_idx], &paths[*path_idx], self.numeric_id); 124 | } 125 | } 126 | } 127 | } 128 | Ok(()) 129 | } 130 | 131 | fn print_identical_snapshots<'a>( 132 | &self, 133 | mut idx: impl Iterator, 134 | mut g: impl Iterator, 135 | ) { 136 | let empty_result = idx.next().is_none(); 137 | let not = if empty_result { "not " } else { "" }; 138 | if self.show_misses || !empty_result { 139 | if self.all { 140 | for sn in g { 141 | let time = sn.time.format("%Y-%m-%d %H:%M:%S"); 142 | println!("{not}found in {} from {time}", sn.id); 143 | } 144 | } else { 145 | let sn = g.next().unwrap(); 146 | let count = g.count(); 147 | let time = sn.time.format("%Y-%m-%d %H:%M:%S"); 148 | match count { 149 | 0 => println!("{not}found in {} from {time}", sn.id), 150 | count => println!("{not}found in {} from {time} (+{count})", sn.id), 151 | }; 152 | } 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/commands/key.rs: -------------------------------------------------------------------------------- 1 | //! `key` subcommand 2 | 3 | use crate::{Application, RUSTIC_APP, repository::CliOpenRepo, status_err}; 4 | 5 | use std::path::PathBuf; 6 | 7 | use abscissa_core::{Command, Runnable, Shutdown}; 8 | use anyhow::Result; 9 | use dialoguer::Password; 10 | use log::info; 11 | 12 | use rustic_core::{CommandInput, KeyOptions, RepositoryOptions}; 13 | 14 | /// `key` subcommand 15 | #[derive(clap::Parser, Command, Debug)] 16 | pub(super) struct KeyCmd { 17 | /// Subcommand to run 18 | #[clap(subcommand)] 19 | cmd: KeySubCmd, 20 | } 21 | 22 | #[derive(clap::Subcommand, Debug, Runnable)] 23 | enum KeySubCmd { 24 | /// Add a new key to the repository 25 | Add(AddCmd), 26 | } 27 | 28 | #[derive(clap::Parser, Debug)] 29 | pub(crate) struct AddCmd { 30 | /// New password 31 | #[clap(long)] 32 | pub(crate) new_password: Option, 33 | 34 | /// File from which to read the new password 35 | #[clap(long)] 36 | pub(crate) new_password_file: Option, 37 | 38 | /// Command to get the new password from 39 | #[clap(long)] 40 | pub(crate) new_password_command: Option, 41 | 42 | /// Key options 43 | #[clap(flatten)] 44 | pub(crate) key_opts: KeyOptions, 45 | } 46 | 47 | impl Runnable for KeyCmd { 48 | fn run(&self) { 49 | self.cmd.run(); 50 | } 51 | } 52 | 53 | impl Runnable for AddCmd { 54 | fn run(&self) { 55 | if let Err(err) = RUSTIC_APP 56 | .config() 57 | .repository 58 | .run_open(|repo| self.inner_run(repo)) 59 | { 60 | status_err!("{}", err); 61 | RUSTIC_APP.shutdown(Shutdown::Crash); 62 | }; 63 | } 64 | } 65 | 66 | impl AddCmd { 67 | fn inner_run(&self, repo: CliOpenRepo) -> Result<()> { 68 | // create new Repository options which just contain password information 69 | let mut pass_opts = RepositoryOptions::default(); 70 | pass_opts.password = self.new_password.clone(); 71 | pass_opts.password_file = self.new_password_file.clone(); 72 | pass_opts.password_command = self.new_password_command.clone(); 73 | 74 | let pass = pass_opts 75 | .evaluate_password() 76 | .map_err(Into::into) 77 | .transpose() 78 | .unwrap_or_else(|| -> Result<_> { 79 | Ok(Password::new() 80 | .with_prompt("enter password for new key") 81 | .allow_empty_password(true) 82 | .with_confirmation("confirm password", "passwords do not match") 83 | .interact()?) 84 | })?; 85 | 86 | let id = repo.add_key(&pass, &self.key_opts)?; 87 | info!("key {id} successfully added."); 88 | 89 | Ok(()) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /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/merge.rs: -------------------------------------------------------------------------------- 1 | //! `merge` subcommand 2 | 3 | use crate::{Application, RUSTIC_APP, repository::CliOpenRepo, status_err}; 4 | use abscissa_core::{Command, Runnable, Shutdown}; 5 | use anyhow::Result; 6 | use log::info; 7 | 8 | use chrono::Local; 9 | 10 | use rustic_core::{SnapshotOptions, last_modified_node, repofile::SnapshotFile}; 11 | 12 | /// `merge` subcommand 13 | #[derive(clap::Parser, Default, Command, Debug)] 14 | pub(super) struct MergeCmd { 15 | /// Snapshots to merge. If none is given, use filter options to filter from all snapshots. 16 | #[clap(value_name = "ID")] 17 | ids: Vec, 18 | 19 | /// Output generated snapshot in json format 20 | #[clap(long)] 21 | json: bool, 22 | 23 | /// Remove input snapshots after merging 24 | #[clap(long)] 25 | delete: bool, 26 | 27 | /// Snapshot options 28 | #[clap(flatten, next_help_heading = "Snapshot options")] 29 | snap_opts: SnapshotOptions, 30 | } 31 | 32 | impl Runnable for MergeCmd { 33 | fn run(&self) { 34 | if let Err(err) = RUSTIC_APP 35 | .config() 36 | .repository 37 | .run_open(|repo| self.inner_run(repo)) 38 | { 39 | status_err!("{}", err); 40 | RUSTIC_APP.shutdown(Shutdown::Crash); 41 | }; 42 | } 43 | } 44 | 45 | impl MergeCmd { 46 | fn inner_run(&self, repo: CliOpenRepo) -> Result<()> { 47 | let config = RUSTIC_APP.config(); 48 | let repo = repo.to_indexed_ids()?; 49 | 50 | let snapshots = if self.ids.is_empty() { 51 | repo.get_matching_snapshots(|sn| config.snapshot_filter.matches(sn))? 52 | } else { 53 | repo.get_snapshots(&self.ids)? 54 | }; 55 | 56 | let snap = SnapshotFile::from_options(&self.snap_opts)?; 57 | 58 | let snap = repo.merge_snapshots(&snapshots, &last_modified_node, snap)?; 59 | 60 | if self.json { 61 | let mut stdout = std::io::stdout(); 62 | serde_json::to_writer_pretty(&mut stdout, &snap)?; 63 | } 64 | info!("saved new snapshot as {}.", snap.id); 65 | 66 | if self.delete { 67 | let now = Local::now(); 68 | // TODO: Maybe use this check in repo.delete_snapshots? 69 | let snap_ids: Vec<_> = snapshots 70 | .iter() 71 | .filter(|sn| !sn.must_keep(now)) 72 | .map(|sn| sn.id) 73 | .collect(); 74 | repo.delete_snapshots(&snap_ids)?; 75 | } 76 | 77 | Ok(()) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/repoinfo.rs: -------------------------------------------------------------------------------- 1 | //! `repoinfo` subcommand 2 | 3 | use crate::{ 4 | Application, RUSTIC_APP, 5 | helpers::{bytes_size_to_string, table_right_from}, 6 | repository::CliRepo, 7 | status_err, 8 | }; 9 | 10 | use abscissa_core::{Command, Runnable, Shutdown}; 11 | use serde::Serialize; 12 | 13 | use anyhow::Result; 14 | use rustic_core::{IndexInfos, RepoFileInfo, RepoFileInfos}; 15 | 16 | /// `repoinfo` subcommand 17 | #[derive(clap::Parser, Command, Debug)] 18 | pub(crate) struct RepoInfoCmd { 19 | /// Only scan repository files (doesn't need repository password) 20 | #[clap(long)] 21 | only_files: bool, 22 | 23 | /// Only scan index 24 | #[clap(long)] 25 | only_index: bool, 26 | 27 | /// Show infos in json format 28 | #[clap(long)] 29 | json: bool, 30 | } 31 | 32 | impl Runnable for RepoInfoCmd { 33 | fn run(&self) { 34 | if let Err(err) = RUSTIC_APP 35 | .config() 36 | .repository 37 | .run(|repo| self.inner_run(repo)) 38 | { 39 | status_err!("{}", err); 40 | RUSTIC_APP.shutdown(Shutdown::Crash); 41 | }; 42 | } 43 | } 44 | 45 | /// Infos about the repository 46 | /// 47 | /// This struct is used to serialize infos in `json` format. 48 | #[serde_with::apply(Option => #[serde(default, skip_serializing_if = "Option::is_none")])] 49 | #[derive(Serialize)] 50 | struct Infos { 51 | files: Option, 52 | index: Option, 53 | } 54 | 55 | impl RepoInfoCmd { 56 | fn inner_run(&self, repo: CliRepo) -> Result<()> { 57 | let infos = Infos { 58 | files: (!self.only_index) 59 | .then(|| -> Result<_> { Ok(repo.infos_files()?) }) 60 | .transpose()?, 61 | index: (!self.only_files) 62 | .then(|| -> Result<_> { Ok(repo.open()?.infos_index()?) }) 63 | .transpose()?, 64 | }; 65 | 66 | if self.json { 67 | let mut stdout = std::io::stdout(); 68 | serde_json::to_writer_pretty(&mut stdout, &infos)?; 69 | return Ok(()); 70 | } 71 | 72 | if let Some(file_info) = infos.files { 73 | print_file_info("repository files", file_info.repo); 74 | if let Some(info) = file_info.repo_hot { 75 | print_file_info("hot repository files", info); 76 | } 77 | } 78 | 79 | if let Some(index_info) = infos.index { 80 | print_index_info(index_info); 81 | } 82 | Ok(()) 83 | } 84 | } 85 | 86 | /// Print infos about repository files 87 | /// 88 | /// # Arguments 89 | /// 90 | /// * `text` - the text to print before the table 91 | /// * `info` - the [`RepoFileInfo`]s to print 92 | pub fn print_file_info(text: &str, info: Vec) { 93 | let mut table = table_right_from(1, ["File type", "Count", "Total Size"]); 94 | let mut total_count = 0; 95 | let mut total_size = 0; 96 | for row in info { 97 | _ = table.add_row([ 98 | format!("{:?}", row.tpe), 99 | row.count.to_string(), 100 | bytes_size_to_string(row.size), 101 | ]); 102 | total_count += row.count; 103 | total_size += row.size; 104 | } 105 | println!("{text}"); 106 | _ = table.add_row([ 107 | "Total".to_string(), 108 | total_count.to_string(), 109 | bytes_size_to_string(total_size), 110 | ]); 111 | 112 | println!(); 113 | println!("{table}"); 114 | println!(); 115 | } 116 | 117 | /// Print infos about index 118 | /// 119 | /// # Arguments 120 | /// 121 | /// * `index_info` - the [`IndexInfos`] to print 122 | pub fn print_index_info(index_info: IndexInfos) { 123 | let mut table = table_right_from( 124 | 1, 125 | ["Blob type", "Count", "Total Size", "Total Size in Packs"], 126 | ); 127 | 128 | let mut total_count = 0; 129 | let mut total_data_size = 0; 130 | let mut total_size = 0; 131 | 132 | for blobs in &index_info.blobs { 133 | _ = table.add_row([ 134 | format!("{:?}", blobs.blob_type), 135 | blobs.count.to_string(), 136 | bytes_size_to_string(blobs.data_size), 137 | bytes_size_to_string(blobs.size), 138 | ]); 139 | total_count += blobs.count; 140 | total_data_size += blobs.data_size; 141 | total_size += blobs.size; 142 | } 143 | for blobs in &index_info.blobs_delete { 144 | if blobs.count > 0 { 145 | _ = table.add_row([ 146 | format!("{:?} to delete", blobs.blob_type), 147 | blobs.count.to_string(), 148 | bytes_size_to_string(blobs.data_size), 149 | bytes_size_to_string(blobs.size), 150 | ]); 151 | total_count += blobs.count; 152 | total_data_size += blobs.data_size; 153 | total_size += blobs.size; 154 | } 155 | } 156 | 157 | _ = table.add_row([ 158 | "Total".to_string(), 159 | total_count.to_string(), 160 | bytes_size_to_string(total_data_size), 161 | bytes_size_to_string(total_size), 162 | ]); 163 | 164 | println!(); 165 | println!("{table}"); 166 | 167 | let mut table = table_right_from( 168 | 1, 169 | ["Blob type", "Pack Count", "Minimum Size", "Maximum Size"], 170 | ); 171 | 172 | for packs in index_info.packs { 173 | _ = table.add_row([ 174 | format!("{:?} packs", packs.blob_type), 175 | packs.count.to_string(), 176 | packs.min_size.map_or("-".to_string(), bytes_size_to_string), 177 | packs.max_size.map_or("-".to_string(), bytes_size_to_string), 178 | ]); 179 | } 180 | for packs in index_info.packs_delete { 181 | if packs.count > 0 { 182 | _ = table.add_row([ 183 | format!("{:?} packs to delete", packs.blob_type), 184 | packs.count.to_string(), 185 | packs.min_size.map_or("-".to_string(), bytes_size_to_string), 186 | packs.max_size.map_or("-".to_string(), bytes_size_to_string), 187 | ]); 188 | } 189 | } 190 | println!(); 191 | println!("{table}"); 192 | } 193 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/commands/tag.rs: -------------------------------------------------------------------------------- 1 | //! `tag` subcommand 2 | 3 | use crate::{Application, RUSTIC_APP, repository::CliOpenRepo, status_err}; 4 | 5 | use abscissa_core::{Command, Runnable, Shutdown}; 6 | 7 | use anyhow::Result; 8 | use chrono::{Duration, Local}; 9 | 10 | use rustic_core::{StringList, repofile::DeleteOption}; 11 | 12 | /// `tag` subcommand 13 | #[derive(clap::Parser, Command, Debug)] 14 | pub(crate) struct TagCmd { 15 | /// Snapshots to change tags. If none is given, use filter to filter from all 16 | /// snapshots. 17 | #[clap(value_name = "ID")] 18 | ids: Vec, 19 | 20 | /// Tags to add (can be specified multiple times) 21 | #[clap( 22 | long, 23 | value_name = "TAG[,TAG,..]", 24 | conflicts_with = "remove", 25 | help_heading = "Tag options" 26 | )] 27 | add: Vec, 28 | 29 | /// Tags to remove (can be specified multiple times) 30 | #[clap(long, value_name = "TAG[,TAG,..]", help_heading = "Tag options")] 31 | remove: Vec, 32 | 33 | /// Tag list to set (can be specified multiple times) 34 | #[clap( 35 | long, 36 | value_name = "TAG[,TAG,..]", 37 | conflicts_with = "remove", 38 | help_heading = "Tag options" 39 | )] 40 | set: Vec, 41 | 42 | /// Remove any delete mark 43 | #[clap( 44 | long, 45 | conflicts_with_all = &["set_delete_never", "set_delete_after"], 46 | help_heading = "Delete mark options" 47 | )] 48 | remove_delete: bool, 49 | 50 | /// Mark snapshot as uneraseable 51 | #[clap( 52 | long, 53 | conflicts_with = "set_delete_after", 54 | help_heading = "Delete mark options" 55 | )] 56 | set_delete_never: bool, 57 | 58 | /// Mark snapshot to be deleted after given duration (e.g. 10d) 59 | #[clap(long, value_name = "DURATION", help_heading = "Delete mark options")] 60 | set_delete_after: Option, 61 | } 62 | 63 | impl Runnable for TagCmd { 64 | fn run(&self) { 65 | if let Err(err) = RUSTIC_APP 66 | .config() 67 | .repository 68 | .run_open(|repo| self.inner_run(repo)) 69 | { 70 | status_err!("{}", err); 71 | RUSTIC_APP.shutdown(Shutdown::Crash); 72 | }; 73 | } 74 | } 75 | 76 | impl TagCmd { 77 | fn inner_run(&self, repo: CliOpenRepo) -> Result<()> { 78 | let config = RUSTIC_APP.config(); 79 | 80 | let snapshots = if self.ids.is_empty() { 81 | repo.get_matching_snapshots(|sn| config.snapshot_filter.matches(sn))? 82 | } else { 83 | repo.get_snapshots(&self.ids)? 84 | }; 85 | 86 | let delete = match ( 87 | self.remove_delete, 88 | self.set_delete_never, 89 | self.set_delete_after, 90 | ) { 91 | (true, _, _) => Some(DeleteOption::NotSet), 92 | (_, true, _) => Some(DeleteOption::Never), 93 | (_, _, Some(d)) => Some(DeleteOption::After(Local::now() + Duration::from_std(*d)?)), 94 | (false, false, None) => None, 95 | }; 96 | 97 | let snapshots: Vec<_> = snapshots 98 | .into_iter() 99 | .filter_map(|mut sn| { 100 | sn.modify_sn(self.set.clone(), self.add.clone(), &self.remove, &delete) 101 | }) 102 | .collect(); 103 | let old_snap_ids: Vec<_> = snapshots.iter().map(|sn| sn.id).collect(); 104 | 105 | match (old_snap_ids.is_empty(), config.global.dry_run) { 106 | (true, _) => println!("no snapshot changed."), 107 | (false, true) => { 108 | println!("would have modified the following snapshots:\n {old_snap_ids:?}"); 109 | } 110 | (false, false) => { 111 | repo.save_snapshots(snapshots)?; 112 | repo.delete_snapshots(&old_snap_ids)?; 113 | } 114 | } 115 | 116 | Ok(()) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/commands/tui.rs: -------------------------------------------------------------------------------- 1 | //! `tui` subcommand 2 | mod ls; 3 | mod progress; 4 | mod restore; 5 | mod snapshots; 6 | mod tree; 7 | mod widgets; 8 | 9 | use crossterm::event::{KeyEvent, KeyModifiers}; 10 | use progress::TuiProgressBars; 11 | use scopeguard::defer; 12 | use snapshots::Snapshots; 13 | 14 | use std::io; 15 | use std::sync::{Arc, RwLock}; 16 | 17 | use crate::{Application, RUSTIC_APP}; 18 | 19 | use anyhow::Result; 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 ratatui::prelude::*; 26 | use rustic_core::{IndexedFull, Progress, ProgressBars, SnapshotGroupCriterion}; 27 | 28 | struct App<'a, P, S> { 29 | snapshots: Snapshots<'a, P, S>, 30 | } 31 | 32 | pub fn run(group_by: SnapshotGroupCriterion) -> Result<()> { 33 | let config = RUSTIC_APP.config(); 34 | 35 | // setup terminal 36 | let terminal = init_terminal()?; 37 | let terminal = Arc::new(RwLock::new(terminal)); 38 | 39 | // restore terminal (even when leaving through ?, early return, or panic) 40 | defer! { 41 | reset_terminal().unwrap(); 42 | } 43 | 44 | let progress = TuiProgressBars { 45 | terminal: terminal.clone(), 46 | }; 47 | let res = config 48 | .repository 49 | .run_indexed_with_progress(progress.clone(), |repo| { 50 | let p = progress.progress_spinner("starting rustic in interactive mode..."); 51 | p.finish(); 52 | // create app and run it 53 | let snapshots = Snapshots::new(&repo, config.snapshot_filter.clone(), group_by)?; 54 | let app = App { snapshots }; 55 | run_app(terminal, app) 56 | }); 57 | 58 | if let Err(err) = res { 59 | println!("{err:?}"); 60 | } 61 | 62 | Ok(()) 63 | } 64 | 65 | /// Initializes the terminal. 66 | fn init_terminal() -> Result>> { 67 | execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; 68 | enable_raw_mode()?; 69 | 70 | let backend = CrosstermBackend::new(io::stdout()); 71 | 72 | let mut terminal = Terminal::new(backend)?; 73 | terminal.hide_cursor()?; 74 | 75 | Ok(terminal) 76 | } 77 | 78 | /// Resets the terminal. 79 | fn reset_terminal() -> Result<()> { 80 | disable_raw_mode()?; 81 | execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?; 82 | Ok(()) 83 | } 84 | 85 | fn run_app( 86 | terminal: Arc>>, 87 | mut app: App<'_, P, S>, 88 | ) -> Result<()> { 89 | loop { 90 | _ = terminal.write().unwrap().draw(|f| ui(f, &mut app))?; 91 | let event = event::read()?; 92 | 93 | if let Event::Key(KeyEvent { 94 | code: KeyCode::Char('c'), 95 | modifiers: KeyModifiers::CONTROL, 96 | kind: KeyEventKind::Press, 97 | .. 98 | }) = event 99 | { 100 | return Ok(()); 101 | } 102 | if app.snapshots.input(event)? { 103 | return Ok(()); 104 | } 105 | } 106 | } 107 | 108 | fn ui(f: &mut Frame<'_>, app: &mut App<'_, P, S>) { 109 | let area = f.area(); 110 | app.snapshots.draw(area, f); 111 | } 112 | -------------------------------------------------------------------------------- /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).to_string_as(true), 126 | length.map_or(String::new(), |l| format!( 127 | "/{}", 128 | ByteSize(l).to_string_as(true) 129 | )) 130 | ) 131 | } 132 | TuiProgressType::Hidden => String::new(), 133 | }; 134 | drop(data); 135 | 136 | let mut terminal = self.terminal.write().unwrap(); 137 | _ = terminal 138 | .draw(|f| { 139 | let area = f.area(); 140 | match self.progress_type { 141 | TuiProgressType::Hidden => {} 142 | TuiProgressType::Spinner => { 143 | let mut popup = popup_text("progress", message.into()); 144 | popup.draw(area, f); 145 | } 146 | TuiProgressType::Counter | TuiProgressType::Bytes => { 147 | let mut popup = popup_gauge("progress", message.into(), ratio); 148 | popup.draw(area, f); 149 | } 150 | } 151 | }) 152 | .unwrap(); 153 | } 154 | } 155 | 156 | impl Progress for TuiProgress { 157 | fn is_hidden(&self) -> bool { 158 | matches!(self.progress_type, TuiProgressType::Hidden) 159 | } 160 | fn set_length(&self, len: u64) { 161 | self.data.write().unwrap().length = Some(len); 162 | self.popup(); 163 | } 164 | fn set_title(&self, title: &'static str) { 165 | self.data.write().unwrap().prefix = String::from(title); 166 | self.popup(); 167 | } 168 | 169 | fn inc(&self, inc: u64) { 170 | self.data.write().unwrap().count += inc; 171 | self.popup(); 172 | } 173 | fn finish(&self) {} 174 | } 175 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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(key); 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 | -------------------------------------------------------------------------------- /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/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::{Application, RUSTIC_APP, RusticConfig, repository::CliIndexedRepo, status_err}; 9 | use abscissa_core::{Command, FrameworkError, Runnable, Shutdown, config::Override}; 10 | use anyhow::{Result, anyhow}; 11 | use conflate::Merge; 12 | use dav_server::{DavHandler, warp::dav_handler}; 13 | use serde::{Deserialize, Serialize}; 14 | 15 | use rustic_core::vfs::{FilePolicy, IdenticalSnapshot, Latest, Vfs}; 16 | use webdavfs::WebDavFS; 17 | 18 | mod webdavfs; 19 | 20 | #[derive(Clone, Command, Default, Debug, clap::Parser, Serialize, Deserialize, Merge)] 21 | #[serde(default, rename_all = "kebab-case", deny_unknown_fields)] 22 | pub struct WebDavCmd { 23 | /// Address to bind the webdav server to. [default: "localhost:8000"] 24 | #[clap(long, value_name = "ADDRESS")] 25 | #[merge(strategy=conflate::option::overwrite_none)] 26 | address: Option, 27 | 28 | /// 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}"] 29 | #[clap(long)] 30 | #[merge(strategy=conflate::option::overwrite_none)] 31 | path_template: Option, 32 | 33 | /// 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"] 34 | #[clap(long)] 35 | #[merge(strategy=conflate::option::overwrite_none)] 36 | time_template: Option, 37 | 38 | /// Use symlinks. This may not be supported by all WebDAV clients 39 | #[clap(long)] 40 | #[merge(strategy=conflate::bool::overwrite_false)] 41 | symlinks: bool, 42 | 43 | /// How to handle access to files. [default: "forbidden" for hot/cold repositories, else "read"] 44 | #[clap(long)] 45 | #[merge(strategy=conflate::option::overwrite_none)] 46 | file_access: Option, 47 | 48 | /// Specify directly which snapshot/path to serve 49 | #[clap(value_name = "SNAPSHOT[:PATH]")] 50 | #[merge(strategy=conflate::option::overwrite_none)] 51 | snapshot_path: Option, 52 | } 53 | 54 | impl Override for WebDavCmd { 55 | // Process the given command line options, overriding settings from 56 | // a configuration file using explicit flags taken from command-line 57 | // arguments. 58 | fn override_config(&self, mut config: RusticConfig) -> Result { 59 | let mut self_config = self.clone(); 60 | // merge "webdav" section from config file, if given 61 | self_config.merge(config.webdav); 62 | config.webdav = self_config; 63 | Ok(config) 64 | } 65 | } 66 | 67 | impl Runnable for WebDavCmd { 68 | fn run(&self) { 69 | if let Err(err) = RUSTIC_APP 70 | .config() 71 | .repository 72 | .run_indexed(|repo| self.inner_run(repo)) 73 | { 74 | status_err!("{}", err); 75 | RUSTIC_APP.shutdown(Shutdown::Crash); 76 | }; 77 | } 78 | } 79 | 80 | impl WebDavCmd { 81 | /// be careful about self VS RUSTIC_APP.config() usage 82 | /// only the RUSTIC_APP.config() involves the TOML and ENV merged configurations 83 | /// see https://github.com/rustic-rs/rustic/issues/1242 84 | fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> { 85 | let config = RUSTIC_APP.config(); 86 | 87 | let path_template = config 88 | .webdav 89 | .path_template 90 | .clone() 91 | .unwrap_or_else(|| "[{hostname}]/[{label}]/{time}".to_string()); 92 | let time_template = config 93 | .webdav 94 | .time_template 95 | .clone() 96 | .unwrap_or_else(|| "%Y-%m-%d_%H-%M-%S".to_string()); 97 | 98 | let sn_filter = |sn: &_| config.snapshot_filter.matches(sn); 99 | 100 | let vfs = if let Some(snap) = &config.webdav.snapshot_path { 101 | let node = repo.node_from_snapshot_path(snap, sn_filter)?; 102 | Vfs::from_dir_node(&node) 103 | } else { 104 | let snapshots = repo.get_matching_snapshots(sn_filter)?; 105 | let (latest, identical) = if config.webdav.symlinks { 106 | (Latest::AsLink, IdenticalSnapshot::AsLink) 107 | } else { 108 | (Latest::AsDir, IdenticalSnapshot::AsDir) 109 | }; 110 | Vfs::from_snapshots(snapshots, &path_template, &time_template, latest, identical)? 111 | }; 112 | 113 | let addr = config 114 | .webdav 115 | .address 116 | .clone() 117 | .unwrap_or_else(|| "localhost:8000".to_string()) 118 | .to_socket_addrs()? 119 | .next() 120 | .ok_or_else(|| anyhow!("no address given"))?; 121 | 122 | let file_access = config.webdav.file_access.as_ref().map_or_else( 123 | || { 124 | if repo.config().is_hot == Some(true) { 125 | Ok(FilePolicy::Forbidden) 126 | } else { 127 | Ok(FilePolicy::Read) 128 | } 129 | }, 130 | |s| s.parse(), 131 | )?; 132 | 133 | let webdavfs = WebDavFS::new(repo, vfs, file_access); 134 | let dav_server = DavHandler::builder() 135 | .filesystem(Box::new(webdavfs)) 136 | .build_handler(); 137 | 138 | tokio::runtime::Builder::new_current_thread() 139 | .enable_all() 140 | .build()? 141 | .block_on(async { 142 | warp::serve(dav_handler(dav_server)).run(addr).await; 143 | }); 144 | 145 | Ok(()) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /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 anyhow::Result; 16 | use conflate::Merge; 17 | use serde::{Deserialize, Serialize}; 18 | 19 | use rustic_core::CommandInput; 20 | 21 | #[derive(Debug, Default, Clone, Serialize, Deserialize, Merge)] 22 | #[serde(default, rename_all = "kebab-case", deny_unknown_fields)] 23 | pub struct Hooks { 24 | /// Call this command before every rustic operation 25 | #[merge(strategy = conflate::vec::append)] 26 | pub run_before: Vec, 27 | 28 | /// Call this command after every successful rustic operation 29 | #[merge(strategy = conflate::vec::append)] 30 | pub run_after: Vec, 31 | 32 | /// Call this command after every failed rustic operation 33 | #[merge(strategy = conflate::vec::append)] 34 | pub run_failed: Vec, 35 | 36 | /// Call this command after every rustic operation 37 | #[merge(strategy = conflate::vec::append)] 38 | pub run_finally: Vec, 39 | 40 | #[serde(skip)] 41 | #[merge(skip)] 42 | pub context: String, 43 | } 44 | 45 | impl Hooks { 46 | pub fn with_context(&self, context: &str) -> Self { 47 | let mut hooks = self.clone(); 48 | hooks.context = context.to_string(); 49 | hooks 50 | } 51 | 52 | fn run_all(cmds: &[CommandInput], context: &str, what: &str) -> Result<()> { 53 | for cmd in cmds { 54 | cmd.run(context, what)?; 55 | } 56 | 57 | Ok(()) 58 | } 59 | 60 | pub fn run_before(&self) -> Result<()> { 61 | Self::run_all(&self.run_before, &self.context, "run-before") 62 | } 63 | 64 | pub fn run_after(&self) -> Result<()> { 65 | Self::run_all(&self.run_after, &self.context, "run-after") 66 | } 67 | 68 | pub fn run_failed(&self) -> Result<()> { 69 | Self::run_all(&self.run_failed, &self.context, "run-failed") 70 | } 71 | 72 | pub fn run_finally(&self) -> Result<()> { 73 | Self::run_all(&self.run_finally, &self.context, "run-finally") 74 | } 75 | 76 | /// Run the given closure using the specified hooks. 77 | /// 78 | /// Note: after a failure no error handling is done for the hooks `run_failed` 79 | /// and `run_finally` which must run after. However, they already log a warning 80 | /// or error depending on the `on_failure` setting. 81 | pub fn use_with(&self, f: impl FnOnce() -> Result) -> Result { 82 | match self.run_before() { 83 | Ok(()) => match f() { 84 | Ok(result) => match self.run_after() { 85 | Ok(()) => { 86 | self.run_finally()?; 87 | Ok(result) 88 | } 89 | Err(err_after) => { 90 | _ = self.run_finally(); 91 | Err(err_after) 92 | } 93 | }, 94 | Err(err_f) => { 95 | _ = self.run_failed(); 96 | _ = self.run_finally(); 97 | Err(err_f) 98 | } 99 | }, 100 | Err(err_before) => { 101 | _ = self.run_failed(); 102 | _ = self.run_finally(); 103 | Err(err_before) 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | //! Error types 2 | 3 | use abscissa_core::error::{BoxError, Context}; 4 | #[cfg(feature = "rhai")] 5 | use rhai::EvalAltResult; 6 | use std::{ 7 | fmt::{self, Display}, 8 | io, 9 | ops::Deref, 10 | }; 11 | use thiserror::Error; 12 | 13 | /// Kinds of errors 14 | #[derive(Clone, Debug, Eq, Error, PartialEq)] 15 | pub(crate) enum ErrorKind { 16 | /// Input/output error 17 | #[error("I/O error")] 18 | Io, 19 | } 20 | 21 | /// Kinds of [`rhai`] errors 22 | #[cfg(feature = "rhai")] 23 | #[derive(Debug, Error)] 24 | pub(crate) enum RhaiErrorKinds { 25 | #[error(transparent)] 26 | RhaiParse(#[from] rhai::ParseError), 27 | #[error(transparent)] 28 | RhaiEval(#[from] Box), 29 | } 30 | 31 | impl ErrorKind { 32 | /// Create an error context from this error 33 | pub(crate) fn context(self, source: impl Into) -> Context { 34 | Context::new(self, Some(source.into())) 35 | } 36 | } 37 | 38 | /// Error type 39 | #[derive(Debug)] 40 | pub(crate) struct Error(Box>); 41 | 42 | impl Deref for Error { 43 | type Target = Context; 44 | 45 | fn deref(&self) -> &Context { 46 | &self.0 47 | } 48 | } 49 | 50 | impl Display for Error { 51 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 52 | self.0.fmt(f) 53 | } 54 | } 55 | 56 | impl std::error::Error for Error { 57 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 58 | self.0.source() 59 | } 60 | } 61 | 62 | impl From for Error { 63 | fn from(kind: ErrorKind) -> Self { 64 | Context::new(kind, None).into() 65 | } 66 | } 67 | 68 | impl From> for Error { 69 | fn from(context: Context) -> Self { 70 | Self(Box::new(context)) 71 | } 72 | } 73 | 74 | impl From for Error { 75 | fn from(err: io::Error) -> Self { 76 | ErrorKind::Io.context(err).into() 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /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).to_string_as(true) 47 | } 48 | -------------------------------------------------------------------------------- /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 | elided_named_lifetimes, 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 | pub(crate) mod repository; 69 | 70 | // rustic_cli Public API 71 | 72 | /// Abscissa core prelude 73 | pub use abscissa_core::prelude::*; 74 | 75 | /// Application state 76 | pub use crate::application::RUSTIC_APP; 77 | 78 | /// Rustic config 79 | pub use crate::config::RusticConfig; 80 | 81 | /// Completions 82 | pub use crate::commands::completions::generate_completion; 83 | -------------------------------------------------------------------------------- /src/repository.rs: -------------------------------------------------------------------------------- 1 | //! Rustic Config 2 | //! 3 | //! See instructions in `commands.rs` to specify the path to your 4 | //! application's configuration file and/or command-line options 5 | //! for specifying it. 6 | 7 | use std::fmt::Debug; 8 | use std::ops::Deref; 9 | 10 | use abscissa_core::Application; 11 | use anyhow::{Result, anyhow, bail}; 12 | use clap::Parser; 13 | use conflate::Merge; 14 | use dialoguer::Password; 15 | use rustic_backend::BackendOptions; 16 | use rustic_core::{ 17 | FullIndex, IndexedStatus, OpenStatus, ProgressBars, Repository, RepositoryOptions, 18 | }; 19 | use serde::{Deserialize, Serialize}; 20 | 21 | use crate::{ 22 | RUSTIC_APP, 23 | config::{hooks::Hooks, progress_options::ProgressOptions}, 24 | }; 25 | 26 | pub(super) mod constants { 27 | pub(super) const MAX_PASSWORD_RETRIES: usize = 5; 28 | } 29 | 30 | #[derive(Clone, Default, Debug, Parser, Serialize, Deserialize, Merge)] 31 | #[serde(default, rename_all = "kebab-case")] 32 | pub struct AllRepositoryOptions { 33 | /// Backend options 34 | #[clap(flatten)] 35 | #[serde(flatten)] 36 | pub be: BackendOptions, 37 | 38 | /// Repository options 39 | #[clap(flatten)] 40 | #[serde(flatten)] 41 | pub repo: RepositoryOptions, 42 | 43 | /// Hooks 44 | #[clap(skip)] 45 | pub hooks: Hooks, 46 | } 47 | 48 | pub type CliRepo = RusticRepo; 49 | pub type CliOpenRepo = Repository; 50 | pub type RusticIndexedRepo

= Repository>; 51 | pub type CliIndexedRepo = RusticIndexedRepo; 52 | 53 | impl AllRepositoryOptions { 54 | fn repository

(&self, po: P) -> Result> { 55 | let backends = self.be.to_backends()?; 56 | let repo = Repository::new_with_progress(&self.repo, &backends, po)?; 57 | Ok(RusticRepo(repo)) 58 | } 59 | 60 | pub fn run(&self, f: impl FnOnce(CliRepo) -> Result) -> Result { 61 | let hooks = self.hooks.with_context("repository"); 62 | let po = RUSTIC_APP.config().global.progress_options; 63 | hooks.use_with(|| f(self.repository(po)?)) 64 | } 65 | 66 | pub fn run_open(&self, f: impl FnOnce(CliOpenRepo) -> Result) -> Result { 67 | let hooks = self.hooks.with_context("repository"); 68 | let po = RUSTIC_APP.config().global.progress_options; 69 | hooks.use_with(|| f(self.repository(po)?.open()?)) 70 | } 71 | 72 | pub fn run_open_or_init_with( 73 | &self, 74 | do_init: bool, 75 | init: impl FnOnce(CliRepo) -> Result, 76 | f: impl FnOnce(CliOpenRepo) -> Result, 77 | ) -> Result { 78 | let hooks = self.hooks.with_context("repository"); 79 | let po = RUSTIC_APP.config().global.progress_options; 80 | hooks.use_with(|| { 81 | f(self 82 | .repository(po)? 83 | .open_or_init_repository_with(do_init, init)?) 84 | }) 85 | } 86 | 87 | pub fn run_indexed_with_progress( 88 | &self, 89 | po: P, 90 | f: impl FnOnce(RusticIndexedRepo

) -> Result, 91 | ) -> Result { 92 | let hooks = self.hooks.with_context("repository"); 93 | hooks.use_with(|| f(self.repository(po)?.indexed()?)) 94 | } 95 | 96 | pub fn run_indexed(&self, f: impl FnOnce(CliIndexedRepo) -> Result) -> Result { 97 | let po = RUSTIC_APP.config().global.progress_options; 98 | self.run_indexed_with_progress(po, f) 99 | } 100 | } 101 | 102 | #[derive(Debug)] 103 | pub struct RusticRepo

(pub Repository); 104 | 105 | impl

Deref for RusticRepo

{ 106 | type Target = Repository; 107 | fn deref(&self) -> &Self::Target { 108 | &self.0 109 | } 110 | } 111 | 112 | impl RusticRepo

{ 113 | pub fn open(self) -> Result> { 114 | match self.0.password()? { 115 | // if password is given, directly return the result of find_key_in_backend and don't retry 116 | Some(pass) => { 117 | return Ok(self.0.open_with_password(&pass)?); 118 | } 119 | None => { 120 | for _ in 0..constants::MAX_PASSWORD_RETRIES { 121 | let pass = Password::new() 122 | .with_prompt("enter repository password") 123 | .allow_empty_password(true) 124 | .interact()?; 125 | match self.0.clone().open_with_password(&pass) { 126 | Ok(repo) => return Ok(repo), 127 | Err(err) if err.is_incorrect_password() => continue, 128 | Err(err) => return Err(err.into()), 129 | } 130 | } 131 | } 132 | } 133 | Err(anyhow!("incorrect password")) 134 | } 135 | 136 | fn open_or_init_repository_with( 137 | self, 138 | do_init: bool, 139 | init: impl FnOnce(Self) -> Result>, 140 | ) -> Result> { 141 | let dry_run = RUSTIC_APP.config().global.check_index; 142 | // Initialize repository if --init is set and it is not yet initialized 143 | let repo = if do_init && self.0.config_id()?.is_none() { 144 | if dry_run { 145 | bail!( 146 | "cannot initialize repository {} in dry-run mode!", 147 | self.0.name 148 | ); 149 | } 150 | init(self)? 151 | } else { 152 | self.open()? 153 | }; 154 | Ok(repo) 155 | } 156 | 157 | fn indexed(self) -> Result>> { 158 | let open = self.open()?; 159 | let check_index = RUSTIC_APP.config().global.check_index; 160 | let repo = if check_index { 161 | open.to_indexed_checked() 162 | } else { 163 | open.to_indexed() 164 | }?; 165 | Ok(repo) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/snapshots/rustic_rs__config__tests__default_config_display_passes.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config.rs 3 | expression: config 4 | --- 5 | [global] 6 | use-profiles = [] 7 | dry-run = false 8 | check-index = false 9 | no-progress = false 10 | 11 | [global.hooks] 12 | run-before = [] 13 | run-after = [] 14 | run-failed = [] 15 | run-finally = [] 16 | 17 | [global.env] 18 | 19 | [global.prometheus-labels] 20 | 21 | [repository] 22 | no-cache = false 23 | warm-up = false 24 | 25 | [repository.options] 26 | 27 | [repository.options-hot] 28 | 29 | [repository.options-cold] 30 | 31 | [repository.hooks] 32 | run-before = [] 33 | run-after = [] 34 | run-failed = [] 35 | run-finally = [] 36 | 37 | [snapshot-filter] 38 | filter-hosts = [] 39 | filter-labels = [] 40 | filter-paths = [] 41 | filter-paths-exact = [] 42 | filter-tags = [] 43 | filter-tags-exact = [] 44 | 45 | [backup] 46 | stdin-filename = "" 47 | with-atime = false 48 | ignore-devid = false 49 | no-scan = false 50 | json = false 51 | long = false 52 | quiet = false 53 | init = false 54 | skip-identical-parent = false 55 | force = false 56 | ignore-ctime = false 57 | ignore-inode = false 58 | globs = [] 59 | iglobs = [] 60 | glob-files = [] 61 | iglob-files = [] 62 | git-ignore = false 63 | no-require-git = false 64 | custom-ignorefiles = [] 65 | exclude-if-present = [] 66 | one-file-system = false 67 | tags = [] 68 | delete-never = false 69 | snapshots = [] 70 | sources = [] 71 | 72 | [backup.hooks] 73 | run-before = [] 74 | run-after = [] 75 | run-failed = [] 76 | run-finally = [] 77 | 78 | [backup.prometheus-labels] 79 | 80 | [copy] 81 | targets = [] 82 | 83 | [forget] 84 | prune = false 85 | filter-hosts = [] 86 | filter-labels = [] 87 | filter-paths = [] 88 | filter-paths-exact = [] 89 | filter-tags = [] 90 | filter-tags-exact = [] 91 | 92 | [webdav] 93 | symlinks = false 94 | -------------------------------------------------------------------------------- /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 | use-profiles = [] 7 | dry-run = false 8 | check-index = false 9 | no-progress = false 10 | 11 | [global.hooks] 12 | run-before = [] 13 | run-after = [] 14 | run-failed = [] 15 | run-finally = [] 16 | 17 | [global.env] 18 | KEY0 = "VALUE0" 19 | KEY1 = "VALUE1" 20 | KEY2 = "VALUE2" 21 | KEY3 = "VALUE3" 22 | KEY4 = "VALUE4" 23 | KEY5 = "VALUE5" 24 | KEY6 = "VALUE6" 25 | KEY7 = "VALUE7" 26 | KEY8 = "VALUE8" 27 | KEY9 = "VALUE9" 28 | 29 | [global.prometheus-labels] 30 | 31 | [repository] 32 | no-cache = false 33 | warm-up = false 34 | 35 | [repository.options] 36 | 37 | [repository.options-hot] 38 | 39 | [repository.options-cold] 40 | 41 | [repository.hooks] 42 | run-before = [] 43 | run-after = [] 44 | run-failed = [] 45 | run-finally = [] 46 | 47 | [snapshot-filter] 48 | filter-hosts = [] 49 | filter-labels = [] 50 | filter-paths = [] 51 | filter-paths-exact = [] 52 | filter-tags = [] 53 | filter-tags-exact = [] 54 | 55 | [backup] 56 | stdin-filename = "" 57 | with-atime = false 58 | ignore-devid = false 59 | no-scan = false 60 | json = false 61 | long = false 62 | quiet = false 63 | init = false 64 | skip-identical-parent = false 65 | force = false 66 | ignore-ctime = false 67 | ignore-inode = false 68 | globs = [] 69 | iglobs = [] 70 | glob-files = [] 71 | iglob-files = [] 72 | git-ignore = false 73 | no-require-git = false 74 | custom-ignorefiles = [] 75 | exclude-if-present = [] 76 | one-file-system = false 77 | tags = [] 78 | delete-never = false 79 | snapshots = [] 80 | sources = [] 81 | 82 | [backup.hooks] 83 | run-before = [] 84 | run-after = [] 85 | run-failed = [] 86 | run-finally = [] 87 | 88 | [backup.prometheus-labels] 89 | 90 | [copy] 91 | targets = [] 92 | 93 | [forget] 94 | prune = false 95 | filter-hosts = [] 96 | filter-labels = [] 97 | filter-paths = [] 98 | filter-paths-exact = [] 99 | filter-tags = [] 100 | filter-tags-exact = [] 101 | 102 | [webdav] 103 | symlinks = false 104 | -------------------------------------------------------------------------------- /src/snapshots/rustic_rs__config__tests__global_env_roundtrip_passes.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config.rs 3 | expression: serialized 4 | --- 5 | [global] 6 | use-profiles = [] 7 | dry-run = false 8 | check-index = false 9 | no-progress = false 10 | 11 | [global.hooks] 12 | run-before = [] 13 | run-after = [] 14 | run-failed = [] 15 | run-finally = [] 16 | 17 | [global.env] 18 | KEY0 = "VALUE0" 19 | KEY1 = "VALUE1" 20 | KEY2 = "VALUE2" 21 | KEY3 = "VALUE3" 22 | KEY4 = "VALUE4" 23 | KEY5 = "VALUE5" 24 | KEY6 = "VALUE6" 25 | KEY7 = "VALUE7" 26 | KEY8 = "VALUE8" 27 | KEY9 = "VALUE9" 28 | 29 | [global.prometheus-labels] 30 | 31 | [repository] 32 | no-cache = false 33 | warm-up = false 34 | 35 | [repository.options] 36 | 37 | [repository.options-hot] 38 | 39 | [repository.options-cold] 40 | 41 | [repository.hooks] 42 | run-before = [] 43 | run-after = [] 44 | run-failed = [] 45 | run-finally = [] 46 | 47 | [snapshot-filter] 48 | filter-hosts = [] 49 | filter-labels = [] 50 | filter-paths = [] 51 | filter-paths-exact = [] 52 | filter-tags = [] 53 | filter-tags-exact = [] 54 | 55 | [backup] 56 | stdin-filename = "" 57 | with-atime = false 58 | ignore-devid = false 59 | no-scan = false 60 | json = false 61 | long = false 62 | quiet = false 63 | init = false 64 | skip-identical-parent = false 65 | force = false 66 | ignore-ctime = false 67 | ignore-inode = false 68 | globs = [] 69 | iglobs = [] 70 | glob-files = [] 71 | iglob-files = [] 72 | git-ignore = false 73 | no-require-git = false 74 | custom-ignorefiles = [] 75 | exclude-if-present = [] 76 | one-file-system = false 77 | tags = [] 78 | delete-never = false 79 | snapshots = [] 80 | sources = [] 81 | 82 | [backup.hooks] 83 | run-before = [] 84 | run-after = [] 85 | run-failed = [] 86 | run-finally = [] 87 | 88 | [backup.prometheus-labels] 89 | 90 | [copy] 91 | targets = [] 92 | 93 | [forget] 94 | prune = false 95 | filter-hosts = [] 96 | filter-labels = [] 97 | filter-paths = [] 98 | filter-paths-exact = [] 99 | filter-tags = [] 100 | filter-tags-exact = [] 101 | 102 | [webdav] 103 | symlinks = false 104 | -------------------------------------------------------------------------------- /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().into_path(); 53 | 54 | { 55 | // Run `backup` for the first time 56 | rustic_runner(&temp_dir)? 57 | .arg("backup") 58 | .arg(&backup) 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) 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().into_path(); 111 | 112 | { 113 | // Run `backup` for the first time 114 | rustic_runner(&temp_dir)? 115 | .arg("backup") 116 | .arg(&backup_files) 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, &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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /tests/generated/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rustic-rs/rustic/2d4e0990ee8652412b3ff05bfdcf1ff24e465e89/tests/generated/.gitkeep -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 removed")); 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 removed")); 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 | -------------------------------------------------------------------------------- /tests/repository-fixtures/COPY.tpl: -------------------------------------------------------------------------------- 1 | [repository] 2 | repository = "${{REPOSITORY}}" 3 | password = "${{PASSWORD}}" 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/repository-fixtures/restic-repo.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rustic-rs/rustic/2d4e0990ee8652412b3ff05bfdcf1ff24e465e89/tests/repository-fixtures/restic-repo.tar.gz -------------------------------------------------------------------------------- /tests/repository-fixtures/rustic-copy-repo.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rustic-rs/rustic/2d4e0990ee8652412b3ff05bfdcf1ff24e465e89/tests/repository-fixtures/rustic-copy-repo.tar.gz -------------------------------------------------------------------------------- /tests/repository-fixtures/rustic-repo.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rustic-rs/rustic/2d4e0990ee8652412b3ff05bfdcf1ff24e465e89/tests/repository-fixtures/rustic-repo.tar.gz -------------------------------------------------------------------------------- /tests/repository-fixtures/src-snapshot.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rustic-rs/rustic/2d4e0990ee8652412b3ff05bfdcf1ff24e465e89/tests/repository-fixtures/src-snapshot.tar.gz -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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__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__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__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__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__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__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__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 | -------------------------------------------------------------------------------- /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__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__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__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__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__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__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 | -------------------------------------------------------------------------------- /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__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__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 | -------------------------------------------------------------------------------- /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/show_config__show_config_passes.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/show-config.rs 3 | expression: output 4 | --- 5 | [global] 6 | use-profiles = [] 7 | dry-run = false 8 | check-index = false 9 | no-progress = false 10 | 11 | [global.hooks] 12 | run-before = [] 13 | run-after = [] 14 | run-failed = [] 15 | run-finally = [] 16 | 17 | [global.env] 18 | 19 | [global.prometheus-labels] 20 | 21 | [repository] 22 | no-cache = false 23 | warm-up = false 24 | 25 | [repository.options] 26 | 27 | [repository.options-hot] 28 | 29 | [repository.options-cold] 30 | 31 | [repository.hooks] 32 | run-before = [] 33 | run-after = [] 34 | run-failed = [] 35 | run-finally = [] 36 | 37 | [snapshot-filter] 38 | filter-hosts = [] 39 | filter-labels = [] 40 | filter-paths = [] 41 | filter-paths-exact = [] 42 | filter-tags = [] 43 | filter-tags-exact = [] 44 | 45 | [backup] 46 | stdin-filename = "" 47 | with-atime = false 48 | ignore-devid = false 49 | no-scan = false 50 | json = false 51 | long = false 52 | quiet = false 53 | init = false 54 | skip-identical-parent = false 55 | force = false 56 | ignore-ctime = false 57 | ignore-inode = false 58 | globs = [] 59 | iglobs = [] 60 | glob-files = [] 61 | iglob-files = [] 62 | git-ignore = false 63 | no-require-git = false 64 | custom-ignorefiles = [] 65 | exclude-if-present = [] 66 | one-file-system = false 67 | tags = [] 68 | delete-never = false 69 | snapshots = [] 70 | sources = [] 71 | 72 | [backup.hooks] 73 | run-before = [] 74 | run-after = [] 75 | run-failed = [] 76 | run-finally = [] 77 | 78 | [backup.prometheus-labels] 79 | 80 | [copy] 81 | targets = [] 82 | 83 | [forget] 84 | prune = false 85 | filter-hosts = [] 86 | filter-labels = [] 87 | filter-paths = [] 88 | filter-paths-exact = [] 89 | filter-tags = [] 90 | filter-tags-exact = [] 91 | 92 | [webdav] 93 | symlinks = false 94 | -------------------------------------------------------------------------------- /tests/snapshots/show_config__show_config_passes.snap.new: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/show-config.rs 3 | assertion_line: 35 4 | expression: output 5 | snapshot_kind: text 6 | --- 7 | [global] 8 | use-profiles = [] 9 | dry-run = false 10 | check-index = false 11 | no-progress = false 12 | 13 | [global.hooks] 14 | run-before = [] 15 | run-after = [] 16 | run-failed = [] 17 | run-finally = [] 18 | 19 | [global.env] 20 | 21 | [global.prometheus-labels] 22 | 23 | [repository] 24 | repository = "local:/tmp/repo" 25 | password = "testx" 26 | no-cache = false 27 | warm-up = false 28 | 29 | [repository.options] 30 | 31 | [repository.options-hot] 32 | 33 | [repository.options-cold] 34 | 35 | [repository.hooks] 36 | run-before = [ 37 | "echo before", 38 | "echo before1", 39 | ] 40 | run-after = [] 41 | run-failed = [] 42 | run-finally = [] 43 | 44 | [snapshot-filter] 45 | filter-hosts = [] 46 | filter-labels = [] 47 | filter-paths = [] 48 | filter-paths-exact = [] 49 | filter-tags = [] 50 | filter-tags-exact = [] 51 | 52 | [backup] 53 | stdin-filename = "" 54 | with-atime = false 55 | ignore-devid = false 56 | no-scan = false 57 | json = false 58 | long = false 59 | quiet = false 60 | init = false 61 | skip-identical-parent = false 62 | force = false 63 | ignore-ctime = false 64 | ignore-inode = false 65 | globs = [] 66 | iglobs = [] 67 | glob-files = [] 68 | iglob-files = [] 69 | git-ignore = false 70 | no-require-git = false 71 | custom-ignorefiles = [] 72 | exclude-if-present = [] 73 | one-file-system = false 74 | tags = [] 75 | delete-never = false 76 | snapshots = [] 77 | sources = [] 78 | 79 | [backup.hooks] 80 | run-before = [] 81 | run-after = [] 82 | run-failed = [] 83 | run-finally = [] 84 | 85 | [backup.prometheus-labels] 86 | 87 | [copy] 88 | targets = [] 89 | 90 | [forget] 91 | prune = false 92 | filter-hosts = [] 93 | filter-labels = [] 94 | filter-paths = [] 95 | filter-paths-exact = [] 96 | filter-tags = [] 97 | filter-tags-exact = [] 98 | 99 | [webdav] 100 | symlinks = false 101 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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@.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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------