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:
@@ -2,8 +2,126 @@
|
|||||||
|
|
||||||
use clawhdf5_format::error::FormatError;
|
use clawhdf5_format::error::FormatError;
|
||||||
|
|
||||||
|
/// `url` as it may be shown in an error, a `Debug` output or a log: no
|
||||||
|
/// userinfo (`user:password@`), no fragment, and the query string's values
|
||||||
|
/// replaced by `REDACTED` (a presigned S3/GCS/Azure URL carries its
|
||||||
|
/// signature or token there). Keys are kept when they look like plain
|
||||||
|
/// names, so a message still says which kind of URL it was.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// assert_eq!(
|
||||||
|
/// clawhdf5_remote::redact_url("https://me:pw@host/f.h5?X-Amz-Signature=abc&a=1#x"),
|
||||||
|
/// "https://host/f.h5?X-Amz-Signature=REDACTED&a=REDACTED"
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
|
pub fn redact_url(url: &str) -> String {
|
||||||
|
let (scheme, rest) = match url.split_once("://") {
|
||||||
|
Some((s, r)) => (Some(s), r),
|
||||||
|
None => (None, url),
|
||||||
|
};
|
||||||
|
let rest = rest.split('#').next().unwrap_or("");
|
||||||
|
let (before_query, query) = match rest.split_once('?') {
|
||||||
|
Some((a, q)) => (a, Some(q)),
|
||||||
|
None => (rest, None),
|
||||||
|
};
|
||||||
|
let mut out = String::with_capacity(url.len());
|
||||||
|
if let Some(s) = scheme {
|
||||||
|
out.push_str(s);
|
||||||
|
out.push_str("://");
|
||||||
|
let auth_end = before_query.find('/').unwrap_or(before_query.len());
|
||||||
|
let (authority, path) = before_query.split_at(auth_end);
|
||||||
|
out.push_str(
|
||||||
|
authority
|
||||||
|
.rsplit_once('@')
|
||||||
|
.map_or(authority, |(_, host)| host),
|
||||||
|
);
|
||||||
|
out.push_str(path);
|
||||||
|
} else {
|
||||||
|
out.push_str(before_query);
|
||||||
|
}
|
||||||
|
if let Some(q) = query {
|
||||||
|
out.push('?');
|
||||||
|
let plain = |k: &str| {
|
||||||
|
!k.is_empty()
|
||||||
|
&& k.len() <= 64
|
||||||
|
&& k.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
|
||||||
|
};
|
||||||
|
let parts: Vec<String> = q
|
||||||
|
.split('&')
|
||||||
|
.map(|kv| {
|
||||||
|
let k = kv.split('=').next().unwrap_or("");
|
||||||
|
if plain(k) {
|
||||||
|
format!("{k}=REDACTED")
|
||||||
|
} else {
|
||||||
|
"REDACTED".to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
out.push_str(&parts.join("&"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the secret parts of one URL (its userinfo and query string,
|
||||||
|
/// and the URL itself) wherever they appear in a message — such as the
|
||||||
|
/// text of an error from the HTTP client.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct Redactor {
|
||||||
|
shown: String,
|
||||||
|
secrets: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Redactor {
|
||||||
|
pub(crate) fn new(url: &str) -> Redactor {
|
||||||
|
let shown = redact_url(url);
|
||||||
|
let mut secrets = vec![(url.to_string(), shown.clone())];
|
||||||
|
let rest = url.split_once("://").map_or(url, |(_, r)| r);
|
||||||
|
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
|
||||||
|
if let Some((userinfo, _)) = authority.rsplit_once('@')
|
||||||
|
&& !userinfo.is_empty()
|
||||||
|
{
|
||||||
|
secrets.push((format!("{userinfo}@"), String::new()));
|
||||||
|
secrets.push((userinfo.to_string(), "REDACTED".into()));
|
||||||
|
}
|
||||||
|
if let Some((_, q)) = rest.split('#').next().unwrap_or("").split_once('?')
|
||||||
|
&& !q.is_empty()
|
||||||
|
{
|
||||||
|
let shown_q = shown.split_once('?').map_or("", |(_, q)| q).to_string();
|
||||||
|
secrets.push((q.to_string(), shown_q));
|
||||||
|
for kv in q.split('&') {
|
||||||
|
if let Some((_, v)) = kv.split_once('=')
|
||||||
|
&& v.len() >= 4
|
||||||
|
{
|
||||||
|
secrets.push((v.to_string(), "REDACTED".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Redactor { shown, secrets }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The URL, redacted.
|
||||||
|
pub(crate) fn shown(&self) -> &str {
|
||||||
|
&self.shown
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `msg` with every secret part of the URL replaced.
|
||||||
|
pub(crate) fn scrub(&self, msg: &str) -> String {
|
||||||
|
let mut m = msg.to_string();
|
||||||
|
for (secret, with) in &self.secrets {
|
||||||
|
if m.contains(secret.as_str()) {
|
||||||
|
m = m.replace(secret.as_str(), with);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Why a remote file could not be opened or read.
|
/// Why a remote file could not be opened or read.
|
||||||
///
|
///
|
||||||
|
/// No message carries a URL's credentials: URLs appear as
|
||||||
|
/// [`redact_url`] shows them.
|
||||||
|
///
|
||||||
/// Inside a [`clawhdf5::File`] read these arrive as
|
/// Inside a [`clawhdf5::File`] read these arrive as
|
||||||
/// `clawhdf5::Error::Format(FormatError::Storage(message))`, the message
|
/// `clawhdf5::Error::Format(FormatError::Storage(message))`, the message
|
||||||
/// being this error's `Display`.
|
/// being this error's `Display`.
|
||||||
@@ -54,6 +172,28 @@ pub enum RemoteError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RemoteError {
|
impl RemoteError {
|
||||||
|
/// The error with every secret part of `r`'s URL scrubbed from its text.
|
||||||
|
#[cfg_attr(not(any(feature = "http", feature = "object-store")), allow(dead_code))]
|
||||||
|
pub(crate) fn scrubbed(self, r: &Redactor) -> RemoteError {
|
||||||
|
let f = |s: String| r.scrub(&s);
|
||||||
|
match self {
|
||||||
|
RemoteError::InvalidUrl(s) => RemoteError::InvalidUrl(f(s)),
|
||||||
|
RemoteError::UnsupportedScheme(s) => RemoteError::UnsupportedScheme(f(s)),
|
||||||
|
RemoteError::RangeNotSupported(s) => RemoteError::RangeNotSupported(f(s)),
|
||||||
|
RemoteError::FileChanged(s) => RemoteError::FileChanged(f(s)),
|
||||||
|
RemoteError::Status { code, what } => RemoteError::Status {
|
||||||
|
code,
|
||||||
|
what: f(what),
|
||||||
|
},
|
||||||
|
RemoteError::BadResponse(s) => RemoteError::BadResponse(f(s)),
|
||||||
|
RemoteError::Transport(s) => RemoteError::Transport(f(s)),
|
||||||
|
RemoteError::ObjectStore(s) => RemoteError::ObjectStore(f(s)),
|
||||||
|
RemoteError::Usage(s) => RemoteError::Usage(f(s)),
|
||||||
|
e @ RemoteError::TooLarge { .. } => e,
|
||||||
|
RemoteError::Backend(s) => RemoteError::Backend(f(s)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether retrying the same request may succeed.
|
/// Whether retrying the same request may succeed.
|
||||||
#[cfg_attr(not(feature = "http"), allow(dead_code))]
|
#[cfg_attr(not(feature = "http"), allow(dead_code))]
|
||||||
pub(crate) fn is_transient(&self) -> bool {
|
pub(crate) fn is_transient(&self) -> bool {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ use std::time::Duration;
|
|||||||
use clawhdf5_format::error::FormatError;
|
use clawhdf5_format::error::FormatError;
|
||||||
use clawhdf5_format::storage::Storage;
|
use clawhdf5_format::storage::Storage;
|
||||||
|
|
||||||
use crate::error::RemoteError;
|
use crate::error::{Redactor, RemoteError};
|
||||||
|
|
||||||
/// Settings of an [`HttpStorage`].
|
/// Settings of an [`HttpStorage`].
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -107,7 +107,10 @@ enum Validator {
|
|||||||
/// documentation](self).
|
/// documentation](self).
|
||||||
pub struct HttpStorage {
|
pub struct HttpStorage {
|
||||||
agent: ureq::Agent,
|
agent: ureq::Agent,
|
||||||
|
/// The URL as given, credentials and all: only ever sent to the server.
|
||||||
url: String,
|
url: String,
|
||||||
|
/// Shows the URL without its credentials, in every message.
|
||||||
|
redactor: Redactor,
|
||||||
len: u64,
|
len: u64,
|
||||||
validator: Validator,
|
validator: Validator,
|
||||||
options: HttpOptions,
|
options: HttpOptions,
|
||||||
@@ -122,7 +125,7 @@ pub struct HttpStorage {
|
|||||||
impl std::fmt::Debug for HttpStorage {
|
impl std::fmt::Debug for HttpStorage {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.debug_struct("HttpStorage")
|
f.debug_struct("HttpStorage")
|
||||||
.field("url", &self.url)
|
.field("url", &self.redactor.shown())
|
||||||
.field("len", &self.len)
|
.field("len", &self.len)
|
||||||
.field("validator", &self.validator)
|
.field("validator", &self.validator)
|
||||||
.field("full_download", &self.full.is_some())
|
.field("full_download", &self.full.is_some())
|
||||||
@@ -181,15 +184,25 @@ impl HttpStorage {
|
|||||||
/// and the bytes that request fetched (the file's start), for a
|
/// and the bytes that request fetched (the file's start), for a
|
||||||
/// [`BlockCache`](crate::BlockCache) to keep.
|
/// [`BlockCache`](crate::BlockCache) to keep.
|
||||||
pub fn open(url: &str, options: HttpOptions) -> Result<(HttpStorage, Vec<u8>), RemoteError> {
|
pub fn open(url: &str, options: HttpOptions) -> Result<(HttpStorage, Vec<u8>), RemoteError> {
|
||||||
|
let redactor = Redactor::new(url);
|
||||||
|
HttpStorage::open_inner(url, options, &redactor).map_err(|e| e.scrubbed(&redactor))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_inner(
|
||||||
|
url: &str,
|
||||||
|
options: HttpOptions,
|
||||||
|
redactor: &Redactor,
|
||||||
|
) -> Result<(HttpStorage, Vec<u8>), RemoteError> {
|
||||||
|
let shown = redactor.shown();
|
||||||
let lower = url.to_ascii_lowercase();
|
let lower = url.to_ascii_lowercase();
|
||||||
if lower.starts_with("https://") {
|
if lower.starts_with("https://") {
|
||||||
if !cfg!(feature = "https") {
|
if !cfg!(feature = "https") {
|
||||||
return Err(RemoteError::UnsupportedScheme(format!(
|
return Err(RemoteError::UnsupportedScheme(format!(
|
||||||
"{url}: https:// needs the `https` feature of clawhdf5-remote"
|
"{shown}: https:// needs the `https` feature of clawhdf5-remote"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
} else if !lower.starts_with("http://") {
|
} else if !lower.starts_with("http://") {
|
||||||
return Err(RemoteError::UnsupportedScheme(url.to_string()));
|
return Err(RemoteError::UnsupportedScheme(shown.to_string()));
|
||||||
}
|
}
|
||||||
let config = ureq::Agent::config_builder()
|
let config = ureq::Agent::config_builder()
|
||||||
.http_status_as_error(false)
|
.http_status_as_error(false)
|
||||||
@@ -198,6 +211,7 @@ impl HttpStorage {
|
|||||||
let mut storage = HttpStorage {
|
let mut storage = HttpStorage {
|
||||||
agent: ureq::Agent::new_with_config(config),
|
agent: ureq::Agent::new_with_config(config),
|
||||||
url: url.to_string(),
|
url: url.to_string(),
|
||||||
|
redactor: redactor.clone(),
|
||||||
len: 0,
|
len: 0,
|
||||||
validator: Validator::None,
|
validator: Validator::None,
|
||||||
options,
|
options,
|
||||||
@@ -216,14 +230,15 @@ impl HttpStorage {
|
|||||||
}
|
}
|
||||||
if storage.options.require_validator && storage.validator == Validator::None {
|
if storage.options.require_validator && storage.validator == Validator::None {
|
||||||
return Err(RemoteError::Usage(format!(
|
return Err(RemoteError::Usage(format!(
|
||||||
"{url}: the server sends neither a strong ETag nor Last-Modified, so a change \
|
"{shown}: the server sends neither a strong ETag nor Last-Modified, so a change \
|
||||||
of the file could not be detected (HttpOptions::require_validator)"
|
of the file could not be detected (HttpOptions::require_validator)"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Ok((storage, bytes))
|
Ok((storage, bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The URL.
|
/// The URL as given — with any credentials it carries, so do not log
|
||||||
|
/// it; [`redact_url`](crate::redact_url) gives a form that can be.
|
||||||
pub fn url(&self) -> &str {
|
pub fn url(&self) -> &str {
|
||||||
&self.url
|
&self.url
|
||||||
}
|
}
|
||||||
@@ -307,18 +322,20 @@ impl HttpStorage {
|
|||||||
let got = reader
|
let got = reader
|
||||||
.take(cap.saturating_add(1))
|
.take(cap.saturating_add(1))
|
||||||
.read_to_end(&mut buf)
|
.read_to_end(&mut buf)
|
||||||
.map_err(|e| RemoteError::Transport(format!("{}: reading the body: {e}", self.url)));
|
.map_err(|e| {
|
||||||
|
RemoteError::Transport(format!("{}: reading the body: {e}", self.redactor.shown()))
|
||||||
|
});
|
||||||
self.bytes.fetch_add(buf.len() as u64, Ordering::Relaxed);
|
self.bytes.fetch_add(buf.len() as u64, Ordering::Relaxed);
|
||||||
got?;
|
got?;
|
||||||
match want {
|
match want {
|
||||||
Some(n) if buf.len() as u64 != n => Err(RemoteError::BadResponse(format!(
|
Some(n) if buf.len() as u64 != n => Err(RemoteError::BadResponse(format!(
|
||||||
"{}: body of {} bytes, expected {n}",
|
"{}: body of {} bytes, expected {n}",
|
||||||
self.url,
|
self.redactor.shown(),
|
||||||
buf.len()
|
buf.len()
|
||||||
))),
|
))),
|
||||||
None if buf.len() as u64 > limit => Err(RemoteError::Usage(format!(
|
None if buf.len() as u64 > limit => Err(RemoteError::Usage(format!(
|
||||||
"{}: the file is larger than HttpOptions::max_full_download ({limit} bytes)",
|
"{}: the file is larger than HttpOptions::max_full_download ({limit} bytes)",
|
||||||
self.url
|
self.redactor.shown()
|
||||||
))),
|
))),
|
||||||
_ => Ok(buf),
|
_ => Ok(buf),
|
||||||
}
|
}
|
||||||
@@ -332,7 +349,7 @@ impl HttpStorage {
|
|||||||
self.requests.fetch_add(1, Ordering::Relaxed);
|
self.requests.fetch_add(1, Ordering::Relaxed);
|
||||||
let resp = self.request(Some((0, n - 1))).call().map_err(transport)?;
|
let resp = self.request(Some((0, n - 1))).call().map_err(transport)?;
|
||||||
let status = resp.status().as_u16();
|
let status = resp.status().as_u16();
|
||||||
check_identity(&self.url, &resp)?;
|
check_identity(self.redactor.shown(), &resp)?;
|
||||||
let validator = match (header(&resp, "etag"), header(&resp, "last-modified")) {
|
let validator = match (header(&resp, "etag"), header(&resp, "last-modified")) {
|
||||||
(Some(e), _) if !e.starts_with("W/") => Validator::ETag(e.to_string()),
|
(Some(e), _) if !e.starts_with("W/") => Validator::ETag(e.to_string()),
|
||||||
(_, Some(t)) => Validator::LastModified(t.to_string()),
|
(_, Some(t)) => Validator::LastModified(t.to_string()),
|
||||||
@@ -341,21 +358,27 @@ impl HttpStorage {
|
|||||||
match status {
|
match status {
|
||||||
206 => {
|
206 => {
|
||||||
let cr = header(&resp, "content-range").ok_or_else(|| {
|
let cr = header(&resp, "content-range").ok_or_else(|| {
|
||||||
RemoteError::BadResponse(format!("{}: 206 without Content-Range", self.url))
|
RemoteError::BadResponse(format!(
|
||||||
|
"{}: 206 without Content-Range",
|
||||||
|
self.redactor.shown()
|
||||||
|
))
|
||||||
})?;
|
})?;
|
||||||
let (a, b, total) = content_range(cr).ok_or_else(|| {
|
let (a, b, total) = content_range(cr).ok_or_else(|| {
|
||||||
RemoteError::BadResponse(format!("{}: bad Content-Range {cr:?}", self.url))
|
RemoteError::BadResponse(format!(
|
||||||
|
"{}: bad Content-Range {cr:?}",
|
||||||
|
self.redactor.shown()
|
||||||
|
))
|
||||||
})?;
|
})?;
|
||||||
let total = total.ok_or_else(|| {
|
let total = total.ok_or_else(|| {
|
||||||
RemoteError::BadResponse(format!(
|
RemoteError::BadResponse(format!(
|
||||||
"{}: the server does not report the file's length (Content-Range {cr:?})",
|
"{}: the server does not report the file's length (Content-Range {cr:?})",
|
||||||
self.url
|
self.redactor.shown()
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
if a != 0 || b >= total || b > n - 1 {
|
if a != 0 || b >= total || b > n - 1 {
|
||||||
return Err(RemoteError::BadResponse(format!(
|
return Err(RemoteError::BadResponse(format!(
|
||||||
"{}: asked for bytes 0-{}, got Content-Range {cr:?}",
|
"{}: asked for bytes 0-{}, got Content-Range {cr:?}",
|
||||||
self.url,
|
self.redactor.shown(),
|
||||||
n - 1
|
n - 1
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -367,14 +390,15 @@ impl HttpStorage {
|
|||||||
return Err(RemoteError::RangeNotSupported(format!(
|
return Err(RemoteError::RangeNotSupported(format!(
|
||||||
"{} answered a range request with the whole file (status 200); set \
|
"{} answered a range request with the whole file (status 200); set \
|
||||||
HttpOptions::allow_full_download to download it",
|
HttpOptions::allow_full_download to download it",
|
||||||
self.url
|
self.redactor.shown()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let want = header(&resp, "content-length").and_then(|v| v.trim().parse().ok());
|
let want = header(&resp, "content-length").and_then(|v| v.trim().parse().ok());
|
||||||
if want.is_some_and(|w: u64| w > self.options.max_full_download) {
|
if want.is_some_and(|w: u64| w > self.options.max_full_download) {
|
||||||
return Err(RemoteError::Usage(format!(
|
return Err(RemoteError::Usage(format!(
|
||||||
"{}: the file is larger than HttpOptions::max_full_download ({} bytes)",
|
"{}: the file is larger than HttpOptions::max_full_download ({} bytes)",
|
||||||
self.url, self.options.max_full_download
|
self.redactor.shown(),
|
||||||
|
self.options.max_full_download
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let bytes = self.body(resp, want, self.options.max_full_download)?;
|
let bytes = self.body(resp, want, self.options.max_full_download)?;
|
||||||
@@ -382,11 +406,11 @@ impl HttpStorage {
|
|||||||
}
|
}
|
||||||
416 => Err(RemoteError::Usage(format!(
|
416 => Err(RemoteError::Usage(format!(
|
||||||
"{}: status 416 for the first bytes (an empty file?)",
|
"{}: status 416 for the first bytes (an empty file?)",
|
||||||
self.url
|
self.redactor.shown()
|
||||||
))),
|
))),
|
||||||
code => Err(RemoteError::Status {
|
code => Err(RemoteError::Status {
|
||||||
code,
|
code,
|
||||||
what: self.url.clone(),
|
what: self.redactor.shown().to_string(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,14 +422,15 @@ impl HttpStorage {
|
|||||||
.request(Some((start, end - 1)))
|
.request(Some((start, end - 1)))
|
||||||
.call()
|
.call()
|
||||||
.map_err(transport)?;
|
.map_err(transport)?;
|
||||||
let changed = |why: String| RemoteError::FileChanged(format!("{}: {why}", self.url));
|
let changed =
|
||||||
check_identity(&self.url, &resp)?;
|
|why: String| RemoteError::FileChanged(format!("{}: {why}", self.redactor.shown()));
|
||||||
|
check_identity(self.redactor.shown(), &resp)?;
|
||||||
match resp.status().as_u16() {
|
match resp.status().as_u16() {
|
||||||
206 => {}
|
206 => {}
|
||||||
200 => {
|
200 => {
|
||||||
return Err(RemoteError::RangeNotSupported(format!(
|
return Err(RemoteError::RangeNotSupported(format!(
|
||||||
"{} answered a range request with the whole file (status 200)",
|
"{} answered a range request with the whole file (status 200)",
|
||||||
self.url
|
self.redactor.shown()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
412 => {
|
412 => {
|
||||||
@@ -417,7 +442,7 @@ impl HttpStorage {
|
|||||||
code => {
|
code => {
|
||||||
return Err(RemoteError::Status {
|
return Err(RemoteError::Status {
|
||||||
code,
|
code,
|
||||||
what: format!("{} bytes {start}-{}", self.url, end - 1),
|
what: format!("{} bytes {start}-{}", self.redactor.shown(), end - 1),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -439,10 +464,16 @@ impl HttpStorage {
|
|||||||
Validator::None => {}
|
Validator::None => {}
|
||||||
}
|
}
|
||||||
let cr = header(&resp, "content-range").ok_or_else(|| {
|
let cr = header(&resp, "content-range").ok_or_else(|| {
|
||||||
RemoteError::BadResponse(format!("{}: 206 without Content-Range", self.url))
|
RemoteError::BadResponse(format!(
|
||||||
|
"{}: 206 without Content-Range",
|
||||||
|
self.redactor.shown()
|
||||||
|
))
|
||||||
})?;
|
})?;
|
||||||
let (a, b, total) = content_range(cr).ok_or_else(|| {
|
let (a, b, total) = content_range(cr).ok_or_else(|| {
|
||||||
RemoteError::BadResponse(format!("{}: bad Content-Range {cr:?}", self.url))
|
RemoteError::BadResponse(format!(
|
||||||
|
"{}: bad Content-Range {cr:?}",
|
||||||
|
self.redactor.shown()
|
||||||
|
))
|
||||||
})?;
|
})?;
|
||||||
if let Some(total) = total
|
if let Some(total) = total
|
||||||
&& total != self.len
|
&& total != self.len
|
||||||
@@ -452,7 +483,7 @@ impl HttpStorage {
|
|||||||
if a != start || b != end - 1 {
|
if a != start || b != end - 1 {
|
||||||
return Err(RemoteError::BadResponse(format!(
|
return Err(RemoteError::BadResponse(format!(
|
||||||
"{}: asked for bytes {start}-{}, got Content-Range {cr:?}",
|
"{}: asked for bytes {start}-{}, got Content-Range {cr:?}",
|
||||||
self.url,
|
self.redactor.shown(),
|
||||||
end - 1
|
end - 1
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
@@ -461,6 +492,7 @@ impl HttpStorage {
|
|||||||
|
|
||||||
fn fetch(&self, start: u64, end: u64) -> Result<Vec<u8>, RemoteError> {
|
fn fetch(&self, start: u64, end: u64) -> Result<Vec<u8>, RemoteError> {
|
||||||
self.with_retries(|| self.fetch_once(start, end))
|
self.with_retries(|| self.fetch_once(start, end))
|
||||||
|
.map_err(|e| e.scrubbed(&self.redactor))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `[offset, offset + len)` clamped to the file, or `None` if empty.
|
/// `[offset, offset + len)` clamped to the file, or `None` if empty.
|
||||||
@@ -539,7 +571,7 @@ impl Storage for HttpStorage {
|
|||||||
if out.len() != ranges.len() {
|
if out.len() != ranges.len() {
|
||||||
return Err(FormatError::Storage(format!(
|
return Err(FormatError::Storage(format!(
|
||||||
"{}: a parallel range read failed",
|
"{}: a parallel range read failed",
|
||||||
self.url
|
self.redactor.shown()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ use clawhdf5::File;
|
|||||||
use clawhdf5_format::storage::Storage;
|
use clawhdf5_format::storage::Storage;
|
||||||
|
|
||||||
pub use cache::{BlockCache, CacheConfig, CacheStats};
|
pub use cache::{BlockCache, CacheConfig, CacheStats};
|
||||||
pub use error::{Error, RemoteError};
|
pub use error::{Error, RemoteError, redact_url};
|
||||||
#[cfg(feature = "http")]
|
#[cfg(feature = "http")]
|
||||||
pub use http::{HttpOptions, HttpStats, HttpStorage};
|
pub use http::{HttpOptions, HttpStats, HttpStorage};
|
||||||
#[cfg(feature = "object-store")]
|
#[cfg(feature = "object-store")]
|
||||||
@@ -93,13 +93,13 @@ pub fn storage_for_url(url: &str, options: &Options) -> Result<Arc<RemoteStorage
|
|||||||
let scheme = url
|
let scheme = url
|
||||||
.split_once("://")
|
.split_once("://")
|
||||||
.map(|(s, _)| s.to_ascii_lowercase())
|
.map(|(s, _)| s.to_ascii_lowercase())
|
||||||
.ok_or_else(|| RemoteError::InvalidUrl(format!("{url}: no scheme")))?;
|
.ok_or_else(|| RemoteError::InvalidUrl(format!("{}: no scheme", redact_url(url))))?;
|
||||||
match scheme.as_str() {
|
match scheme.as_str() {
|
||||||
"http" | "https" => http_storage(url, options),
|
"http" | "https" => http_storage(url, options),
|
||||||
"s3" | "s3a" | "gs" | "az" | "azure" | "abfs" | "abfss" | "adl" => {
|
"s3" | "s3a" | "gs" | "az" | "azure" | "abfs" | "abfss" | "adl" => {
|
||||||
cloud_storage(url, &scheme, options)
|
cloud_storage(url, &scheme, options)
|
||||||
}
|
}
|
||||||
_ => Err(RemoteError::UnsupportedScheme(url.to_string()).into()),
|
_ => Err(RemoteError::UnsupportedScheme(redact_url(url)).into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +116,8 @@ fn http_storage(url: &str, options: &Options) -> Result<Arc<RemoteStorage>, Erro
|
|||||||
#[cfg(not(feature = "http"))]
|
#[cfg(not(feature = "http"))]
|
||||||
fn http_storage(url: &str, _options: &Options) -> Result<Arc<RemoteStorage>, Error> {
|
fn http_storage(url: &str, _options: &Options) -> Result<Arc<RemoteStorage>, Error> {
|
||||||
Err(RemoteError::UnsupportedScheme(format!(
|
Err(RemoteError::UnsupportedScheme(format!(
|
||||||
"{url}: http(s):// needs the `http` feature of clawhdf5-remote"
|
"{}: http(s):// needs the `http` feature of clawhdf5-remote",
|
||||||
|
redact_url(url)
|
||||||
))
|
))
|
||||||
.into())
|
.into())
|
||||||
}
|
}
|
||||||
@@ -136,7 +137,8 @@ fn cloud_storage(url: &str, scheme: &str, _options: &Options) -> Result<Arc<Remo
|
|||||||
_ => "azure",
|
_ => "azure",
|
||||||
};
|
};
|
||||||
Err(RemoteError::UnsupportedScheme(format!(
|
Err(RemoteError::UnsupportedScheme(format!(
|
||||||
"{url}: {scheme}:// needs the `{feature}` feature of clawhdf5-remote"
|
"{}: {scheme}:// needs the `{feature}` feature of clawhdf5-remote",
|
||||||
|
redact_url(url)
|
||||||
))
|
))
|
||||||
.into())
|
.into())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,13 +239,14 @@ impl Storage for ObjectStoreStorage {
|
|||||||
/// `GOOGLE_*`, `AZURE_*`).
|
/// `GOOGLE_*`, `AZURE_*`).
|
||||||
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
|
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
|
||||||
pub(crate) fn store_for_url(url: &str) -> Result<(Arc<dyn ObjectStore>, Path), RemoteError> {
|
pub(crate) fn store_for_url(url: &str) -> Result<(Arc<dyn ObjectStore>, Path), RemoteError> {
|
||||||
|
let redactor = crate::error::Redactor::new(url);
|
||||||
let parsed = object_store::path::Path::parse(
|
let parsed = object_store::path::Path::parse(
|
||||||
url.split_once("://")
|
url.split_once("://")
|
||||||
.and_then(|(_, rest)| rest.split_once('/'))
|
.and_then(|(_, rest)| rest.split_once('/'))
|
||||||
.map(|(_, key)| key)
|
.map(|(_, key)| key)
|
||||||
.unwrap_or(""),
|
.unwrap_or(""),
|
||||||
)
|
)
|
||||||
.map_err(|e| RemoteError::InvalidUrl(format!("{url}: {e}")))?;
|
.map_err(|e| RemoteError::InvalidUrl(format!("{}: {e}", crate::redact_url(url))))?;
|
||||||
let scheme = url.split_once("://").map(|(s, _)| s.to_ascii_lowercase());
|
let scheme = url.split_once("://").map(|(s, _)| s.to_ascii_lowercase());
|
||||||
let store: Arc<dyn ObjectStore> = match scheme.as_deref() {
|
let store: Arc<dyn ObjectStore> = match scheme.as_deref() {
|
||||||
#[cfg(feature = "s3")]
|
#[cfg(feature = "s3")]
|
||||||
@@ -253,23 +254,23 @@ pub(crate) fn store_for_url(url: &str) -> Result<(Arc<dyn ObjectStore>, Path), R
|
|||||||
object_store::aws::AmazonS3Builder::from_env()
|
object_store::aws::AmazonS3Builder::from_env()
|
||||||
.with_url(url)
|
.with_url(url)
|
||||||
.build()
|
.build()
|
||||||
.map_err(os_error)?,
|
.map_err(|e| os_error(e).scrubbed(&redactor))?,
|
||||||
),
|
),
|
||||||
#[cfg(feature = "gcs")]
|
#[cfg(feature = "gcs")]
|
||||||
Some("gs") => Arc::new(
|
Some("gs") => Arc::new(
|
||||||
object_store::gcp::GoogleCloudStorageBuilder::from_env()
|
object_store::gcp::GoogleCloudStorageBuilder::from_env()
|
||||||
.with_url(url)
|
.with_url(url)
|
||||||
.build()
|
.build()
|
||||||
.map_err(os_error)?,
|
.map_err(|e| os_error(e).scrubbed(&redactor))?,
|
||||||
),
|
),
|
||||||
#[cfg(feature = "azure")]
|
#[cfg(feature = "azure")]
|
||||||
Some("az" | "azure" | "abfs" | "abfss" | "adl") => Arc::new(
|
Some("az" | "azure" | "abfs" | "abfss" | "adl") => Arc::new(
|
||||||
object_store::azure::MicrosoftAzureBuilder::from_env()
|
object_store::azure::MicrosoftAzureBuilder::from_env()
|
||||||
.with_url(url)
|
.with_url(url)
|
||||||
.build()
|
.build()
|
||||||
.map_err(os_error)?,
|
.map_err(|e| os_error(e).scrubbed(&redactor))?,
|
||||||
),
|
),
|
||||||
_ => return Err(RemoteError::UnsupportedScheme(url.to_string())),
|
_ => return Err(RemoteError::UnsupportedScheme(crate::redact_url(url))),
|
||||||
};
|
};
|
||||||
Ok((store, parsed))
|
Ok((store, parsed))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ pub struct Shared {
|
|||||||
/// when checking ranges) and serve zeros past its real end: a hostile
|
/// when checking ranges) and serve zeros past its real end: a hostile
|
||||||
/// server lying about the length.
|
/// server lying about the length.
|
||||||
pub fake_total: AtomicU64,
|
pub fake_total: AtomicU64,
|
||||||
|
/// When non-zero, answer every request for a served file with this
|
||||||
|
/// status (and an empty body).
|
||||||
|
pub force_status: AtomicU32,
|
||||||
|
/// Answer ranges with a `Content-Range` one byte off.
|
||||||
|
pub wrong_range: AtomicBool,
|
||||||
/// Requests for a served path (every status); requests for other
|
/// Requests for a served path (every status); requests for other
|
||||||
/// paths are not counted.
|
/// paths are not counted.
|
||||||
pub requests: AtomicU64,
|
pub requests: AtomicU64,
|
||||||
@@ -256,6 +261,11 @@ fn serve(conn: TcpStream, s: &Shared) -> std::io::Result<()> {
|
|||||||
if delay > 0 {
|
if delay > 0 {
|
||||||
std::thread::sleep(Duration::from_millis(delay));
|
std::thread::sleep(Duration::from_millis(delay));
|
||||||
}
|
}
|
||||||
|
let forced = s.force_status.load(Ordering::SeqCst);
|
||||||
|
if forced != 0 {
|
||||||
|
write!(out, "HTTP/1.1 {forced} Forced\r\nContent-Length: 0\r\n\r\n")?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if s.fail_next
|
if s.fail_next
|
||||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| n.checked_sub(1))
|
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| n.checked_sub(1))
|
||||||
.is_ok()
|
.is_ok()
|
||||||
@@ -309,7 +319,11 @@ fn serve(conn: TcpStream, s: &Shared) -> std::io::Result<()> {
|
|||||||
Some(Ok((a, b))) => (
|
Some(Ok((a, b))) => (
|
||||||
"206 Partial Content",
|
"206 Partial Content",
|
||||||
slice_or_zeros(&data, a, b, &mut padded),
|
slice_or_zeros(&data, a, b, &mut padded),
|
||||||
format!("Content-Range: bytes {a}-{b}/{len}\r\n"),
|
if s.wrong_range.load(Ordering::SeqCst) {
|
||||||
|
format!("Content-Range: bytes {}-{}/{len}\r\n", a + 1, b + 1)
|
||||||
|
} else {
|
||||||
|
format!("Content-Range: bytes {a}-{b}/{len}\r\n")
|
||||||
|
},
|
||||||
),
|
),
|
||||||
None => ("200 OK", &data[..], String::new()),
|
None => ("200 OK", &data[..], String::new()),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -604,3 +604,121 @@ fn download_is_bounded_by_its_limit_not_the_claimed_length() {
|
|||||||
);
|
);
|
||||||
assert_eq!(server.requests(), 0, "refused before reading");
|
assert_eq!(server.requests(), 0, "refused before reading");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A URL's credentials — userinfo, and the query string of a presigned
|
||||||
|
/// URL — never appear in an error message or a `Debug` output, whatever
|
||||||
|
/// failed.
|
||||||
|
#[test]
|
||||||
|
fn credentials_never_appear_in_errors_or_debug() {
|
||||||
|
const SECRETS: [&str; 4] = ["hunter2", "SECRETSIG", "AKIDSECRET", "user:"];
|
||||||
|
fn clean(what: &str, text: &str) {
|
||||||
|
for s in SECRETS {
|
||||||
|
assert!(!text.contains(s), "{what}: {s} leaked in {text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn check_err<T>(what: &str, r: Result<T, Error>) {
|
||||||
|
let Err(e) = r else {
|
||||||
|
panic!("{what}: expected an error")
|
||||||
|
};
|
||||||
|
clean(what, &format!("{e}"));
|
||||||
|
clean(what, &format!("{e:?}"));
|
||||||
|
}
|
||||||
|
let bytes = multi_block_file();
|
||||||
|
let server = Server::start(vec![("/m.h5".into(), bytes.clone())]);
|
||||||
|
let secret_url = |path: &str| {
|
||||||
|
format!(
|
||||||
|
"http://user:hunter2@{}{path}?X-Amz-Credential=AKIDSECRET&X-Amz-Signature=SECRETSIG",
|
||||||
|
server.addr
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let url = secret_url("/m.h5");
|
||||||
|
let mut opts = quick();
|
||||||
|
opts.http.retries = 0;
|
||||||
|
|
||||||
|
// Opening works through such a URL, and Debug shows it redacted.
|
||||||
|
let (http, _) = HttpStorage::open(&url, opts.http.clone()).unwrap();
|
||||||
|
let debug = format!("{http:?}");
|
||||||
|
clean("Debug", &debug);
|
||||||
|
assert!(debug.contains("X-Amz-Signature=REDACTED"), "{debug}");
|
||||||
|
assert_eq!(http.url(), url, "the URL itself is kept for requests");
|
||||||
|
|
||||||
|
check_err("404", open_url_with(&secret_url("/missing.h5"), &opts));
|
||||||
|
server.shared.force_status.store(403, Ordering::SeqCst);
|
||||||
|
check_err("403", open_url_with(&url, &opts));
|
||||||
|
server.shared.force_status.store(0, Ordering::SeqCst);
|
||||||
|
server.shared.wrong_range.store(true, Ordering::SeqCst);
|
||||||
|
check_err("bad range at open", open_url_with(&url, &opts));
|
||||||
|
server.shared.wrong_range.store(false, Ordering::SeqCst);
|
||||||
|
server.shared.ignore_range.store(true, Ordering::SeqCst);
|
||||||
|
check_err("no range support", open_url_with(&url, &opts));
|
||||||
|
server.shared.ignore_range.store(false, Ordering::SeqCst);
|
||||||
|
server.shared.gzip_label.store(true, Ordering::SeqCst);
|
||||||
|
check_err("encoded body", open_url_with(&url, &opts));
|
||||||
|
server.shared.gzip_label.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
|
// Errors of reads after the open, through a File.
|
||||||
|
let f = open_url_with(&url, &opts).unwrap();
|
||||||
|
server.shared.wrong_range.store(true, Ordering::SeqCst);
|
||||||
|
check_err(
|
||||||
|
"bad range",
|
||||||
|
f.dataset("big")
|
||||||
|
.map(|d| d.read_f64())
|
||||||
|
.and_then(|r| r)
|
||||||
|
.map_err(Error::Hdf5),
|
||||||
|
);
|
||||||
|
server.shared.wrong_range.store(false, Ordering::SeqCst);
|
||||||
|
let f = open_url_with(&url, &opts).unwrap();
|
||||||
|
server.shared.force_status.store(403, Ordering::SeqCst);
|
||||||
|
check_err(
|
||||||
|
"403 on a read",
|
||||||
|
f.dataset("big")
|
||||||
|
.map(|d| d.read_f64())
|
||||||
|
.and_then(|r| r)
|
||||||
|
.map_err(Error::Hdf5),
|
||||||
|
);
|
||||||
|
server.shared.force_status.store(0, Ordering::SeqCst);
|
||||||
|
let f = open_url_with(&url, &opts).unwrap();
|
||||||
|
let mut other = bytes.clone();
|
||||||
|
let n = other.len();
|
||||||
|
other[n / 2] ^= 0xff;
|
||||||
|
server.shared.put("/m.h5", other);
|
||||||
|
check_err(
|
||||||
|
"ETag change",
|
||||||
|
f.dataset("big")
|
||||||
|
.map(|d| d.read_f64())
|
||||||
|
.and_then(|r| r)
|
||||||
|
.map_err(Error::Hdf5),
|
||||||
|
);
|
||||||
|
// A timeout.
|
||||||
|
let mut slow = opts.clone();
|
||||||
|
slow.http.timeout = Duration::from_millis(100);
|
||||||
|
server.shared.delay_ms.store(1000, Ordering::SeqCst);
|
||||||
|
check_err("timeout", open_url_with(&url, &slow));
|
||||||
|
server.shared.delay_ms.store(0, Ordering::SeqCst);
|
||||||
|
// A connection closed unanswered, a bad scheme.
|
||||||
|
let port = {
|
||||||
|
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let port = l.local_addr().unwrap().port();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for c in l.incoming() {
|
||||||
|
drop(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
port
|
||||||
|
};
|
||||||
|
check_err(
|
||||||
|
"connection closed",
|
||||||
|
open_url_with(
|
||||||
|
&format!("http://user:[email protected]:{port}/a.h5?X-Amz-Signature=SECRETSIG"),
|
||||||
|
&opts,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
check_err(
|
||||||
|
"scheme",
|
||||||
|
open_url("ftp://user:[email protected]/a.h5?X-Amz-Signature=SECRETSIG"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
clawhdf5_remote::redact_url("https://me:pw@host:8/d/f.h5?X-Amz-Signature=abc&a=1#frag"),
|
||||||
|
"https://host:8/d/f.h5?X-Amz-Signature=REDACTED&a=REDACTED"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ pub fn run(args: &mut Args, out: &mut Out) -> std::io::Result<i32> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !quiet {
|
if !quiet {
|
||||||
c.summary(&file, out)?;
|
c.summary(&crate::h5::shown(&file), out)?;
|
||||||
}
|
}
|
||||||
Ok(if c.panicked {
|
Ok(if c.panicked {
|
||||||
3
|
3
|
||||||
|
|||||||
@@ -196,7 +196,8 @@ pub fn run(args: &mut Args, out: &mut Out) -> std::io::Result<i32> {
|
|||||||
Err(_) => {
|
Err(_) => {
|
||||||
writeln!(
|
writeln!(
|
||||||
out.e,
|
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);
|
return Ok(2);
|
||||||
}
|
}
|
||||||
@@ -214,7 +215,7 @@ pub fn run(args: &mut Args, out: &mut Out) -> std::io::Result<i32> {
|
|||||||
let entries = match collected {
|
let entries = match collected {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
writeln!(out.e, "h5rs diff: {f}: {e}")?;
|
writeln!(out.e, "h5rs diff: {}: {e}", crate::h5::shown(f))?;
|
||||||
return Ok(2);
|
return Ok(2);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ pub fn run(args: &mut Args, out: &mut Out) -> std::io::Result<i32> {
|
|||||||
problems: 0,
|
problems: 0,
|
||||||
paths: OnceCell::new(),
|
paths: OnceCell::new(),
|
||||||
};
|
};
|
||||||
|
let file = crate::h5::shown(&file);
|
||||||
let fname = std::path::Path::new(&file)
|
let fname = std::path::Path::new(&file)
|
||||||
.file_name()
|
.file_name()
|
||||||
.map(|s| s.to_string_lossy().into_owned())
|
.map(|s| s.to_string_lossy().into_owned())
|
||||||
|
|||||||
@@ -229,20 +229,21 @@ impl H5 {
|
|||||||
if !is_url(arg) {
|
if !is_url(arg) {
|
||||||
return H5::open(Path::new(arg));
|
return H5::open(Path::new(arg));
|
||||||
}
|
}
|
||||||
|
let name = shown(arg);
|
||||||
#[cfg(feature = "remote")]
|
#[cfg(feature = "remote")]
|
||||||
{
|
{
|
||||||
let storage =
|
let storage =
|
||||||
clawhdf5_remote::storage_for_url(arg, &clawhdf5_remote::Options::default())
|
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 size = storage.len();
|
||||||
let file = File::open_storage(storage).map_err(|e| {
|
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"))]
|
#[cfg(not(feature = "remote"))]
|
||||||
Err(Error::new(format!(
|
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() {
|
if h5.file.contiguous_bytes().is_some() {
|
||||||
return Ok(h5);
|
return Ok(h5);
|
||||||
}
|
}
|
||||||
|
let name = shown(arg);
|
||||||
#[cfg(feature = "remote")]
|
#[cfg(feature = "remote")]
|
||||||
{
|
{
|
||||||
let storage =
|
let storage =
|
||||||
clawhdf5_remote::storage_for_url(arg, &clawhdf5_remote::Options::default())
|
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)
|
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 size = bytes.len() as u64;
|
||||||
let file = File::from_bytes(bytes).map_err(|e| {
|
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"))]
|
#[cfg(not(feature = "remote"))]
|
||||||
{
|
{
|
||||||
@@ -777,6 +779,28 @@ pub fn split_file_arg(arg: &str) -> (String, Option<String>) {
|
|||||||
(arg.to_string(), None)
|
(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.
|
/// Whether a FILE argument is a URL (`scheme://...`) rather than a path.
|
||||||
pub fn is_url(arg: &str) -> bool {
|
pub fn is_url(arg: &str) -> bool {
|
||||||
arg.split_once("://").is_some_and(|(scheme, _)| {
|
arg.split_once("://").is_some_and(|(scheme, _)| {
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ pub fn run(args: &mut Args, out: &mut Out) -> std::io::Result<i32> {
|
|||||||
if let Err(e) = walk {
|
if let Err(e) = walk {
|
||||||
errors.push(e.to_string());
|
errors.push(e.to_string());
|
||||||
}
|
}
|
||||||
report(&h5, &file, &s, out)?;
|
report(&h5, &crate::h5::shown(&file), &s, out)?;
|
||||||
for e in &errors {
|
for e in &errors {
|
||||||
writeln!(out.e, "h5rs stat: {e}")?;
|
writeln!(out.e, "h5rs stat: {e}")?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,3 +119,42 @@ fn check_refuses_a_remote_file_beyond_the_download_limit() {
|
|||||||
assert_eq!(rc, 2, "{out}");
|
assert_eq!(rc, 2, "{out}");
|
||||||
assert!(out.contains("more than the download limit"), "{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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user