more robust "delivery suspend software"

upstream's code doesn't allow any way to match a version that `semver`
can't parse, not even with `*`:

```
> semver.satisfies('1-2-3','*');
false
> semver.satisfies('1.3','*');
false
> semver.satisfies('1.2.3','*');
true
```

while I was there, I added support for regexp in both name and
version, and added unit tests
This commit is contained in:
dakkar 2025-08-03 12:36:33 +01:00
parent 4505a848f6
commit 9b164003d4
4 changed files with 140 additions and 13 deletions

2
locales/index.d.ts vendored
View file

@ -6406,7 +6406,7 @@ export interface Locale extends ILocale {
*/
"deliverSuspendedSoftware": string;
/**
* semver 使>= 2024.3.1 2024.3.1-custom.0 >= 2024.3.1-0 prerelease
* You can specify a range of names and versions of the server's software to stop delivery for vulnerability or other reasons. This version information is provided by the server and is not guaranteed to be reliable. A semver range specification can be used to specify the version, but specifying >= 2024.3.1 will not include custom versions such as 2024.3.1-custom.0, so it is recommended that a prerelease specification be used, such as >= 2024.3.1-0. Specifying * will match any name or version, even when the server doesn't provide one. You can also provide a regular expression like /^sharkey-/i or /^1-/
*/
"deliverSuspendedSoftwareDescription": string;
/**

View file

@ -222,18 +222,37 @@ export class UtilityService {
@bindThis
public isDeliverSuspendedSoftware(software: Pick<MiInstance, 'softwareName' | 'softwareVersion'>): SoftwareSuspension | undefined {
if (software.softwareName == null) return undefined;
if (software.softwareVersion == null) {
// software version is null; suspend iff versionRange is *
return this.meta.deliverSuspendedSoftware.find(x =>
x.software === software.softwareName
&& x.versionRange.trim() === '*');
} else {
const softwareVersion = software.softwareVersion;
return this.meta.deliverSuspendedSoftware.find(x =>
x.software === software.softwareName
&& semver.satisfies(softwareVersion, x.versionRange, { includePrerelease: true }));
// a missing name or version is treated as the empty string
const instanceName = software.softwareName ?? '';
const instanceVersion = software.softwareVersion ?? '';
function maybeRegexpMatch(test: string, target: string): boolean {
const regexpStrPair = test.trim().match(/^\/(.+)\/(.*)$/);
if (!regexpStrPair) return false; // not a regexp, can't match
try {
return new RE2(regexpStrPair[1], regexpStrPair[2]).test(target);
} catch (err) {
return false; // not a well-formed regexp, can't match
}
}
// each element of `meta.deliverSuspendedSoftware` can have a
// normal string, a `*`, or a `/regexp/` for software or
// versionRange
return this.meta.deliverSuspendedSoftware.find(
x => (
(
x.software.trim() === '*' ||
x.software === instanceName ||
maybeRegexpMatch(x.software, instanceName)
) && (
x.versionRange.trim() === '*' ||
semver.satisfies(instanceVersion, x.versionRange, { includePrerelease: true }) ||
maybeRegexpMatch(x.versionRange, instanceVersion)
)
)
);
}
/**

View file

@ -1,18 +1,30 @@
import * as assert from 'assert';
import { Test } from '@nestjs/testing';
import { jest } from '@jest/globals';
import { CoreModule } from '@/core/CoreModule.js';
import { UtilityService } from '@/core/UtilityService.js';
import { MetaService } from '@/core/MetaService.js';
import { GlobalModule } from '@/GlobalModule.js';
import { MiMeta } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import type { SoftwareSuspension } from '@/models/Meta.js';
import type { MiInstance } from '@/models/Instance.js';
describe('UtilityService', () => {
let utilityService: UtilityService;
let meta: jest.Mocked<MiMeta>;
beforeAll(async () => {
const app = await Test.createTestingModule({
imports: [GlobalModule, CoreModule],
}).compile();
providers: [MetaService],
})
.overrideProvider(MetaService).useValue({ fetch: jest.fn() })
.compile();
utilityService = app.get<UtilityService>(UtilityService);
meta = app.get<MiMeta>(DI.meta) as jest.Mocked<MiMeta>;
});
describe('punyHost', () => {
@ -61,4 +73,99 @@ describe('UtilityService', () => {
assert.equal(utilityService.toPuny('www.foo.com:3000'), 'www.foo.com:3000');
});
});
describe('isDeliverSuspendedSoftware', () => {
function checkThis(rules: SoftwareSuspension[], target: Pick<MiInstance, 'softwareName' | 'softwareVersion'>, expect: boolean, message: string) {
meta.deliverSuspendedSoftware = rules;
const match = !!utilityService.isDeliverSuspendedSoftware(target);
assert.equal(match, expect, message);
}
test('equality', () => {
checkThis(
[{ software: 'Test', versionRange: '1.2.3' }],
{ softwareName: 'Test', softwareVersion: '1.2.3' },
true, 'straight match',
);
});
test('normal version', () => {
checkThis(
[{ software: 'Test', versionRange: '1.2.3-pre' }],
{ softwareName: 'Test', softwareVersion: '1.2.3-pre+g1234' },
true, 'straight match',
);
checkThis(
[{ software: 'Test', versionRange: '1.2.3' }],
{ softwareName: 'Test', softwareVersion: '1.2.3-pre+g1234' },
false, 'pre-release',
);
checkThis(
[{ software: 'Test', versionRange: '>= 1.0.0 < 2.0.0' }],
{ softwareName: 'Test', softwareVersion: '1.2.3-pre+g1234' },
true, 'range',
);
checkThis(
[{ software: 'Test', versionRange: '*' }],
{ softwareName: 'Test', softwareVersion: '1.2.3-pre+g1234' },
true, 'asterisk',
);
checkThis(
[{ software: 'Test', versionRange: '/.*/' }],
{ softwareName: 'Test', softwareVersion: '1.2.3-pre+g1234' },
true, 'regexp matching anything',
);
checkThis(
[{ software: 'Test', versionRange: '/-pre\\b/' }],
{ softwareName: 'Test', softwareVersion: '1.2.3-pre+g1234' },
true, 'regexp matching the version',
);
});
test('no version', () => {
checkThis(
[{ software: 'Test', versionRange: '1.2.3' }],
{ softwareName: 'Test', softwareVersion: null },
false, 'semver',
);
checkThis(
[{ software: 'Test', versionRange: '*' }],
{ softwareName: 'Test', softwareVersion: null },
true, 'asterisk',
);
checkThis(
[{ software: 'Test', versionRange: '/^$/' }],
{ softwareName: 'Test', softwareVersion: null },
true, 'regexp matching empty string',
);
checkThis(
[{ software: 'Test', versionRange: '/.*/' }],
{ softwareName: 'Test', softwareVersion: null },
true, 'regexp matching anything',
);
});
test('bad version', () => {
checkThis(
[{ software: 'Test', versionRange: '1.2.3' }],
{ softwareName: 'Test', softwareVersion: '1-2-3' },
false, "semver can't parse softwareVersion",
);
checkThis(
[{ software: 'Test', versionRange: '*' }],
{ softwareName: 'Test', softwareVersion: '1-2-3' },
true, 'asterisk',
);
checkThis(
[{ software: 'Test', versionRange: '/.*/' }],
{ softwareName: 'Test', softwareVersion: '1-2-3' },
true, 'regexp matching anything',
);
checkThis(
[{ software: 'Test', versionRange: '/^1-2-/' }],
{ softwareName: 'Test', softwareVersion: '1-2-3' },
true, 'regexp matching the version',
);
});
});
});

View file

@ -220,6 +220,7 @@ _serverSettings:
inquiryUrlDescription: "Specify the URL of a web page that contains a contact form or the instance operators' contact information."
aboutInstance: "About instance"
aboutInstanceDescription: "A longer description that will be displayed in the 'Instance Information' page, going in place of the regular instance description. Supports HTML."
deliverSuspendedSoftwareDescription: "You can specify a range of names and versions of the server's software to stop delivery for vulnerability or other reasons. This version information is provided by the server and is not guaranteed to be reliable. A semver range specification can be used to specify the version, but specifying >= 2024.3.1 will not include custom versions such as 2024.3.1-custom.0, so it is recommended that a prerelease specification be used, such as >= 2024.3.1-0. Specifying * will match any name or version, even when the server doesn't provide one. You can also provide a regular expression like /^sharkey-/i or /^1-/"
_accountMigration:
moveAccountDescription: "This will migrate your account to a different one.\n ・Followers from this account will automatically be migrated to the new account\n ・This account will unfollow all users it is currently following\n ・You will be unable to create new notes etc. on this account\n\nWhile migration of followers is automatic, you must manually prepare some steps to migrate the list of users you are following. To do so, carry out a follows export that you will later import on the new account in the settings menu. The same procedure applies to your lists as well as your muted and blocked users.\n\n(This explanation applies to Sharkey v13.12.0 and later. Other ActivityPub software, such as Mastodon, might function differently.)"
_achievements: