use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::sha256_hex;
pub const SANDBOX_STATUS_SCHEMA: &str = "roteiro.sandbox.status/v1";
pub const SANDBOX_CLEAR_SCHEMA: &str = "roteiro.sandbox.clear/v1";
pub const SANDBOX_STORE_DIR: &str = "boxlite-home";
const INDEX_DB: &str = "db/boxlite.db";
const KNOWN_ENTRIES: &[&str] = &[".lock", "bases", "boxes", "db", "images", "locks", "tmp"];
const IMAGE_DIRS: &[&str] = &["configs", "disk-images", "extracted", "layers", "manifests"];
#[must_use]
pub fn blob_name(digest: &str) -> String {
digest.replace(':', "-")
}
#[must_use]
pub fn image_digest(layers: &[String]) -> String {
let joined: String = layers.concat();
format!("sha256:{}", sha256_hex(joined.as_bytes()))
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum StoreError {
#[error("cannot read the sandbox image index at {path}: {message}")]
Index {
path: String,
message: String,
},
#[error("{action} {path}: {message}")]
Io {
action: &'static str,
path: String,
message: String,
},
#[error("the sandbox store is not holding `{reference}`; it is holding: {known}")]
UnknownImage {
reference: String,
known: String,
},
#[error(
"the sandbox store has {boxes} registered box(es); \
stop them before clearing, or the bytes a running box is reading go away underneath it"
)]
LiveBoxes {
boxes: usize,
},
#[error(
"the sandbox store holds `{entry}`, which this version of Roteiro does not recognise; \
it will not be cleared, and nothing else was cleared either — \
report it on issue #433, because an entry a digest does not re-obtain does not belong here"
)]
UnrecognisedEntry {
entry: String,
},
#[error("the sandbox index has a base disk at {path}, which is outside the store root {root}")]
BaseOutsideStore {
path: String,
root: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Scope {
Everything,
Image(String),
}
impl Scope {
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::Everything => "everything",
Self::Image(reference) => reference,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Attribution {
Complete,
Partial,
}
#[derive(Debug, Clone, Copy, Default, Serialize)]
pub struct ImageBytes {
pub metadata: u64,
pub layers: u64,
pub extracted: u64,
pub disk_image: u64,
pub base_disk: u64,
pub total: u64,
pub exclusive: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct Objects {
pub expected: usize,
pub present: usize,
}
impl Objects {
#[must_use]
pub fn complete(self) -> bool {
self.expected == self.present
}
}
#[derive(Debug, Clone, Serialize)]
pub struct CachedImage {
pub reference: String,
pub manifest_digest: String,
pub config_digest: String,
pub image_digest: String,
pub cached_at: String,
pub pull_complete: bool,
pub layers: usize,
pub bytes: ImageBytes,
pub objects: Objects,
pub disk_image_built: bool,
pub base_disk_built: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct Unattributed {
pub path: String,
pub bytes: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct Preserved {
pub path: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct SandboxStatus {
pub schema: &'static str,
pub scope: &'static str,
pub store: String,
pub present: bool,
pub images: Vec<CachedImage>,
pub attribution: Attribution,
pub unattributed: Vec<Unattributed>,
pub preserved: Vec<Preserved>,
pub live_boxes: usize,
pub total_bytes: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct RemovedImage {
pub reference: String,
pub freed_bytes: u64,
pub objects_removed: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct VerifiedImage {
pub reference: String,
pub objects: Objects,
pub complete: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct ClearReport {
pub schema: &'static str,
pub scope: &'static str,
pub store: String,
pub requested: String,
pub applied: bool,
pub removed: Vec<RemovedImage>,
pub removed_unattributed: Vec<Unattributed>,
pub freed_bytes: u64,
pub store_bytes_before: u64,
pub store_bytes_after: u64,
pub retained: Vec<VerifiedImage>,
pub preserved: Vec<Preserved>,
}
impl ClearReport {
#[must_use]
pub fn measured_freed_bytes(&self) -> u64 {
self.store_bytes_before
.saturating_sub(self.store_bytes_after)
}
#[must_use]
pub fn survivors_intact(&self) -> bool {
self.retained.iter().all(|image| image.complete)
}
}
#[derive(Debug, Clone)]
struct IndexRow {
reference: String,
manifest_digest: String,
config_digest: String,
layers: Vec<String>,
cached_at: String,
complete: bool,
}
impl IndexRow {
fn unique_layers(&self) -> BTreeSet<String> {
self.layers.iter().cloned().collect()
}
}
#[derive(Debug, Clone)]
struct BaseRow {
name: String,
kind: String,
path: PathBuf,
}
#[derive(Debug, Default)]
struct Index {
images: Vec<IndexRow>,
bases: Vec<BaseRow>,
escaped: Vec<PathBuf>,
boxes: usize,
}
fn read_index(store: &Path) -> Result<Index, StoreError> {
let path = store.join(INDEX_DB);
if !path.exists() {
return Ok(Index::default());
}
let fail = |message: String| StoreError::Index {
path: path.display().to_string(),
message,
};
let db = rusqlite::Connection::open_with_flags(
&path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
)
.map_err(|error| fail(error.to_string()))?;
let mut images = Vec::new();
{
let mut statement = db
.prepare(
"SELECT reference, manifest_digest, config_digest, layers, cached_at, complete \
FROM image_index ORDER BY reference",
)
.map_err(|error| fail(error.to_string()))?;
let rows = statement
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
row.get::<_, i64>(5)?,
))
})
.map_err(|error| fail(error.to_string()))?;
for row in rows {
let (reference, manifest_digest, config_digest, layers, cached_at, complete) =
row.map_err(|error| fail(error.to_string()))?;
let layers: Vec<String> = serde_json::from_str(&layers).map_err(|error| {
fail(format!(
"image_index row `{reference}` has an unreadable layer list: {error}"
))
})?;
images.push(IndexRow {
reference,
manifest_digest,
config_digest,
layers,
cached_at,
complete: complete != 0,
});
}
}
let mut bases = Vec::new();
let mut escaped = Vec::new();
{
let mut statement = db
.prepare("SELECT name, kind, base_path FROM base_disk ORDER BY id")
.map_err(|error| fail(error.to_string()))?;
let rows = statement
.query_map([], |row| {
Ok((
row.get::<_, Option<String>>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.map_err(|error| fail(error.to_string()))?;
for row in rows {
let (name, kind, path) = row.map_err(|error| fail(error.to_string()))?;
let path = PathBuf::from(path);
if path.starts_with(store) {
bases.push(BaseRow {
name: name.unwrap_or_default(),
kind,
path,
});
} else {
escaped.push(path);
}
}
}
let boxes = db
.query_row("SELECT COUNT(*) FROM box_config", [], |row| {
row.get::<_, i64>(0)
})
.map_err(|error| fail(error.to_string()))?;
Ok(Index {
images,
bases,
escaped,
boxes: usize::try_from(boxes).unwrap_or(usize::MAX),
})
}
fn base_name_prefix(image_digest: &str) -> String {
let bare = image_digest.strip_prefix("sha256:").unwrap_or(image_digest);
format!("{}-", &bare[..12.min(bare.len())])
}
#[derive(Debug, Default)]
struct ImageObjects {
pulled: Vec<PathBuf>,
derived: Vec<PathBuf>,
bases: usize,
}
impl ImageObjects {
fn all(&self) -> impl Iterator<Item = &PathBuf> {
self.pulled.iter().chain(self.derived.iter())
}
}
fn objects_for(store: &Path, row: &IndexRow, bases: &[BaseRow]) -> ImageObjects {
let images = store.join("images");
let mut objects = ImageObjects::default();
objects.pulled.push(
images
.join("manifests")
.join(format!("{}.json", blob_name(&row.manifest_digest))),
);
objects.pulled.push(
images
.join("configs")
.join(format!("{}.json", blob_name(&row.config_digest))),
);
for layer in row.unique_layers() {
let name = blob_name(&layer);
objects
.pulled
.push(images.join("layers").join(format!("{name}.tar.gz")));
objects.derived.push(images.join("extracted").join(name));
}
let digest = image_digest(&row.layers);
objects.derived.push(
images
.join("disk-images")
.join(format!("{}.ext4", blob_name(&digest))),
);
let prefix = base_name_prefix(&digest);
for base in bases {
if base.kind == "rootfs" && base.name.starts_with(&prefix) {
objects.derived.push(base.path.clone());
objects.bases += 1;
}
}
objects
}
fn index_manifests_for(store: &Path, retained: &BTreeSet<String>) -> BTreeSet<PathBuf> {
let dir = store.join("images").join("manifests");
let mut keep = BTreeSet::new();
let Ok(entries) = std::fs::read_dir(&dir) else {
return keep;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let Ok(document) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
continue;
};
let Some(children) = document.get("manifests").and_then(|value| value.as_array()) else {
continue;
};
if children
.iter()
.filter_map(|child| child.get("digest").and_then(|value| value.as_str()))
.any(|digest| retained.contains(digest))
{
keep.insert(path);
}
}
keep
}
fn allocated(metadata: &std::fs::Metadata) -> u64 {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
metadata.blocks() * 512
}
#[cfg(not(unix))]
{
metadata.len()
}
}
fn size_of(path: &Path) -> u64 {
let Ok(metadata) = std::fs::symlink_metadata(path) else {
return 0;
};
if !metadata.is_dir() {
return allocated(&metadata);
}
let mut total = allocated(&metadata);
let mut stack = vec![path.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(metadata) = entry.metadata() else {
continue;
};
total += allocated(&metadata);
if metadata.is_dir() {
stack.push(entry.path());
}
}
}
total
}
fn measure(paths: impl IntoIterator<Item = PathBuf>) -> BTreeMap<PathBuf, u64> {
let mut sizes = BTreeMap::new();
for path in paths {
sizes.entry(path).or_insert_with_key(|path| size_of(path));
}
sizes
}
#[must_use]
pub fn store_root(asset_root: &Path) -> PathBuf {
asset_root.join(SANDBOX_STORE_DIR)
}
pub fn status(asset_root: &Path) -> Result<SandboxStatus, StoreError> {
let store = store_root(asset_root);
let mut report = SandboxStatus {
schema: SANDBOX_STATUS_SCHEMA,
scope: "machine",
store: store.display().to_string(),
present: store.is_dir(),
images: Vec::new(),
attribution: Attribution::Complete,
unattributed: Vec::new(),
preserved: Vec::new(),
live_boxes: 0,
total_bytes: 0,
};
if !report.present {
return Ok(report);
}
let index = read_index(&store)?;
report.live_boxes = index.boxes;
report.total_bytes = size_of(&store);
let objects: Vec<(usize, ImageObjects)> = index
.images
.iter()
.enumerate()
.map(|(at, row)| (at, objects_for(&store, row, &index.bases)))
.collect();
let sizes = measure(
objects
.iter()
.flat_map(|(_, object)| object.all().cloned())
.collect::<Vec<_>>(),
);
let mut references: BTreeMap<&PathBuf, usize> = BTreeMap::new();
for (_, object) in &objects {
for path in object.all() {
*references.entry(path).or_default() += 1;
}
}
for (at, object) in &objects {
let row = &index.images[*at];
report
.images
.push(cached_image(&store, row, object, &sizes, &references));
}
report
.images
.sort_by_key(|image| std::cmp::Reverse(image.bytes.total));
let claimed: BTreeSet<PathBuf> = objects
.iter()
.flat_map(|(_, object)| object.all().cloned())
.chain(index_manifests_for(
&store,
&index
.images
.iter()
.map(|row| row.manifest_digest.clone())
.collect(),
))
.chain(preserved_paths(&index))
.collect();
report.unattributed = unattributed(&store, &claimed);
if !report.unattributed.is_empty() {
report.attribution = Attribution::Partial;
}
report.preserved = preserved(&index);
Ok(report)
}
fn cached_image(
store: &Path,
row: &IndexRow,
object: &ImageObjects,
sizes: &BTreeMap<PathBuf, u64>,
references: &BTreeMap<&PathBuf, usize>,
) -> CachedImage {
let images = store.join("images");
let mut bytes = ImageBytes::default();
for path in object.all() {
let size = sizes.get(path).copied().unwrap_or_default();
bytes.total += size;
if references.get(path).copied().unwrap_or(1) == 1 {
bytes.exclusive += size;
}
if path.starts_with(images.join("layers")) {
bytes.layers += size;
} else if path.starts_with(images.join("extracted")) {
bytes.extracted += size;
} else if path.starts_with(images.join("disk-images")) {
bytes.disk_image += size;
} else if path.starts_with(images.join("manifests"))
|| path.starts_with(images.join("configs"))
{
bytes.metadata += size;
} else {
bytes.base_disk += size;
}
}
let digest = image_digest(&row.layers);
let disk = images
.join("disk-images")
.join(format!("{}.ext4", blob_name(&digest)));
CachedImage {
reference: row.reference.clone(),
manifest_digest: row.manifest_digest.clone(),
config_digest: row.config_digest.clone(),
image_digest: digest,
cached_at: row.cached_at.clone(),
pull_complete: row.complete,
layers: row.unique_layers().len(),
bytes,
objects: Objects {
expected: object.pulled.len(),
present: object.pulled.iter().filter(|path| path.exists()).count(),
},
disk_image_built: disk.exists(),
base_disk_built: object.bases > 0,
}
}
fn unattributed(store: &Path, claimed: &BTreeSet<PathBuf>) -> Vec<Unattributed> {
let images = store.join("images");
let mut scanned: Vec<PathBuf> = IMAGE_DIRS.iter().map(|dir| images.join(dir)).collect();
scanned.push(store.join("bases"));
let mut found = Vec::new();
for dir in scanned {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if claimed.contains(&path) {
continue;
}
found.push(Unattributed {
path: path
.strip_prefix(store)
.unwrap_or(&path)
.display()
.to_string(),
bytes: size_of(&path),
});
}
}
found.sort_by_key(|entry| std::cmp::Reverse(entry.bytes));
found
}
fn preserved(index: &Index) -> Vec<Preserved> {
index
.bases
.iter()
.filter(|base| base.kind != "rootfs")
.map(|base| Preserved {
path: base.path.display().to_string(),
reason: format!(
"a `{}` base disk is the state of a box that ran, which no digest re-obtains",
base.kind
),
})
.chain(index.escaped.iter().map(|path| {
Preserved {
path: path.display().to_string(),
reason: "the index names this base disk outside the store root, so nothing \
here measures, lists or removes it"
.to_owned(),
}
}))
.collect()
}
fn preserved_paths(index: &Index) -> BTreeSet<PathBuf> {
index
.bases
.iter()
.filter(|base| base.kind != "rootfs")
.map(|base| base.path.clone())
.collect()
}
fn select<'a>(
index: &'a Index,
scope: &Scope,
) -> Result<(Vec<&'a IndexRow>, Vec<&'a IndexRow>), StoreError> {
match scope {
Scope::Everything => Ok((index.images.iter().collect(), Vec::new())),
Scope::Image(reference) => {
if !index.images.iter().any(|row| &row.reference == reference) {
return Err(StoreError::UnknownImage {
reference: reference.clone(),
known: index
.images
.iter()
.map(|row| row.reference.as_str())
.collect::<Vec<_>>()
.join(", "),
});
}
Ok(index
.images
.iter()
.partition(|row| &row.reference == reference))
}
}
}
fn paths_for(store: &Path, rows: &[&IndexRow], bases: &[BaseRow]) -> BTreeSet<PathBuf> {
rows.iter()
.flat_map(|row| {
objects_for(store, row, bases)
.all()
.cloned()
.collect::<Vec<_>>()
})
.chain(index_manifests_for(
store,
&rows.iter().map(|row| row.manifest_digest.clone()).collect(),
))
.collect()
}
pub fn plan(asset_root: &Path, scope: &Scope) -> Result<(ClearReport, Vec<PathBuf>), StoreError> {
let store = store_root(asset_root);
let mut report = ClearReport {
schema: SANDBOX_CLEAR_SCHEMA,
scope: "machine",
store: store.display().to_string(),
requested: scope.as_str().to_owned(),
applied: false,
removed: Vec::new(),
removed_unattributed: Vec::new(),
freed_bytes: 0,
store_bytes_before: 0,
store_bytes_after: 0,
retained: Vec::new(),
preserved: Vec::new(),
};
if !store.is_dir() {
return Ok((report, Vec::new()));
}
let index = read_index(&store)?;
if index.boxes > 0 {
return Err(StoreError::LiveBoxes { boxes: index.boxes });
}
guard_entries(&store)?;
guard_bases(&store, &index)?;
let (doomed_rows, surviving_rows) = select(&index, scope)?;
let retained = paths_for(&store, &surviving_rows, &index.bases);
report.store_bytes_before = size_of(&store);
report.preserved = preserved(&index);
let mut doomed: Vec<PathBuf> = Vec::new();
for row in &doomed_rows {
let objects = objects_for(&store, row, &index.bases);
let mine: Vec<PathBuf> = objects
.all()
.filter(|path| !retained.contains(*path))
.filter(|path| path.exists())
.cloned()
.collect();
let freed = mine.iter().map(|path| size_of(path)).sum();
report.removed.push(RemovedImage {
reference: row.reference.clone(),
freed_bytes: freed,
objects_removed: mine.len(),
});
report.freed_bytes += freed;
doomed.extend(mine);
}
for path in index_manifests_for(
&store,
&doomed_rows
.iter()
.map(|row| row.manifest_digest.clone())
.collect(),
) {
if !retained.contains(&path) && path.exists() {
report.freed_bytes += size_of(&path);
doomed.push(path);
}
}
if matches!(scope, Scope::Everything) {
let claimed: BTreeSet<PathBuf> = doomed
.iter()
.cloned()
.chain(preserved_paths(&index))
.collect();
report.removed_unattributed = unattributed(&store, &claimed);
for entry in &report.removed_unattributed {
report.freed_bytes += entry.bytes;
doomed.push(store.join(&entry.path));
}
}
doomed.sort();
doomed.dedup();
report.store_bytes_after = report.store_bytes_before;
Ok((report, doomed))
}
pub fn clear(asset_root: &Path, scope: &Scope) -> Result<ClearReport, StoreError> {
let store = store_root(asset_root);
let (mut report, doomed) = plan(asset_root, scope)?;
if !store.is_dir() {
report.applied = true;
return Ok(report);
}
for path in &doomed {
remove(path)?;
}
forget(&store, &report.removed)?;
report.applied = true;
report.store_bytes_after = size_of(&store);
report.retained = verify(&store)?;
Ok(report)
}
fn remove(path: &Path) -> Result<(), StoreError> {
let metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(StoreError::Io {
action: "inspecting",
path: path.display().to_string(),
message: error.to_string(),
});
}
};
let outcome = if metadata.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
};
outcome.map_err(|error| StoreError::Io {
action: "removing",
path: path.display().to_string(),
message: error.to_string(),
})
}
fn forget(store: &Path, removed: &[RemovedImage]) -> Result<(), StoreError> {
let path = store.join(INDEX_DB);
if !path.exists() {
return Ok(());
}
let fail = |message: String| StoreError::Index {
path: path.display().to_string(),
message,
};
let db = rusqlite::Connection::open(&path).map_err(|error| fail(error.to_string()))?;
db.execute_batch("BEGIN IMMEDIATE")
.map_err(|error| fail(error.to_string()))?;
for image in removed {
db.execute(
"DELETE FROM image_index WHERE reference = ?1",
[&image.reference],
)
.map_err(|error| fail(error.to_string()))?;
}
let orphaned: Vec<String> = {
let mut statement = db
.prepare("SELECT base_path FROM base_disk WHERE kind = 'rootfs'")
.map_err(|error| fail(error.to_string()))?;
let rows = statement
.query_map([], |row| row.get::<_, String>(0))
.map_err(|error| fail(error.to_string()))?;
rows.filter_map(Result::ok)
.filter(|base_path| !Path::new(base_path).exists())
.collect()
};
for base_path in &orphaned {
db.execute(
"DELETE FROM base_disk WHERE kind = 'rootfs' AND base_path = ?1",
[base_path],
)
.map_err(|error| fail(error.to_string()))?;
}
db.execute_batch("COMMIT")
.map_err(|error| fail(error.to_string()))?;
Ok(())
}
fn verify(store: &Path) -> Result<Vec<VerifiedImage>, StoreError> {
let index = read_index(store)?;
Ok(index
.images
.iter()
.map(|row| {
let objects = objects_for(store, row, &index.bases);
let tally = Objects {
expected: objects.pulled.len(),
present: objects.pulled.iter().filter(|path| path.exists()).count(),
};
VerifiedImage {
reference: row.reference.clone(),
objects: tally,
complete: tally.complete(),
}
})
.collect())
}
fn guard_entries(store: &Path) -> Result<(), StoreError> {
let entries = std::fs::read_dir(store).map_err(|error| StoreError::Io {
action: "reading",
path: store.display().to_string(),
message: error.to_string(),
})?;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if !KNOWN_ENTRIES.contains(&name.as_str()) {
return Err(StoreError::UnrecognisedEntry { entry: name });
}
}
let images = store.join("images");
if !images.is_dir() {
return Ok(());
}
let entries = std::fs::read_dir(&images).map_err(|error| StoreError::Io {
action: "reading",
path: images.display().to_string(),
message: error.to_string(),
})?;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if !IMAGE_DIRS.contains(&name.as_str()) {
return Err(StoreError::UnrecognisedEntry {
entry: format!("images/{name}"),
});
}
}
Ok(())
}
fn guard_bases(store: &Path, index: &Index) -> Result<(), StoreError> {
if let Some(path) = index.escaped.first() {
return Err(StoreError::BaseOutsideStore {
path: path.display().to_string(),
root: store.display().to_string(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
Attribution, IMAGE_DIRS, INDEX_DB, SANDBOX_STORE_DIR, Scope, StoreError, blob_name, clear,
image_digest, plan, status,
};
use std::path::PathBuf;
const SCHEMA: &str = "
CREATE TABLE image_index (
reference TEXT PRIMARY KEY NOT NULL,
manifest_digest TEXT NOT NULL,
config_digest TEXT NOT NULL,
layers TEXT NOT NULL,
cached_at TEXT NOT NULL,
complete INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE base_disk (
id TEXT PRIMARY KEY NOT NULL,
source_box_id TEXT NOT NULL,
name TEXT,
kind TEXT NOT NULL CHECK(kind IN ('snapshot', 'clone_base', 'rootfs')),
base_path TEXT NOT NULL,
created_at INTEGER NOT NULL,
json TEXT NOT NULL,
UNIQUE(source_box_id, name)
);
CREATE TABLE box_config (
id TEXT PRIMARY KEY NOT NULL,
name TEXT UNIQUE,
created_at INTEGER NOT NULL,
json TEXT NOT NULL
);
";
fn digest(seed: &str) -> String {
format!("sha256:{}", crate::sha256_hex(seed.as_bytes()))
}
struct Fixture {
root: PathBuf,
}
impl Fixture {
fn new(name: &str) -> Self {
let root = std::env::temp_dir()
.join(format!("rto-exec-sandbox-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let store = root.join(SANDBOX_STORE_DIR);
for dir in IMAGE_DIRS {
std::fs::create_dir_all(store.join("images").join(dir)).expect("image dir");
}
std::fs::create_dir_all(store.join("bases")).expect("bases dir");
std::fs::create_dir_all(store.join("db")).expect("db dir");
std::fs::write(store.join(".lock"), []).expect("lock file");
let db = rusqlite::Connection::open(store.join(INDEX_DB)).expect("open index");
db.execute_batch(SCHEMA).expect("index schema");
Self { root }
}
fn store(&self) -> PathBuf {
self.root.join(SANDBOX_STORE_DIR)
}
fn db(&self) -> rusqlite::Connection {
rusqlite::Connection::open(self.store().join(INDEX_DB)).expect("open index")
}
fn write(&self, relative: &str, bytes: usize) -> PathBuf {
let path = self.store().join(relative);
std::fs::create_dir_all(path.parent().expect("a parent")).expect("parent dir");
std::fs::write(&path, vec![b'x'; bytes]).expect("write object");
path
}
fn image(&self, reference: &str, layers: &[&str], layer_bytes: usize) {
let manifest = digest(&format!("{reference} manifest"));
let config = digest(&format!("{reference} config"));
let digests: Vec<String> = layers.iter().map(|seed| digest(seed)).collect();
self.db()
.execute(
"INSERT INTO image_index
(reference, manifest_digest, config_digest, layers, cached_at, complete)
VALUES (?1, ?2, ?3, ?4, ?5, 1)",
rusqlite::params![
reference,
manifest,
config,
serde_json::to_string(&digests).expect("layer list"),
"2026-08-19T00:00:00+00:00",
],
)
.expect("insert image row");
self.write(
&format!("images/manifests/{}.json", blob_name(&manifest)),
64,
);
self.write(&format!("images/configs/{}.json", blob_name(&config)), 32);
for layer in &digests {
self.write(
&format!("images/layers/{}.tar.gz", blob_name(layer)),
layer_bytes,
);
self.write(
&format!("images/extracted/{}/rootfs", blob_name(layer)),
layer_bytes,
);
}
self.write(
&format!(
"images/disk-images/{}.ext4",
blob_name(&image_digest(&digests))
),
layer_bytes * 4,
);
}
fn layer(&self, seed: &str) -> PathBuf {
self.store()
.join("images/layers")
.join(format!("{}.tar.gz", blob_name(&digest(seed))))
}
fn base(&self, id: &str, kind: &str, name: &str) -> PathBuf {
let path = self.write(&format!("bases/{id}.ext4"), 512);
self.db()
.execute(
"INSERT INTO base_disk
(id, source_box_id, name, kind, base_path, created_at, json)
VALUES (?1, '__global__', ?2, ?3, ?4, 0, '{}')",
rusqlite::params![id, name, kind, path.display().to_string()],
)
.expect("insert base row");
path
}
}
#[test]
fn the_disk_image_filename_is_derived_from_the_layer_list() {
let debian = [
"sha256:0f5d7465a5bb9d419f60c93d126a161286c73a1ede4a8b2e46bd5e7ad5782cc7".to_owned(),
];
assert_eq!(
image_digest(&debian),
"sha256:2674b856eab71e6d70f5d8ad573394d1b90f40da02593e3af3a31c17b8de1d97",
"the derivation no longer reproduces the disk image the live store holds for \
docker.io/library/debian:bookworm-slim"
);
let cimg: Vec<String> = [
"c36472b3458398be28ecbfebbaac44143c040eae73411baded48a22060d3055b",
"fee4d731b9208f65a65b57345c4945de0d8eccf9a9f8729e796be8911bd3131c",
"fec6be0b4b4a6668684b8cc97d59c44998ed49004a5e18954caa3f58986549a6",
"943b99e461484cf70776207df97a17783fc424cfeafd5e1bdffab309f42fe84f",
"4dc664574997cda0756a223b9e39e9c4cac313e72dbd59adef0fa723ac8ffc5f",
"cd344ce4edc31b84f799cfd1ff435b61345534535f474fcb8a7f2e9d2ddb209d",
"ab2260fc0eee2ac435fe045b63e2fc28c19cf56cb707101e8eb77601bcf7cdb3",
"4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1",
"5b96641132bf37840e483d28ac60942c3b7b26c2382322c8fa94e62e83b86523",
"4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1",
]
.iter()
.map(|hex| format!("sha256:{hex}"))
.collect();
assert_eq!(
image_digest(&cimg),
"sha256:b038ce43bcc84d230823ec62558e9a5926f9057206e3eb5ec704da92677fcc0d",
"the derivation no longer reproduces the disk image the live store holds for \
docker.io/cimg/rust, whose layer list names one digest twice"
);
}
#[test]
fn the_on_disk_name_is_not_the_index_digest() {
assert_eq!(blob_name("sha256:abc123"), "sha256-abc123");
}
#[test]
fn a_layer_two_images_share_survives_dropping_one_of_them() {
let fixture = Fixture::new("shared-layer");
fixture.image("registry/a:1", &["common", "only-a"], 4096);
fixture.image("registry/b:1", &["common", "only-b"], 4096);
let scope = Scope::Image("registry/a:1".to_owned());
let (_, doomed) = plan(&fixture.root, &scope).expect("plan");
assert!(
!doomed.contains(&fixture.layer("common")),
"a layer `registry/b:1` still references was put in the doomed set"
);
assert!(
doomed.contains(&fixture.layer("only-a")),
"the layer only the dropped image references was not in the doomed set"
);
let report = clear(&fixture.root, &scope).expect("clear");
assert!(
fixture.layer("common").exists(),
"the shared layer was deleted, so `registry/b:1` is now broken"
);
assert!(!fixture.layer("only-a").exists());
assert_eq!(report.retained.len(), 1);
assert!(
report.survivors_intact(),
"a surviving image is incomplete after the clear: {:?}",
report.retained
);
assert_eq!(report.retained[0].reference, "registry/b:1");
assert_eq!(report.retained[0].objects.expected, 4);
assert_eq!(report.retained[0].objects.present, 4);
}
#[test]
fn the_status_row_separates_what_an_image_references_from_what_it_would_free() {
let fixture = Fixture::new("exclusive-bytes");
fixture.image("registry/a:1", &["common", "only-a"], 4096);
fixture.image("registry/b:1", &["common", "only-b"], 4096);
let report = status(&fixture.root).expect("status");
let image = report
.images
.iter()
.find(|image| image.reference == "registry/a:1")
.expect("the image is listed");
assert!(
image.bytes.exclusive < image.bytes.total,
"a shared layer was counted as reclaimable: {:?}",
image.bytes
);
assert_eq!(image.objects.expected, 4, "manifest, config and two layers");
assert_eq!(image.objects.present, 4);
}
#[test]
fn the_accounted_bytes_and_the_measured_bytes_differ_only_by_the_index() {
let fixture = Fixture::new("everything");
fixture.image("registry/a:1", &["common", "only-a"], 4096);
fixture.image("registry/b:1", &["common", "only-b"], 4096);
let before = status(&fixture.root).expect("status").total_bytes;
let report = clear(&fixture.root, &Scope::Everything).expect("clear");
assert_eq!(report.removed.len(), 2);
assert!(report.retained.is_empty());
assert_eq!(report.store_bytes_before, before);
assert!(
report.store_bytes_after < report.store_bytes_before,
"the store did not shrink"
);
let index = super::size_of(&fixture.store().join("db"));
assert!(
report.freed_bytes.abs_diff(report.measured_freed_bytes()) <= index,
"the accounted bytes ({}) and the bytes the filesystem gave back ({}) differ by \
more than the index ({index}) — in either direction, which is the only thing in \
the store that can change size without having been removed",
report.freed_bytes,
report.measured_freed_bytes()
);
let after = status(&fixture.root).expect("status");
assert!(
after.images.is_empty(),
"the index still reports images whose blobs are gone: {:?}",
after.images
);
}
#[test]
fn an_index_manifest_a_survivor_resolves_through_is_kept() {
let fixture = Fixture::new("index-manifest");
fixture.image("registry/a:1", &["only-a"], 4096);
fixture.image("registry/b:1", &["only-b"], 4096);
let survivor = digest("registry/b:1 manifest");
let dropped = digest("registry/a:1 manifest");
let keep = fixture.write("images/manifests/sha256-keep.json", 0);
std::fs::write(
&keep,
serde_json::json!({ "manifests": [{ "digest": survivor }] }).to_string(),
)
.expect("write index manifest");
let go = fixture.write("images/manifests/sha256-go.json", 0);
std::fs::write(
&go,
serde_json::json!({ "manifests": [{ "digest": dropped }] }).to_string(),
)
.expect("write index manifest");
let before = status(&fixture.root).expect("status");
assert!(
before.unattributed.is_empty(),
"an index manifest a cached image resolves through was reported as \
unattributed, which is one `--everything` away from being deleted while its \
image survives: {:?}",
before.unattributed
);
clear(&fixture.root, &Scope::Image("registry/a:1".to_owned())).expect("clear");
assert!(!go.exists(), "the dropped image's index manifest was kept");
let after = status(&fixture.root).expect("status");
assert!(
after.unattributed.is_empty(),
"the survivor's index manifest stopped being attributed once the other image \
was dropped: {:?}",
after.unattributed
);
assert!(
keep.exists(),
"the index manifest `registry/b:1` resolves through was deleted"
);
}
#[test]
fn an_unrecognised_entry_stops_the_clear_without_removing_anything() {
let fixture = Fixture::new("unrecognised");
fixture.image("registry/a:1", &["only-a"], 4096);
std::fs::create_dir_all(fixture.store().join("provenance")).expect("mystery dir");
let error = clear(&fixture.root, &Scope::Everything)
.expect_err("an entry this module cannot classify must stop the clear");
assert!(
matches!(&error, StoreError::UnrecognisedEntry { entry } if entry == "provenance"),
"expected the unrecognised entry to be named, got: {error}"
);
assert!(
fixture.layer("only-a").exists(),
"the refusal removed objects on its way out"
);
}
#[test]
fn a_base_disk_row_pointing_outside_the_store_is_refused() {
let fixture = Fixture::new("escape");
fixture.image("registry/a:1", &["only-a"], 4096);
let outside = fixture.root.join("graph.db");
std::fs::write(&outside, b"not re-obtainable").expect("write");
fixture
.db()
.execute(
"INSERT INTO base_disk
(id, source_box_id, name, kind, base_path, created_at, json)
VALUES ('esc', '__global__', 'escapee', 'rootfs', ?1, 0, '{}')",
rusqlite::params![outside.display().to_string()],
)
.expect("insert base row");
let error = clear(&fixture.root, &Scope::Everything)
.expect_err("a base disk outside the store root must stop the clear");
assert!(
matches!(&error, StoreError::BaseOutsideStore { path, .. } if path.contains("graph.db")),
"expected the escaping path to be named, got: {error}"
);
assert!(outside.exists(), "the clear reached outside the store root");
let report = status(&fixture.root).expect("status");
assert!(
report
.preserved
.iter()
.any(|entry| entry.path.contains("graph.db")),
"the escaping row was not named in the status document"
);
let image = &report.images[0];
assert_eq!(
image.bytes.base_disk, 0,
"a file outside the store root was measured into an image's size"
);
assert!(!image.base_disk_built);
}
#[cfg(unix)]
#[test]
fn a_sparse_disk_image_is_counted_by_what_it_occupies() {
use std::io::{Seek as _, Write as _};
let fixture = Fixture::new("sparse");
let path = fixture.write("images/disk-images/sha256-sparse.ext4", 0);
let mut file = std::fs::File::create(&path).expect("create");
file.seek(std::io::SeekFrom::Start(64 << 20)).expect("seek");
file.write_all(b"end").expect("write");
drop(file);
let apparent = std::fs::metadata(&path).expect("metadata").len();
let occupied = super::size_of(&path);
assert!(
apparent > 64 << 20,
"the fixture file is not long enough to be worth measuring"
);
assert!(
occupied < apparent,
"a sparse file was counted by its length ({apparent}) rather than by what it \
occupies ({occupied}), so a clear would over-report what it freed"
);
}
#[test]
fn a_registered_box_blocks_the_clear() {
let fixture = Fixture::new("live-box");
fixture.image("registry/a:1", &["only-a"], 4096);
fixture
.db()
.execute(
"INSERT INTO box_config (id, name, created_at, json)
VALUES ('box1', 'running', 0, '{}')",
[],
)
.expect("insert box row");
let error = clear(&fixture.root, &Scope::Everything)
.expect_err("a registered box must stop the clear");
assert!(
matches!(error, StoreError::LiveBoxes { boxes: 1 }),
"expected a live-box refusal, got: {error}"
);
assert!(fixture.layer("only-a").exists());
}
#[test]
fn a_snapshot_base_disk_is_preserved_rather_than_treated_as_spare() {
let fixture = Fixture::new("snapshot");
fixture.image("registry/a:1", &["only-a"], 4096);
let snapshot = fixture.base("snap1", "snapshot", "a-snapshot");
let before = status(&fixture.root).expect("status");
assert!(
!before
.unattributed
.iter()
.any(|entry| snapshot.ends_with(&entry.path)),
"a snapshot base disk was reported as unattributed bytes"
);
assert_eq!(before.preserved.len(), 1);
let report = clear(&fixture.root, &Scope::Everything).expect("clear");
assert!(
snapshot.exists(),
"the clear removed a snapshot, which no digest re-obtains"
);
assert_eq!(report.preserved.len(), 1);
}
#[test]
fn an_unknown_reference_names_what_the_store_is_holding() {
let fixture = Fixture::new("unknown-image");
fixture.image("registry/a:1", &["only-a"], 4096);
let error = clear(&fixture.root, &Scope::Image("registry/a:2".to_owned()))
.expect_err("a reference the store is not holding must be refused");
assert!(
matches!(&error, StoreError::UnknownImage { known, .. } if known == "registry/a:1"),
"expected the cached references to be named, got: {error}"
);
}
#[test]
fn unattributed_bytes_survive_a_per_image_clear_and_go_with_everything() {
let fixture = Fixture::new("unattributed");
fixture.image("registry/a:1", &["only-a"], 4096);
fixture.image("registry/b:1", &["only-b"], 4096);
let orphan = fixture.write("images/extracted/sha256-orphan/rootfs", 8192);
let before = status(&fixture.root).expect("status");
assert_eq!(before.attribution, Attribution::Partial);
assert_eq!(before.unattributed.len(), 1);
assert!(before.unattributed[0].path.ends_with("sha256-orphan"));
assert!(
before.unattributed[0].bytes >= 8192,
"the unattributed tree was measured as smaller than the file in it: {:?}",
before.unattributed[0]
);
clear(&fixture.root, &Scope::Image("registry/a:1".to_owned())).expect("clear");
assert!(
orphan.exists(),
"a per-image clear deleted bytes it could not attribute to that image"
);
let report = clear(&fixture.root, &Scope::Everything).expect("clear");
assert!(
!orphan.exists(),
"`everything` left unattributed bytes behind"
);
assert_eq!(report.removed_unattributed.len(), 1);
}
#[test]
fn an_absent_store_is_reported_rather_than_failing() {
let root =
std::env::temp_dir().join(format!("rto-exec-sandbox-absent-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let report = status(&root).expect("status");
assert!(!report.present);
assert!(report.images.is_empty());
assert_eq!(report.total_bytes, 0);
let cleared = clear(&root, &Scope::Everything).expect("clear");
assert_eq!(cleared.freed_bytes, 0);
assert!(cleared.applied);
}
#[test]
fn the_store_directory_is_the_one_boxlite_is_pointed_at() {
let source = include_str!("boxlite.rs");
let marker = "home_dir: assets_root.join(\"";
let named: Vec<&str> = source
.match_indices(marker)
.map(|(at, _)| {
source[at + marker.len()..]
.split_once('"')
.expect("a join argument is closed on the same line")
.0
})
.collect();
assert!(
!named.is_empty(),
"no `home_dir: assets_root.join(..)` was found to check against"
);
for directory in named {
assert_eq!(
directory, SANDBOX_STORE_DIR,
"boxlite.rs points a runtime at `{directory}` and this module clears \
`{SANDBOX_STORE_DIR}`"
);
}
}
#[test]
fn the_status_document_labels_its_scope_as_the_machine() {
let fixture = Fixture::new("scope");
fixture.image("registry/a:1", &["only-a"], 16);
let document =
serde_json::to_value(status(&fixture.root).expect("status")).expect("serialise");
assert_eq!(document["scope"], "machine");
assert_eq!(document["schema"], super::SANDBOX_STATUS_SCHEMA);
assert!(
document["store"]
.as_str()
.expect("a store path")
.ends_with(SANDBOX_STORE_DIR)
);
}
}