diff --git a/src/pkg/Makefile b/src/pkg/Makefile index d6d33315f61..d8d87b08a54 100644 --- a/src/pkg/Makefile +++ b/src/pkg/Makefile @@ -94,6 +94,7 @@ DIRS=\ math\ mime\ net\ + nntp\ once\ os\ os/signal\ diff --git a/src/pkg/nntp/Makefile b/src/pkg/nntp/Makefile new file mode 100644 index 00000000000..3cf3164ce3e --- /dev/null +++ b/src/pkg/nntp/Makefile @@ -0,0 +1,11 @@ +# Copyright 2009 The Go Authors. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. + +include ../../Make.$(GOARCH) + +TARG=nntp +GOFILES=\ + nntp.go + +include ../../Make.pkg diff --git a/src/pkg/nntp/nntp.go b/src/pkg/nntp/nntp.go new file mode 100644 index 00000000000..e78b036f549 --- /dev/null +++ b/src/pkg/nntp/nntp.go @@ -0,0 +1,709 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// The nntp package implements a client for the news protocol NNTP, +// as defined in RFC 3977. +package nntp + +import ( + "bufio" + "bytes" + "container/vector" + "fmt" + "http" + "io" + "io/ioutil" + "os" + "net" + "sort" + "strconv" + "strings" + "time" +) + +// timeFormatNew is the NNTP time format string for NEWNEWS / NEWGROUPS +const timeFormatNew = "20060102 150405" + +// timeFormatDate is the NNTP time format string for responses to the DATE command +const timeFormatDate = "20060102150405" + +// An Error represents an error response from an NNTP server. +type Error struct { + Code uint + Msg string +} + +// A ProtocolError represents responses from an NNTP server +// that seem incorrect for NNTP. +type ProtocolError string + +// A Conn represents a connection to an NNTP server. The connection with +// an NNTP server is stateful; it keeps track of what group you have +// selected, if any, and (if you have a group selected) which article is +// current, next, or previous. +// +// Some methods that return information about a specific message take +// either a message-id, which is global across all NNTP servers, groups, +// and messages, or a message-number, which is an integer number that is +// local to the NNTP session and currently selected group. +// +// For all methods that return an io.Reader (or an *Article, which contains +// an io.Reader), that io.Reader is only valid until the next call to a +// method of Conn. +type Conn struct { + conn io.WriteCloser + r *bufio.Reader + br *bodyReader + close bool +} + +// A Group gives information about a single news group on the server. +type Group struct { + Name string + // High and low message-numbers + High, Low int + // Status indicates if general posting is allowed -- + // typical values are "y", "n", or "m". + Status string +} + +// An Article represents an NNTP article. +type Article struct { + Header map[string][]string + Body io.Reader +} + +// A bodyReader satisfies reads by reading from the connection +// until it finds a line containing just . +type bodyReader struct { + c *Conn + eof bool + buf *bytes.Buffer +} + +var dotnl = []byte(".\n") +var dotdot = []byte("..") + +func (r *bodyReader) Read(p []byte) (n int, err os.Error) { + if r.eof { + return 0, os.EOF + } + if r.buf == nil { + r.buf = &bytes.Buffer{} + } + if r.buf.Len() == 0 { + b, err := r.c.r.ReadBytes('\n') + if err != nil { + return 0, err + } + // canonicalize newlines + if b[len(b)-2] == '\r' { // crlf->lf + b = b[0 : len(b)-1] + b[len(b)-1] = '\n' + } + // stop on . + if bytes.Equal(b, dotnl) { + r.eof = true + return 0, os.EOF + } + // unescape leading .. + if bytes.HasPrefix(b, dotdot) { + b = b[1:] + } + r.buf.Write(b) + } + n, _ = r.buf.Read(p) + return +} + +func (r *bodyReader) discard() os.Error { + _, err := ioutil.ReadAll(r) + return err +} + +// articleReader satisfies reads by dumping out an article's headers +// and body. +type articleReader struct { + a *Article + headerdone bool + headerbuf *bytes.Buffer +} + +func (r *articleReader) Read(p []byte) (n int, err os.Error) { + if r.headerbuf == nil { + buf := new(bytes.Buffer) + for k, fv := range r.a.Header { + for _, v := range fv { + fmt.Fprintf(buf, "%s: %s\n", k, v) + } + } + if r.a.Body != nil { + fmt.Fprintf(buf, "\n") + } + r.headerbuf = buf + } + if !r.headerdone { + n, err = r.headerbuf.Read(p) + if err == os.EOF { + err = nil + r.headerdone = true + } + if n > 0 { + return + } + } + if r.a.Body != nil { + n, err = r.a.Body.Read(p) + if err == os.EOF { + r.a.Body = nil + } + return + } + return 0, os.EOF +} + +func (a *Article) String() string { + id, ok := a.Header["Message-Id"] + if !ok { + return "[NNTP article]" + } + return fmt.Sprintf("[NNTP article %s]", id[0]) +} + +func (a *Article) WriteTo(w io.Writer) (int64, os.Error) { + return io.Copy(w, &articleReader{a: a}) +} + +func (p ProtocolError) String() string { + return string(p) +} + +func (e Error) String() string { + return fmt.Sprintf("%03d %s", e.Code, e.Msg) +} + +func maybeId(cmd, id string) string { + if len(id) > 0 { + return cmd + " " + id + } + return cmd +} + +// Dial connects to an NNTP server. +// The network and addr are passed to net.Dial to +// make the connection. +// +// Example: +// conn, err := nntp.Dial("tcp", "my.news:nntp") +// +func Dial(network, addr string) (*Conn, os.Error) { + res := new(Conn) + c, err := net.Dial(network, "", addr) + if err != nil { + return nil, err + } + + res.conn = c + if res.r, err = bufio.NewReaderSize(c, 4096); err != nil { + return nil, err + } + + _, err = res.r.ReadString('\n') + if err != nil { + return nil, err + } + + return res, nil +} + +func (c *Conn) body() io.Reader { + c.br = &bodyReader{c: c} + return c.br +} + +// readStrings reads a list of strings from the NNTP connection, +// stopping at a line containing only a . (Convenience method for +// LIST, etc.) +func (c *Conn) readStrings() ([]string, os.Error) { + var sv vector.StringVector + for { + line, err := c.r.ReadString('\n') + if err != nil { + return nil, err + } + if strings.HasSuffix(line, "\r\n") { + line = line[0 : len(line)-2] + } else if strings.HasSuffix(line, "\n") { + line = line[0 : len(line)-1] + } + if line == "." { + break + } + sv.Push(line) + } + return []string(sv), nil +} + +// Authenticate logs in to the NNTP server. +// It only sends the password if the server requires one. +func (c *Conn) Authenticate(username, password string) os.Error { + code, _, err := c.cmd(2, "AUTHINFO USER %s", username) + if code/100 == 3 { + _, _, err = c.cmd(2, "AUTHINFO PASS %s", password) + } + return err +} + +// cmd executes an NNTP command: +// It sends the command given by the format and arguments, and then +// reads the response line. If expectCode > 0, the status code on the +// response line must match it. 1 digit expectCodes only check the first +// digit of the status code, etc. +func (c *Conn) cmd(expectCode uint, format string, args ...interface{}) (code uint, line string, err os.Error) { + if c.close { + return 0, "", ProtocolError("connection closed") + } + if c.br != nil { + if err := c.br.discard(); err != nil { + return 0, "", err + } + c.br = nil + } + if _, err := fmt.Fprintf(c.conn, format+"\r\n", args); err != nil { + return 0, "", err + } + line, err = c.r.ReadString('\n') + if err != nil { + return 0, "", err + } + line = strings.TrimSpace(line) + if len(line) < 4 || line[3] != ' ' { + return 0, "", ProtocolError("short response: " + line) + } + code, err = strconv.Atoui(line[0:3]) + if err != nil { + return 0, "", ProtocolError("invalid response code: " + line) + } + line = line[4:] + if 1 <= expectCode && expectCode < 10 && code/100 != expectCode || + 10 <= expectCode && expectCode < 100 && code/10 != expectCode || + 100 <= expectCode && expectCode < 1000 && code != expectCode { + err = Error{code, line} + } + return +} + +// ModeReader switches the NNTP server to "reader" mode, if it +// is a mode-switching server. +func (c *Conn) ModeReader() os.Error { + _, _, err := c.cmd(20, "MODE READER") + return err +} + +// NewGroups returns a list of groups added since the given time. +func (c *Conn) NewGroups(since *time.Time) ([]Group, os.Error) { + if _, _, err := c.cmd(231, "NEWGROUPS %s GMT", since.Format(timeFormatNew)); err != nil { + return nil, err + } + return c.readGroups() +} + +func (c *Conn) readGroups() ([]Group, os.Error) { + lines, err := c.readStrings() + if err != nil { + return nil, err + } + return parseGroups(lines) +} + +// NewNews returns a list of the IDs of articles posted +// to the given group since the given time. +func (c *Conn) NewNews(group string, since *time.Time) ([]string, os.Error) { + if _, _, err := c.cmd(230, "NEWNEWS %s %s GMT", group, since.Format(timeFormatNew)); err != nil { + return nil, err + } + + id, err := c.readStrings() + if err != nil { + return nil, err + } + + sort.SortStrings(id) + w := 0 + for r, s := range id { + if r == 0 || id[r-1] != s { + id[w] = s + w++ + } + } + id = id[0:w] + + return id, nil +} + +// parseGroups is used to parse a list of group states. +func parseGroups(lines []string) ([]Group, os.Error) { + var res vector.Vector + for _, line := range lines { + ss := strings.Split(strings.TrimSpace(line), " ", 4) + if len(ss) < 4 { + return nil, ProtocolError("short group info line: " + line) + } + high, err := strconv.Atoi(ss[1]) + if err != nil { + return nil, ProtocolError("bad number in line: " + line) + } + low, err := strconv.Atoi(ss[2]) + if err != nil { + return nil, ProtocolError("bad number in line: " + line) + } + res.Push(&Group{ss[0], high, low, ss[3]}) + } + realres := make([]Group, res.Len()) + i := 0 + for v := range res.Iter() { + realres[i] = *v.(*Group) + i++ + } + return realres, nil +} + +// Capabilities returns a list of features this server performs. +// Not all servers support capabilities. +func (c *Conn) Capabilities() ([]string, os.Error) { + if _, _, err := c.cmd(101, "CAPABILITIES"); err != nil { + return nil, err + } + return c.readStrings() +} + +// Date returns the current time on the server. +// Typically the time is later passed to NewGroups or NewNews. +func (c *Conn) Date() (*time.Time, os.Error) { + _, line, err := c.cmd(111, "DATE") + if err != nil { + return nil, err + } + t, err := time.Parse(timeFormatDate, line) + if err != nil { + return nil, ProtocolError("invalid time: " + line) + } + return t, nil +} + +// List returns a list of groups present on the server. +// Valid forms are: +// +// List() - return active groups +// List(keyword) - return different kinds of information about groups +// List(keyword, pattern) - filter groups against a glob-like pattern called a wildmat +// +func (c *Conn) List(a ...string) ([]string, os.Error) { + if len(a) > 2 { + return nil, ProtocolError("List only takes up to 2 arguments") + } + cmd := "LIST" + if len(a) > 0 { + cmd += " " + a[0] + if len(a) > 1 { + cmd += " " + a[1] + } + } + if _, _, err := c.cmd(215, cmd); err != nil { + return nil, err + } + return c.readStrings() +} + +// Group changes the current group. +func (c *Conn) Group(group string) (number, low, high int, err os.Error) { + _, line, err := c.cmd(211, "GROUP %s", group) + if err != nil { + return + } + + ss := strings.Split(line, " ", 4) // intentional -- we ignore optional message + if len(ss) < 3 { + err = ProtocolError("bad group response: " + line) + return + } + + var n [3]int + for i, _ := range n { + c, err := strconv.Atoi(ss[i]) + if err != nil { + err = ProtocolError("bad group response: " + line) + return + } + n[i] = c + } + number, low, high = n[0], n[1], n[2] + return +} + +// Help returns the server's help text. +func (c *Conn) Help() (io.Reader, os.Error) { + if _, _, err := c.cmd(100, "HELP"); err != nil { + return nil, err + } + return c.body(), nil +} + +// nextLastStat performs the work for NEXT, LAST, and STAT. +func (c *Conn) nextLastStat(cmd, id string) (string, string, os.Error) { + _, line, err := c.cmd(223, maybeId(cmd, id)) + if err != nil { + return "", "", err + } + ss := strings.Split(line, " ", 3) // optional comment ignored + if len(ss) < 2 { + return "", "", ProtocolError("Bad response to " + cmd + ": " + line) + } + return ss[0], ss[1], nil +} + +// Stat looks up the message with the given id and returns its +// message number in the current group, and vice versa. +// The returned message number can be "0" if the current group +// isn't one of the groups the message was posted to. +func (c *Conn) Stat(id string) (number, msgid string, err os.Error) { + return c.nextLastStat("STAT", id) +} + +// Last selects the previous article, returning its message number and id. +func (c *Conn) Last() (number, msgid string, err os.Error) { + return c.nextLastStat("LAST", "") +} + +// Next selects the next article, returning its message number and id. +func (c *Conn) Next() (number, msgid string, err os.Error) { + return c.nextLastStat("NEXT", "") +} + +// ArticleText returns the article named by id as an io.Reader. +// The article is in plain text format, not NNTP wire format. +func (c *Conn) ArticleText(id string) (io.Reader, os.Error) { + if _, _, err := c.cmd(220, maybeId("ARTICLE", id)); err != nil { + return nil, err + } + return c.body(), nil +} + +// Article returns the article named by id as an *Article. +func (c *Conn) Article(id string) (*Article, os.Error) { + if _, _, err := c.cmd(220, maybeId("ARTICLE", id)); err != nil { + return nil, err + } + r := bufio.NewReader(c.body()) + res, err := c.readHeader(r) + if err != nil { + return nil, err + } + res.Body = r + return res, nil +} + +// HeadText returns the header for the article named by id as an io.Reader. +// The article is in plain text format, not NNTP wire format. +func (c *Conn) HeadText(id string) (io.Reader, os.Error) { + if _, _, err := c.cmd(221, maybeId("HEAD", id)); err != nil { + return nil, err + } + return c.body(), nil +} + +// Head returns the header for the article named by id as an *Article. +// The Body field in the Article is nil. +func (c *Conn) Head(id string) (*Article, os.Error) { + if _, _, err := c.cmd(221, maybeId("HEAD", id)); err != nil { + return nil, err + } + return c.readHeader(bufio.NewReader(c.body())) +} + +// Body returns the body for the article named by id as an io.Reader. +func (c *Conn) Body(id string) (io.Reader, os.Error) { + if _, _, err := c.cmd(222, maybeId("BODY", id)); err != nil { + return nil, err + } + return c.body(), nil +} + +// RawPost reads a text-formatted article from r and posts it to the server. +func (c *Conn) RawPost(r io.Reader) os.Error { + if _, _, err := c.cmd(3, "POST"); err != nil { + return err + } + br := bufio.NewReader(r) + eof := false + for { + line, err := br.ReadString('\n') + if err == os.EOF { + eof = true + } else if err != nil { + return err + } + if eof && len(line) == 0 { + break + } + if strings.HasSuffix(line, "\n") { + line = line[0 : len(line)-1] + } + var prefix string + if strings.HasPrefix(line, ".") { + prefix = "." + } + _, err = fmt.Fprintf(c.conn, "%s%s\r\n", prefix, line) + if err != nil { + return err + } + if eof { + break + } + } + + if _, _, err := c.cmd(240, "."); err != nil { + return err + } + return nil +} + +// Post posts an article to the server. +func (c *Conn) Post(a *Article) os.Error { + return c.RawPost(&articleReader{a: a}) +} + +// Quit sends the QUIT command and closes the connection to the server. +func (c *Conn) Quit() os.Error { + _, _, err := c.cmd(0, "QUIT") + c.conn.Close() + c.close = true + return err +} + +// Functions after this point are mostly copy-pasted from http +// (though with some modifications). They should be factored out to +// a common library. + +// Read a line of bytes (up to \n) from b. +// Give up if the line exceeds maxLineLength. +// The returned bytes are a pointer into storage in +// the bufio, so they are only valid until the next bufio read. +func readLineBytes(b *bufio.Reader) (p []byte, err os.Error) { + if p, err = b.ReadSlice('\n'); err != nil { + // We always know when EOF is coming. + // If the caller asked for a line, there should be a line. + if err == os.EOF { + err = io.ErrUnexpectedEOF + } + return nil, err + } + + // Chop off trailing white space. + var i int + for i = len(p); i > 0; i-- { + if c := p[i-1]; c != ' ' && c != '\r' && c != '\t' && c != '\n' { + break + } + } + return p[0:i], nil +} + +var colon = []byte{':'} + +// Read a key/value pair from b. +// A key/value has the form Key: Value\r\n +// and the Value can continue on multiple lines if each continuation line +// starts with a space/tab. +func readKeyValue(b *bufio.Reader) (key, value string, err os.Error) { + line, e := readLineBytes(b) + if e == io.ErrUnexpectedEOF { + return "", "", nil + } else if e != nil { + return "", "", e + } + if len(line) == 0 { + return "", "", nil + } + + // Scan first line for colon. + i := bytes.Index(line, colon) + if i < 0 { + goto Malformed + } + + key = string(line[0:i]) + if strings.Index(key, " ") >= 0 { + // Key field has space - no good. + goto Malformed + } + + // Skip initial space before value. + for i++; i < len(line); i++ { + if line[i] != ' ' && line[i] != '\t' { + break + } + } + value = string(line[i:]) + + // Look for extension lines, which must begin with space. + for { + c, e := b.ReadByte() + if c != ' ' && c != '\t' { + if e != os.EOF { + b.UnreadByte() + } + break + } + + // Eat leading space. + for c == ' ' || c == '\t' { + if c, e = b.ReadByte(); e != nil { + if e == os.EOF { + e = io.ErrUnexpectedEOF + } + return "", "", e + } + } + b.UnreadByte() + + // Read the rest of the line and add to value. + if line, e = readLineBytes(b); e != nil { + return "", "", e + } + value += " " + string(line) + } + return key, value, nil + +Malformed: + return "", "", ProtocolError("malformed header line: " + string(line)) +} + +// Internal. Parses headers in NNTP articles. Most of this is stolen from the http package, +// and it should probably be split out into a generic RFC822 header-parsing package. +func (c *Conn) readHeader(r *bufio.Reader) (res *Article, err os.Error) { + res = new(Article) + res.Header = make(map[string][]string) + for { + var key, value string + if key, value, err = readKeyValue(r); err != nil { + return nil, err + } + if key == "" { + break + } + key = http.CanonicalHeaderKey(key) + // RFC 3977 says nothing about duplicate keys' values being equivalent to + // a single key joined with commas, so we keep all values seperate. + oldvalue, present := res.Header[key] + if present { + sv := vector.StringVector(oldvalue) + sv.Push(value) + res.Header[key] = []string(sv) + } else { + res.Header[key] = []string{value} + } + } + return res, nil +} diff --git a/src/pkg/nntp/nntp_test.go b/src/pkg/nntp/nntp_test.go new file mode 100644 index 00000000000..9bd7bd6b6d3 --- /dev/null +++ b/src/pkg/nntp/nntp_test.go @@ -0,0 +1,241 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package nntp + +import ( + "bufio" + "bytes" + "fmt" + "io" + "io/ioutil" + "os" + "strings" + "testing" + "time" +) + +func TestSanityChecks(t *testing.T) { + if _, err := Dial("", ""); err == nil { + t.Fatal("Dial should require at least a destination address.") + } +} + +type faker struct { + io.Writer +} + +func (f faker) Close() os.Error { + return nil +} + +func TestBasic(t *testing.T) { + basicServer = strings.Join(strings.Split(basicServer, "\n", 0), "\r\n") + basicClient = strings.Join(strings.Split(basicClient, "\n", 0), "\r\n") + + var cmdbuf bytes.Buffer + var fake faker + fake.Writer = &cmdbuf + + conn := &Conn{conn: fake, r: bufio.NewReader(strings.NewReader(basicServer))} + + // Test some global commands that don't take arguments + if _, err := conn.Capabilities(); err != nil { + t.Fatal("should be able to request CAPABILITIES after connecting: " + err.String()) + } + + _, err := conn.Date() + if err != nil { + t.Fatal("should be able to send DATE: " + err.String()) + } + + /* + Test broken until time.Parse adds this format. + cdate := time.UTC() + if sdate.Year != cdate.Year || sdate.Month != cdate.Month || sdate.Day != cdate.Day { + t.Fatal("DATE seems off, probably erroneous: " + sdate.String()) + } + */ + + // Test LIST (implicit ACTIVE) + if _, err = conn.List(); err != nil { + t.Fatal("LIST should work: " + err.String()) + } + + tt := new(time.Time) + tt.Year = 2010 + tt.Month = 3 + tt.Day = 1 + + const grp = "gmane.comp.lang.go.general" + _, l, h, err := conn.Group(grp) + if err != nil { + t.Fatal("Group shouldn't error: " + err.String()) + } + + // test STAT, NEXT, and LAST + if _, _, err = conn.Stat(""); err != nil { + t.Fatal("should be able to STAT after selecting a group: " + err.String()) + } + if _, _, err = conn.Next(); err != nil { + t.Fatal("should be able to NEXT after selecting a group: " + err.String()) + } + if _, _, err = conn.Last(); err != nil { + t.Fatal("should be able to LAST after a NEXT selecting a group: " + err.String()) + } + + // Can we grab articles? + a, err := conn.Article(fmt.Sprintf("%d", l)) + if err != nil { + t.Fatal("should be able to fetch the low article: " + err.String()) + } + body, err := ioutil.ReadAll(a.Body) + if err != nil { + t.Fatal("error reading reader: " + err.String()) + } + + // Test that the article body doesn't get mangled. + expectedbody := `Blah, blah. +.A single leading . +Fin. +` + if !bytes.Equal([]byte(expectedbody), body) { + t.Fatalf("article body read incorrectly; got:\n%s\nExpected:\n%s", body, expectedbody) + } + + // Test articleReader + expectedart := `Message-Id: + +Body. +` + a, err = conn.Article(fmt.Sprintf("%d", l+1)) + if err != nil { + t.Fatal("shouldn't error reading article low+1: " + err.String()) + } + var abuf bytes.Buffer + _, err = a.WriteTo(&abuf) + if err != nil { + t.Fatal("shouldn't error writing out article: " + err.String()) + } + actualart := abuf.String() + if actualart != expectedart { + t.Fatalf("articleReader broke; got:\n%s\nExpected\n%s", actualart, expectedart) + } + + // Just headers? + if _, err = conn.Head(fmt.Sprintf("%d", h)); err != nil { + t.Fatal("should be able to fetch the high article: " + err.String()) + } + + // Without an id? + if _, err = conn.Head(""); err != nil { + t.Fatal("should be able to fetch the selected article without specifying an id: " + err.String()) + } + + // How about bad articles? Do they error? + if _, err = conn.Head(fmt.Sprintf("%d", l-1)); err == nil { + t.Fatal("shouldn't be able to fetch articles lower than low") + } + if _, err = conn.Head(fmt.Sprintf("%d", h+1)); err == nil { + t.Fatal("shouldn't be able to fetch articles higher than high") + } + + // Just the body? + r, err := conn.Body(fmt.Sprintf("%d", l)) + if err != nil { + t.Fatal("should be able to fetch the low article body\n" + err.String()) + } + if _, err = ioutil.ReadAll(r); err != nil { + t.Fatal("error reading reader: " + err.String()) + } + + if _, err = conn.NewNews(grp, tt); err != nil { + t.Fatal("newnews should work: " + err.String()) + } + + + // NewGroups + if _, err = conn.NewGroups(tt); err != nil { + t.Fatal("newgroups shouldn't error " + err.String()) + } + + if err = conn.Quit(); err != nil { + t.Fatal("Quit shouldn't error: " + err.String()) + } + + actualcmds := cmdbuf.String() + if basicClient != actualcmds { + t.Fatalf("Got:\n%s\nExpected\n%s", actualcmds, basicClient) + } +} + +var basicServer = `101 Capability list: +VERSION 2 +. +111 20100329034158 +215 Blah blah +foo 7 3 y +bar 000008 02 m +. +211 100 1 100 gmane.comp.lang.go.general +223 1 status +223 2 Article retrieved +223 1 Article retrieved +220 1 article +Path: fake!not-for-mail +From: Someone +Newsgroups: gmane.comp.lang.go.general +Subject: [go-nuts] What about base members? +Message-ID: + +Blah, blah. +..A single leading . +Fin. +. +220 2 article +Message-ID: + +Body. +. +221 100 head +Path: fake!not-for-mail +Message-ID: +. +221 100 head +Path: fake!not-for-mail +Message-ID: +. +423 Bad article number +423 Bad article number +222 1 body +Blah, blah. +..A single leading . +Fin. +. +230 list of new articles by message-id follows + +. +231 New newsgroups follow +. +205 Bye! +` + +var basicClient = `CAPABILITIES +DATE +LIST +GROUP gmane.comp.lang.go.general +STAT +NEXT +LAST +ARTICLE 1 +ARTICLE 2 +HEAD 100 +HEAD +HEAD 0 +HEAD 101 +BODY 1 +NEWNEWS gmane.comp.lang.go.general 20100301 000000 GMT +NEWGROUPS 20100301 000000 GMT +QUIT +`