From 00321d2842d7a3ce88b489dda0f65fa7b20eb5f1 Mon Sep 17 00:00:00 2001 From: jutty Date: Sat, 17 May 2025 02:24:26 -0300 Subject: [PATCH 01/10] OCaml: Minor justfile tweaks --- ocaml/.justfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ocaml/.justfile b/ocaml/.justfile index ec282b2..f522c52 100644 --- a/ocaml/.justfile +++ b/ocaml/.justfile @@ -44,7 +44,7 @@ alias ewt := execute-watch-timeout # Run tests on changes [group('dev')] -test-watch: +test-watch: build dune test --watch alias tw := test-watch @@ -91,7 +91,7 @@ alias c := check [group('checks')] cover: clean build find . -name '*.coverage' -exec rm -v '{}' ';' - dune runtest --instrument-with bisect_ppx --force + dune test --instrument-with bisect_ppx --force bisect-ppx-report html bisect-ppx-report summary From 9e402df786426e76cd18d338349532122b0d5ae9 Mon Sep 17 00:00:00 2001 From: jutty Date: Sat, 17 May 2025 06:50:36 -0300 Subject: [PATCH 02/10] OCaml: Add references on ISO 14977:1996 EBNF downsides --- ocaml/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ocaml/README.md b/ocaml/README.md index f547585..08ad098 100644 --- a/ocaml/README.md +++ b/ocaml/README.md @@ -70,10 +70,13 @@ In this grammar, `digit` implies `decimal digit`. Spaces between the key and the ## References -- ISO 14977 EBNF Notation: +- ISO 14977 EBNF Notation: - Comparison of BNF notations: - W3C ABNF Notation: +- W3C EBNF Notation from the XML 1.0 Spec: - IETF RFC 5234 ABNF Notation (replaces 4234, 2234): +- Don’t Use ISO/IEC 14977:1996 EBNF: + - ### Notes From bed93fd58c4481dbdb5add9b6a4c46cd184501e7 Mon Sep 17 00:00:00 2001 From: jutty Date: Tue, 24 Mar 2026 12:01:19 -0300 Subject: [PATCH 03/10] Rust: Scaffold Cargo project --- rust/.gitignore | 1 + rust/Cargo.lock | 7 +++++++ rust/Cargo.toml | 14 ++++++++++++++ rust/src/main.rs | 3 +++ 4 files changed, 25 insertions(+) create mode 100644 rust/.gitignore create mode 100644 rust/Cargo.lock create mode 100644 rust/Cargo.toml create mode 100644 rust/src/main.rs diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1 @@ +/target diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..c3ccd82 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "tori" +version = "0.8.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..415bdfc --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "tori" +version = "0.8.0" +description = "Track your system configuration and replicate it" +license = "GPL-3.0-only" + +repository = "https://codeberg.org/tori/iganaq" +homepage = "https://tori.jutty.dev/" +documentation = "https://tori.jutty.dev/docs/" + +edition = "2024" +rust-version = "1.94.0" + +[dependencies] diff --git a/rust/src/main.rs b/rust/src/main.rs new file mode 100644 index 0000000..66449d7 --- /dev/null +++ b/rust/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, Rust!"); +} From 397ed5a236310848a1ad89167a7c933ac90526ed Mon Sep 17 00:00:00 2001 From: jutty Date: Tue, 24 Mar 2026 17:39:22 -0300 Subject: [PATCH 04/10] Rust: Setup core architecture --- rust/Cargo.toml | 2 - rust/src/conf.rs | 22 ++++++ rust/src/lib.rs | 3 + rust/src/log.rs | 6 ++ rust/src/main.rs | 10 ++- rust/src/run.rs | 177 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 rust/src/conf.rs create mode 100644 rust/src/lib.rs create mode 100644 rust/src/log.rs create mode 100644 rust/src/run.rs diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 415bdfc..35e7360 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -10,5 +10,3 @@ documentation = "https://tori.jutty.dev/docs/" edition = "2024" rust-version = "1.94.0" - -[dependencies] diff --git a/rust/src/conf.rs b/rust/src/conf.rs new file mode 100644 index 0000000..4e3235c --- /dev/null +++ b/rust/src/conf.rs @@ -0,0 +1,22 @@ +use crate::log; + +pub fn load() -> Configuration { + log::elog("Loading configuration"); + + // TODO A3.1. Before parsing the user arguments, a configuration file at + // $XDG_CONFIG_DIR/tori/tori.conf MUST be read for a line such as: + // 'su_command = doas'. + // TODO A3.2. If this line is not found, the su_command MUST default to 'su -c'. + // TODO A3.3. If it is found, the su_command used MUST be whatever was specified. + // TODO A3.4. Whatever su_command MUST be validated once for presence at the path + // provided or obtained from $PATH and filesystem permission to execute + + Configuration { + su_command: String::default(), + } +} + +#[derive(Debug)] +pub struct Configuration { + su_command: String, +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..fc03ecd --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,3 @@ +pub mod conf; +pub mod log; +pub mod run; diff --git a/rust/src/log.rs b/rust/src/log.rs new file mode 100644 index 0000000..7732696 --- /dev/null +++ b/rust/src/log.rs @@ -0,0 +1,6 @@ +pub fn elog(message: &str) { + // DONE MUST be printed only if DEBUG is set in the environment + if let Ok(debug) = std::env::var("DEBUG") && !debug.is_empty() { + eprintln!(" [log] {message}"); + } +} diff --git a/rust/src/main.rs b/rust/src/main.rs index 66449d7..e926ee0 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -1,3 +1,11 @@ +use tori::{conf, log, run}; + fn main() { - println!("Hello, Rust!"); + log::elog(&format!("tori {}", env!("CARGO_PKG_VERSION"))); + let configuration = conf::load(); + log::elog(&format!("Configuration: {configuration:#?}")); + let mut order = run::teller::parse(std::env::args()); + log::elog(&format!("Order: {order:#?}")); + order.fill(); + log::elog(&format!("Filled Order: {order:#?}")); } diff --git a/rust/src/run.rs b/rust/src/run.rs new file mode 100644 index 0000000..c77b9ed --- /dev/null +++ b/rust/src/run.rs @@ -0,0 +1,177 @@ +// B2.1. DONE version | -v | --version -> MUST print the version as in v0.8.0 +// B2.2. TODO help | -h | --help -> MUST print '' +// B2.3. TODO os -> MUST print the OS name and MUST log contents of /etc/os-release +// B2.4. TODO user -> MUST print the output of the 'whoami' command +// B2.5. TODO pkg p -> MUST call the system package manager using the su_command +// to install and then uninstall package p. The user MUST be able to +// freely input to these commands' interactive inputs before control +// is returned. When done, it MUST log 'Done:', a newline, and the +// system commands executed, one per line. If no p is provided, it +// MUST NOT run any system commands and print a message +// B2.6. TODO echo x y z -> MUST print x y z +// B2.7. TODO echo -> MUST NOT print any output and exit with status code 0 +// B2.8. DONE [no input] -> MUST NOT print any output and exit with status code 0 +// B2.9. TODO [any other input] -> MUST print 'Unrecognized command: [command]', +// a newline, '' and exit with status code 1 + +#[derive(Default, Debug)] +pub struct Order { + tasks: Vec, +} + +impl Order { + + pub fn fill(&mut self) { + for task in self.tasks.iter_mut() { + if task.done { continue } + task.complete(); + } + } + + fn is_complete(&self) -> bool { + self.tasks.iter().any(|e| !e.done) + } +} + +#[derive(Debug, Clone)] +pub struct Task { + kind: TaskKind, + done: bool, + parameters: Vec, +} + +impl Task { + pub fn complete(&mut self) { + use crate::{run::exec::{meta, os, shell, pkg}}; + use TaskKind::*; + + self.done = match self.kind { + Version => { meta::print_version() }, + _ => false, // TODO + } + } + + fn new(kind: TaskKind, parameters: Vec) -> Task { + Task { + kind, + done: false, + parameters, + } + } +} + +#[derive(Debug, Clone)] +pub enum TaskKind { + Version, + OperatingSystem, + Package, + User, + Echo, +} + +pub mod teller { + use crate::{log::elog, run::{Order, Task, TaskKind}}; + use std::{env, path::PathBuf}; + + pub fn parse(mut raw_args: env::Args) -> Order { + let (argument, parameters): (String, Vec) = if let Some(first) = raw_args.next() { + if is_executable_path(&first) { + elog("First argument is the executable path"); + if let Some(second) = raw_args.next() { + elog(&format!( + "Assembled command {second}, arguments {raw_args:?}" + )); + (second, raw_args.collect()) + } else { + elog("No arguments provided"); + return Order::default(); + } + } else { + elog("First argument is not the executable path"); + elog(&format!( + "Assembled command {first}, arguments {raw_args:?}" + )); + (first, raw_args.collect()) + } + } else { + elog("No arguments provided"); + return Order::default(); + }; + + use TaskKind::*; + + if argument == "version" || argument == "-v" || argument == "--version" { + elog("Command is 'version'"); + Order { tasks: vec![Task::new(Version, parameters)] } + } else if argument == "os" { + elog("Command is 'os'"); + Order { tasks: vec![Task::new(OperatingSystem, parameters)] } + } else if argument == "pkg" { + elog("Command is 'pkg'"); + Order { tasks: vec![Task::new(Package, parameters)] } + } else if argument == "user" { + elog("Command is 'user'"); + Order { tasks: vec![Task::new(User, parameters)] } + } else if argument == "echo" { + elog("Command is 'echo'"); + Order { tasks: vec![Task::new(Echo, parameters)] } + } else { + Order::default() + } + } + + fn is_executable_path(candidate: &str) -> bool { + + fn assume(message: &str) -> bool { + elog(&format!("Assuming args[0] is the executable {message}")); + true + } + + let Ok(executable_path) = env::current_exe() else { + return assume("Failed to get executable path") + }; + let Some(executable_file) = executable_path.file_name() else { + return assume("Executable path lacks a file component") + }; + + let argument_path = PathBuf::from(candidate); + let Some(argument_file) = argument_path.file_name() else { + return assume("Argument path lacks a file component") + }; + + elog(&format!( + "Executable path: {executable_path:?}, file {executable_file:?} \ + Argument path: {argument_path:?}, file {argument_file:?} " + )); + + if argument_path.exists() { + if let Ok(argument_canonical) = argument_path.canonicalize() + && let Ok(executable_canonical) = executable_path.canonicalize() { + let judgment = argument_canonical == executable_canonical; + elog(&format!("args[0] canonically is executable path: {judgment}")); + judgment + } else { + assume("Could not canonicalize executable and argument paths") + } + } else { + let judgment = argument_file == executable_file; + elog(&format!("args[0] matches executable path by name only: {judgment}")); + judgment + } + } + +} + +pub mod expeditor {} + +pub mod exec { + pub mod meta { + pub fn print_version() -> bool { + println!("v{}", env!("CARGO_PKG_VERSION")); + true + } + } + pub mod os {} + pub mod shell {} + pub mod pkg {} +} From afe7e32a2a29fe6575b25729e84d74a37423cf77 Mon Sep 17 00:00:00 2001 From: jutty Date: Tue, 24 Mar 2026 18:30:10 -0300 Subject: [PATCH 05/10] Rust: Fulfill most of the spec --- rust/src/main.rs | 4 +- rust/src/run.rs | 120 ++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 106 insertions(+), 18 deletions(-) diff --git a/rust/src/main.rs b/rust/src/main.rs index e926ee0..51c26b7 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -1,6 +1,6 @@ use tori::{conf, log, run}; -fn main() { +fn main() -> std::process::ExitCode { log::elog(&format!("tori {}", env!("CARGO_PKG_VERSION"))); let configuration = conf::load(); log::elog(&format!("Configuration: {configuration:#?}")); @@ -8,4 +8,6 @@ fn main() { log::elog(&format!("Order: {order:#?}")); order.fill(); log::elog(&format!("Filled Order: {order:#?}")); + + if order.finished() { 0.into() } else { 1.into() } } diff --git a/rust/src/run.rs b/rust/src/run.rs index c77b9ed..bab96b1 100644 --- a/rust/src/run.rs +++ b/rust/src/run.rs @@ -1,17 +1,17 @@ // B2.1. DONE version | -v | --version -> MUST print the version as in v0.8.0 -// B2.2. TODO help | -h | --help -> MUST print '' -// B2.3. TODO os -> MUST print the OS name and MUST log contents of /etc/os-release -// B2.4. TODO user -> MUST print the output of the 'whoami' command +// B2.2. DONE help | -h | --help -> MUST print '' +// B2.3. DONE os -> MUST print the OS name and MUST log contents of /etc/os-release +// B2.4. DONE user -> MUST print the output of the 'whoami' command // B2.5. TODO pkg p -> MUST call the system package manager using the su_command // to install and then uninstall package p. The user MUST be able to // freely input to these commands' interactive inputs before control // is returned. When done, it MUST log 'Done:', a newline, and the // system commands executed, one per line. If no p is provided, it // MUST NOT run any system commands and print a message -// B2.6. TODO echo x y z -> MUST print x y z -// B2.7. TODO echo -> MUST NOT print any output and exit with status code 0 +// B2.6. DONE echo x y z -> MUST print x y z +// B2.7. DONE echo -> MUST NOT print any output and exit with status code 0 // B2.8. DONE [no input] -> MUST NOT print any output and exit with status code 0 -// B2.9. TODO [any other input] -> MUST print 'Unrecognized command: [command]', +// B2.9. DONE [any other input] -> MUST print 'Unrecognized command: [command]', // a newline, '' and exit with status code 1 #[derive(Default, Debug)] @@ -28,8 +28,12 @@ impl Order { } } - fn is_complete(&self) -> bool { - self.tasks.iter().any(|e| !e.done) + pub fn finished(&self) -> bool { + if self.tasks.is_empty() { + true + } else { + self.tasks.iter().all(|e| e.done) + } } } @@ -37,6 +41,7 @@ impl Order { pub struct Task { kind: TaskKind, done: bool, + argument: String, parameters: Vec, } @@ -47,14 +52,20 @@ impl Task { self.done = match self.kind { Version => { meta::print_version() }, + Help => { meta::print_help() }, + OsInfo => { os::print_info() }, + User => { os::print_user() }, + Echo => { meta::echo(self) }, + Unrecognized => { meta::unrecognized(self) }, _ => false, // TODO } } - fn new(kind: TaskKind, parameters: Vec) -> Task { + fn new(kind: TaskKind, argument: String, parameters: Vec) -> Task { Task { kind, done: false, + argument, parameters, } } @@ -63,10 +74,12 @@ impl Task { #[derive(Debug, Clone)] pub enum TaskKind { Version, - OperatingSystem, + Help, + OsInfo, Package, User, Echo, + Unrecognized, } pub mod teller { @@ -102,21 +115,24 @@ pub mod teller { if argument == "version" || argument == "-v" || argument == "--version" { elog("Command is 'version'"); - Order { tasks: vec![Task::new(Version, parameters)] } + Order { tasks: vec![Task::new(Version, argument, parameters)] } + } else if argument == "help" { + elog("Command is 'help'"); + Order { tasks: vec![Task::new(Help, argument, parameters)] } } else if argument == "os" { elog("Command is 'os'"); - Order { tasks: vec![Task::new(OperatingSystem, parameters)] } + Order { tasks: vec![Task::new(OsInfo, argument, parameters)] } } else if argument == "pkg" { elog("Command is 'pkg'"); - Order { tasks: vec![Task::new(Package, parameters)] } + Order { tasks: vec![Task::new(Package, argument, parameters)] } } else if argument == "user" { elog("Command is 'user'"); - Order { tasks: vec![Task::new(User, parameters)] } + Order { tasks: vec![Task::new(User, argument, parameters)] } } else if argument == "echo" { elog("Command is 'echo'"); - Order { tasks: vec![Task::new(Echo, parameters)] } + Order { tasks: vec![Task::new(Echo, argument, parameters)] } } else { - Order::default() + Order { tasks: vec![Task::new(Unrecognized, argument, parameters)] } } } @@ -166,12 +182,82 @@ pub mod expeditor {} pub mod exec { pub mod meta { + use crate::run::Task; + pub fn print_version() -> bool { println!("v{}", env!("CARGO_PKG_VERSION")); true } + + pub fn print_help() -> bool { + println!(""); + true + } + + pub fn echo(task: &Task) -> bool { + if !task.parameters.is_empty() { + let text = task.parameters.join(" "); + println!("{text}"); + } + true + } + + pub fn unrecognized(task: &Task) -> bool { + println!("Unrecognized command: {}\n", task.argument); + false + } } - pub mod os {} + + pub mod os { + use crate::log::elog; + + pub fn print_info() -> bool { + use std::process::Command; + + let uname_success = if let Ok(output) = Command::new("uname") + .arg("--operating-system") + .output() { + if let Ok(utf8) = String::from_utf8(output.stdout) { + print!("{utf8}"); + true + } else { + elog("Failed UTF8 coversion of uname output"); + false + } + } else { + elog("Failed executing or reading output of uname"); + false + }; + + let os_release_success = if let Ok(os_release) = std::fs::read_to_string("/etc/os-release") { + elog(&os_release); + true + } else { + elog("Failed reading os-release"); + false + }; + + uname_success && os_release_success + } + + pub fn print_user() -> bool { + use std::process::Command; + + if let Ok(output) = Command::new("whoami").output() { + if let Ok(utf8) = String::from_utf8(output.stdout) { + print!("{utf8}"); + true + } else { + elog("Failed UTF8 coversion of whoami output"); + false + } + } else { + elog("Failed executing or reading output of whoami"); + false + } + } + } + pub mod shell {} pub mod pkg {} } From c5bbdec1a75db273416051df7e5591f97104522d Mon Sep 17 00:00:00 2001 From: jutty Date: Tue, 24 Mar 2026 20:52:13 -0300 Subject: [PATCH 06/10] Rust: Implement configuration parsing --- rust/src/conf.rs | 119 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 111 insertions(+), 8 deletions(-) diff --git a/rust/src/conf.rs b/rust/src/conf.rs index 4e3235c..fe8dff6 100644 --- a/rust/src/conf.rs +++ b/rust/src/conf.rs @@ -1,22 +1,125 @@ -use crate::log; +use std::{collections::HashMap, fs::{self, DirEntry}, path::PathBuf}; + +use crate::log::{self, elog}; pub fn load() -> Configuration { log::elog("Loading configuration"); - // TODO A3.1. Before parsing the user arguments, a configuration file at + // DONE A3.1. Before parsing the user arguments, a configuration file at // $XDG_CONFIG_DIR/tori/tori.conf MUST be read for a line such as: // 'su_command = doas'. - // TODO A3.2. If this line is not found, the su_command MUST default to 'su -c'. - // TODO A3.3. If it is found, the su_command used MUST be whatever was specified. - // TODO A3.4. Whatever su_command MUST be validated once for presence at the path + // DONE A4.2. If this line is not found, the su_command MUST default to 'su -c'. + // DONE A3.3. If it is found, the su_command used MUST be whatever was specified. + // DONE A3.4. Whatever su_command MUST be validated once for presence at the path // provided or obtained from $PATH and filesystem permission to execute - Configuration { - su_command: String::default(), + let mut conf = Configuration { + su_command: vec!["su".into(), "-c".into(), "{% command %}".into()], + }; + + let root = get_root(); + let Ok(contents) = fs::read_to_string(root.join("tori.conf")) else { + eprintln!("Failed reading configuration file at {root:?}"); + return conf + }; + + let lines: Vec> = contents.lines() + .map(|line| line.split('=') + .map(|s| s.trim().to_string()).collect()).collect(); + + let mut map: HashMap = HashMap::new(); + + for line in &lines { + if let Some(key) = line.first() && let Some(value) = line.last() { + map.insert(key.clone(), value.clone()); + } } + + elog(&format!("{lines:#?}")); + + if let Some(su_command) = map.get("su_command") { + let split = su_command.split(' ') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()).collect(); + + if let Some(first) = conf.su_command.first() && let Ok(path_match) = resolve_from_path(first) { + elog(&format!("Succesfully resolved 'su_command' configuration value {su_command} to {}", path_match.to_string_lossy())); + conf.su_command = split; + } else { + eprintln!("Failed validation of 'su_command' configuration value"); + } + } + + conf +} + +fn get_root() -> PathBuf { + if let Ok(xdg_config_dir) = std::env::var("XDG_CONFIG_DIR") { + let mut root = PathBuf::from(xdg_config_dir); + root.push("tori"); + root + } else { + if let Some(mut root) = std::env::home_dir() { + root.push(".config"); + root.push("tori"); + root + } else { + if let Ok(user) = std::env::var("USER") { + let mut root = PathBuf::from("/home"); + root.push(user); + root.push(".config"); + root.push("tori"); + root + } else { + eprintln!("Failed to determine home directory"); + PathBuf::from("/etc/tori") + } + } + } +} + +fn resolve_from_path(command: &str) -> Result { + elog(&format!("Solving from PATH for {command}")); + + let paths: Vec = if let Ok(path) = std::env::var("PATH") { + path.split(':') + .filter(|p| !p.is_empty() && PathBuf::from(p).is_dir()).map(PathBuf::from).collect() + } else { + elog("Error: PATH is not set"); + return Err("{command} not found: PATH is not set in the environment".to_string()) + }; + + elog(&format!("Gathered paths {paths:?}")); + for path in paths { + elog(&format!("On path {path:?}")); + let Ok(mut entries) = fs::read_dir(path) else { + elog("Skipping: Could not read directory contents"); + continue + }; + + let filter = |candidate: &Result| -> bool { + if let Ok(entry) = candidate { + entry.path().is_file() && entry.file_name() == command + } else { false } + }; + + let Some(filtered) = entries.find(filter) else { + elog("Skipping: No entries passed filter"); + continue + }; + + if let Ok(found) = filtered { + return Ok(found.path()) + } else { + elog("Skipping: Filtered match is Err"); + continue + }; + + } + Err("{command} not found in any of the directories in PATH".to_string()) } #[derive(Debug)] pub struct Configuration { - su_command: String, + su_command: Vec, } From 4509d5e6ed2754d87515e807dd971a5ff7b35d62 Mon Sep 17 00:00:00 2001 From: jutty Date: Wed, 25 Mar 2026 01:15:41 -0300 Subject: [PATCH 07/10] Rust: Finalize implementation requirements --- rust/Cargo.toml | 5 ++ rust/src/conf.rs | 21 ++++-- rust/src/main.rs | 2 +- rust/src/run.rs | 191 ++++++++++++++++++++++++++++++++--------------- 4 files changed, 150 insertions(+), 69 deletions(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 35e7360..0041d50 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -10,3 +10,8 @@ documentation = "https://tori.jutty.dev/docs/" edition = "2024" rust-version = "1.94.0" + +[lints.clippy] +zombie_processes = "deny" +suspicious_command_arg_space = "warn" + diff --git a/rust/src/conf.rs b/rust/src/conf.rs index fe8dff6..e699898 100644 --- a/rust/src/conf.rs +++ b/rust/src/conf.rs @@ -1,6 +1,6 @@ use std::{collections::HashMap, fs::{self, DirEntry}, path::PathBuf}; -use crate::log::{self, elog}; +use crate::{run::Command, log::{self, elog}}; pub fn load() -> Configuration { log::elog("Loading configuration"); @@ -14,7 +14,10 @@ pub fn load() -> Configuration { // provided or obtained from $PATH and filesystem permission to execute let mut conf = Configuration { - su_command: vec!["su".into(), "-c".into(), "{% command %}".into()], + su_command: Command { + base: "su".into(), + args: vec!["-c".into(), "{% command %}".into()], + } }; let root = get_root(); @@ -38,13 +41,13 @@ pub fn load() -> Configuration { elog(&format!("{lines:#?}")); if let Some(su_command) = map.get("su_command") { - let split = su_command.split(' ') + let split: Vec = su_command.split(' ') .filter(|s| !s.is_empty()) .map(|s| s.to_string()).collect(); - if let Some(first) = conf.su_command.first() && let Ok(path_match) = resolve_from_path(first) { - elog(&format!("Succesfully resolved 'su_command' configuration value {su_command} to {}", path_match.to_string_lossy())); - conf.su_command = split; + if let Some((base, args)) = split.split_first() && let Ok(resolved_path) = resolve_from_path(base) { + elog(&format!("Succesfully resolved 'su_command' configuration value {su_command} through PATH to {resolved_path:?}, with base {base} and args {args:?}")); + conf.su_command = Command { base: base.clone(), args: args.to_vec() } } else { eprintln!("Failed validation of 'su_command' configuration value"); } @@ -119,7 +122,9 @@ fn resolve_from_path(command: &str) -> Result { Err("{command} not found in any of the directories in PATH".to_string()) } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Configuration { - su_command: Vec, + pub su_command: Command, } + + diff --git a/rust/src/main.rs b/rust/src/main.rs index 51c26b7..7787876 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -4,7 +4,7 @@ fn main() -> std::process::ExitCode { log::elog(&format!("tori {}", env!("CARGO_PKG_VERSION"))); let configuration = conf::load(); log::elog(&format!("Configuration: {configuration:#?}")); - let mut order = run::teller::parse(std::env::args()); + let mut order = run::teller::parse(std::env::args(), &configuration); log::elog(&format!("Order: {order:#?}")); order.fill(); log::elog(&format!("Filled Order: {order:#?}")); diff --git a/rust/src/run.rs b/rust/src/run.rs index bab96b1..9b08989 100644 --- a/rust/src/run.rs +++ b/rust/src/run.rs @@ -2,24 +2,20 @@ // B2.2. DONE help | -h | --help -> MUST print '' // B2.3. DONE os -> MUST print the OS name and MUST log contents of /etc/os-release // B2.4. DONE user -> MUST print the output of the 'whoami' command -// B2.5. TODO pkg p -> MUST call the system package manager using the su_command -// to install and then uninstall package p. The user MUST be able to -// freely input to these commands' interactive inputs before control -// is returned. When done, it MUST log 'Done:', a newline, and the -// system commands executed, one per line. If no p is provided, it -// MUST NOT run any system commands and print a message // B2.6. DONE echo x y z -> MUST print x y z // B2.7. DONE echo -> MUST NOT print any output and exit with status code 0 // B2.8. DONE [no input] -> MUST NOT print any output and exit with status code 0 // B2.9. DONE [any other input] -> MUST print 'Unrecognized command: [command]', // a newline, '' and exit with status code 1 +use crate::conf::Configuration; + #[derive(Default, Debug)] -pub struct Order { - tasks: Vec, +pub struct Order<'o> { + tasks: Vec>, } -impl Order { +impl Order<'_> { pub fn fill(&mut self) { for task in self.tasks.iter_mut() { @@ -38,16 +34,17 @@ impl Order { } #[derive(Debug, Clone)] -pub struct Task { +pub struct Task<'t> { kind: TaskKind, done: bool, argument: String, parameters: Vec, + configuration: &'t Configuration, } -impl Task { +impl Task<'_> { pub fn complete(&mut self) { - use crate::{run::exec::{meta, os, shell, pkg}}; + use crate::{run::exec::{meta, os, pkg}}; use TaskKind::*; self.done = match self.kind { @@ -57,16 +54,22 @@ impl Task { User => { os::print_user() }, Echo => { meta::echo(self) }, Unrecognized => { meta::unrecognized(self) }, - _ => false, // TODO + Package => { pkg::install_uninstall(self) }, } } - fn new(kind: TaskKind, argument: String, parameters: Vec) -> Task { + fn new<'t>( + kind: TaskKind, + argument: &str, + parameters: Vec, + configuration: &'t Configuration, + ) -> Task<'t> { Task { kind, done: false, - argument, + argument: String::from(argument), parameters, + configuration, } } } @@ -82,11 +85,17 @@ pub enum TaskKind { Unrecognized, } +#[derive(Debug, Clone)] +pub struct Command { + pub base: String, + pub args: Vec, +} + pub mod teller { - use crate::{log::elog, run::{Order, Task, TaskKind}}; + use crate::{conf::Configuration, log::elog, run::{Order, Task, TaskKind}}; use std::{env, path::PathBuf}; - pub fn parse(mut raw_args: env::Args) -> Order { + pub fn parse(mut raw_args: env::Args, configuration: &Configuration) -> Order<'_> { let (argument, parameters): (String, Vec) = if let Some(first) = raw_args.next() { if is_executable_path(&first) { elog("First argument is the executable path"); @@ -111,28 +120,30 @@ pub mod teller { return Order::default(); }; - use TaskKind::*; + let make_order = |kind: TaskKind| -> Order { + Order { tasks: vec![Task::new(kind, &argument, parameters, configuration)] } + }; if argument == "version" || argument == "-v" || argument == "--version" { elog("Command is 'version'"); - Order { tasks: vec![Task::new(Version, argument, parameters)] } - } else if argument == "help" { + make_order(TaskKind::Version) + } else if argument == "help" || argument == "-h" || argument == "--help" { elog("Command is 'help'"); - Order { tasks: vec![Task::new(Help, argument, parameters)] } + make_order(TaskKind::Help) } else if argument == "os" { elog("Command is 'os'"); - Order { tasks: vec![Task::new(OsInfo, argument, parameters)] } + make_order(TaskKind::OsInfo) } else if argument == "pkg" { elog("Command is 'pkg'"); - Order { tasks: vec![Task::new(Package, argument, parameters)] } + make_order(TaskKind::Package) } else if argument == "user" { elog("Command is 'user'"); - Order { tasks: vec![Task::new(User, argument, parameters)] } + make_order(TaskKind::User) } else if argument == "echo" { elog("Command is 'echo'"); - Order { tasks: vec![Task::new(Echo, argument, parameters)] } + make_order(TaskKind::Echo) } else { - Order { tasks: vec![Task::new(Unrecognized, argument, parameters)] } + make_order(TaskKind::Unrecognized) } } @@ -209,27 +220,14 @@ pub mod exec { } pub mod os { - use crate::log::elog; + use crate::{ log::elog,run::exec::shell::run}; pub fn print_info() -> bool { - use std::process::Command; - let uname_success = if let Ok(output) = Command::new("uname") - .arg("--operating-system") - .output() { - if let Ok(utf8) = String::from_utf8(output.stdout) { - print!("{utf8}"); - true - } else { - elog("Failed UTF8 coversion of uname output"); - false - } - } else { - elog("Failed executing or reading output of uname"); - false - }; + let uname_result = run("uname", &["--operating-system"]); - let os_release_success = if let Ok(os_release) = std::fs::read_to_string("/etc/os-release") { + let os_release_result = if let Ok(os_release) = + std::fs::read_to_string("/etc/os-release") { elog(&os_release); true } else { @@ -237,27 +235,100 @@ pub mod exec { false }; - uname_success && os_release_success + uname_result.is_ok() && os_release_result } pub fn print_user() -> bool { - use std::process::Command; - - if let Ok(output) = Command::new("whoami").output() { - if let Ok(utf8) = String::from_utf8(output.stdout) { - print!("{utf8}"); - true - } else { - elog("Failed UTF8 coversion of whoami output"); - false - } - } else { - elog("Failed executing or reading output of whoami"); - false - } + run("whoami", &[]).is_ok() } } - pub mod shell {} - pub mod pkg {} + pub mod pkg { + use crate::run::Task; + + // B2.5. DONE pkg p -> MUST call the system package manager using the su_command + // to install and then uninstall package p. The user MUST be able to + // freely input to these commands' interactive inputs before control + // is returned. When done, it MUST log 'Done:', a newline, and the + // system commands executed, one per line. If no p is provided, it + // MUST NOT run any system commands and print a message + pub fn install_uninstall(task: &Task) -> bool { + let su_base: String = task.configuration.su_command.base.clone(); + let su_args: Vec = task.configuration.su_command.args.clone(); + let command_base: Vec = vec!["apt".into(), "install".into()]; + let command_args: Vec = task.parameters.clone(); + + let su_args_str: Vec<&str> = su_args.iter().map(|s| s.as_str()).collect(); + let command_base_str: Vec<&str> = command_base.iter().map(|s| s.as_str()).collect(); + let command_args_str: Vec<&str> = command_args.iter().map(|s| s.as_str()).collect(); + + if command_args.is_empty() { + println!("Parameters are empty: Nothing to install or uninstall"); + return false + } + + let args: Vec<&str> = [ + su_args_str, + command_base_str, + command_args_str, + ].iter().flatten().copied().collect(); + + crate::run::exec::shell::spawn(&su_base, &args); + + crate::run::exec::shell::spawn("sudo", vec!["apt", "remove"] + .into_iter() + .chain(task.parameters.iter().map(|s| s.as_str())) + .collect::>().as_slice()); + + println!( + "Done:\n{su_base} {} {} {}", + su_args.join(" "), + command_base.join(" "), + command_args.join(" "), + ); + true + } + } + + pub mod shell { + use std::process::Command; + + pub fn spawn(command: &str, args: &[&str]) -> bool { + if let Ok(mut child) = Command::new(command) + .args(args) + .spawn() { + let Ok(exit_status) = child.wait() else { return false }; + exit_status.success() + } else { + false + } + } + + pub fn run(command: &str, args: &[&str]) -> Result { + use std::process::Command; + + if let Ok(output) = Command::new(command) + .args(args) + .output() + + { + if let Ok(utf8) = String::from_utf8(output.stdout) { + print!("{utf8}"); + Ok(utf8) + } else { + let message = format!("Failed UTF8 coversion of {command} output"); + eprintln!("{message}"); + Err(message) + } + } else { + let message = format!("Failed executing or reading output of {command}"); + eprintln!("{message}"); + Err(message) + + } + } + + } + + } From 4053c354bdcf1b934aa6cdcfcad9daab36be2b8c Mon Sep 17 00:00:00 2001 From: jutty Date: Sat, 28 Mar 2026 14:47:59 -0300 Subject: [PATCH 08/10] Rust: Adapt tests from iganaq OCaml --- rust/README.md | 7 ++++ rust/black-box.sh | 98 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 rust/README.md create mode 100755 rust/black-box.sh diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..25f290b --- /dev/null +++ b/rust/README.md @@ -0,0 +1,7 @@ +# iganaq Rust + +This is the Rust implementation of the iganaq prototype. See the [root README](../README.md) for the general description. + +## Configuration parser + +The configuration format follows a simple ini-style syntax and will silently ignore any unsupported keys. It does not validate values in any way. diff --git a/rust/black-box.sh b/rust/black-box.sh new file mode 100755 index 0000000..809e1a5 --- /dev/null +++ b/rust/black-box.sh @@ -0,0 +1,98 @@ +# This file tests this tori implementation against the Iganaq Napkin Spec v0.2 + +set -eu + +alias tori=target/debug/tori + +echo "Basic smoke test on alias" + +tori echo OK + +echo "A2. 'log' MUST print only if DEBUG is set and MUST be preceded by ' [log] '" + +without_debug=$(tori os 2>&1) +with_debug=$(DEBUG=os tori os 2>&1) +test "$without_debug" != "$with_debug" +echo "$with_debug" | grep -Fq " [log] " +echo "$without_debug" | grep -Fqv " [log] " + +echo "A3.2. if su_command is unset, the default must be 'su -c'" + +echo 'simulate=true' > "$HOME/.config/tori/tori.conf" +DEBUG=1 tori pkg xterm 2>&1 | grep -Fq 'su -c' + +echo "A3.3. if su_command is set, su_command must be the set value" + +echo 'simulate=true' > "$HOME/.config/tori/tori.conf" +echo 'su_command=sudo' >> "$HOME/.config/tori/tori.conf" +DEBUG=1 tori pkg xterm 2>&1 | grep -Fq 'sudo' + +echo "A3.4. [config] su_command must be validated [as path-resolvable and executable]" + +echo 'su_command=sudo' > "$HOME/.config/tori/tori.conf" +! which sudo >/dev/null || tori >/dev/null 2>&1 +echo 'su_command=sudo' > "$HOME/.config/tori/tori.conf" +! which sudo >/dev/null || tori >/dev/null 2>&1 + +echo "B2.1. version | -v | --version -> MUST print the version as in v0.8.0" + +output=$(tori version) +test "$output" = "v0.8.0" + +output=$(tori -v) +test "$output" = "v0.8.0" + +output=$(tori --version) +test "$output" = "v0.8.0" + +echo "B2.2. help | -h | --help -> MUST print ''" + +output=$(tori help) +test "$output" = "" + +output=$(tori -h) +test "$output" = "" + +output=$(tori --help) +test "$output" = "" + +echo "B2.3. os -> MUST print the os name" + +os_name=$(uname -o) +tori_os=$(tori os) +test -n "$os_name" +test -n "$tori_os" +test "$os_name" = "$tori_os" + +echo "B2.3. os -> MUST log the contents of /etc/os-release" + +tori_os=$(DEBUG=os tori os 2>&1) +test -n "$tori_os" +echo "$tori_os" | grep -qFf /etc/os-release + +echo "B2.4. user -> MUST print the output of the 'whoami' command" + +whoami=$(whoami) +tori_user=$(tori user) +test -n "$whoami" +test -n "$tori_user" +test "$whoami" = "$tori_user" + +echo "B2.6. echo x y z -> MUST print x y z" + +output=$(tori echo x y z) +test "$output" = "x y z" + +echo "B2.7. echo -> MUST NOT print any output and exit with status code 0" + +tori echo + +echo "B2.8. [no input] -> MUST NOT print any output and exit with status code 0" + +tori + +echo "B2.9. [any other input] -> MUST print 'Unrecognized command: [command]\n' exit with 1," + +output=$(tori unrecognized_command || true) +test "$output" = "Unrecognized command: unrecognized_command +" From ca20c11962b5816a416d1341fb51a5bc633dd1ab Mon Sep 17 00:00:00 2001 From: jutty Date: Mon, 30 Mar 2026 07:59:30 -0300 Subject: [PATCH 09/10] Adapt code from iganaq --- .clippy.toml | 3 + .gitignore | 2 +- .justfile | 4 + rust/Cargo.lock => Cargo.lock | 0 Cargo.toml | 167 ++++++++++++++ README.md | 63 +----- ocaml/.gitignore | 1 - ocaml/.justfile | 194 ---------------- ocaml/.ocamlformat | 10 - ocaml/.ocamlformat-ignore | 1 - ocaml/README.md | 86 ------- ocaml/bin/dune | 5 - ocaml/bin/main.ml | 16 -- ocaml/dune-project | 30 --- ocaml/dune-workspace | 4 - ocaml/lib/checks/checks.ml | 22 -- ocaml/lib/dune | 7 - ocaml/lib/parsers/argument.ml | 30 --- ocaml/lib/parsers/config/fetcher.ml | 14 -- ocaml/lib/parsers/config/lexer.ml | 106 --------- ocaml/lib/parsers/config/lexer.mli | 15 -- ocaml/lib/parsers/config/parser.ml | 77 ------- ocaml/lib/parsers/config/parser.mli | 3 - ocaml/lib/schema/schema.ml | 101 --------- ocaml/lib/system/file.ml | 21 -- ocaml/lib/system/os.ml | 11 - ocaml/lib/system/package.ml | 66 ------ ocaml/lib/system/process/command.ml | 19 -- ocaml/lib/system/process/fork.ml | 12 - ocaml/lib/system/process/reader.ml | 48 ---- ocaml/lib/system/process/su.ml | 34 --- ocaml/lib/types/structures.ml | 65 ------ ocaml/lib/utilities/aliases.ml | 30 --- ocaml/lib/utilities/exceptions.ml | 2 - ocaml/lib/utilities/log.ml | 11 - ocaml/lib/utilities/text.ml | 8 - ocaml/test/cram.t | 93 -------- ocaml/test/dune | 4 - ocaml/test/test_tori.ml | 14 -- ocaml/tori.opam | 38 ---- rust/.gitignore | 1 - rust/Cargo.toml | 17 -- rust/README.md | 7 - rust/black-box.sh | 98 -------- rust/src/conf.rs | 130 ----------- rust/src/main.rs | 13 -- rust/src/run.rs | 334 ---------------------------- src/conf.rs | 161 ++++++++++++++ {rust/src => src}/lib.rs | 6 +- {rust/src => src}/log.rs | 4 +- src/main.rs | 15 ++ src/os.rs | 39 ++++ src/os/debian.rs | 169 ++++++++++++++ src/os/pkg.rs | 225 +++++++++++++++++++ src/run.rs | 146 ++++++++++++ src/run/executor.rs | 86 +++++++ src/run/executor/meta.rs | 16 ++ src/run/expeditor.rs | 95 ++++++++ src/run/teller.rs | 99 +++++++++ src/state.rs | 91 ++++++++ 60 files changed, 1329 insertions(+), 1860 deletions(-) create mode 100644 .clippy.toml create mode 100644 .justfile rename rust/Cargo.lock => Cargo.lock (100%) create mode 100644 Cargo.toml delete mode 100644 ocaml/.gitignore delete mode 100644 ocaml/.justfile delete mode 100644 ocaml/.ocamlformat delete mode 100644 ocaml/.ocamlformat-ignore delete mode 100644 ocaml/README.md delete mode 100644 ocaml/bin/dune delete mode 100644 ocaml/bin/main.ml delete mode 100644 ocaml/dune-project delete mode 100644 ocaml/dune-workspace delete mode 100644 ocaml/lib/checks/checks.ml delete mode 100644 ocaml/lib/dune delete mode 100644 ocaml/lib/parsers/argument.ml delete mode 100644 ocaml/lib/parsers/config/fetcher.ml delete mode 100644 ocaml/lib/parsers/config/lexer.ml delete mode 100644 ocaml/lib/parsers/config/lexer.mli delete mode 100644 ocaml/lib/parsers/config/parser.ml delete mode 100644 ocaml/lib/parsers/config/parser.mli delete mode 100644 ocaml/lib/schema/schema.ml delete mode 100644 ocaml/lib/system/file.ml delete mode 100644 ocaml/lib/system/os.ml delete mode 100644 ocaml/lib/system/package.ml delete mode 100644 ocaml/lib/system/process/command.ml delete mode 100644 ocaml/lib/system/process/fork.ml delete mode 100644 ocaml/lib/system/process/reader.ml delete mode 100644 ocaml/lib/system/process/su.ml delete mode 100644 ocaml/lib/types/structures.ml delete mode 100644 ocaml/lib/utilities/aliases.ml delete mode 100644 ocaml/lib/utilities/exceptions.ml delete mode 100644 ocaml/lib/utilities/log.ml delete mode 100644 ocaml/lib/utilities/text.ml delete mode 100644 ocaml/test/cram.t delete mode 100644 ocaml/test/dune delete mode 100644 ocaml/test/test_tori.ml delete mode 100644 ocaml/tori.opam delete mode 100644 rust/.gitignore delete mode 100644 rust/Cargo.toml delete mode 100644 rust/README.md delete mode 100755 rust/black-box.sh delete mode 100644 rust/src/conf.rs delete mode 100644 rust/src/main.rs delete mode 100644 rust/src/run.rs create mode 100644 src/conf.rs rename {rust/src => src}/lib.rs (57%) rename {rust/src => src}/log.rs (64%) create mode 100644 src/main.rs create mode 100644 src/os.rs create mode 100644 src/os/debian.rs create mode 100644 src/os/pkg.rs create mode 100644 src/run.rs create mode 100644 src/run/executor.rs create mode 100644 src/run/executor/meta.rs create mode 100644 src/run/expeditor.rs create mode 100644 src/run/teller.rs create mode 100644 src/state.rs diff --git a/.clippy.toml b/.clippy.toml new file mode 100644 index 0000000..3909e5d --- /dev/null +++ b/.clippy.toml @@ -0,0 +1,3 @@ +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-indexing-slicing-in-tests = true diff --git a/.gitignore b/.gitignore index a4fd0d4..ea8c4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -ocaml/_coverage +/target diff --git a/.justfile b/.justfile new file mode 100644 index 0000000..c06b4db --- /dev/null +++ b/.justfile @@ -0,0 +1,4 @@ +watch command="run" args="": + DEBUG=${DEBUG:-} watchexec -c -w src -- cargo {{ command }} {{ args }} + +alias w := watch diff --git a/rust/Cargo.lock b/Cargo.lock similarity index 100% rename from rust/Cargo.lock rename to Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2f250e4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,167 @@ +[package] +name = "tori" +version = "0.8.0" +description = "Track your system configuration and replicate it" +license = "GPL-3.0-only" + +repository = "https://codeberg.org/tori/iganaq" +homepage = "https://tori.jutty.dev/" +documentation = "https://tori.jutty.dev/docs/" + +edition = "2024" +rust-version = "1.94.0" + +[lints.rust] +nonstandard-style = "warn" +future-incompatible = "warn" +keyword-idents = "warn" + +[lints.clippy] +allow_attributes = "warn" +assigning_clones = "warn" +branches_sharing_code = "warn" +clear_with_drain = "warn" +cloned_instead_of_copied = "warn" +coerce_container_to_any = "warn" +collapsible_else_if = "allow" +collapsible_if = "allow" +collection_is_never_read = "warn" +comparison_chain = "warn" +copy_iterator = "warn" +default_trait_access = "warn" +deref_by_slicing = "warn" +doc_paragraphs_missing_punctuation = "warn" +empty_structs_with_brackets = "warn" +equatable_if_let = "warn" +error_impl_error = "warn" +expect_used = "warn" +expl_impl_clone_on_copy = "warn" +explicit_deref_methods = "warn" +explicit_into_iter_loop = "warn" +explicit_iter_loop = "warn" +fallible_impl_from = "warn" +filter_map_next = "warn" +flat_map_option = "warn" +format_collect = "warn" +format_push_string = "warn" +from_iter_instead_of_collect = "warn" +get_unwrap = "warn" +ignore_without_reason = "warn" +ignored_unit_patterns = "warn" +implicit_clone = "warn" +implicit_hasher = "warn" +imprecise_flops = "warn" +inconsistent_struct_constructor = "warn" +index_refutable_slice = "warn" +indexing_slicing = "warn" +inefficient_to_string = "warn" +infinite_loop = "warn" +into_iter_without_iter = "warn" +iter_filter_is_ok = "warn" +iter_filter_is_some = "warn" +iter_not_returning_iterator = "warn" +iter_on_empty_collections = "warn" +iter_on_single_items = "warn" +iter_with_drain = "warn" +iter_without_into_iter = "warn" +literal_string_with_formatting_args = "warn" +manual_assert = "warn" +manual_ilog2 = "warn" +manual_instant_elapsed = "warn" +manual_is_power_of_two = "warn" +manual_is_variant_and = "warn" +manual_let_else = "warn" +manual_midpoint = "warn" +manual_non_exhaustive = "allow" +manual_string_new = "warn" +map_err_ignore = "warn" +map_with_unused_argument_over_ranges = "warn" +match_bool = "warn" +match_same_arms = "warn" +match_wild_err_arm = "warn" +match_wildcard_for_single_variants = "warn" +maybe_infinite_iter = "warn" +mismatching_type_param_order = "warn" +missing_assert_message = "warn" +missing_const_for_fn = "warn" +missing_fields_in_debug = "warn" +missing_panics_doc = "warn" +mixed_read_write_in_expression = "warn" +module_name_repetitions = "warn" +multiple_inherent_impl = "warn" +mut_mut = "warn" +needless_collect = "warn" +needless_for_each = "warn" +needless_pass_by_ref_mut = "warn" +needless_pass_by_value = "warn" +needless_raw_string_hashes = "warn" +needless_raw_strings = "warn" +needless_type_cast = "warn" +no_effect_underscore_binding = "warn" +nonstandard_macro_braces = "warn" +option_as_ref_cloned = "warn" +option_option = "warn" +panic_in_result_fn = "warn" +path_buf_push_overwrite = "warn" +pathbuf_init_then_push = "warn" +pub_underscore_fields = "warn" +pub_without_shorthand = "warn" +range_minus_one = "warn" +range_plus_one = "warn" +redundant_clone = "warn" +redundant_pub_crate = "warn" +redundant_test_prefix = "warn" +redundant_type_annotations = "warn" +renamed_function_params = "warn" +return_and_then = "warn" +return_self_not_must_use = "warn" +same_functions_in_if_condition = "warn" +same_length_and_capacity = "warn" +same_name_method = "warn" +search_is_some = "warn" +semicolon_if_nothing_returned = "warn" +semicolon_inside_block = "warn" +set_contains_or_insert = "warn" +shadow_reuse = "warn" +shadow_same = "warn" +shadow_unrelated = "warn" +should_panic_without_expect = "warn" +single_match_else = "warn" +single_option_map = "warn" +str_split_at_newline = "warn" +string_add = "warn" +string_add_assign = "warn" +string_lit_as_bytes = "warn" +string_lit_chars_any = "warn" +string_slice = "warn" +struct_field_names = "warn" +suspicious_command_arg_space = "warn" +tests_outside_test_module = "warn" +trait_duplication_in_bounds = "warn" +trivially_copy_pass_by_ref = "warn" +type_repetition_in_bounds = "warn" +unicode_not_nfc = "warn" +uninlined_format_args = "warn" +unnecessary_join = "warn" +unnecessary_literal_bound = "warn" +unnecessary_self_imports = "warn" +unnecessary_semicolon = "warn" +unnecessary_struct_initialization = "warn" +unnecessary_wraps = "warn" +unneeded_field_pattern = "warn" +unnested_or_patterns = "warn" +unreadable_literal = "warn" +unseparated_literal_suffix = "warn" +unused_async = "warn" +unused_peekable = "warn" +unused_result_ok = "warn" +unused_rounding = "warn" +unused_self = "warn" +unused_trait_names = "warn" +unwrap_in_result = "warn" +unwrap_used = "warn" +used_underscore_binding = "warn" +used_underscore_items = "warn" +useless_let_if_seq = "warn" +zero_sized_map_values = "warn" +zombie_processes = "deny" diff --git a/README.md b/README.md index 4fc90da..b78e2ff 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,7 @@ -# tori-iganaq +# tori-rs -This is a sandbox project containing alternative implementations of [tori](https://tori.jutty.dev/) using different programming languages. Its name is a reference to a location in the migration routes of puffin birds, the tori symbol. +This is a sandbox project containing an alternative implementation of [tori](https://tori.jutty.dev/) using the Rust programming language as derived from the [iganaq experiment](https://codeberg.org/tori/iganaq). -After evaluating how each solution measures up to the project requirements, the result should be a new main implementation to replace the current one. - -The plan is to evaluate three candidate languages: **OCaml**, **Haskell** and **Rust**. They were chosen for their ability to compile to portable binaries and for their rich type systems that can support predictable and strict logic requirements. OCaml and Haskell, particularly, are interesting candidates for configuration parsing and execution of side-effects only in an outer layer of the architecture. - -## Rationale - -So far, tori has been implemented using POSIX shell scripts. The rationale for this choice has been explained in the [documentation](https://tori.jutty.dev/docs/development/portability.html) and leans heavily on the fact that, because mostly any unix system is bound to have a POSIX shell available, this means you can run (and modify) tori without any extra requirements, not even a C compiler or any libraries. - -While this is a good advantage, what really tipped the scale was how _uncertain_ it felt when running tests in the form of shell scripts. It all depends on the underlying shell's `errexit` and `nounset` options, which can be unpredictable depending on the shell implementation and the context you are evaluating in (e.g. inside a function, inside a sub-shell, inside an if condition, ...). - -As a program that can brick your system if something goes wrong, it's really important that tori is highly testable and predictable. And that is not something that can be reasonably done using shell scripting. - -## Specification - -Each language will be used to implement a simple command-line interface that fulfills the specification below. "Simple" means the goal is not to cover corner cases, but to prototype and make a decision based on language syntax, ergonomics, expressiveness, documentation, ecosystem, tooling and overall experience. - - Iganaq Napkin Spec v0.2 - - A1. 'print' refers to messages for users. They MUST always be printed. - A2. 'log' refers to messages for programmers. They MUST be printed only - if DEBUG is set in the environment and MUST be preceded by ' [log] '. - - A3.1. Before parsing the user arguments, a configuration file at - $XDG_CONFIG_DIR/tori/tori.conf MUST be read for a line such as: - 'su_command = doas'. - A3.2. If this line is not found, the su_command MUST default to 'su -c'. - A3.3. If it is found, the su_command used MUST be whatever was specified. - A3.4. Whatever su_command MUST be validated once for presence at the path - provided or obtained from $PATH and filesystem permission to execute - - A4. The 'command' is the first argument passed to the program. - A5. The 'arguments' are all but the first argument passed to the program. - A6. If a command takes no arguments, they MAY be silently ignored. - - B1.1. The commands in the listing below MUST all be implemented - B1.2. In the listing below, the left side of '->' is the command, and the - right side is the action to be taken when this command is provided - B1.3. In the listing below, the pipe symbol '|' means 'or' - - B2.1. version | -v | --version -> MUST print the version as in v0.8.0 - B2.2. help | -h | --help -> MUST print '' - B2.3. os -> MUST print the OS name and MUST log contents of /etc/os-release - B2.4. user -> MUST print the output of the 'whoami' command - B2.5. pkg p -> MUST call the system package manager using the su_command - to install and then uninstall package p. The user MUST be able to - freely input to these commands' interactive inputs before control - is returned. When done, it MUST log 'Done:', a newline, and the - system commands executed, one per line. If no p is provided, it - MUST NOT run any system commands and print a message - B2.6. echo x y z -> MUST print x y z - B2.7. echo -> MUST NOT print any output and exit with status code 0 - B2.8. [no input] -> MUST NOT print any output and exit with status code 0 - B2.9. [any other input] -> MUST print 'Unrecognized command: [command]', - a newline, '' and exit with status code 1 - - Z1. for the implementation to be 'finished', tests MUST cover all of its - requirements and these tests MUST pass consistently +## Configuration parser +The configuration format follows a simple ini-style syntax and will silently ignore any unsupported keys. It does not validate values in any way. diff --git a/ocaml/.gitignore b/ocaml/.gitignore deleted file mode 100644 index e35d885..0000000 --- a/ocaml/.gitignore +++ /dev/null @@ -1 +0,0 @@ -_build diff --git a/ocaml/.justfile b/ocaml/.justfile deleted file mode 100644 index f522c52..0000000 --- a/ocaml/.justfile +++ /dev/null @@ -1,194 +0,0 @@ -export dependencies := \ - require('dune') + \ - require('ocamlformat') + \ - require('delta') + \ - require('entr') + \ - require('bisect-ppx-report') - -set unstable - -_default: - @just --list - - -# DEV - -# Build on changes -[group('dev')] -build-watch: - dune build --watch - -alias bw := build-watch - -# Build and execute -[group('dev')] -execute *args: - dune exec tori -- {{ args }} - -alias e := execute - -# Build and execute on changes -[group('dev')] -execute-watch *args: - find lib bin -regex '.*\.mli?$' | entr -c -- dune exec tori -- {{ args }} - -alias ew := execute-watch - -# Build and execute on changes with a timeout -[group('dev')] -execute-watch-timeout seconds='2' *args: - find lib bin -regex '.*\.mli?$' | \ - entr -cx -- timeout {{ seconds }} dune exec tori -- {{ args }} - -alias ewt := execute-watch-timeout - -# Run tests on changes -[group('dev')] -test-watch: build - dune test --watch - -alias tw := test-watch - -# Format check on changes -[group('dev')] -format-watch: - find lib bin -regex '.*\.mli?$' | entr -c -- dune fmt --preview - -alias fw := format-watch - -# BUILD - -# Build project with Dune -[group('build')] -build: - dune build - -alias b := build - -# Cleanup build artifacts -[group('build')] -clean: - dune clean - -alias cl := clean - -# Clean, build, run checks and tests with coverage -[group('build')] -full-build: clean check cover - -alias fb := full-build - - -# CHECKS - -# Check formatting and run tests with coverage -[group('checks')] -check: lint format-check cover - -alias c := check - -# Generate coverage files and report -[group('checks')] -cover: clean build - find . -name '*.coverage' -exec rm -v '{}' ';' - dune test --instrument-with bisect_ppx --force - bisect-ppx-report html - bisect-ppx-report summary - -alias co := cover - -# Run tests -[group('checks')] -test: build - dune test - -alias t := test - -# Lint with semgrep -[group('checks')] -lint: - semgrep scan --error - -alias l := lint - -# Format all files -[group('checks')] -format: - dune fmt - dune promote - -alias f := format - -# Check formatting without changing files -[group('checks')] -format-check: - #!/usr/bin/env sh - find . \ - \( -name '*.ml' -o -name '*.mli' \) \ - \( -path './lib/*' -o -path './bin/*' \) | - xargs ocamlformat --check - -alias fck := format-check - -# Format specific files -[group('checks')] -[no-cd] -format-file *args: - ocamlformat --inplace -- {{ args }} - -alias ff := format-file - -# Check formatting on specific files -[group('checks')] -[no-exit-message] -[no-cd] -format-check-file *args: - #!/usr/bin/env sh - files=$(printf '%s' "{{ args }}" | sed 's/ /\n/g') - for file in $files; do - if ocamlformat --check -- $file; then - echo " [ OK ] $file" - else - echo " [ !! ] $file" - extension=$(printf '%s' "$file" | rev | cut -d . -f 1 | rev) - formatted="$(basename $file .$extension).fmt.$extension" - ocamlformat "$file" > "$formatted" - delta "$file" $formatted - fi - done - -alias ffck := format-check-file - -# Cleanup formatting temporary files -[group('checks')] -[no-cd] -format-file-cleanup: - #!/usr/bin/env sh - files=$(find . -regex '.*\.fmt\.[a-zA-Z0-9]+$') - if [ -n "$files" ]; then - printf '%s:\n%s\n\n%s\n%s\n > ' \ - 'Files found' \ - "$files" \ - '[RETURN] Remove all' '[Ctrl-C] Abort' - read _ - rm -v $files - else - echo 'No temporary formatting files found' - fi - -alias ffcl := format-file-cleanup - -# UNGROUPED - -# Show system, compiler and tooling information -info: - @echo OCaml version: $(ocamlc --version) - @echo Dune version: $(dune --version) - @echo Git version: $(git --version | cut -f 3 -d ' ') - @echo Just version: $(just --version | cut -f 2 -d ' ') - @echo OS/Arch: {{ os() }} {{ arch() }} - @echo GCC Triplet: $(gcc -dumpmachine) - @echo Shell: {{ env('SHELL') }} - @echo justfile dependencies: {{ dependencies }} - - diff --git a/ocaml/.ocamlformat b/ocaml/.ocamlformat deleted file mode 100644 index 92b82b2..0000000 --- a/ocaml/.ocamlformat +++ /dev/null @@ -1,10 +0,0 @@ -profile = default -version = 0.27.0 - -margin-check = true -extension-indent = 4 -function-indent = 4 -type-decl-indent = 4 -let-binding-indent = 4 -indicate-nested-or-patterns = space -indicate-multiline-delimiters = closing-on-separate-line diff --git a/ocaml/.ocamlformat-ignore b/ocaml/.ocamlformat-ignore deleted file mode 100644 index 683c15d..0000000 --- a/ocaml/.ocamlformat-ignore +++ /dev/null @@ -1 +0,0 @@ -lib/schema/schema.ml diff --git a/ocaml/README.md b/ocaml/README.md deleted file mode 100644 index 08ad098..0000000 --- a/ocaml/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# iganaq OCaml - -This is the OCaml implementation of the iganaq prototype. See the [root README](../README.md) for the general description. - -## Configuration parser - -Grammar v0.2: - - assignment = { space }, key, { space }, equal, [ space ], value - key = letter, { letter | digit | "_" }, equal - value = valuable, { " " | valuable }, break - valuable = ( letter | digit | "_" | "-" | "~" | "/" ), { valuable } - equal = "=" - break = "\n" - space = " " | "\t" - -Written using the ISO 14977 EBNF Notation. - -In this grammar, `digit` implies `decimal digit`. Spaces between the key and the `=` operator are lexed but meaningless. The first space after the `=` operator is parsed but meaningless. Additional spaces between the first space after the `=` operator and the first non-space character of the value are lexed and considered as part of the value. Spaces before the key and after the last non-space character until the newline are not lexed. - -- ~Note: non-terminals `key` and `value` are ambiguous~. - - Resolved by specifying what character terminates each - -## Task list - -- [x] Spec requirements - - [x] Add log function - - [x] Output begins with ` [log] ` - - [x] Only prints if `DEBUG` is set - - [x] Get su command from `$XDG_CONFIG_HOME/tori/tori.conf` - - [x] Default to `su -c` - - [x] Handle fatal `Sys_error` if `tori.conf` doesn't exist - - [x] Handle checking `su -c` default with `which` when `tori.conf` exists but `su_command` is absent in it - - [x] Properly handle a compose `su_command` such as `su -c` in `System.Package` - - [x] Properly handle dependent options - - [x] Implement log levels - - [x] Write tests - - [x] Validation - - [x] Valid path or in `PATH` - - [x] Executability - - ~`true` exits with status 0~[^3] - - [x] Add logging - - [x] Logs only if DEBUG is set - - [x] Print each command executed, not just package names - - [x] Case with no packages provided - - [x] Prints a message - - [x] MUST NOT run any system commands - - [x] Unrecognized command: exit code 1 - - [x] Command `user`: print the output of `whoami` - - [x] Command `os`: print the OS name - - [x] log the contents of `/etc/os-release`[^4] - -- [ ] Incrementals - - [ ] Simplify and analyze `System.File` - - [ ] Simplify Reader - -- [ ] Additionals - - [ ] Create remaining interface files - - [ ] Expand unit tests coverage - - [ ] Add interactive pkg tests (INS[^1] v0 B2.5[^2]) - - [ ] Try out doc generation - - [ ] Detect `su_command` from PATH before falling back to su - - [ ] Raise on duplicate keys, currently shadowing - -- [ ] Check out - - [ ] - - [ ] - - [ ] - - [ ] - -## References - -- ISO 14977 EBNF Notation: -- Comparison of BNF notations: -- W3C ABNF Notation: -- W3C EBNF Notation from the XML 1.0 Spec: -- IETF RFC 5234 ABNF Notation (replaces 4234, 2234): -- Don’t Use ISO/IEC 14977:1996 EBNF: - - - -### Notes - -[^1]: INS, Iganaq Napkin Spec: -[^2]: INS v0 B2.5 "MUST NOT run any system commands" is only testable if we wrap command execution properly in e.g. a monad or list containing all executed commands, ensuring no command is ever executed without being appended to it -[^3]: INS v0 A3.4 "running 'true' with exit code 0" requires the user to input their password every time. This was dropped in INS v0.2, where "run 'true' with exit code 0" was removed from A3.4 -[^4]: INS v0.1 changes requirement B2.3 to "MUST print the OS name and MUST log contents of /etc/os-release" in order to make the logging function testable without user input diff --git a/ocaml/bin/dune b/ocaml/bin/dune deleted file mode 100644 index 7546de9..0000000 --- a/ocaml/bin/dune +++ /dev/null @@ -1,5 +0,0 @@ -(executable - (public_name tori) - (name main) - (libraries tori) -) diff --git a/ocaml/bin/main.ml b/ocaml/bin/main.ml deleted file mode 100644 index 817140c..0000000 --- a/ocaml/bin/main.ml +++ /dev/null @@ -1,16 +0,0 @@ -module ConfigFetcher = Tori.Parsers.Config.Fetcher - - -let () = - - match Array.to_list Sys.argv with - | _ :: tail -> - let past = ConfigFetcher.fetch Tori.Schema.origin - |> Tori.Checks.post_config - in - let future = Tori.Parsers.Argument.interpret past tail - |> Tori.Checks.exit - in - exit future.meta.status - | [] -> assert false - diff --git a/ocaml/dune-project b/ocaml/dune-project deleted file mode 100644 index 5d89318..0000000 --- a/ocaml/dune-project +++ /dev/null @@ -1,30 +0,0 @@ -(lang dune 3.17) - -(name tori) -(version 0.8.0) - -(homepage https://tori.jutty.dev) -(source (uri git+https://brew.bsd.cafe/tori/tori.git)) -(authors "Juno Takano ") -(maintainers "Juno Takano ") -(license GPL-3.0-only) -(documentation https://tori.jutty.dev/docs) - -(package - (name tori) - (synopsis "Track system configurations and replicate them") - (description - "\> tori lets you define several characteristics of a unix system and - "\> track changes happen along time, allowing you to choose whether or - "\> not to commit. It aims for portability and declarative configuration - "\> practices, meaning you tell it what your system is, not how to - "\> reproduce it. - ) - (tags ("operating systems" "unix" "configuration management")) - (depends - (ocaml (>= 5.3.0)) - (bisect_ppx (and :dev (>= 2.5.0))) - ) -) - -(generate_opam_files true) diff --git a/ocaml/dune-workspace b/ocaml/dune-workspace deleted file mode 100644 index 8b6c24c..0000000 --- a/ocaml/dune-workspace +++ /dev/null @@ -1,4 +0,0 @@ -(lang dune 3.17) -(context default) - -(env (dev (flags (:standard -w +A-23-40-42-48-70)))) diff --git a/ocaml/lib/checks/checks.ml b/ocaml/lib/checks/checks.ml deleted file mode 100644 index 778ae36..0000000 --- a/ocaml/lib/checks/checks.ml +++ /dev/null @@ -1,22 +0,0 @@ -open Utilities.Aliases - -(* - The purpose of this module is to run multiple checks at appropriate times. - All functions should end with a call to exit, which will print error messages - and quit with code schema.meta.status if schema.meta.error_level is Fatal. - - When adding checks, consider that the error message will be overriten if exit - is not called between schema changes. This should be improved later so that a - list instead is printed entirely by exit, and then emptied. -*) - -let exit (schema: Schema.schema): Schema.schema = - if schema.output.main <> "" then print_endline schema.output.main; - if schema.output.log <> "" then elog schema.output.log; - if schema.meta.error_level == Fatal then exit schema.meta.status - else schema - -let post_config (schema: Schema.schema): Schema.schema = - System.Process.Su.is_executable schema - |> exit - diff --git a/ocaml/lib/dune b/ocaml/lib/dune deleted file mode 100644 index 872c4ef..0000000 --- a/ocaml/lib/dune +++ /dev/null @@ -1,7 +0,0 @@ -(library - (name tori) - (libraries unix) - (instrumentation (backend bisect_ppx)) -) - -(include_subdirs qualified) diff --git a/ocaml/lib/parsers/argument.ml b/ocaml/lib/parsers/argument.ml deleted file mode 100644 index b706919..0000000 --- a/ocaml/lib/parsers/argument.ml +++ /dev/null @@ -1,30 +0,0 @@ -let interpret (past : Schema.schema) (arguments : string list) : Schema.schema = - - let say (message : string) : Schema.schema = - { past with output = { past.output with main = message } } - in - - (* - TODO: return a schema with orders, instead of calling side-effects - directly, making this more of a parser and less of a glorified switch - *) - match arguments with - | "pkg" :: tail -> System.Package.merge past tail - | "os" :: _ -> say System.Os.identify - | "user" :: _ -> say (System.Process.Reader.read [||] "whoami").output - | "echo" :: tail -> say (String.concat " " tail) - | ("version" | "-v" | "--version") :: _ -> - say (Schema.format_version past.meta.version) - | ("help" | "-h" | "--help") :: _ -> say past.meta.help.long - | head :: _ -> - { - past with - output = - { - past.output with - main = - "Unrecognized command: " ^ head ^ "\n" ^ past.meta.help.short; - }; - meta = { past.meta with status = 1 }; - } - | _ -> past diff --git a/ocaml/lib/parsers/config/fetcher.ml b/ocaml/lib/parsers/config/fetcher.ml deleted file mode 100644 index bc0404a..0000000 --- a/ocaml/lib/parsers/config/fetcher.ml +++ /dev/null @@ -1,14 +0,0 @@ -let fetch ?clean (origin: Schema.schema): Schema.schema = - let config = Lexer.read origin.meta.defaults.paths.configuration - |> Lexer.scan - |> List.concat - |> Parser.parse - in - - if Option.value clean ~default:false then { - Schema.origin with input = { - origin.input with configuration = { - origin.input.configuration with main = config - } - } - } else Parser.apply origin config diff --git a/ocaml/lib/parsers/config/lexer.ml b/ocaml/lib/parsers/config/lexer.ml deleted file mode 100644 index a005590..0000000 --- a/ocaml/lib/parsers/config/lexer.ml +++ /dev/null @@ -1,106 +0,0 @@ -open Utilities.Aliases - -type key = Schema.configuration_key -type token = - | Key of key - | Equal - | Value of string - | Space - | LineBreak - | Unknown of char - | End - - (* - TODO: This module's functions rely too much on matching a string, so - there are no exhaustion checks and it's entirely up to the human to - not overlook a variant, possibly leading to unhandled cases. Either - refactor, add tests that will fail if a variant is unhandled, or both - *) - -let lex_keyword (literal: string): token = - match literal with - | "su_command" -> Key SuCommand - | "su_command_quoted" -> Key SuCommandQuoted - | "interactive" -> Key Interactive - | "simulate" -> Key Simulate - | _ -> Key Unknown - -let lex_keyvalue (literal: string): token = Value literal - -let string_of_token (token: token): string = - match token with - | Key k -> (match k with - | SuCommand -> "[ KEY: su_command ]" - | SuCommandQuoted -> "[ KEY: su_command_quoted ]" - | Interactive -> " [ KEY: interactive ]" - | Simulate -> " [ KEY: simulate ]" - | Unknown -> "[ UNKNOWN KEY ]") - | Equal -> "[ OP: equal ]" - | Value v -> "[ VAL: " ^ v ^ " ]" - | Space -> "{ Space }" - | LineBreak -> "{ LineBreak }\n" - | End -> "{ End of File }\n" - | Unknown s -> (String.make 1 s) - -let string_of_tokens (tokens: token lists): string = - String.concat " " $ map string_of_token (List.concat tokens) - -let lex_keypair (chars: char list) (position: int): token * int = - - (* For a keypair abc = bcd\n, the middle position is the first space - before =, or = itself if there are no spaces. The final position is the - middle position if parsing before it, or the newline \n if past it *) - - let middle_position = - match List.find_index (fun c -> c == '=' || c == ' ') chars with - | Some b -> b - | None -> raise $ Malformed_source - ("No equal operator for position " ^ str_int position) - in - let final_position = - if position < middle_position then middle_position - else (length chars) - 1 in - let literal = str_chars - (ifilter (fun i _ -> i >= position && i < final_position) chars) in - - if position < middle_position then - lex_keyword literal, final_position - else - lex_keyvalue literal, final_position - -let lex (chars: char list) (position: int): token * int = - match pick position chars with - | '=' -> Equal, position + 1 - | ' '|'\t' -> Space, position + 1 - | '\n' -> LineBreak, position + 1 - | 'a'..'z'|'~'|'/' -> lex_keypair chars position - | c -> Unknown c, position + 1 - -let read (path: string): char lists = - if not $ System.File.can_read path then [[]] else - let lines = System.File.read path - |> String.split_on_char '\n' - |> List.map String.trim in - let lines = imap - (fun i s -> if i + 1 < length lines then s ^ "\n" else s) lines in - (* adds a newline to each line end, except the last *) - let rec to_char_lists - (strings: string list) (position: int) (char_lists: char lists) = - if position == length strings then char_lists - else to_char_lists strings (position + 1) - char_lists $: chars_str (pick position strings) - in - to_char_lists lines 0 [] - -let scan_line (input: char list): token list = - let rec to_tokens (chars: char list) (position: int) (tokens: token list) = - if position == length chars then tokens - else let token, next_position = lex chars position in - to_tokens chars next_position $ token :: tokens - in - reverse $ to_tokens input 0 [] - -let scan (char_lists: char lists): token lists = - let tokens = rmap (scan_line) char_lists $: [End] in - elog ~context:Parsing $ string_of_tokens tokens; - tokens diff --git a/ocaml/lib/parsers/config/lexer.mli b/ocaml/lib/parsers/config/lexer.mli deleted file mode 100644 index a5f29a0..0000000 --- a/ocaml/lib/parsers/config/lexer.mli +++ /dev/null @@ -1,15 +0,0 @@ -type key = Schema.configuration_key - -type token = - | Key of key - | Equal - | Value of string - | Space - | LineBreak - | Unknown of char - | End - -val read : string -> char list list -val scan : char list list -> token list list -val string_of_tokens : token list list -> string -val string_of_token : token -> string diff --git a/ocaml/lib/parsers/config/parser.ml b/ocaml/lib/parsers/config/parser.ml deleted file mode 100644 index fcee48a..0000000 --- a/ocaml/lib/parsers/config/parser.ml +++ /dev/null @@ -1,77 +0,0 @@ -open Lexer -open Utilities.Aliases - -let default_config: Schema.main = Schema.origin.input.configuration.main - -let parse_boolean (key: key) (value: string): Schema.default_bool = - match value with - | "true" -> true - | "false" -> false - | _ -> raise $ Malformed_source - (Schema.string_of_key key ^ " must be either true or false") - -let check (config: Schema.main): Schema.main = - - let default = Schema.origin.input.configuration.main in - - (* Ignore su_command_quoted value if su_command is the default, - and default to unquoted if a custom su_command is set *) - match config.su_command_quoted, config.su_command with - | (true|false), su_command when su_command == default.su_command -> - elog ~context:Parsing $ "[c.parser.check] " ^ - "Ignoring configuration key su_command_quoted: su_command is unset," ^ - " and the default su_command needs quoting"; - { config with su_command_quoted = default.su_command_quoted } - | (true|false), _ -> config - | Default, su_command when su_command <> default.su_command -> - elog ~context:Parsing $ "[c.parser.check] " ^ - "Setting su_command_quoted to false: su_command is set, but " ^ - "su_command_quoted isn't. If it needs quoting, please set it to true"; - { config with su_command_quoted = false } - | Default, _ -> config - -let update (config: Schema.main) (key: Lexer.key) (value: string): Schema.main = - elog ~context:Parsing $ "[c.parser.update] Matching value '" ^ value ^ "'"; - match key with - | SuCommand -> { config with su_command = String.split_on_char ' ' value } - | SuCommandQuoted -> { config with su_command_quoted = parse_boolean key value } - | Interactive -> { config with interactive = bool_of_string value } - | Simulate -> { config with simulate = bool_of_string value } - | Unknown -> elog ~context:Parsing $ "[c.parser.update] Dropped value: unknown key"; config - -let parse tokens: Schema.main = - let rec parse_tokens tokens config ready_key = - match tokens with - | [] -> config - | Key key :: tail -> - elog ~context:Parsing $ "[c.parser.parse] Picked key '" ^ - Schema.string_of_key key ^ "'"; - parse_tokens tail config (Some key) - | Value value :: tail -> - elog ~context:Parsing $ - "[c.parser.parse] Picked value '" ^ value ^ "'"; - (match ready_key with - | Some key -> parse_tokens tail (update config key value) None - | None -> raise $ Malformed_source "Value lacks preceding key") - | Unknown char :: tail -> - elog ~context:Parsing $ - "[c.parser.parse] Dropping unknown token " ^ str_char char; - parse_tokens tail config ready_key - | (Space|Equal|LineBreak|End) :: tail -> - parse_tokens tail config ready_key - - in - parse_tokens tokens default_config None - |> check - -let apply (origin: Schema.schema) (config: Schema.main): Schema.schema = - { origin with input = { - origin.input with configuration = { - origin.input.configuration with main = config - } - }} - -let string_of_config (config: Schema.main): string = - (* TODO: extract, use pattern matching for exhaustion checks *) - "su_command = " ^ String.concat " " config.su_command ^ "\n" ^ - "su_command_quoted = " ^ str_dbool config.su_command_quoted diff --git a/ocaml/lib/parsers/config/parser.mli b/ocaml/lib/parsers/config/parser.mli deleted file mode 100644 index 48e499e..0000000 --- a/ocaml/lib/parsers/config/parser.mli +++ /dev/null @@ -1,3 +0,0 @@ -val parse : Lexer.token list -> Schema.main -val apply : Schema.schema -> Schema.main -> Schema.schema -val string_of_config : Schema.main -> string diff --git a/ocaml/lib/schema/schema.ml b/ocaml/lib/schema/schema.ml deleted file mode 100644 index ae5ff19..0000000 --- a/ocaml/lib/schema/schema.ml +++ /dev/null @@ -1,101 +0,0 @@ -type version = { major : int; minor : int; patch : int } -type help = { short : string; long : string } -type error_level = Clear | Warning | Error | Fatal -type paths = { configuration : string } -type defaults = { paths: paths } -type meta = { - version : version; - help : help; - error_level: error_level; - status : int; - defaults : defaults; -} - -type output = { main : string; log : string } - -type os = Unknown | FreeBSD | Void | Alpine -type host = { os : os; name : string } - -type default_bool = Default | true | false -type configuration_key = - | SuCommand - | SuCommandQuoted - | Interactive - | Simulate - | Unknown -type main = { - su_command : string list; - su_command_quoted: default_bool; - interactive: bool; - simulate: bool; -} -type configuration = { main : main; } -type input = { configuration: configuration; } - -type schema = { meta : meta; output : output; input : input; host : host } - -let origin : schema = { - meta = { - version = { - major = 0; - minor = 8; - patch = 0; - }; - help = { - short = ""; - long = ""; - }; - error_level = Clear; - status = 0; - defaults = { - paths = { - configuration = Unix.getenv "HOME" ^ "/.config/tori/tori.conf"; - }; - }; - }; - input = { - configuration = { - main = { - su_command = [ "su"; "-c" ]; - su_command_quoted = Default; - interactive = true; - simulate = false; - }; - }; - }; - output = { - (* could be lists of strings or lists of a dedicated type with message, - log level, time and origin in code (e.g. module and function) *) - main = ""; - log = ""; - }; - host = { - os = Unknown; - name = "Unknown Host"; - }; -} - -let format_version (version : version) : string = - "v" ^ string_of_int version.major ^ - "." ^ string_of_int version.minor ^ - "." ^ string_of_int version.patch - -let string_of_key key = - match key with - | SuCommand -> "su_command" - | SuCommandQuoted -> "su_command_quoted" - | Interactive -> "interactive" - | Simulate -> "simulate" - | Unknown -> "" - -let string_of_default_bool (b: default_bool): string = - match b with - | true -> "true" - | false -> "false" - | Default -> "default" - -let default_bool_of_string (s: string): default_bool = - match s with - | "true" -> true - | "false" -> false - | _ -> Default diff --git a/ocaml/lib/system/file.ml b/ocaml/lib/system/file.ml deleted file mode 100644 index c164bb0..0000000 --- a/ocaml/lib/system/file.ml +++ /dev/null @@ -1,21 +0,0 @@ -open Utilities.Aliases - -let read_channel channel = - let buffer = Buffer.create 4096 in - let rec read () = - let line = input_line channel in - Buffer.add_string buffer line; - Buffer.add_char buffer '\n'; - read () - in - try read () with End_of_file -> Buffer.contents buffer - -let can_read (path: string): bool = - try Unix.access path [Unix.R_OK]; true - with Unix.Unix_error _ -> - elog $ "Failed to read file " ^ path; - false - -let read path = - let channel = open_in path in - read_channel channel diff --git a/ocaml/lib/system/os.ml b/ocaml/lib/system/os.ml deleted file mode 100644 index a999792..0000000 --- a/ocaml/lib/system/os.ml +++ /dev/null @@ -1,11 +0,0 @@ -(* the side effect could be extracted to a log list in the schema *) - -let identify : string = - let os_release = String.split_on_char '\n' (File.read "/etc/os-release") in - Utilities.Log.elog ~context:OS (String.concat "\n" os_release); - - let os_equals = List.find (String.starts_with ~prefix:"NAME=") os_release in - match String.split_on_char '=' os_equals with - | [ _; s ] -> - String.trim @@ String.map (fun c -> if c = '"' then ' ' else c) s - | _ -> "Unknown" diff --git a/ocaml/lib/system/package.ml b/ocaml/lib/system/package.ml deleted file mode 100644 index 82d8692..0000000 --- a/ocaml/lib/system/package.ml +++ /dev/null @@ -1,66 +0,0 @@ -open Utilities.Aliases - -type command = { interactive: string list; batch: string list } -type manager = { install: command; remove: command } -type manager_table = { apk: manager } - -let table: manager_table = { - apk = { - install = { - interactive = [ "apk"; "-i"; "add"; ]; - batch = [ "apk"; "--no-interactive"; "add"; ]; - }; - remove = { - interactive = [ "apk"; "-i"; "del"; ]; - batch = [ "apk"; "--no-interactive"; "del"; ]; - } - } -} - -let su = Process.Su.elevate_wrapped -let manager = table.apk - -let merge (schema : Schema.schema) (packages : string list) : Schema.schema = - match packages with - | [] -> - { - schema with - output = { schema.output with main = "No packages provided" }; - } - | _ -> - let su_command_line = schema.input.configuration.main.su_command in - let su_command = Process.Su.head_of_su_command su_command_line in - let commands : Process.Command.command list = - [ - { - name = su_command; - arguments = su schema $ manager.install.interactive @ packages; - status = Unevaluated; - }; - { - name = su_command; - arguments = su schema $ manager.remove.interactive @ packages; - status = Unevaluated; - }; - ] - in - - let simulate = schema.input.configuration.main.simulate in - let log_output = - if simulate then - "Would execute:\n" ^ - String.concat "\n" (Process.Command.format_many commands) - else - let ran = - if simulate then [] else Process.Fork.run_many commands in - "Executed:\n" ^ - String.concat "\n" (Process.Command.format_many ran) in - - { - schema with - output = - { - schema.output with - log = log_output; - }; - } diff --git a/ocaml/lib/system/process/command.ml b/ocaml/lib/system/process/command.ml deleted file mode 100644 index 575196d..0000000 --- a/ocaml/lib/system/process/command.ml +++ /dev/null @@ -1,19 +0,0 @@ -open Utilities.Aliases - -type schema = Schema.schema - -type status = Exit of int | Unevaluated -type command = { name : string; arguments : string list; status : status } - -let format (command : command) : string = - command.name ^ " with arguments: " - ^ String.concat " " command.arguments - ^ " (" - ^ - (match command.status with - | Exit n -> "Exit status " ^ str_int n - | Unevaluated -> "Not evaluated") - ^ ")" - -let format_many (commands : command list) : string list = - List.map format commands diff --git a/ocaml/lib/system/process/fork.ml b/ocaml/lib/system/process/fork.ml deleted file mode 100644 index 649d1cd..0000000 --- a/ocaml/lib/system/process/fork.ml +++ /dev/null @@ -1,12 +0,0 @@ -let run (command : Command.command) : Command.command = - match Unix.fork () with - | 0 -> Unix.execvp command.name (Array.of_list command.arguments) - | pid -> ( - let _, status = Unix.waitpid [] pid in - match status with - | WSTOPPED n | WSIGNALED n | WEXITED n -> - { command with status = Exit n } - ) - -let run_many (commands : Command.command list) : Command.command list = - List.map run commands diff --git a/ocaml/lib/system/process/reader.ml b/ocaml/lib/system/process/reader.ml deleted file mode 100644 index 2762dbd..0000000 --- a/ocaml/lib/system/process/reader.ml +++ /dev/null @@ -1,48 +0,0 @@ -open Utilities.Aliases - -type output = { output : string; error : string; status : string } - -let handle_exit_status (status : Unix.process_status) : string = - match status with - | Unix.WEXITED n -> "Exit " ^ str_int n - | Unix.WSIGNALED n -> "Kill " ^ str_int n - | Unix.WSTOPPED n -> "Stopped " ^ str_int n - -let read (env : string array) (command : string) : output = - let stdout, stdin, stderr = Unix.open_process_full command env in - let in_buffer = Buffer.create 4096 in - let err_buffer = Buffer.create 4096 in - - let rec read_in () = - let in_line = input_line stdout in - Buffer.add_string in_buffer in_line; - Buffer.add_char in_buffer '\n'; - read_in () - in - try read_in () - with End_of_file -> ( - (); - - let rec read_err () = - let err_line = input_line stderr in - Buffer.add_string err_buffer err_line; - Buffer.add_char err_buffer '\n'; - read_err () - in - try read_err () - with End_of_file -> - let exit_status = - handle_exit_status (Unix.close_process_full (stdout, stdin, stderr)) - in - { - output = String.trim (Buffer.contents in_buffer); - error = Buffer.contents err_buffer; - status = exit_status; - } - ) - -let format (output : output) : string = - match output with - | { output = o; error = _; status = "Exit 0" } -> o - | { output = ""; error = e; status = s } -> "[" ^ s ^ "]" ^ " " ^ e - | { output = o; error = _; status = s } -> "[" ^ s ^ "]" ^ " " ^ o diff --git a/ocaml/lib/system/process/su.ml b/ocaml/lib/system/process/su.ml deleted file mode 100644 index ecd8ced..0000000 --- a/ocaml/lib/system/process/su.ml +++ /dev/null @@ -1,34 +0,0 @@ -open Utilities.Aliases -type schema = Schema.schema - -let head_of_su_command command_line = - match command_line with - | head :: _ -> head - | [] -> raise $ Malformed_source "su_command is set to an empty value" - -let elevate_wrapped (schema: schema) (command: string list): string list = - let su_command = schema.input.configuration.main.su_command in - match schema.input.configuration.main.su_command_quoted with - | true|Default -> List.concat [ su_command; [(String.concat " " command)]; ] - | false -> List.concat [ su_command; ["--"]; (command); ] - -let is_executable (schema: schema): schema = - let command = head_of_su_command - schema.input.configuration.main.su_command in - let path = Reader.read [||] ("which " ^ command) in - try Unix.access path.output [Unix.X_OK]; schema - with Unix.Unix_error _ -> elog ""; - { - schema with - output = - { - schema.output with - main = - "The configured super user command " ^ command ^ - " either could not be found at path '" ^ path.output ^ - "' or you lack permissions to execute it (" - ^ path.status ^ ", stderr: '" ^ path.error ^ "')\n" - }; - meta = { schema.meta with status = 1; error_level = Fatal }; - } - diff --git a/ocaml/lib/types/structures.ml b/ocaml/lib/types/structures.ml deleted file mode 100644 index 1d6f08a..0000000 --- a/ocaml/lib/types/structures.ml +++ /dev/null @@ -1,65 +0,0 @@ -open Utilities.Aliases -type schema = Schema.schema - -module type Monad = sig - type 'f t - val lift : 'f -> ('f * string) - val (>>=) : 'f t -> ('f -> 'b t) -> 'b t - val ( let* ) : 'f t -> ('f -> 'b t) -> 'b t -end - -module type Writer = sig - include Monad - val write : string -> unit t - val read : 'f t -> string - val withdraw : 'f t -> 'f -end - -module Writer : Writer with type 'f t = 'f * string = struct - - type 'f t = 'f * string - - let lift f = (f, "") - - let append_newline s = - if s == "" then s else s ^"\n" - - let (>>=) pair f = - let (past, pre_str) = pair in - let (future, post_str) = f past in - (future, append_newline pre_str ^ post_str) - - let ( let* ) = ( >>= ) - - let write (s : string) = ((), s) - let read (_, s) = s - let withdraw (m, s) = print s; m (* should this I/O live here? *) - -end - -let demo : unit = - let open Writer in - - let add (i: int) (m: schema): schema = - { m with meta = { m.meta with status = m.meta.status + i }} - in - - let log_add (i: int) (m: schema): schema t = - let current = str_int m.meta.status in - let partial = str_int $ m.meta.status + i in - let addend = str_int i in - add i m, "adding: " ^ current ^ " + " ^ addend ^ " = " ^ partial - in - - let (m: schema) = withdraw ( - lift Schema.origin >>= - log_add 1 >>= - log_add 2 >>= - (* how can this be simplified? *) - fun carry -> write "just write" >>= fun () -> - log_add 1 carry >>= - log_add 5 - ) in - - print_endline $ "total: " ^ str_int m.meta.status - diff --git a/ocaml/lib/utilities/aliases.ml b/ocaml/lib/utilities/aliases.ml deleted file mode 100644 index 46121f8..0000000 --- a/ocaml/lib/utilities/aliases.ml +++ /dev/null @@ -1,30 +0,0 @@ -(* an 'alias' is an alternate name with minor or no alterations to behavior *) - -(* exceptions *) -exception Malformed_source = Exceptions.Malformed_source -exception Malformed_state = Exceptions.Malformed_state - -(* logging *) -let print = print_endline -let elog = Log.elog - -(* casts *) -let str_int = string_of_int -let chars_str = Text.chars_of_string -let str_chars = Text.string_of_chars -let str_char = String.make 1 -let str_dbool = Schema.string_of_default_bool - -(* control flow & precedence *) -let ($) = (@@) - -(* lists *) -type 'a lists = 'a list list -let ($:) list element = list @ [element] -let pick index list = List.nth list index -let rmap = List.rev_map -let reverse = List.rev -let length = List.length -let ifilter = List.filteri -let imap = List.mapi -let map = List.map diff --git a/ocaml/lib/utilities/exceptions.ml b/ocaml/lib/utilities/exceptions.ml deleted file mode 100644 index f9b3248..0000000 --- a/ocaml/lib/utilities/exceptions.ml +++ /dev/null @@ -1,2 +0,0 @@ -exception Malformed_source of string -exception Malformed_state of string diff --git a/ocaml/lib/utilities/log.ml b/ocaml/lib/utilities/log.ml deleted file mode 100644 index cee6c9c..0000000 --- a/ocaml/lib/utilities/log.ml +++ /dev/null @@ -1,11 +0,0 @@ -type context = Default | OS | Parsing - -let elog ?(context: context option) (message : string) : unit = - - let debug_flag = try Unix.getenv "DEBUG" with Not_found -> "" in - let log () = prerr_endline @@ " [log] " ^ message in - - match context with - | None | Some Default -> if debug_flag <> "" then log () - | Some Parsing -> if debug_flag = "parsing" then log () - | Some OS -> if debug_flag = "os" then log () diff --git a/ocaml/lib/utilities/text.ml b/ocaml/lib/utilities/text.ml deleted file mode 100644 index 14fd58a..0000000 --- a/ocaml/lib/utilities/text.ml +++ /dev/null @@ -1,8 +0,0 @@ -let chars_of_string (string: string): char list = - let rec split string index chars = - if index = String.length string then chars - else split string (index + 1) (string.[index] :: chars) - in List.rev (split string 0 []) - -let string_of_chars (chars: char list): string = - String.concat "" (List.map (String.make 1) chars) diff --git a/ocaml/test/cram.t b/ocaml/test/cram.t deleted file mode 100644 index 5090093..0000000 --- a/ocaml/test/cram.t +++ /dev/null @@ -1,93 +0,0 @@ -This file tests this tori implementation against the Iganaq Napkin Spec v0.2 - -A2. 'log' MUST print only if DEBUG is set and MUST be preceded by ' [log] ' - - $ without_debug=$(tori os 2>&1) - $ with_debug=$(DEBUG=os tori os 2>&1) - $ test "$without_debug" != "$with_debug" - $ echo "$with_debug" | grep -Fq " [log] " - $ echo "$without_debug" | grep -Fqv " [log] " - -A3.2. if su_command is unset, the default must be 'su -c' - - $ echo 'simulate=true' > $HOME/.config/tori/tori.conf - $ DEBUG=1 tori pkg xterm 2>&1 | grep -Fq 'su -c' - -A3.3. if su_command is set, su_command must be the set value - - $ echo 'simulate=true' > $HOME/.config/tori/tori.conf - $ echo 'su_command=doas' >> $HOME/.config/tori/tori.conf - $ DEBUG=1 tori pkg xterm 2>&1 | grep -Fq 'doas' - -A3.4. [config] su_command must be validated for presence at the provided path -or a path obtained from $PATH and filesystem permission to execute - - $ echo 'su_command=sudo' > $HOME/.config/tori/tori.conf - $ ! which sudo >/dev/null || tori 2>&1 >/dev/null - $ echo 'su_command=doas' > $HOME/.config/tori/tori.conf - $ ! which doas >/dev/null || tori 2>&1 >/dev/null - -B2.1. version | -v | --version -> MUST print the version as in v0.8.0 - - $ tori version - v0.8.0 - - $ tori -v - v0.8.0 - - $ tori --version - v0.8.0 - -B2.2. help | -h | --help -> MUST print '' - - $ tori help - - - $ tori -h - - - $ tori --help - - -B2.3. os -> MUST print the os name - - $ os_name=$(cat /etc/os-release | grep '^NAME=' | cut -d= -f 2 | sed 's/"//g') - $ tori_os=$(tori os) - $ test -n "$os_name" - $ test -n "$tori_os" - $ test "$os_name" = "$tori_os" - -B2.3. os -> MUST log the contents of /etc/os-release - - $ tori_os=$(DEBUG=os tori os 2>&1) - $ test -n "$tori_os" - $ echo "$tori_os" | grep -qFf /etc/os-release - -B2.4. user -> MUST print the output of the 'whoami' command - - $ whoami=$(whoami) - $ tori_user=$(tori user) - $ test -n "$whoami" - $ test -n "$tori_user" - $ test "$whoami" = "$tori_user" - -B2.6. echo x y z -> MUST print x y z - - $ tori echo x y z - x y z - -B2.7. echo -> MUST NOT print any output and exit with status code 0 - - $ tori echo - -B2.8. [no input] -> MUST NOT print any output and exit with status code 0 - - $ tori - -B2.9. [any other input] -> MUST print 'Unrecognized command: [command]', -a newline, '' and exit with status code 1 - - $ tori unrecognized_command - Unrecognized command: unrecognized_command - - [1] diff --git a/ocaml/test/dune b/ocaml/test/dune deleted file mode 100644 index 002c77c..0000000 --- a/ocaml/test/dune +++ /dev/null @@ -1,4 +0,0 @@ -(test - (name test_tori) - (libraries tori) -) diff --git a/ocaml/test/test_tori.ml b/ocaml/test/test_tori.ml deleted file mode 100644 index 270ac1c..0000000 --- a/ocaml/test/test_tori.ml +++ /dev/null @@ -1,14 +0,0 @@ -module Reader = Tori.System.Process.Reader -module File = Tori.System.File - -let smoke () = - (* Executing echo should return the same string on output *) - let result = Reader.read [||] "echo 0x70121" in - assert (Reader.format result = "0x70121"); - - (* Reading a file, relying on Dune's directory structure *) - let file_contents = File.read "../tori.opam" in - let contents_list = String.split_on_char '\n' file_contents in - assert (List.mem "depends: [" contents_list) - -let () = smoke () diff --git a/ocaml/tori.opam b/ocaml/tori.opam deleted file mode 100644 index 13fb03a..0000000 --- a/ocaml/tori.opam +++ /dev/null @@ -1,38 +0,0 @@ -# This file is generated by dune, edit dune-project instead -opam-version: "2.0" -version: "0.8.0" -synopsis: "Track system configurations and replicate them" -description: """ -tori lets you define several characteristics of a unix system and -track changes happen along time, allowing you to choose whether or -not to commit. It aims for portability and declarative configuration -practices, meaning you tell it what your system is, not how to -reproduce it. -""" -maintainer: ["Juno Takano "] -authors: ["Juno Takano "] -license: "GPL-3.0-only" -tags: ["operating systems" "unix" "configuration management"] -homepage: "https://tori.jutty.dev" -doc: "https://tori.jutty.dev/docs" -depends: [ - "dune" {>= "3.17"} - "ocaml" {>= "5.3.0"} - "bisect_ppx" {dev & >= "2.5.0"} - "odoc" {with-doc} -] -build: [ - ["dune" "subst"] {dev} - [ - "dune" - "build" - "-p" - name - "-j" - jobs - "@install" - "@runtest" {with-test} - "@doc" {with-doc} - ] -] -dev-repo: "git+https://brew.bsd.cafe/tori/tori.git" diff --git a/rust/.gitignore b/rust/.gitignore deleted file mode 100644 index ea8c4bf..0000000 --- a/rust/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target diff --git a/rust/Cargo.toml b/rust/Cargo.toml deleted file mode 100644 index 0041d50..0000000 --- a/rust/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "tori" -version = "0.8.0" -description = "Track your system configuration and replicate it" -license = "GPL-3.0-only" - -repository = "https://codeberg.org/tori/iganaq" -homepage = "https://tori.jutty.dev/" -documentation = "https://tori.jutty.dev/docs/" - -edition = "2024" -rust-version = "1.94.0" - -[lints.clippy] -zombie_processes = "deny" -suspicious_command_arg_space = "warn" - diff --git a/rust/README.md b/rust/README.md deleted file mode 100644 index 25f290b..0000000 --- a/rust/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# iganaq Rust - -This is the Rust implementation of the iganaq prototype. See the [root README](../README.md) for the general description. - -## Configuration parser - -The configuration format follows a simple ini-style syntax and will silently ignore any unsupported keys. It does not validate values in any way. diff --git a/rust/black-box.sh b/rust/black-box.sh deleted file mode 100755 index 809e1a5..0000000 --- a/rust/black-box.sh +++ /dev/null @@ -1,98 +0,0 @@ -# This file tests this tori implementation against the Iganaq Napkin Spec v0.2 - -set -eu - -alias tori=target/debug/tori - -echo "Basic smoke test on alias" - -tori echo OK - -echo "A2. 'log' MUST print only if DEBUG is set and MUST be preceded by ' [log] '" - -without_debug=$(tori os 2>&1) -with_debug=$(DEBUG=os tori os 2>&1) -test "$without_debug" != "$with_debug" -echo "$with_debug" | grep -Fq " [log] " -echo "$without_debug" | grep -Fqv " [log] " - -echo "A3.2. if su_command is unset, the default must be 'su -c'" - -echo 'simulate=true' > "$HOME/.config/tori/tori.conf" -DEBUG=1 tori pkg xterm 2>&1 | grep -Fq 'su -c' - -echo "A3.3. if su_command is set, su_command must be the set value" - -echo 'simulate=true' > "$HOME/.config/tori/tori.conf" -echo 'su_command=sudo' >> "$HOME/.config/tori/tori.conf" -DEBUG=1 tori pkg xterm 2>&1 | grep -Fq 'sudo' - -echo "A3.4. [config] su_command must be validated [as path-resolvable and executable]" - -echo 'su_command=sudo' > "$HOME/.config/tori/tori.conf" -! which sudo >/dev/null || tori >/dev/null 2>&1 -echo 'su_command=sudo' > "$HOME/.config/tori/tori.conf" -! which sudo >/dev/null || tori >/dev/null 2>&1 - -echo "B2.1. version | -v | --version -> MUST print the version as in v0.8.0" - -output=$(tori version) -test "$output" = "v0.8.0" - -output=$(tori -v) -test "$output" = "v0.8.0" - -output=$(tori --version) -test "$output" = "v0.8.0" - -echo "B2.2. help | -h | --help -> MUST print ''" - -output=$(tori help) -test "$output" = "" - -output=$(tori -h) -test "$output" = "" - -output=$(tori --help) -test "$output" = "" - -echo "B2.3. os -> MUST print the os name" - -os_name=$(uname -o) -tori_os=$(tori os) -test -n "$os_name" -test -n "$tori_os" -test "$os_name" = "$tori_os" - -echo "B2.3. os -> MUST log the contents of /etc/os-release" - -tori_os=$(DEBUG=os tori os 2>&1) -test -n "$tori_os" -echo "$tori_os" | grep -qFf /etc/os-release - -echo "B2.4. user -> MUST print the output of the 'whoami' command" - -whoami=$(whoami) -tori_user=$(tori user) -test -n "$whoami" -test -n "$tori_user" -test "$whoami" = "$tori_user" - -echo "B2.6. echo x y z -> MUST print x y z" - -output=$(tori echo x y z) -test "$output" = "x y z" - -echo "B2.7. echo -> MUST NOT print any output and exit with status code 0" - -tori echo - -echo "B2.8. [no input] -> MUST NOT print any output and exit with status code 0" - -tori - -echo "B2.9. [any other input] -> MUST print 'Unrecognized command: [command]\n' exit with 1," - -output=$(tori unrecognized_command || true) -test "$output" = "Unrecognized command: unrecognized_command -" diff --git a/rust/src/conf.rs b/rust/src/conf.rs deleted file mode 100644 index e699898..0000000 --- a/rust/src/conf.rs +++ /dev/null @@ -1,130 +0,0 @@ -use std::{collections::HashMap, fs::{self, DirEntry}, path::PathBuf}; - -use crate::{run::Command, log::{self, elog}}; - -pub fn load() -> Configuration { - log::elog("Loading configuration"); - - // DONE A3.1. Before parsing the user arguments, a configuration file at - // $XDG_CONFIG_DIR/tori/tori.conf MUST be read for a line such as: - // 'su_command = doas'. - // DONE A4.2. If this line is not found, the su_command MUST default to 'su -c'. - // DONE A3.3. If it is found, the su_command used MUST be whatever was specified. - // DONE A3.4. Whatever su_command MUST be validated once for presence at the path - // provided or obtained from $PATH and filesystem permission to execute - - let mut conf = Configuration { - su_command: Command { - base: "su".into(), - args: vec!["-c".into(), "{% command %}".into()], - } - }; - - let root = get_root(); - let Ok(contents) = fs::read_to_string(root.join("tori.conf")) else { - eprintln!("Failed reading configuration file at {root:?}"); - return conf - }; - - let lines: Vec> = contents.lines() - .map(|line| line.split('=') - .map(|s| s.trim().to_string()).collect()).collect(); - - let mut map: HashMap = HashMap::new(); - - for line in &lines { - if let Some(key) = line.first() && let Some(value) = line.last() { - map.insert(key.clone(), value.clone()); - } - } - - elog(&format!("{lines:#?}")); - - if let Some(su_command) = map.get("su_command") { - let split: Vec = su_command.split(' ') - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()).collect(); - - if let Some((base, args)) = split.split_first() && let Ok(resolved_path) = resolve_from_path(base) { - elog(&format!("Succesfully resolved 'su_command' configuration value {su_command} through PATH to {resolved_path:?}, with base {base} and args {args:?}")); - conf.su_command = Command { base: base.clone(), args: args.to_vec() } - } else { - eprintln!("Failed validation of 'su_command' configuration value"); - } - } - - conf -} - -fn get_root() -> PathBuf { - if let Ok(xdg_config_dir) = std::env::var("XDG_CONFIG_DIR") { - let mut root = PathBuf::from(xdg_config_dir); - root.push("tori"); - root - } else { - if let Some(mut root) = std::env::home_dir() { - root.push(".config"); - root.push("tori"); - root - } else { - if let Ok(user) = std::env::var("USER") { - let mut root = PathBuf::from("/home"); - root.push(user); - root.push(".config"); - root.push("tori"); - root - } else { - eprintln!("Failed to determine home directory"); - PathBuf::from("/etc/tori") - } - } - } -} - -fn resolve_from_path(command: &str) -> Result { - elog(&format!("Solving from PATH for {command}")); - - let paths: Vec = if let Ok(path) = std::env::var("PATH") { - path.split(':') - .filter(|p| !p.is_empty() && PathBuf::from(p).is_dir()).map(PathBuf::from).collect() - } else { - elog("Error: PATH is not set"); - return Err("{command} not found: PATH is not set in the environment".to_string()) - }; - - elog(&format!("Gathered paths {paths:?}")); - for path in paths { - elog(&format!("On path {path:?}")); - let Ok(mut entries) = fs::read_dir(path) else { - elog("Skipping: Could not read directory contents"); - continue - }; - - let filter = |candidate: &Result| -> bool { - if let Ok(entry) = candidate { - entry.path().is_file() && entry.file_name() == command - } else { false } - }; - - let Some(filtered) = entries.find(filter) else { - elog("Skipping: No entries passed filter"); - continue - }; - - if let Ok(found) = filtered { - return Ok(found.path()) - } else { - elog("Skipping: Filtered match is Err"); - continue - }; - - } - Err("{command} not found in any of the directories in PATH".to_string()) -} - -#[derive(Debug, Clone)] -pub struct Configuration { - pub su_command: Command, -} - - diff --git a/rust/src/main.rs b/rust/src/main.rs deleted file mode 100644 index 7787876..0000000 --- a/rust/src/main.rs +++ /dev/null @@ -1,13 +0,0 @@ -use tori::{conf, log, run}; - -fn main() -> std::process::ExitCode { - log::elog(&format!("tori {}", env!("CARGO_PKG_VERSION"))); - let configuration = conf::load(); - log::elog(&format!("Configuration: {configuration:#?}")); - let mut order = run::teller::parse(std::env::args(), &configuration); - log::elog(&format!("Order: {order:#?}")); - order.fill(); - log::elog(&format!("Filled Order: {order:#?}")); - - if order.finished() { 0.into() } else { 1.into() } -} diff --git a/rust/src/run.rs b/rust/src/run.rs deleted file mode 100644 index 9b08989..0000000 --- a/rust/src/run.rs +++ /dev/null @@ -1,334 +0,0 @@ -// B2.1. DONE version | -v | --version -> MUST print the version as in v0.8.0 -// B2.2. DONE help | -h | --help -> MUST print '' -// B2.3. DONE os -> MUST print the OS name and MUST log contents of /etc/os-release -// B2.4. DONE user -> MUST print the output of the 'whoami' command -// B2.6. DONE echo x y z -> MUST print x y z -// B2.7. DONE echo -> MUST NOT print any output and exit with status code 0 -// B2.8. DONE [no input] -> MUST NOT print any output and exit with status code 0 -// B2.9. DONE [any other input] -> MUST print 'Unrecognized command: [command]', -// a newline, '' and exit with status code 1 - -use crate::conf::Configuration; - -#[derive(Default, Debug)] -pub struct Order<'o> { - tasks: Vec>, -} - -impl Order<'_> { - - pub fn fill(&mut self) { - for task in self.tasks.iter_mut() { - if task.done { continue } - task.complete(); - } - } - - pub fn finished(&self) -> bool { - if self.tasks.is_empty() { - true - } else { - self.tasks.iter().all(|e| e.done) - } - } -} - -#[derive(Debug, Clone)] -pub struct Task<'t> { - kind: TaskKind, - done: bool, - argument: String, - parameters: Vec, - configuration: &'t Configuration, -} - -impl Task<'_> { - pub fn complete(&mut self) { - use crate::{run::exec::{meta, os, pkg}}; - use TaskKind::*; - - self.done = match self.kind { - Version => { meta::print_version() }, - Help => { meta::print_help() }, - OsInfo => { os::print_info() }, - User => { os::print_user() }, - Echo => { meta::echo(self) }, - Unrecognized => { meta::unrecognized(self) }, - Package => { pkg::install_uninstall(self) }, - } - } - - fn new<'t>( - kind: TaskKind, - argument: &str, - parameters: Vec, - configuration: &'t Configuration, - ) -> Task<'t> { - Task { - kind, - done: false, - argument: String::from(argument), - parameters, - configuration, - } - } -} - -#[derive(Debug, Clone)] -pub enum TaskKind { - Version, - Help, - OsInfo, - Package, - User, - Echo, - Unrecognized, -} - -#[derive(Debug, Clone)] -pub struct Command { - pub base: String, - pub args: Vec, -} - -pub mod teller { - use crate::{conf::Configuration, log::elog, run::{Order, Task, TaskKind}}; - use std::{env, path::PathBuf}; - - pub fn parse(mut raw_args: env::Args, configuration: &Configuration) -> Order<'_> { - let (argument, parameters): (String, Vec) = if let Some(first) = raw_args.next() { - if is_executable_path(&first) { - elog("First argument is the executable path"); - if let Some(second) = raw_args.next() { - elog(&format!( - "Assembled command {second}, arguments {raw_args:?}" - )); - (second, raw_args.collect()) - } else { - elog("No arguments provided"); - return Order::default(); - } - } else { - elog("First argument is not the executable path"); - elog(&format!( - "Assembled command {first}, arguments {raw_args:?}" - )); - (first, raw_args.collect()) - } - } else { - elog("No arguments provided"); - return Order::default(); - }; - - let make_order = |kind: TaskKind| -> Order { - Order { tasks: vec![Task::new(kind, &argument, parameters, configuration)] } - }; - - if argument == "version" || argument == "-v" || argument == "--version" { - elog("Command is 'version'"); - make_order(TaskKind::Version) - } else if argument == "help" || argument == "-h" || argument == "--help" { - elog("Command is 'help'"); - make_order(TaskKind::Help) - } else if argument == "os" { - elog("Command is 'os'"); - make_order(TaskKind::OsInfo) - } else if argument == "pkg" { - elog("Command is 'pkg'"); - make_order(TaskKind::Package) - } else if argument == "user" { - elog("Command is 'user'"); - make_order(TaskKind::User) - } else if argument == "echo" { - elog("Command is 'echo'"); - make_order(TaskKind::Echo) - } else { - make_order(TaskKind::Unrecognized) - } - } - - fn is_executable_path(candidate: &str) -> bool { - - fn assume(message: &str) -> bool { - elog(&format!("Assuming args[0] is the executable {message}")); - true - } - - let Ok(executable_path) = env::current_exe() else { - return assume("Failed to get executable path") - }; - let Some(executable_file) = executable_path.file_name() else { - return assume("Executable path lacks a file component") - }; - - let argument_path = PathBuf::from(candidate); - let Some(argument_file) = argument_path.file_name() else { - return assume("Argument path lacks a file component") - }; - - elog(&format!( - "Executable path: {executable_path:?}, file {executable_file:?} \ - Argument path: {argument_path:?}, file {argument_file:?} " - )); - - if argument_path.exists() { - if let Ok(argument_canonical) = argument_path.canonicalize() - && let Ok(executable_canonical) = executable_path.canonicalize() { - let judgment = argument_canonical == executable_canonical; - elog(&format!("args[0] canonically is executable path: {judgment}")); - judgment - } else { - assume("Could not canonicalize executable and argument paths") - } - } else { - let judgment = argument_file == executable_file; - elog(&format!("args[0] matches executable path by name only: {judgment}")); - judgment - } - } - -} - -pub mod expeditor {} - -pub mod exec { - pub mod meta { - use crate::run::Task; - - pub fn print_version() -> bool { - println!("v{}", env!("CARGO_PKG_VERSION")); - true - } - - pub fn print_help() -> bool { - println!(""); - true - } - - pub fn echo(task: &Task) -> bool { - if !task.parameters.is_empty() { - let text = task.parameters.join(" "); - println!("{text}"); - } - true - } - - pub fn unrecognized(task: &Task) -> bool { - println!("Unrecognized command: {}\n", task.argument); - false - } - } - - pub mod os { - use crate::{ log::elog,run::exec::shell::run}; - - pub fn print_info() -> bool { - - let uname_result = run("uname", &["--operating-system"]); - - let os_release_result = if let Ok(os_release) = - std::fs::read_to_string("/etc/os-release") { - elog(&os_release); - true - } else { - elog("Failed reading os-release"); - false - }; - - uname_result.is_ok() && os_release_result - } - - pub fn print_user() -> bool { - run("whoami", &[]).is_ok() - } - } - - pub mod pkg { - use crate::run::Task; - - // B2.5. DONE pkg p -> MUST call the system package manager using the su_command - // to install and then uninstall package p. The user MUST be able to - // freely input to these commands' interactive inputs before control - // is returned. When done, it MUST log 'Done:', a newline, and the - // system commands executed, one per line. If no p is provided, it - // MUST NOT run any system commands and print a message - pub fn install_uninstall(task: &Task) -> bool { - let su_base: String = task.configuration.su_command.base.clone(); - let su_args: Vec = task.configuration.su_command.args.clone(); - let command_base: Vec = vec!["apt".into(), "install".into()]; - let command_args: Vec = task.parameters.clone(); - - let su_args_str: Vec<&str> = su_args.iter().map(|s| s.as_str()).collect(); - let command_base_str: Vec<&str> = command_base.iter().map(|s| s.as_str()).collect(); - let command_args_str: Vec<&str> = command_args.iter().map(|s| s.as_str()).collect(); - - if command_args.is_empty() { - println!("Parameters are empty: Nothing to install or uninstall"); - return false - } - - let args: Vec<&str> = [ - su_args_str, - command_base_str, - command_args_str, - ].iter().flatten().copied().collect(); - - crate::run::exec::shell::spawn(&su_base, &args); - - crate::run::exec::shell::spawn("sudo", vec!["apt", "remove"] - .into_iter() - .chain(task.parameters.iter().map(|s| s.as_str())) - .collect::>().as_slice()); - - println!( - "Done:\n{su_base} {} {} {}", - su_args.join(" "), - command_base.join(" "), - command_args.join(" "), - ); - true - } - } - - pub mod shell { - use std::process::Command; - - pub fn spawn(command: &str, args: &[&str]) -> bool { - if let Ok(mut child) = Command::new(command) - .args(args) - .spawn() { - let Ok(exit_status) = child.wait() else { return false }; - exit_status.success() - } else { - false - } - } - - pub fn run(command: &str, args: &[&str]) -> Result { - use std::process::Command; - - if let Ok(output) = Command::new(command) - .args(args) - .output() - - { - if let Ok(utf8) = String::from_utf8(output.stdout) { - print!("{utf8}"); - Ok(utf8) - } else { - let message = format!("Failed UTF8 coversion of {command} output"); - eprintln!("{message}"); - Err(message) - } - } else { - let message = format!("Failed executing or reading output of {command}"); - eprintln!("{message}"); - Err(message) - - } - } - - } - - -} diff --git a/src/conf.rs b/src/conf.rs new file mode 100644 index 0000000..cfa81fc --- /dev/null +++ b/src/conf.rs @@ -0,0 +1,161 @@ +use std::{ + collections::HashMap, + fs::{self, DirEntry}, + path::PathBuf, +}; + +use crate::{ + log::{self, elog}, + run::Command, +}; + +pub fn load() -> Configuration { + log::elog("Loading configuration"); + + let mut conf = Configuration { + su_command: SuCommand { + command: Command::new("su", &["-c", "{% command %}"]), + wraps: true, + }, + su_command_wraps: None, + }; + + let root = get_root(); + let Ok(contents) = fs::read_to_string(root.join("tori.conf")) else { + eprintln!("Failed reading configuration file at {root:?}"); + return conf; + }; + + let lines: Vec<(&str, &str)> = contents + .lines() + .filter_map(|line| line.split_once('=')) + .collect(); + + let mut map: HashMap = HashMap::new(); + + for line in &lines { + let (key, value) = line; + map.insert(key.to_string(), value.to_string()); + } + + elog(&format!("{lines:#?}")); + + if let Some(su_command) = map.get("su_command") { + let split: Vec = su_command + .split(' ') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + + if let Some((base, args)) = split.split_first() + && let Ok(resolved_path) = resolve_from_path(base) + { + elog(&format!( + "Succesfully resolved 'su_command' configuration value \ + {su_command} through PATH to {resolved_path:?}, with base \ + {base} and args {args:?}" + )); + conf.su_command = SuCommand { + command: Command::new_from_strings(base, args), + wraps: map.get("su_command_wraps").is_some_and(|v| v == "true"), + } + } else { + eprintln!("Failed validation of 'su_command' configuration value"); + } + } + + if let Some(su_command_wraps) = map.get("su_command_wraps") { + conf.su_command_wraps = Some(su_command_wraps == "true"); + } + + conf +} + +fn get_root() -> PathBuf { + if let Ok(xdg_config_dir) = std::env::var("XDG_CONFIG_DIR") { + PathBuf::from(xdg_config_dir).join("tori") + } else { + if let Some(mut root) = std::env::home_dir() { + root.push(".config"); + root.push("tori"); + root + } else { + if let Ok(user) = std::env::var("USER") { + PathBuf::from("/home").join(user).join(".config").join("tori") + } else { + eprintln!("Failed to determine home directory"); + PathBuf::from("/etc/tori") + } + } + } +} + +fn resolve_from_path(command: &str) -> Result { + elog(&format!("Solving from PATH for {command}")); + + let paths: Vec = if let Ok(path) = std::env::var("PATH") { + path.split(':') + .filter(|p| !p.is_empty() && PathBuf::from(p).is_dir()) + .map(PathBuf::from) + .collect() + } else { + elog("Error: PATH is not set"); + return Err(format!( + "{command} not found: PATH is not set in the environment" + )); + }; + + elog(&format!("Gathered paths {paths:?}")); + for path in paths { + elog(&format!("On path {path:?}")); + let Ok(mut entries) = fs::read_dir(path) else { + elog("Skipping: Could not read directory contents"); + continue; + }; + + let filter = |candidate: &Result| -> bool { + if let Ok(entry) = candidate { + entry.path().is_file() && entry.file_name() == command + } else { + false + } + }; + + let Some(filtered) = entries.find(filter) else { + elog("Skipping: No entries passed filter"); + continue; + }; + + if let Ok(found) = filtered { + return Ok(found.path()); + } else { + elog("Skipping: Filtered match is Err"); + continue; + }; + } + Err(format!( + "{command} not found in any of the directories in PATH" + )) +} + +#[derive(Default, Debug, Clone, PartialEq, Eq)] +pub struct Configuration { + pub su_command: SuCommand, + pub su_command_wraps: Option, +} + +#[derive(Default, Debug, Clone, PartialEq, Eq)] +pub struct SuCommand { + command: Command, + wraps: bool, +} + +impl SuCommand { + pub fn command(&self) -> Command { + self.command.clone() + } + + pub const fn wraps(&self) -> bool { + self.wraps + } +} diff --git a/rust/src/lib.rs b/src/lib.rs similarity index 57% rename from rust/src/lib.rs rename to src/lib.rs index fc03ecd..11eea8a 100644 --- a/rust/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,7 @@ pub mod conf; -pub mod log; +pub mod state; + +pub mod os; pub mod run; + +pub mod log; diff --git a/rust/src/log.rs b/src/log.rs similarity index 64% rename from rust/src/log.rs rename to src/log.rs index 7732696..b7bc14b 100644 --- a/rust/src/log.rs +++ b/src/log.rs @@ -1,6 +1,8 @@ pub fn elog(message: &str) { // DONE MUST be printed only if DEBUG is set in the environment - if let Ok(debug) = std::env::var("DEBUG") && !debug.is_empty() { + if let Ok(debug) = std::env::var("DEBUG") + && !debug.is_empty() + { eprintln!(" [log] {message}"); } } diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..48e19e6 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,15 @@ +use tori::{conf, log, run, state}; + +fn main() -> std::process::ExitCode { + log::elog(&format!("tori {}", env!("CARGO_PKG_VERSION"))); + let configuration = conf::load(); + log::elog(&format!("Configuration: {configuration:#?}")); + let order = run::teller::parse(std::env::args(), &configuration); + log::elog(&format!("Order: {order:#?}")); + let state = state::setup(&configuration, &[order]); + log::elog(&format!("State: {state:#?}")); + let result = run::expeditor::fulfill(&state); + log::elog(&format!("Filled Order: {result:#?}")); + + if result.is_ok() { 0.into() } else { 1.into() } +} diff --git a/src/os.rs b/src/os.rs new file mode 100644 index 0000000..e0f9ee6 --- /dev/null +++ b/src/os.rs @@ -0,0 +1,39 @@ +use pkg::Packager; + +pub mod pkg; + +pub mod debian; + +#[derive(Clone, Default, Debug, PartialEq, Eq)] +pub struct OperatingSystem { + kind: Kind, + packager: Packager, +} + +impl OperatingSystem { + pub fn kind(&self) -> Kind { + self.kind.clone() + } + + pub fn packager(&self) -> Packager { + self.packager.clone() + } + + pub const fn new(kind: Kind, packager: Packager) -> OperatingSystem { + OperatingSystem { kind, packager } + } + + pub const fn unknown() -> OperatingSystem { + OperatingSystem { + kind: Kind::Unknown, + packager: Packager::Unknown, + } + } +} + +#[derive(Default, Debug, Clone, Eq, PartialEq)] +pub enum Kind { + #[default] + Unknown, + Debian, +} diff --git a/src/os/debian.rs b/src/os/debian.rs new file mode 100644 index 0000000..7cf85b8 --- /dev/null +++ b/src/os/debian.rs @@ -0,0 +1,169 @@ +use std::fs::read_to_string; + +use crate::{ + conf::Configuration, + log::elog, + os::{ + OperatingSystem, Kind, + pkg::{self, Package, PackagerVariant, Packages}, + }, + run::{Command, executor::read}, +}; + +pub const DEBIAN: OperatingSystem = OperatingSystem { + kind: Kind::Debian, + packager: pkg::Packager::Apt(APT), +}; + +const APT: Apt = Apt { + variant: PackagerVariant::Apt, +}; + +#[derive(Default, Debug, Clone, PartialEq, Eq)] +pub struct Apt { + variant: PackagerVariant, +} + +impl Packages for Apt { + fn install(&self, packages: &[Package], config: &Configuration) -> Result<(), pkg::Error> { + super::debian::Apt::haul("install", packages, config) + } + + fn uninstall(&self, packages: &[Package], config: &Configuration) -> Result<(), pkg::Error> { + super::debian::Apt::haul("remove", packages, config) + } + + fn manual(&self) -> Result, pkg::Error> { + let raw_all = read(&Command::new( + "dpkg-query", + &["--show", "--showformat", "${Package} ${Status}\\n"], + ))?; + let all: Vec = raw_all + .lines() + .filter_map(|line| { + let pair = line.split_once(' '); + match pair { + Some((pkg, "install ok installed")) => Some(pkg.to_string()), + Some(_) => None, + None => { + elog("Warning: Dropped a None pair when cleaning up package list"); + None + } + } + }) + .collect(); + + let auto_packages: Vec = self.automatic()?; + let mut manual_packages: Vec = vec![]; + + for package in all { + let auto = Package::new_with_manual(&package, false); + if !auto_packages.contains(&auto) { + manual_packages.push(Package::new_with_manual(&package, true)); + } + } + + Ok(manual_packages) + } + + fn automatic(&self) -> Result, pkg::Error> { + let path = "/var/lib/apt/extended_states"; + let Ok(extended_states) = read_to_string(path) else { + return pkg::Error::send( + &format!("Failed reading {path}"), + pkg::ErrorKind::MetadataFileRead, + ); + }; + let lines: Vec = extended_states + .lines() + .map(|s| s.to_string()) + .filter(|line| !line.is_empty()) + .collect(); + + let iterator = lines.chunks_exact(3); + let remainder = iterator.remainder(); + if !remainder.is_empty() { + elog(&format!( + "Warning: Reading package extended states left a remainder: {remainder:?}" + )); + } + + let mut packages: Vec = vec![]; + + for chunk in iterator { + if let Some(name_line) = chunk.first() + && let Some(auto_line) = chunk.get(2) + { + let Some(name_key) = name_line.split(' ').nth(0) else { + elog(&format!( + "Warning: Unexpected structure for package line when \ + reading extended states chunk {chunk:?}" + )); + continue; + }; + if name_key != "Package:" { + elog(&format!( + "Warning: Expected package line key to be 'Package:' \ + but found {name_key} instead in chunk {chunk:?}" + )); + continue; + } + let Some(name_value) = name_line.split(' ').nth(1) else { + elog(&format!( + "Warning: No package name when reading extended states chunk {chunk:?}" + )); + continue; + }; + + let Some(auto_key) = auto_line.split(' ').nth(0) else { + elog(&format!( + "Warning: Unexpected structure for auto-installed line \ + when reading extended states chunk {chunk:?}" + )); + continue; + }; + if auto_key != "Auto-Installed:" { + elog(&format!( + "Warning: Expected auto-installed line key to be 'Auto-Installed:' \ + but found {auto_key} instead in chunk {chunk:?}" + )); + continue; + } + let Some(auto_value) = auto_line.split(' ').nth(1) else { + elog(&format!( + "Warning: No auto-installed value when reading extended states chunk {chunk:?}" + )); + continue; + }; + + packages.push(Package::new_with_manual(name_value, auto_value == "0")); + } + } + + Ok(packages) + } + + fn variant(&self) -> Result { + Ok(self.variant.clone()) + } +} + +impl Apt { + fn haul( + subcommand: &str, + packages: &[Package], + config: &Configuration, + ) -> Result<(), pkg::Error> { + if packages.is_empty() { + println!("Package selection is empty: Nothing to {subcommand}"); + return Ok(()); + } + + let mut args = vec![subcommand]; + args.extend_from_slice(&packages.iter().map(|p| p.into()).collect::>()); + + let command = Command::new("apt", &args).escalate(config)?; + + Ok(crate::run::executor::spawn(&command)?) + } +} diff --git a/src/os/pkg.rs b/src/os/pkg.rs new file mode 100644 index 0000000..4047629 --- /dev/null +++ b/src/os/pkg.rs @@ -0,0 +1,225 @@ +use core::{ + clone::Clone, + cmp::{Eq, PartialEq}, + default::Default, + fmt::Debug, +}; + +use crate::{conf::Configuration, os::debian, run}; + +pub trait Packages: Clone + Default + Debug + PartialEq + Eq { + fn install(&self, packages: &[Package], config: &Configuration) -> Result<(), Error>; + fn uninstall(&self, packages: &[Package], config: &Configuration) -> Result<(), Error>; + fn manual(&self) -> Result, Error>; + fn automatic(&self) -> Result, Error>; + fn variant(&self) -> Result; +} + +#[derive(Clone, Default, Debug, PartialEq, Eq)] +pub enum Packager { + Apt(debian::Apt), + #[default] + Unknown, +} + +impl Packages for Packager { + fn install(&self, packages: &[Package], config: &Configuration) -> Result<(), Error> { + match self { + Packager::Apt(p) => p.install(packages, config), + Packager::Unknown => Error::unknown_packager(&format!("install {packages:?}")), + } + } + + fn uninstall(&self, packages: &[Package], config: &Configuration) -> Result<(), Error> { + match self { + Packager::Apt(p) => p.uninstall(packages, config), + Packager::Unknown => Error::unknown_packager(&format!("uninstall {packages:?}")), + } + } + + fn manual(&self) -> Result, Error> { + match self { + Packager::Apt(p) => p.manual(), + Packager::Unknown => Error::unknown_packager("list manually-installed packages"), + } + } + + fn automatic(&self) -> Result, Error> { + match self { + Packager::Apt(p) => p.automatic(), + Packager::Unknown => Error::unknown_packager("list automatically-installed packages"), + } + } + + fn variant(&self) -> Result { + match self { + Packager::Apt(p) => p.variant(), + Packager::Unknown => Error::unknown_packager( + "Can't determine the package manager's variant because it is unknown", + ), + } + } +} + +#[derive(Default, Debug, Clone, Eq, PartialEq)] +pub enum PackagerVariant { + Apt, + #[default] + Unknown, +} + +#[derive(Default, Debug, Clone, Eq, PartialEq)] +pub struct Package { + name: String, + version: Option, + manual: Option, +} + +impl Package { + pub fn name(&self) -> String { + self.name.clone() + } + + pub fn new_with_manual(name: &str, manual: bool) -> Package { + Package { + name: name.to_string(), + version: None, + manual: Some(manual), + } + } +} + +impl From<&String> for Package { + fn from(s: &String) -> Package { + Package { + name: s.clone(), + ..Package::default() + } + } +} + +impl From for Package { + fn from(s: String) -> Package { + Package { + name: s, + ..Package::default() + } + } +} + +impl From<&str> for Package { + fn from(s: &str) -> Package { + Package { + name: s.to_string(), + ..Package::default() + } + } +} + +impl From for String { + fn from(p: Package) -> String { + p.name + } +} + +impl From<&Package> for String { + fn from(p: &Package) -> String { + p.name.clone() + } +} + +impl<'s> From<&'s Package> for &'s str { + fn from(p: &'s Package) -> &'s str { + &p.name + } +} + +impl std::fmt::Display for Package { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + if let Some(version) = &self.version { + write!(f, "{} {}", &self.name, version) + } else { + write!(f, "{}", &self.name) + } + } +} + +#[derive(Default, Debug, Clone, Eq, PartialEq)] +struct Version { + major: u32, + minor: Option, + patch: Option, + qualifier: Option, +} + +impl std::fmt::Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + if let Some(minor) = &self.minor + && let Some(patch) = &self.patch + && let Some(qualifier) = &self.qualifier + { + write!(f, "{}.{minor}.{patch}-{qualifier}", &self.major) + } else if let Some(minor) = &self.minor + && let Some(patch) = &self.patch + { + write!(f, "{}.{minor}.{patch}", &self.major) + } else if let Some(minor) = &self.minor { + write!(f, "{}.{minor}", &self.major) + } else { + write!(f, "{}", &self.major) + } + } +} + +#[derive(Debug, Clone)] +pub struct Error { + pub message: String, + pub kind: ErrorKind, +} + +impl Error { + pub fn new(message: &str, kind: ErrorKind) -> Error { + Error { + message: message.to_string(), + kind, + } + } + + pub fn send(message: &str, kind: ErrorKind) -> Result { + Err(Error::new(message, kind)) + } + + fn unknown_packager(action: &str) -> Result { + Err(Error { + message: format!("Can't {action} because package manager is unknown"), + kind: ErrorKind::UnknownPackager, + }) + } +} + +impl From for Error { + fn from(executor_error: run::executor::Error) -> Error { + Error { + message: format!("{:?}: {}", executor_error.kind, executor_error.message), + kind: ErrorKind::ExecutorError, + } + } +} + +impl From for Error { + fn from(run_error: run::Error) -> Error { + Error { + message: format!("{:?}: {}", run_error.kind, run_error.message), + kind: ErrorKind::RunError, + } + } +} + +#[derive(Debug, Clone)] +pub enum ErrorKind { + NotFound, + UnknownPackager, + MetadataFileRead, + RunError, + ExecutorError, +} diff --git a/src/run.rs b/src/run.rs new file mode 100644 index 0000000..8cfb045 --- /dev/null +++ b/src/run.rs @@ -0,0 +1,146 @@ +use crate::{conf::Configuration, log::elog}; + +pub mod executor; +pub mod expeditor; +pub mod teller; + +#[derive(Default, Debug, Clone, PartialEq, Eq)] +pub struct Order { + tasks: Vec, +} + +impl Order { + pub fn finished(&self) -> bool { + if self.tasks.is_empty() { + true + } else { + self.tasks.iter().all(|e| e.done) + } + } + + pub fn tasks(&self) -> Vec { + self.tasks.clone() + } +} + +#[derive(Default, Debug, Clone, PartialEq, Eq)] +pub struct Task { + kind: TaskKind, + done: bool, + argument: String, + parameters: Vec, + configuration: Configuration, +} + +impl Task { + fn new( + kind: TaskKind, + argument: &str, + parameters: Vec, + configuration: &Configuration, + ) -> Task { + Task { + kind, + done: false, + argument: String::from(argument), + parameters, + configuration: configuration.clone(), + } + } + + pub fn configuration(&self) -> Configuration { + self.configuration.clone() + } + + pub fn argument(&self) -> String { + self.argument.clone() + } + + pub fn parameters(&self) -> Vec { + self.parameters.clone() + } + + pub const fn done(&self) -> bool { + self.done + } + + pub fn kind(&self) -> TaskKind { + self.kind.clone() + } +} + +#[derive(Default, Debug, Clone, PartialEq, Eq)] +pub enum TaskKind { + Version, + Help, + PackageInstall, + PackageUninstall, + PackageListAuto, + PackageListManual, + #[default] + Unrecognized, +} + +#[derive(Default, Debug, Clone, PartialEq, Eq)] +pub struct Command { + pub base: String, + pub args: Vec, +} + +impl Command { + pub fn escalate(&self, config: &Configuration) -> Result { + let mut args = config.su_command.command().args; + + if config.su_command.wraps() { + let flattened_command = format!("{} {}", self.base, self.args.join(" ")); + let marker_index = args + .iter() + .position(|s| s.replace(" ", "") == "{%command%}"); + if let Some(index) = marker_index + && let Some(marker) = args.get_mut(index) + { + *marker = flattened_command; + } else { + let message = "Could not replace command marker in su command from configuration"; + elog(message); + return Err(Error { + message: message.to_string(), + kind: ErrorKind::BadSuCommandConfig, + }); + } + } else { + args.push(self.base.clone()); + args.extend_from_slice(&self.args); + } + + Ok(Command { + base: config.su_command.command().base, + args, + }) + } + + pub fn new(base: &str, args: &[&str]) -> Command { + Command { + base: base.to_string(), + args: args.iter().map(|e| e.to_string()).collect(), + } + } + + pub fn new_from_strings(base: &str, args: &[String]) -> Command { + Command { + base: base.to_string(), + args: args.to_vec(), + } + } +} + +#[derive(Debug)] +pub struct Error { + pub message: String, + pub kind: ErrorKind, +} + +#[derive(Debug)] +pub enum ErrorKind { + BadSuCommandConfig, +} diff --git a/src/run/executor.rs b/src/run/executor.rs new file mode 100644 index 0000000..7bbb2ed --- /dev/null +++ b/src/run/executor.rs @@ -0,0 +1,86 @@ +use std::process; + +use crate::{os::pkg::Package, run::Command}; + +pub mod meta; + +pub fn print(message: &str) -> Result<(), Error> { + println!("{message}"); + Ok(()) +} + +pub fn print_packages(packages: Vec) -> Result<(), Error> { + for package in packages { + print(&format!("{package}"))?; + } + Ok(()) +} + +pub fn spawn(command: &Command) -> Result<(), Error> { + if let Ok(mut child) = process::Command::new(&command.base) + .args(&command.args) + .spawn() + { + let Ok(exit_status) = child.wait() else { + return Err(Error { + message: format!("Error while waiting for child to exit given {command:?}"), + kind: ErrorKind::ChildExit, + }); + }; + if exit_status.success() { + Ok(()) + } else { + Err(Error { + message: format!("Command {command:?} did not exit with success"), + kind: ErrorKind::DirtyExit, + }) + } + } else { + Err(Error { + message: format!("Failed to spawn child for command {command:?}"), + kind: ErrorKind::FailedSpawn, + }) + } +} + +pub fn read(command: &Command) -> Result { + if let Ok(output) = process::Command::new(&command.base) + .args(&command.args) + .output() + { + if let Ok(utf8) = String::from_utf8(output.stdout) { + Ok(utf8) + } else { + let message = format!("Failed UTF8 coversion of {command:?} output"); + eprintln!("{message}"); + Err(Error { + message, + kind: ErrorKind::UTF8, + }) + } + } else { + let message = format!("Failed executing or reading output of {command:?}"); + eprintln!("{message}"); + Err(Error { + message, + kind: ErrorKind::IO, + }) + } +} + +#[derive(Debug)] +pub struct Error { + pub message: String, + pub kind: ErrorKind, +} + +#[derive(Debug)] +pub enum ErrorKind { + CommandNotFound, + Unknown, + FailedSpawn, + ChildExit, + DirtyExit, + UTF8, + IO, +} diff --git a/src/run/executor/meta.rs b/src/run/executor/meta.rs new file mode 100644 index 0000000..22bd526 --- /dev/null +++ b/src/run/executor/meta.rs @@ -0,0 +1,16 @@ +use crate::run::Task; + +pub fn print_version() -> bool { + println!("v{}", env!("CARGO_PKG_VERSION")); + true +} + +pub fn print_help() -> bool { + println!(""); + true +} + +pub fn unrecognized(task: &Task) -> bool { + println!("Unrecognized command: {}\n", task.argument); + false +} diff --git a/src/run/expeditor.rs b/src/run/expeditor.rs new file mode 100644 index 0000000..ef23492 --- /dev/null +++ b/src/run/expeditor.rs @@ -0,0 +1,95 @@ +use crate::{ + os::pkg::{self, Package, Packages as _}, + run::{TaskKind, executor}, + state::State, +}; + +pub fn fulfill(state: &State) -> Result<(), Error> { + let orders = state.orders(); + + for order in orders { + if order.finished() { + continue; + } + for task in order.tasks().iter().filter(|t| !t.done()) { + match task.kind() { + TaskKind::Version => executor::print(env!("CARGO_PKG_VERSION"))?, + TaskKind::Help => executor::print("")?, + TaskKind::PackageInstall => { + let packages: Vec = task.parameters.iter().map(|s| s.into()).collect(); + state + .os() + .packager() + .install(&packages, &state.configuration())?; + } + TaskKind::PackageUninstall => { + let packages: Vec = task.parameters.iter().map(|s| s.into()).collect(); + state + .os() + .packager() + .uninstall(&packages, &state.configuration())?; + } + TaskKind::PackageListAuto => { + match state.os().packager().automatic() { + Ok(packages) => Ok::<(), Error>(executor::print_packages(packages)?), + Err(error) => { + executor::print(&format!( + "Error gathering automatically-installed packages: {error:?}", + ))?; + Err(error.into()) + } + }?; + } + TaskKind::PackageListManual => { + match state.os().packager().manual() { + Ok(packages) => Ok::<(), Error>(executor::print_packages(packages)?), + Err(error) => { + executor::print(&format!( + "Error gathering manually-installed packages: {error:?}", + ))?; + Err(error.into()) + } + }?; + } + TaskKind::Unrecognized => executor::print(&format!( + "Unrecognized command: {}\n", + task.argument() + ))?, + } + } + } + + Ok(()) +} + +#[derive(Debug)] +pub struct Error { + pub message: String, + pub kind: ErrorKind, +} + +impl From for Error { + fn from(executor_error: executor::Error) -> Error { + Error { + message: format!("{:?}: {}", executor_error.kind, executor_error.message), + kind: ErrorKind::ExecutorError, + } + } +} + +impl From for Error { + fn from(pkg_error: pkg::Error) -> Error { + Error { + message: format!("{:?}: {}", pkg_error.kind, pkg_error.message), + kind: ErrorKind::PackagingError, + } + } +} + +#[derive(Debug)] +pub enum ErrorKind { + ExecutorError, + PackagingError, + PackagerUnknown, + OsUnknown, +} diff --git a/src/run/teller.rs b/src/run/teller.rs new file mode 100644 index 0000000..afb4bb8 --- /dev/null +++ b/src/run/teller.rs @@ -0,0 +1,99 @@ +use crate::{ + conf::Configuration, + log::elog, + run::{Order, Task, TaskKind}, +}; +use std::{env, path::PathBuf}; + +pub fn parse(mut raw_args: env::Args, configuration: &Configuration) -> Order { + let (argument, parameters): (String, Vec) = if let Some(first) = raw_args.next() { + if is_executable_path(&first) { + elog("First argument is the executable path"); + if let Some(second) = raw_args.next() { + elog(&format!( + "Assembled command {second}, arguments {raw_args:?}" + )); + (second, raw_args.collect()) + } else { + elog("No arguments provided"); + return Order::default(); + } + } else { + elog("First argument is not the executable path"); + elog(&format!( + "Assembled command {first}, arguments {raw_args:?}" + )); + (first, raw_args.collect()) + } + } else { + elog("No arguments provided"); + return Order::default(); + }; + + let make_order = |kind: TaskKind| -> Order { + Order { + tasks: vec![Task::new(kind, &argument, parameters, configuration)], + } + }; + + elog(&format!("Command is {argument}")); + if argument == "version" || argument == "-v" || argument == "--version" { + make_order(TaskKind::Version) + } else if argument == "help" || argument == "-h" || argument == "--help" { + make_order(TaskKind::Help) + } else if argument == "install" { + make_order(TaskKind::PackageInstall) + } else if argument == "uninstall" { + make_order(TaskKind::PackageUninstall) + } else if argument == "auto" { + make_order(TaskKind::PackageListAuto) + } else if argument == "manual" { + make_order(TaskKind::PackageListManual) + } else { + make_order(TaskKind::Unrecognized) + } +} + +fn is_executable_path(candidate: &str) -> bool { + fn assume(message: &str) -> bool { + elog(&format!("Assuming args[0] is the executable {message}")); + true + } + + let Ok(executable_path) = env::current_exe() else { + return assume("Failed to get executable path"); + }; + let Some(executable_file) = executable_path.file_name() else { + return assume("Executable path lacks a file component"); + }; + + let argument_path = PathBuf::from(candidate); + let Some(argument_file) = argument_path.file_name() else { + return assume("Argument path lacks a file component"); + }; + + elog(&format!( + "Executable path: {executable_path:?}, file {executable_file:?} \ + Argument path: {argument_path:?}, file {argument_file:?} " + )); + + if argument_path.exists() { + if let Ok(argument_canonical) = argument_path.canonicalize() + && let Ok(executable_canonical) = executable_path.canonicalize() + { + let judgment = argument_canonical == executable_canonical; + elog(&format!( + "args[0] canonically is executable path: {judgment}" + )); + judgment + } else { + assume("Could not canonicalize executable and argument paths") + } + } else { + let judgment = argument_file == executable_file; + elog(&format!( + "args[0] matches executable path by name only: {judgment}" + )); + judgment + } +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..83bc73e --- /dev/null +++ b/src/state.rs @@ -0,0 +1,91 @@ +use std::collections::HashMap; + +use crate::{ + conf::Configuration, + log::elog, + os::OperatingSystem, + run::{Command, Order, executor::read}, +}; + +#[derive(Default, Debug, Clone, Eq, PartialEq)] +pub struct State { + configuration: Configuration, + os: OperatingSystem, + orders: Vec, +} + +impl State { + fn new(config: &Configuration, os: &OperatingSystem, orders: &[Order]) -> State { + State { + configuration: config.clone(), + os: os.clone(), + orders: orders.to_vec(), + } + } + + pub fn configuration(&self) -> Configuration { + self.configuration.clone() + } + + pub fn os(&self) -> OperatingSystem { + self.os.clone() + } + + pub fn orders(&self) -> Vec { + self.orders.clone() + } +} + +pub fn setup(config: &Configuration, orders: &[Order]) -> State { + State::new(config, &detect_os(), orders) +} + +fn detect_os() -> OperatingSystem { + use crate::os; + + if let Ok(os_release) = std::fs::read_to_string("/etc/os-release") { + elog(&os_release); + let mut map: HashMap = HashMap::new(); + let lines: Vec> = os_release + .lines() + .map(|line| line.split_once('=')) + .collect(); + for line in lines.into_iter().flatten() { + let (key, value) = line; + map.insert(key.to_string(), strip_quotes(value)); + } + + elog(&format!("os-release map: {map:#?}")); + + // TODO This should ideally exhaustively check against the possible OSs + if let Some(os_name) = map.get("NAME") { + if os_name == "Debian GNU/Linux" { + return os::debian::DEBIAN; + } + } + } else { + elog("Failed reading os-release"); + if let Ok(uname_stdout) = read(&Command::new("uname", &["--operating-system"])) { + if uname_stdout == "Debian GNU/Linux" { + return os::debian::DEBIAN; + } + } else { + elog("Failed reading uname output"); + } + } + + elog("OS detection failed"); + OperatingSystem::unknown() +} + +fn strip_quotes(original: &str) -> String { + let no_prefix = match original.strip_prefix('"') { + Some(stripped) => stripped, + None => original, + }; + let no_suffix = match no_prefix.strip_suffix('"') { + Some(stripped) => stripped, + None => original, + }; + no_suffix.to_string() +} From 7d89f51eac29de4ac49978694a3a913d300f4b05 Mon Sep 17 00:00:00 2001 From: jutty Date: Mon, 30 Mar 2026 11:40:04 -0300 Subject: [PATCH 10/10] Add test containers --- containers/Containerfile.test | 17 ++++++++++++ containers/Containerfile.test-clean | 20 +++++++++++++++ containers/build-run.sh | 6 +++++ containers/build.sh | 27 +++++++++++++++++++ containers/run.sh | 16 ++++++++++++ containers/test-clean.sh | 38 +++++++++++++++++++++++++++ containers/test.sh | 40 +++++++++++++++++++++++++++++ src/conf.rs | 5 +++- src/os/debian.rs | 2 +- 9 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 containers/Containerfile.test create mode 100644 containers/Containerfile.test-clean create mode 100755 containers/build-run.sh create mode 100755 containers/build.sh create mode 100755 containers/run.sh create mode 100755 containers/test-clean.sh create mode 100755 containers/test.sh diff --git a/containers/Containerfile.test b/containers/Containerfile.test new file mode 100644 index 0000000..ddc07e1 --- /dev/null +++ b/containers/Containerfile.test @@ -0,0 +1,17 @@ +FROM debian:trixie +MAINTAINER Juno Takano juno@jutty.dev + +RUN apt-get --update install -y sudo +RUN useradd -mU sudoer +RUN echo "sudoer ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers + +USER sudoer +WORKDIR /home/sudoer +RUN mkdir -p .config/tori +RUN touch .config/tori/tori.conf +RUN echo "su_command = sudo" > .config/tori/tori.conf + +COPY tori /usr/local/bin/tori +COPY test.sh /usr/local/bin/test.sh + +CMD test.sh diff --git a/containers/Containerfile.test-clean b/containers/Containerfile.test-clean new file mode 100644 index 0000000..6167460 --- /dev/null +++ b/containers/Containerfile.test-clean @@ -0,0 +1,20 @@ +FROM debian:trixie +MAINTAINER Juno Takano juno@jutty.dev + +# RUN apt-get --update install -y sudo +# RUN useradd -mU sudoer +# RUN echo "sudoer ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers + +RUN mkdir -p /root/.config/tori +RUN touch /root/.config/tori/tori.conf + +# USER sudoer +# WORKDIR /home/sudoer +# RUN mkdir -p .config/tori +# RUN touch .config/tori/tori.conf +# RUN echo "su_command = sudo" > .config/tori/tori.conf + +COPY tori /usr/local/bin/tori +COPY test-clean.sh /usr/local/bin/test-clean.sh + +CMD test-clean.sh diff --git a/containers/build-run.sh b/containers/build-run.sh new file mode 100755 index 0000000..f577572 --- /dev/null +++ b/containers/build-run.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env sh + +set -eu + +./build.sh "$1" && clear +./run.sh "$1" diff --git a/containers/build.sh b/containers/build.sh new file mode 100755 index 0000000..affcd99 --- /dev/null +++ b/containers/build.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env sh + +set -eu +suffix=$(printf '%s' "$1" | sed 's/.*\.//') +binary=tori +tag="$binary:$suffix" +shift + +if podman container exists "$tag"; then + podman stop --time 3 "$tag" +fi + +if ! [ -f "../target/debug/$binary" ]; then + cd .. + cargo build + cd - +fi + +cp -v ../target/debug/$binary $binary + +podman build \ + --tag "$tag" \ + -f "Containerfile.$suffix" "$@" + +if [ -f $binary ]; then + rm -v $binary +fi diff --git a/containers/run.sh b/containers/run.sh new file mode 100755 index 0000000..207865f --- /dev/null +++ b/containers/run.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env sh + +set -eu +suffix=$(printf '%s' "$1" | sed 's/.*\.//') +binary=tori +name="$binary-$suffix" +tag="$binary:$suffix" +shift + +podman run \ + --replace \ + --name "$name" \ + --publish 3008:80 \ + --init \ + "$@" \ + "$tag" diff --git a/containers/test-clean.sh b/containers/test-clean.sh new file mode 100755 index 0000000..1bbcdb7 --- /dev/null +++ b/containers/test-clean.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env sh + +set -eu + +info() { printf ' [info] %b\n' "$1"; } +announce() { printf ' [test] %b\n' "$1"; } +ok() { printf " [ OK ] %b\n" "$1"; } +fail() { printf " [FAIL] %b\n" "$1"; exit 1; } + +try() { + actual="$1" + expected="$2" + fail_message="${3:-}" + ok_message="${4:-}" + + if [ "$actual" = "$expected" ]; then + ok "$ok_message" + else + fail "Expected <$expected>, got <$actual> $fail_message" + fi +} + +announce "Fresh install has no manually installed packages" +tori_manual=$(tori manual) +try "$tori_manual" "" + +info "Updating apt packages" +apt-get update >/dev/null + +announce "Manually installed package is the only package in 'tori manual'" +apt-get install -y figlet >/dev/null 2>&1 +tori_manual=$(tori manual) +try "$tori_manual" figlet + +announce "Manually installed packages are the only packages in 'tori manual'" +apt-get install -y sudo >/dev/null 2>&1 +tori_manual=$(tori manual | sort) +try "$tori_manual" "$(printf 'figlet\nsudo')" diff --git a/containers/test.sh b/containers/test.sh new file mode 100755 index 0000000..08ba0d0 --- /dev/null +++ b/containers/test.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env sh + +set -eu + +info() { printf ' [info] %b\n' "$1"; } +announce() { printf ' [test] %b\n' "$1"; } +ok() { printf " [ OK ] %b\n" "$1"; } +fail() { printf " [FAIL] %b\n" "$1"; exit 1; } + +try() { + actual="$1" + expected="$2" + operator="${3:-=}" + fail_message="${3:-}" + ok_message="${4:-}" + + # shellcheck disable=1073,1072,1009 + if [ "$actual" "$operator" "$expected" ]; then + ok "$ok_message" + else + fail_message=${fail_message:+": $fail_message"} + fail "Expected <$expected>, got <$actual>$fail_message" + fi +} + +announce "sudo works" +whoami=$(whoami) +sudo_whoami=$(sudo whoami) +echo try "$whoami" "$sudo_whoami" != +try "$whoami" "$sudo_whoami" != +echo try "$sudo_whoami" root +try "$sudo_whoami" root + +info "Updating apt packages" +sudo apt-get update >/dev/null + +announce "Manually installed packages are the only packages in 'tori manual'" +sudo apt-get install -y sudo >/dev/null 2>&1 +tori_manual=$(tori manual | sort) +try "$tori_manual" "sudo" diff --git a/src/conf.rs b/src/conf.rs index cfa81fc..71db0d5 100644 --- a/src/conf.rs +++ b/src/conf.rs @@ -81,7 +81,10 @@ fn get_root() -> PathBuf { root } else { if let Ok(user) = std::env::var("USER") { - PathBuf::from("/home").join(user).join(".config").join("tori") + PathBuf::from("/home") + .join(user) + .join(".config") + .join("tori") } else { eprintln!("Failed to determine home directory"); PathBuf::from("/etc/tori") diff --git a/src/os/debian.rs b/src/os/debian.rs index 7cf85b8..7e7f417 100644 --- a/src/os/debian.rs +++ b/src/os/debian.rs @@ -4,7 +4,7 @@ use crate::{ conf::Configuration, log::elog, os::{ - OperatingSystem, Kind, + Kind, OperatingSystem, pkg::{self, Package, PackagerVariant, Packages}, }, run::{Command, executor::read},