| 1 | package archive |
| 2 | |
| 3 | import ( |
| 4 | "archive/tar" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "path" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/hostfiles" |
| 12 | "github.com/klauspost/compress/zstd" |
| 13 | ) |
| 14 | |
| 15 | // Cap is the archive's content cap (M5): the sum of the regular files' |
| 16 | // sizes, <host>.env included. Tar headers and padding are not content and |
| 17 | // do not count against it (F25); they are bounded by MaxEntries and |
| 18 | // MaxPathLen instead. |
| 19 | const Cap int64 = 256 << 20 |
| 20 | |
| 21 | // MaxEntries bounds the number of tar entries (files, directories and env) |
| 22 | // in one archive, and MaxPathLen the length of a path inside the files |
| 23 | // directory. With Cap they bound the decoded stream (MaxDecoded), so a zstd |
| 24 | // bomb is bounded on the target even after it passed Verify. |
| 25 | const ( |
| 26 | MaxEntries = 1 << 17 |
| 27 | MaxPathLen = 4096 |
| 28 | ) |
| 29 | |
| 30 | // TreePathLen is the files tree's own cap, hostfiles' (MaxPathBytes, 4095 |
| 31 | // bytes): the most a path inside the files directory may hold and still be |
| 32 | // staged into the prepared files manifest the target builds after the cut |
| 33 | // (hostfiles.Prepared.Stage refuses anything longer). It is one byte under |
| 34 | // MaxPathLen, the archive format's bound that MV-N81 states, so Build |
| 35 | // refuses a path of exactly MaxPathLen with ErrPastTreeCap (a refusal at |
| 36 | // S1, before the cut), and every reader refuses one past TreePathLen |
| 37 | // (checkName), so an archive an older source built with such a path never |
| 38 | // passes the target's verification (#236 fix round 1). |
| 39 | const TreePathLen = hostfiles.MaxPathBytes |
| 40 | |
| 41 | // entryOverhead is the most tar bytes one entry costs beyond its content: |
| 42 | // a 512-byte header, a PAX header (512 bytes plus the path record, at most |
| 43 | // len("files/")+MaxPathLen+1 bytes and a length prefix, rounded up to 4608) |
| 44 | // when the path does not fit the USTAR fields, and under 512 bytes of |
| 45 | // padding. That is at most 6143; 8 KiB leaves room. Owners, times and |
| 46 | // sizes under Cap always fit USTAR, so no other PAX record is written. |
| 47 | // TestTheLongestPathFitsTheEntryOverhead checks the arithmetic. |
| 48 | const entryOverhead = 8 << 10 |
| 49 | |
| 50 | // MaxDecoded is the most bytes a decoded archive (the tar stream) may hold: |
| 51 | // content, per-entry overhead and the two-block end marker. |
| 52 | const MaxDecoded int64 = Cap + MaxEntries*entryOverhead + 2*512 |
| 53 | |
| 54 | // MarkerName is the protocol marker inside the files directory, never |
| 55 | // archived or digested (§5.1, §6.1 F5), and refused if an archive carries |
| 56 | // it or anything under it: an extracted marker would claim an installation |
| 57 | // that never happened, and a directory of that name would block the |
| 58 | // landing's own marker write. |
| 59 | const MarkerName = ".files-gen" |
| 60 | |
| 61 | // EnvEntry is <host>.env's path inside the archive; FilesPrefix is the |
| 62 | // files directory's namespace. The two never overlap. |
| 63 | const ( |
| 64 | EnvEntry = "env" |
| 65 | FilesPrefix = "files/" |
| 66 | ) |
| 67 | |
| 68 | // zstdWindow is the compression window, and the largest window a decoder |
| 69 | // accepts: a hostile frame cannot make the target allocate more. |
| 70 | const zstdWindow = 8 << 20 |
| 71 | |
| 72 | // checkName validates one archive path (a directory's without its |
| 73 | // trailing slash) and says which namespace it is in. It is the one rule |
| 74 | // Verify, Extract and the manifest check share. |
| 75 | func checkName(name string, dir bool) (rel string, isEnv bool, err error) { |
| 76 | bad := func(why string) error { return fmt.Errorf("%w: %q %s", ErrBadPath, name, why) } |
| 77 | switch { |
| 78 | case name == "": |
| 79 | return "", false, bad("is empty") |
| 80 | case len(name) > len(FilesPrefix)+MaxPathLen: |
| 81 | return "", false, bad("is too long") |
| 82 | case strings.ContainsRune(name, 0): |
| 83 | return "", false, bad("holds a NUL") |
| 84 | case path.IsAbs(name): |
| 85 | return "", false, bad("is absolute") |
| 86 | case path.Clean(name) != name: |
| 87 | return "", false, bad("is not clean") |
| 88 | } |
| 89 | for _, c := range strings.Split(name, "/") { |
| 90 | if c == ".." || c == "." || c == "" { |
| 91 | return "", false, bad("leaves its directory") |
| 92 | } |
| 93 | } |
| 94 | if name == EnvEntry { |
| 95 | if dir { |
| 96 | return "", false, bad("is the env file but not a regular file") |
| 97 | } |
| 98 | return "", true, nil |
| 99 | } |
| 100 | rel, ok := strings.CutPrefix(name, FilesPrefix) |
| 101 | if !ok || rel == "" { |
| 102 | return "", false, bad("is outside files/ and env") |
| 103 | } |
| 104 | if len(rel) > TreePathLen { |
| 105 | return "", false, bad("is past the files tree's length cap") |
| 106 | } |
| 107 | if rel == MarkerName || strings.HasPrefix(rel, MarkerName+"/") { |
| 108 | return "", false, bad("is the protocol marker or inside it") |
| 109 | } |
| 110 | // The platform's own rules on top (Windows reserved names and drive |
| 111 | // letters; a backslash is a separator there and a file name here). |
| 112 | if !filepath.IsLocal(filepath.FromSlash(rel)) || (filepath.Separator != '/' && strings.ContainsRune(rel, filepath.Separator)) { |
| 113 | return "", false, bad("is not a local path on this platform") |
| 114 | } |
| 115 | return rel, false, nil |
| 116 | } |
| 117 | |
| 118 | // headerName validates a tar header's name and type: regular files and |
| 119 | // directories only. |
| 120 | func headerName(hdr *tar.Header) (name, rel string, isEnv bool, err error) { |
| 121 | switch hdr.Typeflag { |
| 122 | case tar.TypeReg, tar.TypeDir: |
| 123 | default: |
| 124 | return "", "", false, fmt.Errorf("%w: %q", ErrSpecialFile, hdr.Name) |
| 125 | } |
| 126 | if hdr.Size < 0 || (hdr.Typeflag == tar.TypeDir && hdr.Size != 0) { |
| 127 | return "", "", false, fmt.Errorf("%w: %q has size %d", ErrBadPath, hdr.Name, hdr.Size) |
| 128 | } |
| 129 | name = hdr.Name |
| 130 | if hdr.Typeflag == tar.TypeDir { |
| 131 | name = strings.TrimSuffix(name, "/") |
| 132 | } |
| 133 | rel, isEnv, err = checkName(name, hdr.Typeflag == tar.TypeDir) |
| 134 | return name, rel, isEnv, err |
| 135 | } |
| 136 | |
| 137 | // streamRules are the rules every reader of an archive stream enforces, |
| 138 | // in one place so Verify and Extract cannot drift apart (fix round 2): a |
| 139 | // stream Extract would refuse must not pass Verify and fail at landing. |
| 140 | // Per entry, in order: the entry limit; a valid name and type |
| 141 | // (headerName); no destination named twice; every entry's directory |
| 142 | // listed before it (Build's sorted order always does, and Extract needs |
| 143 | // it so each directory carries its archived mode); and the content cap, |
| 144 | // checked on the header before any content is read. The manifest lists |
| 145 | // regular files only, so directory order is a property of the stream |
| 146 | // alone; Manifest.check applies the same name rule (checkName). |
| 147 | type streamRules struct { |
| 148 | seen map[string]bool |
| 149 | dirs map[string]bool |
| 150 | count int |
| 151 | content int64 |
| 152 | } |
| 153 | |
| 154 | func newStreamRules() *streamRules { |
| 155 | return &streamRules{seen: map[string]bool{}, dirs: map[string]bool{}} |
| 156 | } |
| 157 | |
| 158 | // admit checks one header against the rules and records it. |
| 159 | func (s *streamRules) admit(hdr *tar.Header) (name, rel string, isEnv bool, err error) { |
| 160 | if s.count >= entryLimit { |
| 161 | return "", "", false, ErrTooManyEntries |
| 162 | } |
| 163 | s.count++ |
| 164 | name, rel, isEnv, err = headerName(hdr) |
| 165 | if err != nil { |
| 166 | return "", "", false, err |
| 167 | } |
| 168 | if s.seen[name] { |
| 169 | return "", "", false, fmt.Errorf("%w: %q named twice", ErrBadPath, hdr.Name) |
| 170 | } |
| 171 | s.seen[name] = true |
| 172 | if !isEnv { |
| 173 | if dir := path.Dir(rel); dir != "." && !s.dirs[dir] { |
| 174 | return "", "", false, fmt.Errorf("%w: %q comes before its directory", ErrBadPath, hdr.Name) |
| 175 | } |
| 176 | } |
| 177 | if hdr.Typeflag == tar.TypeDir { |
| 178 | s.dirs[rel] = true |
| 179 | return name, rel, isEnv, nil |
| 180 | } |
| 181 | if hdr.Size > Cap-s.content { |
| 182 | return "", "", false, ErrOverCap |
| 183 | } |
| 184 | s.content += hdr.Size |
| 185 | return name, rel, isEnv, nil |
| 186 | } |
| 187 | |
| 188 | // boundedReader stops a decoded stream at its limit: past it, one more |
| 189 | // byte is ErrTooLarge, so a stream exactly at the limit still ends cleanly. |
| 190 | type boundedReader struct { |
| 191 | r io.Reader |
| 192 | left int64 |
| 193 | } |
| 194 | |
| 195 | func (b *boundedReader) Read(p []byte) (int, error) { |
| 196 | if b.left <= 0 { |
| 197 | var one [1]byte |
| 198 | n, err := b.r.Read(one[:]) |
| 199 | if n > 0 { |
| 200 | return 0, ErrTooLarge |
| 201 | } |
| 202 | return 0, err |
| 203 | } |
| 204 | if int64(len(p)) > b.left { |
| 205 | p = p[:b.left] |
| 206 | } |
| 207 | n, err := b.r.Read(p) |
| 208 | b.left -= int64(n) |
| 209 | return n, err |
| 210 | } |
| 211 | |
| 212 | // newDecoder always streams from r. WithDecodeBuffersBelow(0) turns off |
| 213 | // the decoder's shortcut for a reader with Bytes() and Len() (a |
| 214 | // *bytes.Buffer) under 128 KiB, which decodes the whole buffer at once and |
| 215 | // never drains it: requireEOF would then read the archive again and refuse |
| 216 | // it as data after the archive. |
| 217 | func newDecoder(r io.Reader) (*zstd.Decoder, error) { |
| 218 | return zstd.NewReader(r, zstd.WithDecoderConcurrency(1), zstd.WithDecoderMaxWindow(zstdWindow), zstd.WithDecodeBuffersBelow(0)) |
| 219 | } |
| 220 | |
| 221 | // requireEOF reads the raw stream once more after the decoder finished. A |
| 222 | // stream fed by Open through a pipe reports Open's terminal verdict here |
| 223 | // (the final chunk's authentication); trailing bytes are refused. |
| 224 | func requireEOF(r io.Reader) error { |
| 225 | var one [1]byte |
| 226 | n, err := io.ReadFull(r, one[:]) |
| 227 | if n > 0 { |
| 228 | return fmt.Errorf("%w: data after the archive", ErrBadPath) |
| 229 | } |
| 230 | if err == io.EOF { |
| 231 | return nil |
| 232 | } |
| 233 | return err |
| 234 | } |
| 235 | |