mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
fix(live): CSP-meta patch+revert preserves space before self-closing /
Sanity-check on the live-inject unwrap path turned up a real round-trip
bug on HTML files that ship a `<meta http-equiv="Content-Security-Policy"
content="..." />` tag (the leading space before `/>` is the canonical
self-closing form).
Trace:
- The tag-finder regex (`<meta\s+([^>]*?)\/?>`) captures any whitespace
between the last attribute and the closing `/>` as part of `attrs`.
- patchCspMeta did `attrs.replace(content, newContent) + ' ' + marker`,
appending the marker AFTER that captured trailing whitespace. Result:
`...content="..." data-...="..."` — a double space inside attrs and
the original space-before-slash gone.
- revertCspMeta then strips the marker via `\s*${origAttr.full}`, which
greedily eats both spaces — so the round trip leaves `"/>` with no
space, even though the original was `" />`.
Fix: split off the trailing whitespace from `attrs` before patching,
splice the marker into the attribute body with a single leading space,
and re-append the original trailing whitespace. The marker-removal
regex then consumes exactly one space and the trailing space rides
through unchanged.
Test coverage:
- New `round-trips through CSP-meta patch and revert` test in
live-inject.test.mjs covers the canonical Vite shape (CSP meta with
` />`).
- Plus a `round-trips with insertAfter` test for symmetry — the existing
suite only covered insertBefore.
- Existing 4 round-trip tests (HTML, JSX layout, multi-file, column-0)
all still pass byte-for-byte.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a701ee613a
commit
11dfad81da
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
|
||||
|
||||
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
|
||||
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
|
||||
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
|
||||
// The tagRe captures any whitespace between the last attribute and the
|
||||
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
|
||||
// a replace would land it BEFORE that trailing space, leaving a double
|
||||
// space inside attrs and clobbering the space before `/>`. Split off
|
||||
// the trailing whitespace, splice the marker into the attribute body,
|
||||
// and re-append the original trailing whitespace so a self-closing
|
||||
// `<meta … />` round-trips byte-for-byte.
|
||||
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
|
||||
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
|
||||
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
|
||||
const newTag = tag.full.replace(attrs, newAttrs);
|
||||
|
||||
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
||||
|
||||
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
|
||||
|
||||
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
|
||||
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
|
||||
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
|
||||
// The tagRe captures any whitespace between the last attribute and the
|
||||
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
|
||||
// a replace would land it BEFORE that trailing space, leaving a double
|
||||
// space inside attrs and clobbering the space before `/>`. Split off
|
||||
// the trailing whitespace, splice the marker into the attribute body,
|
||||
// and re-append the original trailing whitespace so a self-closing
|
||||
// `<meta … />` round-trips byte-for-byte.
|
||||
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
|
||||
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
|
||||
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
|
||||
const newTag = tag.full.replace(attrs, newAttrs);
|
||||
|
||||
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
||||
|
||||
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
|
||||
|
||||
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
|
||||
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
|
||||
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
|
||||
// The tagRe captures any whitespace between the last attribute and the
|
||||
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
|
||||
// a replace would land it BEFORE that trailing space, leaving a double
|
||||
// space inside attrs and clobbering the space before `/>`. Split off
|
||||
// the trailing whitespace, splice the marker into the attribute body,
|
||||
// and re-append the original trailing whitespace so a self-closing
|
||||
// `<meta … />` round-trips byte-for-byte.
|
||||
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
|
||||
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
|
||||
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
|
||||
const newTag = tag.full.replace(attrs, newAttrs);
|
||||
|
||||
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
||||
|
||||
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
|
||||
|
||||
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
|
||||
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
|
||||
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
|
||||
// The tagRe captures any whitespace between the last attribute and the
|
||||
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
|
||||
// a replace would land it BEFORE that trailing space, leaving a double
|
||||
// space inside attrs and clobbering the space before `/>`. Split off
|
||||
// the trailing whitespace, splice the marker into the attribute body,
|
||||
// and re-append the original trailing whitespace so a self-closing
|
||||
// `<meta … />` round-trips byte-for-byte.
|
||||
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
|
||||
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
|
||||
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
|
||||
const newTag = tag.full.replace(attrs, newAttrs);
|
||||
|
||||
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
||||
|
||||
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
|
||||
|
||||
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
|
||||
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
|
||||
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
|
||||
// The tagRe captures any whitespace between the last attribute and the
|
||||
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
|
||||
// a replace would land it BEFORE that trailing space, leaving a double
|
||||
// space inside attrs and clobbering the space before `/>`. Split off
|
||||
// the trailing whitespace, splice the marker into the attribute body,
|
||||
// and re-append the original trailing whitespace so a self-closing
|
||||
// `<meta … />` round-trips byte-for-byte.
|
||||
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
|
||||
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
|
||||
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
|
||||
const newTag = tag.full.replace(attrs, newAttrs);
|
||||
|
||||
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
||||
|
||||
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
|
||||
|
||||
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
|
||||
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
|
||||
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
|
||||
// The tagRe captures any whitespace between the last attribute and the
|
||||
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
|
||||
// a replace would land it BEFORE that trailing space, leaving a double
|
||||
// space inside attrs and clobbering the space before `/>`. Split off
|
||||
// the trailing whitespace, splice the marker into the attribute body,
|
||||
// and re-append the original trailing whitespace so a self-closing
|
||||
// `<meta … />` round-trips byte-for-byte.
|
||||
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
|
||||
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
|
||||
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
|
||||
const newTag = tag.full.replace(attrs, newAttrs);
|
||||
|
||||
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
||||
|
||||
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
|
||||
|
||||
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
|
||||
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
|
||||
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
|
||||
// The tagRe captures any whitespace between the last attribute and the
|
||||
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
|
||||
// a replace would land it BEFORE that trailing space, leaving a double
|
||||
// space inside attrs and clobbering the space before `/>`. Split off
|
||||
// the trailing whitespace, splice the marker into the attribute body,
|
||||
// and re-append the original trailing whitespace so a self-closing
|
||||
// `<meta … />` round-trips byte-for-byte.
|
||||
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
|
||||
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
|
||||
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
|
||||
const newTag = tag.full.replace(attrs, newAttrs);
|
||||
|
||||
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
||||
|
||||
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
|
||||
|
||||
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
|
||||
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
|
||||
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
|
||||
// The tagRe captures any whitespace between the last attribute and the
|
||||
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
|
||||
// a replace would land it BEFORE that trailing space, leaving a double
|
||||
// space inside attrs and clobbering the space before `/>`. Split off
|
||||
// the trailing whitespace, splice the marker into the attribute body,
|
||||
// and re-append the original trailing whitespace so a self-closing
|
||||
// `<meta … />` round-trips byte-for-byte.
|
||||
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
|
||||
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
|
||||
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
|
||||
const newTag = tag.full.replace(attrs, newAttrs);
|
||||
|
||||
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
||||
|
||||
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
|
||||
|
||||
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
|
||||
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
|
||||
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
|
||||
// The tagRe captures any whitespace between the last attribute and the
|
||||
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
|
||||
// a replace would land it BEFORE that trailing space, leaving a double
|
||||
// space inside attrs and clobbering the space before `/>`. Split off
|
||||
// the trailing whitespace, splice the marker into the attribute body,
|
||||
// and re-append the original trailing whitespace so a self-closing
|
||||
// `<meta … />` round-trips byte-for-byte.
|
||||
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
|
||||
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
|
||||
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
|
||||
const newTag = tag.full.replace(attrs, newAttrs);
|
||||
|
||||
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
||||
|
||||
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
|
||||
|
||||
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
|
||||
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
|
||||
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
|
||||
// The tagRe captures any whitespace between the last attribute and the
|
||||
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
|
||||
// a replace would land it BEFORE that trailing space, leaving a double
|
||||
// space inside attrs and clobbering the space before `/>`. Split off
|
||||
// the trailing whitespace, splice the marker into the attribute body,
|
||||
// and re-append the original trailing whitespace so a self-closing
|
||||
// `<meta … />` round-trips byte-for-byte.
|
||||
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
|
||||
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
|
||||
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
|
||||
const newTag = tag.full.replace(attrs, newAttrs);
|
||||
|
||||
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
||||
|
||||
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
|
||||
|
||||
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
|
||||
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
|
||||
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
|
||||
// The tagRe captures any whitespace between the last attribute and the
|
||||
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
|
||||
// a replace would land it BEFORE that trailing space, leaving a double
|
||||
// space inside attrs and clobbering the space before `/>`. Split off
|
||||
// the trailing whitespace, splice the marker into the attribute body,
|
||||
// and re-append the original trailing whitespace so a self-closing
|
||||
// `<meta … />` round-trips byte-for-byte.
|
||||
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
|
||||
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
|
||||
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
|
||||
const newTag = tag.full.replace(attrs, newAttrs);
|
||||
|
||||
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
||||
|
||||
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
|
||||
|
||||
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
|
||||
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
|
||||
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
|
||||
// The tagRe captures any whitespace between the last attribute and the
|
||||
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
|
||||
// a replace would land it BEFORE that trailing space, leaving a double
|
||||
// space inside attrs and clobbering the space before `/>`. Split off
|
||||
// the trailing whitespace, splice the marker into the attribute body,
|
||||
// and re-append the original trailing whitespace so a self-closing
|
||||
// `<meta … />` round-trips byte-for-byte.
|
||||
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
|
||||
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
|
||||
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
|
||||
const newTag = tag.full.replace(attrs, newAttrs);
|
||||
|
||||
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
||||
|
||||
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
|
||||
|
||||
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
|
||||
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
|
||||
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
|
||||
// The tagRe captures any whitespace between the last attribute and the
|
||||
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
|
||||
// a replace would land it BEFORE that trailing space, leaving a double
|
||||
// space inside attrs and clobbering the space before `/>`. Split off
|
||||
// the trailing whitespace, splice the marker into the attribute body,
|
||||
// and re-append the original trailing whitespace so a self-closing
|
||||
// `<meta … />` round-trips byte-for-byte.
|
||||
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
|
||||
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
|
||||
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
|
||||
const newTag = tag.full.replace(attrs, newAttrs);
|
||||
|
||||
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
|
||||
|
||||
@@ -128,6 +128,71 @@ describe('live-inject — insert/remove round-trip preserves file bytes', () =>
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips with insertAfter — preserves indented opener line below it', () => {
|
||||
const original = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Hello</h1>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const file = join(tmp, 'index.html');
|
||||
writeFileSync(file, original);
|
||||
|
||||
const cfgPath = join(tmp, 'config.json');
|
||||
writeFileSync(cfgPath, JSON.stringify({
|
||||
files: ['index.html'],
|
||||
insertAfter: '<head>',
|
||||
commentSyntax: 'html',
|
||||
}));
|
||||
|
||||
runInject(tmp, cfgPath, ['--port', '8400']);
|
||||
runInject(tmp, cfgPath, ['--remove']);
|
||||
|
||||
const after = readFileSync(file, 'utf-8');
|
||||
assert.equal(after, original, 'insertAfter round-trip must restore original byte-for-byte');
|
||||
});
|
||||
|
||||
it('round-trips through CSP-meta patch and revert (insert mutates the meta tag, remove restores it)', () => {
|
||||
// Mirrors a Vite app that ships a CSP meta tag in index.html. live-inject
|
||||
// appends `http://localhost:PORT` to script-src / connect-src on insert
|
||||
// and stashes the original directives in `data-impeccable-csp-original`.
|
||||
// --remove must restore the meta tag's original `content` exactly.
|
||||
const original = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; connect-src 'self';" />
|
||||
<title>CSP test</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Hello</h1>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const file = join(tmp, 'index.html');
|
||||
writeFileSync(file, original);
|
||||
|
||||
const cfgPath = join(tmp, 'config.json');
|
||||
writeFileSync(cfgPath, JSON.stringify({
|
||||
files: ['index.html'],
|
||||
insertBefore: '</body>',
|
||||
commentSyntax: 'html',
|
||||
}));
|
||||
|
||||
runInject(tmp, cfgPath, ['--port', '8400']);
|
||||
runInject(tmp, cfgPath, ['--remove']);
|
||||
|
||||
const after = readFileSync(file, 'utf-8');
|
||||
assert.equal(after, original, 'CSP meta tag must round-trip exactly through insert+remove');
|
||||
});
|
||||
|
||||
it('round-trips when the insert anchor has no leading indent (column-0 </body>)', () => {
|
||||
const original = `<html>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user