Skip to content

Commit 03d3d9a

Browse files
authored
feat: improve UX on derived files in archives (#24)
Co-authored-by: Ayoub Faouzi <ayoubfaouzi@users.noreply.github.com>
1 parent 856ed1c commit 03d3d9a

2 files changed

Lines changed: 128 additions & 53 deletions

File tree

cmd/scanui.go

Lines changed: 103 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,12 @@ type scanModel struct {
5959
// --- Messages ---
6060

6161
type fileUploadedMsg struct {
62-
index int
63-
sha256 string
64-
size int64
65-
err error
66-
isArchive bool
67-
childHashes []string
62+
index int
63+
sha256 string
64+
size int64
65+
err error
66+
isArchive bool
67+
children []entity.DerivedFile
6868
}
6969

7070
type fileScanStatusMsg struct {
@@ -74,9 +74,11 @@ type fileScanStatusMsg struct {
7474
}
7575

7676
type fileScanDoneMsg struct {
77-
index int
78-
summary scanSummary
79-
err error
77+
index int
78+
summary scanSummary
79+
isArchive bool
80+
children []entity.DerivedFile
81+
err error
8082
}
8183

8284
// --- Commands (async I/O) ---
@@ -99,16 +101,13 @@ func uploadFileCmd(index int, web webapi.Service, filename, token string) tea.Cm
99101
if err != nil {
100102
return fileUploadedMsg{index: index, err: fmt.Errorf("upload: %w", err)}
101103
}
102-
// Use the SHA256 from the server response. For single-file ZIPs,
103-
// the server extracts the file and returns the child's hash, not
104-
// the ZIP's hash. For multi-file ZIPs, the server returns the
105-
// archive doc with child hashes so we can track them individually.
104+
// Don't use is_archive from the upload response: the file hasn't been
105+
// processed yet, so the field is always false at this point. Archive
106+
// detection happens in fetchResultCmd once the parent scan completes.
106107
return fileUploadedMsg{
107-
index: index,
108-
sha256: file.SHA256,
109-
size: file.Size,
110-
isArchive: file.IsArchive,
111-
childHashes: derivedHashes(file.DerivedFiles),
108+
index: index,
109+
sha256: file.SHA256,
110+
size: file.Size,
112111
}
113112
} else if forceRescanFlag {
114113
// Fetch the existing file to check if it's an archive.
@@ -125,11 +124,11 @@ func uploadFileCmd(index int, web webapi.Service, filename, token string) tea.Cm
125124
}
126125
}
127126
return fileUploadedMsg{
128-
index: index,
129-
sha256: sha256,
130-
size: file.Size,
131-
isArchive: true,
132-
childHashes: derivedHashes(file.DerivedFiles),
127+
index: index,
128+
sha256: sha256,
129+
size: file.Size,
130+
isArchive: true,
131+
children: file.DerivedFiles,
133132
}
134133
}
135134

@@ -159,8 +158,12 @@ func fetchResultCmd(index int, web webapi.Service, sha256 string) tea.Cmd {
159158
if err := web.GetFile(sha256, &file); err != nil {
160159
return fileScanDoneMsg{index: index, err: fmt.Errorf("get file report: %w", err)}
161160
}
162-
summary := buildScanSummary(file)
163-
return fileScanDoneMsg{index: index, summary: summary}
161+
return fileScanDoneMsg{
162+
index: index,
163+
summary: buildScanSummary(file),
164+
isArchive: file.IsArchive,
165+
children: file.DerivedFiles,
166+
}
164167
}
165168
}
166169

@@ -189,11 +192,11 @@ func rescanFileCmd(index int, web webapi.Service, sha256, token string) tea.Cmd
189192
}
190193
}
191194
return fileUploadedMsg{
192-
index: index,
193-
sha256: sha256,
194-
size: file.Size,
195-
isArchive: true,
196-
childHashes: derivedHashes(file.DerivedFiles),
195+
index: index,
196+
sha256: sha256,
197+
size: file.Size,
198+
isArchive: true,
199+
children: file.DerivedFiles,
197200
}
198201
}
199202

@@ -307,27 +310,27 @@ func (m scanModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
307310
}
308311
m.files[i].sha256 = msg.sha256
309312

310-
if msg.isArchive && len(msg.childHashes) > 0 {
313+
if msg.isArchive && len(msg.children) > 0 {
311314
// Archive container: poll parent for completion and track children.
312315
m.files[i].state = stateScanning
313316
m.files[i].isArchive = true
314-
m.files[i].childCount = len(msg.childHashes)
317+
m.files[i].childCount = len(msg.children)
315318
m.files[i].size = msg.size
316319
cmds = append(cmds, pollStatusCmd(i, m.web, msg.sha256))
317320

318321
archiveName := filepath.Base(m.files[i].filename)
319-
for _, childHash := range msg.childHashes {
322+
for _, df := range msg.children {
320323
s := spinner.New()
321324
s.Spinner = spinner.Dot
322325
m.files = append(m.files, fileRow{
323-
filename: archiveName + "/" + truncSha(childHash),
324-
sha256: childHash,
326+
filename: archiveName + "/" + childDisplayName(df),
327+
sha256: df.SHA256,
325328
state: stateScanning,
326329
spinner: s,
327330
})
328331
childIdx := len(m.files) - 1
329332
cmds = append(cmds,
330-
pollStatusCmd(childIdx, m.web, childHash),
333+
pollStatusCmd(childIdx, m.web, df.SHA256),
331334
m.files[childIdx].spinner.Tick,
332335
)
333336
}
@@ -364,9 +367,33 @@ func (m scanModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
364367
} else {
365368
m.files[i].state = stateDone
366369
m.files[i].result = &msg.summary
370+
// Late archive detection: for new uploads is_archive is false at
371+
// upload time and only becomes true once the backend processes the
372+
// file. The rescan/forceRescan paths pre-populate isArchive via
373+
// fileUploadedMsg, so skip them here to avoid adding duplicate rows.
374+
if msg.isArchive && !m.files[i].isArchive && len(msg.children) > 0 {
375+
m.files[i].isArchive = true
376+
m.files[i].childCount = len(msg.children)
377+
archiveName := filepath.Base(m.files[i].filename)
378+
for _, df := range msg.children {
379+
s := spinner.New()
380+
s.Spinner = spinner.Dot
381+
m.files = append(m.files, fileRow{
382+
filename: archiveName + "/" + childDisplayName(df),
383+
sha256: df.SHA256,
384+
state: stateScanning,
385+
spinner: s,
386+
})
387+
childIdx := len(m.files) - 1
388+
cmds = append(cmds,
389+
pollStatusCmd(childIdx, m.web, df.SHA256),
390+
m.files[childIdx].spinner.Tick,
391+
)
392+
}
393+
}
367394
}
368-
cmd := m.maybeQuitOrNext()
369-
return m, cmd
395+
cmds = append(cmds, m.maybeQuitOrNext())
396+
return m, tea.Batch(cmds...)
370397
}
371398

372399
return m, tea.Batch(cmds...)
@@ -470,10 +497,9 @@ func (m scanModel) View() string {
470497
if m.isRescan {
471498
label = " Rescanning "
472499
}
473-
s += f.spinner.View() + styleLabel.Render(label) + name + " ...\n"
500+
s += f.spinner.View() + styleLabel.Render(label) + displayName(name) + " ...\n"
474501
case stateScanning:
475-
sha := truncSha(f.sha256)
476-
s += f.spinner.View() + styleLabel.Render(" Scanning ") + name + " " + styleDim.Render(sha) + "\n"
502+
s += f.spinner.View() + styleLabel.Render(" Scanning ") + displayName(name) + " " + styleDim.Render(f.sha256) + "\n"
477503
}
478504
}
479505

@@ -486,10 +512,13 @@ func (m scanModel) View() string {
486512
name := filepath.Base(f.filename)
487513
switch f.state {
488514
case stateDone:
489-
sha := truncSha(f.sha256)
490-
line := styleSuccess.Render("✓") + " " + name + " " + styleDim.Render(sha)
515+
line := styleSuccess.Render("✓") + " " + displayName(name) + " " + styleDim.Render(f.sha256)
491516
if f.isArchive {
492-
line += " " + styleDim.Render(formatSize(f.size))
517+
size := f.size
518+
if f.result != nil {
519+
size = f.result.Size
520+
}
521+
line += " " + styleDim.Render(formatSize(size))
493522
line += " " + styleLabel.Render(fmt.Sprintf("archive (%d files)", f.childCount))
494523
} else if f.result != nil {
495524
line += " " + styleDim.Render(formatSize(f.result.Size))
@@ -509,7 +538,7 @@ func (m scanModel) View() string {
509538
}
510539
doneRows = append(doneRows, doneRow{line})
511540
case stateError:
512-
line := styleError.Render("✗") + " " + name + " " + styleError.Render(f.err.Error())
541+
line := styleError.Render("✗") + " " + displayName(name) + " " + styleError.Render(f.err.Error())
513542
doneRows = append(doneRows, doneRow{line})
514543
}
515544
}
@@ -546,12 +575,36 @@ func truncSha(sha string) string {
546575
return sha
547576
}
548577

549-
func derivedHashes(files []entity.DerivedFile) []string {
550-
hashes := make([]string, len(files))
551-
for i, f := range files {
552-
hashes[i] = f.SHA256
578+
// looksLikeHash returns true if s is a hex string of a common hash length
579+
// (MD5=32, SHA1=40, SHA256=64).
580+
func looksLikeHash(s string) bool {
581+
if len(s) != 32 && len(s) != 40 && len(s) != 64 {
582+
return false
583+
}
584+
for _, c := range s {
585+
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
586+
return false
587+
}
588+
}
589+
return true
590+
}
591+
592+
// displayName returns the name as-is, unless it looks like a hash, in which
593+
// case it is truncated to the first 12 characters.
594+
func displayName(name string) string {
595+
if looksLikeHash(name) {
596+
return truncSha(name)
597+
}
598+
return name
599+
}
600+
601+
// childDisplayName returns the archive entry name when available, falling back
602+
// to the truncated SHA256 so the row always has a meaningful label.
603+
func childDisplayName(df entity.DerivedFile) string {
604+
if df.Name != "" {
605+
return df.Name
553606
}
554-
return hashes
607+
return truncSha(df.SHA256)
555608
}
556609

557610
func renderEncryptionStatus(s *scanSummary) string {

cmd/view.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ func printFileReport(file entity.File, webSvc webapi.Service) {
5757
// File identification.
5858
fmt.Println(headerStyle.Render("Identification"))
5959
printKV("SHA256", file.SHA256)
60+
if name := submissionFilename(file.Submissions); name != "" {
61+
printKV("Filename", name)
62+
}
6063
if !file.IsArchive {
6164
printKV("MD5", file.MD5)
6265
printKV("SHA1", file.SHA1)
@@ -171,19 +174,21 @@ func printArchiveChildren(derivedFiles []entity.DerivedFile, webSvc webapi.Servi
171174
fmt.Println()
172175

173176
// Table header.
177+
nameCol := lipgloss.NewStyle().Width(28)
174178
fmtCol := lipgloss.NewStyle().Width(16)
175179
sizeCol := lipgloss.NewStyle().Width(10)
176180
avCol := lipgloss.NewStyle().Width(14)
177181
clsCol := lipgloss.NewStyle().Width(12)
178182

179-
fmt.Printf(" %s %s %s %s %s\n",
183+
fmt.Printf(" %s %s %s %s %s %s\n",
180184
styleDim.Render(fmt.Sprintf("%-64s", "SHA256")),
185+
styleDim.Render(nameCol.Render("NAME")),
181186
styleDim.Render(fmtCol.Render("FORMAT")),
182187
styleDim.Render(sizeCol.Render("SIZE")),
183188
styleDim.Render(avCol.Render("DETECTIONS")),
184189
styleDim.Render(clsCol.Render("VERDICT")),
185190
)
186-
fmt.Printf(" %s\n", styleDim.Render(strings.Repeat("─", 119)))
191+
fmt.Printf(" %s\n", styleDim.Render(strings.Repeat("─", 148)))
187192

188193
for _, df := range derivedFiles {
189194
cs := fetchChildSummary(df.SHA256, webSvc)
@@ -202,8 +207,14 @@ func printArchiveChildren(derivedFiles []entity.DerivedFile, webSvc webapi.Servi
202207
detStr = cleanStyle.Render(detStr)
203208
}
204209

205-
fmt.Printf(" %s %s %s %s %s\n",
210+
name := df.Name
211+
if len(name) > 28 {
212+
name = name[:25] + "..."
213+
}
214+
215+
fmt.Printf(" %s %s %s %s %s %s\n",
206216
cs.sha256,
217+
nameCol.Render(name),
207218
fmtCol.Render(cs.format),
208219
sizeCol.Render(formatSize(cs.size)),
209220
avCol.Render(detStr),
@@ -316,3 +327,14 @@ func formatTimestamp(ts int64) string {
316327
t := time.Unix(ts, 0)
317328
return t.Format("2006-01-02 15:04:05 UTC")
318329
}
330+
331+
// submissionFilename returns the first submission filename that does not look
332+
// like a bare hash (MD5/SHA1/SHA256), or empty string if none is found.
333+
func submissionFilename(submissions []entity.Submission) string {
334+
for _, s := range submissions {
335+
if s.Filename != "" && !looksLikeHash(s.Filename) {
336+
return s.Filename
337+
}
338+
}
339+
return ""
340+
}

0 commit comments

Comments
 (0)