22
33use anyhow:: { Context , ensure} ;
44use log:: { debug, info} ;
5- use percent_encoding:: { utf8_percent_encode, AsciiSet , CONTROLS } ;
5+ use percent_encoding:: utf8_percent_encode;
66use schemars:: JsonSchema ;
77use serde:: { Deserialize , Serialize } ;
88
9+ use super :: PATH_SEGMENT ;
910use crate :: sanitize:: { Sanitize , sanitize as sanitize_text} ;
1011use crate :: tool_result;
1112use 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 ) ]
2216pub struct EditWikiPageParams {
@@ -35,6 +29,10 @@ pub struct EditWikiPageParams {
3529impl 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]
185201impl 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\x00 Name" . 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\x00 Name " . to_string ( ) ,
601+ path : "/Page\x01 Name " . 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 ──────────────────────────────────────────────────────────
0 commit comments