212 lines
6.1 KiB
Go
212 lines
6.1 KiB
Go
// Package upstream loads and explicitly synchronizes the reviewed Gitea snapshot.
|
|
package upstream
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
type Lock struct {
|
|
Schema int `json:"schema"`
|
|
Repository string `json:"repository"`
|
|
Tag string `json:"tag"`
|
|
Commit string `json:"commit"`
|
|
Files map[string]string `json:"files"`
|
|
}
|
|
|
|
type Snapshot struct {
|
|
Lock Lock
|
|
Files map[string][]byte
|
|
Templates map[string][]byte // mail/... paths, including shared partials
|
|
Locales map[string]map[string]string
|
|
}
|
|
|
|
// AdapterSources also binds the preview's formatting and rendering semantics to
|
|
// the upstream implementation reviewed during this migration. A changed source
|
|
// requires reviewing the adapter before accepting another snapshot.
|
|
var AdapterSources = map[string]string{
|
|
"modules/translation/translation.go": "4217cbd9beb79c0e1be52dd9a7d7705b9ff4becc2d9515d49236db126da0f7ee",
|
|
"modules/translation/i18n/localestore.go": "e55a7832d01753622a7d47e904de95e5aed7ad3535c59bf6ac3fec4346e40e9d",
|
|
"modules/translation/i18n/format.go": "f440236eaf1f73fe0a9aa36a2c036a4b34c6ab28c9e4cf971bef7fceee701cb4",
|
|
"modules/templates/mail.go": "fb8204b79f700f88c7c3f0da16e50c3d678a2216e62e98f712805ed1d42ab3a9",
|
|
}
|
|
|
|
func Digest(data []byte) string {
|
|
h := sha256.Sum256(data)
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
func SafePath(p string) bool {
|
|
return p != "." && fs.ValidPath(p) && !strings.ContainsAny(p, "\\:")
|
|
}
|
|
|
|
func Load(dir string) (*Snapshot, error) {
|
|
s, err := loadFiles(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s, s.validate()
|
|
}
|
|
|
|
// Ownership/checksum validation is separate so explicitly syncing after an
|
|
// adapter review can replace an older, still intact snapshot.
|
|
func loadFiles(dir string) (*Snapshot, error) {
|
|
data, err := os.ReadFile(filepath.Join(dir, "lock.json"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("snapshot lock: %w (run upstream sync explicitly)", err)
|
|
}
|
|
s := &Snapshot{Files: map[string][]byte{}}
|
|
if err := json.Unmarshal(data, &s.Lock); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.Lock.validateIdentity(); err != nil {
|
|
return nil, err
|
|
}
|
|
for p, expected := range s.Lock.Files {
|
|
if !SafePath(p) {
|
|
return nil, fmt.Errorf("unsafe snapshot path %q", p)
|
|
}
|
|
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(p)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if Digest(b) != expected {
|
|
return nil, fmt.Errorf("snapshot checksum mismatch: %s", p)
|
|
}
|
|
s.Files[p] = b
|
|
}
|
|
// Unlocked files must not silently become input to a build.
|
|
err = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.Type()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("snapshot symlinks are unsupported: %s", p)
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(dir, p)
|
|
rel = filepath.ToSlash(rel)
|
|
if rel != "lock.json" {
|
|
if _, ok := s.Files[rel]; !ok {
|
|
return fmt.Errorf("unlocked snapshot file: %s", rel)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (lock Lock) validateIdentity() error {
|
|
if lock.Schema != 1 || lock.Repository != "https://github.com/go-gitea/gitea" || !validTag(lock.Tag) || len(lock.Commit) != 40 || len(lock.Files) == 0 {
|
|
return fmt.Errorf("invalid snapshot identity or unsupported version")
|
|
}
|
|
if _, err := hex.DecodeString(lock.Commit); err != nil {
|
|
return fmt.Errorf("invalid snapshot commit: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Snapshot) validate() error {
|
|
if err := s.Lock.validateIdentity(); err != nil {
|
|
return err
|
|
}
|
|
for p, hash := range AdapterSources {
|
|
if Digest(s.Files[p]) != hash {
|
|
return fmt.Errorf("upstream adapter changed: %s; review preview semantics before syncing", p)
|
|
}
|
|
}
|
|
if len(s.Files["LICENSE"]) == 0 {
|
|
return fmt.Errorf("missing upstream license")
|
|
}
|
|
s.Templates = map[string][]byte{}
|
|
s.Locales = map[string]map[string]string{}
|
|
for p, b := range s.Files {
|
|
switch {
|
|
case p == "public/assets/img/favicon.png":
|
|
if len(b) == 0 {
|
|
return fmt.Errorf("missing preview favicon")
|
|
}
|
|
case strings.HasPrefix(p, "templates/mail/") && strings.HasSuffix(p, ".tmpl"):
|
|
s.Templates[strings.TrimPrefix(p, "templates/")] = b
|
|
case strings.HasPrefix(p, "options/locale/locale_") && strings.HasSuffix(p, ".json"):
|
|
locale := strings.TrimSuffix(strings.TrimPrefix(p, "options/locale/locale_"), ".json")
|
|
values, err := DecodeLocale(b)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", p, err)
|
|
}
|
|
s.Locales[locale] = values
|
|
default:
|
|
if p != "LICENSE" {
|
|
if _, ok := AdapterSources[p]; !ok {
|
|
return fmt.Errorf("unsupported snapshot file: %s", p)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if len(s.Entrypoints()) == 0 || len(s.Templates["mail/base/head.tmpl"]) == 0 || len(s.Templates["mail/base/footer.tmpl"]) == 0 || len(s.Locales["en-US"]) == 0 {
|
|
return fmt.Errorf("snapshot is missing entrypoints, shared partials, or English translations")
|
|
}
|
|
return ValidateKeys(s)
|
|
}
|
|
|
|
// DecodeLocale mirrors Gitea's flat or one-level nested JSON catalog layout.
|
|
func DecodeLocale(data []byte) (map[string]string, error) {
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
result := map[string]string{}
|
|
for k, v := range raw {
|
|
switch v := v.(type) {
|
|
case string:
|
|
result[k] = v
|
|
case map[string]any:
|
|
for inner, value := range v {
|
|
str, ok := value.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("unsupported translation value: %s.%s", k, inner)
|
|
}
|
|
result[k+"."+inner] = str
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported translation value: %s", k)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Snapshot) Entrypoints() []string {
|
|
var result []string
|
|
for p := range s.Templates {
|
|
if !strings.HasPrefix(p, "mail/base/") {
|
|
result = append(result, p)
|
|
}
|
|
}
|
|
sort.Strings(result)
|
|
return result
|
|
}
|
|
|
|
func (s *Snapshot) Languages() []string {
|
|
var langs []string
|
|
for l := range s.Locales {
|
|
langs = append(langs, l)
|
|
}
|
|
sort.Strings(langs)
|
|
return langs
|
|
}
|
|
|
|
func TemplateID(p string) string { return strings.TrimSuffix(path.Base(p), ".tmpl") }
|