From d29baa0eaf306b9b9181b65d383040be4094db12 Mon Sep 17 00:00:00 2001 From: PIKACHUIM Date: Wed, 9 Sep 2026 15:58:04 +0800 Subject: [PATCH] fix(seed): preserve per-piece MD5 list in CAS payload Encode the slice_md5s and slice_size fields in CAS (single-file and per-file), restore them into SeedFile.Hashes.Pieces.MD5 on decode, and hide the legacy warning when piece hashes are present. Relax the wire-format test to allow the optional extension fields while keeping the five required fields. --- pkg/torrent/seed_test.go | 9 ++- pkg/torrent/torrent.go | 115 ++++++++++++++++++++++++++------------- 2 files changed, 82 insertions(+), 42 deletions(-) diff --git a/pkg/torrent/seed_test.go b/pkg/torrent/seed_test.go index e6b70a932..f9aa7be54 100644 --- a/pkg/torrent/seed_test.go +++ b/pkg/torrent/seed_test.go @@ -60,9 +60,8 @@ func TestCASWireFormatIsLegacyCompatible(t *testing.T) { if err = json.Unmarshal(decodedJSON, &payload); err != nil { t.Fatalf("json.Unmarshal() error = %v", err) } - if len(payload) != 5 { - t.Fatalf("CAS field count = %d, want 5: %#v", len(payload), payload) - } + // The five legacy fields must always be present so the reference client can + // parse the payload. slice_md5s / slice_size are optional extensions. for _, key := range []string{"name", "size", "md5", "sliceMd5", "create_time"} { if _, ok := payload[key]; !ok { t.Fatalf("CAS payload missing %q: %#v", key, payload) @@ -75,6 +74,10 @@ func TestCASWireFormatIsLegacyCompatible(t *testing.T) { if decoded.Files[0].Hashes.MD5 != strings.Repeat("1", 32) { t.Fatalf("DecodeCAS() MD5 = %q", decoded.Files[0].Hashes.MD5) } + // Per-piece MD5 list must round-trip through the slice_md5s extension. + if decoded.Files[0].Hashes.Pieces == nil || len(decoded.Files[0].Hashes.Pieces.MD5) != 2 { + t.Fatalf("DecodeCAS() piece MD5 list = %#v", decoded.Files[0].Hashes.Pieces) + } } func TestTorrentRoundTripPreservesOpenListExtension(t *testing.T) { diff --git a/pkg/torrent/torrent.go b/pkg/torrent/torrent.go index c7340ef9c..4e97200a5 100644 --- a/pkg/torrent/torrent.go +++ b/pkg/torrent/torrent.go @@ -104,22 +104,27 @@ type SeedSource struct { // CASFileEntry describes one file inside a multi-file .cas payload. type CASFileEntry struct { - Name string `json:"name"` - Size int64 `json:"size"` - MD5 string `json:"md5"` - SliceMD5 string `json:"sliceMd5"` - CreateTime string `json:"create_time"` + Name string `json:"name"` + Size int64 `json:"size"` + MD5 string `json:"md5"` + SliceMD5 string `json:"sliceMd5"` + CreateTime string `json:"create_time"` + SliceMD5s []string `json:"slice_md5s,omitempty"` + SliceSize int64 `json:"slice_size,omitempty"` } // CASPayload matches the reference .cas JSON payload. The five legacy fields // describe a single file (byte-for-byte compatible with the reference project); -// the optional "files" array extends it to multi-file seeds. +// the optional "files" array extends it to multi-file seeds, and the optional +// slice_md5s/slice_size preserve the per-piece MD5 list. type CASPayload struct { Name string `json:"name"` Size int64 `json:"size"` MD5 string `json:"md5"` SliceMD5 string `json:"sliceMd5"` CreateTime string `json:"create_time"` + SliceMD5s []string `json:"slice_md5s,omitempty"` + SliceSize int64 `json:"slice_size,omitempty"` Files []CASFileEntry `json:"files,omitempty"` } @@ -816,17 +821,21 @@ func DecodeOSS(data []byte, limits ParseLimits) (*Seed, error) { return &seed, nil } -// buildCASFileEntry computes the reference-compatible five fields for one file. +// buildCASFileEntry computes the reference-compatible five fields for one file, +// preserving the per-piece MD5 list and piece size when available. func buildCASFileEntry(file SeedFile, pieceSize int64) (CASFileEntry, error) { if file.Hashes.MD5 == "" { return CASFileEntry{}, fmt.Errorf("CAS requires a whole-file MD5 for %s", file.Path) } + var sliceMD5s []string + if file.Hashes.Pieces != nil && len(file.Hashes.Pieces.MD5) > 0 { + sliceMD5s = upperStrings(file.Hashes.Pieces.MD5) + } sliceMD5 := strings.ToUpper(file.CASSliceMD5) - if sliceMD5 == "" && file.Hashes.Pieces != nil && len(file.Hashes.Pieces.MD5) > 0 && pieceSize == DefaultPieceSize { - pieces := upperStrings(file.Hashes.Pieces.MD5) - sliceMD5 = pieces[0] - if len(pieces) > 1 { - sliceMD5 = strings.ToUpper(GetMD5Str(strings.Join(pieces, "\n"))) + if sliceMD5 == "" && len(sliceMD5s) > 0 && pieceSize == DefaultPieceSize { + sliceMD5 = sliceMD5s[0] + if len(sliceMD5s) > 1 { + sliceMD5 = strings.ToUpper(GetMD5Str(strings.Join(sliceMD5s, "\n"))) } } if sliceMD5 == "" { @@ -839,10 +848,18 @@ func buildCASFileEntry(file SeedFile, pieceSize int64) (CASFileEntry, error) { if createTime == "" { createTime = fmt.Sprintf("%d", time.Now().Unix()) } - return CASFileEntry{ + entry := CASFileEntry{ Name: path.Base(file.Path), Size: file.Size, MD5: strings.ToUpper(file.Hashes.MD5), SliceMD5: sliceMD5, CreateTime: createTime, - }, nil + } + if len(sliceMD5s) > 0 { + entry.SliceMD5s = sliceMD5s + entry.SliceSize = pieceSize + if entry.SliceSize <= 0 { + entry.SliceSize = DefaultPieceSize + } + } + return entry, nil } // EncodeCAS writes the reference-compatible base64 encoded JSON payload. A @@ -864,6 +881,7 @@ func EncodeCAS(seed *Seed) ([]byte, error) { payload = CASPayload{ Name: entry.Name, Size: entry.Size, MD5: entry.MD5, SliceMD5: entry.SliceMD5, CreateTime: entry.CreateTime, + SliceMD5s: entry.SliceMD5s, SliceSize: entry.SliceSize, } } else { entries := make([]CASFileEntry, 0, len(seed.Files)) @@ -911,41 +929,52 @@ func DecodeCAS(data []byte, limits ParseLimits) (*Seed, error) { return nil, fmt.Errorf("CAS seed exceeds %d files", limits.MaxFiles) } for _, entry := range payload.Files { - if entry.Name == "" || entry.Size < 0 || !validHexHash(entry.MD5, 32) { - return nil, fmt.Errorf("invalid CAS file entry") + file, err := casEntryToSeedFile(entry.Name, entry.Size, entry.MD5, entry.SliceMD5, entry.CreateTime, entry.SliceMD5s) + if err != nil { + return nil, err } - sliceMD5 := entry.SliceMD5 - if sliceMD5 == "" { - sliceMD5 = entry.MD5 + if entry.SliceSize > 0 { + seed.PieceSize = entry.SliceSize } - if !validHexHash(sliceMD5, 32) { - return nil, fmt.Errorf("invalid CAS sliceMd5") - } - seed.Files = append(seed.Files, SeedFile{ - Path: entry.Name, Size: entry.Size, CASCreateTime: entry.CreateTime, - CASSliceMD5: strings.ToLower(sliceMD5), - Hashes: SeedHashes{MD5: strings.ToLower(entry.MD5)}, - }) + seed.Files = append(seed.Files, file) } return seed, ValidateSeed(seed, limits) } - if payload.Name == "" || payload.Size < 0 || !validHexHash(payload.MD5, 32) { - return nil, fmt.Errorf("invalid CAS payload") + file, err := casEntryToSeedFile(payload.Name, payload.Size, payload.MD5, payload.SliceMD5, payload.CreateTime, payload.SliceMD5s) + if err != nil { + return nil, err } - if payload.SliceMD5 == "" { - payload.SliceMD5 = payload.MD5 + if payload.SliceSize > 0 { + seed.PieceSize = payload.SliceSize } - if !validHexHash(payload.SliceMD5, 32) { - return nil, fmt.Errorf("invalid CAS sliceMd5") - } - seed.Files = []SeedFile{{ - Path: payload.Name, Size: payload.Size, CASCreateTime: payload.CreateTime, - CASSliceMD5: strings.ToLower(payload.SliceMD5), - Hashes: SeedHashes{MD5: strings.ToLower(payload.MD5)}, - }} + seed.Files = []SeedFile{file} return seed, ValidateSeed(seed, limits) } +// casEntryToSeedFile converts a CAS payload entry into a SeedFile, restoring the +// per-piece MD5 list when it is present. +func casEntryToSeedFile(name string, size int64, md5Hex, sliceMD5Hex, createTime string, sliceMD5s []string) (SeedFile, error) { + if name == "" || size < 0 || !validHexHash(md5Hex, 32) { + return SeedFile{}, fmt.Errorf("invalid CAS payload") + } + sliceMD5 := sliceMD5Hex + if sliceMD5 == "" { + sliceMD5 = md5Hex + } + if !validHexHash(sliceMD5, 32) { + return SeedFile{}, fmt.Errorf("invalid CAS sliceMd5") + } + file := SeedFile{ + Path: name, Size: size, CASCreateTime: createTime, + CASSliceMD5: strings.ToLower(sliceMD5), + Hashes: SeedHashes{MD5: strings.ToLower(md5Hex)}, + } + if len(sliceMD5s) > 0 { + file.Hashes.Pieces = &SeedPieceHashes{MD5: lowerStrings(sliceMD5s)} + } + return file, nil +} + // DetectFormat determines the seed container from a file name and content. func DetectFormat(fileName string, data []byte) string { switch strings.ToLower(path.Ext(fileName)) { @@ -1384,6 +1413,14 @@ func upperStrings(values []string) []string { return result } +func lowerStrings(values []string) []string { + result := make([]string, len(values)) + for i, value := range values { + result[i] = strings.ToLower(value) + } + return result +} + func uniqueNonEmpty(values []string) []string { seen := make(map[string]struct{}, len(values)) result := make([]string, 0, len(values))