@@ -66,15 +66,54 @@ def _shared_destination_label(project_path: Path, dest: Path) -> str:
6666 return str (dest )
6767
6868
69- def _ensure_safe_shared_destination (project_path : Path , dest : Path ) -> None :
70- """Refuse shared infra writes that would escape or follow symlinks."""
71- root = project_path .resolve ()
69+ def _shared_relative_path (project_path : Path , dest : Path ) -> Path :
7270 try :
73- dest .parent .resolve ().relative_to (root )
74- except (OSError , ValueError ):
71+ rel = dest .relative_to (project_path )
72+ except ValueError :
73+ label = _shared_destination_label (project_path , dest )
74+ raise ValueError (f"Shared infrastructure path escapes project root: { label } " ) from None
75+
76+ if rel .is_absolute () or ".." in rel .parts :
7577 label = _shared_destination_label (project_path , dest )
76- raise ValueError (f"Shared infrastructure destination escapes project root: { label } " ) from None
78+ raise ValueError (f"Shared infrastructure path escapes project root: { label } " )
79+ return rel
80+
81+
82+ def _ensure_safe_shared_directory (project_path : Path , directory : Path , * , create : bool = True ) -> None :
83+ """Create a shared infra directory without following symlinked parents."""
84+ root = project_path .resolve ()
85+ rel = _shared_relative_path (project_path , directory )
86+ current = project_path
87+
88+ for part in rel .parts :
89+ current = current / part
90+ label = _shared_destination_label (project_path , current )
91+ if current .is_symlink ():
92+ raise ValueError (f"Refusing to use symlinked shared infrastructure directory: { label } " )
93+ if current .exists ():
94+ if not current .is_dir ():
95+ raise ValueError (f"Shared infrastructure directory path is not a directory: { label } " )
96+ try :
97+ current .resolve ().relative_to (root )
98+ except (OSError , ValueError ):
99+ raise ValueError (f"Shared infrastructure directory escapes project root: { label } " ) from None
100+ continue
101+ if not create :
102+ raise ValueError (f"Shared infrastructure directory does not exist: { label } " )
103+ current .mkdir ()
104+ if current .is_symlink ():
105+ raise ValueError (f"Refusing to use symlinked shared infrastructure directory: { label } " )
106+ try :
107+ current .resolve ().relative_to (root )
108+ except (OSError , ValueError ):
109+ raise ValueError (f"Shared infrastructure directory escapes project root: { label } " ) from None
77110
111+
112+ def _ensure_safe_shared_destination (project_path : Path , dest : Path ) -> None :
113+ """Refuse shared infra writes that would escape or follow symlinks."""
114+ root = project_path .resolve ()
115+ _shared_relative_path (project_path , dest )
116+ _ensure_safe_shared_directory (project_path , dest .parent , create = False )
78117 label = _shared_destination_label (project_path , dest )
79118 if dest .is_symlink ():
80119 raise ValueError (f"Refusing to overwrite symlinked shared infrastructure path: { label } " )
@@ -90,10 +129,6 @@ def _write_shared_text(project_path: Path, dest: Path, content: str) -> None:
90129 _write_shared_bytes (project_path , dest , content .encode ("utf-8" ))
91130
92131
93- def _copy_shared_file (project_path : Path , src : Path , dest : Path ) -> None :
94- _write_shared_bytes (project_path , dest , src .read_bytes (), mode = src .stat ().st_mode & 0o777 )
95-
96-
97132def _write_shared_bytes (
98133 project_path : Path ,
99134 dest : Path ,
@@ -134,9 +169,10 @@ def refresh_shared_templates(
134169 tracked_files = manifest .files
135170 modified = set (manifest .check_modified ())
136171 skipped_files : list [str ] = []
172+ planned_updates : list [tuple [Path , str , str ]] = []
137173
138174 dest_templates = project_path / ".specify" / "templates"
139- dest_templates . mkdir ( parents = True , exist_ok = True )
175+ _ensure_safe_shared_directory ( project_path , dest_templates )
140176 for src in templates_src .iterdir ():
141177 if not src .is_file () or src .name == "vscode-settings.json" or src .name .startswith ("." ):
142178 continue
@@ -151,6 +187,9 @@ def refresh_shared_templates(
151187
152188 content = src .read_text (encoding = "utf-8" )
153189 content = IntegrationBase .resolve_command_refs (content , invoke_separator )
190+ planned_updates .append ((dst , rel , content ))
191+
192+ for dst , rel , content in planned_updates :
154193 _write_shared_text (project_path , dst , content )
155194 manifest .record_existing (rel )
156195
@@ -178,16 +217,18 @@ def install_shared_infra(
178217 """Install shared scripts and templates into *project_path*."""
179218 manifest = load_speckit_manifest (project_path , version = version , console = console )
180219 skipped_files : list [str ] = []
220+ planned_copies : list [tuple [Path , str , bytes , int ]] = []
221+ planned_templates : list [tuple [Path , str , str ]] = []
181222
182223 scripts_src = shared_scripts_source (core_pack = core_pack , repo_root = repo_root )
183224 if scripts_src .is_dir ():
184225 dest_scripts = project_path / ".specify" / "scripts"
185- dest_scripts . mkdir ( parents = True , exist_ok = True )
226+ _ensure_safe_shared_directory ( project_path , dest_scripts )
186227 variant_dir = "bash" if script_type == "sh" else "powershell"
187228 variant_src = scripts_src / variant_dir
188229 if variant_src .is_dir ():
189230 dest_variant = dest_scripts / variant_dir
190- dest_variant . mkdir ( parents = True , exist_ok = True )
231+ _ensure_safe_shared_directory ( project_path , dest_variant )
191232 for src_path in variant_src .rglob ("*" ):
192233 if not src_path .is_file ():
193234 continue
@@ -199,15 +240,14 @@ def install_shared_infra(
199240 skipped_files .append (str (dst_path .relative_to (project_path )))
200241 continue
201242
202- dst_path .parent .mkdir (parents = True , exist_ok = True )
203- _copy_shared_file (project_path , src_path , dst_path )
243+ _ensure_safe_shared_directory (project_path , dst_path .parent )
204244 rel = dst_path .relative_to (project_path ).as_posix ()
205- manifest . record_existing ( rel )
245+ planned_copies . append (( dst_path , rel , src_path . read_bytes (), src_path . stat (). st_mode & 0o777 ) )
206246
207247 templates_src = shared_templates_source (core_pack = core_pack , repo_root = repo_root )
208248 if templates_src .is_dir ():
209249 dest_templates = project_path / ".specify" / "templates"
210- dest_templates . mkdir ( parents = True , exist_ok = True )
250+ _ensure_safe_shared_directory ( project_path , dest_templates )
211251 for src in templates_src .iterdir ():
212252 if not src .is_file () or src .name == "vscode-settings.json" or src .name .startswith ("." ):
213253 continue
@@ -220,9 +260,16 @@ def install_shared_infra(
220260
221261 content = src .read_text (encoding = "utf-8" )
222262 content = IntegrationBase .resolve_command_refs (content , invoke_separator )
223- _write_shared_text (project_path , dst , content )
224263 rel = dst .relative_to (project_path ).as_posix ()
225- manifest .record_existing (rel )
264+ planned_templates .append ((dst , rel , content ))
265+
266+ for dst_path , rel , content , mode in planned_copies :
267+ _write_shared_bytes (project_path , dst_path , content , mode = mode )
268+ manifest .record_existing (rel )
269+
270+ for dst , rel , content in planned_templates :
271+ _write_shared_text (project_path , dst , content )
272+ manifest .record_existing (rel )
226273
227274 if skipped_files :
228275 console .print (
0 commit comments