Files
launcher/tilde
T

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