package handler import ( "strings" "testing" ) // 文件名只用于展示与下载头;磁盘路径由 sha256 派生, // 因此这里守的是「不污染 HTTP 头、不被当成目录」而非路径穿越。 func TestSanitizeFilename(t *testing.T) { cases := []struct{ in, want string }{ {"report.pdf", "report.pdf"}, {"中文 文件名.txt", "中文 文件名.txt"}, // 目录成分必须剥掉(含 Windows 风格) {"../../etc/passwd", "passwd"}, {"/abs/path/x.log", "x.log"}, {`C:\Users\me\a.txt`, "a.txt"}, {"a/b/c.txt", "c.txt"}, // 控制字符会污染 Content-Disposition {"bad\r\nname.txt", "badname.txt"}, {"tab\there.txt", "tabhere.txt"}, // 无意义的名字兜底 {"", "unnamed"}, {" ", "unnamed"}, {".", "unnamed"}, {"..", "unnamed"}, {"/", "unnamed"}, } for _, c := range cases { if got := sanitizeFilename(c.in); got != c.want { t.Errorf("sanitizeFilename(%q) = %q, want %q", c.in, got, c.want) } } } // 超长名字按 UTF-8 边界截断,不产生非法序列。 func TestSanitizeFilenameTruncates(t *testing.T) { long := strings.Repeat("中", 200) + ".txt" // 每字 3 字节,共 600+ got := sanitizeFilename(long) if len(got) > 255 { t.Errorf("截断后 %d 字节,超过 255", len(got)) } for _, r := range got { if r == '\uFFFD' { t.Fatalf("截断产生非法 UTF-8: %q", got) } } } func TestDetectContentType(t *testing.T) { cases := []struct{ declared, filename, want string }{ {"application/pdf", "x.pdf", "application/pdf"}, // 客户端没给类型时按扩展名猜 {"", "notes.txt", "text/plain"}, {"application/octet-stream", "data.json", "application/json"}, // 带参数的声明要剥掉参数 {"text/plain; charset=utf-8", "a.txt", "text/plain"}, // 认不出就兜底 {"", "blob.unknownext", "application/octet-stream"}, {"garbage//not-a-type", "blob.unknownext", "application/octet-stream"}, } for _, c := range cases { got := detectContentType(c.declared, c.filename) // mime.TypeByExtension 在不同系统上可能返回带参数的值,只比主类型 if !strings.HasPrefix(got, c.want) { t.Errorf("detectContentType(%q, %q) = %q, want prefix %q", c.declared, c.filename, got, c.want) } } } // Content-Disposition 必须双写:filename* 承载 UTF-8,filename= 给老客户端兜底。 // 兜底值里的引号/反斜杠/非 ASCII 一律换成下划线,否则能截断响应头。 func TestContentDisposition(t *testing.T) { got := contentDisposition("报告 v2.pdf") if !strings.HasPrefix(got, "attachment; ") { t.Errorf("必须以 attachment 开头: %s", got) } if !strings.Contains(got, "filename*=UTF-8''") { t.Errorf("缺少 RFC 5987 编码: %s", got) } // 非 ASCII 不能出现在 filename= 的兜底值里 ascii := got[:strings.Index(got, "filename*=")] for _, r := range ascii { if r > 0x7e { t.Errorf("兜底 filename 含非 ASCII 字符 %q: %s", r, ascii) } } // 引号注入不能逃出引号 evil := contentDisposition(`a"; x="y`) if strings.Contains(evil[:strings.Index(evil, "filename*=")], `"; x=`) { t.Errorf("引号未转义,可截断响应头: %s", evil) } // 控制字符(若绕过 sanitize 直达此处)也不能出现 ctl := contentDisposition("a\r\nb.txt") if strings.ContainsAny(ctl, "\r\n") { t.Errorf("响应头含换行: %q", ctl) } } func TestURLEncodeRFC5987(t *testing.T) { cases := []struct{ in, want string }{ {"abc.txt", "abc.txt"}, {"a b", "a%20b"}, {"中", "%E4%B8%AD"}, {`a"b`, "a%22b"}, } for _, c := range cases { if got := urlEncodeRFC5987(c.in); got != c.want { t.Errorf("urlEncodeRFC5987(%q) = %q, want %q", c.in, got, c.want) } } } func TestParseAttachmentIDs(t *testing.T) { valid := "3f2504e0-4f89-11d3-9a0c-0305e82c3301" got, err := parseAttachmentIDs([]string{valid, " ", ""}) if err != nil { t.Fatalf("合法输入报错: %v", err) } if len(got) != 1 { t.Errorf("空白项应被忽略,得到 %d 个", len(got)) } if _, err := parseAttachmentIDs([]string{"not-a-uuid"}); err == nil { t.Error("非法 UUID 应报错") } if got, err := parseAttachmentIDs(nil); err != nil || len(got) != 0 { t.Errorf("nil 应返回空列表,得到 %v, %v", got, err) } }