mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7426af446e | ||
|
|
5444031942 |
@@ -773,14 +773,22 @@ function extractColorFunctionTokens(value) {
|
||||
function parseGradientColors(bgImage) {
|
||||
if (!bgImage || !bgImage.includes('gradient')) return [];
|
||||
const colors = [];
|
||||
const tokenSpans = [];
|
||||
let from = 0;
|
||||
// Stops arrive in whatever syntax the author wrote and the browser kept.
|
||||
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
|
||||
// to read as a gradient with no stops at all.
|
||||
for (const token of extractColorFunctionTokens(bgImage)) {
|
||||
const start = bgImage.indexOf(token, from);
|
||||
if (start < 0) break;
|
||||
tokenSpans.push({ start, end: start + token.length });
|
||||
from = start + token.length;
|
||||
const c = parseAnyColor(token);
|
||||
if (c) colors.push(c);
|
||||
}
|
||||
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
|
||||
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
|
||||
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
|
||||
const h = m[1];
|
||||
if (h.length === 6) {
|
||||
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
|
||||
|
||||
@@ -103,14 +103,22 @@ function extractColorFunctionTokens(value) {
|
||||
function parseGradientColors(bgImage) {
|
||||
if (!bgImage || !bgImage.includes('gradient')) return [];
|
||||
const colors = [];
|
||||
const tokenSpans = [];
|
||||
let from = 0;
|
||||
// Stops arrive in whatever syntax the author wrote and the browser kept.
|
||||
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
|
||||
// to read as a gradient with no stops at all.
|
||||
for (const token of extractColorFunctionTokens(bgImage)) {
|
||||
const start = bgImage.indexOf(token, from);
|
||||
if (start < 0) break;
|
||||
tokenSpans.push({ start, end: start + token.length });
|
||||
from = start + token.length;
|
||||
const c = parseAnyColor(token);
|
||||
if (c) colors.push(c);
|
||||
}
|
||||
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
|
||||
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
|
||||
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
|
||||
const h = m[1];
|
||||
if (h.length === 6) {
|
||||
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
|
||||
|
||||
@@ -936,23 +936,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const filePath = url.searchParams.get('path');
|
||||
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
|
||||
const absPath = path.resolve(process.cwd(), filePath);
|
||||
let realRoot, realTarget;
|
||||
try {
|
||||
realRoot = fs.realpathSync(process.cwd());
|
||||
realTarget = fs.realpathSync(absPath);
|
||||
} catch {
|
||||
res.writeHead(404); res.end('File not found'); return;
|
||||
}
|
||||
// Confine to the project root after symlink resolution. A bare
|
||||
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
|
||||
// root name (projeto -> projeto-backup) slip through; compare on the
|
||||
// relative path instead (same pattern as sessionFileMetadataFromPollReply
|
||||
// below). An empty rel means the request resolved to the root directory
|
||||
// itself, which this file route never serves.
|
||||
const rel = path.relative(realRoot, realTarget);
|
||||
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
|
||||
// sibling dir whose name extends the root name (projeto -> projeto-backup)
|
||||
// slip through; compare on the relative path instead (same pattern as
|
||||
// sessionFileMetadataFromPollReply below). An empty rel means the request
|
||||
// resolved to the root directory itself, which this file route never serves.
|
||||
const rel = path.relative(process.cwd(), absPath);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||
let content;
|
||||
try { content = fs.readFileSync(realTarget, 'utf-8'); }
|
||||
try { content = fs.readFileSync(absPath, 'utf-8'); }
|
||||
catch { res.writeHead(404); res.end('File not found'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(content);
|
||||
|
||||
@@ -272,6 +272,31 @@ describe('detectHtml — static HTML/CSS fixtures', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('color: nested #000 inside color-mix must not become on #000000', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||||
const light = f.filter(r =>
|
||||
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
|
||||
/#f7f3ea/i.test(r.snippet || '')
|
||||
);
|
||||
assert.equal(
|
||||
light.length, 0,
|
||||
`light text on the mixed green must not flag: ${light.map(r => r.snippet).join('; ')}`,
|
||||
);
|
||||
const leaked = f.filter(r => /#3d2418 on #000000/i.test(r.snippet || ''));
|
||||
assert.equal(
|
||||
leaked.length, 0,
|
||||
`nested #000 must not become on #000000: ${leaked.map(r => r.snippet).join('; ')}`,
|
||||
);
|
||||
assert.ok(
|
||||
f.some(r =>
|
||||
r.antipattern === 'low-contrast' &&
|
||||
/#3d2418/i.test(r.snippet || '') &&
|
||||
/#17372d|#295344/i.test(r.snippet || '')
|
||||
),
|
||||
'dark ink on the mixed stop should flag against the mix, not phantom black',
|
||||
);
|
||||
});
|
||||
|
||||
it('color: white text on background-image url() ancestor is not flagged as low-contrast', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||||
// The pass column has white text on a div with background-image: url().
|
||||
|
||||
@@ -1599,6 +1599,32 @@ describe('hover contrast + color-mix', () => {
|
||||
expect(stops).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('parseGradientColors resolves color-mix stops without leaking nested hex', () => {
|
||||
const stops = parseGradientColors('linear-gradient(135deg, color-mix(in srgb, #2d5a4a 92%, #000), color-mix(in srgb, #1a3d32 90%, #000))');
|
||||
expect(stops).toHaveLength(2);
|
||||
expect(stops[0]).toEqual({ r: 41, g: 83, b: 68, a: 1 });
|
||||
expect(stops[1]).toEqual({ r: 23, g: 55, b: 45, a: 1 });
|
||||
});
|
||||
|
||||
test('parseGradientColors does not leak nested hex when color-mix has var()', () => {
|
||||
const stops = parseGradientColors('linear-gradient(135deg, color-mix(in srgb, var(--brand) 92%, #000), color-mix(in srgb, var(--brand-deep) 90%, #000))');
|
||||
expect(stops).toEqual([]);
|
||||
});
|
||||
|
||||
test('parseGradientColors still collects sibling bare hex stops beside color-mix', () => {
|
||||
const stops = parseGradientColors('linear-gradient(color-mix(in srgb, #2d5a4a 92%, #000), #ffffff)');
|
||||
expect(stops).toHaveLength(2);
|
||||
expect(stops[0]).toEqual({ r: 41, g: 83, b: 68, a: 1 });
|
||||
expect(stops[1]).toEqual({ r: 255, g: 255, b: 255, a: 1 });
|
||||
});
|
||||
|
||||
test('parseGradientColors still reads bare hex gradient stops', () => {
|
||||
const stops = parseGradientColors('linear-gradient(#2d5a4a, #000)');
|
||||
expect(stops).toHaveLength(2);
|
||||
expect(stops[0]).toEqual({ r: 45, g: 90, b: 74, a: 1 });
|
||||
expect(stops[1]).toEqual({ r: 0, g: 0, b: 0, a: 1 });
|
||||
});
|
||||
|
||||
test('checkHoverContrast flags a failing hover pair on a styled control', () => {
|
||||
const f = checkHoverContrast({
|
||||
tag: 'a',
|
||||
|
||||
+16
-1
@@ -45,11 +45,14 @@
|
||||
.mix-dark-wrap { background: #0f0f11; padding: 16px; }
|
||||
.mix-glow { background: linear-gradient(160deg, color-mix(in oklab, oklch(90% 0.02 95) 16%, transparent) 0%, #141419 65%); padding: 20px; }
|
||||
.mix-glow p { color: #ded9cf; font-size: 16px; }
|
||||
/* issue #578 — #000 inside color-mix is an ingredient; white-ish text on
|
||||
the mixed dark green must not be scored against phantom black. */
|
||||
.mix-hex-brand { background: linear-gradient(135deg, color-mix(in srgb, var(--mix-hex-brand) 92%, #000), color-mix(in srgb, var(--mix-hex-brand-deep) 90%, #000)); width: 400px; height: 120px; padding: 20px; }
|
||||
/* currentcolor surface: background-color paints with the element's own
|
||||
text color, which is itself a var() token here. jsdom hands both
|
||||
through verbatim, so the walk must resolve the token via the
|
||||
custom-prop map instead of abstaining on a knowable surface. */
|
||||
:root { --fixture-bone: #e8e2d6; }
|
||||
:root { --fixture-bone: #e8e2d6; --mix-hex-brand: #2d5a4a; --mix-hex-brand-deep: #1a3d32; }
|
||||
.currentcolor-surface { background-color: currentcolor; color: var(--fixture-bone); padding: 14px 16px; border-radius: 10px; margin-bottom: 10px; }
|
||||
.currentcolor-low-text { color: #cfc9bd; font-size: 14px; }
|
||||
.currentcolor-good-text { color: #3a352c; font-size: 14px; }
|
||||
@@ -133,6 +136,13 @@
|
||||
<p>Purple-to-indigo gradient</p>
|
||||
</div>
|
||||
|
||||
<h3>color-mix nested hex must not report phantom black</h3>
|
||||
<!-- Dark ink on the mixed green is a real fail against #17372d. The
|
||||
leaked-#000 extractor used to report it as on #000000 instead. -->
|
||||
<div class="mix-hex-brand" data-test="mix-hex-brand-dark">
|
||||
<p style="color: #3d2418; font-size: 16px;">Dark ink on a mixed green stop must not report on #000000</p>
|
||||
</div>
|
||||
|
||||
<h3>currentcolor surface via var() token</h3>
|
||||
<!-- background-color: currentcolor with color: var(--fixture-bone).
|
||||
The surface is knowable (bone #e8e2d6), so the faint text on it is
|
||||
@@ -248,6 +258,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>color-mix nested hex is not a surface</h3>
|
||||
<div class="mix-hex-brand" data-test="mix-hex-brand">
|
||||
<p style="color: #f7f3ea; font-size: 16px;">WhatsApp-style light text on a mixed dark green gradient stays readable</p>
|
||||
</div>
|
||||
|
||||
<h3>currentcolor surface with good contrast</h3>
|
||||
<div class="currentcolor-surface" data-test="currentcolor-good">
|
||||
<p class="currentcolor-good-text">Dark ink text on a bone currentcolor surface</p>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync, realpathSync, symlinkSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync, realpathSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync, execSync, spawn } from 'node:child_process';
|
||||
import {
|
||||
@@ -3319,102 +3319,6 @@ colors: {}
|
||||
}
|
||||
});
|
||||
|
||||
it('/source rejects a symlink that points outside the project root', async () => {
|
||||
const outsideDir = mkdtempSync(join(tmpdir(), 'impeccable-live-outside-'));
|
||||
const outsideFile = join(outsideDir, 'secret.txt');
|
||||
writeFileSync(outsideFile, 'OUTSIDE SECRET');
|
||||
const linkPath = join(serverCwd, 'linked.txt');
|
||||
symlinkSync(outsideFile, linkPath);
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=linked.txt`);
|
||||
await res.text().catch(() => {});
|
||||
assert.equal(res.status, 403);
|
||||
} finally {
|
||||
rmSync(linkPath, { force: true });
|
||||
rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('/source serves a symlink whose target stays inside the project', async () => {
|
||||
const nestedDir = join(serverCwd, 'alias');
|
||||
mkdirSync(nestedDir, { recursive: true });
|
||||
const realFile = join(nestedDir, 'page.html');
|
||||
writeFileSync(realFile, '<h1>via alias</h1>\n');
|
||||
const linkPath = join(serverCwd, 'alias-link.html');
|
||||
symlinkSync(realFile, linkPath);
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=alias-link.html`);
|
||||
assert.equal(res.status, 200);
|
||||
const text = await res.text();
|
||||
assert.ok(text.includes('via alias'));
|
||||
} finally {
|
||||
rmSync(linkPath, { force: true });
|
||||
rmSync(nestedDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('/source returns 404 for a broken symlink', async () => {
|
||||
const linkPath = join(serverCwd, 'broken-link.txt');
|
||||
symlinkSync(join(serverCwd, 'missing-target.txt'), linkPath);
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=broken-link.txt`);
|
||||
await res.text().catch(() => {});
|
||||
assert.equal(res.status, 404);
|
||||
} finally {
|
||||
rmSync(linkPath, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('/source rejects a directory symlink whose nested file is outside the project', async () => {
|
||||
const outsideDir = mkdtempSync(join(tmpdir(), 'impeccable-live-outside-dir-'));
|
||||
writeFileSync(join(outsideDir, 'cred.txt'), 'OUTSIDE SECRET');
|
||||
const linkPath = join(serverCwd, 'escape-dir');
|
||||
symlinkSync(outsideDir, linkPath);
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=escape-dir/cred.txt`);
|
||||
await res.text().catch(() => {});
|
||||
assert.equal(res.status, 403);
|
||||
} finally {
|
||||
rmSync(linkPath, { force: true });
|
||||
rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('/source rejects a chained symlink that resolves outside the project', async () => {
|
||||
const outsideDir = mkdtempSync(join(tmpdir(), 'impeccable-live-outside-chain-'));
|
||||
const outsideFile = join(outsideDir, 'secret.txt');
|
||||
writeFileSync(outsideFile, 'OUTSIDE SECRET');
|
||||
const midPath = join(serverCwd, 'mid-link.txt');
|
||||
const linkPath = join(serverCwd, 'double-out.txt');
|
||||
symlinkSync(outsideFile, midPath);
|
||||
symlinkSync(midPath, linkPath);
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=double-out.txt`);
|
||||
await res.text().catch(() => {});
|
||||
assert.equal(res.status, 403);
|
||||
} finally {
|
||||
rmSync(linkPath, { force: true });
|
||||
rmSync(midPath, { force: true });
|
||||
rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('/source rejects a relative symlink that points outside the project', async () => {
|
||||
const outsideDir = mkdtempSync(join(tmpdir(), 'impeccable-live-outside-rel-'));
|
||||
const outsideFile = join(outsideDir, 'secret.txt');
|
||||
writeFileSync(outsideFile, 'OUTSIDE SECRET');
|
||||
const linkPath = join(serverCwd, 'rel-out.txt');
|
||||
symlinkSync(relative(serverCwd, outsideFile), linkPath);
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=rel-out.txt`);
|
||||
await res.text().catch(() => {});
|
||||
assert.equal(res.status, 403);
|
||||
} finally {
|
||||
rmSync(linkPath, { force: true });
|
||||
rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('/modern-screenshot.js serves the vendored UMD build', async () => {
|
||||
const res = await fetch(`http://localhost:${server.port}/modern-screenshot.js`);
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
Reference in New Issue
Block a user