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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to the "vscode-sops" extension will be documented in this fi
Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.

## [Unreleased]
### Added
- Support for VS Code variables (`${workspaceFolder}`, `${workspaceFolder:Name}`, `${workspaceFolderBasename}`, `${fileWorkspaceFolder}`, `${userHome}`, `${env:VAR}`, `${pathSeparator}`) and a leading `~` in the path settings `sops.binPath`, `sops.configPath`, `sops.defaults.gcpCredentialsPath` and `sops.defaults.ageKeyFile`. This fixes e.g. pointing `sops.binPath` at a [mise](https://mise.jdx.dev/)-managed shim via `${workspaceFolder}`.

### Fixed
- Default a UTF-8 `LANG` locale for the `sops` subprocess when `LANG`/`LC_ALL`/`LC_CTYPE` are all unset. age plugins such as `age-plugin-yubikey` exit without a locale (surfacing as `failed to read line: EOF`).

## [0.9.4]
### Fixed
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ This extension works with binaries `sops / age / ...` installed via [aquaproj/aq
* `sops.beta`: enable/disable beta release without reloading VSCode or enabling/disabling extensions (default: false)
* `sops.binPath`: Path to SOPS binary (default: executables from `$PATH`)
* `sops.configPath`: Path (absolute or relative) to the configuration for this extension (empty: defaults to `.sopsrc` in root of project) See [Config file](#config-file) section.

> The path settings (`sops.binPath`, `sops.configPath`, `sops.defaults.gcpCredentialsPath`, `sops.defaults.ageKeyFile`) support VS Code variables: `${workspaceFolder}`, `${workspaceFolder:Name}` (multi-root), `${workspaceFolderBasename}`, `${fileWorkspaceFolder}`, `${userHome}`, `${env:VAR}`, `${pathSeparator}` and a leading `~`. For example, to use a [mise](https://mise.jdx.dev/)-managed `sops` shim: `"sops.binPath": "${workspaceFolder}/.vscode/mise-tools/sops"`.

* `sops.defaults.awsProfile`: Default AWS profile name which will be used for sops command `--aws-profile` (empty: defaults to environment variable `$AWS_PROFILE`)
* `sops.defaults.gcpCredentialsPath`: Default path used to find GCP credentials. Overrides the `$GOOGLE_APPLICATION_CREDENTIALS` environment variable (empty: defaults to environment variable `$GOOGLE_APPLICATION_CREDENTIALS`)
* `sops.defaults.ageKeyFile`: Default path used to find AGE key file. Overwrites the `$SOPS_AGE_KEY_FILE` environment variable (default: uses from environment variable `$SOPS_AGE_KEY_FILE`)
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,13 @@
"type": "string",
"scope": "resource",
"default": "sops",
"description": "Path to SOPS binary (default: executables from `$PATH`)"
"description": "Path to SOPS binary (default: executables from `$PATH`). Supports VS Code variables like `${workspaceFolder}` and a leading `~`."
},
"sops.configPath": {
"type": "string",
"scope": "resource",
"default": "./.sopsrc",
"description": "Absolute path (Starts with /) or Relative path to project (Starts with ./) where the configuration for this extension is looking for (default: Looking for file `.sopsrc` in root of project) See [Config file](#config-file) section."
"description": "Absolute path (Starts with /) or Relative path to project (Starts with ./) where the configuration for this extension is looking for (default: Looking for file `.sopsrc` in root of project) See [Config file](#config-file) section. Supports VS Code variables like `${workspaceFolder}` and a leading `~`."
},
"sops.defaults.awsProfile": {
"type": "string",
Expand All @@ -75,12 +75,12 @@
"sops.defaults.gcpCredentialsPath": {
"type": "string",
"scope": "resource",
"description": "Default path used to find GCP credentials. Overwrites the `$GOOGLE_APPLICATION_CREDENTIALS` environment variable (default: uses from environment variable `$GOOGLE_APPLICATION_CREDENTIALS`)"
"description": "Default path used to find GCP credentials. Overwrites the `$GOOGLE_APPLICATION_CREDENTIALS` environment variable (default: uses from environment variable `$GOOGLE_APPLICATION_CREDENTIALS`). Supports VS Code variables like `${workspaceFolder}` and a leading `~`."
},
"sops.defaults.ageKeyFile": {
"type": "string",
"scope": "resource",
"description": "Default path used to find AGE key file. Overwrites the `$SOPS_AGE_KEY_FILE` environment variable (default: uses from environment variable `$SOPS_AGE_KEY_FILE`)"
"description": "Default path used to find AGE key file. Overwrites the `$SOPS_AGE_KEY_FILE` environment variable (default: uses from environment variable `$SOPS_AGE_KEY_FILE`). Supports VS Code variables like `${workspaceFolder}` and a leading `~`."
},
"sops.defaults.ignoreMac": {
"type": "boolean",
Expand Down
88 changes: 79 additions & 9 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ const DEFAULT_RUN_CONTROL_FILENAME = '.sopsrc';
const GCP_CREDENTIALS_ENV_VAR_NAME = 'GOOGLE_APPLICATION_CREDENTIALS';
const AGE_KEY_FILE_ENV_VAR_NAME = 'SOPS_AGE_KEY_FILE';
const AWS_PROFILE_ENV_VAR_NAME = 'AWS_PROFILE';
// age plugins (e.g. age-plugin-yubikey) exit on startup without a UTF-8 locale, which
// surfaces as "failed to read line: EOF" during decryption. Editors launched from the GUI
// (Finder/Dock) often inherit no locale, so we default one when none is configured.
const DEFAULT_LOCALE = 'en_US.UTF-8';

// Returns env overrides ensuring the sops/age subprocess has a UTF-8 locale (see DEFAULT_LOCALE).
// Only applied when no locale is configured, so an explicit LANG/LC_ALL/LC_CTYPE is respected.
export function localeEnvFallback(env: NodeJS.ProcessEnv): { LANG?: string } {
if (!env.LANG && !env.LC_ALL && !env.LC_CTYPE) {
return { LANG: DEFAULT_LOCALE };
}
return {};
}

enum Command {
INFO_COMMAND = 'sops.info',
Expand All @@ -64,9 +77,57 @@ enum Command {
const SOPS_CONFIG_FILENAME = '.sops.yaml';

const DECRYPTED_PREFIX = '.decrypted~';
const getSopsBinPath = () => {
const sopsPath: string | undefined = vscode.workspace.getConfiguration(CONFIG_BASE_SECTION).get(ConfigName.binPath);
return sopsPath ?? 'sops';

// Pure, testable core: substitutes VS Code style variables in a path-like config value.
// Supported: ${workspaceFolder}, ${workspaceFolder:Name}, ${workspaceFolderBasename},
// ${fileWorkspaceFolder}, ${userHome}, ${env:VAR}, ${pathSeparator} / ${/}, and a leading
// ~ (home directory).
export function applyVariableSubstitution(
value: string,
vars: {
workspaceFolder?: string; // resolved for the resource (or first folder)
namedWorkspaceFolders?: { [name: string]: string };
userHome: string;
env: NodeJS.ProcessEnv;
pathSep: string;
},
): string {
let result = value
.replace(/\$\{workspaceFolder:([^}]+)\}/g, (m, name) => vars.namedWorkspaceFolders?.[name] ?? m)
.replace(/\$\{workspaceFolderBasename\}/g, (m) => vars.workspaceFolder === undefined ? m : path.basename(vars.workspaceFolder))
// ${fileWorkspaceFolder} is the workspace folder of the processed file; since we already
// resolve ${workspaceFolder} against that resource, it is an alias here.
.replace(/\$\{(?:workspaceFolder|fileWorkspaceFolder)\}/g, (m) => vars.workspaceFolder ?? m)
.replace(/\$\{userHome\}/g, vars.userHome)
.replace(/\$\{env:([^}]+)\}/g, (_m, name) => vars.env[name] ?? '')
.replace(/\$\{(?:pathSeparator|\/)\}/g, vars.pathSep);
// leading ~ expansion (relevant for mise/asdf shim paths under ~/.local/...)
if (result === '~' || result.startsWith('~/') || result.startsWith('~\\')) {
result = path.join(vars.userHome, result.slice(1));
}
return result;
}

// vscode-aware wrapper around applyVariableSubstitution.
function substitutePathVariables(value: string, resourceUri?: vscode.Uri): string {
const folders = vscode.workspace.workspaceFolders ?? [];
const namedWorkspaceFolders: { [name: string]: string } = {};
for (const folder of folders) {
namedWorkspaceFolders[folder.name] = folder.uri.fsPath;
}
const containing = resourceUri ? vscode.workspace.getWorkspaceFolder(resourceUri)?.uri.fsPath : undefined;
return applyVariableSubstitution(value, {
workspaceFolder: containing ?? folders[0]?.uri.fsPath,
namedWorkspaceFolders,
userHome: os.homedir(),
env: process.env,
pathSep: path.sep,
});
}

const getSopsBinPath = (resourceUri?: vscode.Uri) => {
const sopsPath: string | undefined = vscode.workspace.getConfiguration(CONFIG_BASE_SECTION, resourceUri).get(ConfigName.binPath);
return substitutePathVariables(sopsPath ?? 'sops', resourceUri);
};

// TODO wait til vscode provide proper way to get current extension name
Expand Down Expand Up @@ -331,7 +392,7 @@ async function getDecryptedFileContent(uri: vscode.Uri, fileFormat: IFileFormat)
debug('Decrypting', uri.path, encryptedContent);
const { sopsGeneralArgs, sopsGeneralEnvVars } = await getSopsGeneralOptions(uri);
const decryptProcess = child_process.spawnSync(
getSopsBinPath(),
getSopsBinPath(uri),
[
...sopsGeneralArgs,
'--output-type',
Expand Down Expand Up @@ -384,7 +445,7 @@ async function getEncryptedFileContent(uri: vscode.Uri, originalEncryptedUri: vs
await fs.writeFile(tmpEncryptedFilePath, originalEncryptedContent, { mode: 0o600 });
debug('Encrypting', uri.path, decryptedContent);
const { sopsGeneralArgs, sopsGeneralEnvVars } = await getSopsGeneralOptions(uri);
const sopsBin = getSopsBinPath();
const sopsBin = getSopsBinPath(uri);
const cmds = [
...sopsGeneralArgs,
'--output-type',
Expand Down Expand Up @@ -457,7 +518,7 @@ async function getNewEncryptedFileContent(decryptedUri: vscode.Uri, fileFormat:
debug('Encrypting', decryptedUri.path, decryptedContent, tmpDecryptedFilePath);
const { sopsGeneralArgs, sopsGeneralEnvVars } = await getSopsGeneralOptions(decryptedUri);
const encryptProcess = child_process.spawnSync(
getSopsBinPath(),
getSopsBinPath(decryptedUri),
[
...sopsGeneralArgs,
...sopsConfigArgs,
Expand Down Expand Up @@ -529,6 +590,11 @@ async function getSopsGeneralOptions(fileUriToEncryptOrDecrypt: vscode.Uri) {
const sopsGeneralArgs = [];
const sopsGeneralEnvVars: any = {};

// Ensure the sops/age subprocess has a UTF-8 locale. age plugins such as age-plugin-yubikey
// fail with "failed to read line: EOF" when LANG/LC_ALL/LC_CTYPE are all unset, which happens
// when the editor is launched from the GUI rather than a shell.
Object.assign(sopsGeneralEnvVars, localeEnvFallback(process.env));

if (awsProfile) {
sopsGeneralArgs.push('--aws-profile', awsProfile);
sopsGeneralEnvVars[AWS_PROFILE_ENV_VAR_NAME] = awsProfile; // --aws-profile argument doesn't work well
Expand All @@ -543,6 +609,7 @@ async function getSopsGeneralOptions(fileUriToEncryptOrDecrypt: vscode.Uri) {
}

if (gcpCredentialsPath) {
gcpCredentialsPath = substitutePathVariables(gcpCredentialsPath, fileUriToEncryptOrDecrypt);
if (!path.isAbsolute(gcpCredentialsPath) && vscode.workspace.workspaceFolders) {
for (const workspaceFolder of vscode.workspace.workspaceFolders) {
const gcpCredentialsAbsPath = path.join(workspaceFolder.uri.path, gcpCredentialsPath);
Expand All @@ -557,6 +624,7 @@ async function getSopsGeneralOptions(fileUriToEncryptOrDecrypt: vscode.Uri) {
}

if (ageKeyFile) {
ageKeyFile = substitutePathVariables(ageKeyFile, fileUriToEncryptOrDecrypt);
if (!path.isAbsolute(ageKeyFile) && vscode.workspace.workspaceFolders) {
for (const workspaceFolder of vscode.workspace.workspaceFolders) {
const ageKeyFileAbsPath = path.join(workspaceFolder.uri.path, ageKeyFile);
Expand Down Expand Up @@ -601,11 +669,13 @@ async function getRunControl(): Promise<IRunControl> {
let rcPath = vscode.workspace.getConfiguration(CONFIG_BASE_SECTION).get(ConfigName.configPath);
if (vscode.workspace.workspaceFolders) {
if (typeof rcPath === 'string') {
const resolvedRcPath = substitutePathVariables(rcPath);
const isAbsolute = path.isAbsolute(resolvedRcPath);
for (const rootPath of vscode.workspace.workspaceFolders) {
if (rcPath.charAt(0) === '/') { // absolute path in rc file
possibleRCUris.push(rootPath.uri.with({ path: rcPath }));
if (isAbsolute) { // absolute path in rc file
possibleRCUris.push(rootPath.uri.with({ path: resolvedRcPath }));
} else {
possibleRCUris.push(rootPath.uri.with({ path: path.join(rootPath.uri.path, rcPath) }));
possibleRCUris.push(rootPath.uri.with({ path: path.join(rootPath.uri.path, resolvedRcPath) }));
}
}
}
Expand Down
25 changes: 25 additions & 0 deletions src/test/suite/locale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as assert from 'assert';

import { localeEnvFallback } from '../../extension';

suite('localeEnvFallback', () => {
test('defaults LANG to a UTF-8 locale when none is set', () => {
assert.deepStrictEqual(localeEnvFallback({}), { LANG: 'en_US.UTF-8' });
});

test('treats an empty LANG as unset', () => {
assert.deepStrictEqual(localeEnvFallback({ LANG: '' }), { LANG: 'en_US.UTF-8' });
});

test('respects an explicit LANG', () => {
assert.deepStrictEqual(localeEnvFallback({ LANG: 'de_DE.UTF-8' }), {});
});

test('respects an explicit LC_ALL', () => {
assert.deepStrictEqual(localeEnvFallback({ LC_ALL: 'C.UTF-8' }), {});
});

test('respects an explicit LC_CTYPE', () => {
assert.deepStrictEqual(localeEnvFallback({ LC_CTYPE: 'en_US.UTF-8' }), {});
});
});
81 changes: 81 additions & 0 deletions src/test/suite/variables.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import * as assert from 'assert';
import * as path from 'path';

import { applyVariableSubstitution } from '../../extension';

const baseVars = {
workspaceFolder: path.join(path.sep, 'ws'),
namedWorkspaceFolders: {
'frontend': path.join(path.sep, 'repos', 'frontend'),
'backend': path.join(path.sep, 'repos', 'backend'),
},
userHome: path.join(path.sep, 'home', 'tester'),
env: { MY_VAR: 'from-env' } as NodeJS.ProcessEnv,
pathSep: path.sep,
};

suite('applyVariableSubstitution', () => {
test('substitutes ${workspaceFolder} (the reported mise shim case)', () => {
const result = applyVariableSubstitution('${workspaceFolder}/.vscode/mise-tools/sops', baseVars);
assert.strictEqual(result, `${baseVars.workspaceFolder}/.vscode/mise-tools/sops`);
});

test('substitutes named ${workspaceFolder:Name} for multi-root', () => {
const result = applyVariableSubstitution('${workspaceFolder:backend}/bin/sops', baseVars);
assert.strictEqual(result, `${baseVars.namedWorkspaceFolders.backend}/bin/sops`);
});

test('leaves unknown named workspace folder untouched', () => {
const result = applyVariableSubstitution('${workspaceFolder:missing}/sops', baseVars);
assert.strictEqual(result, '${workspaceFolder:missing}/sops');
});

test('substitutes ${workspaceFolderBasename}', () => {
const result = applyVariableSubstitution('${workspaceFolderBasename}/sops', baseVars);
assert.strictEqual(result, `${path.basename(baseVars.workspaceFolder)}/sops`);
});

test('substitutes ${fileWorkspaceFolder} (alias of the resource workspace folder)', () => {
const result = applyVariableSubstitution('${fileWorkspaceFolder}/bin/sops', baseVars);
assert.strictEqual(result, `${baseVars.workspaceFolder}/bin/sops`);
});

test('leaves ${workspaceFolderBasename} intact when no workspace folder is available', () => {
const result = applyVariableSubstitution('${workspaceFolderBasename}/sops', { ...baseVars, workspaceFolder: undefined });
assert.strictEqual(result, '${workspaceFolderBasename}/sops');
});

test('substitutes ${userHome}', () => {
const result = applyVariableSubstitution('${userHome}/age.txt', baseVars);
assert.strictEqual(result, `${baseVars.userHome}/age.txt`);
});

test('substitutes ${env:VAR} and resolves missing vars to empty string', () => {
assert.strictEqual(applyVariableSubstitution('${env:MY_VAR}/x', baseVars), 'from-env/x');
assert.strictEqual(applyVariableSubstitution('${env:NOPE}/x', baseVars), '/x');
});

test('substitutes ${pathSeparator} and ${/}', () => {
assert.strictEqual(applyVariableSubstitution('a${pathSeparator}b${/}c', baseVars), `a${path.sep}b${path.sep}c`);
});

test('expands a leading ~ to the home directory', () => {
const result = applyVariableSubstitution('~/.local/share/mise/shims/sops', baseVars);
assert.strictEqual(result, path.join(baseVars.userHome, '.local/share/mise/shims/sops'));
});

test('does not expand ~ in the middle of a path', () => {
const result = applyVariableSubstitution('/opt/~backup/sops', baseVars);
assert.strictEqual(result, '/opt/~backup/sops');
});

test('leaves ${workspaceFolder} intact when no workspace folder is available', () => {
const result = applyVariableSubstitution('${workspaceFolder}/sops', { ...baseVars, workspaceFolder: undefined });
assert.strictEqual(result, '${workspaceFolder}/sops');
});

test('returns plain paths unchanged', () => {
assert.strictEqual(applyVariableSubstitution('sops', baseVars), 'sops');
assert.strictEqual(applyVariableSubstitution('/usr/local/bin/sops', baseVars), '/usr/local/bin/sops');
});
});