Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
08b5d5b
wip
jjbayer Jul 9, 2026
ce6c726
fix test
jjbayer Jul 10, 2026
d235437
test: show parts are compressed
jjbayer Jul 10, 2026
5bf53ea
clean
jjbayer Jul 10, 2026
e23db71
wip: disable compression
jjbayer Jul 10, 2026
5024e87
lint
jjbayer Jul 10, 2026
67adbf0
wip: compression
jjbayer Jul 12, 2026
a0a080d
compile
jjbayer Jul 13, 2026
16b9503
wip
jjbayer Jul 13, 2026
68439a9
validate offset
jjbayer Jul 14, 2026
4c07979
fix
jjbayer Jul 14, 2026
e2f0ee6
Merge branch 'master' into feat/tus-offset
jjbayer Jul 15, 2026
2d57528
Merge remote-tracking branch 'origin/master' into feat/tus-offset
jjbayer Jul 16, 2026
8ee8867
Merge remote-tracking branch 'origin/feat/tus-offset' into feat/tus-o…
jjbayer Jul 16, 2026
a713d02
fix
jjbayer Jul 16, 2026
c83bc3b
feat(upload): add finish message
jjbayer Jul 16, 2026
b75405f
fix(upload): finalize multipart streams
jjbayer Jul 17, 2026
3b95811
clarify
jjbayer Jul 17, 2026
cd59426
test
jjbayer Jul 17, 2026
19a5bd4
lint
jjbayer Jul 17, 2026
46f1abe
resolve todo
jjbayer Jul 17, 2026
1adf88d
test
jjbayer Jul 17, 2026
dd42199
test
jjbayer Jul 17, 2026
187f733
wip: fix
jjbayer Jul 17, 2026
c100fd4
fix: finish
jjbayer Jul 17, 2026
edea406
lint
jjbayer Jul 17, 2026
c449e5f
fix: finish only multipart
jjbayer Jul 17, 2026
476cf0c
Merge remote-tracking branch 'origin/master' into feat/tus-offset
jjbayer Jul 17, 2026
0bae8de
changelog
jjbayer Jul 17, 2026
3fcff0c
unflake
jjbayer Jul 17, 2026
687d494
review
jjbayer Jul 17, 2026
e3b6dd3
fixes
jjbayer Jul 17, 2026
c665f6b
Revert "fixes"
jjbayer Jul 17, 2026
b97afdc
no finish
jjbayer Jul 18, 2026
71a6eb2
no multipart for Defer-Length-1
jjbayer Jul 18, 2026
f4d1089
reject offset > 0
jjbayer Jul 18, 2026
9c60de6
fix
jjbayer Jul 18, 2026
ace745a
more fixes
jjbayer Jul 18, 2026
941913c
error handling
jjbayer Jul 18, 2026
84c16db
fix: save trailing data
jjbayer Jul 18, 2026
1aad8f4
fix
jjbayer Jul 18, 2026
533f818
fixes by claude
jjbayer Jul 18, 2026
99615fb
test
jjbayer Jul 18, 2026
57ebe51
cleanup
jjbayer Jul 18, 2026
9bf8390
fix: return an error for unaligned bytes
jjbayer Jul 18, 2026
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
- No longer write the deprecated `sentry.transaction` and `db.system` attributes. ([#6237](https://github.com/getsentry/relay/pull/6237), [#6238](https://github.com/getsentry/relay/pull/6238))
- Allow additional exceptions in minidump and apple crash report events. ([#6241](https://github.com/getsentry/relay/pull/6241))

**Internal**:

- Support resumable uploads (feature-flagged). ([#6203](https://github.com/getsentry/relay/pull/6203))

## 26.7.0

**Features**:
Expand Down
8 changes: 6 additions & 2 deletions relay-server/src/endpoints/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use crate::service::ServiceState;
use crate::services::buffer::{ProjectKeyPair, PushError};
use crate::services::outcome::{DiscardItemType, DiscardReason, Outcome};
use crate::services::processor::{BucketSource, MetricData, ProcessMetrics};
use crate::services::upload::{Create, ProjectContext, Stream, Upload};
use crate::services::upload::{Create, ProjectContext, Stream, StreamResult, Upload};
use crate::statsd::{RelayCounters, RelayDistributions};
use crate::utils::{
self, ApiErrorResponse, BoundedStream, FormDataIter, MeteredStream, find_error_source,
Expand Down Expand Up @@ -613,12 +613,16 @@ where
received: Utc::now(),
project,
location,
offset: 0,
stream,
})
.await
.ok()?;

let location = result
let StreamResult {
location,
offset: _,
} = result
.inspect_err(|e| {
relay_log::warn!(
error = e as &dyn std::error::Error,
Expand Down
69 changes: 44 additions & 25 deletions relay-server/src/endpoints/upload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ use crate::services::objectstore;
use crate::services::projects::cache::Project;
use crate::services::projects::project::ProjectState;
use crate::services::upload::{
self, ByteStream, Final, LocationQueryParams, ProjectContext, Provisional, SignedLocation,
UploadLength,
self, ByteStream, LocationQueryParams, ProjectContext, Provisional, SignedLocation,
StreamResult, UploadLength,
};
use crate::services::upstream::UpstreamRequestError;
use crate::statsd::RelayCounters;
Expand All @@ -56,6 +56,9 @@ enum Error {
#[error("TUS protocol error: {0}")]
Tus(#[from] tus::Error),

#[error("Invalid Upload-Offset {0} for Upload-Length {1}")]
InvalidOffset(usize, usize),

#[error("request error: {0}")]
Request(#[from] BadStoreRequest),

Expand All @@ -80,6 +83,7 @@ impl IntoResponse for Error {

let status = match self {
Error::Tus(_) => StatusCode::BAD_REQUEST,
Error::InvalidOffset(_, _) => StatusCode::BAD_REQUEST,
Error::Request(error) => return error.into_response(),
Error::SendError(_) => StatusCode::INTERNAL_SERVER_ERROR,
Error::Upload(error) => match error {
Expand All @@ -100,19 +104,30 @@ impl IntoResponse for Error {
Some(status) => status,
None => StatusCode::INTERNAL_SERVER_ERROR,
},
upload::Error::InvalidLocation(_) | upload::Error::SigningFailed => {
upload::Error::InvalidFromUpstream { .. } | upload::Error::SigningFailed => {
StatusCode::INTERNAL_SERVER_ERROR
}
upload::Error::InvalidFromClient { .. } => StatusCode::BAD_REQUEST,
#[cfg(feature = "processing")]
upload::Error::InvalidUploadId(_) => StatusCode::BAD_REQUEST,
upload::Error::OffsetWithoutLength => StatusCode::BAD_REQUEST,
upload::Error::SerializeFailed(_) => StatusCode::INTERNAL_SERVER_ERROR,
upload::Error::InvalidSignature(_) => StatusCode::BAD_REQUEST,
upload::Error::ObjectstoreServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
#[cfg(feature = "processing")]
upload::Error::Objectstore(service_error) => match service_error.kind {
objectstore::ErrorKind::InvalidScoping => StatusCode::INTERNAL_SERVER_ERROR,
objectstore::ErrorKind::InvalidOffset { .. } => StatusCode::BAD_REQUEST,
objectstore::ErrorKind::OffsetWithoutUploadId => StatusCode::BAD_REQUEST,
objectstore::ErrorKind::InvalidUploadLength { .. } => StatusCode::BAD_REQUEST,
objectstore::ErrorKind::RequestTooSmall { .. } => StatusCode::BAD_REQUEST,
objectstore::ErrorKind::UnalignedBody { .. } => StatusCode::BAD_REQUEST,
objectstore::ErrorKind::UnknownKey { .. } => StatusCode::NOT_FOUND,
objectstore::ErrorKind::Timeout(_) => StatusCode::GATEWAY_TIMEOUT,
objectstore::ErrorKind::LoadShed => StatusCode::SERVICE_UNAVAILABLE,
objectstore::ErrorKind::CompressionFailed(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
objectstore::ErrorKind::UploadFailed(error) => match error {
objectstore_client::Error::Io(error) if is_upload_length_error(&error) => {
StatusCode::BAD_REQUEST
Expand Down Expand Up @@ -184,10 +199,12 @@ async fn handle_post(
StatusCode::SERVICE_UNAVAILABLE
})?;

let multipart = match project.state() {
ProjectState::Enabled(p) => p.has_feature(Feature::UploadMultipart),
_ => false,
};
// We don't enable multipart for `Upload-Defer-Length: 1`, or if the feature flag is disabled.
let multipart = headers.upload_length.is_some()
&& match project.state() {
ProjectState::Enabled(p) => p.has_feature(Feature::UploadMultipart),
_ => false,
};

relay_log::trace!("Checking request");
let project_context = validate_and_limit(&state, meta, &headers, project).await?;
Expand Down Expand Up @@ -224,7 +241,7 @@ async fn handle_patch(
check_kill_switch(&state)?;

relay_log::trace!("Validating headers");
tus::validate_patch_headers(&headers).map_err(Error::from)?;
let offset = tus::validate_patch_headers(&headers).map_err(Error::from)?;

let location = SignedLocation::from_parts(
project_id,
Expand Down Expand Up @@ -259,35 +276,35 @@ async fn handle_patch(
let stream = MeteredStream::new(stream, "upload");

let (lower_bound, upper_bound) = match upload_length.value() {
None => (1, config.max_upload_size()),
Some(u) => (u, u),
None => (0, config.max_upload_size()),
Some(u) => {
let remaining_bytes = u
.checked_sub(offset)
.ok_or(Error::InvalidOffset(offset, u))?;
(0, remaining_bytes)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signed length below Upload-Length

Medium Severity

When Upload-Length is known, the PATCH handler sets the stream lower bound to 0 instead of requiring the remaining byte count. A client can send fewer bytes than declared, still receive 204, and the upload service signs a Final location whose length is the bytes actually streamed (oneshot) rather than the announced total. That can finalize an upload below the committed length the client advertised at creation.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 57ebe51. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is by design: The client is allowed to upload a file in parts. For "oneshot", we deliberately correct the returned upload length down.

};
let stream = BoundedStream::new(stream, lower_bound, upper_bound);
let byte_counter = stream.byte_counter();

relay_log::trace!("Uploading");
let result = upload(&state, project_context, location, stream).await;
let location = result.inspect_err(|e| {
let result = upload(&state, project_context, location, offset, stream).await;
let StreamResult { location, offset } = result.inspect_err(|e| {
relay_log::warn!(error = e as &dyn std::error::Error, "upload failed");
})?;

let upload_offset = byte_counter.get();

let mut response = NoContent.into_response();

// Not required by TUS, but we respond with the location header:
response.headers_mut().insert(
header::LOCATION,
location
.into_header_value()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
);
let location = location
.into_header_value()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
response.headers_mut().insert(header::LOCATION, location);
response
.headers_mut()
.insert(tus::TUS_RESUMABLE, tus::TUS_VERSION);
response
.headers_mut()
.insert(tus::UPLOAD_OFFSET, upload_offset.into());
.insert(tus::UPLOAD_OFFSET, offset.into());

Ok(response)
}
Expand Down Expand Up @@ -337,19 +354,21 @@ async fn upload(
state: &ServiceState,
project: ProjectContext,
location: SignedLocation<Provisional>,
offset: usize,
stream: BoundedStream<MeteredStream<ByteStream>>,
) -> Result<SignedLocation<Final>, Error> {
let location = state
) -> Result<StreamResult, Error> {
let res = state
.upload()
.send(upload::Stream {
received: Utc::now(),
project,
location,
offset,
stream,
})
.await??;

Ok(location)
Ok(res)
}

/// Check request by converting it into a pseudo-envelope.
Expand Down
7 changes: 7 additions & 0 deletions relay-server/src/processing/utils/attachments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ fn validate(item: &Item, config: &Config) -> Result<(), ProcessingError> {
serde_json::from_slice(&payload).map_err(|_| ProcessingError::InvalidAttachmentRef)?;
let signed_location: SignedLocation<Final> = SignedLocation::try_from_str(payload.location)
.ok_or(ProcessingError::InvalidAttachmentRef)?;

if signed_location.upload_id().is_some() {
// `SignedLocation<Final>` should never have an upload ID.
// NOTE: we could encode this into the `Final` type.
return Err(ProcessingError::InvalidAttachmentRef);
}
Comment on lines +56 to +60

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


// NOTE: Using the received timestamp here breaks tests without a pop-relay.
let location = signed_location
.verify(chrono::Utc::now(), config)
Expand Down
Loading
Loading