From c4531b344e3dd8de3bc389e21d08c1685f6d10f9 Mon Sep 17 00:00:00 2001 From: jamesread Date: Fri, 24 Jul 2026 07:12:03 +0100 Subject: [PATCH 1/2] fix(executor): sanitize unsafe chars in log filenames Co-authored-by: Cursor --- docs/modules/ROOT/pages/logs/saving.adoc | 2 + service/internal/executor/executor.go | 20 ++++- service/internal/executor/executor_test.go | 90 ++++++++++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/docs/modules/ROOT/pages/logs/saving.adoc b/docs/modules/ROOT/pages/logs/saving.adoc index d69d181..be2b751 100644 --- a/docs/modules/ROOT/pages/logs/saving.adoc +++ b/docs/modules/ROOT/pages/logs/saving.adoc @@ -29,6 +29,8 @@ actions: From the above example, you can see there There are two types of logs - **results (.yaml)** and **output (.log)** +Filenames are built from the action title, a unix timestamp, and the execution tracking ID. Characters that are unsafe in filenames (for example `/` and `\`) are replaced with `_` so titles like `Create/update Monthly Report` still write a single file. The original title is unchanged inside the results YAML. + * **Results (.yaml)** - this captures almost everything that OliveTin knows about the action and looks like this. + [source,yaml] diff --git a/service/internal/executor/executor.go b/service/internal/executor/executor.go index 6be518d..08dca61 100644 --- a/service/internal/executor/executor.go +++ b/service/internal/executor/executor.go @@ -1391,7 +1391,7 @@ func triggerLoop(req *ExecutionRequest) { } func stepSaveLog(req *ExecutionRequest) bool { - filename := fmt.Sprintf("%v.%v.%v", req.logEntry.ActionTitle, req.logEntry.DatetimeStarted.Unix(), req.logEntry.ExecutionTrackingID) + filename := fmt.Sprintf("%v.%v.%v", sanitizeLogFilename(req.logEntry.ActionTitle), req.logEntry.DatetimeStarted.Unix(), req.logEntry.ExecutionTrackingID) saveLogResults(req, filename) saveLogOutput(req, filename) @@ -1399,6 +1399,24 @@ func stepSaveLog(req *ExecutionRequest) bool { return true } +// sanitizeLogFilename replaces characters that are unsafe in filenames so action +// titles like "Create/update Report" do not create nested paths or fail to write. +func sanitizeLogFilename(title string) string { + replacer := strings.NewReplacer( + "/", "_", + "\\", "_", + ":", "_", + "*", "_", + "?", "_", + "\"", "_", + "<", "_", + ">", "_", + "|", "_", + ) + + return replacer.Replace(title) +} + func firstNonEmpty(one, two string) string { if one != "" { return one diff --git a/service/internal/executor/executor_test.go b/service/internal/executor/executor_test.go index 8b84c33..b2fed8c 100644 --- a/service/internal/executor/executor_test.go +++ b/service/internal/executor/executor_test.go @@ -780,3 +780,93 @@ func (c *executionFinishedCollector) OnExecutionFinished(entry *InternalLogEntry func (c *executionFinishedCollector) OnOutputChunk(_ []byte, _ string) {} func (c *executionFinishedCollector) OnActionMapRebuilt() {} + +func TestSanitizeLogFilename(t *testing.T) { + tests := []struct { + title string + want string + }{ + {"Echo Test", "Echo Test"}, + {"Create/update Monthly Report", "Create_update Monthly Report"}, + {`path\with\backslashes`, "path_with_backslashes"}, + {`a:b*c?d"eg|h`, "a_b_c_d_e_f_g_h"}, + } + + for _, tt := range tests { + assert.Equal(t, tt.want, sanitizeLogFilename(tt.title), "title=%q", tt.title) + } +} + +func TestStepSaveLogSanitizesSlashInTitle(t *testing.T) { + resultsDir := t.TempDir() + outputDir := t.TempDir() + started := time.Unix(1714333384, 0) + trackingID := "5e2dc9e5-b6b3-445b-bff9-c2082b0bbbb2" + title := "Create/update Monthly Report" + + req := &ExecutionRequest{ + Cfg: &config.Config{ + SaveLogs: config.SaveLogsConfig{ + ResultsDirectory: resultsDir, + OutputDirectory: outputDir, + }, + }, + Binding: &ActionBinding{ + Action: &config.Action{}, + }, + logEntry: &InternalLogEntry{ + ActionTitle: title, + DatetimeStarted: started, + ExecutionTrackingID: trackingID, + Output: "report ok", + }, + } + + assert.True(t, stepSaveLog(req)) + + expectedBase := "Create_update Monthly Report.1714333384." + trackingID + resultsPath := filepath.Join(resultsDir, expectedBase+".yaml") + outputPath := filepath.Join(outputDir, expectedBase+".log") + + assert.FileExists(t, resultsPath) + assert.FileExists(t, outputPath) + + resultsEntries, err := os.ReadDir(resultsDir) + assert.NoError(t, err) + assert.Len(t, resultsEntries, 1, "results file must be flat under resultsDirectory, not a subdirectory") + + data, err := os.ReadFile(resultsPath) + assert.NoError(t, err) + assert.Contains(t, string(data), title, "YAML content keeps the original action title") + + output, err := os.ReadFile(outputPath) + assert.NoError(t, err) + assert.Equal(t, "report ok", string(output)) +} + +func TestStepSaveLogKeepsSafeTitleFilename(t *testing.T) { + resultsDir := t.TempDir() + started := time.Unix(1714333384, 0) + trackingID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + req := &ExecutionRequest{ + Cfg: &config.Config{ + SaveLogs: config.SaveLogsConfig{ + ResultsDirectory: resultsDir, + }, + }, + Binding: &ActionBinding{ + Action: &config.Action{}, + }, + logEntry: &InternalLogEntry{ + ActionTitle: "Echo Test", + DatetimeStarted: started, + ExecutionTrackingID: trackingID, + }, + } + + assert.True(t, stepSaveLog(req)) + + expectedPath := filepath.Join(resultsDir, "Echo Test.1714333384."+trackingID+".yaml") + assert.FileExists(t, expectedPath) +} From 58b1c0080d72e5ae0151cfdbfdc23c92b82656e0 Mon Sep 17 00:00:00 2001 From: jamesread Date: Fri, 24 Jul 2026 07:12:16 +0100 Subject: [PATCH 2/2] docs(macos): note notarized release binaries Co-authored-by: Cursor --- docs/modules/ROOT/pages/install/macos.adoc | 6 +++--- docs/modules/ROOT/pages/install/macos_service.adoc | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/modules/ROOT/pages/install/macos.adoc b/docs/modules/ROOT/pages/install/macos.adoc index 6c35f66..fe67e95 100644 --- a/docs/modules/ROOT/pages/install/macos.adoc +++ b/docs/modules/ROOT/pages/install/macos.adoc @@ -34,11 +34,11 @@ tar -xzf OliveTin-darwin-arm64.tar.gz cd OliveTin-darwin-arm64 ---- -== Remove the Gatekeeper quarantine +== Gatekeeper and notarization -The binary is downloaded from the internet and is not notarized by Apple, so on first run Gatekeeper blocks it with a message like _"OliveTin can't be opened because Apple cannot check it for malicious software."_ +Current release binaries are **Developer ID signed and notarized** by Apple. After extract, you should be able to run `./OliveTin` normally. -Clear the quarantine attribute so it will run: +If Gatekeeper still blocks an older (unsigned) build, or you see a prompt that Apple cannot check the binary for malicious software, clear the quarantine attribute: [source,shell] ---- diff --git a/docs/modules/ROOT/pages/install/macos_service.adoc b/docs/modules/ROOT/pages/install/macos_service.adoc index 4b3134c..86e9157 100644 --- a/docs/modules/ROOT/pages/install/macos_service.adoc +++ b/docs/modules/ROOT/pages/install/macos_service.adoc @@ -2,7 +2,7 @@ This option installs OliveTin as a launchd service, so it runs in the background and starts automatically. This is the macOS equivalent of running OliveTin as a Linux systemd service or a xref:install/windows_service.adoc[Windows service]. If you just want to run OliveTin as a regular application, follow the xref:install/macos.adoc[macOS install] instructions instead. -Before continuing, complete the xref:install/macos.adoc[macOS install] steps (download, extract, and clear the Gatekeeper quarantine) and confirm OliveTin starts correctly by running `./OliveTin`. +Before continuing, complete the xref:install/macos.adoc[macOS install] steps (download, extract, and confirm Gatekeeper allows the binary) and confirm OliveTin starts correctly by running `./OliveTin`. == Choose LaunchAgent or LaunchDaemon