From 58823024e9788856a49e75ee15ed4b20bb1ff904 Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Mon, 15 Jun 2026 14:09:17 +0900 Subject: [PATCH] fix: address v1.3.0 verification follow-ups (V13-1/2/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V13-3 (a11y): give the progress bar an accessible name, make the run-log scroll region keyboard focusable (role=log, tabindex), and replace the input-type selector's panel-less Tabs with an aria-pressed toggle group so it no longer emits an aria-controls pointing at a tabpanel that is never rendered (axe aria-valid-attr-value). V13-2 (license): extract the SPDX alias map into a shared spdx-normalize.jq used by both generate-notice.sh and normalize-sbom.sh, and normalize the bom.json component licenses so the web UI license filter, distribution card, and dependency tree group identically to the NOTICE. Free-text aliases such as "Expat license" are promoted to a proper SPDX license id; a valid-but-wrong upstream id (cdxgen mislabeling a package 0BSD) and unmappable strings are left untouched rather than guessed. Output stays byte-stable and idempotent. V13-1 (docs): the 5 flagged anchors resolve correctly on the rendered site — the site's pymdownx slugify keeps the double hyphen left by a removed em-dash, which a hyphen-collapsing checker disagrees with. Add validation.anchors=warn so `mkdocs build --strict` validates anchors against the site's own slugify, catching real breaks without that false positive. Regression: extend tests/test-postprocess.sh and the license-aliases fixture to cover bom.json license promotion and the preserve-don't-guess cases. --- docker/lib/generate-notice.sh | 26 +-------- docker/lib/normalize-sbom.sh | 29 +++++++++- docker/lib/spdx-normalize.jq | 30 ++++++++++ .../src/components/InputTypeSelector.tsx | 56 ++++++++++++------- .../frontend/src/components/ProgressLog.tsx | 8 ++- mkdocs.yml | 11 ++++ tests/fixtures/license-aliases.json | 8 +++ tests/test-postprocess.sh | 20 +++++++ 8 files changed, 144 insertions(+), 44 deletions(-) create mode 100644 docker/lib/spdx-normalize.jq diff --git a/docker/lib/generate-notice.sh b/docker/lib/generate-notice.sh index 15223e0..166e793 100755 --- a/docker/lib/generate-notice.sh +++ b/docker/lib/generate-notice.sh @@ -32,29 +32,9 @@ TXT="${OUT_PREFIX}_NOTICE.txt" HTML="${OUT_PREFIX}_NOTICE.html" # Normalize a license id/name/expression to an SPDX id for common aliases. -# A genuine compound expression ("X OR Y") is left untouched; LGPL/GPL "or later" -# is matched before the compound check so it is not mistaken for a compound. -NORMALIZE_DEF=' -def normalize($s): - ($s | ascii_downcase | gsub("[ ,._/-]+"; " ") | sub("^ +";"") | sub(" +$";"")) as $n | - if ($n | test("(lesser|library) general public.*2 1.*later")) then "LGPL-2.1-or-later" - elif ($n | test("(lesser|library) general public.*2 1")) then "LGPL-2.1-only" - elif ($n | test("(lesser|library) general public.*3.*later")) then "LGPL-3.0-or-later" - elif ($n | test("(lesser|library) general public.*3")) then "LGPL-3.0-only" - elif ($n | test("general public.*2.*later")) then "GPL-2.0-or-later" - elif ($n | test("general public.*2 0|general public.*v2")) then "GPL-2.0-only" - elif ($n | test("general public.*3.*later")) then "GPL-3.0-or-later" - elif ($n | test("general public.*3")) then "GPL-3.0-only" - elif ($n | test(" or | and ")) then $s - elif ($n | test("apache.*2")) then "Apache-2.0" - elif ($n | test("mit license") or $n == "mit" or ($n | test("expat"))) then "MIT" - elif ($n | test("eclipse distribution") or ($n|test("^edl "))) then "BSD-3-Clause" - elif ($n | test("eclipse public.*2")) then "EPL-2.0" - elif ($n | test("eclipse public.*1")) then "EPL-1.0" - elif ($n | test("bsd.*3")) then "BSD-3-Clause" - elif ($n | test("bsd.*2")) then "BSD-2-Clause" - else $s end; -' +# The normalize() definition is shared with normalize-sbom.sh via spdx-normalize.jq +# so the NOTICE and the bom.json the web UI reads group licenses identically. +NORMALIZE_DEF="$(cat "$SCRIPT_DIR/spdx-normalize.jq")" # Build { license, components:[ {comp, copyright} ] } grouped by normalized id. LICENSE_MAP=$(jq -r "$NORMALIZE_DEF"' diff --git a/docker/lib/normalize-sbom.sh b/docker/lib/normalize-sbom.sh index 54dce16..571373e 100755 --- a/docker/lib/normalize-sbom.sh +++ b/docker/lib/normalize-sbom.sh @@ -13,6 +13,8 @@ set -e SBOM="$1" MODE="${2:-}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + if [ -z "$SBOM" ] || [ ! -f "$SBOM" ]; then echo "[normalize] SBOM file not found: $SBOM" >&2 exit 1 @@ -43,6 +45,29 @@ SORT_FILTER='(.components) |= (if type=="array" then sort_by(.purl // ((.name // # component name/version are retained); valid namespaced swift purls are untouched. PURL_FIX='(.metadata.component) |= (if (has("purl") and (.purl|test("^pkg:swift/[^/]+@"))) then with_entries(select(.key!="purl")) else . end) | (.components) |= (if type=="array" then map(if (has("purl") and (.purl|test("^pkg:swift/[^/]+@"))) then with_entries(select(.key!="purl")) else . end) else . end)' +# Always: normalize component license aliases to SPDX ids. cdxgen records some +# licenses as non-SPDX free text ("Expat license", "Apache License 2.0"); the v1.3 +# web UI surfaces (license filter, distribution card, dependency tree) read these +# raw, so the same license splits into several buckets. normalize() (shared with +# generate-notice.sh) maps only recognized aliases — a non-alias string and a +# valid-but-wrong upstream id (e.g. cdxgen mislabeling a package 0BSD) are left +# as-is rather than guessed. Free text that maps to a single SPDX id is promoted +# from .license.name / .expression to a proper .license.id; the source url is kept. +NORMALIZE_DEF="$(cat "$SCRIPT_DIR/spdx-normalize.jq")" +LICENSE_FIX='(.components) |= (if type=="array" then map( + if (.licenses|type)=="array" then + .licenses |= map( + if (has("expression") and (.expression|type)=="string") then + (normalize(.expression)) as $n | + (if $n != .expression then {license:{id:$n}} else . end) + elif ((.license|type)=="object" and (.license.id == null) and ((.license.name // null) != null)) then + .license |= (normalize(.name) as $n | + (if $n != .name then ({id:$n} + (if .url then {url:.url} else {} end)) else . end)) + else . end + ) + else . end +) else . end)' + if [ "$MODE" = "--stable" ]; then # Reproducible build: pin every timestamp (metadata + annotations + tools), # drop random serial number. cdxgen also embeds a human-readable build date @@ -51,8 +76,10 @@ if [ "$MODE" = "--stable" ]; then # resolve python deps (cdxgen-venv-XXXXXX) into component evidence values, so # the same input yields a different byte stream each run; pin that suffix too. jq -S " + ${NORMALIZE_DEF} ${NULL_FIX} | ${PURL_FIX} + | ${LICENSE_FIX} | ${SORT_FILTER} | walk(if type==\"object\" and has(\"timestamp\") then .timestamp = \"1970-01-01T00:00:00Z\" else . end) | walk(if type==\"string\" then gsub(\"cdxgen-venv-[A-Za-z0-9]+\"; \"cdxgen-venv\") else . end) @@ -64,7 +91,7 @@ if [ "$MODE" = "--stable" ]; then | del(.serialNumber) " "$SBOM" > "$TMP" else - jq -S "${NULL_FIX} | ${PURL_FIX} | ${SORT_FILTER}" "$SBOM" > "$TMP" + jq -S "${NORMALIZE_DEF} ${NULL_FIX} | ${PURL_FIX} | ${LICENSE_FIX} | ${SORT_FILTER}" "$SBOM" > "$TMP" fi mv "$TMP" "$SBOM" diff --git a/docker/lib/spdx-normalize.jq b/docker/lib/spdx-normalize.jq new file mode 100644 index 0000000..d731964 --- /dev/null +++ b/docker/lib/spdx-normalize.jq @@ -0,0 +1,30 @@ +# spdx-normalize.jq — map a license id/name/expression to an SPDX id for common +# aliases. Single source of truth shared by generate-notice.sh (NOTICE grouping) +# and normalize-sbom.sh (bom.json component licenses), so the attribution NOTICE +# and the web UI surfaces (license filter, distribution card, dependency tree) +# agree on the same canonical id. +# +# A genuine compound expression ("X OR Y") is left untouched; LGPL/GPL "or later" +# is matched before the compound check so it is not mistaken for a compound. An +# unrecognized string is returned unchanged, so a valid-but-wrong SPDX id from the +# upstream tool (e.g. cdxgen FETCH_LICENSE marking a package 0BSD) is preserved +# rather than silently rewritten to a guess. +def normalize($s): + ($s | ascii_downcase | gsub("[ ,._/-]+"; " ") | sub("^ +";"") | sub(" +$";"")) as $n | + if ($n | test("(lesser|library) general public.*2 1.*later")) then "LGPL-2.1-or-later" + elif ($n | test("(lesser|library) general public.*2 1")) then "LGPL-2.1-only" + elif ($n | test("(lesser|library) general public.*3.*later")) then "LGPL-3.0-or-later" + elif ($n | test("(lesser|library) general public.*3")) then "LGPL-3.0-only" + elif ($n | test("general public.*2.*later")) then "GPL-2.0-or-later" + elif ($n | test("general public.*2 0|general public.*v2")) then "GPL-2.0-only" + elif ($n | test("general public.*3.*later")) then "GPL-3.0-or-later" + elif ($n | test("general public.*3")) then "GPL-3.0-only" + elif ($n | test(" or | and ")) then $s + elif ($n | test("apache.*2")) then "Apache-2.0" + elif ($n | test("mit license") or $n == "mit" or ($n | test("expat"))) then "MIT" + elif ($n | test("eclipse distribution") or ($n|test("^edl "))) then "BSD-3-Clause" + elif ($n | test("eclipse public.*2")) then "EPL-2.0" + elif ($n | test("eclipse public.*1")) then "EPL-1.0" + elif ($n | test("bsd.*3")) then "BSD-3-Clause" + elif ($n | test("bsd.*2")) then "BSD-2-Clause" + else $s end; diff --git a/docker/web/frontend/src/components/InputTypeSelector.tsx b/docker/web/frontend/src/components/InputTypeSelector.tsx index 2eeca44..9b90512 100644 --- a/docker/web/frontend/src/components/InputTypeSelector.tsx +++ b/docker/web/frontend/src/components/InputTypeSelector.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { SOURCE_TYPES, type SourceType } from "@/lib/api"; +import { cn } from "@/lib/utils"; interface Props { value: SourceType; @@ -20,6 +20,11 @@ const LABEL_KEY: Record = { "docker-image": "source.dockerImage", }; +// A segmented selector. Previously this used the Tabs primitive, but Tabs without +// TabsContent panels emits an aria-controls pointing at a tabpanel that is never +// rendered, which fails axe aria-valid-attr-value (V13-3/F3). These are mutually +// exclusive options with no associated panel, so a labelled group of aria-pressed +// toggle buttons is the accessible primitive. export function InputTypeSelector({ value, onChange, @@ -28,23 +33,36 @@ export function InputTypeSelector({ }: Props) { const { t } = useTranslation(); return ( - onChange(v as SourceType)}> - - {SOURCE_TYPES.map((s) => { - const fwLocked = s === "firmware-upload" && firmwareDisabled; - return ( - - {t(LABEL_KEY[s])} - - ); - })} - - +
+ {SOURCE_TYPES.map((s) => { + const fwLocked = s === "firmware-upload" && firmwareDisabled; + const active = value === s; + return ( + + ); + })} +
); } diff --git a/docker/web/frontend/src/components/ProgressLog.tsx b/docker/web/frontend/src/components/ProgressLog.tsx index f5e3764..552a55c 100644 --- a/docker/web/frontend/src/components/ProgressLog.tsx +++ b/docker/web/frontend/src/components/ProgressLog.tsx @@ -37,12 +37,18 @@ export function ProgressLog({ logs, status }: Props) { -
+
{logs.length === 0 ? (

{t("progress.waiting")}

) : ( diff --git a/mkdocs.yml b/mkdocs.yml index 8378ec0..6dc1ca3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -125,6 +125,17 @@ exclude_docs: | internal/ korean-style-guide.md +# Validate internal links and heading anchors against the rendered site. Anchors +# are checked with this config's own pymdownx slugify, so the result matches what +# the site actually serves (a generic GitHub-style checker disagrees on slugs that +# contain a removed symbol such as an em-dash). With `mkdocs build --strict` in CI, +# a link to a missing page or a stale heading anchor fails the build. +validation: + omitted_files: warn + absolute_links: warn + unrecognized_links: warn + anchors: warn + nav: - Home: index.md - Get started: diff --git a/tests/fixtures/license-aliases.json b/tests/fixtures/license-aliases.json index 9882ce4..05aa42d 100644 --- a/tests/fixtures/license-aliases.json +++ b/tests/fixtures/license-aliases.json @@ -25,6 +25,14 @@ { "type": "library", "name": "six", "version": "1.16.0", "licenses": [ { "license": { "id": "MIT" } } ] + }, + { + "type": "library", "name": "flask", "version": "3.0.0", + "licenses": [ { "license": { "id": "0BSD", "url": "https://opensource.org/licenses/0BSD" } } ] + }, + { + "type": "library", "name": "python-dateutil", "version": "2.9.0", + "licenses": [ { "expression": "Dual License" } ] } ] } diff --git a/tests/test-postprocess.sh b/tests/test-postprocess.sh index 0b9beb9..5fa694b 100755 --- a/tests/test-postprocess.sh +++ b/tests/test-postprocess.sh @@ -88,6 +88,26 @@ else fail "generate-notice.sh did not produce $NOTICE" fi +echo "== V13-2: normalize-sbom.sh maps bom.json license aliases to SPDX ids ==" +cp "$FIX/license-aliases.json" "$WORK/c.json" +bash "$LIB/normalize-sbom.sh" "$WORK/c.json" >/dev/null 2>&1 +# Free-text alias in .expression is promoted to a proper .license.id. +mccabe_id=$(jq -r '.components[] | select(.name=="mccabe") | .licenses[0].license.id // "ABSENT"' "$WORK/c.json") +[ "$mccabe_id" = "MIT" ] && pass "Expat expression promoted to license id MIT" || fail "mccabe license id='$mccabe_id', expected MIT" +# Free-text alias in .license.name is promoted as well. +cov_id=$(jq -r '.components[] | select(.name=="coverage") | .licenses[0].license.id // "ABSENT"' "$WORK/c.json") +[ "$cov_id" = "Apache-2.0" ] && pass "free-text license name promoted to id Apache-2.0" || fail "coverage license id='$cov_id', expected Apache-2.0" +# A valid-but-wrong upstream id (cdxgen 0BSD mislabel) is preserved, not guessed. +flask_id=$(jq -r '.components[] | select(.name=="flask") | .licenses[0].license.id // "ABSENT"' "$WORK/c.json") +flask_url=$(jq -r '.components[] | select(.name=="flask") | .licenses[0].license.url // "ABSENT"' "$WORK/c.json") +[ "$flask_id" = "0BSD" ] && pass "valid-but-wrong upstream id (0BSD) preserved, not rewritten" || fail "flask license id='$flask_id', expected 0BSD" +[ "$flask_url" = "https://opensource.org/licenses/0BSD" ] && pass "license url preserved" || fail "flask license url='$flask_url'" +# A non-mappable free-text string and a genuine compound expression are untouched. +date_expr=$(jq -r '.components[] | select(.name=="python-dateutil") | .licenses[0].expression // "ABSENT"' "$WORK/c.json") +[ "$date_expr" = "Dual License" ] && pass "unmappable free text (Dual License) left untouched" || fail "dateutil expression='$date_expr', expected Dual License" +pkg_expr=$(jq -r '.components[] | select(.name=="packaging") | .licenses[0].expression // "ABSENT"' "$WORK/c.json") +[ "$pkg_expr" = "Apache-2.0 OR BSD-2-Clause" ] && pass "compound expression left untouched" || fail "packaging expression='$pkg_expr'" + echo "" echo "Results: ${PASS} passed, ${FAIL} failed" [ "$FAIL" -eq 0 ]