镜像自地址
https://github.com/JGZYES/ParlzPackageManger.git
已同步 2026-09-11 08:52:45 +08:00
382 行
15 KiB
C
382 行
15 KiB
C
/* repo.c - git hosting adapters: GitHub / GitLab / Gitea / Forgejo
|
|
*
|
|
* API endpoints used (latest release):
|
|
* GitHub: {api}/repos/{repo}/releases/latest
|
|
* GitLab: {api}/projects/{url-encoded repo}/releases/permalink/latest
|
|
* Gitea/Forgejo: {api}/api/v1/repos/{repo}/releases/latest
|
|
*
|
|
* Asset selection by OS (first match wins, in priority order):
|
|
* windows: .exe .msi .zip .7z
|
|
* linux: .deb .rpm .appimage .tar.gz .tgz .tar.xz
|
|
* macos: .dmg .pkg .app.zip .tar.gz
|
|
*/
|
|
#include "repo.h"
|
|
#include "http.h"
|
|
#include "json.h"
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <ctype.h>
|
|
|
|
static char *strdup_or(const char *s, const char *dflt) {
|
|
return strdup(s ? s : dflt);
|
|
}
|
|
|
|
static char *url_encode(const char *s) {
|
|
size_t cap = strlen(s) * 3 + 1;
|
|
char *out = malloc(cap);
|
|
size_t o = 0;
|
|
for (const char *p = s; *p && o + 4 < cap; p++) {
|
|
unsigned char c = (unsigned char)*p;
|
|
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
|
|
out[o++] = (char)c;
|
|
} else if (c == '/') {
|
|
out[o++] = '%'; out[o++] = '2'; out[o++] = 'F';
|
|
} else {
|
|
char tmp[8];
|
|
snprintf(tmp, sizeof(tmp), "%%%02X", c);
|
|
out[o++] = tmp[0]; out[o++] = tmp[1]; out[o++] = tmp[2];
|
|
}
|
|
}
|
|
out[o] = '\0';
|
|
return out;
|
|
}
|
|
|
|
/* Normalize "https://host/owner/repo(.git)" or "owner/repo" into "owner/repo". */
|
|
static char *normalize_repo(const char *in) {
|
|
const char *s = in;
|
|
int is_url = 0;
|
|
if (strncmp(s, "https://", 8) == 0) { s += 8; is_url = 1; }
|
|
else if (strncmp(s, "http://", 7) == 0) { s += 7; is_url = 1; }
|
|
/* skip hostname only for full URLs */
|
|
if (is_url) {
|
|
const char *slash = strchr(s, '/');
|
|
if (slash && slash != s) s = slash + 1;
|
|
}
|
|
size_t len = strlen(s);
|
|
if (len > 4 && strcmp(s + len - 4, ".git") == 0) len -= 4;
|
|
/* strip trailing slashes */
|
|
while (len > 0 && s[len - 1] == '/') len--;
|
|
char *out = malloc(len + 1);
|
|
memcpy(out, s, len);
|
|
out[len] = '\0';
|
|
return out;
|
|
}
|
|
|
|
PmmHost repo_detect_host(const char *repo_or_url) {
|
|
if (strstr(repo_or_url, "github.com")) return HOST_GITHUB;
|
|
if (strstr(repo_or_url, "gitlab.")) return HOST_GITLAB;
|
|
if (strstr(repo_or_url, "gitea.")) return HOST_GITEA;
|
|
if (strstr(repo_or_url, "forgejo.")) return HOST_FORGEJO;
|
|
/* full URL with unknown host: try to infer from path; default gitea-compatible */
|
|
if (strncmp(repo_or_url, "http", 4) == 0) return HOST_GITEA;
|
|
return HOST_GITHUB; /* bare slug defaults to github */
|
|
}
|
|
|
|
/* Extract "scheme://host[:port]" from a URL; empty string if none. */
|
|
static void extract_origin(const char *repo, char *origin, size_t size) {
|
|
const char *s = repo;
|
|
origin[0] = '\0';
|
|
if (strncmp(s, "https://", 8) == 0) s += 8;
|
|
else if (strncmp(s, "http://", 7) == 0) { s += 7; }
|
|
else return; /* bare slug: no origin */
|
|
s -= (strncmp(repo, "https://", 8) == 0) ? 8 : 7;
|
|
const char *slash = strchr(s + 8, '/');
|
|
if (!slash) slash = s + strlen(s);
|
|
size_t n = (size_t)(slash - s);
|
|
if (n >= size) n = size - 1;
|
|
memcpy(origin, s, n);
|
|
origin[n] = '\0';
|
|
}
|
|
|
|
RepoContext *repo_open(PmmHost host, const char *repo, const char *mirror_api_base) {
|
|
RepoContext *ctx = calloc(1, sizeof(RepoContext));
|
|
if (!ctx) return NULL;
|
|
ctx->host = host;
|
|
/* support "repo@ref" (e.g. owner/repo@v1.2.3) → specific release tag */
|
|
const char *lastslash = strrchr(repo, '/');
|
|
const char *at = strrchr(repo, '@');
|
|
char repo_buf[1024];
|
|
const char *repo_src = repo;
|
|
if (at && lastslash && at > lastslash) {
|
|
snprintf(repo_buf, sizeof(repo_buf), "%.*s", (int)(at - repo), repo);
|
|
repo_src = repo_buf;
|
|
ctx->ref = strdup(at + 1);
|
|
} else {
|
|
ctx->ref = strdup("");
|
|
}
|
|
char *norm = normalize_repo(repo_src);
|
|
char api[1024];
|
|
ctx->repo_norm = strdup(norm);
|
|
|
|
char origin[512];
|
|
extract_origin(repo_src, origin, sizeof(origin));
|
|
ctx->origin = strdup(origin);
|
|
|
|
const char *base = NULL;
|
|
if (mirror_api_base && *mirror_api_base) {
|
|
base = mirror_api_base; /* mirror overrides host entirely */
|
|
} else {
|
|
switch (host) {
|
|
case HOST_GITHUB: base = "https://api.github.com"; break;
|
|
case HOST_GITLAB: base = "https://gitlab.com"; break;
|
|
default: base = NULL; break; /* gitea/forgejo/auto need a full URL */
|
|
}
|
|
}
|
|
|
|
if (host == HOST_AUTO) {
|
|
/* Generic git URL: api_url is built per-candidate in repo_latest_asset */
|
|
ctx->page_url = strdup_or(origin[0] ? origin : "", "");
|
|
ctx->api_url = NULL;
|
|
free(norm);
|
|
return ctx;
|
|
}
|
|
|
|
if (!base) {
|
|
/* Gitea/Forgejo require a full URL in the repo argument, e.g.
|
|
* https://git.example.com/owner/repo or configured in mirror file. */
|
|
if (!origin[0])
|
|
strncpy(origin, "http://localhost:3000", sizeof(origin) - 1);
|
|
snprintf(api, sizeof(api), "%s/api/v1/repos/%s", origin, norm);
|
|
ctx->page_url = strdup_or(origin, "");
|
|
} else {
|
|
switch (host) {
|
|
case HOST_GITHUB:
|
|
snprintf(api, sizeof(api), "%s/repos/%s", base, norm);
|
|
break;
|
|
case HOST_GITLAB: {
|
|
char *enc = url_encode(norm);
|
|
snprintf(api, sizeof(api), "%s/projects/%s", base, enc);
|
|
free(enc);
|
|
break;
|
|
}
|
|
default: /* gitea/forgejo via mirror base */
|
|
snprintf(api, sizeof(api), "%s/api/v1/repos/%s", base, norm);
|
|
break;
|
|
}
|
|
ctx->page_url = strdup(base);
|
|
}
|
|
ctx->api_url = strdup(api);
|
|
free(norm);
|
|
return ctx;
|
|
}
|
|
|
|
void repo_close(RepoContext *ctx) {
|
|
if (!ctx) return;
|
|
free(ctx->api_url);
|
|
free(ctx->page_url);
|
|
free(ctx->origin);
|
|
free(ctx->repo_norm);
|
|
free(ctx->ref);
|
|
free(ctx);
|
|
}
|
|
|
|
static int has_ext(const char *name, const char *ext) {
|
|
size_t nl = strlen(name), el = strlen(ext);
|
|
if (nl < el) return 0;
|
|
const char *tail = name + nl - el;
|
|
if (strcasecmp(tail, ext) != 0) return 0;
|
|
/* ".tar.gz" style: for ".gz" only match after ".tar"? keep simple: exact suffix */
|
|
return 1;
|
|
}
|
|
|
|
static int asset_is_junk(const char *name);
|
|
|
|
/* Case-insensitive substring test (avoids relying on non-standard strcasestr). */
|
|
static int ci_contains(const char *hay, const char *needle) {
|
|
size_t nl = strlen(needle);
|
|
if (!nl) return 0;
|
|
for (; *hay; hay++) {
|
|
size_t i;
|
|
for (i = 0; i < nl && hay[i]; i++)
|
|
if (tolower((unsigned char)hay[i]) != tolower((unsigned char)needle[i])) break;
|
|
if (i == nl) return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/* True if the asset filename clearly belongs to ANOTHER platform (e.g. an
|
|
* "android"/"windows"/"darwin" tar.gz must not be chosen for Linux). */
|
|
static int name_hints_other_os(const char *name, PmmOS os) {
|
|
if (!name) return 0;
|
|
if (os == OS_LINUX)
|
|
return ci_contains(name,"android") || ci_contains(name,"windows") ||
|
|
ci_contains(name,"win32") || ci_contains(name,"darwin") ||
|
|
ci_contains(name,"macos") || ci_contains(name,"osx");
|
|
if (os == OS_WINDOWS)
|
|
return ci_contains(name,"linux") || ci_contains(name,"darwin") ||
|
|
ci_contains(name,"android") || ci_contains(name,"macos") ||
|
|
ci_contains(name,"osx");
|
|
if (os == OS_MACOS)
|
|
return ci_contains(name,"linux") || ci_contains(name,"windows") ||
|
|
ci_contains(name,"win32") || ci_contains(name,"android");
|
|
return 0;
|
|
}
|
|
|
|
int asset_matches_os(const char *filename, PmmOS os) {
|
|
static const char *win_exts[] = { ".exe", ".msi", ".zip", ".7z", NULL };
|
|
static const char *linux_exts[] = { ".deb", ".rpm", ".appimage", ".tar.gz", ".tgz", ".tar.xz", ".tar.bz2", ".pkg.tar.zst", NULL };
|
|
static const char *mac_exts[] = { ".dmg", ".pkg", ".app.zip", ".tar.gz", ".tgz", NULL };
|
|
const char **exts = NULL;
|
|
switch (os) {
|
|
case OS_WINDOWS: exts = win_exts; break;
|
|
case OS_LINUX: exts = linux_exts; break;
|
|
case OS_MACOS: exts = mac_exts; break;
|
|
default: return 0;
|
|
}
|
|
/* Never pick an asset whose name names a different platform (fzf publishes
|
|
* xxxx-android_<arch>.tar.gz; a Linux user must not get that). */
|
|
if (name_hints_other_os(filename, os)) return 0;
|
|
for (int i = 0; exts[i]; i++)
|
|
if (has_ext(filename, exts[i])) return 1;
|
|
/* A bare (no-dot) binary counts on Linux/macOS — e.g. a release asset named
|
|
* "pmm" that is an ELF. Skip obvious junk like README/LICENCE/checksums. */
|
|
if ((os == OS_LINUX || os == OS_MACOS) && strchr(filename, '.') == NULL
|
|
&& !asset_is_junk(filename))
|
|
return 1;
|
|
return 0;
|
|
}
|
|
|
|
/* Reject common junk assets like sources, checksums, sigs. */
|
|
static int asset_is_junk(const char *name) {
|
|
static const char *junk[] = { ".sha256", ".sha512", ".sig", ".asc", ".sbom",
|
|
".json", ".txt", ".yml", ".yaml", ".xml", ".sb", ".pem", NULL };
|
|
for (int i = 0; junk[i]; i++)
|
|
if (has_ext(name, junk[i])) return 1;
|
|
if (strstr(name, "checksum") || strstr(name, "source") || strstr(name, "-src")) return 1;
|
|
return 0;
|
|
}
|
|
|
|
static ReleaseAsset *asset_from_json(const JsonValue *a, const char *tag) {
|
|
ReleaseAsset *ra = calloc(1, sizeof(ReleaseAsset));
|
|
const char *name = json_str(a, "name");
|
|
const char *url = json_str(a, "browser_download_url");
|
|
if (!url) url = json_str(a, "direct_asset_url");
|
|
ra->name = strdup_or(name, "asset");
|
|
ra->url = strdup_or(url, "");
|
|
ra->tag = strdup_or(tag, "");
|
|
return ra;
|
|
}
|
|
|
|
static ReleaseAsset *pick_from_assets_array(const JsonValue *assets, const char *tag, PmmOS os, const char *name_sub) {
|
|
for (int i = 0; i < assets->count; i++) {
|
|
JsonValue *a = json_at(assets, i);
|
|
if (!a) continue;
|
|
const char *name = json_str(a, "name");
|
|
if (!name || asset_is_junk(name)) continue;
|
|
if (!asset_matches_os(name, os)) continue;
|
|
if (name_sub && !ci_contains(name, name_sub)) continue;
|
|
return asset_from_json(a, tag);
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
static ReleaseAsset *fetch_latest(const char *url, PmmHost host, PmmOS os, const char *name_sub) {
|
|
int status = 0;
|
|
char *body = http_get(url, &status);
|
|
if (!body || (status != 200 && status != 0)) {
|
|
free(body);
|
|
return NULL;
|
|
}
|
|
JsonValue *root = json_parse(body);
|
|
free(body);
|
|
if (!root) return NULL;
|
|
|
|
const char *tag = json_str(root, "tag_name");
|
|
if (!tag) tag = json_str(root, "tag");
|
|
|
|
ReleaseAsset *found = NULL;
|
|
|
|
/* GitHub: assets is at root; Gitea: same; GitLab: assets.links[] */
|
|
JsonValue *assets = json_get(root, "assets");
|
|
if (host == HOST_GITLAB && assets) {
|
|
JsonValue *links = json_get(assets, "links");
|
|
if (links) {
|
|
for (int i = 0; i < links->count && !found; i++) {
|
|
JsonValue *a = json_at(links, i);
|
|
const char *name = json_str(a, "name");
|
|
if (!name) name = json_str(a, "direct_asset_url");
|
|
if (!name || asset_is_junk(name) || !asset_matches_os(name, os)) continue;
|
|
if (name_sub && !ci_contains(name, name_sub)) continue;
|
|
found = calloc(1, sizeof(ReleaseAsset));
|
|
found->name = strdup(name);
|
|
found->url = strdup_or(json_str(a, "direct_asset_url"), json_str(a, "url") ? json_str(a, "url") : "");
|
|
found->tag = strdup_or(tag, "");
|
|
}
|
|
}
|
|
} else if (assets) {
|
|
found = pick_from_assets_array(assets, tag, os, name_sub);
|
|
}
|
|
|
|
json_free(root);
|
|
if (found && (!found->url || !*found->url)) {
|
|
repo_asset_free(found);
|
|
return NULL;
|
|
}
|
|
return found;
|
|
}
|
|
|
|
ReleaseAsset *repo_latest_asset(RepoContext *ctx, PmmOS os) {
|
|
char url[2048];
|
|
|
|
if (ctx->host == HOST_AUTO) {
|
|
/* Generic git repo: probe known API shapes on the given origin. */
|
|
const char *origin = (ctx->origin && *ctx->origin) ? ctx->origin : NULL;
|
|
const char *norm = ctx->repo_norm;
|
|
ReleaseAsset *a = NULL;
|
|
if (origin) {
|
|
snprintf(url, sizeof(url), "%s/api/v1/repos/%s/releases/latest", origin, norm);
|
|
if ((a = fetch_latest(url, HOST_GITEA, os, NULL))) { ctx->host = HOST_GITEA; return a; }
|
|
char *enc = url_encode(norm);
|
|
snprintf(url, sizeof(url), "%s/api/v4/projects/%s/releases/permalink/latest", origin, enc);
|
|
free(enc);
|
|
if ((a = fetch_latest(url, HOST_GITLAB, os, NULL))) { ctx->host = HOST_GITLAB; return a; }
|
|
if (strstr(origin, "github.com")) {
|
|
snprintf(url, sizeof(url), "https://api.github.com/repos/%s/releases/latest", norm);
|
|
if ((a = fetch_latest(url, HOST_GITHUB, os, NULL))) { ctx->host = HOST_GITHUB; return a; }
|
|
}
|
|
} else {
|
|
snprintf(url, sizeof(url), "https://api.github.com/repos/%s/releases/latest", norm);
|
|
if ((a = fetch_latest(url, HOST_GITHUB, os, NULL))) { ctx->host = HOST_GITHUB; return a; }
|
|
snprintf(url, sizeof(url), "https://gitlab.com/api/v4/projects/%s/releases/permalink/latest", url_encode(norm));
|
|
if ((a = fetch_latest(url, HOST_GITLAB, os, NULL))) { ctx->host = HOST_GITLAB; return a; }
|
|
}
|
|
return NULL;
|
|
}
|
|
|
|
switch (ctx->host) {
|
|
case HOST_GITLAB:
|
|
if (ctx->ref && *ctx->ref) { char *e = url_encode(ctx->ref); snprintf(url, sizeof(url), "%s/releases/%s", ctx->api_url, e); free(e); }
|
|
else snprintf(url, sizeof(url), "%s/releases/permalink/latest", ctx->api_url);
|
|
break;
|
|
default:
|
|
if (ctx->ref && *ctx->ref) { char *e = url_encode(ctx->ref); snprintf(url, sizeof(url), "%s/releases/tags/%s", ctx->api_url, e); free(e); }
|
|
else snprintf(url, sizeof(url), "%s/releases/latest", ctx->api_url);
|
|
break;
|
|
}
|
|
return fetch_latest(url, ctx->host, os, NULL);
|
|
}
|
|
|
|
/* (Re)fetch the latest release and return the first asset for this OS whose
|
|
* name contains `name_sub` (e.g. "linux"). Used as a fallback when the first
|
|
* pick turns out to be a cross-platform/wrong binary. Caller frees via
|
|
* repo_asset_free. */
|
|
ReleaseAsset *repo_asset_matching(RepoContext *ctx, PmmOS os, const char *name_sub) {
|
|
if (!ctx || !ctx->api_url || !name_sub) return NULL;
|
|
char url[2048];
|
|
if (ctx->host == HOST_GITLAB) {
|
|
if (ctx->ref && *ctx->ref) { char *e = url_encode(ctx->ref); snprintf(url, sizeof(url), "%s/releases/%s", ctx->api_url, e); free(e); }
|
|
else snprintf(url, sizeof(url), "%s/releases/permalink/latest", ctx->api_url);
|
|
} else {
|
|
if (ctx->ref && *ctx->ref) { char *e = url_encode(ctx->ref); snprintf(url, sizeof(url), "%s/releases/tags/%s", ctx->api_url, e); free(e); }
|
|
else snprintf(url, sizeof(url), "%s/releases/latest", ctx->api_url);
|
|
}
|
|
return fetch_latest(url, ctx->host, os, name_sub);
|
|
}
|
|
|
|
void repo_asset_free(ReleaseAsset *a) {
|
|
if (!a) return;
|
|
free(a->url); free(a->name); free(a->tag);
|
|
free(a);
|
|
}
|