diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index afbee58..6333782 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: | @@ -53,6 +56,22 @@ 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: Reject symlinks in the deployed sources + run: bash scripts/check-no-symlinks.sh + - name: Assemble Portal Distribution run: | mkdir -p dist/landscape/static @@ -73,6 +92,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: diff --git a/.github/workflows/validate-landscape.yml b/.github/workflows/validate-landscape.yml new file mode 100644 index 0000000..30e99d8 --- /dev/null +++ b/.github/workflows/validate-landscape.yml @@ -0,0 +1,49 @@ +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: + 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 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: scripts/package-lock.json + + - name: Install dependencies + run: npm ci --ignore-scripts + working-directory: scripts + + - name: Run validator tests + run: node --test + working-directory: scripts + + - 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/docs/data-schemas.md b/docs/data-schemas.md index e4f80ff..fd47c79 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/example/project # 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. @@ -75,5 +78,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 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: + +* **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 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 49ce739..2fb281d 100644 --- a/landscape/static/app.js +++ b/landscape/static/app.js @@ -20,27 +20,57 @@ 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) { + // 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, '\\$&'); + } + + // 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'); - const parts = text.split(regex); - - parts.forEach(part => { - if (part.toLowerCase() === query.toLowerCase()) { - const mark = document.createElement('mark'); - mark.className = 'match-highlight'; - mark.textContent = part; - parentElement.appendChild(mark); - } else if (part) { - parentElement.appendChild(document.createTextNode(part)); + 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_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; } - }); + 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; + budget.remaining -= 1; + } + if (cursor < text.length) { + parentElement.appendChild(document.createTextNode(text.slice(cursor))); + } } catch (e) { parentElement.textContent = text; } @@ -71,8 +101,10 @@ document.addEventListener('DOMContentLoaded', () => { } } - // Render Landscape Grid - function renderLandscape() { + // 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(); let totalItems = 0; @@ -91,7 +123,12 @@ document.addEventListener('DOMContentLoaded', () => { return; } - const query = state.currentSearch; + // 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(escapeRegExp(query), 'giu'), budget: { remaining: 2_000 } } + : null; state.filteredCategories.forEach(catObj => { const catGroup = document.createElement('section'); @@ -99,7 +136,7 @@ document.addEventListener('DOMContentLoaded', () => { 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 => { @@ -110,7 +147,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'); @@ -127,7 +164,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'); @@ -139,7 +176,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'); @@ -161,7 +198,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); } @@ -186,7 +223,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 (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 => { @@ -198,15 +239,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; @@ -225,7 +264,11 @@ document.addEventListener('DOMContentLoaded', () => { }; }).filter(Boolean); - renderLandscape(); + // 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); } // Fetch landscape.yml and Initialize @@ -235,16 +278,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/landscape/static/index.html b/landscape/static/index.html index 9e5425a..762cf92 100644 --- a/landscape/static/index.html +++ b/landscape/static/index.html @@ -14,8 +14,8 @@ - - + + @@ -41,7 +41,7 @@

Ecosystem Landscape Map

- + 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." diff --git a/scripts/package-lock.json b/scripts/package-lock.json new file mode 100644 index 0000000..40b0d88 --- /dev/null +++ b/scripts/package-lock.json @@ -0,0 +1,43 @@ +{ + "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.3.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.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" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + } + } +} diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..bf8b373 --- /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.3.0" + } +} diff --git a/scripts/validate-landscape.mjs b/scripts/validate-landscape.mjs new file mode 100644 index 0000000..65df709 --- /dev/null +++ b/scripts/validate-landscape.mjs @@ -0,0 +1,418 @@ +#!/usr/bin/env node +/** + * 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 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 + * 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 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] + */ +import { readFileSync, statSync } 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_ITEM_FIELDS = new Set([ + 'name', + 'logo', + 'homepage_url', + 'repo_url', + 'description', + 'project', +]); +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. 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 +// 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, + project: 50, +}; + +// 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() !== ''; +} + +// 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); +} + +// 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 '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) { + if (++edges > MAX_GRAPH_EDGES) return `has more than ${MAX_GRAPH_EDGES} graph edges`; + stack.push(element); + } + } else { + // 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]); + } + } + } + return null; +} + +function unexpectedKeys(object, allowed, location, errors) { + for (const key of Object.keys(object)) { + if (!allowed.has(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)}`); + } + } +} + +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); + } 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)})`; + } + if (parsed.username !== '' || parsed.password !== '') { + return `${field} must not contain credentials`; + } + return null; +} + +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 { + // FAILSAFE_SCHEMA parses only strings, sequences, and mappings, which is all the + // 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) { + // 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')) { + 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 graphIssue = graphProblem(data); + if (graphIssue) { + return [`${source}: ${graphIssue}`]; + } + + // 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); + + const seenCategories = new Map(); + const seenNames = new Map(); + let itemCount = 0; + + categories.forEach((category, categoryIndex) => { + if (!isPlainObject(category) || !Object.hasOwn(category, 'category') || !isNonEmptyString(category.category)) { + 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)) { + 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)) { + addError(errors, `category '${categoryName}': 'subcategories' must be a list`); + return; + } + if (category.subcategories.length === 0) { + 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)) { + 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)) { + addError(errors, `'${categoryName}' / '${subcategoryName}': duplicate subcategory name in this category`); + } else { + seenSubcategories.set(subcategoryKey, subcategoryIndex); + } + if (!Object.hasOwn(subcategory, 'items') || !Array.isArray(subcategory.items)) { + addError(errors, `'${categoryName}' / '${subcategoryName}': 'items' must be a list`); + return; + } + if (subcategory.items.length === 0) { + addError(errors, `'${categoryName}' / '${subcategoryName}': must contain at least one item`); + } + + subcategory.items.forEach((item, itemIndex) => { + itemCount += 1; + const baseLocation = `'${categoryName}' / '${subcategoryName}' / item[${itemIndex}]`; + if (!isPlainObject(item)) { + 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; + 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; + } + } + if (!bounded) return; + + let location = baseLocation; + if (Object.hasOwn(item, 'name') && isNonEmptyString(item.name)) { + location = `'${categoryName}' / '${subcategoryName}' / '${item.name}'`; + } + + for (const field of REQUIRED_FIELDS) { + if (!Object.hasOwn(item, field) || !isNonEmptyString(item[field])) { + addError(errors, `${location}: missing or empty required field '${field}'`); + } + } + if (Object.hasOwn(item, 'project') && isNonEmptyString(item.project) && !PROJECT_VALUES.has(item.project)) { + 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); + if (problem) addError(errors, `${location}: ${problem}`); + } + if (Object.hasOwn(item, 'repo_url')) { + if (!isNonEmptyString(item.repo_url)) { + addError(errors, `${location}: repo_url is present but empty`); + } else { + const problem = urlProblem('repo_url', item.repo_url); + 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)) { + addError(errors, `${location}: duplicate entry name (also at ${seenNames.get(nameKey)})`); + } else { + seenNames.set(nameKey, location); + } + } + }); + }); + }); + + if (itemCount === 0) { + addError(errors, `${source}: landscape contains no items`); + } + return errors; +} + +function main() { + // 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. + // 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}`); + process.exit(2); + } + const errors = validate(text, path); + if (errors.length > 0) { + 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}`); + } + 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..ad7deeb --- /dev/null +++ b/scripts/validate-landscape.test.mjs @@ -0,0 +1,389 @@ +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'; + +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 start with 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/)); +}); + +test('a prototype-pollution merge payload does not satisfy required fields', () => { + // 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: + - <<: {__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), /reused object or array nodes .* 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 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 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/)); +}); + +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"/)); +}); + +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 <= 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 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('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/)); +}); + +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', () => { + 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/)); +}); + +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. 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/)); +}); + +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'); + 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), []); +}); + +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'); + 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'); +});