Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/insomnia-data/common-src/misc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';

import { slugify } from './misc';

describe('slugify', () => {
it('lowercases and joins words with hyphens', () => {
expect(slugify('Cams Project!')).toBe('cams-project');
});

it('strips accents', () => {
expect(slugify('Café México')).toBe('cafe-mexico');
});

it('collapses runs of non-alphanumeric characters', () => {
expect(slugify(' --Weird__Name-- ')).toBe('weird-name');
});

it('returns an empty string when nothing usable remains', () => {
expect(slugify('🚀🚀🚀')).toBe('');
});

it('truncates to maxLength without leaving a trailing hyphen', () => {
const longName = 'a-very-long-project-name-that-goes-on-and-on-and-on';
const slug = slugify(longName, 20);

expect(slug.length).toBeLessThanOrEqual(20);
expect(slug.endsWith('-')).toBe(false);
});
});
22 changes: 22 additions & 0 deletions packages/insomnia-data/common-src/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,25 @@ export function generateId(prefix?: string) {
}
return id;
}

/**
* Turns arbitrary text into a filesystem-safe slug: lowercased, accents
* stripped, runs of non-alphanumeric characters collapsed to a single `-`,
* leading/trailing `-` trimmed, and truncated to `maxLength`.
*
* Returns `''` when nothing usable remains (e.g. all-emoji or all-punctuation
* input) — callers should treat that as "no slug available".
*/
export function slugify(input: string, maxLength = 40) {
const slug = input
.normalize('NFKD')
.replace(/[\u0300-\u036F]/g, '') // strip accents (combining diacritical marks)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');

if (slug.length <= maxLength) {
return slug;
}
return slug.slice(0, maxLength).replace(/-+$/g, '');
}
40 changes: 40 additions & 0 deletions packages/insomnia-data/src/models/git-repository.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { models } from 'insomnia-data';
import { describe, expect, it } from 'vitest';

describe('getGitRepoFolderName', () => {
it('returns the bare id when there is no folderSlug', () => {
const folderName = models.gitRepository.getGitRepoFolderName({
_id: 'git_57d071a646b34393929036c34dd0915e',
folderSlug: null,
});

expect(folderName).toBe('git_57d071a646b34393929036c34dd0915e');
});

it('embeds the slug between the "git" prefix and the hex id when set', () => {
const folderName = models.gitRepository.getGitRepoFolderName({
_id: 'git_57d071a646b34393929036c34dd0915e',
folderSlug: 'cams-project',
});

expect(folderName).toBe('git_cams-project_57d071a646b34393929036c34dd0915e');
});

it('falls back to the whole id if it somehow lacks the "git_" prefix', () => {
const folderName = models.gitRepository.getGitRepoFolderName({
_id: '57d071a646b34393929036c34dd0915e',
folderSlug: 'cams-project',
});

expect(folderName).toBe('git_cams-project_57d071a646b34393929036c34dd0915e');
});

it('falls back to the bare id when folderSlug contains anything outside [a-z0-9-] (e.g. a corrupted or path-traversal value)', () => {
const folderName = models.gitRepository.getGitRepoFolderName({
_id: 'git_57d071a646b34393929036c34dd0915e',
folderSlug: '../../etc',
});

expect(folderName).toBe('git_57d071a646b34393929036c34dd0915e');
});
});
42 changes: 39 additions & 3 deletions packages/insomnia-data/src/models/git-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function init(): BaseGitRepository {
uriNeedsMigration: true,
repoMigrationVersion: 0,
directory: null,
folderSlug: null,
};
}

Expand Down Expand Up @@ -75,15 +76,50 @@ export interface BaseGitRepository {
* repository's working tree and .git directory live.
*
* `null` (the default) means the repository is stored in the app-managed
* location: `{INSOMNIA_DATA_PATH || userData}/version-control/git/{_id}`.
* Insomnia owns that managed folder. When `directory` is set, the user owns
* the folder and Insomnia must not delete it on project removal.
* location: `{INSOMNIA_DATA_PATH || userData}/version-control/git/{folder}`,
* where `folder` is computed by {@link getGitRepoFolderName}. Insomnia owns
* that managed folder. When `directory` is set, the user owns the folder and
* Insomnia must not delete it on project removal.
*/
directory: string | null;
/**
* A filesystem-safe slug derived from the owning project's name at the time
* the app-managed folder was created (or, for repos that predate this field,
* backfilled by a one-time best-effort startup pass before the window is
* created). It is baked into the managed folder name for readability (see
* {@link getGitRepoFolderName}) and is intentionally NOT kept in sync with
* later project renames — renaming the folder on every project rename would
* risk moving a directory out from under an open editor, terminal, or
* native git process.
*
* `null` means either the repo uses a user-chosen `directory` (irrelevant),
* or the folder still uses its legacy bare-id name.
*/
folderSlug: string | null;
}

export const isGitRepository = (model: Pick<BaseModel, 'type'>): model is GitRepository => model.type === type;

// `folderSlug` is only ever written via `slugify()`, which already restricts
// its output to this charset — this is a second, independent check at the
// point the value gets baked into a filesystem path, so a corrupted or
// otherwise unsanitized `folderSlug` can never introduce a path separator or
// a `..` traversal segment into the computed folder name.
const SAFE_FOLDER_SLUG_PATTERN = /^[a-z0-9-]+$/;

/**
* Computes the on-disk folder name for a Git repository's app-managed
* location: `git_<slug>_<hex>` when a `folderSlug` snapshot is available,
* otherwise the bare `_id` (e.g. pre-existing repos not yet backfilled).
*/
export function getGitRepoFolderName(repo: Pick<GitRepository, '_id' | 'folderSlug'>): string {
if (!repo.folderSlug || !SAFE_FOLDER_SLUG_PATTERN.test(repo.folderSlug)) {
return repo._id;
}
const hex = repo._id.startsWith(`${prefix}_`) ? repo._id.slice(prefix.length + 1) : repo._id;
return `${prefix}_${repo.folderSlug}_${hex}`;
}

export interface GitAuthor {
name: string;
email: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,9 @@ export class ProjectPage extends BasePage {
if (await projectModalCloseButton.isVisible()) {
await projectModalCloseButton.click();
}
// The modal's backdrop still intercepts clicks for a moment after closing
// (exit animation / unmount), which flakily blocks the click below.
await this.page.getByRole('dialog').waitFor({ state: 'hidden' });
await this.page.getByRole('button', { name: 'Personal workspace Organizations' }).click();
await this.page.getByRole('option', { name: /Magic/ }).click();
await this.page.getByRole('button', { name: /Magic/ }).click();
Expand Down
8 changes: 7 additions & 1 deletion packages/insomnia/src/entry.main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { AnalyticsEvent, trackAnalyticsEvent } from './main/analytics';
import { registerInsomniaProtocols } from './main/api.protocol';
import { backupIfNewerVersionAvailable } from './main/backup';
import { registerSyncHandlers } from './main/cloud-sync/ipc';
import { registerGitServiceAPI } from './main/git-service';
import { backfillAllManagedGitFolderSlugs, registerGitServiceAPI } from './main/git-service';
import { registerCookieHandlers } from './main/ipc/cookies';
import { ipcMainOn, ipcMainOnce, registerElectronHandlers } from './main/ipc/electron';
import { registerElectronStorageHandlers } from './main/ipc/electron-storage';
Expand Down Expand Up @@ -150,6 +150,12 @@ app.on('ready', async () => {
sentryWatchAnalyticsEnabled();

await runGitCredentialsMigration();
// Must run — and finish — before the window/renderer exists: it's the only
// point in the app's lifecycle where nothing else (a file watcher, a git
// operation kicked off by a route loader) can be concurrently reading or
// writing these same folders, so the rename can never race another reader
// into resurrecting the old path (see backfillManagedFolderSlug).
await backfillAllManagedGitFolderSlugs();

await _launchApp();

Expand Down
Loading
Loading