From 2f854dab5c56bf8de16bacab22f1ca2ae52ef6fb Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:33:26 +0800 Subject: [PATCH 01/16] ci: validate landscape.yml on pull requests Add a pull_request workflow and a small Python validator for landscape/landscape.yml. The Pages deployment only runs on push to main and copies the YAML without parsing it, so malformed data currently passes review and only fails when the browser loads the map. The validator checks structure, required item fields, the project enum, https URLs, and duplicate names against docs/data-schemas.md. It passes on the current data and can also be run locally. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- .github/workflows/validate-landscape.yml | 26 +++++ scripts/validate_landscape.py | 115 +++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 .github/workflows/validate-landscape.yml create mode 100644 scripts/validate_landscape.py diff --git a/.github/workflows/validate-landscape.yml b/.github/workflows/validate-landscape.yml new file mode 100644 index 0000000..5679b08 --- /dev/null +++ b/.github/workflows/validate-landscape.yml @@ -0,0 +1,26 @@ +name: Validate Landscape + +on: + pull_request: + paths: + - "landscape/landscape.yml" + - "scripts/validate_landscape.py" + - ".github/workflows/validate-landscape.yml" + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install PyYAML + run: pip install pyyaml + - name: Validate landscape.yml + run: python scripts/validate_landscape.py landscape/landscape.yml diff --git a/scripts/validate_landscape.py b/scripts/validate_landscape.py new file mode 100644 index 0000000..e0f4f2b --- /dev/null +++ b/scripts/validate_landscape.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Validate landscape/landscape.yml against the schema in docs/data-schemas.md. + +Checks that the file parses, follows the category -> subcategory -> item +structure, has the required item fields, uses a valid `project` value, keeps +URLs on https, and has no duplicate entry names. Prints every problem it finds +and exits non-zero if there are any, so it can gate pull requests that change +the landscape data. + +Usage: python scripts/validate_landscape.py [path/to/landscape.yml] +""" +import sys + +try: + import yaml +except ImportError: + print("error: PyYAML is required (pip install pyyaml)", file=sys.stderr) + sys.exit(2) + +PROJECT_VALUES = {"graduated", "incubating", "member", "external"} +REQUIRED_ITEM_FIELDS = ("name", "homepage_url", "description", "project") + + +def is_nonempty_str(value): + return isinstance(value, str) and value.strip() != "" + + +def validate(path): + errors = [] + try: + with open(path, "r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) + except yaml.YAMLError as exc: + return [f"{path}: YAML parse error: {exc}"] + except OSError as exc: + return [f"{path}: cannot read file: {exc}"] + + if not isinstance(data, dict) or "landscape" not in data: + return [f"{path}: top-level 'landscape' key is missing"] + categories = data["landscape"] + if not isinstance(categories, list): + return [f"{path}: 'landscape' must be a list of categories"] + + seen_names = {} # normalized name -> first location where it appeared + for cat_index, category in enumerate(categories): + if not isinstance(category, dict) or not is_nonempty_str(category.get("category")): + errors.append(f"landscape[{cat_index}]: missing 'category' name") + continue + cat_name = category["category"] + subcategories = category.get("subcategories") + if not isinstance(subcategories, list): + errors.append(f"category '{cat_name}': 'subcategories' must be a list") + continue + for sub_index, subcategory in enumerate(subcategories): + if not isinstance(subcategory, dict) or not is_nonempty_str(subcategory.get("subcategory")): + errors.append(f"category '{cat_name}': subcategory[{sub_index}] missing 'subcategory' name") + continue + sub_name = subcategory["subcategory"] + items = subcategory.get("items") + if not isinstance(items, list): + errors.append(f"'{cat_name}' / '{sub_name}': 'items' must be a list") + continue + for item_index, item in enumerate(items): + location = f"'{cat_name}' / '{sub_name}' / item[{item_index}]" + if not isinstance(item, dict): + errors.append(f"{location}: item must be a mapping") + continue + name = item.get("name") + if is_nonempty_str(name): + location = f"'{cat_name}' / '{sub_name}' / '{name}'" + + for field in REQUIRED_ITEM_FIELDS: + if not is_nonempty_str(item.get(field)): + errors.append(f"{location}: missing or empty required field '{field}'") + + project = item.get("project") + if is_nonempty_str(project) and project not in PROJECT_VALUES: + errors.append( + f"{location}: project '{project}' is not one of {sorted(PROJECT_VALUES)}" + ) + + homepage = item.get("homepage_url") + if is_nonempty_str(homepage) and not homepage.startswith("https://"): + errors.append(f"{location}: homepage_url must start with https:// (got '{homepage}')") + + if "repo_url" in item: + repo = item.get("repo_url") + if not is_nonempty_str(repo): + errors.append(f"{location}: repo_url is present but empty") + elif not repo.startswith("https://"): + errors.append(f"{location}: repo_url must start with https:// (got '{repo}')") + + if is_nonempty_str(name): + key = name.strip().lower() + if key in seen_names: + errors.append(f"{location}: duplicate entry name (also at {seen_names[key]})") + else: + seen_names[key] = location + + return errors + + +def main(): + path = sys.argv[1] if len(sys.argv) > 1 else "landscape/landscape.yml" + errors = validate(path) + if errors: + print(f"landscape validation failed with {len(errors)} problem(s):", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + sys.exit(1) + print(f"{path}: OK") + + +if __name__ == "__main__": + main() From 8df77010785ba3d61584e634762052baed72cc0d Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:13:51 +0800 Subject: [PATCH 02/16] ci: validate landscape.yml with the site's own parser (js-yaml) Replace the earlier PyYAML validator with one built on js-yaml 4.1.0, the same library and version the site loads in the browser. PyYAML silently accepts things js-yaml rejects, most importantly duplicate mapping keys, so a Python-based check could pass data that then breaks the rendered map. Using the site's parser keeps CI and the browser in agreement. The validator also checks the category/subcategory/item structure, required fields, the project enum, https URLs, unexpected fields, and duplicate category, subcategory, or entry names, and rejects an empty landscape. A node:test suite covers the happy path and each failure mode and runs in CI, so the validator cannot be weakened without a test failing. The workflow runs on every pull request (no path filter, so it is safe as a required check) and on push to main, with a timeout, concurrency, and least-privilege permissions. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- .github/workflows/validate-landscape.yml | 37 +++-- scripts/package-lock.json | 33 ++++ scripts/package.json | 14 ++ scripts/validate-landscape.mjs | 191 +++++++++++++++++++++++ scripts/validate-landscape.test.mjs | 92 +++++++++++ scripts/validate_landscape.py | 115 -------------- 6 files changed, 357 insertions(+), 125 deletions(-) create mode 100644 scripts/package-lock.json create mode 100644 scripts/package.json create mode 100644 scripts/validate-landscape.mjs create mode 100644 scripts/validate-landscape.test.mjs delete mode 100644 scripts/validate_landscape.py diff --git a/.github/workflows/validate-landscape.yml b/.github/workflows/validate-landscape.yml index 5679b08..0409cae 100644 --- a/.github/workflows/validate-landscape.yml +++ b/.github/workflows/validate-landscape.yml @@ -1,26 +1,43 @@ name: Validate Landscape +# Runs on every pull request (no path filter) so it stays reliable as a required +# check, and on push to main as a second line of defence for direct pushes. on: pull_request: - paths: - - "landscape/landscape.yml" - - "scripts/validate_landscape.py" - - ".github/workflows/validate-landscape.yml" + push: + branches: ["main"] permissions: contents: read +concurrency: + group: validate-landscape-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: validate: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Check out uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 with: - python-version: "3.12" - - name: Install PyYAML - run: pip install pyyaml + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: scripts/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: scripts + + - name: Run validator tests + run: node --test + working-directory: scripts + - name: Validate landscape.yml - run: python scripts/validate_landscape.py landscape/landscape.yml + run: node scripts/validate-landscape.mjs landscape/landscape.yml diff --git a/scripts/package-lock.json b/scripts/package-lock.json new file mode 100644 index 0000000..977db7e --- /dev/null +++ b/scripts/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "aaif-landscape-tools", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aaif-landscape-tools", + "version": "1.0.0", + "dependencies": { + "js-yaml": "4.1.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + } + } +} diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..74babd0 --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,14 @@ +{ + "name": "aaif-landscape-tools", + "version": "1.0.0", + "private": true, + "description": "CI tooling for validating the AAIF landscape data with the same parser the site uses.", + "type": "module", + "scripts": { + "validate": "node validate-landscape.mjs ../landscape/landscape.yml", + "test": "node --test" + }, + "dependencies": { + "js-yaml": "4.1.0" + } +} diff --git a/scripts/validate-landscape.mjs b/scripts/validate-landscape.mjs new file mode 100644 index 0000000..4787fc2 --- /dev/null +++ b/scripts/validate-landscape.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node +/** + * Validate landscape/landscape.yml against the schema in docs/data-schemas.md. + * + * Parsing uses js-yaml with default options, the same library and settings the + * site itself uses (landscape/static via the js-yaml CDN). That keeps CI and the + * browser in agreement: anything the site would reject at load time, such as a + * duplicate mapping key, fails here too rather than passing review and breaking + * the rendered map. + * + * On top of parsing it checks the category -> subcategory -> item structure, the + * required item fields, the `project` enum, https URLs, unexpected fields, and + * duplicate category, subcategory, or entry names. It prints every problem and + * exits non-zero if there are any. + * + * Usage: node validate-landscape.mjs [path/to/landscape.yml] + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import yaml from 'js-yaml'; + +const PROJECT_VALUES = new Set(['graduated', 'incubating', 'member', 'external']); +const REQUIRED_FIELDS = ['name', 'homepage_url', 'description', 'project']; +const ALLOWED_FIELDS = new Set([ + 'name', + 'logo', + 'homepage_url', + 'repo_url', + 'description', + 'project', +]); + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim() !== ''; +} + +function urlProblem(field, value) { + if (/\s/.test(value)) { + return `${field} must not contain whitespace (got ${JSON.stringify(value)})`; + } + let parsed; + try { + parsed = new URL(value); + } catch { + return `${field} is not a valid URL (got ${JSON.stringify(value)})`; + } + if (parsed.protocol !== 'https:') { + return `${field} must use https:// (got ${JSON.stringify(value)})`; + } + if (!parsed.hostname) { + return `${field} has no host (got ${JSON.stringify(value)})`; + } + return null; +} + +export function validate(text, source = 'landscape.yml') { + let data; + try { + data = yaml.load(text); + } catch (err) { + return [`${source}: YAML parse error: ${err.message}`]; + } + + if (data === null || typeof data !== 'object' || Array.isArray(data) || !('landscape' in data)) { + return [`${source}: top-level 'landscape' key is missing`]; + } + const categories = data.landscape; + if (!Array.isArray(categories)) { + return [`${source}: 'landscape' must be a list of categories`]; + } + if (categories.length === 0) { + return [`${source}: 'landscape' must contain at least one category`]; + } + + const errors = []; + const seenCategories = new Map(); + const seenNames = new Map(); + let itemCount = 0; + + categories.forEach((category, categoryIndex) => { + if (category === null || typeof category !== 'object' || !isNonEmptyString(category.category)) { + errors.push(`landscape[${categoryIndex}]: missing 'category' name`); + return; + } + const categoryName = category.category; + const categoryKey = categoryName.trim().toLowerCase(); + if (seenCategories.has(categoryKey)) { + errors.push(`category '${categoryName}': duplicate category name (also at ${seenCategories.get(categoryKey)})`); + } else { + seenCategories.set(categoryKey, `landscape[${categoryIndex}]`); + } + if (!Array.isArray(category.subcategories)) { + errors.push(`category '${categoryName}': 'subcategories' must be a list`); + return; + } + + const seenSubcategories = new Map(); + category.subcategories.forEach((subcategory, subcategoryIndex) => { + if (subcategory === null || typeof subcategory !== 'object' || !isNonEmptyString(subcategory.subcategory)) { + errors.push(`category '${categoryName}': subcategory[${subcategoryIndex}] missing 'subcategory' name`); + return; + } + const subcategoryName = subcategory.subcategory; + const subcategoryKey = subcategoryName.trim().toLowerCase(); + if (seenSubcategories.has(subcategoryKey)) { + errors.push(`'${categoryName}' / '${subcategoryName}': duplicate subcategory name in this category`); + } else { + seenSubcategories.set(subcategoryKey, subcategoryIndex); + } + if (!Array.isArray(subcategory.items)) { + errors.push(`'${categoryName}' / '${subcategoryName}': 'items' must be a list`); + return; + } + + subcategory.items.forEach((item, itemIndex) => { + itemCount += 1; + let location = `'${categoryName}' / '${subcategoryName}' / item[${itemIndex}]`; + if (item === null || typeof item !== 'object' || Array.isArray(item)) { + errors.push(`${location}: item must be a mapping`); + return; + } + if (isNonEmptyString(item.name)) { + location = `'${categoryName}' / '${subcategoryName}' / '${item.name}'`; + } + + for (const field of REQUIRED_FIELDS) { + if (!isNonEmptyString(item[field])) { + errors.push(`${location}: missing or empty required field '${field}'`); + } + } + if (isNonEmptyString(item.project) && !PROJECT_VALUES.has(item.project)) { + errors.push(`${location}: project '${item.project}' is not one of ${[...PROJECT_VALUES].sort().join(', ')}`); + } + if (isNonEmptyString(item.homepage_url)) { + const problem = urlProblem('homepage_url', item.homepage_url); + if (problem) errors.push(`${location}: ${problem}`); + } + if ('repo_url' in item) { + if (!isNonEmptyString(item.repo_url)) { + errors.push(`${location}: repo_url is present but empty`); + } else { + const problem = urlProblem('repo_url', item.repo_url); + if (problem) errors.push(`${location}: ${problem}`); + } + } + for (const key of Object.keys(item)) { + if (!ALLOWED_FIELDS.has(key)) { + errors.push(`${location}: unknown field '${key}' (allowed: ${[...ALLOWED_FIELDS].sort().join(', ')})`); + } + } + if (isNonEmptyString(item.name)) { + const nameKey = item.name.trim().toLowerCase(); + if (seenNames.has(nameKey)) { + errors.push(`${location}: duplicate entry name (also at ${seenNames.get(nameKey)})`); + } else { + seenNames.set(nameKey, location); + } + } + }); + }); + }); + + if (itemCount === 0) { + errors.push(`${source}: landscape contains no items`); + } + return errors; +} + +function main() { + const path = process.argv[2] ?? '../landscape/landscape.yml'; + let text; + try { + text = readFileSync(path, 'utf8'); + } catch (err) { + console.error(`cannot read ${path}: ${err.message}`); + process.exit(2); + } + const errors = validate(text, path); + if (errors.length > 0) { + console.error(`landscape validation failed with ${errors.length} problem(s):`); + for (const error of errors) { + console.error(` - ${error}`); + } + process.exit(1); + } + console.log(`${path}: OK`); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/validate-landscape.test.mjs b/scripts/validate-landscape.test.mjs new file mode 100644 index 0000000..36efcce --- /dev/null +++ b/scripts/validate-landscape.test.mjs @@ -0,0 +1,92 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { validate } from './validate-landscape.mjs'; + +const VALID = `landscape: + - category: Frameworks + subcategories: + - subcategory: Agents + items: + - name: goose + logo: placeholder.svg + homepage_url: https://goose-docs.ai/ + repo_url: https://github.com/aaif-goose/goose + description: An open agent. + project: member +`; + +function hasError(errors, pattern) { + return errors.some((error) => pattern.test(error)); +} + +test('valid data produces no errors', () => { + assert.deepEqual(validate(VALID), []); +}); + +test('the committed landscape.yml passes', () => { + const path = fileURLToPath(new URL('../landscape/landscape.yml', import.meta.url)); + assert.deepEqual(validate(readFileSync(path, 'utf8'), path), []); +}); + +test('duplicate mapping keys fail parsing, matching the site', () => { + const doc = VALID.replace('name: goose\n', 'name: goose\n name: shadow\n'); + assert.ok(hasError(validate(doc), /parse error/i)); +}); + +test('an empty landscape is rejected', () => { + assert.ok(hasError(validate('landscape: []'), /at least one category/)); +}); + +test('a landscape with no items is rejected', () => { + const doc = `landscape:\n - category: C\n subcategories:\n - subcategory: S\n items: []\n`; + assert.ok(hasError(validate(doc), /no items/)); +}); + +test('a missing required field is caught', () => { + const doc = VALID.replace(' description: An open agent.\n', ''); + assert.ok(hasError(validate(doc), /required field 'description'/)); +}); + +test('an invalid project value is caught', () => { + const doc = VALID.replace('project: member', 'project: hosted'); + assert.ok(hasError(validate(doc), /project 'hosted' is not one of/)); +}); + +test('a non-https url is caught', () => { + const doc = VALID.replace('https://goose-docs.ai/', 'http://goose-docs.ai/'); + assert.ok(hasError(validate(doc), /homepage_url must use https/)); +}); + +test('a url with whitespace is caught', () => { + const doc = VALID.replace('https://goose-docs.ai/', 'https://goose docs.ai/'); + assert.ok(hasError(validate(doc), /must not contain whitespace/)); +}); + +test('a misspelled optional field is caught', () => { + const doc = VALID.replace('repo_url:', 'reop_url:'); + assert.ok(hasError(validate(doc), /unknown field 'reop_url'/)); +}); + +test('duplicate entry names are caught', () => { + const doc = VALID.replace( + ' items:\n', + ' items:\n - {name: goose, homepage_url: "https://x.example", description: d, project: member}\n', + ); + assert.ok(hasError(validate(doc), /duplicate entry name/)); +}); + +test('duplicate categories are caught', () => { + const doc = + VALID + + ' - category: Frameworks\n subcategories:\n - subcategory: Other\n items:\n - {name: b, homepage_url: "https://x.example", description: d, project: member}\n'; + assert.ok(hasError(validate(doc), /duplicate category name/)); +}); + +test('duplicate subcategories in one category are caught', () => { + const doc = + VALID + + ' - subcategory: Agents\n items:\n - {name: c, homepage_url: "https://x.example", description: d, project: member}\n'; + assert.ok(hasError(validate(doc), /duplicate subcategory name/)); +}); diff --git a/scripts/validate_landscape.py b/scripts/validate_landscape.py deleted file mode 100644 index e0f4f2b..0000000 --- a/scripts/validate_landscape.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 -"""Validate landscape/landscape.yml against the schema in docs/data-schemas.md. - -Checks that the file parses, follows the category -> subcategory -> item -structure, has the required item fields, uses a valid `project` value, keeps -URLs on https, and has no duplicate entry names. Prints every problem it finds -and exits non-zero if there are any, so it can gate pull requests that change -the landscape data. - -Usage: python scripts/validate_landscape.py [path/to/landscape.yml] -""" -import sys - -try: - import yaml -except ImportError: - print("error: PyYAML is required (pip install pyyaml)", file=sys.stderr) - sys.exit(2) - -PROJECT_VALUES = {"graduated", "incubating", "member", "external"} -REQUIRED_ITEM_FIELDS = ("name", "homepage_url", "description", "project") - - -def is_nonempty_str(value): - return isinstance(value, str) and value.strip() != "" - - -def validate(path): - errors = [] - try: - with open(path, "r", encoding="utf-8") as handle: - data = yaml.safe_load(handle) - except yaml.YAMLError as exc: - return [f"{path}: YAML parse error: {exc}"] - except OSError as exc: - return [f"{path}: cannot read file: {exc}"] - - if not isinstance(data, dict) or "landscape" not in data: - return [f"{path}: top-level 'landscape' key is missing"] - categories = data["landscape"] - if not isinstance(categories, list): - return [f"{path}: 'landscape' must be a list of categories"] - - seen_names = {} # normalized name -> first location where it appeared - for cat_index, category in enumerate(categories): - if not isinstance(category, dict) or not is_nonempty_str(category.get("category")): - errors.append(f"landscape[{cat_index}]: missing 'category' name") - continue - cat_name = category["category"] - subcategories = category.get("subcategories") - if not isinstance(subcategories, list): - errors.append(f"category '{cat_name}': 'subcategories' must be a list") - continue - for sub_index, subcategory in enumerate(subcategories): - if not isinstance(subcategory, dict) or not is_nonempty_str(subcategory.get("subcategory")): - errors.append(f"category '{cat_name}': subcategory[{sub_index}] missing 'subcategory' name") - continue - sub_name = subcategory["subcategory"] - items = subcategory.get("items") - if not isinstance(items, list): - errors.append(f"'{cat_name}' / '{sub_name}': 'items' must be a list") - continue - for item_index, item in enumerate(items): - location = f"'{cat_name}' / '{sub_name}' / item[{item_index}]" - if not isinstance(item, dict): - errors.append(f"{location}: item must be a mapping") - continue - name = item.get("name") - if is_nonempty_str(name): - location = f"'{cat_name}' / '{sub_name}' / '{name}'" - - for field in REQUIRED_ITEM_FIELDS: - if not is_nonempty_str(item.get(field)): - errors.append(f"{location}: missing or empty required field '{field}'") - - project = item.get("project") - if is_nonempty_str(project) and project not in PROJECT_VALUES: - errors.append( - f"{location}: project '{project}' is not one of {sorted(PROJECT_VALUES)}" - ) - - homepage = item.get("homepage_url") - if is_nonempty_str(homepage) and not homepage.startswith("https://"): - errors.append(f"{location}: homepage_url must start with https:// (got '{homepage}')") - - if "repo_url" in item: - repo = item.get("repo_url") - if not is_nonempty_str(repo): - errors.append(f"{location}: repo_url is present but empty") - elif not repo.startswith("https://"): - errors.append(f"{location}: repo_url must start with https:// (got '{repo}')") - - if is_nonempty_str(name): - key = name.strip().lower() - if key in seen_names: - errors.append(f"{location}: duplicate entry name (also at {seen_names[key]})") - else: - seen_names[key] = location - - return errors - - -def main(): - path = sys.argv[1] if len(sys.argv) > 1 else "landscape/landscape.yml" - errors = validate(path) - if errors: - print(f"landscape validation failed with {len(errors)} problem(s):", file=sys.stderr) - for error in errors: - print(f" - {error}", file=sys.stderr) - sys.exit(1) - print(f"{path}: OK") - - -if __name__ == "__main__": - main() From 654741e9a00ad7043235867ed335d0c125061c1b Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:59:52 +0800 Subject: [PATCH 03/16] ci: fix js-yaml prototype-pollution exposure and harden landscape validation Bump js-yaml from 4.1.0 to 4.1.1 in both the validator (scripts) and the browser (the CDN script in landscape/static), fixing CVE-2025-64718: in 4.1.0 a `__proto__` merge key can pollute the parsed object's prototype. Under 4.1.0 an item with no own required fields could inherit them via a merge payload and pass validation; a regression test now covers that case. The validator also reads every schema field as an own property (Object.hasOwn / Object.keys) so prototype values can never satisfy a required field, rejects categories with no subcategories and subcategories with no items, and checks for unknown fields at the top, category, and subcategory levels rather than only on items. Move the CI job to Node 22 (Node 20 is end-of-life), install with --ignore-scripts, pin the CDN script with Subresource Integrity, and run the validator in the Pages deploy job so a bad file cannot be published on a direct push to main. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- .github/workflows/deploy-pages.yml | 13 ++++ .github/workflows/validate-landscape.yml | 4 +- landscape/static/index.html | 2 +- scripts/package-lock.json | 8 +-- scripts/package.json | 2 +- scripts/validate-landscape.mjs | 75 ++++++++++++++++-------- scripts/validate-landscape.test.mjs | 39 ++++++++++++ 7 files changed, 110 insertions(+), 33 deletions(-) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index afbee58..3cda2ad 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -53,6 +53,19 @@ jobs: # --output-dir out # cd .. + # Validate the landscape data before assembling, so a bad file cannot be + # deployed even on a direct push to main. + - name: Install landscape validator + working-directory: scripts + run: npm ci --ignore-scripts + + - name: Run validator tests + working-directory: scripts + run: node --test + + - name: Validate production landscape + run: node scripts/validate-landscape.mjs landscape/landscape.yml + - name: Assemble Portal Distribution run: | mkdir -p dist/landscape/static diff --git a/.github/workflows/validate-landscape.yml b/.github/workflows/validate-landscape.yml index 0409cae..a005904 100644 --- a/.github/workflows/validate-landscape.yml +++ b/.github/workflows/validate-landscape.yml @@ -27,12 +27,12 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm cache-dependency-path: scripts/package-lock.json - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts working-directory: scripts - name: Run validator tests diff --git a/landscape/static/index.html b/landscape/static/index.html index 9e5425a..de2e90a 100644 --- a/landscape/static/index.html +++ b/landscape/static/index.html @@ -15,7 +15,7 @@ - + diff --git a/scripts/package-lock.json b/scripts/package-lock.json index 977db7e..78e18eb 100644 --- a/scripts/package-lock.json +++ b/scripts/package-lock.json @@ -8,7 +8,7 @@ "name": "aaif-landscape-tools", "version": "1.0.0", "dependencies": { - "js-yaml": "4.1.0" + "js-yaml": "^4.1.1" } }, "node_modules/argparse": { @@ -18,9 +18,9 @@ "license": "Python-2.0" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/scripts/package.json b/scripts/package.json index 74babd0..460e886 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -9,6 +9,6 @@ "test": "node --test" }, "dependencies": { - "js-yaml": "4.1.0" + "js-yaml": "^4.1.1" } } diff --git a/scripts/validate-landscape.mjs b/scripts/validate-landscape.mjs index 4787fc2..39fb2a8 100644 --- a/scripts/validate-landscape.mjs +++ b/scripts/validate-landscape.mjs @@ -2,16 +2,21 @@ /** * Validate landscape/landscape.yml against the schema in docs/data-schemas.md. * - * Parsing uses js-yaml with default options, the same library and settings the - * site itself uses (landscape/static via the js-yaml CDN). That keeps CI and the - * browser in agreement: anything the site would reject at load time, such as a - * duplicate mapping key, fails here too rather than passing review and breaking - * the rendered map. + * Parsing uses js-yaml, the same library the site loads in the browser + * (landscape/static via the js-yaml CDN), pinned to the same version. That keeps + * CI and the browser in agreement: anything the site would reject at load time, + * such as a duplicate mapping key, fails here too rather than passing review and + * breaking the rendered map. * - * On top of parsing it checks the category -> subcategory -> item structure, the - * required item fields, the `project` enum, https URLs, unexpected fields, and - * duplicate category, subcategory, or entry names. It prints every problem and - * exits non-zero if there are any. + * All schema fields are read as own properties (Object.hasOwn / Object.keys), so + * values that only exist on an object's prototype (for example via a YAML merge + * key payload) never satisfy a required field or hide from the unknown-field + * checks. + * + * On top of parsing it checks the category -> subcategory -> item structure, that + * each level is non-empty, the required item fields, the `project` enum, https + * URLs, unexpected fields at every level, and duplicate category, subcategory, or + * entry names. It prints every problem and exits non-zero if there are any. * * Usage: node validate-landscape.mjs [path/to/landscape.yml] */ @@ -21,7 +26,7 @@ import yaml from 'js-yaml'; const PROJECT_VALUES = new Set(['graduated', 'incubating', 'member', 'external']); const REQUIRED_FIELDS = ['name', 'homepage_url', 'description', 'project']; -const ALLOWED_FIELDS = new Set([ +const ALLOWED_ITEM_FIELDS = new Set([ 'name', 'logo', 'homepage_url', @@ -29,11 +34,25 @@ const ALLOWED_FIELDS = new Set([ 'description', 'project', ]); +const ALLOWED_CATEGORY_FIELDS = new Set(['category', 'subcategories']); +const ALLOWED_SUBCATEGORY_FIELDS = new Set(['subcategory', 'items']); function isNonEmptyString(value) { return typeof value === 'string' && value.trim() !== ''; } +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function unexpectedKeys(object, allowed, location, errors) { + for (const key of Object.keys(object)) { + if (!allowed.has(key)) { + errors.push(`${location}: unknown field '${key}'`); + } + } +} + function urlProblem(field, value) { if (/\s/.test(value)) { return `${field} must not contain whitespace (got ${JSON.stringify(value)})`; @@ -61,7 +80,7 @@ export function validate(text, source = 'landscape.yml') { return [`${source}: YAML parse error: ${err.message}`]; } - if (data === null || typeof data !== 'object' || Array.isArray(data) || !('landscape' in data)) { + if (!isPlainObject(data) || !Object.hasOwn(data, 'landscape')) { return [`${source}: top-level 'landscape' key is missing`]; } const categories = data.landscape; @@ -73,16 +92,19 @@ export function validate(text, source = 'landscape.yml') { } const errors = []; + unexpectedKeys(data, new Set(['landscape']), source, errors); + const seenCategories = new Map(); const seenNames = new Map(); let itemCount = 0; categories.forEach((category, categoryIndex) => { - if (category === null || typeof category !== 'object' || !isNonEmptyString(category.category)) { + if (!isPlainObject(category) || !Object.hasOwn(category, 'category') || !isNonEmptyString(category.category)) { errors.push(`landscape[${categoryIndex}]: missing 'category' name`); return; } const categoryName = category.category; + unexpectedKeys(category, ALLOWED_CATEGORY_FIELDS, `category '${categoryName}'`, errors); const categoryKey = categoryName.trim().toLowerCase(); if (seenCategories.has(categoryKey)) { errors.push(`category '${categoryName}': duplicate category name (also at ${seenCategories.get(categoryKey)})`); @@ -93,14 +115,18 @@ export function validate(text, source = 'landscape.yml') { errors.push(`category '${categoryName}': 'subcategories' must be a list`); return; } + if (category.subcategories.length === 0) { + errors.push(`category '${categoryName}': must contain at least one subcategory`); + } const seenSubcategories = new Map(); category.subcategories.forEach((subcategory, subcategoryIndex) => { - if (subcategory === null || typeof subcategory !== 'object' || !isNonEmptyString(subcategory.subcategory)) { + if (!isPlainObject(subcategory) || !Object.hasOwn(subcategory, 'subcategory') || !isNonEmptyString(subcategory.subcategory)) { errors.push(`category '${categoryName}': subcategory[${subcategoryIndex}] missing 'subcategory' name`); return; } const subcategoryName = subcategory.subcategory; + unexpectedKeys(subcategory, ALLOWED_SUBCATEGORY_FIELDS, `'${categoryName}' / '${subcategoryName}'`, errors); const subcategoryKey = subcategoryName.trim().toLowerCase(); if (seenSubcategories.has(subcategoryKey)) { errors.push(`'${categoryName}' / '${subcategoryName}': duplicate subcategory name in this category`); @@ -111,31 +137,34 @@ export function validate(text, source = 'landscape.yml') { errors.push(`'${categoryName}' / '${subcategoryName}': 'items' must be a list`); return; } + if (subcategory.items.length === 0) { + errors.push(`'${categoryName}' / '${subcategoryName}': must contain at least one item`); + } subcategory.items.forEach((item, itemIndex) => { itemCount += 1; let location = `'${categoryName}' / '${subcategoryName}' / item[${itemIndex}]`; - if (item === null || typeof item !== 'object' || Array.isArray(item)) { + if (!isPlainObject(item)) { errors.push(`${location}: item must be a mapping`); return; } - if (isNonEmptyString(item.name)) { + if (Object.hasOwn(item, 'name') && isNonEmptyString(item.name)) { location = `'${categoryName}' / '${subcategoryName}' / '${item.name}'`; } for (const field of REQUIRED_FIELDS) { - if (!isNonEmptyString(item[field])) { + if (!Object.hasOwn(item, field) || !isNonEmptyString(item[field])) { errors.push(`${location}: missing or empty required field '${field}'`); } } - if (isNonEmptyString(item.project) && !PROJECT_VALUES.has(item.project)) { + if (Object.hasOwn(item, 'project') && isNonEmptyString(item.project) && !PROJECT_VALUES.has(item.project)) { errors.push(`${location}: project '${item.project}' is not one of ${[...PROJECT_VALUES].sort().join(', ')}`); } - if (isNonEmptyString(item.homepage_url)) { + if (Object.hasOwn(item, 'homepage_url') && isNonEmptyString(item.homepage_url)) { const problem = urlProblem('homepage_url', item.homepage_url); if (problem) errors.push(`${location}: ${problem}`); } - if ('repo_url' in item) { + if (Object.hasOwn(item, 'repo_url')) { if (!isNonEmptyString(item.repo_url)) { errors.push(`${location}: repo_url is present but empty`); } else { @@ -143,12 +172,8 @@ export function validate(text, source = 'landscape.yml') { if (problem) errors.push(`${location}: ${problem}`); } } - for (const key of Object.keys(item)) { - if (!ALLOWED_FIELDS.has(key)) { - errors.push(`${location}: unknown field '${key}' (allowed: ${[...ALLOWED_FIELDS].sort().join(', ')})`); - } - } - if (isNonEmptyString(item.name)) { + unexpectedKeys(item, ALLOWED_ITEM_FIELDS, location, errors); + if (Object.hasOwn(item, 'name') && isNonEmptyString(item.name)) { const nameKey = item.name.trim().toLowerCase(); if (seenNames.has(nameKey)) { errors.push(`${location}: duplicate entry name (also at ${seenNames.get(nameKey)})`); diff --git a/scripts/validate-landscape.test.mjs b/scripts/validate-landscape.test.mjs index 36efcce..3de69c5 100644 --- a/scripts/validate-landscape.test.mjs +++ b/scripts/validate-landscape.test.mjs @@ -90,3 +90,42 @@ test('duplicate subcategories in one category are caught', () => { ' - subcategory: Agents\n items:\n - {name: c, homepage_url: "https://x.example", description: d, project: member}\n'; assert.ok(hasError(validate(doc), /duplicate subcategory name/)); }); + +test('a prototype-pollution merge payload does not satisfy required fields', () => { + const doc = `defaults: &defaults + __proto__: + name: injected + homepage_url: https://example.com + description: injected + project: member +landscape: + - category: C + subcategories: + - subcategory: S + items: + - <<: *defaults +`; + const errors = validate(doc); + assert.ok(errors.length > 0, errors.join('\n')); + assert.ok(hasError(errors, /required field/), 'inherited fields must not count as own fields'); +}); + +test('an empty subcategories list is rejected', () => { + assert.ok(hasError(validate('landscape:\n - category: C\n subcategories: []\n'), /at least one subcategory/)); +}); + +test('an empty items list is rejected', () => { + const doc = 'landscape:\n - category: C\n subcategories:\n - subcategory: S\n items: []\n'; + assert.ok(hasError(validate(doc), /at least one item/)); +}); + +test('an unexpected field on a category is caught', () => { + const doc = + VALID + + ' - category: Extra\n typo_field: hidden\n subcategories:\n - subcategory: S\n items:\n - {name: z, homepage_url: "https://x.example", description: d, project: member}\n'; + assert.ok(hasError(validate(doc), /unknown field 'typo_field'/)); +}); + +test('an unexpected top-level field is caught', () => { + assert.ok(hasError(validate('metadata: hidden\n' + VALID), /unknown field 'metadata'/)); +}); From 11b29c8f9a2fbbacbcf23aa7046b690383129f6d Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sat, 25 Jul 2026 03:08:54 +0800 Subject: [PATCH 04/16] Harden landscape validation: js-yaml 4.3.0, reject aliases, cap size The bundled js-yaml 4.1.1 is affected by two merge-key DoS advisories (GHSA-h67p-54hq-rp68, GHSA-52cp-r559-cp3m); npm audit flags it. Move both the CI dependency and the browser bundle to 4.3.0, which clears both. cdnjs does not host 4.3.0, so the browser now loads it from jsDelivr with a refreshed SRI hash. Beyond the library, YAML aliases resolve to shared references, so a small file with nested aliases expands to N^3 item visits in the validator (and N^3 DOM nodes in the browser) from ~3N lines. The schema never needs anchors, aliases, or merge keys, so reject any reused node, and cap the file size. Gate the subcategories/items reads on Object.hasOwn to match the module's own-property contract. Tests cover each new guard. Align the Pages deploy job with the validation workflow: Node 22 (20 is end-of-life), a job timeout, and persist-credentials: false on checkout. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- .github/workflows/deploy-pages.yml | 5 ++- landscape/static/index.html | 4 +- scripts/package-lock.json | 18 +++++++-- scripts/package.json | 2 +- scripts/validate-landscape.mjs | 40 +++++++++++++++++++- scripts/validate-landscape.test.mjs | 57 +++++++++++++++++++++++++---- 6 files changed, 108 insertions(+), 18 deletions(-) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 3cda2ad..94e15e4 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -24,9 +24,12 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 + with: + persist-credentials: false - name: Setup Pages uses: actions/configure-pages@v5 @@ -36,7 +39,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 # - name: Install landscape2 # run: | diff --git a/landscape/static/index.html b/landscape/static/index.html index de2e90a..9c898f2 100644 --- a/landscape/static/index.html +++ b/landscape/static/index.html @@ -14,8 +14,8 @@ - - + + diff --git a/scripts/package-lock.json b/scripts/package-lock.json index 78e18eb..40b0d88 100644 --- a/scripts/package-lock.json +++ b/scripts/package-lock.json @@ -8,7 +8,7 @@ "name": "aaif-landscape-tools", "version": "1.0.0", "dependencies": { - "js-yaml": "^4.1.1" + "js-yaml": "4.3.0" } }, "node_modules/argparse": { @@ -18,9 +18,19 @@ "license": "Python-2.0" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/scripts/package.json b/scripts/package.json index 460e886..bf8b373 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -9,6 +9,6 @@ "test": "node --test" }, "dependencies": { - "js-yaml": "^4.1.1" + "js-yaml": "4.3.0" } } diff --git a/scripts/validate-landscape.mjs b/scripts/validate-landscape.mjs index 39fb2a8..d48d662 100644 --- a/scripts/validate-landscape.mjs +++ b/scripts/validate-landscape.mjs @@ -37,6 +37,11 @@ const ALLOWED_ITEM_FIELDS = new Set([ const ALLOWED_CATEGORY_FIELDS = new Set(['category', 'subcategories']); const ALLOWED_SUBCATEGORY_FIELDS = new Set(['subcategory', 'items']); +// The landscape is a curated list a few tens of KB in size. A cap keeps a +// runaway or hostile file from producing an unbounded item list (and DOM) once +// the site renders it; the real data is far below this. +const MAX_BYTES = 2_000_000; + function isNonEmptyString(value) { return typeof value === 'string' && value.trim() !== ''; } @@ -45,6 +50,31 @@ function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } +// YAML anchors/aliases resolve to shared references, so a small file can expand +// into a huge traversal: N reused category nodes, each holding N reused +// subcategory nodes, each holding N reused item nodes, is N^3 item visits here +// (and N^3 DOM nodes in the browser) from ~3N lines of input. The landscape +// schema never needs anchors, aliases, or merge keys, so reject any object or +// array that appears more than once. Iterative (an explicit stack, not +// recursion) so a deeply nested file cannot overflow the call stack, and a +// self-referential alias terminates on the WeakSet hit rather than looping. +function hasReusedNode(root) { + const seen = new WeakSet(); + const stack = [root]; + while (stack.length > 0) { + const value = stack.pop(); + if (value === null || typeof value !== 'object') continue; + if (seen.has(value)) return true; + seen.add(value); + if (Array.isArray(value)) { + for (const element of value) stack.push(element); + } else { + for (const key of Object.keys(value)) stack.push(value[key]); + } + } + return false; +} + function unexpectedKeys(object, allowed, location, errors) { for (const key of Object.keys(object)) { if (!allowed.has(key)) { @@ -73,6 +103,9 @@ function urlProblem(field, value) { } export function validate(text, source = 'landscape.yml') { + if (typeof text === 'string' && Buffer.byteLength(text, 'utf8') > MAX_BYTES) { + return [`${source}: file is larger than ${MAX_BYTES} bytes`]; + } let data; try { data = yaml.load(text); @@ -90,6 +123,9 @@ export function validate(text, source = 'landscape.yml') { if (categories.length === 0) { return [`${source}: 'landscape' must contain at least one category`]; } + if (hasReusedNode(data)) { + return [`${source}: YAML anchors/aliases are not allowed`]; + } const errors = []; unexpectedKeys(data, new Set(['landscape']), source, errors); @@ -111,7 +147,7 @@ export function validate(text, source = 'landscape.yml') { } else { seenCategories.set(categoryKey, `landscape[${categoryIndex}]`); } - if (!Array.isArray(category.subcategories)) { + if (!Object.hasOwn(category, 'subcategories') || !Array.isArray(category.subcategories)) { errors.push(`category '${categoryName}': 'subcategories' must be a list`); return; } @@ -133,7 +169,7 @@ export function validate(text, source = 'landscape.yml') { } else { seenSubcategories.set(subcategoryKey, subcategoryIndex); } - if (!Array.isArray(subcategory.items)) { + if (!Object.hasOwn(subcategory, 'items') || !Array.isArray(subcategory.items)) { errors.push(`'${categoryName}' / '${subcategoryName}': 'items' must be a list`); return; } diff --git a/scripts/validate-landscape.test.mjs b/scripts/validate-landscape.test.mjs index 3de69c5..f30ee36 100644 --- a/scripts/validate-landscape.test.mjs +++ b/scripts/validate-landscape.test.mjs @@ -92,24 +92,65 @@ test('duplicate subcategories in one category are caught', () => { }); test('a prototype-pollution merge payload does not satisfy required fields', () => { - const doc = `defaults: &defaults - __proto__: - name: injected - homepage_url: https://example.com - description: injected - project: member -landscape: + // Inline merge (no anchor/alias), so this exercises the own-property checks + // rather than the alias gate: a merged __proto__ never becomes an own field. + const doc = `landscape: - category: C subcategories: - subcategory: S items: - - <<: *defaults + - <<: {__proto__: {name: injected, homepage_url: 'https://example.com', description: injected, project: member}} `; const errors = validate(doc); assert.ok(errors.length > 0, errors.join('\n')); assert.ok(hasError(errors, /required field/), 'inherited fields must not count as own fields'); }); +test('nested YAML aliases are rejected before they can amplify', () => { + // Each alias reuses a node, so this ~3N-line file would otherwise be N^3 item + // visits here and N^3 DOM nodes in the browser. The gate rejects it outright. + const doc = `landscape: + - &c + category: C + subcategories: + - &s + subcategory: S + items: + - &i {name: a, homepage_url: 'https://x.example', description: d, project: member} + - *i + - *s + - *c +`; + assert.ok(hasError(validate(doc), /anchors\/aliases are not allowed/)); +}); + +test('a file larger than the byte cap is rejected', () => { + const doc = 'landscape:\n' + '#'.repeat(2_000_001); + assert.ok(hasError(validate(doc), /larger than \d+ bytes/)); +}); + +test('an unexpected field on a subcategory is caught', () => { + const doc = `landscape: + - category: C + subcategories: + - subcategory: S + bogus_sub_field: x + items: + - {name: y, homepage_url: "https://x.example", description: d, project: member} +`; + assert.ok(hasError(validate(doc), /unknown field 'bogus_sub_field'/)); +}); + +test('an empty repo_url is caught', () => { + const doc = VALID.replace('repo_url: https://github.com/aaif-goose/goose', 'repo_url: ""'); + assert.ok(hasError(validate(doc), /repo_url is present but empty/)); +}); + +test('a non-https repo_url is caught', () => { + const doc = VALID.replace('https://github.com/aaif-goose/goose', 'http://github.com/aaif-goose/goose'); + assert.ok(hasError(validate(doc), /repo_url must use https/)); +}); + test('an empty subcategories list is rejected', () => { assert.ok(hasError(validate('landscape:\n - category: C\n subcategories: []\n'), /at least one subcategory/)); }); From d01977ba73f69211a348bd8058e2ecf622c57de8 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:40:44 +0800 Subject: [PATCH 05/16] Bound parsed size and disable merge keys in the landscape validator The file-size cap bounded the input but not what it materializes: a document well under the cap could anchor a large description and reuse it via a scalar alias across many items (the object-alias guard only sees reused objects, not reused primitives), or simply carry one huge description or thousands of items, producing an enormous browser render. Parse with FAILSAFE_SCHEMA and maxDepth, which restrict types to strings, sequences, and mappings and disable merge (`<<`) resolution, so a merge key is rejected as an unknown field. Cap the item count and each text field's length so a reused or oversized scalar is rejected per occurrence. Also reject URLs that carry credentials and normalize names with NFKC before duplicate detection. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- scripts/validate-landscape.mjs | 45 +++++++++++++++++--- scripts/validate-landscape.test.mjs | 66 +++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/scripts/validate-landscape.mjs b/scripts/validate-landscape.mjs index d48d662..74fcdeb 100644 --- a/scripts/validate-landscape.mjs +++ b/scripts/validate-landscape.mjs @@ -42,10 +42,28 @@ const ALLOWED_SUBCATEGORY_FIELDS = new Set(['subcategory', 'items']); // the site renders it; the real data is far below this. const MAX_BYTES = 2_000_000; +// Bound the parsed data, not just the file: a file well under MAX_BYTES can still +// materialize an enormous render workload (one huge description, many items, or a +// scalar reused via a YAML alias across thousands of items). These caps bound the +// item count and each text field so the browser cannot be overwhelmed. +const MAX_TOTAL_ITEMS = 5_000; +const LENGTH_LIMITS = { + name: 200, + description: 2_000, + homepage_url: 2_048, + repo_url: 2_048, +}; + function isNonEmptyString(value) { return typeof value === 'string' && value.trim() !== ''; } +// Normalize a name to a duplicate-detection key: NFKC folds compatibility variants +// (full-width, composed vs decomposed) so visually equal names collide. +function normalizeKey(value) { + return value.normalize('NFKC').trim().toLowerCase(); +} + function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } @@ -55,7 +73,9 @@ function isPlainObject(value) { // subcategory nodes, each holding N reused item nodes, is N^3 item visits here // (and N^3 DOM nodes in the browser) from ~3N lines of input. The landscape // schema never needs anchors, aliases, or merge keys, so reject any object or -// array that appears more than once. Iterative (an explicit stack, not +// array that appears more than once. (A scalar alias shares a primitive, not an +// object, so it slips past this WeakSet; the per-field length caps bound those, +// and the FAILSAFE parse schema disables merge keys.) Iterative (an explicit stack, not // recursion) so a deeply nested file cannot overflow the call stack, and a // self-referential alias terminates on the WeakSet hit rather than looping. function hasReusedNode(root) { @@ -99,6 +119,9 @@ function urlProblem(field, value) { if (!parsed.hostname) { return `${field} has no host (got ${JSON.stringify(value)})`; } + if (parsed.username !== '' || parsed.password !== '') { + return `${field} must not contain credentials`; + } return null; } @@ -108,7 +131,11 @@ export function validate(text, source = 'landscape.yml') { } let data; try { - data = yaml.load(text); + // FAILSAFE_SCHEMA parses only strings, sequences, and mappings, which is all the + // landscape uses. It also drops merge (`<<`) resolution, so a merge key becomes a + // plain (and rejected) unknown field instead of silently merging. maxDepth bounds + // nesting so a deeply nested file cannot exhaust the parser. + data = yaml.load(text, { schema: yaml.FAILSAFE_SCHEMA, maxDepth: 10 }); } catch (err) { return [`${source}: YAML parse error: ${err.message}`]; } @@ -141,7 +168,7 @@ export function validate(text, source = 'landscape.yml') { } const categoryName = category.category; unexpectedKeys(category, ALLOWED_CATEGORY_FIELDS, `category '${categoryName}'`, errors); - const categoryKey = categoryName.trim().toLowerCase(); + const categoryKey = normalizeKey(categoryName); if (seenCategories.has(categoryKey)) { errors.push(`category '${categoryName}': duplicate category name (also at ${seenCategories.get(categoryKey)})`); } else { @@ -163,7 +190,7 @@ export function validate(text, source = 'landscape.yml') { } const subcategoryName = subcategory.subcategory; unexpectedKeys(subcategory, ALLOWED_SUBCATEGORY_FIELDS, `'${categoryName}' / '${subcategoryName}'`, errors); - const subcategoryKey = subcategoryName.trim().toLowerCase(); + const subcategoryKey = normalizeKey(subcategoryName); if (seenSubcategories.has(subcategoryKey)) { errors.push(`'${categoryName}' / '${subcategoryName}': duplicate subcategory name in this category`); } else { @@ -193,6 +220,11 @@ export function validate(text, source = 'landscape.yml') { errors.push(`${location}: missing or empty required field '${field}'`); } } + for (const [field, max] of Object.entries(LENGTH_LIMITS)) { + if (Object.hasOwn(item, field) && typeof item[field] === 'string' && item[field].length > max) { + errors.push(`${location}: ${field} is longer than ${max} characters`); + } + } if (Object.hasOwn(item, 'project') && isNonEmptyString(item.project) && !PROJECT_VALUES.has(item.project)) { errors.push(`${location}: project '${item.project}' is not one of ${[...PROJECT_VALUES].sort().join(', ')}`); } @@ -210,7 +242,7 @@ export function validate(text, source = 'landscape.yml') { } unexpectedKeys(item, ALLOWED_ITEM_FIELDS, location, errors); if (Object.hasOwn(item, 'name') && isNonEmptyString(item.name)) { - const nameKey = item.name.trim().toLowerCase(); + const nameKey = normalizeKey(item.name); if (seenNames.has(nameKey)) { errors.push(`${location}: duplicate entry name (also at ${seenNames.get(nameKey)})`); } else { @@ -224,6 +256,9 @@ export function validate(text, source = 'landscape.yml') { if (itemCount === 0) { errors.push(`${source}: landscape contains no items`); } + if (itemCount > MAX_TOTAL_ITEMS) { + errors.push(`${source}: landscape has ${itemCount} items, more than the ${MAX_TOTAL_ITEMS} allowed`); + } return errors; } diff --git a/scripts/validate-landscape.test.mjs b/scripts/validate-landscape.test.mjs index f30ee36..d058c61 100644 --- a/scripts/validate-landscape.test.mjs +++ b/scripts/validate-landscape.test.mjs @@ -170,3 +170,69 @@ test('an unexpected field on a category is caught', () => { test('an unexpected top-level field is caught', () => { assert.ok(hasError(validate('metadata: hidden\n' + VALID), /unknown field 'metadata'/)); }); + +test('an inline merge key is rejected (failsafe schema does not merge)', () => { + const doc = `landscape: + - category: C + subcategories: + - subcategory: S + items: + - <<: {name: n, homepage_url: "https://x.example", description: d, project: member} +`; + // Under FAILSAFE_SCHEMA "<<" is a plain key, so the merge never happens: the item + // has no own required fields and carries an unknown "<<" field. + assert.ok(hasError(validate(doc), /required field|unknown field/)); +}); + +test('a merge alias is rejected', () => { + const doc = `landscape: + - category: C + subcategories: + - subcategory: S + items: + - &base {name: n, homepage_url: "https://x.example", description: d, project: member} + - <<: *base + name: m +`; + assert.ok(validate(doc).length > 0); +}); + +test('a scalar alias reused across items is still length-capped per occurrence', () => { + const huge = 'x'.repeat(3000); + const doc = `landscape: + - category: C + subcategories: + - subcategory: S + items: + - {name: a, homepage_url: "https://x.example/a", description: &D ${huge}, project: member} + - {name: b, homepage_url: "https://x.example/b", description: *D, project: member} +`; + assert.ok(hasError(validate(doc), /description is longer than/)); +}); + +test('a single over-long description is rejected', () => { + const doc = VALID.replace('An open agent.', 'x'.repeat(3000)); + assert.ok(hasError(validate(doc), /description is longer than/)); +}); + +test('too many items are rejected', () => { + let items = ''; + for (let i = 0; i <= 5000; i++) { + items += ` - {name: n${i}, homepage_url: "https://x.example/${i}", description: d, project: member}\n`; + } + const doc = `landscape:\n - category: C\n subcategories:\n - subcategory: S\n items:\n${items}`; + assert.ok(hasError(validate(doc), /more than the 5000 allowed/)); +}); + +test('a url with embedded credentials is rejected', () => { + const doc = VALID.replace('https://goose-docs.ai/', 'https://user:pass@goose-docs.ai/'); + assert.ok(hasError(validate(doc), /must not contain credentials/)); +}); + +test('unicode-equivalent duplicate names are caught', () => { + const doc = VALID.replace( + ' items:\n', + ' items:\n - {name: goose, homepage_url: "https://x.example", description: d, project: member}\n', + ); + assert.ok(hasError(validate(doc), /duplicate entry name/)); +}); From c08ae52119836989272dfa747cb728cfe7f449c3 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:22:01 +0800 Subject: [PATCH 06/16] Align the browser parser with the validator and bound render and error cost Addresses review feedback on the landscape validator. The browser (landscape/static/app.js) parsed landscape.yml with js-yaml's default schema while the CI validator used FAILSAFE_SCHEMA, so a scalar such as `name: 789` or `description: 2026-01-01` validated as a string but became a number or Date in the browser, where the search code then threw calling .toLowerCase() on it. app.js now parses with the same FAILSAFE_SCHEMA and maxDepth options, and a test guards the parity. Bound the work a small but hostile file can force. The validator caps category and subcategory names (so an over-long name cannot inflate every error message), rejects control and format characters in names, caps the logo length, preflights the item count before the detailed pass, lowers the item cap to 500 (the landscape has a few tens of entries), and caps the number and length of reported errors. app.js bounds the highlight nodes a single field can create and builds the load-error message with textContent instead of innerHTML. Document the enforced limits in docs/data-schemas.md so the validator matches the contributor contract. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- docs/data-schemas.md | 15 +++ landscape/static/app.js | 37 ++++++-- scripts/validate-landscape.mjs | 136 ++++++++++++++++++++-------- scripts/validate-landscape.test.mjs | 41 ++++++++- 4 files changed, 178 insertions(+), 51 deletions(-) diff --git a/docs/data-schemas.md b/docs/data-schemas.md index e4f80ff..d6f6556 100644 --- a/docs/data-schemas.md +++ b/docs/data-schemas.md @@ -75,5 +75,20 @@ The landscape configuration follows a hierarchical CNCF-style structure. Each ro * `incubating` - Active AAIF work-in-progress standards/projects * `member` - Member-contributed tools/projects * `external` - Non-member open-source tools/frameworks +* **`logo`** *(String, Optional):* Path to the item's logo asset. + +### Landscape Structural Rules +* Each **`category`** requires a non-empty `category` name and a non-empty `subcategories` list; each **`subcategory`** requires a non-empty `subcategory` name and a non-empty `items` list. +* Category, subcategory, and item names must be unique within their scope (compared case- and Unicode-normalization-insensitively). + +### Landscape Validation Limits +`scripts/validate-landscape.mjs` runs in CI with the same js-yaml parser and options the site loads with, and enforces the following so a malformed or hostile file cannot break the rendered map or the validator itself: + +* **Parsing:** the file is parsed with `FAILSAFE_SCHEMA`, so every scalar is a string — a bare `123` or `2026-01-01` is read as text, matching the browser. YAML anchors, aliases, and merge (`<<`) keys are rejected, and nesting depth and file size (2 MB) are bounded. +* **Field lengths:** `name` ≤ 200, `description` ≤ 2000, `homepage_url` / `repo_url` ≤ 2048, `logo` ≤ 300, and `category` / `subcategory` names ≤ 120 characters. +* **Cardinality:** at most 500 items across the whole landscape. +* **URLs:** `homepage_url` and `repo_url` must be `https://`, contain no whitespace, and carry no embedded credentials. +* **Fields:** only the fields documented above are allowed at each level; any other key is rejected. +* **Characters:** display names must not contain control or format characters (for example zero-width or bidirectional-override characters). --- diff --git a/landscape/static/app.js b/landscape/static/app.js index 49ce739..3c9ae2c 100644 --- a/landscape/static/app.js +++ b/landscape/static/app.js @@ -31,8 +31,18 @@ document.addEventListener('DOMContentLoaded', () => { const regex = new RegExp(`(${escapedQuery})`, 'gi'); const parts = text.split(regex); - parts.forEach(part => { - if (part.toLowerCase() === query.toLowerCase()) { + // Bound the nodes a single field can create. A long value with many matches would + // otherwise produce one node per fragment; after the cap, append the remainder as a + // single text node so the field still renders in full but cannot flood the DOM. + const MAX_HIGHLIGHT_NODES = 100; + const lowerQuery = query.toLowerCase(); + for (let i = 0; i < parts.length; i += 1) { + if (i >= MAX_HIGHLIGHT_NODES) { + parentElement.appendChild(document.createTextNode(parts.slice(i).join(''))); + break; + } + const part = parts[i]; + if (part.toLowerCase() === lowerQuery) { const mark = document.createElement('mark'); mark.className = 'match-highlight'; mark.textContent = part; @@ -40,7 +50,7 @@ document.addEventListener('DOMContentLoaded', () => { } else if (part) { parentElement.appendChild(document.createTextNode(part)); } - }); + } } catch (e) { parentElement.textContent = text; } @@ -235,16 +245,23 @@ document.addEventListener('DOMContentLoaded', () => { if (!response.ok) throw new Error('Failed to fetch landscape.yml'); const yamlText = await response.text(); - state.rawLandscape = jsyaml.load(yamlText); + // Parse with the same options as the CI validator (scripts/validate-landscape.mjs): + // FAILSAFE_SCHEMA keeps every scalar a string, so a value like `name: 789` cannot + // arrive here as a number or Date and then throw in the search .toLowerCase() calls, + // and maxDepth bounds nesting. These options must stay in sync with the validator. + state.rawLandscape = jsyaml.load(yamlText, { schema: jsyaml.FAILSAFE_SCHEMA, maxDepth: 10 }); initCategoryBar(); runFilteringPipeline(); } catch (error) { - landscapeGrid.innerHTML = ` -
-

Error loading landscape configuration.

- Please ensure landscape.yml exists and is valid YAML. (${error.message}) -
- `; + landscapeGrid.replaceChildren(); + const errorState = document.createElement('div'); + errorState.className = 'empty-state'; + const errorTitle = document.createElement('p'); + errorTitle.textContent = 'Error loading landscape configuration.'; + const errorDetail = document.createElement('span'); + errorDetail.textContent = `Please ensure landscape.yml exists and is valid YAML. (${error.message})`; + errorState.append(errorTitle, errorDetail); + landscapeGrid.appendChild(errorState); resultCount.textContent = 'Error loading data'; } } diff --git a/scripts/validate-landscape.mjs b/scripts/validate-landscape.mjs index 74fcdeb..43338fb 100644 --- a/scripts/validate-landscape.mjs +++ b/scripts/validate-landscape.mjs @@ -3,10 +3,13 @@ * Validate landscape/landscape.yml against the schema in docs/data-schemas.md. * * Parsing uses js-yaml, the same library the site loads in the browser - * (landscape/static via the js-yaml CDN), pinned to the same version. That keeps - * CI and the browser in agreement: anything the site would reject at load time, - * such as a duplicate mapping key, fails here too rather than passing review and - * breaking the rendered map. + * (landscape/static via the js-yaml CDN), pinned to the same version AND parsed with + * the same options: FAILSAFE_SCHEMA and maxDepth (see landscape/static/app.js). That + * keeps CI and the browser in agreement: anything the site would reject or mis-type at + * load time fails here too rather than passing review and breaking the rendered map. In + * particular FAILSAFE_SCHEMA keeps every scalar a string, so a value like `name: 789` + * cannot pass here as a string but parse as a number (or `2026-07-25` as a Date) in the + * browser, where the search code would then throw calling `.toLowerCase()` on it. * * All schema fields are read as own properties (Object.hasOwn / Object.keys), so * values that only exist on an object's prototype (for example via a YAML merge @@ -16,7 +19,10 @@ * On top of parsing it checks the category -> subcategory -> item structure, that * each level is non-empty, the required item fields, the `project` enum, https * URLs, unexpected fields at every level, and duplicate category, subcategory, or - * entry names. It prints every problem and exits non-zero if there are any. + * entry names. It also bounds the work: item count, per-field lengths, display-name + * lengths, and the number and size of reported errors, so neither this validator nor + * the browser can be overwhelmed by a small but hostile file. It prints every problem + * and exits non-zero if there are any. * * Usage: node validate-landscape.mjs [path/to/landscape.yml] */ @@ -42,18 +48,45 @@ const ALLOWED_SUBCATEGORY_FIELDS = new Set(['subcategory', 'items']); // the site renders it; the real data is far below this. const MAX_BYTES = 2_000_000; -// Bound the parsed data, not just the file: a file well under MAX_BYTES can still -// materialize an enormous render workload (one huge description, many items, or a -// scalar reused via a YAML alias across thousands of items). These caps bound the -// item count and each text field so the browser cannot be overwhelmed. -const MAX_TOTAL_ITEMS = 5_000; +// Bound the parsed data, not just the file. A file well under MAX_BYTES can still +// materialize an enormous render workload in the browser (one huge name, many items, or +// a scalar reused via a YAML alias across thousands of items) or force this validator to +// build a huge pile of error strings. These caps bound the item count, each text field, +// the display names, and the error output. The real landscape has a few tens of entries, +// so 500 items is already generous headroom. +const MAX_TOTAL_ITEMS = 500; +const MAX_ERRORS = 200; +const MAX_ERROR_LENGTH = 500; +const DISPLAY_NAME_MAX = 120; const LENGTH_LIMITS = { name: 200, description: 2_000, homepage_url: 2_048, repo_url: 2_048, + logo: 300, }; +// Control and format characters (zero-width joiners, bidi overrides, other C0/C1) render +// invisibly and let two visually identical names differ, defeating duplicate detection +// and enabling spoofed entries; reject them in any displayed string. +const CONTROL_OR_FORMAT = /[\p{Cc}\p{Cf}]/u; + +// addError bounds both the number of errors and the length of each, so a file crafted to +// produce a huge volume of error text cannot exhaust memory here or flood the CI log. +function addError(errors, message) { + if (errors.length >= MAX_ERRORS) return; + errors.push(message.length > MAX_ERROR_LENGTH ? `${message.slice(0, MAX_ERROR_LENGTH)}…` : message); +} + +// A displayed string (category, subcategory, or item name) must be within its length cap +// and free of control/format characters. Checking length before the name is interpolated +// into any location string keeps a hostile name from inflating every downstream message. +function displayStringProblem(value, max) { + if (value.length > max) return `is longer than ${max} characters`; + if (CONTROL_OR_FORMAT.test(value)) return 'contains control or format characters'; + return null; +} + function isNonEmptyString(value) { return typeof value === 'string' && value.trim() !== ''; } @@ -74,10 +107,10 @@ function isPlainObject(value) { // (and N^3 DOM nodes in the browser) from ~3N lines of input. The landscape // schema never needs anchors, aliases, or merge keys, so reject any object or // array that appears more than once. (A scalar alias shares a primitive, not an -// object, so it slips past this WeakSet; the per-field length caps bound those, -// and the FAILSAFE parse schema disables merge keys.) Iterative (an explicit stack, not -// recursion) so a deeply nested file cannot overflow the call stack, and a -// self-referential alias terminates on the WeakSet hit rather than looping. +// object, so it slips past this WeakSet; the per-field length caps and the total +// item cap bound those, and the FAILSAFE parse schema disables merge keys.) Iterative +// (an explicit stack, not recursion) so a deeply nested file cannot overflow the call +// stack, and a self-referential alias terminates on the WeakSet hit rather than looping. function hasReusedNode(root) { const seen = new WeakSet(); const stack = [root]; @@ -98,7 +131,7 @@ function hasReusedNode(root) { function unexpectedKeys(object, allowed, location, errors) { for (const key of Object.keys(object)) { if (!allowed.has(key)) { - errors.push(`${location}: unknown field '${key}'`); + addError(errors, `${location}: unknown field '${key}'`); } } } @@ -132,9 +165,10 @@ export function validate(text, source = 'landscape.yml') { let data; try { // FAILSAFE_SCHEMA parses only strings, sequences, and mappings, which is all the - // landscape uses. It also drops merge (`<<`) resolution, so a merge key becomes a - // plain (and rejected) unknown field instead of silently merging. maxDepth bounds - // nesting so a deeply nested file cannot exhaust the parser. + // landscape uses. It drops merge (`<<`) resolution, so a merge key becomes a plain + // (and rejected) unknown field instead of silently merging, and it keeps every scalar + // a string so numeric/timestamp scalars cannot diverge between here and the browser. + // maxDepth bounds nesting. app.js parses with these same options. data = yaml.load(text, { schema: yaml.FAILSAFE_SCHEMA, maxDepth: 10 }); } catch (err) { return [`${source}: YAML parse error: ${err.message}`]; @@ -154,6 +188,19 @@ export function validate(text, source = 'landscape.yml') { return [`${source}: YAML anchors/aliases are not allowed`]; } + // Preflight the item count cheaply (list lengths only) so a file with far too many items + // is rejected before the detailed, allocation-heavy validation below runs on all of them. + let itemTotal = 0; + for (const category of categories) { + if (!isPlainObject(category) || !Array.isArray(category.subcategories)) continue; + for (const sub of category.subcategories) { + if (isPlainObject(sub) && Array.isArray(sub.items)) itemTotal += sub.items.length; + } + } + if (itemTotal > MAX_TOTAL_ITEMS) { + return [`${source}: landscape has ${itemTotal} items, more than the ${MAX_TOTAL_ITEMS} allowed`]; + } + const errors = []; unexpectedKeys(data, new Set(['landscape']), source, errors); @@ -163,52 +210,62 @@ export function validate(text, source = 'landscape.yml') { categories.forEach((category, categoryIndex) => { if (!isPlainObject(category) || !Object.hasOwn(category, 'category') || !isNonEmptyString(category.category)) { - errors.push(`landscape[${categoryIndex}]: missing 'category' name`); + addError(errors, `landscape[${categoryIndex}]: missing 'category' name`); return; } const categoryName = category.category; + const categoryProblem = displayStringProblem(categoryName, DISPLAY_NAME_MAX); + if (categoryProblem) { + addError(errors, `landscape[${categoryIndex}]: category name ${categoryProblem}`); + return; + } unexpectedKeys(category, ALLOWED_CATEGORY_FIELDS, `category '${categoryName}'`, errors); const categoryKey = normalizeKey(categoryName); if (seenCategories.has(categoryKey)) { - errors.push(`category '${categoryName}': duplicate category name (also at ${seenCategories.get(categoryKey)})`); + addError(errors, `category '${categoryName}': duplicate category name (also at ${seenCategories.get(categoryKey)})`); } else { seenCategories.set(categoryKey, `landscape[${categoryIndex}]`); } if (!Object.hasOwn(category, 'subcategories') || !Array.isArray(category.subcategories)) { - errors.push(`category '${categoryName}': 'subcategories' must be a list`); + addError(errors, `category '${categoryName}': 'subcategories' must be a list`); return; } if (category.subcategories.length === 0) { - errors.push(`category '${categoryName}': must contain at least one subcategory`); + addError(errors, `category '${categoryName}': must contain at least one subcategory`); } const seenSubcategories = new Map(); category.subcategories.forEach((subcategory, subcategoryIndex) => { if (!isPlainObject(subcategory) || !Object.hasOwn(subcategory, 'subcategory') || !isNonEmptyString(subcategory.subcategory)) { - errors.push(`category '${categoryName}': subcategory[${subcategoryIndex}] missing 'subcategory' name`); + addError(errors, `category '${categoryName}': subcategory[${subcategoryIndex}] missing 'subcategory' name`); return; } const subcategoryName = subcategory.subcategory; + const subcategoryProblem = displayStringProblem(subcategoryName, DISPLAY_NAME_MAX); + if (subcategoryProblem) { + addError(errors, `'${categoryName}' / subcategory[${subcategoryIndex}]: name ${subcategoryProblem}`); + return; + } unexpectedKeys(subcategory, ALLOWED_SUBCATEGORY_FIELDS, `'${categoryName}' / '${subcategoryName}'`, errors); const subcategoryKey = normalizeKey(subcategoryName); if (seenSubcategories.has(subcategoryKey)) { - errors.push(`'${categoryName}' / '${subcategoryName}': duplicate subcategory name in this category`); + addError(errors, `'${categoryName}' / '${subcategoryName}': duplicate subcategory name in this category`); } else { seenSubcategories.set(subcategoryKey, subcategoryIndex); } if (!Object.hasOwn(subcategory, 'items') || !Array.isArray(subcategory.items)) { - errors.push(`'${categoryName}' / '${subcategoryName}': 'items' must be a list`); + addError(errors, `'${categoryName}' / '${subcategoryName}': 'items' must be a list`); return; } if (subcategory.items.length === 0) { - errors.push(`'${categoryName}' / '${subcategoryName}': must contain at least one item`); + addError(errors, `'${categoryName}' / '${subcategoryName}': must contain at least one item`); } subcategory.items.forEach((item, itemIndex) => { itemCount += 1; let location = `'${categoryName}' / '${subcategoryName}' / item[${itemIndex}]`; if (!isPlainObject(item)) { - errors.push(`${location}: item must be a mapping`); + addError(errors, `${location}: item must be a mapping`); return; } if (Object.hasOwn(item, 'name') && isNonEmptyString(item.name)) { @@ -217,34 +274,37 @@ export function validate(text, source = 'landscape.yml') { for (const field of REQUIRED_FIELDS) { if (!Object.hasOwn(item, field) || !isNonEmptyString(item[field])) { - errors.push(`${location}: missing or empty required field '${field}'`); + addError(errors, `${location}: missing or empty required field '${field}'`); } } for (const [field, max] of Object.entries(LENGTH_LIMITS)) { if (Object.hasOwn(item, field) && typeof item[field] === 'string' && item[field].length > max) { - errors.push(`${location}: ${field} is longer than ${max} characters`); + addError(errors, `${location}: ${field} is longer than ${max} characters`); } } + if (Object.hasOwn(item, 'name') && isNonEmptyString(item.name) && CONTROL_OR_FORMAT.test(item.name)) { + addError(errors, `${location}: name contains control or format characters`); + } if (Object.hasOwn(item, 'project') && isNonEmptyString(item.project) && !PROJECT_VALUES.has(item.project)) { - errors.push(`${location}: project '${item.project}' is not one of ${[...PROJECT_VALUES].sort().join(', ')}`); + addError(errors, `${location}: project '${item.project}' is not one of ${[...PROJECT_VALUES].sort().join(', ')}`); } if (Object.hasOwn(item, 'homepage_url') && isNonEmptyString(item.homepage_url)) { const problem = urlProblem('homepage_url', item.homepage_url); - if (problem) errors.push(`${location}: ${problem}`); + if (problem) addError(errors, `${location}: ${problem}`); } if (Object.hasOwn(item, 'repo_url')) { if (!isNonEmptyString(item.repo_url)) { - errors.push(`${location}: repo_url is present but empty`); + addError(errors, `${location}: repo_url is present but empty`); } else { const problem = urlProblem('repo_url', item.repo_url); - if (problem) errors.push(`${location}: ${problem}`); + if (problem) addError(errors, `${location}: ${problem}`); } } unexpectedKeys(item, ALLOWED_ITEM_FIELDS, location, errors); if (Object.hasOwn(item, 'name') && isNonEmptyString(item.name)) { const nameKey = normalizeKey(item.name); if (seenNames.has(nameKey)) { - errors.push(`${location}: duplicate entry name (also at ${seenNames.get(nameKey)})`); + addError(errors, `${location}: duplicate entry name (also at ${seenNames.get(nameKey)})`); } else { seenNames.set(nameKey, location); } @@ -254,10 +314,7 @@ export function validate(text, source = 'landscape.yml') { }); if (itemCount === 0) { - errors.push(`${source}: landscape contains no items`); - } - if (itemCount > MAX_TOTAL_ITEMS) { - errors.push(`${source}: landscape has ${itemCount} items, more than the ${MAX_TOTAL_ITEMS} allowed`); + addError(errors, `${source}: landscape contains no items`); } return errors; } @@ -273,7 +330,8 @@ function main() { } const errors = validate(text, path); if (errors.length > 0) { - console.error(`landscape validation failed with ${errors.length} problem(s):`); + const count = errors.length >= MAX_ERRORS ? `${MAX_ERRORS}+ (reporting capped)` : `${errors.length}`; + console.error(`landscape validation failed with ${count} problem(s):`); for (const error of errors) { console.error(` - ${error}`); } diff --git a/scripts/validate-landscape.test.mjs b/scripts/validate-landscape.test.mjs index d058c61..0d84d01 100644 --- a/scripts/validate-landscape.test.mjs +++ b/scripts/validate-landscape.test.mjs @@ -217,11 +217,48 @@ test('a single over-long description is rejected', () => { test('too many items are rejected', () => { let items = ''; - for (let i = 0; i <= 5000; i++) { + for (let i = 0; i <= 500; i++) { items += ` - {name: n${i}, homepage_url: "https://x.example/${i}", description: d, project: member}\n`; } const doc = `landscape:\n - category: C\n subcategories:\n - subcategory: S\n items:\n${items}`; - assert.ok(hasError(validate(doc), /more than the 5000 allowed/)); + assert.ok(hasError(validate(doc), /more than the 500 allowed/)); +}); + +test('a numeric scalar stays a string under the failsafe schema (browser parity)', () => { + // name: 789 must not become a number here while the browser (same options) keeps it a + // string; both parse it as "789", so the search .toLowerCase() cannot throw on it. + const doc = VALID.replace('name: goose', 'name: 789'); + assert.deepEqual(validate(doc), []); +}); + +test('the browser parses with the same failsafe options as the validator', () => { + // Guards the CI/browser parser parity: app.js must load YAML with FAILSAFE_SCHEMA so a + // numeric or timestamp scalar cannot diverge between validation and the rendered site. + const appjs = readFileSync(fileURLToPath(new URL('../landscape/static/app.js', import.meta.url)), 'utf8'); + assert.match(appjs, /jsyaml\.load\([^)]*FAILSAFE_SCHEMA/s); +}); + +test('an over-long category name is rejected before it inflates errors', () => { + const doc = VALID.replace('category: Frameworks', `category: ${'A'.repeat(200)}`); + assert.ok(hasError(validate(doc), /category name is longer than/)); +}); + +test('the number of reported errors is capped', () => { + let items = ''; + for (let i = 0; i < 300; i++) items += ' - {}\n'; + const doc = `landscape:\n - category: C\n subcategories:\n - subcategory: S\n items:\n${items}`; + const errors = validate(doc); + assert.ok(errors.length <= 200, `expected the error count to be capped at 200, got ${errors.length}`); +}); + +test('a control or format character in a name is rejected', () => { + const doc = VALID.replace('name: goose', 'name: "goo\\u200bse"'); + assert.ok(hasError(validate(doc), /control or format characters/)); +}); + +test('an over-long logo is rejected', () => { + const doc = VALID.replace('logo: placeholder.svg', `logo: ${'x'.repeat(400)}`); + assert.ok(hasError(validate(doc), /logo is longer than/)); }); test('a url with embedded credentials is rejected', () => { From 04c104e645788dbb0de03dfb37e746df8778ec0e Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:25:48 +0800 Subject: [PATCH 07/16] Reject non-string and oversized item fields before building diagnostics The review found two validator holes. `logo` was only length-checked when it was already a string, so a sub-2MB document could set it to a sequence of hundreds of thousands of distinct empty mappings that passed validation and then deployed to every browser. And the error cap only bounded stored output, not the construction of error strings, so an oversized scalar alias in `project` (uncapped), `name`, a URL, or an unknown key was interpolated and normalized before it could be discarded. Add a per-item preflight that checks every schema field's type and length (and the name's control characters) using only the bounded item index, and skip the rest of the item when a field fails, so no oversized or non-string value reaches a location, normalizeKey, the URL parser, or a diagnostic. Cap `project` length; escape and bound the value shown in the project and unknown-field diagnostics; and require the literal `https://` prefix, since the WHATWG parser canonicalizes forms like `https:host`. Fix the schema-doc example, which was missing the top-level `landscape:` key, and state that item names are unique across the whole landscape. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- docs/data-schemas.md | 23 +++++++------ scripts/validate-landscape.mjs | 53 ++++++++++++++++++++++------- scripts/validate-landscape.test.mjs | 42 +++++++++++++++++++---- 3 files changed, 89 insertions(+), 29 deletions(-) diff --git a/docs/data-schemas.md b/docs/data-schemas.md index d6f6556..11a2e23 100644 --- a/docs/data-schemas.md +++ b/docs/data-schemas.md @@ -51,17 +51,20 @@ Each entry in the master taxonomy array is a JavaScript object representing a co The landscape configuration follows a hierarchical CNCF-style structure. Each root category node contains subcategories, which contain individual items: ```yaml -- category: Security Guardrails & Firewalls - subcategories: - - subcategory: Prompt & Runtime Guardrails - items: - - name: Google Cloud Model Armor - homepage_url: https://cloud.google.com/security/products/model-armor - repo_url: https://github.com/... (optional) - description: Enterprise security service providing prompt injection defense... - project: member +landscape: + - category: Security Guardrails & Firewalls + subcategories: + - subcategory: Prompt & Runtime Guardrails + items: + - name: Google Cloud Model Armor + homepage_url: https://cloud.google.com/security/products/model-armor + repo_url: https://github.com/... (optional) + description: Enterprise security service providing prompt injection defense... + project: member ``` +The file has a single top-level `landscape:` key holding the list of categories. + ### Landscape Item Field Specifications * **`name`** *(String, Required):* The official name of the tool, framework, protocol, or standard. * **`homepage_url`** *(String, Required):* The landing page URL of the project. @@ -79,7 +82,7 @@ The landscape configuration follows a hierarchical CNCF-style structure. Each ro ### Landscape Structural Rules * Each **`category`** requires a non-empty `category` name and a non-empty `subcategories` list; each **`subcategory`** requires a non-empty `subcategory` name and a non-empty `items` list. -* Category, subcategory, and item names must be unique within their scope (compared case- and Unicode-normalization-insensitively). +* Category names and item names must each be unique across the whole landscape; subcategory names must be unique within their category. Names are compared case- and Unicode-normalization-insensitively. ### Landscape Validation Limits `scripts/validate-landscape.mjs` runs in CI with the same js-yaml parser and options the site loads with, and enforces the following so a malformed or hostile file cannot break the rendered map or the validator itself: diff --git a/scripts/validate-landscape.mjs b/scripts/validate-landscape.mjs index 43338fb..cf4bbeb 100644 --- a/scripts/validate-landscape.mjs +++ b/scripts/validate-landscape.mjs @@ -64,6 +64,7 @@ const LENGTH_LIMITS = { homepage_url: 2_048, repo_url: 2_048, logo: 300, + project: 50, }; // Control and format characters (zero-width joiners, bidi overrides, other C0/C1) render @@ -131,7 +132,10 @@ function hasReusedNode(root) { function unexpectedKeys(object, allowed, location, errors) { for (const key of Object.keys(object)) { if (!allowed.has(key)) { - addError(errors, `${location}: unknown field '${key}'`); + // Bound and escape the key: an unknown key can be arbitrarily long (or carry control + // characters), and it would otherwise be echoed verbatim into the error and the CI log. + const shown = key.length > 100 ? `${key.slice(0, 100)}…` : key; + addError(errors, `${location}: unknown field ${JSON.stringify(shown)}`); } } } @@ -140,6 +144,12 @@ function urlProblem(field, value) { if (/\s/.test(value)) { return `${field} must not contain whitespace (got ${JSON.stringify(value)})`; } + // Require the literal scheme: the WHATWG URL parser canonicalizes forms like + // "https:example.com" or "https:\\host" to an https: URL, so the protocol check below is + // not enough to enforce the documented "must start with https://". + if (!value.startsWith('https://')) { + return `${field} must start with https:// (got ${JSON.stringify(value)})`; + } let parsed; try { parsed = new URL(value); @@ -263,11 +273,38 @@ export function validate(text, source = 'landscape.yml') { subcategory.items.forEach((item, itemIndex) => { itemCount += 1; - let location = `'${categoryName}' / '${subcategoryName}' / item[${itemIndex}]`; + const baseLocation = `'${categoryName}' / '${subcategoryName}' / item[${itemIndex}]`; if (!isPlainObject(item)) { - addError(errors, `${location}: item must be a mapping`); + addError(errors, `${baseLocation}: item must be a mapping`); return; } + + // Preflight every schema field for type and length before anything interpolates a + // value into a location, normalizes it, or hands it to the URL parser. This rejects a + // non-string field (for example a `logo` object graph) and an oversized scalar (for + // example a reused alias) using only the bounded baseLocation, so a hostile value + // cannot materialize a giant diagnostic or traversal even while the file stays under + // the byte and item caps. + let bounded = true; + for (const [field, max] of Object.entries(LENGTH_LIMITS)) { + if (!Object.hasOwn(item, field)) continue; + if (typeof item[field] !== 'string') { + addError(errors, `${baseLocation}: ${field} must be a string`); + bounded = false; + } else if (item[field].length > max) { + addError(errors, `${baseLocation}: ${field} is longer than ${max} characters`); + bounded = false; + } + } + // Reject a control/format character in the name here too, before it is interpolated + // into a location that later messages (and the CI log) would echo. + if (Object.hasOwn(item, 'name') && typeof item.name === 'string' && CONTROL_OR_FORMAT.test(item.name)) { + addError(errors, `${baseLocation}: name contains control or format characters`); + bounded = false; + } + if (!bounded) return; + + let location = baseLocation; if (Object.hasOwn(item, 'name') && isNonEmptyString(item.name)) { location = `'${categoryName}' / '${subcategoryName}' / '${item.name}'`; } @@ -277,16 +314,8 @@ export function validate(text, source = 'landscape.yml') { addError(errors, `${location}: missing or empty required field '${field}'`); } } - for (const [field, max] of Object.entries(LENGTH_LIMITS)) { - if (Object.hasOwn(item, field) && typeof item[field] === 'string' && item[field].length > max) { - addError(errors, `${location}: ${field} is longer than ${max} characters`); - } - } - if (Object.hasOwn(item, 'name') && isNonEmptyString(item.name) && CONTROL_OR_FORMAT.test(item.name)) { - addError(errors, `${location}: name contains control or format characters`); - } if (Object.hasOwn(item, 'project') && isNonEmptyString(item.project) && !PROJECT_VALUES.has(item.project)) { - addError(errors, `${location}: project '${item.project}' is not one of ${[...PROJECT_VALUES].sort().join(', ')}`); + addError(errors, `${location}: project ${JSON.stringify(item.project)} is not one of ${[...PROJECT_VALUES].sort().join(', ')}`); } if (Object.hasOwn(item, 'homepage_url') && isNonEmptyString(item.homepage_url)) { const problem = urlProblem('homepage_url', item.homepage_url); diff --git a/scripts/validate-landscape.test.mjs b/scripts/validate-landscape.test.mjs index 0d84d01..e424d7a 100644 --- a/scripts/validate-landscape.test.mjs +++ b/scripts/validate-landscape.test.mjs @@ -51,12 +51,12 @@ test('a missing required field is caught', () => { test('an invalid project value is caught', () => { const doc = VALID.replace('project: member', 'project: hosted'); - assert.ok(hasError(validate(doc), /project 'hosted' is not one of/)); + assert.ok(hasError(validate(doc), /project "hosted" is not one of/)); }); test('a non-https url is caught', () => { const doc = VALID.replace('https://goose-docs.ai/', 'http://goose-docs.ai/'); - assert.ok(hasError(validate(doc), /homepage_url must use https/)); + assert.ok(hasError(validate(doc), /homepage_url must start with https/)); }); test('a url with whitespace is caught', () => { @@ -66,7 +66,7 @@ test('a url with whitespace is caught', () => { test('a misspelled optional field is caught', () => { const doc = VALID.replace('repo_url:', 'reop_url:'); - assert.ok(hasError(validate(doc), /unknown field 'reop_url'/)); + assert.ok(hasError(validate(doc), /unknown field "reop_url"/)); }); test('duplicate entry names are caught', () => { @@ -138,7 +138,7 @@ test('an unexpected field on a subcategory is caught', () => { items: - {name: y, homepage_url: "https://x.example", description: d, project: member} `; - assert.ok(hasError(validate(doc), /unknown field 'bogus_sub_field'/)); + assert.ok(hasError(validate(doc), /unknown field "bogus_sub_field"/)); }); test('an empty repo_url is caught', () => { @@ -148,7 +148,35 @@ test('an empty repo_url is caught', () => { test('a non-https repo_url is caught', () => { const doc = VALID.replace('https://github.com/aaif-goose/goose', 'http://github.com/aaif-goose/goose'); - assert.ok(hasError(validate(doc), /repo_url must use https/)); + assert.ok(hasError(validate(doc), /repo_url must start with https/)); +}); + +test('a logo that is not a string is rejected (no object graph passes)', () => { + const doc = VALID.replace('logo: placeholder.svg', 'logo: [{}, {}, {}]'); + assert.ok(hasError(validate(doc), /logo must be a string/)); +}); + +test('an oversized project scalar is rejected before its value is interpolated', () => { + const doc = VALID.replace('project: member', `project: ${'x'.repeat(100)}`); + assert.ok(hasError(validate(doc), /project is longer than/)); +}); + +test('an oversized name is rejected before a giant location is built', () => { + const doc = VALID.replace('name: goose', `name: ${'n'.repeat(300)}`); + assert.ok(hasError(validate(doc), /name is longer than/)); +}); + +test('a scheme-relative https url without // is rejected', () => { + const doc = VALID.replace('https://goose-docs.ai/', 'https:goose-docs.ai/'); + assert.ok(hasError(validate(doc), /must start with https/)); +}); + +test('an oversized unknown field key is bounded in the diagnostic', () => { + const bigKey = 'z'.repeat(5000); + const doc = `landscape:\n - category: C\n subcategories:\n - subcategory: S\n items:\n - {name: n, homepage_url: https://x.example, description: d, project: member, ${bigKey}: v}\n`; + const errs = validate(doc); + assert.ok(errs.some((e) => /unknown field/.test(e)), 'the unknown key is flagged'); + assert.ok(errs.every((e) => e.length < 300), 'no error echoes the full 5000-char key'); }); test('an empty subcategories list is rejected', () => { @@ -164,11 +192,11 @@ test('an unexpected field on a category is caught', () => { const doc = VALID + ' - category: Extra\n typo_field: hidden\n subcategories:\n - subcategory: S\n items:\n - {name: z, homepage_url: "https://x.example", description: d, project: member}\n'; - assert.ok(hasError(validate(doc), /unknown field 'typo_field'/)); + assert.ok(hasError(validate(doc), /unknown field "typo_field"/)); }); test('an unexpected top-level field is caught', () => { - assert.ok(hasError(validate('metadata: hidden\n' + VALID), /unknown field 'metadata'/)); + assert.ok(hasError(validate('metadata: hidden\n' + VALID), /unknown field "metadata"/)); }); test('an inline merge key is rejected (failsafe schema does not merge)', () => { From 1250d4ee7c833e59995e5e628064d43bb0e9dda0 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:35:31 +0800 Subject: [PATCH 08/16] Align the validator's stated guarantees with its behavior Review feedback, all making the code match its own claims: - Bound YAML parse-error messages too. js-yaml puts the offending token (for example an undefined alias name) into the message, so an oversized token bypassed the MAX_ERROR_LENGTH cap that every other diagnostic respects. - Reword the alias-rejection message and the docs. hasReusedNode rejects reused object/array nodes and cycles, not scalar aliases (which stay within the field and total limits), so "anchors/aliases are rejected" overstated it. - Reject control/format characters in descriptions, not only names, since descriptions are rendered too and the comment already claimed "any displayed string". Fold the type, length, and control-character checks into one pass so the control check runs only on a value already within its length cap. - Document the project length limit (50). Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- docs/data-schemas.md | 6 +++--- scripts/validate-landscape.mjs | 28 +++++++++++++++++--------- scripts/validate-landscape.test.mjs | 31 ++++++++++++++++++++++++++++- 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/docs/data-schemas.md b/docs/data-schemas.md index 11a2e23..65e99fb 100644 --- a/docs/data-schemas.md +++ b/docs/data-schemas.md @@ -87,11 +87,11 @@ The file has a single top-level `landscape:` key holding the list of categories. ### Landscape Validation Limits `scripts/validate-landscape.mjs` runs in CI with the same js-yaml parser and options the site loads with, and enforces the following so a malformed or hostile file cannot break the rendered map or the validator itself: -* **Parsing:** the file is parsed with `FAILSAFE_SCHEMA`, so every scalar is a string — a bare `123` or `2026-01-01` is read as text, matching the browser. YAML anchors, aliases, and merge (`<<`) keys are rejected, and nesting depth and file size (2 MB) are bounded. -* **Field lengths:** `name` ≤ 200, `description` ≤ 2000, `homepage_url` / `repo_url` ≤ 2048, `logo` ≤ 300, and `category` / `subcategory` names ≤ 120 characters. +* **Parsing:** the file is parsed with `FAILSAFE_SCHEMA`, so every scalar is a string — a bare `123` or `2026-01-01` is read as text, matching the browser. Reused object or array nodes (YAML aliases or cycles) and merge (`<<`) keys are rejected; a scalar alias is allowed but stays within the per-field and total limits below. Nesting depth and file size (2 MB) are bounded. +* **Field lengths:** `name` ≤ 200, `description` ≤ 2000, `project` ≤ 50, `homepage_url` / `repo_url` ≤ 2048, `logo` ≤ 300, and `category` / `subcategory` names ≤ 120 characters. * **Cardinality:** at most 500 items across the whole landscape. * **URLs:** `homepage_url` and `repo_url` must be `https://`, contain no whitespace, and carry no embedded credentials. * **Fields:** only the fields documented above are allowed at each level; any other key is rejected. -* **Characters:** display names must not contain control or format characters (for example zero-width or bidirectional-override characters). +* **Characters:** display names and item descriptions must not contain control or format characters (for example zero-width or bidirectional-override characters). --- diff --git a/scripts/validate-landscape.mjs b/scripts/validate-landscape.mjs index cf4bbeb..5900558 100644 --- a/scripts/validate-landscape.mjs +++ b/scripts/validate-landscape.mjs @@ -181,7 +181,12 @@ export function validate(text, source = 'landscape.yml') { // maxDepth bounds nesting. app.js parses with these same options. data = yaml.load(text, { schema: yaml.FAILSAFE_SCHEMA, maxDepth: 10 }); } catch (err) { - return [`${source}: YAML parse error: ${err.message}`]; + // Bound the parser's message too: js-yaml puts the offending token into it (for example + // an undefined alias name), so an oversized token would otherwise flood the output past + // the MAX_ERROR_LENGTH cap that every other diagnostic respects. + const reason = err instanceof Error ? err.message : String(err); + const message = `${source}: YAML parse error: ${reason}`; + return [message.length > MAX_ERROR_LENGTH ? `${message.slice(0, MAX_ERROR_LENGTH)}…` : message]; } if (!isPlainObject(data) || !Object.hasOwn(data, 'landscape')) { @@ -195,7 +200,7 @@ export function validate(text, source = 'landscape.yml') { return [`${source}: 'landscape' must contain at least one category`]; } if (hasReusedNode(data)) { - return [`${source}: YAML anchors/aliases are not allowed`]; + return [`${source}: reused object or array nodes (YAML aliases or cycles) are not allowed`]; } // Preflight the item count cheaply (list lengths only) so a file with far too many items @@ -291,16 +296,21 @@ export function validate(text, source = 'landscape.yml') { if (typeof item[field] !== 'string') { addError(errors, `${baseLocation}: ${field} must be a string`); bounded = false; - } else if (item[field].length > max) { + continue; + } + if (item[field].length > max) { addError(errors, `${baseLocation}: ${field} is longer than ${max} characters`); bounded = false; + continue; + } + // Names and descriptions are rendered directly, so reject control/format characters + // (zero-width, bidi) that would let them spoof. This runs only after the value is + // known to be within its length cap, so the test cost stays bounded even for a + // scalar alias reused across many items. + if ((field === 'name' || field === 'description') && CONTROL_OR_FORMAT.test(item[field])) { + addError(errors, `${baseLocation}: ${field} contains control or format characters`); + bounded = false; } - } - // Reject a control/format character in the name here too, before it is interpolated - // into a location that later messages (and the CI log) would echo. - if (Object.hasOwn(item, 'name') && typeof item.name === 'string' && CONTROL_OR_FORMAT.test(item.name)) { - addError(errors, `${baseLocation}: name contains control or format characters`); - bounded = false; } if (!bounded) return; diff --git a/scripts/validate-landscape.test.mjs b/scripts/validate-landscape.test.mjs index e424d7a..2c1a180 100644 --- a/scripts/validate-landscape.test.mjs +++ b/scripts/validate-landscape.test.mjs @@ -121,7 +121,7 @@ test('nested YAML aliases are rejected before they can amplify', () => { - *s - *c `; - assert.ok(hasError(validate(doc), /anchors\/aliases are not allowed/)); + assert.ok(hasError(validate(doc), /reused object or array nodes .* are not allowed/)); }); test('a file larger than the byte cap is rejected', () => { @@ -179,6 +179,35 @@ test('an oversized unknown field key is bounded in the diagnostic', () => { assert.ok(errs.every((e) => e.length < 300), 'no error echoes the full 5000-char key'); }); +test('an oversized YAML parse error is bounded', () => { + const errors = validate('*' + 'a'.repeat(100_000)); + assert.equal(errors.length, 1); + assert.ok(errors[0].length <= 520, `parse error should be bounded, got ${errors[0].length}`); +}); + +test('a bounded scalar alias is allowed, matching the documented policy', () => { + const doc = `landscape: + - category: C + subcategories: + - subcategory: S + items: + - name: one + homepage_url: https://example.com/one + description: &d shared text + project: member + - name: two + homepage_url: https://example.com/two + description: *d + project: member +`; + assert.deepEqual(validate(doc), []); +}); + +test('a bidi control character in a description is rejected', () => { + const doc = VALID.replace('description: An open agent.', 'description: "Trusted \\u202e project"'); + assert.ok(hasError(validate(doc), /description contains control or format characters/)); +}); + test('an empty subcategories list is rejected', () => { assert.ok(hasError(validate('landscape:\n - category: C\n subcategories: []\n'), /at least one subcategory/)); }); From 9715ad476ce3e0b8ea93f2cfd08c6e633f6a498b Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:50:01 +0800 Subject: [PATCH 09/16] Highlight search results with the same normalized query the filter used renderLandscape read state.currentSearch raw while runFilteringPipeline filtered on the lower-cased, trimmed query, so the two could disagree. A search with leading or trailing spaces filtered items in but highlighted nothing, and a whitespace-only search kept every item while building a regex from the raw spaces for each rendered field. Pass the normalized query into renderLandscape so highlighting matches what was filtered. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- landscape/static/app.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/landscape/static/app.js b/landscape/static/app.js index 3c9ae2c..0bab893 100644 --- a/landscape/static/app.js +++ b/landscape/static/app.js @@ -81,8 +81,10 @@ document.addEventListener('DOMContentLoaded', () => { } } - // Render Landscape Grid - function renderLandscape() { + // Render Landscape Grid. `query` is the already-normalized search string from + // runFilteringPipeline (lower-cased and trimmed); highlighting must use the same value the + // filter matched on, so it is passed in rather than re-read from state here. + function renderLandscape(query) { landscapeGrid.replaceChildren(); let totalItems = 0; @@ -101,8 +103,6 @@ document.addEventListener('DOMContentLoaded', () => { return; } - const query = state.currentSearch; - state.filteredCategories.forEach(catObj => { const catGroup = document.createElement('section'); catGroup.className = 'landscape-category-group'; @@ -235,7 +235,10 @@ document.addEventListener('DOMContentLoaded', () => { }; }).filter(Boolean); - renderLandscape(); + // Render with the same normalized query the filter used above, so highlighting matches + // exactly what was filtered (a query that is only whitespace trims to empty here, so it + // filters nothing out and highlights nothing, instead of building a regex from raw spaces). + renderLandscape(query); } // Fetch landscape.yml and Initialize From e3cf54e69e00afe7947b35a9a11d19a9a5454100 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:56:25 +0800 Subject: [PATCH 10/16] Bound the parsed object graph, harden the highlighter, and fix docs Add a container and edge budget to the graph walk so a wide unknown key (for example a large top-level list of empty mappings, which does not count toward the item cap) is rejected once it exceeds 5000 objects instead of being walked in full; the reused-node check is folded into the same pass. Check the file size with statSync before reading it in the CLI so an oversized file is not read into memory first, while validate() keeps its own byte check for unit tests and other callers. Replace the highlighter's String.split with a bounded RegExp.exec walk, so a long value with many matches no longer allocates a full fragment array before the node cap applies, and cap the search input length. Fix the docs schema example, whose repo_url carried an inline "(optional)" the validator rejects as whitespace in a URL; label the repository link "Repository" rather than "GitHub" since GitLab and others are allowed; and correct the js-yaml comment's affected range. Add tests for the object budget and that the docs example validates. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- docs/data-schemas.md | 4 +- landscape/static/app.js | 50 +++++++++++++---------- landscape/static/index.html | 4 +- scripts/validate-landscape.mjs | 62 ++++++++++++++++++++--------- scripts/validate-landscape.test.mjs | 16 ++++++++ 5 files changed, 93 insertions(+), 43 deletions(-) diff --git a/docs/data-schemas.md b/docs/data-schemas.md index 65e99fb..0648ae7 100644 --- a/docs/data-schemas.md +++ b/docs/data-schemas.md @@ -58,7 +58,7 @@ landscape: items: - name: Google Cloud Model Armor homepage_url: https://cloud.google.com/security/products/model-armor - repo_url: https://github.com/... (optional) + repo_url: https://github.com/example/project # optional description: Enterprise security service providing prompt injection defense... project: member ``` @@ -89,7 +89,7 @@ The file has a single top-level `landscape:` key holding the list of categories. * **Parsing:** the file is parsed with `FAILSAFE_SCHEMA`, so every scalar is a string — a bare `123` or `2026-01-01` is read as text, matching the browser. Reused object or array nodes (YAML aliases or cycles) and merge (`<<`) keys are rejected; a scalar alias is allowed but stays within the per-field and total limits below. Nesting depth and file size (2 MB) are bounded. * **Field lengths:** `name` ≤ 200, `description` ≤ 2000, `project` ≤ 50, `homepage_url` / `repo_url` ≤ 2048, `logo` ≤ 300, and `category` / `subcategory` names ≤ 120 characters. -* **Cardinality:** at most 500 items across the whole landscape. +* **Cardinality:** at most 500 items across the whole landscape, and at most 5000 objects or arrays in the whole document (a budget that stops the graph walk early on a hostile file). * **URLs:** `homepage_url` and `repo_url` must be `https://`, contain no whitespace, and carry no embedded credentials. * **Fields:** only the fields documented above are allowed at each level; any other key is rejected. * **Characters:** display names and item descriptions must not contain control or format characters (for example zero-width or bidirectional-override characters). diff --git a/landscape/static/app.js b/landscape/static/app.js index 0bab893..f057349 100644 --- a/landscape/static/app.js +++ b/landscape/static/app.js @@ -28,28 +28,36 @@ document.addEventListener('DOMContentLoaded', () => { } try { const escapedQuery = query.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); - const regex = new RegExp(`(${escapedQuery})`, 'gi'); - const parts = text.split(regex); - - // Bound the nodes a single field can create. A long value with many matches would - // otherwise produce one node per fragment; after the cap, append the remainder as a - // single text node so the field still renders in full but cannot flood the DOM. - const MAX_HIGHLIGHT_NODES = 100; - const lowerQuery = query.toLowerCase(); - for (let i = 0; i < parts.length; i += 1) { - if (i >= MAX_HIGHLIGHT_NODES) { - parentElement.appendChild(document.createTextNode(parts.slice(i).join(''))); - break; + const regex = new RegExp(escapedQuery, 'gi'); + + // Walk matches with exec() and stop after MAX_MATCHES, rather than splitting the whole + // string into a fragment array first: split() would allocate one entry per match up + // front (a long value with many matches builds a large array before any cap applies). + // Here only the matched slices and the surrounding gaps become nodes, and the remainder + // after the cap is appended as a single text node so the field still renders in full. + const MAX_MATCHES = 100; + let cursor = 0; + let count = 0; + let match; + while (count < MAX_MATCHES && (match = regex.exec(text)) !== null) { + // A zero-length match cannot advance lastIndex on its own and would loop forever; the + // empty query is already handled above, but guard against a pathological pattern anyway. + if (match.index === regex.lastIndex) { + regex.lastIndex += 1; + continue; } - const part = parts[i]; - if (part.toLowerCase() === lowerQuery) { - const mark = document.createElement('mark'); - mark.className = 'match-highlight'; - mark.textContent = part; - parentElement.appendChild(mark); - } else if (part) { - parentElement.appendChild(document.createTextNode(part)); + if (match.index > cursor) { + parentElement.appendChild(document.createTextNode(text.slice(cursor, match.index))); } + const mark = document.createElement('mark'); + mark.className = 'match-highlight'; + mark.textContent = match[0]; + parentElement.appendChild(mark); + cursor = match.index + match[0].length; + count += 1; + } + if (cursor < text.length) { + parentElement.appendChild(document.createTextNode(text.slice(cursor))); } } catch (e) { parentElement.textContent = text; @@ -171,7 +179,7 @@ document.addEventListener('DOMContentLoaded', () => { repoLink.setAttribute('href', item.repo_url); repoLink.setAttribute('target', '_blank'); repoLink.setAttribute('rel', 'noopener noreferrer'); - repoLink.textContent = 'GitHub ↗'; + repoLink.textContent = 'Repository ↗'; cardLinks.appendChild(repoLink); } diff --git a/landscape/static/index.html b/landscape/static/index.html index 9c898f2..762cf92 100644 --- a/landscape/static/index.html +++ b/landscape/static/index.html @@ -14,7 +14,7 @@ - + @@ -41,7 +41,7 @@

Ecosystem Landscape Map

- + diff --git a/scripts/validate-landscape.mjs b/scripts/validate-landscape.mjs index 5900558..a079bd6 100644 --- a/scripts/validate-landscape.mjs +++ b/scripts/validate-landscape.mjs @@ -26,7 +26,7 @@ * * Usage: node validate-landscape.mjs [path/to/landscape.yml] */ -import { readFileSync } from 'node:fs'; +import { readFileSync, statSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import yaml from 'js-yaml'; @@ -102,31 +102,49 @@ function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } -// YAML anchors/aliases resolve to shared references, so a small file can expand -// into a huge traversal: N reused category nodes, each holding N reused -// subcategory nodes, each holding N reused item nodes, is N^3 item visits here -// (and N^3 DOM nodes in the browser) from ~3N lines of input. The landscape -// schema never needs anchors, aliases, or merge keys, so reject any object or -// array that appears more than once. (A scalar alias shares a primitive, not an -// object, so it slips past this WeakSet; the per-field length caps and the total -// item cap bound those, and the FAILSAFE parse schema disables merge keys.) Iterative -// (an explicit stack, not recursion) so a deeply nested file cannot overflow the call -// stack, and a self-referential alias terminates on the WeakSet hit rather than looping. -function hasReusedNode(root) { +// Bound the whole parsed object graph, not just the schema-relevant parts. A file under +// MAX_BYTES can still materialize a very wide graph (for example a large unknown top-level key +// full of empty mappings) that js-yaml builds and this validator would then walk. graphProblem +// walks once, iteratively (an explicit stack, not recursion, so a deeply nested file cannot +// overflow the call stack), and returns a problem string as soon as it exceeds a budget or +// revisits a node, so the walk stops early instead of traversing the whole thing. +// +// It also rejects reused nodes: YAML anchors/aliases resolve to shared references, so a small +// file can expand into an N^3 traversal (and N^3 DOM nodes in the browser) from ~3N lines. The +// landscape schema never needs anchors, aliases, or merge keys, so any object or array seen +// more than once is rejected. (A scalar alias shares a primitive, not an object, so it slips +// past the WeakSet; the per-field length caps and the item cap bound those, and the FAILSAFE +// parse schema disables merge keys.) A self-referential alias terminates on the WeakSet hit. +const MAX_CONTAINERS = 5_000; +const MAX_GRAPH_EDGES = 20_000; +function graphProblem(root) { const seen = new WeakSet(); const stack = [root]; + let containers = 0; + let edges = 0; while (stack.length > 0) { const value = stack.pop(); if (value === null || typeof value !== 'object') continue; - if (seen.has(value)) return true; + if (seen.has(value)) { + return 'reused object or array nodes (YAML aliases or cycles) are not allowed'; + } seen.add(value); + if (++containers > MAX_CONTAINERS) { + return `has more than ${MAX_CONTAINERS} objects or arrays`; + } if (Array.isArray(value)) { - for (const element of value) stack.push(element); + for (const element of value) { + if (++edges > MAX_GRAPH_EDGES) return `has more than ${MAX_GRAPH_EDGES} graph edges`; + stack.push(element); + } } else { - for (const key of Object.keys(value)) stack.push(value[key]); + for (const key of Object.keys(value)) { + if (++edges > MAX_GRAPH_EDGES) return `has more than ${MAX_GRAPH_EDGES} graph edges`; + stack.push(value[key]); + } } } - return false; + return null; } function unexpectedKeys(object, allowed, location, errors) { @@ -199,8 +217,9 @@ export function validate(text, source = 'landscape.yml') { if (categories.length === 0) { return [`${source}: 'landscape' must contain at least one category`]; } - if (hasReusedNode(data)) { - return [`${source}: reused object or array nodes (YAML aliases or cycles) are not allowed`]; + const graphIssue = graphProblem(data); + if (graphIssue) { + return [`${source}: ${graphIssue}`]; } // Preflight the item count cheaply (list lengths only) so a file with far too many items @@ -362,6 +381,13 @@ function main() { const path = process.argv[2] ?? '../landscape/landscape.yml'; let text; try { + // Check the size before reading so an oversized file is not fully read into memory first. + // validate() re-checks the byte length for unit tests and other callers. + const { size } = statSync(path); + if (size > MAX_BYTES) { + console.error(`${path}: file is larger than ${MAX_BYTES} bytes`); + process.exit(1); + } text = readFileSync(path, 'utf8'); } catch (err) { console.error(`cannot read ${path}: ${err.message}`); diff --git a/scripts/validate-landscape.test.mjs b/scripts/validate-landscape.test.mjs index 2c1a180..1ca959b 100644 --- a/scripts/validate-landscape.test.mjs +++ b/scripts/validate-landscape.test.mjs @@ -330,3 +330,19 @@ test('unicode-equivalent duplicate names are caught', () => { ); assert.ok(hasError(validate(doc), /duplicate entry name/)); }); + +test('a document with too many objects is rejected before a full walk', () => { + // A wide unknown top-level key does not count toward the item cap, but the graph budget + // stops the walk once it exceeds MAX_CONTAINERS instead of materializing the whole thing. + const doc = VALID + 'junk: [' + '{},'.repeat(5001) + ']\n'; + assert.ok(hasError(validate(doc), /more than \d+ objects or arrays/)); +}); + +test('the schema example in docs/data-schemas.md validates', () => { + // Guards against a docs example that would fail the validator a reader copies it into. + const docs = readFileSync(fileURLToPath(new URL('../docs/data-schemas.md', import.meta.url)), 'utf8'); + const example = docs.split('```').find((b) => b.includes('landscape:') && b.includes('Model Armor')); + assert.ok(example, 'expected a landscape example block in docs/data-schemas.md'); + const yaml = example.replace(/^[a-zA-Z]*\n/, ''); + assert.deepEqual(validate(yaml), []); +}); From b3d718170f390613d6c0387df1998306ffbcf543 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:06:22 +0800 Subject: [PATCH 11/16] Bound highlight nodes per render, correct the graph-budget claims, test the SRI The highlighter capped matches per field but not per render, so a landscape near the 500-item cap could still create a large number of nodes on one search. Compile the search regex once per render and share a total budget across every field. Correct the graph-budget wording: js-yaml materializes the parsed document before the walk runs, so the walk stops the validation traversal early, it does not stop the parse. The byte cap is what bounds the parse, so lower it from 2 MB to 512 KB (the data is ~14 KB and the 500-item cap keeps a realistic file well under this). Document the edge budget, and walk mappings with for...in so a wide object hits the edge cap without first allocating its full key array. Add a test that the index.html js-yaml version and SRI match the installed bundle and that app.js keeps maxDepth in sync, so a bump that would break the browser on an SRI mismatch fails CI instead of passing silently. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- docs/data-schemas.md | 4 +-- landscape/static/app.js | 47 +++++++++++++++++------------ scripts/validate-landscape.mjs | 15 ++++++--- scripts/validate-landscape.test.mjs | 21 +++++++++++-- 4 files changed, 59 insertions(+), 28 deletions(-) diff --git a/docs/data-schemas.md b/docs/data-schemas.md index 0648ae7..fd47c79 100644 --- a/docs/data-schemas.md +++ b/docs/data-schemas.md @@ -87,9 +87,9 @@ The file has a single top-level `landscape:` key holding the list of categories. ### Landscape Validation Limits `scripts/validate-landscape.mjs` runs in CI with the same js-yaml parser and options the site loads with, and enforces the following so a malformed or hostile file cannot break the rendered map or the validator itself: -* **Parsing:** the file is parsed with `FAILSAFE_SCHEMA`, so every scalar is a string — a bare `123` or `2026-01-01` is read as text, matching the browser. Reused object or array nodes (YAML aliases or cycles) and merge (`<<`) keys are rejected; a scalar alias is allowed but stays within the per-field and total limits below. Nesting depth and file size (2 MB) are bounded. +* **Parsing:** the file is parsed with `FAILSAFE_SCHEMA`, so every scalar is a string — a bare `123` or `2026-01-01` is read as text, matching the browser. Reused object or array nodes (YAML aliases or cycles) and merge (`<<`) keys are rejected; a scalar alias is allowed but stays within the per-field and total limits below. Nesting depth and file size (512 KB) are bounded; the size cap is what limits how much the parser materializes. * **Field lengths:** `name` ≤ 200, `description` ≤ 2000, `project` ≤ 50, `homepage_url` / `repo_url` ≤ 2048, `logo` ≤ 300, and `category` / `subcategory` names ≤ 120 characters. -* **Cardinality:** at most 500 items across the whole landscape, and at most 5000 objects or arrays in the whole document (a budget that stops the graph walk early on a hostile file). +* **Cardinality:** at most 500 items across the whole landscape, and at most 5000 objects or arrays and 20,000 references in the whole document (a budget that stops the validation walk early on a hostile file; it does not change what the parser already materialized). * **URLs:** `homepage_url` and `repo_url` must be `https://`, contain no whitespace, and carry no embedded credentials. * **Fields:** only the fields documented above are allowed at each level; any other key is rejected. * **Characters:** display names and item descriptions must not contain control or format characters (for example zero-width or bidirectional-override characters). diff --git a/landscape/static/app.js b/landscape/static/app.js index f057349..0cd3f16 100644 --- a/landscape/static/app.js +++ b/landscape/static/app.js @@ -20,28 +20,29 @@ document.addEventListener('DOMContentLoaded', () => { const categoryBar = document.getElementById('category-bar'); const resultCount = document.getElementById('result-count'); - // Helper to append highlighted query substrings using pure DOM methods - function appendHighlightedText(parentElement, text, query) { - if (!query) { + // Helper to append highlighted query substrings using pure DOM methods. `highlight` is a + // per-render context { regex, budget } shared across every field, so the regex is compiled + // once and the total number of nodes for the whole render is bounded, not just the + // count per field. + function appendHighlightedText(parentElement, text, highlight) { + if (!highlight || highlight.budget.remaining <= 0) { parentElement.textContent = text; return; } try { - const escapedQuery = query.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); - const regex = new RegExp(escapedQuery, 'gi'); - - // Walk matches with exec() and stop after MAX_MATCHES, rather than splitting the whole - // string into a fragment array first: split() would allocate one entry per match up - // front (a long value with many matches builds a large array before any cap applies). - // Here only the matched slices and the surrounding gaps become nodes, and the remainder - // after the cap is appended as a single text node so the field still renders in full. - const MAX_MATCHES = 100; + const { regex, budget } = highlight; + regex.lastIndex = 0; + + // Walk matches with exec() and stop after MAX_MATCHES_PER_FIELD (or once the shared + // render budget runs out), rather than splitting the whole string into a fragment array + // first. Only the matched slices and the surrounding gaps become nodes; the remainder is + // appended as a single text node so the field still renders in full. + const MAX_MATCHES_PER_FIELD = 100; let cursor = 0; let count = 0; let match; - while (count < MAX_MATCHES && (match = regex.exec(text)) !== null) { - // A zero-length match cannot advance lastIndex on its own and would loop forever; the - // empty query is already handled above, but guard against a pathological pattern anyway. + while (count < MAX_MATCHES_PER_FIELD && budget.remaining > 0 && (match = regex.exec(text)) !== null) { + // A zero-length match cannot advance lastIndex on its own and would loop forever. if (match.index === regex.lastIndex) { regex.lastIndex += 1; continue; @@ -55,6 +56,7 @@ document.addEventListener('DOMContentLoaded', () => { parentElement.appendChild(mark); cursor = match.index + match[0].length; count += 1; + budget.remaining -= 1; } if (cursor < text.length) { parentElement.appendChild(document.createTextNode(text.slice(cursor))); @@ -111,13 +113,20 @@ document.addEventListener('DOMContentLoaded', () => { return; } + // Compile the search regex once for the whole render and share a total budget across + // every field, so the number of highlight nodes is bounded per render, not just per field. + // A whitespace-only query has already been normalized to empty by the caller. + const highlight = query + ? { regex: new RegExp(query.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'gi'), budget: { remaining: 2_000 } } + : null; + state.filteredCategories.forEach(catObj => { const catGroup = document.createElement('section'); catGroup.className = 'landscape-category-group'; const catTitle = document.createElement('h2'); catTitle.className = 'landscape-category-title'; - appendHighlightedText(catTitle, catObj.category, query); + appendHighlightedText(catTitle, catObj.category, highlight); catGroup.appendChild(catTitle); catObj.subcategories.forEach(subcatObj => { @@ -128,7 +137,7 @@ document.addEventListener('DOMContentLoaded', () => { const subTitle = document.createElement('h3'); subTitle.className = 'subcat-title'; - appendHighlightedText(subTitle, subcatObj.subcategory, query); + appendHighlightedText(subTitle, subcatObj.subcategory, highlight); subGroup.appendChild(subTitle); const itemsGrid = document.createElement('div'); @@ -145,7 +154,7 @@ document.addEventListener('DOMContentLoaded', () => { const cardTitle = document.createElement('h4'); cardTitle.className = 'card-title'; - appendHighlightedText(cardTitle, item.name, query); + appendHighlightedText(cardTitle, item.name, highlight); cardHeader.appendChild(cardTitle); const tierBadge = document.createElement('span'); @@ -157,7 +166,7 @@ document.addEventListener('DOMContentLoaded', () => { const cardDesc = document.createElement('p'); cardDesc.className = 'card-desc'; - appendHighlightedText(cardDesc, item.description || '', query); + appendHighlightedText(cardDesc, item.description || '', highlight); card.appendChild(cardDesc); const cardLinks = document.createElement('div'); diff --git a/scripts/validate-landscape.mjs b/scripts/validate-landscape.mjs index a079bd6..6d21ece 100644 --- a/scripts/validate-landscape.mjs +++ b/scripts/validate-landscape.mjs @@ -43,10 +43,12 @@ const ALLOWED_ITEM_FIELDS = new Set([ const ALLOWED_CATEGORY_FIELDS = new Set(['category', 'subcategories']); const ALLOWED_SUBCATEGORY_FIELDS = new Set(['subcategory', 'items']); -// The landscape is a curated list a few tens of KB in size. A cap keeps a -// runaway or hostile file from producing an unbounded item list (and DOM) once -// the site renders it; the real data is far below this. -const MAX_BYTES = 2_000_000; +// The landscape is a curated list a few tens of KB in size. This byte cap is the real bound +// on what js-yaml parses and materializes (the graph budget below only bounds the subsequent +// walk, not the parse), so keep it close to the data: the committed file is ~14 KB and the +// 500-item cap keeps a realistic file well under this, while a hostile file cannot force a +// multi-megabyte parse. +const MAX_BYTES = 512_000; // Bound the parsed data, not just the file. A file well under MAX_BYTES can still // materialize an enormous render workload in the browser (one huge name, many items, or @@ -138,7 +140,10 @@ function graphProblem(root) { stack.push(element); } } else { - for (const key of Object.keys(value)) { + // for...in with an own-property guard rather than Object.keys(), so a very wide mapping + // hits the edge budget without first allocating a full array of its keys. + for (const key in value) { + if (!Object.hasOwn(value, key)) continue; if (++edges > MAX_GRAPH_EDGES) return `has more than ${MAX_GRAPH_EDGES} graph edges`; stack.push(value[key]); } diff --git a/scripts/validate-landscape.test.mjs b/scripts/validate-landscape.test.mjs index 1ca959b..c114f0e 100644 --- a/scripts/validate-landscape.test.mjs +++ b/scripts/validate-landscape.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { validate } from './validate-landscape.mjs'; @@ -332,8 +333,9 @@ test('unicode-equivalent duplicate names are caught', () => { }); test('a document with too many objects is rejected before a full walk', () => { - // A wide unknown top-level key does not count toward the item cap, but the graph budget - // stops the walk once it exceeds MAX_CONTAINERS instead of materializing the whole thing. + // A wide unknown top-level key does not count toward the item cap. js-yaml still parses the + // bounded input (the byte cap limits that), but the graph budget stops the validation walk + // once it exceeds MAX_CONTAINERS instead of traversing the whole graph. const doc = VALID + 'junk: [' + '{},'.repeat(5001) + ']\n'; assert.ok(hasError(validate(doc), /more than \d+ objects or arrays/)); }); @@ -346,3 +348,18 @@ test('the schema example in docs/data-schemas.md validates', () => { const yaml = example.replace(/^[a-zA-Z]*\n/, ''); assert.deepEqual(validate(yaml), []); }); + +test('the browser js-yaml version, SRI, and parse options match the pinned bundle', () => { + // If js-yaml is bumped without updating index.html, the browser rejects the script on an SRI + // mismatch and the site fails to load, while these Node tests would still pass. Lock the CDN + // version and the integrity hash to the installed bundle, and keep maxDepth in sync. + const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('./package.json', import.meta.url)), 'utf8')); + const version = (pkg.dependencies || {})['js-yaml'] || (pkg.devDependencies || {})['js-yaml']; + const bundle = readFileSync(fileURLToPath(new URL('./node_modules/js-yaml/dist/js-yaml.min.js', import.meta.url))); + const expectedSri = `sha512-${createHash('sha512').update(bundle).digest('base64')}`; + const html = readFileSync(fileURLToPath(new URL('../landscape/static/index.html', import.meta.url)), 'utf8'); + assert.ok(html.includes(`js-yaml@${version}/`), `index.html should load js-yaml@${version}`); + assert.ok(html.includes(expectedSri), 'index.html SRI must match the installed js-yaml bundle'); + const appJs = readFileSync(fileURLToPath(new URL('../landscape/static/app.js', import.meta.url)), 'utf8'); + assert.match(appJs, /maxDepth:\s*10/, 'app.js must keep maxDepth: 10 in sync with the validator'); +}); From 7841520e468a9c092edc072ea6ed59b714fb276f Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:38:14 +0800 Subject: [PATCH 12/16] Resolve the CLI default path relative to the script, tighten parity tests The default landscape path was resolved against the current working directory, so running the validator from the repo root looked for ../landscape/landscape.yml outside the repo. Resolve it relative to the script instead, so the command works from anywhere. Compare maxDepth between app.js and the validator rather than only asserting app.js has some value, and add a test for the graph edge budget alongside the existing container-budget test. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- scripts/validate-landscape.mjs | 5 ++++- scripts/validate-landscape.test.mjs | 13 ++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/validate-landscape.mjs b/scripts/validate-landscape.mjs index 6d21ece..65df709 100644 --- a/scripts/validate-landscape.mjs +++ b/scripts/validate-landscape.mjs @@ -383,7 +383,10 @@ export function validate(text, source = 'landscape.yml') { } function main() { - const path = process.argv[2] ?? '../landscape/landscape.yml'; + // Resolve the default relative to this script, not the current working directory, so it is + // the repo's landscape.yml regardless of where the command is run from. + const defaultPath = fileURLToPath(new URL('../landscape/landscape.yml', import.meta.url)); + const path = process.argv[2] ?? defaultPath; let text; try { // Check the size before reading so an oversized file is not fully read into memory first. diff --git a/scripts/validate-landscape.test.mjs b/scripts/validate-landscape.test.mjs index c114f0e..a26113c 100644 --- a/scripts/validate-landscape.test.mjs +++ b/scripts/validate-landscape.test.mjs @@ -340,6 +340,13 @@ test('a document with too many objects is rejected before a full walk', () => { assert.ok(hasError(validate(doc), /more than \d+ objects or arrays/)); }); +test('a document with too many references is rejected by the edge budget', () => { + // Only a couple of containers (one array), but many scalar elements, so the edge budget + // triggers before the container budget does. + const doc = VALID + 'junk: [' + '1,'.repeat(20001) + ']\n'; + assert.ok(hasError(validate(doc), /more than \d+ graph edges/)); +}); + test('the schema example in docs/data-schemas.md validates', () => { // Guards against a docs example that would fail the validator a reader copies it into. const docs = readFileSync(fileURLToPath(new URL('../docs/data-schemas.md', import.meta.url)), 'utf8'); @@ -361,5 +368,9 @@ test('the browser js-yaml version, SRI, and parse options match the pinned bundl assert.ok(html.includes(`js-yaml@${version}/`), `index.html should load js-yaml@${version}`); assert.ok(html.includes(expectedSri), 'index.html SRI must match the installed js-yaml bundle'); const appJs = readFileSync(fileURLToPath(new URL('../landscape/static/app.js', import.meta.url)), 'utf8'); - assert.match(appJs, /maxDepth:\s*10/, 'app.js must keep maxDepth: 10 in sync with the validator'); + const validatorJs = readFileSync(fileURLToPath(new URL('./validate-landscape.mjs', import.meta.url)), 'utf8'); + const appDepth = appJs.match(/maxDepth:\s*(\d+)/)?.[1]; + const validatorDepth = validatorJs.match(/maxDepth:\s*(\d+)/)?.[1]; + assert.ok(appDepth && validatorDepth, 'both app.js and the validator set maxDepth'); + assert.equal(appDepth, validatorDepth, 'app.js and the validator must use the same maxDepth'); }); From 4166336ea583028a03dba45a66a12f7d1df470cb Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:35:03 +0800 Subject: [PATCH 13/16] Reject symlinks in the Pages artifact before upload upload-pages-artifact packs dist with tar --dereference, so a symlink copied into dist (cp -r brings the repo tree in) would be published as the bytes of its target, for example a runner-local file. A symlink is easy to miss in review, so fail the deploy if any are present rather than leak the target to public Pages. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- .github/workflows/deploy-pages.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 94e15e4..b3402ec 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -89,6 +89,18 @@ jobs: sed -i "s|BUILD_TIMESTAMP|$CURRENT_TIME|g" dist/index.html + - name: Reject symlinks in the Pages artifact + run: | + # upload-pages-artifact packs the tree with `tar --dereference`, so a symlink copied + # into dist would be published as the bytes of whatever it points at (for example a + # runner-local file). Fail the deploy if any symlink is present rather than leak it. + links="$(find dist -type l -print)" + if [ -n "$links" ]; then + echo "::error::Symlinks are not allowed in the Pages artifact" + printf '%s\n' "$links" + exit 1 + fi + - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: From a7096d174e2310c4f3783f64f723cf5a85a0bcce Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:57:46 +0800 Subject: [PATCH 14/16] Match filter and highlight on one regex, reject source symlinks robustly The filter used toLowerCase().includes() while the highlighter used a case-insensitive RegExp, so for some Unicode inputs (for example a Turkish dotted-I) an item could match the search yet not be highlighted. Build both from one escaped, case-insensitive regex so an item is highlighted exactly when it matched; behavior is unchanged for ASCII. Also reject symlinks in the deployed sources before assembly, not only in dist after cp: whether cp preserves or dereferences a symlink is version-dependent, and a dereferenced one would be a plain file in dist that the dist check misses. The source check is scoped to the copied paths so it does not trip on node_modules symlinks under scripts/. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- .github/workflows/deploy-pages.yml | 14 +++++++++++ landscape/static/app.js | 38 +++++++++++++++++++----------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index b3402ec..bb81cd9 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -69,6 +69,20 @@ jobs: - name: Validate production landscape run: node scripts/validate-landscape.mjs landscape/landscape.yml + - name: Reject symlinks in the deployed sources + run: | + # A symlink committed to a deployed path would be copied into dist and then + # dereferenced by upload-pages-artifact (tar --dereference), publishing its target's + # bytes (for example a runner-local file) to public Pages. Reject any symlink in the + # sources here, before assembly, so this does not depend on how cp handles them. Only + # the paths that get copied are checked, to avoid node_modules symlinks under scripts/. + links="$(find landscape taxonomy index.html -type l -print)" + if [ -n "$links" ]; then + echo "::error::Symlinks are not allowed in the deployed sources" + printf '%s\n' "$links" + exit 1 + fi + - name: Assemble Portal Distribution run: | mkdir -p dist/landscape/static diff --git a/landscape/static/app.js b/landscape/static/app.js index 0cd3f16..fd5ed81 100644 --- a/landscape/static/app.js +++ b/landscape/static/app.js @@ -20,6 +20,14 @@ document.addEventListener('DOMContentLoaded', () => { const categoryBar = document.getElementById('category-bar'); const resultCount = document.getElementById('result-count'); + // Escape a user query so it is matched as a literal (not a pattern) in a RegExp. Filtering + // and highlighting build their regexes from this with the case-insensitive `i` flag (no `u`), + // so they apply the same ECMAScript case-folding and an item is highlighted exactly when the + // filter matched it. + function escapeRegExp(text) { + return text.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + } + // Helper to append highlighted query substrings using pure DOM methods. `highlight` is a // per-render context { regex, budget } shared across every field, so the regex is compiled // once and the total number of nodes for the whole render is bounded, not just the @@ -117,7 +125,7 @@ document.addEventListener('DOMContentLoaded', () => { // every field, so the number of highlight nodes is bounded per render, not just per field. // A whitespace-only query has already been normalized to empty by the caller. const highlight = query - ? { regex: new RegExp(query.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'gi'), budget: { remaining: 2_000 } } + ? { regex: new RegExp(escapeRegExp(query), 'gi'), budget: { remaining: 2_000 } } : null; state.filteredCategories.forEach(catObj => { @@ -213,7 +221,11 @@ document.addEventListener('DOMContentLoaded', () => { function runFilteringPipeline() { if (!state.rawLandscape || !state.rawLandscape.landscape) return; - const query = state.currentSearch.toLowerCase().trim(); + const rawQuery = state.currentSearch.trim(); + // Filter and highlight share one escaped regex (case-insensitive `i`, no `u`) so an item is + // highlighted exactly when the filter matched it. A `.test()` regex without the global flag + // is stateless, so it is safely reused across every field. + const filterRegex = rawQuery ? new RegExp(escapeRegExp(rawQuery), 'i') : null; // Filter Categories and Subcategories state.filteredCategories = state.rawLandscape.landscape.map(catObj => { @@ -225,15 +237,13 @@ document.addEventListener('DOMContentLoaded', () => { // Filter Subcategories and Items const filteredSubcats = catObj.subcategories.map(subcatObj => { const filteredItems = subcatObj.items.filter(item => { - if (!query) return true; - - const matchName = (item.name || '').toLowerCase().includes(query); - const matchDesc = (item.description || '').toLowerCase().includes(query); - const matchTier = (item.project || '').toLowerCase().includes(query); - const matchHome = (item.homepage_url || '').toLowerCase().includes(query); - const matchRepo = (item.repo_url || '').toLowerCase().includes(query); + if (!filterRegex) return true; - return matchName || matchDesc || matchTier || matchHome || matchRepo; + return filterRegex.test(item.name || '') || + filterRegex.test(item.description || '') || + filterRegex.test(item.project || '') || + filterRegex.test(item.homepage_url || '') || + filterRegex.test(item.repo_url || ''); }); if (filteredItems.length === 0) return null; @@ -252,10 +262,10 @@ document.addEventListener('DOMContentLoaded', () => { }; }).filter(Boolean); - // Render with the same normalized query the filter used above, so highlighting matches - // exactly what was filtered (a query that is only whitespace trims to empty here, so it - // filters nothing out and highlights nothing, instead of building a regex from raw spaces). - renderLandscape(query); + // Highlight with the same query and escaping the filter used, so an item is highlighted + // exactly when it matched (a whitespace-only query trims to empty, filtering nothing out + // and highlighting nothing). + renderLandscape(rawQuery); } // Fetch landscape.yml and Initialize From 4c3c35cd65a7c78caa92a912d0a71caa7de2c589 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:29:38 +0800 Subject: [PATCH 15/16] Fold Unicode case in search, check JS syntax and symlinks in PR CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the filter and highlight regexes with the `u` flag (`iu`/`giu`) so both apply Unicode simple case-folding and keep agreeing on what matched: a Kelvin sign now matches `k` and a capital sharp-S matches `ß`, in the filter and the highlight alike. This needs a `u`-safe escape, so escapeRegExp no longer escapes `-` (it is literal outside a character class, and `\-` is an invalid identity escape under `u`); the standard syntax-character set it now escapes is unchanged in meaning for every earlier query. Move the source-symlink guard into scripts/check-no-symlinks.sh and run it, plus `node --check` on the browser script, from the pull-request workflow as well as the deploy. A committed symlink or a syntax error in app.js is now caught before merge, not only when main is deployed. Correct two stale comments: the shared query is trimmed, not lower-cased (case is handled by the flag), and the filter also searches the tier and the URLs, which are not rendered as highlightable text, so a match there filters a card in without a visible mark. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- .github/workflows/deploy-pages.yml | 13 +-------- .github/workflows/validate-landscape.yml | 6 ++++ landscape/static/app.js | 35 +++++++++++++----------- scripts/check-no-symlinks.sh | 16 +++++++++++ 4 files changed, 42 insertions(+), 28 deletions(-) create mode 100644 scripts/check-no-symlinks.sh diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index bb81cd9..6333782 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -70,18 +70,7 @@ jobs: run: node scripts/validate-landscape.mjs landscape/landscape.yml - name: Reject symlinks in the deployed sources - run: | - # A symlink committed to a deployed path would be copied into dist and then - # dereferenced by upload-pages-artifact (tar --dereference), publishing its target's - # bytes (for example a runner-local file) to public Pages. Reject any symlink in the - # sources here, before assembly, so this does not depend on how cp handles them. Only - # the paths that get copied are checked, to avoid node_modules symlinks under scripts/. - links="$(find landscape taxonomy index.html -type l -print)" - if [ -n "$links" ]; then - echo "::error::Symlinks are not allowed in the deployed sources" - printf '%s\n' "$links" - exit 1 - fi + run: bash scripts/check-no-symlinks.sh - name: Assemble Portal Distribution run: | diff --git a/.github/workflows/validate-landscape.yml b/.github/workflows/validate-landscape.yml index a005904..30e99d8 100644 --- a/.github/workflows/validate-landscape.yml +++ b/.github/workflows/validate-landscape.yml @@ -41,3 +41,9 @@ jobs: - name: Validate landscape.yml run: node scripts/validate-landscape.mjs landscape/landscape.yml + + - name: Check browser JavaScript syntax + run: node --check landscape/static/app.js + + - name: Reject symlinks in the deployed sources + run: bash scripts/check-no-symlinks.sh diff --git a/landscape/static/app.js b/landscape/static/app.js index fd5ed81..2fb281d 100644 --- a/landscape/static/app.js +++ b/landscape/static/app.js @@ -20,12 +20,14 @@ document.addEventListener('DOMContentLoaded', () => { const categoryBar = document.getElementById('category-bar'); const resultCount = document.getElementById('result-count'); - // Escape a user query so it is matched as a literal (not a pattern) in a RegExp. Filtering - // and highlighting build their regexes from this with the case-insensitive `i` flag (no `u`), - // so they apply the same ECMAScript case-folding and an item is highlighted exactly when the - // filter matched it. + // Escape a user query so it is matched as a literal (not a pattern) in a RegExp. Only the regex + // syntax characters are escaped, not `-` (which is literal outside a character class), so the + // result is valid under the `u` flag. Filtering and highlighting build their regexes from this + // with `iu`/`giu`, so both apply Unicode simple case-folding (a Kelvin sign matches `k`, a + // capital sharp-s matches `ß`) and stay in agreement. Locale-specific folds such as Turkish + // dotted-I are not covered by simple case-folding and are not matched. function escapeRegExp(text) { - return text.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // Helper to append highlighted query substrings using pure DOM methods. `highlight` is a @@ -99,9 +101,9 @@ document.addEventListener('DOMContentLoaded', () => { } } - // Render Landscape Grid. `query` is the already-normalized search string from - // runFilteringPipeline (lower-cased and trimmed); highlighting must use the same value the - // filter matched on, so it is passed in rather than re-read from state here. + // Render Landscape Grid. `query` is the trimmed search string from runFilteringPipeline; + // highlighting builds its regex from the same string (and the same escaping) the filter used, + // so it is passed in rather than re-read from state here. function renderLandscape(query) { landscapeGrid.replaceChildren(); @@ -125,7 +127,7 @@ document.addEventListener('DOMContentLoaded', () => { // every field, so the number of highlight nodes is bounded per render, not just per field. // A whitespace-only query has already been normalized to empty by the caller. const highlight = query - ? { regex: new RegExp(escapeRegExp(query), 'gi'), budget: { remaining: 2_000 } } + ? { regex: new RegExp(escapeRegExp(query), 'giu'), budget: { remaining: 2_000 } } : null; state.filteredCategories.forEach(catObj => { @@ -222,10 +224,10 @@ document.addEventListener('DOMContentLoaded', () => { if (!state.rawLandscape || !state.rawLandscape.landscape) return; const rawQuery = state.currentSearch.trim(); - // Filter and highlight share one escaped regex (case-insensitive `i`, no `u`) so an item is - // highlighted exactly when the filter matched it. A `.test()` regex without the global flag - // is stateless, so it is safely reused across every field. - const filterRegex = rawQuery ? new RegExp(escapeRegExp(rawQuery), 'i') : null; + // Filter and highlight share one escaped regex (Unicode case-insensitive, `iu`) so the same + // matching decides both. A `.test()` regex without the global flag is stateless, so it is + // safely reused across every field. + const filterRegex = rawQuery ? new RegExp(escapeRegExp(rawQuery), 'iu') : null; // Filter Categories and Subcategories state.filteredCategories = state.rawLandscape.landscape.map(catObj => { @@ -262,9 +264,10 @@ document.addEventListener('DOMContentLoaded', () => { }; }).filter(Boolean); - // Highlight with the same query and escaping the filter used, so an item is highlighted - // exactly when it matched (a whitespace-only query trims to empty, filtering nothing out - // and highlighting nothing). + // Highlight the rendered text with the same query and escaping the filter used. The filter + // also searches the tier and URLs, which are not rendered as highlightable text, so a match + // there filters the card in without a visible mark. A whitespace-only query trims to empty, + // filtering nothing out and highlighting nothing. renderLandscape(rawQuery); } diff --git a/scripts/check-no-symlinks.sh b/scripts/check-no-symlinks.sh new file mode 100644 index 0000000..5670432 --- /dev/null +++ b/scripts/check-no-symlinks.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Fail if any deployed source path contains a symlink. actions/upload-pages-artifact packs the +# tree with `tar --dereference`, so a symlink committed to the repo would be published as the +# bytes of its target (for example a runner-local file). Only the paths that are copied into the +# Pages artifact are checked, so this does not trip on the node_modules symlinks under scripts/. +# Run this in both PR CI and the deploy assembly, so a bad symlink is caught before merge, not +# only at deploy time. Must be run from the repository root. +set -euo pipefail + +links="$(find landscape taxonomy index.html -type l -print)" +if [ -n "$links" ]; then + echo "::error::Symlinks are not allowed in the deployed sources" + printf '%s\n' "$links" + exit 1 +fi +echo "No symlinks in the deployed sources." From 2b4bebccd06739e6c27db1279ac15885e33dfe42 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:33:39 +0800 Subject: [PATCH 16/16] Test that the browser search regexes stay Unicode-aware Adds a source-parity check, alongside the existing FAILSAFE_SCHEMA and maxDepth ones, asserting the filter and highlight regexes keep the `u` flag and that escapeRegExp does not escape `-`. This fails if either the Unicode case-folding or the `u`-safe escaping is dropped from app.js. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- scripts/validate-landscape.test.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scripts/validate-landscape.test.mjs b/scripts/validate-landscape.test.mjs index a26113c..ad7deeb 100644 --- a/scripts/validate-landscape.test.mjs +++ b/scripts/validate-landscape.test.mjs @@ -296,6 +296,19 @@ test('the browser parses with the same failsafe options as the validator', () => assert.match(appjs, /jsyaml\.load\([^)]*FAILSAFE_SCHEMA/s); }); +test('the browser search regexes stay Unicode-aware and u-safe', () => { + // Guards against reintroducing the case-folding gap, the same way the FAILSAFE_SCHEMA and + // maxDepth checks guard parser parity. The filter and highlight regexes must carry the `u` + // flag so both apply Unicode simple case-folding (a Kelvin sign matches `k`), and escapeRegExp + // must not escape `-`, because a `\-` identity escape throws once the `u` flag is set. + const appjs = readFileSync(fileURLToPath(new URL('../landscape/static/app.js', import.meta.url)), 'utf8'); + assert.match(appjs, /new RegExp\(escapeRegExp\([^)]*\),\s*'iu'\)/, 'the filter regex must use the iu flags'); + assert.match(appjs, /new RegExp\(escapeRegExp\([^)]*\),\s*'giu'\)/, 'the highlight regex must use the giu flags'); + const escClass = appjs.match(/function escapeRegExp[\s\S]*?text\.replace\((\/\[.*?\]\/g)/); + assert.ok(escClass, 'escapeRegExp escapes a character class'); + assert.ok(!escClass[1].includes('-'), 'escapeRegExp must not escape "-" so the pattern stays valid under u'); +}); + test('an over-long category name is rejected before it inflates errors', () => { const doc = VALID.replace('category: Frameworks', `category: ${'A'.repeat(200)}`); assert.ok(hasError(validate(doc), /category name is longer than/));