git.delta.rocks / fleet / refs/commits / 14689aa0c750

difftreelog

fix temp root

urnmkxtlYaroslav Bolyukin2026-07-07parent: #615754c.patch.diff

18 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -607,7 +607,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "cexpr",
  "clang-sys",
  "itertools 0.13.0",
@@ -623,6 +623,12 @@
 
 [[package]]
 name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
 version = "2.13.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
@@ -1275,6 +1281,29 @@
 ]
 
 [[package]]
+name = "ctaphid"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa622743e1747e48d25170854b552b3ddc753125a39a6732e57c5c7a5ff7fe11"
+dependencies = [
+ "ctaphid-types",
+ "hex",
+ "hidapi",
+ "log",
+ "rand_core 0.6.4",
+ "tap",
+]
+
+[[package]]
+name = "ctaphid-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4714cdd86d5134532b9decaa6774db0a6851ecd07e96a2f239332ae1f3239350"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
 name = "ctr"
 version = "0.9.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1623,7 +1652,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "block2",
  "libc",
  "objc2",
@@ -2036,11 +2065,14 @@
  "base64 0.22.1",
  "bytes",
  "camino",
+ "chrono",
  "clap",
+ "ctaphid",
  "fleet-usb",
  "futures",
  "goodlog-subscriber",
  "hex",
+ "hidapi",
  "hostname",
  "http-body-util",
  "hyper",
@@ -2048,6 +2080,7 @@
  "nix",
  "nix-eval",
  "rand 0.10.1",
+ "tempfile",
  "tokio",
  "tracing",
 ]
@@ -2539,6 +2572,18 @@
 ]
 
 [[package]]
+name = "hidapi"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "798154e4b6570af74899d71155fb0072d5b17e6aa12f39c8ef22c60fb8ec99e7"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "winapi",
+]
+
+[[package]]
 name = "hkdf"
 version = "0.12.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3431,7 +3476,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "libc",
  "plain",
  "redox_syscall 0.8.1",
@@ -3731,7 +3776,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "libc",
  "log",
  "netlink-packet-core",
@@ -3743,7 +3788,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "libc",
  "log",
  "netlink-packet-core",
@@ -3819,7 +3864,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "cfg-if",
  "cfg_aliases",
  "libc",
@@ -4063,7 +4108,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "block2",
  "dispatch2",
  "libc",
@@ -4076,7 +4121,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "objc2",
  "objc2-core-foundation",
  "objc2-foundation",
@@ -4096,7 +4141,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "block2",
  "libc",
  "objc2",
@@ -4109,7 +4154,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "objc2",
  "objc2-core-foundation",
 ]
@@ -4130,7 +4175,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "dispatch2",
  "libc",
  "objc2",
@@ -4840,7 +4885,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "num-traits",
  "rand 0.9.4",
  "rand_chacha 0.9.0",
@@ -5067,7 +5112,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
 ]
 
 [[package]]
@@ -5076,7 +5121,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
 ]
 
 [[package]]
@@ -5389,7 +5434,7 @@
 checksum = "bbf893f64684e58da8a68d56a5e84d1cf0440226274c515770fe267707a7d0b0"
 dependencies = [
  "aes 0.9.1",
- "bitflags",
+ "bitflags 2.13.0",
  "block-padding 0.4.2",
  "byteorder",
  "bytes",
@@ -5553,7 +5598,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "errno",
  "libc",
  "linux-raw-sys 0.4.15",
@@ -5566,7 +5611,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "errno",
  "libc",
  "linux-raw-sys 0.12.1",
@@ -5769,7 +5814,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "core-foundation 0.10.1",
  "core-foundation-sys",
  "libc",
@@ -6025,7 +6070,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
 ]
 
 [[package]]
@@ -6253,7 +6298,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "core-foundation 0.9.4",
  "system-configuration-sys",
 ]
@@ -6299,6 +6344,12 @@
 checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
 
 [[package]]
+name = "tap"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+
+[[package]]
 name = "tempfile"
 version = "3.27.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6682,7 +6733,7 @@
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
 dependencies = [
- "bitflags",
+ "bitflags 2.13.0",
  "bytes",
  "futures-util",
  "http",
@@ -7036,7 +7087,7 @@
 checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
 dependencies = [
  "arrayvec",
- "bitflags",
+ "bitflags 2.13.0",
  "cursor-icon",
  "log",
  "memchr",
modifiedCargo.tomldiffbeforeafterboth
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -41,12 +41,14 @@
 chrono = { version = "0.4.41", features = ["serde"] }
 clap = { version = "4.5", features = ["derive", "env", "unicode", "wrap_help"] }
 clap_complete = "4.5"
+ctaphid = "0.3.1"
 console-subscriber = "0.5.0"
 cxx = "1.0.168"
 cxx-build = "1.0.168"
 ed25519-dalek = "3.0.0-rc.0"
 futures = "0.3.31"
 hex = "0.4.3"
+hidapi = { version = "1.2.6", default-features = false, features = ["linux-static-hidraw"] }
 hmac = "0.13.0"
 hostname = "0.4.1"
 http-body-util = "0.1"
modifiedcmds/fleet/src/cmds/usbd.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/usbd.rs
+++ b/cmds/fleet/src/cmds/usbd.rs
@@ -2,6 +2,7 @@
 	collections::HashSet,
 	fs::File,
 	io::{self, Write as _},
+	os::unix::fs::OpenOptionsExt as _,
 	sync::Arc,
 };
 
@@ -12,15 +13,16 @@
 use clap::Parser;
 use fleet_base::{fleetdata::SecretOwner, host::Config};
 use fleet_usb::{
-	MANIFEST_DONE_NAME, MANIFEST_NAME, MANIFEST_VERSION,
+	DATA_DIR, GC_DIR, MANIFEST_VERSION,
 	manifest::{Manifest, PathEntry, encrypt_manifest},
+	manifest_done_name, manifest_name,
 	names::{self, NAMING_SECRET_SIZE, NamingSecret},
 	stream::EncryptWriter,
 };
-use nix_eval::{Store, eval_store};
+use nix_eval::{Store, eval_store, nix_go, nix_go_json};
 use rand::Rng as _;
 use tokio::task::spawn_blocking;
-use tracing::{info, warn};
+use tracing::{debug, info};
 use zbus::zvariant::{self, OwnedObjectPath};
 
 use super::build_systems::build_task;
@@ -54,8 +56,9 @@
 	/// 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)]
+	/// Nix secret signing key, overrides hosts.<name>.usbd.signingKeyFiles;
+	/// the public counterpart must be in trusted-public-keys on the host
+	#[clap(long, verbatim_doc_comment)]
 	sign_key: Option<Utf8PathBuf>,
 	#[clap(long, default_value = "toplevel-fleet")]
 	build_attr: String,
@@ -66,16 +69,29 @@
 		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 secret = naming_secret(config)?;
 
 		let built = build_task(config.clone(), hostname.clone(), &self.build_attr).await?;
 
-		if let Some(key) = self.sign_key.clone() {
+		let mut sign_keys: Vec<Utf8PathBuf> = self.sign_key.clone().into_iter().collect();
+		if sign_keys.is_empty()
+			&& let Some(host_config) = &host.host_config
+		{
+			let usbd = nix_go!(host_config.usbd);
+			let files: Vec<String> = nix_go_json!(usbd.signingKeyFiles);
+			for file in files {
+				let path = Utf8PathBuf::try_from(config.directory.join(&file))
+					.context("fleet directory should be utf8")?;
+				sign_keys.push(path);
+			}
+		}
+		let mut sign_key_names = Vec::new();
+		for key in sign_keys {
+			info!("signing with {key}");
+			sign_key_names.push(sign_key_name(&key)?);
 			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 {
@@ -88,20 +104,25 @@
 		};
 		info!(target: TARGET, "writing update to {dir}");
 
-		let store = eval_store();
+		// Reopened to avoid nix Store::pathInfoCache: infos cached during the build
+		// would otherwise hide the signatures added by sign_closure above.
+		let store = Arc::new(spawn_blocking(|| Store::open("auto")).await??);
 		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??
+			spawn_blocking(move || {
+				write_closure(&store, &secret, &dir, &hostname, &built, &sign_key_names)
+			})
+			.await??
 		};
 
 		let encrypted = encrypt_manifest(&manifest, std::iter::once(&*recipient))?;
 		{
 			let dir = dir.clone();
-			spawn_blocking(move || write_manifest(&dir, &encrypted)).await??;
+			let host_id = names::host_id(&secret, &hostname);
+			spawn_blocking(move || write_manifest(&dir, &host_id, &encrypted)).await??;
 		}
 
 		if let Some(stick) = stick {
@@ -112,36 +133,44 @@
 	}
 }
 
-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")?;
+fn sign_key_name(key_file: &Utf8Path) -> Result<String> {
+	let key = std::fs::read_to_string(key_file)
+		.with_context(|| format!("reading signing key {key_file}"))?;
+	let (name, _) = key
+		.trim()
+		.split_once(':')
+		.with_context(|| format!("signing key {key_file} should look like name:base64"))?;
+	Ok(name.to_owned())
+}
+
+fn naming_secret(config: &Config) -> Result<NamingSecret> {
+	let config_field = &config.config_field;
+	let file: String = nix_go_json!(config_field.usbd.namingSecretFile);
+	let path = Utf8PathBuf::try_from(config.directory.join(&file))
+		.context("fleet directory should be utf8")?;
+	if path.exists() {
+		let value = std::fs::read_to_string(&path)?;
+		let bytes = BASE64
+			.decode(value.trim())
+			.with_context(|| format!("naming secret base64 in {path}"))?;
 		let bytes: [u8; NAMING_SECRET_SIZE] = bytes
 			.try_into()
-			.map_err(|_| anyhow!("naming secret should be {NAMING_SECRET_SIZE} bytes"))?;
+			.map_err(|_| anyhow!("naming secret in {path} 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)),
-		);
+		if let Some(parent) = path.parent() {
+			std::fs::create_dir_all(parent)?;
+		}
+		let mut file = std::fs::OpenOptions::new()
+			.write(true)
+			.create_new(true)
+			.mode(0o600)
+			.open(&path)
+			.with_context(|| format!("creating naming secret file {path}"))?;
+		writeln!(file, "{}", BASE64.encode(bytes))?;
+		info!(target: TARGET, "generated new usbd naming secret at {path}");
 		Ok(NamingSecret(bytes))
 	}
 }
@@ -152,38 +181,100 @@
 	dir: &Utf8Path,
 	hostname: &str,
 	toplevel: &Utf8Path,
+	sign_key_names: &[String],
 ) -> Result<Manifest> {
+	let data_dir = dir.join(DATA_DIR);
+	let gc_dir = dir.join(GC_DIR);
+	std::fs::create_dir_all(&data_dir)?;
+	std::fs::create_dir_all(&gc_dir)?;
+
 	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());
+	for shard in data_dir.read_dir_utf8().context("reading stick")? {
+		let shard = shard?;
+		if !shard.file_type()?.is_dir() {
+			continue;
+		}
+		for entry in shard.path().read_dir_utf8()? {
+			existing.insert(entry?.file_name().to_owned());
+		}
 	}
 
 	let total = closure.len();
 	let mut entries = Vec::with_capacity(total);
+	let mut reused = 0;
 	for (i, path) in closure.iter().enumerate() {
-		let entry = write_path(store, secret, dir, &mut existing, path)
+		let (entry, written) = write_path(store, secret, &data_dir, &mut existing, path)
 			.with_context(|| format!("writing {path}"))?;
-		info!(target: TARGET, "[{}/{total}] {path}", i + 1);
+		if written {
+			info!(target: TARGET, "[{}/{total}] {path}", i + 1);
+		} else {
+			debug!(target: TARGET, "[{}/{total}] {path} (reused)", i + 1);
+			reused += 1;
+		}
+		for name in sign_key_names {
+			if !entry
+				.sigs
+				.iter()
+				.any(|sig| sig.strip_prefix(name).is_some_and(|r| r.starts_with(':')))
+			{
+				bail!(
+					"{path} has no signature by the configured key {name}, refusing to write an update the host would reject; sigs present: {:?}",
+					entry.sigs
+				);
+			}
+		}
 		entries.push(entry);
 	}
+	info!(target: TARGET, "{} paths written, {reused} reused", total - reused);
 
-	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}"))?;
+	{
+		let tmp = gc_dir.join(format!("{TMP_PREFIX}gc"));
+		let mut file = File::create(&tmp)?;
+		for entry in &entries {
+			for chunk in &entry.chunks {
+				writeln!(file, "{chunk}")?;
+			}
+		}
+		file.sync_all()?;
+		drop(file);
+		std::fs::rename(&tmp, gc_dir.join(names::host_id(secret, hostname)))?;
+	}
+
+	let mut referenced = HashSet::new();
+	for entry in gc_dir.read_dir_utf8()? {
+		let entry = entry?;
+		if entry.file_name().starts_with(TMP_PREFIX) {
+			std::fs::remove_file(entry.path())?;
+			continue;
+		}
+		let list = std::fs::read_to_string(entry.path())
+			.with_context(|| format!("reading gc list {}", entry.file_name()))?;
+		referenced.extend(list.lines().filter(|l| !l.is_empty()).map(str::to_owned));
+	}
+
+	for shard in data_dir.read_dir_utf8()? {
+		let shard = shard?;
+		if !shard.file_type()?.is_dir() {
+			if shard.file_name().starts_with(TMP_PREFIX) {
+				std::fs::remove_file(shard.path())?;
+			}
+			continue;
 		}
+		for entry in shard.path().read_dir_utf8()? {
+			let entry = entry?;
+			let name = entry.file_name();
+			let stale_data = names::is_data_name(name) && !referenced.contains(name);
+			if stale_data || name.starts_with(TMP_PREFIX) {
+				std::fs::remove_file(entry.path())
+					.with_context(|| format!("deleting stale {name}"))?;
+			}
+		}
 	}
+	let _ = std::fs::remove_file(dir.join(manifest_done_name(&names::host_id(secret, hostname))));
 
 	Ok(Manifest {
 		version: MANIFEST_VERSION,
@@ -199,7 +290,7 @@
 	dir: &Utf8Path,
 	existing: &mut HashSet<String>,
 	path: &Utf8Path,
-) -> Result<PathEntry> {
+) -> Result<(PathEntry, bool)> {
 	let info = store.query_path_info(path)?;
 	let key = names::file_key(secret, &info.nar_hash);
 
@@ -217,6 +308,7 @@
 		}
 	}
 
+	let written = chunks.is_none();
 	let chunks = if let Some(chunks) = chunks {
 		chunks
 	} else {
@@ -234,31 +326,36 @@
 			.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))?;
+			let dest = dir.join(names::data_rel_path(name));
+			std::fs::create_dir_all(dest.parent().expect("sharded path has a parent"))?;
+			std::fs::rename(tmp, dest)?;
 		}
 		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,
-	})
+	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,
+		},
+		written,
+	))
 }
 
-fn write_manifest(dir: &Utf8Path, encrypted: &[u8]) -> Result<()> {
+fn write_manifest(dir: &Utf8Path, host_id: &str, 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))?;
+	std::fs::rename(&tmp, dir.join(manifest_name(host_id)))?;
 	nix::unistd::sync();
 	Ok(())
 }
modifiedcmds/usbd/Cargo.tomldiffbeforeafterboth
--- a/cmds/usbd/Cargo.toml
+++ b/cmds/usbd/Cargo.toml
@@ -11,10 +11,13 @@
 base64.workspace = true
 bytes.workspace = true
 camino.workspace = true
+chrono.workspace = true
 clap.workspace = true
+ctaphid.workspace = true
 fleet-usb.workspace = true
 futures.workspace = true
 goodlog-subscriber.workspace = true
+hidapi.workspace = true
 hostname.workspace = true
 hex.workspace = true
 http-body-util.workspace = true
@@ -23,5 +26,6 @@
 nix = { workspace = true, features = ["mount"] }
 nix-eval.workspace = true
 rand.workspace = true
+tempfile.workspace = true
 tokio = { workspace = true, features = ["net", "process"] }
 tracing.workspace = true
modifiedcmds/usbd/src/main.rsdiffbeforeafterboth
--- a/cmds/usbd/src/main.rs
+++ b/cmds/usbd/src/main.rs
@@ -5,14 +5,17 @@
 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 fleet_usb::{
+	DATA_DIR, LOGS_DIR, MANIFEST_DONE_SUFFIX, MANIFEST_PREFIX, manifest::decrypt_manifest, names,
+};
 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 rand::RngExt as _;
 use tokio::task::spawn_blocking;
-use tracing::{error, info, warn};
+use tracing::{debug, error, info, warn};
 
 const MOUNT_TARGET: &str = "/run/fleet-usbd/mnt";
 
@@ -39,6 +42,17 @@
 	/// copying, switching, done, noop, failed <error>
 	#[clap(long, verbatim_doc_comment)]
 	hook: Option<Utf8PathBuf>,
+	/// Blink connected Nitrokey 3 devices while the daemon is working:
+	/// blinking stopped and the machine rebooted - update succeeded,
+	/// blinking stopped without a reboot - update failed
+	#[clap(long, verbatim_doc_comment)]
+	nk3_wink: bool,
+	/// After every run, dump the journal onto the stick, encrypted to this armored OpenPGP public key
+	#[clap(long)]
+	log_recipient_file: Option<Utf8PathBuf>,
+	/// journalctl --since window of the log dump
+	#[clap(long, default_value = "-48h")]
+	log_since: String,
 	#[clap(long, default_value = "/nix/var/nix/profiles/system")]
 	profile: String,
 	/// Do not reboot after the update is applied
@@ -92,28 +106,110 @@
 	Ok(())
 }
 
-fn mark_done(dir: &Utf8Path) -> Result<()> {
-	std::fs::rename(dir.join(MANIFEST_NAME), dir.join(MANIFEST_DONE_NAME))
-		.context("marking update as done")?;
+fn mark_done(manifest_path: &Utf8Path) -> Result<()> {
+	std::fs::rename(
+		manifest_path,
+		format!("{manifest_path}{MANIFEST_DONE_SUFFIX}"),
+	)
+	.context("marking update as done")?;
 	nix::unistd::sync();
 	Ok(())
 }
 
+fn dump_logs(dir: &Utf8Path, recipient: &Utf8Path, since: &str) -> Result<()> {
+	use std::process::{Command, Stdio};
+	let logs_dir = dir.join(LOGS_DIR);
+	std::fs::create_dir_all(&logs_dir)?;
+	let tmp = logs_dir.join("usbd-tmp-log");
+	let _ = std::fs::remove_file(&tmp);
+
+	let _ = Command::new("journalctl").arg("--sync").status();
+
+	let gpg_home = tempfile::tempdir().context("creating gpg home")?;
+	let mut journalctl = Command::new("journalctl")
+		.args(["-o", "export"])
+		.arg(format!("--since={since}"))
+		.stdout(Stdio::piped())
+		.spawn()
+		.context("spawning journalctl")?;
+	let gpg = Command::new("gpg")
+		.env("GNUPGHOME", gpg_home.path())
+		.args([
+			"--batch",
+			"--no-tty",
+			"--yes",
+			"--trust-model",
+			"always",
+			"--encrypt",
+			"--recipient-file",
+		])
+		.arg(recipient)
+		.arg("--output")
+		.arg(&tmp)
+		.stdin(journalctl.stdout.take().expect("stdout is piped"))
+		.status()
+		.context("running gpg")?;
+	let journalctl = journalctl.wait()?;
+	if !journalctl.success() {
+		bail!("journalctl failed: {journalctl}");
+	}
+	if !gpg.success() {
+		bail!("gpg failed: {gpg}");
+	}
+
+	let file = std::fs::File::open(&tmp)?;
+	file.sync_all()?;
+	drop(file);
+	let name = format!(
+		"{}-{:04x}.export.gpg",
+		chrono::Local::now().format("%Y-%m-%d-%H-%M-%S"),
+		rand::rng().random::<u16>(),
+	);
+	std::fs::rename(&tmp, logs_dir.join(&name))?;
+	info!("journal dumped to {LOGS_DIR}/{name}");
+	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");
+	let identity = host_identity(&opts.host_key)?;
+
+	let mut manifests = Vec::new();
+	let mut done_files = Vec::new();
+	for entry in dir.read_dir_utf8().context("reading stick")? {
+		let entry = entry?;
+		let name = entry.file_name();
+		if !name.starts_with(MANIFEST_PREFIX) {
+			continue;
+		}
+		if name.ends_with(MANIFEST_DONE_SUFFIX) {
+			done_files.push(entry.path().to_owned());
 		} else {
-			info!("no update manifest on the stick");
+			manifests.push(entry.path().to_owned());
 		}
-		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 mut found = None;
+	for path in manifests {
+		let data = std::fs::read(&path).with_context(|| format!("reading manifest {path}"))?;
+		match decrypt_manifest(&data, &identity).with_context(|| format!("manifest {path}"))? {
+			Some(manifest) => {
+				found = Some((path, manifest));
+				break;
+			}
+			None => debug!("manifest {path} is for a different host"),
+		}
+	}
+	let Some((manifest_path, manifest)) = found else {
+		for path in done_files {
+			let data = std::fs::read(&path)?;
+			if decrypt_manifest(&data, &identity)?.is_some() {
+				info!("update on this stick is already applied");
+				return Ok(Outcome::Nothing);
+			}
+		}
+		info!("no update for this host on the stick");
+		return Ok(Outcome::Nothing);
+	};
 
 	let host = match &opts.hostname {
 		Some(host) => host.clone(),
@@ -132,15 +228,16 @@
 	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)?;
+		mark_done(&manifest_path)?;
 		return Ok(Outcome::UpToDate);
 	}
 
+	let data_dir = dir.join(DATA_DIR);
 	let missing = manifest
 		.paths
 		.iter()
 		.flat_map(|e| e.chunks.iter())
-		.filter(|c| !dir.join(c.as_str()).exists())
+		.filter(|c| !data_dir.join(names::data_rel_path(c)).exists())
 		.count();
 	if missing > 0 {
 		bail!("stick is missing {missing} update files, was it synchronized fully?");
@@ -152,13 +249,21 @@
 		manifest.paths.len()
 	);
 	hook.call("copying", None).await;
-	let cache = server::CacheServer::start(dir.to_owned(), &manifest).await?;
+	let cache = server::CacheServer::start(dir, &manifest).await?;
 	{
 		let url = cache.url.clone();
 		let toplevel = manifest.toplevel.clone();
+		let paths = manifest
+			.paths
+			.iter()
+			.map(|e| e.store_path.clone())
+			.collect::<Vec<_>>();
 		spawn_blocking(move || -> Result<()> {
 			let src = Store::open(&url)?;
 			let dst = Store::open("auto")?;
+			for path in &paths {
+				dst.add_temp_root(path)?;
+			}
 			src.copy_to(&dst, &toplevel)
 		})
 		.await?
@@ -186,12 +291,44 @@
 		bail!("{switch} boot failed: {status}");
 	}
 
-	mark_done(dir)?;
+	mark_done(&manifest_path)?;
 	Ok(Outcome::Applied)
 }
 
+fn wink_all() -> Result<()> {
+	let hidapi = hidapi::HidApi::new().context("initializing hidapi")?;
+	for info in hidapi.device_list() {
+		let nk3 = info.vendor_id() == 0x20a0 && info.product_id() == 0x42b2;
+		let fido = info.usage_page() == 0xf1d0;
+		if !nk3 && !fido {
+			continue;
+		}
+		let winked = info
+			.open_device(&hidapi)
+			.map_err(anyhow::Error::from)
+			.and_then(|device| Ok(ctaphid::Device::new(device, info.clone())?))
+			.and_then(|device| Ok(device.wink()?));
+		if let Err(e) = winked {
+			debug!("winking {:?}: {e:#}", info.path());
+		}
+	}
+	Ok(())
+}
+
 async fn run(opts: &Opts) -> Result<()> {
 	let hook = Hook(opts.hook.clone());
+	let winker = opts.nk3_wink.then(|| {
+		tokio::spawn(async {
+			loop {
+				match spawn_blocking(wink_all).await {
+					Ok(Ok(())) => {}
+					Ok(Err(e)) => debug!("nk3 wink: {e:#}"),
+					Err(e) => debug!("nk3 wink task: {e}"),
+				}
+				tokio::time::sleep(std::time::Duration::from_secs(5)).await;
+			}
+		})
+	});
 
 	let mounted = match &opts.device {
 		Some(device) => {
@@ -207,10 +344,28 @@
 
 	let outcome = apply(opts, &dir, &hook).await;
 
+	if let Err(e) = &outcome {
+		error!("update failed: {e:#}");
+	}
+	if let Some(recipient) = &opts.log_recipient_file {
+		let recipient = recipient.clone();
+		let dir = dir.clone();
+		let since = opts.log_since.clone();
+		let dumped = spawn_blocking(move || dump_logs(&dir, &recipient, &since))
+			.await
+			.expect("dump_logs should not panic");
+		if let Err(e) = dumped {
+			warn!("failed to dump logs onto the stick: {e:#}");
+		}
+	}
+
 	nix::unistd::sync();
 	if mounted && let Err(e) = nix::mount::umount(MOUNT_TARGET) {
 		warn!("unmounting the stick: {e}");
 	}
+	if let Some(winker) = winker {
+		winker.abort();
+	}
 
 	match outcome {
 		Ok(Outcome::Applied) => {
modifiedcmds/usbd/src/server.rsdiffbeforeafterboth
--- a/cmds/usbd/src/server.rs
+++ b/cmds/usbd/src/server.rs
@@ -4,7 +4,7 @@
 use base64::Engine as _;
 use base64::engine::general_purpose::STANDARD as BASE64;
 use bytes::Bytes;
-use camino::Utf8PathBuf;
+use camino::{Utf8Path, Utf8PathBuf};
 use fleet_usb::{manifest::Manifest, manifest::PathEntry, stream::DecryptReader};
 use futures::SinkExt as _;
 use http_body_util::{BodyExt as _, Full, StreamBody, combinators::BoxBody};
@@ -39,7 +39,8 @@
 }
 
 impl CacheServer {
-	pub async fn start(dir: Utf8PathBuf, manifest: &Manifest) -> Result<Self> {
+	pub async fn start(dir: &Utf8Path, manifest: &Manifest) -> Result<Self> {
+		let dir = dir.join(fleet_usb::DATA_DIR);
 		let mut token = [0u8; 16];
 		rand::rng().fill_bytes(&mut token);
 		let token = hex::encode(token);
@@ -167,7 +168,7 @@
 	let files = entry
 		.chunks
 		.iter()
-		.map(|c| state.dir.join(c))
+		.map(|c| state.dir.join(fleet_usb::names::data_rel_path(c)))
 		.collect::<Vec<_>>();
 
 	let (mut tx, rx) = futures::channel::mpsc::channel::<Result<Frame<Bytes>, std::io::Error>>(8);
modifiedcrates/fleet-usb/src/lib.rsdiffbeforeafterboth
--- a/crates/fleet-usb/src/lib.rs
+++ b/crates/fleet-usb/src/lib.rs
@@ -2,6 +2,17 @@
 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_PREFIX: &str = "usbd-update-";
+pub const MANIFEST_DONE_SUFFIX: &str = ".done";
 pub const MANIFEST_VERSION: u32 = 1;
+pub const DATA_DIR: &str = "update-data";
+pub const GC_DIR: &str = "gc";
+pub const LOGS_DIR: &str = "logs";
+
+pub fn manifest_name(host_id: &str) -> String {
+	format!("{MANIFEST_PREFIX}{host_id}")
+}
+
+pub fn manifest_done_name(host_id: &str) -> String {
+	format!("{MANIFEST_PREFIX}{host_id}{MANIFEST_DONE_SUFFIX}")
+}
modifiedcrates/fleet-usb/src/manifest.rsdiffbeforeafterboth
--- a/crates/fleet-usb/src/manifest.rs
+++ b/crates/fleet-usb/src/manifest.rs
@@ -44,11 +44,14 @@
 	Ok(out)
 }
 
-pub fn decrypt_manifest(data: &[u8], identity: &dyn Identity) -> Result<Manifest> {
+/// Ok(None) means the manifest is valid but encrypted for a different host.
+pub fn decrypt_manifest(data: &[u8], identity: &dyn Identity) -> Result<Option<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 reader = match decryptor.decrypt(std::iter::once(identity)) {
+		Ok(reader) => reader,
+		Err(age::DecryptError::NoMatchingKeys) => return Ok(None),
+		Err(e) => return Err(e).context("decrypting manifest"),
+	};
 	let mut json = Vec::new();
 	reader.read_to_end(&mut json)?;
 	let manifest: Manifest = serde_json::from_slice(&json).context("manifest json")?;
@@ -58,5 +61,5 @@
 			manifest.version
 		);
 	}
-	Ok(manifest)
+	Ok(Some(manifest))
 }
modifiedcrates/fleet-usb/src/names.rsdiffbeforeafterboth
--- a/crates/fleet-usb/src/names.rs
+++ b/crates/fleet-usb/src/names.rs
@@ -18,14 +18,14 @@
 }
 
 pub fn file_key(secret: &NamingSecret, nar_hash: &str) -> [u8; 32] {
-	derive(secret, &[b"fleet-usb.v1.key\0", nar_hash.as_bytes()])
+	derive(secret, &[b"fleet-usb.v1.key", 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",
+			b"fleet-usb.v1.name",
 			nar_hash.as_bytes(),
 			&index.to_le_bytes(),
 			&count.to_le_bytes(),
@@ -34,6 +34,15 @@
 	hex::encode(&h[..16])
 }
 
+pub fn host_id(secret: &NamingSecret, host: &str) -> String {
+	let h = derive(secret, &[b"fleet-usb.v1.host", host.as_bytes()]);
+	hex::encode(&h[..16])
+}
+
+pub fn data_rel_path(name: &str) -> String {
+	format!("{}/{name}", &name[..2])
+}
+
 pub fn is_data_name(name: &str) -> bool {
 	name.len() == 32
 		&& name
modifiedcrates/nix-eval/src/lib.ccdiffbeforeafterboth
--- a/crates/nix-eval/src/lib.cc
+++ b/crates/nix-eval/src/lib.cc
@@ -204,6 +204,17 @@
   return out;
 }
 
+rust::String add_temp_root(Store *store, rust::Str path) {
+  try {
+    auto nixStore = store->ptr;
+    auto sp = nixStore->parseStorePath(std::string(path));
+    nixStore->addTempRoot(sp);
+    return rust::String();
+  } catch (const std::exception &e) {
+    return rust::String(e.what());
+  }
+}
+
 CxxBuildResult compute_closure(Store *store, rust::Str path) {
   CxxBuildResult res{rust::String(), {}};
   try {
modifiedcrates/nix-eval/src/lib.hhdiffbeforeafterboth
--- a/crates/nix-eval/src/lib.hh
+++ b/crates/nix-eval/src/lib.hh
@@ -34,6 +34,8 @@
 struct CxxPathInfo;
 CxxPathInfo query_path_info(Store *store, rust::Str path);
 
+rust::String add_temp_root(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,
modifiedcrates/nix-eval/src/lib.rsdiffbeforeafterboth
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 add_temp_root(store: *mut Store, path: &str) -> String;145146		#[allow(clippy::missing_safety_doc)]147		unsafe fn compute_closure(store: *mut Store, path: &str) -> CxxBuildResult;148149		#[allow(clippy::missing_safety_doc)]150		unsafe fn nar_from_path(151			store: *mut Store,152			path: &str,153			sink_data: usize,154			sink: fn(usize, &[u8]) -> bool,155		) -> String;156	}157}158159#[derive(Debug, PartialEq, Eq)]160pub enum NixType {161	Thunk,162	Int,163	Float,164	Bool,165	String,166	Path,167	Null,168	Attrs,169	List,170	Function,171	External,172}173impl NixType {174	fn from_int(c: c_uint) -> Self {175		match c {176			0 => Self::Thunk,177			1 => Self::Int,178			2 => Self::Float,179			3 => Self::Bool,180			4 => Self::String,181			5 => Self::Path,182			6 => Self::Null,183			7 => Self::Attrs,184			8 => Self::List,185			9 => Self::Function,186			10 => Self::External,187			_ => unreachable!("unknown nix type: {c}"),188		}189	}190}191192enum FunctorKind {193	Function,194	Functor,195}196197#[derive(Debug)]198#[repr(i32)]199pub enum NixErrorKind {200	Unknown = err_NIX_ERR_UNKNOWN,201	Overflow = err_NIX_ERR_OVERFLOW,202	Key = err_NIX_ERR_KEY,203	Generic = err_NIX_ERR_NIX_ERROR,204}205impl NixErrorKind {206	fn from_int(v: c_int) -> Option<Self> {207		Some(match v {208			0 => return None,209			nix_raw::err_NIX_ERR_UNKNOWN => Self::Unknown,210			nix_raw::err_NIX_ERR_OVERFLOW => Self::Overflow,211			nix_raw::err_NIX_ERR_KEY => Self::Key,212			nix_raw::err_NIX_ERR_NIX_ERROR => Self::Generic,213			_ => {214				debug_assert!(false, "unexpected nix error kind: {v}");215				Self::Unknown216			}217		})218	}219}220221pub fn gc_now() {222	unsafe { gc_now_raw() };223}224225pub fn gc_register_my_thread() {226	assert_eq!(unsafe { GC_thread_is_registered() }, 0);227228	let mut sb = GC_stack_base {229		mem_base: null_mut(),230	};231	let r = unsafe { GC_get_stack_base(&mut sb) };232	if r as u32 != GC_SUCCESS {233		panic!("failed to get thread stack base");234	}235	unsafe { GC_register_my_thread(&sb) };236}237pub fn gc_unregister_my_thread() {238	assert_eq!(unsafe { GC_thread_is_registered() }, 1);239240	unsafe { GC_unregister_my_thread() };241}242243pub struct ThreadRegisterGuard {}244impl ThreadRegisterGuard {245	#[allow(clippy::new_without_default)]246	pub fn new() -> Self {247		gc_register_my_thread();248		Self {}249	}250}251impl Drop for ThreadRegisterGuard {252	fn drop(&mut self) {253		gc_unregister_my_thread();254	}255}256257#[repr(transparent)]258pub struct NixContext(*mut c_context);259impl NixContext {260	pub fn set_err_raw(&mut self, err: NixErrorKind, msg: &CStr) {261		unsafe { set_err_msg(self.0, err as c_int, msg.as_ptr()) };262	}263	pub fn set_err(&mut self, err: anyhow::Error) {264		let fmt = format!("{err:?}").replace("\0", "\\0");265		self.set_err_raw(266			NixErrorKind::Generic,267			&CString::new(fmt).expect("NUL bytes were just replaced"),268		);269	}270	pub fn new() -> Self {271		let ctx = unsafe { c_context_create() };272		Self(ctx)273	}274	fn error_kind(&self) -> Option<NixErrorKind> {275		let code = unsafe { err_code(self.0) };276		NixErrorKind::from_int(code)277	}278	fn error<'t>(&self) -> Option<(Cow<'t, str>, Option<Box<ErrorInfoBuilder>>)> {279		if let NixErrorKind::Generic = self.error_kind()? {280			let ei = unsafe { logging::nix_logging_cxx::extract_error_info(self.0) };281			let mut err_out = String::new();282			unsafe {283				err_info_msg(284					null_mut(),285					self.0,286					Some(copy_nix_str),287					(&raw mut err_out).cast(),288				)289			};290			return Some((Cow::Owned(err_out), Some(ei)));291		};292293		// TODO: Can throw error (resulting in panic) if unable to retrieve error. Should be able to resolve by passing context as a first argument,294		// but it looks ugly295		let str = unsafe { err_msg(null_mut(), self.0, null_mut()) };296		Some((unsafe { CStr::from_ptr(str) }.to_string_lossy(), None))297	}298	fn clean_err(&mut self) {299		unsafe {300			clear_err(self.0);301		}302	}303304	fn bail_if_error(&self) -> Result<()> {305		if let Some((err, stack)) = self.error() {306			let mut e = Err(anyhow!("{err}"));307			if let Some(stack) = stack {308				for ele in stack.stack_frames {309					e = e.with_context(|| {310						if ele.pos.is_empty() {311							ele.msg312						} else {313							format!("{} at {}", ele.msg, ele.pos)314						}315					})316				}317			}318			return e.context("<nix frames>");319		};320		Ok(())321	}322323	fn run_in_context<T>(&mut self, f: impl FnOnce(*mut c_context) -> T) -> Result<T> {324		self.clean_err();325		let o = f(self.0);326		self.bail_if_error()?;327		self.clean_err();328		Ok(o)329	}330}331332impl Default for NixContext {333	fn default() -> Self {334		Self::new()335	}336}337impl Drop for NixContext {338	fn drop(&mut self) {339		unsafe {340			c_context_free(self.0);341		}342	}343}344struct GlobalState {345	// Store should be valid as long as EvalState is valid346	#[allow(dead_code)]347	store: Arc<Store>,348	state: EvalState,349}350impl GlobalState {351	fn new() -> Result<Self> {352		let mut ctx = NixContext::new();353		let store = Arc::new(354			ctx.run_in_context(|c| unsafe { store_open(c, c"auto".as_ptr(), null_mut()) })355				.map(Store)?,356		);357358		let builder = ctx.run_in_context(|c| unsafe { eval_state_builder_new(c, store.0) })?;359		ctx.run_in_context(|c| unsafe { eval_state_builder_load(c, builder) })?;360		ctx.run_in_context(|c| unsafe {361			eval_state_builder_set_eval_setting(362				c,363				builder,364				c"lazy-trees".as_ptr(),365				c"true".as_ptr(),366			)367		})?;368		ctx.run_in_context(|c| unsafe {369			eval_state_builder_set_eval_setting(370				c,371				builder,372				c"lazy-locks".as_ptr(),373				c"true".as_ptr(),374			)375		})?;376		let state = ctx377			.run_in_context(|c| unsafe { eval_state_build(c, builder) })378			.map(EvalState)?;379380		Ok(Self { store, state })381	}382}383384struct ThreadState {385	ctx: NixContext,386}387impl ThreadState {388	fn new() -> Result<Self> {389		let ctx = NixContext::new();390391		Ok(Self { ctx })392	}393}394395static GLOBAL_STATE: LazyLock<GlobalState> =396	LazyLock::new(|| GlobalState::new().expect("global state init shouldn't fail"));397398thread_local! {399	static THREAD_STATE: RefCell<ThreadState> = RefCell::new(ThreadState::new().expect("thread state init shouldn't fail"));400}401pub(crate) fn with_default_context<T>(402	f: impl FnOnce(*mut c_context, *mut c_eval_state) -> T,403) -> Result<T> {404	let global = &GLOBAL_STATE.state;405	let (ctx, state) = THREAD_STATE.with_borrow_mut(|w| (w.ctx.0, global.0));406	let mut ctx = NixContext(ctx);407	let v = ctx.run_in_context(|c| f(c, state));408	// It is reused for thread409	std::mem::forget(ctx);410	v411}412413pub fn set_setting(s: &CStr, v: &CStr) -> Result<()> {414	with_default_context(|c, _| unsafe { setting_set(c, s.as_ptr(), v.as_ptr()) }).map(|_| ())415}416417pub fn get_setting(s: &CStr) -> Result<String> {418	let mut out = String::new();419	with_default_context(|c, _| unsafe {420		setting_get(c, s.as_ptr(), Some(copy_nix_str), (&raw mut out).cast())421	})?;422	Ok(out)423}424425#[derive(Debug)]426pub struct AddedFile {427	pub store_path: Utf8PathBuf,428	pub hash: String,429}430431#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]432pub struct ProfileGeneration {433	pub id: u64,434	pub store_path: Utf8PathBuf,435	pub creation_time_unix: i64,436	pub current: bool,437}438439#[instrument]440pub fn list_generations(profile_path: &str) -> Result<Vec<ProfileGeneration>> {441	let res = nix_cxx::list_generations(profile_path);442	if !res.error.is_empty() {443		bail!(444			"failed to list generations at {profile_path}: {}",445			res.error446		);447	}448	Ok(res449		.generations450		.into_iter()451		.map(|g| ProfileGeneration {452			id: g.id,453			store_path: Utf8PathBuf::from(g.store_path),454			creation_time_unix: g.creation_time_unix,455			current: g.current,456		})457		.collect())458}459460pub struct FetchSettings(*mut fetchers_settings);461impl FetchSettings {462	pub fn new() -> Self {463		Self::try_new().expect("allocation should not fail")464	}465	fn try_new() -> Result<Self> {466		with_default_context(|c, _| unsafe { fetchers_settings_new(c) }).map(Self)467	}468	pub fn set(&mut self, setting: &CStr, value: &CStr) {469		unsafe {470			set_fetcher_setting(self.0.cast(), setting.as_ptr(), value.as_ptr());471		};472	}473}474unsafe impl Send for FetchSettings {}475unsafe impl Sync for FetchSettings {}476477impl Default for FetchSettings {478	fn default() -> Self {479		Self::new()480	}481}482483impl Drop for FetchSettings {484	fn drop(&mut self) {485		unsafe { fetchers_settings_free(self.0) };486	}487}488pub struct FlakeSettings(*mut flake_settings);489impl FlakeSettings {490	pub fn new() -> Result<Self> {491		with_default_context(|c, _| unsafe { flake_settings_new(c) }).map(Self)492	}493}494unsafe impl Send for FlakeSettings {}495unsafe impl Sync for FlakeSettings {}496impl Drop for FlakeSettings {497	fn drop(&mut self) {498		unsafe {499			flake_settings_free(self.0);500		}501	}502}503504pub struct FlakeReferenceParseFlags(*mut flake_reference_parse_flags);505impl FlakeReferenceParseFlags {506	pub fn new(settings: &FlakeSettings) -> Result<Self> {507		with_default_context(|c, _| unsafe { flake_reference_parse_flags_new(c, settings.0) })508			.map(Self)509	}510	pub fn set_base_dir(&mut self, dir: &str) -> Result<()> {511		with_default_context(|c, _| {512			unsafe {513				flake_reference_parse_flags_set_base_directory(514					c,515					self.0,516					dir.as_ptr().cast(),517					dir.len(),518				)519			};520		})521	}522}523impl Drop for FlakeReferenceParseFlags {524	fn drop(&mut self) {525		unsafe {526			flake_reference_parse_flags_free(self.0);527		}528	}529}530pub struct FlakeLockFlags(*mut flake_lock_flags);531impl FlakeLockFlags {532	pub fn new(settings: &FlakeSettings) -> Result<Self> {533		let o = with_default_context(|c, _| unsafe { flake_lock_flags_new(c, settings.0) })534			.map(Self)?;535		// with_default_context(|c, _| unsafe { flake_lock_flags_set_mode_virtual(c, o.0) })?;536537		Ok(o)538	}539}540impl Drop for FlakeLockFlags {541	fn drop(&mut self) {542		unsafe {543			flake_lock_flags_free(self.0);544		}545	}546}547548pub(crate) unsafe extern "C" fn copy_nix_str(549	start: *const c_char,550	n: c_uint,551	user_data: *mut c_void,552) {553	let s = unsafe { slice::from_raw_parts(start.cast::<u8>(), n as usize) };554	let s = std::str::from_utf8(s).expect("c string has invalid utf-8");555	unsafe { *user_data.cast::<String>() = s.to_owned() };556}557558pub struct Store(*mut c_store);559unsafe impl Send for Store {}560unsafe impl Sync for Store {}561562#[derive(Debug, Clone)]563pub struct PathInfo {564	pub nar_hash: String,565	pub nar_size: u64,566	pub references: Vec<Utf8PathBuf>,567	pub sigs: Vec<String>,568}569570pub fn eval_store() -> Arc<Store> {571	GLOBAL_STATE.store.clone()572}573574impl Store {575	pub fn open(uri: &str) -> Result<Self> {576		let uri = CString::new(uri)?;577		let ptr = with_default_context(|c, _| unsafe { store_open(c, uri.as_ptr(), null_mut()) })?;578		if ptr.is_null() {579			bail!("failed to open store");580		}581		Ok(Store(ptr))582	}583584	pub fn parse_path(&self, path: &Utf8Path) -> Result<StorePath> {585		let path = CString::new(path.as_str()).expect("valid cstr");586		with_default_context(|c, _| {587			StorePath(unsafe { store_parse_path(c, self.0, path.as_ptr()) })588		})589	}590591	#[instrument(skip(self))]592	pub fn sign_closure(&self, path: &Utf8Path, key_file: &Utf8Path) -> Result<()> {593		let err = with_default_context(|_, _| unsafe {594			nix_cxx::sign_closure(self.as_ptr().cast(), path.as_str(), key_file.as_str())595		})?596		.to_string();597598		if err.is_empty() {599			Ok(())600		} else {601			bail!("failed to sign {path}: {err}");602		}603	}604605	#[instrument(skip(self, dst))]606	pub fn copy_to(&self, dst: &Store, path: &Utf8Path) -> Result<()> {607		let sp = self608			.parse_path(path)609			.context("failed to parse store path")?;610		let rc = with_default_context(|c, _| unsafe {611			store_copy_closure(c, self.as_ptr(), dst.0, sp.as_ptr())612		})?;613		if rc != err_NIX_OK {614			bail!("store_copy_closure failed (code {rc})");615		}616		Ok(())617	}618619	/// Would only work with local store.620	#[instrument(skip(self))]621	pub fn switch_profile(&self, profile: &str, path: &Utf8Path) -> Result<()> {622		let msg = unsafe { nix_cxx::switch_profile(self.as_ptr().cast(), profile, path.as_str()) };623		if msg.is_empty() {624			Ok(())625		} else {626			bail!("failed to switch profile {profile}: {msg}");627		}628	}629630	#[instrument(skip(self))]631	pub fn add_file(&self, name: &str, path: &Utf8Path) -> Result<AddedFile> {632		let msg = unsafe { nix_cxx::add_file_to_store(self.as_ptr().cast(), name, path.as_str()) };633		if !msg.error.is_empty() {634			bail!("failed to add {path} to store: {}", msg.error)635		}636		Ok(AddedFile {637			store_path: Utf8PathBuf::from(msg.store_path),638			hash: msg.hash,639		})640	}641642	#[instrument(skip(self, paths))]643	pub fn substitute_paths(&self, paths: &[Utf8PathBuf]) -> Result<Vec<Utf8PathBuf>> {644		let joined = paths.into_iter().join("\n");645		let res = unsafe { nix_cxx::substitute_paths(self.as_ptr().cast(), &joined) };646		if !res.error.is_empty() {647			warn!("substitute_paths reported: {}", res.error);648		}649		Ok(res.outputs.into_iter().map(Utf8PathBuf::from).collect())650	}651652	#[instrument(skip(self))]653	pub fn is_valid_path(&self, path: &Utf8Path) -> bool {654		unsafe { nix_cxx::is_valid_path(self.as_ptr().cast(), path.as_str()) }655	}656657	#[instrument(skip(self))]658	pub fn query_path_info(&self, path: &Utf8Path) -> Result<PathInfo> {659		let res = unsafe { nix_cxx::query_path_info(self.as_ptr().cast(), path.as_str()) };660		if !res.error.is_empty() {661			bail!("failed to query path info for {path}: {}", res.error);662		}663		Ok(PathInfo {664			nar_hash: res.nar_hash,665			nar_size: res.nar_size,666			references: res.references.into_iter().map(Utf8PathBuf::from).collect(),667			sigs: res.sigs,668		})669	}670671	/// The root is held for the lifetime of this process.672	#[instrument(skip(self))]673	pub fn add_temp_root(&self, path: &Utf8Path) -> Result<()> {674		let msg = unsafe { nix_cxx::add_temp_root(self.as_ptr().cast(), path.as_str()) };675		if !msg.is_empty() {676			bail!("failed to add temp root for {path}: {msg}");677		}678		Ok(())679	}680681	#[instrument(skip(self))]682	pub fn compute_closure(&self, path: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {683		let res = unsafe { nix_cxx::compute_closure(self.as_ptr().cast(), path.as_str()) };684		if !res.error.is_empty() {685			bail!("failed to compute closure of {path}: {}", res.error);686		}687		Ok(res.outputs.into_iter().map(Utf8PathBuf::from).collect())688	}689690	#[instrument(skip(self, out))]691	pub fn nar_from_path(&self, path: &Utf8Path, out: &mut dyn std::io::Write) -> Result<()> {692		struct SinkState<'a> {693			out: &'a mut dyn std::io::Write,694			error: Option<std::io::Error>,695		}696		fn sink(state: usize, data: &[u8]) -> bool {697			let state = unsafe { &mut *(state as *mut SinkState) };698			match state.out.write_all(data) {699				Ok(()) => true,700				Err(e) => {701					state.error = Some(e);702					false703				}704			}705		}706		let mut state = SinkState { out, error: None };707		let msg = unsafe {708			nix_cxx::nar_from_path(709				self.as_ptr().cast(),710				path.as_str(),711				(&raw mut state) as usize,712				sink,713			)714		};715		if let Some(e) = state.error {716			return Err(anyhow!(e).context(format!("nar sink failed for {path}")));717		}718		if !msg.is_empty() {719			bail!("failed to dump nar of {path}: {msg}");720		}721		Ok(())722	}723724	#[instrument(skip(self))]725	pub fn build_drv_outputs(726		&self,727		drv_path: &Utf8Path,728		output_names: &[String],729	) -> Result<Vec<String>> {730		let joined = output_names.join("\n");731		let res =732			unsafe { nix_cxx::build_drv_outputs(self.as_ptr().cast(), drv_path.as_str(), &joined) };733		if !res.error.is_empty() {734			bail!("build of {drv_path} failed: {}", res.error);735		}736		Ok(res.outputs)737	}738739	#[instrument(skip(self))]740	pub fn store_dir(&self) -> Result<Utf8PathBuf> {741		let mut out = String::new();742		with_default_context(|c, es| unsafe {743			nix_raw::store_get_storedir(c, self.as_ptr(), Some(copy_nix_str), (&raw mut out).cast())744		})?;745		let p = Utf8PathBuf::from(out);746		assert!(p.is_absolute());747		Ok(p)748	}749750	fn as_ptr(&self) -> *mut c_store {751		self.0752	}753}754impl Drop for Store {755	fn drop(&mut self) {756		unsafe { store_free(self.0) }757	}758}759760#[repr(transparent)]761pub struct EvalState(*mut c_eval_state);762unsafe impl Send for EvalState {}763unsafe impl Sync for EvalState {}764765impl Drop for EvalState {766	fn drop(&mut self) {767		unsafe {768			state_free(self.0);769		}770	}771}772773pub struct FlakeReference(*mut flake_reference);774impl FlakeReference {775	#[instrument(name = "new-flake-reference", skip(flake, parse, fetch))]776	pub fn new(777		s: &str,778		flake: &FlakeSettings,779		parse: &FlakeReferenceParseFlags,780		fetch: &FetchSettings,781	) -> Result<(Self, String)> {782		let mut out = null_mut();783		let mut fragment = String::new();784		// let fetch_settings = fetcher_settings;785		with_default_context(|c, _| unsafe {786			flake_reference_and_fragment_from_string(787				c,788				fetch.0,789				flake.0,790				parse.0,791				s.as_ptr().cast(),792				s.len(),793				&mut out,794				Some(copy_nix_str),795				(&raw mut fragment).cast(),796			)797		})?;798		assert!(!out.is_null());799800		Ok((Self(out), fragment))801	}802	#[instrument(name = "lock-flake", skip(self, fetch, flake, lock))]803	pub fn lock(804		&mut self,805		fetch: &FetchSettings,806		flake: &FlakeSettings,807		lock: &FlakeLockFlags,808	) -> Result<LockedFlake> {809		with_default_context(|c, es| unsafe { flake_lock(c, fetch.0, flake.0, es, lock.0, self.0) })810			.map(LockedFlake)811	}812}813unsafe impl Send for FlakeReference {}814unsafe impl Sync for FlakeReference {}815816pub struct LockedFlake(*mut locked_flake);817impl LockedFlake {818	pub fn get_attrs(&self, settings: &mut FlakeSettings) -> Result<Value> {819		with_default_context(|c, es| unsafe {820			locked_flake_get_output_attrs(c, settings.0, es, self.0)821		})822		.map(Value)823	}824}825unsafe impl Send for LockedFlake {}826unsafe impl Sync for LockedFlake {}827impl Drop for LockedFlake {828	fn drop(&mut self) {829		unsafe {830			locked_flake_free(self.0);831		};832	}833}834835type FieldName = [u8; 64];836fn init_field_name(v: &str) -> FieldName {837	let mut f = [0; 64];838	assert!(v.len() < 64, "max field name is 63 chars");839	assert!(840		v.bytes().all(|v| v != 0),841		"nul bytes are unsupported in field name"842	);843	f[0..v.len()].copy_from_slice(v.as_bytes());844	f845}846847pub struct RealisedString(*mut realised_string);848impl fmt::Debug for RealisedString {849	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {850		self.as_str().fmt(f)851	}852}853854impl RealisedString {855	pub fn as_str(&self) -> &str {856		let len = unsafe { realised_string_get_buffer_size(self.0) };857		let data: *const u8 = unsafe { realised_string_get_buffer_start(self.0) }.cast();858		let data = unsafe { slice::from_raw_parts(data, len) };859		std::str::from_utf8(data).expect("non-utf8 strings not supported")860	}861	pub fn path_count(&self) -> usize {862		unsafe { realised_string_get_store_path_count(self.0) }863	}864	pub fn path(&self, i: usize) -> String {865		assert!(i < self.path_count());866		let path = unsafe { realised_string_get_store_path(self.0, i) };867		let mut err_out = String::new();868		unsafe { store_path_name(path, Some(copy_nix_str), (&raw mut err_out).cast()) };869		err_out870	}871}872873unsafe impl Send for RealisedString {}874impl Drop for RealisedString {875	fn drop(&mut self) {876		unsafe { realised_string_free(self.0) }877	}878}879880#[repr(transparent)]881pub struct Value(*mut value);882883unsafe impl Send for Value {}884unsafe impl Sync for Value {}885886pub trait AsFieldName {887	fn as_field_name<T>(&self, v: impl FnOnce(FieldName) -> Result<T>) -> Result<T>;888	fn to_field_name(&self) -> Result<String>;889}890impl AsFieldName for Value {891	fn as_field_name<T>(&self, v: impl FnOnce(FieldName) -> Result<T>) -> Result<T> {892		let f = self.to_string()?;893		v(init_field_name(&f))894	}895	fn to_field_name(&self) -> Result<String> {896		self.to_string()897	}898}899impl<E> AsFieldName for E900where901	E: AsRef<str>,902{903	fn as_field_name<T>(&self, v: impl FnOnce(FieldName) -> Result<T>) -> Result<T> {904		let f = self.as_ref();905		v(init_field_name(f))906	}907	fn to_field_name(&self) -> Result<String> {908		Ok(self.as_ref().to_owned())909	}910}911912struct AttrsBuilder(*mut c_bindings_builder);913impl AttrsBuilder {914	fn new(capacity: usize) -> Self {915		with_default_context(|c, es| unsafe { make_bindings_builder(c, es, capacity) })916			.map(Self)917			.expect("alloc should not fail")918	}919	fn insert(&mut self, k: &impl AsFieldName, v: Value) {920		k.as_field_name(|name| {921			with_default_context(|c, _| unsafe {922				bindings_builder_insert(c, self.0, name.as_ptr().cast(), v.0);923				// bindings_builder_insert doesn't do incref924			})925		})926		.expect("builder insert shouldn't fail");927	}928}929impl Drop for AttrsBuilder {930	fn drop(&mut self) {931		unsafe { bindings_builder_free(self.0) };932	}933}934935struct ListBuilder(*mut c_list_builder, c_uint);936impl ListBuilder {937	fn new(capacity: usize) -> Self {938		with_default_context(|c, es| unsafe { make_list_builder(c, es, capacity) })939			.map(|l| Self(l, 0))940			.expect("alloc should not fail")941	}942}943impl ListBuilder {944	fn push(&mut self, v: Value) {945		with_default_context(|c, _| unsafe {946			list_builder_insert(947				c,948				self.0,949				{950					let v = self.1;951					self.1 += 1;952					v953				},954				v.0,955			)956		})957		.expect("list insert shouldn't fail");958	}959}960impl Drop for ListBuilder {961	fn drop(&mut self) {962		unsafe { list_builder_free(self.0) };963	}964}965966impl Value {967	pub fn new_primop(v: NativeFn) -> Self {968		let out = Self::new_uninit();969		with_default_context(|c, _| unsafe { init_primop(c, out.0, v.0) })970			.expect("primop initialization should not fail");971		out972	}973	pub fn new_attrs(v: HashMap<&str, Value>) -> Self {974		let out = Self::new_uninit();975		let mut b = AttrsBuilder::new(v.len());976		for (k, v) in v {977			b.insert(&k, v);978		}979		with_default_context(|c, _| unsafe { make_attrs(c, out.0, b.0) })980			.expect("attrs initialization should not fail");981982		out983	}984	fn new_list<T: Into<Self>>(v: Vec<T>) -> Self {985		let out = Self::new_uninit();986		let mut b = ListBuilder::new(v.len());987		for v in v {988			b.push(v.into());989		}990		with_default_context(|c, _| unsafe { make_list(c, b.0, out.0) })991			.expect("list initialization should not fail");992993		out994	}995	fn new_uninit() -> Self {996		let out = with_default_context(|c, es| unsafe { alloc_value(c, es) })997			.expect("value allocation should not fail");998		Self(out)999	}1000	pub fn new_str(v: &str) -> Self {1001		let s = CString::new(v).expect("string should not contain NULs");1002		let out = Self::new_uninit();1003		// String is copied, `s` is free to be dropped1004		with_default_context(|c, _| unsafe { init_string(c, out.0, s.as_ptr()) })1005			.expect("string initialization should not fail");1006		out1007	}1008	pub fn new_int(i: i64) -> Self {1009		let out = Self::new_uninit();1010		with_default_context(|c, _| unsafe { init_int(c, out.0, i) })1011			.expect("int initialization should not fail");1012		out1013	}1014	pub fn new_bool(v: bool) -> Self {1015		let out = Self::new_uninit();1016		with_default_context(|c, _| unsafe { init_bool(c, out.0, v) })1017			.expect("bool initialization should not fail");1018		out1019	}1020	// TODO: As far as I can see, there is no way to get Thunks from nix public C api, so this function is useless1021	// fn force(&mut self, st: &mut EvalState) -> Result<()> {1022	// 	with_default_context(|c, _| unsafe { value_force(c, st.0, self.0) })?;1023	// 	Ok(())1024	// }1025	pub fn type_of(&self) -> NixType {1026		let ty = with_default_context(|c, _| unsafe { get_type(c, self.0) })1027			.expect("get_type should not fail");1028		NixType::from_int(ty)1029	}1030	fn builtin_to_string(&self) -> Result<Self> {1031		let builtin = Self::eval("builtins.toString")?;1032		builtin.call(self.clone())1033	}1034	fn force(&mut self, s: *mut nix_raw::EvalState) -> Result<()> {1035		with_default_context(|c, _| unsafe { value_force(c, s, self.0) })?;1036		Ok(())1037	}1038	pub fn to_string(&self) -> Result<String> {1039		let ty = self.type_of();1040		if !matches!(ty, NixType::String) {1041			bail!("unexpected type: {ty:?}, expected string");1042		}1043		let mut str_out = String::new();1044		with_default_context(|c, _| unsafe {1045			get_string(c, self.0, Some(copy_nix_str), (&raw mut str_out).cast())1046		})?;10471048		Ok(str_out)1049	}1050	pub fn to_realised_string(&self) -> Result<RealisedString> {1051		with_default_context(|c, es| unsafe { string_realise(c, es, self.0, false) })1052			.map(RealisedString)10531054		// let store_paths = unsafe { nix_raw::realised_string_get_store_path_count(str) };1055		// for i in 0..store_paths {1056		// 	let store_path = unsafe { nix_raw::realised_string_get_store_path(str, i) };1057		// 	nix_raw::store_path_name(store_path, callback, user_data);1058		// }1059		// dbg!(store_paths);1060		// todo!();1061	}10621063	pub fn has_field(&self, field: &str) -> Result<bool> {1064		if !matches!(self.type_of(), NixType::Attrs) {1065			bail!("invalid type: expected attrs");1066		}10671068		let f = init_field_name(field);1069		with_default_context(|c, es| unsafe { has_attr_byname(c, self.0, es, f.as_ptr().cast()) })1070	}1071	// pub fn derivation_path(&self) {1072	// 	nix_raw::real1073	// }1074	pub fn list_fields(&self) -> Result<Vec<String>> {1075		if !matches!(self.type_of(), NixType::Attrs) {1076			bail!("invalid type: expected attrs");1077		}10781079		let len = with_default_context(|c, _| unsafe { get_attrs_size(c, self.0) })?;1080		let mut out = Vec::with_capacity(len as usize);10811082		for i in 0..len {1083			let name =1084				with_default_context(|c, es| unsafe { get_attr_name_byidx(c, self.0, es, i) })?;1085			let c = unsafe { CStr::from_ptr(name) };1086			out.push(c.to_str().expect("nix field names are utf-8").to_owned());1087		}1088		Ok(out)1089	}1090	pub fn get_elem(&self, v: usize) -> Result<Self> {1091		if !matches!(self.type_of(), NixType::List) {1092			bail!("invalid type: expected list");1093		}1094		let len = with_default_context(|c, _| unsafe { get_list_size(c, self.0) })? as usize;1095		if v >= len {1096			bail!("oob list get: {v} >= {len}");1097		}10981099		with_default_context(|c, es| unsafe { get_list_byidx(c, self.0, es, v as u32) }).map(Self)1100	}1101	pub fn attrs_update(self, other: Value /*, ignore_errors: bool*/) -> Result<Self> {1102		let attrs_update_fn = Self::eval("a: b: a // b")?;11031104		attrs_update_fn1105			.call(self)?1106			.call(other)1107			.context("attrs update")1108	}1109	pub fn get_field(&self, name: impl AsFieldName) -> Result<Self> {1110		if !matches!(self.type_of(), NixType::Attrs) {1111			bail!("invalid type: expected attrs");1112		}11131114		name.as_field_name(|name| {1115			with_default_context(|c, es| unsafe {1116				get_attr_byname(c, self.0, es, name.as_ptr().cast())1117			})1118			.map(Self)1119		})1120		.with_context(|| format!("getting field {:?}", name.to_field_name()))1121	}1122	pub fn call(&self, v: Value) -> Result<Self> {1123		let kind = self1124			.functor_kind()1125			.ok_or_else(|| anyhow!("can only call function or functor"))?;11261127		let function = match kind {1128			FunctorKind::Function => self.clone(),1129			FunctorKind::Functor => {1130				let f = self1131					.get_field("__functor")1132					.context("getting functor value")?;1133				assert_eq!(1134					f.type_of(),1135					NixType::Function,1136					"invalid functor encountered"1137				);1138				f1139			}1140		};11411142		let out = Value::new_uninit();1143		with_default_context(|c, es| unsafe { value_call(c, es, function.0, v.0, out.0) })?;11441145		Ok(out)1146	}1147	pub fn eval(v: &str) -> Result<Self> {1148		let s = CString::new(v).expect("expression shouldn't have internal NULs");1149		let out = Self::new_uninit();1150		with_default_context(|c, es| unsafe {1151			expr_eval_from_string(c, es, s.as_ptr(), c"/root".as_ptr(), out.0)1152		})?;1153		Ok(out)1154	}1155	#[instrument(name = "build", skip(self), fields(output))]1156	pub fn build(&self, output: &str) -> Result<Utf8PathBuf> {1157		if !self.is_derivation() {1158			bail!("expected derivation to build")1159		}1160		let output_name = self1161			.get_field("outputName")1162			.context("getting output name field")?1163			.to_string()?;1164		let v = if output_name != output {1165			let out = self.get_field(output).context("getting target output")?;1166			if !out.is_derivation() {1167				bail!("unknown output: {output}");1168			}1169			out1170		} else {1171			self.clone()1172		};11731174		let drv_path = Utf8PathBuf::from(1175			v.get_field("drvPath")1176				.context("getting drvPath")?1177				.to_string()?,1178		);1179		let graph = Arc::new(drv::DrvGraph::resolve(&eval_store(), &drv_path)?);1180		let _guard = logging::register_build_graph(&Span::current(), &graph);11811182		scheduler::build_graph_sync(graph.clone(), vec![output.to_owned()])?;11831184		let s = v.builtin_to_string()?;1185		let rs = s.to_realised_string()?;1186		let out_path = rs.as_str().to_owned();1187		Ok(Utf8PathBuf::from(out_path))1188	}1189	pub fn as_json<T: DeserializeOwned>(&self) -> Result<T> {1190		let to_json = Self::eval("builtins.toJSON")?;1191		let s = to_json.call(self.clone())?.to_string()?;1192		Ok(serde_json::from_str(&s)?)1193	}1194	pub fn serialized<T: Serialize>(v: &T) -> Result<Self> {1195		Self::eval(&nixlike::serialize(v)?)1196	}11971198	// Convert to string/evaluate derivations/etc1199	// fn to_string_weak(&self) -> Result<String> {1200	// 	// TODO: For now, it works exactly like to_string, see the comment for fn force()1201	// 	self.to_string()1202	// }12031204	fn is_derivation(&self) -> bool {1205		if !matches!(self.type_of(), NixType::Attrs) {1206			return false;1207		}1208		let Some(ty) = self.get_field("type").ok() else {1209			return false;1210		};1211		matches!(ty.to_string().as_deref(), Ok("derivation"))1212	}1213	fn functor_kind(&self) -> Option<FunctorKind> {1214		match self.type_of() {1215			NixType::Attrs => self1216				.has_field("__functor")1217				.expect("has_field shouldn't fail for attrs")1218				.then_some(FunctorKind::Functor),1219			NixType::Function => Some(FunctorKind::Function),1220			_ => None,1221		}1222	}1223	pub fn is_function(&self) -> bool {1224		self.functor_kind().is_some()1225	}1226	pub fn is_null(&self) -> bool {1227		matches!(self.type_of(), NixType::Null)1228	}1229	pub fn is_string(&self) -> bool {1230		matches!(self.type_of(), NixType::String)1231	}1232	pub fn is_attrs(&self) -> bool {1233		matches!(self.type_of(), NixType::Attrs)1234	}1235}12361237impl From<String> for Value {1238	fn from(value: String) -> Self {1239		Value::new_str(&value)1240	}1241}1242impl From<bool> for Value {1243	fn from(value: bool) -> Self {1244		Value::new_bool(value)1245	}1246}1247impl From<&str> for Value {1248	fn from(value: &str) -> Self {1249		Value::new_str(value)1250	}1251}1252impl<T> From<Vec<T>> for Value1253where1254	T: Into<Value>,1255{1256	fn from(value: Vec<T>) -> Self {1257		Value::new_list(value)1258	}1259}12601261impl Clone for Value {1262	fn clone(&self) -> Self {1263		with_default_context(|c, _| unsafe { value_incref(c, self.0) })1264			.expect("value incref should not fail");1265		Self(self.0)1266	}1267}1268impl Drop for Value {1269	fn drop(&mut self) {1270		with_default_context(|c, _| unsafe { value_decref(c, self.0) })1271			.expect("value drop should not fail");1272	}1273}12741275static TOKIO_FOR_NIX: OnceLock<Arc<tokio::runtime::Runtime>> = OnceLock::new();12761277pub fn init_libraries() {1278	unsafe { GC_allow_register_threads() };12791280	let mut ctx = NixContext::new();1281	ctx.run_in_context(|c| unsafe { libutil_init(c) })1282		.expect("util init should not fail");1283	ctx.run_in_context(|c| unsafe { libstore_init(c) })1284		.expect("store init should not fail");1285	ctx.run_in_context(|c| unsafe { libexpr_init(c) })1286		.expect("expr init should not fail");12871288	nix_logging_cxx::apply_tracing_logger();1289}12901291pub fn init_tokio_for_nix(tokio: Arc<tokio::runtime::Runtime>) {1292	TOKIO_FOR_NIX1293		.set(tokio)1294		.expect("tokio for nix should only be initialized once");1295}12961297pub fn await_in_nix<F: Send + 'static>(f: impl Future<Output = F> + Send + 'static) -> F {1298	// It should be possible to do Handle::current(), but some of the planned features don't work well with that1299	let runtime = TOKIO_FOR_NIX1300		.get()1301		.expect("init_tokio_for_nix was not called");1302	std::thread::spawn(move || runtime.block_on(f))1303		.join()1304		.expect("await_in_nix inner thread panicked")1305}13061307unsafe extern "C" fn nix_primop_closure_adapter<const N: usize>(1308	user_data: *mut c_void,1309	mut context: *mut c_context,1310	state: *mut nix_raw::EvalState,1311	args: *mut *mut value,1312	ret: *mut value,1313) {1314	let user_closure: &UserClosure<N> = unsafe { &*user_data.cast_const().cast() };1315	let args: [&Value; N] = array::from_fn(|i| {1316		let v: &mut Value = unsafe { &mut *args.add(i).cast() };1317		v as &Value1318	});1319	let ctx: &mut NixContext = unsafe { transmute(&mut context) };13201321	let state: &EvalState = unsafe { std::mem::transmute(&state) };13221323	match user_closure(state, args) {1324		Ok(v) => {1325			unsafe { copy_value(context, ret, v.0) };1326		}1327		Err(e) => {1328			ctx.set_err(e);1329		}1330	}1331}13321333type UserClosure<const N: usize> = Box<dyn Fn(&EvalState, [&Value; N]) -> Result<Value>>;13341335pub struct NativeFn(*mut PrimOp);1336impl NativeFn {1337	pub fn new<const N: usize>(1338		name: &'static CStr,1339		doc: &'static CStr,1340		args: [&'static CStr; N],1341		f: impl Fn(&EvalState, [&Value; N]) -> Result<Value> + 'static,1342	) -> Self {1343		// Double-boxing to make it thin pointer, as vtable gets outside of first Box1344		let closure: Box<UserClosure<N>> = Box::new(Box::new(f));1345		let f: PrimOpFun = Some(nix_primop_closure_adapter::<N>);1346		let mut args = args.into_iter().map(|v| v.as_ptr()).collect_vec();1347		args.push(null());1348		let args = args.as_mut_ptr();1349		let primop = unsafe {1350			alloc_primop(1351				null_mut(),1352				f,1353				N as i32,1354				name.as_ptr(),1355				args,1356				doc.as_ptr(),1357				Box::into_raw(closure).cast(),1358			)1359		};13601361		assert!(!primop.is_null(), "primop allocation should not fail");13621363		Self(primop)1364	}1365	pub fn register(self) {1366		unsafe { register_primop(null_mut(), self.0) };1367	}1368}13691370pub struct StorePath(*mut c_store_path);1371impl StorePath {1372	fn as_ptr(&self) -> *mut c_store_path {1373		self.01374	}1375}13761377impl Drop for StorePath {1378	fn drop(&mut self) {1379		unsafe { store_path_free(self.0) }1380	}1381}13821383#[test_log::test]1384fn test_native() -> Result<()> {1385	init_libraries();1386	NativeFn::new(1387		c"__uppercaseSuffix2",1388		c"make string uppercase and add suffix",1389		[c"str", c"suffix"],1390		|_, [str, suffix]: [&Value; 2]| {1391			let str = str.to_string()?;1392			let suffix = suffix.to_string()?;1393			Ok(Value::new_str(&format!("{}{suffix}", str.to_uppercase())))1394		},1395	)1396	.register();13971398	let mut fetch_settings = FetchSettings::new();1399	fetch_settings.set(c"warn-dirty", c"false");14001401	let manifest = format!("git+file://{}/../../", env!("CARGO_MANIFEST_DIR"));1402	let flake = FlakeSettings::new()?;1403	let parse = FlakeReferenceParseFlags::new(&flake)?;1404	let (mut r, _) = FlakeReference::new(&manifest, &flake, &parse, &fetch_settings)?;1405	let lock = FlakeLockFlags::new(&flake)?;1406	let locked = r.lock(&fetch_settings, &flake, &lock)?;1407	let attrs = locked.get_attrs(&mut FlakeSettings::new()?)?;14081409	let builtins = Value::eval("builtins")?;1410	assert_eq!(builtins.type_of(), NixType::Attrs);14111412	assert_eq!(attrs.type_of(), NixType::Attrs);1413	let test_data = nix_go!(attrs.testData);14141415	let test_string: String = nix_go_json!(test_data.testString);1416	assert_eq!(test_string, "hello");14171418	let s = nix_go!(attrs.packages["x86_64-linux"].fleet.drvPath);1419	let s = CString::new(s.to_string()?).expect("path str is cstring");14201421	let uppercase_suffix = Value::new_primop(NativeFn::new(1422		c"uppercase_suffix",1423		c"make string uppercase and add suffix",1424		[c"str", c"suffix"],1425		|es, [str, suffix]: [&Value; 2]| {1426			let str = str.to_string()?;1427			let suffix = suffix.to_string()?;1428			Ok(Value::new_str(&format!("{}{suffix}", str.to_uppercase())))1429		},1430	));14311432	let test_result: String = nix_go_json!(test_data.testPrimop(uppercase_suffix));1433	assert_eq!(test_result, "PREFIX_BODY_SUFFIX");1434	let test_result: String = nix_go_json!(builtins.uppercaseSuffix2("test")("suffix"));1435	assert_eq!(test_result, "TESTsuffix");14361437	let drv_path =1438		nix_go!(attrs.packages["x86_64-linux"]["fleet-install-secrets"].drvPath).to_string()?;1439	let graph = drv::DrvGraph::resolve(&drv_path)?;1440	eprintln!(1441		"fleet-install-secrets dependency graph: {} nodes",1442		graph.nodes.len()1443	);1444	for (path, node) in &graph.nodes {1445		if !node.input_drvs.is_empty() {1446			eprintln!("  {} ({} deps)", node.name, node.input_drvs.len());1447		}1448	}14491450	Ok(())1451}14521453// pub struct GcAlloc;1454// unsafe impl GlobalAlloc for GcAlloc {1455// 	unsafe fn alloc(&self, l: Layout) -> *mut u8 {1456// 		let ptr = unsafe { GC_malloc(l.size()) };1457// 		ptr.cast()1458// 	}1459// 	unsafe fn dealloc(&self, ptr: *mut u8, _: Layout) {1460// 		// unsafe { GC_free(ptr.cast()) };1461// 	}1462//1463// 	unsafe fn realloc(&self, ptr: *mut u8, _: Layout, new_size: usize) -> *mut u8 {1464// 		let ptr = unsafe { GC_realloc(ptr.cast(), new_size) };1465// 		ptr.cast()1466// 	}1467// }1468//1469// #[global_allocator]1470// static GC: GcAlloc = GcAlloc;
modifieddocs/features/usbd.adocdiffbeforeafterboth
--- a/docs/features/usbd.adoc
+++ b/docs/features/usbd.adoc
@@ -2,24 +2,24 @@
 
 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.
+fleet-usbd is a simple update daemon, which watches for a USB stick with the system update to be inserted into the machine, finds its manifest on it, copies the closure from the USB stick, switches the current system generation, renames the manifest to *.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.
+2. Daemon scans usbd-update-* files and tries to decrypt each; the one encrypted to this host's key is its manifest (the daemon has no naming secret, so it cannot derive its manifest name directly). The system name inside is checked as well.
 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.
+6. Manifest is renamed to *.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.
+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 (naming secret, narHash); the naming secret is project-wide, writer-side state in the file referenced by the `usbd.namingSecretFile` option (relative to the project directory, generated on first write). Hosts never need it - each manifest carries the keys for its paths - and because it is shared across hosts, a store path used by two hosts maps to the same file name and key, so shared sticks dedup naturally. 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.
@@ -33,7 +33,7 @@
 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.
+5. The daemon holds a temporary GC root on every update path for the duration of the copy (temp roots protect not-yet-valid paths individually, so intermediate paths are covered before the toplevel becomes reachable), 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.
 
@@ -41,6 +41,15 @@
 
 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.
 
+Layout:
+
+/usbd-update-<derive(hostname)>:: age-encrypted manifest for one host, renamed to *.done after a successful update. One manifest per host, so several hosts can share a stick.
+/update-data/<xx>/<name>:: encrypted path chunks under derived names, sharded into 256 subdirectories by the first two name characters.
+/gc/<derive(hostname)>:: plaintext list of chunk names referenced by that host's update (bare names, the shard prefix is implied). The writer refreshes its own list, then deletes only data files referenced by no list, so several hosts' updates can share one stick without the writers deleting each other's files.
+/logs/<timestamp>-<random>.export.gpg:: journal dump in `journalctl -o export` format, written by the daemon after every run when `logRecipientFile` is set. Encrypted with OpenPGP to the operator key, so it can be decrypted with a smartcard. The random suffix avoids collisions between hosts sharing a stick (the daemon cannot derive its host id, and a plaintext hostname would leak).
+
+FAT32 allows at most 65536 directory entries per directory; a 32-character name costs 4 entries (3 LFN + 1 short), so a flat directory would cap out at ~16000 files. The 256-way shard gives each subdirectory its own budget (~4M files total), and as a side effect keeps the volume root nearly empty, so even a FAT16-formatted stick with its fixed 512-entry root directory works.
+
 == 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.
@@ -57,6 +66,14 @@
     signPublicKeys = [ "client-1:..." ];
     # Optional user feedback script: receives copying/switching/done/noop/failed
     # hook = ./usbd-hook.sh;
+    # Optional builtin feedback: blink connected Nitrokey 3 devices while working.
+    # Blinking stopped and the machine rebooted - success; stopped without
+    # a reboot - failed, send the stick back (it carries the encrypted logs).
+    # The blink is the standard CTAPHID wink: white, firmware-fixed duration,
+    # no colors - the nk3 admin app exposes nothing else.
+    # nk3Wink = true;
+    # Optional journal back-channel, encrypted to this OpenPGP key
+    # logRecipientFile = ./operator.asc;
   };
 }
 ----
@@ -68,16 +85,33 @@
 [source,shell]
 ----
 mkfs.vfat -n FLEETUSBD /dev/sdX1
-fleet usbd write HOSTNAME --sign-key ./client-1.secret
+fleet usbd write HOSTNAME
 ----
 
-The signing keypair is generated once with `nix key generate-secret --key-name client-1`.
+The signing keypair is generated once with `nix key generate-secret --key-name client-1`; the secret key file is referenced from the fleet configuration (or overridden with `--sign-key`):
+
+[source,nix]
+----
+{
+  hosts.HOSTNAME.usbd.signingKeyFiles = [ "client-1.secret" ];
+}
+----
+
+Paths are resolved relative to the fleet project directory and deliberately are not nix paths, so the secret key is never imported into the store.
+
+Reading a journal dump from the stick:
 
+[source,shell]
+----
+gpg -d logs/2026-07-07-10-10-20-1a2b.export.gpg | systemd-journal-remote -o /tmp/metis.journal -
+journalctl --file /tmp/metis.journal -b
+----
+
 == 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.
+Encrypted journal on the host:: journald has no built-in journal encryption (FSS sealing is tamper-evidence only), which is why logs are exported and encrypted on the way to the stick instead.
 GC of old generations:: not a daemon problem.
-Multi-host sticks:: conflicts with incremental stick rewrite; one stick per host for now.
+Hiding cross-host linkage:: the naming secret is shared project-wide for dedup, so an observer of a shared stick can tell which files two hosts have in common (not what they are). Per-host secrets would hide this at the cost of duplicating shared paths; not a concern for the current clients.
modifiedflake.nixdiffbeforeafterboth
--- a/flake.nix
+++ b/flake.nix
@@ -175,6 +175,7 @@
 
                 pkg-config
                 openssl
+                udev
                 rustPlatform.bindgenHook
                 inputs'.nix.packages.nix-expr-c
                 inputs'.nix.packages.nix-flake-c
modifiedmodules/module-list.nixdiffbeforeafterboth
--- a/modules/module-list.nix
+++ b/modules/module-list.nix
@@ -6,4 +6,5 @@
   ./nixos.nix
   ./nixpkgs.nix
   ./secrets.nix
+  ./usbd.nix
 ]
modifiedmodules/nixos/usbd.nixdiffbeforeafterboth
--- a/modules/nixos/usbd.nix
+++ b/modules/nixos/usbd.nix
@@ -47,6 +47,24 @@
         failed <error>.
       '';
     };
+    nk3Wink = mkOption {
+      type = types.bool;
+      default = false;
+      description = ''
+        Blink connected Nitrokey 3 devices while an update is running.
+        Blinking stopped and the machine rebooted - update succeeded;
+        blinking stopped without a reboot - update failed.
+      '';
+    };
+    logRecipientFile = mkOption {
+      type = types.nullOr types.path;
+      default = null;
+      description = ''
+        Armored OpenPGP public key. When set, the daemon dumps the journal
+        of the last 48 hours onto the stick after every run, encrypted to
+        this key.
+      '';
+    };
     extraArgs = mkOption {
       type = types.listOf types.str;
       default = [ ];
@@ -64,6 +82,10 @@
         deviceUnit
         "local-fs.target"
       ];
+      path = [
+        pkgs.gnupg
+        config.systemd.package
+      ];
       serviceConfig = {
         Type = "oneshot";
         ExecStartPre = "-${pkgs.dosfstools}/bin/fsck.vfat -a ${devicePath}";
@@ -77,6 +99,11 @@
             "--hook"
             cfg.hook
           ]
+          ++ optionals cfg.nk3Wink [ "--nk3-wink" ]
+          ++ optionals (cfg.logRecipientFile != null) [
+            "--log-recipient-file"
+            cfg.logRecipientFile
+          ]
           ++ cfg.extraArgs
         );
       };
addedmodules/usbd.nixdiffbeforeafterboth
--- /dev/null
+++ b/modules/usbd.nix
@@ -0,0 +1,55 @@
+{
+  lib,
+  fleetLib,
+  config,
+  ...
+}:
+let
+  inherit (lib.options) mkOption literalExpression;
+  inherit (lib.types) listOf str submodule;
+  inherit (fleetLib.options) mkHostsOption;
+
+  _file = ./usbd.nix;
+
+  usbdOptions = {
+    signingKeyFiles = mkOption {
+      description = ''
+        Nix secret key files `fleet usbd write` signs the update closure with,
+        resolved relative to the fleet project directory.
+        Plain strings instead of paths, so the keys are not imported into the nix store.
+      '';
+      type = listOf str;
+      default = [ ];
+    };
+  };
+in
+{
+  options = {
+    usbd = usbdOptions // {
+      namingSecretFile = mkOption {
+        description = ''
+          File with the project-wide naming secret used to derive on-stick file
+          names and encryption keys, resolved relative to the fleet project
+          directory. Generated by `fleet usbd write` when missing.
+          A plain string instead of a path, so the secret is not imported into
+          the nix store.
+        '';
+        type = str;
+        default = "usbd/naming.secret";
+      };
+    };
+    hosts = mkHostsOption {
+      inherit _file;
+      options.usbd = mkOption {
+        description = ''
+          fleet-usbd stick writing configuration for this host.
+        '';
+        type = submodule { options = usbdOptions; };
+        default = {
+          inherit (config.usbd) signingKeyFiles;
+        };
+        defaultText = literalExpression "{ inherit (fleetConfiguration.usbd) signingKeyFiles; }";
+      };
+    };
+  };
+}
modifiedpkgs/fleet-usbd.nixdiffbeforeafterboth
--- a/pkgs/fleet-usbd.nix
+++ b/pkgs/fleet-usbd.nix
@@ -6,6 +6,7 @@
   stdenv,
   pkg-config,
   rustPlatform,
+  udev,
 }:
 let
   system = stdenv.hostPlatform.system;
@@ -28,6 +29,7 @@
     inputs.nix.packages.${system}.nix-expr-c
     inputs.nix.packages.${system}.nix-flake-c
     inputs.nix.packages.${system}.nix-fetchers-c
+    udev
   ];
   nativeBuildInputs = [
     pkg-config