From 2dbb7e5d7e9ae688d06aea056a84a96a106439c6 Mon Sep 17 00:00:00 2001 From: Artex Date: Mon, 19 Jan 2026 01:51:08 +0200 Subject: [PATCH] Initial commit --- ffs/dreamfile.go | 28 ++++ ffs/dreamsdir.go | 115 ++++++++++++++++ ffs/fs.go | 67 +++++++++ ffs/furrefile.go | 28 ++++ ffs/furresdir.go | 133 ++++++++++++++++++ ffs/rootdir.go | 61 ++++++++ ffs/util.go | 30 ++++ furcadia/character.go | 32 +++++ furcadia/client.go | 75 ++++++++++ furcadia/consts.go | 9 ++ furcadia/errors.go | 34 +++++ furcadia/pounce.go | 292 +++++++++++++++++++++++++++++++++++++++ furcadia/pounce_test.go | 298 ++++++++++++++++++++++++++++++++++++++++ furcadia/util.go | 77 +++++++++++ furcadia/util_test.go | 67 +++++++++ go.mod | 8 ++ go.sum | 4 + main.go | 117 ++++++++++++++++ 18 files changed, 1475 insertions(+) create mode 100644 ffs/dreamfile.go create mode 100644 ffs/dreamsdir.go create mode 100644 ffs/fs.go create mode 100644 ffs/furrefile.go create mode 100644 ffs/furresdir.go create mode 100644 ffs/rootdir.go create mode 100644 ffs/util.go create mode 100644 furcadia/character.go create mode 100644 furcadia/client.go create mode 100644 furcadia/consts.go create mode 100644 furcadia/errors.go create mode 100644 furcadia/pounce.go create mode 100644 furcadia/pounce_test.go create mode 100644 furcadia/util.go create mode 100644 furcadia/util_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/ffs/dreamfile.go b/ffs/dreamfile.go new file mode 100644 index 0000000..615f381 --- /dev/null +++ b/ffs/dreamfile.go @@ -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 +} diff --git a/ffs/dreamsdir.go b/ffs/dreamsdir.go new file mode 100644 index 0000000..30c6f58 --- /dev/null +++ b/ffs/dreamsdir.go @@ -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 +} diff --git a/ffs/fs.go b/ffs/fs.go new file mode 100644 index 0000000..bda2bdd --- /dev/null +++ b/ffs/fs.go @@ -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 +} diff --git a/ffs/furrefile.go b/ffs/furrefile.go new file mode 100644 index 0000000..9464fc6 --- /dev/null +++ b/ffs/furrefile.go @@ -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 +} diff --git a/ffs/furresdir.go b/ffs/furresdir.go new file mode 100644 index 0000000..a075366 --- /dev/null +++ b/ffs/furresdir.go @@ -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 +} diff --git a/ffs/rootdir.go b/ffs/rootdir.go new file mode 100644 index 0000000..979282e --- /dev/null +++ b/ffs/rootdir.go @@ -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 +} diff --git a/ffs/util.go b/ffs/util.go new file mode 100644 index 0000000..392adce --- /dev/null +++ b/ffs/util.go @@ -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 +} diff --git a/furcadia/character.go b/furcadia/character.go new file mode 100644 index 0000000..4f5e237 --- /dev/null +++ b/furcadia/character.go @@ -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 +} diff --git a/furcadia/client.go b/furcadia/client.go new file mode 100644 index 0000000..a7a5d7f --- /dev/null +++ b/furcadia/client.go @@ -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) +} diff --git a/furcadia/consts.go b/furcadia/consts.go new file mode 100644 index 0000000..7db33f8 --- /dev/null +++ b/furcadia/consts.go @@ -0,0 +1,9 @@ +package furcadia + +/////////////////////////////////////////////////////////////////////////////////////////////////// +// CONSTANTS ////////////////////////////////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////////////////////////// + +const ( + DEFAULT_GAMESERVER_ADDRESS = "lightbringer.furcadia.com:6500" +) diff --git a/furcadia/errors.go b/furcadia/errors.go new file mode 100644 index 0000000..8859173 --- /dev/null +++ b/furcadia/errors.go @@ -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") +) diff --git a/furcadia/pounce.go b/furcadia/pounce.go new file mode 100644 index 0000000..063f082 --- /dev/null +++ b/furcadia/pounce.go @@ -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 +} diff --git a/furcadia/pounce_test.go b/furcadia/pounce_test.go new file mode 100644 index 0000000..0670a2a --- /dev/null +++ b/furcadia/pounce_test.go @@ -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) + } + } +} diff --git a/furcadia/util.go b/furcadia/util.go new file mode 100644 index 0000000..8d7f2e6 --- /dev/null +++ b/furcadia/util.go @@ -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 +} diff --git a/furcadia/util_test.go b/furcadia/util_test.go new file mode 100644 index 0000000..a560bab --- /dev/null +++ b/furcadia/util_test.go @@ -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") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f38a826 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..40a7f44 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..8331952 --- /dev/null +++ b/main.go @@ -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 \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) + } +}