| 1 | /* |
| 2 | https_test.c: the app's HTTPS client (src/ps3/http_ps3.c, the same Mbed TLS |
| 3 | build and root certificates) run on Linux against dl.ps3.world, before any UI |
| 4 | is built on it. CI's "HTTPS test" job; see .github/workflows/ps3.yml. |
| 5 |
|
| 6 | ./https_test tests/sample |
| 7 |
|
| 8 | 1. a whole file, streamed through the sink, against its known sha256 |
| 9 | 2. the same file resumed with Range from the middle: 206, and the two parts |
| 10 | together hash to the same sha256 |
| 11 | 3. a missing file: a 404 that comes back as a status, not a hang or a crash |
| 12 | 4. a server with an expired certificate: refused |
| 13 | 5. catalog.json: the live one if it's been uploaded, and the sample, parsed |
| 14 | 6. download.c: a whole file to disk; one stopped part way and resumed with |
| 15 | Range; a wrong sha256 (deleted); not enough space; a 404; a cancel |
| 16 |
|
| 17 | Each result is also printed as a GitHub annotation, readable without signing in. |
| 18 | */ |
| 19 | #include <stdio.h> |
| 20 | #include <stdlib.h> |
| 21 | #include <string.h> |
| 22 | #include <stdarg.h> |
| 23 | #include <pthread.h> |
| 24 | #include <unistd.h> |
| 25 | #include <dirent.h> |
| 26 | #include <sys/stat.h> |
| 27 | #include <sys/statvfs.h> |
| 28 | #include "mbedtls/sha256.h" |
| 29 | #include "plat.h" |
| 30 | #include "app.h" |
| 31 | #include "catalog.h" |
| 32 | #include "download.h" |
| 33 |
|
| 34 | int ps3_net_ok = 1; |
| 35 |
|
| 36 | // ---- the plat_ file functions, on Linux ---- |
| 37 | unsigned char *plat_read_file(const char *path, size_t *len) |
| 38 | { |
| 39 | FILE *f = fopen(path, "rb"); |
| 40 | if (!f) return NULL; |
| 41 | fseek(f, 0, SEEK_END); |
| 42 | long n = ftell(f); |
| 43 | fseek(f, 0, SEEK_SET); |
| 44 | unsigned char *buf = malloc(n + 1); |
| 45 | if (fread(buf, 1, n, f) != (size_t)n) { fclose(f); free(buf); return NULL; } |
| 46 | fclose(f); |
| 47 | buf[n] = 0; |
| 48 | if (len) *len = n; |
| 49 | return buf; |
| 50 | } |
| 51 |
|
| 52 | int plat_write_file(const char *path, const void *data, size_t len) |
| 53 | { |
| 54 | FILE *f = fopen(path, "wb"); |
| 55 | if (!f) return 0; |
| 56 | int ok = fwrite(data, 1, len, f) == len; |
| 57 | return (fclose(f) == 0) && ok; |
| 58 | } |
| 59 |
|
| 60 | long long plat_file_size(const char *path) |
| 61 | { |
| 62 | struct stat st; |
| 63 | return stat(path, &st) == 0 && S_ISREG(st.st_mode) ? (long long)st.st_size : -1; |
| 64 | } |
| 65 |
|
| 66 | int plat_dir_exists(const char *path) |
| 67 | { |
| 68 | struct stat st; |
| 69 | return stat(path, &st) == 0 && S_ISDIR(st.st_mode); |
| 70 | } |
| 71 |
|
| 72 | int plat_mkdirs(const char *path) |
| 73 | { |
| 74 | char buf[512]; |
| 75 | snprintf(buf, sizeof(buf), "%s", path); |
| 76 | for (char *p = strchr(buf + 1, '/'); p; p = strchr(p + 1, '/')) { *p = 0; mkdir(buf, 0777); *p = '/'; } |
| 77 | mkdir(buf, 0777); |
| 78 | return plat_dir_exists(buf); |
| 79 | } |
| 80 |
|
| 81 | long long plat_free_bytes(const char *path) |
| 82 | { |
| 83 | struct statvfs v; |
| 84 | return statvfs(path, &v) == 0 ? (long long)v.f_bavail * (long long)v.f_frsize : -1; |
| 85 | } |
| 86 |
|
| 87 | int plat_list_dirs(const char *path, char (*names)[64], int max) |
| 88 | { |
| 89 | DIR *d = opendir(path); |
| 90 | struct dirent *e; |
| 91 | int n = 0; |
| 92 | if (!d) return 0; |
| 93 | while (n < max && (e = readdir(d))) |
| 94 | if (e->d_name[0] != '.' && e->d_type == DT_DIR) snprintf(names[n++], 64, "%s", e->d_name); |
| 95 | closedir(d); |
| 96 | return n; |
| 97 | } |
| 98 |
|
| 99 | void plat_log(const char *fmt, ...) |
| 100 | { |
| 101 | va_list ap; |
| 102 | va_start(ap, fmt); |
| 103 | printf(" log: "); |
| 104 | vprintf(fmt, ap); |
| 105 | printf("\n"); |
| 106 | va_end(ap); |
| 107 | } |
| 108 |
|
| 109 | struct plat_mutex { pthread_mutex_t m; }; |
| 110 | plat_mutex_t *plat_mutex_new(void) |
| 111 | { |
| 112 | plat_mutex_t *m = malloc(sizeof(*m)); |
| 113 | pthread_mutex_init(&m->m, NULL); |
| 114 | return m; |
| 115 | } |
| 116 | void plat_mutex_lock(plat_mutex_t *m) { pthread_mutex_lock(&m->m); } |
| 117 | void plat_mutex_unlock(plat_mutex_t *m) { pthread_mutex_unlock(&m->m); } |
| 118 |
|
| 119 | #define FILE_URL "https://dl.ps3.world/homebrewgames/Avoidance%20v1.3.pkg" |
| 120 | #define FILE_SIZE 285360 |
| 121 | #define FILE_SHA "4df8da76f006b61f5755806d2497e02bdf4ca25738b5d0610cc6f51fab3c1511" |
| 122 | #define RESUME_AT 100000 |
| 123 |
|
| 124 | static int failures; |
| 125 | static char summary[16384]; |
| 126 |
|
| 127 | static void Result(int ok, const char *name, const char *fmt, ...) |
| 128 | { |
| 129 | char msg[600]; |
| 130 | va_list ap; |
| 131 | va_start(ap, fmt); |
| 132 | vsnprintf(msg, sizeof(msg), fmt, ap); |
| 133 | va_end(ap); |
| 134 | printf("%s %s: %s\n", ok ? "PASS" : "FAIL", name, msg); |
| 135 | // GitHub shows 10 annotations a step: failures each get one, the passes share one at the end |
| 136 | if (getenv("GITHUB_ACTIONS") && !ok) |
| 137 | printf("::error title=FAIL %s::%s\n", name, msg); |
| 138 | size_t n = strlen(summary); |
| 139 | snprintf(summary + n, sizeof(summary) - n, "%s %s: %s%%0A", ok ? "PASS" : "FAIL", name, msg); |
| 140 | if (!ok) failures++; |
| 141 | } |
| 142 |
|
| 143 | // ---- download.c ---- |
| 144 | static const unsigned char *Want_Sha(void) |
| 145 | { |
| 146 | static unsigned char d[32]; |
| 147 | for (int i = 0; i < 32; i++) sscanf(FILE_SHA + i * 2, "%2hhx", &d[i]); |
| 148 | return d; |
| 149 | } |
| 150 |
|
| 151 | static void Dl_Setup(download_t *d, const char *url, const char *dest, long long size) |
| 152 | { |
| 153 | memset(d, 0, sizeof(*d)); |
| 154 | snprintf(d->url, sizeof(d->url), "%s", url); |
| 155 | snprintf(d->dest, sizeof(d->dest), "%s", dest); |
| 156 | d->size = size; |
| 157 | memcpy(d->sha256, Want_Sha(), 32); |
| 158 | } |
| 159 |
|
| 160 | // stops a download (pause or cancel) once this much has arrived |
| 161 | typedef struct { download_t *d; long long at; int cancel; } stopper_t; |
| 162 | static void *Stopper(void *p) |
| 163 | { |
| 164 | stopper_t *s = p; |
| 165 | while (s->d->state != DL_DONE && s->d->state != DL_FAILED && s->d->got < s->at) usleep(1000); |
| 166 | if (s->cancel) s->d->cancel = 1; else s->d->pause = 1; |
| 167 | return NULL; |
| 168 | } |
| 169 |
|
| 170 | static void Download_Tests(void) |
| 171 | { |
| 172 | download_t d; |
| 173 | char part[600]; |
| 174 | const char *dir = "/tmp/ps3world-dltest"; |
| 175 | char dest[256]; |
| 176 | plat_mkdirs(dir); |
| 177 | snprintf(dest, sizeof(dest), "%s/Avoidance v1.3.pkg", dir); |
| 178 | snprintf(part, sizeof(part), "%s.part", dest); |
| 179 | remove(dest); |
| 180 | remove(part); |
| 181 |
|
| 182 | // whole file |
| 183 | Dl_Setup(&d, FILE_URL, dest, FILE_SIZE); |
| 184 | download_run(&d); |
| 185 | Result(d.state == DL_DONE && plat_file_size(dest) == FILE_SIZE && plat_file_size(part) < 0, "download: whole file", |
| 186 | "state %d, %lld bytes on disk, %s", d.state, plat_file_size(dest), d.err); |
| 187 | remove(dest); |
| 188 |
|
| 189 | // stopped part way (as the network dropping or the app quitting), then resumed |
| 190 | pthread_t th; |
| 191 | stopper_t st = {&d, 120000, 0}; |
| 192 | Dl_Setup(&d, FILE_URL, dest, FILE_SIZE); |
| 193 | pthread_create(&th, NULL, Stopper, &st); |
| 194 | download_run(&d); |
| 195 | pthread_join(th, NULL); |
| 196 | long long left = plat_file_size(part); |
| 197 | int st1 = d.state; |
| 198 | Dl_Setup(&d, FILE_URL, dest, FILE_SIZE); |
| 199 | download_run(&d); |
| 200 | Result(st1 == DL_PAUSED && left > 0 && d.state == DL_DONE && d.resumed_from == left && plat_file_size(dest) == FILE_SIZE, |
| 201 | "download: resume with Range", "stopped at %lld bytes (state %d), resumed from %lld, then state %d, %lld bytes, sha256 checked; %s", |
| 202 | left, st1, d.resumed_from, d.state, plat_file_size(dest), d.err); |
| 203 | remove(dest); |
| 204 |
|
| 205 | // a wrong sha256: refused and deleted |
| 206 | Dl_Setup(&d, FILE_URL, dest, FILE_SIZE); |
| 207 | d.sha256[0] ^= 0xff; |
| 208 | download_run(&d); |
| 209 | Result(d.state == DL_FAILED && d.err_kind == ERR_CHECKSUM && plat_file_size(dest) < 0 && plat_file_size(part) < 0, |
| 210 | "download: checksum mismatch deleted", "state %d, kind %d: %s", d.state, d.err_kind, d.err); |
| 211 |
|
| 212 | // more than the drive has |
| 213 | Dl_Setup(&d, FILE_URL, dest, 1LL << 50); |
| 214 | download_run(&d); |
| 215 | Result(d.state == DL_FAILED && d.err_kind == ERR_DISK_FULL, "download: not enough space", "kind %d: %s", d.err_kind, d.err); |
| 216 |
|
| 217 | // 404 |
| 218 | Dl_Setup(&d, "https://dl.ps3.world/homebrewgames/no-such-file.pkg", dest, 1000); |
| 219 | download_run(&d); |
| 220 | Result(d.state == DL_FAILED && d.err_kind == ERR_NOT_FOUND && plat_file_size(part) < 0, "download: 404", |
| 221 | "kind %d: %s", d.err_kind, d.err); |
| 222 |
|
| 223 | // cancel: what arrived is deleted |
| 224 | stopper_t sc = {&d, 50000, 1}; |
| 225 | Dl_Setup(&d, FILE_URL, dest, FILE_SIZE); |
| 226 | pthread_create(&th, NULL, Stopper, &sc); |
| 227 | download_run(&d); |
| 228 | pthread_join(th, NULL); |
| 229 | Result(d.state == DL_CANCELLED && plat_file_size(part) < 0 && plat_file_size(dest) < 0, "download: cancel", |
| 230 | "state %d, partial %lld", d.state, plat_file_size(part)); |
| 231 | } |
| 232 |
|
| 233 | // the download sink: hashes as it goes and counts, like the app's will (nothing kept in memory) |
| 234 | typedef struct { |
| 235 | mbedtls_sha256_context sha; |
| 236 | long long got; |
| 237 | } sink_t; |
| 238 |
|
| 239 | static int Sink(void *ctx, const void *data, int len) |
| 240 | { |
| 241 | sink_t *s = ctx; |
| 242 | mbedtls_sha256_update(&s->sha, data, len); |
| 243 | s->got += len; |
| 244 | return 1; |
| 245 | } |
| 246 |
|
| 247 | static void Hex(const unsigned char *d, char *out) |
| 248 | { |
| 249 | for (int i = 0; i < 32; i++) sprintf(out + i * 2, "%02x", d[i]); |
| 250 | } |
| 251 |
|
| 252 | static int Get(const char *url, const char *headers, sink_t *s, http_res_t *res) |
| 253 | { |
| 254 | http_req_t req; |
| 255 | memset(&req, 0, sizeof(req)); |
| 256 | req.url = url; |
| 257 | req.headers = headers; |
| 258 | req.sink = Sink; |
| 259 | req.ctx = s; |
| 260 | req.timeout_s = 30; |
| 261 | req.total_s = 120; |
| 262 | return http_request(&req, res); |
| 263 | } |
| 264 |
|
| 265 | // the sample catalogue: every page of a list against its sha256, parsed; a folder's list (an avatar pack) too |
| 266 | typedef struct { |
| 267 | const char *root; |
| 268 | int lists, folders, pages, items, want, bad; // (want: the lists' counts; "All" repeats the rest) |
| 269 | char err[160]; |
| 270 | } sample_t; |
| 271 |
|
| 272 | static void Sample_List(sample_t *s, const cat_list_t *l) |
| 273 | { |
| 274 | s->lists++; |
| 275 | s->want += l->count; |
| 276 | for (int p = 0; p < l->num_pages; p++) { |
| 277 | char path[600]; |
| 278 | unsigned char d[32]; |
| 279 | size_t plen; |
| 280 | snprintf(path, sizeof(path), "%s/%s", s->root, l->pages[p].url); |
| 281 | char *page = (char *)plat_read_file(path, &plen); |
| 282 | mbedtls_sha256((const unsigned char *)(page ? page : ""), page ? plen : 0, d, 0); |
| 283 | catalog_t *c = page && !memcmp(d, l->pages[p].sha256, 32) ? catalog_parse_page(page, plen, s->err, sizeof(s->err)) : NULL; |
| 284 | if (!c) s->bad++; |
| 285 | for (int i = 0; c && i < c->count; i++) { |
| 286 | cat_list_t f; |
| 287 | if (!c->items[i].list[0]) continue; |
| 288 | if (catalog_parse_list(c->items[i].list, &f)) { |
| 289 | s->folders++; |
| 290 | Sample_List(s, &f); |
| 291 | catalog_free_list(&f); |
| 292 | } else |
| 293 | s->bad++; |
| 294 | } |
| 295 | if (c) { s->pages++; s->items += c->count; } |
| 296 | catalog_free(c); |
| 297 | free(page); |
| 298 | } |
| 299 | } |
| 300 |
|
| 301 | int main(int argc, char **argv) |
| 302 | { |
| 303 | http_res_t res; |
| 304 | sink_t s; |
| 305 | unsigned char d[32]; |
| 306 | char hex[65]; |
| 307 | http_init(); |
| 308 |
|
| 309 | // 1. whole file |
| 310 | mbedtls_sha256_init(&s.sha); |
| 311 | mbedtls_sha256_starts(&s.sha, 0); |
| 312 | s.got = 0; |
| 313 | int ok = Get(FILE_URL, NULL, &s, &res); |
| 314 | mbedtls_sha256_finish(&s.sha, d); |
| 315 | Hex(d, hex); |
| 316 | Result(ok && res.status == 200 && s.got == FILE_SIZE && !strcmp(hex, FILE_SHA), "whole file", |
| 317 | "HTTP %d, %lld bytes, sha256 %s%s%s", res.status, s.got, hex, res.err[0] ? ", " : "", res.err); |
| 318 |
|
| 319 | // 2. resume: the first RESUME_AT bytes, then Range from there |
| 320 | mbedtls_sha256_init(&s.sha); |
| 321 | mbedtls_sha256_starts(&s.sha, 0); |
| 322 | s.got = 0; |
| 323 | char range[64]; |
| 324 | snprintf(range, sizeof(range), "Range: bytes=0-%d\r\n", RESUME_AT - 1); |
| 325 | int ok1 = Get(FILE_URL, range, &s, &res); |
| 326 | int st1 = res.status; |
| 327 | snprintf(range, sizeof(range), "Range: bytes=%d-\r\n", RESUME_AT); |
| 328 | int ok2 = Get(FILE_URL, range, &s, &res); |
| 329 | mbedtls_sha256_finish(&s.sha, d); |
| 330 | Hex(d, hex); |
| 331 | Result(ok1 && ok2 && st1 == 206 && res.status == 206 && res.length == FILE_SIZE - RESUME_AT && s.got == FILE_SIZE && |
| 332 | !strcmp(hex, FILE_SHA), "Range resume", "HTTP %d then %d (second part %lld bytes), together %lld bytes, sha256 %s", |
| 333 | st1, res.status, res.length, s.got, hex); |
| 334 |
|
| 335 | // 3. 404 |
| 336 | mbedtls_sha256_init(&s.sha); |
| 337 | mbedtls_sha256_starts(&s.sha, 0); |
| 338 | ok = Get("https://dl.ps3.world/catalog/v1/no-such-file.json", NULL, &s, &res); |
| 339 | Result(ok && res.status == 404, "404", "returned %d, HTTP %d", ok, res.status); |
| 340 |
|
| 341 | // 4. a certificate that must be refused |
| 342 | ok = Get("https://expired.badssl.com/", NULL, &s, &res); |
| 343 | Result(!ok && strstr(res.err, "certificate"), "expired certificate refused", "returned %d: %s", ok, res.err); |
| 344 | mbedtls_sha256_free(&s.sha); |
| 345 |
|
| 346 | // 5. the catalogue: the live index (once uploaded) and one of its pages, then the sample |
| 347 | size_t len; |
| 348 | char err[160]; |
| 349 | char *body = http_fetch(NULL, CATALOG_URL, NULL, NULL, 60, NULL, &len, &res); |
| 350 | if (body && res.status == 200) { |
| 351 | cat_index_t *ix = catalog_parse_index(body, len, err, sizeof(err)); |
| 352 | if (ix && ix->num_lists[TYPE_THEME]) { |
| 353 | Result(1, "live index", "%lu bytes: %d homebrew in %d lists, %d themes in %d pages, %d avatars, generated %s", |
| 354 | (unsigned long)len, ix->count[TYPE_HOMEBREW], ix->num_lists[TYPE_HOMEBREW], ix->count[TYPE_THEME], |
| 355 | ix->lists[TYPE_THEME][0].num_pages, ix->count[TYPE_AVATAR], ix->generated); |
| 356 | const cat_file_t *pf = &ix->lists[TYPE_THEME][0].pages[0]; |
| 357 | char url[300]; |
| 358 | size_t plen; |
| 359 | snprintf(url, sizeof(url), "%s%s", CATALOG_BASE, pf->url); |
| 360 | char *page = http_fetch(NULL, url, NULL, NULL, 60, NULL, &plen, &res); |
| 361 | mbedtls_sha256((const unsigned char *)(page ? page : ""), page ? plen : 0, d, 0); |
| 362 | catalog_t *c = page ? catalog_parse_page(page, plen, err, sizeof(err)) : NULL; |
| 363 | Result(c && !memcmp(d, pf->sha256, 32), "live theme page 1", "%s: %lu bytes, sha256 %s the index, %d items, first \"%s\" %s", |
| 364 | pf->url, (unsigned long)plen, memcmp(d, pf->sha256, 32) ? "DIFFERS from" : "matches", c ? c->count : 0, |
| 365 | c && c->count ? c->items[0].title : "", err); |
| 366 | catalog_free(c); |
| 367 | free(page); |
| 368 | } else |
| 369 | Result(0, "live index", "%s", ix ? "no theme list" : err); |
| 370 | catalog_free_index(ix); |
| 371 | } else { |
| 372 | printf("SKIP live index: HTTP %d %s (not uploaded yet?)\n", res.status, res.err); |
| 373 | } |
| 374 | free(body); |
| 375 | if (argc > 1) { |
| 376 | // argv[1]: the sample's root (tests/sample), its index at catalog/v1/catalog.json |
| 377 | char path[600]; |
| 378 | size_t n; |
| 379 | snprintf(path, sizeof(path), "%s/catalog/v1/catalog.json", argv[1]); |
| 380 | char *buf = (char *)plat_read_file(path, &n); |
| 381 | cat_index_t *ix = buf ? catalog_parse_index(buf, n, err, sizeof(err)) : NULL; |
| 382 | sample_t s = {argv[1]}; |
| 383 | for (int t = 0; ix && t < NUM_TYPES; t++) |
| 384 | for (int l = 0; l < ix->num_lists[t]; l++) |
| 385 | Sample_List(&s, &ix->lists[t][l]); |
| 386 | Result(ix && !s.bad && s.folders && s.items == s.want, "sample catalogue parsed", |
| 387 | "%s: %d lists (%d of them folders), %d pages, %d items (the lists say %d; %d homebrew, %d themes, %d avatars), %d bad %s", |
| 388 | argv[1], s.lists, s.folders, s.pages, s.items, s.want, ix ? ix->count[0] : 0, ix ? ix->count[1] : 0, |
| 389 | ix ? ix->count[2] : 0, s.bad, s.err); |
| 390 | catalog_free_index(ix); |
| 391 | free(buf); |
| 392 | } |
| 393 |
|
| 394 | Download_Tests(); |
| 395 |
|
| 396 | printf("\n%s (%d failure%s)\n", failures ? "FAILED" : "all passed", failures, failures == 1 ? "" : "s"); |
| 397 | if (getenv("GITHUB_ACTIONS")) |
| 398 | printf("::notice title=HTTPS and download tests: %s::%s\n", failures ? "FAILED" : "all passed", summary); |
| 399 | return failures ? 1 : 0; |
| 400 | } |