diff --git a/docs/release-notes/2.5.0.md b/docs/release-notes/2.5.0.md index c8a62a9..ffa5813 100644 --- a/docs/release-notes/2.5.0.md +++ b/docs/release-notes/2.5.0.md @@ -62,6 +62,8 @@ Every asset carries a GitHub build-provenance attestation. Linux binaries are st ## Intentional differences from 2.x +- **Connection URLs:** runtime base URLs containing user information, queries, fragments or whitespace are refused before a request. Use token options or profile token references for authentication. +- **Doctor output:** human diagnostics show untrusted formatter markup literally and escape terminal controls and embedded line breaks. Machine JSON retains Server data as structurally encoded values. - **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. diff --git a/rust/README.md b/rust/README.md index 40b6a34..2b364d0 100644 --- a/rust/README.md +++ b/rust/README.md @@ -21,6 +21,7 @@ every command group reaches parity. | `env:list`, `env:set`, `env:use`, `env:show`, `env:delete` | Ported | | `server:health`, `server:info` (and `cluster:info`) | Ported | | Compatibility preflight, session and `--version` warnings, exit 8 refusals | Ported | +| `doctor`: redacted connection sources, server metadata, warnings and next steps | Ported | | Workflow, namespace and schedule commands | Next slices | | `--tls-verify=false` against HTTPS | Refused until durable-workflow/sdk-rust#180 | diff --git a/rust/parity/cases.json b/rust/parity/cases.json index abcec34..66256dc 100644 --- a/rust/parity/cases.json +++ b/rust/parity/cases.json @@ -2464,6 +2464,619 @@ "-a0.9", "-idw" ] + }, + { + "name": "doctor JSON", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [] + }, + { + "name": "doctor JSONL", + "argv": [ + "doctor", + "--output=jsonl", + "--server={mock}" + ], + "mock": [] + }, + { + "name": "doctor human", + "argv": [ + "doctor", + "--server={mock}" + ], + "mock": [] + }, + { + "name": "doctor ANSI", + "argv": [ + "doctor", + "--ansi", + "--server={mock}" + ], + "mock": [] + }, + { + "name": "doctor quiet", + "argv": [ + "doctor", + "-q", + "--server={mock}" + ], + "mock": [] + }, + { + "name": "doctor clean with flag token", + "argv": [ + "doctor", + "--token=parity-test-token", + "--output=json", + "--server={mock}" + ], + "mock": [] + }, + { + "name": "doctor environment connection", + "argv": [ + "doctor", + "--output=json" + ], + "mock": [], + "env": { + "DURABLE_WORKFLOW_SERVER_URL": "{mock}", + "DURABLE_WORKFLOW_NAMESPACE": "env-namespace", + "DURABLE_WORKFLOW_AUTH_TOKEN": "parity-test-token", + "DURABLE_WORKFLOW_TLS_VERIFY": "false" + } + }, + { + "name": "doctor flag profile", + "argv": [ + "doctor", + "--env=prod", + "--output=json" + ], + "mock": [], + "env": {}, + "setup": [ + [ + "env:set", + "prod", + "--server={mock}", + "--namespace=profile-ns", + "--token=profile-test-token", + "--tls-verify=false" + ] + ] + }, + { + "name": "doctor selected profile", + "argv": [ + "doctor", + "--output=json" + ], + "mock": [], + "env": {}, + "setup": [ + [ + "env:set", + "prod", + "--server={mock}", + "--namespace=profile-ns", + "--token=profile-test-token", + "--tls-verify=false" + ], + [ + "env:use", + "prod" + ] + ] + }, + { + "name": "doctor DW_ENV profile", + "argv": [ + "doctor", + "--output=json" + ], + "mock": [], + "env": { + "DW_ENV": "prod" + }, + "setup": [ + [ + "env:set", + "prod", + "--server={mock}", + "--namespace=profile-ns", + "--token=profile-test-token", + "--tls-verify=false" + ] + ] + }, + { + "name": "doctor profile env token present", + "argv": [ + "doctor", + "--env=prod", + "--output=json" + ], + "mock": [], + "env": { + "PARITY_DOCTOR_TOKEN": "profile-env-test-token" + }, + "setup": [ + [ + "env:set", + "prod", + "--server={mock}", + "--token-env=PARITY_DOCTOR_TOKEN" + ] + ] + }, + { + "name": "doctor profile env token missing", + "argv": [ + "doctor", + "--env=prod", + "--output=json" + ], + "mock": [], + "env": { + "PARITY_DOCTOR_TOKEN": "" + }, + "setup": [ + [ + "env:set", + "prod", + "--server={mock}", + "--token-env=PARITY_DOCTOR_TOKEN" + ] + ] + }, + { + "name": "doctor flag precedence", + "argv": [ + "doctor", + "--env=prod", + "--namespace=flag-ns", + "--token=flag-test-token", + "--tls-verify=true", + "--output=json" + ], + "mock": [], + "env": { + "DURABLE_WORKFLOW_NAMESPACE": "env-ns", + "DURABLE_WORKFLOW_AUTH_TOKEN": "env-test-token", + "DURABLE_WORKFLOW_TLS_VERIFY": "false" + }, + "setup": [ + [ + "env:set", + "prod", + "--server={mock}", + "--namespace=profile-ns", + "--token=profile-test-token", + "--tls-verify=false" + ] + ] + }, + { + "name": "doctor TLS disabled", + "argv": [ + "doctor", + "--tls-verify=false", + "--output=json", + "--server={mock}" + ], + "mock": [] + }, + { + "name": "doctor incompatible control plane", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [ + [ + [ + "control_plane", + "version" + ], + "3" + ] + ] + }, + { + "name": "doctor incompatible worker protocol", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [ + [ + [ + "worker_protocol", + "version" + ], + "99.0" + ] + ] + }, + { + "name": "doctor missing protocol metadata", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [ + [ + [ + "control_plane" + ], + null + ], + [ + [ + "worker_protocol" + ], + null + ], + [ + [ + "auth_composition_contract" + ], + null + ] + ] + }, + { + "name": "doctor malformed protocol metadata", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [ + [ + [ + "version" + ], + false + ], + [ + [ + "server_id" + ], + {} + ], + [ + [ + "auth_composition_contract" + ], + [] + ], + [ + [ + "control_plane" + ], + [] + ], + [ + [ + "worker_protocol" + ], + { + "version": 7, + "invocable_carrier_contract": { + "version": true, + "scope": { + "task_kinds": { + "x": "activity_task" + } + } + }, + "external_task_input_contract": { + "version": "2x", + "fixtures": { + "unknown": {}, + "activity_task": {} + } + } + } + ] + ] + }, + { + "name": "doctor native published metadata", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [], + "mock_routes": { + "/api/cluster/info": { + "body": { + "server_id": "9b667a0d792b", + "version": "3.0.5", + "implementation": "rust", + "control_plane": { + "version": "2", + "request_contract": { + "schema": "durable-workflow.v2.control-plane-request.contract", + "version": 1, + "operations": { + "list": { + "fields": { + "status": { + "canonical_values": [ + "running", + "completed", + "failed" + ], + "rejected_aliases": { + "cancelled": "failed", + "terminated": "failed", + "pending": "running", + "waiting": "running" + } + } + } + }, + "update": { + "fields": { + "wait_for": { + "canonical_values": [ + "accepted", + "completed" + ] + }, + "wait_timeout_seconds": { + "minimum": 1, + "maximum": 60 + } + } + } + } + } + }, + "worker_protocol": { + "version": "1.20" + }, + "auth_composition_contract": { + "schema": "durable-workflow.v2.auth-composition.contract", + "version": 1, + "scope": "external_execution_carriers", + "unknown_field_policy": "ignore_additive_reject_unknown_required", + "precedence": { + "connection_values": [ + "flag", + "environment", + "selected_profile", + "default" + ], + "profile_selection": [ + "flag_env", + "DW_ENV", + "current_profile", + "default_profile" + ], + "notes": [ + "flags win only for the current invocation", + "environment values are portable carrier inputs and must not be persisted by default", + "selected profile values are persisted configuration, but secret fields should be references", + "defaults are explicit and must appear in effective-config diagnostics" + ] + }, + "canonical_environment": { + "server_url": "DURABLE_WORKFLOW_SERVER_URL", + "namespace": "DURABLE_WORKFLOW_NAMESPACE", + "auth_token": "DURABLE_WORKFLOW_AUTH_TOKEN", + "tls_verify": "DURABLE_WORKFLOW_TLS_VERIFY", + "profile": "DW_ENV" + }, + "auth_material": { + "token": { + "status": "supported", + "transport": "bearer_authorization_header", + "persisted_as": "secret_reference_or_profile_env_reference", + "effective_config_value": "redacted" + }, + "mtls": { + "status": "reserved", + "persisted_as": "certificate_and_key_references", + "effective_config_value": "references_only" + }, + "signed_headers": { + "status": "reserved", + "persisted_as": "key_reference_and_header_allowlist", + "effective_config_value": "references_only" + }, + "extensions": { + "status": "reserved", + "rule": "new auth material types must define redaction, persistence, and source precedence before use" + } + }, + "effective_config": { + "required_fields": [ + "server_url", + "namespace", + "profile", + "auth", + "tls", + "identity" + ], + "source_values": [ + "flag", + "environment", + "selected_profile", + "profile_env", + "default", + "server" + ], + "field_contracts": { + "server_url": { + "value": "normalized_absolute_url", + "source": "one_of_source_values" + }, + "namespace": { + "value": "non_empty_namespace_name", + "source": "one_of_source_values" + }, + "profile": { + "name": "nullable_string", + "source": "flag_env_or_DW_ENV_or_current_profile_or_default_profile" + }, + "auth": { + "type": "token_or_mtls_or_signed_headers_or_none", + "source": "one_of_source_values", + "value": "redacted_or_reference_only" + }, + "tls": { + "verify": "boolean", + "source": "one_of_source_values" + }, + "identity": { + "subject": "nullable_string", + "roles": "list_of_strings", + "source": "server_or_carrier" + } + } + }, + "redaction": { + "never_echo": [ + "bearer_tokens", + "private_keys", + "shared_signature_keys", + "client_certificate_private_key_material", + "raw_authorization_headers" + ], + "allowed_diagnostics": [ + "redacted", + "secret_reference_name", + "environment_variable_name", + "profile_name", + "certificate_reference_name", + "key_reference_name" + ] + }, + "resolution": { + "server_url": "carriers normalize and use the winning absolute URL without appending hidden path defaults", + "namespace": "carriers send the winning namespace explicitly when the protocol supports namespace headers", + "identity": "servers may report authenticated subject and roles; carriers must treat identity as server-asserted when present", + "tls": "carriers must fail closed on invalid TLS unless the winning tls.verify value is false" + } + } + } + } + } + }, + { + "name": "doctor HTTP 401", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [], + "mock_routes": { + "/api/cluster/info": { + "status": 401, + "body": { + "message": "Synthetic diagnostic refusal" + } + } + } + }, + { + "name": "doctor HTTP 403", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [], + "mock_routes": { + "/api/cluster/info": { + "status": 403, + "body": { + "message": "Synthetic diagnostic refusal" + } + } + } + }, + { + "name": "doctor HTTP 404", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [], + "mock_routes": { + "/api/cluster/info": { + "status": 404, + "body": { + "message": "Synthetic diagnostic refusal" + } + } + } + }, + { + "name": "doctor HTTP 422", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [], + "mock_routes": { + "/api/cluster/info": { + "status": 422, + "body": { + "message": "Synthetic diagnostic refusal" + } + } + } + }, + { + "name": "doctor HTTP 500", + "argv": [ + "doctor", + "--output=json", + "--server={mock}" + ], + "mock": [], + "mock_routes": { + "/api/cluster/info": { + "status": 500, + "body": { + "message": "Synthetic diagnostic refusal" + } + } + } + }, + { + "name": "doctor unreachable JSON", + "argv": [ + "doctor", + "--server=http://127.0.0.1:9", + "--output=json" + ] + }, + { + "name": "doctor unreachable human", + "argv": [ + "doctor", + "--server=http://127.0.0.1:9" + ] } ] } diff --git a/rust/parity/parity.py b/rust/parity/parity.py index a6af970..af87486 100755 --- a/rust/parity/parity.py +++ b/rust/parity/parity.py @@ -49,6 +49,7 @@ BUILD_IDENTITY = re.compile(r"\(commit [^,]+, built [^)]+\)") MARKDOWN_TITLE = re.compile(r"^(dw )\n=+$", re.M) TRANSPORT_LINE = re.compile(r"^(Server unreachable|Request timed out): .*$", re.M) +DOCTOR_TRANSPORT_LINE = re.compile(r"^( Error: )(Server unreachable|Request timed out): .*$", re.M) LOOPBACK_PORT = re.compile(r"127\.0\.0\.1:\d+") SERVER_LABEL = re.compile(r"[a-z][a-z0-9-]{0,31}") # Transport errors name the HTTP stack that failed; only the category is @@ -75,11 +76,28 @@ def substitute(text, case): return text -def own_version(binary): - """The bare version a binary reports, which its messages embed.""" +def own_identity(binary): + """Build metadata from this binary, rather than a different release.""" output = subprocess.run([binary, "--version"], capture_output=True, text=True, timeout=60, check=False).stdout - match = re.match(r"dw (\S+) ", output) - return match.group(1) if match else None + match = re.match(r"dw (\S+) \(commit ([^,]+), built ([^)]+)\)", output) + return dict(zip(("version", "commit", "build_date"), match.groups())) if match else {} + + +def normalize_doctor_identity(stdout_json, stdout_text, identity): + """Normalize only metadata that agrees with the binary's --version. + + A wrong diagnostic commit or build date remains a comparison failure. + """ + for key, label in (("commit", "Commit"), ("build_date", "Built")): + if key not in identity: + continue + replacement = f"<{key}>" + stdout_text = re.sub(r"^ " + label + ": " + re.escape(identity[key]) + r"$", + f" {label}: {replacement}", stdout_text, flags=re.M) + for item in stdout_json or []: + if isinstance(item, dict) and isinstance(item.get("dw"), dict) and item["dw"].get(key) == identity[key]: + item["dw"][key] = replacement + return stdout_json, stdout_text def normalize_text(text, config_dir, version=None): @@ -93,6 +111,7 @@ def normalize_text(text, config_dir, version=None): text = MARKDOWN_TITLE.sub(r"\1\n", text) text = TIME.sub("