From cd41cf87df81d8a28bce60aeee622a72c0fb95bd Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Thu, 30 Apr 2026 15:48:08 +0530 Subject: [PATCH 01/11] fix: correctly check for relative path between the collection and the file Co-authored-by: Prateek Sunal --- .../ClientCertSettings/index.js | 4 +- .../src/components/FilePickerEditor/index.js | 10 +- .../RequestPane/Auth/OAuth1/index.js | 5 +- .../RequestPane/MultipartFormParams/index.js | 8 +- .../index.js | 8 +- packages/bruno-app/src/utils/common/path.js | 27 +++- .../bruno-app/src/utils/common/path.spec.js | 53 +++++++- .../src/utils/common/path.windows.spec.js | 64 ++++++++- .../multipart-file-path.spec.ts | 121 ++++++++++++++++++ tests/utils/page/actions.ts | 47 ++++++- 10 files changed, 318 insertions(+), 29 deletions(-) create mode 100644 tests/collection/opencollection/multipart-file-path.spec.ts diff --git a/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js index 7a671432871..a2b34059281 100644 --- a/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js @@ -4,7 +4,7 @@ import { useFormik } from 'formik'; import * as Yup from 'yup'; import StyledWrapper from './StyledWrapper'; import { useRef } from 'react'; -import path from 'utils/common/path'; +import path, { getStoredFilePath } from 'utils/common/path'; import SensitiveFieldWarning from 'components/SensitiveFieldWarning/index'; import SingleLineEditor from 'components/SingleLineEditor/index'; import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField/index'; @@ -98,7 +98,7 @@ const ClientCertSettings = ({ collection }) => { const getFile = (e) => { const filePath = window?.ipcRenderer?.getFilePath(e?.files?.[0]); if (filePath) { - let relativePath = path.relative(collection.pathname, filePath); + let relativePath = getStoredFilePath(collection.pathname, filePath); formik.setFieldValue(e.name, relativePath); } }; diff --git a/packages/bruno-app/src/components/FilePickerEditor/index.js b/packages/bruno-app/src/components/FilePickerEditor/index.js index 65f9046b5fc..36aeb499bdf 100644 --- a/packages/bruno-app/src/components/FilePickerEditor/index.js +++ b/packages/bruno-app/src/components/FilePickerEditor/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import path from 'utils/common/path'; +import { getStoredFilePath } from 'utils/common/path'; import { useDispatch } from 'react-redux'; import { browseFiles } from 'providers/ReduxStore/slices/collections/actions'; import { IconX, IconUpload, IconFile } from '@tabler/icons'; @@ -48,13 +48,7 @@ const FilePickerEditor = ({ // If file is in the collection's directory, then we use relative path // Otherwise, we use the absolute path filePaths = filePaths.map((filePath) => { - const collectionDir = collection.pathname; - - if (filePath.startsWith(collectionDir)) { - return path.relative(collectionDir, filePath); - } - - return filePath; + return getStoredFilePath(collection.pathname, filePath); }); onChange(isSingleFilePicker ? filePaths[0] : filePaths); diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js index 3b8d5774375..1e5515c37d6 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import get from 'lodash/get'; import { useTheme } from 'providers/Theme'; import { useDispatch } from 'react-redux'; -import path from 'utils/common/path'; +import path, { getStoredFilePath } from 'utils/common/path'; import { IconSettings, IconShieldLock, IconAdjustmentsHorizontal, IconCaretDown, IconChevronRight, IconFile, IconX, IconUpload } from '@tabler/icons'; import MenuDropdown from 'ui/MenuDropdown'; import SingleLineEditor from 'components/SingleLineEditor'; @@ -70,8 +70,7 @@ const OAuth1 = ({ item = {}, collection, request, save, updateAuth }) => { .then((filePaths) => { if (filePaths && filePaths.length > 0) { let filePath = filePaths[0]; - const collectionDir = collection.pathname; - filePath = path.relative(collectionDir, filePath); + filePath = getStoredFilePath(collection.pathname, filePath); dispatch( updateAuth({ mode: 'oauth1', diff --git a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js index 530468445ae..e2f1121a065 100644 --- a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js @@ -14,7 +14,7 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs'; import EditableTable from 'components/EditableTable'; import StyledWrapper from './StyledWrapper'; -import path from 'utils/common/path'; +import { getStoredFilePath } from 'utils/common/path'; import { usePersistedState } from 'hooks/usePersistedState'; import { useTrackScroll } from 'hooks/useTrackScroll'; import { isWindowsOS } from 'utils/common/platform'; @@ -60,11 +60,7 @@ const MultipartFormParams = ({ item, collection }) => { dispatch(browseFiles()) .then((filePaths) => { const processedPaths = filePaths.map((filePath) => { - const collectionDir = collection.pathname; - if (filePath.startsWith(collectionDir)) { - return path.relative(collectionDir, filePath); - } - return filePath; + return getStoredFilePath(collection.pathname, filePath); }); const currentParams = item.draft diff --git a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/index.js b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/index.js index ef327f9a2b9..d71aee148f2 100644 --- a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/index.js +++ b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/index.js @@ -7,7 +7,7 @@ import { updateResponseExampleMultipartFormParams } from 'providers/ReduxStore/s import { browseFiles } from 'providers/ReduxStore/slices/collections/actions'; import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs'; import mime from 'mime-types'; -import path from 'utils/common/path'; +import path, { getStoredFilePath } from 'utils/common/path'; import EditableTable from 'components/EditableTable'; import MultiLineEditor from 'components/MultiLineEditor'; import SingleLineEditor from 'components/SingleLineEditor'; @@ -51,11 +51,7 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit dispatch(browseFiles()) .then((filePaths) => { const processedPaths = filePaths.map((filePath) => { - const collectionDir = collection.pathname; - if (filePath.startsWith(collectionDir)) { - return path.relative(collectionDir, filePath); - } - return filePath; + return getStoredFilePath(collection.pathname, filePath); }); const currentParams = params || []; diff --git a/packages/bruno-app/src/utils/common/path.js b/packages/bruno-app/src/utils/common/path.js index e1b8e826f13..9eeda605669 100644 --- a/packages/bruno-app/src/utils/common/path.js +++ b/packages/bruno-app/src/utils/common/path.js @@ -163,10 +163,35 @@ const getAbsoluteFilePath = (basePath, relativePath, shouldPosixify = false) => return shouldPosixify ? posixify(result) : result; }; +const getStoredFilePath = (collectionPath, filePath) => { + if (!collectionPath || !filePath) { + return filePath; + } + + try { + const resolvedCollectionPath = brunoPath.resolve(collectionPath); + const resolvedFilePath = brunoPath.resolve(filePath); + const relativePath = brunoPath.relative(resolvedCollectionPath, resolvedFilePath); + + if ( + !relativePath + || relativePath === '..' + || relativePath.startsWith(`..${brunoPath.sep}`) + || brunoPath.isAbsolute(relativePath) + ) { + return filePath; + } + + return posixify(relativePath); + } catch (error) { + return filePath; + } +}; + const normalizePath = (p) => { if (!p) return ''; return p.replace(/\\/g, '/').replace(/\/+$/, ''); }; export default brunoPath; -export { getRelativePath, getBasename, getAbsoluteFilePath, normalizePath }; +export { getRelativePath, getBasename, getAbsoluteFilePath, getStoredFilePath, normalizePath }; diff --git a/packages/bruno-app/src/utils/common/path.spec.js b/packages/bruno-app/src/utils/common/path.spec.js index 4df2d6606db..49368621a93 100644 --- a/packages/bruno-app/src/utils/common/path.spec.js +++ b/packages/bruno-app/src/utils/common/path.spec.js @@ -5,7 +5,7 @@ jest.mock('platform', () => ({ } })); -import { getRelativePath, getBasename, getAbsoluteFilePath } from './path'; +import { getRelativePath, getBasename, getAbsoluteFilePath, getStoredFilePath } from './path'; describe('Path Utilities - Unix Platform', () => { describe('getRelativePath', () => { @@ -113,6 +113,57 @@ describe('Path Utilities - Unix Platform', () => { }); }); + describe('getStoredFilePath', () => { + it('should store in-collection files as relative paths', () => { + const result = getStoredFilePath('/users/john/collections/api', '/users/john/collections/api/files/payload.txt'); + expect(result).toBe('files/payload.txt'); + }); + + it('should handle collection paths with trailing separators', () => { + const result = getStoredFilePath('/users/john/collections/api/', '/users/john/collections/api/files/payload.txt'); + expect(result).toBe('files/payload.txt'); + }); + + it('should resolve dot segments before deciding whether a file is inside the collection', () => { + const result = getStoredFilePath('/users/john/collections/api', '/users/john/collections/api/files/../payload.txt'); + expect(result).toBe('payload.txt'); + }); + + it('should keep paths that resolve outside the collection absolute', () => { + const filePath = '/users/john/collections/api/../payload.txt'; + const result = getStoredFilePath('/users/john/collections/api', filePath); + expect(result).toBe(filePath); + }); + + it('should keep outside collection paths absolute', () => { + const filePath = '/users/john/downloads/payload.txt'; + const result = getStoredFilePath('/users/john/collections/api', filePath); + expect(result).toBe(filePath); + }); + + it('should keep sibling prefix paths absolute', () => { + const filePath = '/users/john/collections/api-other/payload.txt'; + const result = getStoredFilePath('/users/john/collections/api', filePath); + expect(result).toBe(filePath); + }); + + it('should keep same-path values unchanged', () => { + const filePath = '/users/john/collections/api'; + const result = getStoredFilePath('/users/john/collections/api', filePath); + expect(result).toBe(filePath); + }); + + it('should store in-collection paths whose names begin with two dots as relative paths', () => { + const result = getStoredFilePath('/users/john/collections/api', '/users/john/collections/api/..payload.txt'); + expect(result).toBe('..payload.txt'); + }); + + it('should keep the original file path when inputs are missing', () => { + expect(getStoredFilePath('', '/users/john/downloads/payload.txt')).toBe('/users/john/downloads/payload.txt'); + expect(getStoredFilePath('/users/john/collections/api', '')).toBe(''); + }); + }); + describe('Edge cases', () => { it('should handle very long paths', () => { const longPath = '/users/john/projects/' + 'a'.repeat(100); diff --git a/packages/bruno-app/src/utils/common/path.windows.spec.js b/packages/bruno-app/src/utils/common/path.windows.spec.js index b94e5a49992..08f2e8185f5 100644 --- a/packages/bruno-app/src/utils/common/path.windows.spec.js +++ b/packages/bruno-app/src/utils/common/path.windows.spec.js @@ -5,7 +5,7 @@ jest.mock('platform', () => ({ } })); -import { getRelativePath, getBasename, getAbsoluteFilePath } from './path'; +import { getRelativePath, getBasename, getAbsoluteFilePath, getStoredFilePath } from './path'; describe('Path Utilities - Windows Platform', () => { describe('getRelativePath', () => { @@ -181,6 +181,68 @@ describe('Path Utilities - Windows Platform', () => { }); }); + describe('getStoredFilePath', () => { + it('should store in-collection files as POSIX relative paths with mixed separators', () => { + const result = getStoredFilePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt'); + expect(result).toBe('files/payload.txt'); + }); + + it('should store nested in-collection files as POSIX relative paths', () => { + const result = getStoredFilePath('C:\\Users\\John\\Collections\\Api', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt'); + expect(result).toBe('folder/payload.txt'); + }); + + it('should handle collection paths with trailing separators', () => { + const result = getStoredFilePath('C:\\Users\\John\\Collections\\Api\\', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt'); + expect(result).toBe('folder/payload.txt'); + }); + + it('should handle case differences in Windows drive paths', () => { + const result = getStoredFilePath('c:\\users\\john\\collections\\api', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt'); + expect(result).toBe('folder/payload.txt'); + }); + + it('should resolve dot segments before deciding whether a file is inside the collection', () => { + const result = getStoredFilePath('C:\\Users\\John\\Collections\\Api', 'C:\\Users\\John\\Collections\\Api\\folder\\..\\payload.txt'); + expect(result).toBe('payload.txt'); + }); + + it('should keep paths that resolve outside the collection absolute', () => { + const filePath = 'C:\\Users\\John\\Collections\\Api\\..\\payload.txt'; + const result = getStoredFilePath('C:\\Users\\John\\Collections\\Api', filePath); + expect(result).toBe(filePath); + }); + + it('should keep sibling prefix paths absolute', () => { + const filePath = 'C:\\Users\\John\\Collections\\ApiOther\\payload.txt'; + const result = getStoredFilePath('C:\\Users\\John\\Collections\\Api', filePath); + expect(result).toBe(filePath); + }); + + it('should keep outside collection paths absolute', () => { + const filePath = 'C:\\Users\\John\\Downloads\\payload.txt'; + const result = getStoredFilePath('C:\\Users\\John\\Collections\\Api', filePath); + expect(result).toBe(filePath); + }); + + it('should keep cross-drive paths absolute', () => { + const filePath = 'D:\\payload.txt'; + const result = getStoredFilePath('C:\\Users\\John\\Collections\\Api', filePath); + expect(result).toBe(filePath); + }); + + it('should keep same-path values unchanged', () => { + const filePath = 'C:\\Users\\John\\Collections\\Api'; + const result = getStoredFilePath('C:\\Users\\John\\Collections\\Api', filePath); + expect(result).toBe(filePath); + }); + + it('should keep the original file path when inputs are missing', () => { + expect(getStoredFilePath('', 'C:\\Users\\John\\Downloads\\payload.txt')).toBe('C:\\Users\\John\\Downloads\\payload.txt'); + expect(getStoredFilePath('C:\\Users\\John\\Collections\\Api', '')).toBe(''); + }); + }); + describe('Cross-platform path handling', () => { describe('Windows fromPath with POSIX toPath', () => { it('should handle Windows fromPath with POSIX toPath in getAbsoluteFilePath', () => { diff --git a/tests/collection/opencollection/multipart-file-path.spec.ts b/tests/collection/opencollection/multipart-file-path.spec.ts new file mode 100644 index 00000000000..672ab7d8d4b --- /dev/null +++ b/tests/collection/opencollection/multipart-file-path.spec.ts @@ -0,0 +1,121 @@ +import { test, expect, closeElectronApp } from '../../../playwright'; +import { + addMultipartFileToLastRow, + createRequest, + openRequest, + removeFirstMultipartFile, + saveRequest, + selectRequestBodyMode, + selectRequestPaneTab +} from '../../utils/page'; +import * as fs from 'fs'; +import * as path from 'path'; + +const collectionName = 'RelativePathBug'; +const requestName = 'upload-payload'; +const relativePayloadPath = 'files/payload.json'; + +const writeJson = async (filePath: string, value: unknown) => { + await fs.promises.writeFile(filePath, JSON.stringify(value, null, 2), 'utf-8'); +}; + +const setupOpenCollection = async (collectionDir: string, userDataDir: string) => { + await fs.promises.mkdir(path.join(collectionDir, 'files'), { recursive: true }); + await fs.promises.mkdir(userDataDir, { recursive: true }); + + await fs.promises.writeFile( + path.join(collectionDir, 'opencollection.yml'), + [ + 'opencollection: "1.0.0"', + 'info:', + ` name: ${collectionName}`, + ' type: collection', + '' + ].join('\n'), + 'utf-8' + ); + + await fs.promises.writeFile( + path.join(collectionDir, relativePayloadPath), + '{"ok":true}\n', + 'utf-8' + ); + + await writeJson(path.join(userDataDir, 'preferences.json'), { + lastOpenedCollections: [collectionDir], + preferences: { + onboarding: { + hasLaunchedBefore: true, + hasSeenWelcomeModal: true + } + } + }); + + await writeJson(path.join(userDataDir, 'collection-security.json'), { + collections: [ + { + path: collectionDir, + securityConfig: { + jsSandboxMode: 'safe' + } + } + ] + }); +}; + +const expectRequestFileToContainRelativePayload = async (requestFilePath: string, payloadPath: string) => { + await expect.poll(async () => fs.existsSync(requestFilePath)).toBe(true); + await expect.poll(async () => fs.promises.readFile(requestFilePath, 'utf-8')).toContain(relativePayloadPath); + await expect.poll(async () => fs.promises.readFile(requestFilePath, 'utf-8')).not.toContain(payloadPath); +}; + +test.describe('OpenCollection multipart file paths', () => { + test('keeps an in-collection multipart file relative after restart, OpenCollection edit, remove, and re-add', async ({ + launchElectronApp, + createTmpDir + }) => { + const collectionDir = path.join(await createTmpDir('opencollection-multipart'), collectionName); + const userDataDir = await createTmpDir('opencollection-multipart-userdata'); + const payloadPath = path.join(collectionDir, relativePayloadPath); + const requestFilePath = path.join(collectionDir, `${requestName}.yml`); + + await setupOpenCollection(collectionDir, userDataDir); + + let electronApp = await launchElectronApp({ userDataPath: userDataDir }); + let page = await electronApp.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await expect(page.locator('#sidebar-collection-name').filter({ hasText: collectionName })).toBeVisible(); + await createRequest(page, requestName, collectionName, { + url: 'https://example.com/upload', + method: 'POST' + }); + await expect.poll(async () => fs.existsSync(requestFilePath), { + timeout: 15000 + }).toBe(true); + + await selectRequestBodyMode(page, 'Multipart Form'); + + await addMultipartFileToLastRow(page, electronApp, payloadPath); + await saveRequest(page); + await expectRequestFileToContainRelativePayload(requestFilePath, payloadPath); + + await closeElectronApp(electronApp); + await fs.promises.appendFile(path.join(collectionDir, 'opencollection.yml'), '\n\n', 'utf-8'); + + electronApp = await launchElectronApp({ userDataPath: userDataDir }); + page = await electronApp.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + await expect(page.locator('#sidebar-collection-name').filter({ hasText: collectionName })).toBeVisible(); + + await openRequest(page, collectionName, requestName, { persist: true }); + await selectRequestPaneTab(page, 'Body'); + await removeFirstMultipartFile(page); + await saveRequest(page); + + await addMultipartFileToLastRow(page, electronApp, payloadPath); + await saveRequest(page); + + await expectRequestFileToContainRelativePayload(requestFilePath, payloadPath); + }); +}); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 5d4ade56580..9ecb0bd9964 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -1,5 +1,6 @@ -import { test, expect, Page } from '../../../playwright'; +import { test, expect, Page, ElectronApplication } from '../../../playwright'; import process from 'node:process'; +import * as path from 'path'; import { buildCommonLocators, buildScriptErrorLocators } from './locators'; type SandboxMode = 'safe' | 'developer'; @@ -854,6 +855,46 @@ const selectRequestPaneTab = async (page: Page, tabName: string) => { await selectPaneTab(page, '[data-testid="request-pane"] > .px-4', tabName); }; +const selectRequestBodyMode = async (page: Page, mode: string) => { + await test.step(`Select request body mode "${mode}"`, async () => { + await selectRequestPaneTab(page, 'Body'); + const locators = buildCommonLocators(page); + await locators.request.bodyModeSelector().click(); + await locators.dropdown.item(mode).click(); + }); +}; + +const mockBrowseFiles = async (electronApp: ElectronApplication, filePaths: string[]) => { + await electronApp.evaluate(({ dialog }, selectedPaths: string[]) => { + dialog.showOpenDialog = async () => ({ + canceled: false, + filePaths: selectedPaths + }); + }, filePaths); +}; + +const addMultipartFileToLastRow = async (page: Page, electronApp: ElectronApplication, filePath: string) => { + await test.step(`Add multipart file "${path.basename(filePath)}"`, async () => { + await mockBrowseFiles(electronApp, [filePath]); + + const table = buildCommonLocators(page).table('editable-table'); + const lastRow = table.allRows().last(); + + await expect(lastRow.locator('.upload-btn')).toBeVisible(); + await lastRow.locator('.upload-btn').click(); + await expect(table.allRows().locator('.file-value-cell').first()).toContainText(path.basename(filePath)); + }); +}; + +const removeFirstMultipartFile = async (page: Page) => { + await test.step('Remove first multipart file', async () => { + const table = buildCommonLocators(page).table('editable-table'); + await expect(table.allRows().locator('.file-value-cell').first()).toBeVisible(); + await table.allRows().first().locator('.clear-file-btn').click(); + await expect(table.allRows().first().locator('.upload-btn')).toBeVisible(); + }); +}; + /** * Verify response contains specific text * @param page - The page object @@ -1224,7 +1265,11 @@ export { getResponseBody, expectResponseContains, selectRequestPaneTab, + selectRequestBodyMode, selectResponsePaneTab, + mockBrowseFiles, + addMultipartFileToLastRow, + removeFirstMultipartFile, sendRequestAndWaitForResponse, switchResponseFormat, switchToPreviewTab, From 2be67a2141af60623da01e402deb26e4da59772b Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Thu, 30 Apr 2026 17:07:02 +0530 Subject: [PATCH 02/11] don't posixify Co-authored-by: Prateek Sunal --- packages/bruno-app/src/utils/common/path.js | 2 +- .../bruno-app/src/utils/common/path.windows.spec.js | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/bruno-app/src/utils/common/path.js b/packages/bruno-app/src/utils/common/path.js index 9eeda605669..954325083fb 100644 --- a/packages/bruno-app/src/utils/common/path.js +++ b/packages/bruno-app/src/utils/common/path.js @@ -182,7 +182,7 @@ const getStoredFilePath = (collectionPath, filePath) => { return filePath; } - return posixify(relativePath); + return relativePath; } catch (error) { return filePath; } diff --git a/packages/bruno-app/src/utils/common/path.windows.spec.js b/packages/bruno-app/src/utils/common/path.windows.spec.js index 08f2e8185f5..628eaf1dfc1 100644 --- a/packages/bruno-app/src/utils/common/path.windows.spec.js +++ b/packages/bruno-app/src/utils/common/path.windows.spec.js @@ -182,24 +182,24 @@ describe('Path Utilities - Windows Platform', () => { }); describe('getStoredFilePath', () => { - it('should store in-collection files as POSIX relative paths with mixed separators', () => { + it('should store in-collection files as Windows relative paths with mixed separators', () => { const result = getStoredFilePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt'); - expect(result).toBe('files/payload.txt'); + expect(result).toBe('files\\payload.txt'); }); - it('should store nested in-collection files as POSIX relative paths', () => { + it('should store nested in-collection files as Windows relative paths', () => { const result = getStoredFilePath('C:\\Users\\John\\Collections\\Api', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt'); - expect(result).toBe('folder/payload.txt'); + expect(result).toBe('folder\\payload.txt'); }); it('should handle collection paths with trailing separators', () => { const result = getStoredFilePath('C:\\Users\\John\\Collections\\Api\\', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt'); - expect(result).toBe('folder/payload.txt'); + expect(result).toBe('folder\\payload.txt'); }); it('should handle case differences in Windows drive paths', () => { const result = getStoredFilePath('c:\\users\\john\\collections\\api', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt'); - expect(result).toBe('folder/payload.txt'); + expect(result).toBe('folder\\payload.txt'); }); it('should resolve dot segments before deciding whether a file is inside the collection', () => { From 1d8aab1201a8335c5dd9c121d1989b588915c41c Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Thu, 30 Apr 2026 17:51:53 +0530 Subject: [PATCH 03/11] fix review comments Co-authored-by: Prateek Sunal --- tests/utils/page/actions.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 9ecb0bd9964..b9b1f431bc4 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -866,10 +866,14 @@ const selectRequestBodyMode = async (page: Page, mode: string) => { const mockBrowseFiles = async (electronApp: ElectronApplication, filePaths: string[]) => { await electronApp.evaluate(({ dialog }, selectedPaths: string[]) => { - dialog.showOpenDialog = async () => ({ - canceled: false, - filePaths: selectedPaths - }); + const originalShowOpenDialog = dialog.showOpenDialog; + dialog.showOpenDialog = async (...args) => { + dialog.showOpenDialog = originalShowOpenDialog; + return { + canceled: false, + filePaths: selectedPaths + }; + }; }, filePaths); }; @@ -882,7 +886,7 @@ const addMultipartFileToLastRow = async (page: Page, electronApp: ElectronApplic await expect(lastRow.locator('.upload-btn')).toBeVisible(); await lastRow.locator('.upload-btn').click(); - await expect(table.allRows().locator('.file-value-cell').first()).toContainText(path.basename(filePath)); + await expect(lastRow.locator('.file-value-cell')).toContainText(path.basename(filePath)); }); }; From 48486564f439169a624527d1a497d79cce4c5ee3 Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Thu, 30 Apr 2026 17:55:58 +0530 Subject: [PATCH 04/11] Improve the test Co-authored-by: Prateek Sunal --- .../multipart-file-path.spec.ts | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/tests/collection/opencollection/multipart-file-path.spec.ts b/tests/collection/opencollection/multipart-file-path.spec.ts index 672ab7d8d4b..5ca32232bad 100644 --- a/tests/collection/opencollection/multipart-file-path.spec.ts +++ b/tests/collection/opencollection/multipart-file-path.spec.ts @@ -1,7 +1,6 @@ import { test, expect, closeElectronApp } from '../../../playwright'; import { addMultipartFileToLastRow, - createRequest, openRequest, removeFirstMultipartFile, saveRequest, @@ -41,6 +40,28 @@ const setupOpenCollection = async (collectionDir: string, userDataDir: string) = 'utf-8' ); + await fs.promises.writeFile( + path.join(collectionDir, `${requestName}.yml`), + [ + 'info:', + ` name: ${requestName}`, + ' type: http', + ' seq: 1', + '', + 'http:', + ' method: POST', + ' url: https://example.com/upload', + '', + 'settings:', + ' encodeUrl: true', + ' timeout: 0', + ' followRedirects: true', + ' maxRedirects: 5', + '' + ].join('\n'), + 'utf-8' + ); + await writeJson(path.join(userDataDir, 'preferences.json'), { lastOpenedCollections: [collectionDir], preferences: { @@ -86,14 +107,11 @@ test.describe('OpenCollection multipart file paths', () => { await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); await expect(page.locator('#sidebar-collection-name').filter({ hasText: collectionName })).toBeVisible(); - await createRequest(page, requestName, collectionName, { - url: 'https://example.com/upload', - method: 'POST' - }); await expect.poll(async () => fs.existsSync(requestFilePath), { timeout: 15000 }).toBe(true); + await openRequest(page, collectionName, requestName, { persist: true }); await selectRequestBodyMode(page, 'Multipart Form'); await addMultipartFileToLastRow(page, electronApp, payloadPath); From 5907558db4e38d1a2913fd0e8ed7503d883f1b49 Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Thu, 30 Apr 2026 19:20:11 +0530 Subject: [PATCH 05/11] Fix join for windows Co-authored-by: Prateek Sunal --- tests/collection/opencollection/multipart-file-path.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/collection/opencollection/multipart-file-path.spec.ts b/tests/collection/opencollection/multipart-file-path.spec.ts index 5ca32232bad..a969aaa7094 100644 --- a/tests/collection/opencollection/multipart-file-path.spec.ts +++ b/tests/collection/opencollection/multipart-file-path.spec.ts @@ -12,7 +12,7 @@ import * as path from 'path'; const collectionName = 'RelativePathBug'; const requestName = 'upload-payload'; -const relativePayloadPath = 'files/payload.json'; +const relativePayloadPath = path.join('files', 'payload.json'); const writeJson = async (filePath: string, value: unknown) => { await fs.promises.writeFile(filePath, JSON.stringify(value, null, 2), 'utf-8'); @@ -86,7 +86,7 @@ const setupOpenCollection = async (collectionDir: string, userDataDir: string) = const expectRequestFileToContainRelativePayload = async (requestFilePath: string, payloadPath: string) => { await expect.poll(async () => fs.existsSync(requestFilePath)).toBe(true); - await expect.poll(async () => fs.promises.readFile(requestFilePath, 'utf-8')).toContain(relativePayloadPath); + await expect.poll(async () => fs.promises.readFile(requestFilePath, 'utf-8')).toContain(` ${relativePayloadPath}\n`); await expect.poll(async () => fs.promises.readFile(requestFilePath, 'utf-8')).not.toContain(payloadPath); }; From 804866e91f72f8c96cacb3606d18318d17de2862 Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Thu, 30 Apr 2026 19:35:56 +0530 Subject: [PATCH 06/11] update as per review Co-authored-by: Prateek Sunal --- .../collection/opencollection/multipart-file-path.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/collection/opencollection/multipart-file-path.spec.ts b/tests/collection/opencollection/multipart-file-path.spec.ts index a969aaa7094..9e940aa9f60 100644 --- a/tests/collection/opencollection/multipart-file-path.spec.ts +++ b/tests/collection/opencollection/multipart-file-path.spec.ts @@ -90,6 +90,11 @@ const expectRequestFileToContainRelativePayload = async (requestFilePath: string await expect.poll(async () => fs.promises.readFile(requestFilePath, 'utf-8')).not.toContain(payloadPath); }; +const expectRequestFileNotToContainPayload = async (requestFilePath: string, payloadPath: string) => { + await expect.poll(async () => fs.promises.readFile(requestFilePath, 'utf-8')).not.toContain(` ${relativePayloadPath}\n`); + await expect.poll(async () => fs.promises.readFile(requestFilePath, 'utf-8')).not.toContain(payloadPath); +}; + test.describe('OpenCollection multipart file paths', () => { test('keeps an in-collection multipart file relative after restart, OpenCollection edit, remove, and re-add', async ({ launchElectronApp, @@ -130,10 +135,12 @@ test.describe('OpenCollection multipart file paths', () => { await selectRequestPaneTab(page, 'Body'); await removeFirstMultipartFile(page); await saveRequest(page); + await expectRequestFileNotToContainPayload(requestFilePath, payloadPath); await addMultipartFileToLastRow(page, electronApp, payloadPath); await saveRequest(page); await expectRequestFileToContainRelativePayload(requestFilePath, payloadPath); + await closeElectronApp(electronApp); }); }); From 2e8f95dc5629ff006756d9fc3a59b78927fdfc85 Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Tue, 5 May 2026 00:53:29 +0530 Subject: [PATCH 07/11] chore: add doc for getStoredFilePath Co-authored-by: Prateek Sunal --- packages/bruno-app/src/utils/common/path.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/bruno-app/src/utils/common/path.js b/packages/bruno-app/src/utils/common/path.js index 954325083fb..e01765dbe9e 100644 --- a/packages/bruno-app/src/utils/common/path.js +++ b/packages/bruno-app/src/utils/common/path.js @@ -163,6 +163,8 @@ const getAbsoluteFilePath = (basePath, relativePath, shouldPosixify = false) => return shouldPosixify ? posixify(result) : result; }; +// Stores in-collection files as relative paths using the current platform's +// separator style: backslashes on Windows, forward slashes on Unix-like systems. const getStoredFilePath = (collectionPath, filePath) => { if (!collectionPath || !filePath) { return filePath; From 3383847c2f0046e046ac3d967e441d461408e76b Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Wed, 6 May 2026 21:17:06 +0530 Subject: [PATCH 08/11] fix: rename to getRelativePathWithinBasePath Co-authored-by: Prateek Sunal --- .../ClientCertSettings/index.js | 4 +- .../src/components/FilePickerEditor/index.js | 4 +- .../RequestPane/Auth/OAuth1/index.js | 4 +- .../RequestPane/MultipartFormParams/index.js | 4 +- .../index.js | 4 +- packages/bruno-app/src/utils/common/path.js | 16 +++--- .../bruno-app/src/utils/common/path.spec.js | 54 ++++++++++++++----- .../src/utils/common/path.windows.spec.js | 40 +++++++++----- 8 files changed, 86 insertions(+), 44 deletions(-) diff --git a/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js index a2b34059281..5895f274c17 100644 --- a/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js @@ -4,7 +4,7 @@ import { useFormik } from 'formik'; import * as Yup from 'yup'; import StyledWrapper from './StyledWrapper'; import { useRef } from 'react'; -import path, { getStoredFilePath } from 'utils/common/path'; +import path, { getRelativePathWithinBasePath } from 'utils/common/path'; import SensitiveFieldWarning from 'components/SensitiveFieldWarning/index'; import SingleLineEditor from 'components/SingleLineEditor/index'; import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField/index'; @@ -98,7 +98,7 @@ const ClientCertSettings = ({ collection }) => { const getFile = (e) => { const filePath = window?.ipcRenderer?.getFilePath(e?.files?.[0]); if (filePath) { - let relativePath = getStoredFilePath(collection.pathname, filePath); + let relativePath = getRelativePathWithinBasePath(collection.pathname, filePath); formik.setFieldValue(e.name, relativePath); } }; diff --git a/packages/bruno-app/src/components/FilePickerEditor/index.js b/packages/bruno-app/src/components/FilePickerEditor/index.js index 36aeb499bdf..907b98eac68 100644 --- a/packages/bruno-app/src/components/FilePickerEditor/index.js +++ b/packages/bruno-app/src/components/FilePickerEditor/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import { getStoredFilePath } from 'utils/common/path'; +import { getRelativePathWithinBasePath } from 'utils/common/path'; import { useDispatch } from 'react-redux'; import { browseFiles } from 'providers/ReduxStore/slices/collections/actions'; import { IconX, IconUpload, IconFile } from '@tabler/icons'; @@ -48,7 +48,7 @@ const FilePickerEditor = ({ // If file is in the collection's directory, then we use relative path // Otherwise, we use the absolute path filePaths = filePaths.map((filePath) => { - return getStoredFilePath(collection.pathname, filePath); + return getRelativePathWithinBasePath(collection.pathname, filePath); }); onChange(isSingleFilePicker ? filePaths[0] : filePaths); diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js index 1e5515c37d6..dbdbaf15fdd 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import get from 'lodash/get'; import { useTheme } from 'providers/Theme'; import { useDispatch } from 'react-redux'; -import path, { getStoredFilePath } from 'utils/common/path'; +import path, { getRelativePathWithinBasePath } from 'utils/common/path'; import { IconSettings, IconShieldLock, IconAdjustmentsHorizontal, IconCaretDown, IconChevronRight, IconFile, IconX, IconUpload } from '@tabler/icons'; import MenuDropdown from 'ui/MenuDropdown'; import SingleLineEditor from 'components/SingleLineEditor'; @@ -70,7 +70,7 @@ const OAuth1 = ({ item = {}, collection, request, save, updateAuth }) => { .then((filePaths) => { if (filePaths && filePaths.length > 0) { let filePath = filePaths[0]; - filePath = getStoredFilePath(collection.pathname, filePath); + filePath = getRelativePathWithinBasePath(collection.pathname, filePath); dispatch( updateAuth({ mode: 'oauth1', diff --git a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js index e2f1121a065..33cd651aaf3 100644 --- a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js @@ -14,7 +14,7 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs'; import EditableTable from 'components/EditableTable'; import StyledWrapper from './StyledWrapper'; -import { getStoredFilePath } from 'utils/common/path'; +import { getRelativePathWithinBasePath } from 'utils/common/path'; import { usePersistedState } from 'hooks/usePersistedState'; import { useTrackScroll } from 'hooks/useTrackScroll'; import { isWindowsOS } from 'utils/common/platform'; @@ -60,7 +60,7 @@ const MultipartFormParams = ({ item, collection }) => { dispatch(browseFiles()) .then((filePaths) => { const processedPaths = filePaths.map((filePath) => { - return getStoredFilePath(collection.pathname, filePath); + return getRelativePathWithinBasePath(collection.pathname, filePath); }); const currentParams = item.draft diff --git a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/index.js b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/index.js index d71aee148f2..03faf08cbbd 100644 --- a/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/index.js +++ b/packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/index.js @@ -7,7 +7,7 @@ import { updateResponseExampleMultipartFormParams } from 'providers/ReduxStore/s import { browseFiles } from 'providers/ReduxStore/slices/collections/actions'; import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs'; import mime from 'mime-types'; -import path, { getStoredFilePath } from 'utils/common/path'; +import path, { getRelativePathWithinBasePath } from 'utils/common/path'; import EditableTable from 'components/EditableTable'; import MultiLineEditor from 'components/MultiLineEditor'; import SingleLineEditor from 'components/SingleLineEditor'; @@ -51,7 +51,7 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit dispatch(browseFiles()) .then((filePaths) => { const processedPaths = filePaths.map((filePath) => { - return getStoredFilePath(collection.pathname, filePath); + return getRelativePathWithinBasePath(collection.pathname, filePath); }); const currentParams = params || []; diff --git a/packages/bruno-app/src/utils/common/path.js b/packages/bruno-app/src/utils/common/path.js index e01765dbe9e..00aeff0779a 100644 --- a/packages/bruno-app/src/utils/common/path.js +++ b/packages/bruno-app/src/utils/common/path.js @@ -163,20 +163,20 @@ const getAbsoluteFilePath = (basePath, relativePath, shouldPosixify = false) => return shouldPosixify ? posixify(result) : result; }; -// Stores in-collection files as relative paths using the current platform's -// separator style: backslashes on Windows, forward slashes on Unix-like systems. -const getStoredFilePath = (collectionPath, filePath) => { - if (!collectionPath || !filePath) { +// Returns a relative path when filePath is contained within basePath. +// For paths outside basePath, this returns the original filePath unchanged. +// Callers can choose whether relative output is posixified. +const getRelativePathWithinBasePath = (basePath, filePath, shouldPosixify = false) => { + if (!basePath || !filePath) { return filePath; } try { - const resolvedCollectionPath = brunoPath.resolve(collectionPath); - const resolvedFilePath = brunoPath.resolve(filePath); - const relativePath = brunoPath.relative(resolvedCollectionPath, resolvedFilePath); + const relativePath = getRelativePath(basePath, filePath, shouldPosixify); if ( !relativePath + || relativePath === '.' || relativePath === '..' || relativePath.startsWith(`..${brunoPath.sep}`) || brunoPath.isAbsolute(relativePath) @@ -196,4 +196,4 @@ const normalizePath = (p) => { }; export default brunoPath; -export { getRelativePath, getBasename, getAbsoluteFilePath, getStoredFilePath, normalizePath }; +export { getRelativePath, getBasename, getAbsoluteFilePath, getRelativePathWithinBasePath, normalizePath }; diff --git a/packages/bruno-app/src/utils/common/path.spec.js b/packages/bruno-app/src/utils/common/path.spec.js index 49368621a93..8f6f8a69df6 100644 --- a/packages/bruno-app/src/utils/common/path.spec.js +++ b/packages/bruno-app/src/utils/common/path.spec.js @@ -5,7 +5,8 @@ jest.mock('platform', () => ({ } })); -import { getRelativePath, getBasename, getAbsoluteFilePath, getStoredFilePath } from './path'; +import path from 'path'; +import { getRelativePath, getBasename, getAbsoluteFilePath, getRelativePathWithinBasePath } from './path'; describe('Path Utilities - Unix Platform', () => { describe('getRelativePath', () => { @@ -25,6 +26,14 @@ describe('Path Utilities - Unix Platform', () => { expect(getRelativePath('/users/john/projects', '/users/john/projects/src/components')).toBe('src/components'); }); + it('should return ".." for direct parent directory', () => { + expect(getRelativePath('/users/john/projects', '/users/john')).toBe('..'); + }); + + it('should return a path that starts with "../" when target is outside and nested', () => { + expect(getRelativePath('/users/john/projects', '/users/john/docs/readme.md')).toBe('../docs/readme.md'); + }); + it('should handle null/undefined inputs', () => { expect(getRelativePath(null, '/users/john/projects')).toBe('/users/john/projects'); expect(getRelativePath(undefined, '/users/john/projects')).toBe('/users/john/projects'); @@ -113,54 +122,75 @@ describe('Path Utilities - Unix Platform', () => { }); }); - describe('getStoredFilePath', () => { + describe('getRelativePathWithinBasePath', () => { it('should store in-collection files as relative paths', () => { - const result = getStoredFilePath('/users/john/collections/api', '/users/john/collections/api/files/payload.txt'); + const result = getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/collections/api/files/payload.txt'); expect(result).toBe('files/payload.txt'); }); it('should handle collection paths with trailing separators', () => { - const result = getStoredFilePath('/users/john/collections/api/', '/users/john/collections/api/files/payload.txt'); + const result = getRelativePathWithinBasePath('/users/john/collections/api/', '/users/john/collections/api/files/payload.txt'); expect(result).toBe('files/payload.txt'); }); it('should resolve dot segments before deciding whether a file is inside the collection', () => { - const result = getStoredFilePath('/users/john/collections/api', '/users/john/collections/api/files/../payload.txt'); + const result = getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/collections/api/files/../payload.txt'); expect(result).toBe('payload.txt'); }); it('should keep paths that resolve outside the collection absolute', () => { const filePath = '/users/john/collections/api/../payload.txt'; - const result = getStoredFilePath('/users/john/collections/api', filePath); + const result = getRelativePathWithinBasePath('/users/john/collections/api', filePath); expect(result).toBe(filePath); }); it('should keep outside collection paths absolute', () => { const filePath = '/users/john/downloads/payload.txt'; - const result = getStoredFilePath('/users/john/collections/api', filePath); + const result = getRelativePathWithinBasePath('/users/john/collections/api', filePath); expect(result).toBe(filePath); }); it('should keep sibling prefix paths absolute', () => { const filePath = '/users/john/collections/api-other/payload.txt'; - const result = getStoredFilePath('/users/john/collections/api', filePath); + const result = getRelativePathWithinBasePath('/users/john/collections/api', filePath); expect(result).toBe(filePath); }); it('should keep same-path values unchanged', () => { const filePath = '/users/john/collections/api'; - const result = getStoredFilePath('/users/john/collections/api', filePath); + const result = getRelativePathWithinBasePath('/users/john/collections/api', filePath); expect(result).toBe(filePath); }); it('should store in-collection paths whose names begin with two dots as relative paths', () => { - const result = getStoredFilePath('/users/john/collections/api', '/users/john/collections/api/..payload.txt'); + const result = getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/collections/api/..payload.txt'); expect(result).toBe('..payload.txt'); }); it('should keep the original file path when inputs are missing', () => { - expect(getStoredFilePath('', '/users/john/downloads/payload.txt')).toBe('/users/john/downloads/payload.txt'); - expect(getStoredFilePath('/users/john/collections/api', '')).toBe(''); + expect(getRelativePathWithinBasePath('', '/users/john/downloads/payload.txt')).toBe('/users/john/downloads/payload.txt'); + expect(getRelativePathWithinBasePath('/users/john/collections/api', '')).toBe(''); + }); + + it('should treat relative collection path as cwd-relative when file path is absolute', () => { + const collectionPath = 'collections/api'; + const filePath = path.resolve(collectionPath, 'files/payload.txt'); + const result = getRelativePathWithinBasePath(collectionPath, filePath); + expect(result).toBe('files/payload.txt'); + }); + + it('should treat relative file path as cwd-relative when collection path is absolute', () => { + const collectionPath = path.resolve('collections/api'); + const filePath = 'collections/api/files/payload.txt'; + const result = getRelativePathWithinBasePath(collectionPath, filePath); + expect(result).toBe('files/payload.txt'); + }); + + it('should treat both relative paths as cwd-relative for containment checks', () => { + const collectionPath = 'collections/api'; + const filePath = 'collections/api/files/payload.txt'; + const result = getRelativePathWithinBasePath(collectionPath, filePath); + expect(result).toBe('files/payload.txt'); }); }); diff --git a/packages/bruno-app/src/utils/common/path.windows.spec.js b/packages/bruno-app/src/utils/common/path.windows.spec.js index 628eaf1dfc1..761e02da67e 100644 --- a/packages/bruno-app/src/utils/common/path.windows.spec.js +++ b/packages/bruno-app/src/utils/common/path.windows.spec.js @@ -5,7 +5,7 @@ jest.mock('platform', () => ({ } })); -import { getRelativePath, getBasename, getAbsoluteFilePath, getStoredFilePath } from './path'; +import { getRelativePath, getBasename, getAbsoluteFilePath, getRelativePathWithinBasePath } from './path'; describe('Path Utilities - Windows Platform', () => { describe('getRelativePath', () => { @@ -25,6 +25,18 @@ describe('Path Utilities - Windows Platform', () => { expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Projects\\src\\components', false)).toBe('src\\components'); }); + it('should return ".." for direct parent directory', () => { + expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John', false)).toBe('..'); + }); + + it('should return a path that starts with "..\\\\" when target is outside and nested', () => { + expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Docs\\readme.md', false)).toBe('..\\Docs\\readme.md'); + }); + + it('should return an absolute path for cross-drive targets', () => { + expect(getRelativePath('C:\\Users\\John\\Projects', 'D:\\payload.txt', false)).toBe('D:\\payload.txt'); + }); + describe('with posixify enabled', () => { it('should convert backslashes to forward slashes', () => { expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Projects\\App')).toBe('App'); @@ -181,65 +193,65 @@ describe('Path Utilities - Windows Platform', () => { }); }); - describe('getStoredFilePath', () => { + describe('getRelativePathWithinBasePath', () => { it('should store in-collection files as Windows relative paths with mixed separators', () => { - const result = getStoredFilePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt'); + const result = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt'); expect(result).toBe('files\\payload.txt'); }); it('should store nested in-collection files as Windows relative paths', () => { - const result = getStoredFilePath('C:\\Users\\John\\Collections\\Api', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt'); + const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt'); expect(result).toBe('folder\\payload.txt'); }); it('should handle collection paths with trailing separators', () => { - const result = getStoredFilePath('C:\\Users\\John\\Collections\\Api\\', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt'); + const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api\\', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt'); expect(result).toBe('folder\\payload.txt'); }); it('should handle case differences in Windows drive paths', () => { - const result = getStoredFilePath('c:\\users\\john\\collections\\api', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt'); + const result = getRelativePathWithinBasePath('c:\\users\\john\\collections\\api', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt'); expect(result).toBe('folder\\payload.txt'); }); it('should resolve dot segments before deciding whether a file is inside the collection', () => { - const result = getStoredFilePath('C:\\Users\\John\\Collections\\Api', 'C:\\Users\\John\\Collections\\Api\\folder\\..\\payload.txt'); + const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:\\Users\\John\\Collections\\Api\\folder\\..\\payload.txt'); expect(result).toBe('payload.txt'); }); it('should keep paths that resolve outside the collection absolute', () => { const filePath = 'C:\\Users\\John\\Collections\\Api\\..\\payload.txt'; - const result = getStoredFilePath('C:\\Users\\John\\Collections\\Api', filePath); + const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath); expect(result).toBe(filePath); }); it('should keep sibling prefix paths absolute', () => { const filePath = 'C:\\Users\\John\\Collections\\ApiOther\\payload.txt'; - const result = getStoredFilePath('C:\\Users\\John\\Collections\\Api', filePath); + const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath); expect(result).toBe(filePath); }); it('should keep outside collection paths absolute', () => { const filePath = 'C:\\Users\\John\\Downloads\\payload.txt'; - const result = getStoredFilePath('C:\\Users\\John\\Collections\\Api', filePath); + const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath); expect(result).toBe(filePath); }); it('should keep cross-drive paths absolute', () => { const filePath = 'D:\\payload.txt'; - const result = getStoredFilePath('C:\\Users\\John\\Collections\\Api', filePath); + const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath); expect(result).toBe(filePath); }); it('should keep same-path values unchanged', () => { const filePath = 'C:\\Users\\John\\Collections\\Api'; - const result = getStoredFilePath('C:\\Users\\John\\Collections\\Api', filePath); + const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath); expect(result).toBe(filePath); }); it('should keep the original file path when inputs are missing', () => { - expect(getStoredFilePath('', 'C:\\Users\\John\\Downloads\\payload.txt')).toBe('C:\\Users\\John\\Downloads\\payload.txt'); - expect(getStoredFilePath('C:\\Users\\John\\Collections\\Api', '')).toBe(''); + expect(getRelativePathWithinBasePath('', 'C:\\Users\\John\\Downloads\\payload.txt')).toBe('C:\\Users\\John\\Downloads\\payload.txt'); + expect(getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', '')).toBe(''); }); }); From 4506b4297615cc6c234462c8f1ceb6f7dcd991b9 Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Wed, 6 May 2026 21:50:03 +0530 Subject: [PATCH 09/11] fix: use correct fn Co-authored-by: Prateek Sunal --- .../components/CollectionSettings/ClientCertSettings/index.js | 4 ++-- .../bruno-app/src/components/RequestPane/Auth/OAuth1/index.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js index 5895f274c17..2342d86cbe0 100644 --- a/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js @@ -4,7 +4,7 @@ import { useFormik } from 'formik'; import * as Yup from 'yup'; import StyledWrapper from './StyledWrapper'; import { useRef } from 'react'; -import path, { getRelativePathWithinBasePath } from 'utils/common/path'; +import path, { getRelativePath } from 'utils/common/path'; import SensitiveFieldWarning from 'components/SensitiveFieldWarning/index'; import SingleLineEditor from 'components/SingleLineEditor/index'; import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField/index'; @@ -98,7 +98,7 @@ const ClientCertSettings = ({ collection }) => { const getFile = (e) => { const filePath = window?.ipcRenderer?.getFilePath(e?.files?.[0]); if (filePath) { - let relativePath = getRelativePathWithinBasePath(collection.pathname, filePath); + let relativePath = getRelativePath(collection.pathname, filePath); formik.setFieldValue(e.name, relativePath); } }; diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js index dbdbaf15fdd..39cb3e8c3cc 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import get from 'lodash/get'; import { useTheme } from 'providers/Theme'; import { useDispatch } from 'react-redux'; -import path, { getRelativePathWithinBasePath } from 'utils/common/path'; +import path, { getRelativePath } from 'utils/common/path'; import { IconSettings, IconShieldLock, IconAdjustmentsHorizontal, IconCaretDown, IconChevronRight, IconFile, IconX, IconUpload } from '@tabler/icons'; import MenuDropdown from 'ui/MenuDropdown'; import SingleLineEditor from 'components/SingleLineEditor'; @@ -70,7 +70,7 @@ const OAuth1 = ({ item = {}, collection, request, save, updateAuth }) => { .then((filePaths) => { if (filePaths && filePaths.length > 0) { let filePath = filePaths[0]; - filePath = getRelativePathWithinBasePath(collection.pathname, filePath); + filePath = getRelativePath(collection.pathname, filePath); dispatch( updateAuth({ mode: 'oauth1', From e2ca49e6fda69e44347e70afa68077506edea133 Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Thu, 7 May 2026 01:08:17 +0530 Subject: [PATCH 10/11] fix: add support for posixify (defaults to false) Co-authored-by: Prateek Sunal --- packages/bruno-app/src/utils/common/path.js | 35 ++++++++++--- .../bruno-app/src/utils/common/path.spec.js | 4 -- .../src/utils/common/path.windows.spec.js | 49 +++++++++++++++++-- 3 files changed, 74 insertions(+), 14 deletions(-) diff --git a/packages/bruno-app/src/utils/common/path.js b/packages/bruno-app/src/utils/common/path.js index 00aeff0779a..338a5067819 100644 --- a/packages/bruno-app/src/utils/common/path.js +++ b/packages/bruno-app/src/utils/common/path.js @@ -163,9 +163,31 @@ const getAbsoluteFilePath = (basePath, relativePath, shouldPosixify = false) => return shouldPosixify ? posixify(result) : result; }; -// Returns a relative path when filePath is contained within basePath. -// For paths outside basePath, this returns the original filePath unchanged. -// Callers can choose whether relative output is posixified. +/** + * Returns a relative path when filePath is contained within basePath. + * For paths outside basePath (or same path), returns the original filePath unchanged. + * + * @param {string} basePath - The base path to check containment against (e.g., collection pathname). + * @param {string} filePath - The absolute file path to compute a relative path for. + * @param {boolean} [shouldPosixify=false] - When true, output uses '/' separators for + * cross-platform safety. Callers storing to version-controlled config files should opt in + * by passing true. Default false preserves legacy platform-native separators for + * backwards compatibility. + * @returns {string} Relative path if filePath is inside basePath, otherwise filePath itself. + * + * @example + * getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/collections/api/files/payload.txt'); + * → "files/payload.txt" + * + * @example + * getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/downloads/payload.txt'); + * → "/users/john/downloads/payload.txt" + * + * @example + * On Windows with posixify enabled + * getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt', true); + * → "files/payload.txt" + */ const getRelativePathWithinBasePath = (basePath, filePath, shouldPosixify = false) => { if (!basePath || !filePath) { return filePath; @@ -173,20 +195,21 @@ const getRelativePathWithinBasePath = (basePath, filePath, shouldPosixify = fals try { const relativePath = getRelativePath(basePath, filePath, shouldPosixify); + const sep = shouldPosixify ? '/' : brunoPath.sep; if ( !relativePath || relativePath === '.' || relativePath === '..' - || relativePath.startsWith(`..${brunoPath.sep}`) + || relativePath.startsWith(`..${sep}`) || brunoPath.isAbsolute(relativePath) ) { - return filePath; + return shouldPosixify ? posixify(filePath) : filePath; } return relativePath; } catch (error) { - return filePath; + return shouldPosixify ? posixify(filePath) : filePath; } }; diff --git a/packages/bruno-app/src/utils/common/path.spec.js b/packages/bruno-app/src/utils/common/path.spec.js index 8f6f8a69df6..e283ff4751f 100644 --- a/packages/bruno-app/src/utils/common/path.spec.js +++ b/packages/bruno-app/src/utils/common/path.spec.js @@ -30,10 +30,6 @@ describe('Path Utilities - Unix Platform', () => { expect(getRelativePath('/users/john/projects', '/users/john')).toBe('..'); }); - it('should return a path that starts with "../" when target is outside and nested', () => { - expect(getRelativePath('/users/john/projects', '/users/john/docs/readme.md')).toBe('../docs/readme.md'); - }); - it('should handle null/undefined inputs', () => { expect(getRelativePath(null, '/users/john/projects')).toBe('/users/john/projects'); expect(getRelativePath(undefined, '/users/john/projects')).toBe('/users/john/projects'); diff --git a/packages/bruno-app/src/utils/common/path.windows.spec.js b/packages/bruno-app/src/utils/common/path.windows.spec.js index 761e02da67e..c11f921d6aa 100644 --- a/packages/bruno-app/src/utils/common/path.windows.spec.js +++ b/packages/bruno-app/src/utils/common/path.windows.spec.js @@ -29,10 +29,6 @@ describe('Path Utilities - Windows Platform', () => { expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John', false)).toBe('..'); }); - it('should return a path that starts with "..\\\\" when target is outside and nested', () => { - expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Docs\\readme.md', false)).toBe('..\\Docs\\readme.md'); - }); - it('should return an absolute path for cross-drive targets', () => { expect(getRelativePath('C:\\Users\\John\\Projects', 'D:\\payload.txt', false)).toBe('D:\\payload.txt'); }); @@ -253,6 +249,51 @@ describe('Path Utilities - Windows Platform', () => { expect(getRelativePathWithinBasePath('', 'C:\\Users\\John\\Downloads\\payload.txt')).toBe('C:\\Users\\John\\Downloads\\payload.txt'); expect(getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', '')).toBe(''); }); + + describe('mixed separators (posix base / win file)', () => { + it('inside → relative path with native separators (default)', () => { + const r = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt'); + expect(r).toBe('files\\payload.txt'); + }); + + it('outside → returns original filePath unchanged (default)', () => { + const r = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Downloads\\payload.txt'); + expect(r).toBe('C:\\Users\\John\\Downloads\\payload.txt'); + }); + + it('outside → posixified absolute fallback when posixify=true', () => { + const r = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Downloads\\payload.txt', true); + expect(r).toBe('C:/Users/John/Downloads/payload.txt'); + }); + + it('inside → posixified relative path when posixify=true', () => { + const r = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt', true); + expect(r).toBe('files/payload.txt'); + }); + }); + + describe('mixed separators (win base / posix file)', () => { + it('inside → relative path with native separators (default)', () => { + const r = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:/Users/John/Collections/Api/files/payload.txt'); + expect(r).toBe('files\\payload.txt'); + }); + + it('outside → returns original filePath as-is (default)', () => { + const r = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:/Users/John/Downloads/payload.txt'); + // filePath uses '/', returned as-is since shouldPosixify=false + expect(r).toBe('C:/Users/John/Downloads/payload.txt'); + }); + + it('outside → posixified fallback when posixify=true', () => { + const r = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:/Users/John/Downloads/payload.txt', true); + expect(r).toBe('C:/Users/John/Downloads/payload.txt'); + }); + + it('inside → posixified relative path when posixify=true', () => { + const r = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:/Users/John/Collections/Api/files/payload.txt', true); + expect(r).toBe('files/payload.txt'); + }); + }); }); describe('Cross-platform path handling', () => { From 3f36bcc77528c6fac57f5b1e6573077653760a5d Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Sun, 10 May 2026 02:19:07 +0530 Subject: [PATCH 11/11] remove additional changes Co-authored-by: Prateek Sunal --- .../CollectionSettings/ClientCertSettings/index.js | 4 ++-- .../src/components/RequestPane/Auth/OAuth1/index.js | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js index 2342d86cbe0..7a671432871 100644 --- a/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/ClientCertSettings/index.js @@ -4,7 +4,7 @@ import { useFormik } from 'formik'; import * as Yup from 'yup'; import StyledWrapper from './StyledWrapper'; import { useRef } from 'react'; -import path, { getRelativePath } from 'utils/common/path'; +import path from 'utils/common/path'; import SensitiveFieldWarning from 'components/SensitiveFieldWarning/index'; import SingleLineEditor from 'components/SingleLineEditor/index'; import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField/index'; @@ -98,7 +98,7 @@ const ClientCertSettings = ({ collection }) => { const getFile = (e) => { const filePath = window?.ipcRenderer?.getFilePath(e?.files?.[0]); if (filePath) { - let relativePath = getRelativePath(collection.pathname, filePath); + let relativePath = path.relative(collection.pathname, filePath); formik.setFieldValue(e.name, relativePath); } }; diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js index 39cb3e8c3cc..3b8d5774375 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth1/index.js @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import get from 'lodash/get'; import { useTheme } from 'providers/Theme'; import { useDispatch } from 'react-redux'; -import path, { getRelativePath } from 'utils/common/path'; +import path from 'utils/common/path'; import { IconSettings, IconShieldLock, IconAdjustmentsHorizontal, IconCaretDown, IconChevronRight, IconFile, IconX, IconUpload } from '@tabler/icons'; import MenuDropdown from 'ui/MenuDropdown'; import SingleLineEditor from 'components/SingleLineEditor'; @@ -70,7 +70,8 @@ const OAuth1 = ({ item = {}, collection, request, save, updateAuth }) => { .then((filePaths) => { if (filePaths && filePaths.length > 0) { let filePath = filePaths[0]; - filePath = getRelativePath(collection.pathname, filePath); + const collectionDir = collection.pathname; + filePath = path.relative(collectionDir, filePath); dispatch( updateAuth({ mode: 'oauth1',