| 1 | package 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 | |
| 7 | import ( |
| 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. |
| 23 | type 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. |
| 31 | type 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. |
| 41 | type GitBackup struct { |
| 42 | Format string `json:"format"` |
| 43 | Repositories []BackupRepository `json:"repositories"` |
| 44 | } |
| 45 | |
| 46 | // ValidateGitBackup validates an import document without writing Git state. |
| 47 | func 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 | |
| 79 | func 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 | |
| 94 | func 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 | |
| 118 | func isObjectID64(s string) bool { return len(s) == 64 && strings.Trim(s, "0123456789abcdef" ) == "" } |
| 119 | func isPubKey(s string) bool { return len(s) == 64 && strings.Trim(s, "0123456789abcdef" ) == "" } |
| 120 | func 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 | |
| 140 | func 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. |
| 155 | func (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 | |
| 192 | func 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 | } |