first commit

This commit is contained in:
2026-01-19 21:13:01 +09:00
commit c70d24be5c
28 changed files with 3674 additions and 0 deletions

182
metainfo/metainfo.go Normal file
View File

@@ -0,0 +1,182 @@
package metainfo
import (
"crypto/sha1"
"fmt"
"os"
"storrent/bencode"
)
type File struct {
Info Info
InfoHash [20]byte
Size int64
}
type Info struct {
Name string
PieceSize int64
Pieces [][20]byte
Size int64
Files []Entry
}
type Entry struct {
Size int64
Offset int64
Path []string
}
type Segment struct {
File int
Offset int64
Size int64
}
func (i *Info) Segments(off, size int64) []Segment {
var segs []Segment
for idx, f := range i.Files {
end := f.Offset + f.Size
if off >= end {
continue
}
n := min(size, end-off)
segs = append(segs, Segment{File: idx, Offset: off - f.Offset, Size: n})
off += n
size -= n
if size == 0 {
break
}
}
return segs
}
func Parse(path string) (*File, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return ParseBytes(data)
}
func ParseBytes(data []byte) (*File, error) {
v, _, err := bencode.Decode(data)
if err != nil {
return nil, err
}
d, ok := v.(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid torrent dict")
}
f := &File{}
raw, err := findInfoBytes(data)
if err != nil {
return nil, err
}
f.InfoHash = sha1.Sum(raw)
dict, ok := d["info"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid info dict")
}
f.Info, err = parseInfo(dict)
if err != nil {
return nil, err
}
if f.Info.Size > 0 {
f.Size = f.Info.Size
} else {
for _, e := range f.Info.Files {
f.Size += e.Size
}
}
return f, nil
}
func findInfoBytes(data []byte) ([]byte, error) {
if len(data) == 0 || data[0] != 'd' {
return nil, fmt.Errorf("not a dict")
}
i := 1
for i < len(data) && data[i] != 'e' {
k, n, err := bencode.DecodeString(data[i:])
if err != nil {
return nil, err
}
i += n
if k == "info" {
_, n, err := bencode.Decode(data[i:])
if err != nil {
return nil, err
}
return data[i : i+n], nil
}
_, n, err = bencode.Decode(data[i:])
if err != nil {
return nil, err
}
i += n
}
return nil, fmt.Errorf("info not found")
}
func parseInfo(d map[string]any) (Info, error) {
var info Info
name, ok := d["name"].(string)
if !ok {
return info, fmt.Errorf("invalid name")
}
info.Name = name
pl, ok := d["piece length"].(int64)
if !ok {
return info, fmt.Errorf("invalid piece length")
}
info.PieceSize = pl
ps, ok := d["pieces"].(string)
if !ok || len(ps)%20 != 0 {
return info, fmt.Errorf("invalid pieces")
}
npieces := len(ps) / 20
info.Pieces = make([][20]byte, npieces)
for i := range npieces {
copy(info.Pieces[i][:], ps[i*20:(i+1)*20])
}
if n, ok := d["length"].(int64); ok {
info.Size = n
return info, nil
}
fs, ok := d["files"].([]any)
if !ok {
return info, fmt.Errorf("invalid files")
}
off := int64(0)
for _, f := range fs {
fd, ok := f.(map[string]any)
if !ok {
return info, fmt.Errorf("invalid file entry")
}
n, ok := fd["length"].(int64)
if !ok {
return info, fmt.Errorf("invalid file length")
}
list, ok := fd["path"].([]any)
if !ok {
return info, fmt.Errorf("invalid file path")
}
var path []string
for _, p := range list {
s, ok := p.(string)
if !ok {
return info, fmt.Errorf("invalid path element")
}
path = append(path, s)
}
info.Files = append(info.Files, Entry{Size: n, Offset: off, Path: path})
off += n
}
return info, nil
}

141
metainfo/metainfo_test.go Normal file
View File

@@ -0,0 +1,141 @@
package metainfo
import (
"crypto/sha1"
"testing"
"storrent/bencode"
)
func TestParse(t *testing.T) {
tests := []struct {
name string
info map[string]any
wantName string
wantSize int64
}{
{
name: "single file",
info: map[string]any{
"name": "test.txt",
"piece length": int64(16384),
"pieces": string(make([]byte, 20)),
"length": int64(100),
},
wantName: "test.txt",
wantSize: 100,
},
{
name: "multi file",
info: map[string]any{
"name": "dir",
"piece length": int64(16384),
"pieces": string(make([]byte, 20)),
"files": []any{
map[string]any{"length": int64(100), "path": []any{"a.txt"}},
map[string]any{"length": int64(200), "path": []any{"b.txt"}},
},
},
wantName: "dir",
wantSize: 300,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, err := bencode.Encode(map[string]any{"info": tt.info})
if err != nil {
t.Fatalf("Encode: %v", err)
}
m, err := ParseBytes(data)
if err != nil {
t.Fatalf("ParseBytes: %v", err)
}
if m.Info.Name != tt.wantName {
t.Errorf("Name: got %s, want %s", m.Info.Name, tt.wantName)
}
if m.Size != tt.wantSize {
t.Errorf("Size: got %d, want %d", m.Size, tt.wantSize)
}
})
}
}
func TestInfoHash(t *testing.T) {
tests := []struct {
name string
raw string
}{
{
name: "basic",
raw: "d6:lengthi1e4:name4:test12:piece lengthi16384e6:pieces20:01234567890123456789e",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
raw := []byte(tt.raw)
torrent := append([]byte("d4:info"), raw...)
torrent = append(torrent, 'e')
m, err := ParseBytes(torrent)
if err != nil {
t.Fatalf("ParseBytes: %v", err)
}
want := sha1.Sum(raw)
if m.InfoHash != want {
t.Errorf("InfoHash: got %x, want %x", m.InfoHash, want)
}
})
}
}
func TestSegments(t *testing.T) {
tests := []struct {
name string
files []Entry
off int64
size int64
want []Segment
}{
{
name: "single file",
files: []Entry{{Size: 32, Offset: 0}},
off: 0,
size: 32,
want: []Segment{{0, 0, 32}},
},
{
name: "spans two files",
files: []Entry{{Size: 16, Offset: 0}, {Size: 16, Offset: 16}},
off: 0,
size: 32,
want: []Segment{{0, 0, 16}, {1, 0, 16}},
},
{
name: "middle of file",
files: []Entry{{Size: 32, Offset: 0}},
off: 8,
size: 16,
want: []Segment{{0, 8, 16}},
},
{
name: "skip first file",
files: []Entry{{Size: 16, Offset: 0}, {Size: 16, Offset: 16}},
off: 16,
size: 16,
want: []Segment{{1, 0, 16}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := Info{Files: tt.files}
got := info.Segments(tt.off, tt.size)
if len(got) != len(tt.want) {
t.Fatalf("got %d segments, want %d", len(got), len(tt.want))
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("segment %d: got %+v, want %+v", i, got[i], tt.want[i])
}
}
})
}
}

68
metainfo/storage.go Normal file
View File

@@ -0,0 +1,68 @@
package metainfo
import (
"os"
"path/filepath"
)
func (info *Info) Write(dir string, i int, data []byte) error {
off := int64(i) * info.PieceSize
if info.Size > 0 {
return writeAt(filepath.Join(dir, info.Name), off, data)
}
for _, seg := range info.Segments(off, int64(len(data))) {
f := info.Files[seg.File]
path := filepath.Join(dir, info.Name, filepath.Join(f.Path...))
if err := writeAt(path, seg.Offset, data[:seg.Size]); err != nil {
return err
}
data = data[seg.Size:]
}
return nil
}
func (info *Info) Read(dir string, i int, size int64) []byte {
off := int64(i) * info.PieceSize
if info.Size > 0 {
return readAt(filepath.Join(dir, info.Name), off, size)
}
data := make([]byte, size)
pos := int64(0)
for _, seg := range info.Segments(off, size) {
f := info.Files[seg.File]
path := filepath.Join(dir, info.Name, filepath.Join(f.Path...))
chunk := readAt(path, seg.Offset, seg.Size)
if chunk == nil {
return nil
}
copy(data[pos:], chunk)
pos += seg.Size
}
return data
}
func writeAt(path string, off int64, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteAt(data, off)
return err
}
func readAt(path string, off, size int64) []byte {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
data := make([]byte, size)
if _, err := f.ReadAt(data, off); err != nil {
return nil
}
return data
}