mirror of
https://github.com/deuxfleurs-org/garage.git
synced 2026-08-14 08:03:11 +00:00
Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b275dc5000 | |||
| 0a48d504fe | |||
| c834f3f024 | |||
| a8d73682a4 | |||
| 8654eb19bf | |||
| 54ea412188 | |||
| 2ade8c86f6 | |||
| b15e2cbb6c | |||
| 0fd1b7342b | |||
| be16bc7a05 | |||
| bfaa1ca6b7 | |||
| de8eeab4ad | |||
| ae3f7ee76c | |||
| 2dc3a6dbbe | |||
| c6bc3f229b | |||
| bba9202f31 | |||
| a605a80806 | |||
| 539af12d21 | |||
| a2a9e3cec4 | |||
| 14274bc13c | |||
| bf4691d98a | |||
| ad151cb1dc | |||
| 3c20984a08 | |||
| e6e4e051a1 | |||
| 9b38cba6f3 | |||
| 4ef954d176 | |||
| 02498a93d0 | |||
| 4caad5425d | |||
| 9ec3f8cc3c | |||
| 14d2f2b18d | |||
| a7d845a999 | |||
| dd20e5d22a | |||
| 6906a4ff12 | |||
| 9053782d71 | |||
| c96be1a9a8 | |||
| 98e56490a1 | |||
| e791ccec8f | |||
| d605c4fed1 | |||
| 0ce5f7eb00 | |||
| 516255321f | |||
| f3b05ff771 | |||
| e254cc20e5 | |||
| 12f15c4c2b | |||
| 42c5d02cdf | |||
| 4689b10448 | |||
| 156b10ee65 | |||
| 8647ebf003 | |||
| 67d7c0769b | |||
| 09ed5ab8cc | |||
| a0ea28b0da | |||
| c5237c31e7 | |||
| f87943a39d | |||
| c0846c56fe | |||
| 1cb0ae10a8 | |||
| 1a8f74fc94 | |||
| 2191620af5 | |||
| bf27a3ec98 | |||
| f64ec6e542 | |||
| 6d38907dac | |||
| cfe8e8d45c | |||
| f6e805e7db | |||
| 45e10e55f9 | |||
| 730bfee753 | |||
| ccab0e4ae5 | |||
| abb60dcf7e | |||
| f8b0817ddc | |||
| 21c0dda16a | |||
| 658541d812 | |||
| c5df820e2c | |||
| a04d6cd5b8 | |||
| 44a896f9b5 | |||
| cee7560fc1 | |||
| 2f0c5ca220 | |||
| 6d798c640f |
Generated
+519
-568
File diff suppressed because it is too large
Load Diff
+17
-17
@@ -24,18 +24,18 @@ default-members = ["src/garage"]
|
||||
|
||||
# Internal Garage crates
|
||||
format_table = { version = "0.1.1", path = "src/format-table" }
|
||||
garage_api_common = { version = "1.0.1", path = "src/api/common" }
|
||||
garage_api_admin = { version = "1.0.1", path = "src/api/admin" }
|
||||
garage_api_s3 = { version = "1.0.1", path = "src/api/s3" }
|
||||
garage_api_k2v = { version = "1.0.1", path = "src/api/k2v" }
|
||||
garage_block = { version = "1.0.1", path = "src/block" }
|
||||
garage_db = { version = "1.0.1", path = "src/db", default-features = false }
|
||||
garage_model = { version = "1.0.1", path = "src/model", default-features = false }
|
||||
garage_net = { version = "1.0.1", path = "src/net" }
|
||||
garage_rpc = { version = "1.0.1", path = "src/rpc" }
|
||||
garage_table = { version = "1.0.1", path = "src/table" }
|
||||
garage_util = { version = "1.0.1", path = "src/util" }
|
||||
garage_web = { version = "1.0.1", path = "src/web" }
|
||||
garage_api_common = { version = "1.1.0", path = "src/api/common" }
|
||||
garage_api_admin = { version = "1.1.0", path = "src/api/admin" }
|
||||
garage_api_s3 = { version = "1.1.0", path = "src/api/s3" }
|
||||
garage_api_k2v = { version = "1.1.0", path = "src/api/k2v" }
|
||||
garage_block = { version = "1.1.0", path = "src/block" }
|
||||
garage_db = { version = "1.1.0", path = "src/db", default-features = false }
|
||||
garage_model = { version = "1.1.0", path = "src/model", default-features = false }
|
||||
garage_net = { version = "1.1.0", path = "src/net" }
|
||||
garage_rpc = { version = "1.1.0", path = "src/rpc" }
|
||||
garage_table = { version = "1.1.0", path = "src/table" }
|
||||
garage_util = { version = "1.1.0", path = "src/util" }
|
||||
garage_web = { version = "1.1.0", path = "src/web" }
|
||||
k2v-client = { version = "0.0.4", path = "src/k2v-client" }
|
||||
|
||||
# External crates from crates.io
|
||||
@@ -58,7 +58,6 @@ git-version = "0.3.4"
|
||||
hex = "0.4"
|
||||
hexdump = "0.1"
|
||||
hmac = "0.12"
|
||||
idna = "0.5"
|
||||
itertools = "0.12"
|
||||
ipnet = "2.9.0"
|
||||
lazy_static = "1.4"
|
||||
@@ -132,8 +131,8 @@ opentelemetry-contrib = "0.9"
|
||||
prometheus = "0.13"
|
||||
|
||||
# used by the k2v-client crate only
|
||||
aws-sigv4 = { version = "1.1" }
|
||||
hyper-rustls = { version = "0.26", features = ["http2"] }
|
||||
aws-sigv4 = { version = "1.1", default-features = false }
|
||||
hyper-rustls = { version = "0.26", default-features = false, features = ["http1", "http2", "ring", "rustls-native-certs"] }
|
||||
log = "0.4"
|
||||
thiserror = "1.0"
|
||||
|
||||
@@ -141,8 +140,9 @@ thiserror = "1.0"
|
||||
assert-json-diff = "2.0"
|
||||
rustc_version = "0.4.0"
|
||||
static_init = "1.0"
|
||||
aws-sdk-config = "1.13"
|
||||
aws-sdk-s3 = "1.14"
|
||||
aws-smithy-runtime = { version = "1.8", default-features = false, features = ["tls-rustls"] }
|
||||
aws-sdk-config = { version = "1.62", default-features = false }
|
||||
aws-sdk-s3 = { version = "1.79", default-features = false, features = ["rt-tokio"] }
|
||||
|
||||
[profile.dev]
|
||||
#lto = "thin" # disabled for now, adds 2-4 min to each CI build
|
||||
|
||||
@@ -687,7 +687,7 @@ paths:
|
||||
operationId: "GetBucketInfo"
|
||||
summary: "Get a bucket"
|
||||
description: |
|
||||
Given a bucket identifier (`id`) or a global alias (`alias`), get its information.
|
||||
Given a bucket identifier (`id`) or a global alias (`globalAlias`), get its information.
|
||||
It includes its aliases, its web configuration, keys that have some permissions
|
||||
on it, some statistics (number of objects, size), number of dangling multipart uploads,
|
||||
and its quotas (if any).
|
||||
@@ -701,7 +701,7 @@ paths:
|
||||
example: "b4018dc61b27ccb5c64ec1b24f53454bbbd180697c758c4d47a22a8921864a87"
|
||||
schema:
|
||||
type: string
|
||||
- name: alias
|
||||
- name: globalAlias
|
||||
in: query
|
||||
description: |
|
||||
The exact global alias of one of the existing buckets.
|
||||
|
||||
@@ -69,7 +69,7 @@ $CONFIG = array(
|
||||
'hostname' => '127.0.0.1', // Can also be a domain name, eg. garage.example.com
|
||||
'port' => 3900, // Put your reverse proxy port or your S3 API port
|
||||
'use_ssl' => false, // Set it to true if you have a TLS enabled reverse proxy
|
||||
'region' => 'garage', // Garage has only one region named "garage"
|
||||
'region' => 'garage', // Garage default region is named "garage", edit according to your cluster config
|
||||
'use_path_style' => true // Garage supports only path style, must be set to true
|
||||
],
|
||||
],
|
||||
@@ -135,7 +135,7 @@ bucket but doesn't also know the secret encryption key.
|
||||
*Click on the picture to zoom*
|
||||
|
||||
Add a new external storage. Put what you want in "folder name" (eg. "shared"). Select "Amazon S3". Keep "Access Key" for the Authentication field.
|
||||
In Configuration, put your bucket name (eg. nextcloud), the host (eg. 127.0.0.1), the port (eg. 3900 or 443), the region (garage). Tick the SSL box if you have put an HTTPS proxy in front of garage. You must tick the "Path access" box and you must leave the "Legacy authentication (v2)" box empty. Put your Key ID (eg. GK...) and your Secret Key in the last two input boxes. Finally click on the tick symbol on the right of your screen.
|
||||
In Configuration, put your bucket name (eg. nextcloud), the host (eg. 127.0.0.1), the port (eg. 3900 or 443), the region ("garage" if you use the default, or the one your configured in your `garage.toml`). Tick the SSL box if you have put an HTTPS proxy in front of garage. You must tick the "Path access" box and you must leave the "Legacy authentication (v2)" box empty. Put your Key ID (eg. GK...) and your Secret Key in the last two input boxes. Finally click on the tick symbol on the right of your screen.
|
||||
|
||||
Now go to your "Files" app and a new "linked folder" has appeared with the name you chose earlier (eg. "shared").
|
||||
|
||||
@@ -238,7 +238,7 @@ object_storage:
|
||||
# Put localhost only if you have a garage instance running on that node
|
||||
endpoint: 'http://localhost:3900' # or "garage.example.com" if you have TLS on port 443
|
||||
|
||||
# Garage supports only one region for now, named garage
|
||||
# Garage default region is named "garage", edit according to your config
|
||||
region: 'garage'
|
||||
|
||||
credentials:
|
||||
@@ -441,7 +441,7 @@ media_storage_providers:
|
||||
store_synchronous: True # do we want to wait that the file has been written before returning?
|
||||
config:
|
||||
bucket: matrix # the name of our bucket, we chose matrix earlier
|
||||
region_name: garage # only "garage" is supported for the region field
|
||||
region_name: garage # "garage" by default, edit according to your cluster config
|
||||
endpoint_url: http://localhost:3900 # the path to the S3 endpoint
|
||||
access_key_id: "GKxxx" # your Key ID
|
||||
secret_access_key: "xxxx" # your Secret Key
|
||||
|
||||
@@ -26,6 +26,13 @@ Or deploy with custom values:
|
||||
helm install --create-namespace --namespace garage garage ./garage -f values.override.yaml
|
||||
```
|
||||
|
||||
If you want to manage the CustomRessourceDefinition used by garage for its `kubernetes_discovery` outside of the helm chart, add `garage.kubernetesSkipCrd: true` to your custom values and use the kustomization before deploying the helm chart:
|
||||
|
||||
```bash
|
||||
kubectl apply -k ../k8s/crd
|
||||
helm install --create-namespace --namespace garage garage ./garage -f values.override.yaml
|
||||
```
|
||||
|
||||
After deploying, cluster layout must be configured manually as described in [Creating a cluster layout](@/documentation/quick-start/_index.md#creating-a-cluster-layout). Use the following command to access garage CLI:
|
||||
|
||||
```bash
|
||||
@@ -86,3 +93,62 @@ helm delete --namespace garage garage
|
||||
```
|
||||
|
||||
Note that this will leave behind custom CRD `garagenodes.deuxfleurs.fr`, which must be removed manually if desired.
|
||||
|
||||
## Increase PVC size on running Garage instances
|
||||
|
||||
Since the Garage Helm chart creates the data and meta PVC based on `StatefulSet` templates, increasing the PVC size can be a bit tricky.
|
||||
|
||||
### Confirm the `StorageClass` used for Garage supports volume expansion
|
||||
|
||||
Confirm the storage class used for garage.
|
||||
|
||||
```bash
|
||||
kubectl -n garage get pvc
|
||||
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
|
||||
data-garage-0 Bound pvc-080360c9-8ce3-4acf-8579-1701e57b7f3f 30Gi RWO longhorn-local <unset> 77d
|
||||
data-garage-1 Bound pvc-ab8ba697-6030-4fc7-ab3c-0d6df9e3dbc0 30Gi RWO longhorn-local <unset> 5d8h
|
||||
data-garage-2 Bound pvc-3ab37551-0231-4604-986d-136d0fd950ec 30Gi RWO longhorn-local <unset> 5d5h
|
||||
meta-garage-0 Bound pvc-3b457302-3023-4169-846e-c928c5f2ea65 3Gi RWO longhorn-local <unset> 77d
|
||||
meta-garage-1 Bound pvc-49ace2b9-5c85-42df-9247-51c4cf64b460 3Gi RWO longhorn-local <unset> 5d8h
|
||||
meta-garage-2 Bound pvc-99e2e50f-42b4-4128-ae2f-b52629259723 3Gi RWO longhorn-local <unset> 5d5h
|
||||
```
|
||||
|
||||
In this case, the storage class is `longhorn-local`. Now, check if `ALLOWVOLUMEEXPANSION` is true for the used `StorageClass`.
|
||||
|
||||
```bash
|
||||
kubectl get storageclasses.storage.k8s.io longhorn-local
|
||||
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
|
||||
longhorn-local driver.longhorn.io Delete Immediate true 103d
|
||||
```
|
||||
|
||||
If your `StorageClass` does not support volume expansion, double check if you can enable it. Otherwise, your only real option is to spin up a new Garage cluster with increased size and migrate all data over.
|
||||
|
||||
If your `StorageClass` supports expansion, you are free to continue.
|
||||
|
||||
### Increase the size of the PVCs
|
||||
|
||||
Increase the size of all PVCs to your desired size.
|
||||
|
||||
```bash
|
||||
kubectl -n garage edit pvc data-garage-0
|
||||
kubectl -n garage edit pvc data-garage-1
|
||||
kubectl -n garage edit pvc data-garage-2
|
||||
kubectl -n garage edit pvc meta-garage-0
|
||||
kubectl -n garage edit pvc meta-garage-1
|
||||
kubectl -n garage edit pvc meta-garage-2
|
||||
```
|
||||
|
||||
### Increase the size of the `StatefulSet` PVC template
|
||||
|
||||
This is an optional step, but if not done, future instances of Garage will be created with the original size from the template.
|
||||
|
||||
```bash
|
||||
kubectl -n garage delete sts --cascade=orphan garage
|
||||
statefulset.apps "garage" deleted
|
||||
```
|
||||
|
||||
This will remove the Garage `StatefulSet` but leave the pods running. It may seem destructive but needs to be done this way since edits to the size of PVC templates are prohibited.
|
||||
|
||||
### Redeploy the `StatefulSet`
|
||||
|
||||
Now the size of future PVCs can be increased, and the Garage Helm chart can be upgraded. The new `StatefulSet` should take ownership of the orphaned pods again.
|
||||
|
||||
@@ -96,14 +96,14 @@ to store 2 TB of data in total.
|
||||
## Get a Docker image
|
||||
|
||||
Our docker image is currently named `dxflrs/garage` and is stored on the [Docker Hub](https://hub.docker.com/r/dxflrs/garage/tags?page=1&ordering=last_updated).
|
||||
We encourage you to use a fixed tag (eg. `v1.0.1`) and not the `latest` tag.
|
||||
For this example, we will use the latest published version at the time of the writing which is `v1.0.1` but it's up to you
|
||||
We encourage you to use a fixed tag (eg. `v1.1.0`) and not the `latest` tag.
|
||||
For this example, we will use the latest published version at the time of the writing which is `v1.1.0` but it's up to you
|
||||
to check [the most recent versions on the Docker Hub](https://hub.docker.com/r/dxflrs/garage/tags?page=1&ordering=last_updated).
|
||||
|
||||
For example:
|
||||
|
||||
```
|
||||
sudo docker pull dxflrs/garage:v1.0.1
|
||||
sudo docker pull dxflrs/garage:v1.1.0
|
||||
```
|
||||
|
||||
## Deploying and configuring Garage
|
||||
@@ -171,7 +171,7 @@ docker run \
|
||||
-v /etc/garage.toml:/etc/garage.toml \
|
||||
-v /var/lib/garage/meta:/var/lib/garage/meta \
|
||||
-v /var/lib/garage/data:/var/lib/garage/data \
|
||||
dxflrs/garage:v1.0.1
|
||||
dxflrs/garage:v1.1.0
|
||||
```
|
||||
|
||||
With this command line, Garage should be started automatically at each boot.
|
||||
@@ -185,7 +185,7 @@ If you want to use `docker-compose`, you may use the following `docker-compose.y
|
||||
version: "3"
|
||||
services:
|
||||
garage:
|
||||
image: dxflrs/garage:v1.0.1
|
||||
image: dxflrs/garage:v1.1.0
|
||||
network_mode: "host"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
@@ -28,6 +28,7 @@ StateDirectory=garage
|
||||
DynamicUser=true
|
||||
ProtectHome=true
|
||||
NoNewPrivileges=true
|
||||
LimitNOFILE=42000
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -71,7 +71,7 @@ The entire procedure would look something like this:
|
||||
|
||||
2. Take each node offline individually to back up its metadata folder, bring them back online once the backup is done.
|
||||
You can do all of the nodes in a single zone at once as that won't impact global cluster availability.
|
||||
Do not try to make a backup of the metadata folder of a running node.
|
||||
Do not try to manually copy the metadata folder of a running node.
|
||||
|
||||
**Since Garage v0.9.4,** you can use the `garage meta snapshot --all` command
|
||||
to take a simultaneous snapshot of the metadata database files of all your
|
||||
|
||||
@@ -129,10 +129,10 @@ docker run \
|
||||
-d \
|
||||
--name garaged \
|
||||
-p 3900:3900 -p 3901:3901 -p 3902:3902 -p 3903:3903 \
|
||||
-v /etc/garage.toml:/path/to/garage.toml \
|
||||
-v /var/lib/garage/meta:/path/to/garage/meta \
|
||||
-v /var/lib/garage/data:/path/to/garage/data \
|
||||
dxflrs/garage:v0.9.4
|
||||
-v /path/to/garage.toml:/etc/garage.toml \
|
||||
-v /path/to/garage/meta:/var/lib/garage/meta \
|
||||
-v /path/to/garage/data:/var/lib/garage/data \
|
||||
dxflrs/garage:v1.1.0
|
||||
```
|
||||
|
||||
Under Linux, you can substitute `--network host` for `-p 3900:3900 -p 3901:3901 -p 3902:3902 -p 3903:3903`
|
||||
|
||||
@@ -46,6 +46,7 @@ bootstrap_peers = [
|
||||
"212fd62eeaca72c122b45a7f4fa0f55e012aa5e24ac384a72a3016413fa724ff@[fc00:F::1]:3901",
|
||||
]
|
||||
|
||||
allow_punycode = false
|
||||
|
||||
[consul_discovery]
|
||||
api = "catalog"
|
||||
@@ -115,6 +116,7 @@ Top-level configuration options:
|
||||
[`rpc_public_addr`](#rpc_public_addr),
|
||||
[`rpc_public_addr_subnet`](#rpc_public_addr_subnet)
|
||||
[`rpc_secret`/`rpc_secret_file`](#rpc_secret).
|
||||
[`allow_punycode`](#allow_punycode).
|
||||
|
||||
The `[consul_discovery]` section:
|
||||
[`api`](#consul_api),
|
||||
@@ -154,7 +156,7 @@ The `[admin]` section:
|
||||
The following configuration parameter must be specified as an environment
|
||||
variable, it does not exist in the configuration file:
|
||||
|
||||
- `GARAGE_LOG_TO_SYSLOG` (since v0.9.4): set this to `1` or `true` to make the
|
||||
- `GARAGE_LOG_TO_SYSLOG` (since `v0.9.4`): set this to `1` or `true` to make the
|
||||
Garage daemon send its logs to `syslog` (using the libc `syslog` function)
|
||||
instead of printing to stderr.
|
||||
|
||||
@@ -300,7 +302,7 @@ data_dir = [
|
||||
See [the dedicated documentation page](@/documentation/operations/multi-hdd.md)
|
||||
on how to operate Garage in such a setup.
|
||||
|
||||
#### `metadata_snapshots_dir` (since Garage `v1.0.2`) {#metadata_snapshots_dir}
|
||||
#### `metadata_snapshots_dir` (since `v1.1.0`) {#metadata_snapshots_dir}
|
||||
|
||||
The directory in which Garage will store metadata snapshots when it
|
||||
performs a snapshot of the metadata database, either when instructed to do
|
||||
@@ -416,7 +418,7 @@ at the cost of a moderate drop in write performance.
|
||||
Similarly to `metatada_fsync`, this is likely not necessary
|
||||
if geographical replication is used.
|
||||
|
||||
#### `metadata_auto_snapshot_interval` (since Garage v0.9.4) {#metadata_auto_snapshot_interval}
|
||||
#### `metadata_auto_snapshot_interval` (since `v0.9.4`) {#metadata_auto_snapshot_interval}
|
||||
|
||||
If this value is set, Garage will automatically take a snapshot of the metadata
|
||||
DB file at a regular interval and save it in the metadata directory.
|
||||
@@ -453,7 +455,7 @@ you should delete it from the data directory and then call `garage repair
|
||||
blocks` on the node to ensure that it re-obtains a copy from another node on
|
||||
the network.
|
||||
|
||||
#### `use_local_tz` {#use_local_tz}
|
||||
#### `use_local_tz` (since `v1.1.0`) {#use_local_tz}
|
||||
|
||||
By default, Garage runs the lifecycle worker every day at midnight in UTC. Set the
|
||||
`use_local_tz` configuration value to `true` if you want Garage to run the
|
||||
@@ -475,7 +477,7 @@ files will remain available. This however means that chunks from existing files
|
||||
will not be deduplicated with chunks from newly uploaded files, meaning you
|
||||
might use more storage space that is optimally possible.
|
||||
|
||||
#### `block_ram_buffer_max` (since v0.9.4) {#block_ram_buffer_max}
|
||||
#### `block_ram_buffer_max` (since `v0.9.4`) {#block_ram_buffer_max}
|
||||
|
||||
A limit on the total size of data blocks kept in RAM by S3 API nodes awaiting
|
||||
to be sent to storage nodes asynchronously.
|
||||
@@ -562,7 +564,7 @@ the node, even in the case of a NAT: the NAT should be configured to forward the
|
||||
port number to the same internal port nubmer. This means that if you have several nodes running
|
||||
behind a NAT, they should each use a different RPC port number.
|
||||
|
||||
#### `rpc_bind_outgoing`(since v0.9.2) {#rpc_bind_outgoing}
|
||||
#### `rpc_bind_outgoing` (since `v0.9.2`) {#rpc_bind_outgoing}
|
||||
|
||||
If enabled, pre-bind all sockets for outgoing connections to the same IP address
|
||||
used for listening (the IP address specified in `rpc_bind_addr`) before
|
||||
@@ -604,7 +606,7 @@ be obtained by running `garage node id` and then included directly in the
|
||||
key will be returned by `garage node id` and you will have to add the IP
|
||||
yourself.
|
||||
|
||||
### `allow_world_readable_secrets` or `GARAGE_ALLOW_WORLD_READABLE_SECRETS` (env) {#allow_world_readable_secrets}
|
||||
#### `allow_world_readable_secrets` or `GARAGE_ALLOW_WORLD_READABLE_SECRETS` (env) {#allow_world_readable_secrets}
|
||||
|
||||
Garage checks the permissions of your secret files to make sure they're not
|
||||
world-readable. In some cases, the check might fail and consider your files as
|
||||
@@ -616,6 +618,13 @@ permission verification.
|
||||
Alternatively, you can set the `GARAGE_ALLOW_WORLD_READABLE_SECRETS`
|
||||
environment variable to `true` to bypass the permissions check.
|
||||
|
||||
#### `allow_punycode` {#allow_punycode}
|
||||
|
||||
Allow creating buckets with names containing punycode. When used for buckets served
|
||||
as websites, this allows using almost any unicode character in the domain name.
|
||||
|
||||
Default to `false`.
|
||||
|
||||
### The `[consul_discovery]` section
|
||||
|
||||
Garage supports discovering other nodes of the cluster using Consul. For this
|
||||
|
||||
@@ -70,7 +70,7 @@ Example response body:
|
||||
```json
|
||||
{
|
||||
"node": "b10c110e4e854e5aa3f4637681befac755154b20059ec163254ddbfae86b09df",
|
||||
"garageVersion": "v1.0.1",
|
||||
"garageVersion": "v1.1.0",
|
||||
"garageFeatures": [
|
||||
"k2v",
|
||||
"lmdb",
|
||||
|
||||
@@ -11,7 +11,7 @@ PATH="${GARAGE_DEBUG}:${GARAGE_RELEASE}:${NIX_RELEASE}:$PATH"
|
||||
FANCYCOLORS=("41m" "42m" "44m" "45m" "100m" "104m")
|
||||
|
||||
export RUST_BACKTRACE=1
|
||||
export RUST_LOG=garage=info,garage_api=debug
|
||||
export RUST_LOG=garage=info,garage_api_common=debug,garage_api_s3=debug
|
||||
MAIN_LABEL="\e[${FANCYCOLORS[0]}[main]\e[49m"
|
||||
|
||||
if [ -z "$GARAGE_BIN" ]; then
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Garage helm3 chart
|
||||
|
||||
Documentation is located [here](/doc/book/cookbook/kubernetes.md).
|
||||
Documentation is located [here](https://garagehq.deuxfleurs.fr/documentation/cookbook/kubernetes/).
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
apiVersion: v2
|
||||
name: garage
|
||||
description: S3-compatible object store for small self-hosted geo-distributed deployments
|
||||
|
||||
# A chart can be either an 'application' or a 'library' chart.
|
||||
#
|
||||
# Application charts are a collection of templates that can be packaged into versioned archives
|
||||
# to be deployed.
|
||||
#
|
||||
# Library charts provide useful utilities or functions for the chart developer. They're included as
|
||||
# a dependency of application charts to inject those utilities and functions into the rendering
|
||||
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
|
||||
type: application
|
||||
version: 0.7.0
|
||||
appVersion: "v1.1.0"
|
||||
home: https://garagehq.deuxfleurs.fr/
|
||||
icon: https://garagehq.deuxfleurs.fr/images/garage-logo.svg
|
||||
|
||||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 0.6.0
|
||||
keywords:
|
||||
- geo-distributed
|
||||
- read-after-write-consistency
|
||||
- s3-compatible
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
||||
# It is recommended to use it with quotes.
|
||||
appVersion: "v1.0.1"
|
||||
sources:
|
||||
- https://git.deuxfleurs.fr/Deuxfleurs/garage.git
|
||||
|
||||
maintainers: []
|
||||
@@ -1,9 +1,15 @@
|
||||
# garage
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
S3-compatible object store for small self-hosted geo-distributed deployments
|
||||
|
||||
**Homepage:** <https://garagehq.deuxfleurs.fr/>
|
||||
|
||||
## Source Code
|
||||
|
||||
* <https://git.deuxfleurs.fr/Deuxfleurs/garage.git>
|
||||
|
||||
## Values
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
@@ -23,6 +29,7 @@ S3-compatible object store for small self-hosted geo-distributed deployments
|
||||
| garage.existingConfigMap | string | `""` | if not empty string, allow using an existing ConfigMap for the garage.toml, if set, ignores garage.toml |
|
||||
| garage.garageTomlString | string | `""` | String Template for the garage configuration if set, ignores above values. Values can be templated, see https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/ |
|
||||
| garage.kubernetesSkipCrd | bool | `false` | Set to true if you want to use k8s discovery but install the CRDs manually outside of the helm chart, for example if you operate at namespace level without cluster ressources |
|
||||
| garage.metadataAutoSnapshotInterval | string | `""` | If this value is set, Garage will automatically take a snapshot of the metadata DB file at a regular interval and save it in the metadata directory. https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#metadata_auto_snapshot_interval |
|
||||
| garage.replicationMode | string | `"3"` | Default to 3 replicas, see the replication_mode section at https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#replication-mode |
|
||||
| garage.rpcBindAddr | string | `"[::]:3901"` | |
|
||||
| garage.rpcSecret | string | `""` | If not given, a random secret will be generated and stored in a Secret object |
|
||||
@@ -49,6 +56,7 @@ S3-compatible object store for small self-hosted geo-distributed deployments
|
||||
| initImage.pullPolicy | string | `"IfNotPresent"` | |
|
||||
| initImage.repository | string | `"busybox"` | |
|
||||
| initImage.tag | string | `"stable"` | |
|
||||
| livenessProbe | object | `{}` | Specifies a livenessProbe |
|
||||
| monitoring.metrics.enabled | bool | `false` | If true, a service for monitoring is created with a prometheus.io/scrape annotation |
|
||||
| monitoring.metrics.serviceMonitor.enabled | bool | `false` | If true, a ServiceMonitor CRD is created for a prometheus operator https://github.com/coreos/prometheus-operator |
|
||||
| monitoring.metrics.serviceMonitor.interval | string | `"15s"` | |
|
||||
@@ -71,6 +79,7 @@ S3-compatible object store for small self-hosted geo-distributed deployments
|
||||
| podSecurityContext.runAsGroup | int | `1000` | |
|
||||
| podSecurityContext.runAsNonRoot | bool | `true` | |
|
||||
| podSecurityContext.runAsUser | int | `1000` | |
|
||||
| readinessProbe | object | `{}` | Specifies a readinessProbe |
|
||||
| resources | object | `{}` | |
|
||||
| securityContext.capabilities | object | `{"drop":["ALL"]}` | The default security context is heavily restricted, feel free to tune it to your requirements |
|
||||
| securityContext.readOnlyRootFilesystem | bool | `true` | |
|
||||
|
||||
@@ -19,6 +19,10 @@ data:
|
||||
|
||||
compression_level = {{ .Values.garage.compressionLevel }}
|
||||
|
||||
{{- if .Values.garage.metadataAutoSnapshotInterval }}
|
||||
metadata_auto_snapshot_interval = {{ .Values.garage.metadataAutoSnapshotInterval | quote }}
|
||||
{{- end }}
|
||||
|
||||
rpc_bind_addr = "{{ .Values.garage.rpcBindAddr }}"
|
||||
# rpc_secret will be populated by the init container from a k8s secret object
|
||||
rpc_secret = "__RPC_SECRET_REPLACE__"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{{- if eq .Values.deployment.kind "StatefulSet" -}}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "garage.fullname" . }}-headless
|
||||
labels:
|
||||
{{- include "garage.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
clusterIP: None
|
||||
ports:
|
||||
- port: {{ .Values.service.s3.api.port }}
|
||||
targetPort: 3900
|
||||
protocol: TCP
|
||||
name: s3-api
|
||||
- port: {{ .Values.service.s3.web.port }}
|
||||
targetPort: 3902
|
||||
protocol: TCP
|
||||
name: s3-web
|
||||
selector:
|
||||
{{- include "garage.selectorLabels" . | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -10,12 +10,11 @@ spec:
|
||||
{{- include "garage.selectorLabels" . | nindent 6 }}
|
||||
{{- if eq .Values.deployment.kind "StatefulSet" }}
|
||||
replicas: {{ .Values.deployment.replicaCount }}
|
||||
serviceName: {{ include "garage.fullname" . }}
|
||||
serviceName: {{ include "garage.fullname" . }}-headless
|
||||
podManagementPolicy: {{ .Values.deployment.podManagementPolicy }}
|
||||
{{- end }}
|
||||
template:
|
||||
metadata:
|
||||
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
@@ -79,15 +78,14 @@ spec:
|
||||
{{- with .Values.extraVolumeMounts }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
# TODO
|
||||
# livenessProbe:
|
||||
# httpGet:
|
||||
# path: /
|
||||
# port: 3900
|
||||
# readinessProbe:
|
||||
# httpGet:
|
||||
# path: /
|
||||
# port: 3900
|
||||
{{- with .Values.livenessProbe }}
|
||||
livenessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.readinessProbe }}
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumes:
|
||||
|
||||
@@ -21,6 +21,10 @@ garage:
|
||||
# https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#compression-level
|
||||
compressionLevel: "1"
|
||||
|
||||
# -- If this value is set, Garage will automatically take a snapshot of the metadata DB file at a regular interval and save it in the metadata directory.
|
||||
# https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#metadata_auto_snapshot_interval
|
||||
metadataAutoSnapshotInterval: ""
|
||||
|
||||
rpcBindAddr: "[::]:3901"
|
||||
# -- If not given, a random secret will be generated and stored in a Secret object
|
||||
rpcSecret: ""
|
||||
@@ -191,6 +195,21 @@ resources: {}
|
||||
# cpu: 100m
|
||||
# memory: 512Mi
|
||||
|
||||
# -- Specifies a livenessProbe
|
||||
livenessProbe: {}
|
||||
#httpGet:
|
||||
# path: /health
|
||||
# port: 3903
|
||||
#initialDelaySeconds: 5
|
||||
#periodSeconds: 30
|
||||
# -- Specifies a readinessProbe
|
||||
readinessProbe: {}
|
||||
#httpGet:
|
||||
# path: /health
|
||||
# port: 3903
|
||||
#initialDelaySeconds: 5
|
||||
#periodSeconds: 30
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
name: garagenodes.deuxfleurs.fr
|
||||
spec:
|
||||
conversion:
|
||||
strategy: None
|
||||
group: deuxfleurs.fr
|
||||
names:
|
||||
kind: GarageNode
|
||||
listKind: GarageNodeList
|
||||
plural: garagenodes
|
||||
singular: garagenode
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- name: v1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: Auto-generated derived type for Node via `CustomResource`
|
||||
properties:
|
||||
spec:
|
||||
properties:
|
||||
address:
|
||||
format: ip
|
||||
type: string
|
||||
hostname:
|
||||
type: string
|
||||
port:
|
||||
format: uint16
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- address
|
||||
- hostname
|
||||
- port
|
||||
type: object
|
||||
required:
|
||||
- spec
|
||||
title: GarageNode
|
||||
type: object
|
||||
served: true
|
||||
storage: true
|
||||
subresources: {}
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
resources:
|
||||
- garagenodes.deuxfleurs.fr.yaml
|
||||
@@ -1,12 +1,12 @@
|
||||
[package]
|
||||
name = "garage_api_admin"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||
edition = "2018"
|
||||
license = "AGPL-3.0"
|
||||
description = "Admin API server crate for the Garage object store"
|
||||
repository = "https://git.deuxfleurs.fr/Deuxfleurs/garage"
|
||||
readme = "../../README.md"
|
||||
readme = "../../../README.md"
|
||||
|
||||
[lib]
|
||||
path = "lib.rs"
|
||||
|
||||
@@ -277,7 +277,7 @@ pub async fn handle_create_bucket(
|
||||
let helper = garage.locked_helper().await;
|
||||
|
||||
if let Some(ga) = &req.global_alias {
|
||||
if !is_valid_bucket_name(ga) {
|
||||
if !is_valid_bucket_name(ga, garage.config.allow_punycode) {
|
||||
return Err(Error::bad_request(format!(
|
||||
"{}: {}",
|
||||
ga, INVALID_BUCKET_NAME_MESSAGE
|
||||
@@ -292,7 +292,7 @@ pub async fn handle_create_bucket(
|
||||
}
|
||||
|
||||
if let Some(la) = &req.local_alias {
|
||||
if !is_valid_bucket_name(&la.alias) {
|
||||
if !is_valid_bucket_name(&la.alias, garage.config.allow_punycode) {
|
||||
return Err(Error::bad_request(format!(
|
||||
"{}: {}",
|
||||
la.alias, INVALID_BUCKET_NAME_MESSAGE
|
||||
@@ -382,7 +382,7 @@ pub async fn handle_delete_bucket(
|
||||
for ((key_id, alias), _, active) in state.local_aliases.items().iter() {
|
||||
if *active {
|
||||
helper
|
||||
.unset_local_bucket_alias(bucket.id, key_id, alias)
|
||||
.purge_local_bucket_alias(bucket.id, key_id, alias)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
[package]
|
||||
name = "garage_api_common"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||
edition = "2018"
|
||||
license = "AGPL-3.0"
|
||||
description = "Common functions for the API server crates for the Garage object store"
|
||||
repository = "https://git.deuxfleurs.fr/Deuxfleurs/garage"
|
||||
readme = "../../README.md"
|
||||
readme = "../../../README.md"
|
||||
|
||||
[lib]
|
||||
path = "lib.rs"
|
||||
@@ -18,16 +18,20 @@ garage_model.workspace = true
|
||||
garage_table.workspace = true
|
||||
garage_util.workspace = true
|
||||
|
||||
base64.workspace = true
|
||||
bytes.workspace = true
|
||||
chrono.workspace = true
|
||||
crc32fast.workspace = true
|
||||
crc32c.workspace = true
|
||||
crypto-common.workspace = true
|
||||
err-derive.workspace = true
|
||||
hex.workspace = true
|
||||
hmac.workspace = true
|
||||
idna.workspace = true
|
||||
md-5.workspace = true
|
||||
tracing.workspace = true
|
||||
nom.workspace = true
|
||||
pin-project.workspace = true
|
||||
sha1.workspace = true
|
||||
sha2.workspace = true
|
||||
|
||||
futures.workspace = true
|
||||
|
||||
@@ -14,9 +14,9 @@ use crate::common_error::{
|
||||
};
|
||||
use crate::helpers::*;
|
||||
|
||||
pub fn find_matching_cors_rule<'a>(
|
||||
pub fn find_matching_cors_rule<'a, B>(
|
||||
bucket_params: &'a BucketParams,
|
||||
req: &Request<impl Body>,
|
||||
req: &Request<B>,
|
||||
) -> Result<Option<&'a GarageCorsRule>, CommonError> {
|
||||
if let Some(cors_config) = bucket_params.cors_config.get() {
|
||||
if let Some(origin) = req.headers().get("Origin") {
|
||||
@@ -132,8 +132,8 @@ pub async fn handle_options_api(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_options_for_bucket(
|
||||
req: &Request<IncomingBody>,
|
||||
pub fn handle_options_for_bucket<B>(
|
||||
req: &Request<B>,
|
||||
bucket_params: &BucketParams,
|
||||
) -> Result<Response<EmptyBody>, CommonError> {
|
||||
let origin = req
|
||||
|
||||
@@ -8,7 +8,6 @@ use hyper::{
|
||||
body::{Body, Bytes},
|
||||
Request, Response,
|
||||
};
|
||||
use idna::domain_to_unicode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use garage_model::bucket_table::BucketParams;
|
||||
@@ -97,7 +96,7 @@ pub fn authority_to_host(authority: &str) -> Result<String, Error> {
|
||||
authority
|
||||
))),
|
||||
};
|
||||
authority.map(|h| domain_to_unicode(h).0)
|
||||
authority.map(|h| h.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// Extract the bucket name and the key name from an HTTP path and possibly a bucket provided in
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use futures::prelude::*;
|
||||
use futures::stream::BoxStream;
|
||||
use http_body_util::{BodyExt, StreamBody};
|
||||
use hyper::body::{Bytes, Frame};
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task;
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::signature::checksum::*;
|
||||
|
||||
pub struct ReqBody {
|
||||
// why need mutex to be sync??
|
||||
pub(crate) stream: Mutex<BoxStream<'static, Result<Frame<Bytes>, Error>>>,
|
||||
pub(crate) checksummer: Checksummer,
|
||||
pub(crate) expected_checksums: ExpectedChecksums,
|
||||
pub(crate) trailer_algorithm: Option<ChecksumAlgorithm>,
|
||||
}
|
||||
|
||||
pub type StreamingChecksumReceiver = task::JoinHandle<Result<Checksums, Error>>;
|
||||
|
||||
impl ReqBody {
|
||||
pub fn add_expected_checksums(&mut self, more: ExpectedChecksums) {
|
||||
if more.md5.is_some() {
|
||||
self.expected_checksums.md5 = more.md5;
|
||||
}
|
||||
if more.sha256.is_some() {
|
||||
self.expected_checksums.sha256 = more.sha256;
|
||||
}
|
||||
if more.extra.is_some() {
|
||||
self.expected_checksums.extra = more.extra;
|
||||
}
|
||||
self.checksummer.add_expected(&self.expected_checksums);
|
||||
}
|
||||
|
||||
pub fn add_md5(&mut self) {
|
||||
self.checksummer.add_md5();
|
||||
}
|
||||
|
||||
// ============ non-streaming =============
|
||||
|
||||
pub async fn json<T: for<'a> Deserialize<'a>>(self) -> Result<T, Error> {
|
||||
let body = self.collect().await?;
|
||||
let resp: T = serde_json::from_slice(&body).ok_or_bad_request("Invalid JSON")?;
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
pub async fn collect(self) -> Result<Bytes, Error> {
|
||||
self.collect_with_checksums().await.map(|(b, _)| b)
|
||||
}
|
||||
|
||||
pub async fn collect_with_checksums(mut self) -> Result<(Bytes, Checksums), Error> {
|
||||
let stream: BoxStream<_> = self.stream.into_inner().unwrap();
|
||||
let bytes = BodyExt::collect(StreamBody::new(stream)).await?.to_bytes();
|
||||
|
||||
self.checksummer.update(&bytes);
|
||||
let checksums = self.checksummer.finalize();
|
||||
checksums.verify(&self.expected_checksums)?;
|
||||
|
||||
Ok((bytes, checksums))
|
||||
}
|
||||
|
||||
// ============ streaming =============
|
||||
|
||||
pub fn streaming_with_checksums(
|
||||
self,
|
||||
) -> (
|
||||
BoxStream<'static, Result<Bytes, Error>>,
|
||||
StreamingChecksumReceiver,
|
||||
) {
|
||||
let Self {
|
||||
stream,
|
||||
mut checksummer,
|
||||
mut expected_checksums,
|
||||
trailer_algorithm,
|
||||
} = self;
|
||||
|
||||
let (frame_tx, mut frame_rx) = mpsc::channel::<Frame<Bytes>>(5);
|
||||
|
||||
let join_checksums = tokio::spawn(async move {
|
||||
while let Some(frame) = frame_rx.recv().await {
|
||||
match frame.into_data() {
|
||||
Ok(data) => {
|
||||
checksummer = tokio::task::spawn_blocking(move || {
|
||||
checksummer.update(&data);
|
||||
checksummer
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
Err(frame) => {
|
||||
let trailers = frame.into_trailers().unwrap();
|
||||
let algo = trailer_algorithm.unwrap();
|
||||
expected_checksums.extra = Some(extract_checksum_value(&trailers, algo)?);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if trailer_algorithm.is_some() && expected_checksums.extra.is_none() {
|
||||
return Err(Error::bad_request("trailing checksum was not sent"));
|
||||
}
|
||||
|
||||
let checksums = checksummer.finalize();
|
||||
checksums.verify(&expected_checksums)?;
|
||||
|
||||
Ok(checksums)
|
||||
});
|
||||
|
||||
let stream: BoxStream<_> = stream.into_inner().unwrap();
|
||||
let stream = stream.filter_map(move |x| {
|
||||
let frame_tx = frame_tx.clone();
|
||||
async move {
|
||||
match x {
|
||||
Err(e) => Some(Err(e)),
|
||||
Ok(frame) => {
|
||||
if frame.is_data() {
|
||||
let data = frame.data_ref().unwrap().clone();
|
||||
let _ = frame_tx.send(frame).await;
|
||||
Some(Ok(data))
|
||||
} else {
|
||||
let _ = frame_tx.send(frame).await;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(stream.boxed(), join_checksums)
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,12 @@ use sha2::Sha256;
|
||||
use http::{HeaderMap, HeaderName, HeaderValue};
|
||||
|
||||
use garage_util::data::*;
|
||||
use garage_util::error::OkOrMessage;
|
||||
|
||||
use garage_model::s3::object_table::*;
|
||||
use super::*;
|
||||
|
||||
use crate::error::*;
|
||||
pub use garage_model::s3::object_table::{ChecksumAlgorithm, ChecksumValue};
|
||||
|
||||
pub const CONTENT_MD5: HeaderName = HeaderName::from_static("content-md5");
|
||||
|
||||
pub const X_AMZ_CHECKSUM_ALGORITHM: HeaderName =
|
||||
HeaderName::from_static("x-amz-checksum-algorithm");
|
||||
@@ -31,8 +32,8 @@ pub type Md5Checksum = [u8; 16];
|
||||
pub type Sha1Checksum = [u8; 20];
|
||||
pub type Sha256Checksum = [u8; 32];
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct ExpectedChecksums {
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ExpectedChecksums {
|
||||
// base64-encoded md5 (content-md5 header)
|
||||
pub md5: Option<String>,
|
||||
// content_sha256 (as a Hash / FixedBytes32)
|
||||
@@ -41,7 +42,7 @@ pub(crate) struct ExpectedChecksums {
|
||||
pub extra: Option<ChecksumValue>,
|
||||
}
|
||||
|
||||
pub(crate) struct Checksummer {
|
||||
pub struct Checksummer {
|
||||
pub crc32: Option<Crc32>,
|
||||
pub crc32c: Option<Crc32c>,
|
||||
pub md5: Option<Md5>,
|
||||
@@ -50,7 +51,7 @@ pub(crate) struct Checksummer {
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Checksums {
|
||||
pub struct Checksums {
|
||||
pub crc32: Option<Crc32Checksum>,
|
||||
pub crc32c: Option<Crc32cChecksum>,
|
||||
pub md5: Option<Md5Checksum>,
|
||||
@@ -59,34 +60,48 @@ pub(crate) struct Checksums {
|
||||
}
|
||||
|
||||
impl Checksummer {
|
||||
pub(crate) fn init(expected: &ExpectedChecksums, require_md5: bool) -> Self {
|
||||
let mut ret = Self {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
crc32: None,
|
||||
crc32c: None,
|
||||
md5: None,
|
||||
sha1: None,
|
||||
sha256: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if expected.md5.is_some() || require_md5 {
|
||||
ret.md5 = Some(Md5::new());
|
||||
}
|
||||
if expected.sha256.is_some() || matches!(&expected.extra, Some(ChecksumValue::Sha256(_))) {
|
||||
ret.sha256 = Some(Sha256::new());
|
||||
}
|
||||
if matches!(&expected.extra, Some(ChecksumValue::Crc32(_))) {
|
||||
ret.crc32 = Some(Crc32::new());
|
||||
}
|
||||
if matches!(&expected.extra, Some(ChecksumValue::Crc32c(_))) {
|
||||
ret.crc32c = Some(Crc32c::default());
|
||||
}
|
||||
if matches!(&expected.extra, Some(ChecksumValue::Sha1(_))) {
|
||||
ret.sha1 = Some(Sha1::new());
|
||||
pub fn init(expected: &ExpectedChecksums, add_md5: bool) -> Self {
|
||||
let mut ret = Self::new();
|
||||
ret.add_expected(expected);
|
||||
if add_md5 {
|
||||
ret.add_md5();
|
||||
}
|
||||
ret
|
||||
}
|
||||
|
||||
pub(crate) fn add(mut self, algo: Option<ChecksumAlgorithm>) -> Self {
|
||||
pub fn add_md5(&mut self) {
|
||||
self.md5 = Some(Md5::new());
|
||||
}
|
||||
|
||||
pub fn add_expected(&mut self, expected: &ExpectedChecksums) {
|
||||
if expected.md5.is_some() {
|
||||
self.md5 = Some(Md5::new());
|
||||
}
|
||||
if expected.sha256.is_some() || matches!(&expected.extra, Some(ChecksumValue::Sha256(_))) {
|
||||
self.sha256 = Some(Sha256::new());
|
||||
}
|
||||
if matches!(&expected.extra, Some(ChecksumValue::Crc32(_))) {
|
||||
self.crc32 = Some(Crc32::new());
|
||||
}
|
||||
if matches!(&expected.extra, Some(ChecksumValue::Crc32c(_))) {
|
||||
self.crc32c = Some(Crc32c::default());
|
||||
}
|
||||
if matches!(&expected.extra, Some(ChecksumValue::Sha1(_))) {
|
||||
self.sha1 = Some(Sha1::new());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(mut self, algo: Option<ChecksumAlgorithm>) -> Self {
|
||||
match algo {
|
||||
Some(ChecksumAlgorithm::Crc32) => {
|
||||
self.crc32 = Some(Crc32::new());
|
||||
@@ -105,7 +120,7 @@ impl Checksummer {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn update(&mut self, bytes: &[u8]) {
|
||||
pub fn update(&mut self, bytes: &[u8]) {
|
||||
if let Some(crc32) = &mut self.crc32 {
|
||||
crc32.update(bytes);
|
||||
}
|
||||
@@ -123,7 +138,7 @@ impl Checksummer {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn finalize(self) -> Checksums {
|
||||
pub fn finalize(self) -> Checksums {
|
||||
Checksums {
|
||||
crc32: self.crc32.map(|x| u32::to_be_bytes(x.finalize())),
|
||||
crc32c: self
|
||||
@@ -183,153 +198,56 @@ impl Checksums {
|
||||
|
||||
// ----
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MultipartChecksummer {
|
||||
pub md5: Md5,
|
||||
pub extra: Option<MultipartExtraChecksummer>,
|
||||
}
|
||||
|
||||
pub(crate) enum MultipartExtraChecksummer {
|
||||
Crc32(Crc32),
|
||||
Crc32c(Crc32c),
|
||||
Sha1(Sha1),
|
||||
Sha256(Sha256),
|
||||
}
|
||||
|
||||
impl MultipartChecksummer {
|
||||
pub(crate) fn init(algo: Option<ChecksumAlgorithm>) -> Self {
|
||||
Self {
|
||||
md5: Md5::new(),
|
||||
extra: match algo {
|
||||
None => None,
|
||||
Some(ChecksumAlgorithm::Crc32) => {
|
||||
Some(MultipartExtraChecksummer::Crc32(Crc32::new()))
|
||||
}
|
||||
Some(ChecksumAlgorithm::Crc32c) => {
|
||||
Some(MultipartExtraChecksummer::Crc32c(Crc32c::default()))
|
||||
}
|
||||
Some(ChecksumAlgorithm::Sha1) => Some(MultipartExtraChecksummer::Sha1(Sha1::new())),
|
||||
Some(ChecksumAlgorithm::Sha256) => {
|
||||
Some(MultipartExtraChecksummer::Sha256(Sha256::new()))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update(
|
||||
&mut self,
|
||||
etag: &str,
|
||||
checksum: Option<ChecksumValue>,
|
||||
) -> Result<(), Error> {
|
||||
self.md5
|
||||
.update(&hex::decode(&etag).ok_or_message("invalid etag hex")?);
|
||||
match (&mut self.extra, checksum) {
|
||||
(None, _) => (),
|
||||
(
|
||||
Some(MultipartExtraChecksummer::Crc32(ref mut crc32)),
|
||||
Some(ChecksumValue::Crc32(x)),
|
||||
) => {
|
||||
crc32.update(&x);
|
||||
}
|
||||
(
|
||||
Some(MultipartExtraChecksummer::Crc32c(ref mut crc32c)),
|
||||
Some(ChecksumValue::Crc32c(x)),
|
||||
) => {
|
||||
crc32c.write(&x);
|
||||
}
|
||||
(Some(MultipartExtraChecksummer::Sha1(ref mut sha1)), Some(ChecksumValue::Sha1(x))) => {
|
||||
sha1.update(&x);
|
||||
}
|
||||
(
|
||||
Some(MultipartExtraChecksummer::Sha256(ref mut sha256)),
|
||||
Some(ChecksumValue::Sha256(x)),
|
||||
) => {
|
||||
sha256.update(&x);
|
||||
}
|
||||
(Some(_), b) => {
|
||||
return Err(Error::internal_error(format!(
|
||||
"part checksum was not computed correctly, got: {:?}",
|
||||
b
|
||||
)))
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn finalize(self) -> (Md5Checksum, Option<ChecksumValue>) {
|
||||
let md5 = self.md5.finalize()[..].try_into().unwrap();
|
||||
let extra = match self.extra {
|
||||
None => None,
|
||||
Some(MultipartExtraChecksummer::Crc32(crc32)) => {
|
||||
Some(ChecksumValue::Crc32(u32::to_be_bytes(crc32.finalize())))
|
||||
}
|
||||
Some(MultipartExtraChecksummer::Crc32c(crc32c)) => Some(ChecksumValue::Crc32c(
|
||||
u32::to_be_bytes(u32::try_from(crc32c.finish()).unwrap()),
|
||||
)),
|
||||
Some(MultipartExtraChecksummer::Sha1(sha1)) => {
|
||||
Some(ChecksumValue::Sha1(sha1.finalize()[..].try_into().unwrap()))
|
||||
}
|
||||
Some(MultipartExtraChecksummer::Sha256(sha256)) => Some(ChecksumValue::Sha256(
|
||||
sha256.finalize()[..].try_into().unwrap(),
|
||||
)),
|
||||
};
|
||||
(md5, extra)
|
||||
pub fn parse_checksum_algorithm(algo: &str) -> Result<ChecksumAlgorithm, Error> {
|
||||
match algo {
|
||||
"CRC32" => Ok(ChecksumAlgorithm::Crc32),
|
||||
"CRC32C" => Ok(ChecksumAlgorithm::Crc32c),
|
||||
"SHA1" => Ok(ChecksumAlgorithm::Sha1),
|
||||
"SHA256" => Ok(ChecksumAlgorithm::Sha256),
|
||||
_ => Err(Error::bad_request("invalid checksum algorithm")),
|
||||
}
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
/// Extract the value of the x-amz-checksum-algorithm header
|
||||
pub(crate) fn request_checksum_algorithm(
|
||||
pub fn request_checksum_algorithm(
|
||||
headers: &HeaderMap<HeaderValue>,
|
||||
) -> Result<Option<ChecksumAlgorithm>, Error> {
|
||||
match headers.get(X_AMZ_CHECKSUM_ALGORITHM) {
|
||||
None => Ok(None),
|
||||
Some(x) if x == "CRC32" => Ok(Some(ChecksumAlgorithm::Crc32)),
|
||||
Some(x) if x == "CRC32C" => Ok(Some(ChecksumAlgorithm::Crc32c)),
|
||||
Some(x) if x == "SHA1" => Ok(Some(ChecksumAlgorithm::Sha1)),
|
||||
Some(x) if x == "SHA256" => Ok(Some(ChecksumAlgorithm::Sha256)),
|
||||
Some(x) => parse_checksum_algorithm(x.to_str()?).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn request_trailer_checksum_algorithm(
|
||||
headers: &HeaderMap<HeaderValue>,
|
||||
) -> Result<Option<ChecksumAlgorithm>, Error> {
|
||||
match headers.get(X_AMZ_TRAILER).map(|x| x.to_str()).transpose()? {
|
||||
None => Ok(None),
|
||||
Some(x) if x == X_AMZ_CHECKSUM_CRC32 => Ok(Some(ChecksumAlgorithm::Crc32)),
|
||||
Some(x) if x == X_AMZ_CHECKSUM_CRC32C => Ok(Some(ChecksumAlgorithm::Crc32c)),
|
||||
Some(x) if x == X_AMZ_CHECKSUM_SHA1 => Ok(Some(ChecksumAlgorithm::Sha1)),
|
||||
Some(x) if x == X_AMZ_CHECKSUM_SHA256 => Ok(Some(ChecksumAlgorithm::Sha256)),
|
||||
_ => Err(Error::bad_request("invalid checksum algorithm")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the value of any of the x-amz-checksum-* headers
|
||||
pub(crate) fn request_checksum_value(
|
||||
pub fn request_checksum_value(
|
||||
headers: &HeaderMap<HeaderValue>,
|
||||
) -> Result<Option<ChecksumValue>, Error> {
|
||||
let mut ret = vec![];
|
||||
|
||||
if let Some(crc32_str) = headers.get(X_AMZ_CHECKSUM_CRC32) {
|
||||
let crc32 = BASE64_STANDARD
|
||||
.decode(&crc32_str)
|
||||
.ok()
|
||||
.and_then(|x| x.try_into().ok())
|
||||
.ok_or_bad_request("invalid x-amz-checksum-crc32 header")?;
|
||||
ret.push(ChecksumValue::Crc32(crc32))
|
||||
if headers.contains_key(X_AMZ_CHECKSUM_CRC32) {
|
||||
ret.push(extract_checksum_value(headers, ChecksumAlgorithm::Crc32)?);
|
||||
}
|
||||
if let Some(crc32c_str) = headers.get(X_AMZ_CHECKSUM_CRC32C) {
|
||||
let crc32c = BASE64_STANDARD
|
||||
.decode(&crc32c_str)
|
||||
.ok()
|
||||
.and_then(|x| x.try_into().ok())
|
||||
.ok_or_bad_request("invalid x-amz-checksum-crc32c header")?;
|
||||
ret.push(ChecksumValue::Crc32c(crc32c))
|
||||
if headers.contains_key(X_AMZ_CHECKSUM_CRC32C) {
|
||||
ret.push(extract_checksum_value(headers, ChecksumAlgorithm::Crc32c)?);
|
||||
}
|
||||
if let Some(sha1_str) = headers.get(X_AMZ_CHECKSUM_SHA1) {
|
||||
let sha1 = BASE64_STANDARD
|
||||
.decode(&sha1_str)
|
||||
.ok()
|
||||
.and_then(|x| x.try_into().ok())
|
||||
.ok_or_bad_request("invalid x-amz-checksum-sha1 header")?;
|
||||
ret.push(ChecksumValue::Sha1(sha1))
|
||||
if headers.contains_key(X_AMZ_CHECKSUM_SHA1) {
|
||||
ret.push(extract_checksum_value(headers, ChecksumAlgorithm::Sha1)?);
|
||||
}
|
||||
if let Some(sha256_str) = headers.get(X_AMZ_CHECKSUM_SHA256) {
|
||||
let sha256 = BASE64_STANDARD
|
||||
.decode(&sha256_str)
|
||||
.ok()
|
||||
.and_then(|x| x.try_into().ok())
|
||||
.ok_or_bad_request("invalid x-amz-checksum-sha256 header")?;
|
||||
ret.push(ChecksumValue::Sha256(sha256))
|
||||
if headers.contains_key(X_AMZ_CHECKSUM_SHA256) {
|
||||
ret.push(extract_checksum_value(headers, ChecksumAlgorithm::Sha256)?);
|
||||
}
|
||||
|
||||
if ret.len() > 1 {
|
||||
@@ -342,48 +260,47 @@ pub(crate) fn request_checksum_value(
|
||||
|
||||
/// Checks for the presence of x-amz-checksum-algorithm
|
||||
/// if so extract the corresponding x-amz-checksum-* value
|
||||
pub(crate) fn request_checksum_algorithm_value(
|
||||
pub fn extract_checksum_value(
|
||||
headers: &HeaderMap<HeaderValue>,
|
||||
) -> Result<Option<ChecksumValue>, Error> {
|
||||
match headers.get(X_AMZ_CHECKSUM_ALGORITHM) {
|
||||
Some(x) if x == "CRC32" => {
|
||||
algo: ChecksumAlgorithm,
|
||||
) -> Result<ChecksumValue, Error> {
|
||||
match algo {
|
||||
ChecksumAlgorithm::Crc32 => {
|
||||
let crc32 = headers
|
||||
.get(X_AMZ_CHECKSUM_CRC32)
|
||||
.and_then(|x| BASE64_STANDARD.decode(&x).ok())
|
||||
.and_then(|x| x.try_into().ok())
|
||||
.ok_or_bad_request("invalid x-amz-checksum-crc32 header")?;
|
||||
Ok(Some(ChecksumValue::Crc32(crc32)))
|
||||
Ok(ChecksumValue::Crc32(crc32))
|
||||
}
|
||||
Some(x) if x == "CRC32C" => {
|
||||
ChecksumAlgorithm::Crc32c => {
|
||||
let crc32c = headers
|
||||
.get(X_AMZ_CHECKSUM_CRC32C)
|
||||
.and_then(|x| BASE64_STANDARD.decode(&x).ok())
|
||||
.and_then(|x| x.try_into().ok())
|
||||
.ok_or_bad_request("invalid x-amz-checksum-crc32c header")?;
|
||||
Ok(Some(ChecksumValue::Crc32c(crc32c)))
|
||||
Ok(ChecksumValue::Crc32c(crc32c))
|
||||
}
|
||||
Some(x) if x == "SHA1" => {
|
||||
ChecksumAlgorithm::Sha1 => {
|
||||
let sha1 = headers
|
||||
.get(X_AMZ_CHECKSUM_SHA1)
|
||||
.and_then(|x| BASE64_STANDARD.decode(&x).ok())
|
||||
.and_then(|x| x.try_into().ok())
|
||||
.ok_or_bad_request("invalid x-amz-checksum-sha1 header")?;
|
||||
Ok(Some(ChecksumValue::Sha1(sha1)))
|
||||
Ok(ChecksumValue::Sha1(sha1))
|
||||
}
|
||||
Some(x) if x == "SHA256" => {
|
||||
ChecksumAlgorithm::Sha256 => {
|
||||
let sha256 = headers
|
||||
.get(X_AMZ_CHECKSUM_SHA256)
|
||||
.and_then(|x| BASE64_STANDARD.decode(&x).ok())
|
||||
.and_then(|x| x.try_into().ok())
|
||||
.ok_or_bad_request("invalid x-amz-checksum-sha256 header")?;
|
||||
Ok(Some(ChecksumValue::Sha256(sha256)))
|
||||
Ok(ChecksumValue::Sha256(sha256))
|
||||
}
|
||||
Some(_) => Err(Error::bad_request("invalid x-amz-checksum-algorithm")),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn add_checksum_response_headers(
|
||||
pub fn add_checksum_response_headers(
|
||||
checksum: &Option<ChecksumValue>,
|
||||
mut resp: http::response::Builder,
|
||||
) -> http::response::Builder {
|
||||
@@ -18,6 +18,10 @@ pub enum Error {
|
||||
/// The request contained an invalid UTF-8 sequence in its path or in other parameters
|
||||
#[error(display = "Invalid UTF-8: {}", _0)]
|
||||
InvalidUtf8Str(#[error(source)] std::str::Utf8Error),
|
||||
|
||||
/// The provided digest (checksum) value was invalid
|
||||
#[error(display = "Invalid digest: {}", _0)]
|
||||
InvalidDigest(String),
|
||||
}
|
||||
|
||||
impl<T> From<T> for Error
|
||||
|
||||
@@ -2,6 +2,7 @@ use chrono::{DateTime, Utc};
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
use hyper::header::HeaderName;
|
||||
use hyper::{body::Incoming as IncomingBody, Request};
|
||||
|
||||
use garage_model::garage::Garage;
|
||||
@@ -10,6 +11,8 @@ use garage_util::data::{sha256sum, Hash};
|
||||
|
||||
use error::*;
|
||||
|
||||
pub mod body;
|
||||
pub mod checksum;
|
||||
pub mod error;
|
||||
pub mod payload;
|
||||
pub mod streaming;
|
||||
@@ -17,36 +20,73 @@ pub mod streaming;
|
||||
pub const SHORT_DATE: &str = "%Y%m%d";
|
||||
pub const LONG_DATETIME: &str = "%Y%m%dT%H%M%SZ";
|
||||
|
||||
// ---- Constants used in AWSv4 signatures ----
|
||||
|
||||
pub const X_AMZ_ALGORITHM: HeaderName = HeaderName::from_static("x-amz-algorithm");
|
||||
pub const X_AMZ_CREDENTIAL: HeaderName = HeaderName::from_static("x-amz-credential");
|
||||
pub const X_AMZ_DATE: HeaderName = HeaderName::from_static("x-amz-date");
|
||||
pub const X_AMZ_EXPIRES: HeaderName = HeaderName::from_static("x-amz-expires");
|
||||
pub const X_AMZ_SIGNEDHEADERS: HeaderName = HeaderName::from_static("x-amz-signedheaders");
|
||||
pub const X_AMZ_SIGNATURE: HeaderName = HeaderName::from_static("x-amz-signature");
|
||||
pub const X_AMZ_CONTENT_SHA256: HeaderName = HeaderName::from_static("x-amz-content-sha256");
|
||||
pub const X_AMZ_TRAILER: HeaderName = HeaderName::from_static("x-amz-trailer");
|
||||
|
||||
/// Result of `sha256("")`
|
||||
pub(crate) const EMPTY_STRING_HEX_DIGEST: &str =
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
|
||||
// Signature calculation algorithm
|
||||
pub const AWS4_HMAC_SHA256: &str = "AWS4-HMAC-SHA256";
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
// Possible values for x-amz-content-sha256, in addition to the actual sha256
|
||||
pub const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
|
||||
pub const STREAMING_UNSIGNED_PAYLOAD_TRAILER: &str = "STREAMING-UNSIGNED-PAYLOAD-TRAILER";
|
||||
pub const STREAMING_AWS4_HMAC_SHA256_PAYLOAD: &str = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD";
|
||||
|
||||
// Used in the computation of StringToSign
|
||||
pub const AWS4_HMAC_SHA256_PAYLOAD: &str = "AWS4-HMAC-SHA256-PAYLOAD";
|
||||
|
||||
// ---- enums to describe stuff going on in signature calculation ----
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ContentSha256Header {
|
||||
UnsignedPayload,
|
||||
Sha256Checksum(Hash),
|
||||
StreamingPayload { trailer: bool, signed: bool },
|
||||
}
|
||||
|
||||
// ---- top-level functions ----
|
||||
|
||||
pub struct VerifiedRequest {
|
||||
pub request: Request<streaming::ReqBody>,
|
||||
pub access_key: Key,
|
||||
pub content_sha256_header: ContentSha256Header,
|
||||
}
|
||||
|
||||
pub async fn verify_request(
|
||||
garage: &Garage,
|
||||
mut req: Request<IncomingBody>,
|
||||
service: &'static str,
|
||||
) -> Result<(Request<streaming::ReqBody>, Key, Option<Hash>), Error> {
|
||||
let (api_key, mut content_sha256) =
|
||||
payload::check_payload_signature(&garage, &mut req, service).await?;
|
||||
let api_key =
|
||||
api_key.ok_or_else(|| Error::forbidden("Garage does not support anonymous access yet"))?;
|
||||
) -> Result<VerifiedRequest, Error> {
|
||||
let checked_signature = payload::check_payload_signature(&garage, &mut req, service).await?;
|
||||
|
||||
let req = streaming::parse_streaming_body(
|
||||
&api_key,
|
||||
let request = streaming::parse_streaming_body(
|
||||
req,
|
||||
&mut content_sha256,
|
||||
&checked_signature,
|
||||
&garage.config.s3_api.s3_region,
|
||||
service,
|
||||
)?;
|
||||
|
||||
Ok((req, api_key, content_sha256))
|
||||
}
|
||||
let access_key = checked_signature
|
||||
.key
|
||||
.ok_or_else(|| Error::forbidden("Garage does not support anonymous access yet"))?;
|
||||
|
||||
pub fn verify_signed_content(expected_sha256: Hash, body: &[u8]) -> Result<(), Error> {
|
||||
if expected_sha256 != sha256sum(body) {
|
||||
return Err(Error::bad_request(
|
||||
"Request content hash does not match signed hash".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
Ok(VerifiedRequest {
|
||||
request,
|
||||
access_key,
|
||||
content_sha256_header: checked_signature.content_sha256_header,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn signing_hmac(
|
||||
|
||||
@@ -13,23 +13,9 @@ use garage_util::data::Hash;
|
||||
use garage_model::garage::Garage;
|
||||
use garage_model::key_table::*;
|
||||
|
||||
use super::LONG_DATETIME;
|
||||
use super::{compute_scope, signing_hmac};
|
||||
use super::*;
|
||||
|
||||
use crate::encoding::uri_encode;
|
||||
use crate::signature::error::*;
|
||||
|
||||
pub const X_AMZ_ALGORITHM: HeaderName = HeaderName::from_static("x-amz-algorithm");
|
||||
pub const X_AMZ_CREDENTIAL: HeaderName = HeaderName::from_static("x-amz-credential");
|
||||
pub const X_AMZ_DATE: HeaderName = HeaderName::from_static("x-amz-date");
|
||||
pub const X_AMZ_EXPIRES: HeaderName = HeaderName::from_static("x-amz-expires");
|
||||
pub const X_AMZ_SIGNEDHEADERS: HeaderName = HeaderName::from_static("x-amz-signedheaders");
|
||||
pub const X_AMZ_SIGNATURE: HeaderName = HeaderName::from_static("x-amz-signature");
|
||||
pub const X_AMZ_CONTENT_SH256: HeaderName = HeaderName::from_static("x-amz-content-sha256");
|
||||
|
||||
pub const AWS4_HMAC_SHA256: &str = "AWS4-HMAC-SHA256";
|
||||
pub const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
|
||||
pub const STREAMING_AWS4_HMAC_SHA256_PAYLOAD: &str = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD";
|
||||
|
||||
pub type QueryMap = HeaderMap<QueryValue>;
|
||||
pub struct QueryValue {
|
||||
@@ -39,11 +25,18 @@ pub struct QueryValue {
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CheckedSignature {
|
||||
pub key: Option<Key>,
|
||||
pub content_sha256_header: ContentSha256Header,
|
||||
pub signature_header: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn check_payload_signature(
|
||||
garage: &Garage,
|
||||
request: &mut Request<IncomingBody>,
|
||||
service: &'static str,
|
||||
) -> Result<(Option<Key>, Option<Hash>), Error> {
|
||||
) -> Result<CheckedSignature, Error> {
|
||||
let query = parse_query_map(request.uri())?;
|
||||
|
||||
if query.contains_key(&X_AMZ_ALGORITHM) {
|
||||
@@ -57,17 +50,46 @@ pub async fn check_payload_signature(
|
||||
// Unsigned (anonymous) request
|
||||
let content_sha256 = request
|
||||
.headers()
|
||||
.get("x-amz-content-sha256")
|
||||
.filter(|c| c.as_bytes() != UNSIGNED_PAYLOAD.as_bytes());
|
||||
if let Some(content_sha256) = content_sha256 {
|
||||
let sha256 = hex::decode(content_sha256)
|
||||
.ok()
|
||||
.and_then(|bytes| Hash::try_from(&bytes))
|
||||
.ok_or_bad_request("Invalid content sha256 hash")?;
|
||||
Ok((None, Some(sha256)))
|
||||
.get(X_AMZ_CONTENT_SHA256)
|
||||
.map(|x| x.to_str())
|
||||
.transpose()?;
|
||||
Ok(CheckedSignature {
|
||||
key: None,
|
||||
content_sha256_header: parse_x_amz_content_sha256(content_sha256)?,
|
||||
signature_header: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_x_amz_content_sha256(header: Option<&str>) -> Result<ContentSha256Header, Error> {
|
||||
let header = match header {
|
||||
Some(x) => x,
|
||||
None => return Ok(ContentSha256Header::UnsignedPayload),
|
||||
};
|
||||
if header == UNSIGNED_PAYLOAD {
|
||||
Ok(ContentSha256Header::UnsignedPayload)
|
||||
} else if let Some(rest) = header.strip_prefix("STREAMING-") {
|
||||
let (trailer, algo) = if let Some(rest2) = rest.strip_suffix("-TRAILER") {
|
||||
(true, rest2)
|
||||
} else {
|
||||
Ok((None, None))
|
||||
}
|
||||
(false, rest)
|
||||
};
|
||||
let signed = match algo {
|
||||
AWS4_HMAC_SHA256_PAYLOAD => true,
|
||||
UNSIGNED_PAYLOAD => false,
|
||||
_ => {
|
||||
return Err(Error::bad_request(
|
||||
"invalid or unsupported x-amz-content-sha256",
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(ContentSha256Header::StreamingPayload { trailer, signed })
|
||||
} else {
|
||||
let sha256 = hex::decode(header)
|
||||
.ok()
|
||||
.and_then(|bytes| Hash::try_from(&bytes))
|
||||
.ok_or_bad_request("Invalid content sha256 hash")?;
|
||||
Ok(ContentSha256Header::Sha256Checksum(sha256))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +98,7 @@ async fn check_standard_signature(
|
||||
service: &'static str,
|
||||
request: &Request<IncomingBody>,
|
||||
query: QueryMap,
|
||||
) -> Result<(Option<Key>, Option<Hash>), Error> {
|
||||
) -> Result<CheckedSignature, Error> {
|
||||
let authorization = Authorization::parse_header(request.headers())?;
|
||||
|
||||
// Verify that all necessary request headers are included in signed_headers
|
||||
@@ -108,18 +130,13 @@ async fn check_standard_signature(
|
||||
|
||||
let key = verify_v4(garage, service, &authorization, string_to_sign.as_bytes()).await?;
|
||||
|
||||
let content_sha256 = if authorization.content_sha256 == UNSIGNED_PAYLOAD {
|
||||
None
|
||||
} else if authorization.content_sha256 == STREAMING_AWS4_HMAC_SHA256_PAYLOAD {
|
||||
let bytes = hex::decode(authorization.signature).ok_or_bad_request("Invalid signature")?;
|
||||
Some(Hash::try_from(&bytes).ok_or_bad_request("Invalid signature")?)
|
||||
} else {
|
||||
let bytes = hex::decode(authorization.content_sha256)
|
||||
.ok_or_bad_request("Invalid content sha256 hash")?;
|
||||
Some(Hash::try_from(&bytes).ok_or_bad_request("Invalid content sha256 hash")?)
|
||||
};
|
||||
let content_sha256_header = parse_x_amz_content_sha256(Some(&authorization.content_sha256))?;
|
||||
|
||||
Ok((Some(key), content_sha256))
|
||||
Ok(CheckedSignature {
|
||||
key: Some(key),
|
||||
content_sha256_header,
|
||||
signature_header: Some(authorization.signature),
|
||||
})
|
||||
}
|
||||
|
||||
async fn check_presigned_signature(
|
||||
@@ -127,7 +144,7 @@ async fn check_presigned_signature(
|
||||
service: &'static str,
|
||||
request: &mut Request<IncomingBody>,
|
||||
mut query: QueryMap,
|
||||
) -> Result<(Option<Key>, Option<Hash>), Error> {
|
||||
) -> Result<CheckedSignature, Error> {
|
||||
let algorithm = query.get(&X_AMZ_ALGORITHM).unwrap();
|
||||
let authorization = Authorization::parse_presigned(&algorithm.value, &query)?;
|
||||
|
||||
@@ -193,7 +210,11 @@ async fn check_presigned_signature(
|
||||
|
||||
// Presigned URLs always use UNSIGNED-PAYLOAD,
|
||||
// so there is no sha256 hash to return.
|
||||
Ok((Some(key), None))
|
||||
Ok(CheckedSignature {
|
||||
key: Some(key),
|
||||
content_sha256_header: ContentSha256Header::UnsignedPayload,
|
||||
signature_header: Some(authorization.signature),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_query_map(uri: &http::uri::Uri) -> Result<QueryMap, Error> {
|
||||
@@ -442,7 +463,7 @@ impl Authorization {
|
||||
.to_string();
|
||||
|
||||
let content_sha256 = headers
|
||||
.get(X_AMZ_CONTENT_SH256)
|
||||
.get(X_AMZ_CONTENT_SHA256)
|
||||
.ok_or_bad_request("Missing X-Amz-Content-Sha256 field")?;
|
||||
|
||||
let date = headers
|
||||
|
||||
@@ -1,84 +1,157 @@
|
||||
use std::pin::Pin;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
|
||||
use futures::prelude::*;
|
||||
use futures::task;
|
||||
use garage_model::key_table::Key;
|
||||
use hmac::Mac;
|
||||
use http_body_util::StreamBody;
|
||||
use hyper::body::{Bytes, Incoming as IncomingBody};
|
||||
use http::header::{HeaderMap, HeaderValue, CONTENT_ENCODING};
|
||||
use hyper::body::{Bytes, Frame, Incoming as IncomingBody};
|
||||
use hyper::Request;
|
||||
|
||||
use garage_util::data::Hash;
|
||||
|
||||
use super::{compute_scope, sha256sum, HmacSha256, LONG_DATETIME};
|
||||
use super::*;
|
||||
|
||||
use crate::helpers::*;
|
||||
use crate::signature::error::*;
|
||||
use crate::signature::payload::{
|
||||
STREAMING_AWS4_HMAC_SHA256_PAYLOAD, X_AMZ_CONTENT_SH256, X_AMZ_DATE,
|
||||
};
|
||||
use crate::helpers::body_stream;
|
||||
use crate::signature::checksum::*;
|
||||
use crate::signature::payload::CheckedSignature;
|
||||
|
||||
pub const AWS4_HMAC_SHA256_PAYLOAD: &str = "AWS4-HMAC-SHA256-PAYLOAD";
|
||||
|
||||
pub type ReqBody = BoxBody<Error>;
|
||||
pub use crate::signature::body::ReqBody;
|
||||
|
||||
pub fn parse_streaming_body(
|
||||
api_key: &Key,
|
||||
req: Request<IncomingBody>,
|
||||
content_sha256: &mut Option<Hash>,
|
||||
mut req: Request<IncomingBody>,
|
||||
checked_signature: &CheckedSignature,
|
||||
region: &str,
|
||||
service: &str,
|
||||
) -> Result<Request<ReqBody>, Error> {
|
||||
match req.headers().get(X_AMZ_CONTENT_SH256) {
|
||||
Some(header) if header == STREAMING_AWS4_HMAC_SHA256_PAYLOAD => {
|
||||
let signature = content_sha256
|
||||
.take()
|
||||
.ok_or_bad_request("No signature provided")?;
|
||||
debug!(
|
||||
"Content signature mode: {:?}",
|
||||
checked_signature.content_sha256_header
|
||||
);
|
||||
|
||||
let secret_key = &api_key
|
||||
.state
|
||||
.as_option()
|
||||
.ok_or_internal_error("Deleted key state")?
|
||||
.secret_key;
|
||||
match checked_signature.content_sha256_header {
|
||||
ContentSha256Header::StreamingPayload { signed, trailer } => {
|
||||
// Sanity checks
|
||||
if !signed && !trailer {
|
||||
return Err(Error::bad_request(
|
||||
"STREAMING-UNSIGNED-PAYLOAD without trailer is not a valid combination",
|
||||
));
|
||||
}
|
||||
|
||||
let date = req
|
||||
.headers()
|
||||
.get(X_AMZ_DATE)
|
||||
.ok_or_bad_request("Missing X-Amz-Date field")?
|
||||
.to_str()?;
|
||||
let date: NaiveDateTime = NaiveDateTime::parse_from_str(date, LONG_DATETIME)
|
||||
.ok_or_bad_request("Invalid date")?;
|
||||
let date: DateTime<Utc> = Utc.from_utc_datetime(&date);
|
||||
// Remove the aws-chunked component in the content-encoding: header
|
||||
// Note: this header is not properly sent by minio client, so don't fail
|
||||
// if it is absent from the request.
|
||||
if let Some(content_encoding) = req.headers_mut().remove(CONTENT_ENCODING) {
|
||||
if let Some(rest) = content_encoding.as_bytes().strip_prefix(b"aws-chunked,") {
|
||||
req.headers_mut()
|
||||
.insert(CONTENT_ENCODING, HeaderValue::from_bytes(rest).unwrap());
|
||||
} else if content_encoding != "aws-chunked" {
|
||||
return Err(Error::bad_request(
|
||||
"content-encoding does not contain aws-chunked for STREAMING-*-PAYLOAD",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let scope = compute_scope(&date, region, service);
|
||||
let signing_hmac = crate::signature::signing_hmac(&date, secret_key, region, service)
|
||||
.ok_or_internal_error("Unable to build signing HMAC")?;
|
||||
// If trailer header is announced, add the calculation of the requested checksum
|
||||
let mut checksummer = Checksummer::init(&Default::default(), false);
|
||||
let trailer_algorithm = if trailer {
|
||||
let algo = Some(
|
||||
request_trailer_checksum_algorithm(req.headers())?
|
||||
.ok_or_bad_request("Missing x-amz-trailer header")?,
|
||||
);
|
||||
checksummer = checksummer.add(algo);
|
||||
algo
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// For signed variants, determine signing parameters
|
||||
let sign_params = if signed {
|
||||
let signature = checked_signature
|
||||
.signature_header
|
||||
.clone()
|
||||
.ok_or_bad_request("No signature provided")?;
|
||||
let signature = hex::decode(signature)
|
||||
.ok()
|
||||
.and_then(|bytes| Hash::try_from(&bytes))
|
||||
.ok_or_bad_request("Invalid signature")?;
|
||||
|
||||
let secret_key = checked_signature
|
||||
.key
|
||||
.as_ref()
|
||||
.ok_or_bad_request("Cannot sign streaming payload without signing key")?
|
||||
.state
|
||||
.as_option()
|
||||
.ok_or_internal_error("Deleted key state")?
|
||||
.secret_key
|
||||
.to_string();
|
||||
|
||||
let date = req
|
||||
.headers()
|
||||
.get(X_AMZ_DATE)
|
||||
.ok_or_bad_request("Missing X-Amz-Date field")?
|
||||
.to_str()?;
|
||||
let date: NaiveDateTime = NaiveDateTime::parse_from_str(date, LONG_DATETIME)
|
||||
.ok_or_bad_request("Invalid date")?;
|
||||
let date: DateTime<Utc> = Utc.from_utc_datetime(&date);
|
||||
|
||||
let scope = compute_scope(&date, region, service);
|
||||
let signing_hmac =
|
||||
crate::signature::signing_hmac(&date, &secret_key, region, service)
|
||||
.ok_or_internal_error("Unable to build signing HMAC")?;
|
||||
|
||||
Some(SignParams {
|
||||
datetime: date,
|
||||
scope,
|
||||
signing_hmac,
|
||||
previous_signature: signature,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(req.map(move |body| {
|
||||
let stream = body_stream::<_, Error>(body);
|
||||
|
||||
let signed_payload_stream =
|
||||
SignedPayloadStream::new(stream, signing_hmac, date, &scope, signature)
|
||||
.map(|x| x.map(hyper::body::Frame::data))
|
||||
.map_err(Error::from);
|
||||
ReqBody::new(StreamBody::new(signed_payload_stream))
|
||||
StreamingPayloadStream::new(stream, sign_params, trailer).map_err(Error::from);
|
||||
ReqBody {
|
||||
stream: Mutex::new(signed_payload_stream.boxed()),
|
||||
checksummer,
|
||||
expected_checksums: Default::default(),
|
||||
trailer_algorithm,
|
||||
}
|
||||
}))
|
||||
}
|
||||
_ => Ok(req.map(|body| ReqBody::new(http_body_util::BodyExt::map_err(body, Error::from)))),
|
||||
_ => Ok(req.map(|body| {
|
||||
let expected_checksums = ExpectedChecksums {
|
||||
sha256: match &checked_signature.content_sha256_header {
|
||||
ContentSha256Header::Sha256Checksum(sha256) => Some(*sha256),
|
||||
_ => None,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let checksummer = Checksummer::init(&expected_checksums, false);
|
||||
|
||||
let stream = http_body_util::BodyStream::new(body).map_err(Error::from);
|
||||
ReqBody {
|
||||
stream: Mutex::new(stream.boxed()),
|
||||
checksummer,
|
||||
expected_checksums,
|
||||
trailer_algorithm: None,
|
||||
}
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of `sha256("")`
|
||||
const EMPTY_STRING_HEX_DIGEST: &str =
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
|
||||
fn compute_streaming_payload_signature(
|
||||
signing_hmac: &HmacSha256,
|
||||
date: DateTime<Utc>,
|
||||
scope: &str,
|
||||
previous_signature: Hash,
|
||||
content_sha256: Hash,
|
||||
) -> Result<Hash, Error> {
|
||||
) -> Result<Hash, StreamingPayloadError> {
|
||||
let string_to_sign = [
|
||||
AWS4_HMAC_SHA256_PAYLOAD,
|
||||
&date.format(LONG_DATETIME).to_string(),
|
||||
@@ -92,12 +165,49 @@ fn compute_streaming_payload_signature(
|
||||
let mut hmac = signing_hmac.clone();
|
||||
hmac.update(string_to_sign.as_bytes());
|
||||
|
||||
Ok(Hash::try_from(&hmac.finalize().into_bytes()).ok_or_internal_error("Invalid signature")?)
|
||||
Hash::try_from(&hmac.finalize().into_bytes())
|
||||
.ok_or_else(|| StreamingPayloadError::Message("Could not build signature".into()))
|
||||
}
|
||||
|
||||
fn compute_streaming_trailer_signature(
|
||||
signing_hmac: &HmacSha256,
|
||||
date: DateTime<Utc>,
|
||||
scope: &str,
|
||||
previous_signature: Hash,
|
||||
trailer_sha256: Hash,
|
||||
) -> Result<Hash, StreamingPayloadError> {
|
||||
let string_to_sign = [
|
||||
AWS4_HMAC_SHA256_PAYLOAD,
|
||||
&date.format(LONG_DATETIME).to_string(),
|
||||
scope,
|
||||
&hex::encode(previous_signature),
|
||||
&hex::encode(trailer_sha256),
|
||||
]
|
||||
.join("\n");
|
||||
|
||||
let mut hmac = signing_hmac.clone();
|
||||
hmac.update(string_to_sign.as_bytes());
|
||||
|
||||
Hash::try_from(&hmac.finalize().into_bytes())
|
||||
.ok_or_else(|| StreamingPayloadError::Message("Could not build signature".into()))
|
||||
}
|
||||
|
||||
mod payload {
|
||||
use http::{HeaderName, HeaderValue};
|
||||
|
||||
use garage_util::data::Hash;
|
||||
|
||||
use nom::bytes::streaming::{tag, take_while};
|
||||
use nom::character::streaming::hex_digit1;
|
||||
use nom::combinator::{map_res, opt};
|
||||
use nom::number::streaming::hex_u32;
|
||||
|
||||
macro_rules! try_parse {
|
||||
($expr:expr) => {
|
||||
$expr.map_err(|e| e.map(Error::Parser))?
|
||||
};
|
||||
}
|
||||
|
||||
pub enum Error<I> {
|
||||
Parser(nom::error::Error<I>),
|
||||
BadSignature,
|
||||
@@ -113,24 +223,13 @@ mod payload {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Header {
|
||||
pub struct ChunkHeader {
|
||||
pub size: usize,
|
||||
pub signature: Hash,
|
||||
pub signature: Option<Hash>,
|
||||
}
|
||||
|
||||
impl Header {
|
||||
pub fn parse(input: &[u8]) -> nom::IResult<&[u8], Self, Error<&[u8]>> {
|
||||
use nom::bytes::streaming::tag;
|
||||
use nom::character::streaming::hex_digit1;
|
||||
use nom::combinator::map_res;
|
||||
use nom::number::streaming::hex_u32;
|
||||
|
||||
macro_rules! try_parse {
|
||||
($expr:expr) => {
|
||||
$expr.map_err(|e| e.map(Error::Parser))?
|
||||
};
|
||||
}
|
||||
|
||||
impl ChunkHeader {
|
||||
pub fn parse_signed(input: &[u8]) -> nom::IResult<&[u8], Self, Error<&[u8]>> {
|
||||
let (input, size) = try_parse!(hex_u32(input));
|
||||
let (input, _) = try_parse!(tag(";")(input));
|
||||
|
||||
@@ -140,96 +239,172 @@ mod payload {
|
||||
|
||||
let (input, _) = try_parse!(tag("\r\n")(input));
|
||||
|
||||
let header = Header {
|
||||
let header = ChunkHeader {
|
||||
size: size as usize,
|
||||
signature,
|
||||
signature: Some(signature),
|
||||
};
|
||||
|
||||
Ok((input, header))
|
||||
}
|
||||
|
||||
pub fn parse_unsigned(input: &[u8]) -> nom::IResult<&[u8], Self, Error<&[u8]>> {
|
||||
let (input, size) = try_parse!(hex_u32(input));
|
||||
let (input, _) = try_parse!(tag("\r\n")(input));
|
||||
|
||||
let header = ChunkHeader {
|
||||
size: size as usize,
|
||||
signature: None,
|
||||
};
|
||||
|
||||
Ok((input, header))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrailerChunk {
|
||||
pub header_name: HeaderName,
|
||||
pub header_value: HeaderValue,
|
||||
pub signature: Option<Hash>,
|
||||
}
|
||||
|
||||
impl TrailerChunk {
|
||||
fn parse_content(input: &[u8]) -> nom::IResult<&[u8], Self, Error<&[u8]>> {
|
||||
let (input, header_name) = try_parse!(map_res(
|
||||
take_while(|c: u8| c.is_ascii_alphanumeric() || c == b'-'),
|
||||
HeaderName::from_bytes
|
||||
)(input));
|
||||
let (input, _) = try_parse!(tag(b":")(input));
|
||||
let (input, header_value) = try_parse!(map_res(
|
||||
take_while(|c: u8| c.is_ascii_alphanumeric() || b"+/=".contains(&c)),
|
||||
HeaderValue::from_bytes
|
||||
)(input));
|
||||
|
||||
// Possible '\n' after the header value, depends on clients
|
||||
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html
|
||||
let (input, _) = try_parse!(opt(tag(b"\n"))(input));
|
||||
|
||||
let (input, _) = try_parse!(tag(b"\r\n")(input));
|
||||
|
||||
Ok((
|
||||
input,
|
||||
TrailerChunk {
|
||||
header_name,
|
||||
header_value,
|
||||
signature: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
pub fn parse_signed(input: &[u8]) -> nom::IResult<&[u8], Self, Error<&[u8]>> {
|
||||
let (input, trailer) = Self::parse_content(input)?;
|
||||
|
||||
let (input, _) = try_parse!(tag(b"x-amz-trailer-signature:")(input));
|
||||
let (input, data) = try_parse!(map_res(hex_digit1, hex::decode)(input));
|
||||
let signature = Hash::try_from(&data).ok_or(nom::Err::Failure(Error::BadSignature))?;
|
||||
let (input, _) = try_parse!(tag(b"\r\n")(input));
|
||||
|
||||
Ok((
|
||||
input,
|
||||
TrailerChunk {
|
||||
signature: Some(signature),
|
||||
..trailer
|
||||
},
|
||||
))
|
||||
}
|
||||
pub fn parse_unsigned(input: &[u8]) -> nom::IResult<&[u8], Self, Error<&[u8]>> {
|
||||
let (input, trailer) = Self::parse_content(input)?;
|
||||
let (input, _) = try_parse!(tag(b"\r\n")(input));
|
||||
|
||||
Ok((input, trailer))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SignedPayloadStreamError {
|
||||
pub enum StreamingPayloadError {
|
||||
Stream(Error),
|
||||
InvalidSignature,
|
||||
Message(String),
|
||||
}
|
||||
|
||||
impl SignedPayloadStreamError {
|
||||
impl StreamingPayloadError {
|
||||
fn message(msg: &str) -> Self {
|
||||
SignedPayloadStreamError::Message(msg.into())
|
||||
StreamingPayloadError::Message(msg.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SignedPayloadStreamError> for Error {
|
||||
fn from(err: SignedPayloadStreamError) -> Self {
|
||||
impl From<StreamingPayloadError> for Error {
|
||||
fn from(err: StreamingPayloadError) -> Self {
|
||||
match err {
|
||||
SignedPayloadStreamError::Stream(e) => e,
|
||||
SignedPayloadStreamError::InvalidSignature => {
|
||||
StreamingPayloadError::Stream(e) => e,
|
||||
StreamingPayloadError::InvalidSignature => {
|
||||
Error::bad_request("Invalid payload signature")
|
||||
}
|
||||
SignedPayloadStreamError::Message(e) => {
|
||||
StreamingPayloadError::Message(e) => {
|
||||
Error::bad_request(format!("Chunk format error: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<I> From<payload::Error<I>> for SignedPayloadStreamError {
|
||||
impl<I> From<payload::Error<I>> for StreamingPayloadError {
|
||||
fn from(err: payload::Error<I>) -> Self {
|
||||
Self::message(err.description())
|
||||
}
|
||||
}
|
||||
|
||||
impl<I> From<nom::error::Error<I>> for SignedPayloadStreamError {
|
||||
impl<I> From<nom::error::Error<I>> for StreamingPayloadError {
|
||||
fn from(err: nom::error::Error<I>) -> Self {
|
||||
Self::message(err.code.description())
|
||||
}
|
||||
}
|
||||
|
||||
struct SignedPayload {
|
||||
header: payload::Header,
|
||||
data: Bytes,
|
||||
enum StreamingPayloadChunk {
|
||||
Chunk {
|
||||
header: payload::ChunkHeader,
|
||||
data: Bytes,
|
||||
},
|
||||
Trailer(payload::TrailerChunk),
|
||||
}
|
||||
|
||||
#[pin_project::pin_project]
|
||||
pub struct SignedPayloadStream<S>
|
||||
where
|
||||
S: Stream<Item = Result<Bytes, Error>>,
|
||||
{
|
||||
#[pin]
|
||||
stream: S,
|
||||
buf: bytes::BytesMut,
|
||||
struct SignParams {
|
||||
datetime: DateTime<Utc>,
|
||||
scope: String,
|
||||
signing_hmac: HmacSha256,
|
||||
previous_signature: Hash,
|
||||
}
|
||||
|
||||
impl<S> SignedPayloadStream<S>
|
||||
#[pin_project::pin_project]
|
||||
pub struct StreamingPayloadStream<S>
|
||||
where
|
||||
S: Stream<Item = Result<Bytes, Error>>,
|
||||
{
|
||||
pub fn new(
|
||||
stream: S,
|
||||
signing_hmac: HmacSha256,
|
||||
datetime: DateTime<Utc>,
|
||||
scope: &str,
|
||||
seed_signature: Hash,
|
||||
) -> Self {
|
||||
#[pin]
|
||||
stream: S,
|
||||
buf: bytes::BytesMut,
|
||||
signing: Option<SignParams>,
|
||||
has_trailer: bool,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl<S> StreamingPayloadStream<S>
|
||||
where
|
||||
S: Stream<Item = Result<Bytes, Error>>,
|
||||
{
|
||||
fn new(stream: S, signing: Option<SignParams>, has_trailer: bool) -> Self {
|
||||
Self {
|
||||
stream,
|
||||
buf: bytes::BytesMut::new(),
|
||||
datetime,
|
||||
scope: scope.into(),
|
||||
signing_hmac,
|
||||
previous_signature: seed_signature,
|
||||
signing,
|
||||
has_trailer,
|
||||
done: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_next(input: &[u8]) -> nom::IResult<&[u8], SignedPayload, SignedPayloadStreamError> {
|
||||
fn parse_next(
|
||||
input: &[u8],
|
||||
is_signed: bool,
|
||||
has_trailer: bool,
|
||||
) -> nom::IResult<&[u8], StreamingPayloadChunk, StreamingPayloadError> {
|
||||
use nom::bytes::streaming::{tag, take};
|
||||
|
||||
macro_rules! try_parse {
|
||||
@@ -238,17 +413,30 @@ where
|
||||
};
|
||||
}
|
||||
|
||||
let (input, header) = try_parse!(payload::Header::parse(input));
|
||||
let (input, header) = if is_signed {
|
||||
try_parse!(payload::ChunkHeader::parse_signed(input))
|
||||
} else {
|
||||
try_parse!(payload::ChunkHeader::parse_unsigned(input))
|
||||
};
|
||||
|
||||
// 0-sized chunk is the last
|
||||
if header.size == 0 {
|
||||
return Ok((
|
||||
input,
|
||||
SignedPayload {
|
||||
header,
|
||||
data: Bytes::new(),
|
||||
},
|
||||
));
|
||||
if has_trailer {
|
||||
let (input, trailer) = if is_signed {
|
||||
try_parse!(payload::TrailerChunk::parse_signed(input))
|
||||
} else {
|
||||
try_parse!(payload::TrailerChunk::parse_unsigned(input))
|
||||
};
|
||||
return Ok((input, StreamingPayloadChunk::Trailer(trailer)));
|
||||
} else {
|
||||
return Ok((
|
||||
input,
|
||||
StreamingPayloadChunk::Chunk {
|
||||
header,
|
||||
data: Bytes::new(),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let (input, data) = try_parse!(take::<_, _, nom::error::Error<_>>(header.size)(input));
|
||||
@@ -256,15 +444,15 @@ where
|
||||
|
||||
let data = Bytes::from(data.to_vec());
|
||||
|
||||
Ok((input, SignedPayload { header, data }))
|
||||
Ok((input, StreamingPayloadChunk::Chunk { header, data }))
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Stream for SignedPayloadStream<S>
|
||||
impl<S> Stream for StreamingPayloadStream<S>
|
||||
where
|
||||
S: Stream<Item = Result<Bytes, Error>> + Unpin,
|
||||
{
|
||||
type Item = Result<Bytes, SignedPayloadStreamError>;
|
||||
type Item = Result<Frame<Bytes>, StreamingPayloadError>;
|
||||
|
||||
fn poll_next(
|
||||
self: Pin<&mut Self>,
|
||||
@@ -274,56 +462,105 @@ where
|
||||
|
||||
let mut this = self.project();
|
||||
|
||||
if *this.done {
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
|
||||
loop {
|
||||
let (input, payload) = match Self::parse_next(this.buf) {
|
||||
Ok(res) => res,
|
||||
Err(nom::Err::Incomplete(_)) => {
|
||||
match futures::ready!(this.stream.as_mut().poll_next(cx)) {
|
||||
Some(Ok(bytes)) => {
|
||||
this.buf.extend(bytes);
|
||||
continue;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
return Poll::Ready(Some(Err(SignedPayloadStreamError::Stream(e))))
|
||||
}
|
||||
None => {
|
||||
return Poll::Ready(Some(Err(SignedPayloadStreamError::message(
|
||||
"Unexpected EOF",
|
||||
))));
|
||||
let (input, payload) =
|
||||
match Self::parse_next(this.buf, this.signing.is_some(), *this.has_trailer) {
|
||||
Ok(res) => res,
|
||||
Err(nom::Err::Incomplete(_)) => {
|
||||
match futures::ready!(this.stream.as_mut().poll_next(cx)) {
|
||||
Some(Ok(bytes)) => {
|
||||
this.buf.extend(bytes);
|
||||
continue;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
return Poll::Ready(Some(Err(StreamingPayloadError::Stream(e))))
|
||||
}
|
||||
None => {
|
||||
return Poll::Ready(Some(Err(StreamingPayloadError::message(
|
||||
"Unexpected EOF",
|
||||
))));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(nom::Err::Error(e)) | Err(nom::Err::Failure(e)) => {
|
||||
return Poll::Ready(Some(Err(e)))
|
||||
}
|
||||
};
|
||||
|
||||
match payload {
|
||||
StreamingPayloadChunk::Chunk { data, header } => {
|
||||
if let Some(signing) = this.signing.as_mut() {
|
||||
let data_sha256sum = sha256sum(&data);
|
||||
|
||||
let expected_signature = compute_streaming_payload_signature(
|
||||
&signing.signing_hmac,
|
||||
signing.datetime,
|
||||
&signing.scope,
|
||||
signing.previous_signature,
|
||||
data_sha256sum,
|
||||
)?;
|
||||
|
||||
if header.signature.unwrap() != expected_signature {
|
||||
return Poll::Ready(Some(Err(StreamingPayloadError::InvalidSignature)));
|
||||
}
|
||||
|
||||
signing.previous_signature = header.signature.unwrap();
|
||||
}
|
||||
|
||||
*this.buf = input.into();
|
||||
|
||||
// 0-sized chunk is the last
|
||||
if data.is_empty() {
|
||||
// if there was a trailer, it would have been returned by the parser
|
||||
assert!(!*this.has_trailer);
|
||||
*this.done = true;
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
|
||||
return Poll::Ready(Some(Ok(Frame::data(data))));
|
||||
}
|
||||
Err(nom::Err::Error(e)) | Err(nom::Err::Failure(e)) => {
|
||||
return Poll::Ready(Some(Err(e)))
|
||||
StreamingPayloadChunk::Trailer(trailer) => {
|
||||
trace!(
|
||||
"In StreamingPayloadStream::poll_next: got trailer {:?}",
|
||||
trailer
|
||||
);
|
||||
|
||||
if let Some(signing) = this.signing.as_mut() {
|
||||
let data = [
|
||||
trailer.header_name.as_ref(),
|
||||
&b":"[..],
|
||||
trailer.header_value.as_ref(),
|
||||
&b"\n"[..],
|
||||
]
|
||||
.concat();
|
||||
let trailer_sha256sum = sha256sum(&data);
|
||||
|
||||
let expected_signature = compute_streaming_trailer_signature(
|
||||
&signing.signing_hmac,
|
||||
signing.datetime,
|
||||
&signing.scope,
|
||||
signing.previous_signature,
|
||||
trailer_sha256sum,
|
||||
)?;
|
||||
|
||||
if trailer.signature.unwrap() != expected_signature {
|
||||
return Poll::Ready(Some(Err(StreamingPayloadError::InvalidSignature)));
|
||||
}
|
||||
}
|
||||
|
||||
*this.buf = input.into();
|
||||
*this.done = true;
|
||||
|
||||
let mut trailers_map = HeaderMap::new();
|
||||
trailers_map.insert(trailer.header_name, trailer.header_value);
|
||||
|
||||
return Poll::Ready(Some(Ok(Frame::trailers(trailers_map))));
|
||||
}
|
||||
};
|
||||
|
||||
// 0-sized chunk is the last
|
||||
if payload.data.is_empty() {
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
|
||||
let data_sha256sum = sha256sum(&payload.data);
|
||||
|
||||
let expected_signature = compute_streaming_payload_signature(
|
||||
this.signing_hmac,
|
||||
*this.datetime,
|
||||
this.scope,
|
||||
*this.previous_signature,
|
||||
data_sha256sum,
|
||||
)
|
||||
.map_err(|e| {
|
||||
SignedPayloadStreamError::Message(format!("Could not build signature: {}", e))
|
||||
})?;
|
||||
|
||||
if payload.header.signature != expected_signature {
|
||||
return Poll::Ready(Some(Err(SignedPayloadStreamError::InvalidSignature)));
|
||||
}
|
||||
|
||||
*this.buf = input.into();
|
||||
*this.previous_signature = payload.header.signature;
|
||||
|
||||
return Poll::Ready(Some(Ok(payload.data)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,7 +573,7 @@ where
|
||||
mod tests {
|
||||
use futures::prelude::*;
|
||||
|
||||
use super::{SignedPayloadStream, SignedPayloadStreamError};
|
||||
use super::{SignParams, StreamingPayloadError, StreamingPayloadStream};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_interrupted_signed_payload_stream() {
|
||||
@@ -358,12 +595,20 @@ mod tests {
|
||||
|
||||
let seed_signature = Hash::default();
|
||||
|
||||
let mut stream =
|
||||
SignedPayloadStream::new(body, signing_hmac, datetime, &scope, seed_signature);
|
||||
let mut stream = StreamingPayloadStream::new(
|
||||
body,
|
||||
Some(SignParams {
|
||||
signing_hmac,
|
||||
datetime,
|
||||
scope,
|
||||
previous_signature: seed_signature,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(stream.try_next().await.is_err());
|
||||
match stream.try_next().await {
|
||||
Err(SignedPayloadStreamError::Message(msg)) if msg == "Unexpected EOF" => {}
|
||||
Err(StreamingPayloadError::Message(msg)) if msg == "Unexpected EOF" => {}
|
||||
item => panic!(
|
||||
"Unexpected result, expected early EOF error, got {:?}",
|
||||
item
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
[package]
|
||||
name = "garage_api_k2v"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||
edition = "2018"
|
||||
license = "AGPL-3.0"
|
||||
description = "K2V API server crate for the Garage object store"
|
||||
repository = "https://git.deuxfleurs.fr/Deuxfleurs/garage"
|
||||
readme = "../../README.md"
|
||||
readme = "../../../README.md"
|
||||
|
||||
[lib]
|
||||
path = "lib.rs"
|
||||
|
||||
@@ -81,7 +81,9 @@ impl ApiHandler for K2VApiServer {
|
||||
return Ok(options_res.map(|_empty_body: EmptyBody| empty_body()));
|
||||
}
|
||||
|
||||
let (req, api_key, _content_sha256) = verify_request(&garage, req, "k2v").await?;
|
||||
let verified_request = verify_request(&garage, req, "k2v").await?;
|
||||
let req = verified_request.request;
|
||||
let api_key = verified_request.access_key;
|
||||
|
||||
let bucket_id = garage
|
||||
.bucket_helper()
|
||||
|
||||
@@ -20,7 +20,7 @@ pub async fn handle_insert_batch(
|
||||
let ReqCtx {
|
||||
garage, bucket_id, ..
|
||||
} = &ctx;
|
||||
let items = parse_json_body::<Vec<InsertBatchItem>, _, Error>(req).await?;
|
||||
let items = req.into_body().json::<Vec<InsertBatchItem>>().await?;
|
||||
|
||||
let mut items2 = vec![];
|
||||
for it in items {
|
||||
@@ -47,7 +47,7 @@ pub async fn handle_read_batch(
|
||||
ctx: ReqCtx,
|
||||
req: Request<ReqBody>,
|
||||
) -> Result<Response<ResBody>, Error> {
|
||||
let queries = parse_json_body::<Vec<ReadBatchQuery>, _, Error>(req).await?;
|
||||
let queries = req.into_body().json::<Vec<ReadBatchQuery>>().await?;
|
||||
|
||||
let resp_results = futures::future::join_all(
|
||||
queries
|
||||
@@ -141,7 +141,7 @@ pub async fn handle_delete_batch(
|
||||
ctx: ReqCtx,
|
||||
req: Request<ReqBody>,
|
||||
) -> Result<Response<ResBody>, Error> {
|
||||
let queries = parse_json_body::<Vec<DeleteBatchQuery>, _, Error>(req).await?;
|
||||
let queries = req.into_body().json::<Vec<DeleteBatchQuery>>().await?;
|
||||
|
||||
let resp_results = futures::future::join_all(
|
||||
queries
|
||||
@@ -262,7 +262,7 @@ pub(crate) async fn handle_poll_range(
|
||||
} = ctx;
|
||||
use garage_model::k2v::sub::PollRange;
|
||||
|
||||
let query = parse_json_body::<PollRangeQuery, _, Error>(req).await?;
|
||||
let query = req.into_body().json::<PollRangeQuery>().await?;
|
||||
|
||||
let timeout_msec = query.timeout.unwrap_or(300).clamp(1, 600) * 1000;
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ pub enum Error {
|
||||
#[error(display = "Authorization header malformed, unexpected scope: {}", _0)]
|
||||
AuthorizationHeaderMalformed(String),
|
||||
|
||||
/// The provided digest (checksum) value was invalid
|
||||
#[error(display = "Invalid digest: {}", _0)]
|
||||
InvalidDigest(String),
|
||||
|
||||
/// The object requested don't exists
|
||||
#[error(display = "Key not found")]
|
||||
NoSuchKey,
|
||||
@@ -54,6 +58,7 @@ impl From<SignatureError> for Error {
|
||||
Self::AuthorizationHeaderMalformed(c)
|
||||
}
|
||||
SignatureError::InvalidUtf8Str(i) => Self::InvalidUtf8Str(i),
|
||||
SignatureError::InvalidDigest(d) => Self::InvalidDigest(d),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +76,7 @@ impl Error {
|
||||
Error::InvalidBase64(_) => "InvalidBase64",
|
||||
Error::InvalidUtf8Str(_) => "InvalidUtf8String",
|
||||
Error::InvalidCausalityToken => "CausalityToken",
|
||||
Error::InvalidDigest(_) => "InvalidDigest",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +91,7 @@ impl ApiError for Error {
|
||||
Error::AuthorizationHeaderMalformed(_)
|
||||
| Error::InvalidBase64(_)
|
||||
| Error::InvalidUtf8Str(_)
|
||||
| Error::InvalidDigest(_)
|
||||
| Error::InvalidCausalityToken => StatusCode::BAD_REQUEST,
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -144,9 +144,7 @@ pub async fn handle_insert_item(
|
||||
.map(parse_causality_token)
|
||||
.transpose()?;
|
||||
|
||||
let body = http_body_util::BodyExt::collect(req.into_body())
|
||||
.await?
|
||||
.to_bytes();
|
||||
let body = req.into_body().collect().await?;
|
||||
|
||||
let value = DvvsValue::Value(body.to_vec());
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
[package]
|
||||
name = "garage_api_s3"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||
edition = "2018"
|
||||
license = "AGPL-3.0"
|
||||
description = "S3 API server crate for the Garage object store"
|
||||
repository = "https://git.deuxfleurs.fr/Deuxfleurs/garage"
|
||||
readme = "../../README.md"
|
||||
readme = "../../../README.md"
|
||||
|
||||
[lib]
|
||||
path = "lib.rs"
|
||||
|
||||
+13
-20
@@ -121,7 +121,9 @@ impl ApiHandler for S3ApiServer {
|
||||
return Ok(options_res.map(|_empty_body: EmptyBody| empty_body()));
|
||||
}
|
||||
|
||||
let (req, api_key, content_sha256) = verify_request(&garage, req, "s3").await?;
|
||||
let verified_request = verify_request(&garage, req, "s3").await?;
|
||||
let req = verified_request.request;
|
||||
let api_key = verified_request.access_key;
|
||||
|
||||
let bucket_name = match bucket_name {
|
||||
None => {
|
||||
@@ -134,14 +136,7 @@ impl ApiHandler for S3ApiServer {
|
||||
|
||||
// Special code path for CreateBucket API endpoint
|
||||
if let Endpoint::CreateBucket {} = endpoint {
|
||||
return handle_create_bucket(
|
||||
&garage,
|
||||
req,
|
||||
content_sha256,
|
||||
&api_key.key_id,
|
||||
bucket_name,
|
||||
)
|
||||
.await;
|
||||
return handle_create_bucket(&garage, req, &api_key.key_id, bucket_name).await;
|
||||
}
|
||||
|
||||
let bucket_id = garage
|
||||
@@ -179,7 +174,7 @@ impl ApiHandler for S3ApiServer {
|
||||
let resp = match endpoint {
|
||||
Endpoint::HeadObject {
|
||||
key, part_number, ..
|
||||
} => handle_head(ctx, &req, &key, part_number).await,
|
||||
} => handle_head(ctx, &req.map(|_| ()), &key, part_number).await,
|
||||
Endpoint::GetObject {
|
||||
key,
|
||||
part_number,
|
||||
@@ -199,20 +194,20 @@ impl ApiHandler for S3ApiServer {
|
||||
response_content_type,
|
||||
response_expires,
|
||||
};
|
||||
handle_get(ctx, &req, &key, part_number, overrides).await
|
||||
handle_get(ctx, &req.map(|_| ()), &key, part_number, overrides).await
|
||||
}
|
||||
Endpoint::UploadPart {
|
||||
key,
|
||||
part_number,
|
||||
upload_id,
|
||||
} => handle_put_part(ctx, req, &key, part_number, &upload_id, content_sha256).await,
|
||||
} => handle_put_part(ctx, req, &key, part_number, &upload_id).await,
|
||||
Endpoint::CopyObject { key } => handle_copy(ctx, &req, &key).await,
|
||||
Endpoint::UploadPartCopy {
|
||||
key,
|
||||
part_number,
|
||||
upload_id,
|
||||
} => handle_upload_part_copy(ctx, &req, &key, part_number, &upload_id).await,
|
||||
Endpoint::PutObject { key } => handle_put(ctx, req, &key, content_sha256).await,
|
||||
Endpoint::PutObject { key } => handle_put(ctx, req, &key).await,
|
||||
Endpoint::AbortMultipartUpload { key, upload_id } => {
|
||||
handle_abort_multipart_upload(ctx, &key, &upload_id).await
|
||||
}
|
||||
@@ -221,7 +216,7 @@ impl ApiHandler for S3ApiServer {
|
||||
handle_create_multipart_upload(ctx, &req, &key).await
|
||||
}
|
||||
Endpoint::CompleteMultipartUpload { key, upload_id } => {
|
||||
handle_complete_multipart_upload(ctx, req, &key, &upload_id, content_sha256).await
|
||||
handle_complete_multipart_upload(ctx, req, &key, &upload_id).await
|
||||
}
|
||||
Endpoint::CreateBucket {} => unreachable!(),
|
||||
Endpoint::HeadBucket {} => {
|
||||
@@ -324,17 +319,15 @@ impl ApiHandler for S3ApiServer {
|
||||
};
|
||||
handle_list_parts(ctx, req, &query).await
|
||||
}
|
||||
Endpoint::DeleteObjects {} => handle_delete_objects(ctx, req, content_sha256).await,
|
||||
Endpoint::DeleteObjects {} => handle_delete_objects(ctx, req).await,
|
||||
Endpoint::GetBucketWebsite {} => handle_get_website(ctx).await,
|
||||
Endpoint::PutBucketWebsite {} => handle_put_website(ctx, req, content_sha256).await,
|
||||
Endpoint::PutBucketWebsite {} => handle_put_website(ctx, req).await,
|
||||
Endpoint::DeleteBucketWebsite {} => handle_delete_website(ctx).await,
|
||||
Endpoint::GetBucketCors {} => handle_get_cors(ctx).await,
|
||||
Endpoint::PutBucketCors {} => handle_put_cors(ctx, req, content_sha256).await,
|
||||
Endpoint::PutBucketCors {} => handle_put_cors(ctx, req).await,
|
||||
Endpoint::DeleteBucketCors {} => handle_delete_cors(ctx).await,
|
||||
Endpoint::GetBucketLifecycleConfiguration {} => handle_get_lifecycle(ctx).await,
|
||||
Endpoint::PutBucketLifecycleConfiguration {} => {
|
||||
handle_put_lifecycle(ctx, req, content_sha256).await
|
||||
}
|
||||
Endpoint::PutBucketLifecycleConfiguration {} => handle_put_lifecycle(ctx, req).await,
|
||||
Endpoint::DeleteBucketLifecycle {} => handle_delete_lifecycle(ctx).await,
|
||||
endpoint => Err(Error::NotImplemented(endpoint.name().to_owned())),
|
||||
};
|
||||
|
||||
+4
-12
@@ -1,6 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use http_body_util::BodyExt;
|
||||
use hyper::{Request, Response, StatusCode};
|
||||
|
||||
use garage_model::bucket_alias_table::*;
|
||||
@@ -10,12 +9,10 @@ use garage_model::key_table::Key;
|
||||
use garage_model::permission::BucketKeyPerm;
|
||||
use garage_table::util::*;
|
||||
use garage_util::crdt::*;
|
||||
use garage_util::data::*;
|
||||
use garage_util::time::*;
|
||||
|
||||
use garage_api_common::common_error::CommonError;
|
||||
use garage_api_common::helpers::*;
|
||||
use garage_api_common::signature::verify_signed_content;
|
||||
|
||||
use crate::api_server::{ReqBody, ResBody};
|
||||
use crate::error::*;
|
||||
@@ -122,15 +119,10 @@ pub async fn handle_list_buckets(
|
||||
pub async fn handle_create_bucket(
|
||||
garage: &Garage,
|
||||
req: Request<ReqBody>,
|
||||
content_sha256: Option<Hash>,
|
||||
api_key_id: &String,
|
||||
bucket_name: String,
|
||||
) -> Result<Response<ResBody>, Error> {
|
||||
let body = BodyExt::collect(req.into_body()).await?.to_bytes();
|
||||
|
||||
if let Some(content_sha256) = content_sha256 {
|
||||
verify_signed_content(content_sha256, &body[..])?;
|
||||
}
|
||||
let body = req.into_body().collect().await?;
|
||||
|
||||
let cmd =
|
||||
parse_create_bucket_xml(&body[..]).ok_or_bad_request("Invalid create bucket XML query")?;
|
||||
@@ -180,7 +172,7 @@ pub async fn handle_create_bucket(
|
||||
}
|
||||
|
||||
// Create the bucket!
|
||||
if !is_valid_bucket_name(&bucket_name) {
|
||||
if !is_valid_bucket_name(&bucket_name, garage.config.allow_punycode) {
|
||||
return Err(Error::bad_request(format!(
|
||||
"{}: {}",
|
||||
bucket_name, INVALID_BUCKET_NAME_MESSAGE
|
||||
@@ -249,11 +241,11 @@ pub async fn handle_delete_bucket(ctx: ReqCtx) -> Result<Response<ResBody>, Erro
|
||||
// 1. delete bucket alias
|
||||
if is_local_alias {
|
||||
helper
|
||||
.unset_local_bucket_alias(*bucket_id, &api_key.key_id, bucket_name)
|
||||
.purge_local_bucket_alias(*bucket_id, &api_key.key_id, bucket_name)
|
||||
.await?;
|
||||
} else {
|
||||
helper
|
||||
.unset_global_bucket_alias(*bucket_id, bucket_name)
|
||||
.purge_global_bucket_alias(*bucket_id, bucket_name)
|
||||
.await?;
|
||||
}
|
||||
|
||||
|
||||
+31
-101
@@ -1,9 +1,9 @@
|
||||
use std::pin::Pin;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use futures::{stream, stream::Stream, StreamExt, TryStreamExt};
|
||||
|
||||
use bytes::Bytes;
|
||||
use http::header::HeaderName;
|
||||
use hyper::{Request, Response};
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -21,16 +21,26 @@ use garage_model::s3::object_table::*;
|
||||
use garage_model::s3::version_table::*;
|
||||
|
||||
use garage_api_common::helpers::*;
|
||||
use garage_api_common::signature::checksum::*;
|
||||
|
||||
use crate::api_server::{ReqBody, ResBody};
|
||||
use crate::checksum::*;
|
||||
use crate::encryption::EncryptionParams;
|
||||
use crate::error::*;
|
||||
use crate::get::full_object_byte_stream;
|
||||
use crate::get::{full_object_byte_stream, PreconditionHeaders};
|
||||
use crate::multipart;
|
||||
use crate::put::{get_headers, save_stream, ChecksumMode, SaveStreamResult};
|
||||
use crate::put::{extract_metadata_headers, save_stream, ChecksumMode, SaveStreamResult};
|
||||
use crate::website::X_AMZ_WEBSITE_REDIRECT_LOCATION;
|
||||
use crate::xml::{self as s3_xml, xmlns_tag};
|
||||
|
||||
pub const X_AMZ_COPY_SOURCE_IF_MATCH: HeaderName =
|
||||
HeaderName::from_static("x-amz-copy-source-if-match");
|
||||
pub const X_AMZ_COPY_SOURCE_IF_NONE_MATCH: HeaderName =
|
||||
HeaderName::from_static("x-amz-copy-source-if-none-match");
|
||||
pub const X_AMZ_COPY_SOURCE_IF_MODIFIED_SINCE: HeaderName =
|
||||
HeaderName::from_static("x-amz-copy-source-if-modified-since");
|
||||
pub const X_AMZ_COPY_SOURCE_IF_UNMODIFIED_SINCE: HeaderName =
|
||||
HeaderName::from_static("x-amz-copy-source-if-unmodified-since");
|
||||
|
||||
// -------- CopyObject ---------
|
||||
|
||||
pub async fn handle_copy(
|
||||
@@ -38,7 +48,7 @@ pub async fn handle_copy(
|
||||
req: &Request<ReqBody>,
|
||||
dest_key: &str,
|
||||
) -> Result<Response<ResBody>, Error> {
|
||||
let copy_precondition = CopyPreconditionHeaders::parse(req)?;
|
||||
let copy_precondition = PreconditionHeaders::parse_copy_source(req)?;
|
||||
|
||||
let checksum_algorithm = request_checksum_algorithm(req.headers())?;
|
||||
|
||||
@@ -48,7 +58,7 @@ pub async fn handle_copy(
|
||||
extract_source_info(&source_object)?;
|
||||
|
||||
// Check precondition, e.g. x-amz-copy-source-if-match
|
||||
copy_precondition.check(source_version, &source_version_meta.etag)?;
|
||||
copy_precondition.check_copy_source(source_version, &source_version_meta.etag)?;
|
||||
|
||||
// Determine encryption parameters
|
||||
let (source_encryption, source_object_meta_inner) =
|
||||
@@ -73,9 +83,20 @@ pub async fn handle_copy(
|
||||
let dest_object_meta = ObjectVersionMetaInner {
|
||||
headers: match req.headers().get("x-amz-metadata-directive") {
|
||||
Some(v) if v == hyper::header::HeaderValue::from_static("REPLACE") => {
|
||||
get_headers(req.headers())?
|
||||
extract_metadata_headers(req.headers())?
|
||||
}
|
||||
_ => {
|
||||
// The x-amz-website-redirect-location header is not copied, instead
|
||||
// it is replaced by the value from the request (or removed if no
|
||||
// value was specified)
|
||||
let is_redirect =
|
||||
|(key, _): &(String, String)| key == X_AMZ_WEBSITE_REDIRECT_LOCATION.as_str();
|
||||
let mut headers: Vec<_> = source_object_meta_inner.headers.clone();
|
||||
headers.retain(|h| !is_redirect(h));
|
||||
let new_headers = extract_metadata_headers(req.headers())?;
|
||||
headers.extend(new_headers.into_iter().filter(is_redirect));
|
||||
headers
|
||||
}
|
||||
_ => source_object_meta_inner.into_owned().headers,
|
||||
},
|
||||
checksum: source_checksum,
|
||||
};
|
||||
@@ -335,7 +356,7 @@ pub async fn handle_upload_part_copy(
|
||||
part_number: u64,
|
||||
upload_id: &str,
|
||||
) -> Result<Response<ResBody>, Error> {
|
||||
let copy_precondition = CopyPreconditionHeaders::parse(req)?;
|
||||
let copy_precondition = PreconditionHeaders::parse_copy_source(req)?;
|
||||
|
||||
let dest_upload_id = multipart::decode_upload_id(upload_id)?;
|
||||
|
||||
@@ -351,7 +372,7 @@ pub async fn handle_upload_part_copy(
|
||||
extract_source_info(&source_object)?;
|
||||
|
||||
// Check precondition on source, e.g. x-amz-copy-source-if-match
|
||||
copy_precondition.check(source_object_version, &source_version_meta.etag)?;
|
||||
copy_precondition.check_copy_source(source_object_version, &source_version_meta.etag)?;
|
||||
|
||||
// Determine encryption parameters
|
||||
let (source_encryption, _) = EncryptionParams::check_decrypt_for_copy_source(
|
||||
@@ -703,97 +724,6 @@ fn extract_source_info(
|
||||
Ok((source_version, source_version_data, source_version_meta))
|
||||
}
|
||||
|
||||
struct CopyPreconditionHeaders {
|
||||
copy_source_if_match: Option<Vec<String>>,
|
||||
copy_source_if_modified_since: Option<SystemTime>,
|
||||
copy_source_if_none_match: Option<Vec<String>>,
|
||||
copy_source_if_unmodified_since: Option<SystemTime>,
|
||||
}
|
||||
|
||||
impl CopyPreconditionHeaders {
|
||||
fn parse(req: &Request<ReqBody>) -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
copy_source_if_match: req
|
||||
.headers()
|
||||
.get("x-amz-copy-source-if-match")
|
||||
.map(|x| x.to_str())
|
||||
.transpose()?
|
||||
.map(|x| {
|
||||
x.split(',')
|
||||
.map(|m| m.trim().trim_matches('"').to_string())
|
||||
.collect::<Vec<_>>()
|
||||
}),
|
||||
copy_source_if_modified_since: req
|
||||
.headers()
|
||||
.get("x-amz-copy-source-if-modified-since")
|
||||
.map(|x| x.to_str())
|
||||
.transpose()?
|
||||
.map(httpdate::parse_http_date)
|
||||
.transpose()
|
||||
.ok_or_bad_request("Invalid date in x-amz-copy-source-if-modified-since")?,
|
||||
copy_source_if_none_match: req
|
||||
.headers()
|
||||
.get("x-amz-copy-source-if-none-match")
|
||||
.map(|x| x.to_str())
|
||||
.transpose()?
|
||||
.map(|x| {
|
||||
x.split(',')
|
||||
.map(|m| m.trim().trim_matches('"').to_string())
|
||||
.collect::<Vec<_>>()
|
||||
}),
|
||||
copy_source_if_unmodified_since: req
|
||||
.headers()
|
||||
.get("x-amz-copy-source-if-unmodified-since")
|
||||
.map(|x| x.to_str())
|
||||
.transpose()?
|
||||
.map(httpdate::parse_http_date)
|
||||
.transpose()
|
||||
.ok_or_bad_request("Invalid date in x-amz-copy-source-if-unmodified-since")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn check(&self, v: &ObjectVersion, etag: &str) -> Result<(), Error> {
|
||||
let v_date = UNIX_EPOCH + Duration::from_millis(v.timestamp);
|
||||
|
||||
let ok = match (
|
||||
&self.copy_source_if_match,
|
||||
&self.copy_source_if_unmodified_since,
|
||||
&self.copy_source_if_none_match,
|
||||
&self.copy_source_if_modified_since,
|
||||
) {
|
||||
// TODO I'm not sure all of the conditions are evaluated correctly here
|
||||
|
||||
// If we have both if-match and if-unmodified-since,
|
||||
// basically we don't care about if-unmodified-since,
|
||||
// because in the spec it says that if if-match evaluates to
|
||||
// true but if-unmodified-since evaluates to false,
|
||||
// the copy is still done.
|
||||
(Some(im), _, None, None) => im.iter().any(|x| x == etag || x == "*"),
|
||||
(None, Some(ius), None, None) => v_date <= *ius,
|
||||
|
||||
// If we have both if-none-match and if-modified-since,
|
||||
// then both of the two conditions must evaluate to true
|
||||
(None, None, Some(inm), Some(ims)) => {
|
||||
!inm.iter().any(|x| x == etag || x == "*") && v_date > *ims
|
||||
}
|
||||
(None, None, Some(inm), None) => !inm.iter().any(|x| x == etag || x == "*"),
|
||||
(None, None, None, Some(ims)) => v_date > *ims,
|
||||
(None, None, None, None) => true,
|
||||
_ => {
|
||||
return Err(Error::bad_request(
|
||||
"Invalid combination of x-amz-copy-source-if-xxxxx headers",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::PreconditionFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type BlockStreamItemOk = (Bytes, Option<Hash>);
|
||||
type BlockStreamItem = Result<BlockStreamItemOk, garage_util::error::Error>;
|
||||
|
||||
|
||||
+1
-10
@@ -2,15 +2,11 @@ use quick_xml::de::from_reader;
|
||||
|
||||
use hyper::{header::HeaderName, Method, Request, Response, StatusCode};
|
||||
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use garage_model::bucket_table::{Bucket, CorsRule as GarageCorsRule};
|
||||
use garage_util::data::*;
|
||||
|
||||
use garage_api_common::helpers::*;
|
||||
use garage_api_common::signature::verify_signed_content;
|
||||
|
||||
use crate::api_server::{ReqBody, ResBody};
|
||||
use crate::error::*;
|
||||
@@ -59,7 +55,6 @@ pub async fn handle_delete_cors(ctx: ReqCtx) -> Result<Response<ResBody>, Error>
|
||||
pub async fn handle_put_cors(
|
||||
ctx: ReqCtx,
|
||||
req: Request<ReqBody>,
|
||||
content_sha256: Option<Hash>,
|
||||
) -> Result<Response<ResBody>, Error> {
|
||||
let ReqCtx {
|
||||
garage,
|
||||
@@ -68,11 +63,7 @@ pub async fn handle_put_cors(
|
||||
..
|
||||
} = ctx;
|
||||
|
||||
let body = BodyExt::collect(req.into_body()).await?.to_bytes();
|
||||
|
||||
if let Some(content_sha256) = content_sha256 {
|
||||
verify_signed_content(content_sha256, &body[..])?;
|
||||
}
|
||||
let body = req.into_body().collect().await?;
|
||||
|
||||
let conf: CorsConfiguration = from_reader(&body as &[u8])?;
|
||||
conf.validate()?;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use http_body_util::BodyExt;
|
||||
use hyper::{Request, Response, StatusCode};
|
||||
|
||||
use garage_util::data::*;
|
||||
@@ -6,7 +5,6 @@ use garage_util::data::*;
|
||||
use garage_model::s3::object_table::*;
|
||||
|
||||
use garage_api_common::helpers::*;
|
||||
use garage_api_common::signature::verify_signed_content;
|
||||
|
||||
use crate::api_server::{ReqBody, ResBody};
|
||||
use crate::error::*;
|
||||
@@ -68,13 +66,8 @@ pub async fn handle_delete(ctx: ReqCtx, key: &str) -> Result<Response<ResBody>,
|
||||
pub async fn handle_delete_objects(
|
||||
ctx: ReqCtx,
|
||||
req: Request<ReqBody>,
|
||||
content_sha256: Option<Hash>,
|
||||
) -> Result<Response<ResBody>, Error> {
|
||||
let body = BodyExt::collect(req.into_body()).await?.to_bytes();
|
||||
|
||||
if let Some(content_sha256) = content_sha256 {
|
||||
verify_signed_content(content_sha256, &body[..])?;
|
||||
}
|
||||
let body = req.into_body().collect().await?;
|
||||
|
||||
let cmd_xml = roxmltree::Document::parse(std::str::from_utf8(&body)?)?;
|
||||
let cmd = parse_delete_objects_xml(&cmd_xml).ok_or_bad_request("Invalid delete XML query")?;
|
||||
|
||||
@@ -29,8 +29,8 @@ use garage_model::garage::Garage;
|
||||
use garage_model::s3::object_table::{ObjectVersionEncryption, ObjectVersionMetaInner};
|
||||
|
||||
use garage_api_common::common_error::*;
|
||||
use garage_api_common::signature::checksum::Md5Checksum;
|
||||
|
||||
use crate::checksum::Md5Checksum;
|
||||
use crate::error::Error;
|
||||
|
||||
const X_AMZ_SERVER_SIDE_ENCRYPTION_CUSTOMER_ALGORITHM: HeaderName =
|
||||
|
||||
+2
-1
@@ -80,7 +80,7 @@ pub enum Error {
|
||||
#[error(display = "Invalid encryption algorithm: {:?}, should be AES256", _0)]
|
||||
InvalidEncryptionAlgorithm(String),
|
||||
|
||||
/// The client sent invalid XML data
|
||||
/// The provided digest (checksum) value was invalid
|
||||
#[error(display = "Invalid digest: {}", _0)]
|
||||
InvalidDigest(String),
|
||||
|
||||
@@ -119,6 +119,7 @@ impl From<SignatureError> for Error {
|
||||
Self::AuthorizationHeaderMalformed(c)
|
||||
}
|
||||
SignatureError::InvalidUtf8Str(i) => Self::InvalidUtf8Str(i),
|
||||
SignatureError::InvalidDigest(d) => Self::InvalidDigest(d),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+152
-48
@@ -2,17 +2,17 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::TryInto;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::future;
|
||||
use futures::stream::{self, Stream, StreamExt};
|
||||
use http::header::{
|
||||
ACCEPT_RANGES, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE,
|
||||
CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, EXPIRES, IF_MODIFIED_SINCE, IF_NONE_MATCH,
|
||||
LAST_MODIFIED, RANGE,
|
||||
HeaderMap, HeaderName, ACCEPT_RANGES, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING,
|
||||
CONTENT_LANGUAGE, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, EXPIRES, IF_MATCH,
|
||||
IF_MODIFIED_SINCE, IF_NONE_MATCH, IF_UNMODIFIED_SINCE, LAST_MODIFIED, RANGE,
|
||||
};
|
||||
use hyper::{body::Body, Request, Response, StatusCode};
|
||||
use hyper::{Request, Response, StatusCode};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use garage_net::stream::ByteStream;
|
||||
@@ -26,13 +26,14 @@ use garage_model::s3::object_table::*;
|
||||
use garage_model::s3::version_table::*;
|
||||
|
||||
use garage_api_common::helpers::*;
|
||||
use garage_api_common::signature::checksum::{add_checksum_response_headers, X_AMZ_CHECKSUM_MODE};
|
||||
|
||||
use crate::api_server::ResBody;
|
||||
use crate::checksum::{add_checksum_response_headers, X_AMZ_CHECKSUM_MODE};
|
||||
use crate::copy::*;
|
||||
use crate::encryption::EncryptionParams;
|
||||
use crate::error::*;
|
||||
|
||||
const X_AMZ_MP_PARTS_COUNT: &str = "x-amz-mp-parts-count";
|
||||
const X_AMZ_MP_PARTS_COUNT: HeaderName = HeaderName::from_static("x-amz-mp-parts-count");
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GetObjectOverrides {
|
||||
@@ -115,49 +116,29 @@ fn getobject_override_headers(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn try_answer_cached(
|
||||
fn handle_http_precondition(
|
||||
version: &ObjectVersion,
|
||||
version_meta: &ObjectVersionMeta,
|
||||
req: &Request<impl Body>,
|
||||
) -> Option<Response<ResBody>> {
|
||||
// <trinity> It is possible, and is even usually the case, [that both If-None-Match and
|
||||
// If-Modified-Since] are present in a request. In this situation If-None-Match takes
|
||||
// precedence and If-Modified-Since is ignored (as per 6.Precedence from rfc7232). The rational
|
||||
// being that etag based matching is more accurate, it has no issue with sub-second precision
|
||||
// for instance (in case of very fast updates)
|
||||
let cached = if let Some(none_match) = req.headers().get(IF_NONE_MATCH) {
|
||||
let none_match = none_match.to_str().ok()?;
|
||||
let expected = format!("\"{}\"", version_meta.etag);
|
||||
let found = none_match
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.any(|etag| etag == expected || etag == "\"*\"");
|
||||
found
|
||||
} else if let Some(modified_since) = req.headers().get(IF_MODIFIED_SINCE) {
|
||||
let modified_since = modified_since.to_str().ok()?;
|
||||
let client_date = httpdate::parse_http_date(modified_since).ok()?;
|
||||
let server_date = UNIX_EPOCH + Duration::from_millis(version.timestamp);
|
||||
client_date >= server_date
|
||||
} else {
|
||||
false
|
||||
};
|
||||
req: &Request<()>,
|
||||
) -> Result<Option<Response<ResBody>>, Error> {
|
||||
let precondition_headers = PreconditionHeaders::parse(req)?;
|
||||
|
||||
if cached {
|
||||
Some(
|
||||
if let Some(status_code) = precondition_headers.check(&version, &version_meta.etag)? {
|
||||
Ok(Some(
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_MODIFIED)
|
||||
.status(status_code)
|
||||
.body(empty_body())
|
||||
.unwrap(),
|
||||
)
|
||||
))
|
||||
} else {
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle HEAD request
|
||||
pub async fn handle_head(
|
||||
ctx: ReqCtx,
|
||||
req: &Request<impl Body>,
|
||||
req: &Request<()>,
|
||||
key: &str,
|
||||
part_number: Option<u64>,
|
||||
) -> Result<Response<ResBody>, Error> {
|
||||
@@ -167,7 +148,7 @@ pub async fn handle_head(
|
||||
/// Handle HEAD request for website
|
||||
pub async fn handle_head_without_ctx(
|
||||
garage: Arc<Garage>,
|
||||
req: &Request<impl Body>,
|
||||
req: &Request<()>,
|
||||
bucket_id: Uuid,
|
||||
key: &str,
|
||||
part_number: Option<u64>,
|
||||
@@ -196,8 +177,8 @@ pub async fn handle_head_without_ctx(
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
if let Some(cached) = try_answer_cached(object_version, version_meta, req) {
|
||||
return Ok(cached);
|
||||
if let Some(res) = handle_http_precondition(object_version, version_meta, req)? {
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
let (encryption, headers) =
|
||||
@@ -278,7 +259,7 @@ pub async fn handle_head_without_ctx(
|
||||
/// Handle GET request
|
||||
pub async fn handle_get(
|
||||
ctx: ReqCtx,
|
||||
req: &Request<impl Body>,
|
||||
req: &Request<()>,
|
||||
key: &str,
|
||||
part_number: Option<u64>,
|
||||
overrides: GetObjectOverrides,
|
||||
@@ -289,7 +270,7 @@ pub async fn handle_get(
|
||||
/// Handle GET request
|
||||
pub async fn handle_get_without_ctx(
|
||||
garage: Arc<Garage>,
|
||||
req: &Request<impl Body>,
|
||||
req: &Request<()>,
|
||||
bucket_id: Uuid,
|
||||
key: &str,
|
||||
part_number: Option<u64>,
|
||||
@@ -318,8 +299,8 @@ pub async fn handle_get_without_ctx(
|
||||
ObjectVersionData::FirstBlock(meta, _) => meta,
|
||||
};
|
||||
|
||||
if let Some(cached) = try_answer_cached(last_v, last_v_meta, req) {
|
||||
return Ok(cached);
|
||||
if let Some(res) = handle_http_precondition(last_v, last_v_meta, req)? {
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
let (enc, headers) =
|
||||
@@ -340,7 +321,12 @@ pub async fn handle_get_without_ctx(
|
||||
enc,
|
||||
&headers,
|
||||
pn,
|
||||
checksum_mode,
|
||||
ChecksumMode {
|
||||
// TODO: for multipart uploads, checksums of each part should be stored
|
||||
// so that we can return the corresponding checksum here
|
||||
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html
|
||||
enabled: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -354,7 +340,12 @@ pub async fn handle_get_without_ctx(
|
||||
&headers,
|
||||
range.start,
|
||||
range.start + range.length,
|
||||
checksum_mode,
|
||||
ChecksumMode {
|
||||
// TODO: for range queries that align with part boundaries,
|
||||
// we should return the saved checksum of the part
|
||||
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html
|
||||
enabled: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -577,7 +568,7 @@ async fn handle_get_part(
|
||||
}
|
||||
|
||||
fn parse_range_header(
|
||||
req: &Request<impl Body>,
|
||||
req: &Request<()>,
|
||||
total_size: u64,
|
||||
) -> Result<Option<http_range::HttpRange>, Error> {
|
||||
let range = match req.headers().get(RANGE) {
|
||||
@@ -618,7 +609,7 @@ struct ChecksumMode {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
fn checksum_mode(req: &Request<impl Body>) -> ChecksumMode {
|
||||
fn checksum_mode(req: &Request<()>) -> ChecksumMode {
|
||||
ChecksumMode {
|
||||
enabled: req
|
||||
.headers()
|
||||
@@ -751,3 +742,116 @@ fn std_error_from_read_error<E: std::fmt::Display>(e: E) -> std::io::Error {
|
||||
format!("Error while reading object data: {}", e),
|
||||
)
|
||||
}
|
||||
|
||||
// ----
|
||||
|
||||
pub struct PreconditionHeaders {
|
||||
if_match: Option<Vec<String>>,
|
||||
if_modified_since: Option<SystemTime>,
|
||||
if_none_match: Option<Vec<String>>,
|
||||
if_unmodified_since: Option<SystemTime>,
|
||||
}
|
||||
|
||||
impl PreconditionHeaders {
|
||||
fn parse<B>(req: &Request<B>) -> Result<Self, Error> {
|
||||
Self::parse_with(
|
||||
req.headers(),
|
||||
&IF_MATCH,
|
||||
&IF_NONE_MATCH,
|
||||
&IF_MODIFIED_SINCE,
|
||||
&IF_UNMODIFIED_SINCE,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_copy_source<B>(req: &Request<B>) -> Result<Self, Error> {
|
||||
Self::parse_with(
|
||||
req.headers(),
|
||||
&X_AMZ_COPY_SOURCE_IF_MATCH,
|
||||
&X_AMZ_COPY_SOURCE_IF_NONE_MATCH,
|
||||
&X_AMZ_COPY_SOURCE_IF_MODIFIED_SINCE,
|
||||
&X_AMZ_COPY_SOURCE_IF_UNMODIFIED_SINCE,
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_with(
|
||||
headers: &HeaderMap,
|
||||
hdr_if_match: &HeaderName,
|
||||
hdr_if_none_match: &HeaderName,
|
||||
hdr_if_modified_since: &HeaderName,
|
||||
hdr_if_unmodified_since: &HeaderName,
|
||||
) -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
if_match: headers
|
||||
.get(hdr_if_match)
|
||||
.map(|x| x.to_str())
|
||||
.transpose()?
|
||||
.map(|x| {
|
||||
x.split(',')
|
||||
.map(|m| m.trim().trim_matches('"').to_string())
|
||||
.collect::<Vec<_>>()
|
||||
}),
|
||||
if_none_match: headers
|
||||
.get(hdr_if_none_match)
|
||||
.map(|x| x.to_str())
|
||||
.transpose()?
|
||||
.map(|x| {
|
||||
x.split(',')
|
||||
.map(|m| m.trim().trim_matches('"').to_string())
|
||||
.collect::<Vec<_>>()
|
||||
}),
|
||||
if_modified_since: headers
|
||||
.get(hdr_if_modified_since)
|
||||
.map(|x| x.to_str())
|
||||
.transpose()?
|
||||
.map(httpdate::parse_http_date)
|
||||
.transpose()
|
||||
.ok_or_bad_request("Invalid date in if-modified-since")?,
|
||||
if_unmodified_since: headers
|
||||
.get(hdr_if_unmodified_since)
|
||||
.map(|x| x.to_str())
|
||||
.transpose()?
|
||||
.map(httpdate::parse_http_date)
|
||||
.transpose()
|
||||
.ok_or_bad_request("Invalid date in if-unmodified-since")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn check(&self, v: &ObjectVersion, etag: &str) -> Result<Option<StatusCode>, Error> {
|
||||
let v_date = UNIX_EPOCH + Duration::from_millis(v.timestamp);
|
||||
|
||||
// Implemented from https://datatracker.ietf.org/doc/html/rfc7232#section-6
|
||||
|
||||
if let Some(im) = &self.if_match {
|
||||
// Step 1: if-match is present
|
||||
if !im.iter().any(|x| x == etag || x == "*") {
|
||||
return Ok(Some(StatusCode::PRECONDITION_FAILED));
|
||||
}
|
||||
} else if let Some(ius) = &self.if_unmodified_since {
|
||||
// Step 2: if-unmodified-since is present, and if-match is absent
|
||||
if v_date > *ius {
|
||||
return Ok(Some(StatusCode::PRECONDITION_FAILED));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(inm) = &self.if_none_match {
|
||||
// Step 3: if-none-match is present
|
||||
if inm.iter().any(|x| x == etag || x == "*") {
|
||||
return Ok(Some(StatusCode::NOT_MODIFIED));
|
||||
}
|
||||
} else if let Some(ims) = &self.if_modified_since {
|
||||
// Step 4: if-modified-since is present, and if-none-match is absent
|
||||
if v_date <= *ims {
|
||||
return Ok(Some(StatusCode::NOT_MODIFIED));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) fn check_copy_source(&self, v: &ObjectVersion, etag: &str) -> Result<(), Error> {
|
||||
match self.check(v, etag)? {
|
||||
Some(_) => Err(Error::PreconditionFailed),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -14,9 +14,8 @@ mod list;
|
||||
mod multipart;
|
||||
mod post_object;
|
||||
mod put;
|
||||
mod website;
|
||||
pub mod website;
|
||||
|
||||
mod checksum;
|
||||
mod encryption;
|
||||
mod router;
|
||||
pub mod xml;
|
||||
|
||||
+2
-10
@@ -1,12 +1,10 @@
|
||||
use quick_xml::de::from_reader;
|
||||
|
||||
use http_body_util::BodyExt;
|
||||
use hyper::{Request, Response, StatusCode};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use garage_api_common::helpers::*;
|
||||
use garage_api_common::signature::verify_signed_content;
|
||||
|
||||
use crate::api_server::{ReqBody, ResBody};
|
||||
use crate::error::*;
|
||||
@@ -16,7 +14,6 @@ use garage_model::bucket_table::{
|
||||
parse_lifecycle_date, Bucket, LifecycleExpiration as GarageLifecycleExpiration,
|
||||
LifecycleFilter as GarageLifecycleFilter, LifecycleRule as GarageLifecycleRule,
|
||||
};
|
||||
use garage_util::data::*;
|
||||
|
||||
pub async fn handle_get_lifecycle(ctx: ReqCtx) -> Result<Response<ResBody>, Error> {
|
||||
let ReqCtx { bucket_params, .. } = ctx;
|
||||
@@ -30,7 +27,7 @@ pub async fn handle_get_lifecycle(ctx: ReqCtx) -> Result<Response<ResBody>, Erro
|
||||
.body(string_body(xml))?)
|
||||
} else {
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(empty_body())?)
|
||||
}
|
||||
}
|
||||
@@ -56,7 +53,6 @@ pub async fn handle_delete_lifecycle(ctx: ReqCtx) -> Result<Response<ResBody>, E
|
||||
pub async fn handle_put_lifecycle(
|
||||
ctx: ReqCtx,
|
||||
req: Request<ReqBody>,
|
||||
content_sha256: Option<Hash>,
|
||||
) -> Result<Response<ResBody>, Error> {
|
||||
let ReqCtx {
|
||||
garage,
|
||||
@@ -65,11 +61,7 @@ pub async fn handle_put_lifecycle(
|
||||
..
|
||||
} = ctx;
|
||||
|
||||
let body = BodyExt::collect(req.into_body()).await?.to_bytes();
|
||||
|
||||
if let Some(content_sha256) = content_sha256 {
|
||||
verify_signed_content(content_sha256, &body[..])?;
|
||||
}
|
||||
let body = req.into_body().collect().await?;
|
||||
|
||||
let conf: LifecycleConfiguration = from_reader(&body as &[u8])?;
|
||||
let config = conf
|
||||
|
||||
+131
-22
@@ -1,13 +1,20 @@
|
||||
use std::collections::HashMap;
|
||||
use std::convert::TryInto;
|
||||
use std::convert::{TryFrom, TryInto};
|
||||
use std::hash::Hasher;
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::prelude::*;
|
||||
use crc32c::Crc32cHasher as Crc32c;
|
||||
use crc32fast::Hasher as Crc32;
|
||||
use futures::prelude::*;
|
||||
use hyper::{Request, Response};
|
||||
use md5::{Digest, Md5};
|
||||
use sha1::Sha1;
|
||||
use sha2::Sha256;
|
||||
|
||||
use garage_table::*;
|
||||
use garage_util::data::*;
|
||||
use garage_util::error::OkOrMessage;
|
||||
|
||||
use garage_model::garage::Garage;
|
||||
use garage_model::s3::block_ref_table::*;
|
||||
@@ -16,10 +23,9 @@ use garage_model::s3::object_table::*;
|
||||
use garage_model::s3::version_table::*;
|
||||
|
||||
use garage_api_common::helpers::*;
|
||||
use garage_api_common::signature::verify_signed_content;
|
||||
use garage_api_common::signature::checksum::*;
|
||||
|
||||
use crate::api_server::{ReqBody, ResBody};
|
||||
use crate::checksum::*;
|
||||
use crate::encryption::EncryptionParams;
|
||||
use crate::error::*;
|
||||
use crate::put::*;
|
||||
@@ -43,7 +49,7 @@ pub async fn handle_create_multipart_upload(
|
||||
let upload_id = gen_uuid();
|
||||
let timestamp = next_timestamp(existing_object.as_ref());
|
||||
|
||||
let headers = get_headers(req.headers())?;
|
||||
let headers = extract_metadata_headers(req.headers())?;
|
||||
let meta = ObjectVersionMetaInner {
|
||||
headers,
|
||||
checksum: None,
|
||||
@@ -94,7 +100,6 @@ pub async fn handle_put_part(
|
||||
key: &str,
|
||||
part_number: u64,
|
||||
upload_id: &str,
|
||||
content_sha256: Option<Hash>,
|
||||
) -> Result<Response<ResBody>, Error> {
|
||||
let ReqCtx { garage, .. } = &ctx;
|
||||
|
||||
@@ -105,17 +110,30 @@ pub async fn handle_put_part(
|
||||
Some(x) => Some(x.to_str()?.to_string()),
|
||||
None => None,
|
||||
},
|
||||
sha256: content_sha256,
|
||||
sha256: None,
|
||||
extra: request_checksum_value(req.headers())?,
|
||||
};
|
||||
|
||||
// Read first chuck, and at the same time try to get object to see if it exists
|
||||
let key = key.to_string();
|
||||
|
||||
let (req_head, req_body) = req.into_parts();
|
||||
let stream = body_stream(req_body);
|
||||
let (req_head, mut req_body) = req.into_parts();
|
||||
|
||||
// Before we stream the body, configure the needed checksums.
|
||||
req_body.add_expected_checksums(expected_checksums.clone());
|
||||
// TODO: avoid parsing encryption headers twice...
|
||||
if !EncryptionParams::new_from_headers(&garage, &req_head.headers)?.is_encrypted() {
|
||||
// For non-encrypted objects, we need to compute the md5sum in all cases
|
||||
// (even if content-md5 is not set), because it is used as an etag of the
|
||||
// part, which is in turn used in the etag computation of the whole object
|
||||
req_body.add_md5();
|
||||
}
|
||||
|
||||
let (stream, stream_checksums) = req_body.streaming_with_checksums();
|
||||
let stream = stream.map_err(Error::from);
|
||||
|
||||
let mut chunker = StreamChunker::new(stream, garage.config.block_size);
|
||||
|
||||
// Read first chuck, and at the same time try to get object to see if it exists
|
||||
let ((_, object_version, mut mpu), first_block) =
|
||||
futures::try_join!(get_upload(&ctx, &key, &upload_id), chunker.next(),)?;
|
||||
|
||||
@@ -172,21 +190,21 @@ pub async fn handle_put_part(
|
||||
garage.version_table.insert(&version).await?;
|
||||
|
||||
// Copy data to version
|
||||
let checksummer =
|
||||
Checksummer::init(&expected_checksums, !encryption.is_encrypted()).add(checksum_algorithm);
|
||||
let (total_size, checksums, _) = read_and_put_blocks(
|
||||
let (total_size, _, _) = read_and_put_blocks(
|
||||
&ctx,
|
||||
&version,
|
||||
encryption,
|
||||
part_number,
|
||||
first_block,
|
||||
&mut chunker,
|
||||
checksummer,
|
||||
chunker,
|
||||
Checksummer::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify that checksums map
|
||||
checksums.verify(&expected_checksums)?;
|
||||
// Verify that checksums match
|
||||
let checksums = stream_checksums
|
||||
.await
|
||||
.ok_or_internal_error("checksum calculation")??;
|
||||
|
||||
// Store part etag in version
|
||||
let etag = encryption.etag_from_md5(&checksums.md5);
|
||||
@@ -248,7 +266,6 @@ pub async fn handle_complete_multipart_upload(
|
||||
req: Request<ReqBody>,
|
||||
key: &str,
|
||||
upload_id: &str,
|
||||
content_sha256: Option<Hash>,
|
||||
) -> Result<Response<ResBody>, Error> {
|
||||
let ReqCtx {
|
||||
garage,
|
||||
@@ -260,11 +277,7 @@ pub async fn handle_complete_multipart_upload(
|
||||
|
||||
let expected_checksum = request_checksum_value(&req_head.headers)?;
|
||||
|
||||
let body = http_body_util::BodyExt::collect(req_body).await?.to_bytes();
|
||||
|
||||
if let Some(content_sha256) = content_sha256 {
|
||||
verify_signed_content(content_sha256, &body[..])?;
|
||||
}
|
||||
let body = req_body.collect().await?;
|
||||
|
||||
let body_xml = roxmltree::Document::parse(std::str::from_utf8(&body)?)?;
|
||||
let body_list_of_parts = parse_complete_multipart_upload_body(&body_xml)
|
||||
@@ -602,3 +615,99 @@ fn parse_complete_multipart_upload_body(
|
||||
|
||||
Some(parts)
|
||||
}
|
||||
|
||||
// ====== checksummer ====
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MultipartChecksummer {
|
||||
pub md5: Md5,
|
||||
pub extra: Option<MultipartExtraChecksummer>,
|
||||
}
|
||||
|
||||
pub(crate) enum MultipartExtraChecksummer {
|
||||
Crc32(Crc32),
|
||||
Crc32c(Crc32c),
|
||||
Sha1(Sha1),
|
||||
Sha256(Sha256),
|
||||
}
|
||||
|
||||
impl MultipartChecksummer {
|
||||
pub(crate) fn init(algo: Option<ChecksumAlgorithm>) -> Self {
|
||||
Self {
|
||||
md5: Md5::new(),
|
||||
extra: match algo {
|
||||
None => None,
|
||||
Some(ChecksumAlgorithm::Crc32) => {
|
||||
Some(MultipartExtraChecksummer::Crc32(Crc32::new()))
|
||||
}
|
||||
Some(ChecksumAlgorithm::Crc32c) => {
|
||||
Some(MultipartExtraChecksummer::Crc32c(Crc32c::default()))
|
||||
}
|
||||
Some(ChecksumAlgorithm::Sha1) => Some(MultipartExtraChecksummer::Sha1(Sha1::new())),
|
||||
Some(ChecksumAlgorithm::Sha256) => {
|
||||
Some(MultipartExtraChecksummer::Sha256(Sha256::new()))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update(
|
||||
&mut self,
|
||||
etag: &str,
|
||||
checksum: Option<ChecksumValue>,
|
||||
) -> Result<(), Error> {
|
||||
self.md5
|
||||
.update(&hex::decode(&etag).ok_or_message("invalid etag hex")?);
|
||||
match (&mut self.extra, checksum) {
|
||||
(None, _) => (),
|
||||
(
|
||||
Some(MultipartExtraChecksummer::Crc32(ref mut crc32)),
|
||||
Some(ChecksumValue::Crc32(x)),
|
||||
) => {
|
||||
crc32.update(&x);
|
||||
}
|
||||
(
|
||||
Some(MultipartExtraChecksummer::Crc32c(ref mut crc32c)),
|
||||
Some(ChecksumValue::Crc32c(x)),
|
||||
) => {
|
||||
crc32c.write(&x);
|
||||
}
|
||||
(Some(MultipartExtraChecksummer::Sha1(ref mut sha1)), Some(ChecksumValue::Sha1(x))) => {
|
||||
sha1.update(&x);
|
||||
}
|
||||
(
|
||||
Some(MultipartExtraChecksummer::Sha256(ref mut sha256)),
|
||||
Some(ChecksumValue::Sha256(x)),
|
||||
) => {
|
||||
sha256.update(&x);
|
||||
}
|
||||
(Some(_), b) => {
|
||||
return Err(Error::internal_error(format!(
|
||||
"part checksum was not computed correctly, got: {:?}",
|
||||
b
|
||||
)))
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn finalize(self) -> (Md5Checksum, Option<ChecksumValue>) {
|
||||
let md5 = self.md5.finalize()[..].try_into().unwrap();
|
||||
let extra = match self.extra {
|
||||
None => None,
|
||||
Some(MultipartExtraChecksummer::Crc32(crc32)) => {
|
||||
Some(ChecksumValue::Crc32(u32::to_be_bytes(crc32.finalize())))
|
||||
}
|
||||
Some(MultipartExtraChecksummer::Crc32c(crc32c)) => Some(ChecksumValue::Crc32c(
|
||||
u32::to_be_bytes(u32::try_from(crc32c.finish()).unwrap()),
|
||||
)),
|
||||
Some(MultipartExtraChecksummer::Sha1(sha1)) => {
|
||||
Some(ChecksumValue::Sha1(sha1.finalize()[..].try_into().unwrap()))
|
||||
}
|
||||
Some(MultipartExtraChecksummer::Sha256(sha256)) => Some(ChecksumValue::Sha256(
|
||||
sha256.finalize()[..].try_into().unwrap(),
|
||||
)),
|
||||
};
|
||||
(md5, extra)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,13 @@ use garage_model::s3::object_table::*;
|
||||
|
||||
use garage_api_common::cors::*;
|
||||
use garage_api_common::helpers::*;
|
||||
use garage_api_common::signature::checksum::*;
|
||||
use garage_api_common::signature::payload::{verify_v4, Authorization};
|
||||
|
||||
use crate::api_server::ResBody;
|
||||
use crate::checksum::*;
|
||||
use crate::encryption::EncryptionParams;
|
||||
use crate::error::*;
|
||||
use crate::put::{get_headers, save_stream, ChecksumMode};
|
||||
use crate::put::{extract_metadata_headers, save_stream, ChecksumMode};
|
||||
use crate::xml as s3_xml;
|
||||
|
||||
pub async fn handle_post_object(
|
||||
@@ -216,8 +216,9 @@ pub async fn handle_post_object(
|
||||
|
||||
// if we ever start supporting ACLs, we likely want to map "acl" to x-amz-acl" somewhere
|
||||
// around here to make sure the rest of the machinery takes our acl into account.
|
||||
let headers = get_headers(¶ms)?;
|
||||
let headers = extract_metadata_headers(¶ms)?;
|
||||
|
||||
let checksum_algorithm = request_checksum_algorithm(¶ms)?;
|
||||
let expected_checksums = ExpectedChecksums {
|
||||
md5: params
|
||||
.get("content-md5")
|
||||
@@ -225,7 +226,9 @@ pub async fn handle_post_object(
|
||||
.transpose()?
|
||||
.map(str::to_string),
|
||||
sha256: None,
|
||||
extra: request_checksum_algorithm_value(¶ms)?,
|
||||
extra: checksum_algorithm
|
||||
.map(|algo| extract_checksum_value(¶ms, algo))
|
||||
.transpose()?,
|
||||
};
|
||||
|
||||
let meta = ObjectVersionMetaInner {
|
||||
|
||||
+76
-13
@@ -31,11 +31,13 @@ use garage_model::s3::object_table::*;
|
||||
use garage_model::s3::version_table::*;
|
||||
|
||||
use garage_api_common::helpers::*;
|
||||
use garage_api_common::signature::body::StreamingChecksumReceiver;
|
||||
use garage_api_common::signature::checksum::*;
|
||||
|
||||
use crate::api_server::{ReqBody, ResBody};
|
||||
use crate::checksum::*;
|
||||
use crate::encryption::EncryptionParams;
|
||||
use crate::error::*;
|
||||
use crate::website::X_AMZ_WEBSITE_REDIRECT_LOCATION;
|
||||
|
||||
const PUT_BLOCKS_MAX_PARALLEL: usize = 3;
|
||||
|
||||
@@ -48,6 +50,10 @@ pub(crate) struct SaveStreamResult {
|
||||
|
||||
pub(crate) enum ChecksumMode<'a> {
|
||||
Verify(&'a ExpectedChecksums),
|
||||
VerifyFrom {
|
||||
checksummer: StreamingChecksumReceiver,
|
||||
trailer_algo: Option<ChecksumAlgorithm>,
|
||||
},
|
||||
Calculate(Option<ChecksumAlgorithm>),
|
||||
}
|
||||
|
||||
@@ -55,10 +61,9 @@ pub async fn handle_put(
|
||||
ctx: ReqCtx,
|
||||
req: Request<ReqBody>,
|
||||
key: &String,
|
||||
content_sha256: Option<Hash>,
|
||||
) -> Result<Response<ResBody>, Error> {
|
||||
// Retrieve interesting headers from request
|
||||
let headers = get_headers(req.headers())?;
|
||||
let headers = extract_metadata_headers(req.headers())?;
|
||||
debug!("Object headers: {:?}", headers);
|
||||
|
||||
let expected_checksums = ExpectedChecksums {
|
||||
@@ -66,9 +71,10 @@ pub async fn handle_put(
|
||||
Some(x) => Some(x.to_str()?.to_string()),
|
||||
None => None,
|
||||
},
|
||||
sha256: content_sha256,
|
||||
sha256: None,
|
||||
extra: request_checksum_value(req.headers())?,
|
||||
};
|
||||
let trailer_checksum_algorithm = request_trailer_checksum_algorithm(req.headers())?;
|
||||
|
||||
let meta = ObjectVersionMetaInner {
|
||||
headers,
|
||||
@@ -78,7 +84,19 @@ pub async fn handle_put(
|
||||
// Determine whether object should be encrypted, and if so the key
|
||||
let encryption = EncryptionParams::new_from_headers(&ctx.garage, req.headers())?;
|
||||
|
||||
let stream = body_stream(req.into_body());
|
||||
// The request body is a special ReqBody object (see garage_api_common::signature::body)
|
||||
// which supports calculating checksums while streaming the data.
|
||||
// Before we start streaming, we configure it to calculate all the checksums we need.
|
||||
let mut req_body = req.into_body();
|
||||
req_body.add_expected_checksums(expected_checksums.clone());
|
||||
if !encryption.is_encrypted() {
|
||||
// For non-encrypted objects, we need to compute the md5sum in all cases
|
||||
// (even if content-md5 is not set), because it is used as the object etag
|
||||
req_body.add_md5();
|
||||
}
|
||||
|
||||
let (stream, checksummer) = req_body.streaming_with_checksums();
|
||||
let stream = stream.map_err(Error::from);
|
||||
|
||||
let res = save_stream(
|
||||
&ctx,
|
||||
@@ -86,7 +104,10 @@ pub async fn handle_put(
|
||||
encryption,
|
||||
stream,
|
||||
key,
|
||||
ChecksumMode::Verify(&expected_checksums),
|
||||
ChecksumMode::VerifyFrom {
|
||||
checksummer,
|
||||
trailer_algo: trailer_checksum_algorithm,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -122,10 +143,15 @@ pub(crate) async fn save_stream<S: Stream<Item = Result<Bytes, Error>> + Unpin>(
|
||||
let version_uuid = gen_uuid();
|
||||
let version_timestamp = next_timestamp(existing_object.as_ref());
|
||||
|
||||
let mut checksummer = match checksum_mode {
|
||||
let mut checksummer = match &checksum_mode {
|
||||
ChecksumMode::Verify(expected) => Checksummer::init(expected, !encryption.is_encrypted()),
|
||||
ChecksumMode::Calculate(algo) => {
|
||||
Checksummer::init(&Default::default(), !encryption.is_encrypted()).add(algo)
|
||||
Checksummer::init(&Default::default(), !encryption.is_encrypted()).add(*algo)
|
||||
}
|
||||
ChecksumMode::VerifyFrom { .. } => {
|
||||
// Checksums are calculated by the garage_api_common::signature module
|
||||
// so here we can just have an empty checksummer that does nothing
|
||||
Checksummer::new()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -133,7 +159,7 @@ pub(crate) async fn save_stream<S: Stream<Item = Result<Bytes, Error>> + Unpin>(
|
||||
// as "inline data". We can then return immediately.
|
||||
if first_block.len() < INLINE_THRESHOLD {
|
||||
checksummer.update(&first_block);
|
||||
let checksums = checksummer.finalize();
|
||||
let mut checksums = checksummer.finalize();
|
||||
|
||||
match checksum_mode {
|
||||
ChecksumMode::Verify(expected) => {
|
||||
@@ -142,6 +168,18 @@ pub(crate) async fn save_stream<S: Stream<Item = Result<Bytes, Error>> + Unpin>(
|
||||
ChecksumMode::Calculate(algo) => {
|
||||
meta.checksum = checksums.extract(algo);
|
||||
}
|
||||
ChecksumMode::VerifyFrom {
|
||||
checksummer,
|
||||
trailer_algo,
|
||||
} => {
|
||||
drop(chunker);
|
||||
checksums = checksummer
|
||||
.await
|
||||
.ok_or_internal_error("checksum calculation")??;
|
||||
if let Some(algo) = trailer_algo {
|
||||
meta.checksum = checksums.extract(Some(algo));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let size = first_block.len() as u64;
|
||||
@@ -213,13 +251,13 @@ pub(crate) async fn save_stream<S: Stream<Item = Result<Bytes, Error>> + Unpin>(
|
||||
garage.version_table.insert(&version).await?;
|
||||
|
||||
// Transfer data
|
||||
let (total_size, checksums, first_block_hash) = read_and_put_blocks(
|
||||
let (total_size, mut checksums, first_block_hash) = read_and_put_blocks(
|
||||
ctx,
|
||||
&version,
|
||||
encryption,
|
||||
1,
|
||||
first_block,
|
||||
&mut chunker,
|
||||
chunker,
|
||||
checksummer,
|
||||
)
|
||||
.await?;
|
||||
@@ -232,6 +270,17 @@ pub(crate) async fn save_stream<S: Stream<Item = Result<Bytes, Error>> + Unpin>(
|
||||
ChecksumMode::Calculate(algo) => {
|
||||
meta.checksum = checksums.extract(algo);
|
||||
}
|
||||
ChecksumMode::VerifyFrom {
|
||||
checksummer,
|
||||
trailer_algo,
|
||||
} => {
|
||||
checksums = checksummer
|
||||
.await
|
||||
.ok_or_internal_error("checksum calculation")??;
|
||||
if let Some(algo) = trailer_algo {
|
||||
meta.checksum = checksums.extract(Some(algo));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Verify quotas are respsected
|
||||
@@ -332,7 +381,7 @@ pub(crate) async fn read_and_put_blocks<S: Stream<Item = Result<Bytes, Error>> +
|
||||
encryption: EncryptionParams,
|
||||
part_number: u64,
|
||||
first_block: Bytes,
|
||||
chunker: &mut StreamChunker<S>,
|
||||
mut chunker: StreamChunker<S>,
|
||||
checksummer: Checksummer,
|
||||
) -> Result<(u64, Checksums, Hash), Error> {
|
||||
let tracer = opentelemetry::global::tracer("garage");
|
||||
@@ -601,7 +650,9 @@ impl Drop for InterruptedCleanup {
|
||||
|
||||
// ============ helpers ============
|
||||
|
||||
pub(crate) fn get_headers(headers: &HeaderMap<HeaderValue>) -> Result<HeaderList, Error> {
|
||||
pub(crate) fn extract_metadata_headers(
|
||||
headers: &HeaderMap<HeaderValue>,
|
||||
) -> Result<HeaderList, Error> {
|
||||
let mut ret = Vec::new();
|
||||
|
||||
// Preserve standard headers
|
||||
@@ -627,6 +678,18 @@ pub(crate) fn get_headers(headers: &HeaderMap<HeaderValue>) -> Result<HeaderList
|
||||
std::str::from_utf8(value.as_bytes())?.to_string(),
|
||||
));
|
||||
}
|
||||
if name == X_AMZ_WEBSITE_REDIRECT_LOCATION {
|
||||
let value = std::str::from_utf8(value.as_bytes())?.to_string();
|
||||
if !(value.starts_with("/")
|
||||
|| value.starts_with("http://")
|
||||
|| value.starts_with("https://"))
|
||||
{
|
||||
return Err(Error::bad_request(format!(
|
||||
"Invalid {X_AMZ_WEBSITE_REDIRECT_LOCATION} header",
|
||||
)));
|
||||
}
|
||||
ret.push((X_AMZ_WEBSITE_REDIRECT_LOCATION.to_string(), value));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ret)
|
||||
|
||||
+5
-10
@@ -1,19 +1,19 @@
|
||||
use quick_xml::de::from_reader;
|
||||
|
||||
use http_body_util::BodyExt;
|
||||
use hyper::{Request, Response, StatusCode};
|
||||
use hyper::{header::HeaderName, Request, Response, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use garage_model::bucket_table::{self, *};
|
||||
use garage_util::data::*;
|
||||
|
||||
use garage_api_common::helpers::*;
|
||||
use garage_api_common::signature::verify_signed_content;
|
||||
|
||||
use crate::api_server::{ReqBody, ResBody};
|
||||
use crate::error::*;
|
||||
use crate::xml::{to_xml_with_header, xmlns_tag, IntValue, Value};
|
||||
|
||||
pub const X_AMZ_WEBSITE_REDIRECT_LOCATION: HeaderName =
|
||||
HeaderName::from_static("x-amz-website-redirect-location");
|
||||
|
||||
pub async fn handle_get_website(ctx: ReqCtx) -> Result<Response<ResBody>, Error> {
|
||||
let ReqCtx { bucket_params, .. } = ctx;
|
||||
if let Some(website) = bucket_params.website_config.get() {
|
||||
@@ -82,7 +82,6 @@ pub async fn handle_delete_website(ctx: ReqCtx) -> Result<Response<ResBody>, Err
|
||||
pub async fn handle_put_website(
|
||||
ctx: ReqCtx,
|
||||
req: Request<ReqBody>,
|
||||
content_sha256: Option<Hash>,
|
||||
) -> Result<Response<ResBody>, Error> {
|
||||
let ReqCtx {
|
||||
garage,
|
||||
@@ -91,11 +90,7 @@ pub async fn handle_put_website(
|
||||
..
|
||||
} = ctx;
|
||||
|
||||
let body = BodyExt::collect(req.into_body()).await?.to_bytes();
|
||||
|
||||
if let Some(content_sha256) = content_sha256 {
|
||||
verify_signed_content(content_sha256, &body[..])?;
|
||||
}
|
||||
let body = req.into_body().collect().await?;
|
||||
|
||||
let conf: WebsiteConfiguration = from_reader(&body as &[u8])?;
|
||||
conf.validate()?;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "garage_block"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||
edition = "2018"
|
||||
license = "AGPL-3.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "garage_db"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||
edition = "2018"
|
||||
license = "AGPL-3.0"
|
||||
|
||||
@@ -109,7 +109,7 @@ impl IDb for LmdbDb {
|
||||
let mut path = to.clone();
|
||||
path.push("data.mdb");
|
||||
self.db
|
||||
.copy_to_path(path, heed::CompactionOption::Disabled)?;
|
||||
.copy_to_path(path, heed::CompactionOption::Enabled)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "garage"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||
edition = "2018"
|
||||
license = "AGPL-3.0"
|
||||
@@ -62,6 +62,7 @@ syslog-tracing = { workspace = true, optional = true }
|
||||
garage_api_common.workspace = true
|
||||
|
||||
aws-sdk-s3.workspace = true
|
||||
aws-smithy-runtime.workspace = true
|
||||
chrono.workspace = true
|
||||
http.workspace = true
|
||||
hmac.workspace = true
|
||||
@@ -71,10 +72,12 @@ hyper-util.workspace = true
|
||||
mktemp.workspace = true
|
||||
sha2.workspace = true
|
||||
|
||||
|
||||
static_init.workspace = true
|
||||
assert-json-diff.workspace = true
|
||||
serde_json.workspace = true
|
||||
base64.workspace = true
|
||||
crc32fast.workspace = true
|
||||
|
||||
k2v-client.workspace = true
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ impl AdminRpcHandler {
|
||||
|
||||
#[allow(clippy::ptr_arg)]
|
||||
async fn handle_create_bucket(&self, name: &String) -> Result<AdminRpc, Error> {
|
||||
if !is_valid_bucket_name(name) {
|
||||
if !is_valid_bucket_name(name, self.garage.config.allow_punycode) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"{}: {}",
|
||||
name, INVALID_BUCKET_NAME_MESSAGE
|
||||
|
||||
@@ -478,6 +478,9 @@ pub enum RepairWhat {
|
||||
/// Recalculate block reference counters
|
||||
#[structopt(name = "block-rc", version = garage_version())]
|
||||
BlockRc,
|
||||
/// Fix inconsistency in bucket aliases (WARNING: EXPERIMENTAL)
|
||||
#[structopt(name = "aliases", version = garage_version())]
|
||||
Aliases,
|
||||
/// Verify integrity of all blocks on disc
|
||||
#[structopt(name = "scrub", version = garage_version())]
|
||||
Scrub {
|
||||
|
||||
@@ -88,6 +88,10 @@ pub async fn launch_online_repair(
|
||||
garage.block_manager.clone(),
|
||||
));
|
||||
}
|
||||
RepairWhat::Aliases => {
|
||||
info!("Repairing bucket aliases (foreground)");
|
||||
garage.locked_helper().await.repair_aliases().await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ pub fn build_client(key: &Key) -> Client {
|
||||
.endpoint_url(format!("http://127.0.0.1:{}", DEFAULT_PORT))
|
||||
.region(super::REGION)
|
||||
.credentials_provider(credentials)
|
||||
.behavior_version(BehaviorVersion::v2023_11_09())
|
||||
.behavior_version(BehaviorVersion::v2024_03_28())
|
||||
.build();
|
||||
|
||||
Client::from_conf(config)
|
||||
|
||||
@@ -192,16 +192,13 @@ impl<'a> RequestBuilder<'a> {
|
||||
.collect::<HeaderMap>();
|
||||
|
||||
let date = now.format(signature::LONG_DATETIME).to_string();
|
||||
all_headers.insert(
|
||||
signature::payload::X_AMZ_DATE,
|
||||
HeaderValue::from_str(&date).unwrap(),
|
||||
);
|
||||
all_headers.insert(signature::X_AMZ_DATE, HeaderValue::from_str(&date).unwrap());
|
||||
all_headers.insert(HOST, HeaderValue::from_str(&host).unwrap());
|
||||
|
||||
let body_sha = match self.body_signature {
|
||||
let body_sha = match &self.body_signature {
|
||||
BodySignature::Unsigned => "UNSIGNED-PAYLOAD".to_owned(),
|
||||
BodySignature::Classic => hex::encode(garage_util::data::sha256sum(&self.body)),
|
||||
BodySignature::Streaming(size) => {
|
||||
BodySignature::Streaming { chunk_size } => {
|
||||
all_headers.insert(
|
||||
CONTENT_ENCODING,
|
||||
HeaderValue::from_str("aws-chunked").unwrap(),
|
||||
@@ -216,18 +213,59 @@ impl<'a> RequestBuilder<'a> {
|
||||
// code.
|
||||
all_headers.insert(
|
||||
CONTENT_LENGTH,
|
||||
to_streaming_body(&self.body, size, String::new(), signer.clone(), now, "")
|
||||
.len()
|
||||
.to_string()
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
to_streaming_body(
|
||||
&self.body,
|
||||
*chunk_size,
|
||||
String::new(),
|
||||
signer.clone(),
|
||||
now,
|
||||
"",
|
||||
)
|
||||
.len()
|
||||
.to_string()
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
"STREAMING-AWS4-HMAC-SHA256-PAYLOAD".to_owned()
|
||||
}
|
||||
BodySignature::StreamingUnsignedTrailer {
|
||||
chunk_size,
|
||||
trailer_algorithm,
|
||||
trailer_value,
|
||||
} => {
|
||||
all_headers.insert(
|
||||
CONTENT_ENCODING,
|
||||
HeaderValue::from_str("aws-chunked").unwrap(),
|
||||
);
|
||||
all_headers.insert(
|
||||
HeaderName::from_static("x-amz-decoded-content-length"),
|
||||
HeaderValue::from_str(&self.body.len().to_string()).unwrap(),
|
||||
);
|
||||
all_headers.insert(
|
||||
HeaderName::from_static("x-amz-trailer"),
|
||||
HeaderValue::from_str(&trailer_algorithm).unwrap(),
|
||||
);
|
||||
|
||||
all_headers.insert(
|
||||
CONTENT_LENGTH,
|
||||
to_streaming_unsigned_trailer_body(
|
||||
&self.body,
|
||||
*chunk_size,
|
||||
&trailer_algorithm,
|
||||
&trailer_value,
|
||||
)
|
||||
.len()
|
||||
.to_string()
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
"STREAMING-UNSIGNED-PAYLOAD-TRAILER".to_owned()
|
||||
}
|
||||
};
|
||||
all_headers.insert(
|
||||
signature::payload::X_AMZ_CONTENT_SH256,
|
||||
signature::X_AMZ_CONTENT_SHA256,
|
||||
HeaderValue::from_str(&body_sha).unwrap(),
|
||||
);
|
||||
|
||||
@@ -276,10 +314,26 @@ impl<'a> RequestBuilder<'a> {
|
||||
let mut request = Request::builder();
|
||||
*request.headers_mut().unwrap() = all_headers;
|
||||
|
||||
let body = if let BodySignature::Streaming(size) = self.body_signature {
|
||||
to_streaming_body(&self.body, size, signature, streaming_signer, now, &scope)
|
||||
} else {
|
||||
self.body.clone()
|
||||
let body = match &self.body_signature {
|
||||
BodySignature::Streaming { chunk_size } => to_streaming_body(
|
||||
&self.body,
|
||||
*chunk_size,
|
||||
signature,
|
||||
streaming_signer,
|
||||
now,
|
||||
&scope,
|
||||
),
|
||||
BodySignature::StreamingUnsignedTrailer {
|
||||
chunk_size,
|
||||
trailer_algorithm,
|
||||
trailer_value,
|
||||
} => to_streaming_unsigned_trailer_body(
|
||||
&self.body,
|
||||
*chunk_size,
|
||||
&trailer_algorithm,
|
||||
&trailer_value,
|
||||
),
|
||||
_ => self.body.clone(),
|
||||
};
|
||||
let request = request
|
||||
.uri(uri)
|
||||
@@ -308,7 +362,14 @@ impl<'a> RequestBuilder<'a> {
|
||||
pub enum BodySignature {
|
||||
Unsigned,
|
||||
Classic,
|
||||
Streaming(usize),
|
||||
Streaming {
|
||||
chunk_size: usize,
|
||||
},
|
||||
StreamingUnsignedTrailer {
|
||||
chunk_size: usize,
|
||||
trailer_algorithm: String,
|
||||
trailer_value: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn query_param_to_string(params: &HashMap<String, Option<String>>) -> String {
|
||||
@@ -363,3 +424,26 @@ fn to_streaming_body(
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
fn to_streaming_unsigned_trailer_body(
|
||||
body: &[u8],
|
||||
chunk_size: usize,
|
||||
trailer_algorithm: &str,
|
||||
trailer_value: &str,
|
||||
) -> Vec<u8> {
|
||||
let mut res = Vec::with_capacity(body.len());
|
||||
for chunk in body.chunks(chunk_size) {
|
||||
let header = format!("{:x}\r\n", chunk.len());
|
||||
res.extend_from_slice(header.as_bytes());
|
||||
res.extend_from_slice(chunk);
|
||||
res.extend_from_slice(b"\r\n");
|
||||
}
|
||||
|
||||
res.extend_from_slice(b"0\r\n");
|
||||
res.extend_from_slice(trailer_algorithm.as_bytes());
|
||||
res.extend_from_slice(b":");
|
||||
res.extend_from_slice(trailer_value.as_bytes());
|
||||
res.extend_from_slice(b"\n\r\n\r\n");
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ rpc_bind_addr = "127.0.0.1:{rpc_port}"
|
||||
rpc_public_addr = "127.0.0.1:{rpc_port}"
|
||||
rpc_secret = "{secret}"
|
||||
|
||||
allow_punycode = true
|
||||
|
||||
[s3_api]
|
||||
s3_region = "{region}"
|
||||
api_bind_addr = "127.0.0.1:{s3_port}"
|
||||
@@ -99,7 +101,10 @@ api_bind_addr = "127.0.0.1:{admin_port}"
|
||||
.arg("server")
|
||||
.stdout(stdout)
|
||||
.stderr(stderr)
|
||||
.env("RUST_LOG", "garage=debug,garage_api=trace")
|
||||
.env(
|
||||
"RUST_LOG",
|
||||
"garage=debug,garage_api_common=trace,garage_api_s3=trace",
|
||||
)
|
||||
.spawn()
|
||||
.expect("Could not start garage");
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::common;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::error::SdkError;
|
||||
use aws_sdk_s3::primitives::{ByteStream, DateTime};
|
||||
use aws_sdk_s3::types::{Delete, ObjectIdentifier};
|
||||
|
||||
const STD_KEY: &str = "hello world";
|
||||
@@ -125,6 +126,129 @@ async fn test_putobject() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_precondition() {
|
||||
let ctx = common::context();
|
||||
let bucket = ctx.create_bucket("precondition");
|
||||
|
||||
let etag = "\"46cf18a9b447991b450cad3facf5937e\"";
|
||||
let etag2 = "\"ae4984b984cd984fe98d4efa954dce98\"";
|
||||
let data = ByteStream::from_static(BODY);
|
||||
|
||||
let r = ctx
|
||||
.client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(STD_KEY)
|
||||
.body(data)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(r.e_tag.unwrap().as_str(), etag);
|
||||
|
||||
let last_modified;
|
||||
{
|
||||
let o = ctx
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(STD_KEY)
|
||||
.if_match(etag)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(o.e_tag.as_ref().unwrap().as_str(), etag);
|
||||
last_modified = o.last_modified.unwrap();
|
||||
|
||||
let err = ctx
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(STD_KEY)
|
||||
.if_match(etag2)
|
||||
.send()
|
||||
.await;
|
||||
assert!(
|
||||
matches!(err, Err(SdkError::ServiceError(se)) if se.raw().status().as_u16() == 412)
|
||||
);
|
||||
}
|
||||
{
|
||||
let o = ctx
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(STD_KEY)
|
||||
.if_none_match(etag2)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(o.e_tag.as_ref().unwrap().as_str(), etag);
|
||||
|
||||
let err = ctx
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(STD_KEY)
|
||||
.if_none_match(etag)
|
||||
.send()
|
||||
.await;
|
||||
assert!(
|
||||
matches!(err, Err(SdkError::ServiceError(se)) if se.raw().status().as_u16() == 304)
|
||||
);
|
||||
}
|
||||
let older_date = DateTime::from_secs_f64(last_modified.as_secs_f64() - 10.0);
|
||||
let newer_date = DateTime::from_secs_f64(last_modified.as_secs_f64() + 10.0);
|
||||
{
|
||||
let err = ctx
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(STD_KEY)
|
||||
.if_modified_since(newer_date)
|
||||
.send()
|
||||
.await;
|
||||
assert!(
|
||||
matches!(err, Err(SdkError::ServiceError(se)) if se.raw().status().as_u16() == 304)
|
||||
);
|
||||
|
||||
let o = ctx
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(STD_KEY)
|
||||
.if_modified_since(older_date)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(o.e_tag.as_ref().unwrap().as_str(), etag);
|
||||
}
|
||||
{
|
||||
let err = ctx
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(STD_KEY)
|
||||
.if_unmodified_since(older_date)
|
||||
.send()
|
||||
.await;
|
||||
assert!(
|
||||
matches!(err, Err(SdkError::ServiceError(se)) if se.raw().status().as_u16() == 412)
|
||||
);
|
||||
|
||||
let o = ctx
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(STD_KEY)
|
||||
.if_unmodified_since(newer_date)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(o.e_tag.as_ref().unwrap().as_str(), etag);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_getobject() {
|
||||
let ctx = common::context();
|
||||
@@ -189,12 +313,14 @@ async fn test_getobject() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_metadata() {
|
||||
use aws_sdk_s3::primitives::{DateTime, DateTimeFormat};
|
||||
|
||||
let ctx = common::context();
|
||||
let bucket = ctx.create_bucket("testmetadata");
|
||||
|
||||
let etag = "\"46cf18a9b447991b450cad3facf5937e\"";
|
||||
let exp = aws_sdk_s3::primitives::DateTime::from_secs(10000000000);
|
||||
let exp2 = aws_sdk_s3::primitives::DateTime::from_secs(10000500000);
|
||||
let exp = DateTime::from_secs(10000000000);
|
||||
let exp2 = DateTime::from_secs(10000500000);
|
||||
|
||||
{
|
||||
// Note. The AWS client SDK adds a Content-Type header
|
||||
@@ -227,7 +353,7 @@ async fn test_metadata() {
|
||||
assert_eq!(o.content_disposition, None);
|
||||
assert_eq!(o.content_encoding, None);
|
||||
assert_eq!(o.content_language, None);
|
||||
assert_eq!(o.expires, None);
|
||||
assert_eq!(o.expires_string, None);
|
||||
assert_eq!(o.metadata.unwrap_or_default().len(), 0);
|
||||
|
||||
let o = ctx
|
||||
@@ -250,7 +376,10 @@ async fn test_metadata() {
|
||||
assert_eq!(o.content_disposition.unwrap().as_str(), "cddummy");
|
||||
assert_eq!(o.content_encoding.unwrap().as_str(), "cedummy");
|
||||
assert_eq!(o.content_language.unwrap().as_str(), "cldummy");
|
||||
assert_eq!(o.expires.unwrap(), exp);
|
||||
assert_eq!(
|
||||
o.expires_string.unwrap(),
|
||||
exp.fmt(DateTimeFormat::HttpDate).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -288,7 +417,10 @@ async fn test_metadata() {
|
||||
assert_eq!(o.content_disposition.unwrap().as_str(), "cdtest");
|
||||
assert_eq!(o.content_encoding.unwrap().as_str(), "cetest");
|
||||
assert_eq!(o.content_language.unwrap().as_str(), "cltest");
|
||||
assert_eq!(o.expires.unwrap(), exp2);
|
||||
assert_eq!(
|
||||
o.expires_string.unwrap(),
|
||||
exp2.fmt(DateTimeFormat::HttpDate).unwrap()
|
||||
);
|
||||
let mut meta = o.metadata.unwrap();
|
||||
assert_eq!(meta.remove("testmeta").unwrap(), "hello people");
|
||||
assert_eq!(meta.remove("nice-unicode-meta").unwrap(), "宅配便");
|
||||
@@ -314,7 +446,10 @@ async fn test_metadata() {
|
||||
assert_eq!(o.content_disposition.unwrap().as_str(), "cddummy");
|
||||
assert_eq!(o.content_encoding.unwrap().as_str(), "cedummy");
|
||||
assert_eq!(o.content_language.unwrap().as_str(), "cldummy");
|
||||
assert_eq!(o.expires.unwrap(), exp);
|
||||
assert_eq!(
|
||||
o.expires_string.unwrap(),
|
||||
exp.fmt(DateTimeFormat::HttpDate).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use base64::prelude::*;
|
||||
use crc32fast::Hasher as Crc32;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::ext::CommandExt;
|
||||
use common::custom_requester::BodySignature;
|
||||
@@ -21,7 +24,7 @@ async fn test_putobject_streaming() {
|
||||
let content_type = "text/csv";
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("content-type".to_owned(), content_type.to_owned());
|
||||
let _ = ctx
|
||||
let res = ctx
|
||||
.custom_request
|
||||
.builder(bucket.clone())
|
||||
.method(Method::PUT)
|
||||
@@ -29,10 +32,11 @@ async fn test_putobject_streaming() {
|
||||
.signed_headers(headers)
|
||||
.vhost_style(true)
|
||||
.body(vec![])
|
||||
.body_signature(BodySignature::Streaming(10))
|
||||
.body_signature(BodySignature::Streaming { chunk_size: 10 })
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(res.status().is_success(), "got response: {:?}", res);
|
||||
|
||||
// assert_eq!(r.e_tag.unwrap().as_str(), etag);
|
||||
// We return a version ID here
|
||||
@@ -65,7 +69,14 @@ async fn test_putobject_streaming() {
|
||||
{
|
||||
let etag = "\"46cf18a9b447991b450cad3facf5937e\"";
|
||||
|
||||
let _ = ctx
|
||||
let mut crc32 = Crc32::new();
|
||||
crc32.update(&BODY[..]);
|
||||
let crc32 = BASE64_STANDARD.encode(&u32::to_be_bytes(crc32.finalize())[..]);
|
||||
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("x-amz-checksum-crc32".to_owned(), crc32.clone());
|
||||
|
||||
let res = ctx
|
||||
.custom_request
|
||||
.builder(bucket.clone())
|
||||
.method(Method::PUT)
|
||||
@@ -73,11 +84,13 @@ async fn test_putobject_streaming() {
|
||||
//fail
|
||||
.path("abc".to_owned())
|
||||
.vhost_style(true)
|
||||
.signed_headers(headers)
|
||||
.body(BODY.to_vec())
|
||||
.body_signature(BodySignature::Streaming(16))
|
||||
.body_signature(BodySignature::Streaming { chunk_size: 16 })
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(res.status().is_success(), "got response: {:?}", res);
|
||||
|
||||
// assert_eq!(r.e_tag.unwrap().as_str(), etag);
|
||||
// assert!(r.version_id.is_some());
|
||||
@@ -88,6 +101,7 @@ async fn test_putobject_streaming() {
|
||||
.bucket(&bucket)
|
||||
//.key(CTRL_KEY)
|
||||
.key("abc")
|
||||
.checksum_mode(aws_sdk_s3::types::ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -98,6 +112,142 @@ async fn test_putobject_streaming() {
|
||||
assert_eq!(o.content_length.unwrap(), 62);
|
||||
assert_eq!(o.parts_count, None);
|
||||
assert_eq!(o.tag_count, None);
|
||||
assert_eq!(o.checksum_crc32.unwrap(), crc32);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_putobject_streaming_unsigned_trailer() {
|
||||
let ctx = common::context();
|
||||
let bucket = ctx.create_bucket("putobject-streaming-unsigned-trailer");
|
||||
|
||||
{
|
||||
// Send an empty object (can serve as a directory marker)
|
||||
// with a content type
|
||||
let etag = "\"d41d8cd98f00b204e9800998ecf8427e\"";
|
||||
let content_type = "text/csv";
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("content-type".to_owned(), content_type.to_owned());
|
||||
|
||||
let empty_crc32 = BASE64_STANDARD.encode(&u32::to_be_bytes(Crc32::new().finalize())[..]);
|
||||
|
||||
let res = ctx
|
||||
.custom_request
|
||||
.builder(bucket.clone())
|
||||
.method(Method::PUT)
|
||||
.path(STD_KEY.to_owned())
|
||||
.signed_headers(headers)
|
||||
.vhost_style(true)
|
||||
.body(vec![])
|
||||
.body_signature(BodySignature::StreamingUnsignedTrailer {
|
||||
chunk_size: 10,
|
||||
trailer_algorithm: "x-amz-checksum-crc32".into(),
|
||||
trailer_value: empty_crc32,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(res.status().is_success(), "got response: {:?}", res);
|
||||
|
||||
// assert_eq!(r.e_tag.unwrap().as_str(), etag);
|
||||
// We return a version ID here
|
||||
// We should check if Amazon is returning one when versioning is not enabled
|
||||
// assert!(r.version_id.is_some());
|
||||
|
||||
//let _version = r.version_id.unwrap();
|
||||
|
||||
let o = ctx
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(STD_KEY)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_bytes_eq!(o.body, b"");
|
||||
assert_eq!(o.e_tag.unwrap(), etag);
|
||||
// We do not return version ID
|
||||
// We should check if Amazon is returning one when versioning is not enabled
|
||||
// assert_eq!(o.version_id.unwrap(), _version);
|
||||
assert_eq!(o.content_type.unwrap(), content_type);
|
||||
assert!(o.last_modified.is_some());
|
||||
assert_eq!(o.content_length.unwrap(), 0);
|
||||
assert_eq!(o.parts_count, None);
|
||||
assert_eq!(o.tag_count, None);
|
||||
}
|
||||
|
||||
{
|
||||
let etag = "\"46cf18a9b447991b450cad3facf5937e\"";
|
||||
|
||||
let mut crc32 = Crc32::new();
|
||||
crc32.update(&BODY[..]);
|
||||
let crc32 = BASE64_STANDARD.encode(&u32::to_be_bytes(crc32.finalize())[..]);
|
||||
|
||||
// try sending with wrong crc32, check that it fails
|
||||
let err_res = ctx
|
||||
.custom_request
|
||||
.builder(bucket.clone())
|
||||
.method(Method::PUT)
|
||||
//.path(CTRL_KEY.to_owned()) at the moment custom_request does not encode url so this
|
||||
//fail
|
||||
.path("abc".to_owned())
|
||||
.vhost_style(true)
|
||||
.body(BODY.to_vec())
|
||||
.body_signature(BodySignature::StreamingUnsignedTrailer {
|
||||
chunk_size: 16,
|
||||
trailer_algorithm: "x-amz-checksum-crc32".into(),
|
||||
trailer_value: "2Yp9Yw==".into(),
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
err_res.status().is_client_error(),
|
||||
"got response: {:?}",
|
||||
err_res
|
||||
);
|
||||
|
||||
let res = ctx
|
||||
.custom_request
|
||||
.builder(bucket.clone())
|
||||
.method(Method::PUT)
|
||||
//.path(CTRL_KEY.to_owned()) at the moment custom_request does not encode url so this
|
||||
//fail
|
||||
.path("abc".to_owned())
|
||||
.vhost_style(true)
|
||||
.body(BODY.to_vec())
|
||||
.body_signature(BodySignature::StreamingUnsignedTrailer {
|
||||
chunk_size: 16,
|
||||
trailer_algorithm: "x-amz-checksum-crc32".into(),
|
||||
trailer_value: crc32.clone(),
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(res.status().is_success(), "got response: {:?}", res);
|
||||
|
||||
// assert_eq!(r.e_tag.unwrap().as_str(), etag);
|
||||
// assert!(r.version_id.is_some());
|
||||
|
||||
let o = ctx
|
||||
.client
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
//.key(CTRL_KEY)
|
||||
.key("abc")
|
||||
.checksum_mode(aws_sdk_s3::types::ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_bytes_eq!(o.body, BODY);
|
||||
assert_eq!(o.e_tag.unwrap(), etag);
|
||||
assert!(o.last_modified.is_some());
|
||||
assert_eq!(o.content_length.unwrap(), 62);
|
||||
assert_eq!(o.parts_count, None);
|
||||
assert_eq!(o.tag_count, None);
|
||||
assert_eq!(o.checksum_crc32.unwrap(), crc32);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +269,7 @@ async fn test_create_bucket_streaming() {
|
||||
.custom_request
|
||||
.builder(bucket.to_owned())
|
||||
.method(Method::PUT)
|
||||
.body_signature(BodySignature::Streaming(10))
|
||||
.body_signature(BodySignature::Streaming { chunk_size: 10 })
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -174,7 +324,7 @@ async fn test_put_website_streaming() {
|
||||
.method(Method::PUT)
|
||||
.query_params(query)
|
||||
.body(website_config.as_bytes().to_vec())
|
||||
.body_signature(BodySignature::Streaming(10))
|
||||
.body_signature(BodySignature::Streaming { chunk_size: 10 })
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -14,6 +14,7 @@ use http::{Request, StatusCode};
|
||||
use http_body_util::BodyExt;
|
||||
use http_body_util::Full as FullBody;
|
||||
use hyper::body::Bytes;
|
||||
use hyper::header::LOCATION;
|
||||
use hyper_util::client::legacy::Client;
|
||||
use hyper_util::rt::TokioExecutor;
|
||||
use serde_json::json;
|
||||
@@ -298,6 +299,33 @@ async fn test_website_s3_api() {
|
||||
);
|
||||
}
|
||||
|
||||
// Test x-amz-website-redirect-location
|
||||
{
|
||||
ctx.client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key("test-redirect.html")
|
||||
.website_redirect_location("https://perdu.com")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!(
|
||||
"http://127.0.0.1:{}/test-redirect.html",
|
||||
ctx.garage.web_port
|
||||
))
|
||||
.header("Host", format!("{}.web.garage", BCKT_NAME))
|
||||
.body(Body::new(Bytes::new()))
|
||||
.unwrap();
|
||||
|
||||
let resp = client.request(req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::MOVED_PERMANENTLY);
|
||||
assert_eq!(resp.headers().get(LOCATION).unwrap(), "https://perdu.com");
|
||||
}
|
||||
|
||||
// Test CORS with an allowed preflight request
|
||||
{
|
||||
let req = Request::builder()
|
||||
@@ -949,3 +977,76 @@ async fn test_website_invalid_redirect() {
|
||||
.await
|
||||
.unwrap_err();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_website_puny() {
|
||||
const BCKT_NAME: &str = "xn--pda.eu";
|
||||
let ctx = common::context();
|
||||
let bucket = ctx.create_bucket(BCKT_NAME);
|
||||
|
||||
let data = ByteStream::from_static(BODY);
|
||||
|
||||
ctx.client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key("index.html")
|
||||
.body(data)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let client = Client::builder(TokioExecutor::new()).build_http();
|
||||
|
||||
let req = |suffix| {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!("http://127.0.0.1:{}/", ctx.garage.web_port))
|
||||
.header("Host", format!("{}{}", BCKT_NAME, suffix))
|
||||
.body(Body::new(Bytes::new()))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
ctx.garage
|
||||
.command()
|
||||
.args(["bucket", "website", "--allow", BCKT_NAME])
|
||||
.quiet()
|
||||
.expect_success_status("Could not allow website on bucket");
|
||||
|
||||
let mut resp = client.request(req("")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||
BODY.as_ref()
|
||||
);
|
||||
|
||||
resp = client.request(req(".web.garage")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
resp.into_body().collect().await.unwrap().to_bytes(),
|
||||
BODY.as_ref()
|
||||
);
|
||||
|
||||
for bname in [
|
||||
BCKT_NAME.to_string(),
|
||||
format!("{BCKT_NAME}.web.garage"),
|
||||
format!("{BCKT_NAME}.s3.garage"),
|
||||
] {
|
||||
let admin_req = || {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!(
|
||||
"http://127.0.0.1:{0}/check?domain={1}",
|
||||
ctx.garage.admin_port, bname
|
||||
))
|
||||
.body(Body::new(Bytes::new()))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let admin_resp = client.request(admin_req()).await.unwrap();
|
||||
assert_eq!(admin_resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
admin_resp.into_body().collect().await.unwrap().to_bytes(),
|
||||
format!("Domain '{bname}' is managed by Garage").as_bytes()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "garage_model"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||
edition = "2018"
|
||||
license = "AGPL-3.0"
|
||||
|
||||
@@ -22,14 +22,10 @@ mod v08 {
|
||||
pub use v08::*;
|
||||
|
||||
impl BucketAlias {
|
||||
pub fn new(name: String, ts: u64, bucket_id: Option<Uuid>) -> Option<Self> {
|
||||
if !is_valid_bucket_name(&name) {
|
||||
None
|
||||
} else {
|
||||
Some(BucketAlias {
|
||||
name,
|
||||
state: crdt::Lww::raw(ts, bucket_id),
|
||||
})
|
||||
pub fn new(name: String, ts: u64, bucket_id: Option<Uuid>) -> Self {
|
||||
BucketAlias {
|
||||
name,
|
||||
state: crdt::Lww::raw(ts, bucket_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +76,7 @@ impl TableSchema for BucketAliasTable {
|
||||
/// In the case of Garage, bucket names must not be hex-encoded
|
||||
/// 32 byte string, which is excluded thanks to the
|
||||
/// maximum length of 63 bytes given in the spec.
|
||||
pub fn is_valid_bucket_name(n: &str) -> bool {
|
||||
pub fn is_valid_bucket_name(n: &str, puny: bool) -> bool {
|
||||
// Bucket names must be between 3 and 63 characters
|
||||
n.len() >= 3 && n.len() <= 63
|
||||
// Bucket names must be composed of lowercase letters, numbers,
|
||||
@@ -92,7 +88,9 @@ pub fn is_valid_bucket_name(n: &str) -> bool {
|
||||
// Bucket names must not be formatted as an IP address
|
||||
&& n.parse::<std::net::IpAddr>().is_err()
|
||||
// Bucket names must not start with "xn--"
|
||||
&& !n.starts_with("xn--")
|
||||
&& (!n.starts_with("xn--") || puny)
|
||||
// We are a bit stricter, to properly restrict punycode in all labels
|
||||
&& (!n.contains(".xn--") || puny)
|
||||
// Bucket names must not end with "-s3alias"
|
||||
&& !n.ends_with("-s3alias")
|
||||
}
|
||||
|
||||
+277
-41
@@ -1,3 +1,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use garage_db as db;
|
||||
|
||||
use garage_util::crdt::*;
|
||||
use garage_util::data::*;
|
||||
use garage_util::error::{Error as GarageError, OkOrMessage};
|
||||
@@ -47,6 +51,10 @@ impl<'a> LockedHelper<'a> {
|
||||
KeyHelper(self.0)
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// global bucket aliases
|
||||
// ================================================
|
||||
|
||||
/// Sets a new alias for a bucket in global namespace.
|
||||
/// This function fails if:
|
||||
/// - alias name is not valid according to S3 spec
|
||||
@@ -57,7 +65,7 @@ impl<'a> LockedHelper<'a> {
|
||||
bucket_id: Uuid,
|
||||
alias_name: &String,
|
||||
) -> Result<(), Error> {
|
||||
if !is_valid_bucket_name(alias_name) {
|
||||
if !is_valid_bucket_name(alias_name, self.0.config.allow_punycode) {
|
||||
return Err(Error::InvalidBucketName(alias_name.to_string()));
|
||||
}
|
||||
|
||||
@@ -88,8 +96,7 @@ impl<'a> LockedHelper<'a> {
|
||||
// writes are now done and all writes use timestamp alias_ts
|
||||
|
||||
let alias = match alias {
|
||||
None => BucketAlias::new(alias_name.clone(), alias_ts, Some(bucket_id))
|
||||
.ok_or_else(|| Error::InvalidBucketName(alias_name.clone()))?,
|
||||
None => BucketAlias::new(alias_name.clone(), alias_ts, Some(bucket_id)),
|
||||
Some(mut a) => {
|
||||
a.state = Lww::raw(alias_ts, Some(bucket_id));
|
||||
a
|
||||
@@ -180,13 +187,14 @@ impl<'a> LockedHelper<'a> {
|
||||
.ok_or_else(|| Error::NoSuchBucket(alias_name.to_string()))?;
|
||||
|
||||
// Checks ok, remove alias
|
||||
let alias_ts = match bucket.state.as_option() {
|
||||
Some(bucket_state) => increment_logical_clock_2(
|
||||
alias.state.timestamp(),
|
||||
bucket_state.aliases.get_timestamp(alias_name),
|
||||
),
|
||||
None => increment_logical_clock(alias.state.timestamp()),
|
||||
};
|
||||
let alias_ts = increment_logical_clock_2(
|
||||
alias.state.timestamp(),
|
||||
bucket
|
||||
.state
|
||||
.as_option()
|
||||
.map(|p| p.aliases.get_timestamp(alias_name))
|
||||
.unwrap_or(0),
|
||||
);
|
||||
|
||||
// ---- timestamp-ensured causality barrier ----
|
||||
// writes are now done and all writes use timestamp alias_ts
|
||||
@@ -204,6 +212,10 @@ impl<'a> LockedHelper<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// local bucket aliases
|
||||
// ================================================
|
||||
|
||||
/// Sets a new alias for a bucket in the local namespace of a key.
|
||||
/// This function fails if:
|
||||
/// - alias name is not valid according to S3 spec
|
||||
@@ -216,14 +228,12 @@ impl<'a> LockedHelper<'a> {
|
||||
key_id: &String,
|
||||
alias_name: &String,
|
||||
) -> Result<(), Error> {
|
||||
let key_helper = KeyHelper(self.0);
|
||||
|
||||
if !is_valid_bucket_name(alias_name) {
|
||||
if !is_valid_bucket_name(alias_name, self.0.config.allow_punycode) {
|
||||
return Err(Error::InvalidBucketName(alias_name.to_string()));
|
||||
}
|
||||
|
||||
let mut bucket = self.bucket().get_existing_bucket(bucket_id).await?;
|
||||
let mut key = key_helper.get_existing_key(key_id).await?;
|
||||
let mut key = self.key().get_existing_key(key_id).await?;
|
||||
|
||||
let key_param = key.state.as_option_mut().unwrap();
|
||||
|
||||
@@ -272,23 +282,13 @@ impl<'a> LockedHelper<'a> {
|
||||
key_id: &String,
|
||||
alias_name: &String,
|
||||
) -> Result<(), Error> {
|
||||
let key_helper = KeyHelper(self.0);
|
||||
|
||||
let mut bucket = self.bucket().get_existing_bucket(bucket_id).await?;
|
||||
let mut key = key_helper.get_existing_key(key_id).await?;
|
||||
let mut key = self.key().get_existing_key(key_id).await?;
|
||||
|
||||
let key_p = key.state.as_option().unwrap();
|
||||
let bucket_p = bucket.state.as_option_mut().unwrap();
|
||||
|
||||
if key
|
||||
.state
|
||||
.as_option()
|
||||
.unwrap()
|
||||
.local_aliases
|
||||
.get(alias_name)
|
||||
.cloned()
|
||||
.flatten()
|
||||
!= Some(bucket_id)
|
||||
{
|
||||
if key_p.local_aliases.get(alias_name).cloned().flatten() != Some(bucket_id) {
|
||||
return Err(GarageError::Message(format!(
|
||||
"Bucket {:?} does not have alias {} in namespace of key {}",
|
||||
bucket_id, alias_name, key_id
|
||||
@@ -305,17 +305,17 @@ impl<'a> LockedHelper<'a> {
|
||||
.local_aliases
|
||||
.items()
|
||||
.iter()
|
||||
.any(|((k, n), _, active)| *k == key.key_id && n == alias_name && *active);
|
||||
.any(|((k, n), _, active)| (*k != key.key_id || n != alias_name) && *active);
|
||||
|
||||
if !has_other_global_aliases && !has_other_local_aliases {
|
||||
return Err(Error::BadRequest(format!("Bucket {} doesn't have other aliases, please delete it instead of just unaliasing.", alias_name)));
|
||||
}
|
||||
|
||||
// Checks ok, remove alias
|
||||
let key_param = key.state.as_option_mut().unwrap();
|
||||
let bucket_p_local_alias_key = (key.key_id.clone(), alias_name.clone());
|
||||
|
||||
let alias_ts = increment_logical_clock_2(
|
||||
key_param.local_aliases.get_timestamp(alias_name),
|
||||
key_p.local_aliases.get_timestamp(alias_name),
|
||||
bucket_p
|
||||
.local_aliases
|
||||
.get_timestamp(&bucket_p_local_alias_key),
|
||||
@@ -324,7 +324,8 @@ impl<'a> LockedHelper<'a> {
|
||||
// ---- timestamp-ensured causality barrier ----
|
||||
// writes are now done and all writes use timestamp alias_ts
|
||||
|
||||
key_param.local_aliases = LwwMap::raw_item(alias_name.clone(), alias_ts, None);
|
||||
key.state.as_option_mut().unwrap().local_aliases =
|
||||
LwwMap::raw_item(alias_name.clone(), alias_ts, None);
|
||||
self.0.key_table.insert(&key).await?;
|
||||
|
||||
bucket_p.local_aliases = LwwMap::raw_item(bucket_p_local_alias_key, alias_ts, false);
|
||||
@@ -333,21 +334,68 @@ impl<'a> LockedHelper<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensures a bucket does not have a certain local alias.
|
||||
/// Contrarily to unset_local_bucket_alias, this does not
|
||||
/// fail on any condition other than:
|
||||
/// - bucket cannot be found (its fine if it is in deleted state)
|
||||
/// - key cannot be found (its fine if alias in key points to nothing
|
||||
/// or to another bucket)
|
||||
pub async fn purge_local_bucket_alias(
|
||||
&self,
|
||||
bucket_id: Uuid,
|
||||
key_id: &String,
|
||||
alias_name: &String,
|
||||
) -> Result<(), Error> {
|
||||
let mut bucket = self.bucket().get_internal_bucket(bucket_id).await?;
|
||||
let mut key = self.key().get_internal_key(key_id).await?;
|
||||
|
||||
let bucket_p_local_alias_key = (key.key_id.clone(), alias_name.clone());
|
||||
|
||||
let alias_ts = increment_logical_clock_2(
|
||||
key.state
|
||||
.as_option()
|
||||
.map(|p| p.local_aliases.get_timestamp(alias_name))
|
||||
.unwrap_or(0),
|
||||
bucket
|
||||
.state
|
||||
.as_option()
|
||||
.map(|p| p.local_aliases.get_timestamp(&bucket_p_local_alias_key))
|
||||
.unwrap_or(0),
|
||||
);
|
||||
|
||||
// ---- timestamp-ensured causality barrier ----
|
||||
// writes are now done and all writes use timestamp alias_ts
|
||||
|
||||
if let Some(kp) = key.state.as_option_mut() {
|
||||
kp.local_aliases = LwwMap::raw_item(alias_name.clone(), alias_ts, None);
|
||||
self.0.key_table.insert(&key).await?;
|
||||
}
|
||||
|
||||
if let Some(bp) = bucket.state.as_option_mut() {
|
||||
bp.local_aliases = LwwMap::raw_item(bucket_p_local_alias_key, alias_ts, false);
|
||||
self.0.bucket_table.insert(&bucket).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// permissions
|
||||
// ================================================
|
||||
|
||||
/// Sets permissions for a key on a bucket.
|
||||
/// This function fails if:
|
||||
/// - bucket or key cannot be found at all (its ok if they are in deleted state)
|
||||
/// - bucket or key is in deleted state and we are trying to set permissions other than "deny
|
||||
/// all"
|
||||
/// - bucket or key is in deleted state and we are trying to set
|
||||
/// permissions other than "deny all"
|
||||
pub async fn set_bucket_key_permissions(
|
||||
&self,
|
||||
bucket_id: Uuid,
|
||||
key_id: &String,
|
||||
mut perm: BucketKeyPerm,
|
||||
) -> Result<(), Error> {
|
||||
let key_helper = KeyHelper(self.0);
|
||||
|
||||
let mut bucket = self.bucket().get_internal_bucket(bucket_id).await?;
|
||||
let mut key = key_helper.get_internal_key(key_id).await?;
|
||||
let mut key = self.key().get_internal_key(key_id).await?;
|
||||
|
||||
if let Some(bstate) = bucket.state.as_option() {
|
||||
if let Some(kp) = bstate.authorized_keys.get(key_id) {
|
||||
@@ -384,21 +432,20 @@ impl<'a> LockedHelper<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ----
|
||||
// ================================================
|
||||
// keys
|
||||
// ================================================
|
||||
|
||||
/// Deletes an API access key
|
||||
pub async fn delete_key(&self, key: &mut Key) -> Result<(), Error> {
|
||||
let state = key.state.as_option_mut().unwrap();
|
||||
|
||||
// --- done checking, now commit ---
|
||||
// (the step at unset_local_bucket_alias will fail if a bucket
|
||||
// does not have another alias, the deletion will be
|
||||
// interrupted in the middle if that happens)
|
||||
|
||||
// 1. Delete local aliases
|
||||
for (alias, _, to) in state.local_aliases.items().iter() {
|
||||
if let Some(bucket_id) = to {
|
||||
self.unset_local_bucket_alias(*bucket_id, &key.key_id, alias)
|
||||
self.purge_local_bucket_alias(*bucket_id, &key.key_id, alias)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
@@ -415,4 +462,193 @@ impl<'a> LockedHelper<'a> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// repair procedure
|
||||
// ================================================
|
||||
|
||||
pub async fn repair_aliases(&self) -> Result<(), GarageError> {
|
||||
self.0.db.transaction(|tx| {
|
||||
info!("--- begin repair_aliases transaction ----");
|
||||
|
||||
// 1. List all non-deleted buckets, so that we can fix bad aliases
|
||||
let mut all_buckets: HashSet<Uuid> = HashSet::new();
|
||||
|
||||
for item in tx.range::<&[u8], _>(&self.0.bucket_table.data.store, ..)? {
|
||||
let bucket = self
|
||||
.0
|
||||
.bucket_table
|
||||
.data
|
||||
.decode_entry(&(item?.1))
|
||||
.map_err(db::TxError::Abort)?;
|
||||
if !bucket.is_deleted() {
|
||||
all_buckets.insert(bucket.id);
|
||||
}
|
||||
}
|
||||
|
||||
info!("number of buckets: {}", all_buckets.len());
|
||||
|
||||
// 2. List all aliases declared in bucket_alias_table and key_table
|
||||
// Take note of aliases that point to non-existing buckets
|
||||
let mut global_aliases: HashMap<String, Uuid> = HashMap::new();
|
||||
|
||||
{
|
||||
let mut delete_global = vec![];
|
||||
for item in tx.range::<&[u8], _>(&self.0.bucket_alias_table.data.store, ..)? {
|
||||
let mut alias = self
|
||||
.0
|
||||
.bucket_alias_table
|
||||
.data
|
||||
.decode_entry(&(item?.1))
|
||||
.map_err(db::TxError::Abort)?;
|
||||
if let Some(id) = alias.state.get() {
|
||||
if all_buckets.contains(id) {
|
||||
// keep aliases
|
||||
global_aliases.insert(alias.name().to_string(), *id);
|
||||
} else {
|
||||
// delete alias
|
||||
warn!(
|
||||
"global alias: remove {} -> {:?} (bucket is deleted)",
|
||||
alias.name(),
|
||||
id
|
||||
);
|
||||
alias.state.update(None);
|
||||
delete_global.push(alias);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("number of global aliases: {}", global_aliases.len());
|
||||
|
||||
info!("global alias table: {} entries fixed", delete_global.len());
|
||||
for ga in delete_global {
|
||||
debug!("Enqueue update to global alias table: {:?}", ga);
|
||||
self.0.bucket_alias_table.queue_insert(tx, &ga)?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut local_aliases: HashMap<(String, String), Uuid> = HashMap::new();
|
||||
|
||||
{
|
||||
let mut delete_local = vec![];
|
||||
|
||||
for item in tx.range::<&[u8], _>(&self.0.key_table.data.store, ..)? {
|
||||
let mut key = self
|
||||
.0
|
||||
.key_table
|
||||
.data
|
||||
.decode_entry(&(item?.1))
|
||||
.map_err(db::TxError::Abort)?;
|
||||
let Some(p) = key.state.as_option_mut() else {
|
||||
continue;
|
||||
};
|
||||
let mut has_changes = false;
|
||||
for (name, _, to) in p.local_aliases.items().to_vec() {
|
||||
if let Some(id) = to {
|
||||
if all_buckets.contains(&id) {
|
||||
local_aliases.insert((key.key_id.clone(), name), id);
|
||||
} else {
|
||||
warn!(
|
||||
"local alias: remove ({}, {}) -> {:?} (bucket is deleted)",
|
||||
key.key_id, name, id
|
||||
);
|
||||
p.local_aliases.update_in_place(name, None);
|
||||
has_changes = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if has_changes {
|
||||
delete_local.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
info!("number of local aliases: {}", local_aliases.len());
|
||||
|
||||
info!("key table: {} entries fixed", delete_local.len());
|
||||
for la in delete_local {
|
||||
debug!("Enqueue update to key table: {:?}", la);
|
||||
self.0.key_table.queue_insert(tx, &la)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Reverse the alias maps to determine the aliases per-bucket
|
||||
let mut bucket_global: HashMap<Uuid, Vec<String>> = HashMap::new();
|
||||
let mut bucket_local: HashMap<Uuid, Vec<(String, String)>> = HashMap::new();
|
||||
|
||||
for (name, bucket) in global_aliases {
|
||||
bucket_global.entry(bucket).or_default().push(name);
|
||||
}
|
||||
for ((key, name), bucket) in local_aliases {
|
||||
bucket_local.entry(bucket).or_default().push((key, name));
|
||||
}
|
||||
|
||||
// 5. Fix the bucket table to ensure consistency
|
||||
let mut bucket_updates = vec![];
|
||||
|
||||
for item in tx.range::<&[u8], _>(&self.0.bucket_table.data.store, ..)? {
|
||||
let bucket = self
|
||||
.0
|
||||
.bucket_table
|
||||
.data
|
||||
.decode_entry(&(item?.1))
|
||||
.map_err(db::TxError::Abort)?;
|
||||
let mut bucket2 = bucket.clone();
|
||||
let Some(param) = bucket2.state.as_option_mut() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// fix global aliases
|
||||
{
|
||||
let ga = bucket_global.remove(&bucket.id).unwrap_or_default();
|
||||
for (name, _, active) in param.aliases.items().to_vec() {
|
||||
if active && !ga.contains(&name) {
|
||||
warn!("bucket {:?}: remove global alias {}", bucket.id, name);
|
||||
param.aliases.update_in_place(name, false);
|
||||
}
|
||||
}
|
||||
for name in ga {
|
||||
if param.aliases.get(&name).copied() != Some(true) {
|
||||
warn!("bucket {:?}: add global alias {}", bucket.id, name);
|
||||
param.aliases.update_in_place(name, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fix local aliases
|
||||
{
|
||||
let la = bucket_local.remove(&bucket.id).unwrap_or_default();
|
||||
for (pair, _, active) in param.local_aliases.items().to_vec() {
|
||||
if active && !la.contains(&pair) {
|
||||
warn!("bucket {:?}: remove local alias {:?}", bucket.id, pair);
|
||||
param.local_aliases.update_in_place(pair, false);
|
||||
}
|
||||
}
|
||||
for pair in la {
|
||||
if param.local_aliases.get(&pair).copied() != Some(true) {
|
||||
warn!("bucket {:?}: add local alias {:?}", bucket.id, pair);
|
||||
param.local_aliases.update_in_place(pair, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bucket2 != bucket {
|
||||
bucket_updates.push(bucket2);
|
||||
}
|
||||
}
|
||||
|
||||
info!("bucket table: {} entries fixed", bucket_updates.len());
|
||||
for b in bucket_updates {
|
||||
debug!("Enqueue update to bucket table: {:?}", b);
|
||||
self.0.bucket_table.queue_insert(tx, &b)?;
|
||||
}
|
||||
|
||||
info!("--- end repair_aliases transaction ----");
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
info!("repair_aliases is done");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "garage_net"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||
edition = "2018"
|
||||
license = "AGPL-3.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "garage_rpc"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||
edition = "2018"
|
||||
license = "AGPL-3.0"
|
||||
|
||||
@@ -650,8 +650,11 @@ impl LayoutVersion {
|
||||
let mut cost = CostFunction::new();
|
||||
for (p, assoc_p) in prev_assign.iter().enumerate() {
|
||||
for n in assoc_p.iter() {
|
||||
let node_zone = zone_to_id[self.expect_get_node_zone(&self.node_id_vec[*n])];
|
||||
cost.insert((Vertex::PZ(p, node_zone), Vertex::N(*n)), -1);
|
||||
if let Some(&node_zone) =
|
||||
zone_to_id.get(self.expect_get_node_zone(&self.node_id_vec[*n]))
|
||||
{
|
||||
cost.insert((Vertex::PZ(p, node_zone), Vertex::N(*n)), -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,8 +754,11 @@ impl LayoutVersion {
|
||||
if let Some(prev_assign) = prev_assign_opt {
|
||||
let mut old_zones_of_p = Vec::<usize>::new();
|
||||
for n in prev_assign[p].iter() {
|
||||
old_zones_of_p
|
||||
.push(zone_to_id[self.expect_get_node_zone(&self.node_id_vec[*n])]);
|
||||
if let Some(&zone_id) =
|
||||
zone_to_id.get(self.expect_get_node_zone(&self.node_id_vec[*n]))
|
||||
{
|
||||
old_zones_of_p.push(zone_id);
|
||||
}
|
||||
}
|
||||
if !old_zones_of_p.contains(&z) {
|
||||
new_partitions_zone[z] += 1;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "garage_table"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||
edition = "2018"
|
||||
license = "AGPL-3.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "garage_util"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
authors = ["Alex Auvolat <alex@adnab.me>"]
|
||||
edition = "2018"
|
||||
license = "AGPL-3.0"
|
||||
|
||||
@@ -135,6 +135,10 @@ pub struct Config {
|
||||
/// Configuration for the admin API endpoint
|
||||
#[serde(default = "Default::default")]
|
||||
pub admin: AdminConfig,
|
||||
|
||||
/// Allow punycode in bucket names
|
||||
#[serde(default)]
|
||||
pub allow_punycode: bool,
|
||||
}
|
||||
|
||||
/// Value for data_dir: either a single directory or a list of dirs with attributes
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "garage_web"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
authors = ["Alex Auvolat <alex@adnab.me>", "Quentin Dufour <quentin@dufour.io>"]
|
||||
edition = "2018"
|
||||
license = "AGPL-3.0"
|
||||
|
||||
+17
-10
@@ -1,14 +1,13 @@
|
||||
use std::fs::{self, Permissions};
|
||||
use std::os::unix::prelude::PermissionsExt;
|
||||
use std::{convert::Infallible, sync::Arc};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::net::{TcpListener, UnixListener};
|
||||
use tokio::sync::watch;
|
||||
|
||||
use hyper::{
|
||||
body::Body,
|
||||
body::Incoming as IncomingBody,
|
||||
header::{HeaderValue, HOST},
|
||||
header::{HeaderValue, HOST, LOCATION},
|
||||
Method, Request, Response, StatusCode,
|
||||
};
|
||||
|
||||
@@ -31,6 +30,7 @@ use garage_api_s3::error::{
|
||||
CommonErrorDerivative, Error as ApiError, OkOrBadRequest, OkOrInternalError,
|
||||
};
|
||||
use garage_api_s3::get::{handle_get_without_ctx, handle_head_without_ctx};
|
||||
use garage_api_s3::website::X_AMZ_WEBSITE_REDIRECT_LOCATION;
|
||||
|
||||
use garage_model::bucket_table::{self, RoutingRule};
|
||||
use garage_model::garage::Garage;
|
||||
@@ -166,6 +166,8 @@ impl WebServer {
|
||||
metrics_tags.push(KeyValue::new("host", host_header.clone()));
|
||||
}
|
||||
|
||||
let req = req.map(|_| ());
|
||||
|
||||
// The actual handler
|
||||
let res = self
|
||||
.serve_file(&req)
|
||||
@@ -221,7 +223,7 @@ impl WebServer {
|
||||
|
||||
async fn serve_file(
|
||||
self: &Arc<Self>,
|
||||
req: &Request<IncomingBody>,
|
||||
req: &Request<()>,
|
||||
) -> Result<Response<BoxBody<ApiError>>, Error> {
|
||||
// Get http authority string (eg. [::1]:3902 or garage.tld:80)
|
||||
let authority = req
|
||||
@@ -325,6 +327,14 @@ impl WebServer {
|
||||
)
|
||||
.await
|
||||
}
|
||||
(Ok(ret), _) if ret.headers().contains_key(X_AMZ_WEBSITE_REDIRECT_LOCATION) => {
|
||||
let redirect_location = ret.headers().get(X_AMZ_WEBSITE_REDIRECT_LOCATION).unwrap();
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::MOVED_PERMANENTLY)
|
||||
.header(LOCATION, redirect_location)
|
||||
.body(empty_body())
|
||||
.unwrap())
|
||||
}
|
||||
_ => ret_doc,
|
||||
};
|
||||
|
||||
@@ -352,7 +362,7 @@ impl WebServer {
|
||||
let req2 = Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!("http://{}/{}", host, &error_document))
|
||||
.body(empty_body::<Infallible>())
|
||||
.body(())
|
||||
.unwrap();
|
||||
|
||||
match handle_inner(
|
||||
@@ -407,7 +417,7 @@ impl WebServer {
|
||||
|
||||
async fn handle_inner(
|
||||
garage: Arc<Garage>,
|
||||
req: &Request<impl Body>,
|
||||
req: &Request<()>,
|
||||
bucket_id: Uuid,
|
||||
key: &str,
|
||||
status_code: StatusCode,
|
||||
@@ -417,10 +427,7 @@ async fn handle_inner(
|
||||
// the original request that would have influenced the result:
|
||||
// - Range header, we don't want to return a subrange of the error document
|
||||
// - Caching directives such as If-None-Match, etc, which are not relevant
|
||||
let cleaned_req = Request::builder()
|
||||
.uri(req.uri())
|
||||
.body(empty_body::<Infallible>())
|
||||
.unwrap();
|
||||
let cleaned_req = Request::builder().uri(req.uri()).body(()).unwrap();
|
||||
|
||||
let mut ret = match req.method() {
|
||||
&Method::HEAD => {
|
||||
|
||||
Reference in New Issue
Block a user