// Package local provides a local filesystem implementation of the io.Medium interface. package local import ( "errors" "os" "path/filepath" "strings" ) // Medium is a local filesystem storage backend. type Medium struct { root string } // New creates a new local Medium with the specified root directory. // The root directory will be created if it doesn't exist. func New(root string) (*Medium, error) { // Ensure root is an absolute path absRoot, err := filepath.Abs(root) if err != nil { return nil, err } // Create root directory if it doesn't exist if err := os.MkdirAll(absRoot, 0755); err != nil { return nil, err } return &Medium{root: absRoot}, nil } // path sanitizes and joins the relative path with the root directory. // Returns an error if a path traversal attempt is detected. // Uses filepath.EvalSymlinks to prevent symlink-based bypass attacks. func (m *Medium) path(relativePath string) (string, error) { // Clean the path to remove any .. or . components cleanPath := filepath.Clean(relativePath) // Check for path traversal attempts in the raw path if strings.HasPrefix(cleanPath, "..") || strings.Contains(cleanPath, string(filepath.Separator)+"..") { return "", errors.New("path traversal attempt detected") } // Reject absolute paths - they bypass the sandbox if filepath.IsAbs(cleanPath) { return "", errors.New("path traversal attempt detected") } fullPath := filepath.Join(m.root, cleanPath) // Verify the resulting path is still within root (boundary-aware check) // Must use separator to prevent /tmp/root matching /tmp/root2 rootWithSep := m.root if !strings.HasSuffix(rootWithSep, string(filepath.Separator)) { rootWithSep += string(filepath.Separator) } if fullPath != m.root && !strings.HasPrefix(fullPath, rootWithSep) { return "", errors.New("path traversal attempt detected") } // Resolve symlinks to prevent bypass attacks // We need to resolve both the root and full path to handle symlinked roots resolvedRoot, err := filepath.EvalSymlinks(m.root) if err != nil { return "", err } // Build boundary-aware prefix for resolved root resolvedRootWithSep := resolvedRoot if !strings.HasSuffix(resolvedRootWithSep, string(filepath.Separator)) { resolvedRootWithSep += string(filepath.Separator) } // For the full path, resolve as much as exists // Use Lstat first to check if the path exists if _, err := os.Lstat(fullPath); err == nil { resolvedPath, err := filepath.EvalSymlinks(fullPath) if err != nil { return "", err } // Verify resolved path is still within resolved root (boundary-aware) if resolvedPath != resolvedRoot && !strings.HasPrefix(resolvedPath, resolvedRootWithSep) { return "", errors.New("path traversal attempt detected via symlink") } return resolvedPath, nil } // Path doesn't exist yet - verify parent directory parentDir := filepath.Dir(fullPath) if _, err := os.Lstat(parentDir); err == nil { resolvedParent, err := filepath.EvalSymlinks(parentDir) if err != nil { return "", err } if resolvedParent != resolvedRoot && !strings.HasPrefix(resolvedParent, resolvedRootWithSep) { return "", errors.New("path traversal attempt detected via symlink") } } return fullPath, nil } // Read retrieves the content of a file as a string. func (m *Medium) Read(relativePath string) (string, error) { fullPath, err := m.path(relativePath) if err != nil { return "", err } content, err := os.ReadFile(fullPath) if err != nil { return "", err } return string(content), nil } // Write saves the given content to a file, overwriting it if it exists. // Parent directories are created automatically. func (m *Medium) Write(relativePath, content string) error { fullPath, err := m.path(relativePath) if err != nil { return err } // Ensure parent directory exists parentDir := filepath.Dir(fullPath) if err := os.MkdirAll(parentDir, 0755); err != nil { return err } return os.WriteFile(fullPath, []byte(content), 0644) } // EnsureDir makes sure a directory exists, creating it if necessary. func (m *Medium) EnsureDir(relativePath string) error { fullPath, err := m.path(relativePath) if err != nil { return err } return os.MkdirAll(fullPath, 0755) } // IsFile checks if a path exists and is a regular file. func (m *Medium) IsFile(relativePath string) bool { fullPath, err := m.path(relativePath) if err != nil { return false } info, err := os.Stat(fullPath) if err != nil { return false } return info.Mode().IsRegular() } // FileGet is a convenience function that reads a file from the medium. func (m *Medium) FileGet(relativePath string) (string, error) { return m.Read(relativePath) } // FileSet is a convenience function that writes a file to the medium. func (m *Medium) FileSet(relativePath, content string) error { return m.Write(relativePath, content) }