diff --git a/package-firewall/bash/lib/common.sh b/package-firewall/bash/lib/common.sh index c3ca813..e791531 100644 --- a/package-firewall/bash/lib/common.sh +++ b/package-firewall/bash/lib/common.sh @@ -26,13 +26,13 @@ # VS Code (product.json) — see the "VS Code" section at the bottom of this file: # endor_b64url / endor_b64d — base64url encode (stdin) / decode # endor_json_* — dependency-free depth-1 JSON editors +# vscode_install_paths / vscode_managed_state / vscode_patch / vscode_unpatch # # NOTE for anyone adding to this file: generate.sh inlines it via # grep -v '^# ' lib/common.sh | sed '/^[[:space:]]*$/d' # so every column-0 comment and every blank line is stripped from the generated # scripts. Nothing here may depend on a blank line or a '# '-prefixed line *inside* -# a heredoc — which is why the launchd plist and systemd units below are emitted -# with printf rather than heredocs. +# a heredoc — use printf for any block whose content matters. # Sentinel markers — identical across all config files so re-runs and remove work reliably ENDOR_BLOCK_START="# ===== BEGIN ENDOR PACKAGE FIREWALL (managed — do not edit) =====" @@ -573,17 +573,16 @@ warn_if_xml_key_conflict() { # and rewritten by a third party (VS Code's own updater), and it is JSON, so it # can carry neither an Endor sentinel comment nor an env-var reference. # -# Hence: a key-level merge into the depth-1 "extensionsGallery" object, and a -# top-level JSON marker key holding the byte-exact original for restore. +# Hence: a key-level merge into the depth-1 "extensionsGallery" object, a +# top-level JSON marker key holding the byte-exact original for restore, and a +# watcher to re-apply after updates. # # The editors below are deliberately line-oriented rather than JSON-aware. There # is no jq or python3 guarantee on a stock macOS or a minimal Linux image, and # plutil is not an option: it reorders every top-level key and minifies the file # (and `plutil -lint` does not even validate JSON — it accepts old-style plists). # Shipped product.json is pretty-printed, one entry per line, so a depth-1 line -# range is unambiguous. Anything else makes these editors decline — they return -# non-zero and leave the file untouched rather than guessing — so a caller can -# fall back to a real JSON parser. +# range is unambiguous. Anything else falls through to vscode_patch_via_node. # ══════════════════════════════════════════════════════════════════════════════ # endor_file_has_final_newline @@ -811,3 +810,363 @@ endor_json_validate() { fi return 0 } + +# vscode_install_paths [user_home] +# Prints one product.json path per line for every VS Code install found, stable +# and Insiders. On Windows the equivalent must resolve the console user's +# AppData rather than the SYSTEM account's; here the analogue is . +vscode_install_paths() { + local home="${1:-}" p + local -a candidates=() + + if [[ "$(uname -s)" == "Darwin" ]]; then + candidates+=( + "/Applications/Visual Studio Code.app/Contents/Resources/app/product.json" + "/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/product.json" + ) + if [[ -n "$home" ]]; then + candidates+=( + "$home/Applications/Visual Studio Code.app/Contents/Resources/app/product.json" + "$home/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/product.json" + ) + fi + else + candidates+=( + "/usr/share/code/resources/app/product.json" + "/usr/share/code-insiders/resources/app/product.json" + "/opt/visual-studio-code/resources/app/product.json" + "/opt/visual-studio-code-insiders/resources/app/product.json" + "/usr/lib/code/product.json" + "/snap/code/current/usr/share/code/resources/app/product.json" + ) + fi + + for p in "${candidates[@]}"; do + [[ -f "$p" ]] && printf '%s\n' "$p" + done + return 0 +} + +# vscode_edition_label — human label for logs. +vscode_edition_label() { + local name + name=$(endor_json_top_string "$1" nameLong 2>/dev/null) || name="" + [[ -n "$name" ]] || name="VS Code" + printf '%s' "$name" +} + +# vscode_is_readonly_install +# snap and flatpak mount their payload read-only, so these installs are +# structurally unpatchable. Tested by path prefix rather than by [[ -w ]], +# because under root [[ -w ]] reports true even on a read-only mount. +vscode_is_readonly_install() { + case "$1" in + /snap/*|/var/lib/snapd/*|/var/lib/flatpak/*|/app/*|*/.local/share/flatpak/*) return 0 ;; + esac + return 1 +} + +# vscode_can_write +# Opens the file for append without writing anything: no content change, no mtime +# change, but it fails with EPERM exactly where a real write would. On macOS +# Ventura+ that is the App Management (TCC) check, which root is NOT exempt from. +vscode_can_write() { + ( : >> "$1" ) 2>/dev/null +} + +# vscode_node_bin +# Path to the bundled Electron, usable as node via ELECTRON_RUN_AS_NODE=1 — the +# same trick VS Code's own bin/code shim uses, so it needs no extra dependency. +# The macOS executable name comes from CFBundleExecutable ("Code" on stable, +# different on Insiders); it must never be hardcoded to "Electron". +vscode_node_bin() { + local pj="$1" root exe c + root=$(cd "$(dirname "$pj")/../.." 2>/dev/null && pwd) || return 1 + + if [[ -f "$root/Info.plist" && -x /usr/bin/plutil ]]; then + exe=$(/usr/bin/plutil -extract CFBundleExecutable raw -o - "$root/Info.plist" 2>/dev/null) + if [[ -n "$exe" && -x "$root/MacOS/$exe" ]]; then + printf '%s' "$root/MacOS/$exe"; return 0 + fi + fi + # Linux .deb/.rpm/tarball: the Electron binary sits at the install root next to + # resources/. Deliberately NOT $root/bin/code or /usr/bin/code — those are the + # `code` CLI wrapper, which would interpret -e as a CLI flag rather than as node. + for c in "$root/code" "$root/code-insiders"; do + [[ -x "$c" ]] && { printf '%s' "$c"; return 0; } + done + return 1 +} + +# vscode_marker_field +# Reads one field out of the marker. The marker holds only base64, an integer and +# URL-safe text, so sed is enough — the removal path must not need a JSON parser. +# +# Two shapes have to be handled: the awk writer emits the whole marker on one +# line, while the node writer runs it through JSON.stringify and pretty-prints it +# across many. Reading only the single-line shape silently breaks restore for +# node-written files, so fall back to extracting the marker object as a block. +vscode_marker_field() { + local file="$1" field="$2" v pat + pat="s/.*\"${field}\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" + + v=$(grep -F "\"${ENDOR_JSON_MARKER_KEY}\"" "$file" 2>/dev/null | sed -n "$pat" | head -1) + if [[ -z "$v" ]]; then + v=$(endor_json_extract_top_object "$file" "$ENDOR_JSON_MARKER_KEY" 2>/dev/null \ + | sed -n "$pat" | head -1) + fi + printf '%s' "$v" +} + +# vscode_managed_state +# Prints unmanaged | current | stale. +# unmanaged — no marker; capture the original, then patch +# current — marker present, expected serviceUrl in place, deleted keys gone; +# nothing to do, so no write happens at all +# stale — marker present but the content no longer matches (credential +# rotation, namespace change, edited block, or a VS Code update +# that restored a key). Must be unpatched before re-patching: +# never patch on top of a patch, or the original is lost forever. +vscode_managed_state() { + local file="$1" url="$2" delete_keys="$3" k + + grep -qF "\"${ENDOR_JSON_MARKER_KEY}\"" "$file" 2>/dev/null || { + printf 'unmanaged'; return 0 + } + if ! grep -qF "\"serviceUrl\": \"${url}\"" "$file" 2>/dev/null; then + printf 'stale'; return 0 + fi + for k in $delete_keys; do + if grep -qF "\"${k}\"" "$file" 2>/dev/null; then printf 'stale'; return 0; fi + done + printf 'current' +} + +# vscode_top_indent — the file's own top-level indent unit. +vscode_top_indent() { + local t + t=$(awk 'NR == 2 { s = $0; sub(/[^ \t].*/, "", s); print s; exit }' "$1") + [[ -n "$t" ]] && printf '%s' "$t" || printf '\t' +} + +# vscode_marker_base +# The marker fields that both writers share. Values are base64, integers and +# URL-safe text only, so no JSON escaper is needed here. +vscode_marker_base() { + local ns="$1" fqdn="$2" pj="$3" ver commit + ver=$(endor_json_top_string "$pj" version 2>/dev/null) || ver="unknown" + commit=$(endor_json_top_string "$pj" commit 2>/dev/null) || commit="unknown" + printf '{"schema":1,"namespace":"%s","fqdn":"%s","appVersion":"%s","appCommit":"%s","patchedAt":"%s"' \ + "$ns" "$fqdn" "$ver" "$commit" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" +} + +# vscode_patch_via_node +# Fallback for a product.json that is not line-oriented (repackaged or minified). +# Reformats the whole file, which is acceptable precisely because the layout was +# already non-standard. Records via:"node" so unpatch restores the same way. +vscode_patch_via_node() { + local pj="$1" node_bin="$2" url="$3" delete_keys="$4" marker_base="$5" out="$6" + ENDOR_PJ="$pj" ENDOR_URL="$url" ENDOR_DEL="$delete_keys" ENDOR_OUT="$out" \ + ENDOR_MARKER_KEY="$ENDOR_JSON_MARKER_KEY" ENDOR_MARKER="${marker_base}}" \ + ELECTRON_RUN_AS_NODE=1 "$node_bin" -e ' + const fs = require("fs"); + const d = JSON.parse(fs.readFileSync(process.env.ENDOR_PJ, "utf8")); + const orig = JSON.stringify(d.extensionsGallery || {}); + const g = Object.assign({}, d.extensionsGallery || {}); + g.serviceUrl = process.env.ENDOR_URL; + (process.env.ENDOR_DEL || "").split(/\s+/).filter(Boolean).forEach(k => { delete g[k]; }); + const marker = Object.assign(JSON.parse(process.env.ENDOR_MARKER), { + via: "node", + originalExtensionsGalleryB64: Buffer.from(orig).toString("base64"), + }); + const out = {}; + out[process.env.ENDOR_MARKER_KEY] = marker; + for (const k of Object.keys(d)) out[k] = (k === "extensionsGallery") ? g : d[k]; + fs.writeFileSync(process.env.ENDOR_OUT, JSON.stringify(out, null, "\t")); + ' >/dev/null 2>&1 +} + +# vscode_unpatch_via_node +vscode_unpatch_via_node() { + ENDOR_PJ="$1" ENDOR_OUT="$3" ENDOR_MARKER_KEY="$ENDOR_JSON_MARKER_KEY" \ + ELECTRON_RUN_AS_NODE=1 "$2" -e ' + const fs = require("fs"); + const d = JSON.parse(fs.readFileSync(process.env.ENDOR_PJ, "utf8")); + const m = d[process.env.ENDOR_MARKER_KEY] || {}; + if (m.originalExtensionsGalleryB64) { + d.extensionsGallery = JSON.parse( + Buffer.from(m.originalExtensionsGalleryB64, "base64").toString("utf8")); + } + delete d[process.env.ENDOR_MARKER_KEY]; + fs.writeFileSync(process.env.ENDOR_OUT, JSON.stringify(d, null, "\t")); + ' >/dev/null 2>&1 +} + +# vscode_patch +# Returns 0 patched · 2 already current (nothing written) · 1 failed (caller warns). +# +# is the rendered gallery block, one '"key": value' per line. +# is a space-separated list of keys to drop from extensionsGallery. +vscode_patch() { + local pj="$1" url="$2" set_lines="$3" delete_keys="$4" ns="$5" fqdn="$6" + local label state node_bin tind marker_base origb64 rc + local setf delf blockf tmp tmp2 + + label=$(vscode_edition_label "$pj") + + if vscode_is_readonly_install "$pj"; then + echo "[endor-vscode] SKIP ${label}: read-only install (snap/flatpak) — $pj" >&2 + echo "[endor-vscode] product.json cannot be patched there. Install the .deb/.tar.gz build" >&2 + echo "[endor-vscode] instead, or enforce via the AllowedExtensions policy." >&2 + return 1 + fi + + state=$(vscode_managed_state "$pj" "$url" "$delete_keys") + if [[ "$state" == "current" ]]; then + echo "[endor-vscode] ok ${label}: already current — no change" + return 2 + fi + + if [[ "${DRY_RUN:-0}" == "1" ]]; then + echo "[dry-run] action : ${state} -> PATCH product.json" + [[ "$state" == "stale" ]] && echo "[dry-run] note : stale — original restored first, then re-patched" + echo "[dry-run] file : $pj" + echo "[dry-run] set : $(printf '%s' "$set_lines" | endor_redact_ak)" + echo "[dry-run] remove : ${delete_keys:-}" + echo "[dry-run] marker : ${ENDOR_JSON_MARKER_KEY} (carries the original for restore)" + echo "" + return 0 + fi + + if ! vscode_can_write "$pj"; then + echo "[endor-vscode] ERROR ${label}: cannot write $pj" >&2 + if [[ "$(uname -s)" == "Darwin" ]]; then + echo "[endor-vscode] On macOS Ventura and later, writing inside another developer's" >&2 + echo "[endor-vscode] .app bundle requires the App Management (SystemPolicyAppBundles)" >&2 + echo "[endor-vscode] TCC grant — root is NOT exempt. Grant your MDM agent App" >&2 + echo "[endor-vscode] Management (or Full Disk Access) via a PPPC profile and re-run." >&2 + else + echo "[endor-vscode] Re-run with sufficient privileges (root) for this install path." >&2 + fi + return 1 + fi + + if [[ "$state" == "stale" ]]; then + echo "[endor-vscode] ${label}: managed but out of date — restoring original first" + vscode_unpatch "$pj" || return 1 + fi + + node_bin=$(vscode_node_bin "$pj" 2>/dev/null || true) + tind=$(vscode_top_indent "$pj") + marker_base=$(vscode_marker_base "$ns" "$fqdn" "$pj") + + setf=$(mktemp); delf=$(mktemp); blockf=$(mktemp); tmp=$(mktemp); tmp2=$(mktemp) + printf '%s\n' "$set_lines" > "$setf" + printf '%s\n' $delete_keys > "$delf" + + rc=1 + if endor_json_extract_top_object "$pj" extensionsGallery > "$blockf" 2>/dev/null; then + origb64=$(endor_b64 < "$blockf") + if endor_json_merge_object_keys "$pj" extensionsGallery "$setf" "$delf" > "$tmp" 2>/dev/null; then + endor_json_insert_top_line "$tmp" \ + "${tind}\"${ENDOR_JSON_MARKER_KEY}\": ${marker_base},\"via\":\"awk\",\"originalExtensionsGalleryB64\":\"${origb64}\"}," \ + > "$tmp2" && rc=0 + fi + fi + + if [[ "$rc" -ne 0 ]]; then + if [[ -n "$node_bin" ]] && vscode_patch_via_node \ + "$pj" "$node_bin" "$url" "$delete_keys" "$marker_base" "$tmp2"; then + echo "[endor-vscode] ${label}: product.json is not line-oriented — used the bundled node writer" + rc=0 + else + echo "[endor-vscode] ERROR ${label}: unrecognised product.json layout and no usable node binary" >&2 + echo "[endor-vscode] $pj was left untouched." >&2 + rm -f "$setf" "$delf" "$blockf" "$tmp" "$tmp2" + return 1 + fi + fi + + if ! endor_json_validate "$tmp2" "$node_bin"; then + echo "[endor-vscode] ERROR ${label}: patched product.json failed validation — not installing it" >&2 + echo "[endor-vscode] $pj was left untouched." >&2 + rm -f "$setf" "$delf" "$blockf" "$tmp" "$tmp2" + return 1 + fi + + endor_replace_contents_inplace "$pj" "$tmp2" + rc=$? + rm -f "$setf" "$delf" "$blockf" "$tmp" "$tmp2" + + if [[ "$rc" -ne 0 ]]; then + echo "[endor-vscode] ERROR ${label}: write failed for $pj" >&2 + return 1 + fi + echo "[endor-vscode] ok ${label}: gallery routed through the Endor firewall" + return 0 +} + +# vscode_unpatch +# Restores the captured original extensionsGallery and drops the marker. Returns +# 0 on success (including "nothing to do"), 1 on failure. +vscode_unpatch() { + local pj="$1" label via origb64 blockf tmp tmp2 node_bin rc + + label=$(vscode_edition_label "$pj") + + if ! grep -qF "\"${ENDOR_JSON_MARKER_KEY}\"" "$pj" 2>/dev/null; then + echo "[endor-vscode] skip ${label}: not managed by Endor — $pj" + return 0 + fi + + if [[ "${DRY_RUN:-0}" == "1" ]]; then + echo "[dry-run] action : RESTORE original extensionsGallery, drop marker" + echo "[dry-run] file : $pj" + return 0 + fi + + if ! vscode_can_write "$pj"; then + echo "[endor-vscode] ERROR ${label}: cannot write $pj (App Management/TCC or privileges)" >&2 + return 1 + fi + + via=$(vscode_marker_field "$pj" via) + origb64=$(vscode_marker_field "$pj" originalExtensionsGalleryB64) + node_bin=$(vscode_node_bin "$pj" 2>/dev/null || true) + + if [[ -z "$origb64" ]]; then + echo "[endor-vscode] ERROR ${label}: marker carries no original — refusing to guess" >&2 + echo "[endor-vscode] Reinstall ${label} to restore a pristine product.json." >&2 + return 1 + fi + + tmp=$(mktemp); tmp2=$(mktemp); blockf=$(mktemp) + rc=1 + + if [[ "$via" == "node" ]]; then + if [[ -n "$node_bin" ]] && vscode_unpatch_via_node "$pj" "$node_bin" "$tmp2"; then rc=0; fi + else + printf '%s' "$origb64" | endor_b64d > "$blockf" 2>/dev/null + if [[ -s "$blockf" ]] \ + && endor_json_replace_top_object "$pj" extensionsGallery "$blockf" > "$tmp" 2>/dev/null \ + && endor_json_remove_top_key "$tmp" "$ENDOR_JSON_MARKER_KEY" > "$tmp2"; then rc=0; fi + fi + + if [[ "$rc" -ne 0 ]] || ! endor_json_validate "$tmp2" "$node_bin"; then + echo "[endor-vscode] ERROR ${label}: restore failed validation — $pj left as-is" >&2 + rm -f "$tmp" "$tmp2" "$blockf" + return 1 + fi + + endor_replace_contents_inplace "$pj" "$tmp2" + rc=$? + rm -f "$tmp" "$tmp2" "$blockf" + + if [[ "$rc" -ne 0 ]]; then + echo "[endor-vscode] ERROR ${label}: write failed for $pj" >&2 + return 1 + fi + echo "[endor-vscode] ok ${label}: original gallery restored" + return 0 +} + diff --git a/package-firewall/tests/README.md b/package-firewall/tests/README.md index d07f6f2..3840288 100644 --- a/package-firewall/tests/README.md +++ b/package-firewall/tests/README.md @@ -39,8 +39,9 @@ byte-level fidelity wrong: |---|---| | `run-all.sh` | runner; aggregates tallies, non-zero on any failure | | `fixtures/product.json` | synthetic `product.json` — the target for almost everything | -| `bash/harness.sh` | paths, assertion helpers, the stripped-lib loader | +| `bash/harness.sh` | paths, assertion helpers, the stripped-lib loader, fixture installs | | `bash/json-primitives.sh` | the awk JSON editing primitives in isolation | +| `bash/lib.sh` | `vscode_*` lifecycle: discovery, state machine, both writers, failure modes | ## The fixture, and why not a real install diff --git a/package-firewall/tests/bash/harness.sh b/package-firewall/tests/bash/harness.sh index ea84f15..4ae1bc9 100644 --- a/package-firewall/tests/bash/harness.sh +++ b/package-firewall/tests/bash/harness.sh @@ -86,3 +86,40 @@ source_stripped_lib() { DRY_RUN=0 _ENDOR_WARNED=0 } + +# ─── Fixture installs ───────────────────────────────────────────────────────── +# make_macos_app [node bin] +# Builds a macOS-shaped bundle around a copy of the fixture. The Info.plist and +# the CFBundleExecutable shim exist so vscode_node_bin has something real to +# resolve — the fallback writer's whole point is using VS Code's own Electron as +# node, and hardcoding "Electron" there would be wrong (stable ships "Code"). +make_macos_app() { + local parent="$1" bundle="$2" long="$3" node="${4:-}" d + d="$parent/$bundle/Contents" + mkdir -p "$d/Resources/app" "$d/MacOS" + set_name_long "$FIXTURE" "$d/Resources/app/product.json" "$long" + printf '\n\nCFBundleExecutableCode\n' \ + > "$d/Info.plist" + if [ -n "$node" ]; then + # ELECTRON_RUN_AS_NODE is simply ignored by real node, so a shim is enough. + printf '#!/bin/sh\nexec %s "$@"\n' "$node" > "$d/MacOS/Code" + chmod +x "$d/MacOS/Code" + fi +} + +# set_name_long — copy to with nameLong +# replaced, which is how the two editions are told apart. GNU sed preserves a +# missing final newline and BSD sed adds one, so the source's final-newline state +# is restored explicitly: the fixture deliberately has none, and every +# byte-exactness assertion downstream depends on that surviving. +set_name_long() { + local src="$1" dst="$2" long="$3" tmp="$2.tmp$$" + sed "s#\"nameLong\"[[:space:]]*:[[:space:]]*\"[^\"]*\"#\"nameLong\": \"$long\"#" "$src" > "$tmp" + # A capture of the last byte is empty exactly when that byte is a newline. + if [ -n "$(tail -c 1 "$src")" ] && [ -z "$(tail -c 1 "$tmp")" ]; then + head -c "$(( $(wc -c < "$tmp") - 1 ))" "$tmp" > "$dst" + rm -f "$tmp" + else + mv "$tmp" "$dst" + fi +} diff --git a/package-firewall/tests/bash/lib.sh b/package-firewall/tests/bash/lib.sh new file mode 100755 index 0000000..b947dc5 --- /dev/null +++ b/package-firewall/tests/bash/lib.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# The vscode_* lifecycle in bash/lib/common.sh: discovery, the three-state machine, +# patch, restore, both writer paths, and the failure modes that must be loud. +# +# The lib is sourced in its *inlined* form — `grep -v '^# ' | sed '/^ *$/d'` — which +# is what generate.sh embeds in the scripts an MDM actually pushes. Testing the +# pristine file instead would not prove that nothing in the lib depends on a comment +# or a blank line surviving, and a heredoc body is exactly the shape that silently +# would. +set -uo pipefail +. "$(dirname "${BASH_SOURCE[0]}")/harness.sh" +require_json_tool + +NODE=$(command -v node || true) +T=$(mktemp -d) +trap 'chmod -R u+w "$T" 2>/dev/null; rm -rf "$T"' EXIT + +echo "== 0. strip, source, syntax ==" +chk "pristine lib parses" "$(bash -n "$LIB" 2>&1; echo rc=$?)" "rc=0" +strip_lib > "$T/stripped.sh" +chk "stripped lib parses" "$(bash -n "$T/stripped.sh" 2>&1; echo rc=$?)" "rc=0" +# The one thing stripping can silently break is content inside a heredoc, because +# a stripped comment or blank line there changes data rather than code. +chk "no heredocs in the lib" "$(grep -c "<<'" "$T/stripped.sh")" "0" +source_stripped_lib "$T" +ok "sourced the stripped lib" + +echo "== 1. encoding helpers ==" +chk "endor_b64url substitutes + and / and strips padding" \ + "$(printf '\xfb\xff\xfe' | endor_b64url)" "-__-" +chk "endor_b64 round-trips through endor_b64d" \ + "$(printf 'userattr:jane@Mac' | endor_b64 | endor_b64d)" "userattr:jane@Mac" +chk "a realistic token comes out with no + / or =" \ + "$(printf 'someuser:somesecret+with/chars==' | endor_b64url | tr -dc '+/=' | wc -c | tr -d ' ')" "0" + +echo "== 2. a writable fixture install, stable and Insiders ==" +make_macos_app "$T/Applications" "Visual Studio Code.app" "Visual Studio Code" "$NODE" +make_macos_app "$T/Applications" "Visual Studio Code - Insiders.app" "Visual Studio Code - Insiders" "$NODE" +PJ="$T/Applications/Visual Studio Code.app/Contents/Resources/app/product.json" +PJI="$T/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/product.json" +MIN="$T/Applications/Visual Studio Code.app/Contents/Resources/app/min.json" +cp "$PJ" "$T/pristine.json" + +chk "discovery finds both fixture editions" \ + "$(vscode_install_paths "$T" | grep -c "^$T/")" "2" +chk "edition label read from nameLong" "$(vscode_edition_label "$PJI")" "Visual Studio Code - Insiders" +chk "version read from the fixture" "$(endor_json_top_string "$PJ" version)" "$FIXTURE_VERSION" +chk "commit read from the fixture" "$(endor_json_top_string "$PJ" commit)" "$FIXTURE_COMMIT" +if [ -n "$NODE" ]; then + # Never hardcode "Electron": stable's CFBundleExecutable is "Code" and Insiders + # differs, so the name has to come out of Info.plist. + chk "node bin resolved via CFBundleExecutable" "$(basename "$(vscode_node_bin "$PJ")")" "Code" +else + skip "node bin (no node on PATH)" +fi +# Under root, [[ -w ]] returns true on a read-only mount, so read-only installs have +# to be recognised by path prefix or the script would fail deep inside the write. +chk "read-only detector: /snap path" \ + "$(vscode_is_readonly_install /snap/code/current/x/product.json && echo ro || echo rw)" "ro" +chk "read-only detector: /var/lib/flatpak path" \ + "$(vscode_is_readonly_install /var/lib/flatpak/app/x/product.json && echo ro || echo rw)" "ro" +chk "read-only detector: an ordinary path" \ + "$(vscode_is_readonly_install "$PJ" && echo ro || echo rw)" "rw" +chk "can_write says yes on a writable file" "$(vscode_can_write "$PJ" && echo y || echo n)" "y" +chk "can_write left the content alone" \ + "$(cmp -s "$PJ" "$T/pristine.json" && echo same || echo changed)" "same" + +echo "== 3. state machine and patch ==" +URL='https://factory.endorlabs.com/v1/namespaces/spiderman/firewall/vscode/_ak/dGVzdHVzZXI6dGVzdHNlY3JldA' +SET="\"serviceUrl\": \"$URL\"" +DEL="extensionUrlTemplate" +FQDN=https://factory.endorlabs.com +chk "unmanaged before patching" "$(vscode_managed_state "$PJ" "$URL" "$DEL")" "unmanaged" + +out=$(vscode_patch "$PJ" "$URL" "$SET" "$DEL" spiderman "$FQDN"); rc=$? +chk "patch returns 0" "$rc" "0" +if json_ok "$PJ"; then ok "patched product.json is valid JSON"; else bad "INVALID JSON: $out"; fi +chk "state is now current" "$(vscode_managed_state "$PJ" "$URL" "$DEL")" "current" +chk "marker records via=awk" "$(vscode_marker_field "$PJ" via)" "awk" +chk "marker records appVersion" "$(vscode_marker_field "$PJ" appVersion)" "$FIXTURE_VERSION" +chk "final newline still absent, as the source had none" \ + "$(endor_file_has_final_newline "$PJ" && echo y || echo n)" "n" +chk "serviceUrl set; unpkg fallback gone; controlUrl, resourceUrlTemplate, SKUs kept" \ + "$(jget "$PJ" "d['extensionsGallery']['serviceUrl'].endswith('/_ak/dGVzdHVzZXI6dGVzdHNlY3JldA') and 'extensionUrlTemplate' not in d['extensionsGallery'] and d['extensionsGallery']['controlUrl'].startswith('https://main.vscode-cdn.net') and d['extensionsGallery']['resourceUrlTemplate'].startswith('https://{publisher}') and len(d['extensionsGallery']['accessSKUs'])==$FIXTURE_SKUS")" "true" + +echo "== 4. idempotency: a re-run must not write ==" +cp "$PJ" "$T/before.json" +out=$(vscode_patch "$PJ" "$URL" "$SET" "$DEL" spiderman "$FQDN"); rc=$? +chk "re-patch returns 2 (already current)" "$rc" "2" +if cmp -s "$T/before.json" "$PJ"; then ok "no bytes changed"; else bad "the file changed on re-patch"; fi + +echo "== 5. credential rotation: stale -> restore, then patch ==" +# Never patch on top of a patch. Restoring first is what lets credentials rotate +# indefinitely without the captured original drifting. +URL2="${URL}rotated" +chk "rotation is detected as stale" "$(vscode_managed_state "$PJ" "$URL2" "$DEL")" "stale" +out=$(vscode_patch "$PJ" "$URL2" "\"serviceUrl\": \"$URL2\"" "$DEL" spiderman "$FQDN"); rc=$? +chk "patch after rotation returns 0" "$rc" "0" +chk "current at the new URL" "$(vscode_managed_state "$PJ" "$URL2" "$DEL")" "current" +chk "exactly one marker, no accumulation" "$(grep -cF '_endorPackageFirewall' "$PJ")" "1" +if json_ok "$PJ"; then ok "still valid JSON"; else bad "INVALID after rotation"; fi + +echo "== 6. unpatch restores pristine bytes ==" +out=$(vscode_unpatch "$PJ"); rc=$? +chk "unpatch returns 0" "$rc" "0" +if cmp -s "$T/pristine.json" "$PJ"; then ok "byte-identical to pristine, after two patch cycles" +else bad "differs: $(diff "$T/pristine.json" "$PJ" | head -4)"; fi +out=$(vscode_unpatch "$PJ"); rc=$? +chk "unpatch on an unmanaged file is a no-op success" "$rc" "0" + +echo "== 7. the node writer, on a minified product.json ==" +if [ -n "$NODE" ]; then + python3 -c "import json,sys; json.dump(json.load(open(sys.argv[1])), open(sys.argv[2],'w'))" \ + "$T/pristine.json" "$MIN" + chk "extract fails on minified input (the fallback trigger)" \ + "$(endor_json_extract_top_object "$MIN" extensionsGallery >/dev/null 2>&1; echo $?)" "1" + out=$(vscode_patch "$MIN" "$URL" "$SET" "$DEL" spiderman "$FQDN"); rc=$? + chk "patch succeeds via the node writer" "$rc" "0" + case "$out" in *"bundled node writer"*) ok "reported which writer ran" ;; + *) bad "did not report the fallback: $out" ;; esac + if json_ok "$MIN"; then ok "node-written file is valid JSON"; else bad "node-written file INVALID"; fi + # The node writer pretty-prints, so the marker spans several lines. This is the + # assertion that catches a marker reader which only handles the single-line form. + chk "marker records via=node" "$(vscode_marker_field "$MIN" via)" "node" + chk "the node path applied the same two edits" \ + "$(jget "$MIN" "d['extensionsGallery']['serviceUrl'].endswith('/_ak/dGVzdHVzZXI6dGVzdHNlY3JldA') and 'extensionUrlTemplate' not in d['extensionsGallery'] and len(d['extensionsGallery']['accessSKUs'])==$FIXTURE_SKUS")" "true" + out=$(vscode_unpatch "$MIN"); rc=$? + chk "node unpatch returns 0" "$rc" "0" + chk "node restore reinstated the original gallery, extensionUrlTemplate included" \ + "$(jget "$MIN" "'_endorPackageFirewall' not in d and d['extensionsGallery']['serviceUrl']=='https://marketplace.visualstudio.com/_apis/public/gallery' and d['extensionsGallery']['extensionUrlTemplate'].startswith('https://www.vscode-unpkg.net')")" "true" +else + skip "node writer fallback (no node on PATH)" +fi + +echo "== 8. validation refuses a corrupt candidate ==" +printf 'not json at all' > "$T/bad.json" +chk "rejects non-JSON" "$(endor_json_validate "$T/bad.json" && echo y || echo n)" "n" +printf '{\n\t"a": 1,\n}\n' > "$T/tc.json" +chk "rejects a trailing comma with no node binary" \ + "$(endor_json_validate "$T/tc.json" && echo y || echo n)" "n" +if [ -n "$NODE" ]; then + chk "rejects a trailing comma with node too" \ + "$(endor_json_validate "$T/tc.json" "$(vscode_node_bin "$PJ")" && echo y || echo n)" "n" +else + skip "node validation path (no node on PATH)" +fi + +echo "== 9. EPERM, which on macOS means the App Management grant is missing ==" +cp "$T/pristine.json" "$T/ro.json"; chmod 444 "$T/ro.json" +if [ "$(id -u)" = "0" ]; then + skip "EPERM path (running as root, chmod cannot simulate it)" +else + out=$(vscode_patch "$T/ro.json" "$URL" "$SET" "$DEL" ns https://f 2>&1); rc=$? + chk "patch fails on an unwritable product.json" "$rc" "1" + # A silent no-op here would look exactly like success in an MDM log, which is the + # whole reason this path is asserted on. + case "$out" in *"App Management"*) ok "surfaced the App Management/TCC cause" ;; + *) bad "no TCC guidance in: $out" ;; esac + if cmp -s "$T/pristine.json" "$T/ro.json"; then ok "left the file untouched" + else bad "modified a file it could not write cleanly"; fi +fi + +echo "== 10. a read-only install is refused with something actionable ==" +out=$(vscode_patch /snap/code/current/product.json "$URL" "$SET" "$DEL" ns https://f 2>&1); rc=$? +chk "snap install refused" "$rc" "1" +case "$out" in *"read-only"*) ok "explained the snap/flatpak situation" ;; *) bad "unclear message: $out" ;; esac + +echo "== 11. dry-run writes nothing and does not print the credential ==" +cp "$T/pristine.json" "$T/dry.json" +DRY_RUN=1 +out=$(vscode_patch "$T/dry.json" "$URL" "$SET" "$DEL" spiderman "$FQDN"); rc=$? +DRY_RUN=0 +chk "dry-run returns 0" "$rc" "0" +if cmp -s "$T/pristine.json" "$T/dry.json"; then ok "no bytes written"; else bad "dry-run modified the file"; fi +# The token is a bearer credential in a URL path, and MDM logs are read by more +# people than product.json is. +case "$out" in + *"dGVzdHVzZXI6dGVzdHNlY3JldA"*) bad "dry-run leaked the token" ;; + *"_ak/"*) ok "token redacted in dry-run output" ;; + *) bad "unexpected dry-run output: $out" ;; +esac + +echo "== 12. cross-check against the real installed product.json, if there is one ==" +# The fixture mirrors a shipped product.json but is not one. This checks the round +# trip against whatever VS Code is actually installed here, without asserting +# anything version-specific — so it keeps working across VS Code updates. +REAL="" +for c in "/Applications/Visual Studio Code.app/Contents/Resources/app/product.json" \ + "$HOME/Applications/Visual Studio Code.app/Contents/Resources/app/product.json" \ + "/usr/share/code/resources/app/product.json" \ + "/opt/visual-studio-code/resources/app/product.json"; do + [ -f "$c" ] && { REAL="$c"; break; } +done +if [ -z "$REAL" ]; then + skip "real-install round-trip (no VS Code installation found)" +else + echo " (using $REAL)" + cp "$REAL" "$T/real.json"; cp "$REAL" "$T/real-pristine.json" + out=$(vscode_patch "$T/real.json" "$URL" "$SET" "$DEL" spiderman "$FQDN"); rc=$? + chk "patch of the real file returns 0" "$rc" "0" + if json_ok "$T/real.json"; then ok "real file still valid JSON"; else bad "real file INVALID: $out"; fi + chk "real file: serviceUrl set and the unpkg fallback removed" \ + "$(jget "$T/real.json" "d['extensionsGallery']['serviceUrl'].startswith('https://factory') and 'extensionUrlTemplate' not in d['extensionsGallery']")" "true" + # 3 lines for the two key edits, plus 1 for the inserted marker. + chk "real file: diff is the 2 key edits plus the marker, nothing else" \ + "$(diff "$T/real-pristine.json" "$T/real.json" | grep -c '^[<>]')" "4" + out=$(vscode_unpatch "$T/real.json"); rc=$? + chk "unpatch of the real file returns 0" "$rc" "0" + if cmp -s "$T/real-pristine.json" "$T/real.json"; then ok "real file restored byte-for-byte" + else bad "real file differs: $(diff "$T/real-pristine.json" "$T/real.json" | head -4)"; fi +fi + +summarize