From b6d75ffb715590f86e92c22262953f9a368a2a25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Otto=20Kr=C3=B6pke?= Date: Thu, 8 Oct 2026 19:15:23 +0200 Subject: [PATCH] ole: replace go-ole with native COM bindings (#2551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jan-Otto Kröpke --- go.mod | 1 - go.sum | 4 - .../scheduled_task/scheduled_task.go | 225 ++++--------- .../scheduled_task_native_test.go | 190 +++++++++++ internal/collector/update/update.go | 241 +++++--------- .../collector/update/update_internal_test.go | 214 ++++++++++++ internal/headers/cfgmgr32/types.go | 8 +- internal/headers/iphlpapi/types.go | 4 +- internal/ole/com_windows.go | 309 ++++++++++++++++++ internal/ole/com_windows_test.go | 266 +++++++++++++++ internal/ole/hresult_other.go | 24 ++ internal/ole/hresult_windows.go | 53 +++ internal/ole/hresult_windows_test.go | 42 +++ internal/ole/ole.go | 117 +++++++ internal/ole/ole_test.go | 201 ++++++++++++ internal/ole/taskschd/taskschd_windows.go | 134 ++++++++ .../ole/taskschd/taskschd_windows_test.go | 135 ++++++++ internal/ole/wuapi/wuapi_windows.go | 155 +++++++++ internal/ole/wuapi/wuapi_windows_test.go | 58 ++++ 19 files changed, 2049 insertions(+), 332 deletions(-) create mode 100644 internal/collector/scheduled_task/scheduled_task_native_test.go create mode 100644 internal/collector/update/update_internal_test.go create mode 100644 internal/ole/com_windows.go create mode 100644 internal/ole/com_windows_test.go create mode 100644 internal/ole/hresult_other.go create mode 100644 internal/ole/hresult_windows.go create mode 100644 internal/ole/hresult_windows_test.go create mode 100644 internal/ole/ole.go create mode 100644 internal/ole/ole_test.go create mode 100644 internal/ole/taskschd/taskschd_windows.go create mode 100644 internal/ole/taskschd/taskschd_windows_test.go create mode 100644 internal/ole/wuapi/wuapi_windows.go create mode 100644 internal/ole/wuapi/wuapi_windows_test.go diff --git a/go.mod b/go.mod index c592e9ce..9ee72b6f 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.27.1 require ( github.com/alecthomas/kingpin/v2 v2.4.0 github.com/bmatcuk/doublestar/v4 v4.10.2 - github.com/go-ole/go-ole v1.3.1-0.20250305162226-6867ec158e36 github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_model v0.6.3 github.com/prometheus/common v0.72.0 diff --git a/go.sum b/go.sum index e5470f87..d13278ca 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,6 @@ github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-ole/go-ole v1.3.1-0.20250305162226-6867ec158e36 h1:PTRdMI7SqYj0FIO+xrw1gWgjB3ygEh1ME45PB0T+npQ= -github.com/go-ole/go-ole v1.3.1-0.20250305162226-6867ec158e36/go.mod h1:2ip94f67up8I6Po4OnI0rjMeULNtsGHkQOLwhRZl5qk= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -53,7 +51,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/xhit/go-str2duration/v2 v2.2.0 h1:SofrD1D+8IuhhkP+OiG1ij4Zq+FiiYSEY/sORBanDG0= @@ -72,7 +69,6 @@ golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= diff --git a/internal/collector/scheduled_task/scheduled_task.go b/internal/collector/scheduled_task/scheduled_task.go index 69965860..2575ad4c 100644 --- a/internal/collector/scheduled_task/scheduled_task.go +++ b/internal/collector/scheduled_task/scheduled_task.go @@ -27,9 +27,9 @@ import ( "time" "github.com/alecthomas/kingpin/v2" - "github.com/go-ole/go-ole" - "github.com/go-ole/go-ole/oleutil" "github.com/prometheus-community/windows_exporter/internal/mi" + "github.com/prometheus-community/windows_exporter/internal/ole" + "github.com/prometheus-community/windows_exporter/internal/ole/taskschd" "github.com/prometheus-community/windows_exporter/internal/types" "github.com/prometheus/client_golang/prometheus" ) @@ -292,232 +292,147 @@ func (c *Collector) collectMetrics(ch chan<- prometheus.Metric, scheduledTasks S } } -// CLSID_TaskScheduler {0F87369F-A4E5-4CFC-BD3E-73E6154572DD}: https://github.com/microsoft/win32metadata/blob/76c04c2021ef4a831a6f1e06d9566002d746139b/generation/WinSDK/RecompiledIdlHeaders/um/taskschd.h#L10359 -// -//nolint:gochecknoglobals // CLSID, not ProgID: go-ole's CLSIDFromProgID can free the ProgID buffer mid-call -var taskSchedulerCLSID = ole.GUID{ - Data1: 0x0F87369F, - Data2: 0xA4E5, - Data3: 0x4CFC, - Data4: [8]byte{0xBD, 0x3E, 0x73, 0xE6, 0x15, 0x45, 0x72, 0xDD}, -} - -// S_FALSE is returned by CoInitialize if it was already called on this thread. -const S_FALSE = 0x00000001 - -// errTasksSkipped is returned with the readable tasks if some tasks or folders could not be read. +// errTasksSkipped accompanies readable tasks when some tasks or folders could not be read. var errTasksSkipped = errors.New("tasks skipped") func getScheduledTasks() (ScheduledTasks, error) { - var scheduledTasks ScheduledTasks - - // The only way to run WMI queries in parallel while being thread-safe is to - // ensure the CoInitialize[Ex]() call is bound to its current OS thread. - // Otherwise, attempting to initialize and run parallel queries across - // goroutines will result in protected memory errors. + // COM initialization and every interface call stay on the same OS thread. runtime.LockOSThread() defer runtime.UnlockOSThread() - if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED|ole.COINIT_DISABLE_OLE1DDE); err != nil { - if oleCode, ok := errors.AsType[*ole.OleError](err); ok && oleCode.Code() != ole.S_OK && oleCode.Code() != S_FALSE { - return nil, err - } + if err := ole.Initialize(); err != nil { + return nil, err } - defer ole.CoUninitialize() + defer ole.Uninitialize() - taskSchedulerObj, err := ole.CreateInstance(&taskSchedulerCLSID, nil) - if err != nil || taskSchedulerObj == nil { - return scheduledTasks, err - } - defer taskSchedulerObj.Release() - - taskServiceObj, err := taskSchedulerObj.QueryInterface(ole.IID_IDispatch) + service, err := taskschd.NewTaskService() if err != nil { - return scheduledTasks, fmt.Errorf("IID_IDispatch: %w", err) + return nil, fmt.Errorf("create Task Scheduler service: %w", err) + } + defer service.Release() + + if err := service.Connect(); err != nil { + return nil, fmt.Errorf("connect Task Scheduler service: %w", err) } - defer taskServiceObj.Release() - - _, err = oleutil.CallMethod(taskServiceObj, "Connect") + root, err := service.Folder(`\`) if err != nil { - return scheduledTasks, err + return nil, fmt.Errorf("get root task folder: %w", err) + } + defer root.Release() + + tasks := ScheduledTasks{} + if err := fetchTasksRecursively(root, `\`, &tasks); err != nil { + return tasks, fmt.Errorf("%w: %w", errTasksSkipped, err) } - res, err := oleutil.CallMethod(taskServiceObj, "GetFolder", `\`) - if err != nil { - return scheduledTasks, err - } - - rootFolderObj := res.ToIDispatch() - defer rootFolderObj.Release() - - if err = fetchTasksRecursively(rootFolderObj, `\`, &scheduledTasks); err != nil { - return scheduledTasks, fmt.Errorf("%w: %w", errTasksSkipped, err) - } - - return scheduledTasks, nil + return tasks, nil } -// fetchTasksInFolder appends the tasks of folder to scheduledTasks. -// A task that can't be read is skipped and its error is returned after all other tasks are read. -func fetchTasksInFolder(folder *ole.IDispatch, scheduledTasks *ScheduledTasks) error { - res, err := oleutil.CallMethod(folder, "GetTasks", 1) +// fetchTasksInFolder appends readable tasks and reports errors after reading the remaining tasks. +func fetchTasksInFolder(folder *taskschd.TaskFolder, scheduledTasks *ScheduledTasks) error { + tasks, err := folder.Tasks() if err != nil { return fmt.Errorf("get tasks: %w", err) } - - tasks := res.ToIDispatch() defer tasks.Release() - errs := make([]error, 0) + errs := []error{} - err = oleutil.ForEach(tasks, func(v *ole.VARIANT) error { - task := v.ToIDispatch() - defer task.Release() + for task, err := range tasks.All() { + if err != nil { + errs = append(errs, fmt.Errorf("enumerate tasks: %w", err)) + + continue + } parsedTask, err := parseTask(task) if err != nil { errs = append(errs, fmt.Errorf("parse task: %w", err)) - return nil + continue } *scheduledTasks = append(*scheduledTasks, parsedTask) - - return nil - }) - if err != nil { - errs = append(errs, fmt.Errorf("enumerate tasks: %w", err)) } return errors.Join(errs...) } -// fetchTasksRecursively appends the tasks of folder and its sub folders to scheduledTasks. -// A folder or task that can't be read is skipped and its error is returned after all other -// folders are read. -func fetchTasksRecursively(folder *ole.IDispatch, folderPath string, scheduledTasks *ScheduledTasks) error { - errs := make([]error, 0) - +// fetchTasksRecursively appends readable tasks, retaining errors from skipped folders or tasks. +func fetchTasksRecursively(folder *taskschd.TaskFolder, folderPath string, scheduledTasks *ScheduledTasks) error { + errs := []error{} if err := fetchTasksInFolder(folder, scheduledTasks); err != nil { errs = append(errs, fmt.Errorf("folder %s: %w", folderPath, err)) } - res, err := oleutil.CallMethod(folder, "GetFolders", 1) + folders, err := folder.Folders() if err != nil { return errors.Join(append(errs, fmt.Errorf("folder %s: get sub folders: %w", folderPath, err))...) } + defer folders.Release() - subFolders := res.ToIDispatch() - defer subFolders.Release() + for subfolder, err := range folders.All() { + if err != nil { + errs = append(errs, fmt.Errorf("folder %s: enumerate sub folders: %w", folderPath, err)) - err = oleutil.ForEach(subFolders, func(v *ole.VARIANT) error { - subFolder := v.ToIDispatch() - defer subFolder.Release() - - subFolderPath := folderPath - - if pathVar, err := oleutil.GetProperty(subFolder, "Path"); err == nil { - subFolderPath = pathVar.ToString() - _ = pathVar.Clear() + continue } - if err := fetchTasksRecursively(subFolder, subFolderPath, scheduledTasks); err != nil { + subfolderPath := folderPath + if path, err := subfolder.Path(); err == nil { + subfolderPath = path + } + + if err := fetchTasksRecursively(subfolder, subfolderPath, scheduledTasks); err != nil { errs = append(errs, err) } - - return nil - }) - if err != nil { - errs = append(errs, fmt.Errorf("folder %s: enumerate sub folders: %w", folderPath, err)) } return errors.Join(errs...) } -func parseTask(task *ole.IDispatch) (ScheduledTask, error) { - var scheduledTask ScheduledTask - - taskNameVar, err := oleutil.GetProperty(task, "Name") +func parseTask(task *taskschd.RegisteredTask) (ScheduledTask, error) { + name, err := task.Name() if err != nil { - return scheduledTask, err + return ScheduledTask{}, fmt.Errorf("get task name: %w", err) } - defer func() { - if tempErr := taskNameVar.Clear(); tempErr != nil { - err = tempErr - } - }() - - taskPathVar, err := oleutil.GetProperty(task, "Path") + path, err := task.Path() if err != nil { - return scheduledTask, err + return ScheduledTask{}, fmt.Errorf("get task path: %w", err) } - defer func() { - if tempErr := taskPathVar.Clear(); tempErr != nil { - err = tempErr - } - }() - - taskEnabledVar, err := oleutil.GetProperty(task, "Enabled") + enabled, err := task.Enabled() if err != nil { - return scheduledTask, err + return ScheduledTask{}, fmt.Errorf("get task enabled: %w", err) } - defer func() { - if tempErr := taskEnabledVar.Clear(); tempErr != nil { - err = tempErr - } - }() - - taskStateVar, err := oleutil.GetProperty(task, "State") + state, err := task.State() if err != nil { - return scheduledTask, err + return ScheduledTask{}, fmt.Errorf("get task state: %w", err) } - defer func() { - if tempErr := taskStateVar.Clear(); tempErr != nil { - err = tempErr - } - }() - - taskNumberOfMissedRunsVar, err := oleutil.GetProperty(task, "NumberOfMissedRuns") + missed, err := task.NumberOfMissedRuns() if err != nil { - return scheduledTask, err + return ScheduledTask{}, fmt.Errorf("get task missed runs: %w", err) } - defer func() { - if tempErr := taskNumberOfMissedRunsVar.Clear(); tempErr != nil { - err = tempErr - } - }() - - taskLastTaskResultVar, err := oleutil.GetProperty(task, "LastTaskResult") + result, err := task.LastTaskResult() if err != nil { - return scheduledTask, err + return ScheduledTask{}, fmt.Errorf("get task last result: %w", err) } - defer func() { - if tempErr := taskLastTaskResultVar.Clear(); tempErr != nil { - err = tempErr - } - }() - - scheduledTask.Name = taskNameVar.ToString() - scheduledTask.Path = strings.ReplaceAll(taskPathVar.ToString(), "\\", "/") - - if val, ok := taskEnabledVar.Value().(bool); ok { - scheduledTask.Enabled = val - } - - scheduledTask.State = TaskState(taskStateVar.Val) - scheduledTask.MissedRunsCount = float64(taskNumberOfMissedRunsVar.Val) - scheduledTask.LastTaskResult = TaskResult(taskLastTaskResultVar.Val) - - return scheduledTask, err + return ScheduledTask{ + Name: name, + Path: strings.ReplaceAll(path, "\\", "/"), + Enabled: enabled, + State: TaskState(state), + MissedRunsCount: float64(missed), + LastTaskResult: TaskResult(result), + }, nil } func (t TaskState) String() string { diff --git a/internal/collector/scheduled_task/scheduled_task_native_test.go b/internal/collector/scheduled_task/scheduled_task_native_test.go new file mode 100644 index 00000000..031cc2d2 --- /dev/null +++ b/internal/collector/scheduled_task/scheduled_task_native_test.go @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows && (amd64 || arm64) + +package scheduled_task + +import ( + "testing" + + "github.com/prometheus-community/windows_exporter/internal/ole" + "github.com/prometheus-community/windows_exporter/internal/ole/taskschd" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +func TestFetchTasksInFolderPartialCollection(t *testing.T) { + for _, tc := range []struct { + name string + countError bool + }{ + {name: "skip unreadable item"}, + {name: "count failure", countError: true}, + } { + t.Run(tc.name, func(t *testing.T) { + var released, collectionsReleased int + + items := []*ole.Object{fakeScheduledTask(1, &released), nil, fakeScheduledTask(3, &released)} + collection, attempted := fakeTaskCollection(items, tc.countError, &collectionsReleased) + folder := fakeTaskFolder(collection, nil, nil) + tasks := ScheduledTasks{} + + err := fetchTasksInFolder(folder, &tasks) + require.ErrorIs(t, err, ole.HRESULT(0x80070005)) + require.Equal(t, 1, collectionsReleased) + + if tc.countError { + require.Empty(t, *attempted) + require.Empty(t, tasks) + require.Zero(t, released) + + return + } + + require.Equal(t, []int64{1, 2, 3}, *attempted) + require.Len(t, tasks, 2) + require.Equal(t, TaskState(1), tasks[0].State) + require.Equal(t, TaskState(3), tasks[1].State) + require.Equal(t, 2, released) + }) + } +} + +func TestFetchTasksRecursivelyPartialFolderCollection(t *testing.T) { + var tasksReleased, foldersReleased, collectionsReleased int + + children := make([]*ole.Object, 0, 2) + + for _, state := range []int32{1, 3} { + tasks, _ := fakeTaskCollection([]*ole.Object{fakeScheduledTask(state, &tasksReleased)}, false, &collectionsReleased) + folders, _ := fakeTaskCollection(nil, false, &collectionsReleased) + child := fakeTaskFolder(tasks, folders, &foldersReleased) + children = append(children, &child.Object) + } + + folders, attempted := fakeTaskCollection([]*ole.Object{children[0], nil, children[1]}, false, &collectionsReleased) + emptyTasks, _ := fakeTaskCollection(nil, false, &collectionsReleased) + root := fakeTaskFolder(emptyTasks, folders, nil) + tasks := ScheduledTasks{} + + err := fetchTasksRecursively(root, `\`, &tasks) + require.ErrorIs(t, err, ole.HRESULT(0x80070005)) + require.Equal(t, []int64{1, 2, 3}, *attempted) + require.Len(t, tasks, 2) + require.Equal(t, TaskState(1), tasks[0].State) + require.Equal(t, TaskState(3), tasks[1].State) + require.Equal(t, 2, tasksReleased) + require.Equal(t, 2, foldersReleased) + require.Equal(t, 6, collectionsReleased) +} + +// The fake native collections use the Task Scheduler's one-based VARIANT indices. +func fakeTaskCollection(items []*ole.Object, countError bool, released *int) (*ole.Object, *[]int64) { + var methods [9]uintptr + + attempted := []int64{} + methods[2] = windows.NewCallback(func(uintptr) uintptr { + *released++ + + return 0 + }) + methods[7] = windows.NewCallback(func(_ uintptr, out *int32) uintptr { + if countError { + return 0x80070005 + } + + *out = int32(len(items)) + + return 0 + }) + methods[8] = windows.NewCallback(func(_ uintptr, index *ole.Variant, out **ole.Object) uintptr { + attempted = append(attempted, index.Value) + + item := items[index.Value-1] + if item == nil { + return 0x80070005 + } + + *out = item + + return 0 + }) + + return &ole.Object{VTable: &methods[0]}, &attempted +} + +func fakeTaskFolder(tasks, folders *ole.Object, released *int) *taskschd.TaskFolder { + var methods [15]uintptr + + methods[2] = windows.NewCallback(func(uintptr) uintptr { + if released != nil { + *released++ + } + + return 0 + }) + methods[8] = windows.NewCallback(func(_ uintptr, out *uintptr) uintptr { + *out = 0 // A null BSTR is a valid empty string. + + return 0 + }) + methods[10] = windows.NewCallback(func(_ uintptr, _ uintptr, out **ole.Object) uintptr { + *out = folders + + return 0 + }) + methods[14] = windows.NewCallback(func(_ uintptr, _ uintptr, out **ole.Object) uintptr { + *out = tasks + + return 0 + }) + + return &taskschd.TaskFolder{VTable: &methods[0]} +} + +func fakeScheduledTask(state int32, released *int) *ole.Object { + var methods [18]uintptr + + methods[2] = windows.NewCallback(func(uintptr) uintptr { + *released++ + + return 0 + }) + stringGetter := windows.NewCallback(func(_ uintptr, out *uintptr) uintptr { + *out = 0 + + return 0 + }) + methods[7], methods[8] = stringGetter, stringGetter + methods[9] = windows.NewCallback(func(_ uintptr, out *int32) uintptr { + *out = state + + return 0 + }) + methods[10] = windows.NewCallback(func(_ uintptr, out *int16) uintptr { + *out = 0 + + return 0 + }) + intGetter := windows.NewCallback(func(_ uintptr, out *int32) uintptr { + *out = 0 + + return 0 + }) + methods[16], methods[17] = intGetter, intGetter + + return &ole.Object{VTable: &methods[0]} +} diff --git a/internal/collector/update/update.go b/internal/collector/update/update.go index 74b44df1..477284ea 100644 --- a/internal/collector/update/update.go +++ b/internal/collector/update/update.go @@ -29,9 +29,9 @@ import ( "time" "github.com/alecthomas/kingpin/v2" - "github.com/go-ole/go-ole" - "github.com/go-ole/go-ole/oleutil" "github.com/prometheus-community/windows_exporter/internal/mi" + "github.com/prometheus-community/windows_exporter/internal/ole" + "github.com/prometheus-community/windows_exporter/internal/ole/wuapi" "github.com/prometheus-community/windows_exporter/internal/types" "github.com/prometheus-community/windows_exporter/internal/utils/recovery" "github.com/prometheus/client_golang/prometheus" @@ -39,16 +39,6 @@ import ( const Name = "update" -// CLSID_UpdateSession {4CB43D7F-7EEE-4906-8698-60DA1C38F2FE}: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-uamg/e839e7e0-1795-451b-94ef-abacd6cbecac -// -//nolint:gochecknoglobals // CLSID, not ProgID: go-ole's CLSIDFromProgID can free the ProgID buffer mid-call -var updateSessionCLSID = ole.GUID{ - Data1: 0x4CB43D7F, - Data2: 0x7EEE, - Data3: 0x4906, - Data4: [8]byte{0x86, 0x98, 0x60, 0xDA, 0x1C, 0x38, 0xF2, 0xFE}, -} - type Config struct { Online bool `yaml:"online"` ScrapeInterval time.Duration `yaml:"scrape-interval"` @@ -204,93 +194,56 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error } func (c *Collector) scheduleUpdateStatus(ctx context.Context, logger *slog.Logger, initErrCh chan<- error, online bool) { - // The only way to run WMI queries in parallel while being thread-safe is to - // ensure the CoInitialize[Ex]() call is bound to its current OS thread. - // Otherwise, attempting to initialize and run parallel queries across - // goroutines will result in protected memory errors. + // COM initialization and every interface call stay on the same OS thread. runtime.LockOSThread() defer runtime.UnlockOSThread() - if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED|ole.COINIT_DISABLE_OLE1DDE); err != nil { - var oleCode *ole.OleError - if errors.As(err, &oleCode) && oleCode.Code() != ole.S_OK && oleCode.Code() != 0x00000001 { - initErrCh <- fmt.Errorf("CoInitializeEx: %w", err) + if err := ole.Initialize(); err != nil { + initErrCh <- err - return - } + return } - defer ole.CoUninitialize() + defer ole.Uninitialize() - // Create a new instance of the WMI object - sessionObj, err := ole.CreateInstance(&updateSessionCLSID, ole.IID_IUnknown) + session, err := wuapi.NewUpdateSession() if err != nil { initErrCh <- fmt.Errorf("create Microsoft.Update.Session: %w", err) return } + defer session.Release() - defer sessionObj.Release() - - // Query the IDispatch interface of the object - musQueryInterface, err := sessionObj.QueryInterface(ole.IID_IDispatch) - if err != nil { - initErrCh <- fmt.Errorf("IID_IDispatch: %w", err) + if err := session.SetUserLocale(1033); err != nil { + initErrCh <- fmt.Errorf("set UserLocale: %w", err) return } - defer musQueryInterface.Release() - - _, err = oleutil.PutProperty(musQueryInterface, "UserLocale", 1033) - if err != nil { - initErrCh <- fmt.Errorf("failed to set ClientApplicationID: %w", err) + if err := session.SetClientApplicationID("windows_exporter"); err != nil { + initErrCh <- fmt.Errorf("set ClientApplicationID: %w", err) return } - _, err = oleutil.PutProperty(musQueryInterface, "ClientApplicationID", "windows_exporter") - if err != nil { - initErrCh <- fmt.Errorf("failed to set ClientApplicationID: %w", err) - - return - } - - // https://learn.microsoft.com/en-us/windows/win32/api/wuapi/nf-wuapi-iupdatesession-createupdatesearcher - us, err := oleutil.CallMethod(musQueryInterface, "CreateUpdateSearcher") - defer func(us *ole.VARIANT) { - if us != nil { - _ = us.Clear() - } - }(us) - + searcher, err := session.CreateUpdateSearcher() if err != nil { initErrCh <- fmt.Errorf("create update searcher: %w", err) return } + defer searcher.Release() - // ush shares the reference of us, which is released by us.Clear(). - ush := us.ToIDispatch() - - _, err = oleutil.PutProperty(ush, "Online", online) - if err != nil { - initErrCh <- fmt.Errorf("put Online: %w", err) + if err := searcher.SetOnline(online); err != nil { + initErrCh <- fmt.Errorf("set Online: %w", err) return } - // lets use the fast local-only query to check if WindowsUpdates service is enabled on the host - hc, err := oleutil.CallMethod(ush, "GetTotalHistoryCount") - defer func(hc *ole.VARIANT) { - if hc != nil { - _ = hc.Clear() - } - }(hc) - - if err != nil { - initErrCh <- ErrUpdateServiceDisabled + // A local history query checks that the Windows Update service is enabled. + if _, err := searcher.GetTotalHistoryCount(); err != nil { + initErrCh <- fmt.Errorf("get update history count: %w", errors.Join(ErrUpdateServiceDisabled, err)) return } @@ -304,7 +257,7 @@ func (c *Collector) scheduleUpdateStatus(ctx context.Context, logger *slog.Logge err = recovery.Run(func() error { var err error - metricsBuf, err = c.fetchUpdates(logger, ush) + metricsBuf, err = c.fetchUpdates(logger, searcher) return err }) @@ -331,16 +284,18 @@ func (c *Collector) scheduleUpdateStatus(ctx context.Context, logger *slog.Logge } } -func (c *Collector) fetchUpdates(logger *slog.Logger, usd *ole.IDispatch) ([]prometheus.Metric, error) { +func (c *Collector) fetchUpdates(logger *slog.Logger, searcher *wuapi.UpdateSearcher) ([]prometheus.Metric, error) { metricsBuf := make([]prometheus.Metric, 0, len(c.metricsBuf)*2+1) timeStart := time.Now() - usr, err := oleutil.CallMethod(usd, "Search", "IsInstalled=0 and IsHidden=0") + result, err := searcher.Search("IsInstalled=0 and IsHidden=0") if err != nil { return nil, fmt.Errorf("search for updates: %w", err) } + defer result.Release() + logger.Debug(fmt.Sprintf("search for updates took %s", time.Since(timeStart))) metricsBuf = append(metricsBuf, prometheus.MustNewConstMetric( @@ -349,24 +304,24 @@ func (c *Collector) fetchUpdates(logger *slog.Logger, usd *ole.IDispatch) ([]pro time.Since(timeStart).Seconds(), )) - usrd := usr.ToIDispatch() - defer usrd.Release() - - upd, err := oleutil.GetProperty(usrd, "Updates") + updates, err := result.Updates() if err != nil { return nil, fmt.Errorf("get updates: %w", err) } + defer updates.Release() - updd := upd.ToIDispatch() - defer updd.Release() + for item, err := range updates.All() { + if err != nil { + if _, ok := errors.AsType[*ole.CollectionItemError](err); !ok { + return nil, fmt.Errorf("enumerate updates: %w", err) + } - countUpdd, err := oleutil.GetProperty(updd, "Count") - if err != nil { - return nil, fmt.Errorf("get updates count: %w", err) - } + logger.Error("failed to fetch Windows Update history item", slog.Any("err", err)) - for i := range int(countUpdd.Val) { - update, err := c.getUpdateStatus(updd, i) + continue + } + + update, err := c.getUpdateStatus(item) if err != nil { logger.Error("failed to fetch Windows Update history item", slog.Any("err", err), @@ -418,73 +373,47 @@ type windowsUpdate struct { // getUpdateStatus retrieves the update status of the given item. // other available properties can be found here: // https://learn.microsoft.com/en-us/previous-versions/windows/desktop/aa386114(v=vs.85) -func (c *Collector) getUpdateStatus(updd *ole.IDispatch, item int) (windowsUpdate, error) { - itemRaw, err := oleutil.GetProperty(updd, "Item", item) - if err != nil { - return windowsUpdate{}, fmt.Errorf("get update item: %w", err) - } - - updateItem := itemRaw.ToIDispatch() - defer updateItem.Release() - - severity, err := oleutil.GetProperty(updateItem, "MsrcSeverity") +func (c *Collector) getUpdateStatus(item *wuapi.Update) (windowsUpdate, error) { + severity, err := item.MsrcSeverity() if err != nil { return windowsUpdate{}, fmt.Errorf("get MsrcSeverity: %w", err) } - defer clearVariant(severity) - - categoriesRaw, err := oleutil.GetProperty(updateItem, "Categories") + categoryName, err := getUpdateCategory(item) if err != nil { - return windowsUpdate{}, fmt.Errorf("get Categories: %w", err) + return windowsUpdate{}, fmt.Errorf("get category: %w", err) } - categories := categoriesRaw.ToIDispatch() - defer categories.Release() - - categoryName, err := getUpdateCategory(categories) - if err != nil { - return windowsUpdate{}, fmt.Errorf("get Category: %w", err) - } - - title, err := oleutil.GetProperty(updateItem, "Title") + title, err := item.Title() if err != nil { return windowsUpdate{}, fmt.Errorf("get Title: %w", err) } - defer clearVariant(title) - - // Get the Identity object - identityVariant, err := oleutil.GetProperty(updateItem, "Identity") + identity, err := item.Identity() if err != nil { return windowsUpdate{}, fmt.Errorf("get Identity: %w", err) } - - identity := identityVariant.ToIDispatch() defer identity.Release() - // Read the UpdateID - updateIDVariant, err := oleutil.GetProperty(identity, "UpdateID") + updateID, err := identity.UpdateID() if err != nil { return windowsUpdate{}, fmt.Errorf("get UpdateID: %w", err) } - defer clearVariant(updateIDVariant) - - revisionVariant, err := oleutil.GetProperty(identity, "RevisionNumber") + revision, err := identity.RevisionNumber() if err != nil { return windowsUpdate{}, fmt.Errorf("get RevisionNumber: %w", err) } - lastPublished, err := oleutil.GetProperty(updateItem, "LastDeploymentChangeTime") + lastPublished, err := item.LastDeploymentChangeTime() if err != nil { return windowsUpdate{}, fmt.Errorf("get LastDeploymentChangeTime: %w", err) } - lastPublishedDate, err := ole.GetVariantDate(uint64(lastPublished.Val)) + lastPublishedDate, err := lastPublished.Time() if err != nil { c.logger.Debug("failed to convert LastDeploymentChangeTime", - slog.String("title", title.ToString()), + slog.String("title", title), slog.Any("err", err), ) @@ -492,66 +421,46 @@ func (c *Collector) getUpdateStatus(updd *ole.IDispatch, item int) (windowsUpdat } return windowsUpdate{ - identity: updateIDVariant.ToString(), - revision: strconv.FormatInt(revisionVariant.Val, 10), + identity: updateID, + revision: strconv.FormatInt(int64(revision), 10), category: categoryName, - severity: severity.ToString(), - title: title.ToString(), + severity: severity, + title: title, lastPublished: lastPublishedDate, }, nil } -func getUpdateCategory(categories *ole.IDispatch) (string, error) { - var categoryName string - - categoryCount, err := oleutil.GetProperty(categories, "Count") +func getUpdateCategory(update *wuapi.Update) (string, error) { + categories, err := update.Categories() if err != nil { - return categoryName, fmt.Errorf("get Categories count: %w", err) + return "", fmt.Errorf("get Categories: %w", err) } + defer categories.Release() + + var categoryName string order := int64(math.MaxInt64) - for i := range categoryCount.Val { - err = func(i int64) error { - categoryRaw, err := oleutil.GetProperty(categories, "Item", i) - if err != nil { - return fmt.Errorf("get Category item: %w", err) - } - - category := categoryRaw.ToIDispatch() - defer category.Release() - - categoryNameRaw, err := oleutil.GetProperty(category, "Name") - if err != nil { - return fmt.Errorf("get Category item Name: %w", err) - } - - defer clearVariant(categoryNameRaw) - - orderRaw, err := oleutil.GetProperty(category, "Order") - if err != nil { - return fmt.Errorf("get Category item Order: %w", err) - } - - if orderRaw.Val < order { - order = orderRaw.Val - categoryName = categoryNameRaw.ToString() - } - - return nil - }(i) + for category, err := range categories.All() { if err != nil { - return "", fmt.Errorf("get Category item: %w", err) + return "", fmt.Errorf("enumerate categories: %w", err) + } + + name, err := category.Name() + if err != nil { + return "", fmt.Errorf("get category Name: %w", err) + } + + categoryOrder, err := category.Order() + if err != nil { + return "", fmt.Errorf("get category Order: %w", err) + } + + if int64(categoryOrder) < order { + order = int64(categoryOrder) + categoryName = name } } return categoryName, nil } - -// clearVariant frees the value of a VARIANT, for example a BSTR. -// Values must be copied, e.g. with [ole.VARIANT.ToString], before. -func clearVariant(v *ole.VARIANT) { - if v != nil { - _ = v.Clear() - } -} diff --git a/internal/collector/update/update_internal_test.go b/internal/collector/update/update_internal_test.go new file mode 100644 index 00000000..a8041817 --- /dev/null +++ b/internal/collector/update/update_internal_test.go @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows && (amd64 || arm64) + +package update + +import ( + "bytes" + "fmt" + "log/slog" + "testing" + "unicode/utf16" + "unsafe" + + "github.com/prometheus-community/windows_exporter/internal/ole" + "github.com/prometheus-community/windows_exporter/internal/ole/wuapi" + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +func TestFetchUpdatesPartialCollection(t *testing.T) { + for _, tc := range []struct { + name string + countError bool + }{ + {name: "skip unreadable item"}, + {name: "count failure", countError: true}, + } { + t.Run(tc.name, func(t *testing.T) { + var logs bytes.Buffer + + logger := slog.New(slog.NewTextHandler(&logs, nil)) + c := &Collector{ + logger: logger, + pendingUpdate: prometheus.NewDesc("test_pending", "", []string{"id", "revision", "category", "severity", "title"}, nil), + pendingUpdateLastPublished: prometheus.NewDesc("test_published", "", []string{"id", "revision"}, nil), + queryDurationSeconds: prometheus.NewDesc("test_duration", "", nil, nil), + lastScrapeMetric: prometheus.NewDesc("test_timestamp", "", nil, nil), + } + + searcher, attempted := fakeUpdateSearch(tc.countError) + + metrics, err := c.fetchUpdates(logger, searcher) + if tc.countError { + require.ErrorIs(t, err, ole.HRESULT(0x80070005)) + require.Nil(t, metrics) + require.Empty(t, *attempted) + + return + } + + require.NoError(t, err) + require.Equal(t, []int32{0, 1, 2}, *attempted) + require.Len(t, metrics, 6, "two readable updates retain info and publication metrics plus scrape metrics") + + ids := []string{} + + for _, metric := range metrics { + if metric.Desc() != c.pendingUpdate { + continue + } + + var sample dto.Metric + require.NoError(t, metric.Write(&sample)) + + for _, label := range sample.GetLabel() { + if label.GetName() == "id" { + ids = append(ids, label.GetValue()) + } + } + } + + require.Equal(t, []string{"update-0", "update-2"}, ids) + require.Contains(t, logs.String(), "failed to fetch Windows Update history item") + require.Contains(t, logs.String(), "get collection item 1") + }) + } +} + +// fakeUpdateSearch implements only the native slots exercised by a scrape. +// Item 1 cannot be read; both surrounding items remain available. +func fakeUpdateSearch(countError bool) (*wuapi.UpdateSearcher, *[]int32) { + release := windows.NewCallback(func(uintptr) uintptr { return 0 }) + attempted := []int32{} + + var selected int32 + + var categoryMethods [10]uintptr + + categoryMethods[2] = release + categoryMethods[9] = windows.NewCallback(func(_ uintptr, out *int32) uintptr { + *out = 0 + + return 0 + }) + categories := &wuapi.CategoryCollection{VTable: &categoryMethods[0]} + + var identityMethods [9]uintptr + + identityMethods[2] = release + identityMethods[7] = windows.NewCallback(func(_ uintptr, out *int32) uintptr { + *out = 1 + + return 0 + }) + identityMethods[8] = windows.NewCallback(func(_ uintptr, out **uint16) uintptr { + return fakeBSTR(fmt.Sprintf("update-%d", selected), out) + }) + identity := &wuapi.UpdateIdentity{VTable: &identityMethods[0]} + + var updateMethods [35]uintptr + + updateMethods[2] = release + updateMethods[7] = windows.NewCallback(func(_ uintptr, out **uint16) uintptr { + return fakeBSTR("readable update", out) + }) + updateMethods[11] = windows.NewCallback(func(_ uintptr, out **wuapi.CategoryCollection) uintptr { + *out = categories + + return 0 + }) + updateMethods[19] = windows.NewCallback(func(_ uintptr, out **wuapi.UpdateIdentity) uintptr { + *out = identity + + return 0 + }) + updateMethods[30] = windows.NewCallback(func(_ uintptr, out *ole.DATE) uintptr { + *out = 25569 // Unix epoch. + + return 0 + }) + updateMethods[34] = windows.NewCallback(func(_ uintptr, out **uint16) uintptr { + return fakeBSTR("Critical", out) + }) + update := &wuapi.Update{VTable: &updateMethods[0]} + + var collectionMethods [11]uintptr + + collectionMethods[2] = release + collectionMethods[7] = windows.NewCallback(func(_ uintptr, index uintptr, out **wuapi.Update) uintptr { + selected = int32(index) + + attempted = append(attempted, selected) + if selected == 1 { + return 0x80070005 + } + + *out = update + + return 0 + }) + collectionMethods[10] = windows.NewCallback(func(_ uintptr, out *int32) uintptr { + if countError { + return 0x80070005 + } + + *out = 3 + + return 0 + }) + updates := &wuapi.UpdateCollection{VTable: &collectionMethods[0]} + + var resultMethods [10]uintptr + + resultMethods[2] = release + resultMethods[9] = windows.NewCallback(func(_ uintptr, out **wuapi.UpdateCollection) uintptr { + *out = updates + + return 0 + }) + result := &wuapi.SearchResult{VTable: &resultMethods[0]} + + var searcherMethods [20]uintptr + + searcherMethods[2] = release + searcherMethods[19] = windows.NewCallback(func(_ uintptr, _ *uint16, out **wuapi.SearchResult) uintptr { + *out = result + + return 0 + }) + + return &wuapi.UpdateSearcher{VTable: &searcherMethods[0]}, &attempted +} + +func fakeBSTR(value string, out **uint16) uintptr { + units := append(utf16.Encode([]rune(value)), 0) + + ptr, _, _ := windows.NewLazySystemDLL("oleaut32.dll").NewProc("SysAllocStringLen").Call( + uintptr(unsafe.Pointer(&units[0])), + uintptr(len(units)-1), + ) + if ptr == 0 { + return 0x8007000e + } + + *out = (*uint16)(unsafe.Pointer(ptr)) + + return 0 +} diff --git a/internal/headers/cfgmgr32/types.go b/internal/headers/cfgmgr32/types.go index 72573e98..bdfb7542 100644 --- a/internal/headers/cfgmgr32/types.go +++ b/internal/headers/cfgmgr32/types.go @@ -16,8 +16,8 @@ package cfgmgr32 import ( - "github.com/go-ole/go-ole" "github.com/prometheus-community/windows_exporter/internal/headers/win32" + "golang.org/x/sys/windows" ) const ( @@ -33,7 +33,7 @@ const ( // DEVPROPKEY represents a device property key (GUID + pid) type DEVPROPKEY struct { - FmtID ole.GUID + FmtID windows.GUID PID uint32 } @@ -48,7 +48,7 @@ type Device struct { var ( // https://github.com/Infinidat/infi.devicemanager/blob/8be9ead6b04ff45c63d9e3bc70d82cceafb75c47/src/infi/devicemanager/setupapi/properties.py#L138C1-L143C34 DEVPKEYDeviceBusNumber = &DEVPROPKEY{ - FmtID: ole.GUID{ + FmtID: windows.GUID{ Data1: 0xa45c254e, Data2: 0xdf1c, Data3: 0x4efd, @@ -59,7 +59,7 @@ var ( // https://github.com/Infinidat/infi.devicemanager/blob/8be9ead6b04ff45c63d9e3bc70d82cceafb75c47/src/infi/devicemanager/setupapi/properties.py#L187-L192 DEVPKEYDeviceAddress = &DEVPROPKEY{ - FmtID: ole.GUID{ + FmtID: windows.GUID{ Data1: 0xa45c254e, Data2: 0xdf1c, Data3: 0x4efd, diff --git a/internal/headers/iphlpapi/types.go b/internal/headers/iphlpapi/types.go index 250dda09..ecb8aca0 100644 --- a/internal/headers/iphlpapi/types.go +++ b/internal/headers/iphlpapi/types.go @@ -21,7 +21,7 @@ import ( "encoding/binary" "fmt" - "github.com/go-ole/go-ole" + "golang.org/x/sys/windows" ) // MIB_TCPROW_OWNER_PID structure for IPv4. @@ -120,7 +120,7 @@ const ( type MIB_IF_ROW2 struct { InterfaceLuid uint64 InterfaceIndex uint32 - InterfaceGuid ole.GUID + InterfaceGuid windows.GUID Alias [IF_MAX_STRING_SIZE + 1]uint16 Description [IF_MAX_STRING_SIZE + 1]uint16 PhysicalAddressLength uint32 diff --git a/internal/ole/com_windows.go b/internal/ole/com_windows.go new file mode 100644 index 00000000..52a595e5 --- /dev/null +++ b/internal/ole/com_windows.go @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows && (amd64 || arm64) + +package ole + +import ( + "errors" + "fmt" + "runtime" + "syscall" + "unicode/utf16" + "unsafe" + + "golang.org/x/sys/windows" +) + +//nolint:gochecknoglobals +var ( + ole32 = windows.NewLazySystemDLL("ole32.dll") + oleaut32 = windows.NewLazySystemDLL("oleaut32.dll") + coInitializeEx = ole32.NewProc("CoInitializeEx") + coUninitialize = ole32.NewProc("CoUninitialize") + coCreateInstance = ole32.NewProc("CoCreateInstance") + sysAllocStringLen = oleaut32.NewProc("SysAllocStringLen") + sysFreeString = oleaut32.NewProc("SysFreeString") + sysStringLen = oleaut32.NewProc("SysStringLen") +) + +// Initialize initializes the MTA and disables legacy OLE 1.0 DDE. The caller +// must lock its OS thread before calling and release all objects before +// Uninitialize on that same thread. S_FALSE is successful initialization. +func Initialize() error { + hr, _, _ := coInitializeEx.Call(0, windows.COINIT_MULTITHREADED|windows.COINIT_DISABLE_OLE1DDE) + if err := ResultError(hr); err != nil { + return fmt.Errorf("initialize COM: %w", err) + } + + return nil +} + +// Uninitialize balances a successful Initialize on the same locked OS thread. +// Release all owned COM interfaces before calling it. +func Uninitialize() { + // CoUninitialize returns void; GetLastError is irrelevant. + _, _, _ = coUninitialize.Call() +} + +// Call invokes a native COM method and returns its primary result. COM methods +// report HRESULTs or reference counts directly; GetLastError is irrelevant. +// Convert pointers to uintptr in the call expression so uintptrescapes moves +// their storage to the heap, where reentrant Go callbacks cannot invalidate +// native pointers by growing and moving the goroutine stack. +// +//go:uintptrescapes +func Call(fn uintptr, args ...uintptr) uintptr { + result, _, _ := syscall.SyscallN(fn, args...) + + return result +} + +// Object is the common native COM interface layout. VTable points to the native +// function table and is owned by COM. Object must be the first field of every +// interface wrapper; never copy it or move it between apartments. +type Object struct{ VTable *uintptr } + +// nativeScalar includes only output layouts used by these COM bindings. A Go +// bool is deliberately excluded: COM VARIANT_BOOL uses a signed 16-bit integer. +type nativeScalar interface { + ~int16 | ~int32 | ~uint32 | ~float64 | bstr +} + +// nativeObject requires the common COM interface layout without extra Go data. +type nativeObject interface{ ~struct{ Object } } + +// Method returns the address of the zero-based native vtable slot. +func (o *Object) Method(slot uintptr) uintptr { + return *(*uintptr)(unsafe.Add(unsafe.Pointer(o.VTable), slot*unsafe.Sizeof(uintptr(0)))) +} + +// Release relinquishes an owned interface reference. Do not call it on borrowed +// iterator items. Like IUnknown::Release, it does not return an HRESULT. +func (o *Object) Release() { + _ = Call(o.Method(2), uintptr(unsafe.Pointer(o))) + runtime.KeepAlive(o) +} + +// Get calls a native scalar getter. T must exactly match its native +// output type. Pointer conversions stay in the Call expression so their storage +// remains alive and stable during native calls and reentrant Go callbacks. +// +//nolint:ireturn // T is a native scalar output, not a Go interface. +func (o *Object) Get[T nativeScalar](slot uintptr) (T, error) { + var value T + + hr := Call(o.Method(slot), uintptr(unsafe.Pointer(o)), uintptr(unsafe.Pointer(&value))) + runtime.KeepAlive(o) + + return value, ResultError(hr) +} + +// GetArg calls a native getter with one input argument. T must exactly match +// its native output type; arg must use the native argument representation. +// +//nolint:ireturn // T is a native scalar output, not a Go interface. +//go:uintptrescapes +func (o *Object) GetArg[T nativeScalar](slot, arg uintptr) (T, error) { + var value T + + hr := Call( + o.Method(slot), + uintptr(unsafe.Pointer(o)), + arg, + uintptr(unsafe.Pointer(&value)), + ) + runtime.KeepAlive(o) + + return value, ResultError(hr) +} + +// GetObject calls a native interface getter and returns an owned reference. +// Successful calls must return a non-nil interface pointer. +func (o *Object) GetObject[T nativeObject](slot uintptr) (*T, error) { + var value *T + + hr := Call(o.Method(slot), uintptr(unsafe.Pointer(o)), uintptr(unsafe.Pointer(&value))) + runtime.KeepAlive(o) + + if err := ResultError(hr); err != nil { + return nil, err + } + + if value == nil { + return nil, fmt.Errorf("COM getter at slot %d returned a nil interface", slot) + } + + return value, nil +} + +// GetObjectArg calls a native interface getter with one input argument and +// returns an owned reference. The argument must use its native representation. +// Convert pointer arguments to uintptr in the call expression. +// +//go:uintptrescapes +func (o *Object) GetObjectArg[T nativeObject](slot, arg uintptr) (*T, error) { + var value *T + + hr := Call( + o.Method(slot), + uintptr(unsafe.Pointer(o)), + arg, + uintptr(unsafe.Pointer(&value)), + ) + runtime.KeepAlive(o) + + if err := ResultError(hr); err != nil { + return nil, err + } + + if value == nil { + return nil, fmt.Errorf("COM getter at slot %d returned a nil interface", slot) + } + + return value, nil +} + +// Put calls a native setter with a scalar argument. +func (o *Object) Put(slot, arg uintptr) error { + hr := Call(o.Method(slot), uintptr(unsafe.Pointer(o)), arg) + runtime.KeepAlive(o) + + return ResultError(hr) +} + +// Create activates class and returns an owned interface reference for iid. +// T must be the matching native interface wrapper, with Object as its first field. +func Create[T nativeObject](class, iid windows.GUID) (*T, error) { + var value *T + + hr, _, _ := coCreateInstance.Call( + uintptr(unsafe.Pointer(&class)), + 0, + 0x1|0x4, // CLSCTX_INPROC_SERVER | CLSCTX_LOCAL_SERVER + uintptr(unsafe.Pointer(&iid)), + uintptr(unsafe.Pointer(&value)), + ) + if err := ResultError(hr); err != nil { + return nil, fmt.Errorf("create COM instance: %w", err) + } + + if value == nil { + return nil, errors.New("create COM instance returned a nil interface") + } + + return value, nil +} + +type bstr struct{ ptr *uint16 } + +func newBSTR(value string) (bstr, error) { + units := utf16.Encode([]rune(value)) + // Keep a non-nil input buffer even for an empty string. + units = append(units, 0) + + p, _, _ := sysAllocStringLen.Call(uintptr(unsafe.Pointer(&units[0])), uintptr(len(units)-1)) + if p == 0 { + return bstr{}, fmt.Errorf("allocate BSTR: %w", HRESULT(0x8007000e)) + } + + return bstr{ptr: (*uint16)(unsafe.Pointer(p))}, nil +} + +func (b bstr) free() { + // SysFreeString returns void and accepts a nil BSTR. + _, _, _ = sysFreeString.Call(uintptr(unsafe.Pointer(b.ptr))) +} + +func (b bstr) string() string { + if b.ptr == nil { + return "" + } + + n, _, _ := sysStringLen.Call(uintptr(unsafe.Pointer(b.ptr))) + + return string(utf16.Decode(unsafe.Slice(b.ptr, int(n)))) +} + +// String calls a BSTR getter, copies its value, and frees the native string. +func (o *Object) String(slot uintptr) (string, error) { + value, err := o.Get[bstr](slot) + defer value.free() + + if err != nil { + return "", err + } + + return value.string(), nil +} + +// Variant is the 24-byte VARIANT layout on both supported Windows architectures. +// Native 64-bit ABIs pass this aggregate by address. Only VT_EMPTY and VT_I4 +// are needed for local Connect and one-based Task Scheduler collection indices. +type Variant struct { + Type uint16 + reserved [3]uint16 + Value int64 + padding [8]byte +} + +// The x64 ABI requires caller-owned aggregate arguments to be 16-byte aligned. +// Go only guarantees eight-byte alignment for VARIANT, so reserve padding and +// align explicitly. The same storage is also valid on arm64. +type variantStorage [39]byte + +func (s *variantStorage) variant() *Variant { + offset := -uintptr(unsafe.Pointer(s)) & 15 + + return (*Variant)(unsafe.Add(unsafe.Pointer(s), offset)) +} + +// GetObjectStringArg calls a native interface getter with one BSTR input and +// returns an owned reference. The temporary BSTR is freed before returning. +func (o *Object) GetObjectStringArg[T nativeObject](slot uintptr, input string) (*T, error) { + value, err := newBSTR(input) + if err != nil { + return nil, err + } + defer value.free() + + return o.GetObjectArg[T](slot, uintptr(unsafe.Pointer(value.ptr))) +} + +// PutString calls a setter with a BSTR input, freeing the temporary string. +func (o *Object) PutString(slot uintptr, input string) error { + value, err := newBSTR(input) + if err != nil { + return err + } + defer value.free() + + return o.Put(slot, uintptr(unsafe.Pointer(value.ptr))) +} + +// NewEmptyVariant returns caller-owned, 16-byte-aligned VT_EMPTY storage. +// It contains no resources requiring VariantClear. +func NewEmptyVariant() *Variant { return new(variantStorage).variant() } + +// NewInt32Variant returns caller-owned, 16-byte-aligned VT_I4 storage. +// It contains no resources requiring VariantClear. +func NewInt32Variant(value int32) *Variant { + v := NewEmptyVariant() + v.Type = 3 + v.Value = int64(value) + + return v +} diff --git a/internal/ole/com_windows_test.go b/internal/ole/com_windows_test.go new file mode 100644 index 00000000..7af5a1b8 --- /dev/null +++ b/internal/ole/com_windows_test.go @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows && (amd64 || arm64) + +package ole + +import ( + "runtime" + "testing" + "unsafe" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +func TestInitialize(t *testing.T) { + runtime.LockOSThread() + + defer runtime.UnlockOSThread() + + require.NoError(t, Initialize()) + + defer Uninitialize() + + // Verify the apartment selected by Initialize, rather than just its status. + var apartment, qualifier int32 + + hr, _, _ := ole32.NewProc("CoGetApartmentType").Call( + uintptr(unsafe.Pointer(&apartment)), + uintptr(unsafe.Pointer(&qualifier)), + ) + require.NoError(t, ResultError(hr)) + require.Equal(t, int32(1), apartment, "APTTYPE_MTA") + + // The second initialization returns S_FALSE; it still needs balancing. + require.NoError(t, Initialize()) + + defer Uninitialize() +} + +func TestBSTR(t *testing.T) { + for _, value := range []string{"", "windows_exporter", "before\x00after", "Unicode \U0001F600"} { + t.Run(value, func(t *testing.T) { + str, err := newBSTR(value) + require.NoError(t, err) + + defer str.free() + + require.Equal(t, value, str.string()) + }) + } + + require.Empty(t, (bstr{}).string()) +} + +func TestNativeGetterCallbackStackGrowth(t *testing.T) { + for _, name := range []string{"Get", "GetArg"} { + t.Run(name, func(t *testing.T) { + var methods [8]uintptr + + if name == "Get" { + methods[7] = windows.NewCallback(func(_ uintptr, out *int32) uintptr { + // A native caller retains the original address even when the + // callback grows and relocates the caller's Go stack. + growCallbackStack(64) + + *out = 1234 + + return 0 + }) + } else { + methods[7] = windows.NewCallback(func(_ uintptr, arg uintptr, out *int32) uintptr { + growCallbackStack(64) + + *out = int32(arg) + + return 0 + }) + } + + object := &Object{VTable: &methods[0]} + + var ( + value int32 + err error + ) + + if name == "Get" { + value, err = object.Get[int32](7) + } else { + value, err = object.GetArg[int32](7, 1234) + } + + require.NoError(t, err) + require.Equal(t, int32(1234), value) + }) + } +} + +type nativeTestObject struct{ Object } + +func TestNativeObjectGetters(t *testing.T) { + tests := []struct { + name string + status uintptr + value *nativeTestObject + }{ + {name: "success", value: &nativeTestObject{}}, + {name: "S_FALSE", status: 1, value: &nativeTestObject{}}, + {name: "null success"}, + {name: "null S_FALSE", status: 1}, + {name: "HRESULT failure", status: 0x80070005}, + } + + for _, getter := range []string{"GetObject", "GetObjectArg", "GetObjectStringArg"} { + t.Run(getter, func(t *testing.T) { + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var methods [8]uintptr + + if getter == "GetObject" { + methods[7] = windows.NewCallback(func(_ uintptr, out **nativeTestObject) uintptr { + *out = test.value + + return test.status + }) + } else { + methods[7] = windows.NewCallback(func(_ uintptr, _ uintptr, out **nativeTestObject) uintptr { + *out = test.value + + return test.status + }) + } + + object := &Object{VTable: &methods[0]} + + var ( + value *nativeTestObject + err error + ) + + switch getter { + case "GetObject": + value, err = object.GetObject[nativeTestObject](7) + case "GetObjectArg": + value, err = object.GetObjectArg[nativeTestObject](7, 1) + case "GetObjectStringArg": + value, err = object.GetObjectStringArg[nativeTestObject](7, "criteria") + } + + if int32(test.status) < 0 { + require.Nil(t, value) + require.ErrorIs(t, err, HRESULT(test.status)) + + return + } + + if test.value == nil { + require.Nil(t, value) + require.ErrorContains(t, err, "returned a nil interface") + + return + } + + require.NoError(t, err) + require.Same(t, test.value, value) + }) + } + }) + } +} + +func TestNativeObjectGetterCallbackStackGrowth(t *testing.T) { + for _, getter := range []string{"GetObject", "GetObjectArg", "GetObjectStringArg"} { + t.Run(getter, func(t *testing.T) { + var methods [8]uintptr + + expected := &nativeTestObject{} + inputValid := true + + switch getter { + case "GetObject": + methods[7] = windows.NewCallback(func(_ uintptr, out **nativeTestObject) uintptr { + growCallbackStack(64) + + *out = expected + + return 0 + }) + case "GetObjectArg": + methods[7] = windows.NewCallback(func(_ uintptr, input *int32, out **nativeTestObject) uintptr { + growCallbackStack(64) + + inputValid = *input == 1234 + *out = expected + + return 0 + }) + case "GetObjectStringArg": + methods[7] = windows.NewCallback(func(_ uintptr, input *uint16, out **nativeTestObject) uintptr { + growCallbackStack(64) + + inputValid = (bstr{ptr: input}).string() == "before\x00after" + *out = expected + + return 0 + }) + } + + object := &Object{VTable: &methods[0]} + + var ( + value *nativeTestObject + err error + ) + + switch getter { + case "GetObject": + value, err = object.GetObject[nativeTestObject](7) + case "GetObjectArg": + input := int32(1234) + value, err = object.GetObjectArg[nativeTestObject](7, uintptr(unsafe.Pointer(&input))) + case "GetObjectStringArg": + value, err = object.GetObjectStringArg[nativeTestObject](7, "before\x00after") + } + + require.NoError(t, err) + require.Same(t, expected, value) + require.True(t, inputValid) + }) + } +} + +//go:noinline +func growCallbackStack(depth int) byte { + var padding [4096]byte + for i := range padding { + padding[i] = byte(i + depth) + } + + if depth > 0 { + padding[0] = growCallbackStack(depth - 1) + } + + runtime.KeepAlive(&padding) + + return padding[0] + padding[len(padding)-1] +} + +func TestVariantLayout(t *testing.T) { + require.Equal(t, uintptr(24), unsafe.Sizeof(Variant{})) + require.Equal(t, uintptr(8), unsafe.Offsetof(Variant{}.Value)) +} diff --git a/internal/ole/hresult_other.go b/internal/ole/hresult_other.go new file mode 100644 index 00000000..0c62d26d --- /dev/null +++ b/internal/ole/hresult_other.go @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows + +package ole + +import "fmt" + +func formatHRESULT(h HRESULT) string { + return fmt.Sprintf("COM HRESULT 0x%08X", uint32(h)) +} diff --git a/internal/ole/hresult_windows.go b/internal/ole/hresult_windows.go new file mode 100644 index 00000000..18a36a48 --- /dev/null +++ b/internal/ole/hresult_windows.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ole + +import ( + "fmt" + "strings" + + "golang.org/x/sys/windows" +) + +func formatHRESULT(h HRESULT) string { + numeric := fmt.Sprintf("COM HRESULT 0x%08X", uint32(h)) + + code := uint32(h) + if (code>>16)&0x1fff == 7 { // FACILITY_WIN32 stores the system error in the low word. + code &= 0xffff + } + + var buffer [1024]uint16 + + n, err := windows.FormatMessage( + windows.FORMAT_MESSAGE_FROM_SYSTEM|windows.FORMAT_MESSAGE_IGNORE_INSERTS, + 0, + code, + 0, + buffer[:], + nil, + ) + if err != nil { + return numeric + } + + message := strings.TrimSpace(windows.UTF16ToString(buffer[:n])) + if message == "" { + return numeric + } + + return numeric + ": " + message +} diff --git a/internal/ole/hresult_windows_test.go b/internal/ole/hresult_windows_test.go new file mode 100644 index 00000000..228cece3 --- /dev/null +++ b/internal/ole/hresult_windows_test.go @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ole + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +func TestHRESULTFormatting(t *testing.T) { + t.Run("Win32 facility", func(t *testing.T) { + message := formatHRESULT(HRESULT(0x80070005)) + require.Contains(t, message, "COM HRESULT 0x80070005") + require.Contains(t, message, strings.TrimSpace(windows.ERROR_ACCESS_DENIED.Error())) + require.Equal(t, strings.TrimSpace(message), message) + }) + + t.Run("COM system message", func(t *testing.T) { + message := formatHRESULT(HRESULT(0x80004005)) + require.Contains(t, message, "COM HRESULT 0x80004005: ") + }) + + t.Run("unknown status", func(t *testing.T) { + require.Equal(t, "COM HRESULT 0xDEADBEEF", formatHRESULT(HRESULT(0xdeadbeef))) + }) +} diff --git a/internal/ole/ole.go b/internal/ole/ole.go new file mode 100644 index 00000000..22872498 --- /dev/null +++ b/internal/ole/ole.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package ole provides native COM initialization, interface calls, strings, +// variants, and iteration shared by the taskschd and wuapi bindings. Native +// interfaces and strings have explicit ownership. +package ole + +import ( + "fmt" + "iter" + "math" + "time" +) + +// HRESULT preserves the 32-bit COM status code, including when wrapped. +type HRESULT uint32 + +func (h HRESULT) Error() string { return formatHRESULT(h) } + +// ResultError returns an error only for failed HRESULTs. +func ResultError(result uintptr) error { + if int32(result) < 0 { + return HRESULT(uint32(result)) + } + + return nil +} + +// DATE is an OLE Automation date: days since 1899-12-30, with the absolute +// fractional part representing the time of day, including for negative dates. +type DATE float64 + +func (d DATE) Time() (time.Time, error) { + value := float64(d) + if math.IsNaN(value) || math.IsInf(value, 0) || value <= -657435 || value >= 2958466 { + return time.Time{}, fmt.Errorf("invalid OLE date: %g", value) + } + + days, fraction := math.Modf(value) + date := time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC).AddDate(0, 0, int(days)) + millis := int64(math.Round(math.Abs(fraction) * 86400000)) + + date = date.Add(time.Duration(millis) * time.Millisecond) + if date.Year() > 9999 { + return time.Time{}, fmt.Errorf("invalid OLE date: %g", value) + } + + return date, nil +} + +// CollectionItemError identifies an unreadable index within a COM collection. +// The iterator can still read later indices; a count failure is not an item error. +type CollectionItemError struct { + Index int32 + Err error +} + +func (e *CollectionItemError) Error() string { + return fmt.Sprintf("get collection item %d: %v", e.Index, e.Err) +} + +func (e *CollectionItemError) Unwrap() error { return e.Err } + +// BorrowedItems releases each item even when yield stops early or panics. Items +// are valid only during yield; callers must not retain them or release them. +// Item errors are yielded as CollectionItemError, and iteration continues if +// yield returns true. Count errors stop iteration because there are no indices. +func BorrowedItems[T any](count func() (int32, error), item func(int32) (T, error), release func(T)) iter.Seq2[T, error] { + return func(yield func(T, error) bool) { + var zero T + + n, err := count() + if err != nil { + yield(zero, fmt.Errorf("get collection count: %w", err)) + + return + } + + if n < 0 { + yield(zero, fmt.Errorf("invalid collection count: %d", n)) + + return + } + + for i := range n { + value, err := item(i) + if err != nil { + if !yield(zero, &CollectionItemError{Index: i, Err: err}) { + return + } + + continue + } + + if !func() bool { + defer release(value) + + return yield(value, nil) + }() { + return + } + } + } +} diff --git a/internal/ole/ole_test.go b/internal/ole/ole_test.go new file mode 100644 index 00000000..7be4149a --- /dev/null +++ b/internal/ole/ole_test.go @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ole + +import ( + "errors" + "fmt" + "math" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestResultError(t *testing.T) { + for _, tc := range []struct { + name string + code uint32 + failed bool + }{ + {name: "S_OK", code: 0}, + {name: "S_FALSE", code: 1}, + {name: "other success", code: 0x7fffffff}, + {name: "access denied", code: 0x80070005, failed: true}, + {name: "changed apartment", code: 0x80010106, failed: true}, + } { + t.Run(tc.name, func(t *testing.T) { + err := ResultError(uintptr(tc.code)) + if !tc.failed { + require.NoError(t, err) + + return + } + + require.ErrorIs(t, err, HRESULT(tc.code)) + wrapped := fmt.Errorf("initialize: %w", err) + hresult, ok := errors.AsType[HRESULT](wrapped) + require.True(t, ok) + require.Equal(t, HRESULT(tc.code), hresult) + }) + } +} + +func TestDATETime(t *testing.T) { + for _, tc := range []struct { + name string + date DATE + want string + }{ + {name: "epoch", date: 0, want: "1899-12-30T00:00:00Z"}, + {name: "positive fraction", date: 2.5, want: "1900-01-01T12:00:00Z"}, + {name: "negative fraction", date: -1.25, want: "1899-12-29T06:00:00Z"}, + {name: "negative zero day", date: -0.5, want: "1899-12-30T12:00:00Z"}, + {name: "Unix epoch", date: 25569, want: "1970-01-01T00:00:00Z"}, + {name: "minimum year", date: -657434, want: "0100-01-01T00:00:00Z"}, + {name: "maximum year", date: 2958465, want: "9999-12-31T00:00:00Z"}, + {name: "NaN", date: DATE(math.NaN())}, + {name: "positive infinity", date: DATE(math.Inf(1))}, + {name: "negative infinity", date: DATE(math.Inf(-1))}, + {name: "too early", date: -657435}, + {name: "too late", date: 2958466}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := tc.date.Time() + if tc.want == "" { + require.Error(t, err) + + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got.Format(time.RFC3339)) + }) + } +} + +func TestBorrowedItems(t *testing.T) { + for _, tc := range []struct { + name string + count int32 + countErr error + itemErrAt int32 + stopAfter int + want []int32 + wantError bool + }{ + {name: "all", count: 3, itemErrAt: -1, want: []int32{0, 1, 2}}, + {name: "empty", itemErrAt: -1, want: []int32{}}, + {name: "early stop", count: 3, itemErrAt: -1, stopAfter: 1, want: []int32{0}}, + {name: "count failure", countErr: HRESULT(0x80070005), itemErrAt: -1, want: []int32{}, wantError: true}, + {name: "negative count", count: -1, itemErrAt: -1, want: []int32{}, wantError: true}, + {name: "first item failure", count: 3, itemErrAt: 0, want: []int32{}, wantError: true}, + {name: "later item failure", count: 3, itemErrAt: 1, want: []int32{0}, wantError: true}, + } { + t.Run(tc.name, func(t *testing.T) { + seen := []int32{} + released := []int32{} + seq := BorrowedItems( + func() (int32, error) { return tc.count, tc.countErr }, + func(index int32) (int32, error) { + if index == tc.itemErrAt { + return 0, HRESULT(0x80070005) + } + + return index, nil + }, + func(value int32) { released = append(released, value) }, + ) + + var gotError error + + for value, err := range seq { + if err != nil { + gotError = err + + break + } + // The current value stays alive until the loop body returns. + require.Equal(t, seen, released) + + seen = append(seen, value) + if tc.stopAfter > 0 && len(seen) == tc.stopAfter { + break + } + } + + require.Equal(t, tc.want, seen) + require.Equal(t, tc.want, released) + + if tc.wantError { + require.Error(t, gotError) + } else { + require.NoError(t, gotError) + } + }) + } +} + +func TestBorrowedItemsPanic(t *testing.T) { + released := 0 + seq := BorrowedItems( + func() (int32, error) { return 2, nil }, + func(index int32) (int32, error) { return index, nil }, + func(int32) { released++ }, + ) + + require.Panics(t, func() { + for range seq { + panic("consumer panic") + } + }) + require.Equal(t, 1, released) +} + +func TestBorrowedItemsContinuesAfterItemError(t *testing.T) { + seen := []int32{} + released := []int32{} + itemErrors := []int32{} + seq := BorrowedItems( + func() (int32, error) { return 3, nil }, + func(index int32) (int32, error) { + if index == 1 { + return 0, HRESULT(0x80070005) + } + + return index, nil + }, + func(value int32) { released = append(released, value) }, + ) + + for value, err := range seq { + if err != nil { + itemError, ok := errors.AsType[*CollectionItemError](err) + require.True(t, ok) + require.ErrorIs(t, err, HRESULT(0x80070005)) + + itemErrors = append(itemErrors, itemError.Index) + + continue + } + + seen = append(seen, value) + } + + require.Equal(t, []int32{0, 2}, seen) + require.Equal(t, []int32{0, 2}, released) + require.Equal(t, []int32{1}, itemErrors) +} diff --git a/internal/ole/taskschd/taskschd_windows.go b/internal/ole/taskschd/taskschd_windows.go new file mode 100644 index 00000000..735324f0 --- /dev/null +++ b/internal/ole/taskschd/taskschd_windows.go @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows && (amd64 || arm64) + +// Package taskschd provides the Task Scheduler COM interfaces needed for task enumeration. +package taskschd + +import ( + "iter" + "runtime" + "unsafe" + + "github.com/prometheus-community/windows_exporter/internal/ole" + "golang.org/x/sys/windows" +) + +// Native method slots, including the seven inherited IUnknown/IDispatch slots. +// https://learn.microsoft.com/en-us/windows/win32/api/taskschd/ +const ( + taskServiceGetFolder = 7 + taskServiceConnect = 10 + taskFolderPath = 8 + taskFolderGetFolders = 10 + taskFolderGetTasks = 14 + taskCollectionCount = 7 + taskCollectionItem = 8 + registeredTaskName = 7 + registeredTaskPath = 8 + registeredTaskState = 9 + registeredTaskEnabled = 10 + registeredTaskLastResult = 16 + registeredTaskMissedRuns = 17 +) + +type ( + TaskService struct{ ole.Object } + TaskFolder struct{ ole.Object } + RegisteredTask struct{ ole.Object } +) + +func NewTaskService() (*TaskService, error) { + return ole.Create[TaskService]( + windows.GUID{Data1: 0x0f87369f, Data2: 0xa4e5, Data3: 0x4cfc, Data4: [8]byte{0xbd, 0x3e, 0x73, 0xe6, 0x15, 0x45, 0x72, 0xdd}}, + windows.GUID{Data1: 0x2faba4c7, Data2: 0x4da9, Data3: 0x4013, Data4: [8]byte{0x96, 0x97, 0x20, 0xcc, 0x3f, 0xd4, 0x0f, 0x85}}, + ) +} + +// Connect connects locally using the current security token. Empty VARIANTs +// supply the four optional arguments. The 24-byte values are passed indirectly +// on Windows amd64 and arm64, each using distinct caller-owned storage. +func (s *TaskService) Connect() error { + server := ole.NewEmptyVariant() + user := ole.NewEmptyVariant() + domain := ole.NewEmptyVariant() + password := ole.NewEmptyVariant() + + hr := ole.Call( + s.Method(taskServiceConnect), + uintptr(unsafe.Pointer(s)), + uintptr(unsafe.Pointer(server)), + uintptr(unsafe.Pointer(user)), + uintptr(unsafe.Pointer(domain)), + uintptr(unsafe.Pointer(password)), + ) + runtime.KeepAlive(s) + + return ole.ResultError(hr) +} + +func (s *TaskService) Folder(path string) (*TaskFolder, error) { + return s.GetObjectStringArg[TaskFolder](taskServiceGetFolder, path) +} + +// Folders returns the direct subfolders. Its reserved flags argument must be zero. +func (f *TaskFolder) Folders() (*taskCollection[TaskFolder], error) { + return f.GetObjectArg[taskCollection[TaskFolder]](taskFolderGetFolders, 0) +} + +// Tasks includes hidden tasks (TASK_ENUM_HIDDEN). +func (f *TaskFolder) Tasks() (*taskCollection[RegisteredTask], error) { + return f.GetObjectArg[taskCollection[RegisteredTask]](taskFolderGetTasks, 1) +} + +func (f *TaskFolder) Path() (string, error) { return f.String(taskFolderPath) } + +func (t *RegisteredTask) Name() (string, error) { return t.String(registeredTaskName) } +func (t *RegisteredTask) Path() (string, error) { return t.String(registeredTaskPath) } +func (t *RegisteredTask) State() (int32, error) { return t.Get[int32](registeredTaskState) } +func (t *RegisteredTask) Enabled() (bool, error) { + value, err := t.Get[int16](registeredTaskEnabled) + + return value != 0, err +} + +func (t *RegisteredTask) LastTaskResult() (int32, error) { + return t.Get[int32](registeredTaskLastResult) +} + +func (t *RegisteredTask) NumberOfMissedRuns() (int32, error) { + return t.Get[int32](registeredTaskMissedRuns) +} + +type taskCollection[T TaskFolder | RegisteredTask] struct{ ole.Object } + +func (c *taskCollection[T]) count() (int32, error) { return c.Get[int32](taskCollectionCount) } + +func (c *taskCollection[T]) item(index int32) (*T, error) { + value := ole.NewInt32Variant(index + 1) // Task Scheduler uses one-based indices. + + item, err := c.GetObjectArg[T](taskCollectionItem, uintptr(unsafe.Pointer(value))) + runtime.KeepAlive(value) + + return item, err +} + +// All yields borrowed interfaces, valid only in the loop body. It releases each +// item on advance, early exit, or panic; callers must not release the items. +// The collection remains owned by the caller and must be released separately. +func (c *taskCollection[T]) All() iter.Seq2[*T, error] { + return ole.BorrowedItems(c.count, c.item, func(item *T) { (*ole.Object)(unsafe.Pointer(item)).Release() }) +} diff --git a/internal/ole/taskschd/taskschd_windows_test.go b/internal/ole/taskschd/taskschd_windows_test.go new file mode 100644 index 00000000..b3992406 --- /dev/null +++ b/internal/ole/taskschd/taskschd_windows_test.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows && (amd64 || arm64) + +package taskschd + +import ( + "testing" + "unsafe" + + "github.com/prometheus-community/windows_exporter/internal/ole" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +func TestNativeTaskGetters(t *testing.T) { + var methods [18]uintptr + + // IRegisteredTask::get_State slot from the Windows SDK. + methods[9] = windows.NewCallback(func(_ uintptr, out *int32) uintptr { + *out = 4 + + return 1 // S_FALSE is a successful HRESULT. + }) + // IRegisteredTask::get_Enabled slot from the Windows SDK. + methods[10] = windows.NewCallback(func(_ uintptr, out *int16) uintptr { + *out = -1 + + return 0 + }) + // IRegisteredTask::get_LastTaskResult slot from the Windows SDK. + methods[16] = windows.NewCallback(func(_ uintptr, out *int32) uintptr { + *out = -2147216629 // 0x8004130B; preserve the native signed LONG. + + return 0 + }) + // IRegisteredTask::get_NumberOfMissedRuns slot from the Windows SDK. + methods[17] = windows.NewCallback(func(_ uintptr, _ *int32) uintptr { + return 0x80070005 + }) + task := &RegisteredTask{VTable: &methods[0]} + state, err := task.State() + require.NoError(t, err) + require.Equal(t, int32(4), state) + + enabled, err := task.Enabled() + require.NoError(t, err) + require.True(t, enabled) + + result, err := task.LastTaskResult() + require.NoError(t, err) + require.Equal(t, uint32(0x8004130b), uint32(result)) + + _, err = task.NumberOfMissedRuns() + require.ErrorIs(t, err, ole.HRESULT(0x80070005)) +} + +func TestNativeConnect(t *testing.T) { + var methods [11]uintptr + + valid := false + // ITaskService::Connect slot from the Windows SDK. + methods[10] = windows.NewCallback(func(_ uintptr, server, user, domain, password *ole.Variant) uintptr { + valid = true + for _, value := range []*ole.Variant{server, user, domain, password} { + valid = valid && *value == (ole.Variant{}) && uintptr(unsafe.Pointer(value))%16 == 0 + } + + return 0 + }) + service := &TaskService{VTable: &methods[0]} + require.NoError(t, service.Connect()) + require.True(t, valid, "Connect arguments must be aligned VT_EMPTY variants") +} + +func TestNativeTaskCollection(t *testing.T) { + var taskMethods [3]uintptr + + released := 0 + taskMethods[2] = windows.NewCallback(func(uintptr) uintptr { + released++ + + return 0 + }) + task := &RegisteredTask{VTable: &taskMethods[0]} + + var methods [9]uintptr + + // IRegisteredTaskCollection::get_Count slot from the Windows SDK. + methods[7] = windows.NewCallback(func(_ uintptr, out *int32) uintptr { + *out = 2 + + return 0 + }) + + var ( + index int64 + aligned bool + ) + + // IRegisteredTaskCollection::get_Item slot from the Windows SDK. + methods[8] = windows.NewCallback(func(_ uintptr, value *ole.Variant, out **RegisteredTask) uintptr { + index = value.Value + aligned = value.Type == 3 && uintptr(unsafe.Pointer(value))%16 == 0 + *out = task + + return 0 + }) + + collection := &taskCollection[RegisteredTask]{VTable: &methods[0]} + for item, err := range collection.All() { + require.NoError(t, err) + require.Same(t, task, item) + require.Zero(t, released) + + break + } + + require.Equal(t, int64(1), index) + require.True(t, aligned) + require.Equal(t, 1, released) +} diff --git a/internal/ole/wuapi/wuapi_windows.go b/internal/ole/wuapi/wuapi_windows.go new file mode 100644 index 00000000..bcc968fc --- /dev/null +++ b/internal/ole/wuapi/wuapi_windows.go @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows && (amd64 || arm64) + +// Package wuapi provides the Windows Update COM interfaces needed for update searches and metadata. +package wuapi + +import ( + "iter" + + "github.com/prometheus-community/windows_exporter/internal/ole" + "golang.org/x/sys/windows" +) + +// Native method slots from wuapi.h, including inherited IDispatch methods. +// https://learn.microsoft.com/en-us/windows/win32/api/wuapi/ +const ( + updateSessionPutApplicationID = 8 + updateSessionCreateSearcher = 12 + updateSessionPutLocale = 16 // IUpdateSession2 + updateSearcherSearch = 19 + updateSearcherPutOnline = 21 + updateSearcherHistoryCount = 22 + searchResultUpdates = 9 + updateCollectionItem = 7 + updateCollectionCount = 10 + updateTitle = 7 + updateCategories = 11 + updateIdentity = 19 + updateDeploymentTime = 30 + updateSeverity = 34 + identityRevision = 7 + identityUpdateID = 8 + categoryCollectionItem = 7 + categoryCollectionCount = 9 + categoryName = 7 + categoryOrder = 12 +) + +type ( + UpdateSession struct{ ole.Object } // IUpdateSession2 + UpdateSearcher struct{ ole.Object } + SearchResult struct{ ole.Object } + Update struct{ ole.Object } + UpdateIdentity struct{ ole.Object } + Category struct{ ole.Object } +) + +func NewUpdateSession() (*UpdateSession, error) { + return ole.Create[UpdateSession]( + windows.GUID{Data1: 0x4cb43d7f, Data2: 0x7eee, Data3: 0x4906, Data4: [8]byte{0x86, 0x98, 0x60, 0xda, 0x1c, 0x38, 0xf2, 0xfe}}, + windows.GUID{Data1: 0x91caf7b0, Data2: 0xeb23, Data3: 0x49ed, Data4: [8]byte{0x99, 0x37, 0xc5, 0x2d, 0x81, 0x7f, 0x46, 0xf7}}, + ) +} + +func (s *UpdateSession) SetUserLocale(locale uint32) error { + return s.Put(updateSessionPutLocale, uintptr(locale)) +} + +func (s *UpdateSession) SetClientApplicationID(id string) error { + return s.PutString(updateSessionPutApplicationID, id) +} + +func (s *UpdateSession) CreateUpdateSearcher() (*UpdateSearcher, error) { + return s.GetObject[UpdateSearcher](updateSessionCreateSearcher) +} + +func (s *UpdateSearcher) SetOnline(online bool) error { + var value uintptr + // VARIANT_TRUE is a signed 16-bit -1. + if online { + value = 0xffff + } + + return s.Put(updateSearcherPutOnline, value) +} + +func (s *UpdateSearcher) GetTotalHistoryCount() (int32, error) { + return s.Get[int32](updateSearcherHistoryCount) +} + +func (s *UpdateSearcher) Search(criteria string) (*SearchResult, error) { + return s.GetObjectStringArg[SearchResult](updateSearcherSearch, criteria) +} + +func (s *SearchResult) Updates() (*UpdateCollection, error) { + return s.GetObject[UpdateCollection](searchResultUpdates) +} + +func (u *Update) Title() (string, error) { return u.String(updateTitle) } +func (u *Update) MsrcSeverity() (string, error) { return u.String(updateSeverity) } +func (u *Update) Categories() (*CategoryCollection, error) { + return u.GetObject[CategoryCollection](updateCategories) +} + +func (u *Update) Identity() (*UpdateIdentity, error) { + return u.GetObject[UpdateIdentity](updateIdentity) +} + +func (u *Update) LastDeploymentChangeTime() (ole.DATE, error) { + return u.Get[ole.DATE](updateDeploymentTime) +} +func (i *UpdateIdentity) UpdateID() (string, error) { return i.String(identityUpdateID) } +func (i *UpdateIdentity) RevisionNumber() (int32, error) { return i.Get[int32](identityRevision) } +func (c *Category) Name() (string, error) { return c.String(categoryName) } +func (c *Category) Order() (int32, error) { return c.Get[int32](categoryOrder) } + +// UpdateCollection uses IUpdateCollection's native layout and zero-based indices. +type UpdateCollection struct{ ole.Object } + +func (c *UpdateCollection) count() (int32, error) { + return c.Get[int32](updateCollectionCount) +} + +func (c *UpdateCollection) item(index int32) (*Update, error) { + return c.GetObjectArg[Update](updateCollectionItem, uintptr(index)) +} + +// All yields borrowed updates, valid only in the loop body. Callers must not +// release or retain them. Item errors can be skipped by continuing the loop. +// The collection itself remains owned by the caller and must be released. +func (c *UpdateCollection) All() iter.Seq2[*Update, error] { + return ole.BorrowedItems(c.count, c.item, func(item *Update) { item.Release() }) +} + +// CategoryCollection uses ICategoryCollection's distinct native count slot. +type CategoryCollection struct{ ole.Object } + +func (c *CategoryCollection) count() (int32, error) { + return c.Get[int32](categoryCollectionCount) +} + +func (c *CategoryCollection) item(index int32) (*Category, error) { + return c.GetObjectArg[Category](categoryCollectionItem, uintptr(index)) +} + +// All yields borrowed categories, valid only in the loop body. Callers must not +// release or retain them. Item errors can be skipped by continuing the loop. +// The collection itself remains owned by the caller and must be released. +func (c *CategoryCollection) All() iter.Seq2[*Category, error] { + return ole.BorrowedItems(c.count, c.item, func(item *Category) { item.Release() }) +} diff --git a/internal/ole/wuapi/wuapi_windows_test.go b/internal/ole/wuapi/wuapi_windows_test.go new file mode 100644 index 00000000..3778136d --- /dev/null +++ b/internal/ole/wuapi/wuapi_windows_test.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows && (amd64 || arm64) + +package wuapi + +import ( + "testing" + + "github.com/prometheus-community/windows_exporter/internal/ole" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +func TestNativeUpdateCollection(t *testing.T) { + var methods [11]uintptr + + // IUpdateCollection::get_Count slot from the Windows SDK. + methods[10] = windows.NewCallback(func(_ uintptr, out *int32) uintptr { + *out = 1 + + return 0 + }) + + var index uintptr + + // IUpdateCollection::get_Item slot from the Windows SDK. + methods[7] = windows.NewCallback(func(_ uintptr, i uintptr, _ **Update) uintptr { + index = i + + return 0x80070005 + }) + collection := &UpdateCollection{VTable: &methods[0]} + + errors := 0 + for item, err := range collection.All() { + errors++ + + require.Nil(t, item) + require.ErrorIs(t, err, ole.HRESULT(0x80070005)) + } + + require.Equal(t, 1, errors) + require.Zero(t, index) +}