From 0a22a46eb01077435587c699e76a6d9319ae7fd4 Mon Sep 17 00:00:00 2001 From: jihtsan <15151999611@163.com> Date: Wed, 28 Oct 2026 19:49:49 +0800 Subject: [PATCH 1/2] fix(nodeenv): preserve surrogateescaped activation content Closes #442 --- CHANGES | 3 +++ nodeenv.py | 2 +- tests/nodeenv_test.py | 29 +++++++++++++++++++++++++++++ tests/test_install_activate.py | 23 +++++++++++++++++++++++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index c42af9e..726ee70 100644 --- a/CHANGES +++ b/CHANGES @@ -4,6 +4,9 @@ Nodeenv changelog Version [unreleased] -------------------- +- Preserve non-UTF-8 filesystem bytes when writing activation scripts, so + environments in such paths can be activated on Linux + `#442 `_ - `nodeenv -p` no longer puts the python venv's `bin` directory on `PATH` a second time: the venv's own `activate` and `activate.fish` put it there already. Envs without those scripts, such as conda envs, and the git-bash diff --git a/nodeenv.py b/nodeenv.py index c643526..de25b82 100644 --- a/nodeenv.py +++ b/nodeenv.py @@ -653,7 +653,7 @@ def writefile(dest, content, overwrite=True, append=False): Create file and write content in it """ if not isinstance(content, bytes): - content = bytes(content, 'utf-8') + content = content.encode('utf-8', 'surrogateescape') if not os.path.exists(dest): logger.debug(' * Writing %s ... ', dest, extra=dict(continued=True)) with open(dest, 'wb') as f: diff --git a/tests/nodeenv_test.py b/tests/nodeenv_test.py index 5ee988b..5ece0eb 100644 --- a/tests/nodeenv_test.py +++ b/tests/nodeenv_test.py @@ -3007,6 +3007,35 @@ def test_with_certifi_is_configurable(self): class TestWritefile: """Tests for writefile()""" + @pytest.mark.parametrize('append', [False, True]) + def test_preserves_surrogateescaped_bytes(self, tmpdir, append): + """Filesystem bytes in activation content must round-trip.""" + dest = tmpdir.join('activate') + dest.write_binary(b'# existing\n') + raw = b"NODE_VIRTUAL_ENV='/tmp/caf\xe9/env'\n" + + nodeenv.writefile(str(dest), os.fsdecode(raw), append=append) + + expected = b'# existing\n' + raw if append else raw + assert dest.read_binary() == expected + nodeenv.writefile(str(dest), os.fsdecode(raw), append=append) + assert dest.read_binary() == expected + + @pytest.mark.parametrize('content, expected', [ + ('caf\u00e9', b'caf\xc3\xa9'), + (b'caf\xe9', b'caf\xe9'), + ]) + def test_preserves_utf8_text_and_bytes(self, tmpdir, content, expected): + """Regular Unicode stays UTF-8 and bytes stay unchanged.""" + dest = tmpdir.join('activate') + nodeenv.writefile(str(dest), content) + assert dest.read_binary() == expected + + def test_rejects_non_filesystem_surrogate(self, tmpdir): + """surrogateescape must not conceal arbitrary invalid Unicode.""" + with pytest.raises(UnicodeEncodeError): + nodeenv.writefile(str(tmpdir.join('activate')), '\ud800') + def test_append_starts_on_its_own_line(self, tmpdir): """A file not ending with a newline must not swallow the first appended line: `:END` plus `@echo off` is a label, not a command diff --git a/tests/test_install_activate.py b/tests/test_install_activate.py index c7f5623..e5f349b 100644 --- a/tests/test_install_activate.py +++ b/tests/test_install_activate.py @@ -62,6 +62,29 @@ def test_write(tmpdir, name, content_var): assert bin_dir.join(name).read() == fix_content(content, tmpdir) +@pytest.mark.skipif(not sys.platform.startswith('linux'), + reason='non-UTF-8 filesystem paths need Linux') +def test_activate_with_surrogateescaped_path(tmp_path): + """Keep a raw filesystem path intact in generated scripts and sh.""" + env_dir = tmp_path / os.fsdecode(b'caf\xe9') + bin_dir = env_dir / 'bin' + bin_dir.mkdir(parents=True) + node = bin_dir / 'node' + node.write_bytes(b'#!/bin/sh\necho stub\n') + node.chmod(0o755) + with mock.patch.object(sys, 'argv', ['nodeenv', str(env_dir)]): + opts = nodeenv.parse_args() + nodeenv.install_activate(str(env_dir), opts) + + activate = bin_dir / 'activate' + assert os.fsencode(str(env_dir)) in activate.read_bytes() + output = subprocess.check_output([ + 'sh', '-c', '. "$1"; printf "%s" "$NODE_VIRTUAL_ENV"', + 'sh', str(activate), + ]) + assert output == os.fsencode(str(env_dir)) + + @pytest.mark.skipif(nodeenv.is_WIN, reason='activate.fish is POSIX only') def test_activate_fish_has_no_caret_redirect(tmpdir): """Generated activate.fish must not contain caret stderr redirects. From a02b38003a25f498640ddd491463487ae3abcf6c Mon Sep 17 00:00:00 2001 From: jihtsan <15151999611@163.com> Date: Wed, 28 Oct 2026 19:50:35 +0800 Subject: [PATCH 2/2] test(nodeenv): make surrogateescape setup portable --- tests/nodeenv_test.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/nodeenv_test.py b/tests/nodeenv_test.py index 5ece0eb..ce9b0af 100644 --- a/tests/nodeenv_test.py +++ b/tests/nodeenv_test.py @@ -3014,11 +3014,13 @@ def test_preserves_surrogateescaped_bytes(self, tmpdir, append): dest.write_binary(b'# existing\n') raw = b"NODE_VIRTUAL_ENV='/tmp/caf\xe9/env'\n" - nodeenv.writefile(str(dest), os.fsdecode(raw), append=append) + nodeenv.writefile( + str(dest), raw.decode('utf-8', 'surrogateescape'), append=append) expected = b'# existing\n' + raw if append else raw assert dest.read_binary() == expected - nodeenv.writefile(str(dest), os.fsdecode(raw), append=append) + nodeenv.writefile( + str(dest), raw.decode('utf-8', 'surrogateescape'), append=append) assert dest.read_binary() == expected @pytest.mark.parametrize('content, expected', [