diff --git a/.github/scripts/release-guard.cjs b/.github/scripts/release-guard.cjs index ec466fa..768c372 100644 --- a/.github/scripts/release-guard.cjs +++ b/.github/scripts/release-guard.cjs @@ -61,6 +61,19 @@ function buildArchives(temp, dist) { } return digests; } +// 仅输出诊断白名单;禁止输出令牌、完整响应头或原始响应正文。 +function formatApiError(method, endpoint, response, body, secrets = []) { + const clean = value => { + let text = String(value ?? ''); + for (const secret of secrets.filter(Boolean)) text = text.split(secret).join('[REDACTED]'); + return text.replace(/Bearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]').replace(/[\r\n\x00-\x1f\x7f]/g, ' '); + }; + const route = endpoint.startsWith('https://') ? new URL(endpoint).pathname : endpoint.split('?')[0]; + const details = ['x-ratelimit-remaining', 'x-ratelimit-reset', 'x-ratelimit-resource', 'retry-after'] + .map(name => `${name}=${clean(response.headers.get(name) ?? 'unknown').slice(0, 100)}`).join(' '); + const message = typeof body?.message === 'string' ? clean(body.message).slice(0, 300) : 'unavailable'; + return `GitHub API ${clean(method)} ${clean(route)}: ${response.status}; ${details}; message=${message}`; +} async function main() { insist(process.env.GITHUB_EVENT_NAME === 'workflow_run', '仅允许 workflow_run'); const repo = process.env.GITHUB_REPOSITORY; @@ -74,7 +87,10 @@ async function main() { insist(url.startsWith(`${root}/`) || url.startsWith(`https://uploads.github.com/repos/${repo}/`), '非预期 API 目标'); const response = await fetch(url, { signal: AbortSignal.timeout(30000), method, headers: { ...(endpoint.startsWith('/actions/') && method === 'GET' ? {} : { Authorization: `Bearer ${token}` }), Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', ...(body ? { 'Content-Type': binary ? 'application/zip' : 'application/json' } : {}) }, body: body ? (binary ? body : JSON.stringify(body)) : undefined }); if (optional && response.status === 404) return null; - insist(response.ok, `GitHub API ${method} ${endpoint}: ${response.status}`); + if (!response.ok) { + const errorBody = await response.json().catch(() => null); + throw new Error(formatApiError(method, endpoint, response, errorBody, [token])); + } return response.json(); } const verifyDownloads = async release => { @@ -138,5 +154,5 @@ async function main() { console.log(`发布成功:${release.html_url};SHA=${sha};两份 ZIP SHA-256 已验证`); } finally { fs.rmSync(temp, { recursive: true, force: true }); } } -module.exports = { guardRun, guardVersions, guardExisting, buildArchives, VERSION, TAG, ASSETS }; +module.exports = { guardRun, guardVersions, guardExisting, buildArchives, formatApiError, VERSION, TAG, ASSETS }; if (require.main === module) main().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2360d68..4c95233 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,9 +92,25 @@ jobs: const repo = process.env.GITHUB_REPOSITORY; const assert = (ok, message) => { if (!ok) throw new Error(message); }; assert(repo === 'Thregren/InfinityTime' && event.repository.full_name === repo && event.repository.fork === false, '目标仓库不匹配'); + // 仅输出诊断白名单;禁止输出令牌、完整响应头或原始响应正文。 + function formatApiError(method, endpoint, response, body, secrets = []) { + const clean = value => { + let text = String(value ?? ''); + for (const secret of secrets.filter(Boolean)) text = text.split(secret).join('[REDACTED]'); + return text.replace(/Bearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]').replace(/[\r\n\x00-\x1f\x7f]/g, ' '); + }; + const route = endpoint.startsWith('https://') ? new URL(endpoint).pathname : endpoint.split('?')[0]; + const details = ['x-ratelimit-remaining', 'x-ratelimit-reset', 'x-ratelimit-resource', 'retry-after'] + .map(name => `${name}=${clean(response.headers.get(name) ?? 'unknown').slice(0, 100)}`).join(' '); + const message = typeof body?.message === 'string' ? clean(body.message).slice(0, 300) : 'unavailable'; + return `GitHub API ${clean(method)} ${clean(route)}: ${response.status}; ${details}; message=${message}`; + } const get = async path => { const response = await fetch(`https://api.github.com/repos/${repo}${path}`, { signal: AbortSignal.timeout(30000), headers: { ...(path.startsWith('/actions/') ? {} : { Authorization: `Bearer ${process.env.GH_TOKEN}` }), Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' } }); - assert(response.ok, `GitHub API ${response.status}`); + if (!response.ok) { + const errorBody = await response.json().catch(() => null); + throw new Error(formatApiError('GET', path, response, errorBody, [process.env.GH_TOKEN])); + } return response.json(); }; const main = await get('/git/ref/heads/main'); diff --git a/tests/gallery-loading-browser.cjs b/tests/gallery-loading-browser.cjs index 1aecdd7..76b9bda 100644 --- a/tests/gallery-loading-browser.cjs +++ b/tests/gallery-loading-browser.cjs @@ -166,8 +166,24 @@ const pixel = ' assert.equal(await page.locator('#stale').count(), 0); assert.equal(await page.locator('#fresh').count(), 1); assert.equal(await page.locator('.pp-load-retry').isVisible(), false); - await page.evaluate(() => InfinityWaterfall.reset({ nextUrl: '/empty' })); - assert.equal(await page.evaluate(() => InfinityWaterfall.loadMore()), true, '合法的搜索末尾空批次能正常结束'); + // 同一浏览器任务内重置并发起明确请求,避免原生滚动事件在两次 evaluate 间抢先加载。 + assert.equal(await page.evaluate(() => { + InfinityWaterfall.reset({ nextUrl: '/empty' }); + return InfinityWaterfall.loadMore(); + }), true, '合法的搜索末尾空批次能正常结束'); + assert.equal(await page.locator('.pp-load-retry').isVisible(), false); + // 自动滚动另行通过真实事件路径验证,不依赖上面的手动调用时序。 + const emptyRequests = requests.filter(p => p === '/empty').length; + await page.evaluate(() => { + InfinityWaterfall.reset({ nextUrl: '/empty' }); + window.scrollTo(0, document.documentElement.scrollHeight); + window.dispatchEvent(new Event('scroll')); + }); + await page.waitForFunction(() => { + const more = document.querySelector('#load-more'); + return more.getAttribute('data-next-url') === '' && more.getAttribute('aria-busy') === 'false'; + }); + assert.equal(requests.filter(p => p === '/empty').length, emptyRequests + 1, '自动滚动恰好加载一次合法末尾空批次'); assert.equal(await page.locator('.pp-load-retry').isVisible(), false); // AVIF 请求和解码失败均必须尝试可用 WebP,不能仅显示永久失败。 await page.evaluate(html => { diff --git a/tests/release-guards.cjs b/tests/release-guards.cjs index af73994..d580fd4 100644 --- a/tests/release-guards.cjs +++ b/tests/release-guards.cjs @@ -88,3 +88,27 @@ test('真实 ZIP 构建确定性、单根目录、排除 data,拒绝 symlink', assert.throws(() => build('unsafe'), /非普通文件/); } finally { process.chdir(old); fs.rmSync(root, { recursive: true, force: true }); } }); + +test('API 错误诊断仅输出白名单和截断 message,隐藏令牌与敏感头', () => { + const { formatApiError } = require('../.github/scripts/release-guard.cjs'); + const response = { status: 403, headers: new Headers({ + 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '1234567890', + 'x-ratelimit-resource': 'core', 'retry-after': '60', + authorization: 'Bearer secret-header', 'set-cookie': 'private-cookie', + }) }; + const result = formatApiError('GET', '/actions/runs/123?secret=query-secret', response, + { message: `rate limited known-token Bearer unknown-token\n${'x'.repeat(500)}`, secret: 'raw-body-secret' }, ['known-token']); + assert.match(result, /GET \/actions\/runs\/123: 403/); + for (const detail of ['x-ratelimit-remaining=0', 'x-ratelimit-reset=1234567890', 'x-ratelimit-resource=core', 'retry-after=60']) assert(result.includes(detail)); + for (const secret of ['known-token', 'unknown-token', 'secret-header', 'private-cookie', 'raw-body-secret', 'query-secret', '\n']) assert(!result.includes(secret)); + assert(result.includes('[REDACTED]')); + assert(result.split('; message=')[1].length <= 300); + assert.match(formatApiError('POST', 'https://uploads.github.com/repos/a/b/releases/1/assets?name=test', response, null), /POST \/repos\/a\/b\/releases\/1\/assets: 403/); +}); +test('checkout 前 inline API 错误格式与共享测试函数完全一致', () => { + const fs = require('node:fs'); + const { formatApiError } = require('../.github/scripts/release-guard.cjs'); + const workflow = fs.readFileSync('.github/workflows/release.yml', 'utf8'); + const lines = workflow.split('\n').map(line => line.startsWith(' ') ? line.slice(12) : line).join('\n'); + assert(lines.includes(formatApiError.toString())); +});