From 11e0a95a2a2f108cd42f6f9de4c96ad0f6b04f92 Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Sun, 11 Oct 2026 15:37:03 +0100 Subject: [PATCH 1/5] fix(cli): label byte sizes in binary units Every size LPM CLI prints is counted in 1024s, but read KB, MB and GB: `lpm cache status`, publish, download, self-update, graph and tunnel limits. `lpm_common::format_bytes` now says KiB, MiB and GiB, matching the LPM.dev Registry, which bills remote cache overage per GiB, and the three local copies of it in download, self-update and graph use it instead. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/lpm-cli/src/commands/download.rs | 12 +---------- crates/lpm-cli/src/commands/graph.rs | 17 +-------------- crates/lpm-cli/src/commands/publish/tests.rs | 2 +- crates/lpm-cli/src/commands/self_update.rs | 21 +------------------ crates/lpm-cli/src/commands/tool_execution.rs | 2 +- crates/lpm-cli/src/commands/tunnel.rs | 4 ++-- crates/lpm-common/src/lib.rs | 20 ++++++++++++++---- tests/workflows/tests/cache.rs | 2 +- tests/workflows/tests/graph.rs | 10 ++++----- 9 files changed, 29 insertions(+), 61 deletions(-) diff --git a/crates/lpm-cli/src/commands/download.rs b/crates/lpm-cli/src/commands/download.rs index aeab22b0a..be9c36027 100644 --- a/crates/lpm-cli/src/commands/download.rs +++ b/crates/lpm-cli/src/commands/download.rs @@ -202,7 +202,7 @@ pub async fn run( crate::install_ui::terminal_line!( " {} {}", install_ui::dim(&format!("{:<16}", "size:")), - format_bytes(size) + lpm_common::format_bytes(size as u64) ) ); eprintln!(); @@ -263,13 +263,3 @@ fn short_integrity(integrity: &str) -> String { .collect(); format!("{prefix}…{suffix}") } - -fn format_bytes(bytes: usize) -> String { - if bytes >= 1_048_576 { - format!("{:.1} MB", bytes as f64 / 1_048_576.0) - } else if bytes >= 1024 { - format!("{:.1} KB", bytes as f64 / 1024.0) - } else { - format!("{bytes} B") - } -} diff --git a/crates/lpm-cli/src/commands/graph.rs b/crates/lpm-cli/src/commands/graph.rs index faa39768a..ef9cb5122 100644 --- a/crates/lpm-cli/src/commands/graph.rs +++ b/crates/lpm-cli/src/commands/graph.rs @@ -250,7 +250,7 @@ pub async fn run( install_ui::done_line(crate::install_ui::terminal_line!( "Generated {} ({})", out_path.display().to_string(), - format_byte_size(html_size), + lpm_common::format_bytes(html_size), )); // Open in browser unless suppressed (headless / CI). @@ -531,21 +531,6 @@ fn prune_unreachable(graph: &mut DepGraph) { graph_render::recompute_stats(graph); } -/// Format a byte count as a short human-readable string. Uses 1024-based -/// units to match `du -h` / file managers; switches unit at the natural -/// boundary so a 900-byte HTML never reads as "0 KB". -fn format_byte_size(bytes: u64) -> String { - const KIB: u64 = 1024; - const MIB: u64 = 1024 * 1024; - if bytes >= MIB { - format!("{:.1} MB", bytes as f64 / MIB as f64) - } else if bytes >= KIB { - format!("{:.1} KB", bytes as f64 / KIB as f64) - } else { - format!("{bytes} B") - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/lpm-cli/src/commands/publish/tests.rs b/crates/lpm-cli/src/commands/publish/tests.rs index 4b746f13c..3c34aeca1 100644 --- a/crates/lpm-cli/src/commands/publish/tests.rs +++ b/crates/lpm-cli/src/commands/publish/tests.rs @@ -772,7 +772,7 @@ fn publish_result_display() { fn dry_run_files_value_uses_slim_value_roles() { assert_eq!( console::strip_ansi_codes(&format_dry_run_files_value(3, 2048)).into_owned(), - "3 files (2.0 KB)" + "3 files (2.0 KiB)" ); } diff --git a/crates/lpm-cli/src/commands/self_update.rs b/crates/lpm-cli/src/commands/self_update.rs index 0b6444a17..3c2cfe3c9 100644 --- a/crates/lpm-cli/src/commands/self_update.rs +++ b/crates/lpm-cli/src/commands/self_update.rs @@ -2992,7 +2992,7 @@ async fn verify_and_stage_for_standalone( install_ui::done_untrusted(&format!( "Verified Sigstore attestation for {} ({}, integratedTime {})", binary_name, - format_bytes(staged.byte_len), + lpm_common::format_bytes(staged.byte_len as u64), audit.integrated_time )); } @@ -3299,16 +3299,6 @@ fn humanize_seconds(secs: u64) -> String { } } -fn format_bytes(bytes: usize) -> String { - if bytes >= 1_048_576 { - format!("{:.1} MB", bytes as f64 / 1_048_576.0) - } else if bytes >= 1024 { - format!("{:.1} KB", bytes as f64 / 1024.0) - } else { - format!("{bytes} B") - } -} - #[cfg(test)] mod tests { use super::*; @@ -5482,15 +5472,6 @@ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb lpm-linux-x64 ); } - #[test] - fn format_bytes_units() { - assert_eq!(format_bytes(500), "500 B"); - assert_eq!(format_bytes(1024), "1.0 KB"); - assert_eq!(format_bytes(1536), "1.5 KB"); - assert_eq!(format_bytes(1_048_576), "1.0 MB"); - assert_eq!(format_bytes(2_621_440), "2.5 MB"); - } - /// Rate-limit lands on `SelfUpdateRateLimited`, not `Forbidden`. /// `Forbidden` is the user-permission category and reads to users /// as "you're banned" — wrong category for a quota-reset wait. diff --git a/crates/lpm-cli/src/commands/tool_execution.rs b/crates/lpm-cli/src/commands/tool_execution.rs index 1b5698f06..eea6a37c5 100644 --- a/crates/lpm-cli/src/commands/tool_execution.rs +++ b/crates/lpm-cli/src/commands/tool_execution.rs @@ -8,7 +8,7 @@ use std::process::{Command, Stdio}; /// Maximum size for captured workspace stdout/stderr before truncation. /// Mirrors the `MAX_CAPTURED_OUTPUT` constant in `commands::run` so chatty /// failing members don't unbound the JSON envelope. -const MAX_CAPTURED_OUTPUT: usize = 10 * 1024 * 1024; // 10 MB +const MAX_CAPTURED_OUTPUT: usize = 10 * 1024 * 1024; // 10 MiB /// Truncate captured output if it exceeds `MAX_CAPTURED_OUTPUT`, cutting at /// the last newline boundary to avoid splitting a line. diff --git a/crates/lpm-cli/src/commands/tunnel.rs b/crates/lpm-cli/src/commands/tunnel.rs index e0fff6142..0ad06c3cc 100644 --- a/crates/lpm-cli/src/commands/tunnel.rs +++ b/crates/lpm-cli/src/commands/tunnel.rs @@ -2292,7 +2292,7 @@ mod tests { assert_eq!( tunnel_limit_summary(Some(&limits)).as_deref(), - Some("1 tunnel · 4000/min · 10.0 MB body · 0 custom domains · 600/min/IP") + Some("1 tunnel · 4000/min · 10.0 MiB body · 0 custom domains · 600/min/IP") ); } @@ -2309,7 +2309,7 @@ mod tests { assert_eq!( tunnel_limit_summary(Some(&limits)).as_deref(), - Some("3 tunnels · 20000/min · 100.0 MB body · 3 custom domains · 600/min/IP") + Some("3 tunnels · 20000/min · 100.0 MiB body · 3 custom domains · 600/min/IP") ); } diff --git a/crates/lpm-common/src/lib.rs b/crates/lpm-common/src/lib.rs index c4795d61b..3725c6cbf 100644 --- a/crates/lpm-common/src/lib.rs +++ b/crates/lpm-common/src/lib.rs @@ -309,16 +309,18 @@ pub fn is_loopback_host(host: &str) -> bool { false } -/// Format bytes into a human-readable string (e.g., "1.2 KB", "3.4 MB"). +/// Format bytes into a human-readable string in binary units, as they're +/// counted (e.g., "1.2 KiB", "3.4 MiB"). Switches unit at each 1024, so a +/// 900-byte file never reads as "0.9 KiB" or "0 KiB". pub fn format_bytes(bytes: u64) -> String { if bytes < 1024 { format!("{bytes} B") } else if bytes < 1024 * 1024 { - format!("{:.1} KB", bytes as f64 / 1024.0) + format!("{:.1} KiB", bytes as f64 / 1024.0) } else if bytes < 1024 * 1024 * 1024 { - format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + format!("{:.1} MiB", bytes as f64 / (1024.0 * 1024.0)) } else { - format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) + format!("{:.1} GiB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) } } @@ -326,6 +328,16 @@ pub fn format_bytes(bytes: u64) -> String { mod tests { use super::*; + #[test] + fn format_bytes_labels_binary_units() { + assert_eq!(format_bytes(500), "500 B"); + assert_eq!(format_bytes(1024), "1.0 KiB"); + assert_eq!(format_bytes(1536), "1.5 KiB"); + assert_eq!(format_bytes(1_048_576), "1.0 MiB"); + assert_eq!(format_bytes(2_621_440), "2.5 MiB"); + assert_eq!(format_bytes(5 * 1024 * 1024 * 1024), "5.0 GiB"); + } + // ── read_capped_state_file ──────────────────────────────────────── /// Files under the cap round-trip transparently. diff --git a/tests/workflows/tests/cache.rs b/tests/workflows/tests/cache.rs index 8885e42e5..78314f35c 100644 --- a/tests/workflows/tests/cache.rs +++ b/tests/workflows/tests/cache.rs @@ -530,7 +530,7 @@ async fn cache_status_human_renders_tables_and_slim_completion() { "cache status must render local and remote sections, got:\n{stdout}" ); assert!( - stdout.contains("1.0 KB / 2.0 KB █████░░░░░"), + stdout.contains("1.0 KiB / 2.0 KiB █████░░░░░"), "cache status must render remote usage with a quota bar, got:\n{stdout}" ); diff --git a/tests/workflows/tests/graph.rs b/tests/workflows/tests/graph.rs index 5dc50e319..b72dadc5a 100644 --- a/tests/workflows/tests/graph.rs +++ b/tests/workflows/tests/graph.rs @@ -349,16 +349,16 @@ fn graph_html_writes_human_readable_size() { String::from_utf8_lossy(&output.stderr) ); - // The size suffix must read as KB or MB — never "0 KB" via integer - // division. The fixture renders well over 1 KB of HTML, so KB is the + // The size suffix must read as KiB or MiB — never "0 KiB" via integer + // division. The fixture renders well over 1 KiB of HTML, so KiB is the // expected unit. assert!( - combined.contains(" KB)") || combined.contains(" MB)"), + combined.contains(" KiB)") || combined.contains(" MiB)"), "size in success message should use a human-readable unit: {combined}" ); assert!( - !combined.contains("(0 KB)"), - "size must never collapse to 0 KB via integer truncation: {combined}" + !combined.contains("(0 KiB)"), + "size must never collapse to 0 KiB via integer truncation: {combined}" ); } From ec4a690d87df0115d7f45f6dd4802fa9c014a985 Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Sun, 11 Oct 2026 15:57:19 +0100 Subject: [PATCH 2/5] fix(cli): label the remaining byte sizes in binary units Skill size limits, `lpm whoami` storage, truncated tool output, runtime download caps and tunnel webhook sizes say KiB or MiB. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/lpm-cli/src/commands/run/parallel.rs | 2 +- crates/lpm-cli/src/commands/run/tests.rs | 4 ++-- crates/lpm-cli/src/commands/skills/author.rs | 8 ++++---- crates/lpm-cli/src/commands/tool_execution.rs | 4 ++-- crates/lpm-cli/src/commands/whoami.rs | 6 +++--- crates/lpm-runtime/src/download.rs | 4 ++-- crates/lpm-tunnel/src/webhook.rs | 12 ++++++------ tests/workflows/tests/skills.rs | 4 ++-- tests/workflows/tests/whoami.rs | 3 ++- 9 files changed, 24 insertions(+), 23 deletions(-) diff --git a/crates/lpm-cli/src/commands/run/parallel.rs b/crates/lpm-cli/src/commands/run/parallel.rs index 5899de03d..241b48498 100644 --- a/crates/lpm-cli/src/commands/run/parallel.rs +++ b/crates/lpm-cli/src/commands/run/parallel.rs @@ -36,7 +36,7 @@ pub(super) fn truncate_output(output: String) -> String { let truncated = &output[..limit]; let end = truncated.rfind('\n').unwrap_or(limit); format!( - "{}...\n\n[output truncated at {}MB]", + "{}...\n\n[output truncated at {} MiB]", &output[..end], MAX_CAPTURED_OUTPUT / (1024 * 1024), ) diff --git a/crates/lpm-cli/src/commands/run/tests.rs b/crates/lpm-cli/src/commands/run/tests.rs index 413885138..eeaa6938f 100644 --- a/crates/lpm-cli/src/commands/run/tests.rs +++ b/crates/lpm-cli/src/commands/run/tests.rs @@ -155,7 +155,7 @@ fn truncate_output_large_truncated() { let large = "x".repeat(11 * 1024 * 1024); let result = truncate_output(large); assert!(result.len() <= MAX_CAPTURED_OUTPUT + 100); // +100 for the message - assert!(result.contains("[output truncated at 10MB]")); + assert!(result.contains("[output truncated at 10 MiB]")); } #[test] @@ -167,7 +167,7 @@ fn truncate_output_cuts_at_newline() { s.push_str(&line); } let result = truncate_output(s); - assert!(result.contains("[output truncated at 10MB]")); + assert!(result.contains("[output truncated at 10 MiB]")); // The truncated content (before "...") should end at a newline boundary let before_ellipsis = result.split("...\n").next().unwrap(); assert!(before_ellipsis.ends_with('\n') || before_ellipsis.ends_with('a')); diff --git a/crates/lpm-cli/src/commands/skills/author.rs b/crates/lpm-cli/src/commands/skills/author.rs index 4bd185836..97f381dc2 100644 --- a/crates/lpm-cli/src/commands/skills/author.rs +++ b/crates/lpm-cli/src/commands/skills/author.rs @@ -154,7 +154,7 @@ fn validate_open_directory(skills_dir: &cap_std::fs::Dir) -> Result MAX_TOTAL_SIZE { report.errors.push(format!( - "total package skills size {} bytes exceeds 100KB limit", + "total package skills size {} bytes exceeds 100 KiB limit", report.total_size_bytes )); } @@ -203,7 +203,7 @@ fn validate_opened_file( if size > MAX_SKILL_SIZE { report .errors - .push(format!("{display}: exceeds 15KB limit ({size} bytes)")); + .push(format!("{display}: exceeds 15 KiB limit ({size} bytes)")); return Ok(()); } @@ -217,7 +217,7 @@ fn validate_opened_file( .saturating_sub(size) .saturating_add(bytes.len() as u64); report.errors.push(format!( - "{display}: exceeds 15KB limit ({} bytes)", + "{display}: exceeds 15 KiB limit ({} bytes)", bytes.len() )); return Ok(()); @@ -597,7 +597,7 @@ mod tests { report .errors .iter() - .any(|error| error.contains("exceeds 100KB limit")) + .any(|error| error.contains("exceeds 100 KiB limit")) ); assert!(retained_bytes <= MAX_TOTAL_SIZE, "{retained_bytes}"); } diff --git a/crates/lpm-cli/src/commands/tool_execution.rs b/crates/lpm-cli/src/commands/tool_execution.rs index eea6a37c5..20521aa2b 100644 --- a/crates/lpm-cli/src/commands/tool_execution.rs +++ b/crates/lpm-cli/src/commands/tool_execution.rs @@ -20,7 +20,7 @@ fn truncate_output(text: &str) -> String { } let end = text[..end].rfind('\n').unwrap_or(end); format!( - "{}...\n\n[output truncated at {}MB]", + "{}...\n\n[output truncated at {} MiB]", &text[..end], MAX_CAPTURED_OUTPUT / (1024 * 1024), ) @@ -343,7 +343,7 @@ mod tests { let huge = "x".repeat(MAX_CAPTURED_OUTPUT + 2_000); let result = truncate_output(&huge); assert!( - result.ends_with("[output truncated at 10MB]"), + result.ends_with("[output truncated at 10 MiB]"), "expected truncation marker, got tail: {:?}", &result[result.len().saturating_sub(60)..] ); diff --git a/crates/lpm-cli/src/commands/whoami.rs b/crates/lpm-cli/src/commands/whoami.rs index d9664e06e..87037cffc 100644 --- a/crates/lpm-cli/src/commands/whoami.rs +++ b/crates/lpm-cli/src/commands/whoami.rs @@ -107,7 +107,7 @@ pub async fn run(client: &RegistryClient, json_output: bool) -> Result<(), LpmEr if let Some(limit_bytes) = limits.storage_bytes { let limit_mb = limit_bytes as f64 / (1024.0 * 1024.0); let storage_msg = with_usage_bar( - format!("{:.2}MB / {:.0}MB", storage_mb, limit_mb), + format!("{:.2} MiB / {:.0} MiB", storage_mb, limit_mb), usage.storage_bytes, limit_bytes, ); @@ -120,7 +120,7 @@ pub async fn run(client: &RegistryClient, json_output: bool) -> Result<(), LpmEr whoami_ui::detail("storage", storage_msg); } } else { - whoami_ui::detail("storage", format!("{:.2}MB", storage_mb)); + whoami_ui::detail("storage", format!("{:.2} MiB", storage_mb)); } // Package count @@ -163,7 +163,7 @@ pub async fn run(client: &RegistryClient, json_output: bool) -> Result<(), LpmEr whoami_ui::warn("Upgrade your plan: https://lpm.dev/dashboard/settings/billing"); } } else { - whoami_ui::detail("storage", format!("{:.2}MB", storage_mb)); + whoami_ui::detail("storage", format!("{:.2} MiB", storage_mb)); whoami_ui::detail("private pkgs", format!("{}", usage.private_packages)); } } diff --git a/crates/lpm-runtime/src/download.rs b/crates/lpm-runtime/src/download.rs index d8f5c5939..da8a84e58 100644 --- a/crates/lpm-runtime/src/download.rs +++ b/crates/lpm-runtime/src/download.rs @@ -369,7 +369,7 @@ async fn read_download_body( fn checked_download_size(size: u64) -> Result { let size = usize::try_from(size).map_err(|_| { LpmError::Script(format!( - "download size {size} bytes exceeds maximum allowed size of {MAX_DOWNLOAD_SIZE} bytes ({}MB)", + "download size {size} bytes exceeds maximum allowed size of {MAX_DOWNLOAD_SIZE} bytes ({} MiB)", MAX_DOWNLOAD_SIZE / (1024 * 1024) )) })?; @@ -763,7 +763,7 @@ const MAX_DOWNLOAD_SIZE: usize = 200 * 1024 * 1024; fn validate_download_size(size: usize) -> Result<(), LpmError> { if size > MAX_DOWNLOAD_SIZE { return Err(LpmError::Network(format!( - "download size {size} bytes exceeds maximum allowed size of {MAX_DOWNLOAD_SIZE} bytes ({}MB)", + "download size {size} bytes exceeds maximum allowed size of {MAX_DOWNLOAD_SIZE} bytes ({} MiB)", MAX_DOWNLOAD_SIZE / (1024 * 1024) ))); } diff --git a/crates/lpm-tunnel/src/webhook.rs b/crates/lpm-tunnel/src/webhook.rs index 18265e605..658c2c7e9 100644 --- a/crates/lpm-tunnel/src/webhook.rs +++ b/crates/lpm-tunnel/src/webhook.rs @@ -219,9 +219,9 @@ fn format_size(bytes: usize) -> String { if bytes < 1024 { format!("{bytes}B") } else if bytes < 1024 * 1024 { - format!("{:.1}KB", bytes as f64 / 1024.0) + format!("{:.1}KiB", bytes as f64 / 1024.0) } else { - format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0)) + format!("{:.1}MiB", bytes as f64 / (1024.0 * 1024.0)) } } @@ -414,7 +414,7 @@ mod tests { w.path = "/api/hook".to_string(); w.request_body = vec![0u8; 2500]; }); - assert_eq!(summarize_webhook(&wh), "POST /api/hook (2.4KB)"); + assert_eq!(summarize_webhook(&wh), "POST /api/hook (2.4KiB)"); } // -- CapturedWebhook methods -- @@ -535,12 +535,12 @@ mod tests { #[test] fn format_size_kilobytes() { - assert_eq!(format_size(1024), "1.0KB"); - assert_eq!(format_size(2560), "2.5KB"); + assert_eq!(format_size(1024), "1.0KiB"); + assert_eq!(format_size(2560), "2.5KiB"); } #[test] fn format_size_megabytes() { - assert_eq!(format_size(1024 * 1024), "1.0MB"); + assert_eq!(format_size(1024 * 1024), "1.0MiB"); } } diff --git a/tests/workflows/tests/skills.rs b/tests/workflows/tests/skills.rs index 6894b0203..74b079548 100644 --- a/tests/workflows/tests/skills.rs +++ b/tests/workflows/tests/skills.rs @@ -2887,7 +2887,7 @@ fn skills_validate_rejects_skill_exceeding_size_limit() { String::from_utf8_lossy(&output.stderr), ); assert!( - combined.contains("15KB") || combined.contains("exceed") || combined.contains("limit"), + combined.contains("15 KiB") || combined.contains("exceed") || combined.contains("limit"), "validate output must surface the per-skill size-limit warning, got:\n{combined}", ); let stderr = String::from_utf8_lossy(&output.stderr); @@ -2940,7 +2940,7 @@ fn skills_validate_json_reports_success_false_and_exits_non_zero_on_error() { .as_str() .expect("each error entry must be a string"); assert!( - error_text.contains("15KB") || error_text.contains("limit"), + error_text.contains("15 KiB") || error_text.contains("limit"), "error text must name the violated limit, got {error_text:?}", ); } diff --git a/tests/workflows/tests/whoami.rs b/tests/workflows/tests/whoami.rs index fbe9903fc..3673c6ade 100644 --- a/tests/workflows/tests/whoami.rs +++ b/tests/workflows/tests/whoami.rs @@ -149,7 +149,8 @@ async fn whoami_human_output_uses_slim_account_summary() { "authenticated whoami should warn when auth storage uses the encrypted file fallback, got:\n{stderr}" ); assert!( - stderr.contains("usage") && stderr.contains("storage 50.00MB / 500MB █░░░░░░░░░"), + stderr.contains("usage") + && stderr.contains("storage 50.00 MiB / 500 MiB █░░░░░░░░░"), "authenticated whoami should show storage usage with a quota bar, got:\n{stderr}" ); assert!( From 996c05754742abef0d31b9b97aab7163d518a352 Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Mon, 12 Oct 2026 14:10:03 +0100 Subject: [PATCH 3/5] fix(cli): show whoami storage, large sizes and env data in binary units - lpm whoami prints usage and limit through format_bytes, as the dashboard shows them, where it printed MiB always. - format_bytes labels TiB and PiB, takes the next unit once the tenth would read 1024.0, and prints a whole number only for an exact size. - The Keychain warning names env data and states the limit in KiB. - Tunnel webhook summaries use format_bytes rather than a copy of it. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/lpm-cli/src/commands/download.rs | 4 +- crates/lpm-cli/src/commands/publish/tests.rs | 2 +- crates/lpm-cli/src/commands/tool_execution.rs | 2 +- crates/lpm-cli/src/commands/tunnel.rs | 4 +- crates/lpm-cli/src/commands/whoami.rs | 9 ++-- crates/lpm-common/src/lib.rs | 44 ++++++++++++++----- crates/lpm-tunnel/src/webhook.rs | 35 +-------------- crates/lpm-vault/src/keychain.rs | 5 ++- tests/workflows/tests/cache.rs | 2 +- tests/workflows/tests/whoami.rs | 7 ++- 10 files changed, 53 insertions(+), 61 deletions(-) diff --git a/crates/lpm-cli/src/commands/download.rs b/crates/lpm-cli/src/commands/download.rs index be9c36027..e618f68e4 100644 --- a/crates/lpm-cli/src/commands/download.rs +++ b/crates/lpm-cli/src/commands/download.rs @@ -128,7 +128,7 @@ pub async fn run( } }; let firewall_json = firewall_verdict.settle(json_output).await?; - let size = downloaded.compressed_size as usize; + let size = downloaded.compressed_size; let integrity_verified = if let Some(sri) = integrity_str.as_ref() { if !json_output { @@ -202,7 +202,7 @@ pub async fn run( crate::install_ui::terminal_line!( " {} {}", install_ui::dim(&format!("{:<16}", "size:")), - lpm_common::format_bytes(size as u64) + lpm_common::format_bytes(size) ) ); eprintln!(); diff --git a/crates/lpm-cli/src/commands/publish/tests.rs b/crates/lpm-cli/src/commands/publish/tests.rs index 3c34aeca1..b2067812f 100644 --- a/crates/lpm-cli/src/commands/publish/tests.rs +++ b/crates/lpm-cli/src/commands/publish/tests.rs @@ -772,7 +772,7 @@ fn publish_result_display() { fn dry_run_files_value_uses_slim_value_roles() { assert_eq!( console::strip_ansi_codes(&format_dry_run_files_value(3, 2048)).into_owned(), - "3 files (2.0 KiB)" + "3 files (2 KiB)" ); } diff --git a/crates/lpm-cli/src/commands/tool_execution.rs b/crates/lpm-cli/src/commands/tool_execution.rs index 20521aa2b..ff1d88263 100644 --- a/crates/lpm-cli/src/commands/tool_execution.rs +++ b/crates/lpm-cli/src/commands/tool_execution.rs @@ -244,7 +244,7 @@ pub(super) fn selected_schedule_state( } /// Emit the workspace JSON envelope. Stdout/stderr surface ONLY for failed -/// members and are truncated at the 10MB ceiling. +/// members and are truncated at the 10 MiB ceiling. pub(super) fn emit_envelope( results: &[MemberResult], total: usize, diff --git a/crates/lpm-cli/src/commands/tunnel.rs b/crates/lpm-cli/src/commands/tunnel.rs index 0ad06c3cc..9ebe4b4cf 100644 --- a/crates/lpm-cli/src/commands/tunnel.rs +++ b/crates/lpm-cli/src/commands/tunnel.rs @@ -2292,7 +2292,7 @@ mod tests { assert_eq!( tunnel_limit_summary(Some(&limits)).as_deref(), - Some("1 tunnel · 4000/min · 10.0 MiB body · 0 custom domains · 600/min/IP") + Some("1 tunnel · 4000/min · 10 MiB body · 0 custom domains · 600/min/IP") ); } @@ -2309,7 +2309,7 @@ mod tests { assert_eq!( tunnel_limit_summary(Some(&limits)).as_deref(), - Some("3 tunnels · 20000/min · 100.0 MiB body · 3 custom domains · 600/min/IP") + Some("3 tunnels · 20000/min · 100 MiB body · 3 custom domains · 600/min/IP") ); } diff --git a/crates/lpm-cli/src/commands/whoami.rs b/crates/lpm-cli/src/commands/whoami.rs index 87037cffc..2a2e1e395 100644 --- a/crates/lpm-cli/src/commands/whoami.rs +++ b/crates/lpm-cli/src/commands/whoami.rs @@ -100,14 +100,13 @@ pub async fn run(client: &RegistryClient, json_output: bool) -> Result<(), LpmEr if let Some(usage) = &user.usage { whoami_ui::blank_line(); whoami_ui::section("usage"); - let storage_mb = usage.storage_bytes as f64 / (1024.0 * 1024.0); + let storage = lpm_common::format_bytes(usage.storage_bytes); if let Some(limits) = &user.limits { // Storage if let Some(limit_bytes) = limits.storage_bytes { - let limit_mb = limit_bytes as f64 / (1024.0 * 1024.0); let storage_msg = with_usage_bar( - format!("{:.2} MiB / {:.0} MiB", storage_mb, limit_mb), + format!("{storage} / {}", lpm_common::format_bytes(limit_bytes)), usage.storage_bytes, limit_bytes, ); @@ -120,7 +119,7 @@ pub async fn run(client: &RegistryClient, json_output: bool) -> Result<(), LpmEr whoami_ui::detail("storage", storage_msg); } } else { - whoami_ui::detail("storage", format!("{:.2} MiB", storage_mb)); + whoami_ui::detail("storage", storage); } // Package count @@ -163,7 +162,7 @@ pub async fn run(client: &RegistryClient, json_output: bool) -> Result<(), LpmEr whoami_ui::warn("Upgrade your plan: https://lpm.dev/dashboard/settings/billing"); } } else { - whoami_ui::detail("storage", format!("{:.2} MiB", storage_mb)); + whoami_ui::detail("storage", storage); whoami_ui::detail("private pkgs", format!("{}", usage.private_packages)); } } diff --git a/crates/lpm-common/src/lib.rs b/crates/lpm-common/src/lib.rs index 3725c6cbf..f4af66859 100644 --- a/crates/lpm-common/src/lib.rs +++ b/crates/lpm-common/src/lib.rs @@ -310,17 +310,31 @@ pub fn is_loopback_host(host: &str) -> bool { } /// Format bytes into a human-readable string in binary units, as they're -/// counted (e.g., "1.2 KiB", "3.4 MiB"). Switches unit at each 1024, so a -/// 900-byte file never reads as "0.9 KiB" or "0 KiB". +/// counted (e.g., "1.2 KiB", "3.4 MiB", "5 GiB"), as the LPM.dev Registry +/// labels them. Switches unit at each 1024, so a 900-byte file never reads +/// as "0.9 KiB", and a value whose tenth would read 1024.0 takes the next +/// unit. A whole number only for an exact size, so 5 GiB and a few bytes +/// reads "5.0 GiB". pub fn format_bytes(bytes: u64) -> String { + const UNITS: [&str; 5] = ["KiB", "MiB", "GiB", "TiB", "PiB"]; if bytes < 1024 { - format!("{bytes} B") - } else if bytes < 1024 * 1024 { - format!("{:.1} KiB", bytes as f64 / 1024.0) - } else if bytes < 1024 * 1024 * 1024 { - format!("{:.1} MiB", bytes as f64 / (1024.0 * 1024.0)) + return format!("{bytes} B"); + } + let tenths = |scale: u64| format!("{:.1}", bytes as f64 / scale as f64); + let mut unit = 0; + let mut scale = 1024_u64; + while unit + 1 < UNITS.len() + && tenths(scale) + .parse::() + .is_ok_and(|shown| shown >= 1024.0) + { + unit += 1; + scale *= 1024; + } + if bytes.is_multiple_of(scale) { + format!("{} {}", bytes / scale, UNITS[unit]) } else { - format!("{:.1} GiB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) + format!("{} {}", tenths(scale), UNITS[unit]) } } @@ -331,11 +345,19 @@ mod tests { #[test] fn format_bytes_labels_binary_units() { assert_eq!(format_bytes(500), "500 B"); - assert_eq!(format_bytes(1024), "1.0 KiB"); + assert_eq!(format_bytes(1024), "1 KiB"); assert_eq!(format_bytes(1536), "1.5 KiB"); - assert_eq!(format_bytes(1_048_576), "1.0 MiB"); + assert_eq!(format_bytes(1_048_576), "1 MiB"); assert_eq!(format_bytes(2_621_440), "2.5 MiB"); - assert_eq!(format_bytes(5 * 1024 * 1024 * 1024), "5.0 GiB"); + assert_eq!(format_bytes(5 * 1024 * 1024 * 1024), "5 GiB"); + assert_eq!(format_bytes(5 * 1024 * 1024 * 1024 + 100), "5.0 GiB"); + assert_eq!(format_bytes(1025 * 1024_u64.pow(3)), "1.0 TiB"); + assert_eq!(format_bytes(2 * 1024_u64.pow(4)), "2 TiB"); + assert_eq!(format_bytes(3 * 1024_u64.pow(5)), "3 PiB"); + assert_eq!(format_bytes(u64::MAX), "16384.0 PiB"); + // Whose tenth would read 1024.0 takes the next unit. + assert_eq!(format_bytes(1_048_525), "1.0 MiB"); + assert_eq!(format_bytes(1_048_524), "1023.9 KiB"); } // ── read_capped_state_file ──────────────────────────────────────── diff --git a/crates/lpm-tunnel/src/webhook.rs b/crates/lpm-tunnel/src/webhook.rs index 658c2c7e9..71ce4fe1c 100644 --- a/crates/lpm-tunnel/src/webhook.rs +++ b/crates/lpm-tunnel/src/webhook.rs @@ -210,21 +210,10 @@ fn extract_json_type(body: &[u8]) -> Option { /// Generic summary: `METHOD /path (size)`. fn format_generic(webhook: &CapturedWebhook) -> String { - let size = format_size(webhook.body_size()); + let size = lpm_common::format_bytes(webhook.body_size() as u64); format!("{} {} ({size})", webhook.method, webhook.path) } -/// Format byte size into a human-readable string. -fn format_size(bytes: usize) -> String { - if bytes < 1024 { - format!("{bytes}B") - } else if bytes < 1024 * 1024 { - format!("{:.1}KiB", bytes as f64 / 1024.0) - } else { - format!("{:.1}MiB", bytes as f64 / (1024.0 * 1024.0)) - } -} - /// Serde helper for base64-encoding `Vec` fields. mod base64_bytes { use base64::Engine; @@ -414,7 +403,7 @@ mod tests { w.path = "/api/hook".to_string(); w.request_body = vec![0u8; 2500]; }); - assert_eq!(summarize_webhook(&wh), "POST /api/hook (2.4KiB)"); + assert_eq!(summarize_webhook(&wh), "POST /api/hook (2.4 KiB)"); } // -- CapturedWebhook methods -- @@ -523,24 +512,4 @@ mod tests { assert_eq!(WebhookProvider::GitHub.to_string(), "GitHub"); assert_eq!(WebhookProvider::SendGrid.to_string(), "SendGrid"); } - - // -- format_size -- - - #[test] - fn format_size_bytes() { - assert_eq!(format_size(0), "0B"); - assert_eq!(format_size(512), "512B"); - assert_eq!(format_size(1023), "1023B"); - } - - #[test] - fn format_size_kilobytes() { - assert_eq!(format_size(1024), "1.0KiB"); - assert_eq!(format_size(2560), "2.5KiB"); - } - - #[test] - fn format_size_megabytes() { - assert_eq!(format_size(1024 * 1024), "1.0MiB"); - } } diff --git a/crates/lpm-vault/src/keychain.rs b/crates/lpm-vault/src/keychain.rs index 744763411..4195ebb16 100644 --- a/crates/lpm-vault/src/keychain.rs +++ b/crates/lpm-vault/src/keychain.rs @@ -1146,7 +1146,10 @@ pub(crate) fn mutate_vault_env( fn warn_if_large(size: usize) { if size > MAX_VAULT_SIZE_WARNING { - tracing::warn!("vault data is {size} bytes (approaching ~100KB Keychain limit)"); + tracing::warn!( + "env data is {} (approaching the Keychain's ~100 KiB limit)", + lpm_common::format_bytes(size as u64) + ); } } diff --git a/tests/workflows/tests/cache.rs b/tests/workflows/tests/cache.rs index 78314f35c..554603a7d 100644 --- a/tests/workflows/tests/cache.rs +++ b/tests/workflows/tests/cache.rs @@ -530,7 +530,7 @@ async fn cache_status_human_renders_tables_and_slim_completion() { "cache status must render local and remote sections, got:\n{stdout}" ); assert!( - stdout.contains("1.0 KiB / 2.0 KiB █████░░░░░"), + stdout.contains("1 KiB / 2 KiB █████░░░░░"), "cache status must render remote usage with a quota bar, got:\n{stdout}" ); diff --git a/tests/workflows/tests/whoami.rs b/tests/workflows/tests/whoami.rs index 3673c6ade..a9f9a46dd 100644 --- a/tests/workflows/tests/whoami.rs +++ b/tests/workflows/tests/whoami.rs @@ -74,11 +74,11 @@ async fn whoami_human_output_uses_slim_account_summary() { "mfa_enabled": false, "has_pool_access": true, "usage": { - "storage_bytes": 1024 * 1024 * 50, + "storage_bytes": 1024 * 1024 * 512 + 1024 * 100, "private_packages": 3 }, "limits": { - "storageBytes": 1024 * 1024 * 500, + "storageBytes": 1024_u64 * 1024 * 1024 * 5, "privatePackages": 100 }, "organizations": [ @@ -149,8 +149,7 @@ async fn whoami_human_output_uses_slim_account_summary() { "authenticated whoami should warn when auth storage uses the encrypted file fallback, got:\n{stderr}" ); assert!( - stderr.contains("usage") - && stderr.contains("storage 50.00 MiB / 500 MiB █░░░░░░░░░"), + stderr.contains("usage") && stderr.contains("storage 512.1 MiB / 5 GiB ██░░░░░░░░"), "authenticated whoami should show storage usage with a quota bar, got:\n{stderr}" ); assert!( From d52b87613bd02e9ce010db854fc7b9ff59a2105f Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Mon, 12 Oct 2026 15:48:49 +0100 Subject: [PATCH 4/5] fix(cli): read quota usage on its side of the limit, and tell Pro its storage overage is billed - format_quota states a usage and its limit as the Registry's billing page does: two decimals, GiB at most with thousands grouped, the usage rounded toward its side of the limit, in exact integer arithmetic. lpm whoami and lpm cache status use it. - lpm whoami tells a Pro account past its storage that the overage is billed, without the restricted warning a free account gets. - format_bytes rounds a halfway tenth up, as the Registry does. - Skill, runtime download and Keychain messages derive their sizes from their limits, and tests pin them; the graph test checks the stated size. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/lpm-cli/src/commands/remote_cache.rs | 3 +- crates/lpm-cli/src/commands/skills/author.rs | 17 ++- crates/lpm-cli/src/commands/whoami.rs | 28 +++- crates/lpm-common/src/lib.rs | 143 +++++++++++++++++-- crates/lpm-runtime/src/download.rs | 26 ++-- crates/lpm-vault/src/keychain.rs | 23 ++- tests/workflows/tests/download.rs | 9 ++ tests/workflows/tests/graph.rs | 6 +- tests/workflows/tests/skills.rs | 4 +- tests/workflows/tests/whoami.rs | 105 +++++++++++++- 10 files changed, 314 insertions(+), 50 deletions(-) diff --git a/crates/lpm-cli/src/commands/remote_cache.rs b/crates/lpm-cli/src/commands/remote_cache.rs index 07cf86d49..5e544beb6 100644 --- a/crates/lpm-cli/src/commands/remote_cache.rs +++ b/crates/lpm-cli/src/commands/remote_cache.rs @@ -258,8 +258,7 @@ pub fn print_cache_status_human(status: &CacheStatus) { ); } if let (Some(usage), Some(limit)) = (status.remote.usage_bytes, status.remote.limit_bytes) { - let usage_display = format_bytes(usage); - let limit_display = format_bytes(limit); + let (usage_display, limit_display) = lpm_common::format_quota(usage, limit); let usage_bar = install_ui::usage_bar(usage, limit, 10); let usage_line = if usage_bar.is_empty() { crate::install_ui::terminal_line!( diff --git a/crates/lpm-cli/src/commands/skills/author.rs b/crates/lpm-cli/src/commands/skills/author.rs index 97f381dc2..d34d50999 100644 --- a/crates/lpm-cli/src/commands/skills/author.rs +++ b/crates/lpm-cli/src/commands/skills/author.rs @@ -2,7 +2,7 @@ use std::collections::BTreeSet; use std::io::Read as _; use std::path::Path; -use lpm_common::LpmError; +use lpm_common::{LpmError, format_bytes}; use lpm_security::skill_security::{self, SkillSecurityIssue}; use sha2::{Digest, Sha256}; @@ -154,8 +154,9 @@ fn validate_open_directory(skills_dir: &cap_std::fs::Dir) -> Result MAX_TOTAL_SIZE { report.errors.push(format!( - "total package skills size {} bytes exceeds 100 KiB limit", - report.total_size_bytes + "total package skills size {} bytes exceeds {} limit", + report.total_size_bytes, + format_bytes(MAX_TOTAL_SIZE) )); } @@ -201,9 +202,10 @@ fn validate_opened_file( let security_count = report.security_issues.len(); if size > MAX_SKILL_SIZE { - report - .errors - .push(format!("{display}: exceeds 15 KiB limit ({size} bytes)")); + report.errors.push(format!( + "{display}: exceeds {} limit ({size} bytes)", + format_bytes(MAX_SKILL_SIZE) + )); return Ok(()); } @@ -217,7 +219,8 @@ fn validate_opened_file( .saturating_sub(size) .saturating_add(bytes.len() as u64); report.errors.push(format!( - "{display}: exceeds 15 KiB limit ({} bytes)", + "{display}: exceeds {} limit ({} bytes)", + format_bytes(MAX_SKILL_SIZE), bytes.len() )); return Ok(()); diff --git a/crates/lpm-cli/src/commands/whoami.rs b/crates/lpm-cli/src/commands/whoami.rs index 2a2e1e395..617611363 100644 --- a/crates/lpm-cli/src/commands/whoami.rs +++ b/crates/lpm-cli/src/commands/whoami.rs @@ -101,22 +101,35 @@ pub async fn run(client: &RegistryClient, json_output: bool) -> Result<(), LpmEr whoami_ui::blank_line(); whoami_ui::section("usage"); let storage = lpm_common::format_bytes(usage.storage_bytes); + // Pro storage past its allowance is billed, not refused; only the + // free plan's limit stops publishing. + let storage_billed = user + .plan_tier + .as_deref() + .is_some_and(|tier| tier.eq_ignore_ascii_case("pro")); if let Some(limits) = &user.limits { // Storage if let Some(limit_bytes) = limits.storage_bytes { + let (used, limit) = lpm_common::format_quota(usage.storage_bytes, limit_bytes); let storage_msg = with_usage_bar( - format!("{storage} / {}", lpm_common::format_bytes(limit_bytes)), + format!("{used} / {limit}"), usage.storage_bytes, limit_bytes, ); - if usage.storage_bytes > limit_bytes { + if usage.storage_bytes <= limit_bytes { + whoami_ui::detail("storage", storage_msg); + } else if storage_billed { + whoami_ui::detail("storage", storage_msg); + whoami_ui::detail( + "overage", + format!("storage past {limit} is billed per GiB a month, rounded up"), + ); + } else { whoami_ui::warn_line(crate::install_ui::terminal_line!( "Storage: {} (OVER LIMIT)", storage_msg )); - } else { - whoami_ui::detail("storage", storage_msg); } } else { whoami_ui::detail("storage", storage); @@ -149,9 +162,10 @@ pub async fn run(client: &RegistryClient, json_output: bool) -> Result<(), LpmEr } // Over-limit warning - let over_storage = limits - .storage_bytes - .is_some_and(|l| usage.storage_bytes > l); + let over_storage = !storage_billed + && limits + .storage_bytes + .is_some_and(|l| usage.storage_bytes > l); let over_packages = limits .private_packages .is_some_and(|l| l > 0 && l != u32::MAX && usage.private_packages > l); diff --git a/crates/lpm-common/src/lib.rs b/crates/lpm-common/src/lib.rs index f4af66859..c13fefde8 100644 --- a/crates/lpm-common/src/lib.rs +++ b/crates/lpm-common/src/lib.rs @@ -314,28 +314,98 @@ pub fn is_loopback_host(host: &str) -> bool { /// labels them. Switches unit at each 1024, so a 900-byte file never reads /// as "0.9 KiB", and a value whose tenth would read 1024.0 takes the next /// unit. A whole number only for an exact size, so 5 GiB and a few bytes -/// reads "5.0 GiB". +/// reads "5.0 GiB". A tenth halfway between two rounds up, as the +/// Registry's does. pub fn format_bytes(bytes: u64) -> String { - const UNITS: [&str; 5] = ["KiB", "MiB", "GiB", "TiB", "PiB"]; + format_scaled(bytes, 1, UNITS.len() - 1, Rounding::Nearest, false) +} + +/// Formats a usage and the limit it's shown against as the LPM.dev +/// Registry's billing page does: to two decimals, in GiB at most with +/// thousands grouped, as limits and prices are stated per GiB. The usage +/// rounds toward the side of the limit it's on, so usage a few bytes past +/// its limit reads as past it, as overage bills it ("5.01 GiB / 5 GiB"), +/// and usage a few bytes short reads as short of it. +pub fn format_quota(used: u64, limit: u64) -> (String, String) { + let rounding = match used.cmp(&limit) { + std::cmp::Ordering::Greater => Rounding::Up, + std::cmp::Ordering::Less => Rounding::Down, + std::cmp::Ordering::Equal => Rounding::Nearest, + }; + ( + format_scaled(used, 2, GIB_UNIT, rounding, true), + format_scaled(limit, 2, GIB_UNIT, Rounding::Nearest, true), + ) +} + +const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; +const GIB_UNIT: usize = 3; + +#[derive(Clone, Copy)] +enum Rounding { + Nearest, + Up, + Down, +} + +/// `bytes` in its largest unit up to `max_unit` whose shown value is at +/// least 1, to `decimals` places, rounded as asked in exact integer +/// arithmetic; a whole number for an exact size. +fn format_scaled( + bytes: u64, + decimals: u32, + max_unit: usize, + rounding: Rounding, + grouped: bool, +) -> String { if bytes < 1024 { return format!("{bytes} B"); } - let tenths = |scale: u64| format!("{:.1}", bytes as f64 / scale as f64); - let mut unit = 0; - let mut scale = 1024_u64; - while unit + 1 < UNITS.len() - && tenths(scale) - .parse::() - .is_ok_and(|shown| shown >= 1024.0) - { + let places = 10_u128.pow(decimals); + let shown = |scale: u128| { + let numerator = u128::from(bytes) * places; + match rounding { + Rounding::Nearest => (numerator + scale / 2) / scale, + Rounding::Up => numerator.div_ceil(scale), + Rounding::Down => numerator / scale, + } + }; + let mut unit = 1; + let mut scale = 1024_u128; + while unit < max_unit && shown(scale) >= 1024 * places { unit += 1; scale *= 1024; } - if bytes.is_multiple_of(scale) { - format!("{} {}", bytes / scale, UNITS[unit]) - } else { - format!("{} {}", tenths(scale), UNITS[unit]) + let whole = |value: u128| { + if grouped { + group_thousands(value) + } else { + value.to_string() + } + }; + if u128::from(bytes) % scale == 0 { + return format!("{} {}", whole(u128::from(bytes) / scale), UNITS[unit]); + } + let value = shown(scale); + format!( + "{}.{:0width$} {}", + whole(value / places), + value % places, + UNITS[unit], + width = decimals as usize + ) +} + +fn group_thousands(value: u128) -> String { + let digits = value.to_string(); + let mut grouped = String::with_capacity(digits.len() + digits.len() / 3); + for (index, digit) in digits.chars().enumerate() { + if index > 0 && (digits.len() - index).is_multiple_of(3) { + grouped.push(','); + } + grouped.push(digit); } + grouped } #[cfg(test)] @@ -360,6 +430,51 @@ mod tests { assert_eq!(format_bytes(1_048_524), "1023.9 KiB"); } + #[test] + fn format_bytes_rounds_a_halfway_tenth_up_as_the_registry_does() { + assert_eq!(format_bytes(1280), "1.3 KiB"); + assert_eq!(format_bytes(1229), "1.2 KiB"); + assert_eq!( + format_bytes(5 * 1024_u64.pow(3) + 1024_u64.pow(3) / 4), + "5.3 GiB" + ); + } + + #[test] + fn format_quota_reads_usage_on_its_side_of_the_limit() { + const GIB: u64 = 1024 * 1024 * 1024; + let quota = |used, limit| { + let (used, limit) = format_quota(used, limit); + format!("{used} / {limit}") + }; + assert_eq!(quota(5 * GIB + 1, 5 * GIB), "5.01 GiB / 5 GiB"); + assert_eq!( + quota(5 * GIB + 51 * 1024 * 1024, 5 * GIB), + "5.05 GiB / 5 GiB" + ); + assert_eq!(quota(5 * GIB - 1, 5 * GIB), "4.99 GiB / 5 GiB"); + assert_eq!(quota(5 * GIB, 5 * GIB), "5 GiB / 5 GiB"); + assert_eq!( + quota(512 * 1024 * 1024 + 104_858, 5 * GIB), + "512.10 MiB / 5 GiB" + ); + assert_eq!(quota(0, 5 * GIB), "0 B / 5 GiB"); + } + + #[test] + fn format_quota_states_sizes_in_gib_at_most_with_thousands_grouped() { + const GIB: u64 = 1024 * 1024 * 1024; + assert_eq!( + format_quota(1253 * GIB, 1250 * GIB), + ("1,253 GiB".into(), "1,250 GiB".into()) + ); + assert_eq!( + format_quota(1253 * GIB + GIB / 2, 1250 * GIB).0, + "1,253.50 GiB" + ); + assert_eq!(format_quota(u64::MAX, u64::MAX).0, "17,179,869,184.00 GiB"); + } + // ── read_capped_state_file ──────────────────────────────────────── /// Files under the cap round-trip transparently. diff --git a/crates/lpm-runtime/src/download.rs b/crates/lpm-runtime/src/download.rs index da8a84e58..4747f8513 100644 --- a/crates/lpm-runtime/src/download.rs +++ b/crates/lpm-runtime/src/download.rs @@ -367,12 +367,7 @@ async fn read_download_body( } fn checked_download_size(size: u64) -> Result { - let size = usize::try_from(size).map_err(|_| { - LpmError::Script(format!( - "download size {size} bytes exceeds maximum allowed size of {MAX_DOWNLOAD_SIZE} bytes ({} MiB)", - MAX_DOWNLOAD_SIZE / (1024 * 1024) - )) - })?; + let size = usize::try_from(size).map_err(|_| LpmError::Script(download_too_large(size)))?; validate_download_size(size)?; Ok(size) } @@ -762,14 +757,18 @@ const MAX_DOWNLOAD_SIZE: usize = 200 * 1024 * 1024; /// after buffering bytes (defence against lying headers / chunked encoding). fn validate_download_size(size: usize) -> Result<(), LpmError> { if size > MAX_DOWNLOAD_SIZE { - return Err(LpmError::Network(format!( - "download size {size} bytes exceeds maximum allowed size of {MAX_DOWNLOAD_SIZE} bytes ({} MiB)", - MAX_DOWNLOAD_SIZE / (1024 * 1024) - ))); + return Err(LpmError::Network(download_too_large(size))); } Ok(()) } +fn download_too_large(size: impl std::fmt::Display) -> String { + format!( + "download size {size} bytes exceeds maximum allowed size of {MAX_DOWNLOAD_SIZE} bytes ({})", + lpm_common::format_bytes(MAX_DOWNLOAD_SIZE as u64) + ) +} + /// Compare an expected hex-encoded SHA-256 hash against the SHA-256 of raw bytes. /// /// Returns `Ok(())` on match, `Err` on mismatch. @@ -1543,7 +1542,12 @@ mod tests { let result = validate_download_size(MAX_DOWNLOAD_SIZE + 1); assert!(result.is_err(), "oversized download must be rejected"); let err_msg = result.unwrap_err().to_string(); - assert!(err_msg.contains("exceeds maximum"), "got: {err_msg}"); + assert!( + err_msg.contains( + "download size 209715201 bytes exceeds maximum allowed size of 209715200 bytes (200 MiB)" + ), + "got: {err_msg}" + ); } // --- Directory and file permissions --- diff --git a/crates/lpm-vault/src/keychain.rs b/crates/lpm-vault/src/keychain.rs index 4195ebb16..8c3c7a26d 100644 --- a/crates/lpm-vault/src/keychain.rs +++ b/crates/lpm-vault/src/keychain.rs @@ -1145,12 +1145,18 @@ pub(crate) fn mutate_vault_env( } fn warn_if_large(size: usize) { - if size > MAX_VAULT_SIZE_WARNING { - tracing::warn!( + if let Some(warning) = large_env_warning(size) { + tracing::warn!("{warning}"); + } +} + +fn large_env_warning(size: usize) -> Option { + (size > MAX_VAULT_SIZE_WARNING).then(|| { + format!( "env data is {} (approaching the Keychain's ~100 KiB limit)", lpm_common::format_bytes(size as u64) - ); - } + ) + }) } fn write_all_environments_with_storage_after_recovery( @@ -1816,6 +1822,15 @@ fn project_discovery_shard(probe: (usize, usize), attempt: usize) -> usize { mod tests { use super::*; + #[test] + fn large_env_data_is_warned_of_in_binary_units() { + assert_eq!( + large_env_warning(95 * 1024).as_deref(), + Some("env data is 95 KiB (approaching the Keychain's ~100 KiB limit)") + ); + assert_eq!(large_env_warning(MAX_VAULT_SIZE_WARNING), None); + } + #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] struct ProjectIndexFixture { diff --git a/tests/workflows/tests/download.rs b/tests/workflows/tests/download.rs index a4cc82018..9d0485a94 100644 --- a/tests/workflows/tests/download.rs +++ b/tests/workflows/tests/download.rs @@ -527,6 +527,15 @@ async fn download_human_output_uses_slim_progress_and_completion() { stderr.contains("output:") && stderr.contains("files extracted:"), "download must report extraction details on stderr, got:\n{stderr}" ); + let size_row = format!( + "{:<16} {}", + "size:", + lpm_common::format_bytes(tarball.len() as u64) + ); + assert!( + stderr.contains(&size_row), + "download must state the tarball's size in binary units, {size_row:?}, got:\n{stderr}" + ); assert!( stderr.contains("✓ Done · tarball extracted in "), "download must report slim timed completion, got:\n{stderr}" diff --git a/tests/workflows/tests/graph.rs b/tests/workflows/tests/graph.rs index b72dadc5a..8ec0da286 100644 --- a/tests/workflows/tests/graph.rs +++ b/tests/workflows/tests/graph.rs @@ -356,9 +356,11 @@ fn graph_html_writes_human_readable_size() { combined.contains(" KiB)") || combined.contains(" MiB)"), "size in success message should use a human-readable unit: {combined}" ); + let written = project.read_file(".lpm/graph.html").len() as u64; + let stated = format!("({})", lpm_common::format_bytes(written)); assert!( - !combined.contains("(0 KiB)"), - "size must never collapse to 0 KiB via integer truncation: {combined}" + combined.contains(&stated), + "the success message must state the written file's size, {stated}: {combined}" ); } diff --git a/tests/workflows/tests/skills.rs b/tests/workflows/tests/skills.rs index 74b079548..5913006f4 100644 --- a/tests/workflows/tests/skills.rs +++ b/tests/workflows/tests/skills.rs @@ -2887,7 +2887,7 @@ fn skills_validate_rejects_skill_exceeding_size_limit() { String::from_utf8_lossy(&output.stderr), ); assert!( - combined.contains("15 KiB") || combined.contains("exceed") || combined.contains("limit"), + combined.contains("exceeds 15 KiB limit ("), "validate output must surface the per-skill size-limit warning, got:\n{combined}", ); let stderr = String::from_utf8_lossy(&output.stderr); @@ -2940,7 +2940,7 @@ fn skills_validate_json_reports_success_false_and_exits_non_zero_on_error() { .as_str() .expect("each error entry must be a string"); assert!( - error_text.contains("15 KiB") || error_text.contains("limit"), + error_text.contains("exceeds 15 KiB limit ("), "error text must name the violated limit, got {error_text:?}", ); } diff --git a/tests/workflows/tests/whoami.rs b/tests/workflows/tests/whoami.rs index a9f9a46dd..36f74a55f 100644 --- a/tests/workflows/tests/whoami.rs +++ b/tests/workflows/tests/whoami.rs @@ -149,7 +149,8 @@ async fn whoami_human_output_uses_slim_account_summary() { "authenticated whoami should warn when auth storage uses the encrypted file fallback, got:\n{stderr}" ); assert!( - stderr.contains("usage") && stderr.contains("storage 512.1 MiB / 5 GiB ██░░░░░░░░"), + stderr.contains("usage") + && stderr.contains("storage 512.09 MiB / 5 GiB ██░░░░░░░░"), "authenticated whoami should show storage usage with a quota bar, got:\n{stderr}" ); assert!( @@ -204,6 +205,108 @@ async fn whoami_human_output_uses_slim_account_summary() { ); } +/// Human `lpm whoami` stderr for an account the registry describes as +/// `account`. +async fn whoami_stderr(account: serde_json::Value) -> String { + let project = TempProject::empty(r#"{"name":"whoami","version":"1.0.0"}"#); + let mock = MockRegistry::start().await; + Mock::given(method("GET")) + .and(path("/api/registry/-/whoami")) + .and(header("authorization", "Bearer access-primary")) + .respond_with(ResponseTemplate::new(200).set_body_json(account)) + .mount(mock.server()) + .await; + seed_sessions( + project.home(), + &[SessionSeed { + registry_url: &mock.url(), + access_token: Some("access-primary"), + refresh_token: None, + session_access_expires_at: None, + }], + ); + + let output = lpm_with_registry(&project, &mock.url()) + .arg("whoami") + .output() + .expect("failed to run lpm whoami"); + assert!( + output.status.success(), + "whoami must exit 0, stderr:\n{}", + String::from_utf8_lossy(&output.stderr), + ); + String::from_utf8_lossy(&output.stderr).into_owned() +} + +const GIB: u64 = 1024 * 1024 * 1024; + +#[tokio::test] +async fn whoami_tells_a_pro_account_past_its_storage_that_it_is_billed_not_restricted() { + let stderr = whoami_stderr(serde_json::json!({ + "username": "testuser", + "plan_tier": "pro", + "usage": { "storage_bytes": 5 * GIB + 51 * 1024 * 1024, "private_packages": 3 }, + "limits": { "storageBytes": 5 * GIB, "privatePackages": 100 }, + })) + .await; + + assert!( + stderr.contains("storage 5.05 GiB / 5 GiB") + && stderr.contains( + "overage storage past 5 GiB is billed per GiB a month, rounded up" + ), + "a Pro account past its storage should read as past it and billed, got:\n{stderr}" + ); + assert!( + !stderr.contains("OVER LIMIT") + && !stderr.contains("restricted") + && !stderr.contains("Upgrade"), + "a Pro account's billed storage must not read as restricted, got:\n{stderr}" + ); +} + +#[tokio::test] +async fn whoami_tells_a_free_account_past_its_storage_that_publishing_is_restricted() { + let stderr = whoami_stderr(serde_json::json!({ + "username": "testuser", + "plan_tier": "free", + "usage": { "storage_bytes": 100 * 1024 * 1024 + 1, "private_packages": 1 }, + "limits": { "storageBytes": 100 * 1024 * 1024, "privatePackages": 2 }, + })) + .await; + + assert!( + stderr.contains("Storage: 100.01 MiB / 100 MiB") + && stderr.contains("(OVER LIMIT)") + && stderr.contains("Write access (publishing, inviting members) is restricted."), + "a free account past its storage should read as over its limit and restricted, got:\n{stderr}" + ); +} + +#[tokio::test] +async fn whoami_shows_storage_alone_without_a_storage_limit() { + let with_other_limits = whoami_stderr(serde_json::json!({ + "username": "testuser", + "plan_tier": "pro", + "usage": { "storage_bytes": 3 * GIB + GIB / 2, "private_packages": 3 }, + "limits": { "privatePackages": 100 }, + })) + .await; + let without_limits = whoami_stderr(serde_json::json!({ + "username": "testuser", + "plan_tier": "pro", + "usage": { "storage_bytes": 3 * GIB + GIB / 2, "private_packages": 3 }, + })) + .await; + + for stderr in [with_other_limits, without_limits] { + assert!( + stderr.contains("storage 3.5 GiB") && !stderr.contains("3.5 GiB /"), + "storage without a limit should read as its size alone, got:\n{stderr}" + ); + } +} + #[tokio::test] async fn whoami_json_reports_file_backed_auth_storage_backend() { let project = TempProject::empty(r#"{"name":"whoami","version":"1.0.0"}"#); From fad590f3c3ec053bd7b10273ee3fde05a9bf1022 Mon Sep 17 00:00:00 2001 From: Tolga Ergin Date: Mon, 12 Oct 2026 19:38:42 +0100 Subject: [PATCH 5/5] fix(cli): state storage overage as the Registry decides it, group bytes in quotas, and cap lpm.dev tarballs at its limit - `lpm whoami` reads the Registry's `storage_overage`: billed, paused until the payment succeeds, or blocked; a Registry that doesn't report it gets no claim either way. `--json` passes it through. A personal account is told publishing is restricted, without "inviting members". - `lpm cache status --json` states usage and limit as the human line does, on its side of the limit. - Quota sizes under 1 KiB group thousands as the Registry does. - A publish that includes lpm.dev refuses a tarball past its 100 MiB limit before any target is published; sizes past a limit round up (`format_bytes_past`). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/commands/publish/orchestrator.rs | 11 ++- .../lpm-cli/src/commands/publish/prepare.rs | 21 ++++- crates/lpm-cli/src/commands/publish/tests.rs | 21 ++++- crates/lpm-cli/src/commands/publish_common.rs | 4 + crates/lpm-cli/src/commands/remote_cache.rs | 12 ++- crates/lpm-cli/src/commands/whoami.rs | 62 +++++++++----- crates/lpm-common/src/lib.rs | 20 ++++- crates/lpm-registry/src/types.rs | 6 ++ tests/workflows/tests/cache.rs | 7 +- tests/workflows/tests/whoami.rs | 82 ++++++++++++++++++- 10 files changed, 211 insertions(+), 35 deletions(-) diff --git a/crates/lpm-cli/src/commands/publish/orchestrator.rs b/crates/lpm-cli/src/commands/publish/orchestrator.rs index 20e1894a8..f1ead1b3c 100644 --- a/crates/lpm-cli/src/commands/publish/orchestrator.rs +++ b/crates/lpm-cli/src/commands/publish/orchestrator.rs @@ -10,7 +10,7 @@ use super::output::{ use super::prepare::read_publish_manifest; use super::prepare::{ PublishSource, prepare_publish_project_from_manifest, read_publish_manifest_from_source, - validate_publish_tarball_size, + validate_lpm_registry_tarball_size, validate_publish_tarball_size, }; use super::provenance::{ ProvenanceRequest, load_provenance_request_file, materialize_provenance_request, @@ -1160,6 +1160,15 @@ async fn prepare_publish_manifest_with_workspace_lock_held( &tarball_data, !allow_secrets, )?; + if targets.contains(&PublishTarget::Lpm) { + let lpm_name = target_names + .get("lpm") + .map_or(name.as_str(), String::as_str); + let lpm_tarball_size = rewritten_tarballs + .get(lpm_name) + .map_or(tarball_data.len(), |tarball| tarball.len()); + validate_lpm_registry_tarball_size(lpm_tarball_size)?; + } let version_data = build_publish_version_data( &pkg_json, &name, diff --git a/crates/lpm-cli/src/commands/publish/prepare.rs b/crates/lpm-cli/src/commands/publish/prepare.rs index b928ffa8f..b9daf42e4 100644 --- a/crates/lpm-cli/src/commands/publish/prepare.rs +++ b/crates/lpm-cli/src/commands/publish/prepare.rs @@ -677,10 +677,25 @@ fn publish_project_changed_error(package_json_path: &Path) -> LpmError { } pub(crate) fn validate_publish_tarball_size(tarball_size: usize) -> Result<(), LpmError> { - if tarball_size > MAX_PUBLISH_TARBALL_BYTES { + tarball_within(tarball_size, MAX_PUBLISH_TARBALL_BYTES as u64, "") +} + +/// Refuses a tarball the LPM registry would refuse for its size. +pub(crate) fn validate_lpm_registry_tarball_size(tarball_size: usize) -> Result<(), LpmError> { + tarball_within( + tarball_size, + publish_common::MAX_LPM_REGISTRY_TARBALL_BYTES, + " for the LPM registry", + ) +} + +fn tarball_within(tarball_size: usize, max: u64, target: &str) -> Result<(), LpmError> { + let size = tarball_size as u64; + if size > max { return Err(LpmError::Registry(format!( - "tarball too large: {} (max 500 MiB)", - lpm_common::format_bytes(tarball_size as u64) + "tarball too large{target}: {} (max {})", + lpm_common::format_bytes_past(size), + lpm_common::format_bytes(max) ))); } Ok(()) diff --git a/crates/lpm-cli/src/commands/publish/tests.rs b/crates/lpm-cli/src/commands/publish/tests.rs index b2067812f..b7f4a7b18 100644 --- a/crates/lpm-cli/src/commands/publish/tests.rs +++ b/crates/lpm-cli/src/commands/publish/tests.rs @@ -819,11 +819,28 @@ fn publish_tarball_size_accepts_the_500_mib_boundary() { } #[test] -fn publish_tarball_size_rejects_one_byte_over_with_mib_wording() { +fn publish_tarball_size_states_one_byte_over_as_past_the_limit() { let error = validate_publish_tarball_size(MAX_PUBLISH_TARBALL_BYTES + 1).unwrap_err(); assert!( - error.to_string().contains("(max 500 MiB)"), + error + .to_string() + .contains("tarball too large: 500.01 MiB (max 500 MiB)"), + "unexpected tarball limit error: {error}" + ); +} + +#[test] +fn publish_tarball_size_for_the_lpm_registry_is_its_100_mib_limit() { + let limit = crate::commands::publish_common::MAX_LPM_REGISTRY_TARBALL_BYTES as usize; + assert!(super::prepare::validate_lpm_registry_tarball_size(limit).is_ok()); + + let error = super::prepare::validate_lpm_registry_tarball_size(limit + 1).unwrap_err(); + + assert!( + error + .to_string() + .contains("tarball too large for the LPM registry: 100.01 MiB (max 100 MiB)"), "unexpected tarball limit error: {error}" ); } diff --git a/crates/lpm-cli/src/commands/publish_common.rs b/crates/lpm-cli/src/commands/publish_common.rs index 46b9f6af8..48a388f22 100644 --- a/crates/lpm-cli/src/commands/publish_common.rs +++ b/crates/lpm-cli/src/commands/publish_common.rs @@ -386,6 +386,10 @@ pub(crate) const MAX_UNCOMPRESSED_TARBALL_BYTES: u64 = 500 * 1024 * 1024; pub(crate) const MAX_COMPRESSED_TARBALL_BYTES: u64 = 500 * 1024 * 1024; +/// The LPM registry's limit for a compressed tarball, below the local one, +/// checked before any target publishes so a larger one fails up front. +pub(crate) const MAX_LPM_REGISTRY_TARBALL_BYTES: u64 = 100 * 1024 * 1024; + pub(crate) const MAX_PUBLISH_ARCHIVE_ENTRIES: usize = 100_000; pub(crate) const MAX_PUBLISH_ARCHIVE_PATH_DEPTH: usize = 256; diff --git a/crates/lpm-cli/src/commands/remote_cache.rs b/crates/lpm-cli/src/commands/remote_cache.rs index 5e544beb6..e06ef5f3f 100644 --- a/crates/lpm-cli/src/commands/remote_cache.rs +++ b/crates/lpm-cli/src/commands/remote_cache.rs @@ -182,6 +182,14 @@ fn local_task_cache_size(path: &Path) -> Result { } pub fn cache_status_json(status: &CacheStatus) -> serde_json::Value { + // As the human line shows them: usage on its side of the limit. + let (usage, limit) = match (status.remote.usage_bytes, status.remote.limit_bytes) { + (Some(usage), Some(limit)) => { + let (usage, limit) = lpm_common::format_quota(usage, limit); + (Some(usage), Some(limit)) + } + (usage, limit) => (usage.map(format_bytes), limit.map(format_bytes)), + }; serde_json::json!({ "success": true, "local": { @@ -195,9 +203,9 @@ pub fn cache_status_json(status: &CacheStatus) -> serde_json::Value { "team": status.remote.team, "status": status.remote.status, "usage_bytes": status.remote.usage_bytes, - "usage": status.remote.usage_bytes.map(format_bytes), + "usage": usage, "limit_bytes": status.remote.limit_bytes, - "limit": status.remote.limit_bytes.map(format_bytes), + "limit": limit, "error": status.remote.error, }, }) diff --git a/crates/lpm-cli/src/commands/whoami.rs b/crates/lpm-cli/src/commands/whoami.rs index 617611363..b7a8a7478 100644 --- a/crates/lpm-cli/src/commands/whoami.rs +++ b/crates/lpm-cli/src/commands/whoami.rs @@ -47,6 +47,7 @@ pub async fn run(client: &RegistryClient, json_output: bool) -> Result<(), LpmEr "storage_bytes": l.storage_bytes, "private_packages": l.private_packages, })), + "storage_overage": user.storage_overage, "orgs": user.organizations.iter().map(|o| serde_json::json!({ "slug": o.slug, "name": o.name, @@ -101,12 +102,11 @@ pub async fn run(client: &RegistryClient, json_output: bool) -> Result<(), LpmEr whoami_ui::blank_line(); whoami_ui::section("usage"); let storage = lpm_common::format_bytes(usage.storage_bytes); - // Pro storage past its allowance is billed, not refused; only the - // free plan's limit stops publishing. - let storage_billed = user - .plan_tier - .as_deref() - .is_some_and(|tier| tier.eq_ignore_ascii_case("pro")); + // What publishing does past the limit, as the Registry's publish + // gate decides it. An older Registry doesn't say, and nothing is + // claimed then. + let storage_overage = user.storage_overage.as_deref(); + let mut over_storage = None; if let Some(limits) = &user.limits { // Storage @@ -119,17 +119,29 @@ pub async fn run(client: &RegistryClient, json_output: bool) -> Result<(), LpmEr ); if usage.storage_bytes <= limit_bytes { whoami_ui::detail("storage", storage_msg); - } else if storage_billed { - whoami_ui::detail("storage", storage_msg); - whoami_ui::detail( - "overage", - format!("storage past {limit} is billed per GiB a month, rounded up"), - ); } else { - whoami_ui::warn_line(crate::install_ui::terminal_line!( - "Storage: {} (OVER LIMIT)", - storage_msg - )); + over_storage = Some(limit.clone()); + match storage_overage { + Some("billed") => { + whoami_ui::detail("storage", storage_msg); + whoami_ui::detail( + "overage", + format!( + "storage past {limit} is billed per GiB a month, rounded up" + ), + ); + } + Some("paused" | "blocked") => { + whoami_ui::warn_line(crate::install_ui::terminal_line!( + "Storage: {} (OVER LIMIT)", + storage_msg + )); + } + _ => { + whoami_ui::detail("storage", storage_msg); + whoami_ui::detail("overage", format!("past the included {limit}")); + } + } } } else { whoami_ui::detail("storage", storage); @@ -162,17 +174,23 @@ pub async fn run(client: &RegistryClient, json_output: bool) -> Result<(), LpmEr } // Over-limit warning - let over_storage = !storage_billed - && limits - .storage_bytes - .is_some_and(|l| usage.storage_bytes > l); + let storage_refused = over_storage.is_some() && storage_overage == Some("blocked"); let over_packages = limits .private_packages .is_some_and(|l| l > 0 && l != u32::MAX && usage.private_packages > l); - if over_storage || over_packages { + if let (Some(limit), Some("paused")) = (&over_storage, storage_overage) { + whoami_ui::warn_line(crate::install_ui::terminal_line!( + "Storage overage is paused until the outstanding payment succeeds; publishing past {} is refused meanwhile.", + limit.as_str() + )); + whoami_ui::warn( + "Update your payment method: https://lpm.dev/dashboard/settings/billing", + ); + } + if storage_refused || over_packages { whoami_ui::warn("Your account is over its plan limits."); - whoami_ui::warn("Write access (publishing, inviting members) is restricted."); + whoami_ui::warn("Publishing is restricted."); whoami_ui::warn("Upgrade your plan: https://lpm.dev/dashboard/settings/billing"); } } else { diff --git a/crates/lpm-common/src/lib.rs b/crates/lpm-common/src/lib.rs index c13fefde8..21dd41df4 100644 --- a/crates/lpm-common/src/lib.rs +++ b/crates/lpm-common/src/lib.rs @@ -320,6 +320,13 @@ pub fn format_bytes(bytes: u64) -> String { format_scaled(bytes, 1, UNITS.len() - 1, Rounding::Nearest, false) } +/// Formats a size past a limit it's shown against: to two decimals, rounded +/// up, so a size a byte past its limit never reads as within it ("500.01 +/// MiB" against 500 MiB), as the LPM.dev Registry states such sizes. +pub fn format_bytes_past(bytes: u64) -> String { + format_scaled(bytes, 2, UNITS.len() - 1, Rounding::Up, false) +} + /// Formats a usage and the limit it's shown against as the LPM.dev /// Registry's billing page does: to two decimals, in GiB at most with /// thousands grouped, as limits and prices are stated per GiB. The usage @@ -359,7 +366,12 @@ fn format_scaled( grouped: bool, ) -> String { if bytes < 1024 { - return format!("{bytes} B"); + let shown = if grouped { + group_thousands(u128::from(bytes)) + } else { + bytes.to_string() + }; + return format!("{shown} B"); } let places = 10_u128.pow(decimals); let shown = |scale: u128| { @@ -473,6 +485,12 @@ mod tests { "1,253.50 GiB" ); assert_eq!(format_quota(u64::MAX, u64::MAX).0, "17,179,869,184.00 GiB"); + assert_eq!(format_bytes_past(500 * 1024 * 1024 + 1), "500.01 MiB"); + assert_eq!(format_bytes_past(500 * 1024 * 1024), "500 MiB"); + assert_eq!(format_bytes_past(5 * GIB + 1), "5.01 GiB"); + // As the Registry groups bytes too ("1,000 B"). + assert_eq!(format_quota(1000, 2048), ("1,000 B".into(), "2 KiB".into())); + assert_eq!(format_bytes(1000), "1000 B"); } // ── read_capped_state_file ──────────────────────────────────────── diff --git a/crates/lpm-registry/src/types.rs b/crates/lpm-registry/src/types.rs index d7fa5ca45..e19c75dbd 100644 --- a/crates/lpm-registry/src/types.rs +++ b/crates/lpm-registry/src/types.rs @@ -993,6 +993,12 @@ pub struct WhoamiResponse { #[serde(default)] pub limits: Option, + + /// What publishing does with storage past the plan's limit: `billed`, + /// `paused` while billing isn't active, or `blocked`. A Registry that + /// predates it sends none. + #[serde(default, rename = "storage_overage")] + pub storage_overage: Option, } /// GET /api/registry/-/package/publish-preflight diff --git a/tests/workflows/tests/cache.rs b/tests/workflows/tests/cache.rs index 554603a7d..ba7b7a89e 100644 --- a/tests/workflows/tests/cache.rs +++ b/tests/workflows/tests/cache.rs @@ -84,7 +84,7 @@ async fn cache_status_json_reports_local_usage_and_remote_status() { .and(path("/v8/artifacts/status")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "enabled", - "usageBytes": 1024, + "usageBytes": 2049, "limitBytes": 2048 }))) .mount(&server) @@ -126,8 +126,11 @@ async fn cache_status_json_reports_local_usage_and_remote_status() { assert!(envelope["local"]["bytes"].as_u64().unwrap_or(0) >= 11); assert_eq!(envelope["remote"]["enabled"], serde_json::json!(true)); assert_eq!(envelope["remote"]["status"], serde_json::json!("enabled")); - assert_eq!(envelope["remote"]["usage_bytes"], serde_json::json!(1024)); + assert_eq!(envelope["remote"]["usage_bytes"], serde_json::json!(2049)); assert_eq!(envelope["remote"]["limit_bytes"], serde_json::json!(2048)); + // A byte past the limit reads as past it, as the human line shows it. + assert_eq!(envelope["remote"]["usage"], serde_json::json!("2.01 KiB")); + assert_eq!(envelope["remote"]["limit"], serde_json::json!("2 KiB")); } #[tokio::test] diff --git a/tests/workflows/tests/whoami.rs b/tests/workflows/tests/whoami.rs index 36f74a55f..e16d1ff28 100644 --- a/tests/workflows/tests/whoami.rs +++ b/tests/workflows/tests/whoami.rs @@ -247,6 +247,7 @@ async fn whoami_tells_a_pro_account_past_its_storage_that_it_is_billed_not_restr "plan_tier": "pro", "usage": { "storage_bytes": 5 * GIB + 51 * 1024 * 1024, "private_packages": 3 }, "limits": { "storageBytes": 5 * GIB, "privatePackages": 100 }, + "storage_overage": "billed", })) .await; @@ -272,15 +273,90 @@ async fn whoami_tells_a_free_account_past_its_storage_that_publishing_is_restric "plan_tier": "free", "usage": { "storage_bytes": 100 * 1024 * 1024 + 1, "private_packages": 1 }, "limits": { "storageBytes": 100 * 1024 * 1024, "privatePackages": 2 }, + "storage_overage": "blocked", })) .await; assert!( stderr.contains("Storage: 100.01 MiB / 100 MiB") && stderr.contains("(OVER LIMIT)") - && stderr.contains("Write access (publishing, inviting members) is restricted."), + && stderr.contains("Publishing is restricted."), "a free account past its storage should read as over its limit and restricted, got:\n{stderr}" ); + assert!( + !stderr.contains("inviting members"), + "a personal account invites no members, got:\n{stderr}" + ); +} + +#[tokio::test] +async fn whoami_tells_an_account_in_a_payment_grace_past_its_storage_that_overage_waits_for_the_payment() + { + let stderr = whoami_stderr(serde_json::json!({ + "username": "testuser", + "plan_tier": "pro", + "usage": { "storage_bytes": 5 * GIB + 1, "private_packages": 3 }, + "limits": { "storageBytes": 5 * GIB, "privatePackages": 100 }, + "storage_overage": "paused", + })) + .await; + + assert!( + stderr.contains("Storage: 5.01 GiB / 5 GiB") + && stderr.contains( + "Storage overage is paused until the outstanding payment succeeds; publishing past 5 GiB is refused meanwhile." + ) + && stderr.contains("Update your payment method"), + "an account in a payment grace should read as paused until it pays, got:\n{stderr}" + ); + assert!( + !stderr.contains("is billed per GiB") && !stderr.contains("Upgrade your plan"), + "a paused overage is neither billed nor an upgrade's to fix, got:\n{stderr}" + ); +} + +#[tokio::test] +async fn whoami_claims_nothing_of_storage_past_its_limit_when_the_registry_doesnt_say() { + let stderr = whoami_stderr(serde_json::json!({ + "username": "testuser", + "plan_tier": "pro", + "usage": { "storage_bytes": 5 * GIB + 1, "private_packages": 3 }, + "limits": { "storageBytes": 5 * GIB, "privatePackages": 100 }, + })) + .await; + + assert!( + stderr.contains("storage 5.01 GiB / 5 GiB") + && stderr.contains("overage past the included 5 GiB"), + "storage past its limit should read as past it, got:\n{stderr}" + ); + assert!( + !stderr.contains("billed") + && !stderr.contains("OVER LIMIT") + && !stderr.contains("restricted"), + "without the Registry's word, whoami should claim neither billing nor a refusal, got:\n{stderr}" + ); +} + +#[tokio::test] +async fn whoami_reads_storage_at_its_limit_as_within_it() { + let stderr = whoami_stderr(serde_json::json!({ + "username": "testuser", + "plan_tier": "free", + "usage": { "storage_bytes": 100 * 1024 * 1024, "private_packages": 1 }, + "limits": { "storageBytes": 100 * 1024 * 1024, "privatePackages": 2 }, + "storage_overage": "blocked", + })) + .await; + + assert!( + stderr.contains("storage 100 MiB / 100 MiB"), + "storage at its limit should read as within it, got:\n{stderr}" + ); + assert!( + !stderr.contains("OVER LIMIT") && !stderr.contains("restricted"), + "storage at its limit isn't past it, got:\n{stderr}" + ); } #[tokio::test] @@ -317,7 +393,8 @@ async fn whoami_json_reports_file_backed_auth_storage_backend() { .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "username": "test@example.com", "profile_username": "testuser", - "email": "test@example.com" + "email": "test@example.com", + "storage_overage": "paused" }))) .mount(mock.server()) .await; @@ -350,6 +427,7 @@ async fn whoami_json_reports_file_backed_auth_storage_backend() { serde_json::json!("encrypted_file_fallback") ); assert_eq!(json["storage_degraded"], serde_json::json!(true)); + assert_eq!(json["storage_overage"], serde_json::json!("paused")); assert_eq!( json["registries"][0]["storage_backend"], serde_json::json!("encrypted_file_fallback")