diff --git a/src/utils/config.mts b/src/utils/config.mts index 3a8685638..a5dd0d2b6 100644 --- a/src/utils/config.mts +++ b/src/utils/config.mts @@ -31,6 +31,7 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { naturalCompare } from '@socketsecurity/registry/lib/sorts' import { debugConfig } from './debug.mts' +import { getEditableJsonClass } from './editable-json.mts' import constants, { CONFIG_KEY_API_BASE_URL, CONFIG_KEY_API_PROXY, @@ -98,17 +99,18 @@ function getConfigValues(): LocalConfig { _cachedConfig = {} as LocalConfig const { socketAppDataPath } = constants if (socketAppDataPath) { - const raw = safeReadFileSync(socketAppDataPath) + const configFilePath = path.join(socketAppDataPath, 'config.json') + const raw = safeReadFileSync(configFilePath) if (raw) { try { Object.assign( _cachedConfig, JSON.parse(Buffer.from(raw, 'base64').toString()), ) - debugConfig(socketAppDataPath, true) + debugConfig(configFilePath, true) } catch (e) { - logger.warn(`Failed to parse config at ${socketAppDataPath}`) - debugConfig(socketAppDataPath, false, e) + logger.warn(`Failed to parse config at ${configFilePath}`) + debugConfig(configFilePath, false, e) } // Normalize apiKey to apiToken and persist it. // This is a one time migration per user. @@ -118,7 +120,7 @@ function getConfigValues(): LocalConfig { updateConfigValue(CONFIG_KEY_API_TOKEN, token) } } else { - mkdirSync(path.dirname(socketAppDataPath), { recursive: true }) + mkdirSync(socketAppDataPath, { recursive: true }) } } } @@ -243,6 +245,16 @@ let _cachedConfig: LocalConfig | undefined // When using --config or SOCKET_CLI_CONFIG, do not persist the config. let _configFromFlag = false +/** + * Reset config cache for testing purposes. + * This allows tests to start with a fresh config state. + * @internal + */ +export function resetConfigForTesting(): void { + _cachedConfig = undefined + _configFromFlag = false +} + export function overrideCachedConfig(jsonConfig: unknown): CResult { debugFn('notice', 'override: full config (not stored)') @@ -340,11 +352,72 @@ export function updateConfigValue( _pendingSave = true process.nextTick(() => { _pendingSave = false + // Capture the config state at write time, not at schedule time. + // This ensures all updates in the same tick are included. + const configToSave = { ...localConfig } const { socketAppDataPath } = constants if (socketAppDataPath) { + mkdirSync(socketAppDataPath, { recursive: true }) + const configFilePath = path.join(socketAppDataPath, 'config.json') + // Read existing file to preserve formatting, then update with new values. + const existingRaw = safeReadFileSync(configFilePath) + const EditableJson = getEditableJsonClass() + const editor = new EditableJson() + if (existingRaw !== undefined) { + const rawString = Buffer.isBuffer(existingRaw) + ? existingRaw.toString('utf8') + : existingRaw + try { + const decoded = Buffer.from(rawString, 'base64').toString('utf8') + editor.fromJSON(decoded) + } catch { + // If decoding fails, start fresh. + } + } else { + // Initialize empty editor for new file. + editor.create(configFilePath) + } + // Update with the captured config state. + // Note: We need to handle deletions explicitly since editor.update() only merges. + // First, get all keys from the existing content. + const existingKeys = new Set( + Object.keys(editor.content).filter(k => typeof k === 'string'), + ) + const newKeys = new Set(Object.keys(configToSave)) + + // Delete keys that are in existing but not in new config. + for (const key of existingKeys) { + if (!newKeys.has(key)) { + delete (editor.content as any)[key] + } + } + + // Now update with new values. + editor.update(configToSave) + // Use the editor's internal stringify which preserves formatting. + // Extract the formatting symbols from the content. + const INDENT_SYMBOL = Symbol.for('indent') + const NEWLINE_SYMBOL = Symbol.for('newline') + const indent = (editor.content as any)[INDENT_SYMBOL] ?? 2 + const newline = (editor.content as any)[NEWLINE_SYMBOL] ?? '\n' + + // Strip formatting symbols from content. + const contentToSave: Record = {} + for (const [key, val] of Object.entries(editor.content)) { + if (typeof key === 'string') { + contentToSave[key] = val + } + } + + // Stringify with formatting preserved. + const jsonContent = JSON.stringify( + contentToSave, + undefined, + indent, + ).replace(/\n/g, newline) writeFileSync( - socketAppDataPath, - Buffer.from(JSON.stringify(localConfig)).toString('base64'), + configFilePath, + Buffer.from(jsonContent + newline).toString('base64'), ) } }) diff --git a/src/utils/config.test.mts b/src/utils/config.test.mts index 2df4dad75..d68308e67 100644 --- a/src/utils/config.test.mts +++ b/src/utils/config.test.mts @@ -1,8 +1,14 @@ -import { promises as fs, mkdtempSync } from 'node:fs' +import { + promises as fs, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' import os from 'node:os' import path from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import { findSocketYmlSync, @@ -80,7 +86,7 @@ describe('utils/config', () => { expect(result.data).toBe(undefined) } finally { // Clean up the temporary directory. - await fs.rm(tmpDir, { force: true, recursive: true }) + rmSync(tmpDir, { force: true, recursive: true }) } }) }) diff --git a/src/utils/editable-json.mts b/src/utils/editable-json.mts new file mode 100644 index 000000000..680876e1a --- /dev/null +++ b/src/utils/editable-json.mts @@ -0,0 +1,374 @@ +/** + * @fileoverview EditableJson utility for non-destructive JSON file manipulation. + * Preserves formatting (indentation and line endings) when updating JSON files. + * This is a standalone implementation copied from @socketsecurity/lib/json/edit. + */ + +import { promises as fs } from 'node:fs' +import { setTimeout } from 'node:timers/promises' +import { isDeepStrictEqual } from 'node:util' + +// Symbols used to store formatting metadata in JSON objects. +const INDENT_SYMBOL = Symbol.for('indent') +const NEWLINE_SYMBOL = Symbol.for('newline') + +/** + * Formatting metadata for JSON files. + */ +interface JsonFormatting { + indent: string | number + newline: string +} + +/** + * Options for saving editable JSON files. + */ +interface EditableJsonSaveOptions { + /** + * Whether to ignore whitespace-only changes when determining if save is needed. + * @default false + */ + ignoreWhitespace?: boolean | undefined + /** + * Whether to sort object keys alphabetically before saving. + * @default false + */ + sort?: boolean | undefined +} + +/** + * Detect indentation from a JSON string. + * Supports space-based indentation (returns count) or mixed indentation (returns string). + */ +function detectIndent(json: string): string | number { + const match = json.match(/^[{[][\r\n]+(\s+)/m) + if (!match || !match[1]) { + return 2 + } + const indent = match[1] + if (/^ +$/.test(indent)) { + return indent.length + } + return indent +} + +/** + * Detect newline character(s) from a JSON string. + * Supports LF (\n) and CRLF (\r\n) line endings. + */ +function detectNewline(json: string): string { + const match = json.match(/\r?\n/) + return match ? match[0] : '\n' +} + +/** + * Sort object keys alphabetically. + * Creates a new object with sorted keys (does not mutate input). + */ +function sortKeys(obj: Record): Record { + const sorted: Record = { __proto__: null } as Record< + string, + unknown + > + const keys = Object.keys(obj).sort() + for (const key of keys) { + sorted[key] = obj[key] + } + return sorted +} + +/** + * Stringify JSON with specific formatting. + * Applies indentation and line ending preferences. + */ +function stringifyWithFormatting( + content: Record, + formatting: JsonFormatting, +): string { + const { indent, newline } = formatting + const format = indent === undefined || indent === null ? ' ' : indent + const eol = newline === undefined || newline === null ? '\n' : newline + return `${JSON.stringify(content, undefined, format)}\n`.replace(/\n/g, eol) +} + +/** + * Strip formatting symbols from content object. + * Removes Symbol.for('indent') and Symbol.for('newline') from the object. + */ +function stripFormattingSymbols( + content: Record, +): Record { + const { + [INDENT_SYMBOL]: _indent, + [NEWLINE_SYMBOL]: _newline, + ...rest + } = content + return rest as Record +} + +/** + * Extract formatting from content object that has symbol-based metadata. + */ +function getFormattingFromContent( + content: Record, +): JsonFormatting { + const indent = content[INDENT_SYMBOL] + const newline = content[NEWLINE_SYMBOL] + return { + indent: + indent === undefined || indent === null ? 2 : (indent as string | number), + newline: + newline === undefined || newline === null ? '\n' : (newline as string), + } +} + +/** + * Determine if content should be saved based on changes and options. + */ +function shouldSave( + currentContent: Record, + originalContent: Record | undefined, + originalFileContent: string, + options: EditableJsonSaveOptions = {}, +): boolean { + const { ignoreWhitespace = false, sort = false } = options + const content = stripFormattingSymbols(currentContent) + const sortedContent = sort ? sortKeys(content) : content + const origContent = originalContent + ? stripFormattingSymbols(originalContent) + : {} + + if (ignoreWhitespace) { + return !isDeepStrictEqual(sortedContent, origContent) + } + + const formatting = getFormattingFromContent(currentContent) + const newFileContent = stringifyWithFormatting(sortedContent, formatting) + return newFileContent.trim() !== originalFileContent.trim() +} + +/** + * Retry write operation with exponential backoff for file system issues. + */ +async function retryWrite( + filepath: string, + content: string, + retries = 3, + baseDelay = 10, +): Promise { + for (let attempt = 0; attempt <= retries; attempt++) { + try { + // eslint-disable-next-line no-await-in-loop + await fs.writeFile(filepath, content) + if (process.platform === 'win32') { + // eslint-disable-next-line no-await-in-loop + await setTimeout(50) + let accessRetries = 0 + const maxAccessRetries = 5 + while (accessRetries < maxAccessRetries) { + try { + // eslint-disable-next-line no-await-in-loop + await fs.access(filepath) + // eslint-disable-next-line no-await-in-loop + await setTimeout(10) + break + } catch { + const delay = 20 * (accessRetries + 1) + // eslint-disable-next-line no-await-in-loop + await setTimeout(delay) + accessRetries++ + } + } + } + return + } catch (err) { + const isLastAttempt = attempt === retries + const isRetriableError = + err instanceof Error && + 'code' in err && + (err.code === 'EPERM' || err.code === 'EBUSY' || err.code === 'ENOENT') + if (!isRetriableError || isLastAttempt) { + throw err + } + const delay = baseDelay * 2 ** attempt + // eslint-disable-next-line no-await-in-loop + await setTimeout(delay) + } + } +} + +/** + * Parse JSON string. + */ +function parseJson(content: string): Record { + return JSON.parse(content) as Record +} + +/** + * Read file with retry logic for file system issues. + */ +async function readFile(filepath: string): Promise { + const maxRetries = process.platform === 'win32' ? 5 : 1 + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + // eslint-disable-next-line no-await-in-loop + return await fs.readFile(filepath, 'utf8') + } catch (err) { + const isLastAttempt = attempt === maxRetries + const isEnoent = + err instanceof Error && 'code' in err && err.code === 'ENOENT' + if (!isEnoent || isLastAttempt) { + throw err + } + const delay = process.platform === 'win32' ? 50 * (attempt + 1) : 20 + // eslint-disable-next-line no-await-in-loop + await setTimeout(delay) + } + } + throw new Error('Unreachable code') +} + +/** + * EditableJson class for non-destructive JSON file manipulation. + * Preserves formatting when updating JSON files. + */ +export class EditableJson> { + private _canSave = true + private _content: Record = {} + private _path: string | undefined = undefined + private _readFileContent = '' + private _readFileJson: Record | undefined = undefined + + get content(): Readonly { + return this._content as Readonly + } + + get filename(): string { + const path = this._path + if (!path) { + return '' + } + return path + } + + get path(): string | undefined { + return this._path + } + + /** + * Create a new JSON file instance. + */ + create(path: string): this { + this._path = path + this._content = {} + this._canSave = true + return this + } + + /** + * Initialize from content object (disables saving). + */ + fromContent(data: unknown): this { + this._content = data as Record + this._canSave = false + return this + } + + /** + * Initialize from JSON string. + */ + fromJSON(data: string): this { + const parsed = parseJson(data) + const indent = detectIndent(data) + const newline = detectNewline(data) + // Use type assertion to allow symbol indexing. + ;(parsed as any)[INDENT_SYMBOL] = indent + ;(parsed as any)[NEWLINE_SYMBOL] = newline + this._content = parsed as Record + return this + } + + /** + * Load JSON file from disk. + */ + async load(path: string, create?: boolean): Promise { + this._path = path + try { + this._readFileContent = await readFile(this.filename) + this.fromJSON(this._readFileContent) + this._readFileJson = parseJson(this._readFileContent) + } catch (err) { + if (!create) { + throw err + } + // File doesn't exist and create is true - initialize empty. + this._content = {} + this._readFileContent = '' + this._readFileJson = undefined + this._canSave = true + } + return this + } + + /** + * Update content with new values. + */ + update(content: Partial): this { + this._content = { + ...this._content, + ...content, + } + return this + } + + /** + * Save JSON file to disk asynchronously. + */ + async save(options?: EditableJsonSaveOptions): Promise { + if (!this._canSave || this.content === undefined) { + throw new Error('No file path to save to') + } + if ( + !shouldSave( + this._content, + this._readFileJson as Record | undefined, + this._readFileContent, + options, + ) + ) { + return false + } + const content = stripFormattingSymbols(this._content) + const sortedContent = options?.sort ? sortKeys(content) : content + const formatting = getFormattingFromContent(this._content) + const fileContent = stringifyWithFormatting(sortedContent, formatting) + await retryWrite(this.filename, fileContent) + this._readFileContent = fileContent + this._readFileJson = parseJson(fileContent) + return true + } + + /** + * Check if save will occur based on current changes. + */ + willSave(options?: EditableJsonSaveOptions): boolean { + if (!this._canSave || this.content === undefined) { + return false + } + return shouldSave( + this._content, + this._readFileJson as Record | undefined, + this._readFileContent, + options, + ) + } +} + +/** + * Get the EditableJson class for JSON file manipulation. + */ +export function getEditableJsonClass< + T = Record, +>(): typeof EditableJson { + return EditableJson as typeof EditableJson +}