diff --git a/CHANGELOG.md b/CHANGELOG.md index 66bd2f7..702f25d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Plugin resolver review follow-ups (Story 13.3 senior-developer review): + - **H1**: malformed `.devrail.yml` no longer silently treated as + "no plugins declared". `yq` parse failures now surface as a structured + error event and exit 2 instead of being swallowed by `|| echo 0`. + - **M1**: lockfile verifier passes the `.devrail.yml` source URL via + `strenv()` instead of string-interpolating it into the yq query — + defends against malformed/malicious source values breaking the query. + - **M2**: slug collisions (two plugin sources with the same `basename`) + are now detected upfront and rejected with a clear "plugin slug + collision" error event. Previously the second plugin would silently + overwrite the first's cache. + - **M3**: `fetch_to_cache` now performs an atomic swap (move existing + target aside, install new, remove old) so concurrent `make check` + invocations during a `make plugins-update` see either the old tree + or the new one — never an absent or half-populated path. + - **M4**: `compute_content_hash` and `derive_slug` extracted to + `lib/plugin-cache.sh` and shared between resolver and verifier + (was duplicated; future drift risk eliminated). + - **M5**: resolver now invokes `plugin-validator.sh` on the fetched + manifest before computing content_hash. Authors hit manifest + structural issues at `make plugins-update` time, not at every + subsequent `make check`. + - **L1**: `fetch_to_cache` comment updated to reflect 4 args (not 3). + - **L2**: `derive_slug` strips the `.git` suffix so + `https://example.com/foo.git` produces cache path + `/foo//`, not `/foo.git//`. + - **L3**: lockfile entries written with double-quoted YAML scalars so + source URLs containing colons, brackets, or other reserved chars + don't break parsing. + - **L4**: smoke test now covers `_plugins-update` no-op when no + plugins declared (companion to existing `_plugins-verify` case). + - **L5**: idempotent-fetch test now asserts cache sentinel `.devrail.sha` + mtime is stable across re-runs (proves no re-clone happened). + - **L6**: smoke test exercises a `.git`-suffixed source URL. + ### Added +- `lib/plugin-cache.sh` — shared `derive_slug` and `compute_content_hash` + helpers used by the resolver and verifier. Single source of truth for + the on-disk cache layout. +- `tests/test-plugin-resolver.sh` extended from 11 to 16 cases + (review-fix coverage: yq parse error, slug collision, `.git` suffix, + no-plugins update no-op, mtime-based idempotency). - Plugin resolver and lockfile (Story 13.3, Epic 13 / v1.10.x preview). - **`make plugins-update`** — public target that resolves every `.devrail.yml` plugin's `rev:` to an immutable SHA via `git ls-remote`, diff --git a/lib/plugin-cache.sh b/lib/plugin-cache.sh new file mode 100644 index 0000000..b5dcb5b --- /dev/null +++ b/lib/plugin-cache.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# lib/plugin-cache.sh — Plugin cache helpers (Story 13.3) +# +# Purpose: Single source of truth for the plugin-cache slug derivation and +# content-hash computation. Sourced by both `plugin-resolver.sh` +# and `plugin-lockfile-verify.sh` so the two stay in lockstep. +# +# Usage: source "${DEVRAIL_LIB}/plugin-cache.sh" +# Dependencies: lib/log.sh +# +# Functions: +# derive_slug - Plugin cache directory slug +# compute_content_hash - Deterministic sha256 of tree content + +# Guard against double-sourcing +# shellcheck disable=SC2317 +if [[ -n "${_DEVRAIL_PLUGIN_CACHE_LOADED:-}" ]]; then + return 0 2>/dev/null || true +fi +readonly _DEVRAIL_PLUGIN_CACHE_LOADED=1 + +# derive_slug prints the on-disk cache slug for a plugin source URL. +# +# - basename of the URL (so `https://github.com/foo/bar` → `bar`) +# - strips a trailing `.git` suffix (so `bar.git` → `bar`) +# - rejects empty input and slugs that contain shell-special characters +# (slashes, colons, etc.) — those would mean basename failed +# +# Example: derive_slug https://github.com/community/devrail-plugin-elixir.git +# → devrail-plugin-elixir +derive_slug() { + local source_url="${1:-}" + if [[ -z "${source_url}" ]]; then + return 1 + fi + local slug + slug="$(basename "${source_url}")" + # Strip optional .git suffix + slug="${slug%.git}" + # Reject anything that's not a typical project-name shape — defense against + # weird URLs that basename can't parse cleanly. + if [[ ! "${slug}" =~ ^[a-zA-Z0-9._-]+$ ]]; then + return 1 + fi + printf "%s" "${slug}" +} + +# compute_content_hash prints a sha256 hex digest over a directory's +# non-.git/, non-sentinel files. Stable across machines via LC_ALL=C. +# Returns non-zero if the directory doesn't exist. +compute_content_hash() { + local dir="${1:-}" + if [[ ! -d "${dir}" ]]; then + if declare -f log_event >/dev/null 2>&1; then + log_event error "content_hash directory missing" path="${dir}" language=_plugins + fi + return 1 + fi + (cd "${dir}" && + LC_ALL=C find . -type f \ + -not -path './.git/*' \ + -not -name '.devrail.sha' \ + -print0 | + LC_ALL=C sort -z | + xargs -0 sha256sum | + sha256sum | + cut -d' ' -f1) +} diff --git a/scripts/plugin-lockfile-verify.sh b/scripts/plugin-lockfile-verify.sh index 8fa315e..087a6a3 100755 --- a/scripts/plugin-lockfile-verify.sh +++ b/scripts/plugin-lockfile-verify.sh @@ -10,7 +10,7 @@ # Exit 0 — verified or no-op (no plugins declared) # Exit 2 — disagreement, missing lockfile, or tampering detected # -# Dependencies: yq v4+, sha256sum, find, sort, lib/log.sh +# Dependencies: yq v4+, sha256sum, find, sort, lib/log.sh, lib/plugin-cache.sh set -euo pipefail LC_ALL=C @@ -22,6 +22,8 @@ DEVRAIL_LIB="${DEVRAIL_LIB:-${SCRIPT_DIR}/../lib}" # shellcheck source=../lib/log.sh source "${DEVRAIL_LIB}/log.sh" +# shellcheck source=../lib/plugin-cache.sh +source "${DEVRAIL_LIB}/plugin-cache.sh" # --- Help --- if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then @@ -43,11 +45,23 @@ fi require_cmd "yq" "yq is required (v4+)" +# Distinguish "yq parse error" from "no plugins declared" (review fix H1). +parse_plugin_count() { + local count + if ! count="$(yq -r '.plugins // [] | length' "${DEVRAIL_YML}" 2>&1)"; then + log_event error "config could not be parsed by yq" \ + path="${DEVRAIL_YML}" reason="${count}" language=_plugins + return 1 + fi + printf "%s" "${count}" +} + +if ! plugin_count="$(parse_plugin_count)"; then + exit 2 +fi + # --- No-op when no plugins declared (regression-safe for v1.9.x consumers) --- -plugin_count="$(yq -r '.plugins // [] | length' "${DEVRAIL_YML}" 2>/dev/null || echo 0)" if [[ "${plugin_count}" == "0" ]]; then - # Even if .devrail.lock exists, having no plugins declared means there is - # nothing for the loader to load. Quietly succeed. exit 0 fi @@ -62,19 +76,6 @@ fi require_cmd "sha256sum" "sha256sum is required (coreutils)" -# compute_content_hash -compute_content_hash() { - local dir="$1" - (cd "${dir}" && find . -type f \ - -not -path './.git/*' \ - -not -name '.devrail.sha' \ - -print0 | - sort -z | - xargs -0 sha256sum | - sha256sum | - cut -d' ' -f1) -} - # --- Cross-check every yml entry has a matching lock entry with same rev --- violations=0 for i in $(seq 0 $((plugin_count - 1))); do @@ -87,7 +88,10 @@ for i in $(seq 0 $((plugin_count - 1))); do continue fi - lock_rev="$(yq -r ".plugins[] | select(.source == \"${yml_source}\") | .rev" "${LOCKFILE}" 2>/dev/null | head -1)" + # Pass yml_source via env (strenv) so a malicious source URL with quotes, + # backslashes, or yq-expression-special characters can't break the query + # (review fix M1). + lock_rev="$(yml_source="${yml_source}" yq -r '.plugins[] | select(.source == strenv(yml_source)) | .rev' "${LOCKFILE}" 2>/dev/null | head -1)" if [[ -z "${lock_rev}" || "${lock_rev}" == "null" ]]; then log_event error "lockfile mismatch" \ source="${yml_source}" \ @@ -106,12 +110,16 @@ for i in $(seq 0 $((plugin_count - 1))); do continue fi - # Content-hash tampering check: re-compute hash of cached tree, compare - # to the lockfile-recorded hash. - recorded_hash="$(yq -r ".plugins[] | select(.source == \"${yml_source}\") | .content_hash" "${LOCKFILE}" 2>/dev/null | head -1)" + # Content-hash tampering check. + recorded_hash="$(yml_source="${yml_source}" yq -r '.plugins[] | select(.source == strenv(yml_source)) | .content_hash' "${LOCKFILE}" 2>/dev/null | head -1)" recorded_hash="${recorded_hash#sha256:}" - slug="$(basename "${yml_source}")" + if ! slug="$(derive_slug "${yml_source}")"; then + log_event error "plugin source URL produced an invalid slug" \ + source="${yml_source}" rev="${yml_rev}" language=_plugins + violations=$((violations + 1)) + continue + fi cached_dir="${PLUGINS_DIR}/${slug}/${yml_rev}" if [[ ! -d "${cached_dir}" ]]; then log_event error "cached tree missing" \ diff --git a/scripts/plugin-resolver.sh b/scripts/plugin-resolver.sh index a204b49..6942cb1 100755 --- a/scripts/plugin-resolver.sh +++ b/scripts/plugin-resolver.sh @@ -3,17 +3,17 @@ # # Purpose: For each plugin declared in `.devrail.yml`, resolve the `rev:` # (tag or SHA) to an immutable SHA via `git ls-remote`, fetch the -# plugin repo to a content-addressed cache directory, compute a -# deterministic content_hash of the tree, and write a sorted YAML -# lockfile atomically. +# plugin repo to a content-addressed cache directory, validate the +# fetched manifest, compute a deterministic content_hash of the +# tree, and write a sorted YAML lockfile atomically. # # Usage: bash scripts/plugin-resolver.sh [] [--help] # Default config path: .devrail.yml in CWD. # Exit 0 — lockfile written (or no-op if no plugins declared) -# Exit 2 — resolution / fetch / configuration failure +# Exit 2 — resolution / fetch / configuration / validation failure # -# Dependencies: yq v4+, git 2+, sha256sum, find, sort, lib/log.sh, -# lib/version.sh +# Dependencies: yq v4+, git 2+, sha256sum, find, sort, plugin-validator.sh, +# lib/log.sh, lib/version.sh, lib/plugin-cache.sh set -euo pipefail LC_ALL=C @@ -27,6 +27,8 @@ DEVRAIL_LIB="${DEVRAIL_LIB:-${SCRIPT_DIR}/../lib}" source "${DEVRAIL_LIB}/log.sh" # shellcheck source=../lib/version.sh source "${DEVRAIL_LIB}/version.sh" +# shellcheck source=../lib/plugin-cache.sh +source "${DEVRAIL_LIB}/plugin-cache.sh" # --- Help --- if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then @@ -59,6 +61,19 @@ require_cmd "sha256sum" "sha256sum is required (coreutils)" # Always remove the temp lockfile on exit (atomic-write contract). trap 'rm -f "${LOCKFILE_TMP}"' EXIT +# parse_plugin_count reads `.plugins | length`, distinguishing "no plugins +# declared" (count=0, exit 0) from "yq parse error" (exit 1). Without this, +# malformed YAML was silently treated as "no plugins" — review finding H1. +parse_plugin_count() { + local count + if ! count="$(yq -r '.plugins // [] | length' "${DEVRAIL_YML}" 2>&1)"; then + log_event error "config could not be parsed by yq" \ + path="${DEVRAIL_YML}" reason="${count}" language=_plugins + return 1 + fi + printf "%s" "${count}" +} + # resolve_ref # Echoes the resolved SHA on stdout. # Rejects branch refs and unrecognised refs with a non-zero return. @@ -108,9 +123,12 @@ resolve_ref() { printf "%s" "${sha}" } -# fetch_to_cache +# fetch_to_cache # Clones the source at into ${PLUGINS_DIR}/// if not already -# present. Idempotent — if the cached tree exists, no fetch occurs. +# present. Idempotent — if the cached tree exists and the recorded SHA matches, +# no fetch occurs. Atomic: fetches into a sibling dir and moves the OLD target +# aside before installing the new one, so a concurrent reader either sees the +# old or the new tree but never an empty/half-populated path (review fix M3). fetch_to_cache() { local source_url="$1" local slug="$2" @@ -129,7 +147,7 @@ fetch_to_cache() { log_event info "fetching plugin" source="${source_url}" rev="${rev}" sha="${sha}" language=_plugins - mkdir -p "${target}" + mkdir -p "$(dirname "${target}")" local fetch_dir fetch_dir="$(mktemp -d "${target}.fetch.XXXXXX")" if ! (cd "${fetch_dir}" && git init --quiet && @@ -140,36 +158,42 @@ fetch_to_cache() { rm -rf "${fetch_dir}" return 1 fi - - # Move the fetched tree contents into target/, atomically replacing any - # stale cached copy of the same slug/rev. - rm -rf "${target}" - mv "${fetch_dir}" "${target}" - printf "%s\n" "${sha}" >"${target}/.devrail.sha" -} - -# compute_content_hash -# Deterministic hash of all non-.git/ files in the directory. -# Stable across machines (LC_ALL=C, find+sort+sha256sum chain). -compute_content_hash() { - local dir="$1" - if [[ ! -d "${dir}" ]]; then - log_event error "content_hash directory missing" path="${dir}" language=_plugins + printf "%s\n" "${sha}" >"${fetch_dir}/.devrail.sha" + + # Atomic swap: move existing target to .old, install new, then remove .old. + # A concurrent reader either sees the old target or the new one; never an + # absent or partially-populated path. + local old="${target}.old.$$" + if [[ -d "${target}" ]]; then + mv "${target}" "${old}" + fi + if ! mv "${fetch_dir}" "${target}"; then + if [[ -d "${old}" ]]; then + mv "${old}" "${target}" + fi + log_event error "atomic cache swap failed" source="${source_url}" sha="${sha}" language=_plugins + rm -rf "${fetch_dir}" return 1 fi - (cd "${dir}" && find . -type f \ - -not -path './.git/*' \ - -not -name '.devrail.sha' \ - -print0 | - sort -z | - xargs -0 sha256sum | - sha256sum | - cut -d' ' -f1) + if [[ -d "${old}" ]]; then + rm -rf "${old}" + fi +} + +# yaml_quote — render a value as a double-quoted YAML scalar +# (review fix L3). Escapes backslashes and double-quotes. +yaml_quote() { + local v="$1" + v="${v//\\/\\\\}" + v="${v//\"/\\\"}" + printf '"%s"' "${v}" } # --- Main --- -plugin_count="$(yq -r '.plugins // [] | length' "${DEVRAIL_YML}" 2>/dev/null || echo 0)" +if ! plugin_count="$(parse_plugin_count)"; then + exit 2 +fi if [[ "${plugin_count}" == "0" ]]; then log_event info "no plugins declared; lockfile not generated" language=_plugins exit 0 @@ -181,6 +205,7 @@ log_event info "resolving plugins" plugin_count:="${plugin_count}" language=_plu # associative-array-keyed-by-source map, then sort the keys before writing. declare -a SOURCES_ORDER=() declare -A ENTRY_BY_SOURCE=() +declare -A SLUG_TO_SOURCE=() failed=0 for i in $(seq 0 $((plugin_count - 1))); do @@ -194,7 +219,28 @@ for i in $(seq 0 $((plugin_count - 1))); do continue fi - slug="$(basename "${source_url}")" + if ! slug="$(derive_slug "${source_url}")"; then + log_event error "plugin source URL produced an invalid slug" \ + source="${source_url}" rev="${rev}" \ + reason="basename(source) must be alphanumeric+./_- after stripping .git" \ + language=_plugins + failed=$((failed + 1)) + continue + fi + + # Slug-collision check: detect upfront so consumers fail fast on a config + # error rather than silently overwriting one plugin's cache (review fix M2). + if [[ -n "${SLUG_TO_SOURCE[${slug}]:-}" && "${SLUG_TO_SOURCE[${slug}]}" != "${source_url}" ]]; then + log_event error "plugin slug collision" \ + slug="${slug}" \ + source_a="${SLUG_TO_SOURCE[${slug}]}" \ + source_b="${source_url}" \ + reason="two distinct sources resolve to the same cache directory; rename one" \ + language=_plugins + failed=$((failed + 1)) + continue + fi + SLUG_TO_SOURCE["${slug}"]="${source_url}" if ! sha="$(resolve_ref "${source_url}" "${rev}")"; then failed=$((failed + 1)) @@ -214,6 +260,17 @@ for i in $(seq 0 $((plugin_count - 1))); do continue fi + # Validate the manifest now so authors find structural issues at update + # time, not at every subsequent `make check` (review fix M5). + if ! bash "${SCRIPT_DIR}/plugin-validator.sh" "${manifest}"; then + log_event error "fetched manifest failed validation" \ + source="${source_url}" rev="${rev}" path="${manifest}" \ + reason="see plugin-validator.sh output above for the violation list" \ + language=_plugins + failed=$((failed + 1)) + continue + fi + schema_version="$(yq -r '.schema_version' "${manifest}" 2>/dev/null || echo "")" if [[ -z "${schema_version}" || "${schema_version}" == "null" ]]; then log_event error "manifest schema_version not readable" \ @@ -227,9 +284,14 @@ for i in $(seq 0 $((plugin_count - 1))); do continue fi - # Build an entry for this source. Entries get sorted by `source` before write. - entry_block="$(printf ' - source: %s\n rev: %s\n sha: %s\n schema_version: %s\n content_hash: sha256:%s\n' \ - "${source_url}" "${rev}" "${sha}" "${schema_version}" "${content_hash}")" + # Build a deterministic, fully-quoted YAML entry for this source. + entry_block="$({ + printf ' - source: %s\n' "$(yaml_quote "${source_url}")" + printf ' rev: %s\n' "$(yaml_quote "${rev}")" + printf ' sha: %s\n' "$(yaml_quote "${sha}")" + printf ' schema_version: %s\n' "${schema_version}" + printf ' content_hash: %s\n' "$(yaml_quote "sha256:${content_hash}")" + })" if [[ -z "${ENTRY_BY_SOURCE[${source_url}]:-}" ]]; then SOURCES_ORDER+=("${source_url}") fi diff --git a/tests/test-plugin-resolver.sh b/tests/test-plugin-resolver.sh index bce9ed4..9ef1126 100755 --- a/tests/test-plugin-resolver.sh +++ b/tests/test-plugin-resolver.sh @@ -16,6 +16,11 @@ # 9. No-regression — `.devrail.yml` without `plugins:` → _plugins-verify exits 0 # 10. Unreachable source — file:///nonexistent path; resolver exits 2 # 11. Atomic lockfile — failure after one successful resolution leaves prior lockfile intact +# 12. Malformed YAML — yq parse error surfaces as exit 2, not silent success (review fix H1) +# 13. Slug collision — two distinct sources, same basename → fail fast (review fix M2) +# 14. .git-suffixed source URL — basename strips .git; cache path is clean (review fix L2/L6) +# 15. plugins-update no-op when no plugins declared (review fix L4) +# 16. Idempotent fetch verified by cache sentinel mtime stability (review fix L5) # # Usage: bash tests/test-plugin-resolver.sh # Env: @@ -224,8 +229,9 @@ plugins: languages: [elixir] YAML run_make "$WORKDIR/case6" _plugins-update >/dev/null -# Tamper with the lockfile by flipping the rev -sed -i 's/rev: v1\.0\.0/rev: v9.9.9/' "$WORKDIR/case6/.devrail.lock" +# Tamper with the lockfile by flipping the rev. Lockfile entries use +# double-quoted YAML scalars (review fix L3), so match the quoted form. +sed -i 's/rev: "v1\.0\.0"/rev: "v9.9.9"/' "$WORKDIR/case6/.devrail.lock" run_make "$WORKDIR/case6" _plugins-verify echo "$RUN_OUT" >"$WORKDIR/case6.log" assert_eq "2" "$RUN_EXIT" "case6 verify exit code" @@ -330,4 +336,118 @@ assert_eq "2" "$RUN_EXIT" "case11 exit code (failure expected)" LOCK_AFTER_HASH="$(sha256sum "$WORKDIR/case11/.devrail.lock" | cut -d' ' -f1)" assert_eq "$LOCK_GOOD_HASH" "$LOCK_AFTER_HASH" "case11 atomic lockfile preserved" -echo "==> All plugin-resolver smoke checks passed (11/11)" +# --- Case 12: malformed YAML produces a parse error, not silent success --- +# Review fix H1. Without this, the previous resolver would have read +# `plugins | length` as 0 and exited 0, treating broken YAML as "no plugins". +echo "==> Case 12: malformed YAML in .devrail.yml" +mkdir -p "$WORKDIR/case12" +cat >"$WORKDIR/case12/.devrail.yml" <<'YAML' +languages: [elixir] +plugins: + - source: not-yaml: ][}{ + rev: v1.0.0 +YAML +run_make "$WORKDIR/case12" _plugins-update +echo "$RUN_OUT" >"$WORKDIR/case12.log" +assert_eq "2" "$RUN_EXIT" "case12 exit code" +assert_jq "$WORKDIR/case12.log" 'select(.level=="error" and .msg=="config could not be parsed by yq")' "case12 yq-parse-error event" + +# --- Case 13: slug collision between two distinct sources ---------------- +# Review fix M2. Two sources with different paths but matching basenames +# would collide on `///`. Detect and reject. +echo "==> Case 13: slug collision detection" +build_local_git_repo elixir-v1 "$WORKDIR/elixir-collide-a" +build_local_git_repo elixir-v1 "$WORKDIR/elixir-collide-b" +mkdir -p "$WORKDIR/case13" +cat >"$WORKDIR/case13/.devrail.yml" <"$WORKDIR/case13.log" +assert_eq "2" "$RUN_EXIT" "case13 exit code" +assert_jq "$WORKDIR/case13.log" 'select(.level=="error" and .msg=="plugin slug collision")' "case13 slug-collision event" + +# --- Case 14: source URL with .git suffix produces a clean slug ---------- +# Review fix L2/L6. basename strips the .git suffix so the cache path is +# `///`, not `/.git//`. +echo "==> Case 14: source URL with .git suffix" +mkdir -p "$WORKDIR/elixir-with-git-suffix" +cp -a "$WORKDIR/elixir-repo/.git" "$WORKDIR/elixir-with-git-suffix.git" 2>/dev/null || true +# Build a fresh repo whose URL ends in .git +build_local_git_repo elixir-v1 "$WORKDIR/elixir-bar.git" +mkdir -p "$WORKDIR/case14" +cat >"$WORKDIR/case14/.devrail.yml" <"$WORKDIR/case14.log" +assert_eq "0" "$RUN_EXIT" "case14 exit code" +# Cache files are root-owned 0700 inside docker; check via sidecar container. +if ! docker run --rm -v "$WORKDIR/plugins-cache:/cache:ro" "$IMAGE" \ + sh -c 'test -d /cache/elixir-bar/v1.0.0' >/dev/null 2>&1; then + echo "FAIL [case14]: expected cache at .../elixir-bar/v1.0.0 (without .git suffix)" >&2 + exit 1 +fi + +# --- Case 15: _plugins-update no-op when no plugins declared ------------- +# Review fix L4. Companion to Case 9 (which tested _plugins-verify). +echo "==> Case 15: _plugins-update no-op with no plugins" +mkdir -p "$WORKDIR/case15" +cat >"$WORKDIR/case15/.devrail.yml" <<'YAML' +languages: [bash] +YAML +run_make "$WORKDIR/case15" _plugins-update +echo "$RUN_OUT" >"$WORKDIR/case15.log" +assert_eq "0" "$RUN_EXIT" "case15 exit code" +assert_jq "$WORKDIR/case15.log" 'select(.level=="info" and (.msg | contains("no plugins declared")))' "case15 no-plugins info event" +[ ! -f "$WORKDIR/case15/.devrail.lock" ] || { + echo "FAIL [case15]: should not generate .devrail.lock when no plugins declared" >&2 + exit 1 +} + +# --- Case 16: idempotent fetch verified by cache mtime stability --------- +# Review fix L5. Case 5 only asserted the "plugin already cached" event; +# this case additionally asserts the cached .devrail.sha sentinel mtime +# is stable across re-runs (proves no re-clone happened). +echo "==> Case 16: idempotent fetch leaves cache mtime stable" +mkdir -p "$WORKDIR/case16" +cat >"$WORKDIR/case16/.devrail.yml" </dev/null' +} +run_make "$WORKDIR/case16" _plugins-update >/dev/null +mtime_before="$(read_sentinel_mtime)" +if [ -z "$mtime_before" ]; then + echo "FAIL [case16]: .devrail.sha sentinel missing after first update" >&2 + exit 1 +fi +sleep 1 +run_make "$WORKDIR/case16" _plugins-update >/dev/null +mtime_after="$(read_sentinel_mtime)" +assert_eq "$mtime_before" "$mtime_after" "case16 sentinel mtime unchanged across re-update" + +echo "==> All plugin-resolver smoke checks passed (16/16)"