diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..6e79b5d --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,64 @@ +name: Checks + +on: + push: + branches: [bootstrap] + pull_request: + branches: [bootstrap] + +permissions: + contents: read + +concurrency: + group: checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: rustup toolchain install 1.97.1 --profile minimal --component clippy,rustfmt --target wasm32v1-none + - run: cargo fmt --all -- --check + - run: cargo clippy --locked --workspace --all-targets -- -D warnings + - run: cargo test --locked --workspace + - run: cargo build --locked --workspace --release + - name: Install SHA-verified WASI SDK + env: + URL: https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-33/wasi-sdk-33.0-x86_64-linux.tar.gz + SHA256: 0ba8b5bfaeb2adf3f29bab5841d76cf5318ab8e1642ea195f88baba1abd47bce + run: | + curl --fail --location --output "$RUNNER_TEMP/wasi-sdk.tar.gz" "$URL" + echo "$SHA256 $RUNNER_TEMP/wasi-sdk.tar.gz" | sha256sum --check + tar -xzf "$RUNNER_TEMP/wasi-sdk.tar.gz" -C "$RUNNER_TEMP" + - run: cargo run --locked --release -p krad-utils -- build "$RUNNER_TEMP/dist" "$RUNNER_TEMP/wasi-sdk-33.0-x86_64-linux/bin/clang++" --json + - name: Verify committed artifacts + run: | + for artifact in dist/*.wasm; do + cmp "$artifact" "$RUNNER_TEMP/dist/${artifact#dist/}" + done + node - <<'NODE' + const fs = require("node:fs"); + const cases = [ + ["krad-add.wasm", "krad_add", [20, 22], 42], + ["krad-clang.wasm", "krad_clang_abi", [], 1], + ["krad-libs.wasm", "krad_errno", [0, 4], 38], + ["krad-musl.wasm", "krad_memcmp", [0, 0, 0], 0], + ["krad-utils.wasm", "krad_wasm_version", [], 1], + ]; + (async () => { + for (const [file, name, args, expected] of cases) { + const bytes = fs.readFileSync(`dist/${file}`); + const module = await WebAssembly.compile(bytes); + if (WebAssembly.Module.imports(module).length) throw new Error(`${file} has imports`); + const instance = await WebAssembly.instantiate(module); + if (instance.exports[name](...args) !== expected) throw new Error(`${file} failed`); + } + })().catch((error) => { console.error(error); process.exit(1); }); + NODE + rejected="$(printf '\143\150\145\145\162\160')" + if grep -R -a -i --exclude-dir=.git --exclude-dir=target "$rejected" .; then exit 1; fi + if find . -path './.git' -prune -o -path './target' -prune -o -iname "*$rejected*" -print | grep -q .; then exit 1; fi diff --git a/.gitignore b/.gitignore index 69c575d..f5d5c38 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ -build/ -dist/ -node_modules/ +/target/ +.*.krad-backup +.*.krad-lock +.*.krad-staging diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ac604c8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,66 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "krad-clang" +version = "0.1.0" +dependencies = [ + "wasmparser", +] + +[[package]] +name = "krad-libs" +version = "0.1.0" +dependencies = [ + "krad-musl", +] + +[[package]] +name = "krad-musl" +version = "0.1.0" + +[[package]] +name = "krad-utils" +version = "0.1.0" +dependencies = [ + "krad-clang", +] + +[[package]] +name = "wasmparser" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" +dependencies = [ + "bitflags", + "indexmap", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c9d61fd --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[workspace] +members = [ + "packages/krad-clang", + "packages/krad-libs", + "packages/krad-musl", + "packages/krad-utils", +] +resolver = "3" + +[workspace.package] +edition = "2024" +license = "MIT" +publish = false +rust-version = "1.97" +version = "0.1.0" + +[profile.release] +codegen-units = 1 +lto = true +opt-level = "s" +panic = "abort" +strip = "symbols" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..19efdcb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Keys + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f479526 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# krad + +Krad is a four-crate Rust workspace for producing small, standard WebAssembly +modules without a JavaScript runtime: + +- `krad-clang` drives an explicit LLVM/Clang WebAssembly compiler and also + exposes its own tiny Wasm ABI marker. +- `krad-musl` provides allocator-free memory and string primitives. +- `krad-libs` provides Linux/FreeBSD errno, page, and copy helpers. +- `krad-utils` provides the build CLI and a small Wasm utility ABI. + +Build every Rust crate plus the real C++ fixture with a Clang that supports the +WebAssembly target. The compiler path is explicit; the Krad CLI never downloads +or extracts a toolchain: + +```sh +cargo run -p krad-utils -- build dist /path/to/wasi-sdk/bin/clang++ +``` + +The command creates five raw, import-free modules: + +```text +dist/krad-add.wasm +dist/krad-clang.wasm +dist/krad-libs.wasm +dist/krad-musl.wasm +dist/krad-utils.wasm +``` + +`krad-add.wasm` is compiled from `examples/add.cpp` with the standard +`wasm32-unknown-unknown` target, `-nostdlib`, and an explicit `krad_add` export. +The checked-in artifact was built with the SHA-verified official WASI SDK 33 +archive and Clang 22.1.0. The other four modules are Rust `wasm32v1-none` +release builds. The CLI uses Bytecode Alliance's pinned `wasmparser` to reject +invalid modules, imports, and missing function exports before replacing an +artifact directory as one set. Existing output is replaced only when it carries +Krad's `.krad-artifacts` ownership marker; concurrent builds serialize on a +sibling lock file. + +```sh +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +node -e "WebAssembly.instantiate(require('node:fs').readFileSync('dist/krad-add.wasm')).then(({instance})=>{if(instance.exports.krad_add(20,22)!==42)process.exit(1)})" +``` + +Krad is not a complete libc, sysroot, browser-hosted compiler, Linux/BSD +distribution, or native-ISA translator. It makes no performance or size claim +without a comparable benchmark. + +Krad is MIT-licensed; see `LICENSE`. diff --git a/dist/.krad-artifacts b/dist/.krad-artifacts new file mode 100644 index 0000000..d7fd2a4 --- /dev/null +++ b/dist/.krad-artifacts @@ -0,0 +1 @@ +krad-artifacts-v1 diff --git a/dist/krad-add.wasm b/dist/krad-add.wasm new file mode 100644 index 0000000..5e40d79 Binary files /dev/null and b/dist/krad-add.wasm differ diff --git a/dist/krad-clang.wasm b/dist/krad-clang.wasm new file mode 100644 index 0000000..29b0a78 Binary files /dev/null and b/dist/krad-clang.wasm differ diff --git a/dist/krad-libs.wasm b/dist/krad-libs.wasm new file mode 100644 index 0000000..dcfd77a Binary files /dev/null and b/dist/krad-libs.wasm differ diff --git a/dist/krad-musl.wasm b/dist/krad-musl.wasm new file mode 100644 index 0000000..984fc9c Binary files /dev/null and b/dist/krad-musl.wasm differ diff --git a/dist/krad-utils.wasm b/dist/krad-utils.wasm new file mode 100644 index 0000000..e3478bf Binary files /dev/null and b/dist/krad-utils.wasm differ diff --git a/examples/add.cpp b/examples/add.cpp new file mode 100644 index 0000000..f5dd075 --- /dev/null +++ b/examples/add.cpp @@ -0,0 +1,3 @@ +extern "C" int krad_add(int left, int right) { + return left + right; +} diff --git a/packages/krad-clang/Cargo.toml b/packages/krad-clang/Cargo.toml new file mode 100644 index 0000000..86a78e3 --- /dev/null +++ b/packages/krad-clang/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "krad-clang" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true +rust-version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +path = "src/lib.rs" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +wasmparser = { version = "=0.252.0", default-features = false, features = ["std", "validate"] } diff --git a/packages/krad-clang/src/lib.rs b/packages/krad-clang/src/lib.rs new file mode 100644 index 0000000..2093b1c --- /dev/null +++ b/packages/krad-clang/src/lib.rs @@ -0,0 +1,148 @@ +#![cfg_attr(target_arch = "wasm32", no_std)] + +#[unsafe(no_mangle)] +pub extern "C" fn krad_clang_abi() -> u32 { + 1 +} + +#[cfg(not(target_arch = "wasm32"))] +mod host { + use std::fs; + use std::path::{Path, PathBuf}; + use std::process::Command; + use wasmparser::{Encoding, ExternalKind, Parser, Payload, validate}; + + pub fn validate_wasm(bytes: &[u8], expected_export: &str) -> Result<(), String> { + validate(bytes).map_err(|error| format!("invalid WebAssembly: {error}"))?; + let mut found = false; + for payload in Parser::new(0).parse_all(bytes) { + match payload.map_err(|error| format!("invalid WebAssembly: {error}"))? { + Payload::Version { encoding, .. } if encoding != Encoding::Module => { + return Err("WebAssembly components are not supported".to_owned()); + } + Payload::ImportSection(section) if section.count() != 0 => { + return Err("WebAssembly imports are not supported".to_owned()); + } + Payload::ExportSection(section) => { + for export in section { + let export = export + .map_err(|error| format!("invalid WebAssembly export: {error}"))?; + found |= + export.name == expected_export && export.kind == ExternalKind::Func; + } + } + _ => {} + } + } + found + .then_some(()) + .ok_or_else(|| format!("missing function export: {expected_export}")) + } + + #[derive(Debug, Clone)] + pub struct Compiler { + executable: PathBuf, + } + + impl Compiler { + pub fn new(executable: impl Into) -> Result { + let executable = executable.into(); + if executable.as_os_str().is_empty() { + return Err("compiler path cannot be empty".to_owned()); + } + Ok(Self { executable }) + } + + pub fn version(&self) -> Result { + let output = Command::new(&self.executable) + .arg("--version") + .output() + .map_err(|error| { + format!("could not execute {}: {error}", self.executable.display()) + })?; + if !output.status.success() { + return Err(format!("{} --version failed", self.executable.display())); + } + let version = String::from_utf8(output.stdout) + .map_err(|_| "compiler version is not UTF-8".to_owned())?; + if !version.contains("clang version") { + return Err("compiler is not LLVM/Clang".to_owned()); + } + Ok(version) + } + + pub fn compile(&self, source: &Path, output: &Path) -> Result<(), String> { + if source == output { + return Err("source and output paths must differ".to_owned()); + } + if !source.is_file() { + return Err(format!("source is not a file: {}", source.display())); + } + if let Some(parent) = output.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("could not create {}: {error}", parent.display()))?; + } + let status = Command::new(&self.executable) + .args([ + "--target=wasm32-unknown-unknown", + "-O3", + "-fno-exceptions", + "-fno-rtti", + "-nostdlib", + "-Wl,--no-entry", + "-Wl,--export=krad_add", + "-Wl,--strip-all", + ]) + .arg(source) + .arg("-o") + .arg(output) + .status() + .map_err(|error| { + format!("could not execute {}: {error}", self.executable.display()) + })?; + if !status.success() { + return Err(format!( + "LLVM/Clang WebAssembly compilation failed with {status}" + )); + } + let bytes = fs::read(output) + .map_err(|error| format!("could not read {}: {error}", output.display()))?; + validate_wasm(&bytes, "krad_add")?; + Ok(()) + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub use host::{Compiler, validate_wasm}; + +#[cfg(all(target_arch = "wasm32", not(test)))] +#[panic_handler] +fn panic(_: &core::panic::PanicInfo<'_>) -> ! { + loop {} +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn validates_the_compiler_contract() { + const IMPORT: &[u8] = b"\0asm\x01\0\0\0\x01\x04\x01\x60\0\0\x02\x07\x01\x01m\x01f\0\0"; + let module = include_bytes!("../../../dist/krad-add.wasm"); + + assert_eq!(krad_clang_abi(), 1); + assert!(validate_wasm(module, "krad_add").is_ok()); + assert!(validate_wasm(module, "missing").is_err()); + assert!(validate_wasm(IMPORT, "f").is_err()); + assert!(validate_wasm(b"not wasm", "krad_add").is_err()); + assert!(Compiler::new("").is_err()); + assert!( + Compiler::new("clang++") + .unwrap() + .compile(Path::new("same"), Path::new("same")) + .is_err() + ); + } +} diff --git a/packages/krad-libs/Cargo.toml b/packages/krad-libs/Cargo.toml new file mode 100644 index 0000000..aa89c2d --- /dev/null +++ b/packages/krad-libs/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "krad-libs" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true +rust-version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +path = "src/lib.rs" + +[dependencies] +krad-musl = { path = "../krad-musl" } diff --git a/packages/krad-libs/src/lib.rs b/packages/krad-libs/src/lib.rs new file mode 100644 index 0000000..941e6c5 --- /dev/null +++ b/packages/krad-libs/src/lib.rs @@ -0,0 +1,52 @@ +#![cfg_attr(target_arch = "wasm32", no_std)] + +pub const ABI_LINUX: u32 = 0; +pub const ABI_FREEBSD: u32 = 1; +pub const ERROR_NO_ENTRY: u32 = 1; +pub const ERROR_NO_MEMORY: u32 = 2; +pub const ERROR_INVALID: u32 = 3; +pub const ERROR_NOT_IMPLEMENTED: u32 = 4; + +#[unsafe(no_mangle)] +pub extern "C" fn krad_errno(abi: u32, error: u32) -> u32 { + match (abi, error) { + (ABI_LINUX | ABI_FREEBSD, ERROR_NO_ENTRY) => 2, + (ABI_LINUX | ABI_FREEBSD, ERROR_NO_MEMORY) => 12, + (ABI_LINUX | ABI_FREEBSD, ERROR_INVALID) => 22, + (ABI_LINUX, ERROR_NOT_IMPLEMENTED) => 38, + (ABI_FREEBSD, ERROR_NOT_IMPLEMENTED) => 78, + _ => 0, + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn krad_page_align(value: u32) -> u32 { + value.checked_add(4095).map_or(0, |value| value & !4095) +} + +/// Copies guest bytes through the shared memory primitive. +/// +/// # Safety +/// Both pointers must be valid for `length` non-overlapping bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn krad_copy( + destination: *mut u8, + source: *const u8, + length: usize, +) -> *mut u8 { + unsafe { krad_musl::krad_memcpy(destination, source, length) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_abi_errors_and_pages() { + assert_eq!(krad_errno(ABI_LINUX, ERROR_NOT_IMPLEMENTED), 38); + assert_eq!(krad_errno(ABI_FREEBSD, ERROR_NOT_IMPLEMENTED), 78); + assert_eq!(krad_errno(99, ERROR_INVALID), 0); + assert_eq!(krad_page_align(4097), 8192); + assert_eq!(krad_page_align(u32::MAX), 0); + } +} diff --git a/packages/krad-musl/Cargo.toml b/packages/krad-musl/Cargo.toml new file mode 100644 index 0000000..9e6f795 --- /dev/null +++ b/packages/krad-musl/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "krad-musl" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true +rust-version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +path = "src/lib.rs" diff --git a/packages/krad-musl/src/lib.rs b/packages/krad-musl/src/lib.rs new file mode 100644 index 0000000..509873f --- /dev/null +++ b/packages/krad-musl/src/lib.rs @@ -0,0 +1,97 @@ +#![cfg_attr(target_arch = "wasm32", no_std)] + +/// Copies `length` non-overlapping bytes. +/// +/// # Safety +/// Both pointers must be valid for `length` bytes and must not overlap. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn krad_memcpy( + destination: *mut u8, + source: *const u8, + length: usize, +) -> *mut u8 { + unsafe { core::ptr::copy_nonoverlapping(source, destination, length) }; + destination +} + +/// Copies `length` bytes, including overlapping ranges. +/// +/// # Safety +/// Both pointers must be valid for `length` bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn krad_memmove( + destination: *mut u8, + source: *const u8, + length: usize, +) -> *mut u8 { + unsafe { core::ptr::copy(source, destination, length) }; + destination +} + +/// Fills `length` bytes with `value`. +/// +/// # Safety +/// `destination` must be valid for writes of `length` bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn krad_memset(destination: *mut u8, value: u8, length: usize) -> *mut u8 { + unsafe { core::ptr::write_bytes(destination, value, length) }; + destination +} + +/// Compares two byte ranges. +/// +/// # Safety +/// Both pointers must be valid for reads of `length` bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn krad_memcmp(left: *const u8, right: *const u8, length: usize) -> i32 { + for index in 0..length { + let left = unsafe { *left.add(index) }; + let right = unsafe { *right.add(index) }; + if left != right { + return i32::from(left) - i32::from(right); + } + } + 0 +} + +/// Returns the length of a NUL-terminated byte string. +/// +/// # Safety +/// `value` must point to a readable NUL-terminated byte string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn krad_strlen(value: *const u8) -> usize { + let mut length = 0; + while unsafe { *value.add(length) } != 0 { + length += 1; + } + length +} + +#[cfg(all(target_arch = "wasm32", not(test)))] +#[panic_handler] +fn panic(_: &core::panic::PanicInfo<'_>) -> ! { + loop {} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn implements_the_memory_subset() { + let source = *b"krad\0"; + let mut output = [0_u8; 5]; + unsafe { + krad_memcpy(output.as_mut_ptr(), source.as_ptr(), source.len()); + assert_eq!(krad_strlen(output.as_ptr()), 4); + assert_eq!( + krad_memcmp(output.as_ptr(), source.as_ptr(), source.len()), + 0 + ); + krad_memmove(output.as_mut_ptr().add(1), output.as_ptr(), 4); + assert_eq!(&output, b"kkrad"); + krad_memset(output.as_mut_ptr(), b'x', 2); + } + assert_eq!(&output, b"xxrad"); + } +} diff --git a/packages/krad-utils/Cargo.toml b/packages/krad-utils/Cargo.toml new file mode 100644 index 0000000..f04875d --- /dev/null +++ b/packages/krad-utils/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "krad-utils" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true +rust-version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +path = "src/lib.rs" + +[[bin]] +name = "krad" +path = "src/main.rs" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +krad-clang = { path = "../krad-clang" } diff --git a/packages/krad-utils/src/lib.rs b/packages/krad-utils/src/lib.rs new file mode 100644 index 0000000..35692a8 --- /dev/null +++ b/packages/krad-utils/src/lib.rs @@ -0,0 +1,29 @@ +#![cfg_attr(target_arch = "wasm32", no_std)] + +#[unsafe(no_mangle)] +pub extern "C" fn krad_wasm_version() -> u32 { + 1 +} + +#[unsafe(no_mangle)] +pub extern "C" fn krad_is_wasm_header(word: u32) -> u32 { + u32::from(word == 0x6d73_6100) +} + +#[cfg(all(target_arch = "wasm32", not(test)))] +#[panic_handler] +fn panic(_: &core::panic::PanicInfo<'_>) -> ! { + loop {} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identifies_the_binary_contract() { + assert_eq!(krad_wasm_version(), 1); + assert_eq!(krad_is_wasm_header(0x6d73_6100), 1); + assert_eq!(krad_is_wasm_header(0), 0); + } +} diff --git a/packages/krad-utils/src/main.rs b/packages/krad-utils/src/main.rs new file mode 100644 index 0000000..d18a9fe --- /dev/null +++ b/packages/krad-utils/src/main.rs @@ -0,0 +1,449 @@ +use krad_clang::{Compiler, validate_wasm}; +use std::env; +use std::ffi::{OsStr, OsString}; +use std::fs::{self, File, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const USAGE: &str = "Usage: krad [paths] [--json] + +Commands: + build [out] [clang++] Build four Rust Wasm crates and the C++ fixture + help Show this help + +Defaults: out=dist, clang++=clang++. The compiler must support the standard +wasm32-unknown-unknown target. Krad never downloads a compiler."; + +const ARTIFACTS: [(&str, &str); 5] = [ + ("krad-add.wasm", "krad_add"), + ("krad-clang.wasm", "krad_clang_abi"), + ("krad-libs.wasm", "krad_errno"), + ("krad-musl.wasm", "krad_memcmp"), + ("krad-utils.wasm", "krad_wasm_version"), +]; +const MARKER: &[u8] = b"krad-artifacts-v1\n"; + +#[derive(Debug, PartialEq)] +struct Artifact { + name: &'static str, + bytes: u64, +} + +fn run(command: &mut Command, description: &str) -> Result<(), String> { + let status = command + .status() + .map_err(|error| format!("could not run {description}: {error}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("{description} failed with {status}")) + } +} + +fn copy_artifact(source: &Path, destination: &Path, expected_export: &str) -> Result<(), String> { + let bytes = fs::read(source) + .map_err(|error| format!("could not read {}: {error}", source.display()))?; + validate_wasm(&bytes, expected_export) + .map_err(|error| format!("invalid {}: {error}", source.display()))?; + fs::write(destination, &bytes) + .map_err(|error| format!("could not write {}: {error}", destination.display()))?; + Ok(()) +} + +fn sidecar(out: &Path, suffix: &str) -> Result { + let name = out + .file_name() + .ok_or_else(|| "output must name a directory".to_owned())?; + let mut sidecar = OsString::from("."); + sidecar.push(name); + sidecar.push(suffix); + Ok(out.with_file_name(sidecar)) +} + +fn existing(path: &Path) -> Result, String> { + match fs::symlink_metadata(path) { + Ok(metadata) => Ok(Some(metadata)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("could not inspect {}: {error}", path.display())), + } +} + +fn require_owned_directory(path: &Path) -> Result<(), String> { + if !existing(path)?.is_some_and(|metadata| metadata.file_type().is_dir()) { + return Err(format!( + "refusing to replace non-directory output: {}", + path.display() + )); + } + let marker = path.join(".krad-artifacts"); + if !existing(&marker)?.is_some_and(|metadata| metadata.file_type().is_file()) + || !matches!(fs::read(marker), Ok(bytes) if bytes == MARKER) + { + return Err(format!( + "refusing to replace unowned output directory: {}", + path.display() + )); + } + Ok(()) +} + +fn output_lock(out: &Path) -> Result { + let path = sidecar(out, ".krad-lock")?; + if existing(&path)?.is_some_and(|metadata| !metadata.file_type().is_file()) { + return Err(format!( + "output lock is not a regular file: {}", + path.display() + )); + } + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .map_err(|error| format!("could not open {}: {error}", path.display()))?; + file.lock() + .map_err(|error| format!("could not lock {}: {error}", path.display()))?; + Ok(file) +} + +fn remove_path(path: &Path) -> std::io::Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path), + Ok(_) => fs::remove_file(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +fn recover(staging: &Path, out: &Path) -> Result<(), String> { + let backup = sidecar(out, ".krad-backup")?; + if existing(&backup)?.is_some() { + require_owned_directory(&backup)?; + if existing(out)?.is_some() { + require_owned_directory(out)?; + remove_path(&backup) + .map_err(|error| format!("could not remove {}: {error}", backup.display()))?; + } else { + fs::rename(&backup, out).map_err(|error| { + format!( + "could not recover {} from {}: {error}", + out.display(), + backup.display() + ) + })?; + } + } else if existing(out)?.is_some() { + require_owned_directory(out)?; + } + if existing(staging)?.is_some() { + return Err(format!( + "refusing to remove pre-existing staging path: {}", + staging.display() + )); + } + Ok(()) +} + +fn publish(staging: &Path, out: &Path) -> Result<(), String> { + let backup = sidecar(out, ".krad-backup")?; + require_owned_directory(staging)?; + if existing(&backup)?.is_some() { + return Err(format!( + "refusing to replace pre-existing backup: {}", + backup.display() + )); + } + if existing(out)?.is_some() { + require_owned_directory(out)?; + fs::rename(out, &backup) + .map_err(|error| format!("could not stage {}: {error}", out.display()))?; + } + if let Err(error) = fs::rename(staging, out) { + if backup.exists() { + fs::rename(&backup, out).map_err(|rollback| { + format!( + "could not publish {}: {error}; rollback failed: {rollback}", + out.display() + ) + })?; + } + return Err(format!("could not publish {}: {error}", out.display())); + } + remove_path(&backup).map_err(|error| format!("could not remove {}: {error}", backup.display())) +} + +fn build(out: &Path, compiler_path: &OsStr) -> Result, String> { + let compiler = Compiler::new(compiler_path)?; + let version = compiler.version()?; + eprintln!("compiler {}", version.lines().next().unwrap_or_default()); + + let parent = out + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent) + .map_err(|error| format!("could not create {}: {error}", parent.display()))?; + let _lock = output_lock(out)?; + let staging = sidecar(out, ".krad-staging")?; + recover(&staging, out)?; + + let workspace = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .ok_or_else(|| "could not find workspace root".to_owned())?; + run( + Command::new("cargo").current_dir(workspace).args([ + "build", + "--locked", + "--quiet", + "--release", + "--target-dir", + "target", + "--target", + "wasm32v1-none", + "--workspace", + "--lib", + ]), + "Rust WebAssembly release build", + )?; + + fs::create_dir(&staging) + .map_err(|error| format!("could not create {}: {error}", staging.display()))?; + let result = (|| { + compiler.compile( + &workspace.join("examples/add.cpp"), + &staging.join("krad-add.wasm"), + )?; + let wasm_root = workspace.join("target/wasm32v1-none/release"); + for (source, (name, export)) in [ + "krad_clang.wasm", + "krad_libs.wasm", + "krad_musl.wasm", + "krad_utils.wasm", + ] + .into_iter() + .zip(ARTIFACTS.into_iter().skip(1)) + { + copy_artifact(&wasm_root.join(source), &staging.join(name), export)?; + } + + let artifacts = ARTIFACTS + .into_iter() + .map(|(name, export)| { + let bytes = fs::read(staging.join(name)) + .map_err(|error| format!("could not read {name}: {error}"))?; + validate_wasm(&bytes, export) + .map_err(|error| format!("invalid {name}: {error}"))?; + Ok(Artifact { + name, + bytes: bytes.len() as u64, + }) + }) + .collect::, String>>()?; + fs::write(staging.join(".krad-artifacts"), MARKER) + .map_err(|error| format!("could not mark {}: {error}", staging.display()))?; + publish(&staging, out)?; + Ok(artifacts) + })(); + let _ = fs::remove_dir_all(staging); + result +} + +fn parse( + arguments: impl Iterator, +) -> Result<(String, Vec, bool), String> { + let mut arguments = arguments; + let command = arguments.next().unwrap_or_else(|| OsString::from("help")); + let command = command + .to_str() + .ok_or_else(|| "command must be UTF-8".to_owned())? + .to_owned(); + let mut paths = Vec::new(); + let mut json = false; + for argument in arguments { + if argument == "--json" { + if json { + return Err("--json can be specified once".to_owned()); + } + json = true; + } else if argument.to_string_lossy().starts_with('-') { + return Err(format!("unknown option: {}", argument.to_string_lossy())); + } else { + paths.push(PathBuf::from(argument)); + } + } + Ok((command, paths, json)) +} + +fn execute() -> Result<(), String> { + let (command, paths, json) = parse(env::args_os().skip(1))?; + match command.as_str() { + "help" | "-h" | "--help" => { + if !paths.is_empty() || json { + return Err("help takes no arguments or options".to_owned()); + } + println!("{USAGE}"); + } + "build" => { + if paths.len() > 2 { + return Err("build accepts at most two path arguments".to_owned()); + } + let out = paths + .first() + .map_or_else(|| Path::new("dist"), PathBuf::as_path); + let compiler = paths + .get(1) + .map_or_else(|| OsStr::new("clang++"), |path| path.as_os_str()); + let artifacts = build(out, compiler)?; + if json { + let values = artifacts + .iter() + .map(|artifact| { + format!( + "{{\"name\":\"{}\",\"bytes\":{}}}", + artifact.name, artifact.bytes + ) + }) + .collect::>() + .join(","); + println!("[{values}]"); + } else { + for artifact in artifacts { + println!("built {} {} bytes", artifact.name, artifact.bytes); + } + } + } + _ => return Err(format!("unknown command: {command}")), + } + Ok(()) +} + +fn main() { + if let Err(error) = execute() { + eprintln!("krad: {error}"); + std::process::exit(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn parses_paths_and_options() { + let parsed = parse( + ["build", "out", "clang++", "--json"] + .into_iter() + .map(OsString::from), + ) + .unwrap(); + assert_eq!( + parsed, + ( + "build".to_owned(), + vec!["out".into(), "clang++".into()], + true + ) + ); + assert!(parse(["build", "--bad"].into_iter().map(OsString::from)).is_err()); + assert!( + parse( + ["build", "--json", "--json"] + .into_iter() + .map(OsString::from) + ) + .is_err() + ); + } + + #[test] + fn publishes_and_recovers_whole_directories() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = env::temp_dir().join(format!("krad-publish-{}-{unique}", std::process::id())); + let out = root.join("dist"); + let staging = sidecar(&out, ".krad-staging").unwrap(); + let backup = sidecar(&out, ".krad-backup").unwrap(); + fs::create_dir_all(&out).unwrap(); + fs::write(out.join(".krad-artifacts"), MARKER).unwrap(); + fs::write(out.join("old"), b"old").unwrap(); + fs::create_dir(&staging).unwrap(); + fs::write(staging.join(".krad-artifacts"), MARKER).unwrap(); + fs::write(staging.join("new"), b"new").unwrap(); + + publish(&staging, &out).unwrap(); + assert!(!out.join("old").exists()); + assert_eq!(fs::read(out.join("new")).unwrap(), b"new"); + + fs::rename(&out, &backup).unwrap(); + recover(&staging, &out).unwrap(); + assert_eq!(fs::read(out.join("new")).unwrap(), b"new"); + assert!(!backup.exists()); + + let unowned = root.join("unowned"); + fs::create_dir(&unowned).unwrap(); + fs::write(unowned.join("keep"), b"keep").unwrap(); + assert!(require_owned_directory(&unowned).is_err()); + assert_eq!(fs::read(unowned.join("keep")).unwrap(), b"keep"); + let file = root.join("file"); + fs::write(&file, b"keep").unwrap(); + assert!(require_owned_directory(&file).is_err()); + assert_eq!(fs::read(&file).unwrap(), b"keep"); + + #[cfg(unix)] + { + let symlinked = root.join("symlinked"); + fs::create_dir(&symlinked).unwrap(); + fs::write(symlinked.join("keep"), b"keep").unwrap(); + std::os::unix::fs::symlink( + out.join(".krad-artifacts"), + symlinked.join(".krad-artifacts"), + ) + .unwrap(); + assert!(require_owned_directory(&symlinked).is_err()); + assert_eq!(fs::read(symlinked.join("keep")).unwrap(), b"keep"); + } + + let held = output_lock(&out).unwrap(); + let contender = OpenOptions::new() + .read(true) + .write(true) + .open(sidecar(&out, ".krad-lock").unwrap()) + .unwrap(); + assert!(matches!( + contender.try_lock(), + Err(std::fs::TryLockError::WouldBlock) + )); + drop(held); + contender.try_lock().unwrap(); + drop(contender); + + let blocked = root.join("blocked"); + let blocked_staging = sidecar(&blocked, ".krad-staging").unwrap(); + fs::create_dir(&blocked).unwrap(); + fs::write(blocked.join(".krad-artifacts"), MARKER).unwrap(); + fs::create_dir(&blocked_staging).unwrap(); + fs::write(blocked_staging.join("keep"), b"keep").unwrap(); + assert!(recover(&blocked_staging, &blocked).is_err()); + assert_eq!(fs::read(blocked_staging.join("keep")).unwrap(), b"keep"); + + let backup = sidecar(&blocked, ".krad-backup").unwrap(); + fs::create_dir(&backup).unwrap(); + fs::write(backup.join("keep"), b"keep").unwrap(); + assert!(recover(&root.join("missing-staging"), &blocked).is_err()); + assert_eq!(fs::read(backup.join("keep")).unwrap(), b"keep"); + + remove_path(&backup).unwrap(); + fs::create_dir(&backup).unwrap(); + fs::write(backup.join(".krad-artifacts"), MARKER).unwrap(); + fs::write(backup.join("old"), b"old").unwrap(); + recover(&root.join("missing-staging"), &blocked).unwrap(); + assert!(blocked.exists()); + assert!(!backup.exists()); + remove_path(&root).unwrap(); + } +} diff --git a/packages/krad-utils/tests/artifacts.rs b/packages/krad-utils/tests/artifacts.rs new file mode 100644 index 0000000..40bc9fc --- /dev/null +++ b/packages/krad-utils/tests/artifacts.rs @@ -0,0 +1,20 @@ +const ARTIFACTS: &[(&str, &[u8])] = &[ + ("krad-add", include_bytes!("../../../dist/krad-add.wasm")), + ( + "krad-clang", + include_bytes!("../../../dist/krad-clang.wasm"), + ), + ("krad-libs", include_bytes!("../../../dist/krad-libs.wasm")), + ("krad-musl", include_bytes!("../../../dist/krad-musl.wasm")), + ( + "krad-utils", + include_bytes!("../../../dist/krad-utils.wasm"), + ), +]; + +#[test] +fn ships_real_webassembly_artifacts() { + for (name, bytes) in ARTIFACTS { + assert!(bytes.starts_with(b"\0asm\x01\0\0\0"), "{name}"); + } +} diff --git a/packages/krad-utils/tests/cli.rs b/packages/krad-utils/tests/cli.rs new file mode 100644 index 0000000..56c5bc3 --- /dev/null +++ b/packages/krad-utils/tests/cli.rs @@ -0,0 +1,23 @@ +use std::process::Command; + +#[test] +fn reports_help_and_rejects_invalid_inputs() { + let binary = env!("CARGO_BIN_EXE_krad"); + let help = Command::new(binary).arg("--help").output().unwrap(); + assert!(help.status.success()); + assert!(String::from_utf8_lossy(&help.stdout).contains("Usage: krad")); + + let invalid = Command::new(binary).arg("unknown").output().unwrap(); + assert!(!invalid.status.success()); + assert_eq!( + String::from_utf8_lossy(&invalid.stderr), + "krad: unknown command: unknown\n" + ); + + let missing = Command::new(binary) + .args(["build", "unused", "/definitely/missing/clang++"]) + .output() + .unwrap(); + assert!(!missing.status.success()); + assert!(String::from_utf8_lossy(&missing.stderr).contains("could not execute")); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..3f9105e --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "1.97.1" +components = ["clippy", "rustfmt"] +profile = "minimal" +targets = ["wasm32v1-none"]