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..ce9b0af 100644 --- a/tests/nodeenv_test.py +++ b/tests/nodeenv_test.py @@ -3007,6 +3007,37 @@ 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), 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), raw.decode('utf-8', 'surrogateescape'), 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.