Skip to content

NUL-joined argv

 2 min Write a readable Python argv literal with NUL separators, provided every formatted value is NUL-free.
Published

Jun 3, 2014

The idiom: write an argv as a single string literal with \0 between arguments, format it, then split it back apart. From zippy’s archive-extraction table:

for sfx, cmd in (
    (_tar, '{env.TAR}\0-C\0{sig_path}\0-xf\0{path}\0'),
    (_zip, '{env.UNZIP}\0-q\0-o\0-d\0{sig_path}\0{path}\0'),
    (None, None),
    ):
    ...
    cmd = cmd.format(**locals())
    cmd = cmd.strip('\0').split('\0')

The formatted paths and cache names can contain spaces, quotes, colons, and newlines. None of those matter, because nothing parses this string as shell. NUL is the boundary: execve hands the kernel an array of NUL-terminated strings, so no argument on a POSIX system can contain one. Python strings can, though. A NUL inside a formatted value creates another argv element when the result is split. The inputs here are operating-system paths and therefore NUL-free; untrusted strings need an explicit NUL rejection first. The trailing strip('\0') removes the terminal separator, but it also erases an intentional empty first or last argument. Remove exactly one known trailing NUL if those empty arguments must survive.

Compare the alternative. Building argv from a formatted shell string means shlex-quoting every interpolated value and hoping you caught them all; building it as a Python list of fragments means the command disappears into syntax. Inside the NUL-free value domain, the literal keeps the whole invocation readable as one table—implicit string concatenation puts each argument on its own line—without introducing shell quoting.

zippy used it in several command tables. The configure invocation is a nine-line literal ('{tsk.cwd}/configure' '\0--prefix=/' ...); the in-process setup.py driver assigns sys.argv directly from one:

sys.argv[:] = (
    'setup.py'
    '\0egg_info'
    '\0--no-svn-revision'
    ...
    '\0--install-data={sections[data]}'
    ).format(**ctx).split('\0')

And the same trick shows up in xconsole, my gamepad window manager, where the Minecraft launcher builds a long Java classpath plus a page of GC flags as one \0-joined property and hands self.cmdline.split('\0') to Popen—classpath entries full of slashes and colons, no escaping anywhere.

One refinement worth copying: zippy keeps two representations. The NUL-split list goes to exec; when persisting a replayable build script for humans, it re-quotes the same argv with pipes.quote. One form for the kernel, one for eyeballs, derived from the same literal.