emplemented stdout template extensions

This commit is contained in:
Matt Joyce
2024-12-01 09:13:22 +11:00
parent af16494be1
commit 160703210b
10 changed files with 782 additions and 5 deletions

View File

@@ -0,0 +1,196 @@
package template
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// ExtensionExecutor handles the secure execution of extensions
// It uses the registry to verify extensions before running them
type ExtensionExecutor struct {
registry *ExtensionRegistry
}
// NewExtensionExecutor creates a new executor instance
// It requires a registry to verify extensions
func NewExtensionExecutor(registry *ExtensionRegistry) *ExtensionExecutor {
return &ExtensionExecutor{
registry: registry,
}
}
// Execute runs an extension with the given operation and value string
// name: the registered name of the extension
// operation: the operation to perform
// value: the input value(s) for the operation
// In extension_executor.go
func (e *ExtensionExecutor) Execute(name, operation, value string) (string, error) {
// Get and verify extension from registry
ext, err := e.registry.GetExtension(name)
if err != nil {
return "", fmt.Errorf("failed to get extension: %w", err)
}
// Format the command using our template system
cmdStr, err := e.formatCommand(ext, operation, value)
if err != nil {
return "", fmt.Errorf("failed to format command: %w", err)
}
// Split the command string into command and arguments
cmdParts := strings.Fields(cmdStr)
if len(cmdParts) < 1 {
return "", fmt.Errorf("empty command after formatting")
}
// Create command with the Executable and formatted arguments
cmd := exec.Command("sh", "-c", cmdStr)
//cmd := exec.Command(cmdParts[0], cmdParts[1:]...)
// Set up environment if specified
if len(ext.Env) > 0 {
cmd.Env = append(os.Environ(), ext.Env...)
}
// Execute based on output method
outputMethod := ext.GetOutputMethod()
if outputMethod == "file" {
return e.executeWithFile(cmd, ext)
}
return e.executeStdout(cmd, ext)
}
// formatCommand uses fabric's template system to format the command
// It creates a variables map for the template system using the input values
func (e *ExtensionExecutor) formatCommand(ext *ExtensionDefinition, operation string, value string) (string, error) {
// Get operation config
opConfig, exists := ext.Operations[operation]
if !exists {
return "", fmt.Errorf("operation %s not found for extension %s", operation, ext.Name)
}
vars := make(map[string]string)
vars["executable"] = ext.Executable
vars["operation"] = operation
vars["value"] = value
// Split on pipe for numbered variables
values := strings.Split(value, "|")
for i, val := range values {
vars[fmt.Sprintf("%d", i+1)] = val
}
return ApplyTemplate(opConfig.CmdTemplate, vars, "")
}
// executeStdout runs the command and captures its stdout
func (e *ExtensionExecutor) executeStdout(cmd *exec.Cmd, ext *ExtensionDefinition) (string, error) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
//debug output
fmt.Printf("Executing command: %s\n", cmd.String())
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("execution failed: %w\nstderr: %s", err, stderr.String())
}
return stdout.String(), nil
}
// executeWithFile runs the command and handles file-based output
func (e *ExtensionExecutor) executeWithFile(cmd *exec.Cmd, ext *ExtensionDefinition) (string, error) {
// Parse timeout - this is now a first-class field
timeout, err := time.ParseDuration(ext.Timeout)
if err != nil {
return "", fmt.Errorf("invalid timeout format: %w", err)
}
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
cmd.Env = cmd.Env
fileConfig := ext.GetFileConfig()
if fileConfig == nil {
return "", fmt.Errorf("no file configuration found")
}
// Handle path from stdout case
if pathFromStdout, ok := fileConfig["path_from_stdout"].(bool); ok && pathFromStdout {
return e.handlePathFromStdout(cmd, ext)
}
// Handle fixed file case
workDir, _ := fileConfig["work_dir"].(string)
outputFile, _ := fileConfig["output_file"].(string)
if outputFile == "" {
return "", fmt.Errorf("no output file specified in configuration")
}
// Set working directory if specified
if workDir != "" {
cmd.Dir = workDir
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("execution timed out after %v", timeout)
}
return "", fmt.Errorf("execution failed: %w\nerr: %s", err, stderr.String())
}
// Construct full file path
outputPath := outputFile
if workDir != "" {
outputPath = filepath.Join(workDir, outputFile)
}
content, err := os.ReadFile(outputPath)
if err != nil {
return "", fmt.Errorf("failed to read output file: %w", err)
}
// Handle cleanup if enabled
if ext.IsCleanupEnabled() {
defer os.Remove(outputPath)
}
return string(content), nil
}
// Helper method to handle path from stdout case
func (e *ExtensionExecutor) handlePathFromStdout(cmd *exec.Cmd, ext *ExtensionDefinition) (string, error) {
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("failed to get output path: %w\nerr: %s", err, stderr.String())
}
outputPath := strings.TrimSpace(stdout.String())
content, err := os.ReadFile(outputPath)
if err != nil {
return "", fmt.Errorf("failed to read output file: %w", err)
}
if ext.IsCleanupEnabled() {
defer os.Remove(outputPath)
}
return string(content), nil
}

View File

@@ -0,0 +1,84 @@
package template
import (
"fmt"
"path/filepath"
)
// ExtensionManager handles the high-level operations of the extension system
type ExtensionManager struct {
registry *ExtensionRegistry
executor *ExtensionExecutor
configDir string
}
// NewExtensionManager creates a new extension manager instance
func NewExtensionManager(configDir string) *ExtensionManager {
registry := NewExtensionRegistry(configDir)
return &ExtensionManager{
registry: registry,
executor: NewExtensionExecutor(registry),
configDir: configDir,
}
}
// ListExtensions handles the listextensions flag action
func (em *ExtensionManager) ListExtensions() error {
extensions, err := em.registry.ListExtensions()
if err != nil {
return fmt.Errorf("failed to list extensions: %w", err)
}
for _, ext := range extensions {
fmt.Printf("Name: %s\n", ext.Name)
fmt.Printf(" Executable: %s\n", ext.Executable)
fmt.Printf(" Type: %s\n", ext.Type)
fmt.Printf(" Timeout: %s\n", ext.Timeout)
fmt.Printf(" Description: %s\n", ext.Description)
fmt.Printf(" Version: %s\n", ext.Version)
fmt.Printf(" Operations:\n")
for opName, opConfig := range ext.Operations {
fmt.Printf(" %s:\n", opName)
fmt.Printf(" Command Template: %s\n", opConfig.CmdTemplate)
}
if fileConfig := ext.GetFileConfig(); fileConfig != nil {
fmt.Printf(" File Configuration:\n")
for k, v := range fileConfig {
fmt.Printf(" %s: %v\n", k, v)
}
}
fmt.Printf("\n")
}
return nil
}
// RegisterExtension handles the addextension flag action
func (em *ExtensionManager) RegisterExtension(configPath string) error {
absPath, err := filepath.Abs(configPath)
if err != nil {
return fmt.Errorf("invalid config path: %w", err)
}
if err := em.registry.Register(absPath); err != nil {
return fmt.Errorf("failed to register extension: %w", err)
}
return nil
}
// RemoveExtension handles the rmextension flag action
func (em *ExtensionManager) RemoveExtension(name string) error {
if err := em.registry.Remove(name); err != nil {
return fmt.Errorf("failed to remove extension: %w", err)
}
return nil
}
// ProcessExtension handles template processing for extension directives
func (em *ExtensionManager) ProcessExtension(name, operation, value string) (string, error) {
return em.executor.Execute(name, operation, value)
}

View File

@@ -0,0 +1,229 @@
package template
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
// Add this import
)
// ExtensionDefinition represents a single extension configuration
type ExtensionDefinition struct {
// Global properties
Name string `yaml:"name"`
Executable string `yaml:"executable"`
Type string `yaml:"type"`
Timeout string `yaml:"timeout"`
Description string `yaml:"description"`
Version string `yaml:"version"`
Env []string `yaml:"env"`
// Operation-specific commands
Operations map[string]OperationConfig `yaml:"operations"`
// Additional config
Config map[string]interface{} `yaml:"config"`
}
type OperationConfig struct {
CmdTemplate string `yaml:"cmd_template"`
}
type ExtensionRegistry struct {
configDir string
registry struct {
Extensions map[string]*ExtensionDefinition
ConfigHashes map[string]string
ExecutableHashes map[string]string
}
}
// Helper methods for Config access
func (e *ExtensionDefinition) GetOutputMethod() string {
if output, ok := e.Config["output"].(map[string]interface{}); ok {
if method, ok := output["method"].(string); ok {
return method
}
}
return "stdout" // default to stdout if not specified
}
func (e *ExtensionDefinition) GetFileConfig() map[string]interface{} {
if output, ok := e.Config["output"].(map[string]interface{}); ok {
if fileConfig, ok := output["file_config"].(map[string]interface{}); ok {
return fileConfig
}
}
return nil
}
func (e *ExtensionDefinition) IsCleanupEnabled() bool {
if fc := e.GetFileConfig(); fc != nil {
if cleanup, ok := fc["cleanup"].(bool); ok {
return cleanup
}
}
return false // default to no cleanup
}
func NewExtensionRegistry(configDir string) *ExtensionRegistry {
r := &ExtensionRegistry{
configDir: configDir,
}
r.registry.Extensions = make(map[string]*ExtensionDefinition)
r.registry.ConfigHashes = make(map[string]string)
r.registry.ExecutableHashes = make(map[string]string)
// Ensure extensions directory exists
r.ensureConfigDir()
// Load existing registry if it exists
if err := r.loadRegistry(); err != nil {
// Since we're in a constructor, we can't return error
// Log it if we have logging, but continue with empty registry
if Debug {
fmt.Printf("Warning: could not load extension registry: %v\n", err)
}
}
return r
}
func (r *ExtensionRegistry) ensureConfigDir() error {
extDir := filepath.Join(r.configDir, "extensions")
return os.MkdirAll(extDir, 0755)
}
func (r *ExtensionRegistry) Register(configPath string) error {
// Read and parse the extension definition
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
var ext ExtensionDefinition
if err := yaml.Unmarshal(data, &ext); err != nil {
return fmt.Errorf("failed to parse config file: %w", err)
}
// Verify Executable exists
if _, err := os.Stat(ext.Executable); err != nil {
return fmt.Errorf("Executable not found: %w", err)
}
// Calculate hashes using template package functions
configHash := ComputeStringHash(string(data))
ExecutableHash, err := ComputeHash(ext.Executable)
if err != nil {
return fmt.Errorf("failed to hash Executable: %w", err)
}
// Store extension and hashes
r.registry.Extensions[ext.Name] = &ext
r.registry.ConfigHashes[ext.Name] = configHash
r.registry.ExecutableHashes[ext.Name] = ExecutableHash
return r.saveRegistry()
}
func (r *ExtensionRegistry) Remove(name string) error {
if _, exists := r.registry.Extensions[name]; !exists {
return fmt.Errorf("extension %s not found", name)
}
delete(r.registry.Extensions, name)
delete(r.registry.ConfigHashes, name)
delete(r.registry.ExecutableHashes, name)
return r.saveRegistry()
}
func (r *ExtensionRegistry) Verify(name string) error {
ext, exists := r.registry.Extensions[name]
if !exists {
return fmt.Errorf("extension %s not found", name)
}
// Verify Executable hash using template package function
currentExecutableHash, err := ComputeHash(ext.Executable)
if err != nil {
return fmt.Errorf("failed to verify Executable: %w", err)
}
if currentExecutableHash != r.registry.ExecutableHashes[name] {
return fmt.Errorf("Executable hash mismatch for %s", name)
}
return nil
}
func (r *ExtensionRegistry) GetExtension(name string) (*ExtensionDefinition, error) {
ext, exists := r.registry.Extensions[name]
if !exists {
return nil, fmt.Errorf("extension %s not found", name)
}
if err := r.Verify(name); err != nil {
return nil, err
}
return ext, nil
}
func (r *ExtensionRegistry) ListExtensions() ([]*ExtensionDefinition, error) {
exts := make([]*ExtensionDefinition, 0, len(r.registry.Extensions))
for _, ext := range r.registry.Extensions {
exts = append(exts, ext)
}
return exts, nil
}
func (r *ExtensionRegistry) calculateFileHash(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func (r *ExtensionRegistry) saveRegistry() error {
data, err := yaml.Marshal(r.registry)
if err != nil {
return fmt.Errorf("failed to marshal extension registry: %w", err)
}
registryPath := filepath.Join(r.configDir, "extensions", "extensions.yaml")
return os.WriteFile(registryPath, data, 0644)
}
func (r *ExtensionRegistry) loadRegistry() error {
registryPath := filepath.Join(r.configDir, "extensions", "extensions.yaml")
data, err := os.ReadFile(registryPath)
if err != nil {
if os.IsNotExist(err) {
return nil // New registry
}
return fmt.Errorf("failed to read extension registry: %w", err)
}
// Need to unmarshal the data into our registry
if err := yaml.Unmarshal(data, &r.registry); err != nil {
return fmt.Errorf("failed to parse extension registry: %w", err)
}
return nil
}

33
plugins/template/hash.go Normal file
View File

@@ -0,0 +1,33 @@
package template
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
)
// ComputeHash computes SHA-256 hash of a file at given path.
// Returns the hex-encoded hash string or an error if the operation fails.
func ComputeHash(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("open file: %w", err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", fmt.Errorf("read file: %w", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// ComputeStringHash returns hex-encoded SHA-256 hash of the given string
func ComputeStringHash(s string) string {
h := sha256.New()
h.Write([]byte(s))
return hex.EncodeToString(h.Sum(nil))
}

View File

@@ -0,0 +1,119 @@
// template/hash_test.go
package template
import (
"os"
"path/filepath"
"testing"
)
func TestComputeHash(t *testing.T) {
// Create a temporary test file
content := []byte("test content for hashing")
tmpfile, err := os.CreateTemp("", "hashtest")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(tmpfile.Name())
if _, err := tmpfile.Write(content); err != nil {
t.Fatalf("failed to write to temp file: %v", err)
}
if err := tmpfile.Close(); err != nil {
t.Fatalf("failed to close temp file: %v", err)
}
tests := []struct {
name string
path string
want string // known hash for test content
wantErr bool
}{
{
name: "valid file",
path: tmpfile.Name(),
want: "e25dd806d495b413931f4eea50b677a7a5c02d00460924661283f211a37f7e7f", // pre-computed hash of "test content for hashing"
wantErr: false,
},
{
name: "nonexistent file",
path: filepath.Join(os.TempDir(), "nonexistent"),
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ComputeHash(tt.path)
if (err != nil) != tt.wantErr {
t.Errorf("ComputeHash() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want && !tt.wantErr {
t.Errorf("ComputeHash() = %v, want %v", got, tt.want)
}
})
}
}
func TestComputeStringHash(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "empty string",
input: "",
want: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
},
{
name: "simple string",
input: "test",
want: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
},
{
name: "longer string with spaces",
input: "this is a test string",
want: "f6774519d1c7a3389ef327e9c04766b999db8cdfb85d1346c471ee86d65885bc",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ComputeStringHash(tt.input); got != tt.want {
t.Errorf("ComputeStringHash() = %v, want %v", got, tt.want)
}
})
}
}
// TestHashConsistency ensures both hash functions produce same results for same content
func TestHashConsistency(t *testing.T) {
content := "test content for consistency check"
// Create a file with the test content
tmpfile, err := os.CreateTemp("", "hashconsistency")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(tmpfile.Name())
if err := os.WriteFile(tmpfile.Name(), []byte(content), 0644); err != nil {
t.Fatalf("failed to write to temp file: %v", err)
}
// Get hashes using both methods
fileHash, err := ComputeHash(tmpfile.Name())
if err != nil {
t.Fatalf("ComputeHash failed: %v", err)
}
stringHash := ComputeStringHash(content)
// Compare results
if fileHash != stringHash {
t.Errorf("Hash inconsistency: file hash %v != string hash %v", fileHash, stringHash)
}
}

View File

@@ -2,6 +2,8 @@ package template
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
@@ -11,11 +13,24 @@ var (
datetimePlugin = &DateTimePlugin{}
filePlugin = &FilePlugin{}
fetchPlugin = &FetchPlugin{}
sysPlugin = &SysPlugin{}
Debug = false // Debug flag
sysPlugin = &SysPlugin{}
extensionManager *ExtensionManager
Debug = true // Debug flag
)
func init() {
homedir, err := os.UserHomeDir()
if err != nil {
// We should probably handle this error appropriately
return
}
configDir := filepath.Join(homedir, ".config/fabric")
extensionManager = NewExtensionManager(configDir)
}
var pluginPattern = regexp.MustCompile(`\{\{plugin:([^:]+):([^:]+)(?::([^}]+))?\}\}`)
var extensionPattern = regexp.MustCompile(`\{\{ext:([^:]+):([^:]+)(?::([^}]+))?\}\}`)
func debugf(format string, a ...interface{}) {
if Debug {
@@ -91,6 +106,31 @@ func ApplyTemplate(content string, variables map[string]string, input string) (s
}
}
if pluginMatches := extensionPattern.FindStringSubmatch(fullMatch); len(pluginMatches) >= 3 {
name := pluginMatches[1]
operation := pluginMatches[2]
value := ""
if len(pluginMatches) == 4 {
value = pluginMatches[3]
}
debugf("\nExtension call:\n")
debugf(" Name: %s\n", name)
debugf(" Operation: %s\n", operation)
debugf(" Value: %s\n", value)
result, err := extensionManager.ProcessExtension(name, operation, value)
if err != nil {
return "", fmt.Errorf("extension %s error: %v", name, err)
}
content = strings.ReplaceAll(content, fullMatch, result)
replaced = true
continue
}
// Handle regular variables and input
debugf("Processing variable: %s\n", varName)
if varName == "input" {

41
plugins/template/utils.go Normal file
View File

@@ -0,0 +1,41 @@
// utils.go in template package for now
package template
import (
"fmt"
"os"
"os/user"
"path/filepath"
"strings"
)
// ExpandPath expands the ~ to user's home directory and returns absolute path
// It also checks if the path exists
// Returns expanded absolute path or error if:
// - cannot determine user home directory
// - cannot convert to absolute path
// - path doesn't exist
func ExpandPath(path string) (string, error) {
// If path starts with ~
if strings.HasPrefix(path, "~/") {
usr, err := user.Current()
if err != nil {
return "", fmt.Errorf("failed to get user home directory: %w", err)
}
// Replace ~/ with actual home directory
path = filepath.Join(usr.HomeDir, path[2:])
}
// Convert to absolute path
absPath, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("failed to get absolute path: %w", err)
}
// Check if path exists
if _, err := os.Stat(absPath); err != nil {
return "", fmt.Errorf("path does not exist: %w", err)
}
return absPath, nil
}