diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index beb3f5f192fb..ff9708a52b71 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -2561,22 +2561,12 @@ where }; let credentials = s3.get_credentials(); let path = s3.path(); - // `Transpiler::env_mut` is the safe accessor for the - // process-singleton dotenv loader (set during init). - let proxy_url = global_this - .bun_vm() - .as_mut() - .transpiler - .env_mut() - .get_http_proxy(true, None, None) - .map(|proxy| proxy.href); let _ = S3::client::stat( credentials, path, Self::on_s3_size_resolved_thunk, this.as_ctx_ptr().cast::(), - proxy_url, s3.request_payer, ); // TODO: properly propagate exception upwards return; diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index ee636cf174f6..3a0e8b69cd08 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -28,23 +28,6 @@ use crate::webcore::{self, Lifetime, ReadableStream, Request, Response, streams} bun_core::define_scoped_log!(debug, Blob, visible); -/// `bunVM().transpiler.env.getHttpProxy(true, null, null)?.href` as an owned -/// buffer. Owned (not borrowed) because the env loader's `URL<'_>` ties the -/// `href` slice to a `&mut Loader` borrow that we cannot keep open across the -/// S3 request setup. -#[inline] -fn http_proxy_href(global: &JSGlobalObject) -> Option> { - // `Transpiler::env_mut` is the safe accessor for the process-singleton - // dotenv loader (initialised before any JS runs). - global - .bun_vm() - .as_mut() - .transpiler - .env_mut() - .get_http_proxy(true, None, None) - .map(|p| p.href.to_vec()) -} - #[path = "blob/Store.rs"] pub mod store; use crate::node::types::{PathLikeExt as _, PathOrFdExt as _}; @@ -617,7 +600,6 @@ impl BlobExt for Blob { poll: bun_io::KeepAlive::default(), }); t.poll.ref_(bun_io::js_vm_ctx()); - let proxy = http_proxy_href(global); // reshaped for borrowck — `heap::alloc(t)` moves `t`, so clone the // credentials ref out and stash `path` as a raw `*const [u8]` // whose backing store is kept alive by the same `t.blob` now @@ -651,18 +633,10 @@ impl BlobExt for Blob { len, Task::::cb, t_ptr, - proxy.as_deref(), payer, )?; } else { - crate::webcore::__s3_client::download( - &cred, - path, - Task::::cb, - t_ptr, - proxy.as_deref(), - payer, - )?; + crate::webcore::__s3_client::download(&cred, path, Task::::cb, t_ptr, payer)?; } return Ok(()); } @@ -1396,12 +1370,6 @@ impl BlobExt for Blob { }; let path = s3.path(); - // SAFETY: bun_vm() never returns null for a Bun-owned global; `env` - // is a live `*mut Loader` owned by the transpiler. - let proxy = unsafe { - (*global_this.bun_vm().as_mut().transpiler.env).get_http_proxy(true, None, None) - }; - let proxy_url = proxy.map(|p| p.href); // When no JS overrides were supplied, hand the store's *base* // credentials to the upload. @@ -1422,7 +1390,7 @@ impl BlobExt for Blob { // backing storage is owned by `aws_options` which outlives this call. aws_options.content_disposition.as_deref(), aws_options.content_encoding.as_deref(), - proxy_url, + None, aws_options.request_payer, None, core::ptr::null_mut(), @@ -1679,16 +1647,6 @@ impl BlobExt for Blob { // content-type writes below don't conflict. let s3 = store.data.as_s3(); let path = s3.path(); - // SAFETY: `bun_vm()` returns the live per-global VM; `transpiler.env` - // is the process-singleton dotenv loader, never null once init'd. - let proxy_url: Option> = unsafe { - (*global_this.bun_vm().as_mut().transpiler.env).get_http_proxy(true, None, None) - }; - // Copy the href out of the env map before any reentrant JS (the - // `get_truthy`/credential getters below) can mutate `process.env` - // and free the backing allocation. - let proxy_owned: Option> = proxy_url.as_ref().map(|p| p.href.to_vec()); - let proxy = proxy_owned.as_deref(); if has_args && arg0.is_object() { let options = arg0; @@ -1735,7 +1693,6 @@ impl BlobExt for Blob { self.content_type_or_mime_type(), content_disposition_str.as_ref().map(|s| s.slice()), content_encoding_str.as_ref().map(|s| s.slice()), - proxy, credentials_with_options.storage_class, credentials_with_options.request_payer, ); @@ -1749,7 +1706,6 @@ impl BlobExt for Blob { self.content_type_or_mime_type(), None, None, - proxy, None, s3.request_payer, ); @@ -4392,8 +4348,6 @@ fn write_file_with_empty_source_to_destination( let promise = jsc::JSPromiseStrong::init(ctx); let promise_value = promise.value(); - let proxy_owned = http_proxy_href(ctx); - let proxy_url = proxy_owned.as_deref(); s3_client::upload( &aws_options.credentials, s3.path(), @@ -4402,7 +4356,6 @@ fn write_file_with_empty_source_to_destination( aws_options.content_disposition.as_deref(), aws_options.content_encoding.as_deref(), aws_options.acl, - proxy_url, aws_options.storage_class, aws_options.request_payer, Wrapper::resolve, @@ -4575,8 +4528,6 @@ pub(crate) fn write_file_with_source_destination( ); } }; - let proxy_owned = http_proxy_href(ctx); - let proxy_url = proxy_owned.as_deref(); match &source_store.data { store::Data::Bytes(bytes) => { if bytes.len() as usize > S3::MultiPartUploadOptions::MAX_SINGLE_UPLOAD_SIZE { @@ -4603,7 +4554,7 @@ pub(crate) fn write_file_with_source_destination( destination_blob.content_type_or_mime_type(), aws_options.content_disposition.as_deref(), aws_options.content_encoding.as_deref(), - proxy_url, + None, aws_options.request_payer, None, core::ptr::null_mut(), @@ -4660,7 +4611,6 @@ pub(crate) fn write_file_with_source_destination( aws_options.content_disposition.as_deref(), aws_options.content_encoding.as_deref(), aws_options.acl, - proxy_url, aws_options.storage_class, aws_options.request_payer, Wrapper::resolve, @@ -4699,7 +4649,7 @@ pub(crate) fn write_file_with_source_destination( destination_blob.content_type_or_mime_type(), aws_options.content_disposition.as_deref(), aws_options.content_encoding.as_deref(), - proxy_url, + None, aws_options.request_payer, None, core::ptr::null_mut(), @@ -4969,8 +4919,6 @@ pub(crate) fn write_file_internal( "ReadableStream has already been used" ))); } - let proxy_owned = http_proxy_href(global_this); - let proxy_url = proxy_owned.as_deref(); return Ok(ControlFlow::Break(s3_client::upload_stream( if options.extra_options.is_some() { aws_options.credentials.dupe() @@ -4986,7 +4934,7 @@ pub(crate) fn write_file_internal( destination_blob.content_type_or_mime_type(), aws_options.content_disposition.as_deref(), aws_options.content_encoding.as_deref(), - proxy_url, + None, aws_options.request_payer, None, core::ptr::null_mut(), @@ -5697,9 +5645,6 @@ impl S3BlobDownloadTask { let credentials = s3_store.get_credentials(); let path = s3_store.path(); - let proxy_owned = http_proxy_href(global_this); - let proxy = proxy_owned.as_deref(); - fn s3_cb( result: crate::webcore::__s3_client::S3DownloadResult<'_>, ctx: *mut c_void, @@ -5723,7 +5668,6 @@ impl S3BlobDownloadTask { len, s3_cb, this.cast::(), - proxy, s3_store.request_payer, )?; } else if blob.size.get() == MAX_SIZE { @@ -5732,7 +5676,6 @@ impl S3BlobDownloadTask { path, s3_cb, this.cast::(), - proxy, s3_store.request_payer, )?; } else { @@ -5745,7 +5688,6 @@ impl S3BlobDownloadTask { Some(len), s3_cb, this.cast::(), - proxy, s3_store.request_payer, )?; } diff --git a/src/runtime/webcore/ReadableStream.rs b/src/runtime/webcore/ReadableStream.rs index 4577965b709b..cbd520316c52 100644 --- a/src/runtime/webcore/ReadableStream.rs +++ b/src/runtime/webcore/ReadableStream.rs @@ -536,15 +536,6 @@ impl ReadableStream { webcore::blob::store::Data::S3(s3) => { let credentials = s3.get_credentials(); let path = s3.path(); - // `Transpiler::env_mut` is the safe accessor for the - // process-singleton dotenv loader (set during init). - let proxy = global_this - .bun_vm() - .as_mut() - .transpiler - .env_mut() - .get_http_proxy(true, None, None); - let proxy_url = proxy.as_ref().map(|p| p.href); crate::webcore::s3::client::readable_stream( credentials, @@ -555,7 +546,6 @@ impl ReadableStream { } else { None }, - proxy_url, s3.request_payer, global_this, ) diff --git a/src/runtime/webcore/S3File.rs b/src/runtime/webcore/S3File.rs index 9ec58ada9db1..da1ae503f38a 100644 --- a/src/runtime/webcore/S3File.rs +++ b/src/runtime/webcore/S3File.rs @@ -471,16 +471,12 @@ impl S3BlobStatTask { let s3_store = blob.store.get().as_ref().unwrap().data.as_s3(); let credentials = s3_store.get_credentials(); let path = s3_store.path(); - // `Transpiler::env_mut` is the safe accessor for the process-singleton - // dotenv loader (set during init). - let env = global.bun_vm().as_mut().transpiler.env_mut(); s3::stat( credentials, path, S3BlobStatTask::on_s3_exists_resolved, this.cast::(), - env.get_http_proxy(true, None, None).map(|proxy| proxy.href), s3_store.request_payer, )?; Ok(promise) @@ -498,16 +494,12 @@ impl S3BlobStatTask { let s3_store = blob.store.get().as_ref().unwrap().data.as_s3(); let credentials = s3_store.get_credentials(); let path = s3_store.path(); - // `Transpiler::env_mut` is the safe accessor for the process-singleton - // dotenv loader (set during init). - let env = global.bun_vm().as_mut().transpiler.env_mut(); s3::stat( credentials, path, S3BlobStatTask::on_s3_stat_resolved, this.cast::(), - env.get_http_proxy(true, None, None).map(|proxy| proxy.href), s3_store.request_payer, )?; Ok(promise) @@ -525,16 +517,12 @@ impl S3BlobStatTask { let s3_store = blob.store.get().as_ref().unwrap().data.as_s3(); let credentials = s3_store.get_credentials(); let path = s3_store.path(); - // `Transpiler::env_mut` is the safe accessor for the process-singleton - // dotenv loader (set during init). - let env = global.bun_vm().as_mut().transpiler.env_mut(); s3::stat( credentials, path, S3BlobStatTask::on_s3_size_resolved, this.cast::(), - env.get_http_proxy(true, None, None).map(|proxy| proxy.href), s3_store.request_payer, )?; Ok(promise) diff --git a/src/runtime/webcore/blob/Store.rs b/src/runtime/webcore/blob/Store.rs index eab4fc0d2f32..8d5b6339f46b 100644 --- a/src/runtime/webcore/blob/Store.rs +++ b/src/runtime/webcore/blob/Store.rs @@ -21,7 +21,6 @@ use crate::webcore::s3::client::{ use bun_core::strings; use bun_http_types::MimeType::MimeType; use bun_ptr::RefPtr; -use bun_url::URL; #[cfg(unix)] use super::SizeType; @@ -314,15 +313,6 @@ impl S3Ext for S3 { let promise = bun_jsc::JSPromiseStrong::init(global_this); let value = promise.value(); - // `Transpiler::env_mut` is the safe accessor for the process-singleton - // dotenv loader (never null once the VM is initialised). - let proxy_url: Option> = global_this - .bun_vm() - .as_mut() - .transpiler - .env_mut() - .get_http_proxy(true, None, None); - let proxy = proxy_url.as_ref().map(|url| url.href); let aws_options = self.get_credentials_with_options(extra_options, global_this)?; // `defer aws_options.deinit()` → Drop handles it. @@ -336,7 +326,6 @@ impl S3Ext for S3 { global: bun_ptr::BackRef::new(global_this), })) .cast::(), - proxy, aws_options.request_payer, )?; @@ -400,15 +389,6 @@ impl S3Ext for S3 { let promise = bun_jsc::JSPromiseStrong::init(global_this); let value = promise.value(); - // `Transpiler::env_mut` is the safe accessor for the process-singleton - // dotenv loader (never null once the VM is initialised). - let proxy_url: Option> = global_this - .bun_vm() - .as_mut() - .transpiler - .env_mut() - .get_http_proxy(true, None, None); - let proxy = proxy_url.as_ref().map(|url| url.href); let aws_options = self.get_credentials_with_options(extra_options, global_this)?; // `defer aws_options.deinit()` → Drop handles it. @@ -432,7 +412,6 @@ impl S3Ext for S3 { unsafe { &(*wrapper).resolved_list_options }, Wrapper::resolve, wrapper.cast::(), - proxy, )?; Ok(value) diff --git a/src/runtime/webcore/s3/client.rs b/src/runtime/webcore/s3/client.rs index 44a113d3f460..8bb6e30e3063 100644 --- a/src/runtime/webcore/s3/client.rs +++ b/src/runtime/webcore/s3/client.rs @@ -63,7 +63,6 @@ pub(crate) fn stat( path: &[u8], callback: fn(S3StatResult, *mut c_void) -> JsResult<()>, callback_context: *mut c_void, - proxy_url: Option<&[u8]>, request_payer: bool, ) -> JsResult<()> { s3_simple_request::execute_simple_s3_request( @@ -71,7 +70,6 @@ pub(crate) fn stat( s3_simple_request::Options { path, method: bun_http::Method::HEAD, - proxy_url, body: b"", request_payer, ..Default::default() @@ -86,7 +84,6 @@ pub(crate) fn download( path: &[u8], callback: fn(S3DownloadResult, *mut c_void) -> JsResult<()>, callback_context: *mut c_void, - proxy_url: Option<&[u8]>, request_payer: bool, ) -> JsResult<()> { s3_simple_request::execute_simple_s3_request( @@ -94,7 +91,6 @@ pub(crate) fn download( s3_simple_request::Options { path, method: bun_http::Method::GET, - proxy_url, body: b"", request_payer, ..Default::default() @@ -111,7 +107,6 @@ pub(crate) fn download_slice( size: Option, callback: fn(S3DownloadResult, *mut c_void) -> JsResult<()>, callback_context: *mut c_void, - proxy_url: Option<&[u8]>, request_payer: bool, ) -> JsResult<()> { let range: Option> = 'brk: { @@ -137,7 +132,6 @@ pub(crate) fn download_slice( s3_simple_request::Options { path, method: bun_http::Method::GET, - proxy_url, body: b"", range: range.map(Vec::into_boxed_slice), request_payer, @@ -153,7 +147,6 @@ pub(crate) fn delete( path: &[u8], callback: fn(S3DeleteResult, *mut c_void) -> JsResult<()>, callback_context: *mut c_void, - proxy_url: Option<&[u8]>, request_payer: bool, ) -> JsResult<()> { s3_simple_request::execute_simple_s3_request( @@ -161,7 +154,6 @@ pub(crate) fn delete( s3_simple_request::Options { path, method: bun_http::Method::DELETE, - proxy_url, body: b"", request_payer, ..Default::default() @@ -176,7 +168,6 @@ pub(crate) fn list_objects( list_options: &S3ListObjectsOptions, callback: fn(S3ListObjectsResult, *mut c_void) -> JsResult<()>, callback_context: *mut c_void, - proxy_url: Option<&[u8]>, ) -> JsResult<()> { let mut search_params: Vec = Vec::::default(); @@ -309,18 +300,12 @@ pub(crate) fn list_objects( task.poll_ref.ref_(bun_io::js_vm_ctx()); - let proxy = proxy_url.unwrap_or(b""); - task.proxy_url = if !proxy.is_empty() { - Box::<[u8]>::from(proxy) - } else { - Box::<[u8]>::default() - }; - // SAFETY: lifetime extension — `url`, `headers_buf`, and `proxy_url` borrow from // heap-allocated fields of `*task` which the task outlives. AsyncHTTP::init wants // `'static` borrows because the HTTP thread reads them concurrently; they remain valid // until `task` is dropped in `on_response`. let url = bun_url::URL::parse(unsafe { bun_ptr::detach_lifetime_ref(&*task.sign_result.url) }); + task.proxy_url = s3_simple_request::resolve_proxy_url(&url, None); // SAFETY: same lifetime-extension invariant as `url` above — `task.headers.buf` is // heap-owned by `*task` and outlives the AsyncHTTP request. let headers_buf: &'static [u8] = @@ -382,7 +367,6 @@ pub(crate) fn upload( content_disposition: Option<&[u8]>, content_encoding: Option<&[u8]>, acl: Option, - proxy_url: Option<&[u8]>, storage_class: Option, request_payer: bool, callback: fn(S3UploadResult, *mut c_void) -> JsResult<()>, @@ -393,7 +377,6 @@ pub(crate) fn upload( s3_simple_request::Options { path, method: bun_http::Method::PUT, - proxy_url, body: content, content_type, content_disposition, @@ -419,7 +402,6 @@ pub(crate) fn writable_stream( content_type: Option<&[u8]>, content_disposition: Option<&[u8]>, content_encoding: Option<&[u8]>, - proxy: Option<&[u8]>, storage_class: Option, request_payer: bool, ) -> JsResult { @@ -489,7 +471,6 @@ pub(crate) fn writable_stream( NetworkSink::on_writable(task, ctx.cast::(), flushed); } - let proxy_url = proxy.unwrap_or(b""); // `credentials` ref adopted by value — moved into the MultiPartUpload below. // JSC_BORROW: `global_this` outlives the task (it owns the VM/heap that owns the JS // objects which keep the task alive); stored via `GlobalRef` in the heap-allocated @@ -516,11 +497,7 @@ pub(crate) fn writable_stream( buffered: JsCell::new(StreamBuffer::default()), uploaded_bytes: Cell::new(0), path: Box::<[u8]>::from(path), - proxy: if !proxy_url.is_empty() { - Box::<[u8]>::from(proxy_url) - } else { - Box::default() - }, + proxy: Box::default(), content_type: content_type.map(Box::<[u8]>::from), content_disposition: content_disposition.map(Box::<[u8]>::from), content_encoding: content_encoding.map(Box::<[u8]>::from), @@ -807,6 +784,7 @@ pub(crate) fn upload_stream( content_type: Option<&[u8]>, content_disposition: Option<&[u8]>, content_encoding: Option<&[u8]>, + // Explicit override; `None`/empty resolves env proxies per request. proxy: Option<&[u8]>, request_payer: bool, callback: Option, @@ -1110,7 +1088,6 @@ fn download_stream( path: &[u8], offset: usize, size: Option, - proxy_url: Option<&[u8]>, request_payer: bool, callback: fn( chunk: &MutableString, @@ -1184,18 +1161,12 @@ fn download_stream( break 'brk bun_http::Headers::from_pico_http_headers(result.headers()); } }; - let proxy = proxy_url.unwrap_or(b""); - let owned_proxy: Box<[u8]> = if !proxy.is_empty() { - Box::<[u8]>::from(proxy) - } else { - Box::<[u8]>::default() - }; let task_ptr = bun_core::heap::into_raw(S3HttpDownloadStreamingTask::new( S3HttpDownloadStreamingTask { // `http: undefined` — fully overwritten by `task.http.write(AsyncHTTP::init(...))` below. http: core::mem::MaybeUninit::uninit(), sign_result: result, - proxy_url: owned_proxy, + proxy_url: Box::default(), callback_context: NonNull::new(callback_context.cast::<()>()) .expect("callers always pass a non-null Box-allocated context"), callback, @@ -1227,6 +1198,7 @@ fn download_stream( // SAFETY: lifetime extension — `url` / `headers_buf` / `proxy_url` borrow from heap-allocated // fields of `*task` which the task outlives. See `execute_simple_s3_request`. let url = bun_url::URL::parse(unsafe { bun_ptr::detach_lifetime_ref(&*task.sign_result.url) }); + task.proxy_url = s3_simple_request::resolve_proxy_url(&url, None); // SAFETY: same lifetime-extension invariant as `url` above — `task.headers.buf` is // heap-owned by `*task` and outlives the AsyncHTTP request. let headers_buf: &'static [u8] = @@ -1450,7 +1422,6 @@ pub(crate) fn readable_stream( path: &[u8], offset: usize, size: Option, - proxy_url: Option<&[u8]>, request_payer: bool, global_this: &JSGlobalObject, ) -> JsResult { @@ -1495,7 +1466,6 @@ pub(crate) fn readable_stream( path, offset, size, - proxy_url, request_payer, S3DownloadStreamWrapper::opaque_callback, wrapper.cast::(), diff --git a/src/runtime/webcore/s3/multipart.rs b/src/runtime/webcore/s3/multipart.rs index 35a01b7b4765..aaf039cec1bb 100644 --- a/src/runtime/webcore/s3/multipart.rs +++ b/src/runtime/webcore/s3/multipart.rs @@ -147,6 +147,7 @@ pub struct MultiPartUpload { pub(crate) uploaded_bytes: Cell, pub path: Box<[u8]>, + /// Explicit override; empty resolves env proxies per part request. pub(crate) proxy: Box<[u8]>, pub(crate) content_type: Option>, pub(crate) content_disposition: Option>, @@ -981,7 +982,11 @@ impl MultiPartUpload { } pub(crate) fn proxy_url(&self) -> Option<&[u8]> { - Some(&self.proxy) + if self.proxy.is_empty() { + None + } else { + Some(&self.proxy) + } } fn process_buffered(&self, part_size: usize) { diff --git a/src/runtime/webcore/s3/simple_request.rs b/src/runtime/webcore/s3/simple_request.rs index a05b3e529091..4577976b8ed4 100644 --- a/src/runtime/webcore/s3/simple_request.rs +++ b/src/runtime/webcore/s3/simple_request.rs @@ -522,6 +522,7 @@ pub struct S3SimpleRequestOptions<'a> { // http request options pub(crate) body: &'a [u8], + /// Explicit override; `None`/empty resolves env proxies per request. pub(crate) proxy_url: Option<&'a [u8]>, /// Owned; ownership transfers to the spawned task (or is dropped on sign error). pub(crate) range: Option>, @@ -549,6 +550,24 @@ impl<'a> Default for S3SimpleRequestOptions<'a> { } } +/// Resolve the proxy like fetch: an explicit proxy is still subject to +/// NO_PROXY; otherwise HTTP_PROXY/HTTPS_PROXY is picked by URL scheme. +/// Owned (empty = none): `process.env` writes can free the env href. +pub(crate) fn resolve_proxy_url(url: &URL<'_>, explicit: Option<&[u8]>) -> Box<[u8]> { + let env = VirtualMachine::get().transpiler.env_mut(); + if let Some(explicit) = explicit { + if !explicit.is_empty() { + if env.is_no_proxy(Some(url.hostname), Some(url.host)) { + return Box::default(); + } + return Box::from(explicit); + } + } + env.get_http_proxy_for(url) + .map(|proxy| Box::from(proxy.href)) + .unwrap_or_default() +} + pub(crate) fn execute_simple_s3_request( this: &S3Credentials, options: S3SimpleRequestOptions<'_>, @@ -620,7 +639,7 @@ pub(crate) fn execute_simple_s3_request( poll_ref.ref_(bun_io::posix_event_loop::get_vm_ctx( bun_io::AllocatorType::Js, )); - let proxy = options.proxy_url.unwrap_or(b""); + let proxy_url = resolve_proxy_url(&URL::parse(&result.url), options.proxy_url); let task_ptr = S3HttpSimpleTask::new(S3HttpSimpleTask { // written below via `MaybeUninit::write` before any read. http: core::mem::MaybeUninit::uninit(), @@ -632,11 +651,7 @@ pub(crate) fn execute_simple_s3_request( response_buffer: MutableString::default(), result: HTTPClientResult::default(), concurrent_task: ConcurrentTask::default(), - proxy_url: if !proxy.is_empty() { - Box::<[u8]>::from(proxy) - } else { - Box::default() - }, + proxy_url, body: Box::<[u8]>::from(options.body), poll_ref, signal_store: Default::default(), diff --git a/test/js/bun/s3/s3-proxy.test.ts b/test/js/bun/s3/s3-proxy.test.ts new file mode 100644 index 000000000000..757e5c18223a --- /dev/null +++ b/test/js/bun/s3/s3-proxy.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "bun:test"; +import { bunEnv, bunExe, tls } from "harness"; + +// S3 requests must resolve HTTP(S)_PROXY / NO_PROXY against the actual +// request URL, the same way fetch does. +// https://github.com/oven-sh/bun/issues/32045 +describe.concurrent("s3 proxy env vars", () => { + const childScript = ` + const endpoint = process.argv[1]; + const op = process.argv[2]; + const opts = { + accessKeyId: "test", + secretAccessKey: "test", + region: "eu-west-3", + bucket: "mybucket", + endpoint, + }; + switch (op) { + case "write": + await Bun.S3Client.file("key", opts).write("content"); + break; + case "stream": { + const body = await Bun.S3Client.file("key", opts).stream().text(); + if (body !== "ok") throw new Error("unexpected stream body: " + body); + break; + } + case "list": + await new Bun.S3Client(opts).list(); + break; + case "writer": { + const writer = Bun.S3Client.file("key", opts).writer(); + writer.write("content"); + await writer.end(); + break; + } + case "fetch-stream": { + // Streaming body: the S3 multipart path, with fetch's explicit proxy option. + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("content")); + controller.close(); + }, + }); + const res = await fetch("s3://mybucket/key", { + method: "PUT", + body, + proxy: process.env.EXPLICIT_PROXY, + s3: opts, + }); + if (!res.ok) throw new Error("fetch-stream failed: " + res.status + " " + (await res.text())); + break; + } + default: + throw new Error("unknown op: " + op); + } + console.log("ok:" + op); + `; + + function servers(tlsOptions?: typeof tls) { + const endpointHits: string[] = []; + const proxyHits: string[] = []; + const endpoint = Bun.serve({ + port: 0, + tls: tlsOptions, + fetch(req) { + endpointHits.push(`${req.method} ${new URL(req.url).pathname}`); + if (req.method === "GET" && req.url.includes("list-type=2")) { + return new Response( + `mybucket0false`, + { headers: { "Content-Type": "application/xml" } }, + ); + } + return new Response("ok"); + }, + }); + const proxy = Bun.serve({ + port: 0, + fetch(req) { + proxyHits.push(`${req.method} ${req.url}`); + return new Response("ok"); + }, + }); + return { endpoint, proxy, endpointHits, proxyHits }; + } + + async function runChild(endpointUrl: string, op: string, env: Record) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", childScript, endpointUrl, op], + env: { + ...bunEnv, + http_proxy: undefined, + HTTP_PROXY: undefined, + https_proxy: undefined, + HTTPS_PROXY: undefined, + no_proxy: undefined, + NO_PROXY: undefined, + ...env, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + for (const op of ["write", "stream", "list", "writer"]) { + it(`${op} bypasses HTTP_PROXY when the endpoint host is in NO_PROXY`, async () => { + const { endpoint, proxy, endpointHits, proxyHits } = servers(); + using _endpoint = endpoint; + using _proxy = proxy; + + const { stdout, stderr, exitCode } = await runChild(endpoint.url.href, op, { + HTTP_PROXY: proxy.url.href, + NO_PROXY: "localhost,127.0.0.1", + }); + + expect({ stdout, exitCode, stderr, proxyHits }).toEqual({ + stdout: `ok:${op}\n`, + exitCode: 0, + stderr: "", + proxyHits: [], + }); + expect(endpointHits.length).toBeGreaterThanOrEqual(1); + }); + } + + it("write goes through HTTP_PROXY when NO_PROXY does not match", async () => { + const { endpoint, proxy, endpointHits, proxyHits } = servers(); + using _endpoint = endpoint; + using _proxy = proxy; + + const { stdout, stderr, exitCode } = await runChild(endpoint.url.href, "write", { + HTTP_PROXY: proxy.url.href, + NO_PROXY: "example.com", + }); + + // The request is sent to the proxy in absolute-URI form. + expect({ stdout, exitCode, stderr, proxyHits, endpointHits }).toEqual({ + stdout: "ok:write\n", + exitCode: 0, + stderr: "", + proxyHits: [`PUT ${endpoint.url.href}mybucket/key`], + endpointHits: [], + }); + }); + + it("explicit fetch proxy is bypassed when the endpoint host is in NO_PROXY", async () => { + const { endpoint, proxy, endpointHits, proxyHits } = servers(); + using _endpoint = endpoint; + using _proxy = proxy; + + const { stdout, stderr, exitCode } = await runChild(endpoint.url.href, "fetch-stream", { + EXPLICIT_PROXY: proxy.url.href, + NO_PROXY: "localhost,127.0.0.1", + }); + + expect({ stdout, exitCode, stderr, proxyHits }).toEqual({ + stdout: "ok:fetch-stream\n", + exitCode: 0, + stderr: "", + proxyHits: [], + }); + expect(endpointHits.length).toBeGreaterThanOrEqual(1); + }); + + it("explicit fetch proxy is used when NO_PROXY does not match", async () => { + const { endpoint, proxy, endpointHits, proxyHits } = servers(); + using _endpoint = endpoint; + using _proxy = proxy; + + const { stdout, stderr, exitCode } = await runChild(endpoint.url.href, "fetch-stream", { + EXPLICIT_PROXY: proxy.url.href, + NO_PROXY: "example.com", + }); + + expect({ stdout, exitCode, stderr, endpointHits }).toEqual({ + stdout: "ok:fetch-stream\n", + exitCode: 0, + stderr: "", + endpointHits: [], + }); + expect(proxyHits.length).toBeGreaterThanOrEqual(1); + }); + + it("write to an https endpoint does not use HTTP_PROXY", async () => { + const { endpoint, proxy, endpointHits, proxyHits } = servers(tls); + using _endpoint = endpoint; + using _proxy = proxy; + + // Only HTTP_PROXY is set: an https endpoint must connect directly. + const { stdout, stderr, exitCode } = await runChild(endpoint.url.href, "write", { + HTTP_PROXY: proxy.url.href, + NODE_TLS_REJECT_UNAUTHORIZED: "0", + }); + + expect({ stdout, exitCode, stderr, proxyHits, endpointHits }).toEqual({ + stdout: "ok:write\n", + exitCode: 0, + stderr: "", + proxyHits: [], + endpointHits: ["PUT /mybucket/key"], + }); + }); +});