diff --git a/packages/insomnia-inso/src/commands/lint-specification.ts b/packages/insomnia-inso/src/commands/lint-specification.ts index ab7646e8727..ee1d53636a5 100644 --- a/packages/insomnia-inso/src/commands/lint-specification.ts +++ b/packages/insomnia-inso/src/commands/lint-specification.ts @@ -11,7 +11,7 @@ import { Resolver } from '@stoplight/spectral-ref-resolver'; import { oas } from '@stoplight/spectral-rulesets'; import { fetch as spectralFetch } from '@stoplight/spectral-runtime'; import { DiagnosticSeverity } from '@stoplight/types'; -import { bundleSpectralRuleset } from 'insomnia/src/common/bundle-spectral-ruleset'; +import { compileSpectralRuleset } from 'insomnia/src/common/bundle-spectral-ruleset'; import { isPrivateOrLoopbackHost } from 'insomnia/src/common/private-host'; import { InsoError } from '../errors'; @@ -92,15 +92,16 @@ export async function lintSpecification({ let ruleset = oas; try { if (rulesetFileName) { - // Flatten all local extends and validate remote extends (SSRF + disallowed keys) - // before any content reaches Spectral. - const bundledContent = await bundleSpectralRuleset(rulesetFileName); - // bundleAndLoadRuleset requires a file path, so write the pre-validated bundle to - // a uniquely-named temp directory and clean it up immediately after loading. + // Flatten all local extends and fetch + validate + fully inline remote extends (SSRF + + // disallowed keys) into a single URL-free object before any content reaches Spectral. This + // leaves bundleAndLoadRuleset with nothing remote to fetch, closing the validate-then-use race. + const compiledContent = await compileSpectralRuleset(rulesetFileName); + // bundleAndLoadRuleset requires a file path, so write the compiled object to a + // uniquely-named temp directory and clean it up immediately after loading. const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'spectral-')); try { const tempRulesetPath = path.join(tempDir, '.spectral.yaml'); - await fs.promises.writeFile(tempRulesetPath, bundledContent, { encoding: 'utf8' }); + await fs.promises.writeFile(tempRulesetPath, compiledContent, { encoding: 'utf8' }); ruleset = await bundleAndLoadRuleset(tempRulesetPath, { fs, fetch: spectralFetch }); } finally { await fs.promises.rm(tempDir, { recursive: true, force: true }); diff --git a/packages/insomnia/src/common/bundle-spectral-ruleset.ts b/packages/insomnia/src/common/bundle-spectral-ruleset.ts index bcc49177523..e6fc8d3e6cd 100644 --- a/packages/insomnia/src/common/bundle-spectral-ruleset.ts +++ b/packages/insomnia/src/common/bundle-spectral-ruleset.ts @@ -80,10 +80,10 @@ function parseRemoteExtendsUrl(entry: string, base?: URL): URL { // - Hostname must not be a known private/loopback address // - DNS resolution must not yield a private/loopback address async function assertSafeRemoteUrl(url: URL): Promise { + const hostname = url.hostname.toLowerCase(); if (url.protocol !== 'https:') { throw new Error(`Remote "extends" URL must use https: ${url.href}`); } - const hostname = url.hostname.toLowerCase(); if (!hostname || isPrivateOrLoopbackHost(hostname)) { throw new Error(`Remote "extends" URL targets a disallowed host: ${url.href}`); } @@ -154,6 +154,61 @@ async function validateRemoteExtends(url: URL, visited: Set, depth: numb } } +// Fetches a remote "extends" URL and fully inlines it into a self-contained ruleset object. +// Unlike validateRemoteExtends (which preserves the URL for Spectral to fetch later), this +// fetches the content (SSRF-guarded), validates it (blocking the "functions" RCE vector and +// prototype pollution), recursively flattens any nested remote/relative extends, and merges +// everything into a single object. Built-in spectral identifiers (spectral:oas, …) are the only +// entries preserved in "extends"; no remote URLs remain. This is the basis for the compiled +// ruleset the lint worker consumes, eliminating the second (unguarded) fetch that created the +// validate-then-use race. +async function flattenRemoteExtends(url: URL, visited: Set, depth: number): Promise { + if (depth > MAX_EXTENDS_DEPTH) { + throw new Error(`"extends" nested too deeply (max ${MAX_EXTENDS_DEPTH}) at ${url.href}`); + } + if (visited.has(url.href)) { + throw new Error(`"extends" cycle detected at ${url.href}`); + } + + const ruleset = await readRemoteRuleset(url); + const validation = validateSpectralRuleset(YAML.stringify(ruleset)); + if (!validation.isValid) { + throw new Error(`Remote ruleset at "${url.href}" failed validation: ${validation.error}`); + } + + const nextVisited = new Set(visited).add(url.href); + const flattened: Ruleset = {}; + const builtinExtends: string[] = []; + + for (const entry of toArray(ruleset.extends)) { + if (Array.isArray(entry)) { + throw new TypeError( + `Failed to process "extends" entry ${JSON.stringify(entry)}: tuple format (e.g. [path, severity]) is not supported. Use a plain string instead.`, + ); + } + // Built-in identifiers (spectral:oas, …) are resolved by Spectral locally; carry through. + if (ALLOWED_EXTENDS_IDENTIFIERS.includes(entry)) { + builtinExtends.push(entry); + continue; + } + // Nested remote/relative URL — resolve against the current URL and flatten recursively. + const child = await flattenRemoteExtends(parseRemoteExtendsUrl(entry, url), nextVisited, depth + 1); + if (child.extends) { + builtinExtends.push(...child.extends); + } + mergeInto(flattened, child); // later extends entries override earlier ones + } + + // Apply this remote ruleset's own rules on top; parent rules override child rules of the same name. + const ownOverrides: Ruleset = { ...ruleset }; + delete ownOverrides.extends; + mergeInto(flattened, ownOverrides); + + const uniqueExtends = [...new Set(builtinExtends)]; + delete flattened.extends; + return uniqueExtends.length > 0 ? { extends: uniqueExtends, ...flattened } : flattened; +} + // Recursively processes a local ruleset file's "extends" entries: // - Local file paths are loaded and their rules merged into the output. // - Remote URLs are validated (SSRF + content) then kept in "extends" for Spectral to fetch at lint time. @@ -165,6 +220,7 @@ async function flattenRuleset( visited: Set, depth: number, rootDir: string, + inlineRemote: boolean, ): Promise { const absolute = path.resolve(filePath); assertAllowed(absolute, visited, depth, rootDir); @@ -174,8 +230,9 @@ async function flattenRuleset( const nextVisited = new Set(visited).add(absolute); const flattenedRuleset: Ruleset = {}; - // Collects entries that stay in "extends": built-in spectral identifiers and remote URLs - // (already validated by validateRemoteExtends). Local file paths are flattened out entirely. + // Collects entries that stay in "extends": built-in spectral identifiers and, in bundle mode, + // remote URLs (already validated by validateRemoteExtends). Local file paths are flattened out + // entirely; in compile mode (inlineRemote) remote URLs are flattened out too. const remainingExtends: string[] = []; for (const entry of toArray(ruleset.extends)) { @@ -189,15 +246,27 @@ async function flattenRuleset( remainingExtends.push(entry); continue; } - // Remote URL extends — validate upfront (SSRF + content checks), then preserve the URL - // in "extends" for Spectral to fetch fresh at lint time via spectralRuntime.fetch. + // Remote URL extends. if (!entry.startsWith('./') && !entry.startsWith('../') && !path.isAbsolute(entry)) { + if (inlineRemote) { + // Compile mode: fetch + validate + fully inline the remote content so the lint worker + // never re-fetches it (eliminates the validate-then-use race). Built-in identifiers + // surfaced by the remote ruleset are preserved. + const remoteRuleset = await flattenRemoteExtends(parseRemoteExtendsUrl(entry), nextVisited, depth + 1); + if (remoteRuleset.extends) { + remainingExtends.push(...remoteRuleset.extends); + } + mergeInto(flattenedRuleset, remoteRuleset); + continue; + } + // Bundle mode: validate upfront (SSRF + content checks), then preserve the URL in "extends" + // as the pollable source. The compile step inlines it before linting. await validateRemoteExtends(parseRemoteExtendsUrl(entry), nextVisited, depth + 1); remainingExtends.push(entry); continue; } // Local file paths are recursively loaded and flattened. - const childRuleset = await flattenRuleset(path.resolve(baseDir, entry), nextVisited, depth + 1, rootDir); + const childRuleset = await flattenRuleset(path.resolve(baseDir, entry), nextVisited, depth + 1, rootDir, inlineRemote); if (childRuleset.extends) { remainingExtends.push(...childRuleset.extends); } @@ -215,14 +284,32 @@ async function flattenRuleset( return uniqueExtends.length > 0 ? { extends: uniqueExtends, ...flattenedRuleset } : flattenedRuleset; } -// Entry point for ruleset processing. Flattens all local "extends" into a single ruleset, -// validates all remote "extends" URLs (SSRF + content), validates the merged output for -// disallowed keys (e.g. "functions"), and returns the result as a YAML string. -// The output is safe to store and pass to Spectral: local content is fully merged, remote URLs -// have been pre-vetted and are preserved in "extends" for Spectral to resolve at lint time. +// Entry point for ruleset processing at upload/storage time. Flattens all local "extends" into a +// single ruleset, validates all remote "extends" URLs (SSRF + content), validates the merged +// output for disallowed keys (e.g. "functions"), and returns the result as a YAML string. +// The output is safe to STORE: local content is fully merged, remote URLs have been pre-vetted +// and are preserved in "extends" as the pollable source. Use compileSpectralRuleset to produce +// the URL-free object that is actually linted. export async function bundleSpectralRuleset(sourcePath: string): Promise { const rootDir = path.dirname(path.resolve(sourcePath)); - const flattenedRuleset = await flattenRuleset(sourcePath, new Set(), 0, rootDir); + const flattenedRuleset = await flattenRuleset(sourcePath, new Set(), 0, rootDir, /* inlineRemote */ false); + const yaml = YAML.stringify(flattenedRuleset); + const validation = validateSpectralRuleset(yaml); + if (!validation.isValid) { + throw new Error(`Invalid Spectral ruleset: ${validation.error}`); + } + return yaml; +} + +// Entry point for ruleset processing at lint time. Behaves like bundleSpectralRuleset but also +// fetches, validates, and fully inlines remote "extends" URLs, leaving only built-in identifiers +// (spectral:oas, …) in "extends". The returned YAML is a single self-contained object with no +// remote references, so the lint worker has nothing left to fetch — closing the validate-then-use +// race that allowed a server to serve clean content to the validator and malicious content to the +// worker. +export async function compileSpectralRuleset(sourcePath: string): Promise { + const rootDir = path.dirname(path.resolve(sourcePath)); + const flattenedRuleset = await flattenRuleset(sourcePath, new Set(), 0, rootDir, /* inlineRemote */ true); const yaml = YAML.stringify(flattenedRuleset); const validation = validateSpectralRuleset(yaml); if (!validation.isValid) { diff --git a/packages/insomnia/src/entry.main.ts b/packages/insomnia/src/entry.main.ts index c1c5e58d578..e8cea40ae50 100644 --- a/packages/insomnia/src/entry.main.ts +++ b/packages/insomnia/src/entry.main.ts @@ -39,6 +39,7 @@ import { registerWebSocketHandlers } from './main/network/websocket'; import { watchProxySettings } from './main/proxy'; import { initializeSentry, sentryWatchAnalyticsEnabled } from './main/sentry'; import { checkIfRestartNeeded } from './main/squirrel-startup'; +import * as spectralRulesetRefresh from './main/spectral-ruleset-refresh'; import * as updates from './main/updates'; import * as windowUtils from './main/window-utils'; @@ -137,6 +138,8 @@ app.on('ready', async () => { // Init the rest await updates.init(); + // Periodically re-check remote spectral "extends" for upstream changes and re-lint. + spectralRulesetRefresh.init(); // recursive = ignore already exists error await fs.mkdir(path.join(dataPath, 'responses'), { recursive: true }); }); diff --git a/packages/insomnia/src/insomnia-data/node-src/services/project-lint-ruleset.ts b/packages/insomnia/src/insomnia-data/node-src/services/project-lint-ruleset.ts index 7e187c08901..b803e4ec5ce 100644 --- a/packages/insomnia/src/insomnia-data/node-src/services/project-lint-ruleset.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/project-lint-ruleset.ts @@ -3,6 +3,10 @@ import { database as db, models } from '~/insomnia-data'; const { type } = models.projectLintRuleset; +export function all() { + return db.find(type); +} + export function getByParentId(projectId: string) { return db.findOne(type, { parentId: projectId }); } diff --git a/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts b/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts index 83284cb6044..ccdf359104c 100644 --- a/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts +++ b/packages/insomnia/src/main/__tests__/bundle-spectral-ruleset.test.ts @@ -19,7 +19,7 @@ vi.mock('node:dns/promises', () => ({ import dns from 'node:dns/promises'; import fs from 'node:fs'; -import { bundleSpectralRuleset } from '~/common/bundle-spectral-ruleset'; +import { bundleSpectralRuleset, compileSpectralRuleset } from '~/common/bundle-spectral-ruleset'; const mockReadFile = vi.mocked(fs.promises.readFile) as MockedFunction<(path: string) => Promise>; @@ -417,3 +417,82 @@ rules: }); }); }); + +describe('compileSpectralRuleset', () => { + it('fully inlines remote ruleset content and drops the URL', async () => { + mockReadFile.mockResolvedValueOnce( + ` +extends: + - "https://example.com/remote.yaml" +rules: + local-rule: + given: "$.info" + severity: warn + then: + function: truthy +`, + ); + vi.mocked(fetch).mockResolvedValue(rulesetResponse(`rules:${VALID_RULE}`)); + + const result = await compileSpectralRuleset('/fake/ruleset.yaml'); + // Local rules stay, remote rules are inlined, and no remote URL remains for the worker to fetch. + expect(result).toContain('local-rule'); + expect(result).toContain('remote-rule'); + expect(result).not.toContain('https://example.com/remote.yaml'); + }); + + it('recursively inlines nested remote extends', async () => { + mockReadFile.mockResolvedValueOnce(`extends:\n - "https://example.com/a.yaml"\n`); + vi.mocked(fetch).mockImplementation(async (input: any) => { + const href = String(input); + if (href === 'https://example.com/a.yaml') { + return rulesetResponse(`extends:\n - "./b.yaml"\nrules:${VALID_RULE}`); + } + if (href === 'https://example.com/b.yaml') { + return rulesetResponse( + `rules:\n nested-rule:\n given: "$.servers"\n severity: warn\n then:\n function: truthy\n`, + ); + } + throw new Error(`Unexpected fetch call: ${href}`); + }); + + const result = await compileSpectralRuleset('/fake/ruleset.yaml'); + // Both levels are inlined; no remote URLs remain. + expect(result).toContain('remote-rule'); + expect(result).toContain('nested-rule'); + expect(result).not.toContain('https://example.com'); + }); + + it('preserves built-in identifiers surfaced by a remote ruleset', async () => { + mockReadFile.mockResolvedValueOnce(`extends:\n - "https://example.com/remote.yaml"\n`); + vi.mocked(fetch).mockResolvedValue(rulesetResponse(`extends:\n - spectral:oas\nrules:${VALID_RULE}`)); + + const result = await compileSpectralRuleset('/fake/ruleset.yaml'); + expect(result).toContain('spectral:oas'); + expect(result).toContain('remote-rule'); + expect(result).not.toContain('https://example.com/remote.yaml'); + }); + + it('rejects a remote ruleset that declares custom functions (RCE vector)', async () => { + mockReadFile.mockResolvedValueOnce(`extends:\n - "https://example.com/exec.yaml"\n`); + vi.mocked(fetch).mockResolvedValue( + rulesetResponse(`functions:\n - exec\nrules:\n env-check:\n given: "$"\n then:\n function: exec\n`), + ); + + await expect(compileSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('failed validation'); + }); + + it('rejects a non-https remote extends without fetching', async () => { + mockReadFile.mockResolvedValueOnce(`extends:\n - "http://example.com/remote.yaml"\n`); + + await expect(compileSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('must use https'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('rejects a remote extends pointing at a loopback host without fetching', async () => { + mockReadFile.mockResolvedValueOnce(`extends:\n - "https://127.0.0.1/remote.yaml"\n`); + + await expect(compileSpectralRuleset('/fake/ruleset.yaml')).rejects.toThrow('disallowed host'); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/insomnia/src/main/__tests__/spectral-ruleset-refresh.test.ts b/packages/insomnia/src/main/__tests__/spectral-ruleset-refresh.test.ts new file mode 100644 index 00000000000..2fa259fcba8 --- /dev/null +++ b/packages/insomnia/src/main/__tests__/spectral-ruleset-refresh.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Temp-file ops used while compiling are no-ops in tests. +vi.mock('node:fs', () => ({ + default: { + promises: { + mkdtemp: vi.fn(async () => '/tmp/spectral-refresh-test'), + writeFile: vi.fn(async () => undefined), + rm: vi.fn(async () => undefined), + }, + }, +})); + +const send = vi.fn(); +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/tmp/userData') }, + BrowserWindow: { + getAllWindows: () => [{ webContents: { send } }], + }, +})); + +vi.mock('~/common/bundle-spectral-ruleset', () => ({ + compileSpectralRuleset: vi.fn(), +})); + +vi.mock('~/insomnia-data', () => ({ + services: { + projectLintRuleset: { + all: vi.fn(), + }, + }, +})); + +import { compileSpectralRuleset } from '~/common/bundle-spectral-ruleset'; +import { services } from '~/insomnia-data'; + +import { refreshOnce, stop } from '../spectral-ruleset-refresh'; + +const mockAll = vi.mocked(services.projectLintRuleset.all); +const mockCompile = vi.mocked(compileSpectralRuleset); + +const REMOTE_SOURCE = 'extends:\n - "https://example.com/remote.yaml"\n'; +const LOCAL_SOURCE = 'rules:\n r:\n given: "$"\n then:\n function: truthy\n'; + +beforeEach(() => { + send.mockReset(); + mockAll.mockReset(); + mockCompile.mockReset(); +}); + +afterEach(() => { + // Clears the in-memory baseline hash map between tests. + stop(); +}); + +describe('spectral-ruleset-refresh', () => { + it('does not notify when the compiled output is unchanged', async () => { + mockAll.mockResolvedValue([{ parentId: 'proj1', rulesetContent: REMOTE_SOURCE }] as any); + mockCompile.mockResolvedValue('rules: {}'); + + await refreshOnce(); // baseline + await refreshOnce(); // unchanged + + expect(send).not.toHaveBeenCalled(); + }); + + it('notifies the renderer when the compiled output changes', async () => { + mockAll.mockResolvedValue([{ parentId: 'proj1', rulesetContent: REMOTE_SOURCE }] as any); + mockCompile.mockResolvedValueOnce('rules: {a: 1}'); // baseline + mockCompile.mockResolvedValueOnce('rules: {a: 2}'); // changed upstream + + await refreshOnce(); + await refreshOnce(); + + expect(send).toHaveBeenCalledWith('spectral-ruleset.updated', { projectId: 'proj1' }); + }); + + it('skips sources without remote extends', async () => { + mockAll.mockResolvedValue([{ parentId: 'proj1', rulesetContent: LOCAL_SOURCE }] as any); + + await refreshOnce(); + + expect(mockCompile).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + }); + + it('keeps the last baseline and does not throw when compile fails', async () => { + mockAll.mockResolvedValue([{ parentId: 'proj1', rulesetContent: REMOTE_SOURCE }] as any); + mockCompile.mockRejectedValue(new Error('fetch failed')); + + await expect(refreshOnce()).resolves.toBeUndefined(); + expect(send).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 88bd9cd8c26..9780db29e6f 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -254,7 +254,8 @@ export type RendererOnChannels = | 'hide-oauth-authorization-modal' | 'mcp-auth-confirmation' | 'git.db-synced' - | 'git.file-problems-changed'; + | 'git.file-problems-changed' + | 'spectral-ruleset.updated'; export const ipcMainOn = ( channel: MainOnChannels, diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index 5fd282317f9..86ead3424e2 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -19,6 +19,7 @@ import type { UtilityProcess } from 'electron/main'; import iconv from 'iconv-lite'; import { bundleSpectralRuleset } from '~/common/bundle-spectral-ruleset'; +import { writeCompiledRuleset } from '~/main/spectral-ruleset-cache'; import { AI_PLUGIN_NAME } from '~/common/constants'; import { cannotAccessPathError } from '~/common/misc'; import type { AuthTypeOAuth2, OAuth2Token, RequestHeader, Services } from '~/insomnia-data'; @@ -427,10 +428,12 @@ export function registerMainHandlers() { rulesetPath = safePath; try { - // Validate the ruleset (flattens local extends, checks remote URLs for SSRF and - // disallowed keys such as "functions") before passing the path to the lint worker. - // Result is discarded — validation only; the original file is not modified. - await bundleSpectralRuleset(rulesetPath); + // Compile the ruleset (flattens local extends, fetches + validates + fully inlines remote + // extends, blocking SSRF and disallowed keys such as "functions") into a URL-free object + // written to a cache path under userData. The worker is pointed at that compiled object so + // it has nothing left to fetch — closing the validate-then-use race. + const { compiledPath } = await writeCompiledRuleset(rulesetPath); + rulesetPath = compiledPath; } catch (err) { // Fall back to the default OAS ruleset if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') { diff --git a/packages/insomnia/src/main/spectral-ruleset-cache.ts b/packages/insomnia/src/main/spectral-ruleset-cache.ts new file mode 100644 index 00000000000..f15979d6d1b --- /dev/null +++ b/packages/insomnia/src/main/spectral-ruleset-cache.ts @@ -0,0 +1,40 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { app } from 'electron'; + +import { compileSpectralRuleset } from '~/common/bundle-spectral-ruleset'; + +// The compiled ruleset is a URL-free, fully-inlined object derived from a stored source +// ruleset. It is cached under userData (never inside a git repo) so the lint worker reads an +// object with no remote references — there is nothing left for it to fetch, which closes the +// validate-then-use race in the linting pipeline. +const CACHE_DIR_NAME = 'lint-cache'; + +export function contentHash(content: string): string { + return createHash('sha256').update(content, 'utf8').digest('hex'); +} + +// Derives the on-disk path for the compiled artifact of a given source ruleset. Keyed by a hash +// of the resolved source path so different projects/rulesets never collide. The basename is kept +// as `.spectral.yaml` to satisfy any downstream path expectations. +export function compiledRulesetPathFor(sourcePath: string): string { + const key = contentHash(path.resolve(sourcePath)); + return path.join(app.getPath('userData'), CACHE_DIR_NAME, key, '.spectral.yaml'); +} + +// Compiles a source ruleset (fetching + validating + inlining any remote extends) and writes the +// result to its compiled-cache path. Returns the compiled path, content, and a content hash for +// change detection. Throws if the source is missing or fails validation. +export async function writeCompiledRuleset(sourcePath: string): Promise<{ + compiledPath: string; + content: string; + hash: string; +}> { + const content = await compileSpectralRuleset(sourcePath); + const compiledPath = compiledRulesetPathFor(sourcePath); + await fs.promises.mkdir(path.dirname(compiledPath), { recursive: true }); + await fs.promises.writeFile(compiledPath, content, 'utf8'); + return { compiledPath, content, hash: contentHash(content) }; +} diff --git a/packages/insomnia/src/main/spectral-ruleset-refresh.ts b/packages/insomnia/src/main/spectral-ruleset-refresh.ts new file mode 100644 index 00000000000..df75729201c --- /dev/null +++ b/packages/insomnia/src/main/spectral-ruleset-refresh.ts @@ -0,0 +1,107 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { BrowserWindow } from 'electron'; +import YAML from 'yaml'; + +import { compileSpectralRuleset } from '~/common/bundle-spectral-ruleset'; +import { services } from '~/insomnia-data'; + +import { contentHash } from './spectral-ruleset-cache'; + +// How often to re-check remote extends for upstream changes. +const REFRESH_INTERVAL_MS = 60_000; + +// Last compiled-output hash observed per project, used to detect upstream changes between ticks. +const lastCompiledHash = new Map(); + +let refreshTimer: NodeJS.Timeout | null = null; + +// True if the stored source ruleset references at least one remote extends URL. Sources with only +// local (already-flattened) rules or built-in identifiers never change upstream, so we skip them. +function hasRemoteExtends(rulesetContent: string): boolean { + let parsed: unknown; + try { + parsed = YAML.parse(rulesetContent); + } catch { + return false; + } + if (parsed === null || typeof parsed !== 'object') { + return false; + } + const extendsValue = (parsed as { extends?: unknown }).extends; + const entries = Array.isArray(extendsValue) ? extendsValue : extendsValue === undefined ? [] : [extendsValue]; + return entries.some(entry => typeof entry === 'string' && /^https?:\/\//i.test(entry)); +} + +// Notify all renderer windows that a project's ruleset has changed upstream so they can re-lint. +// The renderer's lint request re-compiles fresh, producing the updated compiled cache. +function notifyRulesetUpdated(projectId: string): void { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send('spectral-ruleset.updated', { projectId }); + } +} + +// Re-compiles a stored source ruleset (fetching + validating + inlining its remote extends) and +// returns a hash of the result. Compilation reads from a file path, so the stored source content +// is written to a short-lived temp file first. Local extends were already flattened at upload, so +// no local-path resolution is needed here. +async function compileFromSource(rulesetContent: string): Promise { + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'spectral-refresh-')); + try { + const tempPath = path.join(tempDir, '.spectral.yaml'); + await fs.promises.writeFile(tempPath, rulesetContent, { encoding: 'utf8' }); + const compiled = await compileSpectralRuleset(tempPath); + return contentHash(compiled); + } finally { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } +} + +export async function refreshOnce(): Promise { + let rulesets; + try { + rulesets = await services.projectLintRuleset.all(); + } catch (err) { + console.warn('[spectral-refresh] failed to load rulesets:', err); + return; + } + + for (const ruleset of rulesets) { + if (!ruleset.rulesetContent || !hasRemoteExtends(ruleset.rulesetContent)) { + continue; + } + try { + const hash = await compileFromSource(ruleset.rulesetContent); + const previous = lastCompiledHash.get(ruleset.parentId); + lastCompiledHash.set(ruleset.parentId, hash); + // Skip the first observation (baseline) and unchanged content; only notify on a real change. + if (previous !== undefined && previous !== hash) { + console.log(`[spectral-refresh] remote ruleset changed for project ${ruleset.parentId}; re-linting`); + notifyRulesetUpdated(ruleset.parentId); + } + } catch (err) { + // Keep the last known-good baseline; the lint path independently re-compiles and surfaces + // any hard failure to the user, so a transient fetch/validation error here is non-fatal. + console.warn(`[spectral-refresh] compile failed for project ${ruleset.parentId}:`, err instanceof Error ? err.message : err); + } + } +} + +export const init = (): void => { + if (refreshTimer) { + return; + } + refreshTimer = setInterval(() => { + refreshOnce().catch(err => console.warn('[spectral-refresh] tick failed:', err)); + }, REFRESH_INTERVAL_MS); +}; + +export const stop = (): void => { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + lastCompiledHash.clear(); +}; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index b6ade14b603..866d2b91e17 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -271,6 +271,17 @@ const Component = ({ params }: Route.ComponentProps) => { editor.current?.tryToSetOption('lint', { ...lintOptions }); }, [selectedRulesetPath, rulesetContent]); + useEffect(() => { + // The main process periodically re-checks remote "extends" for upstream changes. When this + // project's ruleset changes, re-run lint so the new (re-compiled) ruleset is applied. + return window.main.on('spectral-ruleset.updated', (_event, payload: { projectId: string }) => { + if (payload?.projectId !== projectId) { + return; + } + editor.current?.tryToSetOption('lint', { ...lintOptions }); + }); + }, [projectId]); + useEffect(() => { if (lintErrors.length > 0 || lintWarnings.length > 0) { setIsLintPaneOpen(true);