ODIAN Launcher: hub, installer, local stack and remembered sign-in (M2)

- Tauri 2 + Svelte 5 hub in ODIAN dark: patch notes and roadmap, install and
  update progress, local stack status, settings with DHP and MCP addresses.
- crates/odian-packs: Rust installer for pack format v1 (signed manifests,
  resumable verified downloads from a DHP or an ODIAN drive), byte-compatible
  with ODIAN_MCP_Server's Python odian_packs.
- Local stack on 127.0.0.1: Postgres -> Engine API -> tile server -> Odian.exe,
  one Job Object, clean shutdown; database snapshot restore on a fresh PC only.
- Keycloak sign-in through the DHP's MCP, remembered until Sign out with a
  launcher session that renews the Engine token silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kelorastus
2026-09-15 15:23:17 +02:00
commit b7f3061be3
102 changed files with 47611 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "odian-packs"
version = "0.1.0"
description = "ODIAN pack format v1: verify signed manifests and install a version from a site or an ODIAN drive"
edition = "2021"
rust-version = "1.81"
publish = false
[lib]
name = "odian_packs"
[dependencies]
# Same HTTP/TLS stack as src-tauri: reqwest 0.12, rustls (ring), no OpenSSL.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
tokio = { version = "1", features = ["rt", "fs", "sync", "time", "macros"] }
tokio-util = "0.7"
futures-util = "0.3"
bytes = "1"
sha2 = "0.10"
ed25519-dalek = "2.2"
base64 = "0.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tar = { version = "0.4", default-features = false }
rusqlite = { version = "0.40", features = ["bundled"] }
thiserror = "2"
[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
tempfile = "3"
@@ -0,0 +1 @@
ed25519:DS7z/nhxZE9ffn0PKVtw5fMUBRLdQ424OE236A0tbts=
+116
View File
@@ -0,0 +1,116 @@
//! Canonical JSON: the one serialisation that gets hashed or signed.
//!
//! Byte-for-byte equal to Python's
//! `json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")`.
//!
//! Object keys are sorted HERE, by code point (UTF-8 byte order is the same order).
//! Nothing relies on `serde_json::Map` iteration order, which changes when any crate in
//! the build enables serde_json's `preserve_order` feature.
//!
//! Numbers must be integers in the i64/u64 range. Floats (`1.0`, `1e3`, `-0`) and integers
//! outside that range are refused: Python would serialise them, but no pack document holds
//! one, and refusing them keeps float formatting out of the signed bytes.
use serde_json::Value;
use crate::error::{Error, Result};
/// Canonical bytes of `value`, or [`Error::Manifest`] if it holds a non-integer number.
pub fn canonical_bytes(value: &Value) -> Result<Vec<u8>> {
let mut out = Vec::with_capacity(1024);
write_value(value, &mut out)?;
Ok(out)
}
fn write_value(value: &Value, out: &mut Vec<u8>) -> Result<()> {
match value {
Value::Null => out.extend_from_slice(b"null"),
Value::Bool(true) => out.extend_from_slice(b"true"),
Value::Bool(false) => out.extend_from_slice(b"false"),
Value::Number(n) => {
if let Some(u) = n.as_u64() {
out.extend_from_slice(u.to_string().as_bytes());
} else if let Some(i) = n.as_i64() {
out.extend_from_slice(i.to_string().as_bytes());
} else {
return Err(Error::manifest(format!(
"number {n} is not an integer in the 64-bit range; pack documents hold integers only"
)));
}
}
Value::String(s) => write_str(s, out),
Value::Array(items) => {
out.push(b'[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(b',');
}
write_value(item, out)?;
}
out.push(b']');
}
Value::Object(map) => {
let mut entries: Vec<(&String, &Value)> = map.iter().collect();
entries.sort_unstable_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
out.push(b'{');
for (i, (key, item)) in entries.into_iter().enumerate() {
if i > 0 {
out.push(b',');
}
write_str(key, out);
out.push(b':');
write_value(item, out)?;
}
out.push(b'}');
}
}
Ok(())
}
/// Python's `encode_basestring` (the `ensure_ascii=False` escaper).
fn write_str(s: &str, out: &mut Vec<u8>) {
const HEX: &[u8; 16] = b"0123456789abcdef";
out.push(b'"');
// Escapes only ever replace ASCII bytes, and bytes of multi-byte UTF-8 sequences are
// all >= 0x80, so a byte loop copies every other character through untouched.
for &b in s.as_bytes() {
match b {
b'"' => out.extend_from_slice(b"\\\""),
b'\\' => out.extend_from_slice(b"\\\\"),
b'\n' => out.extend_from_slice(b"\\n"),
b'\r' => out.extend_from_slice(b"\\r"),
b'\t' => out.extend_from_slice(b"\\t"),
0x08 => out.extend_from_slice(b"\\b"),
0x0c => out.extend_from_slice(b"\\f"),
0x00..=0x1f => {
out.extend_from_slice(b"\\u00");
out.push(HEX[(b >> 4) as usize]);
out.push(HEX[(b & 0x0f) as usize]);
}
_ => out.push(b),
}
}
out.push(b'"');
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn sorts_and_escapes_like_python() {
let v = json!({"b": 1, "a": [1, {"d": 2, "c": 3}]});
assert_eq!(canonical_bytes(&v).unwrap(), br#"{"a":[1,{"c":3,"d":2}],"b":1}"#);
let v = json!({"s": "q\"b\\n\n\u{1}\u{7f}é/"});
assert_eq!(canonical_bytes(&v).unwrap(), "{\"s\":\"q\\\"b\\\\n\\n\\u0001\u{7f}é/\"}".as_bytes());
}
#[test]
fn refuses_floats() {
for text in ["1.0", "1e3", "-0", "[0.5]", "18446744073709551616"] {
let v: Value = serde_json::from_str(text).unwrap();
assert!(canonical_bytes(&v).is_err(), "{text}");
}
}
}
+198
View File
@@ -0,0 +1,198 @@
//! Copy a body into a file while hashing it, off the async threads, with cancellation.
use std::io::{BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use bytes::Bytes;
use futures_util::StreamExt;
use sha2::{Digest, Sha256};
use tokio_util::sync::CancellationToken;
use crate::error::{Error, Result};
use crate::hashing::{hex, BUF};
use crate::progress::Reporter;
use crate::source::{blocking, Body};
pub(crate) struct Copied {
/// Bytes written by this copy.
pub written: u64,
pub hasher: Sha256,
/// The body held more than `limit` bytes; the excess was not written.
pub overflow: bool,
}
/// Copy `body` into `file` (opened and positioned by the caller), feeding `hasher`.
/// At most `limit` bytes are written. On a cut-short body or cancellation, whatever was
/// written stays on disk (a `.part` file resumes from it).
pub(crate) async fn copy_body(
body: Body,
file: std::fs::File,
file_path: PathBuf,
hasher: Sha256,
limit: u64,
reporter: Arc<Reporter>,
cancel: CancellationToken,
) -> Result<Copied> {
match body {
Body::File { mut reader, path } => {
blocking(move || {
let mut out = BufWriter::with_capacity(BUF, file);
let mut hasher = hasher;
let mut buf = vec![0u8; BUF];
let (mut written, mut overflow) = (0u64, false);
let result = (|| {
loop {
if cancel.is_cancelled() {
return Err(Error::Cancelled);
}
let n = reader.read(&mut buf).map_err(Error::io(&path))?;
if n == 0 {
return Ok(());
}
let room = (limit - written).min(n as u64) as usize;
overflow = room < n;
out.write_all(&buf[..room]).map_err(Error::io(&file_path))?;
hasher.update(&buf[..room]);
written += room as u64;
reporter.add_transferred(room as u64);
if overflow {
return Ok(());
}
}
})();
let flushed = out.flush().map_err(Error::io(&file_path));
result?;
flushed?;
Ok(Copied { written, hasher, overflow })
})
.await
}
Body::Http { response, url } => {
let (tx, mut rx) = tokio::sync::mpsc::channel::<Bytes>(16);
let writer = tokio::task::spawn_blocking(move || -> Result<(u64, Sha256)> {
let mut out = BufWriter::with_capacity(BUF, file);
let mut hasher = hasher;
let mut written = 0u64;
while let Some(chunk) = rx.blocking_recv() {
out.write_all(&chunk).map_err(Error::io(&file_path))?;
hasher.update(&chunk);
written += chunk.len() as u64;
}
out.flush().map_err(Error::io(&file_path))?;
Ok((written, hasher))
});
let mut stream = response.bytes_stream();
let (mut sent, mut overflow) = (0u64, false);
let mut outcome: Result<()> = Ok(());
loop {
let next = tokio::select! {
_ = cancel.cancelled() => { outcome = Err(Error::Cancelled); break; }
next = stream.next() => next,
};
match next {
Some(Ok(mut chunk)) => {
let room = limit - sent;
if chunk.len() as u64 > room {
chunk.truncate(room as usize);
overflow = true;
}
let len = chunk.len() as u64;
if len > 0 {
if tx.send(chunk).await.is_err() {
break; // the writer failed; its error is reported below
}
sent += len;
reporter.add_transferred(len);
}
if overflow {
break;
}
}
Some(Err(e)) => {
outcome = Err(Error::Http { url: url.clone(), status: None, message: format!("body cut short: {e}") });
break;
}
None => break,
}
}
drop(tx);
let (written, hasher) = match writer.await {
Ok(result) => result?,
Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()),
Err(_) => return Err(Error::Cancelled),
};
outcome?;
Ok(Copied { written, hasher, overflow })
}
}
}
/// Blocking: copy a reader into a new file, hashing. Returns (bytes, sha256 hex).
pub(crate) fn copy_hashing<R: Read>(
reader: &mut R,
target: &Path,
buf: &mut [u8],
reporter: &Reporter,
cancel: &CancellationToken,
read_error: &dyn Fn(std::io::Error) -> Error,
) -> Result<(u64, String)> {
let file = std::fs::File::create(target).map_err(Error::io(target))?;
let mut out = BufWriter::with_capacity(256 * 1024, file);
let mut hasher = Sha256::new();
let mut written = 0u64;
loop {
if cancel.is_cancelled() {
return Err(Error::Cancelled);
}
let n = reader.read(buf).map_err(read_error)?;
if n == 0 {
break;
}
out.write_all(&buf[..n]).map_err(Error::io(target))?;
hasher.update(&buf[..n]);
written += n as u64;
reporter.add_transferred(n as u64);
}
out.flush().map_err(Error::io(target))?;
Ok((written, hex(&hasher.finalize())))
}
/// 32 hex characters, unique within this process and very likely across processes
/// (the role `uuid.uuid4().hex` plays in fetch.py).
pub(crate) fn unique_hex() -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
let mut hasher = Sha256::new();
hasher.update(nanos.to_le_bytes());
hasher.update(std::process::id().to_le_bytes());
hasher.update(n.to_le_bytes());
hex(&hasher.finalize()[..16])
}
/// Write `data` to `path` through a temp file and a rename (fetch.py's `_atomic_write`).
pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(Error::io(parent))?;
}
let mut temp = path.as_os_str().to_owned();
temp.push(format!(".{}.tmp", unique_hex()));
let temp = PathBuf::from(temp);
let result = std::fs::write(&temp, data).map_err(Error::io(&temp)).and_then(|()| {
std::fs::rename(&temp, path).map_err(Error::io(path))
});
if result.is_err() {
let _ = std::fs::remove_file(&temp);
}
result
}
/// `path` + `suffix` (e.g. `Odian.exe` -> `Odian.exe.part`).
pub(crate) fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut s = path.as_os_str().to_owned();
s.push(suffix);
PathBuf::from(s)
}
+92
View File
@@ -0,0 +1,92 @@
//! One error type for the whole crate, with the variants the launcher UI branches on.
use std::fmt;
use std::path::PathBuf;
pub type Result<T, E = Error> = std::result::Result<T, E>;
/// Which check failed when [`Error::Integrity`] is returned.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum IntegrityKind {
/// The manifest is unsigned, altered after signing, or signed by another key.
Signature,
/// The site's signed manifest names another version than the one asked for.
Version,
/// A whole file (app or database dump) does not match its size and sha256.
FileHash,
/// A source-file chunk does not match its sha256.
ChunkHash,
/// A tile index does not match the manifest's `index_sha256`.
IndexHash,
/// A tile block's tiles do not add up to the signed block digest.
BlockDigest,
/// A tile block holds an entry that is not a tile of that block.
TarEntry,
}
impl fmt::Display for IntegrityKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
IntegrityKind::Signature => "signature",
IntegrityKind::Version => "version",
IntegrityKind::FileHash => "file hash",
IntegrityKind::ChunkHash => "chunk hash",
IntegrityKind::IndexHash => "tile index hash",
IntegrityKind::BlockDigest => "tile block digest",
IntegrityKind::TarEntry => "tile block entry",
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The site refused the token (HTTP 401 or 403).
#[error("the DHP refused the sign-in (HTTP {status}) for {url}")]
Unauthorized { url: String, status: u16 },
/// A version, file or tile set does not exist on the site (404) or the drive.
#[error("not found: {what}")]
NotFound { what: String },
/// The site kept answering 503 (stream limit, or its Engine is briefly unreachable).
#[error("the DHP is busy: {url} answered 503 {attempts} times")]
SiteBusy { url: String, attempts: u32, retry_after_secs: Option<f64> },
/// Bytes do not match what the signed manifest (or the signed tile index) pins.
#[error("integrity check failed ({kind}): {what}: {detail}")]
Integrity { kind: IntegrityKind, what: String, detail: String },
/// Any other HTTP failure: connection error, unexpected status, body cut short.
#[error("HTTP error for {url}: {message}")]
Http { url: String, status: Option<u16>, message: String },
#[error("I/O error on {}: {source}", path.display())]
Io { path: PathBuf, #[source] source: std::io::Error },
/// The manifest (or a document it pins) is malformed.
#[error("manifest error: {0}")]
Manifest(String),
#[error("cancelled")]
Cancelled,
}
impl Error {
pub(crate) fn io(path: impl Into<PathBuf>) -> impl FnOnce(std::io::Error) -> Error {
let path = path.into();
move |source| Error::Io { path, source }
}
pub(crate) fn manifest(message: impl Into<String>) -> Error {
Error::Manifest(message.into())
}
pub(crate) fn integrity(kind: IntegrityKind, what: impl Into<String>, detail: impl Into<String>) -> Error {
Error::Integrity { kind, what: what.into(), detail: detail.into() }
}
pub fn is_cancelled(&self) -> bool {
matches!(self, Error::Cancelled)
}
}
+85
View File
@@ -0,0 +1,85 @@
//! sha256 helpers (the Python module `odian_packs.hashing`).
use std::io::Read;
use std::path::Path;
use sha2::{Digest, Sha256};
use tokio_util::sync::CancellationToken;
use crate::error::{Error, Result};
pub(crate) const BUF: usize = 1024 * 1024;
/// Lowercase hex of any byte slice.
pub fn hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for &b in bytes {
s.push(HEX[(b >> 4) as usize] as char);
s.push(HEX[(b & 0x0f) as usize] as char);
}
s
}
/// sha256 hex of a byte slice.
pub fn sha256_hex(data: &[u8]) -> String {
hex(&Sha256::digest(data))
}
/// Inclusive byte range of chunk `index` of a `size`-byte file: the form an HTTP Range takes.
pub fn chunk_range(index: u64, size: u64, chunk_bytes: u64) -> Option<(u64, u64)> {
let start = index.checked_mul(chunk_bytes)?;
if start >= size {
return None;
}
Some((start, size.min(start.saturating_add(chunk_bytes)) - 1))
}
/// Feed the first `limit` bytes (or all, if `None`) of a file into `hasher`. Blocking.
/// Returns how many bytes were read.
pub(crate) fn hash_file_prefix(
path: &Path,
hasher: &mut Sha256,
limit: Option<u64>,
cancel: &CancellationToken,
) -> Result<u64> {
let file = std::fs::File::open(path).map_err(Error::io(path))?;
let mut reader: Box<dyn Read> = match limit {
Some(n) => Box::new(file.take(n)),
None => Box::new(file),
};
let mut buf = vec![0u8; BUF];
let mut total = 0u64;
loop {
if cancel.is_cancelled() {
return Err(Error::Cancelled);
}
let n = reader.read(&mut buf).map_err(Error::io(path))?;
if n == 0 {
return Ok(total);
}
hasher.update(&buf[..n]);
total += n as u64;
}
}
/// (size, sha256 hex) of a whole file. Blocking.
pub(crate) fn sha256_file(path: &Path, cancel: &CancellationToken) -> Result<(u64, String)> {
let mut hasher = Sha256::new();
let size = hash_file_prefix(path, &mut hasher, None, cancel)?;
Ok((size, hex(&hasher.finalize())))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ranges_match_python() {
assert_eq!(chunk_range(2, 25, 10), Some((20, 24)));
assert_eq!(chunk_range(0, 25, 10), Some((0, 9)));
assert_eq!(chunk_range(3, 25, 10), None);
assert_eq!(chunk_range(0, 0, 10), None);
assert_eq!(sha256_hex(b""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
}
}
+620
View File
@@ -0,0 +1,620 @@
//! The installer (fetch.py's `fetch`): verified manifest in, verified install out.
use std::collections::BTreeSet;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use futures_util::StreamExt;
use serde::Serialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
use tokio_util::sync::CancellationToken;
use crate::copy::{atomic_write, copy_body, with_suffix};
use crate::error::{Error, IntegrityKind, Result};
use crate::hashing::{chunk_range, hash_file_prefix, hex, sha256_file};
use crate::manifest::{safe_relpath, AppFile, DbFile, SrcFile, SrcSection, TilesSection, VerifiedManifest};
use crate::progress::{Progress, Reporter, Stage};
use crate::source::{blocking, Backend, Pack, Source};
use crate::tileblocks::{copy_drive_block, fetch_site_block, DriveBlock};
use crate::tiles::{verify_index, IndexBlock};
/// How to install.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallOptions {
/// Parallel downloads (db uses at most 4, source chunks at most 3, like fetch.py).
pub workers: usize,
/// Stages to run; `App`, `Db`, `Src` and `Tiles` are meaningful, others are ignored.
pub only: Vec<Stage>,
}
impl Default for InstallOptions {
fn default() -> Self {
InstallOptions { workers: 6, only: vec![Stage::App, Stage::Db, Stage::Src, Stage::Tiles] }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AppSummary {
pub files: usize,
pub downloaded: usize,
pub skipped: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DbSummary {
pub snapshot: String,
pub files: usize,
pub downloaded: usize,
pub skipped: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SrcFileSummary {
pub path: String,
/// Chunks fetched by this run.
pub chunks: usize,
pub skipped: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TileSetSummary {
pub set: String,
pub blocks: usize,
/// Blocks fetched by this run.
pub downloaded: usize,
pub files: u64,
pub bytes: u64,
}
/// What an install did (fetch.py's summary dict, plus the bytes that were transferred).
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Summary {
pub version: String,
pub app: Option<AppSummary>,
pub db: Option<DbSummary>,
pub src: Option<Vec<SrcFileSummary>>,
pub tiles: Option<Vec<TileSetSummary>>,
/// Bytes received from the site (or copied from the drive) by this run.
pub transferred_bytes: u64,
pub seconds: f64,
}
struct Ctx<'a> {
backend: &'a Backend,
reporter: Arc<Reporter>,
cancel: CancellationToken,
workers: usize,
}
fn join_rel(root: &Path, rel: &str) -> Result<PathBuf> {
let mut path = root.to_path_buf();
for part in safe_relpath(rel)?.split('/') {
path.push(part);
}
Ok(path)
}
/// Install `manifest` from `source` into `dest`. Resumable: re-running skips everything
/// already verified. `versions/<v>/manifest.json` is written last, only on success.
pub async fn install<F>(
source: &Source,
dest: &Path,
manifest: &VerifiedManifest,
opts: InstallOptions,
progress: F,
cancel: CancellationToken,
) -> Result<Summary>
where
F: Fn(Progress) + Send + Sync + 'static,
{
let started = Instant::now();
let backend = Backend::new(source)?;
let m = manifest.manifest();
let version = m.version.clone();
let wants = |stage: Stage| opts.only.contains(&stage);
let do_app = wants(Stage::App);
let do_db = wants(Stage::Db) && m.db.is_some();
let do_src = wants(Stage::Src) && m.src.as_ref().is_some_and(|s| s.enabled);
let do_tiles = wants(Stage::Tiles) && m.tiles.is_some();
let stage_count = 1 + [do_app, do_db, do_src, do_tiles].iter().filter(|b| **b).count() as u32;
let reporter = Arc::new(Reporter::new(Arc::new(progress), stage_count));
reporter.begin_stage(Stage::Manifest, 1, 0, 1);
reporter.begin_sub(format!("manifest {version}: signature OK"), "", 0);
reporter.items_done(1);
reporter.finish_stage();
let ctx = Ctx { backend: &backend, reporter: reporter.clone(), cancel: cancel.clone(), workers: opts.workers.max(1) };
let version_dir = dest.join("versions").join(&version);
let data_dir = dest.join("data");
let mut summary = Summary {
version: version.clone(),
app: None,
db: None,
src: None,
tiles: None,
transferred_bytes: 0,
seconds: 0.0,
};
let mut stage_index = 1;
let check_cancel = || if cancel.is_cancelled() { Err(Error::Cancelled) } else { Ok(()) };
if do_app {
check_cancel()?;
stage_index += 1;
summary.app = Some(install_app(&ctx, stage_index, &m.app.files, &version_dir).await?);
}
if let (true, Some(db)) = (do_db, &m.db) {
check_cancel()?;
stage_index += 1;
let (files, downloaded, skipped) = install_db(&ctx, stage_index, &db.files, &data_dir).await?;
summary.db = Some(DbSummary { snapshot: db.snapshot.clone(), files, downloaded, skipped });
}
if let (true, Some(src)) = (do_src, &m.src) {
check_cancel()?;
stage_index += 1;
summary.src = Some(install_src(&ctx, stage_index, src, &data_dir).await?);
}
if let (true, Some(tiles)) = (do_tiles, &m.tiles) {
check_cancel()?;
stage_index += 1;
summary.tiles = Some(install_tiles(&ctx, stage_index, &version, tiles, &data_dir).await?);
}
check_cancel()?;
// Written last: its presence means this version is fully installed.
let manifest_path = version_dir.join("manifest.json");
let raw = manifest.raw().to_vec();
blocking(move || atomic_write(&manifest_path, &raw)).await?;
summary.transferred_bytes = reporter.transferred();
summary.seconds = (started.elapsed().as_secs_f64() * 10.0).round() / 10.0;
Ok(summary)
}
/// Run `f` over `items` with at most `workers` in flight. On the first error nothing new
/// starts, running items are told to stop (their verified work is kept) and drained, and
/// that first error is returned (a real error wins over the `Cancelled` it caused).
async fn run_pool<I, T, Fut, F, G>(items: Vec<I>, workers: usize, cancel: &CancellationToken, f: F, mut on_ok: G) -> Result<()>
where
F: Fn(I, CancellationToken) -> Fut,
Fut: Future<Output = Result<T>>,
G: FnMut(T) -> Result<()>,
{
let stop = cancel.child_token();
let futures = items.into_iter().map(|item| {
let token = stop.clone();
let fut = f(item, token.clone());
async move {
if token.is_cancelled() {
return Err(Error::Cancelled);
}
fut.await
}
});
let mut stream = futures_util::stream::iter(futures).buffer_unordered(workers.max(1));
let mut first: Option<Error> = None;
while let Some(result) = stream.next().await {
if let Err(e) = result.and_then(&mut on_ok) {
stop.cancel();
first = match first {
None => Some(e),
Some(Error::Cancelled) if !e.is_cancelled() => Some(e),
keep => keep,
};
}
}
match first {
Some(e) => Err(e),
None if cancel.is_cancelled() => Err(Error::Cancelled),
None => Ok(()),
}
}
// ---- whole files (app, db) -------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Outcome {
Downloaded,
Skipped,
}
/// One whole file, resuming `<dest>.part` with a Range request; verified before the rename.
async fn download_file(ctx: &Ctx<'_>, pack: Pack<'_>, dest: &Path, size: u64, sha256: &str, cancel: &CancellationToken) -> Result<Outcome> {
let existing = {
let (dest, cancel) = (dest.to_path_buf(), cancel.clone());
blocking(move || match std::fs::metadata(&dest) {
Ok(md) if md.is_file() && md.len() == size => sha256_file(&dest, &cancel).map(|(_, sha)| Some(sha)),
_ => Ok(None),
})
.await?
};
if existing.as_deref() == Some(sha256) {
ctx.reporter.add_done_bytes(size);
return Ok(Outcome::Skipped);
}
if let Some(parent) = dest.parent() {
tokio::fs::create_dir_all(parent).await.map_err(Error::io(parent))?;
}
let part = with_suffix(dest, ".part");
let mut got = (0u64, String::new());
for _ in 0..2 {
let mut counted = 0u64;
let mut have = match tokio::fs::metadata(&part).await {
Ok(md) if md.is_file() => md.len(),
_ => 0,
};
if have > size {
tokio::fs::remove_file(&part).await.map_err(Error::io(&part))?;
have = 0;
}
let (got_size, got_sha) = if have < size {
let mut hasher = Sha256::new();
if have > 0 {
let (path, cancel) = (part.clone(), cancel.clone());
hasher = blocking(move || {
let mut hasher = Sha256::new();
hash_file_prefix(&path, &mut hasher, Some(have), &cancel)?;
Ok(hasher)
})
.await?;
}
let (body, starts_at_offset) = ctx.backend.open_pack(pack, have, cancel).await?;
if have > 0 && !starts_at_offset {
have = 0; // the server ignored the Range; start over
hasher = Sha256::new();
}
ctx.reporter.add_done_bytes(have);
counted += have;
let file = {
let path = part.clone();
blocking(move || {
let mut options = std::fs::OpenOptions::new();
if have > 0 { options.append(true) } else { options.write(true).create(true).truncate(true) };
options.open(&path).map_err(Error::io(&path))
})
.await?
};
let copied = copy_body(body, file, part.clone(), hasher, size - have, ctx.reporter.clone(), cancel.clone()).await?;
counted += copied.written;
(have + copied.written + u64::from(copied.overflow), hex(&copied.hasher.finalize()))
} else {
let (path, cancel) = (part.clone(), cancel.clone());
let (s, h) = blocking(move || {
if !path.is_file() {
std::fs::File::create(&path).map_err(Error::io(&path))?; // an empty file: nothing to fetch
}
sha256_file(&path, &cancel)
})
.await?;
ctx.reporter.add_done_bytes(s);
counted += s;
(s, h)
};
if got_size == size && got_sha == sha256 {
let (from, to) = (part.clone(), dest.to_path_buf());
blocking(move || std::fs::rename(&from, &to).map_err(Error::io(&to))).await?;
return Ok(Outcome::Downloaded);
}
// Corrupt or stale partial: one clean retry, then give up.
ctx.reporter.retract_bytes(counted);
let _ = tokio::fs::remove_file(&part).await;
got = (got_size, got_sha);
}
Err(Error::integrity(
IntegrityKind::FileHash,
ctx.backend.pack_label(pack),
format!("expected {size} bytes sha256 {sha256}, got {} bytes sha256 {}", got.0, got.1),
))
}
async fn install_app(ctx: &Ctx<'_>, stage_index: u32, files: &[AppFile], version_dir: &Path) -> Result<AppSummary> {
let total: u64 = files.iter().map(|f| f.size).sum();
ctx.reporter.begin_stage(Stage::App, stage_index, total, files.len() as u64);
ctx.reporter.begin_sub("app".into(), "files", files.len() as u64);
let (mut downloaded, mut skipped) = (0, 0);
run_pool(
files.iter().collect(),
ctx.workers,
&ctx.cancel,
|entry: &AppFile, stop| async move {
let target = join_rel(version_dir, &entry.path)?;
download_file(ctx, Pack::App(&entry.sha256), &target, entry.size, &entry.sha256, &stop).await
},
|outcome| {
if outcome == Outcome::Downloaded { downloaded += 1 } else { skipped += 1 }
ctx.reporter.items_done(1);
Ok(())
},
)
.await?;
ctx.reporter.finish_stage();
Ok(AppSummary { files: files.len(), downloaded, skipped })
}
async fn install_db(ctx: &Ctx<'_>, stage_index: u32, files: &[DbFile], data_dir: &Path) -> Result<(usize, usize, usize)> {
let total: u64 = files.iter().map(|f| f.size).sum();
ctx.reporter.begin_stage(Stage::Db, stage_index, total, files.len() as u64);
ctx.reporter.begin_sub("db".into(), "files", files.len() as u64);
let (mut downloaded, mut skipped) = (0, 0);
run_pool(
files.iter().collect(),
ctx.workers.min(4),
&ctx.cancel,
|entry: &DbFile, stop| async move {
let target = join_rel(data_dir, &entry.path)?;
download_file(ctx, Pack::Db(&entry.path), &target, entry.size, &entry.sha256, &stop).await
},
|outcome| {
if outcome == Outcome::Downloaded { downloaded += 1 } else { skipped += 1 }
ctx.reporter.items_done(1);
Ok(())
},
)
.await?;
ctx.reporter.finish_stage();
Ok((files.len(), downloaded, skipped))
}
// ---- source files -----------------------------------------------------------------
async fn install_src(ctx: &Ctx<'_>, stage_index: u32, src: &SrcSection, data_dir: &Path) -> Result<Vec<SrcFileSummary>> {
let total: u64 = src.files.iter().map(|f| f.size).sum();
let chunks: u64 = src.files.iter().map(|f| f.chunks.len() as u64).sum();
ctx.reporter.begin_stage(Stage::Src, stage_index, total, chunks);
let mut out = Vec::with_capacity(src.files.len());
for entry in &src.files {
if ctx.cancel.is_cancelled() {
return Err(Error::Cancelled);
}
let target = join_rel(&data_dir.join("src"), &entry.path)?;
ctx.reporter.begin_sub(format!("src {}", entry.path), "chunks", entry.chunks.len() as u64);
out.push(fetch_source_file(ctx, entry, src.chunk_bytes, &target).await?);
}
ctx.reporter.finish_stage();
Ok(out)
}
/// `{"chunks_sha256": "...", "verified": [0, 1]}`, exactly as Python's `json.dumps` writes it.
fn chunks_progress_json(chunks_sha256: &str, verified: &BTreeSet<u64>) -> String {
let list: Vec<String> = verified.iter().map(u64::to_string).collect();
format!("{{\"chunks_sha256\": \"{chunks_sha256}\", \"verified\": [{}]}}", list.join(", "))
}
/// One source file, chunk by chunk from byte ranges, written in place.
async fn fetch_source_file(ctx: &Ctx<'_>, entry: &SrcFile, chunk_bytes: u64, dest: &Path) -> Result<SrcFileSummary> {
let n = entry.chunks.len() as u64;
let marker = with_suffix(dest, ".ok");
let progress_path = with_suffix(dest, ".chunks.json");
let (done, verified) = {
let (dest, marker, progress_path) = (dest.to_path_buf(), marker.clone(), progress_path.clone());
let (size, digest) = (entry.size, entry.chunks_sha256.clone());
blocking(move || -> Result<(bool, BTreeSet<u64>)> {
let dest_ok = std::fs::metadata(&dest).is_ok_and(|md| md.is_file() && md.len() == size);
if dest_ok && marker.is_file() {
if let Ok(text) = std::fs::read_to_string(&marker) {
if text.trim() == digest {
return Ok((true, BTreeSet::new()));
}
}
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(Error::io(parent))?;
}
let mut verified = BTreeSet::new();
if dest_ok && progress_path.is_file() {
let saved = std::fs::read(&progress_path).ok().and_then(|raw| serde_json::from_slice::<Value>(&raw).ok());
if let Some(saved) = saved.filter(|s| s.get("chunks_sha256").and_then(Value::as_str) == Some(&digest)) {
let list = saved.get("verified").and_then(Value::as_array).cloned().unwrap_or_default();
verified.extend(list.iter().filter_map(Value::as_u64).filter(|i| *i < n));
}
}
if !dest_ok {
let file = std::fs::File::create(&dest).map_err(Error::io(&dest))?;
file.set_len(size).map_err(Error::io(&dest))?;
verified.clear();
}
Ok((false, verified))
})
.await?
};
if done {
ctx.reporter.add_done_bytes(entry.size);
ctx.reporter.items_done(n);
return Ok(SrcFileSummary { path: entry.path.clone(), chunks: 0, skipped: n as usize });
}
for index in &verified {
let (start, end) = chunk_range(*index, entry.size, chunk_bytes).expect("validated chunk index");
ctx.reporter.add_done_bytes(end - start + 1);
}
ctx.reporter.items_done(verified.len() as u64);
let pending: Vec<u64> = (0..n).filter(|i| !verified.contains(i)).collect();
let fetched = pending.len();
let mut verified = verified;
run_pool(
pending,
ctx.workers.min(3),
&ctx.cancel,
|index, stop| fetch_chunk(ctx, entry, chunk_bytes, dest, index, stop),
|index| {
verified.insert(index);
atomic_write(&progress_path, chunks_progress_json(&entry.chunks_sha256, &verified).as_bytes())?;
ctx.reporter.items_done(1);
Ok(())
},
)
.await?;
let (marker_path, progress_file, digest) = (marker, progress_path, entry.chunks_sha256.clone());
blocking(move || {
atomic_write(&marker_path, digest.as_bytes())?;
match std::fs::remove_file(&progress_file) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(Error::Io { path: progress_file, source: e }),
_ => Ok(()),
}
})
.await?;
Ok(SrcFileSummary { path: entry.path.clone(), chunks: fetched, skipped: n as usize - fetched })
}
async fn fetch_chunk(ctx: &Ctx<'_>, entry: &SrcFile, chunk_bytes: u64, dest: &Path, index: u64, cancel: CancellationToken) -> Result<u64> {
let (start, end) = chunk_range(index, entry.size, chunk_bytes).expect("validated chunk index");
let len = end - start + 1;
let expected = &entry.chunks[index as usize];
let mut got = String::new();
for _ in 0..2 {
let body = ctx.backend.open_src_range(&entry.path, start, end, &cancel).await?;
let file = {
let path = dest.to_path_buf();
blocking(move || {
use std::io::{Seek, SeekFrom};
let mut file = std::fs::OpenOptions::new().write(true).open(&path).map_err(Error::io(&path))?;
file.seek(SeekFrom::Start(start)).map_err(Error::io(&path))?;
Ok(file)
})
.await?
};
let copied = copy_body(body, file, dest.to_path_buf(), Sha256::new(), len, ctx.reporter.clone(), cancel.clone()).await?;
got = hex(&copied.hasher.finalize());
if copied.written == len && !copied.overflow && got == *expected {
return Ok(index);
}
ctx.reporter.retract_bytes(copied.written);
}
Err(Error::integrity(
IntegrityKind::ChunkHash,
format!("/src/{} chunk {index}", entry.path),
format!("sha256 mismatch after retry: expected {expected}, got {got}"),
))
}
// ---- tiles --------------------------------------------------------------------------
/// `{"index_sha256": "...", "blocks": ["z/bx/by:digest", ...]}`, as Python's `json.dumps` writes it.
fn blocks_done_json(index_sha256: &str, done: &BTreeSet<String>) -> String {
let list: Vec<String> = done.iter().map(|k| format!("\"{k}\"")).collect();
format!("{{\"index_sha256\": \"{index_sha256}\", \"blocks\": [{}]}}", list.join(", "))
}
async fn install_tiles(ctx: &Ctx<'_>, stage_index: u32, version: &str, tiles: &TilesSection, data_dir: &Path) -> Result<Vec<TileSetSummary>> {
let total: u64 = tiles.sets.iter().map(|s| s.bytes).sum();
let blocks: u64 = tiles.sets.iter().map(|s| s.blocks).sum();
ctx.reporter.begin_stage(Stage::Tiles, stage_index, total, blocks);
let journal = match ctx.backend.drive_root() {
Some(root) if !tiles.sets.is_empty() => {
let path = root.join("tiles").join("tiles.db");
Some(Arc::new(blocking(move || crate::source::Journal::open(&path)).await?))
}
_ => None,
};
let mut out = Vec::with_capacity(tiles.sets.len());
for set in &tiles.sets {
if ctx.cancel.is_cancelled() {
return Err(Error::Cancelled);
}
let (style, dataset) = (set.style.as_str(), set.dataset.as_str());
let what = format!("tile index {style}/{dataset}");
let raw = ctx.backend.tile_index(version, style, dataset, &ctx.cancel).await?;
let index = verify_index(&raw, &set.index_sha256, &what)?;
let set_dir = data_dir.join("tiles").join(style).join(dataset);
let done_path = set_dir.join(".blocks-done.json");
let mut done: BTreeSet<String> = BTreeSet::new();
if let Some(saved) = std::fs::read(&done_path).ok().and_then(|raw| serde_json::from_slice::<Value>(&raw).ok()) {
if saved.get("index_sha256").and_then(Value::as_str) == Some(set.index_sha256.as_str()) {
let list = saved.get("blocks").and_then(Value::as_array).cloned().unwrap_or_default();
done.extend(list.iter().filter_map(Value::as_str).map(str::to_string));
}
}
let pending: Vec<IndexBlock> = index.blocks.iter().filter(|b| !done.contains(&b.done_key())).cloned().collect();
ctx.reporter.begin_sub(format!("tiles {style}/{dataset}"), "blocks", index.blocks.len() as u64);
let skipped_bytes: u64 = index.blocks.iter().filter(|b| done.contains(&b.done_key())).map(|b| b.bytes).sum();
ctx.reporter.add_done_bytes(skipped_bytes);
ctx.reporter.items_done((index.blocks.len() - pending.len()) as u64);
tokio::fs::create_dir_all(&set_dir).await.map_err(Error::io(&set_dir))?;
let fetched = pending.len();
let mut count = 0usize;
let set_root = ctx.backend.drive_root().map(|root| root.join("tiles").join(style).join(dataset));
let result = run_pool(
pending,
ctx.workers,
&ctx.cancel,
|block: IndexBlock, stop| {
let (set_dir, reporter, journal, set_root) = (set_dir.clone(), ctx.reporter.clone(), journal.clone(), set_root.clone());
let (style, dataset, journal_seq) = (style.to_string(), dataset.to_string(), index.journal_seq);
async move {
let key = block.done_key();
let url = format!("/v1/versions/{version}/tiles/{style}/{dataset}/block/{}/{}/{}.tar", block.z, block.bx, block.by);
match (ctx.backend.site(), journal, set_root) {
(Some(site), _, _) => fetch_site_block(site, url, block, set_dir, reporter, stop).await?,
(None, Some(journal), Some(set_root)) => {
blocking(move || {
let drive = DriveBlock { journal: &journal, set_root: &set_root, style: &style, dataset: &dataset, journal_seq };
let what = format!("tiles/{style}/{dataset} block {}/{}/{}", block.z, block.bx, block.by);
copy_drive_block(&drive, &what, &block, &set_dir, &reporter, &stop)
})
.await?
}
_ => unreachable!("a drive install always opens the journal"),
}
Ok(key)
}
},
|key| {
done.insert(key);
count += 1;
ctx.reporter.items_done(1);
if count % 50 == 0 || count == fetched {
atomic_write(&done_path, blocks_done_json(&set.index_sha256, &done).as_bytes())?;
}
Ok(())
},
)
.await;
if result.is_err() && count % 50 != 0 {
// Keep the blocks that did verify, so the next run does not fetch them again.
let _ = atomic_write(&done_path, blocks_done_json(&set.index_sha256, &done).as_bytes());
}
result?;
out.push(TileSetSummary {
set: format!("{style}/{dataset}"),
blocks: index.blocks.len(),
downloaded: fetched,
files: index.files,
bytes: index.bytes,
});
}
ctx.reporter.finish_stage();
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn progress_files_match_python_json_dumps() {
let verified: BTreeSet<u64> = [2, 0, 1].into_iter().collect();
assert_eq!(chunks_progress_json("ab", &verified), r#"{"chunks_sha256": "ab", "verified": [0, 1, 2]}"#);
let done: BTreeSet<String> = ["7/1/0:bb".to_string(), "0/0/0:aa".to_string()].into_iter().collect();
assert_eq!(blocks_done_json("cd", &done), r#"{"index_sha256": "cd", "blocks": ["0/0/0:aa", "7/1/0:bb"]}"#);
assert_eq!(blocks_done_json("cd", &BTreeSet::new()), r#"{"index_sha256": "cd", "blocks": []}"#);
}
#[test]
fn install_future_is_send() {
fn assert_send<T: Send>(_: &T) {}
let source = Source::Drive { root: PathBuf::from(".") };
let raw = b"{}".to_vec();
if let Ok(manifest) = VerifiedManifest::from_bytes(raw, crate::bundled_public_key()) {
let fut = install(&source, Path::new("."), &manifest, InstallOptions::default(), |_| {}, CancellationToken::new());
assert_send(&fut);
}
}
}
+73
View File
@@ -0,0 +1,73 @@
//! ODIAN packs, format v1: verify a signed version manifest and install that version from a
//! site's update service or from an ODIAN drive, checking every byte.
//!
//! A byte-for-byte compatible port of the Python installer (`odian_packs.fetch` in
//! ODIAN_MCP_Server; the format is its `src/odian_packs/README.md`). An install made by
//! either one is resumed and re-verified by the other: same layout, same progress files.
//!
//! ```text
//! dest/versions/<v>/manifest.json written LAST: its presence means "installed"
//! dest/versions/<v>/<app paths>
//! dest/data/db/<snapshot>/...
//! dest/data/src/<paths> + <path>.chunks.json while partial, <path>.ok when done
//! dest/data/tiles/<style>/<dataset>/<z>/<x>/<y><ext> + .blocks-done.json
//! ```
pub mod canonical;
pub mod error;
pub mod hashing;
pub mod manifest;
pub mod signing;
pub mod tiles;
mod copy;
mod install;
mod progress;
mod source;
mod tileblocks;
pub use canonical::canonical_bytes;
pub use error::{Error, IntegrityKind, Result};
pub use install::{
install, AppSummary, DbSummary, InstallOptions, SrcFileSummary, Summary, TileSetSummary,
};
pub use manifest::{safe_relpath, validate, Manifest, VerifiedManifest};
pub use progress::{Progress, Stage};
pub use source::Source;
use tokio_util::sync::CancellationToken;
/// The ODIAN release public key that ships with the launcher (`ed25519:<base64>`).
pub fn bundled_public_key() -> &'static str {
include_str!("../keys/odian-release.pub").trim()
}
/// The source's CURRENT version manifest: fetched, validated and signature-checked.
/// The raw bytes are kept ([`VerifiedManifest::raw`]) because the install writes them verbatim.
pub async fn current_manifest(source: &Source, public_key: &str) -> Result<VerifiedManifest> {
let backend = source::Backend::new(source)?;
let raw = backend.current_manifest(&CancellationToken::new()).await?;
VerifiedManifest::from_bytes(raw, public_key)
}
/// A specific version's manifest, verified, and checked to really be that version.
pub async fn version_manifest(source: &Source, version: &str, public_key: &str) -> Result<VerifiedManifest> {
let backend = source::Backend::new(source)?;
let raw = backend.version_manifest(version, &CancellationToken::new()).await?;
let verified = VerifiedManifest::from_bytes(raw, public_key)?;
if verified.version() != version {
return Err(Error::integrity(
IntegrityKind::Version,
format!("manifest for {version}"),
format!("asked for {version}, the signed manifest says {}", verified.version()),
));
}
Ok(verified)
}
/// A version's patch notes (Markdown). **Untrusted text**: not covered by the signature.
pub async fn fetch_notes(source: &Source, version: &str) -> Result<String> {
let backend = source::Backend::new(source)?;
let raw = backend.notes(version, &CancellationToken::new()).await?;
Ok(String::from_utf8_lossy(&raw).into_owned())
}
+454
View File
@@ -0,0 +1,454 @@
//! Version manifest: schema v1, validation, and the verified form the installer uses
//! (the Python module `odian_packs.manifest`).
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use crate::canonical::canonical_bytes;
use crate::error::{Error, Result};
use crate::hashing::hex;
pub const SCHEMA: u64 = 1;
pub const CHUNK_BYTES: u64 = 256 * 1024 * 1024;
pub const BLOCK_SHIFT: u32 = 6;
pub const DB_SECTIONS: [&str; 4] = ["pre-data", "data", "sequences", "post-data"];
const WINDOWS_FORBIDDEN: &[char] = &['<', '>', ':', '"', '|', '?', '*'];
/// `MAJOR.MINOR.PATCH` with no leading zeros (ASCII digits only).
pub fn parse_version(text: &str) -> Result<(u64, u64, u64)> {
let bad = || Error::manifest(format!("version must be MAJOR.MINOR.PATCH, got {text:?}"));
let parts: Vec<&str> = text.split('.').collect();
if parts.len() != 3 {
return Err(bad());
}
let mut out = [0u64; 3];
for (slot, part) in out.iter_mut().zip(&parts) {
let ok = !part.is_empty()
&& part.bytes().all(|b| b.is_ascii_digit())
&& (*part == "0" || !part.starts_with('0'));
if !ok {
return Err(bad());
}
*slot = part.parse().map_err(|_| bad())?;
}
Ok((out[0], out[1], out[2]))
}
/// A path that stays inside the directory it is joined to, and that Windows can create as
/// a plain file. Same rules and order of checks as Python's `safe_relpath`.
pub fn safe_relpath(path: &str) -> Result<&str> {
if path.is_empty() {
return Err(Error::manifest("empty path"));
}
if path.contains('\\') || path.starts_with('/') {
return Err(Error::manifest(format!("path must be relative with '/' separators: {path:?}")));
}
let parts: Vec<&str> = path.split('/').collect();
if parts.iter().any(|p| matches!(*p, "" | "." | "..")) {
return Err(Error::manifest(format!("path must not contain empty, '.' or '..' parts: {path:?}")));
}
for part in parts {
if part.chars().any(|c| (c as u32) < 32 || WINDOWS_FORBIDDEN.contains(&c)) {
return Err(Error::manifest(format!(
"path part {part:?} holds a control character or one of <>:\"|?*: {path:?}"
)));
}
if part.ends_with('.') || part.ends_with(' ') {
return Err(Error::manifest(format!("path part {part:?} ends with a dot or space: {path:?}")));
}
if is_windows_reserved(part) {
return Err(Error::manifest(format!("path part {part:?} is a reserved Windows device name: {path:?}")));
}
}
Ok(path)
}
/// `^(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³])(\..*)?$`, case-insensitive.
fn is_windows_reserved(part: &str) -> bool {
let base = match part.find('.') {
Some(i) => &part[..i],
None => part,
};
let lower = base.to_ascii_lowercase();
if matches!(lower.as_str(), "con" | "prn" | "aux" | "nul") {
return true;
}
for prefix in ["com", "lpt"] {
if let Some(rest) = lower.strip_prefix(prefix) {
let mut chars = rest.chars();
if let (Some(c), None) = (chars.next(), chars.next()) {
if c.is_ascii_digit() || matches!(c, '¹' | '²' | '³') {
return true;
}
}
}
}
false
}
pub fn chunk_count(size: u64, chunk_bytes: u64) -> u64 {
size.div_ceil(chunk_bytes)
}
/// sha256 over the concatenated chunk hashes (hex text): a source file's identity.
pub fn chunks_digest<S: AsRef<str>>(chunks: &[S]) -> String {
let mut digest = Sha256::new();
for chunk in chunks {
digest.update(chunk.as_ref().as_bytes());
}
hex(&digest.finalize())
}
/// Where a version keeps a tile set's frozen index, relative to `versions/<v>/`.
pub fn tile_index_name(style: &str, dataset: &str) -> String {
format!("tiles/{style}.{dataset}.index.json")
}
/// What the Ed25519 signature covers: canonical JSON of the manifest minus `signature`.
pub fn signing_payload(manifest: &Value) -> Result<Vec<u8>> {
let object = manifest.as_object().ok_or_else(|| Error::manifest("manifest must be a JSON object"))?;
let mut copy = Map::new();
for (k, v) in object {
if k != "signature" {
copy.insert(k.clone(), v.clone());
}
}
canonical_bytes(&Value::Object(copy))
}
// ---- validation -------------------------------------------------------------
fn require(cond: bool, message: impl FnOnce() -> String) -> Result<()> {
if cond { Ok(()) } else { Err(Error::Manifest(message())) }
}
fn is_count(value: Option<&Value>) -> bool {
value.and_then(Value::as_u64).is_some()
}
fn is_sha256(value: Option<&Value>) -> bool {
matches!(value.and_then(Value::as_str), Some(s) if s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')))
}
fn check_sha(value: Option<&Value>, place: &str) -> Result<()> {
require(is_sha256(value), || format!("{place}: sha256 must be 64 lowercase hex chars"))
}
fn is_name(s: &str) -> bool {
(1..=128).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
}
fn is_snapshot(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 16
&& b[..8].iter().all(u8::is_ascii_digit)
&& b[8] == b'T'
&& b[9..15].iter().all(u8::is_ascii_digit)
&& b[15] == b'Z'
}
fn relpath_value(value: Option<&Value>) -> Result<&str> {
match value.and_then(Value::as_str) {
Some(s) => safe_relpath(s),
None => Err(Error::manifest("empty path")),
}
}
fn version_value(value: Option<&Value>) -> Result<()> {
match value {
None => parse_version("").map(|_| ()),
Some(Value::String(s)) => parse_version(s).map(|_| ()),
Some(other) => Err(Error::manifest(format!("version must be MAJOR.MINOR.PATCH, got {other}"))),
}
}
fn entries<'a>(value: Option<&'a Value>, place: &str) -> Result<Vec<&'a Map<String, Value>>> {
let list = value.and_then(Value::as_array).ok_or_else(|| Error::manifest(format!("{place} must be a list")))?;
list.iter()
.enumerate()
.map(|(i, entry)| entry.as_object().ok_or_else(|| Error::manifest(format!("{place}[{i}] must be an object"))))
.collect()
}
fn object<'a>(value: &'a Value, message: &str) -> Result<&'a Map<String, Value>> {
value.as_object().ok_or_else(|| Error::manifest(message))
}
/// Error unless `manifest` is a well-formed schema-1 manifest. Structure only; the
/// signature is checked by [`crate::signing::verify`].
pub fn validate(manifest: &Value) -> Result<()> {
let m = object(manifest, "manifest must be a JSON object")?;
require(m.get("schema").and_then(Value::as_u64) == Some(SCHEMA), || format!("schema must be {SCHEMA}"))?;
require(m.get("product").and_then(Value::as_str) == Some("odian"), || "product must be 'odian'".into())?;
version_value(m.get("version"))?;
require(m.get("released_at").is_some_and(Value::is_string), || "released_at must be an ISO-8601 string".into())?;
if let Some(v) = m.get("min_launcher") {
version_value(Some(v))?;
}
if let Some(v) = m.get("notes") {
relpath_value(Some(v))?;
}
let app_files = match m.get("app") {
None => Vec::new(),
Some(app) => entries(object(app, "app must be an object")?.get("files"), "app.files")?,
};
let mut seen: HashSet<String> = HashSet::new();
for (i, entry) in app_files.iter().enumerate() {
let place = format!("app.files[{i}]");
let path = relpath_value(entry.get("path"))?;
require(seen.insert(path.to_lowercase()), || format!("{place}: duplicate path {path:?}"))?;
require(is_count(entry.get("size")), || format!("{place}: size must be a non-negative integer"))?;
check_sha(entry.get("sha256"), &place)?;
}
if let Some(db) = m.get("db") {
let db = object(db, "db must be an object")?;
require(is_count(db.get("schema_version")), || "db.schema_version must be a non-negative integer".into())?;
let snapshot = db
.get("snapshot")
.and_then(Value::as_str)
.filter(|s| is_snapshot(s))
.ok_or_else(|| Error::manifest("db.snapshot must look like 20260915T101500Z"))?;
let files = entries(db.get("files"), "db.files")?;
require(!files.is_empty(), || "db.files must not be empty".into())?;
let prefix = format!("db/{snapshot}/");
let mut seen: HashSet<String> = HashSet::new();
for (i, entry) in files.iter().enumerate() {
let place = format!("db.files[{i}]");
let section = entry.get("section").and_then(Value::as_str).filter(|s| DB_SECTIONS.contains(s));
let section = section.ok_or_else(|| Error::manifest(format!("{place}: section must be one of {DB_SECTIONS:?}")))?;
if section == "data" {
require(entry.get("table").and_then(Value::as_str).is_some_and(|t| !t.is_empty()), || {
format!("{place}: data dumps name their table")
})?;
}
let path = relpath_value(entry.get("path"))?;
require(path.starts_with(&prefix), || format!("{place}: path must live under {prefix}"))?;
require(seen.insert(path.to_lowercase()), || format!("{place}: duplicate path {path:?}"))?;
require(is_count(entry.get("size")), || format!("{place}: size must be a non-negative integer"))?;
check_sha(entry.get("sha256"), &place)?;
}
}
if let Some(tiles) = m.get("tiles") {
let tiles = object(tiles, "tiles must be an object")?;
require(tiles.get("block_shift").and_then(Value::as_u64) == Some(BLOCK_SHIFT as u64), || {
format!("tiles.block_shift must be {BLOCK_SHIFT}")
})?;
let mut sets_seen: HashSet<(String, String)> = HashSet::new();
for (i, entry) in entries(tiles.get("sets"), "tiles.sets")?.iter().enumerate() {
let place = format!("tiles.sets[{i}]");
for key in ["style", "dataset"] {
require(entry.get(key).and_then(Value::as_str).is_some_and(is_name), || format!("{place}: bad {key}"))?;
}
let style = entry["style"].as_str().unwrap_or_default().to_string();
let dataset = entry["dataset"].as_str().unwrap_or_default().to_string();
require(!sets_seen.contains(&(style.clone(), dataset.clone())), || {
format!("{place}: duplicate tile set {style}/{dataset}")
})?;
for count in ["journal_seq", "blocks", "files", "bytes"] {
require(is_count(entry.get(count)), || format!("{place}: {count} must be a non-negative integer"))?;
}
check_sha(entry.get("index_sha256"), &place)?;
let expected = tile_index_name(&style, &dataset);
require(entry.get("index").and_then(Value::as_str) == Some(expected.as_str()), || {
format!("{place}: index must be {expected:?}")
})?;
sets_seen.insert((style, dataset));
}
}
if let Some(src) = m.get("src") {
let src = object(src, "src must be an object")?;
require(src.get("enabled").is_some_and(Value::is_boolean), || "src.enabled must be true or false".into())?;
let chunk_bytes = src.get("chunk_bytes").and_then(Value::as_u64).filter(|n| *n > 0);
let chunk_bytes = chunk_bytes.ok_or_else(|| Error::manifest("src.chunk_bytes must be a positive integer"))?;
let mut seen: HashSet<String> = HashSet::new();
for (i, entry) in entries(src.get("files"), "src.files")?.iter().enumerate() {
let place = format!("src.files[{i}]");
let path = relpath_value(entry.get("path"))?;
require(seen.insert(path.to_lowercase()), || format!("{place}: duplicate path {path:?}"))?;
let size = entry.get("size").and_then(Value::as_u64);
let size = size.ok_or_else(|| Error::manifest(format!("{place}: size must be a non-negative integer")))?;
let chunks = entry.get("chunks").and_then(Value::as_array);
let chunks = chunks.ok_or_else(|| Error::manifest(format!("{place}: chunks must be a list")))?;
require(chunks.len() as u64 == chunk_count(size, chunk_bytes), || {
format!("{place}: {} chunks for {size} bytes at {chunk_bytes} per chunk", chunks.len())
})?;
let mut hashes = Vec::with_capacity(chunks.len());
for chunk in chunks {
check_sha(Some(chunk), &place)?;
hashes.push(chunk.as_str().unwrap_or_default());
}
require(entry.get("chunks_sha256").and_then(Value::as_str) == Some(chunks_digest(&hashes).as_str()), || {
format!("{place}: chunks_sha256 does not match chunks")
})?;
}
}
if let Some(signature) = m.get("signature") {
require(signature.as_str().is_some_and(|s| s.starts_with("ed25519:")), || {
"signature must look like 'ed25519:<base64>'".into()
})?;
}
Ok(())
}
// ---- typed view -------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppFile {
pub path: String,
pub size: u64,
pub sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AppSection {
pub files: Vec<AppFile>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DbFile {
pub section: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub table: Option<String>,
pub path: String,
pub size: u64,
pub sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DbSection {
pub schema_version: u64,
pub snapshot: String,
pub files: Vec<DbFile>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TileSet {
pub style: String,
pub dataset: String,
pub journal_seq: u64,
pub blocks: u64,
pub files: u64,
pub bytes: u64,
pub index: String,
pub index_sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TilesSection {
pub block_shift: u64,
pub sets: Vec<TileSet>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SrcFile {
pub path: String,
pub size: u64,
pub chunks: Vec<String>,
pub chunks_sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SrcSection {
pub enabled: bool,
pub chunk_bytes: u64,
pub files: Vec<SrcFile>,
}
/// A validated manifest's fields (unknown keys are kept only in the raw bytes).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Manifest {
pub schema: u64,
pub product: String,
pub version: String,
pub released_at: String,
#[serde(default)]
pub min_launcher: Option<String>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub app: AppSection,
#[serde(default)]
pub db: Option<DbSection>,
#[serde(default)]
pub tiles: Option<TilesSection>,
#[serde(default)]
pub src: Option<SrcSection>,
#[serde(default)]
pub signature: Option<String>,
}
/// A manifest that parsed, validated and carries a good signature by the given key.
/// Only [`VerifiedManifest::from_bytes`] builds one.
#[derive(Debug, Clone)]
pub struct VerifiedManifest {
raw: Vec<u8>,
value: Value,
manifest: Manifest,
}
impl VerifiedManifest {
/// Parse, validate and signature-check manifest bytes (e.g. an installed
/// `versions/<v>/manifest.json`, re-checked offline).
pub fn from_bytes(raw: Vec<u8>, public_key: &str) -> Result<Self> {
let value: Value = serde_json::from_slice(&raw)
.map_err(|e| Error::manifest(format!("manifest is not valid UTF-8 JSON: {e}")))?;
// Refuse non-integer numbers anywhere, before anything is hashed.
canonical_bytes(&value)?;
validate(&value)?;
crate::signing::verify(&value, public_key)?;
let manifest: Manifest = serde_json::from_value(value.clone())
.map_err(|e| Error::manifest(format!("validated manifest does not map to its fields: {e}")))?;
Ok(VerifiedManifest { raw, value, manifest })
}
/// The bytes exactly as served; written verbatim to `versions/<v>/manifest.json`.
pub fn raw(&self) -> &[u8] {
&self.raw
}
pub fn value(&self) -> &Value {
&self.value
}
pub fn manifest(&self) -> &Manifest {
&self.manifest
}
pub fn version(&self) -> &str {
&self.manifest.version
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relpaths_like_python_tests() {
assert!(safe_relpath("Odian/x.exe").is_ok());
for bad in ["../a", "a/../b", "/a", "C:/a", "a\\b", "a//b", "", "./a", "Odian/Odian.exe:hidden", "a/b.", "a/b ",
"CON", "a/aux.txt", "a/Lpt1.log", "a/b\u{0}c", "a/b\nc", "a/b?c", "a/b*c", "a/b\"c", "a/b|c", "a/com¹"] {
assert!(safe_relpath(bad).is_err(), "{bad:?}");
}
for fine in ["a/console.txt", "a/auxiliary", "a/com10", "a/.hidden", "a/b c.txt", "a/comA"] {
assert!(safe_relpath(fine).is_ok(), "{fine:?}");
}
}
#[test]
fn versions() {
assert_eq!(parse_version("0.51.0").unwrap(), (0, 51, 0));
for bad in ["1.0", "01.2.3", "0.51.0-rc1", "0.51.0\n", "", "1..2", "a.b.c"] {
assert!(parse_version(bad).is_err(), "{bad:?}");
}
}
}
+207
View File
@@ -0,0 +1,207 @@
//! Install progress, shaped like `InstallProgress` in the launcher's docs/CONTRACT.md,
//! throttled to about ten reports per second.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
/// Install stages. This crate reports `manifest`, `app`, `db`, `src` and `tiles`;
/// `restore` and `finalize` exist so the launcher can reuse the type for its own steps.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Stage {
Manifest,
App,
Db,
Src,
Tiles,
Restore,
Finalize,
}
/// One progress report (`InstallProgress` on the wire).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Progress {
pub stage: Stage,
/// 1-based.
pub stage_index: u32,
pub stage_count: u32,
pub done_bytes: u64,
/// 0 when unknown.
pub total_bytes: u64,
pub done_items: u64,
pub total_items: u64,
pub bytes_per_sec: f64,
pub message: Option<String>,
}
const INTERVAL: Duration = Duration::from_millis(100);
/// Time constant of the smoothed transfer rate.
const RATE_WINDOW_SECS: f64 = 2.0;
pub(crate) type ProgressFn = Arc<dyn Fn(Progress) + Send + Sync>;
struct State {
stage: Stage,
stage_index: u32,
stage_count: u32,
total_bytes: u64,
total_items: u64,
label: String,
unit: &'static str,
sub_total: u64,
last_emit: Option<Instant>,
rate_bytes: u64,
rate_at: Instant,
rate: f64,
}
/// Shared by every worker of one install. Counters are atomics; a report is built and
/// sent at most every [`INTERVAL`], except stage starts and ends, which always go out.
pub(crate) struct Reporter {
callback: ProgressFn,
done_bytes: AtomicU64,
done_items: AtomicU64,
sub_done: AtomicU64,
transferred: AtomicU64,
state: Mutex<State>,
}
impl Reporter {
pub fn new(callback: ProgressFn, stage_count: u32) -> Self {
Reporter {
callback,
done_bytes: AtomicU64::new(0),
done_items: AtomicU64::new(0),
sub_done: AtomicU64::new(0),
transferred: AtomicU64::new(0),
state: Mutex::new(State {
stage: Stage::Manifest,
stage_index: 1,
stage_count,
total_bytes: 0,
total_items: 0,
label: String::new(),
unit: "",
sub_total: 0,
last_emit: None,
rate_bytes: 0,
rate_at: Instant::now(),
rate: 0.0,
}),
}
}
pub fn begin_stage(&self, stage: Stage, stage_index: u32, total_bytes: u64, total_items: u64) {
{
let mut st = self.state.lock().unwrap_or_else(|p| p.into_inner());
st.stage = stage;
st.stage_index = stage_index;
st.total_bytes = total_bytes;
st.total_items = total_items;
st.label.clear();
st.unit = "";
st.sub_total = 0;
self.done_bytes.store(0, Ordering::Relaxed);
self.done_items.store(0, Ordering::Relaxed);
self.sub_done.store(0, Ordering::Relaxed);
}
self.emit(true);
}
/// The part of the stage the message describes ("tiles dark/ds", 1402 "blocks").
pub fn begin_sub(&self, label: String, unit: &'static str, sub_total: u64) {
{
let mut st = self.state.lock().unwrap_or_else(|p| p.into_inner());
st.label = label;
st.unit = unit;
st.sub_total = sub_total;
self.sub_done.store(0, Ordering::Relaxed);
}
self.emit(true);
}
/// Bytes that just crossed the network (or were copied from a drive).
pub fn add_transferred(&self, n: u64) {
self.transferred.fetch_add(n, Ordering::Relaxed);
self.done_bytes.fetch_add(n, Ordering::Relaxed);
self.emit(false);
}
/// Bytes found already in place (skipped files, resumed prefixes, verified chunks).
pub fn add_done_bytes(&self, n: u64) {
self.done_bytes.fetch_add(n, Ordering::Relaxed);
self.emit(false);
}
/// Take back bytes counted for a download that failed verification and restarts.
pub fn retract_bytes(&self, n: u64) {
let _ = self.done_bytes.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| Some(v.saturating_sub(n)));
}
pub fn items_done(&self, n: u64) {
self.done_items.fetch_add(n, Ordering::Relaxed);
self.sub_done.fetch_add(n, Ordering::Relaxed);
self.emit(false);
}
pub fn finish_stage(&self) {
self.emit(true);
}
pub fn transferred(&self) -> u64 {
self.transferred.load(Ordering::Relaxed)
}
fn emit(&self, force: bool) {
let now = Instant::now();
let progress = {
let mut st = if force {
self.state.lock().unwrap_or_else(|p| p.into_inner())
} else {
match self.state.try_lock() {
Ok(guard) => guard,
Err(_) => return, // another worker is reporting right now
}
};
if !force && st.last_emit.is_some_and(|last| now.duration_since(last) < INTERVAL) {
return;
}
st.last_emit = Some(now);
let transferred = self.transferred.load(Ordering::Relaxed);
let dt = now.duration_since(st.rate_at).as_secs_f64();
if dt >= 0.05 {
let instant = transferred.saturating_sub(st.rate_bytes) as f64 / dt;
let alpha = (dt / RATE_WINDOW_SECS).min(1.0);
st.rate += alpha * (instant - st.rate);
st.rate_bytes = transferred;
st.rate_at = now;
}
let mut done_bytes = self.done_bytes.load(Ordering::Relaxed);
if st.total_bytes > 0 {
done_bytes = done_bytes.min(st.total_bytes);
}
let message = (!st.label.is_empty()).then(|| {
if st.sub_total == 0 {
st.label.clone()
} else {
format!("{}: {}/{} {}", st.label, self.sub_done.load(Ordering::Relaxed), st.sub_total, st.unit)
}
});
Progress {
stage: st.stage,
stage_index: st.stage_index,
stage_count: st.stage_count,
done_bytes,
total_bytes: st.total_bytes,
done_items: self.done_items.load(Ordering::Relaxed),
total_items: st.total_items,
bytes_per_sec: st.rate.max(0.0).round(),
message,
}
};
(self.callback)(progress);
}
}
+52
View File
@@ -0,0 +1,52 @@
//! Ed25519 release signatures (verification only; the Python module `odian_packs.signing`).
//!
//! Key text: `ed25519:<base64 of the raw 32 bytes>`. Signature: Ed25519 over
//! [`crate::manifest::signing_payload`], written as `ed25519:<base64 of 64 bytes>`.
use base64::alphabet;
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
use base64::Engine;
use ed25519_dalek::{Signature, VerifyingKey};
use serde_json::Value;
use crate::error::{Error, IntegrityKind, Result};
use crate::manifest::signing_payload;
/// Python's `base64.b64decode(text, validate=True)`: standard alphabet, correct padding,
/// no whitespace, and (like CPython) non-zero trailing bits tolerated.
const B64: GeneralPurpose = GeneralPurpose::new(
&alphabet::STANDARD,
GeneralPurposeConfig::new()
.with_decode_allow_trailing_bits(true)
.with_decode_padding_mode(DecodePaddingMode::RequireCanonical),
);
fn signature_error(detail: &str) -> Error {
Error::integrity(IntegrityKind::Signature, "manifest", detail)
}
/// Parse `ed25519:<base64>` (surrounding whitespace ignored).
pub fn load_public_key(text: &str) -> Result<VerifyingKey> {
let body = text
.trim()
.strip_prefix("ed25519:")
.ok_or_else(|| signature_error("public key must look like 'ed25519:<base64>'"))?;
let raw = B64.decode(body).map_err(|_| signature_error("public key is not valid base64"))?;
let bytes: [u8; 32] = raw.try_into().map_err(|_| signature_error("an Ed25519 public key is 32 bytes"))?;
VerifyingKey::from_bytes(&bytes).map_err(|_| signature_error("public key is not a valid Ed25519 point"))
}
/// Error ([`IntegrityKind::Signature`]) unless `manifest` carries a valid signature by that key.
pub fn verify(manifest: &Value, public_key: &str) -> Result<()> {
let body = manifest
.get("signature")
.and_then(Value::as_str)
.and_then(|s| s.strip_prefix("ed25519:"))
.ok_or_else(|| signature_error("manifest is not signed"))?;
let raw = B64.decode(body).map_err(|_| signature_error("signature is not valid base64"))?;
let key = load_public_key(public_key)?;
let payload = signing_payload(manifest)?;
let mismatch = || signature_error("signature does not match: manifest altered, or signed by another key");
let signature = Signature::from_slice(&raw).map_err(|_| mismatch())?;
key.verify_strict(&payload, &signature).map_err(|_| mismatch())
}
+425
View File
@@ -0,0 +1,425 @@
//! Where a version comes from: a site's update service over HTTP, or an ODIAN drive that
//! holds the site store layout (`packs/`, `tiles/`, `src/`).
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Duration;
use futures_util::StreamExt;
use reqwest::header::{HeaderValue, RANGE, RETRY_AFTER};
use rusqlite::{Connection, OpenFlags};
use tokio_util::sync::CancellationToken;
use crate::error::{Error, Result};
use crate::manifest::{parse_version, tile_index_name};
/// Where to install from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
/// A site's update service, e.g. `http://192.168.1.41:30097`, with an Engine token.
Site { base_url: String, token: String },
/// An ODIAN drive (SSD) whose root holds `packs/`, `tiles/` (with `tiles.db`) and `src/`.
Drive { root: PathBuf },
}
const RETRIES: u32 = 4;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
const READ_TIMEOUT: Duration = Duration::from_secs(120);
/// A Retry-After beyond this is treated as this (Python sleeps whatever the site says).
const MAX_RETRY_AFTER_SECS: f64 = 120.0;
const ERROR_BODY_BYTES: usize = 300;
/// Run blocking work off the async threads.
pub(crate) async fn blocking<T, F>(f: F) -> Result<T>
where
T: Send + 'static,
F: FnOnce() -> Result<T> + Send + 'static,
{
match tokio::task::spawn_blocking(f).await {
Ok(result) => result,
Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()),
Err(_) => Err(Error::Cancelled),
}
}
pub(crate) async fn sleep_or_cancel(secs: f64, cancel: &CancellationToken) -> Result<()> {
tokio::select! {
_ = cancel.cancelled() => Err(Error::Cancelled),
_ = tokio::time::sleep(Duration::from_secs_f64(secs.max(0.0))) => Ok(()),
}
}
/// Percent-encode each segment of a relative path (manifest paths may hold spaces,
/// `%`, `#` or non-ASCII; nginx decodes them back before looking the file up).
pub(crate) fn encode_path(rel: &str) -> String {
let mut out = String::with_capacity(rel.len());
for (i, segment) in rel.split('/').enumerate() {
if i > 0 {
out.push('/');
}
for b in segment.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
}
out
}
fn join_rel(root: &Path, rel: &str) -> PathBuf {
let mut path = root.to_path_buf();
for part in rel.split('/') {
path.push(part);
}
path
}
fn open_error(path: &Path) -> impl FnOnce(std::io::Error) -> Error + '_ {
move |e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::NotFound { what: path.display().to_string() }
} else {
Error::Io { path: path.to_path_buf(), source: e }
}
}
}
/// A body to copy: an HTTP response, or a drive file already positioned and limited.
pub(crate) enum Body {
Http { response: reqwest::Response, url: String },
File { reader: std::io::Take<std::fs::File>, path: PathBuf },
}
/// A pack-store object.
#[derive(Clone, Copy)]
pub(crate) enum Pack<'a> {
/// `app/<sha256>`.
App(&'a str),
/// A manifest `db.files[].path` (`db/<snapshot>/...`).
Db(&'a str),
}
pub(crate) struct Site {
base: String,
token: String,
client: reqwest::Client,
}
pub(crate) struct Drive {
root: PathBuf,
}
pub(crate) enum Backend {
Site(Site),
Drive(Drive),
}
impl Backend {
pub fn new(source: &Source) -> Result<Self> {
match source {
Source::Site { base_url, token } => {
let client = reqwest::Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.read_timeout(READ_TIMEOUT)
.user_agent(concat!("odian-packs/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| Error::Http { url: base_url.clone(), status: None, message: e.to_string() })?;
Ok(Backend::Site(Site { base: base_url.trim_end_matches('/').to_string(), token: token.clone(), client }))
}
Source::Drive { root } => Ok(Backend::Drive(Drive { root: root.clone() })),
}
}
pub async fn current_manifest(&self, cancel: &CancellationToken) -> Result<Vec<u8>> {
match self {
Backend::Site(site) => site.get_bytes("/v1/versions/current", cancel).await,
Backend::Drive(drive) => {
let pointer = drive.root.join("packs").join("versions").join("CURRENT");
let raw = blocking({
let pointer = pointer.clone();
move || std::fs::read(&pointer).map_err(open_error(&pointer))
})
.await?;
let text = String::from_utf8_lossy(&raw[..raw.len().min(256)]).trim().to_string();
if text.is_empty() {
return Err(Error::NotFound { what: format!("{}: no current version", pointer.display()) });
}
parse_version(&text)
.map_err(|_| Error::manifest(format!("{} does not hold a valid version", pointer.display())))?;
drive.read(join_rel(&drive.root, &format!("packs/versions/{text}/manifest.json"))).await
}
}
}
pub async fn version_manifest(&self, version: &str, cancel: &CancellationToken) -> Result<Vec<u8>> {
parse_version(version)?;
match self {
Backend::Site(site) => site.get_bytes(&format!("/v1/versions/{version}/manifest"), cancel).await,
Backend::Drive(drive) => drive.read(join_rel(&drive.root, &format!("packs/versions/{version}/manifest.json"))).await,
}
}
pub async fn notes(&self, version: &str, cancel: &CancellationToken) -> Result<Vec<u8>> {
parse_version(version)?;
match self {
Backend::Site(site) => site.get_bytes(&format!("/v1/versions/{version}/notes"), cancel).await,
Backend::Drive(drive) => drive.read(join_rel(&drive.root, &format!("packs/versions/{version}/notes.md"))).await,
}
}
/// `style` and `dataset` come from a validated manifest (name rule already applied).
pub async fn tile_index(&self, version: &str, style: &str, dataset: &str, cancel: &CancellationToken) -> Result<Vec<u8>> {
match self {
Backend::Site(site) => {
site.get_bytes(&format!("/v1/versions/{version}/tiles/{style}/{dataset}/index"), cancel).await
}
Backend::Drive(drive) => {
let rel = format!("packs/versions/{version}/{}", tile_index_name(style, dataset));
drive.read(join_rel(&drive.root, &rel)).await
}
}
}
pub fn pack_label(&self, pack: Pack<'_>) -> String {
match pack {
Pack::App(sha) => format!("/packs/app/{sha}"),
Pack::Db(path) => format!("/packs/{path}"),
}
}
/// Open a pack object from `offset`. The flag says whether the body really starts at
/// `offset` (a site may ignore the Range and send the whole file).
pub async fn open_pack(&self, pack: Pack<'_>, offset: u64, cancel: &CancellationToken) -> Result<(Body, bool)> {
match self {
Backend::Site(site) => {
let path = match pack {
Pack::App(sha) => format!("/packs/app/{}", encode_path(sha)),
Pack::Db(rel) => format!("/packs/{}", encode_path(rel)),
};
let range = (offset > 0).then(|| format!("bytes={offset}-"));
let (response, url) = site.get(&path, range, cancel).await?;
let partial = offset > 0 && response.status() == reqwest::StatusCode::PARTIAL_CONTENT;
Ok((Body::Http { response, url }, offset == 0 || partial))
}
Backend::Drive(drive) => {
let path = match pack {
Pack::App(sha) => drive.root.join("packs").join("app").join(sha),
Pack::Db(rel) => join_rel(&drive.root.join("packs"), rel),
};
let body = drive.open_range(path, offset, u64::MAX).await?;
Ok((body, true))
}
}
}
/// Inclusive byte range `[start, end]` of a source file.
pub async fn open_src_range(&self, rel: &str, start: u64, end: u64, cancel: &CancellationToken) -> Result<Body> {
match self {
Backend::Site(site) => {
let path = format!("/src/{}", encode_path(rel));
let (response, url) = site.get(&path, Some(format!("bytes={start}-{end}")), cancel).await?;
if response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
return Err(Error::Http {
url,
status: Some(response.status().as_u16()),
message: "the DHP did not honour the byte range".into(),
});
}
Ok(Body::Http { response, url })
}
Backend::Drive(drive) => drive.open_range(join_rel(&drive.root.join("src"), rel), start, end - start + 1).await,
}
}
pub fn drive_root(&self) -> Option<&Path> {
match self {
Backend::Drive(drive) => Some(&drive.root),
Backend::Site(_) => None,
}
}
pub fn site(&self) -> Option<&Site> {
match self {
Backend::Site(site) => Some(site),
Backend::Drive(_) => None,
}
}
}
impl Site {
/// GET with the Bearer token. Retries 502/503/504 (honouring Retry-After) and
/// connection errors with a doubling delay, like fetch.py's `Site.open`.
pub async fn get(&self, path: &str, range: Option<String>, cancel: &CancellationToken) -> Result<(reqwest::Response, String)> {
let url = format!("{}{}", self.base, path);
let mut delay = 1.0f64;
for attempt in 1..=RETRIES {
if cancel.is_cancelled() {
return Err(Error::Cancelled);
}
let mut request = self.client.get(&url).bearer_auth(&self.token);
if let Some(range) = &range {
request = request.header(RANGE, HeaderValue::from_str(range).expect("ascii range"));
}
let sent = tokio::select! {
_ = cancel.cancelled() => return Err(Error::Cancelled),
sent = request.send() => sent,
};
let last = attempt == RETRIES;
match sent {
Ok(response) if response.status().is_success() => return Ok((response, url)),
Ok(response) => {
let status = response.status().as_u16();
let retry_after = response
.headers()
.get(RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.trim().parse::<f64>().ok())
.filter(|v| v.is_finite() && *v >= 0.0)
.map(|v| v.min(MAX_RETRY_AFTER_SECS));
if matches!(status, 502..=504) && !last {
sleep_or_cancel(retry_after.unwrap_or(delay), cancel).await?;
delay *= 2.0;
continue;
}
let body = response.bytes().await.map(|b| b.to_vec()).unwrap_or_default();
let body = String::from_utf8_lossy(&body[..body.len().min(ERROR_BODY_BYTES)]).into_owned();
return Err(match status {
401 | 403 => Error::Unauthorized { url, status },
404 => Error::NotFound { what: format!("{url}: {body}") },
503 => Error::SiteBusy { url, attempts: attempt, retry_after_secs: retry_after },
_ => Error::Http { url, status: Some(status), message: format!("HTTP {status}: {body}") },
});
}
Err(e) => {
if !last {
sleep_or_cancel(delay, cancel).await?;
delay *= 2.0;
continue;
}
return Err(Error::Http { url, status: None, message: format!("request failed after {RETRIES} attempts: {e}") });
}
}
}
unreachable!("the loop returns on its last attempt")
}
pub async fn get_bytes(&self, path: &str, cancel: &CancellationToken) -> Result<Vec<u8>> {
let (response, url) = self.get(path, None, cancel).await?;
let mut stream = response.bytes_stream();
let mut out = Vec::new();
loop {
let next = tokio::select! {
_ = cancel.cancelled() => return Err(Error::Cancelled),
next = stream.next() => next,
};
match next {
Some(Ok(chunk)) => out.extend_from_slice(&chunk),
Some(Err(e)) => return Err(Error::Http { url, status: None, message: format!("body cut short: {e}") }),
None => return Ok(out),
}
}
}
}
impl Drive {
async fn read(&self, path: PathBuf) -> Result<Vec<u8>> {
blocking(move || std::fs::read(&path).map_err(open_error(&path))).await
}
async fn open_range(&self, path: PathBuf, start: u64, len: u64) -> Result<Body> {
blocking(move || {
let mut file = std::fs::File::open(&path).map_err(open_error(&path))?;
if start > 0 {
file.seek(SeekFrom::Start(start)).map_err(Error::io(&path))?;
}
Ok(Body::File { reader: file.take(len), path })
})
.await
}
}
/// One tile row straight from the journal: `(z, x, y, ext, size, sha256)`.
pub(crate) type JournalRow = (i64, i64, i64, String, i64, String);
/// A drive's tile journal (`tiles/tiles.db`), opened read-only.
pub(crate) struct Journal {
conn: Mutex<Connection>,
path: PathBuf,
}
fn sqlite_uri(path: &Path, immutable: bool) -> String {
let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
let text = absolute.to_string_lossy().replace('\\', "/");
let mut uri = String::from(if text.starts_with('/') { "file://" } else { "file:///" });
for b in text.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~' | b'/' | b':') {
uri.push(b as char);
} else {
uri.push_str(&format!("%{b:02X}"));
}
}
uri.push_str("?mode=ro");
if immutable {
uri.push_str("&immutable=1");
}
uri
}
impl Journal {
/// Blocking. `mode=ro` first; on "unable to open"/"readonly" (a WAL journal whose
/// -wal/-shm cannot be created) retry once with `immutable=1`, like the update service.
pub fn open(path: &Path) -> Result<Journal> {
if !path.is_file() {
return Err(Error::NotFound { what: format!("tile journal {}", path.display()) });
}
let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI | OpenFlags::SQLITE_OPEN_NO_MUTEX;
let mut last = None;
for immutable in [false, true] {
let attempt = Connection::open_with_flags(sqlite_uri(path, immutable), flags).and_then(|conn| {
conn.busy_timeout(Duration::from_secs(30))?;
conn.query_row("SELECT COUNT(*) FROM tile WHERE seq < 0", [], |_| Ok(()))?;
Ok(conn)
});
match attempt {
Ok(conn) => return Ok(Journal { conn: Mutex::new(conn), path: path.to_path_buf() }),
Err(e) => {
let message = e.to_string().to_lowercase();
let retryable = message.contains("unable to open") || message.contains("readonly");
last = Some(e);
if !retryable {
break;
}
}
}
}
Err(Error::Io { path: path.to_path_buf(), source: std::io::Error::other(last.expect("an attempt ran")) })
}
/// Blocking. The tiles of one block as of `max_seq`, in digest order (`tiles.block_rows`).
pub fn block_rows(&self, style: &str, dataset: &str, z: u64, bx: u64, by: u64, max_seq: u64) -> Result<Vec<JournalRow>> {
let shift = crate::manifest::BLOCK_SHIFT;
let (z, bx, by, max_seq) = (z as i64, bx as i64, by as i64, max_seq as i64);
let conn = self.conn.lock().unwrap_or_else(|p| p.into_inner());
let run = || -> rusqlite::Result<Vec<JournalRow>> {
let mut stmt = conn.prepare_cached(
"SELECT z, x, y, ext, size, sha256 FROM tile \
WHERE style = ? AND dataset = ? AND z = ? AND x BETWEEN ? AND ? AND y BETWEEN ? AND ? AND seq <= ? \
ORDER BY z, x, y, ext",
)?;
let rows = stmt.query_map(
rusqlite::params![
style, dataset, z,
bx << shift, ((bx + 1) << shift) - 1,
by << shift, ((by + 1) << shift) - 1,
max_seq
],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?)),
)?;
rows.collect()
};
run().map_err(|e| Error::Io { path: self.path.clone(), source: std::io::Error::other(e) })
}
}
+272
View File
@@ -0,0 +1,272 @@
//! One tile block: fetch it (site tar stream or drive journal copy) into a staging
//! directory, check every name and the block digest from the bytes, then move the tiles in.
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use bytes::{Buf, Bytes};
use futures_util::StreamExt;
use tokio_util::sync::CancellationToken;
use crate::copy::{copy_hashing, unique_hex};
use crate::error::{Error, IntegrityKind, Result};
use crate::progress::Reporter;
use crate::source::{Journal, Site};
use crate::tiles::{block_digest, block_of, parse_tile_name, tile_relpath, IndexBlock, TileRow};
const TILE_BUF: usize = 256 * 1024;
/// `<set_dir>/../.incoming/<hex>`, removed however the block ends.
struct Staging {
path: PathBuf,
}
impl Staging {
fn create(set_dir: &Path) -> Result<Staging> {
let root = set_dir.parent().unwrap_or(set_dir).join(".incoming");
std::fs::create_dir_all(&root).map_err(Error::io(&root))?;
loop {
let path = root.join(unique_hex());
match std::fs::create_dir(&path) {
Ok(()) => return Ok(Staging { path }),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(Error::Io { path, source: e }),
}
}
}
}
impl Drop for Staging {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn tile_path(root: &Path, z: u64, x: u64, y: u64, ext: &str) -> PathBuf {
root.join(z.to_string()).join(x.to_string()).join(format!("{y}{ext}"))
}
/// Digest and count check, then move every tile from staging into the set.
fn finish_block(what: &str, block: &IndexBlock, rows: &[TileRow], staging: &Path, set_dir: &Path) -> Result<()> {
let got = block_digest(rows);
if got != block.digest || rows.len() as u64 != block.files {
return Err(Error::integrity(
IntegrityKind::BlockDigest,
what,
format!(
"block digest {got} with {} tiles, signed index says {} with {}",
rows.len(),
block.digest,
block.files
),
));
}
for (z, x, y, ext, _, _) in rows {
let target = tile_path(set_dir, *z, *x, *y, ext);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(Error::io(parent))?;
}
let staged = tile_path(staging, *z, *x, *y, ext);
std::fs::rename(&staged, &target).map_err(Error::io(&target))?;
}
Ok(())
}
// ---- site: tar stream ---------------------------------------------------------
/// Blocking `Read` over body chunks sent from the async side.
struct ChannelReader {
rx: tokio::sync::mpsc::Receiver<std::result::Result<Bytes, String>>,
current: Bytes,
}
impl Read for ChannelReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
while self.current.is_empty() {
match self.rx.blocking_recv() {
Some(Ok(chunk)) => self.current = chunk,
Some(Err(message)) => return Err(std::io::Error::other(message)),
None => return Ok(0),
}
}
let n = buf.len().min(self.current.len());
buf[..n].copy_from_slice(&self.current[..n]);
self.current.advance(n);
Ok(n)
}
}
/// Download `url_path` (an uncompressed tar of `z/x/y.ext`) and install it as `block`.
pub(crate) async fn fetch_site_block(
site: &Site,
url_path: String,
block: IndexBlock,
set_dir: PathBuf,
reporter: Arc<Reporter>,
cancel: CancellationToken,
) -> Result<()> {
let (response, url) = site.get(&url_path, None, &cancel).await?;
let (tx, rx) = tokio::sync::mpsc::channel::<std::result::Result<Bytes, String>>(16);
let extractor = tokio::task::spawn_blocking({
let (url, cancel) = (url.clone(), cancel.clone());
move || extract_tar(ChannelReader { rx, current: Bytes::new() }, &url, &block, &set_dir, &reporter, &cancel)
});
let feeder = async {
let mut stream = response.bytes_stream();
loop {
let next = tokio::select! {
_ = cancel.cancelled() => {
let _ = tx.send(Err("cancelled".into())).await;
return Some(Error::Cancelled);
}
next = stream.next() => next,
};
match next {
Some(Ok(chunk)) => {
if tx.send(Ok(chunk)).await.is_err() {
return None; // the extractor is done (tar end reached, or it failed)
}
}
Some(Err(e)) => {
let message = format!("body cut short: {e}");
let _ = tx.send(Err(message.clone())).await;
return Some(Error::Http { url: url.clone(), status: None, message });
}
None => return None,
}
}
};
let feed_error = feeder.await;
drop(tx);
let extracted = match extractor.await {
Ok(result) => result,
Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()),
Err(_) => Err(Error::Cancelled),
};
match extracted {
Ok(()) => Ok(()),
// A network error or a cancel explains a broken tar better than the tar error does.
Err(e) => Err(feed_error.unwrap_or(e)),
}
}
fn extract_tar<R: Read>(
reader: R,
what: &str,
block: &IndexBlock,
set_dir: &Path,
reporter: &Reporter,
cancel: &CancellationToken,
) -> Result<()> {
let staging = Staging::create(set_dir)?;
let tar_error = |e: std::io::Error| Error::integrity(IntegrityKind::TarEntry, what, format!("malformed tar stream: {e}"));
let mut archive = tar::Archive::new(reader);
let mut rows: Vec<TileRow> = Vec::new();
let mut buf = vec![0u8; TILE_BUF];
for entry in archive.entries().map_err(tar_error)? {
if cancel.is_cancelled() {
return Err(Error::Cancelled);
}
let mut entry = entry.map_err(tar_error)?;
let name = String::from_utf8_lossy(&entry.path_bytes()).into_owned();
let kind = entry.header().entry_type();
let parsed = if kind.is_file() || kind == tar::EntryType::Continuous { parse_tile_name(&name) } else { None };
let (z, x, y, ext) = parsed.ok_or_else(|| {
Error::integrity(IntegrityKind::TarEntry, what, format!("unexpected tar entry {name:?}"))
})?;
if block_of(z, x, y) != (block.z, block.bx, block.by) {
return Err(Error::integrity(IntegrityKind::TarEntry, what, format!("tile {name} is outside this block")));
}
let target = tile_path(&staging.path, z, x, y, &ext);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(Error::io(parent))?;
}
let (size, sha) = copy_hashing(&mut entry, &target, &mut buf, reporter, cancel, &tar_error)?;
rows.push((z, x, y, ext, size, sha));
}
finish_block(what, block, &rows, &staging.path, set_dir)
}
// ---- drive: journal + tile store --------------------------------------------------
pub(crate) struct DriveBlock<'a> {
pub journal: &'a Journal,
/// `<drive>/tiles/<style>/<dataset>`.
pub set_root: &'a Path,
pub style: &'a str,
pub dataset: &'a str,
pub journal_seq: u64,
}
/// Blocking: copy one block's tiles (journal rows at `seq <= journal_seq`) from the drive.
pub(crate) fn copy_drive_block(
drive: &DriveBlock<'_>,
what: &str,
block: &IndexBlock,
set_dir: &Path,
reporter: &Reporter,
cancel: &CancellationToken,
) -> Result<()> {
let journal_rows = drive.journal.block_rows(drive.style, drive.dataset, block.z, block.bx, block.by, drive.journal_seq)?;
let mut expected: Vec<TileRow> = Vec::with_capacity(journal_rows.len());
for (z, x, y, ext, size, sha) in journal_rows {
let unsafe_row = || {
Error::integrity(IntegrityKind::TarEntry, what, format!("the drive's tile journal holds an unsafe tile {z}/{x}/{y}{ext:?}"))
};
if z < 0 || x < 0 || y < 0 || size < 0 {
return Err(unsafe_row());
}
let (z, x, y, size) = (z as u64, x as u64, y as u64, size as u64);
// The same name rule the site path applies to tar entries.
if parse_tile_name(&tile_relpath(z, x, y, &ext)) != Some((z, x, y, ext.clone())) {
return Err(unsafe_row());
}
expected.push((z, x, y, ext, size, sha));
}
// The update service's pre-stream check (its 409): the journal must match the index.
let total: u64 = expected.iter().map(|row| row.4).sum();
let journal_digest = block_digest(&expected);
if expected.len() as u64 != block.files || total != block.bytes || journal_digest != block.digest {
return Err(Error::integrity(
IntegrityKind::BlockDigest,
what,
format!(
"the drive's tile journal does not match the signed index: {} tiles, {total} bytes, digest {journal_digest}; \
index says {} tiles, {} bytes, digest {}",
expected.len(),
block.files,
block.bytes,
block.digest
),
));
}
let staging = Staging::create(set_dir)?;
let mut rows: Vec<TileRow> = Vec::with_capacity(expected.len());
let mut buf = vec![0u8; TILE_BUF];
for (z, x, y, ext, _, _) in &expected {
if cancel.is_cancelled() {
return Err(Error::Cancelled);
}
let source = tile_path(drive.set_root, *z, *x, *y, ext);
let mut file = std::fs::File::open(&source).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::NotFound { what: source.display().to_string() }
} else {
Error::Io { path: source.clone(), source: e }
}
})?;
let target = tile_path(&staging.path, *z, *x, *y, ext);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(Error::io(parent))?;
}
let read_error = |e: std::io::Error| Error::Io { path: source.clone(), source: e };
let (size, sha) = copy_hashing(&mut file, &target, &mut buf, reporter, cancel, &read_error)?;
rows.push((*z, *x, *y, ext.clone(), size, sha));
}
finish_block(what, block, &rows, &staging.path, set_dir)
}
+136
View File
@@ -0,0 +1,136 @@
//! Tile blocks, block digests and frozen tile indexes (the Python module `odian_packs.tiles`).
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::canonical::canonical_bytes;
use crate::error::{Error, Result};
use crate::hashing::hex;
use crate::manifest::BLOCK_SHIFT;
/// One tile as the digest sees it: `(z, x, y, ext, size, sha256)`.
pub type TileRow = (u64, u64, u64, String, u64, String);
pub fn block_of(z: u64, x: u64, y: u64) -> (u64, u64, u64) {
(z, x >> BLOCK_SHIFT, y >> BLOCK_SHIFT)
}
/// `"{z}/{x}/{y}{ext} {size} {sha256}\n"`.
pub fn tile_line(z: u64, x: u64, y: u64, ext: &str, size: u64, sha256: &str) -> String {
format!("{z}/{x}/{y}{ext} {size} {sha256}\n")
}
pub fn tile_relpath(z: u64, x: u64, y: u64, ext: &str) -> String {
format!("{z}/{x}/{y}{ext}")
}
/// Digest of tile rows; sorts them by `(z, x, y, ext)` first, like Python.
pub fn block_digest(rows: &[TileRow]) -> String {
let mut sorted: Vec<&TileRow> = rows.iter().collect();
sorted.sort_by(|a, b| (a.0, a.1, a.2, a.3.as_bytes()).cmp(&(b.0, b.1, b.2, b.3.as_bytes())));
let mut digest = Sha256::new();
for (z, x, y, ext, size, sha) in sorted {
digest.update(tile_line(*z, *x, *y, ext, *size, sha).as_bytes());
}
hex(&digest.finalize())
}
/// sha256 of an index document's canonical JSON.
pub fn index_sha256(index: &Value) -> Result<String> {
Ok(hex(&Sha256::digest(canonical_bytes(index)?)))
}
/// `(z, x, y, ext)` of a tile name `z/x/y.ext` (the installer's tar-entry rule:
/// ASCII digits, and an extension of 1 to 8 ASCII letters or digits).
pub fn parse_tile_name(name: &str) -> Option<(u64, u64, u64, String)> {
let mut parts = name.split('/');
let (zs, xs, last) = (parts.next()?, parts.next()?, parts.next()?);
if parts.next().is_some() {
return None;
}
let dot = last.find('.')?;
let (ys, ext) = last.split_at(dot);
let ext_body = &ext[1..];
if ext_body.is_empty() || ext_body.len() > 8 || !ext_body.bytes().all(|b| b.is_ascii_alphanumeric()) {
return None;
}
Some((digits(zs)?, digits(xs)?, digits(ys)?, ext.to_string()))
}
fn digits(s: &str) -> Option<u64> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
// Leading zeros are allowed (Python's int() takes them); the value is what counts.
let trimmed = s.trim_start_matches('0');
if trimmed.is_empty() {
return Some(0);
}
trimmed.parse().ok()
}
/// One block of a frozen tile index.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IndexBlock {
pub z: u64,
pub bx: u64,
pub by: u64,
pub files: u64,
pub bytes: u64,
pub digest: String,
}
impl IndexBlock {
/// The key recorded in `.blocks-done.json`: `"{z}/{bx}/{by}:{digest}"`.
pub fn done_key(&self) -> String {
format!("{}/{}/{}:{}", self.z, self.bx, self.by, self.digest)
}
}
/// A frozen tile index document (format 1).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TileIndex {
pub format: u64,
pub style: String,
pub dataset: String,
pub block_shift: u64,
pub journal_seq: u64,
pub files: u64,
pub bytes: u64,
pub blocks: Vec<IndexBlock>,
}
/// Parse index bytes and check them against the manifest's `index_sha256`.
pub fn verify_index(raw: &[u8], expected_sha256: &str, what: &str) -> Result<TileIndex> {
let value: Value = serde_json::from_slice(raw)
.map_err(|e| Error::manifest(format!("{what}: tile index is not valid JSON: {e}")))?;
let got = index_sha256(&value)?;
if got != expected_sha256 {
return Err(Error::integrity(
crate::error::IntegrityKind::IndexHash,
what,
format!("index sha256 {got}, the signed manifest says {expected_sha256}"),
));
}
let index: TileIndex = serde_json::from_value(value)
.map_err(|e| Error::manifest(format!("{what}: signed tile index is malformed: {e}")))?;
if index.format != 1 || index.block_shift != BLOCK_SHIFT as u64 {
return Err(Error::manifest(format!("{what}: tile index must be format 1 with block_shift {BLOCK_SHIFT}")));
}
Ok(index)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tile_names() {
assert_eq!(parse_tile_name("7/63/5.png"), Some((7, 63, 5, ".png".into())));
assert_eq!(parse_tile_name("07/1/2.pbf"), Some((7, 1, 2, ".pbf".into())));
for bad in ["7/1/2", "7/1/2.", "7/1/2.tar.gz", "7/1/x.png", "7/-1/2.png", "7/1/2/3.png", "../1/2.png", "7/1/2.abcdefghi"] {
assert_eq!(parse_tile_name(bad), None, "{bad}");
}
}
}
+207
View File
@@ -0,0 +1,207 @@
//! Cross-language fixtures: every expected value was computed by the Python reference
//! implementation (`tests/fixtures/gen_fixtures.py`); Rust must reproduce or verify it.
use std::path::PathBuf;
use base64::Engine;
use ed25519_dalek::{Signer, SigningKey};
use odian_packs::hashing::{hex, sha256_hex};
use odian_packs::manifest::{chunk_count, chunks_digest, signing_payload};
use odian_packs::signing::load_public_key;
use odian_packs::tiles::{block_digest, block_of, index_sha256, tile_line, verify_index, TileRow};
use odian_packs::{bundled_public_key, canonical_bytes, safe_relpath, validate, Error, IntegrityKind, VerifiedManifest};
use serde_json::Value;
fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("fixtures").join(name)
}
fn read(name: &str) -> Vec<u8> {
std::fs::read(fixture_path(name)).unwrap_or_else(|e| panic!("fixture {name}: {e}"))
}
fn load(name: &str) -> Value {
serde_json::from_slice(&read(name)).unwrap()
}
fn s<'a>(v: &'a Value, key: &str) -> &'a str {
v[key].as_str().unwrap_or_else(|| panic!("{key} is not a string in {v}"))
}
fn is_signature_error(result: &Result<VerifiedManifest, Error>) -> bool {
matches!(result, Err(Error::Integrity { kind: IntegrityKind::Signature, .. }))
}
#[test]
fn canonical_bytes_match_python() {
let doc = load("canonical.json");
let cases = doc["cases"].as_array().unwrap();
assert!(cases.len() >= 30);
for case in cases {
let value: Value = serde_json::from_str(s(case, "input")).unwrap();
let got = hex(&canonical_bytes(&value).unwrap());
assert_eq!(got, s(case, "canonical_hex"), "case {}", s(case, "name"));
}
}
#[test]
fn canonical_refuses_non_integer_numbers() {
let doc = load("canonical.json");
for case in doc["rust_rejects"].as_array().unwrap() {
let value: Value = serde_json::from_str(s(case, "input")).unwrap();
assert!(
matches!(canonical_bytes(&value), Err(Error::Manifest(_))),
"case {} should be refused",
s(case, "name")
);
}
}
#[test]
fn signed_manifests_verify_like_python() {
let doc = load("signing.json");
let public = s(&doc, "public_key");
let cases = doc["cases"].as_array().unwrap();
assert_eq!(cases.len(), 5);
for case in cases {
let file = s(case, "file");
let result = VerifiedManifest::from_bytes(read(file), public);
match s(case, "python") {
"ok" => {
let verified = result.unwrap_or_else(|e| panic!("{file}: {e}"));
assert_eq!(verified.raw(), read(file).as_slice(), "raw bytes are kept verbatim");
assert_eq!(verified.version(), "0.51.0");
}
_ => assert!(is_signature_error(&result), "{file}: expected a signature error, got {result:?}"),
}
}
// Signed by a different key.
assert_eq!(s(&doc, "signed_with_other_key_verdict"), "error");
let other = VerifiedManifest::from_bytes(read("signed_manifest.utf8.json"), s(&doc, "other_public_key"));
assert!(is_signature_error(&other));
}
#[test]
fn signing_payload_and_signature_match_python() {
let doc = load("signing.json");
let signed = load("signed_manifest.utf8.json");
let payload = signing_payload(&signed).unwrap();
assert_eq!(hex(&payload), s(&doc, "payload_hex"));
// Ed25519 is deterministic: the same key over the same payload gives Python's signature.
let raw: [u8; 32] = (0u8..32).collect::<Vec<_>>().try_into().unwrap();
assert_eq!(hex(&raw), s(&doc, "private_key_raw_hex"));
let key = SigningKey::from_bytes(&raw);
let signature = base64::engine::general_purpose::STANDARD.encode(key.sign(&payload).to_bytes());
assert_eq!(format!("ed25519:{signature}"), s(&doc, "signature"));
let public = base64::engine::general_purpose::STANDARD.encode(key.verifying_key().to_bytes());
assert_eq!(format!("ed25519:{public}"), s(&doc, "public_key"));
}
#[test]
fn public_key_text_rules() {
let doc = load("signing.json");
let public = s(&doc, "public_key");
assert!(load_public_key(&format!(" {public}\n")).is_ok());
for bad in ["", "rsa:AAAA", "ed25519:AAAA", "ed25519:not base64!", &public.replace("ed25519:", "")] {
assert!(load_public_key(bad).is_err(), "{bad:?}");
}
}
#[test]
fn relpaths_match_python() {
let doc = load("relpaths.json");
let cases = doc["cases"].as_array().unwrap();
assert!(cases.len() >= 60);
for case in cases {
let path = s(case, "path");
assert_eq!(safe_relpath(path).is_ok(), s(case, "python") == "ok", "path {path:?}: python {}", s(case, "python_error"));
}
}
#[test]
fn validate_matches_python_except_documented_quirks() {
let doc = load("validate.json");
let cases = doc["cases"].as_array().unwrap();
let mut quirks = 0;
for case in cases {
let name = s(case, "name");
let value: Value = serde_json::from_str(s(case, "manifest_json")).unwrap();
// The installer's parse path: canonical form (no floats) first, then validate.
let rust = canonical_bytes(&value).and_then(|_| validate(&value));
let expect_ok = s(case, "rust_expect") == "ok";
assert_eq!(rust.is_ok(), expect_ok, "case {name}: rust {rust:?}, python {}", s(case, "python_error"));
if !case["quirk"].is_null() {
quirks += 1;
assert_eq!(s(case, "python"), "ok", "a quirk case is one Python accepts: {name}");
}
}
// The Python library was brought to parity (anchors, ASCII digits, integers and floats),
// so a regenerated fixture must show no case where the two validators disagree.
assert!(cases.len() >= 50 && quirks == 0, "{} cases, {quirks} quirks", cases.len());
}
#[test]
fn hashes_match_python() {
let doc = load("hashes.json");
let row = |v: &Value| -> TileRow {
(v[0].as_u64().unwrap(), v[1].as_u64().unwrap(), v[2].as_u64().unwrap(), v[3].as_str().unwrap().into(),
v[4].as_u64().unwrap(), v[5].as_str().unwrap().into())
};
let rows: Vec<TileRow> = doc["block_digest"]["rows"].as_array().unwrap().iter().map(row).collect();
assert_eq!(block_digest(&rows), s(&doc["block_digest"], "digest"));
assert_eq!(block_digest(&[]), s(&doc, "block_digest_empty"));
for line in doc["tile_lines"].as_array().unwrap() {
let (z, x, y, ext, size, sha) = row(&line["row"]);
assert_eq!(tile_line(z, x, y, &ext, size, &sha), s(line, "line"));
}
for case in doc["block_of"].as_array().unwrap() {
let n: Vec<u64> = case.as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
assert_eq!(block_of(n[0], n[1], n[2]), (n[3], n[4], n[5]));
}
let chunks: Vec<String> = serde_json::from_value(doc["chunks_digest"]["chunks"].clone()).unwrap();
assert_eq!(chunks_digest(&chunks), s(&doc["chunks_digest"], "digest"));
assert_eq!(chunks_digest::<&str>(&[]), s(&doc, "chunks_digest_empty"));
for case in doc["chunk_count"].as_array().unwrap() {
let n: Vec<u64> = case.as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
assert_eq!(chunk_count(n[0], n[1]), n[2]);
}
let index = &doc["index"];
let text = s(index, "text");
assert_eq!(index_sha256(&serde_json::from_str(text).unwrap()).unwrap(), s(index, "index_sha256"));
let parsed = verify_index(text.as_bytes(), s(index, "index_sha256"), "fixture index").unwrap();
assert_eq!(parsed.blocks.len(), 2);
let wrong = verify_index(text.as_bytes(), &"0".repeat(64), "fixture index");
assert!(matches!(wrong, Err(Error::Integrity { kind: IntegrityKind::IndexHash, .. })));
}
#[test]
fn real_published_manifest_verifies_with_the_bundled_key() {
let hashes = load("real/0.51.0.hashes.json");
let raw = read("real/0.51.0.manifest.json");
assert_eq!(raw.len() as u64, hashes["raw_len"].as_u64().unwrap());
assert_eq!(sha256_hex(&raw), s(&hashes, "raw_sha256"));
assert_eq!(bundled_public_key(), s(&hashes, "release_public_key"));
let verified = VerifiedManifest::from_bytes(raw.clone(), bundled_public_key()).expect("the real release verifies");
assert_eq!(verified.version(), s(&hashes, "version"));
let canonical = canonical_bytes(verified.value()).unwrap();
assert_eq!(canonical.len() as u64, hashes["canonical_len"].as_u64().unwrap());
assert_eq!(sha256_hex(&canonical), s(&hashes, "canonical_sha256"));
assert_eq!(sha256_hex(&signing_payload(verified.value()).unwrap()), s(&hashes, "payload_sha256"));
let m = verified.manifest();
assert_eq!(m.app.files.len() as u64, hashes["app_files"].as_u64().unwrap());
assert!(m.db.is_some() && m.tiles.is_some() && m.src.as_ref().is_some_and(|s| s.enabled));
// One changed character anywhere breaks it.
let text = String::from_utf8(raw).unwrap();
let released = verified.manifest().released_at.clone();
let mut bumped = released.clone().into_bytes();
let last_digit = bumped.iter().rposition(u8::is_ascii_digit).unwrap();
bumped[last_digit] = if bumped[last_digit] == b'9' { b'0' } else { bumped[last_digit] + 1 };
let tampered = text.replacen(&released, std::str::from_utf8(&bumped).unwrap(), 1);
assert!(is_signature_error(&VerifiedManifest::from_bytes(tampered.into_bytes(), bundled_public_key())));
// And the test key does not vouch for it.
let test_key = load("signing.json");
assert!(is_signature_error(&VerifiedManifest::from_bytes(read("real/0.51.0.manifest.json"), s(&test_key, "public_key"))));
}
+557
View File
@@ -0,0 +1,557 @@
//! End to end against a local stub site: `tests/stub_site.py` builds a small signed pack
//! store with the Python reference code (odian_packs) and serves it with the real update
//! service (`odian_update.api.create_app`) plus nginx-like Range routes. The same store is
//! also installed as an ODIAN drive.
//!
//! These tests spawn Python, so they are `#[ignore]`d by default. They need Python 3 with
//! fastapi, uvicorn, httpx and cryptography, and the ODIAN_MCP_Server sources:
//!
//! ```text
//! ODIAN_PACKS_PYTHONPATH=C:/Users/JFEXP_D/Documents/Gitea/ODIAN_MCP_Server/src \
//! cargo test -p odian-packs --test e2e_site -- --ignored
//! ```
//!
//! `ODIAN_PACKS_PYTHON` picks the interpreter (default `python`).
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::sync::{Arc, Mutex};
use odian_packs::hashing::sha256_hex;
use odian_packs::{
bundled_public_key, current_manifest, fetch_notes, install, version_manifest, Error, InstallOptions, IntegrityKind,
Progress, Source, Stage, Summary,
};
use serde_json::Value;
use tokio_util::sync::CancellationToken;
const CHUNK: usize = 1024 * 1024;
const NOTES: &str = "# ODIAN 0.51.0\n\nPremier pack signé.\n";
fn python() -> String {
std::env::var("ODIAN_PACKS_PYTHON").unwrap_or_else(|_| "python".into())
}
fn pythonpath() -> String {
std::env::var("ODIAN_PACKS_PYTHONPATH")
.expect("ODIAN_PACKS_PYTHONPATH must point at ODIAN_MCP_Server/src (see the header of tests/e2e_site.rs)")
}
struct Stub {
child: Child,
stdin: Option<ChildStdin>,
_stdout: BufReader<ChildStdout>,
info: Value,
dir: tempfile::TempDir,
}
impl Stub {
fn start(args: &[&str]) -> Stub {
let dir = tempfile::Builder::new().prefix("odian-packs-e2e-").tempdir().unwrap();
let log_path = dir.path().join("stub.log");
let log = std::fs::File::create(&log_path).unwrap();
let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("stub_site.py");
let mut child = Command::new(python())
.arg(script)
.arg("--root")
.arg(dir.path().join("store"))
.args(args)
.env("PYTHONPATH", pythonpath())
.env("PYTHONDONTWRITEBYTECODE", "1")
.env("PYTHONIOENCODING", "utf-8")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(log)
.spawn()
.expect("spawn python");
let stdin = child.stdin.take();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
let mut line = String::new();
let _ = stdout.read_line(&mut line);
if line.trim().is_empty() {
let _ = child.kill();
let _ = child.wait();
panic!("stub site did not start:\n{}", std::fs::read_to_string(&log_path).unwrap_or_default());
}
let info: Value = serde_json::from_str(&line).unwrap();
Stub { child, stdin, _stdout: stdout, info, dir }
}
fn base(&self) -> String {
self.info["base_url"].as_str().unwrap().to_string()
}
fn root(&self) -> PathBuf {
PathBuf::from(self.info["root"].as_str().unwrap())
}
fn key(&self) -> String {
self.info["public_key"].as_str().unwrap().to_string()
}
fn site(&self) -> Source {
Source::Site { base_url: self.base(), token: "good".into() }
}
fn drive(&self) -> Source {
Source::Drive { root: self.root() }
}
fn dest(&self, name: &str) -> PathBuf {
self.dir.path().join(name)
}
fn store_manifest_path(&self) -> PathBuf {
self.root().join("packs").join("versions").join("0.51.0").join("manifest.json")
}
fn store_manifest(&self) -> Value {
serde_json::from_slice(&std::fs::read(self.store_manifest_path()).unwrap()).unwrap()
}
fn app_sha(&self, install_path: &str) -> String {
let m = self.store_manifest();
let entry = m["app"]["files"].as_array().unwrap().iter().find(|f| f["path"] == install_path).unwrap();
entry["sha256"].as_str().unwrap().to_string()
}
fn app_blob(&self, install_path: &str) -> PathBuf {
self.root().join("packs").join("app").join(self.app_sha(install_path))
}
async fn ctl(&self, path: &str) -> Value {
let text = reqwest::get(format!("{}{}", self.base(), path)).await.unwrap().text().await.unwrap();
serde_json::from_str(&text).unwrap()
}
async fn requests(&self) -> Vec<(String, Option<String>)> {
let log = self.ctl("/_ctl/log").await;
log["requests"]
.as_array()
.unwrap()
.iter()
.map(|r| (r["path"].as_str().unwrap().to_string(), r["range"].as_str().map(str::to_string)))
.collect()
}
}
impl Drop for Stub {
fn drop(&mut self) {
drop(self.stdin.take()); // the stub exits when its stdin closes
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn only(stages: &[Stage]) -> InstallOptions {
InstallOptions { only: stages.to_vec(), ..InstallOptions::default() }
}
async fn install_from(source: &Source, dest: &Path, key: &str, opts: InstallOptions) -> Result<Summary, Error> {
let manifest = current_manifest(source, key).await?;
install(source, dest, &manifest, opts, |_| {}, CancellationToken::new()).await
}
/// Every file under `root`: relative path -> sha256.
fn files(root: &Path) -> BTreeMap<String, String> {
fn walk(dir: &Path, prefix: &str, out: &mut BTreeMap<String, String>) {
for entry in std::fs::read_dir(dir).unwrap() {
let entry = entry.unwrap();
let name = entry.file_name().to_string_lossy().into_owned();
let rel = if prefix.is_empty() { name } else { format!("{prefix}/{name}") };
if entry.file_type().unwrap().is_dir() {
walk(&entry.path(), &rel, out);
} else {
out.insert(rel, sha256_hex(&std::fs::read(entry.path()).unwrap()));
}
}
}
let mut out = BTreeMap::new();
walk(root, "", &mut out);
out
}
fn flip(path: &Path, offset: usize) {
let mut data = std::fs::read(path).unwrap();
data[offset] ^= 0xFF;
std::fs::write(path, data).unwrap();
}
fn integrity(result: Result<Summary, Error>, expected: IntegrityKind) -> String {
match result {
Err(Error::Integrity { kind, what, detail }) if kind == expected => format!("{what}: {detail}"),
other => panic!("expected an {expected:?} integrity error, got {other:?}"),
}
}
fn is_payload(path: &str) -> bool {
path.starts_with("/packs/") || path.starts_with("/src/") || path.contains("/block/")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn site_install_verifies_everything_then_rerun_downloads_nothing() {
let stub = Stub::start(&[]);
let (site, key, dest) = (stub.site(), stub.key(), stub.dest("install"));
let manifest = current_manifest(&site, &key).await.unwrap();
assert_eq!(manifest.raw(), std::fs::read(stub.store_manifest_path()).unwrap().as_slice());
let events: Arc<Mutex<Vec<Progress>>> = Arc::default();
let sink = events.clone();
let summary = install(&site, &dest, &manifest, InstallOptions::default(), move |p| sink.lock().unwrap().push(p), CancellationToken::new())
.await
.unwrap();
let app = summary.app.clone().unwrap();
assert_eq!((app.files, app.downloaded, app.skipped), (5, 5, 0));
let db = summary.db.clone().unwrap();
assert_eq!((db.files, db.downloaded, db.skipped), (3, 3, 0));
let src: Vec<(String, usize)> = summary.src.clone().unwrap().into_iter().map(|s| (s.path, s.chunks)).collect();
assert_eq!(src, vec![
("osm_source/world_aeroway_v1.sqlite".to_string(), 3),
("osm_source/small.bin".to_string(), 1),
("osm_source/empty.bin".to_string(), 0),
]);
let tiles = summary.tiles.clone().unwrap();
assert_eq!((tiles[0].blocks, tiles[0].downloaded, tiles[0].files), (4, 4, 8));
assert!(summary.transferred_bytes > 3 * CHUNK as u64);
// Every byte in place, in the fetch.py layout.
let store = stub.root();
let m = stub.store_manifest();
let version_dir = dest.join("versions").join("0.51.0");
for f in m["app"]["files"].as_array().unwrap() {
let path = version_dir.join(f["path"].as_str().unwrap());
assert_eq!(sha256_hex(&std::fs::read(&path).unwrap()), f["sha256"].as_str().unwrap(), "{}", path.display());
}
assert_eq!(std::fs::metadata(version_dir.join("Odian/assets/empty.conf")).unwrap().len(), 0);
for f in m["db"]["files"].as_array().unwrap() {
let path = dest.join("data").join(f["path"].as_str().unwrap());
assert_eq!(sha256_hex(&std::fs::read(&path).unwrap()), f["sha256"].as_str().unwrap());
}
for f in m["src"]["files"].as_array().unwrap() {
let rel = f["path"].as_str().unwrap();
let path = dest.join("data").join("src").join(rel);
assert_eq!(std::fs::read(&path).unwrap(), std::fs::read(store.join("src").join(rel)).unwrap());
let marker = std::fs::read_to_string(format!("{}.ok", path.display())).unwrap();
assert_eq!(marker, f["chunks_sha256"].as_str().unwrap());
assert!(!Path::new(&format!("{}.chunks.json", path.display())).exists());
}
assert_eq!(std::fs::read(version_dir.join("manifest.json")).unwrap(), manifest.raw());
let set_dir = dest.join("data/tiles/dark/test_ds");
let mut tile_files = files(&set_dir);
let done = tile_files.remove(".blocks-done.json").map(|_| std::fs::read_to_string(set_dir.join(".blocks-done.json")).unwrap()).unwrap();
let expected_tiles = ["0/0/0.png", "3/1/2.png", "7/1/2.png", "7/127/63.png", "7/3/2.jpg", "7/3/2.png", "7/63/63.pbf", "7/64/5.png"];
assert_eq!(tile_files.keys().map(String::as_str).collect::<Vec<_>>(), expected_tiles, "the late tile 7/5/5.png must not be installed");
for rel in expected_tiles {
assert_eq!(tile_files[rel], sha256_hex(&std::fs::read(store.join("tiles/dark/test_ds").join(rel)).unwrap()));
}
assert!(done.starts_with("{\"index_sha256\": \""), "Python's json.dumps layout: {done}");
let done: Value = serde_json::from_str(&done).unwrap();
assert_eq!(done["index_sha256"], m["tiles"]["sets"][0]["index_sha256"]);
assert_eq!(done["blocks"].as_array().unwrap().len(), 4);
// No request for the zero-byte file: it never needs the network.
let log = stub.requests().await;
assert!(!log.iter().any(|(p, _)| p.ends_with(&stub.app_sha("Odian/assets/empty.conf"))));
// Progress: stages in order, ending at their totals, contract-shaped JSON.
let events = events.lock().unwrap().clone();
let mut stages: Vec<Stage> = events.iter().map(|p| p.stage).collect();
stages.dedup();
assert_eq!(stages, vec![Stage::Manifest, Stage::App, Stage::Db, Stage::Src, Stage::Tiles]);
assert!(events.iter().all(|p| p.stage_count == 5));
for stage in [Stage::App, Stage::Db, Stage::Src, Stage::Tiles] {
let last = events.iter().rev().find(|p| p.stage == stage).unwrap();
assert_eq!((last.done_bytes, last.done_items), (last.total_bytes, last.total_items), "{stage:?}");
}
assert!(events.iter().any(|p| p.message.as_deref() == Some("tiles dark/test_ds: 4/4 blocks")));
let json = serde_json::to_value(&events[0]).unwrap();
assert_eq!(json["stage"], "manifest");
for field in ["stage_index", "stage_count", "done_bytes", "total_bytes", "done_items", "total_items", "bytes_per_sec", "message"] {
assert!(json.get(field).is_some(), "{field}");
}
// Re-run: nothing is downloaded again.
stub.ctl("/_ctl/reset").await;
let again = install(&site, &dest, &manifest, InstallOptions::default(), |_| {}, CancellationToken::new()).await.unwrap();
assert_eq!(again.transferred_bytes, 0);
assert_eq!(again.app.unwrap().downloaded, 0);
assert_eq!(again.db.unwrap().downloaded, 0);
assert!(again.src.unwrap().iter().all(|s| s.chunks == 0));
assert_eq!(again.tiles.unwrap()[0].downloaded, 0);
let log = stub.requests().await;
assert!(log.iter().all(|(p, _)| !is_payload(p)), "{log:?}");
assert_eq!(fetch_notes(&site, "0.51.0").await.unwrap(), NOTES);
assert_eq!(version_manifest(&site, "0.51.0", &key).await.unwrap().raw(), manifest.raw());
assert!(matches!(version_manifest(&site, "9.9.9", &key).await, Err(Error::NotFound { .. })));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn wrong_token_is_unauthorized_and_edited_manifest_is_a_signature_error() {
let stub = Stub::start(&[]);
let key = stub.key();
let bad = Source::Site { base_url: stub.base(), token: "bad".into() };
assert!(matches!(current_manifest(&bad, &key).await, Err(Error::Unauthorized { status: 401, .. })));
let manifest = current_manifest(&stub.site(), &key).await.unwrap();
let dest = stub.dest("install");
let result = install(&bad, &dest, &manifest, only(&[Stage::App]), |_| {}, CancellationToken::new()).await;
assert!(matches!(result, Err(Error::Unauthorized { .. })), "{result:?}");
assert!(!dest.join("versions/0.51.0/manifest.json").exists());
// The stub's manifest is signed by the test key, not the release key.
let release = current_manifest(&stub.site(), bundled_public_key()).await;
assert!(matches!(release, Err(Error::Integrity { kind: IntegrityKind::Signature, .. })));
let path = stub.store_manifest_path();
let text = std::fs::read_to_string(&path).unwrap();
let edited = text.replacen("\"released_at\": \"2026-09-15T12:00:00Z\"", "\"released_at\": \"2026-09-15T12:00:01Z\"", 1);
assert_ne!(text, edited);
std::fs::write(&path, edited).unwrap();
let result = current_manifest(&stub.site(), &key).await;
assert!(matches!(result, Err(Error::Integrity { kind: IntegrityKind::Signature, .. })), "{result:?}");
std::fs::remove_file(stub.root().join("packs/versions/CURRENT")).unwrap();
assert!(matches!(current_manifest(&stub.site(), &key).await, Err(Error::NotFound { .. })));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn flipped_app_byte_is_an_integrity_error() {
let stub = Stub::start(&[]);
let (site, key, dest) = (stub.site(), stub.key(), stub.dest("install"));
let manifest = current_manifest(&site, &key).await.unwrap();
let sha = stub.app_sha("Odian/Odian.exe");
flip(&stub.app_blob("Odian/Odian.exe"), 1234);
stub.ctl("/_ctl/reset").await;
let detail = integrity(install(&site, &dest, &manifest, only(&[Stage::App]), |_| {}, CancellationToken::new()).await, IntegrityKind::FileHash);
assert!(detail.contains(&sha), "{detail}");
let gets = stub.requests().await.iter().filter(|(p, _)| p.ends_with(&sha)).count();
assert_eq!(gets, 2, "one clean retry, then give up");
let target = dest.join("versions/0.51.0/Odian/Odian.exe");
assert!(!target.exists() && !Path::new(&format!("{}.part", target.display())).exists());
assert!(!dest.join("versions/0.51.0/manifest.json").exists());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn flipped_tile_byte_is_an_integrity_error() {
let stub = Stub::start(&[]);
let (site, key, dest) = (stub.site(), stub.key(), stub.dest("install"));
flip(&stub.root().join("tiles/dark/test_ds/7/63/63.pbf"), 100);
let detail = integrity(install_from(&site, &dest, &key, only(&[Stage::Tiles])).await, IntegrityKind::BlockDigest);
assert!(detail.contains("block/7/0/0.tar"), "{detail}");
let set_dir = dest.join("data/tiles/dark/test_ds");
assert!(!set_dir.join("7/63/63.pbf").exists() && !set_dir.join("7/1/2.png").exists(), "nothing of a bad block moves in");
if let Ok(done) = std::fs::read_to_string(set_dir.join(".blocks-done.json")) {
assert!(!done.contains("\"7/0/0:"), "{done}");
}
assert!(!dest.join("versions/0.51.0/manifest.json").exists());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn flipped_source_chunk_byte_is_an_integrity_error_and_the_rest_resumes() {
let stub = Stub::start(&[]);
let (site, key, dest) = (stub.site(), stub.key(), stub.dest("install"));
let original = stub.root().join("src/osm_source/world_aeroway_v1.sqlite");
flip(&original, CHUNK + 5);
let detail = integrity(install_from(&site, &dest, &key, only(&[Stage::Src])).await, IntegrityKind::ChunkHash);
assert!(detail.contains("chunk 1"), "{detail}");
let target = dest.join("data/src/osm_source/world_aeroway_v1.sqlite");
assert!(!Path::new(&format!("{}.ok", target.display())).exists());
let progress: Value = serde_json::from_str(&std::fs::read_to_string(format!("{}.chunks.json", target.display())).unwrap()).unwrap();
let verified: Vec<u64> = progress["verified"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
assert!(!verified.contains(&1), "{verified:?}");
flip(&original, CHUNK + 5); // the site is fixed
stub.ctl("/_ctl/reset").await;
let summary = install_from(&site, &dest, &key, only(&[Stage::Src])).await.unwrap();
let world = &summary.src.unwrap()[0];
assert_eq!(world.chunks + world.skipped, 3);
assert_eq!(world.skipped, verified.len());
let ranges: Vec<String> = stub
.requests()
.await
.into_iter()
.filter(|(p, _)| p.ends_with("world_aeroway_v1.sqlite"))
.map(|(_, r)| r.unwrap())
.collect();
assert_eq!(ranges.len(), world.chunks);
assert!(ranges.contains(&format!("bytes={}-{}", CHUNK, 2 * CHUNK - 1)), "{ranges:?}");
assert_eq!(std::fs::read(&target).unwrap(), std::fs::read(&original).unwrap());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn interrupted_part_resumes_with_a_range_request() {
let stub = Stub::start(&[]);
let (site, key) = (stub.site(), stub.key());
let manifest = current_manifest(&site, &key).await.unwrap();
let sha = stub.app_sha("Odian/data/big.bin");
let big = std::fs::read(stub.app_blob("Odian/data/big.bin")).unwrap();
let app_total: u64 = manifest.manifest().app.files.iter().map(|f| f.size).sum();
// A .part left by an earlier run (Python or Rust).
let dest = stub.dest("seeded");
let target = dest.join("versions/0.51.0/Odian/data/big.bin");
let part = PathBuf::from(format!("{}.part", target.display()));
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
let have = CHUNK + 1000;
std::fs::write(&part, &big[..have]).unwrap();
stub.ctl("/_ctl/reset").await;
let summary = install(&site, &dest, &manifest, only(&[Stage::App]), |_| {}, CancellationToken::new()).await.unwrap();
assert_eq!(summary.app.unwrap().downloaded, 5);
assert_eq!(summary.transferred_bytes, app_total - have as u64);
let log = stub.requests().await;
let big_requests: Vec<_> = log.iter().filter(|(p, _)| p.ends_with(&sha)).collect();
assert_eq!(big_requests.len(), 1);
assert_eq!(big_requests[0].1.as_deref(), Some(format!("bytes={have}-").as_str()));
assert_eq!(std::fs::read(&target).unwrap(), big);
assert!(!part.exists());
// A connection that really drops mid-file.
let dest = stub.dest("cut");
let target = dest.join("versions/0.51.0/Odian/data/big.bin");
let part = PathBuf::from(format!("{}.part", target.display()));
stub.ctl(&format!("/_ctl/cut?prefix=/packs/app/{sha}&after=700000&count=1")).await;
let result = install(&site, &dest, &manifest, only(&[Stage::App]), |_| {}, CancellationToken::new()).await;
assert!(matches!(result, Err(Error::Http { .. })), "{result:?}");
let have = std::fs::metadata(&part).unwrap().len();
assert!(have > 0 && have <= 700_000, "{have}");
stub.ctl("/_ctl/reset").await;
install(&site, &dest, &manifest, only(&[Stage::App]), |_| {}, CancellationToken::new()).await.unwrap();
let log = stub.requests().await;
let big_requests: Vec<_> = log.iter().filter(|(p, _)| p.ends_with(&sha)).collect();
assert_eq!(big_requests.len(), 1);
assert_eq!(big_requests[0].1.as_deref(), Some(format!("bytes={have}-").as_str()));
assert_eq!(std::fs::read(&target).unwrap(), big);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn busy_site_is_retried_with_retry_after() {
let stub = Stub::start(&[]);
let (site, key) = (stub.site(), stub.key());
stub.ctl("/_ctl/reset").await;
stub.ctl("/_ctl/fail?prefix=/v1/versions/current&status=503&count=2&retry_after=0").await;
current_manifest(&site, &key).await.unwrap();
let tries = stub.requests().await.iter().filter(|(p, _)| p == "/v1/versions/current").count();
assert_eq!(tries, 3);
stub.ctl("/_ctl/fail?prefix=/v1/versions/current&status=503&count=4&retry_after=0").await;
let result = current_manifest(&site, &key).await;
assert!(matches!(result, Err(Error::SiteBusy { attempts: 4, .. })), "{result:?}");
// A 502 on a tile block and a 504 on an app file are retried too.
stub.ctl("/_ctl/fail?prefix=/v1/versions/0.51.0/tiles/dark/test_ds/block/&status=502&count=1&retry_after=0").await;
stub.ctl("/_ctl/fail?prefix=/packs/app/&status=504&count=1&retry_after=0").await;
let summary = install_from(&site, &stub.dest("install"), &key, only(&[Stage::App, Stage::Tiles])).await.unwrap();
assert_eq!(summary.tiles.unwrap()[0].downloaded, 4);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn cancel_stops_and_keeps_verified_work() {
let stub = Stub::start(&[]);
let (site, key, dest) = (stub.site(), stub.key(), stub.dest("install"));
let manifest = current_manifest(&site, &key).await.unwrap();
let cancel = CancellationToken::new();
let trigger = cancel.clone();
let result = install(&site, &dest, &manifest, InstallOptions::default(), move |p| {
if p.stage == Stage::App && p.done_items >= 1 {
trigger.cancel();
}
}, cancel)
.await;
assert!(matches!(result, Err(Error::Cancelled)), "{result:?}");
assert!(!dest.join("versions/0.51.0/manifest.json").exists());
let summary = install(&site, &dest, &manifest, InstallOptions::default(), |_| {}, CancellationToken::new()).await.unwrap();
assert!(summary.app.unwrap().skipped >= 1);
assert!(dest.join("versions/0.51.0/manifest.json").exists());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn drive_install_equals_site_install() {
let stub = Stub::start(&[]);
let (site, drive, key) = (stub.site(), stub.drive(), stub.key());
let from_site = stub.dest("from-site");
install_from(&site, &from_site, &key, InstallOptions::default()).await.unwrap();
let manifest = current_manifest(&drive, &key).await.unwrap();
assert_eq!(manifest.raw(), std::fs::read(stub.store_manifest_path()).unwrap().as_slice());
let from_drive = stub.dest("from-drive");
let summary = install(&drive, &from_drive, &manifest, InstallOptions::default(), |_| {}, CancellationToken::new()).await.unwrap();
assert_eq!(summary.app.unwrap().downloaded, 5);
assert_eq!(summary.tiles.unwrap()[0].downloaded, 4);
assert_eq!(files(&from_site), files(&from_drive), "site and drive installs must be identical, file by file");
let again = install(&drive, &from_drive, &manifest, InstallOptions::default(), |_| {}, CancellationToken::new()).await.unwrap();
assert_eq!(again.transferred_bytes, 0);
assert_eq!(fetch_notes(&drive, "0.51.0").await.unwrap(), NOTES);
assert_eq!(version_manifest(&drive, "0.51.0", &key).await.unwrap().raw(), manifest.raw());
// The same checks guard a drive.
let tile = stub.root().join("tiles/dark/test_ds/7/64/5.png");
flip(&tile, 3);
let detail = integrity(install_from(&drive, &stub.dest("drive-tile"), &key, only(&[Stage::Tiles])).await, IntegrityKind::BlockDigest);
assert!(detail.contains("7/1/0"), "{detail}");
flip(&tile, 3);
flip(&stub.app_blob("api/odian_api.exe"), 7);
integrity(install_from(&drive, &stub.dest("drive-app"), &key, only(&[Stage::App])).await, IntegrityKind::FileHash);
}
fn python_fetch(stub: &Stub, dest: &Path) -> Value {
let code = "import json, sys\n\
from odian_packs.fetch import fetch\n\
summary = fetch(sys.argv[1], 'good', sys.argv[2], public_key=sys.argv[3], log=lambda m: None)\n\
print(json.dumps(summary))\n";
let output = Command::new(python())
.arg("-c")
.arg(code)
.arg(stub.base())
.arg(dest)
.arg(stub.key())
.env("PYTHONPATH", pythonpath())
.env("PYTHONDONTWRITEBYTECODE", "1")
.output()
.unwrap();
assert!(output.status.success(), "python fetch failed:\n{}", String::from_utf8_lossy(&output.stderr));
let stdout = String::from_utf8(output.stdout).unwrap();
serde_json::from_str(stdout.lines().last().unwrap()).unwrap()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
async fn python_and_rust_installs_are_interchangeable() {
// fetch.py cannot install a zero-byte source file (see the report), so leave it out here.
let stub = Stub::start(&["--no-empty-src"]);
let (site, key) = (stub.site(), stub.key());
let python_first = stub.dest("python-first");
let py = tokio::task::block_in_place(|| python_fetch(&stub, &python_first));
assert_eq!(py["app"]["downloaded"], 5);
let rust = install_from(&site, &python_first, &key, InstallOptions::default()).await.unwrap();
assert_eq!(rust.transferred_bytes, 0, "Rust re-verifies a Python install and downloads nothing: {rust:?}");
assert_eq!(rust.app.unwrap().skipped, 5);
let rust_first = stub.dest("rust-first");
install_from(&site, &rust_first, &key, InstallOptions::default()).await.unwrap();
let py = tokio::task::block_in_place(|| python_fetch(&stub, &rust_first));
assert_eq!(py["app"]["downloaded"], 0, "{py}");
assert_eq!(py["db"]["downloaded"], 0, "{py}");
assert!(py["src"].as_array().unwrap().iter().all(|s| s["chunks"] == 0), "{py}");
assert_eq!(py["tiles"][0]["downloaded"], 0, "{py}");
assert_eq!(files(&python_first), files(&rust_first));
}
@@ -0,0 +1,2 @@
# Fixtures are compared byte for byte with what Python produced: never convert line endings.
* -text
File diff suppressed because one or more lines are too long
+373
View File
@@ -0,0 +1,373 @@
"""Generate the cross-language fixtures the odian-packs Rust tests read.
PYTHONPATH=C:/Users/JFEXP_D/Documents/Gitea/ODIAN_MCP_Server/src \
python crates/odian-packs/tests/fixtures/gen_fixtures.py [--real-manifest PATH]
Every expected value below is computed by the reference implementation (odian_packs);
the Rust tests must reproduce or verify each one. Outputs sit next to this script:
canonical.json canonical bytes of tricky JSON inputs (+ inputs Rust refuses)
signing.json test key, verdicts; signed/tampered manifests as separate files
relpaths.json safe_relpath verdicts
validate.json validate() verdicts, with the Python quirks Rust deliberately rejects
hashes.json block_of, block_digest, chunks_digest, chunk_count, index_sha256
real/0.51.0.* the real published 0.51.0 manifest and its Python hashes
"""
import argparse
import base64
import copy
import hashlib
import json
import os
import shutil
import sys
sys.dont_write_bytecode = True
from odian_packs import manifest as mf # noqa: E402
from odian_packs import signing, tiles # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_REAL = (r"C:\Users\JFEXP_D\AppData\Local\Temp\claude\C--Users-JFEXP-D-Documents-Gitea"
r"\d92cce4d-3852-472e-a7e3-10137a35c218\scratchpad\m1\packs\versions\0.51.0\manifest.json")
def out_path(name):
path = os.path.join(HERE, *name.split("/"))
os.makedirs(os.path.dirname(path), exist_ok=True)
return path
def write_bytes(name, data):
with open(out_path(name), "wb") as f:
f.write(data)
def write_json(name, obj):
# ASCII-only fixture files: no editor or checkout can change their bytes.
write_bytes(name, (json.dumps(obj, indent=1, ensure_ascii=True) + "\n").encode("ascii"))
def sha(data):
return hashlib.sha256(data).hexdigest()
def reversed_keys(obj):
if isinstance(obj, dict):
return {k: reversed_keys(obj[k]) for k in reversed(list(obj))}
if isinstance(obj, list):
return [reversed_keys(v) for v in obj]
return obj
def verdict(fn):
try:
fn()
return "ok", ""
except Exception as exc: # noqa: BLE001 - any exception is a refusal
return "error", f"{type(exc).__name__}: {exc}"
# ---- canonical JSON -------------------------------------------------------------
def minimal_manifest():
return {"schema": 1, "product": "odian", "version": "0.51.0", "released_at": "2026-09-15T12:00:00Z",
"app": {"files": [{"path": "Odian/Odian.exe", "size": 3, "sha256": sha(b"exe")}]}}
def canonical_fixture():
control = "".join(chr(i) for i in range(0x20)) + "\x7f\x80\x9f\u2028\u2029"
nested = "leaf"
for i in range(40):
nested = {f"k{i % 3}": [nested, i]} if i % 2 else [nested, {"i": i}]
values = [
("empty_object", {}),
("empty_array", []),
("empty_string", ""),
("null", None),
("true", True),
("false", False),
("empty_containers_nested", {"b": [], "a": {}, "c": [[], {}], "d": ""}),
("non_ascii", {"fr": "données été à Noël", "de": "Straße", "zh": "中文路径", "ar": "مرحبا", "ru": "Привет"}),
("emoji", {"emoji": "😀🚀 and 👨\u200d👩\u200d👧 zwj", "flag": "🇫🇷"}),
("control_chars", {"all": control}),
("quotes_backslashes", {"q": 'say "hi"', "b": "C:\\Odian\\x", "mixed": "\\\"\\n/<>&'"}),
("key_order_code_points", {"\ufb01": 1, "😀": 2, "a": 3, "B": 4, "": 5, "é": 6, "aa": 7, "a\u0000": 8,
"\uffff": 9, "Z": 10, "\u00ff": 11}),
("nested_sorting", {"z": {"y": [{"b": [], "a": {}}, [], {}], "x": None}, "a": [True, False, None, "", 0],
"m": {"k2": {"b": 1, "a": 2}, "k1": 3}}),
("big_ints", {"n": [0, -1, 1, 2**31, 2**32, 2**53, 2**53 + 1, 2**63 - 1, -2**63, 2**64 - 1, 2437523419]}),
("deep_nesting", nested),
("minimal_manifest", minimal_manifest()),
]
cases = []
for name, value in values:
for style, text in (
("ascii_escaped_reversed", json.dumps(reversed_keys(value), indent=2, ensure_ascii=True)),
("raw_utf8", json.dumps(value, ensure_ascii=False)),
):
assert json.loads(text) == value
cases.append({"name": f"{name}/{style}", "input": text,
"canonical_hex": mf.canonical_bytes(json.loads(text)).hex()})
rejects = []
for name, text in (("float", "1.5"), ("float_integral", "1.0"), ("exponent", "1e3"), ("negative_zero", "-0"),
("negative_zero_float", "-0.0"), ("above_u64", "18446744073709551616"),
("below_i64", "-9223372036854775809"), ("float_in_object", '{"a": 2.5}'),
("float_in_array", "[1, 0.1]")):
rejects.append({"name": name, "input": text,
"python_canonical_hex": mf.canonical_bytes(json.loads(text)).hex()})
write_json("canonical.json", {"cases": cases, "rust_rejects": rejects})
# ---- signing ---------------------------------------------------------------------
def full_manifest():
chunks = [sha(b"c0"), sha(b"c1")]
return {
"schema": 1, "product": "odian", "version": "0.51.0", "released_at": "2026-09-15T12:00:00Z",
"min_launcher": "0.1.0", "notes": "notes.md",
"app": {"files": [
{"path": "Odian/Odian.exe", "size": 3, "sha256": sha(b"exe")},
{"path": "Odian/assets/empty.conf", "size": 0, "sha256": sha(b"")},
{"path": "Odian/données/carte été.png", "size": 5, "sha256": sha(b"carte")},
]},
"db": {"schema_version": 0, "snapshot": "20260915T101500Z", "files": [
{"section": "pre-data", "path": "db/20260915T101500Z/pre-data.dump", "size": 1, "sha256": sha(b"p")},
{"section": "data", "table": "public.t1", "path": "db/20260915T101500Z/data/public.t1.dump",
"size": 1, "sha256": sha(b"d")},
]},
"tiles": {"block_shift": 6, "sets": [
{"style": "dark", "dataset": "ds", "journal_seq": 9, "blocks": 2, "files": 3, "bytes": 2437523419,
"index": "tiles/dark.ds.index.json", "index_sha256": "a" * 64},
]},
"src": {"enabled": True, "chunk_bytes": 10, "files": [
{"path": "osm/x.sqlite", "size": 15, "chunks": chunks, "chunks_sha256": mf.chunks_digest(chunks)},
]},
"extra_note": "unknown keys are signed too: \"quoted\" \\ \u0001 😀",
}
def signing_fixture():
raw_key = bytes(range(32))
key = signing.private_key_from_raw(raw_key)
public = signing.public_key_text(key.public_key())
other = signing.public_key_text(signing.private_key_from_raw(bytes(range(1, 33))).public_key())
signed = signing.sign(mf.validate(full_manifest()), key)
tampered = copy.deepcopy(signed)
tampered["app"]["files"][0]["size"] = 4
flipped = copy.deepcopy(signed)
sig = bytearray(base64.b64decode(signed["signature"][len("ed25519:"):]))
sig[0] ^= 0xFF
flipped["signature"] = "ed25519:" + base64.b64encode(bytes(sig)).decode("ascii")
unsigned = {k: v for k, v in signed.items() if k != "signature"}
files = {
"signed_manifest.utf8.json": (json.dumps(signed, indent=2, sort_keys=True, ensure_ascii=False) + "\n").encode("utf-8"),
"signed_manifest.ascii.json": (json.dumps(signed, indent=2, sort_keys=True) + "\n").encode("ascii"),
"tampered_manifest.json": (json.dumps(tampered, indent=2, sort_keys=True) + "\n").encode("ascii"),
"signature_flipped_manifest.json": (json.dumps(flipped, indent=2, sort_keys=True) + "\n").encode("ascii"),
"unsigned_manifest.json": (json.dumps(unsigned, indent=2, sort_keys=True) + "\n").encode("ascii"),
}
cases = []
for name, data in files.items():
write_bytes(name, data)
doc = json.loads(data.decode("utf-8"))
ok, error = verdict(lambda: (mf.validate(doc), signing.verify(doc, public)))
cases.append({"file": name, "python": ok, "python_error": error})
other_ok, _ = verdict(lambda: signing.verify(signed, other))
write_json("signing.json", {
"private_key_raw_hex": raw_key.hex(),
"public_key": public,
"other_public_key": other,
"signed_with_other_key_verdict": other_ok,
"signature": signed["signature"],
"payload_hex": mf.signing_payload(signed).hex(),
"cases": cases,
})
# ---- safe_relpath -----------------------------------------------------------------
RELPATHS = [
"Odian/x.exe", "db/20260915T082529Z/data/public.odb_air_timeline_2026_02.dump",
"../a", "a/../b", "/a", "C:/a", "a\\b", "a//b", "", "./a", "a/", "a/.", ".", "..", "a/..",
"Odian/Odian.exe:hidden", "a/b.", "a/b ", "CON", "con", "a/CoN.TxT", "a/aux.txt", "a/Lpt1.log", "NUL.tar.gz",
"a/nul..x", "a/prn", "a/prn_x", "a/com¹", "a/COM².txt", "a/lpt³", "a/lpt0", "a/com0", "a/comA", "a/com10",
"a/con .txt", "a/b\x00c", "a/b\nc", "a/b\x1fc", "a/b\x7fc", "a/b\u0085c", "a/b\u2028c", "a/b?c", "a/b*c",
'a/b"c', "a/b|c", "a/b<c", "a/b>c", "a/console.txt", "a/auxiliary", "a/.hidden", "a/b c.txt", " a/b",
"a/ b", "a/b.c.", "données/été.png", "中文/路径.txt", "a/😀.png", "a/b#c%20d", "a/aux\u00a0",
"a/com\u0660", "a/\u212aon",
]
def relpath_fixture():
cases = []
for path in RELPATHS:
ok, error = verdict(lambda: mf.safe_relpath(path))
cases.append({"path": path, "python": ok, "python_error": error})
write_json("relpaths.json", {"cases": cases})
# ---- validate ---------------------------------------------------------------------
def validate_fixture():
def app0(m):
return m["app"]["files"][0]
def setp(fn):
def mutate(m):
fn(m)
return m
return mutate
empty_src = {"path": "e.bin", "size": 0, "chunks": [], "chunks_sha256": mf.chunks_digest([])}
# (name, mutation, reason Rust deliberately differs from Python, or None)
mutations = [
("valid_full", lambda m: m, None),
("valid_minimal", lambda m: minimal_manifest(), None),
("no_app_section", setp(lambda m: m.pop("app")), None),
("app_null", setp(lambda m: m.update(app=None)), None),
("app_files_missing", setp(lambda m: m.update(app={})), None),
("app_entry_not_object", setp(lambda m: m.update(app={"files": [None]})), None),
("db_entry_not_object", setp(lambda m: m["db"].update(files=[1])), None),
("tiles_set_not_object", setp(lambda m: m["tiles"].update(sets=[1])), None),
("src_file_not_object", setp(lambda m: m["src"].update(files=["x"])), None),
("manifest_not_object", lambda m: [1], None),
("schema_2", setp(lambda m: m.update(schema=2)), None),
("schema_true", setp(lambda m: m.update(schema=True)),
"Python's `== 1` accepts True; Rust wants the integer 1"),
("schema_float", setp(lambda m: m.update(schema=1.0)),
"Python's `== 1` accepts 1.0; Rust refuses floats anywhere"),
("product_wrong", setp(lambda m: m.update(product="other")), None),
("version_two_parts", setp(lambda m: m.update(version="1.0")), None),
("version_leading_zero", setp(lambda m: m.update(version="01.2.3")), None),
("version_int", setp(lambda m: m.update(version=5)), None),
("version_missing", setp(lambda m: m.pop("version")), None),
("version_trailing_newline", setp(lambda m: m.update(version="0.51.0\n")),
"Python's regex `$` matches before a final newline; Rust refuses it"),
("version_unicode_digit", setp(lambda m: m.update(version="0.51.\u0663")),
"Python's `\\d` matches any Unicode digit; Rust takes ASCII digits only"),
("released_at_int", setp(lambda m: m.update(released_at=5)), None),
("min_launcher_bad", setp(lambda m: m.update(min_launcher="0.1")), None),
("notes_traversal", setp(lambda m: m.update(notes="../notes.md")), None),
("notes_int", setp(lambda m: m.update(notes=5)), None),
("sha_upper", setp(lambda m: app0(m).update(sha256="A" * 64)), None),
("sha_short", setp(lambda m: app0(m).update(sha256="a" * 63)), None),
("sha_trailing_newline", setp(lambda m: app0(m).update(sha256="a" * 64 + "\n")),
"Python's regex `$` matches before a final newline; Rust refuses it"),
("dup_app_path_case", setp(lambda m: m["app"]["files"].append({**app0(m), "path": "ODIAN/odian.EXE"})), None),
("dup_app_path_unicode_case", setp(lambda m: m["app"]["files"].extend([
{"path": "Odian/Ä.txt", "size": 1, "sha256": "b" * 64},
{"path": "odian/ä.txt", "size": 1, "sha256": "c" * 64}])), None),
("app_path_traversal", setp(lambda m: app0(m).update(path="../evil.exe")), None),
("size_negative", setp(lambda m: app0(m).update(size=-1)), None),
("size_bool", setp(lambda m: app0(m).update(size=True)), None),
("size_float", setp(lambda m: app0(m).update(size=3.0)), None),
("size_string", setp(lambda m: app0(m).update(size="3")), None),
("size_above_u64", setp(lambda m: app0(m).update(size=2**64)),
"Rust numbers are 64-bit; a size above u64::MAX is refused"),
("db_empty_files", setp(lambda m: m["db"].update(files=[])), None),
("db_bad_section", setp(lambda m: m["db"]["files"][0].update(section="schema")), None),
("db_data_without_table", setp(lambda m: m["db"]["files"][1].pop("table")), None),
("db_data_empty_table", setp(lambda m: m["db"]["files"][1].update(table="")), None),
("db_path_outside_snapshot", setp(lambda m: m["db"]["files"][0].update(path="db/20260101T000000Z/x.dump")), None),
("db_snapshot_bad", setp(lambda m: m["db"].update(snapshot="2026-09-15")), None),
("db_schema_version_bool", setp(lambda m: m["db"].update(schema_version=True)), None),
("tiles_block_shift_7", setp(lambda m: m["tiles"].update(block_shift=7)), None),
("tiles_block_shift_float", setp(lambda m: m["tiles"].update(block_shift=6.0)),
"Python's `== 6` accepts 6.0; Rust refuses floats anywhere"),
("tiles_bad_style", setp(lambda m: m["tiles"]["sets"][0].update(style="da rk")), None),
("tiles_style_trailing_newline", setp(lambda m: m["tiles"]["sets"][0].update(
style="dark\n", index="tiles/dark\n.ds.index.json")),
"Python's regex `$` matches before a final newline; Rust refuses it"),
("tiles_dup_set", setp(lambda m: m["tiles"]["sets"].append(dict(m["tiles"]["sets"][0]))), None),
("tiles_wrong_index_name", setp(lambda m: m["tiles"]["sets"][0].update(index="tiles/other.json")), None),
("tiles_count_negative", setp(lambda m: m["tiles"]["sets"][0].update(journal_seq=-1)), None),
("src_enabled_int", setp(lambda m: m["src"].update(enabled=1)), None),
("src_chunk_bytes_zero", setp(lambda m: m["src"].update(chunk_bytes=0)), None),
("src_chunk_count_wrong", setp(lambda m: m["src"]["files"][0].update(size=25)), None),
("src_chunks_sha_wrong", setp(lambda m: m["src"]["files"][0].update(chunks_sha256="c" * 64)), None),
("src_empty_file", setp(lambda m: m["src"]["files"].append(empty_src)), None),
("signature_not_ed25519", setp(lambda m: m.update(signature="rsa:xx")), None),
("signature_int", setp(lambda m: m.update(signature=5)), None),
("unknown_keys", setp(lambda m: m.update(future={"x": [1, "y"]})), None),
("unknown_float_key", setp(lambda m: m.update(ratio=0.5)),
"Rust refuses floats anywhere in a manifest, even under keys validate() ignores"),
]
cases = []
for name, mutate, quirk in mutations:
doc = mutate(copy.deepcopy(full_manifest()))
ok, error = verdict(lambda: mf.validate(doc))
if ok == "error":
quirk = None # Python refuses it as well: not a difference
rust = "error" if quirk else ok
cases.append({"name": name, "manifest_json": json.dumps(doc, ensure_ascii=True), "python": ok,
"python_error": error, "rust_expect": rust, "quirk": quirk})
write_json("validate.json", {"cases": cases})
# ---- hashes -----------------------------------------------------------------------
def hashes_fixture():
rows = [(7, 63, 5, ".png", 13, sha(b"b")), (7, 0, 0, ".png", 13, sha(b"a")), (7, 0, 0, ".jpg", 9, sha(b"c")),
(7, 10, 2, ".png", 700, sha(b"d")), (7, 2, 10, ".png", 1, sha(b"e"))]
chunks = [sha(b"x"), sha(b"y"), sha(b"z")]
index = {"format": 1, "style": "dark", "dataset": "ds", "block_shift": 6, "journal_seq": 376157, "files": 3,
"bytes": 2437523419, "blocks": [
{"z": 0, "bx": 0, "by": 0, "files": 1, "bytes": 5321, "digest": sha(b"b0")},
{"z": 7, "bx": 1, "by": 0, "files": 2, "bytes": 2437518098, "digest": sha(b"b1")}]}
write_json("hashes.json", {
"block_digest": {"rows": [list(r) for r in rows], "digest": tiles.block_digest(rows)},
"block_digest_empty": tiles.block_digest([]),
"tile_lines": [{"row": list(r), "line": tiles.tile_line(*r).decode("ascii")} for r in rows],
"block_of": [[z, x, y, *tiles.block_of(z, x, y)] for z, x, y in
((0, 0, 0), (7, 63, 63), (7, 64, 0), (20, 1048575, 65), (3, 7, 7))],
"chunks_digest": {"chunks": chunks, "digest": mf.chunks_digest(chunks)},
"chunks_digest_empty": mf.chunks_digest([]),
"chunk_count": [[size, cb, mf.chunk_count(size, cb)] for size, cb in
((0, 10), (1, 10), (10, 10), (11, 10), (884244480, mf.CHUNK_BYTES), (2**40, mf.CHUNK_BYTES))],
"index": {"text": json.dumps(index, indent=2, sort_keys=True) + "\n", "index_sha256": tiles.index_sha256(index)},
})
# ---- the real published manifest ---------------------------------------------------
def real_fixture(source):
target = out_path("real/0.51.0.manifest.json")
if source and os.path.isfile(source):
shutil.copyfile(source, target)
with open(target, "rb") as f:
raw = f.read()
doc = json.loads(raw.decode("utf-8"))
mf.validate(doc)
signing.verify(doc, signing.bundled_public_key())
canonical = mf.canonical_bytes(doc)
write_json("real/0.51.0.hashes.json", {
"version": doc["version"],
"raw_len": len(raw),
"raw_sha256": sha(raw),
"canonical_len": len(canonical),
"canonical_sha256": sha(canonical),
"payload_sha256": sha(mf.signing_payload(doc)),
"release_public_key": signing.bundled_public_key(),
"app_files": len(doc["app"]["files"]),
})
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--real-manifest", default=DEFAULT_REAL)
args = parser.parse_args()
canonical_fixture()
signing_fixture()
relpath_fixture()
validate_fixture()
hashes_fixture()
real_fixture(args.real_manifest)
print(f"fixtures written to {HERE}")
if __name__ == "__main__":
main()
+192
View File
@@ -0,0 +1,192 @@
{
"block_digest": {
"rows": [
[
7,
63,
5,
".png",
13,
"3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d"
],
[
7,
0,
0,
".png",
13,
"ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb"
],
[
7,
0,
0,
".jpg",
9,
"2e7d2c03a9507ae265ecf5b5356885a53393a2029d241394997265a1a25aefc6"
],
[
7,
10,
2,
".png",
700,
"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4"
],
[
7,
2,
10,
".png",
1,
"3f79bb7b435b05321651daefd374cdc681dc06faa65e374e38337b88ca046dea"
]
],
"digest": "57db4d13b815dbc73ae79fd3ed093fe6c735abafd1898526db086332592f3838"
},
"block_digest_empty": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"tile_lines": [
{
"row": [
7,
63,
5,
".png",
13,
"3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d"
],
"line": "7/63/5.png 13 3e23e8160039594a33894f6564e1b1348bbd7a0088d42c4acb73eeaed59c009d\n"
},
{
"row": [
7,
0,
0,
".png",
13,
"ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb"
],
"line": "7/0/0.png 13 ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb\n"
},
{
"row": [
7,
0,
0,
".jpg",
9,
"2e7d2c03a9507ae265ecf5b5356885a53393a2029d241394997265a1a25aefc6"
],
"line": "7/0/0.jpg 9 2e7d2c03a9507ae265ecf5b5356885a53393a2029d241394997265a1a25aefc6\n"
},
{
"row": [
7,
10,
2,
".png",
700,
"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4"
],
"line": "7/10/2.png 700 18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\n"
},
{
"row": [
7,
2,
10,
".png",
1,
"3f79bb7b435b05321651daefd374cdc681dc06faa65e374e38337b88ca046dea"
],
"line": "7/2/10.png 1 3f79bb7b435b05321651daefd374cdc681dc06faa65e374e38337b88ca046dea\n"
}
],
"block_of": [
[
0,
0,
0,
0,
0,
0
],
[
7,
63,
63,
7,
0,
0
],
[
7,
64,
0,
7,
1,
0
],
[
20,
1048575,
65,
20,
16383,
1
],
[
3,
7,
7,
3,
0,
0
]
],
"chunks_digest": {
"chunks": [
"2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881",
"a1fce4363854ff888cff4b8e7875d600c2682390412a8cf79b37d0b11148b0fa",
"594e519ae499312b29433b7dd8a97ff068defcba9755b6d5d00e84c524d67b06"
],
"digest": "71d8fb1b613dcdc24ac90f01cab91b1c9cb56fd4b40a73862cf87f7636b63c31"
},
"chunks_digest_empty": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"chunk_count": [
[
0,
10,
0
],
[
1,
10,
1
],
[
10,
10,
1
],
[
11,
10,
2
],
[
884244480,
268435456,
4
],
[
1099511627776,
268435456,
4096
]
],
"index": {
"text": "{\n \"block_shift\": 6,\n \"blocks\": [\n {\n \"bx\": 0,\n \"by\": 0,\n \"bytes\": 5321,\n \"digest\": \"c02c0b965e023abee808f2b548d8d5193a8b5229be6f3121a6f16e2d41a449b3\",\n \"files\": 1,\n \"z\": 0\n },\n {\n \"bx\": 1,\n \"by\": 0,\n \"bytes\": 2437518098,\n \"digest\": \"7dc96f776c8423e57a2785489a3f9c43fb6e756876d6ad9a9cac4aa4e72ec193\",\n \"files\": 2,\n \"z\": 7\n }\n ],\n \"bytes\": 2437523419,\n \"dataset\": \"ds\",\n \"files\": 3,\n \"format\": 1,\n \"journal_seq\": 376157,\n \"style\": \"dark\"\n}\n",
"index_sha256": "02bfedeafc1aec68814eeafa4e116d0c7b94d8bbb164c8cab17a58a1241473fa"
}
}
@@ -0,0 +1,10 @@
{
"version": "0.51.0",
"raw_len": 993204,
"raw_sha256": "a0e86b662cd228497a697d22096e0d888bea40484802a4bf393e5283cc883971",
"canonical_len": 756473,
"canonical_sha256": "901818050d56e309cedbc5adfbfafa63a30c6d9ac59d85450d9fa0681d1bbc3d",
"payload_sha256": "1be8d279322c6d16b7cd7a4e942cfc15207fca795be45bbdbe55f94c42de629f",
"release_public_key": "ed25519:DS7z/nhxZE9ffn0PKVtw5fMUBRLdQ424OE236A0tbts=",
"app_files": 4950
}
File diff suppressed because it is too large Load Diff
+309
View File
@@ -0,0 +1,309 @@
{
"cases": [
{
"path": "Odian/x.exe",
"python": "ok",
"python_error": ""
},
{
"path": "db/20260915T082529Z/data/public.odb_air_timeline_2026_02.dump",
"python": "ok",
"python_error": ""
},
{
"path": "../a",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: '../a'"
},
{
"path": "a/../b",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: 'a/../b'"
},
{
"path": "/a",
"python": "error",
"python_error": "ManifestError: path must be relative with '/' separators: '/a'"
},
{
"path": "C:/a",
"python": "error",
"python_error": "ManifestError: path part 'C:' holds a control character or one of <>:\"|?*: 'C:/a'"
},
{
"path": "a\\b",
"python": "error",
"python_error": "ManifestError: path must be relative with '/' separators: 'a\\\\b'"
},
{
"path": "a//b",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: 'a//b'"
},
{
"path": "",
"python": "error",
"python_error": "ManifestError: empty path"
},
{
"path": "./a",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: './a'"
},
{
"path": "a/",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: 'a/'"
},
{
"path": "a/.",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: 'a/.'"
},
{
"path": ".",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: '.'"
},
{
"path": "..",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: '..'"
},
{
"path": "a/..",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: 'a/..'"
},
{
"path": "Odian/Odian.exe:hidden",
"python": "error",
"python_error": "ManifestError: path part 'Odian.exe:hidden' holds a control character or one of <>:\"|?*: 'Odian/Odian.exe:hidden'"
},
{
"path": "a/b.",
"python": "error",
"python_error": "ManifestError: path part 'b.' ends with a dot or space: 'a/b.'"
},
{
"path": "a/b ",
"python": "error",
"python_error": "ManifestError: path part 'b ' ends with a dot or space: 'a/b '"
},
{
"path": "CON",
"python": "error",
"python_error": "ManifestError: path part 'CON' is a reserved Windows device name: 'CON'"
},
{
"path": "con",
"python": "error",
"python_error": "ManifestError: path part 'con' is a reserved Windows device name: 'con'"
},
{
"path": "a/CoN.TxT",
"python": "error",
"python_error": "ManifestError: path part 'CoN.TxT' is a reserved Windows device name: 'a/CoN.TxT'"
},
{
"path": "a/aux.txt",
"python": "error",
"python_error": "ManifestError: path part 'aux.txt' is a reserved Windows device name: 'a/aux.txt'"
},
{
"path": "a/Lpt1.log",
"python": "error",
"python_error": "ManifestError: path part 'Lpt1.log' is a reserved Windows device name: 'a/Lpt1.log'"
},
{
"path": "NUL.tar.gz",
"python": "error",
"python_error": "ManifestError: path part 'NUL.tar.gz' is a reserved Windows device name: 'NUL.tar.gz'"
},
{
"path": "a/nul..x",
"python": "error",
"python_error": "ManifestError: path part 'nul..x' is a reserved Windows device name: 'a/nul..x'"
},
{
"path": "a/prn",
"python": "error",
"python_error": "ManifestError: path part 'prn' is a reserved Windows device name: 'a/prn'"
},
{
"path": "a/prn_x",
"python": "ok",
"python_error": ""
},
{
"path": "a/com\u00b9",
"python": "error",
"python_error": "ManifestError: path part 'com\u00b9' is a reserved Windows device name: 'a/com\u00b9'"
},
{
"path": "a/COM\u00b2.txt",
"python": "error",
"python_error": "ManifestError: path part 'COM\u00b2.txt' is a reserved Windows device name: 'a/COM\u00b2.txt'"
},
{
"path": "a/lpt\u00b3",
"python": "error",
"python_error": "ManifestError: path part 'lpt\u00b3' is a reserved Windows device name: 'a/lpt\u00b3'"
},
{
"path": "a/lpt0",
"python": "error",
"python_error": "ManifestError: path part 'lpt0' is a reserved Windows device name: 'a/lpt0'"
},
{
"path": "a/com0",
"python": "error",
"python_error": "ManifestError: path part 'com0' is a reserved Windows device name: 'a/com0'"
},
{
"path": "a/comA",
"python": "ok",
"python_error": ""
},
{
"path": "a/com10",
"python": "ok",
"python_error": ""
},
{
"path": "a/con .txt",
"python": "ok",
"python_error": ""
},
{
"path": "a/b\u0000c",
"python": "error",
"python_error": "ManifestError: path part 'b\\x00c' holds a control character or one of <>:\"|?*: 'a/b\\x00c'"
},
{
"path": "a/b\nc",
"python": "error",
"python_error": "ManifestError: path part 'b\\nc' holds a control character or one of <>:\"|?*: 'a/b\\nc'"
},
{
"path": "a/b\u001fc",
"python": "error",
"python_error": "ManifestError: path part 'b\\x1fc' holds a control character or one of <>:\"|?*: 'a/b\\x1fc'"
},
{
"path": "a/b\u007fc",
"python": "ok",
"python_error": ""
},
{
"path": "a/b\u0085c",
"python": "ok",
"python_error": ""
},
{
"path": "a/b\u2028c",
"python": "ok",
"python_error": ""
},
{
"path": "a/b?c",
"python": "error",
"python_error": "ManifestError: path part 'b?c' holds a control character or one of <>:\"|?*: 'a/b?c'"
},
{
"path": "a/b*c",
"python": "error",
"python_error": "ManifestError: path part 'b*c' holds a control character or one of <>:\"|?*: 'a/b*c'"
},
{
"path": "a/b\"c",
"python": "error",
"python_error": "ManifestError: path part 'b\"c' holds a control character or one of <>:\"|?*: 'a/b\"c'"
},
{
"path": "a/b|c",
"python": "error",
"python_error": "ManifestError: path part 'b|c' holds a control character or one of <>:\"|?*: 'a/b|c'"
},
{
"path": "a/b<c",
"python": "error",
"python_error": "ManifestError: path part 'b<c' holds a control character or one of <>:\"|?*: 'a/b<c'"
},
{
"path": "a/b>c",
"python": "error",
"python_error": "ManifestError: path part 'b>c' holds a control character or one of <>:\"|?*: 'a/b>c'"
},
{
"path": "a/console.txt",
"python": "ok",
"python_error": ""
},
{
"path": "a/auxiliary",
"python": "ok",
"python_error": ""
},
{
"path": "a/.hidden",
"python": "ok",
"python_error": ""
},
{
"path": "a/b c.txt",
"python": "ok",
"python_error": ""
},
{
"path": " a/b",
"python": "ok",
"python_error": ""
},
{
"path": "a/ b",
"python": "ok",
"python_error": ""
},
{
"path": "a/b.c.",
"python": "error",
"python_error": "ManifestError: path part 'b.c.' ends with a dot or space: 'a/b.c.'"
},
{
"path": "donn\u00e9es/\u00e9t\u00e9.png",
"python": "ok",
"python_error": ""
},
{
"path": "\u4e2d\u6587/\u8def\u5f84.txt",
"python": "ok",
"python_error": ""
},
{
"path": "a/\ud83d\ude00.png",
"python": "ok",
"python_error": ""
},
{
"path": "a/b#c%20d",
"python": "ok",
"python_error": ""
},
{
"path": "a/aux\u00a0",
"python": "ok",
"python_error": ""
},
{
"path": "a/com\u0660",
"python": "ok",
"python_error": ""
},
{
"path": "a/\u212aon",
"python": "ok",
"python_error": ""
}
]
}
@@ -0,0 +1,78 @@
{
"app": {
"files": [
{
"path": "Odian/Odian.exe",
"sha256": "9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b",
"size": 3
},
{
"path": "Odian/assets/empty.conf",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 0
},
{
"path": "Odian/donn\u00e9es/carte \u00e9t\u00e9.png",
"sha256": "d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9",
"size": 5
}
]
},
"db": {
"files": [
{
"path": "db/20260915T101500Z/pre-data.dump",
"section": "pre-data",
"sha256": "148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940",
"size": 1
},
{
"path": "db/20260915T101500Z/data/public.t1.dump",
"section": "data",
"sha256": "18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4",
"size": 1,
"table": "public.t1"
}
],
"schema_version": 0,
"snapshot": "20260915T101500Z"
},
"extra_note": "unknown keys are signed too: \"quoted\" \\ \u0001 \ud83d\ude00",
"min_launcher": "0.1.0",
"notes": "notes.md",
"product": "odian",
"released_at": "2026-09-15T12:00:00Z",
"schema": 1,
"signature": "ed25519:lgdqpswT6p5WOOy74T3v1JA7aWsJUMmRjc+MFDJSMw7W/GG2ec0BuWaeVL+YkMP+WtaF3tOS8Lh43TlD2Y7UDw==",
"src": {
"chunk_bytes": 10,
"enabled": true,
"files": [
{
"chunks": [
"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3",
"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982"
],
"chunks_sha256": "9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867",
"path": "osm/x.sqlite",
"size": 15
}
]
},
"tiles": {
"block_shift": 6,
"sets": [
{
"blocks": 2,
"bytes": 2437523419,
"dataset": "ds",
"files": 3,
"index": "tiles/dark.ds.index.json",
"index_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"journal_seq": 9,
"style": "dark"
}
]
},
"version": "0.51.0"
}
@@ -0,0 +1,78 @@
{
"app": {
"files": [
{
"path": "Odian/Odian.exe",
"sha256": "9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b",
"size": 3
},
{
"path": "Odian/assets/empty.conf",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 0
},
{
"path": "Odian/donn\u00e9es/carte \u00e9t\u00e9.png",
"sha256": "d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9",
"size": 5
}
]
},
"db": {
"files": [
{
"path": "db/20260915T101500Z/pre-data.dump",
"section": "pre-data",
"sha256": "148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940",
"size": 1
},
{
"path": "db/20260915T101500Z/data/public.t1.dump",
"section": "data",
"sha256": "18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4",
"size": 1,
"table": "public.t1"
}
],
"schema_version": 0,
"snapshot": "20260915T101500Z"
},
"extra_note": "unknown keys are signed too: \"quoted\" \\ \u0001 \ud83d\ude00",
"min_launcher": "0.1.0",
"notes": "notes.md",
"product": "odian",
"released_at": "2026-09-15T12:00:00Z",
"schema": 1,
"signature": "ed25519:aQdqpswT6p5WOOy74T3v1JA7aWsJUMmRjc+MFDJSMw7W/GG2ec0BuWaeVL+YkMP+WtaF3tOS8Lh43TlD2Y7UDw==",
"src": {
"chunk_bytes": 10,
"enabled": true,
"files": [
{
"chunks": [
"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3",
"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982"
],
"chunks_sha256": "9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867",
"path": "osm/x.sqlite",
"size": 15
}
]
},
"tiles": {
"block_shift": 6,
"sets": [
{
"blocks": 2,
"bytes": 2437523419,
"dataset": "ds",
"files": 3,
"index": "tiles/dark.ds.index.json",
"index_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"journal_seq": 9,
"style": "dark"
}
]
},
"version": "0.51.0"
}
@@ -0,0 +1,78 @@
{
"app": {
"files": [
{
"path": "Odian/Odian.exe",
"sha256": "9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b",
"size": 3
},
{
"path": "Odian/assets/empty.conf",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 0
},
{
"path": "Odian/données/carte été.png",
"sha256": "d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9",
"size": 5
}
]
},
"db": {
"files": [
{
"path": "db/20260915T101500Z/pre-data.dump",
"section": "pre-data",
"sha256": "148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940",
"size": 1
},
{
"path": "db/20260915T101500Z/data/public.t1.dump",
"section": "data",
"sha256": "18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4",
"size": 1,
"table": "public.t1"
}
],
"schema_version": 0,
"snapshot": "20260915T101500Z"
},
"extra_note": "unknown keys are signed too: \"quoted\" \\ \u0001 😀",
"min_launcher": "0.1.0",
"notes": "notes.md",
"product": "odian",
"released_at": "2026-09-15T12:00:00Z",
"schema": 1,
"signature": "ed25519:aQdqpswT6p5WOOy74T3v1JA7aWsJUMmRjc+MFDJSMw7W/GG2ec0BuWaeVL+YkMP+WtaF3tOS8Lh43TlD2Y7UDw==",
"src": {
"chunk_bytes": 10,
"enabled": true,
"files": [
{
"chunks": [
"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3",
"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982"
],
"chunks_sha256": "9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867",
"path": "osm/x.sqlite",
"size": 15
}
]
},
"tiles": {
"block_shift": 6,
"sets": [
{
"blocks": 2,
"bytes": 2437523419,
"dataset": "ds",
"files": 3,
"index": "tiles/dark.ds.index.json",
"index_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"journal_seq": 9,
"style": "dark"
}
]
},
"version": "0.51.0"
}
+35
View File
@@ -0,0 +1,35 @@
{
"private_key_raw_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"public_key": "ed25519:A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=",
"other_public_key": "ed25519:ebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ=",
"signed_with_other_key_verdict": "error",
"signature": "ed25519:aQdqpswT6p5WOOy74T3v1JA7aWsJUMmRjc+MFDJSMw7W/GG2ec0BuWaeVL+YkMP+WtaF3tOS8Lh43TlD2Y7UDw==",
"payload_hex": "7b22617070223a7b2266696c6573223a5b7b2270617468223a224f6469616e2f4f6469616e2e657865222c22736861323536223a2239303935626462383539333038623632616366303430333666666434616466653336366437663733376432373665623663343661653433346633383136633962222c2273697a65223a337d2c7b2270617468223a224f6469616e2f6173736574732f656d7074792e636f6e66222c22736861323536223a2265336230633434323938666331633134396166626634633839393666623932343237616534316534363439623933346361343935393931623738353262383535222c2273697a65223a307d2c7b2270617468223a224f6469616e2f646f6e6ec3a965732f636172746520c3a974c3a92e706e67222c22736861323536223a2264326331323432306239383135653037623239623132353666646436653339613934623639656431393230303966306335643761666666633939646534346539222c2273697a65223a357d5d7d2c226462223a7b2266696c6573223a5b7b2270617468223a2264622f3230323630393135543130313530305a2f7072652d646174612e64756d70222c2273656374696f6e223a227072652d64617461222c22736861323536223a2231343864653963356137613434643139653536636439616531613535346266363738343761666230633538663665313266613239616337646466636139393430222c2273697a65223a317d2c7b2270617468223a2264622f3230323630393135543130313530305a2f646174612f7075626c69632e74312e64756d70222c2273656374696f6e223a2264617461222c22736861323536223a2231386163336537333433663031363839306335313065393366393335323631313639643965336635363534333634323938333066616630393334663466386534222c2273697a65223a312c227461626c65223a227075626c69632e7431227d5d2c22736368656d615f76657273696f6e223a302c22736e617073686f74223a223230323630393135543130313530305a227d2c2265787472615f6e6f7465223a22756e6b6e6f776e206b65797320617265207369676e656420746f6f3a205c2271756f7465645c22205c5c205c753030303120f09f9880222c226d696e5f6c61756e63686572223a22302e312e30222c226e6f746573223a226e6f7465732e6d64222c2270726f64756374223a226f6469616e222c2272656c65617365645f6174223a22323032362d30392d31355431323a30303a30305a222c22736368656d61223a312c22737263223a7b226368756e6b5f6279746573223a31302c22656e61626c6564223a747275652c2266696c6573223a5b7b226368756e6b73223a5b2231323263353937303833626434333862376636643732616637356430323539343838393936343737313162383036626464326364383266613639373133646233222c2264306636333163613164646261386462336263666362396530353763646339386430333739663162656530306537356135343531343761323764616464393832225d2c226368756e6b735f736861323536223a2239636132623534313738616238653732613635303666313135633137633737623338636464356333323366363463653336653061316661343061613635383637222c2270617468223a226f736d2f782e73716c697465222c2273697a65223a31357d5d7d2c2274696c6573223a7b22626c6f636b5f7368696674223a362c2273657473223a5b7b22626c6f636b73223a322c226279746573223a323433373532333431392c2264617461736574223a226473222c2266696c6573223a332c22696e646578223a2274696c65732f6461726b2e64732e696e6465782e6a736f6e222c22696e6465785f736861323536223a2261616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161222c226a6f75726e616c5f736571223a392c227374796c65223a226461726b227d5d7d2c2276657273696f6e223a22302e35312e30227d",
"cases": [
{
"file": "signed_manifest.utf8.json",
"python": "ok",
"python_error": ""
},
{
"file": "signed_manifest.ascii.json",
"python": "ok",
"python_error": ""
},
{
"file": "tampered_manifest.json",
"python": "error",
"python_error": "SignatureError: signature does not match: manifest altered, or signed by another key"
},
{
"file": "signature_flipped_manifest.json",
"python": "error",
"python_error": "SignatureError: signature does not match: manifest altered, or signed by another key"
},
{
"file": "unsigned_manifest.json",
"python": "error",
"python_error": "SignatureError: manifest is not signed"
}
]
}
@@ -0,0 +1,78 @@
{
"app": {
"files": [
{
"path": "Odian/Odian.exe",
"sha256": "9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b",
"size": 4
},
{
"path": "Odian/assets/empty.conf",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 0
},
{
"path": "Odian/donn\u00e9es/carte \u00e9t\u00e9.png",
"sha256": "d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9",
"size": 5
}
]
},
"db": {
"files": [
{
"path": "db/20260915T101500Z/pre-data.dump",
"section": "pre-data",
"sha256": "148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940",
"size": 1
},
{
"path": "db/20260915T101500Z/data/public.t1.dump",
"section": "data",
"sha256": "18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4",
"size": 1,
"table": "public.t1"
}
],
"schema_version": 0,
"snapshot": "20260915T101500Z"
},
"extra_note": "unknown keys are signed too: \"quoted\" \\ \u0001 \ud83d\ude00",
"min_launcher": "0.1.0",
"notes": "notes.md",
"product": "odian",
"released_at": "2026-09-15T12:00:00Z",
"schema": 1,
"signature": "ed25519:aQdqpswT6p5WOOy74T3v1JA7aWsJUMmRjc+MFDJSMw7W/GG2ec0BuWaeVL+YkMP+WtaF3tOS8Lh43TlD2Y7UDw==",
"src": {
"chunk_bytes": 10,
"enabled": true,
"files": [
{
"chunks": [
"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3",
"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982"
],
"chunks_sha256": "9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867",
"path": "osm/x.sqlite",
"size": 15
}
]
},
"tiles": {
"block_shift": 6,
"sets": [
{
"blocks": 2,
"bytes": 2437523419,
"dataset": "ds",
"files": 3,
"index": "tiles/dark.ds.index.json",
"index_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"journal_seq": 9,
"style": "dark"
}
]
},
"version": "0.51.0"
}
@@ -0,0 +1,77 @@
{
"app": {
"files": [
{
"path": "Odian/Odian.exe",
"sha256": "9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b",
"size": 3
},
{
"path": "Odian/assets/empty.conf",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"size": 0
},
{
"path": "Odian/donn\u00e9es/carte \u00e9t\u00e9.png",
"sha256": "d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9",
"size": 5
}
]
},
"db": {
"files": [
{
"path": "db/20260915T101500Z/pre-data.dump",
"section": "pre-data",
"sha256": "148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940",
"size": 1
},
{
"path": "db/20260915T101500Z/data/public.t1.dump",
"section": "data",
"sha256": "18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4",
"size": 1,
"table": "public.t1"
}
],
"schema_version": 0,
"snapshot": "20260915T101500Z"
},
"extra_note": "unknown keys are signed too: \"quoted\" \\ \u0001 \ud83d\ude00",
"min_launcher": "0.1.0",
"notes": "notes.md",
"product": "odian",
"released_at": "2026-09-15T12:00:00Z",
"schema": 1,
"src": {
"chunk_bytes": 10,
"enabled": true,
"files": [
{
"chunks": [
"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3",
"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982"
],
"chunks_sha256": "9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867",
"path": "osm/x.sqlite",
"size": 15
}
]
},
"tiles": {
"block_shift": 6,
"sets": [
{
"blocks": 2,
"bytes": 2437523419,
"dataset": "ds",
"files": 3,
"index": "tiles/dark.ds.index.json",
"index_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"journal_seq": 9,
"style": "dark"
}
]
},
"version": "0.51.0"
}
+468
View File
@@ -0,0 +1,468 @@
{
"cases": [
{
"name": "valid_full",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "ok",
"python_error": "",
"rust_expect": "ok",
"quirk": null
},
{
"name": "valid_minimal",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}]}}",
"python": "ok",
"python_error": "",
"rust_expect": "ok",
"quirk": null
},
{
"name": "no_app_section",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "ok",
"python_error": "",
"rust_expect": "ok",
"quirk": null
},
{
"name": "app_null",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": null, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app must be an object",
"rust_expect": "error",
"quirk": null
},
{
"name": "app_files_missing",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files must be a list",
"rust_expect": "error",
"quirk": null
},
{
"name": "app_entry_not_object",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [null]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files[0] must be an object",
"rust_expect": "error",
"quirk": null
},
{
"name": "db_entry_not_object",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [1]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: db.files[0] must be an object",
"rust_expect": "error",
"quirk": null
},
{
"name": "tiles_set_not_object",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [1]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: tiles.sets[0] must be an object",
"rust_expect": "error",
"quirk": null
},
{
"name": "src_file_not_object",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [\"x\"]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: src.files[0] must be an object",
"rust_expect": "error",
"quirk": null
},
{
"name": "manifest_not_object",
"manifest_json": "[1]",
"python": "error",
"python_error": "ManifestError: manifest must be a JSON object",
"rust_expect": "error",
"quirk": null
},
{
"name": "schema_2",
"manifest_json": "{\"schema\": 2, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: schema must be 1",
"rust_expect": "error",
"quirk": null
},
{
"name": "schema_true",
"manifest_json": "{\"schema\": true, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: schema must be 1",
"rust_expect": "error",
"quirk": null
},
{
"name": "schema_float",
"manifest_json": "{\"schema\": 1.0, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: manifest.schema: numbers must be integers, got 1.0",
"rust_expect": "error",
"quirk": null
},
{
"name": "product_wrong",
"manifest_json": "{\"schema\": 1, \"product\": \"other\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: product must be 'odian'",
"rust_expect": "error",
"quirk": null
},
{
"name": "version_two_parts",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"1.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: version must be MAJOR.MINOR.PATCH, got '1.0'",
"rust_expect": "error",
"quirk": null
},
{
"name": "version_leading_zero",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"01.2.3\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: version must be MAJOR.MINOR.PATCH, got '01.2.3'",
"rust_expect": "error",
"quirk": null
},
{
"name": "version_int",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": 5, \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: version must be a MAJOR.MINOR.PATCH string, got 5",
"rust_expect": "error",
"quirk": null
},
{
"name": "version_missing",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: version must be MAJOR.MINOR.PATCH, got ''",
"rust_expect": "error",
"quirk": null
},
{
"name": "version_trailing_newline",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\\n\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: version must be MAJOR.MINOR.PATCH, got '0.51.0\\n'",
"rust_expect": "error",
"quirk": null
},
{
"name": "version_unicode_digit",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.\\u0663\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: version must be MAJOR.MINOR.PATCH, got '0.51.\u0663'",
"rust_expect": "error",
"quirk": null
},
{
"name": "released_at_int",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": 5, \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: released_at must be an ISO-8601 string",
"rust_expect": "error",
"quirk": null
},
{
"name": "min_launcher_bad",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: version must be MAJOR.MINOR.PATCH, got '0.1'",
"rust_expect": "error",
"quirk": null
},
{
"name": "notes_traversal",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"../notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: '../notes.md'",
"rust_expect": "error",
"quirk": null
},
{
"name": "notes_int",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": 5, \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: empty path",
"rust_expect": "error",
"quirk": null
},
{
"name": "sha_upper",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files[0]: sha256 must be 64 lowercase hex chars",
"rust_expect": "error",
"quirk": null
},
{
"name": "sha_short",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files[0]: sha256 must be 64 lowercase hex chars",
"rust_expect": "error",
"quirk": null
},
{
"name": "sha_trailing_newline",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\\n\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files[0]: sha256 must be 64 lowercase hex chars",
"rust_expect": "error",
"quirk": null
},
{
"name": "dup_app_path_case",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}, {\"path\": \"ODIAN/odian.EXE\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files[3]: duplicate path 'ODIAN/odian.EXE'",
"rust_expect": "error",
"quirk": null
},
{
"name": "dup_app_path_unicode_case",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}, {\"path\": \"Odian/\\u00c4.txt\", \"size\": 1, \"sha256\": \"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"}, {\"path\": \"odian/\\u00e4.txt\", \"size\": 1, \"sha256\": \"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files[4]: duplicate path 'odian/\u00e4.txt'",
"rust_expect": "error",
"quirk": null
},
{
"name": "app_path_traversal",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"../evil.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: path must not contain empty, '.' or '..' parts: '../evil.exe'",
"rust_expect": "error",
"quirk": null
},
{
"name": "size_negative",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": -1, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files[0]: size must be a non-negative integer",
"rust_expect": "error",
"quirk": null
},
{
"name": "size_bool",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": true, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files[0]: size must be a non-negative integer",
"rust_expect": "error",
"quirk": null
},
{
"name": "size_float",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3.0, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: manifest.app.files[0].size: numbers must be integers, got 3.0",
"rust_expect": "error",
"quirk": null
},
{
"name": "size_string",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": \"3\", \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: app.files[0]: size must be a non-negative integer",
"rust_expect": "error",
"quirk": null
},
{
"name": "size_above_u64",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 18446744073709551616, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: manifest.app.files[0].size: 18446744073709551616 does not fit in 64 bits",
"rust_expect": "error",
"quirk": null
},
{
"name": "db_empty_files",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": []}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: db.files must not be empty",
"rust_expect": "error",
"quirk": null
},
{
"name": "db_bad_section",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"schema\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: db.files[0]: section must be one of ('pre-data', 'data', 'sequences', 'post-data')",
"rust_expect": "error",
"quirk": null
},
{
"name": "db_data_without_table",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: db.files[1]: data dumps name their table",
"rust_expect": "error",
"quirk": null
},
{
"name": "db_data_empty_table",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: db.files[1]: data dumps name their table",
"rust_expect": "error",
"quirk": null
},
{
"name": "db_path_outside_snapshot",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260101T000000Z/x.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: db.files[0]: path must live under db/20260915T101500Z/",
"rust_expect": "error",
"quirk": null
},
{
"name": "db_snapshot_bad",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"2026-09-15\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: db.snapshot must look like 20260915T101500Z",
"rust_expect": "error",
"quirk": null
},
{
"name": "db_schema_version_bool",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": true, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: db.schema_version must be a non-negative integer",
"rust_expect": "error",
"quirk": null
},
{
"name": "tiles_block_shift_7",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 7, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: tiles.block_shift must be 6",
"rust_expect": "error",
"quirk": null
},
{
"name": "tiles_block_shift_float",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6.0, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: manifest.tiles.block_shift: numbers must be integers, got 6.0",
"rust_expect": "error",
"quirk": null
},
{
"name": "tiles_bad_style",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"da rk\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: tiles.sets[0]: bad style",
"rust_expect": "error",
"quirk": null
},
{
"name": "tiles_style_trailing_newline",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\\n\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark\\n.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: tiles.sets[0]: bad style",
"rust_expect": "error",
"quirk": null
},
{
"name": "tiles_dup_set",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}, {\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: tiles.sets[1]: duplicate tile set dark/ds",
"rust_expect": "error",
"quirk": null
},
{
"name": "tiles_wrong_index_name",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/other.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: tiles.sets[0]: index must be 'tiles/dark.ds.index.json'",
"rust_expect": "error",
"quirk": null
},
{
"name": "tiles_count_negative",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": -1, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: tiles.sets[0]: journal_seq must be a non-negative integer",
"rust_expect": "error",
"quirk": null
},
{
"name": "src_enabled_int",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": 1, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: src.enabled must be true or false",
"rust_expect": "error",
"quirk": null
},
{
"name": "src_chunk_bytes_zero",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 0, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: src.chunk_bytes must be a positive integer",
"rust_expect": "error",
"quirk": null
},
{
"name": "src_chunk_count_wrong",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 25, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: src.files[0]: 2 chunks for 25 bytes at 10 per chunk",
"rust_expect": "error",
"quirk": null
},
{
"name": "src_chunks_sha_wrong",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "error",
"python_error": "ManifestError: src.files[0]: chunks_sha256 does not match chunks",
"rust_expect": "error",
"quirk": null
},
{
"name": "src_empty_file",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}, {\"path\": \"e.bin\", \"size\": 0, \"chunks\": [], \"chunks_sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\"}",
"python": "ok",
"python_error": "",
"rust_expect": "ok",
"quirk": null
},
{
"name": "signature_not_ed25519",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\", \"signature\": \"rsa:xx\"}",
"python": "error",
"python_error": "ManifestError: signature must look like 'ed25519:<base64>'",
"rust_expect": "error",
"quirk": null
},
{
"name": "signature_int",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\", \"signature\": 5}",
"python": "error",
"python_error": "ManifestError: signature must look like 'ed25519:<base64>'",
"rust_expect": "error",
"quirk": null
},
{
"name": "unknown_keys",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\", \"future\": {\"x\": [1, \"y\"]}}",
"python": "ok",
"python_error": "",
"rust_expect": "ok",
"quirk": null
},
{
"name": "unknown_float_key",
"manifest_json": "{\"schema\": 1, \"product\": \"odian\", \"version\": \"0.51.0\", \"released_at\": \"2026-09-15T12:00:00Z\", \"min_launcher\": \"0.1.0\", \"notes\": \"notes.md\", \"app\": {\"files\": [{\"path\": \"Odian/Odian.exe\", \"size\": 3, \"sha256\": \"9095bdb859308b62acf04036ffd4adfe366d7f737d276eb6c46ae434f3816c9b\"}, {\"path\": \"Odian/assets/empty.conf\", \"size\": 0, \"sha256\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"}, {\"path\": \"Odian/donn\\u00e9es/carte \\u00e9t\\u00e9.png\", \"size\": 5, \"sha256\": \"d2c12420b9815e07b29b1256fdd6e39a94b69ed192009f0c5d7afffc99de44e9\"}]}, \"db\": {\"schema_version\": 0, \"snapshot\": \"20260915T101500Z\", \"files\": [{\"section\": \"pre-data\", \"path\": \"db/20260915T101500Z/pre-data.dump\", \"size\": 1, \"sha256\": \"148de9c5a7a44d19e56cd9ae1a554bf67847afb0c58f6e12fa29ac7ddfca9940\"}, {\"section\": \"data\", \"table\": \"public.t1\", \"path\": \"db/20260915T101500Z/data/public.t1.dump\", \"size\": 1, \"sha256\": \"18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4\"}]}, \"tiles\": {\"block_shift\": 6, \"sets\": [{\"style\": \"dark\", \"dataset\": \"ds\", \"journal_seq\": 9, \"blocks\": 2, \"files\": 3, \"bytes\": 2437523419, \"index\": \"tiles/dark.ds.index.json\", \"index_sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}]}, \"src\": {\"enabled\": true, \"chunk_bytes\": 10, \"files\": [{\"path\": \"osm/x.sqlite\", \"size\": 15, \"chunks\": [\"122c597083bd438b7f6d72af75d025948899647711b806bdd2cd82fa69713db3\", \"d0f631ca1ddba8db3bcfcb9e057cdc98d0379f1bee00e75a545147a27dadd982\"], \"chunks_sha256\": \"9ca2b54178ab8e72a6506f115c17c77b38cdd5c323f64ce36e0a1fa40aa65867\"}]}, \"extra_note\": \"unknown keys are signed too: \\\"quoted\\\" \\\\ \\u0001 \\ud83d\\ude00\", \"ratio\": 0.5}",
"python": "error",
"python_error": "ManifestError: manifest.ratio: numbers must be integers, got 0.5",
"rust_expect": "error",
"quirk": null
}
]
}
+339
View File
@@ -0,0 +1,339 @@
"""Local stub of an ODIAN site for the odian-packs Rust end-to-end tests.
PYTHONPATH=<ODIAN_MCP_Server>/src python stub_site.py --root DIR [--no-empty-src]
Builds a small signed pack store under DIR with odian_packs itself. The layout is the
site store layout, which is also the ODIAN drive layout:
DIR/packs/versions/CURRENT
DIR/packs/versions/0.51.0/{manifest.json, notes.md, tiles/dark.test_ds.index.json}
DIR/packs/app/<sha256> DIR/packs/db/<snapshot>/...
DIR/tiles/dark/test_ds/<z>/<x>/<y><ext>, DIR/tiles/tiles.db
DIR/src/...
It then serves DIR on 127.0.0.1 (a free port) with ``odian_update.api.create_app`` and a
stub token validator (token ``good``), plus nginx-like Range routes for /packs/app,
/packs/db and /src that check the same Bearer token. It prints one JSON line (port, test
public key, token, layout) and serves until its stdin closes.
Test-only control routes (no token):
GET /_ctl/log every request seen: path, range
POST /_ctl/reset clear the log and all rules
POST /_ctl/fail?prefix=P&status=503&count=N&retry_after=0
POST /_ctl/cut?prefix=P&after=BYTES&count=N send BYTES of the body, then drop the connection
"""
import argparse
import hashlib
import json
import os
import re
import shutil
import socket
import sys
import threading
import urllib.parse
sys.dont_write_bytecode = True
VERSION = "0.51.0"
STYLE = "dark"
DATASET = "test_ds"
SNAPSHOT = "20260915T101500Z"
TOKEN = "good"
CHUNK = 1024 * 1024
TEST_KEY_RAW = bytes(range(32))
APP_FILES = {
"Odian/Odian.exe": 200 * 1024,
"Odian/assets/empty.conf": 0,
"Odian/data/big.bin": 3 * CHUNK + 17,
"Odian/données/carte été.png": 1000,
"api/odian_api.exe": 50 * 1024,
}
DB_FILES = [
("pre-data", None, "pre-data.dump", 5000),
("data", "public.t1", "data/public.t1.dump", 300 * 1024),
("post-data", None, "post-data.dump", 2048),
]
# (z, x, y, ext, size). Blocks (0,0,0), (3,0,0), (7,0,0), (7,1,0). The 8-byte tile is
# below MIN_TILE_BYTES and never journaled.
TILES = [
(0, 0, 0, ".png", 5000),
(3, 1, 2, ".png", 40),
(7, 1, 2, ".png", 700),
(7, 3, 2, ".jpg", 512),
(7, 3, 2, ".png", 9),
(7, 63, 63, ".pbf", 300 * 1024),
(7, 2, 2, ".png", 8),
(7, 64, 5, ".png", 20),
(7, 127, 63, ".png", 1000),
]
# Journaled AFTER the index is frozen, inside block (7,0,0): no install may contain it.
LATE_TILE = (7, 5, 5, ".png", 64)
SRC_FILES = {"osm_source/world_aeroway_v1.sqlite": 2 * CHUNK + 123, "osm_source/small.bin": 100}
EMPTY_SRC = "osm_source/empty.bin"
def blob(label, size):
return hashlib.shake_256(label.encode("utf-8")).digest(size) if size else b""
def write(path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(data)
def write_json(path, obj):
"""Like odian_packs.cli._write_json: pretty, sorted, ASCII, trailing newline."""
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8", newline="\n") as f:
json.dump(obj, f, indent=2, sort_keys=True)
f.write("\n")
def join(root, rel):
return os.path.join(root, *rel.split("/"))
def build(root, empty_src):
from odian_packs import appstore, hashing, signing, tiles
from odian_packs import manifest as mf
packs = os.path.join(root, "packs")
tiles_root = os.path.join(root, "tiles")
src_root = os.path.join(root, "src")
stage = os.path.join(root, "stage")
for rel, size in APP_FILES.items():
write(join(os.path.join(stage, "app"), rel), blob("app:" + rel, size))
app = appstore.store_tree(os.path.join(stage, "app"), packs, workers=2)
shutil.rmtree(stage)
db_files = []
for section, table, rel, size in DB_FILES:
path_rel = f"db/{SNAPSHOT}/{rel}"
full = join(packs, path_rel)
write(full, blob("db:" + rel, size))
got_size, got_sha = hashing.sha256_file(full)
entry = {"section": section, "path": path_rel, "size": got_size, "sha256": got_sha}
if table:
entry["table"] = table
db_files.append(entry)
set_root = os.path.join(tiles_root, STYLE, DATASET)
journal = os.path.join(tiles_root, "tiles.db")
for z, x, y, ext, size in TILES:
write(os.path.join(set_root, str(z), str(x), f"{y}{ext}"), blob(f"tile:{z}/{x}/{y}{ext}", size))
tiles.import_tree(set_root, STYLE, DATASET, journal, workers=2)
index = tiles.build_index(journal, STYLE, DATASET)
version_dir = os.path.join(packs, "versions", VERSION)
index_name = mf.tile_index_name(STYLE, DATASET)
write_json(join(version_dir, index_name), index)
z, x, y, ext, size = LATE_TILE
write(os.path.join(set_root, str(z), str(x), f"{y}{ext}"), blob(f"tile:{z}/{x}/{y}{ext}", size))
tiles.import_tree(set_root, STYLE, DATASET, journal, workers=2)
src_items = dict(SRC_FILES)
if empty_src:
src_items[EMPTY_SRC] = 0
src_files = []
for rel, size in src_items.items():
full = join(src_root, rel)
write(full, blob("src:" + rel, size))
hashed = hashing.hash_chunks(full, CHUNK)
src_files.append({"path": rel, "size": hashed["size"], "chunks": hashed["chunks"],
"chunks_sha256": hashed["chunks_sha256"]})
manifest = {
"schema": 1, "product": "odian", "version": VERSION, "released_at": "2026-09-15T12:00:00Z",
"min_launcher": "0.1.0", "notes": "notes.md",
"app": app,
"db": {"schema_version": 0, "snapshot": SNAPSHOT, "files": db_files},
"tiles": {"block_shift": 6, "sets": [{
"style": STYLE, "dataset": DATASET, "journal_seq": index["journal_seq"],
"blocks": len(index["blocks"]), "files": index["files"], "bytes": index["bytes"],
"index": index_name, "index_sha256": tiles.index_sha256(index)}]},
"src": {"enabled": True, "chunk_bytes": CHUNK, "files": src_files},
}
signed = signing.sign(mf.validate(manifest), signing.private_key_from_raw(TEST_KEY_RAW))
write_json(os.path.join(version_dir, "manifest.json"), signed)
write(os.path.join(version_dir, "notes.md"), "# ODIAN 0.51.0\n\nPremier pack signé.\n".encode("utf-8"))
write(os.path.join(packs, "versions", "CURRENT"), f"{VERSION}\n".encode("ascii"))
def serve(root, info):
import uvicorn
from fastapi import Request
from fastapi.responses import Response
from odian_packs.manifest import ManifestError, safe_relpath
from odian_update.api import create_app
from odian_update.auth import AuthInvalid, Caller, bearer_token
from odian_update.config import Settings
class StubValidator:
async def validate(self, token):
if token != TOKEN:
raise AuthInvalid("invalid token")
return Caller(username="stub-operator", expires_at=0)
packs = os.path.join(root, "packs")
settings = Settings(packs_dir=packs, tiles_dir=os.path.join(root, "tiles"), max_streams=8)
app = create_app(settings, validator=StubValidator())
def serve_file(request: Request, base: str, rel: str) -> Response:
"""nginx `alias` + `auth_request`: token check, then the file, honouring one Range."""
if bearer_token(request.headers.get("authorization")) != TOKEN:
return Response(status_code=401, headers={"WWW-Authenticate": "Bearer"})
try:
safe_relpath(rel)
except ManifestError:
return Response(status_code=404)
path = join(base, rel)
if not os.path.isfile(path):
return Response(status_code=404)
with open(path, "rb") as f:
data = f.read()
size = len(data)
header = request.headers.get("range")
if not header:
return Response(data, media_type="application/octet-stream", headers={"Accept-Ranges": "bytes"})
match = re.fullmatch(r"bytes=(\d*)-(\d*)", header.strip())
unsatisfiable = Response(status_code=416, headers={"Content-Range": f"bytes */{size}"})
if not match or (not match.group(1) and not match.group(2)):
return unsatisfiable
if not match.group(1):
start, end = max(0, size - int(match.group(2))), size - 1
else:
start = int(match.group(1))
end = min(int(match.group(2)), size - 1) if match.group(2) else size - 1
if start >= size or start > end:
return unsatisfiable
return Response(data[start:end + 1], status_code=206, media_type="application/octet-stream",
headers={"Content-Range": f"bytes {start}-{end}/{size}", "Accept-Ranges": "bytes"})
@app.get("/packs/app/{sha}")
def app_file(sha: str, request: Request):
return serve_file(request, os.path.join(packs, "app"), sha)
@app.get("/packs/db/{rel:path}")
def db_file(rel: str, request: Request):
return serve_file(request, os.path.join(packs, "db"), rel)
@app.get("/src/{rel:path}")
def src_file(rel: str, request: Request):
return serve_file(request, os.path.join(root, "src"), rel)
lock = threading.Lock()
state = {"log": [], "fail": [], "cut": []}
async def reply(send, status, obj, headers=()):
body = json.dumps(obj).encode("utf-8")
await send({"type": "http.response.start", "status": status,
"headers": [(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode("ascii")), *headers]})
await send({"type": "http.response.body", "body": body})
async def control(scope, receive, send):
path = scope["path"]
query = {k: v[0] for k, v in urllib.parse.parse_qs(scope["query_string"].decode("latin-1")).items()}
with lock:
if path == "/_ctl/log":
return await reply(send, 200, {"requests": list(state["log"])})
if path == "/_ctl/reset":
state["log"].clear()
state["fail"].clear()
state["cut"].clear()
elif path == "/_ctl/fail":
state["fail"].append({"prefix": query["prefix"], "status": int(query.get("status", "503")),
"count": int(query.get("count", "1")), "retry_after": query.get("retry_after")})
elif path == "/_ctl/cut":
state["cut"].append({"prefix": query["prefix"], "after": int(query["after"]),
"count": int(query.get("count", "1"))})
else:
return await reply(send, 404, {"error": "unknown control"})
await reply(send, 200, {"ok": True})
def take_rule(kind, path):
for rule in state[kind]:
if rule["count"] > 0 and path.startswith(rule["prefix"]):
rule["count"] -= 1
return rule
return None
async def asgi(scope, receive, send):
if scope["type"] != "http":
return await app(scope, receive, send)
path = scope["path"]
if path.startswith("/_ctl/"):
return await control(scope, receive, send)
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
with lock:
state["log"].append({"path": path, "range": headers.get("range")})
fail = take_rule("fail", path)
cut = None if fail else take_rule("cut", path)
if fail:
extra = [(b"retry-after", fail["retry_after"].encode("ascii"))] if fail["retry_after"] is not None else []
return await reply(send, fail["status"], {"error": "stub failure"}, extra)
if not cut:
return await app(scope, receive, send)
sent = 0
async def cutting_send(message):
nonlocal sent
if message["type"] != "http.response.body":
return await send(message)
body = message.get("body", b"")
room = cut["after"] - sent
if len(body) < room:
sent += len(body)
return await send(message)
await send({"type": "http.response.body", "body": body[:room], "more_body": True})
raise ConnectionAbortedError("stub cut the connection")
return await app(scope, receive, cutting_send)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
info.update(port=port, base_url=f"http://127.0.0.1:{port}")
print(json.dumps(info), flush=True)
def exit_when_stdin_closes():
try:
sys.stdin.buffer.read()
finally:
os._exit(0)
threading.Thread(target=exit_when_stdin_closes, daemon=True).start()
server = uvicorn.Server(uvicorn.Config(asgi, log_level="warning", lifespan="off", access_log=False))
server.run(sockets=[sock])
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--root", required=True)
parser.add_argument("--no-empty-src", action="store_true", help="leave out the zero-byte source file")
parser.add_argument("--build-only", action="store_true")
args = parser.parse_args()
root = os.path.abspath(args.root)
if not os.path.isfile(os.path.join(root, "packs", "versions", "CURRENT")):
build(root, empty_src=not args.no_empty_src)
from odian_packs import signing
info = {
"root": root, "token": TOKEN, "version": VERSION, "style": STYLE, "dataset": DATASET, "snapshot": SNAPSHOT,
"public_key": signing.public_key_text(signing.private_key_from_raw(TEST_KEY_RAW).public_key()),
}
if args.build_only:
print(json.dumps(info), flush=True)
return
serve(root, info)
if __name__ == "__main__":
main()