Skip to content

Commit 93d716d

Browse files
ZhiXiao-LinRoy Lin
andauthored
fix: close 2 review regressions + stale lockfile from the operability batch (#139)
Adversarial seam-review of the merged operability batch (v2.3.0..main) surfaced two genuine incompletenesses plus a stale lockfile: 1. --memory-swap missed call-site (medium). The fail-closed `> i64::MAX` guard was added to the run/create path (common.rs) but NOT to `a3s-box update` (container_update.rs), so `update --memory-swap <huge>` still wrapped negative -> resize.rs maps swap < 0 to memory.swap.max=max -> silently UNLIMITED swap. Extract one shared `parse_memory_swap` helper (handles the -1 sentinel + the i64::MAX bound) and call it from both paths so they cannot diverge again. 2. OCI per-entry skip dropped data irrecoverably (low). On a per-entry deserialize failure, read_index_from_disk skipped the bad entry but — unlike the whole-file quarantine path — did NOT preserve it; the next save rewrites index.json with only the survivors, erasing the un-deserializable record (e.g. a schema mismatch after upgrade) with no backup. Add a copy-only `quarantine_copy` (preserve original in place, live catalog untouched) and back up index.json when entries are skipped. 3. Stale Cargo.lock. The monitor concurrent-probes fix added a `futures` dep to a3s-box-cli but didn't regenerate the lock, so the committed Cargo.lock omits the edge and `cargo build --locked` fails on main. Regenerate. Also drops a stale `a3s-box reap` reference (a non-existent command) from a state-file doc comment, matching the accurate operator-facing message. Tests: parse_memory_swap (sentinel/normal/overflow), quarantine_copy (preserve+backup) and quarantine_corrupt (move-aside). Co-authored-by: Roy Lin <roylin@a3s.box>
1 parent c9f7316 commit 93d716d

6 files changed

Lines changed: 107 additions & 21 deletions

File tree

src/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cli/src/commands/common.rs

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,26 @@ pub(crate) fn parse_memory_bytes(s: &str) -> Result<u64, String> {
534534
.ok_or_else(|| format!("size value too large: {s}"))
535535
}
536536

537+
/// Parse a `--memory-swap` value into the cgroup `i64` representation, failing
538+
/// closed on overflow.
539+
///
540+
/// `-1` is Docker's sentinel for "unlimited" and maps to `-1`. Any other value
541+
/// is a byte size that MUST fit in `i64`: a value > `i64::MAX` would wrap
542+
/// negative on the `as i64` cast, and `resize.rs` maps `swap < 0` to
543+
/// `memory.swap.max=max` (unlimited) — so a fat-fingered huge value would
544+
/// silently become *unlimited* swap. Reject it instead. Shared by the
545+
/// run/create path and `a3s-box update` so the two cannot diverge.
546+
pub(crate) fn parse_memory_swap(s: &str) -> Result<i64, String> {
547+
if s == "-1" {
548+
return Ok(-1);
549+
}
550+
let bytes = parse_memory_bytes(s).map_err(|e| format!("Invalid --memory-swap: {e}"))?;
551+
if bytes > i64::MAX as u64 {
552+
return Err(format!("--memory-swap value too large: {s}"));
553+
}
554+
Ok(bytes as i64)
555+
}
556+
537557
/// Build ResourceLimits from common box args.
538558
pub(crate) fn build_resource_limits(
539559
args: &CommonBoxArgs,
@@ -545,17 +565,7 @@ pub(crate) fn build_resource_limits(
545565
None => None,
546566
};
547567
let memory_swap = match &args.memory_swap {
548-
Some(s) if s == "-1" => Some(-1i64),
549-
Some(s) => {
550-
let bytes = parse_memory_bytes(s).map_err(|e| format!("Invalid --memory-swap: {e}"))?;
551-
// Reject before the lossy `as i64` cast: a value > i64::MAX would wrap
552-
// negative, and resize.rs maps swap < 0 to memory.swap.max=max
553-
// (unlimited) — so a fat-fingered huge value silently became unlimited.
554-
if bytes > i64::MAX as u64 {
555-
return Err(format!("--memory-swap value too large: {s}").into());
556-
}
557-
Some(bytes as i64)
558-
}
568+
Some(s) => Some(parse_memory_swap(s)?),
559569
None => None,
560570
};
561571

@@ -644,6 +654,22 @@ mod tests {
644654
assert!(parse_memory_bytes("18446744073709551615k").is_err());
645655
}
646656

657+
#[test]
658+
fn test_parse_memory_swap_sentinel_value_and_overflow() {
659+
// -1 is Docker's "unlimited" sentinel and must survive verbatim.
660+
assert_eq!(parse_memory_swap("-1").unwrap(), -1);
661+
// A normal size parses to its byte count.
662+
assert_eq!(parse_memory_swap("512m").unwrap(), 512 * 1024 * 1024);
663+
// Fail closed: a value > i64::MAX must be rejected, NOT wrap negative
664+
// (which resize.rs would map to memory.swap.max=max → silently unlimited).
665+
// i64::MAX = 9223372036854775807; this is just above it.
666+
let huge = "9300000000000000000";
667+
let err = parse_memory_swap(huge).unwrap_err();
668+
assert!(err.contains("too large"), "got: {err}");
669+
// And the same guard via a suffixed overflow.
670+
assert!(parse_memory_swap("9000000000g").is_err());
671+
}
672+
647673
#[test]
648674
fn test_validate_rejects_zero_and_excessive_cpus() {
649675
let mut args = default_common_args();

src/cli/src/commands/container_update.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,12 +102,9 @@ pub async fn execute(args: ContainerUpdateArgs) -> Result<(), Box<dyn std::error
102102
}
103103

104104
if let Some(ref swap) = args.memory_swap {
105-
let val = if swap == "-1" {
106-
-1i64
107-
} else {
108-
common::parse_memory_bytes(swap).map_err(|e| format!("Invalid --memory-swap: {e}"))?
109-
as i64
110-
};
105+
// Same fail-closed parse as the run/create path so `update --memory-swap`
106+
// can't silently grant unlimited swap on an overflowing value.
107+
let val = common::parse_memory_swap(swap)?;
111108
update.limits.memory_swap = Some(val);
112109
record.resource_limits.memory_swap = Some(val);
113110
updated.push(format!("memory-swap={swap}"));

src/cli/src/state/file.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,9 @@ impl StateFile {
9292
/// orphaning every running VM/overlay with no error and no recoverable
9393
/// record. Instead the corrupt file is quarantined to a timestamped sibling
9494
/// (`boxes.json.corrupt-<unix-secs>`) and a loud warning is emitted, so the
95-
/// data is preserved for recovery (`a3s-box reap`, or manual repair) while
96-
/// the process starts from a clean empty state rather than crashing.
95+
/// data is preserved for recovery (restore it, then `a3s-box ps` re-reconciles;
96+
/// otherwise repair manually) while the process starts from a clean empty
97+
/// state rather than crashing.
9798
fn parse_or_quarantine(path: &Path, data: &str) -> Vec<BoxRecord> {
9899
match serde_json::from_str::<Vec<BoxRecord>>(data) {
99100
Ok(records) => records,

src/runtime/src/oci/store.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,9 +363,15 @@ impl ImageStore {
363363
}
364364
}
365365
if skipped > 0 {
366+
// Preserve the original (with the un-deserializable entries) before the
367+
// next save rewrites index.json with only the survivors — otherwise the
368+
// skipped records are erased with no backup, unlike the whole-file path.
369+
let preserved = crate::store_io::quarantine_copy(&index_path)
370+
.map(|p| p.display().to_string())
371+
.unwrap_or_else(|| "<backup failed>".to_string());
366372
tracing::warn!(
367-
"{skipped} image index entr{} skipped as unreadable; affected images \
368-
will be re-pulled on demand",
373+
"{skipped} image index entr{} skipped as unreadable; preserved a copy at \
374+
{preserved}; affected images will be re-pulled on demand",
369375
if skipped == 1 { "y" } else { "ies" },
370376
);
371377
}

src/runtime/src/store_io.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,58 @@ pub(crate) fn quarantine_label(path: &Path) -> String {
3333
.map(|p| p.display().to_string())
3434
.unwrap_or_else(|| "<backup failed>".to_string())
3535
}
36+
37+
/// Copy a store file aside to a timestamped `*.corrupt-<unix-secs>` sibling
38+
/// WITHOUT removing the original.
39+
///
40+
/// Unlike [`quarantine_corrupt`] (whole-file unreadable → move aside), this is
41+
/// for the *per-entry* skip path: the file still parses and its surviving
42+
/// entries stay live, but the next save rewrites it with only the survivors and
43+
/// would erase the un-deserializable entries (e.g. a schema mismatch after an
44+
/// upgrade) with no backup. Copying — not moving — preserves those entries for
45+
/// recovery while leaving the live catalog untouched. Returns the backup path,
46+
/// `None` if the copy failed.
47+
pub(crate) fn quarantine_copy(path: &Path) -> Option<PathBuf> {
48+
let secs = std::time::SystemTime::now()
49+
.duration_since(std::time::UNIX_EPOCH)
50+
.map(|d| d.as_secs())
51+
.unwrap_or(0);
52+
let backup = path.with_extension(format!("json.corrupt-{secs}"));
53+
std::fs::copy(path, &backup).ok().map(|_| backup)
54+
}
55+
56+
#[cfg(test)]
57+
mod tests {
58+
use super::*;
59+
60+
#[test]
61+
fn quarantine_copy_preserves_original_and_backs_up() {
62+
let dir = tempfile::tempdir().unwrap();
63+
let path = dir.path().join("index.json");
64+
std::fs::write(&path, b"original contents").unwrap();
65+
66+
let backup = quarantine_copy(&path).expect("copy should succeed");
67+
68+
// The live file is untouched (per-entry skip keeps its survivors).
69+
assert!(path.exists(), "original must remain in place");
70+
assert_eq!(std::fs::read(&path).unwrap(), b"original contents");
71+
// A recovery copy exists with the same bytes.
72+
assert!(backup.exists(), "backup copy must exist");
73+
assert_eq!(std::fs::read(&backup).unwrap(), b"original contents");
74+
assert!(backup.to_string_lossy().contains(".corrupt-"));
75+
}
76+
77+
#[test]
78+
fn quarantine_corrupt_moves_original_aside() {
79+
let dir = tempfile::tempdir().unwrap();
80+
let path = dir.path().join("index.json");
81+
std::fs::write(&path, b"corrupt").unwrap();
82+
83+
let backup = quarantine_corrupt(&path).expect("move should succeed");
84+
85+
// Whole-file path removes the original so the next save can't reuse it.
86+
assert!(!path.exists(), "original must be moved aside");
87+
assert!(backup.exists());
88+
assert_eq!(std::fs::read(&backup).unwrap(), b"corrupt");
89+
}
90+
}

0 commit comments

Comments
 (0)