diff --git a/share b/share index ca688b5..6c09780 100755 Binary files a/share and b/share differ diff --git a/src/cli/cli.go b/src/cli/cli.go index de674a6..50cba4e 100644 --- a/src/cli/cli.go +++ b/src/cli/cli.go @@ -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() +} diff --git a/src/daemon/client.go b/src/daemon/client.go new file mode 100644 index 0000000..599d0ed --- /dev/null +++ b/src/daemon/client.go @@ -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 +} diff --git a/src/daemon/paths.go b/src/daemon/paths.go new file mode 100644 index 0000000..d8ecf18 --- /dev/null +++ b/src/daemon/paths.go @@ -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 +} diff --git a/src/daemon/protocol.go b/src/daemon/protocol.go new file mode 100644 index 0000000..5daabd9 --- /dev/null +++ b/src/daemon/protocol.go @@ -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"` +} diff --git a/src/daemon/server.go b/src/daemon/server.go new file mode 100644 index 0000000..c6ba780 --- /dev/null +++ b/src/daemon/server.go @@ -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() + } +} diff --git a/src/swarm/chunks.go b/src/swarm/chunks.go index ec89b78..972a2f9 100644 --- a/src/swarm/chunks.go +++ b/src/swarm/chunks.go @@ -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 +} diff --git a/src/swarm/client.go b/src/swarm/client.go index 81c5c77..a4a6966 100644 --- a/src/swarm/client.go +++ b/src/swarm/client.go @@ -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 diff --git a/src/swarm/message.go b/src/swarm/message.go index b263be0..b8705b5 100644 --- a/src/swarm/message.go +++ b/src/swarm/message.go @@ -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.