Skip to content

Commit 8dde70e

Browse files
author
Kenth Fagerlund
committed
feat: Plugin system added
1 parent e6b399e commit 8dde70e

11 files changed

Lines changed: 800 additions & 102 deletions

File tree

pkg/desktop/notify.go

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -152,29 +152,56 @@ func (n *DesktopNotifier) Notify(title, message string) {
152152
go dispatchOSNotification(runner, targetOS, title, message)
153153
}
154154

155+
// sanitizeNotificationString strips ASCII control characters and truncates length.
156+
func sanitizeNotificationString(s string, maxLen int) string {
157+
s = strings.TrimSpace(s)
158+
var b strings.Builder
159+
b.Grow(len(s))
160+
for _, r := range s {
161+
// Keep printable characters, spaces, and standard unicode
162+
if r >= 0x20 && r != 0x7f {
163+
b.WriteRune(r)
164+
}
165+
}
166+
res := b.String()
167+
if len(res) > maxLen {
168+
res = res[:maxLen]
169+
}
170+
return res
171+
}
172+
155173
func dispatchOSNotification(runner CommandRunner, targetOS, title, message string) {
156174
if runner == nil {
157175
runner = defaultCommandRunner
158176
}
159177

178+
// Strict sanitization to prevent terminal and command injection
179+
title = sanitizeNotificationString(title, 256)
180+
message = sanitizeNotificationString(message, 1024)
181+
160182
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
161183
defer cancel()
162184

163185
switch targetOS {
164186
case "darwin":
165-
// macOS AppleScript notification (silent banner without alert sound)
166-
script := fmt.Sprintf(`display notification %q with title %q`, message, title)
167-
_ = runner(ctx, "osascript", "-e", script)
187+
// macOS AppleScript notification via argv passing (immune to script/command injection)
188+
_ = runner(
189+
ctx,
190+
"osascript",
191+
"-e", "on run argv",
192+
"-e", "display notification (item 2 of argv) with title (item 1 of argv)",
193+
"-e", "end run",
194+
title,
195+
message,
196+
)
168197

169198
case "linux":
170199
// Linux notification via notify-send (silent hint suppresses sound on notification daemons)
171200
_ = runner(ctx, "notify-send", "-a", "halpradio", "-u", "normal", "-h", "boolean:suppress-sound:true", title, message)
172201

173202
case "windows":
174-
// Windows PowerShell Toast Notification (silent audio attribute suppresses chime)
175-
cleanTitle := strings.ReplaceAll(title, "'", "''")
176-
cleanMsg := strings.ReplaceAll(message, "'", "''")
177-
psScript := fmt.Sprintf(`[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; $template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); $textNodes = $template.GetElementsByTagName('text'); $textNodes.Item(0).AppendChild($template.CreateTextNode('%s')) > $null; $textNodes.Item(1).AppendChild($template.CreateTextNode('%s')) > $null; $audio = $template.CreateElement('audio'); $audio.SetAttribute('silent', 'true'); $template.DocumentElement.AppendChild($audio) > $null; $toast = [Windows.UI.Notifications.ToastNotification]::new($template); [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('halpradio').Show($toast);`, cleanTitle, cleanMsg)
178-
_ = runner(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", psScript)
203+
// Windows PowerShell Toast Notification passing parameters as isolated arguments without string interpolation
204+
psScript := `[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; $template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); $textNodes = $template.GetElementsByTagName('text'); $textNodes.Item(0).AppendChild($template.CreateTextNode($args[0])) > $null; $textNodes.Item(1).AppendChild($template.CreateTextNode($args[1])) > $null; $audio = $template.CreateElement('audio'); $audio.SetAttribute('silent', 'true'); $template.DocumentElement.AppendChild($audio) > $null; $toast = [Windows.UI.Notifications.ToastNotification]::new($template); [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('halpradio').Show($toast);`
205+
_ = runner(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", psScript, title, message)
179206
}
180207
}

pkg/desktop/notify_test.go

Lines changed: 33 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -179,28 +179,32 @@ func TestDispatchOSNotification_Darwin_SilentWithoutSound(t *testing.T) {
179179
title string
180180
message string
181181
wantCommand string
182-
wantScript string
182+
wantTitle string
183+
wantMessage string
183184
}{
184185
{
185186
name: "standard song change",
186187
title: "📻 halpradio — SomaFM Secret Agent",
187188
message: "🎶 James Bond Theme",
188189
wantCommand: "osascript",
189-
wantScript: `display notification "🎶 James Bond Theme" with title "📻 halpradio — SomaFM Secret Agent"`,
190+
wantTitle: "📻 halpradio — SomaFM Secret Agent",
191+
wantMessage: "🎶 James Bond Theme",
190192
},
191193
{
192194
name: "song with quotes and special characters",
193195
title: `📻 halpradio — "Rock" 101`,
194196
message: `🎶 Guns N' Roses - "Welcome to the Jungle"`,
195197
wantCommand: "osascript",
196-
wantScript: `display notification "🎶 Guns N' Roses - \"Welcome to the Jungle\"" with title "📻 halpradio — \"Rock\" 101"`,
198+
wantTitle: `📻 halpradio — "Rock" 101`,
199+
wantMessage: `🎶 Guns N' Roses - "Welcome to the Jungle"`,
197200
},
198201
{
199202
name: "generic alert",
200203
title: "halpradio",
201204
message: "Playback paused",
202205
wantCommand: "osascript",
203-
wantScript: `display notification "Playback paused" with title "halpradio"`,
206+
wantTitle: "halpradio",
207+
wantMessage: "Playback paused",
204208
},
205209
}
206210

@@ -221,21 +225,19 @@ func TestDispatchOSNotification_Darwin_SilentWithoutSound(t *testing.T) {
221225
t.Errorf("expected command %q, got %q", tt.wantCommand, calledName)
222226
}
223227

224-
if len(calledArgs) != 2 || calledArgs[0] != "-e" {
225-
t.Fatalf("expected args [-e, script], got %v", calledArgs)
228+
// Expected: [-e "on run argv" -e "display notification (item 2 of argv) with title (item 1 of argv)" -e "end run" <title> <message>]
229+
if len(calledArgs) != 8 {
230+
t.Fatalf("expected 8 args for darwin safe invocation, got %d (%v)", len(calledArgs), calledArgs)
226231
}
227232

228-
actualScript := calledArgs[1]
229-
if actualScript != tt.wantScript {
230-
t.Errorf("script mismatch:\n got: %s\n want: %s", actualScript, tt.wantScript)
233+
if calledArgs[0] != "-e" || calledArgs[1] != "on run argv" {
234+
t.Errorf("expected on run argv header, got %v", calledArgs[:2])
231235
}
232-
233-
// Explicitly assert that no sound parameter or chime sound is present
234-
if strings.Contains(actualScript, "sound name") {
235-
t.Errorf("macOS notification script must NOT contain 'sound name', got: %s", actualScript)
236+
if calledArgs[6] != tt.wantTitle {
237+
t.Errorf("title arg mismatch: got %q, want %q", calledArgs[6], tt.wantTitle)
236238
}
237-
if strings.Contains(strings.ToLower(actualScript), "glass") {
238-
t.Errorf("macOS notification script must NOT contain 'Glass' chime, got: %s", actualScript)
239+
if calledArgs[7] != tt.wantMessage {
240+
t.Errorf("message arg mismatch: got %q, want %q", calledArgs[7], tt.wantMessage)
239241
}
240242
})
241243
}
@@ -318,11 +320,8 @@ func TestDispatchOSNotification_Platforms(t *testing.T) {
318320
os: "darwin",
319321
wantCommand: "osascript",
320322
checkArgs: func(t *testing.T, args []string) {
321-
if len(args) != 2 || args[0] != "-e" {
322-
t.Errorf("darwin expected [-e <script>], got %v", args)
323-
}
324-
if strings.Contains(args[1], "sound name") {
325-
t.Errorf("darwin script should not have sound name, got %s", args[1])
323+
if len(args) != 8 || args[0] != "-e" || args[1] != "on run argv" {
324+
t.Errorf("darwin expected safe argv invocation, got %v", args)
326325
}
327326
},
328327
},
@@ -346,8 +345,8 @@ func TestDispatchOSNotification_Platforms(t *testing.T) {
346345
os: "windows",
347346
wantCommand: "powershell",
348347
checkArgs: func(t *testing.T, args []string) {
349-
if len(args) < 4 || args[0] != "-NoProfile" || args[1] != "-NonInteractive" || args[2] != "-Command" {
350-
t.Errorf("windows expected powershell flags, got %v", args)
348+
if len(args) < 6 || args[0] != "-NoProfile" || args[1] != "-NonInteractive" || args[2] != "-Command" {
349+
t.Errorf("windows expected powershell flags and args, got %v", args)
351350
}
352351
if !strings.Contains(args[3], "SetAttribute('silent', 'true')") {
353352
t.Errorf("windows expected silent audio attribute, got %s", args[3])
@@ -415,19 +414,12 @@ func TestDesktopNotifierDarwinEndToEnd(t *testing.T) {
415414
if calledCommand != "osascript" {
416415
t.Errorf("expected osascript, got %s", calledCommand)
417416
}
418-
if len(calledArgs) != 2 || calledArgs[0] != "-e" {
419-
t.Fatalf("expected [-e, script], got %v", calledArgs)
417+
if len(calledArgs) != 8 || calledArgs[0] != "-e" {
418+
t.Fatalf("expected 8 args for darwin safe invocation, got %v", calledArgs)
420419
}
421420

422-
script := calledArgs[1]
423-
if !strings.HasPrefix(script, `display notification`) {
424-
t.Errorf("expected script to start with display notification, got %s", script)
425-
}
426-
if strings.Contains(script, "sound name") {
427-
t.Errorf("expected silent notification without sound name, got %s", script)
428-
}
429-
if !strings.Contains(script, "Nightwave Plaza") {
430-
t.Errorf("expected station name in script, got %s", script)
421+
if !strings.Contains(calledArgs[6], "Nightwave Plaza") {
422+
t.Errorf("expected station name in title arg, got %s", calledArgs[6])
431423
}
432424
}
433425

@@ -495,8 +487,8 @@ func TestDesktopNotifierWindowsEndToEnd(t *testing.T) {
495487
if calledCommand != "powershell" {
496488
t.Errorf("expected powershell, got %s", calledCommand)
497489
}
498-
if len(calledArgs) < 4 {
499-
t.Fatalf("expected powershell args, got %v", calledArgs)
490+
if len(calledArgs) < 6 {
491+
t.Fatalf("expected at least 6 powershell args, got %v", calledArgs)
500492
}
501493
if !strings.Contains(calledArgs[3], "SetAttribute('silent', 'true')") {
502494
t.Errorf("expected silent audio attribute in windows command, got %s", calledArgs[3])
@@ -524,15 +516,14 @@ func TestDesktopNotifierGenericNotifyDarwin(t *testing.T) {
524516
mu.Lock()
525517
defer mu.Unlock()
526518

527-
if len(calledArgs) != 2 {
528-
t.Fatalf("expected 2 args, got %v", calledArgs)
519+
if len(calledArgs) != 8 {
520+
t.Fatalf("expected 8 args, got %v", calledArgs)
529521
}
530-
if strings.Contains(calledArgs[1], "sound name") {
531-
t.Errorf("expected no sound name, got %s", calledArgs[1])
522+
if calledArgs[6] != "Focus Time" {
523+
t.Errorf("expected title 'Focus Time', got %q", calledArgs[6])
532524
}
533-
expectedScript := `display notification "Session ended" with title "Focus Time"`
534-
if calledArgs[1] != expectedScript {
535-
t.Errorf("expected %q, got %q", expectedScript, calledArgs[1])
525+
if calledArgs[7] != "Session ended" {
526+
t.Errorf("expected message 'Session ended', got %q", calledArgs[7])
536527
}
537528
}
538529

pkg/plugin/manager.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"os"
88
"path/filepath"
9+
"strings"
910
"sync"
1011
"time"
1112

@@ -116,7 +117,17 @@ func (m *Manager) Init() error {
116117
continue
117118
}
118119

120+
if err := manifest.Validate(); err != nil {
121+
m.log(3, fmt.Sprintf("Plugin manifest validation failed for %s: %v", pluginID, err))
122+
continue
123+
}
124+
119125
wasmPath := filepath.Join(pluginDirPath, manifest.WasmFile)
126+
rel, err := filepath.Rel(pluginDirPath, wasmPath)
127+
if err != nil || strings.HasPrefix(rel, "..") {
128+
m.log(3, fmt.Sprintf("Plugin %s has invalid wasm path: %s", pluginID, wasmPath))
129+
continue
130+
}
120131
_, wasmErr := os.Stat(wasmPath)
121132

122133
state, hasState := savedStates[manifest.ID]
@@ -337,8 +348,15 @@ func (m *Manager) InstallFromRegistry(ctx context.Context, reg PluginOrRegistry)
337348
if err != nil {
338349
return fmt.Errorf("failed loading installed manifest: %w", err)
339350
}
351+
if err := manifest.Validate(); err != nil {
352+
return fmt.Errorf("installed plugin manifest validation failed: %w", err)
353+
}
340354

341355
wasmPath := filepath.Join(pluginDirPath, manifest.WasmFile)
356+
rel, err := filepath.Rel(pluginDirPath, wasmPath)
357+
if err != nil || strings.HasPrefix(rel, "..") {
358+
return fmt.Errorf("invalid wasm path in plugin %s", regPlugin.ID)
359+
}
342360

343361
entry := &pluginEntry{
344362
manifest: *manifest,

0 commit comments

Comments
 (0)