// Package hash derives cache keys from file/directory contents. // // For a single file path, Derive returns the same hex digest as // `sha256sum | cut -c1-` (default length 16). This matches // the convention used by the existing .builds/lib/ci-lib.sh shell helpers // it replaces. package hash import ( "crypto/sha256" "fmt" "hash" "io" "io/fs" "os" "path/filepath" "sort" "strings" ) // DefaultLength is the hex-character count after truncation. 16 hex chars = // 64 bits, which matches the existing `cut -c1-16` shell convention. const DefaultLength = 16 // Derive returns a deterministic hex digest of the contents of the given // paths, truncated to length characters. The order of paths matters: // concatenating in flag order is intentional (so callers can express // dependencies like "Dockerfile + context dir" with stable ordering). // // A single regular file produces the same digest as `sha256sum ` // when length is 64 (or its prefix when length<64). A directory is hashed // recursively by walking entries in sorted relative-path order. func Derive(paths []string, length int) (string, error) { if length <= 0 || length > 64 { length = DefaultLength } if len(paths) == 0 { return "", fmt.Errorf("no --hash-from paths given") } final := sha256.New() for _, p := range paths { h, err := hashOne(p) if err != nil { return "", err } // When there is exactly one file path, return its sha256 directly // so the digest matches `sha256sum`. The "wrap into outer sha256" // dance is only needed when combining multiple inputs. if len(paths) == 1 { return h[:length], nil } final.Write([]byte(h)) final.Write([]byte{0}) } return hex(final)[:length], nil } func hashOne(p string) (string, error) { st, err := os.Stat(p) if err != nil { return "", fmt.Errorf("stat %s: %w", p, err) } if st.IsDir() { return hashDir(p) } return hashFile(p) } func hashFile(p string) (string, error) { f, err := os.Open(p) if err != nil { return "", fmt.Errorf("open %s: %w", p, err) } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "", fmt.Errorf("read %s: %w", p, err) } return hex(h), nil } // hashDir walks p in sorted relative-path order and writes // "\0\n" for each regular file into a rolling sha256. // Non-regular entries (symlinks, devices, sockets) are skipped — their // content is not portable and would make the hash brittle. func hashDir(root string) (string, error) { var entries []string err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if !d.Type().IsRegular() { return nil } rel, err := filepath.Rel(root, path) if err != nil { return err } entries = append(entries, rel) return nil }) if err != nil { return "", fmt.Errorf("walk %s: %w", root, err) } sort.Strings(entries) h := sha256.New() for _, rel := range entries { fh, err := hashFile(filepath.Join(root, rel)) if err != nil { return "", err } // Use forward slashes so the digest is OS-independent. h.Write([]byte(filepath.ToSlash(rel))) h.Write([]byte{0}) h.Write([]byte(fh)) h.Write([]byte{'\n'}) } return hex(h), nil } func hex(h hash.Hash) string { const hexdigits = "0123456789abcdef" sum := h.Sum(nil) b := make([]byte, len(sum)*2) for i, x := range sum { b[i*2] = hexdigits[x>>4] b[i*2+1] = hexdigits[x&0x0f] } return string(b) } // ApplyTemplate substitutes {hash} (and {arch} when archSuffix is true) // in keyTemplate with the derived values. If keyTemplate has no {hash} // placeholder and paths is non-empty, the hash is appended before the // final extension. When archSuffix is true, "--" is appended // to the final path component, before its extension. func ApplyTemplate(keyTemplate, derived, goos, goarch string, archSuffix bool) string { key := keyTemplate if derived != "" { if strings.Contains(key, "{hash}") { key = strings.ReplaceAll(key, "{hash}", derived) } else { key = insertBeforeExt(key, "-"+derived) } } if archSuffix { key = insertBeforeExt(key, "-"+goos+"-"+goarch) } return key } func insertBeforeExt(key, suffix string) string { dir, base := filepath.Split(key) ext := filepath.Ext(base) // Handle compound extensions like ".tar.zst" / ".tar.gz". if ext == ".zst" || ext == ".gz" || ext == ".bz2" || ext == ".xz" { if inner := filepath.Ext(strings.TrimSuffix(base, ext)); inner == ".tar" { ext = inner + ext } } stem := strings.TrimSuffix(base, ext) return dir + stem + suffix + ext }