Skip to content

Commit fda942c

Browse files
jamesadevineCopilot
andcommitted
fix(cli): no silent allowOverride downgrade; surface comma hint; harden dry-run
Four follow-ups on PR #602: 1. **`apply_variable_set`: silent `allowOverride` downgrade on secret rotation.** Previously, running `secrets set TOKEN <new>` without `--allow-override` would re-emit the variable with `allowOverride: false`, silently downgrading any variable that was previously created (manually or by another tool) with `allowOverride: true`. The legacy `configure` code in src/configure.rs had explicit preservation logic; the consolidated `apply_variable_set` had lost it. Changed the signature from `allow_override: bool` to `allow_override: Option<bool>`: - `Some(true)` / `Some(false)` — force the flag (CLI `--allow-override` passes `Some(true)`). - `None` — **preserve** existing variable's `allowOverride` when overwriting; default to `false` when creating. `run_set` translates the CLI flag: `--allow-override` → `Some(true)`; absence → `None`. The deprecation alias (`run_set_github_token`) stays at `allow_override: false` on the CLI side, which now maps to `None` (preserve) — restoring parity with the pre-consolidation `configure` body. Help text in `src/main.rs` and `docs/cli.md` updated. Five new unit tests pin the matrix: - `Some(true)` / `Some(false)` / `None` × create/overwrite - Specifically asserts `None` preserves `allowOverride: true` (the silent-downgrade regression guard). 2. **`run.rs::print_queue_plan` silent serialize-failure.** `serde_json::to_string_pretty(&body).unwrap_or_default()` would have printed blank output if serialization ever failed. The value is provably JSON-safe, but defensive code should surface regressions instead of silently swallowing them. Switched to `unwrap_or_else(|e| format!("<serialization error: {e}>"))`. 3. **`run.rs::parse_parameters` opaque comma-in-value error.** When a user writes `--parameters urls=https://a,b`, the error was `Invalid --parameters pair 'b': expected key=value (no '=' found).` — technically accurate but doesn't hint at the comma constraint documented above the function. Added a raw-argument-contains-comma detection branch that produces a self-diagnosable hint: `... Hint: values must not contain commas. The raw argument '...' was split on ',' before the '=' split; use a separate --parameters flag per pair.` 4. **`run.rs::dispatch` deliberate partial-queue + `--wait` behaviour.** When `--wait` is set and some builds fail to queue, the code polls the successfully-queued ones rather than bailing early; `queue_failure` is folded into the final exit code. This is intentional and the only sensible UX, but lacked a comment. Added a multi-paragraph block explaining all three cases (partial queue, zero queued, all queued) and why `poll_until_complete` is called with the partial slice. Not addressed (acknowledged follow-ups, tracked elsewhere): - Sequential `get_latest_build` fanout in `list`/`status`. Already documented inline; tracked as a future improvement. `cargo test` (1572 unit + 14 integration crates, all green) and `cargo clippy --all-targets --all-features` pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6919721 commit fda942c

4 files changed

Lines changed: 138 additions & 13 deletions

File tree

docs/cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg
3838
- `configure` *(deprecated; hidden in --help)* - Alias forwarding to `secrets set GITHUB_TOKEN`. Existing scripts keep working but get a stderr warning. The alias will be removed in the next minor release.
3939

4040
- `secrets set <name> [<value>] [PATH]` - Set a pipeline variable (with `isSecret=true`) on every matched ADO definition. Value resolution: positional `<value>``--value-stdin` (one line) → interactive tty prompt with echo off.
41-
- `--allow-override` - Mark the variable as `allowOverride=true` (default: false).
41+
- `--allow-override` - Force `allowOverride=true` on the set variable. When omitted, `allowOverride` is **preserved** on existing variables (so secret rotation does not silently downgrade an existing `allowOverride=true`) and defaults to `false` for new variables.
4242
- `--value-stdin` - Read the value from a single line on stdin.
4343
- `--dry-run` - Print the planned set without calling the ADO API.
4444
- `--org / --project / --pat` - ADO context overrides (same semantics as the other lifecycle commands).

src/main.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,11 @@ enum SecretsCmd {
4747
project: Option<String>,
4848
#[arg(long, env = "AZURE_DEVOPS_EXT_PAT")]
4949
pat: Option<String>,
50-
/// Mark the variable as `allowOverride=true` (default: false).
50+
/// Force `allowOverride=true` on the set variable. When omitted,
51+
/// `allowOverride` is preserved on existing variables (so secret
52+
/// rotation does not silently downgrade an existing
53+
/// `allowOverride=true`) and defaults to `false` for new
54+
/// variables.
5155
#[arg(long)]
5256
allow_override: bool,
5357
/// Read the value from a single line on stdin. Mutually exclusive

src/run.rs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,28 @@ use crate::detect;
5454
pub fn parse_parameters(values: &[String]) -> Result<serde_json::Map<String, serde_json::Value>> {
5555
let mut out = serde_json::Map::new();
5656
for raw in values {
57+
// The argument-level comma split makes values containing
58+
// commas impossible to express today. Detect the
59+
// ambiguous-fragment case (a comma in the raw argument and
60+
// a fragment with no `=`) and produce a self-diagnosable
61+
// hint instead of the bare "no '=' found" error.
62+
let raw_has_comma = raw.contains(',');
5763
for pair in raw.split(',') {
5864
let pair = pair.trim();
5965
if pair.is_empty() {
6066
continue;
6167
}
6268
let Some((k, v)) = pair.split_once('=') else {
69+
if raw_has_comma {
70+
anyhow::bail!(
71+
"Invalid --parameters pair '{}': expected key=value (no '=' found). \
72+
Hint: values must not contain commas. The raw argument '{}' was \
73+
split on ',' before the '=' split; use a separate --parameters flag \
74+
per pair.",
75+
pair,
76+
raw
77+
);
78+
}
6379
anyhow::bail!(
6480
"Invalid --parameters pair '{}': expected key=value (no '=' found).",
6581
pair
@@ -215,6 +231,22 @@ pub async fn dispatch(opts: RunOptions<'_>) -> Result<()> {
215231
return Ok(());
216232
}
217233

234+
// Deliberate design choice: when `--wait` is set and some builds
235+
// failed to queue, we still poll the successfully-queued ones
236+
// rather than bailing early. Three cases:
237+
//
238+
// - **Partial queue + at-least-one-queued**: `targets` is
239+
// non-empty; the operator wants to know how those builds
240+
// resolve. `queue_failure` is folded into the final exit code
241+
// (non_success below).
242+
// - **Zero queued, queue_failure > 0**: `targets` is empty;
243+
// `poll_until_complete` returns immediately with a default
244+
// `PollOutcome`. We still print the wait summary so the
245+
// operator sees a uniform report shape.
246+
// - **All queued**: the common path, no special handling needed.
247+
//
248+
// The early-exit path for `!opts.wait` above already bails on
249+
// queue_failure, so no further special-casing is required here.
218250
let poll_outcome = poll_until_complete(
219251
&client,
220252
&ado_ctx,
@@ -253,7 +285,16 @@ fn print_queue_plan(
253285
body["templateParameters"] = serde_json::Value::Object(parameters.clone());
254286
}
255287
println!("[dry-run] ▶ would queue: {} (id={})", m.name, m.id);
256-
println!("{}", serde_json::to_string_pretty(&body).unwrap_or_default());
288+
// The body is constructed in-line from primitive types and is
289+
// provably JSON-serializable, so `to_string_pretty` cannot fail
290+
// in practice. Surface any future regression as a visible token
291+
// rather than blank output (which would be invisible in the
292+
// dry-run feedback path).
293+
println!(
294+
"{}",
295+
serde_json::to_string_pretty(&body)
296+
.unwrap_or_else(|e| format!("<serialization error: {e}>"))
297+
);
257298
}
258299

259300
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]

src/secrets.rs

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,23 +48,43 @@ pub fn validate_variable_name(name: &str) -> Result<()> {
4848
}
4949

5050
/// Pure: produce a copy of `definition` with the named variable set
51-
/// to `(value, isSecret=true, allow_override)`. Preserves all other
51+
/// to `(value, isSecret=true, allowOverride)`. Preserves all other
5252
/// keys.
53+
///
54+
/// `allow_override` semantics:
55+
///
56+
/// - `Some(true)` / `Some(false)` — force the flag to the given
57+
/// value (this is what `--allow-override` does, and what the
58+
/// create path uses).
59+
/// - `None` — **preserve** the existing variable's `allowOverride`
60+
/// when overwriting; default to `false` when creating. This
61+
/// matters for secret rotation: running `secrets set TOKEN <new>`
62+
/// without `--allow-override` must not silently downgrade a
63+
/// variable that was previously created with
64+
/// `allowOverride=true`.
5365
pub fn apply_variable_set(
5466
mut definition: serde_json::Value,
5567
name: &str,
5668
value: &str,
57-
allow_override: bool,
69+
allow_override: Option<bool>,
5870
) -> serde_json::Value {
5971
if definition.get("variables").is_none()
6072
|| !definition["variables"].is_object()
6173
{
6274
definition["variables"] = serde_json::json!({});
6375
}
76+
let resolved_override = allow_override.unwrap_or_else(|| {
77+
definition
78+
.get("variables")
79+
.and_then(|vars| vars.get(name))
80+
.and_then(|var| var.get("allowOverride"))
81+
.and_then(|v| v.as_bool())
82+
.unwrap_or(false)
83+
});
6484
definition["variables"][name] = serde_json::json!({
6585
"value": value,
6686
"isSecret": true,
67-
"allowOverride": allow_override,
87+
"allowOverride": resolved_override,
6888
});
6989
definition
7090
}
@@ -197,11 +217,27 @@ pub async fn run_set(opts: SetOptions<'_>) -> Result<()> {
197217

198218
print_matched_summary(&matched);
199219

220+
// Translate the CLI bool flag into the `Option<bool>` shape that
221+
// `apply_variable_set` understands: `--allow-override` forces
222+
// `Some(true)`; its absence means `None` (preserve existing, or
223+
// default to `false` on creation). This is the fix for the
224+
// silent-downgrade bug where rotating a secret would flip an
225+
// existing `allowOverride=true` back to `false`.
226+
let override_action: Option<bool> = if opts.allow_override {
227+
Some(true)
228+
} else {
229+
None
230+
};
231+
200232
if opts.dry_run {
233+
let override_summary = match override_action {
234+
Some(b) => format!("allowOverride={}", b),
235+
None => "preserving existing allowOverride (default false on create)".to_string(),
236+
};
201237
println!(
202-
"[dry-run] Would set '{}' (isSecret=true, allowOverride={}) on {} definition(s).",
238+
"[dry-run] Would set '{}' (isSecret=true, {}) on {} definition(s).",
203239
opts.name,
204-
opts.allow_override,
240+
override_summary,
205241
matched.len()
206242
);
207243
return Ok(());
@@ -217,7 +253,7 @@ pub async fn run_set(opts: SetOptions<'_>) -> Result<()> {
217253
m.id,
218254
opts.name,
219255
&value,
220-
opts.allow_override,
256+
override_action,
221257
)
222258
.await
223259
{
@@ -247,7 +283,7 @@ async fn apply_set_one(
247283
id: u64,
248284
name: &str,
249285
value: &str,
250-
allow_override: bool,
286+
allow_override: Option<bool>,
251287
) -> Result<()> {
252288
let definition = get_definition_full(client, ctx, auth, id).await?;
253289
let updated = apply_variable_set(definition, name, value, allow_override);
@@ -559,7 +595,7 @@ mod tests {
559595
#[test]
560596
fn set_creates_variables_object_when_missing() {
561597
let def = serde_json::json!({ "name": "x" });
562-
let out = apply_variable_set(def, "FOO", "bar", false);
598+
let out = apply_variable_set(def, "FOO", "bar", Some(false));
563599
assert_eq!(out["variables"]["FOO"]["value"], "bar");
564600
assert_eq!(out["variables"]["FOO"]["isSecret"], true);
565601
assert_eq!(out["variables"]["FOO"]["allowOverride"], false);
@@ -570,7 +606,7 @@ mod tests {
570606
let def = serde_json::json!({
571607
"variables": { "OTHER": { "value": "x", "isSecret": true, "allowOverride": false } }
572608
});
573-
let out = apply_variable_set(def, "FOO", "bar", true);
609+
let out = apply_variable_set(def, "FOO", "bar", Some(true));
574610
assert_eq!(out["variables"]["OTHER"]["value"], "x");
575611
assert_eq!(out["variables"]["FOO"]["value"], "bar");
576612
assert_eq!(out["variables"]["FOO"]["allowOverride"], true);
@@ -581,11 +617,55 @@ mod tests {
581617
let def = serde_json::json!({
582618
"variables": { "FOO": { "value": "old", "isSecret": true, "allowOverride": false } }
583619
});
584-
let out = apply_variable_set(def, "FOO", "new", true);
620+
let out = apply_variable_set(def, "FOO", "new", Some(true));
621+
assert_eq!(out["variables"]["FOO"]["value"], "new");
622+
assert_eq!(out["variables"]["FOO"]["allowOverride"], true);
623+
}
624+
625+
// ----- allow_override = None ("preserve") semantics ---------------
626+
627+
/// Rotation case: `secrets set TOKEN <new>` without
628+
/// `--allow-override` must NOT downgrade a variable that was
629+
/// previously created with `allowOverride=true`. This is the
630+
/// silent-downgrade bug guard.
631+
#[test]
632+
fn set_preserves_existing_allow_override_true_when_none() {
633+
let def = serde_json::json!({
634+
"variables": { "FOO": { "value": "old", "isSecret": true, "allowOverride": true } }
635+
});
636+
let out = apply_variable_set(def, "FOO", "new", None);
585637
assert_eq!(out["variables"]["FOO"]["value"], "new");
586638
assert_eq!(out["variables"]["FOO"]["allowOverride"], true);
587639
}
588640

641+
#[test]
642+
fn set_preserves_existing_allow_override_false_when_none() {
643+
let def = serde_json::json!({
644+
"variables": { "FOO": { "value": "old", "isSecret": true, "allowOverride": false } }
645+
});
646+
let out = apply_variable_set(def, "FOO", "new", None);
647+
assert_eq!(out["variables"]["FOO"]["allowOverride"], false);
648+
}
649+
650+
#[test]
651+
fn set_defaults_allow_override_false_on_create_when_none() {
652+
let def = serde_json::json!({ "name": "x" });
653+
let out = apply_variable_set(def, "FOO", "bar", None);
654+
assert_eq!(out["variables"]["FOO"]["allowOverride"], false);
655+
}
656+
657+
#[test]
658+
fn set_some_false_forces_downgrade_explicit() {
659+
// If a caller explicitly passes Some(false), they DO want to
660+
// force the flag back to false (e.g. a future `--no-override`
661+
// flag). Only None preserves.
662+
let def = serde_json::json!({
663+
"variables": { "FOO": { "value": "old", "isSecret": true, "allowOverride": true } }
664+
});
665+
let out = apply_variable_set(def, "FOO", "new", Some(false));
666+
assert_eq!(out["variables"]["FOO"]["allowOverride"], false);
667+
}
668+
589669
// ============ apply_variable_delete ============
590670

591671
#[test]

0 commit comments

Comments
 (0)