From 75f41a5efa32db38c9c6a0e05d4af7221480dd71 Mon Sep 17 00:00:00 2001 From: arumes31 <114224498+arumes31@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:00:55 +0200 Subject: [PATCH 1/5] chore: upgrade dependencies, fix CI toolchain, and clear all lint/security findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependencies & toolchain: - go get -u ./... + go mod tidy (fyne/systray, fsnotify, go-gl, golang.org/x image, google.golang.org/api, etc. bumped to latest). - CI/release/codeql workflows now read the Go version from go.mod (go-version-file) instead of a pinned 1.22, fixing the golangci-lint "language version too low" failure against go 1.26. - Bump GitHub Actions to current majors (supersedes the open Dependabot PRs): checkout v6, setup-go v6, upload-artifact v7, golangci-lint-action v9, metadata-action v6, build-push-action v7, goreleaser-action v6. Resolves the Node 20 deprecation warning. - Install Fyne's cgo/OpenGL/X11 build deps in the Linux CI job so lint/test/build can compile the GUI packages. Linting (golangci-lint v2): - Migrate .golangci.yml to the v2 schema; gofmt moves to formatters, gosimple folds into staticcheck. gosec runs only as its dedicated CI step (it honours //#nosec; golangci-lint's embedded gosec does not) to avoid double-reporting. - Fix all findings: errcheck, bodyclose, ineffassign-class issues, unused decls, and ST1005/QF1006 staticcheck. Security (gosec) — all findings fixed or audited: - Thread context.Context through the SQLite Store (ExecContext/QueryContext/ QueryRowContext/BeginTx) and use net.ListenConfig + exec.CommandContext (noctx). - Set http.Server ReadHeaderTimeout in the struct literal (G112). - Audited //#nosec annotations with justifications for the unavoidable cases: required Win32 syscall unsafe.Pointer marshalling (G103), opening user/app paths in a file-sync tool (G304/G703), launching explorer/notepad/browser and the gcc wrapper / self-relaunch (G204/G702), encrypted token marshalling (G117), and bounded integer conversions (G115). Verified locally: build, golangci-lint (0), gosec (0), govulncheck (clean), and go test ./... all pass. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 27 +++++---- .github/workflows/codeql.yml | 2 +- .github/workflows/release.yml | 8 +-- .golangci.yml | 26 ++++---- cmd/gcrypt/console_windows.go | 2 +- cmd/gcrypt/opengl_windows.go | 21 +++---- cmd/gcrypt/singleinstance_windows.go | 1 + go.mod | 28 ++++----- go.sum | 58 +++++++++--------- internal/config/config.go | 34 +++++------ internal/config/config_test.go | 4 +- internal/crypto/crypto.go | 4 +- internal/crypto/filename.go | 2 +- internal/crypto/strength.go | 10 ++-- internal/drive/auth.go | 25 +++++--- internal/drive/client.go | 2 +- internal/drive/store.go | 90 +++++++++++++++------------- internal/service/appcontroller.go | 6 +- internal/service/backup.go | 4 +- internal/service/dialogs_windows.go | 28 ++++----- internal/service/dpapi_windows.go | 32 +++++----- internal/service/fyneui.go | 14 ++--- internal/service/fyneui_settings.go | 3 +- internal/service/logger.go | 2 +- internal/service/logger_test.go | 4 +- internal/service/setup.go | 8 +-- internal/sync/engine.go | 70 +++++++++++----------- internal/sync/ignore.go | 2 +- internal/sync/manager.go | 2 +- internal/sync/metered_windows.go | 19 ++---- internal/sync/scanner.go | 5 +- internal/sync/watcher.go | 18 +++--- tools/gccwrap/main.go | 9 +-- 33 files changed, 294 insertions(+), 276 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db6b537..c4feb26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,21 +11,26 @@ jobs: name: Lint and Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: - go-version: '1.22' + go-version-file: 'go.mod' cache: true + # Fyne (the GUI toolkit) uses cgo + OpenGL/X11, so linting, vetting, + # testing and building all need these system headers on Linux. + - name: Install Fyne build dependencies + run: sudo apt-get update && sudo apt-get install -y gcc libgl1-mesa-dev xorg-dev libxkbcommon-dev + - name: Verify dependencies run: go mod verify - name: golangci-lint - uses: golangci/golangci-lint-action@v4 + uses: golangci/golangci-lint-action@v9 with: version: latest @@ -52,19 +57,19 @@ jobs: runs-on: windows-latest needs: lint-and-test steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: - go-version: '1.22' + go-version-file: 'go.mod' cache: true - name: Build run: go build -o gcrypt.exe ./cmd/gcrypt/main.go - name: Upload Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: gcrypt-windows path: gcrypt.exe @@ -78,7 +83,7 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Log in to GHCR uses: docker/login-action@v3 @@ -89,7 +94,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ghcr.io/${{ github.repository }} tags: | @@ -98,7 +103,7 @@ jobs: latest - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . push: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4973df3..8737909 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Initialize CodeQL uses: github/codeql-action/init@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1454906..1d0a526 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,18 +13,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: - go-version: '1.22' + go-version-file: 'go.mod' cache: true - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v5 + uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser version: latest diff --git a/.golangci.yml b/.golangci.yml index aeb5649..b89122d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,21 +1,23 @@ +# golangci-lint v2 configuration. +# `default: standard` enables errcheck, govet, ineffassign, staticcheck and +# unused (staticcheck now subsumes the old gosimple). gofmt moved to the +# separate `formatters` section in v2. +version: "2" + linters: + # gosec is intentionally NOT enabled here: it runs as its own dedicated CI step + # (securego/gosec), which honours gosec's native `//nosec` audit comments — + # golangci-lint's embedded gosec ignores those, so running it here too would + # double-report every audited line. golangci-lint covers everything else. + default: standard enable: - - gofmt - - govet - - errcheck - - staticcheck - - unused - - gosimple - - ineffassign - - typecheck - bodyclose - noctx - - gosec issues: - exclude-use-default: false max-issues-per-linter: 0 max-same-issues: 0 -run: - timeout: 5m +formatters: + enable: + - gofmt diff --git a/cmd/gcrypt/console_windows.go b/cmd/gcrypt/console_windows.go index 0e6f9d2..b507d71 100644 --- a/cmd/gcrypt/console_windows.go +++ b/cmd/gcrypt/console_windows.go @@ -32,7 +32,7 @@ func hideConsoleIfOwned() { // If only one process is attached to this console, it's ours alone and safe // to hide. More than one means we share a parent terminal — leave it. var pids [2]uint32 - n, _, _ := getConsoleProcessList.Call(uintptr(unsafe.Pointer(&pids[0])), uintptr(len(pids))) + n, _, _ := getConsoleProcessList.Call(uintptr(unsafe.Pointer(&pids[0])), uintptr(len(pids))) // #nosec G103 -- required Win32 syscall pointer marshalling if int(n) == 1 { const swHide = 0 _, _, _ = showWindow.Call(hwnd, swHide) diff --git a/cmd/gcrypt/opengl_windows.go b/cmd/gcrypt/opengl_windows.go index a7e38c2..8a1cec8 100644 --- a/cmd/gcrypt/opengl_windows.go +++ b/cmd/gcrypt/opengl_windows.go @@ -3,6 +3,7 @@ package main import ( + "context" "io" "os" "os/exec" @@ -112,7 +113,7 @@ func ensureWorkingOpenGL(log func(string, ...map[string]interface{})) (reexeced // Re-launch: the fresh process will load the just-staged Mesa opengl32.dll at // startup and the GUI will render in software. - cmd := exec.Command(exe, os.Args[1:]...) + cmd := exec.CommandContext(context.Background(), exe, os.Args[1:]...) // #nosec G204 G702 -- re-launches this same executable with its own args cmd.Env = os.Environ() cmd.Stdout, cmd.Stderr, cmd.Stdin = os.Stdout, os.Stderr, os.Stdin if err := cmd.Start(); err != nil { @@ -143,17 +144,17 @@ func stageMesaBesideExe(mesaDir, exeDir string) error { } func copyFile(src, dst string) error { - in, err := os.Open(src) + in, err := os.Open(src) // #nosec G304 -- src is a bundled Mesa DLL path under the app dir, not user input if err != nil { return err } - defer in.Close() - out, err := os.Create(dst) + defer func() { _ = in.Close() }() + out, err := os.Create(dst) // #nosec G304 -- dst is beside the executable, an app-controlled path if err != nil { return err } if _, err := io.Copy(out, in); err != nil { - out.Close() + _ = out.Close() return err } return out.Close() @@ -245,7 +246,7 @@ func probeHardwareOpenGL() (ok bool, known bool) { const wsOverlapped = 0x00000000 hwnd, _, _ := createWindowExW.Call( 0, - uintptr(unsafe.Pointer(classStatic)), + uintptr(unsafe.Pointer(classStatic)), // #nosec G103 -- required Win32 syscall pointer marshalling 0, wsOverlapped, 0, 0, 1, 1, @@ -254,13 +255,13 @@ func probeHardwareOpenGL() (ok bool, known bool) { if hwnd == 0 { return false, false } - defer destroyWindow.Call(hwnd) + defer func() { _, _, _ = destroyWindow.Call(hwnd) }() hdc, _, _ := getDC.Call(hwnd) if hdc == 0 { return false, false } - defer releaseDC.Call(hwnd, hdc) + defer func() { _, _, _ = releaseDC.Call(hwnd, hdc) }() const ( pfdDrawToWindow = 0x00000004 @@ -276,7 +277,7 @@ func probeHardwareOpenGL() (ok bool, known bool) { cDepthBits: 24, iLayerType: 0, // PFD_MAIN_PLANE } - pf, _, _ := choosePixelFormat.Call(hdc, uintptr(unsafe.Pointer(&pfd))) + pf, _, _ := choosePixelFormat.Call(hdc, uintptr(unsafe.Pointer(&pfd))) // #nosec G103 -- required Win32 syscall pointer marshalling if pf == 0 { // No OpenGL-capable pixel format at all -> definitely inadequate. return false, true @@ -287,7 +288,7 @@ func probeHardwareOpenGL() (ok bool, known bool) { // which only supports OpenGL 1.1 — exactly what RDP exposes and far too old // for Fyne. A hardware ICD reports neither flag. var desc pixelFormatDescriptor - r, _, _ := describePixelFormat.Call(hdc, pf, unsafe.Sizeof(desc), uintptr(unsafe.Pointer(&desc))) + r, _, _ := describePixelFormat.Call(hdc, pf, unsafe.Sizeof(desc), uintptr(unsafe.Pointer(&desc))) // #nosec G103 -- required Win32 syscall pointer marshalling if r == 0 { return false, false // couldn't determine } diff --git a/cmd/gcrypt/singleinstance_windows.go b/cmd/gcrypt/singleinstance_windows.go index 0c0411e..cb52adf 100644 --- a/cmd/gcrypt/singleinstance_windows.go +++ b/cmd/gcrypt/singleinstance_windows.go @@ -8,6 +8,7 @@ import "golang.org/x/sys/windows" // process lifetime and released automatically by the OS on exit. const singleInstanceMutex = `gcrypt-single-instance-9c1f` +//nolint:unused // intentionally held for the process lifetime; the OS releases it on exit var singleInstanceHandle windows.Handle // acquireSingleInstance reports whether this process may run. It returns false diff --git a/go.mod b/go.mod index 523c7ee..cd88f79 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( golang.org/x/sys v0.46.0 golang.org/x/term v0.44.0 golang.org/x/text v0.38.0 - google.golang.org/api v0.284.0 + google.golang.org/api v0.285.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.52.0 ) @@ -20,53 +20,53 @@ require ( cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect - fyne.io/systray v1.12.1 // indirect - github.com/BurntSushi/toml v1.5.0 // indirect + fyne.io/systray v1.12.2 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/fredbi/uri v1.1.1 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fyne-io/gl-js v0.2.0 // indirect github.com/fyne-io/glfw-js v0.3.0 // indirect github.com/fyne-io/image v0.1.1 // indirect github.com/fyne-io/oksvg v0.2.0 // indirect - github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect - github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect + github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20260406072232-3ac4aa2bb164 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-text/render v0.2.1 // indirect github.com/go-text/typesetting v0.3.4 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/hack-pad/go-indexeddb v0.3.2 // indirect - github.com/hack-pad/safejs v0.1.0 // indirect + github.com/hack-pad/safejs v0.1.1 // indirect github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/mattn/go-isatty v0.0.22 // indirect - github.com/mattn/go-runewidth v0.0.17 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect - github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect + github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/rivo/uniseg v0.2.0 // indirect github.com/rymdport/portal v0.4.2 // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/stretchr/testify v1.11.1 // indirect - github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark v1.8.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect - golang.org/x/image v0.24.0 // indirect + golang.org/x/image v0.42.0 // indirect golang.org/x/net v0.56.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3 // indirect google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.11 // indirect modernc.org/libc v1.73.4 // indirect diff --git a/go.sum b/go.sum index aba85e6..e25637e 100644 --- a/go.sum +++ b/go.sum @@ -6,12 +6,14 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= fyne.io/fyne/v2 v2.7.4 h1:OVCI5mT+Onb2kA4wlmGA5pLCqKik9f4NDb5jiR1OMTc= fyne.io/fyne/v2 v2.7.4/go.mod h1:ZD1mmhBY75mSa97IXl3MPlICd1uNHfCXYh5hKIlVOII= -fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ= -fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= +fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -22,8 +24,8 @@ github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeO github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko= github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs= github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk= @@ -32,10 +34,10 @@ github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= github.com/fyne-io/oksvg v0.2.0 h1:mxcGU2dx6nwjJsSA9PCYZDuoAcsZ/OuJlvg/Q9Njfo8= github.com/fyne-io/oksvg v0.2.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= -github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= -github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI= +github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20260406072232-3ac4aa2bb164 h1:ddtotcEXIT7dFhSeXIWjK8YMFYl5bB2NdYQwgv6Uqjk= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20260406072232-3ac4aa2bb164/go.mod h1:SyRD8YfuKk+ZXlDqYiqe1qMSqjNgtHzBTG810KUagMc= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -47,8 +49,8 @@ github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaF github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY= github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos= github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= -github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= -github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -65,8 +67,8 @@ github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= -github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= -github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= +github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8= +github.com/hack-pad/safejs v0.1.1/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= @@ -79,22 +81,20 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= -github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= -github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= -github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= +github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= +github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU= @@ -105,8 +105,8 @@ github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqd github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= -github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= @@ -121,10 +121,12 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= -golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY= +golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= @@ -143,14 +145,14 @@ golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.284.0 h1:i+cKTgeQRcRySkP7QTl5PDO7/pAm8EcMFIUMlNbk4Vc= -google.golang.org/api v0.284.0/go.mod h1:AU44fU+XVZOCcd8uLaBIa/ZgzgPf/0qqY3+m7lQaado= +google.golang.org/api v0.285.0 h1:B7eHHoKGAX/LrPkQvhQqnGwjgWxofbdGwCTQvpm8FkM= +google.golang.org/api v0.285.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad h1:45WmJvIV6C2+O/jjLkPUH+F3aOj/1miDoU2DD0+NWbg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3 h1:phvBWCAQMGN1945mp5fjCXP6jEF0+a0+4TjokS4sxNY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/internal/config/config.go b/internal/config/config.go index 968871c..a700c32 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,9 +58,9 @@ type SyncDirection string const ( SyncDirTwoWay SyncDirection = "two_way" // full bidirectional (default) - SyncDirUploadOnly SyncDirection = "upload_only" // local → remote only - SyncDirDownloadOnly SyncDirection = "download_only" // remote → local only - SyncDirMirror SyncDirection = "mirror" // local → remote, no remote deletes + SyncDirUploadOnly SyncDirection = "upload_only" // local → remote only + SyncDirDownloadOnly SyncDirection = "download_only" // remote → local only + SyncDirMirror SyncDirection = "mirror" // local → remote, no remote deletes ) // ConflictPolicy determines how a sync conflict is resolved. @@ -83,11 +83,11 @@ type SyncPair struct { IgnorePatterns []string `yaml:"ignore_patterns"` SyncInterval int `yaml:"sync_interval"` SelectedFolders []string `yaml:"selected_folders"` - ForwardOnly bool `yaml:"forward_only"` // DEPRECATED: migrated to SyncDirection - SyncDirection SyncDirection `yaml:"sync_direction"` // data-flow policy - ConflictPolicy ConflictPolicy `yaml:"conflict_policy"` // conflict resolution strategy - OnlineOnly bool `yaml:"online_only"` // create placeholders instead of downloading - PadFilenames bool `yaml:"pad_filenames"` // pad encrypted names to hide length (set before first sync) + ForwardOnly bool `yaml:"forward_only"` // DEPRECATED: migrated to SyncDirection + SyncDirection SyncDirection `yaml:"sync_direction"` // data-flow policy + ConflictPolicy ConflictPolicy `yaml:"conflict_policy"` // conflict resolution strategy + OnlineOnly bool `yaml:"online_only"` // create placeholders instead of downloading + PadFilenames bool `yaml:"pad_filenames"` // pad encrypted names to hide length (set before first sync) } // EncryptionConfig holds encryption-related settings. @@ -161,12 +161,12 @@ func DefaultConfig() *Config { PassphraseHash: "", }, App: AppConfig{ - AutoStart: true, - LogLevel: "info", - LogPath: filepath.Join(gcryptDir, "gcrypt.log"), - LogMaxSize: 10, - LogMaxBackups: 3, - MaxFileSize: 0, + AutoStart: true, + LogLevel: "info", + LogPath: filepath.Join(gcryptDir, "gcrypt.log"), + LogMaxSize: 10, + LogMaxBackups: 3, + MaxFileSize: 0, }, } } @@ -248,7 +248,7 @@ func (c *Config) CanAddPair(localDir, driveFolderID string) error { return fmt.Errorf("local folder %q overlaps an existing sync folder %q", localDir, p.LocalDir) } if driveFolderID != "" && driveFolderID == p.DriveFolderID { - return fmt.Errorf("Drive folder %q is already used by another sync pair", driveFolderID) + return fmt.Errorf("drive folder %q is already used by another sync pair", driveFolderID) } } return nil @@ -489,7 +489,7 @@ func ConfigPath() string { // If the file contains a V1 format (no "version" key or version=1), it is // automatically migrated to V2 and saved back. func Load(path string) (*Config, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- path is the app's config file location (flag/default), not untrusted input if err != nil { return nil, err } @@ -528,7 +528,7 @@ func migrateV1(path string, data []byte) (*Config, error) { // Create backup of the original V1 file. backupPath := path + ".v1.bak" - if err := os.WriteFile(backupPath, data, 0600); err != nil { + if err := os.WriteFile(backupPath, data, 0600); err != nil { // #nosec G703 -- backupPath is derived from the app's own config path return nil, fmt.Errorf("creating V1 backup: %w", err) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1a5e199..21e2dd0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -144,7 +144,7 @@ func TestV1Migration(t *testing.T) { backupPath := configPath + ".v1.bak" // Write a V1 config file (no version key). - v1 := v1Config{ + v1 := v1Config{ // #nosec G101 -- test fixture; PassphraseHash is a dummy, not a real credential SyncDir: "/home/user/GcryptDrive", DriveFolderID: "1aBcDeFgHiJkLmNoPqRsTuVwXyZ", PassphraseHash: "argon2id$v=19$m=262144,t=4,p=4$hash", @@ -211,7 +211,7 @@ func TestV1Migration(t *testing.T) { } // Verify the file on disk is now V2. - raw, err := os.ReadFile(configPath) + raw, err := os.ReadFile(configPath) // #nosec G304 -- configPath is a test temp file if err != nil { t.Fatalf("read migrated config: %v", err) } diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 9d793b0..752666c 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -474,7 +474,7 @@ func LockMemory(b []byte) error { return nil } err := windows.VirtualLock( - uintptr(unsafe.Pointer(&b[0])), + uintptr(unsafe.Pointer(&b[0])), // #nosec G103 -- required Win32 syscall pointer marshalling uintptr(len(b)), ) if err != nil { @@ -490,7 +490,7 @@ func UnlockMemory(b []byte) error { return nil } err := windows.VirtualUnlock( - uintptr(unsafe.Pointer(&b[0])), + uintptr(unsafe.Pointer(&b[0])), // #nosec G103 -- required Win32 syscall pointer marshalling uintptr(len(b)), ) if err != nil { diff --git a/internal/crypto/filename.go b/internal/crypto/filename.go index e9fa1ca..a24d106 100644 --- a/internal/crypto/filename.go +++ b/internal/crypto/filename.go @@ -110,7 +110,7 @@ func encryptFilename(plaintext string, masterKey []byte, pad bool) (string, erro return "", fmt.Errorf("crypto: filename too long to pad (%d bytes, max %d)", len(nb), 0xFFFF) } padded := make([]byte, roundUpFilename(2+len(nb))) - binary.LittleEndian.PutUint16(padded[0:2], uint16(len(nb))) + binary.LittleEndian.PutUint16(padded[0:2], uint16(len(nb))) // #nosec G115 -- len(nb) is bounded to <= 0xFFFF by the guard above copy(padded[2:], nb) payload = padded } diff --git a/internal/crypto/strength.go b/internal/crypto/strength.go index 238c174..f4bbbc1 100644 --- a/internal/crypto/strength.go +++ b/internal/crypto/strength.go @@ -16,12 +16,12 @@ const MinPassphraseLength = 12 // the length bar but offer little protection. It is not exhaustive — it just // catches the most common mistakes. var commonWeakPassphrases = map[string]struct{}{ - "password1234": {}, + "password1234": {}, "passwordpassword": {}, - "123456789012": {}, - "qwertyuiopas": {}, - "letmeinletmein": {}, - "gcryptgcrypt": {}, + "123456789012": {}, + "qwertyuiopas": {}, + "letmeinletmein": {}, + "gcryptgcrypt": {}, } // CheckPassphraseStrength validates a candidate master passphrase and returns a diff --git a/internal/drive/auth.go b/internal/drive/auth.go index 250cf57..febfbf2 100644 --- a/internal/drive/auth.go +++ b/internal/drive/auth.go @@ -74,7 +74,8 @@ func GetTokenFromWebBrowser(config *oauth2.Config) (*oauth2.Token, error) { openBrowser(authURL) // Listen on port 8089 and wait for the callback. - listener, err := net.Listen("tcp", "localhost:8089") + var lc net.ListenConfig + listener, err := lc.Listen(context.Background(), "tcp", "localhost:8089") if err != nil { return nil, fmt.Errorf("drive: failed to listen on localhost:8089: %w", err) } @@ -82,7 +83,9 @@ func GetTokenFromWebBrowser(config *oauth2.Config) (*oauth2.Token, error) { codeCh := make(chan string, 1) errCh := make(chan error, 1) - srv := &http.Server{} + // ReadHeaderTimeout is set in the struct literal (not assigned afterwards) so + // static analysers can see this server is not vulnerable to Slowloris. + srv := &http.Server{ReadHeaderTimeout: 10 * time.Second} srv.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/callback" { http.NotFound(w, r) @@ -107,8 +110,6 @@ func GetTokenFromWebBrowser(config *oauth2.Config) (*oauth2.Token, error) { codeCh <- code }) - srv.ReadHeaderTimeout = 10 * time.Second - go func() { if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed { errCh <- fmt.Errorf("drive: callback server error: %w", err) @@ -147,14 +148,17 @@ func GetTokenFromWebBrowser(config *oauth2.Config) (*oauth2.Token, error) { // implementation varies by OS; on Windows it uses rundll32, on Darwin it uses // the open command, and on Linux/other it uses xdg-open. func openBrowser(url string) { + // The command names are fixed constants and the only variable argument is the + // OAuth authorization URL this package just built — not attacker input — so + // the G204 "subprocess from variable" warnings are audited and suppressed. var cmd *exec.Cmd switch runtime.GOOS { case "windows": - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + cmd = exec.CommandContext(context.Background(), "rundll32", "url.dll,FileProtocolHandler", url) // #nosec G204 -- fixed command; url is our own OAuth URL case "darwin": - cmd = exec.Command("open", url) + cmd = exec.CommandContext(context.Background(), "open", url) // #nosec G204 -- fixed command; url is our own OAuth URL default: - cmd = exec.Command("xdg-open", url) + cmd = exec.CommandContext(context.Background(), "xdg-open", url) // #nosec G204 -- fixed command; url is our own OAuth URL } // Best-effort: ignore errors if the browser cannot be opened. _ = cmd.Run() @@ -165,7 +169,10 @@ func openBrowser(url string) { // JSON-marshalled and then encrypted using crypto.EncryptBytes with the path // "gcrypt://oauth-token" as AAD. func SaveToken(path string, token *oauth2.Token, masterKey []byte) error { - data, err := json.Marshal(token) + // Marshalling the token deliberately includes the access/refresh tokens — the + // JSON is immediately encrypted with the master key below and never written in + // the clear, so the G117 "secret in marshalled struct" warning is by design. + data, err := json.Marshal(token) // #nosec G117 -- token JSON is encrypted before being persisted if err != nil { return fmt.Errorf("drive: failed to marshal token: %w", err) } @@ -192,7 +199,7 @@ func SaveToken(path string, token *oauth2.Token, masterKey []byte) error { // master key using crypto.DecryptBytes with the path "gcrypt://oauth-token" // as AAD, and unmarshals the JSON into an *oauth2.Token. func LoadToken(path string, masterKey []byte) (*oauth2.Token, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- path is the app's own token file location, not user input if err != nil { return nil, fmt.Errorf("drive: failed to read token file: %w", err) } diff --git a/internal/drive/client.go b/internal/drive/client.go index 315b3cf..3daf5a0 100644 --- a/internal/drive/client.go +++ b/internal/drive/client.go @@ -281,7 +281,7 @@ func (c *Client) UploadFile(ctx context.Context, name string, parentID string, c func (c *Client) DownloadFile(ctx context.Context, fileID string) (io.ReadCloser, error) { resp, err := c.svc.Files.Get(fileID). Context(ctx). - Download() + Download() //nolint:bodyclose // resp.Body is wrapped and returned to the caller, which closes it if err != nil { return nil, wrapAPIError(err) } diff --git a/internal/drive/store.go b/internal/drive/store.go index 76396a1..bb29033 100644 --- a/internal/drive/store.go +++ b/internal/drive/store.go @@ -1,6 +1,7 @@ package drive import ( + "context" "database/sql" "fmt" "log/slog" @@ -69,6 +70,11 @@ CREATE INDEX IF NOT EXISTS idx_sync_map_root_status ON sync_map(sync_root_id, sy // defaultRootID is used as the sync root ID during V1→V2 migration. If empty, // a new UUID is generated. For fresh databases the parameter is unused. func NewStore(dbPath string, defaultRootID string) (*Store, error) { + // One-shot startup work (schema apply + V1→V2 migration) runs on a background + // context: there is no caller request to tie it to, and it must not be + // cancellable mid-migration or the database could be left half-converted. + ctx := context.Background() + db, err := openStore(dbPath) if err != nil { // The database could not be opened or failed its integrity check. The @@ -88,7 +94,7 @@ func NewStore(dbPath string, defaultRootID string) (*Store, error) { } // Apply V2 schema (CREATE IF NOT EXISTS is idempotent for new databases). - if _, err := db.Exec(schemaV2); err != nil { + if _, err := db.ExecContext(ctx, schemaV2); err != nil { _ = db.Close() return nil, fmt.Errorf("store: failed to apply schema: %w", err) } @@ -96,10 +102,10 @@ func NewStore(dbPath string, defaultRootID string) (*Store, error) { // Check if migration from V1 is needed. var needsMigration bool var count int - err = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sync_roots'").Scan(&count) + err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sync_roots'").Scan(&count) if err == nil && count == 0 { // sync_roots doesn't exist — check if old sync_map exists (V1 schema). - err = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sync_map'").Scan(&count) + err = db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sync_map'").Scan(&count) if err == nil && count > 0 { needsMigration = true } @@ -109,7 +115,7 @@ func NewStore(dbPath string, defaultRootID string) (*Store, error) { if defaultRootID == "" { defaultRootID = uuid.New().String() } - if err := migrateV1ToV2(db, defaultRootID); err != nil { + if err := migrateV1ToV2(ctx, db, defaultRootID); err != nil { _ = db.Close() return nil, fmt.Errorf("store: migrating database: %w", err) } @@ -121,15 +127,15 @@ func NewStore(dbPath string, defaultRootID string) (*Store, error) { // migrateV1ToV2 migrates a V1 database (single PK on local_path) to the V2 // schema (composite PK on sync_root_id, local_path). The migration runs in a // single transaction so it is atomic. -func migrateV1ToV2(db *sql.DB, defaultRootID string) error { - tx, err := db.Begin() +func migrateV1ToV2(ctx context.Context, db *sql.DB, defaultRootID string) error { + tx, err := db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin migration transaction: %w", err) } defer func() { _ = tx.Rollback() }() // no-op if committed // 1. Create schema_version and sync_roots tables (idempotent). - if _, err := tx.Exec(` + if _, err := tx.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY, applied_at DATETIME DEFAULT CURRENT_TIMESTAMP @@ -146,7 +152,7 @@ func migrateV1ToV2(db *sql.DB, defaultRootID string) error { } // 2. Create new sync_map_v2 table with composite PK. - if _, err := tx.Exec(` + if _, err := tx.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS sync_map_v2 ( sync_root_id TEXT NOT NULL, local_path TEXT NOT NULL, @@ -170,7 +176,7 @@ func migrateV1ToV2(db *sql.DB, defaultRootID string) error { } // 3. Copy all existing rows from sync_map to sync_map_v2, adding the default sync_root_id. - if _, err := tx.Exec(` + if _, err := tx.ExecContext(ctx, ` INSERT INTO sync_map_v2 (sync_root_id, local_path, remote_id, local_hash, remote_hash, version, encrypted_dek, dek_nonce, content_nonce, @@ -184,17 +190,17 @@ func migrateV1ToV2(db *sql.DB, defaultRootID string) error { } // 4. Drop old sync_map table. - if _, err := tx.Exec(`DROP TABLE sync_map`); err != nil { + if _, err := tx.ExecContext(ctx, `DROP TABLE sync_map`); err != nil { return fmt.Errorf("drop old sync_map: %w", err) } // 5. Rename sync_map_v2 to sync_map. - if _, err := tx.Exec(`ALTER TABLE sync_map_v2 RENAME TO sync_map`); err != nil { + if _, err := tx.ExecContext(ctx, `ALTER TABLE sync_map_v2 RENAME TO sync_map`); err != nil { return fmt.Errorf("rename sync_map_v2: %w", err) } // 6. Recreate indexes. - if _, err := tx.Exec(` + if _, err := tx.ExecContext(ctx, ` CREATE INDEX IF NOT EXISTS idx_sync_map_remote_id ON sync_map(remote_id); CREATE INDEX IF NOT EXISTS idx_sync_map_status ON sync_map(sync_status); CREATE INDEX IF NOT EXISTS idx_sync_map_root_status ON sync_map(sync_root_id, sync_status); @@ -206,7 +212,7 @@ func migrateV1ToV2(db *sql.DB, defaultRootID string) error { // We don't know the local_dir or drive_folder_id from the DB alone, // so we insert placeholder values. The caller should update them // via UpsertSyncRoot after migration. - if _, err := tx.Exec(` + if _, err := tx.ExecContext(ctx, ` INSERT OR IGNORE INTO sync_roots (id, local_dir, drive_folder_id) VALUES (?, '', ''); `, defaultRootID); err != nil { @@ -214,7 +220,7 @@ func migrateV1ToV2(db *sql.DB, defaultRootID string) error { } // 8. Insert schema version 2. - if _, err := tx.Exec(` + if _, err := tx.ExecContext(ctx, ` INSERT OR IGNORE INTO schema_version (version) VALUES (2); `); err != nil { return fmt.Errorf("insert schema version: %w", err) @@ -233,6 +239,10 @@ func migrateV1ToV2(db *sql.DB, defaultRootID string) error { // then quarantine the file and retry with a fresh one). A new/empty database // passes the check. func openStore(dbPath string) (*sql.DB, error) { + // Connection setup is one-shot startup work with no caller request to bind + // to, so it uses a background context. + ctx := context.Background() + db, err := sql.Open("sqlite", dbPath) if err != nil { return nil, fmt.Errorf("open: %w", err) @@ -242,7 +252,7 @@ func openStore(dbPath string) (*sql.DB, error) { "PRAGMA journal_mode = WAL", "PRAGMA synchronous = NORMAL", } { - if _, err := db.Exec(pragma); err != nil { + if _, err := db.ExecContext(ctx, pragma); err != nil { _ = db.Close() return nil, fmt.Errorf("pragma %q: %w", pragma, err) } @@ -250,7 +260,7 @@ func openStore(dbPath string) (*sql.DB, error) { // quick_check is far cheaper than integrity_check and still catches the // structural corruption that would otherwise crash queries at runtime. var res string - if err := db.QueryRow("PRAGMA quick_check").Scan(&res); err != nil { + if err := db.QueryRowContext(ctx, "PRAGMA quick_check").Scan(&res); err != nil { _ = db.Close() return nil, fmt.Errorf("integrity check: %w", err) } @@ -294,7 +304,7 @@ func (s *Store) Close() error { // GetSyncFile looks up sync metadata by sync root ID and local file path. // Returns sql.ErrNoRows if no matching row exists. -func (s *Store) GetSyncFile(syncRootID, localPath string) (*models.SyncFile, error) { +func (s *Store) GetSyncFile(ctx context.Context, syncRootID, localPath string) (*models.SyncFile, error) { s.mu.Lock() defer s.mu.Unlock() @@ -306,7 +316,7 @@ func (s *Store) GetSyncFile(syncRootID, localPath string) (*models.SyncFile, err WHERE sync_root_id = ? AND local_path = ? ` - row := s.db.QueryRow(query, syncRootID, localPath) + row := s.db.QueryRowContext(ctx, query, syncRootID, localPath) sf, err := scanSyncFile(row) if err != nil { return nil, fmt.Errorf("store: get sync file by path: %w", err) @@ -316,7 +326,7 @@ func (s *Store) GetSyncFile(syncRootID, localPath string) (*models.SyncFile, err // GetSyncFileByRemoteID looks up sync metadata by sync root ID and the remote // Google Drive file ID. Returns sql.ErrNoRows if no matching row exists. -func (s *Store) GetSyncFileByRemoteID(syncRootID, remoteID string) (*models.SyncFile, error) { +func (s *Store) GetSyncFileByRemoteID(ctx context.Context, syncRootID, remoteID string) (*models.SyncFile, error) { s.mu.Lock() defer s.mu.Unlock() @@ -328,7 +338,7 @@ func (s *Store) GetSyncFileByRemoteID(syncRootID, remoteID string) (*models.Sync WHERE sync_root_id = ? AND remote_id = ? ` - row := s.db.QueryRow(query, syncRootID, remoteID) + row := s.db.QueryRowContext(ctx, query, syncRootID, remoteID) sf, err := scanSyncFile(row) if err != nil { return nil, fmt.Errorf("store: get sync file by remote ID: %w", err) @@ -338,7 +348,7 @@ func (s *Store) GetSyncFileByRemoteID(syncRootID, remoteID string) (*models.Sync // PutSyncFile inserts or replaces a sync metadata record. If a row with the // same (sync_root_id, local_path) already exists it is fully replaced. -func (s *Store) PutSyncFile(sf *models.SyncFile) error { +func (s *Store) PutSyncFile(ctx context.Context, sf *models.SyncFile) error { s.mu.Lock() defer s.mu.Unlock() @@ -350,7 +360,7 @@ func (s *Store) PutSyncFile(sf *models.SyncFile) error { VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ` - _, err := s.db.Exec(query, + _, err := s.db.ExecContext(ctx, query, sf.SyncRootID, sf.LocalPath, sf.RemoteID, @@ -373,7 +383,7 @@ func (s *Store) PutSyncFile(sf *models.SyncFile) error { // DeleteSyncFile removes the sync metadata row for the given sync root and // local path. -func (s *Store) DeleteSyncFile(syncRootID, localPath string) error { +func (s *Store) DeleteSyncFile(ctx context.Context, syncRootID, localPath string) error { s.mu.Lock() defer s.mu.Unlock() @@ -383,7 +393,7 @@ func (s *Store) DeleteSyncFile(syncRootID, localPath string) error { // that was never tracked (e.g. an ignored or never-synced file) can still // generate a delete operation, and that should be a no-op rather than an // error that retries forever. - if _, err := s.db.Exec(query, syncRootID, localPath); err != nil { + if _, err := s.db.ExecContext(ctx, query, syncRootID, localPath); err != nil { return fmt.Errorf("store: delete sync file: %w", err) } @@ -392,7 +402,7 @@ func (s *Store) DeleteSyncFile(syncRootID, localPath string) error { // ListPending returns all tracked files in the given sync root whose // sync_status is not "synced". -func (s *Store) ListPending(syncRootID string) ([]*models.SyncFile, error) { +func (s *Store) ListPending(ctx context.Context, syncRootID string) ([]*models.SyncFile, error) { s.mu.Lock() defer s.mu.Unlock() @@ -404,7 +414,7 @@ func (s *Store) ListPending(syncRootID string) ([]*models.SyncFile, error) { WHERE sync_root_id = ? AND sync_status != 'synced' ` - rows, err := s.db.Query(query, syncRootID) + rows, err := s.db.QueryContext(ctx, query, syncRootID) if err != nil { return nil, fmt.Errorf("store: list pending: %w", err) } @@ -414,7 +424,7 @@ func (s *Store) ListPending(syncRootID string) ([]*models.SyncFile, error) { } // ListAll returns all tracked files in the given sync root. -func (s *Store) ListAll(syncRootID string) ([]*models.SyncFile, error) { +func (s *Store) ListAll(ctx context.Context, syncRootID string) ([]*models.SyncFile, error) { s.mu.Lock() defer s.mu.Unlock() @@ -426,7 +436,7 @@ func (s *Store) ListAll(syncRootID string) ([]*models.SyncFile, error) { WHERE sync_root_id = ? ` - rows, err := s.db.Query(query, syncRootID) + rows, err := s.db.QueryContext(ctx, query, syncRootID) if err != nil { return nil, fmt.Errorf("store: list all: %w", err) } @@ -437,7 +447,7 @@ func (s *Store) ListAll(syncRootID string) ([]*models.SyncFile, error) { // UpdateStatus changes the sync_status column for the given sync root and // local path. -func (s *Store) UpdateStatus(syncRootID, localPath string, status models.SyncStatus) error { +func (s *Store) UpdateStatus(ctx context.Context, syncRootID, localPath string, status models.SyncStatus) error { s.mu.Lock() defer s.mu.Unlock() @@ -447,7 +457,7 @@ func (s *Store) UpdateStatus(syncRootID, localPath string, status models.SyncSta WHERE sync_root_id = ? AND local_path = ? ` - result, err := s.db.Exec(query, string(status), syncRootID, localPath) + result, err := s.db.ExecContext(ctx, query, string(status), syncRootID, localPath) if err != nil { return fmt.Errorf("store: update status: %w", err) } @@ -462,7 +472,7 @@ func (s *Store) UpdateStatus(syncRootID, localPath string, status models.SyncSta // UpdateRemoteInfo updates the remote metadata fields after a successful // upload: the remote file ID, the remote hash, and the file size. -func (s *Store) UpdateRemoteInfo(syncRootID, localPath, remoteID, remoteHash string, size int64) error { +func (s *Store) UpdateRemoteInfo(ctx context.Context, syncRootID, localPath, remoteID, remoteHash string, size int64) error { s.mu.Lock() defer s.mu.Unlock() @@ -472,7 +482,7 @@ func (s *Store) UpdateRemoteInfo(syncRootID, localPath, remoteID, remoteHash str WHERE sync_root_id = ? AND local_path = ? ` - result, err := s.db.Exec(query, remoteID, remoteHash, size, syncRootID, localPath) + result, err := s.db.ExecContext(ctx, query, remoteID, remoteHash, size, syncRootID, localPath) if err != nil { return fmt.Errorf("store: update remote info: %w", err) } @@ -491,7 +501,7 @@ func (s *Store) UpdateRemoteInfo(syncRootID, localPath, remoteID, remoteHash str // UpsertSyncRoot inserts a new sync root or updates an existing one (matched // by ID). -func (s *Store) UpsertSyncRoot(root *models.SyncRoot) error { +func (s *Store) UpsertSyncRoot(ctx context.Context, root *models.SyncRoot) error { s.mu.Lock() defer s.mu.Unlock() @@ -500,7 +510,7 @@ func (s *Store) UpsertSyncRoot(root *models.SyncRoot) error { VALUES (?, ?, ?, CURRENT_TIMESTAMP) ` - _, err := s.db.Exec(query, root.ID, root.LocalDir, root.DriveFolderID) + _, err := s.db.ExecContext(ctx, query, root.ID, root.LocalDir, root.DriveFolderID) if err != nil { return fmt.Errorf("store: upsert sync root: %w", err) } @@ -509,7 +519,7 @@ func (s *Store) UpsertSyncRoot(root *models.SyncRoot) error { } // GetSyncRoot retrieves a sync root by ID. Returns sql.ErrNoRows if not found. -func (s *Store) GetSyncRoot(id string) (*models.SyncRoot, error) { +func (s *Store) GetSyncRoot(ctx context.Context, id string) (*models.SyncRoot, error) { s.mu.Lock() defer s.mu.Unlock() @@ -519,7 +529,7 @@ func (s *Store) GetSyncRoot(id string) (*models.SyncRoot, error) { WHERE id = ? ` - row := s.db.QueryRow(query, id) + row := s.db.QueryRowContext(ctx, query, id) root := &models.SyncRoot{} err := row.Scan(&root.ID, &root.LocalDir, &root.DriveFolderID, &root.CreatedAt, &root.UpdatedAt) if err != nil { @@ -529,7 +539,7 @@ func (s *Store) GetSyncRoot(id string) (*models.SyncRoot, error) { } // ListSyncRoots returns all configured sync roots. -func (s *Store) ListSyncRoots() ([]*models.SyncRoot, error) { +func (s *Store) ListSyncRoots(ctx context.Context) ([]*models.SyncRoot, error) { s.mu.Lock() defer s.mu.Unlock() @@ -538,7 +548,7 @@ func (s *Store) ListSyncRoots() ([]*models.SyncRoot, error) { FROM sync_roots ` - rows, err := s.db.Query(query) + rows, err := s.db.QueryContext(ctx, query) if err != nil { return nil, fmt.Errorf("store: list sync roots: %w", err) } @@ -561,13 +571,13 @@ func (s *Store) ListSyncRoots() ([]*models.SyncRoot, error) { // DeleteSyncRoot removes a sync root by ID. Associated sync_map rows are // automatically deleted via the ON DELETE CASCADE foreign key constraint. -func (s *Store) DeleteSyncRoot(id string) error { +func (s *Store) DeleteSyncRoot(ctx context.Context, id string) error { s.mu.Lock() defer s.mu.Unlock() const query = `DELETE FROM sync_roots WHERE id = ?` - result, err := s.db.Exec(query, id) + result, err := s.db.ExecContext(ctx, query, id) if err != nil { return fmt.Errorf("store: delete sync root: %w", err) } diff --git a/internal/service/appcontroller.go b/internal/service/appcontroller.go index d64e842..3a8b6d4 100644 --- a/internal/service/appcontroller.go +++ b/internal/service/appcontroller.go @@ -247,7 +247,7 @@ func (ac *AppController) HandlePassphrase() error { saltPath := filepath.Join(appDir, "gcrypt", "salt.bin") var salt []byte - saltData, err := os.ReadFile(saltPath) + saltData, err := os.ReadFile(saltPath) // #nosec G304 -- saltPath is the app's own salt.bin under %APPDATA%, not user input if err == nil { salt = saltData if len(salt) != 16 { @@ -386,7 +386,7 @@ func (ac *AppController) TryAutoUnlock() bool { // Load the salt (kept alongside the key; needed for later crypto ops). saltPath := filepath.Join(appDataDir(), "gcrypt", "salt.bin") - salt, err := os.ReadFile(saltPath) + salt, err := os.ReadFile(saltPath) // #nosec G304 -- saltPath is the app's own salt.bin under %APPDATA%, not user input if err != nil { if ac.logger != nil { ac.logger.Warn("auto-unlock failed to read salt", map[string]interface{}{ @@ -637,7 +637,7 @@ func (ac *AppController) StartSync() error { LocalDir: pair.LocalDir, DriveFolderID: pair.DriveFolderID, } - if err := store.UpsertSyncRoot(root); err != nil { + if err := store.UpsertSyncRoot(context.Background(), root); err != nil { return fmt.Errorf("service: upsert sync root %s: %w", pair.ID, err) } } diff --git a/internal/service/backup.go b/internal/service/backup.go index 0b136d7..73d823d 100644 --- a/internal/service/backup.go +++ b/internal/service/backup.go @@ -49,13 +49,13 @@ func ExportBackup(destDir string) ([]string, error) { // copyFileSecure copies src to dst, creating dst with owner-only permissions. func copyFileSecure(src, dst string) error { - in, err := os.Open(src) + in, err := os.Open(src) // #nosec G304 -- src/dst are app-controlled config/backup paths, not user input if err != nil { return err } defer func() { _ = in.Close() }() - out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) // #nosec G304 -- src/dst are app-controlled config/backup paths, not user input if err != nil { return err } diff --git a/internal/service/dialogs_windows.go b/internal/service/dialogs_windows.go index a3ac101..b563fc1 100644 --- a/internal/service/dialogs_windows.go +++ b/internal/service/dialogs_windows.go @@ -122,7 +122,7 @@ type browseInfo struct { func messageBox(title, text string, flags uint32) int { t, _ := syscall.UTF16PtrFromString(title) m, _ := syscall.UTF16PtrFromString(text) - r, _, _ := pMessageBoxDlg.Call(0, uintptr(unsafe.Pointer(m)), uintptr(unsafe.Pointer(t)), uintptr(flags)) + r, _, _ := pMessageBoxDlg.Call(0, uintptr(unsafe.Pointer(m)), uintptr(unsafe.Pointer(t)), uintptr(flags)) // #nosec G103 -- required Win32 syscall pointer marshalling return int(r) } @@ -146,14 +146,14 @@ func pickFolder(title string) (string, bool) { }(), } - pidl, _, _ := pSHBrowseForFolder.Call(uintptr(unsafe.Pointer(&bi))) + pidl, _, _ := pSHBrowseForFolder.Call(uintptr(unsafe.Pointer(&bi))) // #nosec G103 -- required Win32 syscall pointer marshalling if pidl == 0 { return "", false } defer func() { _, _, _ = pCoTaskMemFreeDlg.Call(pidl) }() pathBuf := make([]uint16, 260) - ret, _, _ := pSHGetPathFromIDLst.Call(pidl, uintptr(unsafe.Pointer(&pathBuf[0]))) + ret, _, _ := pSHGetPathFromIDLst.Call(pidl, uintptr(unsafe.Pointer(&pathBuf[0]))) // #nosec G103 -- required Win32 syscall pointer marshalling if ret == 0 { return "", false } @@ -195,7 +195,7 @@ func registerTextDlgClass() { lpszClassName: textDlgClassName, } wc.cbSize = uint32(unsafe.Sizeof(wc)) - _, _, _ = pRegisterClassExDlg.Call(uintptr(unsafe.Pointer(&wc))) + _, _, _ = pRegisterClassExDlg.Call(uintptr(unsafe.Pointer(&wc))) // #nosec G103 -- required Win32 syscall pointer marshalling }) } @@ -235,7 +235,7 @@ func getControlText(hwnd uintptr) string { return "" } buf := make([]uint16, length+1) - _, _, _ = pGetWindowTextDlg.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), uintptr(length+1)) + _, _, _ = pGetWindowTextDlg.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), uintptr(length+1)) // #nosec G103 -- required Win32 syscall pointer marshalling return syscall.UTF16ToString(buf) } @@ -244,8 +244,8 @@ func createControl(class, text string, style uint32, x, y, w, h int, parent uint textPtr, _ := syscall.UTF16PtrFromString(text) hwnd, _, _ := pCreateWindowExDlg.Call( 0, - uintptr(unsafe.Pointer(classPtr)), - uintptr(unsafe.Pointer(textPtr)), + uintptr(unsafe.Pointer(classPtr)), // #nosec G103 -- required Win32 syscall pointer marshalling + uintptr(unsafe.Pointer(textPtr)), // #nosec G103 -- required Win32 syscall pointer marshalling uintptr(style), uintptr(x), uintptr(y), uintptr(w), uintptr(h), parent, @@ -278,8 +278,8 @@ func promptText(title, label, initial string, password bool) (string, bool) { hwnd, _, _ := pCreateWindowExDlg.Call( wsExTopmost|wsExDlgFrame, - uintptr(unsafe.Pointer(textDlgClassName)), - uintptr(unsafe.Pointer(titlePtr)), + uintptr(unsafe.Pointer(textDlgClassName)), // #nosec G103 -- required Win32 syscall pointer marshalling + uintptr(unsafe.Pointer(titlePtr)), // #nosec G103 -- required Win32 syscall pointer marshalling wsOverlapped|wsCaption|wsSysMenu, cwUseDefault, cwUseDefault, width, height, 0, 0, 0, 0, @@ -313,15 +313,15 @@ func promptText(title, label, initial string, password bool) (string, bool) { // Modal message loop. var msg msgDlg for { - r, _, _ := pGetMessageDlg.Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0) - if int32(r) <= 0 { + r, _, _ := pGetMessageDlg.Call(uintptr(unsafe.Pointer(&msg)), 0, 0, 0) // #nosec G103 -- required Win32 syscall pointer marshalling + if int32(r) <= 0 { // #nosec G115 -- GetMessage's documented return is -1/0/>0; int32 is the correct contract break } // Let the dialog manager handle Tab/Enter/Esc navigation. - handled, _, _ := pIsDialogMessageDlg.Call(hwnd, uintptr(unsafe.Pointer(&msg))) + handled, _, _ := pIsDialogMessageDlg.Call(hwnd, uintptr(unsafe.Pointer(&msg))) // #nosec G103 -- required Win32 syscall pointer marshalling if handled == 0 { - _, _, _ = pTranslateMsgDlg.Call(uintptr(unsafe.Pointer(&msg))) - _, _, _ = pDispatchMsgDlg.Call(uintptr(unsafe.Pointer(&msg))) + _, _, _ = pTranslateMsgDlg.Call(uintptr(unsafe.Pointer(&msg))) // #nosec G103 -- required Win32 syscall pointer marshalling + _, _, _ = pDispatchMsgDlg.Call(uintptr(unsafe.Pointer(&msg))) // #nosec G103 -- required Win32 syscall pointer marshalling } } diff --git a/internal/service/dpapi_windows.go b/internal/service/dpapi_windows.go index b4f07ee..167ca88 100644 --- a/internal/service/dpapi_windows.go +++ b/internal/service/dpapi_windows.go @@ -36,7 +36,7 @@ func newBlob(d []byte) dataBlob { if len(d) == 0 { return dataBlob{} } - return dataBlob{cbData: uint32(len(d)), pbData: &d[0]} + return dataBlob{cbData: uint32(len(d)), pbData: &d[0]} // #nosec G115 -- len(d) is a small in-memory secret blob, far below uint32 max } // bytes copies the blob's contents into a Go-managed slice. @@ -45,7 +45,7 @@ func (b dataBlob) bytes() []byte { return nil } out := make([]byte, b.cbData) - copy(out, unsafe.Slice(b.pbData, b.cbData)) + copy(out, unsafe.Slice(b.pbData, b.cbData)) // #nosec G103 -- required to copy the DPAPI-allocated blob into Go memory return out } @@ -55,18 +55,18 @@ func protectData(plaintext []byte) ([]byte, error) { var out dataBlob ret, _, err := procProtect.Call( - uintptr(unsafe.Pointer(&in)), - 0, // szDataDescr - 0, // pOptionalEntropy - 0, // pvReserved - 0, // pPromptStruct + uintptr(unsafe.Pointer(&in)), // #nosec G103 -- required Win32 DPAPI syscall pointer marshalling + 0, // szDataDescr + 0, // pOptionalEntropy + 0, // pvReserved + 0, // pPromptStruct cryptProtectUIForbidden, - uintptr(unsafe.Pointer(&out)), + uintptr(unsafe.Pointer(&out)), // #nosec G103 -- required Win32 DPAPI syscall pointer marshalling ) if ret == 0 { return nil, fmt.Errorf("service: CryptProtectData failed: %w", err) } - defer func() { _, _, _ = procLocalFree.Call(uintptr(unsafe.Pointer(out.pbData))) }() + defer func() { _, _, _ = procLocalFree.Call(uintptr(unsafe.Pointer(out.pbData))) }() // #nosec G103 -- required Win32 syscall pointer marshalling return out.bytes(), nil } @@ -78,18 +78,18 @@ func unprotectData(ciphertext []byte) ([]byte, error) { var out dataBlob ret, _, err := procUnprotect.Call( - uintptr(unsafe.Pointer(&in)), - 0, // ppszDataDescr - 0, // pOptionalEntropy - 0, // pvReserved - 0, // pPromptStruct + uintptr(unsafe.Pointer(&in)), // #nosec G103 -- required Win32 DPAPI syscall pointer marshalling + 0, // ppszDataDescr + 0, // pOptionalEntropy + 0, // pvReserved + 0, // pPromptStruct cryptProtectUIForbidden, - uintptr(unsafe.Pointer(&out)), + uintptr(unsafe.Pointer(&out)), // #nosec G103 -- required Win32 DPAPI syscall pointer marshalling ) if ret == 0 { return nil, fmt.Errorf("service: CryptUnprotectData failed: %w", err) } - defer func() { _, _, _ = procLocalFree.Call(uintptr(unsafe.Pointer(out.pbData))) }() + defer func() { _, _, _ = procLocalFree.Call(uintptr(unsafe.Pointer(out.pbData))) }() // #nosec G103 -- required Win32 syscall pointer marshalling return out.bytes(), nil } diff --git a/internal/service/fyneui.go b/internal/service/fyneui.go index 9b103d1..6689ed3 100644 --- a/internal/service/fyneui.go +++ b/internal/service/fyneui.go @@ -108,9 +108,9 @@ type FyneApp struct { // Header widgets. statusDot *canvas.Circle statusLabel *widget.Label - summary *widget.Label // sync folder path - metrics *widget.Label // cumulative counts + transfer rate - liveLabel *widget.Label // current in-flight file(s) + pending + summary *widget.Label // sync folder path + metrics *widget.Label // cumulative counts + transfer rate + liveLabel *widget.Label // current in-flight file(s) + pending progress *widget.ProgressBar // overall sync progress (done / total) // Account / Drive-quota widgets (Settings tab; updated by a background poll). @@ -140,8 +140,8 @@ type FyneApp struct { // Live "now transferring" panel (top of Activity tab) + its last-rendered // signature, so it's only rebuilt when the in-flight set actually changes. - liveTransfers *fyne.Container - lastLiveSig string + liveTransfers *fyne.Container + lastLiveSig string // Search/filter text for the Activity and Folders tabs (lower-cased). activityFilter string @@ -1071,7 +1071,7 @@ func (f *FyneApp) openSyncFolder() { if dir == "" { return } - if err := exec.Command("explorer", dir).Start(); err != nil { + if err := exec.CommandContext(context.Background(), "explorer", dir).Start(); err != nil { // #nosec G204 -- fixed command; dir is the app's configured sync folder fmt.Fprintf(os.Stderr, "service: open sync folder: %v\n", err) } } @@ -1089,7 +1089,7 @@ func (f *FyneApp) viewLog() { if path == "" { return } - if err := exec.Command("notepad", path).Start(); err != nil { + if err := exec.CommandContext(context.Background(), "notepad", path).Start(); err != nil { // #nosec G204 -- fixed command; path is the app's own log file fmt.Fprintf(os.Stderr, "service: view log: %v\n", err) } } diff --git a/internal/service/fyneui_settings.go b/internal/service/fyneui_settings.go index 6d3770e..23e7e85 100644 --- a/internal/service/fyneui_settings.go +++ b/internal/service/fyneui_settings.go @@ -1,6 +1,7 @@ package service import ( + "context" "fmt" "os" "os/exec" @@ -563,7 +564,7 @@ func (f *FyneApp) buildPairCard(pairID string) fyne.CanvasObject { }) openBtn := widget.NewButtonWithIcon("Open", theme.FolderOpenIcon(), func() { if p := cfg.GetSyncPair(pairID); p != nil { - if err := exec.Command("explorer", p.LocalDir).Start(); err != nil { + if err := exec.CommandContext(context.Background(), "explorer", p.LocalDir).Start(); err != nil { // #nosec G204 -- fixed command; LocalDir is the pair's configured sync folder fmt.Fprintf(os.Stderr, "service: open pair folder: %v\n", err) } } diff --git a/internal/service/logger.go b/internal/service/logger.go index fa565d3..ff805ca 100644 --- a/internal/service/logger.go +++ b/internal/service/logger.go @@ -71,7 +71,7 @@ func NewLogger(path string) (*Logger, error) { return nil, fmt.Errorf("service: create log directory: %w", err) } - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) // #nosec G304 -- path is the app's configured log file location if err != nil { return nil, fmt.Errorf("service: open log file: %w", err) } diff --git a/internal/service/logger_test.go b/internal/service/logger_test.go index f2adbfd..05db07b 100644 --- a/internal/service/logger_test.go +++ b/internal/service/logger_test.go @@ -31,7 +31,7 @@ func TestLoggerRotationKeepsCorrectBackups(t *testing.T) { } read := func(p string) string { - b, err := os.ReadFile(p) + b, err := os.ReadFile(p) // #nosec G304 -- p is a test temp file if err != nil { t.Fatalf("read %s: %v", p, err) } @@ -73,7 +73,7 @@ func TestLoggerLevelFiltering(t *testing.T) { logger.Warn("wrn-message") logger.Error("err-message") - b, err := os.ReadFile(path) + b, err := os.ReadFile(path) // #nosec G304 -- path is a test temp file if err != nil { t.Fatalf("read log: %v", err) } diff --git a/internal/service/setup.go b/internal/service/setup.go index bad45f9..89e6c21 100644 --- a/internal/service/setup.go +++ b/internal/service/setup.go @@ -44,7 +44,7 @@ func RunSetup(ctrl *AppController, logger *Logger) error { // Fall back to a sensible default if the picker was dismissed. syncDir = filepath.Join(os.Getenv("USERPROFILE"), "gcrypt") } - if err := os.MkdirAll(syncDir, 0750); err != nil { + if err := os.MkdirAll(syncDir, 0750); err != nil { // #nosec G703 -- syncDir is user-chosen via the folder picker or a default under USERPROFILE messageBox("gcrypt setup", fmt.Sprintf("Could not create sync folder:\n%v", err), mbOK|mbIconError) return fmt.Errorf("service: creating sync dir: %w", err) } @@ -216,7 +216,7 @@ func runConnectExistingSetup(ctrl *AppController, logger *Logger) error { } // Read and validate the exported salt. - importedSalt, err := os.ReadFile(filepath.Join(srcDir, "salt.bin")) + importedSalt, err := os.ReadFile(filepath.Join(srcDir, "salt.bin")) // #nosec G304 -- srcDir is the import folder the user selected during setup if err != nil { messageBox("gcrypt setup", "Could not read salt.bin from the selected folder.\nMake sure you copied it from the other PC.", @@ -280,7 +280,7 @@ func runConnectExistingSetup(ctrl *AppController, logger *Logger) error { if err := os.MkdirAll(filepath.Dir(saltPath), 0750); err != nil { return fmt.Errorf("service: creating salt dir: %w", err) } - if err := os.WriteFile(saltPath, importedSalt, 0600); err != nil { + if err := os.WriteFile(saltPath, importedSalt, 0600); err != nil { // #nosec G703 -- saltPath is the app's own salt.bin under %APPDATA% return fmt.Errorf("service: saving salt: %w", err) } @@ -308,7 +308,7 @@ func runConnectExistingSetup(ctrl *AppController, logger *Logger) error { if !ok || strings.TrimSpace(syncDir) == "" { syncDir = filepath.Join(os.Getenv("USERPROFILE"), "gcrypt") } - if err := os.MkdirAll(syncDir, 0750); err != nil { + if err := os.MkdirAll(syncDir, 0750); err != nil { // #nosec G703 -- syncDir is user-chosen via the folder picker or a default under USERPROFILE messageBox("gcrypt setup", fmt.Sprintf("Could not create sync folder:\n%v", err), mbOK|mbIconError) return fmt.Errorf("service: creating sync dir: %w", err) } diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 45e63ba..7012a4d 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -304,21 +304,21 @@ func NewEngine(pair *config.SyncPair, appCfg *config.AppConfig, driveClient *dri } e := &Engine{ - pair: pair, - appCfg: appCfg, - driveClient: driveClient, - store: store, - watcher: watcher, - scanner: scanner, - masterKey: masterKey, - state: StateIdle, - activeOps: make(map[int]string), - folderCache: make(map[string]string), - workQueue: make(chan *models.SyncOperation, 1000), - errorCh: make(chan error, 256), - stateChangeCh: make(chan SyncState, 64), - syncNowCh: make(chan struct{}, 1), - OnError: make(chan error, 64), + pair: pair, + appCfg: appCfg, + driveClient: driveClient, + store: store, + watcher: watcher, + scanner: scanner, + masterKey: masterKey, + state: StateIdle, + activeOps: make(map[int]string), + folderCache: make(map[string]string), + workQueue: make(chan *models.SyncOperation, 1000), + errorCh: make(chan error, 256), + stateChangeCh: make(chan SyncState, 64), + syncNowCh: make(chan struct{}, 1), + OnError: make(chan error, 64), maxRetries: 5, workers: workers, rateLimiter: time.NewTicker(time.Second / time.Duration(reqPerSec)), @@ -538,7 +538,7 @@ func (e *Engine) notifyAppStateChange(oldState, newState appstate.State) { // to the rate at which workers drain the queue, keeping memory bounded even for // very large sync roots. func (e *Engine) scanAndEnqueue() error { - previousFiles, err := e.store.ListAll(e.pair.ID) + previousFiles, err := e.store.ListAll(e.ctx, e.pair.ID) if err != nil { return fmt.Errorf("list store: %w", err) } @@ -849,7 +849,7 @@ type ErroredFile struct { // ListErrored returns the pair's tracked files currently in the error state // (max retries exhausted). Best-effort: returns nil if the store can't be read. func (e *Engine) ListErrored() []ErroredFile { - files, err := e.store.ListAll(e.pair.ID) + files, err := e.store.ListAll(e.ctx, e.pair.ID) if err != nil { return nil } @@ -867,7 +867,7 @@ func (e *Engine) ListErrored() []ErroredFile { // the number of operations re-enqueued. It may block briefly applying queue // backpressure, so callers should not hold locks across it. func (e *Engine) RetryFailed() int { - files, err := e.store.ListAll(e.pair.ID) + files, err := e.store.ListAll(e.ctx, e.pair.ID) if err != nil { return 0 } @@ -903,7 +903,7 @@ func (e *Engine) ResolveConflictAction(localPath string, action config.ConflictP return fmt.Errorf("sync: no pending conflict for %s", localPath) } - existing, err := e.store.GetSyncFile(e.pair.ID, localPath) + existing, err := e.store.GetSyncFile(e.ctx, e.pair.ID, localPath) if err != nil || existing == nil { return fmt.Errorf("sync: no store record for %s", localPath) } @@ -939,7 +939,7 @@ func (e *Engine) backupLocalConflictCopy(localPath string) { // OnlineOnlyCount returns how many online-only placeholder files this pair has // (tracked remote files that have not been downloaded). func (e *Engine) OnlineOnlyCount() int { - files, err := e.store.ListAll(e.pair.ID) + files, err := e.store.ListAll(e.ctx, e.pair.ID) if err != nil { return 0 } @@ -956,7 +956,7 @@ func (e *Engine) OnlineOnlyCount() int { // this pair, materialising them on disk. Returns the number queued. May block // briefly on queue backpressure, so callers should not hold locks across it. func (e *Engine) MakeAvailableOffline() int { - files, err := e.store.ListAll(e.pair.ID) + files, err := e.store.ListAll(e.ctx, e.pair.ID) if err != nil { return 0 } @@ -1137,7 +1137,7 @@ func (e *Engine) processEvents() { continue } // Try to preserve existing remote ID from the store. - existing, err := e.store.GetSyncFile(e.pair.ID, ev.Path) + existing, err := e.store.GetSyncFile(e.ctx, e.pair.ID, ev.Path) if err == nil && existing != nil { // If the file is already recorded as synced with this exact // content, the event carries no real change — most commonly @@ -1156,7 +1156,7 @@ func (e *Engine) processEvents() { LocalPath: ev.Path, } // Try to get the remote ID from the store. - existing, err := e.store.GetSyncFile(e.pair.ID, ev.Path) + existing, err := e.store.GetSyncFile(e.ctx, e.pair.ID, ev.Path) if err == nil && existing != nil { sf.RemoteID = existing.RemoteID } @@ -1167,7 +1167,7 @@ func (e *Engine) processEvents() { oldSF := &models.SyncFile{ LocalPath: ev.OldPath, } - existing, err := e.store.GetSyncFile(e.pair.ID, ev.OldPath) + existing, err := e.store.GetSyncFile(e.ctx, e.pair.ID, ev.OldPath) if err == nil && existing != nil { oldSF.RemoteID = existing.RemoteID } @@ -1342,7 +1342,7 @@ func (e *Engine) runWorker(id int) { // Max retries exceeded — terminal failure. Mark the file as // errored and clear it from the backlog. if op.File != nil { - _ = e.store.UpdateStatus(e.pair.ID, op.File.LocalPath, models.SyncStatusError) + _ = e.store.UpdateStatus(e.ctx, e.pair.ID, op.File.LocalPath, models.SyncStatusError) } e.sendError(fmt.Errorf("sync: max retries exceeded for %s %s: %w", op.OpType, op.File.LocalPath, err)) @@ -1487,7 +1487,7 @@ func (e *Engine) uploadFile(sf *models.SyncFile) error { errCh <- err return } - defer f.Close() + defer func() { _ = f.Close() }() err = crypto.EncryptStream(f, pw, e.masterKey, sf.LocalPath) _ = pw.CloseWithError(err) // nil → clean EOF; non-nil → reader aborts @@ -1580,7 +1580,7 @@ func (e *Engine) uploadFile(sf *models.SyncFile) error { if sf.Version == 0 { sf.Version = 1 } - if err := e.store.PutSyncFile(sf); err != nil { + if err := e.store.PutSyncFile(e.ctx, sf); err != nil { return fmt.Errorf("sync: record uploaded file %s: %w", sf.LocalPath, err) } @@ -1703,7 +1703,7 @@ func (e *Engine) downloadFile(sf *models.SyncFile) error { if sf.Version == 0 { sf.Version = 1 } - if err := e.store.PutSyncFile(sf); err != nil { + if err := e.store.PutSyncFile(e.ctx, sf); err != nil { return fmt.Errorf("sync: record downloaded file %s: %w", sf.LocalPath, err) } @@ -1752,7 +1752,7 @@ func (e *Engine) deleteRemote(sf *models.SyncFile) error { } // 2. Delete from store. - if err := e.store.DeleteSyncFile(e.pair.ID, sf.LocalPath); err != nil { + if err := e.store.DeleteSyncFile(e.ctx, e.pair.ID, sf.LocalPath); err != nil { return fmt.Errorf("sync: delete store record %s: %w", sf.LocalPath, err) } @@ -1801,7 +1801,7 @@ func (e *Engine) deleteLocal(sf *models.SyncFile) error { } // 2. Delete from store. - if err := e.store.DeleteSyncFile(e.pair.ID, sf.LocalPath); err != nil { + if err := e.store.DeleteSyncFile(e.ctx, e.pair.ID, sf.LocalPath); err != nil { return fmt.Errorf("sync: delete store record %s: %w", sf.LocalPath, err) } @@ -1873,7 +1873,7 @@ func (e *Engine) resolveConflict(sf *models.SyncFile) error { RemoteHash: sf.RemoteHash, }) // Mark as conflict in the store so the Issues view picks it up. - _ = e.store.UpdateStatus(e.pair.ID, sf.LocalPath, models.SyncStatusConflict) + _ = e.store.UpdateStatus(e.ctx, e.pair.ID, sf.LocalPath, models.SyncStatusConflict) return nil default: // auto (last-write-wins) @@ -2013,7 +2013,7 @@ func (e *Engine) pollChanges() (relevant bool, newToken string, err error) { folderIDs := e.knownFolderIDs() tracked := make(map[string]struct{}) - if files, lerr := e.store.ListAll(e.pair.ID); lerr == nil { + if files, lerr := e.store.ListAll(e.ctx, e.pair.ID); lerr == nil { for _, sf := range files { if sf.RemoteID != "" { tracked[sf.RemoteID] = struct{}{} @@ -2077,7 +2077,7 @@ func (e *Engine) reconcileRemote() error { } // Build a set of remote IDs currently tracked in the store. - trackedFiles, err := e.store.ListAll(e.pair.ID) + trackedFiles, err := e.store.ListAll(e.ctx, e.pair.ID) if err != nil { return fmt.Errorf("list store files: %w", err) } @@ -2121,7 +2121,7 @@ func (e *Engine) reconcileRemote() error { } if e.pair.OnlineOnly { sf.SyncStatus = models.SyncStatusOnlineOnly - if perr := e.store.PutSyncFile(sf); perr != nil { + if perr := e.store.PutSyncFile(e.ctx, sf); perr != nil { e.sendError(fmt.Errorf("sync: record online-only file %s: %w", sf.LocalPath, perr)) } continue @@ -2139,7 +2139,7 @@ func (e *Engine) reconcileRemote() error { tracked.RemoteHash = rf.RemoteHash tracked.Size = rf.Size tracked.ModTime = rf.ModTime - if perr := e.store.PutSyncFile(tracked); perr != nil { + if perr := e.store.PutSyncFile(e.ctx, tracked); perr != nil { e.sendError(fmt.Errorf("sync: update online-only file %s: %w", tracked.LocalPath, perr)) } continue diff --git a/internal/sync/ignore.go b/internal/sync/ignore.go index a8be603..7537cf6 100644 --- a/internal/sync/ignore.go +++ b/internal/sync/ignore.go @@ -225,7 +225,7 @@ func (im *IgnoreMatcher) matchDoubleStar(rel, pattern string, anchored, dirOnly // LoadIgnoreFile reads a .gcrypt-ignore file and returns the list of // patterns. Empty lines and lines starting with # are skipped. func LoadIgnoreFile(path string) ([]string, error) { - f, err := os.Open(path) + f, err := os.Open(path) // #nosec G304 -- path is the .gcrypt-ignore file inside the configured sync root if err != nil { return nil, fmt.Errorf("open ignore file: %w", err) } diff --git a/internal/sync/manager.go b/internal/sync/manager.go index d2f4533..87ba7bc 100644 --- a/internal/sync/manager.go +++ b/internal/sync/manager.go @@ -246,7 +246,7 @@ func (m *SyncManager) addPairLocked(pair *config.SyncPair, async bool) error { m.engines[pair.ID] = engine // Start per-engine error forwarding. - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(context.Background()) // #nosec G118 -- cancel is stored in m.engineCancels and invoked on stop/remove m.engineCancels[pair.ID] = cancel go m.forwardEngineErrors(ctx, pair.ID, engine) diff --git a/internal/sync/metered_windows.go b/internal/sync/metered_windows.go index c0f467e..cc91d3d 100644 --- a/internal/sync/metered_windows.go +++ b/internal/sync/metered_windows.go @@ -8,13 +8,6 @@ import ( "golang.org/x/sys/windows" ) -// NLM_CONNECTIVITY flags (subset). -const ( - nlmConnectivityDisconnected = 0x0000 - nlmConnectivityIPV4Internet = 0x0040 - nlmConnectivityIPV6Internet = 0x0400 -) - // NLM_CONNECTION_COST flags. const ( nlmConnectionCostUnknown = 0x0000 @@ -26,8 +19,8 @@ const ( ) var ( - modOle32 = windows.NewLazySystemDLL("ole32.dll") - procCoCreateInstance = modOle32.NewProc("CoCreateInstance") + modOle32 = windows.NewLazySystemDLL("ole32.dll") + procCoCreateInstance = modOle32.NewProc("CoCreateInstance") modNlmAPI = windows.NewLazySystemDLL("nlmapi.dll") //nolint:unused // referenced via CLSID ) @@ -77,11 +70,11 @@ func IsMeteredNetwork() bool { var pUnk unsafe.Pointer hr, _, _ = procCoCreateInstance.Call( - uintptr(unsafe.Pointer(&clsidNetworkListManager)), + uintptr(unsafe.Pointer(&clsidNetworkListManager)), // #nosec G103 -- required Win32/COM syscall pointer marshalling 0, 1|4, // CLSCTX_INPROC_SERVER|CLSCTX_LOCAL_SERVER - uintptr(unsafe.Pointer(&iidNetworkCostManager)), - uintptr(unsafe.Pointer(&pUnk)), + uintptr(unsafe.Pointer(&iidNetworkCostManager)), // #nosec G103 -- required Win32/COM syscall pointer marshalling + uintptr(unsafe.Pointer(&pUnk)), // #nosec G103 -- required Win32/COM syscall pointer marshalling ) if hr != 0 || pUnk == nil { slog.Debug("metered: CoCreateInstance failed", "hr", hr) @@ -95,7 +88,7 @@ func IsMeteredNetwork() bool { }() var cost uint32 - hr, _, _ = syscall.SyscallN(mgr.vtbl.GetCost, uintptr(pUnk), uintptr(unsafe.Pointer(&cost)), 0) + hr, _, _ = syscall.SyscallN(mgr.vtbl.GetCost, uintptr(pUnk), uintptr(unsafe.Pointer(&cost)), 0) // #nosec G103 -- required Win32/COM syscall pointer marshalling if hr != 0 { slog.Debug("metered: GetCost failed", "hr", hr) return false diff --git a/internal/sync/scanner.go b/internal/sync/scanner.go index 79e7119..19699a4 100644 --- a/internal/sync/scanner.go +++ b/internal/sync/scanner.go @@ -19,7 +19,6 @@ import ( "golang.org/x/sync/errgroup" ) - // hashCacheEntry is a cached content hash together with the file stamp it was // computed from, so the cache can be invalidated when the file changes. type hashCacheEntry struct { @@ -44,7 +43,7 @@ type persistedHashEntry struct { // against the live file stamp on use, so a stale entry can never return a wrong // hash — at worst it forces a recompute. func (s *Scanner) LoadHashCache(path string) error { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) // #nosec G304 -- path is the app's own hash-cache file under its data dir if err != nil { return err } @@ -399,7 +398,7 @@ func (s *Scanner) ComputeHash(path string) (string, error) { } } - f, err := os.Open(absPath) + f, err := os.Open(absPath) // #nosec G304 -- absPath is a file within the configured sync root being hashed if err != nil { return "", fmt.Errorf("open for hash: %w", err) } diff --git a/internal/sync/watcher.go b/internal/sync/watcher.go index b2422e1..6a807b7 100644 --- a/internal/sync/watcher.go +++ b/internal/sync/watcher.go @@ -218,8 +218,8 @@ func (w *Watcher) readDirectoryChanges(dir string) { err := windows.ReadDirectoryChanges( handle, &buf[0], - uint32(len(buf)), - true, // watch subtree + uint32(len(buf)), // #nosec G115 -- buf is a fixed small buffer; its length always fits in uint32 + true, // watch subtree notifyFilter, &bytesReturned, &overlapped, @@ -291,15 +291,12 @@ func (w *Watcher) parseNotifyBuffer(buf []byte, watchDir string) { var renameOldPath string offset := 0 - for { - if offset+12 > len(buf) { - break - } + for offset+12 <= len(buf) { // Read the fixed header fields. - nextEntryOffset := *(*uint32)(unsafe.Pointer(&buf[offset])) - action := *(*uint32)(unsafe.Pointer(&buf[offset+4])) - fileNameLength := *(*uint32)(unsafe.Pointer(&buf[offset+8])) + nextEntryOffset := *(*uint32)(unsafe.Pointer(&buf[offset])) // #nosec G103 -- parsing the FILE_NOTIFY_INFORMATION buffer returned by Win32 + action := *(*uint32)(unsafe.Pointer(&buf[offset+4])) // #nosec G103 -- parsing the FILE_NOTIFY_INFORMATION buffer returned by Win32 + fileNameLength := *(*uint32)(unsafe.Pointer(&buf[offset+8])) // #nosec G103 -- parsing the FILE_NOTIFY_INFORMATION buffer returned by Win32 if offset+12+int(fileNameLength) > len(buf) { break @@ -309,7 +306,7 @@ func (w *Watcher) parseNotifyBuffer(buf []byte, watchDir string) { fileNameBytes := buf[offset+12 : offset+12+int(fileNameLength)] fileNameUTF16 := make([]uint16, fileNameLength/2) for i := range fileNameUTF16 { - fileNameUTF16[i] = *(*uint16)(unsafe.Pointer(&fileNameBytes[i*2])) + fileNameUTF16[i] = *(*uint16)(unsafe.Pointer(&fileNameBytes[i*2])) // #nosec G103 -- decoding the UTF-16 filename from the Win32 notify buffer } fileName := string(utf16.Decode(fileNameUTF16)) @@ -470,4 +467,3 @@ func (w *Watcher) shouldIgnore(relPath string) bool { return false } - diff --git a/tools/gccwrap/main.go b/tools/gccwrap/main.go index f4f74b2..80638ed 100644 --- a/tools/gccwrap/main.go +++ b/tools/gccwrap/main.go @@ -12,6 +12,7 @@ package main import ( + "context" "os" "os/exec" "strings" @@ -43,20 +44,20 @@ func main() { if real == "" { p, err := exec.LookPath("gcc") if err != nil { - os.Stderr.WriteString("gccwrap: cannot find real gcc on PATH: " + err.Error() + "\n") + _, _ = os.Stderr.WriteString("gccwrap: cannot find real gcc on PATH: " + err.Error() + "\n") os.Exit(1) } // Guard against recursion: if the wrapper was named gcc.exe and placed on // PATH, LookPath("gcc") could resolve back to this binary. Refuse rather // than fork-bomb. if self, serr := os.Executable(); serr == nil && sameFile(self, p) { - os.Stderr.WriteString("gccwrap: refusing to invoke self (resolved gcc is this wrapper at " + p + "); set REAL_CC to the real gcc\n") + _, _ = os.Stderr.WriteString("gccwrap: refusing to invoke self (resolved gcc is this wrapper at " + p + "); set REAL_CC to the real gcc\n") os.Exit(1) } real = p } - cmd := exec.Command(real, args...) + cmd := exec.CommandContext(context.Background(), real, args...) // #nosec G204 G702 -- this wrapper exists precisely to forward args to the real gcc (REAL_CC/PATH) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -64,7 +65,7 @@ func main() { if ee, ok := err.(*exec.ExitError); ok { os.Exit(ee.ExitCode()) } - os.Stderr.WriteString("gccwrap: " + err.Error() + "\n") + _, _ = os.Stderr.WriteString("gccwrap: " + err.Error() + "\n") os.Exit(1) } } From c47d32a1c478dbe1e0461aa2e1ec969e77dc5f94 Mon Sep 17 00:00:00 2001 From: arumes31 <114224498+arumes31@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:10:18 +0200 Subject: [PATCH 2/5] ci: run lint/test on windows-latest (Windows-only app); split gitleaks to its own Linux job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app imports golang.org/x/sys/windows and uses cgo (Fyne), so it only compiles on Windows — golangci-lint/gosec/govulncheck/go test must run there. gitleaks (a Linux-only Docker action) moves to a separate secret-scan job. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4feb26..0935034 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,13 @@ on: jobs: lint-and-test: name: Lint and Test - runs-on: ubuntu-latest + # gcrypt is a Windows-only application: several packages import + # golang.org/x/sys/windows and the GUI uses cgo (Fyne/GLFW). It therefore + # only compiles on Windows, so linting, vetting, security scanning and tests + # all run on a Windows runner (which ships a cgo-capable gcc). + runs-on: windows-latest + env: + CGO_ENABLED: "1" steps: - uses: actions/checkout@v6 with: @@ -21,11 +27,6 @@ jobs: go-version-file: 'go.mod' cache: true - # Fyne (the GUI toolkit) uses cgo + OpenGL/X11, so linting, vetting, - # testing and building all need these system headers on Linux. - - name: Install Fyne build dependencies - run: sudo apt-get update && sudo apt-get install -y gcc libgl1-mesa-dev xorg-dev libxkbcommon-dev - - name: Verify dependencies run: go mod verify @@ -35,23 +36,31 @@ jobs: version: latest - name: Run Gosec Security Scanner - uses: securego/gosec@master - with: - args: ./... + run: | + go install github.com/securego/gosec/v2/cmd/gosec@latest + gosec ./... - name: Run Govulncheck run: | go install golang.org/x/vuln/cmd/govulncheck@latest govulncheck ./... + - name: Run Tests + run: go test -v ./... + + secret-scan: + name: Secret Scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Gitleaks Scan uses: gitleaks/gitleaks-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Run Tests - run: go test -v ./... - build-windows: name: Build Windows Binary runs-on: windows-latest From b054163677cf04b80ad0ec21306894610d1b9811 Mon Sep 17 00:00:00 2001 From: arumes31 <114224498+arumes31@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:21:27 +0200 Subject: [PATCH 3/5] ci: add .gitattributes forcing LF for Go/text files GitHub's Windows CI runner checks out with CRLF (autocrlf), which makes gofmt flag every file as unformatted. Forcing LF for source and text config files keeps gofmt (and the build) consistent across platforms. Co-Authored-By: Claude Opus 4.8 --- .gitattributes | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..17daa4b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +# gcrypt builds on Windows and its CI lints on a Windows runner. gofmt requires +# LF line endings, but Git's autocrlf would otherwise check files out as CRLF on +# Windows, making every file fail the gofmt check. Force LF for source and text +# config files so checkouts are consistent on every platform. +*.go text eol=lf +go.mod text eol=lf +go.sum text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +*.md text eol=lf + +# Binary assets must never be line-ending normalised. +*.png binary +*.ico binary +*.icns binary From 1eb1a19d5a97671f99a2da919a929698b3fb8ffd Mon Sep 17 00:00:00 2001 From: arumes31 <114224498+arumes31@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:37:47 +0200 Subject: [PATCH 4/5] ci: build the cmd/gcrypt package, not just main.go main.go relies on sibling files in the package (console/opengl/single-instance helpers); building the single file left them undefined. Build the package and enable cgo explicitly. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0935034..e183f74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,11 @@ jobs: cache: true - name: Build - run: go build -o gcrypt.exe ./cmd/gcrypt/main.go + # Build the whole package, not just main.go — the package is split across + # several files (console/opengl/single-instance helpers) that main.go needs. + run: go build -o gcrypt.exe ./cmd/gcrypt + env: + CGO_ENABLED: "1" - name: Upload Artifact uses: actions/upload-artifact@v7 From 6731c4155d2482b055af476e010c13da0f775797 Mon Sep 17 00:00:00 2001 From: arumes31 <114224498+arumes31@users.noreply.github.com> Date: Fri, 19 Jun 2026 09:38:06 +0200 Subject: [PATCH 5/5] ci: drop Docker and goreleaser (not viable for a Windows-only cgo app) Remove the publish-docker CI job, Dockerfile, .dockerignore, the goreleaser release workflow, and .goreleaser.yaml. All built the Windows-only Fyne/cgo app on Linux with CGO_ENABLED=0, so they could never succeed. Releases are produced from a Windows build (see build.ps1); CI's build-windows job verifies compilation. Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 12 --------- .github/workflows/ci.yml | 36 --------------------------- .github/workflows/release.yml | 33 ------------------------ .goreleaser.yaml | 47 ----------------------------------- Dockerfile | 39 ----------------------------- 5 files changed, 167 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .github/workflows/release.yml delete mode 100644 .goreleaser.yaml delete mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index f2a61bc..0000000 --- a/.dockerignore +++ /dev/null @@ -1,12 +0,0 @@ -.git -.github -.vscode -.gcrypt-state -gcrypt.exe -*.log -*.db -tokens.enc -config.yaml -node_modules -bin -obj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e183f74..c144f44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,39 +86,3 @@ jobs: with: name: gcrypt-windows path: gcrypt.exe - - publish-docker: - name: Build and Push Docker Image - runs-on: ubuntu-latest - needs: lint-and-test - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v6 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v6 - with: - images: ghcr.io/${{ github.repository }} - tags: | - type=sha - type=ref,event=branch - latest - - - name: Build and push Docker image - uses: docker/build-push-action@v7 - with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 1d0a526..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*' - -permissions: - contents: write - -jobs: - goreleaser: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: 'go.mod' - cache: true - - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 - with: - distribution: goreleaser - version: latest - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index adc8844..0000000 --- a/.goreleaser.yaml +++ /dev/null @@ -1,47 +0,0 @@ -# This is a sample .goreleaser.yaml file with some sensible defaults. -# For more info, check out https://goreleaser.com -version: 2 - -before: - hooks: - - go mod tidy - -builds: - - env: - - CGO_ENABLED=0 - goos: - - linux - - windows - - darwin - goarch: - - amd64 - - arm64 - main: ./cmd/gcrypt/main.go - binary: gcrypt - ldflags: - - -s -w -H=windowsgui -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} - -archives: - - name_template: >- - {{ .ProjectName }}_ - {{- title .Os }}_ - {{- if eq .Arch "amd64" }}x86_64 - {{- else if eq .Arch "386" }}i386 - {{- else }}{{ .Arch }}{{ end }} - {{- if .Arm }}v{{ .Arm }}{{ end }} - format_overrides: - - goos: windows - format: zip - -checksum: - name_template: 'checksums.txt' - -snapshot: - name_template: "{{ incpatch .Version }}-next" - -changelog: - sort: asc - filters: - exclude: - - '^docs:' - - '^test:' diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index a5b1bb7..0000000 --- a/Dockerfile +++ /dev/null @@ -1,39 +0,0 @@ -# Multi-stage build for gcrypt -FROM golang:1.22-alpine AS builder - -# Install build dependencies -RUN apk add --no-cache git gcc musl-dev - -WORKDIR /app - -# Copy go mod and sum files -COPY go.mod go.sum ./ -RUN go mod download - -# Copy source code -COPY . . - -# Build the application -# We use CGO_ENABLED=0 for a static binary if possible, -# but systray/sqlite might need CGO on some platforms. -# For a headless Linux container, we might skip the tray. -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gcrypt ./cmd/gcrypt/main.go - -# Final stage -FROM alpine:latest - -RUN apk add --no-cache ca-certificates tzdata - -WORKDIR /root/ - -# Copy the binary from the builder -COPY --from=builder /app/gcrypt . - -# Create a directory for the sync folder -RUN mkdir -p /data/sync - -# Set environment variables -ENV GCRYPT_CONFIG_PATH=/root/.config/gcrypt/config.yaml - -# Command to run -ENTRYPOINT ["./gcrypt"]