rtb-assets 0.6.4

Embedded-asset + overlay filesystem abstraction. Part of the phpboyscout Rust toolkit.
Documentation
---
title: rtb-assets
description: Overlay asset filesystem over rust-embed, physical directories and in-memory fixtures, with YAML/JSON deep-merge across layers.
---

# `rtb-assets`

`rtb-assets` lets a Rust binary ship its own defaults and let users
override them. It unifies three kinds of asset storage behind one
read-only API:

1. **Embedded** via [`rust-embed`][rust-embed] — files compiled into
   the executable. Default configs, templates, docs.
2. **Physical directory** — system-wide and per-user override
   directories on disk.
3. **In-memory** — test fixtures and scaffolder scratch space.

Byte reads follow **last-wins shadowing**: the highest-priority layer
that has the file supplies the whole file. Structured reads (YAML,
JSON) follow **[RFC 7396](https://www.rfc-editor.org/rfc/rfc7396) deep
merge**, so an override file states only the keys it changes.

Part of the [phpboyscout Rust toolkit](https://rust.phpboyscout.uk);
extracted from — and used by —
[rust-tool-base](https://gitlab.com/phpboyscout/rust-tool-base).

```rust
use rtb_assets::Assets;

let assets = Assets::builder()
    .embedded::<Defaults>("defaults")   // lowest priority
    .directory("/etc/mytool", "system")
    .directory(user_config_dir, "user") // highest priority
    .build();

let icon = assets.open("icons/app.png");            // user's, if they have one
let config: Config = assets.load_merged_yaml("config.yaml")?;  // all three, merged
```

## Where to start

| If you want to… | Read |
|---|---|
| Get a working binary with overridable defaults | [Ship defaults a user can override]tutorials/ship-defaults-a-user-can-override.md |
| Design a config file that merges well | [Merge layered configuration]how-to/merge-layered-configuration.md |
| Add a layer the built-ins do not cover | [Write a custom asset source]how-to/write-a-custom-asset-source.md |
| Test code that reads assets | [Use assets in tests]how-to/use-assets-in-tests.md |
| Work out why a read returned nothing | [Diagnose a failed asset lookup]how-to/diagnose-a-failed-asset-lookup.md |
| Look up a method, an error or a default | [Reference]reference/assets-api.md |
| Understand why it works this way | [Why an overlay filesystem]explanation/why-an-overlay-filesystem.md |
| Find out whether it does the thing you need | [What rtb-assets does not do]explanation/limitations.md |

## The public API at a glance

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

Full signatures and failure behaviour: [`Assets` and `AssetsBuilder`
reference](reference/assets-api.md). Rustdoc: [docs.rs/rtb-assets](https://docs.rs/rtb-assets).

## What it does not do

It cannot write, delete or watch. It cannot tell you which layer
supplied a value. It merges YAML and JSON and no other format, arrays
replace rather than append, and `null` in an override deletes a key
rather than setting it. The full list, with the reasoning and the
workarounds, is [What rtb-assets does not do](explanation/limitations.md)
— worth reading before you adopt the crate rather than after.

## Path traversal is rejected lexically

!!! warning "Rejection is silent, and symlinks are not checked"
    `DirectorySource` refuses any path containing `..`, any absolute
    path, and any Windows prefix component, before it touches the
    filesystem. `open("../../etc/passwd")` returns `None`.

    The check is lexical, not `canonicalize()`-based: a symlink inside
    an otherwise-trusted root still resolves outside it. And rejection
    is indistinguishable from a missing file — nothing logs, and no
    distinct error variant exists.

    [Why path traversal is rejected lexically](explanation/path-safety.md)
    sets out the threat model and the boundary.

## How it is tested

The behaviour on this site is covered by 14 unit tests in `tests/unit.rs`
(`T1`–`T14`, including path traversal in `T14`) and 6 Gherkin scenarios
in `tests/features/assets.feature` (`S1`–`S6`) driven by a
[Cucumber](https://github.com/cucumber-rs/cucumber) runner. `just ci`
runs the same gate CI does: `fmt-check`, `clippy -D warnings`,
`cargo doc`, tests, and `cargo deny`.

[rust-embed]: https://crates.io/crates/rust-embed

## Further reading

The blog carries a curated route through this subject: **[Rust, and what survived the port](https://phpboyscout.uk/topics/rust/)** collects
everything written about it, ordered so you can start at the beginning rather
than newest-first.

!!! tip "Ask phpbotscout"

    ![phpbotscout](https://phpboyscout.uk/images/projects/logo-phpbotscout.png){ width="84" align=left style="border-radius:10px;margin-right:1rem" }

    He answers questions about the projects over on the Discord, citing the docs
    where they already cover it, and offering to raise an issue where they don't.
    Bring a bug, an idea, or a questionable engineering decision.

    [Join the Discord](https://discord.gg/mQzGbmGyzZ){ .md-button .md-button--primary }