aboutsummaryrefslogtreecommitdiffstats
path: root/pkgs/populate/src/flake_input.rs
diff options
context:
space:
mode:
Diffstat (limited to 'pkgs/populate/src/flake_input.rs')
-rw-r--r--pkgs/populate/src/flake_input.rs777
1 files changed, 777 insertions, 0 deletions
diff --git a/pkgs/populate/src/flake_input.rs b/pkgs/populate/src/flake_input.rs
new file mode 100644
index 0000000..0084689
--- /dev/null
+++ b/pkgs/populate/src/flake_input.rs
@@ -0,0 +1,777 @@
+//! Flake and flake-input sources: delivering a flake, or one input of a
+//! flake as its lock file pins it, as a link into the store, with garbage
+//! collector roots for its own inputs.
+
+use crate::flake::derives_flake;
+use crate::rsync::{self, Config};
+use crate::run::{self, Context, Failure, Result};
+use crate::sh;
+use crate::spec::Source;
+use serde_json::{Map, Value};
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::process::{Command, Stdio};
+
+/// What a flake-input source delivers: the flake itself, or an input
+/// relative to it, which the target can't fetch on its own, or what
+/// fetchTree takes as locked, and the subdirectory containing the flake.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Selection {
+ pub is_self: bool,
+ pub locked: Option<Value>,
+ pub subdir: String,
+}
+
+impl Selection {
+ fn to_json(&self) -> String {
+ serde_json::json!({
+ "self": self.is_self,
+ "locked": self.locked,
+ "subdir": self.subdir,
+ })
+ .to_string()
+ }
+}
+
+/// The subdirectory a flake reference or lock entry names, as attribute
+/// dir or as query parameter dir of its URL, depending on the version of
+/// Nix. fetchTree supports neither.
+fn subdir(reference: &Value) -> String {
+ if let Some(dir) = reference.get("dir").and_then(Value::as_str) {
+ return dir.to_string();
+ }
+ let url = reference.get("url").and_then(Value::as_str).unwrap_or("");
+ query_dir(url)
+ .map(|(_, dir, _)| dir.to_string())
+ .unwrap_or_default()
+}
+
+/// The first query parameter dir in url: where its separator is, its
+/// value, and where it ends, including a following &.
+fn query_dir(url: &str) -> Option<(usize, &str, usize)> {
+ let bytes = url.as_bytes();
+ (0..bytes.len()).find_map(|i| {
+ if (bytes[i] == b'?' || bytes[i] == b'&') && url[i + 1..].starts_with("dir=") {
+ let start = i + 5;
+ let len = url[start..].find('&').unwrap_or(url.len() - start);
+ let end = start + len;
+ let end_with_amp = if url[end..].starts_with('&') {
+ end + 1
+ } else {
+ end
+ };
+ Some((i, &url[start..end], end_with_amp))
+ } else {
+ None
+ }
+ })
+}
+
+/// The reference without its subdirectory, for fetchTree.
+fn without_subdir(reference: &Value) -> Value {
+ let mut reference = reference.clone();
+ if let Value::Object(object) = &mut reference {
+ object.remove("__final");
+ object.remove("dir");
+ if let Some(Value::String(url)) = object.get_mut("url") {
+ if let Some((i, _, end)) = query_dir(url) {
+ let separator = url[i..i + 1].to_string();
+ url.replace_range(i..end, &separator);
+ }
+ if url.ends_with('?') || url.ends_with('&') {
+ url.pop();
+ }
+ }
+ }
+ reference
+}
+
+/// The subdirectory a node's locked or original reference names.
+fn subdir_of(node: &Value) -> String {
+ ["locked", "original"]
+ .iter()
+ .filter_map(|key| node.get(key))
+ .filter(|reference| !reference.is_null())
+ .map(subdir)
+ .find(|dir| !dir.is_empty())
+ .unwrap_or_default()
+}
+
+/// Selects the flake or input in nix flake metadata's output by the path
+/// of input names input, following the flake's lock file, follows
+/// included. An empty follows path removes an input. Errors say what's
+/// missing.
+pub fn select(metadata: &Value, input: &[&str]) -> std::result::Result<Selection, String> {
+ let locks = &metadata["locks"];
+ let root = locks["root"].as_str().unwrap_or("root");
+ let nodes = &locks["nodes"];
+ fn reference<'a>(
+ nodes: &'a Value,
+ root: &'a str,
+ path: &[&str],
+ depth: usize,
+ ) -> std::result::Result<String, String> {
+ if depth > 64 {
+ return Err("follows nest too deep".to_string());
+ }
+ let mut key = root.to_string();
+ for name in path {
+ let next = &nodes[&key]["inputs"][name];
+ key = match next {
+ Value::String(next) => next.clone(),
+ Value::Array(follows) if !follows.is_empty() => {
+ let follows: Vec<&str> = follows.iter().filter_map(Value::as_str).collect();
+ reference(nodes, root, &follows, depth + 1)?
+ }
+ _ => return Err(format!("no input {name}")),
+ };
+ }
+ Ok(key)
+ }
+ fn join(a: &str, b: &str) -> String {
+ match (a.is_empty(), b.is_empty()) {
+ (true, _) => b.to_string(),
+ (_, true) => a.to_string(),
+ _ => format!("{a}/{b}"),
+ }
+ }
+ fn resolve(
+ metadata: &Value,
+ nodes: &Value,
+ root: &str,
+ key: &str,
+ depth: usize,
+ ) -> std::result::Result<Selection, String> {
+ let node = &nodes[key];
+ if depth > 64 {
+ return Err("parents nest too deep".to_string());
+ }
+ if key == root {
+ return Ok(Selection {
+ is_self: true,
+ locked: None,
+ subdir: subdir_of(metadata),
+ });
+ }
+ if let Some(parent) = node.get("parent").and_then(Value::as_array) {
+ let parent: Vec<&str> = parent.iter().filter_map(Value::as_str).collect();
+ let base = resolve(
+ metadata,
+ nodes,
+ root,
+ &reference(nodes, root, &parent, 0)?,
+ depth + 1,
+ )?;
+ let path = node["locked"]["path"].as_str().unwrap_or("");
+ let path = path.strip_prefix("./").unwrap_or(path);
+ let path = path.strip_suffix('/').unwrap_or(path);
+ let path = if path == "." { "" } else { path };
+ let subdir = join(&join(&base.subdir, path), &subdir_of(node));
+ return Ok(Selection { subdir, ..base });
+ }
+ Ok(Selection {
+ is_self: false,
+ locked: Some(without_subdir(&node["locked"])),
+ subdir: subdir_of(node),
+ })
+ }
+ let key = reference(nodes, root, input, 0)?;
+ resolve(metadata, nodes, root, &key, 0)
+}
+
+/// Whether the target can fetch the flake itself, i.e. whether its locked
+/// reference doesn't refer to the deploying machine.
+pub fn self_is_remote(metadata: &Value) -> bool {
+ let locked = &metadata["locked"];
+ locked["type"].as_str() != Some("path")
+ && !locked["url"].as_str().unwrap_or("").starts_with("file:")
+}
+
+/// The store paths in nix flake archive's output, but not those of inputs
+/// at the paths overridden, nor what's below them. Inputs given by
+/// relative paths are part of their parent.
+pub fn archived_paths(archive: &Value, overridden: &[Vec<String>]) -> Vec<String> {
+ fn walk(node: &Value, prefix: &[String], overridden: &[Vec<String>], out: &mut Vec<String>) {
+ let Some(inputs) = node.get("inputs").and_then(Value::as_object) else {
+ return;
+ };
+ for (name, input) in inputs {
+ let mut path = prefix.to_vec();
+ path.push(name.clone());
+ if overridden.contains(&path) {
+ continue;
+ }
+ if let Some(store_path) = input.get("path").and_then(Value::as_str) {
+ out.push(store_path.to_string());
+ }
+ walk(input, &path, overridden, out);
+ }
+ }
+ let mut out = Vec::new();
+ walk(archive, &[], overridden, &mut out);
+ out
+}
+
+/// The hexadecimal SHA-256 hash in a narHash like sha256-<base64>.
+fn nar_hash_hex(hash: &str) -> Option<String> {
+ let encoded = hash.strip_prefix("sha256-")?;
+ let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ let mut bits = 0u32;
+ let mut count = 0;
+ let mut out = String::new();
+ for byte in encoded.bytes().filter(|b| *b != b'=') {
+ let value = alphabet.iter().position(|a| *a == byte)? as u32;
+ bits = (bits << 6) | value;
+ count += 6;
+ if count >= 8 {
+ count -= 8;
+ out += &format!("{:02x}", (bits >> count) & 0xff);
+ }
+ }
+ Some(out)
+}
+
+/// A Nix command with flakes enabled.
+fn nix(args: &[&str]) -> Command {
+ let mut command = Command::new("nix");
+ command
+ .args(["--extra-experimental-features", "nix-command flakes"])
+ .args(args);
+ command
+}
+
+/// Whether the store on the deploying machine has path.
+fn valid_here(path: &str) -> bool {
+ Command::new("nix-store")
+ .args(["--check-validity", path])
+ .stderr(Stdio::null())
+ .status()
+ .is_ok_and(|status| status.success())
+}
+
+/// The shell code of snippet for the target, with the definitions it uses,
+/// enabling flakes for what if given.
+fn on_target(snippet: &str, flakes: Option<&str>) -> String {
+ let require = flakes
+ .map(|what| format!("require_flakes {}\n", run::quote(what)))
+ .unwrap_or_default();
+ format!(
+ "{}{}{require}{snippet}",
+ sh::prelude(),
+ sh::flake::REMOVE_UPLOAD
+ )
+}
+
+fn setting<'a>(settings: &'a Map<String, Value>, key: &str) -> Option<&'a Value> {
+ settings.get(key)
+}
+
+fn flag(settings: &Map<String, Value>, key: &str, default: bool) -> bool {
+ setting(settings, key)
+ .and_then(Value::as_bool)
+ .unwrap_or(default)
+}
+
+/// Delivers the flake or flake-input source named name to path, preparing
+/// what it needs on the deploying machine in workdir.
+pub fn deliver(
+ context: &Context,
+ name: &str,
+ path: &str,
+ source: &Source,
+ workdir: &Path,
+) -> Result<()> {
+ let settings = &source.settings;
+ let url = setting(settings, "url")
+ .and_then(Value::as_str)
+ .unwrap_or_default();
+ let input = if source.kind == "flake" {
+ None
+ } else {
+ setting(settings, "name").and_then(Value::as_str)
+ };
+ let input_path: Vec<&str> = input.map(|i| i.split('/').collect()).unwrap_or_default();
+ let fetch_on_target = flag(settings, "fetchOnTarget", false);
+ let dir = path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
+ let sentinel = format!("{dir}/.populate");
+ let as_flake = Source {
+ kind: "flake".into(),
+ settings: settings.clone(),
+ };
+ // A source that gets patched, gets its lock file rewritten, or is
+ // materialized gets linked in .populate/derived/<name> instead of at
+ // the target path, and the flakes step derives what the target path
+ // gets.
+ let derived = derives_flake(&as_flake);
+ let link_path = if derived {
+ format!("{sentinel}/derived/{name}/original")
+ } else {
+ path.to_string()
+ };
+ let overridden: Vec<Vec<String>> = as_flake.follows().into_iter().map(|f| f.path).collect();
+ let require_inputs = flag(settings, "requireInputs", true);
+ let hint = if input.is_some() {
+ ". Set its requireInputs to false to deliver it anyway."
+ } else {
+ ""
+ };
+ let failure = |message: String| Failure::Message(message);
+
+ upload_patches(context, name, &sentinel, settings, workdir)?;
+
+ // The store paths of the flake or input, followed by the inputs its
+ // flake in subdir pins in its own lock file, archived where script
+ // runs.
+ let closure = |store_path: &str, subdir: &str, on: &dyn Fn(&str, &[&str]) -> Result<String>| {
+ let overrides: Vec<String> = overridden.iter().map(|p| p.join("/")).collect();
+ let mut args = vec![
+ store_path,
+ subdir,
+ if require_inputs { "1" } else { "" },
+ name,
+ hint,
+ ];
+ args.extend(overrides.iter().map(String::as_str));
+ let archive = on(sh::flake::ARCHIVE, &args)?;
+ let mut paths = vec![store_path.to_string()];
+ if !archive.trim().is_empty() {
+ let archive: Value = serde_json::from_str(&archive).map_err(|e| {
+ failure(format!(
+ "error: can't read what nix flake archive prints: {e}"
+ ))
+ })?;
+ paths.extend(archived_paths(&archive, &overridden));
+ }
+ Ok::<_, Failure>(paths)
+ };
+ let link = |subdir: &str, paths: &[String]| -> Result<()> {
+ let link_name = context.name(&link_path);
+ let mut args = vec![
+ sentinel.as_str(),
+ name,
+ &link_path,
+ &link_name,
+ if derived { "1" } else { "" },
+ subdir,
+ ];
+ args.extend(paths.iter().map(String::as_str));
+ context.run(&on_target(sh::flake::LINK, None), &args)
+ };
+ let on_target_output = |what: &'static str| {
+ move |snippet: &str, args: &[&str]| context.output(&on_target(snippet, Some(what)), args)
+ };
+
+ let is_remote_ref = {
+ let scheme: String = url
+ .chars()
+ .take_while(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '+')
+ .collect();
+ !scheme.is_empty()
+ && url[scheme.len()..].starts_with(':')
+ && ![
+ "file",
+ "flake",
+ "git+file",
+ "hg+file",
+ "path",
+ "tarball+file",
+ ]
+ .contains(&scheme.as_str())
+ };
+
+ // The target fetches the flake itself on its own.
+ if fetch_on_target && input.is_none() && is_remote_ref {
+ let fetching = "fetching flakes on the target";
+ let metadata = context.output(
+ &on_target(
+ "nix flake metadata --json --no-update-lock-file \"$1\"\n",
+ Some(fetching),
+ ),
+ &[url],
+ )?;
+ let metadata: Value = serde_json::from_str(&metadata)
+ .map_err(|e| failure(format!("error: can't read flake metadata of {url}: {e}")))?;
+ let store_path = metadata["path"].as_str().unwrap_or_default();
+ let subdir = subdir_of(&metadata);
+ let paths = closure(store_path, &subdir, &on_target_output(fetching))?;
+ return link(&subdir, &paths);
+ }
+
+ let metadata = context.flake_metadata(url, || {
+ run::output(&mut nix(&[
+ "flake",
+ "metadata",
+ "--json",
+ "--no-update-lock-file",
+ url,
+ ]))
+ })?;
+ let no_input = || {
+ failure(format!(
+ "error: flake {url} has no input {}",
+ input.unwrap_or("")
+ ))
+ };
+ let mut selected = select(&metadata, &input_path).map_err(|_| no_input())?;
+
+ let mut self_path = String::new();
+ if selected.is_self {
+ if fetch_on_target && self_is_remote(&metadata) {
+ // The flake itself has no lock entry, so the target fetches it
+ // by its locked reference.
+ selected = Selection {
+ is_self: false,
+ locked: Some(without_subdir(&metadata["locked"])),
+ ..selected
+ };
+ } else {
+ self_path = metadata["path"].as_str().unwrap_or_default().to_string();
+ if fetch_on_target && !context.local {
+ upload_self(context, name, &sentinel, &self_path)?;
+ }
+ }
+ }
+
+ // The store path the lock file pins what's selected to, which is the
+ // one of a directory named source with the locked hash. A store that
+ // has it needs no fetching, which contacts where the input comes from
+ // even then.
+ let mut expected = String::new();
+ let nar_hash = selected
+ .locked
+ .as_ref()
+ .and_then(|locked| locked.get("narHash"))
+ .and_then(Value::as_str)
+ .and_then(nar_hash_hex);
+ if let Some(hex) = nar_hash {
+ expected = run::output(Command::new("nix-store").args([
+ "--print-fixed-path",
+ "--recursive",
+ "sha256",
+ &hex,
+ "source",
+ ]))?
+ .trim_end()
+ .to_string();
+ }
+
+ if fetch_on_target {
+ let fetching = "fetching flake inputs on the target";
+ let fetched = context.output(
+ &on_target(sh::flake::FETCH, Some(fetching)),
+ &[
+ &sentinel,
+ name,
+ url,
+ input.unwrap_or(""),
+ &selected.to_json(),
+ &self_path,
+ &expected,
+ ],
+ )?;
+ let (store_path, subdir) = fetched.split_once('\n').unwrap_or((&fetched, ""));
+ let paths = closure(store_path, subdir, &on_target_output(fetching))?;
+ return link(subdir, &paths);
+ }
+
+ let store_path = if !self_path.is_empty() {
+ self_path
+ } else if !expected.is_empty() && valid_here(&expected) {
+ expected
+ } else {
+ let locked = selected.locked.clone().unwrap_or(Value::Null).to_string();
+ run::output(
+ nix(&[
+ "eval",
+ "--impure",
+ "--raw",
+ "--expr",
+ "(builtins.fetchTree (builtins.fromJSON (builtins.getEnv \"LOCKED\"))).outPath",
+ ])
+ .env("LOCKED", locked),
+ )?
+ };
+ let here = |snippet: &str, args: &[&str]| {
+ let mut command = run::local_shell(snippet, args, workdir);
+ command.env("NIX_CONFIG", nix_config());
+ run::output(&mut command)
+ };
+ let paths = closure(&store_path, &selected.subdir, &here)?;
+ if !context.local {
+ copy(context, workdir, &paths)?;
+ }
+ link(&selected.subdir, &paths)
+}
+
+/// NIX_CONFIG with flakes enabled.
+fn nix_config() -> String {
+ let config = std::env::var("NIX_CONFIG").unwrap_or_default();
+ format!("{config}\nextra-experimental-features = nix-command flakes")
+}
+
+/// Copies store paths to the target with nix copy, which runs ssh from
+/// PATH, so that's the ssh for Nix's ssh:// store there.
+fn copy(context: &Context, workdir: &Path, paths: &[String]) -> Result<()> {
+ let bin = workdir.join("bin");
+ let link = bin.join("ssh");
+ let prepare = || -> std::io::Result<()> {
+ fs::create_dir_all(&bin)?;
+ if fs::symlink_metadata(&link).is_ok() {
+ fs::remove_file(&link)?;
+ }
+ std::os::unix::fs::symlink(&context.ssh_nix, &link)
+ };
+ prepare()
+ .map_err(|e| Failure::Message(format!("error: can't prepare {}: {e}", bin.display())))?;
+ let path = std::env::var_os("PATH").unwrap_or_default();
+ let path =
+ std::env::join_paths(std::iter::once(bin.clone()).chain(std::env::split_paths(&path)))
+ .map_err(|e| Failure::Message(format!("error: can't extend PATH: {e}")))?;
+ let mut command = nix(&["copy", "--to", &format!("ssh://{}", context.target.host)]);
+ command.args(paths).env("PATH", path).stdout(run::stderr());
+ run::status(&mut command)
+}
+
+/// Uploads the source's patches to .populate/patches/<name>, numbered in
+/// order.
+fn upload_patches(
+ context: &Context,
+ name: &str,
+ sentinel: &str,
+ settings: &Map<String, Value>,
+ workdir: &Path,
+) -> Result<()> {
+ let patches: Vec<&str> = settings
+ .get("patches")
+ .and_then(Value::as_array)
+ .into_iter()
+ .flatten()
+ .filter_map(Value::as_str)
+ .collect();
+ if patches.is_empty() {
+ return Ok(());
+ }
+ let dir = workdir.join("patches");
+ let prepare = || -> std::io::Result<()> {
+ if dir.exists() {
+ fs::remove_dir_all(&dir)?;
+ }
+ fs::create_dir(&dir)?;
+ for (i, patch) in patches.iter().enumerate() {
+ let base = Path::new(patch)
+ .file_name()
+ .unwrap_or_default()
+ .to_string_lossy();
+ fs::copy(patch, dir.join(format!("{:04}-{base}", i + 1)))?;
+ }
+ Ok(())
+ };
+ prepare().map_err(|e| Failure::Message(format!("error: can't copy patches: {e}")))?;
+ context.run(&on_target(sh::flake::PREPARE, None), &[sentinel, name, "1"])?;
+ let config = Config {
+ filter_files: false,
+ use_checksum: true,
+ ..Config::default()
+ };
+ rsync::transfer(
+ context,
+ &config,
+ &dir.to_string_lossy(),
+ &format!("{sentinel}/patches/{name}"),
+ )
+}
+
+/// Uploads the flake itself to a target that can't fetch it, unless the
+/// target's store has it already.
+fn upload_self(context: &Context, name: &str, sentinel: &str, self_path: &str) -> Result<()> {
+ if context.run(sh::flake::VALID, &[self_path]).is_ok() {
+ return Ok(());
+ }
+ run::check_signal()?;
+ context.run(&on_target(sh::flake::PREPARE, None), &[sentinel, name, ""])?;
+ // Without applying .rsync-filter files, which would change the flake.
+ let config = Config {
+ filter_files: false,
+ ..Config::default()
+ };
+ let upload: PathBuf = Path::new(sentinel)
+ .join("flake-uploads")
+ .join(name)
+ .join("tree");
+ rsync::transfer(context, &config, self_path, &upload.to_string_lossy())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ /// A flake with an input dep, which has an input inner following the
+ /// flake's input inner, and an input sub, a flake in a subdirectory.
+ fn metadata(locked: Value, original: Value) -> Value {
+ json!({
+ "locked": { "type": "path", "path": "/src/top", "narHash": "sha256-top" },
+ "path": "/nix/store/top-source",
+ "locks": {
+ "root": "root",
+ "version": 7,
+ "nodes": {
+ "root": { "inputs": { "dep": "dep", "inner": "inner", "sub": "sub" } },
+ "dep": {
+ "inputs": { "inner": [ "inner" ] },
+ "locked": { "type": "tarball", "url": "http://x/dep.tar.gz", "narHash": "sha256-dep" }
+ },
+ "inner": {
+ "locked": { "type": "tarball", "url": "http://x/inner.tar.gz", "narHash": "sha256-inner" }
+ },
+ "sub": { "locked": locked, "original": original }
+ }
+ }
+ })
+ }
+
+ fn attribute() -> Value {
+ metadata(
+ json!({ "type": "tarball", "url": "http://x/mono.tar.gz", "dir": "sub", "narHash": "sha256-mono" }),
+ json!({ "type": "tarball", "url": "http://x/mono.tar.gz", "dir": "sub" }),
+ )
+ }
+
+ fn mono() -> Selection {
+ Selection {
+ is_self: false,
+ locked: Some(
+ json!({ "type": "tarball", "url": "http://x/mono.tar.gz", "narHash": "sha256-mono" }),
+ ),
+ subdir: "sub".into(),
+ }
+ }
+
+ #[test]
+ fn selection() {
+ let url = metadata(
+ json!({ "type": "tarball", "url": "http://x/mono.tar.gz?dir=sub", "narHash": "sha256-mono" }),
+ json!({ "type": "tarball", "url": "http://x/mono.tar.gz?dir=sub" }),
+ );
+ assert_eq!(select(&attribute(), &["sub"]), Ok(mono()));
+ assert_eq!(select(&url, &["sub"]), Ok(mono()));
+ assert_eq!(
+ select(&attribute(), &[]),
+ Ok(Selection {
+ is_self: true,
+ locked: None,
+ subdir: String::new()
+ })
+ );
+ assert_eq!(
+ select(&attribute(), &["dep", "inner"]).map(|s| s.locked),
+ Ok(Some(
+ json!({ "type": "tarball", "url": "http://x/inner.tar.gz", "narHash": "sha256-inner" })
+ ))
+ );
+ assert_eq!(select(&attribute(), &["nope"]), Err("no input nope".into()));
+ }
+
+ #[test]
+ fn removed_and_cyclic_follows() {
+ let mut removed = attribute();
+ removed["locks"]["nodes"]["dep"]["inputs"]["inner"] = json!([]);
+ assert_eq!(
+ select(&removed, &["dep", "inner"]),
+ Err("no input inner".into())
+ );
+ let mut cyclic = attribute();
+ cyclic["locks"]["nodes"]["dep"]["inputs"]["inner"] = json!(["inner", "x"]);
+ cyclic["locks"]["nodes"]["inner"]["inputs"] = json!({ "x": ["dep", "inner"] });
+ assert_eq!(
+ select(&cyclic, &["dep", "inner"]),
+ Err("follows nest too deep".into())
+ );
+ }
+
+ #[test]
+ fn relative_path() {
+ let mut relative = attribute();
+ relative["locked"]["dir"] = json!("sub");
+ relative["locks"]["nodes"]["root"]["inputs"]["lib"] = json!("lib");
+ relative["locks"]["nodes"]["lib"] =
+ json!({ "locked": { "type": "path", "path": "./lib" }, "parent": [] });
+ assert_eq!(
+ select(&relative, &["lib"]),
+ Ok(Selection {
+ is_self: true,
+ locked: None,
+ subdir: "sub/lib".into()
+ })
+ );
+ }
+
+ #[test]
+ fn remote() {
+ let remote = |locked: Value| self_is_remote(&json!({ "locked": locked }));
+ assert!(!remote(json!({ "type": "path", "path": "/src" })));
+ assert!(!remote(json!({ "type": "git", "url": "file:///src" })));
+ assert!(remote(json!({ "type": "git", "url": "https://x/y" })));
+ assert!(remote(
+ json!({ "type": "github", "owner": "o", "repo": "r" })
+ ));
+ }
+
+ #[test]
+ fn locked_self() {
+ let locked = json!({ "__final": true, "type": "tarball", "url": "http://x/mono.tar.gz?dir=sub", "narHash": "sha256-mono" });
+ assert_eq!(Some(without_subdir(&locked)), mono().locked);
+ let url = json!({ "url": "http://x/y?rev=1&dir=sub&z=2" });
+ assert_eq!(
+ without_subdir(&url),
+ json!({ "url": "http://x/y?rev=1&z=2" })
+ );
+ }
+
+ #[test]
+ fn subdirs() {
+ let of = |m: Value| subdir_of(&m);
+ assert_eq!(
+ of(json!({
+ "locked": { "type": "tarball", "url": "http://x/mono.tar.gz?dir=sub&narHash=sha256-mono" },
+ "original": { "type": "tarball", "url": "http://x/mono.tar.gz?dir=sub" }
+ })),
+ "sub"
+ );
+ assert_eq!(
+ of(json!({
+ "locked": { "type": "tarball", "url": "http://x/mono.tar.gz" },
+ "original": { "type": "tarball", "url": "http://x/mono.tar.gz?rev=1&dir=sub" }
+ })),
+ "sub"
+ );
+ assert_eq!(
+ of(json!({ "locked": { "type": "path", "path": "/src/top" } })),
+ ""
+ );
+ }
+
+ #[test]
+ fn archive() {
+ let archive = json!({
+ "path": "/nix/store/top",
+ "inputs": {
+ "a": { "path": "/nix/store/a", "inputs": { "b": { "path": "/nix/store/b" } } },
+ "c": { "path": "/nix/store/c", "inputs": { "d": { "path": "/nix/store/d" } } },
+ "rel": { "inputs": {} }
+ }
+ });
+ assert_eq!(
+ archived_paths(&archive, &[vec!["c".into()]]),
+ vec!["/nix/store/a", "/nix/store/b"]
+ );
+ }
+
+ #[test]
+ fn nar_hashes() {
+ assert_eq!(
+ nar_hash_hex("sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=").as_deref(),
+ Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
+ );
+ }
+}