From a6fbd067d5099d62ff8571f89713de50f81dc858 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:36:40 -0600 Subject: [PATCH 1/5] Port completion and _complete (shell completion) to Rust A port of Symfony Console 7.4's DumpCompletionCommand, CompleteCommand, CompletionInput and the bash, zsh and fish outputs, which 2.x uses unchanged. - completion [shell] prints Symfony's completion script for bash, zsh or fish (the shell argument or $SHELL's basename), with {{ COMMAND_NAME }} and {{ VERSION }} filled, and refuses an unknown or undetected shell on stderr with exit 2. completion --debug tails the debug log. The three scripts are vendored from Symfony with its MIT licence. - _complete reproduces CompletionInput: Symfony's ArgvInput parsing with every token error suppressed, the relevant-token and previous-token rules for option names and values, argument binding, and getFirstArgument's skipping of option values. It answers the application's command list, a found command's name expansion, option names (negatable options as --no-x too), and suggested values, in each shell's format. The API-version check uses PHP truthiness and version_compare (exit 126); setup errors exit 2. - 2.x has no input-dependent completers, so every suggested value is a fixed list. parity/update-completion.sh records them from the pinned 2.x CLI into src/completion.json (91 application entries, 81 commands). - The SYMFONY_COMPLETION_DEBUG log has the same shape but names commands rather than PHP classes. - build.rs skips symlinks under schemas/ (cli#92 review). 53 parity cases against 2.3.5 match byte for byte: the three scripts, $SHELL detection and refusals, and _complete in bash, zsh and fish for command lists, partial and ambiguous names, aliases, option names and values (=, separate, short, prefixed, negatable), global options before the command, help/list/completion arguments and formats, arguments after the last one, after --, unknown commands, and every error path. Overall 308/312, 4 intentional, 0 unexpected. Refs #72 Co-Authored-By: Claude Opus 5.5 --- rust/parity/update-completion.sh | 58 ++ rust/src/completion.json | 1362 ++++++++++++++++++++++++++++++ 2 files changed, 1420 insertions(+) create mode 100755 rust/parity/update-completion.sh create mode 100644 rust/src/completion.json diff --git a/rust/parity/update-completion.sh b/rust/parity/update-completion.sh new file mode 100755 index 0000000..44ee7ff --- /dev/null +++ b/rust/parity/update-completion.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Regenerate src/completion.json from the pinned 2.x reference CLI: the +# values Symfony's shell completion suggests for every option and argument +# of every command, and the command list it offers before a command is +# typed. 2.x has no input-dependent completers, so these are fixed tables. +set -euo pipefail + +crate="$(cd "$(dirname "$0")/.." && pwd)" +reference="$crate/target/dw-reference" +[[ -x "$reference" ]] || "$crate/parity/fetch-reference.sh" >/dev/null + +DW_CONFIG_HOME="$(mktemp -d)" DW_REFERENCE="$reference" python3 -I - "$crate/src/catalog.json" > "$crate/src/completion.json" <<'PY' +import json, os, subprocess, sys + +reference = os.environ["DW_REFERENCE"] +catalog = json.load(open(sys.argv[1], encoding="utf-8")) + +def complete(shell, tokens, current): + argv = [reference, "_complete", f"-s{shell}", "-a1", f"-c{current}"] + argv += [f"-i{token}" for token in tokens] + result = subprocess.run(argv, capture_output=True, text=True, check=True) + return [line for line in result.stdout.split("\n") if line != ""] + +# Before a command is typed: names (and aliases) with descriptions. +application = [] +# The shell scripts never pass empty words; a cursor after the last word is +# index len(tokens). +for line in complete("zsh", ["dw"], 1): + value, _, description = line.partition("\t") + application.append({"value": value, "description": description}) + +values = {} +for command in catalog["commands"]: + name = command["name"] + definition = command["definition"] + options = definition["options"] + options = options.values() if isinstance(options, dict) else options + found = {} + for option in options: + if not option["accept_value"] or option["name"].startswith("--no-") and option["name"][5:] in (o["name"][2:] for o in options): + continue + suggested = complete("bash", ["dw", name, option["name"] + "="], 2) + if suggested: + found.setdefault("options", {})[option["name"][2:]] = suggested + arguments = definition["arguments"] + arguments = list(arguments.values()) if isinstance(arguments, dict) else arguments + for position, argument in enumerate(arguments): + suggested = complete("bash", ["dw", name] + ["x"] * position, position + 2) + if suggested: + found.setdefault("arguments", {})[argument["name"]] = suggested + if found: + values[name] = found + +json.dump({"source": catalog["source"], "application": application, "values": values}, + sys.stdout, indent=1, ensure_ascii=False) +print() +PY +echo "wrote $crate/src/completion.json" diff --git a/rust/src/completion.json b/rust/src/completion.json new file mode 100644 index 0000000..1660c65 --- /dev/null +++ b/rust/src/completion.json @@ -0,0 +1,1362 @@ +{ + "source": "dw 2.3.5", + "application": [ + { + "value": "help", + "description": "Display help for a command" + }, + { + "value": "list", + "description": "List commands" + }, + { + "value": "completion", + "description": "Dump the shell completion script" + }, + { + "value": "server:health", + "description": "Check the health of the Durable Workflow server" + }, + { + "value": "server:info", + "description": "Display server version, capabilities, and role topology" + }, + { + "value": "cluster:info", + "description": "Display server version, capabilities, and role topology" + }, + { + "value": "server:start-dev", + "description": "Serve an existing Laravel project for local development" + }, + { + "value": "doctor", + "description": "Diagnose the resolved dw connection and server compatibility state" + }, + { + "value": "debug", + "description": "Run one-shot diagnostics for a workflow" + }, + { + "value": "upgrade", + "description": "Upgrade the standalone dw binary to the latest stable (or a pinned) release" + }, + { + "value": "workflow:start", + "description": "Start a new workflow execution" + }, + { + "value": "workflow:list", + "description": "List workflow executions" + }, + { + "value": "workflows:list", + "description": "List workflow executions" + }, + { + "value": "workflow:describe", + "description": "Show detailed information about a workflow execution" + }, + { + "value": "workflow:signal", + "description": "Send a signal to a running workflow" + }, + { + "value": "workflow:query", + "description": "Query a workflow's state" + }, + { + "value": "workflow:update", + "description": "Send an update to a workflow" + }, + { + "value": "workflow:cancel", + "description": "Close a workflow as cancelled" + }, + { + "value": "workflow:terminate", + "description": "Terminate a workflow immediately" + }, + { + "value": "workflow:repair", + "description": "Request repair of a workflow run" + }, + { + "value": "workflow:redrive", + "description": "Continue a failed workflow run from its failed activity" + }, + { + "value": "workflow:archive", + "description": "Archive a terminal workflow run" + }, + { + "value": "workflow:list-runs", + "description": "List all runs for a workflow execution" + }, + { + "value": "workflow:show-run", + "description": "Show detailed information about a specific workflow run" + }, + { + "value": "workflow:history", + "description": "Show event history for a workflow run" + }, + { + "value": "workflow:history-export", + "description": "Export a workflow run history as a replay bundle" + }, + { + "value": "workflow:migrate-v1", + "description": "Project a Waterline v1 workflow into standalone read-only visibility" + }, + { + "value": "service-call:describe", + "description": "Inspect a durable Nexus service call and its original target" + }, + { + "value": "env:list", + "description": "List configured dw environment profiles" + }, + { + "value": "env:set", + "description": "Create or update a dw environment profile" + }, + { + "value": "env:use", + "description": "Set the default dw environment profile" + }, + { + "value": "env:show", + "description": "Show details for a dw environment profile" + }, + { + "value": "env:delete", + "description": "Delete a dw environment profile" + }, + { + "value": "namespace:list", + "description": "List all namespaces" + }, + { + "value": "namespace:create", + "description": "Create a new namespace" + }, + { + "value": "namespace:describe", + "description": "Show details of a namespace" + }, + { + "value": "namespace:update", + "description": "Update a namespace" + }, + { + "value": "namespace:delete", + "description": "Delete a namespace and its runtime state" + }, + { + "value": "namespace:set-storage-driver", + "description": "Configure external payload storage for a namespace" + }, + { + "value": "storage:test", + "description": "Run an external payload storage round-trip diagnostic" + }, + { + "value": "activity:list", + "description": "List standalone activity executions" + }, + { + "value": "activities:list", + "description": "List standalone activity executions" + }, + { + "value": "activity:describe", + "description": "Show detailed information about a standalone activity execution" + }, + { + "value": "activities:describe", + "description": "Show detailed information about a standalone activity execution" + }, + { + "value": "activity:complete", + "description": "Complete an activity task externally" + }, + { + "value": "activity:fail", + "description": "Fail an activity task externally" + }, + { + "value": "bridge:webhook", + "description": "Send a bounded webhook bridge adapter event" + }, + { + "value": "schedule:list", + "description": "List schedules with visibility filters and cursor paging" + }, + { + "value": "schedule:create", + "description": "Create a new schedule" + }, + { + "value": "schedule:describe", + "description": "Show details of a schedule" + }, + { + "value": "schedule:delete", + "description": "Delete a schedule" + }, + { + "value": "schedule:pause", + "description": "Pause a schedule" + }, + { + "value": "schedule:resume", + "description": "Resume a paused schedule" + }, + { + "value": "schedule:trigger", + "description": "Trigger a schedule execution immediately" + }, + { + "value": "schedule:update", + "description": "Update a schedule" + }, + { + "value": "schedule:backfill", + "description": "Backfill missed schedule executions" + }, + { + "value": "schedule:history", + "description": "Show the audit history stream for a schedule" + }, + { + "value": "worker:register", + "description": "Register a diagnostic worker with the server" + }, + { + "value": "worker:list", + "description": "List registered workers" + }, + { + "value": "worker:describe", + "description": "Show details of a registered worker" + }, + { + "value": "worker:deregister", + "description": "Deregister a worker from the server" + }, + { + "value": "workflow-task:poll", + "description": "Poll and lease one workflow task" + }, + { + "value": "workflow-task:complete", + "description": "Complete a leased workflow task" + }, + { + "value": "workflow-task:fail", + "description": "Fail a leased workflow task" + }, + { + "value": "workflow-task:history", + "description": "Fetch the next history page for a leased workflow task" + }, + { + "value": "query-task:poll", + "description": "Poll and lease one routed workflow query task" + }, + { + "value": "query-task:complete", + "description": "Complete a leased routed workflow query task" + }, + { + "value": "query-task:fail", + "description": "Fail a leased routed workflow query task" + }, + { + "value": "task-queue:list", + "description": "List task queues with active pollers" + }, + { + "value": "task-queue:describe", + "description": "Show task queue details including pollers and backlog" + }, + { + "value": "task-queue:build-ids", + "description": "Show worker build-id rollout state for a task queue" + }, + { + "value": "task-queue:promote", + "description": "Promote a build_id cohort for new workflow starts" + }, + { + "value": "task-queue:drain", + "description": "Mark a build_id cohort as draining for a task queue" + }, + { + "value": "task-queue:resume", + "description": "Clear drain intent from a build_id cohort on a task queue" + }, + { + "value": "search-attribute:list", + "description": "List search attribute definitions" + }, + { + "value": "search-attributes:list", + "description": "List search attribute definitions" + }, + { + "value": "search-attribute:create", + "description": "Register a custom search attribute" + }, + { + "value": "search-attributes:create", + "description": "Register a custom search attribute" + }, + { + "value": "search-attribute:delete", + "description": "Remove a custom search attribute" + }, + { + "value": "search-attributes:delete", + "description": "Remove a custom search attribute" + }, + { + "value": "system:operator-metrics", + "description": "Show the rollout-safety coordination health snapshot" + }, + { + "value": "system:repair-status", + "description": "Show task repair candidate diagnostics and policy" + }, + { + "value": "system:repair-pass", + "description": "Run one task repair sweep on the server" + }, + { + "value": "system:activity-timeout-status", + "description": "Show expired activity execution diagnostics" + }, + { + "value": "system:activity-timeout-pass", + "description": "Run one activity timeout enforcement sweep on the server" + }, + { + "value": "system:retention-status", + "description": "Show history retention diagnostics for the current namespace" + }, + { + "value": "system:retention-pass", + "description": "Run one history retention enforcement sweep on the server" + }, + { + "value": "schema:list", + "description": "List published JSON schemas" + }, + { + "value": "schema:show", + "description": "Print a published JSON schema" + }, + { + "value": "schema:manifest", + "description": "Print the bundled JSON schema manifest" + }, + { + "value": "watch", + "description": "Watch long-running resources and print state changes" + } + ], + "values": { + "completion": { + "arguments": { + "shell": [ + "bash", + "fish", + "zsh" + ] + } + }, + "debug": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "doctor": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "help": { + "options": { + "format": [ + "txt", + "xml", + "json", + "md", + "rst" + ] + }, + "arguments": { + "command_name": [ + "completion", + "debug", + "doctor", + "help", + "list", + "upgrade", + "watch", + "activity:complete", + "activity:describe", + "activity:fail", + "activity:list", + "bridge:webhook", + "env:delete", + "env:list", + "env:set", + "env:show", + "env:use", + "namespace:create", + "namespace:delete", + "namespace:describe", + "namespace:list", + "namespace:set-storage-driver", + "namespace:update", + "query-task:complete", + "query-task:fail", + "query-task:poll", + "schedule:backfill", + "schedule:create", + "schedule:delete", + "schedule:describe", + "schedule:history", + "schedule:list", + "schedule:pause", + "schedule:resume", + "schedule:trigger", + "schedule:update", + "schema:list", + "schema:manifest", + "schema:show", + "search-attribute:create", + "search-attribute:delete", + "search-attribute:list", + "server:health", + "server:info", + "server:start-dev", + "service-call:describe", + "storage:test", + "system:activity-timeout-pass", + "system:activity-timeout-status", + "system:operator-metrics", + "system:repair-pass", + "system:repair-status", + "system:retention-pass", + "system:retention-status", + "task-queue:build-ids", + "task-queue:describe", + "task-queue:drain", + "task-queue:list", + "task-queue:promote", + "task-queue:resume", + "worker:deregister", + "worker:describe", + "worker:list", + "worker:register", + "workflow:archive", + "workflow:cancel", + "workflow:describe", + "workflow:history", + "workflow:history-export", + "workflow:list", + "workflow:list-runs", + "workflow:migrate-v1", + "workflow:query", + "workflow:redrive", + "workflow:repair", + "workflow:show-run", + "workflow:signal", + "workflow:start", + "workflow:terminate", + "workflow:update", + "workflow-task:complete", + "workflow-task:fail", + "workflow-task:history", + "workflow-task:poll" + ] + } + }, + "list": { + "options": { + "format": [ + "txt", + "xml", + "json", + "md", + "rst" + ] + }, + "arguments": { + "namespace": [ + "_global", + "activities", + "activity", + "bridge", + "cluster", + "env", + "namespace", + "query-task", + "runtime", + "schedule", + "schema", + "search-attribute", + "search-attributes", + "server", + "service-call", + "storage", + "system", + "task-queue", + "worker", + "workflow", + "workflow-task", + "workflows" + ] + } + }, + "upgrade": { + "options": { + "output": [ + "table", + "json" + ] + } + }, + "watch": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "activity:complete": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ], + "input-encoding": [ + "json", + "raw", + "base64" + ] + } + }, + "activity:describe": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "activity:fail": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ], + "failure-kind": [ + "application", + "timeout", + "cancellation", + "malformed_output", + "handler_crash", + "decode_failure", + "unsupported_payload" + ], + "timeout-type": [ + "schedule_to_start", + "start_to_close", + "schedule_to_close", + "heartbeat", + "deadline_exceeded" + ] + } + }, + "activity:list": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ], + "status": [ + "running", + "completed", + "failed" + ] + } + }, + "bridge:webhook": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ], + "input-encoding": [ + "json", + "raw", + "base64" + ] + } + }, + "env:delete": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "env:list": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "env:set": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "env:show": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "env:use": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "namespace:create": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "namespace:delete": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "namespace:describe": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "namespace:list": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "namespace:set-storage-driver": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + }, + "arguments": { + "driver": [ + "local", + "s3", + "gcs", + "azure" + ] + } + }, + "namespace:update": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "query-task:complete": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "query-task:fail": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "query-task:poll": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "schedule:backfill": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ], + "overlap-policy": [ + "skip", + "buffer_one", + "buffer_all", + "cancel_other", + "terminate_other", + "allow_all" + ] + } + }, + "schedule:create": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ], + "overlap-policy": [ + "skip", + "buffer_one", + "buffer_all", + "cancel_other", + "terminate_other", + "allow_all" + ], + "input-encoding": [ + "json", + "raw", + "base64" + ] + } + }, + "schedule:delete": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "schedule:describe": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "schedule:history": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "schedule:list": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ], + "status": [ + "active", + "paused" + ] + } + }, + "schedule:pause": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "schedule:resume": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "schedule:trigger": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ], + "overlap-policy": [ + "skip", + "buffer_one", + "buffer_all", + "cancel_other", + "terminate_other", + "allow_all" + ] + } + }, + "schedule:update": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ], + "overlap-policy": [ + "skip", + "buffer_one", + "buffer_all", + "cancel_other", + "terminate_other", + "allow_all" + ], + "input-encoding": [ + "json", + "raw", + "base64" + ] + } + }, + "search-attribute:create": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + }, + "arguments": { + "type": [ + "keyword", + "text", + "int", + "double", + "bool", + "datetime", + "keyword_list" + ] + } + }, + "search-attribute:delete": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "search-attribute:list": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "server:health": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "server:info": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "server:start-dev": { + "options": { + "db": [ + "sqlite", + "mysql", + "pgsql" + ] + } + }, + "service-call:describe": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "storage:test": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ], + "driver": [ + "local", + "s3", + "gcs", + "azure" + ] + } + }, + "system:activity-timeout-pass": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "system:activity-timeout-status": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "system:operator-metrics": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "system:repair-pass": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "system:repair-status": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "system:retention-pass": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "system:retention-status": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "task-queue:build-ids": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "task-queue:describe": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "task-queue:drain": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "task-queue:list": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "task-queue:promote": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "task-queue:resume": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "worker:deregister": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "worker:describe": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "worker:list": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ], + "status": [ + "active", + "stale", + "draining" + ] + } + }, + "worker:register": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "workflow:archive": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "workflow:cancel": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ], + "status": [ + "running", + "completed", + "failed" + ] + } + }, + "workflow:describe": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "workflow:history": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "workflow:history-export": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "workflow:list": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ], + "status": [ + "running", + "completed", + "failed" + ] + } + }, + "workflow:list-runs": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "workflow:migrate-v1": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "workflow:query": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ], + "input-encoding": [ + "json", + "raw", + "base64" + ] + } + }, + "workflow:redrive": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "workflow:repair": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "workflow:show-run": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "workflow:signal": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ], + "input-encoding": [ + "json", + "raw", + "base64" + ] + } + }, + "workflow:start": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ], + "duplicate-policy": [ + "fail", + "use-existing" + ], + "input-encoding": [ + "json", + "raw", + "base64" + ] + } + }, + "workflow:terminate": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "workflow:update": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ], + "wait": [ + "accepted", + "completed" + ], + "input-encoding": [ + "json", + "raw", + "base64" + ] + } + }, + "workflow-task:complete": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "workflow-task:fail": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "workflow-task:history": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + }, + "workflow-task:poll": { + "options": { + "output": [ + "table", + "json", + "jsonl" + ] + } + } + } +} From b65b61817735e695cec7af388b096f20e8523851 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:36:53 -0600 Subject: [PATCH 2/5] Add the completion commands, Symfony scripts and parity cases The previous commit carried only the recorded completion tables; this adds the rest of the port it describes: src/commands/completion.rs (completion, _complete and CompletionInput), the vendored bash, zsh and fish scripts with Symfony's MIT licence, Input::opt_all for the repeated -i option, the build.rs symlink skip, and 53 parity cases (308/312 overall, 4 intentional, 0 unexpected). Refs #72 Co-Authored-By: Claude Opus 5.5 --- rust/build.rs | 6 + rust/parity/cases.json | 591 ++++++++++++++++++++ rust/src/cli.rs | 8 + rust/src/commands/completion.rs | 838 ++++++++++++++++++++++++++++ rust/src/commands/mod.rs | 1 + rust/src/compat.rs | 2 +- rust/src/completion/LICENSE-symfony | 19 + rust/src/completion/completion.bash | 94 ++++ rust/src/completion/completion.fish | 25 + rust/src/completion/completion.zsh | 82 +++ rust/src/main.rs | 4 +- 11 files changed, 1668 insertions(+), 2 deletions(-) create mode 100644 rust/src/commands/completion.rs create mode 100644 rust/src/completion/LICENSE-symfony create mode 100644 rust/src/completion/completion.bash create mode 100644 rust/src/completion/completion.fish create mode 100644 rust/src/completion/completion.zsh diff --git a/rust/build.rs b/rust/build.rs index e927d32..428dca9 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -11,6 +11,12 @@ fn collect(dir: &Path, root: &Path, files: &mut Vec<(String, PathBuf)>) { .collect(); entries.sort(); for path in entries { + // Never follow a symlink out of schemas/. + let metadata = std::fs::symlink_metadata(&path) + .unwrap_or_else(|error| panic!("cannot stat {}: {error}", path.display())); + if metadata.file_type().is_symlink() { + continue; + } if path.is_dir() { collect(&path, root, files); } else if path diff --git a/rust/parity/cases.json b/rust/parity/cases.json index f33b347..abcec34 100644 --- a/rust/parity/cases.json +++ b/rust/parity/cases.json @@ -1873,6 +1873,597 @@ "schema:list", "--ansi" ] + }, + { + "name": "completion bash", + "argv": [ + "completion", + "bash" + ] + }, + { + "name": "completion zsh", + "argv": [ + "completion", + "zsh" + ] + }, + { + "name": "completion fish", + "argv": [ + "completion", + "fish" + ] + }, + { + "name": "completion from SHELL", + "argv": [ + "completion" + ], + "env": { + "SHELL": "/usr/bin/zsh" + } + }, + { + "name": "completion with SHELL unset", + "argv": [ + "completion" + ], + "env": { + "SHELL": "" + } + }, + { + "name": "completion unsupported shell", + "argv": [ + "completion", + "tcsh" + ] + }, + { + "name": "completion quiet", + "argv": [ + "completion", + "bash", + "-q" + ] + }, + { + "name": "_complete bash: commands before one is typed", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c1", + "-a1", + "-idw" + ] + }, + { + "name": "_complete bash: option names of a command", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c2", + "-a1", + "-idw", + "-iupgrade", + "-i--" + ] + }, + { + "name": "_complete bash: global option names before a command", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c1", + "-a1", + "-idw", + "-i--" + ] + }, + { + "name": "_complete bash: help's command_name", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c2", + "-a1", + "-idw", + "-ihelp" + ] + }, + { + "name": "_complete zsh: commands before one is typed", + "argv": [ + "_complete", + "--no-interaction", + "-szsh", + "-c1", + "-a1", + "-idw" + ] + }, + { + "name": "_complete zsh: option names of a command", + "argv": [ + "_complete", + "--no-interaction", + "-szsh", + "-c2", + "-a1", + "-idw", + "-iupgrade", + "-i--" + ] + }, + { + "name": "_complete zsh: global option names before a command", + "argv": [ + "_complete", + "--no-interaction", + "-szsh", + "-c1", + "-a1", + "-idw", + "-i--" + ] + }, + { + "name": "_complete zsh: help's command_name", + "argv": [ + "_complete", + "--no-interaction", + "-szsh", + "-c2", + "-a1", + "-idw", + "-ihelp" + ] + }, + { + "name": "_complete fish: commands before one is typed", + "argv": [ + "_complete", + "--no-interaction", + "-sfish", + "-c1", + "-a1", + "-idw" + ] + }, + { + "name": "_complete fish: option names of a command", + "argv": [ + "_complete", + "--no-interaction", + "-sfish", + "-c2", + "-a1", + "-idw", + "-iupgrade", + "-i--" + ] + }, + { + "name": "_complete fish: global option names before a command", + "argv": [ + "_complete", + "--no-interaction", + "-sfish", + "-c1", + "-a1", + "-idw", + "-i--" + ] + }, + { + "name": "_complete fish: help's command_name", + "argv": [ + "_complete", + "--no-interaction", + "-sfish", + "-c2", + "-a1", + "-idw", + "-ihelp" + ] + }, + { + "name": "_complete bash: an ambiguous partial command", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c1", + "-a1", + "-idw", + "-iwor" + ] + }, + { + "name": "_complete bash: a unique partial command expands", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c1", + "-a1", + "-idw", + "-iupg" + ] + }, + { + "name": "_complete bash: an alias", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c1", + "-a1", + "-idw", + "-iworkflows:li" + ] + }, + { + "name": "_complete bash: option value with =", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c2", + "-a1", + "-idw", + "-iupgrade", + "-i--output=" + ] + }, + { + "name": "_complete bash: option value with = and a prefix", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c2", + "-a1", + "-idw", + "-iworkflow:list", + "-i--output=js" + ] + }, + { + "name": "_complete bash: option value after a separate option", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c3", + "-a1", + "-idw", + "-iworkflow:list", + "-i--output" + ] + }, + { + "name": "_complete bash: option value after a separate option with a prefix", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c3", + "-a1", + "-idw", + "-iworkflow:list", + "-i--output", + "-ij" + ] + }, + { + "name": "_complete bash: an option without suggestions", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c2", + "-a1", + "-idw", + "-iworkflow:list", + "-i--namespace=" + ] + }, + { + "name": "_complete bash: a short option value", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c2", + "-a1", + "-idw", + "-iworkflow:list", + "-i-s" + ] + }, + { + "name": "_complete bash: a flag is not completed", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c3", + "-a1", + "-idw", + "-iupgrade", + "-i--dry-run" + ] + }, + { + "name": "_complete bash: a negatable option name", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c2", + "-a1", + "-idw", + "-iupgrade", + "-i--no-" + ] + }, + { + "name": "_complete bash: global options before the command", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c3", + "-a1", + "-idw", + "-i-v", + "-iupgrade", + "-i--output=" + ] + }, + { + "name": "_complete bash: an option value before the command", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c3", + "-a1", + "-idw", + "-i--ansi", + "-iactivity:fail", + "-i--failure-kind=" + ] + }, + { + "name": "_complete bash: list namespace", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c2", + "-a1", + "-idw", + "-ilist" + ] + }, + { + "name": "_complete bash: list format", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c2", + "-a1", + "-idw", + "-ilist", + "-i--format=" + ] + }, + { + "name": "_complete bash: help format", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c2", + "-a1", + "-idw", + "-ihelp", + "-i--format=" + ] + }, + { + "name": "_complete bash: completion shell argument", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c2", + "-a1", + "-idw", + "-icompletion" + ] + }, + { + "name": "_complete bash: a second argument", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c3", + "-a1", + "-idw", + "-iworkflow:signal", + "-iwf" + ] + }, + { + "name": "_complete bash: after all arguments", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c3", + "-a1", + "-idw", + "-ienv:use", + "-iprod" + ] + }, + { + "name": "_complete bash: after a double dash", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c3", + "-a1", + "-idw", + "-iupgrade", + "-i--", + "-i--out" + ] + }, + { + "name": "_complete bash: an unknown command", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c2", + "-a1", + "-idw", + "-inope:nope" + ] + }, + { + "name": "_complete bash: an unknown command's options", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c2", + "-a1", + "-idw", + "-inope:nope", + "-i--" + ] + }, + { + "name": "_complete bash: cursor on the first token", + "argv": [ + "_complete", + "--no-interaction", + "-sbash", + "-c1", + "-a1", + "-idw", + "-iupgrade" + ] + }, + { + "name": "_complete: unsupported API version", + "argv": [ + "_complete", + "-sbash", + "-c1", + "-a0", + "-idw" + ] + }, + { + "name": "_complete: deprecated symfony flag", + "argv": [ + "_complete", + "-sbash", + "-c1", + "-S5.4", + "-idw" + ] + }, + { + "name": "_complete: missing shell", + "argv": [ + "_complete", + "-c1", + "-a1", + "-idw" + ] + }, + { + "name": "_complete: unsupported shell", + "argv": [ + "_complete", + "-stcsh", + "-c1", + "-a1", + "-idw" + ] + }, + { + "name": "_complete: missing current", + "argv": [ + "_complete", + "-sbash", + "-a1", + "-idw" + ] + }, + { + "name": "_complete: zero current", + "argv": [ + "_complete", + "-sbash", + "-c0", + "-a1", + "-idw" + ] + }, + { + "name": "_complete: non-numeric current", + "argv": [ + "_complete", + "-sbash", + "-cx", + "-a1", + "-idw" + ] + }, + { + "name": "_complete: current beyond the tokens", + "argv": [ + "_complete", + "-sbash", + "-c5", + "-a1", + "-idw" + ] + }, + { + "name": "_complete: no tokens", + "argv": [ + "_complete", + "-sbash", + "-c1", + "-a1" + ] + }, + { + "name": "_complete: an API version older than 1", + "argv": [ + "_complete", + "-sbash", + "-c1", + "-a0.9", + "-idw" + ] } ] } diff --git a/rust/src/cli.rs b/rust/src/cli.rs index d7c96c6..e852e32 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -86,6 +86,14 @@ impl Input { } /// The non-empty string value of an option, if any. + /// Every value of a multiple-value option, in order. + pub fn opt_all(&self, name: &str) -> Vec<&str> { + self.opts + .get(name) + .map(|values| values.iter().flatten().map(String::as_str).collect()) + .unwrap_or_default() + } + pub fn opt_str(&self, name: &str) -> Option<&str> { self.opt(name).flatten().filter(|value| !value.is_empty()) } diff --git a/rust/src/commands/completion.rs b/rust/src/commands/completion.rs new file mode 100644 index 0000000..1b11a0c --- /dev/null +++ b/rust/src/commands/completion.rs @@ -0,0 +1,838 @@ +//! Shell completion, as 2.x provides it through Symfony Console: +//! `completion` dumps the bash, zsh or fish script, and the hidden +//! `_complete` answers the script's requests. A port of Symfony 7.4's +//! `DumpCompletionCommand`, `CompleteCommand`, `CompletionInput` and the +//! shell outputs. +//! +//! 2.x has no input-dependent completers: every suggested value is a fixed +//! list, recorded from the 2.x CLI in `completion.json` +//! (`parity/update-completion.sh`). + +use crate::catalog; +use crate::cli::Input; +use crate::descriptor::{CommandView, OptionView, APPLICATION_OPTIONS}; +use crate::error::{exit, CliError}; +use crate::formatter::{write_out, writeln_err, writeln_out}; +use serde_json::Value; +use std::collections::{HashMap, VecDeque}; +use std::sync::OnceLock; + +const COMPLETION_API_VERSION: &str = "1"; +const SHELLS: [&str; 3] = ["bash", "fish", "zsh"]; + +fn script(shell: &str) -> Option<&'static str> { + match shell { + "bash" => Some(include_str!("../completion/completion.bash")), + "fish" => Some(include_str!("../completion/completion.fish")), + "zsh" => Some(include_str!("../completion/completion.zsh")), + _ => None, + } +} + +fn tables() -> &'static Value { + static TABLES: OnceLock = OnceLock::new(); + TABLES.get_or_init(|| { + serde_json::from_str(include_str!("../completion.json")).expect("completion.json is valid") + }) +} + +/// `basename($_SERVER['argv'][0])`. +fn command_name() -> String { + let argv0 = std::env::args_os() + .next() + .map(|arg| arg.to_string_lossy().into_owned()) + .unwrap_or_default(); + basename(&argv0) +} + +/// PHP `basename()` for `/`-separated paths (and `\` on Windows). +fn basename(path: &str) -> String { + let trimmed = path.trim_end_matches(|c| c == '/' || (cfg!(windows) && c == '\\')); + trimmed + .rsplit(|c| c == '/' || (cfg!(windows) && c == '\\')) + .next() + .unwrap_or_default() + .to_string() +} + +fn log_path() -> std::path::PathBuf { + std::env::temp_dir().join(format!("sf_{}.log", command_name())) +} + +// --------------------------------------------------------------------------- +// `completion` + +pub fn completion(input: &Input) -> Result { + if input.flag("debug") { + let path = log_path(); + if !path.exists() { + let _ = std::fs::write(&path, ""); + } + let _ = std::process::Command::new("tail") + .arg("-f") + .arg(&path) + .status(); + return Ok(exit::SUCCESS); + } + let shell = match input.arg("shell") { + Some(shell) => shell.to_string(), + None => basename(&std::env::var("SHELL").unwrap_or_default()), + }; + let Some(text) = script(&shell) else { + let supported = SHELLS.join("\", \""); + if shell.is_empty() { + writeln_err(&format!( + "Shell not detected, Symfony shell completion only supports \"{supported}\")." + )); + } else { + writeln_err(&format!( + "Detected shell \"{shell}\", which is not supported by Symfony shell completion (supported shells: \"{supported}\")." + )); + } + return Ok(exit::INVALID); + }; + write_out( + &text + .replace("{{ COMMAND_NAME }}", &command_name()) + .replace("{{ VERSION }}", COMPLETION_API_VERSION), + ); + Ok(exit::SUCCESS) +} + +// --------------------------------------------------------------------------- +// Input definitions and Symfony's ArgvInput parsing, errors suppressed. + +#[derive(Clone)] +struct Opt { + name: String, + shortcuts: Vec, + accept_value: bool, + value_required: bool, + array: bool, + negatable: bool, + description: String, +} + +#[derive(Clone)] +struct Arg { + name: String, + array: bool, +} + +#[derive(Clone)] +struct Definition { + args: Vec, + opts: Vec, +} + +impl Definition { + fn option(&self, name: &str) -> Option<&Opt> { + self.opts.iter().find(|opt| opt.name == name) + } + + fn negation(&self, name: &str) -> Option<&Opt> { + let base = name.strip_prefix("no-")?; + self.opts + .iter() + .find(|opt| opt.negatable && opt.name == base) + } + + fn shortcut(&self, shortcut: &str) -> Option<&Opt> { + self.opts + .iter() + .find(|opt| opt.shortcuts.iter().any(|candidate| candidate == shortcut)) + } +} + +fn option_from(view: &OptionView) -> Opt { + Opt { + name: view.name.clone(), + shortcuts: view + .shortcut + .split('|') + .filter(|part| !part.is_empty()) + .map(str::to_string) + .collect(), + accept_value: view.accept_value, + value_required: view.value_required, + array: view.multiple, + negatable: view.negatable, + description: view.description.clone(), + } +} + +/// The application definition: the `command` argument and the global +/// options, in Symfony's order. +fn application_definition() -> Definition { + let list = catalog::entry("list").expect("list is in the catalog"); + let view = CommandView::new(list); + let opts = view + .options + .iter() + .filter(|option| APPLICATION_OPTIONS.contains(&option.name.as_str())) + .map(option_from) + .collect(); + Definition { + args: vec![Arg { + name: "command".into(), + array: false, + }], + opts, + } +} + +/// A command's definition merged with the application's: the `command` +/// argument first, the command's options, then the global ones. +fn command_definition(entry: &catalog::Entry) -> Definition { + let view = CommandView::new(entry); + let mut args = vec![Arg { + name: "command".into(), + array: false, + }]; + args.extend(view.arguments.iter().map(|argument| Arg { + name: argument.name.clone(), + array: argument.array, + })); + Definition { + args, + opts: view.options.iter().map(option_from).collect(), + } +} + +#[derive(Clone, Debug, PartialEq)] +enum OptValue { + Str(String), + Bool(bool), + Null, + List(Vec>), +} + +#[derive(Default)] +struct Parsed { + /// Bound arguments in binding order, with their values. + arguments: Vec<(String, Vec, bool)>, + options: HashMap, +} + +impl Parsed { + fn argument(&self, name: &str) -> Option<&(String, Vec, bool)> { + self.arguments.iter().find(|(bound, ..)| bound == name) + } +} + +struct Parser<'a> { + definition: &'a Definition, + parsed: VecDeque, + result: Parsed, +} + +impl Parser<'_> { + fn run(definition: &Definition, tokens: &[String]) -> Parsed { + let mut parser = Parser { + definition, + parsed: tokens.iter().cloned().collect(), + result: Parsed::default(), + }; + let mut parse_options = true; + while let Some(token) = parser.parsed.pop_front() { + // CompletionInput::parseToken suppresses every parse error. + let outcome = if parse_options && token.is_empty() { + parser.argument(&token) + } else if parse_options && token == "--" { + parse_options = false; + Ok(()) + } else if parse_options && token.starts_with("--") { + parser.long_token(&token) + } else if parse_options && token.starts_with('-') && token != "-" { + parser.short_token(&token) + } else { + parser.argument(&token) + }; + let _ = outcome; + } + parser.result + } + + fn argument(&mut self, token: &str) -> Result<(), ()> { + let count = self.result.arguments.len(); + if let Some(argument) = self.definition.args.get(count) { + self.result.arguments.push(( + argument.name.clone(), + vec![token.to_string()], + argument.array, + )); + return Ok(()); + } + if count > 0 { + if let Some(argument) = self + .definition + .args + .get(count - 1) + .filter(|argument| argument.array) + { + if let Some(bound) = self + .result + .arguments + .iter_mut() + .find(|(name, ..)| *name == argument.name) + { + bound.1.push(token.to_string()); + return Ok(()); + } + } + } + Err(()) + } + + fn long_token(&mut self, token: &str) -> Result<(), ()> { + let name = &token[2..]; + match name.split_once('=') { + Some((name, value)) => { + if value.is_empty() { + self.parsed.push_front(String::new()); + } + self.long_option(name, Some(value.to_string())) + } + None => self.long_option(name, None), + } + } + + fn short_token(&mut self, token: &str) -> Result<(), ()> { + let name: Vec = token[1..].chars().collect(); + if name.len() > 1 { + let first = name[0].to_string(); + if self + .definition + .shortcut(&first) + .is_some_and(|option| option.accept_value) + { + return self.short_option(&first, Some(name[1..].iter().collect())); + } + return self.short_option_set(&name); + } + self.short_option(&name.iter().collect::(), None) + } + + fn short_option_set(&mut self, name: &[char]) -> Result<(), ()> { + for (index, character) in name.iter().enumerate() { + let option = self + .definition + .shortcut(&character.to_string()) + .cloned() + .ok_or(())?; + if option.accept_value { + let rest = (index + 1 < name.len()).then(|| name[index + 1..].iter().collect()); + return self.long_option(&option.name, rest); + } + self.long_option(&option.name, None)?; + } + Ok(()) + } + + fn short_option(&mut self, shortcut: &str, value: Option) -> Result<(), ()> { + let option = self.definition.shortcut(shortcut).cloned().ok_or(())?; + self.long_option(&option.name, value) + } + + fn long_option(&mut self, name: &str, mut value: Option) -> Result<(), ()> { + let Some(option) = self.definition.option(name).cloned() else { + let negated = self.definition.negation(name).cloned().ok_or(())?; + if value.is_some() { + return Err(()); + } + self.result + .options + .insert(negated.name, OptValue::Bool(false)); + return Ok(()); + }; + if value.is_some() && !option.accept_value { + return Err(()); + } + if value.as_deref().is_none_or(str::is_empty) && option.accept_value { + if let Some(next) = self.parsed.pop_front() { + if next.is_empty() || !next.starts_with('-') { + value = Some(next); + } else { + self.parsed.push_front(next); + } + } + } + let value = match value { + Some(value) => OptValue::Str(value), + None if option.value_required => return Err(()), + None if !option.array && !option.accept_value => OptValue::Bool(true), + None => OptValue::Null, + }; + if option.array { + let entry = self + .result + .options + .entry(option.name) + .or_insert_with(|| OptValue::List(vec![])); + if let OptValue::List(items) = entry { + items.push(match value { + OptValue::Str(text) => Some(text), + _ => None, + }); + } + } else { + self.result.options.insert(option.name, value); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// CompletionInput + +#[derive(Debug, PartialEq)] +enum CompletionType { + ArgumentValue, + OptionValue, + OptionName, + None, +} + +struct CompletionInput { + /// All tokens, the program name included. + tokens: Vec, + current: usize, + parsed: Parsed, + completion_type: CompletionType, + completion_name: Option, + completion_value: String, +} + +/// `$token[0]` for a possibly empty PHP string. +fn first_char(token: Option<&String>) -> Option { + token.and_then(|token| token.chars().next()) +} + +impl CompletionInput { + fn new(tokens: Vec, current: usize) -> Self { + Self { + tokens, + current, + parsed: Parsed::default(), + completion_type: CompletionType::None, + completion_name: None, + completion_value: String::new(), + } + } + + fn cursor_free(&self) -> Result { + if self.current > self.tokens.len() { + return Err( + "Current index is invalid, it must be the number of input tokens or one more." + .into(), + ); + } + Ok(self.current >= self.tokens.len()) + } + + fn option_from_token<'a>(definition: &'a Definition, token: &str) -> Option<&'a Opt> { + let name = token.trim_start_matches('-'); + if name.is_empty() { + return None; + } + if token.chars().nth(1) == Some('-') { + return definition.option(name); + } + definition.shortcut(&name.chars().next()?.to_string()) + } + + fn bind(&mut self, definition: &Definition) -> Result<(), String> { + self.parsed = Parser::run(definition, self.tokens.get(1..).unwrap_or_default()); + self.completion_name = None; + self.completion_value = String::new(); + let free = self.cursor_free()?; + let relevant = if free { + self.current + .checked_sub(1) + .and_then(|index| self.tokens.get(index)) + } else { + self.tokens.get(self.current) + }; + let relevant = relevant.cloned(); + + if first_char(relevant.as_ref()) == Some('-') { + let token = relevant.clone().unwrap_or_default(); + let (option_token, option_value) = token + .split_once('=') + .map_or((token.as_str(), ""), |(name, value)| (name, value)); + let option = Self::option_from_token(definition, option_token); + if option.is_none() && !free { + self.completion_type = CompletionType::OptionName; + self.completion_value = token.clone(); + return Ok(()); + } + if let Some(option) = option.filter(|option| option.accept_value) { + self.completion_type = CompletionType::OptionValue; + self.completion_name = Some(option.name.clone()); + self.completion_value = if !option_value.is_empty() { + option_value.to_string() + } else if !option_token.starts_with("--") { + option_token.get(2..).unwrap_or_default().to_string() + } else { + String::new() + }; + return Ok(()); + } + } + + let previous = self + .current + .checked_sub(1) + .and_then(|index| self.tokens.get(index)) + .cloned(); + if let Some(previous) = + previous.filter(|token| token.starts_with('-') && !token.trim_matches('-').is_empty()) + { + if let Some(option) = + Self::option_from_token(definition, &previous).filter(|option| option.accept_value) + { + self.completion_type = CompletionType::OptionValue; + self.completion_name = Some(option.name.clone()); + self.completion_value = relevant.unwrap_or_default(); + return Ok(()); + } + } + + self.completion_type = CompletionType::ArgumentValue; + let mut last_name = None; + for argument in &definition.args { + last_name = Some(argument.name.clone()); + let Some((_, values, array)) = self.parsed.argument(&argument.name) else { + break; + }; + self.completion_name = Some(argument.name.clone()); + self.completion_value = if *array { + values.last().cloned().unwrap_or_default() + } else { + values[0].clone() + }; + } + if self.current >= self.tokens.len() { + let name = last_name.unwrap_or_default(); + let bound = self.parsed.argument(&name).is_some(); + let array = definition + .args + .iter() + .any(|argument| argument.name == name && argument.array); + if !bound || array { + self.completion_name = Some(name); + } else { + self.completion_type = CompletionType::None; + self.completion_name = None; + } + self.completion_value = String::new(); + } + Ok(()) + } + + /// `ArgvInput::getFirstArgument()`, over the tokens after the program. + fn first_argument(&self, definition: &Definition) -> Option { + let tokens = self.tokens.get(1..).unwrap_or_default(); + let mut is_option = false; + for (index, token) in tokens.iter().enumerate() { + if token.starts_with('-') { + if token.contains('=') || index + 1 >= tokens.len() { + continue; + } + let name = if token.chars().nth(1) == Some('-') { + token[2..].to_string() + } else { + token.chars().last().map(String::from).unwrap_or_default() + }; + let stored = self.parsed.options.get(&name).or_else(|| { + definition + .shortcut(&name) + .and_then(|option| self.parsed.options.get(&option.name)) + }); + if stored == Some(&OptValue::Str(tokens[index + 1].clone())) { + is_option = true; + } + continue; + } + if is_option { + is_option = false; + continue; + } + return Some(token.clone()); + } + None + } +} + +// --------------------------------------------------------------------------- +// `_complete` + +enum Suggestion<'a> { + Value { value: String, description: String }, + Option(&'a Opt), +} + +fn suggested_values(command: &str, kind: &str, name: &str) -> Vec { + tables()["values"][command][kind][name] + .as_array() + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn render(shell: &str, suggestions: &[Suggestion]) { + let mut lines = Vec::new(); + for suggestion in suggestions { + match suggestion { + Suggestion::Value { value, description } => { + if shell == "bash" || description.is_empty() { + lines.push(value.clone()); + } else { + lines.push(format!("{value}\t{description}")); + } + } + Suggestion::Option(_) => {} + } + } + for suggestion in suggestions { + if let Suggestion::Option(option) = suggestion { + let described = |name: String| { + if shell == "bash" || option.description.is_empty() { + name + } else { + format!("{name}\t{}", option.description) + } + }; + lines.push(described(format!("--{}", option.name))); + if option.negatable { + lines.push(described(format!("--no-{}", option.name))); + } + } + } + let text = lines.join("\n"); + match shell { + "bash" => writeln_out(&text), + "zsh" => write_out(&format!("{text}\n")), + _ => write_out(&text), + } +} + +/// `FILTER_VALIDATE_BOOL` truthiness of `SYMFONY_COMPLETION_DEBUG`. +fn debugging() -> bool { + std::env::var("SYMFONY_COMPLETION_DEBUG") + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "on" | "yes" + ) + }) + .unwrap_or(false) +} + +fn log(lines: &[String]) { + if !debugging() { + return; + } + use std::io::Write; + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(log_path()) + { + let _ = file.write_all((lines.join("\n") + "\n").as_bytes()); + } +} + +pub fn complete(input: &Input) -> Result { + match run_complete(input) { + Ok(code) => Ok(code), + Err(message) => { + log(&["Error!".into(), message]); + Ok(exit::INVALID) + } + } +} + +fn run_complete(input: &Input) -> Result { + // PHP truthiness: an empty string and "0" are false. + let truthy = |name: &str| input.opt_str(name).filter(|value| *value != "0"); + let version = if truthy("symfony").is_some() { + Some("1") + } else { + truthy("api-version") + }; + if let Some(version) = version { + if crate::compat::version::php_version_compare(version, COMPLETION_API_VERSION) < 0 { + let message = format!("Completion script version is not supported (\"{version}\" given, \">={COMPLETION_API_VERSION}\" required)."); + log(&[message.clone()]); + writeln_out(&format!("{message} Install the Symfony completion script again by using the \"completion\" command.")); + return Ok(126); + } + } + let shell = input + .opt_str("shell") + .ok_or("The \"--shell\" option must be set.")?; + if !SHELLS.contains(&shell) { + return Err(format!("Shell completion is not supported for your shell: \"{shell}\" (supported: \"bash\", \"fish\", \"zsh\").")); + } + let current = input + .opt_str("current") + .filter(|value| value.bytes().all(|b| b.is_ascii_digit()) && *value != "0"); + let current: usize = current + .and_then(|value| value.parse().ok()) + .ok_or("The \"--current\" option must be set and it must be an integer.")?; + let tokens: Vec = input + .opt_all("input") + .into_iter() + .map(str::to_string) + .collect(); + + let mut completion = CompletionInput::new(tokens, current); + let application = application_definition(); + completion.bind(&application)?; + let command = completion + .first_argument(&application) + .and_then(|name| crate::lookup::find(&name, 80).ok()) + .and_then(|name| catalog::entry(&name)); + + let mut suggestions: Vec = Vec::new(); + let definition; + match command { + None => { + if completion.completion_type == CompletionType::ArgumentValue + && completion.completion_name.as_deref() == Some("command") + { + for item in tables()["application"].as_array().into_iter().flatten() { + suggestions.push(Suggestion::Value { + value: item["value"].as_str().unwrap_or_default().to_string(), + description: item["description"].as_str().unwrap_or_default().to_string(), + }); + } + } else if completion.completion_type == CompletionType::OptionName { + definition = application; + suggestions.extend(definition.opts.iter().map(Suggestion::Option)); + } + } + Some(entry) => { + definition = command_definition(entry); + completion.bind(&definition)?; + if completion.completion_type == CompletionType::ArgumentValue + && completion.completion_name.as_deref() == Some("command") + { + let mut names: Vec = std::iter::once(entry.name.clone()) + .chain(entry.aliases.iter().cloned()) + .filter(|name| !name.is_empty()) + .collect(); + if let Some(name) = names + .iter() + .find(|name| name.starts_with(&completion.completion_value)) + .cloned() + { + names = vec![name]; + } + suggestions.extend(names.into_iter().map(|value| Suggestion::Value { + value, + description: String::new(), + })); + } else if completion.completion_type == CompletionType::OptionName { + suggestions.extend(definition.opts.iter().map(Suggestion::Option)); + } else { + let name = completion.completion_name.clone().unwrap_or_default(); + let values = match completion.completion_type { + CompletionType::OptionValue if definition.option(&name).is_some() => { + suggested_values(&entry.name, "options", &name) + } + CompletionType::ArgumentValue + if definition.args.iter().any(|argument| argument.name == name) => + { + suggested_values(&entry.name, "arguments", &name) + } + _ => vec![], + }; + suggestions.extend(values.into_iter().map(|value| Suggestion::Value { + value, + description: String::new(), + })); + } + } + } + render(shell, &suggestions); + Ok(exit::SUCCESS) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bound(tokens: &[&str], current: usize, definition: &Definition) -> CompletionInput { + let mut input = CompletionInput::new( + tokens.iter().map(|token| token.to_string()).collect(), + current, + ); + input.bind(definition).unwrap(); + input + } + + #[test] + fn completion_types_follow_symfony() { + let upgrade = command_definition(catalog::entry("upgrade").unwrap()); + let value = bound(&["dw", "upgrade", "--output="], 2, &upgrade); + assert_eq!( + (value.completion_type, value.completion_name.as_deref()), + (CompletionType::OptionValue, Some("output")) + ); + let separate = bound(&["dw", "upgrade", "--output"], 3, &upgrade); + assert_eq!(separate.completion_type, CompletionType::OptionValue); + let names = bound(&["dw", "upgrade", "--ou"], 2, &upgrade); + assert_eq!( + (names.completion_type, names.completion_value.as_str()), + (CompletionType::OptionName, "--ou") + ); + let done = bound(&["dw", "upgrade"], 2, &upgrade); + assert_eq!(done.completion_type, CompletionType::None); + let first = bound(&["dw"], 1, &application_definition()); + assert_eq!( + (first.completion_type, first.completion_name.as_deref()), + (CompletionType::ArgumentValue, Some("command")) + ); + } + + #[test] + fn the_first_argument_skips_option_values() { + let application = application_definition(); + let input = bound(&["dw", "-v", "upgrade", "--tag", "x"], 5, &application); + assert_eq!( + input.first_argument(&application).as_deref(), + Some("upgrade") + ); + assert_eq!( + bound(&["dw", "--ansi"], 2, &application).first_argument(&application), + None + ); + } + + #[test] + fn an_invalid_cursor_is_refused() { + let mut input = CompletionInput::new(vec!["dw".into()], 3); + assert!(input.bind(&application_definition()).is_err()); + } + + #[test] + fn scripts_are_symfony_74() { + for shell in SHELLS { + let text = script(shell).unwrap(); + assert!( + text.contains("{{ COMMAND_NAME }}") && text.contains("{{ VERSION }}"), + "{shell}" + ); + } + assert_eq!(basename("/usr/local/bin/dw"), "dw"); + assert_eq!(basename("/bin/zsh/"), "zsh"); + assert_eq!(basename(""), ""); + } +} diff --git a/rust/src/commands/mod.rs b/rust/src/commands/mod.rs index 08840f1..212fdf3 100644 --- a/rust/src/commands/mod.rs +++ b/rust/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod completion; pub mod env; pub mod info; pub mod schema; diff --git a/rust/src/compat.rs b/rust/src/compat.rs index 82703ee..769901a 100644 --- a/rust/src/compat.rs +++ b/rust/src/compat.rs @@ -11,7 +11,7 @@ mod external_task; mod input_envelope; mod request_contract; -mod version; +pub mod version; pub use request_contract::RequestContract; diff --git a/rust/src/completion/LICENSE-symfony b/rust/src/completion/LICENSE-symfony new file mode 100644 index 0000000..0138f8f --- /dev/null +++ b/rust/src/completion/LICENSE-symfony @@ -0,0 +1,19 @@ +Copyright (c) 2004-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/rust/src/completion/completion.bash b/rust/src/completion/completion.bash new file mode 100644 index 0000000..2befe76 --- /dev/null +++ b/rust/src/completion/completion.bash @@ -0,0 +1,94 @@ +# This file is part of the Symfony package. +# +# (c) Fabien Potencier +# +# For the full copyright and license information, please view +# https://symfony.com/doc/current/contributing/code/license.html + +_sf_{{ COMMAND_NAME }}() { + + # Use the default completion for shell redirect operators. + for w in '>' '>>' '&>' '<'; do + if [[ $w = "${COMP_WORDS[COMP_CWORD-1]}" ]]; then + compopt -o filenames + COMPREPLY=($(compgen -f -- "${COMP_WORDS[COMP_CWORD]}")) + return 0 + fi + done + + # Use newline as only separator to allow space in completion values + local IFS=$'\n' + local sf_cmd="${COMP_WORDS[0]}" + + # for an alias, get the real script behind it + sf_cmd_type=$(type -t $sf_cmd) + if [[ $sf_cmd_type == "alias" ]]; then + sf_cmd=$(alias $sf_cmd | sed -E "s/alias $sf_cmd='(.*)'/\1/") + elif [[ $sf_cmd_type == "file" ]]; then + sf_cmd=$(type -p $sf_cmd) + fi + + if [[ $sf_cmd_type != "function" && ! -x $sf_cmd ]]; then + return 1 + fi + + local cur prev words cword + _get_comp_words_by_ref -n := cur prev words cword + + local completecmd=("$sf_cmd" "_complete" "--no-interaction" "-sbash" "-c$cword" "-a{{ VERSION }}") + for w in ${words[@]}; do + w="${w//\\\\/\\}" + # remove quotes from typed values + quote="${w:0:1}" + if [ "$quote" == \' ]; then + w="${w%\'}" + w="${w#\'}" + elif [ "$quote" == \" ]; then + w="${w%\"}" + w="${w#\"}" + fi + # empty values are ignored + if [ ! -z "$w" ]; then + completecmd+=("-i$w") + fi + done + + local sfcomplete + if sfcomplete=$(${completecmd[@]} 2>&1); then + local quote suggestions + quote=${cur:0:1} + + # Use single quotes by default if suggestions contains backslash (FQCN) + if [ "$quote" == '' ] && [[ "$sfcomplete" =~ \\ ]]; then + quote=\' + fi + + if [ "$quote" == \' ]; then + # single quotes: no additional escaping (does not accept ' in values) + suggestions=$(for s in $sfcomplete; do printf $'%q%q%q\n' "$quote" "$s" "$quote"; done) + elif [ "$quote" == \" ]; then + # double quotes: double escaping for \ $ ` " + suggestions=$(for s in $sfcomplete; do + s=${s//\\/\\\\} + s=${s//\$/\\\$} + s=${s//\`/\\\`} + s=${s//\"/\\\"} + printf $'%q%q%q\n' "$quote" "$s" "$quote"; + done) + else + # no quotes: double escaping + suggestions=$(for s in $sfcomplete; do printf $'%q\n' $(printf '%q' "$s"); done) + fi + COMPREPLY=($(IFS=$'\n' compgen -W "$suggestions" -- $(printf -- "%q" "$cur"))) + __ltrim_colon_completions "$cur" + else + if [[ "$sfcomplete" != *"Command \"_complete\" is not defined."* ]]; then + >&2 echo + >&2 echo $sfcomplete + fi + + return 1 + fi +} + +complete -F _sf_{{ COMMAND_NAME }} {{ COMMAND_NAME }} diff --git a/rust/src/completion/completion.fish b/rust/src/completion/completion.fish new file mode 100644 index 0000000..1853dd8 --- /dev/null +++ b/rust/src/completion/completion.fish @@ -0,0 +1,25 @@ +# This file is part of the Symfony package. +# +# (c) Fabien Potencier +# +# For the full copyright and license information, please view +# https://symfony.com/doc/current/contributing/code/license.html + +function _sf_{{ COMMAND_NAME }} + set sf_cmd (commandline -o) + set c (count (commandline -oc)) + + set completecmd "$sf_cmd[1]" "_complete" "--no-interaction" "-sfish" "-a{{ VERSION }}" + + for i in $sf_cmd + if [ $i != "" ] + set completecmd $completecmd "-i$i" + end + end + + set completecmd $completecmd "-c$c" + + $completecmd +end + +complete -c '{{ COMMAND_NAME }}' -a '(_sf_{{ COMMAND_NAME }})' -f diff --git a/rust/src/completion/completion.zsh b/rust/src/completion/completion.zsh new file mode 100644 index 0000000..ff76fe5 --- /dev/null +++ b/rust/src/completion/completion.zsh @@ -0,0 +1,82 @@ +#compdef {{ COMMAND_NAME }} + +# This file is part of the Symfony package. +# +# (c) Fabien Potencier +# +# For the full copyright and license information, please view +# https://symfony.com/doc/current/contributing/code/license.html + +# +# zsh completions for {{ COMMAND_NAME }} +# +# References: +# - https://github.com/spf13/cobra/blob/master/zsh_completions.go +# - https://github.com/symfony/symfony/blob/5.4/src/Symfony/Component/Console/Resources/completion.bash +# +_sf_{{ COMMAND_NAME }}() { + local lastParam flagPrefix requestComp out comp + local -a completions + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CURRENT location, so we need + # to truncate the command-line ($words) up to the $CURRENT location. + # (We cannot use $CURSOR as its value does not work when a command is an alias.) + words=("${=words[1,CURRENT]}") lastParam=${words[-1]} + + # For zsh, when completing a flag with an = (e.g., {{ COMMAND_NAME }} -n=) + # completions must be prefixed with the flag + setopt local_options BASH_REMATCH + if [[ "${lastParam}" =~ '-.*=' ]]; then + # We are dealing with a flag with an = + flagPrefix="-P ${BASH_REMATCH}" + fi + + # Prepare the command to obtain completions + requestComp="${words[0]} ${words[1]} _complete --no-interaction -szsh -a{{ VERSION }} -c$((CURRENT-1))" i="" + for w in ${words[@]}; do + w=$(printf -- '%b' "$w") + # remove quotes from typed values + quote="${w:0:1}" + if [ "$quote" = \' ]; then + w="${w%\'}" + w="${w#\'}" + elif [ "$quote" = \" ]; then + w="${w%\"}" + w="${w#\"}" + fi + # empty values are ignored + if [ ! -z "$w" ]; then + i="${i}-i${w} " + fi + done + + # Ensure at least 1 input + if [ "${i}" = "" ]; then + requestComp="${requestComp} -i\" \"" + else + requestComp="${requestComp} ${i}" + fi + + # Use eval to handle any environment variables and such + out=$(eval ${requestComp} 2>/dev/null) + + while IFS='\n' read -r comp; do + if [ -n "$comp" ]; then + # If requested, completions are returned with a description. + # The description is preceded by a TAB character. + # For zsh's _describe, we need to use a : instead of a TAB. + # We first need to escape any : as part of the completion itself. + comp=${comp//:/\\:} + local tab=$(printf '\t') + comp=${comp//$tab/:} + completions+=${comp} + fi + done < <(printf "%s\n" "${out[@]}") + + # Let inbuilt _describe handle completions + eval _describe "completions" completions $flagPrefix + return $? +} + +compdef _sf_{{ COMMAND_NAME }} {{ COMMAND_NAME }} diff --git a/rust/src/main.rs b/rust/src/main.rs index 5dd2f30..f5e1ead 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -32,8 +32,10 @@ enum Handler { /// Commands ported so far. Every other catalog command is recognized and /// reported as not yet available. fn handler(name: &str) -> Option { - use commands::{env, schema, server, upgrade}; + use commands::{completion, env, schema, server, upgrade}; Some(match name { + "completion" => Handler::Local(completion::completion), + "_complete" => Handler::Local(completion::complete), "schema:list" => Handler::Local(schema::list), "schema:manifest" => Handler::Local(schema::manifest), "schema:show" => Handler::Local(schema::show), From 9d0a439912cedec62e83f6624270ea05c49cecc8 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:59:43 -0600 Subject: [PATCH 3/5] Refuse shell injection names and protect completion debug logs Address the two Security findings on #93. Keep ordinary dw script output identical, open private logs with no-follow and handle checks, and follow the opened descriptor. Add crafted-name sourcing and unsafe-log sentinel regressions; CI requires Bash, Zsh and Fish. Refs #72. --- .github/workflows/build.yml | 4 + docs/release-notes/2.5.0.md | 1 + rust/src/commands/completion.rs | 53 ++--- rust/src/commands/completion_log.rs | 136 +++++++++++++ rust/src/commands/mod.rs | 1 + rust/tests/completion_security.rs | 287 ++++++++++++++++++++++++++++ 6 files changed, 460 insertions(+), 22 deletions(-) create mode 100644 rust/src/commands/completion_log.rs create mode 100644 rust/tests/completion_security.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2afeff8..4d840c7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -193,7 +193,11 @@ jobs: components: rustfmt, clippy - run: cargo fmt --check - run: cargo clippy --locked --all-targets -- -D warnings + - name: Install completion test shells + run: sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends bash zsh fish - run: cargo test --locked + env: + DW_REQUIRE_COMPLETION_SHELLS: '1' - run: cargo build --locked - name: Set up PHP for the 2.x compatibility vectors uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 diff --git a/docs/release-notes/2.5.0.md b/docs/release-notes/2.5.0.md index b4c72b2..c8a62a9 100644 --- a/docs/release-notes/2.5.0.md +++ b/docs/release-notes/2.5.0.md @@ -62,6 +62,7 @@ Every asset carries a GitHub build-provenance attestation. Linux binaries are st ## Intentional differences from 2.x +- **Shell completion:** generating a script requires an executable name made of ASCII letters, digits, dots, underscores and hyphens, starting with a letter or underscore. Unsafe names are refused before any shell code is emitted. Completion debug logs now use a private `completion` directory alongside the CLI config file, and refuse unsafe log entries instead of following temporary-file links. - **Prompting without a terminal:** 2.x treated a non-terminal stdin as interactive and printed a "Do you want to run … instead?" prompt to stdout before exiting 1. 2.5.0 prompts only on a terminal; otherwise it reports the error on stderr, as 2.x did with `-n`. - **`--format=xml`:** this works in the standalone binary. The 2.x standalone binary failed with a PHP fatal error, because it lacked the DOM extension. The output matches the 2.x PHAR. - **IDs and names in request paths:** these are now percent-encoded. 2.x inserted workflow IDs, signal, query and update names and similar values into the URL unencoded, so a value containing `/`, `?`, `#`, `%` or a space reached the wrong route. Values made only of letters, digits and `-._~` are sent unchanged. diff --git a/rust/src/commands/completion.rs b/rust/src/commands/completion.rs index 1b11a0c..4ffb6fc 100644 --- a/rust/src/commands/completion.rs +++ b/rust/src/commands/completion.rs @@ -8,6 +8,7 @@ //! list, recorded from the 2.x CLI in `completion.json` //! (`parity/update-completion.sh`). +use super::completion_log; use crate::catalog; use crate::cli::Input; use crate::descriptor::{CommandView, OptionView, APPLICATION_OPTIONS}; @@ -55,23 +56,30 @@ fn basename(path: &str) -> String { .to_string() } -fn log_path() -> std::path::PathBuf { - std::env::temp_dir().join(format!("sf_{}.log", command_name())) +fn safe_command_name() -> Result { + let name = command_name(); + let mut bytes = name.bytes(); + if !bytes + .next() + .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_') + || !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) + { + return Err(CliError::Invalid("Cannot generate shell completion for this executable name. Invoke the binary as dw, or use ASCII letters, digits, dots, underscores and hyphens starting with a letter or underscore.".into())); + } + Ok(name) } // --------------------------------------------------------------------------- // `completion` pub fn completion(input: &Input) -> Result { + let name = safe_command_name()?; if input.flag("debug") { - let path = log_path(); - if !path.exists() { - let _ = std::fs::write(&path, ""); - } - let _ = std::process::Command::new("tail") - .arg("-f") - .arg(&path) - .status(); + let file = completion_log::open(&name) + .map_err(|_| CliError::Invalid(completion_log::ERROR.into()))?; + completion_log::follow(file).map_err(|_| { + CliError::Failure("Unable to follow the shell completion debug log.".into()) + })?; return Ok(exit::SUCCESS); } let shell = match input.arg("shell") { @@ -93,7 +101,7 @@ pub fn completion(input: &Input) -> Result { }; write_out( &text - .replace("{{ COMMAND_NAME }}", &command_name()) + .replace("{{ COMMAND_NAME }}", &name) .replace("{{ VERSION }}", COMPLETION_API_VERSION), ); Ok(exit::SUCCESS) @@ -634,31 +642,32 @@ fn debugging() -> bool { .unwrap_or(false) } -fn log(lines: &[String]) { +fn log(lines: &[String]) -> Result<(), String> { if !debugging() { - return; + return Ok(()); } use std::io::Write; - if let Ok(mut file) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(log_path()) - { - let _ = file.write_all((lines.join("\n") + "\n").as_bytes()); - } + let name = safe_command_name().map_err(|_| completion_log::ERROR.to_string())?; + let mut file = completion_log::open(&name).map_err(|_| completion_log::ERROR.to_string())?; + file.write_all((lines.join("\n") + "\n").as_bytes()) + .map_err(|_| completion_log::ERROR.to_string()) } pub fn complete(input: &Input) -> Result { match run_complete(input) { Ok(code) => Ok(code), Err(message) => { - log(&["Error!".into(), message]); + let _ = log(&["Error!".into(), message]); Ok(exit::INVALID) } } } fn run_complete(input: &Input) -> Result { + if debugging() { + let name = safe_command_name().map_err(|_| completion_log::ERROR.to_string())?; + completion_log::open(&name).map_err(|_| completion_log::ERROR.to_string())?; + } // PHP truthiness: an empty string and "0" are false. let truthy = |name: &str| input.opt_str(name).filter(|value| *value != "0"); let version = if truthy("symfony").is_some() { @@ -669,7 +678,7 @@ fn run_complete(input: &Input) -> Result { if let Some(version) = version { if crate::compat::version::php_version_compare(version, COMPLETION_API_VERSION) < 0 { let message = format!("Completion script version is not supported (\"{version}\" given, \">={COMPLETION_API_VERSION}\" required)."); - log(&[message.clone()]); + log(&[message.clone()])?; writeln_out(&format!("{message} Install the Symfony completion script again by using the \"completion\" command.")); return Ok(126); } diff --git a/rust/src/commands/completion_log.rs b/rust/src/commands/completion_log.rs new file mode 100644 index 0000000..ad98df9 --- /dev/null +++ b/rust/src/commands/completion_log.rs @@ -0,0 +1,136 @@ +//! Completion debug logs live in private state and are read through the +//! opened handle. Neither creation nor following may traverse a log symlink. + +use std::fs::{File, OpenOptions}; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::path::PathBuf; + +pub const ERROR: &str = "Cannot open the private shell completion debug log safely."; + +fn directory() -> io::Result { + let config = crate::config::default_path().map_err(|_| io::ErrorKind::NotFound)?; + let path = config + .parent() + .ok_or(io::ErrorKind::NotFound)? + .join("completion"); + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(&path)?; + Ok(path) +} + +#[cfg(unix)] +pub fn open(name: &str) -> io::Result { + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + + let dir = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY | libc::O_CLOEXEC) + .open(directory()?)?; + let metadata = dir.metadata()?; + // O_NOFOLLOW protects the directory entry; owner/mode checks protect + // against a pre-created shared directory. Use openat on this same fd + // so replacing the path afterwards cannot redirect the file open. + if metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o077 != 0 { + return Err(io::ErrorKind::PermissionDenied.into()); + } + let filename = + CString::new(format!("sf_{name}.log")).map_err(|_| io::ErrorKind::InvalidInput)?; + let fd = unsafe { + libc::openat( + dir.as_raw_fd(), + filename.as_ptr(), + libc::O_RDWR + | libc::O_CREAT + | libc::O_APPEND + | libc::O_NOFOLLOW + | libc::O_NONBLOCK + | libc::O_CLOEXEC, + 0o600, + ) + }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + let file = unsafe { File::from_raw_fd(fd) }; + let metadata = file.metadata()?; + // Nonblocking open avoids a FIFO wait before fstat. Reject hard links, + // devices, shared files and a file belonging to another account. + if !metadata.is_file() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o077 != 0 + || metadata.nlink() != 1 + { + return Err(io::ErrorKind::PermissionDenied.into()); + } + Ok(file) +} + +#[cfg(windows)] +pub fn open(name: &str) -> io::Result { + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + // Opening the reparse point itself lets us refuse it without writing + // through it. Default Windows ACLs inherit from the user's config home. + let file = OpenOptions::new() + .read(true) + .append(true) + .create(true) + .custom_flags(0x0020_0000) // FILE_FLAG_OPEN_REPARSE_POINT + .open(directory()?.join(format!("sf_{name}.log")))?; + let metadata = file.metadata()?; + if !metadata.is_file() || metadata.file_attributes() & 0x400 != 0 { + return Err(io::ErrorKind::PermissionDenied.into()); + } + Ok(file) +} + +#[cfg(not(any(unix, windows)))] +pub fn open(_name: &str) -> io::Result { + Err(io::ErrorKind::Unsupported.into()) +} + +/// Tail the last ten lines and follow this descriptor, rather than +/// invoking a separate program that reopens the log by its path. +pub fn follow(mut file: File) -> io::Result<()> { + let end = file.seek(SeekFrom::End(0))?; + let mut start = end; + let mut tail = Vec::new(); + while start > 0 && tail.iter().filter(|byte| **byte == b'\n').count() <= 10 { + let count = start.min(8192); + start -= count; + file.seek(SeekFrom::Start(start))?; + let mut chunk = vec![0; count as usize]; + file.read_exact(&mut chunk)?; + chunk.extend(tail); + tail = chunk; + } + let skip = tail + .iter() + .rev() + .enumerate() + .filter(|(_, byte)| **byte == b'\n') + .nth(10) + .map(|(offset, _)| tail.len() - offset) + .unwrap_or(0); + let mut stdout = io::stdout().lock(); + stdout.write_all(&tail[skip..])?; + stdout.flush()?; + file.seek(SeekFrom::Start(end))?; + let mut buffer = [0; 8192]; + loop { + match file.read(&mut buffer)? { + 0 => std::thread::sleep(std::time::Duration::from_millis(100)), + count => { + stdout.write_all(&buffer[..count])?; + stdout.flush()?; + } + } + } +} diff --git a/rust/src/commands/mod.rs b/rust/src/commands/mod.rs index 212fdf3..4963d07 100644 --- a/rust/src/commands/mod.rs +++ b/rust/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod completion; +mod completion_log; pub mod env; pub mod info; pub mod schema; diff --git a/rust/tests/completion_security.rs b/rust/tests/completion_security.rs new file mode 100644 index 0000000..ff24ae1 --- /dev/null +++ b/rust/tests/completion_security.rs @@ -0,0 +1,287 @@ +#![cfg(unix)] + +use std::fs; +use std::io::Read; +use std::os::unix::fs::{symlink, DirBuilderExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +struct Scratch(PathBuf); + +impl Scratch { + fn new() -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = + std::env::temp_dir().join(format!("dw-completion-test-{}-{nonce}", std::process::id())); + fs::DirBuilder::new().mode(0o700).create(&path).unwrap(); + Self(path) + } + + fn command(&self, binary: &Path) -> Command { + let mut command = Command::new(binary); + command + .current_dir(&self.0) + .env_clear() + .env("HOME", &self.0) + .env("TMPDIR", &self.0) + .env("DW_CONFIG_HOME", self.0.join("config")) + .env("PATH", std::env::var_os("PATH").unwrap_or_default()); + command + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn binary() -> PathBuf { + std::env::var_os("DW_COMPLETION_TEST_BINARY") + .map(PathBuf::from) + .unwrap_or_else(|| env!("CARGO_BIN_EXE_dw").into()) +} + +fn bounded(mut command: Command) -> Output { + let mut child = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while child.try_wait().unwrap().is_none() { + if Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("completion command exceeded the test deadline"); + } + std::thread::sleep(Duration::from_millis(10)); + } + child.wait_with_output().unwrap() +} + +fn shell(shell: &str, scratch: &Scratch, script: &Path) -> Option { + if Command::new(shell).arg("--version").output().is_err() { + assert!( + std::env::var_os("DW_REQUIRE_COMPLETION_SHELLS").is_none(), + "required shell missing: {shell}" + ); + eprintln!("Skipping unavailable {shell}; CI requires all three completion shells"); + return None; + } + let mut command = scratch.command(Path::new(shell)); + match shell { + "bash" => { + command + .args(["--noprofile", "--norc", "-c", "source \"$1\"", "bash"]) + .arg(script); + } + "zsh" => { + command + .args([ + "-f", + "-c", + "autoload -Uz compinit; compinit -D; source \"$1\"", + "zsh", + ]) + .arg(script); + } + "fish" => { + command + .args(["--no-config", "--command", "source $argv[1]"]) + .arg(script); + } + _ => unreachable!(), + } + Some(command) +} + +#[test] +fn crafted_executable_names_cannot_execute_when_completion_is_sourced() { + let scratch = Scratch::new(); + let names = [ + "dw two", + "dw'quote", + "dw\"quote", + "dw\nprintf touched > marker\n#", + "dw; printf touched > marker;#", + "dw$(printf touched > marker)", + "dw`printf touched > marker`", + "-dw", + "dw() { :; }; printf touched > marker; _sf_x", + ]; + for name in names { + let link = scratch.0.join(name); + symlink(binary(), &link).unwrap(); + for name in ["bash", "zsh", "fish"] { + let mut generate = scratch.command(&link); + generate.args(["completion", name]); + let output = bounded(generate); + let script = scratch.0.join("generated-completion"); + fs::write(&script, &output.stdout).unwrap(); + if let Some(command) = shell(name, &scratch, &script) { + bounded(command); + } + assert!( + !scratch.0.join("marker").exists(), + "generated code executed a marker command" + ); + assert_eq!( + output.status.code(), + Some(2), + "unsafe executable name must be refused" + ); + assert!( + output.stdout.is_empty(), + "refusal must emit no shell program" + ); + } + } +} + +#[test] +fn safe_executable_names_still_generate_working_shell_syntax() { + let scratch = Scratch::new(); + for command_name in ["dw", "dw.exe", "dw-cli", "dw_2"] { + let link = scratch.0.join(command_name); + symlink(binary(), &link).unwrap(); + for name in ["bash", "zsh", "fish"] { + let mut generate = scratch.command(&link); + generate.args(["completion", name]); + let output = bounded(generate); + assert!(output.status.success()); + let script = scratch.0.join("generated-completion"); + fs::write(&script, &output.stdout).unwrap(); + if let Some(command) = shell(name, &scratch, &script) { + assert!( + bounded(command).status.success(), + "safe completion script failed to source" + ); + } + } + } +} + +fn debug_command(scratch: &Scratch, tail: bool) -> Command { + let link = scratch.0.join("dw"); + if !link.exists() { + symlink(binary(), &link).unwrap(); + } + let mut command = scratch.command(&link); + if tail { + command.args(["completion", "--debug"]); + } else { + command + .args(["_complete", "-a0.9"]) + .env("SYMFONY_COMPLETION_DEBUG", "1"); + } + command +} + +#[test] +fn debug_log_refuses_symlinks_hard_links_and_nonregular_entries() { + for kind in [ + "symlink", + "hardlink", + "fifo", + "directory", + "shared-file", + "directory-symlink", + "shared-directory", + ] { + let scratch = Scratch::new(); + let directory = scratch.0.join("config/completion"); + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&directory) + .unwrap(); + let path = directory.join("sf_dw.log"); + let sentinel = scratch.0.join("sentinel"); + fs::write(&sentinel, "unchanged sentinel\n").unwrap(); + fs::set_permissions(&sentinel, fs::Permissions::from_mode(0o600)).unwrap(); + match kind { + "symlink" => symlink(&sentinel, &path).unwrap(), + "hardlink" => fs::hard_link(&sentinel, &path).unwrap(), + "directory" => fs::create_dir(&path).unwrap(), + "fifo" => { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + let path = CString::new(path.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0); + } + "shared-file" => { + fs::write(&path, "shared").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o666)).unwrap(); + } + "directory-symlink" => { + fs::remove_dir(&directory).unwrap(); + symlink(&scratch.0, &directory).unwrap(); + } + "shared-directory" => { + fs::set_permissions(&directory, fs::Permissions::from_mode(0o777)).unwrap() + } + _ => unreachable!(), + } + for tail in [false, true] { + let output = bounded(debug_command(&scratch, tail)); + assert_eq!( + output.status.code(), + Some(2), + "unsafe log entry was not refused: {kind}" + ); + assert!(output.stdout.is_empty()); + assert_eq!( + fs::read_to_string(&sentinel).unwrap(), + "unchanged sentinel\n" + ); + } + } +} + +#[test] +fn debug_tail_follows_the_same_private_log_as_the_completion_writer() { + let scratch = Scratch::new(); + assert_eq!( + bounded(debug_command(&scratch, false)).status.code(), + Some(126) + ); + let path = scratch.0.join("config/completion/sf_dw.log"); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(path.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + let log = fs::read(&path).unwrap(); + let mut child = debug_command(&scratch, true) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdout = child.stdout.take().unwrap(); + let reader = std::thread::spawn(move || { + let mut bytes = vec![0; log.len()]; + stdout.read_exact(&mut bytes).unwrap(); + bytes + }); + // The tail emits the current log immediately. A separate reader avoids + // blocking the test process on a broken follow implementation. + let deadline = Instant::now() + Duration::from_secs(5); + while !reader.is_finished() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + let _ = child.kill(); + let _ = child.wait(); + assert!(reader.is_finished(), "tail failed to read the private log"); + assert_eq!(reader.join().unwrap(), fs::read(&path).unwrap()); +} From f3d3c9ee779d87e7f7cc96c279acf680688f8903 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:23:23 -0600 Subject: [PATCH 4/5] Report completion shell test failures --- rust/tests/completion_security.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/rust/tests/completion_security.rs b/rust/tests/completion_security.rs index ff24ae1..c4f31fe 100644 --- a/rust/tests/completion_security.rs +++ b/rust/tests/completion_security.rs @@ -153,13 +153,23 @@ fn safe_executable_names_still_generate_working_shell_syntax() { let mut generate = scratch.command(&link); generate.args(["completion", name]); let output = bounded(generate); - assert!(output.status.success()); + assert!( + output.status.success(), + "safe completion generation failed: command={command_name:?} shell={name:?} status={:?} stdout={:?} stderr={:?}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); let script = scratch.0.join("generated-completion"); fs::write(&script, &output.stdout).unwrap(); if let Some(command) = shell(name, &scratch, &script) { + let sourced = bounded(command); assert!( - bounded(command).status.success(), - "safe completion script failed to source" + sourced.status.success(), + "safe completion script failed to source: command={command_name:?} shell={name:?} status={:?} stdout={:?} stderr={:?}", + sourced.status.code(), + String::from_utf8_lossy(&sourced.stdout), + String::from_utf8_lossy(&sourced.stderr), ); } } From 5578f02077c2c397d1e55b81d0948d294f680100 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:25:27 -0600 Subject: [PATCH 5/5] Ignore unrelated insecure zsh completion paths in test --- rust/tests/completion_security.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rust/tests/completion_security.rs b/rust/tests/completion_security.rs index c4f31fe..e265a61 100644 --- a/rust/tests/completion_security.rs +++ b/rust/tests/completion_security.rs @@ -85,7 +85,10 @@ fn shell(shell: &str, scratch: &Scratch, script: &Path) -> Option { .args([ "-f", "-c", - "autoload -Uz compinit; compinit -D; source \"$1\"", + // CI images can expose inherited insecure completion directories. + // Ignore those unrelated entries so this test exercises the + // generated script instead of requiring an interactive prompt. + "autoload -Uz compinit; compinit -D -i; source \"$1\"", "zsh", ]) .arg(script);