Compare commits
10 commits
caafbed5a5
...
7d89f51eac
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d89f51eac | |||
| ca20c11962 | |||
| 4053c354bd | |||
| 4509d5e6ed | |||
| c5bbdec1a7 | |||
| afe7e32a2a | |||
| 397ed5a236 | |||
| bed93fd58c | |||
| 9e402df786 | |||
| 00321d2842 |
60 changed files with 1510 additions and 1255 deletions
3
.clippy.toml
Normal file
3
.clippy.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1 +1 @@
|
|||
ocaml/_coverage
|
||||
/target
|
||||
|
|
|
|||
4
.justfile
Normal file
4
.justfile
Normal 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
7
Cargo.lock
generated
Normal 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
167
Cargo.toml
Normal 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"
|
||||
63
README.md
63
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 '<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.
|
||||
|
|
|
|||
17
containers/Containerfile.test
Normal file
17
containers/Containerfile.test
Normal 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
|
||||
20
containers/Containerfile.test-clean
Normal file
20
containers/Containerfile.test-clean
Normal 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
6
containers/build-run.sh
Executable file
|
|
@ -0,0 +1,6 @@
|
|||
#!/usr/bin/env sh
|
||||
|
||||
set -eu
|
||||
|
||||
./build.sh "$1" && clear
|
||||
./run.sh "$1"
|
||||
27
containers/build.sh
Executable file
27
containers/build.sh
Executable 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
16
containers/run.sh
Executable 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
38
containers/test-clean.sh
Executable 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
40
containers/test.sh
Executable 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
1
ocaml/.gitignore
vendored
|
|
@ -1 +0,0 @@
|
|||
_build
|
||||
194
ocaml/.justfile
194
ocaml/.justfile
|
|
@ -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 }}
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -1 +0,0 @@
|
|||
lib/schema/schema.ml
|
||||
|
|
@ -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
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
(executable
|
||||
(public_name tori)
|
||||
(name main)
|
||||
(libraries tori)
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
(lang dune 3.17)
|
||||
(context default)
|
||||
|
||||
(env (dev (flags (:standard -w +A-23-40-42-48-70))))
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
(library
|
||||
(name tori)
|
||||
(libraries unix)
|
||||
(instrumentation (backend bisect_ppx))
|
||||
)
|
||||
|
||||
(include_subdirs qualified)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
exception Malformed_source of string
|
||||
exception Malformed_state of string
|
||||
|
|
@ -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 ()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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]
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
(test
|
||||
(name test_tori)
|
||||
(libraries tori)
|
||||
)
|
||||
|
|
@ -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 ()
|
||||
|
|
@ -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
164
src/conf.rs
Normal 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
7
src/lib.rs
Normal 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
8
src/log.rs
Normal 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
15
src/main.rs
Normal 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
39
src/os.rs
Normal 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
169
src/os/debian.rs
Normal 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
225
src/os/pkg.rs
Normal 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
146
src/run.rs
Normal 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
86
src/run/executor.rs
Normal 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
16
src/run/executor/meta.rs
Normal 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
95
src/run/expeditor.rs
Normal 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
99
src/run/teller.rs
Normal 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
91
src/state.rs
Normal 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()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue