Skip to content

Commit e0de7d5

Browse files
fix: relative path getting stored as absolute on windows (usebruno#7895)
1 parent e86a036 commit e0de7d5

8 files changed

Lines changed: 446 additions & 23 deletions

File tree

packages/bruno-app/src/components/FilePickerEditor/index.js

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import path from 'utils/common/path';
2+
import { getRelativePathWithinBasePath } from 'utils/common/path';
33
import { useDispatch } from 'react-redux';
44
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
55
import { IconX, IconUpload, IconFile } from '@tabler/icons';
@@ -48,13 +48,7 @@ const FilePickerEditor = ({
4848
// If file is in the collection's directory, then we use relative path
4949
// Otherwise, we use the absolute path
5050
filePaths = filePaths.map((filePath) => {
51-
const collectionDir = collection.pathname;
52-
53-
if (filePath.startsWith(collectionDir)) {
54-
return path.relative(collectionDir, filePath);
55-
}
56-
57-
return filePath;
51+
return getRelativePathWithinBasePath(collection.pathname, filePath);
5852
});
5953

6054
onChange(isSingleFilePicker ? filePaths[0] : filePaths);

packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
1414
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
1515
import EditableTable from 'components/EditableTable';
1616
import StyledWrapper from './StyledWrapper';
17-
import path from 'utils/common/path';
17+
import { getRelativePathWithinBasePath } from 'utils/common/path';
1818
import { usePersistedState } from 'hooks/usePersistedState';
1919
import { useTrackScroll } from 'hooks/useTrackScroll';
2020
import { isWindowsOS } from 'utils/common/platform';
@@ -60,11 +60,7 @@ const MultipartFormParams = ({ item, collection }) => {
6060
dispatch(browseFiles())
6161
.then((filePaths) => {
6262
const processedPaths = filePaths.map((filePath) => {
63-
const collectionDir = collection.pathname;
64-
if (filePath.startsWith(collectionDir)) {
65-
return path.relative(collectionDir, filePath);
66-
}
67-
return filePath;
63+
return getRelativePathWithinBasePath(collection.pathname, filePath);
6864
});
6965

7066
const currentParams = item.draft

packages/bruno-app/src/components/ResponseExample/ResponseExampleRequestPane/ResponseExampleMultipartFormParams/index.js

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { updateResponseExampleMultipartFormParams } from 'providers/ReduxStore/s
77
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
88
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
99
import mime from 'mime-types';
10-
import path from 'utils/common/path';
10+
import path, { getRelativePathWithinBasePath } from 'utils/common/path';
1111
import EditableTable from 'components/EditableTable';
1212
import MultiLineEditor from 'components/MultiLineEditor';
1313
import SingleLineEditor from 'components/SingleLineEditor';
@@ -51,11 +51,7 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
5151
dispatch(browseFiles())
5252
.then((filePaths) => {
5353
const processedPaths = filePaths.map((filePath) => {
54-
const collectionDir = collection.pathname;
55-
if (filePath.startsWith(collectionDir)) {
56-
return path.relative(collectionDir, filePath);
57-
}
58-
return filePath;
54+
return getRelativePathWithinBasePath(collection.pathname, filePath);
5955
});
6056

6157
const currentParams = params || [];

packages/bruno-app/src/utils/common/path.js

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,60 @@ const getAbsoluteFilePath = (basePath, relativePath, shouldPosixify = false) =>
163163
return shouldPosixify ? posixify(result) : result;
164164
};
165165

166+
/**
167+
* Returns a relative path when filePath is contained within basePath.
168+
* For paths outside basePath (or same path), returns the original filePath unchanged.
169+
*
170+
* @param {string} basePath - The base path to check containment against (e.g., collection pathname).
171+
* @param {string} filePath - The absolute file path to compute a relative path for.
172+
* @param {boolean} [shouldPosixify=false] - When true, output uses '/' separators for
173+
* cross-platform safety. Callers storing to version-controlled config files should opt in
174+
* by passing true. Default false preserves legacy platform-native separators for
175+
* backwards compatibility.
176+
* @returns {string} Relative path if filePath is inside basePath, otherwise filePath itself.
177+
*
178+
* @example
179+
* getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/collections/api/files/payload.txt');
180+
* → "files/payload.txt"
181+
*
182+
* @example
183+
* getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/downloads/payload.txt');
184+
* → "/users/john/downloads/payload.txt"
185+
*
186+
* @example
187+
* On Windows with posixify enabled
188+
* getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt', true);
189+
* → "files/payload.txt"
190+
*/
191+
const getRelativePathWithinBasePath = (basePath, filePath, shouldPosixify = false) => {
192+
if (!basePath || !filePath) {
193+
return filePath;
194+
}
195+
196+
try {
197+
const relativePath = getRelativePath(basePath, filePath, shouldPosixify);
198+
const sep = shouldPosixify ? '/' : brunoPath.sep;
199+
200+
if (
201+
!relativePath
202+
|| relativePath === '.'
203+
|| relativePath === '..'
204+
|| relativePath.startsWith(`..${sep}`)
205+
|| brunoPath.isAbsolute(relativePath)
206+
) {
207+
return shouldPosixify ? posixify(filePath) : filePath;
208+
}
209+
210+
return relativePath;
211+
} catch (error) {
212+
return shouldPosixify ? posixify(filePath) : filePath;
213+
}
214+
};
215+
166216
const normalizePath = (p) => {
167217
if (!p) return '';
168218
return p.replace(/\\/g, '/').replace(/\/+$/, '');
169219
};
170220

171221
export default brunoPath;
172-
export { getRelativePath, getBasename, getAbsoluteFilePath, normalizePath };
222+
export { getRelativePath, getBasename, getAbsoluteFilePath, getRelativePathWithinBasePath, normalizePath };

packages/bruno-app/src/utils/common/path.spec.js

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ jest.mock('platform', () => ({
55
}
66
}));
77

8-
import { getRelativePath, getBasename, getAbsoluteFilePath } from './path';
8+
import path from 'path';
9+
import { getRelativePath, getBasename, getAbsoluteFilePath, getRelativePathWithinBasePath } from './path';
910

1011
describe('Path Utilities - Unix Platform', () => {
1112
describe('getRelativePath', () => {
@@ -25,6 +26,10 @@ describe('Path Utilities - Unix Platform', () => {
2526
expect(getRelativePath('/users/john/projects', '/users/john/projects/src/components')).toBe('src/components');
2627
});
2728

29+
it('should return ".." for direct parent directory', () => {
30+
expect(getRelativePath('/users/john/projects', '/users/john')).toBe('..');
31+
});
32+
2833
it('should handle null/undefined inputs', () => {
2934
expect(getRelativePath(null, '/users/john/projects')).toBe('/users/john/projects');
3035
expect(getRelativePath(undefined, '/users/john/projects')).toBe('/users/john/projects');
@@ -113,6 +118,78 @@ describe('Path Utilities - Unix Platform', () => {
113118
});
114119
});
115120

121+
describe('getRelativePathWithinBasePath', () => {
122+
it('should store in-collection files as relative paths', () => {
123+
const result = getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/collections/api/files/payload.txt');
124+
expect(result).toBe('files/payload.txt');
125+
});
126+
127+
it('should handle collection paths with trailing separators', () => {
128+
const result = getRelativePathWithinBasePath('/users/john/collections/api/', '/users/john/collections/api/files/payload.txt');
129+
expect(result).toBe('files/payload.txt');
130+
});
131+
132+
it('should resolve dot segments before deciding whether a file is inside the collection', () => {
133+
const result = getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/collections/api/files/../payload.txt');
134+
expect(result).toBe('payload.txt');
135+
});
136+
137+
it('should keep paths that resolve outside the collection absolute', () => {
138+
const filePath = '/users/john/collections/api/../payload.txt';
139+
const result = getRelativePathWithinBasePath('/users/john/collections/api', filePath);
140+
expect(result).toBe(filePath);
141+
});
142+
143+
it('should keep outside collection paths absolute', () => {
144+
const filePath = '/users/john/downloads/payload.txt';
145+
const result = getRelativePathWithinBasePath('/users/john/collections/api', filePath);
146+
expect(result).toBe(filePath);
147+
});
148+
149+
it('should keep sibling prefix paths absolute', () => {
150+
const filePath = '/users/john/collections/api-other/payload.txt';
151+
const result = getRelativePathWithinBasePath('/users/john/collections/api', filePath);
152+
expect(result).toBe(filePath);
153+
});
154+
155+
it('should keep same-path values unchanged', () => {
156+
const filePath = '/users/john/collections/api';
157+
const result = getRelativePathWithinBasePath('/users/john/collections/api', filePath);
158+
expect(result).toBe(filePath);
159+
});
160+
161+
it('should store in-collection paths whose names begin with two dots as relative paths', () => {
162+
const result = getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/collections/api/..payload.txt');
163+
expect(result).toBe('..payload.txt');
164+
});
165+
166+
it('should keep the original file path when inputs are missing', () => {
167+
expect(getRelativePathWithinBasePath('', '/users/john/downloads/payload.txt')).toBe('/users/john/downloads/payload.txt');
168+
expect(getRelativePathWithinBasePath('/users/john/collections/api', '')).toBe('');
169+
});
170+
171+
it('should treat relative collection path as cwd-relative when file path is absolute', () => {
172+
const collectionPath = 'collections/api';
173+
const filePath = path.resolve(collectionPath, 'files/payload.txt');
174+
const result = getRelativePathWithinBasePath(collectionPath, filePath);
175+
expect(result).toBe('files/payload.txt');
176+
});
177+
178+
it('should treat relative file path as cwd-relative when collection path is absolute', () => {
179+
const collectionPath = path.resolve('collections/api');
180+
const filePath = 'collections/api/files/payload.txt';
181+
const result = getRelativePathWithinBasePath(collectionPath, filePath);
182+
expect(result).toBe('files/payload.txt');
183+
});
184+
185+
it('should treat both relative paths as cwd-relative for containment checks', () => {
186+
const collectionPath = 'collections/api';
187+
const filePath = 'collections/api/files/payload.txt';
188+
const result = getRelativePathWithinBasePath(collectionPath, filePath);
189+
expect(result).toBe('files/payload.txt');
190+
});
191+
});
192+
116193
describe('Edge cases', () => {
117194
it('should handle very long paths', () => {
118195
const longPath = '/users/john/projects/' + 'a'.repeat(100);

packages/bruno-app/src/utils/common/path.windows.spec.js

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ jest.mock('platform', () => ({
55
}
66
}));
77

8-
import { getRelativePath, getBasename, getAbsoluteFilePath } from './path';
8+
import { getRelativePath, getBasename, getAbsoluteFilePath, getRelativePathWithinBasePath } from './path';
99

1010
describe('Path Utilities - Windows Platform', () => {
1111
describe('getRelativePath', () => {
@@ -25,6 +25,14 @@ describe('Path Utilities - Windows Platform', () => {
2525
expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Projects\\src\\components', false)).toBe('src\\components');
2626
});
2727

28+
it('should return ".." for direct parent directory', () => {
29+
expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John', false)).toBe('..');
30+
});
31+
32+
it('should return an absolute path for cross-drive targets', () => {
33+
expect(getRelativePath('C:\\Users\\John\\Projects', 'D:\\payload.txt', false)).toBe('D:\\payload.txt');
34+
});
35+
2836
describe('with posixify enabled', () => {
2937
it('should convert backslashes to forward slashes', () => {
3038
expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Projects\\App')).toBe('App');
@@ -181,6 +189,113 @@ describe('Path Utilities - Windows Platform', () => {
181189
});
182190
});
183191

192+
describe('getRelativePathWithinBasePath', () => {
193+
it('should store in-collection files as Windows relative paths with mixed separators', () => {
194+
const result = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt');
195+
expect(result).toBe('files\\payload.txt');
196+
});
197+
198+
it('should store nested in-collection files as Windows relative paths', () => {
199+
const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt');
200+
expect(result).toBe('folder\\payload.txt');
201+
});
202+
203+
it('should handle collection paths with trailing separators', () => {
204+
const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api\\', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt');
205+
expect(result).toBe('folder\\payload.txt');
206+
});
207+
208+
it('should handle case differences in Windows drive paths', () => {
209+
const result = getRelativePathWithinBasePath('c:\\users\\john\\collections\\api', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt');
210+
expect(result).toBe('folder\\payload.txt');
211+
});
212+
213+
it('should resolve dot segments before deciding whether a file is inside the collection', () => {
214+
const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:\\Users\\John\\Collections\\Api\\folder\\..\\payload.txt');
215+
expect(result).toBe('payload.txt');
216+
});
217+
218+
it('should keep paths that resolve outside the collection absolute', () => {
219+
const filePath = 'C:\\Users\\John\\Collections\\Api\\..\\payload.txt';
220+
const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath);
221+
expect(result).toBe(filePath);
222+
});
223+
224+
it('should keep sibling prefix paths absolute', () => {
225+
const filePath = 'C:\\Users\\John\\Collections\\ApiOther\\payload.txt';
226+
const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath);
227+
expect(result).toBe(filePath);
228+
});
229+
230+
it('should keep outside collection paths absolute', () => {
231+
const filePath = 'C:\\Users\\John\\Downloads\\payload.txt';
232+
const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath);
233+
expect(result).toBe(filePath);
234+
});
235+
236+
it('should keep cross-drive paths absolute', () => {
237+
const filePath = 'D:\\payload.txt';
238+
const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath);
239+
expect(result).toBe(filePath);
240+
});
241+
242+
it('should keep same-path values unchanged', () => {
243+
const filePath = 'C:\\Users\\John\\Collections\\Api';
244+
const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath);
245+
expect(result).toBe(filePath);
246+
});
247+
248+
it('should keep the original file path when inputs are missing', () => {
249+
expect(getRelativePathWithinBasePath('', 'C:\\Users\\John\\Downloads\\payload.txt')).toBe('C:\\Users\\John\\Downloads\\payload.txt');
250+
expect(getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', '')).toBe('');
251+
});
252+
253+
describe('mixed separators (posix base / win file)', () => {
254+
it('inside → relative path with native separators (default)', () => {
255+
const r = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt');
256+
expect(r).toBe('files\\payload.txt');
257+
});
258+
259+
it('outside → returns original filePath unchanged (default)', () => {
260+
const r = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Downloads\\payload.txt');
261+
expect(r).toBe('C:\\Users\\John\\Downloads\\payload.txt');
262+
});
263+
264+
it('outside → posixified absolute fallback when posixify=true', () => {
265+
const r = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Downloads\\payload.txt', true);
266+
expect(r).toBe('C:/Users/John/Downloads/payload.txt');
267+
});
268+
269+
it('inside → posixified relative path when posixify=true', () => {
270+
const r = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt', true);
271+
expect(r).toBe('files/payload.txt');
272+
});
273+
});
274+
275+
describe('mixed separators (win base / posix file)', () => {
276+
it('inside → relative path with native separators (default)', () => {
277+
const r = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:/Users/John/Collections/Api/files/payload.txt');
278+
expect(r).toBe('files\\payload.txt');
279+
});
280+
281+
it('outside → returns original filePath as-is (default)', () => {
282+
const r = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:/Users/John/Downloads/payload.txt');
283+
// filePath uses '/', returned as-is since shouldPosixify=false
284+
expect(r).toBe('C:/Users/John/Downloads/payload.txt');
285+
});
286+
287+
it('outside → posixified fallback when posixify=true', () => {
288+
const r = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:/Users/John/Downloads/payload.txt', true);
289+
expect(r).toBe('C:/Users/John/Downloads/payload.txt');
290+
});
291+
292+
it('inside → posixified relative path when posixify=true', () => {
293+
const r = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:/Users/John/Collections/Api/files/payload.txt', true);
294+
expect(r).toBe('files/payload.txt');
295+
});
296+
});
297+
});
298+
184299
describe('Cross-platform path handling', () => {
185300
describe('Windows fromPath with POSIX toPath', () => {
186301
it('should handle Windows fromPath with POSIX toPath in getAbsoluteFilePath', () => {

0 commit comments

Comments
 (0)