1.1.1版本审核修复
[spine-utils.js (line 35)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/js/spine-utils.js:35) 统一拦截绝对路径、..、Windows 盘符、UNC、URI、控制字符和符号链接越界。 跨素材贴图只能使用已枚举、验证且位于 Eagle 素材目录内的文件。 [spine-render.js (line 62)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/js/spine-render.js:62) 已删除未经验证的路径备用拼接。 自动缩略图只写当前回调提供的 dest,不再调用接口改写同组素材。 已删除后台整组补刷进程及 relatedDataPaths。 [viewer/spine.html (line 482)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/viewer/spine.html:482) 的手动缩略图只针对当前素材,并核对素材 ID、文件名和扩展名。 当前素材已有自定义缩略图时,会提示替换后无法自动恢复并要求确认。 手动生成的中间文件改存系统临时目录,API 失败时不再直接写 Eagle 素材目录。 八种语言文案及 README 已同步。 处理历史日志遗留问题
This commit is contained in:
1 parent
473a270510
commit
bb73cc9227
36 files changed
+499
-652
No files matched your search
@@ -10,8 +10,11 @@ const root=path.resolve(process.argv[2]||path.join(__dirname,'.fixtures'));
|
||||
const dir=path.join(root,version,name),atlasPath=path.join(dir,name+'.atlas');
|
||||
if(!fs.existsSync(atlasPath))continue;
|
||||
const text=fs.readFileSync(atlasPath,'utf8');
|
||||
for(const page of utils.parseAtlasPages(text))assert(fs.existsSync(path.join(dir,page)),version+'/'+name+' missing '+page);
|
||||
const atlas=await render.buildTextureAtlas(spine,text,dir);
|
||||
const texturePaths={};
|
||||
for(const page of utils.parseAtlasPages(text)){
|
||||
const texture=utils.resolveAtlasTextureFile(dir,page);assert(texture,version+'/'+name+' missing '+page);texturePaths[page]=texture;
|
||||
}
|
||||
const atlas=await render.buildTextureAtlas(spine,text,texturePaths);
|
||||
for(const file of fs.readdirSync(dir).filter(n=>/\.(json|skel)$/.test(n))){
|
||||
const source=path.join(dir,file),detected=utils.detectVersion(source);
|
||||
assert.equal(utils.chooseRuntimeDir(detected,file.endsWith('.skel')),version,source+' version');
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
'use strict';
|
||||
const assert = require('assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { execFile } = require('child_process');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-worker-lifecycle-'));
|
||||
const library = path.join(root, 'test.library');
|
||||
const source = path.join(library, 'images', 'test.info', 'hero.atlas.txt');
|
||||
const marker = path.join(root, 'completed');
|
||||
const worker = path.resolve(__dirname, '../js/spine-reconcile-worker.js');
|
||||
const thumbnail = path.resolve(__dirname, '../thumbnail/spine.js');
|
||||
const key = crypto.createHash('sha256').update(path.dirname(worker) + library).digest('hex');
|
||||
const queue = path.join(os.tmpdir(), 'spineviewer-reconcile', key);
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
(async () => {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(source), { recursive: true }); fs.writeFileSync(source, 'atlas');
|
||||
fs.writeFileSync(path.join(library, 'metadata.json'), '{"folders":[]}');
|
||||
// Isolate lifecycle behavior from rendering and the user's running Eagle API.
|
||||
const preload = path.join(root, 'preload.cjs');
|
||||
fs.writeFileSync(preload, `require.cache[${JSON.stringify(thumbnail)}] = { exports: {
|
||||
reconcileOnce: async (src, previous) => {
|
||||
if (previous !== 'stable') return { signature: 'stable', complete: false };
|
||||
require('fs').appendFileSync(${JSON.stringify(marker)}, String(process.pid) + '\\n');
|
||||
return { complete: true };
|
||||
}
|
||||
}};`);
|
||||
await new Promise((resolve, reject) => execFile(process.execPath, ['-e', `require(${JSON.stringify(worker)}).enqueue(${JSON.stringify(source)})`],
|
||||
{ env: { ...process.env, NODE_OPTIONS: '--require=' + preload } }, error => error ? reject(error) : resolve()));
|
||||
assert(!fs.existsSync(marker), 'launcher returns before reconciliation');
|
||||
const deadline = Date.now() + 15000;
|
||||
while ((!fs.existsSync(marker) || fs.existsSync(path.join(queue, 'worker.lock'))) && Date.now() < deadline) await sleep(100);
|
||||
assert(fs.existsSync(marker), 'worker completes after launching process has exited');
|
||||
assert(!fs.existsSync(path.join(queue, 'worker.lock')), 'worker releases lock and exits when idle');
|
||||
assert.equal(fs.readdirSync(queue).filter(name => name.endsWith('.json')).length, 0);
|
||||
console.log('PASS: detached worker survives callback process exit, completes queued work and releases lock');
|
||||
} finally {
|
||||
if (!fs.existsSync(path.join(queue, 'worker.lock'))) {
|
||||
fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(queue, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,43 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const releaseDir = path.join(root, 'release', 'UNIVERSAL_SpineViewer');
|
||||
const archive = path.join(root, 'release', 'SpineViewer.eagleplugin');
|
||||
|
||||
function walk(directory, prefix = '') {
|
||||
const files = [];
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const relative = path.posix.join(prefix, entry.name);
|
||||
if (entry.isDirectory()) files.push(...walk(path.join(directory, entry.name), relative));
|
||||
else files.push(relative);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function zipEntries(file) {
|
||||
const data = fs.readFileSync(file);
|
||||
const entries = [];
|
||||
for (let offset = 0; offset + 46 <= data.length;) {
|
||||
const header = data.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]), offset);
|
||||
if (header < 0) break;
|
||||
const nameLength = data.readUInt16LE(header + 28);
|
||||
const extraLength = data.readUInt16LE(header + 30);
|
||||
const commentLength = data.readUInt16LE(header + 32);
|
||||
entries.push(data.subarray(header + 46, header + 46 + nameLength).toString('utf8'));
|
||||
offset = header + 46 + nameLength + extraLength + commentLength;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
for (const [label, files] of [
|
||||
['release directory', walk(releaseDir)],
|
||||
['eagleplugin archive', zipEntries(archive)],
|
||||
]) {
|
||||
assert.equal(files.some((file) => /\.log$/i.test(file)), false, label + ' must not contain runtime or historical logs');
|
||||
assert.equal(files.some((file) => /(^|\/)\.DS_Store$/i.test(file)), false, label + ' must not contain macOS metadata');
|
||||
}
|
||||
|
||||
console.log('PASS: release directory and .eagleplugin contain no logs or .DS_Store files');
|
||||
@@ -0,0 +1,77 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const utils = require('../js/spine-utils');
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-security-'));
|
||||
try {
|
||||
for (const unsafe of [
|
||||
'../../../secret.png',
|
||||
'..\\..\\secret.png',
|
||||
'/tmp/secret.png',
|
||||
'C:\\secret.png',
|
||||
'C:secret.png',
|
||||
'\\\\server\\share\\secret.png',
|
||||
'file:///tmp/secret.png',
|
||||
'folder/../secret.png',
|
||||
'folder\\..\\secret.png',
|
||||
'bad\0name.png',
|
||||
]) {
|
||||
assert.throws(() => utils.validateAtlasPageName(unsafe), (error) => error && error.code === 'UNSAFE_ATLAS_PATH', unsafe);
|
||||
}
|
||||
assert.equal(utils.validateAtlasPageName('./textures/page.png'), 'textures/page.png');
|
||||
|
||||
const atlasDir = path.join(root, 'atlas');
|
||||
fs.mkdirSync(path.join(atlasDir, 'textures'), { recursive: true });
|
||||
const nestedTexture = path.join(atlasDir, 'textures', 'page.png');
|
||||
fs.writeFileSync(nestedTexture, 'texture');
|
||||
assert.equal(utils.resolveAtlasTextureFile(atlasDir, 'textures/page.png'), nestedTexture);
|
||||
|
||||
const outside = path.join(root, 'outside.png');
|
||||
const link = path.join(atlasDir, 'linked.png');
|
||||
fs.writeFileSync(outside, 'private');
|
||||
try {
|
||||
fs.symlinkSync(outside, link);
|
||||
assert.throws(() => utils.resolveAtlasTextureFile(atlasDir, 'linked.png'), (error) => error && error.code === 'UNSAFE_ATLAS_PATH');
|
||||
} catch (error) {
|
||||
if (!['EPERM', 'EACCES'].includes(error.code)) throw error;
|
||||
}
|
||||
|
||||
const library = path.join(root, 'safe.library');
|
||||
const images = path.join(library, 'images');
|
||||
fs.mkdirSync(images, { recursive: true });
|
||||
fs.writeFileSync(path.join(library, 'metadata.json'), JSON.stringify({ folders: [{ id: 'spine', name: 'Spine', children: [] }] }));
|
||||
function item(id, name, contents) {
|
||||
const dir = path.join(images, id + '.info');
|
||||
fs.mkdirSync(dir);
|
||||
fs.writeFileSync(path.join(dir, 'metadata.json'), JSON.stringify({ id, name: path.parse(name).name, ext: path.extname(name).slice(1), folders: ['spine'], isDeleted: false }));
|
||||
const file = path.join(dir, name);
|
||||
fs.writeFileSync(file, contents);
|
||||
return file;
|
||||
}
|
||||
const skeleton = item('SKELETON', 'hero.skel', Buffer.from('\0' + '3.8.1\0'));
|
||||
item('ATLAS', 'hero.atlas', 'textures/hero.png\nsize: 64,64\n');
|
||||
const texture = item('TEXTURE', 'hero.png', 'texture');
|
||||
const assets = utils.discoverAssets(skeleton, { strictStem: true, refreshIndex: true });
|
||||
assert.equal(assets.texturePaths['textures/hero.png'], texture, 'cross-item texture must come from a validated Eagle item');
|
||||
|
||||
const maliciousDir = path.join(root, 'malicious');
|
||||
fs.mkdirSync(maliciousDir);
|
||||
const maliciousSkeleton = path.join(maliciousDir, 'evil.skel');
|
||||
fs.writeFileSync(maliciousSkeleton, Buffer.from('\0' + '3.8.1\0'));
|
||||
fs.writeFileSync(path.join(maliciousDir, 'evil.atlas'), '../../../secret.png\nsize: 64,64\n');
|
||||
assert.throws(() => utils.discoverAssets(maliciousSkeleton), (error) => error && error.code === 'UNSAFE_ATLAS_PATH');
|
||||
|
||||
const thumbnailSource = fs.readFileSync(path.join(__dirname, '../thumbnail/spine.js'), 'utf8');
|
||||
assert.doesNotMatch(thumbnailSource, /setCustomThumbnail|refreshRelatedThumbnails|spine-reconcile-worker|relatedDataPaths/);
|
||||
assert.equal(fs.existsSync(path.join(__dirname, '../js/spine-reconcile-worker.js')), false);
|
||||
const viewerSource = fs.readFileSync(path.join(__dirname, '../viewer/spine.html'), 'utf8');
|
||||
assert.match(viewerSource, /currentItem\.data\.customThumbnail === true && !window\.confirm/);
|
||||
assert.match(viewerSource, /path\.join\(os\.tmpdir\(\), 'spineviewer-current-'/);
|
||||
assert.doesNotMatch(viewerSource, /fs\.writeFileSync\(outPath/);
|
||||
console.log('PASS: atlas traversal, absolute paths, symlink escape, validated Eagle pairing, and no automatic cross-item thumbnail writes');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const { normalizeItem } = require('../js/eagle-compound');
|
||||
const generate = require('../thumbnail/spine');
|
||||
const { canvasLib } = require('../js/spine-render');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-compound-thumbnail-'));
|
||||
const fixtures = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures/3.4/spineboy'));
|
||||
const fixtures = path.resolve(process.argv[2] || path.join(__dirname, '34-43/3.4/spineboy'));
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { createRequire } = require('node:module');
|
||||
const roots = ['.', 'release/MAC_arm64_SpineViewer', 'release/WIN_x64_SpineViewer'];
|
||||
const roots = ['.', 'release/UNIVERSAL_SpineViewer'];
|
||||
for (const root of roots) {
|
||||
const html = fs.readFileSync(path.join(root, 'viewer/spine.html'), 'utf8');
|
||||
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
|
||||
@@ -40,4 +40,4 @@ for (const root of roots) {
|
||||
assert.equal(children[0].title, table.viewer.staticPoseTitle);
|
||||
}
|
||||
}
|
||||
console.log('PASS: source and both releases; eight languages; animated/static keyframe labels and translation keys');
|
||||
console.log('PASS: source and universal release; eight languages; animated/static keyframe labels and translation keys');
|
||||
Reference in new issue
Block a user