From 0e1ae4369ded7d93904db320f93f6e4e38922710 Mon Sep 17 00:00:00 2001 From: go-while Date: Sat, 14 Nov 2026 06:59:32 +0000 Subject: [PATCH] fakeserver, conformance, nntp-fakeserver: fixes from the v0.14.0 review Four independent reviews (Haiku, Sonnet, Sol, Terra) of v0.14.0; each fix has a test that fails before it. - fakeserver Store: a POST naming a generated group panicked in the connection goroutine and ended the test process; it now gets 441 before anything is filed. - fakeserver Store: POST looked up its override a second time, so OverrideTimes was used twice per POST and a refused POST (Times 1) then read the client's next commands as an article; it now decides on the reply written. - fakeserver Store: the AUTHINFO value is the rest of the line byte for byte, not re-joined from fields ("p q" never matched, "p\tq" matched "p q"). - fakeserver Peer: the verb is split at any space or tab and the argument trimmed ("check\t" was 500, "CHECK " kept a space in the id). - nntp-fakeserver raw "inflate": internal/inflate instead of compress/flate, which held a Z_PARTIAL_FLUSH command back. - conformance: with Options.User, a missing Needs capability no longer skips the check before its ready() logged in and read CAPABILITIES again. - conformance: farNumber stays above high when high is 2^31-1 or more. - conformance: checkOverLine no longer slices the OVERVIEW.FMT name by its lower-cased length (a panic for names like U+023A). Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- cmd/nntp-fakeserver/main_test.go | 54 +++++++++++++++++++ cmd/nntp-fakeserver/raw.go | 14 ++++- conformance/reader.go | 17 ++++-- conformance/reader_test.go | 17 ++++++ conformance/run.go | 7 ++- conformance/run_test.go | 33 ++++++++++++ conformance/types.go | 3 +- fakeserver/conn.go | 12 +++++ fakeserver/doc.go | 3 +- fakeserver/generated.go | 13 +++++ fakeserver/peer.go | 11 ++-- fakeserver/reviewfix_test.go | 90 ++++++++++++++++++++++++++++++++ fakeserver/storehandler.go | 18 +++++-- 14 files changed, 273 insertions(+), 23 deletions(-) create mode 100644 fakeserver/reviewfix_test.go diff --git a/README.md b/README.md index 1a45a89..74009fc 100644 --- a/README.md +++ b/README.md @@ -554,7 +554,9 @@ the order of `Names()`), so one failure cannot poison the next. The checks that all the others, so no article arrives in `PostGroup` while a reader check counts its articles. Before the dial a check is skipped when it is in `Options.Skip` ("skipped by option"), needs credentials without `User` or posts without `PostGroup`; after the dial and CAPABILITIES it is skipped when its capability is not listed -(unless the server lists `MODE-READER`: the check then sends MODE READER and tests again). A +(unless the server lists `MODE-READER`, or `User` is set: the check then sends MODE READER +or logs in, reads CAPABILITIES again and tests then, since a server may list its reader +capabilities only after AUTHINFO). A broken greeting fails `greeting` once and skips the rest; a panic in a check is its Fail. The articles that checks post carry a Message-ID under `@conformance.invalid`. diff --git a/cmd/nntp-fakeserver/main_test.go b/cmd/nntp-fakeserver/main_test.go index 490869b..03750ec 100644 --- a/cmd/nntp-fakeserver/main_test.go +++ b/cmd/nntp-fakeserver/main_test.go @@ -435,6 +435,60 @@ func TestRawInflate(t *testing.T) { } } +// TestRawInflatePartialFlush: a client that flushes like zlib's Z_PARTIAL_FLUSH (a fixed +// block, then an empty fixed block, no sync marker) is read at once. compress/flate held the +// command back until more input came, so the script waited until its Deadline. +func TestRawInflatePartialFlush(t *testing.T) { + p := startProc(t, `{"Mode": "raw", "Raw": [{"Send": [{"Text": "200 hi\r\n"}]}, + {"Expect": "COMPRESS DEFLATE", "Send": [{"Text": "206 ok\r\n"}], "Then": "inflate"}, + {"Expect": "DATE", "Send": [{"Text": "111 20200102030405\r\n"}]}]}`) + c := p.dial() + cmd(t, c, 206, "COMPRESS DEFLATE") + if err := c.Send(string(partialFlush("DATE\r\n"))); err != nil { + t.Fatal(err) + } + if code, line, err := c.ReadStatus(); err != nil || code != 111 { + t.Fatalf("DATE through a partial flush: %d %q %v", code, line, err) + } + s, _ := p.finish() + if want := []string{"COMPRESS DEFLATE", "DATE"}; !slices.Equal(s.Lines, want) || len(s.Mismatches) != 0 { + t.Errorf("lines %q mismatches %q", s.Lines, s.Mismatches) + } +} + +// partialFlush encodes text (bytes below 144 only) as a non-final fixed-Huffman block of +// literals and an empty fixed block, padded to a byte: what zlib writes for Z_PARTIAL_FLUSH. +func partialFlush(text string) []byte { + var out []byte + var acc uint32 + var n uint + bits := func(v uint32, k uint) { // LSB first + acc |= v << n + for n += k; n >= 8; n -= 8 { + out = append(out, byte(acc)) + acc >>= 8 + } + } + code := func(c uint32, k uint) { // a Huffman code, MSB first + var r uint32 + for i := uint(0); i < k; i++ { + r = r<<1 | (c>>i)&1 + } + bits(r, k) + } + bits(0b010, 3) // BFINAL 0, BTYPE 01 + for _, b := range []byte(text) { + code(0x30+uint32(b), 8) + } + code(0, 7) // end of block + bits(0b010, 3) // the empty fixed block + code(0, 7) + if n > 0 { + out = append(out, byte(acc)) + } + return out +} + // TestRawHang: hang reads and never answers, and does not close. func TestRawHang(t *testing.T) { p := startProc(t, `{"Mode": "raw", "Raw": [{"Send": [{"Text": "200 hi\r\n"}]}, {"Expect": "QUIT", "Then": "hang"}]}`) diff --git a/cmd/nntp-fakeserver/raw.go b/cmd/nntp-fakeserver/raw.go index 1550cf8..f41ad06 100644 --- a/cmd/nntp-fakeserver/raw.go +++ b/cmd/nntp-fakeserver/raw.go @@ -6,6 +6,7 @@ import ( "crypto/tls" "errors" "fmt" + "io" "net" "os" "regexp" @@ -14,6 +15,7 @@ import ( "time" "github.com/go-while/nntp-testkit/fakeserver" + "github.com/go-while/nntp-testkit/internal/inflate" ) // recorder holds what raw mode saw (the fakeserver records the other modes; a raw step reads @@ -270,7 +272,17 @@ func rawHandler(steps []rawStep, rec *recorder, tlsCfg *tls.Config, deadline tim rc.conn = c.Raw() rc.r = bufio.NewReader(rc.conn) case "inflate": - rc.r = bufio.NewReader(flate.NewReader(rc.r)) + // the kit's streaming inflater, not compress/flate: that one holds the bytes + // of a Z_PARTIAL_FLUSH back until more input comes (see internal/inflate) + pr, pw := io.Pipe() + go func(src *bufio.Reader) { + _ = pw.CloseWithError(inflate.Run(src, func(b []byte) error { + _, err := pw.Write(b) + return err + })) + }(rc.r) + defer pr.Close() // ends the inflater when the handler returns first + rc.r = bufio.NewReader(pr) } } for { // the lines after the script: no read deadline either diff --git a/conformance/reader.go b/conformance/reader.go index 92bf925..c173248 100644 --- a/conformance/reader.go +++ b/conformance/reader.go @@ -35,12 +35,19 @@ const unknownGroup = "conformance.invalid.no-such-group" // farAbove is added to a group's high water mark for an article number that cannot exist. const farAbove = 1000000 -// maxArticle is the largest article number farNumber gives (2^31-1: servers with 32-bit -// numbers must still parse it). +// maxArticle is the largest article number farNumber gives while the group's numbers fit in +// 32 bits (2^31-1: servers with 32-bit numbers must still parse it). const maxArticle = 2147483647 -// farNumber returns an article number far above high, at most maxArticle. -func farNumber(high int64) int64 { return min(high+farAbove, maxArticle) } +// farNumber returns an article number above high that cannot exist: high+farAbove capped at +// maxArticle, unless high is maxArticle or more (the server counts beyond 32 bits, and the +// cap would name an existing article), when it is high+farAbove. +func farNumber(high int64) int64 { + if high >= maxArticle { + return high + farAbove + } + return min(high+farAbove, maxArticle) +} // readerChecks returns the RFC 3977 checks, in report order. func readerChecks() []check { @@ -1042,7 +1049,7 @@ func checkOverLine(l string, fmtLines []string, want int64, wantID string) (int6 continue } if !strings.HasPrefix(strings.ToLower(f[i]), name+":") { - return n, fmt.Errorf("OVER line %q: field %d %q does not start with %q", l, i+1, f[i], fmtLines[i-1][:len(name)+1]) + return n, fmt.Errorf("OVER line %q: field %d %q does not start with %q", l, i+1, f[i], name+":") } } return n, nil diff --git a/conformance/reader_test.go b/conformance/reader_test.go index 14e3268..fdf8d5d 100644 --- a/conformance/reader_test.go +++ b/conformance/reader_test.go @@ -555,4 +555,21 @@ func TestParseHelpers(t *testing.T) { if n := farNumber(maxArticle - 5); n != maxArticle { t.Errorf("farNumber near the 32-bit limit: %d", n) } + // a group already at or beyond the 32-bit limit: the number stays above high + for _, high := range []int64{maxArticle, maxArticle + 1, 5_000_000_000} { + if n := farNumber(high); n <= high { + t.Errorf("farNumber(%d) = %d, not above high", high, n) + } + } +} + +// TestCheckOverLineFoldingName: a LIST OVERVIEW.FMT name whose lower-case form is longer than +// the original (U+023A, 2 bytes, lower-cases to U+2C65, 3 bytes) gives an error, not a slice +// panic. +func TestCheckOverLineFoldingName(t *testing.T) { + fmtLines := []string{"Subject:", "From:", "Date:", "Message-ID:", "References:", ":bytes", ":lines", "\u023a\u023a\u023a\u023a\u023a:full"} + l := "1\ts\tf\td\t<1@x>\t\t10\t1\tother: x" + if _, err := checkOverLine(l, fmtLines, 1, "<1@x>"); err == nil || !strings.Contains(err.Error(), "does not start with") { + t.Errorf("checkOverLine: %v", err) + } } diff --git a/conformance/run.go b/conformance/run.go index 234420a..54c876f 100644 --- a/conformance/run.go +++ b/conformance/run.go @@ -208,7 +208,8 @@ func runPhase(o Options, checks []check, idx []int, out []Result) { // (Options.Skip, Auth without Options.User, Posts without Options.PostGroup); then the session // is dialed (a dial error is turned into a Result by sessionFailed), CAPABILITIES is read, and // c is skipped when the server does not advertise c.Needs, unless it advertises MODE-READER -// (the check's ready switches the mode and tests Needs again). A panic in the check is a Fail. +// or Options.User is set (the check's ready switches the mode or logs in, reads the +// capabilities again and tests Needs then). A panic in the check is a Fail. func runOne(o Options, c check) (r Result) { defer func() { r = o.allowed(r) }() switch { @@ -231,7 +232,9 @@ func runOne(o Options, c check) (r Result) { return sessionFailed(c, err) } _, _ = s.capabilities() // a failure stays in s.capsErr; the capabilities check reports it - if c.Needs != "" && !s.has(c.Needs) && !s.has("MODE-READER") { + // with Options.User the check's ready logs in and reads the capabilities again before it + // tests Needs: a server may list its reader capabilities only after AUTHINFO + if c.Needs != "" && !s.has(c.Needs) && !s.has("MODE-READER") && o.User == "" { return c.notAdvertised(c.Needs) } r = c.Run(s, c) diff --git a/conformance/run_test.go b/conformance/run_test.go index 9f97ca0..9221d27 100644 --- a/conformance/run_test.go +++ b/conformance/run_test.go @@ -485,6 +485,39 @@ func TestRunNeedsModeReader(t *testing.T) { } } +// TestRunNeedsAfterLogin: a server that lists READER only after AUTHINFO gets its reader +// checks run when Options.User is set (they were skipped before the login could happen), and +// skipped when the login does not bring the capability. +func TestRunNeedsAfterLogin(t *testing.T) { + steps := func(after string) fakeserver.Options { + return fakeserver.Options{Script: []fakeserver.Step{ + {Expect: "CAPABILITIES", Reply: []string{"101 caps", "VERSION 2", "AUTHINFO USER", "."}}, + {Expect: "AUTHINFO USER u", Reply: []string{"381 more"}}, + {Expect: "AUTHINFO PASS p", Reply: []string{"281 ok"}}, + {Expect: "CAPABILITIES", Reply: []string{"101 caps", "VERSION 2", after, "."}}, + }} + } + ran := false + c := check{Name: "needs-reader", RFC: "test", Needs: "READER", Run: func(s *session, c check) Result { + if r, ok := s.ready(c); !ok { + return r + } + ran = true + return c.pass() + }} + srv := fakeserver.New(t, steps("READER")) + r := runChecks(Options{Addr: srv.Addr(), User: "u", Pass: "p"}, []check{c})[0] + if r.Status != Pass || !ran { + t.Errorf("READER after login: %s %q (ran %v), want PASS", r.Status, r.Detail, ran) + } + ran = false + srv = fakeserver.New(t, steps("POST")) + r = runChecks(Options{Addr: srv.Addr(), User: "u", Pass: "p"}, []check{c})[0] + if r.Status != Skip || r.Missing != "READER" || ran { + t.Errorf("no READER after login: %s %q (ran %v), want SKIP", r.Status, r.Detail, ran) + } +} + // TestRunVerbose: RunVerbose logs every result that is not a Skip, one line each, then // reports the Fails as Run does; ByName finds each result. func TestRunVerbose(t *testing.T) { diff --git a/conformance/types.go b/conformance/types.go index 2215a36..ecbb9cc 100644 --- a/conformance/types.go +++ b/conformance/types.go @@ -270,7 +270,8 @@ func (c check) failf(f string, a ...any) Result { return c.fail(fmt.Sprintf(f, a // check is one conformance check. Run opens a session for it (the connection, the greeting and // the capability list) and calls Run; Needs names a capability keyword (e.g. "READER", "OVER", -// "STREAMING") the server must advertise, else the check is skipped; Posts marks checks that +// "STREAMING") the server must advertise, else the check is skipped (tested again after MODE +// READER or the login of Options.User; every check with Needs calls ready); Posts marks checks that // modify the server and need Options.PostGroup; Auth marks checks that need Options.User. // // Each slice contributes its checks through a function returning []check (readerChecks, diff --git a/fakeserver/conn.go b/fakeserver/conn.go index 97b3c43..3e693c0 100644 --- a/fakeserver/conn.go +++ b/fakeserver/conn.go @@ -59,6 +59,18 @@ func command(line string) string { return strings.ToUpper(f[0]) } +// cutWord splits s into its first word and the rest: leading spaces and tabs are skipped, the +// word ends at the next space or tab, and exactly that one separator is dropped, so the rest +// keeps its bytes (a password with repeated or trailing spaces). rest is "" without a separator. +func cutWord(s string) (word, rest string) { + s = strings.TrimLeft(s, " \t") + i := strings.IndexAny(s, " \t") + if i < 0 { + return s, "" + } + return s[:i], s[i+1:] +} + // lineKey is the key of Faults.OverrideLine for a client line: the command upper-cased and the // arguments as sent, joined by single spaces. func lineKey(line string) string { diff --git a/fakeserver/doc.go b/fakeserver/doc.go index 9e67629..5727d66 100644 --- a/fakeserver/doc.go +++ b/fakeserver/doc.go @@ -189,7 +189,8 @@ // // Arrived is at or after the time; date and time as NEWGROUPS, always UTC; 503 when a // // matching generated group spans more than its limit), DATE, POST (440 when // // ReaderOnly; 340 then 240/441: Newsgroups, Subject and From required, Message-ID must be -// // <...> and unused, generated as when missing; stored with +// // <...> and unused, generated as when missing; 441 when +// // Newsgroups names a generated group, before anything is stored; stored with // // AddPosted and AddArticle per group), AUTHINFO USER/PASS (381/281/481/482; USER alone // // answers 281/481 when Pass is empty; 502 when not configured, done or compression is active), COMPRESS alg (with // // Compress or CompressOutputOnly, else 500; 480 before required auth; 501 missing or diff --git a/fakeserver/generated.go b/fakeserver/generated.go index 88ad2f7..46fe208 100644 --- a/fakeserver/generated.go +++ b/fakeserver/generated.go @@ -118,6 +118,19 @@ func (g *Generator) lookup(group, id string) (int64, bool) { return generatedNumber(group, id) } +// firstGenerated returns the first of groups that is a generated group (AddArticle refuses +// those), without creating any group. +func (st *Store) firstGenerated(groups []string) (string, bool) { + st.mu.Lock() + defer st.mu.Unlock() + for _, name := range groups { + if g, ok := st.groups[name]; ok && g.gen != nil { + return name, true + } + } + return "", false +} + // AddGenerated creates group (or turns an existing group without articles into one) as a // generated group: its articles Low..High come from gen and are never stored. Count is // High-Low+1 (holes included, like the estimate of a real server). Store methods work on it: diff --git a/fakeserver/peer.go b/fakeserver/peer.go index fbe3a40..6c21797 100644 --- a/fakeserver/peer.go +++ b/fakeserver/peer.go @@ -146,9 +146,9 @@ func PeerHandler(o *PeerOptions) func(*Conn) { if err != nil { return } - verb, arg := line, "" - if i := strings.IndexByte(line, ' '); i >= 0 { - verb, arg = line[:i], line[i+1:] + verb, arg := cutWord(line) + if !strings.EqualFold(verb, "AUTHINFO") { + arg = strings.TrimSpace(arg) // a message-id or keyword: no padding ("CHECK ") } verb = strings.ToUpper(verb) if o.NoStream && (verb == "CHECK" || verb == "TAKETHIS") { @@ -225,10 +225,7 @@ func PeerHandler(o *PeerOptions) func(*Conn) { reply = []string{"502 Command unavailable"} // RFC 4643 ยง2.3: no AUTHINFO after a successful one break } - sub, val := arg, "" - if i := strings.IndexByte(arg, ' '); i >= 0 { - sub, val = arg[:i], arg[i+1:] - } + sub, val := cutWord(arg) switch strings.ToUpper(sub) { case "USER": userSent = true diff --git a/fakeserver/reviewfix_test.go b/fakeserver/reviewfix_test.go new file mode 100644 index 0000000..8acc08a --- /dev/null +++ b/fakeserver/reviewfix_test.go @@ -0,0 +1,90 @@ +package fakeserver + +import ( + "testing" + + "github.com/go-while/nntp-testkit/client" +) + +// A POST to a generated group gets 441 before anything is filed (it used to panic in the +// connection goroutine and end the test process), also in a crosspost with a normal group. +func TestStorePostGeneratedGroup(t *testing.T) { + st := testStore() + st.AddGenerated("gen.big", "", Generator{Low: 1, High: 100}) + _, c := storeServer(t, st, StoreOptions{}, Faults{}) + for _, ng := range []string{"gen.big", "alt.test,gen.big"} { + wantCode(t, c, "POST", 340) + if err := c.Send("Newsgroups: " + ng + "\r\nSubject: s\r\nFrom: f\r\nMessage-ID: \r\n\r\nb\r\n.\r\n"); err != nil { + t.Fatal(err) + } + if line := wantStatus(t, c, 441); line != "441 Posting failed: gen.big is a generated group" { + t.Errorf("Newsgroups %q: %q", ng, line) + } + } + if n := len(st.Posted()); n != 0 { + t.Errorf("Posted() = %d", n) + } + if _, _, ok := st.ByMessageID(""); ok { + t.Error("the refused article was filed") + } + wantCode(t, c, "DATE", 111) +} + +// An overridden final first reply to POST with OverrideTimes uses one allowance per POST, and +// the server does not wait for an article after it. +func TestStorePostOverrideTimes(t *testing.T) { + f := Faults{Override: map[string][]string{"POST": {"440 not now"}}, OverrideTimes: map[string]int{"POST": 2}} + s, c := storeServer(t, testStore(), StoreOptions{}, f) + wantCode(t, c, "POST", 440) + wantCode(t, c, "DATE", 111) // used to be read as article data + wantCode(t, c, "POST", 440) // the second allowance + wantCode(t, c, "POST", 340) // the override is used up + if err := c.Send("Newsgroups: alt.test\r\nSubject: s\r\nFrom: f\r\n\r\nb\r\n.\r\n"); err != nil { + t.Fatal(err) + } + wantStatus(t, c, 240) + if got := s.Count("POST"); got != 3 { + t.Errorf("Count(POST) = %d", got) + } +} + +// The Store password is compared byte for byte: repeated spaces count, a tab is not a space. +func TestStoreAuthPasswordVerbatim(t *testing.T) { + for _, tc := range []struct { + pass, sent string + want int + }{ + {"p q", "p q", 281}, + {"p q", "p q", 281}, + {"p q", "p q", 481}, + {"p q", "p\tq", 481}, + {"p q", "p q ", 481}, + {"p", "p", 281}, + } { + _, c := storeServer(t, testStore(), StoreOptions{User: "u", Pass: tc.pass}, Faults{}) + wantCode(t, c, "AUTHINFO USER u", 381) + if line := wantCode(t, c, "AUTHINFO PASS "+tc.sent, tc.want); line == "" { + t.Errorf("pass %q sent %q", tc.pass, tc.sent) + } + } + // the separators between the words before the value may be any run of spaces and tabs + _, c := storeServer(t, testStore(), StoreOptions{User: "u", Pass: "p"}, Faults{}) + wantCode(t, c, "authinfo\tuser u", 381) + wantCode(t, c, "AUTHINFO PASS p", 281) +} + +// PeerHandler splits the verb at any space or tab and trims the message-id, like the Faults +// keys and Counts do. +func TestPeerVerbSplit(t *testing.T) { + s := New(t, Options{Handler: PeerHandler(&PeerOptions{Have: map[string]bool{"": true}})}) + c := client.Dial(t, s.Addr()) + if line := wantCode(t, c, "check\t", 238); line != "238 " { + t.Errorf("tab: %q", line) + } + if line := wantCode(t, c, "CHECK ", 438); line != "438 " { + t.Errorf("two spaces: %q", line) + } + if got := s.Count("CHECK"); got != 2 { + t.Errorf("Count(CHECK) = %d", got) + } +} diff --git a/fakeserver/storehandler.go b/fakeserver/storehandler.go index a44e5b8..d570e0e 100644 --- a/fakeserver/storehandler.go +++ b/fakeserver/storehandler.go @@ -47,7 +47,7 @@ func init() { storeHandler = StoreHandler } // NEWGROUPS d t [GMT] 231 groups whose Created is at or after d t (yymmdd or yyyymmdd, hhmmss; always UTC); 501 // NEWNEWS wm d t [GMT] 230 message-ids of the articles in groups matching wm whose Arrived is at or after d t (date and time as NEWGROUPS); 501; 503 when a matching generated group spans more numbers than its limit // DATE 111 yyyymmddhhmmss (UTC) -// POST 440 when ReaderOnly; else 340, then 240 "" or 441 (Newsgroups, Subject and From required; a Message-ID that is duplicate or not "<...>" without spaces) +// POST 440 when ReaderOnly; else 340, then 240 "" or 441 (Newsgroups, Subject and From required; a Message-ID that is duplicate or not "<...>" without spaces; a generated group in Newsgroups) // COMPRESS alg only with Compress or CompressOutputOnly (else 500; 480 before required auth unless CompressBeforeAuth): 206, then both directions compressed (RFC 8054); 501 for a missing or invalid algorithm name; 502 when active; 503 for an algorithm other than DEFLATE // STARTTLS only with StartTLS (else 500): 382 then the TLS handshake (RFC 4642); 502 when TLS or COMPRESS is active or AUTHINFO succeeded; 501 with arguments; StartTLSOptions.Refuse replaces the 382 // QUIT 205, then the connection is closed @@ -110,7 +110,8 @@ type storeConn struct { authed bool user string // AUTHINFO USER argument userSeen bool - loggedIn bool // an AUTHINFO succeeded (STARTTLS is then 502) + loggedIn bool // an AUTHINFO succeeded (STARTTLS is then 502) + line string // the client line being answered } // action tells serve what to do after dispatch. @@ -133,6 +134,7 @@ func (h *storeConn) serve() { if len(args) > 0 { args = args[1:] } + h.line = line reply, act := h.dispatch(cmd, args) switch act { case actPost: @@ -309,7 +311,10 @@ func (h *storeConn) authinfo(args []string) []string { if h.authed || h.c.Compressed() { // RFC 8054 2.2.2: 502 once compression is active return []string{"502 Command unavailable"} } - val := strings.Join(args[1:], " ") + // the user name or password is the rest of the line after the subcommand and one + // separator, byte for byte (not re-joined from fields: "p q" stays "p q") + _, rest := cutWord(h.line) + _, val := cutWord(rest) switch strings.ToUpper(args[0]) { case "USER": if h.o.Pass == "" { // username-only authentication (RFC 4643 2.3.2) @@ -738,8 +743,8 @@ func (h *storeConn) post(line string) bool { if !h.c.writeReply(line, "340 Send article to be posted") { return false } - if ov, ok := h.c.override(line); ok && (len(ov) == 0 || !strings.HasPrefix(ov[0], "340")) { - return true // the overridden first reply was final: no article follows + if !strings.HasPrefix(h.c.last, "340") { + return true // an overridden first reply was final: no article follows } wire, err := h.c.ReadDotBlock() if err != nil { @@ -779,6 +784,9 @@ func (h *storeConn) file(wire []string) string { if len(groups) == 0 { return "441 Posting failed: no newsgroup" } + if g, ok := h.st.firstGenerated(groups); ok { + return "441 Posting failed: " + g + " is a generated group" + } h.postMu.Lock() defer h.postMu.Unlock() switch id := a.Header("Message-ID"); {