diff --git a/.github/workflows/release-readiness.yml b/.github/workflows/release-readiness.yml index fcb6784c..46e77022 100644 --- a/.github/workflows/release-readiness.yml +++ b/.github/workflows/release-readiness.yml @@ -120,7 +120,7 @@ jobs: run: | docker run --rm --network none --entrypoint node \ qinglong-readiness:${{ matrix.image }} \ - -e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.3.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")' + -e 'const assert=require("node:assert/strict"); const {createRequire}=require("node:module"); for(const [name,want] of Object.entries({"@grpc/grpc-js":"1.14.5",compression:"1.8.2",multer:"2.4.0",sequelize:"6.37.8"}))assert.equal(require(name+"/package.json").version,want); const expressRequire=createRequire(require.resolve("express")); assert.equal(expressRequire("proxy-addr/package.json").version,"2.0.8"); const sockRequire=createRequire(require.resolve("sockjs")); assert.equal(sockRequire("websocket-driver/package.json").version,"0.7.5"); require("sqlite3"); require("nodemailer"); require("undici"); console.log("Minimal production patches and runtime imports verified")' - name: Export native Debian image for isolated dev validation if: matrix.image == 'debian' run: | diff --git a/back/config/util.ts b/back/config/util.ts index 6681848b..a1372381 100644 --- a/back/config/util.ts +++ b/back/config/util.ts @@ -14,6 +14,11 @@ import os from 'os'; import { maybeSudo, isInContainer } from './container'; import { shellQuote } from '../shared/shellQuote'; import { resolveFileAccess } from '../shared/fileAccess'; +import { + mirrorWriteCommand, + normalizeMirror, + rewriteMirrorSources, +} from '../shared/linuxMirror'; export * from './share'; @@ -722,7 +727,9 @@ export function getInstallCommand(type: DependenceTypes, name: string): string { path.resolve(config.rootPath, PYTHON_VENV_DIR, 'bin/python3'), ) + ' -m pip' : 'pip3' - } install --disable-pip-version-check --root-user-action=ignore`, + } install --disable-pip-version-check${ + PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore' + }`, [DependenceTypes.linux]: getOsTypeSync() === 'Alpine' ? 'apk add --no-check-certificate' @@ -754,7 +761,9 @@ export function getUninstallCommand( path.resolve(config.rootPath, PYTHON_VENV_DIR, 'bin/python3'), ) + ' -m pip' : 'pip3' - } uninstall --disable-pip-version-check --root-user-action=ignore -y`, + } uninstall --disable-pip-version-check${ + PYTHON_VENV_DIR ? '' : ' --root-user-action=ignore' + } -y`, [DependenceTypes.linux]: getOsTypeSync() === 'Alpine' ? 'apk del' : maybeSudo('apt-get remove -y'), }; @@ -830,95 +839,73 @@ export async function detectOS(): Promise< return osType; } -async function getCurrentMirrorDomain( - filePath: string, -): Promise { - 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 { - 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 { + const defaults: Record = { + 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}`; - } 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}`; + 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`; } - 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.'); + } } + 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 { + // 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`; + } + } + return mirrorWriteCommand( + file, + rewriteMirrorSources(content, target), + S, + update, + ); } export async function updateLinuxMirrorFile(mirror: string): Promise { diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 8ef1f06d..3d68aa95 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -179,7 +179,14 @@ export default ({ app }: { app: Application }) => { app.use(rewrite('/open/*', '/api/$1')); app.use(config.api.prefix, routes()); - app.get('*', (_, res, next) => { + app.get('*', (req, res, next) => { + // Don't serve index.html for API routes + if ( + req.path === config.api.prefix || + req.path.startsWith(`${config.api.prefix}/`) + ) { + return res.status(404).json({ code: 404, message: 'Not Found' }); + } const indexPath = path.join(frontendPath, 'index.html'); res.sendFile(indexPath, (err) => { if (err) { diff --git a/back/shared/linuxMirror.ts b/back/shared/linuxMirror.ts new file mode 100644 index 00000000..2f6b5895 --- /dev/null +++ b/back/shared/linuxMirror.ts @@ -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}`; +} diff --git a/cli/test/internal/subscriptionRunner.test.cjs b/cli/test/internal/subscriptionRunner.test.cjs index b576fbbb..719916ec 100644 --- a/cli/test/internal/subscriptionRunner.test.cjs +++ b/cli/test/internal/subscriptionRunner.test.cjs @@ -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', diff --git a/package.json b/package.json index 81f4139f..21153d52 100644 --- a/package.json +++ b/package.json @@ -109,9 +109,9 @@ "jsonwebtoken": "^9.0.2", "keyv": "^5.2.3", "lodash": "^4.17.21", - "multer": "2.3.0", + "multer": "2.4.0", "node-cron": "4.6.0", - "nodemailer": "^8.0.1", + "nodemailer": "^10.0.9", "p-queue-cjs": "7.3.4", "proper-lockfile": "^4.1.2", "ps-tree": "^1.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e426f08..b2028fca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ dependencies: version: 12.0.1 body-parser: specifier: ^1.20.3 - version: 1.20.3 + version: 1.20.6 celebrate: specifier: ^15.0.3 version: 15.0.3 @@ -73,7 +73,7 @@ dependencies: version: 1.2.0 http-proxy-middleware: specifier: ^3.0.3 - version: 3.0.3 + version: 3.0.7 iconv-lite: specifier: ^0.6.3 version: 0.6.3 @@ -82,7 +82,7 @@ dependencies: version: 2.3.0(@types/node@17.0.45) js-yaml: specifier: ^4.1.0 - version: 4.1.0 + version: 4.3.2 jsonwebtoken: specifier: ^9.0.2 version: 9.0.2 @@ -93,14 +93,14 @@ dependencies: specifier: ^4.17.21 version: 4.17.21 multer: - specifier: 2.3.0 - version: 2.3.0 + specifier: 2.4.0 + version: 2.4.0 node-cron: specifier: 4.6.0 version: 4.6.0 nodemailer: - specifier: ^8.0.1 - version: 8.0.1 + specifier: ^10.0.9 + version: 10.0.9 p-queue-cjs: specifier: 7.3.4 version: 7.3.4 @@ -133,7 +133,7 @@ dependencies: version: 0.10.0 undici: specifier: ^7.9.0 - version: 7.9.0 + version: 7.29.1 uuid: specifier: ^11.0.3 version: 11.0.3 @@ -264,7 +264,7 @@ devDependencies: version: 4.23.0(antd@4.24.16)(react-dom@18.3.1)(react@18.3.1) axios: specifier: ^1.4.0 - version: 1.7.8 + version: 1.20.0 compression-webpack-plugin: specifier: 9.2.0 version: 9.2.0 @@ -2879,7 +2879,7 @@ packages: globals: 13.24.0 ignore: 5.3.2 import-fresh: 3.3.0 - js-yaml: 4.1.0 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3060,7 +3060,7 @@ packages: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.14.1 + js-yaml: 3.15.2 resolve-from: 5.0.0 dev: true @@ -5677,7 +5677,6 @@ packages: debug: 4.4.3 transitivePeerDependencies: - supports-color - dev: false /agentkeepalive@4.5.0: resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==} @@ -8302,7 +8301,7 @@ packages: is-glob: 4.0.3 is-path-inside: 3.0.3 js-sdsl: 4.4.2 - js-yaml: 4.1.0 + js-yaml: 4.3.2 json-stable-stringify-without-jsonify: 1.0.1 levn: 0.4.1 lodash.merge: 4.6.2 @@ -8513,7 +8512,7 @@ packages: dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 1.20.3 + body-parser: 1.20.6 content-disposition: 0.5.4 content-type: 1.0.5 cookie: 0.7.1 @@ -9334,7 +9333,6 @@ packages: debug: 4.4.3 transitivePeerDependencies: - supports-color - dev: false /human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} @@ -10587,7 +10585,7 @@ packages: engines: {node: '>=8.6'} dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 /miller-rabin@4.0.1: resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==} @@ -10654,19 +10652,19 @@ packages: /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.21 dev: true /minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.21 /minimatch@9.0.9: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.1.7 dev: true /minimist-options@4.1.0: @@ -10870,7 +10868,7 @@ packages: semver: 7.7.4 tar: 7.5.20 tinyglobby: 0.2.17 - undici: 6.27.0 + undici: 6.29.0 which: 6.0.1 dev: false optional: true @@ -15794,6 +15792,342 @@ packages: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} dev: true + /brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} + dependencies: + balanced-match: 1.0.2 + dev: true + + + /brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + + /axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + dependencies: + follow-redirects: 1.16.1(debug@4.4.3) + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + dev: true + + + /proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + dev: true + + + /form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + dev: true + + + /hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + dependencies: + function-bind: 1.1.2 + + + /es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + dev: true + + + /get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + + /math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + + /gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + + /get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + + /es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + + + /dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + + /call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + + /es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + + /follow-redirects@1.16.1(debug@4.4.3): + resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dependencies: + debug: 4.4.3 + + + /undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} + engines: {node: '>=20.18.1'} + dev: false + + + /undici@6.29.0: + resolution: {integrity: sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==} + engines: {node: '>=18.17'} + dev: false + optional: true + + + /nodemailer@10.0.9: + resolution: {integrity: sha512-BF0qcyplCwp+jMk6HCjFykBz/YhhZSsxrARhOldLwFWH+8kGjQsd2WIMIZhqEuyXRoGFi0ONbDeWDMDoL8MhLw==} + engines: {node: '>=20.0.0'} + dev: false + + + /multer@2.4.0: + resolution: {integrity: sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==} + engines: {node: '>= 10.16.0'} + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + type-is: 1.6.18 + dev: false + + + /js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + dependencies: + argparse: 2.0.1 + + + /js-yaml@3.15.2: + resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} + hasBin: true + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + dev: true + + + /http-proxy-middleware@3.0.7: + resolution: {integrity: sha512-iwbQltVlx8bCrqePUM8C+hllHvdawVhQJaLrj1X7qllkvFQdXFsr16pW/mo9+JDVjN+QO2XUx9jd8SmoFkE5qw==} + engines: {node: ^14.18.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@types/http-proxy': 1.17.17 + debug: 4.4.3 + http-proxy: 1.18.1(debug@4.4.3) + is-glob: 4.0.3 + is-plain-object: 5.1.0 + micromatch: 4.0.8 + transitivePeerDependencies: + - supports-color + dev: false + + + /picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + + /is-plain-object@5.1.0: + resolution: {integrity: sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==} + engines: {node: '>=0.10.0'} + + + /http-proxy@1.18.1(debug@4.4.3): + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.16.1(debug@4.4.3) + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + dev: false + + + /@types/http-proxy@1.17.17: + resolution: {integrity: sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==} + dependencies: + '@types/node': 17.0.45 + dev: false + + + /body-parser@1.20.6: + resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + dev: false + + + /raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + + /http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + + /statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + + /qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + dev: false + + + /side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + + /side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + + /side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + + /object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + + /call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + + /side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + settings: autoInstallPeers: true excludeLinksFromLockfile: false diff --git a/shell/api.sh b/shell/api.sh index e4b1afe8..ecacf56a 100755 --- a/shell/api.sh +++ b/shell/api.sh @@ -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 diff --git a/shell/update.sh b/shell/update.sh index db8f3eb5..2008cc9a 100755 --- a/shell/update.sh +++ b/shell/update.sh @@ -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" diff --git a/test/back/api-fallback.test.cjs b/test/back/api-fallback.test.cjs new file mode 100644 index 00000000..d0e4888a --- /dev/null +++ b/test/back/api-fallback.test.cjs @@ -0,0 +1,61 @@ +const test = require('node:test'), + assert = require('node:assert/strict'), + fs = require('node:fs'), + os = require('node:os'), + path = require('node:path'), + express = require('express'); +const load = require('../helpers/load-security-module.cjs'); +test('unknown API requests return JSON 404 while env.js and SPA deep links work', async (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-fallback-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + fs.mkdirSync(path.join(dir, 'static/dist'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'static/dist/index.html'), 'SPA'); + const app = express(); + load('back/loaders/express.ts', { + '../api': () => express.Router(), + '../config': { + rootPath: dir, + uploadPath: dir, + api: { prefix: '/api' }, + apiWhiteList: ['/api/env.js'], + jwt: { secret: 'test' }, + }, + 'express-jwt': { + expressjwt: () => ({ unless: () => (_q, _r, next) => next() }), + UnauthorizedError: class extends Error {}, + }, + '../config/util': { + getPlatform: () => 'desktop', + getToken: () => 'stored', + }, + '../config/serverEnv': { + serveEnv: (_q, r) => r.type('js').send('window.QL_CONFIG={};'), + }, + '../shared/store': { + shareStore: { getAuthInfo: async () => ({}), getApps: async () => [] }, + }, + '../shared/auth': { + isValidToken: () => true, + isDefaultAuthInfo: () => false, + }, + '../shared/i18n': { t: (s) => s }, + '../middlewares/uniqueConstraintError': (_q, _r, next) => next(), + '../middlewares/protectedPathCase': (_q, _r, next) => next(), + '../data/system': {}, + '../data/open': {}, + }).default({ app }); + const server = await new Promise((r) => { + const s = app.listen(0, '127.0.0.1', () => r(s)); + }); + t.after(() => new Promise((r) => server.close(r))); + const url = `http://127.0.0.1:${server.address().port}`; + for (const p of ['/api', '/api/missing', '/open/missing']) { + const res = await fetch(url + p); + assert.equal(res.status, 404, p); + assert.match(res.headers.get('content-type'), /json/); + assert.equal((await res.json()).code, 404); + } + const env = await fetch(url + '/api/env.js'); + assert.match(await env.text(), /QL_CONFIG/); + assert.equal(await (await fetch(url + '/script')).text(), 'SPA'); +}); diff --git a/test/back/issue-regressions-2230.test.cjs b/test/back/issue-regressions-2230.test.cjs new file mode 100644 index 00000000..7233b625 --- /dev/null +++ b/test/back/issue-regressions-2230.test.cjs @@ -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')), + ); + } +}); diff --git a/test/back/linux-mirror-result.test.cjs b/test/back/linux-mirror-result.test.cjs index 4945fb7d..8964d2a4 100644 --- a/test/back/linux-mirror-result.test.cjs +++ b/test/back/linux-mirror-result.test.cjs @@ -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' : '',