github.com/anthropics/anthropic-sdk-go@v1.70.1/tools/agenttoolset/skillarchive_test.go (about) 1 package agenttoolset 2 3 import ( 4 "archive/tar" 5 "archive/zip" 6 "bytes" 7 "compress/gzip" 8 "fmt" 9 "os" 10 "path/filepath" 11 "runtime" 12 "strings" 13 "testing" 14 15 "github.com/stretchr/testify/require" 16 ) 17 18 func zipBytes(t *testing.T, entries map[string]string) []byte { 19 t.Helper() 20 var buf bytes.Buffer 21 zw := zip.NewWriter(&buf) 22 for name, body := range entries { 23 w, err := zw.Create(name) 24 if err != nil { 25 t.Fatalf("zip create %q: %v", name, err) 26 } 27 if _, err := w.Write([]byte(body)); err != nil { 28 t.Fatalf("zip write %q: %v", name, err) 29 } 30 } 31 if err := zw.Close(); err != nil { 32 t.Fatalf("zip close: %v", err) 33 } 34 return buf.Bytes() 35 } 36 37 func tarGzBytes(t *testing.T, entries map[string]string) []byte { 38 t.Helper() 39 var buf bytes.Buffer 40 gw := gzip.NewWriter(&buf) 41 tw := tar.NewWriter(gw) 42 for name, body := range entries { 43 if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil { 44 t.Fatalf("tar header %q: %v", name, err) 45 } 46 if _, err := tw.Write([]byte(body)); err != nil { 47 t.Fatalf("tar write %q: %v", name, err) 48 } 49 } 50 if err := tw.Close(); err != nil { 51 t.Fatalf("tar close: %v", err) 52 } 53 if err := gw.Close(); err != nil { 54 t.Fatalf("gzip close: %v", err) 55 } 56 return buf.Bytes() 57 } 58 59 // extractArchiveBytes writes archive bytes to a temp file and extracts it, 60 // exercising the on-disk extraction path the real skill download now uses. 61 func extractArchiveBytes(t *testing.T, data []byte, dest string) error { 62 t.Helper() 63 path := filepath.Join(t.TempDir(), "archive") 64 if err := os.WriteFile(path, data, 0o644); err != nil { 65 t.Fatalf("write archive: %v", err) 66 } 67 return extractSkillArchive(path, dest) 68 } 69 70 func TestExtractSkillArchive_Zip(t *testing.T) { 71 dest := filepath.Join(t.TempDir(), "s") 72 if err := extractArchiveBytes(t, zipBytes(t, map[string]string{"SKILL.md": "hi", "d/x.txt": "x"}), dest); err != nil { 73 t.Fatalf("extract: %v", err) 74 } 75 if got, _ := os.ReadFile(filepath.Join(dest, "SKILL.md")); string(got) != "hi" { 76 t.Fatalf("SKILL.md = %q", got) 77 } 78 if got, _ := os.ReadFile(filepath.Join(dest, "d", "x.txt")); string(got) != "x" { 79 t.Fatalf("d/x.txt = %q", got) 80 } 81 } 82 83 func TestExtractSkillArchive_TarGz(t *testing.T) { 84 dest := filepath.Join(t.TempDir(), "s") 85 if err := extractArchiveBytes(t, tarGzBytes(t, map[string]string{"SKILL.md": "tarred", "sub/y": "Y"}), dest); err != nil { 86 t.Fatalf("extract: %v", err) 87 } 88 if got, _ := os.ReadFile(filepath.Join(dest, "SKILL.md")); string(got) != "tarred" { 89 t.Fatalf("SKILL.md = %q", got) 90 } 91 if got, _ := os.ReadFile(filepath.Join(dest, "sub", "y")); string(got) != "Y" { 92 t.Fatalf("sub/y = %q", got) 93 } 94 } 95 96 func TestArchiveTopDir(t *testing.T) { 97 cases := []struct { 98 desc string 99 names []string 100 want string 101 }{ 102 {"single wrapped skill", []string{"pdf/SKILL.md", "pdf/scripts/x.py"}, "pdf"}, 103 {"single file under wrapper", []string{"pdf/SKILL.md"}, "pdf"}, 104 {"dot-prefixed (tar -C dir .)", []string{"./", "./pdf/", "./pdf/SKILL.md", "./pdf/scripts/x"}, "pdf"}, 105 {"flat archive", []string{"SKILL.md", "scripts/x.py"}, ""}, 106 {"multiple roots", []string{"a/x", "b/y"}, ""}, 107 {"only bare top dir", []string{"pdf/"}, ""}, 108 {"empty", nil, ""}, 109 } 110 for _, c := range cases { 111 t.Run(c.desc, func(t *testing.T) { 112 if got := archiveTopDir(c.names); got != c.want { 113 t.Errorf("archiveTopDir(%v) = %q, want %q", c.names, got, c.want) 114 } 115 }) 116 } 117 } 118 119 func TestStripTopDir(t *testing.T) { 120 cases := []struct{ name, top, want string }{ 121 {"pdf/SKILL.md", "pdf", "SKILL.md"}, 122 {"pdf/scripts/x.py", "pdf", "scripts/x.py"}, 123 {"pdf", "pdf", ""}, // bare top-dir entry 124 {"./pdf/SKILL.md", "pdf", "SKILL.md"}, // dot-prefixed (tar -C dir .) 125 {"./pdf/", "pdf", ""}, 126 {"SKILL.md", "", "SKILL.md"}, // no wrapper -> unchanged 127 {"other/x", "pdf", "other/x"}, 128 } 129 for _, c := range cases { 130 if got := stripTopDir(c.name, c.top); got != c.want { 131 t.Errorf("stripTopDir(%q,%q) = %q, want %q", c.name, c.top, got, c.want) 132 } 133 } 134 } 135 136 // Skill bundles are wrapped in a directory named after the skill; extraction 137 // must strip it so files land at dest/SKILL.md, not the doubled 138 // dest/<skill>/SKILL.md. 139 func TestExtractSkillArchive_StripsWrapperDir(t *testing.T) { 140 for _, tc := range []struct { 141 kind string 142 make func(*testing.T, map[string]string) []byte 143 }{ 144 {"zip", zipBytes}, 145 {"targz", tarGzBytes}, 146 } { 147 t.Run(tc.kind, func(t *testing.T) { 148 dest := filepath.Join(t.TempDir(), "skills", "pdf") 149 // Dot-prefixed names mirror a real `tar -C dir .` / zip of "." 150 // bundle; the wrapper must still be detected and stripped. 151 data := tc.make(t, map[string]string{ 152 "./pdf/SKILL.md": "# PDF", 153 "./pdf/scripts/run.py": "print(1)", 154 }) 155 if err := extractArchiveBytes(t, data, dest); err != nil { 156 t.Fatalf("extract: %v", err) 157 } 158 if got, _ := os.ReadFile(filepath.Join(dest, "SKILL.md")); string(got) != "# PDF" { 159 t.Fatalf("SKILL.md = %q, want %q", got, "# PDF") 160 } 161 if got, _ := os.ReadFile(filepath.Join(dest, "scripts", "run.py")); string(got) != "print(1)" { 162 t.Fatalf("scripts/run.py = %q", got) 163 } 164 if _, err := os.Stat(filepath.Join(dest, "pdf")); err == nil { 165 t.Fatal("wrapper dir was not stripped (skills/pdf/pdf/ doubling)") 166 } 167 }) 168 } 169 } 170 171 // setSkillArchiveLimits temporarily lowers the extraction bounds so tests can 172 // exercise the limits without building 10K-member / 1 GiB archives. Restored 173 // via t.Cleanup; tests that use this must not run in parallel. 174 func setSkillArchiveLimits(t *testing.T, members int, byteLimit int64) { 175 t.Helper() 176 oldM, oldB := skillArchiveMaxMembers, skillArchiveMaxBytes 177 skillArchiveMaxMembers, skillArchiveMaxBytes = members, byteLimit 178 t.Cleanup(func() { skillArchiveMaxMembers, skillArchiveMaxBytes = oldM, oldB }) 179 } 180 181 // requireDestAbsent asserts the extraction destination does not exist — a 182 // failed extraction must remove the partially written directory rather than 183 // leave a half-extracted skill on disk. 184 func requireDestAbsent(t *testing.T, dest string) { 185 t.Helper() 186 if _, err := os.Stat(dest); !os.IsNotExist(err) { 187 t.Fatalf("dest %q should have been removed after extraction failure, stat err = %v", dest, err) 188 } 189 } 190 191 func TestExtractSkillArchive_FailureRemovesDest(t *testing.T) { 192 cases := []struct { 193 desc string 194 data func(t *testing.T) []byte 195 }{ 196 { 197 // Member-count cap fires before any file is written (zip pass 1). 198 desc: "over member limit", 199 data: func(t *testing.T) []byte { 200 return zipBytes(t, map[string]string{"a": "1", "b": "2", "c": "3"}) 201 }, 202 }, 203 { 204 // Byte budget fires mid-extraction, after some files have already 205 // been written into dest. 206 desc: "over byte budget mid-extraction", 207 data: func(t *testing.T) []byte { 208 return zipBytes(t, map[string]string{"a": "abcdef", "b": "ghijkl"}) 209 }, 210 }, 211 } 212 for _, tc := range cases { 213 t.Run(tc.desc, func(t *testing.T) { 214 setSkillArchiveLimits(t, 2, 8) 215 dest := filepath.Join(t.TempDir(), "s") 216 if err := extractArchiveBytes(t, tc.data(t), dest); err == nil { 217 t.Fatal("expected extraction to fail, got nil") 218 } 219 requireDestAbsent(t, dest) 220 }) 221 } 222 } 223 224 // TestExtractTar_Pass1MemberBound proves the member-count cap fires during 225 // pass 1 (top-dir detection) rather than only in pass 2. The archive ends in a 226 // garbage block instead of the usual end-of-archive trailer: a bounded pass 1 227 // stops at member N+1 with the "exceeds N members" error, while an unbounded 228 // pass 1 would read through to the garbage and surface a tar parse error. 229 func TestExtractTar_Pass1MemberBound(t *testing.T) { 230 setSkillArchiveLimits(t, 3, skillArchiveMaxBytes) 231 232 var buf bytes.Buffer 233 tw := tar.NewWriter(&buf) 234 for i := 0; i <= skillArchiveMaxMembers; i++ { 235 hdr := &tar.Header{Name: fmt.Sprintf("f%d", i), Mode: 0o644, Size: 0, Typeflag: tar.TypeReg} 236 if err := tw.WriteHeader(hdr); err != nil { 237 t.Fatalf("tar header: %v", err) 238 } 239 } 240 if err := tw.Flush(); err != nil { 241 t.Fatalf("tar flush: %v", err) 242 } 243 buf.Write(bytes.Repeat([]byte{0xFF}, 512)) 244 245 dest := filepath.Join(t.TempDir(), "s") 246 err := extractArchiveBytes(t, buf.Bytes(), dest) 247 if err == nil { 248 t.Fatal("expected member-count error, got nil") 249 } 250 if !strings.Contains(err.Error(), "members") { 251 t.Fatalf("expected pass-1 member-count error, got: %v", err) 252 } 253 requireDestAbsent(t, dest) 254 } 255 256 // TestExtractTar_Pass1ByteBound proves the declared-size byte budget fires 257 // during pass 1. The budget is computed from hdr.Size alone, so the test only 258 // needs entries whose declared sizes sum past the (lowered) limit. As above, 259 // the trailing garbage block proves pass 1 stopped at the cap rather than 260 // reading through the whole archive. 261 func TestExtractTar_Pass1ByteBound(t *testing.T) { 262 setSkillArchiveLimits(t, skillArchiveMaxMembers, 8) 263 264 var buf bytes.Buffer 265 tw := tar.NewWriter(&buf) 266 body := []byte("xxxxx") // 5 bytes; 3 entries declare 15 > 8 267 for i := 0; i < 3; i++ { 268 hdr := &tar.Header{Name: fmt.Sprintf("f%d", i), Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg} 269 if err := tw.WriteHeader(hdr); err != nil { 270 t.Fatalf("tar header: %v", err) 271 } 272 if _, err := tw.Write(body); err != nil { 273 t.Fatalf("tar write: %v", err) 274 } 275 } 276 if err := tw.Flush(); err != nil { 277 t.Fatalf("tar flush: %v", err) 278 } 279 buf.Write(bytes.Repeat([]byte{0xFF}, 512)) 280 281 dest := filepath.Join(t.TempDir(), "s") 282 err := extractArchiveBytes(t, buf.Bytes(), dest) 283 if err == nil { 284 t.Fatal("expected byte-budget error, got nil") 285 } 286 if !strings.Contains(err.Error(), "bytes") { 287 t.Fatalf("expected pass-1 byte-budget error, got: %v", err) 288 } 289 requireDestAbsent(t, dest) 290 } 291 292 type tarMember struct { 293 hdr tar.Header 294 body string 295 } 296 297 // rawTarBytes writes members in order as an uncompressed tar stream. A member 298 // whose Typeflag is tar.TypeRegA is written as TypeReg and then patched back 299 // to the legacy '\x00' flag on the wire, since tar.Writer refuses to emit it. 300 func rawTarBytes(t *testing.T, members ...tarMember) []byte { 301 t.Helper() 302 var buf bytes.Buffer 303 tw := tar.NewWriter(&buf) 304 var legacyOffsets []int 305 for _, m := range members { 306 hdr := m.hdr 307 hdr.Size = int64(len(m.body)) 308 if hdr.Typeflag == tar.TypeRegA { 309 hdr.Typeflag = tar.TypeReg 310 require.NoError(t, tw.Flush()) 311 legacyOffsets = append(legacyOffsets, buf.Len()) 312 } 313 require.NoError(t, tw.WriteHeader(&hdr), "tar header %q", hdr.Name) 314 if len(m.body) > 0 { 315 _, err := tw.Write([]byte(m.body)) 316 require.NoError(t, err, "tar write %q", hdr.Name) 317 } 318 } 319 require.NoError(t, tw.Close()) 320 out := buf.Bytes() 321 for _, off := range legacyOffsets { 322 block := out[off : off+512] 323 block[156] = tar.TypeRegA 324 copy(block[148:156], " ") 325 var sum int 326 for _, b := range block { 327 sum += int(b) 328 } 329 copy(block[148:156], fmt.Sprintf("%06o\x00 ", sum)) 330 } 331 return out 332 } 333 334 type zipMember struct { 335 hdr zip.FileHeader 336 body string 337 } 338 339 // Unix st_mode type bits as they appear in a zip entry's external attributes. 340 const ( 341 zipUnixHost = 3 << 8 342 sIFDIR = 0o040000 343 sIFIFO = 0o010000 344 sIFLNK = 0o120000 345 ) 346 347 func rawZipBytes(t *testing.T, members ...zipMember) []byte { 348 t.Helper() 349 var buf bytes.Buffer 350 zw := zip.NewWriter(&buf) 351 for _, m := range members { 352 hdr := m.hdr 353 w, err := zw.CreateHeader(&hdr) 354 require.NoError(t, err, "zip header %q", hdr.Name) 355 _, err = w.Write([]byte(m.body)) 356 require.NoError(t, err, "zip write %q", hdr.Name) 357 } 358 require.NoError(t, zw.Close()) 359 return buf.Bytes() 360 } 361 362 // requireOnlyPlainEntries asserts that every entry under dir, checked with 363 // lstat, is a regular file or a directory. 364 func requireOnlyPlainEntries(t *testing.T, dir string) { 365 t.Helper() 366 require.NoError(t, filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error { 367 require.NoError(t, err) 368 require.True(t, d.Type().IsRegular() || d.IsDir(), "%s is neither a regular file nor a directory (%v)", p, d.Type()) 369 return nil 370 })) 371 } 372 373 func TestExtractSkillArchive_SkipsSpecialMembers(t *testing.T) { 374 tests := []struct { 375 description string 376 data func(t *testing.T) []byte 377 wantFiles map[string]string 378 wantDirs []string 379 wantModes map[string]os.FileMode 380 wantAbsent []string 381 }{ 382 { 383 description: "tar symlink, hardlink, FIFO and device members are skipped and the rest extracted with the wrapper stripped", 384 data: func(t *testing.T) []byte { 385 return rawTarBytes(t, 386 tarMember{hdr: tar.Header{Name: "pdf/SKILL.md", Typeflag: tar.TypeReg}, body: "# PDF"}, 387 tarMember{hdr: tar.Header{Name: "pdf/scripts/run.sh", Typeflag: tar.TypeReg, Mode: 0o755}, body: "#!/bin/sh"}, 388 tarMember{hdr: tar.Header{Name: "pdf/old", Typeflag: tar.TypeRegA}, body: "legacy"}, 389 tarMember{hdr: tar.Header{Name: "pdf/abs", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd"}}, 390 tarMember{hdr: tar.Header{Name: "pdf/rel", Typeflag: tar.TypeSymlink, Linkname: "SKILL.md"}}, 391 tarMember{hdr: tar.Header{Name: "pdf/hl", Typeflag: tar.TypeLink, Linkname: "pdf/SKILL.md"}}, 392 tarMember{hdr: tar.Header{Name: "pdf/fifo", Typeflag: tar.TypeFifo}}, 393 tarMember{hdr: tar.Header{Name: "pdf/dev", Typeflag: tar.TypeChar, Devmajor: 1, Devminor: 3}}, 394 ) 395 }, 396 wantFiles: map[string]string{"SKILL.md": "# PDF", "scripts/run.sh": "#!/bin/sh", "old": "legacy"}, 397 wantModes: map[string]os.FileMode{"scripts/run.sh": 0o755}, 398 wantAbsent: []string{"abs", "rel", "hl", "fifo", "dev", "pdf"}, 399 }, 400 { 401 description: "a top-level tar symlink does not defeat wrapper stripping", 402 data: func(t *testing.T) []byte { 403 return rawTarBytes(t, 404 tarMember{hdr: tar.Header{Name: "link", Typeflag: tar.TypeSymlink, Linkname: "/tmp"}}, 405 tarMember{hdr: tar.Header{Name: "pdf/SKILL.md", Typeflag: tar.TypeReg}, body: "# PDF"}, 406 ) 407 }, 408 wantFiles: map[string]string{"SKILL.md": "# PDF"}, 409 wantAbsent: []string{"link", "pdf"}, 410 }, 411 { 412 description: "a git-archive style PAX global header is not written out as a file and does not defeat wrapper stripping", 413 data: func(t *testing.T) []byte { 414 return rawTarBytes(t, 415 tarMember{hdr: tar.Header{Name: "pax_global_header", Typeflag: tar.TypeXGlobalHeader, PAXRecords: map[string]string{"comment": "0123abcd"}, Format: tar.FormatPAX}}, 416 tarMember{hdr: tar.Header{Name: "pdf/SKILL.md", Typeflag: tar.TypeReg}, body: "# PDF"}, 417 ) 418 }, 419 wantFiles: map[string]string{"SKILL.md": "# PDF"}, 420 wantAbsent: []string{"pax_global_header", "pdf"}, 421 }, 422 { 423 description: "a tar symlink named ../x is skipped and nothing named x appears beside dest", 424 data: func(t *testing.T) []byte { 425 return rawTarBytes(t, 426 tarMember{hdr: tar.Header{Name: "../x", Typeflag: tar.TypeSymlink, Linkname: "/etc"}}, 427 tarMember{hdr: tar.Header{Name: "pdf/SKILL.md", Typeflag: tar.TypeReg}, body: "# PDF"}, 428 ) 429 }, 430 wantFiles: map[string]string{"SKILL.md": "# PDF"}, 431 wantAbsent: []string{"x", "../x", "pdf"}, 432 }, 433 { 434 description: "zip Unix-host symlink and FIFO entries are skipped, not written out as regular files", 435 data: func(t *testing.T) []byte { 436 return rawZipBytes(t, 437 zipMember{hdr: zip.FileHeader{Name: "pdf/SKILL.md"}, body: "# PDF"}, 438 zipMember{hdr: zip.FileHeader{Name: "pdf/lnk", CreatorVersion: zipUnixHost, ExternalAttrs: (sIFLNK | 0o777) << 16}, body: "/etc/passwd"}, 439 zipMember{hdr: zip.FileHeader{Name: "pdf/fifo", CreatorVersion: zipUnixHost, ExternalAttrs: (sIFIFO | 0o644) << 16}}, 440 ) 441 }, 442 wantFiles: map[string]string{"SKILL.md": "# PDF"}, 443 wantAbsent: []string{"lnk", "fifo", "pdf"}, 444 }, 445 { 446 description: "zip symlink type bits on a non-Unix-host entry are not a mode, so the entry is a regular file", 447 data: func(t *testing.T) []byte { 448 return rawZipBytes(t, 449 zipMember{hdr: zip.FileHeader{Name: "pdf/SKILL.md"}, body: "# PDF"}, 450 zipMember{hdr: zip.FileHeader{Name: "pdf/lnk", CreatorVersion: 0, ExternalAttrs: (sIFLNK | 0o777) << 16}, body: "/etc/passwd"}, 451 ) 452 }, 453 wantFiles: map[string]string{"SKILL.md": "# PDF", "lnk": "/etc/passwd"}, 454 wantAbsent: []string{"pdf"}, 455 }, 456 { 457 description: "zip Unix-host entries with zero type bits are files and S_IFDIR without a trailing slash is a directory", 458 data: func(t *testing.T) []byte { 459 return rawZipBytes(t, 460 zipMember{hdr: zip.FileHeader{Name: "pdf/SKILL.md", CreatorVersion: zipUnixHost, ExternalAttrs: 0o600 << 16}, body: "# PDF"}, 461 zipMember{hdr: zip.FileHeader{Name: "pdf/run.sh", CreatorVersion: zipUnixHost, ExternalAttrs: 0o755 << 16}, body: "#!/bin/sh"}, 462 zipMember{hdr: zip.FileHeader{Name: "pdf/sub", CreatorVersion: zipUnixHost, ExternalAttrs: (sIFDIR | 0o755) << 16}}, 463 ) 464 }, 465 wantFiles: map[string]string{"SKILL.md": "# PDF", "run.sh": "#!/bin/sh"}, 466 wantModes: map[string]os.FileMode{"run.sh": 0o755}, 467 wantDirs: []string{"sub"}, 468 wantAbsent: []string{"pdf"}, 469 }, 470 } 471 472 for _, tc := range tests { 473 t.Run(tc.description, func(t *testing.T) { 474 parent := t.TempDir() 475 dest := filepath.Join(parent, "s") 476 require.NoError(t, extractArchiveBytes(t, tc.data(t), dest)) 477 478 for rel, want := range tc.wantFiles { 479 fi, err := os.Lstat(filepath.Join(dest, rel)) 480 require.NoError(t, err) 481 require.True(t, fi.Mode().IsRegular(), "%s must be a regular file, got %v", rel, fi.Mode()) 482 got, err := os.ReadFile(filepath.Join(dest, rel)) 483 require.NoError(t, err) 484 require.Equal(t, want, string(got), rel) 485 } 486 if runtime.GOOS != "windows" { 487 for rel, want := range tc.wantModes { 488 fi, err := os.Lstat(filepath.Join(dest, rel)) 489 require.NoError(t, err) 490 require.Equal(t, want, fi.Mode().Perm(), rel) 491 } 492 } 493 for _, rel := range tc.wantDirs { 494 fi, err := os.Lstat(filepath.Join(dest, rel)) 495 require.NoError(t, err) 496 require.True(t, fi.IsDir(), "%s must be a directory, got %v", rel, fi.Mode()) 497 } 498 for _, rel := range tc.wantAbsent { 499 _, err := os.Lstat(filepath.Join(dest, rel)) 500 require.ErrorIs(t, err, os.ErrNotExist, "%s must not exist", rel) 501 } 502 requireOnlyPlainEntries(t, dest) 503 siblings, err := os.ReadDir(parent) 504 require.NoError(t, err) 505 require.Len(t, siblings, 1, "nothing may be created outside dest") 506 }) 507 } 508 } 509 510 func TestExtractSkillArchive_ConfinesTraversal(t *testing.T) { 511 parent := t.TempDir() 512 dest := filepath.Join(parent, "nested", "s") 513 if err := extractArchiveBytes(t, zipBytes(t, map[string]string{"../evil.txt": "pwn", "ok.txt": "fine"}), dest); err != nil { 514 t.Fatalf("extract: %v", err) 515 } 516 if _, err := os.Stat(filepath.Join(parent, "nested", "evil.txt")); err == nil { 517 t.Fatal("zip-slip member escaped the destination directory") 518 } 519 if got, _ := os.ReadFile(filepath.Join(dest, "evil.txt")); string(got) != "pwn" { 520 t.Fatalf("traversal member not confined into dest: %q", got) 521 } 522 if got, _ := os.ReadFile(filepath.Join(dest, "ok.txt")); string(got) != "fine" { 523 t.Fatalf("ok.txt = %q", got) 524 } 525 }