detached 440efd5

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
106106 },
107107 }
108108 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)
110113 default:
111114 return nil, fmt.Errorf("unknown engine type: %q", engineType)
112115 }
@@ -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(){
392392 // reasserts browser focus when an editable element gains DOM focus.
393393 const clipboardSelectionFetchBridgeJS = `(function(){
394394 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- };
404395
405396 function encodeBody(body) {
406397 return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';
@@ -752,7 +743,16 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
752743
753744 // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR
754745 // 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+ }
756756
757757 // Video playback diagnostic — logs video element state changes.
758758 // 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 OSR744 // 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)
5757 require.Contains(t, script, "sendToClipboard(normalized)")
5858 require.Contains(t, script, "mirrorClipboardItems(items)")
5959 require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")
60+ require.NotContains(t, script, "__dumberClipboardBridge")
61+ require.NotContains(t, script, "setBridgeNonce")
6062 require.Contains(t, script, "test-bridge-nonce")
6163 }
@@ -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
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "os"
78 "path/filepath"
@@ -17,7 +18,6 @@ import (
1718 "github.com/bnema/dumber/assets"
1819 "github.com/bnema/dumber/internal/application/port"
1920 "github.com/bnema/dumber/internal/infrastructure/config"
20- "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
2121 transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder"
2222 "github.com/bnema/dumber/internal/logging"
2323 )
@@ -25,11 +25,17 @@ import (
2525 const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"
2626 const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH"
2727
28+// RuntimePaths contains the concrete filesystem paths the CEF adapter needs.
29+type RuntimePaths struct {
30+ StateRoot string
31+ LogFile string
32+}
33+
2834 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
2935 func NewEngine(
3036 ctx context.Context,
3137 opts port.EngineOptions,
32- profile runtimeprofile.Profile,
38+ paths RuntimePaths,
3339 cfg RuntimeConfig,
3440 transcodingCfg TranscodingRuntimeConfig,
3541 audioFactory port.AudioOutputFactory,
@@ -37,11 +43,11 @@ func NewEngine(
3743 ) (*Engine, error) {
3844 logger := logging.FromContext(ctx)
3945 deps.MediaClassifier = deps.MediaClassifier.normalize()
40- stateRoot := resolvedStateRoot(profile, opts)
46+ stateRoot := resolvedStateRoot(paths.StateRoot, opts)
4147 cleanStaleSingletonLocks(logger, stateRoot)
4248 windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate()
4349
44- settings, err := prepareCEFSettings(opts, profile, cfg, logger)
50+ settings, err := prepareCEFSettings(opts, paths, cfg, logger)
4551 if err != nil {
4652 return nil, err
4753 }
@@ -91,7 +97,7 @@ func NewEngine(
9197 )
9298 }
9399
94-func resolvedStateRoot(profile runtimeprofile.Profile, opts port.EngineOptions) string {
100+func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string {
95101 if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {
96102 return root
97103 }
@@ -101,13 +107,13 @@ func resolvedStateRoot(profile runtimeprofile.Profile, opts port.EngineOptions)
101107 if opts.DataDir != "" {
102108 return opts.DataDir
103109 }
104- return profile.CEFUserDataDir()
110+ return defaultStateRoot
105111 }
106112
107113 // prepareCEFSettings builds purecef.Settings from the engine config.
108114 func prepareCEFSettings(
109115 opts port.EngineOptions,
110- profile runtimeprofile.Profile,
116+ paths RuntimePaths,
111117 cfg RuntimeConfig,
112118 logger *zerolog.Logger,
113119 ) (purecef.Settings, error) {
@@ -126,16 +132,16 @@ func prepareCEFSettings(
126132 settings := purecef.DefaultSettings()
127133 settings.MultiThreadedMessageLoop = true
128134 settings.ExternalMessagePump = false
129- settings.RootCachePath = resolvedStateRoot(profile, opts)
135+ settings.RootCachePath = resolvedStateRoot(paths.StateRoot, opts)
130136 if cfg.CEFDir != "" {
131137 settings.CEFDir = cfg.CEFDir
132138 }
133- if runtimeLogFile, err := prepareCEFLogFile(profile, cfg.LogFile); err != nil {
139+ if runtimeLogFile, err := prepareCEFLogFile(paths.LogFile, cfg.LogFile); err != nil {
134140 logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file")
135141 } else {
136142 settings.LogFile = runtimeLogFile
137143 }
138- if bootstrapLogFile, err := prepareCEFInitTraceFile(profile, cfg.LogFile); err != nil {
144+ if bootstrapLogFile, err := prepareCEFInitTraceFile(paths.LogFile, cfg.LogFile); err != nil {
139145 logger.Warn().Err(err).Msg("cef: failed to prepare init trace file")
140146 } else if bootstrapLogFile != "" {
141147 settings.InitTraceFile = bootstrapLogFile
@@ -427,29 +433,40 @@ const (
427433 filePerm = 0o600
428434 )
429435
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
436440 }
437441 if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil {
438442 return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)
439443 }
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+ }
440459 return runtimeLogFile, nil
441460 }
442461
443-func prepareCEFInitTraceFile(profile runtimeprofile.Profile, logFile string) (string, error) {
462+func prepareCEFInitTraceFile(defaultLogFile, logFile string) (string, error) {
444463 if !puregoCEFInitTraceEnabled() {
445464 return "", nil
446465 }
447466
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
453470 }
454471 bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log"
455472 if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil {
@@ -461,6 +478,16 @@ func prepareCEFInitTraceFile(profile runtimeprofile.Profile, logFile string) (st
461478 return bootstrapLogFile, nil
462479 }
463480
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+
464491 func puregoCEFInitTraceEnabled() bool {
465492 return envBoolEnabled(puregoCEFInitTraceEnvVar)
466493 }
@@ -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, err52 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 root102 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.DataDir108 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 = true133 settings.MultiThreadedMessageLoop = true
128 settings.ExternalMessagePump = false134 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.CEFDir137 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 = runtimeLogFile142 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 = bootstrapLogFile147 settings.InitTraceFile = bootstrapLogFile
@@ -427,29 +433,40 @@ const (
427 filePerm = 0o600433 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 := logFile437+ 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, nil459 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 "", nil464 return "", nil
446 }465 }
447 466
448- runtimeLogFile := logFile467+ 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, nil478 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 (
1414
1515 func TestResolvedStateRoot_PrefersCacheDir(t *testing.T) {
1616 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"})
1818 require.Equal(t, "/tmp/cache", root)
1919 }
2020
@@ -22,23 +22,23 @@ func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) {
2222 profile := testCEFDevProfile(t)
2323 t.Setenv(CEFRootCachePathEnvVar, "/tmp/override")
2424
25- root := resolvedStateRoot(profile, port.EngineOptions{})
25+ root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{})
2626 require.Equal(t, "/tmp/override", root)
2727
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"})
2929 require.Equal(t, "/tmp/override", root)
3030 }
3131
3232 func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) {
3333 profile := testCEFProdProfile(t)
34- root := resolvedStateRoot(profile, port.EngineOptions{})
34+ root := resolvedStateRoot(profile.CEFUserDataDir(), port.EngineOptions{})
3535 require.Equal(t, profile.CEFUserDataDir(), root)
3636 }
3737
3838 func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) {
3939 logger := zerolog.Nop()
4040 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)
4242 require.NoError(t, err)
4343 require.Equal(t, profile.CEFUserDataDir(), settings.RootCachePath)
4444 require.Equal(t, profile.CEFLogFile(), settings.LogFile)
@@ -46,41 +46,62 @@ func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) {
4646
4747 func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) {
4848 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)
5051 require.ErrorIs(t, err, ErrCookiePolicyUnsupported)
5152 }
5253
5354 func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) {
5455 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)
5658 require.NoError(t, err)
5759 }
5860
5961 func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) {
6062 t.Setenv(puregoCEFInitTraceEnvVar, "")
63+ profile := testCEFDevProfile(t)
6164
62- path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), "")
65+ path, err := prepareCEFInitTraceFile(profile.CEFLogFile(), "")
6366 require.NoError(t, err)
6467 require.Empty(t, path)
6568 }
6669
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) {
6887 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))
6990
70- path, err := prepareCEFLogFile(testCEFDevProfile(t), logFile)
91+ path, err := prepareCEFLogFile("", logFile)
7192 require.NoError(t, err)
7293 require.Equal(t, logFile, path)
7394
74- info, err := os.Stat(filepath.Dir(logFile))
95+ fileInfo, err := os.Stat(logFile)
7596 require.NoError(t, err)
76- require.Equal(t, os.FileMode(dirPerm), info.Mode().Perm())
97+ require.Equal(t, os.FileMode(filePerm), fileInfo.Mode().Perm())
7798 }
7899
79100 func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) {
80101 t.Setenv(puregoCEFInitTraceEnvVar, "1")
81102 logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log")
82103
83- path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), logFile)
104+ path, err := prepareCEFInitTraceFile("", logFile)
84105 require.NoError(t, err)
85106 require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path)
86107 require.FileExists(t, path)
@@ -98,7 +119,7 @@ func TestPrepareCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T
98119 t.Setenv(puregoCEFInitTraceEnvVar, "1")
99120 profile := testCEFDevProfile(t)
100121
101- path, err := prepareCEFInitTraceFile(profile, "")
122+ path, err := prepareCEFInitTraceFile(profile.CEFLogFile(), "")
102123 require.NoError(t, err)
103124 require.Equal(t, filepath.Join(profile.EnginePaths.LogDir, "cef_runtime.bootstrap.log"), path)
104125 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 (
88 "errors"
99 "fmt"
1010 "math"
11- "strconv"
1211 "strings"
1312 "sync"
1413 "sync/atomic"
@@ -489,16 +488,21 @@ func (wv *WebView) RunJavaScript(_ context.Context, script string) {
489488 // colorScale converts a 0.0–1.0 color component to an 8-bit integer.
490489 const colorScale = 255
491490
491+var bridgeNonceRandom = rand.Read
492+
492493 func newBridgeNonce() string {
493494 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 ""
496497 }
497498 return hex.EncodeToString(buf)
498499 }
499500
500501 func (wv *WebView) rotateBridgeNonce() string {
501502 nonce := newBridgeNonce()
503+ if nonce == "" {
504+ return ""
505+ }
502506 wv.mu.Lock()
503507 wv.bridgeNonce = nonce
504508 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 = 255489 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 = nonce507 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() {
100100 return selection ? selection.toString() : '';
101101 }
102102
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+
103117 function postExplicitCopy(action, text) {
104118 if (!text) {
105119 return;
@@ -153,9 +167,12 @@ const explicitCopyScript = `(function() {
153167 if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
154168 var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard);
155169 navigator.clipboard.writeText = function(text) {
170+ var userActivated = hasUserActivation();
156171 return Promise.resolve(originalWriteText(text)).then(function(result) {
157172 // 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+ }
159176 return result;
160177 });
161178 };
@@ -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) {
3939 assert.Contains(t, script, "document.addEventListener('cut'")
4040 assert.Contains(t, script, "document.execCommand")
4141 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)")
4245 assert.Contains(t, script, "try {")
4346 assert.Contains(t, script, "finally {")
4447 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")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close