This is an automated email from the ASF dual-hosted git repository. arm pushed a commit to branch atr_tagging in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 35b3d99732c3536511b8d8c74377ffb347e93cbb Author: Alastair McFarlane <[email protected]> AuthorDate: Thu Jan 22 14:59:39 2026 +0000 #476 - allow rsync to specify a tag as part of the URL --- atr/ssh.py | 109 +++++++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 89 insertions(+), 20 deletions(-) diff --git a/atr/ssh.py b/atr/ssh.py index 2b153b3..93e37d9 100644 --- a/atr/ssh.py +++ b/atr/ssh.py @@ -20,6 +20,7 @@ import asyncio import asyncio.subprocess import datetime +import glob import os import stat import string @@ -233,7 +234,9 @@ async def _step_02_handle_safely(process: asyncssh.SSHServerProcess, server: SSH ####################################### ### Calls _step_04_command_validate ### ####################################### - project_name, version_name, release_obj = await _step_04_command_validate(process, argv, is_read_request, server) + project_name, version_name, file_patterns, release_obj = await _step_04_command_validate( + process, argv, is_read_request, server + ) # The release object is only present for read requests release_name = sql.release_name(project_name, version_name) @@ -242,7 +245,7 @@ async def _step_02_handle_safely(process: asyncssh.SSHServerProcess, server: SSH #################################################### ### Calls _step_07a_process_validated_rsync_read ### #################################################### - await _step_07a_process_validated_rsync_read(process, argv, release_obj) + await _step_07a_process_validated_rsync_read(process, argv, release_obj, file_patterns) else: _output_stderr(process, f"Received write command: {process.command}") log.info(f"Processing WRITE request for {release_name}") @@ -297,12 +300,15 @@ def _step_03_command_simple_validate(argv: list[str]) -> bool: async def _step_04_command_validate( process: asyncssh.SSHServerProcess, argv: list[str], is_read_request: bool, server: SSHServer -) -> tuple[str, str, sql.Release | None]: +) -> tuple[str, str, list[str] | None, sql.Release | None]: """Validate the path and user permissions for read or write.""" ############################################ - ### Calls _step_05_command_path_validate ### + ### Calls _step_05a/b_command_path_validate ### ############################################ - path_project, path_version = _step_05_command_path_validate(argv[-1]) + if is_read_request: + path_project, path_version, tag = _step_05a_command_path_validate_read(argv[-1]) + else: + path_project, path_version, tag = _step_05b_command_path_validate_write(argv[-1]) ssh_uid = server._get_asf_uid(process) @@ -312,26 +318,71 @@ async def _step_04_command_validate( # Projects are public, so existence information is public raise RsyncArgsError(f"Project '{path_project}' does not exist") - release = await data.release(project_name=project.name, version=path_version).get() + release = await data.release(project_name=project.name, version=path_version, _release_policy=True).get() if is_read_request: ################################################# ### Calls _step_06a_validate_read_permissions ### ################################################# - validated_release = await _step_06a_validate_read_permissions( - ssh_uid, project, release, path_project, path_version + validated_release, file_patterns = await _step_06a_validate_read_permissions( + ssh_uid, project, release, path_project, path_version, tag ) - return path_project, path_version, validated_release + return path_project, path_version, file_patterns, validated_release ################################################## ### Calls _step_06b_validate_write_permissions ### ################################################## await _step_06b_validate_write_permissions(ssh_uid, project, release) - # Return None for the release object for write requests - return path_project, path_version, None + # Return None for the tag and release objects for write requests + return path_project, path_version, None, None -def _step_05_command_path_validate(path: str) -> tuple[str, str]: +def _step_05a_command_path_validate_read(path: str) -> tuple[str, str, str | None]: + """Validate the path argument for rsync commands.""" + # READ: rsync --server --sender -vlogDtpre.iLsfxCIvu . /proj/v1/ + # Validating path: /proj/v1/ + # WRITE: rsync --server -vlogDtpre.iLsfxCIvu . /proj/v1/ + # Validating path: /proj/v1/ + + if not path.startswith("/"): + raise RsyncArgsError("The path argument should be an absolute path") + + if not path.endswith("/"): + # Technically we could ignore this, because we rewrite the path anyway for writes + # But we should enforce good rsync usage practices + raise RsyncArgsError("The path argument should be a directory path, ending with a /") + + if "//" in path: + raise RsyncArgsError("The path argument should not contain //") + + if path.count("/") < 3 or path.count("/") > 4: + raise RsyncArgsError("The path argument should be a /PROJECT/VERSION/(tag)/ directory path") + + path_project, path_version, *rest = path.strip("/").split("/", 2) + tag = rest[0] if rest else None + alphanum = set(string.ascii_letters + string.digits + "-") + if not all(c in alphanum for c in path_project): + raise RsyncArgsError("The project name should contain only alphanumeric characters or hyphens") + + if tag and (not all(c in alphanum for c in path_project)): + raise RsyncArgsError("The tag should contain only alphanumeric characters or hyphens") + + # From a survey of version numbers we find that only . and - are used + # We also allow + which is in common use + version_punctuation = set(".-+") + if path_version[0] not in alphanum: + # Must certainly not allow the directory to be called "." or ".." + # And we also want to avoid patterns like ".htaccess" + raise RsyncArgsError("The version should start with an alphanumeric character") + if path_version[-1] not in alphanum: + raise RsyncArgsError("The version should end with an alphanumeric character") + if not all(c in (alphanum | version_punctuation) for c in path_version): + raise RsyncArgsError("The version should contain only alphanumeric characters, dots, dashes, or pluses") + + return path_project, path_version, tag + + +def _step_05b_command_path_validate_write(path: str) -> tuple[str, str, str | None]: """Validate the path argument for rsync commands.""" # READ: rsync --server --sender -vlogDtpre.iLsfxCIvu . /proj/v1/ # Validating path: /proj/v1/ @@ -352,11 +403,15 @@ def _step_05_command_path_validate(path: str) -> tuple[str, str]: if path.count("/") != 3: raise RsyncArgsError("The path argument should be a /PROJECT/VERSION/ directory path") - path_project, path_version = path.strip("/").split("/", 1) + path_project, path_version, *rest = path.strip("/").split("/", 2) + tag = rest[0] if rest else None alphanum = set(string.ascii_letters + string.digits + "-") if not all(c in alphanum for c in path_project): raise RsyncArgsError("The project name should contain only alphanumeric characters or hyphens") + if tag and (not all(c in alphanum for c in path_project)): + raise RsyncArgsError("The tag should contain only alphanumeric characters or hyphens") + # From a survey of version numbers we find that only . and - are used # We also allow + which is in common use version_punctuation = set(".-+") @@ -369,7 +424,7 @@ def _step_05_command_path_validate(path: str) -> tuple[str, str]: if not all(c in (alphanum | version_punctuation) for c in path_version): raise RsyncArgsError("The version should contain only alphanumeric characters, dots, dashes, or pluses") - return path_project, path_version + return path_project, path_version, tag async def _step_06a_validate_read_permissions( @@ -378,7 +433,8 @@ async def _step_06a_validate_read_permissions( release: sql.Release | None, path_project: str, path_version: str, -) -> sql.Release | None: + tag: str | None, +) -> tuple[sql.Release | None, list[str] | None]: """Validate permissions for a read request.""" if release is None: raise RsyncArgsError(f"Release '{path_project}-{path_version}' does not exist") @@ -396,7 +452,15 @@ async def _step_06a_validate_read_permissions( raise RsyncArgsError( f"You must be a committer or committee member for project '{project.name}' to read this release" ) - return release + + if tag: + if not release.release_policy or (not release.release_policy.atr_file_tagging_spec): + raise RsyncArgsError(f"Release '{release.name}' does not support tags") + tags = release.release_policy.atr_file_tagging_spec.keys() + if tag not in tags: + raise RsyncArgsError(f"Tag '{tag}' is not allowed for release '{release.name}'") + return release, release.release_policy.atr_file_tagging_spec[tag] + return release, None async def _step_06b_validate_write_permissions( @@ -426,6 +490,7 @@ async def _step_07a_process_validated_rsync_read( process: asyncssh.SSHServerProcess, argv: list[str], release: sql.Release, + file_patterns: list[str] | None, ) -> None: """Handle a validated rsync read request.""" exit_status = 1 @@ -441,10 +506,14 @@ async def _step_07a_process_validated_rsync_read( if not await aiofiles.os.path.isdir(source_dir): raise RsyncArgsError(f"Source directory '{source_dir}' not found for release {release.name}") - # Update the rsync command path to the determined source directory - argv[-1] = str(source_dir) - if not argv[-1].endswith("/"): - argv[-1] += "/" + if file_patterns is None: + # Update the rsync command path to the determined source directory + argv[-1] = str(source_dir) + if not argv[-1].endswith("/"): + argv[-1] += "/" + else: + files = [f for pattern in file_patterns for f in glob.glob(f"{source_dir}/{pattern}")] + argv[-1:] = files ################################################### ### Calls _step_08_execute_rsync_sender_command ### --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
