This commit is contained in:
Kelorastus
2026-09-23 16:05:19 +02:00
parent 4a92cc0b63
commit 4d620fc5ad
27 changed files with 3571 additions and 104 deletions
+427
View File
@@ -0,0 +1,427 @@
//! The marketplace: the tile sets and source files of a version an operator may choose.
//!
//! The DHP decides what is optional: a manifest item with `"optional": true` (and a title and
//! description) is left out of Install and offered here; everything else is part of every
//! install. A manifest from before the marketplace flags nothing, and the same rule is applied
//! to it here instead (see `pre_catalog`). What the operator chose is
//! `LauncherState::extras`, a set of item keys that outlive versions (`tiles:<style>/<dataset>`,
//! `src:<path>`), so an update refreshes exactly the chosen items.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use odian_packs::manifest::{Manifest, SrcFile, TileSet};
use serde::Serialize;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
Tiles,
Src,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Presence {
/// Every byte of this version's item is on the PC.
Installed,
/// Part of it is: a download that stopped, or an older version's copy. Installing resumes.
Partial,
Absent,
}
/// One item as the marketplace shows it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Item {
pub key: String,
pub kind: Kind,
pub title: String,
pub description: String,
/// What installing it downloads, in full.
pub bytes: u64,
/// Part of every install: shown, never offered for removal.
pub required: bool,
pub presence: Presence,
/// Chosen: installed with the version and kept by updates (required items always are).
pub selected: bool,
}
/// The map ODIAN opens on, kept required by `catalog.json`.
const ALWAYS_INSTALLED_STYLE: &str = "dark";
/// A manifest from before the marketplace (pack 0.61.0) flags nothing optional, so everything
/// in it would be part of every install: copying such a version from a drive would offer no
/// choice at all, and the operator would face the whole 228 GB or nothing. The catalog's own
/// rule is applied to it instead - the app, the database and the dark map come with every
/// install, the rest is offered - so an older version is chosen from like a current one. A
/// manifest that flags anything is the DHP's to decide and is taken exactly as it stands.
fn pre_catalog(manifest: &Manifest) -> bool {
!manifest.tiles.iter().flat_map(|tiles| &tiles.sets).any(|set| set.optional)
&& !manifest.src.iter().flat_map(|src| &src.files).any(|file| file.optional)
}
fn tiles_optional(set: &TileSet, pre_catalog: bool) -> bool {
set.optional || (pre_catalog && set.style != ALWAYS_INSTALLED_STYLE)
}
fn src_optional(file: &SrcFile, pre_catalog: bool) -> bool {
file.optional || pre_catalog
}
pub fn tiles_key(set: &TileSet) -> String {
format!("tiles:{}/{}", set.style, set.dataset)
}
pub fn src_key(file: &SrcFile) -> String {
format!("src:{}", file.path)
}
fn tile_set_dir(data_dir: &Path, set: &TileSet) -> PathBuf {
data_dir.join("tiles").join(&set.style).join(&set.dataset)
}
fn src_path(data_dir: &Path, file: &SrcFile) -> PathBuf {
file.path.split('/').fold(data_dir.join("src"), |path, part| path.join(part))
}
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.as_os_str().to_owned();
name.push(suffix);
PathBuf::from(name)
}
/// A tile set is complete when the installer's `.blocks-done.json` names this index and lists
/// all its blocks; it is partial when its folder exists at all.
fn tile_set_presence(data_dir: &Path, set: &TileSet) -> Presence {
let dir = tile_set_dir(data_dir, set);
let done = std::fs::read(dir.join(".blocks-done.json"))
.ok()
.and_then(|raw| serde_json::from_slice::<serde_json::Value>(&raw).ok())
.filter(|done| done["index_sha256"].as_str() == Some(set.index_sha256.as_str()))
.and_then(|done| done["blocks"].as_array().map(Vec::len));
match done {
Some(blocks) if blocks as u64 >= set.blocks => Presence::Installed,
_ if dir.is_dir() => Presence::Partial,
_ => Presence::Absent,
}
}
/// A source file is complete when its `.ok` marker names its chunk list and the size is right
/// (the installer's own test); partial when the file exists.
fn src_presence(data_dir: &Path, file: &SrcFile) -> Presence {
let path = src_path(data_dir, file);
let marker_ok = std::fs::read_to_string(with_suffix(&path, ".ok")).is_ok_and(|text| text.trim() == file.chunks_sha256);
let size_ok = std::fs::metadata(&path).is_ok_and(|md| md.is_file() && md.len() == file.size);
match (marker_ok && size_ok, path.exists()) {
(true, _) => Presence::Installed,
(false, true) => Presence::Partial,
(false, false) => Presence::Absent,
}
}
/// Every tile set and source file of `manifest`, required ones first, as the marketplace shows
/// them. `extras` is the operator's choice.
pub fn items(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Item> {
items_from(manifest, data_dir, None, extras)
}
/// The same, for an install that copies another one: an item the source does not carry cannot
/// be offered, whatever the version's manifest lists. `presence` stays what THIS PC has.
pub fn items_from(
manifest: &Manifest,
data_dir: &Path,
source_data: Option<&Path>,
extras: &BTreeSet<String>,
) -> Vec<Item> {
let mut out = all_items(manifest, data_dir, extras);
if let Some(source) = source_data {
let carried: BTreeSet<String> = all_items(manifest, source, extras)
.into_iter()
.filter(|item| item.presence != Presence::Absent)
.map(|item| item.key)
.collect();
out.retain(|item| carried.contains(&item.key));
}
out
}
fn all_items(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Item> {
let old = pre_catalog(manifest);
let mut out = Vec::new();
for set in manifest.tiles.iter().flat_map(|tiles| &tiles.sets) {
let key = tiles_key(set);
let optional = tiles_optional(set, old);
out.push(Item {
selected: !optional || extras.contains(&key),
title: set.title.clone().unwrap_or_else(|| format!("{} map ({})", set.style, set.dataset)),
description: set.description.clone().unwrap_or_default(),
bytes: set.bytes,
required: !optional,
presence: tile_set_presence(data_dir, set),
kind: Kind::Tiles,
key,
});
}
for file in manifest.src.iter().filter(|src| src.enabled).flat_map(|src| &src.files) {
let key = src_key(file);
let optional = src_optional(file, old);
out.push(Item {
selected: !optional || extras.contains(&key),
title: file.title.clone().unwrap_or_else(|| file.path.clone()),
description: file.description.clone().unwrap_or_default(),
bytes: file.size,
required: !optional,
presence: src_presence(data_dir, file),
kind: Kind::Src,
key,
});
}
out.sort_by_key(|item| !item.required);
out
}
/// What installing `manifest` fetches: every required item and the chosen optional ones, as
/// the installer's `tile_sets` (`style/dataset`) and `src_paths` filters.
pub fn install_filter(manifest: &Manifest, extras: &BTreeSet<String>) -> (BTreeSet<String>, BTreeSet<String>) {
let old = pre_catalog(manifest);
let tile_sets = manifest
.tiles
.iter()
.flat_map(|tiles| &tiles.sets)
.filter(|set| !tiles_optional(set, old) || extras.contains(&tiles_key(set)))
.map(|set| format!("{}/{}", set.style, set.dataset))
.collect();
let src_paths = manifest
.src
.iter()
.flat_map(|src| &src.files)
.filter(|file| !src_optional(file, old) || extras.contains(&src_key(file)))
.map(|file| file.path.clone())
.collect();
(tile_sets, src_paths)
}
/// The optional items this PC already has, complete or not. A PC installed before the
/// marketplace has all of them: its first choice starts from what is on disk, so nothing is
/// removed or downloaded again behind the operator's back.
pub fn present_extras(manifest: &Manifest, data_dir: &Path) -> BTreeSet<String> {
items(manifest, data_dir, &BTreeSet::new())
.into_iter()
.filter(|item| !item.required && item.presence != Presence::Absent)
.map(|item| item.key)
.collect()
}
/// Keep only the keys of optional items `manifest` offers: a stale or required key means nothing.
pub fn optional_keys(manifest: &Manifest, keys: impl IntoIterator<Item = String>) -> BTreeSet<String> {
let offered: BTreeSet<String> =
items(manifest, Path::new(""), &BTreeSet::new()).into_iter().filter(|item| !item.required).map(|item| item.key).collect();
keys.into_iter().filter(|key| offered.contains(key)).collect()
}
/// The optional items on this PC the operator no longer wants.
pub fn to_remove(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Item> {
items(manifest, data_dir, extras)
.into_iter()
.filter(|item| !item.required && !item.selected && item.presence != Presence::Absent)
.collect()
}
/// Whether the chosen optional items still need downloading.
pub fn to_add(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Item> {
items(manifest, data_dir, extras)
.into_iter()
.filter(|item| !item.required && item.selected && item.presence != Presence::Installed)
.collect()
}
/// Remove an item's files from the PC: a tile set's folder, or a source file with its markers.
pub fn remove(manifest: &Manifest, data_dir: &Path, key: &str) -> Result<(), String> {
for set in manifest.tiles.iter().flat_map(|tiles| &tiles.sets) {
if tiles_key(set) == key {
let dir = tile_set_dir(data_dir, set);
return if dir.exists() { crate::prune::remove_folder(&dir) } else { Ok(()) };
}
}
for file in manifest.src.iter().flat_map(|src| &src.files) {
if src_key(file) == key {
let path = src_path(data_dir, file);
for extra in ["", ".ok", ".chunks.json", ".part"] {
let target = with_suffix(&path, extra);
if target.exists() {
std::fs::remove_file(&target).map_err(|e| format!("cannot remove {}: {e}", target.display()))?;
}
}
return Ok(());
}
}
Err(format!("{key} is not in this version"))
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest() -> Manifest {
serde_json::from_value(serde_json::json!({
"schema": 1, "product": "odian", "version": "0.61.0", "released_at": "2026-09-22T12:00:00Z",
"app": {"files": []},
"tiles": {"block_shift": 6, "sets": [
{"style": "dark", "dataset": "ds", "journal_seq": 1, "blocks": 2, "files": 3, "bytes": 100,
"index": "tiles/dark.ds.index.json", "index_sha256": "a".repeat(64), "title": "Dark map"},
{"style": "dark_v2", "dataset": "ds", "journal_seq": 1, "blocks": 2, "files": 3, "bytes": 200,
"index": "tiles/dark_v2.ds.index.json", "index_sha256": "b".repeat(64), "optional": true,
"title": "Dark V2 map", "description": "Buildings."}
]},
"src": {"enabled": true, "chunk_bytes": 10, "files": [
{"path": "osm/x.pmtiles", "size": 4, "chunks": ["c".repeat(64)], "chunks_sha256": "d".repeat(64), "optional": true}
]}
}))
.unwrap()
}
/// A version from before the catalog: nothing in it is flagged.
fn old_manifest() -> Manifest {
let mut manifest = manifest();
manifest.version = "0.60.0".to_string();
for set in manifest.tiles.iter_mut().flat_map(|tiles| &mut tiles.sets) {
set.optional = false;
set.description = None;
}
for file in manifest.src.iter_mut().flat_map(|src| &mut src.files) {
file.optional = false;
}
manifest
}
fn data_dir() -> PathBuf {
std::env::temp_dir().join(format!("odian-market-test-{}", uuid::Uuid::new_v4().simple()))
}
fn complete_tile_set(data: &Path, style: &str, index: char, blocks: usize) {
let dir = data.join("tiles").join(style).join("ds");
std::fs::create_dir_all(&dir).unwrap();
let list: Vec<String> = (0..blocks).map(|i| format!("7/{i}/0:x")).collect();
let done = serde_json::json!({"index_sha256": index.to_string().repeat(64), "blocks": list});
std::fs::write(dir.join(".blocks-done.json"), done.to_string()).unwrap();
}
#[test]
fn required_items_come_first_and_the_choice_marks_the_rest() {
let data = data_dir();
let listed = items(&manifest(), &data, &["src:osm/x.pmtiles".to_string()].into_iter().collect());
let summary: Vec<(&str, bool, bool)> = listed.iter().map(|i| (i.key.as_str(), i.required, i.selected)).collect();
assert_eq!(summary, [("tiles:dark/ds", true, true), ("tiles:dark_v2/ds", false, false), ("src:osm/x.pmtiles", false, true)]);
assert_eq!((listed[1].title.as_str(), listed[1].description.as_str(), listed[1].bytes), ("Dark V2 map", "Buildings.", 200));
assert_eq!(listed[2].title, "osm/x.pmtiles", "no title: the path");
assert!(listed.iter().all(|i| i.presence == Presence::Absent));
}
#[test]
fn an_install_fetches_the_required_items_and_the_chosen_ones() {
let (sets, paths) = install_filter(&manifest(), &BTreeSet::new());
assert_eq!((sets.into_iter().collect::<Vec<_>>(), paths.len()), (vec!["dark/ds".to_string()], 0));
let (sets, paths) = install_filter(&manifest(), &["tiles:dark_v2/ds".to_string()].into_iter().collect());
assert_eq!(sets.len(), 2);
assert!(paths.is_empty());
}
#[test]
fn presence_follows_the_installers_own_markers() {
let data = data_dir();
let m = manifest();
complete_tile_set(&data, "dark_v2", 'b', 2);
complete_tile_set(&data, "dark", 'z', 2); // an older index: it resumes, it is not done
let src = data.join("src").join("osm").join("x.pmtiles");
std::fs::create_dir_all(src.parent().unwrap()).unwrap();
std::fs::write(&src, b"PMTi").unwrap();
let presence = |key: &str| items(&m, &data, &BTreeSet::new()).into_iter().find(|i| i.key == key).unwrap().presence;
assert_eq!(presence("tiles:dark_v2/ds"), Presence::Installed);
assert_eq!(presence("tiles:dark/ds"), Presence::Partial);
assert_eq!(presence("src:osm/x.pmtiles"), Presence::Partial, "no .ok marker yet");
std::fs::write(with_suffix(&src, ".ok"), "d".repeat(64)).unwrap();
assert_eq!(presence("src:osm/x.pmtiles"), Presence::Installed);
// A PC from before the marketplace keeps what it has.
assert_eq!(present_extras(&m, &data).into_iter().collect::<Vec<_>>(), ["src:osm/x.pmtiles", "tiles:dark_v2/ds"]);
std::fs::remove_dir_all(&data).unwrap();
}
#[test]
fn unchosen_items_are_removed_and_chosen_missing_ones_added() {
let data = data_dir();
let m = manifest();
complete_tile_set(&data, "dark_v2", 'b', 2);
let src = data.join("src").join("osm").join("x.pmtiles");
let chosen: BTreeSet<String> = ["src:osm/x.pmtiles".to_string()].into_iter().collect();
let keys = |items: Vec<Item>| items.into_iter().map(|i| i.key).collect::<Vec<_>>();
assert_eq!(keys(to_remove(&m, &data, &chosen)), ["tiles:dark_v2/ds"]);
assert_eq!(keys(to_add(&m, &data, &chosen)), ["src:osm/x.pmtiles"]);
remove(&m, &data, "tiles:dark_v2/ds").unwrap();
assert!(!data.join("tiles").join("dark_v2").join("ds").exists());
std::fs::create_dir_all(src.parent().unwrap()).unwrap();
std::fs::write(&src, b"PMTi").unwrap();
std::fs::write(with_suffix(&src, ".chunks.json"), b"{}").unwrap();
remove(&m, &data, "src:osm/x.pmtiles").unwrap();
assert!(!src.exists() && !with_suffix(&src, ".chunks.json").exists());
assert!(remove(&m, &data, "tiles:nope/ds").is_err());
std::fs::remove_dir_all(&data).unwrap();
}
/// The marketplace a real published manifest gives a real install, after checking its
/// signature with the release key. Reads only. `ODIAN_MARKET_MANIFEST=<manifest.json>
/// ODIAN_MARKET_DATA=C:\ODIAN\data cargo test -p odian-launcher real_manifest -- --ignored --nocapture`
#[test]
#[ignore = "reads a real manifest named by ODIAN_MARKET_MANIFEST"]
fn show_the_marketplace_of_a_real_manifest() {
let raw = std::fs::read(std::env::var("ODIAN_MARKET_MANIFEST").unwrap()).unwrap();
let verified = odian_packs::VerifiedManifest::from_bytes(raw, odian_packs::bundled_public_key()).unwrap();
let data = std::env::var("ODIAN_MARKET_DATA").map(PathBuf::from).unwrap_or_default();
let manifest = verified.manifest();
for item in items(manifest, &data, &present_extras(manifest, &data)) {
println!(
"{:>7.1} GB {:<9} required={:<5} selected={:<5} {}",
item.bytes as f64 / 1e9,
format!("{:?}", item.presence),
item.required,
item.selected,
item.title
);
}
let (sets, paths) = install_filter(manifest, &BTreeSet::new());
println!("a new PC installs: {sets:?} {paths:?}");
}
#[test]
fn only_offered_optional_keys_are_kept() {
let keys = ["tiles:dark/ds", "tiles:dark_v2/ds", "src:gone.pmtiles"].map(str::to_string);
assert_eq!(optional_keys(&manifest(), keys).into_iter().collect::<Vec<_>>(), ["tiles:dark_v2/ds"]);
}
#[test]
fn a_version_from_before_the_catalog_is_still_chosen_from() {
let items = items(&old_manifest(), Path::new(""), &BTreeSet::new());
let required: Vec<&str> = items.iter().filter(|item| item.required).map(|item| item.key.as_str()).collect();
// Only the map ODIAN opens on comes with every install; the rest is the operator's to pick.
assert_eq!(required, vec!["tiles:dark/ds"]);
let offered: Vec<&str> = items.iter().filter(|item| !item.required).map(|item| item.key.as_str()).collect();
assert_eq!(offered, vec!["tiles:dark_v2/ds", "src:osm/x.pmtiles"]);
// Unchosen, an install of it brings the dark map alone.
let (tiles, src) = install_filter(&old_manifest(), &BTreeSet::new());
assert_eq!(tiles, BTreeSet::from(["dark/ds".to_string()]));
assert!(src.is_empty());
// Chosen, they come along.
let chosen = BTreeSet::from(["src:osm/x.pmtiles".to_string()]);
let (_, src) = install_filter(&old_manifest(), &chosen);
assert_eq!(src, BTreeSet::from(["osm/x.pmtiles".to_string()]));
}
#[test]
fn a_manifest_that_flags_anything_is_taken_as_it_stands() {
// The DHP decides for a catalog version: `dark_v2` is optional there, and nothing else moves.
let items = items(&manifest(), Path::new(""), &BTreeSet::new());
let required: Vec<&str> = items.iter().filter(|item| item.required).map(|item| item.key.as_str()).collect();
assert_eq!(required, vec!["tiles:dark/ds"]);
assert!(!pre_catalog(&manifest()) && pre_catalog(&old_manifest()));
}
}