From 85f107e03783e7b3ad43339494e76c7f8997df1d Mon Sep 17 00:00:00 2001 From: Joshua James Date: Sun, 5 Jul 2026 18:29:39 -0400 Subject: [PATCH] Fix: resolve relative -d/--project-dir path in `pio device monitor` `device_monitor_cmd`'s `-d/--project-dir` option accepted a relative path without resolving it to absolute. The command then entered `with fs.cd(options["project_dir"]):`, changing the working directory into that relative subdir. Downstream, a platform monitor filter (e.g. the espressif32 exception decoder) re-enters the same still-relative `project_dir` via `load_build_metadata()`'s own `fs.cd()`, duplicating the path segment onto the already-changed cwd and crashing with FileNotFoundError (platformio/platformio-core#5439). Add `resolve_path=True` to the click.Path type so the path is resolved to absolute once, at the point it enters the system, before any fs.cd() chain runs. This does not change the no-flag default (os.getcwd already returns absolute) or the already-absolute case (passes through unchanged, modulo symlink resolution). Add a regression test mirroring the nested fs.cd() scenario, plus coverage for the default and already-absolute cases. --- platformio/device/monitor/command.py | 2 +- tests/commands/test_device_monitor.py | 99 +++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 tests/commands/test_device_monitor.py diff --git a/platformio/device/monitor/command.py b/platformio/device/monitor/command.py index b3dc0a0e5f..66d3e916d8 100644 --- a/platformio/device/monitor/command.py +++ b/platformio/device/monitor/command.py @@ -104,7 +104,7 @@ "-d", "--project-dir", default=os.getcwd, - type=click.Path(exists=True, file_okay=False, dir_okay=True), + type=click.Path(exists=True, file_okay=False, dir_okay=True, resolve_path=True), ) @click.option( "-e", diff --git a/tests/commands/test_device_monitor.py b/tests/commands/test_device_monitor.py new file mode 100644 index 0000000000..087ef31e96 --- /dev/null +++ b/tests/commands/test_device_monitor.py @@ -0,0 +1,99 @@ +# Copyright (c) 2014-present PlatformIO +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +from platformio import fs +from platformio.device.finder import SerialPortFinder +from platformio.device.monitor.command import device_monitor_cmd + + +def _patch_monitor_internals(monkeypatch, on_register_filters): + # `register_filters` is where a platform's monitor filter (e.g. the + # espressif32 exception decoder) re-enters `project_dir` via + # `fs.cd()`/`load_build_metadata()`. Stub the heavy, hardware-dependent + # collaborators so the command can run in CI while still exercising the + # real `project_dir` value it receives. + monkeypatch.setattr( + "platformio.device.monitor.command.register_filters", on_register_filters + ) + monkeypatch.setattr( + "platformio.device.monitor.command.start_terminal", lambda options: None + ) + monkeypatch.setattr( + SerialPortFinder, "find", lambda self, initial_port=None: "socket://localhost:0" + ) + + +def test_relative_project_dir_is_resolved_before_use( + clirunner, validate_cliresult, monkeypatch, tmpdir +): + # Regression test for https://github.com/platformio/platformio-core/issues/5439 + # `pio device monitor -d ` used to crash with a duplicated + # path (e.g. `.../examples/distance-ultrasound/examples/distance-ultrasound`) + # because `project_dir` stayed relative while the working directory had + # already been changed into it via `fs.cd()`. + project_dir = os.path.realpath(str(tmpdir.mkdir("project"))) + captured = {} + + def on_register_filters(platform, options): # pylint: disable=unused-argument + captured["project_dir"] = options["project_dir"] + # Mirror `load_build_metadata()`, which re-enters `project_dir` via a + # second, nested `fs.cd()` while already inside the first one. + with fs.cd(options["project_dir"]): + captured["cwd_after_nested_cd"] = os.getcwd() + + _patch_monitor_internals(monkeypatch, on_register_filters) + monkeypatch.chdir(tmpdir) + + result = clirunner.invoke(device_monitor_cmd, ["-d", "project"]) + validate_cliresult(result) + + # compare against the realpath, not the raw tmpdir string: `resolve_path` + # also resolves symlinks, and some platforms put the temp dir behind one + # (e.g. macOS `/tmp` -> `/private/tmp`) + assert os.path.isabs(captured["project_dir"]) + assert captured["project_dir"] == project_dir + assert captured["cwd_after_nested_cd"] == project_dir + + +def test_default_project_dir_without_flag_still_absolute( + clirunner, validate_cliresult, monkeypatch, tmpdir +): + captured = {} + _patch_monitor_internals( + monkeypatch, + lambda platform, options: captured.update(project_dir=options["project_dir"]), + ) + monkeypatch.chdir(tmpdir) + + result = clirunner.invoke(device_monitor_cmd, []) + validate_cliresult(result) + + assert captured["project_dir"] == os.path.realpath(str(tmpdir)) + + +def test_absolute_project_dir_passthrough( + clirunner, validate_cliresult, monkeypatch, tmpdir +): + captured = {} + _patch_monitor_internals( + monkeypatch, + lambda platform, options: captured.update(project_dir=options["project_dir"]), + ) + + result = clirunner.invoke(device_monitor_cmd, ["-d", str(tmpdir)]) + validate_cliresult(result) + + assert captured["project_dir"] == os.path.realpath(str(tmpdir))