# assetpack
`assetpack` provides Rust crates for content-addressed asset packing. It focuses on splitting files into stable chunks, deduplicating payloads by SHA3-256, writing SQLite-backed/Sealed packs with content-addressed objects, and recording recipes that can rebuild the original bytes.
## Crates
- `assetpack-core`: core hashing, chunking, codec, recipe, `FileReader`, pipeline, and SQLite/Sealed pack primitives.
- `assetpack-transform-precomp2`: optional file transforms built on `precomp2`, with zstd and lzma wrapping variants.
- Core features `serde`, `sqlite-pack`, `rusqlite-store`, and `file-transform` are enabled by default. `sqlx-store`, `sealed`, and `sealed-encryption` are opt-in.
## Features
- SHA3-256 object identity and recipe integrity checks.
- FastCDC content-defined chunking with stable default bounds.
- Per-chunk raw, zstd, and Brotli storage with automatic fallback when compression is not worthwhile.
- SQLite object storage for chunks and recipes, plus optional namespaced stores for embedding.
- File transform selection with decode verification before accepting transformed output.
- Merkle index rebuilds, integrity checks, membership proofs, and batched proof lookup.
- Optional `precomp2`, `precomp2-zstd`, and `precomp2-lzma` transforms.
## Usage
### Choosing a store
The synchronous backends implement `ObjectSource`; SQLx implements `AsyncObjectSource`:
- SealedPack: an immutable single-file snapshot for distribution or archival. It is built once from a complete object set, can be encrypted, and is never updated in place — changing content means building a new pack.
- `SqlitePack`: owns a rusqlite connection and standalone database-file lifecycle.
- `RusqliteStore<'_>`: borrows an existing rusqlite connection or transaction and supports a table prefix.
- `SqlxStore`: owns an existing SQLx `SqlitePool`, supports a table prefix, and accepts caller-owned SQLx transactions.
`sqlite-pack` and `rusqlite-store` do not enable SQLx, Tokio, or futures. Async applications choose their own scheduling boundary when using a synchronous backend.
### Storing files
`Pipeline` splits an input into chunks, compresses each one, and computes everything needed to rebuild the file. Persist the plan and keep the returned recipe hash:
```rust
use assetpack_core::{Codec, FileHint, Hash32, ObjectKind, ObjectRecord, Pipeline, PipelineConfig, SqlitePack, build_recipe};
fn store_file(pack: &mut SqlitePack, data: Vec<u8>) -> assetpack_core::Result<Hash32> {
let original_hash = Hash32::sha3_256(&data);
let hint = FileHint { size: data.len() as u64, extension: None, head: None };
let plan = Pipeline::new(PipelineConfig::default()).run(data, &hint, original_hash, None)?;
.chunks
.into_iter()
.map(|chunk| ObjectRecord {
hash: chunk.hash,
kind: ObjectKind::Chunk,
decoded_len: u64::from(chunk.raw_len),
codec: chunk.codec,
stored_bytes: chunk.payload.expect("payload is kept unless discard_payload is set"),
})
.collect();
store.put_recipe(recipe_hash, &recipe, Codec::Raw)
})?;
Ok(recipe_hash)
}
```
To also try `precomp2`-family transforms during selection, build a `TransformSelector` from `assetpack_transform_precomp2::default_specs()` and pass it as the last argument of `run`.
### Building a SealedPack
```rust
use assetpack_core::{DEFAULT_FORMAT_TAG, SealedPackBuilder, SqlitePack};
fn export(pack: &SqlitePack, root_recipe: assetpack_core::Hash32) -> assetpack_core::Result<Vec<u8>> {
let mut objects = Vec::new();
loop {
let page = pack.object_records(objects.len() as u64, 256)?;
if page.is_empty() { break; }
objects.extend(page);
}
SealedPackBuilder::build_plain(DEFAULT_FORMAT_TAG, root_recipe, objects)
}
```
### Restoring a file
Construct a `TransformDecoderRegistry` with every transform version you have ever written. Use `FileReader` with Sealed/rusqlite sources and `AsyncFileReader` with `SqlxStore`:
```rust
use assetpack_core::{FileReadLimits, FileReader, Hash32, ObjectSource, TransformDecoderRegistry};
fn restore<S: ObjectSource + ?Sized>(source: &S, root: Hash32) -> assetpack_core::Result<Vec<u8>> {
let decoders = TransformDecoderRegistry::default();
FileReader::new(source, &decoders, FileReadLimits::default()).read_file(root)
}
```
Decoders are selected by the exact `(transform_id, transform_version)` recorded in the recipe; unknown pairs fail with `UnsupportedTransform` instead of falling back silently. For precomp2 support, register `assetpack_transform_precomp2::default_decoders(&config)`. Restoration returns the complete file; seek or range decoding of transformed files is not supported.
## What a successful call proves
- `ObjectSource::read_object`: the returned bytes match the requested object hash.
- `AsyncObjectSource::read_object`: the same contract, with explicit asynchronous source scheduling.
- `SealedPackReader::verify_all_objects()`: every object in the container passes that same check.
- `FileReader::read_file` / `restore_file`: the restored bytes equal the original file, in size and SHA3-256.
## Compatibility
These crates are pre-1.0 and the public API may still change. The SealedPack v1 on-disk format is pinned by a byte-exact golden fixture; any format change requires a new revision. Parsers and the shared read path are fuzzed continuously in CI: curated seeds live in `fuzz/corpus`, dynamic corpus stays in CI caches, and every crash becomes a deterministic regression test.
## License
Licensed under the AGPL-3.0-or-later license.