clawhdf5-remote, h5rs: URLs' credentials are never shown

Every RemoteError message and HttpStorage's Debug output held the URL as
given, with any user:password@ and the query string — for a presigned
S3/GCS/Azure URL, its signature or token. An application logging the
error leaked the credential.

- New clawhdf5_remote::redact_url: no userinfo, no fragment, query values
  replaced by REDACTED (plain key names kept).
- HttpStorage formats every message with the redacted URL, and scrubs the
  URL's secret parts from errors of the HTTP client (whose texts can echo
  the URI); Debug shows the redacted URL. storage_for_url's and the object
  store URL errors are redacted too. HttpStorage::url() still returns the
  URL as given, documented as not for logging.
- h5rs prints FILE arguments that are URLs redacted: in errors and in
  dump/stat/check/diff output.
- The test server can force a status and send a wrong Content-Range.

Tests: 404, 403 (at open and on a read), wrong Content-Range (at open and
on a read), no range support, encoded body, ETag change, timeout,
connection closed and bad scheme errors, Display and Debug, contain none
of the secrets; h5rs likewise for every subcommand.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 18:30:02 -05:00
co-authored by Claude Opus 5.5
parent 8df5b209a7
commit c04e34620e
12 changed files with 421 additions and 49 deletions
+1 -1
View File
@@ -204,7 +204,7 @@ pub fn run(args: &mut Args, out: &mut Out) -> std::io::Result<i32> {
}
}
if !quiet {
c.summary(&file, out)?;
c.summary(&crate::h5::shown(&file), out)?;
}
Ok(if c.panicked {
3
+3 -2
View File
@@ -196,7 +196,8 @@ pub fn run(args: &mut Args, out: &mut Out) -> std::io::Result<i32> {
Err(_) => {
writeln!(
out.e,
"h5rs diff: object <{obj}> could not be found in <{f}>"
"h5rs diff: object <{obj}> could not be found in <{}>",
crate::h5::shown(f)
)?;
return Ok(2);
}
@@ -214,7 +215,7 @@ pub fn run(args: &mut Args, out: &mut Out) -> std::io::Result<i32> {
let entries = match collected {
Ok(e) => e,
Err(e) => {
writeln!(out.e, "h5rs diff: {f}: {e}")?;
writeln!(out.e, "h5rs diff: {}: {e}", crate::h5::shown(f))?;
return Ok(2);
}
};
+1
View File
@@ -98,6 +98,7 @@ pub fn run(args: &mut Args, out: &mut Out) -> std::io::Result<i32> {
problems: 0,
paths: OnceCell::new(),
};
let file = crate::h5::shown(&file);
let fname = std::path::Path::new(&file)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
+32 -8
View File
@@ -229,20 +229,21 @@ impl H5 {
if !is_url(arg) {
return H5::open(Path::new(arg));
}
let name = shown(arg);
#[cfg(feature = "remote")]
{
let storage =
clawhdf5_remote::storage_for_url(arg, &clawhdf5_remote::Options::default())
.map_err(|e| Error::new(format!("{arg}: {e}")))?;
.map_err(|e| Error::new(format!("{name}: {e}")))?;
let size = storage.len();
let file = File::open_storage(storage).map_err(|e| {
Error::new(format!("{arg}: not an HDF5 file this tool can open: {e}"))
Error::new(format!("{name}: not an HDF5 file this tool can open: {e}"))
})?;
Ok(H5::new(PathBuf::from(arg), file, size))
Ok(H5::new(PathBuf::from(&name), file, size))
}
#[cfg(not(feature = "remote"))]
Err(Error::new(format!(
"{arg}: URLs need h5rs built with the `remote` feature"
"{name}: URLs need h5rs built with the `remote` feature"
)))
}
@@ -256,18 +257,19 @@ impl H5 {
if h5.file.contiguous_bytes().is_some() {
return Ok(h5);
}
let name = shown(arg);
#[cfg(feature = "remote")]
{
let storage =
clawhdf5_remote::storage_for_url(arg, &clawhdf5_remote::Options::default())
.map_err(|e| Error::new(format!("{arg}: {e}")))?;
.map_err(|e| Error::new(format!("{name}: {e}")))?;
let bytes = clawhdf5_remote::download(&*storage, max_download)
.map_err(|e| Error::new(format!("{arg}: {e}")))?;
.map_err(|e| Error::new(format!("{name}: {e}")))?;
let size = bytes.len() as u64;
let file = File::from_bytes(bytes).map_err(|e| {
Error::new(format!("{arg}: not an HDF5 file this tool can open: {e}"))
Error::new(format!("{name}: not an HDF5 file this tool can open: {e}"))
})?;
Ok(H5::new(PathBuf::from(arg), file, size))
Ok(H5::new(PathBuf::from(&name), file, size))
}
#[cfg(not(feature = "remote"))]
{
@@ -777,6 +779,28 @@ pub fn split_file_arg(arg: &str) -> (String, Option<String>) {
(arg.to_string(), None)
}
/// A FILE argument as it may be printed: a URL without its credentials
/// (userinfo, query string values — a presigned URL's signature), a path
/// as given.
pub fn shown(arg: &str) -> String {
if !is_url(arg) {
return arg.to_string();
}
#[cfg(feature = "remote")]
{
clawhdf5_remote::redact_url(arg)
}
#[cfg(not(feature = "remote"))]
{
let (scheme, rest) = arg.split_once("://").unwrap_or(("", arg));
let rest = rest.split(['?', '#']).next().unwrap_or("");
let host_end = rest.find('/').unwrap_or(rest.len());
let (authority, path) = rest.split_at(host_end);
let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
format!("{scheme}://{host}{path}")
}
}
/// Whether a FILE argument is a URL (`scheme://...`) rather than a path.
pub fn is_url(arg: &str) -> bool {
arg.split_once("://").is_some_and(|(scheme, _)| {
+1 -1
View File
@@ -206,7 +206,7 @@ pub fn run(args: &mut Args, out: &mut Out) -> std::io::Result<i32> {
if let Err(e) = walk {
errors.push(e.to_string());
}
report(&h5, &file, &s, out)?;
report(&h5, &crate::h5::shown(&file), &s, out)?;
for e in &errors {
writeln!(out.e, "h5rs stat: {e}")?;
}
+39
View File
@@ -119,3 +119,42 @@ fn check_refuses_a_remote_file_beyond_the_download_limit() {
assert_eq!(rc, 2, "{out}");
assert!(out.contains("more than the download limit"), "{out}");
}
/// A URL's credentials (userinfo, a presigned URL's query string) are not
/// printed: not in errors, not in the file name of the output.
#[test]
fn credentials_in_urls_are_not_printed() {
let tall = Path::new(env!("CARGO_MANIFEST_DIR")).join("../clawhdf5/tests/fixtures/tall.h5");
let server = server::Server::start(vec![("/t.h5".into(), std::fs::read(&tall).unwrap())]);
let url = |path: &str| {
format!(
"http://user:hunter2@{}{path}?X-Amz-Signature=SECRETSIG",
server.addr
)
};
for args in [
vec!["ls", "-r"],
vec!["dump"],
vec!["stat"],
vec!["check"],
vec!["check", "--max-download", "10"],
] {
for path in ["/t.h5", "/missing.h5"] {
let u = url(path);
let mut a = args.clone();
a.push(&u);
let (out, _) = h5rs(&a);
assert!(
!out.contains("hunter2") && !out.contains("SECRETSIG"),
"h5rs {}: {out}",
a.join(" ")
);
}
}
let (out, rc) = h5rs(&["diff", tall.to_str().unwrap(), &url("/t.h5"), "/nope"]);
assert_eq!(rc, 2, "{out}");
assert!(
!out.contains("hunter2") && !out.contains("SECRETSIG"),
"{out}"
);
}