Skip to content

Commit 7273b60

Browse files
Copilotjamesadevine
andcommitted
refactor: lift PATH_SEGMENT, add null-byte check, fix dead test
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
1 parent 8e1106e commit 7273b60

3 files changed

Lines changed: 83 additions & 47 deletions

File tree

src/tools/create_work_item.rs

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,16 @@
11
//! Create work item reporting schemas
22
33
use log::{debug, info};
4-
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
4+
use percent_encoding::utf8_percent_encode;
55
use schemars::JsonSchema;
66
use serde::{Deserialize, Serialize};
77

8+
use super::PATH_SEGMENT;
89
use crate::tool_result;
910
use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate};
1011
use crate::sanitize::{Sanitize, sanitize as sanitize_text};
1112
use anyhow::{Context, ensure};
1213

13-
/// Characters to percent-encode in a URL path segment.
14-
/// Encodes the structural delimiters that would break URL parsing if left raw:
15-
/// `#` (fragment), `?` (query), `/` (path separator), and space.
16-
/// This hardens operator-controlled values (wiki names, project names, work item
17-
/// types) against accidental corruption of the URL structure.
18-
const PATH_SEGMENT: &AsciiSet = &CONTROLS.add(b'#').add(b'?').add(b'/').add(b' ');
19-
2014
/// Parameters for creating a work item
2115
#[derive(Deserialize, JsonSchema)]
2216
pub struct CreateWorkItemParams {

src/tools/edit_wiki_page.rs

Lines changed: 72 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,15 @@
22
33
use anyhow::{Context, ensure};
44
use log::{debug, info};
5-
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
5+
use percent_encoding::utf8_percent_encode;
66
use schemars::JsonSchema;
77
use serde::{Deserialize, Serialize};
88

9+
use super::PATH_SEGMENT;
910
use crate::sanitize::{Sanitize, sanitize as sanitize_text};
1011
use crate::tool_result;
1112
use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate};
1213

13-
/// Characters to percent-encode in a URL path segment.
14-
/// Encodes the structural delimiters that would break URL parsing if left raw:
15-
/// `#` (fragment), `?` (query), `/` (path separator), and space.
16-
/// This hardens operator-controlled values (wiki names, project names, work item
17-
/// types) against accidental corruption of the URL structure.
18-
const PATH_SEGMENT: &AsciiSet = &CONTROLS.add(b'#').add(b'?').add(b'/').add(b' ');
19-
2014
/// Parameters for editing a wiki page (agent-provided)
2115
#[derive(Deserialize, JsonSchema)]
2216
pub struct EditWikiPageParams {
@@ -35,6 +29,10 @@ pub struct EditWikiPageParams {
3529
impl Validate for EditWikiPageParams {
3630
fn validate(&self) -> anyhow::Result<()> {
3731
ensure!(!self.path.trim().is_empty(), "path must not be empty");
32+
ensure!(
33+
!self.path.contains('\0'),
34+
"path must not contain null bytes"
35+
);
3836
ensure!(
3937
!self.path.contains(".."),
4038
"path must not contain '..': {}",
@@ -181,6 +179,24 @@ fn apply_title_prefix(path: &str, prefix: &str) -> String {
181179
// Stage-2 executor
182180
// ============================================================================
183181

182+
/// Guard that enforces the `create-if-missing` configuration.
183+
///
184+
/// Returns `Some(failure)` when the page does not exist and creation is
185+
/// disabled; returns `None` when the call should proceed normally.
186+
fn check_create_if_missing_guard(
187+
page_exists: bool,
188+
config: &EditWikiPageConfig,
189+
path: &str,
190+
) -> Option<ExecutionResult> {
191+
if !page_exists && !config.create_if_missing {
192+
Some(ExecutionResult::failure(format!(
193+
"Wiki page '{path}' does not exist and create-if-missing is disabled"
194+
)))
195+
} else {
196+
None
197+
}
198+
}
199+
184200
#[async_trait::async_trait]
185201
impl Executor for EditWikiPageResult {
186202
async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result<ExecutionResult> {
@@ -285,10 +301,8 @@ impl Executor for EditWikiPageResult {
285301
None
286302
};
287303

288-
if !page_exists && !config.create_if_missing {
289-
return Ok(ExecutionResult::failure(format!(
290-
"Wiki page '{effective_path}' does not exist and create-if-missing is disabled"
291-
)));
304+
if let Some(err) = check_create_if_missing_guard(page_exists, &config, &effective_path) {
305+
return Ok(err);
292306
}
293307

294308
let comment = self
@@ -448,6 +462,17 @@ mod tests {
448462
assert!(result.is_err());
449463
}
450464

465+
#[test]
466+
fn test_validation_rejects_null_bytes_in_path() {
467+
let params = EditWikiPageParams {
468+
path: "/Page\x00Name".to_string(),
469+
content: "Some valid content here.".to_string(),
470+
comment: None,
471+
};
472+
let result: Result<EditWikiPageResult, _> = params.try_into();
473+
assert!(result.is_err());
474+
}
475+
451476
#[test]
452477
fn test_validation_rejects_short_content() {
453478
let params = EditWikiPageParams {
@@ -569,14 +594,17 @@ wiki-name: "MyProject.wiki"
569594

570595
#[test]
571596
fn test_sanitize_removes_control_chars_from_path() {
597+
// Use \x01 (SOH) — passes validate() but must be stripped by sanitize_fields().
598+
// Null bytes are rejected earlier at the validate() stage (see
599+
// test_validation_rejects_null_bytes_in_path).
572600
let params = EditWikiPageParams {
573-
path: "/Page\x00Name".to_string(),
601+
path: "/Page\x01Name".to_string(),
574602
content: "Some valid content here.".to_string(),
575603
comment: None,
576604
};
577605
let mut result: EditWikiPageResult = params.try_into().unwrap();
578606
result.sanitize_fields();
579-
assert!(!result.path.contains('\x00'));
607+
assert!(!result.path.contains('\x01'));
580608
}
581609

582610
#[test]
@@ -723,33 +751,38 @@ wiki-name: "MyProject.wiki"
723751
}
724752

725753
#[tokio::test]
726-
async fn test_execute_create_if_missing_false_rejected() {
727-
use std::collections::HashMap;
728-
729-
let mut tool_configs = HashMap::new();
730-
tool_configs.insert(
731-
"edit-wiki-page".to_string(),
732-
serde_json::json!({
733-
"wiki-name": "Proj.wiki",
734-
"create-if-missing": false
735-
}),
736-
);
754+
async fn test_execute_create_if_missing_guard_blocks_nonexistent_page() {
755+
let config = EditWikiPageConfig {
756+
wiki_name: Some("Proj.wiki".to_string()),
757+
create_if_missing: false,
758+
..Default::default()
759+
};
760+
let result = check_create_if_missing_guard(false, &config, "/Agent/Page");
761+
assert!(result.is_some());
762+
assert!(!result.unwrap().success);
763+
}
737764

738-
// Simulate a page-not-found scenario by using a non-existent host.
739-
// We inject the context and let reqwest fail — or we can check the
740-
// create-if-missing guard by mocking. Here we test via an HTTP that
741-
// returns 404, but since we cannot spin up a real ADO instance,
742-
// we verify the behavior by testing the path validation logic directly
743-
// using a mock org URL that will be refused by reqwest (no real call).
744-
//
745-
// Instead, test the config parsing and default behavior.
746-
let config: EditWikiPageConfig = serde_json::from_value(
747-
tool_configs["edit-wiki-page"].clone(),
748-
)
749-
.unwrap();
765+
#[tokio::test]
766+
async fn test_execute_create_if_missing_guard_allows_when_enabled() {
767+
let config = EditWikiPageConfig {
768+
wiki_name: Some("Proj.wiki".to_string()),
769+
create_if_missing: true,
770+
..Default::default()
771+
};
772+
let result = check_create_if_missing_guard(false, &config, "/Agent/Page");
773+
assert!(result.is_none());
774+
}
750775

751-
assert!(!config.create_if_missing);
752-
assert_eq!(config.wiki_name.as_deref(), Some("Proj.wiki"));
776+
#[tokio::test]
777+
async fn test_execute_create_if_missing_guard_allows_existing_page() {
778+
// Even when create-if-missing is false, an existing page should be allowed.
779+
let config = EditWikiPageConfig {
780+
wiki_name: Some("Proj.wiki".to_string()),
781+
create_if_missing: false,
782+
..Default::default()
783+
};
784+
let result = check_create_if_missing_guard(true, &config, "/Agent/Page");
785+
assert!(result.is_none());
753786
}
754787

755788
// ── URL encoding ──────────────────────────────────────────────────────────

src/tools/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
//! Tool parameter and result structs for MCP tools
22
3+
use percent_encoding::{AsciiSet, CONTROLS};
4+
5+
/// Characters to percent-encode in a URL path segment.
6+
/// Encodes the structural delimiters that would break URL parsing if left raw:
7+
/// `#` (fragment), `?` (query), `/` (path separator), and space.
8+
/// This hardens operator-controlled values (project names, wiki names, work item
9+
/// types) against accidental corruption of the URL structure.
10+
pub(crate) const PATH_SEGMENT: &AsciiSet = &CONTROLS.add(b'#').add(b'?').add(b'/').add(b' ');
11+
312
mod create_pr;
413
mod create_work_item;
514
mod edit_wiki_page;

0 commit comments

Comments
 (0)