diff --git a/audit/internal/platform/sat.go b/audit/internal/platform/sat.go index 38fd515..4b026d9 100644 --- a/audit/internal/platform/sat.go +++ b/audit/internal/platform/sat.go @@ -1259,7 +1259,7 @@ func storageSATCommands(devPath string, extended bool) []satJob { return jobs } jobs := []satJob{ - {name: "smartctl-health", cmd: []string{"smartctl", "-H", "-A", devPath}}, + {name: "smartctl-health", cmd: []string{"smartctl", "-H", "-A", "-i", devPath}}, } if extended { jobs = append(jobs, satJob{name: "smartctl-self-test-short", cmd: []string{"smartctl", "-t", "short", devPath}}) diff --git a/audit/internal/platform/storage_report.go b/audit/internal/platform/storage_report.go index 94efc58..ebc270f 100644 --- a/audit/internal/platform/storage_report.go +++ b/audit/internal/platform/storage_report.go @@ -94,8 +94,8 @@ func writeNVMeReport(b *strings.Builder, outputs map[string][]byte) { nel := uint64(sl.NumErrLogEntries) // data_units are in 1000 × 512-byte sectors = 512,000 bytes each - dataRead := float64(sl.DataUnitsRead) * 512000 / 1e9 - dataWritten := float64(sl.DataUnitsWritten) * 512000 / 1e9 + readBytes := uint64(sl.DataUnitsRead) * 512000 + writtenBytes := uint64(sl.DataUnitsWritten) * 512000 writeSectionHeader(b, "Health") writeField(b, "Temperature", fmt.Sprintf("%d °C", tempC)) @@ -107,8 +107,8 @@ func writeNVMeReport(b *strings.Builder, outputs map[string][]byte) { writeField(b, "Power On Hours", fmt.Sprintf("%s h", formatUint(poh))) writeField(b, "Power Cycles", formatUint(pc)) writeField(b, "Unsafe Shutdowns", formatUint(us)) - writeField(b, "Data Written", fmt.Sprintf("%.1f GB", dataWritten)) - writeField(b, "Data Read", fmt.Sprintf("%.1f GB", dataRead)) + writeField(b, "Data Written", formatBytesHuman(float64(writtenBytes))) + writeField(b, "Data Read", formatBytesHuman(float64(readBytes))) writeSectionHeader(b, "Errors") writeField(b, "Media Errors", formatUint(me)) @@ -118,18 +118,22 @@ func writeNVMeReport(b *strings.Builder, outputs map[string][]byte) { if capacityBytes == 0 { capacityBytes = uint64(ctrl.NVMCapacity) } - writeResourceSection(b, resourceInfo{ + ri := resourceInfo{ powerOnHours: poh, - writtenBytes: uint64(sl.DataUnitsWritten) * 512000, - readBytes: uint64(sl.DataUnitsRead) * 512000, + powerCycles: pc, + writtenBytes: writtenBytes, + readBytes: readBytes, capacityBytes: capacityBytes, - }) + } + writeResourceSection(b, ri) if selfTest := outputs["nvme-device-self-test"]; len(selfTest) > 0 { writeSectionHeader(b, "Self-Test") result := parseSelfTestResult(string(selfTest)) writeField(b, "Result", result) } + + writeConclusionSection(b, ri) } // ── SATA / SAS (smartctl) ──────────────────────────────────────────────────── @@ -202,13 +206,15 @@ func writeSATAReport(b *strings.Builder, outputs map[string][]byte) { } } - var poh, writtenLBAs, readLBAs uint64 + var poh, pc, writtenLBAs, readLBAs uint64 var readValue int hasReadValue := false for _, a := range attrs { switch a.ID { case 9: // Power_On_Hours poh = parseLeadingUint(a.Raw) + case 12: // Power_Cycle_Count + pc = parseLeadingUint(a.Raw) case 241: // Total_LBAs_Written writtenLBAs = parseLeadingUint(a.Raw) case 242: // Total_LBAs_Read @@ -218,14 +224,16 @@ func writeSATAReport(b *strings.Builder, outputs map[string][]byte) { } } const sataSectorBytes = 512 - writeResourceSection(b, resourceInfo{ + ri := resourceInfo{ powerOnHours: poh, + powerCycles: pc, writtenBytes: writtenLBAs * sataSectorBytes, readBytes: readLBAs * sataSectorBytes, capacityBytes: capacityBytes, readPercent: 100 - readValue, hasReadPercent: hasReadValue, - }) + } + writeResourceSection(b, ri) selfTest := outputs["smartctl-self-test-status"] if len(selfTest) == 0 { @@ -236,6 +244,8 @@ func writeSATAReport(b *strings.Builder, outputs map[string][]byte) { result := parseSelfTestResult(string(selfTest)) writeField(b, "Result", result) } + + writeConclusionSection(b, ri) } func parseSMARTAttrs(text string) []smartAttr { @@ -331,6 +341,7 @@ const ( type resourceInfo struct { powerOnHours uint64 + powerCycles uint64 writtenBytes uint64 readBytes uint64 capacityBytes uint64 @@ -363,6 +374,70 @@ func writeResourceSection(b *strings.Builder, r resourceInfo) { } } +// ── Conclusion (new-vs-used verdict) ──────────────────────────────────────── + +// Thresholds for treating a drive as "new": less than one full drive-write +// (110% of capacity, headroom for provisioning/overprovisioning rounding), +// less than a bit over two full drive-reads (210% of capacity), under a +// week of power-on time, and under 30 power cycles. Any one violation is +// enough to call the drive used — these are deliberately loose bounds, not +// a wear/endurance judgment (see -- Resource -- for that). +const ( + newDiskMaxWrittenFrac = 1.10 + newDiskMaxReadFrac = 2.10 + newDiskMaxUptimeHours = 7 * 24 + newDiskMaxPowerCycles = 30 +) + +func writeConclusionSection(b *strings.Builder, r resourceInfo) { + writeSectionHeader(b, "Conclusion") + + var reasons, notes []string + isNew := true + + if r.capacityBytes > 0 { + writtenFrac := float64(r.writtenBytes) / float64(r.capacityBytes) + readFrac := float64(r.readBytes) / float64(r.capacityBytes) + if writtenFrac >= newDiskMaxWrittenFrac { + isNew = false + reasons = append(reasons, fmt.Sprintf( + "data written %s (%s of capacity)", + formatBytesHuman(float64(r.writtenBytes)), formatPercent(writtenFrac*100))) + } + if readFrac >= newDiskMaxReadFrac { + isNew = false + reasons = append(reasons, fmt.Sprintf( + "data read %s (%s of capacity)", + formatBytesHuman(float64(r.readBytes)), formatPercent(readFrac*100))) + } + } else { + notes = append(notes, "capacity unknown — write/read criteria not evaluated") + } + + if r.powerOnHours >= newDiskMaxUptimeHours { + isNew = false + reasons = append(reasons, fmt.Sprintf("uptime %s", formatHoursHuman(r.powerOnHours))) + } + + if r.powerCycles >= newDiskMaxPowerCycles { + isNew = false + reasons = append(reasons, fmt.Sprintf("power cycles %s", formatUint(r.powerCycles))) + } + + if isNew { + writeField(b, "Disk Condition", "NEW") + } else { + writeField(b, "Disk Condition", "USED") + b.WriteString(" Reason:\n") + for _, reason := range reasons { + fmt.Fprintf(b, " - %s\n", reason) + } + } + for _, note := range notes { + fmt.Fprintf(b, " Note: %s\n", note) + } +} + // progressBar renders a fixed-width pseudographic bar, e.g. "[######------]". func progressBar(frac float64, width int) string { if math.IsNaN(frac) || frac < 0 { diff --git a/audit/internal/platform/storage_report_test.go b/audit/internal/platform/storage_report_test.go index 700f481..338879f 100644 --- a/audit/internal/platform/storage_report_test.go +++ b/audit/internal/platform/storage_report_test.go @@ -83,7 +83,36 @@ func TestGenerateDiskReportNVMe(t *testing.T) { assertContains(t, report, "1,234 h") // power_on_hours with separator assertContains(t, report, "32") // power_cycles assertContains(t, report, "3") // unsafe_shutdowns - assertContains(t, report, "378.0 GB") // data_units_written * 512000 / 1e9 + assertContains(t, report, "378.00 GB") // data_units_written * 512000, human-scaled +} + +// TestGenerateDiskReportNVMeDataUnitsScaleToTB verifies that heavy write/read +// counters render in the "-- Usage --" section as TB/PB, not raw GB, matching +// the "-- Resource --" section which already used formatBytesHuman. +func TestGenerateDiskReportNVMeDataUnitsScaleToTB(t *testing.T) { + t.Parallel() + heavy := []byte(`{ + "critical_warning": 0, + "temperature": 307, + "avail_spare": 100, + "spare_thresh": 10, + "percent_used": 0, + "data_units_read": "252420478", + "data_units_written": "103834055", + "power_cycles": "45", + "power_on_hours": "45", + "unsafe_shutdowns": "35", + "media_errors": "0", + "num_err_log_entries": "0" +}`) + outputs := map[string][]byte{ + "nvme-id-ctrl": testNVMeIdCtrl, + "nvme-smart-log": heavy, + } + report := GenerateDiskReportText(1, "/dev/nvme0n1", outputs, time.Unix(0, 0).UTC()) + + assertContains(t, report, "Data Written : 53.16 TB") + assertContains(t, report, "Data Read : 129.24 TB") } func TestGenerateDiskReportNVMeLoHi(t *testing.T) {