Skip to content
Merged
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
14 changes: 14 additions & 0 deletions src/__tests__/env-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,20 @@ scope: 'user',
// ─── envAdd ──────────────────────────────────────────────

describe('envAdd', () => {
it('refuses a key that would not survive the round trip into env.sh', async () => {
// `generateEnvFile` drops any key that is not a shell identifier, so
// accepting one here would write a variable that never reaches the
// member's shell — and `FOO;cmd` would run `cmd` there if it did. Better
// to reject it at the point the user can still see the mistake.
await envAdd('bad key', 'v', {});

expect(log.error).toHaveBeenCalledWith(expect.stringContaining('bad key'));
// Nothing written, and no env.yaml is created just to hold nothing.
const envYamlPath = path.join(repoPath, 'env', 'env.yaml');
expect(await fse.pathExists(envYamlPath)).toBe(false);
expect(log.success).not.toHaveBeenCalled();
});

it('should add a new variable locally and show push hint', async () => {
await envAdd('NEW_VAR', 'new_value', {});

Expand Down
29 changes: 29 additions & 0 deletions src/__tests__/env-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,35 @@ scope: 'user',
const content = handler.generateEnvFile([]);
expect(content).toBe('\n');
});

it('should drop keys that are not valid shell identifiers', () => {
// A key is interpolated raw into `export <key>=...`, so anything that is
// not an identifier either breaks the line or runs as shell code. The
// whole variable is dropped, not the line rewritten: `parseEnvFile` skips
// such a line anyway, so emitting it would put a variable in env.sh that
// the CLI can never read back.
const content = handler.generateEnvFile([
{ key: 'GOOD_KEY', value: 'ok' },
{ key: 'bad key', value: 'oops' },
{ key: 'FOO;touch /tmp/pwned', value: 'y' },
{ key: '$(whoami)', value: 'w' },
{ key: 'A=B', value: 'z' },
{ key: '9LEADING', value: 'n' },
]);

expect(content).toBe("export GOOD_KEY='ok'\n");
});

it('should keep keys that are valid shell identifiers', () => {
// The guard must not narrow what a legitimate team repo can express:
// digits and underscores after the first character are all valid.
const content = handler.generateEnvFile([
{ key: '_PRIVATE', value: 'a' },
{ key: 'A1_b2', value: 'b' },
]);

expect(content).toBe("export _PRIVATE='a'\nexport A1_b2='b'\n");
});
});

// ─── pullItem ────────────────────────────────────────────
Expand Down
14 changes: 13 additions & 1 deletion src/env-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { requireInit, detectProjectConfig } from './config.js';
import { pullRepo } from './utils/git.js';
import { ensureDir, readFileSafe, writeFile, pathExists } from './utils/fs.js';
import { log, spinner } from './utils/logger.js';
import { EnvHandler, maskEnvValue } from './resources/env.js';
import { EnvHandler, maskEnvValue, ENV_KEY_RE } from './resources/env.js';
import type { GlobalOptions } from './types.js';
import { isSelfMode } from './types.js';

Expand Down Expand Up @@ -59,6 +59,18 @@ export async function envAdd(
value: string,
options: GlobalOptions & { description?: string },
): Promise<void> {
// env.sh is generated as `export <key>=...` and sourced by every member, so a
// key that is not a shell identifier either breaks that line or runs as code.
// `generateEnvFile` drops such keys, which would make this command report
// success for a variable that never reaches anyone's shell — reject it here,
// where the user still sees what they typed.
if (!ENV_KEY_RE.test(key)) {
log.error(
`Invalid env variable name "${key}": use letters, digits and underscores, starting with a letter or underscore.`,
);
return;
}

const projectConfig = await detectProjectConfig();
const localConfig = projectConfig ?? (await requireInit()).localConfig;
const repoPath = localConfig.repo.localPath;
Expand Down
24 changes: 21 additions & 3 deletions src/resources/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ export function maskEnvValue(value: string): string {
return `${value.slice(0, 2)}****`;
}

/**
* A key this module will write into env.sh, and the only shape it reads back.
*
* Shared by `parseEnvFile` and `generateEnvFile` on purpose: the write side has
* to reject exactly what the read side skips, or a variable can exist in env.sh
* that the CLI can never see again.
*/
export const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;

/**
* Read back the assignments `generateEnvFile` writes, as key → value.
*
Expand All @@ -118,14 +127,13 @@ export function maskEnvValue(value: string): string {
*/
export function parseEnvFile(content: string): Map<string, string> {
const PREFIX = 'export ';
const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
const assignments = new Map<string, string>();

let i = 0;
while (i < content.length) {
const eq = content.startsWith(PREFIX, i) ? content.indexOf('=', i + PREFIX.length) : -1;
const key = eq === -1 ? '' : content.slice(i + PREFIX.length, eq);
if (eq === -1 || !KEY.test(key) || content[eq + 1] !== "'") {
if (eq === -1 || !ENV_KEY_RE.test(key) || content[eq + 1] !== "'") {
const nl = content.indexOf('\n', i);
if (nl === -1) break;
i = nl + 1;
Expand Down Expand Up @@ -437,9 +445,19 @@ export class EnvHandler extends ResourceHandler {
* the sourced script. An embedded single quote is encoded with the standard
* `'\''` sequence. env.sh is sourced from every team member's shell profile,
* so values (which originate from the team repo's env/env.yaml) must be safe.
*
* Keys are interpolated raw into the export statement, so they are held to
* the same identifier rule `parseEnvFile` applies when reading env.sh back. A
* key that fails it is dropped rather than emitted: `export bad key='x'` is
* not valid shell, and `export FOO;cmd='x'` would run `cmd` in every member's
* shell. Dropping keeps the write and read sides in agreement — a line
* `parseEnvFile` must skip anyway is better left unwritten. One member's bad
* key must not take the whole file down with it, so the rest still ship.
*/
generateEnvFile(variables: EnvVariable[]): string {
const lines = variables.map(v => `export ${v.key}=${shellQuoteValue(v.value)}`);
const lines = variables
.filter(v => ENV_KEY_RE.test(v.key))
.map(v => `export ${v.key}=${shellQuoteValue(v.value)}`);
return lines.join('\n') + '\n';
}

Expand Down
Loading