Skip to content

Commit 1d6646d

Browse files
committed
Quote remote release tool commands
1 parent 6e6f19d commit 1d6646d

4 files changed

Lines changed: 363 additions & 25 deletions

File tree

run_release.py

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@
4545
DOWNLOADS_SERVER = "downloads.nyc1.psf.io"
4646
DOCS_SERVER = "docs.nyc1.psf.io"
4747

48+
49+
def quote_remote_shell(value: object) -> str:
50+
return shlex.quote(str(value))
51+
52+
4853
WHATS_NEW_TEMPLATE = """
4954
*****************************
5055
What's new in Python {version}
@@ -755,7 +760,7 @@ def upload_files_to_server(db: ReleaseShelf, server: str) -> None:
755760
ftp_client = MySFTPClient.from_transport(transport)
756761
assert ftp_client is not None, f"SFTP client to {server} is None"
757762

758-
client.exec_command(f"rm -rf {destination}")
763+
client.exec_command(f"rm -rf {quote_remote_shell(destination)}")
759764

760765
with contextlib.suppress(OSError):
761766
ftp_client.mkdir(str(destination))
@@ -810,28 +815,29 @@ def execute_command(command: str) -> None:
810815
raise ReleaseException(channel.recv_stderr(1000))
811816

812817
def copy_and_set_permissions(source_glob: str, destination: str) -> None:
813-
execute_command(f"mkdir -p {destination}")
814-
execute_command(f"cp {source_glob} {destination}")
818+
quoted_destination = quote_remote_shell(destination)
819+
execute_command(f"mkdir -p {quoted_destination}")
820+
execute_command(f"cp {source_glob} {quoted_destination}")
815821
# Skip chgrp/chmod if already correct: another RM may have created
816822
# the directory, and only the owner can change group or permissions.
817823
execute_command(
818-
f"find {destination} -maxdepth 0 ! -group downloads "
824+
f"find {quoted_destination} -maxdepth 0 ! -group downloads "
819825
f"-exec chgrp downloads {{}} +"
820826
)
821827
execute_command(
822-
f"find {destination} -maxdepth 0 ! -perm 775 -exec chmod 775 {{}} +"
828+
f"find {quoted_destination} -maxdepth 0 ! -perm 775 -exec chmod 775 {{}} +"
823829
)
824830
execute_command(
825-
f"find {destination} -type f ! -perm 664 -exec chmod 664 {{}} +"
831+
f"find {quoted_destination} -type f ! -perm 664 -exec chmod 664 {{}} +"
826832
)
827833

828-
copy_and_set_permissions(f"{source}/downloads/*", destination)
834+
copy_and_set_permissions(f"{quote_remote_shell(source)}/downloads/*", destination)
829835

830836
# Docs
831837
release_tag = db["release"]
832838
if release_tag.is_final or release_tag.is_release_candidate:
833839
copy_and_set_permissions(
834-
f"{source}/docs/*",
840+
f"{quote_remote_shell(source)}/docs/*",
835841
f"/srv/www.python.org/ftp/python/doc/{release_tag}",
836842
)
837843

@@ -870,13 +876,20 @@ def execute_command(command: str) -> None:
870876
raise ReleaseException(channel.recv_stderr(1000))
871877

872878
docs_filename = f"python-{release_tag}-docs-html"
873-
execute_command(f"mkdir -p {destination}")
874-
execute_command(f"unzip {source}/docs/{docs_filename}.zip -d {destination}")
875-
execute_command(f"mv /{destination}/{docs_filename}/* {destination}")
876-
execute_command(f"rm -rf /{destination}/{docs_filename}")
877-
execute_command(f"chgrp -R docs {destination}")
878-
execute_command(f"chmod -R 775 {destination}")
879-
execute_command(f"find {destination} -type f -exec chmod 664 {{}} \\;")
879+
quoted_destination = quote_remote_shell(destination)
880+
execute_command(f"mkdir -p {quoted_destination}")
881+
execute_command(
882+
f"unzip {quote_remote_shell(f'{source}/docs/{docs_filename}.zip')} "
883+
f"-d {quoted_destination}"
884+
)
885+
execute_command(
886+
f"mv {quote_remote_shell(f'/{destination}/{docs_filename}')}/* "
887+
f"{quoted_destination}"
888+
)
889+
execute_command(f"rm -rf {quote_remote_shell(f'/{destination}/{docs_filename}')}")
890+
execute_command(f"chgrp -R docs {quoted_destination}")
891+
execute_command(f"chmod -R 775 {quoted_destination}")
892+
execute_command(f"find {quoted_destination} -type f -exec chmod 664 {{}} \\;")
880893

881894

882895
@functools.cache
@@ -1088,12 +1101,19 @@ def run_add_to_python_dot_org(db: ReleaseShelf) -> None:
10881101

10891102
# Do the interactive flow to get an identity for Sigstore
10901103
issuer = sigstore.oidc.Issuer(sigstore.oidc.DEFAULT_OAUTH_ISSUER_URL)
1091-
identity_token = issuer.identity_token()
1104+
identity_token = str(issuer.identity_token())
10921105

10931106
print("Adding files to python.org...")
1094-
stdin, stdout, stderr = client.exec_command(
1095-
f"AUTH_INFO={auth_info} SIGSTORE_IDENTITY_TOKEN={identity_token} python3 add_to_pydotorg.py {db['release']}"
1107+
command = " ".join(
1108+
[
1109+
f"AUTH_INFO={quote_remote_shell(auth_info)}",
1110+
f"SIGSTORE_IDENTITY_TOKEN={quote_remote_shell(identity_token)}",
1111+
"python3",
1112+
"add_to_pydotorg.py",
1113+
quote_remote_shell(db["release"]),
1114+
]
10961115
)
1116+
stdin, stdout, stderr = client.exec_command(command)
10971117
stderr_text = stderr.read().decode()
10981118
if stderr_text:
10991119
raise paramiko.SSHException(f"Failed to execute the command: {stderr_text}")

tests/test_run_release.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,3 +248,196 @@ def test_update_whatsnew_toctree(tmp_path: Path) -> None:
248248
# Assert
249249
new_contents = toctree__file.read_text()
250250
assert " 3.15.rst\n 3.14.rst\n" in new_contents
251+
252+
253+
def test_run_add_to_python_dot_org_quotes_remote_environment(monkeypatch) -> None:
254+
commands = []
255+
256+
class FakeSFTPClient:
257+
def put(self, source: str, destination: str) -> None:
258+
pass
259+
260+
def close(self) -> None:
261+
pass
262+
263+
class FakeSSHClient:
264+
def load_system_host_keys(self) -> None:
265+
pass
266+
267+
def set_missing_host_key_policy(self, policy) -> None:
268+
pass
269+
270+
def connect(self, *args, **kwargs) -> None:
271+
pass
272+
273+
def get_transport(self):
274+
return object()
275+
276+
def exec_command(self, command: str):
277+
commands.append(command)
278+
return None, io.BytesIO(b"ok"), io.BytesIO()
279+
280+
class FakeIssuer:
281+
def __init__(self, issuer_url: str) -> None:
282+
self.issuer_url = issuer_url
283+
284+
def identity_token(self) -> str:
285+
return "token; touch /tmp/pwned"
286+
287+
monkeypatch.setattr(run_release.paramiko, "SSHClient", FakeSSHClient)
288+
monkeypatch.setattr(
289+
run_release.MySFTPClient,
290+
"from_transport",
291+
staticmethod(lambda transport: FakeSFTPClient()),
292+
)
293+
monkeypatch.setattr(run_release.sigstore.oidc, "Issuer", FakeIssuer)
294+
295+
db = {
296+
"auth_info": "user:key; echo pwned",
297+
"release": Tag("3.15.0a1"),
298+
"ssh_key": None,
299+
"ssh_user": "release-manager",
300+
}
301+
302+
run_release.run_add_to_python_dot_org(cast(ReleaseShelf, db))
303+
304+
assert commands == [
305+
"AUTH_INFO='user:key; echo pwned' "
306+
"SIGSTORE_IDENTITY_TOKEN='token; touch /tmp/pwned' "
307+
"python3 add_to_pydotorg.py 3.15.0a1"
308+
]
309+
310+
311+
def test_upload_files_to_server_quotes_remote_cleanup_path(
312+
monkeypatch, tmp_path: Path
313+
) -> None:
314+
commands = []
315+
316+
class FakeSFTPClient:
317+
def mkdir(self, path: str) -> None:
318+
pass
319+
320+
def put_dir(self, source: Path, target: str, progress) -> None:
321+
pass
322+
323+
def close(self) -> None:
324+
pass
325+
326+
class FakeSSHClient:
327+
def load_system_host_keys(self) -> None:
328+
pass
329+
330+
def set_missing_host_key_policy(self, policy) -> None:
331+
pass
332+
333+
def connect(self, *args, **kwargs) -> None:
334+
pass
335+
336+
def get_transport(self):
337+
return object()
338+
339+
def exec_command(self, command: str) -> None:
340+
commands.append(command)
341+
342+
@contextlib.contextmanager
343+
def fake_alive_bar(total: int):
344+
yield lambda *args, **kwargs: None
345+
346+
release = Tag("3.15.0a1")
347+
artifacts_path = tmp_path / str(release)
348+
(artifacts_path / "downloads").mkdir(parents=True)
349+
350+
monkeypatch.setattr(run_release.paramiko, "SSHClient", FakeSSHClient)
351+
monkeypatch.setattr(
352+
run_release.MySFTPClient,
353+
"from_transport",
354+
staticmethod(lambda transport: FakeSFTPClient()),
355+
)
356+
monkeypatch.setattr(run_release, "alive_bar", fake_alive_bar)
357+
358+
db = {
359+
"git_repo": tmp_path,
360+
"release": release,
361+
"ssh_key": None,
362+
"ssh_user": "release-manager; touch /tmp/pwned #",
363+
}
364+
365+
run_release.upload_files_to_server(
366+
cast(ReleaseShelf, db), run_release.DOWNLOADS_SERVER
367+
)
368+
369+
assert commands == [
370+
"rm -rf '/home/psf-users/release-manager; touch /tmp/pwned #/3.15.0a1'"
371+
]
372+
373+
374+
def test_release_file_placement_quotes_remote_paths(monkeypatch) -> None:
375+
commands = []
376+
377+
class FakeChannel:
378+
def exec_command(self, command: str) -> None:
379+
commands.append(command)
380+
381+
def recv_exit_status(self) -> int:
382+
return 0
383+
384+
def recv_stderr(self, size: int) -> bytes:
385+
return b""
386+
387+
class FakeTransport:
388+
def open_session(self) -> FakeChannel:
389+
return FakeChannel()
390+
391+
class FakeSSHClient:
392+
def load_system_host_keys(self) -> None:
393+
pass
394+
395+
def set_missing_host_key_policy(self, policy) -> None:
396+
pass
397+
398+
def connect(self, *args, **kwargs) -> None:
399+
pass
400+
401+
def get_transport(self) -> FakeTransport:
402+
return FakeTransport()
403+
404+
monkeypatch.setattr(run_release.paramiko, "SSHClient", FakeSSHClient)
405+
406+
db = {
407+
"release": Tag("3.15.0rc1"),
408+
"ssh_key": None,
409+
"ssh_user": "release-manager; touch /tmp/pwned #",
410+
}
411+
412+
run_release.place_files_in_download_folder(cast(ReleaseShelf, db))
413+
run_release.unpack_docs_in_the_docs_server(cast(ReleaseShelf, db))
414+
415+
assert commands == [
416+
"mkdir -p /srv/www.python.org/ftp/python/3.15.0",
417+
"cp '/home/psf-users/release-manager; touch /tmp/pwned #/3.15.0rc1'/downloads/* "
418+
"/srv/www.python.org/ftp/python/3.15.0",
419+
"find /srv/www.python.org/ftp/python/3.15.0 -maxdepth 0 ! -group downloads "
420+
"-exec chgrp downloads {} +",
421+
"find /srv/www.python.org/ftp/python/3.15.0 -maxdepth 0 ! -perm 775 "
422+
"-exec chmod 775 {} +",
423+
"find /srv/www.python.org/ftp/python/3.15.0 -type f ! -perm 664 "
424+
"-exec chmod 664 {} +",
425+
"mkdir -p /srv/www.python.org/ftp/python/doc/3.15.0rc1",
426+
"cp '/home/psf-users/release-manager; touch /tmp/pwned #/3.15.0rc1'/docs/* "
427+
"/srv/www.python.org/ftp/python/doc/3.15.0rc1",
428+
"find /srv/www.python.org/ftp/python/doc/3.15.0rc1 -maxdepth 0 ! -group downloads "
429+
"-exec chgrp downloads {} +",
430+
"find /srv/www.python.org/ftp/python/doc/3.15.0rc1 -maxdepth 0 ! -perm 775 "
431+
"-exec chmod 775 {} +",
432+
"find /srv/www.python.org/ftp/python/doc/3.15.0rc1 -type f ! -perm 664 "
433+
"-exec chmod 664 {} +",
434+
"mkdir -p /srv/docs.python.org/release/3.15.0rc1",
435+
"unzip '/home/psf-users/release-manager; touch /tmp/pwned #/3.15.0rc1/docs/"
436+
"python-3.15.0rc1-docs-html.zip' -d /srv/docs.python.org/release/3.15.0rc1",
437+
"mv //srv/docs.python.org/release/3.15.0rc1/python-3.15.0rc1-docs-html/* "
438+
"/srv/docs.python.org/release/3.15.0rc1",
439+
"rm -rf //srv/docs.python.org/release/3.15.0rc1/python-3.15.0rc1-docs-html",
440+
"chgrp -R docs /srv/docs.python.org/release/3.15.0rc1",
441+
"chmod -R 775 /srv/docs.python.org/release/3.15.0rc1",
442+
"find /srv/docs.python.org/release/3.15.0rc1 -type f -exec chmod 664 {} \\;",
443+
]

0 commit comments

Comments
 (0)