Skip to content

Commit c03d7a4

Browse files
alukachclaude
andauthored
fix(core): percent-encode object keys in raw-signed backend URLs (#105)
* fix(core): percent-encode object keys in raw-signed backend URLs Multipart operations (CreateMultipartUpload, UploadPart, Complete, Abort) build their backend URL by splicing the decoded object key into a string. For key characters that url::Url leaves literal in paths but that are outside the RFC 3986 unreserved set (`=`, `!`, `(`, `)`, `:`, `@`, ...), the request went out — and was signed — with the literal byte, while S3/MinIO reconstruct the SigV4 canonical URI by strict-encoding the decoded path. The signatures never matched, so multipart uploads to Hive-style partition keys (`country_iso=ETH/...`) failed with 403 SignatureDoesNotMatch at CreateMultipartUpload. Encode the assembled prefix+key with the SigV4 strict set (unreserved chars, `/` kept as separator) before splicing. The URL string is both the signing input and the wire bytes, so the two stay byte-identical on any backend. Presigned CRUD ops already encode this way via object_store's STRICT_ENCODE_SET, which is why single PUT/GET on such keys worked. Also syncs Cargo.lock with the 0.6.3 release version bump. Reported downstream as source-cooperative/data.source.coop#180. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: state the encode-set rationale timelessly Comments described the pre-fix state ('already work', 'pre-existing'); reword to describe the invariant instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7f8823a commit c03d7a4

2 files changed

Lines changed: 119 additions & 0 deletions

File tree

crates/core/src/backend/multipart.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,22 @@ use crate::types::{BucketConfig, S3Operation};
1313
use http::{HeaderMap, Method};
1414
use url::Url;
1515

16+
/// SigV4 canonical path encoding: everything but RFC 3986 unreserved
17+
/// characters is percent-encoded; `/` stays raw as the segment separator.
18+
///
19+
/// AWS (and MinIO) reconstruct the canonical URI by strict-encoding the
20+
/// decoded request path, and `url::Url` leaves `=` and friends literal in
21+
/// paths — so the key must be encoded with this exact set before it is
22+
/// spliced into the URL, or the backend signature won't match
23+
/// (`SignatureDoesNotMatch`). This is the same strict set `object_store`
24+
/// applies when building presigned URLs for the CRUD operations.
25+
const S3_PATH_ENCODE_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
26+
.remove(b'-')
27+
.remove(b'.')
28+
.remove(b'_')
29+
.remove(b'~')
30+
.remove(b'/');
31+
1632
/// Build the backend URL for an S3 operation.
1733
///
1834
/// Used for multipart operations that go through raw signed HTTP.
@@ -40,6 +56,7 @@ pub fn build_backend_url(
4056
key.push('/');
4157
}
4258
key.push_str(operation.key());
59+
let key = percent_encoding::utf8_percent_encode(&key, S3_PATH_ENCODE_SET).to_string();
4360

4461
let mut url = if bucket_is_empty {
4562
format!("{}/{}", base, key)
@@ -192,6 +209,67 @@ mod tests {
192209
);
193210
}
194211

212+
#[test]
213+
fn key_special_chars_percent_encoded_in_backend_path() {
214+
let config = test_bucket_config();
215+
let op = S3Operation::CreateMultipartUpload {
216+
bucket: "test".into(),
217+
key: "by_country/country_iso=ETH/ETH file.pmtiles".into(),
218+
};
219+
220+
let url = build_backend_url(&config, &op).unwrap();
221+
222+
// `=` and space are outside the AWS strict set and must be encoded;
223+
// `/` stays raw as the segment separator.
224+
assert_eq!(
225+
url,
226+
"https://s3.us-east-1.amazonaws.com/my-backend-bucket/by_country/country_iso%3DETH/ETH%20file.pmtiles?uploads"
227+
);
228+
// Url::parse must not re-encode the result: what sign_request signs
229+
// (`url.path()`) is byte-identical to the wire path.
230+
assert_eq!(
231+
Url::parse(&url).unwrap().path(),
232+
"/my-backend-bucket/by_country/country_iso%3DETH/ETH%20file.pmtiles"
233+
);
234+
}
235+
236+
#[test]
237+
fn key_aws_special_chars_encoded_slash_preserved() {
238+
let config = test_bucket_config();
239+
let op = S3Operation::UploadPart {
240+
bucket: "test".into(),
241+
key: "p!('*'):@+,;$&/f.bin".into(),
242+
upload_id: "uid".into(),
243+
part_number: 1,
244+
};
245+
246+
let url = build_backend_url(&config, &op).unwrap();
247+
248+
let path = url.split_once('?').unwrap().0;
249+
assert_eq!(
250+
path,
251+
"https://s3.us-east-1.amazonaws.com/my-backend-bucket/p%21%28%27%2A%27%29%3A%40%2B%2C%3B%24%26/f.bin"
252+
);
253+
}
254+
255+
#[test]
256+
fn key_with_literal_percent_triplet_is_double_encoded() {
257+
// A key that literally contains `%3D` (already decoded upstream) must
258+
// go out as `%253D`, not be mistaken for an encoded `=`.
259+
let config = test_bucket_config();
260+
let op = S3Operation::CreateMultipartUpload {
261+
bucket: "test".into(),
262+
key: "weird/%3D-literal.txt".into(),
263+
};
264+
265+
let url = build_backend_url(&config, &op).unwrap();
266+
267+
assert_eq!(
268+
url,
269+
"https://s3.us-east-1.amazonaws.com/my-backend-bucket/weird/%253D-literal.txt?uploads"
270+
);
271+
}
272+
195273
#[test]
196274
fn normal_upload_id_works() {
197275
let config = test_bucket_config();

tests/integration/test_integration.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,47 @@ def test_multipart_roundtrip_high_level(self):
330330

331331
client.delete_object(Bucket="private-uploads", Key=key)
332332

333+
@pytest.mark.parametrize(
334+
"key_stem",
335+
[
336+
# Hive-style partition path — data.source.coop#180.
337+
"by_country/country_iso=ETH/ETH",
338+
# The other chars the url crate leaves literal in paths but AWS
339+
# strict-encodes when reconstructing the canonical URI. Chars in
340+
# object_store's PathPart INVALID set (`*`, `%`, `~`, `#`, ...)
341+
# are excluded: the presigned CRUD path rewrites those in the
342+
# logical key (`*` → `%2A`), so a byte-faithful multipart write
343+
# can't be read back through it — a separate limitation of the
344+
# presigned path.
345+
"specials !('):@+,;$&/part=2",
346+
],
347+
)
348+
def test_multipart_special_char_key_roundtrip(self, key_stem):
349+
"""Multipart to a key with chars outside the RFC 3986 unreserved set.
350+
351+
The raw-signed backend URL must percent-encode these or the backend
352+
(AWS/MinIO) re-encodes them server-side and rejects the signature with
353+
SignatureDoesNotMatch at CreateMultipartUpload.
354+
"""
355+
client = static_client()
356+
key = f"{key_stem}-{uuid.uuid4()}.bin"
357+
body = b"special-key-payload!" * (6 * MIB // 20 + 1)
358+
body = body[: 6 * MIB] # 6 MiB → two parts, forces multipart
359+
config = TransferConfig(
360+
multipart_threshold=5 * MIB,
361+
multipart_chunksize=5 * MIB,
362+
max_concurrency=1,
363+
use_threads=False,
364+
)
365+
366+
client.upload_fileobj(BytesIO(body), "private-uploads", key, Config=config)
367+
# Reading back through the presigned GET path proves both paths agree
368+
# on what the key is.
369+
resp = client.get_object(Bucket="private-uploads", Key=key)
370+
assert resp["Body"].read() == body
371+
372+
client.delete_object(Bucket="private-uploads", Key=key)
373+
333374
def test_multipart_low_level_explicit(self):
334375
"""Drive Create/UploadPart/Complete directly and verify the round-trip."""
335376
client = static_client()

0 commit comments

Comments
 (0)