Skip to content

Commit 020eb89

Browse files
committed
softwareRequirements: Validate values are URL + make isUrl stricter
Without this check, users would be allowed to write free text even though Codemeta forbids it. We only noticed this when jsonld.js started rewriting 'foo: bar' to ' bar' because it was parsing 'foo:' as an invalid URI scheme and stripping it.
1 parent 29f932f commit 020eb89

File tree

5 files changed

+136
-9
lines changed

5 files changed

+136
-9
lines changed

cypress/integration/special_fields.js

+90-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Copyright (C) 2020 The Software Heritage developers
2+
* Copyright (C) 2020-2024 The Software Heritage developers
33
* See the AUTHORS file at the top-level directory of this distribution
44
* License: GNU Affero General Public License version 3, or any later version
55
* See top-level LICENSE file for more information
@@ -10,6 +10,7 @@
1010
*/
1111

1212
"use strict";
13+
1314
describe('Funder id', function() {
1415
it('can be exported', function() {
1516
cy.get('#name').type('My Test Software');
@@ -88,3 +89,91 @@ describe('Funder name', function() {
8889
});
8990
});
9091

92+
describe('Software requirements', function() {
93+
it('can be exported as multiple values', function() {
94+
cy.get('#name').type('My Test Software');
95+
96+
cy.get('#softwareRequirements').type('https://www.gtk.org\nhttps://github.com/anholt/libepoxy\nhttps://github.com/GNOME/libxml2');
97+
98+
cy.get('#generateCodemetaV2').click();
99+
100+
cy.get('#errorMessage').should('have.text', '');
101+
cy.get('#codemetaText').then((elem) => JSON.parse(elem.text()))
102+
.should('deep.equal', {
103+
"@context": "https://doi.org/10.5063/schema/codemeta-2.0",
104+
"type": "SoftwareSourceCode",
105+
"name": "My Test Software",
106+
"softwareRequirements": [
107+
"https://www.gtk.org",
108+
"https://github.com/anholt/libepoxy",
109+
"https://github.com/GNOME/libxml2",
110+
]
111+
});
112+
});
113+
114+
it('can be imported from multiple values', function() {
115+
cy.get('#codemetaText').then((elem) =>
116+
elem.text(JSON.stringify({
117+
"@context": "https://doi.org/10.5063/schema/codemeta-2.0",
118+
"@type": "SoftwareSourceCode",
119+
"name": "My Test Software",
120+
"softwareRequirements": [
121+
"https://www.gtk.org",
122+
"https://github.com/anholt/libepoxy",
123+
"https://github.com/GNOME/libxml2",
124+
]
125+
}))
126+
);
127+
cy.get('#importCodemeta').click();
128+
129+
cy.get('#softwareRequirements').should('have.value', 'https://www.gtk.org\nhttps://github.com/anholt/libepoxy\nhttps://github.com/GNOME/libxml2');
130+
});
131+
132+
it('can be exported to a single URI', function() {
133+
cy.get('#name').type('My Test Software');
134+
135+
cy.get('#softwareRequirements').type('https://github.com/GNOME/libxml2');
136+
137+
cy.get('#generateCodemetaV2').click();
138+
139+
cy.get('#errorMessage').should('have.text', '');
140+
cy.get('#codemetaText').then((elem) => JSON.parse(elem.text()))
141+
.should('deep.equal', {
142+
"@context": "https://doi.org/10.5063/schema/codemeta-2.0",
143+
"type": "SoftwareSourceCode",
144+
"name": "My Test Software",
145+
"softwareRequirements": "https://github.com/GNOME/libxml2",
146+
});
147+
});
148+
149+
it('can be imported from a single URI', function() {
150+
cy.get('#codemetaText').then((elem) =>
151+
elem.text(JSON.stringify({
152+
"@context": "https://doi.org/10.5063/schema/codemeta-2.0",
153+
"@type": "SoftwareSourceCode",
154+
"name": "My Test Software",
155+
"softwareRequirements": "https://github.com/GNOME/libxml2",
156+
}))
157+
);
158+
cy.get('#importCodemeta').click();
159+
160+
cy.get('#softwareRequirements').should('have.value', 'https://github.com/GNOME/libxml2');
161+
});
162+
163+
it('cannot be exported as text', function() {
164+
cy.get('#name').type('My Test Software');
165+
166+
cy.get('#softwareRequirements').type('libxml2');
167+
168+
cy.get('#generateCodemetaV2').click();
169+
170+
cy.get('#errorMessage').should('have.text', 'Invalid URL in field "softwareRequirements": "libxml2"');
171+
cy.get('#codemetaText').then((elem) => JSON.parse(elem.text()))
172+
.should('deep.equal', {
173+
"@context": "https://doi.org/10.5063/schema/codemeta-2.0",
174+
"type": "SoftwareSourceCode",
175+
"name": "My Test Software",
176+
"softwareRequirements": [],
177+
});
178+
});
179+
});

index.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ <h1>CodeMeta generator v3.0</h1>
204204
<textarea rows="4" cols="50"
205205
name="softwareRequirements" id="softwareRequirements"
206206
placeholder=
207-
"Python 3.4
207+
"https://www.python.org/downloads/release/python-3130/
208208
https://github.com/psf/requests"></textarea>
209209
</fieldset>
210210

js/codemeta_generation.js

+39-5
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,14 @@ const directCodemetaFields = [
9898
'referencePublication'
9999
];
100100

101+
// Tuples of codemeta property, character joining/splitting items in a textarea, and optionally
102+
// post-processing for deserialization and pre-processing for deserialization
101103
const splittedCodemetaFields = [
102104
['keywords', ','],
103105
['programmingLanguage', ','],
104106
['runtimePlatform', ','],
105107
['operatingSystem', ','],
106-
['softwareRequirements', '\n'],
108+
['softwareRequirements', '\n', generateUri, importUri],
107109
['relatedLink', '\n'],
108110
]
109111

@@ -136,6 +138,17 @@ function generateBlankNodeId(customId) {
136138
return `_:${customId}`;
137139
}
138140

141+
// Unambiguously converts a free-form text field that might be a URI or actual free text
142+
// in JSON-LD
143+
function generateUri(fieldName, text) {
144+
if (isUrl(text)) {
145+
return {"@id": text};
146+
} else {
147+
setError(`Invalid URL in field "${fieldName}": "${text}"`);
148+
return undefined;
149+
}
150+
}
151+
139152
function generateShortOrg(fieldName) {
140153
var affiliation = getIfSet(fieldName);
141154
if (affiliation !== undefined) {
@@ -246,9 +259,15 @@ async function buildExpandedDocWithAllContexts() {
246259
splittedCodemetaFields.forEach(function (item, index) {
247260
const id = item[0];
248261
const separator = item[1];
249-
const value = getIfSet('#' + id);
262+
const serializer = item[2];
263+
const deserializer = item[3];
264+
let value = getIfSet('#' + id);
250265
if (value !== undefined) {
251-
doc[id] = value.split(separator).map(trimSpaces);
266+
value = value.split(separator).map(trimSpaces);
267+
if (serializer !== undefined) {
268+
value = value.map((item) => serializer(id, item));
269+
}
270+
doc[id] = value;
252271
}
253272
});
254273

@@ -275,6 +294,8 @@ async function generateCodemeta(codemetaVersion = "2.0") {
275294
var inputForm = document.querySelector('#inputForm');
276295
var codemetaText, errorHTML;
277296

297+
setError();
298+
278299
if (inputForm.checkValidity()) {
279300
// Expand document with all contexts before compacting
280301
// to allow generating property from any context
@@ -285,12 +306,11 @@ async function generateCodemeta(codemetaVersion = "2.0") {
285306
}
286307
else {
287308
codemetaText = "";
288-
errorHTML = "invalid input (see error above)";
309+
setError("invalid input (see error above)");
289310
inputForm.reportValidity();
290311
}
291312

292313
document.querySelector('#codemetaText').innerText = codemetaText;
293-
setError(errorHTML);
294314

295315

296316
// Run validator on the exported value, for extra validation.
@@ -307,6 +327,11 @@ async function generateCodemeta(codemetaVersion = "2.0") {
307327
}
308328
}
309329

330+
// Imports a field that can be either URI or free-form text into a free-form text field
331+
function importUri(fieldName, doc) {
332+
return doc;
333+
}
334+
310335
// Imports a single field (name or @id) from an Organization.
311336
function importShortOrg(fieldName, doc) {
312337
if (doc !== undefined) {
@@ -433,10 +458,19 @@ async function importCodemeta() {
433458
splittedCodemetaFields.forEach(function (item, index) {
434459
const id = item[0];
435460
const separator = item[1];
461+
const serializer = item[2];
462+
const deserializer = item[3];
436463
let value = doc[id];
437464
if (value !== undefined) {
438465
if (Array.isArray(value)) {
466+
if (deserializer !== undefined) {
467+
value = value.map((item) => deserializer(id, item));
468+
}
439469
value = value.join(separator);
470+
} else {
471+
if (deserializer !== undefined) {
472+
value = deserializer(id, value);
473+
}
440474
}
441475
setIfDefined('#' + id, value);
442476
}

js/utils.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ function trimSpaces(s) {
2828
// From https://stackoverflow.com/a/43467144
2929
function isUrl(s) {
3030
try {
31-
new URL(s);
31+
const url = new URL(s);
32+
if (url.origin == "null") {
33+
// forbids "foo: bar" as a URL, for example
34+
return false;
35+
}
3236
return true;
3337
} catch (e) {
3438
return false;

js/validation/things.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ var softwareFieldValidators = {
217217
"processorRequirements": validateTexts,
218218
"releaseNotes": validateTextsOrUrls,
219219
"softwareHelp": validateCreativeWorks,
220-
"softwareRequirements": noValidation, // TODO: validate SoftwareSourceCode
220+
"softwareRequirements": validateUrls,
221221
"softwareVersion": validateText, // TODO?
222222
"storageRequirements": validateTextsOrUrls,
223223
"supportingData": noValidation, // TODO

0 commit comments

Comments
 (0)