Merge branch 'main' into simonsan-patch-1

This commit is contained in:
simonsan 2024-11-12 03:36:18 +01:00 committed by GitHub
commit f7509adee7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
150 changed files with 15692 additions and 5513 deletions

10
.cargo/audit.toml Normal file
View File

@ -0,0 +1,10 @@
[advisories]
ignore = [
# FIXME!: See https://github.com/RustCrypto/RSA/issues/19#issuecomment-1822995643.
# There is no workaround available yet.
"RUSTSEC-2023-0071",
# FIXME!: proc-macro-error's maintainer seems to be unreachable, we use `merge` which is using this.
# `merge` is self-hosted on another platform, we should check if and how to replace it/open upstream
# issue for updating the dependency.
"RUSTSEC-2024-0370",
]

50
.github/renovate.json vendored
View File

@ -1,52 +1,4 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":dependencyDashboard",
"helpers:pinGitHubActionDigests"
],
"separateMinorPatch": false,
"prHourlyLimit": 1,
"prConcurrentLimit": 1,
"major": {
"dependencyDashboardApproval": true
},
"labels": [
"A-dependencies"
],
"packageRules": [
{
"description": "Automerge pin updates for GitHub Actions",
"matchDatasources": [
"github-actions"
],
"matchDepTypes": [
"action"
],
"matchUpdateTypes": [
"pin",
"digest",
"pinDigest"
],
"labels": [
"A-ci"
],
"automerge": true
},
{
"matchDatasources": [
"cargo"
],
"extends": [
"schedule:weekly"
]
}
],
"lockFileMaintenance": {
"enabled": true,
"automerge": true,
"extends": [
"schedule:monthly"
]
}
"extends": ["local>rustic-rs/.github:renovate-config"]
}

View File

@ -14,31 +14,36 @@ on:
merge_group:
types: [checks_requested]
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
audit:
if: ${{ github.repository_owner == 'rustic-rs' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
# Ensure that the latest version of Cargo is installed
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@a95ba195448af2da9b00fb742d14ffaaf3c21f43 # v2
- uses: rustsec/audit-check@dd51754d4e59da7395a4cd9b593f0ff2d61a9b95 # v1.4.1
- uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2
- uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
ignore: RUSTSEC-2023-0071 # rsa thingy, ignored for now
cargo-deny:
name: Run cargo-deny
if: ${{ github.repository_owner == 'rustic-rs' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: EmbarkStudios/cargo-deny-action@1e59595bed8fc55c969333d08d7817b36888f0c5 # v1
- uses: EmbarkStudios/cargo-deny-action@8371184bd11e21dcf8ac82ebf8c9c9f74ebf7268 # v2
with:
command: check bans licenses sources

View File

@ -15,12 +15,16 @@ on:
merge_group:
types: [checks_requested]
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
fmt:
name: Rustfmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1
with:
@ -34,17 +38,17 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
feature: [default]
feature: [release]
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1
with:
toolchain: stable
components: clippy
- uses: Swatinem/rust-cache@a95ba195448af2da9b00fb742d14ffaaf3c21f43 # v2
- uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2
- name: Run clippy
run: cargo clippy --all-targets --features ${{ matrix.feature }} -- -D warnings
run: cargo clippy --locked --all-targets --features ${{ matrix.feature }} -- -D warnings
test:
name: Test
@ -52,18 +56,18 @@ jobs:
strategy:
matrix:
rust: [stable]
feature: [default]
feature: [release]
job:
- os: macos-latest
- os: ubuntu-latest
- os: windows-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
if: github.event_name != 'pull_request'
with:
fetch-depth: 0
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
if: github.event_name == 'pull_request'
with:
ref: ${{ github.event.pull_request.head.sha }}
@ -73,7 +77,7 @@ jobs:
uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@a95ba195448af2da9b00fb742d14ffaaf3c21f43 # v2
- uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2
- name: Run Cargo Test
run: cargo test -r --all-targets --features ${{ matrix.feature }} --workspace
@ -88,12 +92,12 @@ jobs:
- os: ubuntu-latest
- os: windows-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
if: github.event_name != 'pull_request'
with:
fetch-depth: 0
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
if: github.event_name == 'pull_request'
with:
ref: ${{ github.event.pull_request.head.sha }}
@ -103,7 +107,7 @@ jobs:
uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@a95ba195448af2da9b00fb742d14ffaaf3c21f43 # v2
- uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2
- name: Run Cargo Doc
run: cargo doc --no-deps --all-features --workspace --examples

View File

@ -7,8 +7,6 @@ on:
push:
branches:
- main
- "renovate/**"
- "release/**"
paths-ignore:
- "**/*.md"
merge_group:
@ -18,6 +16,10 @@ defaults:
run:
shell: bash
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
cross-check:
name: Cross checking ${{ matrix.job.target }}
@ -26,7 +28,7 @@ jobs:
fail-fast: false
matrix:
rust: [stable]
feature: [default]
feature: [release]
job:
- os: windows-latest
os-name: windows
@ -38,7 +40,7 @@ jobs:
target: x86_64-pc-windows-gnu
architecture: x86_64
use-cross: false
- os: macos-latest
- os: macos-13
os-name: macos
target: x86_64-apple-darwin
architecture: x86_64
@ -63,6 +65,11 @@ jobs:
target: aarch64-unknown-linux-gnu
architecture: arm64
use-cross: true
- os: ubuntu-latest
os-name: linux
target: aarch64-unknown-linux-musl
architecture: arm64
use-cross: true
- os: ubuntu-latest
os-name: linux
target: i686-unknown-linux-gnu
@ -81,7 +88,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Run Cross-CI action
uses: rustic-rs/cross-ci-action@main

View File

@ -7,11 +7,15 @@ on:
merge_group:
types: [checks_requested]
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
style:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: dprint/check@2f1cf31537886c3bfb05591c031f7744e48ba8a1 # v2.2

View File

@ -37,7 +37,7 @@ jobs:
architecture: x86_64
binary-postfix: ".exe"
use-cross: false
- os: macos-latest
- os: macos-13
os-name: macos
target: x86_64-apple-darwin
architecture: x86_64
@ -67,6 +67,12 @@ jobs:
architecture: arm64
binary-postfix: ""
use-cross: true
- os: ubuntu-latest
os-name: linux
target: aarch64-unknown-linux-musl
architecture: arm64
binary-postfix: ""
use-cross: true
- os: ubuntu-latest
os-name: linux
target: i686-unknown-linux-gnu
@ -89,7 +95,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 0 # fetch all history so that git describe works
- name: Create binary artifact
@ -120,7 +126,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Download all workflow run artifacts
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
- name: Releasing nightly builds
shell: bash
run: |
@ -148,7 +154,7 @@ jobs:
mkdir -p $WORKING_DIR/$DEST_DIR
# do the copy
for i in binary-*; do cp -a $i/* $WORKING_DIR/$DEST_DIR; done
for artifact_dir in binary-*; do cp -a $artifact_dir/* $WORKING_DIR/$DEST_DIR; done
# create the commit
cd $WORKING_DIR

View File

@ -10,6 +10,10 @@ on:
- "docs/**/*"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
env:
BINARY_NAME: rustic
@ -79,7 +83,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 0 # fetch all history so that git describe works
- name: Create binary artifact

165
.github/workflows/release-cd.yml vendored Normal file
View File

@ -0,0 +1,165 @@
name: Continuous Deployment (Release)
on:
push:
tags:
# Run on stable releases
- "v*.*.*"
# Run on release candidates
- "v*.*.*-rc*"
defaults:
run:
shell: bash
permissions:
contents: write
discussions: write
env:
BINARY_NAME: rustic
BINARY_NIGHTLY_DIR: rustic
jobs:
publish:
if: ${{ github.repository_owner == 'rustic-rs' }}
name: Publishing ${{ matrix.job.target }}
runs-on: ${{ matrix.job.os }}
strategy:
fail-fast: false # so we upload the artifacts even if one of the jobs fails
matrix:
rust: [stable]
job:
- os: windows-latest
os-name: windows
target: x86_64-pc-windows-msvc
architecture: x86_64
binary-postfix: ".exe"
use-cross: false
- os: windows-latest
os-name: windows
target: x86_64-pc-windows-gnu
architecture: x86_64
binary-postfix: ".exe"
use-cross: false
- os: macos-13
os-name: macos
target: x86_64-apple-darwin
architecture: x86_64
binary-postfix: ""
use-cross: false
- os: macos-latest
os-name: macos
target: aarch64-apple-darwin
architecture: arm64
binary-postfix: ""
use-cross: true
- os: ubuntu-latest
os-name: linux
target: x86_64-unknown-linux-gnu
architecture: x86_64
binary-postfix: ""
use-cross: false
- os: ubuntu-latest
os-name: linux
target: x86_64-unknown-linux-musl
architecture: x86_64
binary-postfix: ""
use-cross: false
- os: ubuntu-latest
os-name: linux
target: aarch64-unknown-linux-gnu
architecture: arm64
binary-postfix: ""
use-cross: true
- os: ubuntu-latest
os-name: linux
target: aarch64-unknown-linux-musl
architecture: arm64
binary-postfix: ""
use-cross: true
- os: ubuntu-latest
os-name: linux
target: i686-unknown-linux-gnu
architecture: i686
binary-postfix: ""
use-cross: true
# TODO!: This needs a fix, linking `execinfo` fails
# - os: ubuntu-latest
# os-name: netbsd
# target: x86_64-unknown-netbsd
# architecture: x86_64
# binary-postfix: ""
# use-cross: true
- os: ubuntu-latest
os-name: linux
target: armv7-unknown-linux-gnueabihf
architecture: armv7
binary-postfix: ""
use-cross: true
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 0 # fetch all history so that git describe works
- name: Create binary artifact
uses: rustic-rs/create-binary-artifact-action@main # dev
with:
toolchain: ${{ matrix.rust }}
target: ${{ matrix.job.target }}
use-cross: ${{ matrix.job.use-cross }}
binary-postfix: ${{ matrix.job.binary-postfix }}
os: ${{ runner.os }}
binary-name: ${{ env.BINARY_NAME }}
package-secondary-name: ${{ matrix.job.target}}
github-token: ${{ secrets.GITHUB_TOKEN }}
gpg-release-private-key: ${{ secrets.GPG_RELEASE_PRIVATE_KEY }}
gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }}
rsign-release-private-key: ${{ secrets.RSIGN_RELEASE_PRIVATE_KEY }}
rsign-passphrase: ${{ secrets.RSIGN_PASSPHRASE }}
github-ref: ${{ github.ref }}
sign-release: true
hash-release: true
use-project-version: true
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
create-release:
name: Creating release with artifacts
needs: publish
runs-on: ubuntu-latest
steps:
# Need to clone the repo again for the CHANGELOG.md
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 0 # fetch all history so that git describe works
- name: Download all workflow run artifacts
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4
- name: Creating Release
uses: softprops/action-gh-release@c062e08bd532815e2082a85e87e3ef29c3e6d191 # v2
with:
discussion_category_name: "Announcements"
draft: true
body_path: ${{ github.workspace }}/CHANGELOG.md
fail_on_unmatched_files: true
files: |
binary-*/${{ env.BINARY_NAME }}-*.tar.gz*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
result:
if: ${{ github.repository_owner == 'rustic-rs' }}
name: Result (Release CD)
runs-on: ubuntu-latest
needs:
- publish
- create-release
steps:
- name: Mark the job as successful
run: exit 0
if: success()
- name: Mark the job as unsuccessful
run: exit 1
if: "!success()"

View File

@ -15,6 +15,10 @@ on:
# - rustic-rs
# - rustic_testing
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
# determine-package:
# name: Determine package to release
@ -39,12 +43,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@a95ba195448af2da9b00fb742d14ffaaf3c21f43 # v2
- uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2
- name: Run Cargo Test
run: cargo test --release -p rustic-rs --test completions -- --ignored
#

25
.github/workflows/release-image.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: Release Docker Image
on: [release]
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3
- name: Login to Docker Hub
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6
with:
push: true
platforms: linux/amd64,linux/arm64
tags: ghcr.io/rustic-rs/rustic:latest,ghcr.io/rustic-rs/rustic:${{ github.ref_name }}
build-args: RUSTIC_VERSION=${{ github.ref_name }}

36
.github/workflows/release-plz.yml vendored Normal file
View File

@ -0,0 +1,36 @@
name: Release-plz
permissions:
pull-requests: write
contents: write
on:
push:
branches:
- main
jobs:
release-plz:
name: Release-plz
if: ${{ github.repository_owner == 'rustic-rs' }}
runs-on: ubuntu-latest
steps:
- name: Generate GitHub token
uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1
id: generate-token
with:
app-id: ${{ secrets.RELEASE_PLZ_APP_ID }}
private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }}
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 0
token: ${{ steps.generate-token.outputs.token }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Run release-plz
uses: MarcoIeni/release-plz-action@394e0e463367550953346be95d427f80f4f7ae30 # v0.5
env:
GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

View File

@ -1,37 +0,0 @@
name: Open a release PR
on:
workflow_dispatch:
inputs:
crate:
description: Crate to release
required: true
type: choice
options:
- rustic-rs
version:
description: Version to release
required: true
type: string
jobs:
make-release-pr:
if: ${{ github.repository_owner == 'rustic-rs' }}
permissions:
id-token: write # Enable OIDC
pull-requests: write
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- uses: chainguard-dev/actions/setup-gitsign@main
- uses: Swatinem/rust-cache@a95ba195448af2da9b00fb742d14ffaaf3c21f43 # v2
- name: Install cargo-release
uses: taiki-e/install-action@f7c663c03b51ed0d93e9cec22a575d3f02175989 # v2
with:
tool: cargo-release
- uses: cargo-bins/release-pr@deeacca5a38bacc74a3f444b798f4b9bba40f6ad # v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
version: ${{ inputs.version }}
crate-name: ${{ inputs.crate }}
check-semver: false # FIXME: Set back to true and check rustic-rs library API

View File

@ -1,122 +0,0 @@
on:
push:
name: Build release binaries
jobs:
publish:
if: startsWith(github.ref, 'refs/tags/')
name: Publishing ${{ matrix.job.target }}
runs-on: ${{ matrix.job.os }}
strategy:
matrix:
rust: [stable]
job:
- os: windows-latest
os-name: windows
target: x86_64-pc-windows-msvc
architecture: x86_64
binary-postfix: ""
use-cross: false
- os: macos-latest
os-name: macos
target: x86_64-apple-darwin
architecture: x86_64
binary-postfix: ""
use-cross: false
- os: macos-latest
os-name: macos
target: aarch64-apple-darwin
architecture: arm64
binary-postfix: ""
use-cross: true
- os: ubuntu-latest
os-name: linux
target: x86_64-unknown-linux-gnu
architecture: x86_64
binary-postfix: ""
use-cross: false
- os: ubuntu-latest
os-name: linux
target: x86_64-unknown-linux-musl
architecture: x86_64
binary-postfix: ""
use-cross: false
- os: ubuntu-latest
os-name: linux
target: aarch64-unknown-linux-gnu
architecture: arm64
binary-postfix: ""
use-cross: true
- os: ubuntu-latest
os-name: linux
target: i686-unknown-linux-gnu
architecture: i686
binary-postfix: ""
use-cross: true
- os: ubuntu-latest
os-name: netbsd
target: x86_64-unknown-netbsd
architecture: x86_64
binary-postfix: ""
use-cross: true
steps:
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
with:
fetch-depth: 0
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1
with:
toolchain: ${{ matrix.rust }}
targets: ${{ matrix.job.target }}
- name: install compiler
shell: bash
run: |
if [[ ${{ matrix.job.target }} == x86_64-unknown-linux-musl ]]; then
sudo apt update
sudo apt-get install -y musl-tools
fi
- uses: Swatinem/rust-cache@a95ba195448af2da9b00fb742d14ffaaf3c21f43 # v2
with:
key: ${{ matrix.job.target }}
- name: Set Version
shell: bash
run: echo "PROJECT_VERSION=$(git describe --tags)" >> $GITHUB_ENV
- name: Cargo build
uses: ClementTsang/cargo-action@a211c79cf22973eb590277586fbea20269ca3ca0 # v0 (attention: this should be double checked for security issues)
with:
command: build
use-cross: ${{ matrix.job.use-cross }}
toolchain: ${{ matrix.rust }}
args: --release --target ${{ matrix.job.target }}
- name: Packaging final binary
if: ${{ !contains(github.ref_name, '/') }}
shell: bash
run: |
cd target/${{ matrix.job.target }}/release
########## create tar.gz ##########
RELEASE_NAME=rustic-${{ github.ref_name }}-${{ matrix.job.target}}
tar czvf $RELEASE_NAME.tar.gz rustic${{ matrix.job.binary-postfix }}
########## create sha256 ##########
if [[ ${{ runner.os }} == 'Windows' ]]; then
certutil -hashfile $RELEASE_NAME.tar.gz sha256 | grep -E [A-Fa-f0-9]{64} > $RELEASE_NAME.sha256
else
shasum -a 256 $RELEASE_NAME.tar.gz > $RELEASE_NAME.sha256
fi
- name: Storing binary as artefact
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3
with:
name: binary-${{ matrix.job.target}}
path: target/${{ matrix.job.target }}/release/rustic-${{ github.ref_name }}-${{ matrix.job.target}}.tar.gz
- name: Releasing release versions
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1
with:
files: |
target/${{ matrix.job.target }}/release/rustic-*.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -13,4 +13,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_URL: ${{ github.event.issue.html_url }}
run: |
gh issue edit $ISSUE_URL --add-label "S-triage"
# check if issue doesn't have any labels
if [[ $(gh issue view $ISSUE_URL --json labels -q '.labels | length') -eq 0 ]]; then
# add S-triage label
gh issue edit $ISSUE_URL --add-label "S-triage"
fi

View File

@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
ref: refs/heads/${{ inputs.pr_branch }}
@ -28,7 +28,7 @@ jobs:
with:
toolchain: stable
- uses: Swatinem/rust-cache@a95ba195448af2da9b00fb742d14ffaaf3c21f43 # v2
- uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2
- name: Run Cargo Install
id: generated-fixtures

5
.gitignore vendored
View File

@ -5,3 +5,8 @@ mutants.out
cargo-test*
coverage/*lcov
.testscompletions-*
# Ignore generated test files
/tests/generated/*.toml
/tests/generated/*.log
/tests/generated/test-restore

View File

@ -2,6 +2,382 @@
All notable changes to this project will be documented in this file.
## [0.9.4](https://github.com/rustic-rs/rustic/compare/v0.9.3...v0.9.4) - 2024-10-24
### Added
- *(commands)* Add tar output to dump command ([#1328](https://github.com/rustic-rs/rustic/pull/1328))
### Fixed
- clippy lints for new Rust version ([#1329](https://github.com/rustic-rs/rustic/pull/1329))
- *(deps)* downgrade self-update to fix problems finding right target ([#1323](https://github.com/rustic-rs/rustic/pull/1323))
### Other
- *(deps)* remove once_cell and replace with std::sync::LazyLock, increase MSRV to 1.80.0 ([#1337](https://github.com/rustic-rs/rustic/pull/1337))
- *(deps)* update tokio, ratatui, and tui-textarea ([#1336](https://github.com/rustic-rs/rustic/pull/1336))
- *(deps)* update rustic_core and rustic_backend ([#1334](https://github.com/rustic-rs/rustic/pull/1334))
- *(deps)* update abscissa framework ([#1330](https://github.com/rustic-rs/rustic/pull/1330))
- introduce a new feature 'release' that includes the 'self-update' feature ([#1307](https://github.com/rustic-rs/rustic/pull/1307))
## [0.9.3](https://github.com/rustic-rs/rustic/compare/v0.9.2...v0.9.3) - 2024-10-10
### Fixed
- *(deps)* update rustic_core to version 0.5.3 ([#1314](https://github.com/rustic-rs/rustic/pull/1314))
### Other
- add status badge for docker image build and shorten workflow name ([#1311](https://github.com/rustic-rs/rustic/pull/1311))
## [0.9.2](https://github.com/rustic-rs/rustic/compare/v0.9.1...v0.9.2) - 2024-10-09
### Added
- *(config)* Add hooks ([#1218](https://github.com/rustic-rs/rustic/pull/1218))
### Other
- *(deps)* update rustic_core ([#1309](https://github.com/rustic-rs/rustic/pull/1309))
- build and publish docker image on release ([#1297](https://github.com/rustic-rs/rustic/pull/1297))
## [0.9.1](https://github.com/rustic-rs/rustic/compare/v0.9.0...v0.9.1) - 2024-10-03
### Added
- *(config)* add more filters ([#1263](https://github.com/rustic-rs/rustic/pull/1263))
- *(check)* Allow to only check trees+packs for given snapshots ([#1230](https://github.com/rustic-rs/rustic/pull/1230))
- *(commands)* add a `docs` command to easily access the user, dev and config documentation ([#1276](https://github.com/rustic-rs/rustic/pull/1276))
### Fixed
- *(docs/cli)* improve the descriptions of the CLI commands ([#1277](https://github.com/rustic-rs/rustic/pull/1277))
- *(deps)* update rustic_core and other dependencies and fix merge precedence ([#1282](https://github.com/rustic-rs/rustic/pull/1282))
- *(docs)* update configuration documentation to align with recent changes ([#1280](https://github.com/rustic-rs/rustic/pull/1280))
### Other
- *(deps)* upgrade dependencies ([#1289](https://github.com/rustic-rs/rustic/pull/1289))
- add triage label to new issues only if no label has been set when creating it ([#1287](https://github.com/rustic-rs/rustic/pull/1287))
- *(interactive)* use update methods for refreshing snapshots ([#1285](https://github.com/rustic-rs/rustic/pull/1285))
## [0.9.0](https://github.com/rustic-rs/rustic/compare/v0.8.1...v0.9.0) - 2024-09-29
### Bug Fixes
- [**breaking**] use multiple options only as array in config profile
([#1240](https://github.com/rustic-rs/rustic/pull/1240))
- Allow snapshots to be modified and marked to forget
([#1253](https://github.com/rustic-rs/rustic/pull/1253))
- make ls and find show the year of mtime date
([#1249](https://github.com/rustic-rs/rustic/pull/1249))
- ls: Remove printing trailing space
([#1247](https://github.com/rustic-rs/rustic/pull/1247))
- webdav/forget: correctly use application config
([#1241](https://github.com/rustic-rs/rustic/pull/1241))
### Features
- [**breaking**] copy: Use config profile as target
([#1131](https://github.com/rustic-rs/rustic/pull/1131))
- backup: Add option `stdin-command`
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.0))
- Add list indexpacks and list indexcontent commands
([#1254](https://github.com/rustic-rs/rustic/pull/1254))
- Add option `--only-identical` for `diff` to allow for bitrot check
([#1250](https://github.com/rustic-rs/rustic/pull/1250))
- ls: Add option --json ([#1251](https://github.com/rustic-rs/rustic/pull/1251))
- backup: Add option `--long`
([#1159](https://github.com/rustic-rs/rustic/pull/1159))
### Documentation
- update installation instructions in readme to use `--locked` flag for install
from crates.io
- update RepositoryErrorKind rustdoc following rustic_core change
([#1237](https://github.com/rustic-rs/rustic/pull/1237))
### Other
- Remove self-update from default crate features
([#1139](https://github.com/rustic-rs/rustic/pull/1139))
- Reduce memory usage of restore
([#1069](https://github.com/rustic-rs/rustic/pull/1069))
- *(deps)* update rust crate libc to v0.2.159
([#1257](https://github.com/rustic-rs/rustic/pull/1257))
- *(deps)* lock file maintenance
([#1269](https://github.com/rustic-rs/rustic/pull/1269))
- *(deps)* update rust crate rstest to 0.23
([#1267](https://github.com/rustic-rs/rustic/pull/1267))
- *(deps)* update rust crate tempfile to v3.13.0
([#1266](https://github.com/rustic-rs/rustic/pull/1266))
- *(deps)* update marcoieni/release-plz-action digest to 8b0f89a
([#1265](https://github.com/rustic-rs/rustic/pull/1265))
- *(deps)* update embarkstudios/cargo-deny-action action to v2
([#1259](https://github.com/rustic-rs/rustic/pull/1259))
- *(deps)* update rustsec/audit-check action to v2
([#1260](https://github.com/rustic-rs/rustic/pull/1260))
- *(deps)* update softprops/action-gh-release action to v2
([#1258](https://github.com/rustic-rs/rustic/pull/1258))
- *(deps)* update embarkstudios/cargo-deny-action digest to 3f4a782
([#1228](https://github.com/rustic-rs/rustic/pull/1228))
## [0.8.1] - 2024-09-08
### Bug Fixes
- Allow to compile without tui feature
([#1208](https://github.com/rustic-rs/rustic/issues/1208))
- Use cargo --locked in CI pipeline
([#1207](https://github.com/rustic-rs/rustic/issues/1207))
- Return exitcode ([#1220](https://github.com/rustic-rs/rustic/issues/1220))
- "Incorrect Password" error is now only shown if password is really incorrect.
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.1))
- Group by now works as expected
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.1))
- A bug in `keep-tags` and `filter-tags` has been fixed.
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.1))
- Building OpenBSD platform target is now possible again
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.1))
### Documentation
- Update config profile readme
([#1221](https://github.com/rustic-rs/rustic/issues/1221))
### Features
- Add autocompletion hints
([#1225](https://github.com/rustic-rs/rustic/issues/1225))
- Allow to modify filters
([#1210](https://github.com/rustic-rs/rustic/issues/1210))
- Allow to view text files
([#1216](https://github.com/rustic-rs/rustic/issues/1216))
### Generated
- Updated Completions fixtures
### Miscellaneous Tasks
- Bump quinn-proto from 0.11.6 to 0.11.8
([#1223](https://github.com/rustic-rs/rustic/issues/1223))
- Dependency updates ([#1227](https://github.com/rustic-rs/rustic/issues/1227))
## [0.8.0] - 2024-08-21
### Bug Fixes
- Add comments for owncloud and nextcloud dependent settings
- Rename service examples
- Ask for password in backup and copy command if it is missing
([#1061](https://github.com/rustic-rs/rustic/issues/1061))
- Ask for missing password in copy when initializing
([#1063](https://github.com/rustic-rs/rustic/issues/1063))
- Fix possible overflow in progress bar ETA
([#1079](https://github.com/rustic-rs/rustic/issues/1079))
- Correct b2.toml ([#1072](https://github.com/rustic-rs/rustic/issues/1072))
- Show log filename if open/creation failed
([#1111](https://github.com/rustic-rs/rustic/issues/1111))
- [**breaking**] Multiple paths in config profile as array
([#1124](https://github.com/rustic-rs/rustic/issues/1124))
- Respect delete-protection when running forget with ids
([#1149](https://github.com/rustic-rs/rustic/issues/1149))
- Reset terminal no matter what
([#1175](https://github.com/rustic-rs/rustic/issues/1175))
- Allow missing fields in snapshot summary (to support restic 0.17.0)
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.0))
- Allow non-value/null xattr fields
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.0))
- Backup file if listing xattrs fails
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.0))
- limit memory usage for restore when having large pack files
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.0))
- prune: correct number of packs to repack
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.0))
### Features
- [**breaking**] Show-config now outputs toml
([#1095](https://github.com/rustic-rs/rustic/issues/1095))
- [**breaking**] Allow specifying many options in config profile without array
([#1130](https://github.com/rustic-rs/rustic/issues/1130))
- Add interactive snapshots mode
([#1114](https://github.com/rustic-rs/rustic/issues/1114))
- The find command has been added
([#1136](https://github.com/rustic-rs/rustic/issues/1136))
- Allow setting extra repository options via env variables
([#1081](https://github.com/rustic-rs/rustic/issues/1081))
- Add --check-index option
([#1078](https://github.com/rustic-rs/rustic/issues/1078))
- Add extra check before writing data and add --set-extra-check config option
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.0))
- Add append-only repository mode
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.0))
- forget: Enforce to have a --keep-* option and add --keep-none.
([rustic_core](https://github.com/rustic-rs/rustic_core/releases/tag/rustic_core-v0.3.0))
- Add s3_idrive config and move configs to a services subdirectory
([#1048](https://github.com/rustic-rs/rustic/issues/1048))
- Add example config for owncloud and nextcloud
([#1052](https://github.com/rustic-rs/rustic/issues/1052))
- Use human-panic to print better error messages in case rustic panics
([#1065](https://github.com/rustic-rs/rustic/issues/1065))
- Prune: Add more debug output
([#1064](https://github.com/rustic-rs/rustic/issues/1064))
- Add interactive ls mode
([#1117](https://github.com/rustic-rs/rustic/issues/1117))
- Add interactive restore
([#1123](https://github.com/rustic-rs/rustic/issues/1123))
- Interactive Ls: remember parent position
([#1126](https://github.com/rustic-rs/rustic/issues/1126))
- Use RFC3339 time format in logfile
([#1133](https://github.com/rustic-rs/rustic/issues/1133))
- Add possibility to change snapshot description
([#1137](https://github.com/rustic-rs/rustic/issues/1137))
- Interactive: Allow to delete snapshots
([#1143](https://github.com/rustic-rs/rustic/issues/1143))
- Interactive: Prompt before exiting
([#1146](https://github.com/rustic-rs/rustic/issues/1146))
- Document opendal options connections and throttle
- Add better progress bars
([#1152](https://github.com/rustic-rs/rustic/issues/1152))
- Show diff statistics
([#1178](https://github.com/rustic-rs/rustic/issues/1178))
### Documentaton
- Update configuration README
([#1088](https://github.com/rustic-rs/rustic/issues/1088))
- Fix typo in find.rs ([#1187](https://github.com/rustic-rs/rustic/issues/1187))
### Miscellaneous Tasks
- Fix cargo-binstall metadata
- Move rustic_testing into rustic_core
- Break old ci jobs when new commits are pushed so we don't fill up the queue
- Bump mio from 0.8.10 to 0.8.11
([#1089](https://github.com/rustic-rs/rustic/issues/1089))
- Update deps and adapt to rustic_core changes
- Bump h2 from 0.3.25 to 0.3.26
([#1113](https://github.com/rustic-rs/rustic/issues/1113))
- Bump rustls from 0.21.10 to 0.21.11
([#1127](https://github.com/rustic-rs/rustic/issues/1127))
- Update rustic_core and rustic_backend
([#1201](https://github.com/rustic-rs/rustic/issues/1201))
### Testing
- Replace missing crates folder with src
- Refactor integration tests to assert_cmd and predicates, test all configs in
config subdirectory ([#1060](https://github.com/rustic-rs/rustic/issues/1060))
## [0.7.0] - 2024-02-03
### Packaging
- Enable RPM file build target
([#951](https://github.com/rustic-rs/rustic/issues/951))
### Bug Fixes
- Remove unmaintained `actions-rs` ci actions
- Remove unmaintained `actions-rs/cargo` ci action with cross.
- Remove unmaintained `actions-rs/toolchain` ci action
- Log config file logs after reading config files
([#961](https://github.com/rustic-rs/rustic/issues/961))
- Fix progress for copy command
([#965](https://github.com/rustic-rs/rustic/issues/965))
- Enable abscissa_core testing feature only for dev
([#976](https://github.com/rustic-rs/rustic/issues/976))
- Update github action to download artifacts, as upload/download actions from
nightly workflow were incompatible with each other
- Update rust crate duct to 0.13.7
([#991](https://github.com/rustic-rs/rustic/issues/991))
- Update rust crate libc to 0.2.151
([#992](https://github.com/rustic-rs/rustic/issues/992))
- Diff: Add local: to path syntax
([#1000](https://github.com/rustic-rs/rustic/issues/1000))
- Update rust crate libc to 0.2.152
([#1016](https://github.com/rustic-rs/rustic/issues/1016))
- Error handling when entering passwords
([#963](https://github.com/rustic-rs/rustic/issues/963))
- Use hyphen in cli api for numeric-uid-gid
### Documentation
- Update changelog
- Fix new lines in changelog
- Update changelog
### Features
- Add --quiet option to backup and forget
([#964](https://github.com/rustic-rs/rustic/issues/964))
- Allow building without self-update feature
([#975](https://github.com/rustic-rs/rustic/issues/975))
- Add option --numeric-uid-gid to ls
([#1019](https://github.com/rustic-rs/rustic/issues/1019))
- Add colors to help texts
([#1007](https://github.com/rustic-rs/rustic/issues/1007))
- Add webdav command ([#1024](https://github.com/rustic-rs/rustic/issues/1024))
### Generated
- Updated Completions fixtures
### Miscellaneous Tasks
- Run actions that need secrets.GITHUB_TOKEN only on rustic-rs org
- Update dtolnay/rust-toolchain
- Update taiki-e/install-action
- Update rustsec/audit-check
- Netbsd nightly builds fail due to missing execinfo, so we don't build on it
for now
- Upgrade dprint config
- Activate automerge for github action digest update
- Activate automerge for github action digest update
- Automerge lockfile maintenance
- Try to fix nightly build
- Display structure of downloaded artifact files
- Display structure of downloaded artifact files II
- Release
- Do not run twice on release branches
- Remove release workflow and fix release continuous deployment
- Run on tag push
- Add release candidates to CD
- Remove conditional for checking tags
- Fix path for release files for CD
- Fix path for release files for CD, second approach with full file name
- Fix binstall pkg-url
- Use tag version in directory names for automation to download new versions
- Set `max-parallel` to 1 for build matrix
- Replace max-parallel with an own job
### Refactor
- Adjust to changes in rustic_core for added rustic_backend
([#966](https://github.com/rustic-rs/rustic/issues/966))
### Testing
- Add missing powershell profile to completions test
### Build
- Bump zerocopy from 0.7.25 to 0.7.31
([#967](https://github.com/rustic-rs/rustic/issues/967))
- Bump h2 from 0.3.22 to 0.3.24
([#1009](https://github.com/rustic-rs/rustic/issues/1009))
### Diff
- Improve code (better lifetime handling)
### Ls
- Add alternative option name --numeric-id
## [0.6.0] - 2023-10-23
### Breaking Changes

3829
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,40 +1,35 @@
[workspace.package]
version = "0.6.1"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/rustic-rs/rustic"
homepage = "https://rustic.cli.rs/"
keywords = ["backup", "restic", "deduplication", "encryption", "cli"]
[package]
name = "rustic-rs"
version = "0.9.4"
authors = ["the rustic-rs team"]
categories = ["command-line-utilities"]
documentation = "https://docs.rs/rustic-rs"
edition = "2021"
homepage = "https://rustic.cli.rs/"
include = ["src/**/*", "LICENSE-*", "README.md", "config/**/*"]
keywords = ["backup", "restic", "deduplication", "encryption", "cli"]
license = "Apache-2.0 OR MIT"
readme = "README.md"
repository = "https://github.com/rustic-rs/rustic"
resolver = "2"
rust-version = "1.80.0"
description = """
rustic - fast, encrypted, deduplicated backups powered by Rust
"""
[package]
name = "rustic-rs"
version = { workspace = true }
authors = ["Alexander Weiss"]
categories = { workspace = true }
documentation = "https://docs.rs/rustic-rs"
edition = { workspace = true }
homepage = { workspace = true }
include = ["src/**/*", "LICENSE-*", "README.md", "config/**/*"]
keywords = { workspace = true }
license = { workspace = true }
readme = "README.md"
repository = { workspace = true }
resolver = "2"
rust-version = "1.70.0"
description = { workspace = true }
[workspace]
members = ["crates/rustic_testing", "xtask"]
[features]
default = ["self-update"]
default = ["tui", "webdav"]
release = ["default", "self-update"]
# Allocators
mimalloc = ["dep:mimalloc"]
jemallocator = ["dep:jemallocator-global"]
# Commands
self-update = ["dep:self_update", "dep:semver"]
tui = ["dep:ratatui", "dep:crossterm", "dep:tui-textarea"]
webdav = ["dep:dav-server", "dep:warp", "dep:tokio", "rustic_core/webdav"]
mount = ["dep:fuse_mt"]
[[bin]]
name = "rustic"
@ -51,109 +46,91 @@ all-features = true
rustdoc-args = ["--document-private-items", "--generate-link-to-definition"]
[dependencies]
abscissa_core = { workspace = true }
rustic_core = { workspace = true }
abscissa_core = { version = "0.8.1", default-features = false, features = ["application"] }
rustic_backend = { version = "0.4.2", features = ["cli"] }
rustic_core = { version = "0.5.5", features = ["cli"] }
# errors
anyhow = { workspace = true }
thiserror = { workspace = true }
# logging
log = { workspace = true }
# serialization
serde = { workspace = true }
serde_json = { workspace = true }
serde_with = { workspace = true }
# other dependencies
chrono = { workspace = true }
self_update = { workspace = true, optional = true }
semver = { workspace = true, optional = true }
# commands
clap = { workspace = true }
clap_complete = { workspace = true }
merge = { workspace = true }
bytesize = { workspace = true }
comfy-table = { workspace = true }
dialoguer = { workspace = true }
directories = { workspace = true }
gethostname = { workspace = true }
humantime = { workspace = true }
indicatif = { workspace = true }
itertools = { workspace = true }
# allocators
jemallocator-global = { version = "0.3.2", optional = true }
mimalloc = { version = "0.1.39", default_features = false, optional = true }
rhai = { workspace = true }
simplelog = { workspace = true }
mimalloc = { version = "0.1.43", default-features = false, optional = true }
[dev-dependencies]
abscissa_core = { workspace = true, features = ["testing"] }
aho-corasick = { workspace = true }
dircmp = { workspace = true }
once_cell = { workspace = true }
pretty_assertions = { workspace = true }
rustic_testing = { path = "crates/rustic_testing" }
tempfile = { workspace = true }
toml = { workspace = true }
# webdav
dav-server = { version = "0.7.0", default-features = false, features = ["warp-compat"], optional = true }
tokio = { version = "1", optional = true }
warp = { version = "0.3.7", optional = true }
[target.'cfg(not(windows))'.dependencies]
libc = "0.2.150"
[workspace.dependencies]
rustic_core = { version = "0.1.2", features = ["cli"] }
abscissa_core = { version = "0.7.0", default-features = false, features = ["application"] }
# tui
crossterm = { version = "0.28", optional = true }
ratatui = { version = "0.29.0", optional = true }
tui-textarea = { version = "0.7.0", optional = true }
# logging
log = "0.4"
# errors
displaydoc = "0.2.4"
thiserror = "1"
anyhow = "1"
displaydoc = "0.2.5"
thiserror = "1"
# serialization
serde = { version = "1", features = ["serde_derive"] }
serde_with = { version = "3.4", features = ["base64"] }
serde_json = "1"
serde_with = { version = "3", features = ["base64"] }
# other dependencies
aho-corasick = "1.1.2"
aho-corasick = "1"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
rhai = { version = "1.16", features = ["sync", "serde", "no_optimize", "no_module", "no_custom_syntax", "only_i64"] }
semver = "1"
comfy-table = "7"
rhai = { version = "1", features = ["sync", "serde", "no_optimize", "no_module", "no_custom_syntax", "only_i64"] }
scopeguard = "1"
semver = { version = "1", optional = true }
simplelog = "0.12"
comfy-table = "7.1.0"
# commands
merge = "0.1"
directories = "5"
dialoguer = "0.11.0"
indicatif = "0.17"
gethostname = "0.4"
bytesize = "1"
itertools = "0.12"
humantime = "2"
clap_complete = "4"
cached = "0.53.1"
clap = { version = "4", features = ["derive", "env", "wrap_help"] }
once_cell = "1.18"
self_update = { version = "0.39", default-features = false, features = ["rustls", "archive-tar", "compression-flate2"] }
clap_complete = "4"
conflate = "0.2"
convert_case = "0.6.0"
dateparser = "0.2.1"
derive_more = { version = "1", features = ["debug"] }
dialoguer = "0.11.0"
directories = "5"
fuse_mt = { version = "0.6", optional = true }
gethostname = "0.5"
globset = "0.4.15"
human-panic = "2"
humantime = "2"
indicatif = "0.17"
itertools = "0.13"
open = "5.3.0"
self_update = { version = "0.39.0", default-features = false, optional = true, features = ["rustls", "archive-tar", "compression-flate2"] } # FIXME: Downgraded to 0.39.0 due to https://github.com/jaemk/self_update/issues/136
tar = "0.4.42"
toml = "0.8"
# dev dependencies
rstest = "0.18"
[dev-dependencies]
abscissa_core = { version = "0.8.1", default-features = false, features = ["testing"] }
assert_cmd = "2.0.16"
dircmp = "0.2"
insta = { version = "1.40.0", features = ["ron"] }
predicates = "3.1.2"
pretty_assertions = "1.4"
quickcheck = "1"
quickcheck_macros = "1"
tempfile = "3.8"
pretty_assertions = "1.4"
rstest = "0.23"
rustic_testing = "0.2.3"
tempfile = "3.13"
toml = "0.8"
dircmp = "0.2"
[target.'cfg(not(windows))'.dependencies]
libc = "0.2.159"
# cargo-binstall support
# https://github.com/cargo-bins/cargo-binstall/blob/HEAD/SUPPORT.md
[package.metadata.binstall]
pkg-url = "{ repo }/releases/download/v{ version }/{ repo }-v{ version }-{ target }{ archive-suffix }"
bin-dir = "{ bin }-{ target }/{ bin }{ binary-ext }"
pkg-url = "{ repo }/releases/download/v{ version }/{ bin }-v{ version }-{ target }{ archive-suffix }"
bin-dir = "{ bin }{ binary-ext }"
pkg-fmt = "tgz"
[package.metadata.binstall.signing]

View File

@ -1,23 +1,18 @@
# Improve build speed with cached deps
ARG RUST_VERSION=1.70.0
FROM lukemathwalker/cargo-chef:latest-rust-${RUST_VERSION} AS chef
WORKDIR /app
FROM alpine AS builder
ARG RUSTIC_VERSION
ARG TARGETARCH
RUN if [ "$TARGETARCH" = "amd64" ]; then \
ASSET="rustic-${RUSTIC_VERSION}-x86_64-unknown-linux-musl.tar.gz";\
elif [ "$TARGETARCH" = "arm64" ]; then \
ASSET="rustic-${RUSTIC_VERSION}-aarch64-unknown-linux-musl.tar.gz"; \
fi; \
wget https://github.com/rustic-rs/rustic/releases/download/${RUSTIC_VERSION}/${ASSET} && \
tar -xzf ${ASSET} && \
mkdir /etc_files && \
touch /etc_files/passwd && \
touch /etc_files/group
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
# Build dependencies - this is the caching Docker layer!
RUN cargo chef cook --release --recipe-path recipe.json
# Build application
COPY . .
RUN cargo build --release
# why we dont use alpine for base image - https://andygrove.io/2020/05/why-musl-extremely-slow/
FROM debian:bookworm-slim as runtime
COPY --from=builder /app/target/release/rustic /usr/local/bin
ENTRYPOINT ["/usr/local/bin/rustic"]
FROM scratch
COPY --from=builder /rustic /
COPY --from=builder /etc_files/ /etc/
ENTRYPOINT ["/rustic"]

66
ECOSYSTEM.md Normal file
View File

@ -0,0 +1,66 @@
# Ecosystem
## Crates
### rustic_backend - [Link](https://crates.io/crates/rustic_backend)
A library for supporting various backends in `rustic` and `rustic_core`.
### rustic_core - [Link](https://crates.io/crates/rustic_core)
Core functionality for the `rustic` ecosystem. Can be found
[here](https://github.com/rustic-rs/rustic_core).
### rustic_scheduler - [Link](https://crates.io/crates/rustic_scheduler)
Scheduling functionality for the `rustic` ecosystem.
### rustic_server - [Link](https://crates.io/crates/rustic_server)
A possible server implementation for `rustic` to support multiple clients when
backing up.
### rustic_testing (not published) - [Link](https://github.com/rustic-rs/rustic_core/tree/main/crates/testing)
Testing functionality for the `rustic` ecosystem.
<!-- ### rustic_bench (reserved) - [Link](https://crates.io/crates/rustic_bench)
Benchmarking functionality for the `rustic` ecosystem. -->
<!-- ### rustic_auth (reserved) - [Link](https://crates.io/crates/rustic_auth)
Authentication functionality for the `rustic` ecosystem. -->
<!-- ### rustic_ui (reserved) - [Link](https://crates.io/crates/rustic_ui)
General UI functionality for the `rustic` ecosystem. -->
<!-- ### rustic_gui (reserved) - [Link](https://crates.io/crates/rustic_gui)
Graphical UI for `rustic`. -->
<!-- ### rustic_tui (reserved) - [Link](https://crates.io/crates/rustic_tui)
Terminal UI for `rustic`. -->
<!-- ### rustic_cli (reserved) - [Link](https://crates.io/crates/rustic_cli)
Common used CLI functionality for the `rustic` ecosystem. -->
<!-- ### rustic_web (reserved) - [Link](https://crates.io/crates/rustic_web)
Possible WASM/WASI functionality for the `rustic` ecosystem. -->
<!-- ### rustic_store (reserved) - [Link](https://crates.io/crates/rustic_store)
Possible store functionality to support a plugin system for the `rustic`
ecosystem. -->
<!-- ### rustic_plugins (reserved) - [Link](https://crates.io/crates/rustic_plugins)
Plugin functionality for the `rustic` ecosystem. -->
<!-- ### rustic_daemon (reserved) - [Link](https://crates.io/crates/rustic_daemon)
A daemon for `rustic` and running it as a service. -->

View File

@ -9,6 +9,7 @@
<a href="https://raw.githubusercontent.com/rustic-rs/rustic/main/"><img src="https://img.shields.io/badge/license-Apache2.0/MIT-blue.svg" /></a>
<a href="https://crates.io/crates/rustic-rs"><img src="https://img.shields.io/crates/d/rustic-rs.svg" /></a>
<a href="https://github.com/rustic-rs/rustic/actions/workflows/nightly.yml"><img src="https://github.com/rustic-rs/rustic/actions/workflows/nightly.yml/badge.svg" /></a>
<a href="https://github.com/rustic-rs/rustic/actions/workflows/release-image.yml"><img src="https://github.com/rustic-rs/rustic/actions/workflows/release-image.yml/badge.svg" /></a>
<p>
## About
@ -35,8 +36,8 @@ The `rustic` project is split into multiple crates:
- [rustic](https://crates.io/crates/rustic-rs) - the main binary
- [rustic-core](https://crates.io/crates/rustic_core) - the core library
<!-- - [rustic-testing](https://crates.io/crates/rustic_testing) - testing utilities -->
- [rustic-backend](https://crates.io/crates/rustic_backend) - the library for
supporting various backends
## Features
@ -97,6 +98,12 @@ Or you can check out the
Nightly binaries are available
[here](https://rustic.cli.rs/docs/nightly_builds.html).
### Docker
```bash
docker pull ghcr.io/rustic-rs/rustic
```
### From source
**Beware**: This installs the latest development version, which might be
@ -109,7 +116,7 @@ cargo install --git https://github.com/rustic-rs/rustic.git rustic-rs
### crates.io
```bash
cargo install rustic-rs
cargo install --locked rustic-rs
```
## Differences to `restic`?
@ -136,7 +143,7 @@ Please make sure, that you read the
## Minimum Rust version policy
This crate's minimum supported `rustc` version is `1.70.0`.
This crate's minimum supported `rustc` version is `1.80.0`.
The current policy is that the minimum Rust version required to use this crate
can be increased in minor version updates. For example, if `crate 1.0` requires

40
build-dependencies.just Normal file
View File

@ -0,0 +1,40 @@
### DEFAULT ###
# Install dependencies for the default feature on x86_64-unknown-linux-musl
install-default-x86_64-unknown-linux-musl:
sudo apt-get update
sudo apt-get install -y musl-tools
# Install dependencies for the default feature on aarch64-unknown-linux-musl
install-default-aarch64-unknown-linux-musl:
sudo apt-get update
sudo apt-get install -y musl-tools
### MOUNT ###
# Install dependencies for the mount feature on x86_64-unknown-linux-gnu
install-mount-x86_64-unknown-linux-gnu:
sudo apt-get update
sudo apt-get install -y fuse3 libfuse3-dev pkg-config
# Install dependencies for the mount feature on aarch64-unknown-linux-gnu
install-mount-aarch64-unknown-linux-gnu:
sudo apt-get update
sudo apt-get install -y fuse3 libfuse3-dev pkg-config
# Install dependencies for the mount feature on i686-unknown-linux-gnu
install-mount-i686-unknown-linux-gnu:
sudo apt-get update
sudo apt-get install -y fuse3 libfuse3-dev pkg-config
# Install dependencies for the mount feature on x86_64-apple-darwin
install-mount-x86_64-apple-darwin:
brew install macfuse
# Install dependencies for the mount feature on aarch64-apple-darwin
install-mount-aarch64-apple-darwin:
brew install macfuse
# Install dependencies for the mount feature on x86_64-pc-windows-msvc
install-mount-x86_64-pc-windows-msvc:
winget install winfsp

View File

@ -2,37 +2,57 @@
<img src="https://raw.githubusercontent.com/rustic-rs/assets/main/logos/readme_header_config.png" height="400" />
</p>
# Rustic Configuration Specification
# rustic Configuration Specification
`rustic` is a backup tool that allows users to define their backup options using
a TOML configuration file. The configuration file consists of various sections
`rustic` is a backup tool that allows users to define their backup options in
profiles using TOML files. A configuration profile consists of various sections
and attributes that control the behavior of `rustic` for different commands and
sources.
This specification covers all the available sections and attributes in the
`rustic` configuration file and includes their corresponding environment
`rustic` configuration profile file and includes their corresponding environment
variable names. Users can customize their backup behavior by modifying these
attributes according to their needs.
## Table of Contents
- [Merge Precedence](#merge-precedence)
- [Profiles](#profiles)
- [Sections and Attributes](#sections-and-attributes)
- [Global Options `[global]`](#global-options-global)
- [Global Hooks `[global.hooks]`](#global-hooks-globalhooks)
- [Global Options - env variables `[global.env]`](#global-options---env-variables-globalenv)
- [Repository Options `[repository]`](#repository-options-repository)
- [Repository Options (Additional) `[repository.options]`](#repository-options-additional-repositoryoptions)
- [Repository Options for cold repo (Additional) `[repository.options-cold]`](#repository-options-for-cold-repo-additional-repositoryoptions-cold)
- [Repository Options for hot repo (Additional) `[repository.options-hot]`](#repository-options-for-hot-repo-additional-repositoryoptions-hot)
- [Repository Hooks `[repository.hooks]`](#repository-hooks-repositoryhooks)
- [Snapshot-Filter Options `[snapshot-filter]`](#snapshot-filter-options-snapshot-filter)
- [Backup Options `[backup]`](#backup-options-backup)
- [Backup Hooks `[backup.hooks]`](#backup-hooks-backuphooks)
- [Backup Snapshots `[[backup.snapshots]]`](#backup-snapshots-backupsnapshots)
- [Forget Options `[forget]`](#forget-options-forget)
- [Copy Targets `[copy]`](#copy-targets-copy)
- [WebDAV Options `[webdav]`](#webdav-options-webdav)
## Merge Precedence
The merge precedence for values is:
Commandline Arguments >> Environment Variables >> Configuration File
Commandline Arguments >> Environment Variables >> Configuration Profile
Values parsed from the `configuration file` can be overwritten by
Values parsed from the `configuration profile` can be overwritten by
`environment variables`, which can be overwritten by `commandline arguments`
options. Therefore `commandline arguments` have the highest precedence.
**NOTE**: There are the following restrictions:
- Not all options are available as environment variables or commandline
arguments. There are also commandline options which cannot be set in the
profile TOML files.
- You can overwrite values, but for most values, you cannot "unset" them on a
higher priority level.
- For some integer values, you cannot even overwrite with the value `0`, e.g.
`keep-weekly = 5` in the `[forget]` section of the config file cannot be
overwritten by `--keep-weekly 0`.
## Profiles
Configuration files can be placed in the user's local config directory, e.g.
@ -41,136 +61,234 @@ use different config files, e.g. `myconfig.toml` and use the `-P` option to
specify the profile name, e.g. `rustic -P myconfig`. Examples for different
configuration files can be found here in the [/config/](/config) directory.
## Services
We have collected some examples how to configure `rustic` for various services
in the [services/](/config/services/) subdirectory. Please note that these
examples are not complete and may not work out of the box. They are intended to
give you a starting point for your own configuration.
If you want to contribute your own configuration, please
[open a pull request](https://rustic.cli.rs/dev-docs/contributing-to-rustic.html#submitting-pull-requests).
## Sections and Attributes
### Global Options
### Global Options `[global]`
| Attribute | Description | Default Value | Example Value | Environment Variable |
| ----------------- | --------------------------------------------------------------------------------- | ------------- | ----------------- | ------------------------ |
| dry-run | If true, performs a dry run without making any changes. | false | | RUSTIC_DRY_RUN |
| log-level | Logging level. Possible values: "off", "error", "warn", "info", "debug", "trace". | "info" | | RUSTIC_LOG_LEVEL |
| log-file | Path to the log file. | No log file | "/log/rustic.log" | RUSTIC_LOG_FILE |
| no-progress | If true, disables progress indicators. | false | | RUSTIC_NO_PROGRESS |
| progress-interval | The interval at which progress indicators are shown. | "100ms" | "1m" | RUSTIC_PROGRESS_INTERVAL |
| use-profile | An array of profiles to use. | Empty array | | RUSTIC_USE_PROFILE |
| Attribute | Description | Default Value | Example Value | Environment Variable | CLI Option |
| ----------------- | --------------------------------------------------------------------------------- | ------------- | ----------------- | ------------------------ | ------------------- |
| check-index | If true, check the index and read pack headers if index information is missing. | false | | RUSTIC_CHECK_INDEX | --check-index |
| dry-run | If true, performs a dry run without making any changes. | false | | RUSTIC_DRY_RUN | --dry-run, -n |
| log-level | Logging level. Possible values: "off", "error", "warn", "info", "debug", "trace". | "info" | | RUSTIC_LOG_LEVEL | --log-level |
| log-file | Path to the log file. | No log file | "/log/rustic.log" | RUSTIC_LOG_FILE | --log-file |
| no-progress | If true, disables progress indicators. | false | | RUSTIC_NO_PROGRESS | --no-progress |
| progress-interval | The interval at which progress indicators are shown. | "100ms" | "1m" | RUSTIC_PROGRESS_INTERVAL | --progress-interval |
| use-profiles | Array of profiles to use. Allows to recursively use other profiles. | Empty array | ["2nd", "3rd"] | RUSTIC_USE_PROFILE | --use-profile, -P |
### Global Options - env variables
### Global Hooks `[global.hooks]`
These external commands are run before and after each commands, respectively.
**Note**: There are also repository hooks, which should be used for commands
needed to set up the repository (like mounting the repo dir), see below.
| Attribute | Description | Default Value | Example Value | Environment Variable |
| ----------- | ------------------------------------------------- | ------------- | ------------- | -------------------- |
| run-before | Run the given commands before execution | not set | ["echo test"] | |
| run-after | Run the given commands after successful execution | not set | ["echo test"] | |
| run-failed | Run the given commands after failed execution | not set | ["echo test"] | |
| run-finally | Run the given commands after every execution | not set | ["echo test"] | |
### Global Options - env variables `[global.env]`
All given environment variables are set before processing. This is handy to
configure e.g. the `rclone`-backend or some commands which will be called by
rustic.
**Important**: Please do not forget to include environment variables set in the
config file as a possible source of errors if you encounter problems. They could
possibly shadow other values that you have already set.
config profile as a possible source of errors if you encounter problems. They
could possibly shadow other values that you have already set.
### Repository Options
### Repository Options `[repository]`
| Attribute | Description | Default Value | Example Value | Environment Variable |
| ---------------- | ---------------------------------------------------------- | ------------------------ | ---------------------- | ----------------------- |
| cache-dir | Path to the cache directory. | ~/.cache/rustic/$REPO_ID | ~/.cache/my_own_cache/ | RUSTIC_CACHE_DIR |
| no-cache | If true, disables caching. | false | | RUSTIC_NO_CACHE |
| repository | The path to the repository. Required. | Not set | "/tmp/rustic" | RUSTIC_REPOSITORY |
| repo-hot | The path to the hot repository. | Not set | | RUSTIC_REPO_HOT |
| password | The password for the repository. | Not set | "mySecretPassword" | RUSTIC_PASSWORD |
| password-file | Path to a file containing the password for the repository. | Not set | | RUSTIC_PASSWORD_FILE |
| password-command | Command to retrieve the password for the repository. | Not set | | RUSTIC_PASSWORD_COMMAND |
| warm-up | If true, warms up the repository by file access. | false | | |
| warm-up-command | Command to warm up the repository. | Not set | | |
| warm-up-wait | The wait time for warming up the repository. | Not set | | |
| Attribute | Description | Default Value | Example Value | Environment Variable | CLI Option |
| ---------------- | ---------------------------------------------------------- | ------------------------ | ---------------------- | ----------------------- | ------------------- |
| cache-dir | Path to the cache directory. | ~/.cache/rustic/$REPO_ID | ~/.cache/my_own_cache/ | RUSTIC_CACHE_DIR | --cache-dir |
| no-cache | If true, disables caching. | false | | RUSTIC_NO_CACHE | --no-cache |
| repository | The path to the repository. Required. | Not set | "/tmp/rustic" | RUSTIC_REPOSITORY | --repositoy, -r |
| repo-hot | The path to the hot repository. | Not set | | RUSTIC_REPO_HOT | --repo-hot |
| password | The password for the repository. | Not set | "mySecretPassword" | RUSTIC_PASSWORD | --password |
| password-file | Path to a file containing the password for the repository. | Not set | | RUSTIC_PASSWORD_FILE | --password-file, -p |
| password-command | Command to retrieve the password for the repository. | Not set | | RUSTIC_PASSWORD_COMMAND | --password-command |
| warm-up | If true, warms up the repository by file access. | false | | | ---warm-up |
| warm-up-command | Command to warm up the repository. | Not set | | | --warm-up-command |
| warm-up-wait | The wait time for warming up the repository. | Not set | | | --warm-up-wait |
### Repository Options (Additional)
### Repository Options (Additional) `[repository.options]`
Additional repository options - depending on backend. These can be only set in
the config file or using env variables. For env variables use upper snake case
and prefix with "RUSTIC_REPO_OPT_", e.g. `use-password = "true"` becomes
`RUSTIC_REPO_OPT_USE_PASSWORD=true`
| Attribute | Description | Default Value | Example Value |
| ------------------- | ------------------------------------------------------------------ | ------------- | ------------------------------ |
| post-create-command | Command to execute after creating a snapshot in the local backend. | Not set | "par2create -qq -n1 -r5 %file" |
| post-delete-command | Command to execute after deleting a snapshot in the local backend. | Not set | "sh -c \"rm -f %file*.par2\"" |
### Snapshot-Filter Options
### Repository Options for cold repo (Additional) `[repository.options-cold]`
| Attribute | Description | Default Value | Example Value |
| ------------ | ------------------------------------- | --------------- | ------------- |
| filter-fn | Custom filter function for snapshots. | Not set | |
| filter-host | Array of hosts to filter snapshots. | Not set | ["myhost"] |
| filter-label | Array of labels to filter snapshots. | No label filter | |
| filter-paths | Array of paths to filter snapshots. | No paths filter | |
| filter-tags | Array of tags to filter snapshots. | No tags filter | |
Additional repository options for cold repository - depending on backend. These
can be only set in the config file or using env variables. For env variables use
upper snake case and prefix with "RUSTIC_REPO_OPTCOLD_".
### Backup Options
### Repository Options for hot repo (Additional) `[repository.options-hot]`
**Note**: Some options are not source-specific, but if set here, they apply for
all sources, although they can be overwritten in the source-specifc
configuration.
Additional repository options for hot repository - depending on backend. These
can be only set in the config file or using env variables. For env variables use
upper snake case and prefix with "RUSTIC_REPO_OPTHOT_".
| Attribute | Description | Default Value | Example Value |
| ---------------- | --------------------------------------------------------- | ------------- | ------------- |
| description | Description for the backup. | Not set | |
| description-from | Path to a file containing the description for the backup. | Not set | |
| delete-never | If true, never delete the backup. | false | |
| delete-after | Time duration after which the backup will be deleted. | Not set | |
see Repository Options
### Backup Sources
### Repository Hooks `[repository.hooks]`
| Attribute | Description | Default Value | Example Value |
| --------- | ------------------------------------ | ------------- | --------------------- |
| source | Source directory or file to back up. | Not set | "/tmp/dir/to_backup/" |
These external commands are run before and after each repository-accessing
commands, respectively.
#### Source-specific options
See [Global Hooks](#global-hooks-globalhooks).
**Note**: The following options can be specified for each source individually in
the source-individual section, see below. If they are specified here, they
provide default values for all sources but can still be overwritten in the
source-individual section.
### Snapshot-Filter Options `[snapshot-filter]`
| Attribute | Description | Default Value |
| ------------------ | --------------------------------------------------------------------------------------- | ------------- |
| as-path | Specifies the path for the backup when the source contains a single path. | Not set |
| exclude-if-present | Array of filenames to exclude from the backup if they are present. | Not set |
| force | If true, forces the backup even if no changes are detected. | Not set |
| git-ignore | If true, use .gitignore rules to exclude files from the backup in the source directory. | true |
| glob-file | Array of glob files specifying additional files to include in the backup. | Not set |
| group-by | Grouping strategy for the backup. | Not set |
| host | Host name for the backup. | Not set |
| ignore-ctime | If true, ignores file change time (ctime) for the backup. | Not set |
| ignore-inode | If true, ignores file inode for the backup. | Not set |
| init | If true, initializes repository if it doesn't exist, yet. | Not set |
| json | If true, returns output of the command as json. | Not set |
| label | Label for the backup. | Not set |
| one-file-system | If true, only backs up files from the same filesystem as the source. | Not set |
| parent | Parent snapshot ID for the backup. | Not set |
| stdin-filename | File name to be used when reading from stdin. | Not set |
| tag | Array of tags for the backup. | Not set |
| with-atime | If true, includes file access time (atime) in the backup. | Not set |
| Attribute | Description | Default Value | Example Value | CLI Option |
| ------------------ | ---------------------------------------------------------------------- | ------------- | ------------------------ | -------------------- |
| filter-hosts | Array of hosts to filter snapshots. | Not set | ["myhost", "host2"] | --filter-host |
| filter-labels | Array of labels to filter snapshots. | Not set | ["mylabal"] | --filter-label |
| filter-paths | Array of pathlists to filter snapshots. | Not set | ["/home,/root"] | --filter-paths |
| filter-paths-exact | Array or string of paths to filter snapshots. Exact match. | Not set | ["path1,path2", "path3"] | --filter-paths-exact |
| filter-tags | Array of taglists to filter snapshots. | Not set | ["tag1,tag2"] | --filter-tags |
| filter-tags-exact | Array or string of tags to filter snapshots. Exact match. | Not set | ["tag1,tag2", "tag3"] | --filter-tags-exact |
| filter-before | Filter snapshots before the given date/time | Not set | "2024-01-01" | --filter-before |
| filter-after | Filter snapshots after the given date/time | Not set | "2023-01-01 11:15:23" | --filter-after |
| filter-size | Filter snapshots for a total size in the size range. | Not set | "1MB..1GB" | --filter-size |
| | If a single value is given, this is taken as lower bound. | | "500 k" | |
| filter-size-added | Filter snapshots for a size added to the repository in the size range. | Not set | "1MB..1GB" | --filter-size-added |
| | If a single value is given, this is taken as lower bound. | | "500 k" | |
| filter-fn | Custom filter function for snapshots. | Not set | | --filter-fn |
### Forget Options
### Backup Options `[backup]`
| Attribute | Description | Default Value | Example Value |
| ----------------- | ---------------------------------------------------------- | ------------- | -------------- |
| filter-host | Array of hosts to filter snapshots. | Not set | ["forgethost"] |
| keep-daily | Number of daily backups to keep. | Not set | |
| keep-within-daily | The time duration within which daily backups will be kept. | Not set | "7 days" |
| keep-hourly | Number of hourly backups to keep. | Not set | |
| keep-monthly | Number of monthly backups to keep. | Not set | |
| keep-weekly | Number of weekly backups to keep. | Not set | |
| keep-yearly | Number of yearly backups to keep. | Not set | |
| keep-tags | Array of tags to keep. | Not set | ["mytag"] |
**Note**: If set here, the backup options apply for all sources, although they
can be overwritten in the source-specific configuration, see below.
### Copy Targets
| Attribute | Description | Default Value | Example Value | CLI Option |
| --------------------- | --------------------------------------------------------------------------------------- | --------------------- | ------------- | ----------------------- |
| as-path | Specifies the path for the backup when the source contains a single path. | Not set | | --as-path |
| command | Set the command saved in the snapshot. | The full command used | | --command |
| custom-ignorefiles | Array of names of custom ignorefiles which will be used to exclude files. | [] | | --custom-ignorefile |
| description | Description for the snapshot. | Not set | | --description |
| description-from | Path to a file containing the description for the snapshot. | Not set | | --description-from |
| delete-never | If true, never delete the snapshot. | false | | --delete-never |
| delete-after | Time duration after which the snapshot be deleted. | Not set | | --delete-after |
| exclude-if-present | Array of filenames to exclude from the backup if they are present. | [] | | --exclude-if-present |
| force | If true, forces the backup even if no changes are detected. | false | | --force |
| git-ignore | If true, use .gitignore rules to exclude files from the backup in the source directory. | false | | --git-ignore |
| globs | Array of globs specifying what to include/exclude in the backup. | [] | | --glob |
| glob-files | Array or string of glob files specifying what to include/exclude in the backup. | [] | | --glob-file |
| group-by | Grouping strategy to find parent snapshot. | "host,label,paths" | | --group-by |
| host | Host name used in the snapshot. | local hostname | | --host |
| iglobs | Like glob, but apply case-insensitive | [] | | --iglob |
| iglob-files | Like glob-file, but apply case-insensitive | [] | | --iglob-file |
| ignore-devid | If true, don't save device ID. | false | | --ignore-devid |
| ignore-ctime | If true, ignore file change time (ctime). | false | | --ignore-ctime |
| ignore-inode | If true, ignore file inode for the backup. | false | | --ignore-inode |
| init | If true, initialize repository if it doesn't exist, yet. | false | | --init |
| json | If true, returns output of the command as json. | false | | --json |
| label | Set label fot the snapshot. | Not set | | --label |
| no-require-git | (with git-ignore:) Apply .git-ignore files even if they are not in a git repository. | false | | --no-require-git |
| no-scan | Don't scan the backup source for its size (disables ETA). | false | | --no-scan |
| one-file-system | If true, only backs up files from the same filesystem as the source. | false | | --one-file-system |
| parent | Parent snapshot ID for the backup. | Not set | | --parent |
| quiet | Don't output backup summary. | false | | --quiet |
| skip-identical-parent | Skip saving of the snapshot if it is identical to the parent. | false | | --skip-identical-parent |
| stdin-filename | File name to be used when reading from stdin. | Not set | | --stdin-filename |
| tags | Array of tags for the backup. | [] | | --tag |
| time | Set the time saved in the snapshot. | current time | | --time |
| with-atime | If true, includes file access time (atime) in the backup. | false | | --with-atime |
**Note**: Copy-targets are simply repositories with the same defaults as within
the repository section.
### Backup Hooks `[backup.hooks]`
| Attribute | Description | Default Value | Example Value |
| ------------------- | ---------------------------------------------------------------------- | ------------------------ | ---------------------- |
| cache-dir | Path to the cache directory for the target repository. | ~/.cache/rustic/$REPO_ID | ~/.cache/my_own_cache/ |
| no-cache | If true, disables caching for the target repository. | false | |
| password | The password for the target repository. | Not set | |
| password-file | Path to a file containing the password for the target repository. | Not set | |
| password-command | Command to retrieve the password for the target repository. | Not set | |
| post-create-command | Command to execute after creating a snapshot in the target repository. | Not set | |
| post-delete-command | Command to execute after deleting a snapshot in the target repository. | Not set | |
| repository | The path or URL to the target repository. | Not set | |
| repo-hot | The path or URL to the hot target repository. | Not set | |
| warm-up | If true, warms up the target repository by file access. | Not set | |
| warm-up-command | Command to warm up the target repository. | Not set | |
| warm-up-wait | The wait time for warming up the target repository. | Not set | |
These external commands are run before and after each backup, respectively.
**Note**: Global hooks and repository hooks are run additionaly.
See [Global Hooks](#global-hooks-globalhooks).
### Backup Snapshots `[[backup.snapshots]]`
**Note**: All of the backup options mentioned before can also be used as
snapshot-specific option and then only apply to this snapshot.
| Attribute | Description | Default Value | Example Value |
| --------- | ------------------------------------------------------------- | ------------- | ---------------------------------------------------------------------- |
| sources | Array of source directories or file(s) to back up. | [] | ["/dir1", "/dir2"] |
| hooks | Hooks to run before and after backing up the defined sources. | Not set | { run-before = [], run-after = [], run-failed = [], run-finally = [] } |
Source-specific hooks are called additionally to global, repository and backup
hooks when backing up the defined sources into a snapshot.
### Forget Options `[forget]`
**Note**: At lest on of the `keep-*` options must be given. Use
`keep-none = true` if you want to remove all snapshots.
| Attribute | Description | Default Value | Example Value | CLI Option |
| -------------------------- | ----------------------------------------------------------------------- | ------------------ | ---------------------- | ---------------------------- |
| group-by | Group snapshots by given criteria before applying keep policies. | "host,label,paths" | | --group-by |
| keep-last | Number of most recent snapshots to keep. | Not set | 15 | --keep-last, -l |
| keep-hourly, -H | Number of hourly snapshots to keep. | Not set | | --keep-hourly |
| keep-daily, -d | Number of daily snapshots to keep. | Not set | 8 | --keep-daily |
| keep-weekly, -w | Number of weekly snapshots to keep. | Not set | | --keep-weekly |
| keep-monthly, -m | Number of monthly snapshots to keep. | Not set | | --keep-monthly |
| keep-quarter-yearly | Number of quarter-yearly snapshots to keep. | Not set | | --keep-quarter-yearly |
| keep-half-yearly | Number of half-yearly snapshots to keep. | Not set | | --keep-half-yearly |
| keep-yearly, -y | Number of yearly snapshots to keep. | Not set | | --keep-yearly |
| keep-within-hourly | The time duration within which hourly snapshots will be kept. | Not set | "1 day" | --keep-within-hourly |
| keep-within-daily | The time duration within which daily snapshots will be kept. | Not set | "7 days" | --keep-within-daily |
| keep-within-weekly | The time duration within which weekly snapshots will be kept. | Not set | | --keep-within-weekly |
| keep-within-monthly | The time duration within which monthly snapshots will be kept. | Not set | | --keep-within-monthly |
| keep-within-quarter-yearly | The time duration within which quarter-yearly snapshots will be kept. | Not set | | --keep-within-quarter-yearly |
| keep-within-half-yearly | The time duration within which half-yearly snapshots will be kept. | Not set | | --keep-within-half-yearly |
| keep-within-yearly | The time duration within which yearly snapshots will be kept. | Not set | | --keep-within-yearly |
| keep-tags | Keep snapshots containing one of these taglists. | [] | ["keep", "important" ] | --keep-tags |
| keep-ids | Keep snapshots containing one of these IDs. | [] | ["6e58f3d32" ] | --keep-id |
| keep-none | Allow to keep no snapshots. | false | true | --keep-none |
| prune | If set to true, prune the repository after snapshots have been removed. | false | | --prune |
Additionally extra snapshot filter options can be given for the `forget` command
here, see Snapshot-Filter options.
### Copy Targets `[copy]`
**Note**: Copy-targets must be defined in their own config profile files.
| Attribute | Description | Default Value | Example Value | CLI Option |
| --------- | ------------------ | ------------- | ------------------------ | ---------- |
| targets | Targets to copy to | [] | ["profile1", "profile2"] | --target |
### WebDAV Options `[webdav]`
`rustic` supports mounting snapshots via WebDAV. This is useful if you want to
access your snapshots via a file manager.
**Note**: `https://` and Authentication are not supported yet.
The following options are available to be used in your configuration file:
| Attribute | Description | Default Value | Example Value | CLI Option |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------- | --------------- |
| address | Address of the WebDAV server. | localhost:8000 | | --address |
| path-template | The path template to use for snapshots. {id}, {id_long}, {time}, {username}, {hostname}, {label}, {tags}, {backup_start}, {backup_end} are replaced. | `[{hostname}]/[{label}]/{time}` | | --path-template |
| time-template | 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. | `%Y-%m-%d_%H-%M-%S` | | --time-template |
| symlinks | If true, follows symlinks. | false | | --symlinks |
| file-access | How to handle access to files. | "forbidden" for hot/cold repositories, else "read" | | --file-access |
| snapshot-path | Specify directly which snapshot/path to serve | Not set, this will generate a virtual tree with all snapshots using path-template | | --snapshot-path |

View File

@ -8,14 +8,6 @@
repository = "/tmp/repo"
password = "test"
# you can specify multiple targets
[[copy.targets]]
repository = "/tmp/repo2"
password = "test"
no-cache = true
[[copy.targets]]
repository = "rclone:ovh:backup"
repo-hot = "clone:ovh:backup-hot"
password-file = "/root/key-rustic-ovh"
cache-dir = "/var/lib/cache/rustic" # explicitly specify cache dir for remote repository
# you can specify multiple targets. Note that each target must be configured via a config profile file
[copy]
targets = ["full", "rustic"]

View File

@ -8,12 +8,23 @@
# Global options: These options are used for all commands.
[global]
use-profile = []
use-profiles = []
log-level = "info" # any of "off", "error", "warn", "info", "debug", "trace"; default: "info"
log-file = "/path/to/rustic.log" # Default: not set
no-progress = false
progress-interval = "100ms"
dry-run = false
check-index = false
# Global hooks: The given commands are called for every command
[global.hooks]
run-before = [
# long form giving command and args explicitely and allow to specify failure behavior
{ command = "echo", args = ["before"], on-failure = "warn" }, # allowed values for on-failure: "error" (default), "warn", "ignore"
] # Default: []
run-after = ["echo after"] # Run after if successful, short version, default: []
run-failed = ["echo failed"] # Default: []
run-finally = ["echo finally"] # Always run after, default: []
# Global env variables: These are set by rustic before calling a subcommand, e.g. rclone or commands
# defined in the repository options.
@ -36,26 +47,60 @@ warm-up = false
warm-up-command = "warmup.sh %id" # Default: not set
warm-up-wait = "10min" # Default: not set
# Additional repository options - depending on backend. These can be only set in the config file.
# Additional repository options - depending on backend. These can be only set in the config file or using env variables.
# For env variables use upper snake case and prefix with "RUSTIC_REPO_OPT_", e.g. `use-passwort = "true"` becomes
# `RUSTIC_REPO_OPT_USE_PASSWORT=true`
[repository.options]
post-create-command = "par2create -qq -n1 -r5 %file" # Only local backend; Default: not set
post-delete-command = "sh -c \"rm -f %file*.par2\"" # Only local backend; Default: not set
retry = "default" # Only rest/rclone backend; Allowed values: "false"/"off", "default" or number of retries
retry = "default" # Only rest/rclone/all opendal backends; Allowed values: "false"/"off", "default" or number of retries
timeout = "10min" # Only rest/rclone backend
rclone-command = "rclone serve restic --addr localhost:0" # Only rclone; Default: not set
use-password = "true" # Only rclone
rest-url = "http://localhost:8000" # Only rclone; Default: determine REST URL from rclone output
connections = "20" # Only opendal backends; Default: Not set
throttle = "10kB,10MB" # limit and burst per second; only opendal backends; Default: Not set
# Note that opendal backends use several service-dependent options which may be specified here, see
# https://opendal.apache.org/docs/rust/opendal/services/index.html
# Additional repository options for the hot part - depending on backend. These can be only set in the config file or
# using env variables.
# For env variables use upper snake case and prefix with "RUSTIC_REPO_OPTHOT_"
[repository.options-hot]
# see [repository.options]
# Additional repository options for the cold part - depending on backend. These can be only set in the config file or
# using env variables.
# For env variables use upper snake case and prefix with "RUSTIC_REPO_OPTCOLD_"
[repository.options-cold]
# see [repository.options]
# Repository hooks: The given commands are called for commands accessing the repository.
[repository.hooks]
run-before = ["echo before"] # Default: []
run-after = ["echo after"] # Run after if successful, default: []
run-failed = ["echo failed"] # Default: []
run-finally = ["echo finally"] # Always run after, default: []
# Snapshot-filter options: These options apply to all commands that use snapshot filters
[snapshot-filter]
filter-host = ["host2", "host2"] # Default: no host filter
filter-label = ["label1", "label2"] # Default: no label filter
filter-tags = ["tag1,tag2", "tag3"] # Default: no tags filger
filter-paths = ["path1", "path2,path3"] # Default: no paths filter
filter-hosts = ["host1", "host2"] # Default: []
filter-labels = ["label1", "label2"] # Default: []
filter-tags = ["tag1,tag2", "tag3"] # Default: []
filter-tags-exact = ["tag1,tag2", "tag2"] # Default: []
filter-paths = ["path1", "path2,path3"] # Default: []
filter-paths-exact = ["path1", "path2,path3"] # Default: []
filter-after = "2024-01-01" # Default: not set
filter-before = "2024-02-05 12:15" # Default: not set
filter-size = "200MiB" # Default: not set
filter-size-added = "1 MB..10MB" # Default: not set
filter-fn = '|sn| {sn.host == "host1" || sn.description.contains("test")}' # Default: no filter function
# Backup options: These options are used for all sources when calling the backup command.
# They can be overwritten by source-specific options (see below) or command line options.
[backup]
label = "label" # Default: not set
tag = ["tag1", "tag2"]
tags = ["tag1", "tag2"]
description = "my description" # Default: not set
description-from = "/path/to/description.txt" # Default: not set
delete-never = false
@ -70,51 +115,47 @@ stdin-filename = "stdin" # Only for stdin source
as-path = "/my/path" # Default: not set; Note: This only works if source contains of a single path.
with-atime = false
ignore-devid = false
glob = []
iglob = []
glob-file = []
iglob-file = []
globs = []
iglobs = []
glob-files = []
iglob-files = []
git-ignore = false
no-require-git = false
exclude-if-present = [".nobackup", "CACHEDIR.TAG"] # Default: not set
custom-ignorefiles = [".rusticignore", ".backupignore"] # Default: not set
one-file-system = false
exclude-larger-than = "100MB" # Default: not set
json = false
init = false
no-scan = false
quiet = false
skip-identical-parent = false
# Backup hooks: The given commands are called for the `backup` command
[backup.hooks]
run-before = ["echo before"] # Default: []
run-after = ["echo after"] # Run after if successful, default: []
run-failed = ["echo failed"] # Default: []
run-finally = ["echo finally"] # Always run after, default: []
# Backup options for specific sources - all above options are also available here and replace them for the given source
[[backup.sources]]
source = "/path/to/source1"
[[backup.snapshots]]
sources = ["/path/to/source1"]
label = "label" # Default: not set
tag = ["tag1", "tag2"]
description = "my description" # Default: not set
description-from = "/path/to/description.txt" # Default: not set
delete-never = false
delete-after = "5d" # Default: not set
host = "manually_set_host" # Default: host name
group-by = "host,label,paths" # Can be any combination of host,label,paths,tags
parent = "123abc" # Default: not set
force = false
ignore-ctime = false
ignore-inode = false
stdin-filename = "stdin" # Only for stdin source
as-path = "/my/path" # Default: not set; Note: This only works if source contains of a single path.
with-atime = false
ignore-devid = false
glob = []
iglob = []
glob-file = []
iglob-file = []
git-ignore = false
no-require-git = false
exclude-if-present = [".nobackup", "CACHEDIR.TAG"] # Default: not set
one-file-system = false
exclude-larger-than = "100MB" # Default: not set
json = false
init = false
# .. and so on. see [backup]
[[backup.sources]]
source = "/path/to/source2 /second/path" # multiple local paths are allowd within one source
# Source-specific hooks: The given commands when backing up the defined source
[backup.snapshots.hooks]
run-before = ["echo before"] # Default: []
run-after = ["echo after"] # Run after if successful, default: []
run-failed = ["echo failed"] # Default: []
run-finally = ["echo finally"] # Always run after, default: []
[[backup.snapshots]]
sources = [
"/path/to/source2",
"/second/path",
] # multiple local paths are given as array
# ...
# forget options
@ -122,17 +163,23 @@ source = "/path/to/source2 /second/path" # multiple local paths are allowd withi
prune = false
group-by = "host,label,paths" # Can be any combination of host,label,paths,tags
# The following filter options can be also defined here and then overwrite the options for the forget command
filter-host = ["host2", "host2"] # Default: no host filter
filter-label = ["label1", "label2"] # Default: no label filter
filter-tags = ["tag1,tag2", "tag3"] # Default: no tags filger
filter-paths = ["path1", "path2,path3"] # Default: no paths filter
filter-hosts = ["host1", "host2"] # Default: []
filter-labels = ["label1", "label2"] # Default: []
filter-tags = ["tag1,tag2", "tag3"] # Default: []
filter-tags-exact = ["tag1,tag2", "tag2"] # Default: []
filter-paths = ["path1", "path2,path3"] # Default: []
filter-paths-exact = ["path1", "path2,path3"] # Default: []
filter-after = "2024-01-01" # Default: not set
filter-before = "2024-02-05 12:15" # Default: not set
filter-size = "200MiB" # Default: not set
filter-size-added = "1 MB..10MB" # Default: not set
filter-fn = '|sn| {sn.host == "host1" || sn.description.contains("test")}' # Default: no filter function
# The retention options follow. All of these are not set by default.
keep-tags = ["tag1", "tag2,tag3"]
keep-tags = ["tag1", "tag2,tag3"] # Default: not set
keep-ids = [
"123abc",
"11122233",
] # Keep all snapshots whose ID starts with any of these strings
] # Keep all snapshots whose ID starts with any of these strings, default: not set
keep-last = 0
keep-daily = 3
keep-weekly = 0
@ -148,22 +195,21 @@ keep-withing-quarter-yearly = "0 year"
keep-withing-half-yearly = "1 year"
keep-within-yearly = "10 years"
# Multiple targets are available for the copy command. Each specify a repository with exactly identical options as in
# the [repository] section.
[[copy.targets]]
repository = "/repo/rustic" # Must be set
repo-hot = "/my/hot/repo" # Default: not set
# one of the three password options must be set
password = "mySecretPassword"
password-file = "/my/password.txt"
password-command = "my_command.sh"
no-cache = false
cache-dir = "/my/rustic/cachedir" # Default: Applications default cache dir, e.g. ~/.cache/rustic
# use either warm-up (warm-up by file access) or warm-up-command to specify warming up
warm-up = false
warm-up-command = "warmup.sh %id" # Default: not set
warm-up-wait = "10min" # Default: not set
[copy]
targets = ["profile1", "profile2"] # Default: []
[[copy.targets]]
repository = "/repo/rustic2" # Must be set
# ...
[webdav]
address = "localhost:8000"
path-template = "[{hostname}]/[{label}]/{time}" # 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}"]. Only relevant if no snapshot-path is given.
time-template = "%Y-%m-%d_%H-%M-%S" # only relevant if no snapshot-path is given
symlinks = false
file-access = "read" # Default: "forbidden" for hot/cold repos, else "read"
snapshot-path = "latest:/dir" # Default: not set - if not set, generate a virtual tree with all snapshots using path-template
[mount]
path-template = "[{hostname}]/[{label}]/{time}" # 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}"]. Only relevant if no snapshot-path is given.
time-template = "%Y-%m-%d_%H-%M-%S" # only relevant if no snapshot-path is given
no-allow-other = true
file-access = "read" # Default: "forbidden" for hot/cold repos, else "read"
mountpoint = "~/mnt"
snapshot-path = "latest:/dir" # Default: not set - if not set, generate a virtual tree with all snapshots using path-template

35
config/hooks.toml Normal file
View File

@ -0,0 +1,35 @@
# Hooks configuration
#
# Hooks are commands that are run during certain events in the application lifecycle.
# They can be used to run custom scripts or commands before or after certain actions.
# The hooks are run in the order they are defined in the configuration file.
# The hooks are divided into 4 categories: global, repository, backup,
# and specific backup sources.
#
# You can also read a more detailed explanation of the hooks in the documentation:
# https://rustic.cli.rs/docs/commands/misc/hooks.html
#
# Please make sure to check the in-repository documentation for the config files
# available at: https://github.com/rustic-rs/rustic/blob/main/config/README.md
#
[global.hooks]
run-before = []
run-after = []
run-failed = []
run-finally = []
[repository.hooks]
run-before = []
run-after = []
run-failed = []
run-finally = []
[backup.hooks]
run-before = []
run-after = []
run-failed = []
run-finally = []
[[backup.snapshots]]
sources = []
hooks = { run-before = [], run-after = [], run-failed = [], run-finally = [] }

View File

@ -17,15 +17,15 @@ keep-yearly = 10
[backup]
exclude-if-present = [".nobackup", "CACHEDIR.TAG"]
glob-file = ["/root/rustic-local.glob"]
glob-files = ["/root/rustic-local.glob"]
one-file-system = true
[[backup.sources]]
source = "/home"
[[backup.snapshots]]
sources = ["/home"]
git-ignore = true
[[backup.sources]]
source = "/etc"
[[backup.snapshots]]
sources = ["/etc"]
[[backup.sources]]
source = "/root"
[[backup.snapshots]]
sources = ["/root"]

View File

@ -17,7 +17,7 @@ password = "mySecretPassword"
# snapshot-filter options: These options apply to all commands that use snapshot filters
[snapshot-filter]
filter-host = ["myhost"]
filter-hosts = ["myhost"]
# backup options: These options are used for all sources when calling the backup command.
# They can be overwritten by source-specific options (see below) or command line options.
@ -29,16 +29,16 @@ git-ignore = true
#
# Note that if you call "rustic backup" without any source, all sources from this config
# file will be processed.
[[backup.sources]]
source = "/data/dir"
[[backup.snapshots]]
sources = ["/data/dir"]
[[backup.sources]]
source = "/home"
glob = ["!/home/*/Downloads/*"]
[[backup.snapshots]]
sources = ["/home"]
globs = ["!/home/*/Downloads/*"]
# forget options
[forget]
filter-host = [
filter-hosts = [
"forgethost",
] # <- this overwrites the snapshot-filter option defined above
keep-tags = ["mytag"]

15
config/services/b2.toml Normal file
View File

@ -0,0 +1,15 @@
# rustic config file to use B2 storage via Apache OpenDAL
[repository]
repository = "opendal:b2" # just specify the opendal service here
password = "<rustic_passwd>"
# or
# password-file = "/home/<username>/etc/secure/rustic_passwd"
# B2 specific options
[repository.options]
# Here, we give the required b2 options, see https://opendal.apache.org/docs/rust/opendal/services/struct.B2.html
application_key_id = "my_id" # B2 application key ID
application_key = "my_key" # B2 application key secret. Can be also set using OPENDAL_APPLICATION_KEY
bucket = "bucket_name" # B2 bucket name
bucket_id = "bucket_id" # B2 bucket ID
# root = "/" # Set a repository root directory if not using the root directory of the bucket

View File

@ -1,9 +1,9 @@
# rustic config file to backup /home, /etc and /root to a hot/cold repository hosted by OVH
# using OVH cloud archive and OVH object storage
#
# backup usage: "rustic -P ovh-hot-cold backup
# cleanup: "rustic -P ovh-hot-cold forget --prune
#
# backup usage: "rustic --use-profile ovh-hot-cold backup
# cleanup: "rustic --use-profile ovh-hot-cold forget --prune
[repository]
repository = "rclone:ovh:backup-home"
repo-hot = "rclone:ovh:backup-home-hot"
@ -20,15 +20,15 @@ keep-yearly = 10
[backup]
exclude-if-present = [".nobackup", "CACHEDIR.TAG"]
glob-file = ["/root/rustic-ovh.glob"]
glob-files = ["/root/rustic-ovh.glob"]
one-file-system = true
[[backup.sources]]
source = "/home"
[[backup.snapshots]]
sources = ["/home"]
git-ignore = true
[[backup.sources]]
source = "/etc"
[[backup.snapshots]]
sources = ["/etc"]
[[backup.sources]]
source = "/root"
[[backup.snapshots]]
sources = ["/root"]

View File

@ -0,0 +1,13 @@
# rustic config file to use s3 storage
# Note that this internally uses opendal S3 service, see https://opendal.apache.org/docs/rust/opendal/services/struct.S3.html
# where endpoint, bucket and root are extracted from the repository URL.
[repository]
repository = "opendal:s3"
password = "password"
# 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
[repository.options]
access_key_id = "xxx" # this can be ommited, when AWS config is used
secret_access_key = "xxx" # this can be ommited, when AWS config is used
bucket = "bucket_name"
root = "/path/to/repo"

View File

@ -0,0 +1,11 @@
[repository]
repository = "opendal:s3"
password = "password"
[repository.options]
root = "/"
bucket = "bucket_name"
endpoint = "https://p7v1.ldn.idrivee2-40.com"
region = "auto" # Explicit region is better, else requests are delayed to determine correct region.
access_key_id = "xxx"
secret_access_key = "xxx"

13
config/services/sftp.toml Normal file
View File

@ -0,0 +1,13 @@
# rustic config file to use sftp storage
# Note:
# - currently sftp only works on unix
# - Using sftp with password is not supported yet, use key authentication, e.g. use
# ssh-copy-id user@host
[repository]
repository = "opendal:sftp"
password = "mypassword"
[repository.options]
user = "myuser"
endpoint = "host:port"
root = "path/to/repo"

View File

@ -0,0 +1,8 @@
[repository]
password = "XXXXXX"
repository = "opendal:sftp"
[repository.options]
endpoint = "ssh://XXXXX.your-storagebox.de:23"
user = "XXXXX"
key = "/root/.ssh/id_XXXXX_ed25519"

View File

@ -0,0 +1,11 @@
[repository]
repository = "opendal:webdav"
password = "my-backup-password"
[repository.options]
endpoint = "https://my-owncloud-or-nextcloud-server.com"
# root = "remote.php/webdav/my-folder" # for owncloud
# root = "remote.php/dav/files/<username>" # for nextcloud
username = "user"
# In `Settings -> Security -> App passwords / tokens` you should create a token to be used here.
password = "token"

View File

@ -1,73 +0,0 @@
# Ecosystem
**Note**: This is a work in progress. The crates are not yet published. The
descriptions will be updated as soon as the crates are published. We needed to
reserve some crates, because we discovered that another project recently chose
the same name as `rustic`.
## Crates
### rustic_core - [Link](https://crates.io/crates/rustic_core)
Core functionality for the `rustic` ecosystem. Can be found
[here](https://github.com/rustic-rs/rustic_core).
### rustic_testing (reserved) - [Link](https://crates.io/crates/rustic_testing)
Testing functionality for the `rustic` ecosystem. Can be found in
[crates/rustic_testing](./rustic_testing/).
### rustic_ui (reserved) - [Link](https://crates.io/crates/rustic_ui)
General UI functionality for the `rustic` ecosystem.
### rustic_gui (reserved) - [Link](https://crates.io/crates/rustic_gui)
Graphical UI for `rustic`.
### rustic_tui (reserved) - [Link](https://crates.io/crates/rustic_tui)
Terminal UI for `rustic`.
### rustic_cli (reserved) - [Link](https://crates.io/crates/rustic_cli)
Common used CLI functionality for the `rustic` ecosystem.
### rustic_web (reserved) - [Link](https://crates.io/crates/rustic_web)
Possible WASM/WASI functionality for the `rustic` ecosystem.
### rustic_store (reserved) - [Link](https://crates.io/crates/rustic_store)
Possible store functionality to support a plugin system for the `rustic`
ecosystem.
### rustic_plugins (reserved) - [Link](https://crates.io/crates/rustic_plugins)
Plugin functionality for the `rustic` ecosystem.
### rustic_scheduler (reserved) - [Link](https://crates.io/crates/rustic_scheduler)
Scheduling functionality for the `rustic` ecosystem.
### rustic_daemon (reserved) - [Link](https://crates.io/crates/rustic_daemon)
A daemon for `rustic` and running it as a service.
### rustic_backend (reserved) - [Link](https://crates.io/crates/rustic_backend)
A possible backend implementation for `rustic` to support multi-system backup
management.
### rustic_server (reserved) - [Link](https://crates.io/crates/rustic_server)
A possible server implementation for `rustic` to support client-/server
communication (for example for a web GUI or a TUI).
### rustic_bench (reserved) - [Link](https://crates.io/crates/rustic_bench)
Benchmarking functionality for the `rustic` ecosystem.
### rustic_auth (reserved) - [Link](https://crates.io/crates/rustic_auth)
Authentication functionality for the `rustic` ecosystem.

View File

@ -1,10 +0,0 @@
[package]
name = "rustic_testing"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
aho-corasick = { workspace = true }
once_cell = { workspace = true }
tempfile = { workspace = true }

View File

@ -1,71 +0,0 @@
use aho_corasick::{AhoCorasick, PatternID};
use std::{error::Error, ffi::OsStr};
use tempfile::NamedTempFile;
pub type TestResult<T> = std::result::Result<T, Box<dyn Error>>;
pub fn get_matches<I, P>(patterns: I, output: String) -> TestResult<Vec<(PatternID, usize)>>
where
I: IntoIterator<Item = P>,
P: AsRef<[u8]>,
{
let ac = AhoCorasick::new(patterns)?;
let mut matches = vec![];
for mat in ac.find_iter(output.as_str()) {
add_match_to_vector(&mut matches, mat);
}
Ok(matches)
}
pub fn add_match_to_vector(matches: &mut Vec<(PatternID, usize)>, mat: aho_corasick::Match) {
matches.push((mat.pattern(), mat.end() - mat.start()))
}
pub fn get_temp_file() -> TestResult<NamedTempFile> {
Ok(NamedTempFile::new()?)
}
pub fn files_differ(
path_left: impl AsRef<OsStr>,
path_right: impl AsRef<OsStr>,
) -> TestResult<bool> {
// diff the directories
#[cfg(not(windows))]
{
let proc = std::process::Command::new("diff")
.arg(path_left)
.arg(path_right)
.output()?;
if proc.stdout.is_empty() {
return Ok(false);
}
}
#[cfg(windows)]
{
let proc = std::process::Command::new("fc.exe")
.arg("/L")
.arg(path_left)
.arg(path_right)
.output()?;
let output = String::from_utf8(proc.stdout)?;
dbg!(&output);
let patterns = &["FC: no differences encountered"];
let ac = AhoCorasick::new(patterns)?;
let mut matches = vec![];
for mat in ac.find_iter(output.as_str()) {
matches.push((mat.pattern(), mat.end() - mat.start()));
}
if matches == vec![(PatternID::must(0), 30)] {
return Ok(false);
}
}
Ok(true)
}

149
deny.toml
View File

@ -11,6 +11,9 @@
# Root options
# The graph table configures how the dependency graph is constructed and thus
# which crates the checks are performed against
[graph]
# If 1 or more target triples (and optionally, target_features) are specified,
# only the specified targets will be checked when running `cargo deny check`.
# This means, if a particular package is only ever used as a target specific
@ -21,10 +24,9 @@
# list here is effectively saying which targets you are building for.
targets = [
# The triple can be any string, but only the target triples built in to
# rustc (as of 1.40) can be checked against actual config expressions
# { triple = "x86_64-unknown-linux-musl" },
# "x86_64-unknown-linux-musl",
# You can also specify which target_features you promise are enabled for a
# particular target. target_features are currently not validated against
# the actual valid features supported by the target architecture.
@ -48,6 +50,9 @@ no-default-features = false
# If set, these feature will be enabled when collecting metadata. If `--features`
# is specified on the cmd line they will take precedence over this option.
# features = []
# The output table provides options for how/if diagnostics are outputted
[output]
# When outputting inclusion graphs in diagnostics that include features, this
# option can be used to specify the depth at which feature edges will be added.
# This option is included since the graphs can be quite large and the addition
@ -59,37 +64,24 @@ feature-depth = 1
# More documentation for the advisories section can be found here:
# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html
[advisories]
# The path where the advisory database is cloned/fetched into
db-path = "~/.cargo/advisory-db"
# The path where the advisory databases are cloned/fetched into
db-path = "$CARGO_HOME/advisory-dbs"
# The url(s) of the advisory databases to use
db-urls = ["https://github.com/rustsec/advisory-db"]
# The lint level for security vulnerabilities
vulnerability = "deny"
# The lint level for unmaintained crates
unmaintained = "warn"
# The lint level for crates that have been yanked from their source registry
yanked = "warn"
# The lint level for crates with security notices. Note that as of
# 2019-12-17 there are no security notice advisories in
# https://github.com/rustsec/advisory-db
notice = "warn"
# A list of advisory IDs to ignore. Note that ignored advisories will still
# output a note when they are encountered.
ignore = [
# "RUSTSEC-0000-0000",
# FIXME!: See https://github.com/RustCrypto/RSA/issues/19#issuecomment-1822995643.
# There is no workaround available yet.
"RUSTSEC-2023-0071",
# FIXME!: proc-macro-error's maintainer seems to be unreachable, we use `merge` which is using this.
# `merge` is self-hosted on another platform, we should check if and how to replace it/open upstream
# issue for updating the dependency.
"RUSTSEC-2024-0370",
# { id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" },
# "a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish
# { crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" },
]
# Threshold for security vulnerabilities, any vulnerability with a CVSS score
# lower than the range specified will be ignored. Note that ignored advisories
# will still output a note when they are encountered.
# * None - CVSS Score 0.0
# * Low - CVSS Score 0.1 - 3.9
# * Medium - CVSS Score 4.0 - 6.9
# * High - CVSS Score 7.0 - 8.9
# * Critical - CVSS Score 9.0 - 10.0
# severity-threshold =
# If this is true, then cargo deny will use the git executable to fetch advisory database.
# If this is false, then it uses a built-in git library.
# Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support.
@ -100,46 +92,22 @@ ignore = [
# More documentation for the licenses section can be found here:
# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html
[licenses]
# The lint level for crates which do not have a detectable license
unlicensed = "warn"
# List of explicitly allowed licenses
# See https://spdx.org/licenses/ for list of possible licenses
# [possible values: any SPDX 3.11 short identifier (+ optional exception)].
allow = [
# "MIT",
# "Apache-2.0",
# "Apache-2.0 WITH LLVM-exception",
# "ISC",
# "BSD-3-Clause",
# "CC0-1.0",
# "Unicode-DFS-2016",
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"ISC",
"Unicode-DFS-2016",
"BSD-2-Clause",
"BSD-3-Clause",
"MPL-2.0",
"OpenSSL",
"CC0-1.0",
"Zlib",
]
# List of explicitly disallowed licenses
# See https://spdx.org/licenses/ for list of possible licenses
# [possible values: any SPDX 3.11 short identifier (+ optional exception)].
deny = [
# "Nokia",
]
# Lint level for licenses considered copyleft
copyleft = "warn"
# Blanket approval or denial for OSI-approved or FSF Free/Libre licenses
# * both - The license will be approved if it is both OSI-approved *AND* FSF
# * either - The license will be approved if it is either OSI-approved *OR* FSF
# * osi - The license will be approved if it is OSI approved
# * fsf - The license will be approved if it is FSF Free
# * osi-only - The license will be approved if it is OSI-approved *AND NOT* FSF
# * fsf-only - The license will be approved if it is FSF *AND NOT* OSI-approved
# * neither - This predicate is ignored and the default lint level is used
allow-osi-fsf-free = "either"
# Lint level used when no other predicates are matched
# 1. License isn't in the allow or deny lists
# 2. License isn't copyleft
# 3. License isn't OSI/FSF, or allow-osi-fsf-free = "neither"
default = "deny"
# The confidence threshold for detecting a license from license text.
# The higher the value, the more closely the license text must be to the
# canonical license text of a valid SPDX license file.
@ -149,31 +117,28 @@ confidence-threshold = 0.8
# aren't accepted for every possible crate as with the normal allow list
exceptions = [
# Each entry is the crate and version constraint, and its specific allow
# list
# { allow = ["Zlib"], name = "adler32", version = "*" },
# { allow = ["Zlib"], crate = "adler32" },
]
# Some crates don't have (easily) machine readable licensing information,
# adding a clarification entry for it allows you to manually specify the
# licensing information
# [[licenses.clarify]]
# The name of the crate the clarification applies to
# name = "ring"
# The optional version constraint for the crate
# version = "*"
[[licenses.clarify]]
# The package spec the clarification applies to
crate = "ring"
# The SPDX expression for the license requirements of the crate
# expression = "MIT AND ISC AND OpenSSL"
expression = "MIT AND ISC AND OpenSSL"
# One or more files in the crate's source used as the "source of truth" for
# the license expression. If the contents match, the clarification will be used
# when running the license check, otherwise the clarification will be ignored
# and the crate will be checked normally, which may produce warnings or errors
# depending on the rest of your configuration
# license-files = [
# Each entry is a crate relative path, and the (opaque) hash of its contents
# { path = "LICENSE", hash = 0xbd0eed23 }
# ]
license-files = [
# Each entry is a crate relative path, and the (opaque) hash of its contents
{ path = "LICENSE", hash = 0xbd0eed23 },
]
[licenses.private]
# If true, ignores workspace crates that aren't published, or are only
@ -186,7 +151,6 @@ ignore = false
# not have its license(s) checked
registries = [
# "https://sekretz.com/registry
]
@ -195,7 +159,7 @@ registries = [
# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html
[bans]
# Lint level for when multiple versions of the same crate are detected
multiple-versions = "warn"
multiple-versions = "allow"
# Lint level for when a crate version requirement is `*`
wildcards = "allow"
# The graph highlighting used when creating dotgraphs for crates
@ -215,27 +179,24 @@ external-default-features = "allow"
# List of crates that are allowed. Use with care!
allow = [
# { name = "ansi_term", version = "=0.11.0" },
# "ansi_term@0.11.0",
# { crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" },
]
# List of crates to deny
deny = [
# Each entry the name of a crate and a version range. If version is
# not specified, all versions will be matched.
# { name = "ansi_term", version = "=0.11.0" },
#
# "ansi_term@0.11.0",
# { crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" },
# Wrapper crates can optionally be specified to allow the crate when it
# is a direct dependency of the otherwise banned crate
# { name = "ansi_term", version = "=0.11.0", wrappers = [] },
# { crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] },
]
# List of features to allow/deny
# Each entry the name of a crate and a version range. If version is
# not specified, all versions will be matched.
# [[bans.features]]
# name = "reqwest"
# crate = "reqwest"
# Features to not allow
# deny = ["json"]
# Features to allow
@ -257,8 +218,8 @@ deny = [
# Certain crates/versions that will be skipped when doing duplicate detection.
skip = [
# { name = "ansi_term", version = "=0.11.0" },
# "ansi_term@0.11.0",
# { crate = "ansi_term@0.11.0", reason = "you can specify a reason why it can't be updated/removed" },
]
# Similarly to `skip` allows you to skip certain crates during duplicate
# detection. Unlike skip, it also includes the entire tree of transitive
@ -266,8 +227,8 @@ skip = [
# by default infinite.
skip-tree = [
# { name = "ansi_term", version = "=0.11.0", depth = 20 },
# "ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies
# { crate = "ansi_term@0.11.0", depth = 20 },
]
# This section is considered when running `cargo deny check sources`.
@ -287,9 +248,9 @@ allow-registry = ["https://github.com/rust-lang/crates.io-index"]
allow-git = []
[sources.allow-org]
# 1 or more github.com organizations to allow git sources for
# github.com organizations to allow git sources for
github = ["rustic-rs"]
# 1 or more gitlab.com organizations to allow git sources for
gitlab = [""]
# 1 or more bitbucket.org organizations to allow git sources for
bitbucket = [""]
# gitlab.com organizations to allow git sources for
gitlab = []
# bitbucket.org organizations to allow git sources for
bitbucket = []

View File

@ -19,11 +19,12 @@
"**/*.{json}"
],
"excludes": [
"target/**/*"
"target/**/*",
"CHANGELOG.md"
],
"plugins": [
"https://plugins.dprint.dev/markdown-0.16.3.wasm",
"https://plugins.dprint.dev/toml-0.5.4.wasm",
"https://plugins.dprint.dev/json-0.19.1.wasm"
"https://plugins.dprint.dev/markdown-0.17.8.wasm",
"https://plugins.dprint.dev/toml-0.6.3.wasm",
"https://plugins.dprint.dev/json-0.19.3.wasm"
]
}

10
platform-settings.toml Normal file
View File

@ -0,0 +1,10 @@
[platforms.defaults]
release-features = [
"release",
]
# Check if 'build-dependencies.just' needs to be updated
[platforms.x86_64-unknown-linux-gnu]
additional-features = [
"mount",
]

10
release-plz.toml Normal file
View File

@ -0,0 +1,10 @@
# configuration spec can be found here https://release-plz.ieni.dev/docs/config
[workspace]
git_release_enable = false # we currently use our own release process
pr_draft = true
# dependencies_update = true # We don't want to update dependencies automatically, as currently our dependencies tree is broken somewhere
# changelog_config = "cliff.toml" # Don't use this for now, as it will override the default changelog config
[changelog]
protect_breaking_commits = true

View File

@ -1,11 +1,11 @@
//! Rustic Abscissa Application
use std::env;
use std::{env, process};
use abscissa_core::{
application::{self, AppCell},
application::{self, fatal_error, AppCell},
config::{self, CfgCell},
terminal::component::Terminal,
Application, Component, FrameworkError, StandardPaths,
Application, Component, FrameworkError, FrameworkErrorKind, Shutdown, StandardPaths,
};
use anyhow::Result;
@ -16,6 +16,14 @@ use crate::{commands::EntryPoint, config::RusticConfig};
/// Application state
pub static RUSTIC_APP: AppCell<RusticApp> = AppCell::new();
// Constants
pub mod constants {
pub const RUSTIC_DOCS_URL: &str = "https://rustic.cli.rs/docs";
pub const RUSTIC_DEV_DOCS_URL: &str = "https://rustic.cli.rs/dev-docs";
pub const RUSTIC_CONFIG_DOCS_URL: &str =
"https://github.com/rustic-rs/rustic/blob/main/config/README.md";
}
/// Rustic Application
#[derive(Debug)]
pub struct RusticApp {
@ -95,8 +103,38 @@ impl Application for RusticApp {
env::set_var(env, value);
}
let global_hooks = config.global.hooks.clone();
self.config.set_once(config);
global_hooks.run_before().map_err(|err| -> FrameworkError {
FrameworkErrorKind::ProcessError.context(err).into()
})?;
Ok(())
}
/// Shut down this application gracefully
fn shutdown(&self, shutdown: Shutdown) -> ! {
let exit_code = match shutdown {
Shutdown::Crash => 1,
_ => 0,
};
self.shutdown_with_exitcode(shutdown, exit_code)
}
/// Shut down this application gracefully, exiting with given exit code.
fn shutdown_with_exitcode(&self, shutdown: Shutdown, exit_code: i32) -> ! {
let hooks = &RUSTIC_APP.config().global.hooks;
match shutdown {
Shutdown::Crash => _ = hooks.run_failed(),
_ => _ = hooks.run_after(),
};
_ = hooks.run_finally();
let result = self.state().components().shutdown(self, shutdown);
if let Err(e) = result {
fatal_error(self, &e)
}
process::exit(exit_code);
}
}

View File

@ -7,13 +7,17 @@ pub(crate) mod completions;
pub(crate) mod config;
pub(crate) mod copy;
pub(crate) mod diff;
pub(crate) mod docs;
pub(crate) mod dump;
pub(crate) mod find;
pub(crate) mod forget;
pub(crate) mod init;
pub(crate) mod key;
pub(crate) mod list;
pub(crate) mod ls;
pub(crate) mod merge;
#[cfg(feature = "mount")]
pub(crate) mod mount;
pub(crate) mod prune;
pub(crate) mod repair;
pub(crate) mod repoinfo;
@ -22,114 +26,146 @@ pub(crate) mod self_update;
pub(crate) mod show_config;
pub(crate) mod snapshots;
pub(crate) mod tag;
#[cfg(feature = "tui")]
pub(crate) mod tui;
#[cfg(feature = "webdav")]
pub(crate) mod webdav;
use std::fmt::Debug;
use std::fs::File;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
#[cfg(feature = "mount")]
use crate::commands::mount::MountCmd;
#[cfg(feature = "webdav")]
use crate::commands::webdav::WebDavCmd;
use crate::{
commands::{
backup::BackupCmd, cat::CatCmd, check::CheckCmd, completions::CompletionsCmd,
config::ConfigCmd, copy::CopyCmd, diff::DiffCmd, dump::DumpCmd, forget::ForgetCmd,
init::InitCmd, key::KeyCmd, list::ListCmd, ls::LsCmd, merge::MergeCmd, prune::PruneCmd,
repair::RepairCmd, repoinfo::RepoInfoCmd, restore::RestoreCmd, self_update::SelfUpdateCmd,
show_config::ShowConfigCmd, snapshots::SnapshotCmd, tag::TagCmd,
config::ConfigCmd, copy::CopyCmd, diff::DiffCmd, docs::DocsCmd, dump::DumpCmd,
forget::ForgetCmd, init::InitCmd, key::KeyCmd, list::ListCmd, ls::LsCmd, merge::MergeCmd,
prune::PruneCmd, repair::RepairCmd, repoinfo::RepoInfoCmd, restore::RestoreCmd,
self_update::SelfUpdateCmd, show_config::ShowConfigCmd, snapshots::SnapshotCmd,
tag::TagCmd,
},
config::{progress_options::ProgressOptions, RusticConfig},
{Application, RUSTIC_APP},
config::RusticConfig,
Application, RUSTIC_APP,
};
use abscissa_core::{
config::Override, terminal::ColorChoice, Command, Configurable, FrameworkError,
FrameworkErrorKind, Runnable, Shutdown,
};
use anyhow::{anyhow, Result};
use dialoguer::Password;
use anyhow::Result;
use clap::builder::{
styling::{AnsiColor, Effects},
Styles,
};
use convert_case::{Case, Casing};
use human_panic::setup_panic;
use log::{log, Level};
use rustic_core::{OpenStatus, Repository};
use simplelog::{CombinedLogger, LevelFilter, TermLogger, TerminalMode, WriteLogger};
pub(super) mod constants {
pub(super) const MAX_PASSWORD_RETRIES: usize = 5;
}
use self::find::FindCmd;
/// Rustic Subcommands
/// Subcommands need to be listed in an enum.
#[derive(clap::Parser, Command, Debug, Runnable)]
enum RusticCmd {
/// Backup to the repository
Backup(BackupCmd),
Backup(Box<BackupCmd>),
/// Show raw data of repository files and blobs
Cat(CatCmd),
/// Show raw data of files and blobs in a repository
Cat(Box<CatCmd>),
/// Change the repository configuration
Config(ConfigCmd),
Config(Box<ConfigCmd>),
/// Generate shell completions
Completions(CompletionsCmd),
Completions(Box<CompletionsCmd>),
/// Check the repository
Check(CheckCmd),
Check(Box<CheckCmd>),
/// Copy snapshots to other repositories. Note: The target repositories must be given in the config file!
Copy(CopyCmd),
/// Copy snapshots to other repositories
Copy(Box<CopyCmd>),
/// Compare two snapshots/paths
/// Note that the exclude options only apply for comparison with a local path
Diff(DiffCmd),
/// Compare two snapshots or paths
Diff(Box<DiffCmd>),
/// dump the contents of a file in a snapshot to stdout
Dump(DumpCmd),
/// Open the documentation
Docs(Box<DocsCmd>),
/// Dump the contents of a file within a snapshot to stdout
Dump(Box<DumpCmd>),
/// Find patterns in given snapshots
Find(Box<FindCmd>),
/// Remove snapshots from the repository
Forget(ForgetCmd),
Forget(Box<ForgetCmd>),
/// Initialize a new repository
Init(InitCmd),
Init(Box<InitCmd>),
/// Manage keys
Key(KeyCmd),
/// Manage keys for a repository
Key(Box<KeyCmd>),
/// List repository files
List(ListCmd),
/// List repository files by file type
List(Box<ListCmd>),
#[cfg(feature = "mount")]
/// Mount a repository as read-only filesystem
Mount(Box<MountCmd>),
/// List file contents of a snapshot
Ls(LsCmd),
Ls(Box<LsCmd>),
/// Merge snapshots
Merge(MergeCmd),
Merge(Box<MergeCmd>),
/// Show a detailed overview of the snapshots within the repository
Snapshots(SnapshotCmd),
Snapshots(Box<SnapshotCmd>),
/// Show the configuration which has been read from the config file(s)
ShowConfig(ShowConfigCmd),
ShowConfig(Box<ShowConfigCmd>),
/// Update to the latest rustic release
/// Update to the latest stable rustic release
#[cfg_attr(not(feature = "self-update"), clap(hide = true))]
SelfUpdate(SelfUpdateCmd),
SelfUpdate(Box<SelfUpdateCmd>),
/// Remove unused data or repack repository pack files
Prune(PruneCmd),
Prune(Box<PruneCmd>),
/// Restore a snapshot/path
Restore(RestoreCmd),
/// Restore (a path within) a snapshot
Restore(Box<RestoreCmd>),
/// Repair a snapshot/path
Repair(RepairCmd),
/// Repair a snapshot or the repository index
Repair(Box<RepairCmd>),
/// Show general information about the repository
Repoinfo(RepoInfoCmd),
Repoinfo(Box<RepoInfoCmd>),
/// Change tags of snapshots
Tag(TagCmd),
Tag(Box<TagCmd>),
/// Start a webdav server which allows to access the repository
#[cfg(feature = "webdav")]
Webdav(Box<WebDavCmd>),
}
fn styles() -> Styles {
Styles::styled()
.header(AnsiColor::Red.on_default() | Effects::BOLD)
.usage(AnsiColor::Red.on_default() | Effects::BOLD)
.literal(AnsiColor::Blue.on_default() | Effects::BOLD)
.placeholder(AnsiColor::Green.on_default())
}
/// Entry point for the application. It needs to be a struct to allow using subcommands!
#[derive(clap::Parser, Command, Debug)]
#[command(author, about, name="rustic", version = option_env!("PROJECT_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")))]
#[command(author, about, name="rustic", styles=styles(), version = option_env!("PROJECT_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")))]
pub struct EntryPoint {
#[command(flatten)]
pub config: RusticConfig,
@ -140,6 +176,9 @@ pub struct EntryPoint {
impl Runnable for EntryPoint {
fn run(&self) {
// Set up panic hook for better error messages and logs
setup_panic!();
self.commands.run();
RUSTIC_APP.shutdown(Shutdown::Graceful)
}
@ -162,14 +201,31 @@ impl Configurable<RusticConfig> for EntryPoint {
// That's why it says `_config`, because it's not read at all and therefore not needed.
let mut config = self.config.clone();
// collect "RUSTIC_REPO_OPT*" and "OPENDAL_*" env variables
for (var, value) in std::env::vars() {
if let Some(var) = var.strip_prefix("RUSTIC_REPO_OPT_") {
let var = var.from_case(Case::UpperSnake).to_case(Case::Kebab);
_ = config.repository.be.options.insert(var, value);
} else if let Some(var) = var.strip_prefix("OPENDAL_") {
let var = var.from_case(Case::UpperSnake).to_case(Case::Snake);
_ = config.repository.be.options.insert(var, value);
} else if let Some(var) = var.strip_prefix("RUSTIC_REPO_OPTHOT_") {
let var = var.from_case(Case::UpperSnake).to_case(Case::Kebab);
_ = config.repository.be.options_hot.insert(var, value);
} else if let Some(var) = var.strip_prefix("RUSTIC_REPO_OPTCOLD_") {
let var = var.from_case(Case::UpperSnake).to_case(Case::Kebab);
_ = config.repository.be.options_cold.insert(var, value);
}
}
// collect logs during merging as we start the logger *after* merging
let mut merge_logs = Vec::new();
// get global options from command line / env and config file
if config.global.use_profile.is_empty() {
if config.global.use_profiles.is_empty() {
config.merge_profile("rustic", &mut merge_logs, Level::Info)?;
} else {
for profile in &config.global.use_profile.clone() {
for profile in &config.global.use_profiles.clone() {
config.merge_profile(profile, &mut merge_logs, Level::Warn)?;
}
}
@ -180,33 +236,44 @@ impl Configurable<RusticConfig> for EntryPoint {
.map_err(|e| FrameworkErrorKind::ConfigError.context(e))?,
None => LevelFilter::Info,
};
let term_config = simplelog::ConfigBuilder::new()
.set_time_level(LevelFilter::Off)
.build();
match &config.global.log_file {
None => TermLogger::init(
level_filter,
simplelog::ConfigBuilder::new()
.set_time_level(LevelFilter::Off)
.build(),
term_config,
TerminalMode::Stderr,
ColorChoice::Auto,
)
.map_err(|e| FrameworkErrorKind::ConfigError.context(e))?,
Some(file) => CombinedLogger::init(vec![
TermLogger::new(
Some(file) => {
let file_config = simplelog::ConfigBuilder::new()
.set_time_format_rfc3339()
.build();
let file = File::options()
.create(true)
.append(true)
.open(file)
.map_err(|e| {
FrameworkErrorKind::PathError {
name: Some(file.clone()),
}
.context(e)
})?;
let term_logger = TermLogger::new(
level_filter.min(LevelFilter::Warn),
simplelog::ConfigBuilder::new()
.set_time_level(LevelFilter::Off)
.build(),
term_config,
TerminalMode::Stderr,
ColorChoice::Auto,
),
WriteLogger::new(
level_filter,
simplelog::Config::default(),
File::options().create(true).append(true).open(file)?,
),
])
.map_err(|e| FrameworkErrorKind::ConfigError.context(e))?,
);
CombinedLogger::init(vec![
term_logger,
WriteLogger::new(level_filter, file_config, file),
])
.map_err(|e| FrameworkErrorKind::ConfigError.context(e))?;
}
}
// display logs from merging
@ -216,6 +283,11 @@ impl Configurable<RusticConfig> for EntryPoint {
match &self.commands {
RusticCmd::Forget(cmd) => cmd.override_config(config),
RusticCmd::Copy(cmd) => cmd.override_config(config),
#[cfg(feature = "webdav")]
RusticCmd::Webdav(cmd) => cmd.override_config(config),
#[cfg(feature = "mount")]
RusticCmd::Mount(cmd) => cmd.override_config(config),
// subcommands that don't need special overrides use a catch all
_ => Ok(config),
@ -223,50 +295,6 @@ impl Configurable<RusticConfig> for EntryPoint {
}
}
/// Open the repository with the given config
///
/// # Arguments
///
/// * `config` - The config file
///
/// # Errors
///
/// * [`RepositoryErrorKind::ReadingPasswordFromReaderFailed`] - If reading the password failed
/// * [`RepositoryErrorKind::OpeningPasswordFileFailed`] - If opening the password file failed
/// * [`RepositoryErrorKind::PasswordCommandParsingFailed`] - If parsing the password command failed
/// * [`RepositoryErrorKind::ReadingPasswordFromCommandFailed`] - If reading the password from the command failed
/// * [`RepositoryErrorKind::FromSplitError`] - If splitting the password command failed
///
/// [`RepositoryErrorKind::ReadingPasswordFromReaderFailed`]: crate::error::RepositoryErrorKind::ReadingPasswordFromReaderFailed
/// [`RepositoryErrorKind::OpeningPasswordFileFailed`]: crate::error::RepositoryErrorKind::OpeningPasswordFileFailed
/// [`RepositoryErrorKind::PasswordCommandParsingFailed`]: crate::error::RepositoryErrorKind::PasswordCommandParsingFailed
/// [`RepositoryErrorKind::ReadingPasswordFromCommandFailed`]: crate::error::RepositoryErrorKind::ReadingPasswordFromCommandFailed
/// [`RepositoryErrorKind::FromSplitError`]: crate::error::RepositoryErrorKind::FromSplitError
fn open_repository(config: &Arc<RusticConfig>) -> Result<Repository<ProgressOptions, OpenStatus>> {
let po = config.global.progress_options;
let repo = Repository::new_with_progress(&config.repository, po)?;
match repo.password()? {
// if password is given, directly return the result of find_key_in_backend and don't retry
Some(pass) => {
return Ok(repo.open_with_password(&pass)?);
}
None => {
for _ in 0..constants::MAX_PASSWORD_RETRIES {
let pass = Password::new()
.with_prompt("enter repository password")
.allow_empty_password(true)
.interact()?;
match repo.clone().open_with_password(&pass) {
Ok(repo) => return Ok(repo),
// TODO: fail if error != Password incorrect
Err(_) => continue,
}
}
}
}
Err(anyhow!("incorrect password"))
}
#[cfg(test)]
mod tests {
use crate::commands::EntryPoint;

View File

@ -3,48 +3,58 @@
use std::path::PathBuf;
use crate::{
commands::open_repository,
helpers::bytes_size_to_string,
{status_err, Application, RUSTIC_APP},
commands::{init::init, snapshots::fill_table},
config::hooks::Hooks,
helpers::{bold_cell, bytes_size_to_string, table},
repository::CliRepo,
status_err, Application, RUSTIC_APP,
};
use abscissa_core::{Command, Runnable, Shutdown};
use anyhow::{bail, Context, Result};
use log::{debug, info, warn};
use merge::Merge;
use serde::Deserialize;
use abscissa_core::{Command, Runnable, Shutdown};
use anyhow::{anyhow, bail, Context, Result};
use clap::ValueHint;
use comfy_table::Cell;
use conflate::Merge;
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use rustic_core::{
BackupOptions, ConfigOptions, KeyOptions, LocalSourceFilterOptions, LocalSourceSaveOptions,
ParentOptions, PathList, Repository, SnapshotOptions,
BackupOptions, CommandInput, ConfigOptions, IndexedIds, KeyOptions, LocalSourceFilterOptions,
LocalSourceSaveOptions, ParentOptions, PathList, ProgressBars, Repository, SnapshotOptions,
};
use super::init::init;
/// `backup` subcommand
#[derive(Clone, Command, Default, Debug, clap::Parser, Deserialize, Merge)]
#[serde_as]
#[derive(Clone, Command, Default, Debug, clap::Parser, Serialize, Deserialize, Merge)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
// Note: using cli_sources, sources and source within this struct is a hack to support serde(deny_unknown_fields)
// Note: using cli_sources, sources and snapshots within this struct is a hack to support serde(deny_unknown_fields)
// for deserializing the backup options from TOML
// Unfortunately we cannot work with nested flattened structures, see
// https://github.com/serde-rs/serde/issues/1547
// A drawback is that a wrongly set "source(s) = ..." won't get correct error handling and need to be manually checked, see below.
// A drawback is that a wrongly set "snapshots = ..." won't get correct error handling and need to be manually checked, see below.
#[allow(clippy::struct_excessive_bools)]
pub struct BackupCmd {
/// Backup source (can be specified multiple times), use - for stdin. If no source is given, uses all
/// sources defined in the config file
#[clap(value_name = "SOURCE")]
#[clap(value_name = "SOURCE", value_hint = ValueHint::AnyPath)]
#[merge(skip)]
#[serde(skip)]
cli_sources: Vec<String>,
/// Set filename to be used when backing up from stdin
#[clap(long, value_name = "FILENAME", default_value = "stdin")]
#[clap(long, value_name = "FILENAME", default_value = "stdin", value_hint = ValueHint::FilePath)]
#[merge(skip)]
stdin_filename: String,
/// Start the given command and use its output as stdin
#[clap(long, value_name = "COMMAND")]
#[merge(strategy=conflate::option::overwrite_none)]
stdin_command: Option<CommandInput>,
/// Manually set backup path in snapshot
#[clap(long, value_name = "PATH")]
#[clap(long, value_name = "PATH", value_hint = ValueHint::DirPath)]
#[merge(strategy=conflate::option::overwrite_none)]
as_path: Option<PathBuf>,
/// Ignore save options
@ -52,19 +62,29 @@ pub struct BackupCmd {
#[serde(flatten)]
ignore_save_opts: LocalSourceSaveOptions,
/// Don't scan the backup source for its size - this disables ETA estimation for backup.
#[clap(long)]
#[merge(strategy=conflate::bool::overwrite_false)]
pub no_scan: bool,
/// Output generated snapshot in json format
#[clap(long)]
#[merge(strategy = merge::bool::overwrite_false)]
#[merge(strategy=conflate::bool::overwrite_false)]
json: bool,
/// Don't show any output
/// Show detailed information about generated snapshot
#[clap(long, conflicts_with = "json")]
#[merge(strategy = merge::bool::overwrite_false)]
#[merge(strategy=conflate::bool::overwrite_false)]
long: bool,
/// Don't show any output
#[clap(long, conflicts_with_all = ["json", "long"])]
#[merge(strategy=conflate::bool::overwrite_false)]
quiet: bool,
/// Initialize repository, if it doesn't exist yet
#[clap(long)]
#[merge(strategy = merge::bool::overwrite_false)]
#[merge(strategy=conflate::bool::overwrite_false)]
init: bool,
/// Parent processing options
@ -94,33 +114,51 @@ pub struct BackupCmd {
#[merge(skip)]
config_opts: ConfigOptions,
/// Backup sources
/// Hooks to use
#[clap(skip)]
#[merge(strategy = merge_sources)]
sources: Vec<BackupCmd>,
hooks: Hooks,
/// Backup snapshots to generate
#[clap(skip)]
#[merge(strategy = merge_snapshots)]
snapshots: Vec<BackupCmd>,
/// Backup source, used within config file
#[clap(skip)]
#[merge(skip)]
source: String,
sources: Vec<String>,
}
/// Merge backup sources
/// Merge backup snapshots to generate
///
/// If a source is already defined on left, use that. Else add it.
/// If a snapshot is already defined on left, use that. Else add it.
///
/// # Arguments
///
/// * `left` - Vector of backup sources
pub(crate) fn merge_sources(left: &mut Vec<BackupCmd>, mut right: Vec<BackupCmd>) {
pub(crate) fn merge_snapshots(left: &mut Vec<BackupCmd>, mut right: Vec<BackupCmd>) {
left.append(&mut right);
left.sort_by(|opt1, opt2| opt1.source.cmp(&opt2.source));
left.dedup_by(|opt1, opt2| opt1.source == opt2.source);
left.sort_by(|opt1, opt2| opt1.sources.cmp(&opt2.sources));
left.dedup_by(|opt1, opt2| opt1.sources == opt2.sources);
}
impl Runnable for BackupCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
let config = RUSTIC_APP.config();
// manually check for a "source" field, check is not done by serde, see above.
if !config.backup.sources.is_empty() {
status_err!("key \"sources\" is not valid in the [backup] section!");
RUSTIC_APP.shutdown(Shutdown::Crash);
}
let snapshot_opts = &config.backup.snapshots;
// manually check for a "sources" field, check is not done by serde, see above.
if snapshot_opts.iter().any(|opt| !opt.snapshots.is_empty()) {
status_err!("key \"snapshots\" is not valid in a [[backup.snapshots]] section!");
RUSTIC_APP.shutdown(Shutdown::Crash);
}
if let Err(err) = config.repository.run(|repo| self.inner_run(repo)) {
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
@ -128,11 +166,10 @@ impl Runnable for BackupCmd {
}
impl BackupCmd {
fn inner_run(&self) -> Result<()> {
fn inner_run(&self, repo: CliRepo) -> Result<()> {
let config = RUSTIC_APP.config();
let snapshot_opts = &config.backup.snapshots;
let po = config.global.progress_options;
let repo = Repository::new_with_progress(&config.repository, po)?;
// Initialize repository if --init is set and it is not yet initialized
let repo = if self.init && repo.config_id()?.is_none() {
if config.global.dry_run {
@ -141,31 +178,22 @@ impl BackupCmd {
repo.name
);
}
init(repo, &self.key_opts, &self.config_opts)?
init(repo.0, &self.key_opts, &self.config_opts)?
} else {
open_repository(&config)?
repo.open()?
}
.to_indexed_ids()?;
// manually check for a "source" field, check is not done by serde, see above.
if !config.backup.source.is_empty() {
bail!("key \"source\" is not valid in the [backup] section!");
}
let config_opts = &config.backup.sources;
// manually check for a "sources" field, check is not done by serde, see above.
if config_opts.iter().any(|opt| !opt.sources.is_empty()) {
bail!("key \"sources\" is not valid in a [[backup.sources]] section!");
}
let config_sources: Vec<_> = config_opts
let config_snapshot_sources: Vec<_> = snapshot_opts
.iter()
.map(|opt| -> Result<_> {
Ok(PathList::from_string(&opt.source)?
Ok(PathList::from_iter(&opt.sources)
.sanitize()
.with_context(|| {
format!("error sanitizing source=\"{}\" in config file", opt.source)
format!(
"error sanitizing sources=\"{:?}\" in config file",
opt.sources
)
})?
.merge())
})
@ -178,86 +206,130 @@ impl BackupCmd {
})
.collect();
let sources = match (self.cli_sources.is_empty(), config_opts.is_empty()) {
let snapshot_sources = match (self.cli_sources.is_empty(), snapshot_opts.is_empty()) {
(false, _) => {
let item = PathList::from_strings(&self.cli_sources).sanitize()?;
let item = PathList::from_iter(&self.cli_sources).sanitize()?;
vec![item]
}
(true, false) => {
info!("using all backup sources from config file.");
config_sources.clone()
config_snapshot_sources.clone()
}
(true, true) => {
bail!("no backup source given.");
}
};
for source in sources {
let mut opts = self.clone();
// merge Options from config file, if given
if let Some(idx) = config_sources.iter().position(|s| s == &source) {
info!("merging source={source} section from config file");
opts.merge(config_opts[idx].clone());
}
if let Some(path) = &opts.as_path {
// as_path only works in combination with a single target
if source.len() > 1 {
bail!("as-path only works with a single target!");
}
// merge Options from config file using as_path, if given
if let Some(path) = path.as_os_str().to_str() {
if let Some(idx) = config_opts.iter().position(|opt| opt.source == path) {
info!("merging source=\"{path}\" section from config file");
opts.merge(config_opts[idx].clone());
}
}
}
// merge "backup" section from config file, if given
opts.merge(config.backup.clone());
let backup_opts = BackupOptions::default()
.stdin_filename(opts.stdin_filename)
.as_path(opts.as_path)
.parent_opts(opts.parent_opts)
.ignore_save_opts(opts.ignore_save_opts)
.ignore_filter_opts(opts.ignore_filter_opts)
.dry_run(config.global.dry_run);
let snap = repo.backup(&backup_opts, source.clone(), opts.snap_opts.to_snapshot()?)?;
if opts.json {
let mut stdout = std::io::stdout();
serde_json::to_writer_pretty(&mut stdout, &snap)?;
} else if !opts.quiet {
let summary = snap.summary.unwrap();
println!(
"Files: {} new, {} changed, {} unchanged",
summary.files_new, summary.files_changed, summary.files_unmodified
);
println!(
"Dirs: {} new, {} changed, {} unchanged",
summary.dirs_new, summary.dirs_changed, summary.dirs_unmodified
);
debug!("Data Blobs: {} new", summary.data_blobs);
debug!("Tree Blobs: {} new", summary.tree_blobs);
println!(
"Added to the repo: {} (raw: {})",
bytes_size_to_string(summary.data_added_packed),
bytes_size_to_string(summary.data_added)
);
println!(
"processed {} files, {}",
summary.total_files_processed,
bytes_size_to_string(summary.total_bytes_processed)
);
println!("snapshot {} successfully saved.", snap.id);
}
info!("backup of {source} done.");
if snapshot_sources.is_empty() {
return Ok(());
}
let hooks = config.backup.hooks.with_context("backup");
hooks.use_with(|| -> Result<_> {
let mut is_err = false;
for sources in snapshot_sources {
let mut opts = self.clone();
// merge Options from config file, if given
if let Some(idx) = config_snapshot_sources.iter().position(|s| s == &sources) {
info!("merging sources={sources} section from config file");
opts.merge(snapshot_opts[idx].clone());
}
if let Err(err) = opts.backup_snapshot(sources.clone(), &repo) {
error!("error backing up {sources}: {err}");
is_err = true;
}
}
if is_err {
Err(anyhow!("Not all snapshots were generated successfully!"))
} else {
Ok(())
}
})
}
fn backup_snapshot<P: ProgressBars, S: IndexedIds>(
mut self,
source: PathList,
repo: &Repository<P, S>,
) -> Result<()> {
let config = RUSTIC_APP.config();
let snapshot_opts = &config.backup.snapshots;
if let Some(path) = &self.as_path {
// as_path only works in combination with a single target
if source.len() > 1 {
bail!("as-path only works with a single source!");
}
// merge Options from config file using as_path, if given
if let Some(path) = path.as_os_str().to_str() {
if let Some(idx) = snapshot_opts
.iter()
.position(|opt| opt.sources == vec![path])
{
info!("merging snapshot=\"{path}\" section from config file");
self.merge(snapshot_opts[idx].clone());
}
}
}
// use the correct source-specific hooks
let hooks = self.hooks.with_context(&format!("backup {source}"));
// merge "backup" section from config file, if given
self.merge(config.backup.clone());
let backup_opts = BackupOptions::default()
.stdin_filename(self.stdin_filename)
.stdin_command(self.stdin_command)
.as_path(self.as_path)
.parent_opts(self.parent_opts)
.ignore_save_opts(self.ignore_save_opts)
.ignore_filter_opts(self.ignore_filter_opts)
.no_scan(self.no_scan)
.dry_run(config.global.dry_run);
let snap = hooks.use_with(|| -> Result<_> {
Ok(repo.backup(&backup_opts, &source, self.snap_opts.to_snapshot()?)?)
})?;
if self.json {
let mut stdout = std::io::stdout();
serde_json::to_writer_pretty(&mut stdout, &snap)?;
} else if self.long {
let mut table = table();
let add_entry = |title: &str, value: String| {
_ = table.add_row([bold_cell(title), Cell::new(value)]);
};
fill_table(&snap, add_entry);
println!("{table}");
} else if !self.quiet {
let summary = snap.summary.unwrap();
println!(
"Files: {} new, {} changed, {} unchanged",
summary.files_new, summary.files_changed, summary.files_unmodified
);
println!(
"Dirs: {} new, {} changed, {} unchanged",
summary.dirs_new, summary.dirs_changed, summary.dirs_unmodified
);
debug!("Data Blobs: {} new", summary.data_blobs);
debug!("Tree Blobs: {} new", summary.tree_blobs);
println!(
"Added to the repo: {} (raw: {})",
bytes_size_to_string(summary.data_added_packed),
bytes_size_to_string(summary.data_added)
);
println!(
"processed {} files, {}",
summary.total_files_processed,
bytes_size_to_string(summary.total_bytes_processed)
);
println!("snapshot {} successfully saved.", snap.id);
}
info!("backup of {source} done.");
Ok(())
}
}

View File

@ -1,6 +1,6 @@
//! `cat` subcommand
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
use crate::{status_err, Application, RUSTIC_APP};
use abscissa_core::{Command, Runnable, Shutdown};
@ -59,19 +59,25 @@ impl Runnable for CatCmd {
impl CatCmd {
fn inner_run(&self) -> Result<()> {
let config = RUSTIC_APP.config();
let repo = open_repository(&config)?;
let data = match &self.cmd {
CatSubCmd::Config => repo.cat_file(FileType::Config, "")?,
CatSubCmd::Index(opt) => repo.cat_file(FileType::Index, &opt.id)?,
CatSubCmd::Snapshot(opt) => repo.cat_file(FileType::Snapshot, &opt.id)?,
// special treatment for 'cat'ing blobs: read the index and use it to locate the blob
CatSubCmd::TreeBlob(opt) => repo.to_indexed()?.cat_blob(BlobType::Tree, &opt.id)?,
CatSubCmd::DataBlob(opt) => repo.to_indexed()?.cat_blob(BlobType::Data, &opt.id)?,
// special treatment for 'cat'ing a tree within a snapshot
CatSubCmd::Tree(opt) => repo
.to_indexed()?
.cat_tree(&opt.snap, |sn| config.snapshot_filter.matches(sn))?,
CatSubCmd::Config => config
.repository
.run_open(|repo| Ok(repo.cat_file(FileType::Config, "")?))?,
CatSubCmd::Index(opt) => config
.repository
.run_open(|repo| Ok(repo.cat_file(FileType::Index, &opt.id)?))?,
CatSubCmd::Snapshot(opt) => config
.repository
.run_open(|repo| Ok(repo.cat_file(FileType::Snapshot, &opt.id)?))?,
CatSubCmd::TreeBlob(opt) => config
.repository
.run_indexed(|repo| Ok(repo.cat_blob(BlobType::Tree, &opt.id)?))?,
CatSubCmd::DataBlob(opt) => config
.repository
.run_indexed(|repo| Ok(repo.cat_blob(BlobType::Data, &opt.id)?))?,
CatSubCmd::Tree(opt) => config.repository.run_indexed(|repo| {
Ok(repo.cat_tree(&opt.snap, |sn| config.snapshot_filter.matches(sn))?)
})?,
};
println!("{}", String::from_utf8(data.to_vec())?);

View File

@ -1,14 +1,18 @@
//! `check` subcommand
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
use crate::{repository::CliOpenRepo, status_err, Application, RUSTIC_APP};
use abscissa_core::{Command, Runnable, Shutdown};
use anyhow::Result;
use rustic_core::CheckOptions;
use rustic_core::{CheckOptions, SnapshotGroupCriterion};
/// `check` subcommand
#[derive(clap::Parser, Command, Debug)]
pub(crate) struct CheckCmd {
/// Snapshots to check. If none is given, use filter options to filter from all snapshots
#[clap(value_name = "ID")]
ids: Vec<String>,
/// Check options
#[clap(flatten)]
opts: CheckOptions,
@ -16,7 +20,11 @@ pub(crate) struct CheckCmd {
impl Runnable for CheckCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
if let Err(err) = RUSTIC_APP
.config()
.repository
.run_open(|repo| self.inner_run(repo))
{
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
@ -24,10 +32,18 @@ impl Runnable for CheckCmd {
}
impl CheckCmd {
fn inner_run(&self) -> Result<()> {
fn inner_run(&self, repo: CliOpenRepo) -> Result<()> {
let config = RUSTIC_APP.config();
let repo = open_repository(&config)?;
repo.check(self.opts)?;
let groups = repo.get_snapshot_group(&self.ids, SnapshotGroupCriterion::new(), |sn| {
config.snapshot_filter.matches(sn)
})?;
let trees = groups
.into_iter()
.flat_map(|(_, snaps)| snaps)
.map(|snap| snap.tree)
.collect();
repo.check_with_trees(self.opts, trees)?;
Ok(())
}
}

View File

@ -53,6 +53,7 @@ mod tests {
fn test_completions() {
generate_completion(shells::Bash, &mut std::io::sink());
generate_completion(shells::Fish, &mut std::io::sink());
generate_completion(shells::PowerShell, &mut std::io::sink());
generate_completion(shells::Zsh, &mut std::io::sink());
}
}

View File

@ -1,6 +1,6 @@
//! `config` subcommand
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
use crate::{status_err, Application, RUSTIC_APP};
use abscissa_core::{Command, Runnable, Shutdown};
@ -28,9 +28,10 @@ impl Runnable for ConfigCmd {
impl ConfigCmd {
fn inner_run(&self) -> Result<()> {
let config = RUSTIC_APP.config();
let repo = open_repository(&config)?;
let changed = repo.apply_config(&self.config_opts)?;
let changed = config
.repository
.run_open(|repo| Ok(repo.apply_config(&self.config_opts)?))?;
if changed {
println!("saved new config");

View File

@ -1,44 +1,67 @@
//! `copy` subcommand
use crate::{
commands::open_repository, helpers::table_with_titles, status_err, Application, RUSTIC_APP,
commands::init::init_password,
helpers::table_with_titles,
repository::{CliIndexedRepo, CliRepo},
status_err, Application, RusticConfig, RUSTIC_APP,
};
use abscissa_core::{Command, Runnable, Shutdown};
use abscissa_core::{config::Override, Command, FrameworkError, Runnable, Shutdown};
use anyhow::{bail, Result};
use log::{error, info};
use conflate::Merge;
use log::{error, info, log, Level};
use serde::{Deserialize, Serialize};
use merge::Merge;
use serde::Deserialize;
use rustic_core::{CopySnapshot, Id, KeyOptions, Repository, RepositoryOptions};
use rustic_core::{repofile::SnapshotFile, CopySnapshot, Id, KeyOptions};
/// `copy` subcommand
#[derive(clap::Parser, Command, Debug)]
pub(crate) struct CopyCmd {
#[derive(clap::Parser, Command, Default, Clone, Debug, Serialize, Deserialize, Merge)]
pub struct CopyCmd {
/// Snapshots to copy. If none is given, use filter options to filter from all snapshots.
#[clap(value_name = "ID")]
#[serde(skip)]
#[merge(skip)]
ids: Vec<String>,
/// Initialize non-existing target repositories
#[clap(long)]
#[serde(skip)]
#[merge(skip)]
init: bool,
/// Target repository (can be specified multiple times)
#[clap(long = "target", value_name = "TARGET")]
#[merge(strategy=conflate::vec::overwrite_empty)]
targets: Vec<String>,
/// Key options (when using --init)
#[clap(flatten, next_help_heading = "Key options (when using --init)")]
#[serde(skip)]
#[merge(skip)]
key_opts: KeyOptions,
}
/// Target repository options
#[derive(Default, Clone, Debug, Deserialize, Merge)]
pub struct Targets {
/// Target repositories
#[merge(strategy = merge::vec::overwrite_empty)]
targets: Vec<RepositoryOptions>,
impl Override<RusticConfig> for CopyCmd {
// Process the given command line options, overriding settings from
// a configuration file using explicit flags taken from command-line
// arguments.
fn override_config(&self, mut config: RusticConfig) -> Result<RusticConfig, FrameworkError> {
let mut self_config = self.clone();
// merge "copy" section from config file, if given
self_config.merge(config.copy);
config.copy = self_config;
Ok(config)
}
}
impl Runnable for CopyCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
let config = RUSTIC_APP.config();
if config.copy.targets.is_empty() {
status_err!("No target given. Please specify at least 1 target either in the profile or using --target!");
RUSTIC_APP.shutdown(Shutdown::Crash);
}
if let Err(err) = config.repository.run_indexed(|repo| self.inner_run(repo)) {
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
@ -46,15 +69,8 @@ impl Runnable for CopyCmd {
}
impl CopyCmd {
fn inner_run(&self) -> Result<()> {
fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
let config = RUSTIC_APP.config();
if config.copy.targets.is_empty() {
status_err!("no [[copy.targets]] section in config file found!");
RUSTIC_APP.shutdown(Shutdown::Crash);
}
let repo = open_repository(&config)?.to_indexed()?;
let mut snapshots = if self.ids.is_empty() {
repo.get_matching_snapshots(|sn| config.snapshot_filter.matches(sn))?
} else {
@ -63,71 +79,86 @@ impl CopyCmd {
// sort for nicer output
snapshots.sort_unstable();
let poly = repo.config().poly()?;
for target_opt in &config.copy.targets {
let repo_dest =
Repository::new_with_progress(target_opt, config.global.progress_options)?;
let repo_dest = if self.init && repo_dest.config_id()?.is_none() {
if config.global.dry_run {
error!(
"cannot initialize target {} in dry-run mode!",
repo_dest.name
);
continue;
}
let mut config_dest = repo.config().clone();
config_dest.id = Id::random();
let pass = repo_dest.password()?.unwrap();
repo_dest.init_with_config(&pass, &self.key_opts, config_dest)?
} else {
repo_dest.open()?
};
info!("copying to target {}...", repo_dest.name);
if poly != repo_dest.config().poly()? {
bail!("cannot copy to repository with different chunker parameter (re-chunking not implemented)!");
for target in &config.copy.targets {
let mut merge_logs = Vec::new();
let mut target_config = RusticConfig::default();
target_config.merge_profile(target, &mut merge_logs, Level::Error)?;
// display logs from merging
for (level, merge_log) in merge_logs {
log!(level, "{}", merge_log);
}
let snaps = repo_dest.relevant_copy_snapshots(
|sn| !self.ids.is_empty() || config.snapshot_filter.matches(sn),
&snapshots,
)?;
let mut table =
table_with_titles(["ID", "Time", "Host", "Label", "Tags", "Paths", "Status"]);
for CopySnapshot { relevant, sn } in snaps.iter() {
let tags = sn.tags.formatln();
let paths = sn.paths.formatln();
let time = sn.time.format("%Y-%m-%d %H:%M:%S").to_string();
_ = table.add_row([
&sn.id.to_string(),
&time,
&sn.hostname,
&sn.label,
&tags,
&paths,
&(if *relevant { "to copy" } else { "existing" }).to_string(),
]);
}
println!("{table}");
let count = snaps.iter().filter(|sn| sn.relevant).count();
if count > 0 {
if config.global.dry_run {
info!("would have copied {count} snapshots.");
} else {
repo.copy(
&repo_dest.to_indexed_ids()?,
snaps
.iter()
.filter_map(|CopySnapshot { relevant, sn }| relevant.then_some(sn)),
)?;
}
} else {
info!("nothing to copy.");
let target_opt = &target_config.repository;
if let Err(err) =
target_opt.run(|target_repo| self.copy(&repo, target_repo, &snapshots))
{
error!("error copying to target: {err}");
}
}
Ok(())
}
fn copy(
&self,
repo: &CliIndexedRepo,
target_repo: CliRepo,
snapshots: &[SnapshotFile],
) -> Result<()> {
let config = RUSTIC_APP.config();
info!("copying to target {}...", target_repo.name);
let target_repo = if self.init && target_repo.config_id()?.is_none() {
let mut config_dest = repo.config().clone();
config_dest.id = Id::random().into();
let pass = init_password(&target_repo)?;
target_repo
.0
.init_with_config(&pass, &self.key_opts, config_dest)?
} else {
target_repo.open()?
};
if repo.config().poly()? != target_repo.config().poly()? {
bail!("cannot copy to repository with different chunker parameter (re-chunking not implemented)!");
}
let snaps = target_repo.relevant_copy_snapshots(
|sn| !self.ids.is_empty() || config.snapshot_filter.matches(sn),
snapshots,
)?;
let mut table =
table_with_titles(["ID", "Time", "Host", "Label", "Tags", "Paths", "Status"]);
for CopySnapshot { relevant, sn } in snaps.iter() {
let tags = sn.tags.formatln();
let paths = sn.paths.formatln();
let time = sn.time.format("%Y-%m-%d %H:%M:%S").to_string();
_ = table.add_row([
&sn.id.to_string(),
&time,
&sn.hostname,
&sn.label,
&tags,
&paths,
&(if *relevant { "to copy" } else { "existing" }).to_string(),
]);
}
println!("{table}");
let count = snaps.iter().filter(|sn| sn.relevant).count();
if count > 0 {
if config.global.dry_run {
info!("would have copied {count} snapshots.");
} else {
repo.copy(
&target_repo.to_indexed_ids()?,
snaps
.iter()
.filter_map(|CopySnapshot { relevant, sn }| relevant.then_some(sn)),
)?;
}
} else {
info!("nothing to copy.");
}
Ok(())
}
}

View File

@ -1,17 +1,22 @@
//! `diff` subcommand
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
use crate::{repository::CliIndexedRepo, status_err, Application, RUSTIC_APP};
use abscissa_core::{Command, Runnable, Shutdown};
use clap::ValueHint;
use log::debug;
use std::path::{Path, PathBuf};
use std::{
fmt::Display,
path::{Path, PathBuf},
};
use anyhow::{bail, Context, Result};
use rustic_core::{
repofile::{BlobType, Node, NodeType},
repofile::{Node, NodeType},
IndexedFull, LocalDestination, LocalSource, LocalSourceFilterOptions, LocalSourceSaveOptions,
LsOptions, ReadSourceEntry, Repository, RusticResult,
LsOptions, ReadSource, ReadSourceEntry, Repository, RusticResult,
};
/// `diff` subcommand
@ -22,7 +27,7 @@ pub(crate) struct DiffCmd {
snap1: String,
/// New snapshot/path or local path [default for PATH2: PATH1]
#[clap(value_name = "SNAPSHOT2[:PATH2]|PATH2")]
#[clap(value_name = "SNAPSHOT2[:PATH2]|PATH2", value_hint = ValueHint::AnyPath)]
snap2: String,
/// show differences in metadata
@ -33,6 +38,10 @@ pub(crate) struct DiffCmd {
#[clap(long)]
no_content: bool,
/// only show differences for identical files, this can be used for a bitrot test on the local path
#[clap(long, conflicts_with = "no_content")]
only_identical: bool,
/// Ignore options
#[clap(flatten)]
ignore_opts: LocalSourceFilterOptions,
@ -40,7 +49,11 @@ pub(crate) struct DiffCmd {
impl Runnable for DiffCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
if let Err(err) = RUSTIC_APP
.config()
.repository
.run_indexed(|repo| self.inner_run(repo))
{
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
@ -48,11 +61,9 @@ impl Runnable for DiffCmd {
}
impl DiffCmd {
fn inner_run(&self) -> Result<()> {
fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
let config = RUSTIC_APP.config();
let repo = open_repository(&config)?.to_indexed()?;
let (id1, path1) = arg_to_snap_path(&self.snap1, "");
let (id2, path2) = arg_to_snap_path(&self.snap2, path1);
@ -92,6 +103,7 @@ impl DiffCmd {
&self.ignore_opts,
&[&path2],
)?
.entries()
.map(|item| -> RusticResult<_> {
let ReadSourceEntry { path, node, .. } = item?;
let path = if is_dir {
@ -104,13 +116,21 @@ impl DiffCmd {
Ok((path, node))
});
diff(
repo.ls(&node1, &LsOptions::default())?,
src,
self.no_content,
|path, node1, _node2| identical_content_local(&local, &repo, path, node1),
self.metadata,
)?;
if self.only_identical {
diff_identical(
repo.ls(&node1, &LsOptions::default())?,
src,
|path, node1, _node2| identical_content_local(&local, &repo, path, node1),
)?;
} else {
diff(
repo.ls(&node1, &LsOptions::default())?,
src,
self.no_content,
|path, node1, _node2| identical_content_local(&local, &repo, path, node1),
self.metadata,
)?;
}
}
(None, _) => {
bail!("cannot use local path as first argument");
@ -133,6 +153,7 @@ impl DiffCmd {
/// A tuple of the snapshot id and the path
fn arg_to_snap_path<'a>(arg: &'a str, default_path: &'a str) -> (Option<&'a str>, &'a str) {
match arg.split_once(':') {
Some(("local", path)) => (None, path),
Some((id, path)) => (Some(id), path),
None => {
if arg.contains('/') {
@ -174,7 +195,7 @@ fn identical_content_local<P, S: IndexedFull>(
};
for id in node.content.iter().flatten() {
let ie = repo.get_index_entry(BlobType::Data, id)?;
let ie = repo.get_index_entry(id)?;
let length = ie.data_length();
if !id.blob_matches_reader(length as usize, &mut open_file) {
return Ok(false);
@ -183,6 +204,102 @@ fn identical_content_local<P, S: IndexedFull>(
Ok(true)
}
/// Statistics about the differences listed with the [`DiffCmd`] command
#[derive(Default)]
struct DiffStatistics {
files_added: usize,
files_removed: usize,
files_changed: usize,
directories_added: usize,
directories_removed: usize,
others_added: usize,
others_removed: usize,
node_type_changed: usize,
metadata_changed: usize,
symlink_added: usize,
symlink_removed: usize,
symlink_changed: usize,
}
impl DiffStatistics {
fn removed_node(&mut self, node_type: &NodeType) {
match node_type {
NodeType::File => self.files_removed += 1,
NodeType::Dir => self.directories_removed += 1,
NodeType::Symlink { .. } => self.symlink_removed += 1,
_ => self.others_removed += 1,
}
}
fn added_node(&mut self, node_type: &NodeType) {
match node_type {
NodeType::File => self.files_added += 1,
NodeType::Dir => self.directories_added += 1,
NodeType::Symlink { .. } => self.symlink_added += 1,
_ => self.others_added += 1,
}
}
fn changed_file(&mut self) {
self.files_changed += 1;
}
fn changed_node_type(&mut self) {
self.node_type_changed += 1;
}
fn changed_metadata(&mut self) {
self.metadata_changed += 1;
}
fn changed_symlink(&mut self) {
self.symlink_changed += 1;
}
}
impl Display for DiffStatistics {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!(
"Files :\t{} new,\t{} removed,\t{} changed\n",
self.files_added, self.files_removed, self.files_changed
))?;
// symlink
if self.symlink_added != 0 || self.symlink_removed != 0 || self.symlink_changed != 0 {
f.write_fmt(format_args!(
"Symlinks:\t{} new,\t{} removed,\t{} changed\n",
self.symlink_added, self.symlink_removed, self.symlink_changed
))?;
}
f.write_fmt(format_args!(
"Dirs :\t{} new,\t{} removed\n",
self.directories_added, self.directories_removed
))?;
if self.others_added != 0 || self.others_removed != 0 {
f.write_fmt(format_args!(
"Others :\t{} new,\t{} removed\n",
self.others_added, self.others_removed
))?;
}
// node type
if self.node_type_changed != 0 {
f.write_fmt(format_args!(
"NodeType:\t{} changed\n",
self.node_type_changed
))?;
}
// metadata
if self.metadata_changed != 0 {
f.write_fmt(format_args!(
"Metadata:\t{} changed\n",
self.metadata_changed
))?;
}
Ok(())
}
}
/// Compare two streams of nodes and print the differences
///
/// # Arguments
@ -206,40 +323,59 @@ fn diff(
let mut item1 = tree_streamer1.next().transpose()?;
let mut item2 = tree_streamer2.next().transpose()?;
let mut diff_statistics = DiffStatistics::default();
loop {
match (&item1, &item2) {
(None, None) => break,
(Some(i1), None) => {
println!("- {:?}", i1.0);
diff_statistics.removed_node(&i1.1.node_type);
item1 = tree_streamer1.next().transpose()?;
}
(None, Some(i2)) => {
println!("+ {:?}", i2.0);
diff_statistics.added_node(&i2.1.node_type);
item2 = tree_streamer2.next().transpose()?;
}
(Some(i1), Some(i2)) if i1.0 < i2.0 => {
println!("- {:?}", i1.0);
diff_statistics.removed_node(&i1.1.node_type);
item1 = tree_streamer1.next().transpose()?;
}
(Some(i1), Some(i2)) if i1.0 > i2.0 => {
println!("+ {:?}", i2.0);
diff_statistics.added_node(&i2.1.node_type);
item2 = tree_streamer2.next().transpose()?;
}
(Some(i1), Some(i2)) => {
let path = &i1.0;
let node1 = &i1.1;
let node2 = &i2.1;
let are_both_symlink = matches!(&node1.node_type, NodeType::Symlink { .. })
&& matches!(&node2.node_type, NodeType::Symlink { .. });
match &node1.node_type {
tpe if tpe != &node2.node_type => println!("T {path:?}"), // type was changed
// if node1.node_type != node2.node_type, they could be different symlinks,
// for this reason we check:
// that their type is different AND that they are not both symlinks
tpe if tpe != &node2.node_type && !are_both_symlink => {
// type was changed
println!("T {path:?}");
diff_statistics.changed_node_type();
}
NodeType::File if !no_content && !file_identical(path, node1, node2)? => {
println!("M {path:?}");
diff_statistics.changed_file();
}
NodeType::File if metadata && node1.meta != node2.meta => {
println!("U {path:?}");
diff_statistics.changed_metadata();
}
NodeType::Symlink { .. } => {
if node1.node_type.to_link() != node1.node_type.to_link() {
if node1.node_type.to_link() != node2.node_type.to_link() {
println!("U {path:?}");
diff_statistics.changed_symlink();
}
}
_ => {} // no difference to show
@ -249,6 +385,65 @@ fn diff(
}
}
}
println!("{diff_statistics}");
Ok(())
}
fn diff_identical(
mut tree_streamer1: impl Iterator<Item = RusticResult<(PathBuf, Node)>>,
mut tree_streamer2: impl Iterator<Item = RusticResult<(PathBuf, Node)>>,
file_identical: impl Fn(&Path, &Node, &Node) -> Result<bool>,
) -> Result<()> {
let mut item1 = tree_streamer1.next().transpose()?;
let mut item2 = tree_streamer2.next().transpose()?;
let mut checked: usize = 0;
loop {
match (&item1, &item2) {
(None, None) => break,
(Some(i1), None) => {
let path = &i1.0;
debug!("not checking {}: not present in target", path.display());
item1 = tree_streamer1.next().transpose()?;
}
(None, Some(i2)) => {
let path = &i2.0;
debug!("not checking {}: not present in source", path.display());
item2 = tree_streamer2.next().transpose()?;
}
(Some(i1), Some(i2)) if i1.0 < i2.0 => {
let path = &i1.0;
debug!("not checking {}: not present in target", path.display());
item1 = tree_streamer1.next().transpose()?;
}
(Some(i1), Some(i2)) if i1.0 > i2.0 => {
let path = &i2.0;
debug!("not checking {}: not present in source", path.display());
item2 = tree_streamer2.next().transpose()?;
}
(Some(i1), Some(i2)) => {
let path = &i1.0;
let node1 = &i1.1;
let node2 = &i2.1;
if matches!(&node1.node_type, NodeType::File)
&& matches!(&node2.node_type, NodeType::File)
&& node1.meta == node2.meta
{
debug!("checking {}", path.display());
checked += 1;
if !file_identical(path, node1, node2)? {
println!("M {path:?}");
}
} else {
debug!("not checking {}: metadata changed", path.display());
}
item1 = tree_streamer1.next().transpose()?;
item2 = tree_streamer2.next().transpose()?;
}
}
}
println!("checked {checked} files.");
Ok(())
}

61
src/commands/docs.rs Normal file
View File

@ -0,0 +1,61 @@
//! `docs` subcommand
use abscissa_core::{status_err, Application, Command, Runnable, Shutdown};
use anyhow::Result;
use clap::Subcommand;
use crate::{
application::constants::{RUSTIC_CONFIG_DOCS_URL, RUSTIC_DEV_DOCS_URL, RUSTIC_DOCS_URL},
RUSTIC_APP,
};
#[derive(Command, Debug, Clone, Copy, Default, Subcommand, Runnable)]
enum DocsTypeSubcommand {
#[default]
/// Show the user documentation
User,
/// Show the development documentation
Dev,
/// Show the configuration documentation
Config,
}
/// Opens the documentation in the default browser.
#[derive(Clone, Command, Default, Debug, clap::Parser)]
pub struct DocsCmd {
#[clap(subcommand)]
cmd: Option<DocsTypeSubcommand>,
}
impl Runnable for DocsCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
}
}
impl DocsCmd {
fn inner_run(&self) -> Result<()> {
let user_string = match self.cmd {
// Default to user docs if no subcommand is provided
Some(DocsTypeSubcommand::User) | None => {
open::that(RUSTIC_DOCS_URL)?;
format!("Opening the user documentation at {RUSTIC_DOCS_URL}")
}
Some(DocsTypeSubcommand::Dev) => {
open::that(RUSTIC_DEV_DOCS_URL)?;
format!("Opening the development documentation at {RUSTIC_DEV_DOCS_URL}")
}
Some(DocsTypeSubcommand::Config) => {
open::that(RUSTIC_CONFIG_DOCS_URL)?;
format!("Opening the configuration documentation at {RUSTIC_CONFIG_DOCS_URL}")
}
};
println!("{user_string}");
Ok(())
}
}

View File

@ -1,9 +1,18 @@
//! `dump` subcommand
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
use std::io::{Read, Write};
use crate::{repository::CliIndexedRepo, status_err, Application, RUSTIC_APP};
use abscissa_core::{Command, Runnable, Shutdown};
use anyhow::Result;
use log::warn;
use rustic_core::{
repofile::{Node, NodeType},
vfs::OpenFile,
LsOptions,
};
use tar::{Builder, EntryType, Header};
/// `dump` subcommand
#[derive(clap::Parser, Command, Debug)]
@ -11,11 +20,19 @@ pub(crate) struct DumpCmd {
/// file from snapshot to dump
#[clap(value_name = "SNAPSHOT[:PATH]")]
snap: String,
/// Listing options
#[clap(flatten)]
ls_opts: LsOptions,
}
impl Runnable for DumpCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
if let Err(err) = RUSTIC_APP
.config()
.repository
.run_indexed(|repo| self.inner_run(repo))
{
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
@ -23,16 +40,116 @@ impl Runnable for DumpCmd {
}
impl DumpCmd {
fn inner_run(&self) -> Result<()> {
fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
let config = RUSTIC_APP.config();
let repo = open_repository(&config)?.to_indexed()?;
let node =
repo.node_from_snapshot_path(&self.snap, |sn| config.snapshot_filter.matches(sn))?;
let mut stdout = std::io::stdout();
repo.dump(&node, &mut stdout)?;
if node.is_file() {
repo.dump(&node, &mut stdout)?;
} else {
dump_tar(&repo, &node, &mut stdout, &self.ls_opts)?;
}
Ok(())
}
}
fn dump_tar(
repo: &CliIndexedRepo,
node: &Node,
w: &mut impl Write,
ls_opts: &LsOptions,
) -> Result<()> {
let mut ar = Builder::new(w);
for item in repo.ls(node, ls_opts)? {
let (path, node) = item?;
let mut header = Header::new_gnu();
let entry_type = match &node.node_type {
NodeType::File => EntryType::Regular,
NodeType::Dir => EntryType::Directory,
NodeType::Symlink { .. } => EntryType::Symlink,
NodeType::Dev { .. } => EntryType::Block,
NodeType::Chardev { .. } => EntryType::Char,
NodeType::Fifo => EntryType::Fifo,
NodeType::Socket => {
warn!(
"socket is not supported. Adding {} as empty file",
path.display()
);
EntryType::Regular
}
};
header.set_entry_type(entry_type);
header.set_size(node.meta.size);
if let Some(mode) = node.meta.mode {
// TODO: this is some go-mapped mode, but lower bits are the standard unix mode bits -> is this ok?
header.set_mode(mode);
}
if let Some(uid) = node.meta.uid {
header.set_uid(uid.into());
}
if let Some(gid) = node.meta.gid {
header.set_uid(gid.into());
}
if let Some(user) = &node.meta.user {
header.set_username(user)?;
}
if let Some(group) = &node.meta.group {
header.set_groupname(group)?;
}
if let Some(mtime) = node.meta.mtime {
header.set_mtime(mtime.timestamp().try_into().unwrap_or_default());
}
// handle special files
if node.is_symlink() {
header.set_link_name(node.node_type.to_link())?;
}
match node.node_type {
NodeType::Dev { device } | NodeType::Chardev { device } => {
header.set_device_minor(device as u32)?;
header.set_device_major((device << 32) as u32)?;
}
_ => {}
}
if node.is_file() {
// write file content if this is a regular file
let open_file = OpenFileReader {
repo,
open_file: repo.open_file(&node)?,
offset: 0,
};
ar.append_data(&mut header, path, open_file)?;
} else {
let data: &[u8] = &[];
ar.append_data(&mut header, path, data)?;
}
}
// finish writing
_ = ar.into_inner()?;
Ok(())
}
struct OpenFileReader<'a> {
repo: &'a CliIndexedRepo,
open_file: OpenFile,
offset: usize,
}
impl<'a> Read for OpenFileReader<'a> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let data = self
.repo
.read_file_at(&self.open_file, self.offset, buf.len())
.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?;
let n = data.len();
buf[..n].copy_from_slice(&data);
self.offset += n;
Ok(n)
}
}

155
src/commands/find.rs Normal file
View File

@ -0,0 +1,155 @@
//! `find` subcommand
use std::path::{Path, PathBuf};
use crate::{repository::CliIndexedRepo, status_err, Application, RUSTIC_APP};
use abscissa_core::{Command, Runnable, Shutdown};
use anyhow::Result;
use clap::ValueHint;
use globset::{Glob, GlobBuilder, GlobSetBuilder};
use itertools::Itertools;
use rustic_core::{
repofile::{Node, SnapshotFile},
FindMatches, FindNode, SnapshotGroupCriterion,
};
use super::ls::print_node;
/// `find` subcommand
#[derive(clap::Parser, Command, Debug)]
pub(crate) struct FindCmd {
/// pattern to find (can be specified multiple times)
#[clap(long, value_name = "PATTERN", conflicts_with = "path")]
glob: Vec<String>,
/// pattern to find case-insensitive (can be specified multiple times)
#[clap(long, value_name = "PATTERN", conflicts_with = "path")]
iglob: Vec<String>,
/// exact path to find
#[clap(long, value_name = "PATH", value_hint = ValueHint::AnyPath)]
path: Option<PathBuf>,
/// Snapshots to search in. If none is given, use filter options to filter from all snapshots
#[clap(value_name = "ID")]
ids: Vec<String>,
/// Group snapshots by any combination of host,label,paths,tags
#[clap(
long,
short = 'g',
value_name = "CRITERION",
default_value = "host,label,paths"
)]
group_by: SnapshotGroupCriterion,
/// Show all snapshots instead of summarizing snapshots with identical search results
#[clap(long)]
all: bool,
/// Also show snapshots which don't contain a search result.
#[clap(long)]
show_misses: bool,
/// Show uid/gid instead of user/group
#[clap(long, long("numeric-uid-gid"))]
numeric_id: bool,
}
impl Runnable for FindCmd {
fn run(&self) {
if let Err(err) = RUSTIC_APP
.config()
.repository
.run_indexed(|repo| self.inner_run(repo))
{
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
}
}
impl FindCmd {
fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
let config = RUSTIC_APP.config();
let groups = repo.get_snapshot_group(&self.ids, self.group_by, |sn| {
config.snapshot_filter.matches(sn)
})?;
for (group, mut snapshots) in groups {
snapshots.sort_unstable();
if !group.is_empty() {
println!("\nsearching in snapshots group {group}...");
}
let ids = snapshots.iter().map(|sn| sn.tree);
if let Some(path) = &self.path {
let FindNode { nodes, matches } = repo.find_nodes_from_path(ids, path)?;
for (idx, g) in &matches
.iter()
.zip(snapshots.iter())
.chunk_by(|(idx, _)| *idx)
{
self.print_identical_snapshots(idx.iter(), g.into_iter().map(|(_, sn)| sn));
if let Some(idx) = idx {
print_node(&nodes[*idx], path, self.numeric_id);
}
}
} else {
let mut builder = GlobSetBuilder::new();
for glob in &self.glob {
_ = builder.add(Glob::new(glob)?);
}
for glob in &self.iglob {
_ = builder.add(GlobBuilder::new(glob).case_insensitive(true).build()?);
}
let globset = builder.build()?;
let matches = |path: &Path, _: &Node| {
globset.is_match(path) || path.file_name().is_some_and(|f| globset.is_match(f))
};
let FindMatches {
paths,
nodes,
matches,
} = repo.find_matching_nodes(ids, &matches)?;
for (idx, g) in &matches
.iter()
.zip(snapshots.iter())
.chunk_by(|(idx, _)| *idx)
{
self.print_identical_snapshots(idx.iter(), g.into_iter().map(|(_, sn)| sn));
for (path_idx, node_idx) in idx {
print_node(&nodes[*node_idx], &paths[*path_idx], self.numeric_id);
}
}
}
}
Ok(())
}
fn print_identical_snapshots<'a>(
&self,
mut idx: impl Iterator,
mut g: impl Iterator<Item = &'a SnapshotFile>,
) {
let empty_result = idx.next().is_none();
let not = if empty_result { "not " } else { "" };
if self.show_misses || !empty_result {
if self.all {
for sn in g {
let time = sn.time.format("%Y-%m-%d %H:%M:%S");
println!("{not}found in {} from {time}", sn.id);
}
} else {
let sn = g.next().unwrap();
let count = g.count();
let time = sn.time.format("%Y-%m-%d %H:%M:%S");
match count {
0 => println!("{not}found in {} from {time}", sn.id),
count => println!("{not}found in {} from {time} (+{count})", sn.id),
};
}
}
}
}

View File

@ -1,16 +1,15 @@
//! `forget` subcommand
use crate::{
commands::open_repository, helpers::table_with_titles, status_err, Application, RusticConfig,
RUSTIC_APP,
};
use crate::repository::CliOpenRepo;
use crate::{helpers::table_with_titles, status_err, Application, RusticConfig, RUSTIC_APP};
use abscissa_core::{config::Override, Shutdown};
use abscissa_core::{Command, FrameworkError, Runnable};
use anyhow::Result;
use merge::Merge;
use serde::Deserialize;
use chrono::Local;
use conflate::Merge;
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DisplayFromStr};
use crate::{commands::prune::PruneCmd, filtering::SnapshotFilter};
@ -63,17 +62,18 @@ impl Override<RusticConfig> for ForgetCmd {
/// Forget options
#[serde_as]
#[derive(Clone, Default, Debug, clap::Parser, Deserialize, Merge)]
#[derive(Clone, Default, Debug, clap::Parser, Serialize, Deserialize, Merge)]
#[serde(default, rename_all = "kebab-case")]
pub struct ForgetOptions {
/// Group snapshots by any combination of host,label,paths,tags (default: "host,label,paths")
#[clap(long, short = 'g', value_name = "CRITERION")]
#[serde_as(as = "Option<DisplayFromStr>")]
#[merge(strategy=conflate::option::overwrite_none)]
group_by: Option<SnapshotGroupCriterion>,
/// Also prune the repository
#[clap(long)]
#[merge(strategy = merge::bool::overwrite_false)]
#[merge(strategy=conflate::bool::overwrite_false)]
prune: bool,
/// Snapshot filter options
@ -89,7 +89,11 @@ pub struct ForgetOptions {
impl Runnable for ForgetCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
if let Err(err) = RUSTIC_APP
.config()
.repository
.run_open(|repo| self.inner_run(repo))
{
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
@ -97,9 +101,11 @@ impl Runnable for ForgetCmd {
}
impl ForgetCmd {
fn inner_run(&self) -> Result<()> {
/// be careful about self vs `RUSTIC_APP.config()` usage
/// only the `RUSTIC_APP.config()` involves the TOML and ENV merged configurations
/// see <https://github.com/rustic-rs/rustic/issues/1242>
fn inner_run(&self, repo: CliOpenRepo) -> Result<()> {
let config = RUSTIC_APP.config();
let repo = open_repository(&config)?;
let group_by = config.forget.group_by.unwrap_or_default();
@ -108,15 +114,26 @@ impl ForgetCmd {
config.forget.filter.matches(sn)
})?
} else {
let now = Local::now();
let item = ForgetGroup {
group: SnapshotGroup::default(),
snapshots: repo
.get_snapshots(&self.ids)?
.into_iter()
.map(|sn| ForgetSnapshot {
snapshot: sn,
keep: false,
reasons: vec!["id argument".to_string()],
.map(|sn| {
if sn.must_keep(now) {
ForgetSnapshot {
snapshot: sn,
keep: true,
reasons: vec!["snapshot".to_string()],
}
} else {
ForgetSnapshot {
snapshot: sn,
keep: false,
reasons: vec!["id argument".to_string()],
}
}
})
.collect(),
};
@ -143,7 +160,7 @@ impl ForgetCmd {
(_, _, true) => {}
}
if self.config.prune {
if config.forget.prune {
let mut prune_opts = self.prune_opts.clone();
prune_opts.opts.ignore_snaps = forget_snaps;
prune_opts.run();

View File

@ -2,11 +2,10 @@
use abscissa_core::{status_err, Command, Runnable, Shutdown};
use anyhow::{bail, Result};
use crate::{Application, RUSTIC_APP};
use dialoguer::Password;
use crate::{repository::CliRepo, Application, RUSTIC_APP};
use rustic_core::{ConfigOptions, KeyOptions, OpenStatus, Repository};
/// `init` subcommand
@ -23,7 +22,11 @@ pub(crate) struct InitCmd {
impl Runnable for InitCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
if let Err(err) = RUSTIC_APP
.config()
.repository
.run(|repo| self.inner_run(repo))
{
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
@ -31,12 +34,9 @@ impl Runnable for InitCmd {
}
impl InitCmd {
fn inner_run(&self) -> Result<()> {
fn inner_run(&self, repo: CliRepo) -> Result<()> {
let config = RUSTIC_APP.config();
let po = config.global.progress_options;
let repo = Repository::new_with_progress(&config.repository, po)?;
// Note: This is again checked in repo.init_with_password(), however we want to inform
// users before they are prompted to enter a password
if repo.config_id()?.is_some() {
@ -51,7 +51,7 @@ impl InitCmd {
);
}
let _ = init(repo, &self.key_opts, &self.config_opts)?;
let _ = init(repo.0, &self.key_opts, &self.config_opts)?;
Ok(())
}
}
@ -69,7 +69,7 @@ impl InitCmd {
/// * [`RepositoryErrorKind::OpeningPasswordFileFailed`] - If opening the password file failed
/// * [`RepositoryErrorKind::ReadingPasswordFromReaderFailed`] - If reading the password failed
/// * [`RepositoryErrorKind::FromSplitError`] - If splitting the password command failed
/// * [`RepositoryErrorKind::PasswordCommandParsingFailed`] - If parsing the password command failed
/// * [`RepositoryErrorKind::PasswordCommandExecutionFailed`] - If executing the password command failed
/// * [`RepositoryErrorKind::ReadingPasswordFromCommandFailed`] - If reading the password from the command failed
///
/// # Returns
@ -79,13 +79,18 @@ impl InitCmd {
/// [`RepositoryErrorKind::OpeningPasswordFileFailed`]: rustic_core::error::RepositoryErrorKind::OpeningPasswordFileFailed
/// [`RepositoryErrorKind::ReadingPasswordFromReaderFailed`]: rustic_core::error::RepositoryErrorKind::ReadingPasswordFromReaderFailed
/// [`RepositoryErrorKind::FromSplitError`]: rustic_core::error::RepositoryErrorKind::FromSplitError
/// [`RepositoryErrorKind::PasswordCommandParsingFailed`]: rustic_core::error::RepositoryErrorKind::PasswordCommandParsingFailed
/// [`RepositoryErrorKind::PasswordCommandExecutionFailed`]: rustic_core::error::RepositoryErrorKind::PasswordCommandExecutionFailed
/// [`RepositoryErrorKind::ReadingPasswordFromCommandFailed`]: rustic_core::error::RepositoryErrorKind::ReadingPasswordFromCommandFailed
pub(crate) fn init<P, S>(
repo: Repository<P, S>,
key_opts: &KeyOptions,
config_opts: &ConfigOptions,
) -> Result<Repository<P, OpenStatus>> {
let pass = init_password(&repo)?;
Ok(repo.init_with_password(&pass, key_opts, config_opts)?)
}
pub(crate) fn init_password<P, S>(repo: &Repository<P, S>) -> Result<String> {
let pass = repo.password()?.unwrap_or_else(|| {
match Password::new()
.with_prompt("enter password for new key")
@ -101,5 +106,5 @@ pub(crate) fn init<P, S>(
}
});
Ok(repo.init_with_password(&pass, key_opts, config_opts)?)
Ok(pass)
}

View File

@ -1,6 +1,6 @@
//! `key` subcommand
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
use crate::{repository::CliOpenRepo, status_err, Application, RUSTIC_APP};
use std::path::PathBuf;
@ -9,7 +9,7 @@ use anyhow::Result;
use dialoguer::Password;
use log::info;
use rustic_core::{KeyOptions, Repository, RepositoryOptions};
use rustic_core::{CommandInput, KeyOptions, RepositoryOptions};
/// `key` subcommand
#[derive(clap::Parser, Command, Debug)]
@ -27,10 +27,18 @@ enum KeySubCmd {
#[derive(clap::Parser, Debug)]
pub(crate) struct AddCmd {
/// New password
#[clap(long)]
pub(crate) new_password: Option<String>,
/// File from which to read the new password
#[clap(long)]
pub(crate) new_password_file: Option<PathBuf>,
/// Command to get the new password from
#[clap(long)]
pub(crate) new_password_command: Option<CommandInput>,
/// Key options
#[clap(flatten)]
pub(crate) key_opts: KeyOptions,
@ -44,7 +52,11 @@ impl Runnable for KeyCmd {
impl Runnable for AddCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
if let Err(err) = RUSTIC_APP
.config()
.repository
.run_open(|repo| self.inner_run(repo))
{
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
@ -52,21 +64,15 @@ impl Runnable for AddCmd {
}
impl AddCmd {
fn inner_run(&self) -> Result<()> {
let config = RUSTIC_APP.config();
fn inner_run(&self, repo: CliOpenRepo) -> Result<()> {
// create new Repository options which just contain password information
let mut pass_opts = RepositoryOptions::default();
pass_opts.password = self.new_password.clone();
pass_opts.password_file = self.new_password_file.clone();
pass_opts.password_command = self.new_password_command.clone();
let repo = open_repository(&config)?;
// create new "artificial" repo using the given password options
let repo_opts = RepositoryOptions {
password_file: self.new_password_file.clone(),
repository: Some(String::new()), // fake repository to make Repository::new() not bail
..Default::default()
};
let repo_newpass = Repository::new(&repo_opts)?;
let pass = repo_newpass
.password()
let pass = pass_opts
.evaluate_password()
.map_err(|err| err.into())
.transpose()
.unwrap_or_else(|| -> Result<_> {

View File

@ -1,24 +1,29 @@
//! `list` subcommand
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
use std::num::NonZero;
use crate::{repository::CliOpenRepo, status_err, Application, RUSTIC_APP};
use abscissa_core::{Command, Runnable, Shutdown};
use anyhow::{bail, Result};
use rustic_core::repofile::{FileType, IndexFile};
use rustic_core::repofile::{IndexFile, IndexId, KeyId, PackId, SnapshotId};
/// `list` subcommand
#[derive(clap::Parser, Command, Debug)]
pub(crate) struct ListCmd {
/// File types to list
#[clap(value_parser=["blobs", "index", "packs", "snapshots", "keys"])]
#[clap(value_parser=["blobs", "indexpacks", "indexcontent", "index", "packs", "snapshots", "keys"])]
tpe: String,
}
impl Runnable for ListCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
if let Err(err) = RUSTIC_APP
.config()
.repository
.run_open(|repo| self.inner_run(repo))
{
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
@ -26,37 +31,73 @@ impl Runnable for ListCmd {
}
impl ListCmd {
fn inner_run(&self) -> Result<()> {
let config = RUSTIC_APP.config();
let repo = open_repository(&config)?;
let tpe = match self.tpe.as_str() {
fn inner_run(&self, repo: CliOpenRepo) -> Result<()> {
match self.tpe.as_str() {
// special treatment for listing blobs: read the index and display it
"blobs" => {
"blobs" | "indexpacks" | "indexcontent" => {
for item in repo.stream_files::<IndexFile>()? {
let (_, index) = item?;
index.packs.into_iter().for_each(|pack| {
for blob in pack.blobs {
println!("{:?} {:?}", blob.tpe, blob.id);
for pack in index.packs {
match self.tpe.as_str() {
"blobs" => {
for blob in pack.blobs {
println!("{:?} {:?}", blob.tpe, blob.id);
}
}
"indexcontent" => {
for blob in pack.blobs {
println!(
"{:?} {:?} {:?} {} {}",
blob.tpe,
blob.id,
pack.id,
blob.length,
blob.uncompressed_length.map_or(0, NonZero::get)
);
}
}
"indexpacks" => println!(
"{:?} {:?} {} {}",
pack.blob_type(),
pack.id,
pack.pack_size(),
pack.time.map_or_else(String::new, |time| format!(
"{}",
time.format("%Y-%m-%d %H:%M:%S")
))
),
t => {
bail!("invalid type: {}", t);
}
}
});
}
}
}
"index" => {
for id in repo.list::<IndexId>()? {
println!("{id:?}");
}
}
"packs" => {
for id in repo.list::<PackId>()? {
println!("{id:?}");
}
}
"snapshots" => {
for id in repo.list::<SnapshotId>()? {
println!("{id:?}");
}
}
"keys" => {
for id in repo.list::<KeyId>()? {
println!("{id:?}");
}
return Ok(());
}
"index" => FileType::Index,
"packs" => FileType::Pack,
"snapshots" => FileType::Snapshot,
"keys" => FileType::Key,
t => {
bail!("invalid type: {}", t);
}
};
for id in repo.list(tpe)? {
println!("{id:?}");
}
Ok(())
}
}

View File

@ -2,7 +2,7 @@
use std::path::Path;
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
use crate::{repository::CliIndexedRepo, status_err, Application, RUSTIC_APP};
use abscissa_core::{Command, Runnable, Shutdown};
use anyhow::Result;
@ -36,13 +36,21 @@ pub(crate) struct LsCmd {
snap: String,
/// show summary
#[clap(long, short = 's')]
#[clap(long, short = 's', conflicts_with = "json")]
summary: bool,
/// show long listing
#[clap(long, short = 'l')]
#[clap(long, short = 'l', conflicts_with = "json")]
long: bool,
/// show listing in json
#[clap(long, conflicts_with_all = ["summary", "long"])]
json: bool,
/// show uid/gid instead of user/group
#[clap(long, long("numeric-uid-gid"))]
numeric_id: bool,
/// Listing options
#[clap(flatten)]
ls_opts: LsOptions,
@ -50,7 +58,11 @@ pub(crate) struct LsCmd {
impl Runnable for LsCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
if let Err(err) = RUSTIC_APP
.config()
.repository
.run_indexed(|repo| self.inner_run(repo))
{
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
@ -61,10 +73,10 @@ impl Runnable for LsCmd {
///
/// This struct is used to print a summary of the ls command.
#[derive(Default)]
struct Summary {
files: usize,
size: u64,
dirs: usize,
pub struct Summary {
pub files: usize,
pub size: u64,
pub dirs: usize,
}
impl Summary {
@ -73,7 +85,7 @@ impl Summary {
/// # Arguments
///
/// * `node` - the node to update the summary with
fn update(&mut self, node: &Node) {
pub fn update(&mut self, node: &Node) {
if node.is_dir() {
self.dirs += 1;
}
@ -84,11 +96,42 @@ impl Summary {
}
}
impl LsCmd {
fn inner_run(&self) -> Result<()> {
let config = RUSTIC_APP.config();
pub trait NodeLs {
fn mode_str(&self) -> String;
fn link_str(&self) -> String;
}
let repo = open_repository(&config)?.to_indexed()?;
impl NodeLs for Node {
fn mode_str(&self) -> String {
format!(
"{:>1}{:>9}",
match self.node_type {
NodeType::Dir => 'd',
NodeType::Symlink { .. } => 'l',
NodeType::Chardev { .. } => 'c',
NodeType::Dev { .. } => 'b',
NodeType::Fifo { .. } => 'p',
NodeType::Socket => 's',
_ => '-',
},
self.meta
.mode
.map(parse_permissions)
.unwrap_or_else(|| "?????????".to_string())
)
}
fn link_str(&self) -> String {
if let NodeType::Symlink { .. } = &self.node_type {
["->", &self.node_type.to_link().to_string_lossy()].join(" ")
} else {
String::new()
}
}
}
impl LsCmd {
fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
let config = RUSTIC_APP.config();
let node =
repo.node_from_snapshot_path(&self.snap, |sn| config.snapshot_filter.matches(sn))?;
@ -99,14 +142,29 @@ impl LsCmd {
let mut summary = Summary::default();
if self.json {
print!("[");
}
let mut first_item = true;
for item in repo.ls(&node, &ls_opts)? {
let (path, node) = item?;
summary.update(&node);
if self.long {
print_node(&node, &path);
if self.json {
if !first_item {
print!(",");
}
print!("{}", serde_json::to_string(&path)?);
} else if self.long {
print_node(&node, &path, self.numeric_id);
} else {
println!("{path:?} ");
println!("{}", path.display());
}
first_item = false;
}
if self.json {
println!("]");
}
if self.summary {
@ -126,34 +184,28 @@ impl LsCmd {
///
/// * `node` - the node to print
/// * `path` - the path of the node
fn print_node(node: &Node, path: &Path) {
pub fn print_node(node: &Node, path: &Path, numeric_uid_gid: bool) {
println!(
"{:>1}{:>9} {:>8} {:>8} {:>9} {:>12} {path:?} {}",
match node.node_type {
NodeType::Dir => 'd',
NodeType::Symlink { .. } => 'l',
NodeType::Chardev { .. } => 'c',
NodeType::Dev { .. } => 'b',
NodeType::Fifo { .. } => 'p',
NodeType::Socket => 's',
_ => '-',
},
node.meta
.mode
.map(parse_permissions)
.unwrap_or_else(|| "?????????".to_string()),
node.meta.user.clone().unwrap_or_else(|| "?".to_string()),
node.meta.group.clone().unwrap_or_else(|| "?".to_string()),
"{:>10} {:>8} {:>8} {:>9} {:>17} {path:?} {}",
node.mode_str(),
if numeric_uid_gid {
node.meta.uid.map(|uid| uid.to_string())
} else {
node.meta.user.clone()
}
.unwrap_or_else(|| "?".to_string()),
if numeric_uid_gid {
node.meta.gid.map(|uid| uid.to_string())
} else {
node.meta.group.clone()
}
.unwrap_or_else(|| "?".to_string()),
node.meta.size,
node.meta
.mtime
.map(|t| t.format("%_d %b %H:%M").to_string())
.map(|t| t.format("%_d %b %Y %H:%M").to_string())
.unwrap_or_else(|| "?".to_string()),
if let NodeType::Symlink { .. } = &node.node_type {
["->", &node.node_type.to_link().to_string_lossy()].join(" ")
} else {
String::new()
}
node.link_str(),
);
}

View File

@ -1,6 +1,6 @@
//! `merge` subcommand
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
use crate::{repository::CliOpenRepo, status_err, Application, RUSTIC_APP};
use abscissa_core::{Command, Runnable, Shutdown};
use anyhow::Result;
use log::info;
@ -31,7 +31,11 @@ pub(super) struct MergeCmd {
impl Runnable for MergeCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
if let Err(err) = RUSTIC_APP
.config()
.repository
.run_open(|repo| self.inner_run(repo))
{
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
@ -39,9 +43,9 @@ impl Runnable for MergeCmd {
}
impl MergeCmd {
fn inner_run(&self) -> Result<()> {
fn inner_run(&self, repo: CliOpenRepo) -> Result<()> {
let config = RUSTIC_APP.config();
let repo = open_repository(&config)?.to_indexed_ids()?;
let repo = repo.to_indexed_ids()?;
let snapshots = if self.ids.is_empty() {
repo.get_matching_snapshots(|sn| config.snapshot_filter.matches(sn))?

148
src/commands/mount.rs Normal file
View File

@ -0,0 +1,148 @@
//! `mount` subcommand
// ignore markdown clippy lints as we use doc-comments to generate clap help texts
#![allow(clippy::doc_markdown)]
mod fusefs;
use fusefs::FuseFS;
use std::{ffi::OsStr, path::PathBuf};
use crate::{repository::CliIndexedRepo, status_err, Application, RusticConfig, RUSTIC_APP};
use abscissa_core::{config::Override, Command, FrameworkError, Runnable, Shutdown};
use anyhow::{anyhow, Result};
use conflate::Merge;
use fuse_mt::{mount, FuseMT};
use rustic_core::vfs::{FilePolicy, IdenticalSnapshot, Latest, Vfs};
use serde::{Deserialize, Serialize};
#[derive(Clone, Command, Default, Debug, clap::Parser, Serialize, Deserialize, Merge)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
pub struct MountCmd {
/// 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}"]
#[clap(long)]
#[merge(strategy=conflate::option::overwrite_none)]
path_template: Option<String>,
/// 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"]
#[clap(long)]
#[merge(strategy=conflate::option::overwrite_none)]
time_template: Option<String>,
/// Don't allow other users to access the mount point
#[clap(long)]
#[merge(strategy=conflate::bool::overwrite_false)]
no_allow_other: bool,
/// How to handle access to files. [default: "forbidden" for hot/cold repositories, else "read"]
#[clap(long)]
#[merge(strategy=conflate::option::overwrite_none)]
file_access: Option<String>,
/// The mount point to use
#[clap(value_name = "PATH")]
#[merge(strategy=conflate::option::overwrite_none)]
mountpoint: Option<PathBuf>,
/// Specify directly which snapshot/path to mount
#[clap(value_name = "SNAPSHOT[:PATH]")]
#[merge(strategy=conflate::option::overwrite_none)]
snapshot_path: Option<String>,
}
impl Override<RusticConfig> for MountCmd {
// Process the given command line options, overriding settings from
// a configuration file using explicit flags taken from command-line
// arguments.
fn override_config(&self, mut config: RusticConfig) -> Result<RusticConfig, FrameworkError> {
let mut self_config = self.clone();
// merge "mount" section from config file, if given
self_config.merge(config.mount);
config.mount = self_config;
Ok(config)
}
}
impl Runnable for MountCmd {
fn run(&self) {
if let Err(err) = RUSTIC_APP
.config()
.repository
.run_indexed(|repo| self.inner_run(repo))
{
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
}
}
impl MountCmd {
fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
let config = RUSTIC_APP.config();
let mountpoint = config
.mount
.mountpoint
.as_ref()
.ok_or_else(|| anyhow!("please specify a mountpoint"))?;
let path_template = config
.mount
.path_template
.clone()
.unwrap_or_else(|| "[{hostname}]/[{label}]/{time}".to_string());
let time_template = config
.mount
.time_template
.clone()
.unwrap_or_else(|| "%Y-%m-%d_%H-%M-%S".to_string());
let sn_filter = |sn: &_| config.snapshot_filter.matches(sn);
let vfs = if let Some(snap_path) = &config.mount.snapshot_path {
let node = repo.node_from_snapshot_path(snap_path, sn_filter)?;
Vfs::from_dir_node(&node)
} else {
let snapshots = repo.get_matching_snapshots(sn_filter)?;
Vfs::from_snapshots(
snapshots,
&path_template,
&time_template,
Latest::AsLink,
IdenticalSnapshot::AsLink,
)?
};
let name_opt = format!("fsname=rusticfs:{}", repo.config().id);
let mut options = vec![
OsStr::new("-o"),
OsStr::new(&name_opt),
OsStr::new("-o"),
OsStr::new("kernel_cache"),
];
if !config.mount.no_allow_other {
options.extend_from_slice(&[
OsStr::new("-o"),
OsStr::new("allow_other"),
OsStr::new("-o"),
OsStr::new("default_permissions"),
]);
}
let file_access = config.mount.file_access.as_ref().map_or_else(
|| {
if repo.config().is_hot == Some(true) {
Ok(FilePolicy::Forbidden)
} else {
Ok(FilePolicy::Read)
}
},
|s| s.parse(),
)?;
let fs = FuseMT::new(FuseFS::new(repo, vfs, file_access), 1);
mount(fs, mountpoint, &options)?;
Ok(())
}
}

View File

@ -0,0 +1,238 @@
#[cfg(not(windows))]
use std::os::unix::prelude::OsStrExt;
use std::{
collections::BTreeMap,
ffi::{CString, OsStr},
path::Path,
sync::RwLock,
time::{Duration, SystemTime},
};
use rustic_core::{
repofile::{Node, NodeType},
vfs::{FilePolicy, OpenFile, Vfs},
IndexedFull, Repository,
};
use fuse_mt::{
CallbackResult, DirectoryEntry, FileAttr, FileType, FilesystemMT, RequestInfo, ResultData,
ResultEmpty, ResultEntry, ResultOpen, ResultReaddir, ResultSlice, ResultXattr, Xattr,
};
use itertools::Itertools;
pub struct FuseFS<P, S> {
repo: Repository<P, S>,
vfs: Vfs,
open_files: RwLock<BTreeMap<u64, OpenFile>>,
now: SystemTime,
file_policy: FilePolicy,
}
impl<P, S: IndexedFull> FuseFS<P, S> {
pub(crate) fn new(repo: Repository<P, S>, vfs: Vfs, file_policy: FilePolicy) -> Self {
let open_files = RwLock::new(BTreeMap::new());
Self {
repo,
vfs,
open_files,
now: SystemTime::now(),
file_policy,
}
}
fn node_from_path(&self, path: &Path) -> Result<Node, i32> {
self.vfs
.node_from_path(&self.repo, path)
.map_err(|_| libc::ENOENT)
}
fn dir_entries_from_path(&self, path: &Path) -> Result<Vec<Node>, i32> {
self.vfs
.dir_entries_from_path(&self.repo, path)
.map_err(|_| libc::ENOENT)
}
}
fn node_to_filetype(node: &Node) -> FileType {
match node.node_type {
NodeType::File => FileType::RegularFile,
NodeType::Dir => FileType::Directory,
NodeType::Symlink { .. } => FileType::Symlink,
NodeType::Chardev { .. } => FileType::CharDevice,
NodeType::Dev { .. } => FileType::BlockDevice,
NodeType::Fifo => FileType::NamedPipe,
NodeType::Socket => FileType::Socket,
}
}
fn node_type_to_rdev(tpe: &NodeType) -> u32 {
u32::try_from(match tpe {
NodeType::Dev { device } | NodeType::Chardev { device } => *device,
_ => 0,
})
.unwrap()
}
fn node_to_linktarget(node: &Node) -> Option<&OsStr> {
if node.is_symlink() {
Some(node.node_type.to_link().as_os_str())
} else {
None
}
}
fn node_to_file_attr(node: &Node, now: SystemTime) -> FileAttr {
FileAttr {
// Size in bytes
size: node.meta.size,
// Size in blocks
blocks: 0,
// Time of last access
atime: node.meta.atime.map(SystemTime::from).unwrap_or(now),
// Time of last modification
mtime: node.meta.mtime.map(SystemTime::from).unwrap_or(now),
// Time of last metadata change
ctime: node.meta.ctime.map(SystemTime::from).unwrap_or(now),
// Time of creation (macOS only)
crtime: now,
// Kind of file (directory, file, pipe, etc.)
kind: node_to_filetype(node),
// Permissions
perm: node.meta.mode.unwrap_or(0o755) as u16,
// Number of hard links
nlink: node.meta.links.try_into().unwrap_or(1),
// User ID
uid: node.meta.uid.unwrap_or(0),
// Group ID
gid: node.meta.gid.unwrap_or(0),
// Device ID (if special file)
rdev: node_type_to_rdev(&node.node_type),
// Flags (macOS only; see chflags(2))
flags: 0,
}
}
impl<P, S: IndexedFull> FilesystemMT for FuseFS<P, S> {
fn getattr(&self, _req: RequestInfo, path: &Path, _fh: Option<u64>) -> ResultEntry {
let node = self.node_from_path(path)?;
Ok((Duration::from_secs(1), node_to_file_attr(&node, self.now)))
}
#[cfg(not(windows))]
fn readlink(&self, _req: RequestInfo, path: &Path) -> ResultData {
let target = node_to_linktarget(&self.node_from_path(path)?)
.ok_or(libc::ENOSYS)?
.as_bytes()
.to_vec();
Ok(target)
}
fn open(&self, _req: RequestInfo, path: &Path, _flags: u32) -> ResultOpen {
if matches!(self.file_policy, FilePolicy::Forbidden) {
return Err(libc::ENOTSUP);
}
let node = self.node_from_path(path)?;
let open = self.repo.open_file(&node).map_err(|_| libc::ENOSYS)?;
let fh = {
let mut open_files = self.open_files.write().unwrap();
let fh = open_files.last_key_value().map_or(0, |(fh, _)| *fh + 1);
_ = open_files.insert(fh, open);
fh
};
Ok((fh, 0))
}
fn release(
&self,
_req: RequestInfo,
_path: &Path,
fh: u64,
_flags: u32,
_lock_owner: u64,
_flush: bool,
) -> ResultEmpty {
_ = self.open_files.write().unwrap().remove(&fh);
Ok(())
}
fn read(
&self,
_req: RequestInfo,
_path: &Path,
fh: u64,
offset: u64,
size: u32,
callback: impl FnOnce(ResultSlice<'_>) -> CallbackResult,
) -> CallbackResult {
if let Some(open_file) = self.open_files.read().unwrap().get(&fh) {
if let Ok(data) =
self.repo
.read_file_at(open_file, offset.try_into().unwrap(), size as usize)
{
return callback(Ok(&data));
}
}
callback(Err(libc::ENOSYS))
}
fn opendir(&self, _req: RequestInfo, _path: &Path, _flags: u32) -> ResultOpen {
Ok((0, 0))
}
fn readdir(&self, _req: RequestInfo, path: &Path, _fh: u64) -> ResultReaddir {
let nodes = self.dir_entries_from_path(path)?;
let result = nodes
.into_iter()
.map(|node| DirectoryEntry {
name: node.name(),
kind: node_to_filetype(&node),
})
.collect();
Ok(result)
}
fn releasedir(&self, _req: RequestInfo, _path: &Path, _fh: u64, _flags: u32) -> ResultEmpty {
Ok(())
}
fn listxattr(&self, _req: RequestInfo, path: &Path, size: u32) -> ResultXattr {
let node = self.node_from_path(path)?;
let xattrs = node
.meta
.extended_attributes
.into_iter()
// convert into null-terminated [u8]
.map(|a| CString::new(a.name).unwrap().into_bytes_with_nul())
.concat();
if size == 0 {
Ok(Xattr::Size(u32::try_from(xattrs.len()).unwrap()))
} else {
Ok(Xattr::Data(xattrs))
}
}
fn getxattr(&self, _req: RequestInfo, path: &Path, name: &OsStr, size: u32) -> ResultXattr {
let node = self.node_from_path(path)?;
match node
.meta
.extended_attributes
.into_iter()
.find(|a| name == OsStr::new(&a.name))
{
None => Err(libc::ENOSYS),
Some(attr) => {
let value = attr.value.unwrap_or_default();
if size == 0 {
Ok(Xattr::Size(u32::try_from(value.len()).unwrap()))
} else {
Ok(Xattr::Data(value))
}
}
}
}
}

View File

@ -1,7 +1,7 @@
//! `prune` subcommand
use crate::{
commands::open_repository, helpers::bytes_size_to_string, status_err, Application, RUSTIC_APP,
helpers::bytes_size_to_string, repository::CliOpenRepo, status_err, Application, RUSTIC_APP,
};
use abscissa_core::{Command, Runnable, Shutdown};
use log::debug;
@ -21,7 +21,11 @@ pub(crate) struct PruneCmd {
impl Runnable for PruneCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
if let Err(err) = RUSTIC_APP
.config()
.repository
.run_open(|repo| self.inner_run(repo))
{
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
@ -29,18 +33,17 @@ impl Runnable for PruneCmd {
}
impl PruneCmd {
fn inner_run(&self) -> Result<()> {
fn inner_run(&self, repo: CliOpenRepo) -> Result<()> {
let config = RUSTIC_APP.config();
let repo = open_repository(&config)?;
let pruner = repo.prune_plan(&self.opts)?;
let prune_plan = repo.prune_plan(&self.opts)?;
print_stats(&pruner.stats);
print_stats(&prune_plan.stats);
if config.global.dry_run {
repo.warm_up(pruner.repack_packs().into_iter())?;
repo.warm_up(prune_plan.repack_packs().into_iter())?;
} else {
pruner.do_prune(&repo, &self.opts)?;
repo.prune(&self.opts, prune_plan)?;
}
Ok(())
@ -58,6 +61,9 @@ fn print_stats(stats: &PruneStats) {
let blob_stat = stats.blobs_sum();
let size_stat = stats.size_sum();
debug!("statistics:");
debug!("{:#?}", stats.debug);
debug!(
"used: {:>10} blobs, {:>10}",
blob_stat.used,

View File

@ -1,6 +1,9 @@
//! `repair` subcommand
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
use crate::{
repository::{CliIndexedRepo, CliOpenRepo},
status_err, Application, RUSTIC_APP,
};
use abscissa_core::{Command, Runnable, Shutdown};
use anyhow::Result;
@ -50,7 +53,8 @@ impl Runnable for RepairCmd {
impl Runnable for IndexSubCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
let config = RUSTIC_APP.config();
if let Err(err) = config.repository.run_open(|repo| self.inner_run(repo)) {
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
@ -58,9 +62,8 @@ impl Runnable for IndexSubCmd {
}
impl IndexSubCmd {
fn inner_run(&self) -> Result<()> {
fn inner_run(&self, repo: CliOpenRepo) -> Result<()> {
let config = RUSTIC_APP.config();
let repo = open_repository(&config)?;
repo.repair_index(&self.opts, config.global.dry_run)?;
Ok(())
}
@ -68,7 +71,8 @@ impl IndexSubCmd {
impl Runnable for SnapSubCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
let config = RUSTIC_APP.config();
if let Err(err) = config.repository.run_indexed(|repo| self.inner_run(repo)) {
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
@ -76,9 +80,8 @@ impl Runnable for SnapSubCmd {
}
impl SnapSubCmd {
fn inner_run(&self) -> Result<()> {
fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
let config = RUSTIC_APP.config();
let repo = open_repository(&config)?.to_indexed()?;
let snaps = if self.ids.is_empty() {
repo.get_all_snapshots()?
} else {

View File

@ -1,15 +1,16 @@
//! `repoinfo` subcommand
use crate::{
commands::open_repository, helpers::bytes_size_to_string, status_err, Application, RUSTIC_APP,
helpers::{bytes_size_to_string, table_right_from},
repository::CliRepo,
status_err, Application, RUSTIC_APP,
};
use abscissa_core::{Command, Runnable, Shutdown};
use serde::Serialize;
use crate::helpers::table_right_from;
use anyhow::Result;
use rustic_core::{IndexInfos, RepoFileInfo, RepoFileInfos, Repository};
use rustic_core::{IndexInfos, RepoFileInfo, RepoFileInfos};
/// `repoinfo` subcommand
#[derive(clap::Parser, Command, Debug)]
@ -29,7 +30,11 @@ pub(crate) struct RepoInfoCmd {
impl Runnable for RepoInfoCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
if let Err(err) = RUSTIC_APP
.config()
.repository
.run(|repo| self.inner_run(repo))
{
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
@ -47,22 +52,13 @@ struct Infos {
}
impl RepoInfoCmd {
fn inner_run(&self) -> Result<()> {
let config = RUSTIC_APP.config();
fn inner_run(&self, repo: CliRepo) -> Result<()> {
let infos = Infos {
files: (!self.only_index)
.then(|| {
let po = config.global.progress_options;
let repo = Repository::new_with_progress(&config.repository, po)?;
repo.infos_files()
})
.then(|| -> Result<_> { Ok(repo.infos_files()?) })
.transpose()?,
index: (!self.only_files)
.then(|| -> Result<_> {
let repo = open_repository(&config)?;
Ok(repo.infos_index()?)
})
.then(|| -> Result<_> { Ok(repo.open()?.infos_index()?) })
.transpose()?,
};

View File

@ -1,7 +1,7 @@
//! `restore` subcommand
use crate::{
commands::open_repository, helpers::bytes_size_to_string, status_err, Application, RUSTIC_APP,
helpers::bytes_size_to_string, repository::CliIndexedRepo, status_err, Application, RUSTIC_APP,
};
use abscissa_core::{Command, Runnable, Shutdown};
@ -41,7 +41,11 @@ pub(crate) struct RestoreCmd {
}
impl Runnable for RestoreCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
if let Err(err) = RUSTIC_APP
.config()
.repository
.run_indexed(|repo| self.inner_run(repo))
{
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
@ -49,10 +53,9 @@ impl Runnable for RestoreCmd {
}
impl RestoreCmd {
fn inner_run(&self) -> Result<()> {
fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
let config = RUSTIC_APP.config();
let dry_run = config.global.dry_run;
let repo = open_repository(&config)?.to_indexed()?;
let node =
repo.node_from_snapshot_path(&self.snap, |sn| config.snapshot_filter.matches(sn))?;
@ -64,7 +67,7 @@ impl RestoreCmd {
let dest = LocalDestination::new(&self.dest, true, !node.is_dir())?;
let restore_infos = repo.prepare_restore(&self.opts, ls.clone(), &dest, dry_run)?;
let restore_infos = repo.prepare_restore(&self.opts, ls, &dest, dry_run)?;
let fs = restore_infos.stats.files;
println!(
@ -94,6 +97,10 @@ impl RestoreCmd {
if dry_run {
repo.warm_up(restore_infos.to_packs().into_iter())?;
} else {
// save some memory
let repo = repo.drop_data_from_index();
let ls = repo.ls(&node, &ls_opts)?;
repo.restore(restore_infos, &self.opts, ls, &dest)?;
println!("restore done.");
}

View File

@ -1,8 +1,10 @@
//! `show-config` subcommand
use crate::{Application, RUSTIC_APP};
use crate::{status_err, Application, RUSTIC_APP};
use abscissa_core::{Command, Runnable};
use abscissa_core::{Command, Runnable, Shutdown};
use anyhow::Result;
use toml::to_string_pretty;
/// `show-config` subcommand
#[derive(clap::Parser, Command, Debug)]
@ -10,7 +12,17 @@ pub(crate) struct ShowConfigCmd {}
impl Runnable for ShowConfigCmd {
fn run(&self) {
let config = RUSTIC_APP.config();
println!("{config:#?}");
if let Err(err) = self.inner_run() {
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
}
}
impl ShowConfigCmd {
fn inner_run(&self) -> Result<()> {
let config = to_string_pretty(RUSTIC_APP.config().as_ref())?;
println!("{config}");
Ok(())
}
}

View File

@ -1,8 +1,8 @@
//! `smapshot` subcommand
use crate::{
commands::open_repository,
helpers::{bold_cell, bytes_size_to_string, table, table_right_from},
repository::CliOpenRepo,
status_err, Application, RUSTIC_APP,
};
@ -17,6 +17,9 @@ use rustic_core::{
SnapshotGroupCriterion,
};
#[cfg(feature = "tui")]
use super::tui;
/// `snapshot` subcommand
#[derive(clap::Parser, Command, Debug)]
pub(crate) struct SnapshotCmd {
@ -44,11 +47,20 @@ pub(crate) struct SnapshotCmd {
/// Show all snapshots instead of summarizing identical follow-up snapshots
#[clap(long, conflicts_with_all = &["long", "json"])]
all: bool,
#[cfg(feature = "tui")]
/// Run in interactive UI mode
#[clap(long, short)]
pub interactive: bool,
}
impl Runnable for SnapshotCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
if let Err(err) = RUSTIC_APP
.config()
.repository
.run_open(|repo| self.inner_run(repo))
{
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
@ -56,9 +68,13 @@ impl Runnable for SnapshotCmd {
}
impl SnapshotCmd {
fn inner_run(&self) -> Result<()> {
fn inner_run(&self, repo: CliOpenRepo) -> Result<()> {
#[cfg(feature = "tui")]
if self.interactive {
return tui::run(self.group_by);
}
let config = RUSTIC_APP.config();
let repo = open_repository(&config)?;
let groups = repo.get_snapshot_group(&self.ids, self.group_by, |sn| {
config.snapshot_filter.matches(sn)
@ -80,40 +96,17 @@ impl SnapshotCmd {
if self.long {
for snap in snapshots {
snap.print_table();
let mut table = table();
let add_entry = |title: &str, value: String| {
_ = table.add_row([bold_cell(title), Cell::new(value)]);
};
fill_table(&snap, add_entry);
println!("{table}");
println!();
}
} else {
let snap_to_table = |(sn, count): (SnapshotFile, usize)| {
let tags = sn.tags.formatln();
let paths = sn.paths.formatln();
let time = sn.time.format("%Y-%m-%d %H:%M:%S");
let (files, dirs, size) = sn.summary.as_ref().map_or_else(
|| ("?".to_string(), "?".to_string(), "?".to_string()),
|s| {
(
s.total_files_processed.to_string(),
s.total_dirs_processed.to_string(),
bytes_size_to_string(s.total_bytes_processed),
)
},
);
let id = match count {
0 => format!("{}", sn.id),
count => format!("{} (+{})", sn.id, count),
};
[
id,
time.to_string(),
sn.hostname,
sn.label,
tags,
paths,
files,
dirs,
size,
]
};
let mut table = table_right_from(
6,
[
@ -121,14 +114,19 @@ impl SnapshotCmd {
],
);
let snapshots: Vec<_> = snapshots
.into_iter()
.group_by(|sn| if self.all { sn.id } else { sn.tree })
.into_iter()
.map(|(_, mut g)| (g.next().unwrap(), g.count()))
.map(snap_to_table)
.collect();
_ = table.add_rows(snapshots);
if self.all {
// Add all snapshots to output table
_ = table.add_rows(snapshots.into_iter().map(|sn| snap_to_table(&sn, 0)));
} else {
// Group snapshts by treeid and output into table
_ = table.add_rows(
snapshots
.into_iter()
.chunk_by(|sn| sn.tree)
.into_iter()
.map(|(_, mut g)| snap_to_table(&g.next().unwrap(), g.count())),
);
}
println!("{table}");
}
println!("{count} snapshot(s)");
@ -141,99 +139,115 @@ impl SnapshotCmd {
}
}
/// Trait to print a table
trait PrintTable {
/// Print a table
fn print_table(&self);
pub fn snap_to_table(sn: &SnapshotFile, count: usize) -> [String; 9] {
let tags = sn.tags.formatln();
let paths = sn.paths.formatln();
let time = sn.time.format("%Y-%m-%d %H:%M:%S");
let (files, dirs, size) = sn.summary.as_ref().map_or_else(
|| ("?".to_string(), "?".to_string(), "?".to_string()),
|s| {
(
s.total_files_processed.to_string(),
s.total_dirs_processed.to_string(),
bytes_size_to_string(s.total_bytes_processed),
)
},
);
let id = match count {
0 => format!("{}", sn.id),
count => format!("{} (+{})", sn.id, count),
};
[
id,
time.to_string(),
sn.hostname.clone(),
sn.label.clone(),
tags,
paths,
files,
dirs,
size,
]
}
impl PrintTable for SnapshotFile {
fn print_table(&self) {
let mut table = table();
let mut add_entry = |title: &str, value: String| {
_ = table.add_row([bold_cell(title), Cell::new(value)]);
};
add_entry("Snapshot", self.id.to_hex().to_string());
// note that if original was not set, it is set to self.id by the load process
if self.original != Some(self.id) {
add_entry("Original ID", self.original.unwrap().to_hex().to_string());
pub fn fill_table(snap: &SnapshotFile, mut add_entry: impl FnMut(&str, String)) {
add_entry("Snapshot", snap.id.to_hex().to_string());
// note that if original was not set, it is set to snap.id by the load process
if let Some(original) = snap.original {
if original != snap.id {
add_entry("Original ID", original.to_hex().to_string());
}
add_entry("Time", self.time.format("%Y-%m-%d %H:%M:%S").to_string());
add_entry("Generated by", self.program_version.clone());
add_entry("Host", self.hostname.clone());
add_entry("Label", self.label.clone());
add_entry("Tags", self.tags.formatln());
let delete = match self.delete {
DeleteOption::NotSet => "not set".to_string(),
DeleteOption::Never => "never".to_string(),
DeleteOption::After(t) => format!("after {}", t.format("%Y-%m-%d %H:%M:%S")),
};
add_entry("Delete", delete);
add_entry("Paths", self.paths.formatln());
let parent = self.parent.map_or_else(
|| "no parent snapshot".to_string(),
|p| p.to_hex().to_string(),
}
add_entry("Time", snap.time.format("%Y-%m-%d %H:%M:%S").to_string());
add_entry("Generated by", snap.program_version.clone());
add_entry("Host", snap.hostname.clone());
add_entry("Label", snap.label.clone());
add_entry("Tags", snap.tags.formatln());
let delete = match snap.delete {
DeleteOption::NotSet => "not set".to_string(),
DeleteOption::Never => "never".to_string(),
DeleteOption::After(t) => format!("after {}", t.format("%Y-%m-%d %H:%M:%S")),
};
add_entry("Delete", delete);
add_entry("Paths", snap.paths.formatln());
let parent = snap.parent.map_or_else(
|| "no parent snapshot".to_string(),
|p| p.to_hex().to_string(),
);
add_entry("Parent", parent);
if let Some(ref summary) = snap.summary {
add_entry("", String::new());
add_entry("Command", summary.command.clone());
let source = format!(
"files: {} / dirs: {} / size: {}",
summary.total_files_processed,
summary.total_dirs_processed,
bytes_size_to_string(summary.total_bytes_processed)
);
add_entry("Parent", parent);
if let Some(ref summary) = self.summary {
add_entry("", String::new());
add_entry("Command", summary.command.clone());
add_entry("Source", source);
add_entry("", String::new());
let source = format!(
"files: {} / dirs: {} / size: {}",
summary.total_files_processed,
summary.total_dirs_processed,
bytes_size_to_string(summary.total_bytes_processed)
);
add_entry("Source", source);
add_entry("", String::new());
let files = format!(
"new: {:>10} / changed: {:>10} / unchanged: {:>10}",
summary.files_new, summary.files_changed, summary.files_unmodified,
);
add_entry("Files", files);
let files = format!(
"new: {:>10} / changed: {:>10} / unchanged: {:>10}",
summary.files_new, summary.files_changed, summary.files_unmodified,
);
add_entry("Files", files);
let trees = format!(
"new: {:>10} / changed: {:>10} / unchanged: {:>10}",
summary.dirs_new, summary.dirs_changed, summary.dirs_unmodified,
);
add_entry("Dirs", trees);
add_entry("", String::new());
let trees = format!(
"new: {:>10} / changed: {:>10} / unchanged: {:>10}",
summary.dirs_new, summary.dirs_changed, summary.dirs_unmodified,
);
add_entry("Dirs", trees);
add_entry("", String::new());
let written = format!(
"data: {:>10} blobs / raw: {:>10} / packed: {:>10}\n\
let written = format!(
"data: {:>10} blobs / raw: {:>10} / packed: {:>10}\n\
tree: {:>10} blobs / raw: {:>10} / packed: {:>10}\n\
total: {:>10} blobs / raw: {:>10} / packed: {:>10}",
summary.data_blobs,
bytes_size_to_string(summary.data_added_files),
bytes_size_to_string(summary.data_added_files_packed),
summary.tree_blobs,
bytes_size_to_string(summary.data_added_trees),
bytes_size_to_string(summary.data_added_trees_packed),
summary.tree_blobs + summary.data_blobs,
bytes_size_to_string(summary.data_added),
bytes_size_to_string(summary.data_added_packed),
);
add_entry("Added to repo", written);
summary.data_blobs,
bytes_size_to_string(summary.data_added_files),
bytes_size_to_string(summary.data_added_files_packed),
summary.tree_blobs,
bytes_size_to_string(summary.data_added_trees),
bytes_size_to_string(summary.data_added_trees_packed),
summary.tree_blobs + summary.data_blobs,
bytes_size_to_string(summary.data_added),
bytes_size_to_string(summary.data_added_packed),
);
add_entry("Added to repo", written);
let duration = format!(
"backup start: {} / backup end: {} / backup duration: {}\n\
let duration = format!(
"backup start: {} / backup end: {} / backup duration: {}\n\
total duration: {}",
summary.backup_start.format("%Y-%m-%d %H:%M:%S"),
summary.backup_end.format("%Y-%m-%d %H:%M:%S"),
format_duration(std::time::Duration::from_secs_f64(summary.backup_duration)),
format_duration(std::time::Duration::from_secs_f64(summary.total_duration))
);
add_entry("Duration", duration);
}
if let Some(ref description) = self.description {
add_entry("Description", description.clone());
}
println!("{table}");
println!();
summary.backup_start.format("%Y-%m-%d %H:%M:%S"),
summary.backup_end.format("%Y-%m-%d %H:%M:%S"),
format_duration(std::time::Duration::from_secs_f64(summary.backup_duration)),
format_duration(std::time::Duration::from_secs_f64(summary.total_duration))
);
add_entry("Duration", duration);
}
if let Some(ref description) = snap.description {
add_entry("Description", description.clone());
}
}

View File

@ -1,9 +1,10 @@
//! `tag` subcommand
use crate::{commands::open_repository, status_err, Application, RUSTIC_APP};
use crate::{repository::CliOpenRepo, status_err, Application, RUSTIC_APP};
use abscissa_core::{Command, Runnable, Shutdown};
use anyhow::Result;
use chrono::{Duration, Local};
use rustic_core::{repofile::DeleteOption, StringList};
@ -61,7 +62,11 @@ pub(crate) struct TagCmd {
impl Runnable for TagCmd {
fn run(&self) {
if let Err(err) = self.inner_run() {
if let Err(err) = RUSTIC_APP
.config()
.repository
.run_open(|repo| self.inner_run(repo))
{
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
@ -69,9 +74,8 @@ impl Runnable for TagCmd {
}
impl TagCmd {
fn inner_run(&self) -> anyhow::Result<()> {
fn inner_run(&self, repo: CliOpenRepo) -> Result<()> {
let config = RUSTIC_APP.config();
let repo = open_repository(&config)?;
let snapshots = if self.ids.is_empty() {
repo.get_matching_snapshots(|sn| config.snapshot_filter.matches(sn))?

112
src/commands/tui.rs Normal file
View File

@ -0,0 +1,112 @@
//! `tui` subcommand
mod ls;
mod progress;
mod restore;
mod snapshots;
mod tree;
mod widgets;
use crossterm::event::{KeyEvent, KeyModifiers};
use progress::TuiProgressBars;
use scopeguard::defer;
use snapshots::Snapshots;
use std::io;
use std::sync::{Arc, RwLock};
use crate::{Application, RUSTIC_APP};
use anyhow::Result;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::prelude::*;
use rustic_core::{IndexedFull, Progress, ProgressBars, SnapshotGroupCriterion};
struct App<'a, P, S> {
snapshots: Snapshots<'a, P, S>,
}
pub fn run(group_by: SnapshotGroupCriterion) -> Result<()> {
let config = RUSTIC_APP.config();
// setup terminal
let terminal = init_terminal()?;
let terminal = Arc::new(RwLock::new(terminal));
// restore terminal (even when leaving through ?, early return, or panic)
defer! {
reset_terminal().unwrap();
}
let progress = TuiProgressBars {
terminal: terminal.clone(),
};
let res = config
.repository
.run_indexed_with_progress(progress.clone(), |repo| {
let p = progress.progress_spinner("starting rustic in interactive mode...");
p.finish();
// create app and run it
let snapshots = Snapshots::new(&repo, config.snapshot_filter.clone(), group_by)?;
let app = App { snapshots };
run_app(terminal, app)
});
if let Err(err) = res {
println!("{err:?}");
}
Ok(())
}
/// Initializes the terminal.
fn init_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
enable_raw_mode()?;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend)?;
terminal.hide_cursor()?;
Ok(terminal)
}
/// Resets the terminal.
fn reset_terminal() -> Result<()> {
disable_raw_mode()?;
execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
Ok(())
}
fn run_app<B: Backend, P: ProgressBars, S: IndexedFull>(
terminal: Arc<RwLock<Terminal<B>>>,
mut app: App<'_, P, S>,
) -> Result<()> {
loop {
_ = terminal.write().unwrap().draw(|f| ui(f, &mut app))?;
let event = event::read()?;
use KeyCode::*;
if let Event::Key(KeyEvent {
code: Char('c'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
}) = event
{
return Ok(());
}
if app.snapshots.input(event)? {
return Ok(());
}
}
}
fn ui<P: ProgressBars, S: IndexedFull>(f: &mut Frame<'_>, app: &mut App<'_, P, S>) {
let area = f.area();
app.snapshots.draw(area, f);
}

320
src/commands/tui/ls.rs Normal file
View File

@ -0,0 +1,320 @@
use std::path::{Path, PathBuf};
use anyhow::Result;
use crossterm::event::{Event, KeyCode, KeyEventKind};
use ratatui::{prelude::*, widgets::*};
use rustic_core::{
repofile::{Node, SnapshotFile, Tree},
vfs::OpenFile,
IndexedFull, ProgressBars, Repository,
};
use style::palette::tailwind;
use crate::commands::{
ls::{NodeLs, Summary},
tui::{
restore::Restore,
widgets::{
popup_prompt, popup_scrollable_text, popup_text, Draw, PopUpPrompt, PopUpText,
ProcessEvent, PromptResult, SelectTable, TextInputResult, WithBlock,
},
},
};
use super::widgets::PopUpInput;
// the states this screen can be in
enum CurrentScreen<'a, P, S> {
Snapshot,
ShowHelp(PopUpText),
Restore(Restore<'a, P, S>),
PromptExit(PopUpPrompt),
ShowFile(PopUpInput),
}
const INFO_TEXT: &str =
"(Esc) quit | (Enter) enter dir | (Backspace) return to parent | (v) view | (r) restore | (?) show all commands";
const HELP_TEXT: &str = r#"
General Commands:
q,Esc : exit
Enter : enter dir
Backspace : return to parent dir
v : view file contents (text files only, up to 1MiB)
r : restore selected item
n : toggle numeric IDs
? : show this help page
"#;
pub(crate) struct Snapshot<'a, P, S> {
current_screen: CurrentScreen<'a, P, S>,
numeric: bool,
table: WithBlock<SelectTable>,
repo: &'a Repository<P, S>,
snapshot: SnapshotFile,
path: PathBuf,
trees: Vec<(Tree, usize)>, // Stack of parent trees with position
tree: Tree,
}
pub enum SnapshotResult {
Exit,
Return,
None,
}
impl<'a, P: ProgressBars, S: IndexedFull> Snapshot<'a, P, S> {
pub fn new(repo: &'a Repository<P, S>, snapshot: SnapshotFile) -> Result<Self> {
let header = ["Name", "Size", "Mode", "User", "Group", "Time"]
.into_iter()
.map(Text::from)
.collect();
let tree = repo.get_tree(&snapshot.tree)?;
let mut app = Self {
current_screen: CurrentScreen::Snapshot,
numeric: false,
table: WithBlock::new(SelectTable::new(header), Block::new()),
repo,
snapshot,
path: PathBuf::new(),
trees: Vec::new(),
tree,
};
app.update_table();
Ok(app)
}
fn ls_row(&self, node: &Node) -> Vec<Text<'static>> {
let (user, group) = if self.numeric {
(
node.meta
.uid
.map_or_else(|| "?".to_string(), |id| id.to_string()),
node.meta
.gid
.map_or_else(|| "?".to_string(), |id| id.to_string()),
)
} else {
(
node.meta.user.clone().unwrap_or_else(|| "?".to_string()),
node.meta.group.clone().unwrap_or_else(|| "?".to_string()),
)
};
let name = node.name().to_string_lossy().to_string();
let size = node.meta.size.to_string();
let mtime = node
.meta
.mtime
.map(|t| format!("{}", t.format("%Y-%m-%d %H:%M:%S")))
.unwrap_or_else(|| "?".to_string());
[name, size, node.mode_str(), user, group, mtime]
.into_iter()
.map(Text::from)
.collect()
}
pub fn selected_node(&self) -> Option<&Node> {
self.table.widget.selected().map(|i| &self.tree.nodes[i])
}
pub fn update_table(&mut self) {
let old_selection = if self.tree.nodes.is_empty() {
None
} else {
Some(self.table.widget.selected().unwrap_or_default())
};
let mut rows = Vec::new();
let mut summary = Summary::default();
for node in &self.tree.nodes {
summary.update(node);
let row = self.ls_row(node);
rows.push(row);
}
self.table.widget.set_content(rows, 1);
self.table.block = Block::new()
.borders(Borders::BOTTOM | Borders::TOP)
.title(format!("{}:{}", self.snapshot.id, self.path.display()))
.title_bottom(format!(
"total: {}, files: {}, dirs: {}, size: {} - {}",
self.tree.nodes.len(),
summary.files,
summary.dirs,
summary.size,
if self.numeric {
"numeric IDs"
} else {
" Id names"
}
))
.title_alignment(Alignment::Center);
self.table.widget.select(old_selection);
}
pub fn enter(&mut self) -> Result<()> {
if let Some(idx) = self.table.widget.selected() {
let node = &self.tree.nodes[idx];
if node.is_dir() {
self.path.push(node.name());
let tree = self.tree.clone();
self.tree = self.repo.get_tree(&node.subtree.unwrap())?;
self.trees.push((tree, idx));
}
}
self.table.widget.set_to(0);
self.update_table();
Ok(())
}
pub fn goback(&mut self) -> bool {
_ = self.path.pop();
if let Some((tree, idx)) = self.trees.pop() {
self.tree = tree;
self.table.widget.set_to(idx);
self.update_table();
false
} else {
true
}
}
pub fn toggle_numeric(&mut self) {
self.numeric = !self.numeric;
self.update_table();
}
pub fn input(&mut self, event: Event) -> Result<SnapshotResult> {
use KeyCode::*;
match &mut self.current_screen {
CurrentScreen::Snapshot => match event {
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
Enter | Right => self.enter()?,
Backspace | Left => {
if self.goback() {
return Ok(SnapshotResult::Return);
}
}
Esc | Char('q') => {
self.current_screen = CurrentScreen::PromptExit(popup_prompt(
"exit rustic",
"do you want to exit? (y/n)".into(),
));
}
Char('?') => {
self.current_screen =
CurrentScreen::ShowHelp(popup_text("help", HELP_TEXT.into()));
}
Char('n') => self.toggle_numeric(),
Char('v') => {
// viewing is not supported on cold repositories
if self.repo.config().is_hot != Some(true) {
if let Some(node) = self.selected_node() {
if node.is_file() {
if let Ok(data) = OpenFile::from_node(self.repo, node).read_at(
self.repo,
0,
node.meta.size.min(1_000_000).try_into().unwrap(),
) {
// viewing is only supported for text files
if let Ok(content) = String::from_utf8(data.to_vec()) {
let lines = content.lines().count();
let path = self.path.join(node.name());
let path = path.display();
self.current_screen =
CurrentScreen::ShowFile(popup_scrollable_text(
format!("{}:/{path}", self.snapshot.id),
&content,
(lines + 1).min(40).try_into().unwrap(),
));
}
}
}
}
}
}
Char('r') => {
if let Some(node) = self.selected_node() {
let is_absolute = self
.snapshot
.paths
.iter()
.any(|p| Path::new(p).is_absolute());
let path = self.path.join(node.name());
let path = path.display();
let default_targt = if is_absolute {
format!("/{path}")
} else {
format!("{path}")
};
let restore = Restore::new(
self.repo,
node.clone(),
format!("{}:/{path}", self.snapshot.id),
&default_targt,
);
self.current_screen = CurrentScreen::Restore(restore);
}
}
_ => self.table.input(event),
},
_ => {}
},
CurrentScreen::ShowFile(prompt) => match prompt.input(event) {
TextInputResult::Cancel | TextInputResult::Input(_) => {
self.current_screen = CurrentScreen::Snapshot;
}
TextInputResult::None => {}
},
CurrentScreen::ShowHelp(_) => match event {
Event::Key(key) if key.kind == KeyEventKind::Press => {
if matches!(key.code, Char('q') | Esc | Enter | Char(' ') | Char('?')) {
self.current_screen = CurrentScreen::Snapshot;
}
}
_ => {}
},
CurrentScreen::Restore(restore) => {
if restore.input(event)? {
self.current_screen = CurrentScreen::Snapshot;
}
}
CurrentScreen::PromptExit(prompt) => match prompt.input(event) {
PromptResult::Ok => return Ok(SnapshotResult::Exit),
PromptResult::Cancel => self.current_screen = CurrentScreen::Snapshot,
PromptResult::None => {}
},
}
Ok(SnapshotResult::None)
}
pub fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
let rects = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(area);
if let CurrentScreen::Restore(restore) = &mut self.current_screen {
restore.draw(area, f);
} else {
// draw the table
self.table.draw(rects[0], f);
// draw the footer
let buffer_bg = tailwind::SLATE.c950;
let row_fg = tailwind::SLATE.c200;
let info_footer = Paragraph::new(Line::from(INFO_TEXT))
.style(Style::new().fg(row_fg).bg(buffer_bg))
.centered();
f.render_widget(info_footer, rects[1]);
}
// draw popups
match &mut self.current_screen {
CurrentScreen::Snapshot | CurrentScreen::Restore(_) => {}
CurrentScreen::ShowHelp(popup) => popup.draw(area, f),
CurrentScreen::PromptExit(popup) => popup.draw(area, f),
CurrentScreen::ShowFile(popup) => popup.draw(area, f),
}
}
}

View File

@ -0,0 +1,174 @@
use std::io::Stdout;
use std::sync::{Arc, RwLock};
use std::time::{Duration, SystemTime};
use bytesize::ByteSize;
use ratatui::{backend::CrosstermBackend, Terminal};
use rustic_core::{Progress, ProgressBars};
use super::widgets::{popup_gauge, popup_text, Draw};
#[derive(Clone)]
pub struct TuiProgressBars {
pub terminal: Arc<RwLock<Terminal<CrosstermBackend<Stdout>>>>,
}
impl TuiProgressBars {
fn as_progress(&self, progress_type: TuiProgressType, prefix: String) -> TuiProgress {
TuiProgress {
terminal: self.terminal.clone(),
data: Arc::new(RwLock::new(CounterData::new(prefix))),
progress_type,
}
}
}
impl ProgressBars for TuiProgressBars {
type P = TuiProgress;
fn progress_hidden(&self) -> Self::P {
self.as_progress(TuiProgressType::Hidden, String::new())
}
fn progress_spinner(&self, prefix: impl Into<std::borrow::Cow<'static, str>>) -> Self::P {
let progress = self.as_progress(TuiProgressType::Spinner, String::from(prefix.into()));
progress.popup();
progress
}
fn progress_counter(&self, prefix: impl Into<std::borrow::Cow<'static, str>>) -> Self::P {
let progress = self.as_progress(TuiProgressType::Counter, String::from(prefix.into()));
progress.popup();
progress
}
fn progress_bytes(&self, prefix: impl Into<std::borrow::Cow<'static, str>>) -> Self::P {
let progress = self.as_progress(TuiProgressType::Bytes, String::from(prefix.into()));
progress.popup();
progress
}
}
struct CounterData {
prefix: String,
begin: SystemTime,
length: Option<u64>,
count: u64,
}
impl CounterData {
fn new(prefix: String) -> Self {
Self {
prefix,
begin: SystemTime::now(),
length: None,
count: 0,
}
}
}
#[derive(Clone)]
enum TuiProgressType {
Hidden,
Spinner,
Counter,
Bytes,
}
#[derive(Clone)]
pub struct TuiProgress {
terminal: Arc<RwLock<Terminal<CrosstermBackend<Stdout>>>>,
data: Arc<RwLock<CounterData>>,
progress_type: TuiProgressType,
}
fn fmt_duration(d: Duration) -> String {
let seconds = d.as_secs();
let (minutes, seconds) = (seconds / 60, seconds % 60);
let (hours, minutes) = (minutes / 60, minutes % 60);
format!("[{hours:02}:{minutes:02}:{seconds:02}]")
}
impl TuiProgress {
fn popup(&self) {
let data = self.data.read().unwrap();
let elapsed = data.begin.elapsed().unwrap();
let length = data.length;
let count = data.count;
let ratio = match length {
None | Some(0) => 0.0,
Some(l) => count as f64 / l as f64,
};
let eta = match ratio {
r if r < 0.01 => " ETA: -".to_string(),
r if r > 0.999999 => String::new(),
r => {
format!(
" ETA: {}",
fmt_duration(Duration::from_secs(1) + elapsed.div_f64(r / (1.0 - r)))
)
}
};
let prefix = &data.prefix;
let message = match self.progress_type {
TuiProgressType::Spinner => {
format!("{} {prefix}", fmt_duration(elapsed))
}
TuiProgressType::Counter => {
format!(
"{} {prefix} {}{}{eta}",
fmt_duration(elapsed),
count,
length.map_or(String::new(), |l| format!("/{l}"))
)
}
TuiProgressType::Bytes => {
format!(
"{} {prefix} {}{}{eta}",
fmt_duration(elapsed),
ByteSize(count).to_string_as(true),
length.map_or(String::new(), |l| format!(
"/{}",
ByteSize(l).to_string_as(true)
))
)
}
TuiProgressType::Hidden => String::new(),
};
drop(data);
let mut terminal = self.terminal.write().unwrap();
_ = terminal
.draw(|f| {
let area = f.area();
match self.progress_type {
TuiProgressType::Hidden => {}
TuiProgressType::Spinner => {
let mut popup = popup_text("progress", message.into());
popup.draw(area, f);
}
TuiProgressType::Counter | TuiProgressType::Bytes => {
let mut popup = popup_gauge("progress", message.into(), ratio);
popup.draw(area, f);
}
}
})
.unwrap();
}
}
impl Progress for TuiProgress {
fn is_hidden(&self) -> bool {
matches!(self.progress_type, TuiProgressType::Hidden)
}
fn set_length(&self, len: u64) {
self.data.write().unwrap().length = Some(len);
self.popup();
}
fn set_title(&self, title: &'static str) {
self.data.write().unwrap().prefix = String::from(title);
self.popup();
}
fn inc(&self, inc: u64) {
self.data.write().unwrap().count += inc;
self.popup();
}
fn finish(&self) {}
}

160
src/commands/tui/restore.rs Normal file
View File

@ -0,0 +1,160 @@
use anyhow::Result;
use crossterm::event::{Event, KeyCode, KeyEventKind};
use ratatui::prelude::*;
use rustic_core::{
repofile::Node, IndexedFull, LocalDestination, LsOptions, ProgressBars, Repository,
RestoreOptions, RestorePlan,
};
use crate::{
commands::tui::widgets::{
popup_input, popup_prompt, Draw, PopUpInput, PopUpPrompt, PopUpText, ProcessEvent,
PromptResult, TextInputResult,
},
helpers::bytes_size_to_string,
};
use super::widgets::popup_text;
// the states this screen can be in
enum CurrentScreen {
GetDestination(PopUpInput),
PromptRestore(PopUpPrompt, Option<RestorePlan>),
RestoreDone(PopUpText),
}
pub(crate) struct Restore<'a, P, S> {
current_screen: CurrentScreen,
repo: &'a Repository<P, S>,
opts: RestoreOptions,
node: Node,
source: String,
dest: String,
}
impl<'a, P: ProgressBars, S: IndexedFull> Restore<'a, P, S> {
pub fn new(repo: &'a Repository<P, S>, node: Node, source: String, path: &str) -> Self {
let opts = RestoreOptions::default();
let title = format!("restore {} to:", source);
let popup = popup_input(title, "enter restore destination", path, 1);
Self {
current_screen: CurrentScreen::GetDestination(popup),
node,
repo,
opts,
source,
dest: String::new(),
}
}
pub fn compute_plan(&mut self, mut dest: String, dry_run: bool) -> Result<RestorePlan> {
if dest.is_empty() {
dest = ".".to_string();
}
self.dest = dest;
let dest = LocalDestination::new(&self.dest, true, !self.node.is_dir())?;
// for restore, always recurse into tree
let mut ls_opts = LsOptions::default();
ls_opts.recursive = true;
let ls = self.repo.ls(&self.node, &ls_opts)?;
let plan = self.repo.prepare_restore(&self.opts, ls, &dest, dry_run)?;
Ok(plan)
}
// restore using the plan
//
// Note: This currently runs `prepare_restore` again and doesn't use `plan`
// TODO: Fix when restore is changed such that `prepare_restore` is always dry_run and all modification is done in `restore`
fn restore(&self, _plan: RestorePlan) -> Result<()> {
let dest = LocalDestination::new(&self.dest, true, !self.node.is_dir())?;
// for restore, always recurse into tree
let mut ls_opts = LsOptions::default();
ls_opts.recursive = true;
let ls = self.repo.ls(&self.node, &ls_opts)?;
let plan = self
.repo
.prepare_restore(&self.opts, ls.clone(), &dest, false)?;
// the actual restore
self.repo.restore(plan, &self.opts, ls, &dest)?;
Ok(())
}
pub fn input(&mut self, event: Event) -> Result<bool> {
use KeyCode::*;
match &mut self.current_screen {
CurrentScreen::GetDestination(prompt) => match prompt.input(event) {
TextInputResult::Cancel => return Ok(true),
TextInputResult::Input(input) => {
let plan = self.compute_plan(input, true)?;
let fs = plan.stats.files;
let ds = plan.stats.dirs;
let popup = popup_prompt(
"restore information",
Text::from(format!(
r#"
restoring from: {}
restoring to: {}
Files: {} to restore, {} unchanged, {} verified, {} to modify, {} additional
Dirs: {} to restore, {} to modify, {} additional
Total restore size: {}
Do you want to proceed (y/n)?
"#,
self.source,
self.dest,
fs.restore,
fs.unchanged,
fs.verified,
fs.modify,
fs.additional,
ds.restore,
ds.modify,
ds.additional,
bytes_size_to_string(plan.restore_size)
)),
);
self.current_screen = CurrentScreen::PromptRestore(popup, Some(plan));
}
TextInputResult::None => {}
},
CurrentScreen::PromptRestore(prompt, plan) => match prompt.input(event) {
PromptResult::Ok => {
let plan = plan.take().unwrap();
self.restore(plan)?;
self.current_screen = CurrentScreen::RestoreDone(popup_text(
"restore done",
format!("restored {} successfully to {}", self.source, self.dest).into(),
));
}
PromptResult::Cancel => return Ok(true),
PromptResult::None => {}
},
CurrentScreen::RestoreDone(_) => match event {
Event::Key(key) if key.kind == KeyEventKind::Press => {
if matches!(key.code, Char('q') | Esc | Enter | Char(' ')) {
return Ok(true);
}
}
_ => {}
},
}
Ok(false)
}
pub fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
// draw popups
match &mut self.current_screen {
CurrentScreen::GetDestination(popup) => popup.draw(area, f),
CurrentScreen::PromptRestore(popup, _) => popup.draw(area, f),
CurrentScreen::RestoreDone(popup) => popup.draw(area, f),
}
}
}

View File

@ -0,0 +1,928 @@
use std::{collections::BTreeSet, iter::once, mem, str::FromStr};
use anyhow::Result;
use chrono::Local;
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use itertools::Itertools;
use ratatui::{prelude::*, widgets::*};
use rustic_core::{
repofile::{DeleteOption, SnapshotFile},
IndexedFull, ProgressBars, Repository, SnapshotGroup, SnapshotGroupCriterion, StringList,
};
use style::palette::tailwind;
use crate::{
commands::{
snapshots::{fill_table, snap_to_table},
tui::{
ls::{Snapshot, SnapshotResult},
tree::{Tree, TreeIterItem, TreeNode},
widgets::{
popup_input, popup_prompt, popup_table, popup_text, Draw, PopUpInput, PopUpPrompt,
PopUpTable, PopUpText, ProcessEvent, PromptResult, SelectTable, TextInputResult,
WithBlock,
},
},
},
filtering::SnapshotFilter,
};
// the states this screen can be in
enum CurrentScreen<'a, P, S> {
Snapshots,
ShowHelp(PopUpText),
SnapshotDetails(PopUpTable),
EnterLabel(PopUpInput),
EnterDescription(PopUpInput),
EnterAddTags(PopUpInput),
EnterSetTags(PopUpInput),
EnterRemoveTags(PopUpInput),
EnterFilter(PopUpInput),
PromptWrite(PopUpPrompt),
PromptExit(PopUpPrompt),
Dir(Snapshot<'a, P, S>),
}
// status of each snapshot
#[derive(Clone, Copy, Default)]
struct SnapStatus {
marked: bool,
modified: bool,
to_forget: bool,
}
impl SnapStatus {
fn toggle_mark(&mut self) {
self.marked = !self.marked;
}
}
#[derive(Debug)]
enum View {
Filter,
All,
Marked,
Modified,
}
#[derive(PartialEq, Eq)]
enum SnapshotNode {
Group(SnapshotGroup),
Snap(usize),
}
const INFO_TEXT: &str =
"(Esc) quit | (F5) reload snapshots | (Enter) show contents | (v) toggle view | (i) show snapshot | (?) show all commands";
const HELP_TEXT: &str = r#"General Commands:
q, Esc : exit
F5 : re-read all snapshots from repository
Enter : show snapshot contents
v : toggle snapshot view [Filtered -> All -> Marked -> Modified]
V : modify filter to use
Ctrl-v : reset filter
i : show detailed snapshot information for selected snapshot
w : write modified snapshots and delete snapshots to-forget
? : show this help page
Commands for marking snapshot(s):
x : toggle marking for selected snapshot
X : toggle markings for all snapshots
Ctrl-x : clear all markings
Commands applied to marked snapshot(s) (selected if none marked):
f : toggle to-forget for snapshot(s)
Ctrl-f : clear to-forget for snapshot(s)
l : set label for snapshot(s)
Ctrl-l : remove label for snapshot(s)
d : set description for snapshot(s)
Ctrl-d : remove description for snapshot(s)
t : add tag(s) for snapshot(s)
Ctrl-t : remove all tags for snapshot(s)
s : set tag(s) for snapshot(s)
r : remove tag(s) for snapshot(s)
p : set delete protection for snapshot(s)
Ctrl-p : remove delete protection for snapshot(s)
"#;
pub(crate) struct Snapshots<'a, P, S> {
current_screen: CurrentScreen<'a, P, S>,
current_view: View,
table: WithBlock<SelectTable>,
repo: &'a Repository<P, S>,
snaps_status: Vec<SnapStatus>,
snapshots: Vec<SnapshotFile>,
original_snapshots: Vec<SnapshotFile>,
filtered_snapshots: Vec<usize>,
tree: Tree<SnapshotNode, usize>,
filter: SnapshotFilter,
default_filter: SnapshotFilter,
group_by: SnapshotGroupCriterion,
}
impl<'a, P: ProgressBars, S: IndexedFull> Snapshots<'a, P, S> {
pub fn new(
repo: &'a Repository<P, S>,
filter: SnapshotFilter,
group_by: SnapshotGroupCriterion,
) -> Result<Self> {
let header = [
"", " ID", "Time", "Host", "Label", "Tags", "Paths", "Files", "Dirs", "Size",
]
.into_iter()
.map(Text::from)
.collect();
let mut app = Self {
current_screen: CurrentScreen::Snapshots,
current_view: View::Filter,
table: WithBlock::new(SelectTable::new(header), Block::new()),
repo,
snaps_status: Vec::new(),
original_snapshots: Vec::new(),
snapshots: Vec::new(),
filtered_snapshots: Vec::new(),
tree: Tree::Leaf(0),
default_filter: filter.clone(),
filter,
group_by,
};
app.reread()?;
Ok(app)
}
fn selected_tree(&self) -> Option<TreeIterItem<'_, SnapshotNode, usize>> {
self.table
.widget
.selected()
.and_then(|selected| self.tree.iter_open().nth(selected))
}
fn selected_tree_mut(&mut self) -> Option<&mut Tree<SnapshotNode, usize>> {
self.table
.widget
.selected()
.and_then(|selected| self.tree.nth_mut(selected))
}
fn snap_idx(&self) -> Vec<usize> {
self.selected_tree()
.iter()
.flat_map(|item| item.tree.iter().map(|item| item.tree))
.filter_map(|tree| tree.leaf_data().copied())
.collect()
}
fn selected_snapshot(&self) -> Option<&SnapshotFile> {
self.selected_tree().map(|tree_info| match tree_info.tree {
Tree::Leaf(index)
| Tree::Node(TreeNode {
data: SnapshotNode::Snap(index),
..
}) => Some(&self.snapshots[*index]),
_ => None,
})?
}
pub fn has_mark(&self) -> bool {
self.snaps_status.iter().any(|s| s.marked)
}
pub fn has_modified(&self) -> bool {
self.snaps_status.iter().any(|s| s.modified)
}
pub fn toggle_view_mark(&mut self) {
match self.current_view {
View::Filter => self.current_view = View::All,
View::All => {
self.current_view = View::Marked;
if !self.has_mark() {
self.toggle_view_mark();
}
}
View::Marked => {
self.current_view = View::Modified;
if !self.has_modified() {
self.toggle_view_mark();
}
}
View::Modified => self.current_view = View::Filter,
}
}
pub fn toggle_view(&mut self) {
self.toggle_view_mark();
self.apply_view();
}
pub fn apply_view(&mut self) {
// select snapshots to show
self.filtered_snapshots = self
.snapshots
.iter()
.enumerate()
.zip(self.snaps_status.iter())
.filter_map(|((i, sn), status)| {
match self.current_view {
View::All => true,
View::Filter => self.filter.matches(sn),
View::Marked => status.marked,
View::Modified => status.modified,
}
.then_some(i)
})
.collect();
self.create_tree();
}
pub fn create_tree(&mut self) {
// remember current snapshot index
let old_tree = self.selected_tree().map(|t| t.tree);
let mut result = Vec::new();
for (group, snaps) in &self
.filtered_snapshots
.iter()
.chunk_by(|i| SnapshotGroup::from_snapshot(&self.snapshots[**i], self.group_by))
{
let mut same_id_group = Vec::new();
for (_, s) in &snaps.into_iter().chunk_by(|i| self.snapshots[**i].tree) {
let leafs: Vec<_> = s.map(|i| Tree::leaf(*i)).collect();
let first = leafs[0].leaf_data().unwrap(); // Cannot be None as leafs[0] is a leaf!
if leafs.len() == 1 {
same_id_group.push(Tree::leaf(*first));
} else {
same_id_group.push(Tree::node(SnapshotNode::Snap(*first), false, leafs));
}
}
result.push(Tree::node(SnapshotNode::Group(group), false, same_id_group));
}
let tree = Tree::node(SnapshotNode::Snap(0), true, result);
let len = tree.iter_open().count();
let selected = if len == 0 {
None
} else {
Some(
tree.iter()
.position(|info| Some(info.tree) == old_tree)
.unwrap_or(len - 1),
)
};
self.tree = tree;
self.update_table();
self.table.widget.select(selected);
}
fn table_row(&self, info: TreeIterItem<'_, SnapshotNode, usize>) -> Vec<Text<'static>> {
let (has_mark, has_not_mark, has_modified, has_to_forget) = info
.tree
.iter()
.filter_map(|item| item.leaf_data().copied())
.fold(
(false, false, false, false),
|(mut a, mut b, mut c, mut d), i| {
if self.snaps_status[i].marked {
a = true;
} else {
b = true;
}
if self.snaps_status[i].modified {
c = true;
}
if self.snaps_status[i].to_forget {
d = true;
}
(a, b, c, d)
},
);
let mark = match (has_mark, has_not_mark) {
(false, _) => " ",
(true, true) => "*",
(true, false) => "X",
};
let modified = if has_modified { "*" } else { " " };
let del = if has_to_forget { "🗑" } else { "" };
let mut collapse = " ".repeat(info.depth);
collapse.push_str(match info.tree {
Tree::Leaf(_) => "",
Tree::Node(TreeNode { open: false, .. }) => "\u{25b6} ", // Arrow to right
Tree::Node(TreeNode { open: true, .. }) => "\u{25bc} ", // Arrow down
});
match info.tree {
Tree::Leaf(index)
| Tree::Node(TreeNode {
data: SnapshotNode::Snap(index),
..
}) => {
let snap = &self.snapshots[*index];
let symbols = match (
snap.delete == DeleteOption::NotSet,
snap.description.is_none(),
) {
(true, true) => "",
(true, false) => "🗎",
(false, true) => "🛡",
(false, false) => "🛡 🗎",
};
let count = info.tree.child_count();
once(&mark.to_string())
.chain(snap_to_table(snap, count).iter())
.cloned()
.enumerate()
.map(|(i, mut content)| {
if i == 1 {
// ID gets modified and protected marks
content = format!("{collapse}{modified}{del}{content}{symbols}");
}
Text::from(content)
})
.collect()
}
Tree::Node(TreeNode {
data: SnapshotNode::Group(group),
..
}) => {
let host = group
.hostname
.as_ref()
.map(String::from)
.unwrap_or_default();
let label = group.label.as_ref().map(String::from).unwrap_or_default();
let paths = group
.paths
.as_ref()
.map_or_else(String::default, |p| p.formatln());
let tags = group
.tags
.as_ref()
.map_or_else(String::default, |t| t.formatln());
[
mark.to_string(),
format!("{collapse}{modified}{del}group"),
String::default(),
host,
label,
tags,
paths,
String::default(),
String::default(),
String::default(),
]
.into_iter()
.map(Text::from)
.collect()
}
}
}
pub fn update_table(&mut self) {
let max_tags = self
.filtered_snapshots
.iter()
.map(|&i| self.snapshots[i].tags.iter().count())
.max()
.unwrap_or(1);
let max_paths = self
.filtered_snapshots
.iter()
.map(|&i| self.snapshots[i].paths.iter().count())
.max()
.unwrap_or(1);
let height = max_tags.max(max_paths).max(1) + 1;
let rows = self
.tree
.iter_open()
.map(|tree| self.table_row(tree))
.collect();
self.table.widget.set_content(rows, height);
self.table.block = Block::new()
.borders(Borders::BOTTOM)
.title_bottom(format!(
"{:?} view: {}, total: {}, marked: {}, modified: {}, to forget: {}",
self.current_view,
self.filtered_snapshots.len(),
self.snapshots.len(),
self.count_marked_snaps(),
self.count_modified_snaps(),
self.count_forget_snaps()
))
.title_alignment(Alignment::Center);
}
pub fn toggle_mark(&mut self) {
for snap_idx in self.snap_idx() {
self.snaps_status[snap_idx].toggle_mark();
}
self.update_table();
}
pub fn toggle_mark_all(&mut self) {
for snap_idx in &self.filtered_snapshots {
self.snaps_status[*snap_idx].toggle_mark();
}
self.update_table();
}
pub fn clear_marks(&mut self) {
for status in self.snaps_status.iter_mut() {
status.marked = false;
}
self.update_table();
}
pub fn reset_filter(&mut self) {
self.filter = self.default_filter.clone();
self.apply_view();
}
pub fn collapse(&mut self) {
if let Some(tree) = self.selected_tree_mut() {
tree.close();
self.update_table();
}
}
pub fn extendable(&self) -> bool {
matches!(self.selected_tree(), Some(tree_info) if tree_info.tree.openable())
}
pub fn extend(&mut self) {
if let Some(tree) = self.selected_tree_mut() {
tree.open();
self.update_table();
}
}
pub fn snapshot_details(&self) -> PopUpTable {
let mut rows = Vec::new();
if let Some(snap) = self.selected_snapshot() {
fill_table(snap, |title, value| {
rows.push(vec![Text::from(title.to_string()), Text::from(value)]);
});
}
popup_table("snapshot details", rows)
}
pub fn dir(&self) -> Result<Option<Snapshot<'a, P, S>>> {
self.selected_snapshot().map_or(Ok(None), |snap| {
Some(Snapshot::new(self.repo, snap.clone())).transpose()
})
}
pub fn count_marked_snaps(&self) -> usize {
self.snaps_status.iter().filter(|s| s.marked).count()
}
pub fn count_modified_snaps(&self) -> usize {
self.snaps_status.iter().filter(|s| s.modified).count()
}
pub fn count_forget_snaps(&self) -> usize {
self.snaps_status.iter().filter(|s| s.to_forget).count()
}
// process marked snapshots (or the current one if none is marked)
// the process function must return true if it modified the snapshot, else false
pub fn process_marked_snaps(&mut self, mut process: impl FnMut(&mut SnapshotFile) -> bool) {
let has_mark = self.has_mark();
if !has_mark {
self.toggle_mark();
}
for ((snap, status), original_snap) in self
.snapshots
.iter_mut()
.zip(self.snaps_status.iter_mut())
.zip(self.original_snapshots.iter())
{
if status.marked && process(snap) {
// Note that snap impls Eq, but only by comparing the time!
status.modified =
serde_json::to_string(snap).ok() != serde_json::to_string(original_snap).ok();
}
}
if !has_mark {
self.toggle_mark();
}
self.update_table();
}
pub fn get_snap_entity(&mut self, f: impl Fn(&SnapshotFile) -> String) -> String {
let has_mark = self.has_mark();
if !has_mark {
self.toggle_mark();
}
let entity = self
.snapshots
.iter()
.zip(self.snaps_status.iter())
.filter_map(|(snap, status)| status.marked.then_some(f(snap)))
.reduce(|entity, e| if entity == e { e } else { String::new() })
.unwrap_or_default();
if !has_mark {
self.toggle_mark();
}
entity
}
pub fn get_label(&mut self) -> String {
self.get_snap_entity(|snap| snap.label.clone())
}
pub fn get_tags(&mut self) -> String {
self.get_snap_entity(|snap| snap.tags.formatln())
}
pub fn get_description(&mut self) -> String {
self.get_snap_entity(|snap| snap.description.clone().unwrap_or_default())
}
pub fn get_filter(&self) -> Result<String> {
Ok(toml::to_string_pretty(&self.filter)?)
}
pub fn set_filter(&mut self, filter: String) {
if let Ok(filter) = toml::from_str::<SnapshotFilter>(&filter) {
self.filter = filter;
self.apply_view();
}
}
pub fn set_label(&mut self, label: String) {
self.process_marked_snaps(|snap| {
if snap.label == label {
return false;
}
snap.label.clone_from(&label);
true
});
}
pub fn clear_label(&mut self) {
self.set_label(String::new());
}
pub fn set_description(&mut self, desc: String) {
let desc = if desc.is_empty() { None } else { Some(desc) };
self.process_marked_snaps(|snap| {
if snap.description == desc {
return false;
}
snap.description.clone_from(&desc);
true
});
}
pub fn clear_description(&mut self) {
self.set_description(String::new());
}
pub fn add_tags(&mut self, tags: String) {
let tags = vec![StringList::from_str(&tags).unwrap()];
self.process_marked_snaps(|snap| snap.add_tags(tags.clone()));
}
pub fn set_tags(&mut self, tags: String) {
let tags = vec![StringList::from_str(&tags).unwrap()];
self.process_marked_snaps(|snap| snap.set_tags(tags.clone()));
}
pub fn remove_tags(&mut self, tags: String) {
let tags = vec![StringList::from_str(&tags).unwrap()];
self.process_marked_snaps(|snap| snap.remove_tags(&tags));
}
pub fn clear_tags(&mut self) {
let no_tags = vec![StringList::default()];
self.process_marked_snaps(|snap| snap.set_tags(no_tags.clone()));
}
pub fn set_delete_protection_to(&mut self, delete: DeleteOption) {
self.process_marked_snaps(|snap| {
if snap.delete == delete {
return false;
}
snap.delete = delete;
true
});
}
pub fn toggle_to_forget(&mut self) {
let has_mark = self.has_mark();
if !has_mark {
self.toggle_mark();
}
let now = Local::now();
for (snap, status) in self.snapshots.iter_mut().zip(self.snaps_status.iter_mut()) {
if status.marked {
if status.to_forget {
status.to_forget = false;
} else if !snap.must_keep(now) {
status.to_forget = true;
}
}
}
if !has_mark {
self.toggle_mark();
}
self.update_table();
}
pub fn clear_to_forget(&mut self) {
for status in self.snaps_status.iter_mut() {
status.to_forget = false;
}
self.update_table();
}
pub fn apply_input(&mut self, input: String) {
match self.current_screen {
CurrentScreen::EnterLabel(_) => self.set_label(input),
CurrentScreen::EnterDescription(_) => self.set_description(input),
CurrentScreen::EnterAddTags(_) => self.add_tags(input),
CurrentScreen::EnterSetTags(_) => self.set_tags(input),
CurrentScreen::EnterRemoveTags(_) => self.remove_tags(input),
CurrentScreen::EnterFilter(_) => self.set_filter(input),
_ => {}
}
}
pub fn set_delete_protection(&mut self) {
self.set_delete_protection_to(DeleteOption::Never);
}
pub fn clear_delete_protection(&mut self) {
self.set_delete_protection_to(DeleteOption::NotSet);
}
pub fn write(&mut self) -> Result<()> {
if !self.has_modified() && self.count_forget_snaps() == 0 {
return Ok(());
};
let save_snaps: Vec<_> = self
.snapshots
.iter()
.zip(self.snaps_status.iter())
.filter_map(|(snap, status)| (status.modified && !status.to_forget).then_some(snap))
.cloned()
.collect();
let old_snap_ids = save_snaps.iter().map(|sn| sn.id);
let snap_ids_to_forget = self
.snapshots
.iter()
.zip(self.snaps_status.iter())
.filter_map(|(snap, status)| status.to_forget.then_some(snap.id));
let delete_ids: Vec<_> = old_snap_ids.chain(snap_ids_to_forget).collect();
self.repo.save_snapshots(save_snaps)?;
self.repo.delete_snapshots(&delete_ids)?;
// remove snapshots-to-reread
let ids: BTreeSet<_> = delete_ids.into_iter().collect();
self.snapshots.retain(|snap| !ids.contains(&snap.id));
// re-read snapshots
self.reread()
}
// re-read all snapshots
pub fn reread(&mut self) -> Result<()> {
let snapshots = mem::take(&mut self.snapshots);
self.snapshots = self.repo.update_all_snapshots(snapshots)?;
self.snapshots
.sort_unstable_by(|sn1, sn2| sn1.cmp_group(self.group_by, sn2).then(sn1.cmp(sn2)));
self.snaps_status = vec![SnapStatus::default(); self.snapshots.len()];
self.original_snapshots.clone_from(&self.snapshots);
self.table.widget.select(None);
self.apply_view();
Ok(())
}
pub fn input(&mut self, event: Event) -> Result<bool> {
use KeyCode::*;
match &mut self.current_screen {
CurrentScreen::Snapshots => {
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => {
if key.modifiers == KeyModifiers::CONTROL {
match key.code {
Char('f') => self.clear_to_forget(),
Char('x') => self.clear_marks(),
Char('l') => self.clear_label(),
Char('d') => self.clear_description(),
Char('t') => self.clear_tags(),
Char('p') => self.clear_delete_protection(),
Char('v') => self.reset_filter(),
_ => {}
}
} else {
match key.code {
Esc | Char('q') => {
self.current_screen = CurrentScreen::PromptExit(popup_prompt(
"exit rustic",
"do you want to exit? (y/n)".into(),
));
}
Char('f') => self.toggle_to_forget(),
F(5) => self.reread()?,
Enter => {
if let Some(dir) = self.dir()? {
self.current_screen = CurrentScreen::Dir(dir);
}
}
Right => {
if self.extendable() {
self.extend();
} else if let Some(dir) = self.dir()? {
self.current_screen = CurrentScreen::Dir(dir);
}
}
Char('+') => {
if self.extendable() {
self.extend();
}
}
Left | Char('-') => self.collapse(),
Char('?') => {
self.current_screen = CurrentScreen::ShowHelp(popup_text(
"help",
HELP_TEXT.into(),
));
}
Char('x') => {
self.toggle_mark();
self.table.widget.next();
}
Char('X') => self.toggle_mark_all(),
Char('v') => self.toggle_view(),
Char('V') => {
self.current_screen = CurrentScreen::EnterFilter(popup_input(
"set filter (Ctrl-s to confirm)",
"enter filter in TOML format",
&self.get_filter()?,
15,
));
}
Char('i') => {
self.current_screen =
CurrentScreen::SnapshotDetails(self.snapshot_details());
}
Char('l') => {
self.current_screen = CurrentScreen::EnterLabel(popup_input(
"set label",
"enter label",
&self.get_label(),
1,
));
}
Char('d') => {
self.current_screen =
CurrentScreen::EnterDescription(popup_input(
"set description (Ctrl-s to confirm)",
"enter description",
&self.get_description(),
5,
));
}
Char('t') => {
self.current_screen = CurrentScreen::EnterAddTags(popup_input(
"add tags",
"enter tags",
"",
1,
));
}
Char('s') => {
self.current_screen = CurrentScreen::EnterSetTags(popup_input(
"set tags",
"enter tags",
&self.get_tags(),
1,
));
}
Char('r') => {
self.current_screen = CurrentScreen::EnterRemoveTags(
popup_input("remove tags", "enter tags", "", 1),
);
}
// TODO: Allow to enter delete protection option
Char('p') => self.set_delete_protection(),
Char('w') => {
let msg = format!(
"Do you want to write {} modified and remove {} snapshots? (y/n)",
self.count_modified_snaps(),
self.count_forget_snaps()
);
self.current_screen = CurrentScreen::PromptWrite(popup_prompt(
"write snapshots",
msg.into(),
));
}
_ => self.table.input(event),
}
}
}
_ => {}
}
}
CurrentScreen::SnapshotDetails(_) | CurrentScreen::ShowHelp(_) => match event {
Event::Key(key) if key.kind == KeyEventKind::Press => {
if matches!(
key.code,
Char('q') | Esc | Enter | Char(' ') | Char('i') | Char('?')
) {
self.current_screen = CurrentScreen::Snapshots;
}
}
_ => {}
},
CurrentScreen::EnterLabel(prompt)
| CurrentScreen::EnterDescription(prompt)
| CurrentScreen::EnterAddTags(prompt)
| CurrentScreen::EnterSetTags(prompt)
| CurrentScreen::EnterRemoveTags(prompt)
| CurrentScreen::EnterFilter(prompt) => match prompt.input(event) {
TextInputResult::Cancel => self.current_screen = CurrentScreen::Snapshots,
TextInputResult::Input(input) => {
self.apply_input(input);
self.current_screen = CurrentScreen::Snapshots;
}
TextInputResult::None => {}
},
CurrentScreen::PromptWrite(prompt) => match prompt.input(event) {
PromptResult::Ok => {
self.write()?;
self.current_screen = CurrentScreen::Snapshots;
}
PromptResult::Cancel => self.current_screen = CurrentScreen::Snapshots,
PromptResult::None => {}
},
CurrentScreen::PromptExit(prompt) => match prompt.input(event) {
PromptResult::Ok => return Ok(true),
PromptResult::Cancel => self.current_screen = CurrentScreen::Snapshots,
PromptResult::None => {}
},
CurrentScreen::Dir(dir) => match dir.input(event)? {
SnapshotResult::Exit => return Ok(true),
SnapshotResult::Return => self.current_screen = CurrentScreen::Snapshots,
SnapshotResult::None => {}
},
}
Ok(false)
}
pub fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
if let CurrentScreen::Dir(dir) = &mut self.current_screen {
dir.draw(area, f);
return;
}
let rects = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(area);
// draw the table
self.table.draw(rects[0], f);
// draw the footer
let buffer_bg = tailwind::SLATE.c950;
let row_fg = tailwind::SLATE.c200;
let info_footer = Paragraph::new(Line::from(INFO_TEXT))
.style(Style::new().fg(row_fg).bg(buffer_bg))
.centered();
f.render_widget(info_footer, rects[1]);
// draw popups
match &mut self.current_screen {
CurrentScreen::SnapshotDetails(popup) => popup.draw(area, f),
CurrentScreen::ShowHelp(popup) => popup.draw(area, f),
CurrentScreen::EnterLabel(popup)
| CurrentScreen::EnterDescription(popup)
| CurrentScreen::EnterAddTags(popup)
| CurrentScreen::EnterSetTags(popup)
| CurrentScreen::EnterRemoveTags(popup)
| CurrentScreen::EnterFilter(popup) => popup.draw(area, f),
CurrentScreen::PromptWrite(popup) | CurrentScreen::PromptExit(popup) => {
popup.draw(area, f);
}
_ => {}
}
}
}

144
src/commands/tui/tree.rs Normal file
View File

@ -0,0 +1,144 @@
#[derive(PartialEq, Eq)]
pub struct TreeNode<Data, LeafData> {
pub data: Data,
pub open: bool,
pub children: Vec<Tree<Data, LeafData>>,
}
#[derive(PartialEq, Eq)]
pub enum Tree<Data, LeafData> {
Node(TreeNode<Data, LeafData>),
Leaf(LeafData),
}
impl<Data, LeafData> Tree<Data, LeafData> {
pub fn leaf(data: LeafData) -> Self {
Self::Leaf(data)
}
pub fn node(data: Data, open: bool, children: Vec<Self>) -> Self {
Self::Node(TreeNode {
data,
open,
children,
})
}
pub fn child_count(&self) -> usize {
match self {
Self::Leaf(_) => 0,
Self::Node(TreeNode { children, .. }) => {
children.len() + children.iter().map(Self::child_count).sum::<usize>()
}
}
}
pub fn leaf_data(&self) -> Option<&LeafData> {
match self {
Self::Node(_) => None,
Self::Leaf(data) => Some(data),
}
}
pub fn openable(&self) -> bool {
matches!(self, Self::Node(node) if !node.open)
}
pub fn open(&mut self) {
if let Self::Node(node) = self {
node.open = true;
}
}
pub fn close(&mut self) {
if let Self::Node(node) = self {
node.open = false;
}
}
pub fn iter(&self) -> impl Iterator<Item = TreeIterItem<'_, Data, LeafData>> {
TreeIter {
tree: Some(self),
iter_stack: Vec::new(),
only_open: false,
}
}
// iter open tree descending only into open nodes.
// Note: This iterator skips the root node!
pub fn iter_open(&self) -> impl Iterator<Item = TreeIterItem<'_, Data, LeafData>> {
TreeIter {
tree: Some(self),
iter_stack: Vec::new(),
only_open: true,
}
.skip(1)
}
pub fn nth_mut(&mut self, n: usize) -> Option<&mut Self> {
let mut count = 0;
let mut tree = Some(self);
let mut iter_stack = Vec::new();
loop {
if count == n + 1 {
return tree;
}
let item = tree?;
if let Self::Node(node) = item {
if node.open {
iter_stack.push(node.children.iter_mut());
}
}
tree = next_from_iter_stack(&mut iter_stack);
count += 1;
}
}
}
pub struct TreeIterItem<'a, Data, LeadData> {
pub depth: usize,
pub tree: &'a Tree<Data, LeadData>,
}
impl<'a, Data, LeafData> TreeIterItem<'a, Data, LeafData> {
pub fn leaf_data(&self) -> Option<&LeafData> {
self.tree.leaf_data()
}
}
pub struct TreeIter<'a, Data, LeafData> {
tree: Option<&'a Tree<Data, LeafData>>,
iter_stack: Vec<std::slice::Iter<'a, Tree<Data, LeafData>>>,
only_open: bool,
}
impl<'a, Data, LeafData> Iterator for TreeIter<'a, Data, LeafData> {
type Item = TreeIterItem<'a, Data, LeafData>;
fn next(&mut self) -> Option<Self::Item> {
let item = self.tree?;
let depth = self.iter_stack.len();
if let Tree::Node(node) = item {
if !self.only_open || node.open {
self.iter_stack.push(node.children.iter());
}
}
self.tree = next_from_iter_stack(&mut self.iter_stack);
Some(TreeIterItem { depth, tree: item })
}
}
// helper function to get next item from iteration stack when iterating over a Tree
fn next_from_iter_stack<T>(stack: &mut Vec<impl Iterator<Item = T>>) -> Option<T> {
loop {
match stack.pop() {
None => {
break None;
}
Some(mut iter) => {
if let Some(next) = iter.next() {
stack.push(iter);
break Some(next);
}
}
}
}
}

102
src/commands/tui/widgets.rs Normal file
View File

@ -0,0 +1,102 @@
mod popup;
mod prompt;
mod select_table;
mod sized_gauge;
mod sized_paragraph;
mod sized_table;
mod text_input;
mod with_block;
pub use popup::*;
pub use prompt::*;
use ratatui::widgets::block::Title;
pub use select_table::*;
pub use sized_gauge::*;
pub use sized_paragraph::*;
pub use sized_table::*;
pub use text_input::*;
pub use with_block::*;
use crossterm::event::Event;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
use ratatui::prelude::*;
use ratatui::widgets::*;
pub trait ProcessEvent {
type Result;
fn input(&mut self, event: Event) -> Self::Result;
}
pub trait SizedWidget {
fn height(&self) -> Option<u16> {
None
}
fn width(&self) -> Option<u16> {
None
}
}
pub trait Draw {
fn draw(&mut self, area: Rect, f: &mut Frame<'_>);
}
// the widgets we are using and convenience builders
pub type PopUpInput = PopUp<WithBlock<TextInput>>;
pub fn popup_input(
title: impl Into<Title<'static>>,
text: &str,
initial: &str,
lines: u16,
) -> PopUpInput {
PopUp(WithBlock::new(
TextInput::new(Some(text), initial, lines, true),
Block::bordered().title(title),
))
}
pub fn popup_scrollable_text(
title: impl Into<Title<'static>>,
text: &str,
lines: u16,
) -> PopUpInput {
PopUp(WithBlock::new(
TextInput::new(None, text, lines, false),
Block::bordered().title(title),
))
}
pub type PopUpText = PopUp<WithBlock<SizedParagraph>>;
pub fn popup_text(title: impl Into<Title<'static>>, text: Text<'static>) -> PopUpText {
PopUp(WithBlock::new(
SizedParagraph::new(text),
Block::bordered().title(title),
))
}
pub type PopUpTable = PopUp<WithBlock<SizedTable>>;
pub fn popup_table(
title: impl Into<Title<'static>>,
content: Vec<Vec<Text<'static>>>,
) -> PopUpTable {
PopUp(WithBlock::new(
SizedTable::new(content),
Block::bordered().title(title),
))
}
pub type PopUpPrompt = Prompt<PopUpText>;
pub fn popup_prompt(title: &'static str, text: Text<'static>) -> PopUpPrompt {
Prompt(popup_text(title, text))
}
pub type PopUpGauge = PopUp<WithBlock<SizedGauge>>;
pub fn popup_gauge(
title: impl Into<Title<'static>>,
text: Span<'static>,
ratio: f64,
) -> PopUpGauge {
PopUp(WithBlock::new(
SizedGauge::new(text, ratio),
Block::bordered().title(title),
))
}

View File

@ -0,0 +1,38 @@
use super::*;
// Make a popup from a SizedWidget
pub struct PopUp<T>(pub T);
impl<T: ProcessEvent> ProcessEvent for PopUp<T> {
type Result = T::Result;
fn input(&mut self, event: Event) -> Self::Result {
self.0.input(event)
}
}
impl<T: Draw + SizedWidget> Draw for PopUp<T> {
fn draw(&mut self, mut area: Rect, f: &mut Frame<'_>) {
// center vertically
if let Some(h) = self.0.height() {
let layout = Layout::vertical([
Constraint::Min(1),
Constraint::Length(h),
Constraint::Min(1),
]);
area = layout.split(area)[1];
}
// center horizontally
if let Some(w) = self.0.width() {
let layout = Layout::horizontal([
Constraint::Min(1),
Constraint::Length(w),
Constraint::Min(1),
]);
area = layout.split(area)[1];
}
f.render_widget(Clear, area);
self.0.draw(area, f);
}
}

View File

@ -0,0 +1,39 @@
use super::*;
pub struct Prompt<T>(pub T);
pub enum PromptResult {
Ok,
Cancel,
None,
}
impl<T: SizedWidget> SizedWidget for Prompt<T> {
fn height(&self) -> Option<u16> {
self.0.height()
}
fn width(&self) -> Option<u16> {
self.0.width()
}
}
impl<T: Draw> Draw for Prompt<T> {
fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
self.0.draw(area, f);
}
}
impl<T> ProcessEvent for Prompt<T> {
type Result = PromptResult;
fn input(&mut self, event: Event) -> PromptResult {
use KeyCode::*;
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
Char('q') | Char('n') | Char('c') | Esc => PromptResult::Cancel,
Enter | Char('y') | Char('j') | Char(' ') => PromptResult::Ok,
_ => PromptResult::None,
},
_ => PromptResult::None,
}
}
}

View File

@ -0,0 +1,196 @@
use super::*;
use std::iter::once;
use style::palette::tailwind;
struct TableColors {
buffer_bg: Color,
header_bg: Color,
header_fg: Color,
row_fg: Color,
selected_style_fg: Color,
normal_row_color: Color,
alt_row_color: Color,
}
impl TableColors {
fn new(color: &tailwind::Palette) -> Self {
Self {
buffer_bg: tailwind::SLATE.c950,
header_bg: color.c900,
header_fg: tailwind::SLATE.c200,
row_fg: tailwind::SLATE.c200,
selected_style_fg: color.c400,
normal_row_color: tailwind::SLATE.c950,
alt_row_color: tailwind::SLATE.c900,
}
}
}
pub struct SelectTable {
header: Vec<Text<'static>>,
table: Table<'static>,
state: TableState,
scroll_state: ScrollbarState,
rows: usize,
rows_display: usize,
row_height: usize,
}
impl SelectTable {
pub fn new(header: Vec<Text<'static>>) -> Self {
let table = Table::default();
Self {
header,
table,
state: TableState::default(),
scroll_state: ScrollbarState::new(0),
rows: 0,
rows_display: 0,
row_height: 0,
}
}
pub fn set_content(&mut self, content: Vec<Vec<Text<'static>>>, row_height: usize) {
let colors = TableColors::new(&tailwind::BLUE);
let selected_style = Style::default()
.add_modifier(Modifier::REVERSED)
.fg(colors.selected_style_fg);
let header_style = Style::default().fg(colors.header_fg).bg(colors.header_bg);
self.row_height = row_height;
let widths = once(&self.header)
.chain(content.iter())
.map(|row| row.iter().map(Text::width).collect())
.reduce(|widths: Vec<usize>, row| {
row.iter()
.zip(widths.iter())
.map(|(r, w)| r.max(w))
.cloned()
.collect()
})
.unwrap_or_default();
self.rows = content.len();
self.scroll_state = ScrollbarState::new(self.rows * self.row_height);
let content = content.into_iter().enumerate().map(|(i, row)| {
let color = match i % 2 {
0 => colors.normal_row_color,
_ => colors.alt_row_color,
};
Row::new(row)
.style(Style::new().fg(colors.row_fg).bg(color))
.height(self.row_height.try_into().unwrap())
});
self.table = Table::default()
.header(Row::new(self.header.clone()).style(header_style))
.row_highlight_style(selected_style)
.bg(colors.buffer_bg)
.widths(widths.iter().map(|w| {
(*w).try_into()
.ok()
.map_or(Constraint::Min(0), Constraint::Length)
}))
.flex(layout::Flex::SpaceBetween)
.rows(content);
}
pub fn selected(&self) -> Option<usize> {
self.state.selected()
}
pub fn select(&mut self, index: Option<usize>) {
self.state.select(index);
}
pub fn set_to(&mut self, i: usize) {
self.state.select(Some(i));
self.scroll_state = self.scroll_state.position(i * self.row_height);
}
pub fn go_forward(&mut self, step: usize) {
if let Some(selected_old) = self.state.selected() {
let selected = (selected_old + step).min(self.rows - 1);
self.set_to(selected);
}
}
pub fn go_back(&mut self, step: usize) {
if let Some(selected_old) = self.state.selected() {
let selected = selected_old.saturating_sub(step);
self.set_to(selected);
}
}
pub fn next(&mut self) {
self.go_forward(1);
}
pub fn page_down(&mut self) {
self.go_forward(self.rows_display);
}
pub fn previous(&mut self) {
self.go_back(1);
}
pub fn page_up(&mut self) {
self.go_back(self.rows_display);
}
pub fn home(&mut self) {
if self.state.selected().is_some() {
self.set_to(0);
}
}
pub fn end(&mut self) {
if self.state.selected().is_some() {
self.set_to(self.rows - 1);
}
}
pub fn set_rows(&mut self, rows: usize) {
self.rows_display = rows / self.row_height;
}
}
impl ProcessEvent for SelectTable {
type Result = ();
fn input(&mut self, event: Event) {
use KeyCode::*;
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
Down => self.next(),
Up => self.previous(),
PageDown => self.page_down(),
PageUp => self.page_up(),
Home => self.home(),
End => self.end(),
_ => {}
},
_ => {}
}
}
}
impl SizedWidget for SelectTable {}
impl Draw for SelectTable {
fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
self.set_rows(area.height.into());
let chunks = Layout::horizontal([Constraint::Min(0), Constraint::Length(1)]).split(area);
f.render_stateful_widget(&self.table, chunks[0], &mut self.state);
f.render_stateful_widget(
Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None),
chunks[1],
&mut self.scroll_state,
);
}
}

View File

@ -0,0 +1,33 @@
use super::*;
pub struct SizedGauge {
p: Gauge<'static>,
width: Option<u16>,
}
impl SizedGauge {
pub fn new(text: Span<'static>, ratio: f64) -> Self {
let width = text.width().try_into().ok();
let p = Gauge::default()
.gauge_style(Style::default().fg(Color::Blue))
.use_unicode(true)
.label(text)
.ratio(ratio);
Self { p, width }
}
}
impl SizedWidget for SizedGauge {
fn width(&self) -> Option<u16> {
self.width.map(|w| w + 10)
}
fn height(&self) -> Option<u16> {
Some(1)
}
}
impl Draw for SizedGauge {
fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
f.render_widget(&self.p, area);
}
}

View File

@ -0,0 +1,31 @@
use super::*;
pub struct SizedParagraph {
p: Paragraph<'static>,
height: Option<u16>,
width: Option<u16>,
}
impl SizedParagraph {
pub fn new(text: Text<'static>) -> Self {
let height = text.height().try_into().ok();
let width = text.width().try_into().ok();
let p = Paragraph::new(text);
Self { p, height, width }
}
}
impl SizedWidget for SizedParagraph {
fn width(&self) -> Option<u16> {
self.width
}
fn height(&self) -> Option<u16> {
self.height
}
}
impl Draw for SizedParagraph {
fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
f.render_widget(&self.p, area);
}
}

View File

@ -0,0 +1,63 @@
use super::*;
pub struct SizedTable {
table: Table<'static>,
height: usize,
width: usize,
}
impl SizedTable {
pub fn new(content: Vec<Vec<Text<'static>>>) -> Self {
let height = content
.iter()
.map(|row| row.iter().map(Text::height).max().unwrap_or_default())
.sum::<usize>();
let widths = content
.iter()
.map(|row| row.iter().map(Text::width).collect())
.reduce(|widths: Vec<usize>, row| {
row.iter()
.zip(widths.iter())
.map(|(r, w)| r.max(w))
.cloned()
.collect()
})
.unwrap_or_default();
let width = widths
.iter()
.cloned()
.reduce(|width, w| width + w + 1) // +1 because of space between entries
.unwrap_or_default();
let rows = content.into_iter().map(Row::new);
let table = Table::default()
.widths(widths.iter().map(|w| {
(*w).try_into()
.ok()
.map_or(Constraint::Min(0), Constraint::Length)
}))
.rows(rows);
Self {
table,
height,
width,
}
}
}
impl SizedWidget for SizedTable {
fn height(&self) -> Option<u16> {
self.height.try_into().ok()
}
fn width(&self) -> Option<u16> {
self.width.try_into().ok()
}
}
impl Draw for SizedTable {
fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
f.render_widget(&self.table, area);
}
}

View File

@ -0,0 +1,88 @@
use super::*;
use crossterm::event::KeyModifiers;
use tui_textarea::{CursorMove, TextArea};
pub struct TextInput {
textarea: TextArea<'static>,
lines: u16,
changeable: bool,
}
pub enum TextInputResult {
Cancel,
Input(String),
None,
}
impl TextInput {
pub fn new(text: Option<&str>, initial: &str, lines: u16, changeable: bool) -> Self {
let mut textarea = TextArea::default();
textarea.set_style(Style::default());
if let Some(text) = text {
textarea.set_placeholder_text(text);
}
_ = textarea.insert_str(initial);
if !changeable {
textarea.move_cursor(CursorMove::Top);
}
Self {
textarea,
lines,
changeable,
}
}
}
impl SizedWidget for TextInput {
fn height(&self) -> Option<u16> {
Some(self.lines)
}
}
impl Draw for TextInput {
fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
f.render_widget(&self.textarea, area);
}
}
impl ProcessEvent for TextInput {
type Result = TextInputResult;
fn input(&mut self, event: Event) -> TextInputResult {
if let Event::Key(key) = event {
let KeyEvent {
code, modifiers, ..
} = key;
use KeyCode::*;
if self.changeable {
match (code, modifiers) {
(Esc, _) => return TextInputResult::Cancel,
(Enter, _) if self.lines == 1 => {
return TextInputResult::Input(self.textarea.lines().join("\n"));
}
(Char('s'), KeyModifiers::CONTROL) => {
return TextInputResult::Input(self.textarea.lines().join("\n"));
}
_ => {
_ = self.textarea.input(key);
}
}
} else {
match (code, modifiers) {
(Esc | Enter | Char('q') | Char('x'), _) => return TextInputResult::Cancel,
(Home, _) => {
self.textarea.move_cursor(CursorMove::Top);
}
(End, _) => {
self.textarea.move_cursor(CursorMove::Bottom);
}
(PageDown | PageUp | Up | Down, _) => {
_ = self.textarea.input(key);
}
_ => {}
}
}
}
TextInputResult::None
}
}

View File

@ -0,0 +1,57 @@
use super::*;
use layout::Size;
pub struct WithBlock<T> {
pub block: Block<'static>,
pub widget: T,
}
impl<T> WithBlock<T> {
pub fn new(widget: T, block: Block<'static>) -> Self {
Self { block, widget }
}
// Note: this could be a method of self.block, but is unfortunately not present
// So we compute ourselves using self.block.inner() on an artificial Rect.
fn size_diff(&self) -> Size {
let rect = Rect {
x: 0,
y: 0,
width: u16::MAX,
height: u16::MAX,
};
let inner = self.block.inner(rect);
Size {
width: rect.as_size().width - inner.as_size().width,
height: rect.as_size().height - inner.as_size().height,
}
}
}
impl<T: ProcessEvent> ProcessEvent for WithBlock<T> {
type Result = T::Result;
fn input(&mut self, event: Event) -> Self::Result {
self.widget.input(event)
}
}
impl<T: SizedWidget> SizedWidget for WithBlock<T> {
fn height(&self) -> Option<u16> {
self.widget
.height()
.map(|h| h.saturating_add(self.size_diff().height))
}
fn width(&self) -> Option<u16> {
self.widget
.width()
.map(|w| w.saturating_add(self.size_diff().width))
}
}
impl<T: Draw + SizedWidget> Draw for WithBlock<T> {
fn draw(&mut self, area: Rect, f: &mut Frame<'_>) {
f.render_widget(self.block.clone(), area);
self.widget.draw(self.block.inner(area), f);
}
}

143
src/commands/webdav.rs Normal file
View File

@ -0,0 +1,143 @@
//! `webdav` subcommand
// ignore markdown clippy lints as we use doc-comments to generate clap help texts
#![allow(clippy::doc_markdown)]
use std::net::ToSocketAddrs;
use crate::{repository::CliIndexedRepo, status_err, Application, RusticConfig, RUSTIC_APP};
use abscissa_core::{config::Override, Command, FrameworkError, Runnable, Shutdown};
use anyhow::{anyhow, Result};
use conflate::Merge;
use dav_server::{warp::dav_handler, DavHandler};
use serde::{Deserialize, Serialize};
use rustic_core::vfs::{FilePolicy, IdenticalSnapshot, Latest, Vfs};
#[derive(Clone, Command, Default, Debug, clap::Parser, Serialize, Deserialize, Merge)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
pub struct WebDavCmd {
/// Address to bind the webdav server to. [default: "localhost:8000"]
#[clap(long, value_name = "ADDRESS")]
#[merge(strategy=conflate::option::overwrite_none)]
address: Option<String>,
/// 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}"]
#[clap(long)]
#[merge(strategy=conflate::option::overwrite_none)]
path_template: Option<String>,
/// 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"]
#[clap(long)]
#[merge(strategy=conflate::option::overwrite_none)]
time_template: Option<String>,
/// Use symlinks. This may not be supported by all WebDAV clients
#[clap(long)]
#[merge(strategy=conflate::bool::overwrite_false)]
symlinks: bool,
/// How to handle access to files. [default: "forbidden" for hot/cold repositories, else "read"]
#[clap(long)]
#[merge(strategy=conflate::option::overwrite_none)]
file_access: Option<String>,
/// Specify directly which snapshot/path to serve
#[clap(value_name = "SNAPSHOT[:PATH]")]
#[merge(strategy=conflate::option::overwrite_none)]
snapshot_path: Option<String>,
}
impl Override<RusticConfig> for WebDavCmd {
// Process the given command line options, overriding settings from
// a configuration file using explicit flags taken from command-line
// arguments.
fn override_config(&self, mut config: RusticConfig) -> Result<RusticConfig, FrameworkError> {
let mut self_config = self.clone();
// merge "webdav" section from config file, if given
self_config.merge(config.webdav);
config.webdav = self_config;
Ok(config)
}
}
impl Runnable for WebDavCmd {
fn run(&self) {
if let Err(err) = RUSTIC_APP
.config()
.repository
.run_indexed(|repo| self.inner_run(repo))
{
status_err!("{}", err);
RUSTIC_APP.shutdown(Shutdown::Crash);
};
}
}
impl WebDavCmd {
/// be careful about self VS RUSTIC_APP.config() usage
/// only the RUSTIC_APP.config() involves the TOML and ENV merged configurations
/// see https://github.com/rustic-rs/rustic/issues/1242
fn inner_run(&self, repo: CliIndexedRepo) -> Result<()> {
let config = RUSTIC_APP.config();
let path_template = config
.webdav
.path_template
.clone()
.unwrap_or_else(|| "[{hostname}]/[{label}]/{time}".to_string());
let time_template = config
.webdav
.time_template
.clone()
.unwrap_or_else(|| "%Y-%m-%d_%H-%M-%S".to_string());
let sn_filter = |sn: &_| config.snapshot_filter.matches(sn);
let vfs = if let Some(snap) = &config.webdav.snapshot_path {
let node = repo.node_from_snapshot_path(snap, sn_filter)?;
Vfs::from_dir_node(&node)
} else {
let snapshots = repo.get_matching_snapshots(sn_filter)?;
let (latest, identical) = if config.webdav.symlinks {
(Latest::AsLink, IdenticalSnapshot::AsLink)
} else {
(Latest::AsDir, IdenticalSnapshot::AsDir)
};
Vfs::from_snapshots(snapshots, &path_template, &time_template, latest, identical)?
};
let addr = config
.webdav
.address
.clone()
.unwrap_or_else(|| "localhost:8000".to_string())
.to_socket_addrs()?
.next()
.ok_or_else(|| anyhow!("no address given"))?;
let file_access = config.webdav.file_access.as_ref().map_or_else(
|| {
if repo.config().is_hot == Some(true) {
Ok(FilePolicy::Forbidden)
} else {
Ok(FilePolicy::Read)
}
},
|s| s.parse(),
)?;
let dav_server = DavHandler::builder()
.filesystem(vfs.into_webdav_fs(repo, file_access))
.build_handler();
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async {
warp::serve(dav_handler(dav_server)).run(addr).await;
});
Ok(())
}
}

View File

@ -4,27 +4,33 @@
//! application's configuration file and/or command-line options
//! for specifying it.
pub(crate) mod hooks;
pub(crate) mod progress_options;
use std::fmt::Debug;
use std::{collections::HashMap, path::PathBuf};
use abscissa_core::{config::Config, path::AbsPathBuf, FrameworkError};
use anyhow::Result;
use clap::{Parser, ValueHint};
use conflate::Merge;
use directories::ProjectDirs;
use merge::Merge;
use abscissa_core::config::Config;
use abscissa_core::path::AbsPathBuf;
use abscissa_core::FrameworkError;
use clap::Parser;
use itertools::Itertools;
use log::Level;
use rustic_core::RepositoryOptions;
use serde::{Deserialize, Serialize};
#[cfg(not(all(feature = "mount", feature = "webdav")))]
use toml::Value;
#[cfg(feature = "mount")]
use crate::commands::mount::MountCmd;
#[cfg(feature = "webdav")]
use crate::commands::webdav::WebDavCmd;
use crate::{
commands::{backup::BackupCmd, copy::Targets, forget::ForgetOptions},
config::progress_options::ProgressOptions,
commands::{backup::BackupCmd, copy::CopyCmd, forget::ForgetOptions},
config::{hooks::Hooks, progress_options::ProgressOptions},
filtering::SnapshotFilter,
repository::AllRepositoryOptions,
};
/// Rustic Configuration
@ -33,7 +39,7 @@ use crate::{
///
/// # Example
// TODO: add example
#[derive(Clone, Default, Debug, Parser, Deserialize, Merge)]
#[derive(Clone, Default, Debug, Parser, Deserialize, Serialize, Merge)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
pub struct RusticConfig {
/// Global options
@ -42,7 +48,7 @@ pub struct RusticConfig {
/// Repository options
#[clap(flatten, next_help_heading = "Repository options")]
pub repository: RepositoryOptions,
pub repository: AllRepositoryOptions,
/// Snapshot filter options
#[clap(flatten, next_help_heading = "Snapshot filter options")]
@ -54,11 +60,27 @@ pub struct RusticConfig {
/// Copy options
#[clap(skip)]
pub copy: Targets,
pub copy: CopyCmd,
/// Forget options
#[clap(skip)]
pub forget: ForgetOptions,
/// mount options
#[clap(skip)]
#[cfg(feature = "mount")]
pub mount: MountCmd,
#[cfg(not(feature = "mount"))]
#[merge(skip)]
pub mount: Option<Value>,
/// webdav options
#[clap(skip)]
#[cfg(feature = "webdav")]
pub webdav: WebDavCmd,
#[cfg(not(feature = "webdav"))]
#[merge(skip)]
pub webdav: Option<Value>,
}
impl RusticConfig {
@ -83,7 +105,7 @@ impl RusticConfig {
merge_logs.push((Level::Info, format!("using config {}", path.display())));
let mut config = Self::load_toml_file(AbsPathBuf::canonicalize(path)?)?;
// if "use_profile" is defined in config file, merge the referenced profiles first
for profile in &config.global.use_profile.clone() {
for profile in &config.global.use_profiles.clone() {
config.merge_profile(profile, merge_logs, Level::Warn)?;
}
self.merge(config);
@ -111,21 +133,27 @@ pub struct GlobalOptions {
/// [default: "rustic"]
#[clap(
short = 'P',
long,
long = "use-profile",
global = true,
value_name = "PROFILE",
env = "RUSTIC_USE_PROFILE"
)]
#[merge(strategy = merge::vec::append)]
pub use_profile: Vec<String>,
#[merge(strategy=conflate::vec::append)]
pub use_profiles: Vec<String>,
/// Only show what would be done without modifying anything. Does not affect read-only commands.
#[clap(long, short = 'n', global = true, env = "RUSTIC_DRY_RUN")]
#[merge(strategy = merge::bool::overwrite_false)]
#[merge(strategy=conflate::bool::overwrite_false)]
pub dry_run: bool,
/// Check if index matches pack files and read pack headers if neccessary
#[clap(long, global = true, env = "RUSTIC_CHECK_INDEX")]
#[merge(strategy=conflate::bool::overwrite_false)]
pub check_index: bool,
/// Use this log level [default: info]
#[clap(long, global = true, env = "RUSTIC_LOG_LEVEL")]
#[merge(strategy=conflate::option::overwrite_none)]
pub log_level: Option<String>,
/// Write log messages to the given file instead of printing them.
@ -133,7 +161,8 @@ pub struct GlobalOptions {
/// # Note
///
/// Warnings and errors are still additionally printed unless they are ignored by `--log-level`
#[clap(long, global = true, env = "RUSTIC_LOG_FILE", value_name = "LOGFILE")]
#[clap(long, global = true, env = "RUSTIC_LOG_FILE", value_name = "LOGFILE", value_hint = ValueHint::FilePath)]
#[merge(strategy=conflate::option::overwrite_none)]
pub log_file: Option<PathBuf>,
/// Settings to customize progress bars
@ -141,18 +170,16 @@ pub struct GlobalOptions {
#[serde(flatten)]
pub progress_options: ProgressOptions,
/// Hooks
#[clap(skip)]
pub hooks: Hooks,
/// List of environment variables to set (only in config file)
#[clap(skip)]
#[merge(strategy = extend)]
#[merge(strategy = conflate::hashmap::ignore)]
pub env: HashMap<String, String>,
}
/// Extend the contents of a [`HashMap`] with the contents of another
/// [`HashMap`] with the same key and value types.
fn extend(left: &mut HashMap<String, String>, right: HashMap<String, String>) {
left.extend(right);
}
/// Get the paths to the config file
///
/// # Arguments

107
src/config/hooks.rs Normal file
View File

@ -0,0 +1,107 @@
//! rustic hooks configuration
//!
//! Hooks are commands that are executed before and after every rustic operation.
//! They can be used to run custom scripts or commands before and after a backup,
//! copy, forget, prune or other operation.
//!
//! Depending on the hook type, the command is being executed at a different point
//! in the lifecycle of the program. The following hooks are available:
//!
//! - global hooks
//! - repository hooks
//! - backup hooks
//! - specific source-related hooks
use anyhow::Result;
use conflate::Merge;
use serde::{Deserialize, Serialize};
use rustic_core::CommandInput;
#[derive(Debug, Default, Clone, Serialize, Deserialize, Merge)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
pub struct Hooks {
/// Call this command before every rustic operation
#[merge(strategy = conflate::vec::append)]
pub run_before: Vec<CommandInput>,
/// Call this command after every successful rustic operation
#[merge(strategy = conflate::vec::append)]
pub run_after: Vec<CommandInput>,
/// Call this command after every failed rustic operation
#[merge(strategy = conflate::vec::append)]
pub run_failed: Vec<CommandInput>,
/// Call this command after every rustic operation
#[merge(strategy = conflate::vec::append)]
pub run_finally: Vec<CommandInput>,
#[serde(skip)]
#[merge(skip)]
pub context: String,
}
impl Hooks {
pub fn with_context(&self, context: &str) -> Self {
let mut hooks = self.clone();
hooks.context = context.to_string();
hooks
}
fn run_all(cmds: &[CommandInput], context: &str, what: &str) -> Result<()> {
for cmd in cmds {
cmd.run(context, what)?;
}
Ok(())
}
pub fn run_before(&self) -> Result<()> {
Self::run_all(&self.run_before, &self.context, "run-before")
}
pub fn run_after(&self) -> Result<()> {
Self::run_all(&self.run_after, &self.context, "run-after")
}
pub fn run_failed(&self) -> Result<()> {
Self::run_all(&self.run_failed, &self.context, "run-failed")
}
pub fn run_finally(&self) -> Result<()> {
Self::run_all(&self.run_finally, &self.context, "run-finally")
}
/// Run the given closure using the specified hooks.
///
/// Note: after a failure no error handling is done for the hooks `run_failed`
/// and `run_finally` which must run after. However, they already log a warning
/// or error depending on the `on_failure` setting.
pub fn use_with<T>(&self, f: impl FnOnce() -> Result<T>) -> Result<T> {
match self.run_before() {
Ok(_) => match f() {
Ok(result) => match self.run_after() {
Ok(_) => {
self.run_finally()?;
Ok(result)
}
Err(err_after) => {
_ = self.run_finally();
Err(err_after)
}
},
Err(err_f) => {
_ = self.run_failed();
_ = self.run_finally();
Err(err_f)
}
},
Err(err_before) => {
_ = self.run_failed();
_ = self.run_finally();
Err(err_before)
}
}
}
}

View File

@ -5,7 +5,7 @@ use std::{borrow::Cow, fmt::Write, time::Duration};
use indicatif::{HumanDuration, ProgressBar, ProgressState, ProgressStyle};
use clap::Parser;
use merge::Merge;
use conflate::Merge;
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DisplayFromStr};
@ -19,7 +19,7 @@ use rustic_core::{Progress, ProgressBars};
pub struct ProgressOptions {
/// Don't show any progress bar
#[clap(long, global = true, env = "RUSTIC_NO_PROGRESS")]
#[merge(strategy=merge::bool::overwrite_false)]
#[merge(strategy=conflate::bool::overwrite_false)]
pub no_progress: bool,
/// Interval to update progress bars
@ -31,6 +31,7 @@ pub struct ProgressOptions {
conflicts_with = "no_progress"
)]
#[serde_as(as = "Option<DisplayFromStr>")]
#[merge(strategy=conflate::option::overwrite_none)]
pub progress_interval: Option<humantime::Duration>,
}
@ -131,11 +132,13 @@ impl Progress for RusticProgress {
ProgressType::Bytes => {
self.0.set_style(
ProgressStyle::default_bar()
.with_key("my_eta", |s: &ProgressState, w: &mut dyn Write|
match (s.pos(), s.len()){
(pos,Some(len)) if pos != 0 => write!(w,"{:#}", HumanDuration(Duration::from_secs(s.elapsed().as_secs() * (len-pos)/pos))),
.with_key("my_eta", |s: &ProgressState, w: &mut dyn Write| {
let _ = match (s.pos(), s.len()){
// Extra checks to prevent panics from dividing by zero or subtract overflow
(pos,Some(len)) if pos != 0 && len > pos => write!(w,"{:#}", HumanDuration(Duration::from_secs(s.elapsed().as_secs() * (len-pos)/pos))),
(_, _) => write!(w,"-"),
}.unwrap())
};
})
.template("[{elapsed_precise}] {prefix:30} {bar:40.cyan/blue} {bytes:>10}/{total_bytes:10} {bytes_per_sec:12} (ETA {my_eta})")
.unwrap()
);

View File

@ -1,11 +1,20 @@
use crate::error::RhaiErrorKinds;
use bytesize::ByteSize;
use derive_more::derive::Display;
use log::warn;
use rustic_core::{repofile::SnapshotFile, StringList};
use std::{error::Error, str::FromStr};
use std::{
error::Error,
fmt::{Debug, Display},
str::FromStr,
};
use cached::proc_macro::cached;
use chrono::{DateTime, Local, NaiveTime};
use conflate::Merge;
use rhai::{serde::to_dynamic, Dynamic, Engine, FnPtr, AST};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DisplayFromStr};
/// A function to filter snapshots
@ -24,6 +33,17 @@ impl FromStr for SnapshotFn {
}
}
#[cached(key = "String", convert = r#"{ s.to_string() }"#, size = 1)]
fn string_to_fn(s: &str) -> Option<SnapshotFn> {
match SnapshotFn::from_str(s) {
Ok(filter_fn) => Some(filter_fn),
Err(err) => {
warn!("Error evaluating filter-fn {s}: {err}",);
None
}
}
}
impl SnapshotFn {
/// Call the function with a [`SnapshotFile`]
///
@ -43,35 +63,72 @@ impl SnapshotFn {
}
#[serde_as]
#[derive(Clone, Default, Debug, Deserialize, merge::Merge, clap::Parser)]
#[derive(Clone, Default, Debug, Serialize, Deserialize, Merge, clap::Parser)]
#[serde(default, rename_all = "kebab-case", deny_unknown_fields)]
pub struct SnapshotFilter {
/// Hostname to filter (can be specified multiple times)
#[clap(long, global = true, value_name = "HOSTNAME")]
#[merge(strategy=merge::vec::overwrite_empty)]
filter_host: Vec<String>,
#[clap(long = "filter-host", global = true, value_name = "HOSTNAME")]
#[merge(strategy=conflate::vec::overwrite_empty)]
filter_hosts: Vec<String>,
/// Label to filter (can be specified multiple times)
#[clap(long, global = true, value_name = "LABEL")]
#[merge(strategy=merge::vec::overwrite_empty)]
filter_label: Vec<String>,
#[clap(long = "filter-label", global = true, value_name = "LABEL")]
#[merge(strategy=conflate::vec::overwrite_empty)]
filter_labels: Vec<String>,
/// Path list to filter (can be specified multiple times)
#[clap(long, global = true, value_name = "PATH[,PATH,..]")]
#[serde_as(as = "Vec<DisplayFromStr>")]
#[merge(strategy=merge::vec::overwrite_empty)]
#[merge(strategy=conflate::vec::overwrite_empty)]
filter_paths: Vec<StringList>,
/// Path list to filter exactly (no superset) as given (can be specified multiple times)
#[clap(long, global = true, value_name = "PATH[,PATH,..]")]
#[serde_as(as = "Vec<DisplayFromStr>")]
#[merge(strategy=conflate::vec::overwrite_empty)]
filter_paths_exact: Vec<StringList>,
/// Tag list to filter (can be specified multiple times)
#[clap(long, global = true, value_name = "TAG[,TAG,..]")]
#[serde_as(as = "Vec<DisplayFromStr>")]
#[merge(strategy=merge::vec::overwrite_empty)]
#[merge(strategy=conflate::vec::overwrite_empty)]
filter_tags: Vec<StringList>,
/// Tag list to filter exactly (no superset) as given (can be specified multiple times)
#[clap(long, global = true, value_name = "TAG[,TAG,..]")]
#[serde_as(as = "Vec<DisplayFromStr>")]
#[merge(strategy=conflate::vec::overwrite_empty)]
filter_tags_exact: Vec<StringList>,
/// Only use snapshots which are taken after the given given date/time
#[serde_as(as = "Option<DisplayFromStr>")]
#[clap(long, global = true, value_name = "DATE(TIME)")]
#[merge(strategy=conflate::option::overwrite_none)]
filter_after: Option<AfterDate>,
/// Only use snapshots which are taken before the given given date/time
#[serde_as(as = "Option<DisplayFromStr>")]
#[clap(long, global = true, value_name = "DATE(TIME)")]
#[merge(strategy=conflate::option::overwrite_none)]
filter_before: Option<BeforeDate>,
/// Only use snapshots with total size in given range
#[serde_as(as = "Option<DisplayFromStr>")]
#[clap(long, global = true, value_name = "SIZE")]
#[merge(strategy=conflate::option::overwrite_none)]
filter_size: Option<SizeRange>,
/// Only use snapshots with size added to the repo in given range
#[serde_as(as = "Option<DisplayFromStr>")]
#[clap(long, global = true, value_name = "SIZE")]
#[merge(strategy=conflate::option::overwrite_none)]
filter_size_added: Option<SizeRange>,
/// Function to filter snapshots
#[clap(long, global = true, value_name = "FUNC")]
#[serde_as(as = "Option<DisplayFromStr>")]
filter_fn: Option<SnapshotFn>,
#[merge(strategy=conflate::option::overwrite_none)]
filter_fn: Option<String>,
}
impl SnapshotFilter {
@ -87,24 +144,153 @@ impl SnapshotFilter {
#[must_use]
pub fn matches(&self, snapshot: &SnapshotFile) -> bool {
if let Some(filter_fn) = &self.filter_fn {
match filter_fn.call::<bool>(snapshot) {
Ok(result) => {
if !result {
return false;
if let Some(func) = string_to_fn(filter_fn) {
match func.call::<bool>(snapshot) {
Ok(result) => {
if !result {
return false;
}
}
Err(err) => {
warn!(
"Error evaluating filter-fn for snapshot {}: {err}",
snapshot.id
);
}
}
Err(err) => {
warn!(
"Error evaluating filter-fn for snapshot {}: {err}",
snapshot.id
);
}
}
}
// For the `Option`s we check if the option is set and the condition is not matched. In this case we can early return false.
if matches!(&self.filter_after, Some(after) if !after.matches(snapshot.time))
|| matches!(&self.filter_before, Some(before) if !before.matches(snapshot.time))
|| matches!((&self.filter_size,&snapshot.summary), (Some(size),Some(summary)) if !size.matches(summary.total_bytes_processed))
|| matches!((&self.filter_size_added,&snapshot.summary), (Some(size),Some(summary)) if !size.matches(summary.data_added))
{
return false;
}
// For the the `Vec`s we have two possibilities:
// - There exists a suitable matches method on the snapshot item
// (this automatically handles empty filter correctly):
snapshot.paths.matches(&self.filter_paths)
&& snapshot.tags.matches(&self.filter_tags)
&& (self.filter_host.is_empty() || self.filter_host.contains(&snapshot.hostname))
&& (self.filter_label.is_empty() || self.filter_label.contains(&snapshot.label))
// - manually check if the snapshot item is contained in the `Vec`
// but only if the `Vec` is not empty.
// If it is empty, no condition is given.
&& (self.filter_paths_exact.is_empty()
|| self.filter_paths_exact.contains(&snapshot.paths))
&& (self.filter_tags_exact.is_empty()
|| self.filter_tags_exact.contains(&snapshot.tags))
&& (self.filter_hosts.is_empty() || self.filter_hosts.contains(&snapshot.hostname))
&& (self.filter_labels.is_empty() || self.filter_labels.contains(&snapshot.label))
}
}
#[derive(Debug, Clone, Display)]
struct AfterDate(DateTime<Local>);
impl AfterDate {
fn matches(&self, datetime: DateTime<Local>) -> bool {
self.0 < datetime
}
}
impl FromStr for AfterDate {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let before_midnight = NaiveTime::from_hms_nano_opt(23, 59, 59, 999_999_999).unwrap();
let datetime = dateparser::parse_with(s, &Local, before_midnight)?;
Ok(Self(datetime.into()))
}
}
#[derive(Debug, Clone, Display)]
struct BeforeDate(DateTime<Local>);
impl BeforeDate {
fn matches(&self, datetime: DateTime<Local>) -> bool {
datetime < self.0
}
}
impl FromStr for BeforeDate {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let midnight = NaiveTime::from_hms_opt(0, 0, 0).unwrap();
let datetime = dateparser::parse_with(s, &Local, midnight)?;
Ok(Self(datetime.into()))
}
}
#[derive(Debug, Clone)]
struct SizeRange {
from: Option<ByteSize>,
to: Option<ByteSize>,
}
impl SizeRange {
fn matches(&self, size: u64) -> bool {
// The matches-expression is only true if the `Option` is `Some` and the size is smaller than from.
// Hence, !matches is true either if `self.from` is `None` or if the size >= the values
!matches!(self.from, Some(from) if size < from.0)
// same logic here, but smaller and greater swapped.
&& !matches!(self.to, Some(to) if size > to.0)
}
}
fn parse_size(s: &str) -> Result<Option<ByteSize>, String> {
let s = s.trim();
if s.is_empty() {
return Ok(None);
}
Ok(Some(s.parse()?))
}
impl FromStr for SizeRange {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (from, to) = match s.split_once("..") {
Some((s1, s2)) => (parse_size(s1)?, parse_size(s2)?),
None => (parse_size(s)?, None),
};
Ok(Self { from, to })
}
}
impl Display for SizeRange {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(from) = self.from {
f.write_str(&from.to_string_as(true))?;
}
f.write_str("..")?;
if let Some(to) = self.to {
f.write_str(&to.to_string_as(true))?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case("..", None, None)]
#[case("10", Some(10), None)]
#[case("..10k", None, Some(10_000))]
#[case("1MB..", Some(1_000_000), None)]
#[case("1 MB .. 1 GiB", Some(1_000_000), Some(1_073_741_824))]
#[case("10 .. 20 ", Some(10), Some(20))]
#[case(" 2G ", Some(2_000_000_000), None)]
fn size_range_from_str(
#[case] input: SizeRange,
#[case] from: Option<u64>,
#[case] to: Option<u64>,
) {
assert_eq!(input.from.map(|v| v.0), from);
assert_eq!(input.to.map(|v| v.0), to);
}
}

View File

@ -34,10 +34,8 @@ Application based on the [Abscissa] framework.
patterns_in_fns_without_body,
trivial_numeric_casts,
unused_results,
trivial_casts,
unused_extern_crates,
unused_import_braces,
unused_qualifications,
unconditional_recursion,
unused,
unused_allocation,
@ -64,6 +62,7 @@ pub(crate) mod config;
pub(crate) mod error;
pub(crate) mod filtering;
pub(crate) mod helpers;
pub(crate) mod repository;
// rustic_cli Public API

167
src/repository.rs Normal file
View File

@ -0,0 +1,167 @@
//! Rustic Config
//!
//! See instructions in `commands.rs` to specify the path to your
//! application's configuration file and/or command-line options
//! for specifying it.
use std::fmt::Debug;
use std::ops::Deref;
use abscissa_core::Application;
use anyhow::{anyhow, bail, Result};
use clap::Parser;
use conflate::Merge;
use dialoguer::Password;
use rustic_backend::BackendOptions;
use rustic_core::{
FullIndex, IndexedStatus, OpenStatus, ProgressBars, Repository, RepositoryOptions,
};
use serde::{Deserialize, Serialize};
use crate::{
config::{hooks::Hooks, progress_options::ProgressOptions},
RUSTIC_APP,
};
pub(super) mod constants {
pub(super) const MAX_PASSWORD_RETRIES: usize = 5;
}
#[derive(Clone, Default, Debug, Parser, Serialize, Deserialize, Merge)]
#[serde(default, rename_all = "kebab-case")]
pub struct AllRepositoryOptions {
/// Backend options
#[clap(flatten)]
#[serde(flatten)]
pub be: BackendOptions,
/// Repository options
#[clap(flatten)]
#[serde(flatten)]
pub repo: RepositoryOptions,
/// Hooks
#[clap(skip)]
pub hooks: Hooks,
}
pub type CliRepo = RusticRepo<ProgressOptions>;
pub type CliOpenRepo = Repository<ProgressOptions, OpenStatus>;
pub type RusticIndexedRepo<P> = Repository<P, IndexedStatus<FullIndex, OpenStatus>>;
pub type CliIndexedRepo = RusticIndexedRepo<ProgressOptions>;
impl AllRepositoryOptions {
fn repository<P>(&self, po: P) -> Result<RusticRepo<P>> {
let backends = self.be.to_backends()?;
let repo = Repository::new_with_progress(&self.repo, &backends, po)?;
Ok(RusticRepo(repo))
}
pub fn run<T>(&self, f: impl FnOnce(CliRepo) -> Result<T>) -> Result<T> {
let hooks = self.hooks.with_context("repository");
let po = RUSTIC_APP.config().global.progress_options;
hooks.use_with(|| f(self.repository(po)?))
}
pub fn run_open<T>(&self, f: impl FnOnce(CliOpenRepo) -> Result<T>) -> Result<T> {
let hooks = self.hooks.with_context("repository");
let po = RUSTIC_APP.config().global.progress_options;
hooks.use_with(|| f(self.repository(po)?.open()?))
}
pub fn run_open_or_init_with<T: Clone>(
&self,
do_init: bool,
init: impl FnOnce(CliRepo) -> Result<CliOpenRepo>,
f: impl FnOnce(CliOpenRepo) -> Result<T>,
) -> Result<T> {
let hooks = self.hooks.with_context("repository");
let po = RUSTIC_APP.config().global.progress_options;
hooks.use_with(|| {
f(self
.repository(po)?
.open_or_init_repository_with(do_init, init)?)
})
}
pub fn run_indexed_with_progress<P: Clone + ProgressBars, T>(
&self,
po: P,
f: impl FnOnce(RusticIndexedRepo<P>) -> Result<T>,
) -> Result<T> {
let hooks = self.hooks.with_context("repository");
hooks.use_with(|| f(self.repository(po)?.indexed()?))
}
pub fn run_indexed<T>(&self, f: impl FnOnce(CliIndexedRepo) -> Result<T>) -> Result<T> {
let po = RUSTIC_APP.config().global.progress_options;
self.run_indexed_with_progress(po, f)
}
}
#[derive(Debug)]
pub struct RusticRepo<P>(pub Repository<P, ()>);
impl<P> Deref for RusticRepo<P> {
type Target = Repository<P, ()>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<P: Clone + ProgressBars> RusticRepo<P> {
pub fn open(self) -> Result<Repository<P, OpenStatus>> {
match self.0.password()? {
// if password is given, directly return the result of find_key_in_backend and don't retry
Some(pass) => {
return Ok(self.0.open_with_password(&pass)?);
}
None => {
for _ in 0..constants::MAX_PASSWORD_RETRIES {
let pass = Password::new()
.with_prompt("enter repository password")
.allow_empty_password(true)
.interact()?;
match self.0.clone().open_with_password(&pass) {
Ok(repo) => return Ok(repo),
Err(err) if err.is_incorrect_password() => continue,
Err(err) => return Err(err.into()),
}
}
}
}
Err(anyhow!("incorrect password"))
}
fn open_or_init_repository_with(
self,
do_init: bool,
init: impl FnOnce(Self) -> Result<Repository<P, OpenStatus>>,
) -> Result<Repository<P, OpenStatus>> {
let dry_run = RUSTIC_APP.config().global.check_index;
// Initialize repository if --init is set and it is not yet initialized
let repo = if do_init && self.0.config_id()?.is_none() {
if dry_run {
bail!(
"cannot initialize repository {} in dry-run mode!",
self.0.name
);
}
init(self)?
} else {
self.open()?
};
Ok(repo)
}
fn indexed(self) -> Result<Repository<P, IndexedStatus<FullIndex, OpenStatus>>> {
let open = self.open()?;
let check_index = RUSTIC_APP.config().global.check_index;
let repo = if check_index {
open.to_indexed_checked()
} else {
open.to_indexed()
}?;
Ok(repo)
}
}

View File

@ -7,134 +7,94 @@
//! You can run them with 'nextest':
//! `cargo nextest run -E 'test(backup)'`.
use abscissa_core::testing::prelude::*;
use aho_corasick::PatternID;
use dircmp::Comparison;
use pretty_assertions::assert_eq;
use rustic_testing::{get_matches, TestResult};
use std::io::Read;
use tempfile::{tempdir, TempDir};
pub fn rustic_runner(temp_dir: &TempDir) -> CmdRunner {
use assert_cmd::Command;
use predicates::prelude::{predicate, PredicateBooleanExt};
use rustic_testing::TestResult;
pub fn rustic_runner(temp_dir: &TempDir) -> TestResult<Command> {
let password = "test";
let repo_dir = temp_dir.path().join("repo");
let mut runner = CmdRunner::new(env!("CARGO_BIN_EXE_rustic"));
let mut runner = Command::new(env!("CARGO_BIN_EXE_rustic"));
runner
.arg("-r")
.arg(repo_dir)
.arg("--password")
.arg(password)
.arg("--no-progress")
.capture_stdout()
.capture_stderr();
runner
.arg("--no-progress");
Ok(runner)
}
fn setup() -> TestResult<TempDir> {
let temp_dir = tempdir()?;
let mut runner = rustic_runner(&temp_dir);
let mut cmd = runner.args(["init"]).run();
rustic_runner(&temp_dir)?
.args(["init"])
.assert()
.success()
.stderr(predicate::str::contains("successfully created."))
.stderr(predicate::str::contains("successfully added."));
let mut stdout = String::new();
let mut stderr = String::new();
cmd.stdout().read_to_string(&mut stdout)?;
cmd.stderr().read_to_string(&mut stderr)?;
let patterns = &["successfully added.", "successfully created."];
let matches = get_matches(patterns, stderr)?;
assert_eq!(
matches,
vec![(PatternID::must(0), 19), (PatternID::must(1), 21),]
);
cmd.wait()?.expect_success();
Ok(temp_dir)
}
#[test]
fn test_backup_and_check_passes() -> TestResult<()> {
let temp_dir = setup()?;
let backup = "crates/";
let backup = "src/";
{
// Run `backup` for the first time
let mut runner = rustic_runner(&temp_dir);
let mut cmd = runner.arg("backup").arg(backup).run();
let mut output = String::new();
cmd.stdout().read_to_string(&mut output)?;
let patterns = &["successfully saved."];
let matches = get_matches(patterns, output)?;
assert_eq!(matches, vec![(PatternID::must(0), 19)]);
cmd.wait()?.expect_success();
rustic_runner(&temp_dir)?
.arg("backup")
.arg(backup)
.assert()
.success()
.stdout(predicate::str::contains("successfully saved."));
}
{
// Run `snapshots`
let mut runner = rustic_runner(&temp_dir);
let mut cmd = runner.arg("snapshots").run();
let mut output = String::new();
cmd.stdout().read_to_string(&mut output)?;
let patterns = &["total: 1 snapshot(s)"];
let matches = get_matches(patterns, output)?;
assert_eq!(matches, vec![(PatternID::must(0), 20)]);
cmd.wait()?.expect_success();
rustic_runner(&temp_dir)?
.arg("snapshots")
.assert()
.success()
.stdout(predicate::str::contains("total: 1 snapshot(s)"));
}
{
// Run `backup` a second time
let mut runner = rustic_runner(&temp_dir);
let mut cmd = runner.arg("backup").arg(backup).run();
let mut output = String::new();
cmd.stdout().read_to_string(&mut output)?;
let patterns = &["Added to the repo: 0 B", "successfully saved."];
let matches = get_matches(patterns, output)?;
assert_eq!(
matches,
vec![(PatternID::must(0), 22), (PatternID::must(1), 19)]
);
cmd.wait()?.expect_success();
rustic_runner(&temp_dir)?
.arg("backup")
.arg(backup)
.assert()
.success()
.stdout(predicate::str::contains("Added to the repo: 0 B"))
.stdout(predicate::str::contains("successfully saved."));
}
{
// Run `snapshots` a second time
let mut runner = rustic_runner(&temp_dir);
let mut cmd = runner.arg("snapshots").run();
let mut output = String::new();
cmd.stdout().read_to_string(&mut output)?;
let patterns = &["total: 2 snapshot(s)"];
let matches = get_matches(patterns, output)?;
assert_eq!(matches, vec![(PatternID::must(0), 20)]);
cmd.wait()?.expect_success();
rustic_runner(&temp_dir)?
.arg("snapshots")
.assert()
.success()
.stdout(predicate::str::contains("total: 2 snapshot(s)"));
}
{
// Run `check --read-data`
let mut runner = rustic_runner(&temp_dir);
let mut cmd = runner.args(["check", "--read-data"]).run();
let mut output = String::new();
cmd.stderr().read_to_string(&mut output)?;
let patterns = &["WARN", "ERROR"];
let matches = get_matches(patterns, output)?;
assert_eq!(matches.len(), 0);
cmd.wait()?.expect_success();
rustic_runner(&temp_dir)?
.args(["check", "--read-data"])
.assert()
.success()
.stderr(predicate::str::contains("WARN").not())
.stderr(predicate::str::contains("ERROR").not());
}
Ok(())
@ -144,44 +104,34 @@ fn test_backup_and_check_passes() -> TestResult<()> {
fn test_backup_and_restore_passes() -> TestResult<()> {
let temp_dir = setup()?;
let restore_dir = temp_dir.path().join("restore");
let backup = "crates";
let backup = "src/";
// actual repository root to backup
let current_dir = std::env::current_dir()?;
let backup_files = current_dir.join(backup);
let backup_files = std::env::current_dir()?.join(backup);
{
// Run `backup` for the first time
let mut runner = rustic_runner(&temp_dir);
let mut cmd = runner.arg("backup").arg(&backup_files).run();
let mut output = String::new();
cmd.stdout().read_to_string(&mut output)?;
let patterns = &["successfully saved."];
let matches = get_matches(patterns, output)?;
assert_eq!(matches, vec![(PatternID::must(0), 19)]);
cmd.wait()?.expect_success();
rustic_runner(&temp_dir)?
.arg("backup")
.arg(&backup_files)
.assert()
.success()
.stdout(predicate::str::contains("successfully saved."));
}
{
// Run `restore`
let mut runner = rustic_runner(&temp_dir);
let mut cmd = runner.arg("restore").arg("latest").arg(&restore_dir).run();
let mut output = String::new();
cmd.stdout().read_to_string(&mut output)?;
let patterns = &["restore done"];
let matches = get_matches(patterns, output)?;
assert_eq!(matches, vec![(PatternID::must(0), 12)]);
cmd.wait()?.expect_success();
rustic_runner(&temp_dir)?
.arg("restore")
.arg("latest")
.arg(&restore_dir)
.assert()
.success()
.stdout(predicate::str::contains("restore done"));
}
let comparison = Comparison::default();
let compare_result = comparison.compare(&backup_files, &restore_dir.join(&backup_files))?;
dbg!(&compare_result);
// Compare the backup and the restored directory
let compare_result =
Comparison::default().compare(&backup_files, &restore_dir.join(&backup_files))?;
// no differences
assert!(compare_result.is_empty());

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -13,10 +13,9 @@
use std::{
io::{Read, Write},
path::PathBuf,
sync::LazyLock,
};
use once_cell::sync::Lazy;
use abscissa_core::testing::prelude::*;
use rustic_testing::{files_differ, get_temp_file, TestResult};
@ -25,7 +24,7 @@ use rustic_testing::{files_differ, get_temp_file, TestResult};
/// the runner acquire a mutex when executing commands and inspecting
/// exit statuses, serializing what would otherwise be multithreaded
/// invocations as `cargo test` executes tests in parallel by default.
pub static LAZY_RUNNER: Lazy<CmdRunner> = Lazy::new(|| {
pub static LAZY_RUNNER: LazyLock<CmdRunner> = LazyLock::new(|| {
let mut runner = CmdRunner::new(env!("CARGO_BIN_EXE_rustic"));
runner.exclusive().capture_stdout();
runner

View File

@ -1,31 +1,17 @@
//! Configuration file tests
use log::LevelFilter;
use anyhow::Result;
use rstest::*;
use rustic_rs::RusticConfig;
use std::{error::Error, fs, path::PathBuf, str::FromStr};
use std::{fs, path::PathBuf};
type Result<T> = std::result::Result<T, Box<dyn Error>>;
fn get_config_file_path() -> PathBuf {
["config", "full.toml"].iter().collect()
}
/// Ensure `full.toml` parses as a valid config file
#[test]
fn parse_full_toml_example() -> Result<()> {
let output = std::process::Command::new("cargo")
.args(["locate-project", "--workspace", "--message-format", "plain"])
.output()?;
let root = PathBuf::from_str(String::from_utf8(output.stdout)?.as_str())?;
let root_dir = root.parent().unwrap();
let config_path = root_dir.join(get_config_file_path());
/// Ensure all `configs` parse as a valid config files
#[rstest]
fn test_parse_rustic_configs_is_ok(
#[files("config/**/*.toml")] config_path: PathBuf,
) -> Result<()> {
let toml_string = fs::read_to_string(config_path)?;
let config: RusticConfig = toml::from_str(&toml_string)?;
assert_eq!(
LevelFilter::from_str(config.global.log_level.unwrap().as_str())?,
LevelFilter::Info
);
assert!(!config.global.dry_run);
let _ = toml::from_str::<RusticConfig>(&toml_string)?;
Ok(())
}

Some files were not shown because too many files have changed in this diff Show More