Compare commits
No commits in common. "0681fba48e6c3450d82a181b5b707e94d5d812ca" and "ac2e83b88dd67766d0222674e51f6aca92bf3e70" have entirely different histories.
0681fba48e
...
ac2e83b88d
19 changed files with 0 additions and 1130 deletions
|
|
@ -1,73 +0,0 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Options configures the CoreDeno sidecar.
|
||||
type Options struct {
|
||||
DenoPath string // path to deno binary (default: "deno")
|
||||
SocketPath string // Unix socket path for gRPC
|
||||
}
|
||||
|
||||
// Permissions declares per-module Deno permission flags.
|
||||
type Permissions struct {
|
||||
Read []string
|
||||
Write []string
|
||||
Net []string
|
||||
Run []string
|
||||
}
|
||||
|
||||
// Flags converts permissions to Deno --allow-* CLI flags.
|
||||
func (p Permissions) Flags() []string {
|
||||
var flags []string
|
||||
if len(p.Read) > 0 {
|
||||
flags = append(flags, fmt.Sprintf("--allow-read=%s", strings.Join(p.Read, ",")))
|
||||
}
|
||||
if len(p.Write) > 0 {
|
||||
flags = append(flags, fmt.Sprintf("--allow-write=%s", strings.Join(p.Write, ",")))
|
||||
}
|
||||
if len(p.Net) > 0 {
|
||||
flags = append(flags, fmt.Sprintf("--allow-net=%s", strings.Join(p.Net, ",")))
|
||||
}
|
||||
if len(p.Run) > 0 {
|
||||
flags = append(flags, fmt.Sprintf("--allow-run=%s", strings.Join(p.Run, ",")))
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
// DefaultSocketPath returns the default Unix socket path.
|
||||
func DefaultSocketPath() string {
|
||||
xdg := os.Getenv("XDG_RUNTIME_DIR")
|
||||
if xdg == "" {
|
||||
xdg = "/tmp"
|
||||
}
|
||||
return filepath.Join(xdg, "core", "deno.sock")
|
||||
}
|
||||
|
||||
// Sidecar manages a Deno child process.
|
||||
type Sidecar struct {
|
||||
opts Options
|
||||
mu sync.RWMutex
|
||||
cmd *exec.Cmd
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewSidecar creates a Sidecar with the given options.
|
||||
func NewSidecar(opts Options) *Sidecar {
|
||||
if opts.DenoPath == "" {
|
||||
opts.DenoPath = "deno"
|
||||
}
|
||||
if opts.SocketPath == "" {
|
||||
opts.SocketPath = DefaultSocketPath()
|
||||
}
|
||||
return &Sidecar{opts: opts}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewSidecar_Good(t *testing.T) {
|
||||
opts := Options{
|
||||
DenoPath: "echo",
|
||||
SocketPath: "/tmp/test-core-deno.sock",
|
||||
}
|
||||
sc := NewSidecar(opts)
|
||||
require.NotNil(t, sc)
|
||||
assert.Equal(t, "echo", sc.opts.DenoPath)
|
||||
assert.Equal(t, "/tmp/test-core-deno.sock", sc.opts.SocketPath)
|
||||
}
|
||||
|
||||
func TestDefaultSocketPath_Good(t *testing.T) {
|
||||
path := DefaultSocketPath()
|
||||
assert.Contains(t, path, "core/deno.sock")
|
||||
}
|
||||
|
||||
func TestSidecar_PermissionFlags_Good(t *testing.T) {
|
||||
perms := Permissions{
|
||||
Read: []string{"./data/"},
|
||||
Write: []string{"./data/config.json"},
|
||||
Net: []string{"pool.lthn.io:3333"},
|
||||
Run: []string{"xmrig"},
|
||||
}
|
||||
flags := perms.Flags()
|
||||
assert.Contains(t, flags, "--allow-read=./data/")
|
||||
assert.Contains(t, flags, "--allow-write=./data/config.json")
|
||||
assert.Contains(t, flags, "--allow-net=pool.lthn.io:3333")
|
||||
assert.Contains(t, flags, "--allow-run=xmrig")
|
||||
}
|
||||
|
||||
func TestSidecar_PermissionFlags_Empty(t *testing.T) {
|
||||
perms := Permissions{}
|
||||
flags := perms.Flags()
|
||||
assert.Empty(t, flags)
|
||||
}
|
||||
|
||||
func TestDefaultSocketPath_XDG(t *testing.T) {
|
||||
orig := os.Getenv("XDG_RUNTIME_DIR")
|
||||
defer os.Setenv("XDG_RUNTIME_DIR", orig)
|
||||
|
||||
os.Setenv("XDG_RUNTIME_DIR", "/run/user/1000")
|
||||
path := DefaultSocketPath()
|
||||
assert.Equal(t, "/run/user/1000/core/deno.sock", path)
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Start launches the Deno sidecar process with the given entrypoint args.
|
||||
func (s *Sidecar) Start(ctx context.Context, args ...string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.cmd != nil {
|
||||
return fmt.Errorf("coredeno: already running")
|
||||
}
|
||||
|
||||
// Ensure socket directory exists
|
||||
sockDir := filepath.Dir(s.opts.SocketPath)
|
||||
if err := os.MkdirAll(sockDir, 0755); err != nil {
|
||||
return fmt.Errorf("coredeno: mkdir %s: %w", sockDir, err)
|
||||
}
|
||||
|
||||
// Remove stale socket
|
||||
os.Remove(s.opts.SocketPath)
|
||||
|
||||
s.ctx, s.cancel = context.WithCancel(ctx)
|
||||
s.cmd = exec.CommandContext(s.ctx, s.opts.DenoPath, args...)
|
||||
s.done = make(chan struct{})
|
||||
if err := s.cmd.Start(); err != nil {
|
||||
s.cmd = nil
|
||||
s.cancel()
|
||||
return fmt.Errorf("coredeno: start: %w", err)
|
||||
}
|
||||
|
||||
// Monitor in background — waits for exit, then signals done
|
||||
go func() {
|
||||
s.cmd.Wait()
|
||||
s.mu.Lock()
|
||||
s.cmd = nil
|
||||
s.mu.Unlock()
|
||||
close(s.done)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop cancels the context and waits for the process to exit.
|
||||
func (s *Sidecar) Stop() error {
|
||||
s.mu.RLock()
|
||||
if s.cmd == nil {
|
||||
s.mu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
done := s.done
|
||||
s.mu.RUnlock()
|
||||
|
||||
s.cancel()
|
||||
<-done
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRunning returns true if the sidecar process is alive.
|
||||
func (s *Sidecar) IsRunning() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.cmd != nil
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStart_Good(t *testing.T) {
|
||||
sockDir := t.TempDir()
|
||||
sc := NewSidecar(Options{
|
||||
DenoPath: "sleep",
|
||||
SocketPath: filepath.Join(sockDir, "test.sock"),
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := sc.Start(ctx, "10") // sleep 10 — will be killed by Stop
|
||||
require.NoError(t, err)
|
||||
assert.True(t, sc.IsRunning())
|
||||
|
||||
err = sc.Stop()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, sc.IsRunning())
|
||||
}
|
||||
|
||||
func TestStop_Good_NotStarted(t *testing.T) {
|
||||
sc := NewSidecar(Options{DenoPath: "sleep"})
|
||||
err := sc.Stop()
|
||||
assert.NoError(t, err, "stopping a not-started sidecar should be a no-op")
|
||||
}
|
||||
|
||||
func TestSocketDirCreated_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sockPath := filepath.Join(dir, "sub", "deno.sock")
|
||||
sc := NewSidecar(Options{
|
||||
DenoPath: "sleep",
|
||||
SocketPath: sockPath,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := sc.Start(ctx, "10")
|
||||
require.NoError(t, err)
|
||||
defer sc.Stop()
|
||||
|
||||
_, err = os.Stat(filepath.Join(dir, "sub"))
|
||||
assert.NoError(t, err, "socket directory should be created")
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CheckPath returns true if the given path is under any of the allowed prefixes.
|
||||
// Empty allowed list means deny all (secure by default).
|
||||
func CheckPath(path string, allowed []string) bool {
|
||||
if len(allowed) == 0 {
|
||||
return false
|
||||
}
|
||||
clean := filepath.Clean(path)
|
||||
for _, prefix := range allowed {
|
||||
cleanPrefix := filepath.Clean(prefix)
|
||||
if strings.HasPrefix(clean, cleanPrefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckNet returns true if the given host:port is in the allowed list.
|
||||
func CheckNet(addr string, allowed []string) bool {
|
||||
for _, a := range allowed {
|
||||
if a == addr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckRun returns true if the given command is in the allowed list.
|
||||
func CheckRun(cmd string, allowed []string) bool {
|
||||
for _, a := range allowed {
|
||||
if a == cmd {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCheckPath_Good_Allowed(t *testing.T) {
|
||||
allowed := []string{"./data/", "./config/"}
|
||||
assert.True(t, CheckPath("./data/file.txt", allowed))
|
||||
assert.True(t, CheckPath("./config/app.json", allowed))
|
||||
}
|
||||
|
||||
func TestCheckPath_Bad_Denied(t *testing.T) {
|
||||
allowed := []string{"./data/"}
|
||||
assert.False(t, CheckPath("./secrets/key.pem", allowed))
|
||||
assert.False(t, CheckPath("../escape/file", allowed))
|
||||
}
|
||||
|
||||
func TestCheckPath_Good_EmptyDenyAll(t *testing.T) {
|
||||
assert.False(t, CheckPath("./anything", nil))
|
||||
assert.False(t, CheckPath("./anything", []string{}))
|
||||
}
|
||||
|
||||
func TestCheckNet_Good_Allowed(t *testing.T) {
|
||||
allowed := []string{"pool.lthn.io:3333", "api.lthn.io:443"}
|
||||
assert.True(t, CheckNet("pool.lthn.io:3333", allowed))
|
||||
}
|
||||
|
||||
func TestCheckNet_Bad_Denied(t *testing.T) {
|
||||
allowed := []string{"pool.lthn.io:3333"}
|
||||
assert.False(t, CheckNet("evil.com:80", allowed))
|
||||
}
|
||||
|
||||
func TestCheckRun_Good(t *testing.T) {
|
||||
allowed := []string{"xmrig", "sha256sum"}
|
||||
assert.True(t, CheckRun("xmrig", allowed))
|
||||
assert.False(t, CheckRun("rm", allowed))
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
syntax = "proto3";
|
||||
package coredeno;
|
||||
option go_package = "forge.lthn.ai/core/go/pkg/coredeno/proto";
|
||||
|
||||
// CoreService is implemented by CoreGO — Deno calls this for I/O.
|
||||
service CoreService {
|
||||
// Filesystem (gated by manifest permissions)
|
||||
rpc FileRead(FileReadRequest) returns (FileReadResponse);
|
||||
rpc FileWrite(FileWriteRequest) returns (FileWriteResponse);
|
||||
rpc FileList(FileListRequest) returns (FileListResponse);
|
||||
rpc FileDelete(FileDeleteRequest) returns (FileDeleteResponse);
|
||||
|
||||
// Object store
|
||||
rpc StoreGet(StoreGetRequest) returns (StoreGetResponse);
|
||||
rpc StoreSet(StoreSetRequest) returns (StoreSetResponse);
|
||||
|
||||
// Process management
|
||||
rpc ProcessStart(ProcessStartRequest) returns (ProcessStartResponse);
|
||||
rpc ProcessStop(ProcessStopRequest) returns (ProcessStopResponse);
|
||||
}
|
||||
|
||||
// DenoService is implemented by CoreDeno — Go calls this for module lifecycle.
|
||||
service DenoService {
|
||||
rpc LoadModule(LoadModuleRequest) returns (LoadModuleResponse);
|
||||
rpc UnloadModule(UnloadModuleRequest) returns (UnloadModuleResponse);
|
||||
rpc ModuleStatus(ModuleStatusRequest) returns (ModuleStatusResponse);
|
||||
}
|
||||
|
||||
// --- Core (Go-side) messages ---
|
||||
|
||||
message FileReadRequest { string path = 1; string module_code = 2; }
|
||||
message FileReadResponse { string content = 1; }
|
||||
|
||||
message FileWriteRequest { string path = 1; string content = 2; string module_code = 3; }
|
||||
message FileWriteResponse { bool ok = 1; }
|
||||
|
||||
message FileListRequest { string path = 1; string module_code = 2; }
|
||||
message FileListResponse {
|
||||
repeated FileEntry entries = 1;
|
||||
}
|
||||
message FileEntry {
|
||||
string name = 1;
|
||||
bool is_dir = 2;
|
||||
int64 size = 3;
|
||||
}
|
||||
|
||||
message FileDeleteRequest { string path = 1; string module_code = 2; }
|
||||
message FileDeleteResponse { bool ok = 1; }
|
||||
|
||||
message StoreGetRequest { string group = 1; string key = 2; }
|
||||
message StoreGetResponse { string value = 1; bool found = 2; }
|
||||
|
||||
message StoreSetRequest { string group = 1; string key = 2; string value = 3; }
|
||||
message StoreSetResponse { bool ok = 1; }
|
||||
|
||||
message ProcessStartRequest { string command = 1; repeated string args = 2; string module_code = 3; }
|
||||
message ProcessStartResponse { string process_id = 1; }
|
||||
|
||||
message ProcessStopRequest { string process_id = 1; }
|
||||
message ProcessStopResponse { bool ok = 1; }
|
||||
|
||||
// --- Deno-side messages ---
|
||||
|
||||
message LoadModuleRequest { string code = 1; string entry_point = 2; repeated string permissions = 3; }
|
||||
message LoadModuleResponse { bool ok = 1; string error = 2; }
|
||||
|
||||
message UnloadModuleRequest { string code = 1; }
|
||||
message UnloadModuleResponse { bool ok = 1; }
|
||||
|
||||
message ModuleStatusRequest { string code = 1; }
|
||||
message ModuleStatusResponse {
|
||||
string code = 1;
|
||||
enum Status {
|
||||
UNKNOWN = 0;
|
||||
LOADING = 1;
|
||||
RUNNING = 2;
|
||||
STOPPED = 3;
|
||||
ERRORED = 4;
|
||||
}
|
||||
Status status = 2;
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
package coredeno
|
||||
|
||||
import "context"
|
||||
|
||||
// Service wraps the CoreDeno sidecar for framework lifecycle integration.
|
||||
// Implements Startable (OnStartup) and Stoppable (OnShutdown) interfaces.
|
||||
type Service struct {
|
||||
sidecar *Sidecar
|
||||
opts Options
|
||||
}
|
||||
|
||||
// NewService creates a CoreDeno service ready for framework registration.
|
||||
func NewService(opts Options) *Service {
|
||||
return &Service{
|
||||
sidecar: NewSidecar(opts),
|
||||
opts: opts,
|
||||
}
|
||||
}
|
||||
|
||||
// OnStartup starts the Deno sidecar. Called by the framework.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnShutdown stops the Deno sidecar. Called by the framework.
|
||||
func (s *Service) OnShutdown() error {
|
||||
return s.sidecar.Stop()
|
||||
}
|
||||
|
||||
// Sidecar returns the underlying sidecar for direct access.
|
||||
func (s *Service) Sidecar() *Sidecar {
|
||||
return s.sidecar
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
package coredeno
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewService_Good(t *testing.T) {
|
||||
opts := Options{
|
||||
DenoPath: "echo",
|
||||
SocketPath: "/tmp/test-service.sock",
|
||||
}
|
||||
svc := NewService(opts)
|
||||
require.NotNil(t, svc)
|
||||
assert.NotNil(t, svc.sidecar)
|
||||
assert.Equal(t, "echo", svc.sidecar.opts.DenoPath)
|
||||
}
|
||||
|
||||
func TestService_OnShutdown_Good_NotStarted(t *testing.T) {
|
||||
svc := NewService(Options{DenoPath: "echo"})
|
||||
err := svc.OnShutdown()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestService_Sidecar_Good(t *testing.T) {
|
||||
svc := NewService(Options{DenoPath: "echo"})
|
||||
assert.NotNil(t, svc.Sidecar())
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
package manifest
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const manifestPath = ".core/view.yml"
|
||||
|
||||
// marshalYAML serializes a manifest to YAML bytes.
|
||||
func marshalYAML(m *Manifest) ([]byte, error) {
|
||||
return yaml.Marshal(m)
|
||||
}
|
||||
|
||||
// Load reads and parses a .core/view.yml from the given root directory.
|
||||
func Load(medium io.Medium, root string) (*Manifest, error) {
|
||||
path := filepath.Join(root, manifestPath)
|
||||
data, err := medium.Read(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("manifest.Load: %w", err)
|
||||
}
|
||||
return Parse([]byte(data))
|
||||
}
|
||||
|
||||
// LoadVerified reads, parses, and verifies the ed25519 signature.
|
||||
func LoadVerified(medium io.Medium, root string, pub ed25519.PublicKey) (*Manifest, error) {
|
||||
m, err := Load(medium, root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ok, err := Verify(m, pub)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("manifest.LoadVerified: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("manifest.LoadVerified: signature verification failed for %q", m.Code)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
package manifest
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoad_Good(t *testing.T) {
|
||||
fs := io.NewMockMedium()
|
||||
fs.Files[".core/view.yml"] = `
|
||||
code: test-app
|
||||
name: Test App
|
||||
version: 1.0.0
|
||||
layout: HLCRF
|
||||
slots:
|
||||
C: main-content
|
||||
`
|
||||
m, err := Load(fs, ".")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test-app", m.Code)
|
||||
assert.Equal(t, "main-content", m.Slots["C"])
|
||||
}
|
||||
|
||||
func TestLoad_Bad_NoManifest(t *testing.T) {
|
||||
fs := io.NewMockMedium()
|
||||
_, err := Load(fs, ".")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestLoadVerified_Good(t *testing.T) {
|
||||
pub, priv, _ := ed25519.GenerateKey(nil)
|
||||
m := &Manifest{
|
||||
Code: "signed-app", Name: "Signed", Version: "1.0.0",
|
||||
Layout: "HLCRF", Slots: map[string]string{"C": "main"},
|
||||
}
|
||||
_ = Sign(m, priv)
|
||||
|
||||
raw, _ := marshalYAML(m)
|
||||
fs := io.NewMockMedium()
|
||||
fs.Files[".core/view.yml"] = string(raw)
|
||||
|
||||
loaded, err := LoadVerified(fs, ".", pub)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "signed-app", loaded.Code)
|
||||
}
|
||||
|
||||
func TestLoadVerified_Bad_Tampered(t *testing.T) {
|
||||
pub, priv, _ := ed25519.GenerateKey(nil)
|
||||
m := &Manifest{Code: "app", Version: "1.0.0"}
|
||||
_ = Sign(m, priv)
|
||||
|
||||
raw, _ := marshalYAML(m)
|
||||
tampered := "code: evil\n" + string(raw)[6:]
|
||||
fs := io.NewMockMedium()
|
||||
fs.Files[".core/view.yml"] = tampered
|
||||
|
||||
_, err := LoadVerified(fs, ".", pub)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
package manifest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Manifest represents a .core/view.yml application manifest.
|
||||
type Manifest struct {
|
||||
Code string `yaml:"code"`
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
Sign string `yaml:"sign"`
|
||||
Layout string `yaml:"layout"`
|
||||
Slots map[string]string `yaml:"slots"`
|
||||
|
||||
Permissions Permissions `yaml:"permissions"`
|
||||
Modules []string `yaml:"modules"`
|
||||
}
|
||||
|
||||
// Permissions declares the I/O capabilities a module requires.
|
||||
type Permissions struct {
|
||||
Read []string `yaml:"read"`
|
||||
Write []string `yaml:"write"`
|
||||
Net []string `yaml:"net"`
|
||||
Run []string `yaml:"run"`
|
||||
}
|
||||
|
||||
// Parse decodes YAML bytes into a Manifest.
|
||||
func Parse(data []byte) (*Manifest, error) {
|
||||
var m Manifest
|
||||
if err := yaml.Unmarshal(data, &m); err != nil {
|
||||
return nil, fmt.Errorf("manifest.Parse: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// SlotNames returns a deduplicated list of component names from slots.
|
||||
func (m *Manifest) SlotNames() []string {
|
||||
seen := make(map[string]bool)
|
||||
var names []string
|
||||
for _, name := range m.Slots {
|
||||
if !seen[name] {
|
||||
seen[name] = true
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
package manifest
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParse_Good(t *testing.T) {
|
||||
raw := `
|
||||
code: photo-browser
|
||||
name: Photo Browser
|
||||
version: 0.1.0
|
||||
sign: dGVzdHNpZw==
|
||||
|
||||
layout: HLCRF
|
||||
slots:
|
||||
H: nav-breadcrumb
|
||||
L: folder-tree
|
||||
C: photo-grid
|
||||
R: metadata-panel
|
||||
F: status-bar
|
||||
|
||||
permissions:
|
||||
read: ["./photos/"]
|
||||
write: []
|
||||
net: []
|
||||
run: []
|
||||
|
||||
modules:
|
||||
- core/media
|
||||
- core/fs
|
||||
`
|
||||
m, err := Parse([]byte(raw))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "photo-browser", m.Code)
|
||||
assert.Equal(t, "Photo Browser", m.Name)
|
||||
assert.Equal(t, "0.1.0", m.Version)
|
||||
assert.Equal(t, "dGVzdHNpZw==", m.Sign)
|
||||
assert.Equal(t, "HLCRF", m.Layout)
|
||||
assert.Equal(t, "nav-breadcrumb", m.Slots["H"])
|
||||
assert.Equal(t, "photo-grid", m.Slots["C"])
|
||||
assert.Len(t, m.Permissions.Read, 1)
|
||||
assert.Equal(t, "./photos/", m.Permissions.Read[0])
|
||||
assert.Len(t, m.Modules, 2)
|
||||
}
|
||||
|
||||
func TestParse_Bad(t *testing.T) {
|
||||
_, err := Parse([]byte("not: valid: yaml: ["))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestManifest_SlotNames_Good(t *testing.T) {
|
||||
m := Manifest{
|
||||
Slots: map[string]string{
|
||||
"H": "nav-bar",
|
||||
"C": "main-content",
|
||||
},
|
||||
}
|
||||
names := m.SlotNames()
|
||||
assert.Contains(t, names, "nav-bar")
|
||||
assert.Contains(t, names, "main-content")
|
||||
assert.Len(t, names, 2)
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
package manifest
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// signable returns the canonical bytes to sign (manifest without sign field).
|
||||
func signable(m *Manifest) ([]byte, error) {
|
||||
tmp := *m
|
||||
tmp.Sign = ""
|
||||
return yaml.Marshal(&tmp)
|
||||
}
|
||||
|
||||
// Sign computes the ed25519 signature and stores it in m.Sign (base64).
|
||||
func Sign(m *Manifest, priv ed25519.PrivateKey) error {
|
||||
msg, err := signable(m)
|
||||
if err != nil {
|
||||
return fmt.Errorf("manifest.Sign: marshal: %w", err)
|
||||
}
|
||||
sig := ed25519.Sign(priv, msg)
|
||||
m.Sign = base64.StdEncoding.EncodeToString(sig)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify checks the ed25519 signature in m.Sign against the public key.
|
||||
func Verify(m *Manifest, pub ed25519.PublicKey) (bool, error) {
|
||||
if m.Sign == "" {
|
||||
return false, fmt.Errorf("manifest.Verify: no signature present")
|
||||
}
|
||||
sig, err := base64.StdEncoding.DecodeString(m.Sign)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("manifest.Verify: decode: %w", err)
|
||||
}
|
||||
msg, err := signable(m)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("manifest.Verify: marshal: %w", err)
|
||||
}
|
||||
return ed25519.Verify(pub, msg, sig), nil
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
package manifest
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSignAndVerify_Good(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
m := &Manifest{
|
||||
Code: "test-app",
|
||||
Name: "Test App",
|
||||
Version: "1.0.0",
|
||||
Layout: "HLCRF",
|
||||
Slots: map[string]string{"C": "main"},
|
||||
}
|
||||
|
||||
err = Sign(m, priv)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, m.Sign)
|
||||
|
||||
ok, err := Verify(m, pub)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
func TestVerify_Bad_Tampered(t *testing.T) {
|
||||
pub, priv, _ := ed25519.GenerateKey(nil)
|
||||
m := &Manifest{Code: "test-app", Version: "1.0.0"}
|
||||
_ = Sign(m, priv)
|
||||
|
||||
m.Code = "evil-app" // tamper
|
||||
|
||||
ok, err := Verify(m, pub)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestVerify_Bad_Unsigned(t *testing.T) {
|
||||
pub, _, _ := ed25519.GenerateKey(nil)
|
||||
m := &Manifest{Code: "test-app"}
|
||||
|
||||
ok, err := Verify(m, pub)
|
||||
assert.Error(t, err)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
package marketplace
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Module is a marketplace entry pointing to a module's Git repo.
|
||||
type Module struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Repo string `json:"repo"`
|
||||
SignKey string `json:"sign_key"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
// Index is the root marketplace catalog.
|
||||
type Index struct {
|
||||
Version int `json:"version"`
|
||||
Modules []Module `json:"modules"`
|
||||
Categories []string `json:"categories"`
|
||||
}
|
||||
|
||||
// ParseIndex decodes a marketplace index.json.
|
||||
func ParseIndex(data []byte) (*Index, error) {
|
||||
var idx Index
|
||||
if err := json.Unmarshal(data, &idx); err != nil {
|
||||
return nil, fmt.Errorf("marketplace.ParseIndex: %w", err)
|
||||
}
|
||||
return &idx, nil
|
||||
}
|
||||
|
||||
// Search returns modules matching the query in code, name, or category.
|
||||
func (idx *Index) Search(query string) []Module {
|
||||
q := strings.ToLower(query)
|
||||
var results []Module
|
||||
for _, m := range idx.Modules {
|
||||
if strings.Contains(strings.ToLower(m.Code), q) ||
|
||||
strings.Contains(strings.ToLower(m.Name), q) ||
|
||||
strings.Contains(strings.ToLower(m.Category), q) {
|
||||
results = append(results, m)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// ByCategory returns all modules in the given category.
|
||||
func (idx *Index) ByCategory(category string) []Module {
|
||||
var results []Module
|
||||
for _, m := range idx.Modules {
|
||||
if m.Category == category {
|
||||
results = append(results, m)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// Find returns the module with the given code, or false if not found.
|
||||
func (idx *Index) Find(code string) (Module, bool) {
|
||||
for _, m := range idx.Modules {
|
||||
if m.Code == code {
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
return Module{}, false
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
package marketplace
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseIndex_Good(t *testing.T) {
|
||||
raw := `{
|
||||
"version": 1,
|
||||
"modules": [
|
||||
{"code": "mining-xmrig", "name": "XMRig Miner", "repo": "https://forge.lthn.io/host-uk/mod-xmrig.git", "sign_key": "abc123", "category": "miner"},
|
||||
{"code": "utils-cyberchef", "name": "CyberChef", "repo": "https://forge.lthn.io/host-uk/mod-cyberchef.git", "sign_key": "def456", "category": "utils"}
|
||||
],
|
||||
"categories": ["miner", "utils"]
|
||||
}`
|
||||
idx, err := ParseIndex([]byte(raw))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, idx.Version)
|
||||
assert.Len(t, idx.Modules, 2)
|
||||
assert.Equal(t, "mining-xmrig", idx.Modules[0].Code)
|
||||
}
|
||||
|
||||
func TestSearch_Good(t *testing.T) {
|
||||
idx := &Index{
|
||||
Modules: []Module{
|
||||
{Code: "mining-xmrig", Name: "XMRig Miner", Category: "miner"},
|
||||
{Code: "utils-cyberchef", Name: "CyberChef", Category: "utils"},
|
||||
},
|
||||
}
|
||||
results := idx.Search("miner")
|
||||
assert.Len(t, results, 1)
|
||||
assert.Equal(t, "mining-xmrig", results[0].Code)
|
||||
}
|
||||
|
||||
func TestByCategory_Good(t *testing.T) {
|
||||
idx := &Index{
|
||||
Modules: []Module{
|
||||
{Code: "a", Category: "miner"},
|
||||
{Code: "b", Category: "utils"},
|
||||
{Code: "c", Category: "miner"},
|
||||
},
|
||||
}
|
||||
miners := idx.ByCategory("miner")
|
||||
assert.Len(t, miners, 2)
|
||||
}
|
||||
|
||||
func TestFind_Good(t *testing.T) {
|
||||
idx := &Index{
|
||||
Modules: []Module{
|
||||
{Code: "mining-xmrig", Name: "XMRig"},
|
||||
},
|
||||
}
|
||||
m, ok := idx.Find("mining-xmrig")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "XMRig", m.Name)
|
||||
}
|
||||
|
||||
func TestFind_Bad_NotFound(t *testing.T) {
|
||||
idx := &Index{}
|
||||
_, ok := idx.Find("nope")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Store is a group-namespaced key-value store backed by SQLite.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// New creates a Store at the given SQLite path. Use ":memory:" for tests.
|
||||
func New(dbPath string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store.New: %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("store.New: WAL: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS kv (
|
||||
grp TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY (grp, key)
|
||||
)`); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("store.New: schema: %w", err)
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying database.
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// Get retrieves a value by group and key.
|
||||
func (s *Store) Get(group, key string) (string, error) {
|
||||
var val string
|
||||
err := s.db.QueryRow("SELECT value FROM kv WHERE grp = ? AND key = ?", group, key).Scan(&val)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", fmt.Errorf("store.Get: not found: %s/%s", group, key)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("store.Get: %w", err)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// Set stores a value by group and key, overwriting if exists.
|
||||
func (s *Store) Set(group, key, value string) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO kv (grp, key, value) VALUES (?, ?, ?)
|
||||
ON CONFLICT(grp, key) DO UPDATE SET value = excluded.value`,
|
||||
group, key, value,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store.Set: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a single key from a group.
|
||||
func (s *Store) Delete(group, key string) error {
|
||||
_, err := s.db.Exec("DELETE FROM kv WHERE grp = ? AND key = ?", group, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store.Delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count returns the number of keys in a group.
|
||||
func (s *Store) Count(group string) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow("SELECT COUNT(*) FROM kv WHERE grp = ?", group).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store.Count: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// DeleteGroup removes all keys in a group.
|
||||
func (s *Store) DeleteGroup(group string) error {
|
||||
_, err := s.db.Exec("DELETE FROM kv WHERE grp = ?", group)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store.DeleteGroup: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render loads all key-value pairs from a group and renders a Go template.
|
||||
func (s *Store) Render(tmplStr, group string) (string, error) {
|
||||
rows, err := s.db.Query("SELECT key, value FROM kv WHERE grp = ?", group)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("store.Render: query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
vars := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return "", fmt.Errorf("store.Render: scan: %w", err)
|
||||
}
|
||||
vars[k] = v
|
||||
}
|
||||
|
||||
tmpl, err := template.New("render").Parse(tmplStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("store.Render: parse: %w", err)
|
||||
}
|
||||
var b strings.Builder
|
||||
if err := tmpl.Execute(&b, vars); err != nil {
|
||||
return "", fmt.Errorf("store.Render: exec: %w", err)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSetGet_Good(t *testing.T) {
|
||||
s, err := New(":memory:")
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
err = s.Set("config", "theme", "dark")
|
||||
require.NoError(t, err)
|
||||
|
||||
val, err := s.Get("config", "theme")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "dark", val)
|
||||
}
|
||||
|
||||
func TestGet_Bad_NotFound(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
_, err := s.Get("config", "missing")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDelete_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
_ = s.Set("config", "key", "val")
|
||||
err := s.Delete("config", "key")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.Get("config", "key")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCount_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
_ = s.Set("grp", "a", "1")
|
||||
_ = s.Set("grp", "b", "2")
|
||||
_ = s.Set("other", "c", "3")
|
||||
|
||||
n, err := s.Count("grp")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, n)
|
||||
}
|
||||
|
||||
func TestDeleteGroup_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
_ = s.Set("grp", "a", "1")
|
||||
_ = s.Set("grp", "b", "2")
|
||||
err := s.DeleteGroup("grp")
|
||||
require.NoError(t, err)
|
||||
|
||||
n, _ := s.Count("grp")
|
||||
assert.Equal(t, 0, n)
|
||||
}
|
||||
|
||||
func TestRender_Good(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
_ = s.Set("user", "pool", "pool.lthn.io:3333")
|
||||
_ = s.Set("user", "wallet", "iz...")
|
||||
|
||||
tmpl := `{"pool":"{{ .pool }}","wallet":"{{ .wallet }}"}`
|
||||
out, err := s.Render(tmpl, "user")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, out, "pool.lthn.io:3333")
|
||||
assert.Contains(t, out, "iz...")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue