mirror of
https://github.com/tildeclub/launcher.git
synced 2026-10-25 08:25:38 +00:00
779 lines
28 KiB
Bash
Executable File
779 lines
28 KiB
Bash
Executable File
#!/bin/sh
|
|
# ---------------------------------------------------------------------------
|
|
# tilde - manage user-submitted scripts and apps
|
|
# Copyright 2018, Ben Harris <ben@tilde.team>
|
|
#
|
|
# GPL-3.0-or-later. See LICENSE for the full licence text.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
set -u
|
|
umask 077
|
|
# Preserve users' PATH, especially for "tilde run". Sanitize only root commands.
|
|
if [ "$(/usr/bin/id -u)" -eq 0 ]; then
|
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
export PATH
|
|
fi
|
|
|
|
PROGNAME=${0##*/}
|
|
VERSION=0.2.1
|
|
ROOT=/tilde
|
|
BIN=$ROOT/bin
|
|
DESCRIPTIONS=$ROOT/descriptions
|
|
PENDING=$ROOT/pending-submissions
|
|
APPROVED=$ROOT/approved
|
|
META=$ROOT/metadata
|
|
REVIEW=$ROOT/review
|
|
REJECTED=$ROOT/rejected
|
|
LOCK=$ROOT/.launcher.lock
|
|
MAX_SCRIPT=52428800
|
|
MAX_DESCRIPTION=16384
|
|
WORK=
|
|
|
|
fail() {
|
|
printf '%s: %s\n' "$PROGNAME" "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
warn() {
|
|
printf '%s: %s\n' "$PROGNAME" "$1" >&2
|
|
}
|
|
|
|
cleanup() {
|
|
if [ -n "$WORK" ]; then
|
|
rm -rf -- "$WORK"
|
|
WORK=
|
|
fi
|
|
}
|
|
trap 'cleanup' EXIT
|
|
trap 'exit 130' INT
|
|
trap 'exit 143' TERM HUP
|
|
|
|
require_root() {
|
|
[ "$(id -u)" -eq 0 ] || fail 'this command requires root'
|
|
}
|
|
|
|
valid_name() {
|
|
case ${1-} in
|
|
''|.*|*[!a-zA-Z0-9_.+-]*) return 1 ;;
|
|
*) return 0 ;;
|
|
esac
|
|
}
|
|
|
|
check_name() {
|
|
valid_name "${1-}" || fail 'invalid script name (use letters, numbers, _, -, + or .; not starting with .)'
|
|
case $1 in
|
|
tilde|about|description|list|ls|submit|update|approve|revoke|run|help|apropos|pending|status|migrate|init|version)
|
|
fail "$1 is a reserved name" ;;
|
|
esac
|
|
}
|
|
|
|
valid_user() {
|
|
case ${1-} in
|
|
''|.*|*[!a-zA-Z0-9_.-]*) return 1 ;;
|
|
*) return 0 ;;
|
|
esac
|
|
}
|
|
|
|
# Never trust pre-existing filesystem entries when operating as root.
|
|
trusted_dir() {
|
|
[ -d "$1" ] && [ ! -L "$1" ] || fail "not a directory: $1"
|
|
[ "$(stat -c %u -- "$1")" = 0 ] || fail "directory must be owned by root: $1"
|
|
mode=$(stat -c %a -- "$1") || fail "cannot stat $1"
|
|
[ $((0$mode & 0022)) -eq 0 ] || fail "directory is writable by group/others: $1"
|
|
}
|
|
|
|
storage_ready() {
|
|
trusted_dir "$ROOT"
|
|
trusted_dir "$BIN"
|
|
trusted_dir "$DESCRIPTIONS"
|
|
trusted_dir "$APPROVED"
|
|
trusted_dir "$META"
|
|
trusted_dir "$REJECTED"
|
|
trusted_dir "$REVIEW"
|
|
[ $((0$(stat -c %a -- "$REVIEW") & 0077)) -eq 0 ] || fail 'review directory must be private (0700)'
|
|
[ -d "$PENDING" ] && [ ! -L "$PENDING" ] || fail "invalid pending directory"
|
|
[ "$(stat -c %u -- "$PENDING")" = 0 ] || fail "pending directory must be root-owned"
|
|
# The pending parent must be sticky when it is publicly writable.
|
|
mode=$(stat -c %a -- "$PENDING") || fail 'cannot stat pending directory'
|
|
[ $((0$mode & 01000)) -ne 0 ] || fail 'pending directory needs the sticky bit'
|
|
}
|
|
|
|
init_storage() {
|
|
require_root
|
|
for dir in "$ROOT" "$BIN" "$DESCRIPTIONS" "$APPROVED" "$META" "$REJECTED" "$REVIEW" "$PENDING"; do
|
|
[ ! -L "$dir" ] || fail "refusing symlink: $dir"
|
|
if [ -e "$dir" ]; then
|
|
[ -d "$dir" ] || fail "not a directory: $dir"
|
|
if [ "$dir" = "$PENDING" ]; then
|
|
[ "$(stat -c %u -- "$dir")" = 0 ] || fail "directory must be root-owned: $dir"
|
|
else
|
|
trusted_dir "$dir"
|
|
fi
|
|
else
|
|
mkdir -m 0755 -- "$dir" || fail "cannot create $dir"
|
|
fi
|
|
done
|
|
chmod 0700 "$REVIEW" || fail 'cannot secure review directory'
|
|
chmod 1733 "$PENDING" || fail 'cannot secure pending directory'
|
|
storage_ready
|
|
printf 'Launcher directories are ready. Existing scripts were not changed.\n'
|
|
}
|
|
|
|
admin_lock() {
|
|
require_root
|
|
storage_ready
|
|
[ ! -L "$LOCK" ] || fail 'lock file must not be a symlink'
|
|
(umask 077; : >> "$LOCK") || fail 'cannot open lock file'
|
|
[ "$(stat -c %u -- "$LOCK")" = 0 ] || fail 'lock file must be root-owned'
|
|
exec 9>"$LOCK" || fail 'cannot lock launcher'
|
|
flock -n 9 || fail 'another launcher administration is in progress'
|
|
}
|
|
|
|
prompt() {
|
|
while :; do
|
|
printf '%s [y/n]: ' "$1"
|
|
IFS= read -r answer || return 1
|
|
case $answer in
|
|
y|Y) return 0 ;;
|
|
n|N) return 1 ;;
|
|
*) printf 'Please answer y or n.\n' ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
ask_line() {
|
|
printf '%s' "$1"
|
|
IFS= read -r INPUT || fail 'input cancelled'
|
|
}
|
|
|
|
help_message() {
|
|
cat <<EOF
|
|
Usage: $PROGNAME [option] [command]
|
|
|
|
Options:
|
|
-h, --help Show this help
|
|
-v, --version Show version
|
|
|
|
Commands:
|
|
list List approved scripts
|
|
about <script> Show description and author (alias: description)
|
|
submit Submit a new script from ~/bin
|
|
update <script> Submit an update to one of your approved scripts
|
|
pending List your submitted scripts awaiting review
|
|
status <script> Show the approval status of a script
|
|
run <script> Run an approved script (passes along arguments)
|
|
approve Review and approve pending submissions (root)
|
|
revoke <script> Revoke an approved script (root)
|
|
migrate [script] Review and migrate legacy symlinks (root)
|
|
init Prepare secured /tilde directories (root)
|
|
|
|
Add /tilde/bin to PATH to run approved scripts directly.
|
|
EOF
|
|
}
|
|
|
|
metadata_value() {
|
|
key=$1
|
|
file=$2
|
|
[ -f "$file" ] && [ ! -L "$file" ] || return 1
|
|
sed -n "s/^${key}=//p" "$file" | head -n 1
|
|
}
|
|
|
|
approved_author() {
|
|
metadata_value author "$META/$1"
|
|
}
|
|
|
|
approved_digest() {
|
|
metadata_value sha256 "$META/$1"
|
|
}
|
|
|
|
# The user's own writable directory is never trusted as an approval record.
|
|
user_pending_dir() {
|
|
owner=$1
|
|
valid_user "$owner" || fail "invalid username: $owner"
|
|
dir=$PENDING/$owner
|
|
[ -d "$dir" ] && [ ! -L "$dir" ] || fail "missing user submissions directory: $dir"
|
|
[ "$(stat -c %U -- "$dir")" = "$owner" ] || fail "submission directory ownership mismatch: $dir"
|
|
printf '%s\n' "$dir"
|
|
}
|
|
|
|
# Older launcher versions sometimes left root-owned submission directories.
|
|
# Admins may review those directories if root alone can change their entries.
|
|
# Ordinary users still need user-owned directories for new submissions.
|
|
review_user_dir() {
|
|
review_owner=$1
|
|
valid_user "$review_owner" || { warn 'invalid submission account'; return 1; }
|
|
review_dir=$PENDING/$review_owner
|
|
if [ ! -d "$review_dir" ] || [ -L "$review_dir" ]; then
|
|
warn "missing or unsafe submission directory: $review_dir"
|
|
return 1
|
|
fi
|
|
review_dir_owner=$(stat -c %U -- "$review_dir") || return 1
|
|
if [ "$review_dir_owner" != "$review_owner" ]; then
|
|
if [ "$review_dir_owner" != root ]; then
|
|
warn "unexpected owner of $review_dir: $review_dir_owner"
|
|
return 1
|
|
fi
|
|
review_mode=$(stat -c %a -- "$review_dir") || return 1
|
|
if [ $((0$review_mode & 0022)) -ne 0 ]; then
|
|
warn "root-owned legacy directory is writable by other accounts: $review_dir"
|
|
return 1
|
|
fi
|
|
fi
|
|
printf '%s\n' "$review_dir"
|
|
}
|
|
|
|
ensure_user_dir() {
|
|
owner=$1
|
|
valid_user "$owner" || fail 'unsupported account name'
|
|
[ -d "$PENDING" ] && [ ! -L "$PENDING" ] || fail 'launcher has not been initialized by an admin'
|
|
dir=$PENDING/$owner
|
|
if [ ! -e "$dir" ] && [ ! -L "$dir" ]; then
|
|
mkdir -m 0700 -- "$dir" || fail 'cannot create your submissions directory (ask an admin if your name is taken)'
|
|
fi
|
|
user_pending_dir "$owner" >/dev/null
|
|
}
|
|
|
|
send_notice() {
|
|
recipient=$1
|
|
subject=$2
|
|
body=$3
|
|
valid_user "$recipient" || { warn 'skipping mail to invalid account'; return 1; }
|
|
if ! {
|
|
printf 'To: %s\nSubject: %s\n\n%s\n' "$recipient" "$subject" "$body"
|
|
} | /usr/sbin/sendmail -t; then
|
|
warn "email to $recipient failed; the filesystem changes were retained"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
submit_script() {
|
|
operation=$1
|
|
[ "$(id -u)" -ne 0 ] || fail 'submit as your own user, not root'
|
|
storage_ready
|
|
current_user=$(id -un) || fail 'cannot identify user'
|
|
valid_user "$current_user" || fail 'unsupported account name'
|
|
if [ "$operation" = update ]; then
|
|
name=${2-}
|
|
check_name "$name"
|
|
[ "$(approved_author "$name" 2>/dev/null || :)" = "$current_user" ] || fail 'only the approved author can submit an update'
|
|
[ -L "$BIN/$name" ] || fail 'script is not currently approved'
|
|
else
|
|
printf 'Hello, %s! Ready to submit your script?\n' "$current_user"
|
|
printf 'Place an executable in ~/bin that supports --help.\n'
|
|
prompt 'Continue?' || return 0
|
|
ask_line 'Script name: '
|
|
name=$INPUT
|
|
check_name "$name"
|
|
[ ! -e "$BIN/$name" ] && [ ! -L "$BIN/$name" ] || fail 'name already approved; use update if it is yours'
|
|
[ ! -f "$META/$name" ] || fail 'name already has an approval record'
|
|
fi
|
|
source=$HOME/bin/$name
|
|
if [ "$operation" = submit ]; then
|
|
located=$(command -v "$name" 2>/dev/null || :)
|
|
if [ -n "$located" ] && [ "$(readlink -f -- "$located" 2>/dev/null || :)" != "$(readlink -f -- "$source" 2>/dev/null || :)" ]; then
|
|
fail "$name is already an existing command; use another name"
|
|
fi
|
|
fi
|
|
[ -f "$source" ] && [ -x "$source" ] || fail "executable not found: $source"
|
|
if [ "$(wc -c < "$source")" -gt "$MAX_SCRIPT" ]; then
|
|
fail 'script is too large (limit: 50 MiB)'
|
|
fi
|
|
if [ "$operation" = update ]; then
|
|
printf 'Updating %s (the currently approved version keeps working).\n' "$name"
|
|
fi
|
|
ask_line 'Description: '
|
|
description=$INPUT
|
|
[ -n "$description" ] || fail 'description is required'
|
|
[ "${#description}" -le "$MAX_DESCRIPTION" ] || fail 'description is too long'
|
|
prompt 'Send this version to the admins for review?' || return 0
|
|
ensure_user_dir "$current_user"
|
|
parent=$PENDING/$current_user
|
|
target=$parent/$name
|
|
if [ -e "$target" ] || [ -L "$target" ]; then
|
|
[ -d "$target" ] && [ ! -L "$target" ] || fail 'unsafe existing submission directory'
|
|
prompt 'Replace your previous pending submission?' || return 0
|
|
fi
|
|
stage=$(mktemp -d "$parent/.submit.XXXXXXXX") || fail 'cannot stage submission'
|
|
if ! cp -- "$source" "$stage/$name" ||
|
|
! chmod 0700 "$stage/$name" ||
|
|
! printf '%s\n' "$description" > "$stage/description.txt"; then
|
|
rm -rf -- "$stage"
|
|
fail 'cannot stage submission'
|
|
fi
|
|
if [ -e "$target" ]; then
|
|
rm -rf -- "$target" || fail 'cannot replace your pending submission'
|
|
fi
|
|
if ! mv -- "$stage" "$target"; then
|
|
rm -rf -- "$stage"
|
|
fail 'cannot save submission'
|
|
fi
|
|
send_notice root 'tilde script submission' "Submission from $current_user: $name. Review with sudo tilde approve." || :
|
|
printf 'Submitted %s for review.\n' "$name"
|
|
}
|
|
|
|
# Capture potentially hostile user-controlled files *as that user*, never as root.
|
|
# Review and publication use only the resulting root-owned immutable snapshot.
|
|
capture() {
|
|
author=$1
|
|
path=$2
|
|
dest=$3
|
|
maximum=$4
|
|
[ -f "$path" ] || return 1
|
|
timeout 60s runuser -u "$author" -- head -c "$((maximum + 1))" -- "$path" > "$dest" || return 1
|
|
[ "$(wc -c < "$dest")" -le "$maximum" ] || return 1
|
|
}
|
|
|
|
capture_submission() {
|
|
author=$1
|
|
name=$2
|
|
if ! getent passwd "$author" >/dev/null; then
|
|
warn "submission account no longer exists: $author"
|
|
return 1
|
|
fi
|
|
folder=$(review_user_dir "$author") || return 1
|
|
submission=$folder/$name
|
|
if [ ! -d "$submission" ] || [ -L "$submission" ]; then
|
|
warn "missing or unsafe submission folder: $submission"
|
|
return 1
|
|
fi
|
|
if [ -L "$submission/$name" ] && [ ! -e "$submission/$name" ]; then
|
|
warn "broken submitted-script symlink: $author/$name; ask the author to restore or resubmit"
|
|
return 1
|
|
fi
|
|
if [ ! -f "$submission/$name" ]; then
|
|
warn "submitted executable is missing or not a regular file: $author/$name"
|
|
return 1
|
|
fi
|
|
if [ ! -f "$submission/description.txt" ]; then
|
|
warn "missing description: $author/$name"
|
|
return 1
|
|
fi
|
|
if ! runuser -u "$author" -- test -r "$submission/$name" ||
|
|
! runuser -u "$author" -- test -x "$submission/$name"; then
|
|
warn "author cannot read or execute submitted script: $author/$name"
|
|
return 1
|
|
fi
|
|
if ! runuser -u "$author" -- test -r "$submission/description.txt"; then
|
|
warn "author cannot read submission description: $author/$name"
|
|
return 1
|
|
fi
|
|
WORK=$(mktemp -d "$REVIEW/.review.XXXXXXXX") || fail 'cannot stage review'
|
|
if ! capture "$author" "$submission/$name" "$WORK/script" "$MAX_SCRIPT"; then
|
|
warn "cannot capture script (unreadable, changed or larger than 50 MiB): $author/$name"
|
|
return 1
|
|
fi
|
|
if ! capture "$author" "$submission/description.txt" "$WORK/description" "$MAX_DESCRIPTION"; then
|
|
warn "cannot capture description (unreadable or larger than 16 KiB): $author/$name"
|
|
return 1
|
|
fi
|
|
if [ ! -s "$WORK/script" ] || [ ! -s "$WORK/description" ]; then
|
|
warn "submitted script or description is empty: $author/$name"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
log_event() {
|
|
logger -t tilde-launcher -- "$1" 2>/dev/null || :
|
|
}
|
|
|
|
write_metadata() {
|
|
name=$1
|
|
author=$2
|
|
digest=$3
|
|
temp=$(mktemp "$META/.record.XXXXXXXX") || return 1
|
|
if ! printf 'author=%s\nsha256=%s\napproved_at=%s\nstate=approved\n' \
|
|
"$author" "$digest" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$temp" ||
|
|
! chmod 0644 "$temp" || ! mv -f -- "$temp" "$META/$name"; then
|
|
rm -f -- "$temp"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
publish() {
|
|
author=$1
|
|
name=$2
|
|
digest=$(sha256sum "$WORK/script" | cut -d ' ' -f 1) || fail 'cannot hash script'
|
|
folder=$APPROVED/$name
|
|
[ ! -L "$folder" ] || fail 'approved directory must not be a symlink'
|
|
if [ ! -e "$folder" ]; then
|
|
mkdir -m 0755 -- "$folder" || fail 'cannot create approved directory'
|
|
fi
|
|
trusted_dir "$folder"
|
|
version=$folder/$digest
|
|
[ ! -L "$version" ] || fail 'approved version must not be a symlink'
|
|
if [ ! -e "$version" ]; then
|
|
temp=$(mktemp "$folder/.version.XXXXXXXX") || fail 'cannot stage approved version'
|
|
if ! cp -- "$WORK/script" "$temp" || ! chmod 0755 "$temp" || ! mv -- "$temp" "$version"; then
|
|
rm -f -- "$temp"
|
|
fail 'cannot publish approved version'
|
|
fi
|
|
else
|
|
[ -f "$version" ] &&
|
|
[ "$(stat -c %u -- "$version")" = 0 ] &&
|
|
[ $((0$(stat -c %a -- "$version") & 0022)) -eq 0 ] &&
|
|
[ "$(sha256sum "$version" | cut -d ' ' -f 1)" = "$digest" ] || fail 'existing approved version has changed'
|
|
fi
|
|
dtmp=$(mktemp "$DESCRIPTIONS/.description.XXXXXXXX") || fail 'cannot stage description'
|
|
if ! cp -- "$WORK/description" "$dtmp" || ! chmod 0644 "$dtmp" || ! mv -f -- "$dtmp" "$DESCRIPTIONS/$name"; then
|
|
rm -f -- "$dtmp"
|
|
fail 'cannot publish description'
|
|
fi
|
|
old_target=$(readlink -f -- "$BIN/$name" 2>/dev/null || :)
|
|
chmod 0755 -- "$version" || fail 'cannot activate approved version'
|
|
write_metadata "$name" "$author" "$digest" || fail 'cannot save approval metadata'
|
|
linktmp=$BIN/.link.$name.$$
|
|
[ ! -e "$linktmp" ] && [ ! -L "$linktmp" ] || fail 'temporary link collision'
|
|
ln -s -- "$version" "$linktmp" || fail 'cannot create approved link'
|
|
if ! mv -Tf -- "$linktmp" "$BIN/$name"; then
|
|
rm -f -- "$linktmp"
|
|
fail 'cannot activate approved version'
|
|
fi
|
|
# Previously approved revisions are retained but no longer user-executable.
|
|
case $old_target in
|
|
"$APPROVED/$name/"*)
|
|
if [ "$old_target" != "$version" ]; then
|
|
chmod 0700 -- "$old_target" || fail 'could not restrict retired version'
|
|
fi ;;
|
|
esac
|
|
log_event "approved $name by $author sha256=$digest"
|
|
send_notice "$author" 'tilde script approved' "$name is approved and available at $BIN/$name" || :
|
|
printf 'Approved %s by %s.\n' "$name" "$author"
|
|
}
|
|
|
|
review_snapshot() {
|
|
author=$1
|
|
name=$2
|
|
printf '\nScript: %s\nAuthor: %s\n' "$name" "$author"
|
|
printf 'SHA-256: %s\n' "$(sha256sum "$WORK/script" | cut -d ' ' -f 1)"
|
|
printf 'Description:\n'
|
|
LC_ALL=C cat -v "$WORK/description"
|
|
printf '\nSource preview (up to 80 lines / 8 KiB, control characters escaped):\n'
|
|
head -c 8192 "$WORK/script" | head -n 80 | LC_ALL=C cat -v
|
|
while :; do
|
|
printf '\n[a]pprove, [r]eject, [v]iew full source, [s]kip? '
|
|
IFS= read -r answer || return 1
|
|
case $answer in
|
|
a|A) return 0 ;;
|
|
r|R) return 2 ;;
|
|
v|V) LC_ALL=C cat -v "$WORK/script" ;;
|
|
s|S) return 1 ;;
|
|
*) printf 'Choose a, r, v or s.\n' ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
submission_fingerprint() {
|
|
sha256sum "$1" "$2" | cut -d ' ' -f 1 | sha256sum | cut -d ' ' -f 1
|
|
}
|
|
|
|
reject_snapshot() {
|
|
author=$1
|
|
name=$2
|
|
ask_line 'Reason for rejection: '
|
|
reason=$INPUT
|
|
[ -n "$reason" ] || { warn 'rejection cancelled: reason required'; return 0; }
|
|
dir=$REJECTED/$author
|
|
if [ ! -e "$dir" ]; then
|
|
mkdir -m 0755 -- "$dir" || fail 'cannot create rejection records'
|
|
fi
|
|
trusted_dir "$dir"
|
|
fingerprint=$(submission_fingerprint "$WORK/script" "$WORK/description")
|
|
temp=$(mktemp "$dir/.rejection.XXXXXXXX") || fail 'cannot stage rejection record'
|
|
if ! printf 'fingerprint=%s\nrejected_at=%s\n' \
|
|
"$fingerprint" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$temp" ||
|
|
! chmod 0644 "$temp" || ! mv -f -- "$temp" "$dir/$name"; then
|
|
rm -f -- "$temp"
|
|
fail 'cannot save rejection record'
|
|
fi
|
|
log_event "rejected $name by $author"
|
|
send_notice "$author" 'tilde script rejected' "$name was not approved. Reason: $reason. You may submit a revised version." || :
|
|
printf 'Rejected %s by %s.\n' "$name" "$author"
|
|
}
|
|
|
|
approve_one() {
|
|
author=$1
|
|
name=$2
|
|
check_name "$name"
|
|
if ! capture_submission "$author" "$name"; then
|
|
warn "could not safely read $author/$name; skipping"
|
|
cleanup
|
|
return 0
|
|
fi
|
|
digest=$(sha256sum "$WORK/script" | cut -d ' ' -f 1)
|
|
existing_author=$(approved_author "$name" 2>/dev/null || :)
|
|
existing_digest=$(approved_digest "$name" 2>/dev/null || :)
|
|
if [ -n "$existing_author" ] && [ "$existing_author" != "$author" ]; then
|
|
warn "name $name belongs to $existing_author; skipping $author"
|
|
cleanup
|
|
return 0
|
|
fi
|
|
if [ "$digest" = "$existing_digest" ] && [ -L "$BIN/$name" ]; then
|
|
cleanup
|
|
return 0
|
|
fi
|
|
rejected=$(metadata_value fingerprint "$REJECTED/$author/$name" 2>/dev/null || :)
|
|
fingerprint=$(submission_fingerprint "$WORK/script" "$WORK/description")
|
|
if [ -n "$rejected" ] && [ "$rejected" = "$fingerprint" ]; then
|
|
cleanup
|
|
return 0
|
|
fi
|
|
if [ -e "$BIN/$name" ] || [ -L "$BIN/$name" ]; then
|
|
[ -n "$existing_author" ] || {
|
|
warn "$name is an untracked legacy script; use tilde migrate first"
|
|
cleanup
|
|
return 0
|
|
}
|
|
[ -L "$BIN/$name" ] || fail "refusing to replace a non-symlink: $BIN/$name"
|
|
fi
|
|
if review_snapshot "$author" "$name"; then
|
|
publish "$author" "$name"
|
|
else
|
|
action=$?
|
|
[ "$action" -ne 2 ] || reject_snapshot "$author" "$name"
|
|
fi
|
|
cleanup
|
|
}
|
|
|
|
approve_queue() {
|
|
admin_lock
|
|
printf 'Pending submissions:\n'
|
|
for ownerdir in "$PENDING"/*; do
|
|
[ -d "$ownerdir" ] && [ ! -L "$ownerdir" ] || continue
|
|
author=${ownerdir##*/}
|
|
if ! review_user_dir "$author" >/dev/null; then
|
|
continue
|
|
fi
|
|
for item in "$ownerdir"/*; do
|
|
[ -d "$item" ] && [ ! -L "$item" ] || continue
|
|
name=${item##*/}
|
|
valid_name "$name" || { warn "skipping invalid name $name"; continue; }
|
|
case $name in
|
|
tilde|about|description|list|ls|submit|update|approve|revoke|run|help|apropos|pending|status|migrate|init|version)
|
|
continue ;;
|
|
esac
|
|
approve_one "$author" "$name"
|
|
done
|
|
done
|
|
printf 'Approval queue complete.\n'
|
|
}
|
|
|
|
migrate_one() {
|
|
name=$1
|
|
check_name "$name"
|
|
link=$BIN/$name
|
|
[ -L "$link" ] || { warn "$name is not a legacy symlink; skipping"; return 0; }
|
|
if [ -n "$(approved_author "$name" 2>/dev/null || :)" ]; then
|
|
warn "$name has already been migrated"
|
|
return 0
|
|
fi
|
|
if ! source=$(readlink -e -- "$link" 2>/dev/null); then
|
|
warn "broken legacy symlink: $name; leaving it unchanged and skipping"
|
|
return 1
|
|
fi
|
|
if [ ! -f "$source" ]; then
|
|
warn "legacy target is not a regular file: $name; skipping"
|
|
return 1
|
|
fi
|
|
author=$(stat -c %U -- "$source") || fail 'cannot inspect author'
|
|
valid_user "$author" || fail 'cannot determine legacy author'
|
|
home=$(getent passwd "$author" | cut -d: -f6)
|
|
case $source in
|
|
"$home"/bin/*) : ;;
|
|
*) warn "legacy target is outside $author's ~/bin: $source"; return 0 ;;
|
|
esac
|
|
[ -f "$DESCRIPTIONS/$name" ] && [ ! -L "$DESCRIPTIONS/$name" ] || {
|
|
warn "missing or unsafe legacy description for $name"
|
|
return 0
|
|
}
|
|
[ "$(stat -c %u -- "$DESCRIPTIONS/$name")" = 0 ] || {
|
|
warn "legacy description $name is not root-owned"
|
|
return 0
|
|
}
|
|
WORK=$(mktemp -d "$REVIEW/.review.XXXXXXXX") || fail 'cannot stage migration'
|
|
if ! capture "$author" "$source" "$WORK/script" "$MAX_SCRIPT" ||
|
|
! cp -- "$DESCRIPTIONS/$name" "$WORK/description"; then
|
|
warn "could not capture legacy $name"
|
|
cleanup
|
|
return 0
|
|
fi
|
|
[ "$(wc -c < "$WORK/description")" -le "$MAX_DESCRIPTION" ] || fail 'legacy description is too large'
|
|
printf '\nLegacy link: %s -> %s\n' "$name" "$source"
|
|
printf 'The reviewed copy will be immutable. Scripts requiring adjacent files may need changes.\n'
|
|
if review_snapshot "$author" "$name"; then
|
|
publish "$author" "$name"
|
|
fi
|
|
cleanup
|
|
}
|
|
|
|
migrate_legacy() {
|
|
admin_lock
|
|
if [ -n "${1-}" ]; then
|
|
migrate_one "$1"
|
|
else
|
|
skipped=0
|
|
for item in "$BIN"/*; do
|
|
[ -L "$item" ] || continue
|
|
migrate_one "${item##*/}" || skipped=$((skipped + 1))
|
|
done
|
|
if [ "$skipped" -gt 0 ]; then
|
|
warn "migration finished with $skipped unreadable or broken link(s); no affected links were removed"
|
|
return 1
|
|
fi
|
|
fi
|
|
}
|
|
|
|
revoke_script() {
|
|
admin_lock
|
|
name=${1-}
|
|
check_name "$name"
|
|
link=$BIN/$name
|
|
[ -L "$link" ] || fail "$name is not an approved symlink"
|
|
author=$(approved_author "$name" 2>/dev/null || :)
|
|
if [ -z "$author" ]; then
|
|
source=$(readlink -e -- "$link" 2>/dev/null || :)
|
|
if [ -n "$source" ]; then
|
|
author=$(stat -c %U -- "$source" 2>/dev/null || :)
|
|
fi
|
|
fi
|
|
if ! valid_user "$author"; then
|
|
author=
|
|
fi
|
|
if [ -z "$author" ]; then
|
|
printf 'No author could be identified for %s. This may be a broken legacy link.\n' "$name"
|
|
prompt "Revoke $name (author unknown)?" || { printf 'Cancelled.\n'; return 0; }
|
|
else
|
|
prompt "Revoke $name by $author?" || { printf 'Cancelled.\n'; return 0; }
|
|
fi
|
|
ask_line 'Reason: '
|
|
reason=$INPUT
|
|
[ -n "$reason" ] || fail 'a reason is required'
|
|
old_target=$(readlink -f -- "$link" 2>/dev/null || :)
|
|
# Retire the previously published file before removing its public link.
|
|
case $old_target in
|
|
"$APPROVED/$name/"*)
|
|
chmod 0700 -- "$old_target" || fail 'could not restrict revoked executable' ;;
|
|
esac
|
|
# Only unlink exact, validated entries. Do not recurse into user submissions.
|
|
rm -- "$link" || fail 'could not revoke executable'
|
|
if [ -e "$DESCRIPTIONS/$name" ] || [ -L "$DESCRIPTIONS/$name" ]; then
|
|
rm -- "$DESCRIPTIONS/$name" || warn 'could not remove description'
|
|
fi
|
|
if [ -e "$META/$name" ] || [ -L "$META/$name" ]; then
|
|
rm -- "$META/$name" || warn 'could not remove metadata'
|
|
fi
|
|
log_event "revoked $name by ${author:-unknown}"
|
|
if [ -n "$author" ]; then
|
|
send_notice "$author" 'tilde script revoked' "$name was revoked. Reason: $reason. You may submit a revised version." || :
|
|
else
|
|
warn 'author unknown; no notification sent'
|
|
fi
|
|
printf '%s revoked. Older reviewed copies were retained in %s.\n' "$name" "$APPROVED/$name"
|
|
}
|
|
|
|
list_scripts() {
|
|
printf 'Available scripts:\n\n'
|
|
for item in "$BIN"/*; do
|
|
[ -x "$item" ] && [ -f "$item" ] || continue
|
|
name=${item##*/}
|
|
author=$(approved_author "$name" 2>/dev/null || :)
|
|
if [ -z "$author" ]; then
|
|
author=$(stat -c %U -- "$item" 2>/dev/null || printf unknown)
|
|
fi
|
|
printf '%s by %s\n' "$name" "$author"
|
|
if [ -f "$DESCRIPTIONS/$name" ]; then
|
|
cat -- "$DESCRIPTIONS/$name"
|
|
fi
|
|
printf '\n'
|
|
done
|
|
}
|
|
|
|
about_script() {
|
|
name=${1-}
|
|
check_name "$name"
|
|
[ -f "$DESCRIPTIONS/$name" ] || fail "$name not found; use '$PROGNAME list'"
|
|
cat -- "$DESCRIPTIONS/$name"
|
|
author=$(approved_author "$name" 2>/dev/null || :)
|
|
[ -z "$author" ] || printf '\nAuthor: %s\n' "$author"
|
|
}
|
|
|
|
pending_scripts() {
|
|
current_user=$(id -un)
|
|
valid_user "$current_user" || fail 'unsupported account name'
|
|
[ -d "$PENDING/$current_user" ] || { printf 'No submissions.\n'; return 0; }
|
|
folder=$(user_pending_dir "$current_user") || return 1
|
|
found=0
|
|
for item in "$folder"/*; do
|
|
[ -d "$item" ] && [ ! -L "$item" ] || continue
|
|
name=${item##*/}
|
|
valid_name "$name" || continue
|
|
current=$(approved_digest "$name" 2>/dev/null || :)
|
|
submitted=$(sha256sum "$item/$name" 2>/dev/null | cut -d ' ' -f 1)
|
|
if [ -n "$current" ] && [ "$current" = "$submitted" ] && [ -L "$BIN/$name" ]; then
|
|
continue
|
|
fi
|
|
rejected=$(metadata_value fingerprint "$REJECTED/$current_user/$name" 2>/dev/null || :)
|
|
fingerprint=$(submission_fingerprint "$item/$name" "$item/description.txt" 2>/dev/null || :)
|
|
if [ -n "$fingerprint" ] && [ "$fingerprint" = "$rejected" ]; then
|
|
printf '%s (rejected; revise with submit)\n' "$name"
|
|
else
|
|
printf '%s (awaiting review)\n' "$name"
|
|
fi
|
|
found=1
|
|
done
|
|
[ "$found" -eq 1 ] || printf 'No pending submissions.\n'
|
|
}
|
|
|
|
status_script() {
|
|
name=${1-}
|
|
check_name "$name"
|
|
author=$(approved_author "$name" 2>/dev/null || :)
|
|
if [ -n "$author" ] && [ -L "$BIN/$name" ]; then
|
|
printf '%s: approved (author: %s)\n' "$name" "$author"
|
|
elif [ -L "$BIN/$name" ]; then
|
|
if [ -f "$BIN/$name" ] && [ -x "$BIN/$name" ]; then
|
|
printf '%s: approved (legacy symlink; needs migration)\n' "$name"
|
|
else
|
|
printf '%s: broken or inaccessible approved symlink; requires admin review\n' "$name"
|
|
fi
|
|
else
|
|
printf '%s: not approved\n' "$name"
|
|
fi
|
|
current_user=$(id -un)
|
|
if [ -d "$PENDING/$current_user/$name" ]; then
|
|
submission=$PENDING/$current_user/$name
|
|
fingerprint=$(submission_fingerprint "$submission/$name" "$submission/description.txt" 2>/dev/null || :)
|
|
rejected=$(metadata_value fingerprint "$REJECTED/$current_user/$name" 2>/dev/null || :)
|
|
if [ -n "$fingerprint" ] && [ "$fingerprint" = "$rejected" ]; then
|
|
printf 'Latest submission: rejected (submit a revision).\n'
|
|
elif [ -n "$author" ] && [ "$author" = "$current_user" ] &&
|
|
[ "$(sha256sum "$submission/$name" 2>/dev/null | cut -d ' ' -f 1)" = "$(approved_digest "$name" 2>/dev/null || :)" ]; then
|
|
printf 'Submitted version matches the current approval.\n'
|
|
else
|
|
printf 'A submission is awaiting review for %s.\n' "$current_user"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
case ${1-} in
|
|
-h|--help|'') help_message ;;
|
|
-v|--version) printf '%s\n' "$VERSION" ;;
|
|
init) init_storage ;;
|
|
list|ls) list_scripts ;;
|
|
about|description) about_script "${2-}" ;;
|
|
submit) submit_script submit ;;
|
|
update) submit_script update "${2-}" ;;
|
|
pending) pending_scripts ;;
|
|
status) status_script "${2-}" ;;
|
|
approve) approve_queue ;;
|
|
migrate) migrate_legacy "${2-}" ;;
|
|
revoke) revoke_script "${2-}" ;;
|
|
run)
|
|
name=${2-}
|
|
check_name "$name"
|
|
[ -f "$BIN/$name" ] && [ -x "$BIN/$name" ] || fail "$name not found or not approved"
|
|
shift 2
|
|
exec "$BIN/$name" "$@"
|
|
;;
|
|
*) fail "invalid command: $1 (use --help)" ;;
|
|
esac
|