go-io/workspace/service.go
Virgil 514ecd7e7a fix(io): enforce ax v0.8.0 polish spec
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-30 06:24:36 +00:00

235 lines
6.3 KiB
Go

package workspace
import (
"crypto/sha256"
"encoding/hex"
"io/fs"
"sync"
core "dappco.re/go/core"
"dappco.re/go/core/io"
)
// Workspace provides management for encrypted user workspaces.
type Workspace interface {
CreateWorkspace(identifier, password string) (string, error)
SwitchWorkspace(name string) error
WorkspaceFileGet(filename string) (string, error)
WorkspaceFileSet(filename, content string) error
}
// cryptProvider is the interface for PGP key generation.
type cryptProvider interface {
CreateKeyPair(name, passphrase string) (string, error)
}
// Service implements the Workspace interface.
type Service struct {
core *core.Core
crypt cryptProvider
activeWorkspace string
rootPath string
medium io.Medium
mu sync.RWMutex
}
// New creates a new Workspace service instance.
// An optional cryptProvider can be passed to supply PGP key generation.
//
// Example usage:
//
// svcAny, _ := workspace.New(core.New(), myCryptProvider)
// svc := svcAny.(*workspace.Service)
func New(c *core.Core, crypt ...cryptProvider) (any, error) {
home := workspaceHome()
if home == "" {
return nil, core.E("workspace.New", "failed to determine home directory", fs.ErrNotExist)
}
rootPath := core.Path(home, ".core", "workspaces")
s := &Service{
core: c,
rootPath: rootPath,
medium: io.Local,
}
if len(crypt) > 0 && crypt[0] != nil {
s.crypt = crypt[0]
}
if err := s.medium.EnsureDir(rootPath); err != nil {
return nil, core.E("workspace.New", "failed to ensure root directory", err)
}
return s, nil
}
// CreateWorkspace creates a new encrypted workspace.
// Identifier is hashed (SHA-256) to create the directory name.
// A PGP keypair is generated using the password.
//
// result := s.CreateWorkspace(...)
func (s *Service) CreateWorkspace(identifier, password string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.crypt == nil {
return "", core.E("workspace.CreateWorkspace", "crypt service not available", nil)
}
hash := sha256.Sum256([]byte(identifier))
wsID := hex.EncodeToString(hash[:])
wsPath, err := s.workspacePath("workspace.CreateWorkspace", wsID)
if err != nil {
return "", err
}
if s.medium.Exists(wsPath) {
return "", core.E("workspace.CreateWorkspace", "workspace already exists", nil)
}
for _, d := range []string{"config", "log", "data", "files", "keys"} {
if err := s.medium.EnsureDir(core.Path(wsPath, d)); err != nil {
return "", core.E("workspace.CreateWorkspace", core.Concat("failed to create directory: ", d), err)
}
}
privKey, err := s.crypt.CreateKeyPair(identifier, password)
if err != nil {
return "", core.E("workspace.CreateWorkspace", "failed to generate keys", err)
}
if err := s.medium.WriteMode(core.Path(wsPath, "keys", "private.key"), privKey, 0600); err != nil {
return "", core.E("workspace.CreateWorkspace", "failed to save private key", err)
}
return wsID, nil
}
// SwitchWorkspace changes the active workspace.
//
// result := s.SwitchWorkspace(...)
func (s *Service) SwitchWorkspace(name string) error {
s.mu.Lock()
defer s.mu.Unlock()
wsPath, err := s.workspacePath("workspace.SwitchWorkspace", name)
if err != nil {
return err
}
if !s.medium.IsDir(wsPath) {
return core.E("workspace.SwitchWorkspace", core.Concat("workspace not found: ", name), nil)
}
s.activeWorkspace = core.PathBase(wsPath)
return nil
}
// activeFilePath returns the full path to a file in the active workspace,
// or an error if no workspace is active.
func (s *Service) activeFilePath(op, filename string) (string, error) {
if s.activeWorkspace == "" {
return "", core.E(op, "no active workspace", nil)
}
filesRoot := core.Path(s.rootPath, s.activeWorkspace, "files")
path, err := joinWithinRoot(filesRoot, filename)
if err != nil {
return "", core.E(op, "file path escapes workspace files", fs.ErrPermission)
}
if path == filesRoot {
return "", core.E(op, "filename is required", fs.ErrInvalid)
}
return path, nil
}
// WorkspaceFileGet retrieves the content of a file from the active workspace.
//
// result := s.WorkspaceFileGet(...)
func (s *Service) WorkspaceFileGet(filename string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
path, err := s.activeFilePath("workspace.WorkspaceFileGet", filename)
if err != nil {
return "", err
}
return s.medium.Read(path)
}
// WorkspaceFileSet saves content to a file in the active workspace.
//
// result := s.WorkspaceFileSet(...)
func (s *Service) WorkspaceFileSet(filename, content string) error {
s.mu.Lock()
defer s.mu.Unlock()
path, err := s.activeFilePath("workspace.WorkspaceFileSet", filename)
if err != nil {
return err
}
return s.medium.Write(path, content)
}
// HandleIPCEvents handles workspace-related IPC messages.
//
// result := s.HandleIPCEvents(...)
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) core.Result {
switch m := msg.(type) {
case map[string]any:
action, _ := m["action"].(string)
switch action {
case "workspace.create":
id, _ := m["identifier"].(string)
pass, _ := m["password"].(string)
wsID, err := s.CreateWorkspace(id, pass)
if err != nil {
return core.Result{}
}
return core.Result{Value: wsID, OK: true}
case "workspace.switch":
name, _ := m["name"].(string)
if err := s.SwitchWorkspace(name); err != nil {
return core.Result{}
}
return core.Result{OK: true}
}
}
return core.Result{OK: true}
}
func workspaceHome() string {
if home := core.Env("CORE_HOME"); home != "" {
return home
}
if home := core.Env("HOME"); home != "" {
return home
}
return core.Env("DIR_HOME")
}
func joinWithinRoot(root string, parts ...string) (string, error) {
candidate := core.Path(append([]string{root}, parts...)...)
sep := core.Env("DS")
if candidate == root || core.HasPrefix(candidate, root+sep) {
return candidate, nil
}
return "", fs.ErrPermission
}
func (s *Service) workspacePath(op, name string) (string, error) {
if name == "" {
return "", core.E(op, "workspace name is required", fs.ErrInvalid)
}
path, err := joinWithinRoot(s.rootPath, name)
if err != nil {
return "", core.E(op, "workspace path escapes root", err)
}
if core.PathDir(path) != s.rootPath {
return "", core.E(op, core.Concat("invalid workspace name: ", name), fs.ErrPermission)
}
return path, nil
}
// Ensure Service implements Workspace.
var _ Workspace = (*Service)(nil)