Skip to content

Commit ad82602

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. In addition, this commit removes the last base64 encryption leftovers.
1 parent b963a1e commit ad82602

10 files changed

Lines changed: 181 additions & 100 deletions

File tree

streamflow/core/utils.py

Lines changed: 68 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
from streamflow.log_handler import logger
@@ -126,6 +129,27 @@ def create_command(
126129
)
127130

128131

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

@@ -211,7 +235,7 @@ async def get_local_to_remote_destination(
211235
) -> str:
212236
is_dst_dir, status = await dst_connector.run(
213237
location=dst_location,
214-
command=[f'test -d "{dst}"'],
238+
command=["test", "-d", shlex.quote(dst)],
215239
capture_output=True,
216240
)
217241
if status > 1:
@@ -254,21 +278,21 @@ async def get_remote_to_remote_write_command(
254278
) -> MutableSequence[str]:
255279
is_dst_dir, status = await dst_connector.run(
256280
location=dst_locations[0],
257-
command=[f'test -d "{dst}"'],
281+
command=["test", "-d", shlex.quote(dst)],
258282
capture_output=True,
259283
)
260284
if status > 1:
261285
raise WorkflowExecutionException(is_dst_dir)
262286
# If destination path exists and is a directory
263287
elif status == 0:
264-
return ["tar", "xf", "-", "-C", dst]
288+
return ["tar", "xf", "-", "-C", shlex.quote(dst)]
265289
# Otherwise, if destination path does not exist
266290
else:
267291
# If basename must be renamed during transfer
268292
if posixpath.basename(src) != posixpath.basename(dst):
269293
is_src_dir, status = await src_connector.run(
270294
location=src_location,
271-
command=[f'test -d "{src}"'],
295+
command=["test", "-d", shlex.quote(src)],
272296
capture_output=True,
273297
)
274298
if status > 1:
@@ -279,19 +303,44 @@ async def get_remote_to_remote_write_command(
279303
*(
280304
asyncio.create_task(
281305
dst_connector.run(
282-
location=dst_location, command=["mkdir", "-p", dst]
306+
location=dst_location,
307+
command=["mkdir", "-p", shlex.quote(dst)],
283308
)
284309
)
285310
for dst_location in dst_locations
286311
)
287312
)
288-
return ["tar", "xf", "-", "-C", dst, "--strip-components", "1"]
313+
return [
314+
"tar",
315+
"xf",
316+
"-",
317+
"-C",
318+
shlex.quote(dst),
319+
"--strip-components",
320+
"1",
321+
]
289322
# Otherwise, if source path is a file
290323
else:
291-
return ["tar", "xf", "-", "-O", "|", "tee", dst, ">", "/dev/null"]
324+
return [
325+
"tar",
326+
"xf",
327+
"-",
328+
"-O",
329+
"|",
330+
"tee",
331+
shlex.quote(dst),
332+
">",
333+
"/dev/null",
334+
]
292335
# Otherwise, if basename must be preserved
293336
else:
294-
return ["tar", "xf", "-", "-C", posixpath.dirname(dst)]
337+
return [
338+
"tar",
339+
"xf",
340+
"-",
341+
"-C",
342+
shlex.quote(posixpath.dirname(dst)),
343+
]
295344

296345

297346
def get_tag(tokens: Iterable[Token]) -> str:
@@ -363,5 +412,16 @@ async def run_in_subprocess(
363412
return None
364413

365414

415+
if sys.platform == "win32":
416+
417+
def quote(value: str, local: bool) -> str:
418+
return mslex.quote(value) if local else shlex.quote(value)
419+
420+
else:
421+
422+
def quote(value: str, local: bool) -> str:
423+
return shlex.quote(value)
424+
425+
366426
def wrap_command(command: str) -> list[str]:
367427
return ["/bin/sh", "-c", f"{command}"]

streamflow/cwl/command.py

Lines changed: 30 additions & 37 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(
@@ -710,17 +708,23 @@ async def _load(
710708
)
711709

712710
def _get_executable_command(
713-
self, context: MutableMapping[str, Any], inputs: MutableMapping[str, Token]
711+
self,
712+
context: MutableMapping[str, Any],
713+
inputs: MutableMapping[str, Token],
714+
local: bool,
714715
) -> MutableSequence[str]:
715-
command = []
716716
options = CWLCommandOptions(
717717
context=context,
718718
expression_lib=self.expression_lib,
719719
full_js=self.full_js,
720+
local=local,
720721
)
721722
# Process baseCommand
722-
if self.base_command:
723-
command.append(shlex.join(self.base_command))
723+
command = (
724+
[quote(cmd, local=options.local) for cmd in self.base_command]
725+
if self.base_command
726+
else []
727+
)
724728
# Process tokens
725729
bindings = ListCommandToken(name=None, position=None, value=[])
726730
for processor in self.processors:
@@ -807,8 +811,14 @@ async def execute(self, job: Job) -> CWLCommandOutput:
807811
)
808812
else:
809813
inputs = job.inputs
814+
# Get execution target
815+
connector = self.step.workflow.context.scheduler.get_connector(job.name)
816+
locations = self.step.workflow.context.scheduler.get_locations(job.name)
817+
local = all(loc.local for loc in locations)
810818
# Build command string
811-
cmd = self._get_executable_command(context, inputs)
819+
cmd = self._get_executable_command(context=context, inputs=inputs, local=local)
820+
if self.is_shell_command:
821+
cmd = create_shell_command(cmd, local=local)
812822
# Build environment variables
813823
parsed_env = {
814824
k: str(
@@ -825,24 +835,14 @@ async def execute(self, job: Job) -> CWLCommandOutput:
825835
parsed_env["HOME"] = job.output_directory
826836
if "TMPDIR" not in parsed_env:
827837
parsed_env["TMPDIR"] = job.tmp_directory
828-
# Get execution target
829-
connector = self.step.workflow.context.scheduler.get_connector(job.name)
830-
locations = self.step.workflow.context.scheduler.get_locations(job.name)
831-
cmd_string = " \\\n\t".join(
832-
["/bin/sh", "-c", '"{cmd}"'.format(cmd=" ".join(cmd))]
833-
if self.is_shell_command
834-
else cmd
835-
)
838+
# Log and persist command
839+
cmd_string = " \\\n\t".join(cmd)
836840
if logger.isEnabledFor(logging.INFO):
837841
logger.info(
838842
"EXECUTING step {step} (job {job}) {location} into directory {outdir}:\n{command}".format(
839843
step=self.step.name,
840844
job=job.name,
841-
location=(
842-
"locally"
843-
if locations[0].local
844-
else f"on location {locations[0]}"
845-
),
845+
location=("locally" if local else f"on location {locations[0]}"),
846846
outdir=job.output_directory,
847847
command=cmd_string,
848848
)
@@ -856,17 +856,6 @@ async def execute(self, job: Job) -> CWLCommandOutput:
856856
job_token_id=job_token.persistent_id,
857857
cmd=cmd_string,
858858
)
859-
# Escape shell command when needed
860-
if self.is_shell_command:
861-
cmd = [
862-
"/bin/sh",
863-
"-c",
864-
'"$(echo {command} | base64 -d)"'.format(
865-
command=base64.b64encode(" ".join(cmd).encode("utf-8")).decode(
866-
"utf-8"
867-
)
868-
),
869-
]
870859
# If step is assigned to multiple locations, add the STREAMFLOW_HOSTS environment variable
871860
if len(locations) > 1 and (
872861
hostnames := [loc.hostname for loc in locations if loc.hostname is not None]
@@ -979,17 +968,19 @@ async def execute(self, job: Job) -> CWLCommandOutput:
979968

980969

981970
class CWLCommandOptions(CommandOptions):
982-
__slots__ = ("context", "expression_lib", "full_js")
971+
__slots__ = ("context", "expression_lib", "full_js", "local")
983972

984973
def __init__(
985974
self,
986975
context: MutableMapping[str, Any],
987976
expression_lib: MutableSequence[str] | None = None,
988977
full_js: bool = False,
978+
local: bool = False,
989979
):
990980
self.context: MutableMapping[str, Any] = context
991981
self.expression_lib: MutableSequence[str] | None = expression_lib
992982
self.full_js: bool = full_js
983+
self.local: bool = local
993984

994985

995986
class CWLCommandTokenProcessor(CommandTokenProcessor):
@@ -1075,7 +1066,7 @@ def bind(
10751066
value = [value]
10761067
# Process shell escape only on the single command token
10771068
if not self.is_shell_command or self.shell_quote:
1078-
value = [_escape_value(v) for v in value]
1069+
value = [_escape_value(value=v, local=options.local) for v in value]
10791070
# Obtain token position
10801071
if isinstance(self.position, str) and not self.position.isnumeric():
10811072
position = utils.eval_expression(
@@ -1219,6 +1210,7 @@ def _update_options(
12191210
| {"inputs": {self.name: get_token_value(token)}},
12201211
expression_lib=options.expression_lib,
12211212
full_js=options.full_js,
1213+
local=options.local,
12221214
)
12231215

12241216

@@ -1236,6 +1228,7 @@ def _update_options(
12361228
| {"inputs": {self.name: value}, "self": value},
12371229
expression_lib=options.expression_lib,
12381230
full_js=options.full_js,
1231+
local=options.local,
12391232
)
12401233

12411234

streamflow/cwl/processor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,7 @@ async def _process_command_output(
559559
if self.target
560560
else job.tmp_directory
561561
),
562-
path=cast(str, path),
562+
path=path,
563563
)
564564
)
565565
for path in globpaths

0 commit comments

Comments
 (0)