diff --git a/README.md b/README.md index 96ef73a..d0b055e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,16 @@ [下载 macOS 桌面小组件 1.1.3](https://raw.githubusercontent.com/Ferryyer/gpu-queue/main/downloads/GPU-Queue-Widget-1.1.3-macOS.zip) · [校验和](downloads/SHA256SUMS) +## 1.1.2 更新报“安装包文件清单不符” + +可手动安装 [1.1.4 修复包](https://raw.githubusercontent.com/Ferryyer/gpu-queue/codex/mf-8-widget-update-fix/downloads/GPU-Queue-Widget-1.1.4-macOS.zip)。 +退出小组件及更新窗口,完整解压 ZIP,双击“安装桌面小组件.command”覆盖安装。 +安装保留 SSH 配置、窗口位置和偏好;服务器任务继续运行。旧更新器在安装新版前就会报错,因此需要这一次手动覆盖。 + +1.1.4 直接校验 ZIP 中央目录与文件头,保留下载大小、SHA-256、精确文件白名单和文件类型/大小检查,不再解析 macOS `unzip` 输出。 +修复包目前在上述审阅分支和 issue 附件,正式更新入口仍为 1.1.3;未改动已发布的旧版包。 +验证与限制见 [更新修复说明](docs/widget-update-1.1.4.md)。 + ## 安装桌面小组件 完整解压 `downloads/GPU-Queue-Widget-1.1.3-macOS.zip`,双击“安装桌面小组件.command”。如果旧更新器报“安装包文件路径无效”,先退出旧面板和更新窗口,再覆盖安装一次。 diff --git a/SOURCE-SHA256.json b/SOURCE-SHA256.json index e33c04a..f19252f 100644 --- a/SOURCE-SHA256.json +++ b/SOURCE-SHA256.json @@ -1,13 +1,15 @@ { ".gitignore": "1364e59c28568273da17a19c4b4fb78dd86aaed90f6b7e71d7823585bba84707", ".openai/hosting.json": "8e1049aefe1f5e5853c8bd13a3e4e1b01f9251a452bfd8b5e384a260f4a551b7", - "README.md": "f6cc6f56f7ca81b7a80cf5359a5849216d620fa5719910aee6a8e11289d289be", + "README.md": "fd412097efe493ac38ada05adf4b9190d90e51e4b289f63b51c80830b0f8b81a", "docs/OPERATIONS.md": "f3bf36698f26eb470016f5ed7b64fa174e8b9422a56375a0ccd9efae6b01ab29", "docs/typography-review.md": "57fc36226bcf1b0cff78acc9ce25c63a57e89ca739161f3c2e959f6ec35b4aa7", + "docs/widget-update-1.1.4.md": "e086a0cb5d7123b32aa472a9afa7c42c0c2c645224ccd67decacd93b535c3297", "downloads/GPU-Queue-Widget-1.1.2-macOS.zip": "77b1652f46675cf7f0b57616411df8d66124fbd2da499a270e1ad246c7902bff", "downloads/GPU-Queue-Widget-1.1.3-macOS.zip": "25d8a432737623a950a65115b1bb6b0f72bbfbbd86c783842a93a245a2ea4be1", + "downloads/GPU-Queue-Widget-1.1.4-macOS.zip": "7f1e6d089b2c087ebebb2ab68a2b796fbfdbee3a43ba97eedd2fe401e3a49b70", "downloads/GPU-Queue-Widget-macOS.zip": "25d8a432737623a950a65115b1bb6b0f72bbfbbd86c783842a93a245a2ea4be1", - "downloads/SHA256SUMS": "7d0dd4eca32d576acab9c20e1f7272506587be5cced5c231ae9ae12bf4395dd5", + "downloads/SHA256SUMS": "87234a735f5ad44d66d44bfbc9c72567be1fded0849b19b68a89e1689eab5ccd", "drizzle/0000_gpuq.sql": "0580c9c8e8c8bf6aece28a1d5911941828cd0c85cf10e83a11687f19d1475310", "drizzle/meta/_journal.json": "f5beaf051c66e5b4c8b52b45c804fb37cc01c8cfe572de8303e155d68c99f531", "examples/acceptance.py": "3e41c27096177b5b552fb1466ceb4d40c2ca9cfcc6509549c1e05c2ff80b991a", @@ -27,12 +29,12 @@ "launchers/macos/widget/Disable-Autostart.command": "66f7fa2765054707b037f7f0e9700d1bf74934bbaeaca72a40dec62dd64028fa", "launchers/macos/widget/Enable-Autostart.command": "27c3e63c0bbc27321f3cd5bdabfadf628476ce706bcd14f26d0598fb3649bd41", "launchers/macos/widget/Install.command": "13255aca0302fbd8b6eca7c0ef937e90181c8019c857d1e9d88f392ee78e7381", - "launchers/macos/widget/Updater.js": "e638d64d3079aaaadffd171727055f288290eae0486d96ca3d6cc84b7ff44aca", + "launchers/macos/widget/Updater.js": "673b45e7e89f8c0aacb7b93c8441c303f4d418883f59a3c43346f7b10aeb1e93", "launchers/macos/widget/Widget.js": "69c8d248615db0c764b85b58702e5259d59739cae36ec4e1cd1f30ded685f9e1", "launchers/macos/widget/model.js": "a47bd27263c05efae63d4c463009a615598853897d4be5ba9caa998437015ac5", "launchers/macos/widget/native.js": "f8c352126391697fdecd9ce21911856290a0b0030308d9a61cd8c85c569c2c68", - "launchers/macos/widget/release.json": "200658c6ebe387443088836031b79d69e9cbfea3a5f7971f448aefc3b3258760", - "launchers/macos/widget/update-model.js": "aeb688466bd70a93e1eb8e4a98968440fb2c52bea89065fd879fb1e122d42768", + "launchers/macos/widget/release.json": "e93eee0040e00742b9e4c6ce96f6a870308d2a7a02b6ed117d47fcb34ee9d656", + "launchers/macos/widget/update-model.js": "c34b6d943fbfb049465f85fe8b70223435b3e0988cde6e2c494fea3a113ed683", "package.json": "5366aaa1577e39588b077e7c3026a1dfca9303f3f07d629ae505acdf64b34bf6", "scripts/build.mjs": "d04fab925b83222807df50f6b820be8706cf354ed83e961d511af7a912fe5bc5", "scripts/package-launcher.py": "4141672b119cd3bbd82d6fce2a1cc7c005f994cc5034907da41eea8e0dc02bbe", @@ -54,7 +56,7 @@ "tests/test_stop.py": "39717a7e08eb2bf7c1e2b9b5416ed335bc71c0760210091fd3c2d70ffbf977cd", "tests/test_widget_autostart.py": "7819f157ca95931554a4aba9e4b0c195f99b10e21499b70e8eb9b0aa987bef40", "tests/test_widget_install.py": "bb0c80f1e2d8bb2fa685a51d8d5d9bebd05b4effc485e00d971618facda02b5c", - "tests/widget-update.test.cjs": "7a3a3f5c9e7dd76c0e54968f09bbe48f9cdbf4ad959d46b46eddb3b8e2d48cb5", + "tests/widget-update.test.cjs": "846ebaf0550e0899e5723f939fe9fb2965ac91030ab761f19be50a576bbe02a5", "tests/widget.test.cjs": "21dde14aa712b8f3c814354af8a1323e6c7f92af866169e78dd9b4095f501faf", "updates/gpu-queue-widget.json": "4ff3b46935ab67689c962cc22ceb3155960ff6de7572ff3435f15c481580a475", "web/app.js": "9401da1be8229a99dc311c9e83df76d760a8eb553ac87269e25d87e2c3903ad0", diff --git a/docs/widget-update-1.1.4.md b/docs/widget-update-1.1.4.md new file mode 100644 index 0000000..4a3f91b --- /dev/null +++ b/docs/widget-update-1.1.4.md @@ -0,0 +1,11 @@ +## 1.1.2 清单错误的修复 + +用户在旧更新器中遇到“安装包文件清单不符”。源码中此错误表示 `unzip -Z -1` 输出分行后的数量不等于 9。已核对 GitHub 当前更新入口为 1.1.3,本地的 1.1.2/1.1.3 发布 ZIP 均包含预期 9 个文件。尚未取得 Mac 上的原始清单输出,无法确定多出或缺少行的具体原因。 + +1.1.4 从已通过 SHA-256 和字节数校验的 NSData 读取 Base64,在共享 ES5 校验器中直接检查 ZIP 中央目录与各本地文件头。不再把 zipinfo 的展示文本作为安装清单。支持本项目打包器产生的 UTF-8、deflate、Unix 普通文件;拒绝 ZIP64、多卷、加密、数据描述符、重复/额外路径、符号链接、路径或大小冲突、重叠/隐藏条目和超限展开大小。后续仍由系统 ditto 解压。 + +旧的 1.1.2 更新器不会因下载新包就自动获得新的校验逻辑。请退出小组件和更新窗口,完整解压 1.1.4 包,双击“安装桌面小组件.command”覆盖一次。保留 SSH 配置、窗口位置和自启动偏好,不停止服务器任务。无需卸载原应用或删除配置。 + +修复包位于本分支 downloads/GPU-Queue-Widget-1.1.4-macOS.zip 及 issue 附件。保留正式更新入口与别名包为已发布的 1.1.3,不修改旧版不可变包。发布人员后续可按现有流程将新版本包、别名、SHA256SUMS 与生成的更新信息一并发布到 Ferryyer/Ferryyer;本次没有修改线上更新入口。 + +验证:28 项 Node 测试(包含 ZIP 实包及损坏结构检查),10 项安装/自启动 CPU 测试,JXA JavaScript 语法和安装脚本语法检查。新二进制校验器接受现有 1.1.2/1.1.3 及新 1.1.4 实包;错误路径、本地/中央记录冲突、重复条目、符号链接、截断和过大条目被拒绝。Linux 环境未执行 Mac AppKit 与真实安装验收。 diff --git a/downloads/GPU-Queue-Widget-1.1.4-macOS.zip b/downloads/GPU-Queue-Widget-1.1.4-macOS.zip new file mode 100644 index 0000000..928c50c Binary files /dev/null and b/downloads/GPU-Queue-Widget-1.1.4-macOS.zip differ diff --git a/downloads/SHA256SUMS b/downloads/SHA256SUMS index 03a52f2..ab2c1fd 100644 --- a/downloads/SHA256SUMS +++ b/downloads/SHA256SUMS @@ -1,3 +1,4 @@ 77b1652f46675cf7f0b57616411df8d66124fbd2da499a270e1ad246c7902bff GPU-Queue-Widget-1.1.2-macOS.zip 25d8a432737623a950a65115b1bb6b0f72bbfbbd86c783842a93a245a2ea4be1 GPU-Queue-Widget-1.1.3-macOS.zip 25d8a432737623a950a65115b1bb6b0f72bbfbbd86c783842a93a245a2ea4be1 GPU-Queue-Widget-macOS.zip +7f1e6d089b2c087ebebb2ab68a2b796fbfdbee3a43ba97eedd2fe401e3a49b70 GPU-Queue-Widget-1.1.4-macOS.zip diff --git a/launchers/macos/widget/Updater.js b/launchers/macos/widget/Updater.js index b3be4e9..fa85b6c 100644 --- a/launchers/macos/widget/Updater.js +++ b/launchers/macos/widget/Updater.js @@ -38,13 +38,6 @@ function command(path, args, done) { } else done(null, out); }); } -function zipListing(option, done) { - // Finder does not reliably provide a UTF-8 locale. Do not inherit options - // that could change the listing format used by the package validator. - command('/usr/bin/env', ['LC_ALL=en_US.UTF-8','LANG=en_US.UTF-8', - 'ZIPINFO=','ZIPINFOOPT=','UNZIP=','UNZIPOPT=', - '/usr/bin/unzip','-Z',option,archive], done); -} function curl(url, output, maximum, done) { var args = ['--fail','--silent','--show-error','--location','--proto','=https','--proto-redir','=https', '--connect-timeout','10','--max-time',output ? '150' : '20','--max-filesize',String(maximum)]; @@ -67,18 +60,15 @@ function operation(phase, release, done) { else done(null); }); } else if (phase === 'extracting') { - zipListing('-1',function (error,out) { + try { + var zip = $.NSData.dataWithContentsOfFile(archive); + if (zip.isNil()) throw new Error('无法读取安装包'); + GPUQUpdates.archiveBase64(text(zip.base64EncodedStringWithOptions(0))); + } catch (error) { done(error.message); return; } + command('/usr/bin/ditto',['-x','-k',archive,unpacked],function (error) { if (error) { done(error); return; } - try { GPUQUpdates.archiveEntries(out); } catch (e) { done(e.message); return; } - zipListing('-l',function (error,listing) { - if (error) { done(error); return; } - try { GPUQUpdates.archiveMetadata(listing); } catch (e) { done(e.message); return; } - command('/usr/bin/ditto',['-x','-k',archive,unpacked],function (error) { - if (error) { done(error); return; } - if (readFile(unpacked + '/GPU任务台桌面小组件/VERSION') !== release.version) done('安装包版本与更新信息不一致。'); - else done(null); - }); - }); + if (readFile(unpacked + '/GPU任务台桌面小组件/VERSION') !== release.version) done('安装包版本与更新信息不一致。'); + else done(null); }); } else if (phase === 'preparing') { command('/bin/bash',[unpacked + '/GPU任务台桌面小组件/安装桌面小组件.command','--update',workDir],done); diff --git a/launchers/macos/widget/release.json b/launchers/macos/widget/release.json index 451d381..b6102c1 100644 --- a/launchers/macos/widget/release.json +++ b/launchers/macos/widget/release.json @@ -1,8 +1,8 @@ { - "version": "1.1.3", + "version": "1.1.4", "notes": [ - "改善桌面小组件字体可读性,连接状态与辅助说明使用更清晰的字号。", - "固定暖色窗口与原生控件为一致的浅色外观,避免系统深色模式下显示不协调;更新说明使用明确的文字与背景色。", - "保留 SSH 配置、模式和自动启动偏好;网页排版修复需同步更新服务器源码。" + "修复更新时“安装包文件清单不符”:直接校验 ZIP 中央目录及本地文件头,不再解析 macOS unzip 的文本输出。", + "继续校验下载大小和 SHA-256,严格限制文件路径、重复项、普通文件类型及展开大小。", + "1.1.2 更新器遇到此错误时,请完整解压本版并双击安装器覆盖安装,保留已有 SSH 配置和偏好。" ] } diff --git a/launchers/macos/widget/update-model.js b/launchers/macos/widget/update-model.js index 52da776..113c2f8 100644 --- a/launchers/macos/widget/update-model.js +++ b/launchers/macos/widget/update-model.js @@ -52,6 +52,73 @@ var GPUQUpdates = (function () { }); if (total > 16 * 1024 * 1024) throw new Error('安装包展开后过大'); } + // Validate our release ZIP directly. macOS zipinfo's display output is not + // a machine-readable archive manifest (locale/build/options can affect it). + // Our packager writes UTF-8 regular files, deflate, no descriptors or ZIP64. + function archiveBytes(bytes) { + function fail() { throw new Error('安装包 ZIP 结构或文件清单无效'); } + function u16(at) { + if (at < 0 || at + 2 > bytes.length) fail(); + return bytes[at] + bytes[at + 1] * 256; + } + function u32(at) { return u16(at) + u16(at + 2) * 65536; } + function name(at, length) { + if (at < 0 || at + length > bytes.length || length > 1024) fail(); + var escaped = ''; + for (var i = at; i < at + length; i++) escaped += '%' + ('0' + bytes[i].toString(16)).slice(-2); + try { return decodeURIComponent(escaped); } catch (error) { fail(); } + } + if (bytes.length < 22 || bytes.length > 20 * 1024 * 1024) fail(); + var end = bytes.length - 22; + if (u32(end) !== 0x06054b50 || u16(end + 4) || u16(end + 6) || + u16(end + 8) !== files.length || u16(end + 10) !== files.length || u16(end + 20)) fail(); + var centralSize = u32(end + 12), central = u32(end + 16); + if (central + centralSize !== end) fail(); + var cursor = central, total = 0, paths = [], ranges = []; + for (var index = 0; index < files.length; index++) { + if (cursor + 46 > end || u32(cursor) !== 0x02014b50) fail(); + var flags = u16(cursor + 8), method = u16(cursor + 10); + var compressed = u32(cursor + 20), size = u32(cursor + 24); + var length = u16(cursor + 28), extra = u16(cursor + 30), comment = u16(cursor + 32); + var offset = u32(cursor + 42), mode = Math.floor(u32(cursor + 38) / 65536); + if (bytes[cursor + 5] !== 3 || flags !== 0x800 || method !== 8 || + u16(cursor + 34) || (mode & 0xf000) !== 0x8000 || size > 4 * 1024 * 1024 || + cursor + 46 + length + extra + comment > end) fail(); + total += size; + if (total > 16 * 1024 * 1024) fail(); + var path = name(cursor + 46, length); + if (offset + 30 > central || u32(offset) !== 0x04034b50 || + u16(offset + 6) !== flags || u16(offset + 8) !== method || + u32(offset + 14) !== u32(cursor + 16) || + u32(offset + 18) !== compressed || u32(offset + 22) !== size || + u16(offset + 26) !== length || name(offset + 30, length) !== path) fail(); + var dataEnd = offset + 30 + length + u16(offset + 28) + compressed; + if (dataEnd > central) fail(); + paths.push(path); ranges.push([offset, dataEnd]); + cursor += 46 + length + extra + comment; + } + if (cursor !== end) fail(); + // No hidden/orphan local entries, prepended payloads or overlapping data. + ranges.sort(function (a, b) { return a[0] - b[0]; }); + var next = 0; + ranges.forEach(function (range) { if (range[0] !== next) fail(); next = range[1]; }); + if (next !== central) fail(); + archiveEntries(paths.join('\n')); + } + function archiveBase64(value) { + var alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', bytes = []; + if (typeof value !== 'string' || value.length > 28 * 1024 * 1024 || + value.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) throw new Error('安装包数据无效'); + for (var i = 0; i < value.length; i += 4) { + var a = alphabet.indexOf(value.charAt(i)), b = alphabet.indexOf(value.charAt(i + 1)); + var c = alphabet.indexOf(value.charAt(i + 2)), d = alphabet.indexOf(value.charAt(i + 3)); + if (a < 0 || b < 0) throw new Error('安装包数据无效'); + bytes.push(a * 4 + Math.floor(b / 16)); + if (c >= 0) bytes.push((b % 16) * 16 + Math.floor(c / 4)); + if (d >= 0) bytes.push((c % 4) * 64 + d); + } + archiveBytes(bytes); + } function Controller(io, current) { version(current); this.io = io; this.current = current; this.phase = 'idle'; this.release = null; this.note = ''; this.epoch = 0; this.deadline = 0; @@ -113,6 +180,6 @@ var GPUQUpdates = (function () { } if (this.busy() && this.phase !== 'checking' && this.io.now() > this.deadline) this.fail('更新超时,请重新打开小组件后重试。'); }; - return {feed:feed, version:version, compare:compare, manifest:manifest, archiveEntries:archiveEntries, archiveMetadata:archiveMetadata, Controller:Controller}; + return {feed:feed, version:version, compare:compare, manifest:manifest, archiveEntries:archiveEntries, archiveMetadata:archiveMetadata, archiveBytes:archiveBytes, archiveBase64:archiveBase64, Controller:Controller}; }()); if (typeof module !== 'undefined') module.exports = GPUQUpdates; diff --git a/tests/widget-update.test.cjs b/tests/widget-update.test.cjs index 4962351..e35e9b7 100644 --- a/tests/widget-update.test.cjs +++ b/tests/widget-update.test.cjs @@ -88,3 +88,29 @@ test('the actual release ZIP has only allowed paths, regular files and bounded s assert.throws(()=>U.archiveMetadata(metadata.replace(/(\s+unx\s+)\d+/,'$1'+'99999999'))); } finally {fs.rmSync(temp,{recursive:true,force:true});} }); + +test('binary ZIP validation is independent of zipinfo text and rejects conflicting local headers',()=>{ + const temp=fs.mkdtempSync(path.join(os.tmpdir(),'gpuq-zip-')); + try { + cp.execFileSync('python3',[path.join(__dirname,'../scripts/package-widget.py'),'--output-dir',temp]); + const bytes=fs.readFileSync(path.join(temp,'GPU任务台-桌面小组件-macOS.zip')); + U.archiveBytes(bytes);U.archiveBase64(bytes.toString('base64')); + // Validate the immutable versions users are trying to update from/to as well. + for(const v of ['1.1.2','1.1.3'])U.archiveBytes(fs.readFileSync(path.join(__dirname,'../downloads/GPU-Queue-Widget-'+v+'-macOS.zip'))); + const end=bytes.length-22,central=bytes.readUInt32LE(end+16); + function corrupt(offset,value,size=4){const copy=Buffer.from(bytes);if(size===2)copy.writeUInt16LE(value,offset);else copy.writeUInt32LE(value,offset);return copy;} + for(const bad of [bytes.subarray(0,-1),Buffer.concat([bytes,Buffer.from([0])]), + corrupt(end+10,10,2),corrupt(end+16,0),corrupt(central+42,1), + corrupt(central+38,0xa1ff0000),corrupt(central+24,99999999), + corrupt(6,0x801,2),corrupt(18,0),corrupt(30,0x2e2e2f2f)])assert.throws(()=>U.archiveBytes(bad)); + // The second/third (enable/disable) file names have equal byte lengths. + const duplicate=Buffer.from(bytes),headers=[];let cursor=central; + for(let i=0;i<9;i++){headers.push(cursor);cursor+=46+bytes.readUInt16LE(cursor+28)+bytes.readUInt16LE(cursor+30)+bytes.readUInt16LE(cursor+32);} + const source=headers[1],target=headers[2],length=bytes.readUInt16LE(source+28),local=bytes.readUInt32LE(target+42); + assert.equal(length,bytes.readUInt16LE(target+28)); + bytes.copy(duplicate,target+46,source+46,source+46+length); + bytes.copy(duplicate,local+30,source+46,source+46+length); + assert.throws(()=>U.archiveBytes(duplicate)); + for(const value of ['!', 'AAA', '=AAA', 'AAAA===='])assert.throws(()=>U.archiveBase64(value)); + } finally {fs.rmSync(temp,{recursive:true,force:true});} +});