diff options
| author | tv <tv@krebsco.de> | 2026-11-13 16:23:34 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-13 16:23:34 +0100 |
| commit | 50a2b8f9628bacdc8f13848e98b909e7be44c640 (patch) | |
| tree | 3ecc002e39c7cd7147f4a49d7c3e874fa4b344dd /pkgs/populate/src | |
| parent | 4afd7d2793e85f260a2a3c20a423f0d1a4844ea7 (diff) | |
populate: add a Rust implementation, selectable for the tests
pkgs.populate takes implementation: "shell", the default, generates a
script from populate's arguments, and "rust" passes them as JSON to the
program in pkgs/populate, built from the crate there by program.nix.
The program checks the spec as checks.nix does during evaluation, with the
same messages, and reports that delivering sources isn't implemented yet.
rustProgram replaces the program, e.g. with one built some other way.
checks.nix holds the checks of populate's arguments during evaluation.
Both implementations check the names of sources and the target path with
it, and the Rust one's wrapper also checks drawing inputs, which the shell
implementation checks where it draws them.
nix-build tests --argstr implementation rust runs the scenarios with the
Rust implementation. They expect it to refuse copying flakes to targets
using sudo when it runs, not during evaluation, because only then it
decides whether the target is local.
Diffstat (limited to 'pkgs/populate/src')
| -rw-r--r-- | pkgs/populate/src/check.rs | 209 | ||||
| -rw-r--r-- | pkgs/populate/src/main.rs | 55 | ||||
| -rw-r--r-- | pkgs/populate/src/spec.rs | 121 |
3 files changed, 385 insertions, 0 deletions
diff --git a/pkgs/populate/src/check.rs b/pkgs/populate/src/check.rs new file mode 100644 index 0000000..875d852 --- /dev/null +++ b/pkgs/populate/src/check.rs @@ -0,0 +1,209 @@ +//! Checks of the spec, the same as checks.nix makes during evaluation, so a +//! spec from anywhere else gets checked as well. Messages are the same. + +use crate::spec::{Source, Spec}; +use std::collections::BTreeMap; + +/// The spec's checks in the order checks.nix makes them, the first failing +/// one's message. +pub fn spec(spec: &Spec) -> Result<(), String> { + target_path(&spec.target.path)?; + for name in spec.source.keys() { + source_name(name)?; + } + inputs(&spec.source) +} + +/// Like JSON, the way Nix' toJSON quotes strings in messages. +fn quote(s: &str) -> String { + serde_json::to_string(s).expect("strings serialize") +} + +/// Populate replaces and removes what's at the target paths of sources, so +/// it refuses names that lead out of the target directory, to the directory +/// itself, or to its own state. The remaining names are portable file +/// names. +pub fn source_name(name: &str) -> Result<(), String> { + let portable = |c: char| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-'; + let ok = !matches!(name, "." | ".." | ".populate") + && !name.is_empty() + && !name.starts_with('-') + && name.chars().all(portable); + if ok { + Ok(()) + } else { + Err(format!( + "krops: source name {} can't be used: it must consist of letters, digits, and the characters ._-, not start with -, and be neither ., .., nor .populate", + quote(name) + )) + } +} + +/// Populate removes what it finds in the target directory, so it refuses +/// paths that aren't absolute, that are the root directory, or that the +/// target would resolve to another directory than the one they name. +pub fn target_path(path: &str) -> Result<(), String> { + let fail = |why: &str| { + Err(format!( + "krops: target path {} can't be used: {why}", + quote(path) + )) + }; + let components: Vec<&str> = path + .split('/') + .filter(|c| !c.is_empty() && *c != ".") + .collect(); + if !path.starts_with('/') { + fail("it must be absolute") + } else if components.is_empty() { + fail("it must not be the root directory") + } else if components.contains(&"..") || path.chars().any(|c| c.is_ascii_control()) { + fail("it must not contain .. or control characters") + } else { + Ok(()) + } +} + +/// Inputs get drawn from other sources, so they must exist, and drawing +/// must not go round in circles. +pub fn inputs(sources: &BTreeMap<String, Source>) -> Result<(), String> { + let drawn_from: BTreeMap<&str, Vec<String>> = sources + .iter() + .map(|(name, source)| { + ( + name.as_str(), + source.follows().into_iter().map(|f| f.source).collect(), + ) + }) + .collect(); + for (name, froms) in &drawn_from { + for from in froms { + if from == name { + return Err(format!("krops: source {name} draws inputs from itself")); + } + if !sources.contains_key(from) { + return Err(format!( + "krops: source {name} draws inputs from missing source {from}" + )); + } + } + } + // Depth-first search, with the path of sources on the way, which repeats + // a source if drawing goes round in circles. + fn visit<'a>( + name: &'a str, + drawn_from: &'a BTreeMap<&str, Vec<String>>, + done: &mut Vec<&'a str>, + path: &mut Vec<&'a str>, + ) -> Result<(), String> { + if let Some(i) = path.iter().position(|p| *p == name) { + return Err(format!( + "krops: sources draw inputs from each other: {}", + path[i..].join(" ") + )); + } + if done.contains(&name) { + return Ok(()); + } + path.push(name); + for from in &drawn_from[name] { + visit(from, drawn_from, done, path)?; + } + path.pop(); + done.push(name); + Ok(()) + } + let mut done = Vec::new(); + for name in drawn_from.keys() { + visit(name, &drawn_from, &mut done, &mut Vec::new())?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_names() { + for name in ["a", "A.b_c-d", ".x", "_", "0"] { + assert!(source_name(name).is_ok(), "{name:?}"); + } + for name in [ + "", + ".", + "..", + ".populate", + "a/b", + "../x", + "/etc", + "a\nb", + "a b", + "a*b", + "a?b", + "a+b", + "a=b", + "-a", + ] { + assert!(source_name(name).is_err(), "{name:?}"); + } + } + + #[test] + fn target_paths() { + for path in ["/a", "/var/src", "/a/./b", "//a"] { + assert!(target_path(path).is_ok(), "{path:?}"); + } + for path in [ + "", + "/", + "//", + "/.", + "relative", + "/var/krops/..", + "/var/krops/../..", + "/var/krops/a\nb", + ] { + assert!(target_path(path).is_err(), "{path:?}"); + } + assert_eq!( + target_path("/a\nb").unwrap_err(), + r#"krops: target path "/a\nb" can't be used: it must not contain .. or control characters"# + ); + } + + fn sources(json: &str) -> BTreeMap<String, Source> { + serde_json::from_str(json).unwrap() + } + + #[test] + fn drawing_inputs() { + let ok = sources( + r#"{ + "a": {"type": "file", "file": {"path": "/a", "inputs": {"x": {"follows": "b"}}}}, + "b": {"type": "file", "file": {"path": "/b"}} + }"#, + ); + assert!(inputs(&ok).is_ok()); + let missing = sources( + r#"{ + "a": {"type": "file", "file": {"path": "/a", "inputs": {"x": {"follows": "c"}}}} + }"#, + ); + assert_eq!( + inputs(&missing).unwrap_err(), + "krops: source a draws inputs from missing source c" + ); + let cycle = sources( + r#"{ + "a": {"type": "file", "file": {"path": "/a", "inputs": {"x": {"follows": "b"}}}}, + "b": {"type": "file", "file": {"path": "/b", + "inputs": {"y": {"inputs": {"z": {"follows": "a"}}}}}} + }"#, + ); + assert_eq!( + inputs(&cycle).unwrap_err(), + "krops: sources draw inputs from each other: a b" + ); + } +} diff --git a/pkgs/populate/src/main.rs b/pkgs/populate/src/main.rs new file mode 100644 index 0000000..a095b74 --- /dev/null +++ b/pkgs/populate/src/main.rs @@ -0,0 +1,55 @@ +//! populate SPEC: delivers sources to a target directory, as the spec, a +//! JSON file or - for standard input, says. See docs/design.md. + +mod check; +mod spec; + +use std::io::Read; +use std::process::ExitCode; + +/// Exit status when the spec can't be read, isn't JSON, or isn't a spec. +const USAGE: u8 = 2; + +fn main() -> ExitCode { + let args: Vec<String> = std::env::args().skip(1).collect(); + let [path] = args.as_slice() else { + eprintln!("usage: populate SPEC"); + return ExitCode::from(USAGE); + }; + let text = if path == "-" { + let mut text = String::new(); + std::io::stdin().read_to_string(&mut text).map(|_| text) + } else { + std::fs::read_to_string(path) + }; + let text = match text { + Ok(text) => text, + Err(e) => { + eprintln!("populate: can't read {path}: {e}"); + return ExitCode::from(USAGE); + } + }; + let version = serde_json::from_str::<serde_json::Value>(&text) + .ok() + .and_then(|value| value.get("version").and_then(|v| v.as_u64())); + if let Some(version) = version.filter(|v| *v != spec::VERSION) { + eprintln!( + "populate: {path} is a spec of version {version}, this populate reads version {}", + spec::VERSION + ); + return ExitCode::from(USAGE); + } + let spec: spec::Spec = match serde_json::from_str(&text) { + Ok(spec) => spec, + Err(e) => { + eprintln!("populate: {path} isn't a spec: {e}"); + return ExitCode::from(USAGE); + } + }; + if let Err(message) = check::spec(&spec) { + eprintln!("{message}"); + return ExitCode::FAILURE; + } + eprintln!("populate: delivering sources isn't implemented yet"); + ExitCode::FAILURE +} diff --git a/pkgs/populate/src/spec.rs b/pkgs/populate/src/spec.rs new file mode 100644 index 0000000..089d330 --- /dev/null +++ b/pkgs/populate/src/spec.rs @@ -0,0 +1,121 @@ +//! The spec: populate's arguments as JSON, as docs/design.md describes them. + +// Delivering sources isn't implemented yet, so most of the spec is unused. +#![allow(dead_code)] + +use serde::Deserialize; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; + +/// The version of the spec this populate reads. +pub const VERSION: u64 = 1; + +/// The source types populate knows. +pub const SOURCE_TYPES: &[&str] = &[ + "derivation", + "file", + "flake", + "flake-input", + "git", + "pass", + "passage", + "pipe", + "symlink", +]; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Spec { + pub version: u64, + #[serde(default)] + pub backup: bool, + #[serde(default)] + pub force: bool, + #[serde(default)] + pub prune: bool, + pub source: BTreeMap<String, Source>, + pub target: Target, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Target { + #[serde(default)] + pub user: Option<String>, + pub host: String, + #[serde(default)] + pub port: Option<String>, + pub path: String, + #[serde(default)] + pub sudo: bool, + #[serde(default)] + pub extra_options: Vec<String>, +} + +/// A source: its type, and the settings of that type, under the type's name. +#[derive(Debug)] +pub struct Source { + pub kind: String, + pub settings: Map<String, Value>, +} + +impl<'de> Deserialize<'de> for Source { + fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { + use serde::de::Error; + let mut object = Map::deserialize(deserializer)?; + let kind = match object.remove("type") { + Some(Value::String(kind)) => kind, + Some(_) => return Err(D::Error::custom("type must be a string")), + None => return Err(D::Error::missing_field("type")), + }; + if !SOURCE_TYPES.contains(&kind.as_str()) { + return Err(D::Error::custom(format!("unknown source type {kind:?}"))); + } + let settings = match object.remove(&kind) { + Some(Value::Object(settings)) => settings, + Some(_) => return Err(D::Error::custom(format!("{kind} must be an object"))), + None => return Err(D::Error::missing_field("settings named after the type")), + }; + if let Some(other) = object.keys().next() { + return Err(D::Error::custom(format!( + "unknown field {other:?} of a {kind} source" + ))); + } + Ok(Source { kind, settings }) + } +} + +/// An input drawn from another source: the path of input names of the flake +/// in the source drawing it, and the name of the source it's drawn from. +#[derive(Debug, PartialEq)] +pub struct Follows { + pub path: Vec<String>, + pub source: String, +} + +impl Source { + /// The inputs this source draws from other sources, like krops' lib's + /// flakeFollows. + pub fn follows(&self) -> Vec<Follows> { + fn walk(prefix: &[String], inputs: &Map<String, Value>, out: &mut Vec<Follows>) { + for (name, input) in inputs { + let mut path = prefix.to_vec(); + path.push(name.clone()); + if let Some(Value::String(source)) = input.get("follows") { + out.push(Follows { + path: path.clone(), + source: source.clone(), + }); + } + if let Some(Value::Object(inputs)) = input.get("inputs") { + walk(&path, inputs, out); + } + } + } + let mut out = Vec::new(); + if let Some(Value::Object(inputs)) = self.settings.get("inputs") { + walk(&[], inputs, &mut out); + } + out + } +} |
