sorcery-go/pkg/cast/summon.go

97 lines
3.5 KiB
Go
Executable File

// Summon: download a source tarball with hash verification.
//
// Replaces the original Bash `summon` which looped `wget` calls. The Go
// version uses net/http so we get redirects and retries.
// The downloaded bytes are streamed through a sha512 hasher in parallel
// with the file write, so we never read the source twice.
//
// If the expected hash is empty we just warn (some DETAILS files omit
// SOURCE_HASH). If it's present and doesn't match, we delete the file
// and fail the cast.
package cast
import (
"context"
"crypto/sha512"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"dcos.net/sorcery-go/pkg/eventbus"
)
// Summon downloads url to destPath and verifies it against expectedHash.
// expectedHash is in the form "sha512:<hex>" or "" to skip verification.
func Summon(ctx context.Context, url, destPath, expectedHash string, bus *eventbus.Bus, taskID string) error {
if bus != nil {
bus.Log(taskID, "↓ Summoning "+url)
}
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "Sorcery-Go/1.0 (Sovereign Coven)")
// NOTE: http.DefaultClient has no per-request timeout of its own.
// The caller is expected to pass a context with a deadline (e.g.,
// context.WithTimeout) that bounds this request. This is appropriate
// because source tarball sizes vary widely — a single fixed timeout
// would be wrong for both tiny configs and multi-GB kernel sources.
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("summon: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("summon: HTTP %d for %s", resp.StatusCode, url)
}
out, err := os.Create(destPath)
if err != nil {
return err
}
defer out.Close()
hasher := sha512.New()
mw := io.MultiWriter(out, hasher)
if _, err := io.Copy(mw, resp.Body); err != nil {
return fmt.Errorf("summon: copy: %w", err)
}
actualHash := "sha512:" + hex.EncodeToString(hasher.Sum(nil))
if expectedHash == "" {
if bus != nil {
bus.Log(taskID, " (no SOURCE_HASH in DETAILS — skipping verification)")
}
return nil
}
// Some SMGL hashes have uppercase hex or no prefix — normalise.
if !hashEqual(actualHash, expectedHash) {
_ = os.Remove(destPath)
return fmt.Errorf("summon: hash mismatch (expected %s, got %s) — file deleted",
expectedHash, actualHash)
}
if bus != nil {
bus.Log(taskID, "✓ Hash verified")
}
return nil
}
// hashEqual compares two "sha512:<hex>" strings case-insensitively.
// If the expected value lacks the "sha512:" prefix we add it.
func hashEqual(actual, expected string) bool {
actual = strings.ToLower(actual)
expected = strings.ToLower(expected)
if !strings.HasPrefix(expected, "sha512:") {
expected = "sha512:" + expected
}
return actual == expected
}