rtb-assets 0.6.1

Embedded-asset + overlay filesystem abstraction. Part of the phpboyscout Rust toolkit.
Documentation
---
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

| 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](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