Full Mattermost server source with integrated Community Enterprise features. Includes vendor directory for offline/air-gapped builds. Structure: - enterprise-impl/: Enterprise feature implementations - enterprise-community/: Init files that register implementations - enterprise/: Bridge imports (community_imports.go) - vendor/: All dependencies for offline builds Build (online): go build ./cmd/mattermost Build (offline/air-gapped): go build -mod=vendor ./cmd/mattermost 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
378 lines
12 KiB
Go
378 lines
12 KiB
Go
package archives
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// FileInfo is a virtualized, generalized file abstraction for interacting with archives.
|
|
type FileInfo struct {
|
|
fs.FileInfo
|
|
|
|
// The file header as used/provided by the archive format.
|
|
// Typically, you do not need to set this field when creating
|
|
// an archive.
|
|
Header any
|
|
|
|
// The path of the file as it appears in the archive.
|
|
// This is equivalent to Header.Name (for most Header
|
|
// types). We require it to be specified here because
|
|
// it is such a common field and we want to preserve
|
|
// format-agnosticism (no type assertions) for basic
|
|
// operations.
|
|
//
|
|
// When extracting, this name or path may not have
|
|
// been sanitized; it should not be trusted at face
|
|
// value. Consider using path.Clean() before using.
|
|
//
|
|
// If this is blank when inserting a file into an
|
|
// archive, the filename's base may be assumed
|
|
// by default to be the name in the archive.
|
|
NameInArchive string
|
|
|
|
// For symbolic and hard links, the target of the link.
|
|
// Not supported by all archive formats.
|
|
LinkTarget string
|
|
|
|
// A callback function that opens the file to read its
|
|
// contents. The file must be closed when reading is
|
|
// complete.
|
|
Open func() (fs.File, error)
|
|
}
|
|
|
|
func (f FileInfo) Stat() (fs.FileInfo, error) { return f.FileInfo, nil }
|
|
|
|
// FilesFromDisk is an opinionated function that returns a list of FileInfos
|
|
// by walking the directories in the filenames map. The keys are the names on
|
|
// disk, and the values become their associated names in the archive.
|
|
//
|
|
// Map keys that specify directories on disk will be walked and added to the
|
|
// archive recursively, rooted at the named directory. They should use the
|
|
// platform's path separator (backslash on Windows; slash on everything else).
|
|
// For convenience, map keys that end in a separator ('/', or '\' on Windows)
|
|
// will enumerate contents only, without adding the folder itself to the archive.
|
|
//
|
|
// Map values should typically use slash ('/') as the separator regardless of
|
|
// the platform, as most archive formats standardize on that rune as the
|
|
// directory separator for filenames within an archive. For convenience, map
|
|
// values that are empty string are interpreted as the base name of the file
|
|
// (sans path) in the root of the archive; and map values that end in a slash
|
|
// will use the base name of the file in that folder of the archive.
|
|
//
|
|
// File gathering will adhere to the settings specified in options.
|
|
//
|
|
// This function is used primarily when preparing a list of files to add to
|
|
// an archive.
|
|
func FilesFromDisk(ctx context.Context, options *FromDiskOptions, filenames map[string]string) ([]FileInfo, error) {
|
|
var files []FileInfo
|
|
for rootOnDisk, rootInArchive := range filenames {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
walkErr := filepath.WalkDir(rootOnDisk, func(filename string, d fs.DirEntry, err error) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
nameInArchive := nameOnDiskToNameInArchive(filename, rootOnDisk, rootInArchive)
|
|
// this is the root folder and we are adding its contents to target rootInArchive
|
|
if info.IsDir() && nameInArchive == "" {
|
|
return nil
|
|
}
|
|
|
|
// handle symbolic links
|
|
var linkTarget string
|
|
if isSymlink(info) {
|
|
if options != nil && options.FollowSymlinks {
|
|
originalFilename := filename
|
|
filename, info, err = followSymlink(filename)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.IsDir() {
|
|
symlinkDirFiles, err := FilesFromDisk(ctx, options, map[string]string{filename: nameInArchive})
|
|
if err != nil {
|
|
return fmt.Errorf("getting files from symlink directory %s dereferenced to %s: %w", originalFilename, linkTarget, err)
|
|
}
|
|
|
|
files = append(files, symlinkDirFiles...)
|
|
return nil
|
|
}
|
|
} else {
|
|
// preserve symlinks
|
|
linkTarget, err = os.Readlink(filename)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: readlink: %w", filename, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handle file attributes
|
|
if options != nil && options.ClearAttributes {
|
|
info = noAttrFileInfo{info}
|
|
}
|
|
|
|
file := FileInfo{
|
|
FileInfo: info,
|
|
NameInArchive: nameInArchive,
|
|
LinkTarget: linkTarget,
|
|
Open: func() (fs.File, error) {
|
|
return os.Open(filename)
|
|
},
|
|
}
|
|
|
|
files = append(files, file)
|
|
|
|
return nil
|
|
})
|
|
if walkErr != nil {
|
|
return nil, walkErr
|
|
}
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
// nameOnDiskToNameInArchive converts a filename from disk to a name in an archive,
|
|
// respecting rules defined by FilesFromDisk. nameOnDisk is the full filename on disk
|
|
// which is expected to be prefixed by rootOnDisk (according to fs.WalkDirFunc godoc)
|
|
// and which will be placed into a folder rootInArchive in the archive.
|
|
func nameOnDiskToNameInArchive(nameOnDisk, rootOnDisk, rootInArchive string) string {
|
|
// These manipulations of rootInArchive could be done just once instead of on
|
|
// every walked file since they don't rely on nameOnDisk which is the only
|
|
// variable that changes during the walk, but combining all the logic into this
|
|
// one function is easier to reason about and test. I suspect the performance
|
|
// penalty is insignificant.
|
|
if strings.HasSuffix(rootOnDisk, string(filepath.Separator)) {
|
|
// "map keys that end in a separator will enumerate contents only,
|
|
// without adding the folder itself to the archive."
|
|
rootInArchive = trimTopDir(rootInArchive)
|
|
} else if rootInArchive == "" {
|
|
// "map values that are empty string are interpreted as the base name
|
|
// of the file (sans path) in the root of the archive"
|
|
rootInArchive = filepath.Base(rootOnDisk)
|
|
}
|
|
if rootInArchive == "." {
|
|
// an in-archive root of "." is an escape hatch for the above rule
|
|
// where an empty in-archive root means to use the base name of the
|
|
// file; if the user does not want this, they can specify a "." to
|
|
// still put it in the root of the archive
|
|
rootInArchive = ""
|
|
}
|
|
if strings.HasSuffix(rootInArchive, "/") {
|
|
// "map values that end in a slash will use the base name of the file in
|
|
// that folder of the archive."
|
|
rootInArchive += filepath.Base(rootOnDisk)
|
|
}
|
|
truncPath := strings.TrimPrefix(nameOnDisk, rootOnDisk)
|
|
return path.Join(rootInArchive, filepath.ToSlash(truncPath))
|
|
}
|
|
|
|
// trimTopDir strips the top or first directory from the path.
|
|
// It expects a forward-slashed path.
|
|
//
|
|
// Examples: "a/b/c" => "b/c", "/a/b/c" => "b/c"
|
|
func trimTopDir(dir string) string {
|
|
return strings.TrimPrefix(dir, topDir(dir)+"/")
|
|
}
|
|
|
|
// topDir returns the top or first directory in the path.
|
|
// It expects a forward-slashed path.
|
|
//
|
|
// Examples: "a/b/c" => "a", "/a/b/c" => "/a"
|
|
func topDir(dir string) string {
|
|
var start int
|
|
if len(dir) > 0 && dir[0] == '/' {
|
|
start = 1
|
|
}
|
|
if pos := strings.Index(dir[start:], "/"); pos >= 0 {
|
|
return dir[:pos+start]
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// noAttrFileInfo is used to zero out some file attributes (issue #280).
|
|
type noAttrFileInfo struct{ fs.FileInfo }
|
|
|
|
// Mode preserves only the type and permission bits.
|
|
func (no noAttrFileInfo) Mode() fs.FileMode {
|
|
return no.FileInfo.Mode() & (fs.ModeType | fs.ModePerm)
|
|
}
|
|
func (noAttrFileInfo) ModTime() time.Time { return time.Time{} }
|
|
func (noAttrFileInfo) Sys() any { return nil }
|
|
|
|
// FromDiskOptions specifies various options for gathering files from disk.
|
|
type FromDiskOptions struct {
|
|
// If true, symbolic links will be dereferenced, meaning that
|
|
// the link will not be added as a link, but what the link
|
|
// points to will be added as a file.
|
|
FollowSymlinks bool
|
|
|
|
// If true, some file attributes will not be preserved.
|
|
// Name, size, type, and permissions will still be preserved.
|
|
ClearAttributes bool
|
|
}
|
|
|
|
// FileHandler is a callback function that is used to handle files as they are read
|
|
// from an archive; it is kind of like fs.WalkDirFunc. Handler functions that open
|
|
// their files must not overlap or run concurrently, as files may be read from the
|
|
// same sequential stream; always close the file before returning.
|
|
//
|
|
// If the special error value fs.SkipDir is returned, the directory of the file
|
|
// (or the file itself if it is a directory) will not be walked. Note that because
|
|
// archive contents are not necessarily ordered, skipping directories requires
|
|
// memory, and skipping lots of directories may run up your memory bill.
|
|
//
|
|
// Any other returned error will terminate a walk and be returned to the caller.
|
|
type FileHandler func(ctx context.Context, info FileInfo) error
|
|
|
|
// openAndCopyFile opens file for reading, copies its
|
|
// contents to w, then closes file.
|
|
func openAndCopyFile(file FileInfo, w io.Writer) error {
|
|
fileReader, err := file.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer fileReader.Close()
|
|
// When file is in use and size is being written to, creating the compressed
|
|
// file will fail with "archive/tar: write too long." Using CopyN gracefully
|
|
// handles this.
|
|
_, err = io.CopyN(w, fileReader, file.Size())
|
|
if err != nil && err != io.EOF {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fileIsIncluded returns true if filename is included according to
|
|
// filenameList; meaning it is in the list, its parent folder/path
|
|
// is in the list, or the list is nil.
|
|
func fileIsIncluded(filenameList []string, filename string) bool {
|
|
// include all files if there is no specific list
|
|
if filenameList == nil {
|
|
return true
|
|
}
|
|
for _, fn := range filenameList {
|
|
// exact matches are of course included
|
|
if filename == fn {
|
|
return true
|
|
}
|
|
// also consider the file included if its parent folder/path is in the list
|
|
if strings.HasPrefix(filename, strings.TrimSuffix(fn, "/")+"/") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isSymlink(info fs.FileInfo) bool {
|
|
return info.Mode()&os.ModeSymlink != 0
|
|
}
|
|
|
|
// streamSizeBySeeking determines the size of the stream by
|
|
// seeking to the end, then back again, so the resulting
|
|
// seek position upon returning is the same as when called
|
|
// (assuming no errors).
|
|
func streamSizeBySeeking(s io.Seeker) (int64, error) {
|
|
currentPosition, err := s.Seek(0, io.SeekCurrent)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("getting current offset: %w", err)
|
|
}
|
|
maxPosition, err := s.Seek(0, io.SeekEnd)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("fast-forwarding to end: %w", err)
|
|
}
|
|
_, err = s.Seek(currentPosition, io.SeekStart)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("returning to prior offset %d: %w", currentPosition, err)
|
|
}
|
|
return maxPosition, nil
|
|
}
|
|
|
|
// skipList keeps a list of non-intersecting paths
|
|
// as long as its add method is used. Identical
|
|
// elements are rejected, more specific paths are
|
|
// replaced with broader ones, and more specific
|
|
// paths won't be added when a broader one already
|
|
// exists in the list. Trailing slashes are ignored.
|
|
type skipList []string
|
|
|
|
func (s *skipList) add(dir string) {
|
|
trimmedDir := strings.TrimSuffix(dir, "/")
|
|
var dontAdd bool
|
|
for i := 0; i < len(*s); i++ {
|
|
trimmedElem := strings.TrimSuffix((*s)[i], "/")
|
|
if trimmedDir == trimmedElem {
|
|
return
|
|
}
|
|
// don't add dir if a broader path already exists in the list
|
|
if strings.HasPrefix(trimmedDir, trimmedElem+"/") {
|
|
dontAdd = true
|
|
continue
|
|
}
|
|
// if dir is broader than a path in the list, remove more specific path in list
|
|
if strings.HasPrefix(trimmedElem, trimmedDir+"/") {
|
|
*s = append((*s)[:i], (*s)[i+1:]...)
|
|
i--
|
|
}
|
|
}
|
|
if !dontAdd {
|
|
*s = append(*s, dir)
|
|
}
|
|
}
|
|
|
|
// followSymlink follows a symlink until it finds a non-symlink,
|
|
// returning the target path, file info, and any error that occurs.
|
|
// It also checks for symlink loops and maximum depth.
|
|
func followSymlink(filename string) (string, os.FileInfo, error) {
|
|
visited := make(map[string]bool)
|
|
visited[filename] = true
|
|
// Limit in Linux kernel: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/namei.c?id=v3.5#n624
|
|
const maxDepth = 40
|
|
|
|
for {
|
|
linkPath, err := os.Readlink(filename)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("%s: readlink: %w", filename, err)
|
|
}
|
|
if !filepath.IsAbs(linkPath) {
|
|
linkPath = filepath.Join(filepath.Dir(filename), linkPath)
|
|
}
|
|
info, err := os.Lstat(linkPath)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("%s: statting dereferenced symlink: %w", filename, err)
|
|
}
|
|
|
|
// Not a symlink, we've found the target, return it
|
|
if info.Mode()&os.ModeSymlink == 0 {
|
|
return linkPath, info, nil
|
|
}
|
|
|
|
if visited[linkPath] {
|
|
return "", nil, fmt.Errorf("%s: symlink loop", filename)
|
|
}
|
|
|
|
if len(visited) >= maxDepth {
|
|
return "", nil, fmt.Errorf("%s: maximum symlink depth (%d) exceeded", filename, maxDepth)
|
|
}
|
|
|
|
visited[linkPath] = true
|
|
filename = linkPath
|
|
}
|
|
}
|