mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
869 lines
34 KiB
Go
869 lines
34 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"bat-api/internal/backendrpc"
|
|
)
|
|
|
|
// Backend is the subset of daemon RPC used by bat-api.
|
|
//
|
|
// Call order for discovery:
|
|
// 1. daemon.status
|
|
// 2. daemon.doctor
|
|
// 3. release.attestation
|
|
// 4. catalog.status / bound resource.manifest (and resource.state as needed)
|
|
type Backend interface {
|
|
DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error)
|
|
DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error)
|
|
ResourceState(ctx context.Context) (*backendrpc.ResourceState, error)
|
|
CatalogStatus(ctx context.Context) (json.RawMessage, error)
|
|
ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error)
|
|
ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error)
|
|
}
|
|
|
|
// ControlBackend is the explicitly allowlisted mutation subset exposed through
|
|
// the authenticated bat-api admin control surface.
|
|
//
|
|
// It intentionally does not include daemon.stop, cleanup, or generic RPC calls.
|
|
// Restart is forwarded only to Rust's lifecycle RPC; Go never execs bat itself.
|
|
type ControlBackend interface {
|
|
DaemonRestart(ctx context.Context) (*backendrpc.Ack, error)
|
|
DaemonReload(ctx context.Context) (*backendrpc.Ack, error)
|
|
DaemonRefresh(ctx context.Context, force bool) (*backendrpc.Ack, error)
|
|
ResourceSync(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error)
|
|
ResourceVerify(ctx context.Context) (*backendrpc.TaskAccepted, error)
|
|
ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted, error)
|
|
CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error)
|
|
}
|
|
|
|
// DaemonLogsBackend exposes the Rust daemon log tail for an authenticated
|
|
// dashboard. It remains read-only and never opens log files from Go.
|
|
type DaemonLogsBackend interface {
|
|
DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error)
|
|
}
|
|
|
|
// TaskBackend exposes Rust-owned async task state to the dashboard. Go only
|
|
// forwards read/cancel requests and does not create generic tasks.
|
|
type TaskBackend interface {
|
|
TaskList(ctx context.Context) (*backendrpc.TaskList, error)
|
|
TaskStatus(ctx context.Context, taskID string) (*backendrpc.TaskRecord, error)
|
|
TaskLogs(ctx context.Context, taskID string) (*backendrpc.TaskLogs, error)
|
|
TaskCancel(ctx context.Context, taskID string) (*backendrpc.TaskCancelResult, error)
|
|
}
|
|
|
|
// ParseBackend exposes existing Rust TextUnit index queries to the dashboard.
|
|
// It is read-only and does not expand parser coverage.
|
|
type ParseBackend interface {
|
|
ParseStatus(ctx context.Context) (json.RawMessage, error)
|
|
ParseTextUnits(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error)
|
|
ParseErrors(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error)
|
|
}
|
|
|
|
// ScheduleBackend exposes the Rust-owned schedule store to an authenticated
|
|
// dashboard. The JSON result remains Rust's report shape so the API does not
|
|
// duplicate schedule state or invent a second schema.
|
|
type ScheduleBackend interface {
|
|
ScheduleList(ctx context.Context, params backendrpc.ScheduleListParams) (json.RawMessage, error)
|
|
ScheduleAdd(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error)
|
|
ScheduleUpdate(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error)
|
|
ScheduleRemove(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error)
|
|
ScheduleRun(ctx context.Context, params backendrpc.ScheduleRunParams) (json.RawMessage, error)
|
|
}
|
|
|
|
// TranslationBackend exposes the narrow provider-worker state mutation used
|
|
// by the dashboard. It does not create arbitrary translation jobs.
|
|
type TranslationBackend interface {
|
|
TranslationTaskUpdate(ctx context.Context, params backendrpc.TranslationTaskUpdateParams) (json.RawMessage, error)
|
|
TranslationTasks(ctx context.Context, params backendrpc.TranslationTaskListParams) (json.RawMessage, error)
|
|
TranslationHandoff(ctx context.Context) (json.RawMessage, error)
|
|
TranslationWorkerRun(ctx context.Context, params backendrpc.TranslationWorkerRunParams) (*backendrpc.TranslationWorkerRunResult, error)
|
|
TranslationProofread(ctx context.Context) (json.RawMessage, error)
|
|
}
|
|
|
|
// TranslationMemoryBackend exposes the Rust-owned Translation Memory query and
|
|
// explicit confirmation operations. Go forwards typed requests and responses
|
|
// but never opens or mutates the TM database itself.
|
|
type TranslationMemoryBackend interface {
|
|
TranslationMemorySummary(ctx context.Context, params backendrpc.TranslationMemorySummaryParams) (*backendrpc.TranslationMemorySummaryReport, error)
|
|
TranslationMemoryQuery(ctx context.Context, params backendrpc.TranslationMemoryQueryParams) (*backendrpc.TranslationMemoryQueryReport, error)
|
|
TranslationMemoryConfirm(ctx context.Context, params backendrpc.TranslationMemoryConfirmParams) (*backendrpc.TranslationMemoryConfirmReport, error)
|
|
}
|
|
|
|
// GlossaryBackend exposes Rust-owned Glossary management and diagnostics.
|
|
// Go forwards these typed calls and never opens the Glossary database.
|
|
type GlossaryBackend interface {
|
|
GlossarySummary(ctx context.Context, params backendrpc.GlossarySummaryParams) (*backendrpc.GlossarySummaryReport, error)
|
|
GlossaryQuery(ctx context.Context, params backendrpc.GlossaryQueryParams) (*backendrpc.GlossaryQueryReport, error)
|
|
GlossaryDiagnose(ctx context.Context, params backendrpc.GlossaryDiagnoseParams) (*backendrpc.GlossaryDiagnoseReport, error)
|
|
GlossaryAdd(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error)
|
|
GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error)
|
|
GlossaryApprove(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error)
|
|
GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error)
|
|
GlossaryDelete(ctx context.Context, params backendrpc.GlossaryDeleteParams) (*backendrpc.GlossaryMutationReport, error)
|
|
}
|
|
|
|
// LocalizedBackend exposes localized release status and the explicit
|
|
// publish/rollback controls used by the authenticated dashboard.
|
|
type LocalizedBackend interface {
|
|
LocalizedStatus(ctx context.Context) (json.RawMessage, error)
|
|
LocalizedPublish(ctx context.Context, params backendrpc.LocalizedPublishParams) (json.RawMessage, error)
|
|
LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error)
|
|
}
|
|
|
|
// ReleaseStatusBackend exposes the Rust-owned release health fact used during
|
|
// discovery. It is kept separate so lightweight test/diagnostic backends do
|
|
// not have to implement the administrative release surface.
|
|
type ReleaseStatusBackend interface {
|
|
ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error)
|
|
}
|
|
|
|
// ReleaseBackend exposes Rust-owned dual-release queries, distribution
|
|
// selection and the explicit cleanup operation.
|
|
type ReleaseBackend interface {
|
|
ReleaseStatusBackend
|
|
ReleaseList(ctx context.Context, params backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error)
|
|
ReleaseDistribution(ctx context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error)
|
|
ReleaseCleanup(ctx context.Context, params backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error)
|
|
}
|
|
|
|
// RPCClient adapts *backendrpc.Client to Backend.
|
|
type RPCClient struct {
|
|
Client *backendrpc.Client
|
|
}
|
|
|
|
func (r RPCClient) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) {
|
|
return r.Client.DaemonStatus(ctx)
|
|
}
|
|
func (r RPCClient) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) {
|
|
return r.Client.DaemonDoctor(ctx)
|
|
}
|
|
func (r RPCClient) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) {
|
|
return r.Client.ResourceState(ctx)
|
|
}
|
|
func (r RPCClient) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
|
return r.Client.CatalogStatus(ctx)
|
|
}
|
|
func (r RPCClient) ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) {
|
|
return r.Client.ResourceManifest(ctx, params)
|
|
}
|
|
func (r RPCClient) ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error) {
|
|
return r.Client.ReleaseAttestation(ctx)
|
|
}
|
|
func (r RPCClient) DaemonRestart(ctx context.Context) (*backendrpc.Ack, error) {
|
|
return r.Client.DaemonRestart(ctx)
|
|
}
|
|
func (r RPCClient) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) {
|
|
return r.Client.DaemonReload(ctx)
|
|
}
|
|
func (r RPCClient) DaemonRefresh(ctx context.Context, force bool) (*backendrpc.Ack, error) {
|
|
return r.Client.DaemonRefresh(ctx, force)
|
|
}
|
|
func (r RPCClient) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) {
|
|
return r.Client.DaemonLogs(ctx, tail)
|
|
}
|
|
func (r RPCClient) ResourceSync(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
|
|
return r.Client.ResourceSync(ctx, force)
|
|
}
|
|
func (r RPCClient) ResourceVerify(ctx context.Context) (*backendrpc.TaskAccepted, error) {
|
|
return r.Client.ResourceVerify(ctx)
|
|
}
|
|
func (r RPCClient) ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted, error) {
|
|
return r.Client.ResourceRepair(ctx)
|
|
}
|
|
func (r RPCClient) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
|
|
return r.Client.CatalogRefresh(ctx, force)
|
|
}
|
|
func (r RPCClient) TaskList(ctx context.Context) (*backendrpc.TaskList, error) {
|
|
return r.Client.TaskList(ctx)
|
|
}
|
|
func (r RPCClient) TaskStatus(ctx context.Context, taskID string) (*backendrpc.TaskRecord, error) {
|
|
return r.Client.TaskStatus(ctx, taskID)
|
|
}
|
|
func (r RPCClient) TaskLogs(ctx context.Context, taskID string) (*backendrpc.TaskLogs, error) {
|
|
return r.Client.TaskLogs(ctx, taskID)
|
|
}
|
|
func (r RPCClient) TaskCancel(ctx context.Context, taskID string) (*backendrpc.TaskCancelResult, error) {
|
|
return r.Client.TaskCancel(ctx, taskID)
|
|
}
|
|
func (r RPCClient) ScheduleList(ctx context.Context, params backendrpc.ScheduleListParams) (json.RawMessage, error) {
|
|
return r.Client.ScheduleListFiltered(ctx, params)
|
|
}
|
|
func (r RPCClient) ScheduleAdd(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) {
|
|
return r.Client.ScheduleAdd(ctx, params)
|
|
}
|
|
func (r RPCClient) ScheduleUpdate(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) {
|
|
return r.Client.ScheduleUpdate(ctx, params)
|
|
}
|
|
func (r RPCClient) ScheduleRemove(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) {
|
|
return r.Client.ScheduleRemove(ctx, params)
|
|
}
|
|
func (r RPCClient) ScheduleRun(ctx context.Context, params backendrpc.ScheduleRunParams) (json.RawMessage, error) {
|
|
return r.Client.ScheduleRun(ctx, params)
|
|
}
|
|
func (r RPCClient) TranslationTaskUpdate(ctx context.Context, params backendrpc.TranslationTaskUpdateParams) (json.RawMessage, error) {
|
|
return r.Client.TranslationTaskUpdate(ctx, params)
|
|
}
|
|
|
|
func (r RPCClient) TranslationTasks(ctx context.Context, params backendrpc.TranslationTaskListParams) (json.RawMessage, error) {
|
|
return r.Client.TranslationTasks(ctx, params)
|
|
}
|
|
|
|
func (r RPCClient) TranslationHandoff(ctx context.Context) (json.RawMessage, error) {
|
|
return r.Client.TranslationHandoff(ctx)
|
|
}
|
|
|
|
func (r RPCClient) TranslationWorkerRun(ctx context.Context, params backendrpc.TranslationWorkerRunParams) (*backendrpc.TranslationWorkerRunResult, error) {
|
|
return r.Client.TranslationWorkerRun(ctx, params)
|
|
}
|
|
|
|
func (r RPCClient) TranslationProofread(ctx context.Context) (json.RawMessage, error) {
|
|
return r.Client.TranslationProofread(ctx)
|
|
}
|
|
|
|
func (r RPCClient) TranslationMemorySummary(ctx context.Context, params backendrpc.TranslationMemorySummaryParams) (*backendrpc.TranslationMemorySummaryReport, error) {
|
|
return r.Client.TranslationMemorySummary(ctx, params)
|
|
}
|
|
|
|
func (r RPCClient) TranslationMemoryQuery(ctx context.Context, params backendrpc.TranslationMemoryQueryParams) (*backendrpc.TranslationMemoryQueryReport, error) {
|
|
return r.Client.TranslationMemoryQuery(ctx, params)
|
|
}
|
|
|
|
func (r RPCClient) TranslationMemoryConfirm(ctx context.Context, params backendrpc.TranslationMemoryConfirmParams) (*backendrpc.TranslationMemoryConfirmReport, error) {
|
|
return r.Client.TranslationMemoryConfirm(ctx, params)
|
|
}
|
|
|
|
func (r RPCClient) GlossarySummary(ctx context.Context, params backendrpc.GlossarySummaryParams) (*backendrpc.GlossarySummaryReport, error) {
|
|
return r.Client.GlossarySummary(ctx, params)
|
|
}
|
|
func (r RPCClient) GlossaryQuery(ctx context.Context, params backendrpc.GlossaryQueryParams) (*backendrpc.GlossaryQueryReport, error) {
|
|
return r.Client.GlossaryQuery(ctx, params)
|
|
}
|
|
func (r RPCClient) GlossaryDiagnose(ctx context.Context, params backendrpc.GlossaryDiagnoseParams) (*backendrpc.GlossaryDiagnoseReport, error) {
|
|
return r.Client.GlossaryDiagnose(ctx, params)
|
|
}
|
|
func (r RPCClient) GlossaryAdd(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) {
|
|
return r.Client.GlossaryAdd(ctx, params)
|
|
}
|
|
func (r RPCClient) GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) {
|
|
return r.Client.GlossaryUpdate(ctx, params)
|
|
}
|
|
func (r RPCClient) GlossaryApprove(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) {
|
|
return r.Client.GlossaryApprove(ctx, params)
|
|
}
|
|
func (r RPCClient) GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) {
|
|
return r.Client.GlossaryDeprecate(ctx, params)
|
|
}
|
|
func (r RPCClient) GlossaryDelete(ctx context.Context, params backendrpc.GlossaryDeleteParams) (*backendrpc.GlossaryMutationReport, error) {
|
|
return r.Client.GlossaryDelete(ctx, params)
|
|
}
|
|
|
|
func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
|
|
return r.Client.LocalizedStatus(ctx)
|
|
}
|
|
|
|
func (r RPCClient) LocalizedPublish(ctx context.Context, params backendrpc.LocalizedPublishParams) (json.RawMessage, error) {
|
|
return r.Client.LocalizedPublish(ctx, params)
|
|
}
|
|
|
|
func (r RPCClient) LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error) {
|
|
return r.Client.LocalizedRollback(ctx, params)
|
|
}
|
|
|
|
func (r RPCClient) ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error) {
|
|
return r.Client.ReleaseStatus(ctx)
|
|
}
|
|
|
|
func (r RPCClient) ReleaseList(ctx context.Context, params backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error) {
|
|
return r.Client.ReleaseList(ctx, params)
|
|
}
|
|
|
|
func (r RPCClient) ReleaseDistribution(ctx context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
|
|
return r.Client.ReleaseDistribution(ctx, params)
|
|
}
|
|
|
|
func (r RPCClient) ReleaseCleanup(ctx context.Context, params backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error) {
|
|
return r.Client.ReleaseCleanup(ctx, params)
|
|
}
|
|
|
|
func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) {
|
|
return r.Client.ParseStatus(ctx)
|
|
}
|
|
func (r RPCClient) ParseTextUnits(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) {
|
|
return r.Client.ParseTextUnits(ctx, query)
|
|
}
|
|
func (r RPCClient) ParseErrors(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) {
|
|
return r.Client.ParseErrors(ctx, query)
|
|
}
|
|
func (r RPCClient) UnityFSPatchTextAsset(ctx context.Context, params backendrpc.UnityFSTextAssetPatchParams) (json.RawMessage, error) {
|
|
return r.Client.UnityFSPatchTextAsset(ctx, params)
|
|
}
|
|
func (r RPCClient) UnityFSPatchStringField(ctx context.Context, params backendrpc.UnityFSStringFieldPatchParams) (json.RawMessage, error) {
|
|
return r.Client.UnityFSPatchStringField(ctx, params)
|
|
}
|
|
func (r RPCClient) UnityFSPatchField(ctx context.Context, params backendrpc.UnityFSFieldPatchParams) (json.RawMessage, error) {
|
|
return r.Client.UnityFSPatchField(ctx, params)
|
|
}
|
|
|
|
// DiscoverResult is the outcome of talking to the bat daemon.
|
|
type DiscoverResult struct {
|
|
RPCAvailable bool
|
|
DoctorHealthy *bool
|
|
Status *backendrpc.DaemonStatusReport
|
|
Doctor *backendrpc.DoctorReport
|
|
Attestation *backendrpc.DistributionAttestation
|
|
ReleaseStatus *backendrpc.ReleaseStatusReport
|
|
Distribution DistributionHealth
|
|
Snapshot *SnapshotSummary
|
|
ResourceRoot string
|
|
Index *ReleaseIndex
|
|
Warnings []string
|
|
}
|
|
|
|
// DiscoverAndIndex contacts the daemon (status first, then doctor, then the
|
|
// lightweight current-release attestation) and builds a release index from
|
|
// pages bound to that attestation plus on-disk checks. Rust's attestation is
|
|
// the only release-level integrity authorization used for the production RPC
|
|
// path; release.status remains an administrative diagnostic.
|
|
//
|
|
// If resourceRootOverride is non-empty, it wins over RPC-reported roots after
|
|
// RPC health probes (still preferred for production to call status/doctor).
|
|
func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride string) (*DiscoverResult, error) {
|
|
out := &DiscoverResult{}
|
|
if backend == nil {
|
|
if resourceRootOverride == "" {
|
|
out.Index = &ReleaseIndex{Source: "empty", byRel: map[string]int{}}
|
|
return out, nil
|
|
}
|
|
idx, err := LoadIndexFromResourceRoot(resourceRootOverride)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.ResourceRoot = idx.ResourceRoot
|
|
out.Distribution = idx.Distribution
|
|
out.Index = idx
|
|
return out, nil
|
|
}
|
|
|
|
// 1) daemon.status first
|
|
status, err := backend.DaemonStatus(ctx)
|
|
if err != nil {
|
|
out.Warnings = append(out.Warnings, fmt.Sprintf("daemon.status: %v", err))
|
|
if resourceRootOverride != "" {
|
|
idx, loadErr := LoadIndexFromResourceRoot(resourceRootOverride)
|
|
if loadErr != nil {
|
|
return out, fmt.Errorf("daemon.status failed (%v) and resource-root load failed: %w", err, loadErr)
|
|
}
|
|
out.ResourceRoot = idx.ResourceRoot
|
|
out.Distribution = idx.Distribution
|
|
out.Index = idx
|
|
return out, nil
|
|
}
|
|
out.Index = &ReleaseIndex{Source: "empty", byRel: map[string]int{}}
|
|
return out, nil
|
|
}
|
|
out.RPCAvailable = true
|
|
out.Status = status
|
|
|
|
// 2) daemon.doctor second
|
|
doctor, err := backend.DaemonDoctor(ctx)
|
|
if err != nil {
|
|
out.Warnings = append(out.Warnings, fmt.Sprintf("daemon.doctor: %v", err))
|
|
} else {
|
|
out.Doctor = doctor
|
|
h := doctor.Healthy
|
|
out.DoctorHealthy = &h
|
|
}
|
|
|
|
// An explicit root is a fixture/emergency read-only override. Keep it
|
|
// outside the production RPC release-health contract, while still probing
|
|
// daemon status and doctor first.
|
|
if resourceRootOverride != "" {
|
|
idx, loadErr := LoadIndexFromResourceRoot(resourceRootOverride)
|
|
if loadErr != nil {
|
|
return out, fmt.Errorf("resource-root override load failed: %w", loadErr)
|
|
}
|
|
idx.Source = "resource_root"
|
|
idx.RPCAvailable = true
|
|
idx.DoctorHealthy = out.DoctorHealthy
|
|
out.ResourceRoot = idx.ResourceRoot
|
|
out.Snapshot = idx.Snapshot
|
|
out.Distribution = idx.Distribution
|
|
out.Index = idx
|
|
return out, nil
|
|
}
|
|
|
|
// 3) release.attestation is the Rust-owned current-release distribution gate.
|
|
attestationBackend, ok := backend.(AttestationBackend)
|
|
if !ok {
|
|
out.Warnings = append(out.Warnings, "release.attestation: backend does not expose Rust health proof")
|
|
return emptyRPCResult(out, nil, "Rust release health is unavailable"),
|
|
fmt.Errorf("rust release health is unavailable")
|
|
}
|
|
attestation, err := attestationBackend.ReleaseAttestation(ctx)
|
|
if err != nil {
|
|
out.Warnings = append(out.Warnings, fmt.Sprintf("release.attestation: %v", err))
|
|
return emptyRPCResult(out, nil, "Rust release health query failed"),
|
|
fmt.Errorf("release.attestation failed: %w", err)
|
|
}
|
|
if attestation == nil {
|
|
out.Warnings = append(out.Warnings, "release.attestation: empty response")
|
|
return emptyRPCResult(out, nil, "Rust release health query returned no response"),
|
|
fmt.Errorf("release.attestation returned an empty response")
|
|
}
|
|
out.Attestation = attestation
|
|
out.Distribution = rustAttestationHealth(attestation)
|
|
if !attestation.Available ||
|
|
!attestation.Ready ||
|
|
attestation.Channel != "official" ||
|
|
attestation.IntegrityStatus != "verified" ||
|
|
attestation.VerificationGeneration == 0 ||
|
|
!attestationIsFresh(attestation) {
|
|
return emptyRPCResult(
|
|
out,
|
|
nil,
|
|
"Rust current official distribution attestation is unavailable or not ready",
|
|
), nil
|
|
}
|
|
|
|
// Catalog / resource discovery
|
|
var snapshot *SnapshotSummary
|
|
resourceRoot := attestation.ResourceRoot
|
|
catalogAvailabilityKnown := false
|
|
catalogAvailable := false
|
|
|
|
if raw, err := backend.CatalogStatus(ctx); err != nil {
|
|
out.Warnings = append(out.Warnings, fmt.Sprintf("catalog.status: %v", err))
|
|
} else {
|
|
catalogAvailable, catalogAvailabilityKnown = parseCatalogAvailability(raw)
|
|
snap, root, ok := parseCatalogStatus(raw)
|
|
if ok {
|
|
snapshot = snap
|
|
resourceRoot = root
|
|
}
|
|
}
|
|
if snapshot != nil &&
|
|
snapshot.VersionID != "" &&
|
|
attestation.ReleaseID != "" &&
|
|
snapshot.VersionID != attestation.ReleaseID {
|
|
return emptyRPCResult(
|
|
out,
|
|
snapshot,
|
|
fmt.Sprintf(
|
|
"release.attestation current ID %q does not match catalog current ID %q",
|
|
attestation.ReleaseID,
|
|
snapshot.VersionID,
|
|
),
|
|
), nil
|
|
}
|
|
if resourceRoot != "" &&
|
|
attestation.ResourceRoot != "" &&
|
|
resourceRoot != attestation.ResourceRoot {
|
|
return emptyRPCResult(
|
|
out,
|
|
snapshot,
|
|
fmt.Sprintf(
|
|
"catalog current root %q does not match attestation root %q",
|
|
resourceRoot,
|
|
attestation.ResourceRoot,
|
|
),
|
|
), nil
|
|
}
|
|
if catalogAvailabilityKnown && !catalogAvailable {
|
|
return emptyRPCResult(out, snapshot, "catalog.status available=false; no published release"), nil
|
|
}
|
|
|
|
if resourceRoot == "" {
|
|
if state, err := backend.ResourceState(ctx); err != nil {
|
|
out.Warnings = append(out.Warnings, fmt.Sprintf("resource.state: %v", err))
|
|
} else if state.ResourceOutputRoot != nil && *state.ResourceOutputRoot != "" {
|
|
// Prefer published current under output root when catalog root missing.
|
|
candidate := filepath.Join(*state.ResourceOutputRoot, "current")
|
|
resourceRoot = candidate
|
|
}
|
|
}
|
|
|
|
if resourceRootOverride != "" {
|
|
resourceRoot = resourceRootOverride
|
|
}
|
|
if resourceRoot == "" {
|
|
out.Snapshot = snapshotWithDistributionHealth(snapshot, out.Distribution)
|
|
out.Index = &ReleaseIndex{
|
|
Source: "rpc",
|
|
RPCAvailable: true,
|
|
DoctorHealthy: out.DoctorHealthy,
|
|
Distribution: out.Distribution,
|
|
Snapshot: snapshotWithDistributionHealth(snapshot, out.Distribution),
|
|
byRel: map[string]int{},
|
|
}
|
|
out.Warnings = append(out.Warnings, "no resource root from RPC; set --resource-root or publish a version")
|
|
return out, nil
|
|
}
|
|
|
|
if resourceRootOverride == "" {
|
|
info, statErr := os.Stat(resourceRoot)
|
|
if statErr != nil || !info.IsDir() {
|
|
warning := fmt.Sprintf("published resource root is unavailable: %s", resourceRoot)
|
|
if statErr != nil {
|
|
warning = fmt.Sprintf("%s (%v)", warning, statErr)
|
|
} else {
|
|
warning = fmt.Sprintf("%s (not a directory)", warning)
|
|
}
|
|
return emptyRPCResult(out, snapshot, warning), nil
|
|
}
|
|
}
|
|
|
|
entries, manifestVersion, rootFromManifest, err := fetchAllManifestEntries(ctx, backend, attestation)
|
|
if err != nil {
|
|
out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err))
|
|
// Without the RPC manifest there is no evidence that the local
|
|
// snapshot matches the Rust health fact. Do not pair a fresh health
|
|
// result with a potentially stale on-disk manifest.
|
|
return emptyRPCResult(
|
|
out,
|
|
snapshot,
|
|
fmt.Sprintf("published release cannot be indexed: %v", err),
|
|
), nil
|
|
}
|
|
if rootFromManifest != "" {
|
|
if attestation.ResourceRoot != "" && rootFromManifest != attestation.ResourceRoot {
|
|
return emptyRPCResult(
|
|
out,
|
|
snapshot,
|
|
fmt.Sprintf(
|
|
"resource.manifest resource root %q does not match attestation root %q",
|
|
rootFromManifest,
|
|
attestation.ResourceRoot,
|
|
),
|
|
), nil
|
|
}
|
|
resourceRoot = rootFromManifest
|
|
}
|
|
if resourceRootOverride != "" {
|
|
resourceRoot = resourceRootOverride
|
|
}
|
|
|
|
idx, err := BuildIndexFromManifestEntries(
|
|
resourceRoot,
|
|
"rpc",
|
|
true,
|
|
out.DoctorHealthy,
|
|
snapshot,
|
|
manifestVersion,
|
|
entries,
|
|
out.Distribution,
|
|
)
|
|
if err != nil {
|
|
if resourceRootOverride == "" {
|
|
return emptyRPCResult(
|
|
out,
|
|
snapshot,
|
|
fmt.Sprintf("published release failed local validation: %v", err),
|
|
), nil
|
|
}
|
|
return out, err
|
|
}
|
|
out.ResourceRoot = idx.ResourceRoot
|
|
out.Snapshot = idx.Snapshot
|
|
out.Index = idx
|
|
return out, nil
|
|
}
|
|
|
|
// AttestationBackend exposes the lightweight current official health proof.
|
|
// It is intentionally separate from ReleaseStatusBackend because health
|
|
// refreshes must not require the historical release scan.
|
|
type AttestationBackend interface {
|
|
ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error)
|
|
}
|
|
|
|
func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning string) *DiscoverResult {
|
|
distribution := out.Distribution
|
|
if distribution.Source == "" {
|
|
distribution = unavailableRustDistributionHealth()
|
|
out.Distribution = distribution
|
|
}
|
|
snapshot = snapshotWithDistributionHealth(snapshot, distribution)
|
|
out.ResourceRoot = ""
|
|
out.Snapshot = snapshot
|
|
out.Index = &ReleaseIndex{
|
|
Source: "rpc",
|
|
RPCAvailable: true,
|
|
DoctorHealthy: out.DoctorHealthy,
|
|
Distribution: distribution,
|
|
Snapshot: snapshot,
|
|
byRel: map[string]int{},
|
|
}
|
|
if warning != "" {
|
|
out.Warnings = append(out.Warnings, warning)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func rustAttestationHealth(report *backendrpc.DistributionAttestation) DistributionHealth {
|
|
health := DistributionHealth{
|
|
Source: "rust_release_attestation",
|
|
Status: "unavailable",
|
|
StatusCode: "distribution.attestation_unavailable",
|
|
IntegrityStatus: "unavailable",
|
|
}
|
|
if report == nil {
|
|
return health
|
|
}
|
|
health.Available = report.Available
|
|
health.Ready = report.Ready
|
|
health.Channel = report.Channel
|
|
health.ReleaseID = report.ReleaseID
|
|
health.ResourceRoot = report.ResourceRoot
|
|
health.PublicationIdentity = report.PublicationIdentity
|
|
health.MappingIdentity = report.MappingIdentity
|
|
health.ManifestIdentity = report.ManifestIdentity
|
|
health.EntryCount = report.EntryCount
|
|
health.VerificationGeneration = report.VerificationGeneration
|
|
health.VerifiedAt = report.VerifiedAt
|
|
health.MaxAgeSeconds = report.MaxAgeSeconds
|
|
health.Status = report.Status
|
|
health.StatusCode = report.StatusCode
|
|
health.IntegrityStatus = report.IntegrityStatus
|
|
health.Diagnostics = append([]string(nil), report.Diagnostics...)
|
|
if health.Status == "" {
|
|
health.Status = "unavailable"
|
|
}
|
|
if health.StatusCode == "" {
|
|
health.StatusCode = "distribution.attestation_unavailable"
|
|
}
|
|
if health.IntegrityStatus == "" {
|
|
health.IntegrityStatus = "unavailable"
|
|
}
|
|
return health
|
|
}
|
|
|
|
func unavailableRustDistributionHealth() DistributionHealth {
|
|
return DistributionHealth{
|
|
Source: "rust_release_attestation",
|
|
Status: "unavailable",
|
|
StatusCode: "distribution.attestation_unavailable",
|
|
IntegrityStatus: "unavailable",
|
|
}
|
|
}
|
|
|
|
func attestationIsFresh(attestation *backendrpc.DistributionAttestation) bool {
|
|
if attestation == nil || attestation.MaxAgeSeconds == 0 || attestation.VerifiedAt == nil {
|
|
return false
|
|
}
|
|
now := uint64(time.Now().Unix())
|
|
age := uint64(0)
|
|
if now > *attestation.VerifiedAt {
|
|
age = now - *attestation.VerifiedAt
|
|
}
|
|
return age <= attestation.MaxAgeSeconds
|
|
}
|
|
|
|
func snapshotWithDistributionHealth(snapshot *SnapshotSummary, health DistributionHealth) *SnapshotSummary {
|
|
if snapshot == nil {
|
|
return nil
|
|
}
|
|
updated := *snapshot
|
|
if health.Status != "" {
|
|
updated.DistributionStatus = health.Status
|
|
}
|
|
if health.StatusCode != "" {
|
|
updated.DistributionStatusCode = health.StatusCode
|
|
}
|
|
return &updated
|
|
}
|
|
|
|
func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) {
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return nil, "", false
|
|
}
|
|
var payload struct {
|
|
Available bool `json:"available"`
|
|
Status string `json:"status"`
|
|
StatusCode string `json:"status_code"`
|
|
DistributionStatus string `json:"distribution_status"`
|
|
DistributionStatusCode string `json:"distribution_status_code"`
|
|
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"`
|
|
GameMainConfigBootstrap *GameMainConfigSummary `json:"game_main_config_bootstrap"`
|
|
Version *struct {
|
|
ID string `json:"id"`
|
|
CompletedUnixSeconds *uint64 `json:"completed_unix_seconds"`
|
|
ResourceRoot string `json:"resource_root"`
|
|
} `json:"version"`
|
|
}
|
|
if err := json.Unmarshal(raw, &payload); err != nil || !payload.Available {
|
|
return nil, "", false
|
|
}
|
|
snap := &SnapshotSummary{
|
|
Status: payload.Status,
|
|
StatusCode: payload.StatusCode,
|
|
DistributionStatus: payload.DistributionStatus,
|
|
DistributionStatusCode: payload.DistributionStatusCode,
|
|
AppVersion: payload.AppVersion,
|
|
BundleVersion: payload.BundleVersion,
|
|
ConnectionGroupName: payload.ConnectionGroupName,
|
|
AddressablesRoot: payload.AddressablesRoot,
|
|
LauncherMetadata: payload.LauncherMetadata,
|
|
GameMainConfig: payload.GameMainConfigBootstrap,
|
|
}
|
|
root := ""
|
|
if payload.Version != nil {
|
|
snap.VersionID = payload.Version.ID
|
|
snap.CompletedUnixSeconds = payload.Version.CompletedUnixSeconds
|
|
root = payload.Version.ResourceRoot
|
|
}
|
|
return snap, root, true
|
|
}
|
|
|
|
func parseCatalogAvailability(raw json.RawMessage) (bool, bool) {
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return false, false
|
|
}
|
|
var payload struct {
|
|
Available *bool `json:"available"`
|
|
}
|
|
if err := json.Unmarshal(raw, &payload); err != nil || payload.Available == nil {
|
|
return false, false
|
|
}
|
|
return *payload.Available, true
|
|
}
|
|
|
|
func fetchAllManifestEntries(
|
|
ctx context.Context,
|
|
backend Backend,
|
|
attestation *backendrpc.DistributionAttestation,
|
|
) ([]manifestEntry, int, string, error) {
|
|
return fetchAllManifestEntriesWithPageSize(ctx, backend, attestation, 500)
|
|
}
|
|
|
|
func fetchAllManifestEntriesWithPageSize(
|
|
ctx context.Context,
|
|
backend Backend,
|
|
attestation *backendrpc.DistributionAttestation,
|
|
pageSize int,
|
|
) ([]manifestEntry, int, string, error) {
|
|
if pageSize <= 0 {
|
|
return nil, 0, "", fmt.Errorf("manifest page size must be positive")
|
|
}
|
|
if attestation == nil {
|
|
return nil, 0, "", fmt.Errorf("resource.manifest requires a Rust attestation")
|
|
}
|
|
if !attestation.Available ||
|
|
!attestation.Ready ||
|
|
attestation.Channel != "official" ||
|
|
attestation.IntegrityStatus != "verified" ||
|
|
attestation.VerificationGeneration == 0 ||
|
|
!attestationIsFresh(attestation) {
|
|
return nil, 0, "", fmt.Errorf("resource.manifest attestation is not ready")
|
|
}
|
|
offset := 0
|
|
var all []manifestEntry
|
|
var version, limit int
|
|
var channel, root, releaseID, publicationIdentity, mappingIdentity, manifestIdentity string
|
|
var generation uint64
|
|
total := -1
|
|
for {
|
|
page, err := backend.ResourceManifest(ctx, backendrpc.ResourceManifestParams{
|
|
ReleaseID: attestation.ReleaseID,
|
|
ExpectedPublicationIdentity: attestation.PublicationIdentity,
|
|
ExpectedManifestIdentity: attestation.ManifestIdentity,
|
|
ExpectedVerificationGeneration: attestation.VerificationGeneration,
|
|
Offset: offset,
|
|
Limit: pageSize,
|
|
})
|
|
if err != nil {
|
|
return nil, 0, "", err
|
|
}
|
|
if page == nil {
|
|
return nil, 0, "", fmt.Errorf("resource.manifest returned nil page")
|
|
}
|
|
if !page.Available {
|
|
return nil, 0, "", fmt.Errorf("resource.manifest available=false")
|
|
}
|
|
if root == "" {
|
|
root = page.ResourceRoot
|
|
channel = page.Channel
|
|
releaseID = page.ReleaseID
|
|
publicationIdentity = page.PublicationIdentity
|
|
mappingIdentity = page.MappingIdentity
|
|
manifestIdentity = page.ManifestIdentity
|
|
generation = page.Generation
|
|
version = page.ManifestVersion
|
|
total = page.TotalEntries
|
|
limit = page.Limit
|
|
} else if page.ResourceRoot != root ||
|
|
page.Channel != channel ||
|
|
page.ReleaseID != releaseID ||
|
|
page.PublicationIdentity != publicationIdentity ||
|
|
page.MappingIdentity != mappingIdentity ||
|
|
page.ManifestIdentity != manifestIdentity ||
|
|
page.Generation != generation ||
|
|
page.ManifestVersion != version ||
|
|
page.TotalEntries != total ||
|
|
page.Limit != limit {
|
|
return nil, 0, "", fmt.Errorf("resource.manifest page identity or total changed")
|
|
}
|
|
if page.Offset != offset {
|
|
return nil, 0, "", fmt.Errorf(
|
|
"resource.manifest page offset mismatch: requested=%d actual=%d",
|
|
offset,
|
|
page.Offset,
|
|
)
|
|
}
|
|
if page.Limit != pageSize {
|
|
return nil, 0, "", fmt.Errorf("resource.manifest page limit is unreasonable: %d", page.Limit)
|
|
}
|
|
if page.TotalEntries < 0 || len(page.Entries) > page.Limit {
|
|
return nil, 0, "", fmt.Errorf("resource.manifest page entry count is unreasonable")
|
|
}
|
|
if total < 0 || offset > total || offset+len(page.Entries) > total {
|
|
return nil, 0, "", fmt.Errorf("resource.manifest page exceeds declared total")
|
|
}
|
|
if channel != attestation.Channel ||
|
|
releaseID != attestation.ReleaseID ||
|
|
root != attestation.ResourceRoot ||
|
|
publicationIdentity != attestation.PublicationIdentity ||
|
|
mappingIdentity != attestation.MappingIdentity ||
|
|
manifestIdentity != attestation.ManifestIdentity ||
|
|
generation != attestation.VerificationGeneration ||
|
|
total != attestation.EntryCount {
|
|
return nil, 0, "", fmt.Errorf("resource.manifest page does not match attestation")
|
|
}
|
|
for _, e := range page.Entries {
|
|
var bytes uint64
|
|
if e.Bytes != nil {
|
|
bytes = *e.Bytes
|
|
}
|
|
all = append(all, manifestEntry{
|
|
URL: e.URL,
|
|
Destination: e.Destination,
|
|
Bytes: bytes,
|
|
BLAKE3: e.BLAKE3,
|
|
})
|
|
}
|
|
offset += len(page.Entries)
|
|
if offset == total {
|
|
break
|
|
}
|
|
if len(page.Entries) == 0 || len(page.Entries) < page.Limit {
|
|
return nil, 0, "", fmt.Errorf("resource.manifest page has a gap before total")
|
|
}
|
|
}
|
|
if len(all) != total {
|
|
return nil, 0, "", fmt.Errorf(
|
|
"resource.manifest final entry count mismatch: entries=%d total=%d",
|
|
len(all),
|
|
total,
|
|
)
|
|
}
|
|
return all, version, root, nil
|
|
}
|