Skip to content

fix(disk-space): promote a size that rounds up to a full unit - #503

Draft
rominf wants to merge 4 commits into
mainfrom
fix-format-bytes-unit-promotion
Draft

rominf wants to merge 4 commits into
mainfrom
fix-format-bytes-unit-promotion

Conversation

@rominf

@rominf rominf commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #491 — draft until that merges. The diff shown against main includes #491's commits; the change proper is the last commit, fix(disk-space): promote a size that rounds up to a full unit. Both PRs add the same proptest dev-dependency to rocm-core, which is why this one is stacked rather than parallel.

  • If this PR fixes a bug, searched tests/e2e-cucumber/expectations.toml for the fixed ticket ID and removed/narrowed any now-stale xfail rows. — no rows reference this area.
  • If this PR adds a new subcommand or subsystem, its domain implementation lives in its own file per docs/architecture.md. — N/A.
  • Every new or changed user-facing message was read against the code path that runs after it, and its test asserts the resulting state — not only the wording, per AGENTS.md §3. — see "On the Gherkin requirement".

Fixes #502

Summary

format_bytes scaled while the raw value was >= 1024.0, then printed it to one decimal place. A value like 1023.95 KiB stops the loop and is then rounded up by the formatting, so 1_048_525 bytes rendered as "1024.0 KiB" instead of "1.0 MiB" — a size shown in a unit it has outgrown. The same happened at every boundary.

That string reaches users: this formatter renders the out-of-space refusal and its "Free up …" shortfall figure, so an install blocked on a nearly-full disk could quote 1024.0 KiB.

Risk: low. One pure function, no API or schema change. The loop condition now compares the value as printed rather than the raw value.

The part worth a reviewer's attention

This defect was already known and already fixed — in the other byte formatter, in apps/rocm/src/main.rs, whose test is named format_bytes_steps_up_instead_of_printing_1024_of_the_smaller_unit and whose comment reads "One byte short of the next unit used to round to 1024.0 KiB". The rocm-core copy kept the bug, because an example test sits next to one implementation and cannot see its twin.

So the guard here is a property, not another example: no rendered size may carry a mantissa of 1024 unless it is already in the largest unit. A property is attached to the contract rather than to a call site, so it holds wherever it is pointed.

The two formatters still disagree in output shape ("1023 bytes" vs "1023 B") and in their largest unit (GiB vs TiB). Unifying them touches user-facing strings on two surfaces and is deliberately not attempted here.

Test plan

Red-before/green-after verified by reverting only the loop condition: the property fails and shrinks to bytes = 1048525, the same minimal input each run. That value and the other boundaries are also pinned as explicit examples, so the specific defect stays named if the generator is ever retuned — including 1_048_524 -> "1023.9 KiB", so the promotion cannot reach down and swallow a value that genuinely belongs to the smaller unit.

Four further properties come with it, covering margin arithmetic (with_margin never shrinks a requirement; the extracted-size estimate never underestimates), classify_space totality, and mount selection (soundness, maximality, and irrelevance of a non-matching mount).

  • cargo test -p rocm-core --lib disk_space — 28 passed, 0 failed, ~0.03s: no subprocess, no filesystem, no GPU
  • cargo clippy --locked --workspace --all-targets -- -D warnings, cargo fmt --all --check, cargo xtask manifest --check, cargo xtask tpn --check — all exit 0

A caveat recorded in the code, because it is easy to get wrong

The first version of the central property passed against a defect that was definitely present. A uniform u64 generator essentially never lands near a unit boundary — almost every draw is in the exabyte range — so it never sampled the only region where the scaling loop can go wrong. The sampling window has to scale with the boundary, because the band where rounding bites is itself proportional. That reasoning is in the generator's doc comment rather than only in this description, since the next person to touch it needs it.

The reference the properties measure against is also kept derived from the contract rather than restated from the implementation, for the same reason.

On the Gherkin requirement

No scenario is added. Reaching this string end-to-end requires a genuinely nearly-full filesystem; a scenario would have to fake one, which tests the fake rather than the behaviour. The formatter is covered at the unit level, where it is a pure function.

rominf added 4 commits October 2, 2026 08:16
`runtime_install_root_is_protected` decides whether a folder may be handed
to `fs::remove_dir_all`. It compared normalized path TEXT, and off Windows
that normalization is `value.to_owned()` -- a no-op. So the check
`path == root || root in path.ancestors()` saw `/etc/` and `/etc` as
different folders, and nothing resolved `.` or `..` at all.

Measured against the real gate,
`ensure_runtime_install_root_is_safe_to_remove` accepted every one of
`/etc/`, `//etc`, `/./etc`, `/etc/.`, `/usr/` and `$HOME/../../etc`,
refusing only the exact spelling `/etc`. The trailing-slash case needs no
adversary: a `dirname`, a shell variable or a hand-edit produces it. The
`..` case defeats the guard differently -- `Path::ancestors()` treats `..`
as an ordinary component, so a path that escapes `$HOME` still looks like
it is inside it and takes the home exemption's early return.

The guard's own comment says it exists for "a hand-edited or corrupted
registry entry [pointing] `install_root` at a protected system location".
It failed at exactly that. The existing test passed throughout because it
used `/etc/rocm-cli-test-runtime` -- a subpath, the one shape that works.

Both comparisons now resolve `.`, `..`, repeated separators and a trailing
separator before comparing. Folding `\` to `/` stays gated on Windows:
off Windows a backslash is an ordinary filename byte, so a directory named
`..\tmp` inside `/usr` must not be rewritten into `/usr/../tmp`. Raw-text
comparison tolerated an unconditional fold because equality never *removed*
components; resolving `..` does, which would have turned this fix into a
new bypass.

The resolution is lexical because these paths often do not exist yet, so
`canonicalize` has nothing to resolve. It is NOT symlink-safe, and the
comments say so rather than claiming otherwise: because the home exemption
is consulted first, a symlink the user owns is enough -- with
`$HOME/link -> /etc`, `$HOME/link/child` is lexically inside home, so the
guard reports it removable while the kernel lands in `/etc`. Closing that
needs a decision about canonicalizing the part of the path that does
exist, and belongs in its own change.

Two further consumers of the same comparison are fixed by this:
`chat_install_prefix_is_system`, which gates an install prefix that
arrives as model-generated JSON, and `rocmd`'s `system_prefix_requires_ack`,
which stopped demanding acknowledgement for a `..` path that escaped home.

The guard is pinned by properties rather than more examples, because the
contract is about every spelling of a path and an example only ever covers
the spellings someone thought of. The reference the properties measure
against is derived from POSIX semantics, not restated from the code, so it
does not inherit the code's mistakes -- it is what makes a one-component
change to the generator alphabet catch the backslash case above.

The comparison helpers are now parameterised by platform instead of
reading `runtime_is_windows()`, so the Windows path-resolution rules are
exercised from a Linux host. Note this covers the helpers only: the branch
selecting the Windows protected-root list still reads `runtime_is_windows()`
and remains reachable only on the Windows lane. Parameterising also
required the containment test to compare bytes rather than `&path[..n]`,
which splits a non-ASCII path mid-character and panics.

On Windows this trades one inaccuracy for another, deliberately: direct
respellings (`C:/Windows/`, `c:/windows`, `C:/Windows//System32`) are now
caught, while `C:\Windows\..\ProgramData` -- previously refused only
because the text walk matched `C:\Windows` as a literal ancestor -- now
correctly resolves to `C:/ProgramData` and is accepted, because that path
is not on the list. The three-entry Windows list omits the drive root,
`C:/Users` and `C:/ProgramData`, and hardcodes drive `C:` and English
folder names. That is an incomplete policy rather than a broken
comparison, it was previously masked by the ancestor-walk bug, and it
deserves its own change.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Three reviewer nits on the respelling fix for
runtime_install_root_is_protected, addressed with tests only:

- rocmd's system_prefix_requires_ack was only claimed as a fixed
  consumer; its existing test used an already-canonical prefix, so it
  never drove the `..`-escaping bug. Add a regression built from the
  real home directory plus `/../../usr`, confirmed red against the
  pre-fix comparison and green against the fix.
- The new respelling tests are unix-only, so the public gate's Windows
  arm (home exemption plus the three protected roots) was never
  exercised on the Windows lane end to end. Add a test, following the
  existing cfg!(windows)-at-runtime fixture-selection pattern, that
  checks respelled Windows paths when actually running on Windows and
  the Unix answer otherwise.
- The property suite's POSIX reference oracle silently assumed
  absolute input, true only because every generator seed happens to be
  absolute today. Pin that assumption with an assertion so a future
  generator change can't desync the oracle from the code it measures.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
`format_bytes` scaled while the raw value was >= 1024, then printed it to
one decimal place. Between those two steps a value like 1023.95 KiB stops
the loop and then rounds up, so 1_048_525 bytes rendered as "1024.0 KiB"
instead of "1.0 MiB" -- a size shown in a unit it has outgrown, in the
user-facing "not enough free disk space" message and its "Free up ..."
figure. The same happened at every unit boundary.

This defect was already known and already fixed in the OTHER byte
formatter, `apps/rocm`'s, whose test is named
`format_bytes_steps_up_instead_of_printing_1024_of_the_smaller_unit`.
The copy here kept it, because an example test sits next to one
implementation and cannot see the other.

So the guard added here is a property, not another example: no rendered
size may carry a mantissa of 1024 unless it is already in the largest
unit. It is attached to the contract rather than to a call site, so it
holds wherever it is pointed. The minimal input proptest shrank to,
1_048_525, is pinned as an example too, so the specific defect stays
named if the generator is ever retuned.

Four more contracts come with it, covering margin arithmetic and mount
selection (soundness, maximality, and irrelevance of a non-matching
mount). All pure and in-process: the module runs in ~0.03s on the
ordinary unit-test lane, with no subprocess, filesystem or GPU.

One caveat is written into the generator's doc comment, because it is the
part that is easy to get wrong: the naive version, drawing a uniform u64,
PASSED against a defect that was definitely present, because essentially
every draw lands in the exabyte range and never visits a unit boundary.
The sampling window has to scale with the boundary, since the band where
rounding bites is itself proportional. The generator is part of the
specification.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
The previous commit made the `cfg(not(unix))` stub a `const fn` while the
unix implementation stays a plain `fn`, so the one function has a
different signature on each platform. That is the pattern to avoid:
`missing_const_for_fn` only fires on the platform with the trivial body,
and a const on one side invites callers to rely on something the other
side cannot provide. Neither caller is const, and the lint is not enabled
on main, so nothing needed it. The commit message didn't mention the
change either.

Signed-off-by: Roman Inflianskas <Roman.Inflianskas@amd.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

disk-space: format_bytes renders a size in a unit it has outgrown ("1024.0 KiB" instead of "1.0 MiB")

1 participant