diff options
| author | Ophestra <cat@gensokyo.uk> | 2026-08-29 18:58:06 +0900 |
|---|---|---|
| committer | Ophestra <cat@gensokyo.uk> | 2026-08-29 18:58:06 +0900 |
| commit | c2d900ec74e03e9c782cfe7b7ce06ff62cda6601 (patch) | |
| tree | fb13320bfdc62d810aa59769e8809dc2f707919e /pkg/archive.go | |
| parent | bd4f29909e0750a4660eaf48e3169777a265b0ab (diff) | |
Closes #43.
Signed-off-by: Ophestra <cat@gensokyo.uk>
Diffstat (limited to 'pkg/archive.go')
| -rw-r--r-- | pkg/archive.go | 411 |
1 files changed, 411 insertions, 0 deletions
diff --git a/pkg/archive.go b/pkg/archive.go new file mode 100644 index 00000000..ea4017d9 --- /dev/null +++ b/pkg/archive.go @@ -0,0 +1,411 @@ +package pkg + +import ( + "crypto/sha512" + "encoding/binary" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "unsafe" + + "hakurei.app/check" +) + +/* +| mode uint32 | path_sz uint32 | +| data_sz uint64 | +| path string | +| data []byte | +*/ + +// An ArchiveHeader represents a single header in an archive. +type ArchiveHeader struct { + Mode fs.FileMode // file mode bits + Path string // pathname of the file + Size uint64 // size of data segment +} + +// Writer implements sequential writing of an archive. [Writer.WriteHeader] +// begins a new file with the provided [ArchiveHeader], and then Writer can be +// treated as an [io.Writer] to supply that file's data. +// +// It is the caller's responsibility to write entries in lexical order. +type Writer struct { + // Underlying writer. + w io.Writer + // Current header. + h ArchiveHeader + // Fixed-size header segment. + buf [wordSize * 2]byte + // Current position in data segment. + n uint64 +} + +// NewWriter returns the address of a new [Writer] writing to w. +func NewWriter(w io.Writer) *Writer { return &Writer{w: w} } + +var zero [wordSize]byte + +// padSize returns the padding size for aligning sz. +func padSize[T int | uint64](sz T) T { + return (wordSize - (sz)%wordSize) % wordSize +} + +// flush concludes writing to the current file and writes padding. +func (aw *Writer) flush() error { + if aw.h.Size > aw.n { + return fmt.Errorf("missed writing %d bytes", aw.h.Size-aw.n) + } else if aw.h.Size < aw.n { + return fmt.Errorf("wrote %d bytes beyond end of file", aw.n-aw.h.Size) + } + + if psz := padSize(aw.h.Size); psz != 0 { + if _, err := aw.w.Write(zero[:psz]); err != nil { + return err + } + } + + aw.n = 0 + return nil +} + +// WriteHeader writes h and begins accepting its corresponding file. +func (aw *Writer) WriteHeader(h *ArchiveHeader) error { + if err := aw.flush(); err != nil { + return err + } + + aw.h = *h + binary.LittleEndian.PutUint32(aw.buf[:], uint32(aw.h.Mode)) + binary.LittleEndian.PutUint32(aw.buf[wordSize/2:], uint32(len(aw.h.Path))) + binary.LittleEndian.PutUint64(aw.buf[wordSize:], aw.h.Size) + if _, err := aw.w.Write(aw.buf[:]); err != nil { + return err + } else if _, err = aw.w.Write( + unsafe.Slice(unsafe.StringData(aw.h.Path), len(aw.h.Path)), + ); err != nil { + return err + } else if psz := padSize(len(aw.h.Path)); psz != 0 { + if _, err = aw.w.Write(zero[:psz]); err != nil { + return err + } + } + return nil +} + +// Write writes p to the underlying writer and records the new position. Invalid +// positions are reported by WriteHeader and Close. +func (aw *Writer) Write(p []byte) (n int, err error) { + n, err = aw.w.Write(p) + aw.n += uint64(n) + return +} + +// Close concludes writing to the archive stream. +func (aw *Writer) Close() (err error) { + err = aw.flush() + aw.w = nil + return +} + +// ErrInsecurePath is returned by [FlatEntry.Decode] if validation is requested +// and a nonlocal path is encountered in the stream. +var ErrInsecurePath = errors.New("insecure file path") + +// Reader implements sequential reading of an archive. [Reader.Next] advances to +// the next file in the archive (including the first), and then Reader can be +// treated as an [io.Reader] to access the file's data. +type Reader struct { + // Underlying reader. + r io.Reader + // Fixed-size header segment. + buf [wordSize * 2]byte + // Remaining bytes in current data segment. + n, pad uint64 +} + +// NewReader returns the address of a new [Reader] reading from r. +func NewReader(r io.Reader) *Reader { return &Reader{r: r} } + +// Next advances ar to the next entry. Remaining bytes of the current data +// segment are discarded. Advancing beyond the final entry returns [io.EOF]. +func (ar *Reader) Next() (*ArchiveHeader, error) { + if dsz := int64(ar.n + ar.pad); dsz > 0 { + if n, err := io.CopyN(io.Discard, ar.r, dsz); err != nil { + if errors.Is(err, io.EOF) && n != dsz { + err = io.ErrUnexpectedEOF + } + return nil, err + } + } + + if _, err := io.ReadFull(ar.r, ar.buf[:]); err != nil { + return nil, err + } + + h := ArchiveHeader{ + Mode: fs.FileMode(binary.LittleEndian.Uint32(ar.buf[:])), + Size: binary.LittleEndian.Uint64(ar.buf[wordSize:]), + } + pathSize := int(binary.LittleEndian.Uint32(ar.buf[wordSize/2:])) + pPathSize := alignSize(pathSize) + + buf := make([]byte, pPathSize) + if _, err := io.ReadFull(ar.r, buf); err != nil { + if errors.Is(err, io.EOF) { + err = io.ErrUnexpectedEOF + } + return nil, err + } + + h.Path = unsafe.String(unsafe.SliceData(buf), pathSize) + if !filepath.IsLocal(h.Path) { + return &h, ErrInsecurePath + } + + ar.n = h.Size + ar.pad = padSize(h.Size) + return &h, nil +} + +// Read implements [io.Reader] for the data segment of the current entry. +func (ar *Reader) Read(p []byte) (n int, err error) { + if uint64(len(p)) > ar.n { + p = p[:ar.n] + } + + if len(p) > 0 { + n, err = ar.r.Read(p) + ar.n -= uint64(n) + } + + switch err { + case io.EOF: + if ar.n > 0 { + return n, io.ErrUnexpectedEOF + } + + case nil: + if ar.n == 0 { + return n, io.EOF + } + } + return +} + +// Write writes a deterministic representation of the contents of fsys to w. +// The resulting data can be hashed to produce a deterministic checksum for the +// directory. +func Write(fsys fs.FS, root string, w io.Writer) error { + aw := NewWriter(w) + if err := fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + var fi fs.FileInfo + fi, err = d.Info() + if err != nil { + return err + } + + h := ArchiveHeader{ + Path: path, + Mode: fi.Mode(), + } + if h.Mode.IsRegular() { + h.Size = uint64(fi.Size()) + if err = aw.WriteHeader(&h); err != nil { + return err + } + + var r fs.File + r, err = fsys.Open(path) + if err != nil { + return err + } + _, err = io.Copy(aw, r) + if _err := r.Close(); err == nil { + err = _err + } + return err + } else if h.Mode&fs.ModeSymlink != 0 { + var newpath string + if newpath, err = fs.ReadLink(fsys, path); err != nil { + return err + } + + h.Size = uint64(len(newpath)) + if err = aw.WriteHeader(&h); err != nil { + return err + } + + _, err = aw.Write(unsafe.Slice(unsafe.StringData(newpath), len(newpath))) + return err + } else if !h.Mode.IsDir() { + return InvalidFileModeError(h.Mode) + } + return aw.WriteHeader(&h) + }); err != nil { + return err + } + return aw.Close() +} + +// SumFS saves checksum of the archive of fsys to the value pointed to by buf. +func SumFS(buf *Checksum, fsys fs.FS, root string) error { + h := sha512.New384() + if err := Write(fsys, root, h); err != nil { + return err + } + h.Sum(buf[:0]) + return nil +} + +// SumDir saves checksum of the archive of directory at pathname to the value +// pointed to by buf. +func SumDir(buf *Checksum, pathname *check.Absolute) error { + return SumFS(buf, os.DirFS(pathname.String()), ".") +} + +// archiveArtifact is an [Artifact] unpacking an archive supported by [Reader] +// backed by a [FileArtifact]. +type archiveArtifact struct { + // Caller-supplied backing archive. + f Artifact +} + +var _ CuresExempt = archiveArtifact{} + +// NewArchive returns a new [Artifact] backed by the supplied [Artifact]. The +// source [Artifact] must be a [FileArtifact] and produce a stream compatible +// with [Reader]. +func NewArchive(a Artifact) Artifact { + return archiveArtifact{a} +} + +// Kind returns the hardcoded [Kind] constant. +func (archiveArtifact) Kind() Kind { return KindArchive } + +// Params is a noop. +func (archiveArtifact) Params(*IContext) {} + +func init() { + register(KindArchive, func(r *IRReader) Artifact { + a := NewArchive(r.Next()) + if _, ok := r.Finalise(); ok { + panic(ErrUnexpectedChecksum) + } + return a + }) +} + +// Inputs returns a slice containing the backing file. +func (a archiveArtifact) Inputs() []Artifact { + return []Artifact{a.f} +} + +// IsExclusive returns false: [Reader] is fully sequential. +func (archiveArtifact) IsExclusive() bool { return false } + +// Cure cures the [Artifact], producing a directory located at work. +func (a archiveArtifact) Cure(t *TContext) (err error) { + var r io.ReadCloser + if r, err = t.Open(a.f); err != nil { + return + } + + defer func() { + closeErr := r.Close() + if err == nil { + err = closeErr + } + }() + + type dirTargetPerm struct { + path string + mode fs.FileMode + } + var madeDirectories []dirTargetPerm + + if err = os.MkdirAll(t.GetWorkDir().String(), 0700); err != nil { + return + } + var root *os.Root + if root, err = os.OpenRoot(t.GetWorkDir().String()); err != nil { + return + } + defer func() { + closeErr := root.Close() + if err == nil { + err = closeErr + } + }() + + var header *ArchiveHeader + ar := NewReader(r) + for header, err = ar.Next(); err == nil; header, err = ar.Next() { + if header.Mode.IsRegular() { + var f *os.File + if f, err = root.OpenFile( + header.Path, + os.O_CREATE|os.O_EXCL|os.O_WRONLY, + header.Mode.Perm(), + ); err != nil { + return + } + if _, err = io.Copy(f, ar); err != nil { + _ = f.Close() + return + } else if err = f.Close(); err != nil { + return + } + } else if header.Mode&fs.ModeSymlink != 0 { + var p []byte + if p, err = io.ReadAll(ar); err != nil { + return + } + + if err = root.Symlink( + unsafe.String(unsafe.SliceData(p), len(p)), + header.Path, + ); err != nil { + return + } + } else if header.Mode.IsDir() { + if header.Path == "." { + continue + } + + madeDirectories = append(madeDirectories, dirTargetPerm{ + path: header.Path, + mode: header.Mode, + }) + if err = root.Mkdir(header.Path, 0700); err != nil { + return + } + } else { + return InvalidFileModeError(header.Mode) + } + } + if errors.Is(err, io.EOF) { + err = nil + } + if err == nil { + for _, e := range madeDirectories { + if err = root.Chmod(e.path, e.mode.Perm()); err != nil { + return + } + } + } else { + return + } + return +} + +// CuresExempt exempts the cheap [KindArchive] implementation often found at +// the end of a [FileArtifact] pipeline. +func (archiveArtifact) CuresExempt() {} |
