Skip to content

Commit 08f8447

Browse files
authored
fix(secrets): address Rust PR review feedback on #624
- `is_direct_match`: return `false` (not `markers.is_empty()`) for the marker-less case. The previous code returned `true` for 0 markers, which would misclassify a non-ado-aw definition as `Direct` if any future caller hit that path. Belt-and-braces — the current callsite in `classify_definition` already guards against it. - `discovered_to_matched`: doc-comment claimed `UnknownRequiredParams` was propagated; implementation drops it. Keep the safer drop behaviour (we can't act on a marker-less definition) but surface a `warn!` summary from `resolve_definitions_via_discovery` so `secrets set --all-repos` operators can see when pipelines were skipped because of required-template-parameters / 403 / other preview failures. Doc comment updated to match. - `AdoContext::repo_url`: percent-encode `project` and `repo_name` so `DiscoveryScope::CurrentRepo` works for projects whose names contain reserved characters (e.g. spaces). The lowercase normalize step can't reconcile a decoded local name with the encoded form ADO returns in `repository.url`. - `AdoAwMarkerExtension`: bash-quote-escape the source path embedded in the runtime `echo` line. A markdown filename containing `'` (e.g. `agents/foo's.md`) would otherwise produce syntactically broken bash. New `bash_single_quote_escape` helper applies the canonical `'\''` idiom; the JSON marker line keeps the raw value because JSON has no quoting concern with `'`. Two new tests cover the idiom and a `foo's-agent.md` path. - `src/detect.rs`: drop the now-stale `#[allow(dead_code)]` attrs on `MARKER_STEP_PREFIX`, `MarkerMetadata`, and `parse_marker_step`. All three are actively consumed by `src/ado/discovery.rs`. - `resolve_for_command`: thread local-lock-file paths into discovery so the `process.yamlFilename` fast-path can skip Preview calls for locally-compiled pipelines. Best-effort scan — failures fall back to Preview-for-everything cleanly. All 1741 tests pass; clippy clean on touched files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
1 parent bca3f40 commit 08f8447

4 files changed

Lines changed: 149 additions & 22 deletions

File tree

src/ado/discovery.rs

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -399,11 +399,21 @@ async fn classify_definition(
399399
/// markdown referenced by the marker has the same stem as the root
400400
/// YAML, the definition is the direct owner. Anything else is a
401401
/// consumer that pulls the template in via `template:` indirection.
402+
///
403+
/// Returns `false` for the marker-less case — `classify_definition`
404+
/// only routes here when at least one marker was found, but defensive
405+
/// against future callers.
402406
fn is_direct_match(def: &DefinitionSummary, markers: &[MarkerMetadata]) -> bool {
403-
if markers.len() != 1 {
407+
if markers.is_empty() {
408+
// 0 markers means "not ado-aw at all", which is neither direct
409+
// nor consumer. Belt-and-braces — `classify_definition`
410+
// currently guards this, but the guard could move.
411+
return false;
412+
}
413+
if markers.len() > 1 {
404414
// Multiple markers means a consumer pulling in more than one
405415
// template; can't be a direct ado-aw pipeline.
406-
return markers.is_empty();
416+
return false;
407417
}
408418
let marker = &markers[0];
409419
let Some(yaml_filename) = def
@@ -459,16 +469,17 @@ async fn parse_local_lock(path: &Path) -> Option<MarkerMetadata> {
459469
/// etc.). This keeps the rest of the codebase unchanged when commands
460470
/// opt into discovery via `--all-repos` / `--source`.
461471
///
462-
/// Returns `None` for non-ado-aw definitions and for failures we don't
463-
/// want to silently surface as matched (forbidden, preview failed).
464-
/// `UnknownRequiredParams` is propagated *with* a marker-less
465-
/// MatchedDefinition because the user explicitly opted in and may
466-
/// want to act on it.
472+
/// Returns `None` for any classification that isn't safely actionable
473+
/// by a write command. In particular `UnknownRequiredParams`,
474+
/// `UnknownForbidden`, and `PreviewFailed` are dropped because we
475+
/// have no markers to attach (so we can't even tell the user which
476+
/// template a write would affect); `NotAdoAw` is dropped because it
477+
/// isn't ado-aw at all. Callers that want a richer summary (e.g. a
478+
/// future `list --all-repos`) should inspect `DiscoveredPipeline`
479+
/// directly rather than going through this adapter.
467480
pub fn discovered_to_matched(d: &DiscoveredPipeline) -> Option<MatchedDefinition> {
468481
match d.status {
469482
DiscoveryStatus::Direct | DiscoveryStatus::Consumer => {}
470-
// Skip non-actionable classifications. The caller can inspect
471-
// d.status directly for `list`-style reporting if needed.
472483
DiscoveryStatus::NotAdoAw
473484
| DiscoveryStatus::UnknownForbidden
474485
| DiscoveryStatus::UnknownRequiredParams
@@ -497,6 +508,12 @@ pub fn discovered_to_matched(d: &DiscoveredPipeline) -> Option<MatchedDefinition
497508
/// `source_filter` filters discovery results so only definitions whose
498509
/// markers reference that source path are kept. Match is by exact
499510
/// equality on the normalized source string in the marker JSON.
511+
///
512+
/// Definitions whose Preview call failed in a known-recoverable way
513+
/// (`UnknownRequiredParams` / `UnknownForbidden` / `PreviewFailed`) are
514+
/// counted and surfaced as a `warn!` so the operator can see that
515+
/// some pipelines were skipped — silently dropping them would be a
516+
/// nasty surprise for `secrets set --all-repos`.
500517
pub async fn resolve_definitions_via_discovery(
501518
client: &reqwest::Client,
502519
ctx: &AdoContext,
@@ -507,21 +524,57 @@ pub async fn resolve_definitions_via_discovery(
507524
) -> Result<Vec<MatchedDefinition>> {
508525
let discovered = discover_ado_aw_pipelines(client, ctx, auth, scope, local_lock_paths).await?;
509526

527+
let mut skipped_required_params = 0usize;
528+
let mut skipped_forbidden = 0usize;
529+
let mut skipped_failed = 0usize;
530+
510531
let kept: Vec<_> = discovered
511532
.into_iter()
512533
.filter(|d| {
534+
match &d.status {
535+
DiscoveryStatus::UnknownRequiredParams => skipped_required_params += 1,
536+
DiscoveryStatus::UnknownForbidden => skipped_forbidden += 1,
537+
DiscoveryStatus::PreviewFailed(_) => skipped_failed += 1,
538+
_ => {}
539+
}
513540
let Some(src) = source_filter else { return true };
514541
d.markers.iter().any(|m| m.source == src)
515542
})
516543
.collect();
517544

545+
if skipped_required_params > 0 {
546+
warn!(
547+
"Discovery skipped {skipped_required_params} definition(s) whose Pipeline Preview \
548+
requires templateParameters with no defaults. Use --definition-ids to act on them \
549+
directly.",
550+
);
551+
}
552+
if skipped_forbidden > 0 {
553+
warn!(
554+
"Discovery skipped {skipped_forbidden} definition(s) the calling identity lacks \
555+
read access to. Check your PAT or AAD permissions.",
556+
);
557+
}
558+
if skipped_failed > 0 {
559+
warn!(
560+
"Discovery skipped {skipped_failed} definition(s) whose Pipeline Preview returned \
561+
an unexpected error. Re-run with --debug to see details.",
562+
);
563+
}
564+
518565
Ok(kept.iter().filter_map(discovered_to_matched).collect())
519566
}
520567

521568
// AdoContext helper: derive the resolved git remote URL for
522569
// `CurrentRepo` scoping. Lives here (rather than on `AdoContext`)
523570
// because the context only stores org+project+repo_name today;
524571
// reconstructing the URL is a local detail of discovery.
572+
//
573+
// Percent-encodes `project` and `repo_name` to match the form ADO
574+
// returns in `repository.url` — without this, projects whose names
575+
// contain spaces or other reserved chars would silently match nothing
576+
// because the lowercase comparison can't reconcile e.g. `my project`
577+
// with `my%20project`.
525578
impl AdoContext {
526579
fn repo_url(&self) -> Option<String> {
527580
if self.repo_name.is_empty() {
@@ -530,8 +583,8 @@ impl AdoContext {
530583
Some(format!(
531584
"{}/{}/_git/{}",
532585
self.org_url.trim_end_matches('/'),
533-
self.project,
534-
self.repo_name
586+
percent_encoding::utf8_percent_encode(&self.project, super::PATH_SEGMENT),
587+
percent_encoding::utf8_percent_encode(&self.repo_name, super::PATH_SEGMENT),
535588
))
536589
}
537590
}

src/compile/extensions/ado_aw_marker.rs

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,16 +69,19 @@ impl CompilerExtension for AdoAwMarkerExtension {
6969
// the build log at runtime, which is a free human-discoverability
7070
// bonus and costs nothing because the step runs in milliseconds.
7171
//
72-
// Single quotes around the echo argument keep the JSON literal
73-
// intact; the metadata contains no single quotes (JSON strings
74-
// escape them via \u0027 if ever needed).
72+
// The echo uses single quotes to keep the literal intact at
73+
// runtime; we apply the bash `'\''` idiom to any `'` inside the
74+
// source path so a markdown filename like `agents/foo's.md`
75+
// doesn't produce broken bash. `version` and `target` are
76+
// controlled inputs and can't contain `'`.
77+
let echo_source = bash_single_quote_escape(&source);
7578
let step = format!(
7679
"- bash: |\n \
7780
# ado-aw-metadata: {metadata}\n \
78-
echo 'ado-aw metadata: source={source} version={version} target={target}'\n \
81+
echo 'ado-aw metadata: source={echo_source} version={version} target={target}'\n \
7982
displayName: \"ado-aw\"\n",
8083
metadata = metadata_json,
81-
source = source,
84+
echo_source = echo_source,
8285
version = version,
8386
target = target,
8487
);
@@ -87,6 +90,13 @@ impl CompilerExtension for AdoAwMarkerExtension {
8790
}
8891
}
8992

93+
/// Escape any `'` in `s` so it can be safely embedded inside a single-quoted
94+
/// bash string. Replaces each `'` with `'\''` (close-quote, escaped quote,
95+
/// reopen-quote — the canonical idiom).
96+
fn bash_single_quote_escape(s: &str) -> String {
97+
s.replace('\'', "'\\''")
98+
}
99+
90100
#[cfg(test)]
91101
mod tests {
92102
use super::*;
@@ -158,4 +168,43 @@ mod tests {
158168
);
159169
}
160170
}
171+
172+
#[test]
173+
fn bash_single_quote_escape_idiom_is_correct() {
174+
// Standard bash idiom: close-quote, escaped quote, reopen.
175+
assert_eq!(bash_single_quote_escape("a'b"), "a'\\''b");
176+
assert_eq!(bash_single_quote_escape("''"), "'\\'''\\''");
177+
assert_eq!(bash_single_quote_escape("plain"), "plain");
178+
assert_eq!(bash_single_quote_escape(""), "");
179+
}
180+
181+
#[test]
182+
fn echo_line_handles_single_quote_in_source_path() {
183+
// A markdown filename with `'` in it must produce syntactically
184+
// valid bash. Without the escape, the generated step would
185+
// break with "unexpected EOF while looking for matching `''".
186+
let fm = parse_fm("name: t\ndescription: x\n");
187+
let input_path = Path::new("agents/foo's-agent.md");
188+
let ctx = CompileContext {
189+
agent_name: &fm.name,
190+
front_matter: &fm,
191+
ado_context: None,
192+
engine: crate::engine::Engine::Copilot,
193+
compile_dir: None,
194+
input_path: Some(input_path),
195+
};
196+
let steps = AdoAwMarkerExtension.setup_steps(&ctx).unwrap();
197+
assert_eq!(steps.len(), 1);
198+
let step = &steps[0];
199+
assert!(
200+
step.contains("echo 'ado-aw metadata: source=agents/foo'\\''s-agent.md "),
201+
"single-quote in source should be escaped via the '\\'' idiom; got:\n{step}",
202+
);
203+
// The JSON marker line should still carry the raw (un-bash-escaped)
204+
// source — JSON has no quoting concern with `'`.
205+
assert!(
206+
step.contains("\"source\":\"agents/foo's-agent.md\""),
207+
"JSON marker should carry raw source unchanged:\n{step}",
208+
);
209+
}
161210
}

src/detect.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,15 +133,13 @@ async fn try_detect_pipeline(
133133
/// (unlike top-of-file YAML comments) survives ADO Pipeline Preview
134134
/// expansion, so this prefix is the canonical surface project-scope
135135
/// discovery searches for in expanded YAML.
136-
#[allow(dead_code)] // Consumed by upcoming Preview-driven discovery (workstream P).
137136
pub const MARKER_STEP_PREFIX: &str = "# ado-aw-metadata:";
138137

139138
/// Parsed metadata from a `# ado-aw-metadata: {…}` marker step line.
140139
///
141140
/// The schema is forward-compatible: unknown JSON fields are ignored,
142141
/// and missing fields fall through to defaults (empty string / zero).
143142
/// Callers that need a specific field should check it explicitly.
144-
#[allow(dead_code)] // Consumed by upcoming Preview-driven discovery (workstream P).
145143
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
146144
pub struct MarkerMetadata {
147145
/// Schema version; `1` for the initial release.
@@ -172,7 +170,6 @@ pub struct MarkerMetadata {
172170
/// - Raw compiled-on-disk lock-file YAML, or
173171
/// - The `finalYaml` returned by ADO's Pipeline Preview API (which
174172
/// strips top-of-file comments but preserves step bodies).
175-
#[allow(dead_code)] // Consumed by upcoming Preview-driven discovery (workstream P).
176173
pub fn parse_marker_step(yaml: &str) -> Vec<MarkerMetadata> {
177174
let mut results = Vec::new();
178175

src/secrets.rs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,9 +205,37 @@ async fn resolve_for_command(
205205
} else {
206206
DiscoveryScope::CurrentRepo
207207
};
208-
let matched =
209-
resolve_definitions_via_discovery(client, ado_ctx, auth, scope, None, source_filter)
210-
.await?;
208+
209+
// Feed local lock files into discovery so its yamlFilename
210+
// fast-path can skip a Preview call per locally-compiled
211+
// pipeline. Best-effort: scan failures aren't fatal — discovery
212+
// simply falls back to Preview for everything.
213+
let local_lock_paths: Vec<PathBuf> = match crate::detect::detect_pipelines(repo_path).await
214+
{
215+
Ok(detected) => detected.into_iter().map(|p| p.yaml_path).collect(),
216+
Err(e) => {
217+
log::debug!(
218+
"Local-fixture scan failed during discovery ({e}); falling back to Preview \
219+
for every definition."
220+
);
221+
Vec::new()
222+
}
223+
};
224+
let local_lock_slice = if local_lock_paths.is_empty() {
225+
None
226+
} else {
227+
Some(local_lock_paths.as_slice())
228+
};
229+
230+
let matched = resolve_definitions_via_discovery(
231+
client,
232+
ado_ctx,
233+
auth,
234+
scope,
235+
local_lock_slice,
236+
source_filter,
237+
)
238+
.await?;
211239
return Ok(Some(matched));
212240
}
213241

0 commit comments

Comments
 (0)