|
| 1 | +//! Cross-path key-encoding contract. |
| 2 | +//! |
| 3 | +//! The proxy builds backend request paths in three places: object_store |
| 4 | +//! presigned URLs (authenticated CRUD), `UnsignedUrlSigner` (anonymous |
| 5 | +//! CRUD), and `build_backend_url` (raw-signed multipart and batch delete). |
| 6 | +//! A backend percent-decodes each wire path to decide which object a |
| 7 | +//! request addresses, so every builder must emit a path that decodes to |
| 8 | +//! the same logical key — and the builders must agree byte-for-byte, or |
| 9 | +//! the same logical key addresses different backend objects depending on |
| 10 | +//! upload size, client payload mode, or bucket auth (see #105, #108). |
| 11 | +//! |
| 12 | +//! If an object_store upgrade changes its path encoding, these tests are |
| 13 | +//! the loud alarm. |
| 14 | +
|
| 15 | +use std::collections::HashMap; |
| 16 | +use std::time::Duration; |
| 17 | + |
| 18 | +use multistore::backend::multipart::build_backend_url; |
| 19 | +use multistore::backend::url_signer::build_signer; |
| 20 | +use multistore::types::{BucketConfig, S3Operation}; |
| 21 | +use object_store::path::Path; |
| 22 | +use percent_encoding::percent_decode_str; |
| 23 | + |
| 24 | +/// Corpus of logical keys: the plain case, every character class the url |
| 25 | +/// crate leaves literal in paths, and the characters object_store's |
| 26 | +/// `Path::from` used to rewrite. |
| 27 | +const KEYS: &[&str] = &[ |
| 28 | + "plain/file.bin", |
| 29 | + "by_country/country_iso=ETH/ETH.pmtiles", |
| 30 | + "spaces in/every segment.txt", |
| 31 | + "specials !('):@+,;$&.bin", |
| 32 | + "unicode/café/naïve.txt", |
| 33 | + "report*.pdf", |
| 34 | + "100%.txt", |
| 35 | + "tilde~hash#pipe|.bin", |
| 36 | + "brackets[1]{2}.bin", |
| 37 | + "literal-%3D-triplet.txt", |
| 38 | +]; |
| 39 | + |
| 40 | +fn bucket_config(with_creds: bool) -> BucketConfig { |
| 41 | + let mut backend_options: HashMap<String, String> = HashMap::new(); |
| 42 | + backend_options.insert( |
| 43 | + "endpoint".into(), |
| 44 | + "https://s3.us-east-1.amazonaws.com".into(), |
| 45 | + ); |
| 46 | + backend_options.insert("bucket_name".into(), "backend-bucket".into()); |
| 47 | + backend_options.insert("region".into(), "us-east-1".into()); |
| 48 | + if with_creds { |
| 49 | + backend_options.insert("access_key_id".into(), "AKIAIOSFODNN7EXAMPLE".into()); |
| 50 | + backend_options.insert( |
| 51 | + "secret_access_key".into(), |
| 52 | + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into(), |
| 53 | + ); |
| 54 | + } |
| 55 | + BucketConfig { |
| 56 | + name: "test".into(), |
| 57 | + backend_type: "s3".into(), |
| 58 | + backend_prefix: None, |
| 59 | + anonymous_access: !with_creds, |
| 60 | + allowed_roles: vec![], |
| 61 | + backend_options, |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +/// Wire path emitted by the presigned CRUD builder (object_store signer |
| 66 | +/// for authenticated buckets, `UnsignedUrlSigner` for anonymous ones). |
| 67 | +/// `Path::parse` mirrors `build_object_path`. |
| 68 | +fn presigned_path(config: &BucketConfig, key: &str) -> String { |
| 69 | + let signer = build_signer(config).unwrap(); |
| 70 | + let path = Path::parse(key).unwrap(); |
| 71 | + let url = futures::executor::block_on(signer.signed_url( |
| 72 | + http::Method::GET, |
| 73 | + &path, |
| 74 | + Duration::from_secs(60), |
| 75 | + )) |
| 76 | + .unwrap(); |
| 77 | + url.path().to_string() |
| 78 | +} |
| 79 | + |
| 80 | +/// Wire path emitted by the raw-signed builder (multipart, batch delete). |
| 81 | +fn raw_signed_path(config: &BucketConfig, key: &str) -> String { |
| 82 | + let op = S3Operation::CreateMultipartUpload { |
| 83 | + bucket: "test".into(), |
| 84 | + key: key.into(), |
| 85 | + }; |
| 86 | + let url = build_backend_url(config, &op).unwrap(); |
| 87 | + url::Url::parse(&url).unwrap().path().to_string() |
| 88 | +} |
| 89 | + |
| 90 | +/// The two SigV4 builders (authenticated presign, raw-signed) and the |
| 91 | +/// anonymous builder must produce byte-identical wire paths for the same |
| 92 | +/// logical key. |
| 93 | +#[test] |
| 94 | +fn all_builders_agree_byte_for_byte() { |
| 95 | + let authed = bucket_config(true); |
| 96 | + let anon = bucket_config(false); |
| 97 | + for key in KEYS { |
| 98 | + let presigned = presigned_path(&authed, key); |
| 99 | + let raw = raw_signed_path(&authed, key); |
| 100 | + let unsigned = presigned_path(&anon, key); |
| 101 | + assert_eq!(presigned, raw, "presigned vs raw-signed for key {key:?}"); |
| 102 | + assert_eq!( |
| 103 | + presigned, unsigned, |
| 104 | + "presigned vs anonymous for key {key:?}" |
| 105 | + ); |
| 106 | + } |
| 107 | +} |
| 108 | + |
| 109 | +/// Every wire path must percent-decode back to exactly the logical key — |
| 110 | +/// that decode is what the backend uses to pick the object. One builder |
| 111 | +/// suffices: `all_builders_agree_byte_for_byte` pins the others to it. |
| 112 | +#[test] |
| 113 | +fn wire_paths_decode_to_the_logical_key() { |
| 114 | + let config = bucket_config(true); |
| 115 | + for key in KEYS { |
| 116 | + let path = presigned_path(&config, key); |
| 117 | + let decoded = percent_decode_str(&path).decode_utf8().unwrap(); |
| 118 | + assert_eq!(decoded, format!("/backend-bucket/{key}"), "key {key:?}"); |
| 119 | + } |
| 120 | +} |
0 commit comments