@@ -33,13 +33,16 @@ const maxPollRetries = 120 // 120 * 5s = 10 minutes
3333
3434// One row in the UI.
3535type fileRow struct {
36- filename string
37- sha256 string
38- state fileState
39- spinner spinner.Model
40- result * scanSummary
41- err error
42- pollCount int
36+ filename string
37+ sha256 string
38+ size int64 // file size in bytes (set from upload response for archives)
39+ state fileState
40+ spinner spinner.Model
41+ result * scanSummary
42+ err error
43+ pollCount int
44+ isArchive bool // true for ZIP containers with multiple files
45+ childCount int // number of extracted files
4346}
4447
4548// Top-level bubbletea model.
@@ -55,9 +58,12 @@ type scanModel struct {
5558// --- Messages ---
5659
5760type fileUploadedMsg struct {
58- index int
59- sha256 string
60- err error
61+ index int
62+ sha256 string
63+ size int64
64+ err error
65+ isArchive bool
66+ childHashes []string
6167}
6268
6369type fileScanStatusMsg struct {
@@ -94,9 +100,38 @@ func uploadFileCmd(index int, web webapi.Service, filename, token string) tea.Cm
94100 }
95101 // Use the SHA256 from the server response. For single-file ZIPs,
96102 // the server extracts the file and returns the child's hash, not
97- // the ZIP's hash.
98- return fileUploadedMsg {index : index , sha256 : file .SHA256 }
103+ // the ZIP's hash. For multi-file ZIPs, the server returns the
104+ // archive doc with child hashes so we can track them individually.
105+ return fileUploadedMsg {
106+ index : index ,
107+ sha256 : file .SHA256 ,
108+ size : file .Size ,
109+ isArchive : file .IsArchive ,
110+ childHashes : file .ArchiveFiles ,
111+ }
99112 } else if forceRescanFlag {
113+ // Fetch the existing file to check if it's an archive.
114+ var file entity.File
115+ if err := web .GetFile (sha256 , & file ); err != nil {
116+ return fileUploadedMsg {index : index , err : fmt .Errorf ("get file: %w" , err )}
117+ }
118+
119+ if file .IsArchive && len (file .ArchiveFiles ) > 0 {
120+ // Archive: rescan each child, not the container itself.
121+ for _ , childHash := range file .ArchiveFiles {
122+ if err := web .Rescan (childHash , token , osFlag , enableDetonationFlag , timeoutFlag ); err != nil {
123+ return fileUploadedMsg {index : index , err : fmt .Errorf ("rescan child %s: %w" , childHash [:12 ], err )}
124+ }
125+ }
126+ return fileUploadedMsg {
127+ index : index ,
128+ sha256 : sha256 ,
129+ size : file .Size ,
130+ isArchive : true ,
131+ childHashes : file .ArchiveFiles ,
132+ }
133+ }
134+
100135 err = web .Rescan (sha256 , token , osFlag , enableDetonationFlag , timeoutFlag )
101136 if err != nil {
102137 return fileUploadedMsg {index : index , err : fmt .Errorf ("rescan: %w" , err )}
@@ -140,6 +175,27 @@ func delayedPollCmd(index int, web webapi.Service, sha256 string) tea.Cmd {
140175
141176func rescanFileCmd (index int , web webapi.Service , sha256 , token string ) tea.Cmd {
142177 return func () tea.Msg {
178+ // Check if the hash is an archive container.
179+ var file entity.File
180+ if err := web .GetFile (sha256 , & file ); err != nil {
181+ return fileUploadedMsg {index : index , err : fmt .Errorf ("get file: %w" , err )}
182+ }
183+
184+ if file .IsArchive && len (file .ArchiveFiles ) > 0 {
185+ for _ , childHash := range file .ArchiveFiles {
186+ if err := web .Rescan (childHash , token , osFlag , enableDetonationFlag , timeoutFlag ); err != nil {
187+ return fileUploadedMsg {index : index , err : fmt .Errorf ("rescan child %s: %w" , childHash [:12 ], err )}
188+ }
189+ }
190+ return fileUploadedMsg {
191+ index : index ,
192+ sha256 : sha256 ,
193+ size : file .Size ,
194+ isArchive : true ,
195+ childHashes : file .ArchiveFiles ,
196+ }
197+ }
198+
143199 err := web .Rescan (sha256 , token , osFlag , enableDetonationFlag , timeoutFlag )
144200 if err != nil {
145201 return fileUploadedMsg {index : index , err : fmt .Errorf ("rescan: %w" , err )}
@@ -249,8 +305,34 @@ func (m scanModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
249305 return m , m .maybeQuitOrNext ()
250306 }
251307 m .files [i ].sha256 = msg .sha256
252- m .files [i ].state = stateScanning
253- cmds = append (cmds , pollStatusCmd (i , m .web , msg .sha256 ))
308+
309+ if msg .isArchive && len (msg .childHashes ) > 0 {
310+ // Archive container: mark it as done immediately and track children.
311+ m .files [i ].state = stateDone
312+ m .files [i ].isArchive = true
313+ m .files [i ].childCount = len (msg .childHashes )
314+ m .files [i ].size = msg .size
315+
316+ archiveName := filepath .Base (m .files [i ].filename )
317+ for _ , childHash := range msg .childHashes {
318+ s := spinner .New ()
319+ s .Spinner = spinner .Dot
320+ m .files = append (m .files , fileRow {
321+ filename : archiveName + "/" + truncSha (childHash ),
322+ sha256 : childHash ,
323+ state : stateScanning ,
324+ spinner : s ,
325+ })
326+ childIdx := len (m .files ) - 1
327+ cmds = append (cmds ,
328+ pollStatusCmd (childIdx , m .web , childHash ),
329+ m .files [childIdx ].spinner .Tick ,
330+ )
331+ }
332+ } else {
333+ m .files [i ].state = stateScanning
334+ cmds = append (cmds , pollStatusCmd (i , m .web , msg .sha256 ))
335+ }
254336
255337 case fileScanStatusMsg :
256338 i := msg .index
@@ -404,7 +486,11 @@ func (m scanModel) View() string {
404486 case stateDone :
405487 sha := truncSha (f .sha256 )
406488 line := styleSuccess .Render ("✓" ) + " " + name + " " + styleDim .Render (sha )
407- if f .result != nil {
489+ if f .isArchive {
490+ line += " " + styleDim .Render (formatSize (f .size ))
491+ line += " " + styleLabel .Render (fmt .Sprintf ("archive (%d files)" , f .childCount ))
492+ } else if f .result != nil {
493+ line += " " + styleDim .Render (formatSize (f .result .Size ))
408494 fmtStr := f .result .FileFormat
409495 if f .result .FileExtension != "" {
410496 fmtStr += "/" + f .result .FileExtension
@@ -454,3 +540,4 @@ func truncSha(sha string) string {
454540 }
455541 return sha
456542}
543+
0 commit comments