Commit Diff


commit - 796e17a92de54043f25d21e24bf89098def4f9c3
commit + fcf319e83dbec9b824aae4d62e9a4d99cc473dd7
blob - 27a30aa6d4197d6488e180e6605d6f569200b5ae (mode 644)
blob + /dev/null
--- fs.go
+++ /dev/null
@@ -1,474 +0,0 @@
-package lemmy
-
-import (
-	"bytes"
-	"errors"
-	"fmt"
-	"io"
-	"io/fs"
-	"path"
-	"strconv"
-	"strings"
-	"time"
-)
-
-/*
-FS is a read-only filesystem interface to a Lemmy instance.
-The root of the filesystem holds directories for each community known to the filesystem.
-Local communities are named by their plain name verbatim.
-Remote communities have the instance address as a suffix. For example:
-
-	golang/
-	plan9@lemmy.sdf.org/
-	openbsd@lemmy.sdf.org/
-
-Each community directory holds posts.
-Each post has associated a directory numbered by its ID.
-Within each post are the following entries:
-
-	body     Text describing, or accompanying, the post.
-	creator  The numeric user ID of the post's author.
-	title    The post's title.
-	url      A URL pointing to a picture or website, usually as the
-	         subject of the post if present.
-	comments Directory holding user-generated discussion.
-	         Described in more detail below.
-
-The comments directory consists of one numbered directory per comment,
-numbered by its unique comment ID.
-Each of these directories contains the following entries:
-
-	content    The text body.
-	creator    User ID of the comment's author.
-	references A list of comment IDs referenced by this comment, one
-	           per line. The first line is the immediately referenced
-	           comment (the parent); the second is the grandparent and
-	           so on. This can be used by readers to render discussion
-	           threads.
-
-FS satisfies io/fs.FS.
-*/
-type FS struct {
-	Client *Client
-	// Communities holds a cache of communities.
-	Communities map[string]Community
-	root        *node
-	baseURL     string
-	started     bool
-}
-
-type node struct {
-	name      string
-	Community *Community
-	Post      *Post
-	Comment   *Comment
-	dummy     *dummy
-	children  []node
-}
-
-func (n *node) Info() fs.FileInfo {
-	switch {
-	case n.Community != nil:
-		return n.Community
-	case n.Post != nil:
-		return n.Post
-	case n.Comment != nil:
-		return n.Comment
-	case n.dummy != nil:
-		return n.dummy
-	}
-	return nil
-}
-
-type file struct {
-	name      string
-	buf       io.ReadCloser
-	dirinfo   *dirInfo
-	client    *Client
-	Community *Community
-	Post      *Post
-	Comment   *Comment
-	dummy     *dummy
-}
-
-type dirInfo struct {
-	entries []fs.DirEntry
-	entryp  int
-}
-
-func (f *file) Stat() (fs.FileInfo, error) {
-	switch {
-	case f.dummy != nil:
-		return f.dummy, nil
-	case f.Community != nil:
-		return f.Community, nil
-	case f.Post != nil:
-		return f.Post, nil
-	case f.Comment != nil:
-		return f.Comment, nil
-	}
-	return &dummy{}, nil
-}
-
-func (f *file) Read(p []byte) (int, error) {
-	if f.buf == nil {
-		return 0, &fs.PathError{"read", f.name, fs.ErrClosed}
-	}
-	n, err := f.buf.Read(p)
-	if errors.Is(err, io.EOF) {
-		return n, io.EOF
-	} else if err != nil {
-		return n, &fs.PathError{"read", f.name, err}
-	}
-	return n, nil
-}
-
-func (f *file) Close() error {
-	if f.buf == nil {
-		return fs.ErrClosed
-	}
-	err := f.buf.Close()
-	f.buf = nil
-	if err != nil {
-		return &fs.PathError{"close", f.name, err}
-	}
-	return nil
-}
-
-func (f *file) ReadDir(n int) ([]fs.DirEntry, error) {
-	if f.dirinfo == nil {
-		return nil, &fs.PathError{"readdir", f.name, errors.New("not a directory")}
-	}
-
-	d := f.dirinfo
-	// generate dir entries if we haven't read any yet
-	if d.entryp == 0 {
-		switch {
-		case f.Community != nil:
-			posts, err := f.client.Posts(f.name, ListAll)
-			if err != nil {
-				return nil, &fs.PathError{"readdir", f.name, err}
-			}
-			for _, post := range posts {
-				post := post
-				d.entries = append(d.entries, fs.FileInfoToDirEntry(&post))
-			}
-		case f.name == "comments":
-			// special case of the comments directory.
-			// Lke a directory in Plan 9, we store directory data as its file contents.
-			// In this case we've stored Post.ID.
-			buf := make([]byte, 8) // big enough for int64
-			n, err := f.Read(buf)
-			if err != nil && !errors.Is(err, io.EOF) {
-				return nil, &fs.PathError{"readdir", f.name, err}
-			}
-			id, err := strconv.Atoi(string(buf[:n]))
-			if err != nil {
-				return nil, &fs.PathError{"readdir", f.name, err}
-			}
-			comments, err := f.client.Comments(id, ListAll)
-			if err != nil {
-				return nil, &fs.PathError{"readdir", f.name, err}
-			}
-			for _, c := range comments {
-				c := c
-				d.entries = append(d.entries, fs.FileInfoToDirEntry(&c))
-			}
-		}
-	}
-	entries := d.entries[d.entryp:]
-	if n < 0 {
-		d.entryp = len(d.entries) // advance to the end
-		if len(entries) == 0 {
-			return nil, nil
-		}
-		return entries, nil
-	}
-
-	var err error
-	if n >= len(entries) {
-		err = io.EOF
-	} else {
-		entries = entries[:n-1]
-	}
-	d.entryp += n
-	return entries, err
-}
-
-type dummy struct {
-	name     string
-	isDir    bool
-	contents []byte
-}
-
-func (f *dummy) Name() string { return f.name }
-func (f *dummy) Size() int64  { return int64(len(f.contents)) }
-
-func (f *dummy) Mode() fs.FileMode {
-	if f.isDir {
-		return fs.ModeDir | 0o0444
-	}
-	return 0o0444
-}
-
-func (f *dummy) ModTime() time.Time { return time.Unix(0, 0) }
-func (f *dummy) IsDir() bool        { return f.isDir }
-func (f *dummy) Sys() interface{}   { return nil }
-
-func (fsys *FS) init() error {
-	if fsys.Client == nil {
-		fsys.Client = &Client{}
-	}
-	if fsys.Communities == nil {
-		fsys.Communities = make(map[string]Community)
-		if fsys.Client.authToken != "" {
-			subscribed, err := fsys.Client.Communities(ListSubscribed)
-			if err != nil {
-				return fmt.Errorf("load subscriptions: %w", err)
-			}
-			for _, c := range subscribed {
-				fsys.Communities[c.Name()] = c
-			}
-		}
-	}
-	fsys.started = true
-	return nil
-}
-
-func (fsys *FS) Open(name string) (fs.File, error) {
-	if !fs.ValidPath(name) {
-		return nil, &fs.PathError{"open", name, fs.ErrInvalid}
-	} else if strings.Contains(name, `\`) {
-		return nil, &fs.PathError{"open", name, fs.ErrInvalid}
-	}
-	name = path.Clean(name)
-
-	if !fsys.started {
-		if err := fsys.init(); err != nil {
-			return nil, fmt.Errorf("start fs: %w", err)
-		}
-	}
-
-	node, err := fsys.lookupNode(name)
-	if err != nil {
-		return nil, &fs.PathError{"open", name, err}
-	}
-	return fsys.nodeOpen(node)
-}
-
-func (fsys *FS) nodeOpen(node *node) (*file, error) {
-	f := &file{
-		name:   node.name,
-		client: fsys.Client,
-	}
-	switch {
-	case node.Community != nil:
-		f.Community = node.Community
-		f.buf = io.NopCloser(strings.NewReader(node.name))
-	case node.Post != nil:
-		f.Post = node.Post
-		f.buf = io.NopCloser(strings.NewReader(node.name))
-	case node.Comment != nil:
-		f.Comment = node.Comment
-		f.buf = io.NopCloser(strings.NewReader(node.Comment.Content))
-	case node.dummy != nil:
-		f.dummy = node.dummy
-		f.buf = io.NopCloser(bytes.NewReader(node.dummy.contents))
-	default:
-		f.buf = io.NopCloser(bytes.NewReader(nil))
-	}
-	if len(node.children) >= 0 && f.dirinfo == nil {
-		f.dirinfo = new(dirInfo)
-		for _, child := range node.children {
-			f.dirinfo.entries = append(f.dirinfo.entries, fs.FileInfoToDirEntry(child.Info()))
-		}
-	}
-	return f, nil
-}
-
-func (fsys *FS) lookupNode(pathname string) (*node, error) {
-	root := &node{
-		name: ".",
-		dummy: &dummy{
-			name:  ".",
-			isDir: true,
-		},
-		children: []node{},
-	}
-	for _, c := range fsys.Communities {
-		c := c
-		root.children = append(root.children, c.fsNode())
-	}
-	if pathname == "." {
-		return root, nil
-	}
-
-	// First element is a community.
-	comname, leftover, _ := strings.Cut(pathname, "/")
-	var community Community
-	community, ok := fsys.Communities[comname]
-	if !ok {
-		var err error
-		community, err = fsys.Client.LookupCommunity(comname)
-		if errors.Is(err, ErrNotFound) {
-			return nil, fs.ErrNotExist
-		} else if err != nil {
-			return nil, err
-		}
-		fsys.Communities[community.Name()] = community
-		root.children = append(root.children, community.fsNode())
-	}
-	// punt; maybe we only want the community.
-	if i, n := findNode(root, pathname); i >= 0 {
-		return n, nil
-	}
-
-	// next element is a post.
-	postname, leftover, _ := strings.Cut(leftover, "/")
-	id, err := strconv.Atoi(postname)
-	if err != nil {
-		return nil, fmt.Errorf("get posts: %w", err)
-	}
-	post, err := fsys.Client.LookupPost(id)
-	if errors.Is(err, ErrNotFound) {
-		return nil, fs.ErrNotExist
-	} else if err != nil {
-		return nil, err
-	}
-	i, cnode := findNode(root, community.String())
-	if i < 0 {
-		return nil, fs.ErrNotExist
-	}
-	cnode.children = append(cnode.children, post.fsNode())
-	root.children[i] = *cnode
-	if i, n := findNode(root, pathname); i >= 0 {
-		return n, nil
-	}
-
-	// community/1234/comments/5678/content
-	// only comments left.
-	// first, trim the "comments" element to get the comment ID.
-	// a comment is a directory file.
-	commentDir := strings.TrimPrefix(leftover, "comments/")
-	commentID, leftover, _ := strings.Cut(commentDir, "/")
-	id, err = strconv.Atoi(path.Clean(commentID))
-	if err != nil {
-		return nil, fmt.Errorf("%s: %w", fs.ErrInvalid, err)
-	}
-	comment, err := fsys.Client.LookupComment(id)
-	if err != nil {
-		return nil, err
-	}
-
-	j, pnode := findNode(root, path.Join(community.String(), postname))
-	if j < 0 {
-		return nil, fs.ErrNotExist
-	}
-	for i, child := range pnode.children {
-		if child.name == "comments" {
-			child.children = []node{comment.fsNode()}
-			pnode.children[i] = child
-		}
-	}
-	root.children[i].children[j] = *pnode
-
-	if i, n := findNode(root, pathname); i >= 0 {
-		return n, nil
-	}
-	return nil, fs.ErrNotExist
-}
-
-func findNode(parent *node, pathname string) (int, *node) {
-	dirname, leftover, _ := strings.Cut(pathname, "/")
-	for i, child := range parent.children {
-		if child.name == dirname && leftover == "" {
-			return i, &child
-		} else if child.name == dirname {
-			return findNode(&child, leftover)
-		}
-	}
-	return -1, nil
-}
-
-func (c *Community) fsNode() node {
-	return node{
-		name:      c.String(),
-		Community: c,
-		children:  []node{},
-	}
-}
-
-func (p *Post) fsNode() node {
-	return node{
-		name: strconv.Itoa(p.ID),
-		Post: p,
-		children: []node{
-			node{
-				name: "title",
-				dummy: &dummy{
-					name:     "title",
-					contents: []byte(p.Title),
-				},
-			},
-			node{
-				name: "creator",
-				dummy: &dummy{
-					name:     "creator",
-					contents: []byte(strconv.Itoa(p.CreatorID)),
-				},
-			},
-			node{
-				name: "body",
-				dummy: &dummy{
-					name:     "body",
-					contents: []byte(p.Body),
-				},
-			},
-			node{
-				name: "url",
-				dummy: &dummy{
-					name:     "url",
-					contents: []byte(p.URL),
-				},
-			},
-			node{
-				name: "comments",
-				dummy: &dummy{
-					name:     "comments",
-					isDir:    true,
-					contents: []byte(strconv.Itoa(p.ID)),
-				},
-			},
-		},
-	}
-}
-
-func (c *Comment) fsNode() node {
-	return node{
-		name:    strconv.Itoa(c.ID),
-		Comment: c,
-		children: []node{
-			node{
-				name: "content",
-				dummy: &dummy{
-					name:     "content",
-					contents: []byte(c.Content),
-				},
-			},
-			node{
-				name: "creator",
-				dummy: &dummy{
-					name:     "creator",
-					contents: []byte(strconv.Itoa(c.CreatorID)),
-				},
-			},
-			node{
-				name: "references",
-				dummy: &dummy{
-					name:     "references",
-					contents: []byte(fmt.Sprintf("%v", c.References)),
-				},
-			},
-		},
-	}
-}
blob - /dev/null
blob + e8e734b105bceb685fcf884777f81a0de929c2cf (mode 644)
--- /dev/null
+++ fs/dir.go
@@ -0,0 +1,33 @@
+package fs
+
+import (
+	"io"
+	"io/fs"
+)
+
+type dirInfo struct {
+	entries []fs.DirEntry
+	entryp  int
+}
+
+func (d *dirInfo) ReadDir(n int) ([]fs.DirEntry, error) {
+	entries := d.entries[d.entryp:]
+	if n < 0 {
+		d.entryp = len(d.entries) // advance to the end
+		if len(entries) == 0 {
+			return nil, nil
+		}
+		return entries, nil
+	}
+
+	var err error
+	if n >= len(entries) {
+		err = io.EOF
+	} else if d.entryp >= len(d.entries) {
+		err = io.EOF
+	} else {
+		entries = entries[:n-1]
+	}
+	d.entryp += n
+	return entries, err
+}
blob - /dev/null
blob + 9227e6b58f1c63117d90ac3e10dae531fa84ec3f (mode 644)
--- /dev/null
+++ fs/doc.go
@@ -0,0 +1,37 @@
+/*
+FS is a read-only filesystem interface to a Lemmy instance.
+The root of the filesystem holds directories for each community known to the filesystem.
+Local communities are named by their plain name verbatim.
+Remote communities have the instance address as a suffix. For example:
+
+	golang/
+	plan9@lemmy.sdf.org/
+	openbsd@lemmy.sdf.org/
+
+Each community directory holds posts.
+Each post has associated a directory numbered by its ID.
+Within each post are the following entries:
+
+	body     Text describing, or accompanying, the post.
+	creator  The numeric user ID of the post's author.
+	title    The post's title.
+	url      A URL pointing to a picture or website, usually as the
+	         subject of the post if present.
+	123...   Numbered files containing user discussion.
+	         Described in more detail below.
+
+A comment file is named by its unique comment ID.
+Its contents are a RFC 5322 message.
+The message body contains the text content of the comment.
+The header contains the following fields:
+
+	From       User ID of the comment's author.
+	References A list of comment IDs referenced by this comment, one
+	           per line. The first line is the immediately referenced
+	           comment (the parent); the second is the grandparent and
+	           so on. This can be used by readers to render discussion
+	           threads.
+
+FS satisfies io/fs.FS.
+*/
+package fs
blob - /dev/null
blob + 934c074d7f631ab82c3d20cbb7fed934b5a413f2 (mode 644)
--- /dev/null
+++ fs/file.go
@@ -0,0 +1,221 @@
+package fs
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+
+	"olowe.co/lemmy"
+)
+
+type fakeStat struct {
+	name  string
+	size  int64
+	mode  fs.FileMode
+	mtime time.Time
+}
+
+func (s *fakeStat) Name() string       { return s.name }
+func (s *fakeStat) Size() int64        { return s.size }
+func (s *fakeStat) Mode() fs.FileMode  { return s.mode }
+func (s *fakeStat) ModTime() time.Time { return s.mtime }
+func (s *fakeStat) IsDir() bool        { return s.mode.IsDir() }
+func (s *fakeStat) Sys() any           { return nil }
+
+type dummy struct {
+	name     string
+	mode     fs.FileMode
+	mtime    time.Time
+	contents []byte
+	dirinfo  *dirInfo
+	buf      io.ReadCloser
+}
+
+func (f *dummy) Name() string               { return f.name }
+func (f *dummy) IsDir() bool                { return f.mode.IsDir() }
+func (f *dummy) Type() fs.FileMode          { return f.mode.Type() }
+func (f *dummy) Info() (fs.FileInfo, error) { return f.Stat() }
+
+func (f *dummy) Stat() (fs.FileInfo, error) {
+	return &fakeStat{
+		name:  f.name,
+		mode:  f.mode,
+		size:  int64(len(f.contents)),
+		mtime: f.mtime,
+	}, nil
+}
+
+func (f *dummy) Read(p []byte) (int, error) {
+	if f.buf == nil {
+		f.buf = io.NopCloser(bytes.NewReader(f.contents))
+	}
+	return f.buf.Read(p)
+}
+
+func (f *dummy) Close() error {
+	if f.buf == nil {
+		return nil
+	}
+	err := f.buf.Close()
+	f.buf = nil
+	return err
+}
+
+func (f *dummy) ReadDir(n int) ([]fs.DirEntry, error) {
+	if !f.mode.IsDir() {
+		return nil, &fs.PathError{"readdir", f.name, fmt.Errorf("not a directory")}
+	} else if f.dirinfo == nil {
+		// TODO(otl): is this accidental? maybe return an error here.
+		return nil, &fs.PathError{"readdir", f.name, fmt.Errorf("no dirinfo to track reads")}
+	}
+
+	return f.dirinfo.ReadDir(n)
+}
+
+type comFile struct {
+	name    string
+	dirinfo *dirInfo
+	client  *lemmy.Client
+	buf     io.ReadCloser
+}
+
+func (f *comFile) Read(p []byte) (int, error) {
+	if f.buf == nil {
+		f.buf = io.NopCloser(strings.NewReader("directory"))
+	}
+	return f.buf.Read(p)
+}
+
+func (f *comFile) Close() error {
+	if f.buf == nil || f.dirinfo == nil {
+		return fs.ErrClosed
+	}
+	f.dirinfo = nil
+	err := f.buf.Close()
+	f.buf = nil
+	return err
+}
+
+func (f *comFile) Stat() (fs.FileInfo, error) {
+	community, err := f.client.LookupCommunity(f.name)
+	if err != nil {
+		return nil, &fs.PathError{"stat", f.name, err}
+	}
+	return &community, nil
+}
+
+func (f *comFile) ReadDir(n int) ([]fs.DirEntry, error) {
+	if f.dirinfo == nil {
+		f.dirinfo = new(dirInfo)
+		posts, err := f.client.Posts(f.name, lemmy.ListAll)
+		if err != nil {
+			return nil, &fs.PathError{"readdir", f.name, err}
+		}
+		for i := range posts {
+			f.dirinfo.entries = append(f.dirinfo.entries, fs.FileInfoToDirEntry(&posts[i]))
+		}
+	}
+	return f.dirinfo.ReadDir(n)
+}
+
+type postFile struct {
+	post    lemmy.Post
+	dirinfo *dirInfo
+	client  *lemmy.Client
+	buf     io.ReadCloser
+}
+
+func (f *postFile) Stat() (fs.FileInfo, error) {
+	name := strconv.Itoa(f.post.ID)
+	var err error
+	f.post, err = f.client.LookupPost(f.post.ID)
+	if errors.Is(err, lemmy.ErrNotFound) {
+		return nil, &fs.PathError{"stat", name, fs.ErrNotExist}
+	} else if err != nil {
+		return nil, &fs.PathError{"stat", name, err}
+	}
+	return &f.post, nil
+}
+
+func (f *postFile) Read(p []byte) (int, error) {
+	if f.buf == nil {
+		return 0, io.EOF
+	}
+	return f.buf.Read(p)
+}
+
+func (f *postFile) Close() error {
+	if f.buf == nil || f.dirinfo == nil {
+		return fs.ErrClosed
+	}
+	f.dirinfo = nil
+	err := f.buf.Close()
+	f.buf = nil
+	return err
+}
+
+func (f *postFile) ReadDir(n int) ([]fs.DirEntry, error) {
+	name := strconv.Itoa(f.post.ID)
+	if f.dirinfo == nil {
+		f.dirinfo = new(dirInfo)
+		for _, v := range postFiles(f.post) {
+			f.dirinfo.entries = append(f.dirinfo.entries, v)
+		}
+		comments, err := f.client.Comments(f.post.ID, lemmy.ListAll)
+		if err != nil {
+			return nil, &fs.PathError{"readdir", name, err}
+		}
+		for i := range comments {
+			f.dirinfo.entries = append(f.dirinfo.entries, fs.FileInfoToDirEntry(&comments[i]))
+		}
+	}
+	return f.dirinfo.ReadDir(n)
+}
+
+func postFiles(post lemmy.Post) map[string]*dummy {
+	m := make(map[string]*dummy)
+	m["body"] = &dummy{
+		name:     "body",
+		mode:     0444,
+		contents: []byte(post.Body),
+		mtime:    post.ModTime(),
+	}
+	m["creator"] = &dummy{
+		name:     "creator",
+		mode:     0444,
+		contents: []byte(strconv.Itoa(post.CreatorID)),
+		mtime:    post.ModTime(),
+	}
+	m["title"] = &dummy{
+		name:     "title",
+		mode:     0444,
+		contents: []byte(post.Title),
+		mtime:    post.ModTime(),
+	}
+	m["url"] = &dummy{
+		name:     "url",
+		mode:     0444,
+		contents: []byte(post.URL),
+		mtime:    post.ModTime(),
+	}
+	return m
+}
+
+func commentFile(c *lemmy.Comment) *dummy {
+	buf := &bytes.Buffer{}
+	fmt.Fprintf(buf, "From: %d\n", c.CreatorID)
+	fmt.Fprintln(buf)
+	fmt.Fprintln(buf, c.Content)
+	return &dummy{
+		name:  c.Name(),
+		mode:  c.Mode(),
+		mtime: c.ModTime(),
+		buf:   io.NopCloser(buf),
+	}
+}
blob - /dev/null
blob + a2e673c958113926567be080fcc56290cd5dda17 (mode 644)
--- /dev/null
+++ fs/fs.go
@@ -0,0 +1,157 @@
+package fs
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"os"
+	"path"
+	"strconv"
+	"strings"
+	"time"
+
+	"olowe.co/lemmy"
+)
+
+type FS struct {
+	Client *lemmy.Client
+	// Communities holds a cache of communities.
+	Communities map[string]lemmy.Community
+	Posts       map[int]lemmy.Post
+	started     bool
+}
+
+func (fsys *FS) start() error {
+	if fsys.Client == nil {
+		fsys.Client = &lemmy.Client{}
+	}
+
+	if fsys.Communities == nil {
+		fsys.Communities = make(map[string]lemmy.Community)
+		/*
+			if fsys.Client.authToken != "" {
+				subscribed, err := fsys.Client.Communities(lemmy.ListSubscribed)
+				if err != nil {
+					return fmt.Errorf("load subscriptions: %w", err)
+				}
+				for _, c := range subscribed {
+					fsys.Communities[c.Name()] = c
+				}
+			}
+		*/
+	}
+
+	if fsys.Posts == nil {
+		fsys.Posts = make(map[int]lemmy.Post)
+	}
+
+	fsys.started = true
+	return nil
+}
+
+func (fsys *FS) Open(name string) (fs.File, error) {
+	if !fs.ValidPath(name) {
+		return nil, &fs.PathError{"open", name, fs.ErrInvalid}
+	} else if strings.Contains(name, `\`) {
+		return nil, &fs.PathError{"open", name, fs.ErrInvalid}
+	}
+	name = path.Clean(name)
+
+	if !fsys.started {
+		if err := fsys.start(); err != nil {
+			return nil, fmt.Errorf("start fs: %w", err)
+		}
+	}
+
+	if name == "." {
+		return fsys.openRoot()
+	}
+
+	elems := strings.Split(name, "/")
+
+	community, ok := fsys.Communities[elems[0]]
+	if !ok {
+		var err error
+		community, err = fsys.Client.LookupCommunity(elems[0])
+		if errors.Is(err, lemmy.ErrNotFound) {
+			return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+		} else if err != nil {
+			return nil, &fs.PathError{"open", name, err}
+		}
+		fsys.Communities[elems[0]] = community
+	}
+	if len(elems) == 1 {
+		return &comFile{
+			name:   community.Name(),
+			buf:    io.NopCloser(strings.NewReader(community.Name())),
+			client: fsys.Client,
+		}, nil
+	}
+
+	id, err := strconv.Atoi(elems[1])
+	if err != nil {
+		return nil, &fs.PathError{"open", name, fmt.Errorf("bad post id")}
+	}
+	post, ok := fsys.Posts[id]
+	if !ok || len(elems) == 2 {
+		post, err = fsys.Client.LookupPost(id)
+		if errors.Is(err, lemmy.ErrNotFound) {
+			delete(fsys.Posts, id)
+			return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+		} else if err != nil {
+			return nil, &fs.PathError{"open", name, err}
+		}
+		fsys.Posts[id] = post
+	}
+
+	if len(elems) == 2 {
+		return &postFile{
+			post:   post,
+			buf:    io.NopCloser(strings.NewReader("")),
+			client: fsys.Client,
+		}, nil
+	}
+
+	// At the bottom of the tree. If there's more than one element left then
+	// it cannot exist.
+	if len(elems) > 3 {
+		return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+	}
+
+	// if it's not a numbered directory (comment) it must be a regular file.
+	if !strings.ContainsAny(elems[2], "0123456789") {
+		files := postFiles(post)
+		if f, ok := files[elems[2]]; ok {
+			return f, nil
+		}
+		return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+	}
+
+	id, err = strconv.Atoi(elems[2])
+	if err != nil {
+		return nil, &fs.PathError{"open", name, fmt.Errorf("bad comment id")}
+	}
+	comment, err := fsys.Client.LookupComment(id)
+	if errors.Is(err, lemmy.ErrNotFound) {
+		return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+	} else if err != nil {
+		return nil, &fs.PathError{"open", name, err}
+	}
+	return commentFile(&comment), nil
+}
+
+func (fsys *FS) openRoot() (fs.File, error) {
+	dirinfo := new(dirInfo)
+	for _, c := range fsys.Communities {
+		de := fs.FileInfoToDirEntry(&c)
+		dirinfo.entries = append(dirinfo.entries, de)
+	}
+	return &dummy{
+		name:     ".",
+		mode:     fs.ModeDir | 0444,
+		contents: []byte("hello, world!"),
+		dirinfo:  dirinfo,
+		mtime:    time.Now(),
+	}, nil
+}
blob - /dev/null
blob + 318c46eb97c837bb2f9b4c64285b56c8d7aaaa1c (mode 644)
--- /dev/null
+++ fs/fs_test.go
@@ -0,0 +1,25 @@
+package fs
+
+import (
+	"testing"
+	"testing/fstest"
+
+	"olowe.co/lemmy"
+)
+
+func TestFS(t *testing.T) {
+	fsys := &FS{
+		Client: &lemmy.Client{
+			Address: "ds9.lemmy.ml",
+			Debug:   true,
+		},
+	}
+	_, err := fsys.Open("zzztestcommunity1")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if err := fstest.TestFS(fsys, "zzztestcommunity1", "zzztestcommunity1/447/title", "zzztestcommunity1/447/331"); err != nil {
+		t.Fatal(err)
+	}
+}
blob - ff18fba3be4189956d8901b3a968fcdee9b4c39d
blob + 18cc639701c8112104fc28817abac957b11744b8
--- lemmy.go
+++ lemmy.go
@@ -70,9 +70,9 @@ type Comment struct {
 func (c *Comment) Name() string { return strconv.Itoa(c.ID) }
 
 func (c *Comment) Size() int64        { return 0 }
-func (c *Comment) Mode() fs.FileMode  { return fs.ModeDir | 0o0444 }
+func (c *Comment) Mode() fs.FileMode  { return 0444 }
 func (c *Comment) ModTime() time.Time { return time.Unix(0, 0) } // TODO c.Updated
-func (c *Comment) IsDir() bool        { return true }
+func (c *Comment) IsDir() bool        { return c.Mode().IsDir() }
 func (c *Comment) Sys() interface{}   { return nil }
 
 // parseCommentPath returns the comment IDs from the path field of a Comment.