(dispatch, getState) => {
const state = getState();
@@ -343,7 +343,7 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
});
};
-export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getState) => {
+export const newFolder = (folderName, directoryName, collectionUid, itemUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -355,14 +355,14 @@ export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getS
if (!itemUid) {
const folderWithSameNameExists = find(
collection.items,
- (i) => i.type === 'folder' && trim(i.name) === trim(folderName)
+ (i) => i.type === 'folder' && trim(i.filename) === trim(directoryName)
);
if (!folderWithSameNameExists) {
- const fullName = `${collection.pathname}${PATH_SEPARATOR}${folderName}`;
+ const fullName = path.join(collection.pathname, directoryName);
const { ipcRenderer } = window;
ipcRenderer
- .invoke('renderer:new-folder', fullName)
+ .invoke('renderer:new-folder', fullName, folderName)
.then(() => resolve())
.catch((error) => reject(error));
} else {
@@ -373,14 +373,14 @@ export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getS
if (currentItem) {
const folderWithSameNameExists = find(
currentItem.items,
- (i) => i.type === 'folder' && trim(i.name) === trim(folderName)
+ (i) => i.type === 'folder' && trim(i.filename) === trim(directoryName)
);
if (!folderWithSameNameExists) {
- const fullName = `${currentItem.pathname}${PATH_SEPARATOR}${folderName}`;
+ const fullName = path.join(currentItem.pathname, directoryName);
const { ipcRenderer } = window;
ipcRenderer
- .invoke('renderer:new-folder', fullName)
+ .invoke('renderer:new-folder', fullName, folderName)
.then(() => resolve())
.catch((error) => reject(error));
} else {
@@ -393,8 +393,7 @@ export const newFolder = (folderName, collectionUid, itemUid) => (dispatch, getS
});
};
-// rename item
-export const renameItem = (newName, itemUid, collectionUid) => (dispatch, getState) => {
+export const renameItem = ({ newName, newFilename, itemUid, collectionUid }) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -409,22 +408,53 @@ export const renameItem = (newName, itemUid, collectionUid) => (dispatch, getSta
return reject(new Error('Unable to locate item'));
}
- const dirname = getDirectoryName(item.pathname);
-
- let newPathname = '';
- if (item.type === 'folder') {
- newPathname = path.join(dirname, trim(newName));
- } else {
- const filename = resolveRequestFilename(newName);
- newPathname = path.join(dirname, filename);
- }
const { ipcRenderer } = window;
- ipcRenderer.invoke('renderer:rename-item', slash(item.pathname), newPathname, newName).then(resolve).catch(reject);
+ const renameName = async () => {
+ return ipcRenderer.invoke('renderer:rename-item-name', { itemPath: item.pathname, newName })
+ .catch((err) => {
+ toast.error('Failed to rename the item name');
+ console.error(err);
+ throw new Error('Failed to rename the item name');
+ });
+ };
+
+ const renameFile = async () => {
+ const dirname = path.dirname(item.pathname);
+ let newPath = '';
+ if (item.type === 'folder') {
+ newPath = path.join(dirname, trim(newFilename));
+ } else {
+ const filename = resolveRequestFilename(newFilename);
+ newPath = path.join(dirname, filename);
+ }
+
+ return ipcRenderer.invoke('renderer:rename-item-filename', { oldPath: item.pathname, newPath, newName, newFilename })
+ .catch((err) => {
+ toast.error('Failed to rename the file');
+ console.error(err);
+ throw new Error('Failed to rename the file');
+ });
+ };
+
+ let renameOperation = null;
+ if (newName) renameOperation = renameName;
+ if (newFilename) renameOperation = renameFile;
+
+ if (!renameOperation) {
+ resolve();
+ }
+
+ renameOperation()
+ .then(() => {
+ toast.success('Item renamed successfully');
+ resolve();
+ })
+ .catch((err) => reject(err));
});
};
-export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getState) => {
+export const cloneItem = (newName, newFilename, itemUid, collectionUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -443,36 +473,41 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
const folderWithSameNameExists = find(
parentFolder.items,
- (i) => i.type === 'folder' && trim(i.name) === trim(newName)
+ (i) => i.type === 'folder' && trim(i?.filename) === trim(newFilename)
);
if (folderWithSameNameExists) {
return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
}
- const collectionPath = `${parentFolder.pathname}${PATH_SEPARATOR}${newName}`;
+ set(item, 'name', newName);
+ set(item, 'filename', newFilename);
+ set(item, 'root.meta.name', newName);
+
+ const collectionPath = path.join(parentFolder.pathname, newFilename);
ipcRenderer.invoke('renderer:clone-folder', item, collectionPath).then(resolve).catch(reject);
return;
}
const parentItem = findParentItemInCollection(collectionCopy, itemUid);
- const filename = resolveRequestFilename(newName);
+ const filename = resolveRequestFilename(newFilename);
const itemToSave = refreshUidsInItem(transformRequestToSaveToFilesystem(item));
- itemToSave.name = trim(newName);
+ set(itemToSave, 'name', trim(newName));
+ set(itemToSave, 'filename', trim(filename));
if (!parentItem) {
const reqWithSameNameExists = find(
collection.items,
(i) => i.type !== 'folder' && trim(i.filename) === trim(filename)
);
if (!reqWithSameNameExists) {
- const fullName = `${collection.pathname}${PATH_SEPARATOR}${filename}`;
+ const fullPathname = path.join(collection.pathname, filename);
const { ipcRenderer } = window;
const requestItems = filter(collection.items, (i) => i.type !== 'folder');
itemToSave.seq = requestItems ? requestItems.length + 1 : 1;
itemSchema
.validate(itemToSave)
- .then(() => ipcRenderer.invoke('renderer:new-request', fullName, itemToSave))
+ .then(() => ipcRenderer.invoke('renderer:new-request', fullPathname, itemToSave))
.then(resolve)
.catch(reject);
@@ -481,7 +516,7 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
uid: uuid(),
type: 'OPEN_REQUEST',
collectionUid,
- itemPathname: fullName
+ itemPathname: fullPathname
})
);
} else {
@@ -493,8 +528,8 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
(i) => i.type !== 'folder' && trim(i.filename) === trim(filename)
);
if (!reqWithSameNameExists) {
- const dirname = getDirectoryName(item.pathname);
- const fullName = isWindowsPath(item.pathname) ? path.win32.join(dirname, filename) : path.join(dirname, filename);
+ const dirname = path.dirname(item.pathname);
+ const fullName = path.join(dirname, filename);
const { ipcRenderer } = window;
const requestItems = filter(parentItem.items, (i) => i.type !== 'folder');
itemToSave.seq = requestItems ? requestItems.length + 1 : 1;
@@ -719,7 +754,7 @@ export const moveItemToRootOfCollection = (collectionUid, draggedItemUid) => (di
};
export const newHttpRequest = (params) => (dispatch, getState) => {
- const { requestName, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth } = params;
+ const { requestName, filename, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth } = params;
return new Promise((resolve, reject) => {
const state = getState();
@@ -747,6 +782,7 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
uid: uuid(),
type: requestType,
name: requestName,
+ filename,
request: {
method: requestMethod,
url: requestUrl,
@@ -769,46 +805,20 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
};
// itemUid is null when we are creating a new request at the root level
- const filename = resolveRequestFilename(requestName);
+ const resolvedFilename = resolveRequestFilename(filename);
if (!itemUid) {
const reqWithSameNameExists = find(
collection.items,
- (i) => i.type !== 'folder' && trim(i.filename) === trim(filename)
+ (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
);
const requestItems = filter(collection.items, (i) => i.type !== 'folder');
item.seq = requestItems.length + 1;
if (!reqWithSameNameExists) {
- const fullName = `${collection.pathname}${PATH_SEPARATOR}${filename}`;
+ const fullName = path.join(collection.pathname, resolvedFilename);
const { ipcRenderer } = window;
- ipcRenderer.invoke('renderer:new-request', fullName, item).then(resolve).catch(reject);
- // task middleware will track this and open the new request in a new tab once request is created
- dispatch(
- insertTaskIntoQueue({
- uid: uuid(),
- type: 'OPEN_REQUEST',
- collectionUid,
- itemPathname: fullName
- })
- );
- } else {
- return reject(new Error('Duplicate request names are not allowed under the same folder'));
- }
- } else {
- const currentItem = findItemInCollection(collection, itemUid);
- if (currentItem) {
- const reqWithSameNameExists = find(
- currentItem.items,
- (i) => i.type !== 'folder' && trim(i.filename) === trim(filename)
- );
- const requestItems = filter(currentItem.items, (i) => i.type !== 'folder');
- item.seq = requestItems.length + 1;
- if (!reqWithSameNameExists) {
- const fullName = `${currentItem.pathname}${PATH_SEPARATOR}${filename}`;
- const { ipcRenderer } = window;
-
- ipcRenderer.invoke('renderer:new-request', fullName, item).then(resolve).catch(reject);
+ ipcRenderer.invoke('renderer:new-request', fullName, item).then(() => {
// task middleware will track this and open the new request in a new tab once request is created
dispatch(
insertTaskIntoQueue({
@@ -818,6 +828,35 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
itemPathname: fullName
})
);
+ resolve();
+ }).catch(reject);
+ } else {
+ return reject(new Error('Duplicate request names are not allowed under the same folder'));
+ }
+ } else {
+ const currentItem = findItemInCollection(collection, itemUid);
+ if (currentItem) {
+ const reqWithSameNameExists = find(
+ currentItem.items,
+ (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
+ );
+ const requestItems = filter(currentItem.items, (i) => i.type !== 'folder');
+ item.seq = requestItems.length + 1;
+ if (!reqWithSameNameExists) {
+ const fullName = path.join(currentItem.pathname, resolvedFilename);
+ const { ipcRenderer } = window;
+ ipcRenderer.invoke('renderer:new-request', fullName, item).then(() => {
+ // task middleware will track this and open the new request in a new tab once request is created
+ dispatch(
+ insertTaskIntoQueue({
+ uid: uuid(),
+ type: 'OPEN_REQUEST',
+ collectionUid,
+ itemPathname: fullName
+ })
+ );
+ resolve();
+ }).catch(reject);
} else {
return reject(new Error('Duplicate request names are not allowed under the same folder'));
}
@@ -859,16 +898,18 @@ export const importEnvironment = (name, variables, collectionUid) => (dispatch,
if (!collection) {
return reject(new Error('Collection not found'));
}
+
+ const sanitizedName = sanitizeName(name);
ipcRenderer
- .invoke('renderer:create-environment', collection.pathname, name, variables)
+ .invoke('renderer:create-environment', collection.pathname, sanitizedName, variables)
.then(
dispatch(
updateLastAction({
collectionUid,
lastAction: {
type: 'ADD_ENVIRONMENT',
- payload: name
+ payload: sanitizedName
}
})
)
@@ -891,15 +932,17 @@ export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, g
return reject(new Error('Environment not found'));
}
+ const sanitizedName = sanitizeName(name);
+
ipcRenderer
- .invoke('renderer:create-environment', collection.pathname, name, baseEnv.variables)
+ .invoke('renderer:create-environment', collection.pathname, sanitizedName, baseEnv.variables)
.then(
dispatch(
updateLastAction({
collectionUid,
lastAction: {
type: 'ADD_ENVIRONMENT',
- payload: name
+ payload: sanitizedName
}
})
)
@@ -923,12 +966,13 @@ export const renameEnvironment = (newName, environmentUid, collectionUid) => (di
return reject(new Error('Environment not found'));
}
+ const sanitizedName = sanitizeName(newName);
const oldName = environment.name;
- environment.name = newName;
+ environment.name = sanitizedName;
environmentSchema
.validate(environment)
- .then(() => ipcRenderer.invoke('renderer:rename-environment', collection.pathname, oldName, newName))
+ .then(() => ipcRenderer.invoke('renderer:rename-environment', collection.pathname, oldName, sanitizedName))
.then(resolve)
.catch(reject);
});
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
index 43fc1c946..010ca49b6 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
@@ -16,10 +16,10 @@ import {
isItemARequest
} from 'utils/collections';
import { parsePathParams, parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url';
-import { getDirectoryName, getSubdirectoriesFromRoot, PATH_SEPARATOR } from 'utils/common/platform';
+import { getSubdirectoriesFromRoot } from 'utils/common/platform';
import toast from 'react-hot-toast';
import mime from 'mime-types';
-import path from 'node:path';
+import path from 'utils/common/path';
const initialState = {
collections: [],
@@ -1655,25 +1655,29 @@ export const collectionsSlice = createSlice({
}
if (isFolderRoot) {
- const folderPath = getDirectoryName(file.meta.pathname);
+ const folderPath = path.dirname(file.meta.pathname);
const folderItem = findItemInCollectionByPathname(collection, folderPath);
if (folderItem) {
+ if (file?.data?.meta?.name) {
+ folderItem.name = file?.data?.meta?.name;
+ }
folderItem.root = file.data;
}
return;
}
if (collection) {
- const dirname = getDirectoryName(file.meta.pathname);
+ const dirname = path.dirname(file.meta.pathname);
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dirname);
let currentPath = collection.pathname;
let currentSubItems = collection.items;
for (const directoryName of subDirectories) {
- let childItem = currentSubItems.find((f) => f.type === 'folder' && f.name === directoryName);
+ let childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
+ currentPath = path.join(currentPath, directoryName);
if (!childItem) {
childItem = {
uid: uuid(),
- pathname: `${currentPath}${PATH_SEPARATOR}${directoryName}`,
+ pathname: currentPath,
name: directoryName,
collapsed: true,
type: 'folder',
@@ -1681,8 +1685,6 @@ export const collectionsSlice = createSlice({
};
currentSubItems.push(childItem);
}
-
- currentPath = `${currentPath}${PATH_SEPARATOR}${directoryName}`;
currentSubItems = childItem.items;
}
@@ -1732,20 +1734,20 @@ export const collectionsSlice = createSlice({
let currentPath = collection.pathname;
let currentSubItems = collection.items;
for (const directoryName of subDirectories) {
- let childItem = currentSubItems.find((f) => f.type === 'folder' && f.name === directoryName);
+ let childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
+ currentPath = path.join(currentPath, directoryName);
if (!childItem) {
childItem = {
uid: uuid(),
- pathname: `${currentPath}${PATH_SEPARATOR}${directoryName}`,
- name: directoryName,
+ pathname: currentPath,
+ name: dir?.meta?.name || directoryName,
+ filename: directoryName,
collapsed: true,
type: 'folder',
items: []
};
currentSubItems.push(childItem);
}
-
- currentPath = `${currentPath}${PATH_SEPARATOR}${directoryName}`;
currentSubItems = childItem.items;
}
addDepth(collection.items);
@@ -1753,11 +1755,25 @@ export const collectionsSlice = createSlice({
},
collectionChangeFileEvent: (state, action) => {
const { file } = action.payload;
+ const isCollectionRoot = file.meta.collectionRoot ? true : false;
+ const isFolderRoot = file.meta.folderRoot ? true : false;
const collection = findCollectionByUid(state.collections, file.meta.collectionUid);
+ if (isCollectionRoot) {
+ if (collection) {
+ collection.root = file.data;
+ }
+ return;
+ }
- // check and update collection root
- if (collection && file.meta.collectionRoot) {
- collection.root = file.data;
+ if (isFolderRoot) {
+ const folderPath = path.dirname(file.meta.pathname);
+ const folderItem = findItemInCollectionByPathname(collection, folderPath);
+ if (folderItem) {
+ if (file?.data?.meta?.name) {
+ folderItem.name = file?.data?.meta?.name;
+ }
+ folderItem.root = file.data;
+ }
return;
}
diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js
index 3ac612c62..04d787f80 100644
--- a/packages/bruno-app/src/utils/collections/index.js
+++ b/packages/bruno-app/src/utils/collections/index.js
@@ -1,7 +1,6 @@
import {cloneDeep, isEqual, sortBy, filter, map, isString, findIndex, find, each, get } from 'lodash';
import { uuid } from 'utils/common';
-import path from 'path';
-import slash from 'utils/common/slash';
+import path from 'utils/common/path';
const replaceTabsWithSpaces = (str, numSpaces = 2) => {
if (!str || !str.length || !isString(str)) {
@@ -90,7 +89,7 @@ export const findCollectionByItemUid = (collections, itemUid) => {
};
export const findItemByPathname = (items = [], pathname) => {
- return find(items, (i) => slash(i.pathname) === slash(pathname));
+ return find(items, (i) => i.pathname === pathname);
};
export const findItemInCollectionByPathname = (collection, pathname) => {
@@ -307,6 +306,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
uid: si.uid,
type: si.type,
name: si.name,
+ filename: si.filename,
seq: si.seq
};
diff --git a/packages/bruno-app/src/utils/common/path.js b/packages/bruno-app/src/utils/common/path.js
new file mode 100644
index 000000000..f85a15d3c
--- /dev/null
+++ b/packages/bruno-app/src/utils/common/path.js
@@ -0,0 +1,12 @@
+import platform from 'platform';
+import path from 'path';
+
+const isWindowsOS = () => {
+ const os = platform.os;
+ const osFamily = os.family.toLowerCase();
+ return osFamily.includes('windows');
+};
+
+const brunoPath = isWindowsOS() ? path.win32 : path.posix;
+
+export default brunoPath;
diff --git a/packages/bruno-app/src/utils/common/platform.js b/packages/bruno-app/src/utils/common/platform.js
index c50ded79a..dc1d7d984 100644
--- a/packages/bruno-app/src/utils/common/platform.js
+++ b/packages/bruno-app/src/utils/common/platform.js
@@ -1,7 +1,6 @@
import trim from 'lodash/trim';
-import path from 'path';
-import slash from './slash';
import platform from 'platform';
+import path from './path';
export const isElectron = () => {
if (!window) {
@@ -16,35 +15,11 @@ export const resolveRequestFilename = (name) => {
};
export const getSubdirectoriesFromRoot = (rootPath, pathname) => {
- // convert to unix style path
- pathname = slash(pathname);
- rootPath = slash(rootPath);
const relativePath = path.relative(rootPath, pathname);
return relativePath ? relativePath.split(path.sep) : [];
};
-
-export const isWindowsPath = (pathname) => {
-
- if (!isWindowsOS()) {
- return false;
- }
-
- // Check for Windows drive letter format (e.g., "C:\")
- const hasDriveLetter = /^[a-zA-Z]:\\/.test(pathname);
-
- // Check for UNC path format (e.g., "\\server\share") a.k.a. network path || WSL path
- const isUNCPath = pathname.startsWith('\\\\');
-
- return hasDriveLetter || isUNCPath;
-};
-
-
-export const getDirectoryName = (pathname) => {
- return isWindowsPath(pathname) ? path.win32.dirname(pathname) : path.dirname(pathname);
-};
-
export const isWindowsOS = () => {
const os = platform.os;
const osFamily = os.family.toLowerCase();
@@ -59,8 +34,6 @@ export const isMacOS = () => {
return osFamily.includes('os x');
};
-export const PATH_SEPARATOR = isWindowsOS() ? '\\' : '/';
-
export const getAppInstallDate = () => {
let dateString = localStorage.getItem('bruno.installedOn');
diff --git a/packages/bruno-app/src/utils/common/regex.js b/packages/bruno-app/src/utils/common/regex.js
index 53f46741e..9338288f0 100644
--- a/packages/bruno-app/src/utils/common/regex.js
+++ b/packages/bruno-app/src/utils/common/regex.js
@@ -1 +1,55 @@
+const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g; // replace invalid characters with hyphens
+const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
+const firstCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot, space, or hyphen at start
+const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no invalid characters
+const lastCharacter = /[^.\s]$/; // no dot or space at end, hyphen allowed
+
export const variableNameRegex = /^[\w-.]*$/;
+
+export const sanitizeName = (name) => {
+ name = name
+ .replace(invalidCharacters, '-') // replace invalid characters with hyphens
+ .replace(/^[.\s-]+/, '') // remove leading dots, hyphens and spaces
+ .replace(/[.\s]+$/, ''); // remove trailing dots and spaces (keep trailing hyphens)
+ return name;
+};
+
+export const validateName = (name) => {
+ if (!name) return false;
+ if (name.length > 255) return false; // max name length
+
+ if (reservedDeviceNames.test(name)) return false; // windows reserved names
+
+ return (
+ firstCharacter.test(name) &&
+ middleCharacters.test(name) &&
+ lastCharacter.test(name)
+ );
+};
+
+export const validateNameError = (name) => {
+ if (!name) return "Name cannot be empty.";
+ if (name.length > 255) {
+ return "Name cannot exceed 255 characters.";
+ }
+
+ if (reservedDeviceNames.test(name)) {
+ return "Name cannot be a reserved device name.";
+ }
+
+ if (!firstCharacter.test(name[0])) {
+ return "Invalid first character.";
+ }
+
+ for (let i = 1; i < name.length - 1; i++) {
+ if (!middleCharacters.test(name[i])) {
+ return `Invalid character '${name[i]}' at position ${i + 1}.`;
+ }
+ }
+
+ if (!lastCharacter.test(name[name.length - 1])) {
+ return "Invalid last character.";
+ }
+
+ return '';
+};
\ No newline at end of file
diff --git a/packages/bruno-app/src/utils/common/regex.spec.js b/packages/bruno-app/src/utils/common/regex.spec.js
new file mode 100644
index 000000000..e7a8b8d36
--- /dev/null
+++ b/packages/bruno-app/src/utils/common/regex.spec.js
@@ -0,0 +1,166 @@
+const { describe, it, expect } = require('@jest/globals');
+
+import { sanitizeName, validateName } from './regex';
+
+describe('regex validators', () => {
+ describe('sanitize name', () => {
+ it('should remove invalid characters', () => {
+ expect(sanitizeName('hello world')).toBe('hello world');
+ expect(sanitizeName('hello-world')).toBe('hello-world');
+ expect(sanitizeName('hello_world')).toBe('hello_world');
+ expect(sanitizeName('hello_world-')).toBe('hello_world-');
+ expect(sanitizeName('hello_world-123')).toBe('hello_world-123');
+ expect(sanitizeName('hello_world-123!@#$%^&*()')).toBe('hello_world-123!@#$%^&-()');
+ expect(sanitizeName('hello_world?')).toBe('hello_world-');
+ expect(sanitizeName('foo/bar/')).toBe('foo-bar-');
+ expect(sanitizeName('foo\\bar\\')).toBe('foo-bar-');
+ });
+
+ it('should remove leading hyphens', () => {
+ expect(sanitizeName('-foo')).toBe('foo');
+ expect(sanitizeName('---foo')).toBe('foo');
+ expect(sanitizeName('-foo-bar')).toBe('foo-bar');
+ });
+
+ it('should remove trailing periods', () => {
+ expect(sanitizeName('.file')).toBe('file');
+ expect(sanitizeName('.file.')).toBe('file');
+ expect(sanitizeName('file.')).toBe('file');
+ expect(sanitizeName('file.name.')).toBe('file.name');
+ expect(sanitizeName('hello world.')).toBe('hello world');
+ });
+
+ it('should handle filenames with only invalid characters', () => {
+ expect(sanitizeName('<>:"/\\|?*')).toBe('');
+ expect(sanitizeName('::::')).toBe('');
+ });
+
+ it('should handle filenames with a mix of valid and invalid characters', () => {
+ expect(sanitizeName('test<>:"/\\|?*')).toBe('test---------');
+ expect(sanitizeName('foo')).toBe('foo-bar-');
+ });
+
+ it('should remove control characters', () => {
+ expect(sanitizeName('foo\x00bar')).toBe('foo-bar');
+ expect(sanitizeName('file\x1Fname')).toBe('file-name');
+ });
+
+ it('should return an empty string if the name is empty or consists only of invalid characters', () => {
+ expect(sanitizeName('')).toBe('');
+ expect(sanitizeName('<>:"/\\|?*')).toBe('');
+ });
+
+ it('should handle filenames with multiple consecutive invalid characters', () => {
+ expect(sanitizeName('foo< {
+ expect(sanitizeName(' ')).toBe('');
+ });
+
+ it('should handle names with leading/trailing spaces', () => {
+ expect(sanitizeName(' foo bar ')).toBe('foo bar');
+ });
+
+ it('should preserve valid non-ASCII characters', () => {
+ expect(sanitizeName('brunó')).toBe('brunó');
+ expect(sanitizeName('文件')).toBe('文件');
+ expect(sanitizeName('brunfais')).toBe('brunfais');
+ expect(sanitizeName('brunai')).toBe('brunai');
+ expect(sanitizeName('brunsборка')).toBe('brunsборка');
+ expect(sanitizeName('brunпривет')).toBe('brunпривет');
+ expect(sanitizeName('🐶')).toBe('🐶');
+ expect(sanitizeName('brunfais🐶')).toBe('brunfais🐶');
+ expect(sanitizeName('file-🐶-bruno')).toBe('file-🐶-bruno');
+ expect(sanitizeName('helló')).toBe('helló');
+ });
+
+ it('should preserve case sensitivity', () => {
+ expect(sanitizeName('FileName')).toBe('FileName');
+ expect(sanitizeName('fileNAME')).toBe('fileNAME');
+ });
+
+ it('should handle filenames with multiple consecutive periods (only remove trailing)', () => {
+ expect(sanitizeName('file.name...')).toBe('file.name');
+ expect(sanitizeName('...file')).toBe('file');
+ expect(sanitizeName('file.name... ')).toBe('file.name');
+ expect(sanitizeName(' ...file')).toBe('file');
+ expect(sanitizeName(' ...file ')).toBe('file');
+ expect(sanitizeName(' ...file.... ')).toBe('file');
+ });
+
+ it('should handle very long filenames', () => {
+ const longName = 'a'.repeat(250) + '.txt';
+ expect(sanitizeName(longName)).toBe(longName);
+ });
+
+ it('should handle names with leading/trailing invalid characters', () => {
+ expect(sanitizeName('-foo/bar-')).toBe('foo-bar-');
+ expect(sanitizeName('/foo\\bar/')).toBe('foo-bar-');
+ });
+
+ it('should handle different language unicode characters', () => {
+ expect(sanitizeName('你好世界!?@#$%^&*()')).toBe('你好世界!-@#$%^&-()');
+ expect(sanitizeName('こんにちは世界!?@#$%^&*()')).toBe('こんにちは世界!-@#$%^&-()');
+ expect(sanitizeName('안녕하세요 세계!?@#$%^&*()')).toBe('안녕하세요 세계!-@#$%^&-()');
+ expect(sanitizeName('مرحبا بالعالم!?@#$%^&*()')).toBe('مرحبا بالعالم!-@#$%^&-()');
+ expect(sanitizeName('Здравствуй мир!?@#$%^&*()')).toBe('Здравствуй мир!-@#$%^&-()');
+ expect(sanitizeName('नमस्ते दुनिया!?@#$%^&*()')).toBe('नमस्ते दुनिया!-@#$%^&-()');
+ expect(sanitizeName('สวัสดีชาวโลก!?@#$%^&*()')).toBe('สวัสดีชาวโลก!-@#$%^&-()');
+ expect(sanitizeName('γειά σου κόσμος!?@#$%^&*()')).toBe('γειά σου κόσμος!-@#$%^&-()');
+ });
+
+ });
+});
+
+describe('sanitizeName and validateName', () => {
+ it('should sanitize and then validate valid names', () => {
+ const validNames = [
+ 'valid_filename.txt',
+ ' valid name ',
+ ' valid-name ',
+ 'valid<>name.txt',
+ 'file/with?invalid*chars'
+ ];
+
+ validNames.forEach(name => {
+ const sanitized = sanitizeName(name);
+ expect(validateName(sanitized)).toBe(true);
+ });
+ });
+
+ it('should sanitize and then validate names with reserved device names', () => {
+ const reservedNames = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'LPT2'];
+
+ reservedNames.forEach(name => {
+ const sanitized = sanitizeName(name);
+ expect(validateName(sanitized)).toBe(false);
+ });
+ });
+
+ it('should sanitize invalid names to empty strings', () => {
+ const invalidNames = [
+ ' <>:"/\\|?* ',
+ ' ... ',
+ ' ',
+ ];
+
+ invalidNames.forEach(name => {
+ const sanitized = sanitizeName(name);
+ expect(validateName(sanitized)).toBe(false);
+ });
+ });
+
+ it('should return false for reserved device names with leading/trailing spaces', () => {
+ const mixedNames = [
+ 'AUX ',
+ ' COM1 '
+ ];
+
+ mixedNames.forEach(name => {
+ const sanitized = sanitizeName(name);
+ expect(validateName(sanitized)).toBe(false);
+ });
+ });
+});
diff --git a/packages/bruno-app/src/utils/common/slash.js b/packages/bruno-app/src/utils/common/slash.js
deleted file mode 100644
index a2b39e94f..000000000
--- a/packages/bruno-app/src/utils/common/slash.js
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * MIT License
- *
- * Copyright (c) Sindre Sorhus (https://sindresorhus.com)
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-const slash = (path) => {
- const isExtendedLengthPath = /^\\\\\?\\/.test(path);
-
- if (isExtendedLengthPath) {
- return path;
- }
-
- return path.replace(/\\/g, '/');
-};
-
-export default slash;
diff --git a/packages/bruno-app/src/utils/importers/common.js b/packages/bruno-app/src/utils/importers/common.js
index 9d370a455..64db764fb 100644
--- a/packages/bruno-app/src/utils/importers/common.js
+++ b/packages/bruno-app/src/utils/importers/common.js
@@ -62,7 +62,6 @@ export const updateUidsInCollection = (_collection) => {
export const transformItemsInCollection = (collection) => {
const transformItems = (items = []) => {
each(items, (item) => {
- item.name = normalizeFileName(item.name);
if (['http', 'graphql'].includes(item.type)) {
item.type = `${item.type}-request`;
diff --git a/packages/bruno-electron/src/app/collections.js b/packages/bruno-electron/src/app/collections.js
index 7bd74c43b..a6b7a178c 100644
--- a/packages/bruno-electron/src/app/collections.js
+++ b/packages/bruno-electron/src/app/collections.js
@@ -45,9 +45,8 @@ const openCollectionDialog = async (win, watcher) => {
const { filePaths } = await dialog.showOpenDialog(win, {
properties: ['openDirectory', 'createDirectory']
});
-
if (filePaths && filePaths[0]) {
- const resolvedPath = normalizeAndResolvePath(filePaths[0]);
+ const resolvedPath = path.resolve(filePaths[0]);
if (isDirectory(resolvedPath)) {
openCollection(win, watcher, resolvedPath);
} else {
diff --git a/packages/bruno-electron/src/app/watcher.js b/packages/bruno-electron/src/app/watcher.js
index b2b60fd55..39c22bb1a 100644
--- a/packages/bruno-electron/src/app/watcher.js
+++ b/packages/bruno-electron/src/app/watcher.js
@@ -2,8 +2,8 @@ const _ = require('lodash');
const fs = require('fs');
const path = require('path');
const chokidar = require('chokidar');
-const { hasBruExtension, isWSLPath, normalizeAndResolvePath, normalizeWslPath, sizeInMB } = require('../utils/filesystem');
-const { bruToEnvJson, bruToJson, bruToJsonViaWorker ,collectionBruToJson } = require('../bru');
+const { hasBruExtension, isWSLPath, normalizeAndResolvePath, sizeInMB } = require('../utils/filesystem');
+const { bruToEnvJson, bruToJson, bruToJsonViaWorker, collectionBruToJson } = require('../bru');
const { dotenvToJson } = require('@usebruno/lang');
const { uuid } = require('../utils/common');
@@ -319,20 +319,24 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
}
};
-const addDirectory = (win, pathname, collectionUid, collectionPath) => {
+const addDirectory = async (win, pathname, collectionUid, collectionPath) => {
const envDirectory = path.join(collectionPath, 'environments');
if (pathname === envDirectory) {
return;
}
+ let name = path.basename(pathname);
+
const directory = {
meta: {
collectionUid,
pathname,
- name: path.basename(pathname)
+ name
}
};
+
+
win.webContents.send('main:collection-tree-updated', 'addDir', directory);
};
@@ -399,6 +403,30 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
}
}
+ if (path.basename(pathname) === 'folder.bru') {
+ const file = {
+ meta: {
+ collectionUid,
+ pathname,
+ name: path.basename(pathname),
+ folderRoot: true
+ }
+ };
+
+ try {
+ let bruContent = fs.readFileSync(pathname, 'utf8');
+
+ file.data = await collectionBruToJson(bruContent);
+
+ hydrateBruCollectionFileWithUuid(file.data);
+ win.webContents.send('main:collection-tree-updated', 'change', file);
+ return;
+ } catch (err) {
+ console.error(err);
+ return;
+ }
+ }
+
if (hasBruExtension(pathname)) {
try {
const file = {
@@ -439,18 +467,29 @@ const unlink = (win, pathname, collectionUid, collectionPath) => {
}
};
-const unlinkDir = (win, pathname, collectionUid, collectionPath) => {
+const unlinkDir = async (win, pathname, collectionUid, collectionPath) => {
const envDirectory = path.join(collectionPath, 'environments');
if (pathname === envDirectory) {
return;
}
+
+ const folderBruFilePath = path.join(pathname, `folder.bru`);
+
+ let name = path.basename(pathname);
+
+ if (fs.existsSync(folderBruFilePath)) {
+ let folderBruFileContent = fs.readFileSync(folderBruFilePath, 'utf8');
+ let folderBruData = await collectionBruToJson(folderBruFileContent);
+ name = folderBruData?.meta?.name || name;
+ }
+
const directory = {
meta: {
collectionUid,
pathname,
- name: path.basename(pathname)
+ name
}
};
win.webContents.send('main:collection-tree-updated', 'unlinkDir', directory);
@@ -477,14 +516,13 @@ class Watcher {
setTimeout(() => {
const watcher = chokidar.watch(watchPath, {
ignoreInitial: false,
- usePolling: watchPath.startsWith('\\\\') || forcePolling ? true : false,
+ usePolling: isWSLPath(watchPath) || forcePolling ? true : false,
ignored: (filepath) => {
- const normalizedPath = isWSLPath(filepath) ? normalizeWslPath(filepath) : normalizeAndResolvePath(filepath);
+ const normalizedPath = normalizeAndResolvePath(filepath);
const relativePath = path.relative(watchPath, normalizedPath);
return ignores.some((ignorePattern) => {
- const normalizedIgnorePattern = isWSLPath(ignorePattern) ? normalizeWslPath(ignorePattern) : ignorePattern.replace(/\\/g, '/');
- return relativePath === normalizedIgnorePattern || relativePath.startsWith(normalizedIgnorePattern);
+ return relativePath === ignorePattern || relativePath.startsWith(ignorePattern);
});
},
persistent: true,
diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js
index 6fcb3723e..d19171e65 100644
--- a/packages/bruno-electron/src/ipc/collection.js
+++ b/packages/bruno-electron/src/ipc/collection.js
@@ -4,10 +4,9 @@ const fsExtra = require('fs-extra');
const os = require('os');
const path = require('path');
const { ipcMain, shell, dialog, app } = require('electron');
-const { envJsonToBru, bruToJson, jsonToBruViaWorker, jsonToCollectionBru, bruToJsonViaWorker } = require('../bru');
+const { envJsonToBru, bruToJson, jsonToBru, jsonToBruViaWorker, collectionBruToJson, jsonToCollectionBru, bruToJsonViaWorker } = require('../bru');
const {
- isValidPathname,
writeFile,
hasBruExtension,
isDirectory,
@@ -15,16 +14,15 @@ const {
browseFiles,
createDirectory,
searchForBruFiles,
- sanitizeDirectoryName,
+ sanitizeName,
isWSLPath,
- normalizeWslPath,
- normalizeAndResolvePath,
safeToRename,
isWindowsOS,
- isValidFilename,
+ validateName,
hasSubDirectories,
getCollectionStats,
- sizeInMB
+ sizeInMB,
+ safeWriteFileSync
} = require('../utils/filesystem');
const { openCollectionDialog } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common');
@@ -74,7 +72,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
'renderer:create-collection',
async (event, collectionName, collectionFolderName, collectionLocation) => {
try {
- collectionFolderName = sanitizeDirectoryName(collectionFolderName);
+ collectionFolderName = sanitizeName(collectionFolderName);
const dirPath = path.join(collectionLocation, collectionFolderName);
if (fs.existsSync(dirPath)) {
const files = fs.readdirSync(dirPath);
@@ -83,7 +81,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
throw new Error(`collection: ${dirPath} already exists and is not empty`);
}
}
- if (!isValidPathname(path.basename(dirPath))) {
+
+ if (!validateName(path.basename(dirPath))) {
throw new Error(`collection: invalid pathname - ${dirPath}`);
}
@@ -116,13 +115,13 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle(
'renderer:clone-collection',
async (event, collectionName, collectionFolderName, collectionLocation, previousPath) => {
- collectionFolderName = sanitizeDirectoryName(collectionFolderName);
+ collectionFolderName = sanitizeName(collectionFolderName);
const dirPath = path.join(collectionLocation, collectionFolderName);
if (fs.existsSync(dirPath)) {
throw new Error(`collection: ${dirPath} already exists`);
}
- if (!isValidPathname(path.basename(dirPath))) {
+ if (!validateName(path.basename(dirPath))) {
throw new Error(`collection: invalid pathname - ${dirPath}`);
}
@@ -221,8 +220,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (fs.existsSync(pathname)) {
throw new Error(`path: ${pathname} already exists`);
}
- if (!isValidFilename(request.name)) {
- throw new Error(`path: ${request.name}.bru is not a valid filename`);
+ // For the actual filename part, we want to be strict
+ if (!validateName(request?.filename)) {
+ throw new Error(`${request.filename}.bru is not a valid filename`);
}
const content = await jsonToBruViaWorker(request);
await writeFile(pathname, content);
@@ -358,18 +358,53 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
});
// rename item
- ipcMain.handle('renderer:rename-item', async (event, oldPath, newPath, newName) => {
- const tempDir = path.join(os.tmpdir(), `temp-folder-${Date.now()}`);
- // const parentDir = path.dirname(oldPath);
- const isWindowsOSAndNotWSLAndItemHasSubDirectories = isDirectory(oldPath) && isWindowsOS() && !isWSLPath(oldPath) && hasSubDirectories(oldPath);
- // let parentDirUnwatched = false;
- // let parentDirRewatched = false;
-
+ ipcMain.handle('renderer:rename-item-name', async (event, { itemPath, newName }) => {
try {
- // Normalize paths if they are WSL paths
- oldPath = isWSLPath(oldPath) ? normalizeWslPath(oldPath) : normalizeAndResolvePath(oldPath);
- newPath = isWSLPath(newPath) ? normalizeWslPath(newPath) : normalizeAndResolvePath(newPath);
+ if (!fs.existsSync(itemPath)) {
+ throw new Error(`path: ${itemPath} does not exist`);
+ }
+
+ if (isDirectory(itemPath)) {
+ const folderBruFilePath = path.join(itemPath, 'folder.bru');
+ let folderBruFileJsonContent;
+ if (fs.existsSync(folderBruFilePath)) {
+ const oldFolderBruFileContent = await fs.promises.readFile(folderBruFilePath, 'utf8');
+ folderBruFileJsonContent = await collectionBruToJson(oldFolderBruFileContent);
+ } else {
+ folderBruFileJsonContent = {};
+ }
+
+ folderBruFileJsonContent.meta = {
+ name: newName,
+ };
+
+ const folderBruFileContent = await jsonToCollectionBru(folderBruFileJsonContent, true);
+ await writeFile(folderBruFilePath, folderBruFileContent);
+
+ return;
+ }
+
+ const isBru = hasBruExtension(itemPath);
+ if (!isBru) {
+ throw new Error(`path: ${itemPath} is not a bru file`);
+ }
+
+ const data = fs.readFileSync(itemPath, 'utf8');
+ const jsonData = await bruToJson(data);
+ jsonData.name = newName;
+ const content = await jsonToBru(jsonData);
+ await writeFile(itemPath, content);
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
+
+ // rename item
+ ipcMain.handle('renderer:rename-item-filename', async (event, { oldPath, newPath, newName, newFilename }) => {
+ const tempDir = path.join(os.tmpdir(), `temp-folder-${Date.now()}`);
+ const isWindowsOSAndNotWSLPathAndItemHasSubDirectories = isDirectory(oldPath) && isWindowsOS() && !isWSLPath(oldPath) && hasSubDirectories(oldPath);
+ try {
// Check if the old path exists
if (!fs.existsSync(oldPath)) {
throw new Error(`path: ${oldPath} does not exist`);
@@ -380,6 +415,22 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
if (isDirectory(oldPath)) {
+ const folderBruFilePath = path.join(oldPath, 'folder.bru');
+ let folderBruFileJsonContent;
+ if (fs.existsSync(folderBruFilePath)) {
+ const oldFolderBruFileContent = await fs.promises.readFile(folderBruFilePath, 'utf8');
+ folderBruFileJsonContent = await collectionBruToJson(oldFolderBruFileContent);
+ } else {
+ folderBruFileJsonContent = {};
+ }
+
+ folderBruFileJsonContent.meta = {
+ name: newName,
+ };
+
+ const folderBruFileContent = await jsonToCollectionBru(folderBruFileJsonContent, true);
+ await writeFile(folderBruFilePath, folderBruFileContent);
+
const bruFilesAtSource = await searchForBruFiles(oldPath);
for (let bruFile of bruFilesAtSource) {
@@ -387,19 +438,16 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
moveRequestUid(bruFile, newBruFilePath);
}
- // watcher.unlinkItemPathInWatcher(parentDir);
- // parentDirUnwatched = true;
-
/**
* If it is windows OS
- * And it is not WSL path (meaning it's not linux running on windows using WSL)
+ * And it is not a WSL path (meaning it is not running in WSL (linux pathtype))
* And it has sub directories
* Only then we need to use the temp dir approach to rename the folder
*
* Windows OS would sometimes throw error when renaming a folder with sub directories
* This is an alternative approach to avoid that error
*/
- if (isWindowsOSAndNotWSLAndItemHasSubDirectories) {
+ if (isWindowsOSAndNotWSLPathAndItemHasSubDirectories) {
await fsExtra.copy(oldPath, tempDir);
await fsExtra.remove(oldPath);
await fsExtra.move(tempDir, newPath, { overwrite: true });
@@ -407,8 +455,6 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
} else {
await fs.renameSync(oldPath, newPath);
}
- // watcher.addItemPathInWatcher(parentDir);
- // parentDirRewatched = true;
return newPath;
}
@@ -417,8 +463,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
throw new Error(`path: ${oldPath} is not a bru file`);
}
- if (!isValidFilename(newName)) {
- throw new Error(`path: ${newName} is not a valid filename`);
+ if (!validateName(newFilename)) {
+ throw new Error(`path: ${newFilename} is not a valid filename`);
}
// update name in file and save new copy, then delete old copy
@@ -433,15 +479,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
return newPath;
} catch (error) {
- // in case an error occurs during the rename file operations after unlinking the parent dir
- // and the rewatch fails, we need to add it back to watcher
- // if (parentDirUnwatched && !parentDirRewatched) {
- // watcher.addItemPathInWatcher(parentDir);
- // }
-
// in case the rename file operations fails, and we see that the temp dir exists
// and the old path does not exist, we need to restore the data from the temp dir to the old path
- if (isWindowsOSAndNotWSLAndItemHasSubDirectories) {
+ if (isWindowsOSAndNotWSLPathAndItemHasSubDirectories) {
if (fsExtra.pathExistsSync(tempDir) && !fsExtra.pathExistsSync(oldPath)) {
try {
await fsExtra.copy(tempDir, oldPath);
@@ -457,12 +497,20 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
});
// new folder
- ipcMain.handle('renderer:new-folder', async (event, pathname) => {
- const resolvedFolderName = sanitizeDirectoryName(path.basename(pathname));
+ ipcMain.handle('renderer:new-folder', async (event, pathname, folderName) => {
+ const resolvedFolderName = sanitizeName(path.basename(pathname));
pathname = path.join(path.dirname(pathname), resolvedFolderName);
try {
if (!fs.existsSync(pathname)) {
fs.mkdirSync(pathname);
+ const folderBruFilePath = path.join(pathname, 'folder.bru');
+ let data = {
+ meta: {
+ name: folderName,
+ }
+ };
+ const content = await jsonToCollectionBru(data, true); // isFolder flag
+ await writeFile(folderBruFilePath, content);
} else {
return Promise.reject(new Error('The directory already exists'));
}
@@ -522,7 +570,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:import-collection', async (event, collection, collectionLocation) => {
try {
- let collectionName = sanitizeDirectoryName(collection.name);
+ let collectionName = sanitizeName(collection.name);
let collectionPath = path.join(collectionLocation, collectionName);
if (fs.existsSync(collectionPath)) {
@@ -533,13 +581,14 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
const parseCollectionItems = (items = [], currentPath) => {
items.forEach(async (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
+ let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.bru`);
const content = await jsonToBruViaWorker(item);
- const filePath = path.join(currentPath, `${item.name}.bru`);
- fs.writeFileSync(filePath, content);
+ const filePath = path.join(currentPath, sanitizedFilename);
+ safeWriteFileSync(filePath, content);
}
if (item.type === 'folder') {
- item.name = sanitizeDirectoryName(item.name);
- const folderPath = path.join(currentPath, item.name);
+ let sanitizedFolderName = sanitizeName(item?.filename || item?.name);
+ const folderPath = path.join(currentPath, sanitizedFolderName);
fs.mkdirSync(folderPath);
if (item?.root?.meta?.name) {
@@ -548,7 +597,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
item.root,
true // isFolder
);
- fs.writeFileSync(folderBruFilePath, folderContent);
+ safeWriteFileSync(folderBruFilePath, folderContent);
}
if (item.items && item.items.length) {
@@ -557,8 +606,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
// Handle items of type 'js'
if (item.type === 'js') {
- const filePath = path.join(currentPath, `${item.name}.js`);
- fs.writeFileSync(filePath, item.fileContent);
+ let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.js`);
+ const filePath = path.join(currentPath, sanitizedFilename);
+ safeWriteFileSync(filePath, item.fileContent);
}
});
};
@@ -571,8 +621,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
environments.forEach(async (env) => {
const content = await envJsonToBru(env);
- const filePath = path.join(envDirPath, `${env.name}.bru`);
- fs.writeFileSync(filePath, content);
+ let sanitizedEnvFilename = sanitizeName(`${env.name}.bru`);
+ const filePath = path.join(envDirPath, sanitizedEnvFilename);
+ safeWriteFileSync(filePath, content);
});
};
@@ -630,20 +681,21 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
const parseCollectionItems = (items = [], currentPath) => {
items.forEach(async (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
- const content = await jsonToBruViaWorker(item);
- const filePath = path.join(currentPath, `${item.name}.bru`);
- fs.writeFileSync(filePath, content);
+ const content = await jsonToBruViaWorker(item);
+ const filePath = path.join(currentPath, item.filename);
+ safeWriteFileSync(filePath, content);
}
if (item.type === 'folder') {
- const folderPath = path.join(currentPath, item.name);
+ const folderPath = path.join(currentPath, item.filename);
fs.mkdirSync(folderPath);
// If folder has a root element, then I should write its folder.bru file
if (item.root) {
const folderContent = await jsonToCollectionBru(item.root, true);
+ folderContent.name = item.name;
if (folderContent) {
const bruFolderPath = path.join(folderPath, `folder.bru`);
- fs.writeFileSync(bruFolderPath, folderContent);
+ safeWriteFileSync(bruFolderPath, folderContent);
}
}
@@ -661,7 +713,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
const folderContent = await jsonToCollectionBru(itemFolder.root, true);
if (folderContent) {
const bruFolderPath = path.join(collectionPath, `folder.bru`);
- fs.writeFileSync(bruFolderPath, folderContent);
+ safeWriteFileSync(bruFolderPath, folderContent);
}
}
@@ -697,7 +749,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
moveRequestUid(itemPath, newItemPath);
fs.unlinkSync(itemPath);
- fs.writeFileSync(newItemPath, itemContent);
+ safeWriteFileSync(newItemPath, itemContent);
} catch (error) {
return Promise.reject(error);
}
diff --git a/packages/bruno-electron/src/utils/collection.js b/packages/bruno-electron/src/utils/collection.js
index bb8d17e97..f96cec877 100644
--- a/packages/bruno-electron/src/utils/collection.js
+++ b/packages/bruno-electron/src/utils/collection.js
@@ -254,16 +254,8 @@ const hydrateRequestWithUuid = (request, pathname) => {
return request;
};
-const slash = (path) => {
- const isExtendedLengthPath = /^\\\\\?\\/.test(path);
- if (isExtendedLengthPath) {
- return path;
- }
- return path?.replace?.(/\\/g, '/');
-};
-
const findItemByPathname = (items = [], pathname) => {
- return find(items, (i) => slash(i.pathname) === slash(pathname));
+ return find(items, (i) => i.pathname === pathname);
};
const findItemInCollectionByPathname = (collection, pathname) => {
@@ -280,7 +272,6 @@ module.exports = {
flattenItems,
findItem,
findItemInCollection,
- slash,
findItemByPathname,
findItemInCollectionByPathname,
findParentItemInCollection,
diff --git a/packages/bruno-electron/src/utils/filesystem.js b/packages/bruno-electron/src/utils/filesystem.js
index a9c8597e7..aaff867b1 100644
--- a/packages/bruno-electron/src/utils/filesystem.js
+++ b/packages/bruno-electron/src/utils/filesystem.js
@@ -44,6 +44,11 @@ const hasSubDirectories = (dir) => {
};
const normalizeAndResolvePath = (pathname) => {
+
+ if (isWSLPath(pathname)) {
+ return normalizeWSLPath(pathname);
+ }
+
if (isSymbolicLink(pathname)) {
const absPath = path.dirname(pathname);
const targetPath = path.resolve(absPath, fs.readlinkSync(pathname));
@@ -59,18 +64,20 @@ const normalizeAndResolvePath = (pathname) => {
function isWSLPath(pathname) {
// Check if the path starts with the WSL prefix
// eg. "\\wsl.localhost\Ubuntu\home\user\bruno\collection\scripting\api\req\getHeaders.bru"
- return pathname.startsWith('/wsl.localhost/') || pathname.startsWith('\\wsl.localhost\\');
+ return pathname.startsWith('\\\\') || pathname.startsWith('//') || pathname.startsWith('/wsl.localhost/') || pathname.startsWith('\\wsl.localhost');
+
}
-function normalizeWslPath(pathname) {
+function normalizeWSLPath(pathname) {
// Replace the WSL path prefix and convert forward slashes to backslashes
// This is done to achieve WSL paths (linux style) to Windows UNC equivalent (Universal Naming Conversion)
return pathname.replace(/^\/wsl.localhost/, '\\\\wsl.localhost').replace(/\//g, '\\');
}
+
const writeFile = async (pathname, content, isBinary = false) => {
try {
- await fs.writeFile(pathname, content, {
+ await safeWriteFile(pathname, content, {
encoding: !isBinary ? "utf-8" : null
});
} catch (err) {
@@ -110,7 +117,7 @@ const browseDirectory = async (win) => {
return false;
}
- const resolvedPath = normalizeAndResolvePath(filePaths[0]);
+ const resolvedPath = path.resolve(filePaths[0]);
return isDirectory(resolvedPath) ? resolvedPath : false;
};
@@ -124,7 +131,7 @@ const browseFiles = async (win, filters = [], properties = []) => {
return [];
}
- return filePaths.map((path) => normalizeAndResolvePath(path)).filter((path) => isFile(path));
+ return filePaths.map((path) => path.resolve(path)).filter((path) => isFile(path));
};
const chooseFileToSave = async (win, preferredFileName = '') => {
@@ -154,28 +161,36 @@ const searchForBruFiles = (dir) => {
return searchForFiles(dir, '.bru');
};
-const sanitizeDirectoryName = (name) => {
- return name.replace(/[<>:"/\\|?*\x00-\x1F]+/g, '-').trim();
+const sanitizeName = (name) => {
+ const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g;
+ name = name
+ .replace(invalidCharacters, '-') // replace invalid characters with hyphens
+ .replace(/^[.\s]+/, '') // remove leading dots and and spaces
+ .replace(/[.\s]+$/, ''); // remove trailing dots and spaces (keep trailing hyphens)
+ return name;
};
const isWindowsOS = () => {
return os.platform() === 'win32';
}
-const isValidFilename = (fileName) => {
- const inValidChars = /[\\/:*?"<>|]/;
+const validateName = (name) => {
+ const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
+ const firstCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot, space, or hyphen at start
+ const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no invalid characters
+ const lastCharacter = /[^.\s]$/; // no dot or space at end, hyphen allowed
+ if (name.length > 255) return false; // max name length
- if (!fileName || inValidChars.test(fileName)) {
- return false;
- }
+ if (reservedDeviceNames.test(name)) return false; // windows reserved names
- if (fileName.endsWith(' ') || fileName.endsWith('.') || fileName.startsWith('.')) {
- return false;
- }
-
- return true;
+ return (
+ firstCharacter.test(name) &&
+ middleCharacters.test(name) &&
+ lastCharacter.test(name)
+ );
};
+
const safeToRename = (oldPath, newPath) => {
try {
// If the new path doesn't exist, it's safe to rename
@@ -244,6 +259,29 @@ const sizeInMB = (size) => {
return size / (1024 * 1024);
}
+const getSafePathToWrite = (filePath) => {
+ const MAX_FILENAME_LENGTH = 255; // Common limit on most filesystems
+ let dir = path.dirname(filePath);
+ let ext = path.extname(filePath);
+ let base = path.basename(filePath, ext);
+ if (base.length + ext.length > MAX_FILENAME_LENGTH) {
+ base = sanitizeName(base);
+ base = base.slice(0, MAX_FILENAME_LENGTH - ext.length);
+ }
+ let safePath = path.join(dir, base + ext);
+ return safePath;
+}
+
+async function safeWriteFile(filePath, data, options) {
+ const safePath = getSafePathToWrite(filePath);
+ await fs.writeFile(safePath, data, options);
+}
+
+function safeWriteFileSync(filePath, data) {
+ const safePath = getSafePathToWrite(filePath);
+ fs.writeFileSync(safePath, data);
+}
+
module.exports = {
isValidPathname,
exists,
@@ -252,7 +290,7 @@ module.exports = {
isDirectory,
normalizeAndResolvePath,
isWSLPath,
- normalizeWslPath,
+ normalizeWSLPath,
writeFile,
hasJsonExtension,
hasBruExtension,
@@ -262,11 +300,13 @@ module.exports = {
chooseFileToSave,
searchForFiles,
searchForBruFiles,
- sanitizeDirectoryName,
+ sanitizeName,
isWindowsOS,
safeToRename,
- isValidFilename,
+ validateName,
hasSubDirectories,
getCollectionStats,
- sizeInMB
+ sizeInMB,
+ safeWriteFile,
+ safeWriteFileSync
};
diff --git a/packages/bruno-electron/src/utils/filesystem.test.js b/packages/bruno-electron/src/utils/filesystem.test.js
index 62d7b502f..a6e2db53a 100644
--- a/packages/bruno-electron/src/utils/filesystem.test.js
+++ b/packages/bruno-electron/src/utils/filesystem.test.js
@@ -1,26 +1,84 @@
-const { sanitizeDirectoryName } = require('./filesystem.js');
+const { sanitizeName, isWSLPath, normalizeWSLPath, normalizeAndResolvePath } = require('./filesystem.js');
-describe('sanitizeDirectoryName', () => {
+describe('sanitizeName', () => {
it('should replace invalid characters with hyphens', () => {
- const input = '<>:"/\\|?*\x00-\x1F';
- const expectedOutput = '---';
- expect(sanitizeDirectoryName(input)).toEqual(expectedOutput);
+ const input = '<>:"/\|?*\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F';
+ const expectedOutput = '----------------------------------------';
+ expect(sanitizeName(input)).toEqual(expectedOutput);
});
it('should not modify valid directory names', () => {
const input = 'my-directory';
- expect(sanitizeDirectoryName(input)).toEqual(input);
+ expect(sanitizeName(input)).toEqual(input);
});
it('should replace multiple invalid characters with a single hyphen', () => {
const input = 'my<>invalid?directory';
- const expectedOutput = 'my-invalid-directory';
- expect(sanitizeDirectoryName(input)).toEqual(expectedOutput);
+ const expectedOutput = 'my--invalid-directory';
+ expect(sanitizeName(input)).toEqual(expectedOutput);
});
it('should handle names with slashes', () => {
const input = 'my/invalid/directory';
const expectedOutput = 'my-invalid-directory';
- expect(sanitizeDirectoryName(input)).toEqual(expectedOutput);
+ expect(sanitizeName(input)).toEqual(expectedOutput);
+ });
+});
+
+describe('WSL Path Utilities', () => {
+ describe('isWSLPath', () => {
+ it('should identify WSL paths starting with double backslash', () => {
+ expect(isWSLPath('\\\\wsl.localhost\\Ubuntu\\home\\user')).toBe(true);
+ });
+
+ it('should identify WSL paths starting with double forward slash', () => {
+ expect(isWSLPath('//wsl.localhost/Ubuntu/home/user')).toBe(true);
+ });
+
+ it('should identify WSL paths starting with /wsl.localhost/', () => {
+ expect(isWSLPath('/wsl.localhost/Ubuntu/home/user')).toBe(true);
+ });
+
+ it('should identify WSL paths starting with \\wsl.localhost', () => {
+ expect(isWSLPath('\\wsl.localhost\\Ubuntu\\home\\user')).toBe(true);
+ });
+
+ it('should return false for non-WSL paths', () => {
+ expect(isWSLPath('C:\\Users\\user\\Documents')).toBe(false);
+ expect(isWSLPath('/home/user/documents')).toBe(false);
+ expect(isWSLPath('relative/path')).toBe(false);
+ });
+ });
+
+ describe('normalizeWSLPath', () => {
+ it('should convert forward slash WSL paths to backslash format', () => {
+ const input = '/wsl.localhost/Ubuntu/home/user/file.txt';
+ const expected = '\\\\wsl.localhost\\Ubuntu\\home\\user\\file.txt';
+ expect(normalizeWSLPath(input)).toBe(expected);
+ });
+
+ it('should handle paths already in backslash format', () => {
+ const input = '\\\\wsl.localhost\\Ubuntu\\home\\user\\file.txt';
+ expect(normalizeWSLPath(input)).toBe(input);
+ });
+
+ it('should convert mixed slash formats to backslash format', () => {
+ const input = '/wsl.localhost\\Ubuntu/home\\user/file.txt';
+ const expected = '\\\\wsl.localhost\\Ubuntu\\home\\user\\file.txt';
+ expect(normalizeWSLPath(input)).toBe(expected);
+ });
+ });
+
+ describe('normalizeAndResolvePath with WSL paths', () => {
+ it('should normalize WSL paths', () => {
+ const input = '/wsl.localhost/Ubuntu/home/user/file.txt';
+ const expected = '\\\\wsl.localhost\\Ubuntu\\home\\user\\file.txt';
+ expect(normalizeAndResolvePath(input)).toBe(expected);
+ });
+
+ it('should handle already normalized WSL paths', () => {
+ const input = '\\\\wsl.localhost\\Ubuntu\\home\\user\\file.txt';
+ expect(normalizeAndResolvePath(input)).toBe(input);
+ });
});
});
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js
index 4ccd8ebfc..863d13d6d 100644
--- a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js
@@ -189,7 +189,7 @@ const addBruShimToContext = (vm, bru) => {
const promise = vm.newPromise();
bru.runRequest(vm.dump(args))
.then((response) => {
- const { status, statusText, headers, data, dataBuffer, size } = response || {};
+ const { status, headers, data, dataBuffer, size, statusText } = response || {};
promise.resolve(marshallToVm(cleanJson({ status, statusText, headers, data, dataBuffer, size }), vm));
})
.catch((err) => {