diff --git a/crates/ecstore/src/disk/error.rs b/crates/ecstore/src/disk/error.rs index 51fb04daa..9c2f1706c 100644 --- a/crates/ecstore/src/disk/error.rs +++ b/crates/ecstore/src/disk/error.rs @@ -853,13 +853,32 @@ mod tests { #[test] fn test_error_conversions() { - // Test From implementations + // A plain io::Error carries no typed payload to recover, so it lands in + // `Io` rather than being guessed at from its kind — `NotFound` here must + // not silently become `FileNotFound`, which quorum aggregation counts as + // a different error (rustfs/backlog#1836). let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "test"); - let _disk_error: DiskError = io_error.into(); + let disk_error: DiskError = io_error.into(); + match &disk_error { + DiskError::Io(inner) => assert_eq!(inner.kind(), std::io::ErrorKind::NotFound), + other => panic!("a plain io::Error must stay typed as Io, got {other:?}"), + } - let json_str = r#"{"invalid": json}"#; // Invalid JSON + // A typed DiskError boxed through io::Error round-trips back to itself + // instead of degrading to `Io`. + let boxed: std::io::Error = std::io::Error::other(DiskError::VolumeNotFound); + assert_eq!(DiskError::from(boxed), DiskError::VolumeNotFound); + + // serde_json errors have no dedicated variant and fold into `other`, + // keeping the original message. + let json_str = r#"{"invalid": json}"#; let json_error = serde_json::from_str::(json_str).unwrap_err(); - let _disk_error: DiskError = json_error.into(); + let json_message = json_error.to_string(); + let disk_error: DiskError = json_error.into(); + assert!( + disk_error.to_string().contains(&json_message), + "the json error message must survive the conversion: {disk_error}" + ); } #[test] diff --git a/scripts/find_assertless_tests.py b/scripts/find_assertless_tests.py index dc73447de..5b9f8ac9a 100755 --- a/scripts/find_assertless_tests.py +++ b/scripts/find_assertless_tests.py @@ -53,15 +53,38 @@ VERIFY_SIGNALS = re.compile( r"unreachable!|matches!\(|insta::|proptest!|\.await\?|\)\?|\?;|should_panic" ) DELEGATION = re.compile( - r"\b(?:assert|verify|check|expect|ensure|run)_[a-z0-9_]*\s*\(|" - r"\b[a-z0-9_]+_(?:case|cases|harness|roundtrip|round_trip)\s*\(" + r"\b(?:assert|verify|check|expect|ensure|run)_[a-z0-9_]*(?:::<[^>]*>)?\s*\(|" + r"\b[a-z0-9_]+_(?:case|cases|harness|roundtrip|round_trip)(?:::<[^>]*>)?\s*\(" ) + +# A body whose whole content is one call delegates by construction, whatever the +# callee is named: `run(DurabilityMode::Strict).await` and +# `aborting_encode_drops_blocked_producer(EncodePipeline::Vec).await` both hand +# every assertion to a shared harness. +SINGLE_CALL_BODY = re.compile( + r"\A\s*[a-zA-Z_][a-zA-Z0-9_:]*(?:::<[^>]*>)?\s*\([^;]*\)\s*(?:\.await\s*)?;?\s*\Z", + re.S, +) + +# A nested `fn` that is only bound and discarded is a signature guard: the type +# system is the assertion, exactly like the `fn _name()` form below. +SIGNATURE_GUARD = re.compile(r"\bfn\s+[a-zA-Z0-9_]+\s*(?:<[^>]*>)?\s*\([^;]*\)[^;]*\{", re.S) +DISCARDED_BINDING = re.compile(r"\blet\s+_\s*=\s*[a-zA-Z_][a-zA-Z0-9_]*\s*;") COMPILE_TIME_CHECK = re.compile(r"\bfn\s+_[a-zA-Z0-9_]*\s*(?:<[^>]*>)?\s*\(") TEST_ATTR = re.compile(r"#\[(?:tokio::)?test[\](]") TEST_CASE_ATTR = re.compile(r"#\[test_case") FN_LINE = re.compile(r"^\s*(?:pub\s+)?(?:async\s+)?fn\s+([a-zA-Z0-9_]+)") +def extract_body(text: str) -> str: + """Return what is between the outermost braces of a scanned function.""" + start = text.find("{") + end = text.rfind("}") + if start == -1 or end <= start: + return text + return text[start + 1 : end] + + def scan_file(path: Path): try: lines = path.read_text(encoding="utf-8").split("\n") @@ -105,7 +128,16 @@ def scan_file(path: Path): break k += 1 text = "\n".join(body) - if not VERIFY_SIGNALS.search(text) and not DELEGATION.search(text) and not COMPILE_TIME_CHECK.search(text): + # The attribute block carries verification too: `#[should_panic(expected + # = "...")]` makes the panic message the assertion. + attr_text = "\n".join(attrs) + inner = extract_body(text) + delegates = ( + DELEGATION.search(text) + or SINGLE_CALL_BODY.match(inner) + or (SIGNATURE_GUARD.search(inner) and DISCARDED_BINDING.search(inner)) + ) + if not VERIFY_SIGNALS.search(text) and not VERIFY_SIGNALS.search(attr_text) and not delegates and not COMPILE_TIME_CHECK.search(text): print(f"{path}:{j + 1}: {name}") i = k + 1