Turn the client into a background daemon; add per-chunk hash verification

Daemon (src/daemon): a Unix-socket control server holds every room this
machine is participating in. "share send"/"share <code>" resolve paths to
absolute, ask the daemon to join (auto-starting it if needed), and exit
immediately instead of blocking. "share stop" leaves every room and shuts
the daemon down; a stale socket from a crashed daemon self-heals on next
start.

swarm.Client is now a Start/Wait/Stop handle instead of a single blocking
Run, so the daemon can hold many rooms concurrently.

Also added per-chunk sha256 verification: the original seeder publishes a
hash for every chunk alongside the whole-file hash, and each chunk is
checked against it the moment it arrives — before it's written to disk or
reshared to other peers — rather than only catching corruption after the
whole file is assembled.
This commit is contained in:
2026-07-20 11:45:33 +08:00
parent 34ce1f4e65
commit 26cef8ee12
9 changed files with 522 additions and 58 deletions
BIN
View File
Binary file not shown.
+82 -13
View File
@@ -4,12 +4,13 @@ import (
"flag"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"github.com/tzgyn/share/src/daemon"
log "github.com/tzgyn/share/src/logger"
"github.com/tzgyn/share/src/models"
"github.com/tzgyn/share/src/swarm"
"github.com/tzgyn/share/src/tcp"
"github.com/tzgyn/share/src/utils"
)
@@ -20,6 +21,9 @@ const usage = `share [GLOBAL OPTIONS] [COMMAND] [COMMAND OPTIONS] [filename(s)]
a torrent-like swarm: everyone who joins a room can send and receive
A background daemon does the actual work: joining a room hands it off to
the daemon (starting it if it isn't already running) and exits right away.
USAGE EXAMPLES:
Seed a file into a new room (prints a code):
share send file.txt
@@ -30,9 +34,14 @@ USAGE EXAMPLES:
Join a room to receive whatever's shared there (and help re-share it):
share secret-code
Stop the background daemon (leaves every room it's in):
share stop
COMMANDS:
send seed file(s) into a room (see options with: share send -h)
relay start your own relay (optional; see options with: share relay -h)
stop stop the background daemon and leave every room
daemon run the background daemon in the foreground (mostly for debugging)
Run "share [command] -h" for the options of a given command.
`
@@ -66,6 +75,10 @@ func Run() error {
return runSend(splice(args, i))
case "relay":
return runRelay(splice(args, i))
case "stop":
return runStop(splice(args, i))
case "daemon":
return runDaemon(splice(args, i))
}
}
return runReceive(args)
@@ -119,19 +132,32 @@ func runSend(args []string) error {
if len(files) == 0 {
return fmt.Errorf("no files specified; usage: share send file1 [file2 ...]")
}
absFiles := make([]string, len(files))
for i, f := range files {
abs, err := filepath.Abs(f)
if err != nil {
return fmt.Errorf("could not resolve %s: %w", f, err)
}
absFiles[i] = abs
}
roomCode := *code
if roomCode == "" {
roomCode = utils.GetRandomName()
}
if err := daemon.EnsureRunning(*debug); err != nil {
return err
}
if err := daemon.Join(*relay, roomCode, absFiles, "", false); err != nil {
return err
}
fmt.Printf("Code is: %s\n", roomCode)
fmt.Println("On the other computer, run:")
fmt.Printf("\tshare %s\n", roomCode)
return swarm.Run(swarm.Options{
RelayAddress: *relay,
Code: roomCode,
Files: files,
})
fmt.Println("Seeding in the background; run \"share stop\" to stop.")
return nil
}
func runReceive(args []string) error {
@@ -155,13 +181,23 @@ func runReceive(args []string) error {
fs.Usage()
return nil
}
absOut, err := filepath.Abs(*out)
if err != nil {
return fmt.Errorf("could not resolve %s: %w", *out, err)
}
return swarm.Run(swarm.Options{
RelayAddress: *relay,
Code: code,
OutDir: *out,
OneTime: *oneTime,
})
if err := daemon.EnsureRunning(*debug); err != nil {
return err
}
if err := daemon.Join(*relay, code, nil, absOut, *oneTime); err != nil {
return err
}
fmt.Printf("Joined room %s; downloading in the background.\n", code)
if !*oneTime {
fmt.Println("It'll keep reseeding to others; run \"share stop\" to stop.")
}
return nil
}
func runRelay(args []string) error {
@@ -190,3 +226,36 @@ func runRelay(args []string) error {
log.Infof("starting share relay version %v", Version)
return tcp.Run(debugString, *host, strconv.Itoa(*port))
}
func runStop(args []string) error {
fs := flag.NewFlagSet("stop", flag.ExitOnError)
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "share stop")
}
if err := fs.Parse(args); err != nil {
return err
}
if err := daemon.Stop(); err != nil {
fmt.Println(err)
return nil
}
fmt.Println("Daemon stopped.")
return nil
}
func runDaemon(args []string) error {
fs := flag.NewFlagSet("daemon", flag.ExitOnError)
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "share daemon [options]")
fs.PrintDefaults()
}
debug := fs.Bool("debug", false, "toggle debug mode")
quiet := fs.Bool("quiet", false, "disable all output")
if err := fs.Parse(args); err != nil {
return err
}
setDebugLevel(*quiet, *debug)
return daemon.Serve()
}
+116
View File
@@ -0,0 +1,116 @@
package daemon
import (
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"syscall"
"time"
)
// IsRunning reports whether a daemon is currently listening on the
// control socket.
func IsRunning() bool {
path, err := socketPath()
if err != nil {
return false
}
conn, err := net.DialTimeout("unix", path, 200*time.Millisecond)
if err != nil {
return false
}
conn.Close()
return true
}
// EnsureRunning starts the daemon in the background if one isn't already
// running, and waits until its control socket is reachable.
func EnsureRunning(debug bool) error {
if IsRunning() {
return nil
}
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("could not locate the share binary to start the daemon: %w", err)
}
args := []string{"daemon"}
if debug {
args = append(args, "--debug")
}
cmd := exec.Command(exe, args...)
cmd.Stdin = nil
if lf, err := logPath(); err == nil {
if out, err := os.OpenFile(lf, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); err == nil {
cmd.Stdout = out
cmd.Stderr = out
}
}
// detach so the daemon survives this (short-lived) CLI process exiting
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return fmt.Errorf("could not start daemon: %w", err)
}
_ = cmd.Process.Release()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if IsRunning() {
return nil
}
time.Sleep(50 * time.Millisecond)
}
return fmt.Errorf("daemon did not start in time")
}
// Join asks the running daemon to start participating in a room. files
// and outDir must already be absolute paths — the daemon may have been
// started long ago from a different working directory.
func Join(relayAddress, code string, files []string, outDir string, oneTime bool) error {
return call(request{
Action: "join",
Join: &joinRequest{
RelayAddress: relayAddress,
Code: code,
Files: files,
OutDir: outDir,
OneTime: oneTime,
},
})
}
// Stop tells the running daemon to leave every room it's in and exit.
func Stop() error {
if !IsRunning() {
return fmt.Errorf("daemon is not running")
}
return call(request{Action: "stop"})
}
func call(req request) error {
path, err := socketPath()
if err != nil {
return err
}
conn, err := net.DialTimeout("unix", path, 2*time.Second)
if err != nil {
return fmt.Errorf("could not reach daemon: %w", err)
}
defer conn.Close()
if err := json.NewEncoder(conn).Encode(req); err != nil {
return err
}
var resp response
if err := json.NewDecoder(conn).Decode(&resp); err != nil {
return err
}
if !resp.OK {
return fmt.Errorf("%s", resp.Error)
}
return nil
}
+23
View File
@@ -0,0 +1,23 @@
package daemon
import (
"path/filepath"
"github.com/tzgyn/share/src/utils"
)
func socketPath() (string, error) {
dir, err := utils.GetConfigDir(true)
if err != nil {
return "", err
}
return filepath.Join(dir, "daemon.sock"), nil
}
func logPath() (string, error) {
dir, err := utils.GetConfigDir(true)
if err != nil {
return "", err
}
return filepath.Join(dir, "daemon.log"), nil
}
+25
View File
@@ -0,0 +1,25 @@
package daemon
// request/response are the only vocabulary spoken over the control
// socket between a CLI invocation and the background daemon.
type request struct {
Action string `json:"action"` // "join" or "stop"
Join *joinRequest `json:"join,omitempty"`
}
// joinRequest asks the daemon to start participating in a room. Paths
// must already be absolute — the daemon may have been started long ago
// from a different working directory than whichever CLI invocation is
// submitting this request.
type joinRequest struct {
RelayAddress string `json:"relay"`
Code string `json:"code"`
Files []string `json:"files,omitempty"`
OutDir string `json:"out,omitempty"`
OneTime bool `json:"oneTime,omitempty"`
}
type response struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
+142
View File
@@ -0,0 +1,142 @@
package daemon
import (
"encoding/json"
"fmt"
"net"
"os"
"sync"
log "github.com/tzgyn/share/src/logger"
"github.com/tzgyn/share/src/swarm"
)
type server struct {
mu sync.Mutex
rooms map[string]*swarm.Client
}
// Serve runs the daemon: it listens on the control socket and services
// join/stop requests until a stop is requested, then tears down every
// room and returns. It's the whole daemon process's job — normally
// started in the background by EnsureRunning, or run in the foreground
// directly for debugging.
func Serve() error {
path, err := socketPath()
if err != nil {
return err
}
if _, statErr := os.Stat(path); statErr == nil {
if IsRunning() {
return fmt.Errorf("a daemon is already running")
}
// stale socket left behind by a daemon that didn't shut down cleanly
os.Remove(path)
}
l, err := net.Listen("unix", path)
if err != nil {
return fmt.Errorf("could not listen on %s: %w", path, err)
}
defer os.Remove(path)
s := &server{rooms: make(map[string]*swarm.Client)}
log.Infof("daemon listening on %s", path)
for {
conn, err := l.Accept()
if err != nil {
return err
}
if s.handleConn(conn) {
s.stopAll()
return nil
}
}
}
// handleConn services one control request and reports whether the
// daemon should shut down after responding (true only for "stop").
func (s *server) handleConn(conn net.Conn) (shutdown bool) {
defer conn.Close()
var req request
if err := json.NewDecoder(conn).Decode(&req); err != nil {
respond(conn, response{OK: false, Error: err.Error()})
return false
}
switch req.Action {
case "join":
if req.Join == nil {
respond(conn, response{OK: false, Error: "missing join request"})
return false
}
if err := s.join(*req.Join); err != nil {
respond(conn, response{OK: false, Error: err.Error()})
return false
}
respond(conn, response{OK: true})
return false
case "stop":
respond(conn, response{OK: true})
return true
default:
respond(conn, response{OK: false, Error: fmt.Sprintf("unknown action %q", req.Action)})
return false
}
}
func respond(conn net.Conn, resp response) {
if err := json.NewEncoder(conn).Encode(resp); err != nil {
log.Debugf("could not send response: %v", err)
}
}
func (s *server) join(req joinRequest) error {
s.mu.Lock()
if _, already := s.rooms[req.Code]; already {
s.mu.Unlock()
return fmt.Errorf("already in room %s", req.Code)
}
s.mu.Unlock()
cl, err := swarm.Start(swarm.Options{
RelayAddress: req.RelayAddress,
Code: req.Code,
Files: req.Files,
OutDir: req.OutDir,
OneTime: req.OneTime,
})
if err != nil {
return err
}
s.mu.Lock()
s.rooms[req.Code] = cl
s.mu.Unlock()
log.Infof("joined room %s", req.Code)
go func() {
err := cl.Wait()
s.mu.Lock()
delete(s.rooms, req.Code)
s.mu.Unlock()
if err != nil {
log.Errorf("room %s ended: %v", req.Code, err)
} else {
log.Infof("room %s finished", req.Code)
}
}()
return nil
}
func (s *server) stopAll() {
s.mu.Lock()
defer s.mu.Unlock()
for code, cl := range s.rooms {
log.Infof("leaving room %s", code)
cl.Stop()
}
}
+28
View File
@@ -2,6 +2,7 @@ package swarm
import (
"bytes"
"crypto/sha256"
"io"
"os"
"sync"
@@ -158,3 +159,30 @@ func (cs *ChunkStore) VerifyHash(expected []byte) (bool, error) {
}
return bytes.Equal(got, expected), nil
}
// ChunkHash returns the sha256 hash of one chunk's content. A
// cryptographic hash is used here (rather than the faster xxhash used
// for the whole-file sanity check) because this is the check that
// actually has to resist a peer deliberately handing out wrong data for
// a chunk, not just catch accidental corruption.
func ChunkHash(data []byte) []byte {
h := sha256.Sum256(data)
return h[:]
}
// ChunkHashes returns the hash of every chunk this store currently has,
// in offset order. Only meaningful for a store that has every chunk
// (i.e. a seeder's own file) — it's how the original hashes get
// published so every other peer can verify each chunk as it arrives.
func (cs *ChunkStore) ChunkHashes() ([][]byte, error) {
n := NumChunks(cs.size)
hashes := make([][]byte, n)
for i := int64(0); i < n; i++ {
data, err := cs.ReadChunk(i * ChunkSize)
if err != nil {
return nil, err
}
hashes[i] = ChunkHash(data)
}
return hashes, nil
}
+94 -38
View File
@@ -1,6 +1,7 @@
package swarm
import (
"bytes"
"crypto/rand"
"encoding/hex"
"fmt"
@@ -30,15 +31,6 @@ type Options struct {
OneTime bool // exit once everything currently known is complete, instead of staying to reseed
}
// Run joins the room described by opts and blocks until either an error
// occurs, or (with OneTime set) every file known in the room has been
// fully downloaded and verified. Without OneTime, Run only returns on
// error — like a real torrent peer, it stays to keep reseeding whatever
// it has to others until stopped.
func Run(opts Options) error {
return newClient(opts).run()
}
type sharedFile struct {
entry FileManifestEntry // immutable identity: ID, Name, Size, Hash
store *ChunkStore
@@ -54,7 +46,10 @@ func newPeerLink(c *comm.Comm, key []byte) *peerLink {
return &peerLink{conn: c, key: key, remote: c.Connection().RemoteAddr().String()}
}
type client struct {
// Client is a handle to one room this process is participating in. Start
// returns one once the room is joined; the actual chunk exchange runs in
// a background goroutine until Wait returns.
type Client struct {
opts Options
selfID string
@@ -65,16 +60,26 @@ type client struct {
dialedMu sync.Mutex
dialed map[string]bool // relay peer IDs already dialed, so we don't redial every peer-list update
listener net.Listener
relayConn *comm.Comm
peerUpdates <-chan []tcp.PeerInfo
stopOnce sync.Once
stopCh chan struct{}
done chan error // buffered 1; sent to exactly once when the room's loop exits
}
func newClient(opts Options) *client {
return &client{
func newClient(opts Options) *Client {
return &Client{
opts: opts,
selfID: randomID(),
files: make(map[string]*sharedFile),
pending: make(map[string]map[int64]*peerLink),
links: make(map[*peerLink]bool),
dialed: make(map[string]bool),
stopCh: make(chan struct{}),
done: make(chan error, 1),
}
}
@@ -86,31 +91,67 @@ func randomID() string {
return hex.EncodeToString(b)
}
func (cl *client) run() error {
for _, path := range cl.opts.Files {
// Start seeds any local Files, opens a listener for other peers, and joins
// the relay room described by opts. It returns as soon as that initial
// join succeeds or fails — it does not wait for any transfer to complete.
// The room then keeps running in the background (dialing peers, trading
// chunks) until Stop is called or, with OneTime set, until everything
// currently known has been downloaded; use Wait to block for that.
func Start(opts Options) (*Client, error) {
cl := newClient(opts)
for _, path := range opts.Files {
if err := cl.addSeedFile(path); err != nil {
return err
return nil, err
}
}
listener, listenAddr, err := startListener()
if err != nil {
return fmt.Errorf("could not open a listener for peers: %w", err)
return nil, fmt.Errorf("could not open a listener for peers: %w", err)
}
defer listener.Close()
go cl.acceptLoop(listener)
relayConn, peerUpdates, err := tcp.JoinRoom(cl.opts.RelayAddress, cl.opts.Code, tcp.PeerInfo{ID: cl.selfID, Addr: listenAddr})
relayConn, peerUpdates, err := tcp.JoinRoom(opts.RelayAddress, opts.Code, tcp.PeerInfo{ID: cl.selfID, Addr: listenAddr})
if err != nil {
return fmt.Errorf("could not join room: %w", err)
listener.Close()
return nil, fmt.Errorf("could not join room: %w", err)
}
defer relayConn.Close()
cl.listener = listener
cl.relayConn = relayConn
cl.peerUpdates = peerUpdates
go cl.acceptLoop(listener)
go cl.loop()
return cl, nil
}
// Wait blocks until this room's participation ends, returning why: nil
// for a clean stop or (with OneTime) a completed transfer, or an error if
// the relay connection was lost.
func (cl *Client) Wait() error {
return <-cl.done
}
// Stop ends this room's participation. Wait will then return nil.
func (cl *Client) Stop() {
cl.stopOnce.Do(func() { close(cl.stopCh) })
}
func (cl *Client) finish(err error) {
cl.listener.Close()
cl.relayConn.Close()
cl.done <- err
}
func (cl *Client) loop() {
for {
select {
case peers, ok := <-peerUpdates:
case peers, ok := <-cl.peerUpdates:
if !ok {
return fmt.Errorf("lost connection to relay")
cl.finish(fmt.Errorf("lost connection to relay"))
return
}
for _, p := range peers {
if p.ID == cl.selfID || p.Addr == "" {
@@ -126,13 +167,17 @@ func (cl *client) run() error {
}
case <-time.After(500 * time.Millisecond):
if cl.opts.OneTime && cl.allKnownFilesComplete() {
return nil
cl.finish(nil)
return
}
case <-cl.stopCh:
cl.finish(nil)
return
}
}
}
func (cl *client) addSeedFile(path string) error {
func (cl *Client) addSeedFile(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
@@ -144,17 +189,22 @@ func (cl *client) addSeedFile(path string) error {
if err != nil {
return err
}
store := NewSeedChunkStore(path, info.Size())
chunkHashes, err := store.ChunkHashes()
if err != nil {
return err
}
name := filepath.Base(path)
id := utils.SHA256(fmt.Sprintf("%s:%d", name, info.Size()))
cl.files[id] = &sharedFile{
entry: FileManifestEntry{ID: id, Name: name, Size: info.Size(), Hash: hash},
store: NewSeedChunkStore(path, info.Size()),
entry: FileManifestEntry{ID: id, Name: name, Size: info.Size(), Hash: hash, ChunkHashes: chunkHashes},
store: store,
}
log.Infof("seeding %s (%s)", name, utils.ByteCountDecimal(info.Size()))
return nil
}
func (cl *client) allKnownFilesComplete() bool {
func (cl *Client) allKnownFilesComplete() bool {
cl.mu.Lock()
defer cl.mu.Unlock()
if len(cl.files) == 0 {
@@ -177,7 +227,7 @@ func startListener() (net.Listener, string, error) {
return l, net.JoinHostPort(utils.LocalIP(), strconv.Itoa(port)), nil
}
func (cl *client) acceptLoop(l net.Listener) {
func (cl *Client) acceptLoop(l net.Listener) {
for {
conn, err := l.Accept()
if err != nil {
@@ -196,7 +246,7 @@ func (cl *client) acceptLoop(l net.Listener) {
}
}
func (cl *client) dialPeer(p tcp.PeerInfo) {
func (cl *Client) dialPeer(p tcp.PeerInfo) {
conn, err := net.DialTimeout("tcp", p.Addr, 5*time.Second)
if err != nil {
log.Debugf("could not dial peer %s at %s: %v", p.ID, p.Addr, err)
@@ -212,7 +262,7 @@ func (cl *client) dialPeer(p tcp.PeerInfo) {
cl.handlePeerLink(newPeerLink(c, key))
}
func (cl *client) handlePeerLink(link *peerLink) {
func (cl *Client) handlePeerLink(link *peerLink) {
cl.mu.Lock()
cl.links[link] = true
cl.mu.Unlock()
@@ -250,7 +300,7 @@ func (cl *client) handlePeerLink(link *peerLink) {
}
}
func (cl *client) sendManifest(link *peerLink) error {
func (cl *Client) sendManifest(link *peerLink) error {
cl.mu.Lock()
files := make([]FileManifestEntry, 0, len(cl.files))
for _, f := range cl.files {
@@ -262,7 +312,7 @@ func (cl *client) sendManifest(link *peerLink) error {
return sendMessage(link.conn, link.key, Message{Type: TypeManifest, Files: files})
}
func (cl *client) handleMessage(link *peerLink, msg Message) error {
func (cl *Client) handleMessage(link *peerLink, msg Message) error {
switch msg.Type {
case TypeManifest:
for _, entry := range msg.Files {
@@ -279,7 +329,7 @@ func (cl *client) handleMessage(link *peerLink, msg Message) error {
return nil
}
func (cl *client) learnFile(entry FileManifestEntry) {
func (cl *Client) learnFile(entry FileManifestEntry) {
cl.mu.Lock()
_, known := cl.files[entry.ID]
cl.mu.Unlock()
@@ -294,7 +344,7 @@ func (cl *client) learnFile(entry FileManifestEntry) {
}
cl.mu.Lock()
cl.files[entry.ID] = &sharedFile{
entry: FileManifestEntry{ID: entry.ID, Name: entry.Name, Size: entry.Size, Hash: entry.Hash},
entry: FileManifestEntry{ID: entry.ID, Name: entry.Name, Size: entry.Size, Hash: entry.Hash, ChunkHashes: entry.ChunkHashes},
store: store,
}
cl.mu.Unlock()
@@ -303,7 +353,7 @@ func (cl *client) learnFile(entry FileManifestEntry) {
// considerRequests asks link for any of the given offsets of fileID that
// we're still missing and haven't already asked someone else for.
func (cl *client) considerRequests(fileID string, offsets []int64, link *peerLink) {
func (cl *Client) considerRequests(fileID string, offsets []int64, link *peerLink) {
cl.mu.Lock()
f, ok := cl.files[fileID]
if !ok {
@@ -334,7 +384,7 @@ func (cl *client) considerRequests(fileID string, offsets []int64, link *peerLin
}
}
func (cl *client) serveRequest(link *peerLink, fileID string, offset int64) error {
func (cl *Client) serveRequest(link *peerLink, fileID string, offset int64) error {
cl.mu.Lock()
f, ok := cl.files[fileID]
cl.mu.Unlock()
@@ -349,7 +399,7 @@ func (cl *client) serveRequest(link *peerLink, fileID string, offset int64) erro
return sendMessage(link.conn, link.key, Message{Type: TypeChunk, FileID: fileID, Offset: offset, Data: data})
}
func (cl *client) receiveChunk(link *peerLink, fileID string, offset int64, data []byte) error {
func (cl *Client) receiveChunk(link *peerLink, fileID string, offset int64, data []byte) error {
cl.mu.Lock()
f, ok := cl.files[fileID]
if ok {
@@ -364,6 +414,12 @@ func (cl *client) receiveChunk(link *peerLink, fileID string, offset int64, data
return nil
}
idx := int(offset / ChunkSize)
if idx < len(f.entry.ChunkHashes) && !bytes.Equal(ChunkHash(data), f.entry.ChunkHashes[idx]) {
log.Warnf("chunk %d/%d of %s from %s failed hash verification; discarding", idx+1, NumChunks(f.entry.Size), f.entry.Name, link.remote)
return nil
}
log.Infof("received %s chunk %d/%d from %s", f.entry.Name, offset/ChunkSize+1, NumChunks(f.entry.Size), link.remote)
if err := f.store.WriteChunk(offset, data); err != nil {
return err
+12 -7
View File
@@ -24,14 +24,19 @@ const (
)
// FileManifestEntry describes one file known to the swarm: its identity,
// size, whole-file hash (for final verification), and which chunk offsets
// the sender of this manifest currently holds.
// size, whole-file hash (for a final sanity check once complete),
// per-chunk hashes (so each chunk can be verified the moment it arrives,
// before it's trusted or reshared), and which chunk offsets the sender of
// this manifest currently holds. ID/Name/Size/Hash/ChunkHashes are set
// once by the original seeder and simply carried along unchanged as the
// file propagates through the swarm; only Have changes per sender.
type FileManifestEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
Hash []byte `json:"hash"`
Have []int64 `json:"have"`
ID string `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
Hash []byte `json:"hash"`
ChunkHashes [][]byte `json:"chunkHashes"`
Have []int64 `json:"have"`
}
// Message is what flows over an established, encrypted peer link.