Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions registry-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,20 @@
"plugin": {
"type": "object",
"required": ["version", "description"],
"allOf": [
{
"if": {
"properties": { "status": { "const": "stable" } },
"required": ["status"]
},
"then": {
"required": ["checksum"],
"properties": {
"checksum": { "type": "string", "minLength": 64 }
}
}
}
],
"properties": {
"name": {
"type": "string",
Expand Down Expand Up @@ -247,8 +261,14 @@
},
"description": "Multi-app isolation support configuration"
},
"checksum": {
"type": "string",
"pattern": "^([a-f0-9]{64})?$",
"description": "SHA-256 checksum of the plugin tarball, lowercase 64-char hex, no 'sha256:' prefix. Empty string means not yet computed (pre-existing placeholder, e.g. ollama). This is the FLAT field the CLI installer actually reads (nself-org/cli internal/plugin/registry_parse.go pluginEntry.Checksum, json:\"checksum\") and enforces in internal/plugin/installer_locked.go's verifyChecksum(). A non-empty value is REQUIRED when status is 'stable' (see the plugin-level conditional below) — a stable plugin with no checksum is refused at install time. Must match checksums.sha256 below when both are non-empty (shared/validate-registry.sh CHECK-15)."
},
"checksums": {
"type": "object",
"description": "Legacy/nested checksum record. NOT read by any known CLI code path (verified against nself-org/cli internal/, 2026-09) — kept for tooling that may still reference it and must stay equal to the flat `checksum` field above.",
"properties": {
"sha256": { "type": "string" }
}
Expand Down
10 changes: 9 additions & 1 deletion scripts/build-and-upload-tarballs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,21 @@ for update in "${REGISTRY_UPDATES[@]}"; do
sha256="${rest2%%:::*}"
release_tag="${rest2##*:::}"

# NOTE (P6 checksum-field fix): the CLI installer's registry parser
# (nself-org/cli internal/plugin/registry_parse.go pluginEntry.Checksum,
# `json:"checksum"`) and installer_locked.go's verifyChecksum() read the
# FLAT `checksum` field, never the nested `checksums.sha256` below. Both
# are written here, raw lowercase hex with NO "sha256:" prefix, so they
# stay in sync (shared/validate-registry.sh CHECK-15 fails the registry if
# they ever disagree).
REGISTRY_JSON="$(printf '%s' "$REGISTRY_JSON" | jq \
--arg name "$plugin_name" \
--arg url "$tarball_url" \
--arg sha "sha256:${sha256}" \
--arg sha "$sha256" \
--arg tag "$release_tag" \
--arg sig "" \
'(.plugins[$name].tarballUrl) = $url |
(.plugins[$name].checksum) = $sha |
(.plugins[$name].checksums.sha256) = $sha |
(.plugins[$name].releaseTag) = $tag |
(.plugins[$name].signature) = $sig')"
Expand Down
127 changes: 127 additions & 0 deletions scripts/verify-published-checksums.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# verify-published-checksums.sh
#
# Purpose: spot-check that the checksum registry.json WOULD carry for a
# release tag matches the SHA-256 of the tarball actually served for that
# tag on the GitHub release. This never writes registry.json — it downloads
# each plugin's real tarball asset for TAG and diffs the computed SHA-256
# against the registry's checksum/checksums.sha256 (if either is already
# populated for that tag) and prints the computed value regardless, so it
# can seed a future registry update by hand or via build-and-upload-tarballs.sh.
#
# Why this exists (P6, registry-flat-checksum-field fix): most of this
# registry's 129 plugins have never had ANY checksum field computed against
# their real published v1.0.0 asset — recomputing all of them from the repo
# source tree would NOT verify what a user's `nself plugin install` actually
# downloads, since the published tarball's bytes depend on the exact `tar`
# invocation/timestamps used at release time, not just file contents. This
# script downloads the real released asset and hashes THAT.
#
# Inputs: TAG (e.g. v1.0.0), PLUGIN_NAMES (space-separated plugin slugs).
# Outputs: a table of plugin / computed sha256 / registry checksum /
# registry checksums.sha256 / match — to stdout. Exit 0 always
# (informational spot-check, not a CI gate); pass --strict to
# exit 1 on any mismatch against a NON-EMPTY registry value.
# Constraints: read-only against registry.json and GitHub; requires gh CLI
# authenticated + jq + sha256sum/shasum. Never edits registry.json.
#
# Usage:
# ./scripts/verify-published-checksums.sh v1.0.0 storage cron notify search maintenance
# ./scripts/verify-published-checksums.sh --strict v1.0.0 ollama

set -euo pipefail

REPO="nself-org/plugins"
STRICT=false

if [ "${1:-}" = "--strict" ]; then
STRICT=true
shift
fi

TAG="${1:-}"
shift || true
PLUGIN_NAMES=("$@")

if [ -z "$TAG" ] || [ "${#PLUGIN_NAMES[@]}" -eq 0 ]; then
printf "Usage: %s [--strict] TAG PLUGIN_NAME [PLUGIN_NAME ...]\n" "$0" >&2
exit 1
fi

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REGISTRY_FILE="$(cd "${SCRIPT_DIR}/.." && pwd)/registry.json"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT

log() { printf "[verify-published-checksums] %s\n" "$*"; }
err() { printf "[verify-published-checksums] ERROR: %s\n" "$*" >&2; }

if ! command -v gh >/dev/null 2>&1; then
err "gh CLI not found."
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
err "jq not found."
exit 1
fi

sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | cut -d' ' -f1
else
shasum -a 256 "$1" | cut -d' ' -f1
fi
}

if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
err "Release $TAG not found in $REPO."
exit 1
fi

MISMATCHES=0

printf "%-20s %-10s %-64s %-10s %-10s\n" "plugin" "version" "computed_sha256" "reg.flat" "reg.nested"
printf "%-20s %-10s %-64s %-10s %-10s\n" "------" "-------" "---------------" "--------" "----------"

for plugin_name in "${PLUGIN_NAMES[@]}"; do
version="${TAG#v}"
tarball_name="${plugin_name}-${version}.tar.gz"
asset_path="${WORK_DIR}/${tarball_name}"

if ! gh release download "$TAG" --repo "$REPO" --pattern "$tarball_name" --dir "$WORK_DIR" --clobber >/dev/null 2>&1; then
printf "%-20s %-10s %-64s %-10s %-10s\n" "$plugin_name" "$version" "(asset not found on $TAG)" "-" "-"
continue
fi

computed_sha="$(sha256_file "$asset_path")"

reg_flat="$(jq -r --arg n "$plugin_name" '.plugins[$n].checksum // ""' "$REGISTRY_FILE")"
reg_nested="$(jq -r --arg n "$plugin_name" '.plugins[$n].checksums.sha256 // ""' "$REGISTRY_FILE")"
reg_nested_norm="${reg_nested#sha256:}"

match_flag=""
if [ -n "$reg_flat" ] && [ "$reg_flat" != "$computed_sha" ]; then
match_flag="${match_flag} FLAT-MISMATCH"
MISMATCHES=$((MISMATCHES + 1))
fi
if [ -n "$reg_nested_norm" ] && [ "$reg_nested_norm" != "$computed_sha" ]; then
match_flag="${match_flag} NESTED-MISMATCH"
MISMATCHES=$((MISMATCHES + 1))
fi
[ -z "$match_flag" ] && match_flag="ok-or-unset"

printf "%-20s %-10s %-64s %-10s %-10s\n" \
"$plugin_name" "$version" "$computed_sha" "${reg_flat:--}" "${reg_nested:--}"
log " ${plugin_name}: ${match_flag}"
done

if [ "$MISMATCHES" -gt 0 ]; then
err "$MISMATCHES mismatch(es) between a computed checksum and a NON-EMPTY registry value."
if [ "$STRICT" = "true" ]; then
exit 1
fi
else
log "No mismatches against any non-empty registry checksum field."
fi

log "Done. Registry.json was NOT modified by this script."
91 changes: 91 additions & 0 deletions shared/validate-registry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,15 @@ _in_list() {
return 1
}

_is_valid_sha256() {
local v="$1"
[ "${#v}" -eq 64 ] || return 1
case "$v" in
*[!0-9a-f]*) return 1 ;;
esac
return 0
}

# ---------------------------------------------------------------------------
# Iterators — produce uniform stream of fields separated by ASCII Unit
# Separator (0x1f). Tabs collapse consecutive empties under bash `read`
Expand Down Expand Up @@ -855,6 +864,88 @@ else
warn "CHECK-14" "registry" "python3 not available — skipping duplicate table-prefix check"
fi

# =============================================================================
# CHECK 15 — checksum integrity (added P6, registry-flat-checksum-field fix)
#
# nself-org/cli's installer only ever reads the FLAT `checksum` field
# (internal/plugin/registry_parse.go pluginEntry.Checksum, json:"checksum")
# and its installer_locked.go verifyChecksum() hard-refuses installing a
# status=stable plugin with no checksum. The nested `checksums.sha256` field
# that build-and-upload-tarballs.sh also writes is NOT consumed by any known
# CLI code path (confirmed via `git grep checksums origin/main -- internal`
# on nself-org/cli returning no registry-entry consumer). So:
# - a stable plugin missing a non-empty flat checksum is an ERROR
# - a non-empty checksum must be 64 lowercase hex chars
# - when both checksum and checksums.sha256 are non-empty they must agree
# (a "sha256:" prefix on the legacy nested field is tolerated/stripped
# before comparing, since some historical writes included it)
# =============================================================================
section "CHECK 15 — checksum integrity (flat field required for status=stable)"
CHECKSUM_VIOLATIONS=0
CHECKSUM_USV="${TMPDIR_VAL}/checksum.usv"
case "$REGISTRY_FORMAT" in
aggregated)
"$JQ" -r '
.plugins
| to_entries[]
| [
.key,
(.value.status // ""),
(.value.checksum // ""),
(.value.checksums.sha256 // "")
]
| join("'"$US"'")
' "$REGISTRY_FILE" > "$CHECKSUM_USV"
;;
array)
"$JQ" -r '
.[]
| [
(.name // ""),
(.status // ""),
(.checksum // ""),
(.checksums.sha256 // "")
]
| join("'"$US"'")
' "$REGISTRY_FILE" > "$CHECKSUM_USV"
;;
array-wrapped)
"$JQ" -r '
.plugins[]
| [
(.name // ""),
(.status // ""),
(.checksum // ""),
(.checksums.sha256 // "")
]
| join("'"$US"'")
' "$REGISTRY_FILE" > "$CHECKSUM_USV"
;;
esac
while IFS="$US" read -r name status checksum checksums_sha; do
[ -z "$name" ] && continue
if [ "$status" = "stable" ] && [ -z "$checksum" ]; then
err "CHECK-15" "$name" "status=stable but flat 'checksum' field is missing/empty — cli's verifyChecksum() hard-refuses installing a stable plugin with no checksum (it reads .checksum, NOT .checksums.sha256)"
CHECKSUM_VIOLATIONS=$((CHECKSUM_VIOLATIONS + 1))
fi
if [ -n "$checksum" ] && ! _is_valid_sha256 "$checksum"; then
err "CHECK-15" "$name" "checksum '$checksum' is not a lowercase 64-char hex sha256"
CHECKSUM_VIOLATIONS=$((CHECKSUM_VIOLATIONS + 1))
fi
if [ -n "$checksum" ] && [ -n "$checksums_sha" ]; then
checksums_sha_norm="${checksums_sha#sha256:}"
checksum_lc="$(printf '%s' "$checksum" | tr 'A-F' 'a-f')"
checksums_sha_lc="$(printf '%s' "$checksums_sha_norm" | tr 'A-F' 'a-f')"
if [ "$checksum_lc" != "$checksums_sha_lc" ]; then
err "CHECK-15" "$name" "checksum ('$checksum') and checksums.sha256 ('$checksums_sha') disagree"
CHECKSUM_VIOLATIONS=$((CHECKSUM_VIOLATIONS + 1))
fi
fi
done < "$CHECKSUM_USV"
if [ "$CHECKSUM_VIOLATIONS" -eq 0 ]; then
ok "CHECK-15" "checksum fields present/consistent for all status=stable plugins"
fi

# =============================================================================
# Optional: --strict mode runs the legacy per-plugin field validator
# (kept for backward compatibility with the original validator behavior)
Expand Down
Loading