diff --git a/AGENTS.md b/AGENTS.md index 3bd212ef..0b136ba4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,10 +4,16 @@ ### Shell Scripts -- Max 30 lines per file -- Use `set -euo pipefail` for scripts +- Executable scripts (`scripts/*.sh`) start with `set -euo pipefail` +- Sourced files must NOT set shell options: `lib/*.sh` and everything under + `DreamcoderShell/.config/shell/` run inside the caller's shell, and `errexit`/`nounset` + there closes an interactive terminal on the first failing command +- Shell fragments loaded by interactive shells (aliases, small functions) stay short; + scripts and libraries have one purpose per file and functions that fit on a screen - Quote all variables: `"${var}"` - Use `[[ ]]` instead of `[ ]` for tests +- Pass values to inline Python or other interpreters through arguments, never by + interpolating them into source text ### Modularity diff --git a/lib/ml4w.sh b/lib/ml4w.sh index 23b6e962..897b1fc4 100644 --- a/lib/ml4w.sh +++ b/lib/ml4w.sh @@ -26,15 +26,33 @@ waybar_is_ml4w_managed() { return 1 } +# A config path is ML4W-managed when it is a symlink itself, or when it resolves +# into the ML4W dotfiles root. ML4W 2.16 links whole app directories +# (~/.config/rofi -> ~/.mydotfiles/...), so the files inside are regular files from +# the link's point of view while ML4W still owns them. ML4W_DOTFILES_DIR relocates +# the root for non-default installs. +path_is_ml4w_managed() { + local path="$1" real root="${ML4W_DOTFILES_DIR:-${HOME}/.mydotfiles}" + [[ -e "${path}" ]] || return 1 + [[ -L "${path}" ]] && return 0 + real="$(readlink -f -- "${path}")" || return 1 + [[ "${real}" == "${root}/"* ]] +} + # Hyprland colour files (colors.lua / colors.conf) carry Dreamcoder colours when # they are a symlink into a Dreamcoder variant, or a regular file byte-identical # to one of the DreamcoderThemes hypr-colors-* variants. Regular files are the # supported layout: ML4W 2.16 ships them as regular files and the theme sync # writes through whatever sits at the path (it only flips existing symlinks). +# True when the path is a symlink whose target name carries "dreamcoder". +_is_dreamcoder_symlink() { + [[ -L "$1" && "$(readlink "$1")" == *dreamcoder* ]] +} + hypr_colors_is_dreamcoder() { local path="$1" variant [[ -e "${path}" ]] || return 1 - [[ -L "${path}" && "$(readlink "${path}")" == *dreamcoder* ]] && return 0 + _is_dreamcoder_symlink "${path}" && return 0 for variant in "${DREAMCODER_DOTS_DIR}"/DreamcoderThemes/dreamcoder/hypr-colors-*."${path##*.}"; do [[ -f "${variant}" ]] && cmp -s "${path}" "${variant}" && return 0 done @@ -48,7 +66,7 @@ hypr_colors_is_dreamcoder() { waybar_colors_is_dreamcoder() { local path="$1" [[ -e "${path}" ]] || return 1 - [[ -L "${path}" && "$(readlink "${path}")" == *dreamcoder* ]] && return 0 + _is_dreamcoder_symlink "${path}" && return 0 [[ -L "${path}" ]] && return 1 head -n 5 "${path}" | grep -q 'Generated by Dreamcoder sync' } diff --git a/scripts/verify-ml4w-setup.sh b/scripts/verify-ml4w-setup.sh index 8359962a..9e1815d3 100755 --- a/scripts/verify-ml4w-setup.sh +++ b/scripts/verify-ml4w-setup.sh @@ -80,22 +80,24 @@ echo "" # ── 1. System checks ═════════════════════════════════════════════════════════ title "1. System" -if command -v hyprctl >/dev/null; then - if hyprctl monitors -j 2>/dev/null | jq -e 'length > 0' >/dev/null 2>&1; then - ok "Hyprland is running" - else - fail "Hyprland is not running (no monitors detected)" - fi -else - fail "hyprctl not found — Hyprland not installed?" -fi - +have_jq=0 if command -v jq >/dev/null; then ok "jq is installed" + have_jq=1 else fail "jq is not installed" fi +if ! command -v hyprctl >/dev/null; then + fail "hyprctl not found — Hyprland not installed?" +elif ((!have_jq)); then + warn "Hyprland running check skipped (needs jq)" +elif hyprctl monitors -j 2>/dev/null | jq -e 'length > 0' >/dev/null 2>&1; then + ok "Hyprland is running" +else + fail "Hyprland is not running (no monitors detected)" +fi + HYPR_VERSION="" if command -v hyprctl >/dev/null; then HYPR_VERSION="$(hyprctl version 2>/dev/null | head -1 | grep -oP 'Hyprland \K[^ ]+' || echo "unknown")" @@ -114,11 +116,10 @@ SYMLINKS=( for entry in "${SYMLINKS[@]}"; do path="${entry%%:*}" label="${entry#*:}" - if [[ -L "$path" ]]; then - target=$(readlink "$path") - ok "${label} (→ ${target})" + if path_is_ml4w_managed "$path"; then + ok "${label} (→ $(readlink -f -- "$path"))" elif [[ -f "$path" ]]; then - warn "${label} — regular file, not symlink" + warn "${label} — regular file outside the ML4W dotfiles" else fail "${label} — NOT FOUND" fi @@ -160,29 +161,22 @@ else fail "waybar/colors.css is missing" fi -# Wlogout → waybar -if [[ -L "${HOME}/.config/wlogout/colors.css" ]]; then - target=$(readlink "${HOME}/.config/wlogout/colors.css") - if [[ "$target" == *"waybar/colors.css" ]]; then - ok "wlogout/colors.css → waybar (shared)" - else - warn "wlogout/colors.css → ${target}" - fi -else - fail "wlogout/colors.css is not a symlink" -fi - -# Swaync → waybar -if [[ -L "${HOME}/.config/swaync/colors.css" ]]; then - target=$(readlink "${HOME}/.config/swaync/colors.css") - if [[ "$target" == *"waybar/colors.css" ]]; then - ok "swaync/colors.css → waybar (shared)" +# Wlogout and Swaync share Waybar's colours through a symlink. +check_shared_waybar_colors() { + local app="$1" link="${HOME}/.config/${1}/colors.css" target + if [[ -L "$link" ]]; then + target=$(readlink "$link") + if [[ "$target" == *"waybar/colors.css" ]]; then + ok "${app}/colors.css → waybar (shared)" + else + warn "${app}/colors.css → ${target}" + fi else - warn "swaync/colors.css → ${target}" + fail "${app}/colors.css is not a symlink" fi -else - fail "swaync/colors.css is not a symlink" -fi +} +check_shared_waybar_colors wlogout +check_shared_waybar_colors swaync # Hyprland colors.lua / colors.conf: a Dreamcoder symlink or a managed regular # file with Dreamcoder content (ML4W 2.16 ships regular files; the sync writes @@ -278,21 +272,22 @@ fi # ── 7. ML4W profile ═══════════════════════════════════════════════════════ title "7. ML4W profile" -if [[ -n "${PROFILE_NAME}" ]]; then - PROFILE_FILE="${DREAMCODER_DOTS_DIR}/DreamcoderProfiles/dreamcoder/${PROFILE_NAME}.json" -else +if [[ -z "${PROFILE_NAME}" ]]; then # Auto-detect HOSTNAME="$(hostname -s 2>/dev/null || echo "unknown")" case "$(echo "${HOSTNAME}" | tr '[:upper:]' '[:lower:]')" in *asus* | *vivobook*) PROFILE_NAME="asus-vivobook15" ;; *) PROFILE_NAME="default" ;; esac - PROFILE_FILE="${DREAMCODER_DOTS_DIR}/DreamcoderProfiles/dreamcoder/${PROFILE_NAME}.json" info "Auto-detected profile: ${PROFILE_NAME}" fi +PROFILES_DIR="${DREAMCODER_DOTS_DIR}/DreamcoderProfiles/dreamcoder" +PROFILE_FILE="${PROFILES_DIR}/${PROFILE_NAME}.json" if [[ -f "$PROFILE_FILE" ]]; then - if jq empty "$PROFILE_FILE" 2>/dev/null; then + if ((!have_jq)); then + warn "Profile ${PROFILE_NAME}.json JSON check skipped (needs jq)" + elif jq empty "$PROFILE_FILE" 2>/dev/null; then ok "Profile ${PROFILE_NAME}.json is valid JSON" else fail "Profile ${PROFILE_NAME}.json is INVALID JSON" @@ -302,22 +297,31 @@ else fi # Schema validation -SCHEMA_FILE="${DREAMCODER_DOTS_DIR}/DreamcoderProfiles/dreamcoder/profile.schema.json" +SCHEMA_FILE="${PROFILES_DIR}/profile.schema.json" if [[ -f "$SCHEMA_FILE" ]]; then ok "Schema file exists" if command -v python3 >/dev/null && python3 -c "import jsonschema" 2>/dev/null; then - if python3 -c " -import json, sys -with open('${SCHEMA_FILE}') as f: schema = json.load(f) -with open('${PROFILE_FILE}') as f: profile = json.load(f) + # Paths travel through argv: interpolating them into Python source breaks on a + # quote and would let a crafted path run code. + if python3 - "$SCHEMA_FILE" "$PROFILE_FILE" 2>/dev/null <<'PY' +import json +import sys + import jsonschema + +with open(sys.argv[1]) as f: + schema = json.load(f) +with open(sys.argv[2]) as f: + profile = json.load(f) jsonschema.validate(instance=profile, schema=schema) -print('OK') -" 2>/dev/null; then +PY + then ok "Profile matches schema" else warn "Profile does NOT match schema" fi + else + warn "Schema validation skipped (python3 with jsonschema not available)" fi else warn "Schema file not found" diff --git a/tests/ml4w/ml4w_managed.bats b/tests/ml4w/ml4w_managed.bats index e78430d3..472f8703 100644 --- a/tests/ml4w/ml4w_managed.bats +++ b/tests/ml4w/ml4w_managed.bats @@ -200,3 +200,53 @@ load_ml4w_lib() { run waybar_colors_is_dreamcoder "${HOME}/.config/waybar/colors.css" [ "$status" -ne 0 ] } + +# ── path_is_ml4w_managed ───────────────────────────────────────────────────── +# ML4W 2.16 links whole app directories (~/.config/rofi -> ~/.mydotfiles/...), so +# the files inside are regular files from the link's point of view while still +# being owned by ML4W. Ownership therefore follows where the path resolves to. + +@test "ml4w lib: a file inside a directory symlinked into the ML4W dotfiles is managed" { + mkdir -p "${HOME}/.config" "${HOME}/.mydotfiles/com.ml4w.dotfiles/.config/rofi" + : >"${HOME}/.mydotfiles/com.ml4w.dotfiles/.config/rofi/config.rasi" + ln -s "${HOME}/.mydotfiles/com.ml4w.dotfiles/.config/rofi" "${HOME}/.config/rofi" + + load_ml4w_lib + run path_is_ml4w_managed "${HOME}/.config/rofi/config.rasi" + [ "$status" -eq 0 ] +} + +@test "ml4w lib: a symlinked file is managed" { + mkdir -p "${HOME}/.config/swaync" "${HOME}/.mydotfiles/x" + : >"${HOME}/.mydotfiles/x/config.json" + ln -s "${HOME}/.mydotfiles/x/config.json" "${HOME}/.config/swaync/config.json" + + load_ml4w_lib + run path_is_ml4w_managed "${HOME}/.config/swaync/config.json" + [ "$status" -eq 0 ] +} + +@test "ml4w lib: a plain regular file outside the ML4W dotfiles is not managed" { + mkdir -p "${HOME}/.config/wlogout" + : >"${HOME}/.config/wlogout/layout" + + load_ml4w_lib + run path_is_ml4w_managed "${HOME}/.config/wlogout/layout" + [ "$status" -ne 0 ] +} + +@test "ml4w lib: a missing path is not managed" { + load_ml4w_lib + run path_is_ml4w_managed "${HOME}/.config/rofi/config.rasi" + [ "$status" -ne 0 ] +} + +@test "ml4w lib: ML4W_DOTFILES_DIR relocates the dotfiles root" { + mkdir -p "${HOME}/elsewhere/rofi" "${HOME}/.config" + : >"${HOME}/elsewhere/rofi/config.rasi" + ln -s "${HOME}/elsewhere/rofi" "${HOME}/.config/rofi" + + load_ml4w_lib + ML4W_DOTFILES_DIR="${HOME}/elsewhere" run path_is_ml4w_managed "${HOME}/.config/rofi/config.rasi" + [ "$status" -eq 0 ] +} diff --git a/tests/ml4w/verify_setup.bats b/tests/ml4w/verify_setup.bats new file mode 100644 index 00000000..31f532bd --- /dev/null +++ b/tests/ml4w/verify_setup.bats @@ -0,0 +1,65 @@ +# ============================================================================ +# BATS tests: scripts/verify-ml4w-setup.sh robustness +# ============================================================================ +# The script used to interpolate file paths into inline Python source, so a path +# with an apostrophe broke schema validation (and a crafted one could inject +# code); paths now travel through sys.argv. A missing jsonschema also skipped the +# check without saying so. + +load '../helpers/setup' + +# tests/helpers/setup.bash points HOME at a temp dir, which hides user-site Python +# packages (jsonschema often lives there). Keep the invoking user's package base so the +# schema check really runs instead of being skipped. +use_real_python_userbase() { + local real_home + real_home="$(getent passwd "$(id -u)" | cut -d: -f6)" + export PYTHONUSERBASE="${real_home}/.local" +} + +# A throwaway dots tree whose directory name contains an apostrophe. +make_dots_tree() { + DOTS="${TEST_TEMP_HOME}/dots'tree" + mkdir -p "${DOTS}/lib" "${DOTS}/DreamcoderProfiles/dreamcoder" + cp "${DREAMCODER_DOTS_DIR}/lib/"*.sh "${DOTS}/lib/" + cp "${DREAMCODER_DOTS_DIR}/DreamcoderProfiles/dreamcoder/default.json" \ + "${DREAMCODER_DOTS_DIR}/DreamcoderProfiles/dreamcoder/profile.schema.json" \ + "${DOTS}/DreamcoderProfiles/dreamcoder/" +} + +run_verify() { + DREAMCODER_DOTS_DIR="${DOTS}" run bash "${BATS_TEST_DIRNAME}/../../scripts/verify-ml4w-setup.sh" \ + --profile default +} + +@test "verify-ml4w-setup: schema validation works when the dots path contains an apostrophe" { + use_real_python_userbase + python3 -c 'import jsonschema' 2>/dev/null || skip "python jsonschema not installed" + command -v jq >/dev/null || skip "jq not installed" + make_dots_tree + run_verify + [[ "$output" == *"Profile matches schema"* ]] +} + +@test "verify-ml4w-setup: says so when schema validation is skipped for lack of jsonschema" { + make_dots_tree + # A python3 without jsonschema: a shim earlier in PATH. + mkdir -p "${TEST_TEMP_HOME}/bin" + printf '#!/bin/sh\nexit 1\n' >"${TEST_TEMP_HOME}/bin/python3" + chmod +x "${TEST_TEMP_HOME}/bin/python3" + PATH="${TEST_TEMP_HOME}/bin:${PATH}" run_verify + [[ "$output" == *"Schema validation skipped"* ]] +} + +@test "verify-ml4w-setup: a missing jq is reported as skipped, not as invalid JSON" { + make_dots_tree + # PATH with every tool except jq: symlink the basics into an empty bin dir. + mkdir -p "${TEST_TEMP_HOME}/nojq" + for tool in bash env dirname basename cat grep sed awk tr cut head tail sort uniq date git python3 readlink; do + src="$(command -v "$tool" 2>/dev/null)" && ln -sf "$src" "${TEST_TEMP_HOME}/nojq/$tool" + done + PATH="${TEST_TEMP_HOME}/nojq" run_verify + [[ "$output" == *"jq is not installed"* ]] + [[ "$output" == *"JSON check skipped (needs jq)"* ]] + [[ "$output" != *"INVALID JSON"* ]] +}