From fb4291131fd91aa066d937ae565341f549fc0bb0 Mon Sep 17 00:00:00 2001 From: Richard Courtman Date: Sat, 29 Aug 2026 11:17:14 +0100 Subject: [PATCH] Harden TrueNAS SMART percentage parsing --- .../v6/internal/subsystems/monitoring.md | 4 +- internal/truenas/client.go | 67 +++++++++++++++++-- internal/truenas/client_test.go | 10 +++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index d97419aa0..7bfec6107 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -2643,7 +2643,9 @@ classes, typed `ue` and `sb` arguments from `alert.list` project onto canonical media-error and available-spare SMART fields after exact disk resolution. The provider must reject negative or out-of-range values, retain the worst value when duplicate evidence is present, and never derive a counter from formatted -alert text. +alert text. Available-spare parsing must validate the integer and its 0..100 +domain before conversion to the canonical `int` field; oversized, fractional, +or wrapped malformed values are rejected rather than narrowed or truncated. The same boundary owns TrueNAS `smart_status` normalization. `internal/truenas/client.go` must parse REST and RPC SMART status separately from native disk state, and `internal/truenas/disk_health.go` plus `internal/truenas/provider.go` must map diff --git a/internal/truenas/client.go b/internal/truenas/client.go index dd881901a..ff15206a8 100644 --- a/internal/truenas/client.go +++ b/internal/truenas/client.go @@ -1298,12 +1298,11 @@ func smartAlertEvidenceFromArgs(class string, args map[string]any) (int64, bool, return *value, true, 0, false } case "smartspareblockcount", "smartspareblockcountalert": - value := readInt64PtrAny(args, "sb") // TrueNAS publishes the normalized spare-block reserve as a percentage. - // Reject malformed/out-of-range values rather than projecting them onto - // the canonical percentage field. - if value != nil && *value >= 0 && *value <= 100 { - return 0, false, int(*value), true + // Parse directly into the canonical integer width and reject malformed or + // out-of-range values before projecting them onto the percentage field. + if value, ok := readSMARTPercentageAny(args, "sb"); ok { + return 0, false, value, true } } return 0, false, 0, false @@ -4788,6 +4787,64 @@ func readInt64PtrAny(record map[string]any, keys ...string) *int64 { return nil } +func readSMARTPercentageAny(record map[string]any, keys ...string) (int, bool) { + if record == nil { + return 0, false + } + for _, key := range keys { + value, ok := record[key] + if !ok || value == nil { + continue + } + if parsed, ok := parseSMARTPercentageAny(value); ok { + return parsed, true + } + } + return 0, false +} + +func parseSMARTPercentageAny(value any) (int, bool) { + var parsed int + switch typed := value.(type) { + case int: + parsed = typed + case int64: + if typed < 0 || typed > 100 { + return 0, false + } + parsed = int(typed) + case float64: + if math.Trunc(typed) != typed || typed < 0 || typed > 100 { + return 0, false + } + parsed = int(typed) + case json.Number: + return parseSMARTPercentageAny(typed.String()) + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" { + return 0, false + } + integer, err := strconv.Atoi(trimmed) + if err != nil { + return 0, false + } + parsed = integer + case map[string]any: + if nested, ok := firstAny(typed, "parsed", "value", "rawvalue", "raw"); ok { + return parseSMARTPercentageAny(nested) + } + return 0, false + default: + return 0, false + } + + if parsed < 0 || parsed > 100 { + return 0, false + } + return parsed, true +} + func parseInt64Any(value any) (int64, bool) { switch typed := value.(type) { case int64: diff --git a/internal/truenas/client_test.go b/internal/truenas/client_test.go index 655dd5294..d929a9cc3 100644 --- a/internal/truenas/client_test.go +++ b/internal/truenas/client_test.go @@ -374,8 +374,18 @@ func TestSMARTAlertEvidenceFromArgsUsesOnlyTypedNativeFields(t *testing.T) { name: "spare reserve", class: "SMARTSpareBlockCountAlert", args: map[string]any{"sb": "8"}, wantSpare: 8, wantSpareSeen: true, }, + { + name: "zero spare reserve", class: "SMARTSpareBlockCount", + args: map[string]any{"sb": json.Number("0")}, wantSpareSeen: true, + }, + { + name: "maximum spare reserve", class: "SMARTSpareBlockCountAlert", + args: map[string]any{"sb": map[string]any{"parsed": int64(100)}}, wantSpare: 100, wantSpareSeen: true, + }, {name: "negative counter rejected", class: "SMARTUncorrectedErrors", args: map[string]any{"ue": -1}}, {name: "out of range percentage rejected", class: "SMARTSpareBlockCount", args: map[string]any{"sb": 101}}, + {name: "oversized percentage rejected", class: "SMARTSpareBlockCount", args: map[string]any{"sb": "9223372036854775807"}}, + {name: "fractional percentage rejected", class: "SMARTSpareBlockCount", args: map[string]any{"sb": 8.5}}, {name: "formatted text is not parsed", class: "SMARTUncorrectedErrors", args: map[string]any{"text": "53 errors"}}, {name: "unrelated alert cannot inject smart data", class: "DiskTemperatureAlert", args: map[string]any{"ue": 53, "sb": 8}}, }