Files
spoopyghosty0--frameport/tests/test_native_upload.py
T
spoopyghosty0 3d260dccb1 OpenSSH uploads log in like the Frame connection; SSH key files in Settings (#242)
A Frame that only authorizes the user's own key refused OpenSSH, which
offered FramePort's keys alone (IdentitiesOnly), so every upload fell back
to Paramiko. The connection now records which login worked (Frame.auth):
after a login with this PC's own keys/agent OpenSSH also offers private
copies of ~/.ssh/id_* (OpenSSH skips its defaults once -i is given) and the
agent; a refused strict login is retried that way once. FramePort doesn't
add its key to the Frame for this.

New: Settings > Installing > SSH keys (library ssh.extra_keys, CLI
frameport frame ssh-keys): extra private key files for the connection and
OpenSSH, checked with Paramiko (passphrase keys: use the agent). OpenSSH
runs with -v and a refused login's error lists the keys offered and its
warnings. whoami's SID is read from bytes (non-ASCII user names).
Checked with Windows ssh.exe/sftp.exe against the Frame.
2026-10-15 19:58:58 -04:00

320 lines
14 KiB
Python

"""Bulk backend selection, paired-host trust, resumable native batches and cancellation."""
import contextlib
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from frameport.core.events import Cancelled, Reporter
from frameport.frame import native_upload as native
from frameport.frame.connection import Frame, FrameTarget
from frameport.install import installer
@pytest.mark.parametrize("port_flag", ["-P", "-p"])
def test_pinned_credentials(tmp_path, monkeypatch, port_flag):
monkeypatch.setattr(native, "ssh_dir", lambda: tmp_path)
(tmp_path / "id_ed25519").write_text("private")
key = SimpleNamespace(get_name=lambda: "ssh-ed25519", get_base64=lambda: "PAIRED_KEY")
client = SimpleNamespace(get_transport=lambda: SimpleNamespace(get_remote_server_key=lambda: key))
frame = Frame(FrameTarget("paired.example", port=2222), client=client)
with native.credentials(frame, port_flag=port_flag) as args:
known = next(arg.split("=", 1)[1].strip('"') for arg in args if arg.startswith("UserKnownHostsFile="))
from pathlib import Path
assert Path(known).read_text() == "[paired.example]:2222 ssh-ed25519 PAIRED_KEY\n"
assert "StrictHostKeyChecking=yes" in args
assert "BatchMode=yes" in args
assert args[-2:] == [port_flag, "2222"]
copy = args[args.index("-i") + 1]
assert Path(copy).read_text() == "private"
assert Path(copy) != tmp_path / "id_ed25519"
assert not Path(copy).exists()
assert (tmp_path / "id_ed25519").read_text() == "private"
@pytest.mark.parametrize("value", ["bad\npath", "bad\rpath", "bad\x00path"])
def test_quote_refuses_batch_injection(value):
with pytest.raises(ValueError):
native.quote(value)
def test_quote_leaves_globs_to_sftp():
"""Inside double quotes sftp escapes * ? [ itself: an extra backslash became part of the name (GitHub #324)."""
assert native.quote('a *?["b') == '"a *?[\\"b"'
assert native.quote("/x/te*s?t [ENG] \\b+(1).apk") == '"/x/te*s?t [ENG] \\\\b+(1).apk"'
def test_quote_windows_local_path_uses_forward_slashes():
"""Win32-OpenSSH turns a local path's backslashes into '/', escapes included ("C://Users", "/[ENG]")."""
path = r"C:\Users\user_name\AppData\Local\FramePort\output\All-In-One - Sports VR [ENG] v100+1.9.0 (x)\a.apk"
assert native.quote(path, windows=True) == (
'"C:/Users/user_name/AppData/Local/FramePort/output/All-In-One - Sports VR [ENG] v100+1.9.0 (x)/a.apk"')
assert native.quote(r"\\server\share\*?[a].apk", windows=True) == '"//server/share/*?[a].apk"'
assert native.quote('C:\\odd "name"\\a.apk', windows=True) == '"C:/odd \\"name\\"/a.apk"'
@pytest.mark.parametrize("os_name,expected", [("nt", '"C:/games/[ENG] a/x.apk"'),
("posix", '"C:\\\\games\\\\[ENG] a\\\\x.apk"')])
def test_quote_local_follows_the_platform(monkeypatch, os_name, expected):
monkeypatch.setattr(native.os, "name", os_name)
assert native.quote_local(r"C:\games\[ENG] a\x.apk") == expected
def test_batch_line_for_bracketed_folder(tmp_path, monkeypatch):
folder = tmp_path / "All-In-One - Sports VR [ENG] v100+1.9.0"
folder.mkdir()
frame, items, files, commands, processes = setup_batch(folder, monkeypatch)
monkeypatch.setattr(native.os, "name", "posix")
native.upload(frame, items, "/incoming", Reporter(), 20, 0, 1)
assert sorted(commands[0].splitlines()) == [f'put "{folder}/patch.obb" "/incoming/patch.obb.part"',
f'reput "{folder}/main.obb" "/incoming/main.obb.part"']
def test_size_lookup_does_not_hide_connection_errors():
sftp = Mock()
sftp.stat.side_effect = ConnectionError("lost connection")
with pytest.raises(ConnectionError):
native.size_of(sftp, "/file")
def setup_batch(tmp_path, monkeypatch, code=0):
paths = [tmp_path / "main.obb", tmp_path / "patch.obb"]
for path in paths:
path.write_bytes(b"0123456789")
items = [(path, path.name, 10) for path in paths]
files = {"/incoming/main.obb.part": 4, "/incoming/patch.obb.part": 20}
monitor = Mock()
monitor.__enter__ = Mock(return_value=monitor)
monitor.__exit__ = Mock(return_value=False)
def stat(path):
if path not in files:
raise FileNotFoundError(path)
return SimpleNamespace(st_size=files[path])
monitor.stat.side_effect = stat
monitor.remove.side_effect = lambda path: files.pop(path)
frame = SimpleNamespace(sftp=monitor, client=SimpleNamespace(open_sftp=lambda: monitor),
target=FrameTarget("paired"), run=Mock(return_value=(0, "", "")), modes_seen=[])
monkeypatch.setattr(native.shutil, "which", lambda _: "sftp")
@contextlib.contextmanager
def creds(frame, relaxed=False):
frame.modes_seen.append(relaxed)
yield ["-o", "StrictHostKeyChecking=yes"]
monkeypatch.setattr(native, "credentials", creds)
commands, processes = [], []
def popen(args, **kwargs):
commands.append(kwargs["stdin"].read().decode())
for _, rel, size in items:
files["/incoming/" + rel + ".part"] = size
process = Mock(returncode=code)
process.poll.return_value = code
processes.append(process)
return process
monkeypatch.setattr(native.subprocess, "Popen", popen)
return frame, items, files, commands, processes
def test_native_batches_resume_reset_oversize_and_verify(tmp_path, monkeypatch):
frame, items, files, commands, processes = setup_batch(tmp_path, monkeypatch)
assert native.upload(frame, items, "/incoming", Reporter(), 20, 0, 3) == 20
assert len(processes) == 2 # never more processes than files
assert commands[0].startswith("reput ")
assert commands[1].startswith("put ")
assert all(".part" in command for command in commands)
frame.sftp.remove.assert_called_once_with("/incoming/patch.obb.part")
assert frame.run.call_count == 1
assert all("mv -f --" in call.args[0] for call in frame.run.call_args_list)
frame.sftp.get_channel().settimeout.assert_called_once_with(2)
assert all(process.wait.called for process in processes)
def test_native_complete_parts_are_only_renamed(tmp_path, monkeypatch):
"""A .part as big as its file (left by a cancelled run) isn't sent again: sftp's reput refuses it."""
frame, items, files, commands, processes = setup_batch(tmp_path, monkeypatch)
files["/incoming/main.obb.part"] = 10
assert native.upload(frame, items, "/incoming", Reporter(), 20, 0, 3) == 20
assert len(processes) == 1 and "main.obb" not in commands[0] and commands[0].startswith("put ")
assert "main.obb.part" in frame.run.call_args.args[0]
files["/incoming/patch.obb.part"] = 10
commands.clear()
processes.clear()
assert native.upload(frame, items, "/incoming", Reporter(), 20, 0, 3) == 20
assert not processes and frame.run.call_count == 2
def test_native_failure_does_not_promote_files(tmp_path, monkeypatch):
frame, items, files, commands, processes = setup_batch(tmp_path, monkeypatch, code=1)
with pytest.raises(OSError, match="OpenSSH SFTP failed"):
native.upload(frame, items, "/incoming", Reporter(), 20, 0, 2)
assert not frame.run.called
assert files["/incoming/main.obb.part"] == 10
assert all(process.wait.called for process in processes)
def test_native_success_still_checks_remote_sizes(tmp_path, monkeypatch):
frame, items, files, commands, processes = setup_batch(tmp_path, monkeypatch)
original = native.subprocess.Popen
def truncated(*args, **kwargs):
process = original(*args, **kwargs)
files["/incoming/main.obb.part"] = 9
return process
monkeypatch.setattr(native.subprocess, "Popen", truncated)
with pytest.raises(OSError, match="unexpected size"):
native.upload(frame, items, "/incoming", Reporter(), 20, 0, 2)
assert not frame.run.called
assert all(process.wait.called for process in processes)
def test_native_cancel_stops_owned_processes(tmp_path, monkeypatch):
frame, items, files, commands, processes = setup_batch(tmp_path, monkeypatch)
report = Reporter()
report.progress = lambda *args, **kwargs: report.cancelled.set()
monkeypatch.setattr(native, "stop_process", Mock())
with pytest.raises(Cancelled):
native.upload(frame, items, "/incoming", report, 20, 0, 2)
assert not frame.run.called
assert native.stop_process.call_count == 2
@pytest.mark.parametrize("cancel", [False, True])
def test_production_fallback_and_cancel(tmp_path, monkeypatch, cancel):
path = tmp_path / "game.apk"
path.write_bytes(b"apk")
frame = Frame(FrameTarget("paired"))
frame.mkdirs = Mock()
frame.put = Mock()
report = Reporter()
fractions = []
report.progress = lambda fraction, *args, **kwargs: fractions.append(fraction)
def fail(frame, items, remote, reporter, *args):
reporter.progress(0.8, "native")
if cancel:
raise Cancelled()
raise OSError("native unavailable")
def fallback(local, remote, progress, **kwargs):
progress(0, installer.BIG_FILE)
progress(installer.BIG_FILE, installer.BIG_FILE)
frame.put.side_effect = fallback
monkeypatch.setattr(native, "upload", fail)
call = lambda: installer.upload_files(frame, [(path, "game.apk", installer.BIG_FILE)],
"/incoming", report, installer.BIG_FILE, workers=1)
if cancel:
with pytest.raises(Cancelled):
call()
assert not frame.put.called
else:
assert call() == installer.BIG_FILE
frame.put.assert_called_once()
assert fractions == sorted(fractions)
@pytest.mark.parametrize("failure", [None, OSError("ssh failed"), Cancelled()])
def test_tar_transport_selection(tmp_path, monkeypatch, failure):
path = tmp_path / "small.bin"
path.write_bytes(b"payload")
frame = Frame(FrameTarget("paired.example"))
frame.mkdirs = Mock()
frame.put_tar = Mock()
events = []
report = Reporter([events.append])
stream = Mock(side_effect=failure)
monkeypatch.setattr(native, "put_tar", stream)
args = (frame, [(path, "data/small.bin", 7)], "/incoming", report, 7)
if isinstance(failure, Cancelled):
with pytest.raises(Cancelled):
installer.upload_files(*args)
frame.put_tar.assert_not_called()
else:
assert installer.upload_files(*args) == 7
assert frame.put_tar.called == (failure is not None)
logs = [event.message for event in events if event.kind == "log"]
assert any("tar batch with OpenSSH" in message for message in logs)
if failure:
assert any("OpenSSH unavailable or failed; resuming with Paramiko" in message for message in logs)
assert any("tar batch with Paramiko" in message for message in logs)
stream.assert_called_once()
def setup_tar(tmp_path, monkeypatch, code=0):
import io
path = tmp_path / "small.bin"
path.write_bytes(b"payload")
frame = SimpleNamespace(target=FrameTarget("paired.example", port=2222), modes_seen=[])
pipe = io.BytesIO()
pipe.close = lambda: None
process = Mock(stdin=pipe, returncode=code)
process.poll.return_value = code
popen = Mock(return_value=process)
monkeypatch.setattr(native.subprocess, "Popen", popen)
monkeypatch.setattr(native.shutil, "which", lambda _: "ssh")
@contextlib.contextmanager
def creds(frame, port_flag, relaxed=False):
assert port_flag == "-p"
frame.modes_seen.append(relaxed)
yield ["-o", "StrictHostKeyChecking=yes", port_flag, "2222"]
monkeypatch.setattr(native, "credentials", creds)
return frame, [(path, "nested/small.bin")], process, popen, pipe
def test_native_tar_stream_and_command(tmp_path, monkeypatch):
import tarfile
frame, files, process, popen, pipe = setup_tar(tmp_path, monkeypatch)
progress = Mock()
native.put_tar(frame, files, "/incoming with 'quote", Reporter(), progress)
args = popen.call_args.args[0]
assert args == ["ssh", "-o", "StrictHostKeyChecking=yes", "-p", "2222", "steamos@paired.example",
"mkdir -p '/incoming with '\"'\"'quote' && tar -xf - -C '/incoming with '\"'\"'quote'"]
pipe.seek(0)
with tarfile.open(fileobj=pipe) as archive:
assert archive.getnames() == ["nested/small.bin"]
assert archive.extractfile("nested/small.bin").read() == b"payload"
progress.assert_called_once_with(7, "nested/small.bin")
process.wait.assert_called_once()
def test_native_tar_failed_exit(tmp_path, monkeypatch):
frame, files, process, popen, pipe = setup_tar(tmp_path, monkeypatch, code=1)
with pytest.raises(OSError, match="OpenSSH tar failed"):
native.put_tar(frame, files, "/incoming", Reporter(), Mock())
process.wait.assert_called_once()
def test_native_tar_cancel_blocked_writer(tmp_path, monkeypatch):
import threading
frame, files, process, popen, pipe = setup_tar(tmp_path, monkeypatch)
report = Reporter()
released = threading.Event()
def blocked_write(data):
report.cancelled.set()
assert released.wait(5)
raise BrokenPipeError()
pipe.write = blocked_write
pipe.close = Mock(side_effect=BrokenPipeError())
process.poll.return_value = None
stop = Mock(side_effect=lambda process: released.set())
monkeypatch.setattr(native, "stop_process", stop)
with pytest.raises(Cancelled):
native.put_tar(frame, files, "/incoming", report, Mock())
stop.assert_called_once_with(process)
@pytest.mark.parametrize("prefer_native", [False, True])
def test_tar_unavailable_and_explicit_paramiko(tmp_path, monkeypatch, prefer_native):
path = tmp_path / "small.bin"
path.write_bytes(b"payload")
frame = Frame(FrameTarget("paired.example"))
frame.mkdirs = Mock()
frame.put_tar = Mock()
monkeypatch.setattr(native.shutil, "which", lambda _: None)
monkeypatch.setattr(installer, "BATCH_FILES", 1)
stream = Mock(wraps=native.put_tar)
monkeypatch.setattr(native, "put_tar", stream)
events = []
assert installer.upload_files(frame, [(path, "one", 7), (path, "two", 7)], "/incoming",
Reporter([events.append]), 14, prefer_native=prefer_native) == 14
assert stream.call_count == int(prefer_native) # a failed backend isn't retried for every batch
assert frame.put_tar.call_count == 2
assert sum("OpenSSH unavailable or failed" in event.message for event in events) == int(prefer_native)