mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-19 16:01:45 +02:00
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.
320 lines
14 KiB
Python
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)
|