go.mondoo.com/cnquery@v0.0.0-20231005093811-59568235f6ea/providers/os/connection/local/statutil/stat.go (about)

     1  // Copyright (c) Mondoo, Inc.
     2  // SPDX-License-Identifier: BUSL-1.1
     3  
     4  package statutil
     5  
     6  import (
     7  	"io"
     8  	"io/fs"
     9  	"os"
    10  	"path/filepath"
    11  	"regexp"
    12  	"strconv"
    13  	"strings"
    14  	"syscall"
    15  	"time"
    16  
    17  	"github.com/cockroachdb/errors"
    18  	"github.com/rs/zerolog/log"
    19  	"go.mondoo.com/cnquery/providers/os/connection/shared"
    20  )
    21  
    22  type CommandRunner interface {
    23  	RunCommand(command string) (*shared.Command, error)
    24  }
    25  
    26  var ESCAPEREGEX = regexp.MustCompile(`[^\w@%+=:,./-]`)
    27  
    28  func ShellEscape(s string) string {
    29  	if len(s) == 0 {
    30  		return "''"
    31  	}
    32  	if ESCAPEREGEX.MatchString(s) {
    33  		return "'" + strings.Replace(s, "'", "'\"'\"'", -1) + "'"
    34  	}
    35  
    36  	return s
    37  }
    38  
    39  func New(cmdRunner CommandRunner) *statHelper {
    40  	return &statHelper{
    41  		commandRunner: cmdRunner,
    42  	}
    43  }
    44  
    45  // Stat helper implements the stat command for various unix systems
    46  // since this helper is used by transports itself, we cannot rely on the
    47  // platform detection mechanism (since it may rely on stat to determine the system)
    48  // therefore we implement the minimum required to detect the right stat parser
    49  type statHelper struct {
    50  	commandRunner CommandRunner
    51  	detected      bool
    52  	isUnix        bool
    53  }
    54  
    55  var bsdunix = map[string]bool{
    56  	"openbsd":   true,
    57  	"dragonfly": true,
    58  	"freebsd":   true,
    59  	"netbsd":    true,
    60  	"darwin":    true, // use bsd stat for macOS
    61  }
    62  
    63  func (s *statHelper) Stat(name string) (os.FileInfo, error) {
    64  	// detect stat version
    65  	if !s.detected {
    66  		cmd, err := s.commandRunner.RunCommand("uname -s")
    67  		if err != nil {
    68  			log.Debug().Err(err).Str("file", name).Msg("could not detect platform for file stat")
    69  			return nil, err
    70  		}
    71  
    72  		data, err := io.ReadAll(cmd.Stdout)
    73  		if err != nil {
    74  			return nil, err
    75  		}
    76  
    77  		// only switch to unix if we properly detected it, otherwise fallback to linux
    78  		val := strings.ToLower(strings.TrimSpace(string(data)))
    79  
    80  		isUnix, ok := bsdunix[val]
    81  		if ok && isUnix {
    82  			s.isUnix = true
    83  		}
    84  		s.detected = true
    85  	}
    86  
    87  	if s.isUnix {
    88  		return s.unix(name)
    89  	}
    90  	return s.linux(name)
    91  }
    92  
    93  func (s *statHelper) linux(name string) (os.FileInfo, error) {
    94  	path := ShellEscape(name)
    95  
    96  	// check if file exists
    97  	cmd, err := s.commandRunner.RunCommand("test -e " + path)
    98  	if err != nil || cmd.ExitStatus != 0 {
    99  		return nil, os.ErrNotExist
   100  	}
   101  
   102  	var sb strings.Builder
   103  	sb.WriteString("stat -L ")
   104  	sb.WriteString(path)
   105  	sb.WriteString(" -c '%s.%f.%u.%g.%X.%Y.%C'")
   106  
   107  	// NOTE: handling the exit code here does not work for all cases
   108  	// sometimes stat returns something like: failed to get security context of '/etc/ssh/sshd_config': No data available
   109  	// Therefore we continue after this command and try to parse the result and focus on making the parsing more robust
   110  	command := sb.String()
   111  	cmd, err = s.commandRunner.RunCommand(command)
   112  
   113  	// we get stderr content in cases where we could not gather the security context via failed to get security context of
   114  	// it could also include: No such file or directory
   115  	if err != nil {
   116  		log.Debug().Str("path", path).Str("command", command).Err(err).Send()
   117  	}
   118  
   119  	if cmd == nil {
   120  		return nil, errors.New("could not parse file stat: " + path)
   121  	}
   122  
   123  	data, err := io.ReadAll(cmd.Stdout)
   124  	if err != nil {
   125  		return nil, err
   126  	}
   127  
   128  	statsData := strings.Split(strings.TrimSpace(string(data)), ".")
   129  	if len(statsData) != 7 {
   130  		log.Debug().Str("path", path).Msg("could not parse file stat information")
   131  		// TODO: we may need to parse the returning error to better distinguish between a real error and file not found
   132  		// if we are going to check for file not found, we probably run into the issue that the error message is returned in
   133  		// multiple languages
   134  		return nil, errors.New("could not parse file stat: " + path)
   135  	}
   136  
   137  	// Note: The SElinux context may not be supported by stats on all OSs.
   138  	// For example: Alpine does not support it, resulting in statsData[6] == "C"
   139  
   140  	size, err := strconv.Atoi(statsData[0])
   141  	if err != nil {
   142  		return nil, errors.Wrap(err, "could not stat "+name)
   143  	}
   144  
   145  	uid, err := strconv.ParseInt(statsData[2], 10, 64)
   146  	if err != nil {
   147  		return nil, errors.Wrap(err, "could not stat "+name)
   148  	}
   149  
   150  	gid, err := strconv.ParseInt(statsData[3], 10, 64)
   151  	if err != nil {
   152  		return nil, errors.Wrap(err, "could not stat "+name)
   153  	}
   154  
   155  	mask, err := strconv.ParseUint(statsData[1], 16, 32)
   156  	if err != nil {
   157  		return nil, errors.Wrap(err, "could not stat "+name)
   158  	}
   159  
   160  	mtime, err := strconv.ParseInt(statsData[4], 10, 64)
   161  	if err != nil {
   162  		return nil, errors.Wrap(err, "could not stat "+name)
   163  	}
   164  
   165  	// extract file modes
   166  	mapMode := toFileMode(mask)
   167  
   168  	return &shared.FileInfo{
   169  		FName:    filepath.Base(path),
   170  		FSize:    int64(size),
   171  		FMode:    mapMode,
   172  		FIsDir:   mapMode.IsDir(),
   173  		FModTime: time.Unix(mtime, 0),
   174  		Uid:      uid,
   175  		Gid:      gid,
   176  	}, nil
   177  }
   178  
   179  func (s *statHelper) unix(name string) (os.FileInfo, error) {
   180  	lstat := "-L"
   181  	format := "-f"
   182  	path := ShellEscape(name)
   183  
   184  	var sb strings.Builder
   185  	sb.WriteString("stat ")
   186  	sb.WriteString(lstat)
   187  	sb.WriteString(" ")
   188  	sb.WriteString(format)
   189  	sb.WriteString(" '%z:%p:%u:%g:%a:%m'")
   190  	sb.WriteString(" ")
   191  	sb.WriteString(path)
   192  
   193  	cmd, err := s.commandRunner.RunCommand(sb.String())
   194  	if err != nil {
   195  		log.Debug().Err(err).Send()
   196  	}
   197  
   198  	data, err := io.ReadAll(cmd.Stdout)
   199  	if err != nil {
   200  		return nil, err
   201  	}
   202  
   203  	statsData := strings.Split(string(data), ":")
   204  	if len(statsData) != 6 {
   205  		log.Error().Str("name", name).Msg("could not parse file stat information")
   206  		// TODO: there are likely cases where the file exist but we could still not parse it
   207  		return nil, os.ErrNotExist
   208  	}
   209  
   210  	size, err := strconv.Atoi(statsData[0])
   211  	if err != nil {
   212  		return nil, errors.Wrap(err, "could not stat "+name)
   213  	}
   214  
   215  	uid, err := strconv.ParseInt(statsData[2], 10, 64)
   216  	if err != nil {
   217  		return nil, errors.Wrap(err, "could not stat "+name)
   218  	}
   219  
   220  	gid, err := strconv.ParseInt(statsData[3], 10, 64)
   221  	if err != nil {
   222  		return nil, errors.Wrap(err, "could not stat "+name)
   223  	}
   224  
   225  	// NOTE: the base is 8 instead of 16 on linux systems
   226  	mask, err := strconv.ParseUint(statsData[1], 8, 32)
   227  	if err != nil {
   228  		return nil, errors.Wrap(err, "could not stat "+name)
   229  	}
   230  
   231  	mode := toFileMode(mask)
   232  
   233  	mtime, err := strconv.ParseInt(statsData[4], 10, 64)
   234  	if err != nil {
   235  		return nil, errors.Wrap(err, "could not stat "+name)
   236  	}
   237  
   238  	return &shared.FileInfo{
   239  		FName:    filepath.Base(path),
   240  		FSize:    int64(size),
   241  		FMode:    mode,
   242  		FIsDir:   mode.IsDir(),
   243  		FModTime: time.Unix(mtime, 0),
   244  		Uid:      uid,
   245  		Gid:      gid,
   246  	}, nil
   247  }
   248  
   249  const (
   250  	S_IFMT  = 0o170000
   251  	S_IFBLK = 0o60000
   252  	S_IFCHR = 0o20000
   253  	S_IFDIR = 0o40000
   254  	S_IFIFO = 10000
   255  	S_ISUID = 0o4000
   256  	S_ISGID = 0o2000
   257  	S_ISVTX = 0o1000
   258  )
   259  
   260  func toFileMode(mask uint64) os.FileMode {
   261  	mode := os.FileMode(uint32(mask) & 0o0777)
   262  
   263  	// taken from https://github.com/golang/go/blob/2ebe77a2fda1ee9ff6fd9a3e08933ad1ebaea039/src/os/stat_linux.go
   264  	switch mask & S_IFMT {
   265  	case S_IFBLK:
   266  		mode |= fs.ModeDevice
   267  	case S_IFCHR:
   268  		mode |= fs.ModeDevice | fs.ModeCharDevice
   269  	case S_IFDIR:
   270  		mode |= fs.ModeDir
   271  	case S_IFIFO:
   272  		mode |= fs.ModeNamedPipe
   273  	case syscall.S_IFLNK:
   274  		mode |= fs.ModeSymlink
   275  	case syscall.S_IFREG:
   276  		// nothing to do
   277  	case syscall.S_IFSOCK:
   278  		mode |= fs.ModeSocket
   279  	}
   280  	if mask&syscall.S_ISGID != 0 {
   281  		mode |= fs.ModeSetgid
   282  	}
   283  	if mask&syscall.S_ISUID != 0 {
   284  		mode |= fs.ModeSetuid
   285  	}
   286  	if mask&syscall.S_ISVTX != 0 {
   287  		mode |= fs.ModeSticky
   288  	}
   289  	return mode
   290  }