chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
const { getAsset } = require('node:sea');
|
||||
const process = require('node:process');
|
||||
const nodeModule = require('node:module');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
/**
|
||||
* Strips the "ghost" argument that Node SEA sometimes injects (argv[2] == argv[0]).
|
||||
* @param {string[]} argv
|
||||
* @param {string} execPath
|
||||
* @param {function} resolveFn
|
||||
* @returns {boolean} True if an argument was removed.
|
||||
*/
|
||||
function sanitizeArgv(argv, execPath, resolveFn = path.resolve) {
|
||||
if (argv.length > 2) {
|
||||
const binaryAbs = execPath;
|
||||
const arg2Abs = resolveFn(argv[2]);
|
||||
if (binaryAbs === arg2Abs) {
|
||||
argv.splice(2, 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a string for use in file paths.
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
function getSafeName(name) {
|
||||
return (name || 'unknown').toString().replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the integrity of the runtime directory against the manifest.
|
||||
* @param {string} dir
|
||||
* @param {object} manifest
|
||||
* @param {object} fsMod
|
||||
* @param {object} cryptoMod
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function verifyIntegrity(dir, manifest, fsMod = fs, cryptoMod = crypto) {
|
||||
try {
|
||||
const calculateHash = (filePath) => {
|
||||
const hash = cryptoMod.createHash('sha256');
|
||||
const fd = fsMod.openSync(filePath, 'r');
|
||||
const buffer = new Uint8Array(65536); // 64KB
|
||||
try {
|
||||
let bytesRead = 0;
|
||||
while (
|
||||
(bytesRead = fsMod.readSync(fd, buffer, 0, buffer.length, null)) !== 0
|
||||
) {
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
}
|
||||
} finally {
|
||||
fsMod.closeSync(fd);
|
||||
}
|
||||
return hash.digest('hex');
|
||||
};
|
||||
|
||||
if (calculateHash(path.join(dir, 'gemini.mjs')) !== manifest.mainHash)
|
||||
return false;
|
||||
if (manifest.files) {
|
||||
for (const file of manifest.files) {
|
||||
if (calculateHash(path.join(dir, file.path)) !== file.hash)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the runtime directory, extracting assets if necessary.
|
||||
* @param {object} manifest
|
||||
* @param {function} getAssetFn
|
||||
* @param {object} deps Dependencies (fs, os, path, processEnv)
|
||||
* @returns {string} The path to the prepared runtime directory.
|
||||
*/
|
||||
function prepareRuntime(manifest, getAssetFn, deps = {}) {
|
||||
const fsMod = deps.fs || fs;
|
||||
const osMod = deps.os || os;
|
||||
const pathMod = deps.path || path;
|
||||
const processEnv = deps.processEnv || process.env;
|
||||
const processPid = deps.processPid || process.pid;
|
||||
const processUid =
|
||||
deps.processUid || (process.getuid ? process.getuid() : 'unknown');
|
||||
|
||||
const version = manifest.version || '0.0.0';
|
||||
const safeVersion = getSafeName(version);
|
||||
const userInfo = osMod.userInfo();
|
||||
const username =
|
||||
userInfo.username || processEnv.USER || processUid || 'unknown';
|
||||
const safeUsername = getSafeName(username);
|
||||
|
||||
let tempBase = osMod.tmpdir();
|
||||
|
||||
if (process.platform === 'win32' && processEnv.LOCALAPPDATA) {
|
||||
const appDir = pathMod.join(processEnv.LOCALAPPDATA, 'Google', 'GeminiCLI');
|
||||
try {
|
||||
if (!fsMod.existsSync(appDir)) {
|
||||
fsMod.mkdirSync(appDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
tempBase = appDir;
|
||||
} catch {
|
||||
// Fallback to tmpdir
|
||||
}
|
||||
}
|
||||
|
||||
const finalRuntimeDir = pathMod.join(
|
||||
tempBase,
|
||||
`gemini-runtime-${safeVersion}-${safeUsername}`,
|
||||
);
|
||||
|
||||
let runtimeDir;
|
||||
let useExisting = false;
|
||||
|
||||
const isSecure = (dir) => {
|
||||
try {
|
||||
const stat = fsMod.lstatSync(dir);
|
||||
if (!stat.isDirectory()) return false;
|
||||
if (processUid !== 'unknown' && stat.uid !== processUid) return false;
|
||||
// Skip strict permission check on Windows as it's unreliable with standard fs.stat
|
||||
if (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o700)
|
||||
return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if (fsMod.existsSync(finalRuntimeDir)) {
|
||||
if (isSecure(finalRuntimeDir)) {
|
||||
if (
|
||||
verifyIntegrity(finalRuntimeDir, manifest, fsMod, deps.crypto || crypto)
|
||||
) {
|
||||
runtimeDir = finalRuntimeDir;
|
||||
useExisting = true;
|
||||
} else {
|
||||
try {
|
||||
fsMod.rmSync(finalRuntimeDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
fsMod.rmSync(finalRuntimeDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (!useExisting) {
|
||||
const setupDir = pathMod.join(
|
||||
tempBase,
|
||||
`gemini-setup-${processPid}-${Date.now()}`,
|
||||
);
|
||||
|
||||
try {
|
||||
fsMod.mkdirSync(setupDir, { recursive: true, mode: 0o700 });
|
||||
const writeToSetup = (assetKey, relPath) => {
|
||||
const content = getAssetFn(assetKey);
|
||||
if (!content) return;
|
||||
const destPath = pathMod.join(setupDir, relPath);
|
||||
const destDir = pathMod.dirname(destPath);
|
||||
if (!fsMod.existsSync(destDir))
|
||||
fsMod.mkdirSync(destDir, { recursive: true, mode: 0o700 });
|
||||
fsMod.writeFileSync(destPath, new Uint8Array(content), {
|
||||
mode: 0o755,
|
||||
});
|
||||
};
|
||||
writeToSetup('gemini.mjs', 'gemini.mjs');
|
||||
if (manifest.files) {
|
||||
for (const file of manifest.files) {
|
||||
writeToSetup(file.key, file.path);
|
||||
}
|
||||
}
|
||||
try {
|
||||
fsMod.renameSync(setupDir, finalRuntimeDir);
|
||||
runtimeDir = finalRuntimeDir;
|
||||
} catch (renameErr) {
|
||||
if (
|
||||
fsMod.existsSync(finalRuntimeDir) &&
|
||||
isSecure(finalRuntimeDir) &&
|
||||
verifyIntegrity(
|
||||
finalRuntimeDir,
|
||||
manifest,
|
||||
fsMod,
|
||||
deps.crypto || crypto,
|
||||
)
|
||||
) {
|
||||
runtimeDir = finalRuntimeDir;
|
||||
try {
|
||||
fsMod.rmSync(setupDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
} else {
|
||||
throw renameErr;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'Fatal Error: Failed to setup secure runtime. Please try running again and if error persists please reinstall.',
|
||||
e,
|
||||
);
|
||||
try {
|
||||
fsMod.rmSync(setupDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return runtimeDir;
|
||||
}
|
||||
|
||||
// --- Main Execution ---
|
||||
|
||||
async function main(getAssetFn = getAsset) {
|
||||
process.env.IS_BINARY = 'true';
|
||||
|
||||
if (nodeModule.enableCompileCache) {
|
||||
nodeModule.enableCompileCache();
|
||||
}
|
||||
|
||||
process.noDeprecation = true;
|
||||
|
||||
sanitizeArgv(process.argv, process.execPath);
|
||||
|
||||
const manifestJson = getAssetFn('manifest.json', 'utf8');
|
||||
if (!manifestJson) {
|
||||
console.error('Fatal Error: Corrupted binary. Please reinstall.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(manifestJson);
|
||||
|
||||
const runtimeDir = prepareRuntime(manifest, getAssetFn, {
|
||||
fs,
|
||||
os,
|
||||
path,
|
||||
processEnv: process.env,
|
||||
crypto,
|
||||
});
|
||||
|
||||
const mainPath = path.join(runtimeDir, 'gemini.mjs');
|
||||
|
||||
await import(pathToFileURL(mainPath).href).catch((err) => {
|
||||
console.error('Fatal Error: Failed to launch. Please reinstall.', err);
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// Only execute if this is the main module (standard Node behavior)
|
||||
// or if explicitly running as the SEA entry point (heuristic).
|
||||
if (require.main === module) {
|
||||
main().catch((err) => {
|
||||
console.error('Unhandled error in sea-launch:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sanitizeArgv,
|
||||
getSafeName,
|
||||
verifyIntegrity,
|
||||
prepareRuntime,
|
||||
main,
|
||||
};
|
||||
@@ -0,0 +1,799 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import process from 'node:process';
|
||||
import {
|
||||
sanitizeArgv,
|
||||
getSafeName,
|
||||
verifyIntegrity,
|
||||
prepareRuntime,
|
||||
main,
|
||||
} from './sea-launch.cjs';
|
||||
|
||||
// Mocking fs and os
|
||||
// We need to use vi.mock factory for ESM mocking of built-in modules in Vitest
|
||||
vi.mock('node:fs', async () => {
|
||||
const fsMock = {
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
existsSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
readFileSync: vi.fn().mockReturnValue('content'),
|
||||
lstatSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
openSync: vi.fn(),
|
||||
readSync: vi.fn(),
|
||||
closeSync: vi.fn(),
|
||||
};
|
||||
return {
|
||||
default: fsMock,
|
||||
...fsMock,
|
||||
};
|
||||
});
|
||||
vi.mock('fs', async () => {
|
||||
const fsMock = {
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
existsSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
readFileSync: vi.fn().mockReturnValue('content'),
|
||||
lstatSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
openSync: vi.fn(),
|
||||
readSync: vi.fn(),
|
||||
closeSync: vi.fn(),
|
||||
};
|
||||
return {
|
||||
default: fsMock,
|
||||
...fsMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:os', async () => {
|
||||
const osMock = {
|
||||
userInfo: () => ({ username: 'user' }),
|
||||
tmpdir: () => '/tmp',
|
||||
};
|
||||
return {
|
||||
default: osMock,
|
||||
...osMock,
|
||||
};
|
||||
});
|
||||
vi.mock('os', async () => {
|
||||
const osMock = {
|
||||
userInfo: () => ({ username: 'user' }),
|
||||
tmpdir: () => '/tmp',
|
||||
};
|
||||
return {
|
||||
default: osMock,
|
||||
...osMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe('sea-launch', () => {
|
||||
describe('main', () => {
|
||||
it('executes main logic', async () => {
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
||||
const consoleSpy = vi
|
||||
.spyOn(globalThis.console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const mockGetAsset = vi.fn((key) => {
|
||||
if (key === 'manifest.json')
|
||||
return JSON.stringify({ version: '1.0.0', mainHash: 'h1' });
|
||||
return Buffer.from('content');
|
||||
});
|
||||
|
||||
await main(mockGetAsset);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
expect(exitSpy).toHaveBeenCalled();
|
||||
|
||||
exitSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeArgv', () => {
|
||||
it('removes ghost argument when argv[2] matches execPath', () => {
|
||||
const execPath = '/bin/node';
|
||||
const argv = ['/bin/node', '/app/script.js', '/bin/node', 'arg1'];
|
||||
const resolveFn = (p) => p;
|
||||
const removed = sanitizeArgv(argv, execPath, resolveFn);
|
||||
expect(removed).toBe(true);
|
||||
expect(argv).toEqual(['/bin/node', '/app/script.js', 'arg1']);
|
||||
});
|
||||
|
||||
it('does nothing if argv[2] does not match execPath', () => {
|
||||
const execPath = '/bin/node';
|
||||
const argv = ['/bin/node', '/app/script.js', 'command', 'arg1'];
|
||||
const resolveFn = (p) => p;
|
||||
const removed = sanitizeArgv(argv, execPath, resolveFn);
|
||||
expect(removed).toBe(false);
|
||||
expect(argv).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('handles resolving relative paths', () => {
|
||||
const execPath = '/bin/node';
|
||||
const argv = ['/bin/node', '/app/script.js', './node', 'arg1'];
|
||||
const resolveFn = (p) => (p === './node' ? '/bin/node' : p);
|
||||
const removed = sanitizeArgv(argv, execPath, resolveFn);
|
||||
expect(removed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSafeName', () => {
|
||||
it('sanitizes strings', () => {
|
||||
expect(getSafeName('user@name')).toBe('user_name');
|
||||
expect(getSafeName('../path')).toBe('.._path');
|
||||
expect(getSafeName('valid-1.2')).toBe('valid-1.2');
|
||||
expect(getSafeName(undefined)).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyIntegrity', () => {
|
||||
it('returns true for matching hashes', () => {
|
||||
const dir = '/tmp/test';
|
||||
const manifest = {
|
||||
mainHash: 'hash1',
|
||||
files: [{ path: 'file.txt', hash: 'hash2' }],
|
||||
};
|
||||
|
||||
const mockFs = {
|
||||
openSync: vi.fn((p) => {
|
||||
if (p.endsWith('gemini.mjs')) return 10;
|
||||
if (p.endsWith('file.txt')) return 20;
|
||||
throw new Error('Not found');
|
||||
}),
|
||||
readSync: vi.fn((fd, buffer) => {
|
||||
let content = '';
|
||||
if (fd === 10) content = 'content1';
|
||||
if (fd === 20) content = 'content2';
|
||||
|
||||
// Simulate simple read: write content to buffer and return length once, then return 0
|
||||
if (!buffer._readDone) {
|
||||
const buf = Buffer.from(content);
|
||||
buf.copy(buffer);
|
||||
buffer._readDone = true;
|
||||
return buf.length;
|
||||
} else {
|
||||
buffer._readDone = false; // Reset for next file
|
||||
return 0;
|
||||
}
|
||||
}),
|
||||
closeSync: vi.fn(),
|
||||
};
|
||||
|
||||
const mockCrypto = {
|
||||
createHash: vi.fn(() => ({
|
||||
update: vi.fn(function (content) {
|
||||
this._content =
|
||||
(this._content || '') + Buffer.from(content).toString();
|
||||
return this;
|
||||
}),
|
||||
digest: vi.fn(function () {
|
||||
if (this._content === 'content1') return 'hash1';
|
||||
if (this._content === 'content2') return 'hash2';
|
||||
return 'wrong';
|
||||
}),
|
||||
})),
|
||||
};
|
||||
|
||||
expect(verifyIntegrity(dir, manifest, mockFs, mockCrypto)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for mismatched hashes', () => {
|
||||
const dir = '/tmp/test';
|
||||
const manifest = { mainHash: 'hash1' };
|
||||
|
||||
const mockFs = {
|
||||
openSync: vi.fn(() => 10),
|
||||
readSync: vi.fn((fd, buffer) => {
|
||||
if (!buffer._readDone) {
|
||||
const buf = Buffer.from('content_wrong');
|
||||
buf.copy(buffer);
|
||||
buffer._readDone = true;
|
||||
return buf.length;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
closeSync: vi.fn(),
|
||||
};
|
||||
|
||||
const mockCrypto = {
|
||||
createHash: vi.fn(() => ({
|
||||
update: vi.fn(function (content) {
|
||||
this._content =
|
||||
(this._content || '') + Buffer.from(content).toString();
|
||||
return this;
|
||||
}),
|
||||
digest: vi.fn(function () {
|
||||
return 'hash_wrong';
|
||||
}),
|
||||
})),
|
||||
};
|
||||
|
||||
expect(verifyIntegrity(dir, manifest, mockFs, mockCrypto)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when fs throws error', () => {
|
||||
const dir = '/tmp/test';
|
||||
const manifest = { mainHash: 'hash1' };
|
||||
const mockFs = {
|
||||
openSync: vi.fn(() => {
|
||||
throw new Error('FS Error');
|
||||
}),
|
||||
};
|
||||
const mockCrypto = { createHash: vi.fn() };
|
||||
expect(verifyIntegrity(dir, manifest, mockFs, mockCrypto)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareRuntime', () => {
|
||||
const mockManifest = {
|
||||
version: '1.0.0',
|
||||
mainHash: 'h1',
|
||||
files: [{ key: 'f1', path: 'p1', hash: 'h1' }],
|
||||
};
|
||||
const mockGetAsset = vi.fn();
|
||||
const S_IFDIR = 0o40000;
|
||||
const MODE_700 = 0o700;
|
||||
|
||||
it('reuses existing runtime if secure and valid', () => {
|
||||
const deps = {
|
||||
fs: {
|
||||
existsSync: vi.fn(() => true),
|
||||
rmSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
openSync: vi.fn(() => 1),
|
||||
readSync: vi.fn((fd, buffer) => {
|
||||
if (!buffer._readDone) {
|
||||
buffer._readDone = true;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
closeSync: vi.fn(),
|
||||
lstatSync: vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
uid: 1000,
|
||||
mode: S_IFDIR | MODE_700,
|
||||
})),
|
||||
},
|
||||
os: {
|
||||
userInfo: () => ({ username: 'user' }),
|
||||
tmpdir: () => '/tmp',
|
||||
},
|
||||
path: path,
|
||||
processEnv: {},
|
||||
crypto: {
|
||||
createHash: vi.fn(() => {
|
||||
const hash = {
|
||||
update: vi.fn().mockReturnThis(),
|
||||
digest: vi.fn(() => 'h1'),
|
||||
};
|
||||
return hash;
|
||||
}),
|
||||
},
|
||||
processUid: 1000,
|
||||
};
|
||||
|
||||
deps.fs.readFileSync.mockReturnValue('content');
|
||||
|
||||
const runtime = prepareRuntime(mockManifest, mockGetAsset, deps);
|
||||
expect(runtime).toContain('gemini-runtime-1.0.0-user');
|
||||
expect(deps.fs.rmSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recreates runtime if existing has wrong owner', () => {
|
||||
const deps = {
|
||||
fs: {
|
||||
existsSync: vi.fn().mockReturnValueOnce(true).mockReturnValue(false),
|
||||
rmSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
readFileSync: vi.fn().mockReturnValue('content'),
|
||||
openSync: vi.fn(() => 1),
|
||||
readSync: vi.fn((fd, buffer) => {
|
||||
if (!buffer._readDone) {
|
||||
buffer._readDone = true;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
closeSync: vi.fn(),
|
||||
lstatSync: vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
uid: 999, // Wrong UID
|
||||
mode: S_IFDIR | MODE_700,
|
||||
})),
|
||||
},
|
||||
os: {
|
||||
userInfo: () => ({ username: 'user' }),
|
||||
tmpdir: () => '/tmp',
|
||||
},
|
||||
path: path,
|
||||
processEnv: {},
|
||||
crypto: {
|
||||
createHash: vi.fn(() => {
|
||||
const hash = {
|
||||
update: vi.fn().mockReturnThis(),
|
||||
digest: vi.fn(() => 'h1'),
|
||||
};
|
||||
return hash;
|
||||
}),
|
||||
},
|
||||
processUid: 1000,
|
||||
processPid: 123,
|
||||
};
|
||||
|
||||
mockGetAsset.mockReturnValue(Buffer.from('asset_content'));
|
||||
|
||||
prepareRuntime(mockManifest, mockGetAsset, deps);
|
||||
|
||||
expect(deps.fs.rmSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('gemini-runtime'),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(deps.fs.mkdirSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('gemini-setup'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('recreates runtime if existing has wrong permissions', () => {
|
||||
const deps = {
|
||||
fs: {
|
||||
existsSync: vi.fn().mockReturnValueOnce(true).mockReturnValue(false),
|
||||
rmSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
readFileSync: vi.fn().mockReturnValue('content'),
|
||||
openSync: vi.fn(() => 1),
|
||||
readSync: vi.fn((fd, buffer) => {
|
||||
if (!buffer._readDone) {
|
||||
buffer._readDone = true;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
closeSync: vi.fn(),
|
||||
lstatSync: vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
uid: 1000,
|
||||
mode: S_IFDIR | 0o777, // Too open
|
||||
})),
|
||||
},
|
||||
os: {
|
||||
userInfo: () => ({ username: 'user' }),
|
||||
tmpdir: () => '/tmp',
|
||||
},
|
||||
path: path,
|
||||
processEnv: {},
|
||||
crypto: {
|
||||
createHash: vi.fn(() => {
|
||||
const hash = {
|
||||
update: vi.fn().mockReturnThis(),
|
||||
digest: vi.fn(() => 'h1'),
|
||||
};
|
||||
return hash;
|
||||
}),
|
||||
},
|
||||
processUid: 1000,
|
||||
processPid: 123,
|
||||
};
|
||||
|
||||
mockGetAsset.mockReturnValue(Buffer.from('asset_content'));
|
||||
|
||||
prepareRuntime(mockManifest, mockGetAsset, deps);
|
||||
|
||||
expect(deps.fs.rmSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('gemini-runtime'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates new runtime if existing is invalid (integrity check)', () => {
|
||||
const deps = {
|
||||
fs: {
|
||||
existsSync: vi.fn().mockReturnValueOnce(true).mockReturnValue(false),
|
||||
rmSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
readFileSync: vi.fn().mockReturnValue('wrong_content'),
|
||||
openSync: vi.fn(() => 1),
|
||||
readSync: vi.fn((fd, buffer) => {
|
||||
if (!buffer._readDone) {
|
||||
buffer._readDone = true;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
closeSync: vi.fn(),
|
||||
lstatSync: vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
uid: 1000,
|
||||
mode: S_IFDIR | MODE_700,
|
||||
})),
|
||||
},
|
||||
os: {
|
||||
userInfo: () => ({ username: 'user' }),
|
||||
tmpdir: () => '/tmp',
|
||||
},
|
||||
path: path,
|
||||
processEnv: {},
|
||||
crypto: {
|
||||
createHash: vi.fn(() => {
|
||||
const hash = {
|
||||
update: vi.fn().mockReturnThis(),
|
||||
digest: vi.fn(() => 'hash_calculated'),
|
||||
};
|
||||
return hash;
|
||||
}),
|
||||
},
|
||||
processUid: 1000,
|
||||
processPid: 123,
|
||||
};
|
||||
|
||||
mockGetAsset.mockReturnValue(Buffer.from('asset_content'));
|
||||
|
||||
prepareRuntime(mockManifest, mockGetAsset, deps);
|
||||
|
||||
expect(deps.fs.rmSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('gemini-runtime'),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(deps.fs.mkdirSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('gemini-setup'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles rename race condition: uses target if secure and valid', () => {
|
||||
const deps = {
|
||||
fs: {
|
||||
existsSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
renameSync: vi.fn(() => {
|
||||
throw new Error('Rename failed');
|
||||
}),
|
||||
readFileSync: vi.fn().mockReturnValue('content'),
|
||||
openSync: vi.fn(() => 1),
|
||||
readSync: vi.fn((fd, buffer) => {
|
||||
if (!buffer._readDone) {
|
||||
buffer._readDone = true;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
closeSync: vi.fn(),
|
||||
lstatSync: vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
uid: 1000,
|
||||
mode: S_IFDIR | MODE_700,
|
||||
})),
|
||||
},
|
||||
os: {
|
||||
userInfo: () => ({ username: 'user' }),
|
||||
tmpdir: () => '/tmp',
|
||||
},
|
||||
path: path,
|
||||
processEnv: {},
|
||||
crypto: {
|
||||
createHash: vi.fn(() => {
|
||||
const hash = {
|
||||
update: vi.fn().mockReturnThis(),
|
||||
digest: vi.fn(() => 'h1'),
|
||||
};
|
||||
return hash;
|
||||
}),
|
||||
},
|
||||
processUid: 1000,
|
||||
processPid: 123,
|
||||
};
|
||||
|
||||
// 1. Initial exists check -> false
|
||||
// 2. mkdir checks (destDir) -> false
|
||||
// 3. renameSync -> throws
|
||||
// 4. existsSync (race check) -> true
|
||||
deps.fs.existsSync
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValue(true);
|
||||
|
||||
mockGetAsset.mockReturnValue(Buffer.from('asset_content'));
|
||||
|
||||
const runtime = prepareRuntime(mockManifest, mockGetAsset, deps);
|
||||
|
||||
expect(deps.fs.renameSync).toHaveBeenCalled();
|
||||
expect(runtime).toContain('gemini-runtime');
|
||||
expect(deps.fs.rmSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('gemini-setup'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles rename race condition: fails if target is insecure', () => {
|
||||
const deps = {
|
||||
fs: {
|
||||
existsSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
renameSync: vi.fn(() => {
|
||||
throw new Error('Rename failed');
|
||||
}),
|
||||
readFileSync: vi.fn().mockReturnValue('content'),
|
||||
openSync: vi.fn(() => 1),
|
||||
readSync: vi.fn((fd, buffer) => {
|
||||
if (!buffer._readDone) {
|
||||
buffer._readDone = true;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
closeSync: vi.fn(),
|
||||
lstatSync: vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
uid: 999, // Wrong UID
|
||||
mode: S_IFDIR | MODE_700,
|
||||
})),
|
||||
},
|
||||
os: {
|
||||
userInfo: () => ({ username: 'user' }),
|
||||
tmpdir: () => '/tmp',
|
||||
},
|
||||
path: path,
|
||||
processEnv: {},
|
||||
crypto: {
|
||||
createHash: vi.fn(() => {
|
||||
const hash = {
|
||||
update: vi.fn().mockReturnThis(),
|
||||
digest: vi.fn(() => 'h1'),
|
||||
};
|
||||
return hash;
|
||||
}),
|
||||
},
|
||||
processUid: 1000,
|
||||
processPid: 123,
|
||||
};
|
||||
|
||||
deps.fs.existsSync
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValue(true);
|
||||
|
||||
mockGetAsset.mockReturnValue(Buffer.from('asset_content'));
|
||||
|
||||
// Mock process.exit and console.error
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
||||
const consoleSpy = vi
|
||||
.spyOn(globalThis.console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
prepareRuntime(mockManifest, mockGetAsset, deps);
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
exitSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('uses LOCALAPPDATA on Windows if available', () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const deps = {
|
||||
fs: {
|
||||
existsSync: vi.fn().mockReturnValue(false),
|
||||
mkdirSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
readFileSync: vi.fn().mockReturnValue('content'),
|
||||
openSync: vi.fn(() => 1),
|
||||
readSync: vi.fn((fd, buffer) => {
|
||||
if (!buffer._readDone) {
|
||||
buffer._readDone = true;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
closeSync: vi.fn(),
|
||||
lstatSync: vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
uid: 0,
|
||||
mode: S_IFDIR | MODE_700,
|
||||
})),
|
||||
},
|
||||
os: {
|
||||
userInfo: () => ({ username: 'user' }),
|
||||
tmpdir: () => 'C:\\Temp',
|
||||
},
|
||||
path: {
|
||||
join: (...args) => args.join('\\'),
|
||||
dirname: (p) => p.split('\\').slice(0, -1).join('\\'),
|
||||
resolve: (p) => p,
|
||||
},
|
||||
processEnv: {
|
||||
LOCALAPPDATA: 'C:\\Users\\User\\AppData\\Local',
|
||||
},
|
||||
crypto: {
|
||||
createHash: vi.fn(() => {
|
||||
const hash = {
|
||||
update: vi.fn().mockReturnThis(),
|
||||
digest: vi.fn(() => 'h1'),
|
||||
};
|
||||
return hash;
|
||||
}),
|
||||
},
|
||||
processUid: 'unknown',
|
||||
};
|
||||
|
||||
prepareRuntime(mockManifest, mockGetAsset, deps);
|
||||
|
||||
expect(deps.fs.mkdirSync).toHaveBeenCalledWith(
|
||||
'C:\\Users\\User\\AppData\\Local\\Google\\GeminiCLI',
|
||||
expect.objectContaining({ recursive: true }),
|
||||
);
|
||||
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to tmpdir on Windows if LOCALAPPDATA is missing', () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const deps = {
|
||||
fs: {
|
||||
existsSync: vi.fn().mockReturnValue(false),
|
||||
mkdirSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
readFileSync: vi.fn().mockReturnValue('content'),
|
||||
openSync: vi.fn(() => 1),
|
||||
readSync: vi.fn((fd, buffer) => {
|
||||
if (!buffer._readDone) {
|
||||
buffer._readDone = true;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
closeSync: vi.fn(),
|
||||
lstatSync: vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
uid: 0,
|
||||
mode: S_IFDIR | MODE_700,
|
||||
})),
|
||||
},
|
||||
os: {
|
||||
userInfo: () => ({ username: 'user' }),
|
||||
tmpdir: () => 'C:\\Temp',
|
||||
},
|
||||
path: {
|
||||
join: (...args) => args.join('\\'),
|
||||
dirname: (p) => p.split('\\').slice(0, -1).join('\\'),
|
||||
resolve: (p) => p,
|
||||
},
|
||||
processEnv: {}, // Missing LOCALAPPDATA
|
||||
crypto: {
|
||||
createHash: vi.fn(() => {
|
||||
const hash = {
|
||||
update: vi.fn().mockReturnThis(),
|
||||
digest: vi.fn(() => 'h1'),
|
||||
};
|
||||
return hash;
|
||||
}),
|
||||
},
|
||||
processUid: 'unknown',
|
||||
};
|
||||
|
||||
const runtime = prepareRuntime(mockManifest, mockGetAsset, deps);
|
||||
|
||||
// Should use tmpdir
|
||||
expect(runtime).toContain('C:\\Temp');
|
||||
expect(runtime).not.toContain('Google\\GeminiCLI');
|
||||
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to tmpdir on Windows if mkdir fails', () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const deps = {
|
||||
fs: {
|
||||
existsSync: vi.fn().mockReturnValue(false),
|
||||
mkdirSync: vi.fn((p) => {
|
||||
if (typeof p === 'string' && p.includes('Google\\GeminiCLI')) {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
}),
|
||||
rmSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
readFileSync: vi.fn().mockReturnValue('content'),
|
||||
openSync: vi.fn(() => 1),
|
||||
readSync: vi.fn((fd, buffer) => {
|
||||
if (!buffer._readDone) {
|
||||
buffer._readDone = true;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
closeSync: vi.fn(),
|
||||
lstatSync: vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
uid: 0,
|
||||
mode: S_IFDIR | MODE_700,
|
||||
})),
|
||||
},
|
||||
os: {
|
||||
userInfo: () => ({ username: 'user' }),
|
||||
tmpdir: () => 'C:\\Temp',
|
||||
},
|
||||
path: {
|
||||
join: (...args) => args.join('\\'),
|
||||
dirname: (p) => p.split('\\').slice(0, -1).join('\\'),
|
||||
resolve: (p) => p,
|
||||
},
|
||||
processEnv: {
|
||||
LOCALAPPDATA: 'C:\\Users\\User\\AppData\\Local',
|
||||
},
|
||||
crypto: {
|
||||
createHash: vi.fn(() => {
|
||||
const hash = {
|
||||
update: vi.fn().mockReturnThis(),
|
||||
digest: vi.fn(() => 'h1'),
|
||||
};
|
||||
return hash;
|
||||
}),
|
||||
},
|
||||
processUid: 'unknown',
|
||||
};
|
||||
|
||||
const runtime = prepareRuntime(mockManifest, mockGetAsset, deps);
|
||||
|
||||
// Should use tmpdir
|
||||
expect(runtime).toContain('C:\\Temp');
|
||||
expect(deps.fs.mkdirSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Google\\GeminiCLI'),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user