diff --git a/docs/_static_html/README.md b/docs/_static_html/README.md
new file mode 100644
index 00000000..6256ec42
--- /dev/null
+++ b/docs/_static_html/README.md
@@ -0,0 +1,40 @@
+# CyTRICS Single-Page SBOM Editor
+
+This is a single-file, browser-based SBOM HTML editor.
+
+## PURL builder placement
+
+The PURL builder remains collapsed by default. After selecting a software entry and clicking **Build Related PURL**, the builder opens immediately below the entry title/action row and above the editable software fields. Selecting another software entry closes it automatically.
+
+## How to use
+
+1. Open `sbom-workbench.html` in a browser.
+2. Load a CyTRICS JSON SBOM.
+3. Search the software section by filename, product name, UUID, SHA-1, SHA-256, MD5, install path, or PURL.
+4. Select a source software/file entry.
+5. Use **Create related PURL component** to manually build a Package URL.
+6. Click **Create software entry + relationship**.
+7. Export the updated SBOM.
+
+## What the PURL action does
+
+For the selected source software entry, the tool creates a new CyTRICS `software[]` component:
+
+- `UUID`: newly generated UUID
+- `softwareType`: `["software package"]`
+- `name[]`: includes a product name and a `package URL (purl)` entry
+- `version`: the manually entered PURL version, if provided
+- `notHashable`: `true`
+- `relationshipAssertion`: `Root`
+
+It also creates a new `relationships[]` entry:
+
+- `xUUID`: selected source file/software UUID
+- `yUUID`: newly generated PURL software UUID
+- `relationship`: selected relationship value, such as `Uses`, `Contains`, `Downloads`, or `Mounted on`
+
+## Notes
+
+- The full software list is intentionally not rendered until you search, which is better for large SBOMs.
+- Export normalizes the document toward CyTRICS 1.0.1 shape and removes legacy top-level fields such as `properties`, `systems`, `analysisData`, `observations`, and `starRelationships`.
+- This single page uses CDN-hosted React/Babel dependencies like the uploaded HTML prototype, so it needs browser access to those CDNs unless you vendor the dependencies locally.
diff --git a/docs/_static_html/sbom-workbench.html b/docs/_static_html/sbom-workbench.html
index 764d3356..570308c1 100644
--- a/docs/_static_html/sbom-workbench.html
+++ b/docs/_static_html/sbom-workbench.html
@@ -3,7 +3,7 @@
-SBOM Workbench
+CyTRICS SBOM Co-editor
@@ -468,6 +468,72 @@
font-family: var(--mono);
}
+ /* Schema-driven PURL builder */
+ .purl-definition-card {
+ background: var(--bg-2);
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ padding: 10px;
+ }
+ .purl-definition-title {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-bottom: 4px;
+ }
+ .purl-requirement {
+ font-family: var(--mono);
+ font-size: 9px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: 1px 5px;
+ border-radius: 2px;
+ border: 1px solid var(--border-2);
+ }
+ .purl-required { color: var(--danger); border-color: rgba(255,84,104,.4); background: rgba(255,84,104,.08); }
+ .purl-optional { color: var(--accent-3); border-color: rgba(132,195,66,.35); background: rgba(132,195,66,.08); }
+ .purl-prohibited { color: var(--fg-3); border-color: var(--border); background: var(--bg); }
+ .purl-validation {
+ border-radius: 3px;
+ padding: 8px 10px;
+ font-size: 11px;
+ margin-top: 8px;
+ }
+ .purl-validation.error { border: 1px solid rgba(255,84,104,.45); background: rgba(255,84,104,.08); color: #ff9aa7; }
+ .purl-validation.warn { border: 1px solid rgba(255,121,0,.4); background: rgba(255,121,0,.08); color: #ffb36b; }
+ .purl-validation.ok { border: 1px solid rgba(132,195,66,.35); background: rgba(132,195,66,.08); color: #b7dd8d; }
+ .purl-example {
+ display: block;
+ width: 100%;
+ text-align: left;
+ font-family: var(--mono);
+ font-size: 10px;
+ color: var(--fg-2);
+ background: var(--bg);
+ border: 1px solid var(--border);
+ padding: 5px 7px;
+ margin-top: 4px;
+ border-radius: 2px;
+ cursor: pointer;
+ word-break: break-all;
+ }
+ .purl-example:hover { border-color: var(--accent); color: var(--fg); }
+ .purl-qualifier-row {
+ display: grid;
+ grid-template-columns: minmax(120px, .7fr) minmax(180px, 1.3fr) 28px;
+ gap: 6px;
+ align-items: start;
+ margin-bottom: 6px;
+ }
+ .purl-help {
+ font-size: 10px;
+ color: var(--fg-3);
+ margin-top: 3px;
+ line-height: 1.35;
+ }
+ .purl-toolbar { display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
+
/* Wave/blink */
@keyframes blink { 50% { opacity: 0.3; } }
.blink { animation: blink 1.2s infinite; }
@@ -853,6 +919,351 @@
try { return await navigator.storage.estimate(); } catch { return null; }
}
+const PURL_TYPE_DEFINITION_SCHEMA_ID = 'https://packageurl.org/schemas/purl-type-definition.schema-1.0.json';
+const PURL_TYPE_PATTERN = /^[a-z][a-z0-9.-]+$/;
+const PURL_QUALIFIER_KEY_PATTERN = /^[a-z][a-z0-9._-]*$/;
+
+function makeBuiltinPurlDefinition({ type, typeName, description, namespaceRequirement='optional', namespaceNativeName='namespace', nameNativeName='name', qualifiers=[] , examples=[] }) {
+ return {
+ $schema: PURL_TYPE_DEFINITION_SCHEMA_ID,
+ $id: `https://packageurl.org/types/${type}-definition.json`,
+ type,
+ type_name: typeName,
+ description,
+ repository: { use_repository: false },
+ namespace_definition: {
+ requirement: namespaceRequirement,
+ case_sensitive: true,
+ native_name: namespaceNativeName,
+ },
+ name_definition: {
+ requirement: 'required',
+ case_sensitive: true,
+ native_name: nameNativeName,
+ },
+ version_definition: { requirement: 'optional', case_sensitive: true, native_name: 'version' },
+ qualifiers_definition: qualifiers,
+ subpath_definition: { requirement: 'optional', case_sensitive: true, native_name: 'subpath' },
+ examples: examples.length ? examples : [`pkg:${type}/example@1.0.0`],
+ };
+}
+
+// Starter definitions keep the page useful before authoritative type-definition
+// files are imported. Imported definitions replace a starter with the same type.
+const BUILTIN_PURL_TYPE_DEFINITIONS = {
+ generic: makeBuiltinPurlDefinition({
+ type: 'generic',
+ typeName: 'Generic Package',
+ description: 'Fallback package type for software that does not have a more specific Package-URL ecosystem type.',
+ examples: ['pkg:generic/acme/widget@1.2.3', 'pkg:generic/widget@1.2.3?arch=x86_64'],
+ }),
+ maven: makeBuiltinPurlDefinition({
+ type: 'maven',
+ typeName: 'Apache Maven',
+ description: 'Starter definition for Maven coordinates. Import an authoritative definition to replace this template.',
+ namespaceRequirement: 'required',
+ namespaceNativeName: 'groupId',
+ nameNativeName: 'artifactId',
+ qualifiers: [
+ { key:'type', requirement:'optional', description:'Maven artifact type or packaging.', default_value:'jar' },
+ { key:'classifier', requirement:'optional', description:'Maven classifier.' },
+ { key:'repository_url', requirement:'optional', description:'Repository URL used to resolve the package.' },
+ ],
+ examples: ['pkg:maven/org.apache.xmlgraphics/batik-anim@1.9.1?type=jar'],
+ }),
+ npm: makeBuiltinPurlDefinition({
+ type: 'npm',
+ typeName: 'npm Package',
+ description: 'Starter definition for npm packages. The namespace represents an optional npm scope.',
+ namespaceRequirement: 'optional',
+ namespaceNativeName: 'scope',
+ nameNativeName: 'package name',
+ examples: ['pkg:npm/%40angular/animation@12.3.1', 'pkg:npm/left-pad@1.3.0'],
+ }),
+ pypi: makeBuiltinPurlDefinition({
+ type: 'pypi',
+ typeName: 'Python Package',
+ description: 'Starter definition for Python packages distributed through Python package repositories.',
+ namespaceRequirement: 'prohibited',
+ nameNativeName: 'distribution name',
+ examples: ['pkg:pypi/django@4.2.11'],
+ }),
+ nuget: makeBuiltinPurlDefinition({
+ type: 'nuget',
+ typeName: 'NuGet Package',
+ description: 'Starter definition for NuGet packages.',
+ namespaceRequirement: 'prohibited',
+ nameNativeName: 'package ID',
+ examples: ['pkg:nuget/EnterpriseLibrary.Common@6.0.1304'],
+ }),
+ golang: makeBuiltinPurlDefinition({
+ type: 'golang',
+ typeName: 'Go Module',
+ description: 'Starter definition for Go modules.',
+ namespaceRequirement: 'required',
+ namespaceNativeName: 'module path namespace',
+ nameNativeName: 'module name',
+ examples: ['pkg:golang/google.golang.org/genproto@abcdef123456'],
+ }),
+ github: makeBuiltinPurlDefinition({
+ type: 'github',
+ typeName: 'GitHub Repository',
+ description: 'Starter definition for software identified by GitHub owner and repository.',
+ namespaceRequirement: 'required',
+ namespaceNativeName: 'owner',
+ nameNativeName: 'repository',
+ examples: ['pkg:github/package-url/purl-spec@244fd47e07d1004f0aed9c'],
+ }),
+};
+
+function encodePurlPart(value) {
+ return encodeURIComponent(String(value ?? '').trim());
+}
+function decodePurlPart(value) {
+ try { return decodeURIComponent(value); } catch { return value; }
+}
+function normalizePurlComponent(value, definition) {
+ let out = String(value ?? '').trim();
+ if (definition && definition.case_sensitive === false) out = out.toLowerCase();
+ return out;
+}
+function componentRequirement(definition, component, fallback='optional') {
+ return definition?.[`${component}_definition`]?.requirement || fallback;
+}
+function componentDefinition(definition, component) {
+ return definition?.[`${component}_definition`] || {};
+}
+function requirementClass(requirement) {
+ return `purl-requirement purl-${requirement || 'optional'}`;
+}
+function validateDefinitionComponent(name, value, allowedRequirements, errors) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ errors.push(`${name} must be an object.`);
+ return;
+ }
+ if (!allowedRequirements.includes(value.requirement)) {
+ errors.push(`${name}.requirement must be ${allowedRequirements.join(' or ')}.`);
+ }
+ if (value.case_sensitive !== undefined && typeof value.case_sensitive !== 'boolean') {
+ errors.push(`${name}.case_sensitive must be boolean.`);
+ }
+ if (value.normalization_rules !== undefined && (!Array.isArray(value.normalization_rules) || value.normalization_rules.some(x => typeof x !== 'string'))) {
+ errors.push(`${name}.normalization_rules must be an array of strings.`);
+ }
+ if (value.permitted_characters) {
+ try { new RegExp(value.permitted_characters); }
+ catch (err) { errors.push(`${name}.permitted_characters is not a valid ECMA-262 regular expression: ${err.message}`); }
+ }
+}
+function validatePurlTypeDefinition(definition) {
+ const errors = [];
+ if (!definition || typeof definition !== 'object' || Array.isArray(definition)) return ['Definition must be a JSON object.'];
+ if (definition.$id === PURL_TYPE_DEFINITION_SCHEMA_ID && definition.title === 'Package-URL Type Definition') {
+ return ['This file is the type-definition schema itself, not a concrete PURL type definition. Load files such as maven-definition.json or pypi-definition.json.'];
+ }
+ const allowed = new Set(['$schema','$id','type','type_name','description','repository','namespace_definition','name_definition','version_definition','qualifiers_definition','subpath_definition','examples','note','reference_urls']);
+ for (const key of Object.keys(definition)) if (!allowed.has(key)) errors.push(`Unknown top-level property: ${key}`);
+ for (const key of ['$id','type','type_name','description','repository','namespace_definition','name_definition','examples']) {
+ if (definition[key] === undefined || definition[key] === null) errors.push(`Missing required property: ${key}`);
+ }
+ if (definition.$schema !== undefined && definition.$schema !== PURL_TYPE_DEFINITION_SCHEMA_ID) errors.push(`$schema must be ${PURL_TYPE_DEFINITION_SCHEMA_ID}`);
+ if (definition.$id && !/^https:\/\/packageurl\.org\/types\/[a-z0-9-]+-definition\.json$/.test(definition.$id)) errors.push('$id does not match the Package-URL type-definition URI pattern.');
+ if (!PURL_TYPE_PATTERN.test(String(definition.type || ''))) errors.push('type must match ^[a-z][a-z0-9.-]+$.');
+ if (typeof definition.type_name !== 'string' || !definition.type_name.trim()) errors.push('type_name must be a non-empty string.');
+ if (typeof definition.description !== 'string' || !definition.description.trim()) errors.push('description must be a non-empty string.');
+ if (!definition.repository || typeof definition.repository !== 'object' || Array.isArray(definition.repository)) {
+ errors.push('repository must be an object.');
+ } else if (typeof definition.repository.use_repository !== 'boolean') {
+ errors.push('repository.use_repository must be boolean.');
+ }
+ validateDefinitionComponent('namespace_definition', definition.namespace_definition, ['required','optional','prohibited'], errors);
+ validateDefinitionComponent('name_definition', definition.name_definition, ['required'], errors);
+ if (definition.version_definition !== undefined) validateDefinitionComponent('version_definition', definition.version_definition, ['optional'], errors);
+ if (definition.subpath_definition !== undefined) validateDefinitionComponent('subpath_definition', definition.subpath_definition, ['optional'], errors);
+ if (!Array.isArray(definition.examples) || !definition.examples.length) {
+ errors.push('examples must be a non-empty array.');
+ } else {
+ const seen = new Set();
+ for (const example of definition.examples) {
+ if (typeof example !== 'string' || !/^pkg:[a-z][a-z0-9.-]+\/.+/.test(example)) errors.push(`Invalid example PURL: ${String(example)}`);
+ if (seen.has(example)) errors.push(`Duplicate example PURL: ${example}`);
+ seen.add(example);
+ }
+ }
+ if (definition.qualifiers_definition !== undefined) {
+ if (!Array.isArray(definition.qualifiers_definition)) errors.push('qualifiers_definition must be an array.');
+ else {
+ const keys = new Set();
+ for (const [i, q] of definition.qualifiers_definition.entries()) {
+ if (!q || typeof q !== 'object' || Array.isArray(q)) { errors.push(`qualifiers_definition[${i}] must be an object.`); continue; }
+ if (!q.key || typeof q.key !== 'string') errors.push(`qualifiers_definition[${i}].key is required.`);
+ else {
+ const key = q.key.toLowerCase();
+ if (!PURL_QUALIFIER_KEY_PATTERN.test(key)) errors.push(`Invalid qualifier key: ${q.key}`);
+ if (keys.has(key)) errors.push(`Duplicate qualifier key: ${q.key}`);
+ keys.add(key);
+ }
+ if (!q.description || typeof q.description !== 'string') errors.push(`qualifiers_definition[${i}].description is required.`);
+ if (q.requirement !== undefined && !['required','optional'].includes(q.requirement)) errors.push(`qualifier ${q.key || i} requirement must be required or optional.`);
+ }
+ }
+ }
+ return errors;
+}
+function normalizePurlTypeDefinition(definition) {
+ const out = deepClone(definition);
+ out.type = String(out.type || '').trim().toLowerCase();
+ out.namespace_definition ||= { requirement:'optional' };
+ out.name_definition ||= { requirement:'required' };
+ out.version_definition ||= { requirement:'optional' };
+ out.subpath_definition ||= { requirement:'optional' };
+ out.qualifiers_definition = Array.isArray(out.qualifiers_definition) ? out.qualifiers_definition : [];
+ out.examples = Array.isArray(out.examples) ? out.examples : [];
+ return out;
+}
+function loadStoredPurlDefinitions() {
+ try {
+ const parsed = JSON.parse(localStorage.getItem('cytrics.purlTypeDefinitions.v1') || '{}');
+ const valid = {};
+ for (const def of Object.values(parsed || {})) {
+ const errors = validatePurlTypeDefinition(def);
+ if (!errors.length) valid[def.type] = normalizePurlTypeDefinition(def);
+ }
+ return valid;
+ } catch { return {}; }
+}
+function persistPurlDefinitions(definitions) {
+ try {
+ const imported = {};
+ for (const [type, def] of Object.entries(definitions || {})) {
+ if (!BUILTIN_PURL_TYPE_DEFINITIONS[type] || !deepEqual(def, BUILTIN_PURL_TYPE_DEFINITIONS[type])) imported[type] = def;
+ }
+ localStorage.setItem('cytrics.purlTypeDefinitions.v1', JSON.stringify(imported));
+ } catch {}
+}
+function parseQualifierText(text) {
+ const q = {};
+ for (const line of String(text || '').split(/\r?\n|,/)) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ const idx = trimmed.indexOf('=');
+ if (idx <= 0) throw new Error(`Qualifier must use key=value: ${trimmed}`);
+ const key = trimmed.slice(0, idx).trim().toLowerCase();
+ const value = trimmed.slice(idx + 1).trim();
+ if (!PURL_QUALIFIER_KEY_PATTERN.test(key)) throw new Error(`Invalid qualifier key: ${key}`);
+ if (Object.prototype.hasOwnProperty.call(q, key)) throw new Error(`Duplicate qualifier key: ${key}`);
+ q[key] = value;
+ }
+ return q;
+}
+function normalizeSubpath(value) {
+ const raw = String(value || '').trim().replace(/^#+/, '').replace(/^\/+|\/+$/g, '');
+ if (!raw) return '';
+ const segments = raw.split('/');
+ if (segments.some(x => !x || x === '.' || x === '..')) throw new Error('Subpath cannot contain empty, ".", or ".." segments.');
+ return segments.join('/');
+}
+function validatePermittedCharacters(value, definition, label, errors) {
+ if (!value || !definition?.permitted_characters) return;
+ try {
+ const pattern = new RegExp(definition.permitted_characters);
+ if (!pattern.test(value)) errors.push(`${label} does not match permitted_characters: ${definition.permitted_characters}`);
+ } catch (err) {
+ errors.push(`${label} definition has an invalid permitted_characters expression: ${err.message}`);
+ }
+}
+function canonicalizePurlFields(fields, definition) {
+ const namespaceDef = componentDefinition(definition, 'namespace');
+ const nameDef = componentDefinition(definition, 'name');
+ const versionDef = componentDefinition(definition, 'version');
+ const subpathDef = componentDefinition(definition, 'subpath');
+ return {
+ type: String(fields.type || '').trim().toLowerCase(),
+ namespace: normalizePurlComponent(fields.namespace, namespaceDef).replace(/^\/+|\/+$/g, ''),
+ name: normalizePurlComponent(fields.name, nameDef),
+ version: normalizePurlComponent(fields.version, versionDef),
+ qualifiers: Object.fromEntries(Object.entries(fields.qualifiers || {}).map(([k,v]) => [String(k).trim().toLowerCase(), String(v ?? '').trim()])),
+ subpath: normalizePurlComponent(normalizeSubpath(fields.subpath), subpathDef),
+ };
+}
+function validatePurlFields(fields, definition) {
+ const errors = [], warnings = [];
+ const f = canonicalizePurlFields(fields, definition);
+ if (!PURL_TYPE_PATTERN.test(f.type)) errors.push('Type must match ^[a-z][a-z0-9.-]+$.');
+ const namespaceReq = componentRequirement(definition, 'namespace', 'optional');
+ if (namespaceReq === 'required' && !f.namespace) errors.push('Namespace is required for this PURL type.');
+ if (namespaceReq === 'prohibited' && f.namespace) errors.push('Namespace is prohibited for this PURL type.');
+ if (!f.name) errors.push('Name is required.');
+ validatePermittedCharacters(f.namespace, componentDefinition(definition, 'namespace'), 'Namespace', errors);
+ validatePermittedCharacters(f.name, componentDefinition(definition, 'name'), 'Name', errors);
+ validatePermittedCharacters(f.version, componentDefinition(definition, 'version'), 'Version', errors);
+ validatePermittedCharacters(f.subpath, componentDefinition(definition, 'subpath'), 'Subpath', errors);
+
+ const qualifierDefs = new Map((definition?.qualifiers_definition || []).map(q => [q.key.toLowerCase(), q]));
+ for (const qDef of qualifierDefs.values()) {
+ const value = f.qualifiers[qDef.key.toLowerCase()];
+ if ((qDef.requirement || 'optional') === 'required' && !value && qDef.default_value === undefined) errors.push(`Qualifier ${qDef.key} is required.`);
+ }
+ for (const [key, value] of Object.entries(f.qualifiers)) {
+ if (!PURL_QUALIFIER_KEY_PATTERN.test(key)) errors.push(`Invalid qualifier key: ${key}`);
+ if (!value) errors.push(`Qualifier ${key} has an empty value.`);
+ if (definition && definition.type !== 'generic' && !qualifierDefs.has(key)) warnings.push(`Qualifier ${key} is not declared by the selected type definition.`);
+ }
+ if (!definition) warnings.push('No loaded type definition matches this type; only base PURL syntax is being checked.');
+ return { fields:f, errors, warnings };
+}
+function buildPackageUrl({ type, namespace, name, version, qualifiers, subpath }) {
+ const t = String(type || '').trim().toLowerCase();
+ const n = String(name || '').trim();
+ if (!PURL_TYPE_PATTERN.test(t)) throw new Error('PURL type is missing or invalid.');
+ if (!n) throw new Error('PURL name is required.');
+ let purl = `pkg:${t}/`;
+ const ns = String(namespace || '').trim().replace(/^\/+|\/+$/g, '');
+ if (ns) purl += ns.split('/').map(encodePurlPart).join('/') + '/';
+ purl += encodePurlPart(n);
+ if (String(version || '').trim()) purl += '@' + encodePurlPart(version);
+ const q = qualifiers || {};
+ const keys = Object.keys(q).filter(k => q[k] !== undefined && q[k] !== null && String(q[k]).trim() !== '').sort();
+ if (keys.length) purl += '?' + keys.map(k => `${encodePurlPart(k.toLowerCase())}=${encodePurlPart(q[k])}`).join('&');
+ const sp = normalizeSubpath(subpath);
+ if (sp) purl += '#' + sp.split('/').map(encodePurlPart).join('/');
+ return purl;
+}
+function parsePackageUrl(value) {
+ const input = String(value || '').trim();
+ if (!input.startsWith('pkg:')) throw new Error('PURL must begin with pkg:.');
+ let rest = input.slice(4), subpath = '', query = '';
+ const hashAt = rest.indexOf('#');
+ if (hashAt >= 0) { subpath = rest.slice(hashAt + 1); rest = rest.slice(0, hashAt); }
+ const queryAt = rest.indexOf('?');
+ if (queryAt >= 0) { query = rest.slice(queryAt + 1); rest = rest.slice(0, queryAt); }
+ const slashAt = rest.indexOf('/');
+ if (slashAt <= 0) throw new Error('PURL must contain a type followed by /.');
+ const type = rest.slice(0, slashAt).toLowerCase();
+ let pathVersion = rest.slice(slashAt + 1), version = '';
+ const at = pathVersion.lastIndexOf('@');
+ if (at >= 0) { version = decodePurlPart(pathVersion.slice(at + 1)); pathVersion = pathVersion.slice(0, at); }
+ const segments = pathVersion.split('/').filter(Boolean).map(decodePurlPart);
+ if (!segments.length) throw new Error('PURL name is missing.');
+ const name = segments.pop();
+ const namespace = segments.join('/');
+ const qualifiers = {};
+ if (query) {
+ for (const pair of query.split('&')) {
+ if (!pair) continue;
+ const eq = pair.indexOf('=');
+ const key = decodePurlPart(eq >= 0 ? pair.slice(0, eq) : pair).toLowerCase();
+ const val = decodePurlPart(eq >= 0 ? pair.slice(eq + 1) : '');
+ if (Object.prototype.hasOwnProperty.call(qualifiers, key)) throw new Error(`Duplicate qualifier key: ${key}`);
+ qualifiers[key] = val;
+ }
+ }
+ return { type, namespace, name, version, qualifiers, subpath: subpath.split('/').map(decodePurlPart).join('/') };
+}
+function packageNameDisplay(namespace, name) {
+ return [namespace, name].filter(Boolean).join('/') || name || 'PURL package';
+}
+
/* ========================================================================= *
* 2. ANCHOR SYSTEM *
* ========================================================================= */
@@ -941,6 +1352,7 @@
SW_ADD: 'sw.add',
SW_REMOVE: 'sw.remove',
SW_SET_FIELD: 'sw.setField',
+ SW_ADD_PURL_REL: 'sw.addPurlRelationship',
// Hardware
HW_ADD: 'hw.add',
HW_REMOVE: 'hw.remove',
@@ -984,6 +1396,25 @@
next.software.push(sw);
return { sbom: next, status: 'applied' };
}
+ case OP.SW_ADD_PURL_REL: {
+ const sourceUUID = resolveAnchor(op.srcAnchor, index);
+ if (!sourceUUID) return { sbom: s, status: 'unresolved', message: 'source software anchor not found' };
+ const sw = clone(op.software);
+ if (!sw.UUID) sw.UUID = genUUID();
+ const next = clone(s);
+ next.software = next.software || [];
+ next.relationships = next.relationships || [];
+ const alreadyPresent = op.anchor && resolveAnchor(op.anchor, index);
+ const targetUUID = alreadyPresent || sw.UUID;
+ if (!alreadyPresent && !next.software.some(x => x.UUID === sw.UUID)) {
+ next.software.push(sw);
+ }
+ const relType = op.relationship || 'Uses';
+ if (!next.relationships.some(r => r.xUUID === sourceUUID && r.yUUID === targetUUID && r.relationship === relType)) {
+ next.relationships.push({ xUUID: sourceUUID, yUUID: targetUUID, relationship: relType });
+ }
+ return { sbom: next, status: 'applied' };
+ }
case OP.SW_REMOVE: {
const uuid = resolveAnchor(op.anchor, index);
if (!uuid) return { sbom: s, status: 'unresolved', message: 'anchor not found' };
@@ -1875,6 +2306,13 @@
}, [list, state.searchTerm, state.softwareFilter, baseByUUID]);
const selected = list.find(s => s.UUID === state.selectedSoftwareUUID);
+ const [showPurlBuilder, setShowPurlBuilder] = useState(false);
+
+ // Keep the entry form uncluttered. The builder opens only on request and
+ // closes when the user selects another software entry.
+ useEffect(() => {
+ setShowPurlBuilder(false);
+ }, [state.selectedSoftwareUUID]);
function addSoftware() {
const sw = {
@@ -1962,7 +2400,9 @@
+ Builds and validates a Package URL, creates a new CyTRICS software entry with notHashable: true,
+ stores the PURL in name[], and creates a relationship from the selected file to the new component.
+ Imported type definitions are checked against the supplied Package-URL type-definition schema structure and saved in this browser.
+
+
+
+ Type-definition registry and PURL parser
+
+
+
Load concrete type-definition JSON
+ importTypeDefinitionFiles(e.target.files)} />
+
+ A type-definition schema describes the definition format; load concrete package-type definitions here. Load one or more concrete definition files to replace the built-in starter templates.
+