fix(bat-api): 完成 issue #19 同机 live 联调
bat-rust / Build and test Go API (push) Canceled after 0s
bat-rust / Build and test Rust (push) Canceled after 0s

This commit is contained in:
2026-08-29 22:58:01 +08:00
parent 90083302a2
commit 7f465523e1
22 changed files with 819 additions and 115 deletions
+144
View File
@@ -66,6 +66,29 @@ func TestLoadIndexFromResourceRoot(t *testing.T) {
}
}
func TestReleaseSummaryRequiresCompleteManifest(t *testing.T) {
idx := &ReleaseIndex{
ResourceRoot: "/tmp/release",
Entries: []ResourceEntry{
{RelativePath: "host/ready.bin", Present: true, SizeMatch: true},
{RelativePath: "host/missing.bin", Present: false, SizeMatch: false},
},
MissingOnDisk: []string{"host/missing.bin"},
byRel: map[string]int{},
}
summary := idx.Summary()
if summary.PresentCount != 1 || summary.MissingCount != 1 || summary.Ready {
t.Fatalf("partial release summary=%+v", summary)
}
idx.Entries[1] = ResourceEntry{RelativePath: "host/missing.bin", Present: true, SizeMatch: false}
idx.MissingOnDisk = []string{"host/missing.bin#size_mismatch"}
summary = idx.Summary()
if summary.Ready {
t.Fatalf("size-mismatched release summary=%+v", summary)
}
}
func TestSplitCDNPathRejectsEscape(t *testing.T) {
if _, _, err := SplitCDNPath("/prod-clientpatch.bluearchiveyostar.com/../etc/passwd"); err == nil {
t.Fatal("expected error")
@@ -124,6 +147,17 @@ func TestCDNServesIndexedFile(t *testing.T) {
if rr.Code != http.StatusNotFound {
t.Fatalf("missing status=%d", rr.Code)
}
req = httptest.NewRequest(
http.MethodGet,
"/prod-clientpatch.bluearchiveyostar.com/%2e%2e/yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json",
nil,
)
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("dot-segment status=%d body=%s", rr.Code, rr.Body.String())
}
}
func TestCDNSupportsRangeHeadAndConditionalRequests(t *testing.T) {
@@ -524,6 +558,7 @@ type fakeBackend struct {
status *backendrpc.DaemonStatusReport
doctor *backendrpc.DoctorReport
catalog json.RawMessage
resource *backendrpc.ResourceState
manifest *backendrpc.ResourceManifestPage
}
@@ -539,6 +574,9 @@ func (f *fakeBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorRepor
return f.doctor, nil
}
func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) {
if f.resource != nil {
return f.resource, nil
}
return &backendrpc.ResourceState{}, nil
}
func (f *fakeBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
@@ -780,6 +818,112 @@ func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) {
}
}
func TestRefreshClearsPublishedIndexWhenCurrentReleaseDisappears(t *testing.T) {
root := fixtureRoot(t)
info, err := os.Stat(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes"))
if err != nil {
t.Fatal(err)
}
size := uint64(info.Size())
catalogRaw, err := json.Marshal(map[string]any{
"available": true,
"status": "published",
"version": map[string]any{
"id": "v1",
"resource_root": root,
},
})
if err != nil {
t.Fatal(err)
}
backend := &fakeBackend{
status: &backendrpc.DaemonStatusReport{Status: "ok", Running: true, RPCAvailable: true},
doctor: &backendrpc.DoctorReport{Healthy: true, Status: "ok"},
catalog: catalogRaw,
manifest: &backendrpc.ResourceManifestPage{
Available: true,
ResourceRoot: root,
ManifestVersion: 1,
TotalEntries: 1,
Entries: []backendrpc.ResourceManifestEntry{{
URL: "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
Destination: "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
Bytes: &size,
BLAKE3: strings.Repeat("0", 64),
}},
},
}
cfg := DefaultConfig()
cfg.RefreshInterval = 0
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
s := NewServer(cfg, backend, log.New(io.Discard, "", 0))
if err := s.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
initial := s.index()
if initial == nil {
t.Fatal("initial index missing")
}
if summary := initial.Summary(); !summary.Ready || summary.ResourceRoot != root {
t.Fatalf("initial summary=%+v", summary)
}
backend.catalog = json.RawMessage(`{"available":false,"status":"waiting"}`)
outputRoot := filepath.Join(t.TempDir(), "official")
backend.resource = &backendrpc.ResourceState{ResourceOutputRoot: &outputRoot}
if err := s.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
current := s.index()
if current == nil {
t.Fatal("current index missing")
}
summary := current.Summary()
if summary.Ready || summary.ResourceRoot != "" {
t.Fatalf("stale summary=%+v", summary)
}
if len(s.meta.Warnings) == 0 {
t.Fatal("missing disappearance warning")
}
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("readyz status=%d body=%s", rr.Code, rr.Body.String())
}
}
func TestDiscoverTreatsCatalogUnavailableAsNoRelease(t *testing.T) {
fixture := fixtureRoot(t)
outputRoot := t.TempDir()
if err := os.Symlink(fixture, filepath.Join(outputRoot, "current")); err != nil {
t.Fatal(err)
}
backend := &fakeBackend{
status: &backendrpc.DaemonStatusReport{Status: "ok", Running: true, RPCAvailable: true},
doctor: &backendrpc.DoctorReport{Healthy: true, Status: "ok"},
catalog: json.RawMessage(`{"available":false,"status":"unavailable"}`),
resource: &backendrpc.ResourceState{ResourceOutputRoot: &outputRoot},
}
result, err := DiscoverAndIndex(context.Background(), backend, "")
if err != nil {
t.Fatal(err)
}
if result.Index == nil {
t.Fatal("index missing")
}
summary := result.Index.Summary()
if summary.Ready || summary.ResourceRoot != "" || summary.EntryCount != 0 {
t.Fatalf("catalog unavailable summary=%+v", summary)
}
if len(result.Warnings) == 0 {
t.Fatal("missing catalog unavailable warning")
}
}
func TestLoadEnvFileDoesNotOverride(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".env")
+27
View File
@@ -4,6 +4,7 @@ import (
"crypto/subtle"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -15,9 +16,35 @@ func (s *Server) wrapHandler(next http.Handler) http.Handler {
handler = s.rateLimitMiddleware(handler)
handler = s.securityHeadersMiddleware(handler)
handler = s.accessLogMiddleware(handler)
// Check before ServeMux can clean dot segments and route an escaped path
// to a different host-shaped endpoint.
handler = rejectDotSegmentsMiddleware(handler)
return handler
}
func rejectDotSegmentsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if hasDotPathSegment(r.URL.Path) || (r.URL.RawPath != "" && hasDotPathSegment(r.URL.RawPath)) {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
func hasDotPathSegment(rawPath string) bool {
decoded, err := url.PathUnescape(rawPath)
if err != nil {
return true
}
for _, segment := range strings.Split(decoded, "/") {
if segment == "." || segment == ".." {
return true
}
}
return false
}
func (s *Server) securityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
+4 -1
View File
@@ -106,7 +106,10 @@ func (idx *ReleaseIndex) Summary() ReleaseSummary {
EntryCount: len(idx.Entries),
PresentCount: present,
MissingCount: len(idx.MissingOnDisk),
Ready: idx.ResourceRoot != "" && present > 0,
// A release is distributable only when every manifest entry is present
// and has the expected size. Serving a partial release can leave clients
// with an apparently valid bootstrap and an unrecoverable download set.
Ready: idx.ResourceRoot != "" && len(idx.Entries) > 0 && present == len(idx.Entries),
}
}
+63
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"bat-api/internal/backendrpc"
@@ -206,16 +207,22 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
// Catalog / resource discovery
var snapshot *SnapshotSummary
var resourceRoot string
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 catalogAvailabilityKnown && !catalogAvailable {
return emptyRPCResult(out, snapshot, "catalog.status available=false; no published release"), nil
}
if resourceRoot == "" {
if state, err := backend.ResourceState(ctx); err != nil {
@@ -243,12 +250,32 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
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)
if err != nil {
out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err))
// Fallback: load local manifest file under root.
idx, loadErr := LoadIndexFromResourceRoot(resourceRoot)
if loadErr != nil {
if resourceRootOverride == "" {
return emptyRPCResult(
out,
snapshot,
fmt.Sprintf("published release cannot be indexed: %v", loadErr),
), nil
}
return out, fmt.Errorf("manifest RPC and local load failed: rpc=%v local=%w", err, loadErr)
}
idx.Source = "rpc+local_manifest"
@@ -279,6 +306,13 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
entries,
)
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
@@ -287,6 +321,22 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
return out, nil
}
func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning string) *DiscoverResult {
out.ResourceRoot = ""
out.Snapshot = snapshot
out.Index = &ReleaseIndex{
Source: "rpc",
RPCAvailable: true,
DoctorHealthy: out.DoctorHealthy,
Snapshot: snapshot,
byRel: map[string]int{},
}
if warning != "" {
out.Warnings = append(out.Warnings, warning)
}
return out
}
func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) {
if len(raw) == 0 || string(raw) == "null" {
return nil, "", false
@@ -333,6 +383,19 @@ func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) {
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) ([]manifestEntry, int, string, error) {
const pageSize = 500
offset := 0