Files
lingma-proxy-compose/internal/sessionbundle/bundle.go
GitHub Actions a4cedecca6 Add Docker Compose Lingma bootstrap support
Package the proxy for Docker Compose deployments and add Lingma bootstrap, session restore, and runtime status support for containerized remote usage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:56:05 +08:00

194 lines
4.5 KiB
Go

package sessionbundle
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/base64"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
const maxBundleBytes = 4 * 1024 * 1024
var bundleFiles = map[string]struct{}{
"cache/id": {},
"cache/user": {},
"cache/quota": {},
"cache/config.json": {},
}
type Result struct {
Configured bool `json:"configured"`
Restored bool `json:"restored"`
Source string `json:"source,omitempty"`
WorkDir string `json:"work_dir,omitempty"`
Files []string `json:"files,omitempty"`
Message string `json:"message,omitempty"`
}
func Restore(workDir string, inline string, filePath string) (Result, error) {
result := Result{
Configured: strings.TrimSpace(inline) != "" || strings.TrimSpace(filePath) != "",
WorkDir: strings.TrimSpace(workDir),
}
if !result.Configured {
result.Message = "session bundle not configured"
return result, nil
}
if result.WorkDir == "" {
return result, fmt.Errorf("session bundle restore requires a work dir")
}
bundle, source, err := resolveBundle(inline, filePath)
if err != nil {
return result, err
}
raw, err := DecodeBundle(bundle)
if err != nil {
return result, err
}
files, err := ApplyBundleToWorkDir(result.WorkDir, raw)
if err != nil {
return result, err
}
result.Restored = true
result.Source = source
result.Files = files
result.Message = fmt.Sprintf("restored %d files", len(files))
return result, nil
}
func DecodeBundle(b64 string) ([]byte, error) {
b64 = strings.TrimSpace(b64)
if b64 == "" {
return nil, fmt.Errorf("empty bundle")
}
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil, fmt.Errorf("invalid base64: %w", err)
}
if len(raw) > maxBundleBytes {
return nil, fmt.Errorf("bundle too large: %d bytes", len(raw))
}
return raw, nil
}
func ApplyBundleToWorkDir(workDir string, raw []byte) ([]string, error) {
if len(raw) > maxBundleBytes {
return nil, fmt.Errorf("bundle too large: %d bytes", len(raw))
}
if err := os.MkdirAll(workDir, 0o755); err != nil {
return nil, err
}
gz, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil {
return nil, fmt.Errorf("open bundle gzip: %w", err)
}
defer gz.Close()
reader := tar.NewReader(gz)
restored := make([]string, 0, len(bundleFiles))
var total int64
for {
header, err := reader.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("read bundle tar: %w", err)
}
if !isSafeMember(header) {
continue
}
total += header.Size
if total > maxBundleBytes {
return nil, fmt.Errorf("bundle expanded too large: %d bytes", total)
}
dest := filepath.Join(workDir, filepath.FromSlash(header.Name))
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return nil, err
}
file, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fileModeFor(header.Name))
if err != nil {
return nil, err
}
if _, err := io.Copy(file, reader); err != nil {
file.Close()
return nil, err
}
if err := file.Close(); err != nil {
return nil, err
}
restored = append(restored, header.Name)
}
return restored, nil
}
func resolveBundle(inline string, filePath string) (string, string, error) {
if value := strings.TrimSpace(inline); value != "" {
return value, "inline", nil
}
path := strings.TrimSpace(filePath)
if path == "" {
return "", "", fmt.Errorf("session bundle file path is empty")
}
expanded := expandHome(path)
body, err := os.ReadFile(expanded)
if err != nil {
return "", "", fmt.Errorf("read bundle file: %w", err)
}
return strings.TrimSpace(string(body)), filepath.Base(expanded), nil
}
func isSafeMember(header *tar.Header) bool {
if header == nil {
return false
}
if _, ok := bundleFiles[header.Name]; !ok {
return false
}
if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA {
return false
}
if header.Name == "" || strings.HasPrefix(header.Name, "/") {
return false
}
for _, part := range strings.Split(header.Name, "/") {
if part == ".." {
return false
}
}
return true
}
func fileModeFor(name string) os.FileMode {
if strings.HasSuffix(name, "/user") {
return 0o600
}
return 0o644
}
func expandHome(path string) string {
path = strings.TrimSpace(path)
if path == "" || path[0] != '~' {
return path
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return path
}
if path == "~" {
return home
}
if len(path) > 1 && (path[1] == '/' || path[1] == '\\') {
return filepath.Join(home, path[2:])
}
return path
}