Merge pull request #36 from nixops4/aanderse/main

Fix path coercion by calling eval_state_builder_load()
This commit is contained in:
Robert Hensing 2026-01-13 02:03:57 +01:00 committed by GitHub
commit a669289887
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 264 additions and 42 deletions

View file

@ -2,6 +2,7 @@
name = "nix-bindings-expr"
version = "0.1.1"
edition = "2021"
build = "build.rs"
license = "LGPL-2.1"
description = "Rust bindings to Nix expression evaluator"
repository = "https://github.com/nixops4/nix-bindings-rust"
@ -19,6 +20,10 @@ ctor = "0.2"
tempfile = "3.10"
cstr = "0.2"
[build-dependencies]
pkg-config = "0.3"
nix-bindings-util = { path = "../nix-bindings-util" }
[lints.rust]
warnings = "deny"
dead-code = "allow"

View file

@ -0,0 +1,6 @@
use nix_bindings_util::nix_version::emit_version_cfg;
fn main() {
let nix_version = pkg_config::probe_library("nix-expr-c").unwrap().version;
emit_version_cfg(&nix_version, &["2.26"]);
}

View file

@ -224,6 +224,8 @@ impl Drop for EvalStateRef {
/// Provides advanced configuration options for evaluation context setup.
/// Use [`EvalState::new`] for simple cases or this builder for custom configuration.
///
/// Requires Nix 2.26.0 or later.
///
/// # Examples
///
/// ```rust
@ -244,11 +246,14 @@ impl Drop for EvalStateRef {
/// # Ok(())
/// # }
/// ```
#[cfg(nix_at_least = "2.26")]
pub struct EvalStateBuilder {
eval_state_builder: *mut raw::eval_state_builder,
lookup_path: Vec<CString>,
load_ambient_settings: bool,
store: Store,
}
#[cfg(nix_at_least = "2.26")]
impl Drop for EvalStateBuilder {
fn drop(&mut self) {
unsafe {
@ -256,6 +261,7 @@ impl Drop for EvalStateBuilder {
}
}
}
#[cfg(nix_at_least = "2.26")]
impl EvalStateBuilder {
/// Creates a new [`EvalStateBuilder`].
pub fn new(store: Store) -> Result<EvalStateBuilder> {
@ -266,6 +272,7 @@ impl EvalStateBuilder {
store,
eval_state_builder,
lookup_path: Vec::new(),
load_ambient_settings: true,
})
}
/// Sets the [lookup path](https://nix.dev/manual/nix/latest/language/constructs/lookup-path.html) for Nix expression evaluation.
@ -281,6 +288,15 @@ impl EvalStateBuilder {
self.lookup_path = lookup_path;
Ok(self)
}
/// Sets whether to load settings from the ambient environment.
///
/// When enabled (default), calls `nix_eval_state_builder_load` to load settings
/// from NIX_CONFIG and other environment variables. When disabled, only the
/// explicitly configured settings are used.
pub fn load_ambient_settings(mut self, load: bool) -> Self {
self.load_ambient_settings = load;
self
}
/// Builds the configured [`EvalState`].
pub fn build(&self) -> Result<EvalState> {
// Make sure the library is initialized
@ -288,6 +304,17 @@ impl EvalStateBuilder {
let mut context = Context::new();
// Load settings from global configuration (including readOnlyMode = false).
// This is necessary for path coercion to work (adding files to the store).
if self.load_ambient_settings {
unsafe {
check_call!(raw::eval_state_builder_load(
&mut context,
self.eval_state_builder
))?;
}
}
// Note: these raw C string pointers borrow from self.lookup_path
let mut lookup_path: Vec<*const c_char> = self
.lookup_path
@ -1241,6 +1268,12 @@ mod tests {
#[ctor]
fn setup() {
test_init();
// Configure Nix settings for the test suite
// Set max-call-depth to 1000 (lower than default 10000) for the
// eval_state_builder_loads_max_call_depth test case, while
// giving other tests sufficient room for normal evaluation.
std::env::set_var("NIX_CONFIG", "max-call-depth = 1000");
}
/// Run a function while making sure that the current thread is registered with the GC.
@ -2643,4 +2676,172 @@ mod tests {
})
.unwrap();
}
/// Test for path coercion fix (commit 8f6ec2e, <https://github.com/nixops4/nix-bindings-rust/pull/35>).
///
/// This test verifies that path coercion works correctly with EvalStateBuilder.
/// Path coercion requires readOnlyMode = false, which is loaded from global
/// settings by calling eval_state_builder_load().
///
/// # Background
///
/// Without the eval_state_builder_load() call, settings from global Nix
/// configuration are never loaded, leaving readOnlyMode = true (the default).
/// This prevents Nix from adding paths to the store during evaluation,
/// which could cause errors like: "error: path '/some/local/path' does not exist"
///
/// # Test Coverage
///
/// This test exercises store file creation:
/// 1. builtins.toFile successfully creates files in the store
/// 2. Files are actually written to /nix/store
/// 3. Content is written correctly
///
/// Note: This test may not reliably fail without the fix in all environments.
/// Use eval_state_builder_loads_max_call_depth for a deterministic test.
#[test]
#[cfg(nix_at_least = "2.26" /* real_path, eval_state_builder_load */)]
fn eval_state_builder_path_coercion() {
gc_registering_current_thread(|| {
let mut store = Store::open(None, HashMap::new()).unwrap();
let mut es = EvalStateBuilder::new(store.clone())
.unwrap()
.build()
.unwrap();
// Use builtins.toFile to create a file in the store.
// This operation requires readOnlyMode = false to succeed.
let expr = r#"builtins.toFile "test-file.txt" "test content""#;
// Evaluate the expression
let value = es.eval_from_string(expr, "<test>").unwrap();
// Realise the string to get the path and associated store paths
let realised = es.realise_string(&value, false).unwrap();
// Verify we got exactly one store path
assert_eq!(
realised.paths.len(),
1,
"Expected 1 store path, got {}",
realised.paths.len()
);
// Get the physical filesystem path for the store path
// In a relocated store, this differs from realised.s
let physical_path = store.real_path(&realised.paths[0]).unwrap();
// Verify the store path actually exists on disk
assert!(
std::path::Path::new(&physical_path).exists(),
"Store path should exist: {}",
physical_path
);
// Verify the content was written correctly
let store_content = std::fs::read_to_string(&physical_path).unwrap();
assert_eq!(store_content, "test content");
})
.unwrap();
}
/// Test that eval_state_builder_load() loads settings.
///
/// Uses max-call-depth as the test setting. The test suite sets
/// max-call-depth = 1000 via NIX_CONFIG in setup() for the purpose of this test case.
/// This test creates a recursive function that calls itself 1100 times.
///
/// - WITH the fix: Settings are loaded, max-call-depth=1000 is enforced,
/// recursion fails at depth 1000
/// - WITHOUT the fix: Settings aren't loaded, default max-call-depth=10000
/// is used, recursion of 1100 succeeds when it should fail
///
/// Complementary to eval_state_builder_ignores_ambient_when_disabled which verifies
/// that ambient settings are NOT loaded when disabled.
#[test]
#[cfg(nix_at_least = "2.26")]
fn eval_state_builder_loads_max_call_depth() {
gc_registering_current_thread(|| {
let store = Store::open(None, HashMap::new()).unwrap();
let mut es = EvalStateBuilder::new(store).unwrap().build().unwrap();
// Create a recursive function that calls itself 1100 times
// This should fail because max-call-depth is 1000 (set in setup())
let expr = r#"
let
recurse = n: if n == 0 then "done" else recurse (n - 1);
in
recurse 1100
"#;
let result = es.eval_from_string(expr, "<test>");
match result {
Err(e) => {
let err_str = e.to_string();
assert!(
err_str.contains("max-call-depth"),
"Expected max-call-depth error, got: {}",
err_str
);
}
Ok(_) => {
panic!(
"Expected recursion to fail with max-call-depth=1000, but it succeeded. \
This indicates eval_state_builder_load() was not called."
);
}
}
})
.unwrap();
}
/// Test that load_ambient_settings(false) ignores the ambient environment.
///
/// The test suite sets max-call-depth = 1000 via NIX_CONFIG in setup().
/// When we disable loading ambient settings, this should be ignored and
/// the default max-call-depth = 10000 should be used instead.
///
/// Complementary to eval_state_builder_loads_max_call_depth which verifies
/// that ambient settings ARE loaded when enabled.
#[test]
#[cfg(nix_at_least = "2.26")]
fn eval_state_builder_ignores_ambient_when_disabled() {
gc_registering_current_thread(|| {
let store = Store::open(None, HashMap::new()).unwrap();
let mut es = EvalStateBuilder::new(store)
.unwrap()
.load_ambient_settings(false)
.build()
.unwrap();
// Create a recursive function that calls itself 1100 times
// With ambient settings disabled, default max-call-depth=10000 is used,
// so this should succeed (unlike eval_state_builder_loads_max_call_depth)
let expr = r#"
let
recurse = n: if n == 0 then "done" else recurse (n - 1);
in
recurse 1100
"#;
let result = es.eval_from_string(expr, "<test>");
match result {
Ok(value) => {
// Success expected - ambient NIX_CONFIG was ignored
let result_str = es.require_string(&value).unwrap();
assert_eq!(result_str, "done");
}
Err(e) => {
panic!(
"Expected recursion to succeed with default max-call-depth=10000, \
but it failed: {}. This indicates ambient settings were not ignored.",
e
);
}
}
})
.unwrap();
}
}