diff options
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 + } +} |
