diff --git a/services/tangled/tangled-helper.js b/services/tangled/tangled-helper.js new file mode 100644 index 0000000000000..b36c116c32258 --- /dev/null +++ b/services/tangled/tangled-helper.js @@ -0,0 +1,61 @@ +const description = ` +[Tangled](https://tangled.org) does not expose license metadata via its AT Protocol record, +so this badge fetches the project's LICENSE file from the default branch and matches it +against well-known license texts to derive an SPDX id. +` + +function httpErrorsFor(notFoundMessage = 'repo not found') { + return { 404: notFoundMessage } +} + +// Order matters: more-specific patterns must precede subsets. +const LICENSE_PATTERNS = [ + { spdx: 'AGPL-3.0', re: /GNU AFFERO GENERAL PUBLIC LICENSE\s+Version 3/i }, + { spdx: 'GPL-3.0', re: /GNU GENERAL PUBLIC LICENSE\s+Version 3/i }, + { spdx: 'GPL-2.0', re: /GNU GENERAL PUBLIC LICENSE\s+Version 2/i }, + { spdx: 'LGPL-3.0', re: /GNU LESSER GENERAL PUBLIC LICENSE\s+Version 3/i }, + { spdx: 'LGPL-2.1', re: /GNU LESSER GENERAL PUBLIC LICENSE\s+Version 2\.1/i }, + { spdx: 'Apache-2.0', re: /Apache License,?\s+Version 2\.0/i }, + { spdx: 'MPL-2.0', re: /Mozilla Public License Version 2\.0/i }, + { spdx: 'BSL-1.0', re: /Boost Software License - Version 1\.0/i }, + { spdx: 'EPL-2.0', re: /Eclipse Public License - v 2\.0/i }, + { + spdx: 'BSD-3-Clause', + re: /Redistribution and use in source and binary forms[\s\S]+Neither the name of[\s\S]+nor the names/i, + }, + { + spdx: 'BSD-2-Clause', + re: /Redistribution and use in source and binary forms/i, + }, + // ISC before 0BSD: ISC requires the copyright notice, 0BSD does not. + { + spdx: 'ISC', + re: /Permission to use, copy, modify, and\/or distribute this software[\s\S]+provided that the above copyright notice/i, + }, + { + spdx: '0BSD', + re: /Permission to use, copy, modify, and\/or distribute this software/i, + }, + { + spdx: 'MIT', + re: /Permission is hereby granted, free of charge, to any person obtaining a copy/i, + }, + { spdx: 'WTFPL', re: /DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE/i }, + { + spdx: 'Unlicense', + re: /This is free and unencumbered software released into the public domain/i, + }, + { spdx: 'CC0-1.0', re: /Creative Commons (?:CC0|Zero) 1\.0/i }, +] + +const SCAN_BYTES = 4096 + +function detectSpdxId(content) { + const head = content.slice(0, SCAN_BYTES) + for (const { spdx, re } of LICENSE_PATTERNS) { + if (re.test(head)) return spdx + } + return undefined +} + +export { description, httpErrorsFor, detectSpdxId } diff --git a/services/tangled/tangled-license.service.js b/services/tangled/tangled-license.service.js new file mode 100644 index 0000000000000..8a13a8fd224f8 --- /dev/null +++ b/services/tangled/tangled-license.service.js @@ -0,0 +1,55 @@ +import { BaseService, NotFound, pathParams } from '../index.js' +import { renderLicenseBadge } from '../licenses.js' +import { description, detectSpdxId, httpErrorsFor } from './tangled-helper.js' + +const CANDIDATE_PATHS = [ + 'LICENSE', + 'LICENSE.md', + 'LICENSE.txt', + 'COPYING', + 'COPYING.md', +] + +export default class TangledLicense extends BaseService { + static category = 'license' + static route = { base: 'tangled/l', pattern: ':owner/:repo' } + static openApi = { + '/tangled/l/{owner}/{repo}': { + get: { + summary: 'Tangled License', + description, + parameters: pathParams( + { name: 'owner', example: 'tangled.org' }, + { name: 'repo', example: 'core' }, + ), + }, + }, + } + + static defaultBadgeData = { label: 'license' } + + static render({ license }) { + if (license === undefined) return { message: 'unknown' } + if (license === null) return { message: 'not specified' } + return renderLicenseBadge({ license }) + } + + async handle({ owner, repo }) { + const httpErrors = httpErrorsFor('repo not found') + for (const path of CANDIDATE_PATHS) { + try { + const { buffer } = await this._request({ + url: `https://tangled.org/${owner}/${repo}/raw/HEAD/${path}`, + httpErrors, + }) + return this.constructor.render({ + license: detectSpdxId(buffer.toString()), + }) + } catch (e) { + if (e instanceof NotFound) continue + throw e + } + } + return this.constructor.render({ license: null }) + } +} diff --git a/services/tangled/tangled-license.spec.js b/services/tangled/tangled-license.spec.js new file mode 100644 index 0000000000000..5729164c24d0a --- /dev/null +++ b/services/tangled/tangled-license.spec.js @@ -0,0 +1,67 @@ +import { test, given } from 'sazerac' +import { detectSpdxId } from './tangled-helper.js' +import TangledLicense from './tangled-license.service.js' + +const MIT_TEXT = `MIT License + +Copyright (c) 2024 Example + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction,` + +const MIT_TEXT_ALT_YEAR = `MIT License + +Copyright (c) 2019 Another Author + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction,` + +const APACHE_TEXT = ` Apache License + Version 2.0, January 2004 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION` + +const GPL3_TEXT = ` GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007` + +const BSD3_TEXT = `Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission.` + +const BSD2_TEXT = `Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.` + +test(TangledLicense.render, () => { + given({ license: undefined }).expect({ message: 'unknown' }) + given({ license: null }).expect({ message: 'not specified' }) + given({ license: 'MIT' }).expect({ message: 'MIT', color: 'green' }) + given({ license: 'Apache-2.0' }).expect({ + message: 'Apache-2.0', + color: 'green', + }) + given({ license: 'GPL-3.0' }).expect({ message: 'GPL-3.0', color: 'orange' }) +}) + +test(detectSpdxId, () => { + given(MIT_TEXT).expect('MIT') + given(MIT_TEXT_ALT_YEAR).expect('MIT') + given(APACHE_TEXT).expect('Apache-2.0') + given(GPL3_TEXT).expect('GPL-3.0') + given(BSD3_TEXT).expect('BSD-3-Clause') + given(BSD2_TEXT).expect('BSD-2-Clause') + given('This is a proprietary software license.').expect(undefined) +}) diff --git a/services/tangled/tangled-license.tester.js b/services/tangled/tangled-license.tester.js new file mode 100644 index 0000000000000..592c738beed2d --- /dev/null +++ b/services/tangled/tangled-license.tester.js @@ -0,0 +1,97 @@ +import { licenseToColor } from '../licenses.js' +import { createServiceTester } from '../tester.js' +export const t = await createServiceTester() + +const mitColor = licenseToColor('MIT') +const apacheColor = licenseToColor('Apache-2.0') +const gplColor = licenseToColor('GPL-3.0') + +const MIT_BODY = `MIT License + +Copyright (c) 2024 Example + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction,` + +const APACHE_BODY = ` Apache License, Version 2.0 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION` +const GPL3_BODY = ` GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007` + +t.create('MIT license') + .get('/testowner/testrepo.json') + .intercept(nock => + nock('https://tangled.org') + .get('/testowner/testrepo/raw/HEAD/LICENSE') + .reply(200, MIT_BODY), + ) + .expectBadge({ label: 'license', message: 'MIT', color: mitColor }) + +t.create('Apache-2.0 license') + .get('/testowner/testrepo.json') + .intercept(nock => + nock('https://tangled.org') + .get('/testowner/testrepo/raw/HEAD/LICENSE') + .reply(200, APACHE_BODY), + ) + .expectBadge({ label: 'license', message: 'Apache-2.0', color: apacheColor }) + +t.create('GPL-3.0 license') + .get('/testowner/testrepo.json') + .intercept(nock => + nock('https://tangled.org') + .get('/testowner/testrepo/raw/HEAD/LICENSE') + .reply(200, GPL3_BODY), + ) + .expectBadge({ label: 'license', message: 'GPL-3.0', color: gplColor }) + +t.create('unrecognised license body') + .get('/testowner/testrepo.json') + .intercept(nock => + nock('https://tangled.org') + .get('/testowner/testrepo/raw/HEAD/LICENSE') + .reply(200, 'All rights reserved. Proprietary and confidential.'), + ) + .expectBadge({ label: 'license', message: 'unknown', color: 'lightgrey' }) + +t.create('falls through to LICENSE.md when LICENSE is absent') + .get('/testowner/testrepo.json') + .intercept(nock => + nock('https://tangled.org') + .get('/testowner/testrepo/raw/HEAD/LICENSE') + .reply(404) + .get('/testowner/testrepo/raw/HEAD/LICENSE.md') + .reply(200, MIT_BODY), + ) + .expectBadge({ label: 'license', message: 'MIT', color: mitColor }) + +t.create('no license file found') + .get('/testowner/nolicense.json') + .intercept(nock => + nock('https://tangled.org') + .get('/testowner/nolicense/raw/HEAD/LICENSE') + .reply(404) + .get('/testowner/nolicense/raw/HEAD/LICENSE.md') + .reply(404) + .get('/testowner/nolicense/raw/HEAD/LICENSE.txt') + .reply(404) + .get('/testowner/nolicense/raw/HEAD/COPYING') + .reply(404) + .get('/testowner/nolicense/raw/HEAD/COPYING.md') + .reply(404), + ) + .expectBadge({ + label: 'license', + message: 'not specified', + color: 'lightgrey', + }) + +t.create('dotted owner handle (e.g. icyphox.sh)') + .get('/icyphox.sh/testrepo.json') + .intercept(nock => + nock('https://tangled.org') + .get('/icyphox.sh/testrepo/raw/HEAD/LICENSE') + .reply(200, MIT_BODY), + ) + .expectBadge({ label: 'license', message: 'MIT', color: mitColor })