fix(review): harden bridge nonce handling
- commit
- 440efd569bb2bc09ca6b520853f54a474217c4df
- author
- brice · · 2026-04-18 18:42 UTC
- committer
- brice · · 2026-04-19 07:06 UTC
- parent
- 6345e20
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +185 -51
M internal/bootstrap/engine.go +4 -1
| @@ -106,7 +106,10 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 106 | 106 | }, | |
| 107 | 107 | } | |
| 108 | 108 | audioFactory := audiofactory.NewAudioOutputFactory() | |
| 109 | - | return cef.NewEngine(input.Ctx, opts, profile, cefCfg, transcodingCfg, audioFactory, deps) | |
| 109 | + | return cef.NewEngine(input.Ctx, opts, cef.RuntimePaths{ | |
| 110 | + | StateRoot: profile.CEFUserDataDir(), | |
| 111 | + | LogFile: profile.CEFLogFile(), | |
| 112 | + | }, cefCfg, transcodingCfg, audioFactory, deps) | |
| 110 | 113 | default: | |
| 111 | 114 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | |
| 112 | 115 | } | |
| @@ -106,7 +106,10 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||||
| 106 | }, | 106 | }, | ||
| 107 | } | 107 | } | ||
| 108 | audioFactory := audiofactory.NewAudioOutputFactory() | 108 | audioFactory := audiofactory.NewAudioOutputFactory() | ||
| 109 | - | return cef.NewEngine(input.Ctx, opts, profile, cefCfg, transcodingCfg, audioFactory, deps) | 109 | + | return cef.NewEngine(input.Ctx, opts, cef.RuntimePaths{ |
| 110 | + | StateRoot: profile.CEFUserDataDir(), | |||
| 111 | + | LogFile: profile.CEFLogFile(), | |||
| 112 | + | }, cefCfg, transcodingCfg, audioFactory, deps) | |||
| 110 | default: | 113 | default: | ||
| 111 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | 114 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | ||
| 112 | } | 115 | } | ||
M internal/infrastructure/cef/content_injector.go +10 -10
| @@ -392,15 +392,6 @@ const redditDirectVideoJS = `(function(){ | |||
| 392 | 392 | // reasserts browser focus when an editable element gains DOM focus. | |
| 393 | 393 | const clipboardSelectionFetchBridgeJS = `(function(){ | |
| 394 | 394 | var bridgeNonce = '__DUMBER_BRIDGE_NONCE__'; | |
| 395 | - | if (window.__dumberClipboardBridge && typeof window.__dumberClipboardBridge.setBridgeNonce === 'function') { | |
| 396 | - | window.__dumberClipboardBridge.setBridgeNonce(bridgeNonce); | |
| 397 | - | return; | |
| 398 | - | } | |
| 399 | - | window.__dumberClipboardBridge = { | |
| 400 | - | setBridgeNonce: function(nextBridgeNonce) { | |
| 401 | - | bridgeNonce = nextBridgeNonce == null ? '' : String(nextBridgeNonce); | |
| 402 | - | } | |
| 403 | - | }; | |
| 404 | 395 | ||
| 405 | 396 | function encodeBody(body) { | |
| 406 | 397 | return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : ''; | |
| @@ -752,7 +743,16 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||
| 752 | 743 | ||
| 753 | 744 | // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR | |
| 754 | 745 | // mode while the native renderer bridge remains disabled. | |
| 755 | - | wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(wv.rotateBridgeNonce())) | |
| 746 | + | bridgeNonce := wv.rotateBridgeNonce() | |
| 747 | + | if bridgeNonce == "" { | |
| 748 | + | ctx := context.Background() | |
| 749 | + | if ci != nil && ci.engine != nil { | |
| 750 | + | ctx = ci.engine.currentContext() | |
| 751 | + | } | |
| 752 | + | logging.FromContext(ctx).Warn().Msg("cef: skipped clipboard bridge injection — nonce generation failed") | |
| 753 | + | } else { | |
| 754 | + | wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(bridgeNonce)) | |
| 755 | + | } | |
| 756 | 756 | ||
| 757 | 757 | // Video playback diagnostic — logs video element state changes. | |
| 758 | 758 | // Gated behind DUMBER_VIDEO_DIAG=1 environment variable. | |
| @@ -392,15 +392,6 @@ const redditDirectVideoJS = `(function(){ | |||||
| 392 | // reasserts browser focus when an editable element gains DOM focus. | 392 | // reasserts browser focus when an editable element gains DOM focus. | ||
| 393 | const clipboardSelectionFetchBridgeJS = `(function(){ | 393 | const clipboardSelectionFetchBridgeJS = `(function(){ | ||
| 394 | var bridgeNonce = '__DUMBER_BRIDGE_NONCE__'; | 394 | var bridgeNonce = '__DUMBER_BRIDGE_NONCE__'; | ||
| 395 | - | if (window.__dumberClipboardBridge && typeof window.__dumberClipboardBridge.setBridgeNonce === 'function') { | |||
| 396 | - | window.__dumberClipboardBridge.setBridgeNonce(bridgeNonce); | |||
| 397 | - | return; | |||
| 398 | - | } | |||
| 399 | - | window.__dumberClipboardBridge = { | |||
| 400 | - | setBridgeNonce: function(nextBridgeNonce) { | |||
| 401 | - | bridgeNonce = nextBridgeNonce == null ? '' : String(nextBridgeNonce); | |||
| 402 | - | } | |||
| 403 | - | }; | |||
| 404 | 395 | ||||
| 405 | function encodeBody(body) { | 396 | function encodeBody(body) { | ||
| 406 | return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : ''; | 397 | return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : ''; | ||
| @@ -752,7 +743,16 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||||
| 752 | 743 | ||||
| 753 | // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR | 744 | // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR | ||
| 754 | // mode while the native renderer bridge remains disabled. | 745 | // mode while the native renderer bridge remains disabled. | ||
| 755 | - | wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(wv.rotateBridgeNonce())) | 746 | + | bridgeNonce := wv.rotateBridgeNonce() |
| 747 | + | if bridgeNonce == "" { | |||
| 748 | + | ctx := context.Background() | |||
| 749 | + | if ci != nil && ci.engine != nil { | |||
| 750 | + | ctx = ci.engine.currentContext() | |||
| 751 | + | } | |||
| 752 | + | logging.FromContext(ctx).Warn().Msg("cef: skipped clipboard bridge injection — nonce generation failed") | |||
| 753 | + | } else { | |||
| 754 | + | wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(bridgeNonce)) | |||
| 755 | + | } | |||
| 756 | 756 | ||||
| 757 | // Video playback diagnostic — logs video element state changes. | 757 | // Video playback diagnostic — logs video element state changes. | ||
| 758 | // Gated behind DUMBER_VIDEO_DIAG=1 environment variable. | 758 | // Gated behind DUMBER_VIDEO_DIAG=1 environment variable. | ||
M internal/infrastructure/cef/content_injector_test.go +2 -0
| @@ -57,5 +57,7 @@ func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T) | |||
| 57 | 57 | require.Contains(t, script, "sendToClipboard(normalized)") | |
| 58 | 58 | require.Contains(t, script, "mirrorClipboardItems(items)") | |
| 59 | 59 | require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__") | |
| 60 | + | require.NotContains(t, script, "__dumberClipboardBridge") | |
| 61 | + | require.NotContains(t, script, "setBridgeNonce") | |
| 60 | 62 | require.Contains(t, script, "test-bridge-nonce") | |
| 61 | 63 | } | |
| @@ -57,5 +57,7 @@ func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T) | |||||
| 57 | require.Contains(t, script, "sendToClipboard(normalized)") | 57 | require.Contains(t, script, "sendToClipboard(normalized)") | ||
| 58 | require.Contains(t, script, "mirrorClipboardItems(items)") | 58 | require.Contains(t, script, "mirrorClipboardItems(items)") | ||
| 59 | require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__") | 59 | require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__") | ||
| 60 | + | require.NotContains(t, script, "__dumberClipboardBridge") | |||
| 61 | + | require.NotContains(t, script, "setBridgeNonce") | |||
| 60 | require.Contains(t, script, "test-bridge-nonce") | 62 | require.Contains(t, script, "test-bridge-nonce") | ||
| 61 | } | 63 | } | ||
M internal/infrastructure/cef/engine_init.go +49 -22
| @@ -2,6 +2,7 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "os" | |
| 7 | 8 | "path/filepath" | |
| @@ -17,7 +18,6 @@ import ( | |||
| 17 | 18 | "github.com/bnema/dumber/assets" | |
| 18 | 19 | "github.com/bnema/dumber/internal/application/port" | |
| 19 | 20 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 20 | - | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 21 | 21 | transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder" | |
| 22 | 22 | "github.com/bnema/dumber/internal/logging" | |
| 23 | 23 | ) | |
| @@ -25,11 +25,17 @@ import ( | |||
| 25 | 25 | const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE" | |
| 26 | 26 | const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH" | |
| 27 | 27 | ||
| 28 | + | // RuntimePaths contains the concrete filesystem paths the CEF adapter needs. | |
| 29 | + | type RuntimePaths struct { | |
| 30 | + | StateRoot string | |
| 31 | + | LogFile string | |
| 32 | + | } | |
| 33 | + | ||
| 28 | 34 | // NewEngine initializes the CEF runtime and returns a ready-to-use Engine. | |
| 29 | 35 | func NewEngine( | |
| 30 | 36 | ctx context.Context, | |
| 31 | 37 | opts port.EngineOptions, | |
| 32 | - | profile runtimeprofile.Profile, | |
| 38 | + | paths RuntimePaths, | |
| 33 | 39 | cfg RuntimeConfig, | |
| 34 | 40 | transcodingCfg TranscodingRuntimeConfig, | |
| 35 | 41 | audioFactory port.AudioOutputFactory, | |
| @@ -37,11 +43,11 @@ func NewEngine( | |||
| 37 | 43 | ) (*Engine, error) { | |
| 38 | 44 | logger := logging.FromContext(ctx) | |
| 39 | 45 | deps.MediaClassifier = deps.MediaClassifier.normalize() | |
| 40 | - | stateRoot := resolvedStateRoot(profile, opts) | |
| 46 | + | stateRoot := resolvedStateRoot(paths.StateRoot, opts) | |
| 41 | 47 | cleanStaleSingletonLocks(logger, stateRoot) | |
| 42 | 48 | windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate() | |
| 43 | 49 | ||
| 44 | - | settings, err := prepareCEFSettings(opts, profile, cfg, logger) | |
| 50 | + | settings, err := prepareCEFSettings(opts, paths, cfg, logger) | |
| 45 | 51 | if err != nil { | |
| 46 | 52 | return nil, err | |
| 47 | 53 | } | |
| @@ -91,7 +97,7 @@ func NewEngine( | |||
| 91 | 97 | ) | |
| 92 | 98 | } | |
| 93 | 99 | ||
| 94 | - | func resolvedStateRoot(profile runtimeprofile.Profile, opts port.EngineOptions) string { | |
| 100 | + | func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string { | |
| 95 | 101 | if root := os.Getenv(CEFRootCachePathEnvVar); root != "" { | |
| 96 | 102 | return root | |
| 97 | 103 | } | |
| @@ -101,13 +107,13 @@ func resolvedStateRoot(profile runtimeprofile.Profile, opts port.EngineOptions) | |||
| 101 | 107 | if opts.DataDir != "" { | |
| 102 | 108 | return opts.DataDir | |
| 103 | 109 | } | |
| 104 | - | return profile.CEFUserDataDir() | |
| 110 | + | return defaultStateRoot | |
| 105 | 111 | } | |
| 106 | 112 | ||
| 107 | 113 | // prepareCEFSettings builds purecef.Settings from the engine config. | |
| 108 | 114 | func prepareCEFSettings( | |
| 109 | 115 | opts port.EngineOptions, | |
| 110 | - | profile runtimeprofile.Profile, | |
| 116 | + | paths RuntimePaths, | |
| 111 | 117 | cfg RuntimeConfig, | |
| 112 | 118 | logger *zerolog.Logger, | |
| 113 | 119 | ) (purecef.Settings, error) { | |
| @@ -126,16 +132,16 @@ func prepareCEFSettings( | |||
| 126 | 132 | settings := purecef.DefaultSettings() | |
| 127 | 133 | settings.MultiThreadedMessageLoop = true | |
| 128 | 134 | settings.ExternalMessagePump = false | |
| 129 | - | settings.RootCachePath = resolvedStateRoot(profile, opts) | |
| 135 | + | settings.RootCachePath = resolvedStateRoot(paths.StateRoot, opts) | |
| 130 | 136 | if cfg.CEFDir != "" { | |
| 131 | 137 | settings.CEFDir = cfg.CEFDir | |
| 132 | 138 | } | |
| 133 | - | if runtimeLogFile, err := prepareCEFLogFile(profile, cfg.LogFile); err != nil { | |
| 139 | + | if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil { | |
| 134 | 140 | logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file") | |
| 135 | 141 | } else { | |
| 136 | 142 | settings.LogFile = runtimeLogFile | |
| 137 | 143 | } | |
| 138 | - | if bootstrapLogFile, err := prepareCEFInitTraceFile(profile, cfg.LogFile); err != nil { | |
| 144 | + | if bootstrapLogFile, err := prepareCEFInitTraceFile(paths.LogFile, cfg.LogFile); err != nil { | |
| 139 | 145 | logger.Warn().Err(err).Msg("cef: failed to prepare init trace file") | |
| 140 | 146 | } else if bootstrapLogFile != "" { | |
| 141 | 147 | settings.InitTraceFile = bootstrapLogFile | |
| @@ -427,29 +433,40 @@ const ( | |||
| 427 | 433 | filePerm = 0o600 | |
| 428 | 434 | ) | |
| 429 | 435 | ||
| 430 | - | func prepareCEFLogFile(profile runtimeprofile.Profile, logFile string) (string, error) { | |
| 431 | - | runtimeLogFile := logFile | |
| 432 | - | if runtimeLogFile != "" { | |
| 433 | - | runtimeLogFile = filepath.Clean(runtimeLogFile) | |
| 434 | - | } else { | |
| 435 | - | runtimeLogFile = profile.CEFLogFile() | |
| 436 | + | func prepareCEFLogFile(defaultLogFile, logFile string) (string, error) { | |
| 437 | + | runtimeLogFile := resolveLogFile(defaultLogFile, logFile) | |
| 438 | + | if runtimeLogFile == "" { | |
| 439 | + | return "", nil | |
| 436 | 440 | } | |
| 437 | 441 | if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil { | |
| 438 | 442 | return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err) | |
| 439 | 443 | } | |
| 444 | + | if _, err := os.Stat(runtimeLogFile); err != nil { | |
| 445 | + | if !errors.Is(err, os.ErrNotExist) { | |
| 446 | + | return "", fmt.Errorf("stat %s: %w", runtimeLogFile, err) | |
| 447 | + | } | |
| 448 | + | file, createErr := os.OpenFile(runtimeLogFile, os.O_CREATE|os.O_WRONLY, filePerm) | |
| 449 | + | if createErr != nil { | |
| 450 | + | return "", fmt.Errorf("create %s: %w", runtimeLogFile, createErr) | |
| 451 | + | } | |
| 452 | + | if closeErr := file.Close(); closeErr != nil { | |
| 453 | + | return "", fmt.Errorf("close %s: %w", runtimeLogFile, closeErr) | |
| 454 | + | } | |
| 455 | + | } | |
| 456 | + | if err := os.Chmod(runtimeLogFile, filePerm); err != nil { | |
| 457 | + | return "", fmt.Errorf("chmod %s: %w", runtimeLogFile, err) | |
| 458 | + | } | |
| 440 | 459 | return runtimeLogFile, nil | |
| 441 | 460 | } | |
| 442 | 461 | ||
| 443 | - | func prepareCEFInitTraceFile(profile runtimeprofile.Profile, logFile string) (string, error) { | |
| 462 | + | func prepareCEFInitTraceFile(defaultLogFile, logFile string) (string, error) { | |
| 444 | 463 | if !puregoCEFInitTraceEnabled() { | |
| 445 | 464 | return "", nil | |
| 446 | 465 | } | |
| 447 | 466 | ||
| 448 | - | runtimeLogFile := logFile | |
| 449 | - | if runtimeLogFile != "" { | |
| 450 | - | runtimeLogFile = filepath.Clean(runtimeLogFile) | |
| 451 | - | } else { | |
| 452 | - | runtimeLogFile = profile.CEFLogFile() | |
| 467 | + | runtimeLogFile := resolveLogFile(defaultLogFile, logFile) | |
| 468 | + | if runtimeLogFile == "" { | |
| 469 | + | return "", nil | |
| 453 | 470 | } | |
| 454 | 471 | bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log" | |
| 455 | 472 | if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil { | |
| @@ -461,6 +478,16 @@ func prepareCEFInitTraceFile(profile runtimeprofile.Profile, logFile string) (st | |||
| 461 | 478 | return bootstrapLogFile, nil | |
| 462 | 479 | } | |
| 463 | 480 | ||
| 481 | + | func resolveLogFile(defaultLogFile, logFile string) string { | |
| 482 | + | if strings.TrimSpace(logFile) != "" { | |
| 483 | + | return filepath.Clean(logFile) | |
| 484 | + | } | |
| 485 | + | if strings.TrimSpace(defaultLogFile) != "" { | |
| 486 | + | return filepath.Clean(defaultLogFile) | |
| 487 | + | } | |
| 488 | + | return "" | |
| 489 | + | } | |
| 490 | + | ||
| 464 | 491 | func puregoCEFInitTraceEnabled() bool { | |
| 465 | 492 | return envBoolEnabled(puregoCEFInitTraceEnvVar) | |
| 466 | 493 | } | |
| @@ -2,6 +2,7 @@ package cef | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "os" | 7 | "os" | ||
| 7 | "path/filepath" | 8 | "path/filepath" | ||
| @@ -17,7 +18,6 @@ import ( | |||||
| 17 | "github.com/bnema/dumber/assets" | 18 | "github.com/bnema/dumber/assets" | ||
| 18 | "github.com/bnema/dumber/internal/application/port" | 19 | "github.com/bnema/dumber/internal/application/port" | ||
| 19 | "github.com/bnema/dumber/internal/infrastructure/config" | 20 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 20 | - | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 21 | transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder" | 21 | transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder" | ||
| 22 | "github.com/bnema/dumber/internal/logging" | 22 | "github.com/bnema/dumber/internal/logging" | ||
| 23 | ) | 23 | ) | ||
| @@ -25,11 +25,17 @@ import ( | |||||
| 25 | const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE" | 25 | const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE" | ||
| 26 | const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH" | 26 | const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH" | ||
| 27 | 27 | ||||
| 28 | + | // RuntimePaths contains the concrete filesystem paths the CEF adapter needs. | |||
| 29 | + | type RuntimePaths struct { | |||
| 30 | + | StateRoot string | |||
| 31 | + | LogFile string | |||
| 32 | + | } | |||
| 33 | + | ||||
| 28 | // NewEngine initializes the CEF runtime and returns a ready-to-use Engine. | 34 | // NewEngine initializes the CEF runtime and returns a ready-to-use Engine. | ||
| 29 | func NewEngine( | 35 | func NewEngine( | ||
| 30 | ctx context.Context, | 36 | ctx context.Context, | ||
| 31 | opts port.EngineOptions, | 37 | opts port.EngineOptions, | ||
| 32 | - | profile runtimeprofile.Profile, | 38 | + | paths RuntimePaths, |
| 33 | cfg RuntimeConfig, | 39 | cfg RuntimeConfig, | ||
| 34 | transcodingCfg TranscodingRuntimeConfig, | 40 | transcodingCfg TranscodingRuntimeConfig, | ||
| 35 | audioFactory port.AudioOutputFactory, | 41 | audioFactory port.AudioOutputFactory, | ||
| @@ -37,11 +43,11 @@ func NewEngine( | |||||
| 37 | ) (*Engine, error) { | 43 | ) (*Engine, error) { | ||
| 38 | logger := logging.FromContext(ctx) | 44 | logger := logging.FromContext(ctx) | ||
| 39 | deps.MediaClassifier = deps.MediaClassifier.normalize() | 45 | deps.MediaClassifier = deps.MediaClassifier.normalize() | ||
| 40 | - | stateRoot := resolvedStateRoot(profile, opts) | 46 | + | stateRoot := resolvedStateRoot(paths.StateRoot, opts) |
| 41 | cleanStaleSingletonLocks(logger, stateRoot) | 47 | cleanStaleSingletonLocks(logger, stateRoot) | ||
| 42 | windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate() | 48 | windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate() | ||
| 43 | 49 | ||||
| 44 | - | settings, err := prepareCEFSettings(opts, profile, cfg, logger) | 50 | + | settings, err := prepareCEFSettings(opts, paths, cfg, logger) |
| 45 | if err != nil { | 51 | if err != nil { | ||
| 46 | return nil, err | 52 | return nil, err | ||
| 47 | } | 53 | } | ||
| @@ -91,7 +97,7 @@ func NewEngine( | |||||
| 91 | ) | 97 | ) | ||
| 92 | } | 98 | } | ||
| 93 | 99 | ||||
| 94 | - | func resolvedStateRoot(profile runtimeprofile.Profile, opts port.EngineOptions) string { | 100 | + | func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string { |
| 95 | if root := os.Getenv(CEFRootCachePathEnvVar); root != "" { | 101 | if root := os.Getenv(CEFRootCachePathEnvVar); root != "" { | ||
| 96 | return root | 102 | return root | ||
| 97 | } | 103 | } | ||
| @@ -101,13 +107,13 @@ func resolvedStateRoot(profile runtimeprofile.Profile, opts port.EngineOptions) | |||||
| 101 | if opts.DataDir != "" { | 107 | if opts.DataDir != "" { | ||
| 102 | return opts.DataDir | 108 | return opts.DataDir | ||
| 103 | } | 109 | } | ||
| 104 | - | return profile.CEFUserDataDir() | 110 | + | return defaultStateRoot |
| 105 | } | 111 | } | ||
| 106 | 112 | ||||
| 107 | // prepareCEFSettings builds purecef.Settings from the engine config. | 113 | // prepareCEFSettings builds purecef.Settings from the engine config. | ||
| 108 | func prepareCEFSettings( | 114 | func prepareCEFSettings( | ||
| 109 | opts port.EngineOptions, | 115 | opts port.EngineOptions, | ||
| 110 | - | profile runtimeprofile.Profile, | 116 | + | paths RuntimePaths, |
| 111 | cfg RuntimeConfig, | 117 | cfg RuntimeConfig, | ||
| 112 | logger *zerolog.Logger, | 118 | logger *zerolog.Logger, | ||
| 113 | ) (purecef.Settings, error) { | 119 | ) (purecef.Settings, error) { | ||
| @@ -126,16 +132,16 @@ func prepareCEFSettings( | |||||
| 126 | settings := purecef.DefaultSettings() | 132 | settings := purecef.DefaultSettings() | ||
| 127 | settings.MultiThreadedMessageLoop = true | 133 | settings.MultiThreadedMessageLoop = true | ||
| 128 | settings.ExternalMessagePump = false | 134 | settings.ExternalMessagePump = false | ||
| 129 | - | settings.RootCachePath = resolvedStateRoot(profile, opts) | 135 | + | settings.RootCachePath = resolvedStateRoot(paths.StateRoot, opts) |
| 130 | if cfg.CEFDir != "" { | 136 | if cfg.CEFDir != "" { | ||
| 131 | settings.CEFDir = cfg.CEFDir | 137 | settings.CEFDir = cfg.CEFDir | ||
| 132 | } | 138 | } | ||
| 133 | - | if runtimeLogFile, err := prepareCEFLogFile(profile, cfg.LogFile); err != nil { | 139 | + | if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil { |
| 134 | logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file") | 140 | logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file") | ||
| 135 | } else { | 141 | } else { | ||
| 136 | settings.LogFile = runtimeLogFile | 142 | settings.LogFile = runtimeLogFile | ||
| 137 | } | 143 | } | ||
| 138 | - | if bootstrapLogFile, err := prepareCEFInitTraceFile(profile, cfg.LogFile); err != nil { | 144 | + | if bootstrapLogFile, err := prepareCEFInitTraceFile(paths.LogFile, cfg.LogFile); err != nil { |
| 139 | logger.Warn().Err(err).Msg("cef: failed to prepare init trace file") | 145 | logger.Warn().Err(err).Msg("cef: failed to prepare init trace file") | ||
| 140 | } else if bootstrapLogFile != "" { | 146 | } else if bootstrapLogFile != "" { | ||
| 141 | settings.InitTraceFile = bootstrapLogFile | 147 | settings.InitTraceFile = bootstrapLogFile | ||
| @@ -427,29 +433,40 @@ const ( | |||||
| 427 | filePerm = 0o600 | 433 | filePerm = 0o600 | ||
| 428 | ) | 434 | ) | ||
| 429 | 435 | ||||
| 430 | - | func prepareCEFLogFile(profile runtimeprofile.Profile, logFile string) (string, error) { | 436 | + | func prepareCEFLogFile(defaultLogFile, logFile string) (string, error) { |
| 431 | - | runtimeLogFile := logFile | 437 | + | runtimeLogFile := resolveLogFile(defaultLogFile, logFile) |
| 432 | - | if runtimeLogFile != "" { | 438 | + | if runtimeLogFile == "" { |
| 433 | - | runtimeLogFile = filepath.Clean(runtimeLogFile) | 439 | + | return "", nil |
| 434 | - | } else { | |||
| 435 | - | runtimeLogFile = profile.CEFLogFile() | |||
| 436 | } | 440 | } | ||
| 437 | if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil { | 441 | if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil { | ||
| 438 | return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err) | 442 | return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err) | ||
| 439 | } | 443 | } | ||
| 444 | + | if _, err := os.Stat(runtimeLogFile); err != nil { | |||
| 445 | + | if !errors.Is(err, os.ErrNotExist) { | |||
| 446 | + | return "", fmt.Errorf("stat %s: %w", runtimeLogFile, err) | |||
| 447 | + | } | |||
| 448 | + | file, createErr := os.OpenFile(runtimeLogFile, os.O_CREATE|os.O_WRONLY, filePerm) | |||
| 449 | + | if createErr != nil { | |||
| 450 | + | return "", fmt.Errorf("create %s: %w", runtimeLogFile, createErr) | |||
| 451 | + | } | |||
| 452 | + | if closeErr := file.Close(); closeErr != nil { | |||
| 453 | + | return "", fmt.Errorf("close %s: %w", runtimeLogFile, closeErr) | |||
| 454 | + | } | |||
| 455 | + | } | |||
| 456 | + | if err := os.Chmod(runtimeLogFile, filePerm); err != nil { | |||
| 457 | + | return "", fmt.Errorf("chmod %s: %w", runtimeLogFile, err) | |||
| 458 | + | } | |||
| 440 | return runtimeLogFile, nil | 459 | return runtimeLogFile, nil | ||
| 441 | } | 460 | } | ||
| 442 | 461 | ||||
| 443 | - | func prepareCEFInitTraceFile(profile runtimeprofile.Profile, logFile string) (string, error) { | 462 | + | func prepareCEFInitTraceFile(defaultLogFile, logFile string) (string, error) { |
| 444 | if !puregoCEFInitTraceEnabled() { | 463 | if !puregoCEFInitTraceEnabled() { | ||
| 445 | return "", nil | 464 | return "", nil | ||
| 446 | } | 465 | } | ||
| 447 | 466 | ||||
| 448 | - | runtimeLogFile := logFile | 467 | + | runtimeLogFile := resolveLogFile(defaultLogFile, logFile) |
| 449 | - | if runtimeLogFile != "" { | 468 | + | if runtimeLogFile == "" { |
| 450 | - | runtimeLogFile = filepath.Clean(runtimeLogFile) | 469 | + | return "", nil |
| 451 | - | } else { | |||
| 452 | - | runtimeLogFile = profile.CEFLogFile() | |||
| 453 | } | 470 | } | ||
| 454 | bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log" | 471 | bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log" | ||
| 455 | if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil { | 472 | if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil { | ||
| @@ -461,6 +478,16 @@ func prepareCEFInitTraceFile(profile runtimeprofile.Profile, logFile string) (st | |||||
| 461 | return bootstrapLogFile, nil | 478 | return bootstrapLogFile, nil | ||
| 462 | } | 479 | } | ||
| 463 | 480 | ||||
| 481 | + | func resolveLogFile(defaultLogFile, logFile string) string { | |||
| 482 | + | if strings.TrimSpace(logFile) != "" { | |||
| 483 | + | return filepath.Clean(logFile) | |||
| 484 | + | } | |||
| 485 | + | if strings.TrimSpace(defaultLogFile) != "" { | |||
| 486 | + | return filepath.Clean(defaultLogFile) | |||
| 487 | + | } | |||
| 488 | + | return "" | |||
| 489 | + | } | |||
| 490 | + | ||||
| 464 | func puregoCEFInitTraceEnabled() bool { | 491 | func puregoCEFInitTraceEnabled() bool { | ||
| 465 | return envBoolEnabled(puregoCEFInitTraceEnvVar) | 492 | return envBoolEnabled(puregoCEFInitTraceEnvVar) | ||
| 466 | } | 493 | } | ||
M internal/infrastructure/cef/engine_init_test.go +35 -14
| @@ -14,7 +14,7 @@ import ( | |||
| 14 | 14 | ||
| 15 | 15 | func TestResolvedStateRoot_PrefersCacheDir(t *testing.T) { | |
| 16 | 16 | profile := testCEFDevProfile(t) | |
| 17 | - | root := resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | |
| 17 | + | root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | |
| 18 | 18 | require.Equal(t, "/tmp/cache", root) | |
| 19 | 19 | } | |
| 20 | 20 | ||
| @@ -22,23 +22,23 @@ func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) { | |||
| 22 | 22 | profile := testCEFDevProfile(t) | |
| 23 | 23 | t.Setenv(CEFRootCachePathEnvVar, "/tmp/override") | |
| 24 | 24 | ||
| 25 | - | root := resolvedStateRoot(profile, port.EngineOptions{}) | |
| 25 | + | root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{}) | |
| 26 | 26 | require.Equal(t, "/tmp/override", root) | |
| 27 | 27 | ||
| 28 | - | root = resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | |
| 28 | + | root = resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | |
| 29 | 29 | require.Equal(t, "/tmp/override", root) | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | 32 | func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) { | |
| 33 | 33 | profile := testCEFProdProfile(t) | |
| 34 | - | root := resolvedStateRoot(profile, port.EngineOptions{}) | |
| 34 | + | root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{}) | |
| 35 | 35 | require.Equal(t, profile.CEFUserDataDir(), root) | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) { | |
| 39 | 39 | logger := zerolog.Nop() | |
| 40 | 40 | profile := testCEFDevProfile(t) | |
| 41 | - | settings, err := prepareCEFSettings(port.EngineOptions{}, profile, RuntimeConfig{}, &logger) | |
| 41 | + | settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger) | |
| 42 | 42 | require.NoError(t, err) | |
| 43 | 43 | require.Equal(t, profile.CEFUserDataDir(), settings.RootCachePath) | |
| 44 | 44 | require.Equal(t, profile.CEFLogFile(), settings.LogFile) | |
| @@ -46,41 +46,62 @@ func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) { | |||
| 46 | 46 | ||
| 47 | 47 | func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) { | |
| 48 | 48 | logger := zerolog.Nop() | |
| 49 | - | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, testCEFDevProfile(t), RuntimeConfig{}, &logger) | |
| 49 | + | profile := testCEFDevProfile(t) | |
| 50 | + | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger) | |
| 50 | 51 | require.ErrorIs(t, err, ErrCookiePolicyUnsupported) | |
| 51 | 52 | } | |
| 52 | 53 | ||
| 53 | 54 | func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) { | |
| 54 | 55 | logger := zerolog.Nop() | |
| 55 | - | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, testCEFDevProfile(t), RuntimeConfig{}, &logger) | |
| 56 | + | profile := testCEFDevProfile(t) | |
| 57 | + | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger) | |
| 56 | 58 | require.NoError(t, err) | |
| 57 | 59 | } | |
| 58 | 60 | ||
| 59 | 61 | func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) { | |
| 60 | 62 | t.Setenv(puregoCEFInitTraceEnvVar, "") | |
| 63 | + | profile := testCEFDevProfile(t) | |
| 61 | 64 | ||
| 62 | - | path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), "") | |
| 65 | + | path, err := prepareCEFInitTraceFile(profile.CEFLogFile(), "") | |
| 63 | 66 | require.NoError(t, err) | |
| 64 | 67 | require.Empty(t, path) | |
| 65 | 68 | } | |
| 66 | 69 | ||
| 67 | - | func TestPrepareCEFLogFile_CreatesPrivateDirectory(t *testing.T) { | |
| 70 | + | func TestPrepareCEFLogFile_CreatesPrivateDirectoryAndFile(t *testing.T) { | |
| 71 | + | logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log") | |
| 72 | + | ||
| 73 | + | path, err := prepareCEFLogFile("", logFile) | |
| 74 | + | require.NoError(t, err) | |
| 75 | + | require.Equal(t, logFile, path) | |
| 76 | + | ||
| 77 | + | dirInfo, err := os.Stat(filepath.Dir(logFile)) | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | require.Equal(t, os.FileMode(dirPerm), dirInfo.Mode().Perm()) | |
| 80 | + | ||
| 81 | + | fileInfo, err := os.Stat(logFile) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | require.Equal(t, os.FileMode(filePerm), fileInfo.Mode().Perm()) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestPrepareCEFLogFile_TightensExistingFilePermissions(t *testing.T) { | |
| 68 | 87 | logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log") | |
| 88 | + | require.NoError(t, os.MkdirAll(filepath.Dir(logFile), dirPerm)) | |
| 89 | + | require.NoError(t, os.WriteFile(logFile, []byte("existing"), 0o644)) | |
| 69 | 90 | ||
| 70 | - | path, err := prepareCEFLogFile(testCEFDevProfile(t), logFile) | |
| 91 | + | path, err := prepareCEFLogFile("", logFile) | |
| 71 | 92 | require.NoError(t, err) | |
| 72 | 93 | require.Equal(t, logFile, path) | |
| 73 | 94 | ||
| 74 | - | info, err := os.Stat(filepath.Dir(logFile)) | |
| 95 | + | fileInfo, err := os.Stat(logFile) | |
| 75 | 96 | require.NoError(t, err) | |
| 76 | - | require.Equal(t, os.FileMode(dirPerm), info.Mode().Perm()) | |
| 97 | + | require.Equal(t, os.FileMode(filePerm), fileInfo.Mode().Perm()) | |
| 77 | 98 | } | |
| 78 | 99 | ||
| 79 | 100 | func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) { | |
| 80 | 101 | t.Setenv(puregoCEFInitTraceEnvVar, "1") | |
| 81 | 102 | logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log") | |
| 82 | 103 | ||
| 83 | - | path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), logFile) | |
| 104 | + | path, err := prepareCEFInitTraceFile("", logFile) | |
| 84 | 105 | require.NoError(t, err) | |
| 85 | 106 | require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path) | |
| 86 | 107 | require.FileExists(t, path) | |
| @@ -98,7 +119,7 @@ func TestPrepareCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T | |||
| 98 | 119 | t.Setenv(puregoCEFInitTraceEnvVar, "1") | |
| 99 | 120 | profile := testCEFDevProfile(t) | |
| 100 | 121 | ||
| 101 | - | path, err := prepareCEFInitTraceFile(profile, "") | |
| 122 | + | path, err := prepareCEFInitTraceFile(profile.CEFLogFile(), "") | |
| 102 | 123 | require.NoError(t, err) | |
| 103 | 124 | require.Equal(t, filepath.Join(profile.EnginePaths.LogDir, "cef_runtime.bootstrap.log"), path) | |
| 104 | 125 | require.FileExists(t, path) | |
| @@ -14,7 +14,7 @@ import ( | |||||
| 14 | 14 | ||||
| 15 | func TestResolvedStateRoot_PrefersCacheDir(t *testing.T) { | 15 | func TestResolvedStateRoot_PrefersCacheDir(t *testing.T) { | ||
| 16 | profile := testCEFDevProfile(t) | 16 | profile := testCEFDevProfile(t) | ||
| 17 | - | root := resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | 17 | + | root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) |
| 18 | require.Equal(t, "/tmp/cache", root) | 18 | require.Equal(t, "/tmp/cache", root) | ||
| 19 | } | 19 | } | ||
| 20 | 20 | ||||
| @@ -22,23 +22,23 @@ func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) { | |||||
| 22 | profile := testCEFDevProfile(t) | 22 | profile := testCEFDevProfile(t) | ||
| 23 | t.Setenv(CEFRootCachePathEnvVar, "/tmp/override") | 23 | t.Setenv(CEFRootCachePathEnvVar, "/tmp/override") | ||
| 24 | 24 | ||||
| 25 | - | root := resolvedStateRoot(profile, port.EngineOptions{}) | 25 | + | root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{}) |
| 26 | require.Equal(t, "/tmp/override", root) | 26 | require.Equal(t, "/tmp/override", root) | ||
| 27 | 27 | ||||
| 28 | - | root = resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | 28 | + | root = resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) |
| 29 | require.Equal(t, "/tmp/override", root) | 29 | require.Equal(t, "/tmp/override", root) | ||
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) { | 32 | func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) { | ||
| 33 | profile := testCEFProdProfile(t) | 33 | profile := testCEFProdProfile(t) | ||
| 34 | - | root := resolvedStateRoot(profile, port.EngineOptions{}) | 34 | + | root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{}) |
| 35 | require.Equal(t, profile.CEFUserDataDir(), root) | 35 | require.Equal(t, profile.CEFUserDataDir(), root) | ||
| 36 | } | 36 | } | ||
| 37 | 37 | ||||
| 38 | func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) { | 38 | func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) { | ||
| 39 | logger := zerolog.Nop() | 39 | logger := zerolog.Nop() | ||
| 40 | profile := testCEFDevProfile(t) | 40 | profile := testCEFDevProfile(t) | ||
| 41 | - | settings, err := prepareCEFSettings(port.EngineOptions{}, profile, RuntimeConfig{}, &logger) | 41 | + | settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger) |
| 42 | require.NoError(t, err) | 42 | require.NoError(t, err) | ||
| 43 | require.Equal(t, profile.CEFUserDataDir(), settings.RootCachePath) | 43 | require.Equal(t, profile.CEFUserDataDir(), settings.RootCachePath) | ||
| 44 | require.Equal(t, profile.CEFLogFile(), settings.LogFile) | 44 | require.Equal(t, profile.CEFLogFile(), settings.LogFile) | ||
| @@ -46,41 +46,62 @@ func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) { | |||||
| 46 | 46 | ||||
| 47 | func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) { | 47 | func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) { | ||
| 48 | logger := zerolog.Nop() | 48 | logger := zerolog.Nop() | ||
| 49 | - | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, testCEFDevProfile(t), RuntimeConfig{}, &logger) | 49 | + | profile := testCEFDevProfile(t) |
| 50 | + | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger) | |||
| 50 | require.ErrorIs(t, err, ErrCookiePolicyUnsupported) | 51 | require.ErrorIs(t, err, ErrCookiePolicyUnsupported) | ||
| 51 | } | 52 | } | ||
| 52 | 53 | ||||
| 53 | func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) { | 54 | func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) { | ||
| 54 | logger := zerolog.Nop() | 55 | logger := zerolog.Nop() | ||
| 55 | - | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, testCEFDevProfile(t), RuntimeConfig{}, &logger) | 56 | + | profile := testCEFDevProfile(t) |
| 57 | + | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, RuntimePaths{StateRoot: profile.CEFUserDataDir(), LogFile: profile.CEFLogFile()}, RuntimeConfig{}, &logger) | |||
| 56 | require.NoError(t, err) | 58 | require.NoError(t, err) | ||
| 57 | } | 59 | } | ||
| 58 | 60 | ||||
| 59 | func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) { | 61 | func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) { | ||
| 60 | t.Setenv(puregoCEFInitTraceEnvVar, "") | 62 | t.Setenv(puregoCEFInitTraceEnvVar, "") | ||
| 63 | + | profile := testCEFDevProfile(t) | |||
| 61 | 64 | ||||
| 62 | - | path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), "") | 65 | + | path, err := prepareCEFInitTraceFile(profile.CEFLogFile(), "") |
| 63 | require.NoError(t, err) | 66 | require.NoError(t, err) | ||
| 64 | require.Empty(t, path) | 67 | require.Empty(t, path) | ||
| 65 | } | 68 | } | ||
| 66 | 69 | ||||
| 67 | - | func TestPrepareCEFLogFile_CreatesPrivateDirectory(t *testing.T) { | 70 | + | func TestPrepareCEFLogFile_CreatesPrivateDirectoryAndFile(t *testing.T) { |
| 71 | + | logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log") | |||
| 72 | + | ||||
| 73 | + | path, err := prepareCEFLogFile("", logFile) | |||
| 74 | + | require.NoError(t, err) | |||
| 75 | + | require.Equal(t, logFile, path) | |||
| 76 | + | ||||
| 77 | + | dirInfo, err := os.Stat(filepath.Dir(logFile)) | |||
| 78 | + | require.NoError(t, err) | |||
| 79 | + | require.Equal(t, os.FileMode(dirPerm), dirInfo.Mode().Perm()) | |||
| 80 | + | ||||
| 81 | + | fileInfo, err := os.Stat(logFile) | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | require.Equal(t, os.FileMode(filePerm), fileInfo.Mode().Perm()) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestPrepareCEFLogFile_TightensExistingFilePermissions(t *testing.T) { | |||
| 68 | logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log") | 87 | logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log") | ||
| 88 | + | require.NoError(t, os.MkdirAll(filepath.Dir(logFile), dirPerm)) | |||
| 89 | + | require.NoError(t, os.WriteFile(logFile, []byte("existing"), 0o644)) | |||
| 69 | 90 | ||||
| 70 | - | path, err := prepareCEFLogFile(testCEFDevProfile(t), logFile) | 91 | + | path, err := prepareCEFLogFile("", logFile) |
| 71 | require.NoError(t, err) | 92 | require.NoError(t, err) | ||
| 72 | require.Equal(t, logFile, path) | 93 | require.Equal(t, logFile, path) | ||
| 73 | 94 | ||||
| 74 | - | info, err := os.Stat(filepath.Dir(logFile)) | 95 | + | fileInfo, err := os.Stat(logFile) |
| 75 | require.NoError(t, err) | 96 | require.NoError(t, err) | ||
| 76 | - | require.Equal(t, os.FileMode(dirPerm), info.Mode().Perm()) | 97 | + | require.Equal(t, os.FileMode(filePerm), fileInfo.Mode().Perm()) |
| 77 | } | 98 | } | ||
| 78 | 99 | ||||
| 79 | func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) { | 100 | func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) { | ||
| 80 | t.Setenv(puregoCEFInitTraceEnvVar, "1") | 101 | t.Setenv(puregoCEFInitTraceEnvVar, "1") | ||
| 81 | logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log") | 102 | logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log") | ||
| 82 | 103 | ||||
| 83 | - | path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), logFile) | 104 | + | path, err := prepareCEFInitTraceFile("", logFile) |
| 84 | require.NoError(t, err) | 105 | require.NoError(t, err) | ||
| 85 | require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path) | 106 | require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path) | ||
| 86 | require.FileExists(t, path) | 107 | require.FileExists(t, path) | ||
| @@ -98,7 +119,7 @@ func TestPrepareCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T | |||||
| 98 | t.Setenv(puregoCEFInitTraceEnvVar, "1") | 119 | t.Setenv(puregoCEFInitTraceEnvVar, "1") | ||
| 99 | profile := testCEFDevProfile(t) | 120 | profile := testCEFDevProfile(t) | ||
| 100 | 121 | ||||
| 101 | - | path, err := prepareCEFInitTraceFile(profile, "") | 122 | + | path, err := prepareCEFInitTraceFile(profile.CEFLogFile(), "") |
| 102 | require.NoError(t, err) | 123 | require.NoError(t, err) | ||
| 103 | require.Equal(t, filepath.Join(profile.EnginePaths.LogDir, "cef_runtime.bootstrap.log"), path) | 124 | require.Equal(t, filepath.Join(profile.EnginePaths.LogDir, "cef_runtime.bootstrap.log"), path) | ||
| 104 | require.FileExists(t, path) | 125 | require.FileExists(t, path) | ||
M internal/infrastructure/cef/webview.go +7 -3
| @@ -8,7 +8,6 @@ import ( | |||
| 8 | 8 | "errors" | |
| 9 | 9 | "fmt" | |
| 10 | 10 | "math" | |
| 11 | - | "strconv" | |
| 12 | 11 | "strings" | |
| 13 | 12 | "sync" | |
| 14 | 13 | "sync/atomic" | |
| @@ -489,16 +488,21 @@ func (wv *WebView) RunJavaScript(_ context.Context, script string) { | |||
| 489 | 488 | // colorScale converts a 0.0–1.0 color component to an 8-bit integer. | |
| 490 | 489 | const colorScale = 255 | |
| 491 | 490 | ||
| 491 | + | var bridgeNonceRandom = rand.Read | |
| 492 | + | ||
| 492 | 493 | func newBridgeNonce() string { | |
| 493 | 494 | buf := make([]byte, 16) | |
| 494 | - | if _, err := rand.Read(buf); err != nil { | |
| 495 | - | return strconv.FormatInt(time.Now().UnixNano(), 16) | |
| 495 | + | if _, err := bridgeNonceRandom(buf); err != nil { | |
| 496 | + | return "" | |
| 496 | 497 | } | |
| 497 | 498 | return hex.EncodeToString(buf) | |
| 498 | 499 | } | |
| 499 | 500 | ||
| 500 | 501 | func (wv *WebView) rotateBridgeNonce() string { | |
| 501 | 502 | nonce := newBridgeNonce() | |
| 503 | + | if nonce == "" { | |
| 504 | + | return "" | |
| 505 | + | } | |
| 502 | 506 | wv.mu.Lock() | |
| 503 | 507 | wv.bridgeNonce = nonce | |
| 504 | 508 | wv.mu.Unlock() | |
| @@ -8,7 +8,6 @@ import ( | |||||
| 8 | "errors" | 8 | "errors" | ||
| 9 | "fmt" | 9 | "fmt" | ||
| 10 | "math" | 10 | "math" | ||
| 11 | - | "strconv" | |||
| 12 | "strings" | 11 | "strings" | ||
| 13 | "sync" | 12 | "sync" | ||
| 14 | "sync/atomic" | 13 | "sync/atomic" | ||
| @@ -489,16 +488,21 @@ func (wv *WebView) RunJavaScript(_ context.Context, script string) { | |||||
| 489 | // colorScale converts a 0.0–1.0 color component to an 8-bit integer. | 488 | // colorScale converts a 0.0–1.0 color component to an 8-bit integer. | ||
| 490 | const colorScale = 255 | 489 | const colorScale = 255 | ||
| 491 | 490 | ||||
| 491 | + | var bridgeNonceRandom = rand.Read | |||
| 492 | + | ||||
| 492 | func newBridgeNonce() string { | 493 | func newBridgeNonce() string { | ||
| 493 | buf := make([]byte, 16) | 494 | buf := make([]byte, 16) | ||
| 494 | - | if _, err := rand.Read(buf); err != nil { | 495 | + | if _, err := bridgeNonceRandom(buf); err != nil { |
| 495 | - | return strconv.FormatInt(time.Now().UnixNano(), 16) | 496 | + | return "" |
| 496 | } | 497 | } | ||
| 497 | return hex.EncodeToString(buf) | 498 | return hex.EncodeToString(buf) | ||
| 498 | } | 499 | } | ||
| 499 | 500 | ||||
| 500 | func (wv *WebView) rotateBridgeNonce() string { | 501 | func (wv *WebView) rotateBridgeNonce() string { | ||
| 501 | nonce := newBridgeNonce() | 502 | nonce := newBridgeNonce() | ||
| 503 | + | if nonce == "" { | |||
| 504 | + | return "" | |||
| 505 | + | } | |||
| 502 | wv.mu.Lock() | 506 | wv.mu.Lock() | ||
| 503 | wv.bridgeNonce = nonce | 507 | wv.bridgeNonce = nonce | ||
| 504 | wv.mu.Unlock() | 508 | wv.mu.Unlock() | ||
A internal/infrastructure/cef/webview_bridge_nonce_test.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestNewBridgeNonce_ReturnsEmptyOnRandomFailure(t *testing.T) { | |
| 11 | + | oldBridgeNonceRandom := bridgeNonceRandom | |
| 12 | + | bridgeNonceRandom = func([]byte) (int, error) { | |
| 13 | + | return 0, errors.New("entropy unavailable") | |
| 14 | + | } | |
| 15 | + | defer func() { | |
| 16 | + | bridgeNonceRandom = oldBridgeNonceRandom | |
| 17 | + | }() | |
| 18 | + | ||
| 19 | + | require.Empty(t, newBridgeNonce()) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestWebViewRotateBridgeNonce_PreservesExistingNonceOnRandomFailure(t *testing.T) { | |
| 23 | + | oldBridgeNonceRandom := bridgeNonceRandom | |
| 24 | + | bridgeNonceRandom = func([]byte) (int, error) { | |
| 25 | + | return 0, errors.New("entropy unavailable") | |
| 26 | + | } | |
| 27 | + | defer func() { | |
| 28 | + | bridgeNonceRandom = oldBridgeNonceRandom | |
| 29 | + | }() | |
| 30 | + | ||
| 31 | + | wv := &WebView{bridgeNonce: "existing-nonce"} | |
| 32 | + | require.Empty(t, wv.rotateBridgeNonce()) | |
| 33 | + | ||
| 34 | + | wv.mu.RLock() | |
| 35 | + | defer wv.mu.RUnlock() | |
| 36 | + | require.Equal(t, "existing-nonce", wv.bridgeNonce) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestWebViewRotateBridgeNonce_ReplacesNonceOnSuccess(t *testing.T) { | |
| 40 | + | oldBridgeNonceRandom := bridgeNonceRandom | |
| 41 | + | bridgeNonceRandom = func(buf []byte) (int, error) { | |
| 42 | + | for i := range buf { | |
| 43 | + | buf[i] = byte(i + 1) | |
| 44 | + | } | |
| 45 | + | return len(buf), nil | |
| 46 | + | } | |
| 47 | + | defer func() { | |
| 48 | + | bridgeNonceRandom = oldBridgeNonceRandom | |
| 49 | + | }() | |
| 50 | + | ||
| 51 | + | wv := &WebView{bridgeNonce: "old-nonce"} | |
| 52 | + | require.Equal(t, "0102030405060708090a0b0c0d0e0f10", wv.rotateBridgeNonce()) | |
| 53 | + | ||
| 54 | + | wv.mu.RLock() | |
| 55 | + | defer wv.mu.RUnlock() | |
| 56 | + | require.Equal(t, "0102030405060708090a0b0c0d0e0f10", wv.bridgeNonce) | |
| 57 | + | } | |
| @@ -0,0 +1,57 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestNewBridgeNonce_ReturnsEmptyOnRandomFailure(t *testing.T) { | |||
| 11 | + | oldBridgeNonceRandom := bridgeNonceRandom | |||
| 12 | + | bridgeNonceRandom = func([]byte) (int, error) { | |||
| 13 | + | return 0, errors.New("entropy unavailable") | |||
| 14 | + | } | |||
| 15 | + | defer func() { | |||
| 16 | + | bridgeNonceRandom = oldBridgeNonceRandom | |||
| 17 | + | }() | |||
| 18 | + | ||||
| 19 | + | require.Empty(t, newBridgeNonce()) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestWebViewRotateBridgeNonce_PreservesExistingNonceOnRandomFailure(t *testing.T) { | |||
| 23 | + | oldBridgeNonceRandom := bridgeNonceRandom | |||
| 24 | + | bridgeNonceRandom = func([]byte) (int, error) { | |||
| 25 | + | return 0, errors.New("entropy unavailable") | |||
| 26 | + | } | |||
| 27 | + | defer func() { | |||
| 28 | + | bridgeNonceRandom = oldBridgeNonceRandom | |||
| 29 | + | }() | |||
| 30 | + | ||||
| 31 | + | wv := &WebView{bridgeNonce: "existing-nonce"} | |||
| 32 | + | require.Empty(t, wv.rotateBridgeNonce()) | |||
| 33 | + | ||||
| 34 | + | wv.mu.RLock() | |||
| 35 | + | defer wv.mu.RUnlock() | |||
| 36 | + | require.Equal(t, "existing-nonce", wv.bridgeNonce) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestWebViewRotateBridgeNonce_ReplacesNonceOnSuccess(t *testing.T) { | |||
| 40 | + | oldBridgeNonceRandom := bridgeNonceRandom | |||
| 41 | + | bridgeNonceRandom = func(buf []byte) (int, error) { | |||
| 42 | + | for i := range buf { | |||
| 43 | + | buf[i] = byte(i + 1) | |||
| 44 | + | } | |||
| 45 | + | return len(buf), nil | |||
| 46 | + | } | |||
| 47 | + | defer func() { | |||
| 48 | + | bridgeNonceRandom = oldBridgeNonceRandom | |||
| 49 | + | }() | |||
| 50 | + | ||||
| 51 | + | wv := &WebView{bridgeNonce: "old-nonce"} | |||
| 52 | + | require.Equal(t, "0102030405060708090a0b0c0d0e0f10", wv.rotateBridgeNonce()) | |||
| 53 | + | ||||
| 54 | + | wv.mu.RLock() | |||
| 55 | + | defer wv.mu.RUnlock() | |||
| 56 | + | require.Equal(t, "0102030405060708090a0b0c0d0e0f10", wv.bridgeNonce) | |||
| 57 | + | } | |||
M internal/infrastructure/webkit/injector.go +18 -1
| @@ -100,6 +100,20 @@ const explicitCopyScript = `(function() { | |||
| 100 | 100 | return selection ? selection.toString() : ''; | |
| 101 | 101 | } | |
| 102 | 102 | ||
| 103 | + | function hasUserActivation() { | |
| 104 | + | try { | |
| 105 | + | if (navigator.userActivation && navigator.userActivation.isActive) { | |
| 106 | + | return true; | |
| 107 | + | } | |
| 108 | + | } catch (_) {} | |
| 109 | + | try { | |
| 110 | + | if (document.hasTransientActivation) { | |
| 111 | + | return true; | |
| 112 | + | } | |
| 113 | + | } catch (_) {} | |
| 114 | + | return false; | |
| 115 | + | } | |
| 116 | + | ||
| 103 | 117 | function postExplicitCopy(action, text) { | |
| 104 | 118 | if (!text) { | |
| 105 | 119 | return; | |
| @@ -153,9 +167,12 @@ const explicitCopyScript = `(function() { | |||
| 153 | 167 | if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { | |
| 154 | 168 | var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard); | |
| 155 | 169 | navigator.clipboard.writeText = function(text) { | |
| 170 | + | var userActivated = hasUserActivation(); | |
| 156 | 171 | return Promise.resolve(originalWriteText(text)).then(function(result) { | |
| 157 | 172 | // writeText only has copy semantics, so postExplicitCopy always uses "copy" here. | |
| 158 | - | postExplicitCopy('copy', typeof text === 'string' ? text : String(text || '')); | |
| 173 | + | if (userActivated) { | |
| 174 | + | postExplicitCopy('copy', typeof text === 'string' ? text : String(text || '')); | |
| 175 | + | } | |
| 159 | 176 | return result; | |
| 160 | 177 | }); | |
| 161 | 178 | }; | |
| @@ -100,6 +100,20 @@ const explicitCopyScript = `(function() { | |||||
| 100 | return selection ? selection.toString() : ''; | 100 | return selection ? selection.toString() : ''; | ||
| 101 | } | 101 | } | ||
| 102 | 102 | ||||
| 103 | + | function hasUserActivation() { | |||
| 104 | + | try { | |||
| 105 | + | if (navigator.userActivation && navigator.userActivation.isActive) { | |||
| 106 | + | return true; | |||
| 107 | + | } | |||
| 108 | + | } catch (_) {} | |||
| 109 | + | try { | |||
| 110 | + | if (document.hasTransientActivation) { | |||
| 111 | + | return true; | |||
| 112 | + | } | |||
| 113 | + | } catch (_) {} | |||
| 114 | + | return false; | |||
| 115 | + | } | |||
| 116 | + | ||||
| 103 | function postExplicitCopy(action, text) { | 117 | function postExplicitCopy(action, text) { | ||
| 104 | if (!text) { | 118 | if (!text) { | ||
| 105 | return; | 119 | return; | ||
| @@ -153,9 +167,12 @@ const explicitCopyScript = `(function() { | |||||
| 153 | if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { | 167 | if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { | ||
| 154 | var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard); | 168 | var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard); | ||
| 155 | navigator.clipboard.writeText = function(text) { | 169 | navigator.clipboard.writeText = function(text) { | ||
| 170 | + | var userActivated = hasUserActivation(); | |||
| 156 | return Promise.resolve(originalWriteText(text)).then(function(result) { | 171 | return Promise.resolve(originalWriteText(text)).then(function(result) { | ||
| 157 | // writeText only has copy semantics, so postExplicitCopy always uses "copy" here. | 172 | // writeText only has copy semantics, so postExplicitCopy always uses "copy" here. | ||
| 158 | - | postExplicitCopy('copy', typeof text === 'string' ? text : String(text || '')); | 173 | + | if (userActivated) { |
| 174 | + | postExplicitCopy('copy', typeof text === 'string' ? text : String(text || '')); | |||
| 175 | + | } | |||
| 159 | return result; | 176 | return result; | ||
| 160 | }); | 177 | }); | ||
| 161 | }; | 178 | }; | ||
M internal/infrastructure/webkit/injector_accent_test.go +3 -0
| @@ -39,6 +39,9 @@ func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) { | |||
| 39 | 39 | assert.Contains(t, script, "document.addEventListener('cut'") | |
| 40 | 40 | assert.Contains(t, script, "document.execCommand") | |
| 41 | 41 | assert.Contains(t, script, "navigator.clipboard.writeText") | |
| 42 | + | assert.Contains(t, script, "navigator.userActivation") | |
| 43 | + | assert.Contains(t, script, "var userActivated = hasUserActivation()") | |
| 44 | + | assert.Contains(t, script, "if (userActivated)") | |
| 42 | 45 | assert.Contains(t, script, "try {") | |
| 43 | 46 | assert.Contains(t, script, "finally {") | |
| 44 | 47 | assert.Contains(t, script, "pendingCommand === command") | |
| @@ -39,6 +39,9 @@ func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) { | |||||
| 39 | assert.Contains(t, script, "document.addEventListener('cut'") | 39 | assert.Contains(t, script, "document.addEventListener('cut'") | ||
| 40 | assert.Contains(t, script, "document.execCommand") | 40 | assert.Contains(t, script, "document.execCommand") | ||
| 41 | assert.Contains(t, script, "navigator.clipboard.writeText") | 41 | assert.Contains(t, script, "navigator.clipboard.writeText") | ||
| 42 | + | assert.Contains(t, script, "navigator.userActivation") | |||
| 43 | + | assert.Contains(t, script, "var userActivated = hasUserActivation()") | |||
| 44 | + | assert.Contains(t, script, "if (userActivated)") | |||
| 42 | assert.Contains(t, script, "try {") | 45 | assert.Contains(t, script, "try {") | ||
| 43 | assert.Contains(t, script, "finally {") | 46 | assert.Contains(t, script, "finally {") | ||
| 44 | assert.Contains(t, script, "pendingCommand === command") | 47 | assert.Contains(t, script, "pendingCommand === command") | ||