aboutsummaryrefslogtreecommitdiffstats
path: root/pkgs/populate/src
diff options
context:
space:
mode:
Diffstat (limited to 'pkgs/populate/src')
-rw-r--r--pkgs/populate/src/check.rs209
-rw-r--r--pkgs/populate/src/main.rs55
-rw-r--r--pkgs/populate/src/spec.rs121
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
+ }
+}