diff --git a/packages/safe-chain-bun/bin/safe-chain-bun.js b/packages/safe-chain-bun/bin/safe-chain-bun.js index d016561..115d42b 100755 --- a/packages/safe-chain-bun/bin/safe-chain-bun.js +++ b/packages/safe-chain-bun/bin/safe-chain-bun.js @@ -1,10 +1,6 @@ #!/usr/bin/env node -// Placeholder for setup function - will be implemented in next step -function setup(configFile) { - console.log("Setup functionality coming soon..."); - console.log("Target config file:", configFile || "~/.bunfig.toml"); -} +import { setup } from "../src/setup.js"; if (process.argv.length < 3) { console.error("No command provided. Please provide a command to execute."); diff --git a/packages/safe-chain-bun/src/setup.js b/packages/safe-chain-bun/src/setup.js new file mode 100644 index 0000000..ef31316 --- /dev/null +++ b/packages/safe-chain-bun/src/setup.js @@ -0,0 +1,127 @@ +import fs from "fs"; +import path from "path"; +import os from "os"; + +/** + * Main setup function that registers safe-chain-bun as a security scanner + * @param {string|undefined} configFile - Optional path to specific bunfig.toml file + */ +export function setup(configFile) { + try { + const targetFile = configFile ? path.resolve(configFile) : getGlobalConfigPath(); + const isGlobal = !configFile; + + if (configFile && !fs.existsSync(targetFile)) { + console.error(`❌ Config file not found: ${configFile}`); + process.exit(1); + } + + const updated = updateBunfigFile(targetFile, isGlobal); + + if (updated) { + const displayPath = isGlobal ? "~/.bunfig.toml" : configFile; + console.log(`✅ Safe-Chain-Bun registered as security scanner in ${displayPath}`); + } else { + const displayPath = isGlobal ? "~/.bunfig.toml" : configFile; + console.log(`ℹ️ Safe-Chain-Bun is already configured as security scanner in ${displayPath}`); + } + } catch (error) { + console.error(`❌ Failed to setup Safe-Chain-Bun: ${error.message}`); + process.exit(1); + } +} + +/** + * Gets the global bunfig.toml path + * @returns {string} Path to global bunfig.toml + */ +function getGlobalConfigPath() { + return path.join(os.homedir(), ".bunfig.toml"); +} + +/** + * Updates or creates a bunfig.toml file with safe-chain-bun scanner configuration + * @param {string} filePath - Path to the bunfig.toml file + * @param {boolean} isGlobal - Whether this is the global config file + * @returns {boolean} True if file was updated, false if already configured + */ +function updateBunfigFile(filePath, isGlobal) { + let content = ""; + let fileExists = fs.existsSync(filePath); + + if (fileExists) { + try { + content = fs.readFileSync(filePath, "utf8"); + } catch (error) { + throw new Error(`Failed to read ${filePath}: ${error.message}`); + } + } else if (!isGlobal) { + // For specific files, they must exist + throw new Error(`Config file does not exist: ${filePath}`); + } + + const result = addScannerToToml(content); + + if (!result.changed) { + return false; // Already configured + } + + try { + // Ensure directory exists for global config + if (isGlobal && !fileExists) { + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + console.log(`ℹ️ Created ${filePath} with Safe-Chain-Bun configuration`); + } else if (fileExists) { + console.log(`ℹ️ Updated existing bunfig.toml with Safe-Chain-Bun scanner`); + } + + fs.writeFileSync(filePath, result.content, "utf8"); + return true; + } catch (error) { + if (error.code === "EACCES") { + throw new Error(`Permission denied writing to ${filePath}`); + } + throw new Error(`Failed to write ${filePath}: ${error.message}`); + } +} + +/** + * Adds or updates the scanner configuration in TOML content + * @param {string} content - Existing TOML content + * @returns {{content: string, changed: boolean}} Updated content and change status + */ +export function addScannerToToml(content) { + const scannerLine = 'scanner = "@aikidosec/safe-chain-bun"'; + + if (content.includes(scannerLine)) { + return { content, changed: false }; + } + + const lines = content.split(/[\r\n\u2028\u2029]+/); + const installSecurityRegex = /^\[install\.security\]$/; + const scannerRegex = /^scanner\s*=.*$/; + + const securitySectionIndex = lines.findIndex(line => installSecurityRegex.test(line)); + + if (securitySectionIndex >= 0) { + const scannerLineIndex = lines.findIndex((line, index) => + index > securitySectionIndex && scannerRegex.test(line) + ); + + if (scannerLineIndex >= 0) { + lines[scannerLineIndex] = scannerLine; + } else { + lines.splice(securitySectionIndex + 1, 0, scannerLine); + } + } else { + if (lines[lines.length - 1] !== '') { + lines.push(''); + } + lines.push('[install.security]'); + lines.push(scannerLine); + lines.push(''); + } + + return { content: lines.join(os.EOL), changed: true }; +} \ No newline at end of file diff --git a/packages/safe-chain-bun/src/setup.spec.js b/packages/safe-chain-bun/src/setup.spec.js new file mode 100644 index 0000000..ecd1d23 --- /dev/null +++ b/packages/safe-chain-bun/src/setup.spec.js @@ -0,0 +1,299 @@ +import { describe, it, before, after, mock } from "node:test"; +import assert from "node:assert"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import { addScannerToToml, setup } from "./setup.js"; + +describe("addScannerToToml", () => { + it("should add scanner to empty content", () => { + const result = addScannerToToml(""); + assert.strictEqual(result.changed, true); + assert.strictEqual( + result.content.trim(), + `[install.security]\nscanner = "@aikidosec/safe-chain-bun"` + ); + }); + + it("should add scanner to existing content without [install.security] section", () => { + const input = `[install]\nregistry = "https://registry.npmjs.org/"`; + const result = addScannerToToml(input); + assert.strictEqual(result.changed, true); + assert.ok(result.content.includes("[install.security]")); + assert.ok(result.content.includes('scanner = "@aikidosec/safe-chain-bun"')); + assert.ok(result.content.includes('registry = "https://registry.npmjs.org/"')); + }); + + it("should add scanner to existing [install.security] section without scanner", () => { + const input = `[install.security]\n# Some comment`; + const result = addScannerToToml(input); + assert.strictEqual(result.changed, true); + assert.ok(result.content.includes("[install.security]")); + assert.ok(result.content.includes('scanner = "@aikidosec/safe-chain-bun"')); + assert.ok(result.content.includes("# Some comment")); + }); + + it("should replace existing scanner in [install.security] section", () => { + const input = `[install.security]\nscanner = "@other/scanner"`; + const result = addScannerToToml(input); + assert.strictEqual(result.changed, true); + assert.strictEqual( + result.content.trim(), + `[install.security]\nscanner = "@aikidosec/safe-chain-bun"` + ); + }); + + it("should not change content if safe-chain-bun scanner already configured", () => { + const input = `[install.security]\nscanner = "@aikidosec/safe-chain-bun"`; + const result = addScannerToToml(input); + assert.strictEqual(result.changed, false); + assert.strictEqual(result.content, input); + }); + + it("should handle complex TOML with multiple sections", () => { + const input = `[install] +registry = "https://registry.npmjs.org/" + +[test] +preload = ["./setup.ts"] + +[install.security] +# Security configuration +scanner = "@other/old-scanner" + +[build] +target = "node"`; + + const result = addScannerToToml(input); + assert.strictEqual(result.changed, true); + assert.ok(result.content.includes('scanner = "@aikidosec/safe-chain-bun"')); + assert.ok(!result.content.includes("@other/old-scanner")); + assert.ok(result.content.includes("registry = \"https://registry.npmjs.org/\"")); + assert.ok(result.content.includes("[test]")); + assert.ok(result.content.includes("[build]")); + }); + + it("should handle scanner with different whitespace formatting", () => { + const input = `[install.security]\nscanner="@other/scanner"`; + const result = addScannerToToml(input); + assert.strictEqual(result.changed, true); + assert.ok(result.content.includes('scanner = "@aikidosec/safe-chain-bun"')); + }); +}); + +describe("setup function", () => { + let tempDir; + let originalConsoleLog; + let originalConsoleError; + let consoleOutput; + let consoleErrors; + + before(() => { + // Create a temporary directory for test files + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "safe-chain-bun-test-")); + + // Mock console output + consoleOutput = []; + consoleErrors = []; + originalConsoleLog = console.log; + originalConsoleError = console.error; + console.log = (...args) => consoleOutput.push(args.join(" ")); + console.error = (...args) => consoleErrors.push(args.join(" ")); + }); + + after(() => { + // Cleanup + console.log = originalConsoleLog; + console.error = originalConsoleError; + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + // Helper to reset console mocks before each test + const resetConsole = () => { + consoleOutput.length = 0; + consoleErrors.length = 0; + }; + + it("should create new global config file", () => { + resetConsole(); + const mockHomedir = mock.method(os, "homedir", () => tempDir); + const globalConfigPath = path.join(tempDir, ".bunfig.toml"); + + // Ensure file doesn't exist + if (fs.existsSync(globalConfigPath)) { + fs.unlinkSync(globalConfigPath); + } + + setup(); + + assert.ok(fs.existsSync(globalConfigPath)); + const content = fs.readFileSync(globalConfigPath, "utf8"); + assert.ok(content.includes("[install.security]")); + assert.ok(content.includes('scanner = "@aikidosec/safe-chain-bun"')); + assert.ok(consoleOutput.some(msg => msg.includes("✅ Safe-Chain-Bun registered"))); + + mockHomedir.mock.restore(); + }); + + it("should update existing global config file", () => { + resetConsole(); + const mockHomedir = mock.method(os, "homedir", () => tempDir); + const globalConfigPath = path.join(tempDir, ".bunfig.toml"); + + // Create existing config + fs.writeFileSync(globalConfigPath, `[install]\nregistry = "https://registry.npmjs.org/"`); + + setup(); + + const content = fs.readFileSync(globalConfigPath, "utf8"); + assert.ok(content.includes("[install.security]")); + assert.ok(content.includes('scanner = "@aikidosec/safe-chain-bun"')); + assert.ok(content.includes('registry = "https://registry.npmjs.org/"')); + assert.ok(consoleOutput.some(msg => msg.includes("✅ Safe-Chain-Bun registered"))); + + mockHomedir.mock.restore(); + }); + + it("should setup specific existing config file", () => { + resetConsole(); + const specificConfigPath = path.join(tempDir, "project-bunfig.toml"); + fs.writeFileSync(specificConfigPath, `[build]\ntarget = "node"`); + + setup(specificConfigPath); + + const content = fs.readFileSync(specificConfigPath, "utf8"); + assert.ok(content.includes("[install.security]")); + assert.ok(content.includes('scanner = "@aikidosec/safe-chain-bun"')); + assert.ok(content.includes('target = "node"')); + assert.ok(consoleOutput.some(msg => msg.includes("✅ Safe-Chain-Bun registered"))); + }); + + it("should report when already configured", () => { + resetConsole(); + const mockHomedir = mock.method(os, "homedir", () => tempDir); + const globalConfigPath = path.join(tempDir, ".bunfig.toml"); + + // Create already configured file + fs.writeFileSync(globalConfigPath, `[install.security]\nscanner = "@aikidosec/safe-chain-bun"`); + + setup(); + + assert.ok(consoleOutput.some(msg => msg.includes("ℹ️ Safe-Chain-Bun is already configured"))); + + mockHomedir.mock.restore(); + }); + + it("should fail when specific config file doesn't exist", () => { + resetConsole(); + const nonExistentPath = path.join(tempDir, "does-not-exist.toml"); + + let exitCode; + const mockExit = mock.method(process, "exit", (code) => { + exitCode = code; + }); + + setup(nonExistentPath); + + assert.strictEqual(exitCode, 1); + assert.ok(consoleErrors.some(msg => msg.includes("❌ Config file not found"))); + + mockExit.mock.restore(); + }); + + it("should handle permission errors gracefully", () => { + resetConsole(); + const mockHomedir = mock.method(os, "homedir", () => tempDir); + const globalConfigPath = path.join(tempDir, ".bunfig.toml"); + + // Create a directory where the file should be to cause EACCES error + if (fs.existsSync(globalConfigPath)) { + fs.unlinkSync(globalConfigPath); + } + fs.mkdirSync(globalConfigPath); + + let exitCode; + const mockExit = mock.method(process, "exit", (code) => { + exitCode = code; + }); + + setup(); + + assert.strictEqual(exitCode, 1); + assert.ok(consoleErrors.some(msg => msg.includes("❌ Failed to setup Safe-Chain-Bun"))); + + // Cleanup + fs.rmSync(globalConfigPath, { recursive: true, force: true }); + + mockExit.mock.restore(); + mockHomedir.mock.restore(); + }); +}); + +describe("Line endings compatibility", () => { + it("should handle Unix line endings (\\n)", () => { + const input = `[install]\nregistry = "https://registry.npmjs.org/"\n\n[build]\ntarget = "node"`; + const result = addScannerToToml(input); + assert.strictEqual(result.changed, true); + assert.ok(result.content.includes("[install.security]")); + assert.ok(result.content.includes('scanner = "@aikidosec/safe-chain-bun"')); + assert.ok(result.content.includes("registry = \"https://registry.npmjs.org/\"")); + }); + + it("should handle Windows line endings (\\r\\n)", () => { + const input = `[install]\r\nregistry = "https://registry.npmjs.org/"\r\n\r\n[build]\r\ntarget = "node"`; + const result = addScannerToToml(input); + assert.strictEqual(result.changed, true); + assert.ok(result.content.includes("[install.security]")); + assert.ok(result.content.includes('scanner = "@aikidosec/safe-chain-bun"')); + assert.ok(result.content.includes("registry = \"https://registry.npmjs.org/\"")); + }); + + it("should handle mixed line endings", () => { + const input = `[install]\r\nregistry = "https://registry.npmjs.org/"\n\n[build]\r\ntarget = "node"`; + const result = addScannerToToml(input); + assert.strictEqual(result.changed, true); + assert.ok(result.content.includes("[install.security]")); + assert.ok(result.content.includes('scanner = "@aikidosec/safe-chain-bun"')); + assert.ok(result.content.includes("registry = \"https://registry.npmjs.org/\"")); + }); + + it("should handle existing [install.security] with Windows line endings", () => { + const input = `[install.security]\r\nscanner = "@other/scanner"\r\n\r\n[build]\r\ntarget = "node"`; + const result = addScannerToToml(input); + assert.strictEqual(result.changed, true); + assert.ok(result.content.includes('scanner = "@aikidosec/safe-chain-bun"')); + assert.ok(!result.content.includes("@other/scanner")); + }); + + it("should detect already configured scanner with Windows line endings", () => { + const input = `[install.security]\r\nscanner = "@aikidosec/safe-chain-bun"\r\n\r\n[build]\r\ntarget = "node"`; + const result = addScannerToToml(input); + assert.strictEqual(result.changed, false); + assert.strictEqual(result.content, input); + }); + + it("should normalize to system line endings like safe-chain", () => { + const input = `[build]\r\ntarget = "node"\r\n`; + const result = addScannerToToml(input); + assert.strictEqual(result.changed, true); + assert.ok(result.content.includes("[install.security]")); + assert.ok(result.content.includes('scanner = "@aikidosec/safe-chain-bun"')); + + const lines = result.content.split(/\r?\n/); + const securitySectionIndex = lines.findIndex(line => line === "[install.security]"); + assert.ok(securitySectionIndex >= 0, "Should find [install.security] section"); + }); + + it("should properly detect patterns across different line endings", () => { + // Test regex patterns work correctly with different line endings + const windowsContent = `[install.security]\r\nscanner = "@aikidosec/safe-chain-bun"\r\n`; + const unixContent = `[install.security]\nscanner = "@aikidosec/safe-chain-bun"\n`; + + const windowsResult = addScannerToToml(windowsContent); + const unixResult = addScannerToToml(unixContent); + + // Both should detect as already configured + assert.strictEqual(windowsResult.changed, false); + assert.strictEqual(unixResult.changed, false); + }); +}); \ No newline at end of file