diff --git a/Cargo.lock b/Cargo.lock index 536bea8..6b15272 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,6 +33,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "ahash" version = "0.8.12" @@ -193,17 +204,21 @@ dependencies = [ name = "andromeda-runtime" version = "0.1.6" dependencies = [ + "aes", "andromeda-core", "anyhow", "anymap", "async-trait", "base64-simd", + "cbc", "chrono", "cosmic-text", + "ctr", "futures", "hotpath", "image", "lazy_static", + "libc", "libffi", "libloading 0.9.0", "lru", @@ -223,6 +238,7 @@ dependencies = [ "signal-hook 0.4.4", "socket2 0.6.3", "swash", + "sysinfo", "tempfile", "thiserror 2.0.18", "tokio", @@ -596,6 +612,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -733,6 +758,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.49" @@ -788,6 +822,16 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -1195,6 +1239,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -2140,7 +2193,7 @@ dependencies = [ "log", "presser", "thiserror 1.0.69", - "windows", + "windows 0.58.0", ] [[package]] @@ -2735,6 +2788,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -3530,6 +3593,15 @@ dependencies = [ "xsum", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -6220,6 +6292,19 @@ dependencies = [ "libc", ] +[[package]] +name = "sysinfo" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "windows 0.57.0", +] + [[package]] name = "system-configuration" version = "0.6.1" @@ -7534,7 +7619,7 @@ dependencies = [ "wasm-bindgen", "web-sys", "wgpu-types", - "windows", + "windows 0.58.0", "windows-core 0.58.0", ] @@ -7589,6 +7674,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.58.0" @@ -7599,6 +7694,18 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.58.0" @@ -7625,6 +7732,17 @@ dependencies = [ "windows-strings 0.5.1", ] +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.58.0" @@ -7647,6 +7765,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.58.0" @@ -7686,6 +7815,15 @@ dependencies = [ "windows-strings 0.5.1", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index b1e848a..1fa7586 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,6 +92,11 @@ webpki-roots = "1.0.7" wgpu = { version = "27.0.1", features = ["wgsl", "webgpu"] } winit = "0.30" raw-window-handle = "0.6" +sysinfo = { version = "0.32", default-features = false, features = ["system"] } +aes = "0.8" +cbc = { version = "0.1", features = ["alloc", "block-padding"] } +ctr = "0.9" +libc = "0.2" [profile.dev] split-debuginfo = "packed" diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index 613d236..175bcac 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -10,7 +10,7 @@ readme = "../../README.md" [features] default = [] canvas = ["dep:wgpu", "dep:image", "dep:lru", "dep:cosmic-text", "dep:swash"] -crypto = ["dep:ring", "dep:rand"] +crypto = ["dep:ring", "dep:rand", "dep:aes", "dep:cbc", "dep:ctr"] storage = ["dep:rusqlite"] virtualfs = ["storage"] serve = [] @@ -67,6 +67,11 @@ uuid.workspace = true wgpu = { workspace = true, optional = true } winit = { workspace = true, optional = true } raw-window-handle = { workspace = true, optional = true } +sysinfo = { workspace = true } +libc = { workspace = true } +aes = { workspace = true, optional = true } +cbc = { workspace = true, optional = true } +ctr = { workspace = true, optional = true } rustls.workspace = true tokio-rustls.workspace = true webpki-roots.workspace = true diff --git a/crates/runtime/src/ext/crypto/mod.ts b/crates/runtime/src/ext/crypto/mod.ts index 77963c9..1a936e4 100644 --- a/crates/runtime/src/ext/crypto/mod.ts +++ b/crates/runtime/src/ext/crypto/mod.ts @@ -40,26 +40,30 @@ const AlgorithmIdentifierConverter = webidl.createUnionConverter([ ]); // AesGcmParams dictionary -const AesGcmParams = webidl.createDictionaryConverter("AesGcmParams", [], [ - { - key: "name", - converter: webidl.converters.DOMString, - required: true, - }, - { - key: "iv", - converter: webidl.converters.BufferSource, - required: true, - }, - { - key: "additionalData", - converter: webidl.converters.BufferSource, - }, - { - key: "tagLength", - converter: webidl.converters.octet, - }, -]); +const AesGcmParams = webidl.createDictionaryConverter( + "AesGcmParams", + [], + [ + { + key: "name", + converter: webidl.converters.DOMString, + required: true, + }, + { + key: "iv", + converter: webidl.converters.BufferSource, + required: true, + }, + { + key: "additionalData", + converter: webidl.converters.BufferSource, + }, + { + key: "tagLength", + converter: webidl.converters.octet, + }, + ], +); // AesKeyGenParams dictionary const AesKeyGenParams = webidl.createDictionaryConverter( @@ -196,12 +200,78 @@ class CryptoKey { const CryptoKeyPrototype = CryptoKey.prototype; webidl.configureInterface(CryptoKey); +/** Internal factory — the public constructor is guarded by + * `webidl.illegalConstructor()` so user code can't mint a CryptoKey, but + * `generateKey` and `importKey` need a way to produce branded instances + * that pass the WebIDL converter in `encrypt`/`decrypt`/`sign`/`verify`. + * `webidl.createBranded` sets both the prototype and the internal brand + * symbol the converter checks for. */ +function createCryptoKey( + type: string, + extractable: boolean, + algorithm: object, + usages: string[], + handle: number, +): CryptoKey { + const key = webidl.createBranded(CryptoKey) as CryptoKey; + (key as any)[_type] = type; + (key as any)[_extractable] = extractable; + (key as any)[_algorithm] = algorithm; + (key as any)[_usages] = usages; + (key as any)[_handle] = handle; + return key; +} + // CryptoKey converter for WebIDL const CryptoKeyConverter = webidl.createInterfaceConverter( "CryptoKey", CryptoKeyPrototype, ); +/** Extract the JSON-serializable shape the Rust ops expect from a + * branded CryptoKey.*/ +function keyForRust(key: CryptoKey): string { + return JSON.stringify({ keyId: (key as any)[_handle] }); +} + +/** Convert a base64 string (as the Rust encrypt/decrypt ops return) into + * a spec-compliant `ArrayBuffer` for the `SubtleCrypto` contract. */ +function base64ToArrayBuffer(b64: string): ArrayBuffer { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; +} + +/** Encode an input buffer as a JSON array string so the Rust op can + * decode it with `serde_json`. `Value::to_string` on a Uint8Array yields + * a CSV of byte values — not parseable as bytes — so we canonicalize + * here. */ +function dataForRust(data: Uint8Array | ArrayBuffer): string { + const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data; + const arr: number[] = new Array(bytes.length); + for (let i = 0; i < bytes.length; i++) arr[i] = bytes[i]; + return JSON.stringify(arr); +} + +/** Serialize the normalized algorithm object for the Rust ops. Any + * `Uint8Array` / `ArrayBuffer` fields (IV, counter, additionalData) are + * flattened into plain number arrays so `serde_json` on the Rust side can + * read them without a binary-aware codec. */ +function algorithmForRust(alg: object): string { + const out: Record = {}; + for (const [k, v] of Object.entries(alg as Record)) { + if (v instanceof Uint8Array) { + out[k] = Array.from(v); + } else if (v instanceof ArrayBuffer) { + out[k] = Array.from(new Uint8Array(v)); + } else { + out[k] = v; + } + } + return JSON.stringify(out); +} + // Helper: Normalize algorithm identifier to algorithm object function normalizeAlgorithm( algorithm: AlgorithmIdentifier, @@ -216,9 +286,7 @@ function normalizeAlgorithm( if (typeof algorithm === "object" && algorithm !== null) { const alg = algorithm as unknown as Record; if (!alg.name || typeof alg.name !== "string") { - throw new TypeError( - `Algorithm: name: Missing or not a string`, - ); + throw new TypeError(`Algorithm: name: Missing or not a string`); } // Return a copy to avoid mutation return { ...alg }; @@ -228,25 +296,15 @@ function normalizeAlgorithm( } // Helper: Validate key usages -function validateKeyUsages( - usages: KeyUsage[], - validUsages: KeyUsage[], -): void { +function validateKeyUsages(usages: KeyUsage[], validUsages: KeyUsage[]): void { for (const usage of usages) { if (!validUsages.includes(usage)) { - throw new DOMException( - `Unsupported key usage: ${usage}`, - "SyntaxError", - ); + throw new DOMException(`Unsupported key usage: ${usage}`, "SyntaxError"); } } } -// Helper: Get usage intersection -function usageIntersection( - a: KeyUsage[], - b: KeyUsage[], -): KeyUsage[] { +function usageIntersection(a: KeyUsage[], b: KeyUsage[]): KeyUsage[] { return a.filter((usage) => b.includes(usage)); } @@ -275,25 +333,17 @@ const subtle = { const prefix = "Failed to execute 'digest' on 'SubtleCrypto'"; webidl.requiredArguments(arguments.length, 2, prefix); - algorithm = AlgorithmIdentifierConverter( - algorithm, - prefix, - "Argument 1", - ); - data = webidl.converters.BufferSource( - data, - prefix, - "Argument 2", - ); + algorithm = AlgorithmIdentifierConverter(algorithm, prefix, "Argument 1"); + data = webidl.converters.BufferSource(data, prefix, "Argument 2"); // Normalize algorithm const normalizedAlgorithm = normalizeAlgorithm(algorithm, "digest"); // Extract algorithm name for Rust FFI (Rust expects a simple string) const algorithmName = - typeof normalizedAlgorithm === "object" && normalizedAlgorithm !== null ? - (normalizedAlgorithm as any).name : - String(normalizedAlgorithm); + typeof normalizedAlgorithm === "object" && normalizedAlgorithm !== null + ? (normalizedAlgorithm as any).name + : String(normalizedAlgorithm); const result = __andromeda__.internal_subtle_digest(algorithmName, data); @@ -334,11 +384,7 @@ const subtle = { // Test converters one by one webidl.requiredArguments(3, 3, prefix); - algorithm = AlgorithmIdentifierConverter( - algorithm, - prefix, - "Argument 1", - ); + algorithm = AlgorithmIdentifierConverter(algorithm, prefix, "Argument 1"); extractable = webidl.converters.boolean(extractable); keyUsages = webidl.createSequenceConverter(KeyUsage)( @@ -352,9 +398,9 @@ const subtle = { // Extract algorithm name for Rust FFI (Rust expects a simple string) const algorithmName = - typeof normalizedAlgorithm === "object" && normalizedAlgorithm !== null ? - (normalizedAlgorithm as any).name : - String(normalizedAlgorithm); + typeof normalizedAlgorithm === "object" && normalizedAlgorithm !== null + ? (normalizedAlgorithm as any).name + : String(normalizedAlgorithm); const result = __andromeda__.internal_subtle_generateKey( algorithmName, @@ -365,7 +411,16 @@ const subtle = { // Parse the result - Rust returns a JSON string if (typeof result === "string") { const keyData = JSON.parse(result); - return keyData; // Return the plain object for now + // Wrap the plain JSON object into a branded CryptoKey so downstream + // encrypt/decrypt/sign/verify converters accept it. `keyId` from + // Rust becomes the internal `_handle` slot the ops look up. + return createCryptoKey( + keyData.type, + keyData.extractable, + keyData.algorithm ?? normalizedAlgorithm, + keyData.usages, + keyData.keyId, + ); } return result; @@ -404,11 +459,7 @@ const subtle = { keyData = webidl.converters.BufferSource(keyData, prefix, "Argument 2"); } - algorithm = AlgorithmIdentifierConverter( - algorithm, - prefix, - "Argument 3", - ); + algorithm = AlgorithmIdentifierConverter(algorithm, prefix, "Argument 3"); extractable = webidl.converters.boolean(extractable); keyUsages = webidl.createSequenceConverter(KeyUsage)( keyUsages, @@ -475,28 +526,20 @@ const subtle = { const prefix = "Failed to execute 'encrypt' on 'SubtleCrypto'"; webidl.requiredArguments(arguments.length, 3, prefix); - algorithm = AlgorithmIdentifierConverter( - algorithm, - prefix, - "Argument 1", - ); + algorithm = AlgorithmIdentifierConverter(algorithm, prefix, "Argument 1"); key = CryptoKeyConverter(key, prefix, "Argument 2"); - data = webidl.converters.BufferSource( - data, - prefix, - "Argument 3", - ); + data = webidl.converters.BufferSource(data, prefix, "Argument 3"); // Normalize algorithm const normalizedAlgorithm = normalizeAlgorithm(algorithm, "encrypt"); const result = __andromeda__.internal_subtle_encrypt( - normalizedAlgorithm, - key, - data, + algorithmForRust(normalizedAlgorithm), + keyForRust(key), + dataForRust(data), ); - return result; + return base64ToArrayBuffer(result as unknown as string); }, /** @@ -521,28 +564,20 @@ const subtle = { const prefix = "Failed to execute 'decrypt' on 'SubtleCrypto'"; webidl.requiredArguments(arguments.length, 3, prefix); - algorithm = AlgorithmIdentifierConverter( - algorithm, - prefix, - "Argument 1", - ); + algorithm = AlgorithmIdentifierConverter(algorithm, prefix, "Argument 1"); key = CryptoKeyConverter(key, prefix, "Argument 2"); - data = webidl.converters.BufferSource( - data, - prefix, - "Argument 3", - ); + data = webidl.converters.BufferSource(data, prefix, "Argument 3"); // Normalize algorithm const normalizedAlgorithm = normalizeAlgorithm(algorithm, "decrypt"); const result = __andromeda__.internal_subtle_decrypt( - normalizedAlgorithm, - key, - data, + algorithmForRust(normalizedAlgorithm), + keyForRust(key), + dataForRust(data), ); - return result; + return base64ToArrayBuffer(result as unknown as string); }, /** @@ -570,17 +605,9 @@ const subtle = { const prefix = "Failed to execute 'sign' on 'SubtleCrypto'"; webidl.requiredArguments(arguments.length, 3, prefix); - algorithm = AlgorithmIdentifierConverter( - algorithm, - prefix, - "Argument 1", - ); + algorithm = AlgorithmIdentifierConverter(algorithm, prefix, "Argument 1"); key = CryptoKeyConverter(key, prefix, "Argument 2"); - data = webidl.converters.BufferSource( - data, - prefix, - "Argument 3", - ); + data = webidl.converters.BufferSource(data, prefix, "Argument 3"); // Normalize algorithm const normalizedAlgorithm = normalizeAlgorithm(algorithm, "sign"); @@ -615,22 +642,10 @@ const subtle = { const prefix = "Failed to execute 'verify' on 'SubtleCrypto'"; webidl.requiredArguments(arguments.length, 4, prefix); - algorithm = AlgorithmIdentifierConverter( - algorithm, - prefix, - "Argument 1", - ); + algorithm = AlgorithmIdentifierConverter(algorithm, prefix, "Argument 1"); key = CryptoKeyConverter(key, prefix, "Argument 2"); - signature = webidl.converters.BufferSource( - signature, - prefix, - "Argument 3", - ); - data = webidl.converters.BufferSource( - data, - prefix, - "Argument 4", - ); + signature = webidl.converters.BufferSource(signature, prefix, "Argument 3"); + data = webidl.converters.BufferSource(data, prefix, "Argument 4"); // Normalize algorithm const normalizedAlgorithm = normalizeAlgorithm(algorithm, "verify"); @@ -669,11 +684,7 @@ const subtle = { const prefix = "Failed to execute 'deriveKey' on 'SubtleCrypto'"; webidl.requiredArguments(arguments.length, 5, prefix); - algorithm = AlgorithmIdentifierConverter( - algorithm, - prefix, - "Argument 1", - ); + algorithm = AlgorithmIdentifierConverter(algorithm, prefix, "Argument 1"); baseKey = CryptoKeyConverter(baseKey, prefix, "Argument 2"); derivedKeyType = AlgorithmIdentifierConverter( derivedKeyType, @@ -725,19 +736,11 @@ const subtle = { const prefix = "Failed to execute 'deriveBits' on 'SubtleCrypto'"; webidl.requiredArguments(arguments.length, 2, prefix); - algorithm = AlgorithmIdentifierConverter( - algorithm, - prefix, - "Argument 1", - ); + algorithm = AlgorithmIdentifierConverter(algorithm, prefix, "Argument 1"); baseKey = CryptoKeyConverter(baseKey, prefix, "Argument 2"); if (length !== undefined) { - length = webidl.converters["unsigned long"]( - length, - prefix, - "Argument 3", - ); + length = webidl.converters["unsigned long"](length, prefix, "Argument 3"); } // Normalize algorithm diff --git a/crates/runtime/src/ext/crypto/subtle.rs b/crates/runtime/src/ext/crypto/subtle.rs index 40aafda..b99e16e 100644 --- a/crates/runtime/src/ext/crypto/subtle.rs +++ b/crates/runtime/src/ext/crypto/subtle.rs @@ -167,78 +167,175 @@ impl SubtleCrypto { } /// Helper function to extract bytes from a Value (Uint8Array or similar) + /// Decode a TS-supplied buffer into `Vec`. + /// + /// The TS layer always sends a JSON-array-of-bytes string via + /// `dataForRust()` (Nova can't round-trip Uint8Array through `ToString` + /// without lossy formatting). Fallbacks: + /// 1. JSON array `[1, 2, 3]` — primary path. + /// 2. All-hex string — accepted so tests / legacy callers still work. + /// 3. Anything else — treated as raw UTF-8 bytes. fn extract_bytes_from_value( agent: &mut Agent, value: Value, gc: GcScope<'_, '_>, ) -> Result, String> { - // Try to convert to string first as a fallback - if let Ok(str_value) = value.to_string(agent, gc) { - let string_data = str_value.as_str(agent).ok_or("Invalid string")?; - // If it looks like hex data, try to decode it - if string_data.chars().all(|c| c.is_ascii_hexdigit()) && string_data.len() % 2 == 0 { - let mut bytes = Vec::new(); - for chunk in string_data.as_bytes().chunks(2) { - if let Ok(byte_str) = std::str::from_utf8(chunk) - && let Ok(byte_val) = u8::from_str_radix(byte_str, 16) - { - bytes.push(byte_val); - } - } - if !bytes.is_empty() { - return Ok(bytes); + let Ok(str_value) = value.to_string(agent, gc) else { + return Err("data argument is not representable as a string".to_string()); + }; + let string_data = str_value.as_str(agent).ok_or("Invalid string")?; + + // 1. Canonical JSON-array path (what `dataForRust` emits). + if string_data.starts_with('[') + && let Ok(json) = serde_json::from_str::(string_data) + && let Some(arr) = json.as_array() + { + return Ok(arr + .iter() + .filter_map(|v| v.as_u64().map(|n| n as u8)) + .collect()); + } + + // 2. Hex fallback — useful for raw keys / IVs supplied as hex text. + if !string_data.is_empty() + && string_data.len() % 2 == 0 + && string_data.chars().all(|c| c.is_ascii_hexdigit()) + { + let mut bytes = Vec::with_capacity(string_data.len() / 2); + for chunk in string_data.as_bytes().chunks(2) { + if let Ok(byte_str) = std::str::from_utf8(chunk) + && let Ok(byte_val) = u8::from_str_radix(byte_str, 16) + { + bytes.push(byte_val); } } - // Otherwise treat as UTF-8 string - Ok(string_data.as_bytes().to_vec()) - } else { - // For now, return a test message when we can't extract properly - // TODO: Implement proper TypedArray extraction when Nova VM supports it - Ok("Secret message!".as_bytes().to_vec()) + if !bytes.is_empty() { + return Ok(bytes); + } } + + // 3. Last resort: treat as UTF-8. + Ok(string_data.as_bytes().to_vec()) } - /// Helper function to parse algorithm from JS value + /// Parse an algorithm descriptor coming from TS. + /// + /// The TS layer always passes a **JSON-stringified** object via + /// `algorithmForRust()` — a plain `name` for bare hashes (e.g. + /// `{"name":"SHA-256"}`) and richer shapes for AES modes that carry + /// their IV / counter as arrays of byte values. Legacy string + /// algorithms (e.g. a bare `"SHA-256"`) are still accepted so the + /// digest path stays compatible. fn parse_algorithm( agent: &mut Agent, algo_value: Value, gc: GcScope<'_, '_>, ) -> Result { - if let Ok(algorithm_str) = algo_value.to_string(agent, gc) { - let algorithm_name = algorithm_str - .as_str(agent) - .expect("String is not valid UTF-8"); - - match algorithm_name { - "SHA-1" => Ok(CryptoAlgorithm::Sha1), - "SHA-256" => Ok(CryptoAlgorithm::Sha256), - "SHA-384" => Ok(CryptoAlgorithm::Sha384), - "SHA-512" => Ok(CryptoAlgorithm::Sha512), - "HMAC" => Ok(CryptoAlgorithm::Hmac { - hash: "SHA-256".to_string(), - length: None, - }), - "AES-GCM" => Ok(CryptoAlgorithm::AesGcm { - length: 256, - iv: vec![0u8; 12], // Placeholder IV - iv_length: Some(12), - additional_data: None, - tag_length: Some(16), - }), - "AES-CBC" => Ok(CryptoAlgorithm::AesCbc { + let algorithm_str = algo_value + .to_string(agent, gc) + .map_err(|_| "Algorithm argument must be a string or object".to_string())?; + let raw = algorithm_str + .as_str(agent) + .expect("String is not valid UTF-8"); + + // Try JSON first — that's what `algorithmForRust` emits. + if let Ok(json) = serde_json::from_str::(raw) { + if let Some(name) = json.get("name").and_then(|v| v.as_str()) { + return Self::algorithm_from_json(name, &json); + } + } + + // Fallback: bare algorithm name (legacy digest path). + Self::algorithm_from_name(raw) + } + + fn algorithm_from_name(name: &str) -> Result { + match name { + "SHA-1" => Ok(CryptoAlgorithm::Sha1), + "SHA-256" => Ok(CryptoAlgorithm::Sha256), + "SHA-384" => Ok(CryptoAlgorithm::Sha384), + "SHA-512" => Ok(CryptoAlgorithm::Sha512), + "HMAC" => Ok(CryptoAlgorithm::Hmac { + hash: "SHA-256".to_string(), + length: None, + }), + "AES-GCM" => Ok(CryptoAlgorithm::AesGcm { + length: 256, + iv: vec![0u8; 12], + iv_length: Some(12), + additional_data: None, + tag_length: Some(16), + }), + "AES-CBC" => Ok(CryptoAlgorithm::AesCbc { + length: 256, + iv: vec![0u8; 16], + }), + "AES-CTR" => Ok(CryptoAlgorithm::AesCtr { + length: 256, + counter: vec![0u8; 16], + counter_length: 64, + }), + _ => Err(format!("Unsupported algorithm: {name}")), + } + } + + fn algorithm_from_json( + name: &str, + json: &serde_json::Value, + ) -> Result { + let byte_array = |field: &str| -> Option> { + json.get(field) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|n| n.as_u64().map(|x| x as u8)) + .collect() + }) + }; + match name { + "SHA-1" => Ok(CryptoAlgorithm::Sha1), + "SHA-256" => Ok(CryptoAlgorithm::Sha256), + "SHA-384" => Ok(CryptoAlgorithm::Sha384), + "SHA-512" => Ok(CryptoAlgorithm::Sha512), + "HMAC" => Ok(CryptoAlgorithm::Hmac { + hash: json + .get("hash") + .and_then(|v| v.as_str()) + .unwrap_or("SHA-256") + .to_string(), + length: json.get("length").and_then(|v| v.as_u64()).map(|n| n as u32), + }), + "AES-GCM" => { + let iv = byte_array("iv").unwrap_or_else(|| vec![0u8; 12]); + Ok(CryptoAlgorithm::AesGcm { length: 256, - iv: vec![0u8; 16], // Placeholder IV - }), - "AES-CTR" => Ok(CryptoAlgorithm::AesCtr { + iv_length: Some(iv.len() as u32), + iv, + additional_data: byte_array("additionalData"), + tag_length: json + .get("tagLength") + .and_then(|v| v.as_u64()) + .map(|n| n as u32) + .or(Some(16)), + }) + } + "AES-CBC" => { + let iv = byte_array("iv").ok_or("AES-CBC: missing iv")?; + Ok(CryptoAlgorithm::AesCbc { length: 256, iv }) + } + "AES-CTR" => { + let counter = byte_array("counter").ok_or("AES-CTR: missing counter")?; + Ok(CryptoAlgorithm::AesCtr { length: 256, - counter: vec![0u8; 16], // Placeholder counter - counter_length: 64, - }), - _ => Err(format!("Unsupported algorithm: {algorithm_name}")), + counter, + counter_length: json + .get("length") + .and_then(|v| v.as_u64()) + .map(|n| n as u32) + .unwrap_or(64), + }) } - } else { - // TODO: Handle algorithm objects (e.g., { name: "SHA-256" }) - Err("Algorithm must be a string or object with name property".to_string()) + _ => Err(format!("Unsupported algorithm: {name}")), } } pub fn digest<'gc>( @@ -900,20 +997,89 @@ impl SubtleCrypto { Ok(result) } - fn encrypt_aes_cbc(_key_data: &[u8], _plaintext: &[u8], _iv: &[u8]) -> Result, String> { - // TODO: Implement AES-CBC encryption using ring or another crate - // Ring doesn't provide AES-CBC, so we'd need another dependency - Err("AES-CBC encryption not yet implemented".to_string()) + /// AES-CBC encryption with PKCS#7 padding. Per the Web Crypto spec + /// (https://w3c.github.io/webcrypto/#aes-cbc-operations), the IV must + /// be exactly 16 bytes and padding is PKCS#7. Supports 128/192/256-bit + /// keys by dispatching on `key_data.len()`. + fn encrypt_aes_cbc(key_data: &[u8], plaintext: &[u8], iv: &[u8]) -> Result, String> { + use cbc::cipher::{BlockEncryptMut, KeyIvInit, block_padding::Pkcs7}; + + if iv.len() != 16 { + return Err(format!( + "AES-CBC IV must be 16 bytes, got {}", + iv.len() + )); + } + + match key_data.len() { + 16 => { + type Enc = cbc::Encryptor; + Ok(Enc::new(key_data.into(), iv.into()) + .encrypt_padded_vec_mut::(plaintext)) + } + 24 => { + type Enc = cbc::Encryptor; + Ok(Enc::new(key_data.into(), iv.into()) + .encrypt_padded_vec_mut::(plaintext)) + } + 32 => { + type Enc = cbc::Encryptor; + Ok(Enc::new(key_data.into(), iv.into()) + .encrypt_padded_vec_mut::(plaintext)) + } + n => Err(format!( + "AES-CBC key must be 16, 24, or 32 bytes; got {n}" + )), + } } + /// AES-CTR encryption. Counter block must be 16 bytes. No padding + /// (CTR is a stream cipher). Supports 128/192/256-bit keys. + /// + /// This uses a full 128-bit big-endian counter. The Web Crypto spec + /// allows a partial-bit counter via `AesCtrParams.length`, but real + /// implementations overwhelmingly use the 128-bit form — and our + /// algorithm parser currently stores only the counter block. If + /// partial-bit counters become needed, thread the `length` value + /// here and pick the appropriate `Ctr` type. fn encrypt_aes_ctr( - _key_data: &[u8], - _plaintext: &[u8], - _counter: &[u8], + key_data: &[u8], + plaintext: &[u8], + counter: &[u8], ) -> Result, String> { - // TODO: Implement AES-CTR encryption using ring or another crate - // Ring doesn't provide AES-CTR, so we'd need another dependency - Err("AES-CTR encryption not yet implemented".to_string()) + use ctr::cipher::{KeyIvInit, StreamCipher}; + + if counter.len() != 16 { + return Err(format!( + "AES-CTR counter block must be 16 bytes, got {}", + counter.len() + )); + } + + let mut out = plaintext.to_vec(); + match key_data.len() { + 16 => { + type Cipher = ctr::Ctr128BE; + Cipher::new(key_data.into(), counter.into()) + .apply_keystream(&mut out); + } + 24 => { + type Cipher = ctr::Ctr128BE; + Cipher::new(key_data.into(), counter.into()) + .apply_keystream(&mut out); + } + 32 => { + type Cipher = ctr::Ctr128BE; + Cipher::new(key_data.into(), counter.into()) + .apply_keystream(&mut out); + } + n => { + return Err(format!( + "AES-CTR key must be 16, 24, or 32 bytes; got {n}" + )); + } + } + Ok(out) } pub fn decrypt<'gc>( @@ -989,11 +1155,11 @@ impl SubtleCrypto { CryptoAlgorithm::AesGcm { .. } => { Self::decrypt_aes_gcm(&crypto_key.key_data, &ciphertext) } - CryptoAlgorithm::AesCbc { .. } => { - Self::decrypt_aes_cbc(&crypto_key.key_data, &ciphertext) + CryptoAlgorithm::AesCbc { iv, .. } => { + Self::decrypt_aes_cbc(&crypto_key.key_data, &ciphertext, &iv) } - CryptoAlgorithm::AesCtr { .. } => { - Self::decrypt_aes_ctr(&crypto_key.key_data, &ciphertext) + CryptoAlgorithm::AesCtr { counter, .. } => { + Self::decrypt_aes_ctr(&crypto_key.key_data, &ciphertext, &counter) } _ => Err("Unsupported decryption algorithm".to_string()), }; @@ -1054,16 +1220,64 @@ impl SubtleCrypto { Ok(plaintext.to_vec()) } - fn decrypt_aes_cbc(_key_data: &[u8], _ciphertext: &[u8]) -> Result, String> { - // TODO: Implement AES-CBC decryption using ring or another crate - // Ring doesn't provide AES-CBC, so we'd need another dependency - Err("AES-CBC decryption not yet implemented".to_string()) + /// AES-CBC decryption with PKCS#7 unpadding. + fn decrypt_aes_cbc( + key_data: &[u8], + ciphertext: &[u8], + iv: &[u8], + ) -> Result, String> { + use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::Pkcs7}; + + if iv.len() != 16 { + return Err(format!( + "AES-CBC IV must be 16 bytes, got {}", + iv.len() + )); + } + if ciphertext.is_empty() || ciphertext.len() % 16 != 0 { + return Err(format!( + "AES-CBC ciphertext length must be a positive multiple of 16, got {}", + ciphertext.len() + )); + } + + let result = match key_data.len() { + 16 => { + type Dec = cbc::Decryptor; + Dec::new(key_data.into(), iv.into()) + .decrypt_padded_vec_mut::(ciphertext) + .map_err(|e| format!("AES-CBC decrypt: {e}"))? + } + 24 => { + type Dec = cbc::Decryptor; + Dec::new(key_data.into(), iv.into()) + .decrypt_padded_vec_mut::(ciphertext) + .map_err(|e| format!("AES-CBC decrypt: {e}"))? + } + 32 => { + type Dec = cbc::Decryptor; + Dec::new(key_data.into(), iv.into()) + .decrypt_padded_vec_mut::(ciphertext) + .map_err(|e| format!("AES-CBC decrypt: {e}"))? + } + n => { + return Err(format!( + "AES-CBC key must be 16, 24, or 32 bytes; got {n}" + )); + } + }; + Ok(result) } - fn decrypt_aes_ctr(_key_data: &[u8], _ciphertext: &[u8]) -> Result, String> { - // TODO: Implement AES-CTR decryption using ring or another crate - // Ring doesn't provide AES-CTR, so we'd need another dependency - Err("AES-CTR decryption not yet implemented".to_string()) + /// AES-CTR decryption — same operation as encryption for a stream + /// cipher, but we keep a separate function for symmetry with the CBC + /// path and to let a future `length`-aware implementation diverge. + fn decrypt_aes_ctr( + key_data: &[u8], + ciphertext: &[u8], + counter: &[u8], + ) -> Result, String> { + Self::encrypt_aes_ctr(key_data, ciphertext, counter) } pub fn sign<'gc>( diff --git a/crates/runtime/src/ext/mod.rs b/crates/runtime/src/ext/mod.rs index c7b4848..70e04ef 100644 --- a/crates/runtime/src/ext/mod.rs +++ b/crates/runtime/src/ext/mod.rs @@ -21,6 +21,7 @@ mod http; #[cfg(feature = "storage")] mod local_storage; mod net; +mod os; mod process; #[cfg(feature = "storage")] mod sqlite; @@ -54,6 +55,7 @@ pub use http::*; #[cfg(feature = "storage")] pub use local_storage::*; pub use net::*; +pub use os::*; pub use process::*; #[cfg(feature = "storage")] pub use sqlite::*; diff --git a/crates/runtime/src/ext/os/mod.rs b/crates/runtime/src/ext/os/mod.rs new file mode 100644 index 0000000..70b642e --- /dev/null +++ b/crates/runtime/src/ext/os/mod.rs @@ -0,0 +1,164 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use andromeda_core::{Extension, ExtensionOp}; +use nova_vm::{ + ecmascript::{Agent, ArgumentsList, JsResult, Value}, + engine::{Bindable, GcScope}, +}; +use std::os::fd::AsRawFd; +use sysinfo::System; + +/// OS extension for Andromeda. +#[derive(Default)] +pub struct OsExt; + +impl OsExt { + pub fn new_extension() -> Extension { + Extension { + name: "os", + ops: vec![ + ExtensionOp::new("internal_os_hostname", Self::hostname, 0, false), + ExtensionOp::new("internal_os_release", Self::os_release, 0, false), + ExtensionOp::new("internal_os_name", Self::os_name, 0, false), + ExtensionOp::new("internal_os_uptime", Self::os_uptime, 0, false), + ExtensionOp::new("internal_os_loadavg", Self::loadavg, 0, false), + ExtensionOp::new("internal_os_memory_usage", Self::memory_usage, 0, false), + ExtensionOp::new("internal_os_console_size", Self::console_size, 0, false), + ], + storage: None, + files: vec![include_str!("./mod.ts")], + } + } + + /// `Andromeda.hostname() -> string` + fn hostname<'gc>( + agent: &mut Agent, + _this: Value, + _args: ArgumentsList, + gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let name = System::host_name().unwrap_or_else(|| "unknown".to_string()); + Ok(Value::from_string(agent, name, gc.nogc()).unbind()) + } + + /// `Andromeda.osRelease() -> string` — kernel version string (e.g. `"23.4.0"` on macOS). + fn os_release<'gc>( + agent: &mut Agent, + _this: Value, + _args: ArgumentsList, + gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let release = System::kernel_version().unwrap_or_else(|| "unknown".to_string()); + Ok(Value::from_string(agent, release, gc.nogc()).unbind()) + } + + /// Operating system identifier — matches Deno's `Deno.build.os` + fn os_name<'gc>( + agent: &mut Agent, + _this: Value, + _args: ArgumentsList, + gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + // `std::env::consts::OS` gives "macos" / "linux" / "windows"; + // normalize the macOS name to match Deno ("darwin"). + let name = match std::env::consts::OS { + "macos" => "darwin", + other => other, + }; + Ok(Value::from_string(agent, name.to_string(), gc.nogc()).unbind()) + } + + /// `Andromeda.osUptime() -> number` — seconds since boot. + fn os_uptime<'gc>( + agent: &mut Agent, + _this: Value, + _args: ArgumentsList, + gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let uptime = System::uptime(); + Ok(Value::from_f64(agent, uptime as f64, gc.nogc()).unbind()) + } + + /// `Andromeda.loadavg() -> [number, number, number]` — 1 / 5 / 15-minute + /// averages. Returns `[0, 0, 0]` on Windows (matches Deno). + fn loadavg<'gc>( + agent: &mut Agent, + _this: Value, + _args: ArgumentsList, + gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let load = System::load_average(); + // Return as JSON array — avoids constructing a Nova Array here; + // the TS shim parses it. + let json = format!("[{}, {}, {}]", load.one, load.five, load.fifteen); + Ok(Value::from_string(agent, json, gc.nogc()).unbind()) + } + + /// `Andromeda.memoryUsage() -> { rss, heapTotal, heapUsed, external }` + /// + /// `rss` is the process resident set size, sourced from sysinfo. Nova + /// does not currently expose V8-style heap accounting, so + /// `heapTotal` / `heapUsed` / `external` mirror the RSS figure (shape + /// compat with Deno for code that only inspects `rss` — other fields + /// are non-zero placeholders pending upstream Nova introspection). + fn memory_usage<'gc>( + agent: &mut Agent, + _this: Value, + _args: ArgumentsList, + gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let mut sys = System::new(); + sys.refresh_memory(); + let pid = sysinfo::get_current_pid().ok(); + let rss = pid + .and_then(|p| { + sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[p]), true); + sys.process(p).map(|proc| proc.memory()) + }) + .unwrap_or(0); + let json = + format!("{{\"rss\":{rss},\"heapTotal\":{rss},\"heapUsed\":{rss},\"external\":0}}"); + Ok(Value::from_string(agent, json, gc.nogc()).unbind()) + } + + /// `Andromeda.consoleSize() -> { columns, rows }` — queries the TTY + /// attached to stdout. Returns `{columns: 80, rows: 24}` when stdout is + /// not a TTY (same fallback shape Deno uses when there is no terminal). + fn console_size<'gc>( + agent: &mut Agent, + _this: Value, + _args: ArgumentsList, + gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let (cols, rows) = terminal_size().unwrap_or((80, 24)); + let json = format!("{{\"columns\":{cols},\"rows\":{rows}}}"); + Ok(Value::from_string(agent, json, gc.nogc()).unbind()) + } +} + +#[cfg(unix)] +fn terminal_size() -> Option<(u16, u16)> { + let mut ws: libc::winsize = unsafe { std::mem::zeroed() }; + let fd = std::io::stdout().as_raw_fd(); + let result = unsafe { libc::ioctl(fd, libc::TIOCGWINSZ, &mut ws) }; + if result == 0 && ws.ws_col > 0 && ws.ws_row > 0 { + Some((ws.ws_col, ws.ws_row)) + } else { + None + } +} + +#[cfg(windows)] +fn terminal_size() -> Option<(u16, u16)> { + // Windows console-size detection needs Win32's GetConsoleScreenBufferInfo. + // Not implemented in this pass — fallback `(80, 24)` is what Deno also + // returns for non-TTY stdout. Tracked for a follow-up. + None +} + +#[cfg(not(any(unix, windows)))] +fn terminal_size() -> Option<(u16, u16)> { + None +} diff --git a/crates/runtime/src/ext/os/mod.ts b/crates/runtime/src/ext/os/mod.ts new file mode 100644 index 0000000..51f2220 --- /dev/null +++ b/crates/runtime/src/ext/os/mod.ts @@ -0,0 +1,43 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// @ts-ignore - cross-file handoff +(globalThis as any).__andromeda_os = { + hostname(): string { + // @ts-ignore - internal op surface + return (globalThis as any).__andromeda__.internal_os_hostname(); + }, + osRelease(): string { + // @ts-ignore - internal op surface + return (globalThis as any).__andromeda__.internal_os_release(); + }, + osName(): string { + // @ts-ignore - internal op surface + return (globalThis as any).__andromeda__.internal_os_name(); + }, + osUptime(): number { + // @ts-ignore - internal op surface + return Number((globalThis as any).__andromeda__.internal_os_uptime()); + }, + loadavg(): [number, number, number] { + // @ts-ignore - internal op surface + const raw = (globalThis as any).__andromeda__.internal_os_loadavg(); + return JSON.parse(raw); + }, + memoryUsage(): { + rss: number; + heapTotal: number; + heapUsed: number; + external: number; + } { + // @ts-ignore - internal op surface + const raw = (globalThis as any).__andromeda__.internal_os_memory_usage(); + return JSON.parse(raw); + }, + consoleSize(): { columns: number; rows: number } { + // @ts-ignore - internal op surface + const raw = (globalThis as any).__andromeda__.internal_os_console_size(); + return JSON.parse(raw); + }, +}; diff --git a/crates/runtime/src/ext/window/mod.ts b/crates/runtime/src/ext/window/mod.ts index 202e7f5..6060607 100644 --- a/crates/runtime/src/ext/window/mod.ts +++ b/crates/runtime/src/ext/window/mod.ts @@ -267,7 +267,8 @@ async function __andromeda_window_mainloop( await callback(); } // Yield to the event loop so timers/promises can run. - await new Promise((r) => setTimeout(r, 0)); + // Prefer a shared already-resolved promise to avoid per-frame timer allocation churn. + await Promise.resolve(); } } finally { for (const w of Array.from(__andromeda_window_registry.values())) { diff --git a/crates/runtime/src/recommended.rs b/crates/runtime/src/recommended.rs index f0c8460..a928680 100644 --- a/crates/runtime/src/recommended.rs +++ b/crates/runtime/src/recommended.rs @@ -10,7 +10,7 @@ use nova_vm::{ use crate::{ BroadcastChannelExt, CommandExt, ConsoleExt, CronExt, FetchExt, FfiExt, FileExt, NetExt, - ProcessExt, RuntimeMacroTask, StreamsExt, TimeExt, TlsExt, URLExt, WebExt, WebIDLExt, + OsExt, ProcessExt, RuntimeMacroTask, StreamsExt, TimeExt, TlsExt, URLExt, WebExt, WebIDLExt, WebLocksExt, }; @@ -31,6 +31,7 @@ pub fn recommended_extensions() -> Vec { TimeExt::new_extension(), CronExt::new_extension(), ProcessExt::new_extension(), + OsExt::new_extension(), CommandExt::new_extension(), URLExt::new_extension(), WebExt::new_extension(), diff --git a/examples/os.ts b/examples/os.ts new file mode 100644 index 0000000..fd9019e --- /dev/null +++ b/examples/os.ts @@ -0,0 +1,36 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +function formatUptime(seconds: number): string { + const total = Math.max(0, Math.floor(seconds)); + const days = Math.floor(total / 86_400); + const hours = Math.floor((total % 86_400) / 3_600); + const minutes = Math.floor((total % 3_600) / 60); + const secs = total % 60; + + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0 || days > 0) parts.push(`${hours}h`); + if (minutes > 0 || hours > 0 || days > 0) parts.push(`${minutes}m`); + parts.push(`${secs}s`); + return parts.join(" "); +} + +const hostname = Andromeda.hostname(); +const release = Andromeda.osRelease(); +const uptimeSeconds = Andromeda.osUptime(); +const load = Andromeda.loadavg(); +const memory = Andromeda.memoryUsage(); +const consoleSize = Andromeda.consoleSize(); + +console.log(`Hostname: ${hostname}`); +console.log(`OS Release: ${release}`); +console.log(`Uptime: ${uptimeSeconds}s (${formatUptime(uptimeSeconds)})`); +console.log(`Load Average (1m, 5m, 15m): [${load[0]}, ${load[1]}, ${load[2]}]`); +console.log( + `Memory Usage (bytes): rss=${memory.rss}, heapTotal=${memory.heapTotal}, heapUsed=${memory.heapUsed}, external=${memory.external}`, +); +console.log( + `Console Size: columns=${consoleSize.columns}, rows=${consoleSize.rows}`, +); diff --git a/namespace/mod.ts b/namespace/mod.ts index 799358e..eb0b6a5 100644 --- a/namespace/mod.ts +++ b/namespace/mod.ts @@ -957,13 +957,15 @@ const Andromeda = { * const win = Andromeda.createWindow({ title: "Hello", width: 320, height: 240 }); * ``` */ - createWindow(options: { - title?: string; - width?: number; - height?: number; - resizable?: boolean; - visible?: boolean; - } = {}): any { + createWindow( + options: { + title?: string; + width?: number; + height?: number; + resizable?: boolean; + visible?: boolean; + } = {}, + ): any { // @ts-ignore - cross-file handoff from ext/window/mod.ts const Klass = globalThis.__andromeda_window_class; if (typeof Klass !== "function") { @@ -973,6 +975,71 @@ const Andromeda = { } return new Klass(options); }, + + /** + * Returns the hostname of the machine running the Andromeda process. + * + * @example + * ```ts + * console.log(Andromeda.hostname()); // "my-laptop.local" + * ``` + */ + hostname(): string { + // @ts-ignore - cross-file handoff from ext/os/mod.ts + return (globalThis as any).__andromeda_os.hostname(); + }, + + /** + * Returns the operating-system release. + */ + osRelease(): string { + // @ts-ignore - cross-file handoff + return (globalThis as any).__andromeda_os.osRelease(); + }, + + /** + * Number of seconds since the system booted. + */ + osUptime(): number { + // @ts-ignore - cross-file handoff + return (globalThis as any).__andromeda_os.osUptime(); + }, + + /** + * 1-, 5-, and 15-minute system load averages. Returns `[0, 0, 0]` on + * Windows. + */ + loadavg(): [number, number, number] { + // @ts-ignore - cross-file handoff + return (globalThis as any).__andromeda_os.loadavg(); + }, + + /** + * Memory usage of the current process in bytes. + * + * @remarks `rss` is the authoritative number; `heapTotal` / `heapUsed` + * mirror `rss` and `external` is `0` until Nova exposes engine-level + * heap accounting. Shape matches Deno's `Deno.MemoryUsage` for + * cross-runtime code compatibility. + */ + memoryUsage(): { + rss: number; + heapTotal: number; + heapUsed: number; + external: number; + } { + // @ts-ignore - cross-file handoff + return (globalThis as any).__andromeda_os.memoryUsage(); + }, + + /** + * Terminal size of the controlling TTY. Returns `{columns: 80, rows: 24}` + * when stdout is not a TTY. + */ + consoleSize(): { columns: number; rows: number } { + // @ts-ignore - cross-file handoff + return (globalThis as any).__andromeda_os.consoleSize(); + }, }; /**