git.delta.rocks / fleet / refs/commits / 615754ca0747

difftreelog

feat usbd

qoyyyxlkYaroslav Bolyukin2026-07-07parent: #51d5ea5.patch.diff

24 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1912,14 +1912,18 @@
 version = "0.2.0"
 dependencies = [
  "anyhow",
+ "base64 0.22.1",
  "camino",
  "clap",
  "clap_complete",
  "fleet-base",
+ "fleet-usb",
  "futures",
  "goodlog-subscriber",
  "itertools 0.15.0",
+ "nix",
  "nix-eval",
+ "rand 0.10.1",
  "remowt-fleet",
  "serde",
  "serde_json",
@@ -1928,6 +1932,8 @@
  "tempfile",
  "tokio",
  "tracing",
+ "zbus",
+ "zstd",
 ]
 
 [[package]]
@@ -2006,6 +2012,47 @@
 ]
 
 [[package]]
+name = "fleet-usb"
+version = "0.1.9"
+dependencies = [
+ "age",
+ "anyhow",
+ "base64 0.22.1",
+ "camino",
+ "chacha20poly1305",
+ "hex",
+ "hmac 0.13.0",
+ "serde",
+ "serde_json",
+ "sha2 0.11.0",
+]
+
+[[package]]
+name = "fleet-usbd"
+version = "0.1.9"
+dependencies = [
+ "age",
+ "anyhow",
+ "base64 0.22.1",
+ "bytes",
+ "camino",
+ "clap",
+ "fleet-usb",
+ "futures",
+ "goodlog-subscriber",
+ "hex",
+ "hostname",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "nix",
+ "nix-eval",
+ "rand 0.10.1",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
 name = "fluent"
 version = "0.16.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7841,6 +7888,34 @@
 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
 
 [[package]]
+name = "zstd"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
+dependencies = [
+ "zstd-safe",
+]
+
+[[package]]
+name = "zstd-safe"
+version = "7.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
+dependencies = [
+ "zstd-sys",
+]
+
+[[package]]
+name = "zstd-sys"
+version = "2.0.16+zstd.1.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
+[[package]]
 name = "zvariant"
 version = "5.12.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
modifiedCargo.tomldiffbeforeafterboth
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,6 +9,7 @@
 [workspace.dependencies]
 fleet-base = { path = "./crates/fleet-base" }
 fleet-shared = { path = "./crates/fleet-shared" }
+fleet-usb = { path = "./crates/fleet-usb" }
 nix-eval = { path = "./crates/nix-eval" }
 nixlike = { path = "./crates/nixlike" }
 opentelemetry-exporter-env = { path = "./crates/opentelemetry-exporter-env" }
@@ -36,6 +37,7 @@
 bindgen = "0.72.0"
 bytes = "1.11.0"
 camino = "1.2.2"
+chacha20poly1305 = "0.10"
 chrono = { version = "0.4.41", features = ["serde"] }
 clap = { version = "4.5", features = ["derive", "env", "unicode", "wrap_help"] }
 clap_complete = "4.5"
@@ -47,6 +49,9 @@
 hex = "0.4.3"
 hmac = "0.13.0"
 hostname = "0.4.1"
+http-body-util = "0.1"
+hyper = { version = "1", features = ["http1", "server"] }
+hyper-util = { version = "0.1", features = ["tokio"] }
 human-repr = "1.1"
 indicatif = "0.18"
 indoc = "2.0.6"
@@ -85,6 +90,7 @@
 unicode_categories = "0.1.1"
 vte = { version = "0.15.0", features = ["ansi"] }
 x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
+zstd = "0.13"
 zbus = "5.16.0"
 zbus_polkit = "5.0.0"
 goodlog-subscriber = { version = "0.1.9", path = "crates/goodlog-subscriber" }
modifiedcmds/fleet/Cargo.tomldiffbeforeafterboth
--- a/cmds/fleet/Cargo.toml
+++ b/cmds/fleet/Cargo.toml
@@ -9,17 +9,23 @@
 
 [dependencies]
 anyhow.workspace = true
+base64.workspace = true
 camino.workspace = true
 clap.workspace = true
 clap_complete.workspace = true
 fleet-base.workspace = true
+fleet-usb.workspace = true
+nix.workspace = true
 nix-eval.workspace = true
+rand.workspace = true
 remowt-fleet.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 tempfile.workspace = true
 tokio.workspace = true
 tracing.workspace = true
+zbus.workspace = true
+zstd.workspace = true
 
 futures.workspace = true
 itertools.workspace = true
modifiedcmds/fleet/src/cmds/build_systems.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/build_systems.rs
+++ b/cmds/fleet/src/cmds/build_systems.rs
@@ -33,7 +33,11 @@
 	build_attr: String,
 }
 
-async fn build_task(config: Config, hostname: String, build_attr: &str) -> Result<Utf8PathBuf> {
+pub(crate) async fn build_task(
+	config: Config,
+	hostname: String,
+	build_attr: &str,
+) -> Result<Utf8PathBuf> {
 	info!(target: TARGET, "building");
 	let host = config.host(&hostname)?;
 	// let action = Action::from(self.subcommand.clone());
modifiedcmds/fleet/src/cmds/mod.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/mod.rs
+++ b/cmds/fleet/src/cmds/mod.rs
@@ -4,3 +4,4 @@
 pub mod rollback;
 pub mod secrets;
 pub mod tf;
+pub mod usbd;
addedcmds/fleet/src/cmds/usbd.rsdiffbeforeafterboth
--- /dev/null
+++ b/cmds/fleet/src/cmds/usbd.rs
@@ -0,0 +1,396 @@
+use std::{
+	collections::HashSet,
+	fs::File,
+	io::{self, Write as _},
+	sync::Arc,
+};
+
+use anyhow::{Context as _, Result, anyhow, bail};
+use base64::Engine as _;
+use base64::engine::general_purpose::STANDARD as BASE64;
+use camino::{Utf8Path, Utf8PathBuf};
+use clap::Parser;
+use fleet_base::{fleetdata::SecretOwner, host::Config};
+use fleet_usb::{
+	MANIFEST_DONE_NAME, MANIFEST_NAME, MANIFEST_VERSION,
+	manifest::{Manifest, PathEntry, encrypt_manifest},
+	names::{self, NAMING_SECRET_SIZE, NamingSecret},
+	stream::EncryptWriter,
+};
+use nix_eval::{Store, eval_store};
+use rand::Rng as _;
+use tokio::task::spawn_blocking;
+use tracing::{info, warn};
+use zbus::zvariant::{self, OwnedObjectPath};
+
+use super::build_systems::build_task;
+
+const TARGET: &str = "usbd";
+
+const TMP_PREFIX: &str = "usbd-tmp-";
+const MAX_CHUNK_SIZE: u64 = 1 << 30;
+const MAX_CHUNKS: u32 = 1024;
+
+#[derive(Parser)]
+pub enum Usbd {
+	/// Write a system update for a host onto an inserted USB stick
+	Write(UsbdWrite),
+}
+
+impl Usbd {
+	pub async fn run(self, config: &Config) -> Result<()> {
+		match self {
+			Usbd::Write(w) => w.run(config).await,
+		}
+	}
+}
+
+#[derive(Parser)]
+pub struct UsbdWrite {
+	hostname: String,
+	/// Volume label of the update stick
+	#[clap(long, default_value = "FLEETUSBD")]
+	label: String,
+	/// Write into this directory instead of discovering and mounting the stick via udisks
+	#[clap(long)]
+	mount_point: Option<Utf8PathBuf>,
+	/// Nix secret signing key; its public counterpart must be in trusted-public-keys on the host
+	#[clap(long)]
+	sign_key: Option<Utf8PathBuf>,
+	#[clap(long, default_value = "toplevel-fleet")]
+	build_attr: String,
+}
+
+impl UsbdWrite {
+	pub async fn run(self, config: &Config) -> Result<()> {
+		let host = config.host(&self.hostname)?;
+		let hostname = host.name.clone();
+		let recipient = config.recipient(&SecretOwner::host(&hostname)).await?;
+		let secret = naming_secret(config, &hostname)?;
+
+		let built = build_task(config.clone(), hostname.clone(), &self.build_attr).await?;
+
+		if let Some(key) = self.sign_key.clone() {
+			let store = eval_store();
+			let path = built.clone();
+			spawn_blocking(move || store.sign_closure(&path, &key)).await??;
+		} else {
+			warn!(target: TARGET, "closure is not signed (no --sign-key), the host will refuse it unless paths are signed by other means");
+		}
+
+		let (stick, dir) = match &self.mount_point {
+			Some(dir) => (None, dir.clone()),
+			None => {
+				let stick = Stick::discover(&self.label).await?;
+				let dir = stick.mount_point.clone();
+				(Some(stick), dir)
+			}
+		};
+		info!(target: TARGET, "writing update to {dir}");
+
+		let store = eval_store();
+		let manifest = {
+			let secret = secret.clone();
+			let hostname = hostname.clone();
+			let built = built.clone();
+			let dir = dir.clone();
+			spawn_blocking(move || write_closure(&store, &secret, &dir, &hostname, &built))
+				.await??
+		};
+
+		let encrypted = encrypt_manifest(&manifest, std::iter::once(&*recipient))?;
+		{
+			let dir = dir.clone();
+			spawn_blocking(move || write_manifest(&dir, &encrypted)).await??;
+		}
+
+		if let Some(stick) = stick {
+			stick.unmount().await?;
+		}
+		info!(target: TARGET, "update for {hostname} written, the stick can be removed");
+		Ok(())
+	}
+}
+
+fn naming_secret(config: &Config, host: &str) -> Result<NamingSecret> {
+	let mut extra = config.data.extra.write().expect("no poisoning");
+	let usbd = extra
+		.entry("usbd".to_owned())
+		.or_insert_with(|| serde_json::json!({}));
+	let usbd = usbd
+		.as_object_mut()
+		.context("fleet.nix extra.usbd should be an object")?;
+	let secrets = usbd
+		.entry("namingSecrets")
+		.or_insert_with(|| serde_json::json!({}));
+	let secrets = secrets
+		.as_object_mut()
+		.context("fleet.nix extra.usbd.namingSecrets should be an object")?;
+	if let Some(value) = secrets.get(host) {
+		let value = value
+			.as_str()
+			.context("naming secret should be a base64 string")?;
+		let bytes = BASE64.decode(value).context("naming secret base64")?;
+		let bytes: [u8; NAMING_SECRET_SIZE] = bytes
+			.try_into()
+			.map_err(|_| anyhow!("naming secret should be {NAMING_SECRET_SIZE} bytes"))?;
+		Ok(NamingSecret(bytes))
+	} else {
+		let mut bytes = [0u8; NAMING_SECRET_SIZE];
+		rand::rng().fill_bytes(&mut bytes);
+		secrets.insert(
+			host.to_owned(),
+			serde_json::Value::String(BASE64.encode(bytes)),
+		);
+		Ok(NamingSecret(bytes))
+	}
+}
+
+fn write_closure(
+	store: &Arc<Store>,
+	secret: &NamingSecret,
+	dir: &Utf8Path,
+	hostname: &str,
+	toplevel: &Utf8Path,
+) -> Result<Manifest> {
+	let mut closure = store.compute_closure(toplevel)?;
+	closure.sort();
+	info!(target: TARGET, "update closure contains {} paths", closure.len());
+
+	let mut existing = HashSet::new();
+	for entry in dir.read_dir_utf8().context("reading stick")? {
+		existing.insert(entry?.file_name().to_owned());
+	}
+
+	let total = closure.len();
+	let mut entries = Vec::with_capacity(total);
+	for (i, path) in closure.iter().enumerate() {
+		let entry = write_path(store, secret, dir, &mut existing, path)
+			.with_context(|| format!("writing {path}"))?;
+		info!(target: TARGET, "[{}/{total}] {path}", i + 1);
+		entries.push(entry);
+	}
+
+	let referenced = entries
+		.iter()
+		.flat_map(|e| e.chunks.iter())
+		.map(String::as_str)
+		.collect::<HashSet<_>>();
+	for name in &existing {
+		let stale_data = names::is_data_name(name) && !referenced.contains(name.as_str());
+		let stale_other = name.starts_with(TMP_PREFIX) || name == MANIFEST_DONE_NAME;
+		if stale_data || stale_other {
+			std::fs::remove_file(dir.join(name))
+				.with_context(|| format!("deleting stale {name}"))?;
+		}
+	}
+
+	Ok(Manifest {
+		version: MANIFEST_VERSION,
+		host: hostname.to_owned(),
+		toplevel: toplevel.to_owned(),
+		paths: entries,
+	})
+}
+
+fn write_path(
+	store: &Arc<Store>,
+	secret: &NamingSecret,
+	dir: &Utf8Path,
+	existing: &mut HashSet<String>,
+	path: &Utf8Path,
+) -> Result<PathEntry> {
+	let info = store.query_path_info(path)?;
+	let key = names::file_key(secret, &info.nar_hash);
+
+	let mut chunks = None;
+	for count in 1..=MAX_CHUNKS {
+		if !existing.contains(&names::chunk_name(secret, &info.nar_hash, 0, count)) {
+			continue;
+		}
+		let all = (0..count)
+			.map(|i| names::chunk_name(secret, &info.nar_hash, i, count))
+			.collect::<Vec<_>>();
+		if all.iter().all(|c| existing.contains(c)) {
+			chunks = Some(all);
+			break;
+		}
+	}
+
+	let chunks = if let Some(chunks) = chunks {
+		chunks
+	} else {
+		let chunk_writer = ChunkWriter::new(dir.to_owned());
+		let encrypt = EncryptWriter::new(&key, chunk_writer);
+		let mut compress =
+			zstd::stream::write::Encoder::new(encrypt, zstd::DEFAULT_COMPRESSION_LEVEL)?;
+		store.nar_from_path(path, &mut compress)?;
+		let tmp_files = compress.finish()?.finish()?.finish()?;
+		let count = u32::try_from(tmp_files.len()).expect("chunk count");
+		if count > MAX_CHUNKS {
+			bail!("{path} does not fit into {MAX_CHUNKS} chunks");
+		}
+		let chunks = (0..count)
+			.map(|i| names::chunk_name(secret, &info.nar_hash, i, count))
+			.collect::<Vec<_>>();
+		for (tmp, name) in tmp_files.iter().zip(&chunks) {
+			std::fs::rename(tmp, dir.join(name))?;
+		}
+		existing.extend(chunks.iter().cloned());
+		chunks
+	};
+
+	Ok(PathEntry {
+		store_path: path.to_owned(),
+		nar_hash: info.nar_hash,
+		nar_size: info.nar_size,
+		references: info.references,
+		sigs: info.sigs,
+		compression: "zstd".to_owned(),
+		key: BASE64.encode(key),
+		chunks,
+	})
+}
+
+fn write_manifest(dir: &Utf8Path, encrypted: &[u8]) -> Result<()> {
+	let tmp = dir.join(format!("{TMP_PREFIX}manifest"));
+	let mut file = File::create(&tmp)?;
+	file.write_all(encrypted)?;
+	file.sync_all()?;
+	drop(file);
+	std::fs::rename(&tmp, dir.join(MANIFEST_NAME))?;
+	nix::unistd::sync();
+	Ok(())
+}
+
+struct ChunkWriter {
+	dir: Utf8PathBuf,
+	files: Vec<(Utf8PathBuf, File)>,
+	current_len: u64,
+}
+
+impl ChunkWriter {
+	fn new(dir: Utf8PathBuf) -> Self {
+		Self {
+			dir,
+			files: Vec::new(),
+			current_len: 0,
+		}
+	}
+
+	fn finish(self) -> io::Result<Vec<Utf8PathBuf>> {
+		for (_, file) in &self.files {
+			file.sync_all()?;
+		}
+		Ok(self.files.into_iter().map(|(path, _)| path).collect())
+	}
+}
+
+impl io::Write for ChunkWriter {
+	fn write(&mut self, data: &[u8]) -> io::Result<usize> {
+		if self.files.is_empty() || self.current_len == MAX_CHUNK_SIZE {
+			let path = self.dir.join(format!("{TMP_PREFIX}{}", self.files.len()));
+			let file = File::create(&path)?;
+			self.files.push((path, file));
+			self.current_len = 0;
+		}
+		let take = data.len().min((MAX_CHUNK_SIZE - self.current_len) as usize);
+		let file = &mut self.files.last_mut().expect("just ensured").1;
+		file.write_all(&data[..take])?;
+		self.current_len += take as u64;
+		Ok(take)
+	}
+	fn flush(&mut self) -> io::Result<()> {
+		Ok(())
+	}
+}
+
+const UDISKS_DEST: &str = "org.freedesktop.UDisks2";
+const IFACE_BLOCK: &str = "org.freedesktop.UDisks2.Block";
+const IFACE_FILESYSTEM: &str = "org.freedesktop.UDisks2.Filesystem";
+
+struct Stick {
+	conn: zbus::Connection,
+	object: OwnedObjectPath,
+	mount_point: Utf8PathBuf,
+	mounted_by_us: bool,
+}
+
+impl Stick {
+	async fn discover(label: &str) -> Result<Self> {
+		let conn = zbus::Connection::system()
+			.await
+			.context("connecting to system dbus for udisks")?;
+		let om = zbus::fdo::ObjectManagerProxy::builder(&conn)
+			.destination(UDISKS_DEST)?
+			.path("/org/freedesktop/UDisks2")?
+			.build()
+			.await?;
+		let objects = om
+			.get_managed_objects()
+			.await
+			.context("is udisks2 running?")?;
+
+		let mut found = Vec::new();
+		for (object, ifaces) in objects {
+			let Some(block) = ifaces.iter().find(|(k, _)| k.as_str() == IFACE_BLOCK) else {
+				continue;
+			};
+			if !ifaces.iter().any(|(k, _)| k.as_str() == IFACE_FILESYSTEM) {
+				continue;
+			}
+			let Some(id_label) = block.1.get("IdLabel") else {
+				continue;
+			};
+			let Ok(id_label) = String::try_from(id_label.clone()) else {
+				continue;
+			};
+			if id_label == label {
+				found.push(object);
+			}
+		}
+		let object = match found.len() {
+			0 => bail!("no inserted filesystem with label {label} found, is the stick inserted?"),
+			1 => found.remove(0),
+			_ => bail!("multiple filesystems with label {label} found: {found:?}"),
+		};
+
+		let fs = zbus::Proxy::new(&conn, UDISKS_DEST, &object, IFACE_FILESYSTEM).await?;
+		let mount_points: Vec<Vec<u8>> = fs.get_property("MountPoints").await?;
+		if let Some(mp) = mount_points.first() {
+			let mp = mp.strip_suffix(&[0]).unwrap_or(mp);
+			let mount_point = Utf8PathBuf::from(String::from_utf8(mp.to_vec())?);
+			info!(target: TARGET, "stick is already mounted at {mount_point}");
+			return Ok(Self {
+				conn,
+				object,
+				mount_point,
+				mounted_by_us: false,
+			});
+		}
+
+		let options = std::collections::HashMap::<&str, zvariant::Value>::new();
+		let mount_point: String = fs
+			.call("Mount", &(options,))
+			.await
+			.context("mounting the stick via udisks")?;
+		Ok(Self {
+			conn,
+			object,
+			mount_point: Utf8PathBuf::from(mount_point),
+			mounted_by_us: true,
+		})
+	}
+
+	async fn unmount(self) -> Result<()> {
+		if !self.mounted_by_us {
+			return Ok(());
+		}
+		let fs = zbus::Proxy::new(&self.conn, UDISKS_DEST, &self.object, IFACE_FILESYSTEM).await?;
+		let options = std::collections::HashMap::<&str, zvariant::Value>::new();
+		fs.call::<_, _, ()>("Unmount", &(options,))
+			.await
+			.context("unmounting the stick")?;
+		Ok(())
+	}
+}
modifiedcmds/fleet/src/main.rsdiffbeforeafterboth
--- a/cmds/fleet/src/main.rs
+++ b/cmds/fleet/src/main.rs
@@ -14,6 +14,7 @@
 	rollback::RollbackSingle,
 	secrets::Secret,
 	tf::Tf,
+	usbd::Usbd,
 };
 use fleet_base::{host::Config, opts::FleetOpts};
 use futures::{TryStreamExt, stream::FuturesUnordered};
@@ -74,6 +75,9 @@
 	Prefetch(Prefetch),
 	/// Config parsing
 	Info(Info),
+	/// Airgapped updates over USB stick
+	#[clap(subcommand)]
+	Usbd(Usbd),
 	/// Command completions
 	#[clap(hide(true))]
 	Complete(Complete),
@@ -100,6 +104,7 @@
 		Opts::RollbackSingle(r) => r.run(config, &opts).await?,
 		Opts::Secret(s) => s.run(config, &opts).await?,
 		Opts::Info(i) => i.run(config).await?,
+		Opts::Usbd(u) => u.run(config).await?,
 		Opts::Prefetch(p) => p.run(config).await?,
 		Opts::Tf(t) => t.run(config).await?,
 		// TODO: actually parse commands before starting the async runtime
addedcmds/usbd/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/cmds/usbd/Cargo.toml
@@ -0,0 +1,27 @@
+[package]
+name = "fleet-usbd"
+description = "Airgapped NixOS update daemon, applies fleet updates from a USB stick"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+age.workspace = true
+anyhow.workspace = true
+base64.workspace = true
+bytes.workspace = true
+camino.workspace = true
+clap.workspace = true
+fleet-usb.workspace = true
+futures.workspace = true
+goodlog-subscriber.workspace = true
+hostname.workspace = true
+hex.workspace = true
+http-body-util.workspace = true
+hyper.workspace = true
+hyper-util.workspace = true
+nix = { workspace = true, features = ["mount"] }
+nix-eval.workspace = true
+rand.workspace = true
+tokio = { workspace = true, features = ["net", "process"] }
+tracing.workspace = true
addedcmds/usbd/src/main.rsdiffbeforeafterboth
--- /dev/null
+++ b/cmds/usbd/src/main.rs
@@ -0,0 +1,279 @@
+mod server;
+
+use std::{process::ExitCode, sync::Arc};
+
+use anyhow::{Context as _, Result, bail};
+use camino::{Utf8Path, Utf8PathBuf};
+use clap::Parser;
+use fleet_usb::{MANIFEST_DONE_NAME, MANIFEST_NAME, manifest::decrypt_manifest};
+use goodlog_subscriber::{LogOpts, setup_logging};
+use nix::mount::MsFlags;
+use nix_eval::{
+	Store, gc_register_my_thread, gc_unregister_my_thread, init_libraries, init_tokio_for_nix,
+};
+use tokio::task::spawn_blocking;
+use tracing::{error, info, warn};
+
+const MOUNT_TARGET: &str = "/run/fleet-usbd/mnt";
+
+#[derive(Parser)]
+#[clap(version, author)]
+struct Opts {
+	/// Block device with the update filesystem, e.g. /dev/disk/by-label/FLEETUSBD
+	#[clap(
+		long,
+		required_unless_present = "mount_point",
+		conflicts_with = "mount_point"
+	)]
+	device: Option<Utf8PathBuf>,
+	/// Already mounted update directory, instead of --device
+	#[clap(long)]
+	mount_point: Option<Utf8PathBuf>,
+	/// Host ssh key the manifest is encrypted to
+	#[clap(long, default_value = "/etc/ssh/ssh_host_ed25519_key")]
+	host_key: Utf8PathBuf,
+	/// Expected manifest host name, defaults to the system hostname
+	#[clap(long)]
+	hostname: Option<String>,
+	/// Executed on update lifecycle events with the event name as the first argument:
+	/// copying, switching, done, noop, failed <error>
+	#[clap(long, verbatim_doc_comment)]
+	hook: Option<Utf8PathBuf>,
+	#[clap(long, default_value = "/nix/var/nix/profiles/system")]
+	profile: String,
+	/// Do not reboot after the update is applied
+	#[clap(long)]
+	no_reboot: bool,
+	#[clap(flatten)]
+	log: LogOpts,
+}
+
+enum Outcome {
+	Applied,
+	UpToDate,
+	Nothing,
+}
+
+struct Hook(Option<Utf8PathBuf>);
+impl Hook {
+	async fn call(&self, event: &str, detail: Option<&str>) {
+		let Some(script) = &self.0 else {
+			return;
+		};
+		let mut cmd = tokio::process::Command::new(script);
+		cmd.arg(event);
+		if let Some(detail) = detail {
+			cmd.arg(detail);
+		}
+		match cmd.status().await {
+			Ok(status) if status.success() => {}
+			Ok(status) => warn!("hook {event}: {status}"),
+			Err(e) => warn!("hook {event}: {e}"),
+		}
+	}
+}
+
+fn host_identity(path: &Utf8Path) -> Result<age::ssh::Identity> {
+	let data = std::fs::read(path).with_context(|| format!("reading host key {path}"))?;
+	age::ssh::Identity::from_buffer(std::io::Cursor::new(data), Some(path.to_string()))
+		.with_context(|| format!("parsing host key {path}"))
+}
+
+fn mount_stick(device: &Utf8Path) -> Result<()> {
+	std::fs::create_dir_all(MOUNT_TARGET)?;
+	nix::mount::mount(
+		Some(device.as_std_path()),
+		MOUNT_TARGET,
+		Some("vfat"),
+		MsFlags::MS_NOSUID | MsFlags::MS_NODEV | MsFlags::MS_NOEXEC,
+		None::<&str>,
+	)
+	.with_context(|| format!("mounting {device} at {MOUNT_TARGET}"))?;
+	Ok(())
+}
+
+fn mark_done(dir: &Utf8Path) -> Result<()> {
+	std::fs::rename(dir.join(MANIFEST_NAME), dir.join(MANIFEST_DONE_NAME))
+		.context("marking update as done")?;
+	nix::unistd::sync();
+	Ok(())
+}
+
+async fn apply(opts: &Opts, dir: &Utf8Path, hook: &Hook) -> Result<Outcome> {
+	let manifest_path = dir.join(MANIFEST_NAME);
+	if !manifest_path.exists() {
+		if dir.join(MANIFEST_DONE_NAME).exists() {
+			info!("update on this stick is already applied");
+		} else {
+			info!("no update manifest on the stick");
+		}
+		return Ok(Outcome::Nothing);
+	}
+
+	let identity = host_identity(&opts.host_key)?;
+	let data = std::fs::read(&manifest_path).context("reading manifest")?;
+	let manifest = decrypt_manifest(&data, &identity)
+		.context("decrypting manifest, is this stick meant for this machine?")?;
+
+	let host = match &opts.hostname {
+		Some(host) => host.clone(),
+		None => hostname::get()
+			.context("querying hostname")?
+			.to_string_lossy()
+			.into_owned(),
+	};
+	if manifest.host != host {
+		bail!(
+			"update is for host {}, but this host is {host}",
+			manifest.host
+		);
+	}
+
+	let current = std::fs::read_link("/run/current-system").ok();
+	if current.as_deref() == Some(manifest.toplevel.as_std_path()) {
+		info!("system is already up to date");
+		mark_done(dir)?;
+		return Ok(Outcome::UpToDate);
+	}
+
+	let missing = manifest
+		.paths
+		.iter()
+		.flat_map(|e| e.chunks.iter())
+		.filter(|c| !dir.join(c.as_str()).exists())
+		.count();
+	if missing > 0 {
+		bail!("stick is missing {missing} update files, was it synchronized fully?");
+	}
+
+	info!(
+		"applying update to {} ({} paths)",
+		manifest.toplevel,
+		manifest.paths.len()
+	);
+	hook.call("copying", None).await;
+	let cache = server::CacheServer::start(dir.to_owned(), &manifest).await?;
+	{
+		let url = cache.url.clone();
+		let toplevel = manifest.toplevel.clone();
+		spawn_blocking(move || -> Result<()> {
+			let src = Store::open(&url)?;
+			let dst = Store::open("auto")?;
+			src.copy_to(&dst, &toplevel)
+		})
+		.await?
+		.context("copying closure from the stick")?;
+	}
+	drop(cache);
+
+	hook.call("switching", None).await;
+	{
+		let profile = opts.profile.clone();
+		let toplevel = manifest.toplevel.clone();
+		spawn_blocking(move || -> Result<()> {
+			let store = Store::open("auto")?;
+			store.switch_profile(&profile, &toplevel)
+		})
+		.await??;
+	}
+	let switch = manifest.toplevel.join("bin/switch-to-configuration");
+	let status = tokio::process::Command::new(&switch)
+		.arg("boot")
+		.status()
+		.await
+		.with_context(|| format!("running {switch}"))?;
+	if !status.success() {
+		bail!("{switch} boot failed: {status}");
+	}
+
+	mark_done(dir)?;
+	Ok(Outcome::Applied)
+}
+
+async fn run(opts: &Opts) -> Result<()> {
+	let hook = Hook(opts.hook.clone());
+
+	let mounted = match &opts.device {
+		Some(device) => {
+			mount_stick(device)?;
+			true
+		}
+		None => false,
+	};
+	let dir = opts
+		.mount_point
+		.clone()
+		.unwrap_or_else(|| Utf8PathBuf::from(MOUNT_TARGET));
+
+	let outcome = apply(opts, &dir, &hook).await;
+
+	nix::unistd::sync();
+	if mounted && let Err(e) = nix::mount::umount(MOUNT_TARGET) {
+		warn!("unmounting the stick: {e}");
+	}
+
+	match outcome {
+		Ok(Outcome::Applied) => {
+			hook.call("done", None).await;
+			if opts.no_reboot {
+				info!("update applied, reboot to activate");
+			} else {
+				info!("update applied, rebooting");
+				let status = tokio::process::Command::new("systemctl")
+					.arg("reboot")
+					.status()
+					.await
+					.context("requesting reboot")?;
+				if !status.success() {
+					bail!("systemctl reboot failed: {status}");
+				}
+			}
+			Ok(())
+		}
+		Ok(Outcome::UpToDate) => {
+			hook.call("done", None).await;
+			Ok(())
+		}
+		Ok(Outcome::Nothing) => {
+			hook.call("noop", None).await;
+			Ok(())
+		}
+		Err(e) => {
+			hook.call("failed", Some(&format!("{e:#}"))).await;
+			Err(e)
+		}
+	}
+}
+
+fn main() -> ExitCode {
+	let opts = Opts::parse();
+	if let Err(e) = setup_logging(&opts.log) {
+		eprintln!("{e:#}");
+		return ExitCode::FAILURE;
+	}
+
+	init_libraries();
+
+	let runtime = tokio::runtime::Builder::new_multi_thread()
+		.enable_all()
+		.on_thread_start(|| {
+			gc_register_my_thread();
+		})
+		.on_thread_stop(|| {
+			gc_unregister_my_thread();
+		})
+		.build()
+		.expect("failed to build runtime");
+	let runtime = Arc::new(runtime);
+
+	init_tokio_for_nix(runtime.clone());
+
+	runtime.block_on(async {
+		if let Err(e) = run(&opts).await {
+			error!("{e:#}");
+			ExitCode::FAILURE
+		} else {
+			ExitCode::SUCCESS
+		}
+	})
+}
addedcmds/usbd/src/server.rsdiffbeforeafterboth
--- /dev/null
+++ b/cmds/usbd/src/server.rs
@@ -0,0 +1,218 @@
+use std::{collections::HashMap, convert::Infallible, fs::File, io::Read as _, sync::Arc};
+
+use anyhow::{Context as _, Result, anyhow};
+use base64::Engine as _;
+use base64::engine::general_purpose::STANDARD as BASE64;
+use bytes::Bytes;
+use camino::Utf8PathBuf;
+use fleet_usb::{manifest::Manifest, manifest::PathEntry, stream::DecryptReader};
+use futures::SinkExt as _;
+use http_body_util::{BodyExt as _, Full, StreamBody, combinators::BoxBody};
+use hyper::{
+	Method, Request, Response, StatusCode,
+	body::{Frame, Incoming},
+	service::service_fn,
+};
+use hyper_util::rt::TokioIo;
+use rand::Rng as _;
+use tokio::{net::TcpListener, task::spawn_blocking};
+use tracing::debug;
+
+type Body = BoxBody<Bytes, std::io::Error>;
+
+struct State {
+	dir: Utf8PathBuf,
+	prefix: String,
+	store_dir: String,
+	entries: HashMap<String, PathEntry>,
+}
+
+pub struct CacheServer {
+	pub url: String,
+	accept_loop: tokio::task::JoinHandle<()>,
+}
+
+impl Drop for CacheServer {
+	fn drop(&mut self) {
+		self.accept_loop.abort();
+	}
+}
+
+impl CacheServer {
+	pub async fn start(dir: Utf8PathBuf, manifest: &Manifest) -> Result<Self> {
+		let mut token = [0u8; 16];
+		rand::rng().fill_bytes(&mut token);
+		let token = hex::encode(token);
+
+		let store_dir = manifest
+			.toplevel
+			.parent()
+			.context("toplevel should be a store path")?
+			.to_string();
+		let mut entries = HashMap::new();
+		for entry in &manifest.paths {
+			let base = entry
+				.store_path
+				.file_name()
+				.context("store path should have a file name")?;
+			let hash = base
+				.split('-')
+				.next()
+				.expect("split returns at least one part");
+			entries.insert(hash.to_owned(), entry.clone());
+		}
+
+		let listener = TcpListener::bind(("127.0.0.1", 0))
+			.await
+			.context("binding local cache server")?;
+		let port = listener.local_addr()?.port();
+		let state = Arc::new(State {
+			dir,
+			prefix: format!("/{token}/"),
+			store_dir,
+			entries,
+		});
+		let accept_loop = tokio::spawn(accept_loop(listener, state));
+		Ok(Self {
+			url: format!("http://127.0.0.1:{port}/{token}"),
+			accept_loop,
+		})
+	}
+}
+
+async fn accept_loop(listener: TcpListener, state: Arc<State>) {
+	loop {
+		let stream = match listener.accept().await {
+			Ok((stream, _)) => stream,
+			Err(e) => {
+				debug!("cache server accept: {e}");
+				continue;
+			}
+		};
+		let state = state.clone();
+		tokio::spawn(async move {
+			let service = service_fn(move |req| handle(state.clone(), req));
+			if let Err(e) = hyper::server::conn::http1::Builder::new()
+				.serve_connection(TokioIo::new(stream), service)
+				.await
+			{
+				debug!("cache server connection: {e}");
+			}
+		});
+	}
+}
+
+async fn handle(state: Arc<State>, req: Request<Incoming>) -> Result<Response<Body>, Infallible> {
+	let head = req.method() == Method::HEAD;
+	if !head && req.method() != Method::GET {
+		return Ok(status(StatusCode::METHOD_NOT_ALLOWED));
+	}
+	let mut resp = route(&state, req.uri().path()).unwrap_or_else(|e| {
+		debug!("cache server request {} failed: {e}", req.uri().path());
+		status(StatusCode::INTERNAL_SERVER_ERROR)
+	});
+	if head {
+		*resp.body_mut() = empty();
+	}
+	Ok(resp)
+}
+
+fn route(state: &Arc<State>, path: &str) -> Result<Response<Body>> {
+	let Some(rest) = path.strip_prefix(&state.prefix) else {
+		return Ok(status(StatusCode::NOT_FOUND));
+	};
+	if rest == "nix-cache-info" {
+		return Ok(text(format!(
+			"StoreDir: {}\nWantMassQuery: 1\nPriority: 30\n",
+			state.store_dir
+		)));
+	}
+	if let Some(hash) = rest.strip_suffix(".narinfo") {
+		let Some(entry) = state.entries.get(hash) else {
+			return Ok(status(StatusCode::NOT_FOUND));
+		};
+		return Ok(text(narinfo(hash, entry)));
+	}
+	if let Some(hash) = rest.strip_prefix("nar/") {
+		let Some(entry) = state.entries.get(hash) else {
+			return Ok(status(StatusCode::NOT_FOUND));
+		};
+		return nar(state, entry);
+	}
+	Ok(status(StatusCode::NOT_FOUND))
+}
+
+fn narinfo(hash: &str, entry: &PathEntry) -> String {
+	let references = entry
+		.references
+		.iter()
+		.filter_map(|r| r.file_name())
+		.collect::<Vec<_>>()
+		.join(" ");
+	let mut out = format!(
+		"StorePath: {}\nURL: nar/{hash}\nCompression: {}\nNarHash: {}\nNarSize: {}\nReferences: {references}\n",
+		entry.store_path, entry.compression, entry.nar_hash, entry.nar_size,
+	);
+	for sig in &entry.sigs {
+		out.push_str(&format!("Sig: {sig}\n"));
+	}
+	out
+}
+
+fn nar(state: &Arc<State>, entry: &PathEntry) -> Result<Response<Body>> {
+	let key: [u8; 32] = BASE64
+		.decode(&entry.key)?
+		.try_into()
+		.map_err(|_| anyhow!("invalid key size in manifest"))?;
+	let files = entry
+		.chunks
+		.iter()
+		.map(|c| state.dir.join(c))
+		.collect::<Vec<_>>();
+
+	let (mut tx, rx) = futures::channel::mpsc::channel::<Result<Frame<Bytes>, std::io::Error>>(8);
+	spawn_blocking(move || {
+		let mut stream = || -> std::io::Result<()> {
+			let mut chunks: Box<dyn std::io::Read> = Box::new(std::io::empty());
+			for file in &files {
+				chunks = Box::new(chunks.chain(File::open(file)?));
+			}
+			let mut reader = DecryptReader::new(&key, chunks);
+			let mut buf = vec![0u8; 64 * 1024];
+			loop {
+				let n = reader.read(&mut buf)?;
+				if n == 0 {
+					return Ok(());
+				}
+				let frame = Frame::data(Bytes::copy_from_slice(&buf[..n]));
+				if futures::executor::block_on(tx.send(Ok(frame))).is_err() {
+					return Ok(());
+				}
+			}
+		};
+		if let Err(e) = stream() {
+			let _ = futures::executor::block_on(tx.send(Err(e)));
+		}
+	});
+	Ok(Response::new(StreamBody::new(rx).boxed()))
+}
+
+fn empty() -> Body {
+	Full::new(Bytes::new())
+		.map_err(|never| match never {})
+		.boxed()
+}
+
+fn text(s: String) -> Response<Body> {
+	Response::new(
+		Full::new(Bytes::from(s))
+			.map_err(|never| match never {})
+			.boxed(),
+	)
+}
+
+fn status(code: StatusCode) -> Response<Body> {
+	let mut resp = Response::new(empty());
+	*resp.status_mut() = code;
+	resp
+}
addedcrates/fleet-usb/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/crates/fleet-usb/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "fleet-usb"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+age.workspace = true
+anyhow.workspace = true
+base64.workspace = true
+camino = { workspace = true, features = ["serde1"] }
+chacha20poly1305.workspace = true
+hex.workspace = true
+hmac.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+sha2.workspace = true
addedcrates/fleet-usb/src/lib.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/fleet-usb/src/lib.rs
@@ -0,0 +1,7 @@
+pub mod manifest;
+pub mod names;
+pub mod stream;
+
+pub const MANIFEST_NAME: &str = "usbd-update";
+pub const MANIFEST_DONE_NAME: &str = "usbd-update.done";
+pub const MANIFEST_VERSION: u32 = 1;
addedcrates/fleet-usb/src/manifest.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/fleet-usb/src/manifest.rs
@@ -0,0 +1,62 @@
+use std::io::{Read as _, Write as _};
+
+use age::{Identity, Recipient};
+use anyhow::{Context as _, Result, anyhow, bail};
+use camino::Utf8PathBuf;
+use serde::{Deserialize, Serialize};
+
+use crate::MANIFEST_VERSION;
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+#[serde(rename_all = "camelCase")]
+pub struct Manifest {
+	pub version: u32,
+	pub host: String,
+	pub toplevel: Utf8PathBuf,
+	pub paths: Vec<PathEntry>,
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+#[serde(rename_all = "camelCase")]
+pub struct PathEntry {
+	pub store_path: Utf8PathBuf,
+	pub nar_hash: String,
+	pub nar_size: u64,
+	pub references: Vec<Utf8PathBuf>,
+	pub sigs: Vec<String>,
+	pub compression: String,
+	pub key: String,
+	pub chunks: Vec<String>,
+}
+
+pub fn encrypt_manifest<'r>(
+	manifest: &Manifest,
+	recipients: impl Iterator<Item = &'r dyn Recipient>,
+) -> Result<Vec<u8>> {
+	let json = serde_json::to_vec(manifest)?;
+	let mut out = Vec::new();
+	let mut encryptor = age::Encryptor::with_recipients(recipients)
+		.map_err(|e| anyhow!("no recipients: {e}"))?
+		.wrap_output(&mut out)
+		.context("age encrypt")?;
+	encryptor.write_all(&json)?;
+	encryptor.finish()?;
+	Ok(out)
+}
+
+pub fn decrypt_manifest(data: &[u8], identity: &dyn Identity) -> Result<Manifest> {
+	let decryptor = age::Decryptor::new(data).context("age decrypt")?;
+	let mut reader = decryptor
+		.decrypt(std::iter::once(identity))
+		.context("manifest is not encrypted for this identity")?;
+	let mut json = Vec::new();
+	reader.read_to_end(&mut json)?;
+	let manifest: Manifest = serde_json::from_slice(&json).context("manifest json")?;
+	if manifest.version != MANIFEST_VERSION {
+		bail!(
+			"unsupported manifest version {}, this fleet-usbd supports {MANIFEST_VERSION}",
+			manifest.version
+		);
+	}
+	Ok(manifest)
+}
addedcrates/fleet-usb/src/names.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/fleet-usb/src/names.rs
@@ -0,0 +1,42 @@
+use hmac::{Hmac, KeyInit as _, Mac as _};
+use sha2::Sha256;
+
+type HmacSha256 = Hmac<Sha256>;
+
+pub const NAMING_SECRET_SIZE: usize = 32;
+
+#[derive(Clone)]
+pub struct NamingSecret(pub [u8; NAMING_SECRET_SIZE]);
+
+fn derive(secret: &NamingSecret, parts: &[&[u8]]) -> [u8; 32] {
+	let mut mac = HmacSha256::new_from_slice(&secret.0).expect("any key size is valid");
+	for part in parts {
+		mac.update(&u64::to_be_bytes(part.len() as u64));
+		mac.update(part);
+	}
+	mac.finalize().into_bytes().into()
+}
+
+pub fn file_key(secret: &NamingSecret, nar_hash: &str) -> [u8; 32] {
+	derive(secret, &[b"fleet-usb.v1.key\0", nar_hash.as_bytes()])
+}
+
+pub fn chunk_name(secret: &NamingSecret, nar_hash: &str, index: u32, count: u32) -> String {
+	let h = derive(
+		secret,
+		&[
+			b"fleet-usb.v1.name\0",
+			nar_hash.as_bytes(),
+			&index.to_le_bytes(),
+			&count.to_le_bytes(),
+		],
+	);
+	hex::encode(&h[..16])
+}
+
+pub fn is_data_name(name: &str) -> bool {
+	name.len() == 32
+		&& name
+			.bytes()
+			.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
+}
addedcrates/fleet-usb/src/stream.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/fleet-usb/src/stream.rs
@@ -0,0 +1,179 @@
+use std::io::{self, Read, Write};
+
+use chacha20poly1305::{KeyInit as _, XChaCha20Poly1305, XNonce, aead::Aead as _};
+
+pub const FRAME_SIZE: usize = 256 * 1024;
+pub const TAG_SIZE: usize = 16;
+
+fn nonce(counter: u64, last: bool) -> XNonce {
+	let mut n = [0u8; 24];
+	n[..8].copy_from_slice(&counter.to_le_bytes());
+	n[23] = last as u8;
+	n.into()
+}
+
+pub struct EncryptWriter<W: Write> {
+	inner: W,
+	aead: XChaCha20Poly1305,
+	buf: Vec<u8>,
+	counter: u64,
+}
+
+impl<W: Write> EncryptWriter<W> {
+	pub fn new(key: &[u8; 32], inner: W) -> Self {
+		Self {
+			inner,
+			aead: XChaCha20Poly1305::new(key.into()),
+			buf: Vec::with_capacity(FRAME_SIZE),
+			counter: 0,
+		}
+	}
+
+	fn emit(&mut self, last: bool) -> io::Result<()> {
+		let ct = self
+			.aead
+			.encrypt(&nonce(self.counter, last), self.buf.as_slice())
+			.map_err(|_| io::Error::other("aead encrypt failed"))?;
+		self.inner.write_all(&ct)?;
+		self.counter += 1;
+		self.buf.clear();
+		Ok(())
+	}
+
+	pub fn finish(mut self) -> io::Result<W> {
+		self.emit(true)?;
+		self.inner.flush()?;
+		Ok(self.inner)
+	}
+}
+
+impl<W: Write> Write for EncryptWriter<W> {
+	fn write(&mut self, data: &[u8]) -> io::Result<usize> {
+		let take = data.len().min(FRAME_SIZE - self.buf.len());
+		self.buf.extend_from_slice(&data[..take]);
+		if self.buf.len() == FRAME_SIZE {
+			self.emit(false)?;
+		}
+		Ok(take)
+	}
+	fn flush(&mut self) -> io::Result<()> {
+		Ok(())
+	}
+}
+
+pub struct DecryptReader<R: Read> {
+	inner: R,
+	aead: XChaCha20Poly1305,
+	counter: u64,
+	plain: Vec<u8>,
+	plain_pos: usize,
+	lookahead: Option<u8>,
+	done: bool,
+}
+
+impl<R: Read> DecryptReader<R> {
+	pub fn new(key: &[u8; 32], inner: R) -> Self {
+		Self {
+			inner,
+			aead: XChaCha20Poly1305::new(key.into()),
+			counter: 0,
+			plain: Vec::new(),
+			plain_pos: 0,
+			lookahead: None,
+			done: false,
+		}
+	}
+
+	fn read_frame(&mut self) -> io::Result<()> {
+		let mut frame = Vec::with_capacity(FRAME_SIZE + TAG_SIZE + 1);
+		if let Some(b) = self.lookahead.take() {
+			frame.push(b);
+		}
+		(&mut self.inner)
+			.take((FRAME_SIZE + TAG_SIZE + 1 - frame.len()) as u64)
+			.read_to_end(&mut frame)?;
+		let last = if frame.len() == FRAME_SIZE + TAG_SIZE + 1 {
+			self.lookahead = frame.pop();
+			false
+		} else {
+			true
+		};
+		if frame.len() < TAG_SIZE {
+			return Err(io::Error::other("truncated stream: incomplete frame"));
+		}
+		self.plain = self
+			.aead
+			.decrypt(&nonce(self.counter, last), frame.as_slice())
+			.map_err(|_| io::Error::other("aead decrypt failed: corrupted or truncated stream"))?;
+		self.counter += 1;
+		self.plain_pos = 0;
+		self.done = last;
+		Ok(())
+	}
+}
+
+impl<R: Read> Read for DecryptReader<R> {
+	fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
+		while self.plain_pos == self.plain.len() {
+			if self.done {
+				return Ok(0);
+			}
+			self.read_frame()?;
+		}
+		let take = out.len().min(self.plain.len() - self.plain_pos);
+		out[..take].copy_from_slice(&self.plain[self.plain_pos..self.plain_pos + take]);
+		self.plain_pos += take;
+		Ok(take)
+	}
+}
+
+#[cfg(test)]
+mod tests {
+	use super::*;
+
+	fn roundtrip(len: usize) {
+		let key = [7u8; 32];
+		let data = (0..len).map(|i| (i * 31 % 256) as u8).collect::<Vec<_>>();
+		let mut enc = EncryptWriter::new(&key, Vec::new());
+		enc.write_all(&data).unwrap();
+		let ct = enc.finish().unwrap();
+		let mut out = Vec::new();
+		DecryptReader::new(&key, ct.as_slice())
+			.read_to_end(&mut out)
+			.unwrap();
+		assert_eq!(out, data);
+	}
+
+	#[test]
+	fn roundtrips() {
+		roundtrip(0);
+		roundtrip(1);
+		roundtrip(FRAME_SIZE - 1);
+		roundtrip(FRAME_SIZE);
+		roundtrip(FRAME_SIZE + 1);
+		roundtrip(FRAME_SIZE * 3 + 17);
+	}
+
+	#[test]
+	fn truncation_detected() {
+		let key = [7u8; 32];
+		let mut enc = EncryptWriter::new(&key, Vec::new());
+		enc.write_all(&vec![1u8; FRAME_SIZE * 2]).unwrap();
+		let ct = enc.finish().unwrap();
+		let mut out = Vec::new();
+		let res = DecryptReader::new(&key, &ct[..ct.len() - TAG_SIZE - 1]).read_to_end(&mut out);
+		assert!(res.is_err());
+	}
+
+	#[test]
+	fn deterministic() {
+		let key = [7u8; 32];
+		let data = vec![3u8; FRAME_SIZE + 100];
+		let ct = |d: &[u8]| {
+			let mut enc = EncryptWriter::new(&key, Vec::new());
+			enc.write_all(d).unwrap();
+			enc.finish().unwrap()
+		};
+		assert_eq!(ct(&data), ct(&data));
+	}
+}
modifiedcrates/nix-eval/src/lib.ccdiffbeforeafterboth
--- a/crates/nix-eval/src/lib.cc
+++ b/crates/nix-eval/src/lib.cc
@@ -14,6 +14,7 @@
 #include <nix/util/hash.hh>
 #include <nix/util/posix-source-accessor.hh>
 #include <nix/util/ref.hh>
+#include <nix/util/serialise.hh>
 #include <nix/util/signature/local-keys.hh>
 #include <nix/util/signature/signer.hh>
 #include <nix/util/source-path.hh>
@@ -184,6 +185,70 @@
   }
 }
 
+CxxPathInfo query_path_info(Store *store, rust::Str path) {
+  CxxPathInfo out{rust::String(), rust::String(), 0, {}, {}};
+  try {
+    auto nixStore = store->ptr;
+    auto sp = nixStore->parseStorePath(std::string(path));
+    auto info = nixStore->queryPathInfo(sp);
+    out.nar_hash =
+        rust::String(info->narHash.to_string(nix::HashFormat::Nix32, true));
+    out.nar_size = info->narSize;
+    for (auto &r : info->references)
+      out.references.push_back(rust::String(nixStore->printStorePath(r)));
+    for (auto &s : info->sigs)
+      out.sigs.push_back(rust::String(s.to_string()));
+  } catch (const std::exception &e) {
+    out.error = rust::String(e.what());
+  }
+  return out;
+}
+
+CxxBuildResult compute_closure(Store *store, rust::Str path) {
+  CxxBuildResult res{rust::String(), {}};
+  try {
+    auto nixStore = store->ptr;
+    auto root = nixStore->parseStorePath(std::string(path));
+    nix::StorePathSet closure;
+    nixStore->computeFSClosure(root, closure);
+    for (auto &p : closure)
+      res.outputs.push_back(rust::String(nixStore->printStorePath(p)));
+  } catch (const std::exception &e) {
+    res.error = rust::String(e.what());
+  }
+  return res;
+}
+
+namespace {
+struct RustFnSink : nix::Sink {
+  size_t data;
+  rust::Fn<bool(size_t, rust::Slice<const uint8_t>)> sink;
+  RustFnSink(size_t data,
+             rust::Fn<bool(size_t, rust::Slice<const uint8_t>)> sink)
+      : data(data), sink(sink) {}
+  void operator()(std::string_view chunk) override {
+    bool ok = sink(data, rust::Slice<const uint8_t>(
+                             reinterpret_cast<const uint8_t *>(chunk.data()),
+                             chunk.size()));
+    if (!ok)
+      throw nix::Error("nar sink aborted");
+  }
+};
+} // namespace
+
+rust::String nar_from_path(Store *store, rust::Str path, size_t sink_data,
+                           rust::Fn<bool(size_t, rust::Slice<const uint8_t>)> sink) {
+  try {
+    auto nixStore = store->ptr;
+    auto sp = nixStore->parseStorePath(std::string(path));
+    RustFnSink s(sink_data, sink);
+    nixStore->narFromPath(sp, s);
+    return rust::String();
+  } catch (const std::exception &e) {
+    return rust::String(e.what());
+  }
+}
+
 AddFileToStoreResult add_file_to_store(Store *store, rust::Str name,
                                        rust::Str path) {
   AddFileToStoreResult out{rust::String(), rust::String(), rust::String()};
modifiedcrates/nix-eval/src/lib.hhdiffbeforeafterboth
--- a/crates/nix-eval/src/lib.hh
+++ b/crates/nix-eval/src/lib.hh
@@ -30,3 +30,11 @@
 CxxBuildResult substitute_paths(Store *store, rust::Str paths_joined);
 
 bool is_valid_path(Store *store, rust::Str path);
+
+struct CxxPathInfo;
+CxxPathInfo query_path_info(Store *store, rust::Str path);
+
+CxxBuildResult compute_closure(Store *store, rust::Str path);
+
+rust::String nar_from_path(Store *store, rust::Str path, size_t sink_data,
+                           rust::Fn<bool(size_t, rust::Slice<const uint8_t>)> sink);
modifiedcrates/nix-eval/src/lib.rsdiffbeforeafterboth
before · crates/nix-eval/src/lib.rs
1use std::borrow::Cow;2use std::cell::RefCell;3use std::collections::HashMap;4use std::ffi::{CStr, CString, c_char, c_int, c_uint, c_void};5use std::ptr::{null, null_mut};6use std::sync::{Arc, LazyLock, OnceLock};7use std::{array, fmt, slice};89use anyhow::{Context, anyhow, bail};10use camino::{Utf8Path, Utf8PathBuf};11use itertools::Itertools;12use serde::Serialize;13use serde::de::DeserializeOwned;14use std::mem::transmute;1516pub use anyhow::Result;17use tracing::{Span, instrument, warn};1819use self::logging::{ErrorInfoBuilder, nix_logging_cxx};20use self::nix_cxx::set_fetcher_setting;21use self::nix_raw::{22	BindingsBuilder as c_bindings_builder, EvalState as c_eval_state, GC_SUCCESS,23	GC_allow_register_threads, GC_get_stack_base, GC_register_my_thread, GC_stack_base,24	GC_thread_is_registered, GC_unregister_my_thread, ListBuilder as c_list_builder, PrimOp,25	PrimOpFun, Store as c_store, StorePath as c_store_path, alloc_primop, alloc_value,26	bindings_builder_free, bindings_builder_insert, c_context, c_context_create, c_context_free,27	clear_err, copy_value, err_NIX_ERR_KEY, err_NIX_ERR_NIX_ERROR, err_NIX_ERR_OVERFLOW,28	err_NIX_ERR_UNKNOWN, err_NIX_OK, err_code, err_info_msg, err_msg, eval_state_build,29	eval_state_builder_load, eval_state_builder_new, eval_state_builder_set_eval_setting,30	expr_eval_from_string, fetchers_settings, fetchers_settings_free, fetchers_settings_new,31	flake_lock, flake_lock_flags, flake_lock_flags_free, flake_lock_flags_new, flake_reference,32	flake_reference_and_fragment_from_string, flake_reference_parse_flags,33	flake_reference_parse_flags_free, flake_reference_parse_flags_new,34	flake_reference_parse_flags_set_base_directory, flake_settings, flake_settings_free,35	flake_settings_new, gc_now as gc_now_raw, get_attr_byname, get_attr_name_byidx, get_attrs_size,36	get_list_byidx, get_list_size, get_string, get_type, has_attr_byname, init_bool, init_int,37	init_primop, init_string, libexpr_init, libstore_init, libutil_init, list_builder_free,38	list_builder_insert, locked_flake, locked_flake_free, locked_flake_get_output_attrs,39	make_attrs, make_bindings_builder, make_list, make_list_builder, realised_string,40	realised_string_free, realised_string_get_buffer_size, realised_string_get_buffer_start,41	realised_string_get_store_path, realised_string_get_store_path_count, register_primop,42	set_err_msg, setting_get, setting_set, state_free, store_copy_closure, store_free, store_open,43	store_parse_path, store_path_free, store_path_name, string_realise, value, value_call,44	value_decref, value_force, value_incref,45};4647// Contains macros helpers48pub mod drv;49pub mod logging;50#[doc(hidden)]51pub mod macros;52pub mod scheduler;5354#[doc(hidden)]55pub mod __macro_support {56	pub use std::collections::hash_map::HashMap;5758	pub use anyhow::Context;59	pub use tokio::task::block_in_place;60}61pub mod util;6263#[allow(64	non_upper_case_globals,65	non_camel_case_types,66	non_snake_case,67	dead_code68)]69mod nix_raw {70	include!(concat!(env!("OUT_DIR"), "/bindings.rs"));71}72#[cxx::bridge]73pub mod nix_cxx {74	struct AddFileToStoreResult {75		error: String,76		store_path: String,77		hash: String,78	}79	struct CxxProfileGeneration {80		id: u64,81		store_path: String,82		creation_time_unix: i64,83		current: bool,84	}85	struct CxxListGenerationsResult {86		error: String,87		generations: Vec<CxxProfileGeneration>,88	}89	struct CxxBuildResult {90		error: String,91		outputs: Vec<String>,92	}93	unsafe extern "C++" {94		type nix_fetchers_settings;95		type Store;96		include!("nix-eval/src/lib.hh");9798		#[allow(clippy::missing_safety_doc)]99		unsafe fn set_fetcher_setting(100			settings: *mut nix_fetchers_settings,101			setting: *const c_char,102			value: *const c_char,103		);104105		#[allow(clippy::missing_safety_doc)]106		unsafe fn switch_profile(store: *mut Store, profile: &str, store_path: &str) -> String;107108		#[allow(clippy::missing_safety_doc)]109		unsafe fn sign_closure(store: *mut Store, store_path: &str, key_file: &str) -> String;110111		#[allow(clippy::missing_safety_doc)]112		unsafe fn add_file_to_store(113			store: *mut Store,114			name: &str,115			path: &str,116		) -> AddFileToStoreResult;117118		fn list_generations(profile_path: &str) -> CxxListGenerationsResult;119120		#[allow(clippy::missing_safety_doc)]121		unsafe fn build_drv_outputs(122			store: *mut Store,123			drv_path: &str,124			output_names_joined: &str,125		) -> CxxBuildResult;126127		#[allow(clippy::missing_safety_doc)]128		unsafe fn substitute_paths(store: *mut Store, paths_joined: &str) -> CxxBuildResult;129130		#[allow(clippy::missing_safety_doc)]131		unsafe fn is_valid_path(store: *mut Store, path: &str) -> bool;132	}133}134135#[derive(Debug, PartialEq, Eq)]136pub enum NixType {137	Thunk,138	Int,139	Float,140	Bool,141	String,142	Path,143	Null,144	Attrs,145	List,146	Function,147	External,148}149impl NixType {150	fn from_int(c: c_uint) -> Self {151		match c {152			0 => Self::Thunk,153			1 => Self::Int,154			2 => Self::Float,155			3 => Self::Bool,156			4 => Self::String,157			5 => Self::Path,158			6 => Self::Null,159			7 => Self::Attrs,160			8 => Self::List,161			9 => Self::Function,162			10 => Self::External,163			_ => unreachable!("unknown nix type: {c}"),164		}165	}166}167168enum FunctorKind {169	Function,170	Functor,171}172173#[derive(Debug)]174#[repr(i32)]175pub enum NixErrorKind {176	Unknown = err_NIX_ERR_UNKNOWN,177	Overflow = err_NIX_ERR_OVERFLOW,178	Key = err_NIX_ERR_KEY,179	Generic = err_NIX_ERR_NIX_ERROR,180}181impl NixErrorKind {182	fn from_int(v: c_int) -> Option<Self> {183		Some(match v {184			0 => return None,185			nix_raw::err_NIX_ERR_UNKNOWN => Self::Unknown,186			nix_raw::err_NIX_ERR_OVERFLOW => Self::Overflow,187			nix_raw::err_NIX_ERR_KEY => Self::Key,188			nix_raw::err_NIX_ERR_NIX_ERROR => Self::Generic,189			_ => {190				debug_assert!(false, "unexpected nix error kind: {v}");191				Self::Unknown192			}193		})194	}195}196197pub fn gc_now() {198	unsafe { gc_now_raw() };199}200201pub fn gc_register_my_thread() {202	assert_eq!(unsafe { GC_thread_is_registered() }, 0);203204	let mut sb = GC_stack_base {205		mem_base: null_mut(),206	};207	let r = unsafe { GC_get_stack_base(&mut sb) };208	if r as u32 != GC_SUCCESS {209		panic!("failed to get thread stack base");210	}211	unsafe { GC_register_my_thread(&sb) };212}213pub fn gc_unregister_my_thread() {214	assert_eq!(unsafe { GC_thread_is_registered() }, 1);215216	unsafe { GC_unregister_my_thread() };217}218219pub struct ThreadRegisterGuard {}220impl ThreadRegisterGuard {221	#[allow(clippy::new_without_default)]222	pub fn new() -> Self {223		gc_register_my_thread();224		Self {}225	}226}227impl Drop for ThreadRegisterGuard {228	fn drop(&mut self) {229		gc_unregister_my_thread();230	}231}232233#[repr(transparent)]234pub struct NixContext(*mut c_context);235impl NixContext {236	pub fn set_err_raw(&mut self, err: NixErrorKind, msg: &CStr) {237		unsafe { set_err_msg(self.0, err as c_int, msg.as_ptr()) };238	}239	pub fn set_err(&mut self, err: anyhow::Error) {240		let fmt = format!("{err:?}").replace("\0", "\\0");241		self.set_err_raw(242			NixErrorKind::Generic,243			&CString::new(fmt).expect("NUL bytes were just replaced"),244		);245	}246	pub fn new() -> Self {247		let ctx = unsafe { c_context_create() };248		Self(ctx)249	}250	fn error_kind(&self) -> Option<NixErrorKind> {251		let code = unsafe { err_code(self.0) };252		NixErrorKind::from_int(code)253	}254	fn error<'t>(&self) -> Option<(Cow<'t, str>, Option<Box<ErrorInfoBuilder>>)> {255		if let NixErrorKind::Generic = self.error_kind()? {256			let ei = unsafe { logging::nix_logging_cxx::extract_error_info(self.0) };257			let mut err_out = String::new();258			unsafe {259				err_info_msg(260					null_mut(),261					self.0,262					Some(copy_nix_str),263					(&raw mut err_out).cast(),264				)265			};266			return Some((Cow::Owned(err_out), Some(ei)));267		};268269		// TODO: Can throw error (resulting in panic) if unable to retrieve error. Should be able to resolve by passing context as a first argument,270		// but it looks ugly271		let str = unsafe { err_msg(null_mut(), self.0, null_mut()) };272		Some((unsafe { CStr::from_ptr(str) }.to_string_lossy(), None))273	}274	fn clean_err(&mut self) {275		unsafe {276			clear_err(self.0);277		}278	}279280	fn bail_if_error(&self) -> Result<()> {281		if let Some((err, stack)) = self.error() {282			let mut e = Err(anyhow!("{err}"));283			if let Some(stack) = stack {284				for ele in stack.stack_frames {285					e = e.with_context(|| {286						if ele.pos.is_empty() {287							ele.msg288						} else {289							format!("{} at {}", ele.msg, ele.pos)290						}291					})292				}293			}294			return e.context("<nix frames>");295		};296		Ok(())297	}298299	fn run_in_context<T>(&mut self, f: impl FnOnce(*mut c_context) -> T) -> Result<T> {300		self.clean_err();301		let o = f(self.0);302		self.bail_if_error()?;303		self.clean_err();304		Ok(o)305	}306}307308impl Default for NixContext {309	fn default() -> Self {310		Self::new()311	}312}313impl Drop for NixContext {314	fn drop(&mut self) {315		unsafe {316			c_context_free(self.0);317		}318	}319}320struct GlobalState {321	// Store should be valid as long as EvalState is valid322	#[allow(dead_code)]323	store: Arc<Store>,324	state: EvalState,325}326impl GlobalState {327	fn new() -> Result<Self> {328		let mut ctx = NixContext::new();329		let store = Arc::new(330			ctx.run_in_context(|c| unsafe { store_open(c, c"auto".as_ptr(), null_mut()) })331				.map(Store)?,332		);333334		let builder = ctx.run_in_context(|c| unsafe { eval_state_builder_new(c, store.0) })?;335		ctx.run_in_context(|c| unsafe { eval_state_builder_load(c, builder) })?;336		ctx.run_in_context(|c| unsafe {337			eval_state_builder_set_eval_setting(338				c,339				builder,340				c"lazy-trees".as_ptr(),341				c"true".as_ptr(),342			)343		})?;344		ctx.run_in_context(|c| unsafe {345			eval_state_builder_set_eval_setting(346				c,347				builder,348				c"lazy-locks".as_ptr(),349				c"true".as_ptr(),350			)351		})?;352		let state = ctx353			.run_in_context(|c| unsafe { eval_state_build(c, builder) })354			.map(EvalState)?;355356		Ok(Self { store, state })357	}358}359360struct ThreadState {361	ctx: NixContext,362}363impl ThreadState {364	fn new() -> Result<Self> {365		let ctx = NixContext::new();366367		Ok(Self { ctx })368	}369}370371static GLOBAL_STATE: LazyLock<GlobalState> =372	LazyLock::new(|| GlobalState::new().expect("global state init shouldn't fail"));373374thread_local! {375	static THREAD_STATE: RefCell<ThreadState> = RefCell::new(ThreadState::new().expect("thread state init shouldn't fail"));376}377pub(crate) fn with_default_context<T>(378	f: impl FnOnce(*mut c_context, *mut c_eval_state) -> T,379) -> Result<T> {380	let global = &GLOBAL_STATE.state;381	let (ctx, state) = THREAD_STATE.with_borrow_mut(|w| (w.ctx.0, global.0));382	let mut ctx = NixContext(ctx);383	let v = ctx.run_in_context(|c| f(c, state));384	// It is reused for thread385	std::mem::forget(ctx);386	v387}388389pub fn set_setting(s: &CStr, v: &CStr) -> Result<()> {390	with_default_context(|c, _| unsafe { setting_set(c, s.as_ptr(), v.as_ptr()) }).map(|_| ())391}392393pub fn get_setting(s: &CStr) -> Result<String> {394	let mut out = String::new();395	with_default_context(|c, _| unsafe {396		setting_get(c, s.as_ptr(), Some(copy_nix_str), (&raw mut out).cast())397	})?;398	Ok(out)399}400401#[derive(Debug)]402pub struct AddedFile {403	pub store_path: Utf8PathBuf,404	pub hash: String,405}406407#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]408pub struct ProfileGeneration {409	pub id: u64,410	pub store_path: Utf8PathBuf,411	pub creation_time_unix: i64,412	pub current: bool,413}414415#[instrument]416pub fn list_generations(profile_path: &str) -> Result<Vec<ProfileGeneration>> {417	let res = nix_cxx::list_generations(profile_path);418	if !res.error.is_empty() {419		bail!(420			"failed to list generations at {profile_path}: {}",421			res.error422		);423	}424	Ok(res425		.generations426		.into_iter()427		.map(|g| ProfileGeneration {428			id: g.id,429			store_path: Utf8PathBuf::from(g.store_path),430			creation_time_unix: g.creation_time_unix,431			current: g.current,432		})433		.collect())434}435436pub struct FetchSettings(*mut fetchers_settings);437impl FetchSettings {438	pub fn new() -> Self {439		Self::try_new().expect("allocation should not fail")440	}441	fn try_new() -> Result<Self> {442		with_default_context(|c, _| unsafe { fetchers_settings_new(c) }).map(Self)443	}444	pub fn set(&mut self, setting: &CStr, value: &CStr) {445		unsafe {446			set_fetcher_setting(self.0.cast(), setting.as_ptr(), value.as_ptr());447		};448	}449}450unsafe impl Send for FetchSettings {}451unsafe impl Sync for FetchSettings {}452453impl Default for FetchSettings {454	fn default() -> Self {455		Self::new()456	}457}458459impl Drop for FetchSettings {460	fn drop(&mut self) {461		unsafe { fetchers_settings_free(self.0) };462	}463}464pub struct FlakeSettings(*mut flake_settings);465impl FlakeSettings {466	pub fn new() -> Result<Self> {467		with_default_context(|c, _| unsafe { flake_settings_new(c) }).map(Self)468	}469}470unsafe impl Send for FlakeSettings {}471unsafe impl Sync for FlakeSettings {}472impl Drop for FlakeSettings {473	fn drop(&mut self) {474		unsafe {475			flake_settings_free(self.0);476		}477	}478}479480pub struct FlakeReferenceParseFlags(*mut flake_reference_parse_flags);481impl FlakeReferenceParseFlags {482	pub fn new(settings: &FlakeSettings) -> Result<Self> {483		with_default_context(|c, _| unsafe { flake_reference_parse_flags_new(c, settings.0) })484			.map(Self)485	}486	pub fn set_base_dir(&mut self, dir: &str) -> Result<()> {487		with_default_context(|c, _| {488			unsafe {489				flake_reference_parse_flags_set_base_directory(490					c,491					self.0,492					dir.as_ptr().cast(),493					dir.len(),494				)495			};496		})497	}498}499impl Drop for FlakeReferenceParseFlags {500	fn drop(&mut self) {501		unsafe {502			flake_reference_parse_flags_free(self.0);503		}504	}505}506pub struct FlakeLockFlags(*mut flake_lock_flags);507impl FlakeLockFlags {508	pub fn new(settings: &FlakeSettings) -> Result<Self> {509		let o = with_default_context(|c, _| unsafe { flake_lock_flags_new(c, settings.0) })510			.map(Self)?;511		// with_default_context(|c, _| unsafe { flake_lock_flags_set_mode_virtual(c, o.0) })?;512513		Ok(o)514	}515}516impl Drop for FlakeLockFlags {517	fn drop(&mut self) {518		unsafe {519			flake_lock_flags_free(self.0);520		}521	}522}523524pub(crate) unsafe extern "C" fn copy_nix_str(525	start: *const c_char,526	n: c_uint,527	user_data: *mut c_void,528) {529	let s = unsafe { slice::from_raw_parts(start.cast::<u8>(), n as usize) };530	let s = std::str::from_utf8(s).expect("c string has invalid utf-8");531	unsafe { *user_data.cast::<String>() = s.to_owned() };532}533534pub struct Store(*mut c_store);535unsafe impl Send for Store {}536unsafe impl Sync for Store {}537538pub fn eval_store() -> Arc<Store> {539	GLOBAL_STATE.store.clone()540}541542impl Store {543	pub fn open(uri: &str) -> Result<Self> {544		let uri = CString::new(uri)?;545		let ptr = with_default_context(|c, _| unsafe { store_open(c, uri.as_ptr(), null_mut()) })?;546		if ptr.is_null() {547			bail!("failed to open store");548		}549		Ok(Store(ptr))550	}551552	pub fn parse_path(&self, path: &Utf8Path) -> Result<StorePath> {553		let path = CString::new(path.as_str()).expect("valid cstr");554		with_default_context(|c, _| {555			StorePath(unsafe { store_parse_path(c, self.0, path.as_ptr()) })556		})557	}558559	#[instrument(skip(self))]560	pub fn sign_closure(&self, path: &Utf8Path, key_file: &Utf8Path) -> Result<()> {561		let err = with_default_context(|_, _| unsafe {562			nix_cxx::sign_closure(self.as_ptr().cast(), path.as_str(), key_file.as_str())563		})?564		.to_string();565566		if err.is_empty() {567			Ok(())568		} else {569			bail!("failed to sign {path}: {err}");570		}571	}572573	#[instrument(skip(self, dst))]574	pub fn copy_to(&self, dst: &Store, path: &Utf8Path) -> Result<()> {575		let sp = self576			.parse_path(path)577			.context("failed to parse store path")?;578		let rc = with_default_context(|c, _| unsafe {579			store_copy_closure(c, self.as_ptr(), dst.0, sp.as_ptr())580		})?;581		if rc != err_NIX_OK {582			bail!("store_copy_closure failed (code {rc})");583		}584		Ok(())585	}586587	/// Would only work with local store.588	#[instrument(skip(self))]589	pub fn switch_profile(&self, profile: &str, path: &Utf8Path) -> Result<()> {590		let msg = unsafe { nix_cxx::switch_profile(self.as_ptr().cast(), profile, path.as_str()) };591		if msg.is_empty() {592			Ok(())593		} else {594			bail!("failed to switch profile {profile}: {msg}");595		}596	}597598	#[instrument(skip(self))]599	pub fn add_file(&self, name: &str, path: &Utf8Path) -> Result<AddedFile> {600		let msg = unsafe { nix_cxx::add_file_to_store(self.as_ptr().cast(), name, path.as_str()) };601		if !msg.error.is_empty() {602			bail!("failed to add {path} to store: {}", msg.error)603		}604		Ok(AddedFile {605			store_path: Utf8PathBuf::from(msg.store_path),606			hash: msg.hash,607		})608	}609610	#[instrument(skip(self, paths))]611	pub fn substitute_paths(&self, paths: &[Utf8PathBuf]) -> Result<Vec<Utf8PathBuf>> {612		let joined = paths.into_iter().join("\n");613		let res = unsafe { nix_cxx::substitute_paths(self.as_ptr().cast(), &joined) };614		if !res.error.is_empty() {615			warn!("substitute_paths reported: {}", res.error);616		}617		Ok(res.outputs.into_iter().map(Utf8PathBuf::from).collect())618	}619620	#[instrument(skip(self))]621	pub fn is_valid_path(&self, path: &Utf8Path) -> bool {622		unsafe { nix_cxx::is_valid_path(self.as_ptr().cast(), path.as_str()) }623	}624625	#[instrument(skip(self))]626	pub fn build_drv_outputs(627		&self,628		drv_path: &Utf8Path,629		output_names: &[String],630	) -> Result<Vec<String>> {631		let joined = output_names.join("\n");632		let res =633			unsafe { nix_cxx::build_drv_outputs(self.as_ptr().cast(), drv_path.as_str(), &joined) };634		if !res.error.is_empty() {635			bail!("build of {drv_path} failed: {}", res.error);636		}637		Ok(res.outputs)638	}639640	#[instrument(skip(self))]641	pub fn store_dir(&self) -> Result<Utf8PathBuf> {642		let mut out = String::new();643		with_default_context(|c, es| unsafe {644			nix_raw::store_get_storedir(c, self.as_ptr(), Some(copy_nix_str), (&raw mut out).cast())645		})?;646		let p = Utf8PathBuf::from(out);647		assert!(p.is_absolute());648		Ok(p)649	}650651	fn as_ptr(&self) -> *mut c_store {652		self.0653	}654}655impl Drop for Store {656	fn drop(&mut self) {657		unsafe { store_free(self.0) }658	}659}660661#[repr(transparent)]662pub struct EvalState(*mut c_eval_state);663unsafe impl Send for EvalState {}664unsafe impl Sync for EvalState {}665666impl Drop for EvalState {667	fn drop(&mut self) {668		unsafe {669			state_free(self.0);670		}671	}672}673674pub struct FlakeReference(*mut flake_reference);675impl FlakeReference {676	#[instrument(name = "new-flake-reference", skip(flake, parse, fetch))]677	pub fn new(678		s: &str,679		flake: &FlakeSettings,680		parse: &FlakeReferenceParseFlags,681		fetch: &FetchSettings,682	) -> Result<(Self, String)> {683		let mut out = null_mut();684		let mut fragment = String::new();685		// let fetch_settings = fetcher_settings;686		with_default_context(|c, _| unsafe {687			flake_reference_and_fragment_from_string(688				c,689				fetch.0,690				flake.0,691				parse.0,692				s.as_ptr().cast(),693				s.len(),694				&mut out,695				Some(copy_nix_str),696				(&raw mut fragment).cast(),697			)698		})?;699		assert!(!out.is_null());700701		Ok((Self(out), fragment))702	}703	#[instrument(name = "lock-flake", skip(self, fetch, flake, lock))]704	pub fn lock(705		&mut self,706		fetch: &FetchSettings,707		flake: &FlakeSettings,708		lock: &FlakeLockFlags,709	) -> Result<LockedFlake> {710		with_default_context(|c, es| unsafe { flake_lock(c, fetch.0, flake.0, es, lock.0, self.0) })711			.map(LockedFlake)712	}713}714unsafe impl Send for FlakeReference {}715unsafe impl Sync for FlakeReference {}716717pub struct LockedFlake(*mut locked_flake);718impl LockedFlake {719	pub fn get_attrs(&self, settings: &mut FlakeSettings) -> Result<Value> {720		with_default_context(|c, es| unsafe {721			locked_flake_get_output_attrs(c, settings.0, es, self.0)722		})723		.map(Value)724	}725}726unsafe impl Send for LockedFlake {}727unsafe impl Sync for LockedFlake {}728impl Drop for LockedFlake {729	fn drop(&mut self) {730		unsafe {731			locked_flake_free(self.0);732		};733	}734}735736type FieldName = [u8; 64];737fn init_field_name(v: &str) -> FieldName {738	let mut f = [0; 64];739	assert!(v.len() < 64, "max field name is 63 chars");740	assert!(741		v.bytes().all(|v| v != 0),742		"nul bytes are unsupported in field name"743	);744	f[0..v.len()].copy_from_slice(v.as_bytes());745	f746}747748pub struct RealisedString(*mut realised_string);749impl fmt::Debug for RealisedString {750	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {751		self.as_str().fmt(f)752	}753}754755impl RealisedString {756	pub fn as_str(&self) -> &str {757		let len = unsafe { realised_string_get_buffer_size(self.0) };758		let data: *const u8 = unsafe { realised_string_get_buffer_start(self.0) }.cast();759		let data = unsafe { slice::from_raw_parts(data, len) };760		std::str::from_utf8(data).expect("non-utf8 strings not supported")761	}762	pub fn path_count(&self) -> usize {763		unsafe { realised_string_get_store_path_count(self.0) }764	}765	pub fn path(&self, i: usize) -> String {766		assert!(i < self.path_count());767		let path = unsafe { realised_string_get_store_path(self.0, i) };768		let mut err_out = String::new();769		unsafe { store_path_name(path, Some(copy_nix_str), (&raw mut err_out).cast()) };770		err_out771	}772}773774unsafe impl Send for RealisedString {}775impl Drop for RealisedString {776	fn drop(&mut self) {777		unsafe { realised_string_free(self.0) }778	}779}780781#[repr(transparent)]782pub struct Value(*mut value);783784unsafe impl Send for Value {}785unsafe impl Sync for Value {}786787pub trait AsFieldName {788	fn as_field_name<T>(&self, v: impl FnOnce(FieldName) -> Result<T>) -> Result<T>;789	fn to_field_name(&self) -> Result<String>;790}791impl AsFieldName for Value {792	fn as_field_name<T>(&self, v: impl FnOnce(FieldName) -> Result<T>) -> Result<T> {793		let f = self.to_string()?;794		v(init_field_name(&f))795	}796	fn to_field_name(&self) -> Result<String> {797		self.to_string()798	}799}800impl<E> AsFieldName for E801where802	E: AsRef<str>,803{804	fn as_field_name<T>(&self, v: impl FnOnce(FieldName) -> Result<T>) -> Result<T> {805		let f = self.as_ref();806		v(init_field_name(f))807	}808	fn to_field_name(&self) -> Result<String> {809		Ok(self.as_ref().to_owned())810	}811}812813struct AttrsBuilder(*mut c_bindings_builder);814impl AttrsBuilder {815	fn new(capacity: usize) -> Self {816		with_default_context(|c, es| unsafe { make_bindings_builder(c, es, capacity) })817			.map(Self)818			.expect("alloc should not fail")819	}820	fn insert(&mut self, k: &impl AsFieldName, v: Value) {821		k.as_field_name(|name| {822			with_default_context(|c, _| unsafe {823				bindings_builder_insert(c, self.0, name.as_ptr().cast(), v.0);824				// bindings_builder_insert doesn't do incref825			})826		})827		.expect("builder insert shouldn't fail");828	}829}830impl Drop for AttrsBuilder {831	fn drop(&mut self) {832		unsafe { bindings_builder_free(self.0) };833	}834}835836struct ListBuilder(*mut c_list_builder, c_uint);837impl ListBuilder {838	fn new(capacity: usize) -> Self {839		with_default_context(|c, es| unsafe { make_list_builder(c, es, capacity) })840			.map(|l| Self(l, 0))841			.expect("alloc should not fail")842	}843}844impl ListBuilder {845	fn push(&mut self, v: Value) {846		with_default_context(|c, _| unsafe {847			list_builder_insert(848				c,849				self.0,850				{851					let v = self.1;852					self.1 += 1;853					v854				},855				v.0,856			)857		})858		.expect("list insert shouldn't fail");859	}860}861impl Drop for ListBuilder {862	fn drop(&mut self) {863		unsafe { list_builder_free(self.0) };864	}865}866867impl Value {868	pub fn new_primop(v: NativeFn) -> Self {869		let out = Self::new_uninit();870		with_default_context(|c, _| unsafe { init_primop(c, out.0, v.0) })871			.expect("primop initialization should not fail");872		out873	}874	pub fn new_attrs(v: HashMap<&str, Value>) -> Self {875		let out = Self::new_uninit();876		let mut b = AttrsBuilder::new(v.len());877		for (k, v) in v {878			b.insert(&k, v);879		}880		with_default_context(|c, _| unsafe { make_attrs(c, out.0, b.0) })881			.expect("attrs initialization should not fail");882883		out884	}885	fn new_list<T: Into<Self>>(v: Vec<T>) -> Self {886		let out = Self::new_uninit();887		let mut b = ListBuilder::new(v.len());888		for v in v {889			b.push(v.into());890		}891		with_default_context(|c, _| unsafe { make_list(c, b.0, out.0) })892			.expect("list initialization should not fail");893894		out895	}896	fn new_uninit() -> Self {897		let out = with_default_context(|c, es| unsafe { alloc_value(c, es) })898			.expect("value allocation should not fail");899		Self(out)900	}901	pub fn new_str(v: &str) -> Self {902		let s = CString::new(v).expect("string should not contain NULs");903		let out = Self::new_uninit();904		// String is copied, `s` is free to be dropped905		with_default_context(|c, _| unsafe { init_string(c, out.0, s.as_ptr()) })906			.expect("string initialization should not fail");907		out908	}909	pub fn new_int(i: i64) -> Self {910		let out = Self::new_uninit();911		with_default_context(|c, _| unsafe { init_int(c, out.0, i) })912			.expect("int initialization should not fail");913		out914	}915	pub fn new_bool(v: bool) -> Self {916		let out = Self::new_uninit();917		with_default_context(|c, _| unsafe { init_bool(c, out.0, v) })918			.expect("bool initialization should not fail");919		out920	}921	// TODO: As far as I can see, there is no way to get Thunks from nix public C api, so this function is useless922	// fn force(&mut self, st: &mut EvalState) -> Result<()> {923	// 	with_default_context(|c, _| unsafe { value_force(c, st.0, self.0) })?;924	// 	Ok(())925	// }926	pub fn type_of(&self) -> NixType {927		let ty = with_default_context(|c, _| unsafe { get_type(c, self.0) })928			.expect("get_type should not fail");929		NixType::from_int(ty)930	}931	fn builtin_to_string(&self) -> Result<Self> {932		let builtin = Self::eval("builtins.toString")?;933		builtin.call(self.clone())934	}935	fn force(&mut self, s: *mut nix_raw::EvalState) -> Result<()> {936		with_default_context(|c, _| unsafe { value_force(c, s, self.0) })?;937		Ok(())938	}939	pub fn to_string(&self) -> Result<String> {940		let ty = self.type_of();941		if !matches!(ty, NixType::String) {942			bail!("unexpected type: {ty:?}, expected string");943		}944		let mut str_out = String::new();945		with_default_context(|c, _| unsafe {946			get_string(c, self.0, Some(copy_nix_str), (&raw mut str_out).cast())947		})?;948949		Ok(str_out)950	}951	pub fn to_realised_string(&self) -> Result<RealisedString> {952		with_default_context(|c, es| unsafe { string_realise(c, es, self.0, false) })953			.map(RealisedString)954955		// let store_paths = unsafe { nix_raw::realised_string_get_store_path_count(str) };956		// for i in 0..store_paths {957		// 	let store_path = unsafe { nix_raw::realised_string_get_store_path(str, i) };958		// 	nix_raw::store_path_name(store_path, callback, user_data);959		// }960		// dbg!(store_paths);961		// todo!();962	}963964	pub fn has_field(&self, field: &str) -> Result<bool> {965		if !matches!(self.type_of(), NixType::Attrs) {966			bail!("invalid type: expected attrs");967		}968969		let f = init_field_name(field);970		with_default_context(|c, es| unsafe { has_attr_byname(c, self.0, es, f.as_ptr().cast()) })971	}972	// pub fn derivation_path(&self) {973	// 	nix_raw::real974	// }975	pub fn list_fields(&self) -> Result<Vec<String>> {976		if !matches!(self.type_of(), NixType::Attrs) {977			bail!("invalid type: expected attrs");978		}979980		let len = with_default_context(|c, _| unsafe { get_attrs_size(c, self.0) })?;981		let mut out = Vec::with_capacity(len as usize);982983		for i in 0..len {984			let name =985				with_default_context(|c, es| unsafe { get_attr_name_byidx(c, self.0, es, i) })?;986			let c = unsafe { CStr::from_ptr(name) };987			out.push(c.to_str().expect("nix field names are utf-8").to_owned());988		}989		Ok(out)990	}991	pub fn get_elem(&self, v: usize) -> Result<Self> {992		if !matches!(self.type_of(), NixType::List) {993			bail!("invalid type: expected list");994		}995		let len = with_default_context(|c, _| unsafe { get_list_size(c, self.0) })? as usize;996		if v >= len {997			bail!("oob list get: {v} >= {len}");998		}9991000		with_default_context(|c, es| unsafe { get_list_byidx(c, self.0, es, v as u32) }).map(Self)1001	}1002	pub fn attrs_update(self, other: Value /*, ignore_errors: bool*/) -> Result<Self> {1003		let attrs_update_fn = Self::eval("a: b: a // b")?;10041005		attrs_update_fn1006			.call(self)?1007			.call(other)1008			.context("attrs update")1009	}1010	pub fn get_field(&self, name: impl AsFieldName) -> Result<Self> {1011		if !matches!(self.type_of(), NixType::Attrs) {1012			bail!("invalid type: expected attrs");1013		}10141015		name.as_field_name(|name| {1016			with_default_context(|c, es| unsafe {1017				get_attr_byname(c, self.0, es, name.as_ptr().cast())1018			})1019			.map(Self)1020		})1021		.with_context(|| format!("getting field {:?}", name.to_field_name()))1022	}1023	pub fn call(&self, v: Value) -> Result<Self> {1024		let kind = self1025			.functor_kind()1026			.ok_or_else(|| anyhow!("can only call function or functor"))?;10271028		let function = match kind {1029			FunctorKind::Function => self.clone(),1030			FunctorKind::Functor => {1031				let f = self1032					.get_field("__functor")1033					.context("getting functor value")?;1034				assert_eq!(1035					f.type_of(),1036					NixType::Function,1037					"invalid functor encountered"1038				);1039				f1040			}1041		};10421043		let out = Value::new_uninit();1044		with_default_context(|c, es| unsafe { value_call(c, es, function.0, v.0, out.0) })?;10451046		Ok(out)1047	}1048	pub fn eval(v: &str) -> Result<Self> {1049		let s = CString::new(v).expect("expression shouldn't have internal NULs");1050		let out = Self::new_uninit();1051		with_default_context(|c, es| unsafe {1052			expr_eval_from_string(c, es, s.as_ptr(), c"/root".as_ptr(), out.0)1053		})?;1054		Ok(out)1055	}1056	#[instrument(name = "build", skip(self), fields(output))]1057	pub fn build(&self, output: &str) -> Result<Utf8PathBuf> {1058		if !self.is_derivation() {1059			bail!("expected derivation to build")1060		}1061		let output_name = self1062			.get_field("outputName")1063			.context("getting output name field")?1064			.to_string()?;1065		let v = if output_name != output {1066			let out = self.get_field(output).context("getting target output")?;1067			if !out.is_derivation() {1068				bail!("unknown output: {output}");1069			}1070			out1071		} else {1072			self.clone()1073		};10741075		let drv_path = Utf8PathBuf::from(1076			v.get_field("drvPath")1077				.context("getting drvPath")?1078				.to_string()?,1079		);1080		let graph = Arc::new(drv::DrvGraph::resolve(&eval_store(), &drv_path)?);1081		let _guard = logging::register_build_graph(&Span::current(), &graph);10821083		scheduler::build_graph_sync(graph.clone(), vec![output.to_owned()])?;10841085		let s = v.builtin_to_string()?;1086		let rs = s.to_realised_string()?;1087		let out_path = rs.as_str().to_owned();1088		Ok(Utf8PathBuf::from(out_path))1089	}1090	pub fn as_json<T: DeserializeOwned>(&self) -> Result<T> {1091		let to_json = Self::eval("builtins.toJSON")?;1092		let s = to_json.call(self.clone())?.to_string()?;1093		Ok(serde_json::from_str(&s)?)1094	}1095	pub fn serialized<T: Serialize>(v: &T) -> Result<Self> {1096		Self::eval(&nixlike::serialize(v)?)1097	}10981099	// Convert to string/evaluate derivations/etc1100	// fn to_string_weak(&self) -> Result<String> {1101	// 	// TODO: For now, it works exactly like to_string, see the comment for fn force()1102	// 	self.to_string()1103	// }11041105	fn is_derivation(&self) -> bool {1106		if !matches!(self.type_of(), NixType::Attrs) {1107			return false;1108		}1109		let Some(ty) = self.get_field("type").ok() else {1110			return false;1111		};1112		matches!(ty.to_string().as_deref(), Ok("derivation"))1113	}1114	fn functor_kind(&self) -> Option<FunctorKind> {1115		match self.type_of() {1116			NixType::Attrs => self1117				.has_field("__functor")1118				.expect("has_field shouldn't fail for attrs")1119				.then_some(FunctorKind::Functor),1120			NixType::Function => Some(FunctorKind::Function),1121			_ => None,1122		}1123	}1124	pub fn is_function(&self) -> bool {1125		self.functor_kind().is_some()1126	}1127	pub fn is_null(&self) -> bool {1128		matches!(self.type_of(), NixType::Null)1129	}1130	pub fn is_string(&self) -> bool {1131		matches!(self.type_of(), NixType::String)1132	}1133	pub fn is_attrs(&self) -> bool {1134		matches!(self.type_of(), NixType::Attrs)1135	}1136}11371138impl From<String> for Value {1139	fn from(value: String) -> Self {1140		Value::new_str(&value)1141	}1142}1143impl From<bool> for Value {1144	fn from(value: bool) -> Self {1145		Value::new_bool(value)1146	}1147}1148impl From<&str> for Value {1149	fn from(value: &str) -> Self {1150		Value::new_str(value)1151	}1152}1153impl<T> From<Vec<T>> for Value1154where1155	T: Into<Value>,1156{1157	fn from(value: Vec<T>) -> Self {1158		Value::new_list(value)1159	}1160}11611162impl Clone for Value {1163	fn clone(&self) -> Self {1164		with_default_context(|c, _| unsafe { value_incref(c, self.0) })1165			.expect("value incref should not fail");1166		Self(self.0)1167	}1168}1169impl Drop for Value {1170	fn drop(&mut self) {1171		with_default_context(|c, _| unsafe { value_decref(c, self.0) })1172			.expect("value drop should not fail");1173	}1174}11751176static TOKIO_FOR_NIX: OnceLock<Arc<tokio::runtime::Runtime>> = OnceLock::new();11771178pub fn init_libraries() {1179	unsafe { GC_allow_register_threads() };11801181	let mut ctx = NixContext::new();1182	ctx.run_in_context(|c| unsafe { libutil_init(c) })1183		.expect("util init should not fail");1184	ctx.run_in_context(|c| unsafe { libstore_init(c) })1185		.expect("store init should not fail");1186	ctx.run_in_context(|c| unsafe { libexpr_init(c) })1187		.expect("expr init should not fail");11881189	nix_logging_cxx::apply_tracing_logger();1190}11911192pub fn init_tokio_for_nix(tokio: Arc<tokio::runtime::Runtime>) {1193	TOKIO_FOR_NIX1194		.set(tokio)1195		.expect("tokio for nix should only be initialized once");1196}11971198pub fn await_in_nix<F: Send + 'static>(f: impl Future<Output = F> + Send + 'static) -> F {1199	// It should be possible to do Handle::current(), but some of the planned features don't work well with that1200	let runtime = TOKIO_FOR_NIX1201		.get()1202		.expect("init_tokio_for_nix was not called");1203	std::thread::spawn(move || runtime.block_on(f))1204		.join()1205		.expect("await_in_nix inner thread panicked")1206}12071208unsafe extern "C" fn nix_primop_closure_adapter<const N: usize>(1209	user_data: *mut c_void,1210	mut context: *mut c_context,1211	state: *mut nix_raw::EvalState,1212	args: *mut *mut value,1213	ret: *mut value,1214) {1215	let user_closure: &UserClosure<N> = unsafe { &*user_data.cast_const().cast() };1216	let args: [&Value; N] = array::from_fn(|i| {1217		let v: &mut Value = unsafe { &mut *args.add(i).cast() };1218		v as &Value1219	});1220	let ctx: &mut NixContext = unsafe { transmute(&mut context) };12211222	let state: &EvalState = unsafe { std::mem::transmute(&state) };12231224	match user_closure(state, args) {1225		Ok(v) => {1226			unsafe { copy_value(context, ret, v.0) };1227		}1228		Err(e) => {1229			ctx.set_err(e);1230		}1231	}1232}12331234type UserClosure<const N: usize> = Box<dyn Fn(&EvalState, [&Value; N]) -> Result<Value>>;12351236pub struct NativeFn(*mut PrimOp);1237impl NativeFn {1238	pub fn new<const N: usize>(1239		name: &'static CStr,1240		doc: &'static CStr,1241		args: [&'static CStr; N],1242		f: impl Fn(&EvalState, [&Value; N]) -> Result<Value> + 'static,1243	) -> Self {1244		// Double-boxing to make it thin pointer, as vtable gets outside of first Box1245		let closure: Box<UserClosure<N>> = Box::new(Box::new(f));1246		let f: PrimOpFun = Some(nix_primop_closure_adapter::<N>);1247		let mut args = args.into_iter().map(|v| v.as_ptr()).collect_vec();1248		args.push(null());1249		let args = args.as_mut_ptr();1250		let primop = unsafe {1251			alloc_primop(1252				null_mut(),1253				f,1254				N as i32,1255				name.as_ptr(),1256				args,1257				doc.as_ptr(),1258				Box::into_raw(closure).cast(),1259			)1260		};12611262		assert!(!primop.is_null(), "primop allocation should not fail");12631264		Self(primop)1265	}1266	pub fn register(self) {1267		unsafe { register_primop(null_mut(), self.0) };1268	}1269}12701271pub struct StorePath(*mut c_store_path);1272impl StorePath {1273	fn as_ptr(&self) -> *mut c_store_path {1274		self.01275	}1276}12771278impl Drop for StorePath {1279	fn drop(&mut self) {1280		unsafe { store_path_free(self.0) }1281	}1282}12831284#[test_log::test]1285fn test_native() -> Result<()> {1286	init_libraries();1287	NativeFn::new(1288		c"__uppercaseSuffix2",1289		c"make string uppercase and add suffix",1290		[c"str", c"suffix"],1291		|_, [str, suffix]: [&Value; 2]| {1292			let str = str.to_string()?;1293			let suffix = suffix.to_string()?;1294			Ok(Value::new_str(&format!("{}{suffix}", str.to_uppercase())))1295		},1296	)1297	.register();12981299	let mut fetch_settings = FetchSettings::new();1300	fetch_settings.set(c"warn-dirty", c"false");13011302	let manifest = format!("git+file://{}/../../", env!("CARGO_MANIFEST_DIR"));1303	let flake = FlakeSettings::new()?;1304	let parse = FlakeReferenceParseFlags::new(&flake)?;1305	let (mut r, _) = FlakeReference::new(&manifest, &flake, &parse, &fetch_settings)?;1306	let lock = FlakeLockFlags::new(&flake)?;1307	let locked = r.lock(&fetch_settings, &flake, &lock)?;1308	let attrs = locked.get_attrs(&mut FlakeSettings::new()?)?;13091310	let builtins = Value::eval("builtins")?;1311	assert_eq!(builtins.type_of(), NixType::Attrs);13121313	assert_eq!(attrs.type_of(), NixType::Attrs);1314	let test_data = nix_go!(attrs.testData);13151316	let test_string: String = nix_go_json!(test_data.testString);1317	assert_eq!(test_string, "hello");13181319	let s = nix_go!(attrs.packages["x86_64-linux"].fleet.drvPath);1320	let s = CString::new(s.to_string()?).expect("path str is cstring");13211322	let uppercase_suffix = Value::new_primop(NativeFn::new(1323		c"uppercase_suffix",1324		c"make string uppercase and add suffix",1325		[c"str", c"suffix"],1326		|es, [str, suffix]: [&Value; 2]| {1327			let str = str.to_string()?;1328			let suffix = suffix.to_string()?;1329			Ok(Value::new_str(&format!("{}{suffix}", str.to_uppercase())))1330		},1331	));13321333	let test_result: String = nix_go_json!(test_data.testPrimop(uppercase_suffix));1334	assert_eq!(test_result, "PREFIX_BODY_SUFFIX");1335	let test_result: String = nix_go_json!(builtins.uppercaseSuffix2("test")("suffix"));1336	assert_eq!(test_result, "TESTsuffix");13371338	let drv_path =1339		nix_go!(attrs.packages["x86_64-linux"]["fleet-install-secrets"].drvPath).to_string()?;1340	let graph = drv::DrvGraph::resolve(&drv_path)?;1341	eprintln!(1342		"fleet-install-secrets dependency graph: {} nodes",1343		graph.nodes.len()1344	);1345	for (path, node) in &graph.nodes {1346		if !node.input_drvs.is_empty() {1347			eprintln!("  {} ({} deps)", node.name, node.input_drvs.len());1348		}1349	}13501351	Ok(())1352}13531354// pub struct GcAlloc;1355// unsafe impl GlobalAlloc for GcAlloc {1356// 	unsafe fn alloc(&self, l: Layout) -> *mut u8 {1357// 		let ptr = unsafe { GC_malloc(l.size()) };1358// 		ptr.cast()1359// 	}1360// 	unsafe fn dealloc(&self, ptr: *mut u8, _: Layout) {1361// 		// unsafe { GC_free(ptr.cast()) };1362// 	}1363//1364// 	unsafe fn realloc(&self, ptr: *mut u8, _: Layout, new_size: usize) -> *mut u8 {1365// 		let ptr = unsafe { GC_realloc(ptr.cast(), new_size) };1366// 		ptr.cast()1367// 	}1368// }1369//1370// #[global_allocator]1371// static GC: GcAlloc = GcAlloc;
after · crates/nix-eval/src/lib.rs
1use std::borrow::Cow;2use std::cell::RefCell;3use std::collections::HashMap;4use std::ffi::{CStr, CString, c_char, c_int, c_uint, c_void};5use std::ptr::{null, null_mut};6use std::sync::{Arc, LazyLock, OnceLock};7use std::{array, fmt, slice};89use anyhow::{Context, anyhow, bail};10use camino::{Utf8Path, Utf8PathBuf};11use itertools::Itertools;12use serde::Serialize;13use serde::de::DeserializeOwned;14use std::mem::transmute;1516pub use anyhow::Result;17use tracing::{Span, instrument, warn};1819use self::logging::{ErrorInfoBuilder, nix_logging_cxx};20use self::nix_cxx::set_fetcher_setting;21use self::nix_raw::{22	BindingsBuilder as c_bindings_builder, EvalState as c_eval_state, GC_SUCCESS,23	GC_allow_register_threads, GC_get_stack_base, GC_register_my_thread, GC_stack_base,24	GC_thread_is_registered, GC_unregister_my_thread, ListBuilder as c_list_builder, PrimOp,25	PrimOpFun, Store as c_store, StorePath as c_store_path, alloc_primop, alloc_value,26	bindings_builder_free, bindings_builder_insert, c_context, c_context_create, c_context_free,27	clear_err, copy_value, err_NIX_ERR_KEY, err_NIX_ERR_NIX_ERROR, err_NIX_ERR_OVERFLOW,28	err_NIX_ERR_UNKNOWN, err_NIX_OK, err_code, err_info_msg, err_msg, eval_state_build,29	eval_state_builder_load, eval_state_builder_new, eval_state_builder_set_eval_setting,30	expr_eval_from_string, fetchers_settings, fetchers_settings_free, fetchers_settings_new,31	flake_lock, flake_lock_flags, flake_lock_flags_free, flake_lock_flags_new, flake_reference,32	flake_reference_and_fragment_from_string, flake_reference_parse_flags,33	flake_reference_parse_flags_free, flake_reference_parse_flags_new,34	flake_reference_parse_flags_set_base_directory, flake_settings, flake_settings_free,35	flake_settings_new, gc_now as gc_now_raw, get_attr_byname, get_attr_name_byidx, get_attrs_size,36	get_list_byidx, get_list_size, get_string, get_type, has_attr_byname, init_bool, init_int,37	init_primop, init_string, libexpr_init, libstore_init, libutil_init, list_builder_free,38	list_builder_insert, locked_flake, locked_flake_free, locked_flake_get_output_attrs,39	make_attrs, make_bindings_builder, make_list, make_list_builder, realised_string,40	realised_string_free, realised_string_get_buffer_size, realised_string_get_buffer_start,41	realised_string_get_store_path, realised_string_get_store_path_count, register_primop,42	set_err_msg, setting_get, setting_set, state_free, store_copy_closure, store_free, store_open,43	store_parse_path, store_path_free, store_path_name, string_realise, value, value_call,44	value_decref, value_force, value_incref,45};4647// Contains macros helpers48pub mod drv;49pub mod logging;50#[doc(hidden)]51pub mod macros;52pub mod scheduler;5354#[doc(hidden)]55pub mod __macro_support {56	pub use std::collections::hash_map::HashMap;5758	pub use anyhow::Context;59	pub use tokio::task::block_in_place;60}61pub mod util;6263#[allow(64	non_upper_case_globals,65	non_camel_case_types,66	non_snake_case,67	dead_code68)]69mod nix_raw {70	include!(concat!(env!("OUT_DIR"), "/bindings.rs"));71}72#[cxx::bridge]73pub mod nix_cxx {74	struct AddFileToStoreResult {75		error: String,76		store_path: String,77		hash: String,78	}79	struct CxxProfileGeneration {80		id: u64,81		store_path: String,82		creation_time_unix: i64,83		current: bool,84	}85	struct CxxListGenerationsResult {86		error: String,87		generations: Vec<CxxProfileGeneration>,88	}89	struct CxxBuildResult {90		error: String,91		outputs: Vec<String>,92	}93	struct CxxPathInfo {94		error: String,95		nar_hash: String,96		nar_size: u64,97		references: Vec<String>,98		sigs: Vec<String>,99	}100	unsafe extern "C++" {101		type nix_fetchers_settings;102		type Store;103		include!("nix-eval/src/lib.hh");104105		#[allow(clippy::missing_safety_doc)]106		unsafe fn set_fetcher_setting(107			settings: *mut nix_fetchers_settings,108			setting: *const c_char,109			value: *const c_char,110		);111112		#[allow(clippy::missing_safety_doc)]113		unsafe fn switch_profile(store: *mut Store, profile: &str, store_path: &str) -> String;114115		#[allow(clippy::missing_safety_doc)]116		unsafe fn sign_closure(store: *mut Store, store_path: &str, key_file: &str) -> String;117118		#[allow(clippy::missing_safety_doc)]119		unsafe fn add_file_to_store(120			store: *mut Store,121			name: &str,122			path: &str,123		) -> AddFileToStoreResult;124125		fn list_generations(profile_path: &str) -> CxxListGenerationsResult;126127		#[allow(clippy::missing_safety_doc)]128		unsafe fn build_drv_outputs(129			store: *mut Store,130			drv_path: &str,131			output_names_joined: &str,132		) -> CxxBuildResult;133134		#[allow(clippy::missing_safety_doc)]135		unsafe fn substitute_paths(store: *mut Store, paths_joined: &str) -> CxxBuildResult;136137		#[allow(clippy::missing_safety_doc)]138		unsafe fn is_valid_path(store: *mut Store, path: &str) -> bool;139140		#[allow(clippy::missing_safety_doc)]141		unsafe fn query_path_info(store: *mut Store, path: &str) -> CxxPathInfo;142143		#[allow(clippy::missing_safety_doc)]144		unsafe fn compute_closure(store: *mut Store, path: &str) -> CxxBuildResult;145146		#[allow(clippy::missing_safety_doc)]147		unsafe fn nar_from_path(148			store: *mut Store,149			path: &str,150			sink_data: usize,151			sink: fn(usize, &[u8]) -> bool,152		) -> String;153	}154}155156#[derive(Debug, PartialEq, Eq)]157pub enum NixType {158	Thunk,159	Int,160	Float,161	Bool,162	String,163	Path,164	Null,165	Attrs,166	List,167	Function,168	External,169}170impl NixType {171	fn from_int(c: c_uint) -> Self {172		match c {173			0 => Self::Thunk,174			1 => Self::Int,175			2 => Self::Float,176			3 => Self::Bool,177			4 => Self::String,178			5 => Self::Path,179			6 => Self::Null,180			7 => Self::Attrs,181			8 => Self::List,182			9 => Self::Function,183			10 => Self::External,184			_ => unreachable!("unknown nix type: {c}"),185		}186	}187}188189enum FunctorKind {190	Function,191	Functor,192}193194#[derive(Debug)]195#[repr(i32)]196pub enum NixErrorKind {197	Unknown = err_NIX_ERR_UNKNOWN,198	Overflow = err_NIX_ERR_OVERFLOW,199	Key = err_NIX_ERR_KEY,200	Generic = err_NIX_ERR_NIX_ERROR,201}202impl NixErrorKind {203	fn from_int(v: c_int) -> Option<Self> {204		Some(match v {205			0 => return None,206			nix_raw::err_NIX_ERR_UNKNOWN => Self::Unknown,207			nix_raw::err_NIX_ERR_OVERFLOW => Self::Overflow,208			nix_raw::err_NIX_ERR_KEY => Self::Key,209			nix_raw::err_NIX_ERR_NIX_ERROR => Self::Generic,210			_ => {211				debug_assert!(false, "unexpected nix error kind: {v}");212				Self::Unknown213			}214		})215	}216}217218pub fn gc_now() {219	unsafe { gc_now_raw() };220}221222pub fn gc_register_my_thread() {223	assert_eq!(unsafe { GC_thread_is_registered() }, 0);224225	let mut sb = GC_stack_base {226		mem_base: null_mut(),227	};228	let r = unsafe { GC_get_stack_base(&mut sb) };229	if r as u32 != GC_SUCCESS {230		panic!("failed to get thread stack base");231	}232	unsafe { GC_register_my_thread(&sb) };233}234pub fn gc_unregister_my_thread() {235	assert_eq!(unsafe { GC_thread_is_registered() }, 1);236237	unsafe { GC_unregister_my_thread() };238}239240pub struct ThreadRegisterGuard {}241impl ThreadRegisterGuard {242	#[allow(clippy::new_without_default)]243	pub fn new() -> Self {244		gc_register_my_thread();245		Self {}246	}247}248impl Drop for ThreadRegisterGuard {249	fn drop(&mut self) {250		gc_unregister_my_thread();251	}252}253254#[repr(transparent)]255pub struct NixContext(*mut c_context);256impl NixContext {257	pub fn set_err_raw(&mut self, err: NixErrorKind, msg: &CStr) {258		unsafe { set_err_msg(self.0, err as c_int, msg.as_ptr()) };259	}260	pub fn set_err(&mut self, err: anyhow::Error) {261		let fmt = format!("{err:?}").replace("\0", "\\0");262		self.set_err_raw(263			NixErrorKind::Generic,264			&CString::new(fmt).expect("NUL bytes were just replaced"),265		);266	}267	pub fn new() -> Self {268		let ctx = unsafe { c_context_create() };269		Self(ctx)270	}271	fn error_kind(&self) -> Option<NixErrorKind> {272		let code = unsafe { err_code(self.0) };273		NixErrorKind::from_int(code)274	}275	fn error<'t>(&self) -> Option<(Cow<'t, str>, Option<Box<ErrorInfoBuilder>>)> {276		if let NixErrorKind::Generic = self.error_kind()? {277			let ei = unsafe { logging::nix_logging_cxx::extract_error_info(self.0) };278			let mut err_out = String::new();279			unsafe {280				err_info_msg(281					null_mut(),282					self.0,283					Some(copy_nix_str),284					(&raw mut err_out).cast(),285				)286			};287			return Some((Cow::Owned(err_out), Some(ei)));288		};289290		// TODO: Can throw error (resulting in panic) if unable to retrieve error. Should be able to resolve by passing context as a first argument,291		// but it looks ugly292		let str = unsafe { err_msg(null_mut(), self.0, null_mut()) };293		Some((unsafe { CStr::from_ptr(str) }.to_string_lossy(), None))294	}295	fn clean_err(&mut self) {296		unsafe {297			clear_err(self.0);298		}299	}300301	fn bail_if_error(&self) -> Result<()> {302		if let Some((err, stack)) = self.error() {303			let mut e = Err(anyhow!("{err}"));304			if let Some(stack) = stack {305				for ele in stack.stack_frames {306					e = e.with_context(|| {307						if ele.pos.is_empty() {308							ele.msg309						} else {310							format!("{} at {}", ele.msg, ele.pos)311						}312					})313				}314			}315			return e.context("<nix frames>");316		};317		Ok(())318	}319320	fn run_in_context<T>(&mut self, f: impl FnOnce(*mut c_context) -> T) -> Result<T> {321		self.clean_err();322		let o = f(self.0);323		self.bail_if_error()?;324		self.clean_err();325		Ok(o)326	}327}328329impl Default for NixContext {330	fn default() -> Self {331		Self::new()332	}333}334impl Drop for NixContext {335	fn drop(&mut self) {336		unsafe {337			c_context_free(self.0);338		}339	}340}341struct GlobalState {342	// Store should be valid as long as EvalState is valid343	#[allow(dead_code)]344	store: Arc<Store>,345	state: EvalState,346}347impl GlobalState {348	fn new() -> Result<Self> {349		let mut ctx = NixContext::new();350		let store = Arc::new(351			ctx.run_in_context(|c| unsafe { store_open(c, c"auto".as_ptr(), null_mut()) })352				.map(Store)?,353		);354355		let builder = ctx.run_in_context(|c| unsafe { eval_state_builder_new(c, store.0) })?;356		ctx.run_in_context(|c| unsafe { eval_state_builder_load(c, builder) })?;357		ctx.run_in_context(|c| unsafe {358			eval_state_builder_set_eval_setting(359				c,360				builder,361				c"lazy-trees".as_ptr(),362				c"true".as_ptr(),363			)364		})?;365		ctx.run_in_context(|c| unsafe {366			eval_state_builder_set_eval_setting(367				c,368				builder,369				c"lazy-locks".as_ptr(),370				c"true".as_ptr(),371			)372		})?;373		let state = ctx374			.run_in_context(|c| unsafe { eval_state_build(c, builder) })375			.map(EvalState)?;376377		Ok(Self { store, state })378	}379}380381struct ThreadState {382	ctx: NixContext,383}384impl ThreadState {385	fn new() -> Result<Self> {386		let ctx = NixContext::new();387388		Ok(Self { ctx })389	}390}391392static GLOBAL_STATE: LazyLock<GlobalState> =393	LazyLock::new(|| GlobalState::new().expect("global state init shouldn't fail"));394395thread_local! {396	static THREAD_STATE: RefCell<ThreadState> = RefCell::new(ThreadState::new().expect("thread state init shouldn't fail"));397}398pub(crate) fn with_default_context<T>(399	f: impl FnOnce(*mut c_context, *mut c_eval_state) -> T,400) -> Result<T> {401	let global = &GLOBAL_STATE.state;402	let (ctx, state) = THREAD_STATE.with_borrow_mut(|w| (w.ctx.0, global.0));403	let mut ctx = NixContext(ctx);404	let v = ctx.run_in_context(|c| f(c, state));405	// It is reused for thread406	std::mem::forget(ctx);407	v408}409410pub fn set_setting(s: &CStr, v: &CStr) -> Result<()> {411	with_default_context(|c, _| unsafe { setting_set(c, s.as_ptr(), v.as_ptr()) }).map(|_| ())412}413414pub fn get_setting(s: &CStr) -> Result<String> {415	let mut out = String::new();416	with_default_context(|c, _| unsafe {417		setting_get(c, s.as_ptr(), Some(copy_nix_str), (&raw mut out).cast())418	})?;419	Ok(out)420}421422#[derive(Debug)]423pub struct AddedFile {424	pub store_path: Utf8PathBuf,425	pub hash: String,426}427428#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]429pub struct ProfileGeneration {430	pub id: u64,431	pub store_path: Utf8PathBuf,432	pub creation_time_unix: i64,433	pub current: bool,434}435436#[instrument]437pub fn list_generations(profile_path: &str) -> Result<Vec<ProfileGeneration>> {438	let res = nix_cxx::list_generations(profile_path);439	if !res.error.is_empty() {440		bail!(441			"failed to list generations at {profile_path}: {}",442			res.error443		);444	}445	Ok(res446		.generations447		.into_iter()448		.map(|g| ProfileGeneration {449			id: g.id,450			store_path: Utf8PathBuf::from(g.store_path),451			creation_time_unix: g.creation_time_unix,452			current: g.current,453		})454		.collect())455}456457pub struct FetchSettings(*mut fetchers_settings);458impl FetchSettings {459	pub fn new() -> Self {460		Self::try_new().expect("allocation should not fail")461	}462	fn try_new() -> Result<Self> {463		with_default_context(|c, _| unsafe { fetchers_settings_new(c) }).map(Self)464	}465	pub fn set(&mut self, setting: &CStr, value: &CStr) {466		unsafe {467			set_fetcher_setting(self.0.cast(), setting.as_ptr(), value.as_ptr());468		};469	}470}471unsafe impl Send for FetchSettings {}472unsafe impl Sync for FetchSettings {}473474impl Default for FetchSettings {475	fn default() -> Self {476		Self::new()477	}478}479480impl Drop for FetchSettings {481	fn drop(&mut self) {482		unsafe { fetchers_settings_free(self.0) };483	}484}485pub struct FlakeSettings(*mut flake_settings);486impl FlakeSettings {487	pub fn new() -> Result<Self> {488		with_default_context(|c, _| unsafe { flake_settings_new(c) }).map(Self)489	}490}491unsafe impl Send for FlakeSettings {}492unsafe impl Sync for FlakeSettings {}493impl Drop for FlakeSettings {494	fn drop(&mut self) {495		unsafe {496			flake_settings_free(self.0);497		}498	}499}500501pub struct FlakeReferenceParseFlags(*mut flake_reference_parse_flags);502impl FlakeReferenceParseFlags {503	pub fn new(settings: &FlakeSettings) -> Result<Self> {504		with_default_context(|c, _| unsafe { flake_reference_parse_flags_new(c, settings.0) })505			.map(Self)506	}507	pub fn set_base_dir(&mut self, dir: &str) -> Result<()> {508		with_default_context(|c, _| {509			unsafe {510				flake_reference_parse_flags_set_base_directory(511					c,512					self.0,513					dir.as_ptr().cast(),514					dir.len(),515				)516			};517		})518	}519}520impl Drop for FlakeReferenceParseFlags {521	fn drop(&mut self) {522		unsafe {523			flake_reference_parse_flags_free(self.0);524		}525	}526}527pub struct FlakeLockFlags(*mut flake_lock_flags);528impl FlakeLockFlags {529	pub fn new(settings: &FlakeSettings) -> Result<Self> {530		let o = with_default_context(|c, _| unsafe { flake_lock_flags_new(c, settings.0) })531			.map(Self)?;532		// with_default_context(|c, _| unsafe { flake_lock_flags_set_mode_virtual(c, o.0) })?;533534		Ok(o)535	}536}537impl Drop for FlakeLockFlags {538	fn drop(&mut self) {539		unsafe {540			flake_lock_flags_free(self.0);541		}542	}543}544545pub(crate) unsafe extern "C" fn copy_nix_str(546	start: *const c_char,547	n: c_uint,548	user_data: *mut c_void,549) {550	let s = unsafe { slice::from_raw_parts(start.cast::<u8>(), n as usize) };551	let s = std::str::from_utf8(s).expect("c string has invalid utf-8");552	unsafe { *user_data.cast::<String>() = s.to_owned() };553}554555pub struct Store(*mut c_store);556unsafe impl Send for Store {}557unsafe impl Sync for Store {}558559#[derive(Debug, Clone)]560pub struct PathInfo {561	pub nar_hash: String,562	pub nar_size: u64,563	pub references: Vec<Utf8PathBuf>,564	pub sigs: Vec<String>,565}566567pub fn eval_store() -> Arc<Store> {568	GLOBAL_STATE.store.clone()569}570571impl Store {572	pub fn open(uri: &str) -> Result<Self> {573		let uri = CString::new(uri)?;574		let ptr = with_default_context(|c, _| unsafe { store_open(c, uri.as_ptr(), null_mut()) })?;575		if ptr.is_null() {576			bail!("failed to open store");577		}578		Ok(Store(ptr))579	}580581	pub fn parse_path(&self, path: &Utf8Path) -> Result<StorePath> {582		let path = CString::new(path.as_str()).expect("valid cstr");583		with_default_context(|c, _| {584			StorePath(unsafe { store_parse_path(c, self.0, path.as_ptr()) })585		})586	}587588	#[instrument(skip(self))]589	pub fn sign_closure(&self, path: &Utf8Path, key_file: &Utf8Path) -> Result<()> {590		let err = with_default_context(|_, _| unsafe {591			nix_cxx::sign_closure(self.as_ptr().cast(), path.as_str(), key_file.as_str())592		})?593		.to_string();594595		if err.is_empty() {596			Ok(())597		} else {598			bail!("failed to sign {path}: {err}");599		}600	}601602	#[instrument(skip(self, dst))]603	pub fn copy_to(&self, dst: &Store, path: &Utf8Path) -> Result<()> {604		let sp = self605			.parse_path(path)606			.context("failed to parse store path")?;607		let rc = with_default_context(|c, _| unsafe {608			store_copy_closure(c, self.as_ptr(), dst.0, sp.as_ptr())609		})?;610		if rc != err_NIX_OK {611			bail!("store_copy_closure failed (code {rc})");612		}613		Ok(())614	}615616	/// Would only work with local store.617	#[instrument(skip(self))]618	pub fn switch_profile(&self, profile: &str, path: &Utf8Path) -> Result<()> {619		let msg = unsafe { nix_cxx::switch_profile(self.as_ptr().cast(), profile, path.as_str()) };620		if msg.is_empty() {621			Ok(())622		} else {623			bail!("failed to switch profile {profile}: {msg}");624		}625	}626627	#[instrument(skip(self))]628	pub fn add_file(&self, name: &str, path: &Utf8Path) -> Result<AddedFile> {629		let msg = unsafe { nix_cxx::add_file_to_store(self.as_ptr().cast(), name, path.as_str()) };630		if !msg.error.is_empty() {631			bail!("failed to add {path} to store: {}", msg.error)632		}633		Ok(AddedFile {634			store_path: Utf8PathBuf::from(msg.store_path),635			hash: msg.hash,636		})637	}638639	#[instrument(skip(self, paths))]640	pub fn substitute_paths(&self, paths: &[Utf8PathBuf]) -> Result<Vec<Utf8PathBuf>> {641		let joined = paths.into_iter().join("\n");642		let res = unsafe { nix_cxx::substitute_paths(self.as_ptr().cast(), &joined) };643		if !res.error.is_empty() {644			warn!("substitute_paths reported: {}", res.error);645		}646		Ok(res.outputs.into_iter().map(Utf8PathBuf::from).collect())647	}648649	#[instrument(skip(self))]650	pub fn is_valid_path(&self, path: &Utf8Path) -> bool {651		unsafe { nix_cxx::is_valid_path(self.as_ptr().cast(), path.as_str()) }652	}653654	#[instrument(skip(self))]655	pub fn query_path_info(&self, path: &Utf8Path) -> Result<PathInfo> {656		let res = unsafe { nix_cxx::query_path_info(self.as_ptr().cast(), path.as_str()) };657		if !res.error.is_empty() {658			bail!("failed to query path info for {path}: {}", res.error);659		}660		Ok(PathInfo {661			nar_hash: res.nar_hash,662			nar_size: res.nar_size,663			references: res.references.into_iter().map(Utf8PathBuf::from).collect(),664			sigs: res.sigs,665		})666	}667668	#[instrument(skip(self))]669	pub fn compute_closure(&self, path: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {670		let res = unsafe { nix_cxx::compute_closure(self.as_ptr().cast(), path.as_str()) };671		if !res.error.is_empty() {672			bail!("failed to compute closure of {path}: {}", res.error);673		}674		Ok(res.outputs.into_iter().map(Utf8PathBuf::from).collect())675	}676677	#[instrument(skip(self, out))]678	pub fn nar_from_path(&self, path: &Utf8Path, out: &mut dyn std::io::Write) -> Result<()> {679		struct SinkState<'a> {680			out: &'a mut dyn std::io::Write,681			error: Option<std::io::Error>,682		}683		fn sink(state: usize, data: &[u8]) -> bool {684			let state = unsafe { &mut *(state as *mut SinkState) };685			match state.out.write_all(data) {686				Ok(()) => true,687				Err(e) => {688					state.error = Some(e);689					false690				}691			}692		}693		let mut state = SinkState { out, error: None };694		let msg = unsafe {695			nix_cxx::nar_from_path(696				self.as_ptr().cast(),697				path.as_str(),698				(&raw mut state) as usize,699				sink,700			)701		};702		if let Some(e) = state.error {703			return Err(anyhow!(e).context(format!("nar sink failed for {path}")));704		}705		if !msg.is_empty() {706			bail!("failed to dump nar of {path}: {msg}");707		}708		Ok(())709	}710711	#[instrument(skip(self))]712	pub fn build_drv_outputs(713		&self,714		drv_path: &Utf8Path,715		output_names: &[String],716	) -> Result<Vec<String>> {717		let joined = output_names.join("\n");718		let res =719			unsafe { nix_cxx::build_drv_outputs(self.as_ptr().cast(), drv_path.as_str(), &joined) };720		if !res.error.is_empty() {721			bail!("build of {drv_path} failed: {}", res.error);722		}723		Ok(res.outputs)724	}725726	#[instrument(skip(self))]727	pub fn store_dir(&self) -> Result<Utf8PathBuf> {728		let mut out = String::new();729		with_default_context(|c, es| unsafe {730			nix_raw::store_get_storedir(c, self.as_ptr(), Some(copy_nix_str), (&raw mut out).cast())731		})?;732		let p = Utf8PathBuf::from(out);733		assert!(p.is_absolute());734		Ok(p)735	}736737	fn as_ptr(&self) -> *mut c_store {738		self.0739	}740}741impl Drop for Store {742	fn drop(&mut self) {743		unsafe { store_free(self.0) }744	}745}746747#[repr(transparent)]748pub struct EvalState(*mut c_eval_state);749unsafe impl Send for EvalState {}750unsafe impl Sync for EvalState {}751752impl Drop for EvalState {753	fn drop(&mut self) {754		unsafe {755			state_free(self.0);756		}757	}758}759760pub struct FlakeReference(*mut flake_reference);761impl FlakeReference {762	#[instrument(name = "new-flake-reference", skip(flake, parse, fetch))]763	pub fn new(764		s: &str,765		flake: &FlakeSettings,766		parse: &FlakeReferenceParseFlags,767		fetch: &FetchSettings,768	) -> Result<(Self, String)> {769		let mut out = null_mut();770		let mut fragment = String::new();771		// let fetch_settings = fetcher_settings;772		with_default_context(|c, _| unsafe {773			flake_reference_and_fragment_from_string(774				c,775				fetch.0,776				flake.0,777				parse.0,778				s.as_ptr().cast(),779				s.len(),780				&mut out,781				Some(copy_nix_str),782				(&raw mut fragment).cast(),783			)784		})?;785		assert!(!out.is_null());786787		Ok((Self(out), fragment))788	}789	#[instrument(name = "lock-flake", skip(self, fetch, flake, lock))]790	pub fn lock(791		&mut self,792		fetch: &FetchSettings,793		flake: &FlakeSettings,794		lock: &FlakeLockFlags,795	) -> Result<LockedFlake> {796		with_default_context(|c, es| unsafe { flake_lock(c, fetch.0, flake.0, es, lock.0, self.0) })797			.map(LockedFlake)798	}799}800unsafe impl Send for FlakeReference {}801unsafe impl Sync for FlakeReference {}802803pub struct LockedFlake(*mut locked_flake);804impl LockedFlake {805	pub fn get_attrs(&self, settings: &mut FlakeSettings) -> Result<Value> {806		with_default_context(|c, es| unsafe {807			locked_flake_get_output_attrs(c, settings.0, es, self.0)808		})809		.map(Value)810	}811}812unsafe impl Send for LockedFlake {}813unsafe impl Sync for LockedFlake {}814impl Drop for LockedFlake {815	fn drop(&mut self) {816		unsafe {817			locked_flake_free(self.0);818		};819	}820}821822type FieldName = [u8; 64];823fn init_field_name(v: &str) -> FieldName {824	let mut f = [0; 64];825	assert!(v.len() < 64, "max field name is 63 chars");826	assert!(827		v.bytes().all(|v| v != 0),828		"nul bytes are unsupported in field name"829	);830	f[0..v.len()].copy_from_slice(v.as_bytes());831	f832}833834pub struct RealisedString(*mut realised_string);835impl fmt::Debug for RealisedString {836	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {837		self.as_str().fmt(f)838	}839}840841impl RealisedString {842	pub fn as_str(&self) -> &str {843		let len = unsafe { realised_string_get_buffer_size(self.0) };844		let data: *const u8 = unsafe { realised_string_get_buffer_start(self.0) }.cast();845		let data = unsafe { slice::from_raw_parts(data, len) };846		std::str::from_utf8(data).expect("non-utf8 strings not supported")847	}848	pub fn path_count(&self) -> usize {849		unsafe { realised_string_get_store_path_count(self.0) }850	}851	pub fn path(&self, i: usize) -> String {852		assert!(i < self.path_count());853		let path = unsafe { realised_string_get_store_path(self.0, i) };854		let mut err_out = String::new();855		unsafe { store_path_name(path, Some(copy_nix_str), (&raw mut err_out).cast()) };856		err_out857	}858}859860unsafe impl Send for RealisedString {}861impl Drop for RealisedString {862	fn drop(&mut self) {863		unsafe { realised_string_free(self.0) }864	}865}866867#[repr(transparent)]868pub struct Value(*mut value);869870unsafe impl Send for Value {}871unsafe impl Sync for Value {}872873pub trait AsFieldName {874	fn as_field_name<T>(&self, v: impl FnOnce(FieldName) -> Result<T>) -> Result<T>;875	fn to_field_name(&self) -> Result<String>;876}877impl AsFieldName for Value {878	fn as_field_name<T>(&self, v: impl FnOnce(FieldName) -> Result<T>) -> Result<T> {879		let f = self.to_string()?;880		v(init_field_name(&f))881	}882	fn to_field_name(&self) -> Result<String> {883		self.to_string()884	}885}886impl<E> AsFieldName for E887where888	E: AsRef<str>,889{890	fn as_field_name<T>(&self, v: impl FnOnce(FieldName) -> Result<T>) -> Result<T> {891		let f = self.as_ref();892		v(init_field_name(f))893	}894	fn to_field_name(&self) -> Result<String> {895		Ok(self.as_ref().to_owned())896	}897}898899struct AttrsBuilder(*mut c_bindings_builder);900impl AttrsBuilder {901	fn new(capacity: usize) -> Self {902		with_default_context(|c, es| unsafe { make_bindings_builder(c, es, capacity) })903			.map(Self)904			.expect("alloc should not fail")905	}906	fn insert(&mut self, k: &impl AsFieldName, v: Value) {907		k.as_field_name(|name| {908			with_default_context(|c, _| unsafe {909				bindings_builder_insert(c, self.0, name.as_ptr().cast(), v.0);910				// bindings_builder_insert doesn't do incref911			})912		})913		.expect("builder insert shouldn't fail");914	}915}916impl Drop for AttrsBuilder {917	fn drop(&mut self) {918		unsafe { bindings_builder_free(self.0) };919	}920}921922struct ListBuilder(*mut c_list_builder, c_uint);923impl ListBuilder {924	fn new(capacity: usize) -> Self {925		with_default_context(|c, es| unsafe { make_list_builder(c, es, capacity) })926			.map(|l| Self(l, 0))927			.expect("alloc should not fail")928	}929}930impl ListBuilder {931	fn push(&mut self, v: Value) {932		with_default_context(|c, _| unsafe {933			list_builder_insert(934				c,935				self.0,936				{937					let v = self.1;938					self.1 += 1;939					v940				},941				v.0,942			)943		})944		.expect("list insert shouldn't fail");945	}946}947impl Drop for ListBuilder {948	fn drop(&mut self) {949		unsafe { list_builder_free(self.0) };950	}951}952953impl Value {954	pub fn new_primop(v: NativeFn) -> Self {955		let out = Self::new_uninit();956		with_default_context(|c, _| unsafe { init_primop(c, out.0, v.0) })957			.expect("primop initialization should not fail");958		out959	}960	pub fn new_attrs(v: HashMap<&str, Value>) -> Self {961		let out = Self::new_uninit();962		let mut b = AttrsBuilder::new(v.len());963		for (k, v) in v {964			b.insert(&k, v);965		}966		with_default_context(|c, _| unsafe { make_attrs(c, out.0, b.0) })967			.expect("attrs initialization should not fail");968969		out970	}971	fn new_list<T: Into<Self>>(v: Vec<T>) -> Self {972		let out = Self::new_uninit();973		let mut b = ListBuilder::new(v.len());974		for v in v {975			b.push(v.into());976		}977		with_default_context(|c, _| unsafe { make_list(c, b.0, out.0) })978			.expect("list initialization should not fail");979980		out981	}982	fn new_uninit() -> Self {983		let out = with_default_context(|c, es| unsafe { alloc_value(c, es) })984			.expect("value allocation should not fail");985		Self(out)986	}987	pub fn new_str(v: &str) -> Self {988		let s = CString::new(v).expect("string should not contain NULs");989		let out = Self::new_uninit();990		// String is copied, `s` is free to be dropped991		with_default_context(|c, _| unsafe { init_string(c, out.0, s.as_ptr()) })992			.expect("string initialization should not fail");993		out994	}995	pub fn new_int(i: i64) -> Self {996		let out = Self::new_uninit();997		with_default_context(|c, _| unsafe { init_int(c, out.0, i) })998			.expect("int initialization should not fail");999		out1000	}1001	pub fn new_bool(v: bool) -> Self {1002		let out = Self::new_uninit();1003		with_default_context(|c, _| unsafe { init_bool(c, out.0, v) })1004			.expect("bool initialization should not fail");1005		out1006	}1007	// TODO: As far as I can see, there is no way to get Thunks from nix public C api, so this function is useless1008	// fn force(&mut self, st: &mut EvalState) -> Result<()> {1009	// 	with_default_context(|c, _| unsafe { value_force(c, st.0, self.0) })?;1010	// 	Ok(())1011	// }1012	pub fn type_of(&self) -> NixType {1013		let ty = with_default_context(|c, _| unsafe { get_type(c, self.0) })1014			.expect("get_type should not fail");1015		NixType::from_int(ty)1016	}1017	fn builtin_to_string(&self) -> Result<Self> {1018		let builtin = Self::eval("builtins.toString")?;1019		builtin.call(self.clone())1020	}1021	fn force(&mut self, s: *mut nix_raw::EvalState) -> Result<()> {1022		with_default_context(|c, _| unsafe { value_force(c, s, self.0) })?;1023		Ok(())1024	}1025	pub fn to_string(&self) -> Result<String> {1026		let ty = self.type_of();1027		if !matches!(ty, NixType::String) {1028			bail!("unexpected type: {ty:?}, expected string");1029		}1030		let mut str_out = String::new();1031		with_default_context(|c, _| unsafe {1032			get_string(c, self.0, Some(copy_nix_str), (&raw mut str_out).cast())1033		})?;10341035		Ok(str_out)1036	}1037	pub fn to_realised_string(&self) -> Result<RealisedString> {1038		with_default_context(|c, es| unsafe { string_realise(c, es, self.0, false) })1039			.map(RealisedString)10401041		// let store_paths = unsafe { nix_raw::realised_string_get_store_path_count(str) };1042		// for i in 0..store_paths {1043		// 	let store_path = unsafe { nix_raw::realised_string_get_store_path(str, i) };1044		// 	nix_raw::store_path_name(store_path, callback, user_data);1045		// }1046		// dbg!(store_paths);1047		// todo!();1048	}10491050	pub fn has_field(&self, field: &str) -> Result<bool> {1051		if !matches!(self.type_of(), NixType::Attrs) {1052			bail!("invalid type: expected attrs");1053		}10541055		let f = init_field_name(field);1056		with_default_context(|c, es| unsafe { has_attr_byname(c, self.0, es, f.as_ptr().cast()) })1057	}1058	// pub fn derivation_path(&self) {1059	// 	nix_raw::real1060	// }1061	pub fn list_fields(&self) -> Result<Vec<String>> {1062		if !matches!(self.type_of(), NixType::Attrs) {1063			bail!("invalid type: expected attrs");1064		}10651066		let len = with_default_context(|c, _| unsafe { get_attrs_size(c, self.0) })?;1067		let mut out = Vec::with_capacity(len as usize);10681069		for i in 0..len {1070			let name =1071				with_default_context(|c, es| unsafe { get_attr_name_byidx(c, self.0, es, i) })?;1072			let c = unsafe { CStr::from_ptr(name) };1073			out.push(c.to_str().expect("nix field names are utf-8").to_owned());1074		}1075		Ok(out)1076	}1077	pub fn get_elem(&self, v: usize) -> Result<Self> {1078		if !matches!(self.type_of(), NixType::List) {1079			bail!("invalid type: expected list");1080		}1081		let len = with_default_context(|c, _| unsafe { get_list_size(c, self.0) })? as usize;1082		if v >= len {1083			bail!("oob list get: {v} >= {len}");1084		}10851086		with_default_context(|c, es| unsafe { get_list_byidx(c, self.0, es, v as u32) }).map(Self)1087	}1088	pub fn attrs_update(self, other: Value /*, ignore_errors: bool*/) -> Result<Self> {1089		let attrs_update_fn = Self::eval("a: b: a // b")?;10901091		attrs_update_fn1092			.call(self)?1093			.call(other)1094			.context("attrs update")1095	}1096	pub fn get_field(&self, name: impl AsFieldName) -> Result<Self> {1097		if !matches!(self.type_of(), NixType::Attrs) {1098			bail!("invalid type: expected attrs");1099		}11001101		name.as_field_name(|name| {1102			with_default_context(|c, es| unsafe {1103				get_attr_byname(c, self.0, es, name.as_ptr().cast())1104			})1105			.map(Self)1106		})1107		.with_context(|| format!("getting field {:?}", name.to_field_name()))1108	}1109	pub fn call(&self, v: Value) -> Result<Self> {1110		let kind = self1111			.functor_kind()1112			.ok_or_else(|| anyhow!("can only call function or functor"))?;11131114		let function = match kind {1115			FunctorKind::Function => self.clone(),1116			FunctorKind::Functor => {1117				let f = self1118					.get_field("__functor")1119					.context("getting functor value")?;1120				assert_eq!(1121					f.type_of(),1122					NixType::Function,1123					"invalid functor encountered"1124				);1125				f1126			}1127		};11281129		let out = Value::new_uninit();1130		with_default_context(|c, es| unsafe { value_call(c, es, function.0, v.0, out.0) })?;11311132		Ok(out)1133	}1134	pub fn eval(v: &str) -> Result<Self> {1135		let s = CString::new(v).expect("expression shouldn't have internal NULs");1136		let out = Self::new_uninit();1137		with_default_context(|c, es| unsafe {1138			expr_eval_from_string(c, es, s.as_ptr(), c"/root".as_ptr(), out.0)1139		})?;1140		Ok(out)1141	}1142	#[instrument(name = "build", skip(self), fields(output))]1143	pub fn build(&self, output: &str) -> Result<Utf8PathBuf> {1144		if !self.is_derivation() {1145			bail!("expected derivation to build")1146		}1147		let output_name = self1148			.get_field("outputName")1149			.context("getting output name field")?1150			.to_string()?;1151		let v = if output_name != output {1152			let out = self.get_field(output).context("getting target output")?;1153			if !out.is_derivation() {1154				bail!("unknown output: {output}");1155			}1156			out1157		} else {1158			self.clone()1159		};11601161		let drv_path = Utf8PathBuf::from(1162			v.get_field("drvPath")1163				.context("getting drvPath")?1164				.to_string()?,1165		);1166		let graph = Arc::new(drv::DrvGraph::resolve(&eval_store(), &drv_path)?);1167		let _guard = logging::register_build_graph(&Span::current(), &graph);11681169		scheduler::build_graph_sync(graph.clone(), vec![output.to_owned()])?;11701171		let s = v.builtin_to_string()?;1172		let rs = s.to_realised_string()?;1173		let out_path = rs.as_str().to_owned();1174		Ok(Utf8PathBuf::from(out_path))1175	}1176	pub fn as_json<T: DeserializeOwned>(&self) -> Result<T> {1177		let to_json = Self::eval("builtins.toJSON")?;1178		let s = to_json.call(self.clone())?.to_string()?;1179		Ok(serde_json::from_str(&s)?)1180	}1181	pub fn serialized<T: Serialize>(v: &T) -> Result<Self> {1182		Self::eval(&nixlike::serialize(v)?)1183	}11841185	// Convert to string/evaluate derivations/etc1186	// fn to_string_weak(&self) -> Result<String> {1187	// 	// TODO: For now, it works exactly like to_string, see the comment for fn force()1188	// 	self.to_string()1189	// }11901191	fn is_derivation(&self) -> bool {1192		if !matches!(self.type_of(), NixType::Attrs) {1193			return false;1194		}1195		let Some(ty) = self.get_field("type").ok() else {1196			return false;1197		};1198		matches!(ty.to_string().as_deref(), Ok("derivation"))1199	}1200	fn functor_kind(&self) -> Option<FunctorKind> {1201		match self.type_of() {1202			NixType::Attrs => self1203				.has_field("__functor")1204				.expect("has_field shouldn't fail for attrs")1205				.then_some(FunctorKind::Functor),1206			NixType::Function => Some(FunctorKind::Function),1207			_ => None,1208		}1209	}1210	pub fn is_function(&self) -> bool {1211		self.functor_kind().is_some()1212	}1213	pub fn is_null(&self) -> bool {1214		matches!(self.type_of(), NixType::Null)1215	}1216	pub fn is_string(&self) -> bool {1217		matches!(self.type_of(), NixType::String)1218	}1219	pub fn is_attrs(&self) -> bool {1220		matches!(self.type_of(), NixType::Attrs)1221	}1222}12231224impl From<String> for Value {1225	fn from(value: String) -> Self {1226		Value::new_str(&value)1227	}1228}1229impl From<bool> for Value {1230	fn from(value: bool) -> Self {1231		Value::new_bool(value)1232	}1233}1234impl From<&str> for Value {1235	fn from(value: &str) -> Self {1236		Value::new_str(value)1237	}1238}1239impl<T> From<Vec<T>> for Value1240where1241	T: Into<Value>,1242{1243	fn from(value: Vec<T>) -> Self {1244		Value::new_list(value)1245	}1246}12471248impl Clone for Value {1249	fn clone(&self) -> Self {1250		with_default_context(|c, _| unsafe { value_incref(c, self.0) })1251			.expect("value incref should not fail");1252		Self(self.0)1253	}1254}1255impl Drop for Value {1256	fn drop(&mut self) {1257		with_default_context(|c, _| unsafe { value_decref(c, self.0) })1258			.expect("value drop should not fail");1259	}1260}12611262static TOKIO_FOR_NIX: OnceLock<Arc<tokio::runtime::Runtime>> = OnceLock::new();12631264pub fn init_libraries() {1265	unsafe { GC_allow_register_threads() };12661267	let mut ctx = NixContext::new();1268	ctx.run_in_context(|c| unsafe { libutil_init(c) })1269		.expect("util init should not fail");1270	ctx.run_in_context(|c| unsafe { libstore_init(c) })1271		.expect("store init should not fail");1272	ctx.run_in_context(|c| unsafe { libexpr_init(c) })1273		.expect("expr init should not fail");12741275	nix_logging_cxx::apply_tracing_logger();1276}12771278pub fn init_tokio_for_nix(tokio: Arc<tokio::runtime::Runtime>) {1279	TOKIO_FOR_NIX1280		.set(tokio)1281		.expect("tokio for nix should only be initialized once");1282}12831284pub fn await_in_nix<F: Send + 'static>(f: impl Future<Output = F> + Send + 'static) -> F {1285	// It should be possible to do Handle::current(), but some of the planned features don't work well with that1286	let runtime = TOKIO_FOR_NIX1287		.get()1288		.expect("init_tokio_for_nix was not called");1289	std::thread::spawn(move || runtime.block_on(f))1290		.join()1291		.expect("await_in_nix inner thread panicked")1292}12931294unsafe extern "C" fn nix_primop_closure_adapter<const N: usize>(1295	user_data: *mut c_void,1296	mut context: *mut c_context,1297	state: *mut nix_raw::EvalState,1298	args: *mut *mut value,1299	ret: *mut value,1300) {1301	let user_closure: &UserClosure<N> = unsafe { &*user_data.cast_const().cast() };1302	let args: [&Value; N] = array::from_fn(|i| {1303		let v: &mut Value = unsafe { &mut *args.add(i).cast() };1304		v as &Value1305	});1306	let ctx: &mut NixContext = unsafe { transmute(&mut context) };13071308	let state: &EvalState = unsafe { std::mem::transmute(&state) };13091310	match user_closure(state, args) {1311		Ok(v) => {1312			unsafe { copy_value(context, ret, v.0) };1313		}1314		Err(e) => {1315			ctx.set_err(e);1316		}1317	}1318}13191320type UserClosure<const N: usize> = Box<dyn Fn(&EvalState, [&Value; N]) -> Result<Value>>;13211322pub struct NativeFn(*mut PrimOp);1323impl NativeFn {1324	pub fn new<const N: usize>(1325		name: &'static CStr,1326		doc: &'static CStr,1327		args: [&'static CStr; N],1328		f: impl Fn(&EvalState, [&Value; N]) -> Result<Value> + 'static,1329	) -> Self {1330		// Double-boxing to make it thin pointer, as vtable gets outside of first Box1331		let closure: Box<UserClosure<N>> = Box::new(Box::new(f));1332		let f: PrimOpFun = Some(nix_primop_closure_adapter::<N>);1333		let mut args = args.into_iter().map(|v| v.as_ptr()).collect_vec();1334		args.push(null());1335		let args = args.as_mut_ptr();1336		let primop = unsafe {1337			alloc_primop(1338				null_mut(),1339				f,1340				N as i32,1341				name.as_ptr(),1342				args,1343				doc.as_ptr(),1344				Box::into_raw(closure).cast(),1345			)1346		};13471348		assert!(!primop.is_null(), "primop allocation should not fail");13491350		Self(primop)1351	}1352	pub fn register(self) {1353		unsafe { register_primop(null_mut(), self.0) };1354	}1355}13561357pub struct StorePath(*mut c_store_path);1358impl StorePath {1359	fn as_ptr(&self) -> *mut c_store_path {1360		self.01361	}1362}13631364impl Drop for StorePath {1365	fn drop(&mut self) {1366		unsafe { store_path_free(self.0) }1367	}1368}13691370#[test_log::test]1371fn test_native() -> Result<()> {1372	init_libraries();1373	NativeFn::new(1374		c"__uppercaseSuffix2",1375		c"make string uppercase and add suffix",1376		[c"str", c"suffix"],1377		|_, [str, suffix]: [&Value; 2]| {1378			let str = str.to_string()?;1379			let suffix = suffix.to_string()?;1380			Ok(Value::new_str(&format!("{}{suffix}", str.to_uppercase())))1381		},1382	)1383	.register();13841385	let mut fetch_settings = FetchSettings::new();1386	fetch_settings.set(c"warn-dirty", c"false");13871388	let manifest = format!("git+file://{}/../../", env!("CARGO_MANIFEST_DIR"));1389	let flake = FlakeSettings::new()?;1390	let parse = FlakeReferenceParseFlags::new(&flake)?;1391	let (mut r, _) = FlakeReference::new(&manifest, &flake, &parse, &fetch_settings)?;1392	let lock = FlakeLockFlags::new(&flake)?;1393	let locked = r.lock(&fetch_settings, &flake, &lock)?;1394	let attrs = locked.get_attrs(&mut FlakeSettings::new()?)?;13951396	let builtins = Value::eval("builtins")?;1397	assert_eq!(builtins.type_of(), NixType::Attrs);13981399	assert_eq!(attrs.type_of(), NixType::Attrs);1400	let test_data = nix_go!(attrs.testData);14011402	let test_string: String = nix_go_json!(test_data.testString);1403	assert_eq!(test_string, "hello");14041405	let s = nix_go!(attrs.packages["x86_64-linux"].fleet.drvPath);1406	let s = CString::new(s.to_string()?).expect("path str is cstring");14071408	let uppercase_suffix = Value::new_primop(NativeFn::new(1409		c"uppercase_suffix",1410		c"make string uppercase and add suffix",1411		[c"str", c"suffix"],1412		|es, [str, suffix]: [&Value; 2]| {1413			let str = str.to_string()?;1414			let suffix = suffix.to_string()?;1415			Ok(Value::new_str(&format!("{}{suffix}", str.to_uppercase())))1416		},1417	));14181419	let test_result: String = nix_go_json!(test_data.testPrimop(uppercase_suffix));1420	assert_eq!(test_result, "PREFIX_BODY_SUFFIX");1421	let test_result: String = nix_go_json!(builtins.uppercaseSuffix2("test")("suffix"));1422	assert_eq!(test_result, "TESTsuffix");14231424	let drv_path =1425		nix_go!(attrs.packages["x86_64-linux"]["fleet-install-secrets"].drvPath).to_string()?;1426	let graph = drv::DrvGraph::resolve(&drv_path)?;1427	eprintln!(1428		"fleet-install-secrets dependency graph: {} nodes",1429		graph.nodes.len()1430	);1431	for (path, node) in &graph.nodes {1432		if !node.input_drvs.is_empty() {1433			eprintln!("  {} ({} deps)", node.name, node.input_drvs.len());1434		}1435	}14361437	Ok(())1438}14391440// pub struct GcAlloc;1441// unsafe impl GlobalAlloc for GcAlloc {1442// 	unsafe fn alloc(&self, l: Layout) -> *mut u8 {1443// 		let ptr = unsafe { GC_malloc(l.size()) };1444// 		ptr.cast()1445// 	}1446// 	unsafe fn dealloc(&self, ptr: *mut u8, _: Layout) {1447// 		// unsafe { GC_free(ptr.cast()) };1448// 	}1449//1450// 	unsafe fn realloc(&self, ptr: *mut u8, _: Layout, new_size: usize) -> *mut u8 {1451// 		let ptr = unsafe { GC_realloc(ptr.cast(), new_size) };1452// 		ptr.cast()1453// 	}1454// }1455//1456// #[global_allocator]1457// static GC: GcAlloc = GcAlloc;
addeddocs/features/usbd.adocdiffbeforeafterboth
--- /dev/null
+++ b/docs/features/usbd.adoc
@@ -0,0 +1,83 @@
+== Fleet-usbd
+
+For airgapped deployments/system updates for low-tech users with limited internet access, it is better to have something more manual than pusher.
+
+fleet-usbd is a simple update daemon, which watches for a USB stick with the system update to be inserted into the machine, reads the manifest (usbd-update) from it, copies the closure from the USB stick, switches the current system generation, moves the manifest (usbd-update.done) and reboots.
+
+The stick is discovered by volume label; the label is only a discovery hint, not a trust anchor. Detection is a systemd device unit on /dev/disk/by-label/<label> with the daemon service bound to it (WantedBy=), no udisks dependency.
+
+== Update flow
+
+1. Stick inserted, device unit starts the service, stick is mounted.
+2. Daemon decrypts the manifest, checks the system name matches this host.
+3. If the manifest's system is already the current generation - noop. Guards against reinsertion when the .done rename previously failed.
+4. Closure is copied from the stick. Hash verification is part of the copy; copying already-present paths is a noop.
+5. New generation is set as the boot default (boot, not switch), so the existing on-boot health-check rollback applies.
+6. Manifest is renamed to usbd-update.done.
+7. Reboot.
+
+== Requirements
+
+One file per store path:: it should be possible to rewrite the system update on USB stick in incremental fashion: unchanged store paths keep their exact file name and bytes, only added/removed paths change on the stick.
+Deterministic, non-leaking names and ciphertext:: file names and file contents must be byte-stable across rewrites (otherwise incremental sync degrades to full rewrite), but must not leak store path names/contents. Mix a secret into the derivation: per-file key and file name are derived from (deployment secret, narHash); the deployment secret is an ordinary fleet shared secret owned by the target host. Derivation must use narHash, not the store path: input-addressed paths can be rebuilt nondeterministically, yielding the same store path with different contents, and a same-name-different-bytes file breaks both immutability and size-only synchronization. Note age cannot be used for the path files - its encryption is randomized, so ciphertext would churn on every rewrite.
+Manifest encrypted to the host ssh key:: the manifest is encrypted the same way fleet already performs secret encryption (age, ssh-ed25519 host key recipient). It carries the system name and the per-path metadata listed in the copying section.
+Manifest should include system name:: update daemon should check if the system update is suitable for it.
+User feedback via callbacks:: how the user learns "update running / done / safe to remove" differs per client (screen, beep, LED). The daemon only exposes callback hooks for overriding; no built-in feedback.
+Backwards-compatible updates:: an old usbd must be able to apply updates produced by a newer fleet (forward compatibility from the daemon's point of view). Consequences: the on-stick format core (discovery label, manifest name and encryption, name/key derivation, file cipher, narinfo fields) is frozen from v1; the manifest is self-describing and old daemons ignore unknown fields, so evolution is additive-only. A truly breaking format change is still possible in two hops: every update ships the whole system including usbd itself, so the writer can emit the old format once to upgrade the daemon, then switch.
+
+== Copying store paths
+
+The daemon exposes the stick as a standard nix binary cache on localhost and lets nix substitution perform the copy. This reuses nix machinery for hash verification, signature checking and skipping already-present paths - no diffing logic in the daemon.
+
+1. Manifest carries, per store path: store path, narHash, narSize, references, build-time signature, on-stick file name and per-file key - everything needed to synthesize a narinfo.
+2. Daemon binds an ephemeral HTTP listener on 127.0.0.1 serving the binary cache protocol: /nix-cache-info, synthesized <hash>.narinfo (Sig: from build time, URL: nar/<derived-name>, Compression: zstd) and /nar/<derived-name>, which streaming-decrypts the stick file (chunked AEAD) into the .nar.zst; nix does the decompression and NAR hash check itself.
+3. The copy goes through the nix-eval FFI (Store::open on the localhost cache, copy_to into the local store), not a subprocess. A random token path prefix in the cache URL keeps other local processes from pulling decrypted NARs off the listener.
+4. Signatures are verified by nix against trusted-public-keys; the deployer signing key is pinned there by the fleet module.
+5. The copy holds a temporary GC root on the system path so a concurrent GC cannot race it. The listener is shut down after the copy, then the normal flow continues: set boot generation, rename manifest, reboot.
+
+Stick writing is the mirror image and also goes through the FFI: query path infos of the system closure, sign, dump each path to NAR, zstd-compress, encrypt with the derived per-file key, write under the derived name, write the age-encrypted manifest last.
+
+== Stick format
+
+FAT32 (mkfs.vfat): universally supported and more robust against unclean ejects than exFAT. The 4 GiB file size limit is handled by splitting large encrypted files into fixed-size chunks; chunk names are derived from (deployment secret, narHash, chunk index) and the manifest lists the chunk sequence per path. This is plain size-splitting, unrelated to dedup chunking. Filesystem is case-insensitive; derived file names should be lowercase.
+
+== Synchronization
+
+Stick content should be synchronizable from http with a known tool that works on Windows. Flat content-addressed layout plus one manifest file satisfies this: rclone handles it (single portable exe, can be pre-installed on the stick itself with a .bat wrapper running `rclone sync --progress`; rclone has no native GUI and its experimental web GUI downloads assets from github on first run, so it is unusable here). Files are immutable under their derived names, so size-only comparison suffices. Sync should use --delete-before: rclone does not order transfers and by default deletes extraneous files last, which would require the stick to hold the old and new closure simultaneously. A partially synced stick is safe: the copy is hash-verified and the closure must be complete before the switch happens; the daemon should stat all manifest-referenced files upfront to report a partially synced stick cleanly.
+
+== Usage
+
+On the host, enable the daemon and trust the update signing key:
+
+[source,nix]
+----
+{
+  services.fleet-usbd = {
+    enable = true;
+    signPublicKeys = [ "client-1:..." ];
+    # Optional user feedback script: receives copying/switching/done/noop/failed
+    # hook = ./usbd-hook.sh;
+  };
+}
+----
+
+The daemon is bound to the volume label (FLEETUSBD by default) via a systemd device unit; inserting the stick runs fsck and applies the update.
+
+Writing the stick (expects an inserted stick with the matching label, mounted via udisks):
+
+[source,shell]
+----
+mkfs.vfat -n FLEETUSBD /dev/sdX1
+fleet usbd write HOSTNAME --sign-key ./client-1.secret
+----
+
+The signing keypair is generated once with `nix key generate-secret --key-name client-1`.
+
+== Not a requirement
+
+Rollback prevention:: not a concern for any of the current clients.
+Update authenticity in the daemon:: handled by nix itself via store signatures during the copy; nothing to do on the usbd level.
+Chunking of large paths:: attic-style sub-path chunking would help large paths that change slightly, but conflicts with ease of synchronization; skipped.
+Status/log back-channel on the stick:: postponed, depends on the client.
+GC of old generations:: not a daemon problem.
+Multi-host sticks:: conflicts with incremental stick rewrite; one stick per host for now.
modifiedlib/flakePart.nixdiffbeforeafterboth
--- a/lib/flakePart.nix
+++ b/lib/flakePart.nix
@@ -72,6 +72,7 @@
                           })
                           fleet-install-secrets
                           fleet-generator-helper
+                          fleet-usbd
                           remowt-plugin-fleet
                           ;
                       })
modifiedmodules/nixos/module-list.nixdiffbeforeafterboth
--- a/modules/nixos/module-list.nix
+++ b/modules/nixos/module-list.nix
@@ -3,6 +3,7 @@
   ./secrets.nix
   ./rollback.nix
   ./nix-sign.nix
+  ./usbd.nix
   ./online.nix
   ./polkit.nix
   ./top-level.nix
addedmodules/nixos/usbd.nixdiffbeforeafterboth
--- /dev/null
+++ b/modules/nixos/usbd.nix
@@ -0,0 +1,89 @@
+# Tied to cmds/usbd, see docs/features/usbd.adoc
+{
+  config,
+  lib,
+  pkgs,
+  utils,
+  ...
+}:
+let
+  inherit (lib)
+    mkEnableOption
+    mkIf
+    mkOption
+    mkPackageOption
+    types
+    ;
+  inherit (lib.lists) optionals;
+  cfg = config.services.fleet-usbd;
+  devicePath = "/dev/disk/by-label/${cfg.label}";
+  deviceUnit = "${utils.escapeSystemdPath devicePath}.device";
+in
+{
+  options.services.fleet-usbd = {
+    enable = mkEnableOption "fleet USB update daemon";
+    package = mkPackageOption pkgs "fleet-usbd" { };
+    label = mkOption {
+      type = types.str;
+      default = "FLEETUSBD";
+      description = ''
+        Volume label of the update stick, matching the label passed to
+        `fleet usbd write`. FAT32 labels are at most 11 characters, uppercase.
+      '';
+    };
+    signPublicKeys = mkOption {
+      type = types.listOf types.str;
+      description = ''
+        Nix public keys the update closure is signed with
+        (the counterparts of `fleet usbd write --sign-key`).
+      '';
+    };
+    hook = mkOption {
+      type = types.nullOr types.path;
+      default = null;
+      description = ''
+        User feedback script, executed on update lifecycle events with the
+        event name as the first argument: copying, switching, done, noop,
+        failed <error>.
+      '';
+    };
+    extraArgs = mkOption {
+      type = types.listOf types.str;
+      default = [ ];
+      description = "Extra arguments for the fleet-usbd invocation.";
+    };
+  };
+
+  config = mkIf cfg.enable {
+    nix.settings.trusted-public-keys = cfg.signPublicKeys;
+
+    systemd.services.fleet-usbd = {
+      description = "Fleet USB update daemon";
+      wantedBy = [ deviceUnit ];
+      after = [
+        deviceUnit
+        "local-fs.target"
+      ];
+      serviceConfig = {
+        Type = "oneshot";
+        ExecStartPre = "-${pkgs.dosfstools}/bin/fsck.vfat -a ${devicePath}";
+        ExecStart = utils.escapeSystemdExecArgs (
+          [
+            (lib.getExe cfg.package)
+            "--device"
+            devicePath
+          ]
+          ++ optionals (cfg.hook != null) [
+            "--hook"
+            cfg.hook
+          ]
+          ++ cfg.extraArgs
+        );
+      };
+      unitConfig = {
+        X-RestartIfChanged = false;
+        X-StopIfChanged = false;
+      };
+    };
+  };
+}
modifiedpkgs/default.nixdiffbeforeafterboth
--- a/pkgs/default.nix
+++ b/pkgs/default.nix
@@ -10,6 +10,7 @@
   fleet = callPackage ./fleet.nix { inherit craneLib inputs remowt-agents-bundle; };
   fleet-install-secrets = callPackage ./fleet-install-secrets.nix { inherit craneLib; };
   fleet-generator-helper = callPackage ./fleet-generator-helper.nix { inherit craneLib; };
+  fleet-usbd = callPackage ./fleet-usbd.nix { inherit craneLib inputs; };
 
   inherit remowt-agents-bundle;
   remowt-plugin-fleet = callPackage ./remowt-plugin-fleet.nix { inherit craneLib inputs; };
addedpkgs/fleet-usbd.nixdiffbeforeafterboth
--- /dev/null
+++ b/pkgs/fleet-usbd.nix
@@ -0,0 +1,38 @@
+{
+  lib,
+  craneLib,
+  inputs,
+
+  stdenv,
+  pkg-config,
+  rustPlatform,
+}:
+let
+  system = stdenv.hostPlatform.system;
+in
+craneLib.buildPackage rec {
+  pname = "fleet-usbd";
+  src = lib.cleanSourceWith {
+    src = ../.;
+    filter =
+      path: type:
+      (lib.hasSuffix "\.cc" path)
+      || (lib.hasSuffix "\.hh" path)
+      || (craneLib.filterCargoSources path type);
+  };
+  strictDeps = true;
+
+  cargoExtraArgs = "--locked -p ${pname}";
+
+  buildInputs = [
+    inputs.nix.packages.${system}.nix-expr-c
+    inputs.nix.packages.${system}.nix-flake-c
+    inputs.nix.packages.${system}.nix-fetchers-c
+  ];
+  nativeBuildInputs = [
+    pkg-config
+    rustPlatform.bindgenHook
+  ];
+
+  meta.mainProgram = "fleet-usbd";
+}