mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
fix(desktop): embed the Project Canvas seed template in the binary
Seeding a project's first canvas read `resources/project-canvas-template` from disk at activation time, resolved from the running executable's directory in dev builds. That directory is the cargo target directory — shared across worktrees, rewritten by sibling builds, and deleted wholesale by toolchain cleanups. When it disappeared under a running app the first-ever activation of `buzz-bugs` failed with `inspect project canvas path: No such file or directory (os error 2)` and could not self-heal. Make the seed bytes part of the binary and drop the on-disk copy, so no runtime lookup exists to go stale. This follows `resources/pocket-voices`, which already embeds 7.6 MB of `.wav` through `include_bytes!` and is deliberately absent from `bundle.resources`. The template is consumed only as seed bytes: agents author canvases from `CANVASES/index.json` → `sourcePath`, never from the template, so the disk copy had no remaining consumer. - New `project_canvas_package/template.rs` embeds the tree with `include_dir!` and walks it into a path-keyed map, running every key through `validate_relative_path` and skipping `.DS_Store` exactly as the on-disk scan does. The validated package is cached in a `OnceLock` so the SHA-256 over 2.7 MB runs once per process, and validation happens on the blocking task rather than the command's async caller. - `build.rs` gains `cargo:rerun-if-changed=resources/project-canvas-template`. `include_dir!` expands to one `include_bytes!` per file, so rustc notices edits but not a file added or removed; without this line the binary can ship a stale file list. - `scan_package` splits into `ensure_package_dir` + `validate_package_files`. The latter is now the single validation gate for both disk-read and embedded packages — there is no second, weaker path for the template. `prepare_snapshot`, `seed_if_missing`, `get_or_activate`, and `activate` take `Option<&ValidatedPackage>`; `accept_agent_update` still passes `None` so an agent update can never seed a canvas that does not exist. - Delete `template_path`/`template_path_from`, the `AppHandle` parameter they forced onto two commands, and `bundle.resources` in `tauri.conf.json`. With no on-disk copy there is no second source of truth to go stale, and the `.app` bundle shrinks by 2.7 MB. - `ensure_package_dir` replaces `ensure_no_symlink` + `Path::is_dir()` with one non-following `symlink_metadata`, so an absent directory is named instead of dying unnamed inside the metadata call. Strictly stronger: the symlink check still runs on non-following metadata, and the directory test now reads off that metadata rather than a link-following `is_dir()`. Tests: `bundled_template_seeds_a_valid_snapshot` and `first_activation_seeds_the_validated_template` repoint at the embedded package and an in-memory map. New `bundled_template_contains_the_expected_tree` compares the embedded key set against a test-time walk of the source tree, `bundled_template_paths_are_package_safe` asserts every key is relative and traversal-free, `missing_package_directory_names_the_path` pins the new error ordering, and `package_root_symlink_is_rejected` covers the package root itself, which no existing test did. Each was checked falsifiable: dropping one embedded file fails the first two, restoring the old ordering fails the third, and removing the symlink arm fails the fourth. Verified: Tauri suite passes 3077 tests, clippy -D warnings, rustfmt, and the file-size gate are clean. `cargo build --bin buzz-desktop` emits no `resources/` directory next to the executable, so the built binary has no on-disk template to read. A live canvas open was not exercised — that needs an interactive `just staging` session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
20
desktop/src-tauri/Cargo.lock
generated
20
desktop/src-tauri/Cargo.lock
generated
@@ -1110,6 +1110,7 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"hex",
|
||||
"image",
|
||||
"include_dir",
|
||||
"infer",
|
||||
"iroh",
|
||||
"keyring",
|
||||
@@ -4249,6 +4250,25 @@ version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
|
||||
|
||||
[[package]]
|
||||
name = "include_dir"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
|
||||
dependencies = [
|
||||
"include_dir_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "include_dir_macros"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
|
||||
@@ -81,6 +81,11 @@ tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
infer = "0.19"
|
||||
hex = "0.4"
|
||||
# Compile-time embedding of the Project Canvas seed template. Keep the
|
||||
# `cargo:rerun-if-changed` line in build.rs alongside it: `include_dir!` expands
|
||||
# to `include_bytes!` per file, so rustc notices edits to existing files but not
|
||||
# files being added or removed.
|
||||
include_dir = "0.7"
|
||||
ed25519-dalek = "=3.0.0-rc.0"
|
||||
tokio = { version = "1", features = ["fs", "sync", "rt", "macros", "time", "net", "io-util"] }
|
||||
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
|
||||
|
||||
@@ -20,6 +20,11 @@ fn main() {
|
||||
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY");
|
||||
println!("cargo:rerun-if-env-changed=BUZZ_BUILD_DEMO_SLUG");
|
||||
println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)");
|
||||
// `include_dir!` embeds the Project Canvas seed template through one
|
||||
// `include_bytes!` per file, so rustc already tracks edits to existing
|
||||
// files — but not a file being added or removed. Without this the binary
|
||||
// can silently ship a stale file list.
|
||||
println!("cargo:rerun-if-changed=resources/project-canvas-template");
|
||||
|
||||
if let Ok(slug) = std::env::var("BUZZ_BUILD_DEMO_SLUG") {
|
||||
let valid = !slug.is_empty()
|
||||
|
||||
@@ -3,6 +3,7 @@ mod manifest;
|
||||
mod path_security;
|
||||
mod protocol;
|
||||
mod storage;
|
||||
mod template;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -22,7 +23,9 @@ use storage::{
|
||||
active_snapshot, clear_committed_updates, commit_snapshot, pending_updates, prepare_snapshot,
|
||||
project_source_location, prune_revisions, record_pending_update, record_source_binding,
|
||||
snapshot_for_revision, validate_widget_id, ProjectBinding, ProjectCanvasSourceLocation,
|
||||
ValidatedPackage,
|
||||
};
|
||||
use template::bundled_template;
|
||||
|
||||
const MAX_ACTIVE_LOADS: usize = 64;
|
||||
|
||||
@@ -65,7 +68,7 @@ impl ProjectCanvasRuntime {
|
||||
fn get_or_activate(
|
||||
&self,
|
||||
request: ProjectCanvasPackageRequest,
|
||||
template: &std::path::Path,
|
||||
template: Option<&ValidatedPackage>,
|
||||
) -> Result<ProjectCanvasPackageDescriptor, String> {
|
||||
let binding = ProjectBinding::parse(request)?;
|
||||
ensure_supported_platform()?;
|
||||
@@ -77,7 +80,7 @@ impl ProjectCanvasRuntime {
|
||||
let root = self.root()?;
|
||||
let snapshot = match active_snapshot(&root, &binding)? {
|
||||
Some(snapshot) => snapshot,
|
||||
None => prepare_snapshot(&root, &binding, Some(template))?,
|
||||
None => prepare_snapshot(&root, &binding, template)?,
|
||||
};
|
||||
let mut retained = self.referenced_revisions(&binding)?;
|
||||
retained.insert(snapshot.revision.clone());
|
||||
@@ -91,7 +94,7 @@ impl ProjectCanvasRuntime {
|
||||
fn activate(
|
||||
&self,
|
||||
request: ProjectCanvasPackageRequest,
|
||||
template: &std::path::Path,
|
||||
template: Option<&ValidatedPackage>,
|
||||
) -> Result<ProjectCanvasPackageDescriptor, String> {
|
||||
let binding = ProjectBinding::parse(request)?;
|
||||
ensure_supported_platform()?;
|
||||
@@ -100,7 +103,7 @@ impl ProjectCanvasRuntime {
|
||||
.lock()
|
||||
.map_err(|_| "project canvas activation lock is unavailable".to_string())?;
|
||||
let root = self.root()?;
|
||||
let snapshot = prepare_snapshot(&root, &binding, Some(template))?;
|
||||
let snapshot = prepare_snapshot(&root, &binding, template)?;
|
||||
let mut retained = self.referenced_revisions(&binding)?;
|
||||
retained.insert(snapshot.revision.clone());
|
||||
prune_revisions(&root, &binding, &retained)?;
|
||||
@@ -395,12 +398,14 @@ struct ProjectCanvasPendingPresentation {
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_project_canvas_package(
|
||||
request: ProjectCanvasPackageRequest,
|
||||
app: AppHandle,
|
||||
runtime: State<'_, ProjectCanvasRuntime>,
|
||||
) -> Result<ProjectCanvasPackageDescriptor, String> {
|
||||
let template = template_path(&app)?;
|
||||
let runtime = runtime.inner().clone();
|
||||
run_blocking(move || runtime.get_or_activate(request, &template)).await
|
||||
run_blocking(move || {
|
||||
let template = bundled_template()?;
|
||||
runtime.get_or_activate(request, Some(&template))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -415,12 +420,14 @@ pub(crate) async fn get_project_canvas_updates(
|
||||
#[tauri::command]
|
||||
pub(crate) async fn activate_project_canvas_package(
|
||||
request: ProjectCanvasPackageRequest,
|
||||
app: AppHandle,
|
||||
runtime: State<'_, ProjectCanvasRuntime>,
|
||||
) -> Result<ProjectCanvasPackageDescriptor, String> {
|
||||
let template = template_path(&app)?;
|
||||
let runtime = runtime.inner().clone();
|
||||
run_blocking(move || runtime.activate(request, &template)).await
|
||||
run_blocking(move || {
|
||||
let template = bundled_template()?;
|
||||
runtime.activate(request, Some(&template))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -474,38 +481,6 @@ pub(crate) fn start_agent_update_listener(app: AppHandle) -> Result<(), String>
|
||||
ipc::start(app)
|
||||
}
|
||||
|
||||
fn template_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let resource_root = app.path().resource_dir().map_err(|error| error.to_string());
|
||||
let dev_exe_dir = if cfg!(debug_assertions) {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(std::path::Path::to_path_buf))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
template_path_from(resource_root, dev_exe_dir)
|
||||
}
|
||||
|
||||
/// Joins the bundled `project-canvas-template` resource against the app's
|
||||
/// resource root.
|
||||
///
|
||||
/// Dev builds run the bare executable out of the cargo target directory,
|
||||
/// where `tauri-build` copies `bundle.resources` next to the binary — but
|
||||
/// Tauri only recognizes that layout when the directory is literally named
|
||||
/// `target`, so a redirected CARGO_TARGET_DIR makes `resource_dir()` fail
|
||||
/// with "unknown path" even though the resources are present. Fall back to
|
||||
/// the executable's directory there; release builds pass `dev_exe_dir: None`
|
||||
/// and keep failing loudly.
|
||||
fn template_path_from(
|
||||
resource_root: Result<PathBuf, String>,
|
||||
dev_exe_dir: Option<PathBuf>,
|
||||
) -> Result<PathBuf, String> {
|
||||
resource_root
|
||||
.or_else(|error| dev_exe_dir.ok_or(error))
|
||||
.map(|root| root.join("resources").join("project-canvas-template"))
|
||||
.map_err(|error| format!("resolve project canvas template: {error}"))
|
||||
}
|
||||
|
||||
async fn run_blocking<T, F>(task: F) -> Result<T, String>
|
||||
where
|
||||
T: Send + 'static,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs::{self, OpenOptions},
|
||||
io::Write,
|
||||
io::{ErrorKind, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
@@ -129,7 +129,7 @@ pub(super) struct ValidatedSnapshot {
|
||||
pub(super) data: serde_json::Value,
|
||||
}
|
||||
|
||||
struct ValidatedPackage {
|
||||
pub(super) struct ValidatedPackage {
|
||||
files: BTreeMap<String, Vec<u8>>,
|
||||
revision: String,
|
||||
manifest: ValidatedManifest,
|
||||
@@ -262,7 +262,7 @@ pub(super) fn snapshot_for_revision(
|
||||
pub(super) fn prepare_snapshot(
|
||||
canvas_root: &Path,
|
||||
binding: &ProjectBinding,
|
||||
template: Option<&Path>,
|
||||
template: Option<&ValidatedPackage>,
|
||||
) -> Result<ValidatedSnapshot, String> {
|
||||
let canvas_root = canonical_canvas_root(canvas_root, true)?
|
||||
.ok_or_else(|| "project canvas root was not created".to_string())?;
|
||||
@@ -597,7 +597,7 @@ fn validate_index(canvas_root: &Path, index: &CanvasIndex) -> Result<(), String>
|
||||
fn seed_if_missing(
|
||||
canvas_root: &Path,
|
||||
project_root: &Path,
|
||||
template: Option<&Path>,
|
||||
template: Option<&ValidatedPackage>,
|
||||
) -> Result<(), String> {
|
||||
if project_root.join("manifest.json").is_file() {
|
||||
ensure_secure_descendant(canvas_root, project_root, false)?;
|
||||
@@ -619,8 +619,7 @@ fn seed_if_missing(
|
||||
.map_err(|error| format!("remove empty project canvas directory: {error}"))?;
|
||||
}
|
||||
|
||||
let template = template.ok_or_else(|| "project canvas template is unavailable".to_string())?;
|
||||
let package = scan_package(template, template)?;
|
||||
let package = template.ok_or_else(|| "project canvas template is unavailable".to_string())?;
|
||||
let parent = project_root
|
||||
.parent()
|
||||
.ok_or_else(|| "project canvas directory has no parent".to_string())?;
|
||||
@@ -642,14 +641,53 @@ fn seed_if_missing(
|
||||
}
|
||||
|
||||
fn scan_package(trusted_root: &Path, root: &Path) -> Result<ValidatedPackage, String> {
|
||||
ensure_no_symlink(root)?;
|
||||
if !root.is_dir() {
|
||||
ensure_package_dir(root)?;
|
||||
validate_package_files(read_package_tree(trusted_root, root)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn scan_package_for_test(trusted_root: &Path, root: &Path) -> Result<(), String> {
|
||||
scan_package(trusted_root, root).map(|_| ())
|
||||
}
|
||||
|
||||
/// One non-following inspection covering existence, symlink, and directory.
|
||||
///
|
||||
/// `symlink_metadata` names the path it failed on, where the previous
|
||||
/// `ensure_no_symlink` + `Path::is_dir()` pair died inside the metadata call
|
||||
/// with an unnamed `os error 2`. It is also strictly stronger: the directory
|
||||
/// test now reads off the non-following metadata rather than `Path::is_dir()`,
|
||||
/// which follows links.
|
||||
fn ensure_package_dir(root: &Path) -> Result<(), String> {
|
||||
let metadata = match fs::symlink_metadata(root) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
return Err(format!(
|
||||
"project canvas package directory does not exist: {}",
|
||||
root.display()
|
||||
))
|
||||
}
|
||||
Err(error) => return Err(format!("inspect project canvas path: {error}")),
|
||||
};
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(format!(
|
||||
"project canvas package directory does not exist: {}",
|
||||
"project canvas paths cannot be symlinks: {}",
|
||||
root.display()
|
||||
));
|
||||
}
|
||||
let files = read_package_tree(trusted_root, root)?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(format!(
|
||||
"project canvas package is not a directory: {}",
|
||||
root.display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The single validation gate for every canvas package, read off disk or
|
||||
/// embedded in the binary. There is no second, weaker path.
|
||||
pub(super) fn validate_package_files(
|
||||
files: BTreeMap<String, Vec<u8>>,
|
||||
) -> Result<ValidatedPackage, String> {
|
||||
if files.len() > MAX_PACKAGE_FILES {
|
||||
return Err(format!(
|
||||
"project canvas package exceeds {MAX_PACKAGE_FILES} files"
|
||||
|
||||
73
desktop/src-tauri/src/project_canvas_package/template.rs
Normal file
73
desktop/src-tauri/src/project_canvas_package/template.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
//! The seed package a project's first canvas is created from, embedded in the
|
||||
//! binary at compile time.
|
||||
//!
|
||||
//! Seeding used to read `resources/project-canvas-template` from disk, resolved
|
||||
//! against the running executable. In dev builds that directory lives inside
|
||||
//! the cargo target directory — shared across worktrees, rewritten by sibling
|
||||
//! builds, and removed wholesale by toolchain cleanups — so when it disappeared
|
||||
//! under a running app the first-ever activation of a project failed with an
|
||||
//! unnamed `os error 2` and could not self-heal. There is no runtime lookup
|
||||
//! left to go stale: the bytes are part of the binary, and the on-disk copy is
|
||||
//! no longer bundled.
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
|
||||
use include_dir::{include_dir, Dir, DirEntry};
|
||||
|
||||
use super::{
|
||||
manifest::validate_relative_path,
|
||||
storage::{validate_package_files, ValidatedPackage},
|
||||
};
|
||||
|
||||
static TEMPLATE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/resources/project-canvas-template");
|
||||
|
||||
/// The embedded template, validated through the same gate every on-disk canvas
|
||||
/// package passes.
|
||||
///
|
||||
/// Validation hashes 2.7 MB, so the result is computed once per process and
|
||||
/// shared. A template that fails validation fails every seed identically —
|
||||
/// `bundled_template_seeds_a_valid_snapshot` keeps that from reaching a build.
|
||||
pub(super) fn bundled_template() -> Result<Arc<ValidatedPackage>, String> {
|
||||
static VALIDATED: OnceLock<Result<Arc<ValidatedPackage>, String>> = OnceLock::new();
|
||||
VALIDATED
|
||||
.get_or_init(|| validate_package_files(template_files()?).map(Arc::new))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The embedded file tree, keyed by package-relative path.
|
||||
///
|
||||
/// `include_dir` normalizes to `/` separators on every host, and each key is
|
||||
/// still run through `validate_relative_path` so the embedded template gets no
|
||||
/// weaker a path gate than a package read off disk.
|
||||
pub(super) fn template_files() -> Result<BTreeMap<String, Vec<u8>>, String> {
|
||||
let mut files = BTreeMap::new();
|
||||
collect_entries(TEMPLATE.entries(), &mut files)?;
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn collect_entries(
|
||||
entries: &[DirEntry<'_>],
|
||||
files: &mut BTreeMap<String, Vec<u8>>,
|
||||
) -> Result<(), String> {
|
||||
for entry in entries {
|
||||
match entry {
|
||||
DirEntry::Dir(directory) => collect_entries(directory.entries(), files)?,
|
||||
DirEntry::File(file) => {
|
||||
let path = file
|
||||
.path()
|
||||
.to_str()
|
||||
.ok_or_else(|| "project canvas package paths must be UTF-8".to_string())?;
|
||||
// Matches the on-disk scan, which ignores Finder metadata rather
|
||||
// than failing the whole package on it.
|
||||
if path.rsplit('/').next() == Some(".DS_Store") {
|
||||
continue;
|
||||
}
|
||||
files.insert(validate_relative_path(path)?, file.contents().to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
use std::{collections::BTreeSet, fs, path::Path};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::{
|
||||
manifest::{MAX_DATA_BYTES, MAX_FILE_BYTES, MAX_PACKAGE_FILES},
|
||||
manifest::{validate_relative_path, MAX_DATA_BYTES, MAX_FILE_BYTES, MAX_PACKAGE_FILES},
|
||||
protocol,
|
||||
storage::{
|
||||
active_snapshot, commit_snapshot, prepare_snapshot, prune_revisions, record_source_binding,
|
||||
ProjectBinding,
|
||||
scan_package_for_test, validate_package_files, ProjectBinding, ValidatedPackage,
|
||||
},
|
||||
template::{bundled_template, template_files},
|
||||
ProjectCanvasAgentUpdateRequest, ProjectCanvasPackageRequest, ProjectCanvasRuntime,
|
||||
ProjectCanvasUpdateChange,
|
||||
};
|
||||
@@ -22,56 +27,69 @@ fn request() -> ProjectCanvasPackageRequest {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_package(root: &Path, marker: &str) {
|
||||
fs::create_dir_all(root.join("widgets")).unwrap();
|
||||
fs::create_dir_all(root.join("styles")).unwrap();
|
||||
fs::create_dir_all(root.join("data")).unwrap();
|
||||
fs::create_dir_all(root.join("assets")).unwrap();
|
||||
fs::write(
|
||||
root.join("manifest.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"format": "buzz-project-canvas",
|
||||
"protocolVersion": 1,
|
||||
"scripts": ["widgets/chore-board.js", "canvas.js"],
|
||||
"styles": ["styles/canvas.css"],
|
||||
"data": "data/dashboards.json",
|
||||
"capabilities": [
|
||||
"project.metadata.read",
|
||||
"project.channels.read",
|
||||
"project.reviews.read"
|
||||
]
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("widgets/chore-board.js"),
|
||||
"globalThis.renderChores = () => {};",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("canvas.js"),
|
||||
format!("globalThis.canvasMarker = {marker:?};"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("styles/canvas.css"), "body { margin: 0; }").unwrap();
|
||||
fs::write(
|
||||
root.join("data/dashboards.json"),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"marker": marker,
|
||||
"dashboards": {
|
||||
"test": {
|
||||
"widgets": [{
|
||||
"id": "chore-board",
|
||||
"data": { "marker": marker }
|
||||
}]
|
||||
fn package_files(marker: &str) -> BTreeMap<String, Vec<u8>> {
|
||||
BTreeMap::from([
|
||||
(
|
||||
"manifest.json".to_string(),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"format": "buzz-project-canvas",
|
||||
"protocolVersion": 1,
|
||||
"scripts": ["widgets/chore-board.js", "canvas.js"],
|
||||
"styles": ["styles/canvas.css"],
|
||||
"data": "data/dashboards.json",
|
||||
"capabilities": [
|
||||
"project.metadata.read",
|
||||
"project.channels.read",
|
||||
"project.reviews.read"
|
||||
]
|
||||
}))
|
||||
.unwrap(),
|
||||
),
|
||||
(
|
||||
"widgets/chore-board.js".to_string(),
|
||||
b"globalThis.renderChores = () => {};".to_vec(),
|
||||
),
|
||||
(
|
||||
"canvas.js".to_string(),
|
||||
format!("globalThis.canvasMarker = {marker:?};").into_bytes(),
|
||||
),
|
||||
(
|
||||
"styles/canvas.css".to_string(),
|
||||
b"body { margin: 0; }".to_vec(),
|
||||
),
|
||||
(
|
||||
"data/dashboards.json".to_string(),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"marker": marker,
|
||||
"dashboards": {
|
||||
"test": {
|
||||
"widgets": [{
|
||||
"id": "chore-board",
|
||||
"data": { "marker": marker }
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("assets/pixel.png"), [137, 80, 78, 71]).unwrap();
|
||||
}))
|
||||
.unwrap(),
|
||||
),
|
||||
("assets/pixel.png".to_string(), vec![137, 80, 78, 71]),
|
||||
])
|
||||
}
|
||||
|
||||
fn write_package(root: &Path, marker: &str) {
|
||||
write_package_files(root, &package_files(marker));
|
||||
}
|
||||
|
||||
fn write_package_files(root: &Path, files: &BTreeMap<String, Vec<u8>>) {
|
||||
for (relative, bytes) in files {
|
||||
let destination = root.join(relative);
|
||||
fs::create_dir_all(destination.parent().unwrap()).unwrap();
|
||||
fs::write(destination, bytes).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn template_package(marker: &str) -> ValidatedPackage {
|
||||
validate_package_files(package_files(marker)).unwrap()
|
||||
}
|
||||
|
||||
fn source_root(temp: &TempDir, binding: &ProjectBinding) -> std::path::PathBuf {
|
||||
@@ -254,8 +272,7 @@ fn revision_retention_keeps_active_live_and_recent_snapshots() {
|
||||
#[test]
|
||||
fn first_activation_seeds_the_validated_template() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let template = temp.path().join("template");
|
||||
write_package(&template, "seeded");
|
||||
let template = template_package("seeded");
|
||||
let binding = ProjectBinding::parse(request()).unwrap();
|
||||
|
||||
let snapshot =
|
||||
@@ -271,15 +288,12 @@ fn first_activation_seeds_the_validated_template() {
|
||||
.any(|entry| entry.file_name().to_string_lossy().starts_with(".seed-")));
|
||||
}
|
||||
|
||||
// Binds the shipped resource to the loader: these are the exact files
|
||||
// `tauri-build` copies next to the executable, so a template edit that fails
|
||||
// Binds the embedded seed bytes to the loader, so a template edit that fails
|
||||
// manifest validation surfaces here instead of on the first canvas load.
|
||||
#[test]
|
||||
fn bundled_template_seeds_a_valid_snapshot() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let template = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("resources")
|
||||
.join("project-canvas-template");
|
||||
let template = bundled_template().unwrap();
|
||||
let binding = ProjectBinding::parse(request()).unwrap();
|
||||
|
||||
let snapshot =
|
||||
@@ -288,6 +302,95 @@ fn bundled_template_seeds_a_valid_snapshot() {
|
||||
assert!(snapshot.data["dashboards"].is_object());
|
||||
}
|
||||
|
||||
// `include_dir!` expands to one `include_bytes!` per file, so a file added to
|
||||
// or removed from the template only reaches the binary if the build script's
|
||||
// `rerun-if-changed` fired. Compare the embedded key set against the tree on
|
||||
// disk to catch a stale or partial embed.
|
||||
#[test]
|
||||
fn bundled_template_contains_the_expected_tree() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("resources")
|
||||
.join("project-canvas-template");
|
||||
let mut expected = BTreeSet::new();
|
||||
collect_relative_paths(&root, &root, &mut expected);
|
||||
|
||||
let embedded = template_files()
|
||||
.unwrap()
|
||||
.into_keys()
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
assert_eq!(embedded, expected);
|
||||
}
|
||||
|
||||
fn collect_relative_paths(root: &Path, directory: &Path, paths: &mut BTreeSet<String>) {
|
||||
for entry in fs::read_dir(directory).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.is_dir() {
|
||||
collect_relative_paths(root, &path, paths);
|
||||
continue;
|
||||
}
|
||||
if path.file_name() == Some(std::ffi::OsStr::new(".DS_Store")) {
|
||||
continue;
|
||||
}
|
||||
paths.insert(
|
||||
path.strip_prefix(root)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.replace(std::path::MAIN_SEPARATOR, "/"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The embedded template must clear the same path gate as any package read off
|
||||
// disk — it is seeded through the identical validation, not a weaker one.
|
||||
#[test]
|
||||
fn bundled_template_paths_are_package_safe() {
|
||||
for path in template_files().unwrap().keys() {
|
||||
assert_eq!(
|
||||
validate_relative_path(path).as_deref(),
|
||||
Ok(path.as_str()),
|
||||
"{path}"
|
||||
);
|
||||
assert!(!Path::new(path).is_absolute(), "{path}");
|
||||
assert!(!path.contains('\\'), "{path}");
|
||||
assert!(!path.split('/').any(|segment| segment == ".."), "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
// The missing-directory arm has to run before the symlink inspection, or an
|
||||
// absent package dies inside `symlink_metadata` with an unnamed `os error 2` —
|
||||
// exactly the failure that made a vanished template unreadable to debug.
|
||||
#[test]
|
||||
fn missing_package_directory_names_the_path() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let absent = temp.path().join("absent-revision");
|
||||
|
||||
let error = scan_package_for_test(temp.path(), &absent).unwrap_err();
|
||||
|
||||
assert!(error.contains("does not exist"), "{error}");
|
||||
assert!(error.contains("absent-revision"), "{error}");
|
||||
}
|
||||
|
||||
// A symlinked package root is refused even when it points at a valid package:
|
||||
// the existence check reads non-following metadata, so it cannot mask the
|
||||
// symlink arm behind a `Path::is_dir()` that follows links.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn package_root_symlink_is_rejected() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = TempDir::new().unwrap();
|
||||
let real = temp.path().join("real-package");
|
||||
write_package(&real, "root-symlink");
|
||||
let linked = temp.path().join("linked-package");
|
||||
symlink(&real, &linked).unwrap();
|
||||
|
||||
assert!(scan_package_for_test(temp.path(), &real).is_ok());
|
||||
let error = scan_package_for_test(temp.path(), &linked).unwrap_err();
|
||||
assert!(error.contains("cannot be symlinks"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_index_is_machine_readable_sorted_and_path_derived() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
@@ -337,7 +440,7 @@ fn malformed_source_index_does_not_block_a_valid_canvas_load() {
|
||||
fs::write(root.join("index.json"), b"not json").unwrap();
|
||||
let runtime = ProjectCanvasRuntime::with_root(root);
|
||||
|
||||
let descriptor = runtime.get_or_activate(request(), temp.path()).unwrap();
|
||||
let descriptor = runtime.get_or_activate(request(), None).unwrap();
|
||||
|
||||
assert_eq!(descriptor.data["marker"], "load-with-corrupt-index");
|
||||
assert!(runtime.source_location(request()).is_ok());
|
||||
@@ -571,9 +674,10 @@ fn host_sdk_routes_serve_the_bundled_sources() {
|
||||
#[test]
|
||||
fn manifest_accepts_the_full_capability_set_and_rejects_unknown_ones() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let template = temp.path().join("template");
|
||||
write_package(&template, "capabilities");
|
||||
let manifest_path = template.join("manifest.json");
|
||||
let binding = ProjectBinding::parse(request()).unwrap();
|
||||
let source = source_root(&temp, &binding);
|
||||
write_package(&source, "capabilities");
|
||||
let manifest_path = source.join("manifest.json");
|
||||
let mut manifest: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
|
||||
// Must accept every capability the desktop protocol schema recognizes.
|
||||
@@ -587,27 +691,23 @@ fn manifest_accepts_the_full_capability_set_and_rejects_unknown_ones() {
|
||||
"app.open"
|
||||
]);
|
||||
fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap();
|
||||
let binding = ProjectBinding::parse(request()).unwrap();
|
||||
assert!(prepare_snapshot(&temp.path().join("CANVASES"), &binding, Some(&template)).is_ok());
|
||||
assert!(prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).is_ok());
|
||||
|
||||
manifest["capabilities"] = serde_json::json!(["network"]);
|
||||
fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap();
|
||||
// Fresh root: prepare_snapshot only seeds from the template on first use.
|
||||
let error =
|
||||
prepare_snapshot(&temp.path().join("CANVASES2"), &binding, Some(&template)).unwrap_err();
|
||||
let error = prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).unwrap_err();
|
||||
assert!(error.contains("unsupported project canvas capability"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_or_undeclared_package_files_fail_closed() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let template = temp.path().join("template");
|
||||
write_package(&template, "bad");
|
||||
fs::write(template.join("index.html"), "<script>bad()</script>").unwrap();
|
||||
let binding = ProjectBinding::parse(request()).unwrap();
|
||||
let source = source_root(&temp, &binding);
|
||||
write_package(&source, "bad");
|
||||
fs::write(source.join("index.html"), "<script>bad()</script>").unwrap();
|
||||
|
||||
let error =
|
||||
prepare_snapshot(&temp.path().join("CANVASES"), &binding, Some(&template)).unwrap_err();
|
||||
let error = prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).unwrap_err();
|
||||
assert!(error.contains("unsupported project canvas file type"));
|
||||
}
|
||||
|
||||
@@ -625,8 +725,9 @@ fn finder_metadata_does_not_break_package_reload() {
|
||||
#[test]
|
||||
fn manifest_paths_cannot_traverse_the_package() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let template = temp.path().join("template");
|
||||
write_package(&template, "bad-path");
|
||||
let binding = ProjectBinding::parse(request()).unwrap();
|
||||
let source = source_root(&temp, &binding);
|
||||
write_package(&source, "bad-path");
|
||||
let manifest = serde_json::json!({
|
||||
"format": "buzz-project-canvas",
|
||||
"protocolVersion": 1,
|
||||
@@ -636,13 +737,12 @@ fn manifest_paths_cannot_traverse_the_package() {
|
||||
"capabilities": []
|
||||
});
|
||||
fs::write(
|
||||
template.join("manifest.json"),
|
||||
source.join("manifest.json"),
|
||||
serde_json::to_vec(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let binding = ProjectBinding::parse(request()).unwrap();
|
||||
|
||||
assert!(prepare_snapshot(&temp.path().join("CANVASES"), &binding, Some(&template)).is_err());
|
||||
assert!(prepare_snapshot(&temp.path().join("CANVASES"), &binding, None).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -818,43 +918,3 @@ fn agent_update_socket_serves_when_started_outside_the_async_runtime() {
|
||||
wait.recv_timeout(Duration::from_secs(5))
|
||||
.expect("socket bound before the runtime handoff should still accept connections");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_resolution_prefers_the_resource_dir() {
|
||||
let resolved = super::template_path_from(
|
||||
Ok(std::path::PathBuf::from("/bundle")),
|
||||
Some(std::path::PathBuf::from("/exe")),
|
||||
);
|
||||
assert_eq!(
|
||||
resolved,
|
||||
Ok(std::path::PathBuf::from(
|
||||
"/bundle/resources/project-canvas-template"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
// A redirected CARGO_TARGET_DIR defeats Tauri's `target/` directory heuristic,
|
||||
// so dev builds must resolve resources next to the executable instead of
|
||||
// failing the canvas load with "unknown path".
|
||||
#[test]
|
||||
fn template_resolution_falls_back_to_the_dev_executable_directory() {
|
||||
let resolved = super::template_path_from(
|
||||
Err("unknown path".to_string()),
|
||||
Some(std::path::PathBuf::from("/exe")),
|
||||
);
|
||||
assert_eq!(
|
||||
resolved,
|
||||
Ok(std::path::PathBuf::from(
|
||||
"/exe/resources/project-canvas-template"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_resolution_without_a_dev_fallback_propagates_the_error() {
|
||||
let resolved = super::template_path_from(Err("unknown path".to_string()), None);
|
||||
assert_eq!(
|
||||
resolved,
|
||||
Err("resolve project canvas template: unknown path".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"resources": ["resources/project-canvas-template/**/*"],
|
||||
"externalBin": [
|
||||
"binaries/buzz-acp",
|
||||
"binaries/buzz-agent",
|
||||
|
||||
Reference in New Issue
Block a user