carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package archive
2
3import (
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.
19const 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.
25const (
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).
39const 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.
48const 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.
52const 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.
59const 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.
63const (
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.
70const 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.
75func 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.
120func 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).
147type streamRules struct {
148 seen map[string]bool
149 dirs map[string]bool
150 count int
151 content int64
152}
153
154func 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.
159func (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.
190type boundedReader struct {
191 r io.Reader
192 left int64
193}
194
195func (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.
217func 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.
224func 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