Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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"));
    }
}