From 6d565604f857d248f3adab4ac6da146d29eac8c2 Mon Sep 17 00:00:00 2001 From: kkdev92 <112151103+kkdev92@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:47:55 +0900 Subject: [PATCH] refactor: build on @kkdev92/vscode-ext-kit 7.1.0 Takes the kit's 7.1.0 and lets it do what this extension did by hand. - clipshot.logLevel is applied with the kit's filterLogger, `silent` included, in place of the hand-written wrapper and its severity table. The level is still read on every paste and every settings change. - The save-directory warnings use the kit's checkRelativePath, which judges a value the same way on every platform: a drive-relative value such as C:images is now reported as not relative, and so is a leading backslash on Linux and macOS. Where an image is written is unchanged; validatePathInsideWorkspace still keeps it inside the workspace. The two helpers the warnings used, and validateRelativePath, which nothing called, go with their tests. - The context key clipshot.enabled is no longer set. No `when` clause in the manifest has ever read it; a keybinding of your own can test the setting as `config.clipshot.enabled`. - The two hosted services that watch settings release their subscription through their signal instead of a variable outside `start`, and the notice that the terminal skip list was written goes through the Notifications service. This file no longer imports vscode. - The manifest test holds engines.vscode to the kit's floor. - CHANGELOG: sharp 0.35.5 (libvips 8.18.7), already on main, and the above. New tests cover clipshot.logLevel at warn and silent, the configuration warnings at activation and after a change, no reaction to a change after deactivate, and rooted save directories. Each fails on a deliberate break; the save-directory test also fails on the previous source, and the others pass on it, as that behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 +++ package-lock.json | 8 +- package.json | 2 +- src/config/validators.ts | 17 ++- src/core/constants.ts | 7 -- src/extension.ts | 159 ++++++-------------------- src/security/path-validator.ts | 56 ---------- test/config/validators.test.ts | 12 ++ test/extension.test.ts | 160 ++++++++++++++++++--------- test/manifest.test.ts | 4 +- test/security/path-validator.test.ts | 47 -------- 11 files changed, 185 insertions(+), 301 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cd1498..199d1e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- sharp, which converts and resizes the pasted image, 0.35.4 → 0.35.5. It ships + libvips 8.18.7, up from 8.18.6, with newer builds of the libraries libvips + uses. +- `@kkdev92/vscode-ext-kit` `^7.0.0` → `^7.1.0`. Its log filter and its check + for relative paths replace ClipShot's own. +- ClipShot no longer sets the `clipshot.enabled` context key. Nothing in + ClipShot read it; a keybinding of your own can test the setting as + `config.clipshot.enabled`. +- A save directory starting with a drive letter, such as `C:images`, or with a + backslash is now reported in the log as not relative on every platform, as + one starting with `/` is. Where images are saved is unchanged. + ## [0.9.1] - 2026-09-28 ### Changed diff --git a/package-lock.json b/package-lock.json index 0878db2..3084029 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@kkdev92/vscode-ext-kit": "^7.0.0", + "@kkdev92/vscode-ext-kit": "^7.1.0", "@types/mocha": "^10.0.10", "@types/node": "^22.20.4", "@types/vscode": "~1.138.0", @@ -1575,9 +1575,9 @@ "license": "MIT" }, "node_modules/@kkdev92/vscode-ext-kit": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@kkdev92/vscode-ext-kit/-/vscode-ext-kit-7.0.0.tgz", - "integrity": "sha512-FAbJoH1iq9i6nhOe2MyARS4dVAl8IfVG/0+mvWbC/NzlM717460cOQxkc7QdTk6B+h4X/tzNnelc/GVm179arQ==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@kkdev92/vscode-ext-kit/-/vscode-ext-kit-7.1.0.tgz", + "integrity": "sha512-XVuDLMGZIdrbFo8dnxwaWTbqXw8tHPFvqnhpIrZNN8rNN7On8rDo1wYoVDi+AFU6hUZPhuoM6JSOahjXoKRWmQ==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 9d315b1..5cb55b5 100644 --- a/package.json +++ b/package.json @@ -280,7 +280,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@kkdev92/vscode-ext-kit": "^7.0.0", + "@kkdev92/vscode-ext-kit": "^7.1.0", "@types/mocha": "^10.0.10", "@types/node": "^22.20.4", "@types/vscode": "~1.138.0", diff --git a/src/config/validators.ts b/src/config/validators.ts index f640eba..f3dc33d 100644 --- a/src/config/validators.ts +++ b/src/config/validators.ts @@ -3,6 +3,8 @@ * Validates user settings to prevent security issues */ +import { checkRelativePath } from '@kkdev92/vscode-ext-kit'; + import type { ValidationResult, ExtensionConfig, DeepPartial } from '../core/types'; import { LIMITS, @@ -12,7 +14,6 @@ import { VALID_RESIZE_MODES, RESIZE_PRESETS, } from '../core/constants'; -import { containsParentTraversal, isAbsolutePath } from '../security/path-validator'; /** * Validate the saveDirectory setting @@ -23,19 +24,17 @@ import { containsParentTraversal, isAbsolutePath } from '../security/path-valida export function validateSaveDirectory(value: string): ValidationResult { const errors: string[] = []; - // Check for empty value - if (!value || value.trim() === '') { + // Empty, rooted (a drive, a leading separator, a share) or climbing out + // with `..`, judged the same way whichever platform the value was written on. + const problem = checkRelativePath(value); + if (problem === 'empty') { errors.push('Save directory cannot be empty'); return { valid: false, errors }; } - - // Check for absolute path - if (isAbsolutePath(value)) { + if (problem === 'absolute') { errors.push('Save directory must be a relative path'); } - - // Check for parent directory traversal - if (containsParentTraversal(value)) { + if (problem === 'parent') { errors.push('Save directory cannot contain parent directory references (..)'); } diff --git a/src/core/constants.ts b/src/core/constants.ts index 02dc8de..14baf41 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -20,13 +20,6 @@ export const COMMANDS = { ENABLE_IN_TERMINAL: `${EXTENSION_ID}.enableInTerminal`, } as const; -/** - * Context keys for VS Code when clause - */ -export const CONTEXT_KEYS = { - ENABLED: `${EXTENSION_ID}.enabled`, -} as const; - /** * Configuration key prefix */ diff --git a/src/extension.ts b/src/extension.ts index 5a1388b..107da24 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,26 +10,25 @@ * safety live where they always did; this file is only how they are reached. */ -import * as vscode from 'vscode'; - import { + Notifications, defineCommandContract, defineExtension, defineModule, + filterLogger, type OperationContext, type Validator, } from '@kkdev92/vscode-ext-kit'; import { Settings, loadConfiguration } from './config/schema'; import { validateConfiguration } from './config/validators'; -import { COMMANDS, CONTEXT_KEYS, EXTENSION_NAME } from './core/constants'; +import { COMMANDS, EXTENSION_NAME } from './core/constants'; import { disposeGlobalClipboardManager } from './clipboard/clipboard-manager'; import { getPasteHandler } from './keyboard/paste-handler'; import { describeSkipShellOutcome, ensureSkipShellEntry } from './terminal/skip-shell'; import { disposeGlobalTempFileManager } from './security/temp-file-manager'; import type { ExtensionConfig, - LogLevel, Logger, NotificationLevel, PasteDestination, @@ -88,61 +87,6 @@ export const EnableInTerminal = defineCommandContract({ id: COMMANDS.ENABLE_IN_TERMINAL, }); -/** Severity order, for comparing against the configured floor. */ -const SEVERITY: Record, number> = { - trace: 0, - debug: 1, - info: 2, - warn: 3, - error: 4, -}; - -/** - * Applies `clipshot.logLevel` on top of the channel's own level. - * - * The framework logs into a `LogOutputChannel`, which VS Code filters by the - * level chosen in the Output panel — so unlike before, this setting can only - * make the log quieter, never louder. It is kept because "stop logging at all" - * (`silent`) and "only warnings and worse" are things a user asks for and the - * panel's dropdown is easy to miss; what it can no longer do is turn on `debug` - * output that VS Code is filtering out one level up. - */ -function filtered(logger: Logger, level: LogLevel): Logger { - if (level === 'trace') { - return logger; - } - const floor = level === 'silent' ? Number.POSITIVE_INFINITY : SEVERITY[level]; - const passes = (of: Exclude): boolean => SEVERITY[of] >= floor; - return { - trace: (message, fields): void => { - if (passes('trace')) { - logger.trace(message, fields); - } - }, - debug: (message, fields): void => { - if (passes('debug')) { - logger.debug(message, fields); - } - }, - info: (message, fields): void => { - if (passes('info')) { - logger.info(message, fields); - } - }, - warn: (message, fields): void => { - if (passes('warn')) { - logger.warn(message, fields); - } - }, - error: (message, error, fields): void => { - if (passes('error')) { - logger.error(message, error, fields); - } - }, - withFields: (fields): Logger => filtered(logger.withFields(fields), level), - }; -} - /** Logs every configuration problem as a warning, without refusing to run. */ function warnAboutConfig(config: ExtensionConfig, logger: Logger): void { const result = validateConfiguration(config); @@ -153,32 +97,6 @@ function warnAboutConfig(config: ExtensionConfig, logger: Logger): void { } } -/** - * Mirrors `enabled` into a context key. - * - * `setContext` is the only way a `when` clause in package.json can see a - * setting, and there is no capability for it — a command is how VS Code - * exposes it, so this is the one place the extension calls one directly. - * - * A failure here is reported and swallowed rather than propagated. The caller - * is a settings-change listener and an activation path, and neither has a way - * to act on it: the extension is still usable with a stale `when` clause, and - * failing activation over a menu item's visibility would be the worse outcome. - * It matters in practice because `setContext` is a VS Code built-in rather than - * something this extension registers, so a test double that only knows - * registered commands rejects it. - */ -async function publishContextKeys(config: ExtensionConfig, logger: Logger): Promise { - try { - await vscode.commands.executeCommand('setContext', CONTEXT_KEYS.ENABLED, config.enabled); - } catch (error) { - logger.debug('Could not publish the context key', { - key: CONTEXT_KEYS.ENABLED, - reason: error instanceof Error ? error.message : String(error), - }); - } -} - /** Whether a notification of this kind should be shown at all. */ function wants(level: NotificationLevel, kind: 'success' | 'error'): boolean { return kind === 'success' ? level === 'all' : level !== 'none'; @@ -233,7 +151,9 @@ export const clipshot = defineModule('clipshot', (module): undefined => { inject: { settings: Settings.token }, execute: async (context: OperationContext, [args], { settings }): Promise => { const config = loadConfiguration(settings); - const logger = filtered(context.logger, config.logLevel); + // clipshot.logLevel is a floor on top of the channel's own level: VS Code + // owns that level, and an extension cannot raise it. + const logger = filterLogger(context.logger, config.logLevel); // Read per invocation rather than held from activation: a setting the // user changed a moment ago should apply to this paste, and the accessor @@ -270,7 +190,7 @@ export const clipshot = defineModule('clipshot', (module): undefined => { inject: { settings: Settings.token }, execute: async (context: OperationContext, _args, { settings }): Promise => { const config = loadConfiguration(settings); - const logger = filtered(context.logger, config.logLevel); + const logger = filterLogger(context.logger, config.logLevel); const outcome = await ensureSkipShellEntry(COMMANDS.PASTE_IMAGE, logger); const message = describeSkipShellOutcome(outcome); // Run by hand, so the result is reported whatever it is and whatever the @@ -286,33 +206,28 @@ export const clipshot = defineModule('clipshot', (module): undefined => { }); // Configuration is read where it is used, so nothing here caches it. What - // this service exists for is the two effects a change has outside a paste: - // the context key a `when` clause reads, and the warnings. - let subscription: { dispose(): void } | undefined; + // this service exists for is the one effect a change has outside a paste: + // the warnings. module.hostedServices.add({ id: 'clipshot.configuration', inject: { settings: Settings.token }, - start: async (context, { settings }) => { - const apply = async (): Promise => { + start: (context, { settings }) => { + const apply = (): void => { const config = loadConfiguration(settings); - const logger = filtered(context.logger, config.logLevel); - warnAboutConfig(config, logger); - await publishContextKeys(config, logger); + warnAboutConfig(config, filterLogger(context.logger, config.logLevel)); }; - // Awaited here so the context key is set before activation reports done; - // `publishContextKeys` swallows its own failure, so this cannot reject. - await apply(); + apply(); // `onDidChange` fires for the section as a whole. Every key here feeds - // either the context key or the warnings, so there is nothing to filter - // on — `watch` per key would be sixteen subscriptions doing one job. - subscription = settings.onDidChange(() => { + // the warnings, so there is nothing to filter on — `watch` per key would + // be sixteen subscriptions doing one job. + const subscription = settings.onDidChange(() => { context.logger.info('Configuration updated'); - void apply(); + apply(); + }); + // Released through the signal, which aborts however the application ends. + context.signal.addEventListener('abort', () => { + subscription.dispose(); }); - }, - stop: () => { - subscription?.dispose(); - subscription = undefined; }, }); @@ -321,11 +236,10 @@ export const clipshot = defineModule('clipshot', (module): undefined => { // contribution point for that list — the reasoning, and why this only started // mattering, is in src/terminal/skip-shell.ts. Activation is where the write // belongs: it is the last moment before a user reaches for the shortcut. - let terminalSubscription: { dispose(): void } | undefined; module.hostedServices.add({ id: 'clipshot.terminalShortcut', - inject: { settings: Settings.token }, - start: async (context, { settings }) => { + inject: { settings: Settings.token, notifications: Notifications }, + start: async (context, { settings, notifications }) => { // Settled once the list has an answer in it. 'failed' is the one outcome // left unsettled: a settings.json that could not be written now may be // writable later, and a change event is a reasonable moment to retry. @@ -335,31 +249,28 @@ export const clipshot = defineModule('clipshot', (module): undefined => { if (settled || !config.terminal.registerShortcut) { return; } - const logger = filtered(context.logger, config.logLevel); + const logger = filterLogger(context.logger, config.logLevel); const outcome = await ensureSkipShellEntry(COMMANDS.PASTE_IMAGE, logger); settled = outcome !== 'failed'; if (outcome === 'added' && wants(config.notifications.level, 'success')) { - // A hosted service carries a logger and no UI — deliberately, since - // VS Code may be shutting down when one stops. Writing to a user's - // settings without telling them is the worse trade, so this is the - // second and last place the extension reaches for a VS Code API by - // hand. It fires once in the life of an installation. - announce( - Promise.resolve(vscode.window.showInformationMessage(describeSkipShellOutcome(outcome))), - logger - ); + // Writing to a user's settings without telling them is the worse + // trade. It fires once in the life of an installation. + announce(notifications.info(describeSkipShellOutcome(outcome)), logger); } }; await apply(); + // Stopped while that ran: a subscription made now would outlive the stop. + if (context.signal.aborted) { + return; + } // Turning the setting on is a request, not a preference to note for next // time, so it is acted on when it happens rather than at the next start. - terminalSubscription = settings.onDidChange(() => { + const subscription = settings.onDidChange(() => { void apply(); }); - }, - stop: () => { - terminalSubscription?.dispose(); - terminalSubscription = undefined; + context.signal.addEventListener('abort', () => { + subscription.dispose(); + }); }, }); diff --git a/src/security/path-validator.ts b/src/security/path-validator.ts index ea23a20..1a18fec 100644 --- a/src/security/path-validator.ts +++ b/src/security/path-validator.ts @@ -91,62 +91,6 @@ async function resolveExistingPrefix(targetPath: string, workspaceRoot: string): } } -/** - * Check if a path contains parent directory traversal (..) - * - * @param inputPath - The path to check - * @returns True if path contains parent traversal - */ -export function containsParentTraversal(inputPath: string): boolean { - // Normalize path separators without resolving .. segments - const normalized = inputPath.replace(/\\/g, '/'); - - // Check for .. segments in the raw path (before resolution) - const segments = normalized.split('/'); - return segments.some((segment) => segment === '..'); -} - -/** - * Check if a path is absolute - * Handles both Unix and Windows paths regardless of current platform - * - * @param inputPath - The path to check - * @returns True if path is absolute - */ -export function isAbsolutePath(inputPath: string): boolean { - // Unix absolute path - if (inputPath.startsWith('/')) { - return true; - } - // Windows absolute path (e.g., C:\, D:\) - if (/^[A-Za-z]:[/\\]/.test(inputPath)) { - return true; - } - return path.isAbsolute(inputPath); -} - -/** - * Validate that a path is a relative path without parent traversal - * - * @param inputPath - The path to validate - * @throws PathValidationError if path is invalid - */ -export function validateRelativePath(inputPath: string): void { - if (isAbsolutePath(inputPath)) { - throw new PathValidationError( - `Path '${inputPath}' is absolute, expected relative`, - 'Path must be relative' - ); - } - - if (containsParentTraversal(inputPath)) { - throw new PathValidationError( - `Path '${inputPath}' contains parent directory traversal`, - 'Path cannot contain ..' - ); - } -} - /** * Normalize a path for consistent comparison * Handles cross-platform differences diff --git a/test/config/validators.test.ts b/test/config/validators.test.ts index 094df4d..b1de990 100644 --- a/test/config/validators.test.ts +++ b/test/config/validators.test.ts @@ -35,6 +35,18 @@ describe('validators', () => { expect(result.errors.some(e => e.includes('relative'))).toBe(true); }); + it('should reject a path rooted at a drive or a separator, on every platform', () => { + for (const value of ['C:images', 'C:\\images', '\\images', '\\\\server\\share']) { + const result = validateSaveDirectory(value); + expect(result.valid, value).toBe(false); + expect(result.errors.some(e => e.includes('relative')), value).toBe(true); + } + }); + + it('should treat a value of only spaces as empty', () => { + expect(validateSaveDirectory(' ').errors).toContain('Save directory cannot be empty'); + }); + it('should reject parent traversal', () => { const result = validateSaveDirectory('../outside'); expect(result.valid).toBe(false); diff --git a/test/extension.test.ts b/test/extension.test.ts index ab390aa..1113c52 100644 --- a/test/extension.test.ts +++ b/test/extension.test.ts @@ -22,8 +22,8 @@ vi.mock('../src/keyboard/paste-handler', () => ({ })); import type * as vscodeTypes from 'vscode'; -import { COMMANDS, CONTEXT_KEYS, EXTENSION_NAME } from '../src/core/constants'; -import type { PasteResult } from '../src/core/types'; +import { COMMANDS, EXTENSION_NAME } from '../src/core/constants'; +import type { Logger, PasteResult } from '../src/core/types'; let vscode: typeof import('vscode'); let extension: typeof import('../src/extension'); @@ -89,6 +89,51 @@ function notifiedMessages(mock: unknown): string[] { .mock.calls.map((call) => String(call[0])); } +/** Answers one `clipshot.*` key with `value`; every other key keeps its default. */ +function stubSetting(key: string, value: unknown): void { + const otherwise = vi.mocked(vscode.workspace.getConfiguration).getMockImplementation(); + vi.mocked(vscode.workspace.getConfiguration).mockImplementation((( + section?: string, + scope?: vscodeTypes.ConfigurationScope + ) => { + const configuration = otherwise?.(section, scope); + if (section !== 'clipshot' || configuration === undefined) { + return configuration; + } + return { + ...configuration, + get: (name: string, fallback?: unknown) => + name === key ? value : configuration.get(name, fallback), + }; + }) as never); +} + +/** + * Tells every configuration listener still subscribed that everything changed. + * + * The mock hands each subscription a `dispose` that detaches nothing, so a + * listener counts as gone once its `dispose` has been called — as it would be + * in VS Code. + */ +function fireConfigurationChange(): void { + const event = { affectsConfiguration: () => true } as vscodeTypes.ConfigurationChangeEvent; + const { calls, results } = vi.mocked(vscode.workspace.onDidChangeConfiguration).mock; + calls.forEach(([listener], index) => { + const subscription = results[index]?.value as { dispose: ReturnType } | undefined; + if (subscription?.dispose.mock.calls.length === 0) { + listener(event); + } + }); +} + +/** Messages the log channel received at `level`. */ +function logged(level: 'trace' | 'debug' | 'info' | 'warn' | 'error'): string[] { + const channel = vi.mocked(vscode.window.createOutputChannel).mock.results[0]?.value as + | Record | undefined> + | undefined; + return channel?.[level]?.mock.calls.map((call) => String(call[0])) ?? []; +} + describe('extension', () => { beforeEach(async () => { // A fresh module registry per test: a new application, a new vscode mock @@ -130,38 +175,6 @@ describe('extension', () => { }); }); - it('publishes the enabled context key', async () => { - await extension.activate(createContext()); - - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - 'setContext', - CONTEXT_KEYS.ENABLED, - true - ); - }); - - it('activates anyway when the context key cannot be published', async () => { - // `setContext` is a VS Code built-in rather than something this extension - // registers, so a host that does not know it rejects the call. That must - // not fail activation: a stale `when` clause is a worse outcome to trade - // a working extension for. - // - // This used to be covered by accident — the kit's mock rejected every - // unregistered command, `setContext` included. It answers that one now, - // which is more faithful and left this path untested. - vi.mocked(vscode.commands.executeCommand).mockRejectedValueOnce( - new Error("command 'setContext' not found") - ); - - // Resolves rather than rejects: the failure is logged and swallowed. - await expect(extension.activate(createContext())).resolves.toBeUndefined(); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - 'setContext', - CONTEXT_KEYS.ENABLED, - true - ); - }); - it('registers disposables on the extension context', async () => { const context = createContext(); @@ -176,27 +189,27 @@ describe('extension', () => { expect(vscode.workspace.onDidChangeConfiguration).toHaveBeenCalled(); }); - it('refreshes the context key when configuration changes', async () => { + it('warns about the configuration at activation, and again when it changes', async () => { + stubSetting('saveDirectory', '../outside'); await extension.activate(createContext()); + const warnings = (): number => + logged('warn').filter((message) => message.includes('Configuration warning')).length; + expect(warnings()).toBe(1); - // Two listeners are registered: the logger's level sync and the - // config schema's section watcher. Fire both. - const listeners = vi - .mocked(vscode.workspace.onDidChangeConfiguration) - .mock.calls.map((call) => call[0]); - expect(listeners.length).toBeGreaterThan(0); - - vi.mocked(vscode.commands.executeCommand).mockClear(); - const event = { affectsConfiguration: () => true } as vscodeTypes.ConfigurationChangeEvent; - for (const listener of listeners) { - listener(event); - } + fireConfigurationChange(); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - 'setContext', - CONTEXT_KEYS.ENABLED, - true - ); + expect(warnings()).toBe(2); + }); + + it('stops listening for configuration changes once deactivated', async () => { + await extension.activate(createContext()); + await extension.deactivate(); + const updates = (): number => + logged('info').filter((message) => message.includes('Configuration updated')).length; + + fireConfigurationChange(); + + expect(updates()).toBe(0); }); }); @@ -395,6 +408,49 @@ describe('extension', () => { }); }); + describe('clipshot.logLevel', () => { + /** A paste that logs one entry at each level through the logger it is handed. */ + function stubLoggingPaste(): void { + vi.mocked(pasteHandler.getPasteHandler).mockReturnValue({ + handlePaste: vi.fn((_config: unknown, logger: Logger) => { + logger.trace('probe'); + logger.debug('probe'); + logger.info('probe'); + logger.warn('probe'); + logger.error('probe'); + return Promise.resolve({ success: true }); + }), + } as never); + } + + /** The levels at which the probe reached the log channel. */ + function levelsLogged(): string[] { + return (['trace', 'debug', 'info', 'warn', 'error'] as const).filter((level) => + logged(level).some((message) => message.includes('probe')) + ); + } + + it('passes on only what is at or above the level set', async () => { + stubSetting('logLevel', 'warn'); + stubLoggingPaste(); + const paste = await activateAndGetPasteCommand(); + + await paste(); + + expect(levelsLogged()).toEqual(['warn', 'error']); + }); + + it('passes on nothing when silent', async () => { + stubSetting('logLevel', 'silent'); + stubLoggingPaste(); + const paste = await activateAndGetPasteCommand(); + + await paste(); + + expect(levelsLogged()).toEqual([]); + }); + }); + describe('deactivate', () => { it('completes without throwing when the extension was activated', async () => { await extension.activate(createContext()); diff --git a/test/manifest.test.ts b/test/manifest.test.ts index 5b963ee..62abf0e 100644 --- a/test/manifest.test.ts +++ b/test/manifest.test.ts @@ -62,10 +62,12 @@ describe('package.json', () => { it('declares what src declares', () => { // Including the three nullable settings. `resize.preset` defaults to null, // so its manifest type has to be `["string","null"]` — which is compared - // here as it is written, rather than normalised away first. + // here as it is written, rather than normalised away first. `engines` + // holds engines.vscode to the floor of the kit this extension is built on. assertManifestMatches(manifest, { settings: [Settings], commands: [PasteImage, EnableInTerminal], + engines: true, }); }); diff --git a/test/security/path-validator.test.ts b/test/security/path-validator.test.ts index 91a62da..826b007 100644 --- a/test/security/path-validator.test.ts +++ b/test/security/path-validator.test.ts @@ -1,58 +1,11 @@ import { describe, it, expect } from 'vitest'; import { - containsParentTraversal, - isAbsolutePath, - validateRelativePath, normalizePath, buildSafeRelativePath, isValidDirectoryName, } from '../../src/security/path-validator'; describe('path-validator', () => { - describe('containsParentTraversal', () => { - it('should detect parent traversal', () => { - expect(containsParentTraversal('../file.txt')).toBe(true); - expect(containsParentTraversal('path/../file.txt')).toBe(true); - expect(containsParentTraversal('../../etc/passwd')).toBe(true); - }); - - it('should allow normal paths', () => { - expect(containsParentTraversal('path/to/file.txt')).toBe(false); - expect(containsParentTraversal('./file.txt')).toBe(false); - expect(containsParentTraversal('file.txt')).toBe(false); - }); - }); - - describe('isAbsolutePath', () => { - it('should detect absolute paths', () => { - expect(isAbsolutePath('/etc/passwd')).toBe(true); - expect(isAbsolutePath('C:\\Windows\\System32')).toBe(true); - }); - - it('should identify relative paths', () => { - expect(isAbsolutePath('path/to/file')).toBe(false); - expect(isAbsolutePath('./file.txt')).toBe(false); - expect(isAbsolutePath('../file.txt')).toBe(false); - }); - }); - - describe('validateRelativePath', () => { - it('should accept valid relative paths', () => { - expect(() => validateRelativePath('path/to/file')).not.toThrow(); - expect(() => validateRelativePath('./file.txt')).not.toThrow(); - expect(() => validateRelativePath('.claude-images')).not.toThrow(); - }); - - it('should reject absolute paths', () => { - expect(() => validateRelativePath('/etc/passwd')).toThrow(); - }); - - it('should reject parent traversal', () => { - expect(() => validateRelativePath('../file.txt')).toThrow(); - expect(() => validateRelativePath('path/../../../etc')).toThrow(); - }); - }); - describe('normalizePath', () => { it('should normalize path separators', () => { expect(normalizePath('path\\to\\file')).toBe('path/to/file');