@@ -40,7 +40,7 @@ pub fn list(source: Option<&str>, dest: Option<&Path>) -> Result<SkillList> {
4040 } ;
4141 reject_remote_source ( source) ?;
4242 let source_path = PathBuf :: from ( source) ;
43- let root = skills_root ( & source_path) ;
43+ let root = skills_root ( & source_path) ? ;
4444 let mut skills = Vec :: new ( ) ;
4545
4646 if root. exists ( ) {
@@ -76,15 +76,17 @@ pub fn list(source: Option<&str>, dest: Option<&Path>) -> Result<SkillList> {
7676
7777pub fn install ( source : & str , dest : & Path , id : & str , force : bool ) -> Result < SkillInstall > {
7878 reject_remote_source ( source) ?;
79+ let id_path = skill_id_path ( id) ?;
7980 let source_path = PathBuf :: from ( source) ;
80- let root = skills_root ( & source_path) ;
81- let source_skill = root. join ( id ) ;
81+ let root = skills_root ( & source_path) ? ;
82+ let source_skill = root. join ( & id_path ) ;
8283 let skill_file = source_skill. join ( "SKILL.md" ) ;
8384 if !skill_file. exists ( ) {
8485 anyhow:: bail!( "Skill not found in source: {id}" ) ;
8586 }
87+ ensure_inside_root ( & root, & source_skill, id) ?;
8688
87- let target = dest. join ( id ) ;
89+ let target = dest. join ( & id_path ) ;
8890 if target. exists ( ) {
8991 if force {
9092 fs:: remove_dir_all ( & target) ?;
@@ -103,15 +105,71 @@ pub fn install(source: &str, dest: &Path, id: &str, force: bool) -> Result<Skill
103105 } )
104106}
105107
106- fn skills_root ( source : & Path ) -> PathBuf {
108+ fn skill_id_path ( id : & str ) -> Result < PathBuf > {
109+ let parts = id. split ( '/' ) . collect :: < Vec < _ > > ( ) ;
110+ if parts. len ( ) != 2
111+ || parts
112+ . iter ( )
113+ . any ( |part| part. is_empty ( ) || * part == "." || * part == ".." || part. contains ( '\\' ) )
114+ {
115+ anyhow:: bail!( "Invalid skill id: {id}. Expected <namespace>/<skill>." ) ;
116+ }
117+
118+ Ok ( PathBuf :: from ( parts[ 0 ] ) . join ( parts[ 1 ] ) )
119+ }
120+
121+ fn skills_root ( source : & Path ) -> Result < PathBuf > {
122+ reject_path_symlink ( source) ?;
107123 let nested = source. join ( "skills" ) ;
108- if nested. exists ( ) {
109- nested
110- } else {
111- source. to_path_buf ( )
124+
125+ match fs:: symlink_metadata ( & nested) {
126+ Ok ( metadata) => {
127+ if metadata. file_type ( ) . is_symlink ( ) {
128+ anyhow:: bail!(
129+ "Skill source contains unsupported symlink: {}" ,
130+ nested. display( )
131+ ) ;
132+ }
133+ if metadata. is_dir ( ) {
134+ Ok ( nested)
135+ } else {
136+ Ok ( source. to_path_buf ( ) )
137+ }
138+ }
139+ Err ( error) if error. kind ( ) == std:: io:: ErrorKind :: NotFound => Ok ( source. to_path_buf ( ) ) ,
140+ Err ( error) => Err ( error) . with_context ( || format ! ( "failed to inspect {}" , nested. display( ) ) ) ,
141+ }
142+ }
143+
144+ fn reject_path_symlink ( path : & Path ) -> Result < ( ) > {
145+ match fs:: symlink_metadata ( path) {
146+ Ok ( metadata) if metadata. file_type ( ) . is_symlink ( ) => {
147+ anyhow:: bail!(
148+ "Skill source contains unsupported symlink: {}" ,
149+ path. display( )
150+ ) ;
151+ }
152+ Ok ( _) => Ok ( ( ) ) ,
153+ Err ( error) if error. kind ( ) == std:: io:: ErrorKind :: NotFound => Ok ( ( ) ) ,
154+ Err ( error) => Err ( error) . with_context ( || format ! ( "failed to inspect {}" , path. display( ) ) ) ,
112155 }
113156}
114157
158+ fn ensure_inside_root ( root : & Path , source_skill : & Path , id : & str ) -> Result < ( ) > {
159+ let root = root
160+ . canonicalize ( )
161+ . with_context ( || format ! ( "failed to resolve {}" , root. display( ) ) ) ?;
162+ let source_skill = source_skill
163+ . canonicalize ( )
164+ . with_context ( || format ! ( "failed to resolve {}" , source_skill. display( ) ) ) ?;
165+
166+ if !source_skill. starts_with ( root) {
167+ anyhow:: bail!( "Skill source escapes skills root: {id}" ) ;
168+ }
169+
170+ Ok ( ( ) )
171+ }
172+
115173fn read_dirs ( path : & Path ) -> Result < Vec < PathBuf > > {
116174 let mut dirs = Vec :: new ( ) ;
117175 for entry in fs:: read_dir ( path) ? {
@@ -154,16 +212,44 @@ fn read_description(path: &Path) -> Result<Option<String>> {
154212}
155213
156214fn copy_dir ( source : & Path , target : & Path ) -> Result < ( ) > {
215+ let metadata = fs:: symlink_metadata ( source)
216+ . with_context ( || format ! ( "failed to inspect {}" , source. display( ) ) ) ?;
217+ let file_type = metadata. file_type ( ) ;
218+ if file_type. is_symlink ( ) {
219+ anyhow:: bail!(
220+ "Skill source contains unsupported symlink: {}" ,
221+ source. display( )
222+ ) ;
223+ }
224+ if !file_type. is_dir ( ) {
225+ anyhow:: bail!(
226+ "Skill source contains unsupported file type: {}" ,
227+ source. display( )
228+ ) ;
229+ }
230+
157231 fs:: create_dir_all ( target) ?;
158232 for entry in fs:: read_dir ( source) ? {
159233 let entry = entry?;
160234 let source_path = entry. path ( ) ;
161235 let target_path = target. join ( entry. file_name ( ) ) ;
162- if entry. file_type ( ) ?. is_dir ( ) {
236+ let file_type = entry. file_type ( ) ?;
237+ if file_type. is_symlink ( ) {
238+ anyhow:: bail!(
239+ "Skill source contains unsupported symlink: {}" ,
240+ source_path. display( )
241+ ) ;
242+ }
243+ if file_type. is_dir ( ) {
163244 copy_dir ( & source_path, & target_path) ?;
164- } else {
245+ } else if file_type . is_file ( ) {
165246 fs:: copy ( & source_path, & target_path)
166247 . with_context ( || format ! ( "failed to copy {}" , source_path. display( ) ) ) ?;
248+ } else {
249+ anyhow:: bail!(
250+ "Skill source contains unsupported file type: {}" ,
251+ source_path. display( )
252+ ) ;
167253 }
168254 }
169255 Ok ( ( ) )
0 commit comments