From 70ae562b572f235ede44f1d15ac44caf3b6273ef Mon Sep 17 00:00:00 2001
From: yeseorizi <172019512+yeseorizi@users.noreply.github.com>
Date: Sat, 10 Oct 2026 00:22:32 +0900
Subject: [PATCH] fix: quote POSIX world paths at launch entry points
Generated-by: OpenAI Codex
Signed-off-by: yeseorizi <172019512+yeseorizi@users.noreply.github.com>
---
examples/posim_demos/CMakeLists.txt | 5 +
.../posim_demos/launch/posim_robot.launch.py | 11 +-
.../posim_demos/launch/posim_world.launch.py | 8 +-
examples/posim_demos/package.xml | 4 +
examples/posim_demos/test/test_world_paths.py | 116 ++++++++++++++++++
5 files changed, 141 insertions(+), 3 deletions(-)
create mode 100644 examples/posim_demos/test/test_world_paths.py
diff --git a/examples/posim_demos/CMakeLists.txt b/examples/posim_demos/CMakeLists.txt
index 831c9270..5dc87b44 100644
--- a/examples/posim_demos/CMakeLists.txt
+++ b/examples/posim_demos/CMakeLists.txt
@@ -8,4 +8,9 @@ install(
DESTINATION share/posim_demos
)
+if(BUILD_TESTING)
+ find_package(ament_cmake_pytest REQUIRED)
+ ament_add_pytest_test(test_world_paths test/test_world_paths.py)
+endif()
+
ament_package()
diff --git a/examples/posim_demos/launch/posim_robot.launch.py b/examples/posim_demos/launch/posim_robot.launch.py
index d3218247..eebf9844 100644
--- a/examples/posim_demos/launch/posim_robot.launch.py
+++ b/examples/posim_demos/launch/posim_robot.launch.py
@@ -1,3 +1,5 @@
+import os
+import shlex
import xml.etree.ElementTree as ET
from launch import LaunchDescription
@@ -11,6 +13,11 @@
from launch_ros.substitutions import FindPackageShare
+def _quote_path(path):
+ # Keep Windows shell handling unchanged; shlex.quote is POSIX-only.
+ return shlex.quote(path) if os.name == "posix" else path
+
+
def launch_setup(context, *args, **kwargs):
paused = LaunchConfiguration("paused")
gui = LaunchConfiguration("gui")
@@ -47,10 +54,10 @@ def launch_setup(context, *args, **kwargs):
if world_element is None or not world_element.get("name"):
raise ValueError(f"World file [{world_filename}] does not declare a world name")
world_entity_name = world_element.get("name")
- gz_args = [world_filepath]
+ gz_args = [_quote_path(world_filepath.perform(context))]
else:
world_entity_name = "empty"
- gz_args = [world_name]
+ gz_args = [_quote_path(world_name.perform(context))]
zoom_camera_value = "true" if selected_world_name == "posim_ocean_waves" else "false"
diff --git a/examples/posim_demos/launch/posim_world.launch.py b/examples/posim_demos/launch/posim_world.launch.py
index f63a0661..a636447f 100644
--- a/examples/posim_demos/launch/posim_world.launch.py
+++ b/examples/posim_demos/launch/posim_world.launch.py
@@ -1,4 +1,5 @@
import os
+import shlex
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, OpaqueFunction, IncludeLaunchDescription
@@ -6,6 +7,11 @@
from launch.substitutions import LaunchConfiguration
+def _quote_path(path):
+ # Keep Windows shell handling unchanged; shlex.quote is POSIX-only.
+ return shlex.quote(path) if os.name == "posix" else path
+
+
def launch_setup(context, *args, **kwargs):
pkg_posim_worlds = get_package_share_directory("posim_worlds")
@@ -19,7 +25,7 @@ def launch_setup(context, *args, **kwargs):
world_path = os.path.join(pkg_posim_worlds, "worlds", world_file_name)
# Gazebo simulation launch
- gz_args = f"-r {world_path}"
+ gz_args = f"-r {_quote_path(world_path)}"
if verbose_flag.lower() == "true":
gz_args += " --verbose"
if headless_flag.lower() == "true":
diff --git a/examples/posim_demos/package.xml b/examples/posim_demos/package.xml
index 76e781be..d89dc1a4 100644
--- a/examples/posim_demos/package.xml
+++ b/examples/posim_demos/package.xml
@@ -8,6 +8,10 @@
Ye-Seol Kwon
Apache 2.0
ament_cmake
+ ament_cmake_pytest
+ ament_index_python
+ launch
+ launch_ros
ament_cmake
diff --git a/examples/posim_demos/test/test_world_paths.py b/examples/posim_demos/test/test_world_paths.py
new file mode 100644
index 00000000..debb1600
--- /dev/null
+++ b/examples/posim_demos/test/test_world_paths.py
@@ -0,0 +1,116 @@
+"""Exercise world-path quoting at both launch entry points."""
+
+from contextlib import ExitStack
+from importlib.util import module_from_spec, spec_from_file_location
+import json
+import os
+from pathlib import Path
+import shlex
+import subprocess
+import sys
+from types import SimpleNamespace
+from unittest.mock import patch
+
+from launch import LaunchContext
+from launch.actions import DeclareLaunchArgument
+from launch.utilities import normalize_to_list_of_substitutions, perform_substitutions
+import pytest
+
+
+def arguments(entry, share, platform_name="posix", empty=False, headless=True, paused=False):
+ path = Path(__file__).resolve().parents[1] / "launch" / f"posim_{entry}.launch.py"
+ spec = spec_from_file_location("launch_under_test", path)
+ module = module_from_spec(spec)
+ spec.loader.exec_module(module)
+ module.os = SimpleNamespace(**{**os.__dict__, "name": platform_name})
+ context = LaunchContext()
+ for action in module.generate_launch_description().entities:
+ if isinstance(action, DeclareLaunchArgument) and action.default_value is not None:
+ context.launch_configurations[action.name] = perform_substitutions(
+ context, action.default_value
+ )
+ context.launch_configurations.update(
+ world_name="empty.sdf" if empty else "scene",
+ headless=str(headless).lower(),
+ gui=str(not headless).lower(),
+ paused=str(paused).lower(),
+ verbose="4" if entry == "robot" else "true",
+ )
+ with ExitStack() as stack:
+ if entry == "robot":
+ stack.enter_context(
+ patch.object(module.FindPackageShare, "find", lambda self, package: str(share))
+ )
+ else:
+ stack.enter_context(
+ patch.object(module, "get_package_share_directory", return_value=str(share))
+ )
+ actions = module.launch_setup(context)
+ include = next(a for a in actions if "gz_args" in dict(a.launch_arguments))
+ value = dict(include.launch_arguments)["gz_args"]
+ return perform_substitutions(context, normalize_to_list_of_substitutions(value))
+
+
+@pytest.fixture
+def share(tmp_path):
+ path = tmp_path / "plain"
+ (path / "worlds").mkdir(parents=True)
+ (path / "worlds/scene.world").write_text('')
+ return path
+
+
+@pytest.mark.skipif(os.name != "posix", reason="Requires a POSIX shell")
+@pytest.mark.parametrize("entry", ["world", "robot"])
+@pytest.mark.parametrize(
+ "directory",
+ [
+ "plain",
+ "space path",
+ "author's files",
+ "semi;colon",
+ "literal$HOME",
+ 'double"quote',
+ "해양 연구",
+ ],
+)
+def test_world_remains_one_literal_shell_argument(tmp_path, entry, directory):
+ share = tmp_path / directory
+ (share / "worlds").mkdir(parents=True)
+ world = share / "worlds/scene.world"
+ world.write_text('')
+ prefix = (
+ shlex.quote(sys.executable)
+ + " -c "
+ + shlex.quote("import json,sys; print(json.dumps(sys.argv[1:]))")
+ )
+ result = subprocess.run(
+ prefix + " " + arguments(entry, share),
+ shell=True,
+ executable="/bin/sh",
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ assert result.returncode == 0, result.stderr
+ argv = json.loads(result.stdout)
+ assert argv.count(str(world)) == 1
+ assert "-r" in argv
+ assert "-s" in argv
+
+
+@pytest.mark.parametrize("entry", ["world", "robot"])
+def test_windows_keeps_unquoted_command_construction(share, entry):
+ # Deliberately preserve Windows behavior; not a Windows execution test.
+ spaced = share.with_name("Program Files")
+ share.rename(spaced)
+ assert str(spaced / "worlds/scene.world") in arguments(entry, spaced, "nt")
+ assert "'" not in arguments(entry, spaced, "nt")
+
+
+@pytest.mark.parametrize("headless", [False, True])
+@pytest.mark.parametrize("paused", [False, True])
+def test_robot_empty_world_preserves_flags(share, headless, paused):
+ argv = shlex.split(arguments("robot", share, empty=True, headless=headless, paused=paused))
+ assert argv.count("empty.sdf") == 1
+ assert ("-r" in argv) == (not paused)
+ assert ("-s" in argv) == headless