Repository navigation
Description
Maintainer scope: before v1.0.0
Backward compatibility is out of scope for this issue before the v1.0.0 release. Do not add legacy readers, API aliases, compatibility shims, or migration machinery to preserve earlier behavior. Current-version correctness, refusal semantics, and authentication behavior remain required.
Problem
An empty GRAPHFORGE_OCI_CREDENTIAL is treated as a credential rather than as no credential, and the CLI sends a malformed empty Bearer header on every OCI request.
oci_credential() in crates/graphforge-cli/src/portable_cli.rs:207 is std::env::var("GRAPHFORGE_OCI_CREDENTIAL").ok(). A variable that is exported but empty yields Ok(""), so the function returns Some("") rather than None. Nothing downstream filters it: HttpOciRegistry::new (crates/graphforge-portable-oci/src/lib.rs:304) validates only for \n and \r, and auth_header (:350) matches:
Some(token) if token.contains(':') => Basic base64(token),
Some(token) => Bearer {token},
None => no header,An empty string has no colon, so it takes the Bearer arm and produces the literal header value Bearer with an empty token. That header is attached at six call sites (:380, :418, :441, :473, :499, :526), so it affects publish and pull alike.
Exporting a variable that is unset is an ordinary shell and CI pattern, for example GRAPHFORGE_OCI_CREDENTIAL="${REGISTRY_TOKEN}" where the secret is not configured. The observable result is that an anonymous pull from a public registry, which should succeed with no header, instead sends an empty bearer token and is rejected. The failure surfaces as an authentication error rather than as a missing credential, which points the operator at the wrong cause.
The codebase already takes the opposite position elsewhere: crates/graphforge-search/src/provider_openrouter.rs:324 explicitly treats an empty bearer credential as absent. The OCI path is inconsistent with it.
Two secondary findings
- The control-character check is narrower than its message.
HttpOciRegistry::newrejects only\nand\rbut reports"credential contains control characters". Header injection through CR or LF is genuinely blocked, so this is not a security hole, but a credential containing a tab or other control character passes a check whose message claims otherwise. Either widen the check or narrow the message. - Nothing asserts credential propagation.
cargo mutants --file crates/graphforge-cli/src/portable_cli.rs --package graphforge-clireports 22 mutants with 3 surviving, all atportable_cli.rs:208: replacing the credential withNone, withSome(String::new()), and withSome("xyzzy".into()). No test detects a credential being dropped, emptied, or fabricated. No test in the workspace referencesGRAPHFORGE_OCI_CREDENTIALorauth_header.
Objective
Treat an absent and an empty credential identically, make the validation match its own error message, and assert credential propagation so the three surviving mutants die.
Requirements
- An empty or whitespace-only
GRAPHFORGE_OCI_CREDENTIALproduces no credential and therefore noAuthorizationheader, matching the provider behavior already established elsewhere. - A present credential is unchanged: a value containing a colon stays Basic, any other value stays Bearer.
- The control-character refusal and its message agree. Refusal remains fail-closed and its structured error class is unchanged.
- Credentials never appear in emitted errors, logs, receipts or telemetry.
- Tests assert the propagation contract directly, so a mutant that drops, empties or fabricates a credential fails.
Acceptance Criteria
- An exported but empty
GRAPHFORGE_OCI_CREDENTIALsends noAuthorizationheader, proven by test rather than by inspection. - A whitespace-only credential behaves the same as an empty one.
- A colon-bearing credential still produces Basic, and a plain token still produces Bearer.
- The control-character check and its error message agree, and the refusal keeps its current structured error class.
-
cargo mutants --file crates/graphforge-cli/src/portable_cli.rs --package graphforge-clireports zero surviving mutants atoci_credential, or any survivor is justified individually. - No credential value appears in any error, log, receipt or test output.
- Existing portable OCI publish and pull behavior is otherwise unchanged.
BDD Completion Scenarios
Scenario: An unset secret does not become a credential
Given GRAPHFORGE_OCI_CREDENTIAL is exported with an empty value
When a portable OCI pull runs against a registry that allows anonymous reads
Then no Authorization header is sent
And the pull succeeds rather than failing authentication.
Scenario: A real credential still authenticates
Given GRAPHFORGE_OCI_CREDENTIAL holds a token
When a portable OCI request runs
Then the header is Basic for a colon-bearing value and Bearer otherwise
And the credential never appears in output.
Scenario: A fabricated credential fails its test
Given the credential source is replaced with a constant
When the propagation tests run
Then they fail.
Security And Privacy
Credential values are never logged, echoed, or written to receipts. Tests use synthetic values and must not print them. This issue does not weaken the existing CR and LF refusal, which blocks header injection.
Testing
Unit tests for the credential-to-header mapping across absent, empty, whitespace, plain-token and colon-bearing inputs. Environment-dependent tests must not mutate process-global state under parallel execution; prefer testing the mapping directly over setting variables in-process. Re-run the scoped mutation command and record the score.
Non-Goals
- Adding registry login, credential storage, or a credential helper.
- Changing the Basic and Bearer selection rule for non-empty credentials.
- Introducing a network-dependent integration test against a live registry.
Relationships
Found by mutation testing during #360, in PR #1335. Assigned to M3 core architecture and maintenance, alongside #360 which surfaced it, so it lands before v0.6.0 release preparation. The OCI publish and pull surface itself is owned by M5 hardened interchange; this is a maintenance repair to that surface rather than new interchange scope.
Activity
fix(portable): treat an empty OCI credential as absent (#1336)
fix(portable): treat an empty OCI credential as absent (CurateLabs#1336…