Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/commands/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions src/commands/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down
64 changes: 64 additions & 0 deletions src/utils/config-guard.ts
Original file line number Diff line number Diff line change
@@ -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 `<file>.corrupt.<mtime>` 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<string | undefined> => {
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`,
)
92 changes: 92 additions & 0 deletions tests/unit/utils/config-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof mockStderr>

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()
})
})
Loading