Security#

Completion candidates flow into shell interpreters as part of command lines, so conda-completion applies defense-in-depth against injection and data corruption.

Trust boundaries#

Three boundaries matter:

  1. Manifest generation (Python, runs as the user) introspects conda’s argparse tree and writes the manifest. Input is trusted (conda’s own parser), but the output path needs symlink protection.

  2. File I/O (Python and Rust) reads and writes cache files, manifests, and version data. Files on disk could be tampered with by other processes.

  3. Shell output (Rust, runs on TAB press) produces text consumed by the shell’s completion system. Malicious data in the manifest or project files could attempt to inject shell commands.

Output sanitization#

The Rust binary sanitizes all completion candidates before emitting them. sanitize() uses an allowlist of permitted characters:

  • Alphanumerics (a-z, A-Z, 0-9)

  • Hyphens (-), dots (.), underscores (_)

  • Slashes (/), equals (=), at signs (@)

  • Spaces, colons (:), plus (+), tilde (~), backslash (\)

Everything else is stripped, blocking shell metacharacters like $, backticks, semicolons, pipes, and parentheses from reaching the shell interpreter.

The function uses Cow<str> to skip allocation when the input is already clean (the common case).

Per-shell escaping#

After sanitization, shell-specific escaping is applied:

  • Bash quotes each insertion with printf %q so spaces, backslashes, and tildes remain literal. Words containing shell quotes use Bash’s filename fallback because the completion engine does not decode those quotes.

  • PowerShell uses single-quoted CompletionText and keeps the original candidate as the display text. Parsed string values are passed back to the engine for subsequent completions.

  • Zsh uses compadd with its normal insertion quoting and -- before candidate values.

  • Fish receives tab-separated candidates and descriptions and handles completion insertion itself.

Generated scripts also quote executable and cache paths. Fish uses its own single-quote escape rules for backslashes and apostrophes, including paths resolved through a cache-directory symlink.

Atomic file operations#

All file writes use a temp-file-then-rename pattern:

  1. Create a temporary file in the same directory as the target.

  2. Write all data to the temp file.

  3. Rename the temp file to the target path.

Readers never see a partially-written file. If the process crashes mid-write, only an orphaned temp file remains. On POSIX systems, rename(2) is atomic within a single filesystem.

File size limits#

Both Python and Rust enforce size limits on file reads:

  • Manifest: 50 MB (MAX_MANIFEST_SIZE in Python)

  • Project files: 10 MB (MAX_FILE_SIZE in Rust)

  • Collection sizes: 2,000,000 entries for arrays and maps

The Python side passes these to msgpack’s deserialization functions (max_str_len, max_bin_len, max_array_len, max_map_len). The Rust side uses rmp_serde with post-load length checks.

No shell execution#

The Rust binary never executes shell commands, subprocesses, or eval-like operations. It reads files, deserializes data, matches strings, and writes text to stdout. The shell’s own completion machinery interprets the output.

Startup hooks may use shell-native evaluation (eval, source, or Invoke-Expression) to load the generated completion function. During completion, that function executes the Rust binary directly and consumes its stdout; the binary itself is a pure data processor.

Summary#

Threat

Mitigation

Shell injection via completion output

Allowlist sanitization + per-shell escaping

Symlink attack on cache/manifest/version files

Symlink rejection + atomic rename

Partial file reads (crash mid-write)

Temp-file-then-rename for all writes

Oversized input files

Size limits on all file reads (10-50 MB)

Malformed msgpack data

Size-bounded deserialization + type validation

Stale data after plugin changes

Automatic hash-based regeneration