From b7f930e2b1861f947b3341e095fe88d9441720bb Mon Sep 17 00:00:00 2001 From: Scott Motte Date: Wed, 23 Sep 2026 06:24:43 -0700 Subject: [PATCH] respect settings with the new editor --- CHANGELOG.md | 6 +++ README.md | 12 +++-- lib/autocloaking.js | 7 +-- lib/decorations.js | 10 ++-- lib/helpers.js | 22 ++++----- lib/secure-editor.js | 13 ++++- lib/settings.js | 47 ++++++++++-------- lib/yaml-cloaking.js | 4 +- media/editor/main.js | 41 +++++++++++++++- media/editor/style.css | 2 +- test/renderer/configuration.js | 43 +++++++++++++++++ test/suite/extension.test.js | 9 ++-- test/suite/lib/autocloaking.test.js | 36 ++++++++++++++ test/suite/lib/secure-editor.test.js | 20 ++++++-- test/suite/lib/secure-renderer.test.js | 66 ++++++++++++++++++++++++++ 15 files changed, 282 insertions(+), 56 deletions(-) create mode 100644 test/renderer/configuration.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e80efc..2797ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to the Official Dotenv VS Code extension will be documented ## [Unreleased](https://github.com/dotenvx/dotenv-vscode/compare/v1.5.2...master) +### Fixed + +* Honor cloak color and icon settings in the Dotenv Editor, and folder-specific cloaking and secret-peeking settings in multi-root workspaces. + +* Honor the auto-cloaking setting in the Dotenv Editor, including on load, settings changes, and tab switches (#139). + ## [1.5.2](https://github.com/dotenvx/dotenv-vscode/compare/v1.5.1...v1.5.2) (2026-09-20) ### Changed diff --git a/README.md b/README.md index 60bb9e1..e78e06c 100644 --- a/README.md +++ b/README.md @@ -48,12 +48,14 @@
Does it automatically cloak my secrets? Yes.
-Open a `.env` file and its values start masked. +By default, open a `.env` file and its values start masked. Click **Toggle auto-cloaking** at the top of the file or run **Dotenv: Toggle auto-cloaking** from the Command Palette to reveal or hide them. -Switching tabs hides them again in the Dotenv Editor. +Switching tabs hides them again in the Dotenv Editor when auto-cloaking is enabled. +Set `dotenv.enableAutocloaking` to `false` to keep values visible, including after +reopening files or switching tabs. Changes to this setting apply to open editors immediately.   @@ -313,7 +315,7 @@ of overwriting the other edit.
Does the Dotenv Editor prevent secrets from flashing when I switch tabs? Yes.
-Values are masked before the editor becomes visible, and masking updates +With auto-cloaking enabled, values are masked before the editor becomes visible, and masking updates locally as you type. Each view starts masked and hides values again on tab switches. The minimap and general hover previews are disabled; encrypted values have their @@ -328,6 +330,10 @@ own explicit **Decrypt value** action. Fonts and basic editor settings follow VS Code. It uses Monaco, the editor component behind VS Code. +`dotenv.cloakColor` and `dotenv.cloakIcon` customize the mask in both the Dotenv +Editor and the native text editor. Changes apply immediately. Folder settings +are respected in multi-root workspaces. + Custom theme token rules, arbitrary user keybindings, and other extensions don't automatically carry into the embedded editor. diff --git a/lib/autocloaking.js b/lib/autocloaking.js index 6d40a3f..2053165 100644 --- a/lib/autocloaking.js +++ b/lib/autocloaking.js @@ -79,10 +79,11 @@ function buildToggle (context) { } async function dotenvToggleAutocloaking () { - if (settings.autocloakingEnabled()) { - await settings.autocloakingOff() + const uri = vscode.window.activeTextEditor?.document.uri + if (settings.autocloakingEnabled(uri)) { + await settings.autocloakingOff(uri) } else { - await settings.autocloakingOn() + await settings.autocloakingOn(uri) } return true diff --git a/lib/decorations.js b/lib/decorations.js index d5908b2..13e83fd 100644 --- a/lib/decorations.js +++ b/lib/decorations.js @@ -8,7 +8,7 @@ const maskDecoration = vscode.window.createTextEditorDecorationType({ color: 'transparent' }) -const parse = function (sourceCode) { +const parse = function (sourceCode, uri) { const arr = [] // const stillDisplayCommentsRegex = /((=|:)\s?".+"|(=|:)\s?'.+'|(=|:)\s?`.+`|(=|:).*\s?(\s#)?)/ const regex = /=.+/ @@ -30,7 +30,7 @@ const parse = function (sourceCode) { // build line and column start and ends arr.push({ - maskedText: settings.cloakIcon().repeat(firstMatch.length), + maskedText: settings.cloakIcon(uri).repeat(firstMatch.length), start: { line: lineIndex, column: startIndex }, end: { line: lineIndex, column: endIndex } }) @@ -52,7 +52,7 @@ const applyDecorations = function (_ctx, editor, patches) { range, renderOptions: { after: { - color: settings.cloakColor(), + color: settings.cloakColor(editor.document.uri), contentText: `=${patch.maskedText}` } } @@ -69,11 +69,11 @@ const decorate = (context, editor) => { try { yaml.decorate(editor) - if (editor.document.languageId !== 'dotenv' || !settings.autocloakingEnabled()) { + if (editor.document.languageId !== 'dotenv' || !settings.autocloakingEnabled(editor.document.uri)) { applyDecorations(context, editor, []) } else { const sourceCode = editor.document.getText() - const patches = parse(sourceCode) + const patches = parse(sourceCode, editor.document.uri) applyDecorations(context, editor, patches) } } catch (e) { diff --git a/lib/helpers.js b/lib/helpers.js index 13db4ee..fb576c2 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -23,17 +23,17 @@ function envEntries (document) { return Object.entries(envParsed(document)) } -function displayValue (value, revealed = settings.secretpeekingEnabled()) { - return (revealed ? value : _mask(value)) || '(empty)' +function displayValue (value, revealed, uri) { + return (revealed ? value : _mask(value, uri)) || '(empty)' } -function valueDocumentation (values, batch, key, hoverRevealed) { +function valueDocumentation (values, batch, key, hoverRevealed, uri) { const doc = new vscode.MarkdownString() for (const { source, value } of values) { doc.appendText(source) doc.appendMarkdown('\n\n') const revealed = batch?.request && batch.request.key === key && batch.request.source === source && batch.request.revealed - doc.appendText(batch ? ((revealed ? value : _mask(value)) || '(empty)') : displayValue(value, hoverRevealed)) + doc.appendText(batch ? ((revealed ? value : _mask(value, uri)) || '(empty)') : displayValue(value, hoverRevealed, uri)) if (batch && value) completionReveal.append(doc, batch, key, source, revealed) doc.appendMarkdown('\n\n---\n\n') } @@ -43,8 +43,8 @@ function valueDocumentation (values, batch, key, hoverRevealed) { function valueHover (key, document, range) { const values = module.exports.envValues(document).get(key) if (!values) return new vscode.Hover(settings.missingText(), range) - const revealed = hoverReveal.begin(document, key, range, settings.secretpeekingEnabled()) - const contents = [valueDocumentation(values, undefined, undefined, revealed)] + const revealed = hoverReveal.begin(document, key, range, settings.secretpeekingEnabled(document.uri)) + const contents = [valueDocumentation(values, undefined, undefined, revealed, document.uri)] const control = values.some(entry => entry.value) && hoverReveal.control(document, key, range, revealed) if (control) contents.push(control) return new vscode.Hover(contents, range) @@ -82,8 +82,8 @@ function hover (language, document, position) { } } -function _mask (str) { - return settings.cloakIcon().repeat(str.length) +function _mask (str, uri) { + return settings.cloakIcon(uri).repeat(str.length) } function autocomplete (triggerCharacter, document, position) { @@ -95,7 +95,7 @@ function autocomplete (triggerCharacter, document, position) { const values = env[1] const completionItemLabel = { label: key, - detail: values.every(entry => entry.value === values[0].value) ? ` ${_mask(values[0].value) || '(empty)'}` : ` (${values.length} files)`, + detail: values.every(entry => entry.value === values[0].value) ? ` ${_mask(values[0].value, document.uri) || '(empty)'}` : ` (${values.length} files)`, description: values.map(entry => entry.source).join(', ') } const item = new vscode.CompletionItem(completionItemLabel, vscode.CompletionItemKind.Variable) @@ -104,12 +104,12 @@ function autocomplete (triggerCharacter, document, position) { item.range = new vscode.Range(new vscode.Position(position.line, position.character - 1), position) // Picks up trigger character as prefix to fix the scoring it does when sorting item.sortText = '0' // Make this the sortText so that any ENV variables will go to the top of the list above anything else - item.documentation = valueDocumentation(values, batch, key) + item.documentation = valueDocumentation(values, batch, key, undefined, document.uri) return item }) const valuesByKey = new Map(entries) - return completionReveal.track(batch, items, (key, refreshed) => valueDocumentation(valuesByKey.get(key), refreshed, key)) + return completionReveal.track(batch, items, (key, refreshed) => valueDocumentation(valuesByKey.get(key), refreshed, key, undefined, document.uri)) } module.exports.envEntries = envEntries diff --git a/lib/secure-editor.js b/lib/secure-editor.js index f8e51cf..4c0af47 100644 --- a/lib/secure-editor.js +++ b/lib/secure-editor.js @@ -31,8 +31,12 @@ function resolveCustomTextEditor (document, panel, context) { const config = vscode.workspace.getConfiguration('editor', document.uri) return Object.fromEntries(['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'tabSize', 'insertSpaces', 'wordWrap', 'renderWhitespace', 'cursorStyle', 'cursorBlinking', 'mouseWheelZoom'].map(key => [key, config.get(key)])) } + const configuration = () => { + const config = vscode.workspace.getConfiguration('dotenv', document.uri) + return { autocloaking: config.get('enableAutocloaking', true), cloakColor: config.get('cloakColor', '#000000'), cloakIcon: config.get('cloakIcon', '█') } + } const send = (extra = {}) => { - if (client && !disposed) webview.postMessage({ type: 'document', client, text: text(), version: document.version, filename: path.basename(document.uri.fsPath || ''), options: options(), ...extra }) + if (client && !disposed) webview.postMessage({ type: 'document', client, text: text(), version: document.version, filename: path.basename(document.uri.fsPath || ''), options: options(), ...configuration(), ...extra }) } const view = { panel, toggle: () => webview.postMessage({ type: 'toggle' }) } views.add(view) @@ -40,7 +44,12 @@ function resolveCustomTextEditor (document, panel, context) { vscode.workspace.onDidChangeTextDocument(event => { if (event.document.uri.toString() === document.uri.toString() && !busy) send() }), - vscode.workspace.onDidChangeConfiguration(event => { if (event.affectsConfiguration('editor', document.uri)) send() }), + vscode.workspace.onDidChangeConfiguration(event => { + if (['enableAutocloaking', 'cloakColor', 'cloakIcon'].some(key => event.affectsConfiguration(`dotenv.${key}`, document.uri)) && client && !disposed) { + webview.postMessage({ type: 'configuration', client, ...configuration() }) + } + if (event.affectsConfiguration('editor', document.uri)) send() + }), webview.onDidReceiveMessage(message => { return (queue = queue.then(async () => { if (!message || typeof message.client !== 'string') return diff --git a/lib/settings.js b/lib/settings.js index 6bbae25..abb870b 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -21,30 +21,32 @@ const editorTokenColorCustomizationsKey = 'editor.tokenColorCustomizations' const toggleStateKey = 'autocloakingOverride' let extensionState -let override +let overrides = [] function initialize (context) { extensionState = context.globalState - override = extensionState.get(toggleStateKey) + const stored = extensionState.get(toggleStateKey) + overrides = Array.isArray(stored) ? stored : stored ? [stored] : [] } -async function setAutocloaking (enabled) { - override = { enabled, configured: !!userConfig().get(enableAutocloakingKey) } - await extensionState.update(toggleStateKey, override) +async function setAutocloaking (enabled, uri) { + const override = { enabled, configured: !!userConfig(uri).get(enableAutocloakingKey), scope: scope(uri) } + overrides = overrides.filter(item => item.scope !== override.scope).concat(override) + await extensionState.update(toggleStateKey, overrides) return enabled } async function resetAutocloaking () { - override = undefined + overrides = [] await extensionState.update(toggleStateKey, undefined) } -async function autocloakingOff () { - return setAutocloaking(false) +async function autocloakingOff (uri) { + return setAutocloaking(false, uri) } -async function autocloakingOn () { - return setAutocloaking(true) +async function autocloakingOn (uri) { + return setAutocloaking(true, uri) } // Older releases persisted invisible syntax colors. Remove only their exact @@ -69,26 +71,31 @@ async function removeLegacyMask () { Object.keys(next).length ? next : undefined, vscode.ConfigurationTarget.Global) } -function userConfig () { - return vscode.workspace.getConfiguration() +function scope (uri) { + return uri && (vscode.workspace.getWorkspaceFolder?.(uri)?.uri || uri).toString() +} + +function userConfig (uri) { + return vscode.workspace.getConfiguration(undefined, uri) } // settings -function autocloakingEnabled () { - const configured = !!userConfig().get(enableAutocloakingKey) +function autocloakingEnabled (uri) { + const configured = !!userConfig(uri).get(enableAutocloakingKey) + const override = overrides.find(item => item.scope === scope(uri)) return override && override.configured === configured ? override.enabled : configured } -function secretpeekingEnabled () { - return !!userConfig().get(enableSecretpeekingKey) +function secretpeekingEnabled (uri) { + return !!userConfig(uri).get(enableSecretpeekingKey) } -function cloakColor () { - return userConfig().get(cloakColorKey) +function cloakColor (uri) { + return userConfig(uri).get(cloakColorKey) } -function cloakIcon () { - return userConfig().get(cloakIconKey) +function cloakIcon (uri) { + return userConfig(uri).get(cloakIconKey) } function missingText () { diff --git a/lib/yaml-cloaking.js b/lib/yaml-cloaking.js index 4613910..693deba 100644 --- a/lib/yaml-cloaking.js +++ b/lib/yaml-cloaking.js @@ -8,10 +8,10 @@ function ranges (document) { return values.ranges(document.getText(), config.get('yamlSections', values.defaultSections)) } function decorate (editor) { - const patches = supported(editor.document) && settings.autocloakingEnabled() ? ranges(editor.document) : [] + const patches = supported(editor.document) && settings.autocloakingEnabled(editor.document.uri) ? ranges(editor.document) : [] editor.setDecorations(mask, patches.map(({ start, end }) => ({ range: new vscode.Range(editor.document.positionAt(start), editor.document.positionAt(end)), - renderOptions: { after: { color: settings.cloakColor(), contentText: settings.cloakIcon().repeat(end - start) } } + renderOptions: { after: { color: settings.cloakColor(editor.document.uri), contentText: settings.cloakIcon(editor.document.uri).repeat(end - start) } } }))) } module.exports = { decorate, ranges, supported, dispose: mask } diff --git a/media/editor/main.js b/media/editor/main.js index c13f15e..368c8d8 100644 --- a/media/editor/main.js +++ b/media/editor/main.js @@ -23,6 +23,10 @@ self.MonacoEnvironment = { getWorker: () => new Worker(workerUrl) } let editor let encryptedHover let filename +let cloakColor = '#000000' +let cloakIcon = '█' +let maskAppearance +let autocloaking = true let masked = true let applying = false let previous = '' @@ -76,8 +80,31 @@ function theme () { }) monaco.editor.setTheme('dotenv') } +// Paint a repeating glyph tile behind transparent text so the original model, +// cursor positions and copy/paste remain unchanged. Canvas treats settings as +// text/color values, never markup or CSS source. +function applyMaskAppearance () { + const font = editor.getOption(monaco.editor.EditorOption.fontInfo) + const key = JSON.stringify([cloakColor, cloakIcon, font.fontFamily, font.fontSize, font.fontWeight, font.spaceWidth, font.lineHeight, window.devicePixelRatio]) + if (key === maskAppearance) return + maskAppearance = key + const scale = window.devicePixelRatio || 1 + const canvas = document.createElement('canvas') + canvas.width = Math.max(1, Math.ceil(font.spaceWidth * scale)) + canvas.height = Math.max(1, Math.ceil(font.lineHeight * scale)) + const context = canvas.getContext('2d') + context.scale(scale, scale) + context.fillStyle = '#000000' + context.fillStyle = cloakColor + context.font = `${font.fontWeight} ${font.fontSize}px ${font.fontFamily}` + context.textBaseline = 'middle' + context.fillText(cloakIcon, 0, font.lineHeight / 2, font.spaceWidth) + container.style.setProperty('--dotenv-mask-image', `url("${canvas.toDataURL()}")`) + container.style.setProperty('--dotenv-mask-size', `${font.spaceWidth}px ${font.lineHeight}px`) +} function applyMask () { if (!editor) return + applyMaskAppearance() const model = editor.getModel() decorations.set(masked ? entries(model.getValue()).filter(entry => entry.maskEnd > entry.maskStart).map(entry => { @@ -92,7 +119,7 @@ function applyMask () { function conceal () { encryptedHover?.hide() container.classList.add('preparing') - masked = true + masked = autocloaking applyMask() if (editor && !document.hidden) { editor.render(true) @@ -109,7 +136,17 @@ function toggleMask () { if (!document.hidden) container.classList.remove('preparing') editor.focus() } +function configure (message) { + cloakColor = typeof message.cloakColor === 'string' ? message.cloakColor : cloakColor + cloakIcon = typeof message.cloakIcon === 'string' ? message.cloakIcon : cloakIcon + if (editor) applyMaskAppearance() + const enabled = message.autocloaking !== false + if (enabled === autocloaking) return + autocloaking = enabled + conceal() +} function updateDocument (message) { + configure(message) filename = message.filename encryptedHover?.hide() if (pending.size) { @@ -141,6 +178,7 @@ function updateDocument (message) { ariaLabel: 'Dotenv source editor. Values are visually masked until revealed.' }) decorations = editor.createDecorationsCollection() + editor.onDidChangeConfiguration(() => applyMaskAppearance()) encryptedHover = decryptHover(editor, request, () => ({ filename, version })) editor.addAction({ id: 'dotenv.save', label: 'Save', keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], run: () => request('save') }) for (const [id, keys] of [['undo', [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyZ]], ['redo', [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyZ, monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyY]]]) { @@ -180,6 +218,7 @@ window.addEventListener('message', event => { if (!message) return if (message.type === 'toggle') { toggleMask(); return } if (message.client !== client) return + if (message.type === 'configuration') configure(message) if (message.type === 'document') updateDocument(message) if (message.type === 'decrypted') encryptedHover?.receive(message) if (message.type === 'ack') { pending.delete(message.id); version = message.version } diff --git a/media/editor/style.css b/media/editor/style.css index 8ba3c74..a3a4d6b 100644 --- a/media/editor/style.css +++ b/media/editor/style.css @@ -6,7 +6,7 @@ button:focus-visible { outline: 1px solid var(--vscode-focusBorder); } #editor { height: calc(100% - 26px); } /* The editor never becomes visible until its model has masking decorations. */ #editor.preparing { visibility: hidden !important; } -.monaco-editor .view-line .dotenv-mask { color: transparent !important; background: var(--vscode-editor-foreground) !important; forced-color-adjust: none; } +.monaco-editor .view-line .dotenv-mask { color: transparent !important; background-color: transparent !important; background-image: var(--dotenv-mask-image) !important; background-size: var(--dotenv-mask-size); background-repeat: repeat; forced-color-adjust: none; } .monaco-editor .view-line .dotenv-mask::selection { color: transparent !important; } .dotenv-decrypt-hover { padding: 10px 12px; max-width: 420px; border: 1px solid var(--vscode-editorHoverWidget-border); border-radius: 5px; background: var(--vscode-editorHoverWidget-background); color: var(--vscode-editorHoverWidget-foreground); box-shadow: 0 2px 8px var(--vscode-widget-shadow); font: 13px var(--vscode-font-family); } .dotenv-decrypt-value { margin: 12px 0; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto; user-select: text; } diff --git a/test/renderer/configuration.js b/test/renderer/configuration.js new file mode 100644 index 0000000..dd0d1f5 --- /dev/null +++ b/test/renderer/configuration.js @@ -0,0 +1,43 @@ +/* global getComputedStyle, requestAnimationFrame, Image */ +import { bridge } from './setup.js' +import { sourceEditor } from '../../media/editor/main.js' + +window.addEventListener('message', async event => { + if (event.data?.type !== 'checkCloaking') return + const { masked, id } = event.data + try { + for (let i = 0; i < 100; i++) { + if (sourceEditor && document.getElementById('toggle').getAttribute('aria-label') === (masked ? 'Reveal dotenv values' : 'Hide dotenv values')) break + await new Promise(resolve => setTimeout(resolve, 25)) + } + for (let i = 0; i < 3; i++) await new Promise(resolve => requestAnimationFrame(resolve)) + const spans = [...document.querySelectorAll('.view-line span')].filter(span => !span.children.length && span.textContent.includes('SECRET_CONFIGURATION')) + if (!spans.length) throw new Error('Secret text was not rendered') + for (const span of spans) { + const style = getComputedStyle(span) + const hidden = style.color === 'rgba(0, 0, 0, 0)' || style.color === 'transparent' || style.opacity === '0' + if (hidden !== masked) throw new Error(`Expected masked=${masked}, got color=${style.color}`) + } + let tile + let colors + if (masked) { + tile = getComputedStyle(spans[0]).backgroundImage + if (!tile.startsWith('url("data:image/png')) throw new Error('Missing cloak glyph tile') + const image = new Image() + image.src = tile.slice(5, -2) + await image.decode() + const canvas = document.createElement('canvas') + canvas.width = image.width + canvas.height = image.height + const context = canvas.getContext('2d') + context.drawImage(image, 0, 0) + const pixels = context.getImageData(0, 0, image.width, image.height).data + colors = [] + for (let i = 0; i < pixels.length; i += 4) { + if (pixels[i + 3] > 200) colors.push(Array.from(pixels.slice(i, i + 3)).join(',')) + } + } + bridge.postMessage({ type: 'cloakingChecked', id, tile, colors }) + } catch (error) { bridge.postMessage({ type: 'cloakingChecked', id, error: error.message }) } +}) +bridge.postMessage({ type: 'configurationReady' }) diff --git a/test/suite/extension.test.js b/test/suite/extension.test.js index 732bd52..acf3170 100644 --- a/test/suite/extension.test.js +++ b/test/suite/extension.test.js @@ -65,27 +65,28 @@ QUOTED="secret \${PLAIN}" SINGLE='secret' ` }) + await vscode.window.showTextDocument(document) const original = document.getText() const config = vscode.workspace.getConfiguration() const keys = ['editor.tokenColorCustomizations', 'dotenv.enableAutocloaking'] const before = keys.map(key => config.inspect(key).globalValue) assert.deepStrictEqual(before, [undefined, undefined]) - const initial = settings.autocloakingEnabled() + const initial = settings.autocloakingEnabled(document.uri) let applied const editor = { document, setDecorations: (type, ranges) => { applied = ranges } } try { await vscode.commands.executeCommand('dotenv.toggleAutocloaking') - assert.strictEqual(settings.autocloakingEnabled(), !initial) + assert.strictEqual(settings.autocloakingEnabled(document.uri), !initial) decorations.decorate({}, editor) assert.strictEqual(applied.length, initial ? 0 : 3) await vscode.commands.executeCommand('dotenv.toggleAutocloaking') - assert.strictEqual(settings.autocloakingEnabled(), initial) + assert.strictEqual(settings.autocloakingEnabled(document.uri), initial) decorations.decorate({}, editor) assert.strictEqual(applied.length, initial ? 3 : 0) assert.deepStrictEqual(keys.map(key => config.inspect(key).globalValue), before) assert.strictEqual(document.getText(), original) } finally { - if (settings.autocloakingEnabled() !== initial) { + if (settings.autocloakingEnabled(document.uri) !== initial) { await vscode.commands.executeCommand('dotenv.toggleAutocloaking') } } diff --git a/test/suite/lib/autocloaking.test.js b/test/suite/lib/autocloaking.test.js index aff42cc..24fc1d2 100644 --- a/test/suite/lib/autocloaking.test.js +++ b/test/suite/lib/autocloaking.test.js @@ -133,6 +133,42 @@ describe('autocloaking lifecycle', () => { }) describe('cloaking settings isolation', () => { + it('reads each folder’s settings and keeps toggles within that folder', async () => { + const configs = { + a: { 'dotenv.enableAutocloaking': true, 'dotenv.enableSecretpeeking': false, 'dotenv.cloakColor': '#ff0000', 'dotenv.cloakIcon': '*' }, + b: { 'dotenv.enableAutocloaking': true, 'dotenv.enableSecretpeeking': true, 'dotenv.cloakColor': '#00ff00', 'dotenv.cloakIcon': '?' } + } + const uri = (folder, file) => ({ folder, toString: () => `file:///${folder}/${file}` }) + const a = uri('a', '.env') + const b = uri('b', '.env') + const module = { exports: {} } + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../../../lib/settings.js'), 'utf8'), { + module, + require: () => ({ + workspace: { + getConfiguration: (_, resource) => ({ get: key => configs[resource.folder][key] }), + getWorkspaceFolder: resource => ({ uri: { toString: () => resource.folder } }) + } + }) + }) + const settings = module.exports + settings.initialize({ globalState: { get: () => undefined, update: async () => {} } }) + for (const resource of [a, b]) { + assert.strictEqual(settings.autocloakingEnabled(resource), true) + assert.strictEqual(settings.secretpeekingEnabled(resource), configs[resource.folder]['dotenv.enableSecretpeeking']) + assert.strictEqual(settings.cloakColor(resource), configs[resource.folder]['dotenv.cloakColor']) + assert.strictEqual(settings.cloakIcon(resource), configs[resource.folder]['dotenv.cloakIcon']) + } + await settings.autocloakingOff(a) + assert.strictEqual(settings.autocloakingEnabled(uri('a', '.env.local')), false) + assert.strictEqual(settings.autocloakingEnabled(b), true) + await settings.autocloakingOff(b) + assert.strictEqual(settings.autocloakingEnabled(a), false) + await settings.autocloakingOn(b) + assert.strictEqual(settings.autocloakingEnabled(a), false) + configs.b['dotenv.enableAutocloaking'] = false + assert.strictEqual(settings.autocloakingEnabled(b), false) + }) function loadSettings (globalValue, workspaceValue) { const writes = [] const stored = new Map() diff --git a/test/suite/lib/secure-editor.test.js b/test/suite/lib/secure-editor.test.js index 60a0ac5..f51ed58 100644 --- a/test/suite/lib/secure-editor.test.js +++ b/test/suite/lib/secure-editor.test.js @@ -3,7 +3,8 @@ const assert = require('assert') const fs = require('fs') const path = require('path') const vm = require('vm') -function fixture (initial = 'KEY=SECRET\r\n') { +function fixture (initial = 'KEY=SECRET\r\n', enabled = true) { + const configurationChanges = [] let text = initial const changes = [] const undo = [] @@ -18,8 +19,8 @@ function fixture (initial = 'KEY=SECRET\r\n') { Range: class { constructor (start, end) { this.start = start; this.end = end } }, WorkspaceEdit: class { replace (uri, range, value) { this.range = range; this.value = value } }, workspace: { - getConfiguration: () => ({ get: () => undefined }), - onDidChangeConfiguration: () => ({ dispose () {} }), + getConfiguration: (section, uri) => ({ get: key => section === 'dotenv' && uri === document.uri && key === 'enableAutocloaking' ? enabled : undefined }), + onDidChangeConfiguration: fn => subscribe(configurationChanges, fn), onDidChangeTextDocument: fn => subscribe(changes, fn), applyEdit: async edit => { undo.push(text); redo.length = 0; changed(text.slice(0, edit.range.start.offset) + edit.value + text.slice(edit.range.end.offset)); return true } }, @@ -38,9 +39,20 @@ function fixture (initial = 'KEY=SECRET\r\n') { onDidDispose: () => ({ dispose () {} }) } module.exports.resolveCustomTextEditor(document, panel, { extensionUri: '/extension' }) - return { document, messages, changed, saves: () => saves, send: (type, extra = {}) => receive({ client: 'test', type, ...extra }) } + return { configure: value => { enabled = value; configurationChanges.forEach(fn => fn({ affectsConfiguration: (key, uri) => key === 'dotenv.enableAutocloaking' && uri === document.uri })) }, document, messages, changed, saves: () => saves, send: (type, extra = {}) => receive({ client: 'test', type, ...extra }) } } describe('Monaco document synchronization', () => { + it('sends resource-scoped cloaking settings on load and changes without resending text', async () => { + const f = fixture(undefined, false) + await f.send('ready') + assert.strictEqual(f.messages.at(-1).autocloaking, false) + for (const enabled of [true, false]) { + f.configure(enabled) + assert.strictEqual(f.messages.at(-1).type, 'configuration') + assert.strictEqual(f.messages.at(-1).autocloaking, enabled) + assert.strictEqual(f.messages.at(-1).text, undefined) + } + }) it('decrypts only the requested current encrypted entry without changing the document', async () => { const f = fixture('KEY="encrypted:test"\r\n') await f.send('ready') diff --git a/test/suite/lib/secure-renderer.test.js b/test/suite/lib/secure-renderer.test.js index 3fadc2a..8f782fc 100644 --- a/test/suite/lib/secure-renderer.test.js +++ b/test/suite/lib/secure-renderer.test.js @@ -6,6 +6,72 @@ const esbuild = require('esbuild') const provider = require('../../../lib/secure-editor') const decryption = require('../../../lib/decrypt-value') describe('Monaco renderer integration', () => { + it('honors disabled cloaking on load, settings changes, edits, and tab switches', async function () { + this.timeout(30000) + const root = path.resolve(__dirname, '../../..') + esbuild.buildSync({ entryPoints: [path.join(root, 'test/renderer/configuration.js')], bundle: true, outfile: path.join(root, 'media/editor/dist/test-renderer.js'), format: 'iife', platform: 'browser', loader: { '.ttf': 'file' }, logLevel: 'silent' }) + const uri = vscode.Uri.joinPath(vscode.workspace.workspaceFolders[0].uri, '.env.configuration') + const config = vscode.workspace.getConfiguration('dotenv', uri) + const originals = Object.fromEntries(['enableAutocloaking', 'cloakColor', 'cloakIcon'].map(key => [key, config.inspect(key).workspaceValue])) + let panel + let listener + try { + await config.update('enableAutocloaking', false, vscode.ConfigurationTarget.Workspace) + await config.update('cloakColor', '#ff0000', vscode.ConfigurationTarget.Workspace) + await config.update('cloakIcon', '*', vscode.ConfigurationTarget.Workspace) + await vscode.workspace.fs.writeFile(uri, Buffer.from('KEY=SECRET_CONFIGURATION\n')) + const document = await vscode.workspace.openTextDocument(uri) + panel = vscode.window.createWebviewPanel('dotenv.configurationTest', 'Dotenv configuration test', vscode.ViewColumn.One, { enableScripts: true, retainContextWhenHidden: true }) + let ready + const loaded = new Promise(resolve => { ready = resolve }) + const checks = new Map() + listener = panel.webview.onDidReceiveMessage(message => { + if (message.type === 'configurationReady') ready() + if (message.type === 'cloakingChecked') { + const { resolve, reject } = checks.get(message.id) + checks.delete(message.id) + if (message.error) reject(new Error(message.error)) + else resolve(message) + } + }) + let sequence = 0 + const check = masked => new Promise((resolve, reject) => { + const id = ++sequence + checks.set(id, { resolve, reject }) + panel.webview.postMessage({ type: 'checkCloaking', id, masked }) + }) + provider.resolveCustomTextEditor(document, panel, { extensionUri: vscode.Uri.file(root) }) + panel.webview.html = panel.webview.html.replace(/dist\/main.js/g, 'dist/test-renderer.js') + await loaded + await check(false) + const edit = new vscode.WorkspaceEdit() + edit.insert(uri, new vscode.Position(1, 0), 'NEXT=SECRET_CONFIGURATION_EDITED\n') + await vscode.workspace.applyEdit(edit) + await check(false) + const notes = await vscode.workspace.openTextDocument({ content: 'Other tab' }) + await vscode.window.showTextDocument(notes, vscode.ViewColumn.One) + await new Promise(resolve => setTimeout(resolve, 100)) + panel.reveal(vscode.ViewColumn.One) + await check(false) + await config.update('enableAutocloaking', true, vscode.ConfigurationTarget.Workspace) + const star = await check(true) + assert(star.colors.includes('255,0,0'), 'Initial cloak color must be used') + await config.update('cloakIcon', '?', vscode.ConfigurationTarget.Workspace) + const question = await check(true) + assert.notStrictEqual(question.tile, star.tile, 'Changing the icon must change the rendered mask') + await config.update('cloakColor', '#00ff00', vscode.ConfigurationTarget.Workspace) + const green = await check(true) + assert(green.colors.includes('0,255,0'), 'Color changes must apply immediately') + assert(!green.colors.includes('255,0,0')) + await config.update('enableAutocloaking', false, vscode.ConfigurationTarget.Workspace) + await check(false) + await document.save() + } finally { + listener?.dispose() + panel?.dispose() + for (const [key, value] of Object.entries(originals)) await config.update(key, value, vscode.ConfigurationTarget.Workspace) + } + }) it('masks every observed animation frame on load, typing and tab switches', async function () { this.timeout(30000) const root = path.resolve(__dirname, '../../..')