diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index cbaa9a4b9a3..5b964162f57 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -213,6 +213,7 @@ dustin Dwm dwmapi DWMWA +EACCESS eagerzeroedthick eastus ebegin @@ -278,6 +279,7 @@ gabcdef gazornaanplatt gcs GENERALIZEDTIME +getfattr getwindowid ghp gitmodules @@ -713,6 +715,7 @@ servernum serviceaccount servicemonitor servicewatcher +setfattr setproxy SFC sharedscripts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 37c5ab457a5..9b47ffd235a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -53,6 +53,9 @@ updates: # Assume the next release will fix it. dependency-name: "unfetch" versions: [ "5.0.0" ] + - # fs-xattr 0.4.0 later are esm-only. + dependency-name: "fs-xattr" + versions: [ ">0.3"] # Maintain dependencies for Golang - package-ecosystem: "gomod" diff --git a/jest.config.js b/jest.config.js index ba93c8d2cee..c68f3d8bc61 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,6 +10,7 @@ module.exports = { moduleFileExtensions: [ 'js', 'json', + 'node', // For native modules, e.g. fs-xattr 'ts', 'vue', ], diff --git a/package.json b/package.json index 2ef62076369..f31f7e344c1 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,7 @@ "nan": "2.20.0", "node-gyp": "10.2.0", "node-gyp-build": "4.8.1", + "node-loader": "^2.0.0", "octokit": "3.2.1", "ps-tree": "1.2.0", "raw-loader": "4.0.2", @@ -177,7 +178,8 @@ "string-width": "^4" }, "optionalDependencies": { - "dmg-license": "1.0.11" + "dmg-license": "1.0.11", + "fs-xattr": "0.3.1" }, "browserslist": [ "node 18", diff --git a/pkg/rancher-desktop/integrations/__tests__/manageLinesInFile.spec.ts b/pkg/rancher-desktop/integrations/__tests__/manageLinesInFile.spec.ts index 4505cde833e..0d52c36dd18 100644 --- a/pkg/rancher-desktop/integrations/__tests__/manageLinesInFile.spec.ts +++ b/pkg/rancher-desktop/integrations/__tests__/manageLinesInFile.spec.ts @@ -3,17 +3,36 @@ import os from 'os'; import path from 'path'; import manageLinesInFile, { START_LINE, END_LINE } from '@pkg/integrations/manageLinesInFile'; +import * as childProcess from '@pkg/utils/childProcess'; +import { withResource } from '@pkg/utils/testUtils/mockResources'; + +const describeUnix = process.platform === 'win32' ? describe.skip : describe; +const testUnix = process.platform === 'win32' ? test.skip : test; const FILE_NAME = 'fakercfile'; const TEST_LINE_1 = 'this is test line 1'; const TEST_LINE_2 = 'this is test line 2'; -let testDir = ''; -let rcFilePath = ''; +let testDir: string; +let rcFilePath: string; +let backupFilePath: string; +let tempFilePath: string; +let symlinkPath: string; +let SystemError: new (key: string, context: {code: string, syscall: string, message: string}) => NodeJS.ErrnoException; beforeEach(async() => { testDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rdtest-')); rcFilePath = path.join(testDir, FILE_NAME); + backupFilePath = `${ rcFilePath }.rd-backup~`; + tempFilePath = `${ rcFilePath }.rd-temp`; + symlinkPath = `${ rcFilePath }.real`; + SystemError = await (async() => { + try { + await fs.promises.readFile(rcFilePath); + } catch (ex) { + return Object.getPrototypeOf(ex).constructor; + } + })(); }); afterEach(async() => { @@ -23,117 +42,298 @@ afterEach(async() => { } }); -describe('managedDotFiles', () => { - test("Create file when true and it doesn't yet exist", async() => { - await manageLinesInFile(rcFilePath, [TEST_LINE_1], true); - const content = await fs.promises.readFile(rcFilePath, 'utf8'); - const expectedContents = `${ START_LINE } -${ TEST_LINE_1 } -${ END_LINE } -`; +describe('manageLinesInFile', () => { + describe('Target does not exist', () => { + test('Create the file when desired', async() => { + const expectedContents = [START_LINE, TEST_LINE_1, END_LINE, ''].join('\n'); - expect(content.replace(/\r\n/g, '\n')).toBe(expectedContents.replace(/\r\n/g, '\n')); - }); + await manageLinesInFile(rcFilePath, [TEST_LINE_1], true); - test('Delete file when false and it contains only the managed lines', async() => { - const data = `${ START_LINE } -${ TEST_LINE_1 } -${ END_LINE } -`; + await expect(fs.promises.readFile(rcFilePath, 'utf8')).resolves.toEqual(expectedContents); + await expect(fs.promises.readFile(tempFilePath)).rejects.toHaveProperty('code', 'ENOENT'); + await expect(fs.promises.readFile(backupFilePath)).rejects.toHaveProperty('code', 'ENOENT'); + }); - await fs.promises.writeFile(rcFilePath, data, { mode: 0o644 }); - await manageLinesInFile(rcFilePath, [TEST_LINE_1], false); - expect(fs.promises.readFile(rcFilePath, 'utf8')).rejects.toHaveProperty('code', 'ENOENT'); + test('Do nothing when not desired', async() => { + await expect(manageLinesInFile(rcFilePath, [TEST_LINE_1], false)).resolves.not.toThrow(); + await expect(fs.promises.readFile(rcFilePath)).rejects.toHaveProperty('code', 'ENOENT'); + await expect(fs.promises.readFile(tempFilePath)).rejects.toHaveProperty('code', 'ENOENT'); + await expect(fs.promises.readFile(backupFilePath)).rejects.toHaveProperty('code', 'ENOENT'); + }); }); - test('Put lines in file that exists and has content', async() => { - const data = 'this is already present in the file\n'; + describe('Target exists as a plain file', () => { + testUnix('Fails if file has extended attributes', async() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- This only fails on Windows + // @ts-ignore // fs-xattr is not available on Windows. + const { get, list, set } = await import('fs-xattr'); - await fs.promises.writeFile(rcFilePath, data, { mode: 0o644 }); - await manageLinesInFile(rcFilePath, [TEST_LINE_1], true); - const content = await fs.promises.readFile(rcFilePath, 'utf8'); - const expectedContents = `${ data } -${ START_LINE } -${ TEST_LINE_1 } -${ END_LINE } -`; + const unmanagedContents = 'existing lines\n'; + const attributeKey = 'user.io.rancherdesktop.test'; + const attributeValue = 'sample attribute contents'; - expect(content.replace(/\r\n/g, '\n')).toBe(expectedContents.replace(/\r\n/g, '\n')); - }); + await fs.promises.writeFile(rcFilePath, unmanagedContents); + await set(rcFilePath, attributeKey, attributeValue); + expect(manageLinesInFile(rcFilePath, [TEST_LINE_1], true)).rejects.toThrow(); + await expect(list(rcFilePath)).resolves.toEqual([attributeKey]); + await expect(get(rcFilePath, attributeKey)).resolves.toEqual(Buffer.from(attributeValue, 'utf-8')); + }); - test('Remove lines from file that exists and has content', async() => { - const unmanagedContents = 'this is already present in the file\n'; - const contents = `${ unmanagedContents } -${ START_LINE } -${ TEST_LINE_1 } -${ END_LINE } -`; + test('Delete file when false and it contains only the managed lines', async() => { + const data = [START_LINE, TEST_LINE_1, END_LINE].join('\n'); - await fs.promises.writeFile(rcFilePath, contents, { mode: 0o644 }); - await manageLinesInFile(rcFilePath, [TEST_LINE_1], false); - const newContents = await fs.promises.readFile(rcFilePath, 'utf8'); + await fs.promises.writeFile(rcFilePath, data, { mode: 0o644 }); + await manageLinesInFile(rcFilePath, [TEST_LINE_1], false); - expect(newContents.replace(/\r\n/g, '\n')).toBe(unmanagedContents.replace(/\r\n/g, '\n')); - }); + await expect(fs.promises.readFile(rcFilePath, 'utf8')).rejects.toHaveProperty('code', 'ENOENT'); + await expect(fs.promises.readFile(tempFilePath)).rejects.toHaveProperty('code', 'ENOENT'); + await expect(fs.promises.readFile(backupFilePath)).rejects.toHaveProperty('code', 'ENOENT'); + }); - test('Update managed lines', async() => { - const topUnmanagedContents = 'this is at the top of the file\n'; - const bottomUnmanagedContents = 'this is at the bottom of the file\n'; - const contents = `${ topUnmanagedContents } -${ START_LINE } -${ TEST_LINE_1 } -${ END_LINE } -${ bottomUnmanagedContents }`; - - await fs.promises.writeFile(rcFilePath, contents, { mode: 0o644 }); - await manageLinesInFile(rcFilePath, [TEST_LINE_1, TEST_LINE_2], true); - const newContents = await fs.promises.readFile(rcFilePath, 'utf8'); - const expectedNewContents = `${ topUnmanagedContents } -${ START_LINE } -${ TEST_LINE_1 } -${ TEST_LINE_2 } -${ END_LINE } -${ bottomUnmanagedContents }`; - - expect(newContents.replace(/\r\n/g, '\n')).toBe(expectedNewContents.replace(/\r\n/g, '\n')); - }); + test('Put lines in file that exists and has content', async() => { + const data = 'this is already present in the file\n'; + const expectedContents = [data, START_LINE, TEST_LINE_1, END_LINE, ''].join('\n'); + + await fs.promises.writeFile(rcFilePath, data, { mode: 0o644 }); + await manageLinesInFile(rcFilePath, [TEST_LINE_1], true); + + await expect(fs.promises.readFile(rcFilePath, 'utf8')).resolves.toEqual(expectedContents); + if (process.platform !== 'win32') { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- This only fails on Windows + // @ts-ignore // fs-xattr is not available on Windows. + const { list } = await import('fs-xattr'); + + await expect(list(rcFilePath)).resolves.toHaveLength(0); + } + }); + + test('Remove lines from file that exists and has content', async() => { + const unmanagedContents = 'this is already present in the file\n'; + const contents = [unmanagedContents, START_LINE, TEST_LINE_1, END_LINE, ''].join('\n'); + + expect(contents).toMatch(/(? { + const topUnmanagedContents = 'this is at the top of the file\n'; + const bottomUnmanagedContents = 'this is at the bottom of the file\n'; + const contents = [ + topUnmanagedContents, START_LINE, TEST_LINE_1, END_LINE, bottomUnmanagedContents].join('\n'); + const expectedNewContents = [ + topUnmanagedContents, START_LINE, TEST_LINE_1, TEST_LINE_2, END_LINE, + bottomUnmanagedContents].join('\n'); + + await fs.promises.writeFile(rcFilePath, contents, { mode: 0o644 }); + await manageLinesInFile(rcFilePath, [TEST_LINE_1, TEST_LINE_2], true); + + await expect(fs.promises.readFile(rcFilePath, 'utf8')).resolves.toEqual(expectedNewContents); + }); + + test('Remove managed lines from between unmanaged lines', async() => { + const topUnmanagedContents = 'this is at the top of the file\n'; + const bottomUnmanagedContents = 'this is at the bottom of the file\n'; + const contents = [ + topUnmanagedContents, START_LINE, TEST_LINE_1, END_LINE, bottomUnmanagedContents].join('\n'); + const expectedNewContents = [topUnmanagedContents, bottomUnmanagedContents].join('\n'); + + await fs.promises.writeFile(rcFilePath, contents, { mode: 0o644 }); + await manageLinesInFile(rcFilePath, [TEST_LINE_1], false); + + await expect(fs.promises.readFile(rcFilePath, 'utf8')).resolves.toEqual(expectedNewContents); + }); + + test('File mode should not be changed when updating a file', async() => { + const unmanagedContents = 'this is already present in the file\n'; + const contents = [unmanagedContents, START_LINE, TEST_LINE_1, END_LINE].join('\n'); + + await fs.promises.writeFile(rcFilePath, contents, { mode: 0o623 }); + const { mode: actualMode } = await fs.promises.stat(rcFilePath); + + await manageLinesInFile(rcFilePath, [TEST_LINE_1], false); + + await expect(fs.promises.stat(rcFilePath)).resolves.toHaveProperty('mode', actualMode); + }); + + test('Should not write directly to target file', async() => { + const unmanagedContents = 'existing lines\n'; + + await fs.promises.writeFile(rcFilePath, unmanagedContents, { mode: 0o600 }); + + using spyWriteFile = withResource(jest.spyOn(fs.promises, 'writeFile')); + using spyRename = withResource(jest.spyOn(fs.promises, 'rename')); + + await manageLinesInFile(rcFilePath, [TEST_LINE_1], true); + expect(spyWriteFile).not.toHaveBeenCalledWith(rcFilePath, expect.anything()); + expect(spyRename).toHaveBeenCalledWith(tempFilePath, rcFilePath); + expect(fs.promises.readFile(tempFilePath)).rejects.toHaveProperty('code', 'ENOENT'); + expect(fs.promises.readFile(backupFilePath)).rejects.toHaveProperty('code', 'ENOENT'); + expect(fs.promises.readFile(rcFilePath, 'utf-8')).resolves + .toEqual([unmanagedContents, START_LINE, TEST_LINE_1, END_LINE, ''].join('\n')); + }); + + test('Handles errors writing to temporary file', async() => { + const unmanagedContents = 'existing lines\n'; - test('Remove managed lines from between unmanaged lines', async() => { - const topUnmanagedContents = 'this is at the top of the file\n'; - const bottomUnmanagedContents = 'this is at the bottom of the file\n'; - const contents = `${ topUnmanagedContents } -${ START_LINE } -${ TEST_LINE_1 } -${ END_LINE } -${ bottomUnmanagedContents }`; - - await fs.promises.writeFile(rcFilePath, contents, { mode: 0o644 }); - await manageLinesInFile(rcFilePath, [TEST_LINE_1], false); - const newContents = await fs.promises.readFile(rcFilePath, 'utf8'); - const expectedNewContents = `${ topUnmanagedContents } -${ bottomUnmanagedContents }`; - - expect(newContents.replace(/\r\n/g, '\n')).toBe(expectedNewContents); + await fs.promises.writeFile(rcFilePath, unmanagedContents, { mode: 0o600 }); + const originalWriteFile = fs.promises.writeFile; + + using spyWriteFile = withResource(jest.spyOn(fs.promises, 'writeFile')) + .mockImplementation(async(file, data, options) => { + if (file.toString() === tempFilePath) { + throw new SystemError('EACCESS', { + code: 'EACCESS', syscall: 'write', message: '', + }); + } + await originalWriteFile(file, data, options); + }); + + await expect(manageLinesInFile(rcFilePath, [TEST_LINE_1], true)).rejects.not.toBeUndefined(); + expect(spyWriteFile).toHaveBeenCalledWith(tempFilePath, expect.anything(), expect.anything()); + // The file should not have been modified + expect(fs.promises.readFile(rcFilePath, 'utf-8')).resolves.toEqual(unmanagedContents); + expect(fs.promises.readFile(tempFilePath)).rejects.toHaveProperty('code', 'ENOENT'); + expect(fs.promises.readFile(backupFilePath)).rejects.toHaveProperty('code', 'ENOENT'); + }); }); - test('File mode should not be changed when updating a file', async() => { - const unmanagedContents = 'this is already present in the file\n'; - const contents = `${ unmanagedContents } -${ START_LINE } -${ TEST_LINE_1 } -${ END_LINE } -`; + describeUnix('Target is a symlink', () => { + beforeEach(async() => { + await fs.promises.symlink(symlinkPath, rcFilePath, 'file'); + }); + + test('Aborts if backup file already exists', async() => { + const backupContents = 'this is never read'; + const unmanagedContents = 'existing lines\n'; + + await fs.promises.writeFile(symlinkPath, unmanagedContents); + await fs.promises.writeFile(backupFilePath, backupContents); + + await expect(manageLinesInFile(rcFilePath, ['hello'], true)).rejects.toThrow(); + await expect(fs.promises.readFile(rcFilePath, 'utf-8')).resolves.toEqual(unmanagedContents); + await expect(fs.promises.readFile(backupFilePath, 'utf-8')).resolves.toEqual(backupContents); + await expect(fs.promises.readlink(rcFilePath)).resolves.toEqual(symlinkPath); + }); + + test('Leave the file empty if removing all content', async() => { + const data = [START_LINE, TEST_LINE_1, END_LINE].join('\n'); + + await fs.promises.writeFile(symlinkPath, data, { mode: 0o644 }); + await manageLinesInFile(rcFilePath, [TEST_LINE_1], false); + + await expect(fs.promises.readFile(symlinkPath, 'utf8')).resolves.toEqual(''); + await expect(fs.promises.readFile(tempFilePath)).rejects.toHaveProperty('code', 'ENOENT'); + await expect(fs.promises.readFile(backupFilePath)).rejects.toHaveProperty('code', 'ENOENT'); + await expect(fs.promises.readlink(rcFilePath)).resolves.toEqual(symlinkPath); + }); + + test('Put lines in file that exists and has content', async() => { + const data = 'this is already present in the file\n'; + const expectedContents = [data, START_LINE, TEST_LINE_1, END_LINE, ''].join('\n'); + + await fs.promises.writeFile(symlinkPath, data, { mode: 0o644 }); + await manageLinesInFile(rcFilePath, [TEST_LINE_1], true); + + await expect(fs.promises.readFile(symlinkPath, 'utf-8')).resolves.toEqual(expectedContents); + await expect(fs.promises.readlink(rcFilePath, 'utf-8')).resolves.toEqual(symlinkPath); + }); + + test('Remove lines from file that exists and has content', async() => { + const unmanagedContents = 'this is already present in the file\n'; + const contents = [unmanagedContents, START_LINE, TEST_LINE_1, END_LINE, ''].join('\n'); + + await fs.promises.writeFile(symlinkPath, contents, { mode: 0o644 }); + await manageLinesInFile(rcFilePath, [TEST_LINE_1], false); + + await expect(fs.promises.readFile(symlinkPath, 'utf-8')).resolves.toEqual(unmanagedContents); + await expect(fs.promises.readlink(rcFilePath, 'utf-8')).resolves.toEqual(symlinkPath); + }); + + test('Update managed lines', async() => { + const topUnmanagedContents = 'this is at the top of the file\n'; + const bottomUnmanagedContents = 'this is at the bottom of the file\n'; + const contents = [ + topUnmanagedContents, START_LINE, TEST_LINE_1, END_LINE, bottomUnmanagedContents].join('\n'); + const expectedNewContents = [ + topUnmanagedContents, START_LINE, TEST_LINE_1, TEST_LINE_2, END_LINE, + bottomUnmanagedContents].join('\n'); + + await fs.promises.writeFile(symlinkPath, contents, { mode: 0o644 }); + await manageLinesInFile(rcFilePath, [TEST_LINE_1, TEST_LINE_2], true); + + await expect(fs.promises.readFile(symlinkPath, 'utf8')).resolves.toEqual(expectedNewContents); + await expect(fs.promises.readlink(rcFilePath, 'utf-8')).resolves.toEqual(symlinkPath); + }); + + test('Remove managed lines from between unmanaged lines', async() => { + const topUnmanagedContents = 'this is at the top of the file\n'; + const bottomUnmanagedContents = 'this is at the bottom of the file\n'; + const contents = [ + topUnmanagedContents, START_LINE, TEST_LINE_1, END_LINE, bottomUnmanagedContents].join('\n'); + const expectedNewContents = [topUnmanagedContents, bottomUnmanagedContents].join('\n'); + + await fs.promises.writeFile(symlinkPath, contents, { mode: 0o644 }); + await manageLinesInFile(rcFilePath, [TEST_LINE_1], false); + + await expect(fs.promises.readFile(symlinkPath, 'utf8')).resolves.toEqual(expectedNewContents); + await expect(fs.promises.readlink(rcFilePath, 'utf-8')).resolves.toEqual(symlinkPath); + }); + + test('File mode should not be changed when updating a file', async() => { + const unmanagedContents = 'this is already present in the file\n'; + const contents = [unmanagedContents, START_LINE, TEST_LINE_1, END_LINE].join('\n'); + + await fs.promises.writeFile(symlinkPath, contents, { mode: 0o623 }); + const { mode: actualMode } = await fs.promises.stat(symlinkPath); + + await manageLinesInFile(rcFilePath, [TEST_LINE_1], false); + + await expect(fs.promises.stat(symlinkPath)).resolves.toHaveProperty('mode', actualMode); + await expect(fs.promises.readlink(rcFilePath, 'utf-8')).resolves.toEqual(symlinkPath); + }); + + test('Write backup file during operation', async() => { + const unmanagedContents = 'existing lines\n'; + + await fs.promises.writeFile(rcFilePath, unmanagedContents, { mode: 0o600 }); + const originalWriteFile = fs.promises.writeFile; - await fs.promises.writeFile(rcFilePath, contents, { mode: 0o623 }); - const oldFileMode = (await fs.promises.stat(rcFilePath)).mode; + using spyWriteFile = withResource(jest.spyOn(fs.promises, 'writeFile')) + .mockImplementation(async(file, data, options) => { + if (file !== rcFilePath) { + // Don't fail when writing to any other files. + await originalWriteFile(file, data, options); - await manageLinesInFile(rcFilePath, [TEST_LINE_1], false); - const newFileMode = (await fs.promises.stat(rcFilePath)).mode; + return; + } + // When doing the actual write, the backup file should already have + // the old contents. + expect(await fs.promises.readFile(backupFilePath)).toEqual(unmanagedContents); + // We also haven't written to the target file yet. + expect(await fs.promises.readFile(symlinkPath)).toEqual(unmanagedContents); + // Throw an error and let it recover. + throw new SystemError('EIO', { + code: 'EIO', syscall: 'write', message: 'Fake error', + }); + }); - expect(newFileMode).toBe(oldFileMode); + await expect(manageLinesInFile(rcFilePath, [TEST_LINE_1], true)).rejects.toThrow(); + expect(spyWriteFile).toHaveBeenCalledWith(rcFilePath, expect.anything(), expect.anything()); + await expect(fs.promises.readFile(tempFilePath)).rejects.toHaveProperty('code', 'ENOENT'); + await expect(fs.promises.readFile(backupFilePath, 'utf-8')).resolves.toEqual(unmanagedContents); + }); }); - test('Do nothing when desiredPresent is false and file does not exist', async() => { - await expect(manageLinesInFile(rcFilePath, [TEST_LINE_1], false)).resolves.not.toThrow(); + describeUnix('Target is neither normal file nor symlink', () => { + // An incorrect implementation would write into the pipe and block, so + // set a timeout to ensure we bail in that case. + test('Abort if target is not a file', async() => { + await childProcess.spawnFile('mknod', [rcFilePath, 'p']); + await expect(manageLinesInFile(rcFilePath, [], true)).rejects.toThrow(); + await expect(childProcess.spawnFile('test', ['-p', rcFilePath])).resolves.not.toThrow(); + }, 1_000); }); }); diff --git a/pkg/rancher-desktop/integrations/manageLinesInFile.ts b/pkg/rancher-desktop/integrations/manageLinesInFile.ts index bb1bc844915..f88237d1a44 100644 --- a/pkg/rancher-desktop/integrations/manageLinesInFile.ts +++ b/pkg/rancher-desktop/integrations/manageLinesInFile.ts @@ -1,5 +1,4 @@ import fs from 'fs'; -import os from 'os'; import isEqual from 'lodash/isEqual.js'; @@ -7,74 +6,129 @@ export const START_LINE = '### MANAGED BY RANCHER DESKTOP START (DO NOT EDIT)'; export const END_LINE = '### MANAGED BY RANCHER DESKTOP END (DO NOT EDIT)'; const DEFAULT_FILE_MODE = 0o644; -// Inserts/removes fenced lines into/from a file. Idempotent. -// @param path The path to the file to work on. -// @param desiredManagedLines The lines to insert into the file. -// @param desiredPresent Whether the lines should be present. +/** + * Inserts/removes fenced lines into/from a file. Idempotent. + * @param path The path to the file to work on. + * @param desiredManagedLines The lines to insert into the file. + * @param desiredPresent Whether the lines should be present. + */ export default async function manageLinesInFile(path: string, desiredManagedLines: string[], desiredPresent: boolean): Promise { - // read file, creating it if it doesn't exist - let currentContent: string; + const desired = getDesiredLines(desiredManagedLines, desiredPresent); + let fileStats: fs.Stats; try { - currentContent = await fs.promises.readFile(path, 'utf8'); - } catch (error: any) { - if (error.code === 'ENOENT' && desiredPresent) { - const lines = buildFileLines([], desiredManagedLines, ['']); - const content = lines.join(os.EOL); + fileStats = await fs.promises.lstat(path); + } catch (ex: any) { + if (ex && 'code' in ex && ex.code === 'ENOENT') { + // File does not exist. + const content = computeTargetContents('', desired); - await fs.promises.writeFile(path, content, { mode: DEFAULT_FILE_MODE }); + if (content) { + await fs.promises.writeFile(path, content, { mode: DEFAULT_FILE_MODE }); + } - return; - } else if (error.code === 'ENOENT' && !desiredPresent) { return; } else { - throw error; + throw ex; } } - // split file into three parts - let before: string[]; - let currentManagedLines: string[]; - let after: string[]; + if (fileStats.isFile()) { + if (await fileHasExtendedAttributes(path)) { + throw new Error(`Refusing to manage ${ path } which has extended attributes`); + } - try { - const currentLines = currentContent.split('\n'); + const tempName = `${ path }.rd-temp`; - [before, currentManagedLines, after] = splitLinesByDelimiters(currentLines); - } catch (error) { - throw new Error(`could not split ${ path }: ${ error }`); - } + await fs.promises.copyFile(path, tempName, fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE); + + try { + const currentContents = await fs.promises.readFile(tempName, 'utf-8'); + const targetContents = computeTargetContents(currentContents, desired); + + if (targetContents === undefined) { + // No changes are needed + return; + } + + if (targetContents === '') { + // The resulting file is empty; unlink it. + await fs.promises.unlink(path); + + return; + } - // make the changes - if (desiredPresent && !isEqual(currentManagedLines, desiredManagedLines)) { - // This is needed to ensure the file ends with an EOL - if (after.length === 0) { - after = ['']; + await fs.promises.writeFile(tempName, targetContents, 'utf-8'); + await fs.promises.rename(tempName, path); + } finally { + try { + await fs.promises.unlink(tempName); + } catch { + // Ignore errors unlinking the temporary file; if everything went well, + // it no longer exists anyway. + } } - const newLines = buildFileLines(before, desiredManagedLines, after); - const newContent = newLines.join(os.EOL); + } else if (fileStats.isSymbolicLink()) { + const backupPath = `${ path }.rd-backup~`; - await fs.promises.writeFile(path, newContent); - } - if (!desiredPresent) { - // Ignore the extra empty line that came from the managed block. - if (after.length === 1 && after[0] === '') { - after = []; + await fs.promises.copyFile(path, backupPath, fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE); + + const currentContents = await fs.promises.readFile(backupPath, 'utf-8'); + const targetContents = computeTargetContents(currentContents, desired); + + if (targetContents === undefined) { + // No changes are needed; just remove the backup file again. + await fs.promises.unlink(backupPath); + + return; } - if (before.length === 0 && after.length === 0) { - await fs.promises.rm(path); - } else { - const newLines = buildFileLines(before, [], after); - const newContent = newLines.join(os.EOL); + // Always write the file, even if the result will be empty. + await fs.promises.writeFile(path, targetContents, 'utf-8'); + + const actualContents = await fs.promises.readFile(path, 'utf-8'); - await fs.promises.writeFile(path, newContent); + if (!isEqual(targetContents, actualContents)) { + throw new Error(`Error writing to ${ path }: written contents are unexpected; see backup in ${ backupPath }`); } + await fs.promises.unlink(backupPath); + } else { + // Target exists, and is neither a normal file nor a symbolic link. + // Return with an error. + throw new Error(`Refusing to manage ${ path } which is neither a regular file nor a symbolic link`); } } -// Splits a file into three arrays containing the lines before the managed portion, -// the lines in the managed portion and the lines after the managed portion. -// @param lines An array where each element represents a line in a file. +/** + * Check if the given file has any extended attributes. + * + * We do this check because we are not confident of being able to write the file + * atomically (that is, either the old content or new content is visible) while + * also preserving extended attributes. + */ +async function fileHasExtendedAttributes(filePath: string): Promise { + try { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment -- This only fails on Windows + // @ts-ignore // fs-xattr is not available on Windows + const { list } = await import('fs-xattr'); + + return (await list(filePath)).length > 0; + } catch { + if (process.env.NODE_ENV === 'test' && process.env.RD_TEST !== 'e2e') { + // When running unit tests, assume they do not have extended attributes. + return false; + } + + console.error(`Failed to import fs-xattr, cannot check for extended attributes on ${ filePath }; assuming it exists.`); + + return true; + } +} + +/** + * Splits a file into three arrays containing the lines before the managed portion, + * the lines in the managed portion and the lines after the managed portion. + * @param lines An array where each element represents a line in a file. + */ function splitLinesByDelimiters(lines: string[]): [string[], string[], string[]] { const startIndex = lines.indexOf(START_LINE); const endIndex = lines.indexOf(END_LINE); @@ -94,12 +148,47 @@ function splitLinesByDelimiters(lines: string[]): [string[], string[], string[]] return [before, currentManagedLines, after]; } -// Builds an array where each element represents a line in a file. -// @param before The portion of the file before the managed lines. -// @param toInsert The managed lines, not including the fences. -// @param after The portion of the file after the managed lines. -function buildFileLines(before: string[], toInsert: string[], after: string[]): string[] { - const rancherDesktopLines = toInsert.length > 0 ? [START_LINE, ...toInsert, END_LINE] : []; +/** + * Calculate the desired content of the managed lines. + * @param desiredManagedLines The lines to insert into the file. + * @param desiredPresent Whether the lines should be present. + * @returns The lines that should end up in the managed section of the final file. + */ +function getDesiredLines(desiredManagedLines: string[], desiredPresent: boolean): string[] { + const desired = desiredPresent && desiredManagedLines.length > 0; + + return desired ? [START_LINE, ...desiredManagedLines, END_LINE] : []; +} + +/** + * Given the current contents of the file, determine what the final file + * contents should be. + * @param currentContents The current contents of the file. + * @param desired The desired content of the managed lines. + * @returns The final content; if no changes are needed, `undefined` is returned. + * There will never be any leading empty lines, + * and there will always be exactly one trailing empty line. + */ +function computeTargetContents(currentContents: string, desired: string[]): string | undefined { + const [before, current, after] = splitLinesByDelimiters(currentContents.split('\n')); + + if (isEqual(current, desired)) { + // No changes are needed + return undefined; + } + + const lines = [...before, ...desired, ...after]; + + // Remove all leading empty lines. + while (lines.length > 0 && lines[0] === '') { + lines.shift(); + } + // Remove all trailing empty lines. + while (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop(); + } + // Add one trailing empty line to the end. + lines.push(''); - return [...before, ...rancherDesktopLines, ...after]; + return lines.join('\n'); } diff --git a/pkg/rancher-desktop/utils/testUtils/mockResources.ts b/pkg/rancher-desktop/utils/testUtils/mockResources.ts new file mode 100644 index 00000000000..c1b4cd09164 --- /dev/null +++ b/pkg/rancher-desktop/utils/testUtils/mockResources.ts @@ -0,0 +1,21 @@ +// `Symbol.dispose` exists as of NodeJS 20; if it's unset, set it (because we +// are currently on NodeJS 18). +(Symbol as any).dispose ??= Symbol.for('nodejs.dispose'); + +/** + * Given a Jest SpyInstance, return it as a Disposable such that mockRestore will + * be called when the instance goes out of scope. + * @note This will no longer be needed as of Jest 30 (where it's built in). + */ +export function withResource< + T = any, + Y extends any[] = any, + C = any, + U extends jest.MockInstance = any, +>(input: U): U & Disposable { + (input as any)[Symbol.dispose] = () => { + input.mockRestore(); + }; + + return input as U & Disposable; +} diff --git a/scripts/lib/build-utils.ts b/scripts/lib/build-utils.ts index c5ef29f82c7..7e6ce4c3537 100644 --- a/scripts/lib/build-utils.ts +++ b/scripts/lib/build-utils.ts @@ -133,7 +133,7 @@ export default { devtool: this.isDevelopment ? 'source-map' : false, resolve: { alias: { '@pkg': path.resolve(this.rootDir, 'pkg', 'rancher-desktop') }, - extensions: ['.ts', '.js', '.json'], + extensions: ['.ts', '.js', '.json', '.node'], modules: ['node_modules'], }, output: { @@ -166,6 +166,10 @@ export default { exclude: [/(?:^|[/\\])assets[/\\]scripts[/\\]/, this.distDir], use: { loader: 'js-yaml-loader' }, }, + { + test: /\.node$/, + use: { loader: 'node-loader' }, + }, { test: /(?:^|[/\\])assets[/\\]scripts[/\\]/, use: { loader: 'raw-loader' }, diff --git a/yarn.lock b/yarn.lock index 875c189822e..ed9242d46c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7007,6 +7007,11 @@ fs-monkey@^1.0.4: resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.4.tgz#ee8c1b53d3fe8bb7e5d2c5c5dfc0168afdd2f747" integrity sha512-INM/fWAxMICjttnD0DX1rBvinKskj5G1w+oy/pnm9u/tSlnBrzFonJMcalKJ30P8RRsPzKcCG7Q8l0jx5Fh9YQ== +fs-xattr@0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/fs-xattr/-/fs-xattr-0.3.1.tgz#a23d88571031f6c56f26d59e0bab7d2e12f49f77" + integrity sha512-UVqkrEW0GfDabw4C3HOrFlxKfx0eeigfRne69FxSBdHIP8Qt5Sq6Pu3RM9KmMlkygtC4pPKkj5CiPO5USnj2GA== + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -9956,6 +9961,13 @@ node-int64@^0.4.0: resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== +node-loader@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/node-loader/-/node-loader-2.0.0.tgz#9109a6d828703fd3e0aa03c1baec12a798071562" + integrity sha512-I5VN34NO4/5UYJaUBtkrODPWxbobrE4hgDqPrjB25yPkonFhCmZ146vTH+Zg417E9Iwoh1l/MbRs1apc5J295Q== + dependencies: + loader-utils "^2.0.0" + node-releases@^2.0.13: version "2.0.13" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d" @@ -11944,16 +11956,7 @@ string-length@^5.0.1: char-regex "^2.0.0" strip-ansi "^7.0.1" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^2.1.1, string-width@^4, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3, string-width@^5.0.1, string-width@^5.1.2: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^2.1.1, string-width@^4, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3, string-width@^5.0.1, string-width@^5.1.2: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -12003,7 +12006,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -12024,13 +12027,6 @@ strip-ansi@^4.0.0: dependencies: ansi-regex "^3.0.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -13310,7 +13306,7 @@ word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -13336,15 +13332,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"