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 Ready is copied from Rust's release.status // official_distribution_ready fact. The local manifest checks only establish // that this process has a usable read snapshot; they do not replace Rust's // release verifier. type DistributionHealth struct { Ready bool `json:"ready"` Source string `json:"source"` 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.Ready && localComplete } 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{ 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) }