From f3bd2090e7da2d95f7a58e800aef0d82fcb263a3 Mon Sep 17 00:00:00 2001 From: Tim Smith Date: Tue, 17 Mar 2026 22:28:30 -0700 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20Add=20os.date=20resource=20with?= =?UTF-8?q?=20time=20and=20timezone=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new os.date resource to the OS provider that queries the remote system's current time and timezone. Unlike the core time.now() which returns the local workstation's time, os.date fetches from the connected asset via SSH/WinRM. Cross-platform support: - Unix (Linux, macOS, FreeBSD, AIX, Solaris): uses date -u for UTC time and /etc/localtime, /etc/timezone, /etc/TIMEZONE for timezone detection - Windows: uses PowerShell Get-Date and Get-TimeZone Co-Authored-By: Claude Opus 4.6 (1M context) --- providers/os/resources/date/date.go | 35 ++++++ providers/os/resources/date/unix.go | 114 ++++++++++++++++++++ providers/os/resources/date/unix_test.go | 87 +++++++++++++++ providers/os/resources/date/windows.go | 71 ++++++++++++ providers/os/resources/date/windows_test.go | 60 +++++++++++ providers/os/resources/os.lr | 10 ++ providers/os/resources/os.lr.go | 105 ++++++++++++++++++ providers/os/resources/os.lr.versions | 3 + providers/os/resources/os_date.go | 73 +++++++++++++ 9 files changed, 558 insertions(+) create mode 100644 providers/os/resources/date/date.go create mode 100644 providers/os/resources/date/unix.go create mode 100644 providers/os/resources/date/unix_test.go create mode 100644 providers/os/resources/date/windows.go create mode 100644 providers/os/resources/date/windows_test.go create mode 100644 providers/os/resources/os_date.go diff --git a/providers/os/resources/date/date.go b/providers/os/resources/date/date.go new file mode 100644 index 0000000000..f7721a9a0e --- /dev/null +++ b/providers/os/resources/date/date.go @@ -0,0 +1,35 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package date + +import ( + "errors" + "time" + + "go.mondoo.com/mql/v13/providers-sdk/v1/inventory" + "go.mondoo.com/mql/v13/providers/os/connection/shared" +) + +type Result struct { + Time time.Time + Timezone string +} + +type Date interface { + Name() string + Get() (*Result, error) +} + +func New(conn shared.Connection) (Date, error) { + pf := conn.Asset().Platform + + switch { + case pf.IsFamily(inventory.FAMILY_UNIX): + return &Unix{conn: conn}, nil + case pf.IsFamily(inventory.FAMILY_WINDOWS): + return &Windows{conn: conn}, nil + default: + return nil, errors.New("your platform is not supported by the date resource") + } +} diff --git a/providers/os/resources/date/unix.go b/providers/os/resources/date/unix.go new file mode 100644 index 0000000000..5163a75845 --- /dev/null +++ b/providers/os/resources/date/unix.go @@ -0,0 +1,114 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package date + +import ( + "fmt" + "io" + "strings" + "time" + + "go.mondoo.com/mql/v13/providers/os/connection/shared" +) + +// Unix date command format: outputs ISO 8601 date and IANA timezone name. +// This works on Linux, macOS, FreeBSD, AIX, and Solaris. +// The command outputs two lines: +// +// Line 1: date in RFC 3339 format (date -u +%Y-%m-%dT%H:%M:%SZ) +// Line 2: timezone identifier from /etc/localtime or TZ env var +// +// We use two separate approaches: +// - UTC time via `date -u` which is universally supported +// - Timezone name via reading the TZ symlink or zone file +const ( + // Get current UTC time in RFC 3339 and the timezone name. + // The date command with these format specifiers works across all target Unix platforms. + // %Z gives abbreviated timezone (e.g., "EST"), but we prefer the IANA name. + unixDateCmd = `date -u +%Y-%m-%dT%H:%M:%SZ` + + // Get IANA timezone name. Try multiple approaches for cross-platform compatibility: + // 1. readlink /etc/localtime (Linux, macOS, FreeBSD) + // 2. Read /etc/timezone (Debian/Ubuntu) + // 3. Parse /etc/TIMEZONE (Solaris/AIX) + // 4. Fall back to date +%Z (abbreviated name) + unixTimezoneCmd = `if [ -L /etc/localtime ]; then readlink /etc/localtime | sed 's|.*/zoneinfo/||'; elif [ -f /etc/timezone ]; then cat /etc/timezone; elif [ -f /etc/TIMEZONE ]; then grep '^TZ=' /etc/TIMEZONE | cut -d= -f2; else date +%Z; fi` +) + +type Unix struct { + conn shared.Connection +} + +func (u *Unix) Name() string { + return "Unix Date" +} + +func (u *Unix) Get() (*Result, error) { + // Get UTC time + cmd, err := u.conn.RunCommand(unixDateCmd) + if err != nil { + return nil, fmt.Errorf("failed to get system date: %w", err) + } + utcTime, err := parseUTCTime(cmd.Stdout) + if err != nil { + return nil, err + } + + // Get timezone + cmd, err = u.conn.RunCommand(unixTimezoneCmd) + if err != nil { + return nil, fmt.Errorf("failed to get system timezone: %w", err) + } + tz, err := parseTimezone(cmd.Stdout) + if err != nil { + return nil, err + } + + // Load the timezone location and convert the UTC time + loc, err := time.LoadLocation(tz) + if err != nil { + // If the timezone can't be loaded (e.g., abbreviated name like "EST"), + // return the UTC time with the timezone string as-is + return &Result{ + Time: utcTime, + Timezone: tz, + }, nil + } + + return &Result{ + Time: utcTime.In(loc), + Timezone: tz, + }, nil +} + +func parseUTCTime(r io.Reader) (time.Time, error) { + content, err := io.ReadAll(r) + if err != nil { + return time.Time{}, fmt.Errorf("failed to read date output: %w", err) + } + + s := strings.TrimSpace(string(content)) + if s == "" { + return time.Time{}, fmt.Errorf("empty date output") + } + + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse date %q: %w", s, err) + } + return t, nil +} + +func parseTimezone(r io.Reader) (string, error) { + content, err := io.ReadAll(r) + if err != nil { + return "", fmt.Errorf("failed to read timezone output: %w", err) + } + + s := strings.TrimSpace(string(content)) + if s == "" { + return "UTC", nil + } + return s, nil +} diff --git a/providers/os/resources/date/unix_test.go b/providers/os/resources/date/unix_test.go new file mode 100644 index 0000000000..802a2857b9 --- /dev/null +++ b/providers/os/resources/date/unix_test.go @@ -0,0 +1,87 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package date + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseUTCTime(t *testing.T) { + tests := []struct { + name string + input string + want time.Time + wantErr bool + }{ + { + name: "valid UTC time", + input: "2026-03-17T14:30:00Z\n", + want: time.Date(2026, 3, 17, 14, 30, 0, 0, time.UTC), + }, + { + name: "empty output", + input: "", + wantErr: true, + }, + { + name: "invalid format", + input: "not a date", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseUTCTime(strings.NewReader(tt.input)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseTimezone(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "IANA timezone from readlink", + input: "America/New_York\n", + want: "America/New_York", + }, + { + name: "timezone from /etc/timezone", + input: "Europe/London\n", + want: "Europe/London", + }, + { + name: "abbreviated timezone fallback", + input: "EST\n", + want: "EST", + }, + { + name: "empty defaults to UTC", + input: "", + want: "UTC", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseTimezone(strings.NewReader(tt.input)) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/providers/os/resources/date/windows.go b/providers/os/resources/date/windows.go new file mode 100644 index 0000000000..e408a1df94 --- /dev/null +++ b/providers/os/resources/date/windows.go @@ -0,0 +1,71 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package date + +import ( + "encoding/json" + "fmt" + "io" + "time" + + "go.mondoo.com/mql/v13/providers/os/connection/shared" + "go.mondoo.com/mql/v13/providers/os/resources/powershell" +) + +type windowsDateResult struct { + DateTime string `json:"DateTime"` + Timezone string `json:"Timezone"` +} + +// PowerShell command that returns both the current UTC time and the system timezone ID. +const windowsDateCmd = `@{DateTime=(Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ');Timezone=(Get-TimeZone).Id} | ConvertTo-Json` + +type Windows struct { + conn shared.Connection +} + +func (w *Windows) Name() string { + return "Windows Date" +} + +func (w *Windows) Get() (*Result, error) { + cmd, err := w.conn.RunCommand(powershell.Wrap(windowsDateCmd)) + if err != nil { + return nil, fmt.Errorf("failed to get system date: %w", err) + } + + return w.parse(cmd.Stdout) +} + +func (w *Windows) parse(r io.Reader) (*Result, error) { + content, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("failed to read date output: %w", err) + } + + var res windowsDateResult + if err := json.Unmarshal(content, &res); err != nil { + return nil, fmt.Errorf("failed to parse date output: %w", err) + } + + t, err := time.Parse(time.RFC3339, res.DateTime) + if err != nil { + return nil, fmt.Errorf("failed to parse datetime %q: %w", res.DateTime, err) + } + + // Windows returns IANA-compatible timezone IDs on modern systems + loc, err := time.LoadLocation(res.Timezone) + if err != nil { + // Fall back to returning UTC time with the Windows timezone ID + return &Result{ + Time: t, + Timezone: res.Timezone, + }, nil + } + + return &Result{ + Time: t.In(loc), + Timezone: res.Timezone, + }, nil +} diff --git a/providers/os/resources/date/windows_test.go b/providers/os/resources/date/windows_test.go new file mode 100644 index 0000000000..74dc8cb42d --- /dev/null +++ b/providers/os/resources/date/windows_test.go @@ -0,0 +1,60 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package date + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseWindowsDate(t *testing.T) { + w := &Windows{} + + tests := []struct { + name string + input string + wantTZ string + wantYear int + wantErr bool + }{ + { + name: "valid output", + input: `{"DateTime":"2026-03-17T14:30:00Z","Timezone":"America/New_York"}`, + wantTZ: "America/New_York", + wantYear: 2026, + }, + { + name: "UTC timezone", + input: `{"DateTime":"2026-03-17T14:30:00Z","Timezone":"UTC"}`, + wantTZ: "UTC", + wantYear: 2026, + }, + { + name: "invalid json", + input: `not json`, + wantErr: true, + }, + { + name: "invalid datetime", + input: `{"DateTime":"not-a-date","Timezone":"UTC"}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := w.parse(strings.NewReader(tt.input)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantTZ, got.Timezone) + assert.Equal(t, tt.wantYear, got.Time.Year()) + }) + } +} diff --git a/providers/os/resources/os.lr b/providers/os/resources/os.lr index 2e73147ab5..8fc66ab9ec 100644 --- a/providers/os/resources/os.lr +++ b/providers/os/resources/os.lr @@ -257,6 +257,16 @@ os { hypervisor() string // Machine ID for this OS machineid() string + // Current date and timezone of the system + date() os.date +} + +// Operating system date and timezone information +os.date { + // Current system time + time() time + // System timezone (e.g., "America/New_York", "UTC") + timezone() string } // Operating system update information diff --git a/providers/os/resources/os.lr.go b/providers/os/resources/os.lr.go index 6921523885..111b70165a 100644 --- a/providers/os/resources/os.lr.go +++ b/providers/os/resources/os.lr.go @@ -36,6 +36,7 @@ const ( ResourceMachineChassis string = "machine.chassis" ResourceMachineCpu string = "machine.cpu" ResourceOs string = "os" + ResourceOsDate string = "os.date" ResourceOsUpdate string = "os.update" ResourceOsBase string = "os.base" ResourceOsUnix string = "os.unix" @@ -290,6 +291,10 @@ func init() { // to override args, implement: initOs(runtime *plugin.Runtime, args map[string]*llx.RawData) (map[string]*llx.RawData, plugin.Resource, error) Create: createOs, }, + "os.date": { + // to override args, implement: initOsDate(runtime *plugin.Runtime, args map[string]*llx.RawData) (map[string]*llx.RawData, plugin.Resource, error) + Create: createOsDate, + }, "os.update": { // to override args, implement: initOsUpdate(runtime *plugin.Runtime, args map[string]*llx.RawData) (map[string]*llx.RawData, plugin.Resource, error) Create: createOsUpdate, @@ -1316,6 +1321,15 @@ var getDataFields = map[string]func(r plugin.Resource) *plugin.DataRes{ "os.machineid": func(r plugin.Resource) *plugin.DataRes { return (r.(*mqlOs).GetMachineid()).ToDataRes(types.String) }, + "os.date": func(r plugin.Resource) *plugin.DataRes { + return (r.(*mqlOs).GetDate()).ToDataRes(types.Resource("os.date")) + }, + "os.date.time": func(r plugin.Resource) *plugin.DataRes { + return (r.(*mqlOsDate).GetTime()).ToDataRes(types.Time) + }, + "os.date.timezone": func(r plugin.Resource) *plugin.DataRes { + return (r.(*mqlOsDate).GetTimezone()).ToDataRes(types.String) + }, "os.update.name": func(r plugin.Resource) *plugin.DataRes { return (r.(*mqlOsUpdate).GetName()).ToDataRes(types.String) }, @@ -4466,6 +4480,22 @@ var setDataFields = map[string]func(r plugin.Resource, v *llx.RawData) bool{ r.(*mqlOs).Machineid, ok = plugin.RawToTValue[string](v.Value, v.Error) return }, + "os.date": func(r plugin.Resource, v *llx.RawData) (ok bool) { + r.(*mqlOs).Date, ok = plugin.RawToTValue[*mqlOsDate](v.Value, v.Error) + return + }, + "os.date.__id": func(r plugin.Resource, v *llx.RawData) (ok bool) { + r.(*mqlOsDate).__id, ok = v.Value.(string) + return + }, + "os.date.time": func(r plugin.Resource, v *llx.RawData) (ok bool) { + r.(*mqlOsDate).Time, ok = plugin.RawToTValue[*time.Time](v.Value, v.Error) + return + }, + "os.date.timezone": func(r plugin.Resource, v *llx.RawData) (ok bool) { + r.(*mqlOsDate).Timezone, ok = plugin.RawToTValue[string](v.Value, v.Error) + return + }, "os.update.__id": func(r plugin.Resource, v *llx.RawData) (ok bool) { r.(*mqlOsUpdate).__id, ok = v.Value.(string) return @@ -10053,6 +10083,7 @@ type mqlOs struct { Hostname plugin.TValue[string] Hypervisor plugin.TValue[string] Machineid plugin.TValue[string] + Date plugin.TValue[*mqlOsDate] } // createOs creates a new instance of this resource @@ -10156,6 +10187,80 @@ func (c *mqlOs) GetMachineid() *plugin.TValue[string] { }) } +func (c *mqlOs) GetDate() *plugin.TValue[*mqlOsDate] { + return plugin.GetOrCompute[*mqlOsDate](&c.Date, func() (*mqlOsDate, error) { + if c.MqlRuntime.HasRecording { + d, err := c.MqlRuntime.FieldResourceFromRecording("os", c.__id, "date") + if err != nil { + return nil, err + } + if d != nil { + return d.Value.(*mqlOsDate), nil + } + } + + return c.date() + }) +} + +// mqlOsDate for the os.date resource +type mqlOsDate struct { + MqlRuntime *plugin.Runtime + __id string + mqlOsDateInternal + Time plugin.TValue[*time.Time] + Timezone plugin.TValue[string] +} + +// createOsDate creates a new instance of this resource +func createOsDate(runtime *plugin.Runtime, args map[string]*llx.RawData) (plugin.Resource, error) { + res := &mqlOsDate{ + MqlRuntime: runtime, + } + + err := SetAllData(res, args) + if err != nil { + return res, err + } + + if res.__id == "" { + res.__id, err = res.id() + if err != nil { + return nil, err + } + } + + if runtime.HasRecording { + args, err = runtime.ResourceFromRecording("os.date", res.__id) + if err != nil || args == nil { + return res, err + } + return res, SetAllData(res, args) + } + + return res, nil +} + +func (c *mqlOsDate) MqlName() string { + return "os.date" +} + +func (c *mqlOsDate) MqlID() string { + return c.__id +} + +func (c *mqlOsDate) GetTime() *plugin.TValue[*time.Time] { + return plugin.GetOrCompute[*time.Time](&c.Time, func() (*time.Time, error) { + return c.time() + }) +} + +func (c *mqlOsDate) GetTimezone() *plugin.TValue[string] { + return plugin.GetOrCompute[string](&c.Timezone, func() (string, error) { + return c.timezone() + }) +} + // mqlOsUpdate for the os.update resource type mqlOsUpdate struct { MqlRuntime *plugin.Runtime diff --git a/providers/os/resources/os.lr.versions b/providers/os/resources/os.lr.versions index 0b60bf7ca3..23b85335d0 100644 --- a/providers/os/resources/os.lr.versions +++ b/providers/os/resources/os.lr.versions @@ -701,6 +701,9 @@ os.base.rebootpending 9.0.0 os.base.updates 9.0.0 os.base.uptime 9.0.0 os.base.users 9.0.0 +os.date 13.2.4 +os.date.time 13.2.4 +os.date.timezone 13.2.4 os.env 9.0.0 os.hostname 9.0.0 os.hypervisor 11.3.35 diff --git a/providers/os/resources/os_date.go b/providers/os/resources/os_date.go new file mode 100644 index 0000000000..66d7415caa --- /dev/null +++ b/providers/os/resources/os_date.go @@ -0,0 +1,73 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package resources + +import ( + "sync" + "time" + + "go.mondoo.com/mql/v13/llx" + "go.mondoo.com/mql/v13/providers/os/connection/shared" + "go.mondoo.com/mql/v13/providers/os/resources/date" +) + +type mqlOsDateInternal struct { + lock sync.Mutex + fetched bool + result *date.Result +} + +func (p *mqlOs) date() (*mqlOsDate, error) { + o, err := CreateResource(p.MqlRuntime, "os.date", map[string]*llx.RawData{}) + if err != nil { + return nil, err + } + return o.(*mqlOsDate), nil +} + +func (d *mqlOsDate) id() (string, error) { + return "os.date", nil +} + +func (d *mqlOsDate) fetch() (*date.Result, error) { + if d.fetched { + return d.result, nil + } + d.lock.Lock() + defer d.lock.Unlock() + if d.fetched { + return d.result, nil + } + + conn := d.MqlRuntime.Connection.(shared.Connection) + dt, err := date.New(conn) + if err != nil { + return nil, err + } + + res, err := dt.Get() + if err != nil { + return nil, err + } + + d.fetched = true + d.result = res + return res, nil +} + +func (d *mqlOsDate) time() (*time.Time, error) { + res, err := d.fetch() + if err != nil { + return nil, err + } + return &res.Time, nil +} + +func (d *mqlOsDate) timezone() (string, error) { + res, err := d.fetch() + if err != nil { + return "", err + } + return res.Timezone, nil +} From e82571c87a6af4e155b7c5c71909f582242a1e3d Mon Sep 17 00:00:00 2001 From: Tim Smith Date: Tue, 17 Mar 2026 22:29:15 -0700 Subject: [PATCH 2/6] Set default display fields for os.date to time and timezone Co-Authored-By: Claude Opus 4.6 (1M context) --- providers/os/resources/date/date.go | 2 +- providers/os/resources/date/unix.go | 214 ++++++++++++++++++----- providers/os/resources/date/unix_test.go | 122 +++++++++++++ providers/os/resources/date/windows.go | 5 +- providers/os/resources/os.lr | 2 +- providers/os/resources/os_date.go | 2 +- 6 files changed, 301 insertions(+), 46 deletions(-) diff --git a/providers/os/resources/date/date.go b/providers/os/resources/date/date.go index f7721a9a0e..e8f505a4f2 100644 --- a/providers/os/resources/date/date.go +++ b/providers/os/resources/date/date.go @@ -12,7 +12,7 @@ import ( ) type Result struct { - Time time.Time + Time *time.Time Timezone string } diff --git a/providers/os/resources/date/unix.go b/providers/os/resources/date/unix.go index 5163a75845..7fe8447402 100644 --- a/providers/os/resources/date/unix.go +++ b/providers/os/resources/date/unix.go @@ -4,37 +4,20 @@ package date import ( + "bufio" "fmt" "io" + "os" + "path/filepath" "strings" "time" + "github.com/spf13/afero" "go.mondoo.com/mql/v13/providers/os/connection/shared" ) -// Unix date command format: outputs ISO 8601 date and IANA timezone name. -// This works on Linux, macOS, FreeBSD, AIX, and Solaris. -// The command outputs two lines: -// -// Line 1: date in RFC 3339 format (date -u +%Y-%m-%dT%H:%M:%SZ) -// Line 2: timezone identifier from /etc/localtime or TZ env var -// -// We use two separate approaches: -// - UTC time via `date -u` which is universally supported -// - Timezone name via reading the TZ symlink or zone file -const ( - // Get current UTC time in RFC 3339 and the timezone name. - // The date command with these format specifiers works across all target Unix platforms. - // %Z gives abbreviated timezone (e.g., "EST"), but we prefer the IANA name. - unixDateCmd = `date -u +%Y-%m-%dT%H:%M:%SZ` - - // Get IANA timezone name. Try multiple approaches for cross-platform compatibility: - // 1. readlink /etc/localtime (Linux, macOS, FreeBSD) - // 2. Read /etc/timezone (Debian/Ubuntu) - // 3. Parse /etc/TIMEZONE (Solaris/AIX) - // 4. Fall back to date +%Z (abbreviated name) - unixTimezoneCmd = `if [ -L /etc/localtime ]; then readlink /etc/localtime | sed 's|.*/zoneinfo/||'; elif [ -f /etc/timezone ]; then cat /etc/timezone; elif [ -f /etc/TIMEZONE ]; then grep '^TZ=' /etc/TIMEZONE | cut -d= -f2; else date +%Z; fi` -) +// unixDateCmd gets the current UTC time. Used when RunCommand is available. +const unixDateCmd = `date -u +%Y-%m-%dT%H:%M:%SZ` type Unix struct { conn shared.Connection @@ -45,43 +28,192 @@ func (u *Unix) Name() string { } func (u *Unix) Get() (*Result, error) { - // Get UTC time - cmd, err := u.conn.RunCommand(unixDateCmd) - if err != nil { - return nil, fmt.Errorf("failed to get system date: %w", err) - } - utcTime, err := parseUTCTime(cmd.Stdout) - if err != nil { - return nil, err + canRunCmd := u.conn.Capabilities().Has(shared.Capability_RunCommand) + + // Get UTC time only if we can actually ask the remote system. + // For static targets (EBS snapshots, Docker images) there is no + // meaningful current time, so we leave it nil. + var utcTime *time.Time + if canRunCmd { + cmd, err := u.conn.RunCommand(unixDateCmd) + if err != nil { + return nil, fmt.Errorf("failed to get system date: %w", err) + } + t, err := parseUTCTime(cmd.Stdout) + if err != nil { + return nil, err + } + utcTime = &t } - // Get timezone - cmd, err = u.conn.RunCommand(unixTimezoneCmd) - if err != nil { - return nil, fmt.Errorf("failed to get system timezone: %w", err) + // Get timezone: try filesystem first (works on EBS snapshots, Docker images), + // fall back to command if filesystem detection fails + tz, err := timezoneFromFS(u.conn.FileSystem()) + if err != nil && canRunCmd { + tz, err = timezoneFromCmd(u.conn) } - tz, err := parseTimezone(cmd.Stdout) if err != nil { - return nil, err + // If all methods fail, default to UTC + return &Result{ + Time: utcTime, + Timezone: "UTC", + }, nil } - // Load the timezone location and convert the UTC time loc, err := time.LoadLocation(tz) if err != nil { - // If the timezone can't be loaded (e.g., abbreviated name like "EST"), - // return the UTC time with the timezone string as-is return &Result{ Time: utcTime, Timezone: tz, }, nil } + if utcTime != nil { + t := utcTime.In(loc) + utcTime = &t + } + return &Result{ - Time: utcTime.In(loc), + Time: utcTime, Timezone: tz, }, nil } +// timezoneFromFS detects the IANA timezone by reading filesystem artifacts. +// It tries these approaches in order: +// 1. readlink /etc/localtime → extract IANA name from symlink target +// 2. Read /etc/timezone (Debian/Ubuntu) +// 3. Parse TZ= from /etc/TIMEZONE (Solaris/AIX) +// 4. Match /etc/localtime contents against the system zoneinfo database +func timezoneFromFS(fs afero.Fs) (string, error) { + // 1. Try readlink on /etc/localtime + if lr, ok := fs.(afero.LinkReader); ok { + if target, err := lr.ReadlinkIfPossible("/etc/localtime"); err == nil { + if tz := extractTZFromPath(target); tz != "" { + return tz, nil + } + } + } + + // 2. Try /etc/timezone (Debian/Ubuntu) + if f, err := fs.Open("/etc/timezone"); err == nil { + defer f.Close() + content, err := io.ReadAll(f) + if err == nil { + if tz := strings.TrimSpace(string(content)); tz != "" { + return tz, nil + } + } + } + + // 3. Try /etc/TIMEZONE (Solaris/AIX) - look for TZ= + if f, err := fs.Open("/etc/TIMEZONE"); err == nil { + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if tz, ok := strings.CutPrefix(line, "TZ="); ok && tz != "" { + return tz, nil + } + } + } + + // 4. Try matching /etc/localtime binary content against zoneinfo database + if tz, err := matchLocaltimeToZoneinfo(fs); err == nil { + return tz, nil + } + + return "", fmt.Errorf("could not detect timezone from filesystem") +} + +// extractTZFromPath extracts the IANA timezone name from a symlink target path. +// e.g., "/usr/share/zoneinfo/America/New_York" → "America/New_York" +func extractTZFromPath(path string) string { + const marker = "zoneinfo/" + if idx := strings.LastIndex(path, marker); idx >= 0 { + tz := path[idx+len(marker):] + if tz != "" && tz != "localtime" { + return tz + } + } + return "" +} + +// matchLocaltimeToZoneinfo reads /etc/localtime and tries to find a matching +// zoneinfo file. This handles cases where /etc/localtime is a regular file +// (copy, not symlink), common in Docker images. +func matchLocaltimeToZoneinfo(fs afero.Fs) (string, error) { + localtime, err := afero.ReadFile(fs, "/etc/localtime") + if err != nil { + return "", err + } + if len(localtime) < 4 || string(localtime[:4]) != "TZif" { + return "", fmt.Errorf("/etc/localtime is not a valid TZif file") + } + + // Walk common zoneinfo directories to find a match + for _, base := range []string{"/usr/share/zoneinfo", "/usr/share/lib/zoneinfo"} { + tz, err := findMatchingZoneinfo(fs, base, localtime) + if err == nil { + return tz, nil + } + } + return "", fmt.Errorf("no matching zoneinfo file found") +} + +// findMatchingZoneinfo walks a zoneinfo directory tree comparing file contents +// to the given localtime data. +func findMatchingZoneinfo(fs afero.Fs, base string, localtime []byte) (string, error) { + var match string + err := afero.Walk(fs, base, func(path string, info os.FileInfo, err error) error { + if err != nil || match != "" { + return err + } + if info.IsDir() { + // Skip directories that aren't timezone data + name := info.Name() + if name == "posix" || name == "right" { + return filepath.SkipDir + } + return nil + } + // Only compare regular files + if !info.Mode().IsRegular() { + return nil + } + + candidate, err := afero.ReadFile(fs, path) + if err != nil { + return nil // skip unreadable files + } + if len(candidate) == len(localtime) && string(candidate) == string(localtime) { + rel := strings.TrimPrefix(path, base+"/") + // Validate it looks like an IANA name (contains a slash, e.g. "America/New_York") + if strings.Contains(rel, "/") { + match = rel + } + } + return nil + }) + if err != nil { + return "", err + } + if match == "" { + return "", fmt.Errorf("no match found in %s", base) + } + return match, nil +} + +// timezoneFromCmd gets the abbreviated timezone name via `date +%Z`. +// This is a last resort — it only returns short names like "EST", not IANA names. +func timezoneFromCmd(conn shared.Connection) (string, error) { + cmd, err := conn.RunCommand("date +%Z") + if err != nil { + return "", fmt.Errorf("failed to get system timezone: %w", err) + } + return parseTimezone(cmd.Stdout) +} + func parseUTCTime(r io.Reader) (time.Time, error) { content, err := io.ReadAll(r) if err != nil { diff --git a/providers/os/resources/date/unix_test.go b/providers/os/resources/date/unix_test.go index 802a2857b9..01f285b8dd 100644 --- a/providers/os/resources/date/unix_test.go +++ b/providers/os/resources/date/unix_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -85,3 +86,124 @@ func TestParseTimezone(t *testing.T) { }) } } + +func TestExtractTZFromPath(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + { + name: "standard Linux path", + path: "/usr/share/zoneinfo/America/New_York", + want: "America/New_York", + }, + { + name: "macOS path", + path: "/var/db/timezone/zoneinfo/Europe/London", + want: "Europe/London", + }, + { + name: "posix subdirectory path returns full relative", + path: "/usr/share/zoneinfo/posix/Asia/Tokyo", + want: "posix/Asia/Tokyo", + }, + { + name: "no zoneinfo marker", + path: "/some/other/path", + want: "", + }, + { + name: "zoneinfo at end with nothing after", + path: "/usr/share/zoneinfo/", + want: "", + }, + { + name: "localtime self-reference", + path: "/usr/share/zoneinfo/localtime", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, extractTZFromPath(tt.path)) + }) + } +} + +func TestTimezoneFromFS_EtcTimezone(t *testing.T) { + // Simulate a Debian/Ubuntu system with /etc/timezone + fs := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(fs, "/etc/timezone", []byte("Europe/Berlin\n"), 0o644)) + + tz, err := timezoneFromFS(fs) + require.NoError(t, err) + assert.Equal(t, "Europe/Berlin", tz) +} + +func TestTimezoneFromFS_EtcTIMEZONE(t *testing.T) { + // Simulate a Solaris/AIX system with /etc/TIMEZONE + fs := afero.NewMemMapFs() + require.NoError(t, afero.WriteFile(fs, "/etc/TIMEZONE", []byte("# timezone config\nTZ=US/Eastern\n"), 0o644)) + + tz, err := timezoneFromFS(fs) + require.NoError(t, err) + assert.Equal(t, "US/Eastern", tz) +} + +func TestTimezoneFromFS_LocaltimeBinaryMatch(t *testing.T) { + // Simulate a Docker image where /etc/localtime is a copied TZif file + fs := afero.NewMemMapFs() + + // Create a fake but valid TZif file + tzifData := []byte("TZif" + strings.Repeat("\x00", 40) + "some timezone data here") + + // Write it as /etc/localtime (a regular file, not a symlink) + require.NoError(t, afero.WriteFile(fs, "/etc/localtime", tzifData, 0o644)) + + // Create a matching file in the zoneinfo tree + require.NoError(t, afero.WriteFile(fs, "/usr/share/zoneinfo/America/Chicago", tzifData, 0o644)) + + // Create a non-matching file to make sure we don't false-positive + require.NoError(t, afero.WriteFile(fs, "/usr/share/zoneinfo/America/Denver", []byte("TZif"+strings.Repeat("\x00", 40)+"different data"), 0o644)) + + tz, err := timezoneFromFS(fs) + require.NoError(t, err) + assert.Equal(t, "America/Chicago", tz) +} + +func TestTimezoneFromFS_NoTimezoneInfo(t *testing.T) { + // Empty filesystem - should fail + fs := afero.NewMemMapFs() + + _, err := timezoneFromFS(fs) + require.Error(t, err) +} + +func TestTimezoneFromFS_SkipsPosixAndRight(t *testing.T) { + fs := afero.NewMemMapFs() + + tzifData := []byte("TZif" + strings.Repeat("\x00", 40) + "unique tz data") + require.NoError(t, afero.WriteFile(fs, "/etc/localtime", tzifData, 0o644)) + + // Put matching file only under posix/ - should be skipped + require.NoError(t, afero.WriteFile(fs, "/usr/share/zoneinfo/posix/America/Chicago", tzifData, 0o644)) + + // Put real match under proper path + require.NoError(t, afero.WriteFile(fs, "/usr/share/zoneinfo/America/Chicago", tzifData, 0o644)) + + tz, err := timezoneFromFS(fs) + require.NoError(t, err) + assert.Equal(t, "America/Chicago", tz) +} + +func TestTimezoneFromFS_InvalidLocaltime(t *testing.T) { + fs := afero.NewMemMapFs() + + // /etc/localtime exists but isn't a valid TZif file + require.NoError(t, afero.WriteFile(fs, "/etc/localtime", []byte("not a tzif file"), 0o644)) + + _, err := timezoneFromFS(fs) + require.Error(t, err) +} diff --git a/providers/os/resources/date/windows.go b/providers/os/resources/date/windows.go index e408a1df94..6ef2ca068d 100644 --- a/providers/os/resources/date/windows.go +++ b/providers/os/resources/date/windows.go @@ -59,13 +59,14 @@ func (w *Windows) parse(r io.Reader) (*Result, error) { if err != nil { // Fall back to returning UTC time with the Windows timezone ID return &Result{ - Time: t, + Time: &t, Timezone: res.Timezone, }, nil } + locT := t.In(loc) return &Result{ - Time: t.In(loc), + Time: &locT, Timezone: res.Timezone, }, nil } diff --git a/providers/os/resources/os.lr b/providers/os/resources/os.lr index 8fc66ab9ec..b408937199 100644 --- a/providers/os/resources/os.lr +++ b/providers/os/resources/os.lr @@ -262,7 +262,7 @@ os { } // Operating system date and timezone information -os.date { +os.date @defaults("time timezone") { // Current system time time() time // System timezone (e.g., "America/New_York", "UTC") diff --git a/providers/os/resources/os_date.go b/providers/os/resources/os_date.go index 66d7415caa..80a2dfb46e 100644 --- a/providers/os/resources/os_date.go +++ b/providers/os/resources/os_date.go @@ -61,7 +61,7 @@ func (d *mqlOsDate) time() (*time.Time, error) { if err != nil { return nil, err } - return &res.Time, nil + return res.Time, nil } func (d *mqlOsDate) timezone() (string, error) { From 28b8729e8f61d6a960e7f6f8d7cfffdc61b27cfe Mon Sep 17 00:00:00 2001 From: Tim Smith Date: Tue, 17 Mar 2026 22:58:44 -0700 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9A=A1=20Optimize=20timezone=20detection?= =?UTF-8?q?=20for=20tar-backed=20filesystems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add fast paths to avoid walking the entire zoneinfo tree when resolving /etc/localtime, which is extremely slow on tar-backed filesystems (Docker images, EBS snapshots). Three strategies are tried in order: parsing the TZif v2/v3 footer for a POSIX TZ string, direct reads of common timezone paths, and finally a capped directory walk. Co-Authored-By: Claude Opus 4.6 (1M context) --- providers/os/resources/date/unix.go | 144 ++++++++++++++++++++++- providers/os/resources/date/unix_test.go | 140 ++++++++++++++++++++++ 2 files changed, 282 insertions(+), 2 deletions(-) diff --git a/providers/os/resources/date/unix.go b/providers/os/resources/date/unix.go index 7fe8447402..0b0cf4e523 100644 --- a/providers/os/resources/date/unix.go +++ b/providers/os/resources/date/unix.go @@ -5,6 +5,7 @@ package date import ( "bufio" + "errors" "fmt" "io" "os" @@ -151,7 +152,21 @@ func matchLocaltimeToZoneinfo(fs afero.Fs) (string, error) { return "", fmt.Errorf("/etc/localtime is not a valid TZif file") } - // Walk common zoneinfo directories to find a match + // Fast path: parse the TZif v2/v3 footer for a POSIX TZ string. + // This avoids walking the entire zoneinfo tree, which is extremely + // expensive on tar-backed filesystems (Docker images, EBS snapshots). + if tz, err := tzFromTZifFooter(localtime); err == nil { + return tz, nil + } + + // Slow path: compare file contents against known zoneinfo files. + // Use direct reads of common timezone paths instead of walking the + // entire directory tree, which would extract every file from a tar. + if tz, err := matchLocaltimeByCommonPaths(fs, localtime); err == nil { + return tz, nil + } + + // Last resort: walk the zoneinfo tree with a file count cap. for _, base := range []string{"/usr/share/zoneinfo", "/usr/share/lib/zoneinfo"} { tz, err := findMatchingZoneinfo(fs, base, localtime) if err == nil { @@ -161,10 +176,129 @@ func matchLocaltimeToZoneinfo(fs afero.Fs) (string, error) { return "", fmt.Errorf("no matching zoneinfo file found") } +// tzFromTZifFooter extracts the POSIX TZ string from a TZif version 2 or 3 +// file's footer and maps it to an IANA timezone name when possible. +func tzFromTZifFooter(data []byte) (string, error) { + if len(data) < 5 { + return "", fmt.Errorf("data too short") + } + + version := data[4] + if version != '2' && version != '3' && version != '4' { + return "", fmt.Errorf("TZif version %c has no footer", version) + } + + // The v2/v3 footer is at the very end: \n\n + // Search backwards for the pattern. + end := len(data) + if data[end-1] != '\n' { + return "", fmt.Errorf("no trailing newline in TZif footer") + } + // Find the preceding newline + start := end - 2 + for start >= 0 && data[start] != '\n' { + start-- + } + if start < 0 { + return "", fmt.Errorf("no footer found") + } + + posixTZ := strings.TrimSpace(string(data[start+1 : end-1])) + if posixTZ == "" || posixTZ == "UTC" || posixTZ == "UTC0" || posixTZ == "UTC-0" { + return "UTC", nil + } + + // Try mapping common POSIX TZ strings to IANA names + if iana, ok := posixToIANA[posixTZ]; ok { + return iana, nil + } + + return "", fmt.Errorf("unmapped POSIX TZ string: %s", posixTZ) +} + +// posixToIANA maps common POSIX TZ strings (from TZif footers) to IANA names. +// This covers the most common timezones; the walk fallback handles the rest. +var posixToIANA = map[string]string{ + "EST5EDT,M3.2.0,M11.1.0": "America/New_York", + "CST6CDT,M3.2.0,M11.1.0": "America/Chicago", + "MST7MDT,M3.2.0,M11.1.0": "America/Denver", + "PST8PDT,M3.2.0,M11.1.0": "America/Los_Angeles", + "MST7": "America/Phoenix", + "HST10": "Pacific/Honolulu", + "AKST9AKDT,M3.2.0,M11.1.0": "America/Anchorage", + "GMT0BST,M3.5.0/1,M10.5.0": "Europe/London", + "CET-1CEST,M3.5.0,M10.5.0/3": "Europe/Berlin", + "EET-2EEST,M3.5.0/3,M10.5.0/4": "Europe/Helsinki", + "IST-5:30": "Asia/Kolkata", + "JST-9": "Asia/Tokyo", + "CST-8": "Asia/Shanghai", + "KST-9": "Asia/Seoul", + "AEST-10AEDT,M10.1.0,M4.1.0/3": "Australia/Sydney", + "NZST-12NZDT,M9.5.0,M4.1.0/3": "Pacific/Auckland", + "<-03>3": "America/Sao_Paulo", + "WET0WEST,M3.5.0/1,M10.5.0": "Europe/Lisbon", + "<+07>-7": "Asia/Bangkok", + "<+05>-5": "Asia/Tashkent", + "<+04>-4": "Asia/Dubai", + "<+03>-3": "Europe/Moscow", + "<+02>-2": "Africa/Cairo", + "<+01>-1": "Africa/Lagos", + "<-05>5": "America/Bogota", + "<-06>6": "America/Mexico_City", +} + +// commonTimezones is a list of frequently-used IANA timezone names, tried +// before falling back to a full directory walk. +var commonTimezones = []string{ + "UTC", "US/Eastern", "US/Central", "US/Mountain", "US/Pacific", + "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", + "America/Phoenix", "America/Anchorage", "Pacific/Honolulu", + "Europe/London", "Europe/Berlin", "Europe/Paris", "Europe/Moscow", + "Asia/Tokyo", "Asia/Shanghai", "Asia/Kolkata", "Asia/Dubai", + "Australia/Sydney", "Pacific/Auckland", + "America/Toronto", "America/Vancouver", "America/Sao_Paulo", "America/Mexico_City", + "America/Argentina/Buenos_Aires", "America/Bogota", "America/Lima", + "Europe/Rome", "Europe/Madrid", "Europe/Amsterdam", "Europe/Helsinki", + "Europe/Istanbul", "Europe/Warsaw", "Europe/Zurich", + "Asia/Seoul", "Asia/Singapore", "Asia/Hong_Kong", "Asia/Bangkok", + "Asia/Jakarta", "Asia/Taipei", "Asia/Tashkent", + "Africa/Cairo", "Africa/Lagos", "Africa/Johannesburg", + "Australia/Melbourne", "Australia/Perth", "Australia/Brisbane", + "Etc/UTC", "Etc/GMT", +} + +// matchLocaltimeByCommonPaths checks /etc/localtime against a curated list of +// common timezone files by direct path, avoiding a full directory walk. +func matchLocaltimeByCommonPaths(fs afero.Fs, localtime []byte) (string, error) { + for _, base := range []string{"/usr/share/zoneinfo", "/usr/share/lib/zoneinfo"} { + for _, tz := range commonTimezones { + candidate, err := afero.ReadFile(fs, base+"/"+tz) + if err != nil { + continue + } + if len(candidate) == len(localtime) && string(candidate) == string(localtime) { + return tz, nil + } + } + } + return "", fmt.Errorf("no common timezone matched") +} + +// errMatchFound is a sentinel error used to stop walking the zoneinfo tree +// once a matching timezone file has been found. +var errMatchFound = errors.New("match found") + +// maxZoneinfoFiles limits the number of files compared during a full zoneinfo +// tree walk. This prevents pathological performance on tar-backed filesystems +// where each file read requires extraction. 600 covers the ~350 real IANA +// zones plus overhead for legacy aliases and alternate directory layouts. +const maxZoneinfoFiles = 600 + // findMatchingZoneinfo walks a zoneinfo directory tree comparing file contents // to the given localtime data. func findMatchingZoneinfo(fs afero.Fs, base string, localtime []byte) (string, error) { var match string + var filesChecked int err := afero.Walk(fs, base, func(path string, info os.FileInfo, err error) error { if err != nil || match != "" { return err @@ -182,6 +316,11 @@ func findMatchingZoneinfo(fs afero.Fs, base string, localtime []byte) (string, e return nil } + filesChecked++ + if filesChecked > maxZoneinfoFiles { + return errMatchFound // bail out, we've checked enough + } + candidate, err := afero.ReadFile(fs, path) if err != nil { return nil // skip unreadable files @@ -191,11 +330,12 @@ func findMatchingZoneinfo(fs afero.Fs, base string, localtime []byte) (string, e // Validate it looks like an IANA name (contains a slash, e.g. "America/New_York") if strings.Contains(rel, "/") { match = rel + return errMatchFound } } return nil }) - if err != nil { + if err != nil && !errors.Is(err, errMatchFound) { return "", err } if match == "" { diff --git a/providers/os/resources/date/unix_test.go b/providers/os/resources/date/unix_test.go index 01f285b8dd..f66acbda5f 100644 --- a/providers/os/resources/date/unix_test.go +++ b/providers/os/resources/date/unix_test.go @@ -207,3 +207,143 @@ func TestTimezoneFromFS_InvalidLocaltime(t *testing.T) { _, err := timezoneFromFS(fs) require.Error(t, err) } + +// buildTZifV2 creates a minimal valid TZif v2 file with the given POSIX TZ +// footer string. The data section is minimal (no transitions) but structurally +// valid, so it can be used for both footer parsing and binary matching tests. +func buildTZifV2(posixTZ string) []byte { + // V1 header (44 bytes) + V1 data section + v1Header := make([]byte, 44) + copy(v1Header[0:4], "TZif") + v1Header[4] = '2' // version + // All counts are zero → minimal v1 data section is just the header + // plus the ttinfo (6 bytes) and the designation (\x00) + // But the simplest valid v1 section: 0 transitions, 1 ttinfo, 1 char + v1Header[32] = 0 // tzh_timecnt = 0 (big-endian uint32, high bytes) + v1Header[35] = 0 + v1Header[36] = 0 // tzh_typecnt = 0 + v1Header[39] = 1 // need at least 1 for valid file + v1Header[40] = 0 // tzh_charcnt = 0 + v1Header[43] = 4 // 4 chars ("UTC\0") + + // V1 data: 1 ttinfo (6 bytes: 4 offset + 1 dst + 1 idx) + 4 chars + v1Data := []byte{0, 0, 0, 0, 0, 0, 'U', 'T', 'C', 0} + + // V2 header (same as v1 header) + v2Header := make([]byte, 44) + copy(v2Header, v1Header) + + // V2 data (same minimal structure) + v2Data := make([]byte, len(v1Data)) + copy(v2Data, v1Data) + + // Footer: \n\n + footer := []byte("\n" + posixTZ + "\n") + + result := make([]byte, 0, len(v1Header)+len(v1Data)+len(v2Header)+len(v2Data)+len(footer)) + result = append(result, v1Header...) + result = append(result, v1Data...) + result = append(result, v2Header...) + result = append(result, v2Data...) + result = append(result, footer...) + return result +} + +func TestTzFromTZifFooter(t *testing.T) { + tests := []struct { + name string + posixTZ string + want string + wantErr bool + }{ + {name: "UTC0", posixTZ: "UTC0", want: "UTC"}, + {name: "empty string means UTC", posixTZ: "", want: "UTC"}, + {name: "UTC", posixTZ: "UTC", want: "UTC"}, + {name: "EST5EDT mapped", posixTZ: "EST5EDT,M3.2.0,M11.1.0", want: "America/New_York"}, + {name: "CST6CDT mapped", posixTZ: "CST6CDT,M3.2.0,M11.1.0", want: "America/Chicago"}, + {name: "PST8PDT mapped", posixTZ: "PST8PDT,M3.2.0,M11.1.0", want: "America/Los_Angeles"}, + {name: "JST-9 mapped", posixTZ: "JST-9", want: "Asia/Tokyo"}, + {name: "unmapped string", posixTZ: "WEIRD3", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := buildTZifV2(tt.posixTZ) + got, err := tzFromTZifFooter(data) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestTzFromTZifFooter_V1Only(t *testing.T) { + // A v1 TZif file has no footer → should error + v1 := make([]byte, 44+10) // header + minimal data + copy(v1[0:4], "TZif") + v1[4] = 0 // version 1 (no version byte) + v1[39] = 1 + v1[43] = 4 + copy(v1[44:], []byte{0, 0, 0, 0, 0, 0, 'U', 'T', 'C', 0}) + + _, err := tzFromTZifFooter(v1) + require.Error(t, err) +} + +func TestMatchLocaltimeByCommonPaths(t *testing.T) { + fs := afero.NewMemMapFs() + + // Create a TZif file for /etc/localtime + localtime := buildTZifV2("CST6CDT,M3.2.0,M11.1.0") + require.NoError(t, afero.WriteFile(fs, "/etc/localtime", localtime, 0o644)) + + // Put a matching file at America/Chicago (which is in commonTimezones) + require.NoError(t, afero.WriteFile(fs, "/usr/share/zoneinfo/America/Chicago", localtime, 0o644)) + + // Put a non-matching file at a different path + other := buildTZifV2("PST8PDT,M3.2.0,M11.1.0") + require.NoError(t, afero.WriteFile(fs, "/usr/share/zoneinfo/America/New_York", other, 0o644)) + + tz, err := matchLocaltimeByCommonPaths(fs, localtime) + require.NoError(t, err) + assert.Equal(t, "America/Chicago", tz) +} + +func TestMatchLocaltimeByCommonPaths_NoMatch(t *testing.T) { + fs := afero.NewMemMapFs() + localtime := buildTZifV2("WEIRD3") + + // No matching file in common paths + _, err := matchLocaltimeByCommonPaths(fs, localtime) + require.Error(t, err) +} + +func TestTimezoneFromFS_UTCDockerImage(t *testing.T) { + // Simulates a Docker image with UTC timezone: /etc/localtime is a + // regular file (not symlink), no /etc/timezone. The TZif footer + // should be parsed directly without walking the zoneinfo tree. + fs := afero.NewMemMapFs() + + utcData := buildTZifV2("UTC0") + require.NoError(t, afero.WriteFile(fs, "/etc/localtime", utcData, 0o644)) + + tz, err := timezoneFromFS(fs) + require.NoError(t, err) + assert.Equal(t, "UTC", tz) +} + +func TestTimezoneFromFS_NonUTCDockerImage(t *testing.T) { + // Docker image with a non-UTC timezone, /etc/localtime is a copied + // TZif file with a recognized POSIX TZ footer. + fs := afero.NewMemMapFs() + + tokyoData := buildTZifV2("JST-9") + require.NoError(t, afero.WriteFile(fs, "/etc/localtime", tokyoData, 0o644)) + + tz, err := timezoneFromFS(fs) + require.NoError(t, err) + assert.Equal(t, "Asia/Tokyo", tz) +} From 1ecae54e647eb04286798f9fa622774143b8e27c Mon Sep 17 00:00:00 2001 From: Tim Smith Date: Tue, 17 Mar 2026 23:11:44 -0700 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=A7=B9=20Address=20review:=20rename?= =?UTF-8?q?=20sentinel,=20use=20bytes.Equal,=20document=20ambiguity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename errMatchFound → errWalkDone (clearer when hit by file count limit) - Use bytes.Equal instead of string conversion for byte slice comparison - Add comment documenting ambiguous POSIX→IANA mappings in fast path Co-Authored-By: Claude Opus 4.6 (1M context) --- providers/os/resources/date/unix.go | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/providers/os/resources/date/unix.go b/providers/os/resources/date/unix.go index 0b0cf4e523..6d955b9ca3 100644 --- a/providers/os/resources/date/unix.go +++ b/providers/os/resources/date/unix.go @@ -5,6 +5,7 @@ package date import ( "bufio" + "bytes" "errors" "fmt" "io" @@ -218,6 +219,10 @@ func tzFromTZifFooter(data []byte) (string, error) { // posixToIANA maps common POSIX TZ strings (from TZif footers) to IANA names. // This covers the most common timezones; the walk fallback handles the rest. +// Note: some mappings are ambiguous (e.g., "CST-8" matches Asia/Shanghai, +// Asia/Taipei, and Asia/Hong_Kong). We pick a representative zone for each +// POSIX string. This is a best-effort fast path — the walk fallback will +// find the exact match if the footer mapping is wrong for a given system. var posixToIANA = map[string]string{ "EST5EDT,M3.2.0,M11.1.0": "America/New_York", "CST6CDT,M3.2.0,M11.1.0": "America/Chicago", @@ -276,7 +281,7 @@ func matchLocaltimeByCommonPaths(fs afero.Fs, localtime []byte) (string, error) if err != nil { continue } - if len(candidate) == len(localtime) && string(candidate) == string(localtime) { + if bytes.Equal(candidate, localtime) { return tz, nil } } @@ -284,9 +289,9 @@ func matchLocaltimeByCommonPaths(fs afero.Fs, localtime []byte) (string, error) return "", fmt.Errorf("no common timezone matched") } -// errMatchFound is a sentinel error used to stop walking the zoneinfo tree -// once a matching timezone file has been found. -var errMatchFound = errors.New("match found") +// errWalkDone is a sentinel error used to stop walking the zoneinfo tree +// early, either because a match was found or the file count limit was reached. +var errWalkDone = errors.New("walk done") // maxZoneinfoFiles limits the number of files compared during a full zoneinfo // tree walk. This prevents pathological performance on tar-backed filesystems @@ -318,24 +323,24 @@ func findMatchingZoneinfo(fs afero.Fs, base string, localtime []byte) (string, e filesChecked++ if filesChecked > maxZoneinfoFiles { - return errMatchFound // bail out, we've checked enough + return errWalkDone // bail out, we've checked enough } candidate, err := afero.ReadFile(fs, path) if err != nil { return nil // skip unreadable files } - if len(candidate) == len(localtime) && string(candidate) == string(localtime) { + if bytes.Equal(candidate, localtime) { rel := strings.TrimPrefix(path, base+"/") // Validate it looks like an IANA name (contains a slash, e.g. "America/New_York") if strings.Contains(rel, "/") { match = rel - return errMatchFound + return errWalkDone } } return nil }) - if err != nil && !errors.Is(err, errMatchFound) { + if err != nil && !errors.Is(err, errWalkDone) { return "", err } if match == "" { From 5704c51ad49ded0ba1ebbd22de6327967b0200fb Mon Sep 17 00:00:00 2001 From: Tim Smith Date: Tue, 17 Mar 2026 23:20:13 -0700 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=90=9B=20Fix=20timezone=20extraction?= =?UTF-8?q?=20for=20posix/right=20symlinks,=20add=20RunCommand=20guard=20f?= =?UTF-8?q?or=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip posix/ and right/ prefixes from zoneinfo symlink targets so /etc/localtime → .../zoneinfo/posix/Asia/Tokyo correctly returns "Asia/Tokyo" instead of the invalid "posix/Asia/Tokyo". Add Capability_RunCommand check to Windows date provider, matching the Unix implementation, so static targets don't error. Co-Authored-By: Claude Opus 4.6 (1M context) --- providers/os/resources/date/unix.go | 58 ++++++++++++++---------- providers/os/resources/date/unix_test.go | 9 +++- providers/os/resources/date/windows.go | 4 ++ 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/providers/os/resources/date/unix.go b/providers/os/resources/date/unix.go index 6d955b9ca3..3cce281202 100644 --- a/providers/os/resources/date/unix.go +++ b/providers/os/resources/date/unix.go @@ -134,9 +134,17 @@ func extractTZFromPath(path string) string { const marker = "zoneinfo/" if idx := strings.LastIndex(path, marker); idx >= 0 { tz := path[idx+len(marker):] - if tz != "" && tz != "localtime" { - return tz + if tz == "" || tz == "localtime" { + return "" } + // Strip posix/ and right/ prefixes — these are alternate + // representations of the same zones, not valid IANA names. + tz = strings.TrimPrefix(tz, "posix/") + tz = strings.TrimPrefix(tz, "right/") + if tz == "" { + return "" + } + return tz } return "" } @@ -224,32 +232,32 @@ func tzFromTZifFooter(data []byte) (string, error) { // POSIX string. This is a best-effort fast path — the walk fallback will // find the exact match if the footer mapping is wrong for a given system. var posixToIANA = map[string]string{ - "EST5EDT,M3.2.0,M11.1.0": "America/New_York", - "CST6CDT,M3.2.0,M11.1.0": "America/Chicago", - "MST7MDT,M3.2.0,M11.1.0": "America/Denver", - "PST8PDT,M3.2.0,M11.1.0": "America/Los_Angeles", - "MST7": "America/Phoenix", - "HST10": "Pacific/Honolulu", - "AKST9AKDT,M3.2.0,M11.1.0": "America/Anchorage", - "GMT0BST,M3.5.0/1,M10.5.0": "Europe/London", - "CET-1CEST,M3.5.0,M10.5.0/3": "Europe/Berlin", + "EST5EDT,M3.2.0,M11.1.0": "America/New_York", + "CST6CDT,M3.2.0,M11.1.0": "America/Chicago", + "MST7MDT,M3.2.0,M11.1.0": "America/Denver", + "PST8PDT,M3.2.0,M11.1.0": "America/Los_Angeles", + "MST7": "America/Phoenix", + "HST10": "Pacific/Honolulu", + "AKST9AKDT,M3.2.0,M11.1.0": "America/Anchorage", + "GMT0BST,M3.5.0/1,M10.5.0": "Europe/London", + "CET-1CEST,M3.5.0,M10.5.0/3": "Europe/Berlin", "EET-2EEST,M3.5.0/3,M10.5.0/4": "Europe/Helsinki", - "IST-5:30": "Asia/Kolkata", - "JST-9": "Asia/Tokyo", - "CST-8": "Asia/Shanghai", - "KST-9": "Asia/Seoul", + "IST-5:30": "Asia/Kolkata", + "JST-9": "Asia/Tokyo", + "CST-8": "Asia/Shanghai", + "KST-9": "Asia/Seoul", "AEST-10AEDT,M10.1.0,M4.1.0/3": "Australia/Sydney", "NZST-12NZDT,M9.5.0,M4.1.0/3": "Pacific/Auckland", - "<-03>3": "America/Sao_Paulo", - "WET0WEST,M3.5.0/1,M10.5.0": "Europe/Lisbon", - "<+07>-7": "Asia/Bangkok", - "<+05>-5": "Asia/Tashkent", - "<+04>-4": "Asia/Dubai", - "<+03>-3": "Europe/Moscow", - "<+02>-2": "Africa/Cairo", - "<+01>-1": "Africa/Lagos", - "<-05>5": "America/Bogota", - "<-06>6": "America/Mexico_City", + "<-03>3": "America/Sao_Paulo", + "WET0WEST,M3.5.0/1,M10.5.0": "Europe/Lisbon", + "<+07>-7": "Asia/Bangkok", + "<+05>-5": "Asia/Tashkent", + "<+04>-4": "Asia/Dubai", + "<+03>-3": "Europe/Moscow", + "<+02>-2": "Africa/Cairo", + "<+01>-1": "Africa/Lagos", + "<-05>5": "America/Bogota", + "<-06>6": "America/Mexico_City", } // commonTimezones is a list of frequently-used IANA timezone names, tried diff --git a/providers/os/resources/date/unix_test.go b/providers/os/resources/date/unix_test.go index f66acbda5f..02861d7b06 100644 --- a/providers/os/resources/date/unix_test.go +++ b/providers/os/resources/date/unix_test.go @@ -104,9 +104,14 @@ func TestExtractTZFromPath(t *testing.T) { want: "Europe/London", }, { - name: "posix subdirectory path returns full relative", + name: "posix subdirectory path stripped", path: "/usr/share/zoneinfo/posix/Asia/Tokyo", - want: "posix/Asia/Tokyo", + want: "Asia/Tokyo", + }, + { + name: "right subdirectory path stripped", + path: "/usr/share/zoneinfo/right/Europe/Berlin", + want: "Europe/Berlin", }, { name: "no zoneinfo marker", diff --git a/providers/os/resources/date/windows.go b/providers/os/resources/date/windows.go index 6ef2ca068d..657ecf2cc5 100644 --- a/providers/os/resources/date/windows.go +++ b/providers/os/resources/date/windows.go @@ -30,6 +30,10 @@ func (w *Windows) Name() string { } func (w *Windows) Get() (*Result, error) { + if !w.conn.Capabilities().Has(shared.Capability_RunCommand) { + return &Result{Timezone: "UTC"}, nil + } + cmd, err := w.conn.RunCommand(powershell.Wrap(windowsDateCmd)) if err != nil { return nil, fmt.Errorf("failed to get system date: %w", err) From d99cb0c92012f77938187c2e0908e400f398fe5c Mon Sep 17 00:00:00 2001 From: Tim Smith Date: Wed, 18 Mar 2026 14:12:27 -0700 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=90=9B=20Fix=20nil=20return=20in=20os?= =?UTF-8?q?.date.time=20and=20correct=20version=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle nil res.Time by setting StateIsNull|StateIsSet before returning, preventing runtime panics on static targets (EBS snapshots, Docker images). Update os.date version entries from 13.2.4 to 13.2.6 (next after provider 13.2.5). Co-Authored-By: Claude Opus 4.6 (1M context) --- providers/os/resources/os.lr.versions | 6 +++--- providers/os/resources/os_date.go | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/providers/os/resources/os.lr.versions b/providers/os/resources/os.lr.versions index 23b85335d0..e53e51c2c2 100644 --- a/providers/os/resources/os.lr.versions +++ b/providers/os/resources/os.lr.versions @@ -701,9 +701,9 @@ os.base.rebootpending 9.0.0 os.base.updates 9.0.0 os.base.uptime 9.0.0 os.base.users 9.0.0 -os.date 13.2.4 -os.date.time 13.2.4 -os.date.timezone 13.2.4 +os.date 13.2.6 +os.date.time 13.2.6 +os.date.timezone 13.2.6 os.env 9.0.0 os.hostname 9.0.0 os.hypervisor 11.3.35 diff --git a/providers/os/resources/os_date.go b/providers/os/resources/os_date.go index 80a2dfb46e..21f06340dc 100644 --- a/providers/os/resources/os_date.go +++ b/providers/os/resources/os_date.go @@ -8,6 +8,7 @@ import ( "time" "go.mondoo.com/mql/v13/llx" + "go.mondoo.com/mql/v13/providers-sdk/v1/plugin" "go.mondoo.com/mql/v13/providers/os/connection/shared" "go.mondoo.com/mql/v13/providers/os/resources/date" ) @@ -61,6 +62,10 @@ func (d *mqlOsDate) time() (*time.Time, error) { if err != nil { return nil, err } + if res.Time == nil { + d.Time.State = plugin.StateIsNull | plugin.StateIsSet + return nil, nil + } return res.Time, nil }