tinyrelayfile
tinyrelay/tinygit/backup_import.go/

tinygit/backup_import.go raw
1package tinygit
2
3// This is the delivery-job seam for converting bind.ws git-sqlite/1 backups
4// into native bare repositories. Validation happens before any object or ref
5// is written; Git remains the final object database and ref transaction.
6
7import (
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)
21
22// 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}
29
30// 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}
39
40// 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}
45
46// ValidateGitBackup validates an import document without writing Git state.
47func ValidateGitBackup(input []byte) (GitBackup, error) {
48 var backup GitBackup
49 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{}, err
68 }
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, nil
77}
78
79func 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 err
86 }
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 nil
92}
93
94func decodeBackupObject(obj BackupObject) ([]byte, error) {
95 var compressed bytes.Buffer
96 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.Buffer
108 if _, err := data.ReadFrom(r); err != nil {
109 _ = r.Close()
110 return nil, err
111 }
112 if err := r.Close(); err != nil {
113 return nil, err
114 }
115 return data.Bytes(), nil
116}
117
118func 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 false
123 }
124 if strings.ContainsAny(s, "~^:?*[\\") {
125 return false
126 }
127 for _, r := range s {
128 if r <= ' ' || r == 127 {
129 return false
130 }
131 }
132 for _, part := range strings.Split(s, "/") {
133 if part == "" || strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".") || strings.HasSuffix(part, ".lock") {
134 return false
135 }
136 }
137 return true
138}
139
140func validIdentifier(id string) bool {
141 if id == "" || id == "." || id == ".." || strings.ContainsAny(id, `/\\\"'$`+"`;&|()<>") {
142 return false
143 }
144 for _, r := range id {
145 if r <= ' ' {
146 return false
147 }
148 }
149 return true
150}
151
152// ImportGitBackup installs a validated backup into empty native repositories.
153// It refuses pre-existing refs or repositories to avoid silently mixing
154// 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 err
159 }
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 err
164 }
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 nil
190}
191
192func 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 candidate
196 }
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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs