carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package archive is the files archive of the move-any-app design §8: a
2// deterministic tar of an instance's files directory and its <host>.env,
3// compressed with zstd, then sealed under the deployment's config keypair.
4//
5// The source box builds and seals it; the target box opens, verifies and
6// extracts it. The target treats every archive as hostile. The config
7// public key is widely held, so anyone who can write the staging bucket can
8// seal an archive that opens: authentication proves who it was sealed for,
9// not who sealed it. Every limit Build obeys, Verify and Extract enforce
10// again, and Build obeys every limit Verify and Extract enforce, so an
11// archive Build wrote always lands.
12package archive
13
14import (
15 "archive/tar"
16 "crypto/sha256"
17 "encoding/hex"
18 "errors"
19 "fmt"
20 "hash"
21 "io"
22 "io/fs"
23 "os"
24 "path/filepath"
25 "sort"
26
27 "github.com/klauspost/compress/zstd"
28)
29
30// ManifestVersion is the Manifest.V this package writes and reads.
31const ManifestVersion = 1
32
33// Errors from Build (ErrSpecialFile, ErrOverCap, ErrTooManyEntries,
34// ErrChanged and the long-path ErrBadPath) reach the operator through the
35// source report's S1; their texts are rows MV-N77 to MV-N81 and MV-N86 of
36// the copy record. The rest are the target's refusals.
37var (
38 ErrSpecialFile = errors.New("archive: only regular files and directories move")
39 ErrOverCap = errors.New("archive: the files directory is over 256 MiB")
40 ErrTooManyEntries = fmt.Errorf("archive: the files directory holds more than %d entries", MaxEntries)
41 ErrChanged = errors.New("archive: a file changed while it was being archived")
42 ErrBadPath = errors.New("archive: refused path")
43 ErrTooLarge = errors.New("archive: the decoded archive is over its size limit")
44 ErrNotEmpty = errors.New("archive: the destination files directory is not empty")
45 // ErrPastTreeCap is Build's refusal of a path longer than TreePathLen
46 // (hostfiles' cap) but within MaxPathLen; the source says MV-N502.
47 ErrPastTreeCap = errors.New("archive: a path is past the files tree's length cap")
48)
49
50// PathError is a Build refusal that names a file: Err is the sentinel
51// (errors.Is sees it), Path the file as Build names it (relative to the
52// files directory, or the env file's base name). It lets a caller say
53// which file in its own words without reading the error's text; Error() is
54// the text the sentinel's rows record (MV-N77, MV-N80, MV-N81, MV-N86).
55type PathError struct {
56 Err error
57 Path string
58 msg string
59}
60
61func (e *PathError) Error() string {
62 if e.msg != "" {
63 return e.msg
64 }
65 return e.Err.Error() + ": " + e.Path
66}
67func (e *PathError) Unwrap() error { return e.Err }
68
69// The refusals that name a file, each one whole recorded sentence (copy
70// record MV-N77, MV-N80, MV-N81 and MV-N86) so the copy gate can read it.
71// Err is the sentinel errors.Is sees; the text is the sentinel's, a colon
72// and the path, as the rows say.
73func specialFileError(path string) error {
74 return &PathError{Err: ErrSpecialFile, Path: path, msg: fmt.Sprintf("archive: only regular files and directories move: %s", path)}
75}
76
77func hardLinkError(path string) error {
78 return &PathError{Err: ErrSpecialFile, Path: path, msg: fmt.Sprintf("archive: only regular files and directories move: %s is a hard link", path)}
79}
80
81func changedError(path string) error {
82 return &PathError{Err: ErrChanged, Path: path, msg: fmt.Sprintf("archive: a file changed while it was being archived: %s", path)}
83}
84
85// The row says 4096, so the sentence does; this fails to compile if
86// MaxPathLen is ever changed without the row.
87const _ = uint(MaxPathLen-4096) + uint(4096-MaxPathLen)
88
89// treePathError is a path past TreePathLen but within MaxPathLen: the
90// archive format could carry it, the target's files manifest could not.
91// The operator's sentence is the caller's (MV-N502, from the sentinel);
92// this text reaches the log only.
93func treePathError(path string) error {
94 return &PathError{Err: ErrPastTreeCap, Path: path, msg: fmt.Sprintf("archive: refused path: %s is longer than the files tree's %d-byte cap", path, TreePathLen)}
95}
96
97func longPathError(path string) error {
98 return &PathError{Err: ErrBadPath, Path: path, msg: fmt.Sprintf("archive: refused path: %s is longer than 4096 bytes", path)}
99}
100
101// The limits as enforced. Variables only so tests can lower them; nothing
102// else writes them.
103var (
104 entryLimit = MaxEntries
105 decodedLimit = MaxDecoded
106)
107
108// Entry is one regular file in the manifest: its archive path ("env" or
109// "files/<path>"), size and sha256.
110type Entry struct {
111 Path string `json:"path"`
112 Size int64 `json:"size"`
113 SHA256 string `json:"sha256"`
114}
115
116// Manifest travels beside the sealed object (§8.2). SealedDataKey is the
117// archive's data key sealed to the deployment's config keypair (a v2
118// envelope bound to the app); it is not an object key (F29).
119type Manifest struct {
120 V int `json:"v"`
121 Op string `json:"op"`
122 Incarnation string `json:"incarnation"`
123 Plain string `json:"plain"`
124 Sealed string `json:"sealed"`
125 Bytes int64 `json:"bytes"`
126 Entries []Entry `json:"entries"`
127 SealedDataKey string `json:"sealed_data_key"`
128 Chunk int `json:"chunk"`
129}
130
131// item is one listed entry: its archive name, where to open it, and its
132// Lstat at listing time, which the open must match.
133type item struct {
134 name string // "env", "files/<rel>"
135 rel string // path inside the files root; "" for env
136 root *os.Root // the files root; nil for env
137 path string // envPath, for env
138 info fs.FileInfo // as listed
139}
140
141// walk lists the files directory and envPath in archive order, refusing
142// anything that is not a regular file or a directory and excluding the
143// marker. The files directory is read through an os.Root, so nothing a
144// tenant does inside it (a directory swapped for a symlink mid-walk) can
145// make the walk or a later open leave it.
146//
147// skip, when non-nil, is asked each path relative to the files directory
148// (slash-separated, as the archive names it) before anything else is read
149// about it: a path it accepts is left out as the marker is, a directory
150// with everything under it, and is neither typed nor counted, so a
151// platform artifact of any kind (a socket, a FIFO) cannot fail the walk.
152// envPath is never asked.
153func walk(root *os.Root, envPath string, skip func(rel string) bool) ([]item, error) {
154 var items []item
155 add := func(it item) error {
156 if len(items) >= entryLimit {
157 return ErrTooManyEntries
158 }
159 items = append(items, it)
160 return nil
161 }
162 if root != nil {
163 err := fs.WalkDir(root.FS(), ".", func(p string, d fs.DirEntry, err error) error {
164 if err != nil {
165 return err
166 }
167 if p == "." {
168 return nil
169 }
170 if p == MarkerName || (skip != nil && skip(p)) {
171 // The marker is never archived, nor anything under it if
172 // it is (wrongly) a directory; nor is a path the caller
173 // skips.
174 if d.IsDir() {
175 return fs.SkipDir
176 }
177 return nil
178 }
179 // Through the root only (census ruling moves-overcap-cut-236,
180 // binding): a read that fails refuses the walk, never a retry by
181 // path.
182 info, err := root.Lstat(p)
183 if err != nil {
184 return err
185 }
186 if !info.Mode().IsRegular() && !info.IsDir() {
187 return specialFileError(p)
188 }
189 if len(p) > MaxPathLen {
190 return longPathError(p)
191 }
192 if len(p) > TreePathLen {
193 return treePathError(p)
194 }
195 return add(item{name: FilesPrefix + p, rel: p, root: root, info: info})
196 })
197 if err != nil {
198 return nil, err
199 }
200 }
201 if envPath != "" {
202 info, err := os.Lstat(envPath)
203 switch {
204 case errors.Is(err, fs.ErrNotExist):
205 case err != nil:
206 return nil, err
207 case !info.Mode().IsRegular():
208 return nil, specialFileError(filepath.Base(envPath))
209 default:
210 if err := add(item{name: EnvEntry, path: envPath, info: info}); err != nil {
211 return nil, err
212 }
213 }
214 }
215 sort.Slice(items, func(i, j int) bool { return items[i].name < items[j].name })
216 return items, nil
217}
218
219// openItem opens a listed regular file. A variable so a test can stand in
220// a file that grows after it was listed.
221var openItem = openItemChecked
222
223// openItemChecked opens it.rel inside the files root (or the env path) and
224// requires the opened file to be the one listed: a regular file with the
225// listed identity and one name. A file swapped after the walk (for a FIFO, another file,
226// or a symlink the root would refuse to leave by) fails here. The open is
227// non-blocking where the platform has the flag, so a swapped-in FIFO
228// returns at once instead of waiting for a writer.
229func openItemChecked(it item) (io.ReadCloser, error) {
230 var f *os.File
231 var err error
232 if it.root != nil {
233 f, err = it.root.OpenFile(it.rel, os.O_RDONLY|openNonblock, 0)
234 } else {
235 f, err = os.OpenFile(it.path, os.O_RDONLY|openNonblock, 0)
236 }
237 if err != nil {
238 return nil, err
239 }
240 got, err := f.Stat()
241 if err != nil {
242 f.Close()
243 return nil, err
244 }
245 if !got.Mode().IsRegular() || !os.SameFile(it.info, got) {
246 f.Close()
247 return nil, changedError(displayName(it))
248 }
249 // Phase 1 does not carry hard links. os.Root constrains names, not
250 // inodes: a pre-existing link to a file outside the instance directory
251 // is a regular file with the listed identity, and reading it would
252 // carry the outside inode to the target. Counted on the opened file.
253 if linkCount(got) > 1 {
254 f.Close()
255 return nil, hardLinkError(displayName(it))
256 }
257 return f, nil
258}
259
260func displayName(it item) string {
261 if it.root == nil {
262 return filepath.Base(it.path)
263 }
264 return it.rel
265}
266
267// hashWriter hashes and counts the tar stream on its way to the
268// compressor. It enforces no limit: content is capped where sizes are
269// admitted, and the stream's length follows from the entry limits.
270type hashWriter struct {
271 w io.Writer
272 h hash.Hash
273 n int64
274}
275
276func (c *hashWriter) Write(p []byte) (int, error) {
277 c.h.Write(p)
278 c.n += int64(len(p))
279 return c.w.Write(p)
280}
281
282func digestString(h hash.Hash) string {
283 return "sha256:" + hex.EncodeToString(h.Sum(nil))
284}
285
286// built is one pass over the tree.
287type built struct {
288 plain string
289 entries []Entry
290 content int64
291 tarBytes int64
292 count int // tar entries: files, directories and env
293}
294
295// openFilesRoot opens filesDir as an os.Root, requiring it to be a real
296// directory (not a symlink to one) at both the Lstat and the open. A
297// missing directory is an empty one: the archive then carries env only.
298func openFilesRoot(filesDir string) (*os.Root, error) {
299 if filesDir == "" {
300 return nil, errors.New("archive: no files directory named")
301 }
302 info, err := os.Lstat(filesDir)
303 if errors.Is(err, fs.ErrNotExist) {
304 return nil, nil
305 }
306 if err != nil {
307 return nil, err
308 }
309 if !info.IsDir() {
310 // The files root itself is not a directory: untyped, so the caller
311 // reports it as the files failing to archive, never as a <path>.
312 return nil, fmt.Errorf("%w: %s", ErrSpecialFile, filesDir)
313 }
314 root, err := os.OpenRoot(filesDir)
315 if err != nil {
316 return nil, err
317 }
318 got, err := root.Stat(".")
319 if err != nil {
320 root.Close()
321 return nil, err
322 }
323 if !os.SameFile(info, got) {
324 root.Close()
325 // The root itself, not a file in it: untyped, as above.
326 return nil, fmt.Errorf("%w: %s", ErrChanged, filesDir)
327 }
328 return root, nil
329}
330
331// build writes the tar of filesDir and envPath to out and returns what
332// Build and PlainDigest report.
333func build(filesDir, envPath string, out io.Writer) (built, error) {
334 return buildFiltered(filesDir, envPath, nil, out)
335}
336
337// buildFiltered is build over the paths skip leaves in (walk).
338func buildFiltered(filesDir, envPath string, skip func(rel string) bool, out io.Writer) (built, error) {
339 root, err := openFilesRoot(filesDir)
340 if err != nil {
341 return built{}, err
342 }
343 if root != nil {
344 defer root.Close()
345 }
346 items, err := walk(root, envPath, skip)
347 if err != nil {
348 return built{}, err
349 }
350 var b built
351 hw := &hashWriter{w: out, h: sha256.New()}
352 tw := tar.NewWriter(hw)
353 for _, it := range items {
354 hdr := &tar.Header{Name: it.name, Mode: int64(it.info.Mode().Perm()), Format: tar.FormatPAX}
355 if it.info.IsDir() {
356 hdr.Typeflag = tar.TypeDir
357 hdr.Name += "/"
358 } else {
359 hdr.Typeflag = tar.TypeReg
360 hdr.Size = it.info.Size()
361 // Admitted against the cap before the header commits the size.
362 if hdr.Size > Cap-b.content {
363 return built{}, ErrOverCap
364 }
365 b.content += hdr.Size
366 }
367 if err := tw.WriteHeader(hdr); err != nil {
368 return built{}, err
369 }
370 b.count++
371 if hdr.Typeflag != tar.TypeReg {
372 continue
373 }
374 e, err := copyItem(tw, it, hdr.Size)
375 if err != nil {
376 return built{}, err
377 }
378 b.entries = append(b.entries, e)
379 }
380 if err := tw.Close(); err != nil {
381 return built{}, err
382 }
383 b.plain, b.tarBytes = digestString(hw.h), hw.n
384 return b, nil
385}
386
387// copyItem streams exactly the admitted size of one file into the tar.
388// This is the cap while streaming: a file is never read past the size its
389// header committed (and the cap admitted), so a file that grows after it
390// was listed cannot carry the archive past Cap. It is refused as changed,
391// as is one that shrank, rather than archived torn.
392func copyItem(tw io.Writer, it item, size int64) (Entry, error) {
393 rc, err := openItem(it)
394 if err != nil {
395 return Entry{}, err
396 }
397 defer rc.Close()
398 fh := sha256.New()
399 n, err := io.CopyN(io.MultiWriter(tw, fh), rc, size)
400 if errors.Is(err, io.EOF) {
401 return Entry{}, changedError(displayName(it))
402 }
403 if err != nil {
404 return Entry{}, err
405 }
406 var one [1]byte
407 m, err := io.ReadFull(rc, one[:])
408 if m > 0 {
409 return Entry{}, changedError(displayName(it))
410 }
411 if err != io.EOF {
412 return Entry{}, err
413 }
414 return Entry{Path: it.name, Size: n, SHA256: digestString(fh)}, nil
415}
416
417// Build writes the archive (tar then zstd) to w and returns the plaintext
418// digest (of the tar stream), the regular-file entries and the content
419// byte count. Owners and times are zeroed, modes kept, so equal content
420// gives equal plaintext (§8.1). The files directory's entries live under
421// "files/" and <host>.env is "env", in separate namespaces, so an
422// application file or directory named "env" is carried as "files/env" and
423// can never collide with the overlay. A missing envPath (or "") means the
424// archive has no env entry; a missing filesDir is an empty one.
425func Build(filesDir, envPath string, w io.Writer) (plain string, entries []Entry, bytes int64, err error) {
426 return BuildFiltered(filesDir, envPath, nil, w)
427}
428
429// BuildFiltered is Build over the files directory without the paths skip
430// accepts. skip is asked each path relative to filesDir, slash-separated
431// (a top-level entry is its bare name); a skipped directory is skipped
432// whole, and a skipped entry of any type is never read, typed or counted.
433// The env entry is never asked. A nil skip is Build. Nothing calls it with
434// a filter today: the offline move's sealer archives a nested instance's
435// files directory (<instance dir>/<host>, which holds nothing of the
436// platform's but the marker) with Build, and refuses the seal over any
437// entry of the app's beside it.
438func BuildFiltered(filesDir, envPath string, skip func(rel string) bool, w io.Writer) (plain string, entries []Entry, bytes int64, err error) {
439 zw, err := zstd.NewWriter(w, zstd.WithEncoderConcurrency(1), zstd.WithWindowSize(zstdWindow))
440 if err != nil {
441 return "", nil, 0, err
442 }
443 b, err := buildFiltered(filesDir, envPath, skip, zw)
444 if err != nil {
445 zw.Close()
446 return "", nil, 0, err
447 }
448 if err := zw.Close(); err != nil {
449 return "", nil, 0, err
450 }
451 return b.plain, b.entries, b.content, nil
452}
453
454// PlainDigest runs Build's walk and tar without compressing or writing
455// anything, and returns the same plaintext digest Build would, the content
456// byte count and the number of regular-file entries (env included). It is
457// the baseline the source records at `stopped` before any archive exists
458// (§8.3), and the comparison every recovery makes.
459func PlainDigest(filesDir, envPath string) (plain string, bytes int64, entries int, err error) {
460 b, err := build(filesDir, envPath, io.Discard)
461 if err != nil {
462 return "", 0, 0, err
463 }
464 return b.plain, b.content, len(b.entries), nil
465}
466