修复浏览器缓存内部链接复制并冻结门禁候选(09-06-release-gate-integrity)
这个提交包含在:
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Copy a frozen local browser cache without flattening internal file aliases."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
|
||||
|
||||
class CacheCopyError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def regular_reader(file):
|
||||
descriptor = os.open(file, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
||||
os.close(descriptor)
|
||||
raise CacheCopyError('special_file')
|
||||
return os.fdopen(descriptor, 'rb')
|
||||
|
||||
|
||||
def file_digest(file):
|
||||
digest = hashlib.sha256()
|
||||
with regular_reader(file) as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def snapshot(source):
|
||||
source = Path(source)
|
||||
if source.is_symlink() or not source.is_dir():
|
||||
raise CacheCopyError('source_not_physical_directory')
|
||||
root = source.resolve(strict=True)
|
||||
entries = {}
|
||||
def walk_error(_error):
|
||||
raise CacheCopyError('source_unreadable')
|
||||
# Classify and validate every link before opening any linked file content.
|
||||
for parent, directories, files in os.walk(root, followlinks=False, onerror=walk_error):
|
||||
for name in sorted(directories + files):
|
||||
file = Path(parent) / name
|
||||
mode = file.lstat().st_mode
|
||||
relative = file.relative_to(root).as_posix()
|
||||
if stat.S_ISLNK(mode):
|
||||
target = os.readlink(file)
|
||||
if os.path.isabs(target):
|
||||
raise CacheCopyError('absolute_link')
|
||||
try:
|
||||
resolved = file.resolve(strict=True)
|
||||
except (OSError, RuntimeError):
|
||||
raise CacheCopyError('broken_or_cyclic_link') from None
|
||||
if not resolved.is_relative_to(root):
|
||||
raise CacheCopyError('external_link')
|
||||
if not resolved.is_file():
|
||||
raise CacheCopyError('link_target_not_regular_file')
|
||||
entries[relative] = {'kind': 'link', 'target': target, 'resolved': resolved.relative_to(root).as_posix()}
|
||||
elif stat.S_ISDIR(mode):
|
||||
entries[relative] = {'kind': 'directory', 'mode': stat.S_IMODE(mode)}
|
||||
elif stat.S_ISREG(mode):
|
||||
entries[relative] = {'kind': 'file', 'mode': stat.S_IMODE(mode), 'size': file.stat().st_size}
|
||||
else:
|
||||
raise CacheCopyError('special_file')
|
||||
for name, entry in entries.items():
|
||||
if entry['kind'] == 'file':
|
||||
entry['sha256'] = file_digest(root / name)
|
||||
for entry in entries.values():
|
||||
if entry['kind'] == 'link':
|
||||
target = entries.get(entry['resolved'])
|
||||
if not target or target['kind'] != 'file':
|
||||
raise CacheCopyError('link_target_not_in_manifest')
|
||||
return {'rootMode': stat.S_IMODE(root.stat().st_mode), 'entries': dict(sorted(entries.items()))}
|
||||
|
||||
|
||||
def snapshot_digest(value):
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
|
||||
|
||||
def check_paths(source, destination):
|
||||
if source.is_symlink() or not source.is_dir():
|
||||
raise CacheCopyError('source_not_physical_directory')
|
||||
source_root = source.resolve(strict=True)
|
||||
target_root = destination.resolve(strict=False)
|
||||
if target_root.is_relative_to(source_root) or source_root.is_relative_to(target_root):
|
||||
raise CacheCopyError('overlapping_directories')
|
||||
if os.path.lexists(destination):
|
||||
raise CacheCopyError('destination_exists')
|
||||
if not destination.parent.is_dir():
|
||||
raise CacheCopyError('destination_parent_missing')
|
||||
return source_root, target_root
|
||||
|
||||
|
||||
def copy_regular(source, destination):
|
||||
# Exclusive destination and no-follow source: never turn a file alias into
|
||||
# an extra loaded library, or reuse a pre-existing destination inode.
|
||||
with regular_reader(source) as reader, destination.open('xb') as writer:
|
||||
metadata = os.fstat(reader.fileno())
|
||||
shutil.copyfileobj(reader, writer)
|
||||
os.fchmod(writer.fileno(), stat.S_IMODE(metadata.st_mode))
|
||||
os.utime(destination, ns=(metadata.st_atime_ns, metadata.st_mtime_ns), follow_symlinks=False)
|
||||
|
||||
|
||||
def copy_cache(source, destination):
|
||||
source, destination = check_paths(Path(source), Path(destination))
|
||||
before = snapshot(source)
|
||||
destination.mkdir(mode=0o700)
|
||||
directories = [name for name, entry in before['entries'].items() if entry['kind'] == 'directory']
|
||||
for name in sorted(directories, key=lambda value: (value.count('/'), value)):
|
||||
(destination / name).mkdir(mode=0o700)
|
||||
for name, entry in before['entries'].items():
|
||||
if entry['kind'] == 'file':
|
||||
copy_regular(source / name, destination / name)
|
||||
for name, entry in before['entries'].items():
|
||||
if entry['kind'] == 'link':
|
||||
(destination / name).symlink_to(entry['target'])
|
||||
for name in reversed(sorted(directories, key=lambda value: (value.count('/'), value))):
|
||||
(destination / name).chmod(before['entries'][name]['mode'])
|
||||
destination.chmod(before['rootMode'])
|
||||
after = snapshot(source)
|
||||
copied = snapshot(destination)
|
||||
if before != after:
|
||||
raise CacheCopyError('source_changed')
|
||||
if copied != before:
|
||||
raise CacheCopyError('copy_identity_mismatch')
|
||||
regular = [name for name, entry in before['entries'].items() if entry['kind'] == 'file']
|
||||
source_inodes = {(entry.st_dev, entry.st_ino) for entry in ((source / name).stat() for name in regular)}
|
||||
if any((entry.st_dev, entry.st_ino) in source_inodes for entry in ((destination / name).stat() for name in regular)):
|
||||
raise CacheCopyError('source_inode_shared')
|
||||
return {
|
||||
'schemaVersion': 1, 'status': 'PASS', 'scope': 'LOCAL_BROWSER_CACHE_COPY_ONLY',
|
||||
'sourceUnchanged': True, 'sourceInodesIndependent': True, 'bytesModesAndLinksMatch': True,
|
||||
'regularFiles': len(regular), 'internalFileLinks': sum(entry['kind'] == 'link' for entry in before['entries'].values()),
|
||||
'sourceBeforeSha256': snapshot_digest(before), 'sourceAfterSha256': snapshot_digest(after),
|
||||
'destinationSha256': snapshot_digest(copied), 'manifest': before, 'deploymentEligible': False,
|
||||
}
|
||||
|
||||
|
||||
def main(arguments):
|
||||
if len(arguments) != 3:
|
||||
raise CacheCopyError('usage_source_destination_receipt_required')
|
||||
source, destination, receipt = map(Path, arguments)
|
||||
source_root, target_root = check_paths(source, destination)
|
||||
receipt_root = receipt.resolve(strict=False)
|
||||
if receipt_root.is_relative_to(source_root) or receipt_root.is_relative_to(target_root):
|
||||
raise CacheCopyError('receipt_inside_cache')
|
||||
if os.path.lexists(receipt):
|
||||
raise CacheCopyError('receipt_exists')
|
||||
# Preserve failures as new evidence; never erase a partially copied cache.
|
||||
with receipt.open('x', encoding='utf8') as stream:
|
||||
os.fchmod(stream.fileno(), 0o600)
|
||||
try:
|
||||
result = copy_cache(source, destination)
|
||||
except CacheCopyError as error:
|
||||
result = {'status': 'FAIL', 'errorKind': str(error), 'deploymentEligible': False}
|
||||
except OSError:
|
||||
result = {'status': 'FAIL', 'errorKind': 'filesystem_error', 'deploymentEligible': False}
|
||||
json.dump(result, stream, ensure_ascii=False, indent=2)
|
||||
stream.write('\n')
|
||||
print(json.dumps({key: value for key, value in result.items() if key != 'manifest'}))
|
||||
return 0 if result['status'] == 'PASS' else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
except CacheCopyError as error:
|
||||
print(json.dumps({'status': 'FAIL', 'errorKind': str(error), 'deploymentEligible': False}))
|
||||
raise SystemExit(1)
|
||||
except OSError:
|
||||
print(json.dumps({'status': 'FAIL', 'errorKind': 'filesystem_error', 'deploymentEligible': False}))
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,163 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const script = fileURLToPath(new URL('../copy-browser-cache.py', import.meta.url));
|
||||
function fixture(t) {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'hyb-cache-copy-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const source = path.join(root, 'source');
|
||||
mkdirSync(path.join(source, 'lib'), { recursive: true });
|
||||
writeFileSync(path.join(source, 'lib', 'engine.so.1.2'), 'synthetic-library-bytes');
|
||||
chmodSync(path.join(source, 'lib', 'engine.so.1.2'), 0o751);
|
||||
return { root, source, destination: path.join(root, 'copied'), receipt: path.join(root, 'receipt.json') };
|
||||
}
|
||||
function run(f, command = script, args = [f.source, f.destination, f.receipt]) {
|
||||
const result = spawnSync('python3', [command, ...args], { encoding: 'utf8', env: { PATH: process.env.PATH, LANG: 'C.UTF-8', HOME: f.root, TMPDIR: f.root, PYTHONDONTWRITEBYTECODE: '1' } });
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(result.stderr, '');
|
||||
assert.doesNotMatch(result.stdout, /Traceback|synthetic-private-data/);
|
||||
assert.equal(result.stdout.includes(f.root), false, 'CLI must not echo absolute paths');
|
||||
return { ...result, summary: JSON.parse(result.stdout) };
|
||||
}
|
||||
|
||||
test('内部别名保留同一目标身份;副本与源文件独立且保留字节/模式/mtime', t => {
|
||||
const f = fixture(t);
|
||||
symlinkSync('engine.so.1.2', path.join(f.source, 'lib', 'engine.so.1'));
|
||||
symlinkSync('engine.so.1', path.join(f.source, 'lib', 'engine.so'));
|
||||
mkdirSync(path.join(f.source, 'plugins'));
|
||||
symlinkSync('../lib/engine.so', path.join(f.source, 'plugins', 'alias.so'));
|
||||
const result = run(f);
|
||||
assert.equal(result.status, 0);
|
||||
const receipt = JSON.parse(readFileSync(f.receipt, 'utf8'));
|
||||
assert.equal(receipt.internalFileLinks, 3);
|
||||
assert.equal(receipt.regularFiles, 1);
|
||||
assert.equal(receipt.deploymentEligible, false);
|
||||
assert.equal(receipt.sourceBeforeSha256, receipt.sourceAfterSha256);
|
||||
assert.equal(receipt.destinationSha256, receipt.sourceBeforeSha256);
|
||||
const original = path.join(f.source, 'lib', 'engine.so.1.2');
|
||||
const copied = path.join(f.destination, 'lib', 'engine.so.1.2');
|
||||
const alias = path.join(f.destination, 'plugins', 'alias.so');
|
||||
assert.ok(lstatSync(alias).isSymbolicLink());
|
||||
assert.equal(readlinkSync(alias), '../lib/engine.so');
|
||||
assert.equal(realpathSync(alias), copied);
|
||||
assert.equal(statSync(alias).ino, statSync(copied).ino);
|
||||
assert.notEqual(statSync(copied).ino, statSync(original).ino);
|
||||
assert.equal(statSync(copied).mode & 0o777, 0o751);
|
||||
assert.equal(statSync(copied, { bigint: true }).mtimeNs, statSync(original, { bigint: true }).mtimeNs);
|
||||
assert.equal(readFileSync(alias, 'utf8'), 'synthetic-library-bytes');
|
||||
writeFileSync(copied, 'changed only in owned copy');
|
||||
assert.equal(readFileSync(original, 'utf8'), 'synthetic-library-bytes');
|
||||
assert.equal(readlinkSync(path.join(f.source, 'lib', 'engine.so')), 'engine.so.1');
|
||||
});
|
||||
|
||||
for (const variant of ['external', 'absolute', 'dangling', 'cycle', 'directory']) {
|
||||
test(`拒绝${variant}链接且不创建副本、不泄露文件内容`, t => {
|
||||
const f = fixture(t);
|
||||
writeFileSync(path.join(f.root, 'outside'), 'synthetic-private-data');
|
||||
const targets = { external: '../outside', absolute: path.join(f.source, 'lib', 'engine.so.1.2'), dangling: 'missing', cycle: 'alias', directory: 'lib' };
|
||||
symlinkSync(targets[variant], path.join(f.source, 'alias'));
|
||||
const result = run(f);
|
||||
const expected = { external: 'external_link', absolute: 'absolute_link', dangling: 'broken_or_cyclic_link', cycle: 'broken_or_cyclic_link', directory: 'link_target_not_regular_file' };
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.summary.errorKind, expected[variant]);
|
||||
assert.equal(existsSync(f.destination), false);
|
||||
assert.equal(readFileSync(path.join(f.root, 'outside'), 'utf8'), 'synthetic-private-data');
|
||||
});
|
||||
}
|
||||
|
||||
test('顶层缓存目录链接不自动跟随', t => {
|
||||
const f = fixture(t);
|
||||
const alias = path.join(f.root, 'source-alias');
|
||||
symlinkSync(f.source, alias);
|
||||
const result = run({ ...f, source: alias });
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.summary.errorKind, 'source_not_physical_directory');
|
||||
assert.equal(existsSync(f.destination), false);
|
||||
});
|
||||
|
||||
test('已有目标与回执保持不动;拒绝源目标交叠或把回执写入缓存', t => {
|
||||
const f = fixture(t);
|
||||
mkdirSync(f.destination);
|
||||
writeFileSync(path.join(f.destination, 'kept'), 'keep');
|
||||
assert.equal(run(f).summary.errorKind, 'destination_exists');
|
||||
assert.equal(readFileSync(path.join(f.destination, 'kept'), 'utf8'), 'keep');
|
||||
const destination = path.join(f.root, 'new-copy');
|
||||
writeFileSync(f.receipt, 'old-evidence');
|
||||
assert.equal(run({ ...f, destination }).summary.errorKind, 'receipt_exists');
|
||||
assert.equal(readFileSync(f.receipt, 'utf8'), 'old-evidence');
|
||||
assert.equal(existsSync(destination), false);
|
||||
assert.equal(run({ ...f, destination: path.join(f.source, 'nested') }).summary.errorKind, 'overlapping_directories');
|
||||
assert.equal(run({ ...f, destination: f.root }).summary.errorKind, 'overlapping_directories');
|
||||
assert.equal(run({ ...f, destination, receipt: path.join(f.source, 'receipt') }).summary.errorKind, 'receipt_inside_cache');
|
||||
assert.equal(run({ ...f, destination, receipt: path.join(destination, 'receipt') }).summary.errorKind, 'receipt_inside_cache');
|
||||
});
|
||||
|
||||
test('特殊文件拒绝,不尝试读取FIFO', t => {
|
||||
const f = fixture(t);
|
||||
const create = spawnSync('python3', ['-c', 'import os,sys;os.mkfifo(sys.argv[1])', path.join(f.source, 'pipe')], { encoding: 'utf8' });
|
||||
assert.equal(create.status, 0);
|
||||
const result = run(f);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.summary.errorKind, 'special_file');
|
||||
assert.equal(existsSync(f.destination), false);
|
||||
});
|
||||
|
||||
test('复制中源内容变化必须失败,保留失败副本而不虚报PASS', t => {
|
||||
const f = fixture(t);
|
||||
const injection = path.join(f.root, 'source-drift.py');
|
||||
writeFileSync(injection, `import importlib.util,sys
|
||||
from pathlib import Path
|
||||
spec=importlib.util.spec_from_file_location('cache_copy',sys.argv[1])
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
original=m.copy_regular
|
||||
def changed(source,destination):
|
||||
original(source,destination)
|
||||
source.write_text('intentional synthetic source drift')
|
||||
m.copy_regular=changed
|
||||
raise SystemExit(m.main(sys.argv[2:]))
|
||||
`);
|
||||
const result = run(f, injection, [script, f.source, f.destination, f.receipt]);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.summary.errorKind, 'source_changed');
|
||||
assert.equal(existsSync(path.join(f.destination, 'lib', 'engine.so.1.2')), true);
|
||||
assert.equal(JSON.parse(readFileSync(f.receipt, 'utf8')).status, 'FAIL');
|
||||
});
|
||||
|
||||
test('把symlink误改成普通副本的变异被结构核验拒绝', t => {
|
||||
const f = fixture(t);
|
||||
symlinkSync('engine.so.1.2', path.join(f.source, 'lib', 'engine.so'));
|
||||
const injection = path.join(f.root, 'flatten-links.py');
|
||||
writeFileSync(injection, `import importlib.util,sys
|
||||
from pathlib import Path
|
||||
spec=importlib.util.spec_from_file_location('cache_copy',sys.argv[1])
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
def flattened(self,target,target_is_directory=False):
|
||||
m.copy_regular((self.parent/target).resolve(strict=True),self)
|
||||
Path.symlink_to=flattened
|
||||
raise SystemExit(m.main(sys.argv[2:]))
|
||||
`);
|
||||
const result = run(f, injection, [script, f.source, f.destination, f.receipt]);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.summary.errorKind, 'copy_identity_mismatch');
|
||||
assert.equal(lstatSync(path.join(f.destination, 'lib', 'engine.so')).isSymbolicLink(), false);
|
||||
});
|
||||
|
||||
test('字节模式相同的硬链接副本也必须拒绝', t => {
|
||||
const f = fixture(t);
|
||||
const injection = path.join(f.root, 'shared-inode.py');
|
||||
writeFileSync(injection, `import importlib.util,os,sys
|
||||
spec=importlib.util.spec_from_file_location('cache_copy',sys.argv[1])
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
m.copy_regular=lambda source,destination:os.link(source,destination)
|
||||
raise SystemExit(m.main(sys.argv[2:]))
|
||||
`);
|
||||
const result = run(f, injection, [script, f.source, f.destination, f.receipt]);
|
||||
assert.equal(result.status, 1);
|
||||
assert.equal(result.summary.errorKind, 'source_inode_shared');
|
||||
assert.equal(statSync(path.join(f.source, 'lib', 'engine.so.1.2')).ino, statSync(path.join(f.destination, 'lib', 'engine.so.1.2')).ino);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户