Files
2026-09-27 16:04:02 -06:00

291 lines
9.5 KiB
Go

package tools
import (
"bufio"
"context"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"sysagent/internal/fsutil"
"sysagent/internal/policy"
)
const (
downloadDefaultTimeout = 10 * time.Minute
downloadMaxTimeout = 2 * time.Hour
)
// downloadTool descarga archivos por HTTP(S) con límite de tamaño y
// verificación de checksum (§41, §68).
type downloadTool struct{}
type downloadArgs struct {
URL string `json:"url"`
Dest string `json:"dest"`
SHA256 string `json:"sha256"`
SHA512 string `json:"sha512"`
ChecksumURL string `json:"checksum_url"`
Overwrite bool `json:"overwrite"`
MaxBytes int64 `json:"max_bytes"`
TimeoutSeconds int `json:"timeout_seconds"`
Reason string `json:"reason"`
}
func (downloadTool) Name() string { return "download" }
func (downloadTool) Description() string {
return "Download a file over HTTP(S) to disk (default .agent/downloads/). Verifies sha256/sha512 when given, or looks the file up in a checksum file published by the vendor (checksum_url, e.g. SHA256SUMS). Always verify when the vendor publishes a checksum. Requires ALLOW_NETWORK and ALLOW_DOWNLOADS."
}
func (downloadTool) Schema() map[string]any {
return obj([]string{"url"}, map[string]any{
"url": str("http(s) URL of the file"),
"dest": str("destination file or directory (default PROJECT_ROOT/.agent/downloads/)"),
"sha256": str("expected SHA-256 hex digest"),
"sha512": str("expected SHA-512 hex digest"),
"checksum_url": str("URL of a checksum list (SHA256SUMS, *.sha256, BSD format) that contains this file"),
"overwrite": boolean("replace dest if it exists"),
"max_bytes": num("size limit in bytes (default and maximum: MAX_DOWNLOAD_SIZE of the policy)"),
"timeout_seconds": num("timeout (default 600, max 7200)"),
"reason": str("why (audit log)"),
})
}
func (downloadTool) Run(ctx context.Context, env *Env, raw json.RawMessage) Result {
var a downloadArgs
if err := decode(raw, &a); err != nil {
return fail("download", err)
}
label := "download " + a.URL
if err := env.Guard.CheckCapability("download", policy.CapNetwork, policy.CapDownloads); err != nil {
return fail(label, err)
}
if _, err := validateURL(a.URL); err != nil {
return fail(label, err)
}
if a.SHA256 != "" && a.SHA512 != "" {
return fail(label, errors.New("give sha256 or sha512, not both"))
}
algo, want := "", ""
switch {
case a.SHA256 != "":
algo, want = "sha256", strings.ToLower(strings.TrimSpace(a.SHA256))
case a.SHA512 != "":
algo, want = "sha512", strings.ToLower(strings.TrimSpace(a.SHA512))
}
if want != "" && !validDigest(algo, want) {
return fail(label, fmt.Errorf("invalid %s digest %q", algo, want))
}
limit := env.Policy.MaxDownloadSize
if a.MaxBytes > 0 && a.MaxBytes < limit {
limit = a.MaxBytes
}
timeout := downloadDefaultTimeout
if a.TimeoutSeconds > 0 {
timeout = min(time.Duration(a.TimeoutSeconds)*time.Second, downloadMaxTimeout)
}
// Destino: archivo concreto o directorio (se usa el nombre de la URL).
dest := a.Dest
if dest == "" {
dest = filepath.Join(env.Root, ".agent", "downloads")
}
dest = env.Guard.Resolve(dest, "")
if st, err := os.Stat(dest); err == nil && st.IsDir() || strings.HasSuffix(a.Dest, "/") || a.Dest == "" {
dest = filepath.Join(dest, fileNameFromURL(a.URL))
}
label = fmt.Sprintf("download %s -> %s", a.URL, dest)
fc := &fileChange{env: env, path: dest, reason: a.Reason}
if err := env.Guard.CheckPath(policy.OpWrite, dest, ""); err != nil {
return fail(label, err)
}
if _, err := os.Stat(dest); err == nil && !a.Overwrite {
return fail(label, fmt.Errorf("%s exists; set overwrite=true to replace it", dest))
}
if env.DryRun {
return dryRun(fmt.Sprintf("download %s to %s (limit %d bytes, checksum: %s)", a.URL, dest, limit, orDefault(algo, "none")))
}
// Checksum publicado por el proveedor.
if a.ChecksumURL != "" {
body, resp, _, err := httpGet(ctx, env, a.ChecksumURL, webTimeout, 1<<20, "text/plain")
if err != nil {
return fail(label, fmt.Errorf("failed to fetch checksum file: %w", err))
}
if resp.StatusCode != 200 {
return fail(label, fmt.Errorf("checksum file returned HTTP %d", resp.StatusCode))
}
fAlgo, fWant, ok := findChecksum(string(body), fileNameFromURL(a.URL))
if !ok {
return fail(label, fmt.Errorf("%s not listed in %s", fileNameFromURL(a.URL), a.ChecksumURL))
}
if want != "" && (fAlgo != algo || fWant != want) {
return fail(label, errors.New("the given digest does not match the one in checksum_url"))
}
algo, want = fAlgo, fWant
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fail(label, err)
}
if err := fc.backupIfNeeded(); err != nil {
return fail(label, err)
}
size, sums, ctype, err := fetchToFile(ctx, env, a.URL, dest, limit, timeout, algo, want)
if err != nil {
return fail(label, err)
}
verified := "NOT VERIFIED (no checksum provided; prefer a vendor-published checksum)"
if want != "" {
verified = "verified (" + algo + ")"
}
detail := fmt.Sprintf("saved: %s\nsize: %d bytes\ncontent_type: %s\nsha256: %s\n", dest, size, ctype, sums["sha256"])
if s, ok := sums["sha512"]; ok {
detail += "sha512: " + s + "\n"
}
detail += "checksum: " + verified
res := fc.done(fmt.Sprintf("%s (%d bytes, %s)", label, size, strings.SplitN(verified, " ", 2)[0]), detail)
res.Audit = append(res.Audit, "url", a.URL, "sha256", sums["sha256"], "checksum", strings.SplitN(verified, " ", 2)[0])
return res
}
// fetchToFile descarga a un temporal en el directorio destino calculando los
// hashes mientras se escribe. Solo si el tamaño y el checksum son correctos
// se renombra al destino: una descarga fallida o manipulada nunca queda en
// la ruta final.
func fetchToFile(ctx context.Context, env *Env, rawURL, dest string, limit int64, timeout time.Duration, algo, want string) (int64, map[string]string, string, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return 0, nil, "", err
}
req.Header.Set("User-Agent", env.userAgent())
resp, err := env.httpClient().Do(req)
if err != nil {
return 0, nil, "", fmt.Errorf("GET %s: %w", rawURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return 0, nil, "", fmt.Errorf("GET %s: HTTP %d", rawURL, resp.StatusCode)
}
if resp.ContentLength > limit {
return 0, nil, "", fmt.Errorf("file is %d bytes, above the limit of %d (MAX_DOWNLOAD_SIZE)", resp.ContentLength, limit)
}
tmp, err := os.CreateTemp(filepath.Dir(dest), "."+filepath.Base(dest)+".sysagent-dl-*")
if err != nil {
return 0, nil, "", fmt.Errorf("failed to create temporary file: %w", err)
}
tmpName := tmp.Name()
ok := false
defer func() {
if !ok {
tmp.Close()
os.Remove(tmpName)
}
}()
h256, h512 := sha256.New(), sha512.New()
writers := []io.Writer{tmp, h256}
if algo == "sha512" {
writers = append(writers, h512)
}
n, err := io.Copy(io.MultiWriter(writers...), io.LimitReader(resp.Body, limit+1))
if err != nil {
return 0, nil, "", fmt.Errorf("download interrupted after %d bytes: %w", n, err)
}
if n > limit {
return 0, nil, "", fmt.Errorf("download exceeded the limit of %d bytes (MAX_DOWNLOAD_SIZE); aborted", limit)
}
if err := tmp.Sync(); err != nil {
return 0, nil, "", err
}
sums := map[string]string{"sha256": hex.EncodeToString(h256.Sum(nil))}
if algo == "sha512" {
sums["sha512"] = hex.EncodeToString(h512.Sum(nil))
}
if want != "" && sums[algo] != want {
return 0, nil, "", fmt.Errorf("CHECKSUM MISMATCH: expected %s %s, got %s; the file was discarded", algo, want, sums[algo])
}
if err := tmp.Close(); err != nil {
return 0, nil, "", err
}
if err := os.Chmod(tmpName, 0o644); err != nil {
return 0, nil, "", err
}
if err := os.Rename(tmpName, dest); err != nil {
return 0, nil, "", fmt.Errorf("failed to move download into place: %w", err)
}
ok = true
_ = fsutil.SyncDir(filepath.Dir(dest))
return n, sums, resp.Header.Get("Content-Type"), nil
}
var (
gnuSumRe = regexp.MustCompile(`^([0-9a-fA-F]{64}|[0-9a-fA-F]{128})\s+\*?(.+)$`)
bsdSumRe = regexp.MustCompile(`^(SHA256|SHA512)\s*\((.+)\)\s*=\s*([0-9a-fA-F]+)$`)
)
// findChecksum busca name en un archivo de checksums en formato GNU
// ("<hex> nombre", "<hex> *nombre") o BSD ("SHA256 (nombre) = <hex>").
// Un archivo con un único hash sin nombre (p.ej. "x.tar.gz.sha256") también vale.
func findChecksum(content, name string) (algo, digest string, ok bool) {
var lone []string
sc := bufio.NewScanner(strings.NewReader(content))
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if m := gnuSumRe.FindStringSubmatch(line); m != nil {
if filepath.Base(strings.TrimSpace(m[2])) == name {
return algoFor(m[1]), strings.ToLower(m[1]), true
}
continue
}
if m := bsdSumRe.FindStringSubmatch(line); m != nil {
if filepath.Base(m[2]) == name {
return strings.ToLower(m[1]), strings.ToLower(m[3]), true
}
continue
}
if len(line) == 64 || len(line) == 128 {
if _, err := hex.DecodeString(line); err == nil {
lone = append(lone, line)
}
}
}
if len(lone) == 1 {
return algoFor(lone[0]), strings.ToLower(lone[0]), true
}
return "", "", false
}
func algoFor(hexDigest string) string {
if len(hexDigest) == 128 {
return "sha512"
}
return "sha256"
}
func validDigest(algo, d string) bool {
var h hash.Hash = sha256.New()
if algo == "sha512" {
h = sha512.New()
}
b, err := hex.DecodeString(d)
return err == nil && len(b) == h.Size()
}