---
title: rtb-assets
description: Overlay asset filesystem over rust-embed + physical directories + in-memory fixtures, with YAML/JSON deep-merge across layers.
---
# `rtb-assets`
`rtb-assets` unifies three kinds of asset storage behind a single
read-only API:
1. **Embedded** via [`rust-embed`][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](https://rust.phpboyscout.uk);
extracted from — and battle-tested by —
[rust-tool-base](https://gitlab.com/phpboyscout/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
| `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](https://docs.rs/rtb-assets).
## Usage patterns
### Embedded defaults + user overrides
```rust
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
```rust
#[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
!!! warning "Path traversal is rejected lexically"
`DirectorySource::read("../../etc/passwd")` returns `None`
— `safe_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](https://gitlab.com/phpboyscout/rust-tool-base/-/blob/main/docs/development/engineering-standards.md#11-path-handling)
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](https://gitlab.com/phpboyscout/rust-tool-base/-/blob/main/docs/development/specs/2026-04-22-rtb-assets-v0.1.md).
Tests cover 19 acceptance criteria: 14 unit tests (`tests/unit.rs`,
T1–T14 including path traversal) and 6 Gherkin scenarios
(`tests/features/assets.feature`).
[rust-embed]: https://crates.io/crates/rust-embed