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:

  • Zsh: colons and backslashes are escaped (\:, \\) because zsh uses colons as delimiters in _describe.

  • Fish and PowerShell: tab-separated candidate\tdescription format.

  • Bash: one candidate per line, no descriptions (compgen/complete does not support them).

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