Skip to content

Commit 6e37796

Browse files
committed
Allow node source files in subdirectories
1 parent c4602ec commit 6e37796

2 files changed

Lines changed: 166 additions & 79 deletions

File tree

mkconcore.py

Lines changed: 142 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@
7272
import stat
7373
import copy_with_port_portname
7474
import numpy as np
75-
import shlex # Added for POSIX shell escaping
75+
import shlex # Added for POSIX shell escaping
76+
import posixpath
7677

7778
# input validation helper
7879
def safe_name(value, context, allow_path=False):
@@ -92,6 +93,36 @@ def safe_name(value, context, allow_path=False):
9293
if re.search(pattern, value):
9394
raise ValueError(f"Unsafe {context}: '{value}' contains illegal characters.")
9495
return value
96+
97+
def _normalize_relpath(value):
98+
return value.replace("\\", "/")
99+
100+
def safe_relpath(value, context):
101+
"""
102+
Validates a relative path for node source files.
103+
Allows subdirectories, but blocks traversal, absolute paths, and drive roots.
104+
"""
105+
if not value:
106+
raise ValueError(f"{context} cannot be empty")
107+
normalized = _normalize_relpath(value)
108+
safe_name(normalized, context, allow_path=True)
109+
if normalized.startswith("/") or normalized.startswith("~"):
110+
raise ValueError(f"Unsafe {context}: absolute paths are not allowed.")
111+
if re.match(r"^[A-Za-z]:", normalized):
112+
raise ValueError(f"Unsafe {context}: drive paths are not allowed.")
113+
if ":" in normalized:
114+
raise ValueError(f"Unsafe {context}: ':' is not allowed in relative paths.")
115+
parts = normalized.split("/")
116+
if any(part == "" for part in parts):
117+
raise ValueError(f"Unsafe {context}: empty path segment is not allowed.")
118+
if any(part == ".." for part in parts):
119+
raise ValueError(f"Unsafe {context}: path traversal ('..') is not allowed.")
120+
return normalized
121+
122+
def ensure_parent_dir(path):
123+
parent = os.path.dirname(path)
124+
if parent:
125+
os.makedirs(parent, exist_ok=True)
95126

96127
MKCONCORE_VER = "22-09-18"
97128

@@ -248,14 +279,15 @@ def cleanup_script_files():
248279
node_label = re.sub(r'(\s+|\n)', ' ', node_label)
249280

250281
#Validate node labels
251-
if ':' in node_label:
252-
container_part, source_part = node_label.split(':', 1)
253-
safe_name(container_part, f"Node container name '{container_part}'")
254-
safe_name(source_part, f"Node source file '{source_part}'")
255-
else:
256-
safe_name(node_label, f"Node label '{node_label}'")
257-
# Explicitly reject incorrect format to prevent later crashes and ambiguity
258-
raise ValueError(f"Invalid node label '{node_label}': expected format 'container:source' with a ':' separator.")
282+
if ':' in node_label:
283+
container_part, source_part = node_label.split(':', 1)
284+
safe_name(container_part, f"Node container name '{container_part}'")
285+
normalized_source = safe_relpath(source_part, f"Node source file '{source_part}'")
286+
node_label = f"{container_part}:{normalized_source}"
287+
else:
288+
safe_name(node_label, f"Node label '{node_label}'")
289+
# Explicitly reject incorrect format to prevent later crashes and ambiguity
290+
raise ValueError(f"Invalid node label '{node_label}': expected format 'container:source' with a ':' separator.")
259291

260292
nodes_dict[node['id']] = node_label
261293
node_id_to_label_map[node['id']] = node_label.split(':')[0]
@@ -373,10 +405,10 @@ def cleanup_script_files():
373405
logging.warning(f"Error processing edge for parameter aggregation: {e}")
374406

375407
# --- Now, run the specialization for each node that has aggregated parameters ---
376-
if node_edge_params:
377-
logging.info("Running script specialization process...")
378-
specialized_scripts_output_dir = os.path.abspath(os.path.join(outdir, "src"))
379-
os.makedirs(specialized_scripts_output_dir, exist_ok=True)
408+
if node_edge_params:
409+
logging.info("Running script specialization process...")
410+
specialized_scripts_output_dir = os.path.abspath(os.path.join(outdir, "src"))
411+
os.makedirs(specialized_scripts_output_dir, exist_ok=True)
380412

381413
for node_id, params_list in node_edge_params.items():
382414
current_node_full_label = nodes_dict[node_id]
@@ -388,23 +420,33 @@ def cleanup_script_files():
388420
if not original_script or "." not in original_script:
389421
continue # Skip if not a script file
390422

391-
template_script_full_path = os.path.join(sourcedir, original_script)
392-
if not os.path.exists(template_script_full_path):
393-
logging.error(f"Cannot specialize: Original script '{template_script_full_path}' not found in '{sourcedir}'.")
394-
continue
395-
396-
new_script_basename = copy_with_port_portname.run_specialization_script(
397-
template_script_full_path,
398-
specialized_scripts_output_dir,
399-
params_list,
400-
python_executable,
401-
copy_script_py_path
402-
)
403-
404-
if new_script_basename:
405-
# Update nodes_dict to point to the new comprehensive specialized script
406-
nodes_dict[node_id] = f"{container_name}:{new_script_basename}"
407-
logging.info(f"Node ID '{node_id}' ('{container_name}') updated to use specialized script '{new_script_basename}'.")
423+
template_script_full_path = os.path.join(sourcedir, original_script)
424+
if not os.path.exists(template_script_full_path):
425+
logging.error(f"Cannot specialize: Original script '{template_script_full_path}' not found in '{sourcedir}'.")
426+
continue
427+
428+
script_subdir = posixpath.dirname(original_script)
429+
node_output_dir = specialized_scripts_output_dir
430+
if script_subdir:
431+
node_output_dir = os.path.join(specialized_scripts_output_dir, script_subdir)
432+
os.makedirs(node_output_dir, exist_ok=True)
433+
434+
new_script_basename = copy_with_port_portname.run_specialization_script(
435+
template_script_full_path,
436+
node_output_dir,
437+
params_list,
438+
python_executable,
439+
copy_script_py_path
440+
)
441+
442+
if new_script_basename:
443+
# Update nodes_dict to point to the new comprehensive specialized script
444+
if script_subdir:
445+
new_script_relpath = posixpath.join(script_subdir, new_script_basename)
446+
else:
447+
new_script_relpath = new_script_basename
448+
nodes_dict[node_id] = f"{container_name}:{new_script_relpath}"
449+
logging.info(f"Node ID '{node_id}' ('{container_name}') updated to use specialized script '{new_script_relpath}'.")
408450
else:
409451
logging.error(f"Failed to generate specialized script for node ID '{node_id}'. It will retain its original script.")
410452

@@ -446,22 +488,25 @@ def cleanup_script_files():
446488
else:
447489
dockername, langext = sourcecode, ""
448490

449-
script_target_path = os.path.join(outdir, "src", sourcecode)
491+
script_target_path = os.path.join(outdir, "src", sourcecode)
492+
ensure_parent_dir(script_target_path)
450493

451494
# If the script was specialized, it's already in outdir/src. If not, copy from sourcedir.
452495
if node_id_key not in node_edge_params:
453496
script_source_path = os.path.join(sourcedir, sourcecode)
454-
if os.path.exists(script_source_path):
455-
shutil.copy2(script_source_path, script_target_path)
456-
else:
457-
logging.error(f"Script '{sourcecode}' not found in sourcedir '{sourcedir}'")
497+
if os.path.exists(script_source_path):
498+
shutil.copy2(script_source_path, script_target_path)
499+
else:
500+
logging.error(f"Script '{sourcecode}' not found in sourcedir '{sourcedir}'")
458501

459502
# The rest of the file handling (Dockerfiles, .dir) uses 'dockername',
460503
# which is now derived from the specialized script name, maintaining consistency.
461504
if concoretype == "docker":
462505
custom_dockerfile = f"Dockerfile.{dockername}"
463-
if os.path.exists(os.path.join(sourcedir, custom_dockerfile)):
464-
shutil.copy2(os.path.join(sourcedir, custom_dockerfile), os.path.join(outdir, "src", custom_dockerfile))
506+
if os.path.exists(os.path.join(sourcedir, custom_dockerfile)):
507+
dest_dockerfile = os.path.join(outdir, "src", custom_dockerfile)
508+
ensure_parent_dir(dest_dockerfile)
509+
shutil.copy2(os.path.join(sourcedir, custom_dockerfile), dest_dockerfile)
465510

466511
dir_for_node = f"{dockername}.dir"
467512
if os.path.isdir(os.path.join(sourcedir, dir_for_node)):
@@ -640,11 +685,15 @@ def cleanup_script_files():
640685
try:
641686
containername, sourcecode = node_label.split(':', 1)
642687
if not sourcecode or "." not in sourcecode: continue
643-
dockername = os.path.splitext(sourcecode)[0]
644-
with open(os.path.join(outdir, "src", f"{dockername}.iport"), "w") as fport:
645-
fport.write(str(ports['iport']).replace("'" + prefixedgenode, "'"))
646-
with open(os.path.join(outdir, "src", f"{dockername}.oport"), "w") as fport:
647-
fport.write(str(ports['oport']).replace("'" + prefixedgenode, "'"))
688+
dockername = os.path.splitext(sourcecode)[0]
689+
iport_path = os.path.join(outdir, "src", f"{dockername}.iport")
690+
oport_path = os.path.join(outdir, "src", f"{dockername}.oport")
691+
ensure_parent_dir(iport_path)
692+
ensure_parent_dir(oport_path)
693+
with open(iport_path, "w") as fport:
694+
fport.write(str(ports['iport']).replace("'" + prefixedgenode, "'"))
695+
with open(oport_path, "w") as fport:
696+
fport.write(str(ports['oport']).replace("'" + prefixedgenode, "'"))
648697
except ValueError:
649698
continue
650699

@@ -653,10 +702,11 @@ def cleanup_script_files():
653702
if (concoretype=="docker"):
654703
for node in nodes_dict:
655704
containername,sourcecode = nodes_dict[node].split(':')
656-
if len(sourcecode)!=0 and sourcecode.find(".")!=-1: #3/28/21
657-
dockername,langext = sourcecode.split(".")
658-
if not os.path.exists(outdir+"/src/Dockerfile."+dockername): # 3/30/21
659-
try:
705+
if len(sourcecode)!=0 and sourcecode.find(".")!=-1: #3/28/21
706+
dockername,langext = sourcecode.split(".")
707+
dockerfile_path = os.path.join(outdir, "src", f"Dockerfile.{dockername}")
708+
if not os.path.exists(dockerfile_path): # 3/30/21
709+
try:
660710
if langext=="py":
661711
src_path = CONCOREPATH+"/Dockerfile.py"
662712
logging.info("assuming .py extension for Dockerfile")
@@ -677,8 +727,9 @@ def cleanup_script_files():
677727
except:
678728
logging.error(f"{CONCOREPATH} is not correct path to concore")
679729
quit()
680-
with open(outdir+"/src/Dockerfile."+dockername,"w") as fcopy:
681-
fcopy.write(source_content)
730+
ensure_parent_dir(dockerfile_path)
731+
with open(dockerfile_path,"w") as fcopy:
732+
fcopy.write(source_content)
682733
if langext=="py":
683734
fcopy.write('CMD ["python", "-i", "'+sourcecode+'"]\n')
684735
if langext=="m":
@@ -695,7 +746,7 @@ def cleanup_script_files():
695746
containername,sourcecode = nodes_dict[node].split(':')
696747
if len(sourcecode)!=0 and sourcecode.find(".")!=-1: #3/28/21
697748
dockername,langext = sourcecode.split(".")
698-
fbuild.write("mkdir docker-"+dockername+"\n")
749+
fbuild.write("mkdir -p docker-"+dockername+"\n")
699750
fbuild.write("cd docker-"+dockername+"\n")
700751
fbuild.write("cp ../src/Dockerfile."+dockername+" Dockerfile\n")
701752
#copy sourcefiles from ./src into corresponding directories
@@ -922,36 +973,48 @@ def cleanup_script_files():
922973
if concoretype=="posix":
923974
fbuild.write('#!/bin/bash' + "\n")
924975

925-
for node in nodes_dict:
926-
containername,sourcecode = nodes_dict[node].split(':')
927-
if len(sourcecode)!=0:
928-
if sourcecode.find(".")==-1:
929-
logging.error("cannot pull container "+sourcecode+" with control core type "+concoretype) #3/28/21
930-
quit()
931-
dockername,langext = sourcecode.split(".")
932-
fbuild.write('mkdir '+containername+"\n")
933-
if concoretype == "windows":
934-
fbuild.write("copy .\\src\\"+sourcecode+" .\\"+containername+"\\"+sourcecode+"\n")
935-
if langext == "py":
936-
fbuild.write("copy .\\src\\concore.py .\\" + containername + "\\concore.py\n")
937-
elif langext == "cpp":
938-
# 6/22/21
939-
fbuild.write("copy .\\src\\concore.hpp .\\" + containername + "\\concore.hpp\n")
940-
elif langext == "v":
941-
# 6/25/21
942-
fbuild.write("copy .\\src\\concore.v .\\" + containername + "\\concore.v\n")
943-
elif langext == "m": # 4/2/21
944-
fbuild.write("copy .\\src\\concore_*.m .\\" + containername + "\\\n")
945-
fbuild.write("copy .\\src\\import_concore.m .\\" + containername + "\\\n")
946-
fbuild.write("copy .\\src\\"+dockername+".iport .\\"+containername+"\\concore.iport\n")
947-
fbuild.write("copy .\\src\\"+dockername+".oport .\\"+containername+"\\concore.oport\n")
948-
#include data files in here if they exist
949-
if os.path.isdir(sourcedir+"/"+dockername+".dir"):
950-
fbuild.write("copy .\\src\\"+dockername+".dir\\*.* .\\"+containername+"\n")
951-
else:
952-
fbuild.write("cp ./src/"+sourcecode+" ./"+containername+"/"+sourcecode+"\n")
953-
if langext == "py":
954-
fbuild.write("cp ./src/concore.py ./"+containername+"/concore.py\n")
976+
for node in nodes_dict:
977+
containername,sourcecode = nodes_dict[node].split(':')
978+
if len(sourcecode)!=0:
979+
if sourcecode.find(".")==-1:
980+
logging.error("cannot pull container "+sourcecode+" with control core type "+concoretype) #3/28/21
981+
quit()
982+
dockername,langext = sourcecode.split(".")
983+
if concoretype == "windows":
984+
fbuild.write('mkdir '+containername+"\n")
985+
else:
986+
fbuild.write("mkdir -p ./"+containername+"\n")
987+
source_subdir = posixpath.dirname(sourcecode)
988+
if source_subdir:
989+
if concoretype == "windows":
990+
source_subdir_win = source_subdir.replace("/", "\\")
991+
fbuild.write("mkdir .\\"+containername+"\\"+source_subdir_win+"\n")
992+
else:
993+
fbuild.write("mkdir -p ./"+containername+"/"+source_subdir+"\n")
994+
if concoretype == "windows":
995+
sourcecode_win = sourcecode.replace("/", "\\")
996+
dockername_win = dockername.replace("/", "\\")
997+
fbuild.write("copy .\\src\\"+sourcecode_win+" .\\"+containername+"\\"+sourcecode_win+"\n")
998+
if langext == "py":
999+
fbuild.write("copy .\\src\\concore.py .\\" + containername + "\\concore.py\n")
1000+
elif langext == "cpp":
1001+
# 6/22/21
1002+
fbuild.write("copy .\\src\\concore.hpp .\\" + containername + "\\concore.hpp\n")
1003+
elif langext == "v":
1004+
# 6/25/21
1005+
fbuild.write("copy .\\src\\concore.v .\\" + containername + "\\concore.v\n")
1006+
elif langext == "m": # 4/2/21
1007+
fbuild.write("copy .\\src\\concore_*.m .\\" + containername + "\\\n")
1008+
fbuild.write("copy .\\src\\import_concore.m .\\" + containername + "\\\n")
1009+
fbuild.write("copy .\\src\\"+dockername_win+".iport .\\"+containername+"\\concore.iport\n")
1010+
fbuild.write("copy .\\src\\"+dockername_win+".oport .\\"+containername+"\\concore.oport\n")
1011+
#include data files in here if they exist
1012+
if os.path.isdir(sourcedir+"/"+dockername+".dir"):
1013+
fbuild.write("copy .\\src\\"+dockername_win+".dir\\*.* .\\"+containername+"\n")
1014+
else:
1015+
fbuild.write("cp ./src/"+sourcecode+" ./"+containername+"/"+sourcecode+"\n")
1016+
if langext == "py":
1017+
fbuild.write("cp ./src/concore.py ./"+containername+"/concore.py\n")
9551018
elif langext == "cpp":
9561019
fbuild.write("cp ./src/concore.hpp ./"+containername+"/concore.hpp\n")
9571020
elif langext == "v":

tests/test_cli.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,30 @@ def test_run_command_default_type(self):
101101
else:
102102
self.assertTrue(Path('out/build').exists())
103103

104+
def test_run_command_subdir_source(self):
105+
with self.runner.isolated_filesystem(temp_dir=self.temp_dir):
106+
result = self.runner.invoke(cli, ['init', 'test-project'])
107+
self.assertEqual(result.exit_code, 0)
108+
109+
subdir = Path('test-project/src/subdir')
110+
subdir.mkdir(parents=True, exist_ok=True)
111+
shutil.move('test-project/src/script.py', subdir / 'script.py')
112+
113+
workflow_path = Path('test-project/workflow.graphml')
114+
content = workflow_path.read_text()
115+
content = content.replace('N1:script.py', 'N1:subdir/script.py')
116+
workflow_path.write_text(content)
117+
118+
result = self.runner.invoke(cli, [
119+
'run',
120+
'test-project/workflow.graphml',
121+
'--source', 'test-project/src',
122+
'--output', 'out',
123+
'--type', 'posix'
124+
])
125+
self.assertEqual(result.exit_code, 0)
126+
self.assertTrue(Path('out/src/subdir/script.py').exists())
127+
104128
def test_run_command_existing_output(self):
105129
with self.runner.isolated_filesystem(temp_dir=self.temp_dir):
106130
result = self.runner.invoke(cli, ['init', 'test-project'])

0 commit comments

Comments
 (0)