fix: 修复完整镜像源地址及 CRLF 任务导入 (#3093)

* fix: address six verified legacy issue regressions

* fix: limit issue patch to mirror URLs and CRLF imports

* fix: preserve APT formats and third-party repositories
This commit is contained in:
whyour
2026-10-13 01:11:35 +08:00
committed by GitHub
parent fdeffebba8
commit be877cfddc
7 changed files with 458 additions and 72 deletions
+61 -64
View File
@@ -13,6 +13,7 @@ import { FormData } from 'undici';
import os from 'os';
import { maybeSudo, isInContainer } from './container';
import { resolveFileAccess } from '../shared/fileAccess';
import { mirrorWriteCommand, normalizeMirror, rewriteMirrorSources } from '../shared/linuxMirror';
export * from './share';
@@ -806,77 +807,73 @@ export async function detectOS(): Promise<
return osType;
}
async function getCurrentMirrorDomain(
filePath: string,
): Promise<string | null> {
const fileContent = await fs.readFile(filePath, 'utf8');
const lines = fileContent.split('\n');
for (const line of lines) {
if (line.trim().startsWith('#')) {
continue;
}
const match = line.match(/https?:\/\/[^\/]+/);
if (match) {
return match[0];
}
}
return null;
}
async function replaceDomainInFile(
filePath: string,
oldDomainWithScheme: string,
newDomainWithScheme: string,
): Promise<void> {
let fileContent = await fs.readFile(filePath, 'utf8');
let updatedContent = fileContent.replace(
new RegExp(oldDomainWithScheme, 'g'),
newDomainWithScheme,
);
if (!newDomainWithScheme.endsWith('/')) {
newDomainWithScheme += '/';
}
await writeFileWithLock(filePath, updatedContent);
}
async function _updateLinuxMirror(
osType: string,
mirrorDomainWithScheme: string,
mirror: string,
): Promise<string> {
const defaults: Record<string, string> = {
Debian: 'http://deb.debian.org/debian',
Ubuntu: 'http://archive.ubuntu.com/ubuntu',
Alpine: 'http://dl-cdn.alpinelinux.org/alpine',
};
if (!defaults[osType])
throw new Error('Unsupported OS type for updating mirrors.');
const target = mirror || defaults[osType];
normalizeMirror(target);
const S = isInContainer() ? 'sudo ' : '';
// APT otherwise returns zero when index downloads fail with transient errors.
const aptUpdate = `${S}apt-get update --error-on=any`;
let filePath: string, currentDomainWithScheme: string | null;
switch (osType) {
case 'Debian':
filePath = '/etc/apt/sources.list.d/debian.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://deb.debian.org'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath}) && ${aptUpdate}`;
const update =
osType === 'Alpine' ? 'apk update' : `${S}apt-get update --error-on=any`;
let file =
osType === 'Alpine'
? '/etc/apk/repositories'
: `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
let content = '';
try {
content = await fs.readFile(file, 'utf8');
} catch (error: any) {
if (error.code !== 'ENOENT') throw error;
if (osType !== 'Alpine') {
try {
file = '/etc/apt/sources.list';
content = await fs.readFile(file, 'utf8');
} catch (legacyError: any) {
if (legacyError.code !== 'ENOENT') throw legacyError;
file = `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
}
}
}
if (!content.trim()) {
const release = await fs.readFile('/etc/os-release', 'utf8');
if (osType === 'Alpine') {
const version = release.match(/^VERSION_ID=["']?(\d+\.\d+)/m)?.[1];
if (!version)
throw new Error('Cannot determine Alpine repository version');
content = `${defaults.Alpine}/v${version}/main\n${defaults.Alpine}/v${version}/community\n`;
} else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://deb.debian.org'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates\\nComponents: main\\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg" | ${S}tee ${filePath} && ${aptUpdate}`;
// The fallback may have read an empty legacy .list. Deb822 stanzas must
// be written to a .sources file, leaving the legacy file untouched.
file = `/etc/apt/sources.list.d/${osType.toLowerCase()}.sources`;
const codename = release.match(/^VERSION_CODENAME=["']?([\w-]+)/m)?.[1];
if (!codename)
throw new Error('Cannot determine APT repository codename');
const ubuntu = osType === 'Ubuntu';
content = `Types: deb\nURIs: ${
defaults[osType]
}\nSuites: ${codename} ${codename}-updates${
ubuntu ? ` ${codename}-backports` : ''
}\nComponents: ${
ubuntu ? 'main restricted universe multiverse' : 'main'
}\nSigned-By: /usr/share/keyrings/${osType.toLowerCase()}-archive-keyring.gpg\n`;
if (!ubuntu)
content += `\nTypes: deb\nURIs: http://security.debian.org/debian-security\nSuites: ${codename}-security\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\n`;
}
case 'Ubuntu':
filePath = '/etc/apt/sources.list.d/ubuntu.sources';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `${S}sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}|g' ${filePath} || (${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath}) && ${aptUpdate}`;
} else {
return `${S}mkdir -p /etc/apt/sources.list.d && echo -e "Types: deb\\nURIs: ${mirrorDomainWithScheme || 'http://archive.ubuntu.com'}\\nSuites: \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2) \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-updates \\$(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)-backports\\nComponents: main restricted universe multiverse\\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg" | ${S}tee ${filePath} && ${aptUpdate}`;
}
case 'Alpine':
filePath = '/etc/apk/repositories';
currentDomainWithScheme = await getCurrentMirrorDomain(filePath);
if (currentDomainWithScheme) {
return `sed -i 's|${currentDomainWithScheme}|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}|g' ${filePath} || (mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath}) && apk update`;
} else {
return `mkdir -p /etc/apk && echo -e "\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/main\\n\\$(grep VERSION_ID /etc/os-release | cut -d= -f2 | cut -d. -f1,2)/community" | sed "s|^|${mirrorDomainWithScheme || 'http://dl-cdn.alpinelinux.org'}/alpine/v|" | tee ${filePath} && apk update`;
}
default:
throw Error('Unsupported OS type for updating mirrors.');
}
return mirrorWriteCommand(
file,
rewriteMirrorSources(content, target),
S,
update,
);
}
export async function updateLinuxMirrorFile(mirror: string): Promise<string> {
+121
View File
@@ -0,0 +1,121 @@
import { shellQuote } from './shellQuote';
const repositoryNames =
/^(debian(?:-security)?|ubuntu(?:-ports)?|alpine|armbian)$/;
export function normalizeMirror(mirror: string): URL {
if (/\s/.test(mirror)) throw new Error('Invalid Linux mirror URL');
const url = new URL(mirror);
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash
) {
throw new Error(
'Linux mirror must be an HTTP(S) URL without credentials, query or fragment',
);
}
return url;
}
export function replaceMirrorUrl(current: string, mirror: string): string {
const target = normalizeMirror(mirror);
const previous = new URL(current);
const oldSegments = previous.pathname.split('/').filter(Boolean);
const newSegments = target.pathname.split('/').filter(Boolean);
const repositoryIndex = oldSegments.findIndex((part) =>
repositoryNames.test(part),
);
const targetIndex = newSegments.findIndex((part) =>
repositoryNames.test(part),
);
const prefix =
targetIndex >= 0 ? newSegments.slice(0, targetIndex) : newSegments;
if (repositoryIndex >= 0) {
const repository = oldSegments[repositoryIndex];
let suffix = oldSegments.slice(repositoryIndex + 1);
// Repair paths already corrupted by repeated authority-only replacement.
while (suffix[0] === repository) suffix.shift();
return `${target.origin}/${[...prefix, repository, ...suffix].join('/')}`;
}
// Unknown repository paths: a full URL replaces the old root, a hostname
// preserves it. Never append the newly entered root to the existing root.
return `${target.origin}/${(newSegments.length
? newSegments
: oldSegments
).join('/')}`;
}
export function rewriteMirrorSources(content: string, mirror: string): string {
normalizeMirror(mirror);
return content
.split(/(\r?\n[ \t]*\r?\n)/)
.map((stanza) => {
const signedBy = stanza.match(/^Signed-By:\s*([^\r\n]*)/im)?.[1];
return stanza
.split('\n')
.map((line) => {
if (!/^\s*(?:URIs:|deb(?:-src)?\s|https?:\/\/)/.test(line))
return line;
const legacy = /^\s*deb(?:-src)?\s/.test(line);
const key = legacy
? line.match(/\bsigned-by=([^\]\s]+)/i)?.[1]
: signedBy;
// In .list files a # starts an inline comment; its URLs are metadata.
const comment = legacy ? line.indexOf('#') : -1;
const source = comment < 0 ? line : line.slice(0, comment);
const rewritten = source.replace(/https?:\/\/[^\s#]+/g, (url) =>
isSystemRepository(url, key) ? replaceMirrorUrl(url, mirror) : url,
);
return rewritten + (comment < 0 ? '' : line.slice(comment));
})
.join('\n');
})
.join('');
}
function isSystemRepository(url: string, signedBy?: string): boolean {
const keys =
signedBy
?.trim()
.split(/[\s,]+/)
.filter(Boolean) || [];
const archiveKeyring =
keys.length > 0 &&
keys.every((key) =>
/\/(?:debian|ubuntu|armbian)-archive-keyring\.(?:gpg|asc)$/.test(key),
);
// A separate signing key identifies a third-party archive, even if its
// repository happens to be called /debian or /ubuntu.
if (keys.length > 0 && !archiveKeyring) return false;
const segments = new URL(url).pathname.split('/').filter(Boolean);
const repositoryIndex = segments.findIndex((part) =>
repositoryNames.test(part),
);
if (repositoryIndex < 0) return false;
// Without archive metadata, accept only conventional archive roots. A
// vendor path such as Docker's /linux/debian is not the Debian archive.
return (
archiveKeyring ||
repositoryIndex === 0 ||
segments
.slice(0, repositoryIndex)
.every((part) => /^mirrors?$/.test(part)) ||
segments[repositoryIndex] === 'alpine'
);
}
export function mirrorWriteCommand(
file: string,
content: string,
prefix: string,
update: string,
): string {
return `${prefix}mkdir -p ${shellQuote(
file.slice(0, file.lastIndexOf('/')),
)} && printf '%s' ${shellQuote(content)} | ${prefix}tee ${shellQuote(
file,
)} >/dev/null && ${update}`;
}
@@ -409,7 +409,7 @@ test('cron metadata precedence agrees with unchanged legacy add_cron', (t) => {
'--noprofile',
'--norc',
'-c',
`${definition}\nt() { :; }\nnotify_api() { :; }\nadd_cron_api() { printf '%s' "$1"; }\nadd_cron "$dir_scripts/list" repo`,
`${definition}\nt() { :; }\nnotify_api() { :; }\nadd_cron_api() { printf '%s:%s:%s:%s' "$1" "$2" "$3" "$4"; }\nadd_cron "$dir_scripts/list" repo`,
],
{
encoding: 'utf8',
+14 -3
View File
@@ -85,9 +85,13 @@ add_cron_api() {
sub_id="null"
fi
local payload
payload=$(jq -cn --arg name "$name" --arg command "$command" \
--arg schedule "$schedule" --argjson sub_id "$sub_id" \
'{name: $name, command: $command, schedule: $schedule, sub_id: $sub_id}') || return $?
local api=$(ql_api_request \
"http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \
--data-raw "$payload" \
-X POST)
code=$(echo "$api" | jq -r .code)
message=$(echo "$api" | jq -r .message)
@@ -112,10 +116,14 @@ update_cron_api() {
local id="$4"
fi
local payload
payload=$(jq -cn --arg name "$name" --arg command "$command" \
--arg schedule "$schedule" --arg id "$id" \
'{name: $name, command: $command, schedule: $schedule, id: $id}') || return $?
local api=$(ql_api_request \
"http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
-X PUT \
--data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"id\":\"$id\"}")
--data-raw "$payload")
code=$(echo "$api" | jq -r .code)
message=$(echo "$api" | jq -r .message)
if [[ $code == 200 ]]; then
@@ -135,10 +143,13 @@ update_cron_command_api() {
local id="$2"
fi
local payload
payload=$(jq -cn --arg command "$command" --arg id "$id" \
'{command: $command, id: $id}') || return $?
local api=$(ql_api_request \
"http://localhost:${ql_port}/open/crons?t=$currentTimeStamp" \
-X PUT \
--data-raw "{\"command\":\"${command//\"/\\\"}\",\"id\":\"$id\"}")
--data-raw "$payload")
code=$(echo "$api" | jq -r .code)
message=$(echo "$api" | jq -r .message)
if [[ $code == 200 ]]; then
+6 -2
View File
@@ -99,7 +99,9 @@ add_cron() {
cron_name=$(grep "new Env" $file | awk -F "\(" '{print $2}' | awk -F "\)" '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1)
[[ -z $cron_name ]] && cron_name=$(grep "name:" $file | awk -F ":" '{print $2}' | head -1 | xargs)
[[ -z $cron_name ]] && cron_name=$(basename "$file_name")
result=$(add_cron_api "${cron_line}:${cmd_task} ${file}:${cron_name}:${SUB_ID}")
cron_name=${cron_name//$'\r'/}
cron_line=${cron_line//$'\r'/}
result=$(add_cron_api "$cron_line" "${cmd_task} ${file}" "$cron_name" "$SUB_ID")
echo -e "$result"
if [[ $detail ]]; then
detail="${detail}${result}\n"
@@ -190,7 +192,9 @@ update_raw() {
[[ -z $cron_line ]] && cron_line=$(grep "cron:" $raw_file_name | awk -F ":" '{print $2}' | head -1 | xargs)
[[ -z $cron_line ]] && cron_line=$(grep "cron " $raw_file_name | awk -F "cron \"" '{print $2}' | awk -F "\" " '{print $1}' | head -1 | xargs)
[[ -z $cron_line ]] && cron_line="$default_cron"
result=$(add_cron_api "${cron_line}:${cmd_task} ${filename}:${cron_name}:${SUB_ID}")
cron_name=${cron_name//$'\r'/}
cron_line=${cron_line//$'\r'/}
result=$(add_cron_api "$cron_line" "${cmd_task} ${filename}" "$cron_name" "$SUB_ID")
echo -e "$result\n"
notify_api "新增任务通知" "\n$result"
# update_cron_api "$cron_line:$cmd_task $filename:$cron_name:$cron_id"
+253
View File
@@ -0,0 +1,253 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { spawnSync } = require('node:child_process');
const load = require('../helpers/load-security-module.cjs');
const hasApt = spawnSync('apt-get', ['--version']).status === 0;
const {
rewriteMirrorSources,
replaceMirrorUrl,
normalizeMirror,
mirrorWriteCommand,
} = require('../../back/shared/linuxMirror');
async function fixture(t) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-issue-fixes-'));
t.after(() => fs.rm(root, { force: true, recursive: true }));
return root;
}
test('#2902/#2871 mirror writes are idempotent, preserve suites, security/ports/version paths and quote URLs', async (t) => {
const original = `# http://leave.example/debian\nTypes: deb\nURIs: http://deb.debian.org/debian\nSuites: bookworm bookworm-updates\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\n\nTypes: deb\nURIs: http://security.debian.org/debian-security\nSuites: bookworm-security\n`;
const url = 'https://mirror.example/mirrors/debian/';
const changed = rewriteMirrorSources(original, url);
assert.equal(rewriteMirrorSources(changed, url), changed);
assert.match(
changed,
/URIs: https:\/\/mirror.example\/mirrors\/debian-security/,
);
assert.match(changed, /Suites: bookworm-security/);
assert.match(changed, /# http:\/\/leave.example\/debian/);
assert.equal(
replaceMirrorUrl('http://old/debian/debian/debian', url),
'https://mirror.example/mirrors/debian',
);
assert.equal(
replaceMirrorUrl('http://old/armbian/armbian', 'https://new/armbian'),
'https://new/armbian',
);
assert.equal(
replaceMirrorUrl('http://old/ubuntu-ports', 'https://new/ubuntu'),
'https://new/ubuntu-ports',
);
assert.equal(
replaceMirrorUrl('http://old/alpine/v3.22/main', 'https://new/alpine'),
'https://new/alpine/v3.22/main',
);
assert.equal(
replaceMirrorUrl('http://old/debian', 'https://new'),
'https://new/debian',
);
for (const bad of [
'file:///tmp/a',
'http://u:p@host',
'https://host?x=1',
'https://host/\nanything',
])
assert.throws(() => normalizeMirror(bad));
const root = await fixture(t);
const file = path.join(root, "quote's", 'sources');
const command = mirrorWriteCommand(file, changed, '', 'true');
const result = spawnSync('bash', ['-c', command], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.equal(await fs.readFile(file, 'utf8'), changed);
});
test('#2701 shell API serializes CR/LF, quotes, backslashes and colons without corrupting JSON', async () => {
const result = spawnSync(
'bash',
[
'-c',
`source shell/api.sh
ql_api_request() { while [[ $# -gt 0 ]]; do if [[ "$1" == --data-raw ]]; then printf '%s\\n' "$2" >&3; break; fi; shift; done; printf '{"code":200}'; }
t() { :; }
add_cron_api '0 0 1 1 *' 'task a:b.js' $'quote"slash\\\\colon:\\r\\n\\t中文' 23
update_cron_api '0 0 1 1 *' 'task a:b.js' $'quote"slash\\\\colon:\\r\\n\\t中文' 27
update_cron_command_api $'task path\\\\with"quotes.js' 27
`,
],
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe', 'pipe'] },
);
assert.equal(result.status, 0, result.stderr);
const payloads = result.output[3].trim().split('\n').map(JSON.parse);
assert.equal(payloads[0].name, 'quote"slash\\colon:\r\n\t中文');
assert.equal(payloads[0].sub_id, 23);
assert.equal(payloads[1].id, '27');
assert.equal(payloads[2].command, 'task path\\with"quotes.js');
});
for (const distribution of ['Debian', 'Ubuntu']) {
for (const legacyContent of ['', ' \t\r\n']) {
test(`${distribution}: empty legacy sources.list falls back to deb822 .sources`, async (t) => {
const root = await fixture(t);
const legacy = path.join(root, 'sources.list');
const sources = path.join(
root,
'sources.list.d',
`${distribution.toLowerCase()}.sources`,
);
await fs.writeFile(legacy, legacyContent);
const util = load('back/config/util.ts', {
'fs/promises': {
readFile: async (file) => {
if (file === '/etc/os-release')
return `ID=${distribution.toLowerCase()}\nVERSION_CODENAME=${
distribution === 'Debian' ? 'bookworm' : 'noble'
}\n`;
if (file === '/etc/apt/sources.list')
return fs.readFile(legacy, 'utf8');
throw Object.assign(new Error('missing fixture source'), {
code: 'ENOENT',
});
},
},
os: { platform: () => 'linux' },
'./index': {},
'./const': {},
'./share': {},
'./container': { isInContainer: () => false },
'../loaders/logger': {},
'../shared/utils': {},
'../shared/fileAccess': {},
'../data/dependence': {},
});
const previousOS = process.env.QL_OS_TYPE;
let command;
try {
process.env.QL_OS_TYPE = distribution.toLowerCase();
command = await util.updateLinuxMirrorFile('https://mirror.example');
} finally {
if (previousOS === undefined) delete process.env.QL_OS_TYPE;
else process.env.QL_OS_TYPE = previousOS;
}
// Execute the entire generated writer, redirecting its APT paths into
// the fixture and replacing only the package-index refresh.
const isolated = command
.replaceAll('/etc/apt', root)
.replace('apt-get update --error-on=any', 'true');
const result = spawnSync('bash', ['-c', isolated], { encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.equal(await fs.readFile(legacy, 'utf8'), legacyContent);
const generated = await fs.readFile(sources, 'utf8');
assert.match(generated, /^Types: deb\nURIs: https:\/\/mirror\.example\//);
assert.match(generated, /Signed-By: \/usr\/share\/keyrings\//);
assert.match(
generated,
distribution === 'Debian' ? /bookworm-security/ : /noble-backports/,
);
if (hasApt) {
// Parse both source locations with real APT, without refreshing indexes
// or touching the host's configuration/cache.
const parsed = spawnSync(
'apt-get',
[
'indextargets',
'-o',
`Dir::Etc::sourcelist=${legacy}`,
'-o',
`Dir::Etc::sourceparts=${path.dirname(sources)}`,
'-o',
`Dir::State::lists=${path.join(root, 'lists')}`,
],
{ encoding: 'utf8' },
);
assert.equal(parsed.status, 0, parsed.stderr);
}
});
}
}
test('deb822 mirror changes preserve vendor archives, signing keys and comments across repeated saves', () => {
const system =
'Types: deb\nURIs: https://deb.debian.org/debian\nSuites: bookworm\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.gpg\n';
const vendors = [
'Types: deb\nURIs: https://download.docker.com/linux/debian\nSuites: bookworm\nComponents: stable\nSigned-By: /etc/apt/keyrings/docker.asc\n',
'Types: deb\nURIs: https://download.docker.com/linux/debian\nSuites: bookworm\nComponents: stable\n',
'Types: deb\nURIs: https://packages.microsoft.com/repos/code\nSuites: stable\nComponents: main\n',
'Types: deb\nURIs: https://vendor.example/debian\nSuites: bookworm\nComponents: main\nSigned-By: /etc/apt/keyrings/vendor.gpg\n',
];
const comment = '# Keep https://vendor.example/debian unchanged\n';
const original = [system, ...vendors, comment].join('\n');
const first = rewriteMirrorSources(
original,
'https://mirror.example/mirrors/debian',
);
assert.ok(first.includes('URIs: https://mirror.example/mirrors/debian\n'));
for (const unchanged of [...vendors, comment])
assert.ok(first.includes(unchanged));
assert.equal(
rewriteMirrorSources(first, 'https://mirror.example/mirrors/debian'),
first,
);
const second = rewriteMirrorSources(first, 'https://second.example/debian');
assert.ok(second.includes('URIs: https://second.example/debian\n'));
for (const unchanged of [...vendors, comment])
assert.ok(second.includes(unchanged));
});
test('legacy mirror changes preserve third-party lines, inline comments and source options', () => {
const system =
'deb [arch=amd64 signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] https://deb.debian.org/debian bookworm main # https://leave.example/debian\n';
const security =
'deb-src https://security.debian.org/debian-security bookworm-security main\n';
const vendors = [
'deb [signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable\n',
'deb https://download.docker.com/linux/debian bookworm stable\n',
'deb [signed-by=/etc/apt/keyrings/vendor.gpg] https://vendor.example/debian bookworm main\n',
'deb https://packages.microsoft.com/repos/code stable main\n',
'# deb https://deb.debian.org/debian bookworm main\n',
];
const original = system + security + vendors.join('');
const changed = rewriteMirrorSources(
original,
'https://mirror.example/debian',
);
assert.ok(
changed.includes(
system.replace(
'https://deb.debian.org/debian',
'https://mirror.example/debian',
),
),
);
assert.ok(
changed.includes(
security.replace(
'https://security.debian.org/debian-security',
'https://mirror.example/debian-security',
),
),
);
for (const unchanged of vendors) assert.ok(changed.includes(unchanged));
assert.equal(
rewriteMirrorSources(changed, 'https://mirror.example/debian'),
changed,
);
});
test('Ubuntu ports, Armbian and Alpine roots still switch mirrors without altering repository metadata', () => {
for (const url of [
'https://ports.ubuntu.com/ubuntu-ports',
'https://apt.armbian.com/armbian',
'https://old.example/alpine/v3.22/main',
'https://old.example/alpine/v3.22/community',
]) {
const original = `URIs: ${url}\nSuites: noble\nComponents: main\n`;
assert.equal(
rewriteMirrorSources(original, 'https://mirror.example'),
original.replace(url, replaceMirrorUrl(url, 'https://mirror.example')),
);
}
});
+1 -1
View File
@@ -129,7 +129,7 @@ Acquire::http::Timeout "1";
'fs/promises': {
readFile: async (file) =>
file === '/etc/os-release'
? distribution
? `${distribution}\nVERSION_CODENAME=bookworm\n`
: existingMirror
? 'URIs: http://old.example/debian\n'
: '',