mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-16 22:35:32 +08:00
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:
+61
-64
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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')),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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'
|
||||
: '',
|
||||
|
||||
Reference in New Issue
Block a user