2020 Ok ( opt. filter ( |s| !s. trim ( ) . is_empty ( ) ) )
2121}
2222use std:: collections:: HashMap ;
23- use std:: path:: Path ;
23+ use std:: path:: { Path , PathBuf } ;
2424use std:: sync:: Mutex ;
2525use utoipa:: ToSchema ;
2626
@@ -207,6 +207,21 @@ pub fn validate_provider_id(id: &str) -> Result<()> {
207207 }
208208}
209209
210+ fn custom_provider_file_path ( id : & str ) -> Result < PathBuf > {
211+ if id. is_empty ( )
212+ || id
213+ . chars ( )
214+ . any ( |ch| ch == '/' || ch == '\\' || ch. is_control ( ) )
215+ {
216+ return Err ( anyhow:: anyhow!(
217+ "Invalid provider id: {}" ,
218+ if id. is_empty( ) { "<empty>" } else { id }
219+ ) ) ;
220+ }
221+
222+ Ok ( custom_providers_dir ( ) . join ( format ! ( "{}.json" , id) ) )
223+ }
224+
210225pub fn generate_api_key_name ( id : & str ) -> String {
211226 format ! ( "{}_API_KEY" , id. to_uppercase( ) )
212227}
@@ -299,7 +314,6 @@ pub fn create_custom_provider(
299314}
300315
301316pub fn update_custom_provider ( params : UpdateCustomProviderParams ) -> Result < ( ) > {
302- validate_provider_id ( & params. id ) ?;
303317 let loaded_provider = load_provider ( & params. id ) ?;
304318 let existing_config = loaded_provider. config ;
305319 let editable = loaded_provider. is_editable ;
@@ -359,24 +373,22 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()>
359373 fast_model : existing_config. fast_model . clone ( ) ,
360374 } ;
361375
362- let file_path = custom_providers_dir ( ) . join ( format ! ( "{}.json" , updated_config. name) ) ;
376+ let file_path = custom_provider_file_path ( & updated_config. name ) ? ;
363377 let json_content = serde_json:: to_string_pretty ( & updated_config) ?;
364378 std:: fs:: write ( file_path, json_content) ?;
365379 }
366380 Ok ( ( ) )
367381}
368382
369383pub fn remove_custom_provider ( id : & str ) -> Result < ( ) > {
370- validate_provider_id ( id) ?;
371384 let config = Config :: global ( ) ;
372385 let loaded_provider = load_provider ( id) ?;
373386 let api_key_env = loaded_provider. config . api_key_env ;
374387 if api_key_env == generate_api_key_name ( id) {
375388 let _ = config. delete_secret ( & api_key_env) ;
376389 }
377390
378- let custom_providers_dir = custom_providers_dir ( ) ;
379- let file_path = custom_providers_dir. join ( format ! ( "{}.json" , id) ) ;
391+ let file_path = custom_provider_file_path ( id) ?;
380392
381393 if file_path. exists ( ) {
382394 std:: fs:: remove_file ( file_path) ?;
@@ -386,8 +398,7 @@ pub fn remove_custom_provider(id: &str) -> Result<()> {
386398}
387399
388400pub fn load_provider ( id : & str ) -> Result < LoadedProvider > {
389- validate_provider_id ( id) ?;
390- let custom_file_path = custom_providers_dir ( ) . join ( format ! ( "{}.json" , id) ) ;
401+ let custom_file_path = custom_provider_file_path ( id) ?;
391402
392403 if custom_file_path. exists ( ) {
393404 let content = std:: fs:: read_to_string ( & custom_file_path) ?;
@@ -677,6 +688,79 @@ mod tests {
677688 assert_eq ! ( config. models[ 0 ] . context_limit, 131072 ) ;
678689 }
679690
691+ #[ test]
692+ fn test_validate_provider_id_rejects_legacy_punctuation_for_new_ids ( ) {
693+ assert ! ( validate_provider_id( "custom_z.ai" ) . is_err( ) ) ;
694+ }
695+
696+ fn write_legacy_provider_config ( id : & str , display_name : & str ) {
697+ let custom_dir = custom_providers_dir ( ) ;
698+ std:: fs:: create_dir_all ( & custom_dir) . unwrap ( ) ;
699+ let content = format ! (
700+ r#"{{
701+ "name": "{id}",
702+ "engine": "openai",
703+ "display_name": "{display_name}",
704+ "description": "legacy provider",
705+ "api_key_env": "",
706+ "base_url": "https://example.invalid/v1/chat/completions",
707+ "models": [],
708+ "requires_auth": false
709+ }}"#
710+ ) ;
711+ std:: fs:: write ( custom_dir. join ( format ! ( "{id}.json" ) ) , content) . unwrap ( ) ;
712+ }
713+
714+ #[ test]
715+ fn test_load_provider_allows_legacy_custom_id_with_punctuation ( ) {
716+ let temp_dir = tempfile:: tempdir ( ) . unwrap ( ) ;
717+ let temp_root = temp_dir. path ( ) . display ( ) . to_string ( ) ;
718+ let _guard = env_lock:: lock_env ( [ ( "GOOSE_PATH_ROOT" , Some ( temp_root. as_str ( ) ) ) ] ) ;
719+
720+ write_legacy_provider_config ( "custom_z.ai" , "Z.AI" ) ;
721+
722+ let loaded = load_provider ( "custom_z.ai" ) . unwrap ( ) ;
723+ assert ! ( loaded. is_editable) ;
724+ assert_eq ! ( loaded. config. name, "custom_z.ai" ) ;
725+ }
726+
727+ #[ test]
728+ fn test_update_and_remove_provider_allow_legacy_custom_id_with_punctuation ( ) {
729+ let temp_dir = tempfile:: tempdir ( ) . unwrap ( ) ;
730+ let temp_root = temp_dir. path ( ) . display ( ) . to_string ( ) ;
731+ let _guard = env_lock:: lock_env ( [ ( "GOOSE_PATH_ROOT" , Some ( temp_root. as_str ( ) ) ) ] ) ;
732+
733+ write_legacy_provider_config ( "custom_z.ai" , "Z.AI" ) ;
734+
735+ update_custom_provider ( UpdateCustomProviderParams {
736+ id : "custom_z.ai" . to_string ( ) ,
737+ engine : "openai" . to_string ( ) ,
738+ display_name : "Z.AI Updated" . to_string ( ) ,
739+ api_url : "https://updated.example.invalid/v1/chat/completions" . to_string ( ) ,
740+ api_key : None ,
741+ models : vec ! [ "z-model" . to_string( ) ] ,
742+ supports_streaming : Some ( true ) ,
743+ headers : None ,
744+ requires_auth : false ,
745+ catalog_provider_id : None ,
746+ base_path : None ,
747+ } )
748+ . unwrap ( ) ;
749+
750+ let updated = load_provider ( "custom_z.ai" ) . unwrap ( ) ;
751+ assert_eq ! ( updated. config. display_name, "Z.AI Updated" ) ;
752+ assert_eq ! ( updated. config. models[ 0 ] . name, "z-model" ) ;
753+
754+ remove_custom_provider ( "custom_z.ai" ) . unwrap ( ) ;
755+ assert ! ( !custom_providers_dir( ) . join( "custom_z.ai.json" ) . exists( ) ) ;
756+ }
757+
758+ #[ test]
759+ fn test_load_provider_rejects_path_segments ( ) {
760+ assert ! ( load_provider( "custom_../secret" ) . is_err( ) ) ;
761+ assert ! ( load_provider( "custom_..\\ secret" ) . is_err( ) ) ;
762+ }
763+
680764 #[ test]
681765 fn test_expand_env_vars_replaces_placeholder ( ) {
682766 let _guard = env_lock:: lock_env ( [ ( "TEST_EXPAND_HOST" , Some ( "https://example.com/api" ) ) ] ) ;
0 commit comments