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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,12 @@ jobs:
with:
toolchain: stable
targets: wasm32-unknown-unknown
components: clippy
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- run: cargo check -p multistore-cf-workers --target wasm32-unknown-unknown
# The Workers crates are excluded from `default-members`, so the native
# clippy job never sees them. Lint them on their real target.
- run: cargo clippy -p multistore-cf-workers -p multistore-cf-workers-example --target wasm32-unknown-unknown -- -D warnings

audit:
name: Audit
Expand Down
10 changes: 9 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,20 @@ cargo check
# Check (WASM)
cargo check -p multistore-cf-workers --target wasm32-unknown-unknown

# Lint (WASM) — the Workers crates are not in `default-members`, so lint them explicitly
cargo clippy -p multistore-cf-workers -p multistore-cf-workers-example --target wasm32-unknown-unknown -- -D warnings

# Unit + doc tests
cargo test

# Integration tests (RustFS via docker compose behind wrangler dev; mirrors CI)
make test-integration
```
### Lints and MSRV

`Cargo.lock` is committed and CI runs `cargo check --locked`, so commit lockfile changes alongside any dependency change. The release PR bumps the workspace members' entries in `Cargo.lock` itself (see `release-please-config.json`), so a release never leaves the lockfile stale.
Lint levels live in `[workspace.lints]` in the root `Cargo.toml`; every crate opts in with `[lints] workspace = true`. Beyond clippy's defaults the workspace warns on `missing_docs` (document every `pub` item) and `clippy::unwrap_used` (return an error, or use `expect` with a message that states the invariant). Tests are exempt from the unwrap lint via `clippy.toml`. CI runs clippy with `-D warnings`, so a new warning fails the build.

The minimum supported Rust version is declared as `rust-version` in the root `Cargo.toml`. It is set by the dependency tree; raise it only when a dependency forces it.

## Release Process

Expand All @@ -50,6 +56,8 @@ When that PR is merged, release-please creates a GitHub release, which triggers
3. Dry-runs all crates to catch packaging errors
4. Publishes all crates to crates.io using OIDC trusted publishing

`Cargo.lock` is committed and CI runs `cargo check --locked`, so commit lockfile changes alongside any dependency change. The release PR bumps the workspace members' entries in `Cargo.lock` itself (see `release-please-config.json`), so a release never leaves the lockfile stale.

### Pre-releases

To publish a pre-release (e.g. `0.2.0-alpha.1`):
Expand Down
11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ resolver = "2"
version = "0.7.2"
edition = "2021"
license = "MIT"
# Floor set by the dependency tree (crc-fast via object_store); verified with `cargo +1.89 check`.
rust-version = "1.89"

# Opted into per crate with `[lints] workspace = true`.
[workspace.lints.rust]
missing_docs = "warn"

[workspace.lints.clippy]
# Production code returns errors; `expect` with a message documents a real invariant.
# Tests are exempt via `allow-unwrap-in-tests` in clippy.toml.
unwrap_used = "warn"

[workspace.dependencies]
# Core
Expand Down
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ fmt-fix:

clippy:
cargo clippy -- -D warnings
clippy-wasm:
cargo clippy -p multistore-cf-workers -p multistore-cf-workers-example --target wasm32-unknown-unknown -- -D warnings
clippy-fix:
cargo clippy --fix --allow-dirty --allow-staged

Expand All @@ -35,7 +37,7 @@ run-server:
run-workers:
npx wrangler dev --cwd examples/cf-workers

ci-fast: fmt clippy check-wasm
ci-fast: fmt clippy clippy-wasm check-wasm
ci: ci-fast test

docs:
Expand Down
1 change: 1 addition & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
allow-unwrap-in-tests = true
3 changes: 3 additions & 0 deletions crates/cf-workers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,6 @@ features = ["wasm_js"]
[dependencies.ring]
version = "0.17"
features = ["wasm32_unknown_unknown_js"]

[lints]
workspace = true
2 changes: 1 addition & 1 deletion crates/cf-workers/src/headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ impl WsHeaders {

impl From<&HeaderMap> for WsHeaders {
fn from(headers: &HeaderMap) -> Self {
let ws = web_sys::Headers::new().unwrap();
let ws = web_sys::Headers::new().expect("Headers::new() cannot fail");
for (key, value) in headers.iter() {
if let Ok(v) = value.to_str() {
let _ = ws.set(key.as_str(), v);
Expand Down
9 changes: 4 additions & 5 deletions crates/cf-workers/src/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,12 @@ pub(crate) fn response_from_proxy_result(result: ProxyResult) -> web_sys::Respon
resp_init.set_headers(&WsHeaders::from(&result.headers).into_inner().into());

match result.body {
ProxyResponseBody::Empty => {
web_sys::Response::new_with_opt_str_and_init(None, &resp_init).unwrap()
}
ProxyResponseBody::Empty => web_sys::Response::new_with_opt_str_and_init(None, &resp_init)
.unwrap_or_else(|_| error_response(500, "Internal Server Error")),
ProxyResponseBody::Bytes(bytes) => {
let uint8 = js_sys::Uint8Array::from(bytes.as_ref());
web_sys::Response::new_with_opt_buffer_source_and_init(Some(&uint8), &resp_init)
.unwrap()
.unwrap_or_else(|_| error_response(500, "Internal Server Error"))
}
}
}
Expand Down Expand Up @@ -69,7 +68,7 @@ pub(crate) fn error_response(status: u16, message: &str) -> web_sys::Response {
let init = web_sys::ResponseInit::new();
init.set_status(status);
web_sys::Response::new_with_opt_str_and_init(Some(message), &init)
.unwrap_or_else(|_| web_sys::Response::new().unwrap())
.unwrap_or_else(|_| web_sys::Response::new().expect("Response::new() cannot fail"))
}

/// Extension trait for converting a [`GatewayResponse`] into a `web_sys::Response`.
Expand Down
3 changes: 3 additions & 0 deletions crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ object_store.workspace = true
reqwest.workspace = true
futures.workspace = true
matchit.workspace = true

[lints]
workspace = true
6 changes: 5 additions & 1 deletion crates/core/src/api/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,12 +273,16 @@ pub fn validate_key(key: &str) -> Result<(), ProxyError> {
Ok(())
}

/// How the bucket name was addressed in the incoming request.
#[derive(Debug, Clone)]
pub enum HostStyle {
/// Path-style: `/{bucket}/{key}`
Path,
/// Virtual-hosted-style: bucket extracted from Host header.
VirtualHosted { bucket: String },
VirtualHosted {
/// The bucket name taken from the `Host` header.
bucket: String,
},
}

fn parse_path_style(path: &str) -> Result<(String, String), ProxyError> {
Expand Down
15 changes: 15 additions & 0 deletions crates/core/src/api/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@ pub use crate::types::BucketOwner;
#[derive(Debug, Serialize)]
#[serde(rename = "Error")]
pub struct ErrorResponse {
/// S3 error code (e.g. `NoSuchKey`).
#[serde(rename = "Code")]
pub code: String,
/// Human-readable description of the error.
#[serde(rename = "Message")]
pub message: String,
/// The request path the error relates to.
#[serde(rename = "Resource")]
pub resource: String,
/// The proxy request identifier, for correlating with logs.
#[serde(rename = "RequestId")]
pub request_id: String,
}
Expand Down Expand Up @@ -69,10 +73,13 @@ impl ErrorResponse {
#[derive(Debug, Serialize)]
#[serde(rename = "InitiateMultipartUploadResult")]
pub struct InitiateMultipartUploadResult {
/// The virtual bucket the upload targets.
#[serde(rename = "Bucket")]
pub bucket: String,
/// The object key being uploaded.
#[serde(rename = "Key")]
pub key: String,
/// Backend-issued identifier for the multipart upload.
#[serde(rename = "UploadId")]
pub upload_id: String,
}
Expand All @@ -91,12 +98,16 @@ impl InitiateMultipartUploadResult {
#[derive(Debug, Serialize)]
#[serde(rename = "CompleteMultipartUploadResult")]
pub struct CompleteMultipartUploadResult {
/// URL of the completed object.
#[serde(rename = "Location")]
pub location: String,
/// The virtual bucket the object was written to.
#[serde(rename = "Bucket")]
pub bucket: String,
/// The object key.
#[serde(rename = "Key")]
pub key: String,
/// ETag of the assembled object.
#[serde(rename = "ETag")]
pub etag: String,
}
Expand All @@ -115,6 +126,7 @@ impl CompleteMultipartUploadResult {
#[derive(Debug, serde::Deserialize)]
#[serde(rename = "CompleteMultipartUpload")]
pub struct CompleteMultipartUploadRequest {
/// The uploaded parts, in ascending part-number order.
#[serde(rename = "Part")]
pub parts: Vec<CompletePart>,
}
Expand All @@ -134,15 +146,18 @@ pub struct CompletePart {
#[derive(Debug, Serialize)]
#[serde(rename = "ListAllMyBucketsResult")]
pub struct ListAllMyBucketsResult {
/// The owner reported for every listed bucket.
#[serde(rename = "Owner")]
pub owner: BucketOwner,
/// The buckets visible to the caller.
#[serde(rename = "Buckets")]
pub buckets: BucketList,
}

/// Wrapper for the `<Buckets>` element in a ListAllMyBucketsResult response.
#[derive(Debug, Serialize)]
pub struct BucketList {
/// One `<Bucket>` element per virtual bucket.
#[serde(rename = "Bucket")]
pub buckets: Vec<BucketEntry>,
}
Expand Down
4 changes: 4 additions & 0 deletions crates/core/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ use crate::types::TemporaryCredentials;
/// Implementations handle token decryption/lookup. The core proxy calls this
/// during identity resolution without knowing the token format.
pub trait TemporaryCredentialResolver: MaybeSend + MaybeSync {
/// Resolve a session token into its temporary credentials.
///
/// Returns `Ok(None)` when the token is well-formed but unknown or
/// expired, and an error when it cannot be decoded at all.
fn resolve(&self, token: &str) -> Result<Option<TemporaryCredentials>, ProxyError>;
}

Expand Down
1 change: 1 addition & 0 deletions crates/core/src/auth/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ impl crate::registry::CredentialRegistry for MockConfig {
// ── Test signing helper ───────────────────────────────────────────

/// Build a valid SigV4 Authorization header value for testing.
#[allow(clippy::too_many_arguments)] // test helper mirrors the SigV4 parameter list
fn sign_request(
method: &http::Method,
uri_path: &str,
Expand Down
6 changes: 6 additions & 0 deletions crates/core/src/backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,11 @@ pub trait ProxyBackend: Clone + MaybeSend + MaybeSync + 'static {

/// Response from a raw HTTP request to a backend.
pub struct RawResponse {
/// HTTP status code from the backend.
pub status: u16,
/// Response headers from the backend.
pub headers: HeaderMap,
/// The fully buffered response body.
pub body: Bytes,
}

Expand All @@ -113,9 +116,12 @@ pub struct ForwardResponse<S> {
/// connector), then call [`build`](Self::build) or
/// [`build_signer`](Self::build_signer).
pub enum StoreBuilder {
/// Amazon S3 or an S3-compatible store.
S3(AmazonS3Builder),
/// Azure Blob Storage.
#[cfg(feature = "azure")]
Azure(MicrosoftAzureBuilder),
/// Google Cloud Storage.
#[cfg(feature = "gcp")]
Gcs(GoogleCloudStorageBuilder),
}
Expand Down
4 changes: 2 additions & 2 deletions crates/core/src/backend/multipart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//! CompleteMultipartUpload, AbortMultipartUpload) and batch delete
//! (DeleteObjects).

use crate::backend::request_signer::S3RequestSigner;
use crate::backend::request_signer::{header_value, S3RequestSigner};
use crate::error::ProxyError;
use crate::types::{BucketConfig, S3Operation};
use http::{HeaderMap, Method};
Expand Down Expand Up @@ -137,7 +137,7 @@ pub(crate) fn sign_s3_request(
} else {
host.to_string()
};
headers.insert("host", host_header.parse().unwrap());
headers.insert("host", header_value("host", &host_header)?);
}

Ok(())
Expand Down
38 changes: 31 additions & 7 deletions crates/core/src/backend/request_signer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,18 @@

use crate::auth::sigv4::{canonicalize_query_string, hmac_sha256};
use crate::error::ProxyError;
use http::HeaderMap;
use http::{HeaderMap, HeaderValue};

/// Build a [`HeaderValue`] from a string produced at runtime.
///
/// Header values must be visible ASCII; anything else (a control character in
/// a configured session token, say) is an error to report, not a reason to
/// panic mid-request. The value itself is deliberately kept out of the
/// message because it may be a credential.
pub(crate) fn header_value(name: &str, value: &str) -> Result<HeaderValue, ProxyError> {
HeaderValue::from_str(value)
.map_err(|_| ProxyError::Internal(format!("invalid value for `{name}` header")))
}

/// Signs outbound HTTP requests using AWS SigV4.
pub struct S3RequestSigner {
Expand Down Expand Up @@ -60,11 +71,17 @@ impl S3RequestSigner {
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();

// Set required headers
headers.insert("x-amz-date", amz_date.parse().unwrap());
headers.insert("x-amz-content-sha256", payload_hash.parse().unwrap());
headers.insert("x-amz-date", header_value("x-amz-date", &amz_date)?);
headers.insert(
"x-amz-content-sha256",
header_value("x-amz-content-sha256", payload_hash)?,
);

if let Some(token) = &self.session_token {
headers.insert("x-amz-security-token", token.parse().unwrap());
headers.insert(
"x-amz-security-token",
header_value("x-amz-security-token", token)?,
);
}

let host = url
Expand All @@ -75,7 +92,7 @@ impl S3RequestSigner {
} else {
host.to_string()
};
headers.insert("host", host_header.parse().unwrap());
headers.insert("host", header_value("host", &host_header)?);

// Canonical request. The query must be in SigV4 canonical form — every
// parameter as `key=value` (value-less flags like `?uploads`/`?delete`
Expand All @@ -89,7 +106,11 @@ impl S3RequestSigner {
let canonical_headers: String = signed_header_names
.iter()
.map(|k| {
let v = headers.get(*k).unwrap().to_str().unwrap_or("").trim();
let v = headers
.get(*k)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.trim();
format!("{}:{}\n", k, v)
})
.collect();
Expand Down Expand Up @@ -137,7 +158,10 @@ impl S3RequestSigner {
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
self.access_key_id, credential_scope, signed_headers, signature
);
headers.insert("authorization", auth_header.parse().unwrap());
headers.insert(
"authorization",
header_value("authorization", &auth_header)?,
);

Ok(())
}
Expand Down
4 changes: 4 additions & 0 deletions crates/core/src/maybe_send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,27 @@

// --- Native targets: MaybeSend = Send, MaybeSync = Sync ---

/// `Send` on native targets, no bound on wasm32.
#[cfg(not(target_arch = "wasm32"))]
pub trait MaybeSend: Send {}
#[cfg(not(target_arch = "wasm32"))]
impl<T: Send> MaybeSend for T {}

/// `Sync` on native targets, no bound on wasm32.
#[cfg(not(target_arch = "wasm32"))]
pub trait MaybeSync: Sync {}
#[cfg(not(target_arch = "wasm32"))]
impl<T: Sync> MaybeSync for T {}

// --- WASM targets: MaybeSend and MaybeSync are no-ops ---

/// `Send` on native targets, no bound on wasm32.
#[cfg(target_arch = "wasm32")]
pub trait MaybeSend {}
#[cfg(target_arch = "wasm32")]
impl<T> MaybeSend for T {}

/// `Sync` on native targets, no bound on wasm32.
#[cfg(target_arch = "wasm32")]
pub trait MaybeSync {}
#[cfg(target_arch = "wasm32")]
Expand Down
Loading
Loading