254 lines
9.2 KiB
Bash
254 lines
9.2 KiB
Bash
#!/usr/bin/env bash
|
|
# Shared bash reader for framework-manifest.txt (#791).
|
|
#
|
|
# This is the bash half of the SSOT ownership resolver; the TypeScript half is
|
|
# packages/mosaic/src/framework/manifest.ts. BOTH read the same
|
|
# framework-manifest.txt and MUST resolve identical ownership for any path — the
|
|
# parity test (manifest-parity.spec.ts) invokes this file's `resolve` CLI and
|
|
# compares it against the TS resolver, so the two can never drift (the #631
|
|
# two-copies failure class this closes).
|
|
#
|
|
# Ownership resolution (deny-wins / fail-safe):
|
|
# 1. operator glob matches -> operator
|
|
# 2. else framework glob -> framework
|
|
# 3. else -> operator (UNKNOWN defaults to operator, #791)
|
|
#
|
|
# Globs are compiled once at load into exact-prefix checks or POSIX EREs, so the
|
|
# hot resolver (manifest_is_framework) forks no subprocesses — the installer
|
|
# calls it once per file across the whole tree.
|
|
#
|
|
# Usage as a library (source it, then):
|
|
# manifest_load [manifest-file] # populates + compiles the manifest
|
|
# manifest_is_framework <rel-path> # rc 0 = framework-owned, rc 1 = operator
|
|
# manifest_resolve <rel-path> # echoes: framework | operator
|
|
# manifest_subtree_roots # echoes shipped framework `dir/**` roots
|
|
#
|
|
# Usage as a CLI (parity harness):
|
|
# bash manifest.sh resolve <rel-path>
|
|
# bash manifest.sh subtree-roots
|
|
# bash manifest.sh classify # reads paths on stdin -> "<own>\t<path>"
|
|
|
|
MANIFEST_FRAMEWORK=()
|
|
MANIFEST_OPERATOR=()
|
|
|
|
# Compiled forms (parallel arrays). _*_KIND[i] is "exact" or "re".
|
|
_MF_KIND=(); _MF_EXACT=(); _MF_RE=()
|
|
_MO_KIND=(); _MO_EXACT=(); _MO_RE=()
|
|
_MF_ROOTS=()
|
|
|
|
_manifest_default_root() { cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd; }
|
|
|
|
# Normalize a path/glob: backslashes -> slashes, strip leading ./ and /, strip
|
|
# trailing / (mirrors normalizeRel in manifest.ts).
|
|
_manifest_norm() {
|
|
local p="$1"
|
|
p="${p//\\//}"
|
|
p="${p#./}"
|
|
while [[ "$p" == /* ]]; do p="${p#/}"; done
|
|
while [[ "$p" == */ ]]; do p="${p%/}"; done
|
|
printf '%s' "$p"
|
|
}
|
|
|
|
# Translate a normalized glob into a POSIX ERE body (mirrors globToRegExpBody).
|
|
_manifest_glob_to_ere() {
|
|
local pattern; pattern="$(_manifest_norm "$1")"
|
|
local out="" c n i len=${#pattern} trailing
|
|
for (( i = 0; i < len; i++ )); do
|
|
c="${pattern:i:1}"
|
|
if [[ "$c" == "*" ]]; then
|
|
n="${pattern:i+1:1}"
|
|
if [[ "$n" == "*" ]]; then
|
|
i=$((i + 1))
|
|
trailing=0
|
|
if [[ "${pattern:i+1:1}" == "/" ]]; then i=$((i + 1)); trailing=1; fi
|
|
if [[ "$out" == */ ]]; then
|
|
out="${out%/}(/.*)?"
|
|
elif [[ "$trailing" -eq 1 ]]; then
|
|
out="$out(.*/)?"
|
|
else
|
|
out="$out.*"
|
|
fi
|
|
else
|
|
out="$out[^/]*"
|
|
fi
|
|
else
|
|
case "$c" in
|
|
.|+|\?|^|\$|\{|\}|\(|\)|\||\[|\]|\\) out="$out\\$c" ;;
|
|
*) out="$out$c" ;;
|
|
esac
|
|
fi
|
|
done
|
|
printf '%s' "$out"
|
|
}
|
|
|
|
# Compile one raw glob into (kind, exact, re) appended to the given section.
|
|
# $1 = raw glob, $2 = section letter (F|O).
|
|
_manifest_compile_one() {
|
|
local norm; norm="$(_manifest_norm "$1")"
|
|
[[ -n "$norm" ]] || return 0
|
|
if [[ "$norm" == *"*"* ]]; then
|
|
local re="^$(_manifest_glob_to_ere "$norm")\$"
|
|
if [[ "$2" == F ]]; then
|
|
_MF_KIND+=(re); _MF_EXACT+=(""); _MF_RE+=("$re")
|
|
else
|
|
_MO_KIND+=(re); _MO_EXACT+=(""); _MO_RE+=("$re")
|
|
fi
|
|
else
|
|
if [[ "$2" == F ]]; then
|
|
_MF_KIND+=(exact); _MF_EXACT+=("$norm"); _MF_RE+=("")
|
|
else
|
|
_MO_KIND+=(exact); _MO_EXACT+=("$norm"); _MO_RE+=("")
|
|
fi
|
|
fi
|
|
[[ "$2" == F && "$norm" == */"**" ]] && _MF_ROOTS+=("${norm%/**}")
|
|
return 0
|
|
}
|
|
|
|
_manifest_compile() {
|
|
_MF_KIND=(); _MF_EXACT=(); _MF_RE=(); _MF_ROOTS=()
|
|
_MO_KIND=(); _MO_EXACT=(); _MO_RE=()
|
|
local g
|
|
for g in "${MANIFEST_FRAMEWORK[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" F; done
|
|
for g in "${MANIFEST_OPERATOR[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" O; done
|
|
# Explicit success: an empty operator array makes the final `[[ -n "" ]] && …`
|
|
# short-circuit to rc 1, which would otherwise become this function's (and
|
|
# manifest_load's) return code — a spurious failure (#791 B2). Never rely on
|
|
# the last loop's exit status here.
|
|
return 0
|
|
}
|
|
|
|
# Load + compile the manifest. Rejects a malformed file the same way
|
|
# parseManifest() does (entry before a section header / unknown header).
|
|
manifest_load() {
|
|
local file="${1:-}"
|
|
[[ -n "$file" ]] || file="$(_manifest_default_root)/framework-manifest.txt"
|
|
# Fail CLOSED on a missing/unreadable manifest. Without this, `done < "$file"`
|
|
# aborts on a raw redirection error with no explanation; downstream that reads
|
|
# as "no framework paths" and an upgrade could no-op silently (#791 B2/B3).
|
|
if [[ ! -r "$file" ]]; then
|
|
echo "manifest: cannot read manifest file: $file — refusing to sync (fail-closed)." >&2
|
|
return 1
|
|
fi
|
|
MANIFEST_FRAMEWORK=()
|
|
MANIFEST_OPERATOR=()
|
|
local section="" line
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
line="${line#"${line%%[![:space:]]*}"}" # ltrim
|
|
line="${line%"${line##*[![:space:]]}"}" # rtrim
|
|
[[ -z "$line" || "${line:0:1}" == "#" ]] && continue
|
|
case "$line" in
|
|
"[framework]") section=framework; continue ;;
|
|
"[operator]") section=operator; continue ;;
|
|
"["*) echo "manifest: unknown section header: $line" >&2; return 1 ;;
|
|
esac
|
|
if [[ -z "$section" ]]; then
|
|
echo "manifest: entry before any [section] header: $line" >&2
|
|
return 1
|
|
fi
|
|
if [[ "$section" == framework ]]; then
|
|
MANIFEST_FRAMEWORK+=("$line")
|
|
else
|
|
MANIFEST_OPERATOR+=("$line")
|
|
fi
|
|
done < "$file"
|
|
# An empty or comment-only manifest defines NO framework-owned paths. Treating
|
|
# that as valid would make every path resolve operator and an upgrade prune
|
|
# nothing / write nothing — a silent no-op indistinguishable from success.
|
|
# Fail loud instead, mirroring parseManifest()'s throw in manifest.ts (#791 B2).
|
|
if [[ ${#MANIFEST_FRAMEWORK[@]} -eq 0 ]]; then
|
|
echo "manifest: no [framework] entries in $file — refusing to sync (empty or malformed manifest)." >&2
|
|
return 1
|
|
fi
|
|
# An entry like `/` or `./` normalizes to nothing and compiles to a glob that
|
|
# matches no path — so a manifest whose only [framework] entries are degenerate
|
|
# passes the count guard above but leaves the framework matcher empty: every
|
|
# path resolves operator, the exact silent no-op we fail closed against. Require
|
|
# at least one entry with a real (non-slash, non-dot) character. Mirrors
|
|
# parseManifest()'s `isUsableFrameworkGlob` `/[^/.]/` test in manifest.ts (#791 blocker-B).
|
|
local _g _usable=0
|
|
for _g in "${MANIFEST_FRAMEWORK[@]:-}"; do
|
|
if [[ "$(_manifest_norm "$_g")" =~ [^/.] ]]; then _usable=1; break; fi
|
|
done
|
|
if [[ "$_usable" -eq 0 ]]; then
|
|
echo "manifest: no usable [framework] entries in $file (every entry is empty or a bare dot segment) — refusing to sync (malformed manifest)." >&2
|
|
return 1
|
|
fi
|
|
_manifest_compile
|
|
return 0
|
|
}
|
|
|
|
# Fork-free: does $1 (a mosaic-home-relative path) match an operator glob?
|
|
_mo_matches() {
|
|
local path="$1" i n=${#_MO_KIND[@]} re pat
|
|
for (( i = 0; i < n; i++ )); do
|
|
if [[ "${_MO_KIND[i]}" == exact ]]; then
|
|
pat="${_MO_EXACT[i]}"
|
|
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
|
|
else
|
|
re="${_MO_RE[i]}"
|
|
[[ "$path" =~ $re ]] && return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Fork-free: does $1 match a framework glob?
|
|
_mf_matches() {
|
|
local path="$1" i n=${#_MF_KIND[@]} re pat
|
|
for (( i = 0; i < n; i++ )); do
|
|
if [[ "${_MF_KIND[i]}" == exact ]]; then
|
|
pat="${_MF_EXACT[i]}"
|
|
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
|
|
else
|
|
re="${_MF_RE[i]}"
|
|
[[ "$path" =~ $re ]] && return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# The installer hot path — no subshell. rc 0 = framework-owned, rc 1 = operator
|
|
# (deny-wins / fail-safe). Assumes an already-clean POSIX relative path.
|
|
manifest_is_framework() {
|
|
_mo_matches "$1" && return 1
|
|
_mf_matches "$1" && return 0
|
|
return 1
|
|
}
|
|
|
|
# Echo the ownership of a path: framework | operator. Normalizes first, so it is
|
|
# safe for CLI / test callers passing unnormalized input.
|
|
manifest_resolve() {
|
|
local path; path="$(_manifest_norm "$1")"
|
|
if manifest_is_framework "$path"; then echo framework; else echo operator; fi
|
|
}
|
|
|
|
# Echo each shipped framework subtree root (a `dir/**` entry, without the /**).
|
|
manifest_subtree_roots() {
|
|
local r
|
|
for r in "${_MF_ROOTS[@]:-}"; do [[ -n "$r" ]] && printf '%s\n' "$r"; done
|
|
}
|
|
|
|
# CLI dispatch — only when executed directly, never when sourced.
|
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
set -o pipefail
|
|
# Propagate a fail-closed manifest_load (missing/empty/malformed) as a non-zero
|
|
# exit instead of continuing to resolve against empty compiled arrays — that is
|
|
# what lets the parity test assert bash and TS reject the same bad inputs (#791 B2).
|
|
manifest_load "${MANIFEST_FILE:-}" || exit 1
|
|
cmd="${1:-}"
|
|
case "$cmd" in
|
|
resolve) manifest_resolve "${2:?path required}" ;;
|
|
subtree-roots) manifest_subtree_roots ;;
|
|
classify)
|
|
while IFS= read -r p; do
|
|
[[ -z "$p" ]] && continue
|
|
printf '%s\t%s\n' "$(manifest_resolve "$p")" "$p"
|
|
done
|
|
;;
|
|
*)
|
|
echo "usage: manifest.sh {resolve <path>|subtree-roots|classify}" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
fi
|