feat: support apk

This commit is contained in:
2025-11-18 12:16:28 +08:00
parent 68b6bb78e6
commit ba5544c28d
28 changed files with 1412 additions and 54 deletions

View File

@@ -0,0 +1,83 @@
// Package apk defines hook behaviors for Alpine APK proxying.
// APKINDEX/签名需要再验证packages/*.apk 视为不可变缓存。
package apk
import (
"path"
"strings"
"github.com/any-hub/any-hub/internal/proxy/hooks"
)
func init() {
hooks.MustRegister("apk", hooks.Hooks{
NormalizePath: normalizePath,
CachePolicy: cachePolicy,
ContentType: contentType,
})
}
func normalizePath(_ *hooks.RequestContext, p string, rawQuery []byte) (string, []byte) {
clean := path.Clean("/" + strings.TrimSpace(p))
return clean, rawQuery
}
func cachePolicy(_ *hooks.RequestContext, locatorPath string, current hooks.CachePolicy) hooks.CachePolicy {
clean := canonicalPath(locatorPath)
switch {
case isAPKIndexPath(clean), isAPKSignaturePath(clean):
// APKINDEX 及签名需要再验证,确保索引最新。
current.AllowCache = true
current.AllowStore = true
current.RequireRevalidate = true
case isAPKPackagePath(clean):
// 包体不可变,允许直接命中缓存,无需 HEAD。
current.AllowCache = true
current.AllowStore = true
current.RequireRevalidate = false
default:
current.AllowCache = false
current.AllowStore = false
current.RequireRevalidate = false
}
return current
}
func contentType(_ *hooks.RequestContext, locatorPath string) string {
clean := canonicalPath(locatorPath)
switch {
case strings.HasSuffix(clean, ".apk"):
return "application/vnd.android.package-archive"
case strings.HasSuffix(clean, ".tar.gz"):
return "application/gzip"
case strings.HasSuffix(clean, ".tar.gz.asc") || strings.HasSuffix(clean, ".tar.gz.sig"):
return "application/pgp-signature"
default:
return ""
}
}
func isAPKIndexPath(p string) bool {
clean := canonicalPath(p)
return strings.HasSuffix(clean, "/apkindex.tar.gz")
}
func isAPKSignaturePath(p string) bool {
clean := canonicalPath(p)
return strings.HasSuffix(clean, "/apkindex.tar.gz.asc") || strings.HasSuffix(clean, "/apkindex.tar.gz.sig")
}
func isAPKPackagePath(p string) bool {
clean := canonicalPath(p)
if isAPKIndexPath(clean) || isAPKSignaturePath(clean) {
return false
}
return strings.HasSuffix(clean, ".apk")
}
func canonicalPath(p string) string {
if p == "" {
return "/"
}
return strings.ToLower(path.Clean("/" + strings.TrimSpace(p)))
}

View File

@@ -0,0 +1,61 @@
package apk
import (
"testing"
"github.com/any-hub/any-hub/internal/proxy/hooks"
)
func TestCachePolicyIndexAndSignatureRevalidate(t *testing.T) {
paths := []string{
"/v3.19/main/x86_64/APKINDEX.tar.gz",
"/v3.19/main/x86_64/APKINDEX.tar.gz.asc",
"/v3.19/community/aarch64/apkindex.tar.gz.sig",
}
for _, p := range paths {
current := cachePolicy(nil, p, hooks.CachePolicy{})
if !current.AllowCache || !current.AllowStore || !current.RequireRevalidate {
t.Fatalf("expected index/signature to require revalidate for %s", p)
}
}
}
func TestCachePolicyPackageImmutable(t *testing.T) {
tests := []string{
"/v3.19/main/x86_64/packages/hello-1.0.apk",
"/v3.18/testing/aarch64/packages/../packages/hello-1.0-r1.APK",
"/v3.22/community/x86_64/tini-static-0.19.0-r3.apk", // 路径不含 /packages/ 也应视作包体
}
for _, p := range tests {
current := cachePolicy(nil, p, hooks.CachePolicy{})
if !current.AllowCache || !current.AllowStore || current.RequireRevalidate {
t.Fatalf("expected immutable cache for %s", p)
}
}
}
func TestCachePolicyNonAPKPath(t *testing.T) {
current := cachePolicy(nil, "/other/path", hooks.CachePolicy{})
if current.AllowCache || current.AllowStore || current.RequireRevalidate {
t.Fatalf("expected non-APK path to disable cache/store")
}
}
func TestNormalizePath(t *testing.T) {
p, _ := normalizePath(nil, "v3.19/main/x86_64/APKINDEX.tar.gz", nil)
if p != "/v3.19/main/x86_64/APKINDEX.tar.gz" {
t.Fatalf("unexpected normalized path: %s", p)
}
}
func TestContentType(t *testing.T) {
if ct := contentType(nil, "/v3.19/main/x86_64/APKINDEX.tar.gz"); ct != "application/gzip" {
t.Fatalf("expected gzip content type, got %s", ct)
}
if ct := contentType(nil, "/v3.19/main/x86_64/APKINDEX.tar.gz.asc"); ct != "application/pgp-signature" {
t.Fatalf("expected signature content type, got %s", ct)
}
if ct := contentType(nil, "/v3.19/main/x86_64/packages/hello.apk"); ct != "application/vnd.android.package-archive" {
t.Fatalf("expected apk content type, got %s", ct)
}
}

View File

@@ -0,0 +1,29 @@
// Package apk registers metadata for Alpine APK proxying.
package apk
import (
"time"
"github.com/any-hub/any-hub/internal/hubmodule"
)
const apkDefaultTTL = 6 * time.Hour
func init() {
// 模块元数据声明,具体 hooks 见 hooks.go已在 init 自动注册)。
hubmodule.MustRegister(hubmodule.ModuleMetadata{
Key: "apk",
Description: "Alpine APK proxy with cached indexes and packages",
MigrationState: hubmodule.MigrationStateBeta,
SupportedProtocols: []string{
"apk",
},
CacheStrategy: hubmodule.CacheStrategyProfile{
TTLHint: 0, // APKINDEX 每次再验证,包体直接命中
ValidationMode: hubmodule.ValidationModeLastModified, // APKINDEX 再验证
DiskLayout: "raw_path",
RequiresMetadataFile: false,
SupportsStreamingWrite: true, // 包体流式写
},
})
}