diff --git a/api/dev/states/shares.ini b/api/dev/states/shares.ini index d4641f453..988a0ac34 100644 --- a/api/dev/states/shares.ini +++ b/api/dev/states/shares.ini @@ -65,4 +65,38 @@ color="yellow-on" size="0" free="9091184" used="32831348" +luksStatus="0" +["system.with.periods"] +name="system.with.periods" +nameOrig="system.with.periods" +comment="system data with periods" +allocator="highwater" +splitLevel="1" +floor="0" +include="" +exclude="" +useCache="prefer" +cachePool="cache" +cow="auto" +color="yellow-on" +size="0" +free="9091184" +used="32831348" +luksStatus="0" +["system.with.🚀"] +name="system.with.🚀" +nameOrig="system.with.🚀" +comment="system data with 🚀" +allocator="highwater" +splitLevel="1" +floor="0" +include="" +exclude="" +useCache="prefer" +cachePool="cache" +cow="auto" +color="yellow-on" +size="0" +free="9091184" +used="32831348" luksStatus="0" \ No newline at end of file diff --git a/api/src/__test__/core/utils/shares/get-shares.test.ts b/api/src/__test__/core/utils/shares/get-shares.test.ts index 97667e949..df6d04010 100644 --- a/api/src/__test__/core/utils/shares/get-shares.test.ts +++ b/api/src/__test__/core/utils/shares/get-shares.test.ts @@ -95,6 +95,48 @@ test('Returns both disk and user shares', async () => { "type": "user", "used": 33619300, }, + { + "allocator": "highwater", + "cachePool": "cache", + "color": "yellow-on", + "comment": "system data with periods", + "cow": "auto", + "exclude": [], + "floor": "0", + "free": 9309372, + "id": "system.with.periods", + "include": [], + "luksStatus": "0", + "name": "system.with.periods", + "nameOrig": "system.with.periods", + "nfs": {}, + "size": 0, + "smb": {}, + "splitLevel": "1", + "type": "user", + "used": 33619300, + }, + { + "allocator": "highwater", + "cachePool": "cache", + "color": "yellow-on", + "comment": "system data with 🚀", + "cow": "auto", + "exclude": [], + "floor": "0", + "free": 9309372, + "id": "system.with.🚀", + "include": [], + "luksStatus": "0", + "name": "system.with.🚀", + "nameOrig": "system.with.🚀", + "nfs": {}, + "size": 0, + "smb": {}, + "splitLevel": "1", + "type": "user", + "used": 33619300, + }, ], } `); @@ -211,6 +253,48 @@ test('Returns shares by type', async () => { "type": "user", "used": 33619300, }, + { + "allocator": "highwater", + "cachePool": "cache", + "color": "yellow-on", + "comment": "system data with periods", + "cow": "auto", + "exclude": [], + "floor": "0", + "free": 9309372, + "id": "system.with.periods", + "include": [], + "luksStatus": "0", + "name": "system.with.periods", + "nameOrig": "system.with.periods", + "nfs": {}, + "size": 0, + "smb": {}, + "splitLevel": "1", + "type": "user", + "used": 33619300, + }, + { + "allocator": "highwater", + "cachePool": "cache", + "color": "yellow-on", + "comment": "system data with 🚀", + "cow": "auto", + "exclude": [], + "floor": "0", + "free": 9309372, + "id": "system.with.🚀", + "include": [], + "luksStatus": "0", + "name": "system.with.🚀", + "nameOrig": "system.with.🚀", + "nfs": {}, + "size": 0, + "smb": {}, + "splitLevel": "1", + "type": "user", + "used": 33619300, + }, ] `); expect(getShares('disk')).toMatchInlineSnapshot('null'); diff --git a/api/src/__test__/store/modules/emhttp.test.ts b/api/src/__test__/store/modules/emhttp.test.ts index 793d9aa47..743397913 100644 --- a/api/src/__test__/store/modules/emhttp.test.ts +++ b/api/src/__test__/store/modules/emhttp.test.ts @@ -1,5 +1,6 @@ -import { expect, test } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { parseConfig } from '@app/core/utils/misc/parse-config.js'; import { store } from '@app/store/index.js'; import { FileLoadStatus } from '@app/store/types.js'; @@ -446,6 +447,44 @@ test('After init returns values from cfg file for all fields', { timeout: 30000 "splitLevel": "1", "used": 33619300, }, + { + "allocator": "highwater", + "cache": false, + "cachePool": "cache", + "color": "yellow-on", + "comment": "system data with periods", + "cow": "auto", + "exclude": [], + "floor": "0", + "free": 9309372, + "id": "system.with.periods", + "include": [], + "luksStatus": "0", + "name": "system.with.periods", + "nameOrig": "system.with.periods", + "size": 0, + "splitLevel": "1", + "used": 33619300, + }, + { + "allocator": "highwater", + "cache": false, + "cachePool": "cache", + "color": "yellow-on", + "comment": "system data with 🚀", + "cow": "auto", + "exclude": [], + "floor": "0", + "free": 9309372, + "id": "system.with.🚀", + "include": [], + "luksStatus": "0", + "name": "system.with.🚀", + "nameOrig": "system.with.🚀", + "size": 0, + "splitLevel": "1", + "used": 33619300, + }, ] `); expect(nfsShares).toMatchInlineSnapshot(` @@ -1110,3 +1149,209 @@ test('After init returns values from cfg file for all fields', { timeout: 30000 } `); }); + +describe('Share parsing with periods in names', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('parseConfig handles periods in INI section names', () => { + const mockIniContent = ` +["share.with.periods"] +name=share.with.periods +useCache=yes +include= +exclude= + +[normal_share] +name=normal_share +useCache=no +include= +exclude= +`; + + const result = parseConfig({ + file: mockIniContent, + type: 'ini', + }); + + // The result should now have properly flattened keys + + expect(result).toHaveProperty('shareWithPeriods'); + expect(result).toHaveProperty('normalShare'); + expect(result.shareWithPeriods.name).toBe('share.with.periods'); + expect(result.normalShare.name).toBe('normal_share'); + }); + + test('shares parser handles periods in share names correctly', async () => { + const { parse } = await import('@app/store/state-parsers/shares.js'); + + // The parser expects an object where values are share configs + const mockSharesState = { + shareWithPeriods: { + name: 'share.with.periods', + free: '1000000', + used: '500000', + size: '1500000', + include: '', + exclude: '', + useCache: 'yes', + }, + normalShare: { + name: 'normal_share', + free: '2000000', + used: '750000', + size: '2750000', + include: '', + exclude: '', + useCache: 'no', + }, + } as any; + + const result = parse(mockSharesState); + + expect(result).toHaveLength(2); + const periodShare = result.find((s) => s.name === 'share.with.periods'); + const normalShare = result.find((s) => s.name === 'normal_share'); + + expect(periodShare).toBeDefined(); + expect(periodShare?.id).toBe('share.with.periods'); + expect(periodShare?.name).toBe('share.with.periods'); + expect(periodShare?.cache).toBe(true); + + expect(normalShare).toBeDefined(); + expect(normalShare?.id).toBe('normal_share'); + expect(normalShare?.name).toBe('normal_share'); + expect(normalShare?.cache).toBe(false); + }); + + test('SMB parser handles periods in share names', async () => { + const { parse } = await import('@app/store/state-parsers/smb.js'); + + const mockSmbState = { + 'share.with.periods': { + export: 'e', + security: 'public', + writeList: '', + readList: '', + volsizelimit: '0', + }, + normal_share: { + export: 'e', + security: 'private', + writeList: 'user1,user2', + readList: '', + volsizelimit: '1000', + }, + } as any; + + const result = parse(mockSmbState); + + expect(result).toHaveLength(2); + const periodShare = result.find((s) => s.name === 'share.with.periods'); + const normalShare = result.find((s) => s.name === 'normal_share'); + + expect(periodShare).toBeDefined(); + expect(periodShare?.name).toBe('share.with.periods'); + expect(periodShare?.enabled).toBe(true); + + expect(normalShare).toBeDefined(); + expect(normalShare?.name).toBe('normal_share'); + expect(normalShare?.writeList).toEqual(['user1', 'user2']); + }); + + test('NFS parser handles periods in share names', async () => { + const { parse } = await import('@app/store/state-parsers/nfs.js'); + + const mockNfsState = { + 'share.with.periods': { + export: 'e', + security: 'public', + writeList: '', + readList: 'user1', + hostList: '', + }, + normal_share: { + export: 'd', + security: 'private', + writeList: 'user2', + readList: '', + hostList: '192.168.1.0/24', + }, + } as any; + + const result = parse(mockNfsState); + + expect(result).toHaveLength(2); + const periodShare = result.find((s) => s.name === 'share.with.periods'); + const normalShare = result.find((s) => s.name === 'normal_share'); + + expect(periodShare).toBeDefined(); + expect(periodShare?.name).toBe('share.with.periods'); + expect(periodShare?.enabled).toBe(true); + expect(periodShare?.readList).toEqual(['user1']); + + expect(normalShare).toBeDefined(); + expect(normalShare?.name).toBe('normal_share'); + expect(normalShare?.enabled).toBe(false); + }); +}); + +describe('Share lookup with periods in names', () => { + test('getShares finds user shares with periods in names', async () => { + // Mock the store state + const mockStore = await import('@app/store/index.js'); + const mockEmhttpState = { + shares: [ + { + id: 'share.with.periods', + name: 'share.with.periods', + cache: true, + free: 1000000, + used: 500000, + size: 1500000, + include: [], + exclude: [], + }, + { + id: 'normal_share', + name: 'normal_share', + cache: false, + free: 2000000, + used: 750000, + size: 2750000, + include: [], + exclude: [], + }, + ], + smbShares: [ + { name: 'share.with.periods', enabled: true, security: 'public' }, + { name: 'normal_share', enabled: true, security: 'private' }, + ], + nfsShares: [ + { name: 'share.with.periods', enabled: false }, + { name: 'normal_share', enabled: true }, + ], + disks: [], + }; + + const gettersSpy = vi.spyOn(mockStore, 'getters', 'get').mockReturnValue({ + emhttp: () => mockEmhttpState, + } as any); + + const { getShares } = await import('@app/core/utils/shares/get-shares.js'); + + const periodShare = getShares('user', { name: 'share.with.periods' }); + const normalShare = getShares('user', { name: 'normal_share' }); + + expect(periodShare).not.toBeNull(); + expect(periodShare?.name).toBe('share.with.periods'); + expect(periodShare?.type).toBe('user'); + + expect(normalShare).not.toBeNull(); + expect(normalShare?.name).toBe('normal_share'); + expect(normalShare?.type).toBe('user'); + + gettersSpy.mockRestore(); + }); +}); diff --git a/api/src/__test__/store/state-parsers/shares.test.ts b/api/src/__test__/store/state-parsers/shares.test.ts index 6435a72ed..08405a347 100644 --- a/api/src/__test__/store/state-parsers/shares.test.ts +++ b/api/src/__test__/store/state-parsers/shares.test.ts @@ -92,6 +92,44 @@ test('Returns parsed state file', async () => { "splitLevel": "1", "used": 33619300, }, + { + "allocator": "highwater", + "cache": false, + "cachePool": "cache", + "color": "yellow-on", + "comment": "system data with periods", + "cow": "auto", + "exclude": [], + "floor": "0", + "free": 9309372, + "id": "system.with.periods", + "include": [], + "luksStatus": "0", + "name": "system.with.periods", + "nameOrig": "system.with.periods", + "size": 0, + "splitLevel": "1", + "used": 33619300, + }, + { + "allocator": "highwater", + "cache": false, + "cachePool": "cache", + "color": "yellow-on", + "comment": "system data with 🚀", + "cow": "auto", + "exclude": [], + "floor": "0", + "free": 9309372, + "id": "system.with.🚀", + "include": [], + "luksStatus": "0", + "name": "system.with.🚀", + "nameOrig": "system.with.🚀", + "size": 0, + "splitLevel": "1", + "used": 33619300, + }, ] `); }); diff --git a/api/src/core/utils/misc/parse-config.ts b/api/src/core/utils/misc/parse-config.ts index 032fc94d9..5aadc19b0 100644 --- a/api/src/core/utils/misc/parse-config.ts +++ b/api/src/core/utils/misc/parse-config.ts @@ -23,6 +23,54 @@ type OptionsWithLoadedFile = { type: ConfigType; }; +/** + * Flattens nested objects that were incorrectly created by periods in INI section names. + * For example: { system: { with: { periods: {...} } } } -> { "system.with.periods": {...} } + */ +const flattenPeriodSections = (obj: Record, prefix = ''): Record => { + const result: Record = {}; + const isNestedObject = (value: unknown) => + Boolean(value && typeof value === 'object' && !Array.isArray(value)); + // prevent prototype pollution/injection + const isUnsafeKey = (k: string) => k === '__proto__' || k === 'prototype' || k === 'constructor'; + + for (const [key, value] of Object.entries(obj)) { + if (isUnsafeKey(key)) continue; + const fullKey = prefix ? `${prefix}.${key}` : key; + + if (!isNestedObject(value)) { + result[fullKey] = value; + continue; + } + + const section = {}; + const nestedObjs = {}; + let hasSectionProps = false; + + for (const [propKey, propValue] of Object.entries(value)) { + if (isUnsafeKey(propKey)) continue; + if (isNestedObject(propValue)) { + nestedObjs[propKey] = propValue; + } else { + section[propKey] = propValue; + hasSectionProps = true; + } + } + + // Process direct properties first to maintain order + if (hasSectionProps) { + result[fullKey] = section; + } + + // Then process nested objects + if (Object.keys(nestedObjs).length > 0) { + Object.assign(result, flattenPeriodSections(nestedObjs, fullKey)); + } + } + + return result; +}; + /** * Converts the following * ``` @@ -127,6 +175,8 @@ export const parseConfig = >( let data: Record; try { data = parseIni(fileContents); + // Fix nested objects created by periods in section names + data = flattenPeriodSections(data); } catch (error) { throw new AppError( `Failed to parse config file: ${error instanceof Error ? error.message : String(error)}` diff --git a/api/src/store/initial-state/initial-config-state.ts b/api/src/store/initial-state/initial-config-state.ts deleted file mode 100644 index e69de29bb..000000000