From 9692219e0f3ae34b40b7513352df2deafdddad90 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 14 Nov 2025 22:00:04 +0800 Subject: [PATCH] fix: npm proxy issues --- internal/cache/doc.go | 2 +- internal/cache/fs_store.go | 163 ++++++++++++++++++++++++++++++----- internal/cache/store.go | 2 +- internal/cache/store_test.go | 85 +++++++++++++++++- 4 files changed, 228 insertions(+), 24 deletions(-) diff --git a/internal/cache/doc.go b/internal/cache/doc.go index fc8e8c6..c7e60e0 100644 --- a/internal/cache/doc.go +++ b/internal/cache/doc.go @@ -1,5 +1,5 @@ // Package cache defines the disk-backed store responsible for translating hub -// requests into StoragePath// files. The store exposes read/write +// requests into StoragePath//.body files. The store exposes read/write // primitives with safe semantics (temp file + rename) and surfaces file info // (size, modtime) for higher layers to implement conditional revalidation. // Proxy handlers depend on this package to stream cached responses or trigger diff --git a/internal/cache/fs_store.go b/internal/cache/fs_store.go index 9a98cde..084d04e 100644 --- a/internal/cache/fs_store.go +++ b/internal/cache/fs_store.go @@ -11,9 +11,12 @@ import ( "path/filepath" "strings" "sync" + "syscall" "time" ) +const cacheFileSuffix = ".body" + // NewStore 以 basePath 为根目录构建磁盘缓存,整站复用一份实例。 func NewStore(basePath string) (Store, error) { if basePath == "" { @@ -55,27 +58,13 @@ func (s *fileStore) Get(ctx context.Context, locator Locator) (*ReadResult, erro default: } - filePath, err := s.path(locator) + primary, legacy, err := s.entryPaths(locator) if err != nil { return nil, err } - info, err := os.Stat(filePath) + filePath, info, f, err := s.openEntryFile(primary, legacy) if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return nil, ErrNotFound - } - return nil, err - } - if info.IsDir() { - return nil, ErrNotFound - } - - f, err := os.Open(filePath) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return nil, ErrNotFound - } return nil, err } @@ -99,12 +88,12 @@ func (s *fileStore) Put(ctx context.Context, locator Locator, body io.Reader, op } defer unlock() - filePath, err := s.path(locator) + filePath, legacyPath, err := s.entryPaths(locator) if err != nil { return nil, err } - if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + if err := s.ensureDirWithUpgrade(filepath.Dir(filePath)); err != nil { return nil, err } @@ -136,6 +125,7 @@ func (s *fileStore) Put(ctx context.Context, locator Locator, body io.Reader, op if err := os.Chtimes(filePath, modTime, modTime); err != nil { return nil, err } + _ = os.Remove(legacyPath) entry := Entry{ Locator: locator, @@ -153,13 +143,16 @@ func (s *fileStore) Remove(ctx context.Context, locator Locator) error { } defer unlock() - filePath, err := s.path(locator) + filePath, legacyPath, err := s.entryPaths(locator) if err != nil { return err } if err := os.Remove(filePath); err != nil && !errors.Is(err, fs.ErrNotExist) { return err } + if err := os.Remove(legacyPath); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } return nil } @@ -201,13 +194,141 @@ func (s *fileStore) path(locator Locator) (string, error) { rel = "root" } - filePath := filepath.Join(s.basePath, locator.HubName, filepath.FromSlash(rel)) - if !strings.HasPrefix(filePath, filepath.Join(s.basePath, locator.HubName)) { + hubRoot := filepath.Join(s.basePath, locator.HubName) + filePath := filepath.Join(hubRoot, filepath.FromSlash(rel)) + hubPrefix := hubRoot + string(os.PathSeparator) + if filePath != hubRoot && !strings.HasPrefix(filePath, hubPrefix) { return "", errors.New("invalid cache path") } return filePath, nil } +func (s *fileStore) entryPaths(locator Locator) (string, string, error) { + legacyPath, err := s.path(locator) + if err != nil { + return "", "", err + } + return legacyPath + cacheFileSuffix, legacyPath, nil +} + +func (s *fileStore) openEntryFile(primaryPath, legacyPath string) (string, fs.FileInfo, *os.File, error) { + info, err := os.Stat(primaryPath) + if err == nil { + if info.IsDir() { + return "", nil, nil, ErrNotFound + } + f, err := os.Open(primaryPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) || isNotDirError(err) { + return "", nil, nil, ErrNotFound + } + return "", nil, nil, err + } + return primaryPath, info, f, nil + } + if !errors.Is(err, fs.ErrNotExist) && !isNotDirError(err) { + return "", nil, nil, err + } + + info, err = os.Stat(legacyPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) || isNotDirError(err) { + return "", nil, nil, ErrNotFound + } + return "", nil, nil, err + } + if info.IsDir() { + return "", nil, nil, ErrNotFound + } + + if migrateErr := s.migrateLegacyFile(primaryPath, legacyPath); migrateErr == nil { + return s.openEntryFile(primaryPath, legacyPath) + } + + f, err := os.Open(legacyPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) || isNotDirError(err) { + return "", nil, nil, ErrNotFound + } + return "", nil, nil, err + } + return legacyPath, info, f, nil +} + +func (s *fileStore) migrateLegacyFile(primaryPath, legacyPath string) error { + if legacyPath == "" || primaryPath == legacyPath { + return nil + } + if _, err := os.Stat(legacyPath); err != nil { + return err + } + if _, err := os.Stat(primaryPath); err == nil { + if removeErr := os.Remove(legacyPath); removeErr != nil && !errors.Is(removeErr, fs.ErrNotExist) { + return removeErr + } + return nil + } + return os.Rename(legacyPath, primaryPath) +} + +func (s *fileStore) ensureDirWithUpgrade(dir string) error { + for i := 0; i < 8; i++ { + if err := os.MkdirAll(dir, 0o755); err != nil { + if isNotDirError(err) { + var pathErr *os.PathError + if errors.As(err, &pathErr) { + if upgradeErr := s.upgradeLegacyNode(pathErr.Path); upgradeErr != nil { + return upgradeErr + } + continue + } + } + return err + } + return nil + } + return fmt.Errorf("ensure cache directory failed for %s", dir) +} + +func (s *fileStore) upgradeLegacyNode(conflictPath string) error { + if conflictPath == "" { + return errors.New("empty conflict path") + } + rel, err := filepath.Rel(s.basePath, conflictPath) + if err != nil { + return err + } + if strings.HasPrefix(rel, "..") { + return fmt.Errorf("conflict path outside storage: %s", conflictPath) + } + info, err := os.Stat(conflictPath) + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if strings.HasSuffix(conflictPath, cacheFileSuffix) { + return nil + } + newPath := conflictPath + cacheFileSuffix + if _, err := os.Stat(newPath); err == nil { + return os.Remove(conflictPath) + } + return os.Rename(conflictPath, newPath) +} + +func isNotDirError(err error) bool { + if err == nil { + return false + } + var pathErr *os.PathError + if errors.As(err, &pathErr) { + return errors.Is(pathErr.Err, syscall.ENOTDIR) + } + return false +} + func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) { var copied int64 buf := make([]byte, 32*1024) diff --git a/internal/cache/store.go b/internal/cache/store.go index 6621fc1..4b8225b 100644 --- a/internal/cache/store.go +++ b/internal/cache/store.go @@ -9,7 +9,7 @@ import ( // Store 负责管理磁盘缓存的读写。磁盘布局遵循: // -// // # 实际正文 +// //.body # 实际正文 // // 每个条目仅由正文文件组成,文件的 ModTime/Size 由文件系统提供。 type Store interface { diff --git a/internal/cache/store_test.go b/internal/cache/store_test.go index 694b014..ef56e0d 100644 --- a/internal/cache/store_test.go +++ b/internal/cache/store_test.go @@ -3,8 +3,12 @@ package cache import ( "bytes" "context" + "errors" "io" + "io/fs" "os" + "path/filepath" + "strings" "testing" "time" ) @@ -38,6 +42,9 @@ func TestStorePutAndGet(t *testing.T) { if !result.Entry.ModTime.Equal(modTime) { t.Fatalf("modtime mismatch: expected %v got %v", modTime, result.Entry.ModTime) } + if !strings.HasSuffix(result.Entry.FilePath, cacheFileSuffix) { + t.Fatalf("expected cache file suffix %s, got %s", cacheFileSuffix, result.Entry.FilePath) + } } func TestStoreGetMissing(t *testing.T) { @@ -75,7 +82,7 @@ func TestStoreIgnoresDirectories(t *testing.T) { if err != nil { t.Fatalf("path error: %v", err) } - if err := os.MkdirAll(filePath, 0o755); err != nil { + if err := os.MkdirAll(filePath+cacheFileSuffix, 0o755); err != nil { t.Fatalf("mkdir error: %v", err) } @@ -84,6 +91,82 @@ func TestStoreIgnoresDirectories(t *testing.T) { } } +func TestStoreMigratesLegacyEntryOnGet(t *testing.T) { + store := newTestStore(t) + fs, ok := store.(*fileStore) + if !ok { + t.Fatalf("unexpected store type %T", store) + } + locator := Locator{HubName: "npm", Path: "/pkg"} + legacyPath, err := fs.path(locator) + if err != nil { + t.Fatalf("path error: %v", err) + } + if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil { + t.Fatalf("mkdir error: %v", err) + } + if err := os.WriteFile(legacyPath, []byte("legacy"), 0o644); err != nil { + t.Fatalf("write legacy error: %v", err) + } + + result, err := store.Get(context.Background(), locator) + if err != nil { + t.Fatalf("get legacy error: %v", err) + } + body, err := io.ReadAll(result.Reader) + if err != nil { + t.Fatalf("read legacy error: %v", err) + } + result.Reader.Close() + if string(body) != "legacy" { + t.Fatalf("unexpected legacy body: %s", string(body)) + } + if !strings.HasSuffix(result.Entry.FilePath, cacheFileSuffix) { + t.Fatalf("expected migrated file suffix, got %s", result.Entry.FilePath) + } + if _, statErr := os.Stat(legacyPath); !errors.Is(statErr, fs.ErrNotExist) { + t.Fatalf("expected legacy path removed, got %v", statErr) + } +} + +func TestStoreHandlesAncestorFileConflict(t *testing.T) { + store := newTestStore(t) + fs, ok := store.(*fileStore) + if !ok { + t.Fatalf("unexpected store type %T", store) + } + metaLocator := Locator{HubName: "npm", Path: "/pkg"} + legacyPath, err := fs.path(metaLocator) + if err != nil { + t.Fatalf("path error: %v", err) + } + if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil { + t.Fatalf("mkdir error: %v", err) + } + if err := os.WriteFile(legacyPath, []byte("legacy"), 0o644); err != nil { + t.Fatalf("write legacy error: %v", err) + } + + tarLocator := Locator{HubName: "npm", Path: "/pkg/-/pkg-1.0.0.tgz"} + if _, err := store.Put(context.Background(), tarLocator, bytes.NewReader([]byte("tar")), PutOptions{}); err != nil { + t.Fatalf("put tar error: %v", err) + } + + if _, err := os.Stat(legacyPath); !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("expected legacy metadata renamed, got %v", err) + } + if _, err := os.Stat(legacyPath + cacheFileSuffix); err != nil { + t.Fatalf("expected migrated legacy cache, got %v", err) + } + primary, _, err := fs.entryPaths(tarLocator) + if err != nil { + t.Fatalf("entry path error: %v", err) + } + if _, err := os.Stat(primary); err != nil { + t.Fatalf("expected tar cache file, got %v", err) + } +} + // newTestStore returns a Store backed by a temporary directory. func newTestStore(t *testing.T) Store { t.Helper()