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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,24 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

# The bundled Node.js version is pinned in package.json's
# devEngines.runtime.version (see standalone/src-tauri/build.rs, which
# fails the build unless the bundled binary matches this pin).
- name: Read pinned Node.js version
id: node-pin
shell: bash
run: |
set -euo pipefail
version=$(jq -r '.devEngines.runtime.version' package.json)
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "ERROR: package.json devEngines.runtime.version is not MAJOR.MINOR.PATCH, got: '$version'" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: standalone/.node-version
node-version: ${{ steps.node-pin.outputs.version }}

- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
Expand Down
8 changes: 4 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ Every dependency shipped in the end-user application is listed at <https://dormo

Those dependency snapshots are generated from the lockfiles and reviewed as part of release work. If a production dependency is added, removed, or upgraded, the dependency lists must be regenerated and committed.

The standalone app ships a Node.js runtime binary (`standalone/src-tauri/build.rs` copies it into the bundle as a Tauri sidecar). Its version is pinned exactly in `standalone/.node-version`, and the build is the authority: `build.rs` runs `--version` on the binary it is about to bundle and fails the build unless it matches the pin. The supply-chain page reads the same pin, so the version disclosed there provably equals the runtime users receive — it cannot drift to whatever Node happened to be on the build machine's PATH. The version is a deliberate, manual pin (no Dependabot ecosystem tracks it); the workflows that do not bundle the runtime are free to track the latest `22`.
The standalone app ships a Node.js runtime binary (`standalone/src-tauri/build.rs` copies it into the bundle as a Tauri sidecar). Its version is pinned exactly in the root `package.json` under `devEngines.runtime.version`, and the build is the authority: `build.rs` runs `--version` on the binary it is about to bundle and fails the build unless it matches the pin. On Windows the build then flips one byte of the bundled `node.exe` — the PE Optional Header's `Subsystem` field from `IMAGE_SUBSYSTEM_WINDOWS_CUI` (3) to `IMAGE_SUBSYSTEM_WINDOWS_GUI` (2) — to suppress Windows Terminal's default-terminal handoff, which would otherwise spawn a stray terminal window behind the app. The version check runs before the byte flip and the patch leaves Node.js semantics unchanged (Node reads its stdio handles from `STARTUPINFO`, which is subsystem-agnostic); the bundled `node.exe` is therefore not byte-identical to the upstream archive — it differs at exactly the documented 2-byte field. The supply-chain page reads the same pin, so the version disclosed there provably equals the runtime users receive — it cannot drift to whatever Node happened to be on the build machine's PATH. Locally, pnpm honors `devEngines` (`onFail: "download"`) so scripts run under the pinned Node; CI extracts the same field to drive `actions/setup-node`. The version is a deliberate, manual pin (no Dependabot ecosystem tracks it); the workflows that do not bundle the runtime are free to track the latest `22`.

- FAIL IF `node website/scripts/generate-deps.js` changes `website/src/data/dependencies-npm.json`, `website/src/data/dependencies-cargo.json`, or `website/src/data/dependencies-runtime.json` when run from a clean checkout.
- FAIL IF `standalone/.node-version` is missing or does not pin an exact Node.js version (a bare major such as `22` is not acceptable; it must be `MAJOR.MINOR.PATCH`).
- FAIL IF `standalone/src-tauri/build.rs` no longer verifies that the bundled Node.js binary matches `standalone/.node-version` (this verification is what makes the disclosed runtime version provable).
- FAIL IF the `build-standalone` job in `.github/workflows/release.yml` does not install the pinned runtime via `actions/setup-node` with `node-version-file: standalone/.node-version` (other jobs may pin `node-version` inline since their interpreter is never bundled).
- FAIL IF the root `package.json` is missing `devEngines.runtime.version`, or its value is not an exact Node.js version (a bare major such as `22` is not acceptable; it must be `MAJOR.MINOR.PATCH`).
- FAIL IF `standalone/src-tauri/build.rs` no longer verifies that the bundled Node.js binary matches `package.json`'s `devEngines.runtime.version` (this verification is what makes the disclosed runtime version provable).
- FAIL IF the `build-standalone` job in `.github/workflows/release.yml` does not install the pinned runtime by reading `devEngines.runtime.version` from `package.json` and passing it to `actions/setup-node` (other jobs may pin `node-version` inline since their interpreter is never bundled).
- FAIL IF `pnpm-workspace.yaml` is missing `minimumReleaseAge: 1440`.
- FAIL IF `.github/dependabot.yml` is missing npm coverage for `/` or Cargo coverage for `/standalone/src-tauri`.
- FAIL IF `.github/dependabot.yml` is missing dependency cooldown windows.
Expand Down
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
"private": true,
"license": "FSL-1.1-MIT",
"packageManager": "pnpm@11.0.6",
"devEngines": {
"runtime": {
"name": "node",
"version": "22.22.3",
"onFail": "download"
}
},
"scripts": {
"build": "pnpm run build:vscode && pnpm --filter dormouse-website build",
"test": "pnpm -r run test",
Expand All @@ -16,5 +23,6 @@
"dogfood:standalone": "bash standalone/scripts/dogfood.sh",
"storybook": "pnpm --filter dormouse-lib storybook",
"bundle-themes": "node lib/scripts/bundle-themes.mjs"
}
},
"devDependencies": {}
}
150 changes: 149 additions & 1 deletion pnpm-lock.yaml

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

1 change: 0 additions & 1 deletion standalone/.node-version

This file was deleted.

1 change: 1 addition & 0 deletions standalone/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ crate-type = ["lib", "cdylib", "staticlib"]

[build-dependencies]
tauri-build = { version = "2", features = [] }
serde_json = "1"

[dependencies]
tauri = { version = "2", features = [] }
Expand Down
95 changes: 83 additions & 12 deletions standalone/src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,14 @@ fn bundle_node_runtime() -> Result<(), Box<dyn Error>> {
let host = env::var("HOST")?;
let node_source = resolve_node_binary(&host, &target)?;

println!("cargo:rerun-if-changed={}", node_source.display());
validate_node_binary(&node_source, &target)?;

// The supply-chain page (website/src/data/dependencies-runtime.json) discloses
// an exact Node.js version. Fail the build if the binary we're about to bundle
// doesn't match standalone/.node-version, so the disclosed version provably
// equals what ships. CI installs the pin via setup-node's node-version-file.
// doesn't match the pin in the root package.json's devEngines.runtime.version,
// so the disclosed version provably equals what ships. Locally pnpm honors
// devEngines (onFail: "download") so scripts run with the pinned Node; CI
// reads the same field to drive actions/setup-node.
let pinned_version = read_pinned_node_version(&manifest_dir)?;
verify_node_version(&node_source, &host, &target, &pinned_version)?;

Expand All @@ -42,6 +43,10 @@ fn bundle_node_runtime() -> Result<(), Box<dyn Error>> {
let node_dest = binaries_dir.join(node_binary_name(&target));
fs::copy(&node_source, &node_dest)?;

if target.contains("windows") {
force_windows_gui_subsystem(&node_dest)?;
}

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
Expand All @@ -54,6 +59,56 @@ fn bundle_node_runtime() -> Result<(), Box<dyn Error>> {
Ok(())
}

// Rewrite the PE subsystem byte of the bundled node.exe from 3 (Windows
// console) to 2 (Windows GUI). Node.js does not care which subsystem its
// host binary advertises — it reads stdio handles from STARTUPINFO either
// way — but a console-subsystem process triggers Windows' default-terminal
// COM handoff, which on Win11 with Windows Terminal as DefTerm activates WT
// to host the sidecar (visible as a stray WT window titled with the node.exe
// path behind Dormouse). Neither CREATE_NO_WINDOW nor DETACHED_PROCESS opts
// out of that handoff; only a non-console subsystem does.
fn force_windows_gui_subsystem(path: &Path) -> Result<(), Box<dyn Error>> {
const IMAGE_SUBSYSTEM_WINDOWS_GUI: u16 = 2;
const IMAGE_SUBSYSTEM_WINDOWS_CUI: u16 = 3;

let mut bytes = fs::read(path)?;
if bytes.len() < 0x40 || &bytes[0..2] != b"MZ" {
return Err(format!("{} is not a PE/COFF binary", path.display()).into());
}
let pe_offset = u32::from_le_bytes(bytes[0x3C..0x40].try_into()?) as usize;
// PE signature (4) + COFF header (20) + Optional header up to Subsystem (0x44).
let subsystem_offset = pe_offset + 0x5C;
if bytes.len() < subsystem_offset + 2 || &bytes[pe_offset..pe_offset + 4] != b"PE\0\0" {
return Err(format!("{} has no PE signature at expected offset", path.display()).into());
}
let current = u16::from_le_bytes(bytes[subsystem_offset..subsystem_offset + 2].try_into()?);
if current == IMAGE_SUBSYSTEM_WINDOWS_GUI {
return Ok(());
}
if current != IMAGE_SUBSYSTEM_WINDOWS_CUI {
return Err(format!(
"{} has unexpected PE subsystem {current}; refusing to patch",
path.display()
)
.into());
}
bytes[subsystem_offset..subsystem_offset + 2]
.copy_from_slice(&IMAGE_SUBSYSTEM_WINDOWS_GUI.to_le_bytes());

// fs::copy preserves the source's read-only attribute. When the runtime
// comes from pnpm's content-addressable store (devEngines `onFail:
// "download"`), the source node.exe is typically read-only, so the
// destination would be too — and fs::write would fail with "access
// denied". Clear it defensively before writing the patched bytes back.
let mut perms = fs::metadata(path)?.permissions();
if perms.readonly() {
perms.set_readonly(false);
fs::set_permissions(path, perms)?;
}
fs::write(path, &bytes)?;
Ok(())
}

#[cfg(target_os = "macos")]
fn validate_node_binary(node_source: &Path, target: &str) -> Result<(), Box<dyn Error>> {
if target.contains("apple-darwin") {
Expand Down Expand Up @@ -92,23 +147,39 @@ fn reject_macos_dynamic_node(node_source: &Path) -> Result<(), Box<dyn Error>> {
}

fn read_pinned_node_version(manifest_dir: &Path) -> Result<String, Box<dyn Error>> {
let pin_path = manifest_dir
let repo_root = manifest_dir
.parent()
.ok_or("manifest dir has no parent")?
.join(".node-version");
.and_then(Path::parent)
.ok_or("manifest dir has no grandparent (expected <repo>/standalone/src-tauri)")?;
let pin_path = repo_root.join("package.json");
println!("cargo:rerun-if-changed={}", pin_path.display());

let raw = fs::read_to_string(&pin_path)
.map_err(|err| format!("failed to read {}: {err}", pin_path.display()))?;
let version = raw.trim().trim_start_matches('v').to_owned();
let pkg: serde_json::Value = serde_json::from_str(&raw)
.map_err(|err| format!("failed to parse {}: {err}", pin_path.display()))?;
let version = pkg
.get("devEngines")
.and_then(|v| v.get("runtime"))
.and_then(|v| v.get("version"))
.and_then(|v| v.as_str())
.ok_or_else(|| {
format!(
"{} is missing devEngines.runtime.version (string)",
pin_path.display()
)
})?
.trim()
.trim_start_matches('v')
.to_owned();

let is_exact = version.split('.').count() == 3
&& version
.split('.')
.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()));
if !is_exact {
return Err(format!(
"{} must pin an exact Node.js version (MAJOR.MINOR.PATCH), found {version:?}",
"{} devEngines.runtime.version must be an exact Node.js version (MAJOR.MINOR.PATCH), found {version:?}",
pin_path.display()
)
.into());
Expand Down Expand Up @@ -144,10 +215,10 @@ fn verify_node_version(
.to_owned();
if actual != pinned {
return Err(format!(
"bundled Node.js {actual} does not match the standalone/.node-version pin {pinned}. \
Install the pinned version (CI uses actions/setup-node with \
node-version-file: standalone/.node-version) or update the pin and regenerate \
website/src/data/dependencies-runtime.json."
"bundled Node.js {actual} does not match the package.json devEngines.runtime.version \
pin {pinned}. Run scripts via pnpm so devEngines (onFail: \"download\") provisions \
the pinned Node, or update the pin in package.json and regenerate \
website/src/data/dependencies-runtime.json (node website/scripts/generate-deps.js)."
)
.into());
}
Expand Down
Loading
Loading