diff --git a/pkg/rancher-desktop/main/__tests__/deploymentProfiles.spec.ts b/pkg/rancher-desktop/main/__tests__/deploymentProfiles.spec.ts new file mode 100644 index 00000000000..b9393c78e38 --- /dev/null +++ b/pkg/rancher-desktop/main/__tests__/deploymentProfiles.spec.ts @@ -0,0 +1,317 @@ +/* eslint object-curly-newline: ["error", {"consistent": true}] */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import * as settings from '@pkg/config/settings'; +import { readDeploymentProfiles } from '@pkg/main/deploymentProfiles'; +import { spawnFile } from '@pkg/utils/childProcess'; +import Logging from '@pkg/utils/logging'; +import { RecursivePartial } from '@pkg/utils/typeUtils'; + +// for some reason `import nativeReg...` => undefined when run in jest on Windows +// import nativeReg from 'native-reg'; +const nativeReg = require('native-reg'); + +const console = Logging.deploymentProfile; + +// Note that we can't modify the HKLM hive without admin privileges, +// so this whole test will just work with the user's HKCU hive. +const REG_PATH_START = ['SOFTWARE', 'Rancher Desktop']; +const FULL_REG_PATH_START = ['HKEY_CURRENT_USER'].concat(REG_PATH_START); +const REGISTRY_PATH_PROFILE = REG_PATH_START.concat('TestProfile'); + +const NON_PROFILE_PATH = FULL_REG_PATH_START.join('\\'); +const FULL_PROFILE_PATH = FULL_REG_PATH_START.concat('TestProfile').join('\\'); + +const describeWindows = process.platform === 'win32' ? describe : describe.skip; + +let testDir = ''; +let regFilePath = ''; + +async function clearRegistry() { + try { + await spawnFile('reg', ['DELETE', `HKCU\\${ REGISTRY_PATH_PROFILE.join('\\') }`, '/f']); + } catch { + // Ignore any errors + } +} + +async function installInRegistry(regFileContents: string) { + await fs.promises.writeFile(regFilePath, regFileContents, { encoding: 'ascii' }); + try { + await spawnFile('reg', ['IMPORT', regFilePath]); + } catch (ex: any) { + // Use expect to display the error message + expect(ex).toBeNull(); + throw ex; + } +} + +// Registry multi-stringSZ settings in a reg file are hard to read, so expand them here. +// e.g.=> ["abc", "def"] would be ucs-2-encoded as '61,00,62,00,63,00,00,00,64,00,65,00,66,00,00,00,00,00' +// where null dwords (so two 00 bytes) separate each pair of words and +// two null dwords ("00 00 00 00") indicate the end of the list +function stringToMultiStringHexBytes(s: string[]): string { + const hexBytes = Buffer.from(s.join('\x00'), 'ucs2') + .toString('hex') + .split(/(..)/) + .filter(x => x) + .join(','); + + return `${ hexBytes },00,00,00,00`; +} + +// We *could* write a routine that converts json to reg files, but that's not the point of this test. +// Better to just hard-wire a few regfiles here. + +const defaultsUserRegFile = `Windows Registry Editor Version 5.00 + +[${ NON_PROFILE_PATH }] + +[${ FULL_PROFILE_PATH }] + +[${ FULL_PROFILE_PATH }\\Defaults] + +[${ FULL_PROFILE_PATH }\\Defaults\\application] + +[${ FULL_PROFILE_PATH }\\Defaults\\application] +"Debug"=dword:1 +"adminAccess"=dword:0 + +[${ FULL_PROFILE_PATH }\\Defaults\\application\\Telemetry] +"ENABLED"=dword:1 + +[${ FULL_PROFILE_PATH }\\Defaults\\CONTAINERENGINE] +"name"="moby" + +[${ FULL_PROFILE_PATH }\\Defaults\\containerEngine\\allowedImages] +"patterns"=hex(7):${ stringToMultiStringHexBytes(['edmonton', 'calgary', 'red deer', 'bassano']) } +"enabled"=dword:00000000 + +[${ FULL_PROFILE_PATH }\\Defaults\\wsl] + +[${ FULL_PROFILE_PATH }\\Defaults\\wsl\\integrations] +"kingston"=dword:0 +"napanee"=dword:0 +"yarker"=dword:1 +"weed"=dword:1 + +[${ FULL_PROFILE_PATH }\\Defaults\\kubernetes] +"version"="867-5309" + +[${ FULL_PROFILE_PATH }\\Defaults\\diagnostics] +"showmuted"=dword:1 + +[${ FULL_PROFILE_PATH }\\Defaults\\diagnostics\\mutedChecks] +"montreal"=dword:1 +"riviere du loup"=dword:0 +"magog"=dword:0 + +[${ FULL_PROFILE_PATH }\\Defaults\\extensions] +"bellingham"="WA" +"portland"="OR" +"shasta"="CA" +"elko"="NV" +`; + +const lockedUserRegFile = `Windows Registry Editor Version 5.00 + +[${ NON_PROFILE_PATH }] + +[${ FULL_PROFILE_PATH }] + +[${ FULL_PROFILE_PATH }\\Locked] + +[${ FULL_PROFILE_PATH }\\Locked\\containerEngine] + +[${ FULL_PROFILE_PATH }\\Locked\\containerEngine\\allowedImages] +"enabled"=dword:00000000 +"patterns"=hex(7):${ stringToMultiStringHexBytes(['busybox', 'nginx']) } +`; + +// Deliberate errors in defaults: +// * Specifying application/updater=1 instead of application/updater/enabled=1 +// * Specifying application/adminAccess/debug=string when it should be 0 or 1 +// * application/debug should be a number, not a string +// * containerEngine/name should be a string, not a number +// * containerEngine/allowedImages/patterns should be a list of strings, not a number +// * containerEngine/allowedImages/enabled should be a boolean, not a string +// * images/namespace should be a single string, not a multi-SZ string value +// * wsl/integrations should be a special-purpose object +// * diagnostics/mutedChecks should be a special-purpose object +// * kubernetes/version should be a string, not an object + +const incorrectDefaultsUserRegFile = `Windows Registry Editor Version 5.00 + +[${ NON_PROFILE_PATH }] + +[${ FULL_PROFILE_PATH }] + +[${ FULL_PROFILE_PATH }\\Defaults] + +[${ FULL_PROFILE_PATH }\\Defaults\\application] + +[${ FULL_PROFILE_PATH }\\Defaults\\application] +"Debug"="should be a number" +"Updater"=dword:0 + +[${ FULL_PROFILE_PATH }\\Defaults\\application\\adminAccess] +"sudo"=dword:1 + +[${ FULL_PROFILE_PATH }\\Defaults\\application\\Telemetry] +"ENABLED"=dword:1 + +[${ FULL_PROFILE_PATH }\\Defaults\\CONTAINERENGINE] +"name"=dword:5 + +[${ FULL_PROFILE_PATH }\\Defaults\\containerEngine\\allowedImages] +"patterns"=DWORD:19 +"enabled"="should be a boolean" + +[${ FULL_PROFILE_PATH }\\Defaults\\images] +"namespace"=hex(7):${ stringToMultiStringHexBytes(['busybox', 'nginx']) } + +[${ FULL_PROFILE_PATH }\\Defaults\\wsl] +"integrations"="should be a sub-object" + +[${ FULL_PROFILE_PATH }\\Defaults\\kubernetes] + +[${ FULL_PROFILE_PATH }\\Defaults\\kubernetes\\version] + +[${ FULL_PROFILE_PATH }\\Defaults\\diagnostics] +"showmuted"=dword:1 +"mutedChecks"=dword:42 +`; + +const arrayFromSingleStringDefaultsUserRegFile = `Windows Registry Editor Version 5.00 + +[${ NON_PROFILE_PATH }] + +[${ FULL_PROFILE_PATH }] + +[${ FULL_PROFILE_PATH }\\Defaults] + +[${ FULL_PROFILE_PATH }\\Defaults\\CONTAINERENGINE] +"name"="moby" + +[${ FULL_PROFILE_PATH }\\Defaults\\containerEngine\\allowedImages] +"patterns"="hokey smoke!" +`; + +describeWindows('windows deployment profiles', () => { + /* Mock console.error() to capture error messages. */ + let consoleMock: jest.SpyInstance; + + beforeEach(async() => { + nativeReg.deleteTree(nativeReg.HKCU, path.join(...(REGISTRY_PATH_PROFILE))); + testDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'regtest-')); + regFilePath = path.join(testDir, 'import.reg'); + consoleMock = jest.spyOn(console, 'error'); + }); + afterEach(async() => { + await fs.promises.rm(testDir, { force: true, recursive: true }); + consoleMock.mockReset(); + }); + // TODO: Add an `afterAll(clearRegistry)` when we're finished developing. + + describe('profile', () => { + describe('defaults', () => { + describe('happy paths', () => { + const defaultUserProfile: RecursivePartial = { + application: { + debug: true, + adminAccess: false, + telemetry: { enabled: true }, + }, + containerEngine: { + allowedImages: { + enabled: false, + patterns: ['edmonton', 'calgary', 'red deer', 'bassano'], + }, + name: settings.ContainerEngine.MOBY, + }, + WSL: { integrations: { kingston: false, napanee: false, yarker: true, weed: true } }, + kubernetes: { + version: '867-5309', + }, + diagnostics: { + showMuted: true, + mutedChecks: { montreal: true, 'riviere du loup': false, magog: false }, + }, + extensions: { bellingham: 'WA', portland: 'OR', shasta: 'CA', elko: 'NV' }, + }; + const lockedUserProfile = { + containerEngine: { + allowedImages: { + enabled: false, + patterns: ['busybox', 'nginx'], + }, + }, + }; + + describe('no system profiles, no user profiles', () => { + it('loads nothing', async() => { + const profile = await readDeploymentProfiles(REGISTRY_PATH_PROFILE); + + expect(profile.defaults).toEqual({}); + expect(profile.locked).toEqual({}); + }); + }); + + describe('no system profiles, both user profiles', () => { + it('loads both profiles', async() => { + await clearRegistry(); + await installInRegistry(defaultsUserRegFile); + await installInRegistry(lockedUserRegFile); + const profile = await readDeploymentProfiles(REGISTRY_PATH_PROFILE); + + expect(profile.defaults).toEqual(defaultUserProfile); + expect(profile.locked).toEqual(lockedUserProfile); + }); + }); + + it('converts a single string into an array', async() => { + await clearRegistry(); + await installInRegistry(arrayFromSingleStringDefaultsUserRegFile); + const profile = await readDeploymentProfiles(REGISTRY_PATH_PROFILE); + + expect(profile.defaults).toEqual({ + containerEngine: { allowedImages: { patterns: ['hokey smoke!'] }, name: 'moby' }, + }); + }); + }); + describe('error paths', () => { + const limitedUserProfile = { + application: { + telemetry: { enabled: true }, + }, + diagnostics: { + showMuted: true, + }, + }; + + it('loads a bad profile, complains about all the errors, and keeps only valid entries', async() => { + await clearRegistry(); + await installInRegistry(incorrectDefaultsUserRegFile); + const profile = await readDeploymentProfiles(REGISTRY_PATH_PROFILE); + + expect(profile.defaults).toEqual(limitedUserProfile); + // Remember that sub-objects are processed before values + expect(consoleMock.mock.calls).toEqual([ + [expect.stringMatching(/Expecting registry entry .*?application.adminAccess to be a boolean, but it's a registry object/)], + [expect.stringMatching(/Expecting registry entry .*?application.Debug to be a boolean, but it's a SZ/)], + [expect.stringMatching(/Expecting registry entry .*?application.Updater to be a registry object, but it's a DWORD, value: 0/)], + [expect.stringMatching(/Expecting registry entry .*?containerEngine.allowedImages.enabled to be a boolean, but it's a SZ, value: should be a boolean/)], + [expect.stringMatching(/Expecting registry entry .*?containerEngine.name to be a string, but it's a DWORD, value: 5/)], + [expect.stringMatching(/Expecting registry entry .*?diagnostics.mutedChecks to be a registry object, but it's a DWORD, value: 66/)], + [expect.stringMatching(/Expecting registry entry .*?images.namespace to be a single string, but it's an array of strings, value: busybox,nginx/)], + [expect.stringMatching(/Expecting registry entry .*?kubernetes.version to be a string, but it's a registry object/)], + [expect.stringMatching(/Expecting registry entry .*?WSL.integrations to be a registry object, but it's a SZ, value: should be a sub-object/)], + ]); + }); + }); + }); + }); +}); diff --git a/pkg/rancher-desktop/main/deploymentProfiles.ts b/pkg/rancher-desktop/main/deploymentProfiles.ts index d1556f7d3e4..807bfc579b9 100644 --- a/pkg/rancher-desktop/main/deploymentProfiles.ts +++ b/pkg/rancher-desktop/main/deploymentProfiles.ts @@ -3,6 +3,7 @@ import os from 'os'; import { join } from 'path'; import stream from 'stream'; +import _ from 'lodash'; import * as nativeReg from 'native-reg'; import * as settings from '@pkg/config/settings'; @@ -13,8 +14,6 @@ import { RecursivePartial } from '@pkg/utils/typeUtils'; const console = Logging.deploymentProfile; -const REGISTRY_PATH_PROFILE = ['SOFTWARE', 'Rancher Desktop', 'Profile']; - export class DeploymentProfileError extends Error { } @@ -31,6 +30,8 @@ const lockableDefaultSettings = { }, }; +const REGISTRY_PATH_PROFILE = ['SOFTWARE', 'Rancher Desktop', 'Profile']; + /** * Read and validate deployment profiles, giving system level profiles * priority over user level profiles. If the system directory contains a @@ -42,7 +43,12 @@ const lockableDefaultSettings = { * located in the main process. */ -export async function readDeploymentProfiles(): Promise { +export async function readDeploymentProfiles(registryProfilePath = REGISTRY_PATH_PROFILE): Promise { + if (process.platform === 'win32') { + const win32DeploymentReader = new Win32DeploymentReader(registryProfilePath); + + return Promise.resolve(win32DeploymentReader.readProfile()); + } const profiles: settings.DeploymentProfileType = { defaults: {}, locked: {}, @@ -51,41 +57,6 @@ export async function readDeploymentProfiles(): Promise; switch (os.platform()) { - case 'win32': - for (const key of [nativeReg.HKLM, nativeReg.HKCU]) { - const registryKey = nativeReg.openKey(key, REGISTRY_PATH_PROFILE.join('\\'), nativeReg.Access.READ); - - if (!registryKey) { - continue; - } - const defaultsKey = nativeReg.openKey(registryKey, 'Defaults', nativeReg.Access.READ); - const lockedKey = nativeReg.openKey(registryKey, 'Locked', nativeReg.Access.READ); - - try { - if (defaultsKey) { - defaults = readRegistryUsingSchema(settings.defaultSettings, defaultsKey) ?? {}; - } - if (lockedKey) { - locked = readRegistryUsingSchema(settings.defaultSettings, lockedKey) ?? {}; - } - } catch (err) { - console.error( `Error reading deployment profile: ${ err }`); - } finally { - nativeReg.closeKey(registryKey); - if (defaultsKey) { - nativeReg.closeKey(defaultsKey); - } - if (lockedKey) { - nativeReg.closeKey(lockedKey); - } - } - // If we found something in the HKLM Defaults or Locked registry hive, don't look at the user's - // Alternatively, if the keys work, we could break, even if both hives are empty. - if ((defaults && Object.keys(defaults).length) || (locked && Object.keys(locked).length)) { - break; - } - } - break; case 'linux': { const linuxPaths = { [paths.deploymentProfileSystem]: ['defaults.json', 'locked.json'], @@ -102,6 +73,7 @@ export async function readDeploymentProfiles(): Promise|null { - let newObject: RecursivePartial|null = null; +class Win32DeploymentReader { + protected registryPathProfile: string[]; + protected keyName = ''; - const schemaKeys = Object.keys(schemaObj); - // ignore case - const registryKeys = nativeReg.enumKeyNames(regKey).concat(nativeReg.enumValueNames(regKey)).map(k => k.toLowerCase()); - const commonKeys = schemaKeys.filter(k => registryKeys.includes(k.toLowerCase())); + constructor(registryPathProfile: string[]) { + this.registryPathProfile = registryPathProfile; + } - for (const k of commonKeys) { - const schemaVal = schemaObj[k]; - let regValue: any = null; + readProfile(): settings.DeploymentProfileType { + const DEFAULTS_HIVE_NAME = 'Defaults'; + const LOCKED_HIVE_NAME = 'Locked'; + let defaults: RecursivePartial = {}; + let locked: RecursivePartial = {}; - if (typeof schemaVal === 'object') { - if (!Array.isArray(schemaVal)) { - const innerKey = nativeReg.openKey(regKey, k, nativeReg.Access.READ); + for (const keyName of ['HKLM', 'HKCU'] as const) { + this.keyName = keyName; + const key = nativeReg[keyName]; + const registryKey = nativeReg.openKey(key, this.registryPathProfile.join('\\'), nativeReg.Access.READ); - if (!innerKey) { - continue; - } - try { - regValue = readRegistryUsingSchema(schemaVal, innerKey); - if (regValue && (typeof regValue === 'object') && Object.keys(regValue).length === 0) { - // Ignore empty objects - regValue = null; - } - } finally { - nativeReg.closeKey(innerKey); - } + if (!registryKey) { + continue; + } + const defaultsKey = nativeReg.openKey(registryKey, DEFAULTS_HIVE_NAME, nativeReg.Access.READ); + const lockedKey = nativeReg.openKey(registryKey, LOCKED_HIVE_NAME, nativeReg.Access.READ); + + try { + defaults = defaultsKey ? this.readRegistryUsingSchema(settings.defaultSettings, defaultsKey, [DEFAULTS_HIVE_NAME]) : {}; + locked = lockedKey ? this.readRegistryUsingSchema(settings.defaultSettings, lockedKey, [LOCKED_HIVE_NAME]) : {}; + } catch (err) { + console.error('Error reading deployment profile: ', err); + } finally { + nativeReg.closeKey(registryKey); + nativeReg.closeKey(defaultsKey); + nativeReg.closeKey(lockedKey); + } + // If we found something in the HKLM Defaults or Locked registry hive, don't look at the user's + // Alternatively, if the keys work, we could break, even if both hives are empty. + if (Object.keys(defaults).length || Object.keys(locked).length) { + break; + } + } + + // Don't bother with the validator, because the registry-based reader validates as it reads. + return { defaults, locked }; + } + + protected fullRegistryPath(...pathParts: string[]): string { + return `${ this.keyName }\\${ this.registryPathProfile.join('\\') }\\${ pathParts.join('\\') }`; + } + + /** + * Windows only. Read settings values from registry using schemaObj as a template. + * @param schemaObj the object used as a template for navigating registry. + * @param regKey the registry key obtained from nativeReg.openKey(). + * @param pathParts the relative path to the current registry key, starting at 'Defaults' or 'Locked' + * @returns null, or the registry data as an object. + */ + protected readRegistryUsingSchema(schemaObj: Record, regKey: nativeReg.HKEY, pathParts: string[]): Record { + const newObject: Record = {}; + const schemaKeys = Object.keys(schemaObj); + const commonKeys: Array<{schemaKey: string, registryKey: string}> = []; + const unknownKeys: string[] = []; + const userDefinedObjectKeys: Array<{schemaKey: string, registryKey: string}> = []; + let regValue: any; + + // Drop the initial 'defaults' or 'locked' field + const pathPartsWithoutHiveType = pathParts.slice(1); + + for (const registryKey of nativeReg.enumKeyNames(regKey)) { + const schemaKey = fixProfileKeyCase(registryKey, schemaKeys); + // "fixed case" means mapping existing keys in the registry (which typically supports case-insensitive lookups) + // to the actual case in the schema. + + if (schemaKey === null) { + unknownKeys.push(registryKey); + } else if (haveUserDefinedObject(pathPartsWithoutHiveType.concat(schemaKey))) { + userDefinedObjectKeys.push({ schemaKey, registryKey }); } else { - const multiSzValue = nativeReg.queryValueRaw(regKey, k); + commonKeys.push({ schemaKey, registryKey }); + } + } + if (unknownKeys.length) { + unknownKeys.sort(caseInsensitiveComparator.compare); + console.error(`Unrecognized keys in registry at ${ this.fullRegistryPath(...pathParts) }: [${ unknownKeys.join(', ') }]`); + } - if (multiSzValue) { - // Registry value can be a single-string or even a DWORD and parseMultiString will handle it. - const arrayValue = nativeReg.parseMultiString(multiSzValue as nativeReg.Value); + // First process the nested keys, then process any values + for (const { schemaKey, registryKey } of commonKeys) { + const schemaVal = schemaObj[schemaKey]; - regValue = arrayValue.length ? arrayValue : null; - } + if ((typeof schemaVal) !== 'object' || schemaVal === null) { + const valueType = schemaVal === null ? 'null' : (typeof schemaVal); + + console.error(`Expecting registry entry ${ this.fullRegistryPath(...pathParts, registryKey) } to be a ${ valueType }, but it's a registry object`); + continue; } - } else { - regValue = nativeReg.queryValue(regKey, k); - if (typeof schemaVal === 'boolean') { - if (typeof regValue === 'number') { - regValue = regValue !== 0; - } else { - console.debug(`Deployment Profile expected boolean value for key ${ k }`); - regValue = false; + const innerKey = nativeReg.openKey(regKey, registryKey, nativeReg.Access.READ); + + if (!innerKey) { + continue; + } + try { + regValue = this.readRegistryUsingSchema(schemaVal, innerKey, pathParts.concat([schemaKey])); + } finally { + nativeReg.closeKey(innerKey); + } + if (Object.keys(regValue).length) { + newObject[schemaKey] = regValue; + } + } + for (const { schemaKey, registryKey } of userDefinedObjectKeys) { + const innerKey = nativeReg.openKey(regKey, registryKey, nativeReg.Access.READ); + + if (innerKey === null) { + console.error(`No value for registry object ${ this.fullRegistryPath(...pathParts, registryKey) }`); + continue; + } + try { + regValue = this.readRegistryObject(innerKey, pathParts.concat([schemaKey]), true); + } catch (err: any) { + console.error(`Error getting registry object for ${ this.fullRegistryPath(...pathParts, registryKey) }: `, err); + } finally { + nativeReg.closeKey(innerKey); + } + if (regValue) { + newObject[schemaKey] = regValue; + } + } + const unknownValueNames: string[] = []; + + for (const originalName of nativeReg.enumValueNames(regKey)) { + const schemaKey = fixProfileKeyCase(originalName, schemaKeys); + + if (schemaKey === null) { + unknownValueNames.push(originalName); + } else { + regValue = this.readRegistryValue(schemaObj[schemaKey], regKey, pathParts, originalName); + if (regValue !== null) { + newObject[schemaKey] = regValue; } } } - if (regValue !== null) { - newObject ??= {}; - (newObject as Record)[k] = regValue; + if (unknownValueNames.length > 0) { + unknownValueNames.sort(caseInsensitiveComparator.compare); + console.error(`Unrecognized value names in registry at ${ this.fullRegistryPath(...pathParts) }: [${ unknownValueNames.join(', ') }]`); + } + + return newObject; + } + + protected readRegistryObject(regKey: nativeReg.HKEY, pathParts: string[], isUserDefinedObject = false) { + const newObject: Record = {}; + + for (const k of nativeReg.enumValueNames(regKey)) { + let newValue = this.readRegistryValue(undefined, regKey, pathParts, k, isUserDefinedObject); + + if (newValue !== null) { + if (isUserDefinedObject && (typeof newValue) === 'number') { + // Currently all user-defined objects are either + // Record or Record + // The registry can't store boolean values, only numbers, so we assume true and false + // are stored as 1 and 0, respectively. Any other numeric values are considered errors. + switch (newValue) { + case 0: + newValue = false; + break; + case 1: + newValue = true; + break; + default: + console.error(`Unexpected numeric value names in registry at ${ this.fullRegistryPath(...pathParts, k) } of ${ newValue }: expecting either 0 or 1`); + } + } + newObject[k] = newValue; + } } + + return newObject; } - return newObject; + protected readRegistryValue(schemaVal: any, regKey: nativeReg.HKEY, pathParts: string[], valueName: string, isUserDefinedObject = false): string[] | string | boolean | number | null { + const fullPath = `\\${ this.fullRegistryPath(...pathParts, valueName) }`; + const valueTypeNames = [ + 'NONE', // 0 + 'SZ', + 'EXPAND_SZ', + 'BINARY', + 'DWORD', + 'DWORD_BIG_ENDIAN', + 'LINK', + 'MULTI_SZ', + 'RESOURCE_LIST', + 'FULL_RESOURCE_DESCRIPTOR', + 'RESOURCE_REQUIREMENTS_LIST', + 'QWORD', + ]; + const rawValue = nativeReg.queryValueRaw(regKey, valueName); + const parsedValueForErrorMessage = nativeReg.queryValue(regKey, valueName); + + if (rawValue === null) { + // This shouldn't happen + return null; + } else if (!isUserDefinedObject && schemaVal && typeof schemaVal === 'object' && !Array.isArray(schemaVal)) { + console.error(`Expecting registry entry ${ fullPath } to be a registry object, but it's a ${ valueTypeNames[rawValue.type] }, value: ${ parsedValueForErrorMessage }`); + + return null; + } + const expectingArray = Array.isArray(schemaVal); + let parsedValue: any = null; + + switch (rawValue.type) { + case nativeReg.ValueType.SZ: + if (isUserDefinedObject || (typeof schemaVal) === 'string') { + return nativeReg.parseString(rawValue); + } else if (expectingArray) { + return [nativeReg.parseString(rawValue)]; + } else { + console.error(`Expecting registry entry ${ fullPath } to be a ${ typeof schemaVal }, but it's a ${ valueTypeNames[rawValue.type] }, value: ${ parsedValueForErrorMessage }`); + } + break; + case nativeReg.ValueType.DWORD: + case nativeReg.ValueType.DWORD_LITTLE_ENDIAN: + case nativeReg.ValueType.DWORD_BIG_ENDIAN: + if (expectingArray) { + console.error(`Expecting registry entry ${ fullPath } to be an array, but it's a ${ valueTypeNames[rawValue.type] }, value: ${ parsedValueForErrorMessage }`); + } else if (typeof schemaVal === 'string') { + console.error(`Expecting registry entry ${ fullPath } to be a string, but it's a ${ valueTypeNames[rawValue.type] }, value: ${ parsedValueForErrorMessage }`); + } else { + parsedValue = nativeReg.parseValue(rawValue) as number; + + return (typeof schemaVal === 'boolean') ? !!parsedValue : parsedValue; + } + break; + case nativeReg.ValueType.MULTI_SZ: + if (expectingArray) { + return nativeReg.parseMultiString(rawValue); + } else if (typeof schemaVal === 'string') { + console.error(`Expecting registry entry ${ fullPath } to be a single string, but it's an array of strings, value: ${ parsedValueForErrorMessage }`); + } else { + console.error(`Expecting registry entry ${ fullPath } to be a ${ typeof schemaVal }, but it's an array of strings, value: ${ parsedValueForErrorMessage }`); + } + break; + default: + console.error(`Unexpected registry entry ${ fullPath }: don't know how to process a registry entry of type ${ valueTypeNames[rawValue.type] }`); + } + + return null; + } } /** @@ -282,7 +443,7 @@ function validateDeploymentProfile(profile: any, schema: any, parentPathParts: s console.log(`Deployment Profile ignoring '${ parentPathParts.join('.') }.${ key }': got an array, expecting type ${ typeof schema[key] }.`); delete profile[key]; } - } else if (isUserDefinedObject(parentPathParts, key)) { + } else if (haveUserDefinedObject(parentPathParts.concat(key))) { // Keep this part of the profile } else { // Finally recurse and compare the schema sub-object with the specified sub-object @@ -293,25 +454,32 @@ function validateDeploymentProfile(profile: any, schema: any, parentPathParts: s return profile; } +const caseInsensitiveComparator = new Intl.Collator('en', { sensitivity: 'base' }); + +function isEquivalentIgnoreCase(a: string, b: string): boolean { + return caseInsensitiveComparator.compare(a, b) === 0; +} + +function fixProfileKeyCase(key: string, schemaKeys: string[]): string|null { + return schemaKeys.find(val => isEquivalentIgnoreCase(key, val)) ?? null; +} + +const userDefinedKeys = [ + 'extensions', + 'WSL.integrations', + 'diagnostics.mutedChecks', +].map(s => s.split('.')); + /** * A "user-defined object" from the schema's point of view is an object that contains user-defined keys. * For example, `WSL.integrations` points to a user-defined object, while * `WSL` alone points to an object that contains only one key, `integrations`. - * @param pathParts - * @param key + * + * @param pathParts - On Windows, the parts of the registry path below KEY\Software\Rancher Desktop\Profile\{defaults|locked|} + * The first field is always either 'defaults' or 'locked' and can be ignored + * On other platforms its the path-parts up to but not including the root (which is unnamed anyway). * @returns boolean */ -function isUserDefinedObject(pathParts: string[], key: string): boolean { - if (pathParts.length > 3) { - return false; - } - switch (pathParts.length) { - case 0: - return key === 'extensions'; - case 1: - return ((key === 'integrations' && pathParts[0] === 'WSL') || - (key === 'mutedChecks' && pathParts[0] === 'diagnostics')); - } - - return false; +function haveUserDefinedObject(pathParts: string[]): boolean { + return userDefinedKeys.some(userDefinedKey => _.isEqual(userDefinedKey, pathParts)); }