@feng0531/dsh-vcs 0.3.62 → 0.3.67

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.
@@ -0,0 +1,42 @@
1
+ /** Pure bounded byte decoding shared by working and committed previews. */
2
+ export const MAX_TEXT = 2 * 1024 * 1024;
3
+
4
+ export function decode(buffer) {
5
+ if (buffer.length > MAX_TEXT) {
6
+ return {text: '', notice: 'File exceeds the 2 MiB preview limit.'};
7
+ }
8
+ let text, encoding = 'UTF-8';
9
+ if (buffer[0] === 0xff && buffer[1] === 0xfe) {
10
+ if (buffer.length % 2) return {text: '', binary: true, notice: 'Invalid UTF-16 file.'};
11
+ text = buffer.subarray(2).toString('utf16le');
12
+ encoding = 'UTF-16LE';
13
+ } else if (buffer[0] === 0xfe && buffer[1] === 0xff) {
14
+ const bytes = Buffer.from(buffer.subarray(2));
15
+ if (bytes.length % 2) return {text: '', binary: true, notice: 'Invalid UTF-16 file.'};
16
+ bytes.swap16();
17
+ text = bytes.toString('utf16le');
18
+ encoding = 'UTF-16BE';
19
+ } else {
20
+ if (buffer.includes(0)) {
21
+ return {text: '', binary: true, notice: 'Binary file; text preview unavailable.'};
22
+ }
23
+ if (buffer.some(byte => byte < 32 && ![9, 10, 12, 13].includes(byte))) {
24
+ return {text: '', binary: true, notice: 'Binary control bytes; text preview unavailable.'};
25
+ }
26
+ try {
27
+ text = new TextDecoder('utf-8', {fatal: true}).decode(buffer);
28
+ } catch {
29
+ // A UTF-8 BOM is authoritative; malformed UTF-8 must not become GBK.
30
+ if (buffer.subarray(0, 3).equals(Buffer.from([0xef, 0xbb, 0xbf]))) {
31
+ return {text: '', binary: true, notice: 'Invalid UTF-8 file.'};
32
+ }
33
+ try {
34
+ text = new TextDecoder('gbk', {fatal: true}).decode(buffer);
35
+ encoding = 'GBK';
36
+ } catch {
37
+ return {text: '', binary: true, notice: 'Unsupported encoding or binary file; preview unavailable.'};
38
+ }
39
+ }
40
+ }
41
+ return {text, encoding};
42
+ }
package/vcs.mjs CHANGED
@@ -1,5 +1,6 @@
1
+ import {parseSvnIdentity} from './src/svn-identity.mjs';
1
2
  import {prepareRaster} from './image-preview.mjs';
2
- import { spawn } from 'node:child_process';
3
+ import {git, svn, TIMEOUT} from './local-command.mjs';
3
4
  import {parseHistory,parseFollowHistory,HISTORY_FORMAT,historyPage,historySearchArgs,parseBlame,parseReferences,REFS_FORMAT} from './git-history.mjs';
4
5
  import * as fs from 'node:fs/promises';
5
6
  import {constants as fileFlags} from 'node:fs';
@@ -7,60 +8,17 @@ import path from 'node:path';
7
8
  import { createHash } from 'node:crypto';
8
9
  import { XMLParser, XMLValidator } from 'fast-xml-parser';
9
10
 
10
- const MAX_TEXT = 2 * 1024 * 1024;
11
- const MAX_OUTPUT = 16 * 1024 * 1024;
12
- const TIMEOUT = 15000;
11
+ import {decode, MAX_TEXT} from './text-content.mjs';
12
+ import {inside, confined} from './repository-path.mjs';
13
13
  const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, parseAttributeValue: false, trimValues: false, processEntities: true, isArray: name => ['entry', 'target', 'property'].includes(name) });
14
14
 
15
- function run(command, args, cwd, max = MAX_OUTPUT, { signal, timeoutMs = TIMEOUT } = {}) {
16
- return new Promise((resolve, reject) => {
17
- if (signal?.aborted) { reject(Object.assign(new Error('Discovery cancelled'), { code: 'ABORT_ERR' })); return; }
18
- // Inherited Git overrides must not redirect reads to a different repository.
19
- const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith('GIT_')));
20
- Object.assign(env, { GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_NO_LAZY_FETCH: '1', GIT_ALLOW_PROTOCOL: '', LC_ALL: process.platform === 'linux' ? 'C.UTF-8' : 'en_US.UTF-8' });
21
- const child = spawn(command, args, { cwd, shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env });
22
- const out = [], err = []; let size = 0, error, done = false;
23
- const stop = e => { error ??= e; child.kill(); };
24
- const abort = () => stop(Object.assign(new Error('Discovery cancelled'), { code: 'ABORT_ERR' }));
25
- signal?.addEventListener('abort', abort, { once: true });
26
- const timer = setTimeout(() => stop(Object.assign(new Error(command + ' timed out'), { code: 'TIMEOUT' })), timeoutMs);
27
- child.stdout.on('data', b => { size += b.length; if (size > max) stop(Object.assign(new Error('Output exceeds size limit'), { code: 'TOO_LARGE' })); else out.push(b); });
28
- child.stderr.on('data', b => { size += b.length; if (size > max) stop(Object.assign(new Error('Output exceeds size limit'), { code: 'TOO_LARGE' })); else err.push(b); });
29
- const finish = e => { if (done) return; done = true; clearTimeout(timer); signal?.removeEventListener('abort', abort); if (e) reject(e); else resolve(Buffer.concat(out)); };
30
- child.on('error', finish);
31
- child.on('close', code => finish(error || (code !== 0 ? Object.assign(new Error(command + ': ' + Buffer.concat(err).toString('utf8').trim()), { code: 'VCS_COMMAND', exitCode: code }) : null)));
32
- });
33
- }
34
- const git = (root, args, max, options) => run('git', ['--no-pager', '--literal-pathspecs', '-c', 'core.fsmonitor=false', ...args], root, max, options);
35
- const svn = (root, args, max, options) => run('svn', ['--non-interactive', ...args], root, max, options);
15
+
36
16
  function parseXML(buffer) {
37
17
  const text = buffer.toString('utf8');
38
18
  if (/<!DOCTYPE|<!ENTITY/i.test(text) || XMLValidator.validate(text) !== true) throw new Error('Invalid VCS XML');
39
19
  return xml.parse(text);
40
20
  }
41
- function inside(root, candidate) { const rel = path.relative(root, candidate); return rel === '' || (!path.isAbsolute(rel) && rel !== '..' && !rel.startsWith('..' + path.sep)); }
42
- async function confined(root, relative) {
43
- if (typeof relative !== 'string' || relative.includes('\0') || path.isAbsolute(relative) || /^[a-z]:/i.test(relative)) throw new Error('Invalid repository path');
44
- const target = path.resolve(root, relative);
45
- if (!inside(root, target)) throw new Error('Path escapes repository');
46
- // Inspect each existing component, including dangling links whose realpath fails.
47
- let component = root;
48
- for (const part of path.relative(root, target).split(path.sep).filter(Boolean)) {
49
- component = path.join(component, part);
50
- try {
51
- if ((await fs.lstat(component)).isSymbolicLink()) {
52
- const destination = path.resolve(path.dirname(component), await fs.readlink(component));
53
- if (!inside(root, destination)) throw new Error('Symlink escapes repository');
54
- }
55
- } catch (e) { if (!['ENOENT', 'ENOTDIR'].includes(e.code)) throw e; }
56
- }
57
- let current = target;
58
- for (;;) {
59
- try { const real = await fs.realpath(current); if (!inside(root, real)) throw new Error('Symlink escapes repository'); break; }
60
- catch (e) { if (!['ENOENT', 'ENOTDIR'].includes(e.code)) throw e; const parent = path.dirname(current); if (parent === current) throw e; current = parent; }
61
- }
62
- return target;
63
- }
21
+
64
22
 
65
23
  /** Detect the nearest enclosing Git or SVN working copy; never initializes one. */
66
24
  export async function detectRepository(cwd) {
@@ -222,6 +180,11 @@ async function checkedRepo(repo, signal) {
222
180
  if (!found || found.type !== repo.type || path.resolve(found.root) !== path.resolve(repo.root)) throw new Error('Repository root changed or is invalid');
223
181
  return found;
224
182
  }
183
+ export async function getSvnIdentity(repo,{signal}={}){
184
+ repo=await checkedRepo(repo,signal);if(repo.type!=='svn')throw Error('SVN working copy required');
185
+ const result=parseSvnIdentity((await svn(repo.root,['info','--xml','--', '.'],MAX_TEXT,{signal})).toString('utf8'));
186
+ if(await fs.realpath(result.wcRoot)!==repo.root)throw Error('SVN working copy root changed');signal?.throwIfAborted();return result;
187
+ }
225
188
  export async function listHistory(repo, {snapshot,offset=0,limit=50,search,signal} = {}) {
226
189
  const query=historySearchArgs(search);
227
190
  if(!Number.isInteger(offset)||offset<0||offset>10000||!Number.isInteger(limit)||limit<1||limit>100) throw new Error('Invalid history pagination');
@@ -479,24 +442,8 @@ async function changes(repo, mode) {
479
442
  }
480
443
  /** Changes have opaque IDs, status words, and repo-relative paths. */
481
444
  export async function listChanges(repo, mode = 'all') { repo = await checkedRepo(repo); modeFor(repo, mode); return changes(repo, mode); }
482
- export function decode(buffer) {
483
- if (buffer.length > MAX_TEXT) return { text: '', notice: 'File exceeds the 2 MiB preview limit.' };
484
- let text, encoding = 'UTF-8';
485
- if (buffer[0] === 0xff && buffer[1] === 0xfe) { if (buffer.length % 2) return { text: '', binary: true, notice: 'Invalid UTF-16 file.' }; text = buffer.subarray(2).toString('utf16le'); encoding = 'UTF-16LE'; }
486
- else if (buffer[0] === 0xfe && buffer[1] === 0xff) { const b = Buffer.from(buffer.subarray(2)); if (b.length % 2) return { text: '', binary: true, notice: 'Invalid UTF-16 file.' }; b.swap16(); text = b.toString('utf16le'); encoding = 'UTF-16BE'; }
487
- else {
488
- if (buffer.includes(0)) return { text: '', binary: true, notice: 'Binary file; text preview unavailable.' };
489
- if (buffer.some(byte => byte < 32 && ![9,10,12,13].includes(byte))) return { text: '', binary: true, notice: 'Binary control bytes; text preview unavailable.' };
490
- try { text = new TextDecoder('utf-8', { fatal: true }).decode(buffer); }
491
- catch {
492
- // A UTF-8 BOM is authoritative; do not reinterpret malformed UTF-8 as GBK.
493
- if (buffer.subarray(0,3).equals(Buffer.from([0xef,0xbb,0xbf]))) return {text:'',binary:true,notice:'Invalid UTF-8 file.'};
494
- try { text = new TextDecoder('gbk', {fatal:true}).decode(buffer); encoding = 'GBK'; }
495
- catch { return {text:'',binary:true,notice:'Unsupported encoding or binary file; preview unavailable.'}; }
496
- }
497
- }
498
- return { text, encoding };
499
- }
445
+ // Keep the public adapter decoding export compatible with existing consumers.
446
+ export {decode} from './text-content.mjs';
500
447
  async function content(fn) { try { return decode(await fn()); } catch (e) { if (e.code === 'TOO_LARGE') return { text: '', notice: 'File exceeds the 2 MiB preview limit.' }; throw e; } }
501
448
  async function working(root, relative, svnLink = false) {
502
449
  const target = await confined(root, relative);