server/lib/src/metering.rs
//! **Usage, and the paid state that may cap it.**
//!
//! Two doors monetize's holger plugin has been waiting for since 2026-09-03, when
//! it was written as a documented stub that declared six meters and refused to
//! invent readings (`monetize/products/holger`, `read_usage` answering
//! `Unavailable("holger: not wired")`). holger had eight services and no usage,
//! entitlement or quota call at all.
//!
//! ## The split, and why it is not symmetric
//!
//! * [`UsageService`] is in **every** build. Counting is not money: a self-hoster
//! wants the numbers too, and a box that cannot say how much it holds is worse
//! for everyone. It names no plan, no price and no cap.
//! * `EntitlementService` is compiled **only** with the `metered` feature, and
//! lives in [`crate::metered`]. The community build does not decline to enforce
//! a plan — it does not contain one.
//!
//! ## ⚠ The door does not count any more — [`count`] does, and a guest narrates
//!
//! The counting used to live inline in the `ReadUsage` handler. It is now
//! [`count`], with two callers: this door, and the `usage.read` host service a
//! loaded meter guest reaches through its linker
//! (`crate::meter_services::HolgerMeterServices`).
//!
//! When a meter guest is loaded, **the guest is what answers this door** — the
//! numbers cross the plugin ABI into the module and come back out of it, and
//! each reading's `source` says `meter:<module>` so nothing has to infer that.
//! When no guest is loaded (or the build has no `meter-guests`), the door reads
//! [`count`] directly. One counter, two narrators: a guest cannot report numbers
//! this server would not have reported itself.
//!
//! ## The tenant is a repository
//!
//! holger has no namespaces. `RepositoryInfo.name` is the unit an operator sells
//! (a team's `rust-dev`, a product's `maven-prod`), and it is already what
//! monetize's plugin calls the tenant.
//!
//! ## ⚠ A zero nobody counted is not a zero
//!
//! `list(..).map(|v| v.len()).unwrap_or(0)`, stamped with a REPOSITORY scope,
//! was wrong twice over. Four backends (`znippy-go`, `znippy-composer`,
//! `znippy-docker`, `znippy-nuget`) do not override
//! `RepositoryBackendTrait::list` and answer the trait default — an empty
//! listing — so a full repository was reported as empty; and `unwrap_or` folded
//! a genuine backend ERROR into the same `0`, so a broken disk and an idle
//! tenant were one number. An invoice drawn from either is wrong.
//!
//! Every reading now carries `counted`, and `value` means nothing when it is
//! false. [`RepositoryBackendTrait::supports_listing`] is what the counter asks
//! before it believes a listing, and a backend error becomes a refusal carrying
//! the error text rather than a number.
//!
//! ## ⚠ Three of the six meters are SERVER-WIDE, and the wire says so
//!
//! Only `artifacts` and `archive_bytes` are measured per repository.
//! `bytes_served`, `requests` and `proxy_upstream_hits` are process counters
//! (`metrics.rs`) with no per-repository breakdown, and `repos` is a property of
//! the server. Returning a server-wide number under one repository's name would be
//! a number that is not true, and an invoice drawn from it would be wrong — so each
//! reading carries its own [`MeterScope`] and a caller that ignores the field
//! cannot mistake one for the other. That is the same honesty `monetize_product::
//! Enforcement` exists for on the other side of the seam.
use std::sync::Arc;
use tonic::{Request, Response, Status};
use crate::grpc::holger_proto::usage_service_server::UsageService;
use crate::grpc::holger_proto::{MeterReading, MeterScope, ReadUsageRequest, ReadUsageResponse};
use crate::grpc::HolgerGrpc;
/// The meter names, spelled once. monetize's plugin declares the same six and an
/// order may only cap a meter that exists, so a rename here is a rename there.
pub const METER_ARTIFACTS: &str = "artifacts";
pub const METER_ARCHIVE_BYTES: &str = "archive_bytes";
/// **What one repository occupies on holger's disk** (owner 2026-09-27: "bill
/// disk bytes", "the customer can see where their bytes are") — allocated bytes
/// over the repository's [`crate::repository::StoragePlace`]s, whatever form the
/// bytes take (a znippy archive, loose files), counted ONCE. The reading's
/// `source` names where they are. The meter holger sells.
pub const METER_DISK_BYTES: &str = "disk_bytes";
pub const METER_BYTES_SERVED: &str = "bytes_served";
pub const METER_REQUESTS: &str = "requests";
pub const METER_PROXY_UPSTREAM_HITS: &str = "proxy_upstream_hits";
pub const METER_REPOS: &str = "repos";
/// The meters holger measures PER REPOSITORY. A cap on one of these can be
/// policed; a cap on any other is a promise about a number that is not the
/// tenant's, which is why `Entitlement.Set` refuses it (see [`crate::metered`]).
pub const PER_REPOSITORY_METERS: &[&str] =
&[METER_ARTIFACTS, METER_ARCHIVE_BYTES, METER_DISK_BYTES];
/// The meters that are only ever measured for the WHOLE SERVER.
pub const SERVER_WIDE_METERS: &[&str] = &[
METER_BYTES_SERVED,
METER_REQUESTS,
METER_PROXY_UPSTREAM_HITS,
METER_REPOS,
];
/// The bounded listing cap, the same number the `/metrics` gauge uses: the artifact
/// count is a health signal, not a census, and a huge repository must not turn one
/// RPC into an unbounded walk. Spelled here because the HTTP door's copy is private
/// to that module — two constants with one value, and a test holds them equal rather
/// than a comment claiming they agree.
pub const LIST_CAP: usize = 100_000;
// ── THE COUNTING CORE ──────────────────────────────────────────────────────
//
// Lifted out of the gRPC handler so there is ONE counter with TWO callers: the
// `ReadUsage` door below, and `crate::meter_services::HolgerMeterServices` —
// the `usage.read` host service a meter guest reaches through its linker.
//
// That is the whole point of the extraction. With the door counting inline and
// the plugin seam wired to something else, a guest would answer from numbers
// nobody could compare against the door's; one counter makes "the guest is what
// answered" a change of narrator, not a change of arithmetic.
/// The product-scope storage meters the open guest declares, spelled here
/// because [`count`] is what has to answer them.
pub const METER_STORAGE_TOTAL_BYTES: &str = "storage_total_bytes";
pub const METER_STORAGE_SERVABLE_BYTES: &str = "storage_servable_bytes";
pub const METER_STORAGE_USED_BYTES: &str = "storage_used_bytes";
/// Why the three product-scope storage meters were refused before 2026-09-20,
/// and the reason still printed when this server was built without a data root
/// to measure.
///
/// **ANSWERED 2026-09-20.** holger now HAS a free-space source: [`statvfs`],
/// over `rustix`. What survives from the old note is the discipline it was
/// written to protect — a filesystem that cannot be stat'ed is a NAMED REFUSAL
/// and never a zero, because a zero here reads as "no storage", which is a
/// different claim. So this constant did not go away; it narrowed, from "holger
/// has no source" to "this holger was handed no data root to measure".
///
/// [`ReadingValue::Refused`]: holger_plugin_abi::v2::ReadingValue::Refused
pub const NO_FILESYSTEM_SOURCE: &str =
"this holger was given no data root, so there is no filesystem for it to measure — and a \
zero here would read as \"no storage\", which is a different claim";
/// Why one of the three is refused when the data root IS known but the kernel
/// would not answer. Formatted with the path and the errno, because an operator's
/// next move differs entirely between ENOENT and EACCES.
pub fn statvfs_refused(root: &std::path::Path, e: &std::io::Error) -> String {
format!(
"statvfs({}) failed: {e}. holger will not guess how much storage there is from a \
filesystem the kernel would not describe",
root.display()
)
}
/// **How much storage there is, how much is still servable, how much is in use**
/// — one `statvfs(3)` over holger's data root.
///
/// ## Why `rustix` and not `libc`
///
/// Both were already in `Cargo.lock` through other crates, so neither adds a
/// new name to the tree; the choice is about what the call SITE looks like.
/// `rustix` is safe Rust — `rustix::fs::statvfs(path) -> io::Result<StatVfs>` —
/// with no `unsafe` block, no hand-rolled `MaybeUninit`, no errno fishing, and
/// on Linux it issues the syscall through its `linux_raw` backend, so it does
/// not pull a C library in at all. `libc` would have meant an `unsafe` block and
/// a zeroed struct in the middle of a metering path whose entire reason to exist
/// is that a zero must never be mistaken for a measurement. It is also the one
/// the neighbours already use.
///
/// ## The three numbers, and why `servable` is `f_bavail`
///
/// * `total` = `f_blocks * f_frsize` — the filesystem's size.
/// * `used` = `(f_blocks - f_bfree) * f_frsize` — what is gone, including what
/// is reserved for root. Not `total - bavail`: that would count the root
/// reserve as somebody's data.
/// * `servable` = `f_bavail * f_frsize` — what an UNPRIVILEGED writer can still
/// put there, which is what holger is. `f_bfree` is the kernel's number and
/// includes the 5% root reserve holger cannot touch; reporting it as
/// "servable" would promise space that a `holger` user's write cannot reach.
///
/// So `used + servable <= total`, and the gap is the reserve. The open guest's
/// selftest checks exactly those inequalities.
///
/// ## It never answers a zero for a failure
///
/// An unreadable or absent root is [`Counted::value`] `Err`, carrying the errno
/// and the path. Only a real filesystem that really is empty reports a small
/// `used`.
pub fn storage_readings(data_root: Option<&std::path::Path>) -> Vec<Counted> {
let names = [
METER_STORAGE_TOTAL_BYTES,
METER_STORAGE_SERVABLE_BYTES,
METER_STORAGE_USED_BYTES,
];
let Some(root) = data_root else {
return names
.into_iter()
.map(|name| Counted {
name,
scope: MeterScope::Server,
source: "data-root".to_string(),
value: Err(NO_FILESYSTEM_SOURCE.to_string()),
})
.collect();
};
let source = root.display().to_string();
let values: [Result<u64, String>; 3] = match statvfs(root) {
Ok(f) => [Ok(f.total_bytes), Ok(f.servable_bytes), Ok(f.used_bytes)],
Err(reason) => [Err(reason.clone()), Err(reason.clone()), Err(reason)],
};
names
.into_iter()
.zip(values)
.map(|(name, value)| Counted {
name,
scope: MeterScope::Server,
source: source.clone(),
value,
})
.collect()
}
/// What one `statvfs(3)` says, in holger's words.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StorageFigures {
pub total_bytes: u64,
pub servable_bytes: u64,
pub used_bytes: u64,
}
/// One `statvfs(3)` over `root`, or the reason it could not be taken.
///
/// Public because the proof
/// (`server/lib/tests/the_storage_meters_measure_a_real_filesystem.rs`) runs it
/// against a tmpfs of known size, and a measurement nothing can call is a
/// measurement nobody has checked.
pub fn statvfs(root: &std::path::Path) -> Result<StorageFigures, String> {
let f =
rustix::fs::statvfs(root).map_err(|e| statvfs_refused(root, &std::io::Error::from(e)))?;
// `f_frsize` is the fragment size every block count here is in. A
// filesystem reporting 0 for it is one this cannot convert into bytes, and
// multiplying by zero would answer "no storage" — the exact lie the whole
// module is written against.
if f.f_frsize == 0 {
return Err(format!(
"statvfs({}) reported a fragment size of 0, so its block counts cannot be turned \
into bytes",
root.display()
));
}
let unit = f.f_frsize;
Ok(StorageFigures {
total_bytes: f.f_blocks.saturating_mul(unit),
servable_bytes: f.f_bavail.saturating_mul(unit),
used_bytes: f.f_blocks.saturating_sub(f.f_bfree).saturating_mul(unit),
})
}
/// One number holger counted, in holger's own vocabulary — before it is either
/// put on the proto wire or handed across the plugin ABI.
///
/// `value` is a `Result`, never a number with a flag beside it: a refusal that
/// can be read as a `0` by forgetting one field is the bug the whole module
/// header is about.
#[derive(Debug, Clone)]
pub struct Counted {
pub name: &'static str,
pub scope: MeterScope,
/// Where the number came from: a backend name, `metrics`, `route-table`.
pub source: String,
/// `Ok` is a measurement. `Err` is a REFUSAL carrying its reason by name.
pub value: Result<u64, String>,
}
impl Counted {
/// Onto the proto wire the `ReadUsage` door answers with.
pub fn into_reading(self) -> MeterReading {
match self.value {
Ok(v) => counted(self.name, v, self.scope, &self.source),
Err(reason) => refused(self.name, self.scope, &self.source, reason),
}
}
}
/// **Count `repository`, or the product scope when `repository` is empty.**
///
/// Synchronous and blocking on purpose: `list` and `archive_info` are disk /
/// znippy I/O. Every caller runs it off the reactor, and in ONE hop, so the six
/// numbers share one instant.
///
/// An unknown repository is an `Err`, not six zeroes: six zeroes read as "this
/// tenant is idle" rather than as "you asked about something that is not here".
/// Every repository's storage places, by name (`Holger::storage_places`).
pub type StoragePlaces = std::collections::BTreeMap<String, Vec<crate::repository::StoragePlace>>;
pub fn count(
routes: &crate::exposed::fast_routes::FastRoutes,
repository: &str,
data_root: Option<&std::path::Path>,
places: Option<&StoragePlaces>,
) -> Result<Vec<Counted>, String> {
let mut out = Vec::new();
if repository.is_empty() {
// THE PRODUCT SCOPE. `meters::PRODUCT_SCOPE` on the plugin ABI is the
// empty repository name, and it asks how much storage this holger HAS
// rather than what one tenant holds. Answered by one `statvfs(3)` over
// the data root since 2026-09-20; refused BY NAME, never zeroed, when
// there is no root or the kernel will not describe it.
out.extend(storage_readings(data_root));
return Ok(out);
}
let backend = routes
.lookup(repository)
.ok_or_else(|| format!("no repository named `{repository}` is served here"))?;
let backend_name = backend.name().to_string();
// ASK FIRST. A backend that does not override `list` answers an empty
// listing, and an empty listing nobody enumerated is not a count of zero —
// it is the absence of a count.
let artifacts: Result<u64, String> = if backend.caps().listing {
backend
.list(None, LIST_CAP)
.map(|v| v.len() as u64)
// An Err is the disk, not the tenant. It stays an Err.
.map_err(|e| format!("the backend could not enumerate: {e}"))
} else {
Err("this backend format cannot enumerate its artifacts".to_string())
};
// Likewise: a repository with no archive has no archive byte count.
// Reporting 0 would let "not archive-backed" be summed with a real zero
// from a repository that has an empty archive.
let archive_bytes: Result<u64, String> = if backend.caps().archive {
backend
.archive()
.map(|a| a.info().total_uncompressed_bytes)
.map_err(|e| format!("the archive could not be read: {e}"))
} else {
Err("this repository is not archive-backed".to_string())
};
out.push(Counted {
name: METER_ARTIFACTS,
scope: MeterScope::Repository,
source: backend_name.clone(),
value: artifacts,
});
out.push(Counted {
name: METER_ARCHIVE_BYTES,
scope: MeterScope::Repository,
source: backend_name.clone(),
value: archive_bytes,
});
// What the repository occupies on disk, and WHERE (the source names the
// places): the meter holger sells.
let (disk, whr) = match places.and_then(|m| m.get(repository)) {
Some(p) => match disk_bytes(p) {
Ok((n, whr)) => (Ok(n), whr),
Err(e) => (Err(e), backend_name.clone()),
},
None => (
Err("where this repository's bytes are was not given to this reader".to_string()),
backend_name,
),
};
out.push(Counted {
name: METER_DISK_BYTES,
scope: MeterScope::Repository,
source: whr,
value: disk,
});
// ── the server-wide four, labelled as such. These are process counters:
// they are always counted, and always for the whole box. ──
let m = crate::metrics::global();
// `requests` is the sum over every verb the registry counts — there is no
// per-repository breakdown, which is exactly what the SERVER scope says.
let requests: u64 = crate::metrics::ALL_VERBS
.iter()
.map(|v| m.request_count(*v))
.sum();
let server = |name: &'static str, value: u64, source: &str| Counted {
name,
scope: MeterScope::Server,
source: source.to_string(),
value: Ok(value),
};
out.push(server(METER_BYTES_SERVED, m.bytes_served(), "metrics"));
out.push(server(METER_REQUESTS, requests, "metrics"));
out.push(server(
METER_PROXY_UPSTREAM_HITS,
m.proxy_count(crate::metrics::ProxyOutcome::UpstreamHit),
"metrics",
));
out.push(server(
METER_REPOS,
routes.all_repos().len() as u64,
"route-table",
));
Ok(out)
}
/// One reading the server actually counted.
fn counted(name: &str, value: u64, scope: MeterScope, source: &str) -> MeterReading {
MeterReading {
name: name.to_string(),
value,
scope: scope as i32,
source: source.to_string(),
counted: true,
refusal_reason: String::new(),
}
}
/// One reading this server cannot make. `value` is zero because the field must
/// hold something; `counted: false` is what says the zero is not a measurement.
fn refused(name: &str, scope: MeterScope, source: &str, reason: String) -> MeterReading {
MeterReading {
name: name.to_string(),
value: 0,
scope: scope as i32,
source: source.to_string(),
counted: false,
refusal_reason: reason,
}
}
#[tonic::async_trait]
impl UsageService for Arc<HolgerGrpc> {
async fn read_usage(
&self,
request: Request<ReadUsageRequest>,
) -> Result<Response<ReadUsageResponse>, Status> {
let repo = request.get_ref().repository.clone();
// READER on the named repository, through the SAME gate every read door
// uses: usage is a fact about a tenant's own data, and a server that hands
// it to anyone has told the world how much each customer stores.
let identity = self.authorize_read(&request, &repo).await?;
// The call a guest would be stamped with if it ever wrote an audit
// record: ONE spelling of identity, the same string every other door
// records, and the peer address the transport actually saw. The guest
// never supplies either — see `meter_services`.
#[cfg(feature = "meter-guests")]
let stamp = crate::meter_services::CallStamp {
ident: identity
.as_ref()
.map(|i| i.subject.clone())
.unwrap_or_else(|| "anonymous".to_string()),
source_ip: self.audit_peer(&request),
};
let _ = identity;
// The repository must exist — a reading for a name nothing serves would be
// six zeroes, and six zeroes read as "this tenant is idle" rather than as
// "you asked about something that is not here".
let _ = self.get_repo(&repo)?;
// ── WHO ANSWERS ──────────────────────────────────────────────────────
//
// The loaded guest, when there is one; this server's own counter when
// there is not. Both read the SAME numbers out of [`count`] — the guest
// reaches them through its `usage.read` import, which is the only thing
// its linker defines besides `store.list`. What changes is the narrator,
// not the arithmetic, and the `source` field on each reading says which
// narrator it was.
//
// Either way it is ONE `spawn_blocking`: `list` and `archive_info` are
// synchronous disk/znippy I/O, and two hops for one reading would be two
// different instants under the single `measured_at_unix_ms` below.
let routes = self.routes.clone();
// The filesystem the product-scope storage meters are measured over.
// `None` ⇒ those three are refused by name; see [`storage_readings`].
let data_root = self.data_root.clone();
let places = self.storage_places.clone();
#[cfg(feature = "meter-guests")]
let meters = {
let guests = self.meters.clone();
let repo_for_task = repo.clone();
tokio::task::spawn_blocking(move || {
if guests.is_empty() {
crate::metering::count(
&routes,
&repo_for_task,
data_root.as_deref(),
Some(&places),
)
.map(|rows| rows.into_iter().map(Counted::into_reading).collect())
} else {
crate::meter_services::in_call(stamp, || {
crate::meter_services::read_through_guests(&guests, &repo_for_task)
})
}
})
.await
.map_err(|e| crate::grpc::internal_err(format!("usage listing task failed: {e}")))?
.map_err(|e| crate::grpc::internal_err(format!("usage: {e}")))?
};
#[cfg(not(feature = "meter-guests"))]
let meters: Vec<MeterReading> = {
let repo_for_task = repo.clone();
tokio::task::spawn_blocking(move || {
crate::metering::count(&routes, &repo_for_task, data_root.as_deref(), Some(&places))
})
.await
.map_err(|e| crate::grpc::internal_err(format!("usage listing task failed: {e}")))?
.map_err(|e| crate::grpc::internal_err(format!("usage: {e}")))?
.into_iter()
.map(Counted::into_reading)
.collect()
};
crate::grpc::functional_status(
"holger-grpc/read_usage",
"usage_served",
true,
"six meters, each carrying the scope it was measured at and whether it was \
counted at all",
);
// The ceilings in force, echoed so a tenant can see its own. Empty in a
// build with no paid state -- and empty is not "zero everywhere".
#[cfg(feature = "metered")]
let caps = self.caps_for_repository(&repo);
#[cfg(not(feature = "metered"))]
let caps = Vec::new();
Ok(Response::new(ReadUsageResponse {
meters,
measured_at_unix_ms: crate::metering::now_unix_ms(),
caps,
}))
}
}
/// Wall-clock milliseconds. `monetize_product::Usage` carries the same field and
/// will not invent one, so the reading says when it was taken.
pub fn now_unix_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
// ── DISK BYTES: what a repository occupies, and where ─────────────────────
/// Allocated bytes of one file (blocks × 512 on unix: what the disk really
/// holds, a sparse file counted as its blocks), its length elsewhere.
fn allocated(meta: &std::fs::Metadata) -> u64 {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
meta.blocks().saturating_mul(512)
}
#[cfg(not(unix))]
{
meta.len()
}
}
/// **What `places` occupy on disk, and where** — pure over the filesystem. A
/// file place counts the file AND its siblings named after it (a writable
/// archive keeps `x.znippy.*` beside `x.znippy`); a directory place is walked,
/// symlinks not followed. A place that does not exist yet holds 0 bytes (a
/// repository nothing was written to); a place that exists and cannot be read
/// is a refusal naming it — never a zero.
pub fn disk_bytes(places: &[crate::repository::StoragePlace]) -> Result<(u64, String), String> {
let mut total = 0u64;
let mut said = Vec::new();
for p in places {
let meta = match std::fs::symlink_metadata(&p.path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
said.push(format!(
"{} {} (0 B, nothing written yet)",
p.kind,
p.path.display()
));
continue;
}
Err(e) => return Err(format!("{} {}: {e}", p.kind, p.path.display())),
};
let mut here = 0u64;
if meta.is_dir() {
let mut stack = vec![p.path.clone()];
while let Some(d) = stack.pop() {
for entry in std::fs::read_dir(&d).map_err(|e| format!("{}: {e}", d.display()))? {
let entry = entry.map_err(|e| format!("{}: {e}", d.display()))?;
let m = entry
.metadata()
.map_err(|e| format!("{}: {e}", entry.path().display()))?;
if m.is_symlink() {
continue;
}
if m.is_dir() {
stack.push(entry.path());
} else {
here = here.saturating_add(allocated(&m));
}
}
}
} else {
here = allocated(&meta);
if let (Some(dir), Some(name)) =
(p.path.parent(), p.path.file_name().and_then(|n| n.to_str()))
{
let prefix = format!("{name}.");
if let Ok(rd) = std::fs::read_dir(if dir.as_os_str().is_empty() {
std::path::Path::new(".")
} else {
dir
}) {
for entry in rd.flatten() {
let n = entry.file_name();
if n.to_str().is_some_and(|s| s.starts_with(&prefix)) {
if let Ok(m) = entry.metadata() {
if m.is_file() {
here = here.saturating_add(allocated(&m));
}
}
}
}
}
}
}
total = total.saturating_add(here);
said.push(format!("{} {} ({here} B)", p.kind, p.path.display()));
}
if places.is_empty() {
return Err(
"this repository owns no storage (a group is a view over its members)".to_string(),
);
}
Ok((total, said.join("; ")))
}
#[cfg(test)]
mod disk_bytes_tests {
use super::*;
use crate::repository::StoragePlace;
#[test]
fn a_repository_counts_its_files_or_its_archive_once_and_says_where() {
let d = tempfile::tempdir().unwrap();
let files = d.path().join("crates");
std::fs::create_dir_all(files.join("a/b")).unwrap();
std::fs::write(files.join("a/b/x.tar"), vec![1u8; 100_000]).unwrap();
std::fs::write(files.join("y.tar"), vec![2u8; 50_000]).unwrap();
let (n, whr) = disk_bytes(&[StoragePlace {
kind: "files",
path: files.clone(),
}])
.unwrap();
assert!(n >= 150_000, "allocated covers the bytes: {n}");
assert!(whr.starts_with("files ") && whr.contains("crates"), "{whr}");
let archive = d.path().join("store.znippy");
std::fs::write(&archive, vec![3u8; 40_000]).unwrap();
std::fs::write(d.path().join("store.znippy.tokens"), vec![4u8; 8_000]).unwrap();
std::fs::write(d.path().join("other.znippy"), vec![5u8; 90_000]).unwrap();
let (a, whr) = disk_bytes(&[StoragePlace {
kind: "archive (writable)",
path: archive,
}])
.unwrap();
assert!(
(48_000..90_000).contains(&a),
"the archive and its own sidecars, not a neighbour: {a}"
);
assert!(whr.contains("archive (writable)"), "{whr}");
let (z, whr) = disk_bytes(&[StoragePlace {
kind: "files",
path: d.path().join("nothing-yet"),
}])
.unwrap();
assert_eq!(z, 0);
assert!(whr.contains("nothing written yet"));
assert!(disk_bytes(&[]).unwrap_err().contains("group"));
}
}