Skip to content

rtb-assets

rtb-assets unifies three kinds of asset storage behind a single read-only API:

  1. Embedded via rust-embed — compile-time bundling with dev-mode disk passthrough. Default configs, templates, docs.
  2. Physical directory — per-user overrides under $XDG_CONFIG_HOME/<tool>/….
  3. In-memory — test fixtures and scaffolder scratch space.

Binary-like blobs follow last-wins shadowing; structured data (YAML/JSON) follows RFC-7396 deep merge so nested maps combine recursively.

Part of the phpboyscout Rust toolkit; extracted from — and battle-tested by — rust-tool-base.

Overview

Downstream tools don't care where an asset lives. Assets::open, Assets::list_dir, and Assets::load_merged_yaml offer a uniform surface over heterogeneous backing stores. The crate ships three built-in layer types plus an AssetSource trait for exotic cases (HTTP overlays, in-process archives, etc.).

Design rationale

  • Own AssetSource trait, not vfs::OverlayFS. The vfs crate's OverlayFS is 2-layer; this crate needs N-layer merge with structured-data awareness. An as_vfs() adapter can be added later if downstream interop demands it.
  • json-patch::merge for YAML. YAML round-trips through serde_yaml::Value → serde_json::Value before merging. Adequate for all realistic config shapes (maps, sequences, scalars, null).
  • Parse failures name the offending layer. Silent fallback to a lower layer would hide bugs. A layer's name() feeds AssetError::Parse.path.
  • Path traversal rejected lexically. DirectorySource::read goes through safe_join.., absolute paths, and Windows prefix components are rejected at the front door, without a filesystem call.

Public API

Item Purpose
Assets Read-only overlay handle; Clone is refcount-only. open, open_text, exists, list_dir, load_merged_yaml, load_merged_json.
AssetsBuilder Registers layers in priority order (later wins): embedded, directory, memory, or any custom source.
AssetSource Trait for custom layers — read, list, and a diagnostic name().
EmbeddedSource<E> Layer over rust-embed generated tables — compile-time bundled defaults.
DirectorySource Layer over a directory on disk — user overrides, staging.
MemorySource Layer over a HashMap<String, Vec<u8>> — tests, scaffolder scratch.
AssetError NotFound, NotUtf8, Parse — each a miette::Diagnostic with a stable code.

Full API reference: docs.rs/rtb-assets.

Usage patterns

Embedded defaults + user overrides

use rtb_assets::Assets;

#[derive(rust_embed::RustEmbed)]
#[folder = "assets/"]
struct Defaults;

let assets = Assets::builder()
    .embedded::<Defaults>("defaults")
    .directory("/etc/mytool/assets", "system")
    .directory(dirs::config_dir().unwrap().join("mytool"), "user")
    .build();

// First existing file wins (user > system > defaults).
let icon = assets.open("icons/app.png").expect("missing icon");

Deep-merged YAML config

#[derive(Deserialize)]
struct Theme { name: String, palette: Palette }
#[derive(Deserialize)]
struct Palette { primary: String, accent: String }

// defaults.yaml ships `name: "classic", palette: { primary: "blue", accent: "orange" }`.
// user.yaml overrides just the accent: `palette: { accent: "pink" }`.
let theme: Theme = assets.load_merged_yaml("theme.yaml")?;
assert_eq!(theme.name, "classic");              // from defaults
assert_eq!(theme.palette.primary, "blue");      // from defaults
assert_eq!(theme.palette.accent, "pink");       // from user

Security

Path traversal is rejected lexically

DirectorySource::read("../../etc/passwd") returns Nonesafe_join rejects .. components, absolute paths, and Windows prefixes before touching the filesystem. This is a lexical check, not a canonicalize()-based one; symlink following is a caller concern.

The test suite verifies this (T14) with a DirectorySource rooted at /tmp/assets and a sibling /tmp/secret.txt the source cannot reach.

See Engineering Standards §1.1 for the standing rule.

Where it's wired

In rust-tool-base, rtb-app holds an Arc<Assets> on the application context, rtb-cli's builder threads in a user-constructed overlay, and the TUI docs browser reads its markdown via list_dir + open_text. Outside the framework the crate is dependency-light and stands alone.

Design record

The authoritative contract is the crate's v0.1 spec, retained in the rust-tool-base spec series. Tests cover 19 acceptance criteria: 14 unit tests (tests/unit.rs, T1–T14 including path traversal) and 6 Gherkin scenarios (tests/features/assets.feature).