From a6b6bfeeb7b743bb97020c2310e6f9a88e7f5e69 Mon Sep 17 00:00:00 2001 From: DavidWells Date: Mon, 6 Jul 2026 11:05:11 -0700 Subject: [PATCH] fix(cli): back up corrupt state/config files instead of silently resetting A malformed .netlify/state.json or global config.json (which holds auth tokens) was silently ignored and could be silently wiped, losing the project link or credentials with no trace. The corrupt file is now copied to .corrupt. with a stderr warning naming both paths before the command proceeds. --- src/commands/base-command.ts | 3 + src/commands/main.ts | 2 + src/utils/config-guard.ts | 64 +++++++++++++++++++ tests/unit/utils/config-guard.test.ts | 92 +++++++++++++++++++++++++++ 4 files changed, 161 insertions(+) create mode 100644 src/utils/config-guard.ts create mode 100644 tests/unit/utils/config-guard.test.ts diff --git a/src/commands/base-command.ts b/src/commands/base-command.ts index 11f10bf859b..38e767e924f 100644 --- a/src/commands/base-command.ts +++ b/src/commands/base-command.ts @@ -35,6 +35,7 @@ import { logError, } from '../utils/command-helpers.js' import { handleOptionError, isOptionError } from '../utils/command-error-handler.js' +import { guardGlobalConfigFile, guardLocalStateFile } from '../utils/config-guard.js' import type { FeatureFlags } from '../utils/feature-flags.js' import { getFrameworksAPIPaths } from '../utils/frameworks-api.js' import { getSiteByName } from '../utils/get-site.js' @@ -653,6 +654,8 @@ export default class BaseCommand extends Command { // ================================================== // Retrieve Site id and build state from the state.json // ================================================== + guardGlobalConfigFile() + await guardLocalStateFile(this.workingDir) const state = new LocalState(this.workingDir) const [token] = await getToken(flags.auth) diff --git a/src/commands/main.ts b/src/commands/main.ts index f7167573f08..9aae52a5776 100644 --- a/src/commands/main.ts +++ b/src/commands/main.ts @@ -18,6 +18,7 @@ import { warn, logError, } from '../utils/command-helpers.js' +import { guardGlobalConfigFile } from '../utils/config-guard.js' import execa from '../utils/execa.js' import getCLIPackageJson from '../utils/get-cli-package-json.js' import { didEnableCompileCache } from '../utils/nodejs-compile-cache.js' @@ -155,6 +156,7 @@ ${USER_AGENT} */ // @ts-expect-error TS(7006) FIXME: Parameter 'options' implicitly has an 'any' type. const mainCommand = async function (options, command) { + guardGlobalConfigFile() const globalConfig = await getGlobalConfigStore() if (options.telemetryDisable) { diff --git a/src/utils/config-guard.ts b/src/utils/config-guard.ts new file mode 100644 index 00000000000..e90aad30a10 --- /dev/null +++ b/src/utils/config-guard.ts @@ -0,0 +1,64 @@ +import { copyFileSync, existsSync, readFileSync, statSync } from 'fs' +import { join } from 'path' + +import { findUp } from 'find-up' + +import { getPathInHome } from '../lib/settings.js' + +import { BANG, chalk } from './command-helpers.js' + +const STATE_RELATIVE_PATH = join('.netlify', 'state.json') + +const writeCorruptFileWarning = (filePath: string, backupPath: string, recoveryHint: string) => { + const bang = chalk.yellow(BANG) + process.stderr.write(` ${bang} Warning: ${filePath} contains malformed JSON and will be reset.\n`) + process.stderr.write(` ${bang} A backup of the corrupt file was saved to ${backupPath}.\n`) + process.stderr.write(` ${bang} Repair and restore the backup, or ${recoveryHint}.\n`) +} + +/** + * Detects an existing-but-unparseable JSON config file before the underlying store + * silently resets it, copies it to `.corrupt.` and warns on stderr. + * Returns the backup path when a corrupt file was found, `undefined` otherwise. + */ +export const backUpCorruptJsonFile = (filePath: string, recoveryHint: string): string | undefined => { + let raw: string + try { + raw = readFileSync(filePath, 'utf8') + } catch { + return undefined + } + if (raw.trim() === '') { + return undefined + } + try { + JSON.parse(raw) + return undefined + } catch { + const backupPath = `${filePath}.corrupt.${String(Math.round(statSync(filePath).mtimeMs))}` + if (!existsSync(backupPath)) { + copyFileSync(filePath, backupPath) + } + writeCorruptFileWarning(filePath, backupPath, recoveryHint) + return backupPath + } +} + +/** + * Guards the project-local `.netlify/state.json` (resolved with the same find-up + * semantics as `LocalState` in `@netlify/dev-utils`). + */ +export const guardLocalStateFile = async (workingDir: string): Promise => { + const statePath = (await findUp(STATE_RELATIVE_PATH, { cwd: workingDir })) ?? join(workingDir, STATE_RELATIVE_PATH) + return backUpCorruptJsonFile(statePath, `delete it and re-run ${chalk.cyanBright('netlify link')}`) +} + +/** + * Guards the global config store file (`~/.config/netlify/config.json` or platform + * equivalent) which holds auth tokens, before `getGlobalConfigStore` resets it. + */ +export const guardGlobalConfigFile = (): string | undefined => + backUpCorruptJsonFile( + getPathInHome(['config.json']), + `delete it and re-run ${chalk.cyanBright('netlify login')} to restore your auth tokens`, + ) diff --git a/tests/unit/utils/config-guard.test.ts b/tests/unit/utils/config-guard.test.ts new file mode 100644 index 00000000000..e74a8bc5d6c --- /dev/null +++ b/tests/unit/utils/config-guard.test.ts @@ -0,0 +1,92 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { backUpCorruptJsonFile, guardLocalStateFile } from '../../../src/utils/config-guard.js' + +const mockStderr = () => vi.spyOn(process.stderr, 'write').mockReturnValue(true) + +describe('config corruption guard', () => { + let dir: string + let stderrSpy: ReturnType + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'netlify-config-guard-')) + stderrSpy = mockStderr() + }) + + afterEach(() => { + stderrSpy.mockRestore() + rmSync(dir, { recursive: true, force: true }) + }) + + const stderrOutput = () => stderrSpy.mock.calls.map(([chunk]) => String(chunk)).join('') + + test('returns undefined for a missing file', () => { + expect(backUpCorruptJsonFile(join(dir, 'missing.json'), 'fix it')).toBeUndefined() + expect(stderrSpy).not.toHaveBeenCalled() + }) + + test('returns undefined for valid JSON and writes no backup', () => { + const filePath = join(dir, 'state.json') + writeFileSync(filePath, '{"siteId":"123"}') + expect(backUpCorruptJsonFile(filePath, 'fix it')).toBeUndefined() + expect(stderrSpy).not.toHaveBeenCalled() + }) + + test('returns undefined for an empty file', () => { + const filePath = join(dir, 'state.json') + writeFileSync(filePath, '') + expect(backUpCorruptJsonFile(filePath, 'fix it')).toBeUndefined() + }) + + test('backs up a corrupt file and warns on stderr naming both paths', () => { + const filePath = join(dir, 'state.json') + writeFileSync(filePath, '{ totally not json') + + const backupPath = backUpCorruptJsonFile(filePath, 'run netlify link') + + expect(backupPath).toMatch(/state\.json\.corrupt\.\d+$/) + expect(existsSync(backupPath ?? '')).toBe(true) + expect(readFileSync(backupPath ?? '', 'utf8')).toBe('{ totally not json') + + const output = stderrOutput() + expect(output).toContain(filePath) + expect(output).toContain(backupPath) + expect(output).toContain('run netlify link') + }) + + test('does not overwrite an existing backup for the same mtime', () => { + const filePath = join(dir, 'state.json') + writeFileSync(filePath, 'garbage') + + const first = backUpCorruptJsonFile(filePath, 'fix it') + const second = backUpCorruptJsonFile(filePath, 'fix it') + + expect(first).toBe(second) + expect(readFileSync(first ?? '', 'utf8')).toBe('garbage') + }) + + test('guardLocalStateFile resolves .netlify/state.json from the working directory', async () => { + const projectDir = join(dir, 'project') + writeFileSync(join(dir, 'unrelated.txt'), '', { flag: 'w' }) + const stateDir = join(projectDir, '.netlify') + const { mkdirSync } = await import('fs') + mkdirSync(stateDir, { recursive: true }) + writeFileSync(join(stateDir, 'state.json'), 'not json at all') + + const backupPath = await guardLocalStateFile(projectDir) + + expect(backupPath).toMatch(/state\.json\.corrupt\.\d+$/) + expect(existsSync(backupPath ?? '')).toBe(true) + }) + + test('guardLocalStateFile is a no-op when no state file exists', async () => { + const projectDir = join(dir, 'empty-project') + const { mkdirSync } = await import('fs') + mkdirSync(projectDir, { recursive: true }) + await expect(guardLocalStateFile(projectDir)).resolves.toBeUndefined() + }) +})