fix(ssh): keep bulk sync extraction scoped to .hermes
This commit is contained in:
@@ -91,7 +91,7 @@ class TestSSHBulkUpload:
|
|||||||
assert "/home/testuser/.hermes/credentials" in mkdir_str
|
assert "/home/testuser/.hermes/credentials" in mkdir_str
|
||||||
|
|
||||||
def test_staging_symlinks_mirror_remote_layout(self, mock_env, tmp_path):
|
def test_staging_symlinks_mirror_remote_layout(self, mock_env, tmp_path):
|
||||||
"""Symlinks in staging dir should mirror the remote path structure."""
|
"""Symlinks in staging dir should mirror the .hermes-relative layout."""
|
||||||
f1 = tmp_path / "local_a.txt"
|
f1 = tmp_path / "local_a.txt"
|
||||||
f1.write_text("content a")
|
f1.write_text("content a")
|
||||||
|
|
||||||
@@ -107,9 +107,7 @@ class TestSSHBulkUpload:
|
|||||||
c_idx = cmd.index("-C")
|
c_idx = cmd.index("-C")
|
||||||
staging_dir = cmd[c_idx + 1]
|
staging_dir = cmd[c_idx + 1]
|
||||||
# Check the symlink exists
|
# Check the symlink exists
|
||||||
expected = os.path.join(
|
expected = os.path.join(staging_dir, "skills/my_skill.md")
|
||||||
staging_dir, "home/testuser/.hermes/skills/my_skill.md"
|
|
||||||
)
|
|
||||||
staging_paths.append(expected)
|
staging_paths.append(expected)
|
||||||
assert os.path.islink(expected), f"Expected symlink at {expected}"
|
assert os.path.islink(expected), f"Expected symlink at {expected}"
|
||||||
assert os.readlink(expected) == os.path.abspath(str(f1))
|
assert os.readlink(expected) == os.path.abspath(str(f1))
|
||||||
@@ -166,14 +164,42 @@ class TestSSHBulkUpload:
|
|||||||
assert "-" in tar_cmd # stdout
|
assert "-" in tar_cmd # stdout
|
||||||
assert "-C" in tar_cmd
|
assert "-C" in tar_cmd
|
||||||
|
|
||||||
# ssh: extract from stdin at /, preserving existing dir modes (#17767)
|
# ssh: extract from stdin at ~/.hermes, preserving existing dir modes (#17767)
|
||||||
ssh_str = " ".join(ssh_cmd)
|
ssh_str = " ".join(ssh_cmd)
|
||||||
assert "ssh" in ssh_str
|
assert "ssh" in ssh_str
|
||||||
assert "tar xf -" in ssh_str
|
assert "tar xf -" in ssh_str
|
||||||
assert "--no-overwrite-dir" in ssh_str
|
assert "--no-overwrite-dir" in ssh_str
|
||||||
assert "-C /" in ssh_str
|
assert "-C /home/testuser/.hermes" in ssh_str
|
||||||
assert "testuser@example.com" in ssh_str
|
assert "testuser@example.com" in ssh_str
|
||||||
|
|
||||||
|
def test_bulk_upload_never_stages_remote_home_prefix(self, mock_env, tmp_path):
|
||||||
|
"""Regression: do not archive /home/<user> path components."""
|
||||||
|
f1 = tmp_path / "nested.txt"
|
||||||
|
f1.write_text("nested")
|
||||||
|
files = [(str(f1), "/home/testuser/.hermes/cache/nested.txt")]
|
||||||
|
|
||||||
|
def capture_tar_cmd(cmd, **kwargs):
|
||||||
|
if cmd[0] == "tar":
|
||||||
|
c_idx = cmd.index("-C")
|
||||||
|
staging_dir = cmd[c_idx + 1]
|
||||||
|
assert not os.path.exists(os.path.join(staging_dir, "home"))
|
||||||
|
expected = os.path.join(staging_dir, "cache/nested.txt")
|
||||||
|
assert os.path.islink(expected)
|
||||||
|
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.stdout = MagicMock()
|
||||||
|
mock.returncode = 0
|
||||||
|
mock.poll.return_value = 0
|
||||||
|
mock.communicate.return_value = (b"", b"")
|
||||||
|
mock.stderr = MagicMock()
|
||||||
|
mock.stderr.read.return_value = b""
|
||||||
|
return mock
|
||||||
|
|
||||||
|
with patch.object(subprocess, "run",
|
||||||
|
return_value=subprocess.CompletedProcess([], 0)), \
|
||||||
|
patch.object(subprocess, "Popen", side_effect=capture_tar_cmd):
|
||||||
|
mock_env._ssh_bulk_upload(files)
|
||||||
|
|
||||||
def test_mkdir_failure_raises(self, mock_env, tmp_path):
|
def test_mkdir_failure_raises(self, mock_env, tmp_path):
|
||||||
"""mkdir failure should raise RuntimeError before tar pipe."""
|
"""mkdir failure should raise RuntimeError before tar pipe."""
|
||||||
f1 = tmp_path / "y.txt"
|
f1 = tmp_path / "y.txt"
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ class SSHEnvironment(BaseEnvironment):
|
|||||||
if not files:
|
if not files:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
base = f"{self._remote_home}/.hermes"
|
||||||
parents = unique_parent_dirs(files)
|
parents = unique_parent_dirs(files)
|
||||||
if parents:
|
if parents:
|
||||||
cmd = self._build_ssh_command()
|
cmd = self._build_ssh_command()
|
||||||
@@ -180,7 +181,19 @@ class SSHEnvironment(BaseEnvironment):
|
|||||||
# Symlink staging avoids fragile GNU tar --transform rules.
|
# Symlink staging avoids fragile GNU tar --transform rules.
|
||||||
with tempfile.TemporaryDirectory(prefix="hermes-ssh-bulk-") as staging:
|
with tempfile.TemporaryDirectory(prefix="hermes-ssh-bulk-") as staging:
|
||||||
for host_path, remote_path in files:
|
for host_path, remote_path in files:
|
||||||
staged = os.path.join(staging, remote_path.lstrip("/"))
|
try:
|
||||||
|
rel_remote = os.path.relpath(remote_path, base)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"remote path {remote_path!r} is not under sync base {base!r}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if rel_remote == "." or rel_remote.startswith("../"):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"remote path {remote_path!r} escapes sync base {base!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
staged = os.path.join(staging, rel_remote)
|
||||||
os.makedirs(os.path.dirname(staged), exist_ok=True)
|
os.makedirs(os.path.dirname(staged), exist_ok=True)
|
||||||
os.symlink(os.path.abspath(host_path), staged)
|
os.symlink(os.path.abspath(host_path), staged)
|
||||||
|
|
||||||
@@ -190,7 +203,7 @@ class SSHEnvironment(BaseEnvironment):
|
|||||||
# existing directories (e.g. /home/<user>) with the staging
|
# existing directories (e.g. /home/<user>) with the staging
|
||||||
# directory's mode. Without this, a umask 002 produces 0775
|
# directory's mode. Without this, a umask 002 produces 0775
|
||||||
# dirs which breaks sshd StrictModes (refuses authorized_keys).
|
# dirs which breaks sshd StrictModes (refuses authorized_keys).
|
||||||
ssh_cmd.append("tar xf - --no-overwrite-dir -C /")
|
ssh_cmd.append(f"tar xf - --no-overwrite-dir -C {shlex.quote(base)}")
|
||||||
|
|
||||||
tar_proc = subprocess.Popen(
|
tar_proc = subprocess.Popen(
|
||||||
tar_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
tar_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||||
|
|||||||
Reference in New Issue
Block a user