diff options
Diffstat (limited to 'pkgs/populate/src')
| -rw-r--r-- | pkgs/populate/src/check.rs | 64 | ||||
| -rw-r--r-- | pkgs/populate/src/cli.rs | 212 | ||||
| -rw-r--r-- | pkgs/populate/src/main.rs | 65 | ||||
| -rw-r--r-- | pkgs/populate/src/run.rs | 89 | ||||
| -rw-r--r-- | pkgs/populate/src/spec.rs | 162 |
5 files changed, 501 insertions, 91 deletions
diff --git a/pkgs/populate/src/check.rs b/pkgs/populate/src/check.rs index cb67865..ebe5dbf 100644 --- a/pkgs/populate/src/check.rs +++ b/pkgs/populate/src/check.rs @@ -1,13 +1,16 @@ -//! 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. +//! Checks of the sources and the target, the same as checks.nix makes during +//! evaluation, so sources from anywhere else get checked as well. Messages +//! are the same. use crate::spec::{Source, Spec}; use serde_json::{Map, Value}; use std::collections::BTreeMap; -/// The spec's checks in the order checks.nix makes them, the first failing -/// one's message. +/// The checks in the order checks.nix makes them, the first failing one's +/// message. The target's host and user, which a command line gives, come +/// first. pub fn spec(spec: &Spec) -> Result<(), String> { + target_host(&spec.target.host, spec.target.user.as_deref())?; target_path(&spec.target.path)?; for (name, source) in &spec.source { source_name(name)?; @@ -16,6 +19,24 @@ pub fn spec(spec: &Spec) -> Result<(), String> { inputs(&spec.source) } +/// The target's host and user, which ssh gets as arguments, mustn't be +/// taken for its options, and the host can't be empty. +fn target_host(host: &str, user: Option<&str>) -> Result<(), String> { + if host.is_empty() || host.starts_with('-') { + return Err(format!( + "krops: target host {} can't be used: it must not be empty or start with -", + quote(host) + )); + } + if user.is_some_and(|u| u.starts_with('-')) { + return Err(format!( + "krops: target user {} can't be used: it must not start with -", + quote(user.unwrap_or_default()) + )); + } + Ok(()) +} + /// What a setting holds. #[derive(Clone, Copy)] enum Kind { @@ -365,14 +386,37 @@ mod tests { } #[test] + fn target_hosts() { + assert!(target_host("host", Some("root")).is_ok()); + assert!(target_host("2001:db8::1", None).is_ok()); + assert_eq!( + target_host("-oProxyCommand=x", None).unwrap_err(), + r#"krops: target host "-oProxyCommand=x" can't be used: it must not be empty or start with -"# + ); + assert!(target_host("", None).is_err()); + assert!(target_host("host", Some("-x")).is_err()); + } + + #[test] fn settings() { let spec = |file: &str| -> Spec { - serde_json::from_str(&format!( - r#"{{"version": 1, "target": {{"host": "h", "path": "/a"}}, - "source": {{"x": {{"type": "file", "file": {}}}}}}}"#, - file - )) - .unwrap() + Spec { + backup: false, + force: false, + prune: false, + source: serde_json::from_str(&format!( + r#"{{"x": {{"type": "file", "file": {file}}}}}"# + )) + .unwrap(), + target: crate::spec::Target { + user: None, + host: "h".to_string(), + port: None, + path: "/a".to_string(), + sudo: false, + extra_options: vec![], + }, + } }; assert!(super::spec(&spec(r#"{"path": "/src"}"#)).is_ok()); assert!(super::spec(&spec(r#"{"path": "--rsync-path=x"}"#)).is_err()); diff --git a/pkgs/populate/src/cli.rs b/pkgs/populate/src/cli.rs new file mode 100644 index 0000000..c059c0f --- /dev/null +++ b/pkgs/populate/src/cli.rs @@ -0,0 +1,212 @@ +//! populate's command line. + +use crate::spec::TargetParts; + +/// What the command line asks for. +#[derive(Debug, PartialEq)] +pub enum Command { + Help, + Version, + Populate(Options), +} + +/// The arguments of populating. +#[derive(Debug, Default, PartialEq)] +pub struct Options { + pub backup: bool, + pub force: bool, + pub prune: bool, + /// The file the sources are in, or - for standard input. + pub sources: String, + /// The target, as given, with the options changing its parts applied. + pub target: TargetParts, + pub sudo: bool, + pub ssh_args: Vec<String>, + /// Whether the target is the machine populate runs on, if --local or + /// --remote says so. + pub local: Option<bool>, +} + +pub const USAGE: &str = "\ +usage: populate [OPTION]... SOURCES [TARGET] + +Delivers the sources in the file SOURCES, a JSON object, or standard input +if SOURCES is -, to the target directory TARGET. + +TARGET is [USER@][HOST][:PORT][PATH]. USER defaults to $LOGNAME, HOST to +the name of the machine, and PATH to /var/src. A target is local if its +user is $LOGNAME, and its host is localhost or the name of the machine; +populate reaches other targets via ssh. + +Options: + --backup copy the target directory to a backup first + --force create the target directory's sentinel if it's missing + --prune remove what isn't a source from the target directory + --sudo deliver as root, via sudo on the target + --ssh-arg ARG pass ARG to ssh; repeatable + --user USER the target's user; empty for ssh's default + --host HOST the target's host + --port PORT the port ssh connects to + --path PATH the target directory + --local the target is the machine populate runs on + --remote the target isn't, even if it looks so + -h, --help print this and exit + -V, --version print populate's version and exit +"; + +/// The command line, without the program's name. Options and the other +/// arguments may come in any order, up to --. +pub fn parse(args: &[String]) -> Result<Command, String> { + let mut options = Options::default(); + let mut positional: Vec<String> = Vec::new(); + let mut overrides: Vec<(&'static str, String)> = Vec::new(); + let mut args = args.iter(); + while let Some(arg) = args.next() { + if arg == "--" { + positional.extend(args.by_ref().cloned()); + break; + } + if arg == "-" || !arg.starts_with('-') { + positional.push(arg.clone()); + continue; + } + let (name, inline) = match arg.split_once('=') { + Some((name, value)) if name.starts_with("--") => (name, Some(value.to_string())), + _ => (arg.as_str(), None), + }; + let mut value = |name: &str| -> Result<String, String> { + match &inline { + Some(value) => Ok(value.clone()), + None => args + .next() + .cloned() + .ok_or_else(|| format!("option {name} needs an argument")), + } + }; + match name { + "-h" | "--help" => return Ok(Command::Help), + "-V" | "--version" => return Ok(Command::Version), + "--backup" => options.backup = true, + "--force" => options.force = true, + "--prune" => options.prune = true, + "--sudo" => options.sudo = true, + "--local" => options.local = Some(true), + "--remote" => options.local = Some(false), + "--ssh-arg" => options.ssh_args.push(value(name)?), + "--user" => overrides.push(("user", value(name)?)), + "--host" => overrides.push(("host", value(name)?)), + "--port" => overrides.push(("port", value(name)?)), + "--path" => overrides.push(("path", value(name)?)), + _ => return Err(format!("unknown option {name}")), + } + if inline.is_some() + && matches!( + name, + "--backup" | "--force" | "--prune" | "--sudo" | "--local" | "--remote" + ) + { + return Err(format!("option {name} takes no argument")); + } + } + let (sources, target) = match positional.as_slice() { + [sources] => (sources.clone(), String::new()), + [sources, target] => (sources.clone(), target.clone()), + [] => return Err("SOURCES is missing".to_string()), + _ => return Err("too many arguments".to_string()), + }; + options.sources = sources; + options.target = TargetParts::parse(&target)?; + for (part, value) in overrides { + let target = &mut options.target; + match part { + "user" => target.user = Some(value), + "host" => target.host = Some(value), + "port" => target.port = Some(value), + _ => target.path = Some(value), + } + } + if let Some(path) = &options.target.path { + if path.is_empty() { + return Err("the target's path is empty".to_string()); + } + } + Ok(Command::Populate(options)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Result<Command, String> { + super::parse(&args.iter().map(|a| a.to_string()).collect::<Vec<_>>()) + } + + fn options(args: &[&str]) -> Options { + match parse(args) { + Ok(Command::Populate(options)) => options, + other => panic!("{other:?}"), + } + } + + #[test] + fn targets() { + let o = options(&["s.json", "root@host:2222/var/src"]); + assert_eq!(o.sources, "s.json"); + assert_eq!( + o.target, + TargetParts { + user: Some("root".to_string()), + host: Some("host".to_string()), + port: Some("2222".to_string()), + path: Some("/var/src".to_string()), + } + ); + assert_eq!(options(&["-"]).target, TargetParts::default()); + let o = options(&["--host", "2001:db8::1", "-", "root@x", "--port=22"]); + assert_eq!(o.target.user.as_deref(), Some("root")); + assert_eq!(o.target.host.as_deref(), Some("2001:db8::1")); + assert_eq!(o.target.port.as_deref(), Some("22")); + } + + #[test] + fn flags() { + let o = options(&[ + "--backup", + "--prune", + "--sudo", + "--ssh-arg", + "-o", + "--ssh-arg=X=y", + "--remote", + "s", + ]); + assert!(o.backup && o.prune && o.sudo && !o.force); + assert_eq!(o.ssh_args, ["-o", "X=y"]); + assert_eq!(o.local, Some(false)); + // After --, arguments are no options. + assert_eq!(options(&["--", "--sources"]).sources, "--sources"); + } + + #[test] + fn errors() { + assert_eq!(parse(&["--help", "--bogus"]), Ok(Command::Help)); + assert_eq!( + parse(&["--bogus"]), + Err("unknown option --bogus".to_string()) + ); + assert_eq!(parse(&[]), Err("SOURCES is missing".to_string())); + assert_eq!( + parse(&["a", "b", "c"]), + Err("too many arguments".to_string()) + ); + assert_eq!( + parse(&["s", "--user"]), + Err("option --user needs an argument".to_string()) + ); + assert_eq!( + parse(&["--prune=yes", "s"]), + Err("option --prune takes no argument".to_string()) + ); + assert!(parse(&["s", "host:"]).is_err()); + } +} diff --git a/pkgs/populate/src/main.rs b/pkgs/populate/src/main.rs index 2884fd0..eeed1ef 100644 --- a/pkgs/populate/src/main.rs +++ b/pkgs/populate/src/main.rs @@ -1,7 +1,9 @@ -//! populate SPEC: delivers sources to a target directory, as the spec, a -//! JSON file or - for standard input, says. See docs/design.md. +//! populate [OPTION]... SOURCES [TARGET]: delivers the sources in the JSON +//! file SOURCES, or standard input for -, to the target directory TARGET. +//! See README.md. mod check; +mod cli; mod flake; mod flake_input; mod populate; @@ -13,15 +15,36 @@ 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. +/// Exit status when the command line is wrong, or the sources can't be read +/// or aren't sources. 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 options = match cli::parse(&args) { + Ok(cli::Command::Help) => { + print!("{}", cli::USAGE); + return ExitCode::SUCCESS; + } + Ok(cli::Command::Version) => { + println!("populate {}", env!("CARGO_PKG_VERSION")); + return ExitCode::SUCCESS; + } + Ok(cli::Command::Populate(options)) => options, + Err(message) => { + eprintln!("populate: {message}"); + eprint!( + "{}", + cli::USAGE + .lines() + .next() + .map(|l| format!("{l}\n")) + .unwrap_or_default() + ); + return ExitCode::from(USAGE); + } }; + let path = &options.sources; let text = if path == "-" { let mut text = String::new(); std::io::stdin().read_to_string(&mut text).map(|_| text) @@ -35,29 +58,31 @@ fn main() -> ExitCode { 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, + let source: spec::Sources = match serde_json::from_str(&text) { + Ok(source) => source, Err(e) => { - eprintln!("populate: {path} isn't a spec: {e}"); + eprintln!("populate: {path} doesn't contain sources: {e}"); return ExitCode::from(USAGE); } }; + let origin = run::Origin::from_env(); + let target = options + .target + .complete(&origin, options.sudo, options.ssh_args); + let local = options.local.unwrap_or_else(|| origin.is_local(&target)); + let spec = spec::Spec { + backup: options.backup, + force: options.force, + prune: options.prune, + source, + target, + }; if let Err(message) = check::spec(&spec) { eprintln!("{message}"); return ExitCode::FAILURE; } run::trap_signals(); - let context = match run::Context::new(spec.target.clone(), spec.target.local) { + let context = match run::Context::new(spec.target.clone(), local) { Ok(context) => context, Err(failure) => return exit(failure), }; diff --git a/pkgs/populate/src/run.rs b/pkgs/populate/src/run.rs index a16ffe4..c9bd9fb 100644 --- a/pkgs/populate/src/run.rs +++ b/pkgs/populate/src/run.rs @@ -111,8 +111,7 @@ pub struct Context { impl Context { /// Creates the temporary directory, and the ssh script in it. - pub fn new(target: Target, local: Option<bool>) -> Result<Context> { - let local = local.unwrap_or_else(|| is_local(&target)); + pub fn new(target: Target, local: bool) -> Result<Context> { let output = Command::new("mktemp") .args(["-dt", "populate.XXXXXXXX"]) .stderr(Stdio::inherit()) @@ -358,37 +357,50 @@ pub fn output(command: &mut Command) -> Result<String> { } } -/// Whether the target is the machine populate runs on, as the user running -/// it, like krops' lib's isLocalTarget decides during evaluation. -fn is_local(target: &Target) -> bool { - let etc_hostname = fs::read_to_string("/etc/hostname").ok(); - is_local_with( - target, - std::env::var("LOGNAME").ok().as_deref(), - std::env::var("HOSTNAME").ok().as_deref(), - etc_hostname.as_deref(), - ) +/// The user running populate, and the name of the machine it runs on, like +/// krops' lib's mkTarget and isLocalTarget take them. +#[derive(Clone, Debug)] +pub struct Origin { + pub user: Option<String>, + pub host: String, } -/// is_local, given LOGNAME, HOSTNAME, and the contents of /etc/hostname. -/// Like lib.maybeEnv, an empty variable counts as unset. The user running -/// populate is LOGNAME, if set, and the machine's name is HOSTNAME, or else -/// the only line of /etc/hostname that is a label, or else localhost. The -/// target is local if its user is that user, and its host is that name or -/// localhost. -fn is_local_with( - target: &Target, - logname: Option<&str>, - hostname: Option<&str>, - etc_hostname: Option<&str>, -) -> bool { - let user = logname.filter(|v| !v.is_empty()); - let host = hostname - .filter(|v| !v.is_empty()) - .map(str::to_string) - .or_else(|| etc_hostname.and_then(host_name)) - .unwrap_or_else(|| "localhost".to_string()); - target.user.as_deref() == user && (target.host == "localhost" || target.host == host) +impl Origin { + /// From LOGNAME, HOSTNAME, and /etc/hostname. + pub fn from_env() -> Origin { + let etc_hostname = fs::read_to_string("/etc/hostname").ok(); + Origin::from( + std::env::var("LOGNAME").ok().as_deref(), + std::env::var("HOSTNAME").ok().as_deref(), + etc_hostname.as_deref(), + ) + } + + /// Given LOGNAME, HOSTNAME, and the contents of /etc/hostname. Like + /// lib.maybeEnv, an empty variable counts as unset. The user is + /// LOGNAME, if set, and the machine's name is HOSTNAME, or else the only + /// line of /etc/hostname that is a label, or else localhost. + pub fn from( + logname: Option<&str>, + hostname: Option<&str>, + etc_hostname: Option<&str>, + ) -> Origin { + Origin { + user: logname.filter(|v| !v.is_empty()).map(str::to_string), + host: hostname + .filter(|v| !v.is_empty()) + .map(str::to_string) + .or_else(|| etc_hostname.and_then(host_name)) + .unwrap_or_else(|| "localhost".to_string()), + } + } + + /// Whether target is the machine populate runs on, as the user running + /// it: its user is that user, and its host is the machine's name or + /// localhost. + pub fn is_local(&self, target: &Target) -> bool { + target.user == self.user && (target.host == "localhost" || target.host == self.host) + } } /// The machine's name in the contents of /etc/hostname: its only line that @@ -425,15 +437,20 @@ mod tests { } fn target(user: Option<&str>, host: &str) -> Target { - serde_json::from_value(serde_json::json!({ - "user": user, "host": host, "path": "/var/src" - })) - .unwrap() + Target { + user: user.map(str::to_string), + host: host.to_string(), + port: None, + path: "/var/src".to_string(), + sudo: false, + extra_options: vec![], + } } #[test] fn local_targets() { - let local = |t: &Target, logname, hostname, etc| is_local_with(t, logname, hostname, etc); + let local = + |t: &Target, logname, hostname, etc| Origin::from(logname, hostname, etc).is_local(t); // An empty LOGNAME counts as unset, i.e. the user null. assert!(local(&target(None, "localhost"), Some(""), None, None)); assert!(!local(&target(None, "localhost"), Some("tv"), None, None)); diff --git a/pkgs/populate/src/spec.rs b/pkgs/populate/src/spec.rs index 8fc74fc..cde5e29 100644 --- a/pkgs/populate/src/spec.rs +++ b/pkgs/populate/src/spec.rs @@ -1,12 +1,11 @@ -//! The spec: populate's arguments as JSON, as docs/design.md describes them. +//! What populate delivers where: the sources, as JSON, and the target, as +//! the command line gives it. See README.md. +use crate::run::Origin; 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", @@ -20,39 +19,86 @@ pub const SOURCE_TYPES: &[&str] = &[ "symlink", ]; -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +/// The sources, by name, as a JSON object. +pub type Sources = BTreeMap<String, Source>; + +#[derive(Debug)] pub struct Spec { - /// Checked before the rest, so it's only part of the shape here. - #[allow(dead_code)] - pub version: u64, - #[serde(default)] pub backup: bool, - #[serde(default)] pub force: bool, - #[serde(default)] pub prune: bool, - pub source: BTreeMap<String, Source>, + pub source: Sources, pub target: Target, } -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] +#[derive(Clone, Debug)] 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>, - /// Whether the target is the machine populate runs on, or null to - /// decide that when it runs. - #[serde(default)] - pub local: Option<bool>, +} + +/// The parts of a target that [USER@][HOST][:PORT][PATH] gives, like krops' +/// lib's mkTarget parses it. +#[derive(Debug, Default, PartialEq)] +pub struct TargetParts { + pub user: Option<String>, + pub host: Option<String>, + pub port: Option<String>, + pub path: Option<String>, +} + +impl TargetParts { + /// Parses text like the regular expression + /// (([^@]*)@)?(([^:/]+))?(:([^/]+))?(/.*)? of mkTarget: the user up to + /// the first @, the host up to a : or a /, the port up to a /, and the + /// path from there. + pub fn parse(text: &str) -> Result<TargetParts, String> { + let invalid = || format!("invalid target {text:?}: not [USER@][HOST][:PORT][PATH]"); + let (user, rest) = match text.split_once('@') { + Some((user, rest)) => (Some(user.to_string()), rest), + None => (None, text), + }; + let end = rest.find([':', '/']).unwrap_or(rest.len()); + let (host, rest) = rest.split_at(end); + let (port, path) = match rest.strip_prefix(':') { + Some(rest) => { + let end = rest.find('/').unwrap_or(rest.len()); + let (port, path) = rest.split_at(end); + if port.is_empty() { + return Err(invalid()); + } + (Some(port.to_string()), path) + } + None => (None, rest), + }; + Ok(TargetParts { + user, + host: Some(host.to_string()).filter(|h| !h.is_empty()), + port, + path: Some(path.to_string()).filter(|p| !p.is_empty()), + }) + } + + /// The target, with what's missing as mkTarget defaults it: the user + /// running populate, the machine's name, and /var/src. An empty user + /// means ssh's default. + pub fn complete(self, origin: &Origin, sudo: bool, extra_options: Vec<String>) -> Target { + Target { + user: match self.user { + Some(user) => Some(user).filter(|u| !u.is_empty()), + None => origin.user.clone(), + }, + host: self.host.unwrap_or_else(|| origin.host.clone()), + port: self.port, + path: self.path.unwrap_or_else(|| "/var/src".to_string()), + sudo, + extra_options, + } + } } /// A source: its type, and the settings of that type, under the type's name. @@ -97,8 +143,8 @@ pub struct Follows { } impl Source { - /// The inputs this source draws from other sources, like krops' lib's - /// flakeFollows. + /// The inputs this source draws from other sources, like flakeFollows + /// of populate's Nix library. pub fn follows(&self) -> Vec<Follows> { fn walk(prefix: &[String], inputs: &Map<String, Value>, out: &mut Vec<Follows>) { for (name, input) in inputs { @@ -122,3 +168,69 @@ impl Source { out } } + +#[cfg(test)] +mod tests { + use super::*; + + fn parts(text: &str) -> TargetParts { + TargetParts::parse(text).unwrap() + } + + fn some(s: &str) -> Option<String> { + Some(s.to_string()) + } + + #[test] + fn target_syntax() { + assert_eq!(parts(""), TargetParts::default()); + assert_eq!( + parts("/srv/src"), + TargetParts { + path: some("/srv/src"), + ..Default::default() + } + ); + assert_eq!( + parts("host"), + TargetParts { + host: some("host"), + ..Default::default() + } + ); + assert_eq!( + parts("tv@host:22/x"), + TargetParts { + user: some("tv"), + host: some("host"), + port: some("22"), + path: some("/x") + } + ); + // The user ends at the first @, the port at the first /. + assert_eq!(parts("a@b@c").host, some("b@c")); + assert_eq!(parts("h:p:q/r").port, some("p:q")); + assert_eq!(parts(":22").host, None); + assert!(TargetParts::parse("host:").is_err()); + assert!(TargetParts::parse("host:/x").is_err()); + } + + #[test] + fn target_defaults() { + let origin = Origin { + user: some("tv"), + host: "ni".to_string(), + }; + let target = parts("").complete(&origin, false, vec![]); + assert_eq!( + ( + target.user.as_deref(), + target.host.as_str(), + target.path.as_str() + ), + (Some("tv"), "ni", "/var/src") + ); + // An empty user means ssh's default. + assert_eq!(parts("@h").complete(&origin, true, vec![]).user, None); + } +} |
