diff --git a/README.md b/README.md index ccd7d2a..693964b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,42 @@ # DSH Runtime Release +## runtime-7.7:预览下载插件 + +内置独立插件 dsh-preview-download@1.0.0,在文档预览工具栏提供「下载原文件」。使用正式 sidebar.right.tab.document.actions 插槽,不修改上游 UI、不扫描 DOM、不增加服务端下载接口。通过当前会话 workspaceFiles.readBytes 获取原始字节,沿用服务端权限和完整文件大小上限;支持中文文件名、下载中状态、失败提示及切换文件/卸载时取消读取。下载的是原始文件,不是 Office 转换出的 PDF。文件元数据无法读取时不会显示工具栏按钮。 + +新建空 profile 默认启用。已有实例升级镜像后,在 /workspace/profiles/web/package.json 的 dependencies 中添加以下条目,并在 dsh.profile.bundles 中追加一次 dsh-preview-download,保留其他配置,然后重启: + +~~~json +"dsh-preview-download": "file:/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.0.tgz" +~~~ + +插件源码位于 image/preview-download,也可单独打包安装。无第三方运行时依赖,React 由 DSH 浏览器提供。当前仅针对 DSH 0.2.0-rc.2 验证。25 项离线回归测试通过,已在现有 AMD64 镜像中验证插件打包、新 profile 离线安装及服务启动;完整 runtime-7.7 构建在 MiSans 下载阶段超时,尚未完成浏览器实际点击下载、移动端和 ARM64 验收。不应把单元测试中的拒绝响应模拟当作真实权限端到端验收。 + +## runtime-7.6:原生 Office 文档预览 + +Linux 文档预览通过构建期补丁将 Office-to-PDF 转换入口接到系统 LibreOffice,不再使用该入口原先默认的 WASM 后端。补丁限定 DSH 和 dsh-office-to-pdf 为 0.2.0-rc.2,目标 import 不匹配或重复应用时构建失败。保留上游授权、队列、缓存和 PDF 验证;不修改独立 dsoffice CLI 或其他插件。 + +每次转换使用私有临时目录、独立 profile、最高宏安全级别和独立进程组;保留默认 60 秒超时,取消时终止进程组。检查输入大小、OOXML 条目数、解压总量和 CRC、输出大小及完整性。子进程采用最小环境变量;校验器地址空间上限 512 MiB,LibreOffice 为 2 GiB,单文件写入上限为 max(maxOutputBytes, 16 MiB),最终 PDF 仍严格检查 maxOutputBytes。进程资源限制不是进程树总量或临时目录总量限制,部署时仍须容器内存、CPU、PID 和临时磁盘限制。 + +安全边界:原生 LibreOffice 与 DSH 使用同一 UID,独立 profile 不是文件系统或网络沙箱;不应将此功能视为隔离恶意文档的服务。容器内敏感文件及网络访问仍需部署侧隔离,尤其是处理不可信外部文档时。 + +字体由系统解析;显式 fontDirectories/fontFallbacks 配置会被拒绝,应移除并改用系统字体配置。kit 的 maxFontFiles/maxFontFileBytes/maxLoadedFontBytes 不再约束系统字体。missingFonts 为空仅表示不提供缺失字体诊断,不代表没有字体替换。 + +已完成 AMD64 构建、22 项回归测试,以及非 root/禁网容器中 DOCX/PPTX/DOC/PPT 并发转换、已安装 DSH convertBytes 集成、输出/压缩包限制、超时及清理验证。小型单页样本约 3 秒/文件,不代表实际文档性能保证。ARM64、真实失败样本、浏览器端到端、XLS/XLSX 和视觉版式尚未验收。 + +构建后在仓库根目录运行: + +~~~sh +docker run --rm --network none --init \ + --mount type=bind,src="$PWD/image",dst=/test,readonly \ + --entrypoint node dsh-web:runtime-7.6-amd64 /test/office-preview-smoke.mjs +~~~ + +现有实例需升级新镜像才生效,不需要清空工作区。不自动提交、推送或升级线上实例。 + ## runtime-7.5:精简 MiSans 字重 -本次待发布修订为 runtime-7.5,仅保留 MiSans Regular 和 Bold,从第三方 GitHub 仓库 boyan01/mi_sans_font 的固定提交下载独立 TTF 并逐个校验 SHA-256,合计约 15.73 MB,不再下载官方完整 ZIP。构建环境需要能够访问 raw.githubusercontent.com。 +runtime-7.5 修订仅保留 MiSans Regular 和 Bold,从第三方 GitHub 仓库 boyan01/mi_sans_font 的固定提交下载独立 TTF 并逐个校验 SHA-256,合计约 15.73 MB,不再下载官方完整 ZIP。构建环境需要能够访问 raw.githubusercontent.com。 ## runtime-7.4:文档工具、中文字体与 Chat Manager 修复 @@ -36,7 +70,7 @@ Runtime 镜像包含 Debian Bookworm 的 Python 3、pip、venv(`python` 指向 ## 发布清单 -`release.json` 是发布入口:`schema_version` 为清单格式版本,`release_id` 标识一次发布,`version` 为精确 DSH 版本,`deployment_schema` 为运行布局版本,`platforms` 为支持的平台。当前待发布配置为 `runtime-7.5`、DSH `0.2.0-rc.2`、布局 7、`linux/amd64` 和 `linux/arm64`(aarch64,不含 32 位 ARM)。 +`release.json` 是发布入口:`schema_version` 为清单格式版本,`release_id` 标识一次发布,`version` 为精确 DSH 版本,`deployment_schema` 为运行布局版本,`platforms` 为支持的平台。当前待发布配置为 `runtime-7.7`、DSH `0.2.0-rc.2`、布局 7、`linux/amd64` 和 `linux/arm64`(aarch64,不含 32 位 ARM)。 ARM64 验收状态:已确认 Node/Caddy 基础镜像提供 ARM64 版本,但尚未完成 ARM64 runtime 构建及运行验收。清单声明会开放面板的对应平台构建入口,不等于兼容性证明;验收完成前不要推送发布。 diff --git a/image/Dockerfile b/image/Dockerfile index 0298b60..29fe0a4 100644 --- a/image/Dockerfile +++ b/image/Dockerfile @@ -32,6 +32,11 @@ RUN chmod 755 /usr/local/bin/dsh-entrypoint && mkdir -p /data /workspace /shared COPY install-misans.mjs /usr/local/lib/dsh-mgr/install-misans.mjs RUN node /usr/local/lib/dsh-mgr/install-misans.mjs COPY MiSans-NOTICE.txt /usr/local/share/fonts/misans/NOTICE.txt +COPY install-office-preview.mjs native-office.mjs validate-office.py office-worker.py /usr/local/lib/dsh-mgr/ +RUN node /usr/local/lib/dsh-mgr/install-office-preview.mjs +COPY preview-download /usr/local/lib/dsh-mgr/preview-download +COPY install-preview-download.mjs /usr/local/lib/dsh-mgr/install-preview-download.mjs +RUN node /usr/local/lib/dsh-mgr/install-preview-download.mjs ARG RECIPE_REV=7 LABEL dsh-mgr.role=runtime dsh-mgr.version=$DSH_VERSION dsh-mgr.recipe=$RECIPE_REV ENV DSH_HOME=/workspace HOME=/workspace XDG_DATA_HOME=/workspace/.local/share XDG_CONFIG_HOME=/workspace/.config diff --git a/image/entrypoint.sh b/image/entrypoint.sh index f2dbe72..968c5f5 100644 --- a/image/entrypoint.sh +++ b/image/entrypoint.sh @@ -8,7 +8,7 @@ if [[ -f "${DSH_HOME}/profiles/web/package.json" ]]; then # pnpm otherwise probes registry metadata for the overlapping optional peer. # Restrict offline mode to profiles containing only our fixed archives. install_env=() - if node -e 'const m=require(process.env.DSH_HOME+"/profiles/web/package.json"); const pins={"dsh-chat-manager":"file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.12-dsh-mgr.1.tgz","dsh-mgr-workspace":"file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz","dsh-web-mobile":"file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz","dsh-free-search":"file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz"}; process.exit(Object.entries({...m.dependencies,...m.devDependencies,...m.optionalDependencies}).every(([n,v])=>pins[n]===v)?0:1)'; then + if node -e 'const m=require(process.env.DSH_HOME+"/profiles/web/package.json"); const pins={"dsh-preview-download":"file:/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.0.tgz","dsh-chat-manager":"file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.12-dsh-mgr.1.tgz","dsh-mgr-workspace":"file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz","dsh-web-mobile":"file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz","dsh-free-search":"file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz"}; process.exit(Object.entries({...m.dependencies,...m.devDependencies,...m.optionalDependencies}).every(([n,v])=>pins[n]===v)?0:1)'; then install_env=(npm_config_offline=true) fi if timeout --signal=TERM --kill-after=5s 120s env CI=true "${install_env[@]}" dsh plugin --profile web install --ignore-scripts; then diff --git a/image/init-profile.mjs b/image/init-profile.mjs index 768956f..e3ab31d 100644 --- a/image/init-profile.mjs +++ b/image/init-profile.mjs @@ -14,14 +14,14 @@ if (!existsSync(profile) || readdirSync(profile).length === 0) { mkdirSync(profile, { recursive: true }); writeFileSync(join(profile, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', private: true, - dependencies: { 'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.12-dsh-mgr.1.tgz', 'dsh-mgr-workspace': 'file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz', 'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz', 'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz' }, + dependencies: { 'dsh-preview-download': 'file:/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.0.tgz', 'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.12-dsh-mgr.1.tgz', 'dsh-mgr-workspace': 'file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz', 'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz', 'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz' }, pnpm: { overrides: { 'dsh-chat-manager>clsx': 'file:/usr/local/lib/dsh-mgr/clsx-2.1.1.tgz', 'dsh-free-search>@deepseek-ai/schemastery': 'file:/usr/local/lib/dsh-mgr/schemastery-3.18.4.tgz', '@deepseek-ai/schemastery>@deepseek-ai/cosmokit': 'file:/usr/local/lib/dsh-mgr/cosmokit-1.8.5.tgz', '@deepseek-ai/schemastery>@standard-schema/spec': 'file:/usr/local/lib/dsh-mgr/spec-1.1.0.tgz', } }, - dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'dsh-web-mobile', 'dsh-free-search', 'dsh-mgr-workspace', 'dsh-chat-manager'] } }, + dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'dsh-web-mobile', 'dsh-free-search', 'dsh-mgr-workspace', 'dsh-chat-manager', 'dsh-preview-download'] } }, }, null, 2), { flag: 'wx', mode: 0o600 }); console.log('dsh-mgr: new web profile includes dsh-web-mobile@3.0.5 and dsh-free-search@0.8.5 and dsh-chat-manager@1.5.12-dsh-mgr.1'); } diff --git a/image/init-profile.test.mjs b/image/init-profile.test.mjs index 2dcd62c..7b28d70 100644 --- a/image/init-profile.test.mjs +++ b/image/init-profile.test.mjs @@ -11,6 +11,8 @@ test('new profile enables bundle once and preserves existing configuration', () run(); const file=join(home,'profiles/web/package.json'); const original=readFileSync(file,'utf8'); + assert.equal(JSON.parse(original).dependencies['dsh-preview-download'], 'file:/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.0.tgz'); + assert.equal(JSON.parse(original).dsh.profile.bundles.filter(name => name === 'dsh-preview-download').length, 1); assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-chat-manager')); assert.equal(JSON.parse(original).dependencies['dsh-chat-manager'], 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.12-dsh-mgr.1.tgz'); assert.equal(JSON.parse(original).pnpm.overrides['dsh-chat-manager>clsx'], 'file:/usr/local/lib/dsh-mgr/clsx-2.1.1.tgz'); diff --git a/image/install-office-preview.mjs b/image/install-office-preview.mjs new file mode 100644 index 0000000..ca84ca1 --- /dev/null +++ b/image/install-office-preview.mjs @@ -0,0 +1,21 @@ +import { readFileSync, writeFileSync, copyFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const target = 'import { createConverter } from "@deepseek-ai/libreoffice-kit";'; +export function patchOffice(source, hostVersion, packageVersion) { + if (hostVersion !== '0.2.0-rc.2' || packageVersion !== '0.2.0-rc.2') throw new Error('unsupported Office preview version'); + if (source.split(target).length !== 2) throw new Error('expected exactly one original converter import'); + return source.replace(target, 'import { createConverter } from "./dsh-native-office.mjs";'); +} +export function install(root = '/usr/local/lib/node_modules/@deepseek-ai/dsh') { + const dir = join(root, 'node_modules/@deepseek-ai/dsh-office-to-pdf'); + const version = path => JSON.parse(readFileSync(join(path, 'package.json'), 'utf8')).version; + const file = join(dir, 'lib/index.js'); + const patched = patchOffice(readFileSync(file, 'utf8'), version(root), version(dir)); + for (const [source, name] of [['native-office.mjs', 'dsh-native-office.mjs'], ['validate-office.py', 'validate-office.py'], ['office-worker.py', 'office-worker.py']]) { + copyFileSync(new URL(source, import.meta.url), join(dir, 'lib', name)); + } + writeFileSync(file, patched); +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) install(); diff --git a/image/install-preview-download.mjs b/image/install-preview-download.mjs new file mode 100644 index 0000000..2315981 --- /dev/null +++ b/image/install-preview-download.mjs @@ -0,0 +1,8 @@ +import { cpSync, mkdirSync, readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +const root = '/usr/local/lib/dsh-mgr'; +const host = JSON.parse(readFileSync('/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json')); +if (host.version !== '0.2.0-rc.2') throw new Error('Unverified preview download host'); +mkdirSync(root + '/preview-download-package', { recursive: true }); +cpSync(new URL('./preview-download', import.meta.url), root + '/preview-download-package/package', { recursive: true }); +execFileSync('tar', ['-czf', root + '/dsh-preview-download-1.0.0.tgz', '-C', root + '/preview-download-package', 'package']); diff --git a/image/native-office.mjs b/image/native-office.mjs new file mode 100644 index 0000000..30acbab --- /dev/null +++ b/image/native-office.mjs @@ -0,0 +1,119 @@ +import { spawn } from 'node:child_process'; +import { constants } from 'node:fs'; +import { open, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, extname, isAbsolute } from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; + +const failure = (code, message) => Object.assign(new Error(message), { code }); + +// No shell; terminate the whole group, including soffice.bin, on cancellation. +export function runProcess(executable, args, signal, env = process.env) { + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const child = spawn(executable, args, { detached: true, stdio: 'ignore', env }); + let error; + const kill = () => { + if (child.pid) { + try { process.kill(-child.pid, 'SIGKILL'); } + catch (cause) { if (cause.code !== 'ESRCH') error ??= cause; } + } + }; + const abort = () => { error = signal.reason; kill(); }; + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + child.once('error', cause => { error ??= failure('unavailable', 'Cannot start helper: ' + cause.code); }); + child.once('close', code => { + signal.removeEventListener('abort', abort); + kill(); + if (error) reject(error); + else if (code !== 0) reject(failure('invalid-document', 'Conversion helper exit: ' + code)); + else resolve(); + }); + }); +} + +async function readBounded(path, maximum, kind, signal) { + const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const stat = await file.stat(); + if (!stat.isFile()) throw failure('invalid-' + (kind === 'input' ? 'document' : 'output'), 'Regular file required'); + if (stat.size > maximum) throw failure(kind + '-too-large', 'File exceeds limit'); + const bytes = Buffer.alloc(stat.size + 1); + let length = 0; + while (length < bytes.length) { + signal.throwIfAborted(); + const read = await file.read(bytes, length, bytes.length - length, null); + if (!read.bytesRead) break; + length += read.bytesRead; + } + if (length !== stat.size) throw failure('invalid-' + (kind === 'input' ? 'document' : 'output'), 'File changed'); + return bytes.subarray(0, length); + } finally { await file.close(); } +} + +export async function createConverter(options = {}) { + if (process.platform !== 'linux') throw failure('unavailable', 'Native preview requires Linux'); + if (options.fontDirectories !== undefined || options.fontFallbacks !== undefined) { + throw failure('unavailable', 'Native preview uses system fonts; remove kit-only font overrides'); + } + const lifetime = new AbortController(); + const active = new Set(); + const limits = { timeoutMs: 60000, maxInputBytes: 50 * 1024 ** 2, maxOutputBytes: 100 * 1024 ** 2, maxArchiveEntries: 10000, maxUncompressedBytes: 250 * 1024 ** 2, maxImageResolution: 192, ...options }; + for (const key of ['timeoutMs', 'maxInputBytes', 'maxOutputBytes', 'maxArchiveEntries', 'maxUncompressedBytes', 'maxImageResolution']) { + if (!Number.isSafeInteger(limits[key]) || limits[key] < 1) throw new TypeError('Invalid ' + key); + } + async function render({ inputPath, outputPath }, external = new AbortController().signal) { + const deadline = new AbortController(); + const signal = AbortSignal.any([external, lifetime.signal, deadline.signal]); + const timer = setTimeout(() => deadline.abort(failure('timeout', 'Native Office conversion timed out')), limits.timeoutMs); + let scratch; + let output; + let success = false; + try { + signal.throwIfAborted(); + const extension = extname(inputPath).slice(1).toLowerCase(); + if (!['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(extension)) throw failure('unsupported-format', 'Unsupported Office extension'); + if (!isAbsolute(inputPath) || !isAbsolute(outputPath)) throw failure('invalid-document', 'Absolute paths required'); + output = await open(outputPath, 'wx', 0o600); + scratch = await mkdtemp(join(tmpdir(), 'dsh-native-office-')); + const source = join(scratch, 'source.' + extension); + await writeFile(source, await readBounded(inputPath, limits.maxInputBytes, 'input', signal), { flag: 'wx', mode: 0o600, signal }); + const launcher = fileURLToPath(new URL('./office-worker.py', import.meta.url)); + const env = { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8', HOME: scratch, TMPDIR: scratch, SAL_USE_VCLPLUGIN: 'svp', SAL_DISABLE_PRINTERLIST: '1', SAL_DISABLE_DEFAULTPRINTER: '1' }; + await runProcess('/usr/bin/python3', ['-I', launcher, String(512 * 1024 ** 2), String(limits.maxOutputBytes), '/usr/bin/python3', '-I', fileURLToPath(new URL('./validate-office.py', import.meta.url)), source, extension, String(limits.maxArchiveEntries), String(limits.maxUncompressedBytes)], signal, env); + const profile = join(scratch, 'profile'); + await mkdir(join(profile, 'user'), { recursive: true, mode: 0o700 }); + await writeFile(join(profile, 'user/registrymodifications.xcu'), '3'); + const filter = extension.startsWith('ppt') ? 'impress_pdf_Export' : extension.startsWith('xls') ? 'calc_pdf_Export' : 'writer_pdf_Export'; + const pdfOptions = JSON.stringify({ ReduceImageResolution: { type: 'boolean', value: 'true' }, MaxImageResolution: { type: 'long', value: String(limits.maxImageResolution) } }); + await runProcess('/usr/bin/python3', ['-I', launcher, String(2 * 1024 ** 3), String(Math.max(limits.maxOutputBytes, 16 * 1024 ** 2)), '/usr/bin/libreoffice', '-env:UserInstallation=' + pathToFileURL(profile).href, '--headless', '--nologo', '--nodefault', '--nofirststartwizard', '--convert-to', 'pdf:' + filter + ':' + pdfOptions, '--outdir', scratch, source], signal, env); + let bytes; + try { bytes = await readBounded(join(scratch, 'source.pdf'), limits.maxOutputBytes, 'output', signal); } + catch (cause) { if (cause.code === 'ENOENT') throw failure('invalid-output', 'LibreOffice produced no PDF'); throw cause; } + if (!/^%PDF-[0-9][.][0-9]/.test(bytes.subarray(0, 8).toString()) || !bytes.subarray(-1024).toString().trimEnd().endsWith('%%EOF')) throw failure('invalid-output', 'Incomplete PDF'); + signal.throwIfAborted(); + await output.writeFile(bytes); + signal.throwIfAborted(); + success = true; + // System LibreOffice has no kit missing-font diagnostic API. + return { backend: 'native-system', missingFonts: [] }; + } finally { + clearTimeout(timer); + try { await output?.close(); } + finally { + try { if (output && !success) await rm(outputPath, { force: true }); } + finally { if (scratch) await rm(scratch, { recursive: true, force: true }); } + } + } + } + return { + render(request, signal) { + const task = render(request, signal); + active.add(task); + task.then(() => active.delete(task), () => active.delete(task)); + return task; + }, + async dispose() { lifetime.abort(failure('unavailable', 'Converter disposed')); await Promise.allSettled([...active]); } + }; +} diff --git a/image/native-office.test.mjs b/image/native-office.test.mjs new file mode 100644 index 0000000..0cf2007 --- /dev/null +++ b/image/native-office.test.mjs @@ -0,0 +1,50 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, readFile, rm, access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createConverter, runProcess } from './native-office.mjs'; +import { patchOffice, target } from './install-office-preview.mjs'; + +test('patch is exact, version gated and refuses repeat application', () => { + const patched = patchOffice(target, '0.2.0-rc.2', '0.2.0-rc.2'); + assert.match(patched, /dsh-native-office/); + for (const source of ['', target + target, patched]) assert.throws(() => patchOffice(source, '0.2.0-rc.2', '0.2.0-rc.2')); + assert.throws(() => patchOffice(target, 'next', '0.2.0-rc.2')); + assert.throws(() => patchOffice(target, '0.2.0-rc.2', 'next')); +}); + +test('process cancellation, missing binary and failure are classified', async () => { + const signal = new AbortController().signal; + await assert.rejects(runProcess('/not-an-office-binary', [], signal), { code: 'unavailable' }); + await assert.rejects(runProcess(process.execPath, ['-e', 'process.exit(2)'], signal), { code: 'invalid-document' }); + const controller = new AbortController(); + const reason = new Error('cancel test'); + const pending = runProcess(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], controller.signal); + controller.abort(reason); + await assert.rejects(pending, error => error === reason); +}); + +test('input bounds, corruption, exclusive output, abort and disposal', async () => { + const directory = await mkdtemp(join(tmpdir(), 'office-test-')); + const inputPath = join(directory, 'source.docx'); + const outputPath = join(directory, 'output.pdf'); + const converter = await createConverter(); + try { + await writeFile(inputPath, 'invalid'); + await assert.rejects(converter.render({ inputPath, outputPath }), { code: 'invalid-document' }); + await assert.rejects(access(outputPath)); + const small = await createConverter({ maxInputBytes: 1 }); + await assert.rejects(small.render({ inputPath, outputPath }), { code: 'input-too-large' }); + await small.dispose(); + await writeFile(outputPath, 'preserve'); + await assert.rejects(converter.render({ inputPath, outputPath }), { code: 'EEXIST' }); + assert.equal(await readFile(outputPath, 'utf8'), 'preserve'); + const controller = new AbortController(); + controller.abort(new Error('cancelled')); + await assert.rejects(converter.render({ inputPath, outputPath }, controller.signal), /cancelled/); + await converter.dispose(); + await assert.rejects(converter.render({ inputPath, outputPath }), { code: 'unavailable' }); + await assert.rejects(createConverter({ fontDirectories: [] }), { code: 'unavailable' }); + } finally { await converter.dispose(); await rm(directory, { recursive: true, force: true }); } +}); diff --git a/image/office-preview-smoke.mjs b/image/office-preview-smoke.mjs new file mode 100644 index 0000000..256c1fd --- /dev/null +++ b/image/office-preview-smoke.mjs @@ -0,0 +1,47 @@ +// Run inside the built image as UID 1000. No downloads or fixture dependencies. +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, readFile, rm, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { pathToFileURL } from 'node:url'; +import { runProcess } from './native-office.mjs'; +const installed = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-office-to-pdf/lib/'; +const { createConverter } = await import(installed + 'dsh-native-office.mjs'); +const { OfficeToPdf } = await import(installed + 'index.js'); + +assert.notEqual(process.getuid(), 0, 'smoke test must run as non-root'); +const dir = await mkdtemp(join(tmpdir(), 'office-smoke-')); +const signal = new AbortController().signal; +const ns = 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"'; +const converter = await createConverter(); +try { + await writeFile(join(dir, 'word.fodt'), '中文预览 Word conversion test'); + await writeFile(join(dir, 'slides.fodp'), '中文预览 PPT conversion test'); + for (const [source, format] of [['word.fodt', 'docx'], ['slides.fodp', 'pptx'], ['word.fodt', 'doc'], ['slides.fodp', 'ppt']]) { + await runProcess('/usr/bin/libreoffice', ['-env:UserInstallation=' + pathToFileURL(join(dir, 'fixture-profile')).href, '--headless', '--convert-to', format, '--outdir', dir, join(dir, source)], signal); + } + await Promise.all(['word.docx', 'slides.pptx', 'word.doc', 'slides.ppt'].map(async name => { + const start = performance.now(); + const outputPath = join(dir, name + '.pdf'); + const result = await converter.render({ inputPath: join(dir, name), outputPath }); + assert.equal(result.backend, 'native-system'); + assert.match((await readFile(outputPath)).subarray(0, 8).toString(), /^%PDF-/); + console.log(name + ': native PDF in ' + Math.round(performance.now() - start) + 'ms'); + })); + const small = await createConverter({ maxOutputBytes: 1 }); + await assert.rejects(small.render({ inputPath: join(dir, 'word.docx'), outputPath: join(dir, 'small.pdf') }), { code: 'output-too-large' }); + await small.dispose(); + const timeout = await createConverter({ timeoutMs: 1 }); + await assert.rejects(timeout.render({ inputPath: join(dir, 'word.docx'), outputPath: join(dir, 'timeout.pdf') }), { code: 'timeout' }); + await timeout.dispose(); + const limited = await createConverter({ maxArchiveEntries: 1 }); + await assert.rejects(limited.render({ inputPath: join(dir, 'word.docx'), outputPath: join(dir, 'limit.pdf') }), { code: 'invalid-document' }); + await limited.dispose(); + const provider = { slots: [], options: {}, config: { maxOutputBytes: 100 * 1024 ** 2 } }; + const converted = await OfficeToPdf.prototype.convertBytes.call(provider, await readFile(join(dir, 'slides.pptx')), 'pptx', signal); + assert.match(Buffer.from(converted.pdf).subarray(0, 8).toString(), /^%PDF-/); + await Promise.all(provider.slots.map(async slot => (await slot.converter).dispose())); + console.log('installed DSH convertBytes integration passed'); + assert.equal((await readdir(tmpdir())).filter(name => name.startsWith('dsh-native-office-')).length, 0); + console.log('concurrent DOCX/PPTX/DOC/PPT, output/ZIP limits, timeout and cleanup passed'); +} finally { await converter.dispose(); await rm(dir, { recursive: true, force: true }); } diff --git a/image/office-worker.py b/image/office-worker.py new file mode 100644 index 0000000..fdacce4 --- /dev/null +++ b/image/office-worker.py @@ -0,0 +1,10 @@ +"""Apply process limits before executing a trusted conversion helper.""" +import os +import resource +import sys + +memory, file_bytes, *command = sys.argv[1:] +resource.setrlimit(resource.RLIMIT_AS, (int(memory), int(memory))) +resource.setrlimit(resource.RLIMIT_FSIZE, (int(file_bytes), int(file_bytes))) +resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) +os.execv(command[0], command) diff --git a/image/preview-download.test.mjs b/image/preview-download.test.mjs new file mode 100644 index 0000000..9b57c49 --- /dev/null +++ b/image/preview-download.test.mjs @@ -0,0 +1,47 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; + +function load() { + let plugin; + runInNewContext(readFileSync(new URL('./preview-download/client.js', import.meta.url), 'utf8'), { + Uint8Array, window: { __ModuleLoader__: { load({ id, factory }) { assert.equal(id, 'dsh-preview-download'); plugin = factory(() => ({})); } } } + }); + return plugin; +} +test('download passes exact session/path/signal to authorized complete-file RPC', async () => { + const plugin = load(); + const data = new Uint8Array([0, 255, 10]); + const signal = new AbortController().signal; + const remote = { workspaceFiles: { async readBytes(...args) { + assert.equal(args[0], 'session-A'); assert.equal(args[1], '/workspace/中文.pptx'); + assert.equal(Object.keys(args[2]).length, 0); assert.equal(args[3], signal); + return { ok: true, value: { offset: 0, eof: true, bytes: 3, data } }; + } } }; + assert.equal(await plugin.readOriginal(remote, 'session-A', '/workspace/中文.pptx', signal), data); + assert.equal(plugin.filename('/workspace/中文.pptx'), '中文.pptx'); + assert.equal(plugin.filename('C:' + String.fromCharCode(92) + 'folder' + String.fromCharCode(92) + 'file.docx'), 'file.docx'); +}); +test('denied, partial, size mismatch and cancelled reads never produce download data', async () => { + const plugin = load(); + for (const result of [ + { ok: false, error: { code: 'gateway/forbidden' } }, + { ok: true, value: { offset: 0, eof: false, data: new Uint8Array() } }, + { ok: true, value: { offset: 0, eof: true, bytes: 5, data: new Uint8Array(2) } } + ]) { + await assert.rejects(plugin.readOriginal({ workspaceFiles: { readBytes: async () => result } }, 's', '/file', new AbortController().signal)); + } + const controller = new AbortController(); controller.abort(); + await assert.rejects(plugin.readOriginal({ workspaceFiles: { readBytes: async () => ({ ok: true, value: {} }) } }, 's', '/file', controller.signal)); +}); +test('plugin registers only the supported preview action slot', () => { + const plugin = load(); + let registration; + plugin.apply({ effect: fn => fn(), locale: { register: () => () => {} }, remote: {}, slots: { + inject(name, fn) { assert.equal(name, 'sidebar.right.tab.document.actions'); return fn(); }, + register(options, component) { registration = options; assert.equal(typeof component, 'function'); return () => {}; } + } }); + assert.equal(registration.id, 'dsh-preview-download'); + assert.equal(typeof registration.inject('session-A').download, 'function'); +}); diff --git a/image/preview-download/client.js b/image/preview-download/client.js new file mode 100644 index 0000000..93ab5fa --- /dev/null +++ b/image/preview-download/client.js @@ -0,0 +1,77 @@ +window.__ModuleLoader__.load({ id: 'dsh-preview-download', factory: require => { + const React = require('react'); + const { createElement: h, useState, useRef, useEffect } = React; + const namespace = 'previewDownload'; + + function filename(path) { + const name = path.replaceAll('\\', '/').split('/').pop() || 'download'; + return Array.from(name, char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127 || '<>:"|?*'.includes(char) ? '_' : char).join(''); + } + + async function readOriginal(remote, sessionId, path, signal) { + signal.throwIfAborted(); + const result = await remote.workspaceFiles.readBytes(sessionId, path, {}, signal); + signal.throwIfAborted(); + if (!result.ok) throw Object.assign(new Error('Download refused'), { code: result.error.code }); + const file = result.value; + if (!file.eof || file.offset !== 0 || !(file.data instanceof Uint8Array) || (file.bytes !== undefined && file.bytes !== file.data.byteLength)) { + throw new Error('Incomplete file response'); + } + return file.data; + } + + function Download({ absolutePath, download, t }) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const pending = useRef(null); + useEffect(() => { + setBusy(false); + setError(''); + return () => { pending.current?.abort(); pending.current = null; }; + }, [absolutePath, download]); + async function start() { + if (pending.current) return; + const controller = new AbortController(); + pending.current = controller; + setBusy(true); + setError(''); + try { + const bytes = await download(absolutePath, controller.signal); + controller.signal.throwIfAborted(); + const url = URL.createObjectURL(new Blob([bytes], { type: 'application/octet-stream' })); + const link = document.createElement('a'); + link.href = url; + link.download = filename(absolutePath); + link.hidden = true; + document.body.appendChild(link); + try { link.click(); } + finally { link.remove(); setTimeout(() => URL.revokeObjectURL(url), 60000); } + } catch (cause) { + if (!controller.signal.aborted) setError(t(cause?.code === 'workspace-file/too-large' ? 'tooLarge' : 'failed')); + } finally { + if (pending.current === controller) { pending.current = null; setBusy(false); } + } + } + return h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '6px', flexShrink: 0 } }, + h('button', { type: 'button', disabled: busy, onClick: start, title: t('download'), 'aria-label': t('download'), 'aria-busy': busy, 'data-preview-download': '', + style: { color: 'inherit', background: 'transparent', border: '1px solid currentColor', borderRadius: '6px', padding: '4px 8px', font: 'inherit', cursor: busy ? 'wait' : 'pointer' } + }, busy ? t('loading') : t('download')), + error && h('span', { role: 'alert', style: { fontSize: '12px', maxWidth: '180px' } }, error)); + } + + return { + inject: ['slots', 'locale', 'remote.workspaceFiles'], + apply(ctx) { + ctx.effect(() => ctx.locale.register(namespace, { + zh: { download: '下载原文件', loading: '下载中…', failed: '下载失败,请检查文件权限或重试。', tooLarge: '文件超过下载大小限制。' }, + en: { download: 'Download original', loading: 'Downloading…', failed: 'Download failed. Check access or retry.', tooLarge: 'File exceeds the download size limit.' } + })); + ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document.actions', () => ctx.slots.register({ + name: 'sidebar.right.tab.document.actions', id: 'dsh-preview-download', locale: namespace, + inject: sessionId => ({ download: (path, signal) => readOriginal(ctx.remote, sessionId, path, signal) }) + }, Download))); + }, + // Pure helpers are also exercised by the offline regression suite. + filename, readOriginal + }; +} }); diff --git a/image/preview-download/cordis.patch.yml b/image/preview-download/cordis.patch.yml new file mode 100644 index 0000000..cb54d2c --- /dev/null +++ b/image/preview-download/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: dsh-preview-download + name: dsh-preview-download diff --git a/image/preview-download/index.js b/image/preview-download/index.js new file mode 100644 index 0000000..bbebfa1 --- /dev/null +++ b/image/preview-download/index.js @@ -0,0 +1,3 @@ +// Client-only extension: no new server endpoints or authorization changes. +export const name = 'dsh-preview-download'; +export function apply() {} diff --git a/image/preview-download/package.json b/image/preview-download/package.json new file mode 100644 index 0000000..819cf3e --- /dev/null +++ b/image/preview-download/package.json @@ -0,0 +1,15 @@ +{ + "name": "dsh-preview-download", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "index.js", + "exports": { ".": "./index.js", "./client": "./client.js" }, + "dsh": { + "bundle": { "patch": "cordis.patch.yml" }, + "client": { + "platform": "web", + "inject": ["@deepseek-ai/dsh-client-ui-sidebar-documentpreview", "@deepseek-ai/dsh-client-ui-slots", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-api-workspace-files"] + } + } +} diff --git a/image/validate-office.py b/image/validate-office.py new file mode 100644 index 0000000..770bde4 --- /dev/null +++ b/image/validate-office.py @@ -0,0 +1,29 @@ +"""Bounded, non-extracting Office container validation; Python stdlib only.""" +import sys +import zipfile + +path, ext, entries, maximum = sys.argv[1:] +if ext in ('doc', 'xls', 'ppt'): + with open(path, 'rb') as source: + if source.read(8) != bytes.fromhex('d0cf11e0a1b11ae1'): + raise ValueError('not OLE') +else: + expected = {'docx': 'word/document.xml', 'xlsx': 'xl/workbook.xml', 'pptx': 'ppt/presentation.xml'}[ext] + with zipfile.ZipFile(path) as archive: + info = archive.infolist() + names = [entry.filename for entry in info] + if len(info) > int(entries) or len(set(names)) != len(names): + raise ValueError('entry limit or duplicate') + if expected not in names or '[Content_Types].xml' not in names: + raise ValueError('format mismatch') + if sum(entry.file_size for entry in info) > int(maximum): + raise ValueError('uncompressed size limit') + total = 0 + for entry in info: + if entry.flag_bits & 1: + raise ValueError('encrypted') + with archive.open(entry) as stream: + while chunk := stream.read(65536): + total += len(chunk) + if total > int(maximum): + raise ValueError('uncompressed size limit') diff --git a/release.json b/release.json index 1c70953..64702d9 100644 --- a/release.json +++ b/release.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "release_id": "runtime-7.5", + "release_id": "runtime-7.7", "version": "0.2.0-rc.2", "deployment_schema": 7, "platforms": ["linux/amd64", "linux/arm64"]