Compare commits

..

10 commits

60 changed files with 1510 additions and 1255 deletions

3
.clippy.toml Normal file
View file

@ -0,0 +1,3 @@
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-indexing-slicing-in-tests = true

2
.gitignore vendored
View file

@ -1 +1 @@
ocaml/_coverage
/target

4
.justfile Normal file
View file

@ -0,0 +1,4 @@
watch command="run" args="":
DEBUG=${DEBUG:-} watchexec -c -w src -- cargo {{ command }} {{ args }}
alias w := watch

7
Cargo.lock generated Normal file
View file

@ -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"

167
Cargo.toml Normal file
View file

@ -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"

View file

@ -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 '<long help>'
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, '<short help>' 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.

View file

@ -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

View file

@ -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

6
containers/build-run.sh Executable file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -eu
./build.sh "$1" && clear
./run.sh "$1"

27
containers/build.sh Executable file
View file

@ -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

16
containers/run.sh Executable file
View file

@ -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"

38
containers/test-clean.sh Executable file
View file

@ -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')"

40
containers/test.sh Executable file
View file

@ -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"

1
ocaml/.gitignore vendored
View file

@ -1 +0,0 @@
_build

View file

@ -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:
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 runtest --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 }}

View file

@ -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

View file

@ -1 +0,0 @@
lib/schema/schema.ml

View file

@ -1,83 +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
- [ ] <https://github.com/janestreet/shexp>
- [ ] <https://erratique.ch/software/bos>
- [ ] <https://github.com/ninjaaron/ocaml-subprocess>
- [ ] <https://github.com/charlesetc/feather>
## References
- ISO 14977 EBNF Notation: <https://www.cl.cam.ac.uk/~mgk25/iso-14977.pdf>
- Comparison of BNF notations: <https://www.cs.man.ac.uk/~pjj/bnf/ebnf.html>
- W3C ABNF Notation: <https://www.w3.org/Notation.html>
- IETF RFC 5234 ABNF Notation (replaces 4234, 2234): <https://www.rfc-editor.org/rfc/rfc5234>
### Notes
[^1]: INS, Iganaq Napkin Spec: <https://brew.bsd.cafe/tori/iganaq#specification>
[^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

View file

@ -1,5 +0,0 @@
(executable
(public_name tori)
(name main)
(libraries tori)
)

View file

@ -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

View file

@ -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 <juno@jutty.dev>")
(maintainers "Juno Takano <juno@jutty.dev>")
(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)

View file

@ -1,4 +0,0 @@
(lang dune 3.17)
(context default)
(env (dev (flags (:standard -w +A-23-40-42-48-70))))

View file

@ -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

View file

@ -1,7 +0,0 @@
(library
(name tori)
(libraries unix)
(instrumentation (backend bisect_ppx))
)
(include_subdirs qualified)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 = "<short help>";
long = "<long help>";
};
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 -> "<unknown key>"
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

View file

@ -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

View file

@ -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"

View file

@ -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;
};
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 };
}

View file

@ -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

View file

@ -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

View file

@ -1,2 +0,0 @@
exception Malformed_source of string
exception Malformed_state of string

View file

@ -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 ()

View file

@ -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)

View file

@ -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 '<long help>'
$ tori help
<long help>
$ tori -h
<long help>
$ tori --help
<long 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, '<short help>' and exit with status code 1
$ tori unrecognized_command
Unrecognized command: unrecognized_command
<short help>
[1]

View file

@ -1,4 +0,0 @@
(test
(name test_tori)
(libraries tori)
)

View file

@ -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 ()

View file

@ -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 <juno@jutty.dev>"]
authors: ["Juno Takano <juno@jutty.dev>"]
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"

164
src/conf.rs Normal file
View file

@ -0,0 +1,164 @@
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<String, String> = 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<String> = 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<PathBuf, String> {
elog(&format!("Solving from PATH for {command}"));
let paths: Vec<PathBuf> = 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<DirEntry, std::io::Error>| -> 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<bool>,
}
#[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
}
}

7
src/lib.rs Normal file
View file

@ -0,0 +1,7 @@
pub mod conf;
pub mod state;
pub mod os;
pub mod run;
pub mod log;

8
src/log.rs Normal file
View file

@ -0,0 +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()
{
eprintln!(" [log] {message}");
}
}

15
src/main.rs Normal file
View file

@ -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() }
}

39
src/os.rs Normal file
View file

@ -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,
}

169
src/os/debian.rs Normal file
View file

@ -0,0 +1,169 @@
use std::fs::read_to_string;
use crate::{
conf::Configuration,
log::elog,
os::{
Kind, OperatingSystem,
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<Vec<Package>, pkg::Error> {
let raw_all = read(&Command::new(
"dpkg-query",
&["--show", "--showformat", "${Package} ${Status}\\n"],
))?;
let all: Vec<String> = 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<Package> = self.automatic()?;
let mut manual_packages: Vec<Package> = 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<Vec<Package>, 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<String> = 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<Package> = 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<PackagerVariant, pkg::Error> {
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::<Vec<&str>>());
let command = Command::new("apt", &args).escalate(config)?;
Ok(crate::run::executor::spawn(&command)?)
}
}

225
src/os/pkg.rs Normal file
View file

@ -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<Vec<Package>, Error>;
fn automatic(&self) -> Result<Vec<Package>, Error>;
fn variant(&self) -> Result<PackagerVariant, Error>;
}
#[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<Vec<Package>, Error> {
match self {
Packager::Apt(p) => p.manual(),
Packager::Unknown => Error::unknown_packager("list manually-installed packages"),
}
}
fn automatic(&self) -> Result<Vec<Package>, Error> {
match self {
Packager::Apt(p) => p.automatic(),
Packager::Unknown => Error::unknown_packager("list automatically-installed packages"),
}
}
fn variant(&self) -> Result<PackagerVariant, Error> {
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<Version>,
manual: Option<bool>,
}
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<String> 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<Package> 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<u32>,
patch: Option<u32>,
qualifier: Option<String>,
}
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<T>(message: &str, kind: ErrorKind) -> Result<T, Error> {
Err(Error::new(message, kind))
}
fn unknown_packager<T>(action: &str) -> Result<T, Error> {
Err(Error {
message: format!("Can't {action} because package manager is unknown"),
kind: ErrorKind::UnknownPackager,
})
}
}
impl From<run::executor::Error> for Error {
fn from(executor_error: run::executor::Error) -> Error {
Error {
message: format!("{:?}: {}", executor_error.kind, executor_error.message),
kind: ErrorKind::ExecutorError,
}
}
}
impl From<run::Error> 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,
}

146
src/run.rs Normal file
View file

@ -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<Task>,
}
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<Task> {
self.tasks.clone()
}
}
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct Task {
kind: TaskKind,
done: bool,
argument: String,
parameters: Vec<String>,
configuration: Configuration,
}
impl Task {
fn new(
kind: TaskKind,
argument: &str,
parameters: Vec<String>,
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<String> {
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<String>,
}
impl Command {
pub fn escalate(&self, config: &Configuration) -> Result<Command, Error> {
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,
}

86
src/run/executor.rs Normal file
View file

@ -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<Package>) -> 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<String, Error> {
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,
}

16
src/run/executor/meta.rs Normal file
View file

@ -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!("<long help>");
true
}
pub fn unrecognized(task: &Task) -> bool {
println!("Unrecognized command: {}\n<short help>", task.argument);
false
}

95
src/run/expeditor.rs Normal file
View file

@ -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("<long help>")?,
TaskKind::PackageInstall => {
let packages: Vec<Package> = task.parameters.iter().map(|s| s.into()).collect();
state
.os()
.packager()
.install(&packages, &state.configuration())?;
}
TaskKind::PackageUninstall => {
let packages: Vec<Package> = 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<short help>",
task.argument()
))?,
}
}
}
Ok(())
}
#[derive(Debug)]
pub struct Error {
pub message: String,
pub kind: ErrorKind,
}
impl From<executor::Error> for Error {
fn from(executor_error: executor::Error) -> Error {
Error {
message: format!("{:?}: {}", executor_error.kind, executor_error.message),
kind: ErrorKind::ExecutorError,
}
}
}
impl From<pkg::Error> 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,
}

99
src/run/teller.rs Normal file
View file

@ -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<String>) = 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
}
}

91
src/state.rs Normal file
View file

@ -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<Order>,
}
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<Order> {
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<String, String> = HashMap::new();
let lines: Vec<Option<(&str, &str)>> = 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()
}