// Package supportbundle creates bounded, redacted diagnostic archives without // accepting arbitrary source paths from API clients. package supportbundle import ( "archive/zip" "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "sort" "strings" "sync" "time" "tera-clone/services/tera-api/internal/storage/atomicfile" ) const ( defaultMaxArtifacts = 4 defaultMaxBytes = 8 * 1024 * 1024 defaultMaxSection = 2 * 1024 * 1024 maxSections = 16 ) type Options struct { Directory string MaxArtifacts int MaxBytes int MaxSection int } type Section struct { Name string Value any } type CreateRequest struct { BundleID string GeneratedAt time.Time Sections []Section } type Artifact struct { BundleID string Filename string SizeBytes int SHA256 string Sections []string Content []byte } type Service interface { Create(context.Context, CreateRequest) (Artifact, error) Read(context.Context, string) (Artifact, error) } type Manager struct { directory string maxArtifacts int maxBytes int maxSection int mu sync.Mutex } func NewManager(options Options) (*Manager, error) { if options.Directory == "" || !filepath.IsAbs(options.Directory) { return nil, errors.New("support bundle directory must be an absolute path") } if options.MaxArtifacts == 0 { options.MaxArtifacts = defaultMaxArtifacts } if options.MaxBytes == 0 { options.MaxBytes = defaultMaxBytes } if options.MaxSection == 0 { options.MaxSection = defaultMaxSection } if options.MaxArtifacts < 1 || options.MaxArtifacts > 32 { return nil, errors.New("support bundle max artifacts must be between 1 and 32") } if options.MaxBytes < 64*1024 || options.MaxBytes > 64*1024*1024 { return nil, errors.New("support bundle max bytes must be between 65536 and 67108864") } if options.MaxSection < 1024 || options.MaxSection > options.MaxBytes { return nil, errors.New("support bundle max section must be between 1024 and max bytes") } return &Manager{ directory: filepath.Clean(options.Directory), maxArtifacts: options.MaxArtifacts, maxBytes: options.MaxBytes, maxSection: options.MaxSection, }, nil } func (m *Manager) Create(ctx context.Context, request CreateRequest) (Artifact, error) { if err := validateBundleID(request.BundleID); err != nil { return Artifact{}, err } if request.GeneratedAt.IsZero() { return Artifact{}, errors.New("support bundle generated time is required") } if len(request.Sections) == 0 || len(request.Sections) > maxSections { return Artifact{}, fmt.Errorf("support bundle must contain between 1 and %d sections", maxSections) } if err := ctx.Err(); err != nil { return Artifact{}, err } m.mu.Lock() defer m.mu.Unlock() if err := ensureDirectory(m.directory); err != nil { return Artifact{}, err } filename := request.BundleID + ".zip" artifactPath := filepath.Join(m.directory, filename) if _, err := os.Lstat(artifactPath); err == nil { return Artifact{}, errors.New("support bundle artifact already exists") } else if !os.IsNotExist(err) { return Artifact{}, fmt.Errorf("inspect support bundle artifact: %w", err) } content, sectionNames, err := m.build(ctx, request) if err != nil { return Artifact{}, err } if err := atomicfile.Write(artifactPath, content, 0o600); err != nil { return Artifact{}, fmt.Errorf("persist support bundle: %w", err) } if err := m.trimLocked(); err != nil { return Artifact{}, err } digest := sha256.Sum256(content) return Artifact{ BundleID: request.BundleID, Filename: filename, SizeBytes: len(content), SHA256: hex.EncodeToString(digest[:]), Sections: sectionNames, }, nil } func (m *Manager) Read(ctx context.Context, bundleID string) (Artifact, error) { if err := validateBundleID(bundleID); err != nil { return Artifact{}, err } if err := ctx.Err(); err != nil { return Artifact{}, err } m.mu.Lock() defer m.mu.Unlock() if err := ensureDirectory(m.directory); err != nil { return Artifact{}, err } filename := bundleID + ".zip" file, err := os.Open(filepath.Join(m.directory, filename)) if err != nil { if os.IsNotExist(err) { return Artifact{}, errors.New("support bundle not found") } return Artifact{}, fmt.Errorf("open support bundle: %w", err) } defer file.Close() info, err := file.Stat() if err != nil { return Artifact{}, fmt.Errorf("inspect support bundle: %w", err) } if !info.Mode().IsRegular() || info.Size() < 1 || info.Size() > int64(m.maxBytes) { return Artifact{}, errors.New("support bundle artifact is not a bounded regular file") } content, err := io.ReadAll(io.LimitReader(file, int64(m.maxBytes)+1)) if err != nil { return Artifact{}, fmt.Errorf("read support bundle: %w", err) } if len(content) > m.maxBytes { return Artifact{}, errors.New("support bundle artifact exceeds the configured limit") } digest := sha256.Sum256(content) return Artifact{ BundleID: bundleID, Filename: filename, SizeBytes: len(content), SHA256: hex.EncodeToString(digest[:]), Content: content, }, nil } func (m *Manager) build(ctx context.Context, request CreateRequest) ([]byte, []string, error) { sectionNames := make([]string, 0, len(request.Sections)) sectionContent := make(map[string][]byte, len(request.Sections)) seen := make(map[string]bool, len(request.Sections)) totalSectionBytes := 0 for _, section := range request.Sections { if err := ctx.Err(); err != nil { return nil, nil, err } if err := validateSectionName(section.Name); err != nil { return nil, nil, err } if seen[section.Name] { return nil, nil, fmt.Errorf("support bundle section %q is duplicated", section.Name) } seen[section.Name] = true content, err := json.MarshalIndent(section.Value, "", " ") if err != nil { return nil, nil, fmt.Errorf("encode support bundle section %q: %w", section.Name, err) } content = append(content, '\n') if len(content) > m.maxSection { return nil, nil, fmt.Errorf("support bundle section %q exceeds the configured limit", section.Name) } totalSectionBytes += len(content) if totalSectionBytes > m.maxBytes { return nil, nil, errors.New("support bundle sections exceed the configured aggregate limit") } sectionNames = append(sectionNames, section.Name) sectionContent[section.Name] = content } sort.Strings(sectionNames) manifest := struct { FormatVersion int `json:"format_version"` BundleID string `json:"bundle_id"` GeneratedAt time.Time `json:"generated_at"` Sections []string `json:"sections"` }{FormatVersion: 1, BundleID: request.BundleID, GeneratedAt: request.GeneratedAt.UTC(), Sections: sectionNames} manifestContent, err := json.MarshalIndent(manifest, "", " ") if err != nil { return nil, nil, err } manifestContent = append(manifestContent, '\n') var buffer bytes.Buffer archive := zip.NewWriter(&buffer) if err := writeZipEntry(archive, "manifest.json", manifestContent, request.GeneratedAt); err != nil { return nil, nil, err } for _, name := range sectionNames { if err := writeZipEntry(archive, name, sectionContent[name], request.GeneratedAt); err != nil { return nil, nil, err } if buffer.Len() > m.maxBytes { _ = archive.Close() return nil, nil, errors.New("support bundle artifact exceeds the configured limit") } } if err := archive.Close(); err != nil { return nil, nil, fmt.Errorf("finalize support bundle: %w", err) } if buffer.Len() > m.maxBytes { return nil, nil, errors.New("support bundle artifact exceeds the configured limit") } return buffer.Bytes(), sectionNames, nil } func writeZipEntry(archive *zip.Writer, name string, content []byte, modified time.Time) error { header := &zip.FileHeader{Name: name, Method: zip.Deflate} header.SetMode(0o600) header.SetModTime(modified.UTC()) entry, err := archive.CreateHeader(header) if err != nil { return fmt.Errorf("create support bundle entry %q: %w", name, err) } if _, err := entry.Write(content); err != nil { return fmt.Errorf("write support bundle entry %q: %w", name, err) } return nil } func (m *Manager) trimLocked() error { entries, err := os.ReadDir(m.directory) if err != nil { return fmt.Errorf("list support bundles: %w", err) } names := make([]string, 0, len(entries)) for _, entry := range entries { name := entry.Name() if entry.Type().IsRegular() && strings.HasPrefix(name, "support_bundle_") && strings.HasSuffix(name, ".zip") { names = append(names, name) } } sort.Strings(names) for len(names) > m.maxArtifacts { if err := os.Remove(filepath.Join(m.directory, names[0])); err != nil { return fmt.Errorf("remove expired support bundle: %w", err) } names = names[1:] } return nil } func ensureDirectory(directory string) error { if info, err := os.Lstat(directory); err == nil { if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { return errors.New("support bundle directory must be a real directory") } } else if os.IsNotExist(err) { if err := os.MkdirAll(directory, 0o700); err != nil { return fmt.Errorf("create support bundle directory: %w", err) } } else { return fmt.Errorf("inspect support bundle directory: %w", err) } if err := os.Chmod(directory, 0o700); err != nil { return fmt.Errorf("protect support bundle directory: %w", err) } return nil } func validateBundleID(value string) error { if len(value) != len("support_bundle_20060102_000000") || !strings.HasPrefix(value, "support_bundle_") { return errors.New("support bundle id is invalid") } date := value[len("support_bundle_") : len("support_bundle_")+8] if _, err := time.Parse("20060102", date); err != nil { return errors.New("support bundle id is invalid") } if value[len("support_bundle_")+8] != '_' { return errors.New("support bundle id is invalid") } for _, char := range value[len(value)-6:] { if char < '0' || char > '9' { return errors.New("support bundle id is invalid") } } return nil } func validateSectionName(value string) error { if len(value) < 6 || len(value) > 64 || !strings.HasSuffix(value, ".json") || strings.ContainsAny(value, `/\\`) { return errors.New("support bundle section name is invalid") } for _, char := range strings.TrimSuffix(value, ".json") { if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '-' { continue } return errors.New("support bundle section name is invalid") } return nil }