* feat(diagnostics): chunked report upload fallback for proxy body caps
Diagnostics bundles can be up to max_bundle_bytes (10 MiB default), but a
reverse proxy in front of Silo commonly caps request bodies at nginx's
default client_max_body_size of 1 MiB. Such a proxy answers the single-shot
multipart upload with its own 413 before Silo ever sees the request, so any
report over the cap could never be delivered.
Add a chunked upload fallback under /api/v1/diagnostics/reports/uploads:
- POST / {manifest, bundle_bytes} opens a session
- PUT /{id}/chunks/{index} streams one ≤768 KiB chunk (proxy-safe)
- POST /{id}/complete ingests the assembled bundle
- DELETE /{id} best-effort abandon
The assembled bundle goes through the exact same Ingest path as the
single-shot endpoint, so every content check (manifest contract, archive
sha/bytes/entries, quotas, profile attribution) applies identically.
Sessions reuse internal/uploads (the plugin chunked-upload spool manager)
plus a small owner map for per-user isolation; they spool to disk, expire
after 15 minutes, cap at one per user / 16 global, and complete shares the
existing per-user + global in-flight ingest limiter.
/diagnostics/status now advertises upload_chunk_bytes so clients can detect
support; older servers omit the field and clients treat that as
unsupported. The demo guard's diagnostics prefix gains PUT to cover the
chunk route.
Verified end to end against an OpenResty proxy with a 1m body cap: the
single-shot upload 413s, the same 1.6 MiB bundle uploads in three chunks
and lands as an accepted report; also exercised from the tvOS client's
fallback path in the simulator.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(diagnostics): harden chunked upload sessions per review
- Reserve the per-user slot and global cap atomically in init (a
reservation map counted with live sessions), so concurrent inits by one
account can no longer fan out past one session or transiently exceed the
cap. Creation failures roll the reservation back.
- Move chunk body I/O outside the uploads.Manager mutex: a slow client
streaming one chunk no longer serializes every other session's chunk
writes, completes, and cancels. A per-chunk in-flight flag rejects
duplicate concurrent writes to the same offset (ErrChunkBusy → 409), and
cancel/expiry defer spool-directory removal to the last finishing
writer.
- Chunk arrivals refresh the session expiry, making the TTL an idle
timeout instead of an absolute deadline so a slow-but-progressing upload
cannot expire mid-transfer.
- Extend the request read deadline on chunk PUTs and both deadlines on
complete, matching the single-shot handler's slow-uplink handling.
- Keep the session when complete's availability re-check fails
transiently (status load error → 500): only definitive
disabled/storage-unavailable answers discard the spool, so a retried
complete succeeds without re-uploading every chunk.
- Reclaim orphaned spool directories at startup (a restart previously
stranded the old process's partial uploads forever) and sweep expired
sessions on a timer instead of only from later init traffic.
- Document that session state is process-local and what that means for
multi-replica deployments.
Adds concurrency/race tests (go test -race) for atomic admission,
same-chunk write exclusion, expiry refresh, transient-status retry, and
startup reclaim.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(diagnostics): count detached chunk writers and lift chunk PUT write deadline
Second review round:
- A canceled session whose slow chunk writer was still draining held a
connection and spool disk but vanished from every count, so a
cancel-and-reinit loop could stack unbounded live writers behind the
16-session cap. The uploads manager now parks such sessions in a
detached set (exposed as DetachedWriterSessions) until their last
writer returns, and diagnostics init counts them in its admission gate.
- Chunk PUTs now extend the write deadline as well as the read deadline:
on an uplink slow enough to eat the server's 120s WriteTimeout, the
stored chunk's JSON acknowledgement would otherwise be lost and the
client would retry an already-accepted chunk.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
586 lines
21 KiB
Go
586 lines
21 KiB
Go
package diagnostics
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"reflect"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/diagnostics/contract"
|
|
)
|
|
|
|
const (
|
|
MaxManifestBytes = int64(64 * 1024)
|
|
BundleContentType = "application/gzip"
|
|
// UploadChunkBytes is the fixed chunk payload size for chunked bundle
|
|
// uploads. Deliberately well under 1 MiB: chunked uploads exist to pass
|
|
// reverse proxies that keep nginx's default `client_max_body_size 1m`, so
|
|
// each chunk request — payload plus multipart/header overhead — must stay
|
|
// clearly below that floor.
|
|
UploadChunkBytes = int64(768 * 1024)
|
|
diagnosticObjectPrefix = "diagnostics"
|
|
|
|
failedUploadCompensationTimeout = 10 * time.Second
|
|
)
|
|
|
|
var (
|
|
ErrDisabled = errors.New("diagnostics disabled")
|
|
ErrStorageUnavailable = errors.New("diagnostics storage unavailable")
|
|
ErrTooLarge = errors.New("diagnostics upload too large")
|
|
ErrUnsupportedSchema = errors.New("diagnostics schema version unsupported")
|
|
ErrDestinationMismatch = errors.New("diagnostics destination mismatch")
|
|
ErrStaleConsent = errors.New("diagnostics consent notice is stale")
|
|
ErrArchiveMismatch = errors.New("diagnostics archive metadata mismatch")
|
|
ErrReportStoreUnavailable = errors.New("diagnostics report store unavailable")
|
|
ErrProfileMismatch = errors.New("diagnostics profile id mismatch")
|
|
ErrChildProfileForbidden = errors.New("diagnostics child profile attribution forbidden")
|
|
)
|
|
|
|
type AvailabilityStatus string
|
|
|
|
const (
|
|
StatusAvailable AvailabilityStatus = "available"
|
|
StatusDisabled AvailabilityStatus = "disabled"
|
|
StatusStorageUnavailable AvailabilityStatus = "storage_unavailable"
|
|
)
|
|
|
|
type Status struct {
|
|
Status AvailabilityStatus `json:"status"`
|
|
ServerInstanceID string `json:"server_instance_id"`
|
|
AcceptedSchemaVersions []int `json:"accepted_schema_versions"`
|
|
MaxBundleBytes int64 `json:"max_bundle_bytes"`
|
|
MaxManifestBytes int64 `json:"max_manifest_bytes"`
|
|
RetentionDays int `json:"retention_days"`
|
|
ConsentNoticeVersion int `json:"consent_notice_version"`
|
|
// UploadChunkBytes advertises chunked-upload support and the fixed chunk
|
|
// payload size. Clients that see it may split a bundle across
|
|
// POST /diagnostics/reports/uploads + chunk PUTs when the single-shot
|
|
// upload is rejected by a reverse proxy's request-body cap. Absent/zero on
|
|
// older servers, which clients must treat as "chunking unsupported".
|
|
UploadChunkBytes int64 `json:"upload_chunk_bytes"`
|
|
}
|
|
|
|
type IngestResult struct {
|
|
ReportID string `json:"report_id"`
|
|
ShortID string `json:"short_id"`
|
|
}
|
|
|
|
type Service struct {
|
|
reports ReportStore
|
|
settings SettingsStore
|
|
store ObjectStore
|
|
profileValidator ProfileAttributionValidator
|
|
logger *slog.Logger
|
|
now func() time.Time
|
|
}
|
|
|
|
func NewService(reports ReportStore, settings SettingsStore, store ObjectStore, logger *slog.Logger) *Service {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &Service{
|
|
reports: reports,
|
|
settings: settings,
|
|
store: store,
|
|
logger: logger,
|
|
now: func() time.Time { return time.Now().UTC() },
|
|
}
|
|
}
|
|
|
|
type ProfileAttributionValidator interface {
|
|
ProfileBelongsToUser(ctx context.Context, userID int, profileID string) (bool, error)
|
|
}
|
|
|
|
type ProfileAttributionValidatorFunc func(ctx context.Context, userID int, profileID string) (bool, error)
|
|
|
|
func (f ProfileAttributionValidatorFunc) ProfileBelongsToUser(ctx context.Context, userID int, profileID string) (bool, error) {
|
|
return f(ctx, userID, profileID)
|
|
}
|
|
|
|
// ProfileLookup resolves whether profileID exists for userID and whether it is
|
|
// a child profile. It backs NewProfileAttributionValidator.
|
|
type ProfileLookup func(ctx context.Context, userID int, profileID string) (found bool, isChild bool, err error)
|
|
|
|
// NewProfileAttributionValidator builds a ProfileAttributionValidator that
|
|
// attributes a report to a profile only when it belongs to the user and is not
|
|
// a child profile. The client diagnostics design forbids child profiles from
|
|
// performing diagnostics actions, so a nonconforming client that sends a child
|
|
// profile's ID (via X-Profile-Id or a manifest profile_id) has the upload
|
|
// rejected with ErrChildProfileForbidden rather than silently losing the
|
|
// attribution. A profile that is simply not the user's (found == false) is not
|
|
// an error: attribution is dropped and the upload proceeds unattributed.
|
|
func NewProfileAttributionValidator(lookup ProfileLookup) ProfileAttributionValidator {
|
|
return ProfileAttributionValidatorFunc(func(ctx context.Context, userID int, profileID string) (bool, error) {
|
|
found, isChild, err := lookup(ctx, userID, profileID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if found && isChild {
|
|
return false, ErrChildProfileForbidden
|
|
}
|
|
return found, nil
|
|
})
|
|
}
|
|
|
|
func (s *Service) SetProfileAttributionValidator(validator ProfileAttributionValidator) {
|
|
s.profileValidator = validator
|
|
}
|
|
|
|
func (s *Service) Status(ctx context.Context, userID int) (Status, error) {
|
|
settings, err := s.currentSettings(ctx)
|
|
if err != nil {
|
|
return Status{}, err
|
|
}
|
|
return s.statusFromSettings(settings), nil
|
|
}
|
|
|
|
func (s *Service) Ingest(ctx context.Context, userID int, profileID *string, manifestJSON []byte, bundle io.Reader) (IngestResult, error) {
|
|
settings, err := s.currentSettings(ctx)
|
|
if err != nil {
|
|
s.logRejected(ctx, "", userID, "", "", 0, "settings_error")
|
|
return IngestResult{}, err
|
|
}
|
|
status := s.statusFromSettings(settings).Status
|
|
switch status {
|
|
case StatusDisabled:
|
|
s.logRejected(ctx, "", userID, "", "", 0, "disabled")
|
|
return IngestResult{}, ErrDisabled
|
|
case StatusStorageUnavailable:
|
|
s.logRejected(ctx, "", userID, "", "", 0, "storage_unavailable")
|
|
return IngestResult{}, ErrStorageUnavailable
|
|
}
|
|
if s.reports == nil {
|
|
s.logRejected(ctx, "", userID, "", "", 0, "report_store_unavailable")
|
|
return IngestResult{}, ErrReportStoreUnavailable
|
|
}
|
|
if len(manifestJSON) > int(MaxManifestBytes) {
|
|
s.logRejected(ctx, "", userID, "", "", int64(len(manifestJSON)), "too_large")
|
|
return IngestResult{}, ErrTooLarge
|
|
}
|
|
if manifestHasUnsupportedSchema(manifestJSON) {
|
|
s.logRejected(ctx, "", userID, "", "", int64(len(manifestJSON)), "unsupported_schema")
|
|
return IngestResult{}, ErrUnsupportedSchema
|
|
}
|
|
|
|
manifest, err := contract.ValidateManifest(manifestJSON)
|
|
if err != nil {
|
|
s.logRejected(ctx, "", userID, "", "", int64(len(manifestJSON)), "invalid_manifest")
|
|
return IngestResult{}, fmt.Errorf("%w: invalid manifest: %v", ErrInvalidBundle, err)
|
|
}
|
|
if manifest.Archive.Bytes > settings.MaxBundleBytes {
|
|
s.logRejected(ctx, "", userID, manifest.Report.Platform, manifest.Report.Type, manifest.Archive.Bytes, "too_large")
|
|
return IngestResult{}, ErrTooLarge
|
|
}
|
|
if manifest.Destination.ServerInstanceID != settings.ServerInstanceID {
|
|
s.logRejected(ctx, "", userID, manifest.Report.Platform, manifest.Report.Type, manifest.Archive.Bytes, "destination_mismatch")
|
|
return IngestResult{}, ErrDestinationMismatch
|
|
}
|
|
if manifest.Consent.NoticeVersion != settings.ConsentNoticeVersion {
|
|
s.logRejected(ctx, "", userID, manifest.Report.Platform, manifest.Report.Type, manifest.Archive.Bytes, "stale_consent")
|
|
return IngestResult{}, ErrStaleConsent
|
|
}
|
|
|
|
reportProfileID, err := s.validatedProfileID(ctx, userID, profileID, manifest.Report.ProfileID)
|
|
if err != nil {
|
|
s.logRejected(ctx, "", userID, manifest.Report.Platform, manifest.Report.Type, manifest.Archive.Bytes, "profile_validation_error")
|
|
return IngestResult{}, err
|
|
}
|
|
crashSummary := crashSummaryFromManifest(manifest)
|
|
// ExpectedBlobBytes reserves the client-claimed archive size against the
|
|
// per-user byte quota before the bundle streams in. Trusting the claim here is
|
|
// safe: a report only reaches MarkReady (which rewrites blob_bytes to the
|
|
// measured size) after archiveMatches confirms the claimed bytes/sha/entries
|
|
// exactly equal the streamed bundle, so no stored report can ever occupy more
|
|
// bytes than it reserved. A client that under-claims to under-reserve then
|
|
// uploads a larger bundle fails archiveMatches and is compensated (blob
|
|
// deleted, row marked failed) before it counts, gaining no storage. The
|
|
// residual cost of such rejected churn is bandwidth, not storage — bounded by
|
|
// the per-account single in-flight upload cap, not by max_bytes_per_user,
|
|
// which is a storage cap enforced oldest-first by retention.
|
|
reserved, err := s.reports.InsertReceiving(ctx, InsertReceivingInput{
|
|
UserID: userID,
|
|
ProfileID: reportProfileID,
|
|
CapturedAt: manifest.Report.CapturedAt,
|
|
ReportType: manifest.Report.Type,
|
|
Platform: manifest.Report.Platform,
|
|
AppVersion: manifest.Report.AppVersion,
|
|
CrashSummary: crashSummary,
|
|
Manifest: append(json.RawMessage(nil), manifestJSON...),
|
|
PlaybackSessionIDs: manifest.PlaybackSessionIDs,
|
|
ExpectedBlobBytes: manifest.Archive.Bytes,
|
|
MaxReportsPerUserDay: settings.MaxReportsPerUserDay,
|
|
MaxBytesPerUser: settings.MaxBytesPerUser,
|
|
Now: s.now(),
|
|
})
|
|
if err != nil {
|
|
reason := "insert_failed"
|
|
if errors.Is(err, ErrQuotaExceeded) {
|
|
reason = "quota_exceeded"
|
|
} else {
|
|
s.logger.ErrorContext(ctx, "diagnostic report insert failed",
|
|
"component", "diagnostics",
|
|
"user_id", userID,
|
|
"error", err,
|
|
)
|
|
}
|
|
s.logRejected(ctx, "", userID, manifest.Report.Platform, manifest.Report.Type, manifest.Archive.Bytes, reason)
|
|
return IngestResult{}, err
|
|
}
|
|
|
|
key := reportObjectKey(userID, reserved.ID)
|
|
info, err := s.putValidatedBundle(ctx, key, bundle, settings)
|
|
if err != nil {
|
|
s.compensateFailedUpload(ctx, reserved.ID, key)
|
|
rejectErr := classifyBundleUploadError(err)
|
|
s.logger.ErrorContext(ctx, "diagnostic bundle validation failed",
|
|
"component", "diagnostics",
|
|
"report_id", reserved.ID,
|
|
"user_id", userID,
|
|
"error", err,
|
|
)
|
|
s.logRejected(ctx, reserved.ID, userID, manifest.Report.Platform, manifest.Report.Type, manifest.Archive.Bytes, rejectReason(rejectErr))
|
|
return IngestResult{}, rejectErr
|
|
}
|
|
if !archiveMatches(manifest.Archive, info) {
|
|
s.compensateFailedUpload(ctx, reserved.ID, key)
|
|
s.logRejected(ctx, reserved.ID, userID, manifest.Report.Platform, manifest.Report.Type, info.CompressedBytes, "archive_mismatch")
|
|
return IngestResult{}, ErrArchiveMismatch
|
|
}
|
|
if !embeddedManifestMatches(manifestJSON, info.EmbeddedManifest) {
|
|
s.compensateFailedUpload(ctx, reserved.ID, key)
|
|
s.logRejected(ctx, reserved.ID, userID, manifest.Report.Platform, manifest.Report.Type, info.CompressedBytes, "manifest_mismatch")
|
|
return IngestResult{}, ErrArchiveMismatch
|
|
}
|
|
|
|
if err := s.reports.MarkReady(ctx, reserved.ID, BlobInfo{
|
|
Bucket: s.store.Bucket(),
|
|
Key: key,
|
|
Bytes: info.CompressedBytes,
|
|
UncompressedBytes: info.UncompressedBytes,
|
|
SHA256: info.SHA256,
|
|
}); err != nil {
|
|
s.compensateFailedUpload(ctx, reserved.ID, key)
|
|
s.logRejected(ctx, reserved.ID, userID, manifest.Report.Platform, manifest.Report.Type, info.CompressedBytes, "mark_ready_failed")
|
|
return IngestResult{}, err
|
|
}
|
|
|
|
s.logger.InfoContext(ctx, "diagnostic report accepted",
|
|
"component", "diagnostics",
|
|
"report_id", reserved.ID,
|
|
"user_id", userID,
|
|
"platform", manifest.Report.Platform,
|
|
"type", manifest.Report.Type,
|
|
"bytes", info.CompressedBytes,
|
|
"result", "accepted",
|
|
)
|
|
return IngestResult{ReportID: reserved.ID, ShortID: reserved.ShortID}, nil
|
|
}
|
|
|
|
func (s *Service) currentSettings(ctx context.Context) (Settings, error) {
|
|
settings, err := LoadSettings(ctx, s.settings)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
if strings.TrimSpace(settings.ServerInstanceID) != "" || s.settings == nil {
|
|
return settings, nil
|
|
}
|
|
|
|
instanceID, err := ensureServerInstanceID(ctx, s.settings)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
settings.ServerInstanceID = instanceID
|
|
return settings, nil
|
|
}
|
|
|
|
func (s *Service) statusFromSettings(settings Settings) Status {
|
|
availability := StatusDisabled
|
|
if settings.UploadsEnabled {
|
|
availability = StatusAvailable
|
|
if s.store == nil || strings.TrimSpace(s.store.Bucket()) == "" {
|
|
availability = StatusStorageUnavailable
|
|
}
|
|
}
|
|
|
|
return Status{
|
|
Status: availability,
|
|
ServerInstanceID: settings.ServerInstanceID,
|
|
AcceptedSchemaVersions: []int{contract.SchemaVersion},
|
|
MaxBundleBytes: settings.MaxBundleBytes,
|
|
MaxManifestBytes: MaxManifestBytes,
|
|
RetentionDays: settings.RetentionDays,
|
|
ConsentNoticeVersion: settings.ConsentNoticeVersion,
|
|
UploadChunkBytes: UploadChunkBytes,
|
|
}
|
|
}
|
|
|
|
func (s *Service) putValidatedBundle(ctx context.Context, key string, bundle io.Reader, settings Settings) (BundleInfo, error) {
|
|
pr, pw := io.Pipe()
|
|
resultCh := make(chan bundleValidationResult, 1)
|
|
go func() {
|
|
info, err := ValidateBundle(pr, BundleLimits{
|
|
MaxCompressedBytes: settings.MaxBundleBytes,
|
|
MaxUncompressedBytes: settings.MaxUncompressedBytes,
|
|
})
|
|
if err != nil {
|
|
_ = pr.CloseWithError(err)
|
|
}
|
|
resultCh <- bundleValidationResult{info: info, err: err}
|
|
}()
|
|
|
|
reader := &validatingUploadReader{src: bundle, pipe: pw}
|
|
putErr := s.store.PutStream(ctx, s.store.Bucket(), key, reader, BundleContentType)
|
|
if putErr != nil {
|
|
reader.abort(&bundleUploadAbortError{err: putErr})
|
|
}
|
|
result := <-resultCh
|
|
if putErr != nil {
|
|
if result.err != nil && !isBundleUploadAbortError(result.err) {
|
|
return result.info, result.err
|
|
}
|
|
return BundleInfo{}, fmt.Errorf("%w: store diagnostic bundle: %w", ErrStorageUnavailable, putErr)
|
|
}
|
|
if result.err != nil {
|
|
return result.info, result.err
|
|
}
|
|
return result.info, nil
|
|
}
|
|
|
|
func (s *Service) compensateFailedUpload(ctx context.Context, reportID, key string) {
|
|
compensationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), failedUploadCompensationTimeout)
|
|
defer cancel()
|
|
|
|
if s.store != nil && key != "" {
|
|
if err := s.store.DeleteObject(compensationCtx, s.store.Bucket(), key); err != nil {
|
|
s.logger.WarnContext(compensationCtx, "diagnostic report blob compensation failed",
|
|
"component", "diagnostics",
|
|
"report_id", reportID,
|
|
"key", key,
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
if s.reports != nil && reportID != "" {
|
|
if err := s.reports.MarkFailed(compensationCtx, reportID); err != nil {
|
|
s.logger.WarnContext(compensationCtx, "diagnostic report failed-state compensation failed",
|
|
"component", "diagnostics",
|
|
"report_id", reportID,
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Service) logRejected(ctx context.Context, reportID string, userID int, platform, reportType string, bytes int64, reason string) {
|
|
s.logger.InfoContext(ctx, "diagnostic report rejected",
|
|
"component", "diagnostics",
|
|
"report_id", reportID,
|
|
"user_id", userID,
|
|
"platform", platform,
|
|
"type", reportType,
|
|
"bytes", bytes,
|
|
"result", "rejected",
|
|
"reason", reason,
|
|
)
|
|
}
|
|
|
|
type bundleValidationResult struct {
|
|
info BundleInfo
|
|
err error
|
|
}
|
|
|
|
type validatingUploadReader struct {
|
|
src io.Reader
|
|
pipe *io.PipeWriter
|
|
closed bool
|
|
}
|
|
|
|
func (r *validatingUploadReader) Read(p []byte) (int, error) {
|
|
n, readErr := r.src.Read(p)
|
|
if n > 0 {
|
|
if _, err := r.pipe.Write(p[:n]); err != nil {
|
|
return n, err
|
|
}
|
|
}
|
|
if readErr != nil {
|
|
r.close(readErr)
|
|
}
|
|
return n, readErr
|
|
}
|
|
|
|
func (r *validatingUploadReader) abort(err error) {
|
|
r.close(err)
|
|
}
|
|
|
|
func (r *validatingUploadReader) close(err error) {
|
|
if r.closed {
|
|
return
|
|
}
|
|
r.closed = true
|
|
if errors.Is(err, io.EOF) {
|
|
_ = r.pipe.Close()
|
|
return
|
|
}
|
|
_ = r.pipe.CloseWithError(err)
|
|
}
|
|
|
|
func classifyBundleUploadError(err error) error {
|
|
switch {
|
|
case errors.Is(err, ErrCompressedTooLarge),
|
|
errors.Is(err, ErrUncompressedTooLarge),
|
|
errors.Is(err, ErrEntryTooLarge):
|
|
return ErrTooLarge
|
|
case errors.Is(err, ErrInvalidBundle),
|
|
errors.Is(err, ErrTooManyEntries),
|
|
errors.Is(err, ErrCompressionRatio):
|
|
return fmt.Errorf("%w: %v", ErrInvalidBundle, err)
|
|
default:
|
|
return err
|
|
}
|
|
}
|
|
|
|
func rejectReason(err error) string {
|
|
switch {
|
|
case errors.Is(err, ErrTooLarge):
|
|
return "too_large"
|
|
case errors.Is(err, ErrUnsupportedSchema):
|
|
return "unsupported_schema"
|
|
case errors.Is(err, ErrInvalidBundle):
|
|
return "invalid_bundle"
|
|
default:
|
|
return "storage_error"
|
|
}
|
|
}
|
|
|
|
func manifestHasUnsupportedSchema(data []byte) bool {
|
|
var header struct {
|
|
SchemaVersion *int `json:"schema_version"`
|
|
}
|
|
if err := json.Unmarshal(data, &header); err != nil {
|
|
return false
|
|
}
|
|
return header.SchemaVersion != nil && *header.SchemaVersion != contract.SchemaVersion
|
|
}
|
|
|
|
// resolveProfileID chooses the profile a report is attributed to from the
|
|
// X-Profile-Id header and the manifest's report.profile_id fallback. When both
|
|
// are present they must agree: a header captured under a different profile than
|
|
// the manifest means the client switched profiles between capture and upload,
|
|
// and silently preferring either source would attribute the DB row to one
|
|
// profile while the stored manifest/download still names the other. Reject that
|
|
// as ErrProfileMismatch. Single-source cases (only one present, or both equal)
|
|
// resolve to that value; when neither is present the report is unattributed.
|
|
func resolveProfileID(profileID *string, fallback string) (*string, error) {
|
|
header := ""
|
|
if profileID != nil {
|
|
header = strings.TrimSpace(*profileID)
|
|
}
|
|
manifestID := strings.TrimSpace(fallback)
|
|
|
|
switch {
|
|
case header != "" && manifestID != "" && header != manifestID:
|
|
return nil, fmt.Errorf("%w: header %q, manifest %q", ErrProfileMismatch, header, manifestID)
|
|
case header != "":
|
|
return &header, nil
|
|
case manifestID != "":
|
|
return &manifestID, nil
|
|
default:
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
func (s *Service) validatedProfileID(ctx context.Context, userID int, profileID *string, fallback string) (*string, error) {
|
|
candidate, err := resolveProfileID(profileID, fallback)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if candidate == nil {
|
|
return nil, nil
|
|
}
|
|
if s.profileValidator == nil {
|
|
return nil, nil
|
|
}
|
|
found, err := s.profileValidator.ProfileBelongsToUser(ctx, userID, *candidate)
|
|
if err != nil {
|
|
// A child-profile attribution is a client-facing rejection, not an
|
|
// internal failure: surface it verbatim so the handler can return a
|
|
// clear error instead of a generic 500.
|
|
if errors.Is(err, ErrChildProfileForbidden) {
|
|
return nil, err
|
|
}
|
|
return nil, fmt.Errorf("validate diagnostic profile attribution: %w", err)
|
|
}
|
|
if !found {
|
|
return nil, nil
|
|
}
|
|
return candidate, nil
|
|
}
|
|
|
|
func crashSummaryFromManifest(manifest contract.Manifest) *string {
|
|
if manifest.Crash == nil {
|
|
return nil
|
|
}
|
|
summary := manifest.Crash.Summary
|
|
return &summary
|
|
}
|
|
|
|
func reportObjectKey(userID int, reportID string) string {
|
|
return diagnosticObjectPrefix + "/" + strconv.Itoa(userID) + "/" + reportID + ".tar.gz"
|
|
}
|
|
|
|
func archiveMatches(archive contract.Archive, info BundleInfo) bool {
|
|
return archive.Bytes == info.CompressedBytes &&
|
|
archive.UncompressedBytes == info.UncompressedBytes &&
|
|
strings.EqualFold(archive.SHA256, info.SHA256) &&
|
|
slices.Equal(archive.Entries, info.Entries)
|
|
}
|
|
|
|
// embeddedManifestMatches reports whether the manifest.json embedded as the
|
|
// archive's first entry equals the received part-1 manifest with its `archive`
|
|
// object removed, per the bundle contract. Both are decoded to JSON before
|
|
// comparison so formatting or key-ordering differences don't matter.
|
|
func embeddedManifestMatches(received, embedded []byte) bool {
|
|
receivedMap, err := decodeJSONObject(received)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
delete(receivedMap, "archive")
|
|
embeddedMap, err := decodeJSONObject(embedded)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return reflect.DeepEqual(receivedMap, embeddedMap)
|
|
}
|
|
|
|
func decodeJSONObject(data []byte) (map[string]any, error) {
|
|
dec := json.NewDecoder(bytes.NewReader(data))
|
|
// UseNumber keeps every JSON number as its exact decimal text (json.Number)
|
|
// instead of float64. Without it, two manifests that differ only in a large
|
|
// integer above 2^53 (e.g. log_summary.lines) both round to the same float
|
|
// and DeepEqual reports a false match, letting a tampered bundle through.
|
|
dec.UseNumber()
|
|
var obj map[string]any
|
|
if err := dec.Decode(&obj); err != nil {
|
|
return nil, err
|
|
}
|
|
// json.Unmarshal rejects trailing bytes after the value; Decoder.Decode does
|
|
// not, and Decoder.More() misses a stray closing delimiter (e.g. `{...}}`)
|
|
// because it only reports iteration inside an array/object. Re-assert that
|
|
// strictness by requiring the stream to be exhausted: a second Decode must
|
|
// hit io.EOF, so a manifest with any trailing token — junk or an extra
|
|
// `}`/`]` — is rejected and cannot compare equal. Trailing whitespace is
|
|
// fine; the decoder skips it and still returns io.EOF.
|
|
if err := dec.Decode(new(json.RawMessage)); err != io.EOF {
|
|
return nil, errors.New("unexpected trailing data after JSON object")
|
|
}
|
|
return obj, nil
|
|
}
|