GitNexus/gitnexus/test/unit/lbug-pool-win-fts-probe.test.ts
Gergő Magyar d4449b4ec8
fix(lbug): resolve non-ASCII paths for KuzuDB on Windows (#1811) (#1817)
* fix(lbug): resolve non-ASCII paths to 8.3 short form on Windows (#1811)

KuzuDB's native C++ layer uses ANSI file APIs (fopen) on Windows.
When the repo path contains CJK or other non-ASCII characters, the
UTF-8 bytes from Node.js are misinterpreted as the system's Active
Code Page (e.g. GBK), producing a garbled path — "Error 3: The
system cannot find the path specified."

Add `toNativeSafePath()` which converts non-ASCII paths to their
Windows 8.3 short-name form (all-ASCII) before passing them to the
native layer. Applied to both the database open path and the COPY
CSV paths. No-ops on non-Windows and on all-ASCII paths.

Closes #1811

* test(lbug): add unit + integration tests for non-ASCII path handling (#1811)

- Unit tests for toNativeSafePath: ASCII passthrough, non-Windows
  no-op, Windows short-path conversion, nonexistent-path fallback
- Integration test: full initLbug + loadGraphToLbug round-trip with
  CJK characters in the storage path — runs on all platforms
- Fix toNativeSafePath to reject cmd.exe output containing '?' chars
  (replacement for unrepresentable Unicode in the console code page)
- Register integration test in vitest lbug-db project and
  cross-platform-tests.ts matrix

* chore(autofix): apply prettier + eslint fixes via /autofix command

* feat(lbug): junction fallback, tmpdir CSV staging, pool-adapter coverage (#1811)

U1+U4: toNativeSafePath now tries 8.3 short path → NTFS junction
fallback → diagnostic warning. Junctions target path.dirname(p) and
reconstruct the leaf. Handles EEXIST races. Registers cleanup on
exit/SIGTERM/SIGINT. Orphan scan on first call removes stale
junctions from prior crashes.

U2: loadGraphToLbug redirects csvDir to os.tmpdir() when
storagePath contains non-ASCII on Windows, avoiding non-ASCII
characters in COPY FROM paths entirely.

U3: All 4 createLbugDatabase call sites in pool-adapter.ts now
wrap dbPath with toNativeSafePath.

* fix(test): fix CI failures from toNativeSafePath addition (#1811)

- Fix lbug-non-ascii-path integration test: use CodeRelation (actual
  relationship table name) instead of CALLS
- Add toNativeSafePath to lbug-config.js mocks in pool-wal-recovery
  and lbug-pool-win-fts-probe tests — pool-adapter now imports it

* fix(lbug): sanitize path before cmd.exe shell expansion (CodeQL)

Reject paths containing cmd.exe metacharacters (" % | & < > ^)
before interpolating into the `for %I` short-path command.
Prevents command injection via crafted path names.

* fix(lbug): address code review findings in non-ASCII path implementation

- U1: Use process.exit(0) on Windows instead of process.kill re-raise
  (SIGTERM forcefully kills on Windows, handlers never fire)
- U2: Pass safePath to openWithLockRetry so sidecar sweep targets the
  path KuzuDB actually opened, not the original non-ASCII path
- U3: Skip junction creation in worker threads (isMainThread guard) to
  prevent junction leaks from pool-adapter workers
- U4: Replace existsSync with lstatSync in orphan scan to avoid 30s
  blocking on unreachable UNC network targets

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(lbug): correct SIGTERM exit code and run Prettier (#1811)

- Use exit code 143 (SIGTERM) / 130 (SIGINT) on Windows instead of 0
  so termination is not masked as success
- Run Prettier to fix formatting (CI Gate blocker)

* fix(lbug): eliminate CodeQL command-injection taint in tryShortPath

Pass the path via GITNEXUS_SP environment variable instead of
interpolating it into the cmd.exe command string. The FOR loop
reads %GITNEXUS_SP% from the environment, so the command text is
entirely static — no user-controlled data in the shell command.

Also removes CMD_UNSAFE_RE since the env var approach makes
character-level sanitization unnecessary.

---------

Co-authored-by: Test <test@example.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-25 21:28:12 +01:00

133 lines
5.6 KiB
TypeScript

/**
* Unit tests for the Windows FTS probe in pool-adapter.ts.
*
* Covers `hasLocalWinFtsExtension()` — the helper that gates the
* Windows-only skip of `loadFTSExtension` in `doInitLbug` and
* `initLbugWithDb`. Issue #1690 / PR #1692.
*
* The probe is exercised against a real temp filesystem with
* `os.homedir()` spied to point at the tempdir. This tests the
* actual fs surface (readdir/stat semantics, missing-dir behavior,
* zero-byte file handling) rather than mocking fs internals.
*
* The Windows-branch conditional in `doInitLbug` / `initLbugWithDb`
* is intentionally not unit-tested in isolation: those functions
* require a fully constructed `lbug.Database` + `Connection` pool
* and are exercised end-to-end by `test/integration/lbug-pool*.test.ts`
* on the `windows-latest` matrix. The conditional itself is a single
* expression — `(await hasLocalWinFtsExtension()) ? load : true` —
* whose correctness reduces to the probe being correctly tested here.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import os from 'os';
import path from 'path';
import fs from 'fs/promises';
// Stub out the LadybugDB native loader and its transitive importers so that
// importing pool-adapter.ts in this unit test does not pull in the .node binary
// (which is built by the postinstall script and is not always present in the
// dev install used for unit tests).
vi.mock('@ladybugdb/core', () => ({
default: { Database: vi.fn(), Connection: vi.fn() },
}));
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
isReadOnlyDbError: vi.fn(() => false),
loadFTSExtension: vi.fn(),
}));
vi.mock('../../src/core/lbug/lbug-config.js', () => ({
createLbugDatabase: vi.fn(),
toNativeSafePath: vi.fn((p: string) => p),
isWalCorruptionError: vi.fn(() => false),
WAL_RECOVERY_SUGGESTION: '',
}));
import { hasLocalWinFtsExtension } from '../../src/core/lbug/pool-adapter.js';
describe('hasLocalWinFtsExtension', () => {
let tmpHome: string;
beforeEach(async () => {
tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-fts-probe-'));
vi.spyOn(os, 'homedir').mockReturnValue(tmpHome);
});
afterEach(async () => {
vi.restoreAllMocks();
await fs.rm(tmpHome, { recursive: true, force: true });
});
it('returns false when ~/.lbdb/extension does not exist', async () => {
// tmpHome is empty; the probe should swallow the readdir ENOENT and return false.
await expect(hasLocalWinFtsExtension()).resolves.toBe(false);
});
it('returns false when ~/.lbdb/extension exists but has no version dirs', async () => {
await fs.mkdir(path.join(tmpHome, '.lbdb', 'extension'), { recursive: true });
await expect(hasLocalWinFtsExtension()).resolves.toBe(false);
});
it('returns true when a single version dir contains the FTS binary', async () => {
const ftsDir = path.join(tmpHome, '.lbdb', 'extension', '0.16.0', 'win_amd64', 'fts');
await fs.mkdir(ftsDir, { recursive: true });
await fs.writeFile(path.join(ftsDir, 'libfts.lbug_extension'), Buffer.from('mock-binary'));
await expect(hasLocalWinFtsExtension()).resolves.toBe(true);
});
it('returns true when the binary is a zero-byte stub (LOAD failure handled downstream)', async () => {
// Empirically verified in #1690 thread: LadybugDB resolves LOAD EXTENSION fts to a
// version-specific path internally and the ExtensionManager's tryLoad try/catch
// catches the resulting load error cleanly. Probe is intentionally generous here;
// safety lives in the loader, not the probe.
const ftsDir = path.join(tmpHome, '.lbdb', 'extension', '0.16.0', 'win_amd64', 'fts');
await fs.mkdir(ftsDir, { recursive: true });
await fs.writeFile(path.join(ftsDir, 'libfts.lbug_extension'), '');
await expect(hasLocalWinFtsExtension()).resolves.toBe(true);
});
it('returns true when multiple version dirs exist and only one carries the binary', async () => {
const versions = ['0.15.0', '0.16.0', '0.17.0'];
for (const v of versions) {
await fs.mkdir(path.join(tmpHome, '.lbdb', 'extension', v, 'win_amd64', 'fts'), {
recursive: true,
});
}
// Only 0.16.0 has the binary; the probe should keep iterating past empty siblings.
await fs.writeFile(
path.join(
tmpHome,
'.lbdb',
'extension',
'0.16.0',
'win_amd64',
'fts',
'libfts.lbug_extension',
),
Buffer.from('mock-binary'),
);
await expect(hasLocalWinFtsExtension()).resolves.toBe(true);
});
it('returns false when version dirs exist but none contain the binary', async () => {
// Adversarial topology raised by #1690 review: tree exists (Nix store, Bazel
// sandbox seeding, corporate MDM-prepopulated user dirs) but the actual
// libfts.lbug_extension file is absent. Probe must distinguish file from dir.
const versions = ['0.15.0', '0.16.0', '0.17.0'];
for (const v of versions) {
await fs.mkdir(path.join(tmpHome, '.lbdb', 'extension', v, 'win_amd64', 'fts'), {
recursive: true,
});
}
await expect(hasLocalWinFtsExtension()).resolves.toBe(false);
});
it('returns false when fs.readdir throws (e.g. permission denied on the extension root)', async () => {
// Cover the outer try/catch — any fs error walking the extension root is
// treated as "no binary present", matching the upstream skip-guard intent.
const eaccess = Object.assign(new Error('EACCES: permission denied'), {
code: 'EACCES',
}) as NodeJS.ErrnoException;
vi.spyOn(fs, 'readdir').mockRejectedValue(eaccess);
await expect(hasLocalWinFtsExtension()).resolves.toBe(false);
});
});