dsh-file-convert 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,6 +54,12 @@ dsh plugin --profile default add github:zzy-12345678/dsh-file-convert
54
54
  ```
55
55
 
56
56
  Git installs may ask you to allow the build step in the profile's `pnpm-workspace.yaml`:
57
+ > **First-install note**: pnpm may also ask you to decide on a build script for `tesseract.js` (it only shows a donation notice — skipping is safe). Set it in the same file and re-run the install:
58
+ >
59
+ > ```yaml
60
+ > allowBuilds:
61
+ > tesseract.js: false
62
+ > ```
57
63
 
58
64
  ```yaml
59
65
  allowBuilds:
@@ -163,6 +169,8 @@ All 26 conversions with their live availability on *this* machine — unavailabl
163
169
  | `maxInputMb` | `2048` | Refuse inputs above this size (MB) |
164
170
  | `maxPdfPages` | `200` | Full-document PDF rasterization refuses more pages; use `pages` for larger documents |
165
171
  | `maxOutputPixels` | `16000000` | Clamp rasterized pixels per page (width × height) to this budget |
172
+
173
+ **Limit coverage**: `maxInputMb` applies to convert, batch, inspect and optimize alike; `maxPdfPages` applies to PDF rasterization and text extraction (both implicit full-document runs and explicit `pages` selections); `maxOutputPixels` applies to PDF rasterization, OCR rendering and SVG rasterization; `timeoutMs` truly cancels every conversion (the underlying work stops, it is not merely abandoned).
166
174
  | `batchMaxFiles` | `500` | Max files examined per `batch_convert` run; beyond it the summary reports what was skipped instead of silently capping |
167
175
  | `outputRoots` | `[]` | When non-empty, explicit `output` paths must resolve inside one of these directories (recommended for shared deployments; the default next-to-input output is always exempt) |
168
176
  | `ffmpegPath` / `ffprobePath` | - | Explicit binary paths when ffmpeg is not on PATH (common on Windows) |
@@ -201,7 +209,7 @@ All 26 conversions with their live availability on *this* machine — unavailabl
201
209
  ```sh
202
210
  npm install
203
211
  npm run build # tsc -> lib/
204
- npm test # vitest, 31 tests
212
+ npm test # vitest, 63 tests (plus a few environment-gated suites)
205
213
  npm run smoke # end-to-end against lib/
206
214
  ```
207
215
 
@@ -213,6 +221,17 @@ Add a conversion = add one capability row + implement it in a converter. Add a b
213
221
  - **Cached ffmpeg builds**: the download convenience installs pinned sha256-verified FFmpeg 6.1.1 static builds. For untrusted media, a current system FFmpeg takes priority — prefer it in security-sensitive setups.
214
222
  - **Not a sandbox**: `outputRoots` resolves symlinks and resource limits (`maxInputMb`, `maxPdfPages`, `maxOutputPixels`, `batchMaxFiles`) cap runaway jobs, but the default next-to-input output is intentionally exempt from roots, and an agent that may write files can always write somewhere. For hostile multi-tenant use, add OS-level isolation on top.
215
223
 
224
+ ## Compatibility
225
+
226
+ | Component | Verified version |
227
+ | --- | --- |
228
+ | DeepSeek Harness | 0.1.1-rc.2 |
229
+ | @deepseek-ai/dsh-tools | 0.0.1-rc.1 |
230
+ | @deepseek-ai/cordis | 4.0.1 |
231
+ | Node.js | >= 20 (CI covers 22) |
232
+
233
+ DSH is a developer preview and its APIs will move - the plugin keeps all DSH imports inside a thin glue layer so adapting stays cheap.
234
+
216
235
  ## Roadmap
217
236
 
218
237
  - ~~V0.2 — Media (FFmpeg)~~ **shipped**: MP4→GIF/MP3, WAV→MP3, MOV→MP4, plus `optimize_file` with target-size two-pass encoding.
package/README.zh-CN.md CHANGED
@@ -54,6 +54,14 @@ dsh plugin --profile default add github:zzy-12345678/dsh-file-convert
54
54
  ```
55
55
 
56
56
  Git 安装可能需要在 profile 的 `pnpm-workspace.yaml` 里放行构建步骤:
57
+ > **首次安装提示**:pnpm 还可能要求你决定 `tesseract.js` 的构建脚本是否执行(它只是捐赠提示,跳过毫无影响)。在同一个文件里加上:
58
+ >
59
+ > ```yaml
60
+ > allowBuilds:
61
+ > tesseract.js: false
62
+ > ```
63
+ >
64
+ > 然后重新执行安装命令即可。
57
65
 
58
66
  ```yaml
59
67
  allowBuilds:
@@ -121,7 +129,7 @@ PDF 走 Ghostscript 三档预设(printer/ebook/screen)自动迭代:某档
121
129
 
122
130
  下载固定的 FFmpeg 6.1.1 静态构建(ffmpeg + ffprobe 两个 ~28 MB 文件)到插件缓存(`~/.dsh-file-convert/bin`),校验钉死的 sha256,并真实执行一次二进制确认可用后才报告成功。npmmirror 二进制 CDN 优先,GitHub release 为字节一致的回退源。系统安装的 ffmpeg 优先于缓存。体积可观,调用前请先征得用户同意。
123
131
 
124
- 默认从 `npmmirror.com` 镜像下载固定版本的 `@ffmpeg-installer` / `@ffprobe-installer` 二进制到插件缓存(`~/.dsh-file-convert/bin`),校验 sha512 完整性,并真实执行一次二进制确认可用后才报告成功(npmjs.org 自动回退,`registry` 参数可指定其它源)。系统安装的 ffmpeg 优先于缓存。体积可观,调用前请先征得用户同意。
132
+ 下载钉死 sha256 的 FFmpeg 6.1.1 静态构建(ffmpeg + ffprobe,各 ~28 MB)到插件缓存(`~/.dsh-file-convert/bin`),并真实执行一次二进制确认可用后才报告成功。npmmirror 二进制 CDN 优先,GitHub release 为字节一致的回退源。系统安装的 ffmpeg 优先于缓存。体积可观,调用前请先征得用户同意。
125
133
 
126
134
  ### `install_ocr_dependencies` —— 一键补齐 OCR 语言包
127
135
 
@@ -141,6 +149,8 @@ PDF 走 Ghostscript 三档预设(printer/ebook/screen)自动迭代:某档
141
149
  | `maxInputMb` | `2048` | 拒绝超过此大小(MB)的输入 |
142
150
  | `maxPdfPages` | `200` | 整本 PDF 光栅化超过此页数即拒绝;更大的文档请用 `pages` 选择 |
143
151
  | `maxOutputPixels` | `16000000` | 每页光栅化像素(宽 × 高)钳制到此预算 |
152
+
153
+ **限制覆盖范围**:`maxInputMb` 作用于 convert / batch / inspect / optimize 全部入口;`maxPdfPages` 作用于 PDF 光栅化与文本提取(隐式整本与显式 `pages` 选择都受限);`maxOutputPixels` 作用于 PDF 光栅化、OCR 渲染和 SVG 光栅化;`timeoutMs` 对所有转换真取消(超时会中止底层工作,不只是提前返回)。
144
154
  | `batchMaxFiles` | `500` | 每次 `batch_convert` 最多检查的文件数;超出时会在结果里明确报告跳过了多少,而不是静默截断 |
145
155
  | `outputRoots` | `[]` | 非空时,显式指定的 `output` 路径必须落在这些目录之内(共享部署建议开启;默认写到输入文件旁的输出不受限) |
146
156
  | `ffmpegPath` / `ffprobePath` | - | ffmpeg 不在 PATH 时(Windows 常见)手动指定二进制路径 |
@@ -179,7 +189,7 @@ PDF 走 Ghostscript 三档预设(printer/ebook/screen)自动迭代:某档
179
189
  ```sh
180
190
  npm install
181
191
  npm run build # tsc -> lib/
182
- npm test # vitest,31 个测试
192
+ npm test # vitest,63 项测试(另有个位数按环境门控的套件)
183
193
  npm run smoke # 针对 lib/ 的端到端冒烟测试
184
194
  ```
185
195
 
@@ -191,6 +201,17 @@ npm run smoke # 针对 lib/ 的端到端冒烟测试
191
201
  - **缓存版 ffmpeg**:便捷下载安装的是钉死 sha256 的 FFmpeg 6.1.1 静态构建。处理不可信媒体时,新版系统 FFmpeg 优先——安全敏感场景请优先系统安装。
192
202
  - **不是沙箱**:`outputRoots` 会解析符号链接,资源上限(`maxInputMb`、`maxPdfPages`、`maxOutputPixels`、`batchMaxFiles`)能拦住失控任务,但默认"写到输入文件旁"的输出有意不受 roots 约束,且能写文件的 Agent 总能找到地方写。敌对多租户场景请在操作系统层再加隔离。
193
203
 
204
+ ## 兼容性
205
+
206
+ | 组件 | 已验证版本 |
207
+ | --- | --- |
208
+ | DeepSeek Harness | 0.1.1-rc.2 |
209
+ | @deepseek-ai/dsh-tools | 0.0.1-rc.1 |
210
+ | @deepseek-ai/cordis | 4.0.1 |
211
+ | Node.js | ≥ 20(CI 覆盖 22) |
212
+
213
+ DSH 仍处于 developer preview,API 可能变化——插件把 DSH 依赖隔离在薄胶水层,适配成本被刻意压低。
214
+
194
215
  ## Roadmap
195
216
 
196
217
  - ~~V0.2 —— 音视频(FFmpeg)~~ **已发布**:MP4→GIF/MP3、WAV→MP3、MOV→MP4,以及按目标体积两遍编码的 `optimize_file`。
@@ -19,3 +19,12 @@ export declare function downloadBinary(dep: BinaryDependency, opts: {
19
19
  signal?: AbortSignal;
20
20
  force?: boolean;
21
21
  }): Promise<DownloadOutcome>;
22
+ export interface CacheManifestEntry {
23
+ version: string;
24
+ sha256: string;
25
+ bytes: number;
26
+ installedAt: string;
27
+ }
28
+ export type CacheManifest = Record<string, CacheManifestEntry>;
29
+ export declare function manifestPath(): string;
30
+ export declare function readCacheManifest(): Promise<CacheManifest>;
@@ -7,7 +7,8 @@ import { Readable } from 'node:stream';
7
7
  import { pipeline } from 'node:stream/promises';
8
8
  import { createWriteStream } from 'node:fs';
9
9
  import { execTool } from '../utils/exec.js';
10
- import { cachedBinaryPath } from './cache.js';
10
+ import { writeFileAtomic } from '../utils/write-file.js';
11
+ import { cachedBinaryPath, cacheDir } from './cache.js';
11
12
  /**
12
13
  * Where each external binary comes from when the machine has none: pinned
13
14
  * single-binary FFmpeg 6.1.1 builds from the ffmpeg-static project, served by
@@ -88,12 +89,24 @@ export async function downloadBinary(dep, opts) {
88
89
  const gzPath = path.join(tmpDir, 'bin.gz');
89
90
  const bytes = await downloadTo(url, gzPath, opts.timeoutMs, opts.signal);
90
91
  verifyDigest(await fs.readFile(gzPath), entry.sha256);
91
- await fs.mkdir(path.dirname(target), { recursive: true });
92
- await fs.writeFile(target, gunzipSync(await fs.readFile(gzPath)));
92
+ // Prove the binary inside the scratch dir first, then publish it into
93
+ // the cache - a failed download never leaves a half-written target.
94
+ const candidate = path.join(tmpDir, 'bin');
95
+ await fs.writeFile(candidate, gunzipSync(await fs.readFile(gzPath)));
93
96
  if (process.platform !== 'win32')
94
- await fs.chmod(target, 0o755);
95
- const { stdout, stderr } = await execTool(target, ['-version'], { timeoutMs: 15_000, signal: opts.signal });
97
+ await fs.chmod(candidate, 0o755);
98
+ const { stdout, stderr } = await execTool(candidate, ['-version'], { timeoutMs: 15_000, signal: opts.signal });
96
99
  const versionLine = (stdout || stderr).split(/\r?\n/, 1)[0]?.trim() ?? '';
100
+ await fs.mkdir(path.dirname(target), { recursive: true });
101
+ await fs.rename(candidate, target).catch(async () => {
102
+ await fs.copyFile(candidate, target);
103
+ });
104
+ await writeManifestEntry(dep.name, {
105
+ version: versionLine,
106
+ sha256: entry.sha256,
107
+ bytes,
108
+ installedAt: new Date().toISOString(),
109
+ });
97
110
  return { path: target, bytes, versionLine };
98
111
  }
99
112
  catch (err) {
@@ -106,6 +119,22 @@ export async function downloadBinary(dep, opts) {
106
119
  }
107
120
  throw new DownloadError('network', `All mirrors failed for ${dep.name}: ${errors.join(' | ')}`);
108
121
  }
122
+ export function manifestPath() {
123
+ return path.join(cacheDir(), 'manifest.json');
124
+ }
125
+ export async function readCacheManifest() {
126
+ try {
127
+ return JSON.parse(await fs.readFile(manifestPath(), 'utf8'));
128
+ }
129
+ catch {
130
+ return {};
131
+ }
132
+ }
133
+ async function writeManifestEntry(name, entry) {
134
+ const manifest = await readCacheManifest();
135
+ manifest[name] = entry;
136
+ await writeFileAtomic(manifestPath(), JSON.stringify(manifest, null, 2));
137
+ }
109
138
  async function downloadTo(url, target, timeoutMs, signal) {
110
139
  let res;
111
140
  try {
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import { parse as csvParse } from 'csv-parse/sync';
3
3
  import { stringify as csvStringify } from 'csv-stringify/sync';
4
4
  import yaml from 'js-yaml';
5
+ import { writeFileAtomic } from '../utils/write-file.js';
5
6
  import { convertError } from '../errors.js';
6
7
  /**
7
8
  * JSON / YAML / CSV share one intermediate representation (a plain JS value):
@@ -39,7 +40,7 @@ export class DataConverter {
39
40
  const bytesIn = Buffer.byteLength(raw);
40
41
  const value = this.read(from, raw, req.options.delimiter);
41
42
  const text = this.write(to, value, req.options.indent);
42
- await fs.writeFile(req.output, text, 'utf8');
43
+ await writeFileAtomic(req.output, text);
43
44
  return {
44
45
  ok: true,
45
46
  input: req.input,
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import sharp from 'sharp';
3
+ import { writeFileAtomic } from '../utils/write-file.js';
3
4
  import { convertError } from '../errors.js';
4
5
  const IMAGE_INPUTS = ['png', 'jpg', 'webp', 'svg'];
5
6
  const IMAGE_OUTPUTS = ['png', 'jpg', 'webp'];
@@ -21,7 +22,20 @@ export class ImageConverter {
21
22
  const bytesIn = (await fs.stat(req.input)).size;
22
23
  // density only affects vector input (SVG): 72 renders at the SVG's own
23
24
  // pixel size; a higher dpi option upscales. Rasters ignore it.
24
- const pipeline = sharp(req.input, { density: req.options.dpi ?? 72 });
25
+ // A hostile SVG can declare enormous dimensions - clamp the raster to
26
+ // the pixel budget before decoding.
27
+ let density = req.options.dpi ?? 72;
28
+ const maxOutputPixels = ctx.limits?.maxOutputPixels;
29
+ if (req.from === 'svg' && maxOutputPixels) {
30
+ const meta = await sharp(req.input).metadata();
31
+ const factor = density / 72;
32
+ const pixels = (meta.width ?? 1000) * factor * ((meta.height ?? 1000) * factor);
33
+ if (pixels > maxOutputPixels) {
34
+ const shrink = Math.sqrt(maxOutputPixels / pixels);
35
+ density = Math.max(1, Math.floor(density * shrink));
36
+ }
37
+ }
38
+ const pipeline = sharp(req.input, { density });
25
39
  const { quality, background } = req.options;
26
40
  let out;
27
41
  if (req.to === 'jpg') {
@@ -35,8 +49,9 @@ export class ImageConverter {
35
49
  else {
36
50
  out = pipeline.png();
37
51
  }
38
- await out.toFile(req.output);
39
- const bytesOut = (await fs.stat(req.output)).size;
52
+ const buffer = await out.toBuffer();
53
+ await writeFileAtomic(req.output, buffer);
54
+ const bytesOut = buffer.length;
40
55
  return {
41
56
  ok: true,
42
57
  input: req.input,
@@ -174,12 +174,24 @@ export class PdfToDocxConverter {
174
174
  const bytesIn = (await fs.stat(req.input)).size;
175
175
  // pdf2docx has no __main__ module and its console script may not be on
176
176
  // PATH - drive the library API directly through the resolved interpreter.
177
- await execTool(python, [
178
- '-c', 'import sys; from pdf2docx import parse; parse(sys.argv[1], sys.argv[2])',
179
- req.input, req.output,
180
- ], { timeoutMs: ctx.timeoutMs, signal: ctx.signal });
181
- if (!(await exists(req.output))) {
182
- return fail(req, convertError('conversion_failed', 'pdf2docx reported success but produced no file.'));
177
+ // It writes its output directly, so use a scratch file in the output's
178
+ // own directory and only publish a complete document under the real name.
179
+ const scratch = path.join(path.dirname(req.output), `.${path.basename(req.output)}.${Date.now()}.tmp`);
180
+ try {
181
+ await execTool(python, [
182
+ '-c', 'import sys; from pdf2docx import parse; parse(sys.argv[1], sys.argv[2])',
183
+ req.input, scratch,
184
+ ], { timeoutMs: ctx.timeoutMs, signal: ctx.signal });
185
+ if (!(await exists(scratch))) {
186
+ return fail(req, convertError('conversion_failed', 'pdf2docx reported success but produced no file.'));
187
+ }
188
+ await fs.rename(scratch, req.output).catch(async () => {
189
+ await fs.copyFile(scratch, req.output);
190
+ await fs.rm(scratch, { force: true });
191
+ });
192
+ }
193
+ finally {
194
+ await fs.rm(scratch, { force: true }).catch(() => undefined);
183
195
  }
184
196
  return {
185
197
  ok: true,
@@ -8,6 +8,7 @@ import { createCanvas } from '@napi-rs/canvas';
8
8
  import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';
9
9
  import { convertError } from '../errors.js';
10
10
  import { PageRangeError, parsePageRange } from '../utils/pages.js';
11
+ import { writeFileAtomic } from '../utils/write-file.js';
11
12
  import { OcrLanguageMissingError, OCR_LANGUAGE_DATA, TESSERACT, ocrLanguagesCached, resolveOcrEngine } from '../ocr.js';
12
13
  const require = createRequire(import.meta.url);
13
14
  /**
@@ -109,6 +110,11 @@ export class PdfConverter {
109
110
  hint: `Use pages (e.g. '1-${maxPdfPages}') to select, or raise 'maxPdfPages' in the plugin config.`,
110
111
  }));
111
112
  }
113
+ if (explicitSelection && maxPdfPages && selected.length > maxPdfPages) {
114
+ return failErr(req, convertError('invalid_input', `${selected.length} pages selected, above the ${maxPdfPages}-page rasterization limit.`, {
115
+ hint: `Narrow the pages selection or raise 'maxPdfPages' in the plugin config.`,
116
+ }));
117
+ }
112
118
  const scale = (req.options.dpi ?? 150) / 72; // PDFs have no intrinsic pixel size; 150 is a sane default.
113
119
  const maxOutputPixels = ctx.limits?.maxOutputPixels;
114
120
  let scaleReduced = false;
@@ -142,10 +148,16 @@ export class PdfConverter {
142
148
  to: req.to,
143
149
  });
144
150
  const out = selected.length === 1 ? req.output : withPageNumber(req.output, n, pad);
145
- await fs.writeFile(out, buffer);
151
+ await writeFileAtomic(out, buffer);
146
152
  bytesOut += buffer.byteLength;
147
153
  outputs.push(out);
148
154
  }
155
+ catch (err) {
156
+ // all-or-nothing: a failed page removes the pages already written
157
+ for (const written of outputs)
158
+ await fs.rm(written, { force: true }).catch(() => undefined);
159
+ throw err;
160
+ }
149
161
  finally {
150
162
  page.cleanup();
151
163
  }
@@ -174,7 +186,19 @@ export class PdfConverter {
174
186
  }
175
187
  async toText(doc, req, ctx, bytesIn, started) {
176
188
  const totalPages = doc.numPages;
189
+ const explicitSelection = Boolean(req.options.pages);
177
190
  const selected = req.options.pages ? parsePageRange(req.options.pages, totalPages) : pageRange(totalPages);
191
+ const maxPdfPages = ctx.limits?.maxPdfPages;
192
+ if (!explicitSelection && maxPdfPages && totalPages > maxPdfPages) {
193
+ return failErr(req, convertError('invalid_input', `PDF has ${totalPages} pages, above the ${maxPdfPages}-page text-extraction limit.`, {
194
+ hint: `Use pages (e.g. '1-${maxPdfPages}') to select, or raise 'maxPdfPages' in the plugin config.`,
195
+ }));
196
+ }
197
+ if (explicitSelection && maxPdfPages && selected.length > maxPdfPages) {
198
+ return failErr(req, convertError('invalid_input', `${selected.length} pages selected, above the ${maxPdfPages}-page text-extraction limit.`, {
199
+ hint: `Narrow the pages selection or raise 'maxPdfPages' in the plugin config.`,
200
+ }));
201
+ }
178
202
  const warnings = [];
179
203
  const pageTexts = [];
180
204
  for (const n of selected) {
@@ -215,6 +239,7 @@ export class PdfConverter {
215
239
  }
216
240
  // OCR needs legible pixels: default to a higher density than rasterization.
217
241
  const scale = Math.max((req.options.dpi ?? 200) / 72, 200 / 72);
242
+ const maxOutputPixels = ctx.limits?.maxOutputPixels;
218
243
  const ocrTexts = [];
219
244
  for (const n of selected) {
220
245
  if (ctx.signal?.aborted) {
@@ -222,7 +247,18 @@ export class PdfConverter {
222
247
  }
223
248
  const page = await doc.getPage(n);
224
249
  try {
225
- const png = await renderPage(page, scale, { to: 'png', quality: 100 });
250
+ let renderScale = scale;
251
+ if (maxOutputPixels) {
252
+ const viewport = page.getViewport({ scale });
253
+ const pixels = viewport.width * viewport.height;
254
+ if (pixels > maxOutputPixels) {
255
+ // Recognition quality suffers on tiny rasters, so clamp gently
256
+ // (2x the pixel budget) and say so.
257
+ renderScale = scale * Math.sqrt((maxOutputPixels * 2) / pixels);
258
+ warnings.push('OCR render scale was reduced on some pages to fit the pixel budget; recognition quality may drop.');
259
+ }
260
+ }
261
+ const png = await renderPage(page, renderScale, { to: 'png', quality: 100 });
226
262
  ocrTexts.push((await engine.recognizePng(png, ocrLang, ctx)).trim());
227
263
  }
228
264
  catch (err) {
@@ -245,7 +281,7 @@ export class PdfConverter {
245
281
  warnings.push('No extractable text found - this may be a scanned PDF. Re-run with ocr: true.');
246
282
  }
247
283
  const text = pageTexts.join('\n\n').replace(/\n{4,}/g, '\n\n\n') + '\n';
248
- await fs.writeFile(req.output, text, 'utf8');
284
+ await writeFileAtomic(req.output, text);
249
285
  if (selected.length < totalPages) {
250
286
  warnings.push(`Converted page(s) ${selected.join(', ')} of ${totalPages} (pages option).`);
251
287
  }
@@ -8,6 +8,8 @@ export { ConversionRouter, type RouterDefaults, type ConvertFileRequest, type Co
8
8
  export { detectFile, DetectError, type DetectOutcome } from './detect.js';
9
9
  export { resolveBinary } from './binary.js';
10
10
  export { defaultOutputPath, batchOutputPath } from './paths.js';
11
+ export { isInsideAnyRoot, isInsideRoot, isSameFile, realPathBestEffort } from './utils/path-guard.js';
12
+ export { writeFileAtomic } from './utils/write-file.js';
11
13
  export { ImageConverter } from './converters/image.js';
12
14
  export { PdfConverter } from './converters/pdf.js';
13
15
  export { DataConverter } from './converters/data.js';
package/lib/core/index.js CHANGED
@@ -8,6 +8,8 @@ export { ConversionRouter } from './router.js';
8
8
  export { detectFile, DetectError } from './detect.js';
9
9
  export { resolveBinary } from './binary.js';
10
10
  export { defaultOutputPath, batchOutputPath } from './paths.js';
11
+ export { isInsideAnyRoot, isInsideRoot, isSameFile, realPathBestEffort } from './utils/path-guard.js';
12
+ export { writeFileAtomic } from './utils/write-file.js';
11
13
  export { ImageConverter } from './converters/image.js';
12
14
  export { PdfConverter } from './converters/pdf.js';
13
15
  export { DataConverter } from './converters/data.js';
@@ -5,6 +5,7 @@ import { formatCategory, FORMAT_IDS, parseFormatArg } from './formats.js';
5
5
  import { detectFile, DetectError } from './detect.js';
6
6
  import { inspectFile } from './inspect.js';
7
7
  import { defaultOutputPath } from './paths.js';
8
+ import { isInsideAnyRoot, isSameFile } from './utils/path-guard.js';
8
9
  import { resolveBinary } from './binary.js';
9
10
  import { FFPROBE } from './converters/media.js';
10
11
  const DEFAULTS = { quality: 85, dpi: 150, timeoutMs: 120_000 };
@@ -128,7 +129,7 @@ export class ConversionRouter {
128
129
  }),
129
130
  };
130
131
  }
131
- if (req.output !== undefined && !(await isInsideRoots(output, this.defaults.outputRoots))) {
132
+ if (req.output !== undefined && !(await isInsideAnyRoot(output, this.defaults.outputRoots))) {
132
133
  return {
133
134
  ok: false, input: req.input, from, to,
134
135
  error: convertError('invalid_input', `Output path ${output} is outside every configured outputRoot.`, {
@@ -157,18 +158,41 @@ export class ConversionRouter {
157
158
  ocrLang: req.ocrLang,
158
159
  };
159
160
  const request = { input: req.input, output, from, to, options };
161
+ // True cancellation: the timeout (or the caller) aborts an internal
162
+ // controller that converters actually observe, so work stops instead of
163
+ // being abandoned behind an already-returned promise.
164
+ const controller = new AbortController();
165
+ const onCallerAbort = () => controller.abort();
166
+ run.signal?.addEventListener('abort', onCallerAbort, { once: true });
160
167
  const ctx = {
161
168
  logger: run.logger,
162
- signal: run.signal,
169
+ signal: AbortSignal.any([controller.signal, ...(run.signal ? [run.signal] : [])]),
163
170
  timeoutMs: this.defaults.timeoutMs,
164
171
  limits: { maxPdfPages: this.defaults.maxPdfPages, maxOutputPixels: this.defaults.maxOutputPixels },
165
172
  };
166
- const result = await withTimeout(converter.convert(request, ctx), ctx.timeoutMs, {
167
- input: req.input, from, to,
168
- });
169
- if (result.ok)
170
- result.warnings.unshift(...warnings);
171
- return result;
173
+ let timer;
174
+ try {
175
+ const timeoutPromise = new Promise((resolve) => {
176
+ timer = setTimeout(() => {
177
+ controller.abort();
178
+ resolve({
179
+ ok: false,
180
+ input: req.input,
181
+ from,
182
+ to,
183
+ error: convertError('timeout', `Conversion exceeded ${Math.round(ctx.timeoutMs / 1000)}s and was cancelled.`),
184
+ });
185
+ }, ctx.timeoutMs);
186
+ });
187
+ const result = await Promise.race([converter.convert(request, ctx), timeoutPromise]);
188
+ if (result.ok)
189
+ result.warnings.unshift(...warnings);
190
+ return result;
191
+ }
192
+ finally {
193
+ clearTimeout(timer);
194
+ run.signal?.removeEventListener('abort', onCallerAbort);
195
+ }
172
196
  }
173
197
  catch (err) {
174
198
  if (err instanceof DetectError) {
@@ -183,6 +207,16 @@ export class ConversionRouter {
183
207
  }
184
208
  }
185
209
  async inspect(input) {
210
+ const inputStat = await fs.stat(input).catch(() => null);
211
+ if (inputStat?.isDirectory()) {
212
+ throw new DetectError(convertError('invalid_input', `Input is a directory, not a file: ${input}`));
213
+ }
214
+ const maxInputBytes = this.defaults.maxInputBytes ?? 2 * 1024 ** 3;
215
+ if (inputStat && inputStat.size > maxInputBytes) {
216
+ throw new DetectError(convertError('invalid_input', `Input is ${Math.round(inputStat.size / 1048576).toLocaleString('en-US')} MB, above the ${Math.round(maxInputBytes / 1048576).toLocaleString('en-US')} MB limit for inspection.`, {
217
+ hint: "Raise 'maxInputMb' in the plugin config.",
218
+ }));
219
+ }
186
220
  const { detection } = await detectFile(input);
187
221
  const bytes = (await fs.stat(input)).size;
188
222
  let media;
@@ -199,51 +233,6 @@ export class ConversionRouter {
199
233
  const CATEGORY_RANK = new Map([...FORMAT_IDS]
200
234
  .sort((a, b) => formatCategory(a).localeCompare(formatCategory(b)))
201
235
  .map((format, index) => [format, index]));
202
- /**
203
- * realpath the deepest EXISTING ancestor of a path and rejoin the remainder:
204
- * symlinks anywhere in the existing part are resolved, which is what
205
- * outputRoots confinement needs (a symlink inside a root can point outside).
206
- */
207
- async function realPathBestEffort(p) {
208
- let current = path.resolve(p);
209
- const tail = [];
210
- for (;;) {
211
- try {
212
- return path.join(await fs.realpath(current), ...tail.reverse());
213
- }
214
- catch {
215
- /* segment does not exist yet - walk up */
216
- }
217
- const parent = path.dirname(current);
218
- if (parent === current)
219
- return path.resolve(p);
220
- tail.push(path.basename(current));
221
- current = parent;
222
- }
223
- }
224
- async function isSameFile(a, b) {
225
- const ra = await realPathBestEffort(a);
226
- const rb = await realPathBestEffort(b);
227
- if (ra === rb)
228
- return true;
229
- // Windows paths are case-insensitive; also fold / vs \.
230
- return process.platform === 'win32' && ra.replace(/\\/g, '/').toLowerCase() === rb.replace(/\\/g, '/').toLowerCase();
231
- }
232
- async function isInsideRoots(output, roots) {
233
- if (!roots || roots.length === 0)
234
- return true;
235
- const candidate = await realPathBestEffort(output);
236
- const normalized = process.platform === 'win32' ? candidate.replace(/\\/g, '/').toLowerCase() : candidate;
237
- for (const root of roots) {
238
- const prefix = await realPathBestEffort(path.resolve(root));
239
- const normalizedPrefix = process.platform === 'win32' ? prefix.replace(/\\/g, '/').toLowerCase() : prefix;
240
- if (normalized === normalizedPrefix ||
241
- normalized.startsWith(normalizedPrefix.endsWith('/') ? normalizedPrefix : normalizedPrefix + '/')) {
242
- return true;
243
- }
244
- }
245
- return false;
246
- }
247
236
  async function missingDeps(converter, overrides) {
248
237
  const missing = [];
249
238
  for (const dep of converter.binaryDeps) {
@@ -264,25 +253,4 @@ async function exists(path) {
264
253
  return false;
265
254
  }
266
255
  }
267
- /** Cooperative deadline: long-running converters also observe ctx.signal. */
268
- async function withTimeout(promise, timeoutMs, meta) {
269
- let timer;
270
- const timeout = new Promise((resolve) => {
271
- timer = setTimeout(() => {
272
- resolve({
273
- ok: false,
274
- input: meta.input,
275
- from: meta.from,
276
- to: meta.to,
277
- error: convertError('timeout', `Conversion exceeded ${Math.round(timeoutMs / 1000)}s and was abandoned.`),
278
- });
279
- }, timeoutMs);
280
- });
281
- try {
282
- return await Promise.race([promise, timeout]);
283
- }
284
- finally {
285
- clearTimeout(timer);
286
- }
287
- }
288
256
  const NULL_LOGGER = { debug() { }, info() { }, warn() { }, error() { } };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * realpath the deepest EXISTING ancestor of a path and rejoin the remainder:
3
+ * symlinks anywhere in the existing part are resolved, which is what output
4
+ * confinement and same-file checks need (a symlink inside a root can point
5
+ * outside, and two different-looking paths can be the same file).
6
+ */
7
+ export declare function realPathBestEffort(p: string): Promise<string>;
8
+ /** Same file on disk, comparing real paths (symlink/case/separator aware). */
9
+ export declare function isSameFile(a: string, b: string): Promise<boolean>;
10
+ /** True when `candidate` is `root` itself or lives inside it (real paths). */
11
+ export declare function isInsideRoot(candidate: string, root: string): Promise<boolean>;
12
+ /** True when the candidate is inside at least one of the roots. */
13
+ export declare function isInsideAnyRoot(candidate: string, roots: string[] | undefined): Promise<boolean>;
@@ -0,0 +1,53 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ /**
4
+ * realpath the deepest EXISTING ancestor of a path and rejoin the remainder:
5
+ * symlinks anywhere in the existing part are resolved, which is what output
6
+ * confinement and same-file checks need (a symlink inside a root can point
7
+ * outside, and two different-looking paths can be the same file).
8
+ */
9
+ export async function realPathBestEffort(p) {
10
+ let current = path.resolve(p);
11
+ const tail = [];
12
+ for (;;) {
13
+ try {
14
+ return path.join(await fs.realpath(current), ...tail.reverse());
15
+ }
16
+ catch {
17
+ /* segment does not exist yet - walk up */
18
+ }
19
+ const parent = path.dirname(current);
20
+ if (parent === current)
21
+ return path.resolve(p);
22
+ tail.push(path.basename(current));
23
+ current = parent;
24
+ }
25
+ }
26
+ /** Same file on disk, comparing real paths (symlink/case/separator aware). */
27
+ export async function isSameFile(a, b) {
28
+ const ra = await realPathBestEffort(a);
29
+ const rb = await realPathBestEffort(b);
30
+ if (ra === rb)
31
+ return true;
32
+ // Windows paths are case-insensitive; also fold / vs \.
33
+ return process.platform === 'win32' && ra.replace(/\\/g, '/').toLowerCase() === rb.replace(/\\/g, '/').toLowerCase();
34
+ }
35
+ function normalizeForCompare(p) {
36
+ return process.platform === 'win32' ? p.replace(/\\/g, '/').toLowerCase() : p;
37
+ }
38
+ /** True when `candidate` is `root` itself or lives inside it (real paths). */
39
+ export async function isInsideRoot(candidate, root) {
40
+ const realCandidate = normalizeForCompare(await realPathBestEffort(candidate));
41
+ const realRoot = normalizeForCompare(await realPathBestEffort(path.resolve(root)));
42
+ return realCandidate === realRoot || realCandidate.startsWith(realRoot.endsWith('/') ? realRoot : realRoot + '/');
43
+ }
44
+ /** True when the candidate is inside at least one of the roots. */
45
+ export async function isInsideAnyRoot(candidate, roots) {
46
+ if (!roots || roots.length === 0)
47
+ return true;
48
+ for (const root of roots) {
49
+ if (await isInsideRoot(candidate, root))
50
+ return true;
51
+ }
52
+ return false;
53
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Write `data` to `target` atomically: the bytes land in a sibling temp file
3
+ * first and are renamed into place only when complete, so an interrupted run
4
+ * never leaves a half-written output behind.
5
+ */
6
+ export declare function writeFileAtomic(target: string, data: Buffer | string): Promise<void>;
@@ -0,0 +1,25 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ /**
4
+ * Write `data` to `target` atomically: the bytes land in a sibling temp file
5
+ * first and are renamed into place only when complete, so an interrupted run
6
+ * never leaves a half-written output behind.
7
+ */
8
+ export async function writeFileAtomic(target, data) {
9
+ const tmp = path.join(path.dirname(target), `.${path.basename(target)}.${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`);
10
+ try {
11
+ await fs.writeFile(tmp, data);
12
+ try {
13
+ await fs.rename(tmp, target);
14
+ }
15
+ catch {
16
+ // cross-device or platform quirk: fall back to a full copy
17
+ await fs.copyFile(tmp, target);
18
+ await fs.rm(tmp, { force: true });
19
+ }
20
+ }
21
+ catch (err) {
22
+ await fs.rm(tmp, { force: true }).catch(() => undefined);
23
+ throw err;
24
+ }
25
+ }
package/lib/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Context } from '@deepseek-ai/cordis';
2
2
  import { type Config as ConvertConfig } from './config.js';
3
3
  export { Config } from './config.js';
4
+ export * from './core/index.js';
4
5
  export declare const name = "dsh-file-convert";
5
6
  export declare const inject: string[];
6
7
  export declare function apply(ctx: Context, config: ConvertConfig): void;
package/lib/index.js CHANGED
@@ -7,6 +7,9 @@ import { createOptimizeFileTool } from './tools/optimize-file.js';
7
7
  import { createInstallMediaTool } from './tools/install-media.js';
8
8
  import { createInstallOcrTool } from './tools/install-ocr.js';
9
9
  export { Config } from './config.js';
10
+ // Library consumers (CLI, MCP wrapper, tests) can import the whole core
11
+ // surface from the package root; the DSH loader only reads name/inject/apply.
12
+ export * from './core/index.js';
10
13
  export const name = 'dsh-file-convert';
11
14
  export const inject = ['tools'];
12
15
  const CONSOLE_LOGGER = {
@@ -1,6 +1,6 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
2
  import { FFMPEG, FFPROBE, resolveBinary } from '../core/index.js';
3
- import { downloadBinary } from '../core/binaries/download.js';
3
+ import { downloadBinary, readCacheManifest } from '../core/binaries/download.js';
4
4
  import { formatBytes } from '../format.js';
5
5
  /**
6
6
  * One explicit, user-approved path to media support: downloads pinned static
@@ -31,10 +31,14 @@ export function createInstallMediaTool(config, logger) {
31
31
  if (config.ffprobePath)
32
32
  overrides.ffprobePath = config.ffprobePath;
33
33
  const lines = [];
34
+ const manifest = await readCacheManifest();
34
35
  for (const dep of [FFMPEG, FFPROBE]) {
35
36
  const existing = await resolveBinary(dep, overrides, logger);
36
37
  if (existing && args.force !== true) {
37
38
  lines.push(`= ${dep.name}: already available at ${existing}`);
39
+ if (!manifest[dep.name]) {
40
+ lines.push(`~ ${dep.name}: the cached copy predates the manifest - run with force: true once to upgrade to a sha256-verified build`);
41
+ }
38
42
  continue;
39
43
  }
40
44
  logger.info(`downloading ${dep.name} into the plugin cache...`);
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools';
4
4
  import { DetectError, optimizeFile, resolveBinary } from '../core/index.js';
5
5
  import { canonicalExtension } from '../core/index.js';
6
+ import { isInsideAnyRoot } from '../core/index.js';
6
7
  import { formatBytes, formatDuration, formatFailure } from '../format.js';
7
8
  export function createOptimizeFileTool(router, config, logger) {
8
9
  return defineTool({
@@ -25,6 +26,13 @@ export function createOptimizeFileTool(router, config, logger) {
25
26
  isConcurrencySafe: () => true,
26
27
  async execute(args, exec) {
27
28
  const targetBytes = Math.max(1, Math.round(args.target_size_mb * 1024 * 1024));
29
+ const inputStat = await fs.stat(args.input).catch(() => null);
30
+ if (!inputStat)
31
+ throw new Error(`Input file not found: ${args.input}`);
32
+ const maxBytes = config.maxInputMb * 1024 * 1024;
33
+ if (inputStat.size > maxBytes) {
34
+ throw new Error(`Input is ${Math.round(inputStat.size / 1048576).toLocaleString('en-US')} MB, above the ${config.maxInputMb} MB limit. Raise 'maxInputMb' in the plugin config if this file is intentional.`);
35
+ }
28
36
  let detection;
29
37
  try {
30
38
  detection = (await router.detect(args.input)).detection;
@@ -42,7 +50,7 @@ export function createOptimizeFileTool(router, config, logger) {
42
50
  if (path.resolve(output) === path.resolve(args.input)) {
43
51
  throw new Error('Output path equals the input path; optimizing would destroy the source. Use the default -min output name or pick another path.');
44
52
  }
45
- if (args.output !== undefined && config.outputRoots.length > 0 && !isInsideRoots(args.output, config.outputRoots)) {
53
+ if (args.output !== undefined && config.outputRoots.length > 0 && !(await isInsideAnyRoot(args.output, config.outputRoots))) {
46
54
  throw new Error(`Output path is outside every configured outputRoot (${config.outputRoots.join(', ')}).`);
47
55
  }
48
56
  if (args.overwrite !== true && (await exists(output))) {
@@ -71,15 +79,6 @@ export function createOptimizeFileTool(router, config, logger) {
71
79
  },
72
80
  });
73
81
  }
74
- function isInsideRoots(output, roots) {
75
- const resolved = path.resolve(output);
76
- const candidate = process.platform === 'win32' ? resolved.replace(/\\/g, '/').toLowerCase() : resolved;
77
- return roots.some((root) => {
78
- const rr = path.resolve(root);
79
- const prefix = process.platform === 'win32' ? rr.replace(/\\/g, '/').toLowerCase() : rr;
80
- return candidate === prefix || candidate.startsWith(prefix.endsWith('/') ? prefix : prefix + '/');
81
- });
82
- }
83
82
  async function exists(p) {
84
83
  try {
85
84
  await fs.access(p);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-file-convert",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Local-first file conversion for DeepSeek Harness - images, PDF, data, audio/video and office documents. No API keys, no uploads, no token cost.",
5
5
  "license": "MIT",
6
6
  "type": "module",