Initial commit

This commit is contained in:
2026-01-19 01:51:08 +02:00
commit 2dbb7e5d7e
18 changed files with 1475 additions and 0 deletions

28
ffs/dreamfile.go Normal file
View File

@@ -0,0 +1,28 @@
package ffs
import (
"context"
"fmt"
"fusetest/furcadia"
"bazil.org/fuse"
)
///////////////////////////////////////////////////////////////////////////////////////////////////
// TYPE: DreamFile ////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
type DreamFile struct {
Dream *furcadia.PounceDream
_attr fuse.Attr
}
func (f *DreamFile) GetName() string {
return f.Dream.ShortURL
}
func (f *DreamFile) Attr(ctx context.Context, a *fuse.Attr) error {
fmt.Printf("[D] DreamFile('%v').Attr()\n", f.GetName())
*a = f._attr
return nil
}

115
ffs/dreamsdir.go Normal file
View File

@@ -0,0 +1,115 @@
package ffs
import (
"context"
"fmt"
"os"
"syscall"
"time"
"bazil.org/fuse"
"bazil.org/fuse/fs"
)
///////////////////////////////////////////////////////////////////////////////////////////////////
// CONSTANTS //////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
const (
DREAMS_DIR_INODE_BASE = 0x2000
DREAMS_DIR_MAX_INODES = 0x1000
)
///////////////////////////////////////////////////////////////////////////////////////////////////
// TYPE: DreamsDir ////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
type DreamsDir struct {
_attr fuse.Attr
_entries []fuse.Dirent
}
func NewDreamsDir(inodeBase uint64, rootUID, rootGID uint32) *DreamsDir {
now := time.Now()
dir := &DreamsDir{
_attr: fuse.Attr{
Inode: inodeBase,
Nlink: 1,
Mode: os.ModeDir | 0500,
Uid: rootUID,
Gid: rootGID,
Atime: now,
Ctime: now,
Mtime: now,
},
}
// add basic entries
dir._entries = append(
dir._entries,
fuse.Dirent{Inode: dir._attr.Inode, Name: ".", Type: fuse.DT_Dir},
fuse.Dirent{Inode: ROOT_INODE, Name: "..", Type: fuse.DT_Dir},
)
return dir
}
func (d *DreamsDir) GetAttr() *fuse.Attr {
return &d._attr
}
func (d *DreamsDir) Attr(ctx context.Context, a *fuse.Attr) error {
*a = d._attr
return nil
}
func (d *DreamsDir) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
pounce := G_FS._pounce
dreams := pounce.Dreams
items := make([]fuse.Dirent, len(dreams)+2)
items[0] = fuse.Dirent{Inode: d._attr.Inode, Name: ".", Type: fuse.DT_Dir}
items[1] = fuse.Dirent{Inode: ROOT_INODE, Name: "..", Type: fuse.DT_Dir}
i := 2
for shortURL := range dreams {
items[i] = fuse.Dirent{
Inode: uint64(2000 + i),
Name: shortURL,
Type: fuse.DT_File,
}
i++
}
return items, nil
}
func (d *DreamsDir) Lookup(ctx context.Context, name string) (fs.Node, error) {
if name == "." {
return d, nil
}
dreams := G_FS._pounce.Dreams
dream, exists := dreams[name]
if !exists {
fmt.Printf("[D] DreamsDir().Lookup('%v'): ENOENT\n", name)
return nil, syscall.ENOENT
}
fmt.Printf("[D] DreamsDir().Lookup('%v'): OK\n", name)
now := time.Now()
return &DreamFile{
Dream: dream,
_attr: fuse.Attr{
Inode: 0,
Nlink: 1,
Mode: 0400,
Size: 0,
Ctime: now,
Atime: now,
Mtime: now,
Uid: d._attr.Uid,
Gid: d._attr.Gid,
},
}, nil
}

67
ffs/fs.go Normal file
View File

@@ -0,0 +1,67 @@
package ffs
import (
"fusetest/furcadia"
"os"
"time"
"bazil.org/fuse"
"bazil.org/fuse/fs"
)
///////////////////////////////////////////////////////////////////////////////////////////////////
// VAR ////////////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
var (
// Filesystem context (there should only be one)
G_FS *FurcFS = nil
)
///////////////////////////////////////////////////////////////////////////////////////////////////
// TYPE: FS ///////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
type FurcFS struct {
RootDir *RootDir
_pounce *furcadia.PounceList
}
func InitFS(rootUID, rootGID uint32) *FurcFS {
if G_FS != nil {
panic("G_FS already initialized - wtf?")
}
now := time.Now()
fs := &FurcFS{
RootDir: &RootDir{
_nodes: make(map[string]TopLevelDir),
_attr: fuse.Attr{
Inode: ROOT_INODE,
Nlink: 1,
Mode: os.ModeDir | 0500,
Uid: rootUID,
Gid: rootGID,
Atime: now,
Ctime: now,
Mtime: now,
},
},
_pounce: furcadia.NewPounceList(),
}
///////////////////////////////////////////////////////////////////////////
// register root-level items
///////////////////////////////////////////////////////////////////////////
fs.RootDir._nodes["furres"] = NewFurresDir(FURRES_DIR_INODE_BASE, rootUID, rootGID)
fs.RootDir._nodes["dreams"] = NewDreamsDir(DREAMS_DIR_INODE_BASE, rootUID, rootGID)
// FS context is now ready
G_FS = fs
return fs
}
func (fs *FurcFS) Root() (fs.Node, error) {
return fs.RootDir, nil
}

28
ffs/furrefile.go Normal file
View File

@@ -0,0 +1,28 @@
package ffs
import (
"context"
"fmt"
"fusetest/furcadia"
"bazil.org/fuse"
)
///////////////////////////////////////////////////////////////////////////////////////////////////
// TYPE: DreamFile ////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
type FurreFile struct {
Furre *furcadia.PounceFurre
_attr fuse.Attr
}
func (f *FurreFile) GetName() string {
return f.Furre.DisplayName
}
func (f *FurreFile) Attr(ctx context.Context, a *fuse.Attr) error {
fmt.Printf("[D] FurreFile('%v').Attr()\n", f.GetName())
*a = f._attr
return nil
}

133
ffs/furresdir.go Normal file
View File

@@ -0,0 +1,133 @@
package ffs
import (
"context"
"fmt"
"fusetest/furcadia"
"os"
"syscall"
"time"
"bazil.org/fuse"
"bazil.org/fuse/fs"
)
///////////////////////////////////////////////////////////////////////////////////////////////////
// CONSTANTS //////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
const (
FURRES_DIR_INODE_BASE = 0x1000
FURRES_DIR_MAX_INODES = 0x1000
)
///////////////////////////////////////////////////////////////////////////////////////////////////
// TYPE: DreamsDir ////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
type FurresDir struct {
_attr fuse.Attr
_entries []fuse.Dirent
_index map[string]int
}
func NewFurresDir(inodeBase uint64, rootUID, rootGID uint32) *FurresDir {
now := time.Now()
dir := &FurresDir{
_attr: fuse.Attr{
Inode: inodeBase,
Nlink: 1,
Mode: os.ModeDir | 0500,
Uid: rootUID,
Gid: rootGID,
Atime: now,
Ctime: now,
Mtime: now,
},
_index: make(map[string]int),
}
// add basic entries
dir._entries = append(
dir._entries,
fuse.Dirent{Inode: dir._attr.Inode, Name: ".", Type: fuse.DT_Dir},
fuse.Dirent{Inode: ROOT_INODE, Name: "..", Type: fuse.DT_Dir},
)
// add basic entries to the index
for i := range dir._entries {
dirent := &dir._entries[i]
dir._index[dirent.Name] = i
}
return dir
}
func (d *FurresDir) GetAttr() *fuse.Attr {
return &d._attr
}
func (d *FurresDir) Attr(ctx context.Context, a *fuse.Attr) error {
*a = d._attr
return nil
}
func (d *FurresDir) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
return d._entries, nil
// pounce := G_FS._pounce
// furres := pounce.Furres
// items := make([]fuse.Dirent, len(furres)+2)
// items[0] = fuse.Dirent{Inode: d._attr.Inode, Name: ".", Type: fuse.DT_Dir}
// items[1] = fuse.Dirent{Inode: ROOT_INODE, Name: "..", Type: fuse.DT_Dir}
// var i uint64 = 2
// for _, furre := range furres {
// items[i] = fuse.Dirent{
// Inode: d._attr.Inode + i - 1,
// Name: furre.DisplayName,
// Type: fuse.DT_File,
// }
// i++
// }
// return items, nil
}
func (d *FurresDir) Lookup(ctx context.Context, name string) (fs.Node, error) {
if name == "." {
return d, nil
}
// find dirent for the given name
direntIndex, exists = d._index[name]
if !exists {
return nil, syscall.ENOENT
}
furres := G_FS._pounce.Furres
shortname := furcadia.GetShortName(name)
furre, exists := furres[shortname]
if !exists {
fmt.Printf("[F] FurresDir().Lookup('%v' -> %v): ENOENT\n", name, shortname)
return nil, syscall.ENOENT
}
fmt.Printf("[F] FurresDir().Lookup('%v'): OK\n", name)
now := time.Now()
return &FurreFile{
Furre: furre,
_attr: fuse.Attr{
Inode: 0,
Nlink: 1,
Mode: 0400,
Size: 0,
Ctime: now,
Atime: now,
Mtime: now,
Uid: d._attr.Uid,
Gid: d._attr.Gid,
},
}, nil
}

61
ffs/rootdir.go Normal file
View File

@@ -0,0 +1,61 @@
package ffs
import (
"context"
"fmt"
"syscall"
"bazil.org/fuse"
"bazil.org/fuse/fs"
)
///////////////////////////////////////////////////////////////////////////////////////////////////
// CONSTANTS //////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
const ROOT_INODE = 1
///////////////////////////////////////////////////////////////////////////////////////////////////
// TYPE: RootDir //////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
type RootDir struct {
_attr fuse.Attr
_nodes map[string]TopLevelDir
}
func (d *RootDir) Attr(ctx context.Context, a *fuse.Attr) error {
*a = d._attr
return nil
}
func (d *RootDir) Lookup(ctx context.Context, name string) (fs.Node, error) {
if name == "." {
return d, nil
}
node, exists := d._nodes[name]
if exists {
fmt.Printf("[D] RootDir.Lookup('%v'): OK\n", name)
return node, nil
}
fmt.Printf("[D] RootDir.Lookup('%v'): ENOENT\n", name)
return nil, syscall.ENOENT
}
func (d *RootDir) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
fmt.Printf("[D] RootDir.ReadDirAll()\n")
items := make([]fuse.Dirent, len(d._nodes))
i := 0
for name, node := range d._nodes {
items[i] = fuse.Dirent{
Inode: node.GetAttr().Inode,
Name: name,
Type: fuse.DT_Dir,
}
i++
}
return items, nil
}

30
ffs/util.go Normal file
View File

@@ -0,0 +1,30 @@
package ffs
import (
"strings"
"bazil.org/fuse"
"bazil.org/fuse/fs"
)
type TopLevelDir interface {
fs.Node
GetAttr() *fuse.Attr
}
func IsValidName(name string) bool {
l := len(name)
if l == 0 || l > 255 {
return false
}
if name == "." || name == ".." {
return false
}
if strings.ContainsRune(name, '/') {
return false
}
return true
}

32
furcadia/character.go Normal file
View File

@@ -0,0 +1,32 @@
package furcadia
type Character struct {
// e-mail address of the Furcadia account (e.g., artex@furcadia.com)
AccountEmail string
// password for the Furcadia account
AccountPassword string
// display name of the character (with regular spaces) (e.g., "Ice Dragon")
DisplayName string
// shortname of the character (derived from DisplayName) (e.g., "icedragon")
ShortName string
// Furcadia color string
Color []byte
// description text
Description string
}
func NewCharacter(account, password, displayName string, color []byte, desc string) (*Character, error) {
return &Character{
AccountEmail: account,
AccountPassword: password,
DisplayName: displayName,
ShortName: GetShortName(displayName),
Color: append([]byte(nil), color...),
Description: desc,
}, nil
}

75
furcadia/client.go Normal file
View File

@@ -0,0 +1,75 @@
package furcadia
import (
"fmt"
"log"
"net"
)
///////////////////////////////////////////////////////////////////////////////////////////////////
// TYPE: FurcadiaClient ///////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
type FurcadiaClient struct {
// Furcadia character to authenticate as
Character *Character
// Network connection object
_Conn net.Conn
}
func NewFurcadiaClient(c Character) *FurcadiaClient {
return &FurcadiaClient{
Character: &c,
_Conn: nil,
}
}
// IsConnected returns true if this furcadia client instance had Connect()
// called on it at least once.
func (fc *FurcadiaClient) IsConnected() bool {
return fc._Conn != nil
}
// Connect establishes a connection to the Furcadia game server
// (for the first time).
//
// Note: this method CANNOT be called twice on the same client instance!
func (fc *FurcadiaClient) Connect(address string) error {
if fc.IsConnected() {
return ErrAlreadyConnected
}
c, err := net.Dial("tcp", address)
if err != nil {
log.Printf("[%v] Cannot connect to %v: %v", fc.String(), address, err)
return err
}
log.Printf("[%v] Connected to %v...", fc.String(), address)
fc._Conn = c
return nil
}
// Disconnect closes an existing connection to the Furcadia game server.
// Calling this method allows future calls to Connect() again. If this instance
// was not connected at the time, nothing will happen and nil is returned.
//
// If closing the underlying network connection produces an error, it will be
// returned back to the caller. In either case, the connection will be closed
// and no longer owned by this instance.
func (fc *FurcadiaClient) Disconnect() error {
if !fc.IsConnected() {
return nil
}
err := fc._Conn.Close()
fc._Conn = nil
return err
}
// String returns a string representation of a FurcadiaClient instance for
// printing/logging purposes
func (fc *FurcadiaClient) String() string {
return fmt.Sprintf("FurcadiaClient<%s>", fc.Character.DisplayName)
}

9
furcadia/consts.go Normal file
View File

@@ -0,0 +1,9 @@
package furcadia
///////////////////////////////////////////////////////////////////////////////////////////////////
// CONSTANTS //////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
const (
DEFAULT_GAMESERVER_ADDRESS = "lightbringer.furcadia.com:6500"
)

34
furcadia/errors.go Normal file
View File

@@ -0,0 +1,34 @@
package furcadia
import (
"errors"
)
///////////////////////////////////////////////////////////////////////////////////////////////////
// ERRORS /////////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
var (
// ErrAlreadyConnected indicates that a FurcadiaClient instance has already
// been connected via a Connect() call, and cannot have it done twice.
ErrAlreadyConnected = errors.New("already connected")
// ErrExists is returned when we are trying to add an entry, but an
// equivalent entry is already present and would have otherwise been
// overwritten.
ErrExists = errors.New("entry exists")
// ErrInvalidDreamURL is returned when a provided dream url is invalid.
ErrInvalidDreamURL = errors.New("invalid dream url")
// ErrNotFound is returned when we were asked to change an entry, but there
// is no entry on record.
//
// This is not necessarily an error if, say, we are deleting a record which
// was already deleted. In such cases, this errors may be ignored.
ErrNotFound = errors.New("entry not found")
// ErrNotImplemented indicates that a requested feature has not yet been
// implemented and cannot be used.
ErrNotImplemented = errors.New("not implemented")
)

292
furcadia/pounce.go Normal file
View File

@@ -0,0 +1,292 @@
package furcadia
import (
"fmt"
"strings"
)
// CONSTANTS //////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
const (
POUNCE_API_BASE_URL = "http://on.furcadia.com/q/"
)
///////////////////////////////////////////////////////////////////////////////////////////////////
// TYPE: OnlineState //////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
type OnlineState int
const (
StateUnknown = iota
StateOffline
StateOnline
)
var OnlineStateName = map[OnlineState]string{
StateUnknown: "unknown",
StateOffline: "offline",
StateOnline: "online",
}
func (state OnlineState) String() string {
return OnlineStateName[state]
}
func (state OnlineState) IsOnline() bool {
return state == StateOnline
}
///////////////////////////////////////////////////////////////////////////////////////////////////
// TYPE: PounceFurre //////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
type PounceFurre struct {
DisplayName string
LastState OnlineState
}
func (pf *PounceFurre) GetShortName() string {
return GetShortName(pf.DisplayName)
}
///////////////////////////////////////////////////////////////////////////////////////////////////
// TYPE: PounceDream //////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
type PounceDream struct {
Title string // title of the dream, or its full dream url
ShortURL string // dream url without the "furc://" prefix or the "/" suffix - just "uploader:name"
LastState OnlineState // last known state of this dream
}
// GetDreamURL returns a full Furcadia dream URL for this dream.
func (pd *PounceDream) GetDreamURL() string {
return "furc://" + pd.ShortURL + "/"
}
///////////////////////////////////////////////////////////////////////////////////////////////////
// TYPE: PounceList ///////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
type PounceList struct {
Furres map[string]*PounceFurre // shortname -> *PounceFurre
Dreams map[string]*PounceDream // shortname -> *PounceDream
}
func NewPounceList() *PounceList {
return &PounceList{
Furres: make(map[string]*PounceFurre),
Dreams: make(map[string]*PounceDream),
}
}
// GetQueryString returns an HTTP query element of a URL for an online check.
// The string contains all furrenly watched furres and dreams whose status our
// Pounce client wants to check.
func (p *PounceList) GetQueryString() string {
sb := strings.Builder{}
for _, furre := range p.Furres {
sb.WriteString(fmt.Sprintf("u[]=%s&", furre.GetShortName()))
}
for _, dream := range p.Dreams {
sb.WriteString(fmt.Sprintf("d[]=%s&", dream.ShortURL))
}
return strings.TrimSuffix(sb.String(), "&")
}
// AddFurre adds a furre to the Pounce list, and returns the record of that
// furre back to the caller.
//
// If a record for the given name already exists, then the existing record is
// returned along with ErrExists.
func (p *PounceList) AddFurre(name string) (*PounceFurre, error) {
shortname := GetShortName(name)
oldFurre, ok := p.Furres[shortname]
if ok {
return oldFurre, ErrExists
}
newFurre := p._newFurre(name)
if newFurre.GetShortName() != shortname {
panic(fmt.Sprintf("shortname inconsistency: newFurre.GetShortName()=%v; GetShortName(%v)=%v", newFurre.GetShortName(), name, shortname))
}
p.Furres[shortname] = newFurre
return newFurre, nil
}
// DeleteFurre deletes a furre from the Pounce list. If no furre by that name
// exists, the method returns ErrNotFound.
func (p *PounceList) DeleteFurre(name string) error {
shortname := GetShortName(name)
_, ok := p.Furres[shortname]
if !ok {
return ErrNotFound
}
delete(p.Furres, shortname)
return nil
}
// AddDream adds a dream to the Pounce list, and returns the record of that
// dream back to the caller.
//
// This method accepts the uploader character name, and the "dream name" string
// from which a dream url is constructed. The "dreamName" argument MAY be an
// empty string.
//
// If a dream record for the given dream url already exists, then the existing
// record is returned instead, along with ErrExists.
func (p *PounceList) AddDream(uploaderName, dreamName string) (*PounceDream, error) {
shortURL := GetShortDreamURLFromName(uploaderName, dreamName)
var title string
if dreamName == "" {
title = uploaderName
} else {
title = uploaderName + ": " + dreamName
}
return p.__addDream(shortURL, title)
}
// AddDreamURL adds a dream to the Pounce list, and returns the record of that
// dream back to the caller. This method accepts a Furcadia dream URL.
//
// If the URL is invalid, ErrInvalidDreamURL will be returned without a
// PounceDream object.
//
// If a dream record for the given dream URL already exists, then the existing
// record is returned instead, along with ErrExists.
func (p *PounceList) AddDreamURL(url string) (*PounceDream, error) {
shortURL, err := GetShortDreamURL(url)
if err != nil {
return nil, err
}
return p.__addDream(shortURL, url)
}
// DeleteDream deletes a dream from the Pounce list.
// If no dream by this criteria exists, the method returns ErrNotFound.
func (p *PounceList) DeleteDream(uploaderName, dreamName string) error {
shortURL := GetShortDreamURLFromName(uploaderName, dreamName)
return p.__delDream(shortURL)
}
// DeleteDreamURL deletes a dream from the Pouince list.
// If no dream by this dream URL exists, the method returns ErrNotFound.
// If provided dream URL is invalid, the method returns ErrInvalidDreamURL.
func (p *PounceList) DeleteDreamURL(url string) error {
shortURL, err := GetShortDreamURL(url)
if err != nil {
return err
}
return p.__delDream(shortURL)
}
///////////////////////////////////////////////////////////////////////////////////////////////////
func (p *PounceList) __addDream(shortURL, title string) (*PounceDream, error) {
oldDream, ok := p.Dreams[shortURL]
if ok {
return oldDream, ErrExists
}
dream := p._newDream(shortURL, title)
p.Dreams[shortURL] = dream
return dream, nil
}
func (p *PounceList) __delDream(shortURL string) error {
_, ok := p.Dreams[shortURL]
if !ok {
return ErrNotFound
}
delete(p.Dreams, shortURL)
return nil
}
///////////////////////////////////////////////////////////////////////////////////////////////////
// _newFurre creates a new PounceFurre object with the given display name.
//
// displayName is the full name of the furre; it may contain pipes or spaces
// in it.
func (p *PounceList) _newFurre(displayName string) *PounceFurre {
return &PounceFurre{
DisplayName: displayName,
LastState: StateUnknown,
}
}
// _newDream creates a new PounceDream object with the given parameters.
//
// The "title" argument specifies a "name" for this dream. If title is an empty
// string, then the title will be this dream's full dream url.
func (p *PounceList) _newDream(shortDreamURL, title string) *PounceDream {
pd := &PounceDream{
Title: title,
ShortURL: shortDreamURL,
LastState: StateUnknown,
}
// if no title provided, use the full dream URL as the title
if pd.Title == "" {
pd.Title = pd.GetDreamURL()
}
return pd
}
///////////////////////////////////////////////////////////////////////////////////////////////////
// TYPE: PounceResponse ///////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
const MIN_POLL_INTERVAL_MSEC = 10000
type PounceResponse struct {
NumOnlineFurres int // O422
NumOnlineDreams int // D751
PollIntervalMsec int // T30000
OnlineFurres []string // @10 Albert|Quirky
OnlineDreams []string // #1furc://allegria:/ #1furc://allegria/
}
func ParsePounceResponse(r []byte) (*PounceResponse, error) {
//// Example Input:
// https://on.furcadia.com/q/?u[]=artex&u[]=albertquirky&d[]=allegria&d[]=ansteorrakingdom
//
//// Example Output:
// T30000 @10 Albert|Quirky O422 #1furc://allegria:/ #1furc://allegria/ #1furc://ansteorrakingdom:medievaltradecity/ #1furc://ansteorrakingdom/ #1furc://ansteorrakingdom:medievaltradecity/ #1furc://ansteorrakingdom/ D751
//
// TODO: Split into lines
// TODO: Process each line
return nil, ErrNotImplemented
}
// ToBytes creates a response string much like a pounce server would produce.
// This method is mostly useful for testing, or if you wish to implement your
// own pounce service that's compatible with the official Pounce client.
func (r *PounceResponse) ToBytes() (output []byte) {
output = fmt.Appendf(output, "T%d\n", r.PollIntervalMsec)
for _, furre := range r.OnlineFurres {
output = fmt.Appendf(output, "@10 %s\n", furre)
}
output = fmt.Appendf(output, "O%d\n", r.NumOnlineFurres)
for _, dreamUrl := range r.OnlineDreams {
output = fmt.Appendf(output, "#1%s\n", dreamUrl)
}
output = fmt.Appendf(output, "D%d\n", r.NumOnlineDreams)
return
}

298
furcadia/pounce_test.go Normal file
View File

@@ -0,0 +1,298 @@
package furcadia
import (
"errors"
"strings"
"testing"
)
func TestAddFurre(t *testing.T) {
testNames := []string{
"Artex",
"Albert Quirky",
"A|B|C",
"#SA",
}
pounce := NewPounceList()
// Success Scenarios //////////////////////////////////////////////////////////////////////////
for _, name := range testNames {
// check AddFurre() success
furre, err := pounce.AddFurre(name)
if err != nil {
t.Errorf("AddFurre(%v) failed: %v", name, err)
}
// check display name
if furre.DisplayName != name {
t.Errorf("AddFurre(%v).DisplayName == %v (mismatch)", name, furre.DisplayName)
}
// check shortname
expShort := GetShortName(name)
furreShort := furre.GetShortName()
if furreShort != expShort {
t.Errorf("AddFurre(%v).GetShortName() -> %v (expected: %v)", name, furreShort, expShort)
}
}
if len(pounce.Furres) != len(testNames) {
t.Errorf("Length inconsistency: len(pounce.Furres)=%v, len(testNames)=%v", len(pounce.Furres), len(testNames))
}
// Bad Scenarios //////////////////////////////////////////////////////////////////////////////
// already on record
furre, err := pounce.AddFurre("||ARTEX||")
if err == nil {
t.Errorf("AddFurre(||ARTEX||) unexpectedly succeeded: %v", furre)
}
// already on record
furre, err = pounce.AddFurre("albertquirky")
if err == nil {
t.Errorf("AddFurre(albertquirky) unexpectedly succeeded: %v", furre)
}
}
func TestDeleteFurre(t *testing.T) {
testNames := []string{
"Artex",
"Albert Quirky",
"A|B|C",
"#SA",
}
// create a pounce list and populate it with names
pounce := NewPounceList()
for _, name := range testNames {
pounce.AddFurre(name)
}
// delete using a different display name - should work
err := pounce.DeleteFurre("ALBERT|QUIRKY")
if err != nil {
t.Errorf("DeleteFurre(ALBERT|QUIRKY) failed: %v", err)
}
if len(pounce.Furres) != len(testNames)-1 {
t.Errorf("DeleteFurre(ALBERT|QUIRKY) caused invalid furre count: %v (testNames: %v)", len(pounce.Furres), len(testNames))
}
// delete for the second time - expect ErrNotFound
err = pounce.DeleteFurre("Albert Quirky")
if !errors.Is(err, ErrNotFound) {
t.Errorf("DeleteFurre(Albert Quirky) for a missing record produced: %v (expected ErrNotFound)", err)
}
// delete everyone
for _, name := range testNames {
pounce.DeleteFurre(name)
}
if len(pounce.Furres) != 0 {
t.Errorf("Mass-delete did not empty furre list: %v", pounce.Furres)
}
}
func TestAddDream(t *testing.T) {
pounce := NewPounceList()
dream, err := pounce.AddDream("Ice Dragon", "Some dream name")
if err != nil {
t.Errorf("AddDream() failed: %v", err)
}
// test basic output
if dream.Title != "Ice Dragon: Some dream name" {
t.Errorf("AddDream(): unexpected title '%v'", dream.Title)
}
if dream.ShortURL != "icedragon:somedreamname" {
t.Errorf("AddDream(): unexpected short url '%v'", dream.ShortURL)
}
if dream.LastState != StateUnknown {
t.Errorf("AddDream(): unexpected initial state '%v'", dream.LastState)
}
if dream.LastState.IsOnline() {
t.Errorf("AddDream(): dream is with unknown state is initially online")
}
if dream.GetDreamURL() != "furc://icedragon:somedreamname/" {
t.Errorf("AddDream(): unexpected URL from GetDreamURL(): '%v'", dream.GetDreamURL())
}
// try to add this dream again; make sure it fails
d, err := pounce.AddDream("icedragon", "some_dream_name")
if !errors.Is(err, ErrExists) {
t.Errorf("AddDream(): duplicate add did not produce ErrExists, but %T(%v)", err, err)
}
if d != dream {
t.Errorf("AddDream(): expected existing dream, but got %T(%v)", d, d)
}
// try to add an analogous dream via dreamurl; make sure it also fails
d, err = pounce.AddDreamURL(dream.GetDreamURL())
if !errors.Is(err, ErrExists) {
t.Errorf("AddDreamURL(): analogous duplicate add did not produce ErrExists, but %T(%v)", err, err)
}
if d != dream {
t.Errorf("AddDreamURL(): expected existing dream, but got %T(%v)", d, d)
}
}
func TestAddDreamURL(t *testing.T) {
const longURL = "furc://a_r_t_e_x:some_dream_name"
const normalURL = "furc://artex:somedreamname/"
const shortURL = "artex:somedreamname"
pounce := NewPounceList()
dream, err := pounce.AddDreamURL(longURL)
if err != nil {
t.Errorf("AddDreamURL() failed: %v", err)
}
// test basic output
if dream.Title != longURL {
t.Errorf("AddDreamURL(): unexpected title '%v' (expected '%v')", dream.Title, longURL)
}
if dream.ShortURL != shortURL {
t.Errorf("AddDreamURL(): unexpected dream short url '%v' (expected '%v')", dream.ShortURL, shortURL)
}
if dream.LastState != StateUnknown {
t.Errorf("AddDream(): unexpected initial state '%v'", dream.LastState)
}
if dream.LastState.IsOnline() {
t.Errorf("AddDream(): dream is with unknown state is initially online")
}
if dream.GetDreamURL() != normalURL {
t.Errorf("AddDream(): unexpected URL from GetDreamURL(): '%v' (expected: '%v')", dream.GetDreamURL(), normalURL)
}
// try to add this dream again; make sure it fails
testDup := func(url string) {
d, err := pounce.AddDreamURL(url)
if !errors.Is(err, ErrExists) {
t.Errorf("AddDreamURL(): duplicate add for '%v' did not produce ErrExists, but %T(%v)", url, err, err)
}
// in case of ErrExists, d is the existing dream object
if d != dream {
t.Errorf("AddDreamURL(): expected existing dream for '%v', but got %+v", url, d)
}
}
testDup(longURL)
testDup(normalURL)
// try to add analogous dream via AddDream(); make sure it also fails
d, err := pounce.AddDream("Artex", "Some Dream Name")
if !errors.Is(err, ErrExists) {
t.Errorf("AddDream(): duplicate analogous add did not produce ErrExists, but %T(%v)", err, err)
}
if d != dream {
// in case of ErrExists, d is the existing dream object
t.Errorf("AddDream(): expected existing dream, but got %+v", d)
}
}
func TestDeleteDream(t *testing.T) {
const uploader = "Ice Dragon"
const dreamName = "Some dream name"
pounce := NewPounceList()
// try deleting a non-existant dream
err := pounce.DeleteDream(uploader, dreamName)
if !errors.Is(err, ErrNotFound) {
t.Errorf("DeleteDream(): expected ErrNotFound for non-existent dream, but got %T(%v)", err, err)
}
// add the dream
_, err = pounce.AddDream(uploader, dreamName)
if err != nil {
t.Errorf("AddDream() failed: %v", err)
}
// delete should succeed now
err = pounce.DeleteDream(uploader, dreamName)
if err != nil {
t.Errorf("DeleteDream() unexpectedly failed with %T(%v)", err, err)
}
// dream is no longer available again
err = pounce.DeleteDream(uploader, dreamName)
if !errors.Is(err, ErrNotFound) {
t.Errorf("DeleteDream(): expected ErrNotFound for deleted dream, but got %T(%v)", err, err)
}
}
func TestDeleteDreamURL(t *testing.T) {
const dreamURL = "furc://icedragon:somedreamname"
pounce := NewPounceList()
// try deleting a non-existant dream
err := pounce.DeleteDreamURL(dreamURL)
if !errors.Is(err, ErrNotFound) {
t.Errorf("DeleteDreamURL(): expected ErrNotFound for non-existent dream, but got %T(%v)", err, err)
}
// add the dream
_, err = pounce.AddDreamURL(dreamURL)
if err != nil {
t.Errorf("AddDreamURL() failed: %v", err)
}
// delete should succeed now
err = pounce.DeleteDreamURL(dreamURL)
if err != nil {
t.Errorf("DeleteDreamURL() unexpectedly failed with %T(%v)", err, err)
}
// dream is no longer available again
err = pounce.DeleteDreamURL(dreamURL)
if !errors.Is(err, ErrNotFound) {
t.Errorf("DeleteDreamURL(): expected ErrNotFound for deleted dream, but got %T(%v)", err, err)
}
}
func TestGetQueryString(t *testing.T) {
pounce := NewPounceList()
pounce.AddFurre("Artex")
pounce.AddFurre("Albert Quirky")
pounce.AddFurre("|Test|Alt|666#SA")
pounce.AddDream("Allegria", "")
pounce.AddDream("FurN", "")
pounce.AddDream("Bramblebrook", "Welcome")
pounce.AddDreamURL("furc://artex")
pounce.AddDreamURL("furc://artex:somename/")
query := pounce.GetQueryString() + "&"
expectedSubs := []string{
"u[]=artex&",
"u[]=albertquirky&",
"u[]=testalt666sa&",
"d[]=allegria&",
"d[]=furn&",
"d[]=bramblebrook:welcome&",
"d[]=artex&",
"d[]=artex:somename&",
}
unexpectedSubs := []string{
"&&",
}
nItems := len(strings.Split(query, "&"))
if nItems != len(expectedSubs)+1 {
t.Errorf("Unexpected number of items (%v) in query: %v", nItems, query)
}
for _, sub := range expectedSubs {
if !strings.Contains(query, sub) {
t.Errorf("Missing substring '%v' in query: %v", sub, query)
}
}
for _, sub := range unexpectedSubs {
if strings.Contains(query, sub) {
t.Errorf("Unexpected substring '%v' in query: %v", sub, query)
}
}
}

77
furcadia/util.go Normal file
View File

@@ -0,0 +1,77 @@
package furcadia
import (
"fmt"
"strings"
"unicode"
)
///////////////////////////////////////////////////////////////////////////////////////////////////
// FUNCTIONS //////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
// GetShortName returns a "shortname" derived from a given Furcadia display name
func GetShortName(displayName string) string {
sb := &strings.Builder{}
for _, r := range displayName {
if unicode.IsLetter(r) || unicode.IsNumber(r) {
sb.WriteRune(unicode.ToLower(r))
}
}
return sb.String()
}
// GetShortDreamURL converts a regular "furc://uploader:name/" dream url into
// a "short" form that can be used as a hash key (e.g., "uploader:name").
//
// ErrInvalidDreamURL is returned if the given url is not a valid dream URL.
func GetShortDreamURL(url string) (string, error) {
// cut prefix
u, found := strings.CutPrefix(strings.ToLower(url), "furc://")
if !found {
return "", ErrInvalidDreamURL
}
// cut suffix
u = strings.SplitN(u, "/", 2)[0]
// split uploader name and dream name
var uploaderName string
var dreamName string
elements := strings.Split(u, ":")
switch len(elements) {
case 0:
panic(fmt.Sprintf("len(%v) == 0", elements))
case 1:
uploaderName = elements[0]
dreamName = ""
case 2:
uploaderName = elements[0]
dreamName = elements[1]
default:
return "", ErrInvalidDreamURL
}
short := GetShortDreamURLFromName(uploaderName, dreamName)
if short == "" || short[0] == ':' {
// furc://
// furc:///
// furc://:/
// furc://:something/
return "", ErrInvalidDreamURL
}
return short, nil
}
func GetShortDreamURLFromName(uploaderName, dreamName string) (short string) {
shortName := GetShortName(uploaderName)
shortDream := GetShortName(dreamName)
if shortDream == "" {
short = shortName
} else {
short = shortName + ":" + shortDream
}
return
}

67
furcadia/util_test.go Normal file
View File

@@ -0,0 +1,67 @@
package furcadia
import (
"errors"
"testing"
)
func TestGetShortName(t *testing.T) {
check := func(displayName, expectedShortname string) {
short := GetShortName(displayName)
if short != expectedShortname {
t.Errorf("GetShortName('%v') -> '%v' (expected: '%v')", displayName, short, expectedShortname)
}
}
check("ArTeX", "artex")
check("Ice|Dragon", "icedragon")
check("123_arte_X", "123artex")
check("|~!@#$%^&*()_+<>[]{}:;'\",./?artex ", "artex")
}
func TestGetShortDreamURL(t *testing.T) {
check := func(dreamURL, expectedShortURL string) {
short, err := GetShortDreamURL(dreamURL)
if err != nil {
t.Errorf("GetShortDreamURL('%v') -> unexpected error %T (%v)", dreamURL, err, err)
}
if short != expectedShortURL {
t.Errorf("GetShortDreamURL('%v') -> '%v' (expected: '%v')", dreamURL, short, expectedShortURL)
}
}
check("furc://ice_dragon:some-dream-name/128", "icedragon:somedreamname")
check("furc://artex", "artex")
check("furc://artex:", "artex")
check("furc://artex/128", "artex")
check("furc://name:", "name")
checkErr := func(dreamURL string, expectedErr error) {
_, err := GetShortDreamURL(dreamURL)
if !errors.Is(err, expectedErr) {
t.Errorf("GetShortDreamURL('%v') -> unexpected error %T (%v)", dreamURL, err, err)
}
}
checkErr("furc://", ErrInvalidDreamURL)
checkErr("furc:///", ErrInvalidDreamURL)
checkErr("furc://:", ErrInvalidDreamURL)
checkErr("furc://:/", ErrInvalidDreamURL)
checkErr("furc://:nouploader", ErrInvalidDreamURL)
checkErr("furc://:nouploader/", ErrInvalidDreamURL)
checkErr("furc://artex::", ErrInvalidDreamURL)
}
func TestGetShortDreamURLFromName(t *testing.T) {
check := func(uploader, name, expected string) {
short := GetShortDreamURLFromName(uploader, name)
if short != expected {
t.Errorf("GetShortDreamURLFromName('%v', '%v') -> '%v' (expected: '%v')", uploader, name, short, expected)
}
}
check("Ice Dragon", "", "icedragon")
check("Ice Dragon", "Some name here", "icedragon:somenamehere")
check("icedragon", "some_name_here", "icedragon:somenamehere")
check("", "Illegal but possible", ":illegalbutpossible")
}

8
go.mod Normal file
View File

@@ -0,0 +1,8 @@
module fusetest
go 1.24.7
require (
bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5 // indirect
golang.org/x/sys v0.4.0 // indirect
)

4
go.sum Normal file
View File

@@ -0,0 +1,4 @@
bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5 h1:A0NsYy4lDBZAC6QiYeJ4N+XuHIKBpyhAVRMHRQZKTeQ=
bazil.org/fuse v0.0.0-20230120002735-62a210ff1fd5/go.mod h1:gG3RZAMXCa/OTes6rr9EwusmR1OH1tDDy+cg9c5YliY=
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

117
main.go Normal file
View File

@@ -0,0 +1,117 @@
package main
import (
"context"
"fmt"
"fusetest/ffs"
"fusetest/furcadia"
"log"
"os"
"sync"
"time"
"bazil.org/fuse"
"bazil.org/fuse/fs"
)
///////////////////////////////////////////////////////////////////////////////////////////////////
// TYPE: File /////////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////////////////////
type File struct {
mu sync.Mutex
name string
data []byte
}
func (f *File) Access(ctx context.Context, req *fuse.AccessRequest) error {
fmt.Printf("[D] File[%v].Attr() <- %v\n", f.name, req.String())
return nil
}
func (f *File) Attr(ctx context.Context, a *fuse.Attr) error {
f.mu.Lock()
defer f.mu.Unlock()
a.Inode = 12345
a.Mode = 0755
a.Size = uint64(len(f.data))
a.Gid = 999
a.Uid = 999
a.Atime = time.Now()
a.Mtime = time.Now()
fmt.Printf("[D] File[%v].Attr() <- %v\n", f.name, a.String())
return nil
}
func (f *File) ReadAll(ctx context.Context, req *fuse.WriteRequest, resp *fuse.WriteResponse) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
fmt.Printf("[D] File[%v].ReadAll(%v) <- %v\n", f.name, req.String(), resp.String())
return append([]byte(nil), f.data...), nil
}
func (f *File) Write(ctx context.Context, req *fuse.WriteRequest, resp *fuse.WriteResponse) error {
f.mu.Lock()
defer f.mu.Unlock()
end := int(req.Offset) + len(req.Data)
if end > len(f.data) {
newData := make([]byte, end)
copy(newData, f.data)
f.data = newData
}
copy(f.data[req.Offset:], req.Data)
resp.Size = len(req.Data)
fmt.Printf("[D] File[%v].Write(%v) <- %v\n", f.name, req.String(), resp.String())
return nil
}
func (f *File) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenResponse) (fs.Handle, error) {
fmt.Printf("[D] File[%v].Open(%v) <- %v\n", f.name, req.String(), resp.String())
return f, nil
}
func (f *File) Release(ctx context.Context, req *fuse.ReleaseRequest) error {
fmt.Printf("[D] File[%v].Release(%v)\n", f.name, req.String())
return nil
}
func (f *File) Forget() {
fmt.Printf("[D] File[%v].Forget()\n", f.name)
}
///////////////////////////////////////////////////////////////////////////////////////////////////
func main() {
if len(os.Args) != 2 {
fmt.Printf("Usage: %v <mountpoint>\n", os.Args[0])
os.Exit(1)
}
mountpoint := os.Args[1]
c, err := fuse.Mount(
mountpoint,
fuse.FSName("rawrfs"),
fuse.Subtype("rawrfs"),
)
if err != nil {
log.Fatal(err)
}
defer fuse.Unmount(mountpoint)
defer c.Close()
uid := uint32(os.Getuid())
gid := uint32(os.Getgid())
pounce := furcadia.NewPounceList()
pounce.AddDream("Artex", "Some dream name")
pounce.AddDream("Ice Dragon", "")
pounce.AddDream("Albert Quirky", "Welcome!")
pfs := ffs.InitFS(uid, gid, pounce)
if err := fs.Serve(c, pfs); err != nil {
log.Fatal(err)
}
}