feat: registry core (model, guard, verify, store, net, install, commands), registry browser UI, signing tool, size profile and font dedupe, lazy Hebrew/Arabic fonts, htmlmd/srs/textdiff crates
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-10-21 17:28:10 -03:00
parent e8c8c06d1e
commit c78fd488cc
94 changed files with 7619 additions and 107 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ NotesCraft is a native, Obsidian-compatible notes app written in **Rust only** (
- One crate per concern in `crates/<name>`, package `notescraft-<name>`, `[lints] workspace = true`, registered in the root `[workspace.dependencies]` and in `xtask/src/layers.rs`.
- **Layering is enforced** by `cargo xtask layers`: a crate may depend only on strictly lower layers; nothing below L5 may use egui, eframe, winit or rfd. Layers: L0 `vault` `markdown`; L1 `index` `canvas` `diagrams` `whiteboard`; L2 `backlinks` `graph` `templates`; L3 `plugins` `themes`; L4 `engine`; L5 `editor` `mcp` `i18n`; L6 `ui-egui`.
- Shared crates come from craft-libs (`craft-atomic`, `craft-settings`, `craft-ui`, `craft-quit`, `craft-print`), pinned by git rev in the root `Cargo.toml`. Do not change their API; ask first.
- A new third-party dependency needs a written reason and owner approval. Already approved: `notify`, `pulldown-cmark`, `toml`, `wasmi`, `wat` (dev), `thiserror`, `serde`, `serde_json`, and for sync (2026-10-19): `sha2`, `axum`, `tokio`, `ureq` (rustls), `rusqlite`, and for paste (2026-10-20): `arboard` (image-data), `image` (png feature only).
- A new third-party dependency needs a written reason and owner approval. Already approved: `notify`, `pulldown-cmark`, `toml`, `wasmi`, `wat` (dev), `thiserror`, `serde`, `serde_json`, and for sync (2026-10-19): `sha2`, `axum`, `tokio`, `ureq` (rustls), `rusqlite`, and for paste (2026-10-20): `arboard` (image-data), `image` (png feature only), and for the registry (2026-10-21): `ring` (ed25519 verify, already linked through rustls).
## Never crash (outranks features)
Generated
+25
View File
@@ -2847,6 +2847,7 @@ dependencies = [
"notescraft-templates",
"notescraft-themes",
"notescraft-vault",
"ring",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -2869,6 +2870,13 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "notescraft-htmlmd"
version = "0.1.0"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "notescraft-i18n"
version = "0.1.0"
@@ -2968,6 +2976,10 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "notescraft-srs"
version = "0.1.0"
[[package]]
name = "notescraft-sync"
version = "0.1.0"
@@ -3038,6 +3050,13 @@ dependencies = [
name = "notescraft-testkit"
version = "0.1.0"
[[package]]
name = "notescraft-textdiff"
version = "0.1.0"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "notescraft-themes"
version = "0.1.0"
@@ -6125,7 +6144,13 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
name = "xtask"
version = "0.1.0"
dependencies = [
"craft-atomic",
"notescraft-engine",
"ring",
"serde",
"serde_json",
"sha2",
"toml",
]
[[package]]
+9 -1
View File
@@ -42,6 +42,9 @@ notescraft-collab = { path = "crates/collab" }
notescraft-annotate = { path = "crates/annotate" }
notescraft-calendar = { path = "crates/calendar" }
notescraft-tasks = { path = "crates/tasks" }
notescraft-textdiff = { path = "crates/textdiff" }
notescraft-htmlmd = { path = "crates/htmlmd" }
notescraft-srs = { path = "crates/srs" }
notescraft-templater = { path = "crates/templater" }
notescraft-plugins = { path = "crates/plugins" }
notescraft-themes = { path = "crates/themes" }
@@ -87,6 +90,8 @@ tokio = { version = "1", default-features = false, features = ["rt-multi-thread"
rusqlite = { version = "0.37", features = ["bundled"] }
# Blocking HTTP client with rustls (sync server tests now, the engine sync client next).
ureq = { version = "3", default-features = false, features = ["rustls", "json"] }
# ed25519 verification of registry entries (owner-approved 2026-10-21, already linked through rustls).
ring = { version = "0.17", default-features = false }
# Pure-Rust WebAssembly interpreter, same configuration as photocraft-plugins.
wasmi = { version = "2.0.0", default-features = false, features = ["std", "validate", "stable", "portable-dispatch"] }
wat = "1.228"
@@ -113,4 +118,7 @@ debug = "line-tables-only"
[profile.release]
lto = "thin"
codegen-units = 1
codegen-units = 1
# Size: owner-approved 2026-10-21 (specs/registry/measurements.md): -8 MB on macOS; crash backtraces lose symbol names.
opt-level = "s"
strip = "symbols"
+1 -1
View File
@@ -23,7 +23,7 @@ notescraft-calendar = { workspace = true }
notescraft-templates = { workspace = true }
serde_json = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["wgpu", "default_fonts", "accesskit", "wayland", "x11"] }
eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11"] }
log = { workspace = true }
cpal = { workspace = true }
craft-quit = { workspace = true, features = ["egui"] }
Binary file not shown.
+92
View File
@@ -0,0 +1,92 @@
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+1
View File
@@ -32,6 +32,7 @@ notescraft-annotate = { workspace = true }
notescraft-bases = { workspace = true }
notescraft-collab = { workspace = true }
ureq = { workspace = true }
ring = { workspace = true }
notescraft-regex = { workspace = true }
grep-regex = { workspace = true }
grep-searcher = { workspace = true }
+334
View File
@@ -0,0 +1,334 @@
//! The `registry.*` commands (spec D9): list, info, suggest, check, install, update, remove,
//! rollback, enable and disable.
//!
//! Reads work from the cached index and never touch the network; `registry.check` and the install
//! commands are the only network triggers. Mutating commands are disabled in a read-only session.
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
use super::TRUSTED_KEYS;
use super::install::{self, Ctx, InstallReport};
use super::model::{Entry, Index, Kind, Version};
use super::net::{IndexCache, fetch_index, split_code};
use super::state::{State, VaultFile, key};
use super::store::Store;
use super::verify::{Ed25519, Verifier};
use crate::commands::{json, text_param, writable};
use crate::error::{CommandError, Error};
use crate::params::{ParamKind, ParamSpec};
use crate::registry::Registry;
use crate::session::Session;
use crate::spec::{CommandResult, CommandSpec};
/// Folder of the per-vault choices.
const VAULT_FILE: &str = ".notescraft/registry.json";
/// Rows `registry.list` returns when `limit` is not given.
const DEFAULT_LIMIT: usize = 200;
/// Most rows `registry.list` may return.
const MAX_LIMIT: usize = 2000;
/// The default verifier: the keys embedded in the build. With none configured it refuses with a
/// clear message instead of the generic "unknown key".
#[derive(Debug, Clone, Copy)]
pub struct TrustedVerifier;
impl Verifier for TrustedVerifier {
fn verify(&self, entry: &Entry) -> crate::error::Result<()> {
if TRUSTED_KEYS.is_empty() {
return Err(super::net::coded("addon", "this build has no trusted registry key, so add-ons cannot be installed yet"));
}
Ed25519::trusted().verify(entry)
}
}
fn fail(error: &Error) -> CommandError {
match error {
Error::Addon(message) => {
let (code, rest) = split_code(message);
CommandError::new(code, rest)
}
other => crate::commands::command_error(other),
}
}
fn invalid(message: impl Into<String>) -> CommandError {
CommandError::new("invalid_params", message)
}
fn kind_param(params: &Value) -> Result<Kind, CommandError> {
let name = text_param(params, "kind")?;
Kind::parse(&name).ok_or_else(|| invalid(format!("`kind` is not one of plugin theme pack grammar font snippet: `{name}`")))
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |elapsed| elapsed.as_secs())
}
fn app_version() -> Version {
Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version { major: 0, minor: 0, patch: 0 })
}
pub(super) fn store(session: &Session) -> Result<Store, CommandError> {
let base: Option<PathBuf> = match session.settings_dir() {
Some(dir) => Some(dir.to_path_buf()),
None => match std::env::var_os(craft_settings::paths::env_var_name(crate::app_settings::APP_ID)) {
Some(dir) if !dir.is_empty() => Some(PathBuf::from(dir)),
_ => craft_settings::paths::data_dir(crate::app_settings::APP_ID),
},
};
let base = base.ok_or_else(|| CommandError::new("addon", "there is no data folder for the add-on store"))?;
Store::open(&base).map_err(|e| fail(&e))
}
fn cached(store: &Store) -> Result<Option<(IndexCache, Index)>, CommandError> {
let Some(cache) = IndexCache::load(store.root()).map_err(|e| fail(&e))? else { return Ok(None) };
let index = cache.index().map_err(|e| fail(&e))?;
Ok(Some((cache, index)))
}
fn need_index(store: &Store) -> Result<Index, CommandError> {
cached(store)?.map(|(_, index)| index).ok_or_else(|| CommandError::new("not_found", "no add-on index is cached yet; run registry.check first"))
}
/// The state of an entry for the UI: `available`, `installed`, `update`, `revoked`,
/// `incompatible` or `unsupported`.
fn entry_state(index: &Index, entry: &Entry, state: &State) -> &'static str {
let installed = state.installed.get(&key(entry.kind, &entry.id));
if index.is_revoked(entry) {
return "revoked";
}
if let Some(row) = installed {
return if row.version < entry.version { "update" } else { "installed" };
}
if entry.min_app_version > app_version() {
return "incompatible";
}
if matches!(entry.kind, Kind::Font | Kind::Snippet) { "unsupported" } else { "available" }
}
fn row(index: &Index, entry: &Entry, state: &State) -> Value {
let installed = state.installed.get(&key(entry.kind, &entry.id)).map(|r| r.version.to_string());
json!({
"id": entry.id, "kind": entry.kind.as_str(), "title": entry.title, "summary": entry.summary,
"version": entry.version.to_string(), "installed_version": installed, "size": entry.total_size(),
"state": entry_state(index, entry, state), "permissions": entry.permissions,
})
}
fn load_all(session: &Session) -> Result<(Store, Option<Index>, State), CommandError> {
let store = store(session)?;
let state = store.load().map_err(|e| fail(&e))?;
let index = cached(&store)?.map(|(_, index)| index);
Ok((store, index, state))
}
fn list(session: &mut Session, params: &Value) -> CommandResult {
let (_, index, state) = load_all(session)?;
let kind = params.get("kind").map(|_| kind_param(params)).transpose()?;
let query = params.get("query").and_then(Value::as_str).map(str::to_lowercase);
let wanted = params.get("state").and_then(Value::as_str);
let limit = params.get("limit").and_then(Value::as_u64).map_or(DEFAULT_LIMIT, |n| usize::try_from(n).unwrap_or(MAX_LIMIT).min(MAX_LIMIT));
let Some(index) = index else { return json(json!({ "entries": [], "cached": false })) };
let rows: Vec<Value> = index
.entries
.iter()
.filter(|e| kind.is_none_or(|k| e.kind == k))
.filter(|e| query.as_ref().is_none_or(|q| matches_query(e, q)))
.filter(|e| {
let current = entry_state(&index, e, &state);
match wanted {
None => true,
Some("installed") => matches!(current, "installed" | "update"),
Some("updates") => current == "update",
Some(other) => current == other,
}
})
.take(limit)
.map(|e| row(&index, e, &state))
.collect();
json(json!({ "entries": rows, "cached": true }))
}
fn matches_query(entry: &Entry, query: &str) -> bool {
[entry.id.as_str(), &entry.title, &entry.summary].iter().any(|text| text.to_lowercase().contains(query))
|| entry.aliases.iter().any(|alias| alias.contains(query))
}
fn info(session: &mut Session, params: &Value) -> CommandResult {
let (kind, id) = (kind_param(params)?, text_param(params, "id")?);
let (_, index, state) = load_all(session)?;
let index = index.ok_or_else(|| CommandError::new("not_found", "no add-on index is cached yet; run registry.check first"))?;
let entry = index.find(kind, &id).ok_or_else(|| CommandError::new("not_found", format!("the registry has no {} `{id}`", kind.as_str())))?;
let files: Vec<Value> = entry.files.iter().map(|f| json!({ "path": f.path, "size": f.size })).collect();
let mut value = row(&index, entry, &state);
if let Some(map) = value.as_object_mut() {
map.insert("license".into(), json!(entry.license));
map.insert("source".into(), json!(entry.source));
map.insert("aliases".into(), json!(entry.aliases));
map.insert("min_app_version".into(), json!(entry.min_app_version.to_string()));
map.insert("key_id".into(), json!(entry.signature.key_id));
map.insert("files".into(), json!(files));
}
json(value)
}
fn suggest(session: &mut Session, params: &Value) -> CommandResult {
let (_, index, state) = load_all(session)?;
let Some(index) = index else { return json(json!({ "entries": [] })) };
let rows: Vec<Value> = if let Some(label) = params.get("label").and_then(Value::as_str) {
let label = label.trim().to_lowercase();
index.entries.iter().filter(|e| e.kind == Kind::Grammar && (e.id == label || e.aliases.contains(&label))).map(|e| row(&index, e, &state)).collect()
} else {
let kind = kind_param(params).map_err(|_| invalid("give `label`, or `kind` and `query`"))?;
let query = text_param(params, "query")?.to_lowercase();
index.entries.iter().filter(|e| e.kind == kind && matches_query(e, &query)).map(|e| row(&index, e, &state)).collect()
};
json(json!({ "entries": rows }))
}
fn check(session: &mut Session, _params: &Value) -> CommandResult {
let store = store(session)?;
let old = IndexCache::load(store.root()).map_err(|e| fail(&e))?;
let fresh = fetch_index(&*session.addons_transport, old.as_ref(), now()).map_err(|e| fail(&e))?;
fresh.save(store.root()).map_err(|e| fail(&e))?;
let index = fresh.index().map_err(|e| fail(&e))?;
let state = store.load().map_err(|e| fail(&e))?;
let updates = index.entries.iter().filter(|e| entry_state(&index, e, &state) == "update").count();
let changed = old.as_ref().is_none_or(|o| o.body != fresh.body);
json(json!({ "entries": index.entries.len(), "skipped": index.skipped, "updates": updates, "changed": changed, "fetched_at": fresh.fetched_at }))
}
fn report(r: &InstallReport) -> Value {
json!({
"kind": r.kind.as_str(), "id": r.id, "version": r.version.to_string(), "previous": r.previous.map(|v| v.to_string()),
"source": format!("{:?}", r.source).to_lowercase(), "bytes": r.bytes,
})
}
fn with_ctx<T>(session: &Session, run: impl FnOnce(&Ctx<'_>, &Index) -> crate::error::Result<T>) -> Result<T, CommandError> {
let store = store(session)?;
let index = need_index(&store)?;
let ctx = Ctx { store: &store, transport: &*session.addons_transport, verifier: &*session.addons_verifier, app_version: app_version(), seed: None };
run(&ctx, &index).map_err(|e| fail(&e))
}
fn install_cmd(session: &mut Session, params: &Value) -> CommandResult {
let (kind, id) = (kind_param(params)?, text_param(params, "id")?);
let version = params.get("version").and_then(Value::as_str).map(Version::parse).transpose().map_err(|e| fail(&e))?;
json(report(&with_ctx(session, |ctx, index| install::install(ctx, index, kind, &id, version))?))
}
fn update_cmd(session: &mut Session, params: &Value) -> CommandResult {
if params.get("id").is_some() {
let (kind, id) = (kind_param(params)?, text_param(params, "id")?);
return json(report(&with_ctx(session, |ctx, index| install::update(ctx, index, kind, &id))?));
}
let (store, index, state) = load_all(session)?;
let index = index.ok_or_else(|| CommandError::new("not_found", "no add-on index is cached yet; run registry.check first"))?;
let kind = params.get("kind").map(|_| kind_param(params)).transpose()?;
let due: Vec<&Entry> = index.entries.iter().filter(|e| kind.is_none_or(|k| e.kind == k) && entry_state(&index, e, &state) == "update").collect();
let ctx = Ctx { store: &store, transport: &*session.addons_transport, verifier: &*session.addons_verifier, app_version: app_version(), seed: None };
let (mut updated, mut failed) = (Vec::new(), Vec::new());
for entry in due {
match install::update(&ctx, &index, entry.kind, &entry.id) {
Ok(done) => updated.push(report(&done)),
Err(error) => failed.push(json!({ "kind": entry.kind.as_str(), "id": entry.id, "error": fail(&error) })),
}
}
json(json!({ "updated": updated, "failed": failed }))
}
fn remove_cmd(session: &mut Session, params: &Value) -> CommandResult {
let (kind, id) = (kind_param(params)?, text_param(params, "id")?);
install::remove(&store(session)?, kind, &id).map_err(|e| fail(&e))?;
json(json!({ "kind": kind.as_str(), "id": id }))
}
fn rollback_cmd(session: &mut Session, params: &Value) -> CommandResult {
let (kind, id) = (kind_param(params)?, text_param(params, "id")?);
json(report(&install::rollback(&store(session)?, kind, &id).map_err(|e| fail(&e))?))
}
fn vault_file(session: &Session) -> Result<VaultFile, CommandError> {
match session.vault().read(VAULT_FILE) {
Ok(bytes) => VaultFile::parse(&bytes).map_err(|e| fail(&e)),
Err(notescraft_vault::Error::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => Ok(VaultFile::default()),
Err(error) => Err(fail(&error.into())),
}
}
fn set_enabled(session: &mut Session, params: &Value, on: bool) -> CommandResult {
let id = text_param(params, "id")?;
let name = key(Kind::Plugin, &id);
if on && !store(session)?.load().map_err(|e| fail(&e))?.installed.contains_key(&name) {
return Err(CommandError::new("not_found", format!("plugin `{id}` is not installed")));
}
let mut file = vault_file(session)?;
file.enabled.retain(|known| *known != name);
if on {
file.enabled.push(name);
}
let json_text = file.to_json().map_err(|e| fail(&e))?;
session.vault().write_private(VAULT_FILE, json_text.as_bytes()).map_err(|e| fail(&e.into()))?;
json(json!({ "id": id, "enabled": on }))
}
fn enable(session: &mut Session, params: &Value) -> CommandResult {
set_enabled(session, params, true)
}
fn disable(session: &mut Session, params: &Value) -> CommandResult {
set_enabled(session, params, false)
}
fn kind_id(spec: CommandSpec<Session>, what: &str) -> CommandSpec<Session> {
spec.with_param(ParamSpec::required("kind", ParamKind::String, "plugin, theme, pack, grammar, font or snippet")).with_param(ParamSpec::required(
"id",
ParamKind::String,
what.to_owned(),
))
}
/// Registers the `registry.*` commands.
///
/// # Errors
/// Whatever [`Registry::register`] returns.
pub(crate) fn register_addons(reg: &mut Registry<Session>) -> crate::error::Result<()> {
let specs = vec![
CommandSpec::new("registry.list", "List add-ons", "The cached add-on index joined with what is installed; no network", |_| true, list)
.with_param(ParamSpec::optional("kind", ParamKind::String, "only this kind"))
.with_param(ParamSpec::optional("query", ParamKind::String, "text in the id, title, summary or aliases"))
.with_param(ParamSpec::optional("state", ParamKind::String, "installed, updates, available, revoked, incompatible or unsupported"))
.with_param(ParamSpec::optional("limit", ParamKind::Integer, "most rows, at most 2000")),
kind_id(CommandSpec::new("registry.info", "Add-on info", "One add-on in full: files, sizes, license, signing key", |_| true, info), "add-on id"),
CommandSpec::new("registry.suggest", "Suggest add-ons", "Add-ons for a fence label, or by kind and text; no network", |_| true, suggest)
.with_param(ParamSpec::optional("label", ParamKind::String, "a fence label such as `twig`"))
.with_param(ParamSpec::optional("kind", ParamKind::String, "kind to search"))
.with_param(ParamSpec::optional("query", ParamKind::String, "text to search")),
CommandSpec::new("registry.check", "Check for add-on updates", "Fetches the registry index (one conditional request) and caches it", writable, check),
kind_id(CommandSpec::new("registry.install", "Install add-on", "Downloads, verifies and installs an add-on", writable, install_cmd), "add-on id")
.with_param(ParamSpec::optional("version", ParamKind::String, "must equal the indexed version")),
CommandSpec::new("registry.update", "Update add-ons", "Installs newer versions; no id means every add-on with an update", writable, update_cmd)
.with_param(ParamSpec::optional("kind", ParamKind::String, "kind of the add-on, or only this kind"))
.with_param(ParamSpec::optional("id", ParamKind::String, "one add-on")),
kind_id(CommandSpec::new("registry.remove", "Remove add-on", "Removes an installed add-on", writable, remove_cmd), "installed add-on id"),
kind_id(
CommandSpec::new("registry.rollback", "Roll back add-on", "Returns to the previous installed version", writable, rollback_cmd),
"installed add-on id",
),
CommandSpec::new("registry.enable", "Enable plugin", "Switches an installed plugin on in this vault", writable, enable)
.with_param(ParamSpec::required("id", ParamKind::String, "plugin id")),
CommandSpec::new("registry.disable", "Disable plugin", "Switches a plugin off in this vault", writable, disable).with_param(ParamSpec::required(
"id",
ParamKind::String,
"plugin id",
)),
];
for spec in specs {
reg.register(spec)?;
}
Ok(())
}
+202
View File
@@ -0,0 +1,202 @@
//! Hostile-input rules for an add-on file list: paths, sizes, duplicates and download links.
//!
//! Pure: works on strings and numbers, reads nothing, never follows a link. A path is accepted
//! only if every component passes a character-set check, and [`join_under`] builds the final path
//! from those validated components alone. Nothing is "fixed": a bad input is an `Err`.
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
/// Most files an entry may list.
pub const MAX_FILES: usize = 64;
/// Most path components of one file.
pub const MAX_COMPONENTS: usize = 4;
/// Longest path component, in bytes.
pub const MAX_COMPONENT_BYTES: usize = 64;
/// Longest download link, in bytes.
const MAX_URL_BYTES: usize = 300;
/// One KiB.
const KIB: u64 = 1024;
/// One MiB.
const MIB: u64 = 1024 * KIB;
/// Windows device names that no component may use, with or without an extension.
const RESERVED: [&str; 4] = ["con", "prn", "aux", "nul"];
/// What an add-on is, as far as file rules go (task 1's `Kind` maps onto it).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KindRule {
/// A WebAssembly plugin.
Plugin,
/// A colour theme.
Theme,
/// A syntax grammar.
Grammar,
/// A font.
Font,
/// Snippets and templates.
Snippet,
/// A multi-consumer pack.
Pack,
}
impl From<super::Kind> for KindRule {
fn from(kind: super::Kind) -> Self {
match kind {
super::Kind::Plugin => Self::Plugin,
super::Kind::Theme => Self::Theme,
super::Kind::Grammar => Self::Grammar,
super::Kind::Font => Self::Font,
super::Kind::Snippet => Self::Snippet,
super::Kind::Pack => Self::Pack,
}
}
}
/// A path that passed [`check_path`]: its validated parts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Checked {
/// The path components, in order.
pub components: Vec<String>,
}
/// Most bytes the files of one entry of this kind may add up to.
#[must_use]
pub const fn kind_cap(kind: KindRule) -> u64 {
match kind {
KindRule::Plugin => 4 * MIB,
KindRule::Theme => 64 * KIB,
KindRule::Grammar => 512 * KIB,
KindRule::Snippet => MIB,
KindRule::Pack => 8 * MIB,
KindRule::Font => 32 * MIB,
}
}
fn bad<T>(reason: &str) -> Result<T> {
Err(Error::Addon(reason.to_owned()))
}
fn is_reserved(component: &str) -> bool {
let stem = component.split('.').next().unwrap_or_default().to_ascii_lowercase();
let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n.as_bytes(), [b'1'..=b'9']));
RESERVED.contains(&stem.as_str()) || numbered("com") || numbered("lpt")
}
fn extension_ok(name: &str, extensions: &[&str]) -> bool {
let lower = name.to_ascii_lowercase();
extensions.iter().any(|ext| lower.ends_with(ext))
}
/// Checks one file path of an entry of `kind` (spec D7).
///
/// # Errors
/// [`Error::Addon`] with the broken rule.
pub fn check_path(kind: KindRule, path: &str) -> Result<Checked> {
let components: Vec<&str> = path.split('/').collect();
if components.len() > MAX_COMPONENTS {
return bad("path has too many components");
}
for component in &components {
if component.is_empty() || component.len() > MAX_COMPONENT_BYTES {
return bad("path component is empty or too long");
}
if component.starts_with('.') {
return bad("path component starts with a dot");
}
if !component.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) {
return bad("path has a character outside A-Z a-z 0-9 . _ -");
}
if is_reserved(component) {
return bad("path uses a reserved device name");
}
}
let (Some(name), root) = (components.last().copied(), components.first().copied()) else { return bad("empty path") };
let allowed = match kind {
KindRule::Plugin => components.len() == 1 && matches!(name, "manifest.json" | "plugin.wasm" | "README.md"),
KindRule::Theme => extension_ok(name, &[".toml"]),
KindRule::Grammar => extension_ok(name, &[".sublime-syntax"]),
KindRule::Font => extension_ok(name, &[".ttf", ".otf"]),
KindRule::Snippet => extension_ok(name, &[".md", ".txt", ".toml"]),
KindRule::Pack => components.len() >= 2 && pack_ok(root.unwrap_or_default(), name),
};
if !allowed {
return bad("file name or extension is not allowed for this kind");
}
Ok(Checked { components: components.into_iter().map(str::to_owned).collect() })
}
fn pack_ok(folder: &str, name: &str) -> bool {
match folder {
"grammars" => extension_ok(name, &[".sublime-syntax"]),
"themes" => extension_ok(name, &[".toml"]),
"templates" => extension_ok(name, &[".md"]),
"icons" => extension_ok(name, &[".json", ".svg"]),
_ => false,
}
}
/// Joins validated components under `root`. Cannot leave `root`: no part is empty, `.`, `..`,
/// absolute or contains a separator.
#[must_use]
pub fn join_under(root: &Path, checked: &Checked) -> PathBuf {
checked.components.iter().fold(root.to_path_buf(), |path, part| path.join(part))
}
/// Checks a whole file list `(path, size)`: every path, the count, case-insensitive duplicates,
/// zero sizes, the overflow-checked total and the kind cap. Returns the total size.
///
/// # Errors
/// [`Error::Addon`] with the broken rule.
pub fn check_set(kind: KindRule, files: &[(&str, u64)]) -> Result<u64> {
if files.is_empty() || files.len() > MAX_FILES {
return bad("file count out of range");
}
let cap = kind_cap(kind);
let mut seen: Vec<String> = Vec::with_capacity(files.len());
let mut total = 0_u64;
for (path, size) in files {
check_path(kind, path)?;
if *size == 0 {
return bad("zero-size file");
}
if *size > cap {
return bad("file is over the size cap");
}
let lower = path.to_ascii_lowercase();
if seen.contains(&lower) {
return bad("duplicate path");
}
seen.push(lower);
total = total.checked_add(*size).map_or_else(|| bad("total size overflows"), Ok)?;
}
if total > cap {
return bad("files are over the size cap");
}
Ok(total)
}
/// Checks a download link: [`notescraft_syntax::validate::check_url`] against each allowed host,
/// no `?` or `#`, at most 300 bytes.
///
/// # Errors
/// [`Error::Addon`] with the reason.
pub fn check_download_url(url: &str, allowed_hosts: &[&str]) -> Result<()> {
if url.len() > MAX_URL_BYTES {
return bad("link is too long");
}
if url.contains(['?', '#']) {
return bad("link has a query or fragment");
}
let mut last = String::from("no host is allowed");
for host in allowed_hosts {
match notescraft_syntax::validate::check_url(url, Some(host)) {
Ok(_) => return Ok(()),
Err(error) => last = error.to_string(),
}
}
Err(Error::Addon(last))
}
#[cfg(test)]
mod tests;
+109
View File
@@ -0,0 +1,109 @@
use super::*;
const HOSTS: [&str; 1] = ["git.marcospaulo.dev.br"];
#[test]
fn rejected_paths() {
let long = "a".repeat(65);
let cases: Vec<(KindRule, String)> = [
(KindRule::Theme, "../x"),
(KindRule::Theme, "a/../../b"),
(KindRule::Theme, "/abs"),
(KindRule::Theme, "C:\\x"),
(KindRule::Theme, "a\\b"),
(KindRule::Theme, "a//b"),
(KindRule::Theme, ".hidden"),
(KindRule::Pack, "themes/.b.toml"),
(KindRule::Theme, "a b.toml"),
(KindRule::Theme, "\u{e9}.toml"),
(KindRule::Theme, "x\0.toml"),
(KindRule::Theme, "CON.toml"),
(KindRule::Theme, "nul"),
(KindRule::Theme, "com1/x.toml"),
(KindRule::Theme, "LPT9.toml"),
(KindRule::Theme, ""),
(KindRule::Theme, "a/b/c/d/e.toml"),
(KindRule::Plugin, "evil.wasm"),
(KindRule::Plugin, "sub/plugin.wasm"),
(KindRule::Theme, "x.exe"),
(KindRule::Pack, "bin/x.toml"),
(KindRule::Pack, "x.toml"),
(KindRule::Pack, "grammars/x.toml"),
(KindRule::Grammar, "x.tmLanguage"),
]
.into_iter()
.map(|(k, p)| (k, p.to_owned()))
.chain([(KindRule::Theme, format!("{long}.toml")), (KindRule::Theme, "dir/".to_owned() + &long)])
.collect();
for (kind, path) in cases {
assert!(check_path(kind, &path).is_err(), "{path:?} should be rejected");
}
}
#[test]
fn accepted_paths() {
for (kind, path) in [
(KindRule::Grammar, "twig.sublime-syntax"),
(KindRule::Plugin, "plugin.wasm"),
(KindRule::Plugin, "manifest.json"),
(KindRule::Plugin, "README.md"),
(KindRule::Pack, "grammars/twig.sublime-syntax"),
(KindRule::Pack, "themes/a.toml"),
(KindRule::Pack, "icons/a.svg"),
(KindRule::Font, "Inter-Regular.ttf"),
(KindRule::Snippet, "a.txt"),
] {
assert!(check_path(kind, path).is_ok(), "{path}");
}
assert_eq!(check_path(KindRule::Pack, "themes/a.toml").unwrap().components, ["themes", "a.toml"]);
}
#[test]
fn set_rules() {
let many: Vec<String> = (0..65).map(|i| format!("f{i}.toml")).collect();
let many: Vec<(&str, u64)> = many.iter().map(|p| (p.as_str(), 1)).collect();
assert!(check_set(KindRule::Snippet, &many).is_err());
assert_eq!(check_set(KindRule::Snippet, &many[..64]).unwrap(), 64);
assert!(check_set(KindRule::Theme, &[]).is_err());
assert!(check_set(KindRule::Theme, &[("a.toml", 1), ("A.toml", 1)]).is_err());
assert!(check_set(KindRule::Theme, &[("a.toml", 0)]).is_err());
assert!(check_set(KindRule::Snippet, &[("a.md", u64::MAX), ("b.md", 2)]).is_err());
let cap = kind_cap(KindRule::Theme);
assert_eq!(check_set(KindRule::Theme, &[("a.toml", cap - 1), ("b.toml", 1)]).unwrap(), cap);
assert!(check_set(KindRule::Pack, &[("themes/a.toml", cap), ("themes/b.toml", 1)]).is_ok());
assert!(check_set(KindRule::Theme, &[("a.toml", cap), ("b.toml", 1)]).is_err());
assert!(check_set(KindRule::Font, &[("a.ttf", 33 * 1024 * 1024)]).is_err());
assert!(check_set(KindRule::Font, &[("a.ttf", 32 * 1024 * 1024)]).is_ok());
assert!(check_set(KindRule::Theme, &[("../a.toml", 1)]).is_err());
}
#[test]
fn download_urls() {
let ok = "https://git.marcospaulo.dev.br/masi/notescraft-registry/raw/branch/main/files/a.toml";
assert!(check_download_url(ok, &HOSTS).is_ok());
assert!(check_download_url(ok, &["other.example", "git.marcospaulo.dev.br"]).is_ok());
for url in [
"http://git.marcospaulo.dev.br/a",
"https://user:pw@git.marcospaulo.dev.br/a",
"https://git.marcospaulo.dev.br:8443/a",
"https://127.0.0.1/a",
"https://localhost/a",
"https://evil.example/a",
"https://git.marcospaulo.dev.br/a?x=1",
"https://git.marcospaulo.dev.br/a#f",
] {
assert!(check_download_url(url, &HOSTS).is_err(), "{url}");
}
let long = format!("https://git.marcospaulo.dev.br/{}", "a".repeat(300 - "https://git.marcospaulo.dev.br/".len() + 1));
assert_eq!(long.len(), 301);
assert!(check_download_url(&long, &HOSTS).is_err());
assert!(check_download_url(ok, &[]).is_err());
}
#[test]
fn join_stays_under_root() {
let root = Path::new("/data/registry/store");
let joined = join_under(root, &check_path(KindRule::Pack, "grammars/twig.sublime-syntax").unwrap());
assert!(joined.starts_with(root));
assert!(joined.ends_with("grammars/twig.sublime-syntax"));
}
+233
View File
@@ -0,0 +1,233 @@
//! Install, update, remove and rollback of add-ons (spec D6).
//!
//! The only commit point is [`Store::commit`]: the version folder is written first and is
//! unreferenced until the state names it, so a crash leaves garbage that [`Store::gc`] removes,
//! never a half-install. The old version is never deleted before the new state is committed.
use std::fmt::Write as _;
use std::io::ErrorKind;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use notescraft_syntax::validate::sha256_hex;
use super::fileset::{KindRule, check_path, check_set, join_under};
use super::kinds;
use super::model::{Entry, Index, Kind, Version};
use super::net::{ENTRY_TIMEOUT, Transport, coded, download_file};
use super::state::{Installed, MAX_INSTALLED, Source, State, key};
use super::store::Store;
use super::verify::Verifier;
use crate::error::{Error, Result};
/// A read-only copy of signed add-on files shipped with the app (spec D11).
pub trait Seed: std::fmt::Debug {
/// The bytes of `path` of this add-on release, if the seed holds it. The caller still checks
/// the sha256 against the entry, so a stale or tampered seed file is simply not used.
fn read(&self, kind: Kind, id: &str, version: Version, path: &str) -> Option<Vec<u8>>;
}
/// Everything an operation needs; all parts are injected so tests never touch the network.
pub struct Ctx<'a> {
/// The on-disk store.
pub store: &'a Store,
/// How files are downloaded.
pub transport: &'a dyn Transport,
/// How signatures are checked.
pub verifier: &'a dyn Verifier,
/// This app's version, compared with `min_app_version`.
pub app_version: Version,
/// Files shipped with the app, preferred over the network when their hash matches.
pub seed: Option<&'a dyn Seed>,
}
/// What an operation changed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallReport {
/// Kind of the add-on.
pub kind: Kind,
/// Its id.
pub id: String,
/// The version now active.
pub version: Version,
/// The version rollback returns to.
pub previous: Option<Version>,
/// Where the files came from.
pub source: Source,
/// Total bytes of the files.
pub bytes: u64,
}
/// Downloaded and checked files, not yet on disk.
#[derive(Debug)]
pub struct Staged {
/// `(path, bytes)` in the entry's order.
pub files: Vec<(String, Vec<u8>)>,
/// Whether every file came from the seed.
pub all_seed: bool,
}
fn io(context: &str, error: &std::io::Error) -> Error {
if error.kind() == ErrorKind::StorageFull { coded("disk_full", context) } else { Error::Addon(format!("{context}: {error}")) }
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |elapsed| elapsed.as_secs())
}
/// Steps 1 and 2 of D6: every refusal that needs no download. Returns the installed row, if any.
fn preflight(ctx: &Ctx<'_>, index: &Index, entry: &Entry, state: &State) -> Result<Option<Installed>> {
if entry.min_app_version > ctx.app_version {
return Err(coded("incompatible", format!("`{}` needs NotesCraft {} or newer", entry.id, entry.min_app_version)));
}
if index.is_revoked(entry) {
return Err(coded("revoked", format!("{} of `{}` was pulled", entry.version, entry.id)));
}
if matches!(entry.kind, Kind::Font | Kind::Snippet) {
return Err(Error::Addon(format!("`{}`: this version has no use for {} add-ons", entry.id, entry.kind.as_str())));
}
ctx.verifier.verify(entry)?;
let listed: Vec<(&str, u64)> = entry.files.iter().map(|f| (f.path.as_str(), f.size)).collect();
check_set(KindRule::from(entry.kind), &listed)?;
let current = state.installed.get(&key(entry.kind, &entry.id)).cloned();
match &current {
Some(row) if entry.version == row.version => return Err(Error::Addon(format!("`{}` {} is already installed", entry.id, row.version))),
Some(row) if entry.version < row.version => {
return Err(Error::Addon(format!("`{}` {} is older than the installed {}; use rollback", entry.id, entry.version, row.version)));
}
None if state.installed.len() >= MAX_INSTALLED => return Err(coded("limit", format!("more than {MAX_INSTALLED} add-ons installed"))),
_ => {}
}
Ok(current)
}
/// Steps 3 and 4 of D6: fetch every file (seed first), then run the per-kind checks.
///
/// # Errors
/// [`Error::Addon`] from the transport, a gate or a content check; nothing is on disk.
pub fn fetch_files(ctx: &Ctx<'_>, entry: &Entry) -> Result<Staged> {
let cap = entry.kind.cap();
let started = Instant::now();
let mut files = Vec::with_capacity(entry.files.len());
let mut all_seed = true;
for file in &entry.files {
if started.elapsed() > ENTRY_TIMEOUT {
return Err(coded("offline", format!("downloading `{}` took too long", entry.id)));
}
let seeded = ctx.seed.and_then(|seed| seed.read(entry.kind, &entry.id, entry.version, &file.path)).filter(|bytes| sha256_hex(bytes) == file.sha256);
let bytes = if let Some(bytes) = seeded {
bytes
} else {
all_seed = false;
download_file(ctx.transport, file, cap)?
};
files.push((file.path.clone(), bytes));
}
kinds::check(entry.kind, entry, &files)?;
Ok(Staged { files, all_seed })
}
/// Step 5 of D6: writes the version folder with `write_new` per file. A leftover folder of the
/// same version (garbage of a crash; the state does not reference it) is replaced.
///
/// # Errors
/// [`Error::Addon`] (`disk_full: ...` when the disk is full).
pub fn write_tree(store: &Store, entry: &Entry, staged: &Staged) -> Result<()> {
let dir = store.version_dir(entry.kind, &entry.id, entry.version)?;
if dir.exists() {
std::fs::remove_dir_all(&dir).map_err(|e| io("cannot replace an unreferenced version folder", &e))?;
}
for (path, bytes) in &staged.files {
let target = join_under(&dir, &check_path(KindRule::from(entry.kind), path)?);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|e| io("cannot create a store folder", &e))?;
}
craft_atomic::write_new(&target, bytes).map_err(|e| io("cannot write a store file", &e))?;
}
Ok(())
}
fn files_hash(entry: &Entry) -> String {
let mut files: Vec<_> = entry.files.iter().collect();
files.sort_by(|a, b| a.path.cmp(&b.path));
let text = files.iter().fold(String::new(), |mut text, f| {
let _ = writeln!(text, "{}\n{}", f.path, f.sha256);
text
});
sha256_hex(text.as_bytes())
}
fn apply(ctx: &Ctx<'_>, index: &Index, kind: Kind, id: &str, version: Option<Version>, must_be_installed: bool) -> Result<InstallReport> {
let entry = index.find(kind, id).ok_or_else(|| coded("not_found", format!("the registry has no {} `{id}`", kind.as_str())))?;
if version.is_some_and(|wanted| wanted != entry.version) {
return Err(coded("not_found", format!("the registry has no version of `{id}` but {}", entry.version)));
}
let mut state = ctx.store.load()?;
if must_be_installed && !state.installed.contains_key(&key(kind, id)) {
return Err(coded("not_found", format!("`{id}` is not installed")));
}
let current = preflight(ctx, index, entry, &state)?;
let staged = fetch_files(ctx, entry)?;
write_tree(ctx.store, entry, &staged)?;
let source = if staged.all_seed { Source::Seed } else { Source::Registry };
let row = Installed {
version: entry.version,
previous: current.map(|row| row.version),
source,
installed_at: now().to_string(),
sha256_of_files: files_hash(entry),
};
let previous = row.previous;
state.installed.insert(key(kind, id), row);
ctx.store.commit(&state)?;
// ponytail: a failed sweep after the commit only leaves garbage the next start removes.
let _ = ctx.store.gc(&state);
Ok(InstallReport { kind, id: id.to_owned(), version: entry.version, previous, source, bytes: entry.total_size() })
}
/// Installs `id` (or, when an older version is installed, updates it).
///
/// # Errors
/// `not_found`, `incompatible`, `revoked`, `limit`, `offline`, `disk_full` (see
/// [`super::net::split_code`]), or a refused gate: signature, file rules, hash, content.
pub fn install(ctx: &Ctx<'_>, index: &Index, kind: Kind, id: &str, version: Option<Version>) -> Result<InstallReport> {
apply(ctx, index, kind, id, version, false)
}
/// Installs the index's newer version of an installed add-on; the old one stays as `previous`.
///
/// # Errors
/// As [`install`], plus `not_found` when `id` is not installed.
pub fn update(ctx: &Ctx<'_>, index: &Index, kind: Kind, id: &str) -> Result<InstallReport> {
apply(ctx, index, kind, id, None, true)
}
/// Removes an add-on: the state row goes first (the commit), then the folders.
///
/// # Errors
/// `not_found: ...` when it is not installed, or a store failure.
pub fn remove(store: &Store, kind: Kind, id: &str) -> Result<()> {
let mut state = store.load()?;
if state.installed.remove(&key(kind, id)).is_none() {
return Err(coded("not_found", format!("`{id}` is not installed")));
}
store.commit(&state)?;
store.gc(&state).map(|_| ())
}
/// Swaps `version` and `previous`; the old folder is still in the store.
///
/// # Errors
/// `not_found: ...` when it is not installed or has no previous version.
pub fn rollback(store: &Store, kind: Kind, id: &str) -> Result<InstallReport> {
let mut state = store.load()?;
let row = state.installed.get_mut(&key(kind, id)).ok_or_else(|| coded("not_found", format!("`{id}` is not installed")))?;
let Some(back) = row.previous else { return Err(coded("not_found", format!("`{id}` has no earlier version to return to"))) };
if !store.version_dir(kind, id, back)?.is_dir() {
return Err(coded("not_found", format!("the files of `{id}` {back} are gone")));
}
let (version, source) = (back, row.source);
row.previous = Some(row.version);
row.version = back;
store.commit(&state)?;
Ok(InstallReport { kind, id: id.to_owned(), version, previous: state.installed.get(&key(kind, id)).and_then(|r| r.previous), source, bytes: 0 })
}
+32
View File
@@ -0,0 +1,32 @@
//! What the store holds for the consumers: the grammar texts the highlighter loads.
use super::model::Kind;
use super::state::parse_key;
use crate::session::Session;
/// Most bytes read from one installed grammar file.
const MAX_GRAMMAR_BYTES: u64 = 512 * 1024;
/// Extension of the one grammar format the registry serves.
const GRAMMAR_EXT: &str = "sublime-syntax";
/// `(id, .sublime-syntax text)` for every grammar the add-on store has installed. A missing,
/// oversized or unreadable file is skipped; no network, no writes.
pub(crate) fn installed_grammars(session: &Session) -> Vec<(String, String)> {
let Ok(store) = super::commands::store(session) else { return Vec::new() };
let Ok(state) = store.load() else { return Vec::new() };
let mut found = Vec::new();
for (name, row) in &state.installed {
let Ok((Kind::Grammar, id)) = parse_key(name) else { continue };
let Ok(dir) = store.version_dir(Kind::Grammar, id, row.version) else { continue };
let Ok(files) = std::fs::read_dir(&dir) else { continue };
for file in files.flatten() {
let path = file.path();
let ok = path.extension().is_some_and(|ext| ext == GRAMMAR_EXT) && file.metadata().is_ok_and(|meta| meta.len() <= MAX_GRAMMAR_BYTES);
if let (true, Ok(text)) = (ok, std::fs::read_to_string(&path)) {
found.push((id.to_owned(), text));
break;
}
}
}
found
}
+10
View File
@@ -0,0 +1,10 @@
//! The ed25519 public keys the app trusts for registry entries (spec D3).
//!
//! A rotation is a release that adds `k2` and later drops `k1`. The private key lives with the
//! owner and never enters this repository.
/// `(key_id, 32-byte ed25519 public key)` pairs.
///
/// Empty until the owner supplies the production public key: with no trusted key every entry
/// fails verification, so nothing can be installed from an unsigned or foreign index.
pub const TRUSTED_KEYS: [(&str, [u8; 32]); 0] = [];
+130
View File
@@ -0,0 +1,130 @@
//! Per-kind content checks of staged add-on files (spec D8).
//!
//! Runs on the bytes already received and hash-checked, before anything reaches the store. It
//! parses; it never instantiates WebAssembly (the plugin consumer does the dry load).
use std::collections::BTreeSet;
use std::path::Path;
use notescraft_plugins::Manifest;
use notescraft_syntax::validate::check_grammar;
use notescraft_themes::{DiagnosticKind, Theme};
use super::model::{Entry, Kind};
use crate::error::{Error, Result};
/// Text every grammar is run over; the index carries no sample of its own.
const GRAMMAR_SAMPLE: &str = "x = 1\nfoo(bar)\n";
/// First four bytes of a WebAssembly module.
const WASM_MAGIC: &[u8] = b"\0asm";
/// Accepted font signatures: TrueType, OpenType with CFF, old Apple TrueType.
const FONT_MAGICS: [&[u8]; 3] = [&[0, 1, 0, 0], b"OTTO", b"true"];
/// Plugin files that may carry any bytes (a README is text, checked by size only).
const PLUGIN_README: &str = "README.md";
fn bad<T>(reason: impl Into<String>) -> Result<T> {
Err(Error::Addon(reason.into()))
}
fn text<'a>(path: &str, bytes: &'a [u8]) -> Result<&'a str> {
let text = std::str::from_utf8(bytes).map_err(|_| Error::Addon(format!("`{path}` is not UTF-8 text")))?;
if text.contains('\0') {
return bad(format!("`{path}` contains a NUL byte"));
}
Ok(text)
}
fn grammar(path: &str, bytes: &[u8]) -> Result<()> {
check_grammar(text(path, bytes)?, GRAMMAR_SAMPLE).map(|_| ()).map_err(|e| Error::Addon(format!("`{path}` is not a usable grammar: {e}")))
}
/// A theme file: loads with no rejected value, and does not reuse a built-in id. With
/// `expect_id` (a theme entry) the theme id must also equal the entry id.
fn theme(path: &str, bytes: &[u8], expect_id: Option<&str>) -> Result<()> {
let (theme, diagnostics) = Theme::from_toml(text(path, bytes)?).map_err(|e| Error::Addon(format!("`{path}` is not a theme: {e}")))?;
if let Some(hard) = diagnostics.iter().find(|d| d.kind == DiagnosticKind::InvalidValue) {
return bad(format!("`{path}` has a rejected value: {hard}"));
}
if notescraft_themes::builtin(&theme.id).is_some() {
return bad(format!("`{path}` reuses the built-in theme id `{}`", theme.id));
}
match expect_id {
Some(id) if id != theme.id => bad(format!("`{path}` has theme id `{}`, the entry is `{id}`", theme.id)),
_ => Ok(()),
}
}
fn plugin(entry: &Entry, files: &[(String, Vec<u8>)]) -> Result<()> {
let find = |name: &str| files.iter().find(|(path, _)| path == name).map(|(_, bytes)| bytes.as_slice());
let (Some(manifest), Some(wasm)) = (find("manifest.json"), find("plugin.wasm")) else {
return bad("a plugin needs manifest.json and plugin.wasm");
};
let manifest = Manifest::from_json(text("manifest.json", manifest)?).map_err(|e| Error::Addon(format!("manifest.json: {e}")))?;
if manifest.id != entry.id {
return bad(format!("manifest id `{}` is not the entry id `{}`", manifest.id, entry.id));
}
if manifest.version != entry.version.to_string() {
return bad(format!("manifest version `{}` is not the entry version {}", manifest.version, entry.version));
}
let declared: BTreeSet<&str> = manifest.permissions.iter().map(|p| p.as_str()).collect();
let listed: BTreeSet<&str> = entry.permissions.iter().map(String::as_str).collect();
if declared != listed {
return bad("manifest permissions are not the permissions the index lists");
}
if !wasm.starts_with(WASM_MAGIC) {
return bad("plugin.wasm is not a WebAssembly module");
}
if let Some(readme) = find(PLUGIN_README) {
text(PLUGIN_README, readme)?;
}
Ok(())
}
fn font(path: &str, bytes: &[u8]) -> Result<()> {
if FONT_MAGICS.iter().any(|magic| bytes.starts_with(magic)) { Ok(()) } else { bad(format!("`{path}` is not a TrueType or OpenType font")) }
}
fn pack_file(path: &str, bytes: &[u8]) -> Result<()> {
match path.split('/').next() {
Some("grammars") => grammar(path, bytes),
Some("themes") => theme(path, bytes, None),
Some("templates") => text(path, bytes).map(|_| ()),
Some("icons") if Path::new(path).extension().is_some_and(|ext| ext.eq_ignore_ascii_case("json")) => {
serde_json::from_slice::<serde_json::Value>(bytes).map(|_| ()).map_err(|e| Error::Addon(format!("`{path}` is not JSON: {e}")))
}
Some("icons") => text(path, bytes).map(|_| ()),
_ => bad(format!("`{path}` is not in a pack folder")),
}
}
/// Checks the staged `files` of `entry` for `kind`: the file set equals the listed one, then each
/// file passes the check of its kind (grammar parses and runs a sample, theme loads with no hard
/// diagnostic, plugin manifest equals the entry and the module has the wasm magic, font magic
/// bytes, snippet UTF-8 without NUL, pack files by folder).
///
/// # Errors
/// [`Error::Addon`] naming the file and the broken rule.
pub fn check(kind: Kind, entry: &Entry, files: &[(String, Vec<u8>)]) -> Result<()> {
let staged: BTreeSet<&str> = files.iter().map(|(path, _)| path.as_str()).collect();
let listed: BTreeSet<&str> = entry.files.iter().map(|file| file.path.as_str()).collect();
if staged != listed || staged.len() != files.len() {
return bad("the staged files are not the listed files");
}
if kind == Kind::Plugin {
return plugin(entry, files);
}
if matches!(kind, Kind::Grammar | Kind::Theme) && files.len() != 1 {
return bad("a grammar or theme entry has exactly one file");
}
for (path, bytes) in files {
match kind {
Kind::Grammar => grammar(path, bytes)?,
Kind::Theme => theme(path, bytes, Some(&entry.id))?,
Kind::Font => font(path, bytes)?,
Kind::Snippet => text(path, bytes).map(|_| ())?,
Kind::Pack => pack_file(path, bytes)?,
Kind::Plugin => {}
}
}
Ok(())
}
+30
View File
@@ -0,0 +1,30 @@
//! Separable add-ons: the registry index, hostile-input guards, signatures and the local store.
//!
//! Not the command registry (`crate::registry`): this module is the add-on catalogue the
//! `registry.*` commands will drive.
mod commands;
mod fileset;
mod install;
mod installed;
mod keys;
mod kinds;
mod model;
mod net;
mod state;
mod store;
mod verify;
pub(crate) use commands::{TrustedVerifier, register_addons};
pub use fileset::{Checked, KindRule, MAX_COMPONENT_BYTES, MAX_COMPONENTS, MAX_FILES, check_download_url, check_path, check_set, join_under, kind_cap};
pub use install::{Ctx, InstallReport, Seed, Staged, fetch_files, install, remove, rollback, update, write_tree};
pub(crate) use installed::installed_grammars;
pub use keys::TRUSTED_KEYS;
pub use kinds::check as check_content;
pub use model::{Entry, File, Index, Kind, MAX_INDEX_BYTES, Revoked, Signature, Version};
pub use net::{
ENTRY_TIMEOUT, FILE_TIMEOUT, FakeTransport, INDEX_URL, IndexCache, REGISTRY_HOSTS, Response, Transport, Ureq, coded, download_file, fetch_index, split_code,
};
pub use state::{FILE_VERSION, Installed, MAX_INSTALLED, MAX_VAULT_IDS, Source, State, VaultFile, key, parse_key};
pub use store::Store;
pub use verify::{Ed25519, FakeVerifier, Verifier, canonical_text};
+282
View File
@@ -0,0 +1,282 @@
//! The add-on index (schema v2): entries, versions, file lists and their validation.
//!
//! Pure data: no IO, no network, no signature check. [`Index::parse`] refuses a bad top level and
//! skips a broken entry (recording why in [`Index::skipped`]) so one bad row never hides the rest.
//! Nothing is clamped or repaired.
use serde_json::Value;
use self::raw::{RawEntry, RawIndex, RawRevoked, validate};
use crate::error::{Error, Result};
/// Largest index file, in bytes.
pub const MAX_INDEX_BYTES: usize = 1024 * 1024;
/// Most entries an index may hold.
pub const MAX_ENTRIES: usize = 2000;
/// Most files an entry may list.
pub const MAX_ENTRY_FILES: usize = 64;
/// Most aliases an entry may carry.
const MAX_ALIASES: usize = 16;
/// The only index schema version this build reads.
const INDEX_VERSION: u64 = 2;
/// Longest id, in bytes.
const MAX_ID: usize = 48;
/// Longest title, in characters.
const MAX_TITLE: usize = 80;
/// Longest summary, in characters.
const MAX_SUMMARY: usize = 200;
/// Longest license, in characters.
const MAX_LICENSE: usize = 40;
/// Longest alias, in bytes.
const MAX_ALIAS: usize = 32;
/// Longest link or file path, in bytes.
const MAX_TEXT: usize = 2048;
/// Longest signature key id, in bytes.
const MAX_KEY_ID: usize = 32;
/// Longest base64 signature, in bytes.
const MAX_SIG: usize = 128;
/// Longest timestamp, in bytes.
const MAX_STAMP: usize = 40;
/// Largest value of a version component.
const MAX_VERSION_PART: u16 = 9999;
/// Hex length of a SHA-256.
const SHA256_HEX: usize = 64;
/// Permissions a plugin entry may request.
const PERMISSIONS: [&str; 3] = ["vault.read", "vault.write", "editor"];
/// One KiB.
const KIB: u64 = 1024;
/// One MiB.
const MIB: u64 = 1024 * KIB;
/// What an add-on is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Kind {
/// A WebAssembly plugin.
Plugin,
/// A colour theme.
Theme,
/// Several files for several consumers, installed as one unit.
Pack,
/// A syntax-highlighting grammar.
Grammar,
/// A font file.
Font,
/// Text snippets and templates.
Snippet,
}
impl Kind {
/// The name used in the index and in store folders.
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Plugin => "plugin",
Self::Theme => "theme",
Self::Pack => "pack",
Self::Grammar => "grammar",
Self::Font => "font",
Self::Snippet => "snippet",
}
}
/// Parses the name used in the index; anything else is `None`.
#[must_use]
pub fn parse(name: &str) -> Option<Self> {
[Self::Plugin, Self::Theme, Self::Pack, Self::Grammar, Self::Font, Self::Snippet].into_iter().find(|kind| kind.as_str() == name)
}
/// Most bytes all files of one entry of this kind may add up to.
#[must_use]
pub const fn cap(self) -> u64 {
match self {
Self::Plugin => 4 * MIB,
Self::Theme => 64 * KIB,
Self::Grammar => 512 * KIB,
Self::Snippet => MIB,
Self::Pack => 8 * MIB,
Self::Font => 32 * MIB,
}
}
}
/// `MAJOR.MINOR.PATCH`, each part 0..=9999, no pre-release. Orders numerically.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Version {
/// Major part.
pub major: u16,
/// Minor part.
pub minor: u16,
/// Patch part.
pub patch: u16,
}
impl Version {
/// Parses `1.2.3`: three decimal parts, no sign, no leading zero, none above 9999.
///
/// # Errors
/// [`Error::Addon`] naming the problem.
pub fn parse(text: &str) -> Result<Self> {
let mut parts = text.split('.');
let (Some(major), Some(minor), Some(patch), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
return Err(Error::Addon(format!("version `{text}` is not MAJOR.MINOR.PATCH")));
};
Ok(Self { major: part(text, major)?, minor: part(text, minor)?, patch: part(text, patch)? })
}
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
fn part(text: &str, digits: &str) -> Result<u16> {
let bad = || Error::Addon(format!("version `{text}` is not MAJOR.MINOR.PATCH"));
if digits.is_empty() || digits.len() > 4 || !digits.bytes().all(|b| b.is_ascii_digit()) || digits.len() > 1 && digits.starts_with('0') {
return Err(bad());
}
let value: u16 = digits.parse().map_err(|_| bad())?;
if value > MAX_VERSION_PART { Err(bad()) } else { Ok(value) }
}
/// One downloadable file of an entry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct File {
/// Relative path inside the entry (checked again by the fileset guard).
pub path: String,
/// Size in bytes, never zero.
pub size: u64,
/// Lowercase hex SHA-256 of the bytes.
pub sha256: String,
/// Absolute `https` link.
pub url: String,
}
/// The owner's ed25519 signature over the entry's canonical text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Signature {
/// Which trusted key signed.
pub key_id: String,
/// Base64 signature.
pub sig: String,
}
/// One add-on of the index.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
/// Unique per kind: `[a-z0-9-]{1,48}`.
pub id: String,
/// What it is.
pub kind: Kind,
/// Display name.
pub title: String,
/// One-line description, may be empty.
pub summary: String,
/// Release version.
pub version: Version,
/// Oldest app version that can use it.
pub min_app_version: Version,
/// License name.
pub license: String,
/// Where the source lives (display only).
pub source: Option<String>,
/// Fence labels (grammars) or search words.
pub aliases: Vec<String>,
/// Offered in the first-run list.
pub suggested: bool,
/// Plugin permissions (`vault.read`, `vault.write`, `editor`).
pub permissions: Vec<String>,
/// The files, 1..=64.
pub files: Vec<File>,
/// The signature.
pub signature: Signature,
}
impl Entry {
/// Sum of the file sizes, saturating.
#[must_use]
pub fn total_size(&self) -> u64 {
self.files.iter().fold(0_u64, |sum, file| sum.saturating_add(file.size))
}
}
/// A pulled release.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Revoked {
/// The add-on id.
pub id: String,
/// The pulled version.
pub version: Version,
}
/// A validated index.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Index {
/// When the index was generated (opaque text).
pub generated_at: String,
/// The valid entries.
pub entries: Vec<Entry>,
/// Pulled releases.
pub revoked: Vec<Revoked>,
/// `id: reason` of every entry or revocation that was skipped.
pub skipped: Vec<String>,
}
impl Index {
/// Parses and validates an index document.
///
/// # Errors
/// [`Error::Addon`] when the input is over [`MAX_INDEX_BYTES`], is not JSON, is not schema
/// version 2 or holds more than [`MAX_ENTRIES`] entries. A broken entry is skipped, not an error.
pub fn parse(json: &[u8]) -> Result<Self> {
if json.len() > MAX_INDEX_BYTES {
return Err(Error::Addon(format!("index is over {MAX_INDEX_BYTES} bytes")));
}
let raw: RawIndex = serde_json::from_slice(json).map_err(|e| Error::Addon(format!("index is not valid: {e}")))?;
if raw.version != Some(INDEX_VERSION) {
return Err(Error::Addon("index version is not 2".into()));
}
if raw.entries.len() > MAX_ENTRIES {
return Err(Error::Addon(format!("index has more than {MAX_ENTRIES} entries")));
}
if raw.generated_at.len() > MAX_STAMP {
return Err(Error::Addon("index generated_at is too long".into()));
}
let mut index = Self { generated_at: raw.generated_at, ..Self::default() };
for value in raw.entries {
let label = value.get("id").and_then(Value::as_str).map_or_else(|| "?".to_owned(), str::to_owned);
match serde_json::from_value::<RawEntry>(value).map_err(|e| e.to_string()).and_then(validate) {
Ok(entry) if index.find(entry.kind, &entry.id).is_some() => index.skipped.push(format!("{label}: duplicate id")),
Ok(entry) => index.entries.push(entry),
Err(reason) => index.skipped.push(format!("{label}: {reason}")),
}
}
for value in raw.revoked {
let parsed = serde_json::from_value::<RawRevoked>(value).map_err(|e| e.to_string());
match parsed.and_then(|r| Version::parse(&r.version).map(|version| Revoked { id: r.id, version }).map_err(|e| e.to_string())) {
Ok(revoked) => index.revoked.push(revoked),
Err(reason) => index.skipped.push(format!("?: revoked: {reason}")),
}
}
Ok(index)
}
/// The entry with this kind and id.
#[must_use]
pub fn find(&self, kind: Kind, id: &str) -> Option<&Entry> {
self.entries.iter().find(|e| e.kind == kind && e.id == id)
}
/// Whether the index pulled this exact release.
#[must_use]
pub fn is_revoked(&self, entry: &Entry) -> bool {
self.revoked.iter().any(|r| r.id == entry.id && r.version == entry.version)
}
}
mod raw;
pub(crate) use raw::is_valid_id;
#[cfg(test)]
mod tests;
+156
View File
@@ -0,0 +1,156 @@
//! Raw JSON shapes of the index and the rules that turn them into validated [`Entry`] values.
use serde::Deserialize;
use serde_json::Value;
use super::{
Entry, File, Kind, MAX_ALIAS, MAX_ALIASES, MAX_ENTRY_FILES, MAX_ID, MAX_KEY_ID, MAX_LICENSE, MAX_SIG, MAX_SUMMARY, MAX_TEXT, MAX_TITLE, PERMISSIONS,
SHA256_HEX, Signature, Version,
};
#[derive(Deserialize)]
pub(super) struct RawIndex {
pub(super) version: Option<u64>,
#[serde(default)]
pub(super) generated_at: String,
#[serde(default)]
pub(super) entries: Vec<Value>,
#[serde(default)]
pub(super) revoked: Vec<Value>,
}
#[derive(Deserialize)]
pub(super) struct RawFile {
pub(super) path: String,
pub(super) size: u64,
pub(super) sha256: String,
pub(super) url: String,
}
#[derive(Deserialize)]
pub(super) struct RawSignature {
pub(super) key_id: String,
pub(super) sig: String,
}
#[derive(Deserialize)]
pub(super) struct RawEntry {
pub(super) id: String,
pub(super) kind: String,
pub(super) title: String,
#[serde(default)]
pub(super) summary: String,
pub(super) version: String,
pub(super) min_app_version: String,
pub(super) license: String,
pub(super) source: Option<String>,
#[serde(default)]
pub(super) aliases: Vec<String>,
#[serde(default)]
pub(super) suggested: bool,
#[serde(default)]
pub(super) permissions: Vec<String>,
pub(super) files: Vec<RawFile>,
pub(super) signature: RawSignature,
}
#[derive(Deserialize)]
pub(super) struct RawRevoked {
pub(super) id: String,
pub(super) version: String,
}
/// Whether `id` is a valid add-on id: `[a-z0-9-]{1,48}`.
pub(crate) fn is_valid_id(id: &str) -> bool {
charset(id, MAX_ID, |c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn no_control(text: &str) -> bool {
!text.chars().any(char::is_control)
}
fn charset(text: &str, max: usize, ok: impl Fn(char) -> bool) -> bool {
!text.is_empty() && text.len() <= max && text.chars().all(ok)
}
fn https(text: &str) -> bool {
text.len() <= MAX_TEXT && text.starts_with("https://") && !text.chars().any(|c| c.is_control() || c.is_whitespace())
}
pub(super) fn validate(raw: RawEntry) -> std::result::Result<Entry, String> {
if !is_valid_id(&raw.id) {
return Err("invalid id".into());
}
let kind = Kind::parse(&raw.kind).ok_or("unknown kind")?;
let title_len = raw.title.chars().count();
if title_len == 0 || title_len > MAX_TITLE || !no_control(&raw.title) {
return Err("invalid title".into());
}
if raw.summary.chars().count() > MAX_SUMMARY || !no_control(&raw.summary) {
return Err("invalid summary".into());
}
let license_len = raw.license.chars().count();
if license_len == 0 || license_len > MAX_LICENSE || !no_control(&raw.license) {
return Err("invalid license".into());
}
let version = Version::parse(&raw.version).map_err(|e| e.to_string())?;
let min_app_version = Version::parse(&raw.min_app_version).map_err(|e| e.to_string())?;
if raw.source.as_deref().is_some_and(|s| !https(s)) {
return Err("invalid source".into());
}
let alias_ok = |a: &String| charset(a, MAX_ALIAS, |c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '+' | '-'));
if raw.aliases.len() > MAX_ALIASES || !raw.aliases.iter().all(alias_ok) {
return Err("invalid aliases".into());
}
if !raw.permissions.is_empty() && kind != Kind::Plugin {
return Err("permissions on a non-plugin".into());
}
if !raw.permissions.iter().all(|p| PERMISSIONS.contains(&p.as_str())) {
return Err("unknown permission".into());
}
if raw.files.is_empty() || raw.files.len() > MAX_ENTRY_FILES {
return Err("file count out of range".into());
}
let mut total = 0_u64;
let mut files = Vec::with_capacity(raw.files.len());
for file in raw.files {
let hex = file.sha256.len() == SHA256_HEX && file.sha256.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
if file.path.is_empty() || file.path.len() > MAX_TEXT || !no_control(&file.path) {
return Err("invalid file path".into());
}
if !hex {
return Err("invalid sha256".into());
}
if !https(&file.url) {
return Err("file url is not https".into());
}
if file.size == 0 {
return Err("zero-size file".into());
}
total = total.saturating_add(file.size);
files.push(File { path: file.path, size: file.size, sha256: file.sha256, url: file.url });
}
if total > kind.cap() {
return Err("files are over the size cap".into());
}
let key_ok = charset(&raw.signature.key_id, MAX_KEY_ID, |c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'));
let sig_ok = charset(&raw.signature.sig, MAX_SIG, |c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '='));
if !key_ok || !sig_ok {
return Err("invalid signature".into());
}
Ok(Entry {
id: raw.id,
kind,
title: raw.title,
summary: raw.summary,
version,
min_app_version,
license: raw.license,
source: raw.source,
aliases: raw.aliases,
suggested: raw.suggested,
permissions: raw.permissions,
files,
signature: Signature { key_id: raw.signature.key_id, sig: raw.signature.sig },
})
}
+135
View File
@@ -0,0 +1,135 @@
use serde_json::{Value, json};
use super::*;
fn entry(id: &str, kind: &str) -> Value {
json!({
"id": id, "kind": kind, "title": "T", "summary": "s", "version": "1.0.0", "min_app_version": "0.1.0", "license": "MIT",
"aliases": ["a"], "permissions": [],
"files": [{ "path": "x.toml", "size": 10, "sha256": "a".repeat(64), "url": "https://h.example/x.toml" }],
"signature": { "key_id": "k1", "sig": "QUJD" }
})
}
fn index(entries: &[Value]) -> Vec<u8> {
json!({ "version": 2, "generated_at": "2026-10-21T12:00:00Z", "entries": entries, "revoked": [{ "id": "old", "version": "0.9.0" }] })
.to_string()
.into_bytes()
}
fn with(mut e: Value, key: &str, value: Value) -> Value {
e[key] = value;
e
}
#[test]
fn good_index_round_trip() {
let parsed = Index::parse(&index(&[entry("twig", "grammar"), entry("dice", "plugin")])).unwrap();
assert_eq!(parsed.entries.len(), 2);
assert_eq!(parsed.skipped.len(), 0);
assert_eq!(parsed.generated_at, "2026-10-21T12:00:00Z");
let twig = parsed.find(Kind::Grammar, "twig").unwrap();
assert_eq!(twig.version, Version { major: 1, minor: 0, patch: 0 });
assert_eq!(twig.total_size(), 10);
assert!(parsed.find(Kind::Theme, "twig").is_none());
assert_eq!(parsed.revoked.len(), 1);
}
#[test]
fn top_level_rejections() {
assert!(Index::parse(br#"{"version":1,"entries":[]}"#).is_err());
assert!(Index::parse(br#"{"version":3,"entries":[]}"#).is_err());
assert!(Index::parse(br#"{"entries":[]}"#).is_err());
assert!(Index::parse(b"nope").is_err());
assert!(Index::parse(&vec![b' '; MAX_INDEX_BYTES + 1]).is_err());
let many: Vec<Value> = (0..=MAX_ENTRIES).map(|i| entry(&format!("e{i}"), "theme")).collect();
assert!(Index::parse(&index(&many)).is_err());
let deep = format!("{}{}", "[".repeat(200), "]".repeat(200));
assert!(Index::parse(deep.as_bytes()).is_err());
assert!(Index::parse(format!(r#"{{"version":2,"entries":{deep}}}"#).as_bytes()).is_err());
}
#[test]
fn broken_entries_are_skipped() {
let long_files: Vec<Value> =
(0..65).map(|i| json!({ "path": format!("f{i}"), "size": 1, "sha256": "a".repeat(64), "url": "https://h.example/f" })).collect();
let file = |patch: Value| {
let mut f = entry("x", "theme")["files"][0].clone();
for (k, v) in patch.as_object().unwrap() {
f[k] = v.clone();
}
json!([f])
};
let bad = [
with(entry("Bad Id", "theme"), "id", json!("Bad Id")),
with(entry("sha", "theme"), "files", file(json!({ "sha256": "A".repeat(64) }))),
with(entry("http", "theme"), "files", file(json!({ "url": "http://h.example/x" }))),
with(entry("zero", "theme"), "files", file(json!({ "size": 0 }))),
with(entry("many", "theme"), "files", json!(long_files)),
with(entry("perm", "plugin"), "permissions", json!(["net"])),
with(entry("permtheme", "theme"), "permissions", json!(["editor"])),
entry("exe", "exe"),
with(entry("ver", "theme"), "version", json!("1.2")),
with(entry("nosig", "theme"), "signature", json!({ "key_id": "", "sig": "" })),
json!({ "kind": "theme" }),
];
let mut all = vec![entry("ok1", "theme")];
all.extend(bad.iter().cloned());
all.push(entry("ok2", "theme"));
let parsed = Index::parse(&index(&all)).unwrap();
assert_eq!(parsed.entries.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["ok1", "ok2"]);
assert_eq!(parsed.skipped.len(), bad.len());
assert!(parsed.skipped.iter().any(|s| s.starts_with("?:")));
assert!(parsed.skipped.iter().any(|s| s.starts_with("exe:")));
}
#[test]
fn valid_plugin_permissions_and_duplicates() {
let ok = with(entry("dice", "plugin"), "permissions", json!(["vault.read", "editor"]));
let parsed = Index::parse(&index(&[ok.clone(), ok, entry("dice", "theme")])).unwrap();
assert_eq!(parsed.entries.len(), 2);
assert_eq!(parsed.skipped, ["dice: duplicate id"]);
}
#[test]
fn per_kind_cap_is_exact() {
let big = |size: u64| {
with(entry("g", "grammar"), "files", json!([{ "path": "g.sublime-syntax", "size": size, "sha256": "b".repeat(64), "url": "https://h.example/g" }]))
};
let cap = Kind::Grammar.cap();
assert_eq!(Index::parse(&index(&[big(cap)])).unwrap().entries.len(), 1);
let over = Index::parse(&index(&[big(cap + 1)])).unwrap();
assert_eq!(over.entries.len(), 0);
assert_eq!(over.skipped.len(), 1);
let huge = Index::parse(&index(&[big(u64::MAX)])).unwrap();
assert_eq!(huge.entries.len(), 0);
}
#[test]
fn version_parse_and_order() {
assert_eq!(Version::parse("1.2.3").unwrap(), Version { major: 1, minor: 2, patch: 3 });
assert_eq!(Version::parse("9999.0.0").unwrap().to_string(), "9999.0.0");
for bad in ["01.2.3", "1.2", "1.2.3-beta", "10000.0.0", "", "1.2.3.4", "1..3", "+1.2.3", "-1.2.3", "1.2.x"] {
assert!(Version::parse(bad).is_err(), "{bad}");
}
assert!(Version::parse("1.10.0").unwrap() > Version::parse("1.9.9").unwrap());
}
#[test]
fn total_size_saturates() {
let parsed = Index::parse(&index(&[entry("a", "theme")])).unwrap();
let mut e = parsed.entries[0].clone();
let mut f = e.files[0].clone();
f.size = u64::MAX;
e.files = vec![f.clone(), f];
assert_eq!(e.total_size(), u64::MAX);
}
#[test]
fn revoked_match() {
let mut e = Index::parse(&index(&[entry("old", "theme")])).unwrap();
let old = e.entries[0].clone();
assert!(!e.is_revoked(&old));
e.revoked.push(Revoked { id: "old".into(), version: old.version });
assert!(e.is_revoked(&old));
}
+265
View File
@@ -0,0 +1,265 @@
//! Transport and bounded download of the add-on registry (spec D5, D7).
//!
//! [`Transport`] is injectable so no test touches the network. [`Ureq`] speaks https only, never
//! follows a redirect, has a total timeout and reads at most `max_bytes + 1` so an over-long body
//! is a failure, never a truncation. [`download_file`] adds the entry-level gates: host allow-list,
//! the listed size and the listed sha256, checked on the bytes actually received.
use std::io::Read;
use std::path::Path;
use std::sync::Mutex;
use std::time::Duration;
use notescraft_syntax::validate::sha256_hex;
use serde::{Deserialize, Serialize};
use super::fileset::check_download_url;
use super::model::{File, Index, MAX_INDEX_BYTES};
use crate::error::{Error, Result};
/// Hosts a download link may point at.
pub const REGISTRY_HOSTS: [&str; 1] = ["git.marcospaulo.dev.br"];
/// The registry index.
pub const INDEX_URL: &str = "https://git.marcospaulo.dev.br/masi/notescraft-registry/raw/branch/main/index.json";
/// Total time allowed for one request.
pub const FILE_TIMEOUT: Duration = Duration::from_secs(30);
/// Total time allowed for all files of one entry.
pub const ENTRY_TIMEOUT: Duration = Duration::from_secs(300);
/// Name of the cached index under the registry folder.
const CACHE_FILE: &str = "index-cache.json";
/// First redirect status.
const REDIRECT_FROM: u16 = 300;
/// Not modified.
const NOT_MODIFIED: u16 = 304;
/// OK.
const OK: u16 = 200;
/// Every machine code an add-on error may carry in its message prefix.
pub const CODES: [&str; 9] = ["offline", "not_found", "incompatible", "revoked", "limit", "disk_full", "needs_dependency", "download_failed", "addon"];
/// An [`Error::Addon`] whose message starts with a stable command code, e.g. `offline: ...`.
#[must_use]
pub fn coded(code: &str, message: impl std::fmt::Display) -> Error {
Error::Addon(format!("{code}: {message}"))
}
/// The command code and message of an add-on error: a tagged message keeps its tag, any other
/// message is a refused gate (`registry_refused`).
#[must_use]
pub fn split_code(message: &str) -> (&str, &str) {
match message.split_once(": ") {
Some((code, rest)) if CODES.contains(&code) => (code, rest),
_ => ("registry_refused", message),
}
}
/// What a server answered.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Response {
/// HTTP status: 200, or 304 for a conditional request that matched.
pub status: u16,
/// The `ETag` header, when present.
pub etag: Option<String>,
/// The body (empty for 304).
pub bytes: Vec<u8>,
}
/// Fetches one https link, never more than `max_bytes`.
pub trait Transport: std::fmt::Debug + Send {
/// Sends `If-None-Match` when `if_none_match` is set.
///
/// # Errors
/// `offline: ...` when there is no network, `download_failed: ...` for a redirect, another
/// status or a body over `max_bytes`.
fn get(&self, url: &str, max_bytes: u64, if_none_match: Option<&str>) -> Result<Response>;
}
/// The real transport: `ureq` with https only, no redirects and a global timeout.
#[derive(Debug, Clone)]
pub struct Ureq {
agent: ureq::Agent,
}
impl Default for Ureq {
fn default() -> Self {
Self::new()
}
}
impl Ureq {
/// A transport with the registry limits.
#[must_use]
pub fn new() -> Self {
let agent = ureq::Agent::config_builder().http_status_as_error(false).max_redirects(0).timeout_global(Some(FILE_TIMEOUT)).build().into();
Self { agent }
}
}
impl Transport for Ureq {
fn get(&self, url: &str, max_bytes: u64, if_none_match: Option<&str>) -> Result<Response> {
if !url.starts_with("https://") {
return Err(coded("download_failed", "only https links are allowed"));
}
let mut request = self.agent.get(url).header("User-Agent", concat!("NotesCraft/", env!("CARGO_PKG_VERSION")));
if let Some(etag) = if_none_match {
request = request.header("If-None-Match", etag);
}
let response = request.call().map_err(|error| match error {
ureq::Error::HostNotFound | ureq::Error::ConnectionFailed | ureq::Error::Timeout(_) | ureq::Error::Io(_) => coded("offline", "no network"),
other => coded("download_failed", other),
})?;
let status = response.status().as_u16();
let etag = response.headers().get("etag").and_then(|value| value.to_str().ok()).map(str::to_owned);
if status == NOT_MODIFIED {
return Ok(Response { status, etag, bytes: Vec::new() });
}
if status != OK {
let what = if (REDIRECT_FROM..400).contains(&status) { "redirects are not followed" } else { "the server refused" };
return Err(coded("download_failed", format!("{what} (status {status})")));
}
let mut bytes = Vec::new();
let mut body = response.into_body().into_reader().take(max_bytes.saturating_add(1));
body.read_to_end(&mut bytes).map_err(|error| coded("offline", error))?;
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max_bytes {
return Err(coded("download_failed", format!("the body is over {max_bytes} bytes")));
}
Ok(Response { status, etag, bytes })
}
}
/// A transport for tests: answers from a script, records the links asked for, and enforces
/// `max_bytes` and the status rules like the real one.
#[derive(Debug, Default)]
pub struct FakeTransport {
answers: Mutex<Vec<(String, std::result::Result<Response, String>)>>,
asked: Mutex<Vec<String>>,
}
impl FakeTransport {
/// An empty script: every link is `offline`.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Answers `url` with a 200 and `bytes`.
#[must_use]
pub fn with(self, url: &str, bytes: &[u8]) -> Self {
self.with_response(url, Ok(Response { status: OK, etag: None, bytes: bytes.to_vec() }))
}
/// Answers `url` with any response, or fails it with this [`Error::Addon`] message.
#[must_use]
pub fn with_response(self, url: &str, response: std::result::Result<Response, String>) -> Self {
if let Ok(mut answers) = self.answers.lock() {
answers.push((url.to_owned(), response));
}
self
}
/// The links asked for so far, in order.
#[must_use]
pub fn asked(&self) -> Vec<String> {
self.asked.lock().map(|asked| asked.clone()).unwrap_or_default()
}
}
impl Transport for FakeTransport {
fn get(&self, url: &str, max_bytes: u64, _if_none_match: Option<&str>) -> Result<Response> {
if let Ok(mut asked) = self.asked.lock() {
asked.push(url.to_owned());
}
let found = self.answers.lock().ok().and_then(|answers| answers.iter().find(|(known, _)| known == url).map(|(_, answer)| answer.clone()));
let response = found.unwrap_or_else(|| Err("offline: no network".to_owned())).map_err(Error::Addon)?;
if response.status >= REDIRECT_FROM && response.status != NOT_MODIFIED {
return Err(coded("download_failed", format!("status {}", response.status)));
}
if u64::try_from(response.bytes.len()).unwrap_or(u64::MAX) > max_bytes {
return Err(coded("download_failed", format!("the body is over {max_bytes} bytes")));
}
Ok(response)
}
}
/// Downloads one listed file and checks it: link on an allowed host, listed size at most `cap`,
/// body exactly the listed size, sha256 equal to the listed one.
///
/// # Errors
/// [`Error::Addon`]: a gate, `offline: ...` or `download_failed: ...`.
pub fn download_file(transport: &dyn Transport, file: &File, cap: u64) -> Result<Vec<u8>> {
check_download_url(&file.url, &REGISTRY_HOSTS)?;
if file.size > cap {
return Err(Error::Addon(format!("`{}` is over the size cap", file.path)));
}
let response = transport.get(&file.url, file.size, None)?;
if response.status != OK {
return Err(coded("download_failed", format!("status {} for `{}`", response.status, file.path)));
}
if u64::try_from(response.bytes.len()).ok() != Some(file.size) {
return Err(Error::Addon(format!("`{}` is not the listed size", file.path)));
}
if sha256_hex(&response.bytes) != file.sha256 {
return Err(Error::Addon(format!("`{}` does not match its sha256", file.path)));
}
Ok(response.bytes)
}
/// The last verified index, kept on disk beside `state.json`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IndexCache {
/// Unix seconds of the last successful check.
pub fetched_at: u64,
/// The server's `ETag` for `body`.
pub etag: Option<String>,
/// The index document, exactly as received.
pub body: String,
}
impl IndexCache {
/// Reads `<registry root>/index-cache.json`; `None` when absent.
///
/// # Errors
/// [`Error::Addon`] for an unreadable or invalid file.
pub fn load(root: &Path) -> Result<Option<Self>> {
match std::fs::read(root.join(CACHE_FILE)) {
Ok(bytes) => serde_json::from_slice(&bytes).map(Some).map_err(|e| Error::Addon(format!("index cache is not valid: {e}"))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(Error::Addon(format!("cannot read the index cache: {e}"))),
}
}
/// Writes the cache atomically.
///
/// # Errors
/// [`Error::Addon`] when the write fails (the old cache stays).
pub fn save(&self, root: &Path) -> Result<()> {
let json = serde_json::to_string(self).map_err(|e| Error::Addon(e.to_string()))?;
craft_atomic::atomic_write(&root.join(CACHE_FILE), json.as_bytes()).map_err(|e| Error::Addon(format!("cannot write the index cache: {e}")))
}
/// The parsed index.
///
/// # Errors
/// [`Error::Addon`] when the cached body no longer parses.
pub fn index(&self) -> Result<Index> {
Index::parse(self.body.as_bytes())
}
}
/// One conditional GET of the index. Returns the new cache, or `cache` itself (with a fresh
/// `fetched_at`) on a 304. A failed fetch or an invalid index leaves the stored cache untouched.
///
/// # Errors
/// `offline: ...`, `download_failed: ...` or the reason the index is invalid.
pub fn fetch_index(transport: &dyn Transport, cache: Option<&IndexCache>, now: u64) -> Result<IndexCache> {
check_download_url(INDEX_URL, &REGISTRY_HOSTS)?;
let max = u64::try_from(MAX_INDEX_BYTES).unwrap_or(u64::MAX);
let etag = cache.and_then(|c| c.etag.as_deref());
let response = transport.get(INDEX_URL, max, etag)?;
if response.status == NOT_MODIFIED {
let kept = cache.ok_or_else(|| coded("download_failed", "304 without a cached index"))?;
return Ok(IndexCache { fetched_at: now, ..kept.clone() });
}
let body = String::from_utf8(response.bytes).map_err(|_| Error::Addon("the index is not UTF-8".into()))?;
Index::parse(body.as_bytes())?;
Ok(IndexCache { fetched_at: now, etag: response.etag, body })
}
+215
View File
@@ -0,0 +1,215 @@
//! The two small JSON documents of the add-on store: `state.json` (what is installed, global) and
//! `<vault>/.notescraft/registry.json` (what this vault enables and wants). Spec D4.
//!
//! Both are versioned (`1`), size and count capped, and strict: an unknown version, a malformed
//! key or a value over a cap is an `Err`; nothing is repaired.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::model::{Kind, Version, is_valid_id};
use crate::error::{Error, Result};
/// The version of both documents this build reads and writes.
pub const FILE_VERSION: u32 = 1;
/// Most installed add-ons.
pub const MAX_INSTALLED: usize = 512;
/// Most ids in a vault's `enabled` or `wanted` list.
pub const MAX_VAULT_IDS: usize = 256;
/// Largest document, in bytes.
pub const MAX_DOC_BYTES: usize = 1024 * 1024;
/// Longest timestamp, in bytes.
const MAX_STAMP: usize = 40;
/// Hex length of a SHA-256.
const SHA256_HEX: usize = 64;
/// Where an installed add-on came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Source {
/// Downloaded from the registry.
Registry,
/// Adopted from the pre-registry grammar settings.
Legacy,
/// Installed from the seed shipped with the app.
Seed,
/// Installed from a file the user picked.
Sideload,
}
/// One installed add-on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Installed {
/// The active version.
pub version: Version,
/// The version rollback returns to, if one is kept.
pub previous: Option<Version>,
/// Where it came from.
pub source: Source,
/// When it was installed (opaque text).
pub installed_at: String,
/// Hex SHA-256 over the files, as recorded at install time.
pub sha256_of_files: String,
}
/// `state.json`: the installed table, keyed `<kind>:<id>`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct State {
/// Installed add-ons.
pub installed: BTreeMap<String, Installed>,
}
#[derive(Serialize, Deserialize)]
struct RawInstalled {
version: String,
previous: Option<String>,
source: Source,
installed_at: String,
sha256_of_files: String,
}
#[derive(Serialize, Deserialize)]
struct RawState {
version: u32,
installed: BTreeMap<String, RawInstalled>,
}
fn addon(reason: impl Into<String>) -> Error {
Error::Addon(reason.into())
}
/// The table key of an add-on: `<kind>:<id>`.
#[must_use]
pub fn key(kind: Kind, id: &str) -> String {
format!("{}:{id}", kind.as_str())
}
/// Splits and validates a `<kind>:<id>` key.
///
/// # Errors
/// [`Error::Addon`] when the kind or id is not valid.
pub fn parse_key(text: &str) -> Result<(Kind, &str)> {
let (kind, id) = text.split_once(':').ok_or_else(|| addon(format!("`{text}` is not <kind>:<id>")))?;
match Kind::parse(kind) {
Some(kind) if is_valid_id(id) => Ok((kind, id)),
_ => Err(addon(format!("`{text}` is not <kind>:<id>"))),
}
}
fn check_len(bytes: &[u8]) -> Result<()> {
if bytes.len() > MAX_DOC_BYTES { Err(addon(format!("document is over {MAX_DOC_BYTES} bytes"))) } else { Ok(()) }
}
impl State {
/// Parses `state.json`.
///
/// # Errors
/// [`Error::Addon`] for bad JSON, an unknown version, more than [`MAX_INSTALLED`] rows, a bad
/// key, version, hash or timestamp.
pub fn parse(bytes: &[u8]) -> Result<Self> {
check_len(bytes)?;
let raw: RawState = serde_json::from_slice(bytes).map_err(|e| addon(format!("state is not valid: {e}")))?;
if raw.version != FILE_VERSION {
return Err(addon(format!("state version {} is not {FILE_VERSION}", raw.version)));
}
if raw.installed.len() > MAX_INSTALLED {
return Err(addon(format!("more than {MAX_INSTALLED} installed add-ons")));
}
let mut installed = BTreeMap::new();
for (name, row) in raw.installed {
parse_key(&name)?;
let hex = row.sha256_of_files.len() == SHA256_HEX && row.sha256_of_files.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
if !hex || row.installed_at.len() > MAX_STAMP || row.installed_at.chars().any(char::is_control) {
return Err(addon(format!("`{name}` has an invalid hash or timestamp")));
}
let previous = row.previous.as_deref().map(Version::parse).transpose()?;
let version = Version::parse(&row.version)?;
installed.insert(name, Installed { version, previous, source: row.source, installed_at: row.installed_at, sha256_of_files: row.sha256_of_files });
}
Ok(Self { installed })
}
/// Serialises to the `state.json` text.
///
/// # Errors
/// [`Error::Addon`] when the table is over [`MAX_INSTALLED`] or serialisation fails.
pub fn to_json(&self) -> Result<String> {
if self.installed.len() > MAX_INSTALLED {
return Err(addon(format!("more than {MAX_INSTALLED} installed add-ons")));
}
let installed = self
.installed
.iter()
.map(|(name, row)| {
let raw = RawInstalled {
version: row.version.to_string(),
previous: row.previous.map(|v| v.to_string()),
source: row.source,
installed_at: row.installed_at.clone(),
sha256_of_files: row.sha256_of_files.clone(),
};
(name.clone(), raw)
})
.collect();
serde_json::to_string_pretty(&RawState { version: FILE_VERSION, installed }).map_err(|e| addon(e.to_string()))
}
}
/// `<vault>/.notescraft/registry.json`: per-vault add-on choices (spec D4).
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct VaultFile {
/// Plugins switched on in this vault, as `plugin:<id>`.
pub enabled: Vec<String>,
/// Add-ons this vault was seen to use, as `<kind>:<id>`; never installed automatically.
pub wanted: Vec<String>,
}
#[derive(Serialize, Deserialize)]
struct RawVault {
version: u32,
#[serde(default)]
enabled: Vec<String>,
#[serde(default)]
wanted: Vec<String>,
}
impl VaultFile {
/// Parses the per-vault file.
///
/// # Errors
/// [`Error::Addon`] for bad JSON, an unknown version, a list over [`MAX_VAULT_IDS`] or a bad key.
pub fn parse(bytes: &[u8]) -> Result<Self> {
check_len(bytes)?;
let raw: RawVault = serde_json::from_slice(bytes).map_err(|e| addon(format!("registry.json is not valid: {e}")))?;
if raw.version != FILE_VERSION {
return Err(addon(format!("registry.json version {} is not {FILE_VERSION}", raw.version)));
}
let file = Self { enabled: raw.enabled, wanted: raw.wanted };
file.check()?;
Ok(file)
}
fn check(&self) -> Result<()> {
if self.enabled.len() > MAX_VAULT_IDS || self.wanted.len() > MAX_VAULT_IDS {
return Err(addon(format!("more than {MAX_VAULT_IDS} ids in a list")));
}
for name in self.enabled.iter().chain(&self.wanted) {
parse_key(name)?;
}
if let Some(name) = self.enabled.iter().find(|n| !n.starts_with("plugin:")) {
return Err(addon(format!("`{name}` is enabled but is not a plugin")));
}
Ok(())
}
/// Serialises to the file text.
///
/// # Errors
/// [`Error::Addon`] when a list is over the cap, a key is malformed or serialisation fails.
pub fn to_json(&self) -> Result<String> {
self.check()?;
serde_json::to_string_pretty(&RawVault { version: FILE_VERSION, enabled: self.enabled.clone(), wanted: self.wanted.clone() })
.map_err(|e| addon(e.to_string()))
}
}
+147
View File
@@ -0,0 +1,147 @@
//! The on-disk store under `<data dir>/registry/` (spec D4): `state.json`, immutable version
//! folders and a staging folder.
//!
//! Everything here stays inside the registry folder. `state.json` is written by [`Store::commit`]
//! alone, through `craft-atomic`; the version folders are unreferenced until that commit, so a
//! crash leaves garbage that [`Store::gc`] removes, never a half-install.
use std::collections::BTreeSet;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use super::model::{Kind, Version, is_valid_id};
use super::state::State;
use crate::error::{Error, Result};
/// Folder name under the data dir.
const ROOT: &str = "registry";
/// The installed table.
const STATE_FILE: &str = "state.json";
/// Immutable `<kind>/<id>/<version>/` trees.
const STORE_DIR: &str = "store";
/// Downloads in progress.
const STAGING_DIR: &str = "staging";
/// Handle to `<data dir>/registry/`.
#[derive(Debug, Clone)]
pub struct Store {
root: PathBuf,
}
fn io(context: &str, error: &std::io::Error) -> Error {
Error::Addon(format!("{context}: {error}"))
}
impl Store {
/// Opens (creating the folders) the store under `data_dir`.
///
/// # Errors
/// [`Error::Addon`] when a folder cannot be created.
pub fn open(data_dir: &Path) -> Result<Self> {
let root = data_dir.join(ROOT);
for dir in [STORE_DIR, STAGING_DIR] {
fs::create_dir_all(root.join(dir)).map_err(|e| io("cannot create the registry folder", &e))?;
}
Ok(Self { root })
}
/// The registry folder itself.
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
/// The folder downloads are staged in.
#[must_use]
pub fn staging_dir(&self) -> PathBuf {
self.root.join(STAGING_DIR)
}
/// `store/<kind>/<id>/<version>/`, built from the validated id and the numeric version.
///
/// # Errors
/// [`Error::Addon`] when `id` is not a valid add-on id.
pub fn version_dir(&self, kind: Kind, id: &str, version: Version) -> Result<PathBuf> {
if !is_valid_id(id) {
return Err(Error::Addon(format!("`{id}` is not a valid add-on id")));
}
Ok(self.root.join(STORE_DIR).join(kind.as_str()).join(id).join(version.to_string()))
}
/// Reads `state.json`; a missing file is an empty state.
///
/// # Errors
/// [`Error::Addon`] for an unreadable or invalid file. The file is never touched.
pub fn load(&self) -> Result<State> {
match fs::read(self.root.join(STATE_FILE)) {
Ok(bytes) => State::parse(&bytes),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(State::default()),
Err(e) => Err(io("cannot read state.json", &e)),
}
}
/// Writes `state.json` atomically: the one commit point of every install and removal.
///
/// # Errors
/// [`Error::Addon`] when the state is invalid or the write fails (the old file stays).
pub fn commit(&self, state: &State) -> Result<()> {
let json = state.to_json()?;
craft_atomic::atomic_write(&self.root.join(STATE_FILE), json.as_bytes()).map_err(|e| io("cannot write state.json", &e))
}
/// Removes every version folder `state` does not reference (its `version` and `previous` are
/// kept), empty parents, and everything in `staging/`. Returns how many folders went.
///
/// # Errors
/// [`Error::Addon`] when the store cannot be listed or a folder cannot be removed.
pub fn gc(&self, state: &State) -> Result<usize> {
let mut keep: BTreeSet<PathBuf> = BTreeSet::new();
for (name, row) in &state.installed {
let (kind, id) = super::state::parse_key(name)?;
keep.insert(self.version_dir(kind, id, row.version)?);
if let Some(previous) = row.previous {
keep.insert(self.version_dir(kind, id, previous)?);
}
}
let mut removed = 0;
for kind in children(&self.root.join(STORE_DIR))? {
for id in children(&kind)? {
for version in children(&id)? {
if !keep.contains(&version) {
remove(&version)?;
removed += 1;
}
}
prune_empty(&id)?;
}
prune_empty(&kind)?;
}
for staged in children(&self.root.join(STAGING_DIR))? {
remove(&staged)?;
}
Ok(removed)
}
}
fn children(dir: &Path) -> Result<Vec<PathBuf>> {
match fs::read_dir(dir) {
Ok(entries) => Ok(entries.flatten().map(|e| e.path()).collect()),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(io("cannot list the registry folder", &e)),
}
}
/// Removes a file, a link (not its target) or a folder tree.
fn remove(path: &Path) -> Result<()> {
let is_dir = fs::symlink_metadata(path).is_ok_and(|m| m.is_dir());
let done = if is_dir { fs::remove_dir_all(path) } else { fs::remove_file(path) };
done.map_err(|e| io("cannot remove a registry folder", &e))
}
fn prune_empty(dir: &Path) -> Result<()> {
if children(dir)?.is_empty() {
fs::remove_dir(dir).map_err(|e| io("cannot remove a registry folder", &e))?;
}
Ok(())
}
+88
View File
@@ -0,0 +1,88 @@
//! Signature check of an index entry: canonical text and ed25519 verification (spec D3).
//!
//! The signature covers the id, kind, version and every file's path, hash and size, so a changed
//! byte anywhere in a payload invalidates it. An entry without a verifying signature is refused:
//! there is no "trust me" path.
use std::fmt::Write as _;
use ring::signature::{ED25519, UnparsedPublicKey};
use super::keys::TRUSTED_KEYS;
use super::model::Entry;
use crate::base64;
use crate::error::{Error, Result};
/// First line of every canonical text; bumps with the signing format.
const CANONICAL_HEADER: &str = "notescraft-registry-v1";
/// Byte length of an ed25519 signature.
const SIGNATURE_BYTES: usize = 64;
/// Decides whether an entry is signed by a trusted key.
pub trait Verifier: std::fmt::Debug {
/// Returns `Ok` only when the entry's signature is valid.
///
/// # Errors
/// [`Error::Addon`] naming why it was refused.
fn verify(&self, entry: &Entry) -> Result<()>;
}
/// The text the owner signs: header, id, kind, version, then every file sorted by path.
#[must_use]
pub fn canonical_text(entry: &Entry) -> String {
let mut files: Vec<_> = entry.files.iter().collect();
files.sort_by(|a, b| a.path.cmp(&b.path));
let mut text = format!("{CANONICAL_HEADER}\n{}\n{}\n{}\n", entry.id, entry.kind.as_str(), entry.version);
for file in files {
let _ = writeln!(text, "{}\n{}\n{}", file.path, file.sha256, file.size);
}
text
}
/// Ed25519 verification against a fixed set of `(key_id, public key)` pairs.
#[derive(Debug, Clone)]
pub struct Ed25519 {
keys: Vec<(String, [u8; 32])>,
}
impl Ed25519 {
/// Trusts exactly these keys.
#[must_use]
pub fn new(keys: Vec<(String, [u8; 32])>) -> Self {
Self { keys }
}
/// Trusts the keys embedded in the app ([`TRUSTED_KEYS`]).
#[must_use]
pub fn trusted() -> Self {
Self::new(TRUSTED_KEYS.iter().map(|(id, key)| ((*id).to_owned(), *key)).collect())
}
}
impl Verifier for Ed25519 {
fn verify(&self, entry: &Entry) -> Result<()> {
let Some((_, key)) = self.keys.iter().find(|(id, _)| *id == entry.signature.key_id) else {
return Err(Error::Addon(format!("unknown signing key `{}`", entry.signature.key_id)));
};
let sig = base64::decode(&entry.signature.sig)?;
if sig.len() != SIGNATURE_BYTES {
return Err(Error::Addon("signature has the wrong length".into()));
}
UnparsedPublicKey::new(&ED25519, key)
.verify(canonical_text(entry).as_bytes(), &sig)
.map_err(|_| Error::Addon(format!("signature of `{}` does not verify", entry.id)))
}
}
/// A verifier for other tasks' tests: accepts everything, or refuses everything.
#[derive(Debug, Clone, Copy)]
pub struct FakeVerifier {
/// Whether [`Verifier::verify`] succeeds.
pub accept: bool,
}
impl Verifier for FakeVerifier {
fn verify(&self, entry: &Entry) -> Result<()> {
if self.accept { Ok(()) } else { Err(Error::Addon(format!("fake verifier refuses `{}`", entry.id))) }
}
}
+10
View File
@@ -34,6 +34,11 @@ pub const DELETE_COMMANDS: &[&str] = &[
"server.connect",
"server.create_vault",
"server.delete_vault",
"registry.check",
"registry.install",
"registry.update",
"registry.remove",
"registry.rollback",
];
/// The core plugin a command belongs to, for the per-plugin tool toggle of
@@ -169,6 +174,8 @@ pub const WRITE_COMMANDS: &[&str] = &[
"tags.rename",
"tags.merge",
"export.pandoc",
"registry.enable",
"registry.disable",
];
/// The explicit read allow-list; everything else is treated as a write.
@@ -270,6 +277,9 @@ pub const READ_COMMANDS: &[&str] = &[
"maps.status",
"maps.tile",
"uri.resolve",
"registry.list",
"registry.info",
"registry.suggest",
];
/// Assistant commands the agent itself may never call: they would let it
+23 -6
View File
@@ -29,12 +29,29 @@ use crate::params::{ParamKind, ParamSpec};
use crate::session::Session;
use crate::spec::{CommandResult, CommandSpec};
/// The body font, embedded whole.
const INTER_REGULAR: &[u8] = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
/// The bold font (headings, strong text), embedded whole.
const INTER_SEMIBOLD: &[u8] = include_bytes!("../../../../assets/fonts/Inter-SemiBold.ttf");
/// The code font, embedded whole.
const JETBRAINS_MONO: &[u8] = include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf");
/// Bytes of the regular `Inter` font, as shipped.
const INTER_REGULAR_BYTES: usize = 411_640;
/// Bytes of the semibold `Inter` font, as shipped.
const INTER_SEMIBOLD_BYTES: usize = 419_744;
/// Bytes of the `JetBrains Mono` font, as shipped.
const JETBRAINS_MONO_BYTES: usize = 269_984;
/// The body font, embedded whole. The UI installs the same copy.
///
/// The bytes live in a public static: a private one is copied by the linker into
/// every LTO partition that reads it, so the binary carried the font twice.
pub static INTER_REGULAR_DATA: [u8; INTER_REGULAR_BYTES] = *include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
/// The body font, embedded whole. The UI installs the same copy.
pub const INTER_REGULAR: &[u8] = &INTER_REGULAR_DATA;
/// The bold font (headings, strong text), embedded whole. The UI installs the same copy.
pub static INTER_SEMIBOLD_DATA: [u8; INTER_SEMIBOLD_BYTES] = *include_bytes!("../../../../assets/fonts/Inter-SemiBold.ttf");
/// The bold font (headings, strong text), embedded whole. The UI installs the same copy.
pub const INTER_SEMIBOLD: &[u8] = &INTER_SEMIBOLD_DATA;
/// The code font, embedded whole. The UI installs the same copy: PDF text has no
/// ligature shaping, so the ligature-free file is valid there too.
pub static JETBRAINS_MONO_DATA: [u8; JETBRAINS_MONO_BYTES] = *include_bytes!("../../../../assets/fonts/JetBrainsMono-NoLigatures.ttf");
/// The code font, embedded whole. The UI installs the same copy.
pub const JETBRAINS_MONO: &[u8] = &JETBRAINS_MONO_DATA;
/// The fonts the exporter embeds.
fn fonts() -> FontSet {
+1
View File
@@ -68,6 +68,7 @@ pub fn register_core(reg: &mut Registry<Session>) -> Result<()> {
crate::prop_edit::commands::register_prop_edit(reg)?;
crate::uri::register_uri(reg)?;
crate::pandoc::register_pandoc(reg)?;
crate::addons::register_addons(reg)?;
Ok(())
}
+6
View File
@@ -236,6 +236,11 @@ pub enum Error {
/// holds an unknown version.
#[error("smart folder: {0}")]
SmartFolder(String),
/// An add-on index, entry, file list, signature or store file broke a rule of the registry
/// spec (D2, D3, D4, D7).
#[error("add-on: {0}")]
Addon(String),
}
impl Error {
@@ -276,6 +281,7 @@ impl Error {
Self::ReviewLimit { .. } => "limit",
Self::StaleRange { .. } => "stale_range",
Self::SmartFolder(_) => "smart_folder",
Self::Addon(_) => "addon",
}
}
}
+2 -1
View File
@@ -223,8 +223,9 @@ fn save(session: &mut Session, id: &str, grammar: &FetchedGrammar) -> CommandRes
pub(crate) fn reload_installed(session: &Session) {
let Ok((settings, _)) = load_for(session) else { return };
let Some(dir) = grammar_dir(session) else { return };
let installed: Vec<(String, String)> =
let mut installed: Vec<(String, String)> =
settings.grammars.iter().filter_map(|grammar| Some((grammar.id.clone(), std::fs::read_to_string(dir.join(&grammar.file)).ok()?))).collect();
installed.extend(crate::addons::installed_grammars(session));
load::set_installed(&installed);
}
+2
View File
@@ -27,6 +27,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod activity;
pub mod addons;
mod ai;
mod base64;
mod bases;
@@ -145,6 +146,7 @@ pub use crate::sync::{
pub use crate::uri::{UriAction, format_open_uri};
pub use crate::vault::snapshots::Snapshot;
pub use crate::viewers::FileKind;
pub use builtin::export_commands::{INTER_REGULAR, INTER_SEMIBOLD, JETBRAINS_MONO};
pub use notescraft_vault::{FileCount, LARGE_FOLDER_FILES, count_files};
#[cfg(test)]
+40 -4
View File
@@ -77,6 +77,10 @@ pub struct Session {
pub(crate) batch: crate::batch::BatchState,
/// How `grammar.install` downloads; `curl` unless a test swaps it.
pub(crate) grammar_transport: Box<dyn notescraft_syntax::fetch::Transport>,
/// How `registry.*` downloads; `ureq` unless a test swaps it.
pub(crate) addons_transport: Box<dyn crate::addons::Transport>,
/// How `registry.install` checks signatures; the embedded keys unless a test swaps it.
pub(crate) addons_verifier: Box<dyn crate::addons::Verifier + Send>,
}
impl Session {
@@ -284,6 +288,16 @@ impl Session {
self.grammar_transport = transport;
}
/// Replaces how `registry.*` downloads (tests; the app keeps `ureq`).
pub fn set_addons_transport(&mut self, transport: Box<dyn crate::addons::Transport>) {
self.addons_transport = transport;
}
/// Replaces how `registry.*` checks signatures (tests; the app keeps the embedded keys).
pub fn set_addons_verifier(&mut self, verifier: Box<dyn crate::addons::Verifier + Send>) {
self.addons_verifier = verifier;
}
/// Loads the installed syntax grammars into the highlighter (call once after opening).
pub fn load_grammars(&self) {
crate::grammars::reload_installed(self);
@@ -395,7 +409,29 @@ impl Session {
/// Builds the session and fills the indexes.
pub(crate) fn with_vault(vault: Vault, config: SessionConfig) -> Self {
let mut session = Self {
let mut session = Self::bare(vault, config);
session.begin_scan();
session
}
/// A session for a worker thread that only runs `registry.*` commands: the vault is a
/// sibling of the one the app holds (no second lock), `settings_dir` is the app's, and the
/// vault is not scanned.
#[must_use]
pub fn registry_worker(vault: Vault, settings_dir: Option<PathBuf>) -> Self {
let mut session = Self::bare(vault, SessionConfig::default());
session.settings_dir = settings_dir;
session
}
/// The folder `use_settings_dir` picked, for a worker to share the app's registry store.
#[must_use]
pub fn picked_settings_dir(&self) -> Option<PathBuf> {
self.settings_dir.clone()
}
fn bare(vault: Vault, config: SessionConfig) -> Self {
Self {
git: notescraft_git::Git::open(vault.root()),
vault,
index: Index::new(),
@@ -419,13 +455,13 @@ impl Session {
scan: crate::indexing::ScanState::default(),
batch: crate::batch::BatchState::default(),
grammar_transport: Box::new(notescraft_syntax::fetch::Curl),
addons_transport: Box::new(crate::addons::Ureq::new()),
addons_verifier: Box::new(crate::addons::TrustedVerifier),
sync: None,
rooms: crate::live::rooms::Rooms::new(),
sync_attachments: true,
calendar_cache: crate::calendar::CalendarCache::default(),
};
session.begin_scan();
session
}
}
}
+187
View File
@@ -0,0 +1,187 @@
//! The `registry.*` commands through the registry: list, check, install, update, remove,
//! rollback, enable, the read-only refusal and the assistant gate classes.
mod addons_support;
mod common;
use std::sync::Arc;
use addons_support::{Shared, TINY, WASM, entry, index_json, manifest, serve};
use common::{TempVault, temp_config, temp_vault};
use notescraft_engine::addons::{FakeTransport, FakeVerifier, INDEX_URL, Response};
use notescraft_engine::{AssistantGate, AssistantSettings, Decision, Error, GateSource, Registry, Session, register_core};
use serde_json::{Value, json};
const GRAMMAR: &str = "twig.sublime-syntax";
struct Fixture {
reg: Registry<Session>,
session: Session,
net: Arc<FakeTransport>,
vault_path: std::path::PathBuf,
_guards: (TempVault, TempVault),
}
fn script(version: &str, bytes: &[u8]) -> FakeTransport {
let doc = index_json(&[entry("grammar", "twig", version, &[(GRAMMAR, bytes)], &[])], &[]);
let net = FakeTransport::new().with_response(INDEX_URL, Ok(Response { status: 200, etag: Some("\"e\"".into()), bytes: doc }));
serve(net, "grammar", "twig", version, &[(GRAMMAR, bytes)])
}
fn fixture(name: &str, net: FakeTransport) -> Fixture {
let (vault, config) = (temp_vault(name), temp_config(name));
let mut session = Session::open(vault.path()).unwrap();
session.use_settings_dir(config.path());
let net = Arc::new(net);
session.set_addons_transport(Box::new(Shared(net.clone())));
session.set_addons_verifier(Box::new(FakeVerifier { accept: true }));
let mut reg = Registry::new();
register_core(&mut reg).unwrap();
Fixture { reg, session, net, vault_path: vault.path().to_path_buf(), _guards: (vault, config) }
}
impl Fixture {
fn run(&mut self, id: &str, params: impl Into<Value>) -> Result<Value, Error> {
self.reg.run(id, &mut self.session, &params.into())
}
fn ok(&mut self, id: &str, params: impl Into<Value>) -> Value {
self.run(id, params).unwrap_or_else(|e| panic!("{id}: {e}"))
}
fn code(&mut self, id: &str, params: impl Into<Value>) -> String {
match self.run(id, params) {
Err(Error::Command { source, .. }) => source.code,
other => panic!("{id}: expected a command error, got {other:?}"),
}
}
}
#[test]
fn list_is_empty_until_a_check_then_joins_the_state() {
let mut f = fixture("list", script("1.0.0", TINY.as_bytes()));
assert_eq!(f.ok("registry.list", json!({}))["cached"], false);
assert!(f.net.asked().is_empty(), "list never touches the network");
let checked = f.ok("registry.check", json!({}));
assert_eq!((checked["entries"].clone(), checked["updates"].clone()), (json!(1), json!(0)));
let rows = f.ok("registry.list", json!({}));
assert_eq!(rows["entries"][0]["state"], "available");
f.ok("registry.install", json!({ "kind": "grammar", "id": "twig" }));
assert_eq!(f.ok("registry.list", json!({ "state": "installed" }))["entries"][0]["installed_version"], "1.0.0");
assert_eq!(f.ok("registry.list", json!({ "state": "available" }))["entries"], json!([]));
assert_eq!(f.ok("registry.list", json!({ "query": "TW" }))["entries"].as_array().map(Vec::len), Some(1));
assert_eq!(f.ok("registry.info", json!({ "kind": "grammar", "id": "twig" }))["files"][0]["path"], GRAMMAR);
assert_eq!(f.ok("registry.suggest", json!({ "label": "Twig" }))["entries"][0]["id"], "twig");
assert_eq!(f.ok("registry.suggest", json!({ "label": "nope" }))["entries"], json!([]));
}
#[test]
fn install_update_rollback_remove_run_through_the_registry() {
let mut f = fixture("flow", script("1.0.0", TINY.as_bytes()));
f.ok("registry.check", json!({}));
let done = f.ok("registry.install", json!({ "kind": "grammar", "id": "twig" }));
assert_eq!(done["version"], "1.0.0");
let newer = format!("{TINY}# 1.1\n");
// A new index with 1.1.0 appears after another check.
let net = script("1.1.0", newer.as_bytes());
f.session.set_addons_transport(Box::new(Shared(Arc::new(net))));
assert_eq!(f.ok("registry.check", json!({}))["updates"], 1);
assert_eq!(f.ok("registry.list", json!({ "state": "updates" }))["entries"][0]["state"], "update");
let all = f.ok("registry.update", json!({}));
assert_eq!(all["updated"][0]["previous"], "1.0.0");
assert_eq!(f.ok("registry.rollback", json!({ "kind": "grammar", "id": "twig" }))["version"], "1.0.0");
f.ok("registry.remove", json!({ "kind": "grammar", "id": "twig" }));
assert_eq!(f.code("registry.remove", json!({ "kind": "grammar", "id": "twig" })), "not_found");
assert_eq!(f.code("registry.install", json!({ "kind": "grammar", "id": "nope" })), "not_found");
assert_eq!(f.code("registry.install", json!({ "kind": "exe", "id": "twig" })), "invalid_params");
}
#[test]
fn install_without_a_trusted_key_is_a_clear_addon_error() {
let mut f = fixture("nokey", script("1.0.0", TINY.as_bytes()));
f.ok("registry.check", json!({}));
let config = temp_config("nokey-default");
let vault = temp_vault("nokey-default");
let mut plain = Session::open(vault.path()).unwrap();
plain.use_settings_dir(config.path());
plain.set_addons_transport(Box::new(Shared(f.net.clone())));
f.session = plain;
f.ok("registry.check", json!({}));
let error = f.run("registry.install", json!({ "kind": "grammar", "id": "twig" })).unwrap_err().to_string();
assert!(error.contains("no trusted registry key"), "{error}");
assert_eq!(f.code("registry.install", json!({ "kind": "grammar", "id": "twig" })), "addon");
f.session.set_addons_verifier(Box::new(notescraft_engine::addons::Ed25519::new(vec![])));
assert_eq!(f.code("registry.install", json!({ "kind": "grammar", "id": "twig" })), "registry_refused");
}
#[test]
fn offline_check_leaves_the_cache() {
let mut f = fixture("offline", script("1.0.0", TINY.as_bytes()));
f.ok("registry.check", json!({}));
f.session.set_addons_transport(Box::new(Shared(Arc::new(FakeTransport::new()))));
assert_eq!(f.code("registry.check", json!({})), "offline");
assert_eq!(f.ok("registry.list", json!({}))["entries"].as_array().map(Vec::len), Some(1));
}
#[test]
fn install_before_any_check_says_so() {
let mut f = fixture("nocache", script("1.0.0", TINY.as_bytes()));
assert_eq!(f.code("registry.install", json!({ "kind": "grammar", "id": "twig" })), "not_found");
}
#[test]
fn enable_and_disable_write_the_vault_file() {
let text = manifest("dice", "1.0.0", &[]);
let files: &[(&str, &[u8])] = &[("manifest.json", text.as_bytes()), ("plugin.wasm", WASM)];
let doc = index_json(&[entry("plugin", "dice", "1.0.0", files, &[])], &[]);
let net = serve(FakeTransport::new().with(INDEX_URL, &doc), "plugin", "dice", "1.0.0", files);
let mut f = fixture("enable", net);
assert_eq!(f.code("registry.enable", json!({ "id": "dice" })), "not_found", "not installed yet");
f.ok("registry.check", json!({}));
f.ok("registry.install", json!({ "kind": "plugin", "id": "dice" }));
f.ok("registry.enable", json!({ "id": "dice" }));
let file = std::fs::read_to_string(f.vault_path.join(".notescraft/registry.json")).unwrap();
assert!(file.contains("plugin:dice"), "{file}");
f.ok("registry.disable", json!({ "id": "dice" }));
let file = std::fs::read_to_string(f.vault_path.join(".notescraft/registry.json")).unwrap();
assert!(!file.contains("plugin:dice"), "{file}");
}
#[test]
fn a_read_only_session_refuses_the_mutating_ids() {
let vault = temp_vault("ro");
let mut session = Session::open_read_only(vault.path()).unwrap();
let mut reg = Registry::new();
register_core(&mut reg).unwrap();
for id in ["registry.check", "registry.install", "registry.update", "registry.remove", "registry.rollback", "registry.enable", "registry.disable"] {
let params = json!({ "kind": "grammar", "id": "twig" });
assert!(matches!(reg.run(id, &mut session, &params), Err(Error::Disabled(_))), "{id}");
}
for id in ["registry.list", "registry.suggest"] {
assert!(!matches!(reg.run(id, &mut session, &json!({ "label": "x" })), Err(Error::Disabled(_))), "{id}");
}
}
#[test]
fn the_gate_classes_hold() {
let f = fixture("gate", FakeTransport::new());
let mut session = f.session;
let decide = |session: &Session, id: &str| AssistantGate::check(GateSource::Assistant, session, id, &Value::Null);
for id in ["registry.list", "registry.info", "registry.suggest"] {
assert!(matches!(decide(&session, id), Decision::Allow), "{id}");
}
for id in ["registry.enable", "registry.disable", "registry.check", "registry.install", "registry.update", "registry.remove", "registry.rollback"] {
assert!(matches!(decide(&session, id), Decision::Deny(_)), "{id} needs allow_write");
}
session.set_assistant_settings(AssistantSettings { allow_write: true, allow_delete: false, review_writes: false, ..AssistantSettings::default() });
for id in ["registry.enable", "registry.disable"] {
assert!(matches!(decide(&session, id), Decision::Allow), "{id}");
}
for id in ["registry.check", "registry.install", "registry.update", "registry.remove", "registry.rollback"] {
assert!(matches!(decide(&session, id), Decision::Deny(_)), "{id} needs allow_delete");
}
session.set_assistant_settings(AssistantSettings { allow_write: true, allow_delete: true, review_writes: false, ..AssistantSettings::default() });
for id in ["registry.check", "registry.install", "registry.update", "registry.remove", "registry.rollback"] {
assert!(matches!(decide(&session, id), Decision::Allow), "{id}");
}
}
+221
View File
@@ -0,0 +1,221 @@
//! Install, update, remove and rollback against a fake transport and a temp store.
mod addons_support;
use addons_support::{TINY, WASM, entry, index, manifest, serve};
use notescraft_engine::addons::{
Ctx, Ed25519, FakeTransport, FakeVerifier, Index, Kind, Seed, Source, Store, Version, fetch_files, install, key, remove, rollback, update, write_tree,
};
const GRAMMAR: &str = "twig.sublime-syntax";
struct Env {
dir: std::path::PathBuf,
store: Store,
}
impl Env {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("notescraft-addons-install-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let store = Store::open(&dir).unwrap();
Self { dir, store }
}
fn ctx<'a>(&'a self, transport: &'a FakeTransport, verifier: &'a FakeVerifier) -> Ctx<'a> {
Ctx { store: &self.store, transport, verifier, app_version: v(0, 5, 0), seed: None }
}
}
impl Drop for Env {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn v(major: u16, minor: u16, patch: u16) -> Version {
Version { major, minor, patch }
}
const OK: FakeVerifier = FakeVerifier { accept: true };
fn grammar_index(version: &str, bytes: &[u8]) -> Index {
index(&[entry("grammar", "twig", version, &[(GRAMMAR, bytes)], &[])], &[])
}
fn grammar_net(version: &str, bytes: &[u8]) -> FakeTransport {
serve(FakeTransport::new(), "grammar", "twig", version, &[(GRAMMAR, bytes)])
}
fn error_of<T: std::fmt::Debug>(result: notescraft_engine::Result<T>) -> String {
result.expect_err("must fail").to_string()
}
#[test]
fn a_clean_install_commits_state_and_files() {
let env = Env::new("clean");
let report = install(&env.ctx(&grammar_net("1.0.0", TINY.as_bytes()), &OK), &grammar_index("1.0.0", TINY.as_bytes()), Kind::Grammar, "twig", None).unwrap();
assert_eq!((report.version, report.previous, report.source), (v(1, 0, 0), None, Source::Registry));
let state = env.store.load().unwrap();
assert_eq!(state.installed[&key(Kind::Grammar, "twig")].version, v(1, 0, 0));
let file = env.store.version_dir(Kind::Grammar, "twig", v(1, 0, 0)).unwrap().join(GRAMMAR);
assert_eq!(std::fs::read(file).unwrap(), TINY.as_bytes());
}
#[test]
fn a_bad_download_changes_nothing() {
let env = Env::new("bad");
let idx = grammar_index("1.0.0", TINY.as_bytes());
let short = grammar_net("1.0.0", &TINY.as_bytes()[..10]);
assert!(install(&env.ctx(&short, &OK), &idx, Kind::Grammar, "twig", None).is_err());
let wrong = grammar_net("1.0.0", &TINY.to_uppercase().into_bytes());
assert!(error_of(install(&env.ctx(&wrong, &OK), &idx, Kind::Grammar, "twig", None)).contains("sha256"));
let long = grammar_net("1.0.0", &[TINY.as_bytes(), b"x"].concat());
assert!(install(&env.ctx(&long, &OK), &idx, Kind::Grammar, "twig", None).is_err());
assert!(error_of(install(&env.ctx(&FakeTransport::new(), &OK), &idx, Kind::Grammar, "twig", None)).contains("offline"));
assert!(env.store.load().unwrap().installed.is_empty());
assert_eq!(env.store.gc(&env.store.load().unwrap()).unwrap(), 0, "no folder was written");
}
#[test]
fn a_content_check_failure_writes_nothing() {
let env = Env::new("content");
let junk = b"nope: [".as_slice();
let idx = grammar_index("1.0.0", junk);
assert!(install(&env.ctx(&grammar_net("1.0.0", junk), &OK), &idx, Kind::Grammar, "twig", None).is_err());
assert!(!env.store.version_dir(Kind::Grammar, "twig", v(1, 0, 0)).unwrap().exists());
}
#[test]
fn the_gates_refuse_before_any_download() {
let env = Env::new("gates");
let net = grammar_net("1.0.0", TINY.as_bytes());
let idx = grammar_index("1.0.0", TINY.as_bytes());
assert!(error_of(install(&env.ctx(&net, &FakeVerifier { accept: false }), &idx, Kind::Grammar, "twig", None)).contains("refuses"));
let real = Ed25519::new(vec![]);
let ctx = Ctx { store: &env.store, transport: &net, verifier: &real, app_version: v(0, 5, 0), seed: None };
assert!(error_of(install(&ctx, &idx, Kind::Grammar, "twig", None)).contains("unknown signing key"));
let old_app = Ctx { app_version: v(0, 0, 9), ..env.ctx(&net, &OK) };
assert!(error_of(install(&old_app, &idx, Kind::Grammar, "twig", None)).contains("incompatible"));
let revoked = index(&[entry("grammar", "twig", "1.0.0", &[(GRAMMAR, TINY.as_bytes())], &[])], &[("twig", "1.0.0")]);
assert!(error_of(install(&env.ctx(&net, &OK), &revoked, Kind::Grammar, "twig", None)).contains("revoked"));
assert!(error_of(install(&env.ctx(&net, &OK), &idx, Kind::Grammar, "missing", None)).contains("not_found"));
assert!(error_of(install(&env.ctx(&net, &OK), &idx, Kind::Grammar, "twig", Some(v(2, 0, 0)))).contains("not_found"));
let font = index(&[entry("font", "f", "1.0.0", &[("f.ttf", &[0, 1, 0, 0])], &[])], &[]);
assert!(error_of(install(&env.ctx(&net, &OK), &font, Kind::Font, "f", None)).contains("no use"));
assert!(net.asked().is_empty(), "nothing was fetched: {:?}", net.asked());
assert!(env.store.load().unwrap().installed.is_empty());
}
#[test]
fn a_crash_before_the_state_flip_leaves_the_old_state_and_gc_cleans_up() {
let env = Env::new("crash");
let (idx, net) = (grammar_index("1.0.0", TINY.as_bytes()), grammar_net("1.0.0", TINY.as_bytes()));
let ctx = env.ctx(&net, &OK);
let entry = idx.find(Kind::Grammar, "twig").unwrap();
let staged = fetch_files(&ctx, entry).unwrap();
write_tree(&env.store, entry, &staged).unwrap();
let dir = env.store.version_dir(Kind::Grammar, "twig", v(1, 0, 0)).unwrap();
assert!(dir.is_dir());
assert!(env.store.load().unwrap().installed.is_empty(), "state was never flipped");
assert_eq!(env.store.gc(&env.store.load().unwrap()).unwrap(), 1);
assert!(!dir.exists());
write_tree(&env.store, entry, &staged).unwrap();
write_tree(&env.store, entry, &staged).unwrap();
}
#[test]
fn update_keeps_previous_and_rollback_swaps() {
let env = Env::new("update");
install(&env.ctx(&grammar_net("1.0.0", TINY.as_bytes()), &OK), &grammar_index("1.0.0", TINY.as_bytes()), Kind::Grammar, "twig", None).unwrap();
let newer = format!("{TINY}# 1.1\n");
let (idx, net) = (grammar_index("1.1.0", newer.as_bytes()), grammar_net("1.1.0", newer.as_bytes()));
let report = update(&env.ctx(&net, &OK), &idx, Kind::Grammar, "twig").unwrap();
assert_eq!((report.version, report.previous), (v(1, 1, 0), Some(v(1, 0, 0))));
assert!(env.store.version_dir(Kind::Grammar, "twig", v(1, 0, 0)).unwrap().is_dir(), "the old version stays for rollback");
assert!(error_of(update(&env.ctx(&net, &OK), &idx, Kind::Grammar, "twig")).contains("already installed"));
let back = rollback(&env.store, Kind::Grammar, "twig").unwrap();
assert_eq!((back.version, back.previous), (v(1, 0, 0), Some(v(1, 1, 0))));
let forward = rollback(&env.store, Kind::Grammar, "twig").unwrap();
assert_eq!(forward.version, v(1, 1, 0));
let old = (grammar_index("1.0.0", TINY.as_bytes()), grammar_net("1.0.0", TINY.as_bytes()));
assert!(error_of(install(&env.ctx(&old.1, &OK), &old.0, Kind::Grammar, "twig", None)).contains("rollback"), "a downgrade is never an install");
}
#[test]
fn update_and_rollback_need_an_installed_add_on() {
let env = Env::new("needs");
let (idx, net) = (grammar_index("1.0.0", TINY.as_bytes()), grammar_net("1.0.0", TINY.as_bytes()));
assert!(error_of(update(&env.ctx(&net, &OK), &idx, Kind::Grammar, "twig")).contains("not installed"));
assert!(error_of(rollback(&env.store, Kind::Grammar, "twig")).contains("not installed"));
install(&env.ctx(&net, &OK), &idx, Kind::Grammar, "twig", None).unwrap();
assert!(error_of(rollback(&env.store, Kind::Grammar, "twig")).contains("earlier"));
}
#[test]
fn remove_commits_first_then_deletes_the_files() {
let env = Env::new("remove");
install(&env.ctx(&grammar_net("1.0.0", TINY.as_bytes()), &OK), &grammar_index("1.0.0", TINY.as_bytes()), Kind::Grammar, "twig", None).unwrap();
remove(&env.store, Kind::Grammar, "twig").unwrap();
assert!(env.store.load().unwrap().installed.is_empty());
assert!(!env.store.version_dir(Kind::Grammar, "twig", v(1, 0, 0)).unwrap().exists());
assert!(error_of(remove(&env.store, Kind::Grammar, "twig")).contains("not_found"));
}
#[derive(Debug)]
struct Fixed(Vec<u8>);
impl Seed for Fixed {
fn read(&self, _: Kind, _: &str, _: Version, _: &str) -> Option<Vec<u8>> {
Some(self.0.clone())
}
}
#[test]
fn a_seed_installs_offline_and_a_tampered_seed_is_ignored() {
let env = Env::new("seed");
let idx = grammar_index("1.0.0", TINY.as_bytes());
let seed = Fixed(TINY.as_bytes().to_vec());
let offline = FakeTransport::new();
let ctx = Ctx { seed: Some(&seed), ..env.ctx(&offline, &OK) };
let report = install(&ctx, &idx, Kind::Grammar, "twig", None).unwrap();
assert_eq!(report.source, Source::Seed);
assert_eq!(offline.asked().len(), 0);
let env = Env::new("seed-bad");
let tampered = Fixed(b"tampered".to_vec());
let ctx = Ctx { seed: Some(&tampered), ..env.ctx(&offline, &OK) };
assert!(error_of(install(&ctx, &idx, Kind::Grammar, "twig", None)).contains("offline"), "falls back to the network, which is down");
}
#[test]
fn a_plugin_installs_with_its_manifest_checked() {
let env = Env::new("plugin");
let text = manifest("dice", "1.0.0", &["vault.read"]);
let files: &[(&str, &[u8])] = &[("manifest.json", text.as_bytes()), ("plugin.wasm", WASM)];
let idx = index(&[entry("plugin", "dice", "1.0.0", files, &["vault.read"])], &[]);
let net = serve(FakeTransport::new(), "plugin", "dice", "1.0.0", files);
install(&env.ctx(&net, &OK), &idx, Kind::Plugin, "dice", None).unwrap();
let dir = env.store.version_dir(Kind::Plugin, "dice", v(1, 0, 0)).unwrap();
assert!(dir.join("manifest.json").is_file() && dir.join("plugin.wasm").is_file());
}
#[test]
fn the_installed_table_cap_is_enforced() {
let env = Env::new("cap");
let mut state = env.store.load().unwrap();
for n in 0..512 {
state.installed.insert(
format!("grammar:g{n}"),
notescraft_engine::addons::Installed {
version: v(1, 0, 0),
previous: None,
source: Source::Registry,
installed_at: "t".into(),
sha256_of_files: "e".repeat(64),
},
);
}
env.store.commit(&state).unwrap();
let (idx, net) = (grammar_index("1.0.0", TINY.as_bytes()), grammar_net("1.0.0", TINY.as_bytes()));
assert!(error_of(install(&env.ctx(&net, &OK), &idx, Kind::Grammar, "twig", None)).contains("limit"));
}
+81
View File
@@ -0,0 +1,81 @@
//! Per-kind content checks of staged add-on files.
mod addons_support;
use addons_support::{THEME, TINY, WASM, entry, index, manifest};
use notescraft_engine::addons::{Entry, Kind, check_content};
type Staged = Vec<(String, Vec<u8>)>;
fn parsed(kind: &str, id: &str, files: &[(&str, &[u8])], permissions: &[&str]) -> Entry {
index(&[entry(kind, id, "1.0.0", files, permissions)], &[]).entries.remove(0)
}
fn run(kind: Kind, id: &str, files: &[(&str, &[u8])], permissions: &[&str]) -> Result<(), String> {
let staged: Staged = files.iter().map(|(p, b)| ((*p).to_owned(), b.to_vec())).collect();
check_content(kind, &parsed(kind.as_str(), id, files, permissions), &staged).map_err(|e| e.to_string())
}
#[test]
fn grammar() {
assert_eq!(run(Kind::Grammar, "tiny", &[("tiny.sublime-syntax", TINY.as_bytes())], &[]), Ok(()));
assert!(run(Kind::Grammar, "tiny", &[("tiny.sublime-syntax", b"<plist></plist>")], &[]).is_err());
assert!(run(Kind::Grammar, "tiny", &[("tiny.sublime-syntax", &[0xff, 0xfe])], &[]).is_err());
}
#[test]
fn theme() {
assert_eq!(run(Kind::Theme, "mine", &[("mine.toml", THEME.as_bytes())], &[]), Ok(()));
let wrong_id = run(Kind::Theme, "other", &[("other.toml", THEME.as_bytes())], &[]);
assert!(wrong_id.unwrap_err().contains("entry"));
let builtin = notescraft_themes::builtin_ids()[0];
let reuse = format!("id = \"{builtin}\"\nname = \"X\"\nvariant = \"dark\"\n");
assert!(run(Kind::Theme, builtin, &[("t.toml", reuse.as_bytes())], &[]).unwrap_err().contains("built-in"));
let bad_value = "id = \"mine\"\nname = \"Mine\"\nvariant = \"dark\"\n[colors]\nbackground = \"nope\"\n";
assert!(run(Kind::Theme, "mine", &[("mine.toml", bad_value.as_bytes())], &[]).unwrap_err().contains("rejected"));
}
#[test]
fn plugin() {
let good = manifest("dice", "1.0.0", &["vault.read"]);
let files: &[(&str, &[u8])] = &[("manifest.json", good.as_bytes()), ("plugin.wasm", WASM)];
assert_eq!(run(Kind::Plugin, "dice", files, &["vault.read"]), Ok(()));
let greedy = manifest("dice", "1.0.0", &["vault.read", "vault.write"]);
let files: &[(&str, &[u8])] = &[("manifest.json", greedy.as_bytes()), ("plugin.wasm", WASM)];
assert!(run(Kind::Plugin, "dice", files, &["vault.read"]).unwrap_err().contains("permissions"));
let other = manifest("other", "1.0.0", &[]);
assert!(run(Kind::Plugin, "dice", &[("manifest.json", other.as_bytes()), ("plugin.wasm", WASM)], &[]).is_err());
let old = manifest("dice", "0.9.0", &[]);
assert!(run(Kind::Plugin, "dice", &[("manifest.json", old.as_bytes()), ("plugin.wasm", WASM)], &[]).is_err());
let plain = manifest("dice", "1.0.0", &[]);
assert!(run(Kind::Plugin, "dice", &[("manifest.json", plain.as_bytes()), ("plugin.wasm", b"MZ not wasm")], &[]).is_err());
assert!(run(Kind::Plugin, "dice", &[("plugin.wasm", WASM)], &[]).is_err());
}
#[test]
fn font_and_snippet() {
assert_eq!(run(Kind::Font, "f", &[("f.ttf", &[0, 1, 0, 0, 9])], &[]), Ok(()));
assert_eq!(run(Kind::Font, "f", &[("f.otf", b"OTTO..")], &[]), Ok(()));
assert!(run(Kind::Font, "f", &[("f.ttf", b"GIF89a")], &[]).is_err());
assert_eq!(run(Kind::Snippet, "s", &[("s.md", b"# hi")], &[]), Ok(()));
assert!(run(Kind::Snippet, "s", &[("s.md", b"a\0b")], &[]).is_err());
assert!(run(Kind::Snippet, "s", &[("s.md", &[0xff])], &[]).is_err());
}
#[test]
fn pack_checks_each_file_by_folder() {
let ok: &[(&str, &[u8])] = &[("grammars/tiny.sublime-syntax", TINY.as_bytes()), ("themes/mine.toml", THEME.as_bytes()), ("icons/a.json", b"{}")];
assert_eq!(run(Kind::Pack, "p", ok, &[]), Ok(()));
let bad: &[(&str, &[u8])] = &[("grammars/tiny.sublime-syntax", TINY.as_bytes()), ("icons/a.json", b"{nope")];
assert!(run(Kind::Pack, "p", bad, &[]).is_err(), "one bad file fails the pack");
}
#[test]
fn the_staged_files_must_be_the_listed_files() {
let listed: &[(&str, &[u8])] = &[("tiny.sublime-syntax", TINY.as_bytes())];
let entry = parsed("grammar", "tiny", listed, &[]);
let swapped: Staged = vec![("evil.sublime-syntax".into(), TINY.as_bytes().to_vec())];
assert!(check_content(Kind::Grammar, &entry, &swapped).is_err());
let doubled: Staged = vec![("tiny.sublime-syntax".into(), TINY.as_bytes().to_vec()), ("tiny.sublime-syntax".into(), TINY.as_bytes().to_vec())];
assert!(check_content(Kind::Grammar, &entry, &doubled).is_err());
}
+112
View File
@@ -0,0 +1,112 @@
//! Bounded download and index fetch of the add-on registry, against a fake transport.
mod addons_support;
use addons_support::{entry, index_json, url};
use notescraft_engine::addons::{FakeTransport, File, INDEX_URL, IndexCache, Response, Transport, Ureq, download_file, fetch_index, split_code};
use notescraft_syntax::validate::sha256_hex;
const BODY: &[u8] = b"hello grammar";
const CAP: u64 = 512 * 1024;
fn file(link: &str) -> File {
File { path: "a.txt".into(), size: BODY.len() as u64, sha256: sha256_hex(BODY), url: link.into() }
}
fn good() -> String {
url("grammar", "x", "1.0.0", "a.txt")
}
fn refused(link: &str) -> String {
let transport = FakeTransport::new().with(link, BODY);
download_file(&transport, &file(link), CAP).expect_err("refused").to_string()
}
#[test]
fn a_listed_file_downloads() {
let transport = FakeTransport::new().with(&good(), BODY);
assert_eq!(download_file(&transport, &file(&good()), CAP).unwrap(), BODY);
}
#[test]
fn hostile_links_never_reach_the_transport() {
for link in [
"https://evil.example.com/a.txt".to_owned(),
good().replacen("https", "http", 1),
good().replacen("https://", "https://user:pw@", 1),
good().replacen("git.marcospaulo.dev.br", "127.0.0.1", 1),
format!("{}?x=1", good()),
] {
let transport = FakeTransport::new().with(&link, BODY);
assert!(download_file(&transport, &file(&link), CAP).is_err(), "{link}");
assert!(transport.asked().is_empty(), "{link} was fetched");
}
assert!(refused("https://evil.example.com/a.txt").contains("registry"));
}
#[test]
fn a_redirect_status_is_a_failure() {
let transport = FakeTransport::new().with_response(&good(), Ok(Response { status: 302, etag: None, bytes: Vec::new() }));
let error = download_file(&transport, &file(&good()), CAP).expect_err("redirect").to_string();
assert!(error.contains("download_failed"), "{error}");
}
#[test]
fn a_body_one_byte_too_long_or_short_is_refused() {
let mut longer = BODY.to_vec();
longer.push(b'!');
let transport = FakeTransport::new().with(&good(), &longer);
assert!(download_file(&transport, &file(&good()), CAP).is_err());
let transport = FakeTransport::new().with(&good(), &BODY[..BODY.len() - 1]);
assert!(download_file(&transport, &file(&good()), CAP).is_err());
}
#[test]
fn a_wrong_hash_or_a_size_over_the_cap_is_refused() {
let transport = FakeTransport::new().with(&good(), b"HELLO grammar");
assert!(download_file(&transport, &file(&good()), CAP).unwrap_err().to_string().contains("sha256"));
let transport = FakeTransport::new().with(&good(), BODY);
assert!(download_file(&transport, &file(&good()), 3).unwrap_err().to_string().contains("cap"));
}
#[test]
fn no_network_maps_to_the_offline_code() {
let error = download_file(&FakeTransport::new(), &file(&good()), CAP).unwrap_err().to_string();
let message = error.strip_prefix("add-on: ").expect("an add-on error");
assert_eq!(split_code(message).0, "offline");
assert_eq!(split_code("a gate failed").0, "registry_refused");
}
#[test]
fn the_real_transport_refuses_plain_http_without_a_request() {
let error = Ureq::new().get("http://git.marcospaulo.dev.br/x", 10, None).expect_err("http").to_string();
assert!(error.contains("https"), "{error}");
}
#[test]
fn a_304_keeps_the_cache_and_a_failed_fetch_changes_nothing() {
let doc = index_json(&[entry("grammar", "x", "1.0.0", &[("a.txt", BODY)], &[])], &[]);
let first = FakeTransport::new().with_response(INDEX_URL, Ok(Response { status: 200, etag: Some("\"v1\"".into()), bytes: doc.clone() }));
let cache = fetch_index(&first, None, 100).unwrap();
assert_eq!((cache.fetched_at, cache.etag.as_deref()), (100, Some("\"v1\"")));
let again = FakeTransport::new().with_response(INDEX_URL, Ok(Response { status: 304, etag: None, bytes: Vec::new() }));
let kept = fetch_index(&again, Some(&cache), 200).unwrap();
assert_eq!((kept.fetched_at, kept.body.as_str(), kept.etag.as_deref()), (200, cache.body.as_str(), Some("\"v1\"")));
let down = fetch_index(&FakeTransport::new(), Some(&cache), 300).expect_err("offline").to_string();
assert!(down.contains("offline"), "{down}");
let junk = FakeTransport::new().with(INDEX_URL, b"not json");
assert!(fetch_index(&junk, Some(&cache), 300).is_err());
}
#[test]
fn the_cache_round_trips_on_disk() {
let dir = std::env::temp_dir().join(format!("notescraft-addons-net-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(IndexCache::load(&dir).unwrap(), None);
let cache = IndexCache { fetched_at: 5, etag: None, body: String::from_utf8(index_json(&[], &[])).unwrap() };
cache.save(&dir).unwrap();
assert_eq!(IndexCache::load(&dir).unwrap(), Some(cache));
std::fs::write(dir.join("index-cache.json"), b"{").unwrap();
assert!(IndexCache::load(&dir).is_err());
std::fs::remove_dir_all(&dir).unwrap();
}
+143
View File
@@ -0,0 +1,143 @@
//! State file, per-vault file and store layout of the add-on registry.
mod common;
use notescraft_engine::addons::{Installed, Kind, Source, State, Store, VaultFile, Version, key};
fn v(major: u16, minor: u16, patch: u16) -> Version {
Version { major, minor, patch }
}
fn row(version: Version, previous: Option<Version>) -> Installed {
Installed { version, previous, source: Source::Registry, installed_at: "2026-10-21T12:00:00Z".into(), sha256_of_files: "e".repeat(64) }
}
fn sample() -> State {
let mut state = State::default();
state.installed.insert(key(Kind::Grammar, "twig"), row(v(1, 1, 0), Some(v(1, 0, 0))));
state.installed.insert(key(Kind::Plugin, "dice"), row(v(0, 1, 0), None));
state
}
#[test]
fn state_round_trip() {
let state = sample();
assert_eq!(State::parse(state.to_json().unwrap().as_bytes()).unwrap(), state);
}
#[test]
fn state_rejections() {
assert!(State::parse(b"nope").is_err());
assert!(State::parse(br#"{"version":2,"installed":{}}"#).is_err());
assert!(State::parse(br#"{"installed":{}}"#).is_err());
let good = |k: &str| {
format!(r#"{{"version":1,"installed":{{"{k}":{{"version":"1.0.0","source":"seed","installed_at":"t","sha256_of_files":"{}"}}}}}}"#, "e".repeat(64))
};
assert!(State::parse(good("grammar:twig").as_bytes()).is_ok());
for bad in ["twig", "exe:twig", "grammar:../x", "grammar:Twig", "grammar:"] {
assert!(State::parse(good(bad).as_bytes()).is_err(), "{bad}");
}
assert!(State::parse(good("grammar:a").replace("1.0.0", "1.0").as_bytes()).is_err());
assert!(State::parse(good("grammar:a").replace("seed", "magic").as_bytes()).is_err());
let mut big = State::default();
for i in 0..513 {
big.installed.insert(key(Kind::Theme, &format!("t{i}")), row(v(1, 0, 0), None));
}
assert!(big.to_json().is_err());
let rows: Vec<String> =
(0..513).map(|i| format!(r#""theme:t{i}":{{"version":"1.0.0","source":"seed","installed_at":"t","sha256_of_files":"{}"}}"#, "e".repeat(64))).collect();
assert!(State::parse(format!(r#"{{"version":1,"installed":{{{}}}}}"#, rows.join(",")).as_bytes()).is_err());
}
#[test]
fn vault_file_round_trip_and_caps() {
let file = VaultFile { enabled: vec!["plugin:dice".into()], wanted: vec!["grammar:twig".into(), "theme:x".into()] };
assert_eq!(VaultFile::parse(file.to_json().unwrap().as_bytes()).unwrap(), file);
assert!(VaultFile::parse(br#"{"version":2}"#).is_err());
assert!(VaultFile::parse(br#"{"version":1,"enabled":["grammar:twig"]}"#).is_err());
assert!(VaultFile::parse(br#"{"version":1,"wanted":["../x"]}"#).is_err());
let many: Vec<String> = (0..257).map(|i| format!("grammar:g{i}")).collect();
assert!(VaultFile { enabled: vec![], wanted: many }.to_json().is_err());
assert_eq!(VaultFile::parse(br#"{"version":1}"#).unwrap(), VaultFile::default());
}
#[test]
fn commit_and_load() {
let dir = common::temp_config("addons-commit");
let store = Store::open(dir.path()).unwrap();
assert_eq!(store.load().unwrap(), State::default());
let state = sample();
store.commit(&state).unwrap();
assert_eq!(store.load().unwrap(), state);
assert!(store.root().join("state.json").is_file());
}
#[test]
fn corrupt_state_errs_and_file_is_untouched() {
let dir = common::temp_config("addons-corrupt");
let store = Store::open(dir.path()).unwrap();
let path = store.root().join("state.json");
std::fs::write(&path, b"{ not json").unwrap();
assert!(store.load().is_err());
assert!(store.gc(&State::default()).is_ok());
assert_eq!(std::fs::read(&path).unwrap(), b"{ not json");
}
#[test]
fn version_dir_uses_validated_parts() {
let dir = common::temp_config("addons-vdir");
let store = Store::open(dir.path()).unwrap();
let path = store.version_dir(Kind::Grammar, "twig", v(1, 2, 3)).unwrap();
assert!(path.starts_with(store.root()));
assert!(path.ends_with("store/grammar/twig/1.2.3"));
assert!(store.version_dir(Kind::Grammar, "../x", v(1, 0, 0)).is_err());
assert!(store.version_dir(Kind::Grammar, "a/b", v(1, 0, 0)).is_err());
assert!(store.version_dir(Kind::Grammar, "", v(1, 0, 0)).is_err());
}
#[test]
fn gc_keeps_version_and_previous_only() {
let dir = common::temp_config("addons-gc");
let store = Store::open(dir.path()).unwrap();
let state = sample();
let touch = |kind, id, ver| {
let d = store.version_dir(kind, id, ver).unwrap();
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("f"), b"x").unwrap();
d
};
let active = touch(Kind::Grammar, "twig", v(1, 1, 0));
let previous = touch(Kind::Grammar, "twig", v(1, 0, 0));
let old = touch(Kind::Grammar, "twig", v(0, 9, 0));
let orphan = touch(Kind::Theme, "gone", v(1, 0, 0));
let plugin = touch(Kind::Plugin, "dice", v(0, 1, 0));
std::fs::create_dir_all(store.staging_dir().join("abc")).unwrap();
std::fs::write(store.staging_dir().join("abc/part"), b"x").unwrap();
std::fs::write(store.staging_dir().join("loose"), b"x").unwrap();
let outside = dir.path().join("outside");
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("keep"), b"x").unwrap();
assert_eq!(store.gc(&state).unwrap(), 2);
assert!(active.is_dir() && previous.is_dir() && plugin.is_dir());
assert!(!old.exists() && !orphan.exists());
assert!(!orphan.parent().unwrap().exists(), "empty parents are pruned");
assert_eq!(std::fs::read_dir(store.staging_dir()).unwrap().count(), 0);
assert!(outside.join("keep").is_file());
}
#[cfg(unix)]
#[test]
fn gc_does_not_follow_links() {
let dir = common::temp_config("addons-link");
let store = Store::open(dir.path()).unwrap();
let outside = dir.path().join("outside");
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("keep"), b"x").unwrap();
let parent = store.version_dir(Kind::Theme, "t", v(1, 0, 0)).unwrap().parent().unwrap().to_path_buf();
std::fs::create_dir_all(&parent).unwrap();
std::os::unix::fs::symlink(&outside, parent.join("2.0.0")).unwrap();
store.gc(&State::default()).unwrap();
assert!(outside.join("keep").is_file());
assert!(!parent.join("2.0.0").exists());
}
+68
View File
@@ -0,0 +1,68 @@
//! Shared fixtures of the add-on tests: signed-looking entries, index documents and a fake
//! transport that serves their files. Signatures are not real here (tests use `FakeVerifier`).
#![allow(dead_code)] // each test target uses a different part
use std::sync::Arc;
use notescraft_engine::addons::{FakeTransport, Index, Response, Transport};
use notescraft_syntax::validate::sha256_hex;
use serde_json::{Value, json};
/// Where the first-party registry serves files from.
pub const BASE: &str = "https://git.marcospaulo.dev.br/masi/notescraft-registry/raw/branch/main/files";
/// A grammar the syntax crate accepts.
pub const TINY: &str = "%YAML 1.2\n---\nname: Tiny\nfile_extensions: [tiny]\nscope: source.tiny\ncontexts:\n main:\n - match: '\\bfoo\\b'\n scope: keyword.control.tiny\n";
/// A theme the themes crate accepts.
pub const THEME: &str = "id = \"mine\"\nname = \"Mine\"\nvariant = \"dark\"\n";
/// The smallest WebAssembly module header.
pub const WASM: &[u8] = b"\0asm\x01\0\0\0";
/// A plugin manifest for `id` at `version` with `permissions`.
pub fn manifest(id: &str, version: &str, permissions: &[&str]) -> String {
json!({ "id": id, "name": "Dice", "version": version, "min_app_version": "0.1.0", "permissions": permissions, "commands": [{ "id": "roll", "name": "Roll" }] })
.to_string()
}
/// The download link of one file of an entry.
pub fn url(kind: &str, id: &str, version: &str, path: &str) -> String {
format!("{BASE}/{kind}/{id}/{version}/{path}")
}
/// An entry as the index writes it, with the real hashes and sizes of `files`.
pub fn entry(kind: &str, id: &str, version: &str, files: &[(&str, &[u8])], permissions: &[&str]) -> Value {
let listed: Vec<Value> = files
.iter()
.map(|(path, bytes)| json!({ "path": path, "size": bytes.len(), "sha256": sha256_hex(bytes), "url": url(kind, id, version, path) }))
.collect();
json!({
"id": id, "kind": kind, "title": id, "summary": "s", "version": version, "min_app_version": "0.1.0", "license": "MIT",
"aliases": [id], "permissions": permissions, "files": listed, "signature": { "key_id": "k1", "sig": "QUJD" },
})
}
/// An index document.
pub fn index_json(entries: &[Value], revoked: &[(&str, &str)]) -> Vec<u8> {
let revoked: Vec<Value> = revoked.iter().map(|(id, version)| json!({ "id": id, "version": version })).collect();
json!({ "version": 2, "generated_at": "2026-10-21T00:00:00Z", "entries": entries, "revoked": revoked }).to_string().into_bytes()
}
/// The parsed index of `entries`.
pub fn index(entries: &[Value], revoked: &[(&str, &str)]) -> Index {
Index::parse(&index_json(entries, revoked)).expect("the fixture index parses")
}
/// A transport serving the files of `entry` (as written by [`entry`]) from `files`.
pub fn serve(transport: FakeTransport, kind: &str, id: &str, version: &str, files: &[(&str, &[u8])]) -> FakeTransport {
files.iter().fold(transport, |t, (path, bytes)| t.with(&url(kind, id, version, path), bytes))
}
/// A transport shared between the test and the session that owns the box.
#[derive(Debug, Clone)]
pub struct Shared(pub Arc<FakeTransport>);
impl Transport for Shared {
fn get(&self, url: &str, max_bytes: u64, if_none_match: Option<&str>) -> notescraft_engine::Result<Response> {
self.0.get(url, max_bytes, if_none_match)
}
}
+115
View File
@@ -0,0 +1,115 @@
//! Signature canonical text and ed25519 verification of registry entries.
use notescraft_engine::addons::{Ed25519, Entry, FakeVerifier, Index, Verifier, canonical_text};
use ring::signature::{Ed25519KeyPair, KeyPair};
use serde_json::{Value, json};
/// Test-only seed; not a secret and not the production key.
const TEST_SEED: [u8; 32] = [7; 32];
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
fn b64(bytes: &[u8]) -> String {
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n = chunk.iter().enumerate().fold(0_u32, |n, (i, b)| n | u32::from(*b) << (16 - 8 * i));
for i in 0..4 {
if i <= chunk.len() {
out.push(char::from(B64[usize::try_from((n >> (18 - 6 * i)) & 63).unwrap()]));
} else {
out.push('=');
}
}
}
out
}
fn pair() -> Ed25519KeyPair {
Ed25519KeyPair::from_seed_unchecked(&TEST_SEED).unwrap()
}
fn verifier() -> Ed25519 {
Ed25519::new(vec![("k1".into(), pair().public_key().as_ref().try_into().unwrap())])
}
fn files(order: &[&str]) -> Value {
Value::Array(order.iter().map(|p| json!({ "path": p, "size": 5, "sha256": "c".repeat(64), "url": format!("https://h.example/{p}") })).collect())
}
fn entry_json(files: &Value, sig: &str, key_id: &str) -> Value {
json!({
"id": "p", "kind": "pack", "title": "P", "version": "1.2.3", "min_app_version": "0.1.0", "license": "MIT",
"files": files, "signature": { "key_id": key_id, "sig": sig }
})
}
fn parse(value: &Value) -> Entry {
let index = Index::parse(json!({ "version": 2, "entries": [value] }).to_string().as_bytes()).unwrap();
index.entries[0].clone()
}
fn signed(order: &[&str]) -> Entry {
let unsigned = parse(&entry_json(&files(order), "QUJD", "k1"));
let sig = b64(pair().sign(canonical_text(&unsigned).as_bytes()).as_ref());
parse(&entry_json(&files(order), &sig, "k1"))
}
const A: &str = "grammars/a.sublime-syntax";
const B: &str = "themes/b.toml";
#[test]
fn known_answer_verifies() {
let entry = signed(&[A, B]);
assert_eq!(canonical_text(&entry), format!("notescraft-registry-v1\np\npack\n1.2.3\n{A}\n{}\n5\n{B}\n{}\n5\n", "c".repeat(64), "c".repeat(64)));
verifier().verify(&entry).unwrap();
}
#[test]
fn file_order_does_not_change_text_or_signature() {
let entry = signed(&[A, B]);
let mut shuffled = entry.clone();
shuffled.files.reverse();
assert_eq!(canonical_text(&entry), canonical_text(&shuffled));
verifier().verify(&shuffled).unwrap();
}
#[test]
fn tampering_fails() {
let entry = signed(&[A, B]);
let mut hash = entry.clone();
hash.files[0].sha256 = format!("d{}", "c".repeat(63));
assert!(verifier().verify(&hash).is_err());
let mut size = entry.clone();
size.files[0].size += 1;
assert!(verifier().verify(&size).is_err());
let mut version = entry.clone();
version.version.patch += 1;
assert!(verifier().verify(&version).is_err());
let mut id = entry.clone();
id.id = "q".into();
assert!(verifier().verify(&id).is_err());
let mut kind = entry;
kind.kind = notescraft_engine::addons::Kind::Theme;
assert!(verifier().verify(&kind).is_err());
}
#[test]
fn unknown_key_bad_length_and_empty_trust_fail() {
let entry = signed(&[A]);
let mut other = entry.clone();
other.signature.key_id = "k2".into();
assert!(verifier().verify(&other).is_err());
let mut short = entry.clone();
short.signature.sig = b64(&[1, 2, 3]);
assert!(verifier().verify(&short).is_err());
let mut junk = entry.clone();
junk.signature.sig = "!!!!".into();
assert!(verifier().verify(&junk).is_err());
assert!(Ed25519::trusted().verify(&entry).is_err());
}
#[test]
fn fake_verifier() {
let entry = signed(&[A]);
assert!(FakeVerifier { accept: true }.verify(&entry).is_ok());
assert!(FakeVerifier { accept: false }.verify(&entry).is_err());
}
+10
View File
@@ -39,6 +39,16 @@ const CORE_IDS: &[&str] = &[
"grammar.install",
"grammar.install_text",
"grammar.remove",
"registry.check",
"registry.disable",
"registry.enable",
"registry.info",
"registry.install",
"registry.list",
"registry.remove",
"registry.rollback",
"registry.suggest",
"registry.update",
"json.format_block",
"json.minify_block",
"vault.info",
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "notescraft-htmlmd"
description = "forgiving HTML tokenizer for the clean paste pipeline"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
publish = false
[lib]
path = "src/lib.rs"
[dependencies]
thiserror = { workspace = true }
[lints]
workspace = true
+94
View File
@@ -0,0 +1,94 @@
//! Decoding of HTML character references.
/// The named entities the tokenizer understands.
const NAMED: &[(&str, char)] = &[
("amp", '&'),
("lt", '<'),
("gt", '>'),
("quot", '"'),
("apos", '\''),
("nbsp", '\u{00a0}'),
("ndash", '\u{2013}'),
("mdash", '\u{2014}'),
("hellip", '\u{2026}'),
("lsquo", '\u{2018}'),
("rsquo", '\u{2019}'),
("ldquo", '\u{201c}'),
("rdquo", '\u{201d}'),
("copy", '\u{00a9}'),
("reg", '\u{00ae}'),
("trade", '\u{2122}'),
("bull", '\u{2022}'),
("middot", '\u{00b7}'),
("laquo", '\u{00ab}'),
("raquo", '\u{00bb}'),
("euro", '\u{20ac}'),
("pound", '\u{00a3}'),
("yen", '\u{00a5}'),
("cent", '\u{00a2}'),
("deg", '\u{00b0}'),
("times", '\u{00d7}'),
("divide", '\u{00f7}'),
];
/// The longest entity reference accepted, in characters.
const MAX_ENTITY_LEN: usize = 32;
/// Replace the character references of `text` by the characters they stand for.
///
/// Named, decimal and hexadecimal references are understood. An unknown name
/// or an invalid code point is left verbatim.
#[must_use]
pub fn decode_entities(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(pos) = rest.find('&') {
out.push_str(rest.get(..pos).unwrap_or(""));
let tail = rest.get(pos..).unwrap_or("");
if let Some((ch, len)) = decode_one(tail) {
out.push(ch);
rest = tail.get(len..).unwrap_or("");
} else {
out.push('&');
rest = tail.get(1..).unwrap_or("");
}
}
out.push_str(rest);
out
}
/// Decode the reference at the start of `tail`, which begins with `&`.
///
/// Returns the character and the number of bytes the reference uses.
fn decode_one(tail: &str) -> Option<(char, usize)> {
let body = tail.get(1..)?;
let Some(digits) = body.strip_prefix('#') else {
return decode_named(body);
};
let (radix, digits) = match digits.strip_prefix('x').or_else(|| digits.strip_prefix('X')) {
Some(hex) => (16, hex),
None => (10, digits),
};
let end = digits.find(';')?;
if end == 0 || end > MAX_ENTITY_LEN {
return None;
}
let number = digits.get(..end)?;
let code = u32::from_str_radix(number, radix).ok()?;
if code == 0 {
return None;
}
let ch = char::from_u32(code)?;
Some((ch, 1 + 1 + usize::from(radix == 16) + end + 1))
}
/// Decode a named reference; `body` is the text after the `&`.
fn decode_named(body: &str) -> Option<(char, usize)> {
let end = body.find(';')?;
if end == 0 || end > MAX_ENTITY_LEN {
return None;
}
let name = body.get(..end)?;
let ch = NAMED.iter().find(|(n, _)| *n == name).map(|(_, ch)| *ch)?;
Some((ch, 1 + end + 1))
}
+33
View File
@@ -0,0 +1,33 @@
//! A forgiving HTML tokenizer for the clean paste pipeline.
//!
//! The crate is pure: no IO, no network, no third-party dependency. It turns
//! the HTML of a paste into a stream of [`Token`]s that a converter can walk
//! without building a DOM, so malformed markup can never make it loop or
//! panic.
//!
//! ```
//! use notescraft_htmlmd::{Token, tokenize};
//!
//! let tokens = tokenize("<p class=\"a\">Hi &amp; bye</p>", 64).expect("tokens");
//! assert_eq!(tokens[0], Token::Open { name: "p".to_string(), attrs: vec![("class".to_string(), "a".to_string())], self_closing: false });
//! assert_eq!(tokens[1], Token::Text("Hi & bye".to_string()));
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod entities;
pub mod tokens;
pub use entities::decode_entities;
pub use tokens::{Token, tokenize};
/// The errors of this crate.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The input is longer than the limit, or it produced too many tokens.
#[error("input too large")]
TooLarge,
}
/// The result of this crate.
pub type Result<T> = std::result::Result<T, Error>;
+393
View File
@@ -0,0 +1,393 @@
//! A forgiving tokenizer for pasted HTML.
//!
//! The tokenizer never rejects malformed input: markup that does not parse is
//! returned as text. It only has to be good enough to find tags, attributes
//! and entities for the clean paste pipeline.
//!
//! ```
//! use notescraft_htmlmd::{Token, tokenize};
//!
//! let tokens = tokenize("<p>Hi &amp; bye</p>", 64).expect("tokens");
//! assert_eq!(tokens.len(), 3);
//! assert_eq!(tokens[1], Token::Text("Hi & bye".to_string()));
//! ```
use crate::entities::decode_entities;
use crate::{Error, Result};
/// Longest input accepted by [`tokenize`], in bytes.
pub const MAX_INPUT_BYTES: usize = 2 * 1024 * 1024;
/// Elements that never have a closing tag.
const VOID_ELEMENTS: &[&str] = &["br", "hr", "img", "input", "meta", "link"];
/// Elements whose content is raw text, not markup.
const RAW_TEXT_ELEMENTS: &[&str] = &["script", "style"];
/// One token of the HTML stream.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Token {
/// Text between tags, with entities decoded.
Text(String),
/// An opening tag.
Open {
/// The lowercased tag name.
name: String,
/// The attributes in source order; names lowercased, values decoded.
attrs: Vec<(String, String)>,
/// True for void elements and for tags closed with `/>`.
self_closing: bool,
},
/// A closing tag, holding the lowercased tag name.
Close(String),
/// The body of an HTML comment.
Comment(String),
}
/// What a `<` opens.
enum Kind {
/// Not a tag: the `<` is text.
Text,
/// A comment.
Comment,
/// A declaration (`<!DOCTYPE>`, `<![CDATA[`, `<?xml>`): dropped.
Dropped,
/// An opening tag.
Open,
/// A closing tag.
Close,
}
/// The outcome of reading a tag.
enum TagResult {
/// The tag and the position just after it.
Parsed(Token, usize),
/// The tag never ended: the rest of the input is text.
Unterminated,
}
/// The scanner state.
struct Scanner<'a> {
/// The whole input.
src: &'a str,
/// The current byte position, always on a character boundary.
pos: usize,
/// The tokens produced so far.
out: Vec<Token>,
/// The maximum number of tokens allowed.
max_tokens: usize,
}
/// Split `html` into tokens.
///
/// Tag and attribute names are lowercased, attribute values are decoded and
/// the first of two attributes with the same name wins. The content of
/// `script` and `style` is returned raw. Comments are returned as
/// [`Token::Comment`]; declarations are dropped. Markup that is not closed
/// is returned as text.
///
/// # Errors
/// Returns [`Error::TooLarge`] when the input is longer than
/// [`MAX_INPUT_BYTES`] or when more than `max_tokens` tokens are produced.
pub fn tokenize(html: &str, max_tokens: usize) -> Result<Vec<Token>> {
if html.len() > MAX_INPUT_BYTES {
return Err(Error::TooLarge);
}
let scanner = Scanner { src: html, pos: 0, out: Vec::new(), max_tokens };
scanner.run()
}
impl Scanner<'_> {
fn run(mut self) -> Result<Vec<Token>> {
let mut text = String::new();
while let Some(tail) = self.src.get(self.pos..) {
let Some(rel) = tail.find('<') else {
text.push_str(tail);
self.pos = self.src.len();
break;
};
let lt = self.pos + rel;
text.push_str(self.src.get(self.pos..lt).unwrap_or(""));
self.pos = lt;
match self.classify() {
Kind::Text => {
text.push('<');
self.pos += 1;
}
Kind::Comment => {
let Some((content, next)) = self.read_comment() else {
text.push_str(self.src.get(self.pos..).unwrap_or(""));
self.pos = self.src.len();
self.flush(&mut text)?;
break;
};
self.flush(&mut text)?;
self.pos = next;
self.push(Token::Comment(content))?;
}
Kind::Dropped => {
let Some(next) = self.read_declaration() else {
text.push_str(self.src.get(self.pos..).unwrap_or(""));
self.pos = self.src.len();
self.flush(&mut text)?;
break;
};
self.flush(&mut text)?;
self.pos = next;
}
Kind::Open => {
self.flush(&mut text)?;
let TagResult::Parsed(token, next) = self.read_open_tag() else {
text.push_str(self.src.get(self.pos..).unwrap_or(""));
self.pos = self.src.len();
self.flush(&mut text)?;
break;
};
self.pos = next;
self.push(token.clone())?;
self.read_raw_text(&token)?;
}
Kind::Close => {
self.flush(&mut text)?;
let TagResult::Parsed(token, next) = self.read_close_tag(self.pos) else {
text.push_str(self.src.get(self.pos..).unwrap_or(""));
self.pos = self.src.len();
self.flush(&mut text)?;
break;
};
self.pos = next;
self.push(token)?;
}
}
}
self.flush(&mut text)?;
Ok(self.out)
}
/// Push a token, refusing to go over the cap.
fn push(&mut self, token: Token) -> Result<()> {
if self.out.len() >= self.max_tokens {
return Err(Error::TooLarge);
}
self.out.push(token);
Ok(())
}
/// Move the pending text into a token.
fn flush(&mut self, text: &mut String) -> Result<()> {
if text.is_empty() {
return Ok(());
}
let decoded = decode_entities(text);
text.clear();
self.push(Token::Text(decoded))
}
/// Decide what the `<` at the current position opens.
fn classify(&self) -> Kind {
let Some(after) = self.src.get(self.pos + 1..) else { return Kind::Text };
let Some(first) = after.chars().next() else { return Kind::Text };
match first {
'!' if after.starts_with("!--") => Kind::Comment,
'!' | '?' => Kind::Dropped,
'/' => {
if after.get(1..).and_then(|s| s.chars().next()).is_some_and(|c| c.is_ascii_alphabetic()) {
Kind::Close
} else {
Kind::Text
}
}
c if c.is_ascii_alphabetic() => Kind::Open,
_ => Kind::Text,
}
}
/// Read a comment; `None` when it is never closed.
fn read_comment(&self) -> Option<(String, usize)> {
let after = self.src.get(self.pos + 4..)?;
let end = after.find("-->")?;
let content = after.get(..end)?.to_string();
Some((content, self.pos + 4 + end + 3))
}
/// Skip a declaration; `None` when it is never closed.
fn read_declaration(&self) -> Option<usize> {
let after = self.src.get(self.pos + 1..)?;
if let Some(rest) = after.strip_prefix("![CDATA[") {
let end = rest.find("]]>")?;
return Some(self.pos + 1 + 8 + end + 3);
}
let end = after.find('>')?;
Some(self.pos + 1 + end + 1)
}
/// Read an opening tag at `lt`.
fn read_open_tag(&self) -> TagResult {
let src = self.src;
let (name, mut p) = read_name(src, self.pos + 1);
if name.is_empty() {
return TagResult::Unterminated;
}
let mut attrs: Vec<(String, String)> = Vec::new();
let mut self_closing = false;
loop {
let Some(tail) = src.get(p..) else { return TagResult::Unterminated };
let Some(first) = tail.chars().next() else { return TagResult::Unterminated };
if first.is_whitespace() {
p += first.len_utf8();
continue;
}
if first == '>' {
p += 1;
break;
}
if first == '/' {
if tail.get(1..).is_some_and(|s| s.starts_with('>')) {
self_closing = true;
p += 2;
break;
}
p += 1;
continue;
}
let (attr, after) = read_attr_name(src, p);
if attr.is_empty() {
p += first.len_utf8();
continue;
}
p = after;
let (value, next) = read_attr_value(src, p);
let Some(value) = value else { return TagResult::Unterminated };
p = next;
if !attrs.iter().any(|(n, _)| n == &attr) {
attrs.push((attr, decode_entities(&value)));
}
}
let self_closing = self_closing || VOID_ELEMENTS.contains(&name.as_str());
TagResult::Parsed(Token::Open { name, attrs, self_closing }, p)
}
/// Read a closing tag at `lt`.
fn read_close_tag(&self, lt: usize) -> TagResult {
let src = self.src;
let (name, mut p) = read_name(src, lt + 2);
if name.is_empty() {
return TagResult::Unterminated;
}
loop {
let Some(tail) = src.get(p..) else { return TagResult::Unterminated };
let Some(first) = tail.chars().next() else { return TagResult::Unterminated };
p += first.len_utf8();
if first == '>' {
break;
}
}
TagResult::Parsed(Token::Close(name), p)
}
/// Read the raw content of `script` and `style` elements.
fn read_raw_text(&mut self, token: &Token) -> Result<()> {
let Token::Open { name, self_closing, .. } = token else { return Ok(()) };
if *self_closing || !RAW_TEXT_ELEMENTS.contains(&name.as_str()) {
return Ok(());
}
let start = self.pos;
let Some(lt) = find_raw_end(self.src, start, name) else {
let content = self.src.get(start..).unwrap_or("").to_string();
self.pos = self.src.len();
if !content.is_empty() {
self.push(Token::Text(content))?;
}
return Ok(());
};
let content = self.src.get(start..lt).unwrap_or("").to_string();
self.pos = lt;
if !content.is_empty() {
self.push(Token::Text(content))?;
}
if let TagResult::Parsed(close, next) = self.read_close_tag(lt) {
self.pos = next;
self.push(close)?;
}
Ok(())
}
}
/// Read a tag name at `pos`, lowercased.
fn read_name(src: &str, pos: usize) -> (String, usize) {
let mut end = pos;
for (offset, ch) in src.get(pos..).unwrap_or("").char_indices() {
if !ch.is_ascii_alphanumeric() && ch != '-' {
break;
}
end = pos + offset + ch.len_utf8();
}
(src.get(pos..end).unwrap_or("").to_ascii_lowercase(), end)
}
/// Read an attribute name at `pos`, lowercased.
fn read_attr_name(src: &str, pos: usize) -> (String, usize) {
let mut end = pos;
for (offset, ch) in src.get(pos..).unwrap_or("").char_indices() {
if ch.is_whitespace() || ch == '=' || ch == '>' || ch == '/' {
break;
}
end = pos + offset + ch.len_utf8();
}
(src.get(pos..end).unwrap_or("").to_ascii_lowercase(), end)
}
/// Read the value of the attribute whose name ends at `pos`.
///
/// Returns the raw value and the position after it, or `None` when the
/// value is never closed.
fn read_attr_value(src: &str, pos: usize) -> (Option<String>, usize) {
let mut p = pos;
while let Some(first) = src.get(p..).and_then(|s| s.chars().next()) {
if !first.is_whitespace() {
break;
}
p += first.len_utf8();
}
if !src.get(p..).is_some_and(|s| s.starts_with('=')) {
return (Some(String::new()), p);
}
p += 1;
while let Some(first) = src.get(p..).and_then(|s| s.chars().next()) {
if !first.is_whitespace() {
break;
}
p += first.len_utf8();
}
let Some(tail) = src.get(p..) else { return (None, p) };
let Some(first) = tail.chars().next() else { return (None, p) };
if first == '"' || first == '\'' {
let rest = src.get(p + 1..).unwrap_or("");
let Some(end) = rest.find(first) else { return (None, p) };
return (Some(rest.get(..end).unwrap_or("").to_string()), p + 1 + end + 1);
}
let end = tail.find(|c: char| c.is_whitespace() || c == '>').unwrap_or(tail.len());
(Some(tail.get(..end).unwrap_or("").to_string()), p + end)
}
/// Find the `<` of the closing tag of `name` at or after `from`.
fn find_raw_end(src: &str, from: usize, name: &str) -> Option<usize> {
let mut pos = from;
while let Some(tail) = src.get(pos..) {
let rel = tail.find('<')?;
let lt = pos + rel;
let after = src.get(lt + 1..)?;
let Some(tail2) = after.strip_prefix('/') else {
pos = lt + 1;
continue;
};
if let Some(head) = tail2.get(..name.len()) {
let rest = tail2.get(name.len()..).unwrap_or("");
if head.eq_ignore_ascii_case(name) && (rest.starts_with('>') || rest.starts_with(char::is_whitespace)) {
return Some(lt);
}
}
pos = lt + 1;
}
None
}
+171
View File
@@ -0,0 +1,171 @@
//! Tests of the forgiving HTML tokenizer.
use notescraft_htmlmd::{Error, Token, decode_entities, tokenize};
/// Build an open token.
fn open(name: &str, attrs: &[(&str, &str)], self_closing: bool) -> Token {
Token::Open { name: name.to_string(), attrs: attrs.iter().map(|(n, v)| (n.to_string(), v.to_string())).collect(), self_closing }
}
#[test]
fn tags_attributes_and_text() {
let tokens = tokenize("<p class=\"a b\">Hi &amp; bye</p>", 64).expect("tokens");
assert_eq!(tokens, vec![open("p", &[("class", "a b")], false), Token::Text("Hi & bye".to_string()), Token::Close("p".to_string()),]);
}
#[test]
fn attribute_values_may_be_unquoted_or_single_quoted() {
let tokens = tokenize("<a href=/foo title='x y'>t</a>", 64).expect("tokens");
assert_eq!(tokens[0], open("a", &[("href", "/foo"), ("title", "x y")], false));
}
#[test]
fn attribute_values_are_decoded() {
let tokens = tokenize("<a href=\"?a=1&amp;b=2\">t</a>", 64).expect("tokens");
assert_eq!(tokens[0], open("a", &[("href", "?a=1&b=2")], false));
}
#[test]
fn a_duplicate_attribute_keeps_the_first_value() {
let tokens = tokenize("<p class=\"a\" class=\"b\">x</p>", 64).expect("tokens");
assert_eq!(tokens[0], open("p", &[("class", "a")], false));
}
#[test]
fn names_are_lowercased() {
let tokens = tokenize("<P CLASS=\"A\">x</P>", 64).expect("tokens");
assert_eq!(tokens[0], open("p", &[("class", "A")], false));
assert_eq!(tokens[2], Token::Close("p".to_string()));
}
#[test]
fn void_elements_are_self_closing() {
let tokens = tokenize("<br><img src=\"x\"><hr/>", 64).expect("tokens");
assert_eq!(tokens[0], open("br", &[], true));
assert_eq!(tokens[1], open("img", &[("src", "x")], true));
assert_eq!(tokens[2], open("hr", &[], true));
}
#[test]
fn script_content_is_raw() {
let tokens = tokenize("<script>if (a < b) { x(); }</script>", 64).expect("tokens");
assert_eq!(tokens, vec![open("script", &[], false), Token::Text("if (a < b) { x(); }".to_string()), Token::Close("script".to_string()),]);
}
#[test]
fn style_content_is_raw() {
let tokens = tokenize("<style>a > b { color: red }</style>", 64).expect("tokens");
assert_eq!(tokens[1], Token::Text("a > b { color: red }".to_string()));
}
#[test]
fn comments_are_returned() {
let tokens = tokenize("<!-- hi -->", 64).expect("tokens");
assert_eq!(tokens, vec![Token::Comment(" hi ".to_string())]);
}
#[test]
fn conditional_comments_are_returned() {
let html = "<!--[if gte mso 9]><p>x</p><![endif]-->";
let tokens = tokenize(html, 64).expect("tokens");
assert_eq!(tokens, vec![Token::Comment("[if gte mso 9]><p>x</p><![endif]".to_string())]);
}
#[test]
fn declarations_are_dropped() {
let html = "<!DOCTYPE html><?xml version=\"1.0\"?><![CDATA[ raw ]]>text";
let tokens = tokenize(html, 64).expect("tokens");
assert_eq!(tokens, vec![Token::Text("text".to_string())]);
}
#[test]
fn unterminated_markup_is_text() {
let tokens = tokenize("<p class=\"abc", 64).expect("tokens");
assert_eq!(tokens, vec![Token::Text("<p class=\"abc".to_string())]);
let tokens = tokenize("<p class=x", 64).expect("tokens");
assert_eq!(tokens, vec![Token::Text("<p class=x".to_string())]);
let tokens = tokenize("<!-- oops", 64).expect("tokens");
assert_eq!(tokens, vec![Token::Text("<!-- oops".to_string())]);
}
#[test]
fn a_bare_angle_bracket_is_text() {
let tokens = tokenize("a < b", 64).expect("tokens");
assert_eq!(tokens, vec![Token::Text("a < b".to_string())]);
let tokens = tokenize("<3", 64).expect("tokens");
assert_eq!(tokens, vec![Token::Text("<3".to_string())]);
}
#[test]
fn named_entities_are_decoded() {
let tokens = tokenize("&amp; &lt; &gt; &quot; &apos; &nbsp; &hellip; &mdash; &copy;", 64).expect("tokens");
assert_eq!(tokens, vec![Token::Text("& < > \" ' \u{a0} … — ©".to_string())]);
}
#[test]
fn numeric_entities_are_decoded() {
let tokens = tokenize("&#39; &#x27; &#128512;", 64).expect("tokens");
assert_eq!(tokens, vec![Token::Text("' ' 😀".to_string())]);
}
#[test]
fn invalid_and_unknown_entities_stay_verbatim() {
let tokens = tokenize("&#0; &#xD800; &#x110000; &nope;", 64).expect("tokens");
assert_eq!(tokens, vec![Token::Text("&#0; &#xD800; &#x110000; &nope;".to_string())]);
}
#[test]
fn emoji_survive() {
let tokens = tokenize("hello 😀", 64).expect("tokens");
assert_eq!(tokens, vec![Token::Text("hello 😀".to_string())]);
}
#[test]
fn decode_entities_is_public() {
assert_eq!(decode_entities("a &amp; b"), "a & b");
}
#[test]
fn too_large_input_is_rejected() {
let html = "a".repeat(3 * 1024 * 1024);
let err = tokenize(&html, 64).expect_err("too large");
assert!(matches!(err, Error::TooLarge));
}
#[test]
fn the_token_cap_is_enforced() {
let err = tokenize("<p>a</p>", 2).expect_err("too many tokens");
assert!(matches!(err, Error::TooLarge));
let tokens = tokenize("<p>a</p>", 3).expect("three tokens");
assert_eq!(tokens.len(), 3);
}
/// A tiny linear congruential generator, so the soups are reproducible.
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
self.0 >> 33
}
}
#[test]
fn random_soups_never_panic() {
const ALPHABET: &[char] = &['<', '>', '/', '=', '"', '\'', ' ', 'a', '&', ';', '!', '-'];
let mut rng = Lcg(0x5eed);
for _ in 0..5000 {
let len = usize::try_from(rng.next() % 64).unwrap_or(0);
let mut html = String::new();
for _ in 0..len {
let index = usize::try_from(rng.next()).unwrap_or(0) % ALPHABET.len();
if let Some(ch) = ALPHABET.get(index) {
html.push(*ch);
}
}
let _ = tokenize(&html, 4096);
}
}
+53
View File
@@ -1915,3 +1915,56 @@ remove-banner = "Remove banner"
no-colour = "No colour"
more = "Refine the search to see more icons"
no-images = "No image in the vault"
[ui.registry]
title = "Registry"
description = "Add tools, themes and languages to your workspace."
search = "Search add-ons"
check = "Check for updates"
never = "Never checked"
last-checked = "Last checked {time}"
auto-check = "Check for updates automatically"
all = "All"
plugins = "Plugins"
themes = "Themes"
packs = "Packs"
grammars = "Grammars"
fonts = "Fonts"
snippets = "Snippets"
install = "Install"
installed = "Installed"
update = "Update to {version}"
updates = "{count} updates"
incompatible = "Needs NotesCraft {version}"
revoked = "Revoked"
unsupported = "Not available in this version"
details = "Details"
close = "Close details"
remove = "Remove"
rollback = "Rollback"
enable = "Enable in this vault"
disable = "Disable in this vault"
offline = "Offline — showing cached add-ons. Try checking again when connected."
empty = "No add-ons found. Try another search or check for updates."
loading = "Working…"
permissions = "Permissions"
read = "Read notes and search"
write = "Create and change notes"
edit = "Edit the open note"
permission-unknown = "Additional permission: {permission}"
license = "License: {license}"
source = "Source: {source}"
signature = "Signature key: {key}"
files = "Files"
bytes = "{size} bytes"
version = "Version {version} · {size}"
invalid-reply = "The registry returned an invalid catalogue."
file = "{path} · {size} bytes"
worker-stopped = "Registry worker stopped"
backend-unavailable = "Registry installation is not available until the command worker is connected."
just-now = "just now"
install-grammar = "Install {name} grammar ({size})"
suggestions-title = "Add-ons you may want"
suggestions-dismiss = "Not now"
suggestions-empty = "No suggestions for this vault yet."
wanted-title = "This vault uses these add-ons"
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "notescraft-srs"
description = "SM-2 spaced repetition scheduler for NotesCraft flashcards"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
publish = false
[lib]
path = "src/lib.rs"
[dependencies]
[lints]
workspace = true
+25
View File
@@ -0,0 +1,25 @@
//! Spaced repetition scheduling for `NotesCraft` flashcards.
//!
//! The crate is pure: no clock, no file, no environment. The caller passes
//! the current day (the number of days since 1970-01-01) and gets the next
//! scheduling state back.
//!
//! [`new_state`] builds the state of a card that was never reviewed,
//! [`review`] applies one grade and [`is_due`] tells whether a card is due.
//!
//! ```
//! use notescraft_srs::{Grade, is_due, new_state, review};
//!
//! let state = new_state(100);
//! assert!(!is_due(&state, 99));
//!
//! let state = review(state, Grade::Good, 100);
//! assert_eq!(state.interval_days, 1);
//! assert!(is_due(&state, 101));
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod sm2;
pub use sm2::{EASE_MAX, EASE_MIN, EASE_START, Grade, MAX_INTERVAL_DAYS, State, is_due, new_state, review};
+155
View File
@@ -0,0 +1,155 @@
//! The SM-2 scheduler: the public `SuperMemo` 2 algorithm.
//!
//! Every number of the algorithm is a named constant in thousandths, so the
//! arithmetic stays exact and no floating point enters the state.
/// The ease factor of a card that was never reviewed, in thousandths (2.5).
pub const EASE_START: u16 = 2500;
/// The lowest ease factor a card may reach, in thousandths (1.3).
pub const EASE_MIN: u16 = 1300;
/// The highest ease factor a card may reach, in thousandths (4.0).
pub const EASE_MAX: u16 = 4000;
/// The longest interval between two reviews, in days (one hundred years).
pub const MAX_INTERVAL_DAYS: u32 = 36_500;
/// The interval multiplier of a Hard answer, in thousandths (1.2).
pub const HARD_FACTOR_X1000: u64 = 1200;
/// The interval bonus of an Easy answer, in thousandths (1.3).
pub const EASY_BONUS_X1000: u64 = 1300;
/// The ease penalty of a lapsed card, in thousandths.
pub const AGAIN_EASE_PENALTY: u16 = 200;
/// The ease penalty of a Hard answer, in thousandths.
pub const HARD_EASE_PENALTY: u16 = 150;
/// The ease bonus of an Easy answer, in thousandths.
pub const EASY_EASE_BONUS: u16 = 150;
/// The interval of the first successful review of a card, in days.
pub const GOOD_FIRST_INTERVAL: u32 = 1;
/// The interval of the second successful review of a card, in days.
pub const GOOD_SECOND_INTERVAL: u32 = 6;
/// The interval of the first Easy review of a card, in days.
pub const EASY_FIRST_INTERVAL: u32 = 4;
/// The interval of the second Easy review of a card, in days.
pub const EASY_SECOND_INTERVAL: u32 = 10;
/// One scheduling step of a card, in thousandths of a day.
const MILLI: u64 = 1000;
/// The scheduling state of one card.
///
/// The state is the whole history the scheduler needs: the ease factor, the
/// current interval, how many times the card was answered, how many times it
/// lapsed and the day it comes back.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct State {
/// The ease factor, in thousandths (2.5 is 2500).
pub ease_x1000: u16,
/// The number of days between the last review and the next one.
pub interval_days: u32,
/// The number of successful reviews since the last lapse.
pub reps: u32,
/// The number of times the card was answered with `Again`.
pub lapses: u32,
/// The day the card comes back, in days since 1970-01-01.
pub due_day: u32,
}
/// The grade the user gives to a card.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Grade {
/// The card was not remembered: it lapses and comes back today.
Again,
/// The card was hard to recall: a small interval, a small ease penalty.
Hard,
/// The card was recalled: the usual interval.
Good,
/// The card was easy: a long interval and an ease bonus.
Easy,
}
/// Builds the state of a card that was never reviewed.
///
/// The card starts with the starting ease, no interval, no review and it is
/// due on `today`.
#[must_use]
pub fn new_state(today: u32) -> State {
State { ease_x1000: EASE_START, interval_days: 0, reps: 0, lapses: 0, due_day: today }
}
/// Applies one grade to a card and returns its next state.
///
/// `today` is the number of days since 1970-01-01. The new due day is
/// `today` plus the new interval; it saturates instead of overflowing.
#[must_use]
pub fn review(state: State, grade: Grade, today: u32) -> State {
let (reps, lapses, interval_days, ease_x1000) = match grade {
Grade::Again => (0, state.lapses.saturating_add(1), 0, ease_down(state.ease_x1000, AGAIN_EASE_PENALTY)),
Grade::Hard => (state.reps.saturating_add(1), state.lapses, grow_hard(state), ease_down(state.ease_x1000, HARD_EASE_PENALTY)),
Grade::Good => (state.reps.saturating_add(1), state.lapses, grow_good(state), state.ease_x1000),
Grade::Easy => (state.reps.saturating_add(1), state.lapses, grow_easy(state), ease_up(state.ease_x1000, EASY_EASE_BONUS)),
};
State { ease_x1000, interval_days, reps, lapses, due_day: today.saturating_add(interval_days) }
}
/// Tells whether a card is due on `today`.
#[must_use]
pub fn is_due(state: &State, today: u32) -> bool {
state.due_day <= today
}
/// The interval of a Hard answer: the current interval times 1.2, at least a day.
fn grow_hard(state: State) -> u32 {
let grown = scale(u64::from(state.interval_days).saturating_mul(HARD_FACTOR_X1000), MILLI);
clamp_interval(grown.max(1))
}
/// The interval of a Good answer: one day, then six, then the interval times the ease.
fn grow_good(state: State) -> u32 {
match state.reps {
0 => GOOD_FIRST_INTERVAL,
1 => GOOD_SECOND_INTERVAL,
_ => clamp_interval(scale(u64::from(state.interval_days).saturating_mul(u64::from(state.ease_x1000)), MILLI)),
}
}
/// The interval of an Easy answer: four days, then ten, then the interval times the ease and the bonus.
fn grow_easy(state: State) -> u32 {
match state.reps {
0 => EASY_FIRST_INTERVAL,
1 => EASY_SECOND_INTERVAL,
_ => {
let base = u64::from(state.interval_days).saturating_mul(u64::from(state.ease_x1000)).saturating_mul(EASY_BONUS_X1000);
clamp_interval(scale(base, MILLI.saturating_mul(MILLI)))
}
}
}
/// Lowers an ease factor by a penalty and keeps it inside its bounds.
fn ease_down(ease: u16, penalty: u16) -> u16 {
ease.saturating_sub(penalty).max(EASE_MIN)
}
/// Raises an ease factor by a bonus and keeps it inside its bounds.
fn ease_up(ease: u16, bonus: u16) -> u16 {
ease.saturating_add(bonus).min(EASE_MAX)
}
/// Divides two numbers and rounds the result to the nearest integer, halves up.
fn scale(num: u64, den: u64) -> u64 {
num.saturating_add(den / 2) / den
}
/// Turns a day count into the interval range of the algorithm.
fn clamp_interval(days: u64) -> u32 {
u32::try_from(days).unwrap_or(MAX_INTERVAL_DAYS).min(MAX_INTERVAL_DAYS)
}
+139
View File
@@ -0,0 +1,139 @@
//! Table tests of the SM-2 scheduler.
use notescraft_srs::{EASE_MAX, EASE_MIN, EASE_START, Grade, MAX_INTERVAL_DAYS, State, is_due, new_state, review};
const TODAY: u32 = 20_000;
#[test]
fn new_state_is_due_today_with_no_interval() {
let state = new_state(TODAY);
assert_eq!(state.ease_x1000, EASE_START);
assert_eq!(state.interval_days, 0);
assert_eq!(state.reps, 0);
assert_eq!(state.lapses, 0);
assert_eq!(state.due_day, TODAY);
}
#[test]
fn good_gives_one_day_then_six_then_ease_times_interval() {
let first = review(new_state(TODAY), Grade::Good, TODAY);
assert_eq!(first.interval_days, 1);
assert_eq!(first.reps, 1);
assert_eq!(first.ease_x1000, EASE_START);
let second = review(first, Grade::Good, TODAY);
assert_eq!(second.interval_days, 6);
assert_eq!(second.reps, 2);
let third = review(second, Grade::Good, TODAY);
assert_eq!(third.interval_days, 15);
assert_eq!(third.reps, 3);
}
#[test]
fn easy_gives_four_then_ten_then_ease_and_bonus() {
let first = review(new_state(TODAY), Grade::Easy, TODAY);
assert_eq!(first.interval_days, 4);
assert_eq!(first.ease_x1000, 2650);
let second = review(first, Grade::Easy, TODAY);
assert_eq!(second.interval_days, 10);
assert_eq!(second.ease_x1000, 2800);
let third = review(second, Grade::Easy, TODAY);
assert_eq!(third.interval_days, 36);
assert_eq!(third.ease_x1000, 2950);
}
#[test]
fn hard_grows_the_interval_and_lowers_the_ease() {
let first = review(new_state(TODAY), Grade::Hard, TODAY);
assert_eq!(first.interval_days, 1);
assert_eq!(first.ease_x1000, 2350);
let second = review(first, Grade::Hard, TODAY);
assert_eq!(second.interval_days, 1);
assert_eq!(second.ease_x1000, 2200);
}
#[test]
fn again_resets_the_card_and_lowers_the_ease() {
let mut state = new_state(TODAY);
for _ in 0..3 {
state = review(state, Grade::Good, TODAY);
}
assert_eq!(state.interval_days, 15);
let state = review(state, Grade::Again, TODAY);
assert_eq!(state.reps, 0);
assert_eq!(state.interval_days, 0);
assert_eq!(state.lapses, 1);
assert_eq!(state.ease_x1000, 2300);
assert_eq!(state.due_day, TODAY);
}
#[test]
fn ease_never_falls_below_the_floor() {
let mut state = new_state(TODAY);
for _ in 0..10 {
state = review(state, Grade::Again, TODAY);
}
assert_eq!(state.ease_x1000, EASE_MIN);
assert_eq!(state.lapses, 10);
}
#[test]
fn ease_never_rises_above_the_ceiling() {
let mut state = new_state(TODAY);
for _ in 0..40 {
state = review(state, Grade::Easy, TODAY);
assert!(state.ease_x1000 <= EASE_MAX);
}
assert_eq!(state.ease_x1000, EASE_MAX);
}
#[test]
fn the_interval_is_capped() {
let state = State { ease_x1000: EASE_MAX, interval_days: MAX_INTERVAL_DAYS, reps: 5, lapses: 0, due_day: TODAY };
let state = review(state, Grade::Good, TODAY);
assert_eq!(state.interval_days, MAX_INTERVAL_DAYS);
let state = review(state, Grade::Easy, TODAY);
assert_eq!(state.interval_days, MAX_INTERVAL_DAYS);
}
#[test]
fn a_day_at_the_end_of_the_range_does_not_overflow() {
let state = review(new_state(u32::MAX), Grade::Good, u32::MAX);
assert_eq!(state.due_day, u32::MAX);
assert!(is_due(&state, u32::MAX));
}
#[test]
fn is_due_holds_at_the_due_day() {
let state = review(new_state(TODAY), Grade::Good, TODAY);
assert_eq!(state.due_day, TODAY + 1);
assert!(!is_due(&state, TODAY));
assert!(is_due(&state, TODAY + 1));
assert!(is_due(&state, TODAY + 2));
}
#[test]
fn every_grade_over_a_grid_keeps_the_card_sane() {
let grid = [
new_state(TODAY),
State { ease_x1000: EASE_MIN, interval_days: 0, reps: 0, lapses: 3, due_day: TODAY },
State { ease_x1000: EASE_MAX, interval_days: MAX_INTERVAL_DAYS, reps: 9, lapses: 1, due_day: TODAY },
];
let grades = [Grade::Again, Grade::Hard, Grade::Good, Grade::Easy];
for state in grid {
for grade in grades {
let next = review(state, grade, TODAY);
assert!(next.due_day >= TODAY, "{grade:?} moved the card into the past");
assert!(next.ease_x1000 >= EASE_MIN);
assert!(next.ease_x1000 <= EASE_MAX);
assert!(next.interval_days <= MAX_INTERVAL_DAYS);
assert_eq!(next.lapses, state.lapses + u32::from(grade == Grade::Again));
}
}
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "notescraft-textdiff"
description = "word-level diff between two texts, with bounded work"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
publish = false
[lib]
path = "src/lib.rs"
[dependencies]
thiserror = { workspace = true }
[lints]
workspace = true
+202
View File
@@ -0,0 +1,202 @@
//! Word-level diff between two texts.
//!
//! The crate splits both texts into tokens (runs of word characters, runs of
//! whitespace and single other characters), finds the shortest edit script
//! between the token sequences and reports the result as a list of [`Span`]s:
//! text present in both texts ([`Op::Same`]), only in the new text
//! ([`Op::Add`]) or only in the old one ([`Op::Del`]).
//!
//! The work is bounded: [`Limits`] caps the bytes, the tokens and the cells
//! the diff may use. When a limit is hit the call returns [`Error::TooLarge`];
//! nothing is ever truncated silently.
//!
//! ```
//! use notescraft_textdiff::{Limits, Op, word_diff};
//!
//! let spans = word_diff("the quick brown fox", "the quick red fox", &Limits::default()).expect("a diff");
//! let adds = spans.iter().filter(|span| span.op == Op::Add).count();
//! let dels = spans.iter().filter(|span| span.op == Op::Del).count();
//! assert_eq!((adds, dels), (1, 1));
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod myers;
mod tokens;
use myers::Edit;
use tokens::Token;
/// What happened to a run of text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Op {
/// The text is in the old text and in the new text.
Same,
/// The text is only in the new text.
Add,
/// The text is only in the old text.
Del,
}
/// A run of text with one operation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Span {
/// What happened to the run.
pub op: Op,
/// The text of the run.
pub text: String,
}
/// The bounds of one diff run.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
/// The most bytes the two texts may have together.
pub max_bytes: usize,
/// The most tokens the two texts may have together.
pub max_tokens: usize,
/// The most cells the diff may compute.
pub max_cells: usize,
}
/// Default byte budget: 2 MiB for the two texts together.
pub const DEFAULT_MAX_BYTES: usize = 2 * 1024 * 1024;
/// Default token budget: 40 000 tokens for the two texts together.
pub const DEFAULT_MAX_TOKENS: usize = 40_000;
/// Default cell budget: 16 000 000 cells.
pub const DEFAULT_MAX_CELLS: usize = 16_000_000;
impl Default for Limits {
fn default() -> Self {
Self { max_bytes: DEFAULT_MAX_BYTES, max_tokens: DEFAULT_MAX_TOKENS, max_cells: DEFAULT_MAX_CELLS }
}
}
/// The errors of the diff.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The texts are bigger than the limits allow.
#[error("the texts are too large to diff within the given limits")]
TooLarge,
}
/// The result of a diff call.
pub type Result<T> = std::result::Result<T, Error>;
/// The diff of two texts, as a list of spans.
///
/// The spans keep the order of the texts: the text of every [`Op::Same`] and
/// [`Op::Del`] span, in order, is the old text and the text of every
/// [`Op::Same`] and [`Op::Add`] span, in order, is the new text.
///
/// # Errors
///
/// Returns [`Error::TooLarge`] when a limit of `limits` is exceeded.
pub fn word_diff(old: &str, new: &str, limits: &Limits) -> Result<Vec<Span>> {
let bytes = old.len().saturating_add(new.len());
if bytes > limits.max_bytes {
return Err(Error::TooLarge);
}
let old_tokens = tokens::tokenize(old);
let new_tokens = tokens::tokenize(new);
let count = old_tokens.len().saturating_add(new_tokens.len());
if count > limits.max_tokens {
return Err(Error::TooLarge);
}
let (prefix, suffix) = common_edges(&old_tokens, &new_tokens);
let old_mid = middle(&old_tokens, prefix, suffix);
let new_mid = middle(&new_tokens, prefix, suffix);
let edits = myers::diff(&old_mid, &new_mid, limits.max_cells)?;
let mut spans: Vec<Span> = Vec::new();
for token in old_tokens.iter().take(prefix) {
push_span(&mut spans, Op::Same, &token.text);
}
let mut old_iter = old_mid.iter();
let mut new_iter = new_mid.iter();
for edit in edits {
match edit {
Edit::Equal(len) => {
for (old_token, _) in old_iter.by_ref().zip(new_iter.by_ref()).take(len) {
push_span(&mut spans, Op::Same, &old_token.text);
}
}
Edit::Delete(len) => {
for token in old_iter.by_ref().take(len) {
push_span(&mut spans, Op::Del, &token.text);
}
}
Edit::Insert(len) => {
for token in new_iter.by_ref().take(len) {
push_span(&mut spans, Op::Add, &token.text);
}
}
}
}
for token in old_tokens.iter().skip(old_tokens.len() - suffix) {
push_span(&mut spans, Op::Same, &token.text);
}
Ok(spans)
}
/// The number of words added and removed by a diff.
///
/// Words are runs of word characters; whitespace and punctuation are not
/// counted.
#[must_use]
pub fn stats(spans: &[Span]) -> (usize, usize) {
let mut added = 0usize;
let mut removed = 0usize;
for span in spans {
let words = tokens::count_words(&span.text);
match span.op {
Op::Add => added += words,
Op::Del => removed += words,
Op::Same => {}
}
}
(added, removed)
}
/// The number of tokens both texts share at the start and at the end.
fn common_edges(old: &[Token], new: &[Token]) -> (usize, usize) {
let shortest = old.len().min(new.len());
let mut prefix = 0usize;
while prefix < shortest && old.get(prefix) == new.get(prefix) {
prefix += 1;
}
let mut suffix = 0usize;
while suffix < shortest - prefix {
let old_at = old.len() - 1 - suffix;
let new_at = new.len() - 1 - suffix;
if old.get(old_at) != new.get(new_at) {
break;
}
suffix += 1;
}
(prefix, suffix)
}
/// The tokens between the common prefix and the common suffix.
fn middle(tokens: &[Token], prefix: usize, suffix: usize) -> Vec<Token> {
tokens.iter().skip(prefix).take(tokens.len() - prefix - suffix).cloned().collect()
}
/// Appends a run of text to the spans, merging it with the last one when the
/// operation is the same.
fn push_span(spans: &mut Vec<Span>, op: Op, text: &str) {
if text.is_empty() {
return;
}
if let Some(last) = spans.last_mut()
&& last.op == op
{
last.text.push_str(text);
return;
}
spans.push(Span { op, text: text.to_string() });
}
+191
View File
@@ -0,0 +1,191 @@
//! Myers' shortest edit script over two token sequences.
//!
//! The script is built in two passes: the forward pass walks the diagonals of
//! the edit graph and keeps the furthest point reached on each diagonal; the
//! back pass walks the kept points backwards to recover the script. The work
//! of both passes is counted in cells and the diff stops with
//! [`Error::TooLarge`] when the budget is spent.
use crate::tokens::Token;
use crate::{Error, Result};
/// One run of the edit script.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Edit {
/// The next tokens are equal in both texts.
Equal(usize),
/// The next tokens of the old text are gone.
Delete(usize),
/// The next tokens of the new text are new.
Insert(usize),
}
/// One step of the script while it is being recovered.
#[derive(Debug, Clone, Copy)]
enum Step {
Equal,
Delete,
Insert,
}
/// The points the forward pass kept for the back pass.
struct Forward {
/// One row per level, holding the diagonals of the level.
rows: Vec<Vec<i32>>,
/// The last level the forward pass reached.
last_d: usize,
}
/// The diff of two token sequences, as the runs of its edit script.
pub(crate) fn diff(old: &[Token], new: &[Token], max_cells: usize) -> Result<Vec<Edit>> {
let forward = forward_pass(old, new, max_cells)?;
let steps = back_pass(old, new, &forward)?;
Ok(compress(steps))
}
/// Walks the diagonals of the edit graph and keeps the points of every level.
fn forward_pass(old: &[Token], new: &[Token], max_cells: usize) -> Result<Forward> {
let old_len = old.len();
let new_len = new.len();
let old_last = i32::try_from(old_len).map_err(|_| Error::TooLarge)?;
let new_last = i32::try_from(new_len).map_err(|_| Error::TooLarge)?;
let offset = old_last + new_last;
let mut points = vec![0i32; 2 * (old_len + new_len) + 1];
let mut rows: Vec<Vec<i32>> = Vec::new();
let mut cells = 0usize;
let mut last_d = None;
for level in 0..=old_len + new_len {
let level_i = i32::try_from(level).map_err(|_| Error::TooLarge)?;
if level >= 1 {
let len = 2 * level - 1;
let start = usize::try_from(offset - (level_i - 1)).map_err(|_| Error::TooLarge)?;
let row: Vec<i32> = points.iter().skip(start).take(len).copied().collect();
cells += len;
if cells > max_cells {
return Err(Error::TooLarge);
}
rows.push(row);
}
let mut diag = -level_i;
while diag <= level_i {
let down = diag == -level_i || (diag != level_i && diagonal(&points, offset, diag - 1) < diagonal(&points, offset, diag + 1));
let mut pos_x = if down { diagonal(&points, offset, diag + 1) } else { diagonal(&points, offset, diag - 1) + 1 };
let mut pos_y = pos_x - diag;
let mut steps = 0usize;
while same(old, new, pos_x, pos_y) {
pos_x += 1;
pos_y += 1;
steps += 1;
}
cells += 1 + steps;
if cells > max_cells {
return Err(Error::TooLarge);
}
let index = usize::try_from(diag + offset).map_err(|_| Error::TooLarge)?;
if let Some(slot) = points.get_mut(index) {
*slot = pos_x;
}
if pos_x >= old_last && pos_y >= new_last {
last_d = Some(level);
break;
}
diag += 2;
}
if last_d.is_some() {
break;
}
}
let last_d = last_d.ok_or(Error::TooLarge)?;
Ok(Forward { rows, last_d })
}
/// Walks the kept points backwards to recover the steps of the script.
fn back_pass(old: &[Token], new: &[Token], forward: &Forward) -> Result<Vec<Step>> {
let old_last = i32::try_from(old.len()).map_err(|_| Error::TooLarge)?;
let new_last = i32::try_from(new.len()).map_err(|_| Error::TooLarge)?;
let mut steps: Vec<Step> = Vec::new();
let mut pos_x = old_last;
let mut pos_y = new_last;
for (level, row) in (1..=forward.last_d).rev().zip(forward.rows.iter().rev()) {
let level_i = i32::try_from(level).map_err(|_| Error::TooLarge)?;
let base = -(level_i - 1);
let diag = pos_x - pos_y;
let down = diag == -level_i || (diag != level_i && row_diagonal(row, base, diag - 1) < row_diagonal(row, base, diag + 1));
let prev_diag = if down { diag + 1 } else { diag - 1 };
let prev_x = row_diagonal(row, base, prev_diag);
let prev_y = prev_x - prev_diag;
while pos_x > prev_x && pos_y > prev_y {
steps.push(Step::Equal);
pos_x -= 1;
pos_y -= 1;
}
if pos_x == prev_x {
steps.push(Step::Insert);
pos_y -= 1;
} else {
steps.push(Step::Delete);
pos_x -= 1;
}
}
while pos_x > 0 && pos_y > -1 {
steps.push(Step::Equal);
pos_x -= 1;
pos_y -= 1;
}
steps.reverse();
Ok(steps)
}
/// Joins the steps of the script into runs.
fn compress(steps: Vec<Step>) -> Vec<Edit> {
let mut edits: Vec<Edit> = Vec::new();
for step in steps {
match step {
Step::Equal => merge(&mut edits, Edit::Equal(1)),
Step::Delete => merge(&mut edits, Edit::Delete(1)),
Step::Insert => merge(&mut edits, Edit::Insert(1)),
}
}
edits
}
/// Adds a run to the script, joining it with the last one when they match.
fn merge(edits: &mut Vec<Edit>, edit: Edit) {
match (edits.last_mut(), edit) {
(Some(Edit::Equal(count)), Edit::Equal(more)) | (Some(Edit::Delete(count)), Edit::Delete(more)) | (Some(Edit::Insert(count)), Edit::Insert(more)) => {
*count += more;
}
_ => edits.push(edit),
}
}
/// Whether the tokens at the given positions are equal.
fn same(old: &[Token], new: &[Token], pos_x: i32, pos_y: i32) -> bool {
let (Ok(old_at), Ok(new_at)) = (usize::try_from(pos_x), usize::try_from(pos_y)) else {
return false;
};
match (old.get(old_at), new.get(new_at)) {
(Some(left), Some(right)) => left == right,
_ => false,
}
}
/// The furthest point reached on a diagonal of the live graph.
fn diagonal(points: &[i32], offset: i32, diag: i32) -> i32 {
match usize::try_from(diag + offset) {
Ok(at) => points.get(at).copied().unwrap_or_default(),
Err(_) => 0,
}
}
/// The furthest point reached on a diagonal of a kept row.
fn row_diagonal(row: &[i32], base: i32, diag: i32) -> i32 {
match usize::try_from(diag - base) {
Ok(at) => row.get(at).copied().unwrap_or_default(),
Err(_) => 0,
}
}
+101
View File
@@ -0,0 +1,101 @@
//! Splitting a text into the tokens the diff works on.
/// What a token is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenKind {
/// A run of word characters: letters, digits or `_`.
Word,
/// A run of whitespace.
Space,
/// A single other character.
Other,
}
/// One token of a text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Token {
/// The kind of the token.
pub kind: TokenKind,
/// The text of the token.
pub text: String,
}
/// Splits a text into word runs, whitespace runs and single other characters.
pub fn tokenize(text: &str) -> Vec<Token> {
let mut tokens: Vec<Token> = Vec::new();
let mut chars = text.chars().peekable();
while let Some(first) = chars.next() {
if is_word_char(first) {
let mut run = String::new();
run.push(first);
while let Some(&next) = chars.peek()
&& is_word_char(next)
{
run.push(next);
chars.next();
}
tokens.push(Token { kind: TokenKind::Word, text: run });
} else if first.is_whitespace() {
let mut run = String::new();
run.push(first);
while let Some(&next) = chars.peek()
&& next.is_whitespace()
{
run.push(next);
chars.next();
}
tokens.push(Token { kind: TokenKind::Space, text: run });
} else {
tokens.push(Token { kind: TokenKind::Other, text: first.to_string() });
}
}
tokens
}
/// The number of word tokens in a text.
pub fn count_words(text: &str) -> usize {
tokenize(text).iter().filter(|token| token.kind == TokenKind::Word).count()
}
/// Whether a character belongs to a word.
fn is_word_char(ch: char) -> bool {
ch.is_alphanumeric() || ch == '_'
}
#[cfg(test)]
mod tests {
use super::{TokenKind, count_words, tokenize};
#[test]
fn splits_words_spaces_and_single_characters() {
let tokens = tokenize("hi there!");
assert_eq!(tokens.len(), 4);
assert_eq!(tokens.first().map(|token| token.text.as_str()), Some("hi"));
assert_eq!(tokens.first().map(|token| token.kind), Some(TokenKind::Word));
assert_eq!(tokens.get(1).map(|token| token.kind), Some(TokenKind::Space));
assert_eq!(tokens.get(2).map(|token| token.text.as_str()), Some("there"));
assert_eq!(tokens.get(3).map(|token| token.text.as_str()), Some("!"));
assert_eq!(tokens.get(3).map(|token| token.kind), Some(TokenKind::Other));
}
#[test]
fn keeps_underscores_and_digits_in_words() {
let tokens = tokenize("a_1 2b");
assert_eq!(tokens.first().map(|token| token.text.as_str()), Some("a_1"));
assert_eq!(tokens.get(2).map(|token| token.text.as_str()), Some("2b"));
}
#[test]
fn treats_a_combining_mark_as_its_own_character() {
let tokens = tokenize("e\u{0301}");
assert_eq!(tokens.len(), 2);
assert_eq!(tokens.get(1).map(|token| token.kind), Some(TokenKind::Other));
}
#[test]
fn counts_only_words() {
assert_eq!(count_words("one two, three!"), 3);
assert_eq!(count_words(" "), 0);
assert_eq!(count_words(""), 0);
}
}
+195
View File
@@ -0,0 +1,195 @@
//! Word-level diff of two texts.
use notescraft_textdiff::{Error, Limits, Op, Span, stats, word_diff};
/// A small linear congruential generator, so the cases are the same on every
/// run.
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Self(seed)
}
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
self.0 >> 33
}
fn below(&mut self, limit: usize) -> usize {
let limit = u64::try_from(limit).unwrap_or(1);
usize::try_from(self.next() % limit).unwrap_or(0)
}
}
const WORDS: [&str; 8] = ["alpha", "beta", "gamma", "delta", "eps", "zeta", "eta", "theta"];
fn random_text(rng: &mut Lcg) -> String {
let count = rng.below(12);
let mut out = String::new();
for index in 0..count {
if index > 0 {
out.push(' ');
}
out.push_str(WORDS.get(rng.below(WORDS.len())).copied().unwrap_or("word"));
if rng.below(6) == 0 {
out.push(',');
}
}
out
}
/// The old text of a diff: the same spans and the deletions, in order.
fn old_text(spans: &[Span]) -> String {
let mut out = String::new();
for span in spans {
match span.op {
Op::Same | Op::Del => out.push_str(&span.text),
Op::Add => {}
}
}
out
}
/// The new text of a diff: the same spans and the additions, in order.
fn new_text(spans: &[Span]) -> String {
let mut out = String::new();
for span in spans {
match span.op {
Op::Same | Op::Add => out.push_str(&span.text),
Op::Del => {}
}
}
out
}
fn count(spans: &[Span], op: Op) -> usize {
spans.iter().filter(|span| span.op == op).count()
}
#[test]
fn reproduces_both_texts_for_a_thousand_pairs() {
let mut rng = Lcg::new(0x5EED_1234);
for _ in 0..1000 {
let old = random_text(&mut rng);
let new = if rng.below(2) == 0 { random_text(&mut rng) } else { old.clone() };
let spans = word_diff(&old, &new, &Limits::default()).expect("a diff");
assert_eq!(old_text(&spans), old, "the old text must be reproduced");
assert_eq!(new_text(&spans), new, "the new text must be reproduced");
}
}
#[test]
fn one_changed_word_gives_one_deletion_and_one_addition() {
let old = "the quick brown fox jumps over the lazy dog and keeps running until the end of the paragraph";
let new = old.replace("brown", "red");
let spans = word_diff(old, &new, &Limits::default()).expect("a diff");
assert_eq!(count(&spans, Op::Del), 1);
assert_eq!(count(&spans, Op::Add), 1);
assert_eq!(old_text(&spans), old);
assert_eq!(new_text(&spans), new);
assert_eq!(stats(&spans), (1, 1));
}
#[test]
fn inserts_at_the_start_and_at_the_end() {
let old = "middle";
let new = "start middle end";
let spans = word_diff(old, new, &Limits::default()).expect("a diff");
assert_eq!(old_text(&spans), old);
assert_eq!(new_text(&spans), new);
assert_eq!(count(&spans, Op::Add), 2);
assert_eq!(count(&spans, Op::Same), 1);
assert_eq!(stats(&spans), (2, 0));
}
#[test]
fn an_empty_old_text_is_all_additions() {
let spans = word_diff("", "hello world", &Limits::default()).expect("a diff");
assert_eq!(count(&spans, Op::Add), 1);
assert_eq!(count(&spans, Op::Del), 0);
assert_eq!(new_text(&spans), "hello world");
assert_eq!(old_text(&spans), "");
}
#[test]
fn an_empty_new_text_is_all_deletions() {
let spans = word_diff("hello world", "", &Limits::default()).expect("a diff");
assert_eq!(count(&spans, Op::Del), 1);
assert_eq!(count(&spans, Op::Add), 0);
assert_eq!(old_text(&spans), "hello world");
assert_eq!(new_text(&spans), "");
}
#[test]
fn two_empty_texts_give_no_spans() {
let spans = word_diff("", "", &Limits::default()).expect("a diff");
assert_eq!(spans, Vec::<Span>::new());
assert_eq!(stats(&spans), (0, 0));
}
#[test]
fn carriage_returns_stay_in_the_whitespace() {
let old = "first\r\nsecond\r\n";
let new = "first\r\nthird\r\n";
let spans = word_diff(old, new, &Limits::default()).expect("a diff");
assert_eq!(old_text(&spans), old);
assert_eq!(new_text(&spans), new);
assert_eq!(count(&spans, Op::Del), 1);
assert_eq!(count(&spans, Op::Add), 1);
}
#[test]
fn emoji_and_combining_marks_are_never_lost() {
let old = "cafe\u{0301} \u{1F600} e\u{0301}";
let new = "caf\u{e9} \u{1F600} \u{e8}";
let spans = word_diff(old, new, &Limits::default()).expect("a diff");
assert_eq!(old_text(&spans), old);
assert_eq!(new_text(&spans), new);
}
#[test]
fn too_many_bytes_is_refused() {
let limits = Limits { max_bytes: 8, ..Limits::default() };
let result = word_diff("a longer old text", "a longer new text", &limits);
assert!(matches!(result, Err(Error::TooLarge)));
}
#[test]
fn too_many_tokens_is_refused() {
let limits = Limits { max_tokens: 4, ..Limits::default() };
let result = word_diff("one two three four five", "one two three four five", &limits);
assert!(matches!(result, Err(Error::TooLarge)));
}
#[test]
fn too_many_cells_is_refused() {
let limits = Limits { max_cells: 0, ..Limits::default() };
let result = word_diff("one two", "three four", &limits);
assert!(matches!(result, Err(Error::TooLarge)));
}
#[test]
fn the_default_limits_accept_a_normal_note() {
let note = "a note with a few hundred words, repeated for a while to look like a real note in a vault";
let spans = word_diff(note, note, &Limits::default()).expect("a diff");
assert_eq!(count(&spans, Op::Same), 1);
assert_eq!(count(&spans, Op::Add), 0);
assert_eq!(count(&spans, Op::Del), 0);
}
#[test]
#[ignore = "slow: two texts of 2 MiB"]
fn two_big_texts_are_fast_or_too_large() {
let big = "word ".repeat(400_000);
let start = std::time::Instant::now();
let result = word_diff(big.as_str(), big.as_str(), &Limits::default());
let elapsed = start.elapsed();
match result {
Ok(spans) => {
assert_eq!(count(&spans, Op::Same), 1);
assert!(elapsed.as_millis() < 500, "the diff took {elapsed:?}");
}
Err(Error::TooLarge) => {}
}
}
+3
View File
@@ -117,6 +117,9 @@ fn main() {
if args.iter().any(|a| a == "--settings") {
app.open_note(notescraft_ui_egui::tabs::SETTINGS_PATH);
}
if args.iter().any(|arg| arg == "--registry") {
app.open_registry();
}
// `--right-tab related` opens that right-card tab; `--related-json <file>` shows a canned `knowledge.related` result.
if let Some(tab) = arg(&args, "--right-tab") {
use notescraft_ui_egui::right_view::RightTab;
+40 -47
View File
@@ -1,6 +1,9 @@
//! Fonts: `Inter` for the UI, `JetBrains Mono` for code and `STIX Two Math` for math,
//! shipped in `assets/fonts` like the other Craft apps (SIL OFL 1.1, see `ATTRIBUTION.md`).
//!
//! The `Inter` and `JetBrains Mono` programs come from the engine, which embeds the same
//! bytes for the PDF export: the binary carries each font once.
//!
//! Every other script (Indic, Thai, CJK, ...) is loaded lazily from the system fonts: [`ensure_scripts`] scans
//! text and appends the font of each script it finds, once. egui does no OpenType shaping, so conjuncts and
//! reordering marks (Bengali, Devanagari, Tamil, Thai, Khmer, ...) draw as unjoined letters.
@@ -14,34 +17,29 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use egui::{FontData, FontDefinitions, FontFamily};
use notescraft_engine::{INTER_REGULAR, INTER_SEMIBOLD, JETBRAINS_MONO};
const INTER_REGULAR: &[u8] = include_bytes!("../../../../../assets/fonts/Inter-Regular.ttf");
const INTER_MEDIUM: &[u8] = include_bytes!("../../../../../assets/fonts/Inter-Medium.ttf");
const INTER_SEMIBOLD: &[u8] = include_bytes!("../../../../../assets/fonts/Inter-SemiBold.ttf");
const JETBRAINS_MONO: &[u8] = include_bytes!("../../../../../assets/fonts/JetBrainsMono-NoLigatures.ttf");
const STIX_MATH: &[u8] = include_bytes!("../../../../../assets/fonts/STIXTwoMath-Regular.ttf");
const NOTO_EMOJI: &[u8] = include_bytes!("../../../../../assets/fonts/NotoEmoji-Regular.ttf");
const NOTO_EMOJI_NAME: &str = "noto-emoji";
/// Fonts the system ships with Hebrew and Arabic letters, read when present: the bundled fonts have none, and a
/// line of right-to-left text would otherwise show empty boxes.
const SYSTEM_FALLBACKS: [&str; 7] = [
"/System/Library/Fonts/SFHebrew.ttf",
"/System/Library/Fonts/SFArabic.ttf",
"C:\\Windows\\Fonts\\segoeui.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf",
"/usr/share/fonts/noto/NotoSansHebrew-Regular.ttf",
];
/// Largest system font read, in bytes.
const MAX_FALLBACK_BYTES: u64 = 24 * 1024 * 1024;
/// The regular `Inter` bytes the UI installs, for the shared-copy test.
#[cfg(test)]
fn inter_regular() -> &'static [u8] {
INTER_REGULAR
}
/// The bytes of the system fallback fonts that exist here.
fn system_fallbacks() -> Vec<Vec<u8>> {
SYSTEM_FALLBACKS
.iter()
.filter(|path| std::fs::metadata(path).is_ok_and(|meta| meta.len() <= MAX_FALLBACK_BYTES))
.filter_map(|path| std::fs::read(path).ok())
.collect()
/// The semibold `Inter` bytes the UI installs, for the shared-copy test.
#[cfg(test)]
fn inter_semibold() -> &'static [u8] {
INTER_SEMIBOLD
}
/// The `JetBrains Mono` bytes the UI installs, for the shared-copy test.
#[cfg(test)]
fn jetbrains_mono() -> &'static [u8] {
JETBRAINS_MONO
}
/// Named family for medium-weight text.
@@ -70,32 +68,27 @@ pub fn semibold() -> FontFamily {
}
/// Installs `Inter` as the proportional font, `JetBrains Mono` as the monospace font and the two
/// named weight families. egui's bundled fonts stay behind them as glyph fallbacks.
/// named weight families. Noto Emoji and STIX follow them as glyph fallbacks; egui's own default fonts are not
/// compiled in (`default_fonts` is off), so the binary carries each face once.
pub fn install(ctx: &egui::Context) {
let mut fonts = FontDefinitions::default();
for (name, bytes) in [("inter", INTER_REGULAR), (MEDIUM, INTER_MEDIUM), (SEMIBOLD, INTER_SEMIBOLD), ("jetbrains-mono", JETBRAINS_MONO), (MATH, STIX_MATH)] {
let mut fonts = FontDefinitions::empty();
for (name, bytes) in [
("inter", INTER_REGULAR),
(MEDIUM, INTER_MEDIUM),
(SEMIBOLD, INTER_SEMIBOLD),
("jetbrains-mono", JETBRAINS_MONO),
(MATH, STIX_MATH),
(NOTO_EMOJI_NAME, NOTO_EMOJI),
] {
fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes)));
}
let proportional = fonts.families.entry(FontFamily::Proportional).or_default();
proportional.insert(0, "inter".to_owned());
let mut fallback_names = Vec::new();
for (index, bytes) in system_fallbacks().into_iter().enumerate() {
let name = format!("system-fallback-{index}");
fonts.font_data.insert(name.clone(), Arc::new(FontData::from_owned(bytes)));
if let Some(chain) = fonts.families.get_mut(&FontFamily::Proportional) {
chain.push(name.clone());
}
fallback_names.push(name);
}
// Hebrew, Arabic and the other scripts load on demand through `note_text`; nothing is read from disk here.
// STIX Two Math is the last resort of every text family, so symbols, arrows and math operators never show boxes.
if let Some(chain) = fonts.families.get_mut(&FontFamily::Proportional) {
chain.push(MATH.to_owned());
}
let proportional = fonts.families.entry(FontFamily::Proportional).or_default();
proportional.extend(["inter", NOTO_EMOJI_NAME, MATH].map(str::to_owned));
let monospace = fonts.families.entry(FontFamily::Monospace).or_default();
monospace.insert(0, "jetbrains-mono".to_owned());
// Hebrew and Arabic inside code (strings, comments) must not show boxes either.
monospace.extend(fallback_names);
monospace.push(MATH.to_owned());
// Inter backs the monospace chain for the few symbols JetBrains Mono lacks (the removed egui fonts did the same).
monospace.extend(["jetbrains-mono", "inter", NOTO_EMOJI_NAME, MATH].map(str::to_owned));
for (family, face) in [(MEDIUM, MEDIUM), (SEMIBOLD, SEMIBOLD)] {
let mut chain = vec![face.to_owned(), "inter".to_owned()];
if let Some(base) = fonts.families.get(&FontFamily::Proportional) {
@@ -141,7 +134,7 @@ fn covers(script: &scripts::Script, code: u32) -> bool {
fn detect(text: &str) -> u64 {
let mut found = 0_u64;
let mut bytes = 0_usize;
let mut last = 0_usize;
let mut last: Option<usize> = None;
for c in text.chars().take(MAX_SCAN_CHARS) {
bytes += c.len_utf8();
if bytes > MAX_SCAN_BYTES {
@@ -149,12 +142,12 @@ fn detect(text: &str) -> u64 {
}
let code = u32::from(c);
// Runs of one script are the common case: test the previous hit first.
if code < scripts::FIRST_SCRIPT_CHAR || scripts::SCRIPTS.get(last).is_some_and(|script| covers(script, code)) {
if code < scripts::FIRST_SCRIPT_CHAR || last.and_then(|at| scripts::SCRIPTS.get(at)).is_some_and(|script| covers(script, code)) {
continue;
}
if let Some(index) = scripts::SCRIPTS.iter().position(|script| covers(script, code)) {
found |= 1 << index;
last = index;
last = Some(index);
}
}
found
@@ -45,6 +45,8 @@ macro_rules! face {
/// Every lazily loaded script; the position is the bit in the loaded set (at most 64 entries).
#[rustfmt::skip]
pub(super) const SCRIPTS: &[Script] = &[
Script { name: "hebrew", ranges: &[(0x0590, 0x05FF), (0xFB1D, 0xFB4F)], files: &[face!("/System/Library/Fonts/", "SFHebrew.ttf"), face!("C:\\Windows\\Fonts\\", "segoeui.ttf")], noto: &[("NotoSansHebrew-Regular.ttf", 0)] },
Script { name: "arabic", ranges: &[(0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF), (0xFB50, 0xFDFF), (0xFE70, 0xFEFF)], files: &[face!("/System/Library/Fonts/", "SFArabic.ttf"), face!("C:\\Windows\\Fonts\\", "segoeui.ttf")], noto: &[("NotoSansArabic-Regular.ttf", 0)] },
Script { name: "armenian", ranges: &[(0x0530, 0x058F), (0xFB13, 0xFB17)], files: &[face!("/System/Library/Fonts/", "SFArmenian.ttf"), face!("/System/Library/Fonts/Supplemental/", "Mshtakan.ttc"), face!("C:\\Windows\\Fonts\\", "segoeui.ttf")], noto: &[("NotoSansArmenian-Regular.ttf", 0)] },
Script { name: "georgian", ranges: &[(0x10A0, 0x10FF), (0x1C90, 0x1CBF), (0x2D00, 0x2D2F)], files: &[face!("/System/Library/Fonts/", "SFGeorgian.ttf"), face!("C:\\Windows\\Fonts\\", "segoeui.ttf")], noto: &[("NotoSansGeorgian-Regular.ttf", 0)] },
Script { name: "devanagari", ranges: &[(0x0900, 0x097F), (0xA8E0, 0xA8FF)], files: &[face!("/System/Library/Fonts/Supplemental/", "Devanagari Sangam MN.ttc"), face!("/System/Library/Fonts/Supplemental/", "DevanagariMT.ttc"), face!("C:\\Windows\\Fonts\\", "Nirmala.ttf")], noto: &[("NotoSansDevanagari-Regular.ttf", 0)] },
+49 -1
View File
@@ -19,12 +19,13 @@ fn detects_each_script() {
assert_eq!(detect("ひらがな"), bit("kana"));
assert_eq!(detect("한국어"), bit("hangul"));
assert_eq!(detect("\u{20000}"), bit("han"));
assert_eq!(detect("עברית"), bit("hebrew"));
assert_eq!(detect("العربية"), bit("arabic"));
}
#[test]
fn bundled_and_startup_scripts_need_nothing() {
assert_eq!(detect("Hello Привет Γειά"), 0);
assert_eq!(detect("עברית العربية"), 0);
assert_eq!(detect("😀 ✓"), 0);
assert_eq!(detect(""), 0);
}
@@ -143,6 +144,22 @@ fn symbols_never_show_boxes_in_any_text_family() {
}
}
#[test]
fn the_ui_and_the_exporter_share_one_inter_copy() {
assert!(
std::ptr::eq(notescraft_engine::INTER_REGULAR.as_ptr(), inter_regular().as_ptr()),
"the UI must install the engine's Inter copy, not embed its own"
);
assert!(
std::ptr::eq(notescraft_engine::INTER_SEMIBOLD.as_ptr(), inter_semibold().as_ptr()),
"the UI must install the engine's Inter copy, not embed its own"
);
assert!(
std::ptr::eq(notescraft_engine::JETBRAINS_MONO.as_ptr(), jetbrains_mono().as_ptr()),
"the UI must install the engine's JetBrains Mono copy, not embed its own"
);
}
#[test]
fn math_stays_last_when_a_script_font_is_added() {
let ctx = egui::Context::default();
@@ -158,3 +175,34 @@ fn math_stays_last_when_a_script_font_is_added() {
assert!(chain.iter().any(|n| n == "script-bengali"));
}
}
#[test]
fn install_reads_no_system_font_and_hebrew_arabic_load_on_demand() {
let ctx = installed();
let before = font_count(&ctx);
let reads = std::cell::Cell::new(0_u32);
let read = |_: &str| {
reads.set(reads.get() + 1);
Some(vec![0_u8; 4])
};
assert!(!add_scripts(&ctx, detect("Hello"), |_| Some(10), read));
assert_eq!(reads.get(), 0, "plain text reads nothing");
assert!(add_scripts(&ctx, detect("עברית العربية"), |_| Some(10), read));
assert_eq!(reads.get(), 2);
assert_eq!(font_count(&ctx), before + 2);
}
#[test]
// Astral colour emoji (U+1F600) have no glyph here or in egui's default fonts; the symbol range still resolves.
fn emoji_and_symbols_resolve_without_the_egui_default_fonts() {
let ctx = installed();
ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
for family in [FontFamily::Proportional, FontFamily::Monospace] {
let missing: Vec<String> = ['\u{2764}', '\u{2605}', '\u{2192}', '\u{2713}']
.into_iter()
.filter(|ch| !resolves(&ctx, family.clone(), *ch))
.map(|ch| format!("U+{:04X}", u32::from(ch)))
.collect();
assert!(missing.is_empty(), "{family:?} lacks {missing:?}");
}
}
+4
View File
@@ -56,6 +56,7 @@ const REMINDER_PERIOD: f64 = 30.0;
/// UI-string constants (they move to the i18n crate later).
pub use large_folder::LargeFolderGate;
pub use vault::explorer::focus_filter_id as explorer_focus_filter_id;
pub use vault::registry::RegistryError;
pub use vault_switch::VaultRequest;
pub use window::menubar::{EditAction, Menu, MenuEntry, MenuItem, MenuRole, MenuTarget, Predefined, menu_model};
@@ -169,6 +170,8 @@ pub struct NotesApp {
pub(crate) batch_review: Option<vault::batch_review::BatchReview>,
/// The "Add a grammar" dialog, while it is open.
pub(crate) grammar_dialog: Option<vault::grammar_dialog::GrammarDialog>,
/// Registry command queue and its owned worker dispatcher.
pub(crate) registry_jobs: vault::registry::RegistryJobs,
recent_vaults: Vec<std::path::PathBuf>,
watch_updates: Option<Box<WatchUpdates>>,
/// When the reminders are next checked (egui time); none until the start-up check ran.
@@ -304,6 +307,7 @@ impl NotesApp {
link_hover: None,
batch_review: None,
grammar_dialog: None,
registry_jobs: vault::registry::RegistryJobs::default(),
recent_vaults: Vec::new(),
watch_updates,
reminder_next: None,
@@ -0,0 +1,133 @@
//! First-run add-on suggestions, rendered from cached data supplied by the shell.
#[path = "wanted_banner.rs"]
mod wanted_banner;
use crate::shell::app::NotesApp;
use crate::state::UiState;
use serde_json::json;
use std::collections::BTreeMap;
/// Cached alias lookups, discarded whenever the catalogue changes.
#[derive(Clone, Default)]
struct Offers {
catalogue: String,
labels: BTreeMap<String, Option<Entry>>,
}
impl NotesApp {
/// Pumps cache-only grammar suggestions and explicit install clicks after painting.
pub(in crate::shell::app) fn pump_registry_offers(&mut self, ctx: &egui::Context) {
let vault = self.session.vault().root().to_path_buf();
let opened_id = egui::Id::new(("registry-offers-opened", &vault));
if !ctx.data(|data| data.get_temp::<bool>(opened_id)).unwrap_or(false) {
if let Some(reply) = self.engine_command("registry.list", &json!({"limit": 512})) {
self.preferences.view.registry.apply_reply(&reply);
}
ctx.data_mut(|data| data.insert_temp(opened_id, true));
}
let cache_id = egui::Id::new(("registry-offers", &vault));
let mut offers = ctx.data_mut(|data| data.remove_temp::<Offers>(cache_id)).unwrap_or_default();
let catalogue = serde_json::to_string(&self.preferences.view.registry.rows).unwrap_or_default();
if offers.catalogue != catalogue {
for label in offers.labels.keys() {
crate::note_view::clear_registry_offer(ctx, label);
}
offers.labels.clear();
offers.catalogue = catalogue;
}
for label in crate::note_view::take_registry_labels(ctx) {
if !offers.labels.contains_key(&label) {
let entry = self
.engine_command("registry.suggest", &json!({"label": label}))
.and_then(|reply| reply.get("entries")?.as_array()?.first().cloned())
.and_then(|entry| serde_json::from_value::<Entry>(entry).ok())
.filter(|entry| entry.state == "available");
if entry.is_some() {
ctx.request_repaint();
}
offers.labels.insert(label.clone(), entry);
}
if let Some(Some(entry)) = offers.labels.get(&label) {
crate::note_view::set_registry_offer(ctx, &label, entry.id.clone(), entry.title.clone(), size_text(entry.size));
}
}
if let Some(request) = crate::note_view::take_registry_install_request(ctx) {
self.state.pending_registry_install = Some(request);
}
if let Some((kind, id)) = self.state.pending_registry_install.take() {
self.preferences.view.registry_actions.push(Action::Install(kind, id));
}
let mut entries: Vec<Entry> =
self.preferences.view.registry.rows.iter().filter(|entry| entry.suggested && entry.state == "available").cloned().collect();
for entry in offers.labels.values().flatten() {
if !entries.iter().any(|known| known.kind == entry.kind && known.id == entry.id) {
entries.push(entry.clone());
}
}
ctx.data_mut(|data| data.insert_temp(cache_id, offers));
self.state.registry_update_badge =
if self.preferences.view.registry.last_checked.is_some() { u32::try_from(self.preferences.view.registry.updates()).unwrap_or(u32::MAX) } else { 0 };
let dismissed = self.state.registry_suggestions_dismissed;
if !entries.is_empty() && !dismissed {
egui::Area::new(egui::Id::new("registry-first-run")).anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-24.0, -24.0)).show(ctx, |ui| {
self.preferences.view.registry_actions.extend(show(ui, &self.tokens, &mut self.state, &entries));
});
}
if self.state.registry_suggestions_dismissed != dismissed {
self.persist_ui();
}
wanted_banner::show(self, ctx);
}
}
/// Formats a download size with the existing translated byte label.
fn size_text(bytes: u64) -> String {
crate::text::registry::bytes().replace("{size}", &bytes.to_string())
}
use crate::views::registry_view::{Action, Entry};
/// Paints the one-time suggestion card and returns explicit install requests.
pub(crate) fn show(ui: &mut egui::Ui, tokens: &crate::theme::Tokens, state: &mut UiState, entries: &[Entry]) -> Vec<Action> {
if state.registry_suggestions_dismissed {
return Vec::new();
}
let mut actions = Vec::new();
let colors = &tokens.theme.colors;
egui::Frame::new().fill(crate::theme::color(colors.surface)).corner_radius(tokens.radius_medium()).inner_margin(egui::Margin::same(16)).show(ui, |ui| {
ui.heading(crate::text::registry::suggestions_title());
for entry in entries.iter().filter(|entry| entry.state == "available") {
ui.horizontal(|ui| {
ui.label(&entry.title);
if crate::views::registry_view::button(ui, tokens, ("first-run-install", &entry.kind, &entry.id), crate::text::registry::install(), true, true)
{
actions.push(Action::Install(entry.kind.clone(), entry.id.clone()));
}
});
}
if entries.is_empty() {
ui.label(crate::text::registry::suggestions_empty());
}
if crate::views::registry_view::button(ui, tokens, "first-run-dismiss", crate::text::registry::suggestions_dismiss(), false, true) {
state.registry_suggestions_dismissed = true;
}
});
actions
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dismissed_card_stays_hidden_and_suggestions_do_not_fetch() {
let Some(theme) = notescraft_themes::builtin("catppuccin-mocha") else { return };
let mut state = UiState::new();
state.registry_suggestions_dismissed = true;
let mut actions = Vec::new();
let tokens = crate::theme::Tokens::new(theme);
crate::testsupport::run_frames_ui(800.0, 600.0, |ui| actions = show(ui, &tokens, &mut state, &[]));
assert_eq!(actions, Vec::<Action>::new());
}
}
@@ -0,0 +1,70 @@
//! Cached vault add-on requirements; this banner never fetches or installs automatically.
use crate::shell::app::NotesApp;
use crate::views::registry_view::{Action, Entry};
/// Shows available add-ons already recorded in the vault's wanted list.
pub(super) fn show(app: &mut NotesApp, ctx: &egui::Context) {
let rows = &app.preferences.view.registry.rows;
let generation = serde_json::to_string(rows).unwrap_or_default();
let id = egui::Id::new(("registry-wanted-banner", app.session.vault().root(), generation));
let entries = ctx.data(|data| data.get_temp::<Vec<Entry>>(id)).unwrap_or_else(|| {
let wanted = app
.session
.vault()
.read(".notescraft/registry.json")
.ok()
.and_then(|bytes| notescraft_engine::addons::VaultFile::parse(&bytes).ok())
.map(|file| file.wanted)
.unwrap_or_default();
let entries = wanted_entries(rows, &wanted);
ctx.data_mut(|data| data.insert_temp(id, entries.clone()));
entries
});
if entries.is_empty() {
return;
}
egui::Area::new(egui::Id::new("registry-wanted")).anchor(egui::Align2::LEFT_BOTTOM, egui::vec2(24.0, -24.0)).show(ctx, |ui| {
egui::Frame::new()
.fill(crate::theme::color(app.tokens.theme.colors.surface))
.corner_radius(app.tokens.radius_medium())
.inner_margin(egui::Margin::same(16))
.show(ui, |ui| {
ui.heading(crate::text::registry::wanted_title());
for entry in &entries {
ui.horizontal(|ui| {
ui.label(format!("{} · {}", entry.title, super::size_text(entry.size)));
if crate::views::registry_view::button(
ui,
&app.tokens,
("wanted-install", &entry.kind, &entry.id),
crate::text::registry::install(),
true,
!app.preferences.view.registry.busy,
) {
app.preferences.view.registry_actions.push(Action::Install(entry.kind.clone(), entry.id.clone()));
}
});
}
});
});
}
fn wanted_entries(rows: &[Entry], wanted: &[String]) -> Vec<Entry> {
rows.iter().filter(|entry| entry.state == "available" && wanted.iter().any(|key| key == &format!("{}:{}", entry.kind, entry.id))).cloned().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wanted_banner_excludes_installed_and_unknown_entries() {
let available = Entry { kind: "grammar".into(), id: "twig".into(), state: "available".into(), ..Entry::default() };
let installed = Entry { kind: "grammar".into(), id: "blade".into(), state: "installed".into(), ..Entry::default() };
let wanted = vec!["grammar:twig".into(), "grammar:blade".into(), "grammar:unknown".into()];
let entries = wanted_entries(&[available, installed], &wanted);
assert_eq!(entries.len(), 1);
assert_eq!(entries.first().map(|entry| entry.id.as_str()), Some("twig"));
}
}
@@ -3,6 +3,7 @@
pub(super) mod batch_review;
pub(super) mod explorer;
pub(super) mod file_commands;
pub(super) mod first_run;
pub(super) mod grammar_dialog;
pub(super) mod hibernation;
pub(super) mod history;
@@ -11,6 +12,7 @@ pub(super) mod large_folder;
pub(super) mod lifecycle;
pub(super) mod link_preview;
pub(super) mod preferences;
pub(super) mod registry;
pub(super) mod switcher;
pub(super) mod vault_switch;
pub(super) mod workspaces;
@@ -0,0 +1,123 @@
//! End to end: stub index and files -> Registry view -> Check -> Install -> the fence loses its banner.
use super::*;
use egui_kittest::kittest::Queryable;
use notescraft_engine::addons::{FakeTransport, FakeVerifier, INDEX_URL, Response};
use notescraft_syntax::validate::sha256_hex;
const ID: &str = "twigstub";
const FILE: &str = "twigstub.sublime-syntax";
const BASE: &str = "https://git.marcospaulo.dev.br/masi/notescraft-registry/raw/branch/main/files";
const GRAMMAR: &str = "%YAML 1.2\n---\nname: TwigStub\nfile_extensions: [twigstub]\nscope: source.twigstub\ncontexts:\n main:\n - match: '\\bfoo\\b'\n scope: keyword.control.twigstub\n";
fn stub() -> FakeTransport {
let url = format!("{BASE}/grammar/{ID}/1.0.0/{FILE}");
let entry = json!({
"id": ID, "kind": "grammar", "title": "TwigStub", "summary": "s", "version": "1.0.0", "min_app_version": "0.1.0", "license": "MIT",
"aliases": [ID], "permissions": [], "signature": { "key_id": "k1", "sig": "QUJD" },
"files": [{ "path": FILE, "size": GRAMMAR.len(), "sha256": sha256_hex(GRAMMAR.as_bytes()), "url": url }],
});
let index = json!({ "version": 2, "generated_at": "2026-10-21T00:00:00Z", "entries": [entry], "revoked": [] }).to_string().into_bytes();
FakeTransport::new().with_response(INDEX_URL, Ok(Response { status: 200, etag: None, bytes: index })).with(&url, GRAMMAR.as_bytes())
}
/// A worker backend over the stub; `trusted` keeps the build's own (empty) key list.
fn backend(app: &crate::app::NotesApp, trusted: bool) -> impl FnMut(&str, &Value) -> Result<Value, RegistryError> + Send + 'static {
let (vault, dir) = (app.session.vault().sibling(), app.session.picked_settings_dir());
move |id, params| {
let mut commands = notescraft_engine::Registry::new();
notescraft_engine::register_core(&mut commands).map_err(|e| RegistryError { code: e.code().into(), message: e.to_string() })?;
let mut session = notescraft_engine::Session::registry_worker(vault.sibling(), dir.clone());
session.set_addons_transport(Box::new(stub()));
if !trusted {
session.set_addons_verifier(Box::new(FakeVerifier { accept: true }));
}
commands.run(id, &mut session, params).map_err(|error| match error {
notescraft_engine::Error::Command { source, .. } => RegistryError { code: source.code, message: source.message },
other => RegistryError { code: other.code().into(), message: other.to_string() },
})
}
}
fn app(name: &str) -> crate::app::NotesApp {
let root = crate::testsupport::temp_vault(name);
crate::testsupport::write_note(&root, "t", &format!("```{ID}\nfoo\n```\n"));
let mut session = notescraft_engine::Session::open(&root).unwrap();
session.use_settings_dir(root.join(".notescraft/test-config"));
let theme = notescraft_themes::builtin("catppuccin-mocha").unwrap();
let mut app = crate::app::NotesApp::new(session, theme);
app.state.animations = false;
app
}
/// Steps until the registry worker is idle again (bounded), then a few frames more.
fn settle(harness: &mut egui_kittest::Harness<'_, crate::app::NotesApp>) {
for _ in 0..1000 {
harness.step();
std::thread::sleep(std::time::Duration::from_millis(5));
if !harness.state().preferences.view.registry.busy {
break;
}
}
for _ in 0..10 {
harness.step();
}
}
fn harness(app: crate::app::NotesApp) -> egui_kittest::Harness<'static, crate::app::NotesApp> {
egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 1000.0)).build_ui_state(|ui, app: &mut crate::app::NotesApp| app.ui(ui.ctx()), app)
}
#[test]
fn check_then_install_a_grammar_removes_the_fence_banner() {
let mut app = app("registry-e2e");
let stub_backend = backend(&app, false);
app.set_registry_backend(stub_backend);
app.open_note("t.md");
let mut harness = harness(app);
settle(&mut harness);
let add = crate::text::note_view::add_grammar();
assert!(harness.query_by_label(add).is_some(), "an unknown fence shows the banner");
harness.state_mut().open_registry();
settle(&mut harness);
harness.get_by_label(crate::text::registry::check()).click();
settle(&mut harness);
harness.get_by_label(crate::text::registry::install()).click();
settle(&mut harness);
harness.state_mut().open_note("t.md");
// The highlighter is process-global and other tests' apps reload it as they start, so reload
// from the store and look again instead of trusting one frame.
let gone = (0..50).any(|_| {
harness.state().session.load_grammars();
harness.step();
notescraft_syntax::load::is_known(ID) && harness.query_by_label(add).is_none() && harness.query_by_label_contains("TwigStub").is_none()
});
assert!(gone, "the installed grammar reached the highlighter and the banner is gone");
}
#[test]
fn no_trusted_key_shows_the_addon_error() {
let mut app = app("registry-e2e-key");
let stub_backend = backend(&app, true);
app.set_registry_backend(stub_backend);
app.open_registry();
let mut harness = harness(app);
settle(&mut harness);
harness.get_by_label(crate::text::registry::check()).click();
settle(&mut harness);
harness.get_by_label(crate::text::registry::install()).click();
settle(&mut harness);
assert!(harness.query_by_label_contains("no trusted registry key").is_some());
assert!(!notescraft_syntax::load::is_known(ID));
}
#[test]
fn default_backend_lists_the_cache_without_network_or_error() {
let mut app = app("registry-e2e-default");
app.open_registry();
let mut harness = harness(app);
settle(&mut harness);
let view = &harness.state().preferences.view.registry;
assert!(view.error.is_none() && !view.offline && !view.busy);
assert!(view.rows.is_empty());
}
@@ -0,0 +1,253 @@
//! Serial registry jobs: the shell owns the backend and painting only queues actions.
use std::collections::VecDeque;
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use serde_json::{Value, json};
use crate::views::registry_view::Action;
/// Delay between worker result polls.
const POLL: std::time::Duration = std::time::Duration::from_millis(50);
/// A registry refusal, preserving its command error code for the offline banner.
#[derive(Debug, Clone)]
pub struct RegistryError {
/// Engine command error code.
pub code: String,
/// Engine diagnostic.
pub message: String,
}
/// Owned command dispatcher moved to a worker; it must never borrow the live UI session.
pub type Backend = Box<dyn FnMut(&str, &Value) -> Result<Value, RegistryError> + Send>;
type Reply = (Backend, &'static str, Result<Value, RegistryError>);
/// Serial worker queue for the Registry Settings category.
#[derive(Default)]
pub struct RegistryJobs {
backend: Option<Backend>,
receiver: Option<Receiver<Reply>>,
queue: VecDeque<(&'static str, Value)>,
consumers_changed: bool,
/// A backend supplied by the host (tests, embedders); the shell never replaces it.
explicit: bool,
/// Vault the default backend was built for.
root: Option<std::path::PathBuf>,
/// Whether the opt-in automatic check already ran for this backend.
auto_checked: bool,
}
impl RegistryJobs {
/// Installs the owned command backend before any work starts.
pub fn set_backend(&mut self, backend: Backend) {
self.backend = Some(backend);
}
/// Whether a backend has been supplied or is currently running.
#[must_use]
pub fn has_backend(&self) -> bool {
self.backend.is_some() || self.receiver.is_some()
}
/// Reads the cached index only. Opening never queues `registry.check`.
pub fn open(&mut self) {
self.queue.push_back(("registry.list", json!({})));
}
/// Queues exactly one command for a user action; preferences stay in the shell.
pub fn request(&mut self, action: Action) {
if let Some(call) = command(action) {
self.queue.push_back(call);
}
}
/// Polls without waiting and starts the next command on an owned worker thread.
pub fn pump(&mut self, view: &mut crate::views::registry_view::RegistryView, ctx: &egui::Context) {
if let Some(receiver) = &self.receiver {
match receiver.try_recv() {
Ok((backend, id, reply)) => {
ctx.request_repaint();
self.receiver = None;
self.backend = Some(backend);
match reply {
Ok(value) => {
view.error = None;
if matches!(id, "registry.install" | "registry.update" | "registry.remove" | "registry.rollback") {
self.consumers_changed = true;
}
if id == "registry.info" {
view.apply_info(&value);
} else if id == "registry.list" {
view.apply_reply(&value);
} else {
if id == "registry.check" {
view.last_checked = Some(crate::text::registry::just_now().to_owned());
view.offline = false;
}
self.open();
}
}
Err(error) => view.set_error(&error.code, &error.message),
}
}
Err(TryRecvError::Disconnected) => {
ctx.request_repaint();
self.receiver = None;
self.queue.clear();
view.set_error("needs_dependency", crate::text::registry::worker_stopped());
}
Err(TryRecvError::Empty) => {}
}
}
if self.receiver.is_none()
&& !self.queue.is_empty()
&& let Some(mut backend) = self.backend.take()
&& let Some((id, params)) = self.queue.pop_front()
{
let (sender, receiver) = channel();
match std::thread::Builder::new().name("notescraft-registry".into()).spawn(move || {
let reply = backend(id, &params);
let _ = sender.send((backend, id, reply));
}) {
Ok(_) => self.receiver = Some(receiver),
Err(error) => {
self.queue.clear();
view.set_error("needs_dependency", &error.to_string());
}
}
}
view.busy = self.receiver.is_some() || !self.queue.is_empty();
if view.busy {
ctx.request_repaint_after(POLL);
}
}
}
impl crate::app::NotesApp {
/// Connects an owned engine command dispatcher to the registry worker.
/// The dispatcher runs serially off the UI thread and must preserve engine write gates.
/// The host must reconnect the dispatcher when switching vaults.
pub fn set_registry_backend(&mut self, backend: impl FnMut(&str, &Value) -> Result<Value, RegistryError> + Send + 'static) {
self.registry_jobs.set_backend(Box::new(backend));
self.registry_jobs.explicit = true;
self.preferences.view.registry_open = false;
}
/// Opens Settings directly on the cache-only Registry category.
pub fn open_registry(&mut self) {
self.open_note(crate::tabs::SETTINGS_PATH);
self.preferences.view.selected = crate::settings_view::SettingsCategory::Registry;
}
/// Registers the localized Registry navigation command for the palette.
pub(in crate::shell::app) fn register_registry_command(&mut self) {
let spec = notescraft_engine::CommandSpec::new(
"ui.open_registry",
crate::text::registry::title(),
crate::text::registry::description(),
|_: &crate::state::UiState| true,
crate::shell::app::save_stub,
);
if let Err(error) = self.ui_registry.register(spec) {
self.toast = Some(error.to_string());
}
}
/// Handles the Registry navigation command before engine dispatch.
pub(in crate::shell::app) fn run_registry_command(&mut self, id: &str) -> bool {
if id != "ui.open_registry" {
return false;
}
self.open_registry();
true
}
/// Builds the default worker backend for this vault unless the host supplied one.
/// The worker owns a vault sibling and a fresh engine session; the UI session never crosses threads.
fn ensure_registry_backend(&mut self) {
let root = self.session.vault().root().to_path_buf();
let jobs = &mut self.registry_jobs;
if jobs.explicit || jobs.receiver.is_some() || (jobs.has_backend() && jobs.root.as_ref() == Some(&root)) {
return;
}
jobs.set_backend(engine_backend(self.session.vault().sibling(), self.session.picked_settings_dir()));
jobs.root = Some(root);
jobs.auto_checked = false;
self.preferences.view.registry_open = false;
}
/// Hands off registry actions after painting and polls without waiting.
pub(in crate::shell::app) fn pump_registry(&mut self, ctx: &egui::Context) {
self.ensure_registry_backend();
self.preferences.view.registry.auto_check = self.state.registry_auto_check;
let active =
self.tabs.active_path() == Some(crate::tabs::SETTINGS_PATH) && self.preferences.view.selected == crate::settings_view::SettingsCategory::Registry;
if !active {
self.preferences.view.registry_open = false;
} else if !self.preferences.view.registry_open {
self.preferences.view.registry_open = true;
self.registry_jobs.open();
}
if self.state.registry_auto_check && !self.registry_jobs.auto_checked && self.registry_jobs.has_backend() {
self.registry_jobs.auto_checked = true;
self.registry_jobs.request(Action::Check);
}
for action in std::mem::take(&mut self.preferences.view.registry_actions) {
if let Action::AutoCheck(enabled) = action {
self.state.registry_auto_check = enabled;
self.preferences.view.registry.auto_check = enabled;
self.persist_ui();
} else {
self.registry_jobs.request(action);
}
}
let previous_error = self.preferences.view.registry.error.clone();
self.registry_jobs.pump(&mut self.preferences.view.registry, ctx);
if !active && self.preferences.view.registry.error != previous_error {
self.toast.clone_from(&self.preferences.view.registry.error);
}
if std::mem::take(&mut self.registry_jobs.consumers_changed) {
self.session.load_grammars();
ctx.request_repaint();
}
self.state.registry_update_badge = u32::try_from(self.preferences.view.registry.updates()).unwrap_or(u32::MAX);
}
}
/// The default backend: each command runs on a fresh worker session over a vault sibling, so it
/// shares the app's registry store and never takes a second vault lock.
fn engine_backend(vault: notescraft_vault::Vault, settings_dir: Option<std::path::PathBuf>) -> Backend {
Box::new(move |id, params| {
let refuse = |code: &str, message: String| RegistryError { code: code.to_owned(), message };
let mut commands = notescraft_engine::Registry::new();
notescraft_engine::register_core(&mut commands).map_err(|error| refuse(error.code(), error.to_string()))?;
let mut session = notescraft_engine::Session::registry_worker(vault.sibling(), settings_dir.clone());
commands.run(id, &mut session, params).map_err(|error| match error {
notescraft_engine::Error::Command { source, .. } => RegistryError { code: source.code, message: source.message },
other => refuse(other.code(), other.to_string()),
})
})
}
fn command(action: Action) -> Option<(&'static str, Value)> {
let (command, kind, id) = match action {
Action::Check => return Some(("registry.check", json!({}))),
Action::Enable(id) => return Some(("registry.enable", json!({ "id": id }))),
Action::Disable(id) => return Some(("registry.disable", json!({ "id": id }))),
Action::AutoCheck(_) => return None,
Action::Install(kind, id) => ("registry.install", kind, id),
Action::Update(kind, id) => ("registry.update", kind, id),
Action::Remove(kind, id) => ("registry.remove", kind, id),
Action::Rollback(kind, id) => ("registry.rollback", kind, id),
Action::Info(kind, id) => ("registry.info", kind, id),
};
Some((command, json!({ "kind": kind, "id": id })))
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod e2e;
@@ -0,0 +1,165 @@
//! Registry worker and Settings interaction tests.
use super::*;
use std::sync::{Arc, Mutex};
fn finish(jobs: &mut RegistryJobs, view: &mut crate::views::registry_view::RegistryView) {
let ctx = egui::Context::default();
for _ in 0..100 {
jobs.pump(view, &ctx);
if !view.busy {
return;
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert!(!view.busy, "registry worker did not finish");
}
#[test]
fn opening_reads_cache_without_network_and_install_runs_once() {
let calls = Arc::new(Mutex::new(Vec::new()));
let recorded = calls.clone();
let mut jobs = RegistryJobs::default();
jobs.set_backend(Box::new(move |id, params| {
recorded.lock().unwrap().push((id.to_owned(), params.clone()));
Ok(json!({"entries": []}))
}));
let mut view = crate::views::registry_view::RegistryView::default();
jobs.open();
finish(&mut jobs, &mut view);
assert_eq!(*calls.lock().unwrap(), vec![("registry.list".into(), json!({}))]);
jobs.request(Action::Install("grammar".into(), "twig".into()));
finish(&mut jobs, &mut view);
let calls = calls.lock().unwrap();
assert_eq!(calls.iter().filter(|(id, _)| id == "registry.install").count(), 1);
assert!(!calls.iter().any(|(id, _)| id == "registry.check"));
assert!(calls.iter().any(|(id, params)| id == "registry.install" && params == &json!({"kind":"grammar","id":"twig"})));
}
#[test]
fn an_install_click_runs_one_worker_command() {
use egui_kittest::kittest::Queryable;
let calls = Arc::new(Mutex::new(Vec::new()));
let recorded = calls.clone();
let mut jobs = RegistryJobs::default();
jobs.set_backend(Box::new(move |id, _| {
recorded.lock().unwrap().push(id.to_owned());
Ok(json!({"entries": [{"id":"twig", "kind":"grammar", "title":"Twig", "state":"available"}]}))
}));
let tokens = crate::theme::Tokens::new(notescraft_themes::builtin("catppuccin-mocha").unwrap());
let view = crate::views::registry_view::RegistryView::default();
jobs.open();
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state(
move |ui, (jobs, view): &mut (RegistryJobs, crate::views::registry_view::RegistryView)| {
crate::fonts::install(ui.ctx());
crate::style::apply(ui.ctx(), &tokens);
crate::motion::set_enabled(ui.ctx(), false);
for action in view.show(ui, &tokens) {
jobs.request(action);
}
jobs.pump(view, ui.ctx());
},
(jobs, view),
);
for _ in 0..20 {
harness.step();
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert_eq!(*calls.lock().unwrap(), vec!["registry.list"]);
harness.get_by_label(crate::text::registry::install()).click();
for _ in 0..20 {
harness.step();
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert_eq!(calls.lock().unwrap().iter().filter(|id| id.as_str() == "registry.install").count(), 1);
}
#[test]
fn opening_settings_registry_reads_only_cache_then_click_installs_once() {
use egui_kittest::kittest::Queryable;
let root = crate::testsupport::temp_vault("registry-category");
let mut session = notescraft_engine::Session::open(&root).unwrap();
session.use_settings_dir(root.join(".notescraft/test-config"));
let theme = notescraft_themes::builtin("catppuccin-mocha").unwrap();
let mut app = crate::app::NotesApp::new(session, theme);
app.state.animations = false;
let calls = Arc::new(Mutex::new(Vec::new()));
let recorded = calls.clone();
app.set_registry_backend(move |id, _| {
recorded.lock().unwrap().push(id.to_owned());
Ok(json!({"entries": [{"id":"twig", "kind":"grammar", "title":"Twig", "state":"available"}]}))
});
app.run_command("ui.open_registry", &json!({}));
assert_eq!(app.preferences.view.selected, crate::settings_view::SettingsCategory::Registry);
app.preferences.view.selected = crate::settings_view::SettingsCategory::Appearance;
let mut harness =
egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 1000.0)).build_ui_state(|ui, app: &mut crate::app::NotesApp| app.ui(ui.ctx()), app);
for _ in 0..20 {
harness.step();
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert!(calls.lock().unwrap().is_empty());
harness.get_by_label(crate::text::registry::title()).click();
for _ in 0..20 {
harness.step();
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert_eq!(*calls.lock().unwrap(), vec!["registry.list"]);
harness.get_by_label(crate::text::registry::install()).click();
for _ in 0..20 {
harness.step();
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert_eq!(calls.lock().unwrap().iter().filter(|id| id.as_str() == "registry.install").count(), 1);
assert!(!calls.lock().unwrap().iter().any(|id| id == "registry.check"));
}
#[test]
fn offline_check_keeps_cached_rows() {
let mut jobs = RegistryJobs::default();
jobs.set_backend(Box::new(|_, _| Err(RegistryError { code: "offline".into(), message: "offline fixture".into() })));
let mut view = crate::views::registry_view::RegistryView::default();
view.apply_reply(&json!({"entries":[{"id":"twig", "kind":"grammar"}]}));
jobs.request(Action::Check);
finish(&mut jobs, &mut view);
assert!(view.offline);
assert!(view.last_checked.is_none());
assert_eq!(view.rows.len(), 1);
assert_eq!(view.rows.first().map(|row| row.id.as_str()), Some("twig"));
}
#[test]
fn command_mapping_preserves_kind_and_id() {
for (action, id) in [
(Action::Update("theme".into(), "frappe".into()), "registry.update"),
(Action::Remove("theme".into(), "frappe".into()), "registry.remove"),
(Action::Rollback("theme".into(), "frappe".into()), "registry.rollback"),
(Action::Info("theme".into(), "frappe".into()), "registry.info"),
] {
assert_eq!(command(action), Some((id, json!({"kind":"theme","id":"frappe"}))));
}
assert!(command(Action::AutoCheck(true)).is_none());
}
#[test]
fn successful_mutation_marks_consumers_for_refresh_but_failure_preserves_diagnostic() {
let mut jobs = RegistryJobs::default();
jobs.set_backend(Box::new(|id, _| {
if id == "registry.install" {
return Ok(json!({"kind":"grammar", "id":"twig"}));
}
Ok(json!({"entries": []}))
}));
let mut view = crate::views::registry_view::RegistryView::default();
jobs.request(Action::Install("grammar".into(), "twig".into()));
finish(&mut jobs, &mut view);
assert!(std::mem::take(&mut jobs.consumers_changed));
jobs.set_backend(Box::new(|_, _| {
Err(RegistryError { code: "addon".into(), message: "this build has no trusted registry key, so add-ons cannot be installed yet".into() })
}));
jobs.request(Action::Install("grammar".into(), "twig".into()));
finish(&mut jobs, &mut view);
assert!(!jobs.consumers_changed);
assert!(view.error.as_deref().is_some_and(|error| error.contains("no trusted registry key")));
assert!(!view.busy);
}
@@ -76,6 +76,7 @@ impl NotesApp {
self.toast = Some(error.to_string());
}
self.register_hotkey_commands();
self.register_registry_command();
}
pub(crate) fn start_pdf_export(&mut self, params: Value) {
@@ -165,7 +166,7 @@ impl NotesApp {
/// Dispatches a registered command and applies its result to the live shell.
/// Errors become the app toast; settings use the engine's validated atomic store.
pub fn run_command(&mut self, id: &str, params: &Value) {
if self.run_vault_command(id) || self.run_hotkey_command(id, params) {
if self.run_vault_command(id) || self.run_hotkey_command(id, params) || self.run_registry_command(id) {
return;
}
match id {
@@ -273,6 +273,8 @@ impl NotesApp {
let embed_edit = response.embed_edit.take();
self.handle_center(response, now, ctx);
self.pump_bases(ctx);
self.pump_registry_offers(ctx);
self.pump_registry(ctx);
// The rooms talk to the editor: the local edits go out, the remote ones come in.
self.push_live_edits(ctx);
self.poll_live(ctx);
+14
View File
@@ -92,6 +92,16 @@ pub struct UiState {
pub volume: f32,
/// Whether animations run (off: every transition jumps).
pub animations: bool,
/// Whether the first-run add-on suggestions card was dismissed.
pub registry_suggestions_dismissed: bool,
/// Opt-in: check the registry index once per start (off by default).
pub registry_auto_check: bool,
/// Number of available add-on updates, populated only by a successful check.
#[serde(skip)]
pub registry_update_badge: u32,
/// Grammar add-on selected from a code card, awaiting an explicit install action.
#[serde(skip)]
pub pending_registry_install: Option<(String, String)>,
/// Per-note property card expansion, bounded to 200 notes.
#[serde(deserialize_with = "restore_properties_expanded")]
pub properties_expanded: std::collections::BTreeMap<String, bool>,
@@ -197,6 +207,10 @@ impl UiState {
sound: true,
volume: DEFAULT_VOLUME,
animations: true,
registry_suggestions_dismissed: false,
registry_auto_check: false,
registry_update_badge: 0,
pending_registry_install: None,
properties_expanded: std::collections::BTreeMap::new(),
pinned_folders: Vec::new(),
pinned_tabs: Vec::new(),
+3 -2
View File
@@ -19,6 +19,7 @@ pub mod lightbox;
pub mod maps;
pub mod media_view;
pub mod note_view;
pub mod registry;
pub mod slash_popup;
pub mod whiteboard_view_arrange;
pub mod whiteboard_view_layers_panel;
mod whiteboard;
pub use whiteboard::{whiteboard_view_arrange, whiteboard_view_layers_panel};
+55
View File
@@ -0,0 +1,55 @@
//! Copy for the offline add-on registry browser.
table! {
just_now = "ui.registry.just-now";
worker_stopped = "ui.registry.worker-stopped";
backend_unavailable = "ui.registry.backend-unavailable";
title = "ui.registry.title";
description = "ui.registry.description";
search = "ui.registry.search";
check = "ui.registry.check";
never = "ui.registry.never";
last_checked = "ui.registry.last-checked";
auto_check = "ui.registry.auto-check";
all = "ui.registry.all";
plugins = "ui.registry.plugins";
themes = "ui.registry.themes";
packs = "ui.registry.packs";
grammars = "ui.registry.grammars";
fonts = "ui.registry.fonts";
snippets = "ui.registry.snippets";
install = "ui.registry.install";
installed = "ui.registry.installed";
update = "ui.registry.update";
updates = "ui.registry.updates";
incompatible = "ui.registry.incompatible";
revoked = "ui.registry.revoked";
unsupported = "ui.registry.unsupported";
details = "ui.registry.details";
close = "ui.registry.close";
remove = "ui.registry.remove";
rollback = "ui.registry.rollback";
enable = "ui.registry.enable";
disable = "ui.registry.disable";
offline = "ui.registry.offline";
empty = "ui.registry.empty";
loading = "ui.registry.loading";
permissions = "ui.registry.permissions";
read = "ui.registry.read";
write = "ui.registry.write";
edit = "ui.registry.edit";
permission_unknown = "ui.registry.permission-unknown";
license = "ui.registry.license";
source = "ui.registry.source";
signature = "ui.registry.signature";
file = "ui.registry.file";
files = "ui.registry.files";
bytes = "ui.registry.bytes";
version = "ui.registry.version";
invalid_reply = "ui.registry.invalid-reply";
install_grammar = "ui.registry.install-grammar";
wanted_title = "ui.registry.wanted-title";
suggestions_title = "ui.registry.suggestions-title";
suggestions_dismiss = "ui.registry.suggestions-dismiss";
suggestions_empty = "ui.registry.suggestions-empty";
}
@@ -0,0 +1,4 @@
//! Copy for whiteboard arranging and layer controls.
pub mod whiteboard_view_arrange;
pub mod whiteboard_view_layers_panel;
+1
View File
@@ -19,6 +19,7 @@ pub mod math_view;
pub mod media_view;
pub mod note_view;
pub mod properties_view;
pub mod registry_view;
pub mod settings_view;
pub mod share_dialog;
pub mod trash_view;
+1
View File
@@ -35,6 +35,7 @@ pub use blocks::rich::image_targets;
use blocks::rich::text_line::render_text_line;
pub use blocks::{EmbedNotes, dismissed_offers, request_grammar, set_dismissed_offers, set_embeds, take_grammar_request};
use blocks::{callout_card, code_block, display_math, properties_card, table};
pub(crate) use blocks::{clear_registry_offer, set_registry_offer, take_registry_install_request, take_registry_labels};
pub use edit::NoteEdit;
pub use header::{banner, title_icon};
pub use inlines::tag_target;
@@ -28,6 +28,7 @@ pub(super) use base_embed::{opens_base, show_inline as show_base_block};
pub(super) use code_actions::take_edit as code_card_edit;
pub use code_actions::{dismissed_offers, request_grammar, set_dismissed_offers, take_grammar_request};
pub(super) use code_card::take_click as code_card_clicked;
pub(crate) use code_card::{clear_registry_offer, set_registry_offer, take_registry_install_request, take_registry_labels};
pub use embed::set_embeds;
pub(super) use embed::{Call, details, show as show_embed};
pub use embed_data::EmbedNotes;
@@ -5,6 +5,11 @@
//! repaint wake-up brings the colours in. A block over [`VIRTUAL_FROM`] lines draws only the rows
//! near the viewport (fixed pitch, no wrapping) and asks the cache for just that window.
mod registry;
pub(crate) use registry::{clear_registry_offer, set_registry_offer, take_registry_install_request, take_registry_labels};
use registry::{registry_install_id, registry_offer, request_registry_label};
use std::ops::Range;
use egui::{Align, Color32, FontId, Id, Layout, Margin, RichText, Ui, vec2};
@@ -199,6 +204,7 @@ fn header(ui: &mut Ui, tokens: &Tokens, card: &Card<'_>) -> bool {
let shown = if card.label.is_empty() { strings::code_plain() } else { card.label }.to_uppercase();
let json_labelled = card.label.eq_ignore_ascii_case("json");
let suggestion = suggestion(ui.ctx(), card.label);
let registry_offer = registry_offer(ui.ctx(), card.label);
let unknown = !card.label.is_empty() && !json_labelled && !notescraft_syntax::load::is_known(card.label);
egui::Frame::new().inner_margin(Margin { left: 16, right: 8, top: HEADER_PAD_TOP, bottom: 0 }).show(ui, |ui| {
ui.set_min_width(ui.available_width());
@@ -220,8 +226,16 @@ fn header(ui: &mut Ui, tokens: &Tokens, card: &Card<'_>) -> bool {
{
post(ui.ctx(), edit);
}
if unknown && action(ui, tokens, strings::add_grammar(), color(colors.text_muted), None) {
request_grammar(ui.ctx(), card.label);
if unknown {
request_registry_label(ui.ctx(), card.label);
if let Some((id, title, size)) = registry_offer {
let text = crate::text::registry::install_grammar().replace("{name}", &title).replace("{size}", &size);
if action(ui, tokens, &text, color(colors.accent), None) {
ui.ctx().data_mut(|data| data.insert_temp(registry_install_id(), ("grammar".to_owned(), id)));
}
} else if action(ui, tokens, strings::add_grammar(), color(colors.text_muted), None) {
request_grammar(ui.ctx(), card.label);
}
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let copied_at = ui.data(|data| data.get_temp::<f64>(card.id.with("copied")));
@@ -373,24 +387,5 @@ fn row_spans(text: &str, tokens: Option<&[TokenSpan]>, palette: &[Color32; 8], p
}
#[cfg(test)]
mod tests {
use notescraft_syntax::TokenKind;
use super::*;
#[test]
fn row_spans_cover_the_text_in_order_and_survive_bad_ranges() {
let palette = [Color32::RED; 8];
let plain = Color32::WHITE;
let tokens = [TokenSpan { start: 0, end: 2, kind: TokenKind::Keyword }, TokenSpan { start: 3, end: 7, kind: TokenKind::Function }];
let spans = row_spans("fn main()", Some(&tokens), &palette, plain);
let joined: String = spans.iter().map(|s| if let Span::CodeTok(t, _) = s { t.as_str() } else { "" }).collect();
assert_eq!(joined, "fn main()");
assert!(matches!(spans.first(), Some(Span::CodeTok(t, c)) if t == "fn" && *c == Color32::RED));
let hostile = [TokenSpan { start: 5, end: 99, kind: TokenKind::String }, TokenSpan { start: 1, end: 3, kind: TokenKind::String }];
let joined: String =
row_spans("héllo wörld", Some(&hostile), &palette, plain).iter().map(|s| if let Span::CodeTok(t, _) = s { t.as_str() } else { "" }).collect();
assert_eq!(joined, "héllo wörld", "out-of-range and non-boundary ranges are skipped, text is intact");
assert!(matches!(row_spans("", None, &palette, plain).as_slice(), [Span::CodeTok(t, _)] if t.is_empty()));
}
}
#[path = "code_card/tests.rs"]
mod tests;
@@ -0,0 +1,103 @@
//! Cached grammar offers and explicit requests shared with the shell.
use egui::Id;
fn registry_offer_id(label: &str) -> Id {
Id::new(("notescraft-registry-grammar-offer", label.to_lowercase()))
}
pub(super) fn registry_install_id() -> Id {
Id::new("notescraft-registry-grammar-install")
}
/// Stores a cached registry suggestion for a grammar label; this performs no engine call.
pub(crate) fn set_registry_offer(ctx: &egui::Context, label: &str, id: String, title: String, size: String) {
ctx.data_mut(|data| data.insert_temp(registry_offer_id(label), (id, title, size)));
}
/// Clears an offer whose cached catalogue entry changed or disappeared.
pub(crate) fn clear_registry_offer(ctx: &egui::Context, label: &str) {
ctx.data_mut(|data| {
data.remove_temp::<(String, String, String)>(registry_offer_id(label));
});
}
/// Takes the cached registry suggestion matching `label` for painting.
pub(super) fn registry_offer(ctx: &egui::Context, label: &str) -> Option<(String, String, String)> {
ctx.data(|data| data.get_temp(registry_offer_id(label)))
}
/// Takes the grammar install request made by an explicit code-card click.
#[must_use]
pub(crate) fn take_registry_install_request(ctx: &egui::Context) -> Option<(String, String)> {
ctx.data_mut(|data| data.remove_temp(registry_install_id()))
}
/// Queues a cache-only alias lookup for a rendered fence.
pub(super) fn request_registry_label(ctx: &egui::Context, label: &str) {
ctx.data_mut(|data| {
let id = Id::new("registry-labels");
let mut labels = data.get_temp::<Vec<String>>(id).unwrap_or_default();
let label = label.to_lowercase();
if !labels.contains(&label) {
labels.push(label);
}
data.insert_temp(id, labels);
});
}
/// Takes visible fence labels without any network operation.
pub(crate) fn take_registry_labels(ctx: &egui::Context) -> Vec<String> {
ctx.data_mut(|data| data.remove_temp(Id::new("registry-labels"))).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cached_offer_does_not_request_install() {
let ctx = egui::Context::default();
set_registry_offer(&ctx, "Twig", "twig".into(), "Twig".into(), "3.9 KB".into());
assert_eq!(registry_offer(&ctx, "twig"), Some(("twig".into(), "Twig".into(), "3.9 KB".into())));
assert_eq!(take_registry_install_request(&ctx), None);
request_registry_label(&ctx, "Twig");
request_registry_label(&ctx, "twig");
assert_eq!(take_registry_labels(&ctx), vec!["twig"]);
assert_eq!(take_registry_labels(&ctx), Vec::<String>::new());
}
#[test]
fn code_card_click_queues_one_registry_install() {
use egui_kittest::kittest::Queryable;
let Some(theme) = notescraft_themes::builtin("catppuccin-mocha") else { return };
let tokens = crate::theme::Tokens::new(theme);
let source = "```twig-registry-test\nhello\n```";
let lines: Vec<(usize, usize)> = source
.lines()
.scan(0, |offset, line| {
let start = *offset;
*offset += line.len() + 1;
Some((start, start + line.len()))
})
.collect();
let mut warmed = false;
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 400.0)).build_ui(move |ui| {
if !warmed {
crate::fonts::install(ui.ctx());
crate::style::apply(ui.ctx(), &tokens);
warmed = true;
return;
}
set_registry_offer(ui.ctx(), "twig-registry-test", "twig".into(), "Twig".into(), "3900 bytes".into());
super::super::show(ui, &tokens, source, &lines, 0, lines.len());
});
let label = crate::text::registry::install_grammar().replace("{name}", "Twig").replace("{size}", "3900 bytes");
harness.get_by_label(&label).click();
harness.run();
assert_eq!(take_registry_install_request(&harness.ctx), Some(("grammar".into(), "twig".into())));
assert_eq!(take_registry_install_request(&harness.ctx), None);
clear_registry_offer(&harness.ctx, "twig-registry-test");
assert!(registry_offer(&harness.ctx, "twig-registry-test").is_none());
}
}
@@ -0,0 +1,21 @@
//! Code-card token rendering checks.
use notescraft_syntax::TokenKind;
use super::*;
#[test]
fn row_spans_cover_the_text_in_order_and_survive_bad_ranges() {
let palette = [Color32::RED; 8];
let plain = Color32::WHITE;
let tokens = [TokenSpan { start: 0, end: 2, kind: TokenKind::Keyword }, TokenSpan { start: 3, end: 7, kind: TokenKind::Function }];
let spans = row_spans("fn main()", Some(&tokens), &palette, plain);
let joined: String = spans.iter().map(|s| if let Span::CodeTok(t, _) = s { t.as_str() } else { "" }).collect();
assert_eq!(joined, "fn main()");
assert!(matches!(spans.first(), Some(Span::CodeTok(t, c)) if t == "fn" && *c == Color32::RED));
let hostile = [TokenSpan { start: 5, end: 99, kind: TokenKind::String }, TokenSpan { start: 1, end: 3, kind: TokenKind::String }];
let joined: String =
row_spans("héllo wörld", Some(&hostile), &palette, plain).iter().map(|s| if let Span::CodeTok(t, _) = s { t.as_str() } else { "" }).collect();
assert_eq!(joined, "héllo wörld", "out-of-range and non-boundary ranges are skipped, text is intact");
assert!(matches!(row_spans("", None, &palette, plain).as_slice(), [Span::CodeTok(t, _)] if t.is_empty()));
}
@@ -0,0 +1,100 @@
//! Cached entry drawer and human-readable plugin permissions.
use super::{Action, Entry, RegistryView, list};
use crate::{
text::registry as strings,
theme::{Tokens, color},
};
/// Detail card width, points, bounded by the viewport.
const WIDTH: f32 = 440.0;
/// Padding around drawer content, points.
const PAD: i8 = 24;
/// Viewport margin around drawer, points.
const MARGIN: f32 = 16.0;
/// Shows the selected cached entry; the drawer never fetches.
pub(super) fn show(view: &mut RegistryView, ui: &mut egui::Ui, tokens: &Tokens, actions: &mut Vec<Action>) {
let Some((kind, id)) = &view.selected else { return };
let Some(entry) = view.rows.iter().find(|row| &row.kind == kind && &row.id == id).cloned() else { return };
let ctx = ui.ctx();
let mut close = ctx.input(|input| input.key_pressed(egui::Key::Escape));
crate::dialog::paint_backdrop(ctx, "registry-detail", 1.0);
egui::Area::new(egui::Id::new("registry-detail")).order(crate::dialog::CARD_ORDER).anchor(egui::Align2::RIGHT_TOP, egui::vec2(-MARGIN, MARGIN)).show(
ctx,
|ui| {
egui::Frame::new().fill(color(tokens.theme.colors.surface)).corner_radius(crate::theme::PANEL_RADIUS).inner_margin(PAD).show(ui, |ui| {
ui.set_max_height(ctx.viewport_rect().height() - MARGIN * 2.0 - f32::from(PAD) * 2.0);
ui.set_width(WIDTH.min((ctx.viewport_rect().width() - MARGIN * 2.0 - f32::from(PAD) * 2.0).max(0.0)));
egui::ScrollArea::vertical()
.id_salt("notescraft-registry-detail-scroll")
.max_height(ctx.viewport_rect().height() - MARGIN * 2.0 - f32::from(PAD) * 2.0)
.show(ui, |ui| {
ui.heading(&entry.title);
ui.label(&entry.summary);
ui.add_space(MARGIN);
metadata(ui, &entry);
if entry.kind == "plugin" {
ui.add_space(MARGIN);
ui.label(egui::RichText::new(strings::permissions()).strong());
for permission in &entry.permissions {
ui.label(permission_sentence(permission));
}
}
ui.add_space(MARGIN);
ui.horizontal_wrapped(|ui| {
if entry.installed_version.is_some() {
if list::button(ui, tokens, "registry-remove", strings::remove(), false, !view.busy) {
actions.push(Action::Remove(entry.kind.clone(), entry.id.clone()));
}
if entry.previous.is_some() && list::button(ui, tokens, "registry-rollback", strings::rollback(), false, !view.busy) {
actions.push(Action::Rollback(entry.kind.clone(), entry.id.clone()));
}
}
close |= list::button(ui, tokens, "registry-close", strings::close(), false, true);
});
if entry.kind == "plugin" && entry.installed_version.is_some() {
ui.horizontal_wrapped(|ui| {
let label = if entry.enabled { strings::disable() } else { strings::enable() };
ui.label(label);
list::switch(ui, tokens, "registry-enable", label, entry.enabled, !view.busy, |next| {
actions.push(if next { Action::Enable(entry.id.clone()) } else { Action::Disable(entry.id.clone()) });
});
});
}
});
});
},
);
if close {
view.selected = None;
}
}
fn metadata(ui: &mut egui::Ui, entry: &Entry) {
ui.label(strings::license().replace("{license}", &entry.license));
let source = strings::source().replace("{source}", &entry.source);
if entry.source.starts_with("https://") {
ui.hyperlink_to(source, &entry.source);
} else {
ui.label(source);
}
ui.label(strings::signature().replace("{key}", &entry.key_id));
if !entry.files.is_empty() {
ui.label(egui::RichText::new(strings::files()).strong());
for file in &entry.files {
let path = file.get("path").and_then(serde_json::Value::as_str).unwrap_or_default();
let size = file.get("size").and_then(serde_json::Value::as_u64).unwrap_or_default();
ui.label(strings::file().replace("{path}", path).replace("{size}", &size.to_string()));
}
}
}
fn permission_sentence(permission: &str) -> String {
match permission {
"vault.read" | "read" | "notes.read" | "search" => strings::read().to_owned(),
"vault.write" | "write" | "notes.write" => strings::write().to_owned(),
"editor.edit" | "editor" | "editor.write" => strings::edit().to_owned(),
_ => strings::permission_unknown().replace("{permission}", permission),
}
}
@@ -0,0 +1,194 @@
//! Registry toolbar, local filters and tonal catalogue cards.
use super::{Action, Entry, RegistryView};
use crate::{
text::registry as strings,
theme::{Tokens, color},
};
use egui::{RichText, Ui};
/// Gap between cards, points.
const GAP: f32 = 12.0;
/// Card inner padding, points.
const PAD: i8 = 16;
/// Default control height, points; compact controls grow to 44.
const CONTROL_HEIGHT: f32 = 32.0;
/// Space around control text, points.
const BUTTON_PAD: f32 = 24.0;
/// Body text size, points.
const BODY_SIZE: f32 = 13.0;
/// Search field width on a wide viewport, points.
const SEARCH_WIDTH: f32 = 320.0;
/// Browser heading size, points.
const TITLE_SIZE: f32 = 24.0;
/// Catalogue title size, points.
const ENTRY_TITLE_SIZE: f32 = 16.0;
/// Switch hit width, points.
const SWITCH_WIDTH: f32 = 48.0;
/// Switch track width and height, points.
const SWITCH_TRACK: egui::Vec2 = egui::vec2(40.0, 24.0);
/// Switch track radius and thumb inset, points.
const SWITCH_RADIUS: f32 = 12.0;
/// Switch thumb radius, points.
const THUMB_RADIUS: f32 = 8.0;
/// A stable, accessible tonal button, with the same tokens as dialog buttons.
pub(crate) fn button(ui: &mut Ui, tokens: &Tokens, salt: impl std::hash::Hash + std::fmt::Debug, label: &str, accent: bool, enabled: bool) -> bool {
let colors = &tokens.theme.colors;
let tint = if accent { colors.on_accent } else { colors.text };
let galley = ui.painter().layout_no_wrap(label.to_owned(), egui::FontId::proportional(BODY_SIZE), color(tint));
let height = crate::style::compact::target(ui.ctx(), CONTROL_HEIGHT);
let size = egui::vec2((galley.size().x + BUTTON_PAD).max(height), height);
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
let response = ui.interact(rect, ui.id().with(salt), if enabled { egui::Sense::click() } else { egui::Sense::hover() });
let fill = if accent {
colors.accent
} else if response.hovered() {
colors.selection
} else {
colors.surface_2
};
ui.painter().rect_filled(rect, crate::style::radius_u8(tokens.radius_medium()), color(fill).gamma_multiply(if enabled { 1.0 } else { 0.4 }));
ui.painter().galley(rect.center() - galley.size() / 2.0, galley, color(tint));
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label));
response.clicked() && enabled
}
/// Toolbar with cache-only search and explicit network check.
pub(super) fn header(view: &mut RegistryView, ui: &mut Ui, tokens: &Tokens, actions: &mut Vec<Action>) {
ui.label(RichText::new(strings::title()).size(TITLE_SIZE).strong().color(color(tokens.theme.colors.text)));
ui.label(RichText::new(strings::description()).color(color(tokens.theme.colors.text_muted)));
ui.add_space(GAP);
ui.horizontal_wrapped(|ui| {
let width = SEARCH_WIDTH.min(ui.available_width());
ui.push_id("registry-search", |ui| {
crate::settings_view::controls::input(ui, tokens, &mut view.query, width, strings::search());
});
if button(ui, tokens, "registry-check", strings::check(), true, !view.busy) {
actions.push(Action::Check);
}
});
ui.add_space(GAP);
ui.horizontal_wrapped(|ui| {
for (kind, label) in kinds() {
if button(ui, tokens, ("registry-kind", kind), label, view.kind == kind, true) {
kind.clone_into(&mut view.kind);
}
}
});
ui.add_space(GAP);
ui.horizontal_wrapped(|ui| {
let checked = view.last_checked.as_ref().map_or_else(|| strings::never().to_owned(), |time| strings::last_checked().replace("{time}", time));
ui.label(RichText::new(checked).color(color(tokens.theme.colors.text_muted)));
if view.updates() > 0 {
ui.label(RichText::new(strings::updates().replace("{count}", &view.updates().to_string())).color(color(tokens.theme.colors.accent)));
}
});
ui.horizontal_wrapped(|ui| {
let label = strings::auto_check();
ui.label(label);
switch(ui, tokens, "registry-auto-check", label, view.auto_check, !view.busy, |next| {
view.auto_check = next;
actions.push(Action::AutoCheck(next));
});
});
ui.add_space(GAP);
}
/// An accessible switch with compact touch sizing and stable identity.
pub(super) fn switch(ui: &mut Ui, tokens: &Tokens, salt: &str, label: &str, value: bool, enabled: bool, mut changed: impl FnMut(bool)) {
let height = crate::style::compact::target(ui.ctx(), CONTROL_HEIGHT);
let (rect, _) = ui.allocate_exact_size(egui::vec2(SWITCH_WIDTH, height), egui::Sense::hover());
let response = ui.interact(rect, ui.id().with(salt), if enabled { egui::Sense::click() } else { egui::Sense::hover() });
let track = egui::Rect::from_center_size(rect.center(), SWITCH_TRACK);
let colors = &tokens.theme.colors;
ui.painter().rect_filled(track, SWITCH_RADIUS, color(if value { colors.accent } else { colors.surface_2 }));
let center = egui::pos2(if value { track.right() - SWITCH_RADIUS } else { track.left() + SWITCH_RADIUS }, track.center().y);
ui.painter().circle_filled(center, THUMB_RADIUS, color(colors.on_accent));
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, enabled, value, label));
if enabled && response.clicked() {
changed(!value);
}
}
fn kinds() -> [(&'static str, &'static str); 7] {
[
("", strings::all()),
("plugin", strings::plugins()),
("theme", strings::themes()),
("pack", strings::packs()),
("grammar", strings::grammars()),
("font", strings::fonts()),
("snippet", strings::snippets()),
]
}
/// User-facing kind label from the i18n catalogue.
pub(super) fn kind_label(kind: &str) -> &'static str {
kinds().into_iter().find(|(id, _)| *id == kind).map_or(strings::all(), |(_, label)| label)
}
/// Visible catalogue, filtered locally with no actions for search or chips.
pub(super) fn show(view: &mut RegistryView, ui: &mut Ui, tokens: &Tokens, actions: &mut Vec<Action>) {
let query = view.query.trim().to_lowercase();
let rows: Vec<_> = view
.rows
.iter()
.filter(|row| {
(view.kind.is_empty() || row.kind == view.kind)
&& [row.title.as_str(), row.id.as_str(), row.summary.as_str()].iter().any(|text| text.to_lowercase().contains(&query))
})
.collect();
egui::ScrollArea::vertical().id_salt("notescraft-registry-scroll").auto_shrink([false, false]).show(ui, |ui| {
if rows.is_empty() {
ui.label(strings::empty());
}
for row in rows {
ui.push_id(("registry-entry", &row.kind, &row.id), |ui| {
egui::Frame::new().fill(color(tokens.theme.colors.surface)).corner_radius(crate::theme::PANEL_RADIUS).inner_margin(PAD).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(RichText::new(&row.title).strong().size(ENTRY_TITLE_SIZE).color(color(tokens.theme.colors.text)));
ui.label(RichText::new(kind_label(&row.kind)).color(color(tokens.theme.colors.accent)));
let size = strings::bytes().replace("{size}", &row.size.to_string());
ui.label(
RichText::new(strings::version().replace("{version}", &row.version).replace("{size}", &size))
.color(color(tokens.theme.colors.text_muted)),
);
if !row.summary.is_empty() {
ui.label(&row.summary);
}
ui.add_space(GAP);
ui.horizontal_wrapped(|ui| {
state_button(ui, tokens, row, !view.busy, actions);
if button(ui, tokens, "details", strings::details(), false, !view.busy) {
view.selected = Some((row.kind.clone(), row.id.clone()));
actions.push(Action::Info(row.kind.clone(), row.id.clone()));
}
});
});
});
ui.add_space(GAP);
}
});
}
fn state_button(ui: &mut Ui, tokens: &Tokens, row: &Entry, enabled: bool, actions: &mut Vec<Action>) {
let action = match row.state.as_str() {
"available" => Some((strings::install().to_owned(), Action::Install(row.kind.clone(), row.id.clone()))),
"update" => Some((strings::update().replace("{version}", &row.version), Action::Update(row.kind.clone(), row.id.clone()))),
_ => None,
};
if let Some((label, action)) = action {
if button(ui, tokens, "entry-action", &label, true, enabled) {
actions.push(action);
}
return;
}
let label = match row.state.as_str() {
"installed" => strings::installed().to_owned(),
"revoked" => strings::revoked().to_owned(),
"incompatible" => strings::incompatible().replace("{version}", &row.min_app_version),
_ => strings::unsupported().to_owned(),
};
ui.label(RichText::new(label).color(color(tokens.theme.colors.text_muted)));
}
@@ -0,0 +1,163 @@
//! Offline registry browser. Painting emits actions; the shell owns every engine call.
mod detail;
mod list;
pub(crate) use list::button;
use crate::text::registry as strings;
use crate::theme::Tokens;
use serde::Deserialize;
use serde_json::Value;
/// A cached add-on row, joined with its installed state by `registry.list`.
#[derive(Clone, Debug, Default, Deserialize, serde::Serialize)]
#[serde(default)]
pub struct Entry {
/// Stable registry identifier.
pub id: String,
/// Registry kind.
pub kind: String,
/// Display title.
pub title: String,
/// Available version.
pub version: String,
/// Currently installed version.
pub installed_version: Option<String>,
/// Download size in bytes.
pub size: u64,
/// Engine availability state.
pub state: String,
/// Whether the publisher recommends this entry on first run.
pub suggested: bool,
/// Publisher description.
pub summary: String,
/// Plugin capabilities, displayed as sentences.
pub permissions: Vec<String>,
/// SPDX license identifier.
pub license: String,
/// Publisher source URL.
pub source: String,
/// Signature verification key identifier.
pub key_id: String,
/// Published file descriptors.
pub files: Vec<Value>,
/// Minimum supported application version.
pub min_app_version: String,
/// Whether this plugin is enabled in the current vault.
pub enabled: bool,
/// Retained version available for rollback.
pub previous: Option<String>,
}
/// An explicit request for the shell; the view never performs IO.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Action {
/// Refresh the signed index.
Check,
/// Install kind and identifier.
Install(String, String),
/// Update kind and identifier.
Update(String, String),
/// Remove kind and identifier.
Remove(String, String),
/// Restore the previous version.
Rollback(String, String),
/// Approve and enable a plugin in this vault.
Enable(String),
/// Disable a plugin in this vault.
Disable(String),
/// Persist automatic update checks.
AutoCheck(bool),
/// Load cached details for kind and identifier.
Info(String, String),
}
/// Browser state independent of engine and network implementations.
#[derive(Clone, Debug, Default)]
pub struct RegistryView {
/// Rows from the last successful cache read.
pub rows: Vec<Entry>,
/// Local search text; typing never requests network access.
pub query: String,
/// Kind filter; empty means all kinds.
pub kind: String,
/// Last network operation failed offline; cached rows remain visible.
pub offline: bool,
/// Latest command failure.
pub error: Option<String>,
/// Whether a command is running.
pub busy: bool,
/// Human-readable time supplied by the shell.
pub last_checked: Option<String>,
/// Automatic checks are off by default.
pub auto_check: bool,
/// Open detail drawer, keyed by kind and identifier.
pub selected: Option<(String, String)>,
}
impl RegistryView {
/// Applies a cache-only list reply, preserving existing rows on malformed replies.
pub fn apply_reply(&mut self, reply: &Value) {
let rows = reply.as_array().map(|_| reply).or_else(|| reply.get("entries")).or_else(|| reply.get("items"));
if let Some(rows) = rows {
match serde_json::from_value(rows.clone()) {
Ok(rows) => {
self.rows = rows;
self.error = None;
}
Err(_) => self.error = Some(strings::invalid_reply().to_owned()),
}
}
}
/// Merges a cache-only detail reply into its existing catalogue row.
pub fn apply_info(&mut self, reply: &Value) {
let data = reply.get("entry").unwrap_or(reply);
let Some(id) = data.get("id").and_then(Value::as_str) else { return };
let Some(row) = self.rows.iter_mut().find(|row| row.id == id && data.get("kind").and_then(Value::as_str).is_none_or(|kind| row.kind == kind)) else {
return;
};
let Ok(mut merged) = serde_json::to_value(&*row) else { return };
if let (Some(target), Some(source)) = (merged.as_object_mut(), data.as_object()) {
target.extend(source.clone());
if let Some(key) = data.get("signature").and_then(|signature| signature.get("key_id")) {
target.insert("key_id".to_owned(), key.clone());
}
}
if let Ok(updated) = serde_json::from_value(merged) {
*row = updated;
}
}
/// Applies an engine error without discarding the cached catalogue.
pub fn set_error(&mut self, code: &str, message: &str) {
if code == "offline" {
self.offline = true;
}
self.error = Some(message.to_owned());
}
/// Update count shown only after a successful explicit check.
#[must_use]
pub fn updates(&self) -> usize {
if self.last_checked.is_some() { self.rows.iter().filter(|row| row.state == "update").count() } else { 0 }
}
/// Paints the browser and returns explicit user actions from this frame.
pub fn show(&mut self, ui: &mut egui::Ui, tokens: &Tokens) -> Vec<Action> {
let mut actions = Vec::new();
list::header(self, ui, tokens, &mut actions);
if self.offline {
ui.label(strings::offline());
}
if let Some(error) = &self.error {
ui.colored_label(crate::theme::color(tokens.theme.colors.danger), error);
}
if self.busy {
ui.label(strings::loading());
}
list::show(self, ui, tokens, &mut actions);
detail::show(self, ui, tokens, &mut actions);
actions
}
}
+15
View File
@@ -56,6 +56,8 @@ pub enum SettingsCategory {
CorePlugins,
/// Installed community plugins.
CommunityPlugins,
/// Cached add-on browser.
Registry,
/// External assistant permissions and command.
AiAssistant,
/// Advanced preferences.
@@ -74,6 +76,7 @@ impl SettingsCategory {
Self::Calendar,
Self::CorePlugins,
Self::CommunityPlugins,
Self::Registry,
Self::AiAssistant,
Self::Advanced,
];
@@ -88,6 +91,7 @@ impl SettingsCategory {
Self::DailyNotes,
Self::CorePlugins,
Self::CommunityPlugins,
Self::Registry,
Self::AiAssistant,
Self::Advanced,
];
@@ -105,6 +109,7 @@ impl SettingsCategory {
Self::Calendar => strings::calendar(),
Self::CorePlugins => strings::core_plugins(),
Self::CommunityPlugins => strings::community_plugins(),
Self::Registry => crate::text::registry::title(),
Self::AiAssistant => strings::ai_assistant(),
Self::Advanced => strings::advanced(),
}
@@ -123,6 +128,7 @@ impl SettingsCategory {
Self::Calendar => "calendar",
Self::CorePlugins => "layout-dashboard",
Self::CommunityPlugins => "git-branch",
Self::Registry => "download",
Self::AiAssistant => "sparkles",
Self::Advanced => "bug",
}
@@ -157,6 +163,12 @@ impl ThemeInfo {
pub struct SettingsView {
/// Currently selected navigation category.
pub selected: SettingsCategory,
/// Cached registry browser state.
pub registry: crate::views::registry_view::RegistryView,
/// Registry actions waiting for the shell after painting.
pub(crate) registry_actions: Vec<crate::views::registry_view::Action>,
/// Whether the shell has read the cache during this visit.
pub(crate) registry_open: bool,
/// Pages whose sub-items are open in the list.
pub(crate) expanded: BTreeSet<SettingsCategory>,
/// Search text applied across all setting rows.
@@ -190,6 +202,9 @@ impl Default for SettingsView {
fn default() -> Self {
Self {
selected: SettingsCategory::Appearance,
registry: crate::views::registry_view::RegistryView::default(),
registry_actions: Vec::new(),
registry_open: false,
expanded: BTreeSet::new(),
search_text: String::new(),
pending_ui_scale: None,
@@ -52,6 +52,10 @@ pub(super) fn show_category(
SettingsCategory::DailyNotes => daily_notes(ui, tokens, settings, state, category, changes),
SettingsCategory::Calendar => calendar::show(ui, tokens, settings, state, category, changes),
SettingsCategory::Sync => sync::show(ui, tokens, state, category),
SettingsCategory::Registry if matches(state, category, crate::text::registry::title(), crate::text::registry::description()) => {
state.registry_actions.extend(state.registry.show(ui, tokens));
}
SettingsCategory::Registry => {}
SettingsCategory::CorePlugins => {
core_cards::show(ui, tokens, settings, state, category, changes);
maps::show(ui, tokens, settings, state, category, changes);
+26 -2
View File
@@ -9,6 +9,8 @@ use crate::theme::{Tokens, color};
const CHILD_INDENT: f32 = 22.0;
/// Id of the section heading the page should scroll to once it is drawn.
const ANCHOR_ID: &str = "settings-anchor";
/// Registry update badge radius, points.
const BADGE_RADIUS: f32 = 4.0;
/// One visible row of the list.
struct Row {
@@ -77,7 +79,15 @@ pub(super) fn navigation(ui: &mut Ui, tokens: &Tokens, state: &mut SettingsView)
);
for row in &list {
let active = is_active(row);
let (rect, hit) = ui.allocate_exact_size(Vec2::new(width, NAV_ROW_HEIGHT), Sense::click());
let sense = if row.page == SettingsCategory::Registry { Sense::hover() } else { Sense::click() };
let (rect, hit) = ui.allocate_exact_size(Vec2::new(width, NAV_ROW_HEIGHT), sense);
let hit = if row.page == SettingsCategory::Registry {
let hit = ui.interact(rect, ui.id().with("settings-registry-category"), Sense::click());
hit.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), active, row.label));
hit
} else {
hit
};
let hover = crate::motion::hover(ui.ctx(), ("settings-category", row.label), &hit);
let wobble = crate::motion::wobble(ui.ctx(), ("settings-category-wobble", row.label), hit.clicked(), 0.45);
if !active {
@@ -100,6 +110,9 @@ pub(super) fn navigation(ui: &mut Ui, tokens: &Tokens, state: &mut SettingsView)
let arrow = egui::Rect::from_center_size(egui::Pos2::new(rect.right() - 18.0, rect.center().y), Vec2::splat(14.0));
crate::icons::paint(ui, name, arrow, tint);
}
if row.page == SettingsCategory::Registry && state.registry.last_checked.is_some() && state.registry.updates() > 0 {
ui.painter().circle_filled(egui::pos2(rect.right() - 18.0, rect.center().y), BADGE_RADIUS, color(c.accent));
}
if hit.clicked() {
click(ui, state, row);
}
@@ -116,11 +129,22 @@ pub(super) fn strip(ui: &mut Ui, tokens: &Tokens, state: &mut SettingsView) {
let active = state.selected == category;
let galley = ui.painter().layout_no_wrap(category.label().to_owned(), egui::FontId::proportional(13.0), color(c.text));
let size = Vec2::new(galley.size().x + 32.0, crate::style::compact::MIN_TARGET_COMPACT);
let (rect, hit) = ui.allocate_exact_size(size, Sense::click());
let sense = if category == SettingsCategory::Registry { Sense::hover() } else { Sense::click() };
let (rect, hit) = ui.allocate_exact_size(size, sense);
let hit = if category == SettingsCategory::Registry {
let hit = ui.interact(rect, ui.id().with("settings-registry-category"), Sense::click());
hit.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), active, category.label()));
hit
} else {
hit
};
let fill = if active { color(c.selection) } else { color(c.surface) };
ui.painter().rect_filled(rect, CornerRadius::same(12), fill);
let tint = if active { color(c.text) } else { color(c.text_muted) };
ui.painter().galley(rect.center() - galley.size() / 2.0, galley, tint);
if category == SettingsCategory::Registry && state.registry.last_checked.is_some() && state.registry.updates() > 0 {
ui.painter().circle_filled(egui::pos2(rect.right() - 8.0, rect.top() + 8.0), BADGE_RADIUS, color(c.accent));
}
if hit.clicked() {
state.selected = category;
}
+170
View File
@@ -0,0 +1,170 @@
//! Registry browser driven through real egui frames with cached engine-shaped replies.
use egui::{Event, Vec2};
use egui_kittest::{Harness, kittest::Queryable};
use notescraft_ui_egui::{
theme::Tokens,
views::registry_view::{Action, RegistryView},
};
use serde_json::json;
struct Page {
view: RegistryView,
actions: Vec<Action>,
tokens: Tokens,
warmed: bool,
}
fn rows() -> serde_json::Value {
json!([
{"id":"twig","kind":"grammar","title":"Twig","summary":"Syntax highlighting for Twig templates.","version":"1.2.0","size":3900,"state":"available"},
{"id":"dice","kind":"plugin","title":"Dice","summary":"Roll dice in your notes.","version":"1.2.0","installed_version":"1.0.0","previous":"0.9.0","size":4800,"state":"update","permissions":["vault.read","vault.write","editor"],"license":"MIT","source":"https://example.test/dice","key_id":"first-party","files":[{"path":"plugin.wasm","size":4800}]},
{"id":"theme","kind":"theme","title":"Frappe","version":"1.0.0","state":"installed","installed_version":"1.0.0"},
{"id":"old","kind":"plugin","title":"Revoked plugin","state":"revoked"},
{"id":"future","kind":"plugin","title":"Future plugin","state":"incompatible","min_app_version":"0.3"},
{"id":"font","kind":"font","title":"Math font","state":"unsupported"}
])
}
fn page(theme: &str, width: f32) -> Harness<'static, Page> {
let mut view = RegistryView::default();
view.apply_reply(&rows());
let page = Page { view, actions: Vec::new(), tokens: Tokens::new(notescraft_themes::builtin(theme).expect("theme")), warmed: false };
Harness::builder().with_size(Vec2::new(width, 1200.0)).build_ui_state(
|ui, page: &mut Page| {
if !page.warmed {
notescraft_ui_egui::fonts::install(ui.ctx());
notescraft_ui_egui::motion::set_enabled(ui.ctx(), false);
notescraft_ui_egui::style::apply(ui.ctx(), &page.tokens);
page.warmed = true;
return;
}
egui::Frame::new().fill(notescraft_ui_egui::theme::color(page.tokens.theme.colors.canvas)).inner_margin(24).show(ui, |ui| {
page.actions.extend(page.view.show(ui, &page.tokens));
});
},
page,
)
}
fn settle(h: &mut Harness<'_, Page>) {
h.run_steps(4);
}
#[test]
fn every_row_state_and_install_click() {
let mut h = page("catppuccin-mocha", 1000.0);
settle(&mut h);
for label in ["Install", "Update to 1.2.0", "Installed", "Revoked", "Needs NotesCraft 0.3", "Not available in this version"] {
assert!(h.query_by_label(label).is_some(), "missing {label}");
}
h.get_by_label("Install").click();
settle(&mut h);
assert_eq!(h.state().actions, vec![Action::Install("grammar".to_owned(), "twig".to_owned())]);
}
#[test]
fn typing_search_and_kind_filter_are_local() {
let mut h = page("catppuccin-latte", 1000.0);
settle(&mut h);
h.get_by_role(egui::accesskit::Role::TextInput).click();
h.step();
h.input_mut().events.push(Event::Text("Twig".to_owned()));
settle(&mut h);
assert!(h.query_by_label("Twig").is_some());
assert!(h.query_by_label("Dice").is_none());
assert_eq!(h.state().actions, Vec::<Action>::new());
h.get_by_role_and_label(egui::accesskit::Role::Button, "Plugins").click();
settle(&mut h);
assert!(h.query_by_label("Twig").is_none());
assert!(h.query_by_label("No add-ons found. Try another search or check for updates.").is_some());
assert_eq!(h.state().actions, Vec::<Action>::new());
}
#[test]
fn offline_error_and_empty_states_preserve_cache() {
let mut h = page("catppuccin-mocha", 1000.0);
h.state_mut().view.set_error("offline", "Connection refused");
settle(&mut h);
assert!(h.query_by_label("Offline — showing cached add-ons. Try checking again when connected.").is_some());
assert!(h.query_by_label("Connection refused").is_some());
assert!(h.query_by_label("Twig").is_some());
h.state_mut().view.rows.clear();
settle(&mut h);
assert!(h.query_by_label("No add-ons found. Try another search or check for updates.").is_some());
}
#[test]
fn unknown_signing_key_error_stays_visible_without_discarding_cache() {
let mut h = page("catppuccin-mocha", 1000.0);
let message = notescraft_engine::Error::Addon("unknown signing key `first-party`".to_owned()).to_string();
h.state_mut().view.set_error("addon", &message);
settle(&mut h);
assert!(h.query_by_label(&message).is_some());
assert!(h.query_by_label("Twig").is_some());
assert!(!h.state().view.offline);
assert_eq!(h.state().actions, Vec::<Action>::new());
}
#[test]
fn permissions_are_sentences_and_detail_actions_are_explicit() {
let mut h = page("catppuccin-mocha", 1000.0);
h.state_mut().view.selected = Some(("plugin".to_owned(), "dice".to_owned()));
settle(&mut h);
for label in ["Read notes and search", "Create and change notes", "Edit the open note", "Remove", "Rollback"] {
assert!(h.query_by_label(label).is_some(), "missing {label}");
}
assert_eq!(h.state().actions, Vec::<Action>::new());
assert!(h.query_by_role_and_label(egui::accesskit::Role::CheckBox, "Enable in this vault").is_some());
h.get_by_label("Rollback").click();
settle(&mut h);
assert_eq!(h.state().actions, vec![Action::Rollback("plugin".to_owned(), "dice".to_owned())]);
}
#[test]
fn update_badge_requires_a_successful_check() {
let mut h = page("catppuccin-mocha", 1000.0);
settle(&mut h);
assert_eq!(h.state().view.updates(), 0);
assert!(h.query_by_label("1 updates").is_none());
h.get_by_label("Check for updates").click();
settle(&mut h);
assert_eq!(h.state().actions, vec![Action::Check]);
assert_eq!(h.state().view.updates(), 0);
h.state_mut().view.last_checked = Some("just now".to_owned());
settle(&mut h);
assert!(h.query_by_label("1 updates").is_some());
}
#[test]
fn malformed_reply_does_not_discard_cache_and_info_keeps_install_state() {
let mut view = RegistryView::default();
view.apply_reply(&rows());
view.apply_reply(&json!({"entries":[{"permissions":12}]}));
assert_eq!(view.rows.len(), 6);
assert!(view.error.is_some());
view.apply_info(&json!({"kind":"plugin","id":"dice","summary":"Updated metadata"}));
let row = view.rows.iter().find(|row| row.id == "dice").expect("dice");
assert_eq!(row.summary, "Updated metadata");
assert_eq!(row.installed_version.as_deref(), Some("1.0.0"));
}
#[test]
#[ignore = "offscreen PNG visual review"]
fn render_registry_pngs() {
let output = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/agent-sol/png");
std::fs::create_dir_all(&output).expect("png folder");
for (theme, width, name) in [
("catppuccin-mocha", 1000.0, "registry-mocha.png"),
("catppuccin-latte", 1000.0, "registry-latte.png"),
("catppuccin-mocha", 390.0, "registry-compact.png"),
] {
let mut h = page(theme, width);
settle(&mut h);
h.render().expect("render").save(output.join(name)).expect("png");
}
let mut h = page("catppuccin-mocha", 1000.0);
h.state_mut().view.selected = Some(("plugin".to_owned(), "dice".to_owned()));
settle(&mut h);
h.render().expect("render detail").save(output.join("registry-detail.png")).expect("png");
}
+2
View File
@@ -61,6 +61,8 @@ rm -rf "$WORK"
# ---- stage an FHS tree (shared by every format) -------------------------------------------------
install -Dm755 "$BIN/notescraft" "$STAGE/usr/bin/notescraft"
install -Dm755 "$BIN/notescraft-cli" "$STAGE/usr/bin/notescraft-cli"
# Signed snapshot of the add-ons moved out of the binary (registry spec section 11); absent until it is built.
if [ -d "$ROOT/registry-seed" ]; then cp -R "$ROOT/registry-seed" "$STAGE/usr/bin/registry-seed"; fi
"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/notescraft" "$STAGE/usr/bin/notescraft-cli" 2>/dev/null || true
install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop"
install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml"
+2
View File
@@ -105,6 +105,8 @@ cp "$WORK/bin/notescraft" "$APP/Contents/MacOS/NotesCraft"
# The assistant hands agents `notescraft-cli mcp` as the vault MCP server and looks for it beside the app binary.
cp "$WORK/bin/notescraft-cli" "$APP/Contents/MacOS/notescraft-cli"
cp "$ROOT/assets/app-icon/notescraft.icns" "$APP/Contents/Resources/NotesCraft.icns"
# Signed snapshot of the add-ons moved out of the binary (registry spec section 11); absent until it is built.
if [ -d "$ROOT/registry-seed" ]; then cp -R "$ROOT/registry-seed" "$APP/Contents/Resources/registry-seed"; fi
sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \
-e "s/@BUILD_SHA@/${NOTESCRAFT_BUILD_SHA:-unknown}/g" \
"$HERE/Info.plist.in" >"$APP/Contents/Info.plist"
+4
View File
@@ -100,6 +100,9 @@ $Stage = Join-Path $TargetDir "windows-package\$Arch"
Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Stage | Out-Null
Copy-Item (Join-Path $Bin 'notescraft.exe'), (Join-Path $Bin 'notescraft-cli.exe') $Stage
# Signed snapshot of the add-ons moved out of the binary (registry spec section 11); absent until it is built.
$SeedSrc = Join-Path $Root 'registry-seed'
if (Test-Path $SeedSrc) { Copy-Item $SeedSrc (Join-Path $Stage 'registry-seed') -Recurse }
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'notescraft.exe') (Join-Path $Stage 'notescraft-cli.exe')
@@ -119,6 +122,7 @@ $Portable = Join-Path $TargetDir "windows-package\notescraft-$Version-windows-$A
Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Portable | Out-Null
Copy-Item (Join-Path $Stage '*.exe') $Portable
if (Test-Path (Join-Path $Stage 'registry-seed')) { Copy-Item (Join-Path $Stage 'registry-seed') $Portable -Recurse }
foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') {
$p = Join-Path $Root $f
if (Test-Path $p) { Copy-Item $p $Portable }
+17 -17
View File
@@ -8,27 +8,27 @@ One task = one agent (`specs/agents/opencode-runbook.md`). `[ ]` open. Spec: `sp
## Phase 0 - size fixes without a registry (do these first, they are 25 % of the binary)
- [ ] 0.1 LEAD: release profile. OWNS: root `Cargo.toml` (`[profile.release]` only), `packaging/macos/*.sh` and `packaging/windows/*` only if they strip separately. GOAL: add `strip = "symbols"`; confirm Linux `package.sh` strip is now redundant but harmless. VERIFY: `CARGO_TARGET_DIR=target/agent-size cargo build --release -p notescraft`, `ls -l` is at most 34.9 MB (measured 34,851,464), the app still starts (`measure.py` in `measurements.md`), `cargo xtask ci`. DO NOT: set `panic = "abort"` (the never-crash policy and tests rely on unwinding).
- [ ] 0.2 Share fonts (brief `briefs/29-font-dedupe.md`).
- [ ] 0.3 Drop egui `default_fonts`. OWNS: `apps/notescraft/Cargo.toml` (eframe feature list only, LEAD edit), `crates/ui-egui/src/design/fonts/mod.rs`, `assets/fonts/NotoEmoji-Regular.ttf` (copy from `epaint_default_fonts`, with its OFL text), `crates/ui-egui/tests/fonts_emoji.rs`. FILES TO OPEN: `crates/ui-egui/src/design/fonts/mod.rs` (`install`), `~/.cargo/registry/src/*/epaint_default_fonts-*/fonts/`. GOAL: `eframe` without `default_fonts`, `FontDefinitions::empty()` plus Inter, JetBrains Mono, STIX, NotoEmoji (last in every chain), the system fallbacks as today. TESTS: kittest renders `😀 ★ → ✓` in proportional and monospace with no missing-glyph box (compare against the egui replacement glyph); the snapshot of the Settings and Explorer screens differs only by sub-pixel noise. VERIFY: standing + `cargo run -p notescraft-ui-egui --example snapshot -- --vault <v> --out a.png --size 1440x900` before and after, read both PNGs; binary shrinks by at least 0.9 MiB. DO NOT: change any font size or weight.
- [x] 0.1 LEAD: release profile. OWNS: root `Cargo.toml` (`[profile.release]` only), `packaging/macos/*.sh` and `packaging/windows/*` only if they strip separately. GOAL: add `strip = "symbols"`; confirm Linux `package.sh` strip is now redundant but harmless. VERIFY: `CARGO_TARGET_DIR=target/agent-size cargo build --release -p notescraft`, `ls -l` is at most 34.9 MB (measured 34,851,464), the app still starts (`measure.py` in `measurements.md`), `cargo xtask ci`. DO NOT: set `panic = "abort"` (the never-crash policy and tests rely on unwinding).
- [x] 0.2 Share fonts (brief `briefs/29-font-dedupe.md`).
- [x] 0.3 Drop egui `default_fonts`. OWNS: `apps/notescraft/Cargo.toml` (eframe feature list only, LEAD edit), `crates/ui-egui/src/design/fonts/mod.rs`, `assets/fonts/NotoEmoji-Regular.ttf` (copy from `epaint_default_fonts`, with its OFL text), `crates/ui-egui/tests/fonts_emoji.rs`. FILES TO OPEN: `crates/ui-egui/src/design/fonts/mod.rs` (`install`), `~/.cargo/registry/src/*/epaint_default_fonts-*/fonts/`. GOAL: `eframe` without `default_fonts`, `FontDefinitions::empty()` plus Inter, JetBrains Mono, STIX, NotoEmoji (last in every chain), the system fallbacks as today. TESTS: kittest renders `😀 ★ → ✓` in proportional and monospace with no missing-glyph box (compare against the egui replacement glyph); the snapshot of the Settings and Explorer screens differs only by sub-pixel noise. VERIFY: standing + `cargo run -p notescraft-ui-egui --example snapshot -- --vault <v> --out a.png --size 1440x900` before and after, read both PNGs; binary shrinks by at least 0.9 MiB. DO NOT: change any font size or weight.
- [ ] 0.4 LEAD: `opt-level = "s"` decision. GOAL: measure on `target/agent-size` with the profile env vars: index build of the 6500-note vault (CPU seconds, `measure.py --large-ok`), `cargo test --release -p notescraft-ui-egui --test perf_code_blocks -- --ignored --nocapture`, a graph timelapse frame time; adopt only if none is worse than 10 %. Write the numbers in `measurements.md` section 1.3. DO NOT: adopt without numbers.
- [ ] 0.5 Lazy system fallback fonts. OWNS: `crates/ui-egui/src/design/fonts/mod.rs`, `.../scripts.rs` (add Hebrew and Arabic to the on-demand table if missing), `crates/ui-egui/src/design/fonts/tests.rs`. FILES TO OPEN: same, `note_text` / `apply_pending`. GOAL: remove `SYSTEM_FALLBACKS` eager read at `install`; Hebrew and Arabic load through `note_text` like other scripts, so launch does not read `SFHebrew.ttf` and `SFArabic.ttf`. TESTS: a note with Hebrew shows no box after one frame loop (existing test style in `tests.rs`); a note without them never reads the files (counter in the injected `file_size`). VERIFY: standing. DO NOT: change the Latin chain.
- [x] 0.5 Lazy system fallback fonts. OWNS: `crates/ui-egui/src/design/fonts/mod.rs`, `.../scripts.rs` (add Hebrew and Arabic to the on-demand table if missing), `crates/ui-egui/src/design/fonts/tests.rs`. FILES TO OPEN: same, `note_text` / `apply_pending`. GOAL: remove `SYSTEM_FALLBACKS` eager read at `install`; Hebrew and Arabic load through `note_text` like other scripts, so launch does not read `SFHebrew.ttf` and `SFArabic.ttf`. TESTS: a note with Hebrew shows no box after one frame loop (existing test style in `tests.rs`); a note without them never reads the files (counter in the injected `file_size`). VERIFY: standing. DO NOT: change the Latin chain.
## Phase 1 - pure registry core (engine, no IO unless stated)
- [ ] 1. Index model, validation, version compare (brief `briefs/30-registry-index-model.md`).
- [ ] 2. Fileset guard: path and size rules (brief `briefs/31-registry-fileset-guard.md`).
- [ ] 3. BLOCKED (owner Q1). Signature canonical text and verify.
- [x] 1. Index model, validation, version compare (brief `briefs/30-registry-index-model.md`).
- [x] 2. Fileset guard: path and size rules (brief `briefs/31-registry-fileset-guard.md`).
- [x] 3. Signature canonical text and verify.
OWNS: `crates/engine/src/addons/verify.rs`, `crates/engine/src/addons/keys.rs`, `crates/engine/tests/addons_verify.rs`. FILES TO OPEN: `spec.md` D3, `crates/engine/src/addons/model.rs` (task 1), `crates/syntax/src/validate.rs` (`sha256_hex`). GOAL: `pub trait Verifier { fn verify(&self, entry: &Entry) -> Result<()>; }`, `pub fn canonical_text(entry: &Entry) -> String`, `pub struct Ed25519 { keys }` implementing it with the dependency the owner approved, `TRUSTED_KEYS` with the owner's public key (placeholder key in tests only), a `FakeVerifier` for other tasks. TESTS: known-answer signature generated by the xtask tool (task 18) verifies; one flipped hash byte fails; unknown `key_id` fails; wrong kind or version in the text fails; files in a different order produce the same text. VERIFY: standing. DO NOT: put a private key anywhere; accept an entry without `signature`.
- [ ] 4. State and store layout.
- [x] 4. State and store layout.
OWNS: `crates/engine/src/addons/state.rs`, `crates/engine/src/addons/store.rs`, `crates/engine/tests/addons_state.rs`. FILES TO OPEN: `spec.md` D4, `crates/engine/src/activity/outbox.rs` (versioned JSON pattern), `crates/engine/src/grammars/mod.rs` (`grammar_dir`, settings dir), `craft-libs/crates/atomic/src/lib.rs` (`atomic_write`, `write_new`). GOAL: `State { installed: BTreeMap<String, Installed> }` with `parse/to_json` (version 1, cap 512, unknown version `Err`), `Store::open(data_dir)`, `Store::version_dir(kind, id, version) -> PathBuf` built from validated components, `Store::gc(&State)` removing version dirs not in state and `staging/`, `commit(&State)` via `atomic_write`, `VaultFile { enabled, wanted }` with `parse/to_json` (cap 256 each). TESTS: round trip; corrupt JSON returns `Err` and leaves the file byte-identical; gc keeps `version` and `previous` and deletes the rest; commit is the only writer of `state.json`. VERIFY: standing. DO NOT: touch the vault; delete anything outside `<data dir>/registry/`.
- [ ] 5. Transport and bounded download.
- [x] 5. Transport and bounded download.
OWNS: `crates/engine/src/addons/net.rs`, `crates/engine/tests/addons_net.rs`. FILES TO OPEN: `crates/syntax/src/fetch.rs` (`Transport`, `Curl`), `crates/syntax/src/validate.rs` (`check_url`), `crates/engine/src/sync/` (how `ureq` is used), `spec.md` D5, D7. GOAL: `pub trait Transport { fn get(&self, url: &str, max_bytes: u64, if_none_match: Option<&str>) -> Result<Response>; }`, `Response { status, etag, bytes }` (304 allowed), `Ureq` implementation (https only, no redirects, total timeout, reads at most `max_bytes + 1` and fails above), `FakeTransport` for tests, `download_file(&dyn Transport, &File, cap) -> Result<Vec<u8>>` that checks host allow-list, size equals entry size, sha256 equals entry. TESTS: host not allow-listed; `http://`; credentials; IP literal; redirect status; body one byte too long; one byte short; wrong sha256; 304 keeps cache; error maps to `offline`. VERIFY: standing; no real network in tests. DO NOT: spawn `curl`; follow redirects; add a header with an identifier beyond `NotesCraft/<version>`.
- [ ] 6. Per-kind content checks.
- [x] 6. Per-kind content checks.
OWNS: `crates/engine/src/addons/kinds.rs`, `crates/engine/tests/addons_kinds.rs`. FILES TO OPEN: `spec.md` D8, `crates/syntax/src/validate.rs` (`check_grammar`), `crates/themes/src/load.rs` (`Theme::from_toml` diagnostics), `crates/plugins/src/manifest.rs`, `crates/plugins/src/lib.rs`. GOAL: `check(kind, &Entry, &[(path, bytes)]) -> Result<()>` dispatching to grammar, theme, plugin (manifest equals entry: id, version, permissions, command ids; wasm magic `\0asm`), font (magic bytes), snippet (UTF-8, no NUL), pack (each file by its subfolder). TESTS: one good and one bad fixture per kind; plugin manifest with extra permission fails; theme reusing a built-in id fails; `.tmLanguage` renamed to `.sublime-syntax` fails. VERIFY: standing. DO NOT: instantiate wasm here (task 12 does the dry load).
- [ ] 7. BLOCKED on 3. Install, update, remove, rollback pipeline.
- [x] 7. BLOCKED on 3. Install, update, remove, rollback pipeline.
OWNS: `crates/engine/src/addons/install.rs`, `crates/engine/tests/addons_install.rs`. FILES TO OPEN: `spec.md` D6, tasks 1 to 6 outputs, `crates/engine/src/grammars/mod.rs` (`save`). GOAL: `install(&Ctx, &Index, kind, id, version) -> Result<InstallReport>`, `update`, `remove`, `rollback` exactly as D6 steps 1 to 7; `Ctx { store, transport: &dyn Transport, verifier: &dyn Verifier, app_version, seed: Option<&SeedDir> }`; seed file used when its sha256 equals the entry. TESTS: the list in `spec.md` D13 IO bullet (hash mismatch, short and long body, crash before the state flip leaves the old state and gc removes the new dir, update keeps `previous`, rollback, remove, seed offline, revoked, app too old, bad signature, cap reached). VERIFY: standing. DO NOT: write anywhere but the store and `state.json`; delete the old version before the new state is committed.
- [ ] 8. Commands, gate, command list.
- [x] 8. Commands, gate, command list.
OWNS: `crates/engine/src/addons/commands.rs`, `crates/engine/src/addons/mod.rs`, one `mod addons;` in `crates/engine/src/lib.rs`, one `register_addons(reg)` call in `crates/engine/src/commands.rs::register_core`, `crates/engine/src/ai/assistant_gate.rs`, `crates/engine/tests/core_commands.rs` (`CORE_IDS`), `crates/engine/tests/addons_commands.rs`. FILES TO OPEN: `spec.md` D9, `crates/engine/src/bases/commands.rs` (group shape), `crates/engine/src/ai/assistant_gate.rs` (`READ_COMMANDS`, `WRITE_COMMANDS`, `DELETE_COMMANDS`), `docs/CODE-MAP.md`. GOAL: the nine commands of D9, error codes, session fields `addons_transport` and `addons_verifier` injectable like `grammar_transport`. TESTS: list joins state and cache; `registry.install` through the registry with a fake transport; read-only session refuses mutating ids; gate: `list|info|suggest` READ, `enable|disable` WRITE, the five others need `allow_delete`; offline `check` leaves the cache. VERIFY: standing + `--test core_commands --test addons_commands`. DO NOT: touch UI; change the `grammar.*` codes.
- [ ] 9. CLI sugar. OWNS: `apps/notescraft-cli/src/args.rs`, `apps/notescraft-cli/src/commands/addons.rs`, `apps/notescraft-cli/tests/registry.rs`. FILES TO OPEN: `apps/notescraft-cli/src/commands.rs`, `apps/notescraft-cli/src/commands/notes.rs`, `apps/notescraft-cli/src/output.rs`. GOAL: `notescraft registry list|info|check|install|update|remove|rollback` mapped to the commands, `--json` like the rest, exit code 3 only for write commands while the app holds the lock (`list` and `info` use `open_read_only`). TESTS: parse table; `list --json` shape; `install` against a fake transport env var is not allowed: test the argument-to-params mapping only. VERIFY: standing, `-p notescraft-cli`. DO NOT: add network code to the CLI.
@@ -41,19 +41,19 @@ One task = one agent (`specs/agents/opencode-runbook.md`). `[ ]` open. Spec: `sp
## Phase 3 - UI
- [ ] 14. Registry browser view. OWNS: `crates/ui-egui/src/views/registry_view/mod.rs`, `.../list.rs`, `.../detail.rs`, `crates/ui-egui/src/text/views/registry.rs`, `[ui.registry]` at the END of `crates/i18n/locales/en.toml`, `crates/ui-egui/tests/registry_view.rs`. FILES TO OPEN: `spec.md` D10, `crates/ui-egui/src/views/settings_view/categories.rs`, `crates/ui-egui/src/views/share_dialog/mod.rs` (modal pattern), `.claude/skills/notescraft-egui-screen/`. GOAL: the view returns `Action`s only (`Check`, `Install(kind,id)`, `Update`, `Remove`, `Rollback`, `Enable(id)`), draws rows by state, the drawer with permission sentences, the offline banner; no engine calls inside the view. TESTS: kittest: each state renders its button; Install emits one action; search filters; offline banner; permissions listed as sentences. VERIFY: standing + `cargo xtask i18n`. DO NOT: use raw egui buttons or inputs; hardcode strings; fetch from the view.
- [ ] 15. Shell wiring: category, commands, snapshot flag. OWNS: `crates/ui-egui/src/views/settings_view/categories.rs` (one Registry entry), `crates/ui-egui/src/shell/app/vault/registry.rs`, `crates/ui-egui/examples/snapshot.rs` (`--registry`). GOAL: actions call the engine commands on a worker thread (poll like `grammar_dialog.rs`), results update the view data; `registry.list` on open reads the cache only; Check/Install are the network triggers. TESTS: kittest: opening the category makes zero network calls (counter on the fake transport); Install runs one command. VERIFY: standing + snapshot PNG read. DO NOT: block the UI thread.
- [ ] 16. Code card, first-run card, update badge. OWNS: `crates/ui-egui/src/views/note_view/blocks/code_card.rs` (the Add-grammar link only), `crates/ui-egui/src/shell/app/vault/first_run.rs`, `crates/ui-egui/src/text/views/registry.rs` (append). GOAL: D10 bullets 5 and 6; card dismissal remembered in `UiState`. TESTS: code card shows "Install Twig grammar" when `registry.suggest` hits, the old dialog link otherwise; first-run card never fetches; badge only after a check. VERIFY: standing + `cargo xtask i18n`. DO NOT: install without a click, unless `registry.auto_install_for_vault` is on and the file comes from the seed.
- [x] 14. Registry browser view. OWNS: `crates/ui-egui/src/views/registry_view/mod.rs`, `.../list.rs`, `.../detail.rs`, `crates/ui-egui/src/text/views/registry.rs`, `[ui.registry]` at the END of `crates/i18n/locales/en.toml`, `crates/ui-egui/tests/registry_view.rs`. FILES TO OPEN: `spec.md` D10, `crates/ui-egui/src/views/settings_view/categories.rs`, `crates/ui-egui/src/views/share_dialog/mod.rs` (modal pattern), `.claude/skills/notescraft-egui-screen/`. GOAL: the view returns `Action`s only (`Check`, `Install(kind,id)`, `Update`, `Remove`, `Rollback`, `Enable(id)`), draws rows by state, the drawer with permission sentences, the offline banner; no engine calls inside the view. TESTS: kittest: each state renders its button; Install emits one action; search filters; offline banner; permissions listed as sentences. VERIFY: standing + `cargo xtask i18n`. DO NOT: use raw egui buttons or inputs; hardcode strings; fetch from the view.
- [x] 15. Shell wiring: category, commands, snapshot flag. OWNS: `crates/ui-egui/src/views/settings_view/categories.rs` (one Registry entry), `crates/ui-egui/src/shell/app/vault/registry.rs`, `crates/ui-egui/examples/snapshot.rs` (`--registry`). GOAL: actions call the engine commands on a worker thread (poll like `grammar_dialog.rs`), results update the view data; `registry.list` on open reads the cache only; Check/Install are the network triggers. TESTS: kittest: opening the category makes zero network calls (counter on the fake transport); Install runs one command. VERIFY: standing + snapshot PNG read. DO NOT: block the UI thread.
- [x] 16. Code card, first-run card, update badge. OWNS: `crates/ui-egui/src/views/note_view/blocks/code_card.rs` (the Add-grammar link only), `crates/ui-egui/src/shell/app/vault/first_run.rs`, `crates/ui-egui/src/text/views/registry.rs` (append). GOAL: D10 bullets 5 and 6; card dismissal remembered in `UiState`. TESTS: code card shows "Install Twig grammar" when `registry.suggest` hits, the old dialog link otherwise; first-run card never fetches; badge only after a check. VERIFY: standing + `cargo xtask i18n`. DO NOT: install without a click, unless `registry.auto_install_for_vault` is on and the file comes from the seed.
- [ ] 17. Vault `wanted` scan and banner. OWNS: `crates/engine/src/addons/wanted.rs`, `crates/engine/tests/addons_wanted.rs`, `crates/ui-egui/src/shell/app/vault/wanted_banner.rs`. FILES TO OPEN: `crates/engine/src/vault/session.rs`, the content-search code that uses `ignore`/`grep-*`, `spec.md` D11 step 4. GOAL: `scan_wanted(session, budget_ms) -> Vec<String>` over fence labels and `$`/`$$` in the vault with a 3 s budget, run once per vault and registry generation, written to `.notescraft/registry.json` `wanted`; banner lists not-installed entries with size. TESTS: a vault with a ```` ```twig ```` fence yields `grammar:twig`; budget stops a 6500-note scan early without error; installed items are not listed; no network. VERIFY: standing. DO NOT: read notes over 2 MiB; write outside `.notescraft/registry.json`.
## Phase 4 - first-party content and release
- [ ] 18. Registry repo layout and signing tool. OWNS: `xtask/src/registry_sign.rs`, one subcommand line in `xtask/src/main.rs`, `xtask/tests/registry_sign.rs`; outside this repo (separate commit by the lead): `notescraft-registry/README.md` (schema v2, `id` not `name`), `index.json`, `files/`. FILES TO OPEN: `spec.md` D1 to D3, `/Volumes/Storage/Projects/notescraft-registry/README.md`, `xtask/src/main.rs`. GOAL: `cargo xtask registry-sign --dir <registry checkout> --key <file>` rebuilds `index.json` from `files/<kind>/<id>/<version>/` plus a small `entry.toml` per item (title, license, aliases), fills size and sha256, signs; `--check` verifies an existing index. TESTS: sign then verify with the engine verifier; tampered file fails `--check`. VERIFY: standing, `-p xtask`. DO NOT: read the key from the repo; print it.
- [x] 18. Registry repo layout and signing tool. OWNS: `xtask/src/registry_sign.rs`, one subcommand line in `xtask/src/main.rs`, `xtask/tests/registry_sign.rs`; outside this repo (separate commit by the lead): `notescraft-registry/README.md` (schema v2, `id` not `name`), `index.json`, `files/`. FILES TO OPEN: `spec.md` D1 to D3, `/Volumes/Storage/Projects/notescraft-registry/README.md`, `xtask/src/main.rs`. GOAL: `cargo xtask registry-sign --dir <registry checkout> --key <file>` rebuilds `index.json` from `files/<kind>/<id>/<version>/` plus a small `entry.toml` per item (title, license, aliases), fills size and sha256, signs; `--check` verifies an existing index. TESTS: sign then verify with the engine verifier; tampered file fails `--check`. VERIFY: standing, `-p xtask`. DO NOT: read the key from the repo; print it.
- [ ] 19. Move the 25 grammars out. OWNS: `crates/syntax/src/embedded.rs` (shrinks to an empty list or removed), `crates/syntax/grammars/` (moved to the registry repo and to the seed), `crates/syntax/src/load.rs` (drop the embedded loop), `crates/engine/tests/addons_migration_grammars.rs`. GOAL: only after 7, 10, 16, 17, 18, 23 are done; registry entries `twig`, `blade`, ... one per file; tests per D11 step 5; the TOML and TypeScript grammars stay if the owner wants highlighting out of the box (they are the two most common: decide with the owner, default keep). TESTS: for every moved id: suggest finds it, seed verifies, offline install from the seed makes `is_known` true; removal restores plain text. VERIFY: standing + measure `load::current()` (expect about 75 ms instead of 110 ms) and binary delta (about -69 KB). DO NOT: remove a grammar that has no seed copy.
- [ ] 20. STIX Two Math as a font entry. OWNS: `crates/ui-egui/src/design/fonts/mod.rs` (STIX loaded from the store when present), `crates/ui-egui/src/views/math_view.rs` (Install button when absent), `assets/fonts/STIXTwoMath-Regular.ttf` (moves to the seed), tests. GOAL: after 7, 16, 18, 23; math without the font uses the existing Inter/JetBrains chain and shows "Install math font (376 KB)". TESTS: with and without the font; rendering never panics; the migration test of D11. VERIFY: binary delta about -0.36 MiB; snapshot of a note with display math both ways.
- [ ] 21. Lottie icons as a pack. OWNS: `crates/ui-egui/src/design/lottie_icon/files.rs` (store lookup, static fallback), `assets/icons-anim/` (moves to the seed), tests. GOAL: after 13, 18, 23; `icons-animated` pack; without it every animated icon draws its static Lucide icon (the skill `notescraft-icon-motion` rules still hold). TESTS: each icon id has a static fallback; motion tests run with the pack installed from a temp store. VERIFY: binary delta about -0.14 MiB. DO NOT: change any animation JSON.
- [ ] 22. First plugins and themes in the registry. OWNS (registry repo, plus this repo's tests only): `files/plugin/dice/1.0.0/`, `files/theme/{catppuccin-frappe,catppuccin-macchiato,solarized-light,solarized-dark}/1.0.0/` (themes copied from `crates/themes/src/builtin.rs` and kept built-in until a migration test passes), `plugins/dice/README.md` (point to the registry), `crates/engine/tests/addons_first_party.rs`. GOAL: dice builds with `plugins/dice/build.sh`, its `manifest.json` equals the entry; citations follows when roadmap 10 starts. TESTS: dice installs offline from the seed, rolls, `2d6+3` result in range. DO NOT: remove built-in themes in this task.
- [ ] 23. Seed packaging. OWNS: `packaging/macos/*`, `packaging/windows/*`, `packaging/linux/package.sh` (copy `registry-seed/` next to the binary), `crates/engine/src/addons/seed.rs`, `crates/engine/tests/addons_seed.rs`. FILES TO OPEN: `packaging/README.md`, `packaging/macos/`. GOAL: `SeedDir::locate()` finds `Contents/Resources/registry-seed` (macOS), `registry-seed/` beside the exe (Windows, Linux), verified signed snapshot of moved items only. TESTS: locate with a fake exe path; missing seed is fine (network install still works); tampered seed file rejected. DO NOT: put anything unsigned in the seed.
- [x] 23. Seed packaging. OWNS: `packaging/macos/*`, `packaging/windows/*`, `packaging/linux/package.sh` (copy `registry-seed/` next to the binary), `crates/engine/src/addons/seed.rs`, `crates/engine/tests/addons_seed.rs`. FILES TO OPEN: `packaging/README.md`, `packaging/macos/`. GOAL: `SeedDir::locate()` finds `Contents/Resources/registry-seed` (macOS), `registry-seed/` beside the exe (Windows, Linux), verified signed snapshot of moved items only. TESTS: locate with a fake exe path; missing seed is fine (network install still works); tampered seed file rejected. DO NOT: put anything unsigned in the seed.
- [ ] 24. Docs, numbers, status. OWNS: `ROADMAP.md`, `docs/ARCHITECTURE.md`, `docs/CODE-MAP.md` (addons rows), `SPEC.md` sections 6.1 and 7 (a "Registry" subsection, community store no longer deferred), `specs/registry/measurements.md` (new numbers), `specs/plugins-parity/roadmap.md` (add the rows). VERIFY: `cargo xtask ci`. DO NOT: add features.
## Order and parallelism
+6
View File
@@ -12,7 +12,13 @@ name = "xtask"
path = "src/main.rs"
[dependencies]
craft-atomic = { workspace = true }
notescraft-engine = { workspace = true }
ring = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
toml = { workspace = true }
[lints]
workspace = true
+3
View File
@@ -42,6 +42,9 @@ pub const TABLE: &[(&str, Class)] = &[
("math", Class::Layer(0)),
("lottie", Class::Layer(0)),
("acp", Class::Layer(0)),
("htmlmd", Class::Layer(0)),
("srs", Class::Layer(0)),
("textdiff", Class::Layer(0)),
("pdf", Class::Layer(1)),
// L1
("index", Class::Layer(1)),
+3
View File
@@ -9,6 +9,7 @@ mod layers;
mod lex;
#[cfg(test)]
mod lex_tests;
mod registry_sign;
mod rules;
mod tree;
mod version;
@@ -26,6 +27,7 @@ commands:
layers enforce the crate dependency layering (SPEC §3.1)
tree fail when a source directory holds more than 12 loose .rs files
ci verify, layers, tree, i18n --strict
registry-sign sign or --check the add-on registry index; `keygen --out <file>` makes a key (see registry_sign.rs)
ico ico OUT.ico IN.png... packs PNGs into a Windows .ico (packaging/icons.sh)
version print the workspace version; `version set X.Y.Z[-pre]` changes it
i18n scan ui-egui/src and apps/*/src for hardcoded user strings (SPEC §12);
@@ -40,6 +42,7 @@ fn main() -> ExitCode {
Some("tree") => tree::run(&root()),
Some("ci") => cmd_ci(),
Some("i18n") => cmd_i18n(&args),
Some("registry-sign") => registry_sign::run(&args.iter().skip(1).map(String::as_str).collect::<Vec<_>>()),
Some("ico") => ico::run(&args.iter().skip(1).map(String::as_str).collect::<Vec<_>>()),
Some("version") => version::run(&root(), &args.iter().skip(1).map(String::as_str).collect::<Vec<_>>()),
Some("-h" | "--help" | "help") | None => {
+365
View File
@@ -0,0 +1,365 @@
//! `cargo xtask registry-sign`: builds, signs and checks the add-on registry index (spec D1 to D3).
//!
//! - `registry-sign --dir <checkout> (--key <file> | --key-env <VAR>) [--key-id <id>]` rebuilds
//! `index.json` from `files/<kind>/<id>/entry.toml` and `files/<kind>/<id>/<version>/<file>`
//! (the highest version folder is the one indexed), signs every entry and writes the index.
//! Pulled releases (`revoked`) of an existing schema-2 index are kept.
//! - `registry-sign --check --dir <checkout> --public-key <base64> [--key-id <id>]` verifies the
//! index signatures with the engine verifier and every payload's size and sha256.
//! - `registry-sign keygen --out <file>` writes a new PKCS#8 ed25519 key (base64) to a path
//! outside this repository and prints only the public key (base64).
//!
//! The private key is read from a file or an environment variable and is never printed. A key
//! path inside this repository or the registry checkout is refused.
use std::path::{Component, Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use notescraft_engine::addons::{Ed25519, Entry, File, Index, Kind, Revoked, Signature, Verifier, Version, canonical_text};
use notescraft_engine::decode_base64;
use ring::rand::SystemRandom;
use ring::signature::{Ed25519KeyPair, KeyPair};
use serde::Deserialize;
use serde_json::{Value, json};
use self::encode::{encode_base64, sha256_hex};
use crate::root;
mod encode;
/// The index file at the checkout root.
const INDEX_FILE: &str = "index.json";
/// The payload folder at the checkout root.
const FILES_DIR: &str = "files";
/// The per-item metadata file, next to the version folders.
const ENTRY_FILE: &str = "entry.toml";
/// Key id written when `--key-id` is not given.
const DEFAULT_KEY_ID: &str = "k1";
/// Where the registry is served from; the raw tree of the Gitea repository.
const RAW_BASE: &str = "https://git.marcospaulo.dev.br/masi/notescraft-registry/raw/branch/main";
/// The only index schema this tool writes and reads.
const INDEX_VERSION: u64 = 2;
/// Byte length of an ed25519 public key.
const PUBLIC_KEY_BYTES: usize = 32;
/// Command-line options after the subcommand.
#[derive(Default)]
struct Options {
dir: Option<PathBuf>,
key: Option<PathBuf>,
key_env: Option<String>,
key_id: Option<String>,
public_key: Option<String>,
out: Option<PathBuf>,
check: bool,
}
/// `entry.toml`: the fields the folder layout cannot carry.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ItemMeta {
title: String,
#[serde(default)]
summary: String,
license: String,
source: Option<String>,
min_app_version: String,
#[serde(default)]
aliases: Vec<String>,
#[serde(default)]
suggested: bool,
#[serde(default)]
permissions: Vec<String>,
}
/// Runs `registry-sign` with the arguments after the subcommand name.
///
/// # Errors
/// A message for the user: bad options, a key inside the repository, an unreadable or malformed
/// registry, or a failed verification.
pub fn run(args: &[&str]) -> Result<(), String> {
if let Some(&"keygen") = args.first() {
return keygen(&parse(args.get(1..).unwrap_or_default())?);
}
let options = parse(args)?;
if options.check { check(&options) } else { sign(&options) }
}
fn parse(args: &[&str]) -> Result<Options, String> {
let mut options = Options::default();
let mut iter = args.iter();
while let Some(&flag) = iter.next() {
match flag {
"--check" => options.check = true,
"--dir" => options.dir = Some(value(&mut iter, flag)?.into()),
"--key" => options.key = Some(value(&mut iter, flag)?.into()),
"--key-env" => options.key_env = Some(value(&mut iter, flag)?.to_owned()),
"--key-id" => options.key_id = Some(value(&mut iter, flag)?.to_owned()),
"--public-key" => options.public_key = Some(value(&mut iter, flag)?.to_owned()),
"--out" => options.out = Some(value(&mut iter, flag)?.into()),
other => return Err(format!("unknown option `{other}`")),
}
}
Ok(options)
}
fn value<'a>(iter: &mut std::slice::Iter<'_, &'a str>, flag: &str) -> Result<&'a str, String> {
iter.next().copied().ok_or_else(|| format!("{flag} needs a value"))
}
fn sign(options: &Options) -> Result<(), String> {
let dir = required(options.dir.as_ref(), "--dir")?;
let key_id = options.key_id.as_deref().unwrap_or(DEFAULT_KEY_ID);
let pair = load_key(options, dir)?;
let public_key: [u8; PUBLIC_KEY_BYTES] = pair.public_key().as_ref().try_into().map_err(|_| "public key has the wrong length".to_owned())?;
let mut entries = scan(dir)?;
for entry in &mut entries {
let sig = pair.sign(canonical_text(entry).as_bytes());
entry.signature = Signature { key_id: key_id.to_owned(), sig: encode_base64(sig.as_ref()) };
}
let bytes = index_bytes(&entries, &existing_revoked(&dir.join(INDEX_FILE))?)?;
craft_atomic::atomic_write(&dir.join(INDEX_FILE), &bytes).map_err(|e| format!("cannot write index.json: {e}"))?;
println!("signed {} entries with key `{key_id}` (public key {})", entries.len(), encode_base64(&public_key));
println!("wrote {}", dir.join(INDEX_FILE).display());
Ok(())
}
fn check(options: &Options) -> Result<(), String> {
let dir = required(options.dir.as_ref(), "--dir")?;
let key_text = required(options.public_key.as_ref(), "--public-key")?;
let key: [u8; PUBLIC_KEY_BYTES] =
decode_base64(key_text).map_err(|e| e.to_string())?.try_into().map_err(|_| "public key must be 32 bytes of base64".to_owned())?;
let key_id = options.key_id.as_deref().unwrap_or(DEFAULT_KEY_ID);
let bytes = std::fs::read(dir.join(INDEX_FILE)).map_err(|e| format!("cannot read index.json: {e}"))?;
let index = Index::parse(&bytes).map_err(|e| e.to_string())?;
if !index.skipped.is_empty() {
return Err(format!("index has refused entries: {}", index.skipped.join("; ")));
}
let verifier = Ed25519::new(vec![(key_id.to_owned(), key)]);
for entry in &index.entries {
verifier.verify(entry).map_err(|e| e.to_string())?;
for file in &entry.files {
check_payload(dir, entry, file)?;
}
}
println!("OK: {} entries verified against key `{key_id}`", index.entries.len());
Ok(())
}
/// Compares one payload file on disk with the size and hash the index records.
fn check_payload(dir: &Path, entry: &Entry, file: &File) -> Result<(), String> {
let path = payload_path(dir, entry.kind, &entry.id, entry.version, &file.path)?;
let bytes = std::fs::read(&path).map_err(|e| format!("{}: cannot read: {e}", path.display()))?;
if u64::try_from(bytes.len()).ok() != Some(file.size) {
return Err(format!("{}: size differs from the index", path.display()));
}
if sha256_hex(&bytes) != file.sha256 {
return Err(format!("{}: sha256 differs from the index", path.display()));
}
Ok(())
}
/// Refuses a payload name that is not a single normal path component.
fn payload_path(dir: &Path, kind: Kind, id: &str, version: Version, name: &str) -> Result<PathBuf, String> {
let mut parts = Path::new(name).components();
if !matches!((parts.next(), parts.next()), (Some(Component::Normal(_)), None)) {
return Err(format!("payload name `{name}` is not a plain file name"));
}
Ok(dir.join(FILES_DIR).join(kind.as_str()).join(id).join(version.to_string()).join(name))
}
fn keygen(options: &Options) -> Result<(), String> {
let out = required(options.out.as_ref(), "--out")?;
let out = resolve(out)?;
refuse_inside(&out, &[root()])?;
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).map_err(|_| "cannot generate a key".to_owned())?;
let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).map_err(|_| "generated key is invalid".to_owned())?;
let text = format!("{}\n", encode_base64(pkcs8.as_ref()));
craft_atomic::write_new(&out, text.as_bytes()).map_err(|e| format!("cannot write {}: {e}", out.display()))?;
eprintln!("wrote private key to {} (owner only, outside the repository)", out.display());
println!("{}", encode_base64(pair.public_key().as_ref()));
Ok(())
}
/// The private key, from `--key <file>` or `--key-env <VAR>`; never echoed.
fn load_key(options: &Options, dir: &Path) -> Result<Ed25519KeyPair, String> {
let text = match (&options.key, &options.key_env) {
(Some(path), None) => {
refuse_inside(&resolve(path)?, &[root(), dir.to_path_buf()])?;
std::fs::read_to_string(path).map_err(|e| format!("cannot read the key file: {e}"))?
}
(None, Some(name)) => std::env::var(name).map_err(|_| format!("environment variable `{name}` is not set"))?,
_ => return Err("give exactly one of --key <file> or --key-env <VAR>".to_owned()),
};
let der = decode_base64(text.trim()).map_err(|_| "the key is not base64 PKCS#8".to_owned())?;
Ed25519KeyPair::from_pkcs8(&der).map_err(|_| "the key is not an ed25519 PKCS#8 key".to_owned())
}
/// Absolute form of `path`; a file that does not exist yet is resolved through its folder.
fn resolve(path: &Path) -> Result<PathBuf, String> {
if path.exists() {
return std::fs::canonicalize(path).map_err(|e| format!("{}: {e}", path.display()));
}
let parent = match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent,
_ => Path::new("."),
};
let name = path.file_name().ok_or_else(|| format!("{}: not a file path", path.display()))?;
Ok(std::fs::canonicalize(parent).map_err(|e| format!("{}: {e}", parent.display()))?.join(name))
}
/// Refuses a path under any of `repos`, so a key cannot end up in a repository.
fn refuse_inside(path: &Path, repos: &[PathBuf]) -> Result<(), String> {
for repo in repos {
let repo = std::fs::canonicalize(repo).map_err(|e| format!("{}: {e}", repo.display()))?;
if path.starts_with(&repo) {
return Err(format!("refusing a key path inside a repository ({})", repo.display()));
}
}
Ok(())
}
fn required<'a, T>(option: Option<&'a T>, flag: &str) -> Result<&'a T, String> {
option.ok_or_else(|| format!("{flag} is required"))
}
/// Unsigned entries from the checkout's folder layout, sorted by kind then id.
fn scan(dir: &Path) -> Result<Vec<Entry>, String> {
let files = dir.join(FILES_DIR);
if !files.is_dir() {
return Err(format!("{} has no {FILES_DIR}/ folder", dir.display()));
}
let mut entries = Vec::new();
for kind_dir in subdirs(&files)? {
let kind_name = name_of(&kind_dir)?;
let kind = Kind::parse(&kind_name).ok_or_else(|| format!("unknown kind folder `{kind_name}`"))?;
for id_dir in subdirs(&kind_dir)? {
entries.push(scan_item(kind, &name_of(&id_dir)?, &id_dir)?);
}
}
entries.sort_by(|a, b| a.kind.as_str().cmp(b.kind.as_str()).then_with(|| a.id.cmp(&b.id)));
Ok(entries)
}
/// One item: `entry.toml` plus the highest version folder's files.
fn scan_item(kind: Kind, id: &str, id_dir: &Path) -> Result<Entry, String> {
let meta_path = id_dir.join(ENTRY_FILE);
let meta: ItemMeta = toml::from_str(&std::fs::read_to_string(&meta_path).map_err(|e| format!("{}: {e}", meta_path.display()))?)
.map_err(|e| format!("{}: {e}", meta_path.display()))?;
let version = subdirs(id_dir)?
.iter()
.map(|sub| name_of(sub).and_then(|name| Version::parse(&name).map_err(|e| e.to_string())))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.max()
.ok_or_else(|| format!("{id}: no version folder"))?;
let version_dir = id_dir.join(version.to_string());
let mut files = Vec::new();
for path in files_in(&version_dir)? {
let name = name_of(&path)?;
let size = std::fs::metadata(&path).map_err(|e| format!("{}: {e}", path.display()))?.len();
let sha256 = sha256_hex(&std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?);
let url = format!("{RAW_BASE}/{FILES_DIR}/{}/{id}/{version}/{name}", kind.as_str());
files.push(File { path: name, size, sha256, url });
}
if files.is_empty() {
return Err(format!("{id}: version {version} has no files"));
}
Ok(Entry {
id: id.to_owned(),
kind,
title: meta.title,
summary: meta.summary,
version,
min_app_version: Version::parse(&meta.min_app_version).map_err(|e| format!("{id}: {e}"))?,
license: meta.license,
source: meta.source,
aliases: meta.aliases,
suggested: meta.suggested,
permissions: meta.permissions,
files,
signature: Signature { key_id: String::new(), sig: String::new() },
})
}
/// Sorted sub-folders of `dir`; plain files are ignored.
fn subdirs(dir: &Path) -> Result<Vec<PathBuf>, String> {
let mut out = Vec::new();
for item in std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? {
let path = item.map_err(|e| format!("{}: {e}", dir.display()))?.path();
if path.is_dir() {
out.push(path);
}
}
out.sort();
Ok(out)
}
/// Sorted regular files directly inside `dir`; sub-folders are refused, payloads are flat.
fn files_in(dir: &Path) -> Result<Vec<PathBuf>, String> {
let mut out = Vec::new();
for item in std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? {
let path = item.map_err(|e| format!("{}: {e}", dir.display()))?.path();
if path.is_dir() {
return Err(format!("{}: sub-folders are not allowed in a version folder", path.display()));
}
if path.is_file() {
out.push(path);
}
}
out.sort();
Ok(out)
}
fn name_of(path: &Path) -> Result<String, String> {
path.file_name().and_then(|name| name.to_str()).map(str::to_owned).ok_or_else(|| format!("{}: name is not UTF-8", path.display()))
}
/// The `revoked` list of an existing schema-2 index; an older or missing index has none.
fn existing_revoked(index: &Path) -> Result<Vec<Revoked>, String> {
if !index.is_file() {
return Ok(Vec::new());
}
let bytes = std::fs::read(index).map_err(|e| format!("cannot read index.json: {e}"))?;
let schema: Value = serde_json::from_slice(&bytes).map_err(|e| format!("index.json is not JSON: {e}"))?;
if schema.get("version").and_then(Value::as_u64) != Some(INDEX_VERSION) {
return Ok(Vec::new());
}
Index::parse(&bytes).map(|parsed| parsed.revoked).map_err(|e| e.to_string())
}
/// The serialized index. Refuses output that the engine's own validator would skip an entry of.
fn index_bytes(entries: &[Entry], revoked: &[Revoked]) -> Result<Vec<u8>, String> {
let generated_at = SystemTime::now().duration_since(UNIX_EPOCH).map_err(|e| format!("clock: {e}"))?.as_secs();
let document = json!({
"version": INDEX_VERSION,
"generated_at": generated_at.to_string(),
"entries": entries.iter().map(entry_json).collect::<Vec<_>>(),
"revoked": revoked.iter().map(|r| json!({ "id": r.id, "version": r.version.to_string() })).collect::<Vec<_>>(),
});
let mut bytes = serde_json::to_vec_pretty(&document).map_err(|e| e.to_string())?;
bytes.push(b'\n');
let parsed = Index::parse(&bytes).map_err(|e| e.to_string())?;
if !parsed.skipped.is_empty() {
return Err(format!("the engine refuses entries: {}", parsed.skipped.join("; ")));
}
Ok(bytes)
}
fn entry_json(entry: &Entry) -> Value {
json!({
"id": entry.id,
"kind": entry.kind.as_str(),
"title": entry.title,
"summary": entry.summary,
"version": entry.version.to_string(),
"min_app_version": entry.min_app_version.to_string(),
"license": entry.license,
"source": entry.source,
"aliases": entry.aliases,
"suggested": entry.suggested,
"permissions": entry.permissions,
"files": entry.files.iter().map(|f| json!({ "path": f.path, "size": f.size, "sha256": f.sha256, "url": f.url })).collect::<Vec<_>>(),
"signature": { "key_id": entry.signature.key_id, "sig": entry.signature.sig },
})
}
+46
View File
@@ -0,0 +1,46 @@
//! Hashing and base64 for the registry tool: a SHA-256 hex digest and standard base64 with padding.
use std::fmt::Write as _;
use sha2::{Digest, Sha256};
/// Standard base64 alphabet (RFC 4648).
const BASE64_ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Bits per base64 symbol.
const SYMBOL_BITS: u32 = 6;
/// Mask of one base64 symbol.
const SYMBOL_MASK: u32 = 0x3f;
/// Bits of a full base64 group, three bytes.
const GROUP_BITS: u32 = 24;
/// Base64 symbols per three-byte group.
const GROUP_SYMBOLS: usize = 4;
/// Bytes per base64 group.
const GROUP_BYTES: usize = 3;
/// Lowercase hex SHA-256 of `bytes`.
pub fn sha256_hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(Sha256::output_size() * 2);
for byte in Sha256::digest(bytes) {
let _ = write!(out, "{byte:02x}");
}
out
}
/// Standard base64 with padding.
pub fn encode_base64(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len().div_ceil(GROUP_BYTES) * GROUP_SYMBOLS);
for chunk in bytes.chunks(GROUP_BYTES) {
let byte = |i: usize| chunk.get(i).copied().map_or(0, u32::from);
let group = (byte(0) << 16) | (byte(1) << 8) | byte(2);
for symbol in 0..GROUP_SYMBOLS {
if symbol > chunk.len() {
out.push('=');
continue;
}
let shift = GROUP_BITS - SYMBOL_BITS * u32::try_from(symbol).unwrap_or(0) - SYMBOL_BITS;
let index = usize::try_from((group >> shift) & SYMBOL_MASK).unwrap_or(0);
out.push(BASE64_ALPHABET.get(index).copied().map_or('=', char::from));
}
}
out
}
+110
View File
@@ -0,0 +1,110 @@
//! Integration tests for `cargo xtask registry-sign`: sign, verify with the engine, tamper, refuse.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use notescraft_engine::addons::{Ed25519, Index, Kind, Verifier};
use notescraft_engine::decode_base64;
/// Key id the tool signs with by default.
const KEY_ID: &str = "k1";
/// This crate's manifest, which lives inside the repository.
const REPO_CARGO_TOML: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml");
/// A fresh scratch directory outside the repository.
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("registry-sign-test-{}-{name}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn xtask(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_xtask")).arg("registry-sign").args(args).output().unwrap()
}
fn path_arg(path: &Path) -> &str {
path.to_str().unwrap()
}
/// A registry checkout with one grammar that has two versions; `1.0.0` is the one indexed.
fn fixture(root: &Path) {
let item = root.join("files/grammar/twig");
fs::create_dir_all(item.join("1.0.0")).unwrap();
fs::create_dir_all(item.join("0.9.0")).unwrap();
fs::write(item.join("entry.toml"), "title = \"Twig\"\nlicense = \"MIT\"\nmin_app_version = \"0.1.0\"\naliases = [\"twig\"]\n").unwrap();
fs::write(item.join("0.9.0/twig.sublime-syntax"), "old\n").unwrap();
fs::write(item.join("1.0.0/twig.sublime-syntax"), "name: Twig\n").unwrap();
}
/// Generates a key outside the repository and returns `(key file, public key base64)`.
fn keygen(dir: &Path) -> (PathBuf, String) {
let key = dir.join("owner.key");
let out = xtask(&["keygen", "--out", path_arg(&key)]);
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let public = String::from_utf8(out.stdout).unwrap().trim().to_owned();
(key, public)
}
fn public_bytes(public: &str) -> [u8; 32] {
decode_base64(public).unwrap().try_into().unwrap()
}
#[test]
fn keygen_prints_only_the_public_key_and_refuses_to_overwrite() {
let dir = scratch("keygen");
let (key, public) = keygen(&dir);
assert_eq!(public_bytes(&public).len(), 32);
let again = xtask(&["keygen", "--out", path_arg(&key)]);
assert!(!again.status.success(), "a second keygen must not replace the key");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn signed_index_verifies_with_the_engine_and_check_catches_tampering() {
let dir = scratch("sign");
let reg = dir.join("registry");
fixture(&reg);
let (key, public) = keygen(&dir);
let signed = xtask(&["--dir", path_arg(&reg), "--key", path_arg(&key)]);
assert!(signed.status.success(), "{}", String::from_utf8_lossy(&signed.stderr));
let index = Index::parse(&fs::read(reg.join("index.json")).unwrap()).unwrap();
assert!(index.skipped.is_empty(), "{:?}", index.skipped);
let entry = index.find(Kind::Grammar, "twig").expect("twig is indexed");
assert_eq!(entry.version.to_string(), "1.0.0", "the highest version folder is indexed");
Ed25519::new(vec![(KEY_ID.to_owned(), public_bytes(&public))]).verify(entry).expect("the signature verifies");
let check = |extra: &[&str]| {
let mut args = vec!["--check", "--dir", path_arg(&reg), "--public-key", public.as_str()];
args.extend_from_slice(extra);
xtask(&args)
};
assert!(check(&[]).status.success());
fs::write(reg.join("files/grammar/twig/1.0.0/twig.sublime-syntax"), "name: Tw1g\n").unwrap();
let tampered = check(&[]);
assert!(!tampered.status.success());
assert!(String::from_utf8_lossy(&tampered.stderr).contains("differs from the index"));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn key_inside_the_repository_is_refused_and_nothing_is_written() {
let dir = scratch("refuse");
let reg = dir.join("registry");
fixture(&reg);
let out = xtask(&["--dir", path_arg(&reg), "--key", REPO_CARGO_TOML]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("inside a repository"));
assert!(!reg.join("index.json").exists());
let in_repo = concat!(env!("CARGO_MANIFEST_DIR"), "/should-not-exist.key");
let made = xtask(&["keygen", "--out", in_repo]);
assert!(!made.status.success());
assert!(!Path::new(in_repo).exists());
fs::remove_dir_all(&dir).unwrap();
}