This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git
The following commit(s) were added to refs/heads/main by this push:
new 7960012 Increase rsync compatibility and refactor the ssh code
7960012 is described below
commit 796001234e6581e4268112bcaf69370e9033a273
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Jun 19 15:18:44 2025 +0100
Increase rsync compatibility and refactor the ssh code
---
atr/ssh.py | 428 +++++++++++++++++----------------------
atr/templates/user-ssh-keys.html | 3 +
2 files changed, 184 insertions(+), 247 deletions(-)
diff --git a/atr/ssh.py b/atr/ssh.py
index 86f2564..2e6641f 100644
--- a/atr/ssh.py
+++ b/atr/ssh.py
@@ -42,6 +42,12 @@ _CONFIG: Final = config.get()
T = TypeVar("T")
+class RsyncArgsError(Exception):
+ """Exception raised when the rsync arguments are invalid."""
+
+ pass
+
+
class SSHServer(asyncssh.SSHServer):
"""Simple SSH server that handles connections."""
@@ -133,152 +139,124 @@ async def server_stop(server: asyncssh.SSHAcceptor) ->
None:
def _fail[T](process: asyncssh.SSHServerProcess, message: str, return_value:
T) -> T:
+ _output_stderr(process, message)
+ if not process.is_closing():
+ process.exit(1)
+ return return_value
+
+
+def _output_stderr(process: asyncssh.SSHServerProcess, message: str) -> None:
+ """Output a message to the client's stderr."""
+ message = f"ATR SSH: {message}"
_LOGGER.error(message)
- # Ensure message is encoded before writing to stderr
- encoded_message = f"ATR SSH error: {message}\n".encode()
+ encoded_message = f"{message}\n".encode()
try:
process.stderr.write(encoded_message)
except BrokenPipeError:
- _LOGGER.warning("Failed to write error to client stderr: Broken pipe")
+ _LOGGER.warning("Failed to write error to client stderr: broken pipe")
except Exception as e:
_LOGGER.exception(f"Error writing to client stderr: {e}")
- process.exit(1)
- return return_value
async def _step_01_handle_client(process: asyncssh.SSHServerProcess) -> None:
"""Process client command, validating and dispatching to read or write
handlers."""
+ try:
+ await _step_02_handle_safely(process)
+ except RsyncArgsError as e:
+ return _fail(process, f"Error: {e}", None)
+ except Exception as e:
+ _LOGGER.exception(f"Error during client command processing: {e}")
+ return _fail(process, f"Exception: {e}", None)
+
+
+async def _step_02_handle_safely(process: asyncssh.SSHServerProcess) -> None:
asf_uid = process.get_extra_info("username")
_LOGGER.info(f"Handling command for authenticated user: {asf_uid}")
if not process.command:
- return _fail(process, "No command specified", None)
+ raise RsyncArgsError("No command specified")
- _LOGGER.info(f"Command received: {process.command}")
+ _output_stderr(process, f"Received command: {process.command}")
# TODO: Use shlex.split or similar if commands can contain quoted arguments
argv = process.command.split()
##############################################
- ### Calls _step_02_command_simple_validate ###
+ ### Calls _step_03_command_simple_validate ###
##############################################
- simple_validation_error, path_index, is_read_request =
_step_02_command_simple_validate(argv)
- if simple_validation_error:
- return _fail(process, f"{simple_validation_error}\nCommand:
{process.command}", None)
+ is_read_request = _step_03_command_simple_validate(argv)
#######################################
### Calls _step_04_command_validate ###
#######################################
- validation_results = await _step_04_command_validate(process, argv,
path_index, is_read_request)
- if not validation_results:
- return
-
- # Unpack results
+ project_name, version_name, release_obj = await
_step_04_command_validate(process, argv, is_read_request)
# The release object is only present for read requests
- project_name, version_name, release_obj = validation_results
release_name = models.release_name(project_name, version_name)
- if is_read_request:
- if release_obj is None:
- # This should not happen if the validation logic is correct
- return _fail(process, "Internal error: Release object missing for
read request after validation", None)
+ if release_obj is not None:
_LOGGER.info(f"Processing READ request for {release_name}")
####################################################
### Calls _step_07a_process_validated_rsync_read ###
####################################################
- await _step_07a_process_validated_rsync_read(process, argv,
path_index, release_obj)
+ await _step_07a_process_validated_rsync_read(process, argv,
release_obj)
else:
_LOGGER.info(f"Processing WRITE request for {release_name}")
#####################################################
### Calls _step_07b_process_validated_rsync_write ###
#####################################################
- await _step_07b_process_validated_rsync_write(process, argv,
path_index, project_name, version_name)
+ await _step_07b_process_validated_rsync_write(process, argv,
project_name, version_name)
-def _step_02_command_simple_validate(argv: list[str]) -> tuple[str | None,
int, bool]:
+def _step_03_command_simple_validate(argv: list[str]) -> bool:
"""Validate the basic structure of the rsync command and detect read vs
write."""
+ # We use our own arg parsing here to be more strict about the syntax
# READ: ['rsync', '--server', '--sender', '-vlogDtpre.iLsfxCIvu', '.',
'/proj/v1/']
# WRITE: ['rsync', '--server', '-vlogDtpre.iLsfxCIvu', '.', '/proj/v1/']
+ argv = argv[:]
- if not argv:
- return "Empty command", -1, False
+ if argv[:2] != ["rsync", "--server"]:
+ raise RsyncArgsError("The first two arguments must be rsync and
--server")
+ argv = argv[2:]
- if argv[0] != "rsync":
- return "The first argument must be rsync", -1, False
+ is_read_request = False
+ if argv[:1] == ["--sender"]:
+ is_read_request = True
+ argv = argv[1:]
- if argv[1] != "--server":
- return "The second argument must be --server", -1, False
+ flags = set()
+ while argv and argv[0].startswith("-") and (not argv[0].startswith("--")):
+ aflags = argv.pop(0)[1:]
+ if "e." in aflags:
+ aflags = aflags.split("e.", 1)[0]
+ flags.update(aflags)
+ # The -r flag takes precedence over -d and --dirs
+ flags.discard("d")
- is_read_request = False
- option_index = 2
+ if flags != {"D", "g", "l", "o", "p", "r", "t", "v"}:
+ raise RsyncArgsError(f"The flags must be -Dgloprtv, got
{sorted(flags)}")
- # Check for --sender flag, which indicates a read request
- if (len(argv) > 2) and (argv[2] == "--sender"):
- is_read_request = True
- option_index = 3
- if len(argv) <= option_index:
- return "Missing options after --sender", -1, True
- elif len(argv) <= 2:
- return "Missing options argument", -1, False
-
- # Validate the options argument strictly
- options = argv[option_index]
- if "e." in options:
- options = options.split("e.", 1)[0]
- if options != "-vlogDtpr":
- return "The options argument (after --sender) must be
'-vlogDtpr[.e<FLAGS>]'", -1, True
-
- ####################################################
- ### Calls _step_03_validate_rsync_args_structure ###
- ####################################################
- error, path_index = _step_03_validate_rsync_args_structure(argv,
option_index, is_read_request)
- if error:
- return error, -1, is_read_request
-
- return None, path_index, is_read_request
-
-
-def _step_03_validate_rsync_args_structure(
- argv: list[str], option_index: int, is_read_request: bool
-) -> tuple[str | None, int]:
- """Validate the dot argument and path argument presence and count."""
- # READ: ['rsync', '--server', '--sender', '-vlogDtpre.iLsfxCIvu', '.',
'/proj/v1/'] :: 3 :: True
- # WRITE: ['rsync', '--server', '-vlogDtpre.iLsfxCIvu', '.', '/proj/v1/']
:: 2 :: False
- dot_arg_index = option_index + 1
- path_index = option_index + 2
-
- # Write requests might have --delete
- has_delete = False
- if (not is_read_request) and (len(argv) > dot_arg_index) and
(argv[dot_arg_index] == "--delete"):
- has_delete = True
- dot_arg_index += 1
- path_index += 1
-
- if (len(argv) <= dot_arg_index) or (argv[dot_arg_index] != "."):
- expected_pos = "fourth" if (is_read_request or (not has_delete)) else
"fifth"
- return f"The {expected_pos} argument must be .", -1
-
- if len(argv) <= path_index:
- return "Missing path argument", -1
-
- # Check expected total number of arguments
- expected_len = path_index + 1
- if len(argv) != expected_len:
- return f"Expected {expected_len} arguments, but got {len(argv)}", -1
-
- return None, path_index
+ # The -r flag takes precedence over -d and --dirs
+ if argv[:1] == ["--dirs"]:
+ argv = argv[1:]
+
+ if (not is_read_request) and (argv[:1] == ["--delete"]):
+ argv = argv[1:]
+
+ if len(argv) != 2:
+ raise RsyncArgsError(f"Expected two path arguments, got {argv}")
+
+ if argv[0] != ".":
+ raise RsyncArgsError("The first path argument must be .")
+ return is_read_request
async def _step_04_command_validate(
- process: asyncssh.SSHServerProcess, argv: list[str], path_index: int,
is_read_request: bool
-) -> tuple[str, str, models.Release | None] | None:
+ process: asyncssh.SSHServerProcess, argv: list[str], is_read_request: bool
+) -> tuple[str, str, models.Release | None]:
"""Validate the path and user permissions for read or write."""
############################################
### Calls _step_05_command_path_validate ###
############################################
- result = _step_05_command_path_validate(argv[path_index])
- if isinstance(result, str):
- return _fail(process, result, None)
- path_project, path_version = result
+ path_project, path_version = _step_05_command_path_validate(argv[-1])
ssh_uid = process.get_extra_info("username")
@@ -286,32 +264,28 @@ async def _step_04_command_validate(
project = await data.project(name=path_project,
status=models.ProjectStatus.ACTIVE, _committee=True).get()
if project is None:
# Projects are public, so existence information is public
- return _fail(process, f"Project '{path_project}' does not exist",
None)
+ raise RsyncArgsError(f"Project '{path_project}' does not exist")
release = await data.release(project_name=project.name,
version=path_version).get()
- if is_read_request:
- #################################################
- ### Calls _step_06a_validate_read_permissions ###
- #################################################
- validated_release, success = await
_step_06a_validate_read_permissions(
- process, ssh_uid, project, release, path_project, path_version
- )
- if success is None:
- return None
- return path_project, path_version, validated_release
- else:
- ##################################################
- ### Calls _step_06b_validate_write_permissions ###
- ##################################################
- success = await _step_06b_validate_write_permissions(process,
ssh_uid, project, release)
- if success is None:
- return None
- # Return None for the release object for write requests
- return path_project, path_version, None
+ 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
+ )
+ return path_project, path_version, 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
-def _step_05_command_path_validate(path: str) -> tuple[str, str] | str:
+
+def _step_05_command_path_validate(path: str) -> tuple[str, str]:
"""Validate the path argument for rsync commands."""
# READ: rsync --server --sender -vlogDtpre.iLsfxCIvu . /proj/v1/
# Validating path: /proj/v1/
@@ -319,23 +293,23 @@ def _step_05_command_path_validate(path: str) ->
tuple[str, str] | str:
# Validating path: /proj/v1/
if not path.startswith("/"):
- return "The path argument should be an absolute path"
+ 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
- return "The path argument should be a directory path, ending with a /"
+ raise RsyncArgsError("The path argument should be a directory path,
ending with a /")
if "//" in path:
- return "The path argument should not contain //"
+ raise RsyncArgsError("The path argument should not contain //")
if path.count("/") != 3:
- return "The path argument should be a /PROJECT/VERSION/ directory path"
+ raise RsyncArgsError("The path argument should be a /PROJECT/VERSION/
directory path")
path_project, path_version = path.strip("/").split("/", 1)
alphanum = set(string.ascii_letters + string.digits + "-")
if not all(c in alphanum for c in path_project):
- return "The project name should contain only alphanumeric characters
or hyphens"
+ raise RsyncArgsError("The project name 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
@@ -343,27 +317,25 @@ def _step_05_command_path_validate(path: str) ->
tuple[str, str] | str:
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"
- return "The version should start with an alphanumeric character"
+ raise RsyncArgsError("The version should start with an alphanumeric
character")
if path_version[-1] not in alphanum:
- return "The version should end with an alphanumeric character"
+ raise RsyncArgsError("The version should end with an alphanumeric
character")
if not all(c in (alphanum | version_punctuation) for c in path_version):
- return "The version should contain only alphanumeric characters, dots,
dashes, or pluses"
+ raise RsyncArgsError("The version should contain only alphanumeric
characters, dots, dashes, or pluses")
return path_project, path_version
async def _step_06a_validate_read_permissions(
- process: asyncssh.SSHServerProcess,
ssh_uid: str,
project: models.Project,
release: models.Release | None,
path_project: str,
path_version: str,
-) -> tuple[models.Release | None, bool]:
+) -> models.Release | None:
"""Validate permissions for a read request."""
if release is None:
- _fail(process, f"Release '{path_project}-{path_version}' does not
exist", None)
- return None, False
+ raise RsyncArgsError(f"Release '{path_project}-{path_version}' does
not exist")
allowed_read_phases = {
models.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
@@ -371,57 +343,41 @@ async def _step_06a_validate_read_permissions(
models.ReleasePhase.RELEASE_PREVIEW,
}
if release.phase not in allowed_read_phases:
- _fail(process, f"Release '{release.name}' is not in a readable phase
({release.phase.value})", None)
- return None, False
+ raise RsyncArgsError(f"Release '{release.name}' is not in a readable
phase ({release.phase.value})")
if not user.is_committer(project.committee, ssh_uid):
- _fail(
- process,
- f"You must be a committer or committee member for project
'{project.name}' to read this release",
- None,
+ raise RsyncArgsError(
+ f"You must be a committer or committee member for project
'{project.name}' to read this release"
)
- return None, False
- return release, True
+ return release
async def _step_06b_validate_write_permissions(
- process: asyncssh.SSHServerProcess,
ssh_uid: str,
project: models.Project,
release: models.Release | None,
-) -> bool:
+) -> None:
"""Validate permissions for a write request."""
if release is None:
# Creating a new release requires committee membership
if not user.is_committee_member(project.committee, ssh_uid):
- return _fail(
- process,
- f"You must be a member of project '{project.name}' committee
to create a release",
- False,
- )
+ raise RsyncArgsError(f"You must be a member of project
'{project.name}' committee to create a release")
else:
# Uploading to existing release, requires DRAFT and participant status
if release.phase != models.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
- return _fail(
- process,
- f"Cannot upload: Release '{release.name}' is no longer in
draft phase ({release.phase.value})",
- False,
+ raise RsyncArgsError(
+ f"Cannot upload: Release '{release.name}' is no longer in
draft phase ({release.phase.value})"
)
if not user.is_committer(project.committee, ssh_uid):
- return _fail(
- process,
- f"You must be a committer or committee member for project
'{project.name}' "
- "to upload to this draft release",
- False,
+ raise RsyncArgsError(
+ f"You must be a committer or committee member for project
'{project.name}' to upload to this release"
)
- return True
async def _step_07a_process_validated_rsync_read(
process: asyncssh.SSHServerProcess,
argv: list[str],
- path_index: int,
release: models.Release,
) -> None:
"""Handle a validated rsync read request."""
@@ -436,12 +392,12 @@ async def _step_07a_process_validated_rsync_read(
# Check whether the source directory actually exists before proceeding
if not await aiofiles.os.path.isdir(source_dir):
- return _fail(process, f"Source directory '{source_dir}' not found
for release {release.name}", None)
+ raise RsyncArgsError(f"Source directory '{source_dir}' not found
for release {release.name}")
# Update the rsync command path to the determined source directory
- argv[path_index] = str(source_dir)
- if not argv[path_index].endswith("/"):
- argv[path_index] += "/"
+ argv[-1] = str(source_dir)
+ if not argv[-1].endswith("/"):
+ argv[-1] += "/"
###################################################
### Calls _step_08_execute_rsync_sender_command ###
@@ -458,114 +414,92 @@ async def _step_07a_process_validated_rsync_read(
except Exception as e:
_LOGGER.exception(f"Error during rsync read processing for
{release.name}")
- _fail(process, f"Internal error processing read request: {e}", None)
- process.exit(1)
+ raise RsyncArgsError(f"Internal error processing read request: {e}")
async def _step_07b_process_validated_rsync_write(
process: asyncssh.SSHServerProcess,
argv: list[str],
- path_index: int,
project_name: str,
version_name: str,
) -> None:
"""Handle a validated rsync write request."""
asf_uid = process.get_extra_info("username")
- exit_status = 1
-
+ exit_status = 0
release_name = models.release_name(project_name, version_name)
- try:
- # Ensure the release object exists or is created
- # This must happen before creating the revision directory
- #######################################################
- ### Calls _step_07c_ensure_release_object_for_write ###
- #######################################################
- if not await _step_07c_ensure_release_object_for_write(process,
project_name, version_name):
- # The _fail function was already called in
_07b2_ensure_release_object_for_write
- return
-
- # Create the draft revision directory structure
- description = "File synchronisation through ssh, using rsync"
- async with revision.create_and_manage(project_name, version_name,
asf_uid, description=description) as creating:
- # Uses new_revision_number for logging only
+
+ # Ensure the release object exists or is created
+ # This must happen before creating the revision directory
+ #######################################################
+ ### Calls _step_07c_ensure_release_object_for_write ###
+ #######################################################
+ await _step_07c_ensure_release_object_for_write(project_name, version_name)
+
+ # Create the draft revision directory structure
+ description = "File synchronisation through ssh, using rsync"
+ async with revision.create_and_manage(project_name, version_name, asf_uid,
description=description) as creating:
+ # Uses new_revision_number for logging only
+ if creating.old is not None:
+ _LOGGER.info(f"Using old revision {creating.old.number} and
interim path {creating.interim_path}")
+ # Update the rsync command path to the new revision directory
+ argv[-1] = str(creating.interim_path)
+
+ ###################################################
+ ### Calls _step_08_execute_rsync_upload_command ###
+ ###################################################
+ exit_status = await _step_08_execute_rsync(process, argv)
+ if exit_status != 0:
if creating.old is not None:
- _LOGGER.info(f"Using old revision {creating.old.number} and
interim path {creating.interim_path}")
- # Update the rsync command path to the new revision directory
- argv[path_index] = str(creating.interim_path)
-
- ###################################################
- ### Calls _step_08_execute_rsync_upload_command ###
- ###################################################
- exit_status = await _step_08_execute_rsync(process, argv)
- if exit_status != 0:
- if creating.old is not None:
- for_revision = f"successor of revision
{creating.old.number}"
- else:
- for_revision = f"initial revision for release
{release_name}"
- _LOGGER.error(
- f"rsync upload failed with exit status {exit_status} for
{for_revision}. "
- f"Command: {process.command} (run as {' '.join(argv)})"
- )
- raise revision.FailedError(f"rsync upload failed with exit
status {exit_status} for {for_revision}")
- if creating.new is not None:
- _LOGGER.info(f"rsync upload successful for revision
{creating.new.number}")
- host = config.get().APP_HOST
- message = f"\nATR: Created revision {creating.new.number} of
{project_name} {version_name}\n"
- message += f"ATR:
https://{host}/compose/{project_name}/{version_name}\n"
- if not process.stderr.is_closing():
- process.stderr.write(message.encode())
- await process.stderr.drain()
- else:
- _LOGGER.info(f"rsync upload unsuccessful for release
{release_name}")
- if not process.is_closing():
- process.exit(exit_status)
+ for_revision = f"successor of revision {creating.old.number}"
+ else:
+ for_revision = f"initial revision for release {release_name}"
+ _LOGGER.error(
+ f"rsync upload failed with exit status {exit_status} for
{for_revision}. "
+ f"Command: {process.command} (run as {' '.join(argv)})"
+ )
+ raise revision.FailedError(f"rsync upload failed with exit status
{exit_status} for {for_revision}")
+
+ if creating.new is not None:
+ _LOGGER.info(f"rsync upload successful for revision
{creating.new.number}")
+ host = config.get().APP_HOST
+ message = f"\nATR: Created revision {creating.new.number} of
{project_name} {version_name}\n"
+ message += f"ATR:
https://{host}/compose/{project_name}/{version_name}\n"
+ if not process.stderr.is_closing():
+ process.stderr.write(message.encode())
+ await process.stderr.drain()
+ else:
+ _LOGGER.info(f"rsync upload unsuccessful for release {release_name}")
- except Exception as e:
- _LOGGER.exception(f"Error during draft revision processing for
{release_name}")
- _fail(process, f"Internal error processing upload revision: {e}", None)
- if not process.is_closing():
- process.exit(1)
+ # If we got here, there was no exception
+ if not process.is_closing():
+ process.exit(exit_status)
-async def _step_07c_ensure_release_object_for_write(
- process: asyncssh.SSHServerProcess, project_name: str, version_name: str
-) -> bool:
+async def _step_07c_ensure_release_object_for_write(project_name: str,
version_name: str) -> None:
"""Ensure the release object exists or create it for a write operation."""
release_name = models.release_name(project_name, version_name)
- try:
- async with db.session() as data:
- async with data.begin():
- release = await data.release(
- name=models.release_name(project_name, version_name),
_committee=True
- ).get()
- if release is None:
- project = await data.project(
- name=project_name, status=models.ProjectStatus.ACTIVE,
_committee=True
- ).demand(RuntimeError("Project not found after
validation"))
- if version_name_error :=
util.version_name_error(version_name):
- # This should ideally be caught by path validation,
but double check
- raise RuntimeError(f'Invalid version name
"{version_name}": {version_name_error}')
- # Create a new release object
- _LOGGER.info(f"Creating new release object for
{release_name}")
- release = models.Release(
- project_name=project.name,
- project=project,
- version=version_name,
- phase=models.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
- created=datetime.datetime.now(datetime.UTC),
- )
- data.add(release)
- elif release.phase !=
models.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
- return _fail(
- process,
- f"Release '{release.name}' is no longer in draft phase
({release.phase.value}) "
- "- cannot create new revision",
- False,
- )
- return True
- except Exception as e:
- _LOGGER.exception(f"Error ensuring release object for write:
{release_name}")
- return _fail(process, f"Internal error ensuring release object: {e}",
False)
+ async with db.session() as data:
+ release = await data.release(name=models.release_name(project_name,
version_name), _committee=True).get()
+ if release is None:
+ project = await data.project(name=project_name,
status=models.ProjectStatus.ACTIVE, _committee=True).demand(
+ RuntimeError("Project not found after validation")
+ )
+ if version_name_error := util.version_name_error(version_name):
+ # This should ideally be caught by path validation, but double
check
+ raise RuntimeError(f'Invalid version name "{version_name}":
{version_name_error}')
+ # Create a new release object
+ _LOGGER.info(f"Creating new release object for {release_name}")
+ release = models.Release(
+ project_name=project.name,
+ project=project,
+ version=version_name,
+ phase=models.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+ created=datetime.datetime.now(datetime.UTC),
+ )
+ data.add(release)
+ elif release.phase != models.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+ raise RsyncArgsError(f"Release '{release.name}' is no longer in
draft phase ({release.phase.value})")
+ await data.commit()
async def _step_08_execute_rsync(process: asyncssh.SSHServerProcess, argv:
list[str]) -> int:
diff --git a/atr/templates/user-ssh-keys.html b/atr/templates/user-ssh-keys.html
index 5e8f639..7864e89 100644
--- a/atr/templates/user-ssh-keys.html
+++ b/atr/templates/user-ssh-keys.html
@@ -1,6 +1,9 @@
<p>
The ATR server should be compatible with long obsolete versions of rsync, as
long as you use the command as shown, but as of May 2025 the only rsync version
line without <a
href="https://github.com/google/security-research/security/advisories/GHSA-p5pg-x43v-mvqj">known
CVEs</a> is 3.4.*. Your package manager may have backports.
</p>
+<p>
+ If you find that you receive errors from ATR when using rsync, please <a
href="https://github.com/apache/tooling-trusted-release/issues/new?template=BLANK_ISSUE">open
an issue</a> and we will try our best to make ATR compatible.
+</p>
{% set key_count = user_ssh_keys|length %}
{% if key_count == 0 %}
<p>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]