diff --git a/js/.changeset/issue-172-go-template-args.md b/js/.changeset/issue-172-go-template-args.md new file mode 100644 index 0000000..673063c --- /dev/null +++ b/js/.changeset/issue-172-go-template-args.md @@ -0,0 +1,13 @@ +--- +'command-stream': patch +--- + +Clarify and diagnose Go/Docker template arguments that contain an internal space (issue #172). + +A literal template token such as `--format {{json .Config.Env}}` contains an unquoted space, so `command-stream` splits it into separate argv words — exactly as a POSIX shell (`bash`) would. This made Docker report `template parsing error: ... unclosed action`, while a space-free `{{.Id}}` worked, which was surprising and hard to diagnose. + +This is shell-faithful behavior, not a bug: quoting (`'{{json .Config.Env}}'`), double-quoting, or interpolating the whole token as a single `${value}` all pass it through untouched. To make the failing case easy to spot: + +- **Diagnostics**: when a built command contains an unquoted `{{ … }}` token with an internal space, `command-stream` now prints a one-line warning to stderr pointing at the gotcha (fired once per unique token, silenced via `COMMAND_STREAM_NO_TEMPLATE_WARNING=1`). +- **Docs**: a new "Go templates & `{{ }}` arguments" section in the README documents the works/breaks/workaround patterns. +- A runnable `examples/go-template-arguments.mjs` demonstrates each pattern. diff --git a/js/README.md b/js/README.md index 05ab5fa..33d838e 100644 --- a/js/README.md +++ b/js/README.md @@ -184,6 +184,53 @@ const doubleQuoted = '"/path with spaces/file"'; await $`cat ${doubleQuoted}`; // → cat '"/path with spaces/file"' (preserves intent) ``` +### Go templates & `{{ }}` arguments + +`command-stream` gives you a real shell's word-splitting, including for tokens +you type **literally** in the template. That means a Go/Docker template flag +like `--format {{json .Config.Env}}` behaves exactly as it would in `bash`: the +**unquoted space** inside `{{ }}` splits the token into separate words, so the +child process receives a broken `--format` value (e.g. Docker reports +`template parsing error: ... unclosed action`). + +The rule is simple — **quote template tokens that contain spaces**, just like +you would in a shell script: + +```javascript +import { $ } from 'command-stream'; + +// ❌ BREAKS — the unquoted space splits the token into 3 argv words: +// `--format`, `{{json`, `.Config.Env}}` +await $`docker image inspect alpine --format {{json .Config.Env}}`; + +// ✅ WORKS — a token with no space stays a single word +await $`docker image inspect alpine --format {{.Id}}`; + +// ✅ WORKS — single-quote the template (recommended, mirrors shell scripts) +await $`docker image inspect alpine --format '{{json .Config.Env}}'`; + +// ✅ WORKS — double quotes work too +await $`docker image inspect alpine --format "{{json .Config.Env}}"`; + +// ✅ WORKS — interpolate the whole template as one ${value}; it is +// auto-quoted and always reaches the child as a single argument +const format = '{{json .Config.Env}}'; +await $`docker image inspect alpine --format ${format}`; + +// ✅ WORKAROUND — drop --format and parse the full JSON in JS +const all = await $`docker image inspect alpine`; +const env = JSON.parse(all.stdout)[0].Config.Env; +``` + +To help diagnose the broken case, `command-stream` prints a one-line warning to +stderr when a built command contains an **unquoted** `{{ … }}` token with an +internal space (the warning fires once per unique token). Silence it by setting +the `COMMAND_STREAM_NO_TEMPLATE_WARNING=1` environment variable. + +> Note: a token whose space is already **quoted** (`'{{json .Config.Env}}'`) or +> supplied via interpolation (`${format}`) is passed through untouched — no +> splitting, no warning. + ### Shell Injection Protection All interpolated values are automatically secured: diff --git a/js/examples/go-template-arguments.mjs b/js/examples/go-template-arguments.mjs new file mode 100644 index 0000000..23e5acc --- /dev/null +++ b/js/examples/go-template-arguments.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node + +// Demonstrates how Go/Docker template flags (`--format {{ ... }}`) behave with +// command-stream's shell-faithful word splitting, and the safe patterns for +// passing a template token that contains an internal space. See issue #172. +// +// Run: node examples/go-template-arguments.mjs +// +// command-stream mirrors a POSIX shell: an UNQUOTED space inside `{{ }}` splits +// the token into multiple arguments (just like bash), so quote it — exactly as +// you would in a shell script. + +import { $ } from '../src/$.mjs'; + +// Print each received argument on its own line so we can see the word splitting. +const PRINTER = new URL('../tests/fixtures/argprint.mjs', import.meta.url) + .pathname; + +async function show(title, run) { + const result = await run(); + const args = result.stdout + .split('\n') + .filter((l) => l.startsWith('ARG[')) + .map((l) => l.slice(4, -1)); + console.log(`${title}\n -> ${JSON.stringify(args)}\n`); +} + +console.log('=== Go template arguments with command-stream ===\n'); + +// ❌ Unquoted token with a space — split into 3 args (and a one-line warning +// is printed to stderr explaining why). This is what bash does too. +await show( + '1. UNQUOTED --format {{json .Config.Env}} (BREAKS, splits like bash)', + () => $({ mirror: false })`node ${PRINTER} --format {{json .Config.Env}}` +); + +// ✅ Space-free token stays a single word. +await show( + '2. NO SPACE --format {{.Id}} (works)', + () => $({ mirror: false })`node ${PRINTER} --format {{.Id}}` +); + +// ✅ Single-quote the template — recommended, mirrors shell scripts. +await show( + "3. SINGLE-QUOTED --format '{{json .Config.Env}}' (works)", + () => $({ mirror: false })`node ${PRINTER} --format '{{json .Config.Env}}'` +); + +// ✅ Interpolate the whole template as one ${value}; it is auto-quoted. +const format = '{{json .Config.Env}}'; +await show( + '4. INTERPOLATED --format ${format} (works)', + () => $({ mirror: false })`node ${PRINTER} --format ${format}` +); + +console.log( + 'Tip: set COMMAND_STREAM_NO_TEMPLATE_WARNING=1 to silence the diagnostic.' +); diff --git a/js/src/$.quote.mjs b/js/src/$.quote.mjs index d9d8c41..7914cad 100644 --- a/js/src/$.quote.mjs +++ b/js/src/$.quote.mjs @@ -54,6 +54,126 @@ export function quote(value) { return `'${value.replace(/'/g, "'\\''")}'`; } +// Remember which split-template snippets we've already warned about so a hot +// loop doesn't spam stderr with the same diagnostic over and over. +const warnedTemplateSnippets = new Set(); + +/** + * Scan a fully-built command string for an unquoted Go/Handlebars-style + * template token (`{{ ... }}`) that contains an unquoted space. + * + * Such a token is split by the shell (and by command-stream, which mirrors + * shell word-splitting) into multiple argv words, so `--format {{json .X}}` + * reaches the child as `--format`, `{{json`, `.X}}` — exactly what a POSIX + * shell would do, but surprising for Go templates. We return the offending + * snippet so the caller can point the user at the gotcha. + * + * @param {string} command - The assembled command string + * @returns {string|null} The split template snippet, or null if none + */ +export function findSplitTemplateToken(command) { + if (typeof command !== 'string' || !command.includes('{{')) { + return null; + } + + let inSingle = false; + let inDouble = false; + for (let i = 0; i < command.length; i++) { + const char = command[i]; + if (inSingle) { + inSingle = char !== "'"; + continue; + } + if (inDouble) { + inDouble = char !== '"'; + continue; + } + if (char === "'") { + inSingle = true; + continue; + } + if (char === '"') { + inDouble = true; + continue; + } + + // An unquoted `{{` — scan forward for its matching `}}`, reporting it when + // an unquoted space appears in between (which is what triggers splitting). + if (char === '{' && command[i + 1] === '{') { + const close = scanTemplateClose(command, i + 2); + if (close.splits) { + return command.slice(i, close.endIndex + 2); + } + i = close.endIndex; + } + } + + return null; +} + +/** + * Starting just after an unquoted `{{`, scan to the matching unquoted `}}`, + * tracking whether an unquoted space appears in between. + * + * @param {string} command - The command string + * @param {number} startIndex - Index just past the opening `{{` + * @returns {{ splits: boolean, endIndex: number }} `splits` is true when a + * closing `}}` was found with an intervening unquoted space; `endIndex` + * points at the closing `}}` (or the end of input). + */ +function scanTemplateClose(command, startIndex) { + let j = startIndex; + let hasUnquotedSpace = false; + let inSingle = false; + let inDouble = false; + while (j < command.length) { + const cj = command[j]; + if (inSingle) { + inSingle = cj !== "'"; + } else if (inDouble) { + inDouble = cj !== '"'; + } else if (cj === "'") { + inSingle = true; + } else if (cj === '"') { + inDouble = true; + } else if (cj === '}' && command[j + 1] === '}') { + return { splits: hasUnquotedSpace, endIndex: j }; + } else if (/\s/.test(cj)) { + hasUnquotedSpace = true; + } + j++; + } + return { splits: false, endIndex: j }; +} + +/** + * Emit a one-line diagnostic when a built command contains an unquoted Go + * template token with an internal space. This points users at the + * shell-splitting gotcha behind the cryptic downstream errors (e.g. Go's + * "unclosed action"). Silenced via COMMAND_STREAM_NO_TEMPLATE_WARNING=1, and + * each unique snippet is only reported once per process. + * + * @param {string} command - The assembled command string + */ +function warnOnSplitTemplate(command) { + if (process.env.COMMAND_STREAM_NO_TEMPLATE_WARNING) { + return; + } + const snippet = findSplitTemplateToken(command); + if (!snippet || warnedTemplateSnippets.has(snippet)) { + return; + } + warnedTemplateSnippets.add(snippet); + console.error( + `[command-stream] Warning: template token \`${snippet}\` contains an ` + + `unquoted space, so the shell splits it into multiple arguments (just ` + + `like bash would). Quote it ('${snippet}') or interpolate it as a ` + + `single \${value} to pass it as one argument. See README ` + + `"Go templates & {{ }} arguments". Set ` + + `COMMAND_STREAM_NO_TEMPLATE_WARNING=1 to silence.` + ); +} + /** * Build a shell command from template strings and values * @param {string[]} strings - Template literal strings @@ -92,6 +212,7 @@ export function buildShellCommand(strings, values) { () => `BRANCH: buildShellCommand => COMPLETE_COMMAND | ${JSON.stringify({ command: commandStr }, null, 2)}` ); + warnOnSplitTemplate(commandStr); return commandStr; } } @@ -109,6 +230,7 @@ export function buildShellCommand(strings, values) { () => `buildShellCommand EXIT | ${JSON.stringify({ command: out }, null, 2)}` ); + warnOnSplitTemplate(out); return out; } diff --git a/js/tests/fixtures/argprint.mjs b/js/tests/fixtures/argprint.mjs new file mode 100644 index 0000000..e42c587 --- /dev/null +++ b/js/tests/fixtures/argprint.mjs @@ -0,0 +1,5 @@ +// Test fixture: print each received argv argument on its own line, wrapped in +// ARG[...] markers so tests can assert exact word-splitting. See issue #172. +for (const a of process.argv.slice(2)) { + console.log(`ARG[${a}]`); +} diff --git a/js/tests/go-template-quoting.test.mjs b/js/tests/go-template-quoting.test.mjs new file mode 100644 index 0000000..354bc0d --- /dev/null +++ b/js/tests/go-template-quoting.test.mjs @@ -0,0 +1,139 @@ +// Tests for Go/Docker template (`{{ }}`) argument handling and the diagnostic +// warning for unquoted template tokens that contain an internal space. +// See issue #172. + +import { $ } from '../src/$.mjs'; +import { buildShellCommand, findSplitTemplateToken } from '../src/$.quote.mjs'; +import { test, expect, beforeEach, afterEach } from 'bun:test'; +import { fileURLToPath } from 'node:url'; +import './test-helper.mjs'; // Automatically sets up beforeEach/afterEach cleanup + +// A tiny fixture that prints each argv on its own line, so we can assert +// exactly how a command is word-split into arguments. Interpolated as a safe +// path (single argument), while the template flags stay literal template text. +const PRINTER = fileURLToPath( + new URL('./fixtures/argprint.mjs', import.meta.url) +); + +function argsOf(stdout) { + return stdout + .split('\n') + .filter((l) => l.startsWith('ARG[')) + .map((l) => l.slice(4, -1)); +} + +test('findSplitTemplateToken detects an unquoted template with a space', () => { + expect( + findSplitTemplateToken('docker inspect --format {{json .Config.Env}}') + ).toBe('{{json .Config.Env}}'); +}); + +test('findSplitTemplateToken ignores space-free templates', () => { + expect(findSplitTemplateToken('docker inspect --format {{.Id}}')).toBeNull(); +}); + +test('findSplitTemplateToken ignores single-quoted templates', () => { + expect( + findSplitTemplateToken("docker inspect --format '{{json .Config.Env}}'") + ).toBeNull(); +}); + +test('findSplitTemplateToken ignores double-quoted templates', () => { + expect( + findSplitTemplateToken('docker inspect --format "{{json .Config.Env}}"') + ).toBeNull(); +}); + +test('space-free template stays a single argument', async () => { + const result = await $({ mirror: false })`node ${PRINTER} --format {{.Id}}`; + expect(argsOf(result.stdout)).toEqual(['--format', '{{.Id}}']); +}); + +test('single-quoted template with a space stays a single argument', async () => { + const result = await $({ + mirror: false, + })`node ${PRINTER} --format '{{json .Config.Env}}'`; + expect(argsOf(result.stdout)).toEqual(['--format', '{{json .Config.Env}}']); +}); + +test('double-quoted template with a space stays a single argument', async () => { + const result = await $({ + mirror: false, + })`node ${PRINTER} --format "{{json .Config.Env}}"`; + expect(argsOf(result.stdout)).toEqual(['--format', '{{json .Config.Env}}']); +}); + +test('interpolated template with a space stays a single argument', async () => { + const format = '{{json .Config.Env}}'; + const result = await $({ + mirror: false, + })`node ${PRINTER} --format ${format}`; + expect(argsOf(result.stdout)).toEqual(['--format', '{{json .Config.Env}}']); +}); + +test('unquoted template with a space is split (shell parity with bash)', async () => { + const result = await $({ + mirror: false, + })`node ${PRINTER} --format {{json .Config.Env}}`; + // Exactly what `bash` does with the same unquoted token. + expect(argsOf(result.stdout)).toEqual([ + '--format', + '{{json', + '.Config.Env}}', + ]); +}); + +// Warning diagnostics ------------------------------------------------------- + +let warnings; +let originalError; +let originalEnv; + +beforeEach(() => { + warnings = []; + originalError = console.error; + console.error = (...args) => warnings.push(args.join(' ')); + originalEnv = process.env.COMMAND_STREAM_NO_TEMPLATE_WARNING; + delete process.env.COMMAND_STREAM_NO_TEMPLATE_WARNING; +}); + +afterEach(() => { + console.error = originalError; + if (originalEnv === undefined) { + delete process.env.COMMAND_STREAM_NO_TEMPLATE_WARNING; + } else { + process.env.COMMAND_STREAM_NO_TEMPLATE_WARNING = originalEnv; + } +}); + +test('warns when an unquoted template token with a space is built', () => { + // Use a unique token so the once-per-snippet dedup does not hide the warning. + buildShellCommand( + ['docker inspect --format {{json .Unique1.Env}} alpine'], + [] + ); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain('{{json .Unique1.Env}}'); + expect(warnings[0]).toContain('command-stream'); +}); + +test('does not warn for space-free or quoted templates', () => { + buildShellCommand(['docker inspect --format {{.Unique2}} alpine'], []); + buildShellCommand( + ["docker inspect --format '{{json .Unique3.Env}}' alpine"], + [] + ); + expect(warnings.length).toBe(0); +}); + +test('warns only once per unique token', () => { + buildShellCommand(['docker inspect --format {{json .Unique4.Env}}'], []); + buildShellCommand(['docker inspect --format {{json .Unique4.Env}}'], []); + expect(warnings.length).toBe(1); +}); + +test('COMMAND_STREAM_NO_TEMPLATE_WARNING silences the warning', () => { + process.env.COMMAND_STREAM_NO_TEMPLATE_WARNING = '1'; + buildShellCommand(['docker inspect --format {{json .Unique5.Env}}'], []); + expect(warnings.length).toBe(0); +}); diff --git a/rust/changelog.d/20260621_151257_go_template_split_diagnostic.md b/rust/changelog.d/20260621_151257_go_template_split_diagnostic.md new file mode 100644 index 0000000..e851c92 --- /dev/null +++ b/rust/changelog.d/20260621_151257_go_template_split_diagnostic.md @@ -0,0 +1,13 @@ +--- +bump: patch +--- + +### Added +- Diagnostic warning for Go/Docker template arguments with an internal space + (issue #172). When a built command contains an unquoted `{{ … }}` token that + contains a space (e.g. `--format {{json .Config.Env}}`), the shell — and + command-stream, which mirrors shell word-splitting — splits it into multiple + argv words. `command-stream` now prints a one-line warning to stderr pointing + at the gotcha (fired once per unique token, silenced via + `COMMAND_STREAM_NO_TEMPLATE_WARNING`). Quote the token (`'{{json .Config.Env}}'`) + or interpolate it as a single value to pass it through untouched. diff --git a/rust/src/macros.rs b/rust/src/macros.rs index ff51ae4..75f93e6 100644 --- a/rust/src/macros.rs +++ b/rust/src/macros.rs @@ -49,6 +49,7 @@ pub fn build_shell_command(parts: &[&str], values: &[&str]) -> String { } } + crate::quote::warn_on_split_template(&result); result } @@ -130,6 +131,7 @@ macro_rules! cmd { result.push_str(&$crate::quote::quote(values_ref[i])); } } + $crate::quote::warn_on_split_template(&result); async move { $crate::run(result).await diff --git a/rust/src/quote.rs b/rust/src/quote.rs index adddc4f..3dc14c3 100644 --- a/rust/src/quote.rs +++ b/rust/src/quote.rs @@ -3,6 +3,9 @@ //! This module provides functions for safely quoting values for shell usage, //! preventing command injection and ensuring proper argument handling. +use std::collections::HashSet; +use std::sync::{Mutex, OnceLock}; + /// Quote a value for safe shell usage /// /// This function quotes strings appropriately for use in shell commands, @@ -101,6 +104,145 @@ pub fn needs_quoting(value: &str) -> bool { !safe_pattern.is_match(value) } +/// Scan a built command string for an unquoted Go/Handlebars-style template +/// token (`{{ ... }}`) that contains an unquoted space. +/// +/// Such a token is split by the shell (and by command-stream, which mirrors +/// shell word-splitting) into multiple argv words, so `--format {{json .X}}` +/// reaches the child as `--format`, `{{json`, `.X}}` — exactly what a POSIX +/// shell would do, but surprising for Go templates. Returns the offending +/// snippet so callers can point the user at the gotcha. +/// +/// # Examples +/// +/// ``` +/// use command_stream::quote::find_split_template_token; +/// +/// assert_eq!( +/// find_split_template_token("docker inspect --format {{json .Config.Env}}"), +/// Some("{{json .Config.Env}}".to_string()) +/// ); +/// // Space-free or quoted tokens are not flagged. +/// assert_eq!(find_split_template_token("docker inspect --format {{.Id}}"), None); +/// assert_eq!( +/// find_split_template_token("docker inspect --format '{{json .Config.Env}}'"), +/// None +/// ); +/// ``` +pub fn find_split_template_token(command: &str) -> Option { + if !command.contains("{{") { + return None; + } + + let chars: Vec = command.chars().collect(); + let n = chars.len(); + let mut in_single = false; + let mut in_double = false; + let mut i = 0; + while i < n { + let c = chars[i]; + if in_single { + in_single = c != '\''; + i += 1; + continue; + } + if in_double { + in_double = c != '"'; + i += 1; + continue; + } + if c == '\'' { + in_single = true; + i += 1; + continue; + } + if c == '"' { + in_double = true; + i += 1; + continue; + } + + // An unquoted `{{` — scan forward for its matching `}}`, reporting it + // when an unquoted space appears in between (which triggers splitting). + if c == '{' && i + 1 < n && chars[i + 1] == '{' { + let (splits, end) = scan_template_close(&chars, i + 2); + if splits { + return Some(chars[i..=end + 1].iter().collect()); + } + i = end + 1; + continue; + } + i += 1; + } + + None +} + +/// Starting just after an unquoted `{{`, scan to the matching unquoted `}}`, +/// tracking whether an unquoted space appears in between. +/// +/// Returns `(splits, end_index)` where `splits` is true when a closing `}}` +/// was found with an intervening unquoted space, and `end_index` points at the +/// first `}` of that closing pair (or the end of input when no `}}` is found). +fn scan_template_close(chars: &[char], start: usize) -> (bool, usize) { + let n = chars.len(); + let mut j = start; + let mut has_unquoted_space = false; + let mut in_single = false; + let mut in_double = false; + while j < n { + let c = chars[j]; + if in_single { + in_single = c != '\''; + } else if in_double { + in_double = c != '"'; + } else if c == '\'' { + in_single = true; + } else if c == '"' { + in_double = true; + } else if c == '}' && j + 1 < n && chars[j + 1] == '}' { + return (has_unquoted_space, j); + } else if c.is_whitespace() { + has_unquoted_space = true; + } + j += 1; + } + (false, j) +} + +fn warned_template_snippets() -> &'static Mutex> { + static WARNED: OnceLock>> = OnceLock::new(); + WARNED.get_or_init(|| Mutex::new(HashSet::new())) +} + +/// Emit a one-line diagnostic when a built command contains an unquoted Go +/// template token with an internal space. This points users at the +/// shell-splitting gotcha behind the cryptic downstream errors (e.g. Go's +/// "unclosed action"). Silenced via `COMMAND_STREAM_NO_TEMPLATE_WARNING`, and +/// each unique snippet is only reported once per process. +pub fn warn_on_split_template(command: &str) { + if std::env::var_os("COMMAND_STREAM_NO_TEMPLATE_WARNING").is_some() { + return; + } + let snippet = match find_split_template_token(command) { + Some(s) => s, + None => return, + }; + { + let mut warned = warned_template_snippets().lock().unwrap(); + if !warned.insert(snippet.clone()) { + return; + } + } + eprintln!( + "[command-stream] Warning: template token `{snippet}` contains an \ +unquoted space, so the shell splits it into multiple arguments (just like \ +bash would). Quote it ('{snippet}') or interpolate it as a single ${{value}} \ +to pass it as one argument. See README \"Go templates & {{{{ }}}} arguments\". \ +Set COMMAND_STREAM_NO_TEMPLATE_WARNING=1 to silence." + ); +} + #[cfg(test)] mod tests { use super::*; @@ -158,4 +300,41 @@ mod tests { fn test_quote_with_tabs() { assert_eq!(quote("col1\tcol2"), "'col1\tcol2'"); } + + #[test] + fn test_find_split_template_unquoted_with_space() { + assert_eq!( + find_split_template_token("docker inspect --format {{json .Config.Env}}"), + Some("{{json .Config.Env}}".to_string()) + ); + } + + #[test] + fn test_find_split_template_space_free() { + assert_eq!( + find_split_template_token("docker inspect --format {{.Id}}"), + None + ); + } + + #[test] + fn test_find_split_template_single_quoted() { + assert_eq!( + find_split_template_token("docker inspect --format '{{json .Config.Env}}'"), + None + ); + } + + #[test] + fn test_find_split_template_double_quoted() { + assert_eq!( + find_split_template_token("docker inspect --format \"{{json .Config.Env}}\""), + None + ); + } + + #[test] + fn test_find_split_template_none_without_braces() { + assert_eq!(find_split_template_token("echo hello world"), None); + } }