gowalk-cicd 1.0.84 → 1.0.86
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -0
- package/README.md +10 -0
- package/action/.daemux-version +1 -1
- package/action/scripts/cert_factory.py +2 -4
- package/action/scripts/certificate_failure.py +47 -0
- package/action/scripts/test_certificate_cap_policy.py +6 -2
- package/action/scripts/test_certificate_failure.py +64 -0
- package/android-action/.daemux-version +1 -1
- package/android-action/scripts/flutter_manifest.py +31 -19
- package/android-action/scripts/flutter_manifest_env.sh +2 -1
- package/android-action/scripts/flutter_manifest_fixture.py +5 -4
- package/android-action/scripts/test_flutter_archive.py +64 -0
- package/backend-action/.daemux-version +1 -1
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -123,6 +123,8 @@ node /path/to/gowalk-cicd/bin/cli.mjs
|
|
|
123
123
|
Version source staged before signing is preserved separately with its original index bytes.
|
|
124
124
|
- Never revoke a distribution certificate automatically, including legacy caches.
|
|
125
125
|
`certificate-cap-policy` accepts only `fail`; a full cap requires registry reconciliation.
|
|
126
|
+
A certificate POST's HTTP 409 alone does not prove a full cap. `certificate_failure.py`
|
|
127
|
+
emits fixed reason/code evidence in `apple_certificate_rejected`; raw provider details stay private.
|
|
126
128
|
- Keep preinstalled Android SDK/NDKs. Every Gradle test/build and SDK download runs
|
|
127
129
|
under `play_store_proxy.py --`, whose owned loopback relay gives Java an explicit
|
|
128
130
|
authenticated account exit without credentials in JVM options. The child HTTP_PROXY
|
package/README.md
CHANGED
|
@@ -852,6 +852,12 @@ common ones:
|
|
|
852
852
|
| `ai-metadata` | `false` to disable AI auto-fill of empty ASC metadata |
|
|
853
853
|
| `ai-metadata-model` | GitHub Models model id (default `openai/gpt-4o`) |
|
|
854
854
|
|
|
855
|
+
Certificate creation refusals emit `apple_certificate_rejected` with schema
|
|
856
|
+
`gowalk-cicd/apple-certificate-rejected.v1`, `operation`, `http_status`, `reason` and `error_codes`.
|
|
857
|
+
HTTP 409 is classified as `certificate_limit` only when Apple's returned detail identifies the limit;
|
|
858
|
+
otherwise it remains `unclassified_conflict`. Codes come from a fixed allowlist. Raw provider text and
|
|
859
|
+
CSR data are excluded. Neither outcome permits automatic certificate deletion or another creation attempt.
|
|
860
|
+
|
|
855
861
|
## First-time app setup
|
|
856
862
|
|
|
857
863
|
The app record must exist in App Store Connect before the first CI upload.
|
|
@@ -1052,3 +1058,7 @@ different bytes under the same number. Build numbers come from `/builds`,
|
|
|
1052
1058
|
`/preReleaseVersions` **and** `/apps/{id}/buildUploads`, so an unfinished or refused
|
|
1053
1059
|
reservation is never reused; delete such a reservation only after reading it back.
|
|
1054
1060
|
All requests and parts still use the Apple proxy.
|
|
1061
|
+
|
|
1062
|
+
Flutter SDK setup retries interrupted manifest and archive GETs on the assigned proxy. Archive attempts remain
|
|
1063
|
+
bounded, expose only complete bytes to extraction, and never retry authentication or certificate refusals. Cache
|
|
1064
|
+
identities remain those of the pinned upstream Flutter action.
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.86
|
|
@@ -18,6 +18,7 @@ import base64
|
|
|
18
18
|
import os
|
|
19
19
|
|
|
20
20
|
from asc_common import request
|
|
21
|
+
import certificate_failure
|
|
21
22
|
from cryptography import x509
|
|
22
23
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
23
24
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
@@ -76,10 +77,7 @@ def create_distribution_cert(token: str, csr_b64: str) -> tuple[str, bytes]:
|
|
|
76
77
|
"POST", "/certificates", token, json_body=body, allow_status={409}
|
|
77
78
|
)
|
|
78
79
|
if resp.status_code == 409:
|
|
79
|
-
|
|
80
|
-
"distribution certificate cap reached; policy=fail preserves all "
|
|
81
|
-
"existing certificates and refuses automatic revocation; reconcile the account registry"
|
|
82
|
-
)
|
|
80
|
+
certificate_failure.refuse(resp)
|
|
83
81
|
data = resp.json()["data"]
|
|
84
82
|
cert_id = data["id"]
|
|
85
83
|
cert_der = base64.b64decode(data["attributes"]["certificateContent"])
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""A certificate POST conflict is not proof of quota exhaustion."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
_CODES = frozenset({"ENTITY_ERROR", "ENTITY_ERROR.ATTRIBUTE.INVALID", "ENTITY_ERROR.ATTRIBUTE.REQUIRED",
|
|
7
|
+
"ENTITY_ERROR.RELATIONSHIP.INVALID", "ENTITY_ERROR.RELATIONSHIP.REQUIRED",
|
|
8
|
+
"STATE_ERROR", "FORBIDDEN_ERROR", "UNEXPECTED_ERROR"})
|
|
9
|
+
_CAP_DETAILS = (
|
|
10
|
+
"maximum number of certificates",
|
|
11
|
+
"maximum allowed number of certificates",
|
|
12
|
+
"you already have a current distribution certificate or a pending certificate request",
|
|
13
|
+
"certificate limit has been reached",
|
|
14
|
+
"certificate limit reached",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def conflict(response) -> dict:
|
|
19
|
+
"""Use fixed labels and code allowlists; never return provider text, URLs or CSR data."""
|
|
20
|
+
try:
|
|
21
|
+
payload = response.json()
|
|
22
|
+
except (ValueError, TypeError):
|
|
23
|
+
payload = {}
|
|
24
|
+
errors = payload.get("errors", []) if isinstance(payload, dict) else []
|
|
25
|
+
errors = errors[:32] if isinstance(errors, list) else []
|
|
26
|
+
codes, capped = set(), False
|
|
27
|
+
for error in errors:
|
|
28
|
+
if not isinstance(error, dict):
|
|
29
|
+
continue
|
|
30
|
+
code = error.get("code")
|
|
31
|
+
if isinstance(code, str) and code in _CODES:
|
|
32
|
+
codes.add(code)
|
|
33
|
+
detail = error.get("detail")
|
|
34
|
+
if isinstance(detail, str):
|
|
35
|
+
capped |= any(marker in detail[:8192].lower() for marker in _CAP_DETAILS)
|
|
36
|
+
return {"schema": "gowalk-cicd/apple-certificate-rejected.v1", "operation": "certificates_createInstance",
|
|
37
|
+
"http_status": 409, "reason": "certificate_limit" if capped else "unclassified_conflict",
|
|
38
|
+
"error_codes": sorted(codes)}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def refuse(response) -> None:
|
|
42
|
+
result = conflict(response)
|
|
43
|
+
print("::error title=apple_certificate_rejected::" + json.dumps(result, separators=(",", ":")))
|
|
44
|
+
raise SystemExit(
|
|
45
|
+
f"Apple certificate request rejected (HTTP 409; reason={result['reason']}); "
|
|
46
|
+
"policy=fail preserves all existing certificates and refuses automatic revocation"
|
|
47
|
+
)
|
|
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|
|
5
5
|
import os
|
|
6
6
|
import sys
|
|
7
7
|
import unittest
|
|
8
|
+
from contextlib import redirect_stdout
|
|
9
|
+
from io import StringIO
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
from unittest import mock
|
|
10
12
|
|
|
@@ -27,7 +29,8 @@ class CertificateCapPolicyTests(unittest.TestCase):
|
|
|
27
29
|
def test_fail_policy_preserves_every_certificate_on_cap(self):
|
|
28
30
|
response = mock.MagicMock(status_code=409)
|
|
29
31
|
with mock.patch.dict(os.environ, {"CERTIFICATE_CAP_POLICY": "fail"}, clear=True):
|
|
30
|
-
with mock.patch.object(cert_factory, "request", return_value=response) as request
|
|
32
|
+
with (mock.patch.object(cert_factory, "request", return_value=response) as request,
|
|
33
|
+
redirect_stdout(StringIO())):
|
|
31
34
|
with self.assertRaisesRegex(SystemExit, "refuses automatic revocation"):
|
|
32
35
|
cert_factory.create_distribution_cert("token", "csr")
|
|
33
36
|
request.assert_called_once()
|
|
@@ -36,7 +39,8 @@ class CertificateCapPolicyTests(unittest.TestCase):
|
|
|
36
39
|
def test_default_policy_never_revokes_or_retries_a_full_cap(self):
|
|
37
40
|
capped = mock.MagicMock(status_code=409)
|
|
38
41
|
with mock.patch.dict(os.environ, {}, clear=True):
|
|
39
|
-
with mock.patch.object(cert_factory, "request", return_value=capped) as request
|
|
42
|
+
with (mock.patch.object(cert_factory, "request", return_value=capped) as request,
|
|
43
|
+
redirect_stdout(StringIO())):
|
|
40
44
|
with self.assertRaisesRegex(SystemExit, "refuses automatic revocation"):
|
|
41
45
|
cert_factory.create_distribution_cert("token", "csr")
|
|
42
46
|
request.assert_called_once()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Retain useful Apple refusal evidence without inventing a cap or exposing response data."""
|
|
2
|
+
import contextlib
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import unittest
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest import mock
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
14
|
+
import cert_factory # noqa: E402
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CertificateFailureTests(unittest.TestCase):
|
|
18
|
+
def request(self, body):
|
|
19
|
+
response = requests.Response()
|
|
20
|
+
response.status_code = 409
|
|
21
|
+
response._content = body.encode()
|
|
22
|
+
output = io.StringIO()
|
|
23
|
+
with mock.patch.dict(os.environ, {"CERTIFICATE_CAP_POLICY": "fail"}, clear=True):
|
|
24
|
+
with mock.patch.object(cert_factory, "request", return_value=response) as request:
|
|
25
|
+
with contextlib.redirect_stdout(output), self.assertRaises(SystemExit) as failed:
|
|
26
|
+
cert_factory.create_distribution_cert("private-token", "private-csr")
|
|
27
|
+
request.assert_called_once()
|
|
28
|
+
self.assertEqual(request.call_args.args[:2], ("POST", "/certificates"))
|
|
29
|
+
text = output.getvalue() + str(failed.exception)
|
|
30
|
+
self.assertNotIn("private-", text)
|
|
31
|
+
return json.loads(output.getvalue().split("::", 2)[2]), str(failed.exception)
|
|
32
|
+
|
|
33
|
+
def test_limit_requires_the_provider_limit_detail(self):
|
|
34
|
+
for detail in ["Maximum number of certificates generated.",
|
|
35
|
+
"You already have a current Distribution certificate or a pending certificate request."]:
|
|
36
|
+
with self.subTest(detail=detail):
|
|
37
|
+
result, message = self.request(json.dumps({"errors": [{
|
|
38
|
+
"code": "ENTITY_ERROR.ATTRIBUTE.INVALID", "detail": detail,
|
|
39
|
+
"source": {"pointer": "private-source"}, "links": {"about": "private-url"}}]}))
|
|
40
|
+
self.assertEqual(result["reason"], "certificate_limit")
|
|
41
|
+
self.assertEqual(result["error_codes"], ["ENTITY_ERROR.ATTRIBUTE.INVALID"])
|
|
42
|
+
self.assertIn("refuses automatic revocation", message)
|
|
43
|
+
|
|
44
|
+
def test_other_conflicts_are_not_reported_as_quota(self):
|
|
45
|
+
cases = [{"errors": [{"code": "ENTITY_ERROR.ATTRIBUTE.INVALID", "detail": "Invalid private-csr"}]},
|
|
46
|
+
{"errors": [{"code": "private-secret", "detail": "private-response"}]},
|
|
47
|
+
{"errors": [{"code": ["private-value"]}]}, {"errors": "private-response"},
|
|
48
|
+
{"errors": ["private-response"]}, [], None]
|
|
49
|
+
for case in cases:
|
|
50
|
+
with self.subTest(case=case):
|
|
51
|
+
result, message = self.request(json.dumps(case))
|
|
52
|
+
self.assertEqual(result["reason"], "unclassified_conflict")
|
|
53
|
+
self.assertNotIn("cap reached", message)
|
|
54
|
+
self.assertEqual(result["http_status"], 409)
|
|
55
|
+
self.assertEqual(result["operation"], "certificates_createInstance")
|
|
56
|
+
|
|
57
|
+
def test_non_json_response_is_an_unclassified_conflict(self):
|
|
58
|
+
result, _ = self.request("<html>private-response</html>")
|
|
59
|
+
self.assertEqual(result["reason"], "unclassified_conflict")
|
|
60
|
+
self.assertEqual(result["error_codes"], [])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
unittest.main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.86
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
"""Retry the official Flutter manifest
|
|
1
|
+
"""Retry the official Flutter manifest and SDK archive GETs without exposing partial JSON or proxy secrets."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
import re
|
|
8
|
+
import shutil
|
|
8
9
|
import subprocess
|
|
9
10
|
import sys
|
|
10
11
|
import tempfile
|
|
@@ -16,30 +17,23 @@ RETRY_HTTP = {408, 429, 500, 502, 503, 504}
|
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
def download(arguments: list[str], directory: Path) -> int:
|
|
19
|
-
|
|
20
|
-
if arguments[:-1] != ["--silent", "--connect-timeout", "15", "--retry", "5"]:
|
|
21
|
-
raise ValueError("flutter_manifest_arguments_unrecognized")
|
|
22
|
-
url = urlsplit(arguments[-1])
|
|
23
|
-
if (url.scheme != "https" or not url.hostname or url.username or url.password
|
|
24
|
-
or url.query or url.fragment or not re.fullmatch(
|
|
25
|
-
r"/flutter_infra_release/releases/releases_(linux|macos|windows)\.json", url.path)):
|
|
26
|
-
raise ValueError("flutter_manifest_url_unrecognized")
|
|
20
|
+
kind = request_kind(arguments)
|
|
27
21
|
if not (os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy")):
|
|
28
|
-
raise ValueError("
|
|
22
|
+
raise ValueError("flutter_download_proxy_required")
|
|
29
23
|
child_env = {**os.environ, "NO_PROXY": "", "no_proxy": ""}
|
|
30
|
-
deadline, code, http, proxy_http = time.monotonic() + 60, 1, 0, 0
|
|
24
|
+
deadline, code, http, proxy_http = time.monotonic() + (60 if kind == "manifest" else 600), 1, 0, 0
|
|
31
25
|
for attempt in range(1, 5):
|
|
32
26
|
remaining = deadline - time.monotonic()
|
|
33
27
|
if remaining <= 0:
|
|
34
28
|
break
|
|
35
|
-
destination = directory / f"
|
|
29
|
+
destination = directory / f"download-{attempt}"
|
|
36
30
|
destination.touch(mode=0o600, exist_ok=False)
|
|
37
31
|
destination.chmod(0o600)
|
|
38
32
|
try:
|
|
39
33
|
result = subprocess.run([
|
|
40
34
|
"curl", "--disable", "--silent", "--show-error", "--fail", "--retry", "0",
|
|
41
|
-
"--connect-timeout", "15", "--max-time", str(min(20, remaining)),
|
|
42
|
-
"--max-filesize",
|
|
35
|
+
"--connect-timeout", "15", "--max-time", str(min(20 if kind == "manifest" else 300, remaining)),
|
|
36
|
+
"--max-filesize", str(16777216 if kind == "manifest" else 2147483648), "--output", str(destination),
|
|
43
37
|
"--write-out", "%{http_code} %{http_connect}", arguments[-1],
|
|
44
38
|
], capture_output=True, timeout=remaining, env=child_env)
|
|
45
39
|
code = result.returncode
|
|
@@ -49,20 +43,38 @@ def download(arguments: list[str], directory: Path) -> int:
|
|
|
49
43
|
except subprocess.TimeoutExpired:
|
|
50
44
|
code, http, proxy_http = 28, 0, 0
|
|
51
45
|
if code == 0 and http == 200:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
46
|
+
if kind == "manifest":
|
|
47
|
+
json.loads(destination.read_bytes())
|
|
48
|
+
with destination.open("rb") as source:
|
|
49
|
+
shutil.copyfileobj(source, sys.stdout.buffer, length=1024 * 1024)
|
|
55
50
|
if attempt > 1:
|
|
56
|
-
print(f"
|
|
51
|
+
print(f"flutter_{kind}_recovered attempts={attempt}", file=sys.stderr)
|
|
57
52
|
return 0
|
|
53
|
+
destination.unlink(missing_ok=True)
|
|
58
54
|
if attempt == 4 or proxy_http in {401, 403, 407} or (code not in RETRY_CODES and http not in RETRY_HTTP):
|
|
59
55
|
break
|
|
60
56
|
time.sleep(min(2 ** (attempt - 1), max(0, deadline - time.monotonic())))
|
|
61
|
-
print(f"
|
|
57
|
+
print(f"flutter_{kind}_transport_failed curl_exit={code} http_status={http} "
|
|
62
58
|
f"proxy_status={proxy_http} attempts={attempt}", file=sys.stderr)
|
|
63
59
|
return code or 1
|
|
64
60
|
|
|
65
61
|
|
|
62
|
+
def request_kind(arguments):
|
|
63
|
+
if not arguments:
|
|
64
|
+
raise ValueError("flutter_download_arguments_missing")
|
|
65
|
+
url = urlsplit(arguments[-1])
|
|
66
|
+
if (url.scheme != "https" or not url.hostname or url.username or url.password
|
|
67
|
+
or url.query or url.fragment or ".." in url.path):
|
|
68
|
+
raise ValueError("flutter_download_url_unrecognized")
|
|
69
|
+
if arguments[:-1] == ["--silent", "--connect-timeout", "15", "--retry", "5"] and re.fullmatch(
|
|
70
|
+
r"/flutter_infra_release/releases/releases_(linux|macos|windows)\.json", url.path):
|
|
71
|
+
return "manifest"
|
|
72
|
+
if arguments[:-1] == ["--connect-timeout", "15", "--retry", "5"] and re.fullmatch(
|
|
73
|
+
r"/flutter_infra_release/releases/[A-Za-z0-9_./+-]+\.(tar\.xz|tar\.gz|zip)", url.path):
|
|
74
|
+
return "archive"
|
|
75
|
+
raise ValueError("flutter_download_arguments_unrecognized")
|
|
76
|
+
|
|
77
|
+
|
|
66
78
|
def main() -> int:
|
|
67
79
|
try:
|
|
68
80
|
with tempfile.TemporaryDirectory(prefix="flutter-manifest-", dir=os.environ["RUNNER_TEMP"]) as scratch:
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Loaded only by the two pinned Flutter setup invocations, never the whole job.
|
|
2
2
|
curl() {
|
|
3
3
|
case "${!#}" in
|
|
4
|
-
*/flutter_infra_release/releases/releases_*.json
|
|
4
|
+
*/flutter_infra_release/releases/releases_*.json|*/flutter_infra_release/releases/*.tar.xz|\
|
|
5
|
+
*/flutter_infra_release/releases/*.tar.gz|*/flutter_infra_release/releases/*.zip)
|
|
5
6
|
python3 "${GOWALK_FLUTTER_MANIFEST_HELPER:?}" "$@"
|
|
6
7
|
;;
|
|
7
8
|
*) command curl "$@" ;;
|
|
@@ -57,15 +57,16 @@ class Proxy(socketserver.StreamRequestHandler):
|
|
|
57
57
|
pass
|
|
58
58
|
self.server.requests.append(request[1].decode())
|
|
59
59
|
status = b"401 Unauthorized" if mode == "origin_auth" else b"200 OK"
|
|
60
|
-
|
|
60
|
+
complete = self.server.body
|
|
61
|
+
body = complete[:31] if mode == "partial" else complete
|
|
61
62
|
stream.sendall(b"HTTP/1.1 " + status + b"\r\nContent-Length: "
|
|
62
|
-
+ str(len(
|
|
63
|
+
+ str(len(complete)).encode() + b"\r\nConnection: close\r\n\r\n" + body)
|
|
63
64
|
except (OSError, ssl.SSLError):
|
|
64
65
|
pass # An untrusted fixture certificate must be rejected by the real client.
|
|
65
66
|
|
|
66
67
|
|
|
67
68
|
@contextmanager
|
|
68
|
-
def tls_proxy(root: Path, modes: list[str]):
|
|
69
|
+
def tls_proxy(root: Path, modes: list[str], body: bytes = MANIFEST):
|
|
69
70
|
cert, key = root / "cert.pem", root / "key.pem"
|
|
70
71
|
command = ["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
|
|
71
72
|
"-keyout", str(key), "-out", str(cert), "-subj", "/CN=" + HOST,
|
|
@@ -75,7 +76,7 @@ def tls_proxy(root: Path, modes: list[str]):
|
|
|
75
76
|
context.load_cert_chain(cert, key)
|
|
76
77
|
server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Proxy)
|
|
77
78
|
server.daemon_threads = True
|
|
78
|
-
server.context, server.modes = context, list(modes)
|
|
79
|
+
server.context, server.modes, server.body = context, list(modes), body
|
|
79
80
|
server.connects, server.requests, server.lock = 0, [], threading.Lock()
|
|
80
81
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
81
82
|
thread.start()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Real proxy/TLS transfers discard interrupted SDK bytes and preserve terminal refusals."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
from flutter_manifest_fixture import HOST, tls_proxy
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).parent
|
|
11
|
+
ARCHIVE = bytes(range(256)) * 512
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ArchiveRecoveryTests(unittest.TestCase):
|
|
15
|
+
def invoke(self, root, server, cert, hooked=True, trusted=True):
|
|
16
|
+
proxy = f"http://fixture-user:fixture-secret@127.0.0.1:{server.server_address[1]}"
|
|
17
|
+
env = {**os.environ, "HOME": str(root), "RUNNER_TEMP": str(root),
|
|
18
|
+
"HTTPS_PROXY": proxy, "https_proxy": proxy, "NO_PROXY": "", "no_proxy": ""}
|
|
19
|
+
for key in ("BASH_ENV", "CURL_CA_BUNDLE", "SSL_CERT_FILE", "SSL_CERT_DIR"):
|
|
20
|
+
env.pop(key, None)
|
|
21
|
+
if trusted:
|
|
22
|
+
env["CURL_CA_BUNDLE"] = str(cert)
|
|
23
|
+
if hooked:
|
|
24
|
+
env.update(BASH_ENV=str(ROOT / "flutter_manifest_env.sh"), NO_PROXY="*", no_proxy="*",
|
|
25
|
+
GOWALK_FLUTTER_MANIFEST_HELPER=str(ROOT / "flutter_manifest.py"))
|
|
26
|
+
(root / ".curlrc").write_text('noproxy = "*"\ninsecure\n')
|
|
27
|
+
url = f"https://{HOST}/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
|
|
28
|
+
result = subprocess.run(["bash", "-c", 'curl --connect-timeout 15 --retry 5 "$1"', "bash", url],
|
|
29
|
+
env=env, capture_output=True, timeout=20)
|
|
30
|
+
self.assertNotIn(b"fixture-secret", result.stdout + result.stderr)
|
|
31
|
+
self.assertFalse(list(root.glob("flutter-manifest-*")))
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
def test_original_partial_exit18_recovers_to_only_the_complete_archive(self):
|
|
35
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
36
|
+
root = Path(directory)
|
|
37
|
+
with tls_proxy(root, ["partial"], ARCHIVE) as (server, cert):
|
|
38
|
+
original = self.invoke(root, server, cert, hooked=False)
|
|
39
|
+
self.assertEqual(original.returncode, 18)
|
|
40
|
+
with tls_proxy(root, ["partial", "ok"], ARCHIVE) as (server, cert):
|
|
41
|
+
result = self.invoke(root, server, cert)
|
|
42
|
+
self.assertEqual((result.returncode, result.stdout), (0, ARCHIVE))
|
|
43
|
+
self.assertEqual(server.connects, 2)
|
|
44
|
+
self.assertIn(b"flutter_archive_recovered attempts=2", result.stderr)
|
|
45
|
+
|
|
46
|
+
def test_archive_transport_never_retries_auth_or_untrusted_certificate(self):
|
|
47
|
+
for mode, trusted in [("proxy_auth", True), ("origin_auth", True), ("ok", False)]:
|
|
48
|
+
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory:
|
|
49
|
+
root = Path(directory)
|
|
50
|
+
with tls_proxy(root, [mode], ARCHIVE) as (server, cert):
|
|
51
|
+
result = self.invoke(root, server, cert, trusted=trusted)
|
|
52
|
+
self.assertNotEqual(result.returncode, 0)
|
|
53
|
+
self.assertEqual((result.stdout, server.connects), (b"", 1))
|
|
54
|
+
|
|
55
|
+
def test_exhausted_archive_retry_never_emits_partial_bytes(self):
|
|
56
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
57
|
+
root = Path(directory)
|
|
58
|
+
with tls_proxy(root, ["partial"] * 4, ARCHIVE) as (server, cert):
|
|
59
|
+
result = self.invoke(root, server, cert)
|
|
60
|
+
self.assertEqual((result.returncode, result.stdout, server.connects), (18, b"", 4))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
unittest.main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.86
|