├── .cargo └── config.toml ├── .github ├── install-arm-linkers.yml ├── renovate.json └── workflows │ ├── audit.yml │ ├── ci.yml │ ├── cross-ci.yml │ ├── lint-docs.yml │ ├── nightly.yml │ ├── prebuilt-pr.yml │ ├── release-image.yml │ ├── release-plz.yml │ ├── release.yml │ └── triage.yml ├── .gitignore ├── .justfile ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── Cross.toml ├── LICENSE ├── README.md ├── USAGE.md ├── build-dependencies.just ├── cliff.toml ├── committed.toml ├── config ├── README.md ├── acl.example.toml ├── rustic_profile.toml └── rustic_server.toml ├── containers ├── Dockerfile └── docker-compose.yml ├── deny.toml ├── dist-workspace.toml ├── dprint.json ├── maskfile.md ├── release-plz.toml ├── self_signed_certs └── .gitkeep ├── src ├── acl.rs ├── application.rs ├── auth.rs ├── bin │ └── rustic-server.rs ├── commands.rs ├── commands │ ├── auth.rs │ └── serve.rs ├── config.rs ├── context.rs ├── error.rs ├── handlers.rs ├── handlers │ ├── access_check.rs │ ├── file_config.rs │ ├── file_exchange.rs │ ├── file_helpers.rs │ ├── file_length.rs │ ├── files_list.rs │ ├── health.rs │ └── repository.rs ├── htpasswd.rs ├── lib.rs ├── log.rs ├── prelude.rs ├── snapshots │ ├── rustic_server__acl__tests__acl_default_impl.snap │ ├── rustic_server__acl__tests__repo_acl_passes.snap │ ├── rustic_server__config__test__config_parsing_from_file_passes.snap │ ├── rustic_server__config__test__default_config_passes.snap │ ├── rustic_server__config__test__file_read.snap │ ├── rustic_server__config__test__issue_60_parse_config_passes.snap │ ├── rustic_server__config__test__optional_explicit_parse_config_passes.snap │ ├── rustic_server__config__test__optional_implicit_parse_config_passes.snap │ ├── rustic_server__config__test__parse_config_passes.snap │ └── rustic_server__htpasswd__test__htpasswd_passes.snap ├── storage.rs ├── testing.rs ├── typed_path.rs └── web.rs ├── tests ├── fixtures │ ├── hurl │ │ └── endpoints.hurl │ └── test_data │ │ ├── .htpasswd │ │ ├── README.md │ │ ├── acl.toml │ │ ├── certs │ │ ├── test.crt │ │ └── test.key │ │ ├── rustic.toml │ │ ├── rustic_server.toml │ │ ├── server_acl_minimal.toml │ │ └── test_repo_source │ │ ├── my_file.html │ │ ├── my_file.txt │ │ └── my_folder │ │ ├── my_file.html │ │ ├── my_file.txt │ │ └── random_data.bin ├── generated │ └── test_storage │ │ └── test_repo │ │ ├── config │ │ └── keys │ │ └── 3f918b737a2b9f72f044d06d6009eb34e0e8d06668209be3ce86e5c18dac0295 └── integration │ ├── _impl.rs │ ├── acceptance.rs │ ├── config.rs │ └── main.rs └── wix └── main.wxs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | rustdocflags = ["--document-private-items"] 3 | # rustflags = "-C target-cpu=native -D warnings" 4 | # incremental = true 5 | 6 | [target.armv7-unknown-linux-gnueabihf] 7 | linker = "arm-linux-gnueabihf-gcc" 8 | 9 | [target.armv7-unknown-linux-musleabihf] 10 | linker = "arm-linux-gnueabihf-gcc" 11 | 12 | [target.aarch64-unknown-linux-gnu] 13 | linker = "aarch64-linux-gnu-gcc" 14 | 15 | [target.aarch64-unknown-linux-musl] 16 | linker = "aarch64-linux-gnu-gcc" 17 | 18 | [target.i686-unknown-linux-gnu] 19 | linker = "i686-linux-gnu-gcc" 20 | 21 | [env] 22 | CC_i686-unknown-linux-gnu = "i686-linux-gnu-gcc" 23 | CC_aarch64_unknown_linux_musl = "aarch64-linux-gnu-gcc" 24 | CC_armv7_unknown_linux_gnueabihf = "arm-linux-gnueabihf-gcc" 25 | CC_armv7_unknown_linux_musleabihf = "arm-linux-gnueabihf-gcc" 26 | -------------------------------------------------------------------------------- /.github/install-arm-linkers.yml: -------------------------------------------------------------------------------- 1 | - name: Install armv7 and aarch64 Linkers 2 | if: runner.os == 'Linux' 3 | run: | 4 | sudo apt install gcc-aarch64-linux-gnu 5 | sudo apt install gcc-arm-none-eabi 6 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["local>rustic-rs/.github:renovate-config"] 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | 3 | on: 4 | pull_request: 5 | schedule: 6 | # Runs at 00:00 UTC everyday 7 | - cron: "0 0 * * *" 8 | push: 9 | paths: 10 | - "**/Cargo.toml" 11 | - "**/Cargo.lock" 12 | - "crates/**/Cargo.toml" 13 | - "crates/**/Cargo.lock" 14 | merge_group: 15 | types: [checks_requested] 16 | 17 | concurrency: 18 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | audit: 23 | if: ${{ github.repository_owner == 'rustic-rs' }} 24 | name: Audit 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 29 | # Ensure that the latest version of Cargo is installed 30 | - name: Install Rust toolchain 31 | uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1 32 | with: 33 | toolchain: stable 34 | - uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2 35 | - uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0 36 | with: 37 | token: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | cargo-deny: 40 | if: ${{ github.repository_owner == 'rustic-rs' }} 41 | name: Run cargo-deny 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 45 | 46 | - uses: EmbarkStudios/cargo-deny-action@8371184bd11e21dcf8ac82ebf8c9c9f74ebf7268 # v2 47 | with: 48 | command: check bans licenses sources 49 | 50 | result: 51 | if: ${{ github.repository_owner == 'rustic-rs' }} 52 | name: Result (Audit) 53 | runs-on: ubuntu-latest 54 | needs: 55 | - audit 56 | - cargo-deny 57 | steps: 58 | - name: Mark the job as successful 59 | run: exit 0 60 | if: success() 61 | - name: Mark the job as unsuccessful 62 | run: exit 1 63 | if: "!success()" 64 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - "**/*.md" 7 | push: 8 | branches: 9 | - main 10 | - "renovate/**" 11 | paths-ignore: 12 | - "**/*.md" 13 | schedule: 14 | - cron: "0 0 * * 0" 15 | merge_group: 16 | types: [checks_requested] 17 | 18 | concurrency: 19 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | fmt: 24 | name: Rustfmt 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 28 | - name: Install Rust toolchain 29 | uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1 30 | with: 31 | toolchain: stable 32 | components: rustfmt 33 | - name: Run Cargo Fmt 34 | run: cargo fmt --all -- --check 35 | 36 | clippy: 37 | name: Clippy 38 | runs-on: ubuntu-latest 39 | 40 | steps: 41 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 42 | - name: Install Rust toolchain 43 | uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1 44 | with: 45 | toolchain: stable 46 | components: clippy 47 | - uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2 48 | - name: Run clippy 49 | run: cargo clippy --all-targets --all-features -- -D warnings 50 | 51 | test: 52 | name: Test 53 | runs-on: ${{ matrix.job.os }} 54 | strategy: 55 | matrix: 56 | rust: [stable] 57 | job: 58 | - os: macos-latest 59 | - os: ubuntu-latest 60 | - os: windows-latest 61 | steps: 62 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 63 | if: github.event_name != 'pull_request' 64 | with: 65 | fetch-depth: 0 66 | 67 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 68 | if: github.event_name == 'pull_request' 69 | with: 70 | ref: ${{ github.event.pull_request.head.sha }} 71 | fetch-depth: 0 72 | 73 | - name: Install Rust toolchain 74 | uses: dtolnay/rust-toolchain@1482605bfc5719782e1267fd0c0cc350fe7646b8 # v1 75 | with: 76 | toolchain: stable 77 | - uses: Swatinem/rust-cache@82a92a6e8fbeee089604da2575dc567ae9ddeaab # v2 78 | - name: Run Cargo Test 79 | run: cargo test --all-targets --all-features --workspace 80 | 81 | result: 82 | name: Result (CI) 83 | runs-on: ubuntu-latest 84 | needs: 85 | - fmt 86 | - clippy 87 | - test 88 | steps: 89 | - name: Mark the job as successful 90 | run: exit 0 91 | if: success() 92 | - name: Mark the job as unsuccessful 93 | run: exit 1 94 | if: "!success()" 95 | -------------------------------------------------------------------------------- /.github/workflows/cross-ci.yml: -------------------------------------------------------------------------------- 1 | name: Cross CI 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - "**/*.md" 7 | push: 8 | branches: 9 | - main 10 | - "renovate/**" 11 | - "release/**" 12 | paths-ignore: 13 | - "**/*.md" 14 | merge_group: 15 | types: [checks_requested] 16 | 17 | defaults: 18 | run: 19 | shell: bash 20 | 21 | concurrency: 22 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 23 | cancel-in-progress: true 24 | 25 | jobs: 26 | cross-check: 27 | name: Cross checking ${{ matrix.job.target }} on ${{ matrix.rust }} 28 | runs-on: ${{ matrix.job.os }} 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | rust: [stable] 33 | job: 34 | - os: windows-latest 35 | os-name: windows 36 | target: x86_64-pc-windows-msvc 37 | architecture: x86_64 38 | use-cross: false 39 | - os: windows-latest 40 | os-name: windows 41 | target: x86_64-pc-windows-gnu 42 | architecture: x86_64 43 | use-cross: false 44 | - os: macos-13 45 | os-name: macos 46 | target: x86_64-apple-darwin 47 | architecture: x86_64 48 | use-cross: false 49 | - os: macos-latest 50 | os-name: macos 51 | target: aarch64-apple-darwin 52 | architecture: arm64 53 | use-cross: true 54 | - os: ubuntu-latest 55 | os-name: linux 56 | target: x86_64-unknown-linux-gnu 57 | architecture: x86_64 58 | use-cross: false 59 | - os: ubuntu-latest 60 | os-name: linux 61 | target: x86_64-unknown-linux-musl 62 | architecture: x86_64 63 | use-cross: false 64 | - os: ubuntu-latest 65 | os-name: linux 66 | target: aarch64-unknown-linux-gnu 67 | architecture: arm64 68 | use-cross: true 69 | - os: ubuntu-latest 70 | os-name: linux 71 | target: aarch64-unknown-linux-musl 72 | architecture: arm64 73 | use-cross: true 74 | - os: ubuntu-latest 75 | os-name: linux 76 | target: i686-unknown-linux-gnu 77 | architecture: i386 78 | use-cross: true 79 | - os: ubuntu-latest 80 | os-name: linux 81 | target: armv7-unknown-linux-gnueabihf 82 | architecture: armv7 83 | use-cross: true 84 | 85 | steps: 86 | - name: Checkout repository 87 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 88 | 89 | - name: Run Cross-CI action 90 | uses: rustic-rs/cross-ci-action@main 91 | with: 92 | toolchain: ${{ matrix.rust }} 93 | target: ${{ matrix.job.target }} 94 | use-cross: ${{ matrix.job.use-cross }} 95 | project-cache-key: "rustic_server" 96 | 97 | result: 98 | name: Result (Cross-CI) 99 | runs-on: ubuntu-latest 100 | needs: cross-check 101 | steps: 102 | - name: Mark the job as successful 103 | run: exit 0 104 | if: success() 105 | - name: Mark the job as unsuccessful 106 | run: exit 1 107 | if: "!success()" 108 | -------------------------------------------------------------------------------- /.github/workflows/lint-docs.yml: -------------------------------------------------------------------------------- 1 | name: Lint Markdown / Toml 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | merge_group: 8 | types: [checks_requested] 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | style: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 19 | 20 | - uses: dprint/check@2f1cf31537886c3bfb05591c031f7744e48ba8a1 # v2.2 21 | 22 | result: 23 | name: Result (Style) 24 | runs-on: ubuntu-latest 25 | needs: 26 | - style 27 | steps: 28 | - name: Mark the job as successful 29 | run: exit 0 30 | if: success() 31 | - name: Mark the job as unsuccessful 32 | run: exit 1 33 | if: "!success()" 34 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Deployment 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | # “At 00:10.” 7 | # https://crontab.guru/#10_0_*_*_* 8 | - cron: "10 0 * * *" 9 | 10 | defaults: 11 | run: 12 | shell: bash 13 | 14 | env: 15 | BINARY_NAME: rustic-server 16 | BINARY_NIGHTLY_DIR: rustic_server 17 | 18 | jobs: 19 | publish: 20 | if: ${{ github.repository_owner == 'rustic-rs' && github.ref == 'refs/heads/main' }} 21 | name: Publishing ${{ matrix.job.target }} 22 | runs-on: ${{ matrix.job.os }} 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | rust: [stable] 27 | job: 28 | - os: windows-latest 29 | os-name: windows 30 | target: x86_64-pc-windows-msvc 31 | architecture: x86_64 32 | binary-postfix: ".exe" 33 | use-cross: false 34 | - os: windows-latest 35 | os-name: windows 36 | target: x86_64-pc-windows-gnu 37 | architecture: x86_64 38 | binary-postfix: ".exe" 39 | use-cross: false 40 | - os: macos-13 41 | os-name: macos 42 | target: x86_64-apple-darwin 43 | architecture: x86_64 44 | binary-postfix: "" 45 | use-cross: false 46 | - os: macos-latest 47 | os-name: macos 48 | target: aarch64-apple-darwin 49 | architecture: arm64 50 | binary-postfix: "" 51 | use-cross: false 52 | - os: ubuntu-latest 53 | os-name: linux 54 | target: x86_64-unknown-linux-gnu 55 | architecture: x86_64 56 | binary-postfix: "" 57 | use-cross: false 58 | - os: ubuntu-latest 59 | os-name: linux 60 | target: x86_64-unknown-linux-musl 61 | architecture: x86_64 62 | binary-postfix: "" 63 | use-cross: false 64 | - os: ubuntu-latest 65 | os-name: linux 66 | target: aarch64-unknown-linux-gnu 67 | architecture: arm64 68 | binary-postfix: "" 69 | use-cross: true 70 | - os: ubuntu-latest 71 | os-name: linux 72 | target: aarch64-unknown-linux-musl 73 | architecture: arm64 74 | binary-postfix: "" 75 | use-cross: true 76 | - os: ubuntu-latest 77 | os-name: linux 78 | target: i686-unknown-linux-gnu 79 | architecture: i386 80 | binary-postfix: "" 81 | use-cross: true 82 | - os: ubuntu-latest 83 | os-name: linux 84 | target: armv7-unknown-linux-gnueabihf 85 | architecture: armv7 86 | binary-postfix: "" 87 | use-cross: true 88 | 89 | steps: 90 | - name: Checkout repository 91 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 92 | with: 93 | fetch-depth: 0 # fetch all history so that git describe works 94 | - name: Create binary artifact 95 | uses: rustic-rs/create-binary-artifact-action@main # dev 96 | with: 97 | toolchain: ${{ matrix.rust }} 98 | target: ${{ matrix.job.target }} 99 | use-cross: ${{ matrix.job.use-cross }} 100 | describe-tag-suffix: -nightly 101 | binary-postfix: ${{ matrix.job.binary-postfix }} 102 | os: ${{ runner.os }} 103 | binary-name: ${{ env.BINARY_NAME }} 104 | package-secondary-name: nightly-${{ matrix.job.target}} 105 | github-token: ${{ secrets.GITHUB_TOKEN }} 106 | gpg-release-private-key: ${{ secrets.GPG_RELEASE_PRIVATE_KEY }} 107 | gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }} 108 | rsign-release-private-key: ${{ secrets.RSIGN_RELEASE_PRIVATE_KEY }} 109 | rsign-passphrase: ${{ secrets.RSIGN_PASSPHRASE }} 110 | github-ref: ${{ github.ref }} 111 | sign-release: true 112 | hash-release: true 113 | use-project-version: false 114 | 115 | publish-nightly: 116 | if: ${{ github.repository_owner == 'rustic-rs' && github.ref == 'refs/heads/main' }} 117 | name: Publishing nightly builds 118 | needs: publish 119 | runs-on: ubuntu-latest 120 | steps: 121 | - name: Download all workflow run artifacts 122 | uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4 123 | - name: Releasing nightly builds 124 | shell: bash 125 | run: | 126 | # set up some directories 127 | WORKING_DIR=$(mktemp -d) 128 | DEST_DIR=$BINARY_NIGHTLY_DIR 129 | 130 | # set up the github deploy key 131 | mkdir -p ~/.ssh 132 | echo "${{ secrets.NIGHTLY_RELEASE_KEY }}" > ~/.ssh/id_ed25519 133 | chmod 600 ~/.ssh/id_ed25519 134 | 135 | # set up git 136 | git config --global user.name "${{ github.actor }}" 137 | git config --global user.email "${{ github.actor }}" 138 | ssh-keyscan -H github.com > ~/.ssh/known_hosts 139 | GIT_SSH='ssh -i ~/.ssh/id_ed25519 -o UserKnownHostsFile=~/.ssh/known_hosts' 140 | 141 | # clone the repo into our working directory 142 | # we use --depth 1 to avoid cloning the entire history 143 | # and only the main branch to avoid cloning all branches 144 | GIT_SSH_COMMAND=$GIT_SSH git clone git@github.com:rustic-rs/nightly.git --branch main --single-branch --depth 1 $WORKING_DIR 145 | 146 | # ensure destination directory exists 147 | mkdir -p $WORKING_DIR/$DEST_DIR 148 | 149 | # do the copy 150 | for i in binary-*; do cp -a $i/* $WORKING_DIR/$DEST_DIR; done 151 | 152 | # create the commit 153 | cd $WORKING_DIR 154 | git add . 155 | git commit -m "${{ github.job }} from https://github.com/${{ github.repository }}/commit/${{ github.sha }}" || echo 156 | GIT_SSH_COMMAND=$GIT_SSH git pull --rebase 157 | GIT_SSH_COMMAND=$GIT_SSH git push 158 | -------------------------------------------------------------------------------- /.github/workflows/prebuilt-pr.yml: -------------------------------------------------------------------------------- 1 | name: Create PR artifacts 2 | 3 | on: 4 | pull_request: 5 | types: [labeled] 6 | branches: 7 | - main 8 | paths-ignore: 9 | - "**/*.md" 10 | - "docs/**/*" 11 | workflow_dispatch: 12 | 13 | env: 14 | BINARY_NAME: rustic-server 15 | 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | pr-build: 22 | if: ${{ github.event.label.name == 'S-build' && github.repository_owner == 'rustic-rs' }} 23 | name: Build PR on ${{ matrix.job.target }} 24 | runs-on: ${{ matrix.job.os }} 25 | strategy: 26 | matrix: 27 | rust: [stable] 28 | job: 29 | - os: windows-latest 30 | os-name: windows 31 | target: x86_64-pc-windows-msvc 32 | architecture: x86_64 33 | binary-postfix: ".exe" 34 | use-cross: false 35 | - os: macos-13 36 | os-name: macos 37 | target: x86_64-apple-darwin 38 | architecture: x86_64 39 | binary-postfix: "" 40 | use-cross: false 41 | - os: macos-latest 42 | os-name: macos 43 | target: aarch64-apple-darwin 44 | architecture: arm64 45 | binary-postfix: "" 46 | use-cross: false 47 | - os: ubuntu-latest 48 | os-name: linux 49 | target: x86_64-unknown-linux-gnu 50 | architecture: x86_64 51 | binary-postfix: "" 52 | use-cross: false 53 | - os: ubuntu-latest 54 | os-name: linux 55 | target: x86_64-unknown-linux-musl 56 | architecture: x86_64 57 | binary-postfix: "" 58 | use-cross: false 59 | - os: ubuntu-latest 60 | os-name: linux 61 | target: aarch64-unknown-linux-gnu 62 | architecture: arm64 63 | binary-postfix: "" 64 | use-cross: true 65 | - os: ubuntu-latest 66 | os-name: linux 67 | target: aarch64-unknown-linux-musl 68 | architecture: arm64 69 | binary-postfix: "" 70 | use-cross: true 71 | - os: ubuntu-latest 72 | os-name: linux 73 | target: i686-unknown-linux-gnu 74 | architecture: i386 75 | binary-postfix: "" 76 | use-cross: true 77 | - os: ubuntu-latest 78 | os-name: linux 79 | target: armv7-unknown-linux-gnueabihf 80 | architecture: armv7 81 | binary-postfix: "" 82 | use-cross: true 83 | 84 | steps: 85 | - name: Checkout repository 86 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 87 | with: 88 | fetch-depth: 0 # fetch all history so that git describe works 89 | - name: Create binary artifact 90 | uses: rustic-rs/create-binary-artifact-action@main # dev 91 | with: 92 | toolchain: ${{ matrix.rust }} 93 | target: ${{ matrix.job.target }} 94 | use-cross: ${{ matrix.job.use-cross }} 95 | describe-tag-suffix: -${{ github.run_id }}-${{ github.run_attempt }} 96 | binary-postfix: ${{ matrix.job.binary-postfix }} 97 | os: ${{ runner.os }} 98 | binary-name: ${{ env.BINARY_NAME }} 99 | package-secondary-name: ${{ matrix.job.target}} 100 | github-token: ${{ secrets.GITHUB_TOKEN }} 101 | github-ref: ${{ github.ref }} 102 | sign-release: false 103 | hash-release: true 104 | use-project-version: false # not being used in rustic_server 105 | 106 | remove-build-label: 107 | name: Remove build label 108 | needs: pr-build 109 | permissions: 110 | contents: read 111 | issues: write 112 | pull-requests: write 113 | runs-on: ubuntu-latest 114 | if: | 115 | always() && 116 | ! contains(needs.*.result, 'skipped') && 117 | github.repository_owner == 'rustic-rs' 118 | steps: 119 | - name: Remove label 120 | env: 121 | GH_TOKEN: ${{ github.token }} 122 | run: | 123 | gh api \ 124 | --method DELETE \ 125 | -H "Accept: application/vnd.github+json" \ 126 | -H "X-GitHub-Api-Version: 2022-11-28" \ 127 | /repos/${{ github.repository }}/issues/${{ github.event.number }}/labels/S-build 128 | -------------------------------------------------------------------------------- /.github/workflows/release-image.yml: -------------------------------------------------------------------------------- 1 | name: Release Docker Image 2 | 3 | on: [release] 4 | 5 | jobs: 6 | docker: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Set up Docker Buildx 10 | uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3 11 | 12 | - name: Login to Docker Hub 13 | uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3 14 | with: 15 | registry: ghcr.io 16 | username: ${{ github.actor }} 17 | password: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | - name: Build and push 20 | uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6 21 | with: 22 | context: "{{defaultContext}}:containers" 23 | push: true 24 | platforms: linux/amd64,linux/arm64 25 | tags: ghcr.io/rustic-rs/rustic_server:latest,ghcr.io/rustic-rs/rustic_server:${{ github.ref_name }} 26 | build-args: RUSTIC_SERVER_VERSION=${{ github.ref_name }} 27 | -------------------------------------------------------------------------------- /.github/workflows/release-plz.yml: -------------------------------------------------------------------------------- 1 | name: Release-plz 2 | 3 | permissions: 4 | pull-requests: write 5 | contents: write 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | release-plz: 14 | name: Release-plz 15 | if: ${{ github.repository_owner == 'rustic-rs' }} 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Generate GitHub token 19 | uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1 20 | id: generate-token 21 | with: 22 | app-id: ${{ secrets.RELEASE_PLZ_APP_ID }} 23 | private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }} 24 | - name: Checkout repository 25 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 26 | with: 27 | fetch-depth: 0 28 | token: ${{ steps.generate-token.outputs.token }} 29 | - name: Install Rust toolchain 30 | uses: dtolnay/rust-toolchain@stable 31 | 32 | - name: Run release-plz 33 | uses: MarcoIeni/release-plz-action@301fd6d8c641b97f25b5ade37651a478a5faa7da # v0.5 34 | env: 35 | GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} 36 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/triage.yml: -------------------------------------------------------------------------------- 1 | on: 2 | issues: 3 | types: 4 | - opened 5 | 6 | jobs: 7 | label_issue: 8 | if: ${{ github.repository_owner == 'rustic-rs' }} 9 | name: Label issue 10 | runs-on: ubuntu-latest 11 | steps: 12 | - env: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | ISSUE_URL: ${{ github.event.issue.html_url }} 15 | run: | 16 | # check if issue doesn't have any labels 17 | if [[ $(gh issue view $ISSUE_URL --json labels -q '.labels | length') -eq 0 ]]; then 18 | # add S-triage label 19 | gh issue edit $ISSUE_URL --add-label "S-triage" 20 | fi 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | # Generated by Cargo 3 | # will have compiled files and executables 4 | /target/ 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | 9 | # IDE files 10 | .idea 11 | .vscode 12 | 13 | # Test data created may accidentally be included 14 | tests/fixtures/rest_server 15 | repo_remove_me* 16 | __* 17 | ci_repo 18 | repo_not_* 19 | containers/volumes -------------------------------------------------------------------------------- /.justfile: -------------------------------------------------------------------------------- 1 | # 'Just' Configuration 2 | # Loads .env file for variables to be used in 3 | # in this just file 4 | 5 | set dotenv-load := true 6 | 7 | # Ignore recipes that are commented out 8 | 9 | set ignore-comments := true 10 | 11 | # Set shell for Windows OSs: 12 | # If you have PowerShell Core installed and want to use it, 13 | # use `pwsh.exe` instead of `powershell.exe` 14 | 15 | set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] 16 | 17 | # Set shell for non-Windows OSs: 18 | 19 | set shell := ["bash", "-uc"] 20 | 21 | export RUST_BACKTRACE := "1" 22 | export RUST_LOG := "" 23 | export CI := "1" 24 | 25 | build: 26 | cargo build --all-features 27 | cargo build -r --all-features 28 | 29 | b: build 30 | 31 | check: 32 | cargo check --no-default-features 33 | cargo check --all-features 34 | 35 | c: check 36 | 37 | ci: 38 | just loop . dev 39 | 40 | dev: format lint test 41 | 42 | d: dev 43 | 44 | format-dprint: 45 | dprint fmt 46 | 47 | format-cargo: 48 | cargo fmt --all 49 | 50 | format: format-cargo format-dprint 51 | 52 | fmt: format 53 | 54 | rev: 55 | cargo insta review 56 | 57 | inverse-deps crate: 58 | cargo tree -e features -i {{ crate }} 59 | 60 | lint: check 61 | cargo clippy --no-default-features -- -D warnings 62 | cargo clippy --all-targets --all-features -- -D warnings 63 | 64 | loop dir action: 65 | watchexec -w {{ dir }} -- "just {{ action }}" 66 | 67 | test: check lint 68 | cargo test --all-targets --all-features --workspace 69 | 70 | test-ignored: check lint 71 | cargo test --all-targets --all-features --workspace -- --ignored 72 | 73 | t: test test-ignored 74 | 75 | test-restic $RESTIC_REPOSITORY="rest:http://restic:restic@127.0.0.1:8080/ci_repo" $RESTIC_PASSWORD="restic": 76 | restic init 77 | restic backup tests/fixtures/test_data/test_repo_source 78 | restic backup src 79 | restic check 80 | restic forget --keep-last 1 --prune 81 | restic snapshots 82 | 83 | test-server: 84 | cargo run -- serve -c tests/fixtures/test_data/rustic_server.toml -v 85 | 86 | test-restic-server: 87 | tests/fixtures/rest_server/rest-server.exe --path ./tests/generated/test_storage/ --htpasswd-file ./tests/fixtures/test_data/.htpasswd --log ./tests/fixtures/rest_server/response2.log 88 | 89 | loop-test-server: 90 | watchexec --stop-signal "CTRL+C" -r -w src -w tests -- "cargo run -- serve -c tests/fixtures/test_data/rustic_server.toml -v" 91 | 92 | hurl: 93 | hurl -i tests/fixtures/hurl/endpoints.hurl 94 | 95 | dbg-test test_name $RUST_LOG="debug": 96 | cargo test --package rustic_server --lib -- {{ test_name }} --exact --nocapture --show-output 97 | 98 | build-docker version="0.4.0": 99 | podman build containers --build-arg RUSTIC_SERVER_VERSION=v{{ version }} --format docker --tag rustic_server:v{{ version }} 100 | 101 | server-up: build-docker 102 | uv --directory containers run podman-compose -f docker-compose.yml up --detach 103 | 104 | server-down: 105 | uv --directory containers run podman-compose -f docker-compose.yml down 106 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [0.4.4](https://github.com/rustic-rs/rustic_server/compare/v0.4.3...v0.4.4) - 2024-11-29 6 | 7 | ### Other 8 | 9 | - add targets ([#72](https://github.com/rustic-rs/rustic_server/pull/72)) 10 | 11 | ## [0.4.3](https://github.com/rustic-rs/rustic_server/compare/v0.4.2...v0.4.3) - 2024-11-27 12 | 13 | ### Added 14 | 15 | - enhance cross-check workflow with additional target configurations and enable cross-compilation for specific platforms ([#70](https://github.com/rustic-rs/rustic_server/pull/70)) 16 | 17 | ## [0.4.2](https://github.com/rustic-rs/rustic_server/compare/v0.4.1...v0.4.2) - 2024-11-26 18 | 19 | ### Other 20 | 21 | - update Cargo.lock dependencies 22 | 23 | ## [0.4.1](https://github.com/rustic-rs/rustic_server/compare/v0.4.0...v0.4.1) - 2024-11-23 24 | 25 | ### Added 26 | 27 | - update conflate dependency to version 0.3.2 and refactor merge strategies 28 | 29 | ### Other 30 | 31 | - add workspace linting configuration 32 | 33 | ## [0.4.0](https://github.com/rustic-rs/rustic_server/compare/v0.3.0...v0.4.0) - 2024-11-17 34 | 35 | ### Added 36 | 37 | - read more cli options from environment variables 38 | - add /health route(s) 39 | 40 | ### Other 41 | 42 | - set CI in test to run also locally in CI mode 43 | 44 | ## [0.3.0](https://github.com/rustic-rs/rustic_server/compare/v0.2.0...v0.3.0) - 2024-11-16 45 | 46 | ### Added 47 | 48 | - add context module and update configuration files 49 | 50 | ### Other 51 | 52 | - preparing the docs for another release 53 | - *(readme)* update readme for current state 54 | - some fixes applied to testing 55 | 56 | ## [0.2.0](https://github.com/rustic-rs/rustic_server/compare/v0.1.1...v0.2.0) - 2024-11-14 57 | 58 | ### Other 59 | 60 | - update readme 61 | - [**breaking**] move to axum - Part II ([#56](https://github.com/rustic-rs/rustic_server/pull/56)) 62 | 63 | ## [0.1.1] - 2024-01-09 64 | 65 | ### Bug Fixes 66 | 67 | - Nightly builds, exclude arm64 darwin build until issue Publishing 68 | aarch64-apple-darwin failed #6 is fixed 69 | - Update rust crate toml to 0.8 70 | - Deserialization with newest toml 71 | - Clippy 72 | - Remove unmaintained `actions-rs` ci actions 73 | - Update rust crate clap to 4.4.10 74 | ([#37](https://github.com/rustic-rs/rustic_server/issues/37)) 75 | - Update github action to download artifacts, as upload/download actions from 76 | nightly workflow were incompatible with each other 77 | - Don't unwrap in bin 78 | - Imports 79 | 80 | ### Documentation 81 | 82 | - Update readme, fix manifest 83 | - Add continuous deployment badge to readme 84 | - Fix typo in html element 85 | - Add link to nightly 86 | - Add link to nightly downloads in documentation 87 | - Remove CI Todo 88 | - Remove To-dos from Readme 89 | - Break line in toml code for usability 90 | - Update changelog 91 | - Rewrite contributing remark 92 | - Fix list indent 93 | - Add contributing 94 | - Remove tide remarks from readme 95 | 96 | ### Features 97 | 98 | - Pr-build flag to build artifacts for a pr manually if needed 99 | 100 | ### Miscellaneous Tasks 101 | 102 | - Add ci 103 | - Nightly builds 104 | - Update header link 105 | - Add release pr workflow 106 | - Add caching 107 | - Add signature and shallow clones to nightly 108 | - Declutter and reorganize 109 | - Remove lint from ci workflow and keep it separate, replace underscore in 110 | workflow files 111 | - Rebase and extract action to own repository 112 | - Use create-binary-artifact action 113 | - Put action version to follow main branch while action is still in development 114 | - Switch ci to rustic-rs/create-binary-artifact action 115 | - Switch rest of ci to rustic-rs/create-binary-artifact action 116 | - Change license 117 | - Fix workflow name for create-binary-artifact action, and check breaking 118 | changes package dependent 119 | - Decrease build times on windows 120 | - Fix github refs 121 | - Set right package 122 | - Use bash substring comparison to determine package name from branch 123 | - Fix woggly github action comparison 124 | - Add changelog generation 125 | - Initialize cargo release, update changelog 126 | - Add dev tooling 127 | - Run git-cliff with latest tag during release 128 | - Remove comment from cargo manifest 129 | - Change workflow extensions to yml 130 | - Add triaging of issues 131 | - Run release checks also on release subbranches 132 | - Add maskfile 133 | - Update changelog 134 | - Run workflow on renovate branches 135 | - Add merge queue checks 136 | - Add cargo deny 137 | - Relink to new image location 138 | - Add binstall support 139 | - Build nightly with rsign signed binaries 140 | - Update public key 141 | - Support rsign signature 142 | - Remove special os-dependent linker/compiler settings 143 | - Update cross ci 144 | - Check if nightly builds for arm64 darwin builds work now 145 | - Arm64 on darwin still fails 146 | - Add x86_64-pc-windows-gnu target 147 | - Compile dependencies with optimizations in dev mode 148 | - Add results to ci 149 | - Lockfile maintenance 150 | - Run actions that need secrets.GITHUB_TOKEN only on rustic-rs org 151 | - Update dtolnay/rust-toolchain 152 | - Update taiki-e/install-action 153 | - Update rustsec/audit-check 154 | - Netbsd nightly builds fail due to missing execinfo, so we don't build on it 155 | for now 156 | - Upgrade dprint config 157 | - Activate automerge for github action digest update 158 | - Activate automerge for github action digest update 159 | - Automerge lockfile maintenance 160 | - :debug 161 | - Update to latest axum and apply fixes 162 | - Reactivate audit workflow 163 | - Remove OnceCell dep and set rust-version 164 | - Remove justfile 165 | 166 | ### Refactor 167 | 168 | - Refactor to library and server binary 169 | - Begin refactor to axum 170 | - [**breaking**] Moving to axum 171 | ([#40](https://github.com/rustic-rs/rustic_server/issues/40)) 172 | - Use own errors throughout library part 173 | - State better which file is not able to be read 174 | - More error handling stuff 175 | 176 | ### Testing 177 | 178 | - Fix config tests 179 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `rustic_server` 2 | 3 | Thank you for your interest in contributing to `rustic_server`! 4 | 5 | We appreciate your help in making this project better. 6 | 7 | Please read the 8 | [contribution guide](https://rustic.cli.rs/docs/contributing-to-rustic.html) to 9 | get started. 10 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rustic_server" 3 | version = "0.4.4" 4 | authors = ["the rustic-rs team"] 5 | categories = ["command-line-utilities"] 6 | edition = "2021" 7 | homepage = "https://rustic.cli.rs/" 8 | include = [ 9 | "src/**/*", 10 | "config/**/*", 11 | "Cargo.toml", 12 | "Cargo.lock", 13 | "LICENSE", 14 | "README.md", 15 | ] 16 | keywords = ["backup", "restic", "cli", "server"] 17 | license = "AGPL-3.0-or-later" 18 | repository = "https://github.com/rustic-rs/rustic_server" 19 | rust-version = "1.74.0" 20 | description = """ 21 | rustic server - a REST server built in rust to use with rustic and restic. 22 | """ 23 | # cargo-binstall support 24 | # https://github.com/cargo-bins/cargo-binstall/blob/HEAD/SUPPORT.md 25 | [package.metadata.binstall] 26 | pkg-url = "{ repo }/releases/download/v{ version }/{ repo }-v{ version }-{ target }{ archive-suffix }" 27 | bin-dir = "{ bin }-{ target }/{ bin }{ binary-ext }" 28 | pkg-fmt = "tar.gz" 29 | 30 | [package.metadata.binstall.signing] 31 | algorithm = "minisign" 32 | pubkey = "RWSWSCEJEEacVeCy0va71hlrVtiW8YzMzOyJeso0Bfy/ZXq5OryWi/8T" 33 | 34 | [package.metadata.wix] 35 | upgrade-guid = "EE4ED7D1-CE20-4919-B988-33482C0C3042" 36 | path-guid = "F5605741-D1CF-45E2-B082-3A71B58C01C8" 37 | license = false 38 | eula = false 39 | 40 | [dependencies] 41 | abscissa_tokio = "0.8.0" 42 | anyhow = "1" 43 | async-trait = "0.1" 44 | axum = { version = "0.7", features = ["tracing", "multipart", "http2", "macros"] } 45 | axum-auth = "0.7" 46 | axum-extra = { version = "0.9", features = ["typed-header", "query", "async-read-body", "typed-routing", "erased-json"] } 47 | axum-macros = "0.4" 48 | axum-range = "0.4" 49 | axum-server = { version = "0.7", features = ["tls-rustls-no-provider"] } 50 | chrono = { version = "0.4.38", features = ["serde"] } 51 | clap = { version = "4", features = ["derive", "env", "wrap_help"] } 52 | conflate = "0.3.3" 53 | displaydoc = "0.2" 54 | # enum_dispatch = "0.3.12" 55 | futures = "0.3" 56 | futures-util = "0.3" 57 | htpasswd-verify = "0.3" 58 | http-body-util = "0.1" 59 | http-range = "0.1" 60 | inquire = "0.7" 61 | pin-project = "1" 62 | rand = "0.8" 63 | serde = { version = "1", default-features = false, features = ["derive"] } 64 | serde_derive = "1" 65 | strum = { version = "0.26", features = ["derive"] } 66 | thiserror = "2" 67 | tokio = { version = "1", features = ["full"] } 68 | tokio-util = { version = "0.7", features = ["io", "io-util"] } 69 | toml = "0.8" 70 | tracing = "0.1" 71 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 72 | uuid = { version = "1.11.0", features = ["v4"] } 73 | walkdir = "2" 74 | 75 | [dependencies.abscissa_core] 76 | version = "0.8.1" 77 | # optional: use `gimli` to capture backtraces 78 | # see https://github.com/rust-lang/backtrace-rs/issues/189 79 | # features = ["gimli-backtrace"] 80 | 81 | [dependencies.rustls] 82 | version = "0.23.17" 83 | features = ["logging", "std", "ring", "tls12"] 84 | default-features = false 85 | 86 | [dev-dependencies] 87 | abscissa_core = { version = "0.8.1", features = ["testing"] } 88 | anyhow = "1" 89 | assert_cmd = "2" 90 | base64 = "0.22" 91 | dircmp = "0.2" 92 | insta = { version = "1", features = ["redactions", "toml"] } 93 | once_cell = "1.20" 94 | predicates = "3.1.2" 95 | pretty_assertions = "1" 96 | rstest = "0.23" 97 | serde_json = "1" 98 | # reqwest = "0.11.18" 99 | serial_test = { version = "3.2.0", features = ["file_locks"] } 100 | tower = "0.5" 101 | 102 | # see: https://nnethercote.github.io/perf-book/build-configuration.html 103 | [profile.dev] 104 | opt-level = 0 105 | debug = true 106 | rpath = false 107 | lto = false 108 | debug-assertions = true 109 | codegen-units = 4 110 | 111 | # compile dependencies with optimizations in dev mode 112 | # see: https://doc.rust-lang.org/stable/cargo/reference/profiles.html#overrides 113 | [profile.dev.package."*"] 114 | opt-level = 3 115 | debug = true 116 | 117 | [profile.release] 118 | opt-level = 3 119 | debug = false # true for profiling 120 | rpath = false 121 | lto = "fat" 122 | debug-assertions = false 123 | codegen-units = 1 124 | strip = true 125 | panic = "abort" 126 | 127 | [profile.test] 128 | opt-level = 1 129 | debug = true 130 | rpath = false 131 | lto = false 132 | debug-assertions = true 133 | codegen-units = 4 134 | 135 | [profile.bench] 136 | opt-level = 3 137 | debug = true # true for profiling 138 | rpath = false 139 | lto = true 140 | debug-assertions = false 141 | codegen-units = 1 142 | 143 | # The profile that 'dist' will build with 144 | [profile.dist] 145 | inherits = "release" 146 | lto = "thin" 147 | 148 | [workspace.lints.rust] 149 | unsafe_code = "forbid" 150 | missing_docs = "warn" 151 | rust_2018_idioms = { level = "warn", priority = -1 } 152 | trivial_casts = "warn" 153 | unused_lifetimes = "warn" 154 | unused_qualifications = "warn" 155 | bad_style = "warn" 156 | dead_code = "allow" # TODO: "warn" 157 | improper_ctypes = "warn" 158 | missing_copy_implementations = "warn" 159 | missing_debug_implementations = "warn" 160 | non_shorthand_field_patterns = "warn" 161 | no_mangle_generic_items = "warn" 162 | overflowing_literals = "warn" 163 | path_statements = "warn" 164 | patterns_in_fns_without_body = "warn" 165 | trivial_numeric_casts = "warn" 166 | unused_results = "warn" 167 | unused_extern_crates = "warn" 168 | unused_import_braces = "warn" 169 | unconditional_recursion = "warn" 170 | unused = { level = "warn", priority = -1 } 171 | unused_allocation = "warn" 172 | unused_comparisons = "warn" 173 | unused_parens = "warn" 174 | while_true = "warn" 175 | unreachable_pub = "allow" 176 | non_local_definitions = "allow" 177 | 178 | [workspace.lints.clippy] 179 | redundant_pub_crate = "allow" 180 | pedantic = { level = "warn", priority = -1 } 181 | nursery = { level = "warn", priority = -1 } 182 | # expect_used = "warn" # TODO! 183 | # unwrap_used = "warn" # TODO! 184 | enum_glob_use = "warn" 185 | correctness = { level = "warn", priority = -1 } 186 | suspicious = { level = "warn", priority = -1 } 187 | complexity = { level = "warn", priority = -1 } 188 | perf = { level = "warn", priority = -1 } 189 | cast_lossless = "warn" 190 | default_trait_access = "warn" 191 | doc_markdown = "warn" 192 | manual_string_new = "warn" 193 | match_same_arms = "warn" 194 | semicolon_if_nothing_returned = "warn" 195 | trivially_copy_pass_by_ref = "warn" 196 | module_name_repetitions = "allow" 197 | # TODO: Remove when Windows support landed 198 | # mostly Windows-related functionality is missing `const` 199 | # as it's only OK(()), but doesn't make it reasonable to 200 | # have a breaking change in the future. They won't be const. 201 | missing_const_for_fn = "allow" 202 | needless_raw_string_hashes = "allow" 203 | 204 | [workspace.lints.rustdoc] 205 | # We run rustdoc with `--document-private-items` so we can document private items 206 | private_intra_doc_links = "allow" 207 | -------------------------------------------------------------------------------- /Cross.toml: -------------------------------------------------------------------------------- 1 | [target.i686-unknown-linux-gnu] 2 | image = "ghcr.io/cross-rs/i686-unknown-linux-gnu:edge" 3 | pre-build = [ 4 | "dpkg --add-architecture $CROSS_DEB_ARCH", 5 | "apt-get update && apt-get --assume-yes install gcc-multilib-i686-linux-gnu gcc-i686-linux-gnu", 6 | ] 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 |

REST server for rustic

5 |

6 | 7 | 8 | 9 | 10 |

11 |

12 | 13 | 14 |

15 | 16 | # ⚠️ This project is in early development and not yet ready for production use 17 | 18 | For now, expect bugs, breaking changes, and a lot of refactoring. 19 | 20 | Please feel free to contribute to this project, we are happy to help you getting 21 | started. Join our [Discord](https://discord.gg/WRUWENZnzQ) to get in touch with 22 | us. 23 | 24 | ## About 25 | 26 | A REST server built in rust for use with rustic and rustic. 27 | 28 | Works pretty similar to [rest-server](https://github.com/restic/rest-server). 29 | Most features are already implemented. 30 | 31 | ## Contact 32 | 33 | | Contact | Where? | 34 | | ------------- | --------------------------------------------------------------------------------------------- | 35 | | Issue Tracker | [GitHub Issues](https://github.com/rustic-rs/rustic_server/issues) | 36 | | Discord | [![Discord](https://dcbadge.vercel.app/api/server/WRUWENZnzQ)](https://discord.gg/WRUWENZnzQ) | 37 | | Discussions | [GitHub Discussions](https://github.com/rustic-rs/rustic/discussions) | 38 | 39 | ## Are binaries available? 40 | 41 | Yes, you can find them [here](https://rustic.cli.rs/docs/nightly_builds.html). 42 | 43 | ## Installation 44 | 45 | You can install `rustic-server` using `cargo`: 46 | 47 | ```console 48 | cargo install rustic_server 49 | ``` 50 | 51 | or you can download the binaries from the 52 | [releases page](https://github.com/rustic-rs/rustic_server/releases). 53 | 54 | ## Usage 55 | 56 | After installing `rustic-server`, you can start the server with the following 57 | command: 58 | 59 | ```console 60 | rustic-server serve 61 | ``` 62 | 63 | For more information, please refer to the 64 | [`rustic-server` usage documentation](https://github.com/rustic-rs/rustic_server/blob/main/USAGE.md). 65 | 66 | ## Contributing 67 | 68 | Tried rustic-server and not satisfied? Don't just walk away! You can help: 69 | 70 | - You can report issues or suggest new features on our 71 | [Discord server](https://discord.gg/WRUWENZnzQ) or using 72 | [Github Issues](https://github.com/rustic-rs/rustic_server/issues/new/choose)! 73 | 74 | Do you know how to code or got an idea for an improvement? Don't keep it to 75 | yourself! 76 | 77 | - Contribute fixes or new features via a pull requests! 78 | 79 | Please make sure, that you read the 80 | [contribution guide](https://rustic.cli.rs/docs/contributing-to-rustic.html). 81 | 82 | ## Minimum Rust version policy 83 | 84 | This crate's minimum supported `rustc` version is `1.70.0`. 85 | 86 | The current policy is that the minimum Rust version required to use this crate 87 | can be increased in minor version updates. For example, if `crate 1.0` requires 88 | Rust 1.20.0, then `crate 1.0.z` for all values of `z` will also require Rust 89 | 1.20.0 or newer. However, `crate 1.y` for `y > 0` may require a newer minimum 90 | version of Rust. 91 | 92 | In general, this crate will be conservative with respect to the minimum 93 | supported version of Rust. 94 | 95 | # License 96 | 97 | `rustic-server` is open-sourced software licensed under the 98 | [GNU Affero General Public License v3.0 or later](./LICENSE). 99 | -------------------------------------------------------------------------------- /USAGE.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | You can start the server with the following command: 4 | 5 | ```console 6 | rustic-server serve 7 | ``` 8 | 9 | ## Defaults 10 | 11 | ### Storage 12 | 13 | By default the server persists backup data in the OS temporary directory 14 | (`/tmp/rustic` on Linux/BSD and others, in `%TEMP%\\rustic` in Windows, etc). 15 | 16 | **If `rustic-server` is launched using the default path, all backups will be 17 | lost**. To start the server with a custom persistence directory and with 18 | authentication disabled: 19 | 20 | ```sh 21 | rustic-server --path /user/home/backup --no-auth 22 | ``` 23 | 24 | `rustic-server` uses exactly the same directory structure as local backend, so 25 | you should be able to access it both locally and via HTTP, even simultaneously. 26 | 27 | ### Authentication (Basic) 28 | 29 | To authenticate users (for access to the `rustic-server`), the server supports 30 | using a `.htpasswd` file to specify users. By default, the server looks for this 31 | file at the root of the persistence directory, but this can be changed using the 32 | `--htpasswd-file` option. You can create such a file by executing the following 33 | command (note that you need the `htpasswd` program from Apache's http-tools). In 34 | order to append new user to the file, just omit the `-c` argument. Only bcrypt 35 | and SHA encryption methods are supported, so use -B (very secure) or -s 36 | (insecure by today's standards) when adding/changing passwords. 37 | 38 | ```sh 39 | htpasswd -B -c .htpasswd username 40 | ``` 41 | 42 | If you want to disable authentication, you must add the `--no-auth` flag. If 43 | this flag is not specified and the `.htpasswd` cannot be opened, `rustic-server` 44 | will refuse to start. 45 | 46 | ### Transport Layer Security (TLS) 47 | 48 | By default the server uses HTTP protocol. This is not very secure since with 49 | Basic Authentication, user name and passwords will be sent in clear text in 50 | every request. In order to enable TLS support just add the `--tls` argument and 51 | specify private and public keys by `--tls-cert` and `--tls-key`. 52 | 53 | Signed certificate is normally required by `restic` and `rustic`, but if you 54 | just want to test the feature you can generate password-less unsigned keys with 55 | the following command: 56 | 57 | ```sh 58 | openssl req -newkey rsa:2048 -nodes -x509 -keyout private_key -out public_key -days 365 -addext "subjectAltName = IP:127.0.0.1,DNS:yourdomain.com" 59 | ``` 60 | 61 | Omit the `IP:127.0.0.1` if you don't need your server be accessed via SSH 62 | Tunnels. No need to change default values in the openssl dialog, hitting enter 63 | every time is sufficient. 64 | 65 | To access this server via `restic` use `--cacert public_key`, meaning with a 66 | self-signed certificate you have to distribute your `public_key` file to every 67 | `restic` client. 68 | 69 | ### Access Control List (ACL) 70 | 71 | To prevent your users from accessing each others' repositories, you may use the 72 | `--private-repos` flag in combination with an ACL file. 73 | 74 | This server supports `ACL`s to restrict access to repositories. The ACL file is 75 | formatted in TOML and can be specified using the `--acl-path` option. More 76 | information about the ACL file format can be found in the `acl.toml` file in the 77 | `config` directory. If the ACL file is not specified, the server will allow all 78 | users to access all repositories. 79 | 80 | For example, user "foo" using the repository URLs 81 | `rest:https://foo:pass@host:8000/foo` or `rest:https://foo:pass@host:8000/foo/` 82 | would be granted access, but the same user using repository URLs 83 | `rest:https://foo:pass@host:8000/` or `rest:https://foo:pass@host:8000/foobar/` 84 | would be denied access. Users can also create their own sub repositories, like 85 | `/foo/bar/`. 86 | 87 | ## Append-Only Mode 88 | 89 | The `--append-only` mode allows creation of new backups but prevents deletion 90 | and modification of existing backups. This can be useful when backing up systems 91 | that have a potential of being hacked. 92 | 93 | ## Credits 94 | 95 | This project is based on the 96 | [rest-server](https://github.com/restic/rest-server) project by 97 | [restic](https://restic.net). This document is based on the 98 | [README.md](https://github.com/restic/rest-server/blob/e35c6e39d9c8d658338e1d9a0e4a57a50e151957/README.md) 99 | of the rest-server project. 100 | -------------------------------------------------------------------------------- /build-dependencies.just: -------------------------------------------------------------------------------- 1 | ### DEFAULT ### 2 | 3 | # Install dependencies for the default feature on x86_64-unknown-linux-musl 4 | install-default-x86_64-unknown-linux-musl: 5 | sudo apt-get update 6 | sudo apt-get install -y musl-tools 7 | 8 | # Install dependencies for the default feature on aarch64-unknown-linux-musl 9 | install-default-aarch64-unknown-linux-musl: 10 | sudo apt-get update 11 | sudo apt-get install -y musl-tools 12 | 13 | # Install dependencies for the default feature on i686-unknown-linux-gnu 14 | install-default-i686-unknown-linux-gnu: 15 | sudo apt-get update 16 | sudo apt-get install -y gcc-multilib-i686-linux-gnu gcc-i686-linux-gnu 17 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ default configuration file 2 | # https://git-cliff.org/docs/configuration 3 | # 4 | # Lines starting with "#" are comments. 5 | # Configuration options are organized into tables and keys. 6 | # See documentation for more information on available options. 7 | 8 | [changelog] 9 | # changelog header 10 | header = """ 11 | # Changelog\n 12 | All notable changes to this project will be documented in this file.\n 13 | """ 14 | # template for the changelog body 15 | # https://tera.netlify.app/docs 16 | body = """ 17 | {% if version %}\ 18 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 19 | {% else %}\ 20 | ## [unreleased] 21 | {% endif %}\ 22 | {% for group, commits in commits | group_by(attribute="group") %} 23 | ### {{ group | upper_first }} 24 | {% for commit in commits %} 25 | - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\ 26 | {% endfor %} 27 | {% endfor %}\n 28 | """ 29 | # remove the leading and trailing whitespace from the template 30 | trim = true 31 | # changelog footer 32 | footer = """ 33 | 34 | """ 35 | # postprocessors 36 | postprocessors = [ 37 | { pattern = '', replace = "https://github.com/rustic-rs/rustic_server" }, 38 | ] 39 | [git] 40 | # parse the commits based on https://www.conventionalcommits.org 41 | conventional_commits = true 42 | # filter out the commits that are not conventional 43 | filter_unconventional = true 44 | # process each line of a commit as an individual commit 45 | split_commits = false 46 | # regex for preprocessing the commit messages 47 | commit_preprocessors = [ 48 | { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))" }, # replace issue numbers 49 | ] 50 | # regex for parsing and grouping commits 51 | commit_parsers = [ 52 | { message = "^feat", group = "Features" }, 53 | { message = "^fix", group = "Bug Fixes" }, 54 | { message = "^doc", group = "Documentation" }, 55 | { message = "^perf", group = "Performance" }, 56 | { message = "^refactor", group = "Refactor" }, 57 | { message = "^style", group = "Styling", skip = true }, # we ignore styling in the changelog 58 | { message = "^test", group = "Testing" }, 59 | { message = "^chore\\(release\\): prepare for", skip = true }, 60 | { message = "^chore\\(deps\\)", skip = true }, 61 | { message = "^chore\\(pr\\)", skip = true }, 62 | { message = "^chore\\(pull\\)", skip = true }, 63 | { message = "^chore|ci", group = "Miscellaneous Tasks" }, 64 | { body = ".*security", group = "Security" }, 65 | { message = "^revert", group = "Revert" }, 66 | ] 67 | # protect breaking changes from being skipped due to matching a skipping commit_parser 68 | protect_breaking_commits = false 69 | # filter out the commits that are not matched by commit parsers 70 | filter_commits = false 71 | # glob pattern for matching git tags 72 | tag_pattern = "[0-9]*" 73 | # regex for skipping tags 74 | skip_tags = "v0.1.0-beta.1" 75 | # regex for ignoring tags 76 | ignore_tags = "" 77 | # sort the tags topologically 78 | topo_order = false 79 | # sort the commits inside sections by oldest/newest order 80 | sort_commits = "oldest" 81 | # limit the number of commits included in the changelog. 82 | # limit_commits = 42 83 | -------------------------------------------------------------------------------- /committed.toml: -------------------------------------------------------------------------------- 1 | subject_length = 50 2 | subject_capitalized = false 3 | subject_not_punctuated = true 4 | imperative_subject = true 5 | no_fixup = true 6 | no_wip = true 7 | hard_line_length = 0 8 | line_length = 80 9 | style = "none" 10 | allowed_types = [ 11 | "fix", 12 | "feat", 13 | "chore", 14 | "docs", 15 | "style", 16 | "refactor", 17 | "perf", 18 | "test", 19 | ] 20 | merge_commit = true 21 | -------------------------------------------------------------------------------- /config/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # `rustic-server` Configuration Specification 6 | 7 | This folder contains a few configuration files as an example. 8 | 9 | `rustic-server` has a few configuration files: 10 | 11 | - server configuration (`rustic_server.toml`) 12 | - basic http credential authentication (`.htpasswd`) 13 | - access control list (`acl.toml`) 14 | 15 | ## Server Config File - `rustic_server.toml` 16 | 17 | This file may have any name, but requires valid toml formatting, as shown below. 18 | A path to this file can be entered on the command line when starting the server. 19 | 20 | ```console 21 | rustic-server serve --config/-c 22 | ``` 23 | 24 | ### Server File format 25 | 26 | ```toml 27 | [server] 28 | listen = "127.0.0.1:8000" 29 | 30 | [storage] 31 | data-dir = "./test_data/test_repos/" 32 | # The API for `quota` is not implemented yet, so this is not used 33 | # We are also thinking about human readable sizes, like "1GB" and 34 | # "1MB" etc., for deactivation of the quota, we might use `false`. 35 | quota = 0 36 | 37 | [auth] 38 | disable-auth = false 39 | htpasswd-file = "/test_data/test_repo/.htpasswd" 40 | 41 | [acl] 42 | disable-acl = false 43 | acl-path = "/test_data/test_repo/acl.toml" 44 | append-only = false 45 | 46 | [tls] 47 | disable-tls = false 48 | tls-cert = "/test_data/test_repo/cert.pem" 49 | tls-key = "/test_data/test_repo/key.pem" 50 | 51 | [log] 52 | log-level = "info" 53 | log-file = "/test_data/test_repo/rustic.log" 54 | ``` 55 | 56 | ## Access Control List File - `acl.toml` 57 | 58 | Using the server configuration file, this file may have any name, but requires 59 | valid toml formatting, as shown below. 60 | 61 | A **path** to this file can be entered on the command line when starting the 62 | server. 63 | 64 | ### ACL File format 65 | 66 | ```toml 67 | # Format: 68 | # [] 69 | # = 70 | # ... more users 71 | 72 | [default] # Default repository 73 | alex = "Read" # Alex can read 74 | admin = "Modify" # admin can modify, so has full access, even delete 75 | 76 | [alex] # a repository named 'alex' 77 | alex = "Modify" # Alex can modify his own repository 78 | bob = "Append" # Bob can append to Alex's repository 79 | ``` 80 | 81 | The `access_type` can have values: 82 | 83 | - "Read" --> allows read only access 84 | - "Append" --> allows addition of new files, including initializing a new repo 85 | - "Modify" --> allows write-access, including delete of a repo 86 | 87 | 88 | 89 | # User Credential File - `.htpasswd` 90 | 91 | This file is formatted as a vanilla `Apache .htpasswd` file. 92 | 93 | Using the server configuration file, this file may have any name, but requires 94 | valid formatting. 95 | 96 | A **path** to this file can be entered on the command line when starting the 97 | server. The server binary allows this file to be created from the command line. 98 | Execute `rustic-server auth --help` for details. (This feature is not well 99 | tested, yet. Please use with caution.) 100 | 101 | You can also create this file manually, using the `htpasswd` command line tool. 102 | 103 | ```console 104 | htpasswd -B -c username 105 | ``` 106 | 107 | # Configure `rustic_server` from the command line 108 | 109 | It is also possible to configure the server from the command-line, and skip the 110 | server configuration file. We recommend a configuration file for more complex 111 | setups, though. 112 | 113 | To see all options, use: 114 | 115 | ```console 116 | rustic-server serve --help 117 | ``` 118 | 119 | They are all optional, and the server will use default values if not provided. 120 | The server will also print the configuration it is using, so you can check if it 121 | is correct when starting the server. For example: 122 | 123 | ```console 124 | rustic-server serve --verbose 125 | ``` 126 | -------------------------------------------------------------------------------- /config/acl.example.toml: -------------------------------------------------------------------------------- 1 | [default] # Default repository 2 | alex = "Read" # Alex can read 3 | admin = "Modify" # admin can modify, so has full access, even delete 4 | 5 | [alex] # a repository named 'alex' 6 | alex = "Modify" # Alex can modify his own repository 7 | bob = "Append" # Bob can append to Alex's repository 8 | -------------------------------------------------------------------------------- /config/rustic_profile.toml: -------------------------------------------------------------------------------- 1 | # Adapt to your own configuration, a full list of options can be found at: 2 | # https://github.com/rustic-rs/rustic/tree/main/config 3 | [global] 4 | log-level = "info" 5 | log-file = "~/rustic.log" 6 | 7 | [repository] 8 | repository = "rest:http://rustic:rustic@127.0.0.1:8000/ci_repo" 9 | password = "rustic" 10 | 11 | [backup] 12 | 13 | [[backup.snapshots]] 14 | sources = ["src"] 15 | -------------------------------------------------------------------------------- /config/rustic_server.toml: -------------------------------------------------------------------------------- 1 | [server] 2 | listen = "127.0.0.1:8000" 3 | 4 | [storage] 5 | data-dir = "./test_data/test_repos/" 6 | quota = 0 7 | 8 | [auth] 9 | disable-auth = false 10 | htpasswd-file = "/test_data/test_repo/.htpasswd" 11 | 12 | [acl] 13 | disable-acl = true 14 | acl-path = "/test_data/test_repo/acl.toml" 15 | append-only = false 16 | 17 | [tls] 18 | disable-tls = false 19 | tls-cert = "/test_data/test_repo/cert.pem" 20 | tls-key = "/test_data/test_repo/key.pem" 21 | 22 | [log] 23 | log-level = "info" 24 | log-file = "/test_data/test_repo/rustic.log" 25 | -------------------------------------------------------------------------------- /containers/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine AS builder 2 | ARG RUSTIC_SERVER_VERSION 3 | ARG TARGETARCH 4 | RUN if [ "$TARGETARCH" = "amd64" ]; then \ 5 | ASSET="rustic_server-x86_64-unknown-linux-musl.tar.xz";\ 6 | elif [ "$TARGETARCH" = "arm64" ]; then \ 7 | ASSET="rustic_server-aarch64-unknown-linux-musl.tar.xz"; \ 8 | fi; \ 9 | wget https://github.com/rustic-rs/rustic_server/releases/download/${RUSTIC_SERVER_VERSION}/${ASSET} && \ 10 | tar -xf ${ASSET} --strip-components=1 && \ 11 | mkdir /etc_files && \ 12 | touch /etc_files/passwd && \ 13 | touch /etc_files/group 14 | 15 | FROM scratch 16 | COPY --from=builder /rustic-server /rustic-server 17 | COPY --from=builder /etc_files/ /etc/ 18 | EXPOSE 8000 19 | ENTRYPOINT ["/rustic-server", "serve"] 20 | HEALTHCHECK --interval=90s --timeout=10s --retries=3 \ 21 | CMD curl --fail -s http://localhost:8000/health/live || exit 1 22 | -------------------------------------------------------------------------------- /containers/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | rustic-server: 3 | image: rustic-server:latest 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | args: 8 | RUSTIC_SERVER_VERSION: "v0.4.0" # Replace with the actual version 9 | ports: 10 | - "8000:8000" 11 | volumes: 12 | - ./volumes/config:/etc/rustic-server/config:ro 13 | - ./volumes/certs:/etc/rustic-server/certs:ro 14 | - ./volumes/data:/var/lib/rustic-server/data 15 | - ./volumes/logs:/var/log/ 16 | environment: 17 | - RUSTIC_SERVER_LISTEN=0.0.0.0:8000 18 | - RUSTIC_SERVER_DATA_DIR=/var/lib/rustic-server/data 19 | - RUSTIC_SERVER_QUOTA=0 # 0 means no quota 20 | - RUSTIC_SERVER_VERBOSE=false 21 | # - RUSTIC_SERVER_CONFIG_PATH=/etc/rustic-server/config/server.toml 22 | - RUSTIC_SERVER_DISABLE_AUTH=false 23 | - RUSTIC_SERVER_HTPASSWD_FILE=/var/lib/rustic-server/data/.htpasswd 24 | - RUSTIC_SERVER_PRIVATE_REPOS=true 25 | - RUSTIC_SERVER_APPEND_ONLY=false 26 | - RUSTIC_SERVER_ACL_PATH=/etc/rustic-server/config/acl.toml 27 | - RUSTIC_SERVER_DISABLE_TLS=false 28 | - RUSTIC_SERVER_TLS_KEY=/etc/rustic-server/certs/server.key 29 | - RUSTIC_SERVER_TLS_CERT=/etc/rustic-server/certs/server.crt 30 | - RUSTIC_SERVER_LOG_FILE=/var/log/rustic-server.log 31 | logging: 32 | driver: "json-file" 33 | options: 34 | max-size: "10m" 35 | max-file: "3" 36 | healthcheck: 37 | test: ["CMD", "curl", "--fail", "-s", "http://localhost:8000/health/live"] 38 | interval: 90s 39 | timeout: 10s 40 | retries: 3 41 | networks: 42 | - rustic-network 43 | deploy: 44 | resources: 45 | limits: 46 | cpus: '0.50' 47 | memory: 512M 48 | restart: unless-stopped 49 | 50 | networks: 51 | rustic-network: 52 | driver: bridge 53 | -------------------------------------------------------------------------------- /dist-workspace.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cargo:."] 3 | 4 | # Config for 'dist' 5 | [dist] 6 | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) 7 | cargo-dist-version = "0.25.1" 8 | # Whether to enable GitHub Attestations 9 | github-attestations = true 10 | # CI backends to support 11 | ci = "github" 12 | # The installers to generate for each app 13 | installers = ["shell", "powershell", "homebrew", "msi"] 14 | # Target platforms to build apps for (Rust target-triple syntax) 15 | targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc", "i686-unknown-linux-gnu"] 16 | # Path that installers should place binaries in 17 | install-path = "CARGO_HOME" 18 | # Whether to install an updater program 19 | install-updater = true 20 | # Extra static files to include in each App (path relative to this Cargo.toml's dir) 21 | include = ["./config/", "./USAGE.md", "./LICENSE"] 22 | # Which actions to run on pull requests 23 | pr-run-mode = "upload" 24 | # A GitHub repo to push Homebrew formulas to 25 | tap = "rustic-rs/homebrew-tap" 26 | # Publish jobs to run in CI 27 | publish-jobs = ["homebrew"] 28 | github-build-setup = "../install-arm-linkers.yml" 29 | 30 | [dist.github-custom-runners] 31 | aarch64-apple-darwin = "macos-latest" 32 | aarch64-unknown-linux-gnu = "ubuntu-latest" 33 | aarch64-unknown-linux-musl = "ubuntu-latest" 34 | armv7-unknown-linux-gnueabihf = "ubuntu-latest" 35 | armv7-unknown-linux-musleabihf = "ubuntu-latest" 36 | i686-unknown-linux-gnu = "ubuntu-latest" 37 | x86_64-apple-darwin = "macos-13" 38 | x86_64-pc-windows-gnu = "windows-latest" 39 | x86_64-pc-windows-msvc = "windows-latest" 40 | x86_64-unknown-linux-gnu = "ubuntu-latest" 41 | x86_64-unknown-linux-musl = "ubuntu-latest" 42 | 43 | [dist.dependencies.chocolatey] 44 | nasm = '*' # Required for building `aws-lc-sys` on Windows 45 | 46 | [dist.dependencies.apt] 47 | gcc-aarch64-linux-gnu = { version = '*', targets = ["aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl"] } 48 | gcc-arm-linux-gnueabihf = { version = '*', targets = ["armv7-unknown-linux-gnueabihf", "armv7-unknown-linux-musleabihf"] } 49 | gcc-i686-linux-gnu = { version = '*', targets = ["i686-unknown-linux-gnu"] } 50 | gcc-multilib-i686-linux-gnu = { version = '*', targets = ["i686-unknown-linux-gnu"] } 51 | musl-tools = { version = '*', targets = ["aarch64-unknown-linux-musl", "x86_64-unknown-linux-musl", "armv7-unknown-linux-musleabihf"] } 52 | musl-dev = { version = '*', targets = ["aarch64-unknown-linux-musl", "x86_64-unknown-linux-musl", "armv7-unknown-linux-musleabihf"] } 53 | -------------------------------------------------------------------------------- /dprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "lineWidth": 80, 3 | "markdown": { 4 | "lineWidth": 80, 5 | "emphasisKind": "asterisks", 6 | "strongKind": "asterisks", 7 | "textWrap": "always" 8 | }, 9 | "toml": { 10 | "lineWidth": 80 11 | }, 12 | "json": { 13 | "lineWidth": 80, 14 | "indentWidth": 4 15 | }, 16 | "includes": [ 17 | "**/*.{md}", 18 | "**/*.{toml}", 19 | "**/*.{json}" 20 | ], 21 | "excludes": [ 22 | "target/**/*", 23 | "CHANGELOG.md", 24 | "dist-workspace.toml" 25 | ], 26 | "plugins": [ 27 | "https://plugins.dprint.dev/markdown-0.17.8.wasm", 28 | "https://plugins.dprint.dev/toml-0.6.3.wasm", 29 | "https://plugins.dprint.dev/json-0.19.4.wasm" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /maskfile.md: -------------------------------------------------------------------------------- 1 | # Tasks 2 | 3 | Development tasks for rustic. 4 | 5 | You can run this file with [mask](https://github.com/jacobdeichert/mask/). 6 | 7 | Install `mask` with `cargo install mask`. 8 | 9 | ## check 10 | 11 | > Checks the library for syntax and HIR errors. 12 | 13 | Bash: 14 | 15 | ```bash 16 | cargo check --no-default-features \ 17 | && cargo check --all-features 18 | ``` 19 | 20 | PowerShell: 21 | 22 | ```powershell 23 | [Diagnostics.Process]::Start("cargo", "check --no-default-features").WaitForExit() 24 | [Diagnostics.Process]::Start("cargo", "cargo check --all-features").WaitForExit() 25 | ``` 26 | 27 | ## ci 28 | 29 | > Continually runs the development routines. 30 | 31 | Bash: 32 | 33 | ```bash 34 | mask loop dev 35 | ``` 36 | 37 | PowerShell: 38 | 39 | ```powershell 40 | [Diagnostics.Process]::Start("mask", "loop dev").WaitForExit() 41 | ``` 42 | 43 | ## clean 44 | 45 | > Removes all build artifacts. 46 | 47 | Bash: 48 | 49 | ```bash 50 | cargo clean 51 | ``` 52 | 53 | PowerShell: 54 | 55 | ```powershell 56 | [Diagnostics.Process]::Start("cargo", "clean").WaitForExit() 57 | ``` 58 | 59 | ## dev 60 | 61 | > Runs the development routines 62 | 63 | Bash: 64 | 65 | ```bash 66 | $MASK format \ 67 | && $MASK lint \ 68 | && $MASK test \ 69 | && $MASK doc 70 | ``` 71 | 72 | PowerShell: 73 | 74 | ```powershell 75 | [Diagnostics.Process]::Start("mask", "format").WaitForExit() 76 | [Diagnostics.Process]::Start("mask", "lint").WaitForExit() 77 | [Diagnostics.Process]::Start("mask", "test").WaitForExit() 78 | [Diagnostics.Process]::Start("mask", "doc").WaitForExit() 79 | ``` 80 | 81 | ## doc (crate) 82 | 83 | > Opens the crate documentation 84 | 85 | Bash: 86 | 87 | ```bash 88 | cargo doc --all-features --no-deps --open $crate 89 | ``` 90 | 91 | PowerShell: 92 | 93 | ```powershell 94 | [Diagnostics.Process]::Start("cargo", "doc --all-features --no-deps --open $crate").WaitForExit() 95 | ``` 96 | 97 | ## format 98 | 99 | > Run formatters on the repository. 100 | 101 | ### format cargo 102 | 103 | > Runs the formatter on all Rust files. 104 | 105 | Bash: 106 | 107 | ```bash 108 | cargo fmt --all 109 | ``` 110 | 111 | PowerShell: 112 | 113 | ```powershell 114 | [Diagnostics.Process]::Start("cargo", "fmt --all").WaitForExit() 115 | ``` 116 | 117 | ### format dprint 118 | 119 | > Runs the formatter on md, json, and toml files 120 | 121 | Bash: 122 | 123 | ```bash 124 | dprint fmt 125 | ``` 126 | 127 | PowerShell: 128 | 129 | ```powershell 130 | [Diagnostics.Process]::Start("dprint", "fmt").WaitForExit() 131 | ``` 132 | 133 | ### format all 134 | 135 | > Runs all the formatters. 136 | 137 | Bash: 138 | 139 | ```bash 140 | $MASK format cargo \ 141 | && $MASK format dprint 142 | ``` 143 | 144 | PowerShell: 145 | 146 | ```powershell 147 | [Diagnostics.Process]::Start("mask", "format cargo").WaitForExit() 148 | [Diagnostics.Process]::Start("mask", "format dprint").WaitForExit() 149 | ``` 150 | 151 | ## inverse-deps (crate) 152 | 153 | > Lists all crates that depend on the given crate 154 | 155 | Bash: 156 | 157 | ```bash 158 | cargo tree -e features -i $crate 159 | ``` 160 | 161 | PowerShell: 162 | 163 | ```powershell 164 | [Diagnostics.Process]::Start("cargo", "tree -e features -i $crate").WaitForExit() 165 | ``` 166 | 167 | ## lint 168 | 169 | > Runs the linter 170 | 171 | Bash: 172 | 173 | ```bash 174 | $MASK check \ 175 | && cargo clippy --no-default-features -- -D warnings \ 176 | && cargo clippy --all-features -- -D warnings 177 | ``` 178 | 179 | PowerShell: 180 | 181 | ```powershell 182 | [Diagnostics.Process]::Start("mask", "check").WaitForExit() 183 | [Diagnostics.Process]::Start("cargo", "clippy --no-default-features -- -D warnings").WaitForExit() 184 | [Diagnostics.Process]::Start("cargo", "clippy --all-features -- -D warnings").WaitForExit() 185 | ``` 186 | 187 | ## loop (action) 188 | 189 | > Continually runs some recipe from this file. 190 | 191 | Bash: 192 | 193 | ```bash 194 | watchexec -w src -- "$MASK $action" 195 | ``` 196 | 197 | PowerShell: 198 | 199 | ```powershell 200 | [Diagnostics.Process]::Start("watchexec", "-w src -- $MASK $action).WaitForExit() 201 | ``` 202 | 203 | ## miri (tests) 204 | 205 | > Looks for undefined behavior in the (non-doc) test suite. 206 | 207 | **NOTE**: This requires the nightly toolchain. 208 | 209 | Bash: 210 | 211 | ```bash 212 | cargo +nightly miri test --all-features -q --lib --tests $tests 213 | ``` 214 | 215 | PowerShell: 216 | 217 | ```powershell 218 | [Diagnostics.Process]::Start("cargo", "+nightly miri test --all-features -q --lib --tests $tests").WaitForExit() 219 | ``` 220 | 221 | ## nextest 222 | 223 | > Runs the whole test suite with nextest. 224 | 225 | ### nextest ignored 226 | 227 | > Runs the whole test suite with nextest on the workspace, including ignored 228 | > tests. 229 | 230 | Bash: 231 | 232 | ```bash 233 | cargo nextest run -r --all-features --workspace -- --ignored 234 | ``` 235 | 236 | PowerShell: 237 | 238 | ```powershell 239 | [Diagnostics.Process]::Start("cargo", "nextest run -r --all-features --workspace -- --ignored").WaitForExit() 240 | ``` 241 | 242 | ### nextest ws 243 | 244 | > Runs the whole test suite with nextest on the workspace. 245 | 246 | Bash: 247 | 248 | ```bash 249 | cargo nextest run -r --all-features --workspace 250 | ``` 251 | 252 | PowerShell: 253 | 254 | ```powershell 255 | [Diagnostics.Process]::Start("cargo", "nextest run -r --all-features --workspace").WaitForExit() 256 | ``` 257 | 258 | ### nextest test 259 | 260 | > Runs a single test with nextest. 261 | 262 | - test 263 | - flags: -t, --test 264 | - type: string 265 | - desc: Only run the specified test target 266 | - required 267 | 268 | Bash: 269 | 270 | ```bash 271 | cargo nextest run -r --all-features -E "test($test)" 272 | ``` 273 | 274 | PowerShell: 275 | 276 | ```powershell 277 | [Diagnostics.Process]::Start("cargo", "nextest run -r --all-features -E 'test($test)'").WaitForExit() 278 | ``` 279 | 280 | ## pr 281 | 282 | > Prepare a Contribution/Pull request and run necessary checks and lints 283 | 284 | Bash: 285 | 286 | ```bash 287 | $MASK fmt \ 288 | && $MASK test \ 289 | && $MASK lint 290 | ``` 291 | 292 | PowerShell: 293 | 294 | ```powershell 295 | [Diagnostics.Process]::Start("mask", "fmt").WaitForExit() 296 | [Diagnostics.Process]::Start("mask", "test").WaitForExit() 297 | [Diagnostics.Process]::Start("mask", "lint").WaitForExit() 298 | ``` 299 | 300 | ## test 301 | 302 | > Runs the test suites. 303 | 304 | Bash: 305 | 306 | ```bash 307 | $MASK check \ 308 | && $MASK lint 309 | && cargo test --all-features 310 | ``` 311 | 312 | PowerShell: 313 | 314 | ```powershell 315 | [Diagnostics.Process]::Start("mask", "check").WaitForExit() 316 | [Diagnostics.Process]::Start("mask", "lint").WaitForExit() 317 | [Diagnostics.Process]::Start("cargo", "test --all-features").WaitForExit() 318 | ``` 319 | 320 | ## test-restic 321 | 322 | > Run a restic test against the server 323 | 324 | Bash: 325 | 326 | ```bash 327 | export RESTIC_REPOSITORY=rest:http://127.0.0.1:8000/ci_repo 328 | export RESTIC_PASSWORD=restic 329 | export RESTIC_REST_USERNAME=restic 330 | export RESTIC_REST_PASSWORD=restic 331 | restic init 332 | restic backup tests/fixtures/test_data/test_repo_source 333 | restic backup tests/fixtures/test_data/test_repo_source 334 | restic check 335 | restic forget --keep-last 1 --prune 336 | ``` 337 | 338 | PowerShell: 339 | 340 | ```powershell 341 | $env:RESTIC_REPOSITORY = "rest:http://127.0.0.1:8000/ci_repo"; 342 | $env:RESTIC_PASSWORD = "restic"; 343 | $env:RESTIC_REST_USERNAME = "restic"; 344 | $env:RESTIC_REST_PASSWORD = "restic"; 345 | restic init 346 | restic backup tests/fixtures/test_data/test_repo_source 347 | restic backup tests/fixtures/test_data/test_repo_source 348 | restic check 349 | restic forget --keep-last 1 --prune 350 | ``` 351 | 352 | ## test-server 353 | 354 | > Run our server for testing 355 | 356 | Bash: 357 | 358 | ```bash 359 | cargo run -- serve -c tests/fixtures/test_data/rustic_server.toml -v 360 | ``` 361 | 362 | PowerShell: 363 | 364 | ```powershell 365 | [Diagnostics.Process]::Start("cargo", "run -- serve -c tests/fixtures/test_data/rustic_server.toml -v").WaitForExit() 366 | ``` 367 | 368 | 369 | 370 | ## test-restic-server 371 | 372 | > Run a restic server for testing 373 | 374 | Bash: 375 | 376 | ```bash 377 | tests/fixtures/rest_server/rest-server.exe --path ./tests/generated/test_storage/ --htpasswd-file ./tests/fixtures/test_data/.htpasswd --log ./tests/fixtures/rest_server/response2.log 378 | ``` 379 | 380 | PowerShell: 381 | 382 | ```powershell 383 | [Diagnostics.Process]::Start(".\\tests\\fixtures\\rest_server\\rest-server.exe", "--path .\\tests\\generated\\test_storage\\ --htpasswd-file .\\tests\\fixtures\\test_data\\.htpasswd --log .\\tests\\fixtures\\rest_server\\response2.log").WaitForExit() 384 | ``` 385 | 386 | ## loop-test-server 387 | 388 | > Run our server for testing in a loop 389 | 390 | PowerShell: 391 | 392 | ```powershell 393 | watchexec --stop-signal "CTRL+C" -r -w src -w tests -- "cargo run -- serve -c tests/fixtures/test_data/rustic_server.toml -v" 394 | ``` 395 | 396 | ## hurl 397 | 398 | > Run a hurl test against the server 399 | 400 | Bash: 401 | 402 | ```bash 403 | hurl -i tests/fixtures/hurl/endpoints.hurl 404 | ``` 405 | 406 | PowerShell: 407 | 408 | ```powershell 409 | hurl -i tests/fixtures/hurl/endpoints.hurl 410 | ``` 411 | 412 | ## debug-test 413 | 414 | > Run a single test with debug output 415 | 416 | **OPTIONS** 417 | 418 | - name 419 | - flags: -n --name 420 | - type: string 421 | - desc: Which test to run 422 | 423 | - domain 424 | - flags: -d --domain 425 | - type: string 426 | - desc: Which domain to ping 427 | - required 428 | 429 | Bash: 430 | 431 | ```bash 432 | echo $name 433 | RUST_LOG="debug"; cargo test --package rustic_server --lib -- $test --exact --nocapture --show-output 434 | ``` 435 | 436 | PowerShell: 437 | 438 | ```powershell 439 | $env:RUST_LOG="debug"; cargo test --package rustic_server --lib -- $test --exact --nocapture --show-output 440 | ``` 441 | -------------------------------------------------------------------------------- /release-plz.toml: -------------------------------------------------------------------------------- 1 | # configuration spec can be found here https://release-plz.ieni.dev/docs/config 2 | 3 | [workspace] 4 | pr_draft = true 5 | dependencies_update = true 6 | git_release_enable = false # disable GitHub/Gitea releases 7 | # changelog_config = "cliff.toml" # Don't use this for now, as it will override the default changelog config 8 | 9 | [changelog] 10 | protect_breaking_commits = true 11 | -------------------------------------------------------------------------------- /self_signed_certs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rustic-rs/rustic_server/c1435f6bbe940262df0179a1e9794d2b5bb461df/self_signed_certs/.gitkeep -------------------------------------------------------------------------------- /src/application.rs: -------------------------------------------------------------------------------- 1 | //! `RusticServer` Abscissa Application 2 | 3 | use crate::{commands::EntryPoint, config::RusticServerConfig}; 4 | use abscissa_core::Config; 5 | use abscissa_core::FrameworkErrorKind::IoError; 6 | use abscissa_core::{ 7 | application::{self, AppCell}, 8 | config::{self, CfgCell}, 9 | path::AbsPathBuf, 10 | trace, Application, FrameworkError, StandardPaths, 11 | }; 12 | use abscissa_tokio::TokioComponent; 13 | use std::path::Path; 14 | 15 | /// Application state 16 | pub static RUSTIC_SERVER_APP: AppCell = AppCell::new(); 17 | 18 | /// `RusticServer` Application 19 | #[derive(Debug)] 20 | pub struct RusticServerApp { 21 | /// Application configuration. 22 | config: CfgCell, 23 | 24 | /// Application state. 25 | state: application::State, 26 | } 27 | 28 | /// Initialize a new application instance. 29 | /// 30 | /// By default no configuration is loaded, and the framework state is 31 | /// initialized to a default, empty state (no components, threads, etc). 32 | impl Default for RusticServerApp { 33 | fn default() -> Self { 34 | Self { 35 | config: CfgCell::default(), 36 | state: application::State::default(), 37 | } 38 | } 39 | } 40 | 41 | impl Application for RusticServerApp { 42 | /// Entrypoint command for this application. 43 | type Cmd = EntryPoint; 44 | 45 | /// Application configuration. 46 | type Cfg = RusticServerConfig; 47 | 48 | /// Paths to resources within the application. 49 | type Paths = StandardPaths; 50 | 51 | /// Accessor for application configuration. 52 | fn config(&self) -> config::Reader { 53 | self.config.read() 54 | } 55 | 56 | /// Borrow the application state immutably. 57 | fn state(&self) -> &application::State { 58 | &self.state 59 | } 60 | 61 | /// Register all components used by this application. 62 | /// 63 | /// If you would like to add additional components to your application 64 | /// beyond the default ones provided by the framework, this is the place 65 | /// to do so. 66 | fn register_components(&mut self, command: &Self::Cmd) -> Result<(), FrameworkError> { 67 | let mut components = self.framework_components(command)?; 68 | 69 | // Create `TokioComponent` and add it to your app's components here: 70 | components.push(Box::new(TokioComponent::new()?)); 71 | 72 | self.state.components_mut().register(components) 73 | } 74 | 75 | /// Post-configuration lifecycle callback. 76 | /// 77 | /// Called regardless of whether config is loaded to indicate this is the 78 | /// time in app lifecycle when configuration would be loaded if 79 | /// possible. 80 | fn after_config(&mut self, config: Self::Cfg) -> Result<(), FrameworkError> { 81 | // Configure components 82 | self.state.components_mut().after_config(&config)?; 83 | self.config.set_once(config); 84 | Ok(()) 85 | } 86 | 87 | /// Load configuration from the given path. 88 | /// 89 | /// Returns an error if the configuration could not be loaded. 90 | fn load_config(&mut self, path: &Path) -> Result { 91 | let canonical_path = AbsPathBuf::canonicalize(path).map_err(|_err| { 92 | FrameworkError::from(IoError.context( 93 | "It seems like your configuration wasn't found! Please make sure it exists at the given location!" 94 | )) 95 | })?; 96 | 97 | Self::Cfg::load_toml_file(canonical_path) 98 | } 99 | 100 | /// Get tracing configuration from command-line options 101 | fn tracing_config(&self, command: &EntryPoint) -> trace::Config { 102 | if command.verbose { 103 | trace::Config::verbose() 104 | } else { 105 | trace::Config::default() 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/auth.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Borrow, path::PathBuf}; 2 | 3 | use abscissa_core::SecretString; 4 | use axum::{extract::FromRequestParts, http::request::Parts}; 5 | use axum_auth::AuthBasic; 6 | use serde_derive::Deserialize; 7 | use std::sync::OnceLock; 8 | 9 | use crate::{ 10 | config::HtpasswdSettings, 11 | error::{ApiErrorKind, ApiResult, AppResult}, 12 | htpasswd::{CredentialMap, Htpasswd}, 13 | }; 14 | 15 | // Static storage of our credentials 16 | pub static AUTH: OnceLock = OnceLock::new(); 17 | 18 | pub(crate) fn init_auth(auth: Auth) -> AppResult<()> { 19 | let _ = AUTH.get_or_init(|| auth); 20 | Ok(()) 21 | } 22 | 23 | #[derive(Debug, Clone, Default)] 24 | pub struct Auth { 25 | users: Option, 26 | } 27 | 28 | impl From for Auth { 29 | fn from(users: CredentialMap) -> Self { 30 | Self { users: Some(users) } 31 | } 32 | } 33 | 34 | impl From for Auth { 35 | fn from(htpasswd: Htpasswd) -> Self { 36 | Self { 37 | users: Some(htpasswd.credentials), 38 | } 39 | } 40 | } 41 | 42 | impl Auth { 43 | pub fn from_file(disable_auth: bool, path: &PathBuf) -> AppResult { 44 | Ok(if disable_auth { 45 | Self::default() 46 | } else { 47 | Htpasswd::from_file(path)?.into() 48 | }) 49 | } 50 | 51 | pub fn from_config(settings: &HtpasswdSettings, path: PathBuf) -> AppResult { 52 | Self::from_file(settings.is_disabled(), &path) 53 | } 54 | 55 | // verify verifies user/passwd against the credentials saved in users. 56 | // returns true if Auth::users is None. 57 | pub fn verify(&self, user: impl Into, passwd: impl Into) -> bool { 58 | let user = user.into(); 59 | let passwd = passwd.into(); 60 | 61 | self.users.as_ref().map_or(true, |users| matches!(users.get(&user), Some(passwd_data) if htpasswd_verify::Htpasswd::from(passwd_data.to_string().borrow()).check(user, passwd))) 62 | } 63 | 64 | pub const fn is_disabled(&self) -> bool { 65 | self.users.is_none() 66 | } 67 | } 68 | 69 | #[derive(Deserialize, Debug)] 70 | pub struct BasicAuthFromRequest { 71 | pub(crate) user: String, 72 | pub(crate) _password: SecretString, 73 | } 74 | 75 | #[async_trait::async_trait] 76 | impl FromRequestParts for BasicAuthFromRequest { 77 | type Rejection = ApiErrorKind; 78 | 79 | // FIXME: We also have a configuration flag do run without authentication 80 | // This must be handled here too ... otherwise we get an Auth header missing error. 81 | async fn from_request_parts(parts: &mut Parts, state: &S) -> ApiResult { 82 | let checker = AUTH.get().unwrap(); 83 | 84 | let auth_result = AuthBasic::from_request_parts(parts, state).await; 85 | 86 | tracing::debug!(?auth_result, "[AUTH]"); 87 | 88 | return match auth_result { 89 | Ok(auth) => { 90 | let AuthBasic((user, passw)) = auth; 91 | let password = passw.unwrap_or_else(String::new); 92 | if checker.verify(user.as_str(), password.as_str()) { 93 | Ok(Self { 94 | user, 95 | _password: password.into(), 96 | }) 97 | } else { 98 | Err(ApiErrorKind::UserAuthenticationError(user)) 99 | } 100 | } 101 | Err(_) => { 102 | let user = String::new(); 103 | if checker.verify("", "") { 104 | return Ok(Self { 105 | user, 106 | _password: String::new().into(), 107 | }); 108 | } 109 | Err(ApiErrorKind::AuthenticationHeaderError) 110 | } 111 | }; 112 | } 113 | } 114 | 115 | #[cfg(test)] 116 | mod test { 117 | use super::*; 118 | 119 | use crate::testing::{basic_auth_header_value, init_test_environment, server_config}; 120 | 121 | use anyhow::Result; 122 | use axum::{ 123 | body::Body, 124 | http::{Method, Request, StatusCode}, 125 | routing::get, 126 | Router, 127 | }; 128 | use http_body_util::BodyExt; 129 | use rstest::{fixture, rstest}; 130 | use tower::ServiceExt; 131 | 132 | #[fixture] 133 | fn auth() -> Auth { 134 | let htpasswd = PathBuf::from("tests/fixtures/test_data/.htpasswd"); 135 | Auth::from_file(false, &htpasswd).unwrap() 136 | } 137 | 138 | #[rstest] 139 | fn test_auth_passes(auth: Auth) -> Result<()> { 140 | assert!(auth.verify("rustic", "rustic")); 141 | assert!(!auth.verify("rustic", "_rustic")); 142 | 143 | Ok(()) 144 | } 145 | 146 | #[rstest] 147 | fn test_auth_from_file_passes(auth: Auth) { 148 | init_auth(auth).unwrap(); 149 | 150 | let auth = AUTH.get().unwrap(); 151 | assert!(auth.verify("rustic", "rustic")); 152 | assert!(!auth.verify("rustic", "_rustic")); 153 | } 154 | 155 | async fn format_auth_basic(AuthBasic((id, password)): AuthBasic) -> String { 156 | format!("Got {} and {:?}", id, password) 157 | } 158 | 159 | async fn format_handler_from_auth_request(auth: BasicAuthFromRequest) -> String { 160 | format!("User = {}", auth.user) 161 | } 162 | 163 | /// The requests which should be returned OK 164 | #[tokio::test] 165 | async fn test_authentication_passes() { 166 | init_test_environment(server_config()); 167 | 168 | // ----------------------------------------- 169 | // Try good basic 170 | // ----------------------------------------- 171 | let app = Router::new().route("/basic", get(format_auth_basic)); 172 | 173 | let request = Request::builder() 174 | .uri("/basic") 175 | .method(Method::GET) 176 | .header( 177 | "Authorization", 178 | basic_auth_header_value("My Username", Some("My Password")), 179 | ) 180 | .body(Body::empty()) 181 | .unwrap(); 182 | 183 | let resp = app.oneshot(request).await.unwrap(); 184 | 185 | assert_eq!(resp.status(), StatusCode::OK); 186 | let body = resp.into_parts().1; 187 | let byte_vec = body.into_data_stream().collect().await.unwrap().to_bytes(); 188 | let body_str = String::from_utf8(byte_vec.to_vec()).unwrap(); 189 | assert_eq!( 190 | body_str, 191 | String::from("Got My Username and Some(\"My Password\")") 192 | ); 193 | 194 | // ----------------------------------------- 195 | // Try good using auth struct 196 | // ----------------------------------------- 197 | let app = Router::new().route("/rustic_server", get(format_handler_from_auth_request)); 198 | 199 | let request = Request::builder() 200 | .uri("/rustic_server") 201 | .method(Method::GET) 202 | .header( 203 | "Authorization", 204 | basic_auth_header_value("rustic", Some("rustic")), 205 | ) 206 | .body(Body::empty()) 207 | .unwrap(); 208 | 209 | let resp = app.oneshot(request).await.unwrap(); 210 | 211 | assert_eq!(resp.status().as_u16(), StatusCode::OK.as_u16()); 212 | let body = resp.into_parts().1; 213 | let byte_vec = body.collect().await.unwrap().to_bytes(); 214 | let body_str = String::from_utf8(byte_vec.to_vec()).unwrap(); 215 | assert_eq!(body_str, String::from("User = rustic")); 216 | } 217 | 218 | #[tokio::test] 219 | async fn test_fail_authentication_passes() { 220 | init_test_environment(server_config()); 221 | 222 | // ----------------------------------------- 223 | // Try wrong password rustic_server 224 | // ----------------------------------------- 225 | let app = Router::new().route("/rustic_server", get(format_handler_from_auth_request)); 226 | 227 | let request = Request::builder() 228 | .uri("/rustic_server") 229 | .method(Method::GET) 230 | .header( 231 | "Authorization", 232 | basic_auth_header_value("rustic", Some("_rustic")), 233 | ) 234 | .body(Body::empty()) 235 | .unwrap(); 236 | 237 | let resp = app.oneshot(request).await.unwrap(); 238 | 239 | assert_eq!(resp.status(), StatusCode::FORBIDDEN); 240 | 241 | // ----------------------------------------- 242 | // Try without authentication header 243 | // ----------------------------------------- 244 | let app = Router::new().route("/rustic_server", get(format_handler_from_auth_request)); 245 | 246 | let request = Request::builder() 247 | .uri("/rustic_server") 248 | .method(Method::GET) 249 | .body(Body::empty()) 250 | .unwrap(); 251 | 252 | let resp = app.oneshot(request).await.unwrap(); 253 | 254 | assert_eq!(resp.status().as_u16(), StatusCode::FORBIDDEN); 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /src/bin/rustic-server.rs: -------------------------------------------------------------------------------- 1 | //! Main entry point for RusticServer 2 | 3 | #![deny(warnings, missing_docs, trivial_casts, unused_qualifications)] 4 | #![forbid(unsafe_code)] 5 | 6 | use rustic_server::application::RUSTIC_SERVER_APP; 7 | 8 | /// Boot RusticServer 9 | fn main() { 10 | abscissa_core::boot(&RUSTIC_SERVER_APP); 11 | } 12 | -------------------------------------------------------------------------------- /src/commands.rs: -------------------------------------------------------------------------------- 1 | //! `RusticServer` Subcommands 2 | //! 3 | //! This is where you specify the subcommands of your application. 4 | //! 5 | //! The default application comes with two subcommands: 6 | //! 7 | //! - `start`: launches the application 8 | //! - `--version`: print application version 9 | //! 10 | //! See the `impl Configurable` below for how to specify the path to the 11 | //! application's configuration file. 12 | 13 | mod auth; 14 | mod serve; 15 | 16 | use crate::{ 17 | commands::{auth::AuthCmd, serve::ServeCmd}, 18 | config::RusticServerConfig, 19 | }; 20 | use abscissa_core::{ 21 | config::Override, tracing::info, Command, Configurable, FrameworkError, Runnable, 22 | }; 23 | use clap::builder::{ 24 | styling::{AnsiColor, Effects}, 25 | Styles, 26 | }; 27 | use std::path::PathBuf; 28 | 29 | /// `RusticServer` Configuration Filename 30 | pub const CONFIG_FILE: &str = "rustic_server.toml"; 31 | 32 | /// `RusticServer` Subcommands 33 | /// Subcommands need to be listed in an enum. 34 | #[derive(clap::Parser, Command, Debug, Runnable)] 35 | pub enum RusticServerCmd { 36 | /// Authentication for users. Add, update, delete, or list users. 37 | Auth(AuthCmd), 38 | 39 | /// Start a server with the specified configuration 40 | Serve(ServeCmd), 41 | } 42 | 43 | fn styles() -> Styles { 44 | Styles::styled() 45 | .header(AnsiColor::Red.on_default() | Effects::BOLD) 46 | .usage(AnsiColor::Red.on_default() | Effects::BOLD) 47 | .literal(AnsiColor::Blue.on_default() | Effects::BOLD) 48 | .placeholder(AnsiColor::Green.on_default()) 49 | } 50 | 51 | /// Entry point for the application. It needs to be a struct to allow using subcommands! 52 | #[derive(clap::Parser, Command, Debug)] 53 | #[command(author, about, version)] 54 | #[command(author, about, name="rustic-server", styles=styles(), version = env!("CARGO_PKG_VERSION"))] 55 | pub struct EntryPoint { 56 | #[command(subcommand)] 57 | cmd: RusticServerCmd, 58 | 59 | /// Enable verbose logging 60 | #[arg(short, long, global = true, env = "RUSTIC_SERVER_VERBOSE")] 61 | pub verbose: bool, 62 | 63 | /// Use the specified config file 64 | #[arg(short, long, global = true, env = "RUSTIC_SERVER_CONFIG_PATH")] 65 | pub config: Option, 66 | } 67 | 68 | impl Runnable for EntryPoint { 69 | fn run(&self) { 70 | self.cmd.run(); 71 | } 72 | } 73 | 74 | /// This trait allows you to define how application configuration is loaded. 75 | impl Configurable for EntryPoint { 76 | /// Location of the configuration file 77 | fn config_path(&self) -> Option { 78 | // Early return if no config file was provided 79 | if self.config.is_none() { 80 | info!("No configuration file provided."); 81 | return None; 82 | } 83 | 84 | let filename = self 85 | .config 86 | .as_ref() 87 | .map(PathBuf::from) 88 | .unwrap_or_else(|| CONFIG_FILE.into()); 89 | 90 | if filename.exists() { 91 | // Check if the config file exists, and if it does not, 92 | info!("Using configuration file: `{filename:?}`"); 93 | Some(filename) 94 | } else { 95 | info!("Provided configuration file not found. Trying default."); 96 | // for a missing configuration file to be a hard error 97 | // instead, always return `Some(CONFIG_FILE)` here. 98 | Some(PathBuf::from(CONFIG_FILE)) 99 | } 100 | } 101 | 102 | /// Apply changes to the config after it's been loaded, e.g. overriding 103 | /// values in a config file using command-line options. 104 | /// 105 | /// This can be safely deleted if you don't want to override config 106 | /// settings from command-line options. 107 | fn process_config( 108 | &self, 109 | config: RusticServerConfig, 110 | ) -> Result { 111 | match &self.cmd { 112 | RusticServerCmd::Serve(cmd) => cmd.override_config(config), 113 | _ => Ok(config), 114 | } 115 | } 116 | } 117 | 118 | #[cfg(test)] 119 | mod tests { 120 | use crate::commands::EntryPoint; 121 | use clap::CommandFactory; 122 | 123 | #[test] 124 | fn verify_cli() { 125 | EntryPoint::command().debug_assert(); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/commands/auth.rs: -------------------------------------------------------------------------------- 1 | //! `auth` subcommand 2 | 3 | use std::path::PathBuf; 4 | 5 | use abscissa_core::{status_err, Application, Command, Runnable, Shutdown}; 6 | use anyhow::{bail, Result}; 7 | use clap::{Args, Parser, Subcommand}; 8 | 9 | use crate::{htpasswd::Htpasswd, prelude::RUSTIC_SERVER_APP}; 10 | 11 | /// `auth` subcommand 12 | /// 13 | /// The `Parser` proc macro generates an option parser based on the struct 14 | /// definition, and is defined in the `clap` crate. See their documentation 15 | /// for a more comprehensive example: 16 | /// 17 | /// 18 | #[derive(Command, Debug, Parser)] 19 | pub struct AuthCmd { 20 | #[command(subcommand)] 21 | command: Commands, 22 | } 23 | 24 | impl Runnable for AuthCmd { 25 | /// Start the application. 26 | fn run(&self) { 27 | if let Err(err) = self.inner_run() { 28 | status_err!("{}", err); 29 | RUSTIC_SERVER_APP.shutdown(Shutdown::Crash); 30 | } 31 | } 32 | } 33 | 34 | #[derive(Subcommand, Debug)] 35 | enum Commands { 36 | /// Add a new credential to the .htpasswd file. 37 | /// If the username already exists it will update the password only. 38 | Add(AddArg), 39 | /// Change the password for an existing user. 40 | Update(AddArg), 41 | /// Delete an existing credential from the .htpasswd file. 42 | Delete(DelArg), 43 | /// List all users known in the .htpasswd file. 44 | List(PrintArg), 45 | } 46 | 47 | #[derive(Args, Debug)] 48 | struct AddArg { 49 | ///Path to authorization file 50 | #[arg(short = 'f')] 51 | pub config_path: PathBuf, 52 | /// Name of the user to be added. 53 | #[arg(short = 'u')] 54 | user: String, 55 | /// Password. 56 | #[arg(short = 'p')] 57 | password: String, 58 | } 59 | 60 | #[derive(Args, Debug)] 61 | struct DelArg { 62 | ///Path to authorization file 63 | #[arg(short = 'f')] 64 | pub config_path: PathBuf, 65 | /// Name of the user to be removed. 66 | #[arg(short = 'u')] 67 | user: String, 68 | } 69 | 70 | #[derive(Args, Debug)] 71 | struct PrintArg { 72 | ///Path to authorization file 73 | #[arg(short = 'f')] 74 | pub config_path: PathBuf, 75 | } 76 | 77 | /// The server configuration file should point us to the `.htpasswd` file. 78 | /// If not we complain to the user. 79 | /// 80 | /// To be nice, if the `.htpasswd` file pointed to does not exist, then we create it. 81 | /// We do so, even if it is not called `.htpasswd`. 82 | impl AuthCmd { 83 | pub fn inner_run(&self) -> Result<()> { 84 | match &self.command { 85 | Commands::Add(arg) => { 86 | add(arg)?; 87 | } 88 | Commands::Update(arg) => { 89 | update(arg)?; 90 | } 91 | Commands::Delete(arg) => { 92 | delete(arg)?; 93 | } 94 | Commands::List(arg) => { 95 | print(arg)?; 96 | } 97 | }; 98 | Ok(()) 99 | } 100 | } 101 | 102 | fn check(path: &PathBuf) -> Result<()> { 103 | //Check 104 | if path.exists() { 105 | if !path.is_file() { 106 | bail!( 107 | "Error: Given path leads to a folder, not a file: {}", 108 | path.to_string_lossy() 109 | ); 110 | } 111 | 112 | if let Err(err) = std::fs::OpenOptions::new() 113 | //Test: "open for writing" (fail fast) 114 | .create(false) 115 | .truncate(false) 116 | .append(true) 117 | .open(path) 118 | { 119 | bail!( 120 | "No write access to the htpasswd file: {} due to {}", 121 | path.to_string_lossy(), 122 | err 123 | ); 124 | }; 125 | } else { 126 | //"touch server_config file" (fail fast) 127 | if let Err(err) = std::fs::OpenOptions::new() 128 | .create(true) 129 | .truncate(false) 130 | .write(true) 131 | .open(path) 132 | { 133 | bail!( 134 | "Failed to create empty server configuration file: {} due to {}", 135 | &path.to_string_lossy(), 136 | err 137 | ); 138 | }; 139 | }; 140 | 141 | Ok(()) 142 | } 143 | 144 | fn add(arg: &AddArg) -> Result<()> { 145 | let ht_access_path = PathBuf::from(&arg.config_path); 146 | check(&ht_access_path)?; 147 | let mut ht_access = Htpasswd::from_file(&ht_access_path)?; 148 | 149 | if ht_access.users().contains(&arg.user.to_string()) { 150 | bail!( 151 | "User '{}' exists; use update to change password. No changes were made.", 152 | arg.user.as_str() 153 | ); 154 | } 155 | 156 | let _ = ht_access.update(arg.user.as_str(), arg.password.as_str()); 157 | 158 | ht_access.to_file()?; 159 | Ok(()) 160 | } 161 | 162 | fn update(arg: &AddArg) -> Result<()> { 163 | let ht_access_path = PathBuf::from(&arg.config_path); 164 | check(&ht_access_path)?; 165 | let mut ht_access = Htpasswd::from_file(&ht_access_path)?; 166 | 167 | if !ht_access.credentials.contains_key(arg.user.as_str()) { 168 | bail!( 169 | "I can not find a user with name {}. Use add command?", 170 | arg.user.as_str() 171 | ); 172 | } 173 | let _ = ht_access.update(arg.user.as_str(), arg.password.as_str()); 174 | ht_access.to_file()?; 175 | Ok(()) 176 | } 177 | 178 | fn delete(arg: &DelArg) -> Result<()> { 179 | let ht_access_path = PathBuf::from(&arg.config_path); 180 | check(&ht_access_path)?; 181 | let mut ht_access = Htpasswd::from_file(&ht_access_path)?; 182 | 183 | if ht_access.users().contains(&arg.user.to_string()) { 184 | println!("Deleting user with name {}.", arg.user.as_str()); 185 | let _ = ht_access.delete(arg.user.as_str()); 186 | ht_access.to_file()?; 187 | } else { 188 | println!( 189 | "Could not find a user with name {}. No changes were made.", 190 | arg.user.as_str() 191 | ); 192 | }; 193 | Ok(()) 194 | } 195 | 196 | fn print(arg: &PrintArg) -> Result<()> { 197 | let ht_access_path = PathBuf::from(&arg.config_path); 198 | check(&ht_access_path)?; 199 | let ht_access = Htpasswd::from_file(&ht_access_path)?; 200 | 201 | println!("Listing users in the access file for a rustic_server."); 202 | println!( 203 | "\tConfiguration file used: {} ", 204 | ht_access_path.to_string_lossy() 205 | ); 206 | println!("List:"); 207 | for u in ht_access.users() { 208 | println!("\t{}", u); 209 | } 210 | println!("Done."); 211 | Ok(()) 212 | } 213 | 214 | #[cfg(test)] 215 | mod tests { 216 | use super::*; 217 | use clap::CommandFactory; 218 | 219 | #[test] 220 | fn verify_auth() { 221 | AuthCmd::command().debug_assert(); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/commands/serve.rs: -------------------------------------------------------------------------------- 1 | //! `serve` subcommand 2 | 3 | use abscissa_core::{ 4 | config::Override, 5 | status_err, 6 | tracing::{debug, info}, 7 | Application, Command, FrameworkError, Runnable, Shutdown, 8 | }; 9 | use anyhow::Result; 10 | use clap::Parser; 11 | use conflate::Merge; 12 | 13 | use crate::{ 14 | config::RusticServerConfig, context::ServerRuntimeContext, error::AppResult, 15 | prelude::RUSTIC_SERVER_APP, storage::LocalStorage, web::start_web_server, 16 | }; 17 | 18 | /// `serve` subcommand 19 | /// 20 | /// The `Parser` proc macro generates an option parser based on the struct 21 | /// definition, and is defined in the `clap` crate. See their documentation 22 | /// for a more comprehensive example: 23 | /// 24 | /// 25 | #[derive(Command, Debug, Parser)] 26 | pub struct ServeCmd { 27 | /// Server settings 28 | #[clap(flatten)] 29 | context: RusticServerConfig, 30 | } 31 | 32 | impl Override for ServeCmd { 33 | fn override_config( 34 | &self, 35 | mut config: RusticServerConfig, 36 | ) -> Result { 37 | debug!(?config, "ServerConfig before merge."); 38 | debug!(?self.context, "Command context from CLI."); 39 | 40 | // Merge the command-line context into the config 41 | // This will override the config with the command-line values, 42 | // if they are present, because the command-line values have 43 | // precedence. 44 | config.merge(self.context.clone()); 45 | 46 | Ok(config) 47 | } 48 | } 49 | 50 | impl Runnable for ServeCmd { 51 | /// Start the application. 52 | fn run(&self) { 53 | if let Err(tokio_err) = abscissa_tokio::run(&RUSTIC_SERVER_APP, async { 54 | if let Err(err) = self.inner_run().await { 55 | status_err!("{}", err); 56 | RUSTIC_SERVER_APP.shutdown(Shutdown::Crash); 57 | } 58 | }) { 59 | status_err!("{}", tokio_err); 60 | RUSTIC_SERVER_APP.shutdown(Shutdown::Crash); 61 | }; 62 | } 63 | } 64 | 65 | impl ServeCmd { 66 | pub async fn inner_run(&self) -> AppResult<()> { 67 | let server_config = RUSTIC_SERVER_APP.config(); 68 | 69 | debug!(?server_config, "Loaded ServerConfig."); 70 | 71 | let runtime_ctx: ServerRuntimeContext = 72 | ServerRuntimeContext::from_config(server_config.clone())?; 73 | 74 | _ = tokio::spawn(async move { 75 | // If we're running in test mode, we want to shutdown after 76 | // 10 seconds automatically, if the environment variable 77 | // `CI=1` is set. 78 | if std::env::var("CI").is_ok() { 79 | tokio::time::sleep(tokio::time::Duration::from_secs(3)).await; 80 | info!("Shutting down gracefully ..."); 81 | RUSTIC_SERVER_APP.shutdown(Shutdown::Graceful); 82 | } 83 | 84 | tokio::signal::ctrl_c().await.unwrap(); 85 | info!("Shutting down gracefully ..."); 86 | RUSTIC_SERVER_APP.shutdown(Shutdown::Graceful); 87 | }); 88 | 89 | start_web_server(runtime_ctx).await?; 90 | 91 | Ok(()) 92 | } 93 | } 94 | 95 | #[cfg(test)] 96 | mod tests { 97 | use super::*; 98 | use clap::CommandFactory; 99 | 100 | #[test] 101 | fn verify_serve() { 102 | ServeCmd::command().debug_assert(); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/context.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::create_dir_all, 3 | net::SocketAddr, 4 | path::{Path, PathBuf}, 5 | sync::Arc, 6 | }; 7 | 8 | use abscissa_core::prelude::{debug, info}; 9 | use serde::{Deserialize, Serialize}; 10 | use tracing::warn; 11 | 12 | use crate::{ 13 | acl::Acl, 14 | auth::Auth, 15 | config::{ 16 | default_data_dir, default_socket_address, AclSettings, HtpasswdSettings, LogSettings, 17 | RusticServerConfig, TlsSettings, 18 | }, 19 | error::{AppResult, ErrorKind}, 20 | storage::Storage, 21 | }; 22 | 23 | #[derive(Clone, Serialize, Deserialize, Default, Debug)] 24 | #[serde(deny_unknown_fields, rename_all = "kebab-case")] 25 | pub struct TlsOptions { 26 | /// Optional path to the TLS key file 27 | pub tls_key: PathBuf, 28 | 29 | /// Optional path to the TLS certificate file 30 | pub tls_cert: PathBuf, 31 | } 32 | 33 | #[derive(Clone, Debug)] 34 | pub struct ServerRuntimeContext 35 | where 36 | S: Storage + Clone + std::fmt::Debug, 37 | { 38 | pub(crate) acl: Acl, 39 | pub(crate) auth: Auth, 40 | pub(crate) _quota: usize, 41 | pub(crate) socket_address: SocketAddr, 42 | pub(crate) storage: S, 43 | pub(crate) tls: Option, 44 | } 45 | 46 | impl ServerRuntimeContext 47 | where 48 | S: Storage + Clone + std::fmt::Debug, 49 | { 50 | pub fn from_config(config: Arc) -> AppResult { 51 | let storage_dir = Self::data_dir( 52 | config 53 | .storage 54 | .data_dir 55 | .clone() 56 | .unwrap_or_else(default_data_dir), 57 | )?; 58 | 59 | let socket_address = 60 | Self::socket_address(config.server.listen.unwrap_or_else(default_socket_address))?; 61 | 62 | let quota = Self::quota(config.storage.quota); 63 | 64 | let acl = Self::acl(config.acl.clone(), storage_dir.clone())?; 65 | 66 | let auth = Self::auth(config.auth.clone(), storage_dir.clone())?; 67 | 68 | let tls = Self::tls(config.tls.clone())?; 69 | 70 | let storage = Self::storage(storage_dir)?; 71 | 72 | Ok(Self { 73 | acl, 74 | auth, 75 | _quota: quota, 76 | socket_address, 77 | storage, 78 | tls, 79 | }) 80 | } 81 | 82 | fn quota(quota: Option) -> usize { 83 | quota.unwrap_or(0) 84 | } 85 | 86 | fn storage(data_dir: PathBuf) -> AppResult { 87 | let storage = S::init(&data_dir).map_err(|err| { 88 | ErrorKind::GeneralStorageError.context(format!("Could not create storage: {}", err)) 89 | })?; 90 | 91 | debug!(?storage, "Loaded Storage."); 92 | 93 | Ok(storage) 94 | } 95 | 96 | fn tls(tls_settings: TlsSettings) -> AppResult> { 97 | // TODO: Do we need to validate the TLS settings? 98 | let tls = if tls_settings.is_disabled() { 99 | info!("TLS is disabled."); 100 | None 101 | } else { 102 | let (Some(tls_key), Some(tls_cert)) = (tls_settings.tls_key, tls_settings.tls_cert) 103 | else { 104 | return Err(ErrorKind::GeneralStorageError 105 | .context("TLS is enabled but no key or certificate was provided.") 106 | .into()); 107 | }; 108 | info!("TLS is enabled."); 109 | 110 | Some(TlsOptions { tls_key, tls_cert }) 111 | }; 112 | 113 | debug!(?tls, "Loaded TLS settings."); 114 | 115 | Ok(tls) 116 | } 117 | 118 | #[allow(clippy::cognitive_complexity)] 119 | fn auth(htpasswd_settings: HtpasswdSettings, data_dir: PathBuf) -> AppResult { 120 | let auth = if htpasswd_settings.is_disabled() { 121 | info!("Authentication is disabled."); 122 | warn!("This allows anyone to push to your repositories. This should be considered insecure and is not recommended for production use."); 123 | Auth::default() 124 | } else { 125 | info!( 126 | "Authentication is enabled by default. If you want to disable it, add `--no-auth`." 127 | ); 128 | 129 | let valid_htpasswd_path = htpasswd_settings.htpasswd_file_or_default(data_dir)?; 130 | 131 | Auth::from_config(&htpasswd_settings, valid_htpasswd_path).map_err(|err| { 132 | ErrorKind::GeneralStorageError 133 | .context(format!("Could not create authentication due to `{err}`",)) 134 | })? 135 | }; 136 | 137 | debug!(?auth, "Loaded Auth."); 138 | 139 | Ok(auth) 140 | } 141 | 142 | fn data_dir(data_dir: impl Into) -> AppResult { 143 | let data_dir = data_dir.into(); 144 | 145 | if !data_dir.exists() { 146 | debug!("Creating data directory: `{:?}`", data_dir); 147 | 148 | create_dir_all(&data_dir).map_err(|err| { 149 | ErrorKind::GeneralStorageError 150 | .context(format!("Could not create data directory: `{}`", err)) 151 | })?; 152 | } 153 | 154 | info!( 155 | "Using directory for storing repositories: `{}`", 156 | data_dir.display() 157 | ); 158 | 159 | Ok(data_dir) 160 | } 161 | 162 | fn socket_address(address: SocketAddr) -> AppResult { 163 | debug!(?address, "Parsed socket address."); 164 | 165 | Ok(address) 166 | } 167 | 168 | fn acl(acl_settings: AclSettings, data_dir: PathBuf) -> AppResult { 169 | let acl = if acl_settings.is_disabled() { 170 | info!("ACL is disabled."); 171 | Acl::default().set_append_only(acl_settings.append_only) 172 | } else { 173 | info!("ACL is enabled."); 174 | 175 | let valid_acl_path = acl_settings.acl_file_or_default(data_dir)?; 176 | 177 | Acl::from_config(&acl_settings, Some(valid_acl_path)).map_err(|err| { 178 | ErrorKind::GeneralStorageError 179 | .context(format!("Could not create ACL due to `{err}`")) 180 | })? 181 | }; 182 | 183 | debug!(?acl, "Loaded Access Control List."); 184 | 185 | Ok(acl) 186 | } 187 | 188 | fn _log(log_settings: LogSettings) -> AppResult { 189 | let log = if log_settings.is_disabled() { 190 | info!("Logging is set to default."); 191 | LogSettings::default() 192 | } else { 193 | log_settings 194 | }; 195 | 196 | debug!(?log, "Loaded LogSettings."); 197 | 198 | Ok(log) 199 | } 200 | 201 | pub fn storage_path(&self) -> &Path { 202 | self.storage.path() 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | //! Error types 2 | 3 | use abscissa_core::error::{BoxError, Context}; 4 | use axum::http::StatusCode; 5 | use axum::response::{IntoResponse, Response}; 6 | use std::{ 7 | fmt::{self, Display}, 8 | io, 9 | ops::Deref, 10 | result::Result, 11 | }; 12 | 13 | pub type AppResult = Result; 14 | pub type ApiResult = Result; 15 | 16 | /// Kinds of errors 17 | #[derive(Clone, Debug, Eq, thiserror::Error, PartialEq, Copy)] 18 | pub enum ErrorKind { 19 | /// Error in configuration file 20 | #[error("config error")] 21 | Config, 22 | 23 | /// Input/output error 24 | #[error("I/O error")] 25 | Io, 26 | 27 | /// General storage error 28 | #[error("storage error")] 29 | GeneralStorageError, 30 | 31 | /// Missing user input 32 | #[error("missing user input")] 33 | MissingUserInput, 34 | } 35 | 36 | #[derive(Debug, thiserror::Error, displaydoc::Display)] 37 | pub enum ApiErrorKind { 38 | /// Internal server error: `{0}` 39 | InternalError(String), 40 | /// Bad request: `{0}` 41 | BadRequest(String), 42 | /// Filename `{0}` not allowed 43 | FilenameNotAllowed(String), 44 | /// Path `{0}` is ambiguous with internal types and not allowed 45 | AmbiguousPath(String), 46 | /// Path `{0}` not allowed 47 | PathNotAllowed(String), 48 | /// Path `{0}` is not valid 49 | InvalidPath(String), 50 | /// Path `{0}` is not valid unicode 51 | NonUnicodePath(String), 52 | /// Creating directory failed: `{0}` 53 | CreatingDirectoryFailed(String), 54 | /// Not yet implemented 55 | NotImplemented, 56 | /// File not found: `{0}` 57 | FileNotFound(String), 58 | /// Getting file metadata failed: `{0}` 59 | GettingFileMetadataFailed(String), 60 | /// Range not valid 61 | RangeNotValid, 62 | /// Seeking file failed 63 | SeekingFileFailed, 64 | /// Multipart range not implemented 65 | MultipartRangeNotImplemented, 66 | /// General range error 67 | GeneralRange, 68 | /// Conversion from length to u64 failed 69 | ConversionToU64Failed, 70 | /// Opening file failed: `{0}` 71 | OpeningFileFailed(String), 72 | /// Writing file failed: `{0}` 73 | WritingToFileFailed(String), 74 | /// Finalizing file failed: `{0}` 75 | FinalizingFileFailed(String), 76 | /// Getting file handle failed 77 | GettingFileHandleFailed, 78 | /// Removing file failed: `{0}` 79 | RemovingFileFailed(String), 80 | /// Reading from stream failed 81 | ReadingFromStreamFailed, 82 | /// Removing repository folder failed: `{0}` 83 | RemovingRepositoryFailed(String), 84 | /// Bad authentication header 85 | AuthenticationHeaderError, 86 | /// Failed to authenticate user: `{0}` 87 | UserAuthenticationError(String), 88 | /// General Storage error: `{0}` 89 | GeneralStorageError(String), 90 | /// Invalid API version: `{0}` 91 | InvalidApiVersion(String), 92 | } 93 | 94 | impl IntoResponse for ApiErrorKind { 95 | fn into_response(self) -> Response { 96 | let response = match self { 97 | Self::InvalidApiVersion(err) => ( 98 | StatusCode::BAD_REQUEST, 99 | format!("Invalid API version: {err}"), 100 | ), 101 | Self::InternalError(err) => ( 102 | StatusCode::INTERNAL_SERVER_ERROR, 103 | format!("Internal server error: {}", err), 104 | ), 105 | Self::BadRequest(err) => ( 106 | StatusCode::BAD_REQUEST, 107 | format!("Internal server error: {}", err), 108 | ), 109 | Self::FilenameNotAllowed(filename) => ( 110 | StatusCode::FORBIDDEN, 111 | format!("filename {filename} not allowed"), 112 | ), 113 | Self::AmbiguousPath(path) => ( 114 | StatusCode::FORBIDDEN, 115 | format!("path {path} is ambiguous with internal types and not allowed"), 116 | ), 117 | Self::PathNotAllowed(path) => { 118 | (StatusCode::FORBIDDEN, format!("path {path} not allowed")) 119 | } 120 | Self::NonUnicodePath(path) => ( 121 | StatusCode::BAD_REQUEST, 122 | format!("path {path} is not valid unicode"), 123 | ), 124 | Self::InvalidPath(path) => { 125 | (StatusCode::BAD_REQUEST, format!("path {path} is not valid")) 126 | } 127 | Self::CreatingDirectoryFailed(err) => ( 128 | StatusCode::INTERNAL_SERVER_ERROR, 129 | format!("error creating dir: {:?}", err), 130 | ), 131 | Self::NotImplemented => ( 132 | StatusCode::NOT_IMPLEMENTED, 133 | "not yet implemented".to_string(), 134 | ), 135 | Self::FileNotFound(path) => (StatusCode::NOT_FOUND, format!("file not found: {path}")), 136 | Self::GettingFileMetadataFailed(err) => ( 137 | StatusCode::INTERNAL_SERVER_ERROR, 138 | format!("error getting file metadata: {err}"), 139 | ), 140 | Self::RangeNotValid => (StatusCode::BAD_REQUEST, "range not valid".to_string()), 141 | Self::SeekingFileFailed => ( 142 | StatusCode::INTERNAL_SERVER_ERROR, 143 | "error seeking file".to_string(), 144 | ), 145 | Self::MultipartRangeNotImplemented => ( 146 | StatusCode::NOT_IMPLEMENTED, 147 | "multipart range not implemented".to_string(), 148 | ), 149 | Self::ConversionToU64Failed => ( 150 | StatusCode::INTERNAL_SERVER_ERROR, 151 | "error converting length to u64".to_string(), 152 | ), 153 | Self::OpeningFileFailed(err) => ( 154 | StatusCode::INTERNAL_SERVER_ERROR, 155 | format!("error opening file: {err}"), 156 | ), 157 | Self::WritingToFileFailed(err) => ( 158 | StatusCode::INTERNAL_SERVER_ERROR, 159 | format!("error writing file: {err}"), 160 | ), 161 | Self::FinalizingFileFailed(err) => ( 162 | StatusCode::INTERNAL_SERVER_ERROR, 163 | format!("error finalizing file: {err}"), 164 | ), 165 | Self::GettingFileHandleFailed => ( 166 | StatusCode::INTERNAL_SERVER_ERROR, 167 | "error getting file handle".to_string(), 168 | ), 169 | Self::RemovingFileFailed(err) => ( 170 | StatusCode::INTERNAL_SERVER_ERROR, 171 | format!("error removing file: {err}"), 172 | ), 173 | Self::GeneralRange => (StatusCode::INTERNAL_SERVER_ERROR, "range error".to_string()), 174 | Self::ReadingFromStreamFailed => ( 175 | StatusCode::INTERNAL_SERVER_ERROR, 176 | "error reading from stream".to_string(), 177 | ), 178 | Self::RemovingRepositoryFailed(err) => ( 179 | StatusCode::INTERNAL_SERVER_ERROR, 180 | format!("error removing repository folder: {:?}", err), 181 | ), 182 | Self::AuthenticationHeaderError => ( 183 | StatusCode::FORBIDDEN, 184 | "Bad authentication header".to_string(), 185 | ), 186 | Self::UserAuthenticationError(err) => ( 187 | StatusCode::FORBIDDEN, 188 | format!("Failed to authenticate user: {:?}", err), 189 | ), 190 | Self::GeneralStorageError(err) => ( 191 | StatusCode::INTERNAL_SERVER_ERROR, 192 | format!("Storage error: {:?}", err), 193 | ), 194 | }; 195 | 196 | response.into_response() 197 | } 198 | } 199 | 200 | impl ErrorKind { 201 | /// Create an error context from this error 202 | pub fn context(self, source: impl Into) -> Context { 203 | Context::new(self, Some(source.into())) 204 | } 205 | } 206 | 207 | /// Error type 208 | #[derive(Debug)] 209 | pub struct Error(Box>); 210 | 211 | impl Deref for Error { 212 | type Target = Context; 213 | 214 | fn deref(&self) -> &Context { 215 | &self.0 216 | } 217 | } 218 | 219 | impl Display for Error { 220 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 221 | self.0.fmt(f) 222 | } 223 | } 224 | 225 | impl std::error::Error for Error { 226 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 227 | self.0.source() 228 | } 229 | } 230 | 231 | impl From for Error { 232 | fn from(kind: ErrorKind) -> Self { 233 | Context::new(kind, None).into() 234 | } 235 | } 236 | 237 | impl From> for Error { 238 | fn from(context: Context) -> Self { 239 | Self(Box::new(context)) 240 | } 241 | } 242 | 243 | impl From for Error { 244 | fn from(err: io::Error) -> Self { 245 | ErrorKind::Io.context(err).into() 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/handlers.rs: -------------------------------------------------------------------------------- 1 | // web server response handler modules 2 | pub(crate) mod file_config; 3 | pub(crate) mod file_exchange; 4 | pub(crate) mod file_length; 5 | pub(crate) mod files_list; 6 | pub(crate) mod health; 7 | pub(crate) mod repository; 8 | 9 | // Support modules 10 | mod access_check; 11 | pub(crate) mod file_helpers; 12 | -------------------------------------------------------------------------------- /src/handlers/access_check.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use axum::{http::StatusCode, response::IntoResponse}; 4 | use tracing::debug; 5 | 6 | // used for using auto-generated TpeKind variant names 7 | use strum::VariantNames; 8 | 9 | use crate::{ 10 | acl::{AccessType, AclChecker, ACL}, 11 | error::{ApiErrorKind, ApiResult}, 12 | typed_path::TpeKind, 13 | }; 14 | 15 | pub fn check_auth_and_acl( 16 | user: String, 17 | tpe: impl Into>, 18 | path: &Path, 19 | access_type: AccessType, 20 | ) -> ApiResult { 21 | let tpe = tpe.into(); 22 | 23 | // don't allow paths that includes any of the defined types 24 | for part in path.iter() { 25 | //FIXME: Rewrite to?? -> if TYPES.contains(part) {} 26 | if let Some(part) = part.to_str() { 27 | for tpe_i in TpeKind::VARIANTS.iter() { 28 | if &part == tpe_i { 29 | debug!("PathNotAllowed: {:?}", part); 30 | return Err(ApiErrorKind::AmbiguousPath(path.display().to_string())); 31 | } 32 | } 33 | } 34 | } 35 | 36 | let acl = ACL.get().unwrap(); 37 | let path = if let Some(path) = path.to_str() { 38 | path 39 | } else { 40 | return Err(ApiErrorKind::NonUnicodePath(path.display().to_string())); 41 | }; 42 | let allowed = acl.is_allowed(&user, path, tpe, access_type); 43 | tracing::debug!(name: "auth", %user, %path, "type" = ?tpe, allowed); 44 | 45 | match allowed { 46 | true => Ok(StatusCode::OK), 47 | false => Err(ApiErrorKind::PathNotAllowed(path.to_string())), 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/handlers/file_helpers.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cell::RefCell, 3 | fs, 4 | io::Result as IoResult, 5 | path::PathBuf, 6 | pin::Pin, 7 | result::Result, 8 | task::{Context, Poll}, 9 | }; 10 | 11 | use serde::{Serialize, Serializer}; 12 | use tokio::{ 13 | fs::{File, OpenOptions}, 14 | io::AsyncWrite, 15 | }; 16 | 17 | use crate::error::{ApiErrorKind, ApiResult}; 18 | 19 | // helper struct which is like a async_std|tokio::fs::File but removes the file 20 | // if finalize() was not called. 21 | #[derive(Debug)] 22 | pub struct WriteOrDeleteFile { 23 | file: File, 24 | path: PathBuf, 25 | finalized: bool, 26 | } 27 | 28 | #[async_trait::async_trait] 29 | pub trait Finalizer { 30 | async fn finalize(&mut self) -> ApiResult<()>; 31 | } 32 | 33 | impl WriteOrDeleteFile { 34 | pub async fn new(path: PathBuf) -> ApiResult { 35 | tracing::debug!("[WriteOrDeleteFile] path: {path:?}"); 36 | 37 | if !path.exists() { 38 | let parent = path.parent().ok_or_else(|| { 39 | ApiErrorKind::WritingToFileFailed("Could not get parent directory".to_string()) 40 | })?; 41 | 42 | fs::create_dir_all(parent).map_err(|err| { 43 | ApiErrorKind::WritingToFileFailed(format!("Could not create directory: {}", err)) 44 | })?; 45 | } 46 | 47 | let file = OpenOptions::new() 48 | .write(true) 49 | .create_new(true) 50 | .open(&path) 51 | .await 52 | .map_err(|err| { 53 | ApiErrorKind::WritingToFileFailed(format!("Could not write to file: {}", err)) 54 | })?; 55 | 56 | Ok(Self { 57 | file, 58 | path, 59 | finalized: false, 60 | }) 61 | } 62 | } 63 | 64 | #[async_trait::async_trait] 65 | impl Finalizer for WriteOrDeleteFile { 66 | async fn finalize(&mut self) -> ApiResult<()> { 67 | self.file.sync_all().await.map_err(|err| { 68 | ApiErrorKind::FinalizingFileFailed(format!("Could not sync file: {}", err)) 69 | })?; 70 | self.finalized = true; 71 | Ok(()) 72 | } 73 | } 74 | 75 | impl AsyncWrite for WriteOrDeleteFile { 76 | fn poll_write(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { 77 | Pin::new(&mut self.get_mut().file).poll_write(cx, buf) 78 | } 79 | 80 | fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 81 | Pin::new(&mut self.get_mut().file).poll_flush(cx) 82 | } 83 | 84 | fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { 85 | Pin::new(&mut self.get_mut().file).poll_shutdown(cx) 86 | } 87 | } 88 | 89 | impl Drop for WriteOrDeleteFile { 90 | fn drop(&mut self) { 91 | if !self.finalized { 92 | // ignore errors 93 | fs::remove_file(&self.path).unwrap_or(()); 94 | } 95 | } 96 | } 97 | 98 | // helper struct to make iterators serializable 99 | pub struct IteratorAdapter(RefCell); 100 | 101 | impl IteratorAdapter { 102 | pub const fn new(iterator: I) -> Self { 103 | Self(RefCell::new(iterator)) 104 | } 105 | } 106 | 107 | impl Serialize for IteratorAdapter 108 | where 109 | I: Iterator, 110 | I::Item: Serialize, 111 | { 112 | fn serialize(&self, serializer: S) -> Result 113 | where 114 | S: Serializer, 115 | { 116 | serializer.collect_seq(self.0.borrow_mut().by_ref()) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/handlers/file_length.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use axum::{http::header, response::IntoResponse}; 4 | // use axum_extra::headers::HeaderMap; 5 | 6 | use crate::{ 7 | acl::AccessType, 8 | auth::BasicAuthFromRequest, 9 | error::{ApiErrorKind, ApiResult}, 10 | handlers::access_check::check_auth_and_acl, 11 | storage::STORAGE, 12 | typed_path::PathParts, 13 | }; 14 | 15 | /// Length 16 | /// Interface: HEAD {path}/{type}/{name} 17 | pub async fn file_length( 18 | path: P, 19 | auth: BasicAuthFromRequest, 20 | ) -> ApiResult { 21 | let (path, tpe, name) = path.parts(); 22 | 23 | tracing::debug!("[length] path: {path:?}, tpe: {tpe:?}, name: {name:?}"); 24 | 25 | let path_str = path.unwrap_or_default(); 26 | 27 | let path = Path::new(&path_str); 28 | 29 | let _ = check_auth_and_acl(auth.user, tpe, path, AccessType::Read)?; 30 | 31 | let tpe = if let Some(tpe) = tpe { 32 | tpe.into_str() 33 | } else { 34 | return Err(ApiErrorKind::InternalError("tpe is not valid".to_string())); 35 | }; 36 | 37 | let storage = STORAGE.get().unwrap(); 38 | 39 | let file = storage.filename(path, tpe, name.as_deref()); 40 | 41 | if file.exists() { 42 | let storage = STORAGE.get().unwrap(); 43 | 44 | let file = storage 45 | .open_file(path, tpe, name.as_deref()) 46 | .await 47 | .map_err(|err| { 48 | ApiErrorKind::OpeningFileFailed(format!("Could not open file: {err}")) 49 | })?; 50 | 51 | let length = file 52 | .metadata() 53 | .await 54 | .map_err(|err| { 55 | ApiErrorKind::GettingFileMetadataFailed(format!( 56 | "path: {path:?}, tpe: {tpe}, name: {name:?}, err: {err}" 57 | )) 58 | })? 59 | .len() 60 | .to_string(); 61 | 62 | Ok([(header::CONTENT_LENGTH, length)]) 63 | } else { 64 | Err(ApiErrorKind::FileNotFound(path_str)) 65 | } 66 | } 67 | 68 | #[cfg(test)] 69 | mod test { 70 | use axum::{ 71 | http::{header, Method, StatusCode}, 72 | middleware, Router, 73 | }; 74 | use axum_extra::routing::RouterExt; // for `Router::typed_*` 75 | use http_body_util::BodyExt; 76 | use tower::ServiceExt; // for `call`, `oneshot`, and `ready` 77 | 78 | use crate::{ 79 | handlers::file_length::file_length, 80 | log::print_request_response, 81 | testing::{init_test_environment, request_uri_for_test, server_config}, 82 | typed_path::RepositoryTpeNamePath, 83 | }; 84 | 85 | #[tokio::test] 86 | async fn test_get_file_length_passes() { 87 | init_test_environment(server_config()); 88 | 89 | // ---------------------------------- 90 | // File exists 91 | // ---------------------------------- 92 | let app = Router::new() 93 | .typed_head(file_length::) 94 | .layer(middleware::from_fn(print_request_response)); 95 | 96 | let uri = 97 | "/test_repo/keys/3f918b737a2b9f72f044d06d6009eb34e0e8d06668209be3ce86e5c18dac0295"; 98 | 99 | let request = request_uri_for_test(uri, Method::HEAD); 100 | 101 | let resp = app.oneshot(request).await.unwrap(); 102 | 103 | assert_eq!(resp.status(), StatusCode::OK); 104 | 105 | let length = resp 106 | .headers() 107 | .get(header::CONTENT_LENGTH) 108 | .unwrap() 109 | .to_str() 110 | .unwrap(); 111 | 112 | assert_eq!(length, "460"); 113 | 114 | let b = resp 115 | .into_body() 116 | .collect() 117 | .await 118 | .unwrap() 119 | .to_bytes() 120 | .to_vec(); 121 | 122 | assert!(b.is_empty()); 123 | 124 | // ---------------------------------- 125 | // File does NOT exist 126 | // ---------------------------------- 127 | let app = Router::new() 128 | .typed_head(file_length::) 129 | .layer(middleware::from_fn(print_request_response)); 130 | 131 | let uri = "/test_repo/keys/__I_do_not_exist__"; 132 | 133 | let request = request_uri_for_test(uri, Method::HEAD); 134 | 135 | let resp = app.oneshot(request).await.unwrap(); 136 | 137 | assert_eq!(resp.status(), StatusCode::NOT_FOUND); 138 | 139 | let b = resp 140 | .into_body() 141 | .collect() 142 | .await 143 | .unwrap() 144 | .to_bytes() 145 | .to_vec(); 146 | 147 | assert!(b.is_empty()); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/handlers/files_list.rs: -------------------------------------------------------------------------------- 1 | use std::{path::Path, str::FromStr}; 2 | 3 | use axum::{ 4 | http::{ 5 | header::{self, AUTHORIZATION}, 6 | StatusCode, 7 | }, 8 | response::IntoResponse, 9 | Json, 10 | }; 11 | use axum_extra::headers::HeaderMap; 12 | use serde_derive::{Deserialize, Serialize}; 13 | 14 | use crate::{ 15 | acl::AccessType, 16 | auth::BasicAuthFromRequest, 17 | error::{ApiErrorKind, ApiResult}, 18 | handlers::{access_check::check_auth_and_acl, file_helpers::IteratorAdapter}, 19 | storage::STORAGE, 20 | typed_path::PathParts, 21 | }; 22 | 23 | #[derive(Debug, Clone, Copy)] 24 | enum ApiVersionKind { 25 | V1, 26 | V2, 27 | } 28 | 29 | impl ApiVersionKind { 30 | pub const fn to_static_str(self) -> &'static str { 31 | match self { 32 | Self::V1 => "application/vnd.x.restic.rest.v1", 33 | Self::V2 => "application/vnd.x.restic.rest.v2", 34 | } 35 | } 36 | } 37 | 38 | impl std::fmt::Display for ApiVersionKind { 39 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 40 | match self { 41 | Self::V1 => write!(f, "application/vnd.x.restic.rest.v1"), 42 | Self::V2 => write!(f, "application/vnd.x.restic.rest.v2"), 43 | } 44 | } 45 | } 46 | 47 | impl FromStr for ApiVersionKind { 48 | type Err = ApiErrorKind; 49 | 50 | fn from_str(s: &str) -> Result { 51 | match s { 52 | "application/vnd.x.restic.rest.v1" => Ok(Self::V1), 53 | "application/vnd.x.restic.rest.v2" => Ok(Self::V2), 54 | _ => Err(ApiErrorKind::InvalidApiVersion(s.to_string())), 55 | } 56 | } 57 | } 58 | 59 | /// List files 60 | /// Interface: GET {path}/{type}/ 61 | #[derive(Serialize, Deserialize)] 62 | struct RepoPathEntry { 63 | name: String, 64 | size: u64, 65 | } 66 | 67 | pub async fn list_files( 68 | path: P, 69 | auth: BasicAuthFromRequest, 70 | headers: HeaderMap, 71 | ) -> ApiResult { 72 | let (path, tpe, _) = path.parts(); 73 | 74 | tracing::debug!(?path, "type" = ?tpe, "[list_files]"); 75 | 76 | let path = path.unwrap_or_default(); 77 | 78 | let path = Path::new(&path); 79 | 80 | let _ = check_auth_and_acl(auth.user, tpe, path, AccessType::Read)?; 81 | 82 | let storage = STORAGE.get().unwrap(); 83 | 84 | let read_dir = storage.read_dir(path, tpe.map(|f| f.into())); 85 | 86 | let mut res = match headers 87 | .get(header::ACCEPT) 88 | .and_then(|header| header.to_str().ok()) 89 | { 90 | Some(version) if version == ApiVersionKind::V2.to_static_str() => { 91 | let read_dir_version = read_dir.map(|entry| { 92 | RepoPathEntry { 93 | name: entry.file_name().to_str().unwrap().to_string(), 94 | size: entry.metadata().unwrap().len(), 95 | // FIXME: return Err(WebErrorKind::GettingFileMetadataFailed.into()); 96 | } 97 | }); 98 | 99 | let mut response = Json(&IteratorAdapter::new(read_dir_version)).into_response(); 100 | 101 | tracing::debug!("[list_files::dir_content] Api V2 | {:?}", response.body()); 102 | 103 | let _ = response.headers_mut().insert( 104 | header::CONTENT_TYPE, 105 | header::HeaderValue::from_static(ApiVersionKind::V2.to_static_str()), 106 | ); 107 | 108 | let status = response.status_mut(); 109 | 110 | *status = StatusCode::OK; 111 | 112 | response 113 | } 114 | _ => { 115 | let read_dir_version = read_dir.map(|e| e.file_name().to_str().unwrap().to_string()); 116 | 117 | let mut response = Json(&IteratorAdapter::new(read_dir_version)).into_response(); 118 | 119 | tracing::debug!( 120 | "[list_files::dir_content] Fallback to V1 | {:?}", 121 | response.body() 122 | ); 123 | 124 | let _ = response.headers_mut().insert( 125 | header::CONTENT_TYPE, 126 | header::HeaderValue::from_static(ApiVersionKind::V1.to_static_str()), 127 | ); 128 | 129 | let status = response.status_mut(); 130 | 131 | *status = StatusCode::OK; 132 | 133 | response 134 | } 135 | }; 136 | 137 | let _ = res 138 | .headers_mut() 139 | .insert(AUTHORIZATION, headers.get(AUTHORIZATION).unwrap().clone()); 140 | 141 | Ok(res) 142 | } 143 | 144 | #[cfg(test)] 145 | mod test { 146 | use axum::{ 147 | body::Body, 148 | http::{ 149 | header::{ACCEPT, CONTENT_TYPE}, 150 | Request, StatusCode, 151 | }, 152 | middleware, Router, 153 | }; 154 | use axum_extra::routing::RouterExt; // for `Router::typed_*` 155 | use http_body_util::BodyExt; 156 | use tower::ServiceExt; // for `call`, `oneshot`, and `ready` 157 | 158 | use crate::{ 159 | handlers::files_list::{list_files, ApiVersionKind, RepoPathEntry}, 160 | log::print_request_response, 161 | testing::{basic_auth_header_value, init_test_environment, server_config}, 162 | typed_path::RepositoryTpePath, 163 | }; 164 | 165 | #[tokio::test] 166 | async fn test_get_list_files_passes() { 167 | init_test_environment(server_config()); 168 | 169 | // V1 170 | let app = Router::new() 171 | .typed_get(list_files::) 172 | .layer(middleware::from_fn(print_request_response)); 173 | 174 | let request = Request::builder() 175 | .uri("/test_repo/keys/") 176 | .header(ACCEPT, ApiVersionKind::V1.to_static_str()) 177 | .header( 178 | "Authorization", 179 | basic_auth_header_value("rustic", Some("rustic")), 180 | ) 181 | .body(Body::empty()) 182 | .unwrap(); 183 | 184 | let resp = app.oneshot(request).await.unwrap(); 185 | 186 | assert_eq!(resp.status(), StatusCode::OK); 187 | 188 | assert_eq!( 189 | resp.headers().get(CONTENT_TYPE).unwrap().to_str().unwrap(), 190 | ApiVersionKind::V1.to_static_str() 191 | ); 192 | 193 | let b = resp 194 | .into_body() 195 | .collect() 196 | .await 197 | .unwrap() 198 | .to_bytes() 199 | .to_vec(); 200 | 201 | assert!(!b.is_empty()); 202 | 203 | let body = std::str::from_utf8(&b).unwrap(); 204 | 205 | let r: Vec = serde_json::from_str(body).unwrap(); 206 | 207 | let mut found = false; 208 | 209 | for rpe in r { 210 | if rpe == "3f918b737a2b9f72f044d06d6009eb34e0e8d06668209be3ce86e5c18dac0295" { 211 | found = true; 212 | break; 213 | } 214 | } 215 | assert!(found); 216 | 217 | // V2 218 | let app = Router::new() 219 | .typed_get(list_files::) 220 | .layer(middleware::from_fn(print_request_response)); 221 | 222 | let request = Request::builder() 223 | .uri("/test_repo/keys/") 224 | .header(ACCEPT, ApiVersionKind::V2.to_static_str()) 225 | .header( 226 | "Authorization", 227 | basic_auth_header_value("rustic", Some("rustic")), 228 | ) 229 | .body(Body::empty()) 230 | .unwrap(); 231 | 232 | let resp = app.oneshot(request).await.unwrap(); 233 | 234 | assert_eq!(resp.status(), StatusCode::OK); 235 | 236 | assert_eq!( 237 | resp.headers().get(CONTENT_TYPE).unwrap().to_str().unwrap(), 238 | ApiVersionKind::V2.to_static_str() 239 | ); 240 | let b = resp 241 | .into_body() 242 | .collect() 243 | .await 244 | .unwrap() 245 | .to_bytes() 246 | .to_vec(); 247 | 248 | let body = std::str::from_utf8(&b).unwrap(); 249 | 250 | let r: Vec = serde_json::from_str(body).unwrap(); 251 | 252 | assert!(!r.is_empty()); 253 | 254 | let mut found = false; 255 | 256 | for rpe in r { 257 | if rpe.name == "3f918b737a2b9f72f044d06d6009eb34e0e8d06668209be3ce86e5c18dac0295" { 258 | assert_eq!(rpe.size, 460); 259 | found = true; 260 | break; 261 | } 262 | } 263 | assert!(found); 264 | 265 | // We may have more files, this does not work... 266 | // let rr = r.first().unwrap(); 267 | // assert_eq!( rr.name, "3f918b737a2b9f72f044d06d6009eb34e0e8d06668209be3ce86e5c18dac0295"); 268 | // assert_eq!(rr.size, 363); 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /src/handlers/health.rs: -------------------------------------------------------------------------------- 1 | use std::{sync::OnceLock, time::Instant}; 2 | 3 | use axum::{http::StatusCode, response::IntoResponse}; 4 | use axum_extra::json; 5 | 6 | use crate::auth::BasicAuthFromRequest; 7 | 8 | // Global that stores the current when the server started 9 | // This is used to check if the server is running 10 | pub static START_TIME: OnceLock = OnceLock::new(); 11 | 12 | pub fn init_start_time() { 13 | let _ = START_TIME.get_or_init(Instant::now); 14 | } 15 | 16 | pub async fn live_check() -> impl IntoResponse { 17 | let start = START_TIME.get().expect("start time not initialized"); 18 | let uptime = Instant::now().duration_since(*start); 19 | 20 | ( 21 | StatusCode::OK, 22 | json!({ 23 | "status": "ok", 24 | "version": env!("CARGO_PKG_VERSION"), 25 | "uptime": uptime.as_secs(), 26 | "timestamp": chrono::Local::now().timestamp(), 27 | }), 28 | ) 29 | .into_response() 30 | } 31 | 32 | // /health/ready 33 | // 34 | // Example response as an idea of what to return: 35 | // 36 | // ```json 37 | // { 38 | // "status": "ready", 39 | // "version": "1.2.3", 40 | // "uptime": 123456, 41 | // "error_count": 0, 42 | // "timestamp": "2024-11-16T12:34:56Z", 43 | // "last_backup_status": "success", 44 | // "last_backup_timestamp": "2024-11-15T23:59:59Z", 45 | // "backup_queue_length": 0, 46 | // "backup_size_last": "10GB", 47 | // "dependencies": { 48 | // "storage_status": "ok", 49 | // "available_disk_space": "120GB" 50 | // }, 51 | // "performance": { 52 | // "cpu_usage": "15%", 53 | // "memory_usage": "300MB", 54 | // "active_tasks": 2, 55 | // } 56 | // } 57 | // ``` 58 | // TODO: Implement ready_check 59 | #[allow(dead_code)] 60 | pub async fn ready_check(_auth: BasicAuthFromRequest) -> impl IntoResponse { 61 | StatusCode::NOT_IMPLEMENTED 62 | } 63 | -------------------------------------------------------------------------------- /src/handlers/repository.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use axum::{extract::Query, http::StatusCode, response::IntoResponse}; 4 | use serde_derive::Deserialize; 5 | 6 | use crate::{ 7 | acl::AccessType, auth::BasicAuthFromRequest, error::ApiResult, 8 | handlers::access_check::check_auth_and_acl, storage::STORAGE, typed_path::TpeKind, 9 | }; 10 | 11 | // used for using auto-generated TpeKind variant names 12 | use crate::typed_path::PathParts; 13 | use strum::VariantNames; 14 | 15 | /// `Create_repository` 16 | /// Interface: POST {path}?create=true 17 | #[derive(Default, Deserialize)] 18 | #[serde(default)] 19 | pub struct Create { 20 | create: bool, 21 | } 22 | 23 | pub async fn create_repository( 24 | path: P, 25 | auth: BasicAuthFromRequest, 26 | Query(params): Query, 27 | ) -> ApiResult { 28 | tracing::debug!( 29 | "[create_repository] repository path: {}", 30 | path.repo().unwrap() 31 | ); 32 | let path = PathBuf::new().join(path.repo().unwrap()); 33 | let _ = check_auth_and_acl(auth.user, None, &path, AccessType::Append)?; 34 | 35 | let storage = STORAGE.get().unwrap(); 36 | match params.create { 37 | true => { 38 | for tpe in TpeKind::VARIANTS.iter() { 39 | // config is not a directory, but a file 40 | // it is handled separately 41 | if tpe == &TpeKind::Config.into_str() { 42 | continue; 43 | } 44 | 45 | storage.create_dir(&path, Some(tpe)).await?; 46 | } 47 | 48 | Ok(( 49 | StatusCode::OK, 50 | format!("Called create_files with path {:?}", &path), 51 | )) 52 | } 53 | false => Ok(( 54 | StatusCode::OK, 55 | format!("Called create_files with path {:?}, create=false", &path), 56 | )), 57 | } 58 | } 59 | 60 | /// `Delete_repository` 61 | /// Interface: Delete {path} 62 | // FIXME: The input path should at least NOT point to a file in any repository 63 | pub async fn delete_repository( 64 | path: P, 65 | auth: BasicAuthFromRequest, 66 | ) -> ApiResult { 67 | tracing::debug!( 68 | "[delete_repository] repository path: {}", 69 | &path.repo().unwrap() 70 | ); 71 | let path = PathBuf::new().join(path.repo().unwrap()); 72 | let _ = check_auth_and_acl(auth.user, None, &path, AccessType::Modify)?; 73 | 74 | let storage = STORAGE.get().unwrap(); 75 | storage.remove_repository(&path).await?; 76 | 77 | Ok(()) 78 | } 79 | 80 | #[cfg(test)] 81 | mod test { 82 | use crate::log::print_request_response; 83 | use crate::testing::{basic_auth_header_value, init_test_environment, request_uri_for_test}; 84 | use crate::typed_path::RepositoryPath; 85 | use crate::{ 86 | handlers::repository::{create_repository, delete_repository}, 87 | testing::server_config, 88 | }; 89 | use axum::http::Method; 90 | use axum::{ 91 | body::Body, 92 | http::{Request, StatusCode}, 93 | }; 94 | use axum::{middleware, Router}; 95 | use axum_extra::routing::RouterExt; 96 | use pretty_assertions::assert_eq; 97 | use std::path::PathBuf; 98 | use tokio::fs; 99 | use tower::ServiceExt; 100 | 101 | /// The acl.toml test allows the create of "repo_remove_me" 102 | /// for user test with the correct password 103 | #[tokio::test] 104 | async fn test_repo_create_delete_passes() { 105 | init_test_environment(server_config()); 106 | 107 | //Start with a clean slate ... 108 | let path = PathBuf::new() 109 | .join("tests") 110 | .join("generated") 111 | .join("test_storage") 112 | .join("repo_remove_me"); 113 | 114 | if path.exists() { 115 | fs::remove_dir_all(&path).await.unwrap(); 116 | assert!(!path.exists()); 117 | } 118 | 119 | let not_allowed_path = PathBuf::new() 120 | .join("tests") 121 | .join("generated") 122 | .join("test_storage") 123 | .join("repo_not_allowed"); 124 | 125 | if not_allowed_path.exists() { 126 | fs::remove_dir_all(¬_allowed_path).await.unwrap(); 127 | assert!(!not_allowed_path.exists()); 128 | } 129 | 130 | // ------------------------------------ 131 | // Create a new repository: {path}?create=true 132 | // ------------------------------------ 133 | let repo_name_uri = "/repo_remove_me/?create=true".to_string(); 134 | let app = Router::new() 135 | .typed_post(create_repository::) 136 | .layer(middleware::from_fn(print_request_response)); 137 | 138 | let request = request_uri_for_test(&repo_name_uri, Method::POST); 139 | let resp = app.oneshot(request).await.unwrap(); 140 | 141 | assert_eq!(resp.status(), StatusCode::OK); 142 | assert!(path.exists()); 143 | 144 | // ------------------------------------------ 145 | // Create a new repository WITHOUT ACL access 146 | // ------------------------------------------ 147 | let repo_name_uri = "/repo_not_allowed/?create=true".to_string(); 148 | let app = Router::new() 149 | .typed_post(create_repository::) 150 | .layer(middleware::from_fn(print_request_response)); 151 | 152 | let request = request_uri_for_test(&repo_name_uri, Method::POST); 153 | let resp = app.oneshot(request).await.unwrap(); 154 | 155 | assert_eq!(resp.status(), StatusCode::FORBIDDEN); 156 | assert!(!not_allowed_path.exists()); 157 | 158 | // ------------------------------------------ 159 | // Delete a repository WITHOUT ACL access 160 | // ------------------------------------------ 161 | let repo_name_uri = "/repo_remove_me/?create=true".to_string(); 162 | let app = Router::new() 163 | .typed_delete(delete_repository::) 164 | .layer(middleware::from_fn(print_request_response)); 165 | 166 | let request = Request::builder() 167 | .uri(&repo_name_uri) 168 | .method(Method::DELETE) 169 | .header( 170 | "Authorization", 171 | basic_auth_header_value("rustic", Some("__wrong_password__")), 172 | ) 173 | .body(Body::empty()) 174 | .unwrap(); 175 | 176 | let resp = app.oneshot(request).await.unwrap(); 177 | 178 | assert_eq!(resp.status(), StatusCode::FORBIDDEN); 179 | assert!(path.exists()); 180 | 181 | // ------------------------------------------ 182 | // Delete a repository WITH access... 183 | // ------------------------------------------ 184 | assert!(path.exists()); // pre condition: repo exists 185 | let repo_name_uri = "/repo_remove_me/".to_string(); 186 | let app = Router::new() 187 | .typed_delete(delete_repository::) 188 | .layer(middleware::from_fn(print_request_response)); 189 | 190 | let request = request_uri_for_test(&repo_name_uri, Method::DELETE); 191 | let resp = app.oneshot(request).await.unwrap(); 192 | 193 | assert_eq!(resp.status(), StatusCode::OK); 194 | assert!(!path.exists()); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/htpasswd.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{btree_map::Entry, BTreeMap}, 3 | fmt::{Display, Formatter}, 4 | fs::{self, read_to_string}, 5 | io::Write, 6 | path::PathBuf, 7 | }; 8 | 9 | use htpasswd_verify::md5::{format_hash, md5_apr1_encode}; 10 | use rand::{distributions::Alphanumeric, thread_rng, Rng}; 11 | use serde::Serialize; 12 | 13 | use crate::error::{ApiErrorKind, ApiResult, AppResult, ErrorKind}; 14 | 15 | pub mod constants { 16 | pub(super) const SALT_LEN: usize = 8; 17 | } 18 | 19 | #[derive(Clone, Debug, Default, Serialize)] 20 | pub struct CredentialMap(BTreeMap); 21 | 22 | impl CredentialMap { 23 | pub fn new() -> Self { 24 | Self::default() 25 | } 26 | } 27 | 28 | impl std::ops::DerefMut for CredentialMap { 29 | fn deref_mut(&mut self) -> &mut Self::Target { 30 | &mut self.0 31 | } 32 | } 33 | 34 | impl std::ops::Deref for CredentialMap { 35 | type Target = BTreeMap; 36 | 37 | fn deref(&self) -> &Self::Target { 38 | &self.0 39 | } 40 | } 41 | 42 | #[derive(Clone, Debug, Default, Serialize)] 43 | pub struct Htpasswd { 44 | pub path: PathBuf, 45 | pub credentials: CredentialMap, 46 | } 47 | 48 | impl Htpasswd { 49 | pub fn new() -> Self { 50 | Self::default() 51 | } 52 | 53 | pub fn from_file(pth: &PathBuf) -> AppResult { 54 | let mut c = CredentialMap::new(); 55 | 56 | if pth.exists() { 57 | read_to_string(pth) 58 | .map_err(|err| { 59 | ErrorKind::Io.context(format!( 60 | "Could not read htpasswd file: {} at {:?}", 61 | err, pth 62 | )) 63 | })? 64 | .lines() // split the string into an iterator of string slices 65 | .map(str::trim) 66 | .map(String::from) // make each slice into a string 67 | .filter_map(|s| Credential::from_line(s).ok()) 68 | .for_each(|cred| { 69 | let _ = c.insert(cred.name.clone(), cred); 70 | }); 71 | } 72 | 73 | Ok(Self { 74 | path: pth.clone(), 75 | credentials: c, 76 | }) 77 | } 78 | 79 | pub fn users(&self) -> Vec { 80 | self.credentials.keys().cloned().collect() 81 | } 82 | 83 | pub fn create(&mut self, name: &str, pass: &str) -> AppResult<()> { 84 | let cred = Credential::new(name, pass); 85 | 86 | self.insert(cred)?; 87 | 88 | Ok(()) 89 | } 90 | 91 | pub fn read(&self, name: &str) -> Option<&Credential> { 92 | self.credentials.get(name) 93 | } 94 | 95 | pub fn update(&mut self, name: &str, pass: &str) -> AppResult<()> { 96 | let cred = Credential::new(name, pass); 97 | 98 | let _ = self 99 | .credentials 100 | .entry(name.to_owned()) 101 | .and_modify(|entry| *entry = cred.clone()) 102 | .or_insert(cred); 103 | 104 | Ok(()) 105 | } 106 | 107 | /// Removes one credential by username 108 | pub fn delete(&mut self, name: &str) -> Option { 109 | self.credentials.remove(name) 110 | } 111 | 112 | pub fn insert(&mut self, cred: Credential) -> AppResult<()> { 113 | let Entry::Vacant(entry) = self.credentials.entry(cred.name.clone()) else { 114 | return Err(ErrorKind::Io 115 | .context(format!( 116 | "Entry already exists, could not insert credential: `{}`. Please use update instead.", 117 | cred.name.as_str() 118 | )) 119 | .into()); 120 | }; 121 | 122 | let _ = entry.insert(cred); 123 | 124 | Ok(()) 125 | } 126 | 127 | pub fn to_file(&self) -> ApiResult<()> { 128 | let mut file = fs::OpenOptions::new() 129 | .create(true) 130 | .truncate(false) 131 | .write(true) 132 | .open(&self.path) 133 | .map_err(|err| { 134 | ApiErrorKind::OpeningFileFailed(format!( 135 | "Could not open htpasswd file: {} at {:?}", 136 | err, self.path 137 | )) 138 | })?; 139 | 140 | for (_n, c) in self.credentials.iter() { 141 | let _e = file.write(c.to_string().as_bytes()).map_err(|err| { 142 | ApiErrorKind::WritingToFileFailed(format!( 143 | "Could not write to htpasswd file: {} at {:?}", 144 | err, self.path 145 | )) 146 | }); 147 | } 148 | Ok(()) 149 | } 150 | } 151 | 152 | #[derive(Clone, Debug, Serialize)] 153 | pub struct Credential { 154 | name: String, 155 | hash: String, 156 | } 157 | 158 | impl Credential { 159 | pub fn new(name: &str, pass: &str) -> Self { 160 | let salt: String = thread_rng() 161 | .sample_iter(&Alphanumeric) 162 | .take(constants::SALT_LEN) 163 | .map(char::from) 164 | .collect(); 165 | let hash = md5_apr1_encode(pass, salt.as_str()); 166 | let hash = format_hash(hash.as_str(), salt.as_str()); 167 | 168 | Self { 169 | name: name.into(), 170 | hash, 171 | } 172 | } 173 | 174 | /// Returns a credential struct from a htpasswd file line 175 | pub fn from_line(line: String) -> AppResult { 176 | let split: Vec<&str> = line.split(':').collect(); 177 | 178 | if split.len() != 2 { 179 | return Err(ErrorKind::Io 180 | .context(format!( 181 | "Could not parse htpasswd file line: `{}`. Expected format: `name:hash`", 182 | line 183 | )) 184 | .into()); 185 | } 186 | 187 | Ok(Self { 188 | name: split[0].to_string(), 189 | hash: split[1].to_string(), 190 | }) 191 | } 192 | } 193 | 194 | impl Display for Credential { 195 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 196 | writeln!(f, "{}:{}", self.name, self.hash) 197 | } 198 | } 199 | 200 | #[cfg(test)] 201 | mod test { 202 | use crate::auth::Auth; 203 | use crate::htpasswd::Htpasswd; 204 | use anyhow::Result; 205 | use insta::assert_toml_snapshot; 206 | 207 | #[test] 208 | fn test_htpasswd_passes() -> Result<()> { 209 | let mut htpasswd = Htpasswd::new(); 210 | 211 | let _ = htpasswd.update("Administrator", "stuff"); 212 | let _ = htpasswd.update("backup-user", "its_me"); 213 | 214 | assert_toml_snapshot!(htpasswd, { 215 | ".credentials.*.hash" => "[hash]", 216 | }); 217 | 218 | let auth = Auth::from(htpasswd); 219 | assert!(auth.verify("Administrator", "stuff")); 220 | assert!(auth.verify("backup-user", "its_me")); 221 | 222 | Ok(()) 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! `RusticServer` 2 | //! 3 | //! Application based on the [Abscissa] framework. 4 | //! 5 | //! [Abscissa]: https://github.com/iqlusioninc/abscissa 6 | 7 | #![allow(non_local_definitions)] 8 | 9 | pub mod acl; 10 | pub mod application; 11 | pub mod auth; 12 | pub mod commands; 13 | pub mod config; 14 | pub mod context; 15 | pub mod error; 16 | pub mod handlers; 17 | pub mod htpasswd; 18 | pub mod log; 19 | pub mod prelude; 20 | pub mod storage; 21 | pub mod typed_path; 22 | /// Web module 23 | /// 24 | /// implements a REST server as specified by 25 | /// 26 | pub mod web; 27 | 28 | #[cfg(test)] 29 | pub mod testing; 30 | -------------------------------------------------------------------------------- /src/log.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | body::{Body, Bytes}, 3 | extract::Request, 4 | middleware::Next, 5 | response::{IntoResponse, Response}, 6 | }; 7 | use http_body_util::BodyExt; 8 | 9 | use crate::error::ApiErrorKind; 10 | 11 | // Add the `#[debug_middleware]` attribute to the function to make debugging easier. 12 | // use axum_macros::debug_middleware; 13 | // 14 | // #[debug_middleware] 15 | /// Router middleware function to print additional information on the request and response. 16 | pub async fn print_request_response( 17 | req: Request, 18 | next: Next, 19 | ) -> Result { 20 | let (parts, body) = req.into_parts(); 21 | let uuid = uuid::Uuid::new_v4(); 22 | 23 | tracing::debug!( 24 | id = %uuid, 25 | method = %parts.method, 26 | uri = %parts.uri, 27 | "[REQUEST]", 28 | ); 29 | 30 | tracing::debug!(id = %uuid, headers = ?parts.headers, "[HEADERS]"); 31 | 32 | let bytes = buffer_and_print(&uuid, body).await?; 33 | 34 | let req = Request::from_parts(parts, Body::from(bytes)); 35 | 36 | let res = next.run(req).await; 37 | let (parts, body) = res.into_parts(); 38 | 39 | tracing::debug!( 40 | id = %uuid, 41 | headers = ?parts.headers, 42 | status = %parts.status, 43 | "[RESPONSE]", 44 | ); 45 | 46 | let bytes = buffer_and_print(&uuid, body).await?; 47 | let res = Response::from_parts(parts, Body::from(bytes)); 48 | 49 | Ok(res) 50 | } 51 | 52 | async fn buffer_and_print(uuid: &uuid::Uuid, body: B) -> Result 53 | where 54 | B: axum::body::HttpBody + Send, 55 | B::Error: std::fmt::Display, 56 | { 57 | let bytes = match body.collect().await { 58 | Ok(collected) => collected.to_bytes(), 59 | Err(err) => { 60 | return Err(ApiErrorKind::BadRequest(format!( 61 | "failed to read body: {err}" 62 | ))); 63 | } 64 | }; 65 | 66 | if let Ok(body) = std::str::from_utf8(&bytes) { 67 | tracing::debug!(id = %uuid, body = %body, "[BODY]"); 68 | } 69 | 70 | Ok(bytes) 71 | } 72 | -------------------------------------------------------------------------------- /src/prelude.rs: -------------------------------------------------------------------------------- 1 | //! Application-local prelude: conveniently import types/functions/macros 2 | //! which are generally useful and should be available in every module with 3 | //! `use crate::prelude::*;` 4 | 5 | /// Abscissa core prelude 6 | pub use abscissa_core::prelude::*; 7 | 8 | /// Application state 9 | pub use crate::application::RUSTIC_SERVER_APP; 10 | -------------------------------------------------------------------------------- /src/snapshots/rustic_server__acl__tests__acl_default_impl.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/acl.rs 3 | expression: acl 4 | --- 5 | Acl { 6 | private_repo: true, 7 | append_only: true, 8 | repos: {}, 9 | } 10 | -------------------------------------------------------------------------------- /src/snapshots/rustic_server__acl__tests__repo_acl_passes.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/acl.rs 3 | expression: acl 4 | --- 5 | Acl { 6 | private_repo: true, 7 | append_only: true, 8 | repos: { 9 | "all": RepoAcl( 10 | { 11 | "bob": Modify, 12 | "paul": Read, 13 | "sam": Append, 14 | }, 15 | ), 16 | "bob": RepoAcl( 17 | { 18 | "bob": Modify, 19 | }, 20 | ), 21 | "sam": RepoAcl( 22 | { 23 | "bob": Read, 24 | "sam": Append, 25 | }, 26 | ), 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /src/snapshots/rustic_server__config__test__config_parsing_from_file_passes.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config.rs 3 | expression: config 4 | --- 5 | [server] 6 | listen = '127.0.0.1:8080' 7 | 8 | [storage] 9 | data-dir = 'tests/generated/test_storage/' 10 | 11 | [auth] 12 | disable-auth = false 13 | htpasswd-file = 'tests/fixtures/test_data/.htpasswd' 14 | 15 | [acl] 16 | disable-acl = false 17 | append-only = false 18 | acl-path = 'tests/fixtures/test_data/acl.toml' 19 | 20 | [tls] 21 | disable-tls = true 22 | 23 | [log] 24 | -------------------------------------------------------------------------------- /src/snapshots/rustic_server__config__test__default_config_passes.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config.rs 3 | expression: config 4 | --- 5 | [server] 6 | listen = '127.0.0.1:8000' 7 | 8 | [storage] 9 | data-dir = 'C:\Users\dailyuse\AppData\Local\Temp\rustic' 10 | 11 | [auth] 12 | disable-auth = false 13 | 14 | [acl] 15 | disable-acl = false 16 | append-only = true 17 | 18 | [tls] 19 | disable-tls = true 20 | 21 | [log] 22 | -------------------------------------------------------------------------------- /src/snapshots/rustic_server__config__test__file_read.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config.rs 3 | expression: config 4 | --- 5 | RusticServerConfig { 6 | server: ConnectionSettings { 7 | listen: 127.0.0.1:8080, 8 | }, 9 | storage: StorageSettings { 10 | data_dir: Some( 11 | "tests/generated/test_storage/", 12 | ), 13 | quota: None, 14 | }, 15 | auth: HtpasswdSettings { 16 | disable_auth: false, 17 | htpasswd_file: Some( 18 | "tests/fixtures/test_data/.htpasswd", 19 | ), 20 | }, 21 | acl: AclSettings { 22 | disable_acl: false, 23 | private_repos: true, 24 | append_only: false, 25 | acl_path: Some( 26 | "tests/fixtures/test_data/acl.toml", 27 | ), 28 | }, 29 | tls: TlsSettings { 30 | disable_tls: false, 31 | tls_key: Some( 32 | "tests/fixtures/test_data/certs/test.key", 33 | ), 34 | tls_cert: Some( 35 | "tests/fixtures/test_data/certs/test.crt", 36 | ), 37 | }, 38 | log: LogSettings { 39 | log_level: None, 40 | log_file: None, 41 | }, 42 | } 43 | -------------------------------------------------------------------------------- /src/snapshots/rustic_server__config__test__issue_60_parse_config_passes.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config.rs 3 | expression: config 4 | --- 5 | RusticServerConfig { 6 | server: ConnectionSettings { 7 | listen: Some( 8 | 127.0.0.1:8000, 9 | ), 10 | }, 11 | storage: StorageSettings { 12 | data_dir: Some( 13 | "C:\\Users\\dailyuse\\AppData\\Local\\Temp\\rustic", 14 | ), 15 | quota: None, 16 | }, 17 | auth: HtpasswdSettings { 18 | disable_auth: false, 19 | htpasswd_file: None, 20 | }, 21 | acl: AclSettings { 22 | disable_acl: true, 23 | private_repos: true, 24 | append_only: false, 25 | acl_path: None, 26 | }, 27 | tls: TlsSettings { 28 | disable_tls: true, 29 | tls_key: None, 30 | tls_cert: None, 31 | }, 32 | log: LogSettings { 33 | log_level: None, 34 | log_file: None, 35 | }, 36 | } 37 | -------------------------------------------------------------------------------- /src/snapshots/rustic_server__config__test__optional_explicit_parse_config_passes.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config.rs 3 | expression: config 4 | --- 5 | RusticServerConfig { 6 | server: ConnectionSettings { 7 | listen: Some( 8 | 127.0.0.1:8000, 9 | ), 10 | }, 11 | storage: StorageSettings { 12 | data_dir: Some( 13 | "./test_data/test_repos/", 14 | ), 15 | quota: None, 16 | }, 17 | auth: HtpasswdSettings { 18 | disable_auth: true, 19 | htpasswd_file: None, 20 | }, 21 | acl: AclSettings { 22 | disable_acl: true, 23 | private_repos: true, 24 | append_only: true, 25 | acl_path: None, 26 | }, 27 | tls: TlsSettings { 28 | disable_tls: true, 29 | tls_key: None, 30 | tls_cert: None, 31 | }, 32 | log: LogSettings { 33 | log_level: Some( 34 | "info", 35 | ), 36 | log_file: None, 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /src/snapshots/rustic_server__config__test__optional_implicit_parse_config_passes.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config.rs 3 | expression: config 4 | --- 5 | RusticServerConfig { 6 | server: ConnectionSettings { 7 | listen: Some( 8 | 127.0.0.1:8000, 9 | ), 10 | }, 11 | storage: StorageSettings { 12 | data_dir: Some( 13 | "./test_data/test_repos/", 14 | ), 15 | quota: None, 16 | }, 17 | auth: HtpasswdSettings { 18 | disable_auth: false, 19 | htpasswd_file: None, 20 | }, 21 | acl: AclSettings { 22 | disable_acl: false, 23 | private_repos: true, 24 | append_only: true, 25 | acl_path: None, 26 | }, 27 | tls: TlsSettings { 28 | disable_tls: true, 29 | tls_key: None, 30 | tls_cert: None, 31 | }, 32 | log: LogSettings { 33 | log_level: None, 34 | log_file: None, 35 | }, 36 | } 37 | -------------------------------------------------------------------------------- /src/snapshots/rustic_server__config__test__parse_config_passes.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/config.rs 3 | expression: config 4 | --- 5 | RusticServerConfig { 6 | server: ConnectionSettings { 7 | listen: "127.0.0.1:8000", 8 | }, 9 | storage: StorageSettings { 10 | data_dir: Some( 11 | "./test_data/test_repos/", 12 | ), 13 | quota: Some( 14 | 0, 15 | ), 16 | }, 17 | auth: HtpasswdSettings { 18 | disable_auth: false, 19 | htpasswd_file: Some( 20 | "/test_data/test_repo/.htpasswd", 21 | ), 22 | }, 23 | acl: AclSettings { 24 | disable_acl: true, 25 | append_only: false, 26 | acl_path: Some( 27 | "/test_data/test_repo/acl.toml", 28 | ), 29 | }, 30 | tls: TlsSettings { 31 | disable_tls: false, 32 | tls_key: Some( 33 | "/test_data/test_repo/key.pem", 34 | ), 35 | tls_cert: Some( 36 | "/test_data/test_repo/cert.pem", 37 | ), 38 | }, 39 | log: LogSettings { 40 | log_level: Some( 41 | "info", 42 | ), 43 | log_file: Some( 44 | "/test_data/test_repo/rustic.log", 45 | ), 46 | }, 47 | } 48 | -------------------------------------------------------------------------------- /src/snapshots/rustic_server__htpasswd__test__htpasswd_passes.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: src/htpasswd.rs 3 | expression: htpasswd 4 | --- 5 | path = '' 6 | [credentials.Administrator] 7 | name = 'Administrator' 8 | hash = '[hash]' 9 | 10 | [credentials.backup-user] 11 | name = 'backup-user' 12 | hash = '[hash]' 13 | -------------------------------------------------------------------------------- /src/storage.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | path::{Path, PathBuf}, 3 | sync::{Arc, OnceLock}, 4 | }; 5 | 6 | use tokio::fs::{create_dir_all, remove_dir_all, remove_file, File}; 7 | use walkdir::WalkDir; 8 | 9 | use crate::{ 10 | config::default_data_dir, 11 | error::{ApiErrorKind, ApiResult, AppResult}, 12 | handlers::file_helpers::WriteOrDeleteFile, 13 | }; 14 | 15 | //Static storage of our credentials 16 | pub static STORAGE: OnceLock> = OnceLock::new(); 17 | 18 | pub(crate) fn init_storage(storage: impl Storage) -> AppResult<()> { 19 | let _ = STORAGE.get_or_init(|| Arc::new(storage)); 20 | Ok(()) 21 | } 22 | 23 | #[async_trait::async_trait] 24 | //#[enum_dispatch(StorageEnum)] 25 | pub trait Storage: Send + Sync + 'static { 26 | /// Initialize the storage 27 | fn init(path: &Path) -> ApiResult 28 | where 29 | Self: Sized; 30 | 31 | /// Returns the path of the storage 32 | fn path(&self) -> &Path; 33 | 34 | async fn create_dir(&self, path: &Path, tpe: Option<&str>) -> ApiResult<()>; 35 | 36 | fn read_dir( 37 | &self, 38 | path: &Path, 39 | tpe: Option<&str>, 40 | ) -> Box>; 41 | 42 | fn filename(&self, path: &Path, tpe: &str, name: Option<&str>) -> PathBuf; 43 | 44 | async fn open_file(&self, path: &Path, tpe: &str, name: Option<&str>) -> ApiResult; 45 | 46 | async fn create_file( 47 | &self, 48 | path: &Path, 49 | tpe: &str, 50 | name: Option<&str>, 51 | ) -> ApiResult; 52 | 53 | async fn remove_file(&self, path: &Path, tpe: &str, name: Option<&str>) -> ApiResult<()>; 54 | 55 | async fn remove_repository(&self, path: &Path) -> ApiResult<()>; 56 | } 57 | 58 | #[derive(Debug, Clone)] 59 | pub struct LocalStorage { 60 | path: PathBuf, 61 | } 62 | 63 | impl Default for LocalStorage { 64 | fn default() -> Self { 65 | Self { 66 | path: default_data_dir(), 67 | } 68 | } 69 | } 70 | 71 | impl LocalStorage {} 72 | 73 | #[async_trait::async_trait] 74 | impl Storage for LocalStorage { 75 | fn init(path: &Path) -> ApiResult { 76 | Ok(Self { 77 | path: path.to_path_buf(), 78 | }) 79 | } 80 | 81 | fn path(&self) -> &Path { 82 | &self.path 83 | } 84 | 85 | async fn create_dir(&self, path: &Path, tpe: Option<&str>) -> ApiResult<()> { 86 | match tpe { 87 | Some(tpe) if tpe == "data" => { 88 | for i in 0..256 { 89 | create_dir_all(self.path.join(path).join(tpe).join(format!("{:02x}", i))) 90 | .await 91 | .map_err(|err| { 92 | ApiErrorKind::CreatingDirectoryFailed(format!( 93 | "Could not create directory: {err}" 94 | )) 95 | })?; 96 | } 97 | Ok(()) 98 | } 99 | Some(tpe) => create_dir_all(self.path.join(path).join(tpe)) 100 | .await 101 | .map_err(|err| { 102 | ApiErrorKind::CreatingDirectoryFailed(format!( 103 | "Could not create directory: {err}" 104 | )) 105 | }), 106 | None => create_dir_all(self.path.join(path)).await.map_err(|err| { 107 | ApiErrorKind::CreatingDirectoryFailed(format!("Could not create directory: {err}")) 108 | }), 109 | } 110 | } 111 | 112 | // FIXME: Make async? 113 | fn read_dir( 114 | &self, 115 | path: &Path, 116 | tpe: Option<&str>, 117 | ) -> Box> { 118 | let path = tpe.map_or_else( 119 | || self.path.join(path), 120 | |tpe| self.path.join(path).join(tpe), 121 | ); 122 | 123 | let walker = WalkDir::new(path) 124 | .into_iter() 125 | .filter_map(walkdir::Result::ok) 126 | // FIXME: Why do we filter out directories!? 127 | .filter(|e| e.file_type().is_file()); 128 | 129 | Box::new(walker) 130 | } 131 | 132 | fn filename(&self, path: &Path, tpe: &str, name: Option<&str>) -> PathBuf { 133 | match (tpe, name) { 134 | ("config", _) => self.path.join(path).join("config"), 135 | ("data", Some(name)) => self.path.join(path).join(tpe).join(&name[0..2]).join(name), 136 | (tpe, Some(name)) => self.path.join(path).join(tpe).join(name), 137 | (path, None) => self.path.join(path), 138 | } 139 | } 140 | 141 | async fn open_file(&self, path: &Path, tpe: &str, name: Option<&str>) -> ApiResult { 142 | let file_path = self.filename(path, tpe, name); 143 | Ok(File::open(file_path).await.map_err(|err| { 144 | ApiErrorKind::OpeningFileFailed(format!("Could not open file: {}", err)) 145 | })?) 146 | } 147 | 148 | async fn create_file( 149 | &self, 150 | path: &Path, 151 | tpe: &str, 152 | name: Option<&str>, 153 | ) -> ApiResult { 154 | let file_path = self.filename(path, tpe, name); 155 | WriteOrDeleteFile::new(file_path).await 156 | } 157 | 158 | async fn remove_file(&self, path: &Path, tpe: &str, name: Option<&str>) -> ApiResult<()> { 159 | let file_path = self.filename(path, tpe, name); 160 | remove_file(file_path).await.map_err(|err| { 161 | ApiErrorKind::RemovingFileFailed(format!("Could not remove file: {err}")) 162 | }) 163 | } 164 | 165 | async fn remove_repository(&self, path: &Path) -> ApiResult<()> { 166 | tracing::debug!( 167 | "Deleting repository: {}", 168 | self.path.join(path).to_string_lossy() 169 | ); 170 | remove_dir_all(self.path.join(path)).await.map_err(|err| { 171 | ApiErrorKind::RemovingRepositoryFailed(format!("Could not remove repository: {err}")) 172 | }) 173 | } 174 | } 175 | 176 | #[cfg(test)] 177 | mod test { 178 | use crate::storage::{init_storage, LocalStorage, Storage, STORAGE}; 179 | use std::path::PathBuf; 180 | 181 | #[test] 182 | fn test_file_access_passes() { 183 | let local_storage = 184 | LocalStorage::init(&PathBuf::from("tests/generated/test_storage")).unwrap(); 185 | init_storage(local_storage).unwrap(); 186 | 187 | let storage = STORAGE.get().unwrap(); 188 | 189 | // path must not start with slash !! that will skip the self.path from Storage! 190 | let path = PathBuf::new().join("test_repo/"); 191 | let c = storage.read_dir(&path, Some("keys")); 192 | let mut found = false; 193 | for a in c.into_iter() { 194 | let file_name = a.file_name().to_string_lossy(); 195 | if file_name == "3f918b737a2b9f72f044d06d6009eb34e0e8d06668209be3ce86e5c18dac0295" { 196 | found = true; 197 | break; 198 | } 199 | } 200 | assert!(found); 201 | } 202 | 203 | #[tokio::test] 204 | async fn test_config_access_passes() { 205 | let local_storage = 206 | LocalStorage::init(&PathBuf::from("tests/generated/test_storage")).unwrap(); 207 | init_storage(local_storage).unwrap(); 208 | 209 | let storage = STORAGE.get().unwrap(); 210 | 211 | // path must not start with slash !! that will skip the self.path from Storage! 212 | let path = PathBuf::new().join("test_repo/"); 213 | let c = storage.open_file(&path, "", Some("config")).await; 214 | assert!(c.is_ok()); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/testing.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | path::PathBuf, 3 | sync::{Mutex, OnceLock}, 4 | }; 5 | 6 | use axum::{ 7 | body::Body, 8 | http::{HeaderValue, Method}, 9 | }; 10 | 11 | use tracing::debug; 12 | use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; 13 | 14 | use crate::{ 15 | acl::{init_acl, Acl}, 16 | auth::{init_auth, Auth}, 17 | config::{ 18 | default_data_dir, AclSettings, HtpasswdSettings, RusticServerConfig, StorageSettings, 19 | }, 20 | storage::{init_storage, LocalStorage, Storage}, 21 | }; 22 | 23 | // ------------------------------------------------ 24 | // test facility prevent repeated calls in tests 25 | // ------------------------------------------------ 26 | 27 | /// Common requests, using a password that should 28 | /// be recognized as OK for the repository we are trying to access. 29 | pub fn request_uri_for_test(uri: &str, method: Method) -> axum::http::Request { 30 | axum::http::Request::builder() 31 | .uri(uri) 32 | .method(method) 33 | .header( 34 | "Authorization", 35 | basic_auth_header_value("rustic", Some("rustic")), 36 | ) 37 | .body(Body::empty()) 38 | .unwrap() 39 | } 40 | 41 | // ------------------------------------------------ 42 | // test facility for tracing 43 | // ------------------------------------------------ 44 | 45 | pub(crate) fn init_tracing() { 46 | init_mutex(); 47 | } 48 | 49 | /// When we initialize the global tracing subscriber, this must only happen once. 50 | /// During tests, each test will initialize, to make sure we have at least tracing once. 51 | /// This means that the `init()` call must be robust for this. 52 | /// Since we do not need this in production code, it is located in the test code. 53 | static TRACER: OnceLock> = OnceLock::new(); 54 | fn init_mutex() { 55 | let _ = TRACER.get_or_init(|| { 56 | tracing_subscriber::registry() 57 | .with( 58 | tracing_subscriber::EnvFilter::try_from_default_env() 59 | .unwrap_or_else(|_| "RUSTIC_SERVER_LOG_LEVEL=debug".into()), 60 | ) 61 | .with(tracing_subscriber::fmt::layer()) 62 | .init(); 63 | Mutex::new(0) 64 | }); 65 | } 66 | 67 | // ------------------------------------------------ 68 | // test facility for creating a minimum test environment 69 | // ------------------------------------------------ 70 | 71 | pub(crate) fn test_data_path() -> PathBuf { 72 | PathBuf::from("tests/fixtures/test_data") 73 | } 74 | 75 | pub(crate) fn server_config() -> RusticServerConfig { 76 | let server_config_path = test_data_path().join("rustic_server.toml"); 77 | RusticServerConfig::from_file(&server_config_path).unwrap() 78 | } 79 | 80 | pub(crate) fn init_test_environment(server_config: RusticServerConfig) { 81 | init_tracing(); 82 | init_static_htpasswd(server_config.auth); 83 | init_static_auth(server_config.acl); 84 | init_static_storage(server_config.storage); 85 | } 86 | 87 | fn init_static_htpasswd(htpasswd_settings: HtpasswdSettings) { 88 | let auth = Auth::from_config(&htpasswd_settings, test_data_path().join(".htpasswd")).unwrap(); 89 | debug!(?auth, "Loaded Auth."); 90 | init_auth(auth).unwrap(); 91 | } 92 | 93 | fn init_static_auth(acl_settings: AclSettings) { 94 | let acl = Acl::from_config(&acl_settings.clone(), acl_settings.acl_path).unwrap(); 95 | debug!(?acl, "Loaded Acl."); 96 | init_acl(acl).unwrap(); 97 | } 98 | 99 | fn init_static_storage(storage_settings: StorageSettings) { 100 | let local_storage = LocalStorage::init( 101 | storage_settings 102 | .data_dir 103 | .unwrap_or_else(default_data_dir) 104 | .as_ref(), 105 | ) 106 | .unwrap(); 107 | 108 | debug!(?local_storage, "Loaded Storage."); 109 | 110 | init_storage(local_storage).unwrap(); 111 | } 112 | 113 | // ------------------------------------------------ 114 | // test facility for authentication 115 | // ------------------------------------------------ 116 | 117 | /// Creates a header value from a username, and password. 118 | /// Copy for the reqwest crate; 119 | pub(crate) fn basic_auth_header_value(username: U, password: Option

) -> HeaderValue 120 | where 121 | U: std::fmt::Display, 122 | P: std::fmt::Display, 123 | { 124 | use base64::prelude::BASE64_STANDARD; 125 | use base64::write::EncoderWriter; 126 | use std::io::Write; 127 | 128 | let mut buf = b"Basic ".to_vec(); 129 | { 130 | let mut encoder = EncoderWriter::new(&mut buf, &BASE64_STANDARD); 131 | let _ = write!(encoder, "{}:", username); 132 | if let Some(password) = password { 133 | let _ = write!(encoder, "{}", password); 134 | } 135 | } 136 | let mut header = HeaderValue::from_bytes(&buf).expect("base64 is always valid HeaderValue"); 137 | header.set_sensitive(true); 138 | header 139 | } 140 | -------------------------------------------------------------------------------- /src/typed_path.rs: -------------------------------------------------------------------------------- 1 | use axum_extra::routing::TypedPath; 2 | use serde_derive::{Deserialize, Serialize}; 3 | use strum::{AsRefStr, Display, EnumString, IntoStaticStr, VariantNames}; 4 | 5 | pub trait PathParts: Send { 6 | fn parts(&self) -> (Option, Option, Option) { 7 | (self.repo(), self.tpe(), self.name()) 8 | } 9 | 10 | fn repo(&self) -> Option { 11 | None 12 | } 13 | 14 | fn tpe(&self) -> Option { 15 | None 16 | } 17 | 18 | fn name(&self) -> Option { 19 | None 20 | } 21 | } 22 | 23 | #[derive( 24 | Debug, 25 | Clone, 26 | Copy, 27 | PartialEq, 28 | Eq, 29 | Default, 30 | Display, 31 | Serialize, 32 | Deserialize, 33 | IntoStaticStr, 34 | AsRefStr, 35 | VariantNames, 36 | EnumString, 37 | )] 38 | #[serde(rename_all = "lowercase")] 39 | #[strum(serialize_all = "lowercase")] 40 | #[strum(ascii_case_insensitive)] 41 | pub enum TpeKind { 42 | Config, 43 | #[default] 44 | Data, 45 | Index, 46 | Keys, 47 | Locks, 48 | Snapshots, 49 | } 50 | 51 | impl TpeKind { 52 | pub fn into_str(self) -> &'static str { 53 | self.into() 54 | } 55 | } 56 | 57 | // A type safe route with `"/:repo/config"` as its associated path. 58 | #[derive(TypedPath, Deserialize, Debug)] 59 | #[typed_path("/:repo/config")] 60 | pub struct RepositoryConfigPath { 61 | pub repo: String, 62 | } 63 | 64 | impl PathParts for RepositoryConfigPath { 65 | fn repo(&self) -> Option { 66 | Some(self.repo.clone()) 67 | } 68 | } 69 | 70 | // A type safe route with `"/:repo/"` as its associated path. 71 | #[derive(TypedPath, Deserialize, Debug)] 72 | #[typed_path("/:repo/")] 73 | pub struct RepositoryPath { 74 | pub repo: String, 75 | } 76 | 77 | impl PathParts for RepositoryPath { 78 | fn repo(&self) -> Option { 79 | Some(self.repo.clone()) 80 | } 81 | } 82 | 83 | // A type safe route with `"/:tpe"` as its associated path. 84 | #[derive(TypedPath, Deserialize, Debug, Copy, Clone)] 85 | #[typed_path("/:tpe")] 86 | pub struct TpePath { 87 | pub tpe: TpeKind, 88 | } 89 | 90 | impl PathParts for TpePath { 91 | fn tpe(&self) -> Option { 92 | Some(self.tpe) 93 | } 94 | } 95 | 96 | // A type safe route with `"/:repo/:tpe/"` as its associated path. 97 | #[derive(TypedPath, Deserialize, Debug)] 98 | #[typed_path("/:repo/:tpe/")] 99 | pub struct RepositoryTpePath { 100 | pub repo: String, 101 | pub tpe: TpeKind, 102 | } 103 | 104 | impl PathParts for RepositoryTpePath { 105 | fn repo(&self) -> Option { 106 | Some(self.repo.clone()) 107 | } 108 | 109 | fn tpe(&self) -> Option { 110 | Some(self.tpe) 111 | } 112 | } 113 | 114 | // A type safe route with `"/:tpe/:name"` as its associated path. 115 | #[derive(TypedPath, Deserialize, Debug)] 116 | #[typed_path("/:tpe/:name")] 117 | pub struct TpeNamePath { 118 | pub tpe: TpeKind, 119 | pub name: String, 120 | } 121 | 122 | impl PathParts for TpeNamePath { 123 | fn tpe(&self) -> Option { 124 | Some(self.tpe) 125 | } 126 | 127 | fn name(&self) -> Option { 128 | Some(self.name.clone()) 129 | } 130 | } 131 | 132 | // A type safe route with `"/:repo/:tpe/:name"` as its associated path. 133 | #[derive(TypedPath, Deserialize, Debug)] 134 | #[typed_path("/:repo/:tpe/:name")] 135 | pub struct RepositoryTpeNamePath { 136 | pub repo: String, 137 | pub tpe: TpeKind, 138 | pub name: String, 139 | } 140 | 141 | impl PathParts for RepositoryTpeNamePath { 142 | fn repo(&self) -> Option { 143 | Some(self.repo.clone()) 144 | } 145 | 146 | fn tpe(&self) -> Option { 147 | Some(self.tpe) 148 | } 149 | 150 | fn name(&self) -> Option { 151 | Some(self.name.clone()) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/web.rs: -------------------------------------------------------------------------------- 1 | use axum::{middleware, routing::get, Router}; 2 | use axum_extra::routing::RouterExt; 3 | use axum_server::tls_rustls::RustlsConfig; 4 | use tokio::net::TcpListener; 5 | use tracing::{info, level_filters::LevelFilter}; 6 | 7 | use crate::{ 8 | acl::init_acl, 9 | auth::init_auth, 10 | context::ServerRuntimeContext, 11 | error::{AppResult, ErrorKind}, 12 | handlers::{ 13 | file_config::{add_config, delete_config, get_config, has_config}, 14 | file_exchange::{add_file, delete_file, get_file}, 15 | file_length::file_length, 16 | files_list::list_files, 17 | health::{init_start_time, live_check}, 18 | repository::{create_repository, delete_repository}, 19 | }, 20 | log::print_request_response, 21 | storage::{init_storage, Storage}, 22 | typed_path::{RepositoryConfigPath, RepositoryPath, RepositoryTpeNamePath, RepositoryTpePath}, 23 | }; 24 | 25 | /// Start the web server 26 | /// 27 | /// # Arguments 28 | /// 29 | /// * `runtime_ctx` - The server runtime context 30 | pub async fn start_web_server(runtime_ctx: ServerRuntimeContext) -> AppResult<()> 31 | where 32 | S: Storage + Clone + std::fmt::Debug, 33 | { 34 | let ServerRuntimeContext { 35 | socket_address, 36 | acl, 37 | auth, 38 | storage, 39 | tls, 40 | .. 41 | } = runtime_ctx; 42 | 43 | init_start_time(); 44 | init_acl(acl)?; 45 | init_auth(auth)?; 46 | init_storage(storage)?; 47 | 48 | let mut app = Router::new(); 49 | 50 | // /health/live 51 | // 52 | // Liveness probe. This is used to check if the server is running. 53 | // Returns “200 OK” if the server is running. 54 | app = app.route("/health/live", get(live_check)); 55 | 56 | // /health/ready 57 | // 58 | // Readiness probe. This is used to check if the server is ready to accept requests. 59 | // Returns “200 OK” if the server is ready to accept requests. 60 | // app = app.route("/health/ready", get(ready_check)); 61 | 62 | // /:repo/:tpe/:name 63 | app = app 64 | // Returns “200 OK” if the blob with the given name and type is stored in the repository, 65 | // “404 not found” otherwise. If the blob exists, the HTTP header Content-Length 66 | // is set to the file size. 67 | .typed_head(file_length::) 68 | // Returns the content of the blob with the given name and type if it is stored 69 | // in the repository, “404 not found” otherwise. 70 | // If the request specifies a partial read with a Range header field, then the 71 | // status code of the response is 206 instead of 200 and the response only contains 72 | // the specified range. 73 | // 74 | // Response format: binary/octet-stream 75 | .typed_get(get_file::) 76 | // Saves the content of the request body as a blob with the given name and type, 77 | // an HTTP error otherwise. 78 | // 79 | // Request format: binary/octet-stream 80 | .typed_post(add_file::) 81 | // Returns “200 OK” if the blob with the given name and type has been deleted from 82 | // the repository, an HTTP error otherwise. 83 | .typed_delete(delete_file::); 84 | 85 | // /:repo/config 86 | app = app 87 | // Returns “200 OK” if the repository has a configuration, an HTTP error otherwise. 88 | .typed_head(has_config) 89 | // Returns the content of the configuration file if the repository has a configuration, 90 | // an HTTP error otherwise. 91 | // 92 | // Response format: binary/octet-stream 93 | .typed_get(get_config::) 94 | // Returns “200 OK” if the configuration of the request body has been saved, 95 | // an HTTP error otherwise. 96 | .typed_post(add_config::) 97 | // Returns “200 OK” if the configuration of the repository has been deleted, 98 | // an HTTP error otherwise. 99 | // Note: This is not part of the API documentation, but it is implemented 100 | // to allow for the deletion of the configuration file during testing. 101 | .typed_delete(delete_config::); 102 | 103 | // /:repo/:tpe/ 104 | // # API version 1 105 | // 106 | // Returns a JSON array containing the names of all the blobs stored for a given type, example: 107 | // 108 | // ```json 109 | // [ 110 | // "245bc4c430d393f74fbe7b13325e30dbde9fb0745e50caad57c446c93d20096b", 111 | // "85b420239efa1132c41cea0065452a40ebc20c6f8e0b132a5b2f5848360973ec", 112 | // "8e2006bb5931a520f3c7009fe278d1ebb87eb72c3ff92a50c30e90f1b8cf3e60", 113 | // "e75c8c407ea31ba399ab4109f28dd18c4c68303d8d86cc275432820c42ce3649" 114 | // ] 115 | // ``` 116 | // 117 | // # API version 2 118 | // 119 | // Returns a JSON array containing an object for each file of the given type. 120 | // The objects have two keys: name for the file name, and size for the size in bytes. 121 | // 122 | // [ 123 | // { 124 | // "name": "245bc4c430d393f74fbe7b13325e30dbde9fb0745e50caad57c446c93d20096b", 125 | // "size": 2341058 126 | // }, 127 | // { 128 | // "name": "85b420239efa1132c41cea0065452a40ebc20c6f8e0b132a5b2f5848360973ec", 129 | // "size": 2908900 130 | // }, 131 | // { 132 | // "name": "8e2006bb5931a520f3c7009fe278d1ebb87eb72c3ff92a50c30e90f1b8cf3e60", 133 | // "size": 3030712 134 | // }, 135 | // { 136 | // "name": "e75c8c407ea31ba399ab4109f28dd18c4c68303d8d86cc275432820c42ce3649", 137 | // "size": 2804 138 | // } 139 | // ] 140 | app = app.typed_get(list_files::); 141 | 142 | // /:repo/ --> note: trailing slash 143 | app = app 144 | // This request is used to initially create a new repository. 145 | // The server responds with “200 OK” if the repository structure was created 146 | // successfully or already exists, otherwise an error is returned. 147 | .typed_post(create_repository::) 148 | // Deletes the repository on the server side. The server responds with “200 OK” 149 | // if the repository was successfully removed. If this function is not implemented 150 | // the server returns “501 Not Implemented”, if this it is denied by the server it 151 | // returns “403 Forbidden”. 152 | .typed_delete(delete_repository::); 153 | 154 | // TODO: This is not reflected in the API documentation? 155 | // TODO: Decide if we want to keep this or not! 156 | // // /:tpe/:name 157 | // // we loop here over explicit types, to prevent conflict with paths "/:repo/:tpe" 158 | // for tpe in constants::TYPES.into_iter() { 159 | // let path = format!("/{}:name", &tpe); 160 | // app = app 161 | // .route(path.as_str(), head(file_length::)) 162 | // .route(path.as_str(), get(get_file::)) 163 | // .route(path.as_str(), post(add_file::)) 164 | // .route(path.as_str(), delete(delete_file::)); 165 | // } 166 | // 167 | // /:tpe --> note: NO trailing slash 168 | // we loop here over explicit types, to prevent the conflict with paths "/:repo/" 169 | // for tpe in constants::TYPES.into_iter() { 170 | // let path = format!("/{}", &tpe); 171 | // app = app.route(path.as_str(), get(list_files::)); 172 | // } 173 | 174 | // Extra logging requested. Handlers will log too 175 | // TODO: Use LogSettings here, this should be set from the cli by `--log` 176 | // TODO: and then needs to go to a file 177 | // e.g. log_opts.is_disabled() or other checks 178 | match LevelFilter::current() { 179 | LevelFilter::TRACE | LevelFilter::DEBUG | LevelFilter::INFO => { 180 | app = app.layer(middleware::from_fn(print_request_response)); 181 | } 182 | _ => {} 183 | }; 184 | 185 | info!("Starting web server ..."); 186 | 187 | if let Some(tls) = tls { 188 | // Start server with or without TLS 189 | let config = RustlsConfig::from_pem_file(tls.tls_cert, tls.tls_key) 190 | .await 191 | .map_err(|err| 192 | ErrorKind::Io.context( 193 | format!("Failed to load TLS certificate/key. Please make sure the paths are correct. `{err}`") 194 | ) 195 | )?; 196 | 197 | info!("Listening on: `https://{socket_address}`"); 198 | 199 | axum_server::bind_rustls(socket_address, config) 200 | .serve(app.into_make_service()) 201 | .await 202 | .expect("Failed to start server. Is the address already in use?"); 203 | } else { 204 | info!("Listening on: `http://{socket_address}`"); 205 | 206 | axum::serve( 207 | TcpListener::bind(socket_address) 208 | .await 209 | .expect("Failed to bind to socket. Please make sure the address is correct."), 210 | app.into_make_service(), 211 | ) 212 | .await 213 | .expect("Failed to start server. Is the address already in use?"); 214 | }; 215 | 216 | Ok(()) 217 | } 218 | -------------------------------------------------------------------------------- /tests/fixtures/hurl/endpoints.hurl: -------------------------------------------------------------------------------- 1 | # No auth 2 | HEAD http://127.0.0.1:8000/ci_repo/config 3 | HTTP 403 4 | 5 | # Access a new repository 6 | HEAD http://127.0.0.1:8000/ci_repo/ 7 | [BasicAuth] 8 | hurl: hurl 9 | HTTP 405 10 | 11 | # Create a new repository 12 | POST http://127.0.0.1:8000/ci_repo/?create=true 13 | [BasicAuth] 14 | hurl: hurl 15 | HTTP 200 16 | 17 | 18 | HEAD http://127.0.0.1:8000/ci_repo/config 19 | [BasicAuth] 20 | hurl: hurl 21 | HTTP 200 22 | 23 | # Access to keys 24 | GET http://127.0.0.1:8000/ci_repo/keys/ 25 | [BasicAuth] 26 | hurl: hurl 27 | HTTP 200 28 | Content-Type: application/vnd.x.restic.rest.v1 29 | 30 | GET http://127.0.0.1:8000/ci_repo/keys/eb7e523a1916c2cc1c750dc89cd6024f5dd319814c417a3f9081578f8c2c4a76 31 | RANGE: bytes=0-230 32 | [BasicAuth] 33 | hurl: hurl 34 | HTTP 206 35 | content-length: 231 36 | 37 | # GET http://127.0.0.1:8000/ci_repo/keys/eb7e523a1916c2cc1c750dc89cd6024f5dd319814c417a3f9081578f8c2c4a76 38 | # [BasicAuth] 39 | # hurl: hurl 40 | # HTTP 200 41 | 42 | GET http://127.0.0.1:8000/ci_repo/config 43 | [BasicAuth] 44 | hurl: hurl 45 | HTTP 200 46 | 47 | GET http://127.0.0.1:8000/ci_repo/locks/ 48 | [BasicAuth] 49 | hurl: hurl 50 | HTTP 200 51 | 52 | # POST http://127.0.0.1:8000/ci_repo/locks/ac4ff62472b009cf71c81199f4fc635152639909cb1143911150db467ca86544 53 | # [BasicAuth] 54 | # hurl: hurl 55 | # HTTP 200 56 | 57 | GET http://127.0.0.1:8000/ci_repo/locks/ 58 | [BasicAuth] 59 | hurl: hurl 60 | HTTP 200 61 | 62 | GET http://127.0.0.1:8000/ci_repo/snapshots/ 63 | [BasicAuth] 64 | hurl: hurl 65 | HTTP 200 66 | 67 | GET http://127.0.0.1:8000/ci_repo/index/ 68 | [BasicAuth] 69 | hurl: hurl 70 | HTTP 200 71 | 72 | # POST http://127.0.0.1:8000/ci_repo/data/3b013253cd72fa7e98f9dcd6106f9565933556f1c80a720e1e44dbf3b57af446 73 | # [BasicAuth] 74 | # hurl: hurl 75 | # HTTP 200 76 | 77 | # POST http://127.0.0.1:8000/ci_repo/data/89923722810777f3026a7cf9b246eb9613c7e3f64e77e6ccecb4001774c38acf 78 | # [BasicAuth] 79 | # hurl: hurl 80 | # HTTP 200 81 | 82 | # POST http://127.0.0.1:8000/ci_repo/index/004ebe81e7927131b6dde40bd4595ebf95a355bf26509de83fb9d12b4ab280b4 83 | # [BasicAuth] 84 | # hurl: hurl 85 | # HTTP 200 86 | 87 | # POST http://127.0.0.1:8000/ci_repo/snapshots/ddd75013f2d2470d910adadf728c5c8cd6e91cb591bdfbf1cfd7f3af7e32c7eb 88 | # [BasicAuth] 89 | # hurl: hurl 90 | # HTTP 200 91 | 92 | # DELETE http://127.0.0.1:8000/ci_repo/locks/ac4ff62472b009cf71c81199f4fc635152639909cb1143911150db467ca86544 93 | # [BasicAuth] 94 | # hurl: hurl 95 | # HTTP 200 96 | -------------------------------------------------------------------------------- /tests/fixtures/test_data/.htpasswd: -------------------------------------------------------------------------------- 1 | rustic:$2y$05$48brsXSjDCo83AKq.52w1.A9NgpQth3emdWZZXbPStHtqv7hxIleW 2 | restic:$2y$05$iKXd4X4AKOpBPufMhlSfwOQqrl/nu1A9yAFbKYG742cJz325qeB/a 3 | hurl:$2y$05$63hF2CpYPDYuM3Jlm04hH.TYIxGo6nk1eFjVBHd06X7LLRcTFyMz2 4 | -------------------------------------------------------------------------------- /tests/fixtures/test_data/README.md: -------------------------------------------------------------------------------- 1 | # Test data folder 2 | 3 | The test data folder contains data required for testing the server. 4 | 5 | ## Basic files for test access to a repository 6 | 7 | ### `.htpasswd` 8 | 9 | File governing the access to the server. Without access all is rejected. 10 | 11 | The `.htpasswd` file has three entries: 12 | 13 | - rustic:rustic 14 | - restic:restic 15 | - hurl:hurl 16 | 17 | ### `acl.toml` 18 | 19 | Definition which user from the HTACCESS file has what privileges on which 20 | repository. 21 | 22 | Check [here](config/README.md) for more information. 23 | 24 | ### `rustic_server.toml` 25 | 26 | Server configuration file. 27 | 28 | ### `rustic.toml` 29 | 30 | Configuration file for the `rustic` commands. Start as: 31 | 32 | ```console 33 | rustic -P /test.toml 34 | ``` 35 | 36 | In the configuration folder there is an example given. Adapt to your 37 | configuration. To make use of the `test_repo`, the file has to contain the 38 | following credentials: 39 | 40 | ```toml 41 | [repository] 42 | repository = "rest:http://rustic:rustic@localhost:8000/ci_repo" 43 | password = "rustic" 44 | ``` 45 | 46 | ### `certs` directory 47 | 48 | Contains the test certificates for the server. 49 | 50 | ## Source folder for Testing 51 | 52 | There is a source folder with test data. 53 | 54 | ## Storage folder for Testing 55 | 56 | There is a storage folder with test data. It is used to store the data for the 57 | server. The data is stored in the `tests/generated/test_storage` directory. 58 | -------------------------------------------------------------------------------- /tests/fixtures/test_data/acl.toml: -------------------------------------------------------------------------------- 1 | [test_repo] 2 | rustic = "Append" 3 | restic = "Append" 4 | hurl = "Append" 5 | 6 | [repo_remove_me] 7 | rustic = "Modify" 8 | restic = "Modify" 9 | 10 | [repo_remove_me_2] 11 | rustic = "Modify" 12 | restic = "Modify" 13 | 14 | [ci_repo] 15 | rustic = "Modify" 16 | restic = "Modify" 17 | hurl = "Modify" 18 | -------------------------------------------------------------------------------- /tests/fixtures/test_data/certs/test.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFazCCA1OgAwIBAgIUbXfB4z/6MIKJgY+VTqnJ7+nTNWAwDQYJKoZIhvcNAQEL 3 | BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM 4 | GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDExMTUwNzE4MTVaFw0yNDEy 5 | MTUwNzE4MTVaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw 6 | HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggIiMA0GCSqGSIb3DQEB 7 | AQUAA4ICDwAwggIKAoICAQC7+I2MESwS2ITiACX24F1ad7LDJRatsa40KNfG0o9x 8 | TGC/v0Hi22h2uvxoIf/Sol9uikm+agFbTiCptzvkG8ceMqO+Wfb55UyElvu3RD5z 9 | xYVlpSHs4FKI25C+OENAALVxCmCGR+/CEWHamhRT0goGgkxdieh0zDqWKzg3ZGR8 10 | 7ez6MjuRag8ffTgMd6ShBNun0HrS37hVOSMXnZvOm8gTp3Ns+lacui4LIA75+COl 11 | KxPz2uVX0ChmCw9o1u8hZ0WRvaTZ20ftdr4PKIJ7PkubQeO03NuB0vV1OHQWP57/ 12 | zGeuz9GKxlpeeNrZPWtcEUONEyBVopSVOVDiKHmVgC5prrqIJ55eykxaKR977NNP 13 | 8+acVLHItmMozdnQTYdWqmqjrVadUBXEMvLhcZQkPOFcEKVETkN+XWey0Gy+wVRr 14 | BFkY8DJhtkUSVOCvyvtXnZuGAqNsZjEdaa5fCIYFL6OIcEuEmdcSY0wjY81mBmUm 15 | 4klXLZ/fqht6uiF+zr/2gesSxaookj8wDH88eIRzp79ZEwddXbYl0vdROgbYfP3u 16 | Jg4SGPC+ixcx0+QgBvnNo4wACTK4gpxmWdu6AfqorOJUjxlKT6Pl67Z7D9N6GQaL 17 | 9ixb5rMY2TFrca+qJ9lNWJoOyHCmPqhrEUwc4P7ttPK6wSunhtEvFMRghT2Prhtr 18 | DwIDAQABo1MwUTAdBgNVHQ4EFgQUdmGNwC3UyYQoY8aRjZLZZP548O8wHwYDVR0j 19 | BBgwFoAUdmGNwC3UyYQoY8aRjZLZZP548O8wDwYDVR0TAQH/BAUwAwEB/zANBgkq 20 | hkiG9w0BAQsFAAOCAgEAczt0ZT/bX7d2CmS/3gT/j8uAduEdKP6EcAQqmNO88+V+ 21 | Ib4+VC4wtPlz5VH1u2cySFiCMb55I16HNScP3Ufjqzi6W6tAaIFkEr1iGT8JF8o1 22 | vXhxwrpkg8hWUSR/eK4Huatx68Y9r3HwkTIAUxQ6OpiduPcrLt+hvH8ybQFOAm0h 23 | 0sfSF1MPlBuuoMo6xAbn/PwxdTISeYjeu4oqSV85dRZ0J7q0gmnRMP9dnUdJaYwS 24 | x2f8/y1lbl6WskNphHJs8l07BnhyulcxmU/wupo7TF80aNd29xaSnZ/WZ9OUO+JE 25 | o3uEEWVJOFZ/xWxH284G6zpkgrzKqQWql1EDcRwEsCP7Mb1nhcHuKFh6w+KUuy3Q 26 | +2zlRE20n6+30YSJ0sI+cHsYBZ3ay7qQHa00ZJtId7i5xswMhnl+HzNvwPRN8RNC 27 | dTKY0+mL554ahQXKwWXNB4CAIxYE84SCA+N2nNzVOuGME0jsulzyhsEqh0eIVQp2 28 | +pg3BIAUGESRfCy+b5dHE4THeyeXZQ3fdsPeH5eE1wSuTysBYWdoh+0uOKGHXRQX 29 | h3HB8LEuN7GHNkMMHwPRimFaeOrznbgX+IL/wnQSRe1Sw/j0/YPP3zZpLRZUaglB 30 | hQRc9rZdD6vxSeSpSZRUbtgRuwBAjl+EBl5ACope2SXtC9+61+xojA8yp6IGw1o= 31 | -----END CERTIFICATE----- 32 | -------------------------------------------------------------------------------- /tests/fixtures/test_data/certs/test.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQC7+I2MESwS2ITi 3 | ACX24F1ad7LDJRatsa40KNfG0o9xTGC/v0Hi22h2uvxoIf/Sol9uikm+agFbTiCp 4 | tzvkG8ceMqO+Wfb55UyElvu3RD5zxYVlpSHs4FKI25C+OENAALVxCmCGR+/CEWHa 5 | mhRT0goGgkxdieh0zDqWKzg3ZGR87ez6MjuRag8ffTgMd6ShBNun0HrS37hVOSMX 6 | nZvOm8gTp3Ns+lacui4LIA75+COlKxPz2uVX0ChmCw9o1u8hZ0WRvaTZ20ftdr4P 7 | KIJ7PkubQeO03NuB0vV1OHQWP57/zGeuz9GKxlpeeNrZPWtcEUONEyBVopSVOVDi 8 | KHmVgC5prrqIJ55eykxaKR977NNP8+acVLHItmMozdnQTYdWqmqjrVadUBXEMvLh 9 | cZQkPOFcEKVETkN+XWey0Gy+wVRrBFkY8DJhtkUSVOCvyvtXnZuGAqNsZjEdaa5f 10 | CIYFL6OIcEuEmdcSY0wjY81mBmUm4klXLZ/fqht6uiF+zr/2gesSxaookj8wDH88 11 | eIRzp79ZEwddXbYl0vdROgbYfP3uJg4SGPC+ixcx0+QgBvnNo4wACTK4gpxmWdu6 12 | AfqorOJUjxlKT6Pl67Z7D9N6GQaL9ixb5rMY2TFrca+qJ9lNWJoOyHCmPqhrEUwc 13 | 4P7ttPK6wSunhtEvFMRghT2PrhtrDwIDAQABAoICACo0H7/LmTwv/gHqK5v2+y7V 14 | Qj87ZBCinKzcYLoky40SK4TR0d64CYfi6soMnC40Q74DcZQ9o8lWzNGeMOXB8N6Q 15 | WyBhfajU+W2poqGewnDm79EHFwtiwFU3CxQSeNL1dceIH2z22NeXZIOa5aZ+Ob8F 16 | YVT9IkKbGipeUNRrB37fQr5YKfS3veaBjGSMNlvqxdCzZ+hRz8beucjTG8jzRCRZ 17 | i/pzaJ/u02ivDX3FX2d1uRie1LB3LaLfp9mPrIgw8jdLP6ikac1gxEKOA2HddkdJ 18 | L3GLxwMqlO75OmNGbJIdWEgxI+iHKYIdm3F8L08wFx0lJrTjeH3arRTiJo5RBdpU 19 | y8z6q8rlBpDE6c189mqEAXVMCEvFe259LfWDwI45BE7Oyrb9DqgxJ35psQuIWB+g 20 | fnBwiSBTVlVjKcg+2ufhF0dlU/dn7L87jvc83GMzy3i2sTYwqxBXkcTpdI6AwfPL 21 | hsB+/8yK46OTk0/9e80+0K4KGCrTCXCyg/YGDXRoTkHpopvc4eW8L82alFvdWKn8 22 | glN5PQYIlSXm02DyL63IiJiIo0Y05OP4uG8tPEasEzDhTPiG/Wiij3EU1uNi5Dhq 23 | A8tih5KxeouAAouXtDjB/zvUAkVnPso2vhI8pIG3lFnIfSvGGf/5GZmcR3OJztju 24 | FF2tu1yM8JyvKcEpX+NpAoIBAQDtypVSesjNBG3MCaFzaDKKBen6rDKxi+LoSHSg 25 | aPdTpwcy1O9HZlHOAqvBzhk+70C+mGrQ67eji55n24hLxqjPsXWuEeYWqaAP6lxy 26 | E/gfL5exQD6En6FLieg5LVxxFhzOhfqQNHm6B/X8GSKJW+M8Y8stYRX44Q+2qLIY 27 | 1GC33KfXvKLL2E9NYxep/jEItJiuhnFkSGACRLdxRP9x604cpZPHyin7nSK57GV4 28 | gmjFBF71n0ImVefgaq/wiPAUHp6uAdpHvPI4m23al96wdNsSQrjgoTFLEpTHEk6a 29 | sUZswWlRSjRwFoFV2mpdsIz6wo7qkFXsXQuIU6ln+09eXbVnAoIBAQDKXVdXFlGl 30 | MBeE7NKJSXEbagDrmKlpoa36KLT5uokZ4UTToF1bBtB1dE0nLOvDzK8+SP60NuNd 31 | CiUr2solksqMSOplZQa0ErSgxFhg1cPtndzLpKG+cA4FqWsfmCCIDfjOiQ4raqK0 32 | hQCO+0w3i1W/mDLidx+U6thXlma96jhXVRM4YaB42vZ1IAbyCLW/ZsNCna4+7KQ3 33 | 3Q04Fu+ODMno2ax16mUzYTwSpRUOk3DeHVjHsHwg5dRk286kG7YLCAsxqnWi0lci 34 | yWdw0czviSquCCNhaBy2v+q1Sr6X52n919b5Qq03NoedlFvZDNfuu0VwwTuX6bMZ 35 | 8UhstzPKoSwZAoIBABff4yYo1chfzXZS8TbZG1noRhm4+E4DMYEI5UzFvS9U8dAU 36 | uQF+MHByrDQFMVu0QdfhDbh0MjflaL4cuI6DH6fatWoIugVEeqGecjGx95OJ/7z4 37 | Kk6+iD9BVWOIPmPMSJAju4iG/EHFUtlA3MrVwvbpPhkMSlx2nFFGlrsPd7Z+HQv4 38 | EJBO44dtj46tytaI61t6fCAJdDpGE/T3bH5PlcQii7ffgF+W40mvhnCXB1Xgnngi 39 | yU14CpPpokiQNyqN+HiYDwZs3hT45gqwfNdSpDwtHsrO5FgZQX7LQ8EcU2nP8XcL 40 | 8D/gwpOQHmXRuBXlOtwqKbZVkTbaz4N1I/7hgbkCggEBALJStH9afJHbIi6RXil6 41 | XUByFeOGQGuD/MJ/kpKprNzwZG00WG+5PoLx/Hb0H8IHobl6K3B1Gb/IC99sSkv3 42 | 4aLjMiItd03BWgh9XP/f/2ppfMK7DYi6R7D3jR1nOeuKnGCr55+ctUnsFgTpL02W 43 | 6/YM0XI81Miudfwg3eKm/gT/RBOf+//ju+CUW2p3AGlszc3rEzwB3egYU+jEVU7Q 44 | uDAHePDjn1A6c+xeKoMQrBoetDgrrgZzYMmL5Lk6bh2kdfDLLCRRxFU0M4H4buX5 45 | 8nEvXLYeP4dO8S3WcsN7OixlQduexPLti23x6UoIBlQqFFP7A1+noZrPwymB+xKp 46 | GikCggEAPkvkGP7IsU24e0o+1DhSTXne1P4uL3gb9J2ajS2zXM/mHEnsMXk1tDbR 47 | hZMPWbeEdy9JNDVmIOn2DlHe8XA/IxpseMGuw84Lw2xdnG9KxcFfL3tKxBuQSgWW 48 | 2Rh/Bj0K91TjSWUuuWdyXGa956EEvm9kElvS0yAzrtcPEq+TMvY/EXpxBiRgmumd 49 | CByZ/Cslt5UYy1WQleYLhfwRGYvPVSxm9YsDnxUuElTp1GXmAhMyPB/GPIU4K387 50 | NPg7ORaAzwq/aBUstgvZzMxSVh1NFwuOoPSbBl94VN90KEBRjJsCWMprUqRsaCr0 51 | 3qSwXbVVeQ1hLFeZrLDH1U+xTu0W8g== 52 | -----END PRIVATE KEY----- 53 | -------------------------------------------------------------------------------- /tests/fixtures/test_data/rustic.toml: -------------------------------------------------------------------------------- 1 | [repository] 2 | repository = "rest:http://rustic:rustic@localhost:8000/ci_repo" 3 | password = "rustic" 4 | -------------------------------------------------------------------------------- /tests/fixtures/test_data/rustic_server.toml: -------------------------------------------------------------------------------- 1 | [server] 2 | listen = "127.0.0.1:8080" 3 | 4 | [storage] 5 | data-dir = "tests/generated/test_storage/" 6 | 7 | [auth] 8 | disable-auth = false 9 | htpasswd-file = "tests/fixtures/test_data/.htpasswd" 10 | 11 | [acl] 12 | disable-acl = false 13 | append-only = false 14 | acl-path = "tests/fixtures/test_data/acl.toml" 15 | 16 | [tls] 17 | disable-tls = true 18 | # tls-cert = "tests/fixtures/test_data/certs/test.crt" 19 | # tls-key = "tests/fixtures/test_data/certs/test.key" 20 | -------------------------------------------------------------------------------- /tests/fixtures/test_data/server_acl_minimal.toml: -------------------------------------------------------------------------------- 1 | [acl] 2 | disable-acl = true 3 | acl-path = "/test_data/test_repo/acl.toml" 4 | append-only = false 5 | -------------------------------------------------------------------------------- /tests/fixtures/test_data/test_repo_source/my_file.html: -------------------------------------------------------------------------------- 1 |

hello wold

2 | -------------------------------------------------------------------------------- /tests/fixtures/test_data/test_repo_source/my_file.txt: -------------------------------------------------------------------------------- 1 | hello world 2 | -------------------------------------------------------------------------------- /tests/fixtures/test_data/test_repo_source/my_folder/my_file.html: -------------------------------------------------------------------------------- 1 |

hello wold in folder

2 | -------------------------------------------------------------------------------- /tests/fixtures/test_data/test_repo_source/my_folder/my_file.txt: -------------------------------------------------------------------------------- 1 | hello world in folder 2 | -------------------------------------------------------------------------------- /tests/generated/test_storage/test_repo/config: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rustic-rs/rustic_server/c1435f6bbe940262df0179a1e9794d2b5bb461df/tests/generated/test_storage/test_repo/config -------------------------------------------------------------------------------- /tests/generated/test_storage/test_repo/keys/3f918b737a2b9f72f044d06d6009eb34e0e8d06668209be3ce86e5c18dac0295: -------------------------------------------------------------------------------- 1 | {"created":"2024-11-13T09:36:49.8626939+01:00","username":"TOWERPC\\dailyuse","hostname":"TowerPC","kdf":"scrypt","N":32768,"r":8,"p":7,"salt":"eOkZJZ+hvbGoe3ebzcNcEUXA/VP/e9WK2FUa0yDr96ZrjSoFc9qTbpgt4A3Z7m0NxqkF72D/aAgslDLw8Oe4cQ==","data":"ciGXdAUQBygykL/F+FE+/YnwNdcvdUenJ4ndRVIyOZ7BDs/VJxxEVul0YEykxtNJ0tJc2fruOzcMsSyjZkrATod4Zi3c2D0CJEE9kKeggwNDSJou9TOFjXF8eoZTQrzzeEkDXXPy26wlxhtLSlj4RqR3UgZzLOuT9rp9oUalxuCq7k0CAPz65rqOvUbmE0+krmuefJwhDWXK97E+gDp5iQ=="} -------------------------------------------------------------------------------- /tests/integration/_impl.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use crate::config::ConfigFile; 4 | use assert_cmd::Command; 5 | use serde::Serialize; 6 | 7 | pub trait AssertCmdExt { 8 | /// Add the given configuration file 9 | fn config(&mut self, config: &impl Serialize) -> &mut Self; 10 | 11 | /// Enable test mode 12 | fn test_mode_args(&mut self) -> &mut Self; 13 | } 14 | 15 | impl AssertCmdExt for Command { 16 | fn config(&mut self, config: &impl Serialize) -> &mut Self { 17 | let target_bin = self.get_program().to_owned(); 18 | let config_file = ConfigFile::create(&target_bin, config); 19 | 20 | // Leak the config file to keep it alive for the duration of the test 21 | let static_config: &'static mut ConfigFile = Box::leak(Box::new(config_file)); 22 | 23 | self.args(["-c", &static_config.path().display().to_string()]); 24 | self 25 | } 26 | 27 | fn test_mode_args(&mut self) -> &mut Self { 28 | self.timeout(Duration::from_secs(10)) // Set a timeout of 10 seconds 29 | .args(["-v"]) // Enable verbose logging 30 | .env("CI", "1"); // Enable CI test mode 31 | 32 | self 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/integration/acceptance.rs: -------------------------------------------------------------------------------- 1 | //! Acceptance test: runs the application as a subprocess and asserts its 2 | //! output for given argument combinations matches what is expected. 3 | //! 4 | //! Modify and/or delete these as you see fit to test the specific needs of 5 | //! your application. 6 | //! 7 | //! For more information, see: 8 | //! 9 | 10 | // Tip: Deny warnings with `RUSTFLAGS="-D warnings"` environment variable in CI 11 | 12 | #![forbid(unsafe_code)] 13 | #![warn( 14 | missing_docs, 15 | rust_2018_idioms, 16 | trivial_casts, 17 | unused_lifetimes, 18 | unused_qualifications 19 | )] 20 | 21 | use std::{net::SocketAddr, path::PathBuf}; 22 | 23 | use crate::_impl::AssertCmdExt; 24 | use anyhow::{Ok, Result}; 25 | use assert_cmd::Command; 26 | use rstest::{fixture, rstest}; 27 | use rustic_server::config::RusticServerConfig; 28 | use serial_test::file_serial; 29 | 30 | #[fixture] 31 | fn setup() -> Result { 32 | let runner = Command::cargo_bin(env!("CARGO_PKG_NAME").replace("_", "-"))?; 33 | 34 | Ok(runner) 35 | } 36 | 37 | /// Use command-line argument value 38 | #[rstest] 39 | #[file_serial] 40 | #[ignore = "FIXME: This test doesn't run in CI because it needs to bind to a port."] 41 | fn test_serve_with_args_passes(setup: Result) -> Result<()> { 42 | let assert = setup? 43 | .arg("serve") 44 | .args(["--listen", "127.0.0.1:8001"]) 45 | .args(["--htpasswd-file", "tests/fixtures/test_data/.htpasswd"]) 46 | .args([ 47 | "--tls", 48 | "--tls-cert", 49 | "tests/fixtures/test_data/certs/test.crt", 50 | "--tls-key", 51 | "tests/fixtures/test_data/certs/test.key", 52 | ]) 53 | .args([ 54 | "--private-repos", 55 | "--acl-path", 56 | "tests/fixtures/test_data/acl.toml", 57 | ]) 58 | .args(["--path", "tests/generated/test_storage"]) 59 | .args(["--max-size", "1000"]) 60 | .test_mode_args() 61 | .assert(); 62 | 63 | assert 64 | .stdout(predicates::str::contains("Parsed socket address.")) 65 | .stdout(predicates::str::contains("ACL is enabled.")) 66 | .stdout(predicates::str::contains( 67 | "Authentication is enabled by default.", 68 | )) 69 | .stdout(predicates::str::contains("TLS is enabled.")) 70 | .stdout(predicates::str::contains( 71 | "Listening on: `https://127.0.0.1:8001`", 72 | )) 73 | .stdout(predicates::str::contains("Shutting down gracefully ...")) 74 | .success(); 75 | 76 | Ok(()) 77 | } 78 | 79 | /// Use configured value 80 | #[rstest] 81 | #[file_serial] 82 | #[ignore = "FIXME: This test doesn't run in CI because it needs to bind to a port."] 83 | fn start_with_config_no_args(setup: Result) -> Result<()> { 84 | let mut config = RusticServerConfig::default(); 85 | config.server.listen = Some(SocketAddr::from(([127, 0, 0, 1], 8081))); 86 | config.storage.quota = Some(1000); 87 | config.acl.acl_path = Some(PathBuf::from("tests/fixtures/test_data/acl.toml")); 88 | config.auth.htpasswd_file = Some(PathBuf::from("tests/fixtures/test_data/.htpasswd")); 89 | 90 | let assert = setup? 91 | .test_mode_args() 92 | .config(&config) 93 | .arg("serve") 94 | .assert(); 95 | 96 | assert 97 | .stdout(predicates::str::contains("Using configuration file:")) 98 | .stdout(predicates::str::contains("ACL is enabled.")) 99 | .stdout(predicates::str::contains("TLS is disabled.")) 100 | .stdout(predicates::str::contains("Starting web server ...")) 101 | .stdout(predicates::str::contains( 102 | "Listening on: `http://127.0.0.1:8081`", 103 | )) 104 | .stdout(predicates::str::contains("Shutting down gracefully ...")) 105 | .success(); 106 | 107 | Ok(()) 108 | } 109 | 110 | /// Check merge precedence 111 | #[rstest] 112 | #[file_serial] 113 | #[ignore = "FIXME: This test doesn't run in CI because it needs to bind to a port."] 114 | fn start_with_config_and_args(setup: Result) -> Result<()> { 115 | let mut config = RusticServerConfig::default(); 116 | config.server.listen = Some(SocketAddr::from(([127, 0, 0, 1], 8081))); 117 | config.acl.acl_path = Some(PathBuf::from("tests/fixtures/test_data/acl.toml")); 118 | 119 | let assert = setup? 120 | .test_mode_args() 121 | .config(&config) 122 | .arg("serve") 123 | .args(["--listen", "127.0.0.1:8001"]) 124 | .args(["--htpasswd-file", "tests/fixtures/test_data/.htpasswd"]) 125 | .assert(); 126 | 127 | assert 128 | .stdout(predicates::str::contains("ACL is enabled.")) 129 | .stdout(predicates::str::contains( 130 | "Authentication is enabled by default.", 131 | )) 132 | .stdout(predicates::str::contains("TLS is disabled.")) 133 | .stdout(predicates::str::contains( 134 | "Listening on: `http://127.0.0.1:8001", 135 | )) 136 | .stdout(predicates::str::contains("Shutting down gracefully ...")) 137 | .success(); 138 | 139 | Ok(()) 140 | } 141 | 142 | // /// Override configured value with command-line argument 143 | // #[test] 144 | // fn start_with_config_and_args() { 145 | // let mut config = RusticServerConfig::default(); 146 | // config.hello.recipient = "configured recipient".to_owned(); 147 | 148 | // let mut runner = RUNNER.clone(); 149 | // let mut cmd = runner 150 | // .config(&config) 151 | // .args(&["start", "acceptance", "test"]) 152 | // .capture_stdout() 153 | // .run(); 154 | 155 | // cmd.stdout().expect_line("Hello, acceptance test!"); 156 | // cmd.wait().unwrap().expect_success(); 157 | // } 158 | 159 | #[rstest] 160 | fn test_version_no_args_passes(setup: Result) -> Result<()> { 161 | let assert = setup?.arg("--version").assert(); 162 | 163 | assert 164 | .stdout(predicates::str::contains(env!("CARGO_PKG_VERSION"))) 165 | .success(); 166 | 167 | Ok(()) 168 | } 169 | -------------------------------------------------------------------------------- /tests/integration/config.rs: -------------------------------------------------------------------------------- 1 | //! Support for writing config files and using them in tests 2 | // 3 | // Taken from https://github.com/iqlusioninc/abscissa/blob/091c84d388a8ec6a1e2d69b9f61cf4439c839de1/core/src/testing/config.rs 4 | // Licensed under Apache License Version 2.0, Copyright © 2018-2024 iqlusion 5 | // 6 | // The abscissa crate is distributed under the terms of the Apache License (Version 2.0). 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. 9 | // You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0 10 | // Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions 12 | // and limitations under the License. 13 | // 14 | // SPDX-License-Identifier: Apache-2.0 15 | // 16 | // Remove this file once the `abscissa` crate is updated to a version that exposes this functionality: 17 | // https://github.com/iqlusioninc/abscissa/pull/944 18 | 19 | use serde::Serialize; 20 | use std::{ 21 | env, 22 | ffi::OsStr, 23 | fs::{self, File, OpenOptions}, 24 | io::{self, Write}, 25 | path::{Path, PathBuf}, 26 | }; 27 | 28 | /// Number of times to attempt to create a file before giving up 29 | const FILE_CREATE_ATTEMPTS: usize = 1024; 30 | 31 | /// Configuration file RAII guard which deletes it on completion 32 | #[derive(Debug)] 33 | pub struct ConfigFile { 34 | /// Path to the config file 35 | path: PathBuf, 36 | } 37 | 38 | impl ConfigFile { 39 | /// Create a config file by serializing it to the given location 40 | pub fn create(app_name: &OsStr, config: &C) -> Self 41 | where 42 | C: Serialize, 43 | { 44 | let (path, mut file) = Self::open(app_name); 45 | 46 | let config_toml = toml::to_string_pretty(config) 47 | .unwrap_or_else(|e| panic!("error serializing config as TOML: {}", e)) 48 | .into_bytes(); 49 | 50 | file.write_all(&config_toml) 51 | .unwrap_or_else(|e| panic!("error writing config to {}: {}", path.display(), e)); 52 | 53 | Self { path } 54 | } 55 | 56 | /// Get path to the configuration file 57 | pub fn path(&self) -> &Path { 58 | self.path.as_ref() 59 | } 60 | 61 | /// Create a temporary filename for the config 62 | fn open(app_name: &OsStr) -> (PathBuf, File) { 63 | // TODO: fully `OsString`-based path building 64 | let filename_prefix = app_name.to_string_lossy().to_string(); 65 | 66 | for n in 0..FILE_CREATE_ATTEMPTS { 67 | let filename = format!("{}-{}.toml", &filename_prefix, n); 68 | let path = env::temp_dir().join(filename); 69 | 70 | match OpenOptions::new().write(true).create_new(true).open(&path) { 71 | Ok(file) => return (path, file), 72 | Err(e) => { 73 | if e.kind() == io::ErrorKind::AlreadyExists { 74 | continue; 75 | } else { 76 | panic!("couldn't create {}: {}", path.display(), e); 77 | } 78 | } 79 | } 80 | } 81 | 82 | panic!( 83 | "couldn't create {}.toml after {} attempts!", 84 | filename_prefix, FILE_CREATE_ATTEMPTS 85 | ) 86 | } 87 | } 88 | 89 | impl Drop for ConfigFile { 90 | fn drop(&mut self) { 91 | fs::remove_file(&self.path).unwrap_or_else(|e| { 92 | eprintln!("error removing {}: {}", self.path.display(), e); 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /tests/integration/main.rs: -------------------------------------------------------------------------------- 1 | mod _impl; 2 | mod acceptance; 3 | pub(crate) mod config; 4 | --------------------------------------------------------------------------------