Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ by CI.
- A `.hashdb` file is **immutable once published**; updates ship as new versioned files.
- Files are **untrusted** (downloaded): the header + section bounds are validated on `open`,
and every read bounds-checks its own extent. `verify()` (checksum + full scan) is opt-in.
Frames above `MAX_FRAME_SIZE` are rejected on open, which bounds what one lookup allocates.
- Published crates (`ltk_hashdb`, `ltk_mimir_cache`) keep public enums and structs with
public fields `#[non_exhaustive]` and enable the `missing_docs` and
`missing_debug_implementations` lints (CI fails on warnings). Don't expose dependency
types like `zeekstd::Error` in their APIs.
- **A lookup miss must never touch the arena** - it's decided by binary search over the keys.
There is a `decompressions` counter and a unit test asserting misses don't bump it; keep
that invariant when changing the reader.
Expand Down
18 changes: 0 additions & 18 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ members = ["crates/*"]

[workspace.package]
edition = "2021"
# std `File::try_lock` (the cache's update lock) was stabilized in 1.89.
rust-version = "1.89"
license = "Apache-2.0"
repository = "https://github.com/LeagueToolkit/mimir"
authors = ["Crauzer <0xcrauzer@proton.me>", "League Toolkit contributors"]
Expand Down Expand Up @@ -31,7 +33,7 @@ serde_json = "1"
directories = "5"
sha2 = "0.10"
time = { version = "0.3", features = ["formatting", "parsing"] }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls"] }
ureq = "2"

# CLI
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ let remote = UreqFetch::new(ReleaseSource::github("LeagueToolkit/mimir"));
match store.update(&remote, UpdateOptions::default())? {
UpdateOutcome::Completed(report) => println!("installed {:?}", report.installed),
UpdateOutcome::Locked => println!("another process is already updating"),
_ => {}
}
```

Expand Down Expand Up @@ -271,7 +272,7 @@ frame cache. `Send + Sync`.
| `manifest` · `path_for` | what is installed, and where |
| `check` · `check_async` | what an update *would* do - no download, no lock |
| `update` · `update_async` | compare → download → verify → install → GC |
| `commit` · `gc` | publish versions, sweep superseded ones |
| `commit` · `gc` | publish versions, delete superseded ones (both take the update lock) |
| `try_lock_update` · `lock_update_timeout` · `lock_holder` | take the update lock, wait for it, or ask who has it |

**`Table`** - which logical table, and how it hashes.
Expand Down
6 changes: 5 additions & 1 deletion crates/ltk_hashdb/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
[package]
name = "ltk_hashdb"
description = "The .hashdb binary format: an mmap-backed read-only integer-keyed string table (reader + streaming writer)"
description = "The .hashdb binary format: a read-only table mapping integer hashes to strings, with a reader and a writer"
readme = "README.md"
keywords = ["league-of-legends", "hash", "lookup-table", "mmap", "zstd"]
categories = ["data-structures", "encoding", "game-development"]
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
Expand Down
59 changes: 59 additions & 0 deletions crates/ltk_hashdb/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# ltk_hashdb

The `.hashdb` binary format: a read-only table that maps integer hashes to strings. League
Toolkit uses it to turn WAD path hashes, bin hashes, and RST keys back into names. League
tables use the `.lhdb` extension.

A file has a fixed 80-byte header, a sorted array of keys, per-entry offsets and lengths,
and a string arena that is either raw or compressed with the zstd seekable format. Strings
are stored in path order, so paths in the same directory compress together.

- A lookup miss is decided by a binary search over the keys and never reads the arena.
- A hit on a compressed table decompresses only the frame that holds the string, and
caches it for later lookups.
- Files are treated as untrusted: opening validates the header and section bounds, one
lookup allocates at most a few `MAX_FRAME_SIZE` (1 MiB) frames, and `HashDb::verify`
runs a full checksum pass.

The byte-level specification is
[`docs/FORMAT.md`](https://github.com/LeagueToolkit/mimir/blob/main/docs/FORMAT.md).

## Example

```rust
use std::io::Cursor;

use ltk_hashdb::{Casing, Compression, HashDb, HashDbWriter, HashKind, KeyConfig, KeyWidth};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = KeyConfig::new(KeyWidth::U64, HashKind::Xxh64, Casing::AsciiInsensitive);
let path = "assets/characters/aatrox/aatrox.bin";

let mut writer = HashDbWriter::with_key_config(config, Compression::default());
writer.insert(config.hash(path), path);
let mut file = Cursor::new(Vec::new());
writer.build(&mut file)?;

let db = HashDb::open_bytes(file.into_inner())?;
let hash = db.hash_path("ASSETS/Characters/Aatrox/Aatrox.bin");
assert_eq!(db.get(hash).as_deref(), Some(path));
Ok(())
}
```

## Opening files

`HashDb::open` memory-maps the file. The mapping is only valid while the file does not
change, so never rewrite or truncate a `.hashdb` in place while it is open: write a new
file and rename it over the old one. `HashDb::open_bytes` takes an in-memory image
instead.

## Layering

`LayeredHashDb` puts a writable in-memory overlay in front of one or more tables that
share a `KeyConfig`, for example to add mod paths at runtime or to search the `game` and
`lcu` tables with one lookup.

## License

Apache-2.0.
108 changes: 92 additions & 16 deletions crates/ltk_hashdb/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,75 +3,149 @@

use thiserror::Error;

use crate::KeyConfig;
use crate::{HashKind, KeyConfig, KeyWidth};

/// Errors from opening a `.hashdb` file ([`HashDb::open`] / [`HashDb::open_bytes`]):
/// I/O, or the untrusted header/section-bounds validation rejecting the file.
///
/// [`HashDb::open`]: crate::HashDb::open
/// [`HashDb::open_bytes`]: crate::HashDb::open_bytes
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum OpenError {
/// Reading or mapping the file failed.
#[error("io error")]
Io(#[from] std::io::Error),

/// The file does not start with [`MAGIC`](crate::MAGIC).
#[error("bad magic: not a hashdb file")]
BadMagic,

/// The header names a format version this build cannot read.
#[error("unsupported format version {0}")]
UnsupportedVersion(u16),

/// A header field is out of range or inconsistent.
#[error("malformed header: {0}")]
MalformedHeader(&'static str),

/// A section or the seek table is inconsistent with the header, including a
/// frame larger than [`MAX_FRAME_SIZE`](crate::MAX_FRAME_SIZE).
#[error("malformed file: {0}")]
Malformed(&'static str),

#[error("zstd seekable format error")]
Zeekstd(#[from] zeekstd::Error),
/// The compressed arena's seek table could not be parsed.
#[error(transparent)]
Compression(#[from] CompressionError),
}

/// Errors from the opt-in full integrity check ([`HashDb::verify`]).
/// Errors from the opt-in integrity checks ([`HashDb::verify`],
/// [`HashDb::verify_index`]) and from [`HashDb::try_get`].
///
/// [`HashDb::verify`]: crate::HashDb::verify
/// [`HashDb::verify_index`]: crate::HashDb::verify_index
/// [`HashDb::try_get`]: crate::HashDb::try_get
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum VerifyError {
/// The stored checksum does not match the file's sections.
#[error("checksum mismatch")]
ChecksumMismatch,

/// The sections describe an invalid table.
#[error("malformed file: {0}")]
Malformed(&'static str),

/// Decompressing a frame failed.
#[error("io error")]
Io(#[from] std::io::Error),

#[error("zstd seekable format error")]
Zeekstd(#[from] zeekstd::Error),
/// The compressed arena is inconsistent with its seek table.
#[error(transparent)]
Compression(#[from] CompressionError),
}

/// Errors from building a table ([`HashDbWriter::build`]): invalid input
/// entries, a bad compression config, or I/O while writing.
/// entries, a bad configuration, or I/O while writing.
///
/// [`HashDbWriter::build`]: crate::HashDbWriter::build
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum BuildError {
/// Writing the output failed.
#[error("io error")]
Io(#[from] std::io::Error),

/// One key was inserted with two different paths.
#[error("duplicate key {key:#x} with conflicting paths")]
DuplicateKey { key: u64 },
DuplicateKey {
/// The conflicting key.
key: u64,
},

/// A key does not fit in a [`KeyWidth::U32`] table.
#[error("key {key:#x} does not fit in a u32 table")]
KeyOutOfRange { key: u64 },
KeyOutOfRange {
/// The out-of-range key.
key: u64,
},

/// A path is longer than the format's `u16` length field allows.
#[error("path for key {key:#x} is {len} bytes; lengths are u16 (max 65535)")]
PathTooLong { key: u64, len: usize },
PathTooLong {
/// The key the path was inserted under.
key: u64,

/// The path's length in bytes.
len: usize,
},

/// [`Compression::Zeekstd`](crate::Compression::Zeekstd) `frame_size` is
/// outside `1..=`[`MAX_FRAME_SIZE`](crate::MAX_FRAME_SIZE).
#[error(
"zeekstd frame_size {frame_size} is outside 1..={}",
crate::MAX_FRAME_SIZE
)]
InvalidFrameSize {
/// The rejected frame size.
frame_size: u32,
},

/// A 64-bit hash algorithm was recorded for a [`KeyWidth::U32`] table, so
/// [`HashDb::hash_path`](crate::HashDb::hash_path) could never produce one
/// of its keys.
#[error("hash kind {hash_kind} produces 64-bit keys but the table is {key_width}")]
HashKindTooWide {
/// The recorded algorithm.
hash_kind: HashKind,

/// The table's key width.
key_width: KeyWidth,
},

/// Compressing the arena failed.
#[error(transparent)]
Compression(#[from] CompressionError),
}

#[error("zeekstd frame_size must be nonzero")]
ZeroFrameSize,
/// A failure inside the zstd seekable-format layer.
///
/// Opaque so the compression library can change without a breaking release.
/// The underlying error is available through [`std::error::Error::source`].
#[derive(Debug, Error)]
#[error("zstd seekable format error")]
pub struct CompressionError(#[source] zeekstd::Error);

/// Converts zeekstd results without a public `From<zeekstd::Error>` impl, which
/// would put zeekstd in this crate's API.
pub(crate) trait ZeekstdResultExt<T> {
fn zeek(self) -> Result<T, CompressionError>;
}

#[error("zstd seekable format error")]
Zeekstd(#[from] zeekstd::Error),
impl<T> ZeekstdResultExt<T> for Result<T, zeekstd::Error> {
fn zeek(self) -> Result<T, CompressionError> {
self.map_err(CompressionError)
}
}

/// A base rejected by [`LayeredHashDb`] because it does not hash its keys the way
Expand All @@ -80,13 +154,15 @@ pub enum BuildError {
/// [`LayeredHashDb`]: crate::LayeredHashDb
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error(
"base {index} is keyed {found}, but the layer is keyed {expected}; a base that hashes differently can never be hit by a caller's precomputed probe"
"base {index} is keyed {found}, but the layer is keyed {expected}; a base that hashes \
differently can never be hit by a caller's precomputed probe"
)]
#[non_exhaustive]
pub struct KeyConfigMismatch {
/// Position of the rejected base, counting the ones already layered.
pub index: usize,

/// What the layer hashes under - its first base's configuration.
/// What the layer hashes under: its first base's configuration.
pub expected: KeyConfig,

/// What the rejected base hashes under.
Expand Down
Loading
Loading