Files
wehub-resource-sync e0e362d700
SDK Tests / SDK CI (push) Blocked by required conditions
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
SDK Tests / Python SDK Tests (sandbox) (push) Waiting to run
SDK Tests / CLI Quality (push) Waiting to run
SDK Tests / CLI Tests (push) Waiting to run
SDK Tests / Python SDK Quality (code-interpreter) (push) Waiting to run
SDK Tests / Python SDK Quality (sandbox) (push) Waiting to run
SDK Tests / Python SDK Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Go SDK Quality And Tests (push) Waiting to run
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:39:33 +08:00

572 lines
15 KiB
Go

// Copyright 2026 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package isolation
import (
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"github.com/alibaba/opensandbox/execd/pkg/vfs"
)
// MergedView provides a userspace overlay filesystem view: reads check upper
// first then fall through to lower; writes always go to upper.
//
// Limitation (overlay mode): MergedView writes to the upper directory on the
// host are NOT visible inside a running bwrap overlayfs mount. The kernel VFS
// caches directory entries at mount time; direct modifications to the upper
// directory bypass the overlay and are invisible to processes inside the
// namespace. Only the Run→API direction works (process writes go through the
// overlay to upper, MergedView reads upper on host). For bidirectional
// file exchange, use workspace mode "rw" instead of "overlay".
// Compile-time check: MergedView satisfies vfs.FS.
var _ vfs.FS = (*MergedView)(nil)
type MergedView struct {
LowerDir string
UpperDir string
Uid, Gid uint32
Mode WorkspaceMode
}
// NewMergedView creates a merged view. upperDir may be empty (tmpfs).
func NewMergedView(lower, upper string, mode WorkspaceMode, uid, gid uint32) *MergedView {
return &MergedView{
LowerDir: lower,
UpperDir: upper,
Uid: uid,
Gid: gid,
Mode: mode,
}
}
// resolveUpper returns the upper path for a relative path.
func (m *MergedView) resolveUpper(rel string) string {
return filepath.Join(m.UpperDir, rel)
}
// resolveLower returns the lower path for a relative path.
func (m *MergedView) resolveLower(rel string) string {
return filepath.Join(m.LowerDir, rel)
}
// safePath validates and returns a path relative to the workspace.
// Absolute paths under LowerDir are stripped to relative; all others
// are cleaned normally.
func (m *MergedView) safePath(path string) (string, error) {
cleaned := filepath.Clean(path)
// Strip workspace prefix from absolute paths so Join works correctly.
if m.LowerDir != "" && strings.HasPrefix(cleaned, m.LowerDir+"/") {
cleaned = strings.TrimPrefix(cleaned, m.LowerDir+"/")
} else if m.LowerDir != "" && cleaned == m.LowerDir {
cleaned = "."
}
if strings.HasPrefix(cleaned, "..") {
return "", fmt.Errorf("path traversal denied: %s", path)
}
return cleaned, nil
}
func (m *MergedView) rejectSymlink(path string) error {
// Check every existing component, not just the final element.
var check string
for _, seg := range strings.Split(filepath.Clean(path), string(filepath.Separator)) {
if seg == "" {
check = string(filepath.Separator)
continue
}
check = filepath.Join(check, seg)
info, err := os.Lstat(check)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("symlink target denied: %s", check)
}
}
return nil
}
// createWhiteout creates a whiteout marker in upper to hide a lower entry.
func (m *MergedView) createWhiteout(rel string) error {
whName := filepath.Join(filepath.Dir(rel), ".wh."+filepath.Base(rel))
whPath := m.resolveUpper(whName)
if err := os.MkdirAll(filepath.Dir(whPath), 0o755); err != nil {
return err
}
f, err := os.Create(whPath)
if err != nil {
return err
}
return f.Close()
}
// hasWhiteout checks if a whiteout marker exists for the given relative path.
func (m *MergedView) hasWhiteout(rel string) bool {
if m.UpperDir == "" {
return false
}
whName := filepath.Join(filepath.Dir(rel), ".wh."+filepath.Base(rel))
_, err := os.Lstat(m.resolveUpper(whName))
return err == nil
}
// Stat returns file info for a path. Checks upper first, then lower.
func (m *MergedView) Stat(path string) (os.FileInfo, error) {
rel, err := m.safePath(path)
if err != nil {
return nil, err
}
if m.UpperDir != "" {
upperPath := m.resolveUpper(rel)
if err := m.rejectSymlink(upperPath); err != nil {
return nil, err
}
if info, err := os.Lstat(upperPath); err == nil {
return info, nil
}
if m.hasWhiteout(rel) {
return nil, &os.PathError{Op: "stat", Path: path, Err: os.ErrNotExist}
}
}
return os.Lstat(m.resolveLower(rel))
}
// ReadDir lists directory contents, merging upper and lower entries.
func (m *MergedView) ReadDir(path string) ([]os.DirEntry, error) {
rel, err := m.safePath(path)
if err != nil {
return nil, err
}
entryMap := make(map[string]os.DirEntry)
// Lower first.
lowerEntries, lowerErr := os.ReadDir(m.resolveLower(rel))
for _, e := range lowerEntries {
entryMap[e.Name()] = e
}
// Upper overlays — takes precedence over lower.
var upperErr error
if m.UpperDir != "" {
var upperEntries []os.DirEntry
upperEntries, upperErr = os.ReadDir(m.resolveUpper(rel))
for _, e := range upperEntries {
if strings.HasPrefix(e.Name(), ".wh.") {
origName := strings.TrimPrefix(e.Name(), ".wh.")
delete(entryMap, origName)
continue
}
entryMap[e.Name()] = e
}
}
if len(entryMap) == 0 && os.IsNotExist(lowerErr) && (m.UpperDir == "" || os.IsNotExist(upperErr)) {
return nil, &os.PathError{Op: "readdir", Path: path, Err: os.ErrNotExist}
}
entries := make([]os.DirEntry, 0, len(entryMap))
for _, e := range entryMap {
entries = append(entries, e)
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
return entries, nil
}
// Open opens a file for reading. Checks upper first, then lower.
func (m *MergedView) Open(path string) (*os.File, error) {
rel, err := m.safePath(path)
if err != nil {
return nil, err
}
if m.UpperDir != "" {
upperPath := m.resolveUpper(rel)
if err := m.rejectSymlink(upperPath); err != nil {
return nil, err
}
if f, err := os.Open(upperPath); err == nil {
return f, nil
}
if m.hasWhiteout(rel) {
return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrNotExist}
}
}
return os.Open(m.resolveLower(rel))
}
// ReadFile reads file content. Checks upper first, then lower.
func (m *MergedView) ReadFile(path string) ([]byte, error) {
rel, err := m.safePath(path)
if err != nil {
return nil, err
}
if m.UpperDir != "" {
upperPath := m.resolveUpper(rel)
if err := m.rejectSymlink(upperPath); err != nil {
return nil, err
}
if data, err := os.ReadFile(upperPath); err == nil {
return data, nil
}
if m.hasWhiteout(rel) {
return nil, &os.PathError{Op: "read", Path: path, Err: os.ErrNotExist}
}
}
return os.ReadFile(m.resolveLower(rel))
}
// WriteFile writes data to upper directory.
// In overlay mode, files written here are visible to subsequent MergedView
// reads but NOT to processes inside the bwrap namespace (see MergedView doc).
func (m *MergedView) WriteFile(path string, data []byte, perm os.FileMode) error {
if m.Mode == WorkspaceRO {
return fmt.Errorf("write denied: workspace is read-only")
}
if m.UpperDir == "" {
return fmt.Errorf("no upper directory")
}
rel, err := m.safePath(path)
if err != nil {
return err
}
upperPath := m.resolveUpper(rel)
if err := m.rejectSymlink(upperPath); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(upperPath), 0o755); err != nil {
return err
}
if err := os.WriteFile(upperPath, data, perm); err != nil {
return err
}
return os.Chown(upperPath, int(m.Uid), int(m.Gid))
}
// WriteFileReader writes from a reader to upper directory.
func (m *MergedView) WriteFileReader(path string, r io.Reader, perm os.FileMode) (int64, error) {
if m.Mode == WorkspaceRO {
return 0, fmt.Errorf("write denied: workspace is read-only")
}
if m.UpperDir == "" {
return 0, fmt.Errorf("no upper directory")
}
rel, err := m.safePath(path)
if err != nil {
return 0, err
}
upperPath := m.resolveUpper(rel)
if err := m.rejectSymlink(upperPath); err != nil {
return 0, err
}
if err := os.MkdirAll(filepath.Dir(upperPath), 0o755); err != nil {
return 0, err
}
f, err := os.OpenFile(upperPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return 0, err
}
defer f.Close()
n, err := io.Copy(f, r)
if err != nil {
return n, err
}
return n, os.Chown(upperPath, int(m.Uid), int(m.Gid))
}
// Remove deletes a file. For overlay mode, creates a whiteout to hide
// lower-only files.
func (m *MergedView) Remove(path string) error {
if m.Mode == WorkspaceRO {
return fmt.Errorf("remove denied: workspace is read-only")
}
rel, err := m.safePath(path)
if err != nil {
return err
}
if m.UpperDir != "" {
upperPath := m.resolveUpper(rel)
if _, err := os.Stat(upperPath); err == nil {
if err := os.Remove(upperPath); err != nil {
return err
}
// If it also exists in lower, create whiteout to hide it.
if _, err := os.Stat(m.resolveLower(rel)); err == nil {
return m.createWhiteout(rel)
}
return nil
}
}
// File exists only in lower — create whiteout to mask it.
lowerPath := m.resolveLower(rel)
if _, err := os.Stat(lowerPath); err == nil {
if m.UpperDir == "" {
return fmt.Errorf("remove denied: no upper directory")
}
return m.createWhiteout(rel)
}
return fs.ErrNotExist
}
// RemoveAll deletes a path recursively. In overlay mode, removes the upper
// tree and creates a whiteout to hide any lower entry at the same path.
func (m *MergedView) RemoveAll(path string) error {
if m.Mode == WorkspaceRO {
return fmt.Errorf("remove denied: workspace is read-only")
}
rel, err := m.safePath(path)
if err != nil {
return err
}
if m.UpperDir == "" {
return fmt.Errorf("remove denied: no upper directory")
}
upperPath := m.resolveUpper(rel)
if err := os.RemoveAll(upperPath); err != nil {
return err
}
if _, err := os.Stat(m.resolveLower(rel)); err == nil {
return m.createWhiteout(rel)
}
return nil
}
// MkdirAll creates directories in upper.
func (m *MergedView) MkdirAll(path string, perm os.FileMode) error {
if m.Mode == WorkspaceRO {
return fmt.Errorf("mkdir denied: workspace is read-only")
}
if m.UpperDir == "" {
return fmt.Errorf("no upper directory")
}
rel, err := m.safePath(path)
if err != nil {
return err
}
upperPath := m.resolveUpper(rel)
if err := os.MkdirAll(upperPath, perm); err != nil {
return err
}
return os.Chown(upperPath, int(m.Uid), int(m.Gid))
}
// Rename moves a file within upper, or copies lower→upper then creates
// a whiteout to hide the lower source.
func (m *MergedView) Rename(oldPath, newPath string) error {
if m.Mode == WorkspaceRO {
return fmt.Errorf("rename denied: workspace is read-only")
}
oldRel, err := m.safePath(oldPath)
if err != nil {
return err
}
newRel, err := m.safePath(newPath)
if err != nil {
return err
}
if m.UpperDir == "" {
return fmt.Errorf("no upper directory")
}
oldUpper := m.resolveUpper(oldRel)
newUpper := m.resolveUpper(newRel)
copiedUp := false
if _, err := os.Stat(oldUpper); os.IsNotExist(err) {
data, err := os.ReadFile(m.resolveLower(oldRel))
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(oldUpper), 0o755); err != nil {
return err
}
if err := os.WriteFile(oldUpper, data, 0o644); err != nil { //nolint:gosec
return err
}
copiedUp = true
}
if err := os.MkdirAll(filepath.Dir(newUpper), 0o755); err != nil {
return err
}
if err := os.Rename(oldUpper, newUpper); err != nil {
return err
}
// If source existed in lower (either copied-up or both layers),
// create whiteout to hide the lower source.
if copiedUp {
return m.createWhiteout(oldRel)
}
if _, err := os.Stat(m.resolveLower(oldRel)); err == nil {
return m.createWhiteout(oldRel)
}
return nil
}
// Chmod changes permissions on a path. Copy-up from lower if needed
// to avoid mutating the original workspace.
func (m *MergedView) Chmod(path string, mode os.FileMode) error {
if m.Mode == WorkspaceRO {
return fmt.Errorf("chmod denied: workspace is read-only")
}
rel, err := m.safePath(path)
if err != nil {
return err
}
if m.UpperDir != "" {
upperPath := m.resolveUpper(rel)
if _, err := os.Stat(upperPath); err == nil {
return os.Chmod(upperPath, mode)
}
// File only in lower — copy-up first to avoid mutating original.
lowerPath := m.resolveLower(rel)
data, err := os.ReadFile(lowerPath)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(upperPath), 0o755); err != nil {
return err
}
if err := os.WriteFile(upperPath, data, mode); err != nil {
return err
}
return os.Chown(upperPath, int(m.Uid), int(m.Gid))
}
if m.Mode == WorkspaceRW {
return os.Chmod(m.resolveLower(rel), mode)
}
return fmt.Errorf("chmod denied: no upper directory")
}
// Search walks the merged view under root and returns matching file paths.
// Directories are excluded from results. Whiteout entries are respected.
func (m *MergedView) Search(root, pattern string) ([]string, error) { //nolint:gocognit
rootRel, err := m.safePath(root)
if err != nil {
return nil, err
}
var results []string
seen := make(map[string]bool)
whiteouts := make(map[string]bool)
// Collect whiteouts from upper first.
if m.UpperDir != "" {
upperRoot := m.resolveUpper(rootRel)
_ = filepath.WalkDir(upperRoot, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil //nolint:nilerr // skip unreadable dirs
}
if strings.HasPrefix(d.Name(), ".wh.") {
rel, _ := filepath.Rel(m.UpperDir, p)
origName := strings.TrimPrefix(d.Name(), ".wh.")
origRel := filepath.Join(filepath.Dir(rel), origName)
whiteouts[origRel] = true
}
return nil
})
}
// Walk lower.
if m.LowerDir != "" {
lowerRoot := m.resolveLower(rootRel)
_ = filepath.WalkDir(lowerRoot, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil //nolint:nilerr // skip unreadable dirs
}
if d.IsDir() {
return nil
}
rel, _ := filepath.Rel(m.LowerDir, p)
if whiteouts[rel] {
return nil
}
if matched, _ := filepath.Match(pattern, filepath.Base(rel)); matched {
seen[rel] = true
results = append(results, rel)
}
return nil
})
}
// Walk upper.
if m.UpperDir != "" {
upperRoot := m.resolveUpper(rootRel)
_ = filepath.WalkDir(upperRoot, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil //nolint:nilerr // skip unreadable dirs
}
if d.IsDir() {
return nil
}
if strings.HasPrefix(d.Name(), ".wh.") {
return nil
}
rel, _ := filepath.Rel(m.UpperDir, p)
if matched, _ := filepath.Match(pattern, filepath.Base(rel)); matched && !seen[rel] {
results = append(results, rel)
}
return nil
})
}
sort.Strings(results)
return results, nil
}
// ReplaceContent reads a file, replaces text, and writes to upper.
func (m *MergedView) ReplaceContent(path, old, newStr string) error {
data, err := m.ReadFile(path)
if err != nil {
return err
}
content := strings.ReplaceAll(string(data), old, newStr)
return m.WriteFile(path, []byte(content), 0o644) //nolint:gosec
}