From bf5d626fe8ce06b4bbaba48d79890e38d1528287 Mon Sep 17 00:00:00 2001 From: David Schroeder Date: Tue, 28 Jul 2026 22:26:17 -0500 Subject: [PATCH] excel export update --- go.mod | 17 +- go.sum | 44 ++- internal/app/audit_export.go | 532 ++++++++++-------------------- internal/app/audit_export_test.go | 99 +++--- 4 files changed, 266 insertions(+), 426 deletions(-) diff --git a/go.mod b/go.mod index 224c07a..0910457 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,11 @@ module example.com/tapm/deployment-broker -go 1.24 +go 1.24.0 -require modernc.org/sqlite v1.38.2 +require ( + github.com/xuri/excelize/v2 v2.10.1 + modernc.org/sqlite v1.38.2 +) require ( github.com/dustin/go-humanize v1.0.1 // indirect @@ -10,8 +13,16 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/richardlehane/mscfb v1.0.6 // indirect + github.com/richardlehane/msoleps v1.0.6 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect + golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/sys v0.34.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index aac187a..61fb4de 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -8,19 +10,45 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= +github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= +github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= diff --git a/internal/app/audit_export.go b/internal/app/audit_export.go index 0a689e6..c3ce36f 100644 --- a/internal/app/audit_export.go +++ b/internal/app/audit_export.go @@ -1,16 +1,14 @@ package app import ( - "archive/zip" - "compress/flate" "fmt" - "hash/crc32" "io" "log" "net/http" "strings" "time" - "unicode/utf8" + + "github.com/xuri/excelize/v2" ) const maxAuditExportRows = 1_048_572 @@ -77,135 +75,67 @@ func writeAuditWorkbook( displayTimeZone = time.UTC } - archive := zip.NewWriter(output) - files := []struct { - name string - content func(io.Writer) error - }{ - {"[Content_Types].xml", func(w io.Writer) error { - return writeOnlyString(w, contentTypesXML) - }}, - {"_rels/.rels", func(w io.Writer) error { - return writeOnlyString(w, packageRelationshipsXML) - }}, - {"docProps/app.xml", func(w io.Writer) error { - return writeOnlyString(w, appPropertiesXML) - }}, - {"docProps/core.xml", func(w io.Writer) error { - return writeAuditCoreProperties(w, exportedAt) - }}, - {"xl/workbook.xml", func(w io.Writer) error { - return writeOnlyString(w, workbookXML) - }}, - {"xl/_rels/workbook.xml.rels", func(w io.Writer) error { - return writeOnlyString(w, workbookRelationshipsXML) - }}, - {"xl/styles.xml", func(w io.Writer) error { - return writeOnlyString(w, auditStylesXML) - }}, - {"xl/worksheets/sheet1.xml", func(w io.Writer) error { - return writeAuditWorksheet(w, records, filters, exportedAt, displayTimeZone) - }}, - } - - for _, file := range files { - if err := writeRawZipFile(archive, file.name, file.content); err != nil { - _ = archive.Close() - return err + workbook := excelize.NewFile() + defer func() { + if err := workbook.Close(); err != nil { + log.Printf("close audit Excel workbook: %v", err) } - } - return archive.Close() -} + }() -func writeRawZipFile( - archive *zip.Writer, - name string, - content func(io.Writer) error, -) error { - checksum := crc32.NewIEEE() - var compressedSize byteCounter - var uncompressedSize byteCounter - sizeCompressor, err := flate.NewWriter(&compressedSize, flate.DefaultCompression) - if err != nil { + const sheet = "Audit Log" + if err := workbook.SetSheetName("Sheet1", sheet); err != nil { return err } - if err := content(io.MultiWriter(checksum, &uncompressedSize, sizeCompressor)); err != nil { - _ = sizeCompressor.Close() + if err := workbook.SetDocProps(&excelize.DocProperties{ + Title: "TAPM Audit Log", + Creator: "TAPM Broker", + LastModifiedBy: "TAPM Broker", + Created: exportedAt.UTC().Format(time.RFC3339), + Modified: exportedAt.UTC().Format(time.RFC3339), + }); err != nil { return err } - if err := sizeCompressor.Close(); err != nil { - return err - } - header := &zip.FileHeader{ - Name: name, - Method: zip.Deflate, - CRC32: checksum.Sum32(), - CompressedSize64: uint64(compressedSize), - UncompressedSize64: uint64(uncompressedSize), - } - entry, err := archive.CreateRaw(header) - if err != nil { - return err - } - compressor, err := flate.NewWriter(entry, flate.DefaultCompression) - if err != nil { - return err - } - if err := content(compressor); err != nil { - _ = compressor.Close() - return err - } - return compressor.Close() -} -func writeAuditWorksheet( - w io.Writer, - records []auditRecord, - filters auditFilters, - exportedAt time.Time, - displayTimeZone *time.Location, -) error { - lastRow := len(records) + 4 - if lastRow < 4 { - lastRow = 4 + styles, err := newAuditWorkbookStyles(workbook) + if err != nil { + return err } - if _, err := fmt.Fprintf( - w, - ``+ - ``+ - ``+ - ``+ - ``+ - ``+ - ``+ - ``+ - ``+ - ``+ - ``+ - ``+ - ``+ - ``+ - ``+ - ``+ - ``, - lastRow, + stream, err := workbook.NewStreamWriter(sheet) + if err != nil { + return err + } + if err := configureAuditWorksheet(stream); err != nil { + return err + } + + if err := stream.SetRow( + "A1", + styledAuditRow(styles.title, "TAPM Audit Log"), + excelize.RowOpts{Height: 30}, ); err != nil { return err } - - if err := writeMergedRow(w, 1, "TAPM Audit Log", 1, 30); err != nil { + if err := stream.MergeCell("A1", "H1"); err != nil { return err } + metadata := fmt.Sprintf( "Exported %s · %s · %d event(s)", exportedAt.In(displayTimeZone).Format("Jan 2, 2006 3:04 PM MST"), auditFilterDescription(filters), len(records), ) - if err := writeMergedRow(w, 2, metadata, 2, 24); err != nil { + if err := stream.SetRow( + "A2", + styledAuditRow(styles.metadata, metadata), + excelize.RowOpts{Height: 24}, + ); err != nil { return err } - if _, err := writeString(w, ``); err != nil { + if err := stream.MergeCell("A2", "H2"); err != nil { + return err + } + if err := stream.SetRow("A3", []interface{}{nil}, excelize.RowOpts{Height: 8}); err != nil { return err } @@ -219,151 +149,163 @@ func writeAuditWorksheet( "Source IP", "Details", } - if _, err := writeString(w, ``); err != nil { - return err - } + headerRow := make([]interface{}, len(headers)) for index, header := range headers { - if err := writeInlineStringCell(w, cellReference(index+1, 4), header, 3); err != nil { - return err - } + headerRow[index] = excelize.Cell{StyleID: styles.header, Value: header} } - if _, err := writeString(w, ``); err != nil { + if err := stream.SetRow("A4", headerRow, excelize.RowOpts{Height: 24}); err != nil { return err } for index, record := range records { rowNumber := index + 5 - if _, err := fmt.Fprintf(w, ``, rowNumber); err != nil { - return err + local := record.CreatedAt.In(displayTimeZone) + displayedTime := time.Date( + local.Year(), + local.Month(), + local.Day(), + local.Hour(), + local.Minute(), + local.Second(), + local.Nanosecond(), + time.UTC, + ) + row := []interface{}{ + excelize.Cell{StyleID: styles.timestamp, Value: displayedTime}, + excelize.Cell{StyleID: styles.body, Value: record.EventType}, + excelize.Cell{StyleID: styles.body, Value: record.CustomerLabel}, + excelize.Cell{StyleID: styles.body, Value: record.User}, + excelize.Cell{StyleID: styles.body, Value: record.Hostname}, + excelize.Cell{StyleID: styles.body, Value: record.PackageSlug}, + excelize.Cell{StyleID: styles.body, Value: record.SourceIP}, + excelize.Cell{StyleID: styles.details, Value: record.Details}, } - if err := writeDateCell( - w, - cellReference(1, rowNumber), - record.CreatedAt, - displayTimeZone, - 5, - ); err != nil { - return err - } - values := []string{ - record.EventType, - record.CustomerLabel, - record.User, - record.Hostname, - record.PackageSlug, - record.SourceIP, - record.Details, - } - for column, value := range values { - style := 4 - if column == len(values)-1 { - style = 6 - } - if err := writeInlineStringCell( - w, - cellReference(column+2, rowNumber), - value, - style, - ); err != nil { - return err - } - } - if _, err := writeString(w, ``); err != nil { + if err := stream.SetRow(fmt.Sprintf("A%d", rowNumber), row); err != nil { return err } } - if _, err := fmt.Fprintf( - w, - ``+ - ``+ - ``+ - ``+ - ``, - lastRow, - ); err != nil { + if len(records) > 0 { + lastRow := len(records) + 4 + showStripes := false + if err := stream.AddTable(&excelize.Table{ + Range: fmt.Sprintf("A4:H%d", lastRow), + Name: "AuditLog", + StyleName: "TableStyleMedium2", + ShowRowStripes: &showStripes, + ShowFirstColumn: false, + ShowLastColumn: false, + }); err != nil { + return err + } + } + if err := stream.Flush(); err != nil { return err } - return nil + return workbook.Write(output) } -func writeMergedRow(w io.Writer, row int, value string, style int, height int) error { - if _, err := fmt.Fprintf(w, ``, row, height); err != nil { - return err +type auditWorkbookStyles struct { + title int + metadata int + header int + body int + timestamp int + details int +} + +func newAuditWorkbookStyles(workbook *excelize.File) (auditWorkbookStyles, error) { + var styles auditWorkbookStyles + var err error + + styles.title, err = workbook.NewStyle(&excelize.Style{ + Font: &excelize.Font{ + Bold: true, + Family: "Aptos Display", + Size: 18, + Color: "FFFFFF", + }, + Fill: excelize.Fill{Type: "pattern", Color: []string{"33404A"}, Pattern: 1}, + Alignment: &excelize.Alignment{Vertical: "center"}, + }) + if err != nil { + return styles, err } - for column := 1; column <= 8; column++ { - cellValue := "" - if column == 1 { - cellValue = value - } - if err := writeInlineStringCell(w, cellReference(column, row), cellValue, style); err != nil { + styles.metadata, err = workbook.NewStyle(&excelize.Style{ + Font: &excelize.Font{Family: "Aptos", Size: 11, Color: "33404A"}, + Fill: excelize.Fill{Type: "pattern", Color: []string{"F4F7ED"}, Pattern: 1}, + Alignment: &excelize.Alignment{ + Vertical: "center", + }, + }) + if err != nil { + return styles, err + } + styles.header, err = workbook.NewStyle(&excelize.Style{ + Font: &excelize.Font{ + Bold: true, + Family: "Aptos", + Size: 11, + Color: "FFFFFF", + }, + Fill: excelize.Fill{Type: "pattern", Color: []string{"33404A"}, Pattern: 1}, + Alignment: &excelize.Alignment{Vertical: "center"}, + }) + if err != nil { + return styles, err + } + bodyStyle := excelize.Style{ + Font: &excelize.Font{Family: "Aptos", Size: 11, Color: "33404A"}, + Border: []excelize.Border{{Type: "bottom", Color: "D9DED4", Style: 1}}, + Alignment: &excelize.Alignment{Vertical: "top"}, + } + styles.body, err = workbook.NewStyle(&bodyStyle) + if err != nil { + return styles, err + } + numberFormat := "yyyy-mm-dd hh:mm:ss" + timestampStyle := bodyStyle + timestampStyle.CustomNumFmt = &numberFormat + styles.timestamp, err = workbook.NewStyle(×tampStyle) + if err != nil { + return styles, err + } + detailsStyle := bodyStyle + detailsStyle.Alignment = &excelize.Alignment{Vertical: "top", WrapText: true} + styles.details, err = workbook.NewStyle(&detailsStyle) + return styles, err +} + +func configureAuditWorksheet(stream *excelize.StreamWriter) error { + widths := []float64{22, 28, 30, 22, 24, 24, 18, 54} + for index, width := range widths { + if err := stream.SetColWidth(index+1, index+1, width); err != nil { return err } } - _, err := writeString(w, ``) - return err + return stream.SetPanes(&excelize.Panes{ + Freeze: true, + YSplit: 4, + TopLeftCell: "A5", + ActivePane: "bottomLeft", + Selection: []excelize.Selection{{ + SQRef: "A5", + ActiveCell: "A5", + Pane: "bottomLeft", + }}, + }) } -func writeInlineStringCell(w io.Writer, reference string, value string, style int) error { - if _, err := fmt.Fprintf(w, ``); err != nil { - return err - } - if err := writeEscapedXML(w, value); err != nil { - return err - } - _, err := writeString(w, ``) - return err -} - -func writeDateCell( - w io.Writer, - reference string, - value time.Time, - displayTimeZone *time.Location, - style int, -) error { - local := value.In(displayTimeZone) - displayedTime := time.Date( - local.Year(), - local.Month(), - local.Day(), - local.Hour(), - local.Minute(), - local.Second(), - local.Nanosecond(), - time.UTC, - ) - serial := float64(displayedTime.Unix())/86_400 + 25_569 - _, err := fmt.Fprintf(w, `%.10f`, reference, style, serial) - return err -} - -func writeAuditCoreProperties(w io.Writer, exportedAt time.Time) error { - if _, err := writeString( - w, - ``+ - ``+ - `TAPM Audit LogTAPM Broker`+ - ``, - ); err != nil { - return err - } - if err := writeEscapedXML(w, exportedAt.UTC().Format(time.RFC3339)); err != nil { - return err - } - _, err := writeString(w, ``) - return err + return row } func auditFilterDescription(filters auditFilters) string { @@ -394,127 +336,3 @@ func auditFilterDescription(filters auditFilters) string { } return strings.Join(parts, " · ") } - -func cellReference(column int, row int) string { - var letters [3]byte - position := len(letters) - for column > 0 { - column-- - position-- - letters[position] = byte('A' + column%26) - column /= 26 - } - return string(letters[position:]) + fmt.Sprintf("%d", row) -} - -func writeEscapedXML(w io.Writer, value string) error { - value = strings.ToValidUTF8(value, "\uFFFD") - for _, character := range value { - if character == '\t' || character == '\n' || character == '\r' || - (character >= 0x20 && character <= 0xD7FF) || - (character >= 0xE000 && character <= 0xFFFD) || - (character >= 0x10000 && character <= utf8.MaxRune) { - switch character { - case '&': - if _, err := writeString(w, "&"); err != nil { - return err - } - case '<': - if _, err := writeString(w, "<"); err != nil { - return err - } - case '>': - if _, err := writeString(w, ">"); err != nil { - return err - } - case '"': - if _, err := writeString(w, """); err != nil { - return err - } - case '\'': - if _, err := writeString(w, "'"); err != nil { - return err - } - default: - if _, err := fmt.Fprint(w, string(character)); err != nil { - return err - } - } - } - } - return nil -} - -func writeString(w io.Writer, value string) (int, error) { - return io.WriteString(w, value) -} - -func writeOnlyString(w io.Writer, value string) error { - _, err := io.WriteString(w, value) - return err -} - -const contentTypesXML = ` - - - - - - - - -` - -const packageRelationshipsXML = ` - - - - -` - -const appPropertiesXML = ` - - TAPM Broker -` - -const workbookXML = ` - - -` - -const workbookRelationshipsXML = ` - - - -` - -const auditStylesXML = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - -` diff --git a/internal/app/audit_export_test.go b/internal/app/audit_export_test.go index 2e7f9a4..40cb52e 100644 --- a/internal/app/audit_export_test.go +++ b/internal/app/audit_export_test.go @@ -1,15 +1,15 @@ package app import ( - "archive/zip" "bytes" - "io" "net/http" "net/http/httptest" "os" "strings" "testing" "time" + + "github.com/xuri/excelize/v2" ) func TestWriteAuditWorkbookCreatesSafeExcelFile(t *testing.T) { @@ -44,50 +44,38 @@ func TestWriteAuditWorkbookCreatesSafeExcelFile(t *testing.T) { t.Fatal("workbook is not a ZIP-based Excel file") } - archive, err := zip.NewReader(bytes.NewReader(output.Bytes()), int64(output.Len())) + workbook, err := excelize.OpenReader(bytes.NewReader(output.Bytes())) if err != nil { t.Fatal(err) } - required := map[string]bool{ - "[Content_Types].xml": false, - "xl/workbook.xml": false, - "xl/styles.xml": false, - "xl/worksheets/sheet1.xml": false, - "xl/_rels/workbook.xml.rels": false, - } - var worksheet string - for _, file := range archive.File { - if _, ok := required[file.Name]; ok { - required[file.Name] = true + defer func() { + if err := workbook.Close(); err != nil { + t.Error(err) } - if file.Name != "xl/worksheets/sheet1.xml" { - continue - } - reader, err := file.Open() - if err != nil { - t.Fatal(err) - } - content, err := io.ReadAll(reader) - _ = reader.Close() - if err != nil { - t.Fatal(err) - } - worksheet = string(content) + }() + if sheets := workbook.GetSheetList(); len(sheets) != 1 || sheets[0] != "Audit Log" { + t.Fatalf("sheets = %#v, want Audit Log", sheets) } - for file, found := range required { - if !found { - t.Errorf("workbook is missing %s", file) - } + customer, err := workbook.GetCellValue("Audit Log", "C5") + if err != nil { + t.Fatal(err) } - if !strings.Contains(worksheet, "Acme & Sons") { - t.Fatal("worksheet text was not XML escaped") + if customer != "Acme & Sons" { + t.Fatalf("customer = %q, want Acme & Sons", customer) } - if strings.Contains(worksheet, "") { - t.Fatal("untrusted audit text must not be written as a formula") + hostname, err := workbook.GetCellValue("Audit Log", "E5") + if err != nil { + t.Fatal(err) } - if !strings.Contains(worksheet, `t="inlineStr"`) || - !strings.Contains(worksheet, `=HYPERLINK`) { - t.Fatal("formula-looking audit text was not preserved as a text cell") + if hostname != `=HYPERLINK("https://invalid.example")` { + t.Fatalf("hostname = %q, formula-looking text was not preserved", hostname) + } + formula, err := workbook.GetCellFormula("Audit Log", "E5") + if err != nil { + t.Fatal(err) + } + if formula != "" { + t.Fatalf("untrusted audit text was written as formula %q", formula) } if samplePath := os.Getenv("TAPM_AUDIT_SAMPLE_PATH"); samplePath != "" { @@ -128,31 +116,26 @@ func TestAuditExportReturnsAllRowsAndRecordsDownload(t *testing.T) { t.Fatalf("content disposition = %q", disposition) } - archive, err := zip.NewReader( - bytes.NewReader(response.Body.Bytes()), - int64(response.Body.Len()), - ) + workbook, err := excelize.OpenReader(bytes.NewReader(response.Body.Bytes())) if err != nil { t.Fatal(err) } - var worksheet string - for _, file := range archive.File { - if file.Name != "xl/worksheets/sheet1.xml" { - continue + defer func() { + if err := workbook.Close(); err != nil { + t.Error(err) } - reader, err := file.Open() - if err != nil { - t.Fatal(err) - } - content, err := io.ReadAll(reader) - _ = reader.Close() - if err != nil { - t.Fatal(err) - } - worksheet = string(content) + }() + rows, err := workbook.GetRows("Audit Log") + if err != nil { + t.Fatal(err) } - if !strings.Contains(worksheet, "older_event") || - !strings.Contains(worksheet, "newer_event") { + var exportedEvents []string + for _, row := range rows { + if len(row) > 1 && (row[1] == "older_event" || row[1] == "newer_event") { + exportedEvents = append(exportedEvents, row[1]) + } + } + if len(exportedEvents) != 2 { t.Fatal("complete export did not contain all retained audit rows") }