clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / tinygit / backup_import.go
backup_import.go 6.6K
1package tinygit23// This is the delivery-job seam for converting bind.ws git-sqlite/1 backups4// into native bare repositories. Validation happens before any object or ref5// is written; Git remains the final object database and ref transaction.67import (8 "bytes"9 "compress/zlib"10 "context"11 "crypto/sha1"12 "encoding/base64"13 "encoding/hex"14 "encoding/json"15 "errors"16 "fmt"17 "os/exec"18 "sort"19 "strings"20)2122// BackupObject is one validated Git object and its compressed content chunks.23type BackupObject struct {24 OID string `json:"oid"`25 Type string `json:"type"`26 Size int `json:"size"`27 Chunks []string `json:"chunks"`28}2930// BackupRepository contains the objects and refs for one imported repository.31type BackupRepository struct {32 Key string `json:"key"`33 Owner string `json:"owner"`34 Identifier string `json:"identifier"`35 Alternative bool `json:"alternative"`36 Objects []BackupObject `json:"objects"`37 Refs map[string]string `json:"refs"`38}3940// GitBackup is the complete bind.ws/git-sqlite/1 import document.41type GitBackup struct {42 Format string `json:"format"`43 Repositories []BackupRepository `json:"repositories"`44}4546// ValidateGitBackup validates an import document without writing Git state.47func ValidateGitBackup(input []byte) (GitBackup, error) {48 var backup GitBackup49 if err := json.Unmarshal(input, &backup); err != nil {50 return GitBackup{}, fmt.Errorf("invalid Git backup JSON: %w", err)51 }52 if backup.Format != "bind.ws/git-sqlite/1" {53 return GitBackup{}, errors.New("invalid Git backup format")54 }55 seen := make(map[string]struct{}, len(backup.Repositories))56 for i := range backup.Repositories {57 r := &backup.Repositories[i]58 if !isObjectID64(r.Key) || !isPubKey(r.Owner) || !validIdentifier(r.Identifier) {59 return GitBackup{}, errors.New("invalid Git repository identity")60 }61 if _, ok := seen[r.Key]; ok {62 return GitBackup{}, errors.New("duplicate Git repository")63 }64 seen[r.Key] = struct{}{}65 for _, obj := range r.Objects {66 if err := validateBackupObject(obj); err != nil {67 return GitBackup{}, err68 }69 }70 for ref, oid := range r.Refs {71 if !validRef(ref) || !isObjectID(oid) {72 return GitBackup{}, errors.New("invalid Git backup ref")73 }74 }75 }76 return backup, nil77}7879func validateBackupObject(obj BackupObject) error {80 if !isObjectID(obj.OID) || obj.Size < 0 || (obj.Type != "blob" && obj.Type != "tree" && obj.Type != "commit" && obj.Type != "tag") || len(obj.Chunks) == 0 {81 return errors.New("invalid Git backup object")82 }83 data, err := decodeBackupObject(obj)84 if err != nil {85 return err86 }87 h := sha1.Sum(append([]byte(fmt.Sprintf("%s %d\x00", obj.Type, len(data))), data...))88 if len(data) != obj.Size || hex.EncodeToString(h[:]) != obj.OID {89 return errors.New("Git backup object hash mismatch")90 }91 return nil92}9394func decodeBackupObject(obj BackupObject) ([]byte, error) {95 var compressed bytes.Buffer96 for _, chunk := range obj.Chunks {97 b, err := base64.StdEncoding.DecodeString(chunk)98 if err != nil {99 return nil, errors.New("invalid Git backup chunk")100 }101 compressed.Write(b)102 }103 r, err := zlib.NewReader(bytes.NewReader(compressed.Bytes()))104 if err != nil {105 return nil, errors.New("invalid Git backup compression")106 }107 var data bytes.Buffer108 if _, err := data.ReadFrom(r); err != nil {109 _ = r.Close()110 return nil, err111 }112 if err := r.Close(); err != nil {113 return nil, err114 }115 return data.Bytes(), nil116}117118func isObjectID64(s string) bool { return len(s) == 64 && strings.Trim(s, "0123456789abcdef") == "" }119func isPubKey(s string) bool { return len(s) == 64 && strings.Trim(s, "0123456789abcdef") == "" }120func validRef(s string) bool {121 if !strings.HasPrefix(s, "refs/") || s == "refs/" || strings.Contains(s, "//") || strings.Contains(s, "..") || strings.Contains(s, "@{") {122 return false123 }124 if strings.ContainsAny(s, "~^:?*[\\") {125 return false126 }127 for _, r := range s {128 if r <= ' ' || r == 127 {129 return false130 }131 }132 for _, part := range strings.Split(s, "/") {133 if part == "" || strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".") || strings.HasSuffix(part, ".lock") {134 return false135 }136 }137 return true138}139140func validIdentifier(id string) bool {141 if id == "" || id == "." || id == ".." || strings.ContainsAny(id, `/\\\"'$`+"`;&|()<>") {142 return false143 }144 for _, r := range id {145 if r <= ' ' {146 return false147 }148 }149 return true150}151152// ImportGitBackup installs a validated backup into empty native repositories.153// It refuses pre-existing refs or repositories to avoid silently mixing154// namespaces; callers can stage an import into a fresh Git root and swap it.155func (g *GitRelay) ImportGitBackup(ctx context.Context, input []byte) error {156 backup, err := ValidateGitBackup(input)157 if err != nil {158 return err159 }160 for _, r := range backup.Repositories {161 repo := Repository{Owner: r.Owner, Identifier: r.Identifier, Alternative: r.Alternative, Refs: r.Refs}162 if err := g.ensureRepo(repo); err != nil {163 return err164 }165 for _, ref := range r.Refs {166 if out, err := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(repo), "show-ref", "--verify", ref).CombinedOutput(); err == nil {167 return fmt.Errorf("Git backup target is not empty: %s", strings.TrimSpace(string(out)))168 }169 }170 for _, obj := range r.Objects {171 data, _ := decodeBackupObject(obj)172 cmd := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(repo), "hash-object", "-w", "--stdin", "-t", obj.Type)173 cmd.Stdin = bytes.NewReader(data)174 if out, err := cmd.CombinedOutput(); err != nil || strings.TrimSpace(string(out)) != obj.OID {175 return fmt.Errorf("install Git object %s: %w", obj.OID, err)176 }177 }178 for ref, oid := range r.Refs {179 if out, err := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(repo), "update-ref", ref, oid).CombinedOutput(); err != nil {180 return fmt.Errorf("install Git ref %s: %w: %s", ref, err, strings.TrimSpace(string(out)))181 }182 }183 if head := backupHead(r.Refs); head != "" {184 if out, err := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(repo), "symbolic-ref", "HEAD", head).CombinedOutput(); err != nil {185 return fmt.Errorf("install Git HEAD: %w: %s", err, strings.TrimSpace(string(out)))186 }187 }188 }189 return nil190}191192func backupHead(refs map[string]string) string {193 for _, candidate := range []string{"refs/heads/main", "refs/heads/master"} {194 if _, ok := refs[candidate]; ok {195 return candidate196 }197 }198 branches := make([]string, 0)199 for ref := range refs {200 if strings.HasPrefix(ref, "refs/heads/") {201 branches = append(branches, ref)202 }203 }204 sort.Strings(branches)205 if len(branches) > 0 {206 return branches[0]207 }208 return ""209}