From a00b98ec3f181c2e1940bdb312615024c17d75ef Mon Sep 17 00:00:00 2001 From: Andrew Gerrand Date: Thu, 30 Sep 2010 11:59:46 +1000 Subject: [PATCH] archive/zip: new package for reading ZIP files R=rsc CC=golang-dev https://golang.org/cl/2125042 --- src/pkg/Makefile | 1 + src/pkg/archive/zip/Makefile | 12 + src/pkg/archive/zip/reader.go | 278 ++++++++++++++++++ src/pkg/archive/zip/reader_test.go | 180 ++++++++++++ src/pkg/archive/zip/struct.go | 33 +++ .../archive/zip/testdata/gophercolor16x16.png | Bin 0 -> 785 bytes src/pkg/archive/zip/testdata/r.zip | Bin 0 -> 440 bytes src/pkg/archive/zip/testdata/readme.notzip | Bin 0 -> 1905 bytes src/pkg/archive/zip/testdata/readme.zip | Bin 0 -> 1885 bytes src/pkg/archive/zip/testdata/test.zip | Bin 0 -> 1170 bytes 10 files changed, 504 insertions(+) create mode 100644 src/pkg/archive/zip/Makefile create mode 100644 src/pkg/archive/zip/reader.go create mode 100644 src/pkg/archive/zip/reader_test.go create mode 100644 src/pkg/archive/zip/struct.go create mode 100644 src/pkg/archive/zip/testdata/gophercolor16x16.png create mode 100644 src/pkg/archive/zip/testdata/r.zip create mode 100644 src/pkg/archive/zip/testdata/readme.notzip create mode 100644 src/pkg/archive/zip/testdata/readme.zip create mode 100644 src/pkg/archive/zip/testdata/test.zip diff --git a/src/pkg/Makefile b/src/pkg/Makefile index 33194918b8..d7351c5993 100644 --- a/src/pkg/Makefile +++ b/src/pkg/Makefile @@ -15,6 +15,7 @@ all: install DIRS=\ archive/tar\ + archive/zip\ asn1\ big\ bufio\ diff --git a/src/pkg/archive/zip/Makefile b/src/pkg/archive/zip/Makefile new file mode 100644 index 0000000000..32a543133c --- /dev/null +++ b/src/pkg/archive/zip/Makefile @@ -0,0 +1,12 @@ +# Copyright 2010 The Go Authors. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. + +include ../../../Make.inc + +TARG=archive/zip +GOFILES=\ + reader.go\ + struct.go\ + +include ../../../Make.pkg diff --git a/src/pkg/archive/zip/reader.go b/src/pkg/archive/zip/reader.go new file mode 100644 index 0000000000..579ba16029 --- /dev/null +++ b/src/pkg/archive/zip/reader.go @@ -0,0 +1,278 @@ +// Copyright 2010 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +/* +The zip package provides support for reading ZIP archives. + +See: http://www.pkware.com/documents/casestudies/APPNOTE.TXT + +This package does not support ZIP64 or disk spanning. +*/ +package zip + +import ( + "bufio" + "bytes" + "compress/flate" + "hash" + "hash/crc32" + "encoding/binary" + "io" + "os" +) + +var ( + FormatError = os.NewError("not a valid zip file") + UnsupportedMethod = os.NewError("unsupported compression algorithm") + ChecksumError = os.NewError("checksum error") +) + +type Reader struct { + r io.ReaderAt + File []*File + Comment string +} + +type File struct { + FileHeader + zipr io.ReaderAt + zipsize int64 + headerOffset uint32 + bodyOffset int64 +} + +// OpenReader will open the Zip file specified by name and return a Reader. +func OpenReader(name string) (*Reader, os.Error) { + f, err := os.Open(name, os.O_RDONLY, 0644) + if err != nil { + return nil, err + } + fi, err := f.Stat() + if err != nil { + return nil, err + } + return NewReader(f, fi.Size) +} + +// NewReader returns a new Reader reading from r, which is assumed to +// have the given size in bytes. +func NewReader(r io.ReaderAt, size int64) (*Reader, os.Error) { + end, err := readDirectoryEnd(r, size) + if err != nil { + return nil, err + } + z := &Reader{ + r: r, + File: make([]*File, end.directoryRecords), + Comment: end.comment, + } + rs := io.NewSectionReader(r, 0, size) + if _, err = rs.Seek(int64(end.directoryOffset), 0); err != nil { + return nil, err + } + buf := bufio.NewReader(rs) + for i := range z.File { + z.File[i] = &File{zipr: r, zipsize: size} + if err := readDirectoryHeader(z.File[i], buf); err != nil { + return nil, err + } + } + return z, nil +} + +// Open returns a ReadCloser that provides access to the File's contents. +func (f *File) Open() (rc io.ReadCloser, err os.Error) { + off := int64(f.headerOffset) + if f.bodyOffset == 0 { + r := io.NewSectionReader(f.zipr, off, f.zipsize-off) + if err = readFileHeader(f, r); err != nil { + return + } + if f.bodyOffset, err = r.Seek(0, 1); err != nil { + return + } + } + r := io.NewSectionReader(f.zipr, off+f.bodyOffset, int64(f.CompressedSize)) + switch f.Method { + case 0: // store (no compression) + rc = nopCloser{r} + case 8: // DEFLATE + rc = flate.NewReader(r) + default: + err = UnsupportedMethod + } + if rc != nil { + rc = &checksumReader{rc, crc32.NewIEEE(), f.CRC32} + } + return +} + +type checksumReader struct { + rc io.ReadCloser + hash hash.Hash32 + sum uint32 +} + +func (r *checksumReader) Read(b []byte) (n int, err os.Error) { + n, err = r.rc.Read(b) + r.hash.Write(b[:n]) + if err != os.EOF { + return + } + if r.hash.Sum32() != r.sum { + err = ChecksumError + } + return +} + +func (r *checksumReader) Close() os.Error { return r.rc.Close() } + +type nopCloser struct { + io.Reader +} + +func (f nopCloser) Close() os.Error { return nil } + +func readFileHeader(f *File, r io.Reader) (err os.Error) { + defer func() { + if rerr, ok := recover().(os.Error); ok { + err = rerr + } + }() + var ( + signature uint32 + filenameLength uint16 + extraLength uint16 + ) + read(r, &signature) + if signature != fileHeaderSignature { + return FormatError + } + read(r, &f.ReaderVersion) + read(r, &f.Flags) + read(r, &f.Method) + read(r, &f.ModifiedTime) + read(r, &f.ModifiedDate) + read(r, &f.CRC32) + read(r, &f.CompressedSize) + read(r, &f.UncompressedSize) + read(r, &filenameLength) + read(r, &extraLength) + f.Name = string(readByteSlice(r, filenameLength)) + f.Extra = readByteSlice(r, extraLength) + return +} + +func readDirectoryHeader(f *File, r io.Reader) (err os.Error) { + defer func() { + if rerr, ok := recover().(os.Error); ok { + err = rerr + } + }() + var ( + signature uint32 + filenameLength uint16 + extraLength uint16 + commentLength uint16 + startDiskNumber uint16 // unused + internalAttributes uint16 // unused + externalAttributes uint32 // unused + ) + read(r, &signature) + if signature != directoryHeaderSignature { + return FormatError + } + read(r, &f.CreatorVersion) + read(r, &f.ReaderVersion) + read(r, &f.Flags) + read(r, &f.Method) + read(r, &f.ModifiedTime) + read(r, &f.ModifiedDate) + read(r, &f.CRC32) + read(r, &f.CompressedSize) + read(r, &f.UncompressedSize) + read(r, &filenameLength) + read(r, &extraLength) + read(r, &commentLength) + read(r, &startDiskNumber) + read(r, &internalAttributes) + read(r, &externalAttributes) + read(r, &f.headerOffset) + f.Name = string(readByteSlice(r, filenameLength)) + f.Extra = readByteSlice(r, extraLength) + f.Comment = string(readByteSlice(r, commentLength)) + return +} + +func readDirectoryEnd(r io.ReaderAt, size int64) (d *directoryEnd, err os.Error) { + // look for directoryEndSignature in the last 1k, then in the last 65k + var b []byte + for i, bLen := range []int64{1024, 65 * 1024} { + if bLen > size { + bLen = size + } + b = make([]byte, int(bLen)) + if _, err := r.ReadAt(b, size-bLen); err != nil && err != os.EOF { + return nil, err + } + if p := findSignatureInBlock(b); p >= 0 { + b = b[p:] + break + } + if i == 1 || bLen == size { + return nil, FormatError + } + } + + // read header into struct + defer func() { + if rerr, ok := recover().(os.Error); ok { + err = rerr + d = nil + } + }() + br := bytes.NewBuffer(b[4:]) // skip over signature + d = new(directoryEnd) + read(br, &d.diskNbr) + read(br, &d.dirDiskNbr) + read(br, &d.dirRecordsThisDisk) + read(br, &d.directoryRecords) + read(br, &d.directorySize) + read(br, &d.directoryOffset) + read(br, &d.commentLen) + d.comment = string(readByteSlice(br, d.commentLen)) + return d, nil +} + +func findSignatureInBlock(b []byte) int { + const minSize = 4 + 2 + 2 + 2 + 2 + 4 + 4 + 2 // fixed part of header + for i := len(b) - minSize; i >= 0; i-- { + // defined from directoryEndSignature in struct.go + if b[i] == 'P' && b[i+1] == 'K' && b[i+2] == 0x05 && b[i+3] == 0x06 { + // n is length of comment + n := int(b[i+minSize-2]) | int(b[i+minSize-1])<<8 + if n+minSize+i == len(b) { + return i + } + } + } + return -1 +} + +func read(r io.Reader, data interface{}) { + if err := binary.Read(r, binary.LittleEndian, data); err != nil { + panic(err) + } +} + +func readByteSlice(r io.Reader, l uint16) []byte { + b := make([]byte, l) + if l == 0 { + return b + } + if _, err := io.ReadFull(r, b); err != nil { + panic(err) + } + return b +} diff --git a/src/pkg/archive/zip/reader_test.go b/src/pkg/archive/zip/reader_test.go new file mode 100644 index 0000000000..36b925c4fc --- /dev/null +++ b/src/pkg/archive/zip/reader_test.go @@ -0,0 +1,180 @@ +// Copyright 2010 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package zip + +import ( + "bytes" + "encoding/binary" + "io" + "io/ioutil" + "os" + "testing" +) + +type ZipTest struct { + Name string + Comment string + File []ZipTestFile + Error os.Error // the error that Opening this file should return +} + +type ZipTestFile struct { + Name string + Content []byte // if blank, will attempt to compare against File + File string // name of file to compare to (relative to testdata/) +} + +var tests = []ZipTest{ + ZipTest{ + Name: "test.zip", + Comment: "This is a zipfile comment.", + File: []ZipTestFile{ + ZipTestFile{ + Name: "test.txt", + Content: []byte("This is a test text file.\n"), + }, + ZipTestFile{ + Name: "gophercolor16x16.png", + File: "gophercolor16x16.png", + }, + }, + }, + ZipTest{ + Name: "r.zip", + File: []ZipTestFile{ + ZipTestFile{ + Name: "r/r.zip", + File: "r.zip", + }, + }, + }, + ZipTest{Name: "readme.zip"}, + ZipTest{Name: "readme.notzip", Error: FormatError}, +} + +func TestReader(t *testing.T) { + for _, zt := range tests { + readTestZip(t, zt) + } +} + +func readTestZip(t *testing.T, zt ZipTest) { + z, err := OpenReader("testdata/" + zt.Name) + if err != zt.Error { + t.Errorf("error=%v, want %v", err, zt.Error) + return + } + + // bail here if no Files expected to be tested + // (there may actually be files in the zip, but we don't care) + if zt.File == nil { + return + } + + if z.Comment != zt.Comment { + t.Errorf("%s: comment=%q, want %q", zt.Name, z.Comment, zt.Comment) + } + if len(z.File) != len(zt.File) { + t.Errorf("%s: file count=%d, want %d", zt.Name, len(z.File), len(zt.File)) + } + + // test read of each file + for i, ft := range zt.File { + readTestFile(t, ft, z.File[i]) + } + + // test simultaneous reads + n := 0 + done := make(chan bool) + for i := 0; i < 5; i++ { + for j, ft := range zt.File { + go func() { + readTestFile(t, ft, z.File[j]) + done <- true + }() + n++ + } + } + for ; n > 0; n-- { + <-done + } + + // test invalid checksum + z.File[0].CRC32++ // invalidate + r, err := z.File[0].Open() + if err != nil { + t.Error(err) + return + } + var b bytes.Buffer + _, err = io.Copy(&b, r) + if err != ChecksumError { + t.Errorf("%s: copy error=%v, want %v", err, ChecksumError) + } +} + +func readTestFile(t *testing.T, ft ZipTestFile, f *File) { + if f.Name != ft.Name { + t.Errorf("name=%q, want %q", f.Name, ft.Name) + } + var b bytes.Buffer + r, err := f.Open() + if err != nil { + t.Error(err) + return + } + _, err = io.Copy(&b, r) + if err != nil { + t.Error(err) + return + } + r.Close() + var c []byte + if len(ft.Content) != 0 { + c = ft.Content + } else if c, err = ioutil.ReadFile("testdata/" + ft.File); err != nil { + t.Error(err) + return + } + if b.Len() != len(c) { + t.Errorf("%s: len=%d, want %d", f.Name, b.Len(), len(c)) + return + } + for i, b := range b.Bytes() { + if b != c[i] { + t.Errorf("%s: content[%d]=%q want %q", i, b, c[i]) + return + } + } +} + +func TestInvalidFiles(t *testing.T) { + const size = 1024 * 70 // 70kb + b := make([]byte, size) + + // zeroes + _, err := NewReader(sliceReaderAt(b), size) + if err != FormatError { + t.Errorf("zeroes: error=%v, want %v", err, FormatError) + } + + // repeated directoryEndSignatures + sig := make([]byte, 4) + binary.LittleEndian.PutUint32(sig, directoryEndSignature) + for i := 0; i < size-4; i += 4 { + copy(b[i:i+4], sig) + } + _, err = NewReader(sliceReaderAt(b), size) + if err != FormatError { + t.Errorf("sigs: error=%v, want %v", err, FormatError) + } +} + +type sliceReaderAt []byte + +func (r sliceReaderAt) ReadAt(b []byte, off int64) (int, os.Error) { + copy(b, r[int(off):int(off)+len(b)]) + return len(b), nil +} diff --git a/src/pkg/archive/zip/struct.go b/src/pkg/archive/zip/struct.go new file mode 100644 index 0000000000..8a8c727d47 --- /dev/null +++ b/src/pkg/archive/zip/struct.go @@ -0,0 +1,33 @@ +package zip + +const ( + fileHeaderSignature = 0x04034b50 + directoryHeaderSignature = 0x02014b50 + directoryEndSignature = 0x06054b50 +) + +type FileHeader struct { + Name string + CreatorVersion uint16 + ReaderVersion uint16 + Flags uint16 + Method uint16 + ModifiedTime uint16 + ModifiedDate uint16 + CRC32 uint32 + CompressedSize uint32 + UncompressedSize uint32 + Extra []byte + Comment string +} + +type directoryEnd struct { + diskNbr uint16 // unused + dirDiskNbr uint16 // unused + dirRecordsThisDisk uint16 // unused + directoryRecords uint16 + directorySize uint32 + directoryOffset uint32 // relative to file + commentLen uint16 + comment string +} diff --git a/src/pkg/archive/zip/testdata/gophercolor16x16.png b/src/pkg/archive/zip/testdata/gophercolor16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..48854ff3b7225c8b4e8aa8a25754e1d0b7c77b6e GIT binary patch literal 785 zcmV+s1Md8ZP)5r00004XF*Lt007q5 z)K6G40000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!qe(a@st@4BKo_oJPzw>?HIhVm0L#Ej6 zcCguOm(QI#dLysUSQM1xW#7b?WhI9coK{!icDucq@rulkmX=oZy1mWeXsEqpG#cQz z^?utRB)7Nn2_^KAYm5|#yEXazMjH4t8k!0_NeXY&iE#p@M7{5k^WvsIwt5X66u zgMz_u`%zcjR>QW&SS(65Ye^9(fMuCu1-f+|cW+i$cI({q=d?f-4KkbL8H||JduEK zWEsv^R+I=c^Vc^Q6f|YEz%UF)-b=4uIMaXYiY0$xaS0d}!L(ln-P#;bsW@OrfJ(`M zqjL}(Z9R?iQmBps*cDKe=-Au$LmfdmOt7Tycmj$y<|dN@QaA~tlBZF^f-Lp@sesL# z=v+{~^TgRR$*YtpDuIysfOkp+VMc;zEC#y#TrZp+NA%AD{$()=e}R z3@I8(`tF+qL{{dnWNKppLokAoTC?UfF*Tt`)9K%C@(KhS0IXFLXxD~r|ta@O^5g7&dyB3xG)uXH!$3b5r40` zx_Z)TwPycJf*@p8yGZo8%QIme`{s}EJpZ7(yZbY-h4gwoy>wZv{T5&Vs0a;<1LtDu P00000NkvXXu0mjff_q_+ literal 0 HcmV?d00001 diff --git a/src/pkg/archive/zip/testdata/r.zip b/src/pkg/archive/zip/testdata/r.zip new file mode 100644 index 0000000000000000000000000000000000000000..ea0fa2ffccce585ecaf4826bac13a6d8e6c4a2a6 GIT binary patch literal 440 zcmWIWW@Zs#U|`^2;9yR%`T3HiQNXksrEvsLMiZOwEjm`=(yDN-!kM|m-QZ*H%98ge{d~p@!nJ&*A`}w`YI}`|g!5ivJN$;;SA3EQk zIOoyJH4UHK%^pxaSQ=)8Imurw?x{9R3ON<;wrz5Mp?cA@aZQK$_WtUD?P1YX-N7M8 zJ#ERQl&0sr^Wn7hHRB>5ul1#@Gxg+DWR+}qT->}AwQ3J*>j&)W%36ck+3WhK_p_F< zGg^P&zU}%u-tctlnx`W1xGyHWtvIStk2Fnl%PUV*4&>EmKwgvIf2E`UQ2J}d%2Nkt z?PoR}v~1einYy|^wUe8nIa6}D{#a7JQ0i4T+8&&4T3=A>8fxh)uiO)tksY<`=O5Y% zFPuN|&8{sy-R6zYU42!NIkoV+!Rpx9on6byn_lYmdOFK54)!Z!cg$RQx2Z;NZkQxC zNBq>@(;{h2C%eCGS8f@s6pi+_u=C2zh}|D{)76~6er%(rWo^^J?QjF}pnlHeI?E5CH3Q8{}@ zYwdF5N`B|Z`_{AV$76y!4}9-ipc-C(FXorfEeGywxc%v)!5f2De>JX7Zn<{hPQ~ey z6=90*qtRd6)Az1cm~(?hO;r3JmlH6r6cvQO&1=cO0HwixN}nOroEtovL^BEWEhNV6=UIUA4!2(nPTM&)5}3E&h(cZyEu zKqR9U@eUopxmZNoNJ8DnlUk?fVsRQ!hPwn_XaN=;c?HO8N%b_l2>Cz}(jCrEB7v3W zfQNxzyw^F#3R!s^7cGj=rcwPXy`>EB633AQ4un@hLIUYdpE5IXW}-$#n#V$sjJR-p3N(_xR7I^AOM6D@LmQ>fKMQ3iw{UUB0f2c1VHl;*cd_N8H*%$ zDseVEA2DfOVMc-=ma!19cpeVtu_L@nlp)K;oODBep+P>>a z?l?@_k`Y^x3&VrMF-L(~jDHe|bJNxp053BfE~}FSUME|GA&Vz)jl}WLA~7rm!OkPs z2>t^22LzZ{t%VkxV7dUYBhSznhXp3h2u>uiSS(gRfVRnqVEzKq{XN@=5<09Y)(iPb z7Mze}V+C0f*)Xz^U@7f#voJo;C@d9u+KNe#6~pGp{`#kzl5NT}$b%8yU5vwt-EiZb zC##KE7I_J&ft{npdze6$LYxQq1xwwC7lGd;eIVOYYv#0~Z2c5+O?hetjjHfJWRVXH N7a|BlY#}RX{{#kl!8-r| literal 0 HcmV?d00001 diff --git a/src/pkg/archive/zip/testdata/readme.zip b/src/pkg/archive/zip/testdata/readme.zip new file mode 100644 index 0000000000000000000000000000000000000000..db3bb900e4e9f30bafa25fec963df4dba21a464d GIT binary patch literal 1885 zcmZ{kdrVVT9LF!V3}{!Ui6aJx9zjJmXxSX1WJM9mLkg{cIvKj%wx{jY+k5JLv@kYN z!5JNo4aFBh<3P!DBFwoztb=VNPNqXQ6{aRQT~vG!dePQq(h46jfkI%g8e zQVuF8O1`*?@=TZJjr;uWq3w!-v;}N_RP?Gf^!Rp8n9Jq|ixOs_FG}=jWzwyBn5iYU=NPvm|7(PkVE$ zn%di$xNlF2X7Iz>ccTV36;$mSJ1(qRz3_GQ#iaMwnGc>DNSyodrP{_%?qmn>Ym_`BcAr;QcBZ%&iP<^W$pOL zM{9j4>rA~l6iev9+|0J$w{5-ljyF7=y6UM& zJm!lDZ!eB&(j!gtyz;8!RfBmA8IafH4_xjXIGFxwvGU}B+54D{2P_-6bfvByNbTZg zYEG9NYB-vdFO+)KO}6`|nkx(HT*IyXr^ zb^HgqOO3wPq2SAXb#rneMv5o4JvP34HlnBI=;Pp$q}mNz<3{4bQ};JSovnWG+B9qA z`L`nMm%5od^-K4rKX)VOUiZCa6BcZ#+EovCv*1kL;g;FHVQ zY{{E$Hhk&TSBEcoFZ1oX<^AJhEn}y}rX+ZV+siLrZ&J?L-d4B5xQgHL@!m?d<5)~^ z*Z%K)3sobPcVm7D-Ms(y`dgnq9J)SqM@h-LjT^3s4&Dr}Qa8&AGwPkgOp5G~&;#m~W^oaxwzIFVP8sfF#}u zffd;x;Gr7{jEML$U`M5NQ(^O z0Bu2%2%N|ZLLLZ-v@NhYw;9PlvE3uwSGR>;chxM)#? zHjV0E@!yEO|7#w6uU}gZ;uP$y07Z?8G>4pr;Som=03MoWXbT>5YL=)&o_wkgjbFGhHGF%Bno!;Md#tTtj<l&aaaM)^pwV1FgBTd*)~VY5GrSri z$jrb1!XgYZ4C(m=8L36d`8oMThGrFpW_ksA>0oQD44Qqcff&u2&Hz7mUM?w+fxMm` zE&U=x?Zy@V2qPe0vcxr_Bsf2F!C6nVlf&(QE?5{rm?pSGb*exy9+#I&RurKk- z+m)0yRCPG=d-HHDZeo&8l5*8w*j(kQ)h25CKV`;*MHBS|=I94cI>i2RTbstM*llm) zO8IWizic_XckQR<_w%av+wcEed*5>UK?&z&H{O(96e0boH{+WCE(bu95f2^3EZ4O=VNZ?3)UvtyJg5QDRcf$^EI~Jki zWtQzJsL_x*GnsW)scd27+nNv&O9`(nP~TLZC!C)&dpmb9Gex+~DL%5m{kC!2{41u2r}!o#L;7=ONQ zE-qq{y7hnokNZ9E^>G%L>tA=N)+ZKnN{AiY63=$`mQ27(iv*7buak|7iv?tIZF@fl zt>|OAAsR5{#f{tFon;*@eKst4U%}L6{!DE$Q;-Gc;z`Q`4>jq0{kyW^?3@=d0sB55 zxoLcI^%PC5#tAeEd>m$^DR^;2N$vDP24)a*LyE?_;6T$0ZaWgUlR!) zO)1Y`?`E^OggslIl9kNpY|u0@YV$J_L(}Dqa{cO(OH|YIR<91PW~;8;CG9SvxT%P1 zf&1?rTi;(l9{BL-LFV7-o3%9b_5H&Yx{{nGExEP)?O%T1_U}*5o%1=o!$7pc+~7{P zVEy`CyQ(f_ZB6^{)1;6Rw%g;&&eO(;vcI48f3W$#VE69d#v5C|RN3w=iCp_vEP-JK z2X8m?^Q2e6G|k}Y>gTe~DWNIAn~_P58CTww04Zev=23TwQdKa(&kYWhQ$ShU>qC|zN%!0JcoK%J6 L{M_8syb?VCG^Dz$ literal 0 HcmV?d00001 -- 2.48.1