From 8cf57352628fc91d80c0909856e7634f4cab647a Mon Sep 17 00:00:00 2001 From: Panagiotis Moustafellos Date: Sun, 25 Oct 2026 22:32:40 +0200 Subject: [PATCH 1/4] fix(s3s-fs): list object ETags and url-encode listed keys ListObjectsV2 entries carried no ETag, although s3s-fs stores one per object at PUT, so a client that compares listed ETags (conditional writes, consistency checks) could not use the listing. Each listed object now carries the ETag from its stored internal info. Under encoding-type=url the listing echoed the encoding type but returned raw keys. Clients decode those keys and read `+` as a space, so the key `a+b` came back as `a b`. Listed keys and common prefixes are now percent-encoded when the request asks for url encoding. Two cases in tests/aws/list.rs pin both: the listed ETag equals the PUT response ETag, and under encoding-type=url `a b`, `a+b` and a prefix with a space list as `a%20b`, `a%2Bb` and `e%20f/`, while a listing without it returns the keys as stored. Signed-off-by: Panagiotis Moustafellos --- crates/s3s-fs/src/s3.rs | 40 +++++++++++++++++- crates/s3s-fs/tests/aws/list.rs | 73 +++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/crates/s3s-fs/src/s3.rs b/crates/s3s-fs/src/s3.rs index 976d1db6c..c6e6aec7e 100644 --- a/crates/s3s-fs/src/s3.rs +++ b/crates/s3s-fs/src/s3.rs @@ -825,7 +825,32 @@ impl S3 for FileSystem { // The walk collects one page in key order, so a page no longer has to be cut out of the whole // bucket. The page arithmetic itself is shared with the full scan the walk replaced. - let page = self.list_page(&path, &query).await?; + let mut page = self.list_page(&path, &query).await?; + + // Listed objects carry the ETag the PUT stored, as S3 does; a client that + // reads ETags from a listing (conditional writes, consistency checks) needs it. + for object in &mut page.objects { + if let Some(key) = object.key.as_deref() { + let info = self.load_internal_info(&input.bucket, key).await?; + object.e_tag = info.as_ref().and_then(crate::checksum::load_e_tag).map(ETag::Strong); + } + } + + // Under encoding-type=url, S3 returns every key and common prefix + // URL-encoded and clients decode `+` as a space, so a raw `+` would + // read back as a different key. + if input.encoding_type.as_ref().is_some_and(|e| e.as_str() == EncodingType::URL) { + for object in &mut page.objects { + if let Some(key) = object.key.as_mut() { + *key = url_encode_listed(key); + } + } + for prefix in &mut page.common_prefixes { + if let Some(p) = prefix.prefix.as_mut() { + *p = url_encode_listed(p); + } + } + } let contents = page.objects.is_empty().not().then_some(page.objects); let common_prefixes = page.common_prefixes.is_empty().not().then_some(page.common_prefixes); @@ -1726,6 +1751,19 @@ impl ListingQuery<'_> { } } +/// Percent-encodes every byte outside the unreserved set and `/`. +fn url_encode_listed(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/') { + out.push(char::from(b)); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + /// One page of a listing, plus what follows it. #[derive(Debug, Default)] struct ListingPage { diff --git a/crates/s3s-fs/tests/aws/list.rs b/crates/s3s-fs/tests/aws/list.rs index fddc621fc..9b83738b9 100644 --- a/crates/s3s-fs/tests/aws/list.rs +++ b/crates/s3s-fs/tests/aws/list.rs @@ -29,6 +29,8 @@ pub fn register(tcx: &mut TestContext) { case!(tcx, FsServer, List, test_list_objects_v2_continuation_token_pagination); case!(tcx, FsServer, List, test_list_objects_v2_continuation_token_and_start_after_uses_max); case!(tcx, FsServer, List, test_list_objects_v2_max_keys_zero); + case!(tcx, FsServer, List, test_list_objects_v2_lists_etags); + case!(tcx, FsServer, List, test_list_objects_v2_url_encodes_keys); } impl List { @@ -751,4 +753,75 @@ impl List { Ok(()) } + + async fn test_list_objects_v2_lists_etags(self: Arc) -> Result<()> { + let c = &self.s3; + let bucket = format!("test-list-etags-{}", Uuid::new_v4()); + let bucket = bucket.as_str(); + create_bucket(c, bucket).await?; + + let put = c + .put_object() + .bucket(bucket) + .key("a.txt") + .body(ByteStream::from_static(b"hello")) + .send() + .await?; + + let result = c.list_objects_v2().bucket(bucket).send().await?; + let listed: Vec<_> = result.contents().iter().map(|obj| obj.e_tag()).collect(); + assert_eq!(listed, [put.e_tag()], "a listed object carries the ETag its PUT returned"); + + delete_object(c, bucket, "a.txt").await?; + delete_bucket(c, bucket).await?; + + Ok(()) + } + + async fn test_list_objects_v2_url_encodes_keys(self: Arc) -> Result<()> { + let c = &self.s3; + let bucket = format!("test-list-url-encoding-{}", Uuid::new_v4()); + let bucket = bucket.as_str(); + create_bucket(c, bucket).await?; + + let keys = ["d/a b", "d/a+b", "e f/x"]; + for key in &keys { + c.put_object() + .bucket(bucket) + .key(*key) + .body(ByteStream::from_static(b"x")) + .send() + .await?; + } + + let encoded = c + .list_objects_v2() + .bucket(bucket) + .encoding_type(aws_sdk_s3::types::EncodingType::Url) + .send() + .await?; + let listed: Vec<_> = encoded.contents().iter().filter_map(|obj| obj.key()).collect(); + assert_eq!(listed, ["d/a%20b", "d/a%2Bb", "e%20f/x"], "keys are url-encoded, `+` included"); + + let grouped = c + .list_objects_v2() + .bucket(bucket) + .delimiter("/") + .encoding_type(aws_sdk_s3::types::EncodingType::Url) + .send() + .await?; + let prefixes: Vec<_> = grouped.common_prefixes().iter().filter_map(|p| p.prefix()).collect(); + assert_eq!(prefixes, ["d/", "e%20f/"], "common prefixes are url-encoded too"); + + let raw = c.list_objects_v2().bucket(bucket).send().await?; + let listed: Vec<_> = raw.contents().iter().filter_map(|obj| obj.key()).collect(); + assert_eq!(listed, keys, "without encoding-type the keys are returned as stored"); + + for key in &keys { + delete_object(c, bucket, key).await?; + } + delete_bucket(c, bucket).await?; + + Ok(()) + } } From b1b5b6032fccf2cdb02c752f1d9c41ed5dd7b19e Mon Sep 17 00:00:00 2001 From: Panagiotis Moustafellos Date: Mon, 2 Nov 2026 14:20:27 +0200 Subject: [PATCH 2/4] test(s3s-fs): pin long imported keys and url-encoded listing echoes Two listing defects have no test yet. An object imported under a long nested key, `segment/` 25 times then `object`, has short path components, but its sidecar name embeds the whole encoded key in one component and exceeds the 255-byte limit. The listing now reads that sidecar for the ETag, so the read fails with a name-too-long error and the whole listing fails. The new case imports such an object beside a stored one and expects both v1 and v2 listings to return it without an ETag, while the stored object keeps its ETag. Under encoding-type=url, S3 encodes the echoed Prefix, Delimiter and StartAfter in ListObjectsV2, and Prefix, Delimiter, Marker and NextMarker in ListObjects. s3s-fs returns them raw, and ListObjects does not echo Marker at all. The new cases list a literal `a%2Fb/` prefix with a `+` delimiter and expect the echoes encoded while filtering still uses the raw values. The v2 case also checks that the continuation token is returned and echoed unchanged and resumes the listing. Signed-off-by: Panagiotis Moustafellos --- crates/s3s-fs/tests/aws/list.rs | 202 +++++++++++++++++++++++++++++++- 1 file changed, 201 insertions(+), 1 deletion(-) diff --git a/crates/s3s-fs/tests/aws/list.rs b/crates/s3s-fs/tests/aws/list.rs index 9b83738b9..b4e6fd26d 100644 --- a/crates/s3s-fs/tests/aws/list.rs +++ b/crates/s3s-fs/tests/aws/list.rs @@ -2,8 +2,9 @@ // SPDX-FileCopyrightText: 2023-2026 The s3s Authors use crate::case; -use crate::suite::{List, create_bucket, delete_bucket, delete_object}; +use crate::suite::{FS_ROOT, List, create_bucket, delete_bucket, delete_object}; +use std::path::Path; use std::sync::Arc; use aws_sdk_s3::primitives::ByteStream; @@ -31,6 +32,9 @@ pub fn register(tcx: &mut TestContext) { case!(tcx, FsServer, List, test_list_objects_v2_max_keys_zero); case!(tcx, FsServer, List, test_list_objects_v2_lists_etags); case!(tcx, FsServer, List, test_list_objects_v2_url_encodes_keys); + case!(tcx, FsServer, List, test_list_objects_lists_imported_key_with_unnameable_sidecar); + case!(tcx, FsServer, List, test_list_objects_v2_url_encodes_echoed_fields); + case!(tcx, FsServer, List, test_list_objects_v1_url_encodes_echoed_fields); } impl List { @@ -824,4 +828,200 @@ impl List { Ok(()) } + + async fn test_list_objects_lists_imported_key_with_unnameable_sidecar(self: Arc) -> Result<()> { + let c = &self.s3; + let bucket = format!("test-list-long-key-{}", Uuid::new_v4()); + let bucket = bucket.as_str(); + create_bucket(c, bucket).await?; + + let put = c + .put_object() + .bucket(bucket) + .key("a.txt") + .body(ByteStream::from_static(b"hello")) + .send() + .await?; + + // Every path component of the key is short, so the object itself can be imported, but the + // sidecar name embeds the whole encoded key in one component. + let long_key = format!("{}object", "segment/".repeat(25)); + let encode = |s: &str| base64_simd::URL_SAFE_NO_PAD.encode_to_string(s); + let sidecar_name = format!(".bucket-{}.object-{}.internal.json", encode(bucket), encode(&long_key)); + assert!(sidecar_name.len() > 255, "the sidecar name must exceed the 255-byte component limit"); + + let object_path = Path::new(FS_ROOT).join(bucket).join(&long_key); + if let Some(parent) = object_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&object_path, b"imported")?; + + for v1 in [false, true] { + let listed: Vec<(Option, Option)> = if v1 { + let result = c.list_objects().bucket(bucket).send().await?; + result + .contents() + .iter() + .map(|obj| (obj.key().map(str::to_owned), obj.e_tag().map(str::to_owned))) + .collect() + } else { + let result = c.list_objects_v2().bucket(bucket).send().await?; + result + .contents() + .iter() + .map(|obj| (obj.key().map(str::to_owned), obj.e_tag().map(str::to_owned))) + .collect() + }; + assert_eq!( + listed, + [ + (Some("a.txt".to_owned()), put.e_tag().map(str::to_owned)), + (Some(long_key.clone()), None), + ], + "the imported key lists without an ETag and the stored object keeps its ETag (v1: {v1})" + ); + } + + delete_bucket(c, bucket).await?; + + Ok(()) + } + + async fn test_list_objects_v2_url_encodes_echoed_fields(self: Arc) -> Result<()> { + use aws_sdk_s3::types::EncodingType; + + let c = &self.s3; + let bucket = format!("test-list-url-echo-v2-{}", Uuid::new_v4()); + let bucket = bucket.as_str(); + create_bucket(c, bucket).await?; + + // The prefix holds a literal `%2F`: a client that decodes the echoed prefix must get it back. + let keys = ["a%2Fb/x+1", "a%2Fb/y"]; + for key in &keys { + c.put_object() + .bucket(bucket) + .key(*key) + .body(ByteStream::from_static(b"x")) + .send() + .await?; + } + + let grouped = c + .list_objects_v2() + .bucket(bucket) + .prefix("a%2Fb/") + .delimiter("+") + .start_after("a%2Fb/") + .encoding_type(EncodingType::Url) + .send() + .await?; + assert_eq!(grouped.prefix(), Some("a%252Fb/"), "the echoed prefix is url-encoded"); + assert_eq!(grouped.delimiter(), Some("%2B"), "the echoed delimiter is url-encoded"); + assert_eq!(grouped.start_after(), Some("a%252Fb/"), "the echoed start-after is url-encoded"); + let listed: Vec<_> = grouped.contents().iter().filter_map(|obj| obj.key()).collect(); + assert_eq!(listed, ["a%252Fb/y"], "filtering uses the raw prefix, delimiter and start-after"); + let prefixes: Vec<_> = grouped.common_prefixes().iter().filter_map(|p| p.prefix()).collect(); + assert_eq!(prefixes, ["a%252Fb/x%2B"]); + + // The continuation token is opaque: it is passed back as returned and echoed as sent. + let first = c + .list_objects_v2() + .bucket(bucket) + .prefix("a%2Fb/") + .max_keys(1) + .encoding_type(EncodingType::Url) + .send() + .await?; + let listed: Vec<_> = first.contents().iter().filter_map(|obj| obj.key()).collect(); + assert_eq!(listed, ["a%252Fb/x%2B1"]); + let token = first + .next_continuation_token() + .expect("a truncated page carries a continuation token"); + + let second = c + .list_objects_v2() + .bucket(bucket) + .prefix("a%2Fb/") + .max_keys(1) + .continuation_token(token) + .encoding_type(EncodingType::Url) + .send() + .await?; + assert_eq!(second.continuation_token(), Some(token), "the continuation token is echoed as sent"); + let listed: Vec<_> = second.contents().iter().filter_map(|obj| obj.key()).collect(); + assert_eq!(listed, ["a%252Fb/y"], "the returned continuation token resumes after the first page"); + + for key in &keys { + delete_object(c, bucket, key).await?; + } + delete_bucket(c, bucket).await?; + + Ok(()) + } + + async fn test_list_objects_v1_url_encodes_echoed_fields(self: Arc) -> Result<()> { + use aws_sdk_s3::types::EncodingType; + + let c = &self.s3; + let bucket = format!("test-list-url-echo-v1-{}", Uuid::new_v4()); + let bucket = bucket.as_str(); + create_bucket(c, bucket).await?; + + let keys = ["a%2Fb/x+1", "a%2Fb/y"]; + for key in &keys { + c.put_object() + .bucket(bucket) + .key(*key) + .body(ByteStream::from_static(b"x")) + .send() + .await?; + } + + let grouped = c + .list_objects() + .bucket(bucket) + .prefix("a%2Fb/") + .delimiter("+") + .marker("a%2Fb/") + .encoding_type(EncodingType::Url) + .send() + .await?; + assert_eq!(grouped.prefix(), Some("a%252Fb/"), "the echoed prefix is url-encoded"); + assert_eq!(grouped.delimiter(), Some("%2B"), "the echoed delimiter is url-encoded"); + assert_eq!(grouped.marker(), Some("a%252Fb/"), "the echoed marker is url-encoded"); + let listed: Vec<_> = grouped.contents().iter().filter_map(|obj| obj.key()).collect(); + assert_eq!(listed, ["a%252Fb/y"], "filtering uses the raw prefix, delimiter and marker"); + let prefixes: Vec<_> = grouped.common_prefixes().iter().filter_map(|p| p.prefix()).collect(); + assert_eq!(prefixes, ["a%252Fb/x%2B"]); + + // `NextMarker` is a key, so it is encoded like one; the client decodes it before sending it back. + let first = c + .list_objects() + .bucket(bucket) + .prefix("a%2Fb/") + .max_keys(1) + .encoding_type(EncodingType::Url) + .send() + .await?; + assert_eq!(first.next_marker(), Some("a%252Fb/x%2B1"), "the next marker is url-encoded"); + + let second = c + .list_objects() + .bucket(bucket) + .prefix("a%2Fb/") + .max_keys(1) + .marker("a%2Fb/x+1") + .encoding_type(EncodingType::Url) + .send() + .await?; + let listed: Vec<_> = second.contents().iter().filter_map(|obj| obj.key()).collect(); + assert_eq!(listed, ["a%252Fb/y"], "the decoded next marker resumes after the first page"); + + for key in &keys { + delete_object(c, bucket, key).await?; + } + delete_bucket(c, bucket).await?; + + Ok(()) + } } From b4678dfd7742e3c3bf6a055b01ea815affca882b Mon Sep 17 00:00:00 2001 From: Panagiotis Moustafellos Date: Mon, 2 Nov 2026 14:21:55 +0200 Subject: [PATCH 3/4] fix(s3s-fs): list objects whose sidecar name is too long to exist A sidecar name holds the whole encoded bucket and key in one path component. An object imported under a long nested key has short path components of its own, but its sidecar name can exceed the 255-byte component limit. Reading that sidecar for the listed ETag failed with a name-too-long error, and the error failed the whole listing. No sidecar can exist under a name the filesystem cannot represent, so the listing now reads such a name as absent metadata and lists the object without an ETag, as it does when the sidecar is missing. The change is limited to the listing. HEAD, GET and writes keep their current behaviour. Signed-off-by: Panagiotis Moustafellos --- crates/s3s-fs/src/fs.rs | 23 +++++++++++++++++++++++ crates/s3s-fs/src/s3.rs | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/s3s-fs/src/fs.rs b/crates/s3s-fs/src/fs.rs index 543ffcef2..2e1ff6216 100644 --- a/crates/s3s-fs/src/fs.rs +++ b/crates/s3s-fs/src/fs.rs @@ -45,6 +45,19 @@ async fn read_sidecar(path: &Path) -> Result>> { } } +/// Read a sidecar like [`read_sidecar`], also treating a name the filesystem cannot represent as absent. +/// +/// A sidecar name holds the whole encoded key in one path component, so an object imported under a +/// long nested key can have a sidecar name over the component limit. No sidecar can exist under such +/// a name, and a listing reports the object as it would one whose sidecar is missing. +async fn read_nameable_sidecar(path: &Path) -> Result>> { + match fs::read(path).await { + Ok(content) => Ok(Some(content)), + Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::InvalidFilename) => Ok(None), + Err(err) => Err(err.into()), + } +} + /// Stores standard object attributes alongside user metadata #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub(crate) struct ObjectAttributes { @@ -239,6 +252,16 @@ impl FileSystem { Ok(Some(map)) } + /// Load internal info for a listed object, where a sidecar name too long to exist reads as absent. + pub(crate) async fn load_listed_internal_info(&self, bucket: &str, key: &str) -> Result> { + let path = self.get_internal_info_path(bucket, key)?; + let Some(content) = read_nameable_sidecar(&path).await? else { + return Ok(None); + }; + let map = serde_json::from_slice(&content)?; + Ok(Some(map)) + } + pub(crate) async fn save_internal_info(&self, bucket: &str, key: &str, info: &InternalInfo) -> Result<()> { let path = self.get_internal_info_path(bucket, key)?; let content = serde_json::to_vec(info)?; diff --git a/crates/s3s-fs/src/s3.rs b/crates/s3s-fs/src/s3.rs index c6e6aec7e..23ac9c01c 100644 --- a/crates/s3s-fs/src/s3.rs +++ b/crates/s3s-fs/src/s3.rs @@ -831,7 +831,7 @@ impl S3 for FileSystem { // reads ETags from a listing (conditional writes, consistency checks) needs it. for object in &mut page.objects { if let Some(key) = object.key.as_deref() { - let info = self.load_internal_info(&input.bucket, key).await?; + let info = self.load_listed_internal_info(&input.bucket, key).await?; object.e_tag = info.as_ref().and_then(crate::checksum::load_e_tag).map(ETag::Strong); } } From 84941353fb109b983225dbe5d7f79b8b69da78a8 Mon Sep 17 00:00:00 2001 From: Panagiotis Moustafellos Date: Mon, 2 Nov 2026 14:22:01 +0200 Subject: [PATCH 4/4] fix(s3s-fs): url-encode the echoed listing fields Under encoding-type=url, S3 returns the echoed Prefix, Delimiter and StartAfter of ListObjectsV2 url-encoded, and the Prefix, Delimiter, Marker and NextMarker of ListObjects. s3s-fs encoded the keys and common prefixes but echoed these fields raw. A client decodes them, so a literal prefix `a%2Fb/` read back as `a/b/` and a `+` read back as a space. ListObjectsV2 now encodes the echoed prefix, delimiter and start-after after the walk has filtered with the raw values. The continuation tokens are opaque and stay as sent and as returned. ListObjects now echoes the marker, which it dropped before, and encodes NextMarker, which is a key in V1 and not an opaque token. Signed-off-by: Panagiotis Moustafellos --- crates/s3s-fs/src/s3.rs | 58 +++++++++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/crates/s3s-fs/src/s3.rs b/crates/s3s-fs/src/s3.rs index 23ac9c01c..e9355dca9 100644 --- a/crates/s3s-fs/src/s3.rs +++ b/crates/s3s-fs/src/s3.rs @@ -782,17 +782,25 @@ impl S3 for FileSystem { async fn list_objects(&self, req: S3Request) -> S3Result> { let v2_resp = self.list_objects_v2(req.map_input(Into::into)).await?; - Ok(v2_resp.map_output(|v2| ListObjectsOutput { - contents: v2.contents, - common_prefixes: v2.common_prefixes, - delimiter: v2.delimiter, - encoding_type: v2.encoding_type, - name: v2.name, - prefix: v2.prefix, - max_keys: v2.max_keys, - is_truncated: v2.is_truncated, - next_marker: v2.next_continuation_token, - ..Default::default() + // The V1 marker travels as the V2 start-after, so the V2 output already echoes it encoded. + // `NextMarker` is a key, unlike the opaque V2 continuation token, and is encoded like one. + Ok(v2_resp.map_output(|v2| { + let url_encoded = is_url_encoding(v2.encoding_type.as_ref()); + ListObjectsOutput { + contents: v2.contents, + common_prefixes: v2.common_prefixes, + delimiter: v2.delimiter, + name: v2.name, + prefix: v2.prefix, + marker: v2.start_after, + max_keys: v2.max_keys, + is_truncated: v2.is_truncated, + next_marker: v2 + .next_continuation_token + .map(|marker| if url_encoded { url_encode_listed(&marker) } else { marker }), + encoding_type: v2.encoding_type, + ..Default::default() + } })) } @@ -839,7 +847,8 @@ impl S3 for FileSystem { // Under encoding-type=url, S3 returns every key and common prefix // URL-encoded and clients decode `+` as a space, so a raw `+` would // read back as a different key. - if input.encoding_type.as_ref().is_some_and(|e| e.as_str() == EncodingType::URL) { + let url_encoded = is_url_encoding(input.encoding_type.as_ref()); + if url_encoded { for object in &mut page.objects { if let Some(key) = object.key.as_mut() { *key = url_encode_listed(key); @@ -855,6 +864,16 @@ impl S3 for FileSystem { let contents = page.objects.is_empty().not().then_some(page.objects); let common_prefixes = page.common_prefixes.is_empty().not().then_some(page.common_prefixes); + // The echoed prefix, delimiter and start-after are encoded only now, after the walk filtered + // with the raw values. The continuation token is opaque and returned as the client sent it. + let encode_echo = |value: Option| { + if url_encoded { + value.map(|v| url_encode_listed(&v)) + } else { + value + } + }; + let output = ListObjectsV2Output { key_count: Some(page.key_count), max_keys: Some(max_keys), @@ -863,11 +882,11 @@ impl S3 for FileSystem { common_prefixes, continuation_token: input.continuation_token, next_continuation_token: page.next_continuation_token, - delimiter: input.delimiter, + delimiter: encode_echo(input.delimiter), encoding_type: input.encoding_type, name: Some(input.bucket), - prefix: input.prefix, - start_after: input.start_after, + prefix: encode_echo(input.prefix), + start_after: encode_echo(input.start_after), ..Default::default() }; Ok(S3Response::new(output)) @@ -1751,14 +1770,21 @@ impl ListingQuery<'_> { } } +/// Whether a listing request asked for `encoding-type=url`. +fn is_url_encoding(encoding_type: Option<&EncodingType>) -> bool { + encoding_type.is_some_and(|e| e.as_str() == EncodingType::URL) +} + /// Percent-encodes every byte outside the unreserved set and `/`. fn url_encode_listed(s: &str) -> String { + use std::fmt::Write as _; + let mut out = String::with_capacity(s.len()); for b in s.bytes() { if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/') { out.push(char::from(b)); } else { - out.push_str(&format!("%{b:02X}")); + let _ = write!(out, "%{b:02X}"); } } out