From 95088103c87b04982918c79954180bfd1747a8a3 Mon Sep 17 00:00:00 2001 From: imxyy_soope_ Date: Sat, 10 Jan 2026 22:52:10 +0800 Subject: [PATCH] feat: initial derivation implementation --- Cargo.lock | 79 ++ nix-js/Cargo.toml | 3 + nix-js/runtime-ts/src/builtins/derivation.ts | 339 ++++++++- nix-js/runtime-ts/src/derivation-helpers.ts | 71 ++ nix-js/runtime-ts/src/types/global.d.ts | 9 + nix-js/src/lib.rs | 1 + nix-js/src/nix_hash.rs | 123 +++ nix-js/src/runtime.rs | 49 ++ nix-js/src/value.rs | 24 + nix-js/tests/derivation.rs | 743 +++++++++++++++++++ 10 files changed, 1436 insertions(+), 5 deletions(-) create mode 100644 nix-js/runtime-ts/src/derivation-helpers.ts create mode 100644 nix-js/src/nix_hash.rs create mode 100644 nix-js/tests/derivation.rs diff --git a/Cargo.lock b/Cargo.lock index b59101f..567d169 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,6 +125,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "boxed_error" version = "0.2.3" @@ -324,6 +333,15 @@ version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -400,6 +418,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "data-encoding" version = "2.9.0" @@ -550,6 +578,16 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "diplomat" version = "0.14.0" @@ -776,6 +814,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -852,6 +900,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "home" version = "0.5.11" @@ -1231,6 +1285,7 @@ dependencies = [ "deno_error", "derive_more", "hashbrown 0.16.1", + "hex", "itertools 0.14.0", "mimalloc", "nix-js-macros", @@ -1238,6 +1293,7 @@ dependencies = [ "regex", "rnix", "rustyline", + "sha2", "string-interner", "tempfile", "thiserror", @@ -1737,6 +1793,17 @@ dependencies = [ "v8", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2043,6 +2110,12 @@ dependencies = [ "syn", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + [[package]] name = "unicode-id-start" version = "1.4.0" @@ -2124,6 +2197,12 @@ dependencies = [ "which", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "vsimd" version = "0.8.0" diff --git a/nix-js/Cargo.toml b/nix-js/Cargo.toml index a8470ec..950b3cd 100644 --- a/nix-js/Cargo.toml +++ b/nix-js/Cargo.toml @@ -25,6 +25,9 @@ regex = "1.11" deno_core = "0.376" deno_error = "0.7" +sha2 = "0.10" +hex = "0.4" + rnix = "0.12" nix-js-macros = { path = "../nix-js-macros" } diff --git a/nix-js/runtime-ts/src/builtins/derivation.ts b/nix-js/runtime-ts/src/builtins/derivation.ts index f00a9be..ef4f402 100644 --- a/nix-js/runtime-ts/src/builtins/derivation.ts +++ b/nix-js/runtime-ts/src/builtins/derivation.ts @@ -1,9 +1,338 @@ -import type { NixValue } from "../types"; +import type { NixValue, NixAttrs } from "../types"; +import { forceString, forceList } from "../type-assert"; +import { force } from "../thunk"; +import { type DerivationData, type OutputInfo, generateAterm } from "../derivation-helpers"; +import { coerceToString, StringCoercionMode } from "./conversion"; -export const derivation = (args: NixValue) => { - throw new Error("Not implemented: derivation"); +const forceAttrs = (value: NixValue): NixAttrs => { + const forced = force(value); + if (typeof forced !== "object" || forced === null || Array.isArray(forced)) { + throw new TypeError(`Expected attribute set for derivation, got ${typeof forced}`); + } + return forced as NixAttrs; }; -export const derivationStrict = (args: NixValue) => { - throw new Error("Not implemented: derivationStrict"); +const validateName = (attrs: NixAttrs): string => { + if (!("name" in attrs)) { + throw new Error("derivation: missing required attribute 'name'"); + } + const name = forceString(attrs.name); + if (!name) { + throw new Error("derivation: 'name' cannot be empty"); + } + if (name.endsWith(".drv")) { + throw new Error(`derivation: invalid name '${name}' (cannot end with .drv)`); + } + return name; +}; + +const validateBuilder = (attrs: NixAttrs): string => { + if (!("builder" in attrs)) { + throw new Error("derivation: missing required attribute 'builder'"); + } + return forceString(attrs.builder); +}; + +const validateSystem = (attrs: NixAttrs): string => { + if (!("system" in attrs)) { + throw new Error("derivation: missing required attribute 'system'"); + } + return forceString(attrs.system); +}; + +const extractOutputs = (attrs: NixAttrs): string[] => { + if (!("outputs" in attrs)) { + return ["out"]; + } + const outputsList = forceList(attrs.outputs); + const outputs = outputsList.map((o) => forceString(o)); + + if (outputs.length === 0) { + throw new Error("derivation: outputs list cannot be empty"); + } + + if (outputs.includes("drv")) { + throw new Error("derivation: invalid output name 'drv'"); + } + + const seen = new Set(); + for (const output of outputs) { + if (seen.has(output)) { + throw new Error(`derivation: duplicate output '${output}'`); + } + seen.add(output); + } + + return outputs; +}; + +const extractArgs = (attrs: NixAttrs): string[] => { + if (!("args" in attrs)) { + return []; + } + const argsList = forceList(attrs.args); + return argsList.map((a) => coerceToString(a, StringCoercionMode.ToString, false)); +}; + +const nixValueToJson = (value: NixValue, seen = new Set()): any => { + const v = force(value); + + if (v === null) return null; + if (typeof v === "boolean") return v; + if (typeof v === "string") return v; + if (typeof v === "number") return v; + + if (typeof v === "bigint") { + const num = Number(v); + if (v > Number.MAX_SAFE_INTEGER || v < Number.MIN_SAFE_INTEGER) { + console.warn(`derivation: integer ${v} exceeds safe range, precision may be lost in __structuredAttrs`); + } + return num; + } + + if (typeof v === "object" && v !== null) { + if (seen.has(v)) { + throw new Error("derivation: circular reference detected in __structuredAttrs"); + } + seen.add(v); + } + + if (Array.isArray(v)) { + return v.map((item) => nixValueToJson(item, seen)); + } + + if (typeof v === "object") { + const result: Record = {}; + for (const [key, val] of Object.entries(v)) { + result[key] = nixValueToJson(val, seen); + } + return result; + } + + if (typeof v === "function") { + throw new Error("derivation: cannot serialize function in __structuredAttrs"); + } + + throw new Error(`derivation: cannot serialize ${typeof v} to JSON`); +}; + +const extractEnv = (attrs: NixAttrs, structuredAttrs: boolean, ignoreNulls: boolean): Map => { + const specialAttrs = new Set([ + "name", + "builder", + "system", + "args", + "outputs", + "__structuredAttrs", + "__ignoreNulls", + "__contentAddressed", + "impure", + ]); + + const env = new Map(); + + if (structuredAttrs) { + const jsonAttrs: Record = {}; + for (const [key, value] of Object.entries(attrs)) { + if (!specialAttrs.has(key)) { + const forcedValue = force(value); + if (ignoreNulls && forcedValue === null) { + continue; + } + jsonAttrs[key] = nixValueToJson(value); + } + } + env.set("__json", JSON.stringify(jsonAttrs)); + } else { + for (const [key, value] of Object.entries(attrs)) { + if (!specialAttrs.has(key)) { + const forcedValue = force(value); + if (ignoreNulls && forcedValue === null) { + continue; + } + env.set(key, coerceToString(value, StringCoercionMode.ToString, false)); + } + } + } + + return env; +}; + +interface FixedOutputInfo { + hash: string; + hashAlgo: string; + hashMode: string; +} + +const extractFixedOutputInfo = (attrs: NixAttrs): FixedOutputInfo | null => { + if (!("outputHash" in attrs)) { + return null; + } + + const hash = forceString(attrs.outputHash); + const hashAlgo = "outputHashAlgo" in attrs ? forceString(attrs.outputHashAlgo) : "sha256"; + const hashMode = "outputHashMode" in attrs ? forceString(attrs.outputHashMode) : "flat"; + + if (hashMode !== "flat" && hashMode !== "recursive") { + throw new Error(`derivation: invalid outputHashMode '${hashMode}' (must be 'flat' or 'recursive')`); + } + + return { hash, hashAlgo, hashMode }; +}; + +const validateFixedOutputConstraints = (fixedOutput: FixedOutputInfo | null, outputs: string[]) => { + if (fixedOutput && (outputs.length !== 1 || outputs[0] !== "out")) { + throw new Error("derivation: fixed-output derivations must have exactly one 'out' output"); + } +}; + +export const derivationStrict = (args: NixValue): NixAttrs => { + const attrs = forceAttrs(args); + + const drvName = validateName(attrs); + const builder = validateBuilder(attrs); + const platform = validateSystem(attrs); + + const outputs = extractOutputs(attrs); + const fixedOutputInfo = extractFixedOutputInfo(attrs); + validateFixedOutputConstraints(fixedOutputInfo, outputs); + + const structuredAttrs = "__structuredAttrs" in attrs ? force(attrs.__structuredAttrs) === true : false; + + const ignoreNulls = "__ignoreNulls" in attrs ? force(attrs.__ignoreNulls) === true : false; + + const drvArgs = extractArgs(attrs); + const env = extractEnv(attrs, structuredAttrs, ignoreNulls); + + env.set("name", drvName); + env.set("builder", builder); + env.set("system", platform); + if (outputs.length > 1 || outputs[0] !== "out") { + env.set("outputs", outputs.join(" ")); + } + + let outputInfos: Map; + let drvPath: string; + + if (fixedOutputInfo) { + const pathName = Deno.core.ops.op_output_path_name(drvName, "out"); + const outPath = Deno.core.ops.op_make_fixed_output_path( + fixedOutputInfo.hashAlgo, + fixedOutputInfo.hash, + fixedOutputInfo.hashMode, + pathName, + ); + + const hashAlgoPrefix = fixedOutputInfo.hashMode === "recursive" ? "r:" : ""; + outputInfos = new Map([ + [ + "out", + { + path: outPath, + hashAlgo: hashAlgoPrefix + fixedOutputInfo.hashAlgo, + hash: fixedOutputInfo.hash, + }, + ], + ]); + env.set("out", outPath); + + const finalDrv: DerivationData = { + name: drvName, + outputs: outputInfos, + inputDrvs: new Map(), + inputSrcs: new Set(), + platform, + builder, + args: drvArgs, + env, + }; + const finalAterm = generateAterm(finalDrv); + const finalDrvHash = Deno.core.ops.op_sha256_hex(finalAterm); + drvPath = Deno.core.ops.op_make_store_path("text", finalDrvHash, `${drvName}.drv`); + } else { + const maskedOutputs = new Map( + outputs.map((o) => [ + o, + { + path: "", + hashAlgo: "", + hash: "", + }, + ]), + ); + const maskedEnv = new Map(env); + for (const output of outputs) { + maskedEnv.set(output, ""); + } + + const maskedDrv: DerivationData = { + name: drvName, + outputs: maskedOutputs, + inputDrvs: new Map(), + inputSrcs: new Set(), + platform, + builder, + args: drvArgs, + env: maskedEnv, + }; + + const maskedAterm = generateAterm(maskedDrv); + const drvModuloHash = Deno.core.ops.op_sha256_hex(maskedAterm); + + outputInfos = new Map(); + for (const outputName of outputs) { + const pathName = Deno.core.ops.op_output_path_name(drvName, outputName); + const outPath = Deno.core.ops.op_make_store_path(`output:${outputName}`, drvModuloHash, pathName); + outputInfos.set(outputName, { + path: outPath, + hashAlgo: "", + hash: "", + }); + env.set(outputName, outPath); + } + + const finalDrv: DerivationData = { + ...maskedDrv, + outputs: outputInfos, + env, + }; + const finalAterm = generateAterm(finalDrv); + const finalDrvHash = Deno.core.ops.op_sha256_hex(finalAterm); + + drvPath = Deno.core.ops.op_make_store_path("text", finalDrvHash, `${drvName}.drv`); + } + + const result: NixAttrs = { + type: "derivation", + drvPath, + name: drvName, + builder, + system: platform, + }; + + for (const [outputName, outputInfo] of outputInfos.entries()) { + result[outputName] = outputInfo.path; + } + + if (outputInfos.has("out")) { + result.outPath = outputInfos.get("out")!.path; + } + + if (drvArgs.length > 0) { + result.args = drvArgs; + } + + if (!structuredAttrs) { + for (const [key, value] of env.entries()) { + if (!["name", "builder", "system", ...outputs].includes(key)) { + result[key] = value; + } + } + } + + return result; +}; + +export const derivation = (args: NixValue): NixAttrs => { + return derivationStrict(args); }; diff --git a/nix-js/runtime-ts/src/derivation-helpers.ts b/nix-js/runtime-ts/src/derivation-helpers.ts new file mode 100644 index 0000000..93f8f7c --- /dev/null +++ b/nix-js/runtime-ts/src/derivation-helpers.ts @@ -0,0 +1,71 @@ +export interface OutputInfo { + path: string; + hashAlgo: string; + hash: string; +} + +export interface DerivationData { + name: string; + outputs: Map; + inputDrvs: Map>; + inputSrcs: Set; + platform: string; + builder: string; + args: string[]; + env: Map; +} + +export const escapeString = (s: string): string => { + let result = ""; + for (const char of s) { + switch (char) { + case '"': + result += '\\"'; + break; + case "\\": + result += "\\\\"; + break; + case "\n": + result += "\\n"; + break; + case "\r": + result += "\\r"; + break; + case "\t": + result += "\\t"; + break; + default: + result += char; + } + } + return `"${result}"`; +}; + +const quoteString = (s: string): string => `"${s}"`; + +export const generateAterm = (drv: DerivationData): string => { + const outputEntries: string[] = []; + const sortedOutputs = Array.from(drv.outputs.entries()).sort(); + for (const [name, info] of sortedOutputs) { + outputEntries.push( + `(${quoteString(name)},${quoteString(info.path)},${quoteString(info.hashAlgo)},${quoteString(info.hash)})`, + ); + } + const outputs = outputEntries.join(","); + + const inputDrvEntries: string[] = []; + for (const [drvPath, outputs] of drv.inputDrvs) { + const outList = `[${Array.from(outputs).map(quoteString).join(",")}]`; + inputDrvEntries.push(`(${quoteString(drvPath)},${outList})`); + } + const inputDrvs = inputDrvEntries.join(","); + + const inputSrcs = Array.from(drv.inputSrcs).map(quoteString).join(","); + + const args = drv.args.map(escapeString).join(","); + const envs = Array.from(drv.env.entries()) + .sort() + .map(([k, v]) => `(${escapeString(k)},${escapeString(v)})`); + + return `Derive([${outputs}],[${inputDrvs}],[${inputSrcs}],${quoteString(drv.platform)},${quoteString(drv.builder)},[${args}],[${envs}])`; +}; diff --git a/nix-js/runtime-ts/src/types/global.d.ts b/nix-js/runtime-ts/src/types/global.d.ts index 2ee569e..b97f068 100644 --- a/nix-js/runtime-ts/src/types/global.d.ts +++ b/nix-js/runtime-ts/src/types/global.d.ts @@ -9,6 +9,15 @@ declare global { function op_import(path: string): string; function op_read_file(path: string): string; function op_path_exists(path: string): boolean; + function op_sha256_hex(data: string): string; + function op_make_store_path(ty: string, hash_hex: string, name: string): string; + function op_output_path_name(drv_name: string, output_name: string): string; + function op_make_fixed_output_path( + hash_algo: string, + hash: string, + hash_mode: string, + name: string, + ): string; } } } diff --git a/nix-js/src/lib.rs b/nix-js/src/lib.rs index f90cb95..53dd99e 100644 --- a/nix-js/src/lib.rs +++ b/nix-js/src/lib.rs @@ -4,6 +4,7 @@ mod codegen; pub mod context; pub mod error; pub mod ir; +mod nix_hash; mod runtime; pub mod value; diff --git a/nix-js/src/nix_hash.rs b/nix-js/src/nix_hash.rs new file mode 100644 index 0000000..2980911 --- /dev/null +++ b/nix-js/src/nix_hash.rs @@ -0,0 +1,123 @@ +use sha2::{Digest, Sha256}; + +const NIX_BASE32_CHARS: &[u8; 32] = b"0123456789abcdfghijklmnpqrsvwxyz"; +const STORE_DIR: &str = "/nix/store"; + +pub fn sha256_hex(data: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(data.as_bytes()); + hex::encode(hasher.finalize()) +} + +pub fn compress_hash(hash: &[u8; 32], new_size: usize) -> Vec { + let mut result = vec![0u8; new_size]; + for i in 0..32 { + result[i % new_size] ^= hash[i]; + } + result +} + +pub fn nix_base32_encode(bytes: &[u8]) -> String { + let len = (bytes.len() * 8 - 1) / 5 + 1; + let mut result = String::with_capacity(len); + + for n in (0..len).rev() { + let b = n * 5; + let i = b / 8; + let j = b % 8; + + let c = if i >= bytes.len() { + 0 + } else { + let mut c = (bytes[i] as u16) >> j; + if j > 3 && i + 1 < bytes.len() { + c |= (bytes[i + 1] as u16) << (8 - j); + } + c + }; + + result.push(NIX_BASE32_CHARS[(c & 0x1f) as usize] as char); + } + + result +} + +pub fn make_store_path(ty: &str, hash_hex: &str, name: &str) -> String { + let s = format!("{}:sha256:{}:{}:{}", ty, hash_hex, STORE_DIR, name); + + let mut hasher = Sha256::new(); + hasher.update(s.as_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + + let compressed = compress_hash(&hash, 20); + let encoded = nix_base32_encode(&compressed); + + format!("{}/{}-{}", STORE_DIR, encoded, name) +} + +pub fn output_path_name(drv_name: &str, output_name: &str) -> String { + if output_name == "out" { + drv_name.to_string() + } else { + format!("{}-{}", drv_name, output_name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nix_base32_encode() { + let bytes = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; + let encoded = nix_base32_encode(&bytes); + assert_eq!(encoded.len(), 8); + + let bytes_zero = [0u8; 20]; + let encoded_zero = nix_base32_encode(&bytes_zero); + assert_eq!(encoded_zero.len(), 32); + assert!(encoded_zero.chars().all(|c| c == '0')); + } + + #[test] + fn test_compress_hash() { + let hash = [0u8; 32]; + let compressed = compress_hash(&hash, 20); + assert_eq!(compressed.len(), 20); + assert!(compressed.iter().all(|&b| b == 0)); + + let hash_ones = [0xFF; 32]; + let compressed_ones = compress_hash(&hash_ones, 20); + assert_eq!(compressed_ones.len(), 20); + } + + #[test] + fn test_sha256_hex() { + let data = "hello world"; + let hash = sha256_hex(data); + assert_eq!(hash.len(), 64); + assert_eq!( + hash, + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + ); + } + + #[test] + fn test_output_path_name() { + assert_eq!(output_path_name("hello", "out"), "hello"); + assert_eq!(output_path_name("hello", "dev"), "hello-dev"); + assert_eq!(output_path_name("hello", "doc"), "hello-doc"); + } + + #[test] + fn test_make_store_path() { + let path = make_store_path("output:out", "abc123", "hello"); + assert!(path.starts_with("/nix/store/")); + assert!(path.ends_with("-hello")); + + let hash_parts: Vec<&str> = path.split('/').collect(); + assert_eq!(hash_parts.len(), 4); + let name_part = hash_parts[3]; + assert!(name_part.contains('-')); + } +} diff --git a/nix-js/src/runtime.rs b/nix-js/src/runtime.rs index 94b268d..9756851 100644 --- a/nix-js/src/runtime.rs +++ b/nix-js/src/runtime.rs @@ -28,6 +28,10 @@ fn runtime_extension() -> Extension { op_read_file(), op_path_exists(), op_resolve_path::(), + op_sha256_hex(), + op_make_store_path(), + op_output_path_name(), + op_make_fixed_output_path(), ]; Extension { @@ -127,6 +131,51 @@ fn op_resolve_path( .map_err(|e| format!("Failed to resolve path {}: {}", path, e))?) } +#[deno_core::op2] +#[string] +fn op_sha256_hex(#[string] data: String) -> String { + crate::nix_hash::sha256_hex(&data) +} + +#[deno_core::op2] +#[string] +fn op_make_store_path( + #[string] ty: String, + #[string] hash_hex: String, + #[string] name: String, +) -> String { + crate::nix_hash::make_store_path(&ty, &hash_hex, &name) +} + +#[deno_core::op2] +#[string] +fn op_output_path_name(#[string] drv_name: String, #[string] output_name: String) -> String { + crate::nix_hash::output_path_name(&drv_name, &output_name) +} + +#[deno_core::op2] +#[string] +fn op_make_fixed_output_path( + #[string] hash_algo: String, + #[string] hash: String, + #[string] hash_mode: String, + #[string] name: String, +) -> String { + use sha2::{Digest, Sha256}; + + if hash_algo == "sha256" && hash_mode == "recursive" { + crate::nix_hash::make_store_path("source", &hash, &name) + } else { + let prefix = if hash_mode == "recursive" { "r:" } else { "" }; + let inner_input = format!("fixed:out:{}{}:{}:", prefix, hash_algo, hash); + let mut hasher = Sha256::new(); + hasher.update(inner_input.as_bytes()); + let inner_hash = hex::encode(hasher.finalize()); + + crate::nix_hash::make_store_path("output:out", &inner_hash, &name) + } +} + pub(crate) struct Runtime { js_runtime: JsRuntime, is_thunk_symbol: v8::Global, diff --git a/nix-js/src/value.rs b/nix-js/src/value.rs index d3d2dea..5248312 100644 --- a/nix-js/src/value.rs +++ b/nix-js/src/value.rs @@ -74,6 +74,18 @@ pub struct AttrSet { data: BTreeMap, } +impl AttrSet { + /// Gets a value by key (string or Symbol). + pub fn get(&self, key: impl Into) -> Option<&Value> { + self.data.get(&key.into()) + } + + /// Checks if a key exists in the attribute set. + pub fn contains_key(&self, key: impl Into) -> bool { + self.data.contains_key(&key.into()) + } +} + impl Debug for AttrSet { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { use Value::*; @@ -117,6 +129,18 @@ pub struct List { data: Vec, } +impl List { + /// Returns the number of elements in the list. + pub fn len(&self) -> usize { + self.data.len() + } + + /// Returns true if the list is empty. + pub fn is_empty(&self) -> bool { + self.data.is_empty() + } +} + impl Display for List { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { write!(f, "[ ")?; diff --git a/nix-js/tests/derivation.rs b/nix-js/tests/derivation.rs new file mode 100644 index 0000000..5181871 --- /dev/null +++ b/nix-js/tests/derivation.rs @@ -0,0 +1,743 @@ +use nix_js::context::Context; +use nix_js::value::Value; + +#[test] +fn derivation_minimal() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"derivation { name = "hello"; builder = "/bin/sh"; system = "x86_64-linux"; }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert_eq!(attrs.get("type"), Some(&Value::String("derivation".into()))); + assert_eq!(attrs.get("name"), Some(&Value::String("hello".into()))); + assert_eq!(attrs.get("builder"), Some(&Value::String("/bin/sh".into()))); + assert_eq!( + attrs.get("system"), + Some(&Value::String("x86_64-linux".into())) + ); + + assert!(attrs.contains_key("outPath")); + assert!(attrs.contains_key("drvPath")); + + if let Some(Value::String(path)) = attrs.get("outPath") { + assert_eq!(path, "/nix/store/pnwh4xsfs4j508bs9iw6bpkyc4zw6ryx-hello"); + } else { + panic!("outPath should be a string"); + } + + if let Some(Value::String(path)) = attrs.get("drvPath") { + assert_eq!( + path, + "/nix/store/x0sj6ynccvc1a8kxr8fifnlf7qlxw6hd-hello.drv" + ); + } else { + panic!("drvPath should be a string"); + } + } + _ => panic!("Expected AttrSet, got {:?}", result), + } +} + +#[test] +fn derivation_with_args() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"derivation { + name = "test"; + builder = "/bin/sh"; + system = "x86_64-linux"; + args = ["-c" "echo hello"]; + }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert!(attrs.contains_key("args")); + if let Some(Value::List(args)) = attrs.get("args") { + assert_eq!(args.len(), 2); + } + } + _ => panic!("Expected AttrSet"), + } +} + +#[test] +fn derivation_to_string() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"toString (derivation { name = "foo"; builder = "/bin/sh"; system = "x86_64-linux"; })"#, + ) + .unwrap(); + + match result { + Value::String(s) => assert_eq!(s, "/nix/store/xpcvxsx5sw4rbq666blz6sxqlmsqphmr-foo"), + _ => panic!("Expected String, got {:?}", result), + } +} + +#[test] +fn derivation_missing_name() { + let mut ctx = Context::new().unwrap(); + let result = ctx.eval_code(r#"derivation { builder = "/bin/sh"; system = "x86_64-linux"; }"#); + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("missing required attribute 'name'")); +} + +#[test] +fn derivation_invalid_name_with_drv_suffix() { + let mut ctx = Context::new().unwrap(); + let result = ctx.eval_code( + r#"derivation { name = "foo.drv"; builder = "/bin/sh"; system = "x86_64-linux"; }"#, + ); + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("cannot end with .drv")); +} + +#[test] +fn derivation_missing_builder() { + let mut ctx = Context::new().unwrap(); + let result = ctx.eval_code(r#"derivation { name = "test"; system = "x86_64-linux"; }"#); + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("missing required attribute 'builder'")); +} + +#[test] +fn derivation_missing_system() { + let mut ctx = Context::new().unwrap(); + let result = ctx.eval_code(r#"derivation { name = "test"; builder = "/bin/sh"; }"#); + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("missing required attribute 'system'")); +} + +#[test] +fn derivation_with_env_vars() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"derivation { + name = "test"; + builder = "/bin/sh"; + system = "x86_64-linux"; + MY_VAR = "hello"; + ANOTHER = "world"; + }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert_eq!(attrs.get("MY_VAR"), Some(&Value::String("hello".into()))); + assert_eq!(attrs.get("ANOTHER"), Some(&Value::String("world".into()))); + } + _ => panic!("Expected AttrSet"), + } +} + +#[test] +fn derivation_strict() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"builtins.derivationStrict { name = "test"; builder = "/bin/sh"; system = "x86_64-linux"; }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert_eq!(attrs.get("type"), Some(&Value::String("derivation".into()))); + assert!(attrs.contains_key("drvPath")); + assert!(attrs.contains_key("outPath")); + } + _ => panic!("Expected AttrSet"), + } +} + +#[test] +fn derivation_deterministic_paths() { + let mut ctx = Context::new().unwrap(); + + let expr = r#"derivation { name = "hello"; builder = "/bin/sh"; system = "x86_64-linux"; }"#; + + let result1 = ctx.eval_code(expr).unwrap(); + let result2 = ctx.eval_code(expr).unwrap(); + + match (result1, result2) { + (Value::AttrSet(attrs1), Value::AttrSet(attrs2)) => { + assert_eq!(attrs1.get("drvPath"), attrs2.get("drvPath")); + assert_eq!(attrs1.get("outPath"), attrs2.get("outPath")); + } + _ => panic!("Expected AttrSet"), + } +} + +#[test] +fn derivation_escaping_in_aterm() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"derivation { + name = "test"; + builder = "/bin/sh"; + system = "x86_64-linux"; + args = ["-c" "echo \"hello\nworld\""]; + }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert!(attrs.contains_key("drvPath")); + assert!(attrs.contains_key("outPath")); + } + _ => panic!("Expected AttrSet"), + } +} + +#[test] +fn multi_output_two_outputs() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"derivation { + name = "multi"; + builder = "/bin/sh"; + system = "x86_64-linux"; + outputs = ["out" "dev"]; + }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert!(attrs.contains_key("out")); + assert!(attrs.contains_key("dev")); + assert!(attrs.contains_key("outPath")); + assert!(attrs.contains_key("drvPath")); + + // Verify exact paths match CppNix + if let Some(Value::String(drv_path)) = attrs.get("drvPath") { + assert_eq!( + drv_path, + "/nix/store/vmyjryfipkn9ss3ya23hk8p3m58l6dsl-multi.drv" + ); + } else { + panic!("drvPath should be a string"); + } + + if let Some(Value::String(out_path)) = attrs.get("out") { + assert_eq!( + out_path, + "/nix/store/a3d95yg9d215c54n0ybr4npmpnj29229-multi" + ); + } else { + panic!("out should be a string"); + } + + if let Some(Value::String(dev_path)) = attrs.get("dev") { + assert_eq!( + dev_path, + "/nix/store/hq3b99lz71gwfq6x8lqwg14hf929q0d2-multi-dev" + ); + } else { + panic!("dev should be a string"); + } + + if let Some(Value::String(out_path)) = attrs.get("outPath") { + assert_eq!( + out_path, + "/nix/store/a3d95yg9d215c54n0ybr4npmpnj29229-multi" + ); + assert_eq!(attrs.get("out"), Some(&Value::String(out_path.clone()))); + } + } + _ => panic!("Expected AttrSet"), + } +} + +#[test] +fn multi_output_three_outputs() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"derivation { + name = "three"; + builder = "/bin/sh"; + system = "x86_64-linux"; + outputs = ["out" "dev" "doc"]; + }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert!(attrs.contains_key("out")); + assert!(attrs.contains_key("dev")); + assert!(attrs.contains_key("doc")); + + // Verify exact paths match CppNix + if let Some(Value::String(drv_path)) = attrs.get("drvPath") { + assert_eq!( + drv_path, + "/nix/store/w08rpwvs5j9yxvdx5f5yg0p5i3ncazdx-three.drv" + ); + } + + if let Some(Value::String(out_path)) = attrs.get("out") { + assert_eq!( + out_path, + "/nix/store/i479clih5pb6bn2d2b758sbaylvbs2cl-three" + ); + } + if let Some(Value::String(dev_path)) = attrs.get("dev") { + assert_eq!( + dev_path, + "/nix/store/gg8v395vci5xg1i9grc8ifh5xagw5f2j-three-dev" + ); + } + if let Some(Value::String(doc_path)) = attrs.get("doc") { + assert_eq!( + doc_path, + "/nix/store/p2avgz16qx5k2jgnq3ch04k154xj1ac0-three-doc" + ); + } + } + _ => panic!("Expected AttrSet"), + } +} + +#[test] +fn multi_output_backward_compat() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"derivation { + name = "compat"; + builder = "/bin/sh"; + system = "x86_64-linux"; + outputs = ["out"]; + }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert!(attrs.contains_key("outPath")); + assert!(attrs.contains_key("out")); + + if let (Some(Value::String(out_path)), Some(Value::String(out))) = + (attrs.get("outPath"), attrs.get("out")) + { + assert_eq!(out_path, out); + } + } + _ => panic!("Expected AttrSet"), + } +} + +#[test] +fn multi_output_deterministic() { + let mut ctx = Context::new().unwrap(); + let result1 = ctx + .eval_code( + r#"derivation { + name = "determ"; + builder = "/bin/sh"; + system = "x86_64-linux"; + outputs = ["out" "dev"]; + }"#, + ) + .unwrap(); + + let result2 = ctx + .eval_code( + r#"derivation { + name = "determ"; + builder = "/bin/sh"; + system = "x86_64-linux"; + outputs = ["out" "dev"]; + }"#, + ) + .unwrap(); + + assert_eq!(result1, result2); +} + +#[test] +fn fixed_output_sha256_flat() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"derivation { + name = "fixed"; + builder = "/bin/sh"; + system = "x86_64-linux"; + outputHash = "0000000000000000000000000000000000000000000000000000000000000000"; + outputHashAlgo = "sha256"; + outputHashMode = "flat"; + }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert!(attrs.contains_key("outPath")); + assert!(attrs.contains_key("drvPath")); + + // Verify exact paths match CppNix + if let Some(Value::String(out_path)) = attrs.get("outPath") { + assert_eq!( + out_path, + "/nix/store/ap9h69qwrm5060ldi96axyklh3pr3yjn-fixed" + ); + } + + if let Some(Value::String(drv_path)) = attrs.get("drvPath") { + assert_eq!( + drv_path, + "/nix/store/kj9gsfz5cngc38n1xlf6ljlgvnsfg0cj-fixed.drv" + ); + } + } + _ => panic!("Expected AttrSet"), + } +} + +#[test] +fn fixed_output_default_algo() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"derivation { + name = "default"; + builder = "/bin/sh"; + system = "x86_64-linux"; + outputHash = "0000000000000000000000000000000000000000000000000000000000000000"; + }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert!(attrs.contains_key("outPath")); + // Verify it defaults to sha256 (same as explicitly specifying it) + if let Some(Value::String(out_path)) = attrs.get("outPath") { + assert!(out_path.contains("/nix/store/")); + } + } + _ => panic!("Expected AttrSet"), + } +} + +#[test] +fn fixed_output_recursive_mode() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"derivation { + name = "recursive"; + builder = "/bin/sh"; + system = "x86_64-linux"; + outputHash = "1111111111111111111111111111111111111111111111111111111111111111"; + outputHashAlgo = "sha256"; + outputHashMode = "recursive"; + }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert!(attrs.contains_key("outPath")); + assert!(attrs.contains_key("drvPath")); + + // Verify exact path matches CppNix + if let Some(Value::String(out_path)) = attrs.get("outPath") { + assert_eq!( + out_path, + "/nix/store/qyal5s16hfwxhz5zwpf8h8yv2bs84z56-recursive" + ); + } + } + _ => panic!("Expected AttrSet"), + } +} + +#[test] +fn fixed_output_rejects_multi_output() { + let mut ctx = Context::new().unwrap(); + let result = ctx.eval_code( + r#"derivation { + name = "invalid"; + builder = "/bin/sh"; + system = "x86_64-linux"; + outputHash = "0000000000000000000000000000000000000000000000000000000000000000"; + outputs = ["out" "dev"]; + }"#, + ); + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("fixed-output") && err_msg.contains("one")); +} + +#[test] +fn fixed_output_invalid_hash_mode() { + let mut ctx = Context::new().unwrap(); + let result = ctx.eval_code( + r#"derivation { + name = "invalid"; + builder = "/bin/sh"; + system = "x86_64-linux"; + outputHash = "0000000000000000000000000000000000000000000000000000000000000000"; + outputHashMode = "invalid"; + }"#, + ); + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("outputHashMode") && err_msg.contains("invalid")); +} + +#[test] +fn structured_attrs_basic() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"derivation { + name = "struct"; + builder = "/bin/sh"; + system = "x86_64-linux"; + __structuredAttrs = true; + foo = "bar"; + count = 42; + enabled = true; + }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert!(attrs.contains_key("drvPath")); + assert!(attrs.contains_key("outPath")); + assert!(!attrs.contains_key("foo")); + assert!(!attrs.contains_key("count")); + } + _ => panic!("Expected AttrSet"), + } +} + +#[test] +fn structured_attrs_nested() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"derivation { + name = "nested"; + builder = "/bin/sh"; + system = "x86_64-linux"; + __structuredAttrs = true; + data = { x = 1; y = [2 3]; }; + }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert!(attrs.contains_key("drvPath")); + assert!(!attrs.contains_key("data")); + } + _ => panic!("Expected AttrSet"), + } +} + +#[test] +fn structured_attrs_rejects_functions() { + let mut ctx = Context::new().unwrap(); + let result = ctx.eval_code( + r#"derivation { + name = "invalid"; + builder = "/bin/sh"; + system = "x86_64-linux"; + __structuredAttrs = true; + func = x: x + 1; + }"#, + ); + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("function") && err_msg.contains("serialize")); +} + +#[test] +fn structured_attrs_false() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"derivation { + name = "normal"; + builder = "/bin/sh"; + system = "x86_64-linux"; + __structuredAttrs = false; + foo = "bar"; + }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert!(attrs.contains_key("foo")); + if let Some(Value::String(val)) = attrs.get("foo") { + assert_eq!(val, "bar"); + } + } + _ => panic!("Expected AttrSet"), + } +} + +#[test] +fn ignore_nulls_true() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"derivation { + name = "ignore"; + builder = "/bin/sh"; + system = "x86_64-linux"; + __ignoreNulls = true; + foo = "bar"; + nullValue = null; + }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert!(attrs.contains_key("foo")); + assert!(!attrs.contains_key("nullValue")); + } + _ => panic!("Expected AttrSet"), + } +} + +#[test] +fn ignore_nulls_false() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"derivation { + name = "keep"; + builder = "/bin/sh"; + system = "x86_64-linux"; + __ignoreNulls = false; + nullValue = null; + }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert!(attrs.contains_key("nullValue")); + if let Some(Value::String(val)) = attrs.get("nullValue") { + assert_eq!(val, ""); + } + } + _ => panic!("Expected AttrSet"), + } +} + +#[test] +fn ignore_nulls_with_structured_attrs() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"derivation { + name = "combined"; + builder = "/bin/sh"; + system = "x86_64-linux"; + __structuredAttrs = true; + __ignoreNulls = true; + foo = "bar"; + nullValue = null; + }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert!(attrs.contains_key("drvPath")); + assert!(!attrs.contains_key("foo")); + assert!(!attrs.contains_key("nullValue")); + } + _ => panic!("Expected AttrSet"), + } +} + +#[test] +fn all_features_combined() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"derivation { + name = "all"; + builder = "/bin/sh"; + system = "x86_64-linux"; + outputs = ["out" "dev"]; + __structuredAttrs = true; + __ignoreNulls = true; + data = { x = 1; }; + nullValue = null; + }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert!(attrs.contains_key("out")); + assert!(attrs.contains_key("dev")); + assert!(attrs.contains_key("outPath")); + assert!(!attrs.contains_key("data")); + assert!(!attrs.contains_key("nullValue")); + } + _ => panic!("Expected AttrSet"), + } +} + +#[test] +fn fixed_output_with_structured_attrs() { + let mut ctx = Context::new().unwrap(); + let result = ctx + .eval_code( + r#"derivation { + name = "fixstruct"; + builder = "/bin/sh"; + system = "x86_64-linux"; + outputHash = "abc123"; + __structuredAttrs = true; + data = { key = "value"; }; + }"#, + ) + .unwrap(); + + match result { + Value::AttrSet(attrs) => { + assert!(attrs.contains_key("outPath")); + assert!(attrs.contains_key("drvPath")); + assert!(!attrs.contains_key("data")); + } + _ => panic!("Expected AttrSet"), + } +}