github.com/google/osv-scalibr@v0.4.1/artifact/image/symlink/symlink.go (about)

     1  // Copyright 2025 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package symlink provides symlink-related util functions for container extraction.
    16  package symlink
    17  
    18  import (
    19  	"fmt"
    20  	"io/fs"
    21  	"os"
    22  	"path/filepath"
    23  	"strings"
    24  
    25  	"github.com/google/osv-scalibr/log"
    26  	"github.com/google/uuid"
    27  )
    28  
    29  // RemoveObsoleteSymlinks removes symlinks that point to a destination file or directory path that
    30  // does not exist. Note: There are three terms used in this function: symlink, target link, and
    31  // destination file.
    32  //
    33  //	symlink: Refers to the symlink file itself.
    34  //	target link: The link stored in a symlink file that points to another file (or symlink).
    35  //	destination file: The last file pointed to by a symlink. That is, if there is a chain of
    36  //	  symlinks, the destination file is the file pointed to by the last symlink.
    37  //
    38  // Example: In this file system, the symlink, sym3, points to a destination file that doesn't exist
    39  //
    40  //	       (b.txt). This function would remove the sym3.txt file.
    41  //	root
    42  //	  dir1
    43  //	    a.txt
    44  //	    sym1.txt -> ../dir2/sym2.txt
    45  //	  dir2
    46  //	    sym2.txt -> ../dir1/a.txt
    47  //	    sym3.txt -> ../dir1/b.txt (would be removed since b.txt does not exist)
    48  func RemoveObsoleteSymlinks(root string) error {
    49  	err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
    50  		if err != nil {
    51  			log.Warnf("Failed to walk directory %q: %v", path, err)
    52  			return fmt.Errorf("failed to walk directory %q: %w", path, err)
    53  		}
    54  
    55  		if (d.Type() & fs.ModeType) != fs.ModeSymlink {
    56  			return nil
    57  		}
    58  
    59  		// Gets the target link from the symlink file.
    60  		linkTarget, err := os.Readlink(path)
    61  		if err != nil {
    62  			log.Warnf("Failed to read target of symlink %q: %v", path, err)
    63  			return fmt.Errorf("failed to read target of symlink %q: %w", path, err)
    64  		}
    65  
    66  		// Relative symlinks must be resolved in order to determine if the destination exists.
    67  		if !filepath.IsAbs(linkTarget) {
    68  			linkTarget = filepath.Join(filepath.Dir(path), linkTarget)
    69  		}
    70  
    71  		// The destination exists, so we can return and go on to the next path.
    72  		if _, err = os.Stat(linkTarget); err == nil {
    73  			return nil
    74  		}
    75  
    76  		// Destination doesn't exist so remove symlink file.
    77  		err = os.Remove(path)
    78  		if err != nil {
    79  			log.Warnf("Failed to remove symlink %q: %v", path, err)
    80  			return err
    81  		}
    82  		log.Infof("Removed symlink %q", path)
    83  		return nil
    84  	})
    85  	return err
    86  }
    87  
    88  // ResolveInterLayerSymlinks resolves absolute and relative symlinks in a layer sub-directory with
    89  // a given layer digest by redirecting the symlink target path to point to the SQUASHED layer's
    90  // symlink target path if it exists.
    91  //
    92  // The structure of the layered directory before resolving symlinks is as follows:
    93  //
    94  //	root
    95  //	  layer1digest
    96  //	    dir1
    97  //	      sample.txt
    98  //	  layer2digest
    99  //	    dir2
   100  //	      relative-symlink.txt -> ../sample.txt    (notice how ../sample.txt wouldn't be found due to the layering approach)
   101  //	      absolute-symlink.txt -> /dir1/sample.txt (the /dir1/sample.txt target file also wouldn't be found)
   102  //	  SQUASHED
   103  //	    dir1
   104  //	      sample.txt
   105  //	    dir2
   106  //	      relative-symlink.txt -> /root/SQUASHED/dir1/sample.txt
   107  //	      absolute-symlink.txt -> /root/SQUASHED/dir1/sample.txt
   108  //
   109  // After resolving the layer with layer digest of "layer2digest", the file system is as follows:
   110  //
   111  //	root
   112  //	  layer1digest
   113  //	    dir1
   114  //	      sample.txt
   115  //	  layer2digest
   116  //	    dir2
   117  //	      relative-symlink.txt -> /root/SQUASHED/dir2/relative-symlink.txt
   118  //	      absolute-symlink.txt -> /root/SQUASHED/dir2/relative-symlink.txt
   119  //	  SQUASHED
   120  //	    dir1
   121  //	      sample.txt
   122  //	    dir2
   123  //	      relative-symlink.txt -> /root/SQUASHED/dir1/sample.txt
   124  //	      absolute-symlink.txt -> /root/SQUASHED/dir1/sample.txt
   125  func ResolveInterLayerSymlinks(root, layerDigest, squashedImageDirectory string) error {
   126  	layerPath := filepath.Join(root, strings.ReplaceAll(layerDigest, ":", "-"))
   127  
   128  	// Walk through each symlink in the layer, convert to absolute symlink, then resolve cross layer
   129  	// symlinks.
   130  	err := filepath.WalkDir(layerPath, func(path string, d fs.DirEntry, err error) error {
   131  		if err != nil {
   132  			log.Warnf("Failed to walk directory %q: %v", path, err)
   133  			return fmt.Errorf("failed to walk directory %q: %w", path, err)
   134  		}
   135  
   136  		// Skip anything that isn't a symlink.
   137  		if (d.Type() & fs.ModeType) != fs.ModeSymlink {
   138  			return nil
   139  		}
   140  
   141  		if err = resolveSingleSymlink(root, path, layerPath, squashedImageDirectory); err != nil {
   142  			return fmt.Errorf("failed to resolve symlink %q: %w", path, err)
   143  		}
   144  		return nil
   145  	})
   146  	if err != nil {
   147  		return fmt.Errorf("failed to walk directory %q: %w", layerPath, err)
   148  	}
   149  	return nil
   150  }
   151  
   152  // resolveSingleSymlink resolves a single symlink by checking if the target path of the symlink
   153  // exists in the squashed layer. If it does, then the symlink is updated to point to the target path
   154  // in the squashed layer. This relies on the squashed layer having all of its symlinks resolved
   155  // properly.
   156  func resolveSingleSymlink(root, symlink, layerPath, resolvedDirectory string) error {
   157  	targetPath, err := os.Readlink(symlink)
   158  	if err != nil {
   159  		return fmt.Errorf("failed to read symlink %q: %w", symlink, err)
   160  	}
   161  
   162  	if !filepath.IsAbs(targetPath) {
   163  		targetPath = removeLayerPathPrefix(filepath.Join(filepath.Dir(symlink), targetPath), layerPath)
   164  	} else {
   165  		targetPath = removeLayerPathPrefix(targetPath, layerPath)
   166  	}
   167  
   168  	targetPathInSquashedLayer := filepath.Join(root, resolvedDirectory, targetPath)
   169  
   170  	// Remove the existing symlink.
   171  	if err := os.Remove(symlink); err != nil && !os.IsNotExist(err) {
   172  		log.Warnf("Failed to remove symlink %q: %v", symlink, err)
   173  		return fmt.Errorf("failed to remove symlink %q: %w", symlink, err)
   174  	}
   175  
   176  	// If target path does not exist, then squashed layer did not contain the file that the symlink
   177  	// pointed to and will not be included in final file-system for SCALIBR to scan.
   178  	if _, err = os.Stat(targetPathInSquashedLayer); os.IsNotExist(err) {
   179  		return nil
   180  	} else if err != nil {
   181  		return fmt.Errorf("failed to get status of file %q: %w", targetPathInSquashedLayer, err)
   182  	}
   183  
   184  	// Recreate the symlink with the new destination path.
   185  	if err := os.Symlink(targetPathInSquashedLayer, symlink); err != nil {
   186  		log.Warnf("Failed to create symlink %q: %v", symlink, err)
   187  		return fmt.Errorf("failed to create symlink %q: %w", symlink, err)
   188  	}
   189  	return nil
   190  }
   191  
   192  func removeLayerPathPrefix(path, layerPath string) string {
   193  	return filepath.Clean(strings.TrimPrefix(path, layerPath))
   194  }
   195  
   196  // TargetOutsideRoot checks if the target of a symlink points outside of the root directory of that
   197  // symlink's path.
   198  // For example, if a symlink with path `a/symlink.txt“ points to “../../file.text“, then
   199  // this function would return true because the target file is outside of the root directory.
   200  func TargetOutsideRoot(path, target string) bool {
   201  	// Create a marker directory as root to check if the target path is outside of the root directory.
   202  	markerDir := uuid.New().String()
   203  	if filepath.IsAbs(target) {
   204  		// Absolute paths may still point outside of the root directory.
   205  		// e.g. "/../file.txt"
   206  		markerTarget := filepath.Join(markerDir, target)
   207  		return !strings.Contains(markerTarget, markerDir)
   208  	}
   209  
   210  	markerTargetAbs := filepath.Join(markerDir, filepath.Dir(path), target)
   211  	return !strings.Contains(markerTargetAbs, markerDir)
   212  }