diff --git a/.github/workflows/async-get.yml b/.github/workflows/async-get.yml new file mode 100644 index 000000000..5cf399334 --- /dev/null +++ b/.github/workflows/async-get.yml @@ -0,0 +1,38 @@ +name: Async mod HTTP and file downloads +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + transport: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.x' + - if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y love + - name: Exercise real worker requests against a local server + shell: python + run: | + import os, pathlib, shutil, subprocess, urllib.request, zipfile + host = os.environ['RUNNER_OS'] + if host == 'Linux': + executable = shutil.which('love') + else: + suffix = 'macos' if host == 'macOS' else 'win64' + target = pathlib.Path(os.environ['RUNNER_TEMP']) / 'love-get-check' + target.mkdir() + archive = target / 'runtime.zip' + urllib.request.urlretrieve(f'https://github.com/love2d/love/releases/download/11.5/love-11.5-{suffix}.zip', archive) + with zipfile.ZipFile(archive) as z: z.extractall(target) + executable = next(target.rglob('love.app/Contents/MacOS/love')) if host == 'macOS' else next(target.rglob('lovec.exe')) + if host == 'macOS': executable.chmod(0o755) + subprocess.run(['python', 'tools/check_async_get.py', '--love', str(executable), '--tls'], check=True) diff --git a/.github/workflows/mod-native-http.yml b/.github/workflows/mod-native-http.yml new file mode 100644 index 000000000..54929ffcb --- /dev/null +++ b/.github/workflows/mod-native-http.yml @@ -0,0 +1,71 @@ +name: Native bounded HTTP +on: + pull_request: + paths: + - 'src/mods/LogReport.lua' + - 'src/mods/Net.lua' + - 'src/mods/Manifest.lua' + - 'src/mods/Loader.lua' + - 'src/net/**' + - 'src/core/HostShell.lua' + - 'mobile/ios/native/GRHTTPBridge.swift' + - 'mobile/ios/patch_love_src.py' + - 'mobile/android/love/src/main/java/org/love2d/android/BoundedHttp.java' + - 'mobile/android/love/src/main/java/org/love2d/android/GameActivity.java' + - 'mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp' + - 'tests/native_http/**' + - 'tests/engine/log_reporting.lua' + - 'tests/engine/bounded_http.lua' + - 'tools/qualify_native_http.py' + - '.github/workflows/mod-native-http.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: native-bounded-http-${{ github.ref }} + cancel-in-progress: true +jobs: + android-bridge-build: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - name: Install native Android build dependencies + run: '"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "platforms;android-34" "build-tools;34.0.0" "ndk;25.2.9519653"' + - name: Compile the Android HTTP adapter and native Lua binding + working-directory: mobile/android + run: bash ./gradlew --no-daemon :love:compileEmbedDebugJavaWithJavac :love:externalNativeBuildEmbedDebug + native-protocols: + runs-on: macos-15 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - run: brew install luajit + - run: luajit tests/engine/log_reporting.lua && luajit tests/engine/async_get_queue.lua && luajit tests/engine/bounded_http.lua + - name: Verify the pinned host-test dependency + shell: bash + run: | + set -euo pipefail + curl --fail --location https://repo.maven.apache.org/maven2/org/json/json/20240303/json-20240303.jar -o "$RUNNER_TEMP/json-20240303.jar" + printf '%s %s\n' 3cf6cd6892e32e2b4c1c39e0f52f5248a2f5b37646fdfbb79a66b46b618414ed "$RUNNER_TEMP/json-20240303.jar" | shasum -a 256 -c - + - name: Exercise native adapter logic and typecheck iOS + shell: bash + run: | + python3 tools/qualify_native_http.py --swiftc "$(xcrun --find swiftc)" --sdk "$(xcrun --sdk macosx --show-sdk-path)" --ios-sdk "$(xcrun --sdk iphoneos --show-sdk-path)" --java-home "$JAVA_HOME" --json-jar "$RUNNER_TEMP/json-20240303.jar" --output "$RUNNER_TEMP/native-http" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: native-bounded-http + path: | + ${{ runner.temp }}/native-http/*.json + ${{ runner.temp }}/native-http/*.log + if-no-files-found: warn + retention-days: 7 diff --git a/LICENSE-ROXAS2712.md b/LICENSE-ROXAS2712.md new file mode 100644 index 000000000..8931eb41f --- /dev/null +++ b/LICENSE-ROXAS2712.md @@ -0,0 +1,88 @@ +# Roxas2712 — Fork Contributions + +Version 1.0 · Effective 25 October 2026 + +Copyright © 2026 Roxas2712, to the extent of rights held. +All rights reserved except as expressly granted below or already granted. + +## 1. Scope and ownership + +This notice applies only to original contributions made by Roxas2712 +(GitHub: Roxas2712) to `Roxas2712/Gen2Recomped`, including original additions +within modified files, insofar as he owns the relevant rights. Git history, +pull-request diffs and source provenance identify contributions; a commit +author or a whole-file change alone does not establish ownership of every +part of that file. + +It does not claim ownership of Cedric's work, third-party engine work, +other contributors' work, third-party dependencies, or ROM-derived +content. Their applicable licences and notices remain in force. It does not +grant permission to distribute the complete combined fork where another +rights holder's permission is required. + +## 2. General permission and reserved rights + +For contributions covered by this notice and not already licensed more +broadly, you may read and study the published source and make private, +non-commercial modifications and builds for your own use. This grants no +general permission to redistribute, sublicense, sell, or offer those +contributions as a hosted service. Other uses require Roxas2712's +written permission, except for the specific permission below and rights +available independently under applicable law. + +Keep the relevant copyright, licence and attribution notices. Do not +represent Roxas2712's contributions as exclusively authored by someone +else. Public source availability alone does not put them in the public +domain or grant an open-source licence. + +## 3. Specific permission for Cedric / UNDERdecoded + +Roxas2712 expressly grants Cedric (Ced; GitHub: UNDERdecoded / +UNDERdecodedHD) a non-exclusive, worldwide, perpetual, royalty-free +permission to use, reproduce, integrate, modify and distribute the covered +contributions that Roxas2712 submits or expressly offers through pull +requests to `UNDERdecoded/Gen2Recomped`, for Cedric's Gen2Recomped / +RetroRecompiled project. This includes its renamed continuation maintained +by Cedric, its official builds, and its commercial operation. + +Cedric may license the incorporated contributions to recipients as part of +that project, subject to preserving this permission's applicable credit and +notice requirements. This permission is not a copyright assignment, is not +exclusive, and does not cover unpublished work, separate repositories, +ModCompass service operation, credentials, or content not offered in those +pull requests. Roxas2712 retains his own rights to his contributions. + +For uses relying on this permission: + +- retain the relevant source copyright and licence notices; +- give visible credit in the project's README or equivalent public project + page, and in the application's existing About / Credits view, if present; +- use at least the following credit, or a substantially equivalent one: + + > Contributions by Roxas2712 — + > https://github.com/Roxas2712 + +A maintained consolidated contributor credit is sufficient; a separate +credit on every screen or for every change is not required. Adapted +contributions must not be attributed exclusively to another author. Other +contributors' credits must also remain intact. + +## 4. Earlier licences and permissions remain effective + +This notice does not revoke, narrow, or retrospectively add conditions to +any permission already validly granted, including MIT / GPL grants, +separate agreements, or permissions arising from previously submitted +pull requests. Where an earlier grant permits a use independently of this +notice, its existing terms govern that use. For such earlier uses, the +visible credit above is requested to the extent it is not already required. + +For subsequent contributions, this is the default statement of Roxas2712's own reserved rights and the specific PR permission, subject to any +applicable existing licence obligations or separately agreed terms. It +does not change another rights holder's licence or guarantee compatibility +of a particular combination of code. + +## 5. No warranty + +To the extent permitted by applicable law, these contributions are provided +as is, without warranty. No support obligation, endorsement, or rights to +third-party code, game assets, names, or trademarks are granted. diff --git a/docs/mod-log-reporting.md b/docs/mod-log-reporting.md new file mode 100644 index 000000000..602013f6f --- /dev/null +++ b/docs/mod-log-reporting.md @@ -0,0 +1,60 @@ +# Fixed-destination log reporting and bounded native HTTP + +Declare a fixed HTTPS `log_url` and the `network` permission in the manifest. +API 2/3 reject invalid or ungranted declarations before executing the mod; legacy +declarations warn and discard an invalid destination. + +```lua +local job, why = mod:postLog("diagnostic text", {format="text"}) +-- Later: poll and release through the same interface as GET. +if job then + local result = mod.fetch:poll(job) + if result.status ~= "pending" then mod.fetch:release(job) end +end +``` + +The only option is `format`, either `text` (default) or `json`. Input is at most +65,536 bytes. JSON wraps it in `{ts, mod, format, body}`; its encoded payload is +bounded separately at 512 KiB. Unknown keys and invalid values submit nothing. +The endpoint and mod identity are captured when the API is created. Manifest +mutation, options and redirects cannot change the log destination. POST does not +follow redirects. No server response body reaches the mod, including error and +late/cancelled results. Public log results expose status, progress, HTTP status +and a stable failure diagnostic. The mod's existing network permission applies. + +GET and logs share the existing three-worker queue, global 16-job ceiling and +four-pending-requests-per-mod ceiling. Queue time counts towards the timeout. +Log requests use 20 seconds; other internal requests cap at 30 seconds. Released +or timed-out jobs retain their physical worker slot until completion. Poll reads +only worker results and performs no network, pipe or filesystem reads. + +Desktop POST reuses the bounded curl path. Payloads reside in worker-owned files +and never enter shell syntax; those files are removed after the request. Curl +configuration is disabled and explicit HTTPS protocol limits remain enforced. + +The new mobile `boundedHttpVersion()` / `httpRequestBounded()` protocol carries +binary payloads as bounded Base64 fields. Android reuses the existing JNI route +with a mandatory bounded envelope and a separately validated URLConnection +adapter; legacy cloud HTTP is unchanged. iOS uses a separate Foundation adapter +and private ephemeral sessions, without cookie/credential storage. Both adapters +retain platform TLS verification, bound body accumulation, refuse non-HTTP URLs +and HTTPS downgrades, and cap GET redirects at five. A POST is always HTTPS and +cannot redirect. iOS retains native task slots until terminal delegate callbacks; +Android closes the current connection on the absolute request deadline. + +An HTTP failure, timeout or cancellation does not prove that a server did not +accept the submitted log. The service does not retry POST automatically. + +Verification includes real owned desktop TLS transfers, literal bytes, expanded +JSON payloads, reply suppression, redirect refusal and existing GET/file-download +regressions. `tools/qualify_native_http.py` executes the production Swift and Java +adapter logic with URLProtocol/URLConnection fixtures and typechecks iOS. Those +fixtures do not exercise mobile TLS stacks or physical devices. Native packaging, +real mobile TLS/device acceptance, and resource behavior under suspension remain +separate gates. + +The fixed Switch runtime currently has no bounded HTTP binding; the service +reports unavailable there and does not attempt a subprocess. Its TLS/runtime +adapter still needs integration and qualification. A native source build and +matching Lua payload are required for the mobile protocol. This change does not +establish full mod gameplay or complete platform parity. diff --git a/docs/modding.md b/docs/modding.md index f6f71594f..a81e07943 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -1,5 +1,29 @@ # Native modding +## Background HTTP GET + +A mod declaring `"network"` receives `mod.fetch:get(url, options)`, `:poll(handle)`, +`:cancel(handle)`, `:release(handle)`, `:available()` and `:capabilities()`. +Handles belong to their creating mod. Poll returns a detached table with +`status` (`pending`, `ok`, `error` or `cancelled`), `body`, `err`, +`receivedBytes` and, when known, `httpStatus`. Always release finished handles. +Rollback and unload release a mod's requests automatically. + +GET transport runs in three LÖVE workers. The engine caps active/queued work at +16 requests, with four pending requests per mod. `maxSeconds` defaults to 20 +and caps at 30, including queue time; `maxBytes` defaults to 4 MiB and caps at +16 MiB. `boundedHttpsGet = 1` requires HTTPS throughout redirects. Desktop +capabilities advertise this constraint. Desktop curl is required; mobile +bridges are not yet supported by this API. Remote URLs and headers are passed +through a private curl config file, outside shell syntax. + +Cancel discards the result immediately. A curl already executing may occupy +its worker until its bounded timeout; releasing a handle cannot bypass the +global worker limit. Shutdown waits for those bounded operations. This adds +GET support; it does not implement POST, arbitrary requests or `mod.job`. +The older download-to-file API still performs a synchronous first poll. + + The modding book lives on the [project wiki](https://github.com/bryanthaboi/gen1recomp/wiki). @@ -277,3 +301,7 @@ The launcher hands the link to the operating system to open, so only `http://` and `https://` links are accepted. Any other value, such as a `file:` link or a custom scheme, is dropped with a warning. The mod itself still loads; it just gets no button. + +With `network` and a fixed HTTPS `log_url`, `mod:postLog` sends bounded one-way +diagnostics through the shared workers. See [log reporting](mod-log-reporting.md) +for payload, ownership and native platform boundaries. diff --git a/docs/native-download-worker-retirement.md b/docs/native-download-worker-retirement.md new file mode 100644 index 000000000..2da302a9b --- /dev/null +++ b/docs/native-download-worker-retirement.md @@ -0,0 +1,30 @@ +# Physical download worker retirement in transport QA + +Prepared on the existing offered HTTP/logging PR273 head523bf0bd. Production +transport and request/queue bounds are unchanged. The actual worker fixture +forwards original responses before reporting native request completion on a +QA-only channel. Before filling the queue, it waits for preceding physical +responses; a separate real-worker barrier proves that cancellation retains a +credit until physical retirement and returns exactly one credit afterward. +The existing cold-start/publication barriers, overflow, cancellation, +file-preservation and shutdown checks remain active. + +This also carries the independently qualified newer cold-start/publication +test synchronization into this older offer. Its existing45-second external +fixture process deadline remains unchanged. A newer fork composition passes +the actual official LOVE11.5 TLS fixture (1512checks), all949/4 official +ROM-free suites and70CI checks. Those receipts do not qualify this older +combined host. Its own committed source `d8c7deb0` independently passes the +actual official LOVE11.5 owned-loopback TLS fixture:1521 checks/595pump ticks +in6.98seconds, with the original45-second process deadline unchanged. All134 +regular runtime files match the official archive byte-for-byte. Receipt: +`upstream-download-retirement-1793247736116883000/receipt.json`. The installed +storage helper manages the ephemeral fixture, processes and unique profile; +only Python process/storage ownership is instrumented, not Lua assertions, +worker responses or transport behavior. No GPU, release, +all-sprite-download or selected actor-art acceptance is claimed. + +Integration/tests by Roxas2712 are offered under +https://github.com/Roxas2712/Gen2Recomped/blob/main/LICENSE-ROXAS2712.md, +including its specific Cedric / UNDERdecoded permission with visible credit. +Existing authors' rights, source notices and prior grants remain applicable. diff --git a/main.lua b/main.lua index 18325ffdf..8da697a54 100644 --- a/main.lua +++ b/main.lua @@ -1190,6 +1190,9 @@ function love.quit() if package.loaded["src.translation.Service"] then pcall(package.loaded["src.translation.Service"].shutdown) end + if package.loaded["src.net.Fetch"] then + pcall(package.loaded["src.net.Fetch"].shutdown) + end end function love.filedropped(file) diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index ec2348985..024e3fa3a 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -22,6 +22,7 @@ #include "wrap_System.h" #include "common/android.h" #include "sdl/System.h" +#include namespace love { @@ -199,6 +200,24 @@ int w_httpRequest(lua_State *L) return 1; } +// Bounded worker transport added by Roxas2712; shares the existing JNI route. +int w_httpRequestBounded(lua_State *L) +{ + size_t size = 0; + const char *json = luaL_checklstring(L, 1, &size); + if (size > 1024 * 1024 || std::memchr(json, 0, size) != nullptr) + lua_pushliteral(L, "{\"error\":\"invalid bounded request\"}"); + else + { + std::string envelope = "{\"boundedEnvelope\":"; + envelope.append(json, size); + envelope.push_back('}'); + luax_pushstring(L, instance()->httpRequest(envelope.c_str())); + } + return 1; +} +int w_boundedHttpVersion(lua_State *L) { lua_pushinteger(L, 1); return 1; } + int w_walkerNotify(lua_State *L) { const char *json = luaL_checkstring(L, 1); @@ -263,6 +282,8 @@ static const luaL_Reg functions[] = { "mkdirs", w_mkdirs }, { "walkerNotify", w_walkerNotify }, { "httpRequest", w_httpRequest }, + { "httpRequestBounded", w_httpRequestBounded }, + { "boundedHttpVersion", w_boundedHttpVersion }, { "syncHealthSteps", w_syncHealthSteps }, { "restartApp", w_restartApp }, { "httpDownload", w_httpDownload }, diff --git a/mobile/android/love/src/main/java/org/love2d/android/BoundedHttp.java b/mobile/android/love/src/main/java/org/love2d/android/BoundedHttp.java new file mode 100644 index 000000000..8d36bea24 --- /dev/null +++ b/mobile/android/love/src/main/java/org/love2d/android/BoundedHttp.java @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package org.love2d.android; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.concurrent.Semaphore; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.json.JSONObject; + +/** Worker-only bounded transport; the existing cloud transport is unchanged. */ +final class BoundedHttp { + static final int MAX_BODY = 512 * 1024, MAX_BYTES = 16 * 1024 * 1024; + private static final Semaphore slots = new Semaphore(16); + private static final ScheduledThreadPoolExecutor deadlines = new ScheduledThreadPoolExecutor(3, new ThreadFactory() { + public Thread newThread(Runnable task) { + Thread t = new Thread(task, "bounded-http-deadline"); t.setDaemon(true); return t; + } + }); + static { deadlines.setRemoveOnCancelPolicy(true); } + private static String string(JSONObject req, String key, int maximum) throws Exception { + Object value = req.get(key); + if (!(value instanceof String)) throw new Exception("invalid field"); + String text = (String) value; + if (text.length() > maximum) throw new Exception("field too large"); + for (int i=0;imaximum) throw new Exception("invalid bounds"); + return n; + } + private static boolean flag(JSONObject req,String key) throws Exception { + Object value=req.get(key);if (!(value instanceof Boolean)) throw new Exception("invalid flag");return (Boolean)value; + } + private static URL url(String value, boolean secure) throws Exception { + if (value.length()>8192) throw new Exception("URL too long"); + for (int i=0;i current=new AtomicReference<>(); + ScheduledFuture timeout=null;boolean acquired=false; + try { + if (req.toString().length()>1024*1024 || number(req,"boundedVersion",1,1)!=1) throw new Exception("invalid protocol"); + String method=string(req,"method",4); + if (!method.equals("GET") && !method.equals("POST")) throw new Exception("unsupported method"); + boolean secure=flag(req,"httpsOnly") || method.equals("POST"); + boolean discard=flag(req,"discardBody"); + URL target=url(string(req,"url",8192),secure); + String agent=string(req,"userAgent",512),accept=string(req,"accept",512),mime=string(req,"contentType",128); + double limit=number(req,"maxBytes",1,MAX_BYTES); + if (Math.floor(limit)!=limit) throw new Exception("invalid byte bound"); + int maximum=(int)limit; + double seconds=number(req,"maxSeconds",0.001,30); + byte[] body=null; + if (method.equals("POST")) { + Object encoded=req.get("bodyBase64"); + if (!(encoded instanceof String) || ((String)encoded).length()>((MAX_BODY+2)/3)*4) throw new Exception("request too large"); + body=android.util.Base64.decode((String)encoded,android.util.Base64.DEFAULT); + if (body.length>MAX_BODY) throw new Exception("request too large"); + } + if (!slots.tryAcquire()) throw new Exception("transport busy");acquired=true; + final long deadline=System.nanoTime()+(long)(seconds*1000000000L); + timeout=deadlines.schedule(new Runnable() { + public void run() { HttpURLConnection c=current.get();if(c!=null)c.disconnect(); } + },Math.max(1,(long)Math.ceil(seconds*1000)),TimeUnit.MILLISECONDS); + int redirects=0; + while (true) { + HttpURLConnection conn=(HttpURLConnection)target.openConnection();current.set(conn); + conn.setInstanceFollowRedirects(false);conn.setConnectTimeout(Math.min(10000,remaining(deadline))); + conn.setUseCaches(false); + conn.setReadTimeout(remaining(deadline));conn.setRequestMethod(method); + conn.setRequestProperty("User-Agent",agent);conn.setRequestProperty("Accept",accept); + conn.setRequestProperty("Accept-Encoding","identity"); + if (body!=null) { + conn.setRequestProperty("Content-Type",mime);conn.setDoOutput(true);conn.setFixedLengthStreamingMode(body.length); + try (OutputStream out=conn.getOutputStream()) { out.write(body); } + } + remaining(deadline);int status=conn.getResponseCode();result.put("status",status); + if (status==301 || status==302 || status==303 || status==307 || status==308) { + String location=conn.getHeaderField("Location"); + if (!method.equals("GET") || location==null || ++redirects>5) throw new Exception("redirect refused"); + target=url(new URL(target,location).toString(),secure);conn.disconnect();current.set(null);continue; + } + if (status<200 || status>=300) throw new Exception("HTTP status"); + if (!discard) { + long declared=-1;String length=conn.getHeaderField("Content-Length"); + if (length!=null) {try {declared=Long.parseLong(length);} catch(NumberFormatException bad) {throw new Exception("invalid length");}} + if (declared>maximum) throw new Exception("response too large"); + ByteArrayOutputStream bytes=new ByteArrayOutputStream(Math.min(maximum,65536)); + try (InputStream in=conn.getInputStream()) { + byte[] chunk=new byte[8192];int n; + while (true) { + conn.setReadTimeout(remaining(deadline));n=in.read(chunk);if(n<0)break; + if (n>maximum-bytes.size()) throw new Exception("response too large");bytes.write(chunk,0,n); + } + } + if (declared>=0 && declared!=bytes.size()) throw new Exception("partial response"); + result.put("bodyBase64",android.util.Base64.encodeToString(bytes.toByteArray(),android.util.Base64.NO_WRAP)); + } + remaining(deadline);return result.toString(); + } + } catch (Exception error) { + try {result.remove("bodyBase64");result.put("error","bounded HTTP request failed");} catch(Exception ignored){} + return result.toString(); + } finally { + if (timeout!=null)timeout.cancel(false); + try {HttpURLConnection conn=current.get();if(conn!=null)conn.disconnect();} + finally {if(acquired)slots.release();} + } + } +} diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 4eedb2f2c..1eed05e29 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -1175,6 +1175,8 @@ public static String httpRequest(String json) { HttpURLConnection conn = null; try { org.json.JSONObject req = new org.json.JSONObject(json); + if (req.has("boundedEnvelope")) return BoundedHttp.request(req.getJSONObject("boundedEnvelope")); + if (req.has("boundedVersion")) return BoundedHttp.request(req); String method = req.optString("method", "GET"); URL url = new URL(req.getString("url")); conn = (HttpURLConnection) url.openConnection(); diff --git a/mobile/ios/native/GRHTTPBridge.swift b/mobile/ios/native/GRHTTPBridge.swift new file mode 100644 index 000000000..c91c4e97a --- /dev/null +++ b/mobile/ios/native/GRHTTPBridge.swift @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +import Foundation +import CoreFoundation + +enum GRHTTPFailure: Error { case invalid } +struct GRHTTPRequest { + let url: URL, method: String, agent: String, accept: String, mime: String + let seconds: Double, maximum: Int, secure: Bool, discard: Bool, body: Data? + static func text(_ value: Any?, maximum: Int) throws -> String { + guard let text=value as? String, text.utf8.count<=maximum, + !text.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) + else { throw GRHTTPFailure.invalid } + return text + } + static func number(_ value: Any?, minimum: Double, maximum: Double) throws -> Double { + guard let n=value as? NSNumber, CFGetTypeID(n) != CFBooleanGetTypeID(), + n.doubleValue.isFinite, n.doubleValue>=minimum, n.doubleValue<=maximum + else { throw GRHTTPFailure.invalid } + return n.doubleValue + } + static func flag(_ value: Any?) throws -> Bool { + guard let n=value as? NSNumber, CFGetTypeID(n)==CFBooleanGetTypeID() else { throw GRHTTPFailure.invalid } + return n.boolValue + } + static func allowed(_ url: URL, secure: Bool) -> Bool { + guard let scheme=url.scheme?.lowercased(), let host=url.host, !host.isEmpty, + url.absoluteString.utf8.count<=8192, + !url.absoluteString.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) }) + else { return false } + return scheme=="https" || (!secure && scheme=="http") + } + init(_ json: String) throws { + guard json.utf8.count<=1024*1024, let data=json.data(using:.utf8), + let doc=try JSONSerialization.jsonObject(with:data) as? [String:Any] + else { throw GRHTTPFailure.invalid } + guard try Self.number(doc["boundedVersion"],minimum:1,maximum:1)==1 else { throw GRHTTPFailure.invalid } + method=try Self.text(doc["method"],maximum:4) + guard method=="GET" || method=="POST" else { throw GRHTTPFailure.invalid } + secure=try Self.flag(doc["httpsOnly"]) || method=="POST" + discard=try Self.flag(doc["discardBody"]) + let address=try Self.text(doc["url"],maximum:8192) + guard !address.unicodeScalars.contains(where:{CharacterSet.whitespacesAndNewlines.contains($0)}), + let target=URL(string:address), Self.allowed(target,secure:secure) + else { throw GRHTTPFailure.invalid } + url=target + agent=try Self.text(doc["userAgent"],maximum:512);accept=try Self.text(doc["accept"],maximum:512) + mime=try Self.text(doc["contentType"],maximum:128) + seconds=try Self.number(doc["maxSeconds"],minimum:0.001,maximum:30) + let bytes=try Self.number(doc["maxBytes"],minimum:1,maximum:16*1024*1024) + guard bytes.rounded(.down)==bytes else { throw GRHTTPFailure.invalid };maximum=Int(bytes) + if method=="POST" { + let encoded=try Self.text(doc["bodyBase64"],maximum:((512*1024+2)/3)*4) + guard let decoded=Data(base64Encoded:encoded), decoded.count<=512*1024 else { throw GRHTTPFailure.invalid } + body=decoded + } else { body=nil } + } +} + +private final class GRHTTPTransfer: NSObject, URLSessionDataDelegate, @unchecked Sendable { + let request: GRHTTPRequest + let done=DispatchSemaphore(value:0) + private let gate=NSLock() + private var result: [String:Any]? + private var bytes=Data() + private var status: Int? + private var declared: Int64 = -1 + private var redirects=0 + private var released=false + private let release: () -> Void + init(_ request: GRHTTPRequest, release: @escaping () -> Void) { self.request=request;self.release=release } + func finish(error: Bool) { + gate.lock() + if result != nil { gate.unlock();return } + var value: [String:Any]=[:] + if let code=status { value["status"]=code } + if error {value["error"]="bounded HTTP request failed"} + else if !request.discard {value["bodyBase64"]=bytes.base64EncodedString()} + result=value;gate.unlock();done.signal() + } + func take() -> [String:Any] { gate.lock();defer{gate.unlock()};return result ?? ["error":"bounded HTTP request failed"] } + func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) { + gate.lock() + let allowed=request.method=="GET" && redirects<5 && result==nil && + newRequest.url.map{GRHTTPRequest.allowed($0,secure:request.secure)}==true + if allowed {redirects+=1} else {status=response.statusCode} + gate.unlock() + if allowed { + var next=newRequest;next.setValue(request.agent,forHTTPHeaderField:"User-Agent") + next.setValue(request.accept,forHTTPHeaderField:"Accept") + next.setValue("identity",forHTTPHeaderField:"Accept-Encoding") + completionHandler(next) + } else {finish(error:true);completionHandler(nil)} + } + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + guard let http=response as? HTTPURLResponse else {finish(error:true);completionHandler(.cancel);return} + gate.lock();status=http.statusCode;declared=response.expectedContentLength + let valid=result==nil && (200..<300).contains(http.statusCode) + let oversized=declared>Int64(request.maximum) + gate.unlock() + if !valid || (!request.discard && oversized) {finish(error:true);completionHandler(.cancel)} + else if request.discard {finish(error:false);completionHandler(.cancel)} + else {completionHandler(.allow)} + } + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + gate.lock() + if result != nil {gate.unlock();return} + let oversized=data.count>request.maximum-bytes.count + if !oversized {bytes.append(data)} + gate.unlock() + if oversized {finish(error:true);dataTask.cancel()} + } + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + gate.lock() + let incomplete=declared>=0 && declared != Int64(bytes.count) + let firstRelease = !released;released=true + gate.unlock() + finish(error:error != nil || incomplete) + // A cancelled platform task retains its native slot until this terminal callback. + if firstRelease {release()} + } +} + +enum GRBoundedHTTP { + private static let pool=NSLock() + private static var active=0 + private static func acquire() -> Bool {pool.lock();defer{pool.unlock()};if active>=16{return false};active+=1;return true} + private static func release() {pool.lock();active-=1;pool.unlock()} + static func perform(_ json: String, configuration: URLSessionConfiguration? = nil) -> String { + let failure="{\"error\":\"bounded HTTP request failed\"}" + guard let request=try? GRHTTPRequest(json), acquire() else {return failure} + let transfer=GRHTTPTransfer(request,release:release) + let config=configuration ?? URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest=request.seconds;config.timeoutIntervalForResource=request.seconds + config.httpShouldSetCookies=false;config.httpCookieStorage=nil;config.urlCredentialStorage=nil + config.urlCache=nil;config.requestCachePolicy = .reloadIgnoringLocalCacheData + let session=URLSession(configuration:config,delegate:transfer,delegateQueue:nil) + var message=URLRequest(url:request.url,timeoutInterval:request.seconds) + message.httpMethod=request.method;message.httpBody=request.body + message.setValue(request.agent,forHTTPHeaderField:"User-Agent") + message.setValue(request.accept,forHTTPHeaderField:"Accept") + message.setValue("identity",forHTTPHeaderField:"Accept-Encoding") + if request.body != nil {message.setValue(request.mime,forHTTPHeaderField:"Content-Type")} + let task=session.dataTask(with:message);task.resume() + if transfer.done.wait(timeout:.now()+request.seconds) == .timedOut {transfer.finish(error:true);task.cancel()} + session.invalidateAndCancel() + guard let data=try? JSONSerialization.data(withJSONObject:transfer.take()), + let value=String(data:data,encoding:.utf8) else {return failure} + return value + } +} + +@objc(GRHTTPBridge) +public final class GRHTTPBridge: NSObject { + @objc(requestWithJSON:) + public static func request(json: UnsafePointer?) -> NSString { + guard let json=json, let value=String(validatingUTF8:json) else {return "{\"error\":\"invalid bounded request\"}"} + return GRBoundedHTTP.perform(value) as NSString + } +} diff --git a/mobile/ios/patch_love_src.py b/mobile/ios/patch_love_src.py index 9c0fa0ac4..c0c8e5b55 100644 --- a/mobile/ios/patch_love_src.py +++ b/mobile/ios/patch_love_src.py @@ -29,7 +29,7 @@ PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj" ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements" -NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m") +NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRHTTPBridge.swift", "GRBootstrap.m") MARKER = "gen1recomp iOS picker bridge" @@ -40,6 +40,7 @@ #include #include #include +#include #include "filesystem/Filesystem.h" #endif """ % MARKER @@ -94,6 +95,32 @@ { return gr_callBridge(L, "GRHealthBridge", "syncStepsWithCommand:saveDir:", "sync"); } +int w_boundedHttpVersion(lua_State *L) +{ + lua_pushinteger(L, objc_getClass("GRHTTPBridge") != nullptr ? 1 : 0); + return 1; +} +int w_httpRequestBounded(lua_State *L) +{ + size_t size = 0; + const char *json = luaL_checklstring(L, 1, &size); + Class cls = objc_getClass("GRHTTPBridge"); + if (cls == nullptr || size > 1024 * 1024 || std::memchr(json, 0, size) != nullptr) + { + lua_pushliteral(L, "{\\\"error\\\":\\\"invalid bounded request\\\"}"); + return 1; + } + typedef id (*GRNew)(Class, SEL); + typedef id (*GRRequest)(Class, SEL, const char *); + typedef const char *(*GRUTF8)(id, SEL); + typedef void (*GRDrain)(id, SEL); + id pool = ((GRNew)objc_msgSend)(objc_getClass("NSAutoreleasePool"), sel_registerName("new")); + id result = ((GRRequest)objc_msgSend)(cls, sel_registerName("requestWithJSON:"), json); + const char *text = result == nullptr ? nullptr : ((GRUTF8)objc_msgSend)(result, sel_registerName("UTF8String")); + lua_pushstring(L, text == nullptr ? "{\\\"error\\\":\\\"bounded request failed\\\"}" : text); + ((GRDrain)objc_msgSend)(pool, sel_registerName("drain")); + return 1; +} #endif // LOVE_IOS // --------------------------------------------------------------------------- @@ -103,6 +130,8 @@ { "pickFile", w_pickFile }, { "createFile", w_createFile }, { "syncHealthSteps", w_syncHealthSteps }, + { "httpRequestBounded", w_httpRequestBounded }, + { "boundedHttpVersion", w_boundedHttpVersion }, #endif """ @@ -114,6 +143,8 @@ ID_BUILD_PICKER = "6E1AC0DE0002000000000001" ID_BUILD_OBJC = "6E1AC0DE0002000000000002" ID_BUILD_HEALTH = "6E1AC0DE0002000000000003" +ID_FILE_HTTP = "6E1AC0DE0001000000000004" +ID_BUILD_HTTP = "6E1AC0DE0002000000000004" SOURCES_PHASE_ID = "FA0B7F021A95AAF3000E1D17" # love-ios Sources phase IOS_APP_CONFIG_IDS = ( "FA0B7F261A95AAF4000E1D17", # Debug @@ -124,6 +155,7 @@ PBX_SOURCES = ( ("GRPickerBridge.swift", ID_FILE_PICKER, ID_BUILD_PICKER, "sourcecode.swift"), ("GRHealthBridge.swift", ID_FILE_HEALTH, ID_BUILD_HEALTH, "sourcecode.swift"), + ("GRHTTPBridge.swift", ID_FILE_HTTP, ID_BUILD_HTTP, "sourcecode.swift"), ("GRBootstrap.m", ID_FILE_OBJC, ID_BUILD_OBJC, "sourcecode.c.objc"), ) diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index 72e75e2ba..32444e862 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -381,4 +381,101 @@ function HostShell.httpGet(url, userAgent, accept) return body end + +-- Worker-only GET. Curl arguments containing remote input live in a config +-- file, never in shell syntax (also preserves percent-encoded Windows URLs). +-- errorPath belongs to the engine's private per-job workspace, not the mod. +local function httpBounded(url, opts, method, postBody) + opts = opts or {} + if not HostShell.canSpawnProcess() then return nil, "bounded HTTP requires desktop curl" end + if type(url) ~= "string" or #url > 8192 or url:find("[%z%c%s]") + or not url:lower():match("^https?://[^/?#]+") then return nil, "invalid HTTP URL" end + if opts.httpsOnly and not url:lower():match("^https://") then return nil, "HTTPS required" end + local function bounded(n, default, cap) + n = tonumber(n) + if not n or n ~= n then n = default end + return math.max(0.1, math.min(cap, n)) + end + local seconds = bounded(opts.maxSeconds, 20, 30) + local bytes = math.floor(bounded(opts.maxBytes, 4 * 1024 * 1024, 16 * 1024 * 1024)) + if bytes < 1 then return nil, "invalid byte limit" end + local path = opts.errorPath + if type(path) ~= "string" or path == "" or path:find("[%z%c]") then return nil, "missing worker path" end + local windows = love.system.getOS() == "Windows" + -- cmd expands these even inside quotes. Only the local config pathname is + -- shell-visible; refuse an unsupported local pathname rather than expand it. + if windows and path:find('[%%!"^]') then return nil, "unsupported characters in worker path" end + local configPath = path .. ".cfg" + local bodyPath = path .. ".body" + local function cleanup() os.remove(configPath);if postBody then os.remove(bodyPath) end end + local function quoted(value) + value = tostring(value) + if value:find("[%z%c]") then return nil end + return '"' .. value:gsub("\\", "\\\\"):gsub('"', '\\"') .. '"' + end + local ua, accept = quoted(opts.userAgent or "gen2recomp"), quoted(opts.accept or "*/*") + if not ua or not accept then return nil, "invalid HTTP header" end + local lines = {"silent", "show-error", "fail", "globoff", + "connect-timeout = " .. math.min(10, seconds), "max-time = " .. seconds, + "max-filesize = " .. bytes, "max-redirs = 5", + 'proto = "=' .. (opts.httpsOnly and "https" or "http,https") .. '"', + 'proto-redir = "=' .. (opts.httpsOnly and "https" or "http,https") .. '"', + "user-agent = " .. ua, "header = " .. quoted("Accept: " .. (opts.accept or "*/*")), + "stderr = " .. quoted(path), 'write-out = "\\n%{http_code}"', "url = " .. quoted(url)} + -- Schannel does not consistently consume this curl environment setting. + -- Put the caller's trusted CA bundle in the private config on every desktop. + local ca=os.getenv('CURL_CA_BUNDLE') + if ca and ca~='' then + local value=quoted(ca);if not value then return nil,'invalid CA bundle path' end + lines[#lines+1]='cacert = '..value + end + if method == "POST" then + if type(postBody) ~= "string" or #postBody > 512 * 1024 then return nil, "POST body exceeds byte limit" end + local contentType=opts.contentType or "text/plain; charset=utf-8" + if type(contentType)~="string" or #contentType>128 or not quoted(contentType) then return nil,"invalid Content-Type" end + local data,why=io.open(bodyPath,"wb");if not data then return nil,tostring(why) end + local wrote,err=data:write(postBody);local closed=data:close() + if not wrote or not closed then cleanup();return nil,tostring(err or "POST staging failed") end + lines[#lines+1]='request = "POST"' + lines[#lines+1]="header = "..quoted("Content-Type: "..contentType) + lines[#lines+1]="data-binary = "..quoted("@"..bodyPath) + else lines[#lines+1]="location" end + lines[#lines+1]="" + local config = table.concat(lines,"\n") + local f, err = io.open(configPath, "wb") + if not f then cleanup();return nil, tostring(err) end + local wrote, why = f:write(config); local closed = f:close() + if not wrote or not closed then cleanup(); return nil, tostring(why or "config write failed") end + local pipe = HostShell.popen("curl --disable --config " .. HostShell.quote(configPath), windows and "rb" or "r") + if not pipe then cleanup(); return nil, "could not start curl" end + local ok, body = pcall(pipe.read, pipe, bytes + 5) + local closeOk, done, _, code = pcall(pipe.close, pipe) + cleanup() + local ef = io.open(path, "rb") + local complaint + if ef then complaint = HostShell.curlComplaint(ef:read(4096)); ef:close() end + os.remove(path) + if not ok then return nil, "response read failed" end + body = body or "" + local status = tonumber(body:match("\n(%d%d%d)$")) + if status then body = body:sub(1, -5) end + if #body > bytes then return nil, "response exceeds byte limit" end + if complaint then + return nil, complaint, tonumber(complaint:match("returned error: (%d%d%d)")) or status + end + if not closeOk or done == nil or (type(code) == "number" and code ~= 0) then + return nil, "curl failed" + end + if not status or status < 200 or status >= 300 then return nil, "incomplete HTTP response", status end + return body, nil, status +end + +function HostShell.httpGetBounded(url,opts)return httpBounded(url,opts,"GET")end +function HostShell.httpPostBounded(url,body,opts) + opts=opts or {} + if type(url)~="string" or not url:lower():match("^https://") then return nil,"HTTPS required" end + local bounded={};for key,value in pairs(opts)do bounded[key]=value end + bounded.httpsOnly=true + return httpBounded(url,bounded,"POST",body) +end return HostShell diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 4094a38c4..2f5d69ae7 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -11,6 +11,7 @@ local Data = require("src.core.Data") local Version = require("src.core.Version") local Assets = require("src.render.Assets") local ModUI = require("src.ui.ModUI") +local Net = require("src.mods.Net") local AssetTransform = require("src.mods.AssetTransform") local Manifest = require("src.mods.Manifest") local Merge = require("src.mods.Merge") @@ -916,6 +917,7 @@ function Loader:_api(mod) end }, -- the widget toolkit facade (12 4.5) is one shared surface, not -- per-mod state; each widget inside it loads on first touch + fetch = Net.forMod(loader, mod), ui = ModUI, -- namespaced per mod; M11 backs these with save.modData / -- options.modOptions, the shape mods compile against is already final @@ -1119,6 +1121,7 @@ function Loader:_api(mod) end return data end) + api.postLog = Net.logReporter(loader,mod) -- mod.world / mod.game / mod.storage all materialize on first touch, for -- the same reason: a headless load must not drag the world stack in, and -- the Game the facade acts on is still being wired when the entry chunk @@ -1196,6 +1199,7 @@ end -- migration it took goes with them. The journal only exists around an -- entry chunk; a later failure (script validation) purges every registry. function Loader:_rollback(modId) + Net.releaseAll(self, modId) for name in pairs(self.journal or self.content) do self.content[name]:rollback(modId) end @@ -1653,6 +1657,7 @@ end -- boots) ran on a modded engine whose mod instance nothing drives any more, -- and the next Play loaded every mod on top of what the last one left. function Loader.teardown() + Net.unloadAll() local undone = select(1, ModSeams.unwindAll()) pcall(function() Runtime.install(Events.new(), Hooks.new(), nil) diff --git a/src/mods/LogReport.lua b/src/mods/LogReport.lua new file mode 100644 index 000000000..ec0c7a9a8 --- /dev/null +++ b/src/mods/LogReport.lua @@ -0,0 +1,18 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +local Json=require('src.link.Json') +local L={MAX_BODY=65536,MAX_WIRE_BODY=512*1024} +function L.prepare(owner,body,opts) + if type(body)~='string' or #body>L.MAX_BODY then return nil,'log body must be a string of at most 65536 bytes' end + if opts~=nil and (type(opts)~='table' or getmetatable(opts)) then return nil,'invalid log options' end + opts=opts or {} + for key in pairs(opts)do if key~='format' then return nil,'unknown log option' end end + local format=opts.format + if format==nil then format='text' end + if format~='text' and format~='json' then return nil,'log format must be text or json' end + if type(owner)~='string' or #owner>256 then return nil,'invalid log owner' end + if format=='text' then return body,'text/plain; charset=utf-8' end + local value=Json.encode({ts=os.time(),mod=owner,format=format,body=body}) + if #value>L.MAX_WIRE_BODY then return nil,'encoded log exceeds byte limit' end + return value,'application/json' +end +return L diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index ac5614634..8a71645bd 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -240,6 +240,16 @@ function Manifest.validate(raw, path) end end + local logUrl=raw.log_url + if logUrl~=nil then + local bad=type(logUrl)~="string" or #logUrl>8192 or logUrl:find("[%z%c%s]") + or not logUrl:lower():match("^https://[^/?#]+") + if bad or not permissionSet.network then + violation(strict,raw.id,bad and "log_url requires a valid HTTPS URL" or 'log_url requires the "network" permission') + logUrl=nil + end + end + local gameVersionOk, gameVersionErr = Semver.validRange(raw.game_version) assert(gameVersionOk, ("malformed game_version %q: %s") :format(tostring(raw.game_version), tostring(gameVersionErr))) @@ -325,6 +335,7 @@ function Manifest.validate(raw, path) affects_link = affectsLink, permissions = permissions, permissionSet = permissionSet, + log_url = logUrl, options_schema = optionalFile(raw.options_schema, "options_schema"), -- Base files the mod needs but cannot ship -- a Stadium 2 cartridge, a -- second game's ROM. Declared here, satisfied by the launcher, read by diff --git a/src/mods/Net.lua b/src/mods/Net.lua new file mode 100644 index 000000000..919cdfa2e --- /dev/null +++ b/src/mods/Net.lua @@ -0,0 +1,108 @@ +-- Per-mod, opaque ownership over the engine's non-blocking GET transport. +local Fetch = require("src.net.Fetch") +local Transport = require('src.net.BoundedHttp') +local Net = { MAX_INFLIGHT = 4 } +local loaders = setmetatable({}, {__mode = "k"}) + +function Net.urlDenial(url) + if type(url) ~= "string" or #url > 8192 or url:find("[%z%c%s]") then return "invalid URL" end + if not url:match("^[Hh][Tt][Tt][Pp][Ss]?://[^/?#]+") then return "HTTP or HTTPS URL with a host required" end +end + +local function bucket(loader, owner) + loader.netJobs = loader.netJobs or {} + loader.netJobs[owner] = loader.netJobs[owner] or {} + loaders[loader] = true + return loader.netJobs[owner] +end + +function Net.releaseAll(loader, owner) + if loader.netOneWay then loader.netOneWay[owner]=nil end + local b = loader.netJobs and loader.netJobs[owner] + if not b then return end + for handle, id in pairs(b) do Fetch.cancel(id); Fetch.release(id); b[handle] = nil end + loader.netJobs[owner] = nil +end + +local function submit(loader,owner,method,...) + local b,count=bucket(loader,owner),0 + for _,id in pairs(b)do if Fetch.isPending(id) then count=count+1 end end + if count>=Net.MAX_INFLIGHT then return nil,'too many requests in flight' end + local id,why=Fetch[method](...) + if not id then return nil,why end + local handle={};b[handle]=id + if method=='postLog' then + loader.netOneWay=loader.netOneWay or {};loader.netOneWay[owner]=loader.netOneWay[owner] or {} + loader.netOneWay[owner][handle]=true + end + return handle +end + +function Net.logReporter(loader,mod) + local owner,url,granted=mod.manifest.id,mod.manifest.log_url,mod.manifest.permissionSet.network + return function(_,body,opts) + if not granted then error('mod.postLog for '..owner..' needs the "network" permission',2) end + if not url then return nil,'mod has no log_url' end + local payload,kind=require('src.mods.LogReport').prepare(owner,body,opts) + if not payload then return nil,kind end + if not Transport.available() then return nil,'bounded HTTP transport unavailable' end + return submit(loader,owner,'postLog',url,payload,{userAgent='gen2recomp-mod/'..owner,contentType=kind, + maxSeconds=20,maxBytes=4*1024*1024,boundedHttpsGet=1}) + end +end + +function Net.unloadAll() + for loader in pairs(loaders) do + for owner in pairs(loader.netJobs or {}) do Net.releaseAll(loader, owner) end + end +end + +function Net.forMod(loader, mod) + local owner = mod.manifest.id + if not mod.manifest.permissionSet.network then + local function refuse() error(('mod.fetch for %s needs the "network" permission'):format(owner), 2) end + return { available = function() return false end, get = refuse, + poll = refuse, cancel = refuse, release = refuse, + capabilities = function() return {} end } + end + local api = {} + function api:available() return Transport.available() and Fetch.available() end + function api:capabilities() + return Transport.available() and {boundedHttpsGet = 1, maxResponseBytes = require("src.net.AsyncGet").MAX_BYTES} or {} + end + function api:get(url, opts) + local denial = Net.urlDenial(url) + if denial then return nil, denial end + opts = type(opts) == "table" and opts or {} + if opts.boundedHttpsGet == 1 and not url:lower():match("^https://") then return nil, "HTTPS required" end + return submit(loader,owner,'get',url, { userAgent = "gen2recomp-mod/" .. owner, + accept = type(opts.accept) == "string" and opts.accept or nil, + maxSeconds = opts.maxSeconds, maxBytes = opts.maxBytes, boundedHttpsGet = opts.boundedHttpsGet }) + end + function api:poll(handle) + local id = bucket(loader, owner)[handle] + if not id then return {status = "error", err = "unknown request"} end + local state = Fetch.poll(id); local out = {} + for key, value in pairs(state) do out[key] = value end + if loader.netOneWay and loader.netOneWay[owner] and loader.netOneWay[owner][handle] then + return {status=out.status,progress=out.progress,receivedBytes=0,httpStatus=out.httpStatus, + err=out.status=='error' and 'log upload failed' or nil} + end + return out + end + function api:cancel(handle) + local id = bucket(loader, owner)[handle] + if not id then return false end + Fetch.cancel(id); return true + end + function api:release(handle) + local b = bucket(loader, owner); local id = b[handle] + if not id then return false end + Fetch.cancel(id); Fetch.release(id); b[handle] = nil + if loader.netOneWay and loader.netOneWay[owner] then loader.netOneWay[owner][handle]=nil end + return true + end + return api +end + +return Net diff --git a/src/net/AsyncDownload.lua b/src/net/AsyncDownload.lua new file mode 100644 index 000000000..f6478e13d --- /dev/null +++ b/src/net/AsyncDownload.lua @@ -0,0 +1,188 @@ +-- File downloads have bounded, engine-owned workers. Poll only drains results, +-- reads coalesced progress. All file IO/publication stays on the workers. +local M = { MAX_JOBS = 8, MAX_BYTES = 512 * 1024 * 1024 } +local jobs, workers, destinations, nextId = {}, {}, {}, 0 +local commands, results, quit, directory, ready, failure +local function now() return love.timer.getTime() end + +local function ensure() + if ready ~= nil then return ready end + if not (love and love.thread and love.thread.newThread and love.filesystem and love.timer) + or not require("src.core.HostShell").canSpawnProcess() then + ready, failure = false, "background file downloads unavailable"; return false + end + commands, results, quit = love.thread.newChannel(), love.thread.newChannel(), love.thread.newChannel() + directory = "net-download/" .. os.time() .. "-" .. tostring({}):gsub("[^%w]", "") + local ok, made = pcall(love.filesystem.createDirectory, directory) + if not ok or not made then ready, failure = false, "could not create download workspace"; return false end + for _ = 1, 2 do + local created, thread = pcall(love.thread.newThread, "src/net/download_worker.lua") + if created and thread and pcall(thread.start, thread, commands, results, quit) then workers[#workers + 1] = thread end + end + ready = #workers > 0 + if not ready then failure = "could not start file-download workers" end + return ready +end + +local function retire(job) + if destinations[job.key] == job.id then destinations[job.key] = nil end +end +local function cancelTransfer(job) + if not job.control then return true end + return job.control:performAtomic(function(control) + if control:peek() ~= "open" then return false end + control:clear(); control:push("cancelled"); job.cancel:push(true) + return true + end) +end +local function failed(job, reason) + job.state = {status = "error", err = reason, progress = 0} + retire(job) +end +local function drain() + if not results then return end + while true do + local response = results:pop() + if not response then break end + local job = type(response) == "table" and jobs[response.id] + if job then + job.busy = false + if job.retained and job.state.status == "pending" then + if not response.ok then failed(job, response.err or "download failed") + else + job.state = {status = "ok", progress = 1, receivedBytes = response.bytes, httpStatus = response.httpStatus} + retire(job) + end + if job.state.status == "error" then job.state.httpStatus = response.httpStatus end + end + retire(job) + if not job.retained then jobs[response.id] = nil end + end + end + for _, thread in ipairs(workers) do + local err = thread:getError() + if err then + ready, failure = false, tostring(err) + quit:push(true); commands:clear() + for _ = 1, #workers do commands:push(false) end + for _, job in pairs(jobs) do + if job.state.status == "pending" then cancelTransfer(job); failed(job, failure) end + end + break + end + end + for _, job in pairs(jobs) do + if job.state.status == "pending" and now() >= job.deadline and cancelTransfer(job) then + failed(job, "download timed out") + end + end +end + +local function path(value) + if type(value) ~= "string" or #value == 0 or #value > 2048 or value:find("[%z%c:]") then return nil end + value = value:gsub("\\", "/") + if value:sub(1, 1) == "/" or value:sub(-1) == "/" or value:find("//", 1, true) + or value == "net-download" or value:sub(1, 13) == "net-download/" then return nil end + for part in value:gmatch("[^/]+") do if part == "." or part == ".." then return nil end end + return value +end +local function number(value, default, maximum) + if value == nil then return default end + value = tonumber(value) + if not value or value ~= value or value < 0 or value > maximum then return nil end + return value +end + +function M.download(url, destination, options) + options = type(options) == "table" and options or {} + if type(url) ~= "string" or #url > 8192 or url:find("[%z%c%s]") + or not url:lower():match("^https?://[^/?#]+") then return nil, "invalid HTTP URL" end + if options.boundedHttpsGet == 1 and not url:lower():match("^https://") then return nil, "HTTPS required" end + for _, name in ipairs({"userAgent", "accept"}) do + local value = options[name] + if value ~= nil and (type(value) ~= "string" or #value > 1024 or value:find("[%z%c]")) then + return nil, "invalid HTTP header" + end + end + local dest = path(destination) + if not dest then return nil, "relative download file path required" end + local size = number(options.size, nil, M.MAX_BYTES) + if options.size ~= nil and (size == nil or size % 1 ~= 0) then return nil, "invalid expected size" end + local limit = number(options.maxBytes, math.max(size or 0, 64 * 1024 * 1024), M.MAX_BYTES) + local seconds = number(options.maxSeconds, 60, 300) + if not limit or limit < 1 or limit % 1 ~= 0 or size and size > limit then return nil, "invalid byte limit" end + if not seconds or seconds < .1 then return nil, "invalid download timeout" end + drain() + local busy = 0 + for _, job in pairs(jobs) do if job.busy then busy = busy + 1 end end + if busy >= M.MAX_JOBS then return nil, "file-download worker queue is full" end + local key = love and love.system and love.system.getOS and love.system.getOS() == "Windows" and dest:lower() or dest + if destinations[key] then return nil, "download destination is busy" end + nextId = nextId + 1 + local id = nextId + local job = {id = id, dest = dest, key = key, size = size, retained = true, + state = {status = "pending", progress = 0, receivedBytes = 0}} + jobs[id] = job + if not ensure() then failed(job, failure); return id end + job.stage, job.cancel = directory .. "/" .. id .. ".part", love.thread.newChannel() + job.control, job.progress = love.thread.newChannel(), love.thread.newChannel() + job.control:push("open") + job.deadline, job.busy = now() + seconds, true + destinations[key] = id + commands:push({id = id, url = url, stage = job.stage, dest = dest, cancel = job.cancel, + control = job.control, progress = job.progress, + deadline = job.deadline, maxSeconds = seconds, size = size, maxBytes = limit, + userAgent = options.userAgent, accept = options.accept, httpsOnly = options.boundedHttpsGet == 1}) + return id +end + +function M.poll(id) + drain() + local job = jobs[id] + if not job or not job.retained then return {status = "error", err = "invalid job handle"} end + local state = {} + for k, v in pairs(job.state) do state[k] = v end + if state.status == "pending" and job.progress then + local received = job.progress:peek() + job.receivedBytes = math.max(job.receivedBytes or 0, tonumber(received) or 0) + state.receivedBytes = job.receivedBytes + state.progress = job.size and job.size > 0 and math.min(1, state.receivedBytes / job.size) or 0 + state.cancellable = job.control:peek() == "open" + if not state.cancellable then state.phase = "publishing" end + end + return state +end +function M.cancel(id) + local job = jobs[id] + if not job or not job.retained then return false end + if job.state.status == "pending" then + -- Publication claims and accepted cancellations are ordered by one short + -- channel transaction. No disk IO runs under that lock. Once publication + -- has claimed the completed transfer, cancellation is too late. + if not cancelTransfer(job) then return false end + failed(job, "cancelled") + end + return job.state.status ~= "ok" +end +function M.release(id) + local job = jobs[id] + if not job then return end + M.cancel(id); job.retained = false + if not job.busy then jobs[id] = nil end +end +function M.shutdown() + for id in pairs(jobs) do M.cancel(id) end + if quit then quit:push(true); commands:clear(); for _ = 1, #workers do commands:push(false) end end + for _, thread in ipairs(workers) do pcall(thread.wait, thread) end + drain() + for _, job in pairs(jobs) do + job.busy = false + if job.stage then love.filesystem.remove(job.stage) end + if job.stage then love.filesystem.remove(job.stage .. ".cfg"); love.filesystem.remove(job.stage .. ".stderr") end + end + if directory then love.filesystem.remove(directory) end + workers, destinations = {}, {} + commands, results, quit, directory, ready, failure = nil, nil, nil, nil, nil, nil +end + +return M diff --git a/src/net/AsyncGet.lua b/src/net/AsyncGet.lua new file mode 100644 index 000000000..b01e966d5 --- /dev/null +++ b/src/net/AsyncGet.lua @@ -0,0 +1,160 @@ +-- Bounded engine-owned GET workers. No network or pipe read runs in poll(). +local Get = { MAX_JOBS = 16, MAX_BYTES = 16 * 1024 * 1024 } +local jobs, workers, nextId = {}, {}, 0 +local commands, results, quit, ready, failure, poolDirectory + +local function now() return love.timer.getTime() end + +local function ensure() + if ready ~= nil then return ready end + if not (love and love.thread and love.thread.newThread and love.thread.newChannel) then + ready, failure = false, "background threads unavailable"; return false + end + commands, results, quit = love.thread.newChannel(), love.thread.newChannel(), love.thread.newChannel() + local token = tostring(os.time()) .. "-" .. tostring({}):gsub("[^%w]", "") + poolDirectory = "net-get/" .. token + for workerIndex = 1, 3 do + -- Initialize the writable filesystem on the main thread, before any + -- workers race its first write-directory creation. Each owns its files. + if love.filesystem then + local made, done = pcall(love.filesystem.createDirectory, poolDirectory .. "/" .. workerIndex) + if not made or not done then + ready, failure = false, "could not create GET worker directory" + return false + end + end + end + for workerIndex = 1, 3 do + local ok, thread = pcall(love.thread.newThread, "src/net/get_worker.lua") + if ok and thread then + local started = pcall(thread.start, thread, commands, results, quit, token, workerIndex) + if started then workers[#workers + 1] = thread end + end + end + ready = #workers > 0 + if not ready then failure = "could not start GET workers" end + return ready +end + +local function drain() + if not results then return end + while true do + local msg = results:pop() + if not msg then break end + local job = type(msg) == "table" and jobs[msg.id] + if job then + job.busy = false + if not job.retained then jobs[msg.id] = nil + elseif job.state.status == "pending" then + local body=not job.oneWay and msg.body or nil + local failure=msg.err + if job.oneWay then failure=not msg.ok and 'log upload failed' or nil end + job.state = { status = msg.ok and "ok" or "error", body = body, + err = failure, httpStatus = msg.httpStatus, + receivedBytes = body and #body or 0, progress = msg.ok and 1 or 0 } + end + end + end + for _, thread in ipairs(workers) do + local err = thread:getError() + if err then + ready, failure = false, tostring(err) + quit:push(true); commands:clear() + for _ = 1, #workers do commands:push(false) end + for _, job in pairs(jobs) do + if job.state.status == "pending" then job.state = { status = "error", err = failure } end + end + break + end + end + for _, job in pairs(jobs) do + if job.state.status == "pending" and now() >= job.deadline then + job.cancel:push(true); job.state = { status = "error", err = "request timed out" } + end + end +end + +local function finite(value, fallback, maximum) + value = tonumber(value) + if not value or value ~= value then value = fallback end + return math.max(1, math.min(maximum, value)) +end + +function Get.available() return ensure() end + +local function request(url, opts, method, body) + opts = type(opts) == "table" and opts or {} + if type(url) ~= "string" or #url > 8192 or url:find("[%z%c%s]") + or not url:lower():match("^https?://[^/?#]+") then return nil, "invalid HTTP URL" end + if (opts.boundedHttpsGet == 1 or method=="POST") and not url:lower():match("^https://") then return nil, "HTTPS required" end + if method=="POST" and (type(body)~="string" or #body>512*1024) then return nil,"POST body exceeds byte limit" end + drain() + local busy = 0 + for _, job in pairs(jobs) do if job.busy then busy = busy + 1 end end + if busy >= Get.MAX_JOBS then return nil, "GET worker queue is full" end + nextId = nextId + 1 + local id = nextId + local seconds = finite(opts.maxSeconds, 20, 30) + local state = { status = "pending", progress = 0, receivedBytes = 0 } + if not ensure() then + jobs[id] = { retained = true, state = {status = "error", err = failure} } + return id + end + local cancel = love.thread.newChannel() + local deadline = now() + seconds + jobs[id] = { retained = true, busy = true, cancel = cancel, state = state, deadline = deadline,oneWay=method=="POST" } + commands:push({ id = id, url = url, cancel = cancel, deadline = deadline, + method=method,body=body,discardBody=method=="POST",contentType=opts.contentType, + userAgent = opts.userAgent, accept = opts.accept, + maxSeconds = seconds, maxBytes = math.floor(finite(opts.maxBytes, 4 * 1024 * 1024, Get.MAX_BYTES)), + httpsOnly = opts.boundedHttpsGet == 1 or method=="POST" }) + return id +end + +function Get.get(url,opts)return request(url,opts,"GET")end +function Get.postLog(url,body,opts)return request(url,opts,"POST",body)end + +function Get.poll(id) + drain() + local job = jobs[id] + if not job or not job.retained then return {status = "error", err = "unknown request"} end + local out = {} + for key, value in pairs(job.state) do out[key] = value end + return out +end + +function Get.cancel(id) + local job = jobs[id] + if not job or not job.retained then return false end + if job.state.status == "pending" then + job.cancel:push(true); job.state = {status = "cancelled"} + end + return true +end + +function Get.release(id) + local job = jobs[id] + if not job then return end + Get.cancel(id) + job.retained = false + if not job.busy then jobs[id] = nil end +end + +function Get.shutdown() + if quit then quit:push(true) end + if commands then + commands:clear() + for _ = 1, #workers do commands:push(false) end + end + for _, thread in ipairs(workers) do pcall(thread.wait, thread) end + if poolDirectory and love.filesystem then love.filesystem.remove(poolDirectory) end + poolDirectory = nil + for _, job in pairs(jobs) do + job.busy = false + if job.state.status == "pending" then job.state = {status = "cancelled"} end + end + commands, results, quit, ready, failure = nil, nil, nil, nil, nil + workers = {} +end + +return Get diff --git a/src/net/BoundedHttp.lua b/src/net/BoundedHttp.lua new file mode 100644 index 000000000..66cbf4389 --- /dev/null +++ b/src/net/BoundedHttp.lua @@ -0,0 +1,51 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +-- Engine-only request bridge. The public mod service retains URL ownership. +local Host=require('src.core.HostShell') +local Json=require('src.link.Json') +local H={MAX_BODY=512*1024,MAX_BYTES=16*1024*1024,MAX_SECONDS=30} +function H.nativeAvailable() + local system=love and love.system + if not (system and type(system.boundedHttpVersion)=='function' and type(system.httpRequestBounded)=='function') then return false end + local ok,version=pcall(system.boundedHttpVersion) + return ok and version==1 and love.data and type(love.data.encode)=='function' and type(love.data.decode)=='function' or false +end +function H.available()return Host.canSpawnProcess() or H.nativeAvailable()end +function H.request(url,options) + local o=options or {};local method=o.method or 'GET' + if method~='GET' and method~='POST' then return nil,'unsupported HTTP method' end + if type(url)~='string' or #url>8192 or url:find('[%z%c%s]') or not url:lower():match('^https?://[^/?#]+') then return nil,'invalid HTTP URL' end + if (o.httpsOnly or method=='POST') and not url:lower():match('^https://') then return nil,'HTTPS required' end + local body=o.body + if method=='POST' and (type(body)~='string' or #body>H.MAX_BODY) then return nil,'POST body exceeds byte limit' end + local seconds=tonumber(o.maxSeconds) or 20;local bytes=tonumber(o.maxBytes) or 4*1024*1024 + if seconds~=seconds or bytes~=bytes or seconds<=0 or bytes<1 then return nil,'invalid HTTP bounds' end + seconds=math.min(seconds,H.MAX_SECONDS);bytes=math.floor(math.min(bytes,H.MAX_BYTES)) + if Host.canSpawnProcess() then + local opts={userAgent=o.userAgent,accept=o.accept,maxSeconds=seconds,maxBytes=bytes, + httpsOnly=o.httpsOnly,contentType=o.contentType,errorPath=o.errorPath} + local value,why,status + if method=='POST' then value,why,status=Host.httpPostBounded(url,body,opts) + else value,why,status=Host.httpGetBounded(url,opts) end + if o.discardBody and value~=nil then value='' end + return value,why,status + end + if not H.nativeAvailable() then return nil,'bounded native HTTP transport unavailable' end + local req={boundedVersion=1,method=method,url=url,userAgent=o.userAgent or 'gen2recomp', + accept=o.accept or '*/*',maxSeconds=seconds,maxBytes=bytes, + httpsOnly=o.httpsOnly==true or method=='POST',discardBody=o.discardBody==true, + contentType=o.contentType or 'text/plain; charset=utf-8'} + if body then req.bodyBase64=love.data.encode('string','base64',body) end + local ok,wire=pcall(love.system.httpRequestBounded,Json.encode(req)) + if not ok or type(wire)~='string' or #wire>math.ceil(bytes*4/3)+8192 then return nil,'native HTTP response failed' end + local decoded,response=pcall(Json.decode,wire) + if not decoded or type(response)~='table' then return nil,'invalid native HTTP response' end + local status=response.status + if type(status)~='number' or status%1~=0 or status<100 or status>599 then status=nil end + if response.error or not status or status<200 or status>=300 then return nil,'native HTTP request failed',status end + if o.discardBody then return '',nil,status end + if type(response.bodyBase64)~='string' or #response.bodyBase64>math.ceil(bytes/3)*4 then return nil,'native HTTP response exceeds byte limit',status end + local valid,value=pcall(love.data.decode,'string','base64',response.bodyBase64) + if not valid or #value>bytes then return nil,'invalid native HTTP body',status end + return value,nil,status +end +return H diff --git a/src/net/DownloadPublish.lua b/src/net/DownloadPublish.lua new file mode 100644 index 000000000..4ccac01da --- /dev/null +++ b/src/net/DownloadPublish.lua @@ -0,0 +1,25 @@ +-- Worker-only atomic replacement. The caller owns the publication claim; +-- slow filesystem/antivirus operations never run in the game's poll path. +return function(source, target) + if love.system.getOS() ~= "Windows" then return os.rename(source, target) end + -- Windows rename cannot overwrite an existing file. Never delete the old + -- destination first: MoveFileExW replaces it in one filesystem operation. + local ffi = require("ffi") + ffi.cdef[[ + int MultiByteToWideChar(unsigned int, unsigned long, const char *, int, unsigned short *, int); + int MoveFileExW(const unsigned short *, const unsigned short *, unsigned long); + unsigned long GetLastError(void); + ]] + local function wide(s) + local n = ffi.C.MultiByteToWideChar(65001, 8, s, #s, nil, 0) + if n == 0 then error("invalid UTF-8 download path") end + local out = ffi.new("unsigned short[?]", n + 1) + if ffi.C.MultiByteToWideChar(65001, 8, s, #s, out, n) ~= n then error("download path conversion failed") end + return out + end + if ffi.C.MoveFileExW(wide(source), wide(target), 1) == 0 then + return nil, "download publish failed (Windows " .. tonumber(ffi.C.GetLastError()) .. ")" + end + return true +end + diff --git a/src/net/DownloadTransport.lua b/src/net/DownloadTransport.lua new file mode 100644 index 000000000..9f638fbe3 --- /dev/null +++ b/src/net/DownloadTransport.lua @@ -0,0 +1,88 @@ +-- Worker-only streaming HTTP transport. Remote values stay in a curl config; +-- the response is never assembled in the main thread or retained in memory. +local Host = require("src.core.HostShell") +local M = {} + +function M.run(job, output) + if not Host.canSpawnProcess() then return nil, "background downloads require desktop curl" end + local fs = love.filesystem + local errorPath, configPath = job.stage .. ".stderr", job.stage .. ".cfg" + local absolute = fs.getSaveDirectory() .. "/" .. configPath + if love.system.getOS() == "Windows" and absolute:find('[%%!"^]') then + return nil, "unsupported characters in worker path" + end + local function quote(value) + value = tostring(value) + if value:find("[%z%c]") then return nil end + return '"' .. value:gsub("\\", "\\\\"):gsub('"', '\\"') .. '"' + end + local ua, accept = quote(job.userAgent or "gen2recomp-fetch"), quote(job.accept or "*/*") + if not ua or not accept then return nil, "invalid HTTP header" end + local seconds = math.max(.1, math.min(job.maxSeconds, job.deadline - love.timer.getTime())) + local protocol = job.httpsOnly and "https" or "http,https" + local lines = { "silent", "show-error", "fail", "location", "globoff", + "connect-timeout = " .. math.min(10, seconds), "max-time = " .. seconds, + "max-filesize = " .. job.maxBytes, "max-redirs = 5", + 'proto = "=' .. protocol .. '"', 'proto-redir = "=' .. protocol .. '"', + "user-agent = " .. ua, "header = " .. quote("Accept: " .. (job.accept or "*/*")), + "stderr = " .. quote(fs.getSaveDirectory() .. "/" .. errorPath), + 'write-out = "\\n%{http_code}"', "url = " .. quote(job.url) } + -- Keep explicit trust bundles consistent with the bounded request transport, + -- including Windows curl builds that do not consume CURL_CA_BUNDLE themselves. + local ca = os.getenv("CURL_CA_BUNDLE") + if ca and ca ~= "" then + local value = quote(ca) + if not value then return nil, "invalid CA bundle path" end + lines[#lines + 1] = "cacert = " .. value + end + lines[#lines + 1] = "" + local config = table.concat(lines, "\n") + local wrote, why = fs.write(configPath, config) + if not wrote then return nil, tostring(why or "could not write download config") end + local pipe = Host.popen("curl --disable --config " .. Host.quote(absolute), + love.system.getOS() == "Windows" and "rb" or "r") + if not pipe then fs.remove(configPath); return nil, "could not start download" end + local bytes, tail, failure = 0, "", nil + local ok, err = pcall(function() + while true do + if job.cancel:peek() then failure = "cancelled"; break end + if love.timer.getTime() >= job.deadline then failure = "download timed out"; break end + local chunk = pipe:read(65536) + if not chunk or chunk == "" then break end + -- Retain curl's four-byte HTTP trailer, even across arbitrary reads. + chunk = tail .. chunk + local n = math.max(0, #chunk - 4) + if bytes + n > job.maxBytes then failure = "response exceeds byte limit"; break end + if n > 0 then + local saved, reason = output:write(chunk:sub(1, n)) + if not saved then error(reason or "download write failed") end + local flushed, flushWhy = output:flush() + if not flushed then error(flushWhy or "download flush failed") end + bytes = bytes + n + -- One progress slot per request; a paused menu cannot accumulate an + -- unbounded message queue while a large file keeps streaming. + job.progress:clear(); job.progress:push(bytes) + end + tail = chunk:sub(n + 1) + end + end) + local closed, done, _, code = pcall(pipe.close, pipe) + local readOk, complaint = pcall(fs.read, errorPath, 4096) + fs.remove(configPath); fs.remove(errorPath) + if not ok then return nil, tostring(err) end + if failure then return nil, failure end + local status = tonumber(tail:match("^\n(%d%d%d)$")) + complaint = readOk and Host.curlComplaint(complaint) or nil + if complaint then return nil, complaint, status end + if not closed or done == nil or type(code) == "number" and code ~= 0 then + return nil, "download transport failed", status + end + if not status or status < 200 or status >= 300 then + return nil, "incomplete HTTP response", status + end + if job.cancel:peek() then return nil, "cancelled", status end + if job.size ~= nil and bytes ~= job.size then return nil, "download size mismatch", status end + return bytes, nil, status +end + +return M diff --git a/src/net/Fetch.lua b/src/net/Fetch.lua index d1bd1268a..b5eaa0dd2 100644 --- a/src/net/Fetch.lua +++ b/src/net/Fetch.lua @@ -1,155 +1,38 @@ --- Streaming download API for mods to download large files with progress. --- Wraps HostShell's httpDownload in an async job interface for per-frame pumping. --- Required by mods like HD sheet installer that need progress updates during download. - +-- Engine-owned GET and file-download workers. No HTTP/pipe read in poll. local Fetch = {} - --- Active jobs table: jobId -> { url, dest, size, userAgent, startedAt, maxSeconds, state, progress, err, downloaded } -local jobs = {} -local nextJobId = 1 - --- Job states: "pending", "ok", "error" -local function newJob(url, dest, options) - local job = { - id = nextJobId, - url = url, - dest = dest, - size = options and options.size, - userAgent = options and options.userAgent or "gen1recomp-fetch", - maxSeconds = options and options.maxSeconds or 60, - state = "pending", - progress = 0, - err = nil, - startedAt = os.time(), - downloaded = false, - } - jobs[nextJobId] = job - nextJobId = nextJobId + 1 - return job.id +local Get = require("src.net.AsyncGet") +local Download = require("src.net.AsyncDownload") +local jobs, nextId = {}, 0 +local function start(service, method, ...) + local request, err = service[method](...) + if not request then return nil, err end + nextId = nextId + 1 + jobs[nextId] = {service = service, request = request} + return nextId end --- Start a download job. Returns job handle (integer). --- options: { size?, userAgent?, maxSeconds? } +-- Legacy shape: download(url, relativeSavePath, {size?,userAgent?,maxSeconds?}). +-- Additional optional bounds: maxBytes, accept, boundedHttpsGet. function Fetch.download(url, dest, options) - if type(url) ~= "string" or url == "" then - return nil - end - if type(dest) ~= "string" or dest == "" then - return nil - end - local jobId = newJob(url, dest, options) - local job = jobs[jobId] - - -- Start the download in background using HostShell - local HostShell = require("src.core.HostShell") - if not HostShell.canFetch() then - job.state = "error" - job.err = "no network transport on this platform" - return jobId - end - - -- Convert dest to absolute path if relative - local absDest = dest - if love and love.filesystem then - local saveOk, saveDir = pcall(function() - return love.filesystem.getSaveDirectory() - end) - if saveOk and saveDir and saveDir ~= "" then - absDest = saveDir .. "/" .. dest - end - end - - -- Note: HostShell.httpDownload is synchronous, so we mark it as started - -- The actual download will happen on the first poll - job.absDest = absDest - return jobId + return start(Download, "download", url, dest, options) end - --- Poll a job's status. Returns { status, progress?, err? } --- status: "pending", "ok", "error" -function Fetch.poll(jobId) - local job = jobs[jobId] - if not job then - return { status = "error", err = "invalid job handle" } - end - - if job.state ~= "pending" then - return { status = job.state, progress = job.progress, err = job.err } - end - - -- Check timeout - if os.time() - job.startedAt > job.maxSeconds then - job.state = "error" - job.err = "download timed out" - return { status = "error", err = job.err } - end - - -- Perform the actual download on first poll (synchronous but framed) - if not job.downloaded then - job.downloaded = true - local HostShell = require("src.core.HostShell") - local ok, err = pcall(function() - HostShell.httpDownload(job.url, job.absDest, job.userAgent) - end) - - if not ok then - job.state = "error" - job.err = err or "download failed" - return { status = "error", err = job.err } - end - end - - -- Check file size to estimate progress - local currentSize = 0 - if love and love.filesystem then - local infoOk, info = pcall(love.filesystem.getInfo, job.dest) - if infoOk and info and info.size then - currentSize = tonumber(info.size) or 0 - end - end - - -- If we have an expected size, calculate progress - if job.size and job.size > 0 then - job.progress = math.min(1.0, currentSize / job.size) - else - job.progress = 0 - end - - -- Check if download is complete (file exists and non-zero) - if currentSize > 0 then - -- If we have an expected size, verify it matches - if job.size and job.size > 0 then - if currentSize >= job.size then - job.state = "ok" - job.progress = 1.0 - end - else - -- No expected size, assume complete if file exists - job.state = "ok" - job.progress = 1.0 - end - end - - return { status = job.state, progress = job.progress, err = job.err } +function Fetch.get(url, options) return start(Get, "get", url, options) end +function Fetch.postLog(url, body, options) return start(Get, "postLog", url, body, options) end +function Fetch.poll(id) + local job = jobs[id] + if not job then return {status = "error", err = "invalid job handle"} end + return job.service.poll(job.request) end - --- Cancel an active job. -function Fetch.cancel(jobId) - local job = jobs[jobId] - if not job then return end - if job.state == "pending" then - job.state = "error" - job.err = "cancelled" - -- Try to remove partial file - if love and love.filesystem then - pcall(love.filesystem.remove, job.dest) - end - end +function Fetch.cancel(id) + local job = jobs[id] + if not job then return false end + return job.service.cancel(job.request) end - --- Release job resources. Call after job is complete or cancelled. -function Fetch.release(jobId) - jobs[jobId] = nil +function Fetch.release(id) + local job = jobs[id] + if job then job.service.release(job.request); jobs[id] = nil end end - +function Fetch.available() return Get.available() end +function Fetch.isPending(id) return Fetch.poll(id).status == "pending" end +function Fetch.shutdown() Get.shutdown(); Download.shutdown() end return Fetch diff --git a/src/net/download_worker.lua b/src/net/download_worker.lua new file mode 100644 index 000000000..6863f5dfe --- /dev/null +++ b/src/net/download_worker.lua @@ -0,0 +1,57 @@ +require("love.thread") +require("love.filesystem") +require("love.timer") +require("love.system") +local commands, results, quit = ... +local Transport = require("src.net.DownloadTransport") +local publish = require("src.net.DownloadPublish") +while true do + local job = commands:demand() + if quit:peek() or job == false then break end + if type(job) == "table" then + local output + local ok, bytes, err, status = pcall(function() + if job.cancel:peek() then return nil, "cancelled" end + if love.timer.getTime() >= job.deadline then return nil, "download timed out" end + local why + local openFile = love.filesystem.openFile or love.filesystem.newFile + output, why = openFile(job.stage, "w") + if not output then return nil, tostring(why or "could not create download staging file") end + return Transport.run(job, output) + end) + if output then + local closed, result = pcall(output.close, output) + if not closed or not result then bytes, err = nil, "could not close download staging file" end + end + if not ok then err, bytes = tostring(bytes or err), nil end + if bytes ~= nil then + local checked, result, why = pcall(function() + local info = love.filesystem.getInfo(job.stage, "file") + if not info or info.size ~= bytes then return nil, "download size mismatch" end + local parent = job.dest:match("^(.+)/[^/]+$") + if parent and not love.filesystem.createDirectory(parent) then + return nil, "could not create download destination directory" + end + -- The claim is the completion boundary. Cancel either wins before it + -- or reports false after it; it can never race a later file overwrite. + local claimed = job.control:performAtomic(function(control) + if control:peek() ~= "open" or job.cancel:peek() then return false end + if love.timer.getTime() >= job.deadline then return false end + control:clear(); control:push("publishing"); return true + end) + if not claimed then return nil, job.cancel:peek() and "cancelled" or "download timed out" end + local base = love.filesystem.getSaveDirectory() .. "/" + return publish(base .. job.stage, base .. job.dest) + end) + if not checked or not result then err, bytes = tostring(why or result or "could not publish download"), nil end + end + love.filesystem.remove(job.stage .. ".cfg") + love.filesystem.remove(job.stage .. ".stderr") + if bytes == nil then + love.filesystem.remove(job.stage) + results:push({id = job.id, ok = false, err = err or "cancelled", httpStatus = status}) + else + results:push({id = job.id, ok = true, bytes = bytes, httpStatus = status}) + end + end +end diff --git a/src/net/get_worker.lua b/src/net/get_worker.lua new file mode 100644 index 000000000..ccc85a49d --- /dev/null +++ b/src/net/get_worker.lua @@ -0,0 +1,42 @@ +require("love.thread") +require("love.filesystem") +require("love.timer") +require("love.system") +require("love.data") +local commands, results, quit, token, workerIndex = ... +local Transport = require("src.net.BoundedHttp") +local directory = "net-get/" .. token .. "/" .. workerIndex +-- The main thread creates this workspace before starting any worker. +local saveDir = love.filesystem.getSaveDirectory() + +local function run(job) + if job.cancel:peek() or love.timer.getTime() >= job.deadline then + return {id = job.id, ok = false, err = "cancelled"} + end + local path = directory .. "/" .. job.id .. ".stderr" + local body, err, httpStatus = Transport.request(job.url, { + method=job.method,body=job.body,discardBody=job.discardBody,contentType=job.contentType, + userAgent = job.userAgent, accept = job.accept, + maxSeconds = math.max(0.1, math.min(job.maxSeconds, job.deadline - love.timer.getTime())), + maxBytes = job.maxBytes, httpsOnly = job.httpsOnly, + errorPath = saveDir .. "/" .. path, + }) + love.filesystem.remove(path) + if job.cancel:peek() then return {id = job.id, ok = false, err = "cancelled"} end + if job.discardBody then err=body==nil and 'log upload failed' or nil end + return {id = job.id, ok = body ~= nil, body = not job.discardBody and body or nil, + err = err, httpStatus = httpStatus} +end + +while true do + local job = commands:demand() + if quit:peek() or job == false then break end + if type(job) == "table" then + local ok, response = pcall(run, job) + love.filesystem.remove(directory .. "/" .. job.id .. ".stderr") + love.filesystem.remove(directory .. "/" .. job.id .. ".stderr.cfg") + if not ok then response = {id = job.id, ok = false, err = tostring(response)} end + results:push(response) + end +end +love.filesystem.remove(directory) diff --git a/tests/engine/async_download_queue.lua b/tests/engine/async_download_queue.lua new file mode 100644 index 000000000..8e385c753 --- /dev/null +++ b/tests/engine/async_download_queue.lua @@ -0,0 +1,107 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local checks, ticks, workers, queued = 0, 0, {}, {} +local function check(ok, label) checks = checks + 1; assert(ok, label) end +local function channel() + local q = {} + function q:push(v) self[#self + 1] = v; if type(v) == "table" and v.stage then queued[v.id] = v end end + function q:pop() if #self > 0 then return table.remove(self, 1) end end + function q:peek() return self[1] end + function q:clear() for i = #self, 1, -1 do self[i] = nil end end + function q:performAtomic(fn) return fn(self) end + return q +end +love = {system = {getOS = function() return "Linux" end}, timer = {getTime = function() return ticks end}, + filesystem = {createDirectory = function() return true end, getSaveDirectory = function() return "/owned" end, + getInfo = function() error("filesystem IO in main-thread poll") end, + remove = function() return true end}, thread = {newChannel = channel, + newThread = function() + local t = {start = function(self, c, r) self.commands, self.results = c, r end, + getError = function(self) return self.err end, wait = function() end} + workers[#workers + 1] = t; return t + end}} +package.loaded["src.core.HostShell"] = {canSpawnProcess = function() return true end} +local rename = os.rename +os.rename = function() error("filesystem publication in main-thread poll") end +local D = require("src.net.AsyncDownload") +local function complete(id, ok, bytes, err) + check(queued[id] ~= nil, "request queued before worker result") + workers[#workers].results:push({id = id, ok = ok, bytes = bytes, err = err, httpStatus = ok and 200 or 404}) +end +local function claim(id) + local control = queued[id].control + return control:performAtomic(function(c) + if c:peek() ~= "open" then return false end + c:clear(); c:push("publishing"); return true + end) +end +for _, dest in ipairs({"", "/absolute", "../escape", "x/../escape", "C:\\escape", "x//file", "x/", "net-download/file"}) do + check(D.download("https://host/file", dest) == nil, "invalid target refused: " .. dest) +end +check(D.download("file:///private", "out") == nil, "only HTTP transport") +check(D.download("http://host/file", "out", {boundedHttpsGet = 1}) == nil, "HTTPS constraint") +check(D.download("https://host/file", "out", {size = 3, maxBytes = 2}) == nil, "contradictory size limit") +check(D.download("https://host/file", "out", {size = .5}) == nil, "fractional size refused") +check(D.download("https://host/file", "out", {maxBytes = math.huge}) == nil, "infinite limit refused") +check(D.download("https://host/file", "out", {userAgent = "bad\r\nheader"}) == nil, "invalid header refused") +local id = assert(D.download("https://host/file", "nested/out", {size = 6})) +check(#workers == 2, "fixed file worker ceiling") +check(D.download("https://host/file", "nested\\out") == nil, "one active writer per normalized destination") +check(D.poll(id).status == "pending", "poll starts no HTTP or filesystem work") +queued[id].progress:push(3) +local progress = D.poll(id) +check(progress.receivedBytes == 3 and progress.progress == .5, "coalesced progress is available without filesystem IO") +queued[id].progress:clear() +check(D.poll(id).receivedBytes == 3, "progress cannot move backwards between worker updates") +check(claim(id), "worker claims completed transfer") +check(D.cancel(id) == false, "cancellation after publication claim is refused") +check(D.poll(id).phase == "publishing" and D.poll(id).cancellable == false, "publication boundary exposed honestly") +complete(id, true, 6) +local state = D.poll(id) +check(state.status == "ok" and state.receivedBytes == 6 and state.progress == 1, "completed file metadata") +state.status = "modified"; check(D.poll(id).status == "ok", "detached polling state") +check(not D.cancel(id) and D.poll(id).status == "ok", "completed publication is not rolled back") +D.release(id); check(D.poll(id).status == "error", "released handle invalid") +for _, mode in ipairs({"cancel", "release", "timeout"}) do + id = assert(D.download("https://host/file", "preserved", {size = 6, maxSeconds = 1})) + if mode == "cancel" then check(D.cancel(id), "pre-publication cancellation accepted") + elseif mode == "release" then D.release(id) + else ticks = ticks + 2; check(D.poll(id).status == "error", "queue time counts toward deadline") end + check(not claim(id), mode .. " cannot permit worker publication") + complete(id, true, 6) -- adversarial late result must not revive the request + check(D.poll(id).status == "error", mode .. " remains terminal after late completion") + D.release(id) +end +for _, err in ipairs({"HTTP 404", "download size mismatch", "could not publish download"}) do + id = assert(D.download("https://host/file", "retry")); complete(id, false, nil, err) + state = D.poll(id) + check(state.status == "error" and state.err == err and state.httpStatus == 404, "worker error preserved: " .. err) + D.release(id) +end +id = assert(D.download("https://host/empty", "empty", {size = 0})); claim(id); complete(id, true, 0) +check(D.poll(id).status == "ok" and D.poll(id).receivedBytes == 0, "zero-byte success completes") +D.release(id) +id = assert(D.download("https://host/finishing", "released-publish")); check(claim(id), "publication claim before release") +D.release(id) +check(D.download("https://host/retry", "released-publish") == nil, "released publication still owns destination until finished") +complete(id, true, 6) +local replacement = assert(D.download("https://host/retry", "released-publish")) +check(D.poll(id).status == "error", "released completion handle remains invalid") +D.cancel(replacement); complete(replacement, false, nil, "cancelled"); D.poll(replacement); D.release(replacement) +id = assert(D.download("https://host/finishing", "shutdown-publish")); claim(id); complete(id, true, 6) +D.shutdown(); check(D.poll(id).status == "ok", "shutdown drains a completed publication instead of leaving it pending") +D.release(id) +local held = {} +for i = 1, D.MAX_JOBS do held[i] = assert(D.download("https://host/slow", "held" .. i)) end +check(D.download("https://host/slow", "extra") == nil, "bounded active and queued file jobs") +for _, h in ipairs(held) do D.cancel(h); D.release(h) end +check(D.download("https://host/slow", "extra") == nil, "release cannot evade occupied worker budget") +for _, h in ipairs(held) do complete(h, false, nil, "cancelled") end +id = assert(D.download("https://host/new", "reused")) +check(D.poll(held[1]).status == "error", "released request never revived") +workers[#workers].err = "worker stopped" +check(D.poll(id).status == "error", "worker death terminates pending file jobs") +D.shutdown() +id = assert(D.download("https://host/new", "restart")); claim(id); complete(id, true, 5) +check(D.poll(id).status == "ok", "file pool restarts after shutdown") +D.release(id); D.shutdown(); os.rename = rename +print(("async file download queue: %d checks PASS"):format(checks)) diff --git a/tests/engine/async_get_queue.lua b/tests/engine/async_get_queue.lua new file mode 100644 index 000000000..ecc4aa833 --- /dev/null +++ b/tests/engine/async_get_queue.lua @@ -0,0 +1,51 @@ +package.path='./?.lua;'..package.path +local ticks, checks, channels, workers = 0, 0, {}, {} +local function check(ok, label) checks=checks+1;assert(ok,label)end +local function channel() + local q={} + function q:push(v)self[#self+1]=v end + function q:pop()if #self==0 then return nil end;return table.remove(self,1)end + function q:peek()return self[1]end + function q:clear()for i=#self,1,-1 do self[i]=nil end end + channels[#channels+1]=q;return q +end +love={timer={getTime=function()return ticks end},thread={newChannel=channel,newThread=function() + local t={start=function(self,c,r,q)self.commands,self.results,self.quit=c,r,q end, + getError=function(self)return self.err end,wait=function()end};workers[#workers+1]=t;return t +end}} +local Get=require('src.net.AsyncGet') +check(Get.get('file:///x')==nil,'direct caller URL validation') +check(Get.get('http://host',{boundedHttpsGet=1})==nil,'direct caller HTTPS constraint') +local handles={} +for i=1,Get.MAX_JOBS do handles[i]=assert(Get.get('https://host/x')) end +check(#workers==3,'fixed worker ceiling') +check(Get.get('https://host/x')==nil,'global active ceiling') +for _,id in ipairs(handles)do Get.cancel(id);Get.release(id)end +check(Get.get('https://host/x')==nil,'release cannot evade running worker ceiling') +for _,id in ipairs(handles)do workers[1].results:push({id=id,ok=true,body='late'})end +local id=assert(Get.get('https://host/x',{maxSeconds=1})) +check(Get.poll(handles[1]).status=='error','released handle never revived by late result') +ticks=2 +check(Get.poll(id).status=='error','queue time counts toward deadline') +workers[1].results:push({id=id,ok=true,body='too late'}) +check(Get.poll(id).body==nil,'timeout never accepts late body');Get.release(id) +id=assert(Get.get('https://host/x'));Get.cancel(id) +workers[1].results:push({id=id,ok=true,body='late'}) +check(Get.poll(id).status=='cancelled' and Get.poll(id).body==nil,'cancel never accepts late body');Get.release(id) +id=assert(Get.get('https://host/x'));workers[1].err='worker stopped' +check(Get.poll(id).status=='error','worker failure terminates pending jobs');Get.release(id) +Get.shutdown();check(Get.available(),'shutdown restarts pool') +id=assert(Get.get('https://host/x'));local w=workers[#workers] +w.results:push({id=id,ok=true,body='ok'}) +local state=Get.poll(id);state.status='changed' +check(Get.poll(id).status=='ok','poll returns detached state') +Get.release(id);Get.shutdown() +check(Get.postLog('http://host','log')==nil,'POST always requires HTTPS') +check(Get.postLog('https://host',string.rep('x',512*1024+1))==nil,'wire payload has a byte ceiling') +id=assert(Get.postLog('https://host','log',{contentType='text/plain'})) +w=workers[#workers] +w.results:push({id=id,ok=true,body='must not escape',err='must not escape',httpStatus=204}) +state=Get.poll(id) +check(state.status=='ok' and state.body==nil and state.err==nil,'one-way worker results hide body and diagnostics') +Get.release(id);Get.shutdown() +print(('async GET queue: %d checks PASS'):format(checks)) diff --git a/tests/engine/bounded_http.lua b/tests/engine/bounded_http.lua new file mode 100644 index 000000000..8206e31b6 --- /dev/null +++ b/tests/engine/bounded_http.lua @@ -0,0 +1,64 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Host=require('src.core.HostShell') +local Json=require('src.link.Json') +local Http=require('src.net.BoundedHttp') +local spawn=Host.canSpawnProcess +Host.canSpawnProcess=function()return false end +local previous=love +local binary='a\0\255' +local calls,request,reply=0,nil,{status=200,bodyBase64='YQD/'} +love={system={},data={ + encode=function(kind,format,value) + assert(kind=='string' and format=='base64' and value==binary) + return 'YQD/' + end, + decode=function(kind,format,value) + assert(kind=='string' and format=='base64' and value=='YQD/') + return binary + end, +}} +T.eq(Http.available(),false,'old mobile runtime does not advertise bounded HTTP') +love.system.boundedHttpVersion=function()return 2 end +love.system.httpRequestBounded=function(wire) + calls=calls+1;request=Json.decode(wire) + if type(reply)=='string' then return reply end + return Json.encode(reply) +end +T.eq(Http.nativeAvailable(),false,'unknown wire version is not silently accepted') +love.system.boundedHttpVersion=function()return 1 end +T.eq(Http.available(),true,'matching native protocol enables the service without subprocesses') +T.eq(Http.request('https://fixture.invalid/get',{maxBytes=3}),binary,'native GET preserves binary bytes') +T.eq(request.method,'GET','GET dispatch reaches the native binding') +T.eq(request.maxBytes,3,'response bound reaches the native adapter') +T.eq(Http.request('https://fixture.invalid/log',{method='POST',body=binary,discardBody=true}),'','log success discards the native reply') +T.eq(request.bodyBase64,'YQD/','binary request uses the native wire codec') +T.eq(request.httpsOnly,true,'POST wire always enforces HTTPS') +T.eq(request.discardBody,true,'native adapter receives one-way mode') +local count=calls +T.eq(Http.request('http://fixture.invalid/log',{method='POST',body=binary}),nil,'insecure POST is refused before native submission') +T.eq(Http.request('file:///fixture'),nil,'non-HTTP target is refused before native submission') +T.eq(Http.request('https://fixture.invalid',{maxSeconds=0}),nil,'invalid deadline submits nothing') +T.eq(calls,count,'invalid requests never enter the platform transport') +reply={status=200,bodyBase64='YQD/'} +T.eq(Http.request('https://fixture.invalid',{maxBytes=2}),nil,'decoded binary cannot exceed the requested bound') +reply={status=200,bodyBase64='invalid'} +T.eq(Http.request('https://fixture.invalid'),nil,'malformed native Base64 is a failure') +reply={status=200,bodyBase64=string.rep('x',12)} +T.eq(Http.request('https://fixture.invalid',{maxBytes=3}),nil,'oversized wire body is rejected before decoding') +for _,bad in ipairs({'not JSON','[]',Json.encode({status=999}),Json.encode({status=200.5}), + Json.encode({status=200,error='private platform diagnostic',bodyBase64='YQD/'})})do + reply=bad + local body,why=Http.request('https://fixture.invalid') + T.eq(body,nil,'malformed or failed native response cannot become success') + T.check(not tostring(why):find('private',1,true),'native diagnostics do not escape the bridge') +end +reply={status=307,bodyBase64='private redirect reply'} +local body,why,status=Http.request('https://fixture.invalid/log',{method='POST',body=binary,discardBody=true}) +T.eq(body,nil,'one-way redirect remains a failure') +T.eq(status,307,'redirect status is retained without its body') +reply={status=204} +T.eq(Http.request('https://fixture.invalid/log',{method='POST',body=binary,discardBody=true}),'','empty accepted log response needs no Base64 field') +love=previous;Host.canSpawnProcess=spawn +T.finish('Bounded native HTTP wire') diff --git a/tests/engine/log_reporting.lua b/tests/engine/log_reporting.lua new file mode 100644 index 000000000..78c4088ef --- /dev/null +++ b/tests/engine/log_reporting.lua @@ -0,0 +1,91 @@ +-- Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Manifest=require('src.mods.Manifest') +local Net=require('src.mods.Net') +local Fetch=require('src.net.Fetch') +local Transport=require('src.net.BoundedHttp') +local Log=require('src.mods.LogReport') +local Json=require('src.link.Json') +local function raw(extra) + local m={id='logmod',name='Log mod',version='1.0.0',api=2,entry='main.lua',permissions={'network'},log_url='https://logs.example.invalid/ingest'} + for k,v in pairs(extra or {})do m[k]=v end;return m +end +T.eq(Manifest.validate(raw()).log_url,'https://logs.example.invalid/ingest','manifest retains fixed destination') +for _,url in ipairs({'http://logs.invalid','https://','file:///log','https://log.invalid\nX: y',false})do + T.raises(function()Manifest.validate(raw({log_url=url}))end,'log_url','invalid destination rejected before entry') +end +T.raises(function()Manifest.validate(raw({permissions={}}))end,'network','strict declaration needs network grant') +T.eq(Manifest.validate(raw({api=1,permissions={}})).log_url,nil,'legacy warning cannot retain an ungranted endpoint') +local body='a\0\255\r\n`literal` $(literal) "quote"' +T.eq(Log.prepare('a',body),body,'text preserves arbitrary bytes') +T.eq(Log.prepare('a',string.rep('x',65536)),string.rep('x',65536),'exact input limit accepted') +T.eq(Log.prepare('a',string.rep('x',65537)),nil,'oversized input refused') +for _,opts in ipairs({false,'json',{format=false},{format=1},{format='xml'},{url='https://other.invalid'}, + {headers={}},{maxSeconds=99},setmetatable({},{__index=function()error('must not run')end})})do + T.eq(Log.prepare('a','x',opts),nil,'closed option contract rejects unknown switches and values') +end +local encoded,mime=Log.prepare('a','hello',{format='json'});local envelope=Json.decode(encoded) +T.eq(mime,'application/json','JSON MIME fixed');T.eq(envelope.mod,'a','envelope attributes owner') +T.eq(envelope.body,'hello','envelope retains body');T.eq(envelope.format,'json','envelope format') +T.eq(type(envelope.ts),'number','envelope captures one timestamp') +local original,available={},Transport.available +for _,name in ipairs({'get','postLog','poll','cancel','release','isPending'})do original[name]=Fetch[name]end +Transport.available=function()return true end +local calls,jobs,nextId={},{},0 +local function start(method,url,payload,opts) + nextId=nextId+1;jobs[nextId]={status='pending'} + calls[#calls+1]={method=method,url=url,body=payload,opts=opts,id=nextId};return nextId +end +Fetch.get=function(url,opts)return start('GET',url,nil,opts)end +Fetch.postLog=function(url,payload,opts)return start('POST',url,payload,opts)end +Fetch.poll=function(id)return jobs[id] or {status='error'}end +Fetch.cancel=function(id)if jobs[id]then jobs[id]={status='cancelled'}end end +Fetch.release=function(id)jobs[id]=nil end +Fetch.isPending=function(id)return jobs[id] and jobs[id].status=='pending'end +local loader={};local mod={manifest=Manifest.validate(raw())} +local api=Net.forMod(loader,mod);local send=Net.logReporter(loader,mod) +mod.manifest.log_url='https://changed.invalid' +local handle=assert(send(nil,body)) +local call=calls[#calls] +T.eq(call.url,'https://logs.example.invalid/ingest','runtime manifest mutation cannot choose another destination') +T.eq(call.body,body,'worker receives literal body bytes');T.eq(call.method,'POST','log uses POST') +T.eq(call.opts.userAgent,'gen2recomp-mod/logmod','caller identity cannot be overridden') +T.eq(call.opts.boundedHttpsGet,1,'HTTPS constraint is mandatory') +jobs[call.id]={status='ok',body='server private body',err='server diagnostic',raw='server private body',httpStatus=204,progress=1} +local state=api:poll(handle) +T.eq(state.status,'ok','accepted transfer result');T.eq(state.body,nil,'reply body never reaches mod') +T.eq(state.raw,nil,'one-way result is a closed public shape');T.eq(state.err,nil,'success cannot leak arbitrary diagnostics') +T.eq(state.httpStatus,204,'HTTP status remains observable');api:release(handle) +T.eq(next(loader.netOneWay.logmod),nil,'release removes private log-handle metadata') +local other=Net.forMod(loader,{manifest={id='other',permissionSet={network=true}}}) +handle=assert(send(nil,'failure'));call=calls[#calls] +jobs[call.id]={status='error',body='server secret',err='server secret'} +T.eq(api:poll(handle).err,'log upload failed','remote failures have a stable diagnostic') +T.eq(other:poll(handle).status,'error','a log handle belongs to its own mod') +api:release(handle) +local count=#calls +T.eq(send(nil,'x',{url='https://override.invalid'}),nil,'per-call destination override refused') +T.eq(#calls,count,'invalid options submit no request') +local held={} +for i=1,3 do held[i]=assert(api:get('https://data.invalid'))end +held[4]=assert(send(nil,'one more')) +T.eq(send(nil,'over quota'),nil,'GET and log share the per-mod ceiling') +Net.releaseAll(loader,'logmod') +T.eq(api:poll(held[4]).status,'error','unload invalidates log requests') +local missing=Net.logReporter(loader,{manifest={id='none',permissionSet={network=true}}}) +T.eq(missing(nil,'x'),nil,'missing destination is a normal refusal') +local denied=Net.logReporter(loader,{manifest={id='denied',permissionSet={}}}) +T.raises(function()denied(nil,'x')end,'network','ungranted call names its permission') +Transport.available=function()return false end +T.eq(send(nil,'offline'),nil,'unsupported native transport does not advertise a successful send') +Transport.available=function()return true end +local files={['mods/logmod/manifest.json']=Json.encode(raw()),['mods/logmod/main.lua']=[[ +local mod=...;mod.exports.handle=assert(mod:postLog('from actual loader'));mod.exports.fetch=mod.fetch +]]} +local loaded=T.sdk.loadMod('mods/logmod',{fs=T.sdk.memfs(files)}) +T.eq(loaded.mod.state,'loaded','actual mod loader installs postLog') +T.eq(calls[#calls].body,'from actual loader','actual entry reaches the native transport facade') +loaded.release();Net.unloadAll() +Transport.available=available;for name,method in pairs(original)do Fetch[name]=method end +T.finish('Native fixed-destination log reporting') diff --git a/tests/engine/net_permission.lua b/tests/engine/net_permission.lua new file mode 100644 index 000000000..7cc94cf85 --- /dev/null +++ b/tests/engine/net_permission.lua @@ -0,0 +1,28 @@ +package.path='./?.lua;./?/init.lua;'..package.path +local T=require('tests.modkit') +local Json=require('src.link.Json') +local Net=require('src.mods.Net') +local function load(permissions, entry) + local fs=T.sdk.memfs({ + ['mods/net_probe/manifest.json']=Json.encode({id='net_probe',name='Net probe',version='1.0.0',api=2,entry='main.lua',permissions=permissions}), + ['mods/net_probe/main.lua']=entry, + }) + return T.sdk.loadMod('mods/net_probe',{fs=fs}) +end +local a=load({'network'},[[local mod=...;assert(type(mod.fetch.get)=='function');assert(type(mod.fetch.poll)=='function');mod.exports.fetch=mod.fetch]]) +T.eq(#a.errors,0,'network mod loads real fetch facade') +T.eq(a.mod.state,'loaded','entry completed') +T.eq(type(a.loader.exports.net_probe.fetch.cancel),'function','cancellation exposed') +T.eq(type(a.loader.exports.net_probe.fetch.capabilities),'function','capability query exposed') +T.eq(a.loader.exports.net_probe.fetch:get('file:///private'),nil,'file URL refused') +a.release() +local b=load({},[[local mod=...;mod.exports.available=mod.fetch:available();mod.exports.get=pcall(mod.fetch.get,mod.fetch,'https://example.invalid')]]) +T.eq(#b.errors,0,'ungranted query does not fail entry') +T.eq(b.loader.exports.net_probe.available,false,'transport unavailable without grant') +T.eq(b.loader.exports.net_probe.get,false,'operation requires network grant') +b.release() +for _,bad in ipairs({'https://','https://example.invalid\nHeader: bad','ftp://host','https://host\0','http://a b'})do + T.eq(type(Net.urlDenial(bad)),'string','bad URL denied') +end +T.eq(Net.urlDenial('https://host/a%20b?x=%22&z=1'),nil,'encoded URL accepted') +T.finish('mod network permission') diff --git a/tests/native_http/BoundedHttpCheck.java b/tests/native_http/BoundedHttpCheck.java new file mode 100644 index 000000000..fdbb82c46 --- /dev/null +++ b/tests/native_http/BoundedHttpCheck.java @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +package org.love2d.android; +import java.io.*; +import java.net.*; +import java.util.*; +import org.json.JSONObject; +public final class BoundedHttpCheck { + static int checks=0;static final List seen=Collections.synchronizedList(new ArrayList<>()); + static void check(boolean value,String label) {checks++;if(!value)throw new AssertionError(label);} + static final class Connection extends HttpURLConnection { + final ByteArrayOutputStream sent=new ByteArrayOutputStream();boolean closed; + Connection(URL url) {super(url);seen.add(this);} + public void connect() {} public boolean usingProxy(){return false;} + public synchronized void disconnect(){closed=true;notifyAll();} + public OutputStream getOutputStream(){return sent;} + public int getResponseCode(){return url.getPath().startsWith("/redirect") || url.getPath().equals("/downgrade") ? 307:200;} + public String getHeaderField(String name) { + if(name.equals("Location"))return (url.getPath().equals("/downgrade")?"http":"https")+"://fixture.invalid/target"; + if(name.equals("Content-Length"))return url.getPath().equals("/partial")?"100":null; + return null; + } + public InputStream getInputStream() { + if(url.getPath().equals("/slow"))return new InputStream(){public int read()throws IOException{ + synchronized(Connection.this){while(!closed){try{Connection.this.wait(1000);}catch(InterruptedException e){throw new IOException(e);}}} + throw new IOException("closed by deadline"); + }}; + byte[] bytes=url.getPath().equals("/post")?"private server reply".getBytes():new byte[]{0,(byte)255,13,10}; + if(url.getPath().equals("/chunked"))bytes=new byte[100]; + return new ByteArrayInputStream(bytes); + } + } + static JSONObject request(String method,String path)throws Exception { + JSONObject r=new JSONObject();r.put("boundedVersion",1);r.put("method",method);r.put("url","https://fixture.invalid"+path); + r.put("httpsOnly",true);r.put("discardBody",method.equals("POST"));r.put("maxSeconds",1);r.put("maxBytes",1024); + r.put("userAgent","gen2recomp-mod/native_probe");r.put("accept","*/*");r.put("contentType","text/plain; charset=utf-8"); + if(method.equals("POST"))r.put("bodyBase64",java.util.Base64.getEncoder().encodeToString(new byte[]{0,(byte)255,13,10})); + return r; + } + static JSONObject run(JSONObject r)throws Exception{return new JSONObject(BoundedHttp.request(r));} + public static void main(String[] args)throws Exception { + URL.setURLStreamHandlerFactory(protocol -> new URLStreamHandler(){protected URLConnection openConnection(URL u){return new Connection(u);}}); + JSONObject result=run(request("GET","/binary")); + check(result.getInt("status")==200,"GET status"); + check(Arrays.equals(java.util.Base64.getDecoder().decode(result.getString("bodyBase64")),new byte[]{0,(byte)255,13,10}),"binary response"); + result=run(request("POST","/post")); + check(!result.has("bodyBase64") && !result.has("error"),"one-way response hidden"); + Connection post=seen.get(seen.size()-1); + check(Arrays.equals(post.sent.toByteArray(),new byte[]{0,(byte)255,13,10}),"POST bytes"); + check(post.getRequestProperty("User-Agent").equals("gen2recomp-mod/native_probe"),"fixed identity"); + check(post.getRequestProperty("Accept-Encoding").equals("identity"),"no implicit compression"); + result=run(request("POST","/redirect-post"));check(result.has("error"),"POST redirect refused"); + check(seen.stream().noneMatch(c -> c.getURL().getPath().equals("/target")),"POST never forwarded"); + check(!run(request("GET","/redirect-get")).has("error"),"GET redirect allowed"); + check(run(request("GET","/downgrade")).has("error"),"HTTPS downgrade refused"); + for(String path:new String[]{"/chunked","/partial"}) { + JSONObject r=request("GET",path);r.put("maxBytes",path.equals("/partial")?1024:16);result=run(r); + check(result.has("error") && !result.has("bodyBase64"),"bounded complete response"); + } + JSONObject slow=request("GET","/slow");slow.put("maxSeconds",0.1);long start=System.nanoTime();result=run(slow); + check(result.has("error") && System.nanoTime()-start<700000000L,"absolute deadline closes connection"); + for(String key:new String[]{"boundedVersion","maxBytes","httpsOnly"}) { + JSONObject bad=request("GET","/binary");bad.put(key,key.equals("httpsOnly")?1:true); + check(run(bad).has("error"),"typed wire fields"); + } + JSONObject bad=request("POST","/post");bad.put("url","http://fixture.invalid/post");check(run(bad).has("error"),"POST HTTPS required"); + bad=request("GET","/binary");bad.put("url","file:///secret");check(run(bad).has("error"),"local file rejected"); + System.out.println("Native HTTP Java: "+checks+" PASS; URLConnection fixture, not Android TLS"); + } +} diff --git a/tests/native_http/android/util/Base64.java b/tests/native_http/android/util/Base64.java new file mode 100644 index 000000000..4908d9de3 --- /dev/null +++ b/tests/native_http/android/util/Base64.java @@ -0,0 +1,8 @@ +// Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +// Host-only codec adapter. Android builds use the platform implementation. +package android.util; +public final class Base64 { + public static final int DEFAULT=0,NO_WRAP=2; + public static byte[] decode(String input,int flags) {return java.util.Base64.getMimeDecoder().decode(input);} + public static String encodeToString(byte[] input,int flags) {return java.util.Base64.getEncoder().encodeToString(input);} +} diff --git a/tests/native_http/main.swift b/tests/native_http/main.swift new file mode 100644 index 000000000..59b808b4d --- /dev/null +++ b/tests/native_http/main.swift @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +import Foundation +final class HTTPFixture: URLProtocol { + static let gate=NSLock() + static var seen: [URLRequest]=[] + static var bodies: [Data?]=[] + private var stopped=false + private let state=NSLock() + override class func canInit(with request: URLRequest) -> Bool {request.url?.host=="fixture.invalid"} + override class func canonicalRequest(for request: URLRequest) -> URLRequest {request} + override func stopLoading() {state.lock();stopped=true;state.unlock()} + override func startLoading() { + var body=request.httpBody + if body==nil, let stream=request.httpBodyStream { + stream.open();defer{stream.close()} + var collected=Data();var chunk=[UInt8](repeating:0,count:4096) + while true {let count=stream.read(&chunk,maxLength:chunk.count);if count<=0{break};collected.append(contentsOf:chunk.prefix(count))} + body=collected + } + Self.gate.lock();Self.seen.append(request);Self.bodies.append(body);Self.gate.unlock() + let path=request.url!.path + if path=="/redirect-post" || path=="/redirect-get" || path=="/downgrade" { + let target=URL(string:(path=="/downgrade" ? "http" : "https")+"://fixture.invalid/target")! + var next=request;next.url=target + let response=HTTPURLResponse(url:request.url!,statusCode:307,httpVersion:"HTTP/1.1",headerFields:["Location":target.absoluteString])! + client?.urlProtocol(self,wasRedirectedTo:next,redirectResponse:response) + return + } + if path=="/slow" { + DispatchQueue.global().asyncAfter(deadline:.now()+0.8) { + self.state.lock();let stopped=self.stopped;self.state.unlock() + if !stopped {self.reply(Data([1,2,3]),length:3)} + } + return + } + if path=="/oversized" {reply(Data(repeating:1,count:100),length:100);return} + if path=="/chunked" {reply(Data(repeating:2,count:100),length:nil);return} + if path=="/partial" {reply(Data([1,2]),length:100);return} + reply(path=="/post" ? Data("private server reply".utf8) : Data([0,255,13,10]),length:nil) + } + private func reply(_ bytes: Data,length: Int?) { + let headers=length.map{["Content-Length":String($0)]} ?? [:] + let response=HTTPURLResponse(url:request.url!,statusCode:200,httpVersion:"HTTP/1.1",headerFields:headers)! + client?.urlProtocol(self,didReceive:response,cacheStoragePolicy:.notAllowed) + client?.urlProtocol(self,didLoad:bytes);client?.urlProtocolDidFinishLoading(self) + } +} +var checks=0 +func check(_ ok: @autoclosure () -> Bool,_ label: String) {checks+=1;precondition(ok(),label)} +func wire(_ method: String="GET",_ path: String="/binary",_ extra: [String:Any]=[:]) -> String { + var req: [String:Any] = ["boundedVersion":1,"method":method,"url":"https://fixture.invalid"+path, + "httpsOnly":true,"discardBody":method=="POST","userAgent":"gen2recomp-mod/native_probe", + "accept":"*/*","contentType":"text/plain; charset=utf-8","maxSeconds":1.0,"maxBytes":1024] + if method=="POST" {req["bodyBase64"]=Data([0,255,13,10]).base64EncodedString()} + for (key,value) in extra {req[key]=value} + return String(data:try! JSONSerialization.data(withJSONObject:req),encoding:.utf8)! +} +func run(_ json: String) -> [String:Any] { + let config=URLSessionConfiguration.ephemeral;config.protocolClasses=[HTTPFixture.self] + let result=GRBoundedHTTP.perform(json,configuration:config) + return try! JSONSerialization.jsonObject(with:Data(result.utf8)) as! [String:Any] +} +let binary=run(wire()) +check((binary["status"] as? Int)==200,"GET status") +check(Data(base64Encoded:binary["bodyBase64"] as! String)==Data([0,255,13,10]),"binary reply preserved") +let post=run(wire("POST","/post")) +check((post["status"] as? Int)==200 && post["bodyBase64"]==nil && post["error"]==nil,"one-way POST discards reply") +HTTPFixture.gate.lock();let sent=HTTPFixture.seen.last!;let sentBody=HTTPFixture.bodies.last!;HTTPFixture.gate.unlock() +check(sent.httpMethod=="POST" && sentBody==Data([0,255,13,10]),"request bytes preserved") +check(sent.value(forHTTPHeaderField:"User-Agent")=="gen2recomp-mod/native_probe","fixed identity") +check(sent.value(forHTTPHeaderField:"Accept-Encoding")=="identity","no implicit transfer compression") +let redirected=run(wire("POST","/redirect-post")) +check(redirected["error"] != nil,"POST redirect refused") +HTTPFixture.gate.lock();let targets=HTTPFixture.seen.filter{$0.url?.path=="/target"}.count;HTTPFixture.gate.unlock() +check(targets==0,"POST is never forwarded to another destination") +let getRedirect=run(wire("GET","/redirect-get")) +check((getRedirect["status"] as? Int)==200,"GET follows allowed redirects") +check(run(wire("GET","/downgrade"))["error"] != nil,"HTTPS downgrade refused") +for path in ["/oversized","/chunked","/partial"] { + let result=run(wire("GET",path,["maxBytes":path=="/partial" ? 1024 : 16])) + check(result["error"] != nil && result["bodyBase64"]==nil,"bounded and complete response required") +} +let began=Date();let slow=run(wire("GET","/slow",["maxSeconds":0.1])) +check(slow["error"] != nil && Date().timeIntervalSince(began)<0.7,"absolute native deadline") +for change in [["boundedVersion":true],["maxBytes":true],["httpsOnly":1],["method":"DELETE"],["url":"file:///secret"], + ["maxSeconds":31],["userAgent":"bad\nheader"],["method":"POST","url":"http://fixture.invalid/post"]] as [[String:Any]] { + check(run(wire("GET","/binary",change))["error"] != nil,"invalid native wire refused") +} +print("Native HTTP Swift: \(checks) PASS; URLProtocol fixture, not physical TLS") diff --git a/tools/async-get-check/conf.lua b/tools/async-get-check/conf.lua new file mode 100644 index 000000000..b58b4efe7 --- /dev/null +++ b/tools/async-get-check/conf.lua @@ -0,0 +1,6 @@ +function love.conf(t) + t.identity = assert(os.getenv('GET_TEST_IDENTITY')) + t.version = love.getVersion() >= 12 and '12.0' or '11.5' + t.window = false + t.modules.graphics, t.modules.audio, t.modules.joystick, t.modules.physics = false, false, false, false +end diff --git a/tools/async-get-check/download_test_worker.lua b/tools/async-get-check/download_test_worker.lua new file mode 100644 index 000000000..cc17860f3 --- /dev/null +++ b/tools/async-get-check/download_test_worker.lua @@ -0,0 +1,42 @@ +-- Hold only the final OS publication call, to exercise the real worker's +-- cancellation/commit boundary while the main thread keeps pumping. +require("love.thread") +require("love.filesystem") +require("love.timer") +require("love.system") +local publish = require("src.net.DownloadPublish") +package.loaded["src.net.DownloadPublish"] = function(source, target) + if target:sub(-18) == "/delay-publish.bin" then love.timer.sleep(.25) end + return publish(source, target) +end +-- Hold the bounded-queue fixture before the real worker reads cancellation. +-- A wall-clock HTTP delay can expire while a busy CI host deschedules main. +local commands, results, quit = ... +local actualResults=results +results={push=function(_,response) + -- Forward unchanged production results before the QA acknowledgement. + actualResults:push(response) + love.thread.getChannel("download-qa-completed"):push(response.id) +end} + +local gate = love.thread.getChannel("download-qa-hold") +local entered = love.thread.getChannel("download-qa-entered") +local firstFile = true +local proxy = { demand = function() + local job = commands:demand() + if type(job) == "table" and firstFile and job.dest == "downloads/target.bin" then + firstFile = false + love.thread.getChannel("download-qa-start-entered"):push(job.id) + while not love.thread.getChannel("download-qa-start"):peek() do love.timer.sleep(.001) end + end + if type(job)=="table" and job.dest=="downloads/retiring.bin" then + love.thread.getChannel("download-qa-retire-entered"):push(job.id) + while not love.thread.getChannel("download-qa-retire-release"):peek()do love.timer.sleep(.001)end + end + if type(job) == "table" and job.dest:match("^downloads/held%d+$") then + entered:push(job.id) + while not gate:peek() do love.timer.sleep(.001) end + end + return job +end } +assert(love.filesystem.load("src/net/download_worker.lua"))(proxy, results, quit) diff --git a/tools/async-get-check/main.lua b/tools/async-get-check/main.lua new file mode 100644 index 000000000..e31bfdfc2 --- /dev/null +++ b/tools/async-get-check/main.lua @@ -0,0 +1,294 @@ +function love.errorhandler(err) + io.stderr:write(debug.traceback(tostring(err)).."\n");io.stderr:flush() + return function()return 1 end +end +local Fetch, Net, base +local fileSubmitted,fileCompleted,fileDestinations={},{},{} +local checks, pulses = 0, 0 +local function check(ok, message) + checks = checks + 1 + assert(ok, message) +end +local function await(api, handle) + local at = love.timer.getTime() + while true do + local before = love.timer.getTime() + local state = api:poll(handle) + check(love.timer.getTime() - before < .1, 'poll must not perform transport IO') + if state.status ~= 'pending' then return state end + check(love.timer.getTime() - at < 10, 'request completes within test deadline') + pulses = pulses + 1; love.timer.sleep(.005) + end +end +-- Logical cancellation precedes physical worker retirement. Record real +-- response acknowledgements so each bounded-queue phase starts deliberately. +local function awaitFileRetirement(wanted) + local at=love.timer.getTime() + while true do + local completed=love.thread.getChannel("download-qa-completed") + while true do local id=completed:pop();if not id then break end;fileCompleted[id]=true end + local pending=false + for id in pairs(wanted)do if not fileCompleted[id]then pending=true;break end end + if not pending then return end + check(love.timer.getTime()-at<5,'actual file workers retire before the next queue phase') + love.timer.sleep(.005) + end +end +function love.load() + love.thread.getChannel("download-qa-start"):clear() + love.thread.getChannel("download-qa-start-entered"):clear() + love.thread.getChannel("download-qa-hold"):clear() + love.thread.getChannel("download-qa-entered"):clear() + love.thread.getChannel("download-qa-completed"):clear() + love.thread.getChannel("download-qa-retire-entered"):clear() + love.thread.getChannel("download-qa-retire-release"):clear() + local Download=require('src.net.AsyncDownload') + local download=Download.download + Download.download=function(url,destination,options) + local id,why=download(url,destination,options) + if id then fileSubmitted[id]=true;fileDestinations[destination]=id end + return id,why + end + Fetch = require('src.net.Fetch'); Net = require('src.mods.Net') + base = assert(os.getenv('GET_TEST_URL')) + local newThread = love.thread.newThread + love.thread.newThread = function(source, ...) + if source == 'src/net/download_worker.lua' then source = 'download_test_worker.lua' end + return newThread(source, ...) + end +end +function love.run() + local ok, err = xpcall(function() + love.load() + local loader = {} + local function mod(id, network) return Net.forMod(loader, {manifest={id=id, permissionSet={network=network}}}) end + local a, b, denied = mod('a', true), mod('b', true), mod('denied', false) + if os.getenv('GET_TEST_UNTRUSTED')=='1' then + local record={manifest={id='untrusted',permissionSet={network=true},log_url=base..'/must-not-send'}} + local logs=Net.forMod(loader,record) + local h=assert(Net.logReporter(loader,record)(nil,'untrusted certificate probe')) + local reply=await(logs,h) + check(reply.status=='error' and reply.body==nil,'untrusted TLS certificate must be refused') + logs:release(h);Net.unloadAll();Fetch.shutdown();return + end + if base:sub(1,8)=='https://' then + local function reporter(id,path) + local record={manifest={id=id,permissionSet={network=true},log_url=base..path}} + return Net.forMod(loader,record),Net.logReporter(loader,record) + end + local logs,send=reporter('log_text','/post-text') + local h=assert(send(nil,'literal\0\255\r\n$(printf injected) "quote"')) + local reply=await(logs,h) + check(reply.status=='ok' and reply.body==nil and reply.httpStatus==200,'TLS log hides real server reply') + logs:release(h) + logs,send=reporter('log_json','/post-json') + h=assert(send(nil,string.rep('\0',65536),{format='json'}));reply=await(logs,h) + check(reply.status=='ok' and reply.body==nil,'expanded JSON envelope preserves the input byte ceiling') + logs:release(h) + logs,send=reporter('log_redirect','/redirect-post') + h=assert(send(nil,'fixed destination'));reply=await(logs,h) + check(reply.status=='error' and reply.body==nil and reply.httpStatus==307,'POST cannot change its declared endpoint via redirect: '..tostring(reply.status)..' HTTP '..tostring(reply.httpStatus)) + logs:release(h) + end + check(not denied:available(), 'network permission required') + check(not pcall(denied.get, denied, base), 'ungranted GET refuses') + check(a:available(), 'real thread workers available') + check(a:capabilities().maxResponseBytes >= 4194304, 'bounded receive ceiling advertised to package consumers') + check(a:get('file:///etc/hosts') == nil, 'non HTTP URL denied') + check(a:get(base:gsub('^https:','http:')..'/binary', {boundedHttpsGet=1}) == nil, 'bounded HTTPS rejects HTTP') + local started = love.timer.getTime() + local slow = assert(a:get(base..'/slow', {maxSeconds=3})) + check(love.timer.getTime()-started < .1, 'GET starts without waiting for server') + check(b:poll(slow).status == 'error', 'handles isolated between mods') + check(not b:cancel(slow) and not b:release(slow), 'foreign cancellation denied') + local binary = assert(a:get(base..'/binary?encoded=%22%26%25', {maxBytes=256})) + local result = await(a, binary) + check(result.status=='ok' and result.body==string.char(0,255,13,10)..'GET binary', 'binary bytes and encoded URL preserved: '..tostring(result.status)..' '..tostring(result.err)..' bytes='..tostring(result.body and #result.body)) + check(result.receivedBytes==14, 'received byte count exact') + a:release(binary) + local state = await(a, slow) + check(state.status=='ok' and state.body=='slow', 'delayed request completes: '..tostring(state.status)..' '..tostring(state.err)..' bytes='..tostring(state.body and #state.body)) + a:release(slow) + check(pulses>20, 'caller keeps pumping during slow response') + local missing=assert(a:get(base..'/missing'));state=await(a, missing) + check(state.status=='error' and state.httpStatus==404, 'HTTP errors retained');a:release(missing) + local big=assert(a:get(base..'/big', {maxBytes=64}));state=await(a,big) + check(state.status=='error' and state.body==nil, 'oversize body refused');a:release(big) + local chunk=assert(a:get(base..'/chunk', {maxBytes=64}));state=await(a,chunk) + check(state.status=='error' and state.body==nil, 'body without content length bounded');a:release(chunk) + local cancelled=assert(a:get(base..'/slow'));a:cancel(cancelled) + check(a:poll(cancelled).status=='cancelled','cancel terminal');a:release(cancelled) + local timeout=assert(a:get(base..'/slow', {maxSeconds=1}));state=await(a,timeout) + check(state.status=='error','deadline enforced');a:release(timeout) + local held={} + for i=1,4 do held[i]=assert(a:get(base..'/slow')) end + check(a:get(base..'/slow')==nil,'per-mod pending ceiling') + Net.releaseAll(loader,'a') + check(a:poll(held[1]).status=='error','unload invalidates handles') + Net.unloadAll();Fetch.shutdown() + check(Fetch.available(),'worker service can restart') + local restart=assert(b:get(base..'/binary'));state=await(b,restart) + check(state.status=='ok','GET after shutdown works');b:release(restart) + local inFilePoll = false + local getInfo = love.filesystem.getInfo + love.filesystem.getInfo = function(...) + assert(not inFilePoll, 'file poll must not perform filesystem IO') + return getInfo(...) + end + local fileApi = {poll = function(_, id) + inFilePoll = true + local ok, result = pcall(Fetch.poll, id) + inFilePoll = false + assert(ok, result) + return result + end} + local expected = string.char(0,255,13,10)..'GET binary' + love.filesystem.createDirectory('downloads') + local target = 'downloads/target.bin' + local function oldFile() + assert(love.filesystem.write(target, 'previous complete file')) + end + local function preserved(label) + check(love.filesystem.read(target) == 'previous complete file', label) + end + oldFile() + started = love.timer.getTime() + local fileJob = assert(Fetch.download(base..'/stream', target, {size=262144})) + -- Cold thread startup depends on the host scheduler. Prove nonblocking + -- behavior with a closed worker gate, rather than timing that startup. + local startGate = love.thread.getChannel("download-qa-start") + local enteredStart = love.thread.getChannel("download-qa-start-entered") + local gateAt = love.timer.getTime() + while enteredStart:getCount() == 0 do + check(love.timer.getTime()-gateAt < 10, 'cold file worker enters the start barrier') + check(fileApi:poll(fileJob).status == 'pending', 'caller pumps before transport may begin') + pulses = pulses + 1; love.timer.sleep(.005) + end + check(not startGate:peek(), 'download returned while the transport gate was closed') + preserved('gated transport preserves the previous complete destination') + startGate:push(true) + local observedProgress = false + local pattern = {}; for i=0,255 do pattern[#pattern+1] = string.char(i) end + pattern = table.concat(pattern) + local completeFile = pattern:rep(1024) + while true do + local before = love.timer.getTime() + state = fileApi:poll(fileJob) + check(love.timer.getTime()-before < .1, 'file poll never waits on HTTP') + if state.status ~= 'pending' then break end + -- Publication can finish after poll returns pending. Bracket the read + -- with a fresh control snapshot: only an unclaimed transfer still + -- promises the old file. At every phase the bytes must be one complete + -- version, never an in-place/partial overwrite. + local contents = love.filesystem.read(target) + local boundary = fileApi:poll(fileJob) + check(contents == 'previous complete file' or contents == completeFile, + 'concurrent publication exposes only a complete old or new file') + if boundary.status == 'pending' and boundary.cancellable then + check(contents == 'previous complete file', + 'unclaimed transfer preserves previous complete file') + end + if state.receivedBytes > 0 and state.receivedBytes < 262144 then observedProgress = true end + pulses = pulses + 1; love.timer.sleep(.005) + end + check(state.status == 'ok' and state.receivedBytes == 262144, 'streamed file completes: '..tostring(state.err)) + check(observedProgress, 'progress available before transfer finishes') + check(love.filesystem.read(target) == pattern:rep(1024), 'streaming preserves all binary bytes') + Fetch.release(fileJob) + for _, case in ipairs({ + {'/missing', {size=14}, 'HTTP failure'}, + {'/partial', {}, 'truncated response'}, + {'/binary', {size=13}, 'expected-size mismatch'}, + {'/binary', {maxBytes=3}, 'known-length bound'}, + {'/chunk', {maxBytes=64}, 'unknown-length bound'}, + {'/slow', {maxSeconds=.1}, 'deadline'}, + }) do + oldFile();fileJob=assert(Fetch.download(base..case[1], target, case[2])) + state=await(fileApi,fileJob) + check(state.status=='error',case[3]..' reported: '..tostring(state.err)) + if case[1]=='/missing' then check(state.httpStatus==404,'file HTTP status retained') end + preserved(case[3]..' preserves previous file');Fetch.release(fileJob) + end + oldFile();fileJob=assert(Fetch.download(base..'/slow',target)) + Fetch.cancel(fileJob);state=Fetch.poll(fileJob) + check(state.status=='error' and state.err=='cancelled','legacy file cancellation remains terminal') + preserved('cancel preserves previous file');Fetch.release(fileJob) + local binaryFile=assert(Fetch.download(base..'/redirect','downloads/redirect.bin',{size=14})) + state=await(fileApi,binaryFile) + check(state.status=='ok' and love.filesystem.read('downloads/redirect.bin')==expected,'redirect and encoded binary output') + Fetch.release(binaryFile) + local empty=assert(Fetch.download(base..'/empty','downloads/empty.bin',{size=0})) + state=await(fileApi,empty) + check(state.status=='ok' and love.filesystem.getInfo('downloads/empty.bin').size==0,'zero-byte HTTP success') + Fetch.release(empty) + local large=assert(Fetch.download(base..'/large','downloads/large.bin',{size=17*1024*1024,maxBytes=20*1024*1024})) + state=await(fileApi,large) + check(state.status=='ok' and state.receivedBytes==17*1024*1024,'file can exceed in-memory GET ceiling') + check(love.filesystem.read('downloads/large.bin')==pattern:rep(69632),'large file bytes exact') + Fetch.release(large) + local delayedPath='downloads/delay-publish.bin' + assert(love.filesystem.write(delayedPath,'previous complete file')) + local finishing=assert(Fetch.download(base..'/binary',delayedPath,{size=14})) + local observedPublication=false + local finishingAt=love.timer.getTime() + while true do + state=fileApi:poll(finishing) + if state.status~='pending' then break end + if state.phase=='publishing' then + observedPublication=true + check(not Fetch.cancel(finishing),'cancellation after publication claim is refused without waiting') + check(state.cancellable==false,'committing request reports cancellation boundary') + end + check(love.timer.getTime()-finishingAt<5,'delayed publication completes') + love.timer.sleep(.005) + end + check(observedPublication and state.status=='ok','main thread pumps during slow filesystem publication') + check(love.filesystem.read(delayedPath)==expected,'claimed publication completes with exact bytes') + Fetch.release(finishing) + local invalidTarget=assert(Fetch.download(base..'/binary','downloads',{size=14})) + state=await(fileApi,invalidTarget) + check(state.status=='error' and love.filesystem.getInfo('downloads').type=='directory','publish error preserves existing directory') + Fetch.release(invalidTarget) + awaitFileRetirement(fileSubmitted) + -- Hold one real cancelled worker, fill seven remaining credits, then + -- verify exactly one credit returns on its actual response acknowledgement. + local retiring=assert(Fetch.download(base..'/slow','downloads/retiring.bin')) + local retireEntered=love.thread.getChannel("download-qa-retire-entered") + local retireAt=love.timer.getTime() + while retireEntered:getCount()==0 do + check(love.timer.getTime()-retireAt<5,'retiring worker enters its actual fixture barrier') + love.timer.sleep(.005) + end + local nativeRetiring=retireEntered:pop() + check(nativeRetiring==fileDestinations['downloads/retiring.bin'],'acknowledgement identifies the actual native request') + Fetch.cancel(retiring);Fetch.release(retiring) + local heldFiles={} + for i=1,7 do heldFiles[i]=assert(Fetch.download(base..'/slow','downloads/held'..i)) end + check(Fetch.download(base..'/slow','downloads/held8')==nil,'cancelled occupied worker still consumes its native credit') + love.thread.getChannel("download-qa-retire-release"):push(true) + awaitFileRetirement({[nativeRetiring]=true}) + heldFiles[8]=assert(Fetch.download(base..'/slow','downloads/held8')) + local entered=love.thread.getChannel("download-qa-entered") + local heldAt=love.timer.getTime() + while entered:getCount()<2 do + check(love.timer.getTime()-heldAt<5,'both real file workers enter the fixture barrier') + love.timer.sleep(.005) + end + check(Fetch.download(base..'/slow','downloads/overflow')==nil,'file queue bounded') + for _, handle in ipairs(heldFiles) do Fetch.cancel(handle);Fetch.release(handle) end + check(Fetch.download(base..'/slow','downloads/overflow')==nil,'release cannot bypass occupied file workers') + love.thread.getChannel("download-qa-start"):push(true) + love.thread.getChannel("download-qa-hold"):push(true) + Fetch.shutdown() + for _, child in ipairs(love.filesystem.getDirectoryItems('net-download')) do + check(#love.filesystem.getDirectoryItems('net-download/'..child)==0,'shutdown cleans file worker staging data') + end + preserved('late cancellation cannot publish after shutdown') + print(('async GET and file downloads: %d checks, %d pump ticks PASS'):format(checks,pulses)) + end, debug.traceback) + love.thread.getChannel("download-qa-start"):push(true) + love.thread.getChannel("download-qa-hold"):push(true) + love.thread.getChannel("download-qa-retire-release"):push(true) + if not ok then io.stderr:write(tostring(err).."\n");io.stderr:flush(); if Fetch then Fetch.shutdown() end end + return function() return ok and 0 or 1 end +end diff --git a/tools/check_async_get.py b/tools/check_async_get.py new file mode 100644 index 000000000..634786af6 --- /dev/null +++ b/tools/check_async_get.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Exercise real LOVE worker HTTP against only a task-owned loopback server.""" +import argparse, http.server, os, pathlib, subprocess, threading, time, tempfile, shutil, uuid, ssl, json +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): pass + def do_POST(self): + length=int(self.headers.get('Content-Length','0')) + if length>512*1024:self.send_error(413);return + body=self.rfile.read(length) + self.server.posts.append(dict(path=self.path,body=body,agent=self.headers.get('User-Agent'),mime=self.headers.get('Content-Type'))) + if self.path=='/redirect-post': + self.send_response(307);self.send_header('Location','/forbidden-post-target');self.send_header('Content-Length','0');self.end_headers();return + self.send_response(200);self.send_header('Content-Length','18');self.end_headers();self.wfile.write(b'private reply body') + def do_GET(self): + path=self.path.split('?')[0] + if path=='/slow': time.sleep(1.25) + if path=='/missing': self.send_error(404); return + if path=='/redirect': + self.send_response(302);self.send_header('Location','/binary');self.send_header('Content-Length','0');self.end_headers();return + if path=='/empty': + self.send_response(204);self.send_header('Content-Length','0');self.end_headers();return + if path=='/stream': + self.send_response(200);self.send_header('Content-Length',str(4*65536));self.end_headers() + try: + for _ in range(4):self.wfile.write(bytes(range(256))*256);self.wfile.flush();time.sleep(.25) + except (BrokenPipeError,ConnectionResetError,ConnectionAbortedError):pass + return + if path=='/partial': + self.send_response(200);self.send_header('Content-Length','999');self.end_headers();self.wfile.write(b'short');return + if path=='/large': + self.send_response(200);self.send_header('Content-Length',str(17*1024*1024));self.end_headers() + try: + for _ in range(272):self.wfile.write(bytes(range(256))*256) + except (BrokenPipeError,ConnectionResetError,ConnectionAbortedError):pass + return + body=b'\x00\xff\r\nGET binary' if path=='/binary' else b'slow' if path=='/slow' else b'x'*4096 + self.send_response(200) + if path!='/chunk': self.send_header('Content-Length',str(len(body))) + self.end_headers() + try: self.wfile.write(body) + except (BrokenPipeError,ConnectionResetError,ConnectionAbortedError): pass +def main(): + p=argparse.ArgumentParser();p.add_argument('--love',required=True);p.add_argument('--source-root',type=pathlib.Path) + p.add_argument('--tls',action='store_true',help='also exercise fixed-destination POST against an owned TLS fixture') + args=p.parse_args() + root=pathlib.Path(__file__).resolve().parents[1] + source=(args.source_root or root).resolve() + executable=pathlib.Path(args.love).resolve() + token=uuid.uuid4().hex + identity='oasc-native-http-qa-'+token + temporary=None + run=subprocess.run + # Local participating checkouts use the shared storage leases. Standalone + # upstream/ephemeral CI uses its own isolated temporary project instead. + if os.name!='nt' and not os.getenv('GITHUB_ACTIONS') and (root/'tools/qa_storage.py').is_file(): + from qa_storage import qa_source,qa_copy,qa_profile,qa_run + qa_source(root);qa_source(source);qa_source(executable) + project=qa_copy(root/'.local/qa'/('native-http-test-'+token)/'unit') + profile=(pathlib.Path.home()/'Library/Application Support/LOVE'/identity if os.uname().sysname=='Darwin' + else pathlib.Path.home()/'.local/share/love'/identity) + qa_profile(profile) + run=qa_run + project.mkdir(parents=True) + else: + temporary=tempfile.TemporaryDirectory(prefix='gen2recomp-get-check-') + project=pathlib.Path(temporary.name) + for name in ('src/net/Fetch.lua','src/net/AsyncGet.lua','src/net/get_worker.lua','src/net/BoundedHttp.lua','src/link/Json.lua','src/mods/LogReport.lua', + 'src/net/AsyncDownload.lua','src/net/DownloadTransport.lua','src/net/DownloadPublish.lua','src/net/download_worker.lua', + 'src/mods/Net.lua','src/core/HostShell.lua'): + target=project/name;target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(source/name,target) + for name in ('main.lua','conf.lua','download_test_worker.lua'):shutil.copy2(root/'tools/async-get-check'/name,project/name) + server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Handler);server.posts=[] + cert=None + if args.tls: + config=project/'tls.cnf';cert=project/'tls.crt';key=project/'tls.key' + config.write_text('[req]\ndistinguished_name=dn\nx509_extensions=ext\nprompt=no\n[dn]\nCN=localhost\n[ext]\nsubjectAltName=IP:127.0.0.1,DNS:localhost\n') + result=run(['openssl','req','-x509','-newkey','rsa:2048','-nodes','-days','2','-config',str(config),'-keyout',str(key),'-out',str(cert)],capture_output=True,text=True,timeout=30) + result.check_returncode();key.chmod(0o600) + context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER);context.load_cert_chain(cert,key) + server.socket=context.wrap_socket(server.socket,server_side=True) + thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() + try: + env=os.environ.copy();env.update(GET_TEST_IDENTITY=identity,GET_TEST_URL=f'{"https" if args.tls else "http"}://127.0.0.1:{server.server_port}') + if cert: + untrusted=dict(env,GET_TEST_UNTRUSTED='1') + untrusted.pop('CURL_CA_BUNDLE',None) + rejected=run([str(executable),str(project)],env=untrusted,cwd=root,timeout=15) + rejected.check_returncode();assert not server.posts,'untrusted TLS sent a request body' + if cert:env['CURL_CA_BUNDLE']=str(cert) + result=run([str(executable),str(project)],env=env,cwd=root,timeout=45) + if args.tls and result.returncode==0: + text=[r for r in server.posts if r['path']=='/post-text'];encoded=[r for r in server.posts if r['path']=='/post-json'] + assert len(text)==1 and text[0]['body']==b'literal\x00\xff\r\n$(printf injected) "quote"' + assert text[0]['agent']=='gen2recomp-mod/log_text' and text[0]['mime']=='text/plain; charset=utf-8' + assert len(encoded)==1 + envelope=json.loads(encoded[0]['body']);assert envelope['mod']=='log_json' and envelope['body']=='\0'*65536 and envelope['format']=='json' + assert not any(r['path']=='/forbidden-post-target' for r in server.posts),'POST followed a redirect' + print('TLS POST: exact raw bytes, JSON envelope, identity and redirect refusal PASS',flush=True) + return result.returncode + finally: + server.shutdown();server.server_close() + if temporary is not None:temporary.cleanup() + +if __name__=='__main__':raise SystemExit(main()) diff --git a/tools/qualify_native_http.py b/tools/qualify_native_http.py new file mode 100644 index 000000000..df2c1b318 --- /dev/null +++ b/tools/qualify_native_http.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Exercise the production native HTTP adapters with owned protocol fixtures.""" +# Copyright (c) 2026 Roxas2712. See LICENSE-ROXAS2712.md. +import argparse,atexit,hashlib,json,os,re,subprocess,sys,time +from pathlib import Path +ROOT=Path(__file__).resolve().parents[1] +INPUTS=('mobile/ios/native/GRHTTPBridge.swift', + 'mobile/android/love/src/main/java/org/love2d/android/BoundedHttp.java', + 'tests/native_http/android/util/Base64.java','tests/native_http/main.swift', + 'tests/native_http/BoundedHttpCheck.java') +JSON_SHA256='3cf6cd6892e32e2b4c1c39e0f52f5248a2f5b37646fdfbb79a66b46b618414ed' +def main(): + p=argparse.ArgumentParser(description=__doc__) + for name in ['swiftc','sdk','ios-sdk','java-home','json-jar']:p.add_argument('--'+name,type=Path,required=True) + p.add_argument('--output',type=Path);p.add_argument('--qa-tools',type=Path) + a=p.parse_args();swift=a.swiftc.expanduser().absolute();java=a.java_home.resolve(strict=True) + sdk=a.sdk.resolve(strict=True);ios=a.ios_sdk.resolve(strict=True);jar=a.json_jar.resolve(strict=True) + if not swift.is_file() or hashlib.sha256(jar.read_bytes()).hexdigest()!=JSON_SHA256:raise ValueError('compiler or pinned test dependency invalid') + output=(a.output or ROOT/'.local/qa'/('gen2-native-mod-http-'+str(time.time_ns()))/'candidate').resolve() + run=subprocess.run + if not os.getenv('GITHUB_ACTIONS'): + if a.qa_tools:sys.path.insert(0,str(a.qa_tools.resolve(strict=True))) + from qa_storage import Storage + manager=Storage(ROOT);atexit.register(manager.close) + for source in (ROOT,swift,java,sdk,ios,jar):manager.source(source) + manager.cache(output.parent);manager.copy(output);run=manager.run + inputs={name:(ROOT/name).read_bytes() for name in INPUTS} + output.mkdir(parents=True,exist_ok=False) + for name,data in inputs.items():(output/Path(name).name).write_bytes(data) + def command(name,args): + r=run([str(v) for v in args],cwd=ROOT,capture_output=True,text=True,timeout=120) + (output/(name+'.log')).write_text(r.stdout+r.stderr) + if r.returncode:print(r.stdout+r.stderr,flush=True) + r.check_returncode();return r.stdout + command('swift-build',[swift,'-swift-version','5','-sdk',sdk,'-module-cache-path',output/'mac-modules', + output/'GRHTTPBridge.swift',output/'main.swift','-o',output/'http-swift']) + command('ios-typecheck',[swift,'-swift-version','5','-sdk',ios,'-target','arm64-apple-ios14.0', + '-module-cache-path',output/'ios-modules','-typecheck',output/'GRHTTPBridge.swift']) + command('java-build',[java/'bin/javac','-cp',jar,'-d',output/'classes', + output/'BoundedHttp.java',output/'Base64.java',output/'BoundedHttpCheck.java']) + s=command('swift-test',[output/'http-swift']) + j=command('java-test',[java/'bin/java','-cp',str(output/'classes')+os.pathsep+str(jar),'org.love2d.android.BoundedHttpCheck']) + if any((ROOT/name).read_bytes()!=data for name,data in inputs.items()):raise RuntimeError('native HTTP sources changed during qualification') + report={'passed':True,'checks':{'swift':int(re.search(r'Native HTTP Swift: (\d+) PASS',s)[1]), + 'java':int(re.search(r'Native HTTP Java: (\d+) PASS',j)[1])},'iosTypechecked':True, + 'sourceSha256':{name:hashlib.sha256(data).hexdigest() for name,data in inputs.items()}, + 'jsonTestDependencySha256':JSON_SHA256, + 'scope':'Production Foundation/URLConnection adapter logic with owned URLProtocol/URLConnection fixtures; no real mobile TLS, JNI, phone or console proof.'} + (output/'report.json').write_text(json.dumps(report,indent=2)+'\n');print(json.dumps({'output':str(output),**report}),flush=True) +if __name__=='__main__':main()