Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions clients/python/src/objectstore_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,14 +354,14 @@ def put(
headers[HEADER_EXPIRATION] = format_expiration(expiration_policy)

if origin:
headers[HEADER_ORIGIN] = origin
headers[HEADER_ORIGIN] = utils.encode_header_value(origin)

if filename is not None:
headers[HEADER_FILENAME] = filename
headers[HEADER_FILENAME] = utils.encode_header_value(filename)

if metadata:
for k, v in metadata.items():
headers[f"{HEADER_META_PREFIX}{k}"] = v
headers[f"{HEADER_META_PREFIX}{k}"] = utils.encode_header_value(v)

if key == "":
key = None
Expand Down Expand Up @@ -619,14 +619,14 @@ def initiate_multipart_upload(
headers[HEADER_EXPIRATION] = format_expiration(expiration_policy)

if origin:
headers[HEADER_ORIGIN] = origin
headers[HEADER_ORIGIN] = utils.encode_header_value(origin)

if filename is not None:
headers[HEADER_FILENAME] = filename
headers[HEADER_FILENAME] = utils.encode_header_value(filename)

if metadata:
for k, v in metadata.items():
headers[f"{HEADER_META_PREFIX}{k}"] = v
headers[f"{HEADER_META_PREFIX}{k}"] = utils.encode_header_value(v)

if key == "":
key = None
Expand Down
8 changes: 5 additions & 3 deletions clients/python/src/objectstore_client/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from datetime import datetime, timedelta
from typing import Literal, TypeVar, cast

from objectstore_client.utils import decode_header_value

Compression = Literal["zstd"] | Literal["none"]

HEADER_EXPIRATION = "x-sn-expiration"
Expand Down Expand Up @@ -108,13 +110,13 @@ def from_headers(cls, headers: Mapping[str, str]) -> Metadata:
elif k == HEADER_TIME_EXPIRES:
time_expires = datetime.fromisoformat(v)
elif k == HEADER_ORIGIN:
origin = v
origin = decode_header_value(v)
elif k == HEADER_FILENAME:
filename = v
filename = decode_header_value(v)
elif k == HEADER_SIZE:
size = int(v)
elif k.startswith(HEADER_META_PREFIX):
custom_metadata[k[len(HEADER_META_PREFIX) :]] = v
custom_metadata[k[len(HEADER_META_PREFIX) :]] = decode_header_value(v)

return Metadata(
content_type=content_type,
Expand Down
33 changes: 32 additions & 1 deletion clients/python/src/objectstore_client/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import io
from typing import IO, Any
from urllib.parse import quote
from urllib.parse import quote, unquote

import filetype # type: ignore[import-untyped]
from zstandard import ZstdCompressionReader
Expand All @@ -22,6 +22,16 @@
_PATH_SAFE = "/:@!$&'()*+,;="
_QUERY_SAFE = _PATH_SAFE + "?"

# Characters left unescaped in a metadata header value: every visible ASCII
# character except "%". Non-ASCII, the C0 controls, and DEL are escaped because a
# header value cannot carry them; "%" is escaped so that a literal percent sign in
# a value can never be confused with an escape sequence when decoding.
#
# This is byte-for-byte identical to the Rust `HEADER_ESCAPE` set (see
# `objectstore-types/src/headers.rs`), so both clients and the server agree on the
# wire form. Values that are already plain ASCII are left untouched.
_HEADER_VALUE_SAFE = "".join(chr(b) for b in range(0x20, 0x7F) if b != ord("%"))


def encode_path(path: str) -> str:
"""Percent-encodes a request path as it will appear on the wire.
Expand All @@ -42,6 +52,27 @@ def encode_query(query: str) -> str:
return quote(query, safe=_QUERY_SAFE)


def encode_header_value(value: str) -> str:
"""Percent-encodes a free-form metadata string for an HTTP header value.

HTTP header values carry only visible ASCII, so anything outside that range has
to be escaped to survive the transport (see :data:`_HEADER_VALUE_SAFE`). This is
the inverse of :func:`decode_header_value`; only the transport representation is
escaped, never the logical string held in :class:`Metadata`.
"""
return quote(value, safe=_HEADER_VALUE_SAFE)


def decode_header_value(value: str) -> str:
"""Decodes a percent-encoded HTTP header value into its logical string.

Inverse of :func:`encode_header_value`. Values without escape sequences are
returned unchanged, so headers written before the encoding existed still read
back as-is.
"""
return unquote(value, errors="strict")


def parse_accept_encoding(header: str) -> list[str]:
"""Parse an Accept-Encoding header value for use in objectstore GET requests.

Expand Down
36 changes: 36 additions & 0 deletions clients/python/tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,42 @@ def test_full_cycle_with_origin(server_url: str) -> None:
assert retrieved.metadata.filename == "report.pdf"


def test_full_cycle_with_unicode_metadata(server_url: str) -> None:
client = Client(
server_url,
token=TestSecretKey.get(),
)
test_usecase = Usecase(
"test-usecase",
expiration_policy=TimeToLive(timedelta(days=1)),
)

session = client.session(test_usecase, org=42, project=1337)

object_key = session.put(
b"test data",
origin="Ünknown-源",
filename="réport-📄.pdf",
metadata={"release": "vérsion-1.0-🚀", "note": "100% done"},
)
assert object_key is not None

retrieved = session.get(object_key)
assert retrieved is not None
assert retrieved.payload.read() == b"test data"
assert retrieved.metadata.origin == "Ünknown-源"
assert retrieved.metadata.filename == "réport-📄.pdf"
assert retrieved.metadata.custom == {
"release": "vérsion-1.0-🚀",
"note": "100% done",
}

head = session.head(object_key)
assert head is not None
assert head.filename == "réport-📄.pdf"
assert head.custom["release"] == "vérsion-1.0-🚀"


def test_full_cycle_uncompressed(server_url: str) -> None:
client = Client(
server_url,
Expand Down
40 changes: 40 additions & 0 deletions clients/python/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import zstandard
from objectstore_client.utils import (
_ZstdCompressionReaderWrapper,
decode_header_value,
encode_header_value,
parse_accept_encoding,
)

Expand Down Expand Up @@ -81,3 +83,41 @@ def test_parse_accept_encoding_empty() -> None:
def test_parse_accept_encoding_q_spacing() -> None:
assert parse_accept_encoding("gzip ; q=0") == []
assert parse_accept_encoding("gzip ; q=1") == ["gzip"]


def test_encode_header_value_escapes_unicode() -> None:
assert encode_header_value("réport-📄.pdf") == "r%C3%A9port-%F0%9F%93%84.pdf"


def test_encode_header_value_escapes_percent() -> None:
assert encode_header_value("100% done") == "100%25 done"


def test_encode_header_value_leaves_visible_ascii_alone() -> None:
# Must stay byte-identical so values that predate the encoding are unaffected.
value = "has\"quote path/to.txt!$&'()*+,;=:@?<>[]{}|^`~#"
assert encode_header_value(value) == value


def test_encode_header_value_escapes_control_characters() -> None:
assert encode_header_value("a\r\nb\tc\x7f") == "a%0D%0Ab%09c%7F"


@pytest.mark.parametrize(
"value",
[
"réport-📄.pdf",
"100% done",
"50%.pdf",
"plain.txt",
"a\r\nb",
"",
],
)
def test_header_value_roundtrip(value: str) -> None:
assert decode_header_value(encode_header_value(value)) == value


def test_decode_header_value_rejects_invalid_utf8() -> None:
with pytest.raises(UnicodeDecodeError):
decode_header_value("%FF.pdf")
1 change: 0 additions & 1 deletion clients/rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ infer = { workspace = true }
jsonwebtoken = { workspace = true }
multer = { workspace = true }
objectstore-types = { workspace = true }
percent-encoding = { workspace = true }
# Pinned below workspace version for MSRV compatibility
reqwest = { version = "0.13.1", default-features = false, features = ["charset", "http2", "system-proxy", "json", "stream", "multipart"] }
sentry-core = { version = ">=0.41", default-features = false, features = ["client"] }
Expand Down
18 changes: 3 additions & 15 deletions clients/rust/src/many.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ use std::task::{Context, Poll};

use futures_util::{Stream, StreamExt as _};
use multer::Field;
use objectstore_types::headers;
use objectstore_types::metadata::{Compression, Metadata};
use percent_encoding::NON_ALPHANUMERIC;
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
use reqwest::multipart::Part;

Expand Down Expand Up @@ -180,12 +180,9 @@ fn operation_headers(operation: &str, key: Option<&str>) -> HeaderMap {
HeaderValue::from_str(operation).expect("operation kind is always a valid header value"),
);
if let Some(key) = key {
let encoded =
percent_encoding::percent_encode(key.as_bytes(), NON_ALPHANUMERIC).to_string();
headers.insert(
HeaderName::from_static(HEADER_BATCH_OPERATION_KEY),
HeaderValue::try_from(encoded)
.expect("percent-encoded string is always a valid header value"),
headers::encode_header_value(key),
);
}
headers
Expand Down Expand Up @@ -338,16 +335,7 @@ impl OperationResult {
// Prioritize the server-provided key, fall back to the one from context.
let key = headers
.remove(HEADER_BATCH_OPERATION_KEY)
.and_then(|v| {
v.to_str()
.ok()
.and_then(|encoded| {
percent_encoding::percent_decode_str(encoded)
.decode_utf8()
.ok()
})
.map(|s| s.into_owned())
})
.and_then(|v| headers::decode_header_value(&v).ok())
.or_else(|| ctx.key().map(str::to_owned));

let body = field.bytes().await?;
Expand Down
48 changes: 48 additions & 0 deletions clients/rust/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,54 @@ async fn stores_under_given_key() {
assert_eq!(stored_id, "test-key123!!");
}

#[tokio::test]
async fn roundtrips_unicode_metadata() {
let server = test_server().await;

let client = Client::builder(server.url("/"))
.token(test_token_generator())
.build()
.unwrap();
let usecase = Usecase::new("usecase");
let session = client.session(usecase.for_project(12345, 1337)).unwrap();

let filename = "réport-📄.pdf";
let release = "vérsion-1.0-🚀";

let stored_id = session
.put("oh hai!")
.filename(filename)
.origin("Ünknown-源")
.append_metadata("release", release)
.append_metadata("note", "100% done")
.key("unicode-metadata")
.send()
.await
.unwrap()
.key;

let response = session.get(&stored_id).send().await.unwrap().unwrap();
let metadata = response.metadata.clone();
assert_eq!(metadata.filename.as_deref(), Some(filename));
assert_eq!(metadata.origin.as_deref(), Some("Ünknown-源"));
assert_eq!(
metadata.custom.get("release").map(String::as_str),
Some(release)
);
assert_eq!(
metadata.custom.get("note").map(String::as_str),
Some("100% done"),
);
assert_eq!(response.payload().await.unwrap(), "oh hai!");

let metadata = session.head(&stored_id).send().await.unwrap().unwrap();
assert_eq!(metadata.filename.as_deref(), Some(filename));
assert_eq!(
metadata.custom.get("release").map(String::as_str),
Some(release)
);
}

#[tokio::test]
async fn stores_structured_keys() {
let server = test_server().await;
Expand Down
1 change: 0 additions & 1 deletion objectstore-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ objectstore-service = { workspace = true }
objectstore-types = { workspace = true }
papaya = { workspace = true }
pin-project-lite = { workspace = true }
percent-encoding = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true, features = ["charset", "http2", "system-proxy", "native-tls-no-alpn"] }
rustls = { workspace = true }
Expand Down
7 changes: 4 additions & 3 deletions objectstore-server/src/batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@
/// Clients use this to match each response part back to its corresponding request operation.
pub const HEADER_BATCH_OPERATION_INDEX: &str = "x-sn-batch-operation-index";

/// Base64-encoded object key for this batch operation, required on request parts.
/// Object key for this batch operation, required on request parts.
///
/// The key is base64-encoded to allow arbitrary byte sequences in object keys without conflicting
/// with HTTP header encoding restrictions.
/// The key is escaped with [`encode_header_value`](objectstore_types::headers::encode_header_value)
/// so that keys containing characters a header value cannot carry — non-ASCII in particular — still
/// survive the transport.
pub const HEADER_BATCH_OPERATION_KEY: &str = "x-sn-batch-operation-key";

/// Operation kind for this batch part: `"get"`, `"insert"`, or `"delete"`.
Expand Down
7 changes: 2 additions & 5 deletions objectstore-server/src/endpoints/batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use http::header::CONTENT_TYPE;
use http::{HeaderMap, HeaderValue, StatusCode};
use objectstore_service::id::{ObjectContext, ObjectKey};
use objectstore_service::streaming::{OpResponse, Operation};
use percent_encoding::NON_ALPHANUMERIC;
use objectstore_types::headers;

use crate::auth::AuthAwareService;
use crate::batch::{
Expand Down Expand Up @@ -262,12 +262,9 @@ fn insert_index_header(headers: &mut HeaderMap, idx: usize) {
}

fn insert_key_header(headers: &mut HeaderMap, key: &ObjectKey) {
let encoded = percent_encoding::percent_encode(key.as_bytes(), NON_ALPHANUMERIC).to_string();
headers.insert(
HEADER_BATCH_OPERATION_KEY,
encoded
.parse()
.expect("percent-encoded string is always a valid header value"),
headers::encode_header_value(key),
);
}

Expand Down
Loading
Loading