mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-29 11:15:34 +00:00
volume: reject invalid -minFreeSpace and duration flags at startup like Go (#11447)
* volume: reject invalid -minFreeSpace and duration flags at startup like Go Symptom: the Rust volume server swallowed invalid values for three startup flags and ran with a limit the operator did not ask for. -minFreeSpace=150 became 150 bytes (Go: fatal, percent must be 0..=100), -minFreeSpace=10GiBx and -minFreeSpace=50B fell through to the 1% default (Go: fatal), and -inflightUploadDataTimeout=30sec, =abc or =30 silently became the 60 s default (Go: flag.Duration usage error, exit 2). Go's byte-size units (1,024MB, 42 mib, 100Ki, 1eb) were also not understood and mapped to 1%. Go reference: weed/util/minfreespace.go:73-106 (MustParseMinFreeSpace / ParseMinFreeSpace), weed/util/bytes.go:212-243 (ParseBytes and bytesSizeTable), weed/command/volume.go:226-227 (flag.Duration, i.e. time.ParseDuration grammar). Fix: the two inflight timeout Cli fields are now std::time::Duration with a clap value_parser that ports time.ParseDuration (units ns/us/µs/ μs/ms/s/m/h, fractions, bare 0, int64-nanosecond overflow), so clap prints a usage error naming the value and exits 2 at the same layer as Go's flag package. parse_min_free_spaces returns Result and is built from Go-parity parse_min_free_space / parse_bytes; resolve_config_with_env aborts with Go's "The value specified in -minFreeSpace not a valid value" message, the fail-fast pattern it already uses for --max, --index and --readMode. No new dependency. Negative durations and NaN percent are rejected explicitly (Go quirks with no sane Rust representation). Tests cover the Go fixtures from minfreespace_test.go and bytes_test.go, the duration grammar, and flag-level rejection through Cli::try_parse_from and resolve_config_with_env; the flag-level tests were confirmed to fail against the previous parser. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * volume: say that -minFreeSpace commas separate directories, never thousands Review follow-up. parse_bytes keeps Go's ParseBytes thousands-comma support, but the flag is split on commas into per-directory entries before any entry is parsed (Go's MustParseMinFreeSpace does the same), so -minFreeSpace=1,024MB is the two entries 1 and 024MB. The doc comment and the single-entry tests advertised the comma form as if it were reachable from the flag. Document the split and add a flag-level test: two entries from parse_min_free_spaces, and resolve_config aborting on the directory-count mismatch with one -dir. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * volume: trim comments on the strict flag parsing Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com> Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Chris Lu
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
afce0a3dd3
commit
3e9fc9e75b
+506
-103
@@ -178,12 +178,22 @@ pub struct Cli {
|
||||
pub min_free_space: String,
|
||||
|
||||
/// Inflight upload data wait timeout of volume servers.
|
||||
#[arg(long = "inflightUploadDataTimeout", default_value = "60s")]
|
||||
pub inflight_upload_data_timeout: String,
|
||||
/// Go `time.Duration` syntax: `300ms`, `1.5h`, `2h45m`; a bare number other than `0` is rejected.
|
||||
#[arg(
|
||||
long = "inflightUploadDataTimeout",
|
||||
default_value = "60s",
|
||||
value_parser = parse_go_duration
|
||||
)]
|
||||
pub inflight_upload_data_timeout: std::time::Duration,
|
||||
|
||||
/// Inflight download data wait timeout of volume servers.
|
||||
#[arg(long = "inflightDownloadDataTimeout", default_value = "60s")]
|
||||
pub inflight_download_data_timeout: String,
|
||||
/// Go `time.Duration` syntax: `300ms`, `1.5h`, `2h45m`; a bare number other than `0` is rejected.
|
||||
#[arg(
|
||||
long = "inflightDownloadDataTimeout",
|
||||
default_value = "60s",
|
||||
value_parser = parse_go_duration
|
||||
)]
|
||||
pub inflight_download_data_timeout: std::time::Duration,
|
||||
|
||||
/// <experimental> if true, prevents slow reads from blocking other requests,
|
||||
/// but large file read P99 latency will increase.
|
||||
@@ -471,99 +481,214 @@ fn find_options_arg(args: &[String]) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Parse a duration string like "60s", "5m", "1h" into a std::time::Duration.
|
||||
fn parse_duration(s: &str) -> std::time::Duration {
|
||||
let s = s.trim();
|
||||
/// Parse a duration with Go's `time.ParseDuration` grammar, as Go's
|
||||
/// `flag.Duration` flags do: `[0-9]*(\.[0-9]*)?<unit>` repeated, units
|
||||
/// `ns`/`us`/`µs`/`μs`/`ms`/`s`/`m`/`h`. A bare `0` is the only unit-less
|
||||
/// value; a leading sign parses in Go but a negative has no
|
||||
/// `std::time::Duration`, so it is an error, not a clamp.
|
||||
fn parse_go_duration(s: &str) -> Result<std::time::Duration, String> {
|
||||
let orig = s;
|
||||
let invalid = || format!("time: invalid duration {:?}", orig);
|
||||
|
||||
let mut s = s;
|
||||
if let Some(rest) = s.strip_prefix('-') {
|
||||
if rest.is_empty() {
|
||||
return Err(invalid());
|
||||
}
|
||||
return Err(format!("negative duration {:?} is not supported", orig));
|
||||
}
|
||||
if let Some(rest) = s.strip_prefix('+') {
|
||||
s = rest;
|
||||
}
|
||||
if s == "0" {
|
||||
return Ok(std::time::Duration::ZERO);
|
||||
}
|
||||
if s.is_empty() {
|
||||
return std::time::Duration::from_secs(60);
|
||||
return Err(invalid());
|
||||
}
|
||||
if let Some(secs) = s.strip_suffix('s')
|
||||
&& let Ok(v) = secs.parse::<u64>()
|
||||
{
|
||||
return std::time::Duration::from_secs(v);
|
||||
|
||||
// Go bounds the total by 1<<63 (int64 nanoseconds): overflow is an error.
|
||||
const MAX: u64 = 1 << 63;
|
||||
let mut total: u64 = 0;
|
||||
let mut bytes = s.as_bytes();
|
||||
while !bytes.is_empty() {
|
||||
if !(bytes[0] == b'.' || bytes[0].is_ascii_digit()) {
|
||||
return Err(invalid());
|
||||
}
|
||||
|
||||
// Consume [0-9]*
|
||||
let mut i = 0;
|
||||
let mut v: u64 = 0;
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||
v = v
|
||||
.checked_mul(10)
|
||||
.and_then(|v| v.checked_add(u64::from(bytes[i] - b'0')))
|
||||
.filter(|v| *v <= MAX)
|
||||
.ok_or_else(invalid)?;
|
||||
i += 1;
|
||||
}
|
||||
let pre = i != 0;
|
||||
bytes = &bytes[i..];
|
||||
|
||||
// Consume (\.[0-9]*)?
|
||||
let mut f: u64 = 0;
|
||||
let mut scale: f64 = 1.0;
|
||||
let mut post = false;
|
||||
if let Some((b'.', rest)) = bytes.split_first() {
|
||||
bytes = rest;
|
||||
let mut i = 0;
|
||||
let mut overflow = false;
|
||||
while i < bytes.len() && bytes[i].is_ascii_digit() {
|
||||
if !overflow {
|
||||
// Like Go's leadingFraction, digits past ~19 are dropped.
|
||||
match f
|
||||
.checked_mul(10)
|
||||
.and_then(|f| f.checked_add(u64::from(bytes[i] - b'0')))
|
||||
.filter(|f| *f <= MAX)
|
||||
{
|
||||
Some(next) => {
|
||||
f = next;
|
||||
scale *= 10.0;
|
||||
}
|
||||
None => overflow = true,
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
post = i != 0;
|
||||
bytes = &bytes[i..];
|
||||
}
|
||||
if !pre && !post {
|
||||
return Err(invalid());
|
||||
}
|
||||
|
||||
// Consume the unit: everything up to the next digit or '.'.
|
||||
let unit_len = bytes
|
||||
.iter()
|
||||
.position(|c| *c == b'.' || c.is_ascii_digit())
|
||||
.unwrap_or(bytes.len());
|
||||
if unit_len == 0 {
|
||||
return Err(format!("time: missing unit in duration {:?}", orig));
|
||||
}
|
||||
// The boundary is an ASCII byte or the end, so it is a char boundary.
|
||||
let unit = std::str::from_utf8(&bytes[..unit_len]).map_err(|_| invalid())?;
|
||||
bytes = &bytes[unit_len..];
|
||||
let unit_nanos: u64 = match unit {
|
||||
"ns" => 1,
|
||||
"us" | "\u{00b5}s" | "\u{03bc}s" => 1_000,
|
||||
"ms" => 1_000_000,
|
||||
"s" => 1_000_000_000,
|
||||
"m" => 60 * 1_000_000_000,
|
||||
"h" => 3600 * 1_000_000_000,
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"time: unknown unit {:?} in duration {:?}",
|
||||
unit, orig
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if v > MAX / unit_nanos {
|
||||
return Err(invalid());
|
||||
}
|
||||
v *= unit_nanos;
|
||||
if f > 0 {
|
||||
// Go's float math, so `1.5h` rounds the same way on both sides.
|
||||
v += (f as f64 * (unit_nanos as f64 / scale)) as u64;
|
||||
if v > MAX {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
total = total
|
||||
.checked_add(v)
|
||||
.filter(|t| *t <= MAX)
|
||||
.ok_or_else(invalid)?;
|
||||
}
|
||||
if let Some(mins) = s.strip_suffix('m')
|
||||
&& let Ok(v) = mins.parse::<u64>()
|
||||
&& let Some(seconds) = v.checked_mul(60)
|
||||
{
|
||||
return std::time::Duration::from_secs(seconds);
|
||||
if total > MAX - 1 {
|
||||
return Err(invalid());
|
||||
}
|
||||
if let Some(hours) = s.strip_suffix('h')
|
||||
&& let Ok(v) = hours.parse::<u64>()
|
||||
&& let Some(seconds) = v.checked_mul(3600)
|
||||
{
|
||||
return std::time::Duration::from_secs(seconds);
|
||||
}
|
||||
// Fallback: try parsing as raw seconds
|
||||
if let Ok(v) = s.parse::<u64>() {
|
||||
return std::time::Duration::from_secs(v);
|
||||
}
|
||||
std::time::Duration::from_secs(60)
|
||||
Ok(std::time::Duration::from_nanos(total))
|
||||
}
|
||||
|
||||
/// Parse minFreeSpace / minFreeSpacePercent into MinFreeSpace values.
|
||||
/// Mirrors Go's `util.MustParseMinFreeSpace()`.
|
||||
fn parse_min_free_spaces(min_free_space: &str, min_free_space_percent: &str) -> Vec<MinFreeSpace> {
|
||||
// If --minFreeSpace is provided, use it (takes precedence).
|
||||
/// Byte-size suffixes of Go's `util.ParseBytes`, lower-cased: SI (`kb`, `k`)
|
||||
/// and IEC (`kib`, `ki`) forms, `b`, and no suffix.
|
||||
fn byte_unit_multiplier(unit: &str) -> Option<u64> {
|
||||
const K: u64 = 1_000;
|
||||
const KI: u64 = 1 << 10;
|
||||
Some(match unit {
|
||||
"" | "b" => 1,
|
||||
"kb" | "k" => K,
|
||||
"kib" | "ki" => KI,
|
||||
"mb" | "m" => K * K,
|
||||
"mib" | "mi" => KI * KI,
|
||||
"gb" | "g" => K * K * K,
|
||||
"gib" | "gi" => KI * KI * KI,
|
||||
"tb" | "t" => K * K * K * K,
|
||||
"tib" | "ti" => KI * KI * KI * KI,
|
||||
"pb" | "p" => K * K * K * K * K,
|
||||
"pib" | "pi" => KI * KI * KI * KI * KI,
|
||||
"eb" | "e" => K * K * K * K * K * K,
|
||||
"eib" | "ei" => KI * KI * KI * KI * KI * KI,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a human-readable byte count with Go's `util.ParseBytes` grammar:
|
||||
/// decimal number, optional whitespace, then a [`byte_unit_multiplier`]
|
||||
/// suffix, case-insensitive. `10GiB` → 10737418240, `1.5TB` → 1500000000000.
|
||||
///
|
||||
/// Thousands commas parse as in Go but never arrive from `-minFreeSpace`,
|
||||
/// which is split on commas into per-directory entries first.
|
||||
fn parse_bytes(s: &str) -> Result<u64, String> {
|
||||
let digits_end = s
|
||||
.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == ','))
|
||||
.unwrap_or(s.len());
|
||||
let num = s[..digits_end].replace(',', "");
|
||||
let value: f64 = num
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid byte size {:?}: {}", s, e))?;
|
||||
let unit = s[digits_end..].trim().to_lowercase();
|
||||
let multiplier =
|
||||
byte_unit_multiplier(&unit).ok_or_else(|| format!("unhandled size name: {}", unit))?;
|
||||
let bytes = value * multiplier as f64;
|
||||
if bytes >= u64::MAX as f64 {
|
||||
return Err(format!("too large: {}", s));
|
||||
}
|
||||
Ok(bytes as u64)
|
||||
}
|
||||
|
||||
/// Parse one `-minFreeSpace` entry like Go's `util.ParseMinFreeSpace()`: a
|
||||
/// plain number is a percentage in `0..=100`, anything else a byte size
|
||||
/// above 100 bytes. `150` errors rather than reading as bytes.
|
||||
fn parse_min_free_space(s: &str) -> Result<MinFreeSpace, String> {
|
||||
let s = s.trim();
|
||||
if let Ok(percent) = s.parse::<f64>() {
|
||||
// Go's range check lets NaN through; rejecting it is the one
|
||||
// intentional difference.
|
||||
if !(0.0..=100.0).contains(&percent) {
|
||||
return Err(format!("minFreeSpace is invalid: {:?}", s));
|
||||
}
|
||||
return Ok(MinFreeSpace::Percent(percent));
|
||||
}
|
||||
match parse_bytes(s) {
|
||||
Ok(bytes) if bytes > 100 => Ok(MinFreeSpace::Bytes(bytes)),
|
||||
_ => Err(format!("minFreeSpace is invalid: {:?}", s)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse minFreeSpace / minFreeSpacePercent like Go's
|
||||
/// `util.MustParseMinFreeSpace()`: `--minFreeSpace` wins when set, and any
|
||||
/// invalid comma-separated entry fails the whole list.
|
||||
fn parse_min_free_spaces(
|
||||
min_free_space: &str,
|
||||
min_free_space_percent: &str,
|
||||
) -> Result<Vec<MinFreeSpace>, String> {
|
||||
let source = if !min_free_space.is_empty() {
|
||||
min_free_space
|
||||
} else {
|
||||
min_free_space_percent
|
||||
};
|
||||
|
||||
source
|
||||
.split(',')
|
||||
.map(|s| {
|
||||
let s = s.trim();
|
||||
// Try parsing as a percentage (value <= 100)
|
||||
if let Ok(v) = s.parse::<f64>() {
|
||||
if v <= 100.0 {
|
||||
return MinFreeSpace::Percent(v);
|
||||
}
|
||||
// Treat as bytes if > 100
|
||||
return MinFreeSpace::Bytes(v as u64);
|
||||
}
|
||||
// Try parsing human-readable bytes: e.g. "10GiB", "500MiB", "1TiB"
|
||||
let s_upper = s.to_uppercase();
|
||||
if let Some(rest) = s_upper.strip_suffix("TIB")
|
||||
&& let Ok(v) = rest.trim().parse::<f64>()
|
||||
{
|
||||
return MinFreeSpace::Bytes((v * 1024.0 * 1024.0 * 1024.0 * 1024.0) as u64);
|
||||
}
|
||||
if let Some(rest) = s_upper.strip_suffix("GIB")
|
||||
&& let Ok(v) = rest.trim().parse::<f64>()
|
||||
{
|
||||
return MinFreeSpace::Bytes((v * 1024.0 * 1024.0 * 1024.0) as u64);
|
||||
}
|
||||
if let Some(rest) = s_upper.strip_suffix("MIB")
|
||||
&& let Ok(v) = rest.trim().parse::<f64>()
|
||||
{
|
||||
return MinFreeSpace::Bytes((v * 1024.0 * 1024.0) as u64);
|
||||
}
|
||||
if let Some(rest) = s_upper.strip_suffix("KIB")
|
||||
&& let Ok(v) = rest.trim().parse::<f64>()
|
||||
{
|
||||
return MinFreeSpace::Bytes((v * 1024.0) as u64);
|
||||
}
|
||||
if let Some(rest) = s_upper.strip_suffix("TB")
|
||||
&& let Ok(v) = rest.trim().parse::<f64>()
|
||||
{
|
||||
return MinFreeSpace::Bytes((v * 1_000_000_000_000.0) as u64);
|
||||
}
|
||||
if let Some(rest) = s_upper.strip_suffix("GB")
|
||||
&& let Ok(v) = rest.trim().parse::<f64>()
|
||||
{
|
||||
return MinFreeSpace::Bytes((v * 1_000_000_000.0) as u64);
|
||||
}
|
||||
if let Some(rest) = s_upper.strip_suffix("MB")
|
||||
&& let Ok(v) = rest.trim().parse::<f64>()
|
||||
{
|
||||
return MinFreeSpace::Bytes((v * 1_000_000.0) as u64);
|
||||
}
|
||||
// Default: 1%
|
||||
MinFreeSpace::Percent(1.0)
|
||||
})
|
||||
.collect()
|
||||
source.split(',').map(parse_min_free_space).collect()
|
||||
}
|
||||
|
||||
/// Parse comma-separated tag groups like "fast:ssd,archive" into per-folder tag vectors.
|
||||
@@ -657,7 +782,9 @@ fn resolve_config_with_env(cli: Cli, env: EnvLookup<'_>) -> VolumeServerConfig {
|
||||
|
||||
// Parse min free spaces
|
||||
let mut min_free_spaces =
|
||||
parse_min_free_spaces(&cli.min_free_space, &cli.min_free_space_percent);
|
||||
parse_min_free_spaces(&cli.min_free_space, &cli.min_free_space_percent).unwrap_or_else(
|
||||
|e| panic!("The value specified in -minFreeSpace not a valid value: {e}"),
|
||||
);
|
||||
if min_free_spaces.len() == 1 && folder_count > 1 {
|
||||
let v = min_free_spaces[0].clone();
|
||||
min_free_spaces.resize(folder_count, v);
|
||||
@@ -772,10 +899,6 @@ fn resolve_config_with_env(cli: Cli, env: EnvLookup<'_>) -> VolumeServerConfig {
|
||||
.collect();
|
||||
white_list.extend(sec.guard_white_list.iter().cloned());
|
||||
|
||||
// Parse durations
|
||||
let inflight_upload_data_timeout = parse_duration(&cli.inflight_upload_data_timeout);
|
||||
let inflight_download_data_timeout = parse_duration(&cli.inflight_download_data_timeout);
|
||||
|
||||
VolumeServerConfig {
|
||||
port: cli.port,
|
||||
grpc_port,
|
||||
@@ -807,8 +930,8 @@ fn resolve_config_with_env(cli: Cli, env: EnvLookup<'_>) -> VolumeServerConfig {
|
||||
file_size_limit_bytes: cli.file_size_limit_mb as i64 * 1024 * 1024,
|
||||
concurrent_upload_limit: cli.concurrent_upload_limit_mb as i64 * 1024 * 1024,
|
||||
concurrent_download_limit: cli.concurrent_download_limit_mb as i64 * 1024 * 1024,
|
||||
inflight_upload_data_timeout,
|
||||
inflight_download_data_timeout,
|
||||
inflight_upload_data_timeout: cli.inflight_upload_data_timeout,
|
||||
inflight_download_data_timeout: cli.inflight_download_data_timeout,
|
||||
has_slow_read: cli.has_slow_read,
|
||||
read_buffer_size_mb: cli.read_buffer_size_mb,
|
||||
ldb_timeout: cli.ldb_timeout,
|
||||
@@ -1260,25 +1383,224 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration() {
|
||||
assert_eq!(parse_duration("60s"), std::time::Duration::from_secs(60));
|
||||
assert_eq!(parse_duration("5m"), std::time::Duration::from_secs(300));
|
||||
assert_eq!(parse_duration("1h"), std::time::Duration::from_secs(3600));
|
||||
assert_eq!(parse_duration("30"), std::time::Duration::from_secs(30));
|
||||
assert_eq!(parse_duration(""), std::time::Duration::from_secs(60));
|
||||
fn test_parse_go_duration_accepts_go_grammar() {
|
||||
use std::time::Duration;
|
||||
let cases = [
|
||||
("60s", Duration::from_secs(60)),
|
||||
("5m", Duration::from_secs(300)),
|
||||
("1h", Duration::from_secs(3600)),
|
||||
("1.5h", Duration::from_secs(5400)),
|
||||
("2h45m", Duration::from_secs(2 * 3600 + 45 * 60)),
|
||||
("300ms", Duration::from_millis(300)),
|
||||
("1.5s", Duration::from_millis(1500)),
|
||||
("250us", Duration::from_micros(250)),
|
||||
("250\u{00b5}s", Duration::from_micros(250)),
|
||||
("250\u{03bc}s", Duration::from_micros(250)),
|
||||
("7ns", Duration::from_nanos(7)),
|
||||
(".5s", Duration::from_millis(500)),
|
||||
("5.s", Duration::from_secs(5)),
|
||||
("+10s", Duration::from_secs(10)),
|
||||
("0", Duration::ZERO),
|
||||
("0s", Duration::ZERO),
|
||||
// Go: fraction digits past the 19th are ignored, not an error.
|
||||
("1.00000000000000000001s", Duration::from_secs(1)),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
assert_eq!(parse_go_duration(input), Ok(expected), "input={:?}", input);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_go_duration_rejects_what_go_rejects() {
|
||||
// Each of these makes Go's flag.Duration fail flag parsing; the old
|
||||
// Rust parser turned them all into the 60 s default.
|
||||
let rejected = [
|
||||
"30", // bare number other than 0
|
||||
"30sec", // unknown unit
|
||||
"abc", // no number at all
|
||||
"", // empty
|
||||
"1h ", // trailing space is not a unit
|
||||
"s", // unit without number
|
||||
".s", // dot without digits
|
||||
"1.5", // fraction without unit
|
||||
"1h-30m", // sign is only allowed at the front
|
||||
"-", // sign alone
|
||||
"5124095576030432h", // overflows int64 nanoseconds
|
||||
"307445734561825861m", // overflows int64 nanoseconds
|
||||
"9223372036854775808ns", // 1<<63, one past int64
|
||||
];
|
||||
for input in rejected {
|
||||
let result = parse_go_duration(input);
|
||||
assert!(result.is_err(), "input={:?} parsed as {:?}", input, result);
|
||||
let message = result.unwrap_err();
|
||||
assert!(
|
||||
message.contains(&format!("{:?}", input)) || input.is_empty(),
|
||||
"error for {:?} does not name the value: {}",
|
||||
input,
|
||||
message
|
||||
);
|
||||
}
|
||||
// Largest value Go accepts: 1<<63 - 1 nanoseconds.
|
||||
assert_eq!(
|
||||
parse_duration("307445734561825861m"),
|
||||
parse_go_duration("9223372036854775807ns"),
|
||||
Ok(std::time::Duration::from_nanos(i64::MAX as u64))
|
||||
);
|
||||
// Go accepts negatives; std::time::Duration cannot, so it errors.
|
||||
let negative = parse_go_duration("-1s").unwrap_err();
|
||||
assert!(negative.contains("negative"), "{}", negative);
|
||||
}
|
||||
|
||||
/// Clap refuses the flag so the process exits with a usage error, exactly
|
||||
/// where Go's flag package does.
|
||||
#[test]
|
||||
fn test_cli_rejects_invalid_duration_flags() {
|
||||
for value in ["30sec", "abc", "30"] {
|
||||
let result = Cli::try_parse_from(["bin", "--inflightUploadDataTimeout", value]);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"--inflightUploadDataTimeout={} was accepted as {:?}",
|
||||
value,
|
||||
result.map(|cli| cli.inflight_upload_data_timeout)
|
||||
);
|
||||
let err = Cli::try_parse_from(["bin", "--inflightDownloadDataTimeout", value])
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("--inflightDownloadDataTimeout={} was accepted", value));
|
||||
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
|
||||
assert!(err.to_string().contains(value), "{}", err);
|
||||
}
|
||||
let cli = Cli::parse_from(["bin", "--inflightUploadDataTimeout", "1.5h"]);
|
||||
assert_eq!(
|
||||
cli.inflight_upload_data_timeout,
|
||||
std::time::Duration::from_secs(5400)
|
||||
);
|
||||
// The default still parses through the same grammar.
|
||||
let cli = Cli::parse_from(["bin"]);
|
||||
assert_eq!(
|
||||
cli.inflight_upload_data_timeout,
|
||||
std::time::Duration::from_secs(60)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_duration("5124095576030432h"),
|
||||
cli.inflight_download_data_timeout,
|
||||
std::time::Duration::from_secs(60)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_bytes_matches_go_parse_bytes() {
|
||||
// Expected values are what Go's util.ParseBytes returns (weed/util/bytes_test.go).
|
||||
let cases: [(&str, u64); 14] = [
|
||||
("42", 42),
|
||||
("42MB", 42_000_000),
|
||||
("42 MB", 42_000_000),
|
||||
("42 mib", 44_040_192),
|
||||
("42M", 42_000_000),
|
||||
("100Ki", 100 * 1024),
|
||||
("10GiB", 10 * 1024 * 1024 * 1024),
|
||||
("10gib", 10 * 1024 * 1024 * 1024),
|
||||
("10GB", 10_000_000_000),
|
||||
("1.5TB", 1_500_000_000_000),
|
||||
// Go's ParseBytes accepts thousands commas; the -minFreeSpace flag
|
||||
// never delivers one (see test_min_free_space_flag_commas_are_list_separators).
|
||||
("1,024MB", 1_024_000_000),
|
||||
("1,024", 1024),
|
||||
("1PiB", 1 << 50),
|
||||
("1eb", 1_000_000_000_000_000_000),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
assert_eq!(parse_bytes(input), Ok(expected), "input={:?}", input);
|
||||
}
|
||||
for input in [
|
||||
"10GiBx",
|
||||
"GiB",
|
||||
"",
|
||||
"-5GB",
|
||||
"1.2.3GB",
|
||||
"10 Gi B",
|
||||
"99999999999999999999EB",
|
||||
] {
|
||||
assert!(
|
||||
parse_bytes(input).is_err(),
|
||||
"input={:?} was accepted",
|
||||
input
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_percent(result: Result<MinFreeSpace, String>, expected: f64, input: &str) {
|
||||
match result {
|
||||
Ok(MinFreeSpace::Percent(v)) => {
|
||||
assert!(
|
||||
(v - expected).abs() < f64::EPSILON,
|
||||
"input={:?}: {}",
|
||||
input,
|
||||
v
|
||||
)
|
||||
}
|
||||
other => panic!(
|
||||
"input={:?}: expected Percent({}), got {:?}",
|
||||
input, expected, other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_bytes(result: Result<MinFreeSpace, String>, expected: u64, input: &str) {
|
||||
match result {
|
||||
Ok(MinFreeSpace::Bytes(v)) => assert_eq!(v, expected, "input={:?}", input),
|
||||
other => panic!(
|
||||
"input={:?}: expected Bytes({}), got {:?}",
|
||||
input, expected, other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_min_free_space_matches_go() {
|
||||
// Fixtures from weed/util/minfreespace_test.go plus the byte-unit table.
|
||||
expect_percent(parse_min_free_space("42"), 42.0, "42");
|
||||
expect_percent(parse_min_free_space(" 42 "), 42.0, " 42 ");
|
||||
expect_percent(parse_min_free_space("50"), 50.0, "50");
|
||||
expect_percent(parse_min_free_space("100"), 100.0, "100");
|
||||
expect_percent(parse_min_free_space("0"), 0.0, "0");
|
||||
expect_percent(parse_min_free_space("2.5"), 2.5, "2.5");
|
||||
expect_bytes(parse_min_free_space("100Ki"), 100 * 1024, "100Ki");
|
||||
expect_bytes(parse_min_free_space("100GiB"), 100 << 30, "100GiB");
|
||||
expect_bytes(parse_min_free_space("42M"), 42_000_000, "42M");
|
||||
expect_bytes(parse_min_free_space(" 42M "), 42_000_000, " 42M ");
|
||||
expect_bytes(parse_min_free_space("10GiB"), 10 << 30, "10GiB");
|
||||
expect_bytes(parse_min_free_space("10gib"), 10 << 30, "10gib");
|
||||
expect_bytes(parse_min_free_space("1.5TB"), 1_500_000_000_000, "1.5TB");
|
||||
// Comma inputs never arrive whole from the flag; see
|
||||
// test_min_free_space_flag_commas_are_list_separators.
|
||||
expect_bytes(parse_min_free_space("1,024MB"), 1_024_000_000, "1,024MB");
|
||||
expect_bytes(parse_min_free_space("1,000"), 1000, "1,000");
|
||||
|
||||
// Go: percent outside 0..=100, or a byte size of at most 100 bytes.
|
||||
for input in [
|
||||
"150", "101", "-1", "1e3", "inf", "nan", "100B", "50B", "0B", "10GiBx", "", "abc",
|
||||
"10 Gi B",
|
||||
] {
|
||||
let result = parse_min_free_space(input);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"input={:?} was accepted as {:?}",
|
||||
input,
|
||||
result
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.as_ref()
|
||||
.unwrap_err()
|
||||
.contains(&format!("{:?}", input.trim())),
|
||||
"error for {:?} does not name the value: {:?}",
|
||||
input,
|
||||
result
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_min_free_spaces_percent() {
|
||||
let result = parse_min_free_spaces("", "1");
|
||||
let result = parse_min_free_spaces("", "1").unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
match &result[0] {
|
||||
MinFreeSpace::Percent(v) => assert!((v - 1.0).abs() < f64::EPSILON),
|
||||
@@ -1288,7 +1610,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_min_free_spaces_bytes() {
|
||||
let result = parse_min_free_spaces("10GiB", "");
|
||||
let result = parse_min_free_spaces("10GiB", "").unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
match &result[0] {
|
||||
MinFreeSpace::Bytes(v) => assert_eq!(*v, 10 * 1024 * 1024 * 1024),
|
||||
@@ -1296,6 +1618,87 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_min_free_spaces_list_and_precedence() {
|
||||
// --minFreeSpace wins over --minFreeSpacePercent, as in Go's EmptyTo().
|
||||
let result = parse_min_free_spaces("5, 10GiB", "1").unwrap();
|
||||
assert_eq!(result.len(), 2);
|
||||
assert!(matches!(result[0], MinFreeSpace::Percent(v) if v == 5.0));
|
||||
assert!(matches!(result[1], MinFreeSpace::Bytes(v) if v == 10 << 30));
|
||||
// One bad entry fails the whole list and names that entry.
|
||||
let err = parse_min_free_spaces("5,10GiBx", "1").unwrap_err();
|
||||
assert!(err.contains("\"10GiBx\""), "{}", err);
|
||||
// Both flags empty: Go fatals (ParseMinFreeSpace("") fails) rather than
|
||||
// assuming 1%.
|
||||
assert!(parse_min_free_spaces("", "").is_err());
|
||||
}
|
||||
|
||||
/// A comma in `-minFreeSpace` separates per-directory entries, as in Go's
|
||||
/// `MustParseMinFreeSpace`: `1,024MB` is `1` (percent) and `024MB`, and
|
||||
/// one `-dir` fails the count check.
|
||||
#[test]
|
||||
fn test_min_free_space_flag_commas_are_list_separators() {
|
||||
let result = parse_min_free_spaces("1,024MB", "").unwrap();
|
||||
assert_eq!(result.len(), 2, "{result:?}");
|
||||
assert!(
|
||||
matches!(result[0], MinFreeSpace::Percent(v) if v == 1.0),
|
||||
"{result:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(result[1], MinFreeSpace::Bytes(v) if v == 24_000_000),
|
||||
"{result:?}"
|
||||
);
|
||||
|
||||
let _guard = process_state_lock();
|
||||
let cli = Cli::parse_from(["bin", "--dir", "/tmp/a", "--minFreeSpace", "1,024MB"]);
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
resolve_config_with_env(cli, &|_| None).min_free_spaces
|
||||
}));
|
||||
assert!(
|
||||
outcome.is_err(),
|
||||
"one -dir with two minFreeSpace entries must abort"
|
||||
);
|
||||
}
|
||||
|
||||
/// Negative control at the startup layer: resolving the config with an
|
||||
/// invalid --minFreeSpace must abort, as Go's MustParseMinFreeSpace does.
|
||||
#[test]
|
||||
fn test_resolve_config_rejects_invalid_min_free_space() {
|
||||
// The security-config search reads the working directory.
|
||||
let _guard = process_state_lock();
|
||||
for (flag, value) in [
|
||||
("--minFreeSpace", "10GiBx"),
|
||||
("--minFreeSpace", "150"),
|
||||
("--minFreeSpace", "50B"),
|
||||
("--minFreeSpacePercent", "150"),
|
||||
("--minFreeSpacePercent", "abc"),
|
||||
] {
|
||||
let cli = Cli::parse_from(["bin", flag, value]);
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
resolve_config_with_env(cli, &|_| None).min_free_spaces
|
||||
}));
|
||||
let payload = match outcome {
|
||||
Ok(spaces) => panic!("{}={} was accepted as {:?}", flag, value, spaces),
|
||||
Err(payload) => payload,
|
||||
};
|
||||
let message = payload
|
||||
.downcast_ref::<String>()
|
||||
.cloned()
|
||||
.or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string()))
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
message.contains("-minFreeSpace") && message.contains(value),
|
||||
"{}={}: unexpected panic message {:?}",
|
||||
flag,
|
||||
value,
|
||||
message
|
||||
);
|
||||
}
|
||||
let cfg =
|
||||
resolve_config_with_env(Cli::parse_from(["bin", "--minFreeSpace", "100"]), &|_| None);
|
||||
assert!(matches!(cfg.min_free_spaces[0], MinFreeSpace::Percent(v) if v == 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_volume_tags_single() {
|
||||
let tags = parse_volume_tags("fast:ssd", 3);
|
||||
|
||||
Reference in New Issue
Block a user