// Package mirror publishes a signed distribution document to ≥3 independent // mirrors (Cloudflare Pages / GitHub / object storage) and // verifies that every mirror serves byte-identical content (doc/05 §1: the // announcement/endpoint channel has the most mirrors and the highest priority). // // Mirrors are modelled filesystem-first so publishing and consistency checks are // fully offline-testable: each Target has a local content root (Dir) that the // real deploy syncs to its platform via an out-of-band SyncCmd (NOT executed by // this package). For read-back/verify and client-style failover fetch, a Target // may also expose a FetchURL (file://, http:// or https://). package mirror import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" ) // Target is one mirror destination. type Target struct { Name string `json:"name"` // Dir is the local content root that Publish writes the object into. Dir string `json:"dir,omitempty"` // FetchURL is the base location used for read-back verification and failover // fetch. If empty, Dir is used. Supports file://, http://, https://. FetchURL string `json:"fetch_url,omitempty"` // SyncCmd documents how the deploy ships Dir to the platform. Recorded for // the operator; this package never runs it. SyncCmd []string `json:"sync_cmd,omitempty"` } // Config is the publish-mirrors config file. type Config struct { Mirrors []Target `json:"mirrors"` } // Result is the outcome of publishing to a single mirror. type Result struct { Name string Path string SHA256 string Err error } // SHA256Hex returns the hex sha256 of content. func SHA256Hex(content []byte) string { sum := sha256.Sum256(content) return hex.EncodeToString(sum[:]) } // Publish writes content as objectName into every target's Dir and returns a // per-target Result. It returns an error if any mirror failed, but still // reports results for all of them. func Publish(content []byte, objectName string, targets []Target) ([]Result, error) { if len(targets) == 0 { return nil, errors.New("mirror: no targets configured") } want := SHA256Hex(content) results := make([]Result, 0, len(targets)) var firstErr error for _, t := range targets { r := Result{Name: t.Name, SHA256: want} if t.Dir == "" { r.Err = fmt.Errorf("mirror %q: dir is empty, cannot publish", t.Name) } else if err := os.MkdirAll(t.Dir, 0o755); err != nil { r.Err = fmt.Errorf("mirror %q: mkdir: %w", t.Name, err) } else { r.Path = filepath.Join(t.Dir, objectName) if err := os.WriteFile(r.Path, content, 0o644); err != nil { r.Err = fmt.Errorf("mirror %q: write: %w", t.Name, err) } } if r.Err != nil && firstErr == nil { firstErr = r.Err } results = append(results, r) } return results, firstErr } // VerifyConsistency fetches objectName from every target and confirms each // matches want (hex sha256). A nil error means every mirror is byte-identical. func VerifyConsistency(objectName string, targets []Target, want string) error { if len(targets) == 0 { return errors.New("mirror: no targets configured") } var problems []string for _, t := range targets { content, err := fetchOne(t, objectName) if err != nil { problems = append(problems, fmt.Sprintf("%s: %v", t.Name, err)) continue } got := SHA256Hex(content) if got != want { problems = append(problems, fmt.Sprintf("%s: sha256 mismatch (got %s want %s)", t.Name, got, want)) } } if len(problems) > 0 { return fmt.Errorf("mirror: consistency check failed:\n %s", strings.Join(problems, "\n ")) } return nil } // Fetch tries each target in order and returns the content from the first one // that both downloads and passes validate. This is the client-side failover: // any single mirror being down/poisoned still lets us fetch from the rest. // validate may be nil. It returns the winning target name. func Fetch(objectName string, targets []Target, validate func([]byte) error) ([]byte, string, error) { if len(targets) == 0 { return nil, "", errors.New("mirror: no targets configured") } var attempts []string for _, t := range targets { content, err := fetchOne(t, objectName) if err != nil { attempts = append(attempts, fmt.Sprintf("%s: %v", t.Name, err)) continue } if validate != nil { if err := validate(content); err != nil { attempts = append(attempts, fmt.Sprintf("%s: %v", t.Name, err)) continue } } return content, t.Name, nil } return nil, "", fmt.Errorf("mirror: all mirrors failed:\n %s", strings.Join(attempts, "\n ")) } func fetchOne(t Target, objectName string) ([]byte, error) { base := t.FetchURL if base == "" { // Fall back to the local Dir. if t.Dir == "" { return nil, errors.New("no fetch_url or dir configured") } return os.ReadFile(filepath.Join(t.Dir, objectName)) } switch { case strings.HasPrefix(base, "file://"): root := strings.TrimPrefix(base, "file://") return os.ReadFile(filepath.Join(root, objectName)) case strings.HasPrefix(base, "http://"), strings.HasPrefix(base, "https://"): return httpGet(joinURL(base, objectName)) default: // Treat as a bare filesystem path. return os.ReadFile(filepath.Join(base, objectName)) } } func joinURL(base, name string) string { return strings.TrimRight(base, "/") + "/" + strings.TrimLeft(name, "/") } func httpGet(u string) ([]byte, error) { client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Get(u) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("http %d", resp.StatusCode) } return io.ReadAll(io.LimitReader(resp.Body, 4<<20)) // 4 MiB cap }