Artifact 4a1e63306cc484bf4203cb9a265e683e91a8fd1dbffa594cfe9841418a221ad8:
- File
internal/xfer/decode.go
— part of check-in
[d3fb5397c8]
at
2026-10-13 15:15:43
on branch main
— fix(sync): answer a client clone_seqno with bad command; make Clone's phantom pull opt-in (#427)
* fix(sync): answer a client clone_seqno with bad command; make Clone's phantom pull opt-in
Two deliberate #255 deviations from sync-protocol-01 move to conformance.
SP-5.2-6: the server now answers a client-sent clone_seqno with "error bad command" and processes the cards after it, as Section 5.2 requires for a reply-only card and as fossil's page_xfer does.
SP-8.2-5: Clone now sends nothing after clone_seqno 0 and its cleanup round, so by default it leaves the phantoms a clone produces in place, as fossil(1)'s clone does. The follow-up pull that fetched them is now opt-in through CloneOpts.PullPhantoms, whose doc comment says enabling it departs from Section 8.2.
Both cases leave the sync deviations map. Tracked in #424.
* fix(sync): quote a client clone_seqno as received in bad command; keep DST coverage of the opt-in pull
Tracked in #424. (user: [email protected], size: 25246) [annotate] [blame] [check-ins using]
package xfer import ( "bufio" "bytes" "compress/zlib" "encoding/binary" "errors" "fmt" "io" "slices" "strconv" "strings" "github.com/danmestas/go-libfossil/internal/deck" "github.com/danmestas/go-libfossil/internal/hash" ) // maxPayloadSize is the largest size token a card may carry. Fossil parses // these tokens into a C int (xfer.c via blob_is_int), so no fossil peer can // send or store a larger artifact; a larger token is not a size at all. const maxPayloadSize = 1<<31 - 1 // payloadChunk is how much of a payload is allocated before its bytes arrive. // A declared size is the peer's claim, so memory grows with bytes actually // read rather than with the claim. const payloadChunk = 64 << 10 // parseSize parses a card's size token. The sync draft's grammar makes every // size `decimal = 1*DIGIT` (Appendix B), and fossil reads it with blob_is_int, // which accepts digits only; strconv.Atoi alone would also take a sign. A bad // token is the peer's error, returned as one, and never reaches readPayload's // assertion. func parseSize(tok string) (int, error) { if !isDecimal(tok) { return 0, fmt.Errorf("%q is not a decimal", tok) } n, err := strconv.ParseInt(tok, 10, 64) if err != nil || n > maxPayloadSize { return 0, fmt.Errorf("%q exceeds %d", tok, maxPayloadSize) } return int(n), nil } // readPayload reads exactly size bytes from the reader. Its buffer grows with // the bytes read, doubling up to size, so a size that the message does not // back fails on a short read after allocating about what was sent. func readPayload(r *bufio.Reader, size int) ([]byte, error) { if r == nil { panic("xfer.readPayload: r must not be nil") } // Callers pass only sizes from parseSize; a peer cannot reach this. if size < 0 || size > maxPayloadSize { panic("xfer.readPayload: size out of range") } buf := make([]byte, 0, min(size, payloadChunk)) for len(buf) < size { if len(buf) == cap(buf) { buf = slices.Grow(buf, min(size, 2*cap(buf))-len(buf)) } n, err := io.ReadFull(r, buf[len(buf):min(size, cap(buf))]) buf = buf[:len(buf)+n] if err != nil { if err == io.EOF { err = io.ErrUnexpectedEOF } return nil, fmt.Errorf("read payload: %w", err) } } return buf, nil } // cutShort reports whether a payload read failed because the body ended // before the payload's declared count. Section 5.1 of sync-protocol-01 (erratum // s5.1-truncated-payload-receiver) makes such a card a handler failure that // names the card's malformed-line error, so the decoder yields a BadFileCard // and stops, leaving the cards before it in effect. fossil(1) instead keeps // the bytes present (blob_extract clamps the count); -01 is followed here. func cutShort(err error) bool { return errors.Is(err, io.ErrUnexpectedEOF) } // readPayloadWithTrailingNewline reads size bytes then consumes one trailing \n. func readPayloadWithTrailingNewline(r *bufio.Reader, size int) ([]byte, error) { buf, err := readPayload(r, size) if err != nil { return nil, err } b, err := r.ReadByte() if err == nil && b != '\n' { r.UnreadByte() } return buf, nil } // uvFileOmitsContent returns true if the flags indicate no binary payload follows. func uvFileOmitsContent(flags int) bool { return flags&0x0001 != 0 || flags&0x0004 != 0 } // DecodeCard reads one card from r. It skips comment lines (starting with #) // and empty lines. Returns io.EOF when the reader is exhausted. // Unrecognized command words produce an *UnknownCard (not an error). // A cfile card may inflate to at most MaxCFileInflateBytes. func DecodeCard(r *bufio.Reader) (Card, error) { budget := int64(MaxCFileInflateBytes) return decodeCard(r, &budget) } // decodeCard reads one card as DecodeCard does, charging any cfile payload's // inflated size to budget, which the caller shares across a message. func decodeCard(r *bufio.Reader, budget *int64) (Card, error) { if r == nil { panic("xfer.decodeCard: r must not be nil") } if budget == nil || *budget < 0 { panic("xfer.decodeCard: budget must be a non-negative count") } for { rawLine, err := r.ReadString('\n') // Handle final line without trailing newline if err != nil && err != io.EOF { return nil, err } line := strings.TrimRight(rawLine, "\n\r") // EOF with no data left if err == io.EOF && line == "" { return nil, io.EOF } // Skip empty lines and comments if line == "" || strings.HasPrefix(line, "#") { if err == io.EOF { return nil, io.EOF } continue } // rawLine keeps its terminating LF so a BadCommandCard quotes the // line exactly as fossil's %F(blob_str(&xfer.line)) does. return parseLine(r, rawLine, line, budget) } } // cardTokenWindow is how many tokens a receiver reads from a card line. // Canonical fossil tokenizes each line into a fixed Blob aToken[6] // (src/xfer.c), so a card is six tokens at most and a seventh and beyond are // never seen -- the §3.9 window that makes a surplus token harmless. const cardTokenWindow = 6 // isCardSpace reports whether c separates card tokens, matching fossil's // fossil_isspace (src/blob.c): ASCII space and the controls \t through \r. // A byte at or above 0x80, such as a UTF-8 no-break space, is not a // separator, so a token may carry one. func isCardSpace(c byte) bool { return c == ' ' || (c >= '\t' && c <= '\r') } // tokenizeCard splits a card line into at most cardTokenWindow tokens on runs // of card whitespace, as fossil's blob_tokenize fills aToken[6] with // blob_token. Tokens past the sixth are dropped, not merged into the last. func tokenizeCard(line string) []string { tokens := make([]string, 0, cardTokenWindow) i, n := 0, len(line) for len(tokens) < cardTokenWindow { for i < n && isCardSpace(line[i]) { i++ } if i >= n { break } start := i for i < n && !isCardSpace(line[i]) { i++ } tokens = append(tokens, line[start:i]) } return tokens } // parseLine dispatches a trimmed, non-empty line to the appropriate card // parser, reading at most the §3.9 token window. A line no rule accepts // becomes a *BadCommandCard, and a file/cfile/uvfile line that cannot be // framed a *BadFileCard; neither is an error, so one malformed card no longer // fails the whole message. The reader r is passed through for payload cards. func parseLine(r *bufio.Reader, rawLine, line string, budget *int64) (Card, error) { fields := tokenizeCard(line) if len(fields) == 0 { return &BadCommandCard{Line: rawLine}, nil } cmd := fields[0] args := fields[1:] switch cmd { case "file": return parseFile(r, args) case "cfile": return parseCFile(r, args, budget) case "uvfile": return parseUVFile(r, args) case "config": return parseConfig(r, rawLine, args) case "schema": return parseSchema(r, args) case "xrow": return parseXRow(r, args) case "xdelete": return parseXDelete(r, args) } if c := parseControlCard(cmd, args); c != nil { return keepRawLine(c, rawLine), nil } return &BadCommandCard{Line: rawLine}, nil } // keepRawLine records the line as received on the cards a server answers with // "bad command: LINE" when a client sends them (§5.2). fossil quotes // blob_str(&xfer.line) verbatim, so re-encoding the parsed card would alter a // spelling such as "clone_seqno 05" or a run of spaces. func keepRawLine(c Card, rawLine string) Card { if !strings.HasSuffix(rawLine, "\n") { rawLine += "\n" } switch c := c.(type) { case *CloneSeqNoCard: c.Raw = rawLine case *ErrorCard: c.Raw = rawLine case *MessageCard: c.Raw = rawLine case *UnknownCard: c.Raw = rawLine } return c } // parseControlCard reads a card that carries no payload. It returns nil when // the line is a bad command -- an unrecognized operator, or a known one whose // arity breaks its rule -- which the caller turns into a *BadCommandCard. The // §3.9 window already dropped any token past the sixth, so a card that accepts // a fixed arity silently tolerates a surplus token (igot, private). func parseControlCard(cmd string, args []string) Card { switch cmd { case "igot": if len(args) < 1 { return nil } c := &IGotCard{UUID: args[0]} if len(args) >= 2 && args[1] == "1" { c.IsPrivate = true } return c case "gimme": if len(args) != 1 { return nil } return &GimmeCard{UUID: args[0]} // push/pull: canonical (src/xfer.c) accepts them only at nToken==3 and // reads the project code it validates out of the second argument. case "push": if len(args) != 2 { return nil } return &PushCard{ServerCode: args[0], ProjectCode: args[1]} case "pull": if len(args) != 2 { return nil } return &PullCard{ServerCode: args[0], ProjectCode: args[1]} case "cookie": if len(args) != 1 { return nil } return &CookieCard{Value: args[0]} case "reqconfig": if len(args) != 1 { return nil } return &ReqConfigCard{Name: args[0]} case "private": // Canonical matches `private` on its keyword alone; a surplus token // is past the window and harmless. return &PrivateCard{} case "clone": return parseClone(args) case "clone_seqno": return parseCloneSeqNo(args) case "uvgimme": if len(args) != 1 { return nil } return &UVGimmeCard{Name: deck.FossilDecode(args[0])} case "pragma": return parsePragma(args) case "login": if len(args) != 3 { return nil } return &LoginCard{User: deck.FossilDecode(args[0]), Nonce: args[1], Signature: args[2]} case "error": if len(args) != 1 { return nil } return &ErrorCard{Message: deck.FossilDecode(args[0])} case "message": if len(args) != 1 { return nil } return &MessageCard{Message: deck.FossilDecode(args[0])} case "uvigot": return parseUVIGot(args) case "xigot": return parseXIGot(args) case "xgimme": return parseXGimme(args) } return nil } // isDecimal reports whether s matches the spec's `decimal = 1*DIGIT` // production (Appendix B). Tokens such as "-1" parse as integers but are // not decimal, and several card rules turn on that distinction rather than // on the parsed value. func isDecimal(s string) bool { if s == "" { return false } for i := 0; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { return false } } return true } // parseClone returns nil for a line no clone rule accepts, so the caller // makes it a bad command. A clone with two tokens neither of which parses as // an integer degrades to an *UnknownCard rather than a bad command: §8.1 // withholds its fatal from a non-decimal SEQNO, and treating the whole // message as bad would be harsher still. func parseClone(args []string) Card { if len(args) != 0 && len(args) != 2 { return nil } if len(args) == 0 { return &CloneCard{} } v, vErr := strconv.Atoi(args[0]) s, sErr := strconv.Atoi(args[1]) if vErr != nil || sErr != nil { return &UnknownCard{Command: "clone", Args: args} } // §8.1 keys its fatal on a digit-only SEQNO and explicitly withholds it // from one that fails digit-only recognition, so record whether this // token qualifies rather than only what it parsed to. return &CloneCard{Version: v, SeqNo: s, SeqNoIsDecimal: isDecimal(args[1])} } // parseCloneSeqNo degrades a non-decimal NEXT to an *UnknownCard: §3.2 records // NEXT only when it is decimal and §8.2 makes a visible but non-decimal NEXT a // receiver-tolerance exception, so the token is kept but denied any effect on // the cursor (a CloneSeqNoCard would be recorded). func parseCloneSeqNo(args []string) Card { if len(args) != 1 { return nil } if !isDecimal(args[0]) { return &UnknownCard{Command: "clone_seqno", Args: args} } s, _ := strconv.Atoi(args[0]) // isDecimal guarantees this parses return &CloneSeqNoCard{SeqNo: s} } func parsePragma(args []string) Card { if len(args) < 1 { return nil } c := &PragmaCard{Name: args[0]} if len(args) > 1 { c.Values = args[1:] } return c } func parseUVIGot(args []string) Card { if len(args) != 4 { return nil } mtime, err := strconv.ParseInt(args[1], 10, 64) if err != nil { return nil } size, err := strconv.Atoi(args[3]) if err != nil { return nil } return &UVIGotCard{Name: deck.FossilDecode(args[0]), MTime: mtime, Hash: args[2], Size: size} } // --- Payload card parsers --- // parseFile decodes: file UUID SIZE \n CONTENT or file UUID DELTASRC SIZE \n CONTENT // No trailing newline after CONTENT. func parseFile(r *bufio.Reader, args []string) (Card, error) { if len(args) != 2 && len(args) != 3 { return &BadFileCard{Message: "malformed file line"}, nil } c := &FileCard{UUID: args[0]} var sizeStr string if len(args) == 3 { c.DeltaSrc = args[1] sizeStr = args[2] } else { sizeStr = args[1] } size, err := parseSize(sizeStr) if err != nil { // A bad SIZE leaves the payload unframeable: fossil's xfer_accept_file // writes "malformed file line" and page_xfer breaks. return &BadFileCard{Message: "malformed file line"}, nil } content, err := readPayload(r, size) if cutShort(err) { return &BadFileCard{Message: "malformed file line"}, nil } if err != nil { return nil, fmt.Errorf("xfer: file payload: %w", err) } c.Content = content return c, nil } // parseCFile decodes: cfile UUID USIZE CSIZE \n ZCONTENT // or: cfile UUID DELTASRC USIZE CSIZE \n ZCONTENT // No trailing newline after ZCONTENT. func parseCFile(r *bufio.Reader, args []string, budget *int64) (Card, error) { if len(args) != 3 && len(args) != 4 { return &BadFileCard{Message: "malformed cfile line"}, nil } c := &CFileCard{UUID: args[0]} var usizeStr, csizeStr string if len(args) == 4 { c.DeltaSrc = args[1] usizeStr = args[2] csizeStr = args[3] } else { usizeStr = args[1] csizeStr = args[2] } usize, err := parseSize(usizeStr) if err != nil { return &BadFileCard{Message: "malformed cfile line"}, nil } csize, err := parseSize(csizeStr) if err != nil { return &BadFileCard{Message: "malformed cfile line"}, nil } compressed, err := readPayload(r, csize) if cutShort(err) { return &BadFileCard{Message: "malformed cfile line"}, nil } if err != nil { return nil, fmt.Errorf("xfer: cfile payload: %w", err) } // Decompress zlib. Fossil's server may send blobs in its blob format // ([4-byte BE size prefix][zlib data]) or plain zlib. Try plain first // (matches our own encoder), fall back to skipping a 4-byte prefix // (matches Fossil's send_compressed_file which sends raw blob content). // decompressCFile also hands back storedBlob: the same bytes recast // into Fossil's on-disk blob format, built without a second zlib pass, // so a receive path can persist storedBlob verbatim instead of // recompressing decompressed content and getting different bytes. limit := min(cfileInflateLimit(usize, c.DeltaSrc != ""), *budget) decompressed, storedBlob, err := decompressCFile(compressed, limit) if err != nil { return nil, err } *budget -= int64(len(decompressed)) // USIZE is metadata, not a frame boundary. fossil's // xfer_accept_compressed_file reads CSIZE bytes and never checks that the // inflated size equals USIZE; the content is bound by its hash at storage // time instead. So a USIZE that disagrees with the inflated size (a sender // counting a trailing byte, say) is carried through, not rejected (§7.2). c.USize = usize c.Content = decompressed c.StoredBlob = storedBlob return c, nil } // parseConfig decodes: config NAME SIZE \n CONTENT <1 byte>. // // Canonical (src/xfer.c) enters its config branch only at nToken==3 with a // non-negative integer SIZE; otherwise the line is a bad command and no // payload is read -- so a wrong arity or a signed SIZE degrades here to a // *BadCommandCard, leaving the next line to the decoder. // // After the SIZE payload canonical does blob_seek(xfer.pIn, 1, BLOB_SEEK_CUR): // it steps over exactly one byte whatever it is, not only a LF. A sender // writes a LF there, but the receiver does not require one. // // A config payload cut short by the end of the body still fails the whole // decode (an HTTP 400 upstream), unlike file, cfile and uvfile: Section 5.1 // names handler texts only for those three, so this is a deliberate choice, // not an oversight; the cards before it are not kept. func parseConfig(r *bufio.Reader, rawLine string, args []string) (Card, error) { if len(args) != 2 { return &BadCommandCard{Line: rawLine}, nil } name := args[0] size, err := parseSize(args[1]) if err != nil { return &BadCommandCard{Line: rawLine}, nil } content, err := readPayload(r, size) if err != nil { return nil, fmt.Errorf("xfer: config payload: %w", err) } // Step over the one trailing byte, as canonical does. An EOF here (the // payload ended the message) is fine: there was nothing to step over. if _, err := r.ReadByte(); err != nil && err != io.EOF { return nil, fmt.Errorf("xfer: config trailing byte: %w", err) } return &ConfigCard{Name: name, Content: content}, nil } // parseUVFile decodes: uvfile NAME MTIME HASH SIZE FLAGS \n CONTENT // FLAGS bits: 0x0001 = deleted, 0x0004 = content omitted. When set, no payload follows. func parseUVFile(r *bufio.Reader, args []string) (Card, error) { // A malformed uvfile line leaves its payload unframeable: fossil's // xfer_accept_unversioned_file writes "malformed uvfile line" and // page_xfer breaks. Its checks are reproduced here -- arity, a NAME that // is a strict simple pathname, a non-negative MTIME, a HASH that is an // artifact name or "-", and SIZE and FLAGS that are non-negative // decimals (parseSize enforces the last). NAME is fossilized (draft // Section 7.3), so it is checked defossilized; fossil(1) checks the token // as sent and refuses any escape, sending names raw. if len(args) != 5 { return &BadFileCard{Message: "malformed uvfile line"}, nil } name := deck.FossilDecode(args[0]) if !deck.StrictPathname(name) || (args[2] != "-" && !hash.IsValidHash(args[2])) { return &BadFileCard{Message: "malformed uvfile line"}, nil } mtime, err := strconv.ParseInt(args[1], 10, 64) if err != nil || mtime < 0 { return &BadFileCard{Message: "malformed uvfile line"}, nil } size, err := parseSize(args[3]) if err != nil { return &BadFileCard{Message: "malformed uvfile line"}, nil } flags, err := parseSize(args[4]) if err != nil { return &BadFileCard{Message: "malformed uvfile line"}, nil } c := &UVFileCard{ Name: name, MTime: mtime, Hash: args[2], Size: size, Flags: flags, } if !uvFileOmitsContent(flags) { content, err := readPayload(r, size) if cutShort(err) { return &BadFileCard{Message: "malformed uvfile line"}, nil } if err != nil { return nil, fmt.Errorf("xfer: uvfile payload: %w", err) } c.Content = content } return c, nil } // cfileInflateLimit bounds how far a cfile payload may inflate. Without it, // a few kilobytes of zlib on the wire inflate to gigabytes, since zlib reaches // about 1000:1. A full artifact must inflate to exactly usize. A delta is // bounded by usize too: fossil's delta_create and this module's delta.Create // emit a copy only for a match of 16 bytes or more, which costs no more than // the bytes it replaces, so a delta exceeds its target by little more than its // header and checksum. The margin below is far wider than that. func cfileInflateLimit(usize int, isDelta bool) int64 { if usize < 0 || usize > maxPayloadSize { panic("xfer.cfileInflateLimit: usize out of range") } if !isDelta { return int64(usize) } return int64(usize) + int64(usize)/2 + 64 } // decompressCFile decompresses a cfile card's wire payload and also returns // storedBlob: the same bytes recast into Fossil's on-disk blob format // ([4-byte BE uncompressed-size prefix][zlib data]), built without ever // invoking zlib as a writer. This lets a caller persist storedBlob directly // as blob.content -- reproducing whatever bytes the sender's own storage // held -- instead of decompressing here and recompressing later, which // would depend on this process's zlib writer parameters rather than the // sender's. Neither form may inflate past limit; the prefixed form may not // inflate past its prefix either, as fossil's blob_uncompress sizes its // output buffer from that prefix. func decompressCFile(data []byte, limit int64) (decompressed []byte, storedBlob []byte, err error) { // Try plain zlib, which this module's encoder sent before it adopted // fossil's prefixed form. out, overLimit, readErr := inflateBounded(data, limit) if readErr == nil { // data carries no size prefix. Fossil's on-disk format needs // one, so prepend it -- the zlib bytes themselves pass through // unchanged, so this is bookkeeping, not recompression. var buf bytes.Buffer if err := binary.Write(&buf, binary.BigEndian, uint32(len(out))); err != nil { return nil, nil, fmt.Errorf("xfer: cfile size prefix: %w", err) } buf.Write(data) return out, buf.Bytes(), nil } // Try with 4-byte size prefix skip (Fossil blob format). Fossil's // send_compressed_file transmits the source's own blob.content bytes // unmodified, so data IS already the canonical on-disk form here -- // nothing needs to be rebuilt. if len(data) > 4 { prefix := int64(binary.BigEndian.Uint32(data[:4])) out, over, readErr := inflateBounded(data[4:], min(limit, prefix)) if readErr == nil { return out, data, nil } overLimit = overLimit || over } if overLimit { return nil, nil, fmt.Errorf( "xfer: cfile payload inflates past %d bytes, its declared size or the message budget", limit) } return nil, nil, fmt.Errorf( "xfer: cfile zlib init: could not decompress payload (%d bytes)", len(data)) } // inflateBounded inflates one zlib stream, reading at most limit+1 bytes of // output so a stream claiming more stops there. overLimit reports that case; // a stream that is not zlib, or is corrupt, returns only an error. func inflateBounded(data []byte, limit int64) (out []byte, overLimit bool, err error) { zr, err := zlib.NewReader(bytes.NewReader(data)) if err != nil { return nil, false, err } defer zr.Close() out, err = io.ReadAll(io.LimitReader(zr, limit+1)) if err != nil { return nil, false, err } if int64(len(out)) > limit { return nil, true, fmt.Errorf("inflates past %d bytes", limit) } return out, false, nil } func parseSchema(r *bufio.Reader, args []string) (Card, error) { if len(args) != 5 { return nil, fmt.Errorf("xfer: schema requires 5 args, got %d", len(args)) } version, err := strconv.Atoi(args[1]) if err != nil { return nil, fmt.Errorf("xfer: schema version: %w", err) } mtime, err := strconv.ParseInt(args[3], 10, 64) if err != nil { return nil, fmt.Errorf("xfer: schema mtime: %w", err) } size, err := parseSize(args[4]) if err != nil { return nil, fmt.Errorf("xfer: schema size: %w", err) } const maxSchemaSize = 64 << 20 // 64 MiB — schema payloads should be small JSON. if size > maxSchemaSize { return nil, fmt.Errorf("xfer: schema size out of bounds: %d", size) } content, err := readPayloadWithTrailingNewline(r, size) if err != nil { return nil, fmt.Errorf("xfer: schema payload: %w", err) } return &SchemaCard{Table: args[0], Version: version, Hash: args[2], MTime: mtime, Content: content}, nil } // parseXIGot and parseXGimme return nil for a malformed line, which the // caller turns into a bad command, as for the other payload-free cards. func parseXIGot(args []string) Card { if len(args) != 3 { return nil } mtime, err := strconv.ParseInt(args[2], 10, 64) if err != nil { return nil } return &XIGotCard{Table: args[0], PKHash: args[1], MTime: mtime} } func parseXGimme(args []string) Card { if len(args) != 2 { return nil } return &XGimmeCard{Table: args[0], PKHash: args[1]} } func parseXDelete(r *bufio.Reader, args []string) (Card, error) { if len(args) != 4 { return nil, fmt.Errorf("xfer: xdelete requires 4 args, got %d", len(args)) } mtime, err := strconv.ParseInt(args[2], 10, 64) if err != nil { return nil, fmt.Errorf("xfer: xdelete mtime: %w", err) } size, err := parseSize(args[3]) if err != nil { return nil, fmt.Errorf("xfer: xdelete size: %w", err) } const maxXDeleteSize = 64 << 20 // 64 MiB — PK data should be tiny JSON if size > maxXDeleteSize { return nil, fmt.Errorf("xfer: xdelete size out of range: %d", size) } pkData, err := readPayloadWithTrailingNewline(r, size) if err != nil { return nil, fmt.Errorf("xfer: xdelete payload: %w", err) } return &XDeleteCard{Table: args[0], PKHash: args[1], MTime: mtime, PKData: pkData}, nil } func parseXRow(r *bufio.Reader, args []string) (Card, error) { if len(args) != 4 { return nil, fmt.Errorf("xfer: xrow requires 4 args, got %d", len(args)) } mtime, err := strconv.ParseInt(args[2], 10, 64) if err != nil { return nil, fmt.Errorf("xfer: xrow mtime: %w", err) } size, err := parseSize(args[3]) if err != nil { return nil, fmt.Errorf("xfer: xrow size: %w", err) } const maxXRowSize = 64 << 20 // 64 MiB — row payloads should be small JSON. if size > maxXRowSize { return nil, fmt.Errorf("xfer: xrow size out of bounds: %d", size) } content, err := readPayloadWithTrailingNewline(r, size) if err != nil { return nil, fmt.Errorf("xfer: xrow payload: %w", err) } return &XRowCard{Table: args[0], PKHash: args[1], MTime: mtime, Content: content}, nil }