Skip to content

Commit 9d313f1

Browse files
Further improve command quoting
This commit further improves command quoting in StreamFlow `Command` and `Connector` classes by properly using `mslex` and `shlex` utilities.
1 parent 2b166b3 commit 9d313f1

7 files changed

Lines changed: 137 additions & 79 deletions

File tree

streamflow/core/utils.py

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@
77
import os
88
import posixpath
99
import shlex
10+
import sys
1011
import uuid
1112
from collections.abc import Iterable, MutableMapping, MutableSequence
1213
from pathlib import PurePosixPath
1314
from typing import TYPE_CHECKING, Any
1415

16+
import mslex
17+
1518
from streamflow.core.exception import ProcessorTypeError, WorkflowExecutionException
1619
from streamflow.core.persistence import PersistableEntity
1720

@@ -125,6 +128,27 @@ def create_command(
125128
)
126129

127130

131+
if sys.platform == "win32":
132+
133+
def create_shell_command(command: MutableSequence[str], local: bool) -> list[str]:
134+
cmd = " ".join(command)
135+
return (
136+
["cmd", "/C", quote(value=cmd, local=local)]
137+
if local
138+
else ["sh", "-c", shlex.quote(cmd)]
139+
)
140+
141+
else:
142+
143+
def create_shell_command(command: MutableSequence[str], local: bool) -> list[str]:
144+
cmd = " ".join(command)
145+
return (
146+
[os.environ.get("SHELL", "sh"), "-c", quote(value=cmd, local=local)]
147+
if local
148+
else ["sh", "-c", shlex.quote(cmd)]
149+
)
150+
151+
128152
def get_job_step_name(job_name: str) -> str:
129153
return PurePosixPath(job_name).parent.as_posix()
130154

@@ -210,7 +234,7 @@ async def get_local_to_remote_destination(
210234
) -> str:
211235
is_dst_dir, status = await dst_connector.run(
212236
location=dst_location,
213-
command=[f'test -d "{dst}"'],
237+
command=["test", "-d", quote(value=dst, local=dst_location.local)],
214238
capture_output=True,
215239
)
216240
if status > 1:
@@ -251,23 +275,24 @@ async def get_remote_to_remote_write_command(
251275
dst_locations: MutableSequence[ExecutionLocation],
252276
dst: str,
253277
) -> MutableSequence[str]:
278+
local = all(loc.local for loc in dst_locations)
254279
is_dst_dir, status = await dst_connector.run(
255280
location=dst_locations[0],
256-
command=[f'test -d "{dst}"'],
281+
command=["test", "-d", quote(value=dst, local=local)],
257282
capture_output=True,
258283
)
259284
if status > 1:
260285
raise WorkflowExecutionException(is_dst_dir)
261286
# If destination path exists and is a directory
262287
elif status == 0:
263-
return ["tar", "xf", "-", "-C", dst]
288+
return ["tar", "xf", "-", "-C", quote(value=dst, local=local)]
264289
# Otherwise, if destination path does not exist
265290
else:
266291
# If basename must be renamed during transfer
267292
if posixpath.basename(src) != posixpath.basename(dst):
268293
is_src_dir, status = await src_connector.run(
269294
location=src_location,
270-
command=[f'test -d "{src}"'],
295+
command=["test", "-d", quote(value=src, local=src_location.local)],
271296
capture_output=True,
272297
)
273298
if status > 1:
@@ -278,19 +303,44 @@ async def get_remote_to_remote_write_command(
278303
*(
279304
asyncio.create_task(
280305
dst_connector.run(
281-
location=dst_location, command=["mkdir", "-p", dst]
306+
location=dst_location,
307+
command=["mkdir", "-p", quote(value=dst, local=local)],
282308
)
283309
)
284310
for dst_location in dst_locations
285311
)
286312
)
287-
return ["tar", "xf", "-", "-C", dst, "--strip-components", "1"]
313+
return [
314+
"tar",
315+
"xf",
316+
"-",
317+
"-C",
318+
quote(value=dst, local=local),
319+
"--strip-components",
320+
"1",
321+
]
288322
# Otherwise, if source path is a file
289323
else:
290-
return ["tar", "xf", "-", "-O", "|", "tee", dst, ">", "/dev/null"]
324+
return [
325+
"tar",
326+
"xf",
327+
"-",
328+
"-O",
329+
"|",
330+
"tee",
331+
quote(value=dst, local=local),
332+
">",
333+
"/dev/null",
334+
]
291335
# Otherwise, if basename must be preserved
292336
else:
293-
return ["tar", "xf", "-", "-C", posixpath.dirname(dst)]
337+
return [
338+
"tar",
339+
"xf",
340+
"-",
341+
"-C",
342+
quote(value=posixpath.dirname(dst), local=local),
343+
]
294344

295345

296346
def get_tag(tokens: Iterable[Token]) -> str:
@@ -307,6 +357,17 @@ def make_future(obj: T) -> asyncio.Future[T]:
307357
return future
308358

309359

360+
if sys.platform == "win32":
361+
362+
def quote(value: str, local: bool) -> str:
363+
return mslex.quote(value) if local else shlex.quote(value)
364+
365+
else:
366+
367+
def quote(value: str, local: bool) -> str:
368+
return shlex.quote(value)
369+
370+
310371
def random_name() -> str:
311372
return str(uuid.uuid4())
312373

streamflow/cwl/command.py

Lines changed: 30 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
from __future__ import annotations
22

33
import asyncio
4-
import base64
54
import json
65
import logging
76
import posixpath
8-
import shlex
97
import time
108
from asyncio.subprocess import STDOUT
119
from collections.abc import MutableMapping, MutableSequence
@@ -30,7 +28,7 @@
3028
MapCommandOutputProcessor,
3129
UnionCommandOutputProcessor,
3230
)
33-
from streamflow.core.utils import flatten_list
31+
from streamflow.core.utils import create_shell_command, flatten_list, quote
3432
from streamflow.core.workflow import (
3533
Command,
3634
CommandOptions,
@@ -226,11 +224,11 @@ def _build_command_output_processor(
226224
)
227225

228226

229-
def _escape_value(value: Any) -> Any:
227+
def _escape_value(value: Any, local: bool) -> Any:
230228
if isinstance(value, MutableSequence):
231-
return [_escape_value(v) for v in value]
229+
return [_escape_value(value=v, local=local) for v in value]
232230
else:
233-
return shlex.quote(_get_value_repr(value))
231+
return quote(value=_get_value_repr(value), local=local)
234232

235233

236234
async def _get_source_location(
@@ -706,17 +704,23 @@ async def _load(
706704
)
707705

708706
def _get_executable_command(
709-
self, context: MutableMapping[str, Any], inputs: MutableMapping[str, Token]
707+
self,
708+
context: MutableMapping[str, Any],
709+
inputs: MutableMapping[str, Token],
710+
local: bool,
710711
) -> MutableSequence[str]:
711-
command = []
712712
options = CWLCommandOptions(
713713
context=context,
714714
expression_lib=self.expression_lib,
715715
full_js=self.full_js,
716+
local=local,
716717
)
717718
# Process baseCommand
718-
if self.base_command:
719-
command.append(shlex.join(self.base_command))
719+
command = (
720+
[quote(cmd, local=options.local) for cmd in self.base_command]
721+
if self.base_command
722+
else []
723+
)
720724
# Process tokens
721725
bindings = ListCommandToken(name=None, position=None, value=[])
722726
for processor in self.processors:
@@ -803,8 +807,14 @@ async def execute(self, job: Job) -> CWLCommandOutput:
803807
)
804808
else:
805809
inputs = job.inputs
810+
# Get execution target
811+
connector = self.step.workflow.context.scheduler.get_connector(job.name)
812+
locations = self.step.workflow.context.scheduler.get_locations(job.name)
813+
local = all(loc.local for loc in locations)
806814
# Build command string
807-
cmd = self._get_executable_command(context, inputs)
815+
cmd = self._get_executable_command(context=context, inputs=inputs, local=local)
816+
if self.is_shell_command:
817+
cmd = create_shell_command(cmd, local=local)
808818
# Build environment variables
809819
parsed_env = {
810820
k: str(
@@ -821,29 +831,18 @@ async def execute(self, job: Job) -> CWLCommandOutput:
821831
parsed_env["HOME"] = job.output_directory
822832
if "TMPDIR" not in parsed_env:
823833
parsed_env["TMPDIR"] = job.tmp_directory
824-
# Get execution target
825-
connector = self.step.workflow.context.scheduler.get_connector(job.name)
826-
locations = self.step.workflow.context.scheduler.get_locations(job.name)
827-
cmd_string = " \\\n\t".join(
828-
["/bin/sh", "-c", '"{cmd}"'.format(cmd=" ".join(cmd))]
829-
if self.is_shell_command
830-
else cmd
831-
)
834+
# Log and persist command
835+
cmd_string = " \\\n\t".join(cmd)
832836
if logger.isEnabledFor(logging.INFO):
833837
logger.info(
834838
"EXECUTING step {step} (job {job}) {location} into directory {outdir}:\n{command}".format(
835839
step=self.step.name,
836840
job=job.name,
837-
location=(
838-
"locally"
839-
if locations[0].local
840-
else f"on location {locations[0]}"
841-
),
841+
location=("locally" if local else f"on location {locations[0]}"),
842842
outdir=job.output_directory,
843843
command=cmd_string,
844844
)
845845
)
846-
# Persist command
847846
job_token = get_job_token(
848847
job.name, cast(ExecuteStep, self.step).get_job_port().token_list
849848
)
@@ -852,17 +851,6 @@ async def execute(self, job: Job) -> CWLCommandOutput:
852851
job_token_id=job_token.persistent_id,
853852
cmd=cmd_string,
854853
)
855-
# Escape shell command when needed
856-
if self.is_shell_command:
857-
cmd = [
858-
"/bin/sh",
859-
"-c",
860-
'"$(echo {command} | base64 -d)"'.format(
861-
command=base64.b64encode(" ".join(cmd).encode("utf-8")).decode(
862-
"utf-8"
863-
)
864-
),
865-
]
866854
# If step is assigned to multiple locations, add the STREAMFLOW_HOSTS environment variable
867855
if len(locations) > 1 and (
868856
hostnames := [loc.hostname for loc in locations if loc.hostname is not None]
@@ -976,17 +964,19 @@ async def execute(self, job: Job) -> CWLCommandOutput:
976964

977965

978966
class CWLCommandOptions(CommandOptions):
979-
__slots__ = ("context", "expression_lib", "full_js")
967+
__slots__ = ("context", "expression_lib", "full_js", "local")
980968

981969
def __init__(
982970
self,
983971
context: MutableMapping[str, Any],
984972
expression_lib: MutableSequence[str] | None = None,
985973
full_js: bool = False,
974+
local: bool = False,
986975
):
987976
self.context: MutableMapping[str, Any] = context
988977
self.expression_lib: MutableSequence[str] | None = expression_lib
989978
self.full_js: bool = full_js
979+
self.local: bool = local
990980

991981

992982
class CWLCommandTokenProcessor(CommandTokenProcessor):
@@ -1072,7 +1062,7 @@ def bind(
10721062
value = [value]
10731063
# Process shell escape only on the single command token
10741064
if not self.is_shell_command or self.shell_quote:
1075-
value = [_escape_value(v) for v in value]
1065+
value = [_escape_value(value=v, local=options.local) for v in value]
10761066
# Obtain token position
10771067
if isinstance(self.position, str) and not self.position.isnumeric():
10781068
position = utils.eval_expression(
@@ -1216,6 +1206,7 @@ def _update_options(
12161206
| {"inputs": {self.name: get_token_value(token)}},
12171207
expression_lib=options.expression_lib,
12181208
full_js=options.full_js,
1209+
local=options.local,
12191210
)
12201211

12211212

@@ -1233,6 +1224,7 @@ def _update_options(
12331224
| {"inputs": {self.name: value}, "self": value},
12341225
expression_lib=options.expression_lib,
12351226
full_js=options.full_js,
1227+
local=options.local,
12361228
)
12371229

12381230

streamflow/data/remotepath.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,9 @@ async def _size(
113113
[
114114
"find -L ",
115115
(
116-
" ".join([f'"{p}"' for p in path])
116+
shlex.join(path)
117117
if isinstance(path, MutableSequence)
118-
else f'"{path}"'
118+
else shlex.quote(path)
119119
),
120120
" -type f -exec ls -ln {} \\+ | ",
121121
"awk 'BEGIN {sum=0} {sum+=$5} END {print sum}'; ",
@@ -740,7 +740,7 @@ async def size(self) -> int:
740740
"".join(
741741
[
742742
"find -L ",
743-
f'"{self.__str__()}"',
743+
shlex.quote(self.__str__()),
744744
" -type f -exec ls -ln {} \\+ | ",
745745
"awk 'BEGIN {sum=0} {sum+=$5} END {print sum}'; ",
746746
]

streamflow/deployment/connector/base.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from streamflow.core.data import StreamWrapper
1616
from streamflow.core.deployment import Connector, ExecutionLocation
1717
from streamflow.core.exception import WorkflowExecutionException
18-
from streamflow.core.utils import get_local_to_remote_destination
18+
from streamflow.core.utils import get_local_to_remote_destination, quote
1919
from streamflow.deployment import aiotarstream
2020
from streamflow.deployment.future import FutureAware
2121
from streamflow.deployment.stream import (
@@ -174,7 +174,16 @@ async def copy_remote_to_remote(
174174
)
175175
# Build reader and writer commands
176176
if reader_command is None:
177-
reader_command = ["tar", "chf", "-", "-C", *posixpath.split(src)]
177+
reader_command = [
178+
"tar",
179+
"chf",
180+
"-",
181+
"-C",
182+
*(
183+
quote(value=path, local=source_location.local)
184+
for path in posixpath.split(src)
185+
),
186+
]
178187
if writer_command is None:
179188
writer_command = await utils.get_remote_to_remote_write_command(
180189
src_connector=source_connector,
@@ -305,7 +314,16 @@ async def copy_remote_to_local(
305314
location=location,
306315
src=src,
307316
dst=dst,
308-
reader_command=["tar", "chf", "-", "-C", *posixpath.split(src)],
317+
reader_command=[
318+
"tar",
319+
"chf",
320+
"-",
321+
"-C",
322+
*(
323+
quote(value=path, local=location.local)
324+
for path in posixpath.split(src)
325+
),
326+
],
309327
)
310328

311329
async def copy_remote_to_remote(

0 commit comments

Comments
 (0)