Files
nyaKazuha 99355effe4
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
fix(release): 完成分发证明代际绑定与质量门禁收口
2026-09-15 21:13:52 +08:00

361 lines
13 KiB
Go

package api
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
// ResourceEntry is one distributable object from the published release.
type ResourceEntry struct {
URL string `json:"url"`
RelativePath string `json:"relative_path"`
Bytes uint64 `json:"bytes"`
BLAKE3 string `json:"blake3,omitempty"`
Present bool `json:"present"`
SizeMatch bool `json:"size_match"`
}
// SnapshotSummary is a subset of official-sync-snapshot.json / catalog.status.
type SnapshotSummary struct {
Status string `json:"status,omitempty"`
StatusCode string `json:"status_code,omitempty"`
DistributionStatus string `json:"distribution_status,omitempty"`
DistributionStatusCode string `json:"distribution_status_code,omitempty"`
AppVersion string `json:"app_version,omitempty"`
BundleVersion string `json:"bundle_version,omitempty"`
ConnectionGroupName string `json:"connection_group_name,omitempty"`
AddressablesRoot string `json:"addressables_root,omitempty"`
VersionID string `json:"version_id,omitempty"`
CompletedUnixSeconds *uint64 `json:"completed_unix_seconds,omitempty"`
LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata,omitempty"`
GameMainConfig *GameMainConfigSummary `json:"game_main_config,omitempty"`
}
// LauncherMetadataSummary mirrors the resource-relevant part of Rust's launcher metadata snapshot.
type LauncherMetadataSummary struct {
LauncherVersion string `json:"launcher_version,omitempty"`
GameLatestVersion string `json:"game_latest_version,omitempty"`
GameLatestFilePath string `json:"game_latest_file_path,omitempty"`
GameLowestVersion string `json:"game_lowest_version,omitempty"`
GameStartExeName string `json:"game_start_exe_name,omitempty"`
GameStartParams []string `json:"game_start_params,omitempty"`
ManifestURL string `json:"manifest_url,omitempty"`
ManifestSource string `json:"manifest_source,omitempty"`
ManifestFileCount int `json:"manifest_file_count,omitempty"`
}
// GameMainConfigSummary mirrors the resource-relevant decrypted GameMainConfig fields.
type GameMainConfigSummary struct {
ServerInfoDataURL string `json:"server_info_data_url,omitempty"`
DefaultConnectionGroup string `json:"default_connection_group,omitempty"`
}
// DistributionHealth is the release-level authorization used by read paths.
//
// In RPC mode these fields are copied from Rust's current official
// attestation. The local manifest checks only establish that this process has
// a complete, safe read snapshot; they do not replace Rust's verifier.
type DistributionHealth struct {
Available bool `json:"available"`
Ready bool `json:"ready"`
Source string `json:"source"`
Channel string `json:"channel,omitempty"`
ReleaseID string `json:"release_id,omitempty"`
ResourceRoot string `json:"resource_root,omitempty"`
PublicationIdentity string `json:"publication_identity,omitempty"`
MappingIdentity string `json:"mapping_identity,omitempty"`
ManifestIdentity string `json:"manifest_identity,omitempty"`
EntryCount int `json:"entry_count,omitempty"`
VerificationGeneration uint64 `json:"verification_generation,omitempty"`
VerifiedAt *uint64 `json:"verified_at,omitempty"`
MaxAgeSeconds uint64 `json:"max_age_seconds,omitempty"`
Status string `json:"status,omitempty"`
StatusCode string `json:"status_code,omitempty"`
IntegrityStatus string `json:"integrity_status,omitempty"`
Diagnostics []string `json:"diagnostics,omitempty"`
}
// ReleaseIndex is the in-memory view of a published resource root.
type ReleaseIndex struct {
mu sync.RWMutex
ResourceRoot string `json:"resource_root"`
Source string `json:"source"` // "rpc" | "resource_root" | "empty"
RPCAvailable bool `json:"rpc_available"`
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
Distribution DistributionHealth `json:"distribution"`
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
Entries []ResourceEntry `json:"entries"`
// byRel maps relative path (host/path...) to entry index.
byRel map[string]int
// MissingOnDisk lists relative paths present in the index but absent on disk.
MissingOnDisk []string `json:"missing_on_disk,omitempty"`
}
// Summary returns a JSON-serializable overview without the full entry list.
type ReleaseSummary struct {
ResourceRoot string `json:"resource_root"`
Source string `json:"source"`
RPCAvailable bool `json:"rpc_available"`
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
Distribution DistributionHealth `json:"distribution"`
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
EntryCount int `json:"entry_count"`
PresentCount int `json:"present_count"`
MissingCount int `json:"missing_count"`
Ready bool `json:"ready"`
}
// Summary builds a compact release overview.
func (idx *ReleaseIndex) Summary() ReleaseSummary {
idx.mu.RLock()
defer idx.mu.RUnlock()
present := 0
for _, e := range idx.Entries {
if e.Present && e.SizeMatch {
present++
}
}
distribution := idx.Distribution
distribution.Diagnostics = append([]string(nil), idx.Distribution.Diagnostics...)
localComplete := idx.ResourceRoot != "" && len(idx.Entries) > 0 && present == len(idx.Entries)
// Hand-built indexes are retained for compatibility with local tests and
// diagnostics. Any index explicitly sourced from RPC must carry the Rust
// health fact; an RPC index without it is never considered distributable.
if distribution.Source == "" {
distribution.Ready = localComplete && idx.Source != "rpc" && idx.Source != "rpc+local_manifest"
} else {
distribution.Ready = distribution.Available &&
distribution.Ready &&
localComplete &&
(idx.Source != "rpc" ||
(distribution.ManifestIdentity != "" &&
distribution.EntryCount == len(idx.Entries)))
}
return ReleaseSummary{
ResourceRoot: idx.ResourceRoot,
Source: idx.Source,
RPCAvailable: idx.RPCAvailable,
DoctorHealthy: idx.DoctorHealthy,
Distribution: distribution,
Snapshot: idx.Snapshot,
ManifestVersion: idx.ManifestVersion,
EntryCount: len(idx.Entries),
PresentCount: present,
MissingCount: len(idx.MissingOnDisk),
// A release is distributable only when Rust authorizes it and every
// entry in this process's read snapshot is usable. Serving a partial
// release can leave clients with an unrecoverable download set.
Ready: distribution.Ready,
}
}
// Lookup returns the entry for a host/path relative path.
func (idx *ReleaseIndex) Lookup(rel string) (ResourceEntry, bool) {
idx.mu.RLock()
defer idx.mu.RUnlock()
rel = filepath.ToSlash(rel)
rel = strings.TrimPrefix(rel, "/")
i, ok := idx.byRel[rel]
if !ok {
return ResourceEntry{}, false
}
return idx.Entries[i], true
}
// List returns a stable page of entries.
func (idx *ReleaseIndex) List(offset, limit int) (items []ResourceEntry, total int) {
idx.mu.RLock()
defer idx.mu.RUnlock()
total = len(idx.Entries)
if offset < 0 {
offset = 0
}
if limit <= 0 {
limit = 100
}
if offset >= total {
return []ResourceEntry{}, total
}
end := offset + limit
if end > total {
end = total
}
out := make([]ResourceEntry, end-offset)
copy(out, idx.Entries[offset:end])
return out, total
}
// BuildIndexFromManifestEntries builds an index against resourceRoot.
func BuildIndexFromManifestEntries(
resourceRoot string,
source string,
rpcAvailable bool,
doctorHealthy *bool,
snapshot *SnapshotSummary,
manifestVersion int,
entries []manifestEntry,
distribution DistributionHealth,
) (*ReleaseIndex, error) {
rootAbs, err := filepath.Abs(resourceRoot)
if err != nil {
return nil, err
}
idx := &ReleaseIndex{
ResourceRoot: rootAbs,
Source: source,
RPCAvailable: rpcAvailable,
DoctorHealthy: doctorHealthy,
Distribution: distribution,
Snapshot: snapshotWithDistributionHealth(snapshot, distribution),
ManifestVersion: manifestVersion,
byRel: make(map[string]int),
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].URL < entries[j].URL
})
for _, raw := range entries {
rel := filepath.ToSlash(raw.Destination)
rel = strings.TrimPrefix(rel, "/")
if rel == "" {
// Derive from URL when destination is empty.
rel = relativePathFromURL(raw.URL)
}
if rel == "" {
continue
}
entry := ResourceEntry{
URL: raw.URL,
RelativePath: rel,
Bytes: raw.Bytes,
BLAKE3: raw.BLAKE3,
}
abs, err := ResolveUnderRoot(rootAbs, rel, false)
if err != nil {
entry.Present = false
entry.SizeMatch = false
idx.MissingOnDisk = append(idx.MissingOnDisk, rel)
} else {
info, err := os.Lstat(abs)
if err != nil || !info.Mode().IsRegular() {
entry.Present = false
entry.SizeMatch = false
idx.MissingOnDisk = append(idx.MissingOnDisk, rel)
} else {
entry.Present = true
entry.SizeMatch = uint64(info.Size()) == raw.Bytes || raw.Bytes == 0
if !entry.SizeMatch {
idx.MissingOnDisk = append(idx.MissingOnDisk, rel+"#size_mismatch")
}
}
}
idx.byRel[rel] = len(idx.Entries)
idx.Entries = append(idx.Entries, entry)
}
return idx, nil
}
// LoadIndexFromResourceRoot reads official-download-manifest.json under root.
// Used for tests and --resource-root fallback without RPC.
func LoadIndexFromResourceRoot(resourceRoot string) (*ReleaseIndex, error) {
rootAbs, err := filepath.Abs(resourceRoot)
if err != nil {
return nil, err
}
manifestPath := filepath.Join(rootAbs, "official-download-manifest.json")
data, err := os.ReadFile(manifestPath)
if err != nil {
return nil, fmt.Errorf("read download manifest: %w", err)
}
var manifest fileManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("parse download manifest: %w", err)
}
if manifest.Version != 0 && manifest.Version != 1 {
return nil, fmt.Errorf("unsupported download manifest version %d", manifest.Version)
}
entries := make([]manifestEntry, 0, len(manifest.Entries))
for _, e := range manifest.Entries {
entries = append(entries, manifestEntry{
URL: e.URL,
Destination: e.Destination,
Bytes: e.Bytes,
BLAKE3: e.BLAKE3,
})
}
var snapshot *SnapshotSummary
if snapData, err := os.ReadFile(filepath.Join(rootAbs, "official-sync-snapshot.json")); err == nil {
var s fileSnapshot
if json.Unmarshal(snapData, &s) == nil {
snapshot = &SnapshotSummary{
AppVersion: s.AppVersion,
BundleVersion: s.BundleVersion,
ConnectionGroupName: s.ConnectionGroupName,
AddressablesRoot: s.AddressablesRoot,
LauncherMetadata: s.LauncherMetadata,
GameMainConfig: s.GameMainConfigBootstrap,
}
}
}
return BuildIndexFromManifestEntries(
rootAbs,
"resource_root",
false,
nil,
snapshot,
manifest.Version,
entries,
DistributionHealth{
Available: true,
Ready: true,
Source: "resource_root_override",
Status: "ready",
StatusCode: "resource_root.ready",
},
)
}
type manifestEntry struct {
URL string
Destination string
Bytes uint64
BLAKE3 string
}
type fileManifest struct {
Version int `json:"version"`
Entries map[string]struct {
URL string `json:"url"`
Destination string `json:"destination"`
Bytes uint64 `json:"bytes"`
BLAKE3 string `json:"blake3"`
} `json:"entries"`
}
type fileSnapshot struct {
AppVersion string `json:"app_version"`
BundleVersion string `json:"bundle_version"`
ConnectionGroupName string `json:"connection_group_name"`
AddressablesRoot string `json:"addressables_root"`
LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata,omitempty"`
GameMainConfigBootstrap *GameMainConfigSummary `json:"game_main_config_bootstrap,omitempty"`
}
func relativePathFromURL(rawURL string) string {
const prefix = "https://"
if !strings.HasPrefix(rawURL, prefix) {
return ""
}
rest := strings.TrimPrefix(rawURL, prefix)
rest = strings.SplitN(rest, "?", 2)[0]
rest = strings.SplitN(rest, "#", 2)[0]
return filepath.ToSlash(rest)
}