From 36ceaf0ab66b05636d0dcaf44169d28e4d6f3835 Mon Sep 17 00:00:00 2001 From: caorushizi <84996057@qq.com> Date: Wed, 30 Sep 2026 23:53:49 +0800 Subject: [PATCH] fix(cli): fetch Infisical secrets once per invocation --- apps/docs/content/docs/en/dev.md | 2 +- apps/docs/content/docs/en/run.md | 4 +- apps/docs/content/docs/zh/dev.md | 2 +- apps/docs/content/docs/zh/run.md | 4 +- .../internal/adapters/env/infisical/fetch.go | 111 +++--- .../env/infisical/fetch_batch_test.go | 317 +++++++++--------- .../adapters/env/infisical/fetch_test.go | 8 +- .../internal/platform/i18n/locales/en-US.json | 2 + .../internal/platform/i18n/locales/zh-CN.json | 2 + 9 files changed, 214 insertions(+), 238 deletions(-) diff --git a/apps/docs/content/docs/en/dev.md b/apps/docs/content/docs/en/dev.md index 9017a196..6bcb1732 100644 --- a/apps/docs/content/docs/en/dev.md +++ b/apps/docs/content/docs/en/dev.md @@ -19,7 +19,7 @@ Without `-p`, the root dev task must exist. With `-p`, the selected project's de Named tasks share one preparation policy. Node dependencies are prepared at the workspace root. With pnpm 10.14 or later, One verifies installed dependencies and reuses matching installations, including manual installs. When needed, it runs `pnpm install --no-frozen-lockfile`. Go preparation resolves the fixed module build list without automatically running `go mod tidy` or `go work sync`. Preparation failure prevents task startup; `one exec` does not install dependencies. -One assigns a task to the registered project containing its effective working directory. Enabled projects receive their own Infisical snapshot for the invocation. Parallel projects do not share variable maps, and child processes inherit the correct project environment. No env plugin declaration or secret value is required in your mise file. +One assigns a task to the registered project containing its effective working directory. When projects enable environment variables, One makes a single recursive request to Infisical at startup for the selected environment, then distributes variables in memory by project directory. Root and ancestor variables are shared; project variables override matching ancestor keys. Variables from other projects or deeper subdirectories are excluded. Parallel projects receive separate variable maps, and child processes inherit the correct project environment. The next invocation fetches fresh values. No env plugin declaration or secret value is required in your mise file. ## Terminal and exit diff --git a/apps/docs/content/docs/en/run.md b/apps/docs/content/docs/en/run.md index 7b0ce129..581d8daf 100644 --- a/apps/docs/content/docs/en/run.md +++ b/apps/docs/content/docs/en/run.md @@ -77,7 +77,9 @@ POSIX shell argument forwarding preserves spaces, quotes, Unicode and metacharac Task ownership follows the deepest registered project directory containing its effective working directory. Root aggregates do not receive a union of child variables. -One batch-loads an immutable project snapshot once per invocation. `--env` selects a declared environment, defaulting to `dev`. Values override matching shell, mise and task variables. Each leaf receives only its project's snapshot, including empty values. Child processes inherit the same environment. +When projects enable environment variables, One makes a single recursive request to Infisical per invocation and builds immutable project snapshots in memory. A later invocation fetches fresh values. `--env` selects a declared environment, defaulting to `dev`. Each project inherits root, ancestor, and project-directory variables in that order, with closer folders overriding matching keys. Variables from other projects or deeper subdirectories are excluded. Values override matching shell, mise and task variables. Each leaf receives only its project's snapshot, including empty values. Child processes inherit the same environment. + +Infisical recursive reads support at most 20 directory levels. Projects beyond that depth fail before the request. A response containing a secret without an absolute folder path also fails, preventing incomplete or incorrectly scoped variables from being distributed. Environment values and commands are sent to private leaf processes through an authenticated loopback channel. Generated YAML contains graph metadata and worker identities; it contains no injected variable values. No environment plugins or temporary binding TOML are generated. The channel and private configuration directory are closed and removed when the invocation ends. diff --git a/apps/docs/content/docs/zh/dev.md b/apps/docs/content/docs/zh/dev.md index 3737d930..2cf74c31 100644 --- a/apps/docs/content/docs/zh/dev.md +++ b/apps/docs/content/docs/zh/dev.md @@ -19,7 +19,7 @@ one run dev -p web -- --port 4300 所有命名任务共用准备策略。Node 依赖在工作区根目录准备;pnpm 10.14 及以上会检查现有安装并复用匹配的依赖,包括手动安装的依赖。需要安装时执行 `pnpm install --no-frozen-lockfile`。Go 准备会解析固定模块构建列表,不自动执行 `go mod tidy` 或 `go work sync`。准备失败则不启动任务;`one exec` 不安装依赖。 -One 按任务实际工作目录匹配登记项目。启用环境变量的项目各自获取本次运行的 Infisical 快照;并行项目不共用变量表,孙进程继承对应项目的环境。无需在 mise 中声明 env 插件或填写密钥值。 +One 按任务实际工作目录匹配登记项目。项目启用环境变量时,启动只向 Infisical 发起一次递归读取,获取所选环境的变量,再在内存中按项目目录分发:根目录与父目录的变量共享,项目目录的同名变量覆盖父目录,其他项目和更深层子目录的变量不会混入。并行项目各自使用独立变量表,孙进程继承对应项目的环境;下次启动重新获取最新值。无需在 mise 中声明 env 插件或填写密钥值。 ## 终端与退出 diff --git a/apps/docs/content/docs/zh/run.md b/apps/docs/content/docs/zh/run.md index 2294106a..d8af6307 100644 --- a/apps/docs/content/docs/zh/run.md +++ b/apps/docs/content/docs/zh/run.md @@ -77,7 +77,9 @@ POSIX shell 参数保留空格、引号、中文和元字符。在 Windows 上 任务归属按实际工作目录匹配最深的已注册项目。根聚合任务不合并各子项目变量。 -每次运行批量读取一次不可变的项目变量快照,`--env` 选择已声明环境,默认 `dev`。变量覆盖同名 shell、mise 和任务变量;每个叶子只接收自己的项目快照,包含空值。后续子进程继承相同环境。 +项目启用环境变量时,每次运行只向 Infisical 发起一次递归读取,在内存中生成各项目的不可变变量快照;再次运行重新读取最新值。`--env` 选择已声明环境,默认 `dev`。每个项目依次继承根目录、父目录和自身目录的变量,较近目录覆盖同名键,其他项目和更深层子目录的变量不会混入。变量覆盖同名 shell、mise 和任务变量;每个叶子只接收自己的项目快照,包含空值。后续子进程继承相同环境。 + +Infisical 递归读取最多支持 20 层目录。超过此深度的项目会在请求前报错;响应中的变量缺少绝对目录路径时也会报错,避免分发不完整或归属不明的变量。 变量和命令通过本地认证通道交给私有叶子进程。生成的 YAML 只含任务图元数据和 worker 标识,不含注入变量值;不生成环境插件或临时绑定 TOML。运行结束后关闭通道,清理私有配置目录。 diff --git a/packages/cli/internal/adapters/env/infisical/fetch.go b/packages/cli/internal/adapters/env/infisical/fetch.go index 6341c5fa..18faf786 100644 --- a/packages/cli/internal/adapters/env/infisical/fetch.go +++ b/packages/cli/internal/adapters/env/infisical/fetch.go @@ -3,7 +3,7 @@ package infisical import ( "context" "path/filepath" - "sync" + "strings" "time" "github.com/go-resty/resty/v2" @@ -12,6 +12,7 @@ import ( "github.com/infisical/go-sdk/packages/util" "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" ) @@ -39,8 +40,12 @@ func FetchSecretsForSubproject(ctx context.Context, projectRoot, relativeDir, en return projects[relativeDir], nil } +// Infisical's recursive list endpoint supports at most 20 directory levels. +const maxSnapshotDepth = 20 + // Snapshot lifetime is exactly one call: a later launch always reads fresh -// values. Unique folders are requested concurrently, then merged in chain order. +// values. One recursive read supplies all projects, then each project receives +// only the folders in its inheritance chain, merged from root to leaf. func fetchSecretsForProjects(ctx context.Context, projectRoot string, dirs []string, envName string) (map[string]map[string]string, error) { if err := ctx.Err(); err != nil { return nil, err @@ -66,29 +71,42 @@ func fetchSecretsForProjects(ctx context.Context, projectRoot string, dirs []str } cfg.SiteURL = siteURL chains := make(map[string][]string, len(dirs)) - indices := map[string]int{} - paths := []string{} + paths := map[string]bool{} for _, dir := range dirs { resolution, err := resolveRunPath(projectRoot, dir) if err != nil { return nil, err } + if len(resolution.Chain)-1 > maxSnapshotDepth { + return nil, cliErrors.New(cliErrors.INFISICAL_API_ERROR, + i18n.Tf("infisical.snapshot_depth_exceeded", dir, maxSnapshotDepth)) + } chains[dir] = resolution.Chain for _, path := range resolution.Chain { - if _, exists := indices[path]; !exists { - indices[path] = len(paths) - paths = append(paths, path) - } + paths[path] = true } } - folders, err := fetchFolders(ctx, cfg, creds, env, paths) + snapshot, err := fetchSnapshot(ctx, cfg, creds, env) if err != nil { return nil, err } + folders := make(map[string][]models.Secret, len(paths)) + for _, secret := range snapshot { + // A recursive response must identify each secret's folder. Treating a + // missing path as root would distribute project-only values to siblings. + if !strings.HasPrefix(secret.SecretPath, "/") { + return nil, cliErrors.New(cliErrors.INFISICAL_API_ERROR, + i18n.T("infisical.snapshot_path_missing")) + } + path := NormalizePath(secret.SecretPath) + if paths[path] { + folders[path] = append(folders[path], secret) + } + } for dir, chain := range chains { merged := map[string]string{} for _, path := range chain { - for _, secret := range folders[indices[path]] { + for _, secret := range folders[path] { merged[secret.SecretKey] = secret.SecretValue } } @@ -97,14 +115,10 @@ func fetchSecretsForProjects(ctx context.Context, projectRoot string, dirs []str return result, nil } -const maxFolderRequests = 6 - -func fetchFolders(ctx context.Context, cfg *WorkspaceConfig, creds *Credentials, env string, paths []string) ([][]models.Secret, error) { - ctx, cancel := context.WithCancel(ctx) - defer cancel() +func fetchSnapshot(ctx context.Context, cfg *WorkspaceConfig, creds *Credentials, env string) ([]models.Secret, error) { // The SDK's high-level client does not pass its constructor context to // HTTP requests. Reuse its list API and error contract with a cancellable - // request client so Ctrl-C and a failed sibling stop in-flight reads. + // request client so Ctrl-C stops the snapshot read. client := resty.New(). SetBaseURL(util.AppendAPIEndpoint(cfg.SiteURLOrDefault())). SetHeader("User-Agent", "one-cli/"+clientVersion). @@ -116,64 +130,17 @@ func fetchFolders(ctx context.Context, cfg *WorkspaceConfig, creds *Credentials, return ctx.Err() }) defer client.GetClient().CloseIdleConnections() - results := make([][]models.Secret, len(paths)) - jobs := make(chan int, len(paths)) - for i := range paths { - jobs <- i - } - close(jobs) - var wg sync.WaitGroup - var once sync.Once - var firstErr error - for range min(maxFolderRequests, len(paths)) { - wg.Go(func() { - for i := range jobs { - if ctx.Err() != nil { - return - } - response, err := api.CallListSecretsV3(nil, client, api.ListSecretsV3RawRequest{ - ProjectID: cfg.ProjectID, Environment: env, SecretPath: paths[i], - ExpandSecretReferences: true, - }) - if err != nil { - if ctx.Err() != nil { - return - } - err = mapAPIError(err) - // Missing ancestors are empty, never a reason to hide auth, - // network, or project-not-found failures. - if isFolderNotFound(err) { - continue - } - once.Do(func() { firstErr = err; cancel() }) - return - } - results[i] = response.Secrets - } - }) - } - wg.Wait() - if firstErr != nil { - return nil, firstErr - } - if err := ctx.Err(); err != nil { - return nil, err + response, err := api.CallListSecretsV3(nil, client, api.ListSecretsV3RawRequest{ + ProjectID: cfg.ProjectID, Environment: env, SecretPath: "/", + ExpandSecretReferences: true, Recursive: true, + }) + if ctx.Err() != nil { + return nil, ctx.Err() } - return results, nil -} - -// isFolderNotFound reports whether err is the structured -// INFISICAL_FOLDER_NOT_FOUND envelope (the only "soft" error class in -// the chain walk). -func isFolderNotFound(err error) bool { - if err == nil { - return false - } - type coded interface{ ErrorCode() string } - if c, ok := err.(coded); ok { - return c.ErrorCode() == "INFISICAL_FOLDER_NOT_FOUND" + if err != nil { + return nil, mapAPIError(err) } - return false + return response.Secrets, nil } // resolveRunPath derives the fixed folder inheritance chain for a task directory. diff --git a/packages/cli/internal/adapters/env/infisical/fetch_batch_test.go b/packages/cli/internal/adapters/env/infisical/fetch_batch_test.go index 606429fe..6fb3b8c8 100644 --- a/packages/cli/internal/adapters/env/infisical/fetch_batch_test.go +++ b/packages/cli/internal/adapters/env/infisical/fetch_batch_test.go @@ -8,12 +8,16 @@ import ( "net/http" "net/http/httptest" "reflect" + "strings" "sync" "sync/atomic" "testing" "time" + "github.com/infisical/go-sdk/packages/models" "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" + "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/secrets" "github.com/zalando/go-keyring" ) @@ -31,161 +35,114 @@ func batchWorkspace(t *testing.T, handler http.HandlerFunc) string { return root } -func waitFolderRequests(t *testing.T, started <-chan string, n int) { +func waitSnapshotRequest(t *testing.T, started <-chan struct{}) { t.Helper() - timer := time.NewTimer(5 * time.Second) - defer timer.Stop() - for range n { - select { - case <-started: - case <-timer.C: - t.Fatal("folder reads did not overlap") - } + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("snapshot request did not start") } } -func TestBatchFetchDeduplicatesAndMergesFreshSnapshots(t *testing.T) { - var mu sync.Mutex - calls := map[string]int{} - var generation atomic.Int32 - started := make(chan string, 32) - release := make(chan struct{}) - var releaseOnce sync.Once - unblock := func() { releaseOnce.Do(func() { close(release) }) } - // Registered after server cleanup as well, so a failing test releases handlers. - folders := map[string]map[string]string{ - "/": {"SHARED": "root", "OVERRIDE": "root"}, - "/services": {"OVERRIDE": "services"}, - "/services/server": {"OVERRIDE": "server", "SERVER_ONLY": "yes"}, - "/apps": {"OVERRIDE": "apps"}, - "/apps/home": {"OVERRIDE": "home", "HOME_ONLY": "yes"}, - } +func TestBatchFetchReadsOnceAndMergesFreshSnapshots(t *testing.T) { + var calls, generation atomic.Int32 root := batchWorkspace(t, func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - path := q.Get("secretPath") - if r.Method != http.MethodGet || r.URL.Path != "/api/v3/secrets/raw" || q.Get("workspaceId") != "remote" || q.Get("environment") != "dev" || q.Get("expandSecretReferences") != "true" || q.Get("recursive") != "false" || r.Header.Get("Authorization") == "" { + if r.Method != http.MethodGet || r.URL.Path != "/api/v3/secrets/raw" || q.Get("workspaceId") != "remote" || q.Get("environment") != "dev" || q.Get("secretPath") != "/" || q.Get("expandSecretReferences") != "true" || q.Get("recursive") != "true" || q.Get("include_imports") != "false" || r.Header.Get("Authorization") == "" { t.Errorf("unexpected request %s %s", r.Method, r.URL) } - mu.Lock() - calls[path]++ - mu.Unlock() - started <- path - select { - case <-release: - case <-r.Context().Done(): - return - } - w.Header().Set("Content-Type", "application/json") - if path == "/apps/admin" { - w.WriteHeader(http.StatusNotFound) - _ = json.NewEncoder(w).Encode(map[string]string{"message": "Folder with path '/apps/admin' in environment 'dev' was not found."}) - return - } - // Parents finish last: completion order must never define precedence. - if path == "/" { - time.Sleep(20 * time.Millisecond) + calls.Add(1) + // Children precede their parents in the response. Only inheritance + // order, never response order, may decide which value wins. + folders := []struct { + path string + values map[string]string + }{ + {"/services/server", map[string]string{"OVERRIDE": "server", "SERVER_ONLY": "yes"}}, + {"/apps/home", map[string]string{"OVERRIDE": "home", "HOME_ONLY": "yes"}}, + {"/apps/home/nested", map[string]string{"NESTED_ONLY": "yes", "OVERRIDE": "nested"}}, + {"/apps/home-other", map[string]string{"PREFIX_ONLY": "yes"}}, + {"/unrelated", map[string]string{"UNRELATED_ONLY": "yes"}}, + {"/apps", map[string]string{"OVERRIDE": "apps"}}, + {"/services", map[string]string{"OVERRIDE": "services"}}, + {"/", map[string]string{"SHARED": "root", "OVERRIDE": "root"}}, } - values := []map[string]string{} - for key, value := range folders[path] { - values = append(values, map[string]string{"secretKey": key, "secretValue": fmt.Sprintf("%d:%s", generation.Load(), value)}) + values := []models.Secret{} + for _, folder := range folders { + for key, value := range folder.values { + values = append(values, models.Secret{SecretPath: folder.path, SecretKey: key, SecretValue: fmt.Sprintf("%d:%s", generation.Load(), value)}) + } } + values = append(values, models.Secret{SecretPath: "/", SecretKey: "EMPTY", SecretValue: ""}) + w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"secrets": values}) }) - t.Cleanup(unblock) - dirs := []string{"services/server", "apps/home", "apps/admin", "apps/home"} - type result struct { - values map[string]map[string]string - err error + dirs := []string{"services/server", "apps/home", "apps/admin", "apps/home", "", "missing/project"} + got, err := secrets.LoadProjects(context.Background(), Loader(), root, dirs, "dev") + if err != nil { + t.Fatal(err) } - done := make(chan result, 1) - go func() { - values, err := fetchSecretsForProjects(context.Background(), root, dirs, "dev") - done <- result{values, err} - }() - waitFolderRequests(t, started, 6) - unblock() - got := <-done - if got.err != nil { - t.Fatal(got.err) + if calls.Load() != 1 || len(got) != 5 { + t.Fatalf("requests = %d, snapshots = %d", calls.Load(), len(got)) } - for _, tc := range []struct{ dir, override string }{{"services/server", "server"}, {"apps/home", "home"}, {"apps/admin", "apps"}} { - values := got.values[tc.dir] + for _, tc := range []struct{ dir, override string }{{"services/server", "server"}, {"apps/home", "home"}, {"apps/admin", "apps"}, {"", "root"}, {"missing/project", "root"}} { + values := got[tc.dir] if values["SHARED"] != "0:root" || values["OVERRIDE"] != "0:"+tc.override { - t.Fatalf("wrong inheritance: %v", got.values) + t.Fatalf("wrong inheritance: %v", got) + } + if empty, exists := values["EMPTY"]; !exists || empty != "" { + t.Fatal("empty value was lost") + } + for _, key := range []string{"NESTED_ONLY", "PREFIX_ONLY", "UNRELATED_ONLY"} { + if _, exists := values[key]; exists { + t.Fatalf("%s leaked into %s", key, tc.dir) + } } } - if got.values["apps/home"]["SERVER_ONLY"] != "" || got.values["apps/admin"]["HOME_ONLY"] != "" { + if got["apps/home"]["SERVER_ONLY"] != "" || got["apps/admin"]["HOME_ONLY"] != "" || got[""]["HOME_ONLY"] != "" { t.Fatal("project values leaked") } - got.values["apps/home"]["SHARED"] = "modified" - if got.values["apps/admin"]["SHARED"] != "0:root" { + got["apps/home"]["SHARED"] = "modified" + if got["apps/admin"]["SHARED"] != "0:root" { t.Fatal("project maps alias") } - mu.Lock() - for path, n := range calls { - if n != 1 { - t.Errorf("%s read %d times", path, n) - } - } - mu.Unlock() generation.Store(1) - next, err := fetchSecretsForProjects(context.Background(), root, dirs, "dev") + next, err := secrets.LoadProjects(context.Background(), Loader(), root, dirs, "dev") if err != nil { t.Fatal(err) } - if next["apps/home"]["SHARED"] != "1:root" { - t.Fatal("a later invocation reused stale values") - } - mu.Lock() - defer mu.Unlock() - for path, n := range calls { - if n != 2 { - t.Errorf("%s read %d times after two invocations", path, n) - } + if next["apps/home"]["SHARED"] != "1:root" || calls.Load() != 2 { + t.Fatal("a later invocation did not read exactly one fresh snapshot") } } -func TestBatchFetchBoundsConcurrency(t *testing.T) { - var active, peak atomic.Int32 - started := make(chan string, 64) - release := make(chan struct{}) - var once sync.Once - unblock := func() { once.Do(func() { close(release) }) } +func TestBatchFetchReadsManyProjectsOnce(t *testing.T) { + var calls atomic.Int32 root := batchWorkspace(t, func(w http.ResponseWriter, r *http.Request) { - n := active.Add(1) - defer active.Add(-1) - for old := peak.Load(); n > old; old = peak.Load() { - if peak.CompareAndSwap(old, n) { - break - } - } - started <- r.URL.Query().Get("secretPath") - select { - case <-release: - case <-r.Context().Done(): - return - } + calls.Add(1) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"secrets":[]}`)) }) - t.Cleanup(unblock) dirs := []string{} for i := range 20 { dirs = append(dirs, fmt.Sprintf("apps/p%d", i)) } - done := make(chan error, 1) - go func() { _, err := fetchSecretsForProjects(context.Background(), root, dirs, "dev"); done <- err }() - waitFolderRequests(t, started, maxFolderRequests) - unblock() - if err := <-done; err != nil { - t.Fatal(err) + got, err := fetchSecretsForProjects(context.Background(), root, dirs, "dev") + if err != nil || len(got) != len(dirs) || calls.Load() != 1 { + t.Fatalf("snapshots = %d, requests = %d, error = %v", len(got), calls.Load(), err) + } + for _, dir := range dirs { + if got[dir] == nil || len(got[dir]) != 0 { + t.Fatalf("missing empty snapshot for %s", dir) + } } - if peak.Load() != maxFolderRequests { - t.Fatalf("peak concurrent requests = %d", peak.Load()) + got, err = fetchSecretsForProjects(context.Background(), t.TempDir(), nil, "dev") + if err != nil || len(got) != 0 || calls.Load() != 1 { + t.Fatal("empty batch required configuration or performed a request") } } -func TestBatchFetchFailureCancelsReads(t *testing.T) { +func TestBatchFetchFailureReturnsNoSnapshot(t *testing.T) { for _, tc := range []struct { name string status int @@ -194,27 +151,15 @@ func TestBatchFetchFailureCancelsReads(t *testing.T) { {"unauthorized", 401, "INFISICAL_AUTH_FAILED"}, {"forbidden", 403, "INFISICAL_API_ERROR"}, {"project_missing", 404, "INFISICAL_API_ERROR"}, + {"rate_limited", 429, "INFISICAL_API_ERROR"}, {"server_error", 500, "INFISICAL_API_ERROR"}, {"connection_closed", 0, "INFISICAL_API_ERROR"}, + {"invalid_json", 200, "INFISICAL_API_ERROR"}, } { t.Run(tc.name, func(t *testing.T) { - started := make(chan string, 6) - release := make(chan struct{}) - var once sync.Once - unblock := func() { once.Do(func() { close(release) }) } - ctx, cancel := context.WithCancel(context.Background()) + var calls atomic.Int32 root := batchWorkspace(t, func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Query().Get("secretPath") - started <- path - if path != "/" { - <-r.Context().Done() - return - } - select { - case <-release: - case <-r.Context().Done(): - return - } + calls.Add(1) if tc.status == 0 { conn, _, err := w.(http.Hijacker).Hijack() if err == nil { @@ -224,44 +169,92 @@ func TestBatchFetchFailureCancelsReads(t *testing.T) { } w.Header().Set("Content-Type", "application/json") w.WriteHeader(tc.status) - _, _ = w.Write([]byte(`{"message":"request rejected"}`)) - }) - t.Cleanup(cancel) - t.Cleanup(unblock) - done := make(chan error, 1) - go func() { - values, err := fetchSecretsForProjects(ctx, root, []string{"services/server", "apps/home", "apps/admin"}, "dev") - if values != nil { - t.Error("partial snapshot returned on failure") - } - done <- err - }() - waitFolderRequests(t, started, 6) - unblock() - select { - case err := <-done: - var coded interface{ ErrorCode() string } - if !errors.As(err, &coded) || coded.ErrorCode() != tc.code { - t.Fatalf("error = %v, want %s", err, tc.code) + if tc.status == 200 { + _, _ = w.Write([]byte(`{"secrets":`)) + return } - case <-time.After(3 * time.Second): - t.Fatal("failed sibling did not cancel outstanding HTTP requests") + _, _ = w.Write([]byte(`{"message":"request rejected: synthetic-secret-value"}`)) + }) + values, err := fetchSecretsForProjects(context.Background(), root, []string{"services/server", "apps/home", "apps/admin"}, "dev") + var coded interface{ ErrorCode() string } + if values != nil || !errors.As(err, &coded) || coded.ErrorCode() != tc.code || calls.Load() != 1 { + t.Fatalf("error = %v, requests = %d, want %s", err, calls.Load(), tc.code) + } + if strings.Contains(err.Error(), "synthetic-secret-value") { + t.Fatal("error exposed the response body") + } + }) + } +} + +func TestBatchFetchRejectsMissingSecretPaths(t *testing.T) { + for _, locale := range []string{"en-US", "zh-CN"} { + t.Run(locale, func(t *testing.T) { + if err := i18n.Init(locale); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = i18n.Init("en-US") }) + root := batchWorkspace(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"secrets":[{"secretPath":"/apps/home","secretKey":"VALID","secretValue":"synthetic-secret-value"},{"secretKey":"PRIVATE","secretValue":"synthetic-secret-value"}]}`)) + }) + values, err := fetchSecretsForProjects(context.Background(), root, []string{"apps/home", "apps/admin"}, "dev") + var coded interface{ ErrorCode() string } + if values != nil || !errors.As(err, &coded) || coded.ErrorCode() != "INFISICAL_API_ERROR" || err.Error() != i18n.T("infisical.snapshot_path_missing") { + t.Fatalf("missing secret path was not rejected: %v", err) + } + if strings.Contains(err.Error(), "synthetic-secret-value") { + t.Fatal("error exposed secret values") + } + }) + } +} + +func TestBatchFetchChecksRecursiveDepth(t *testing.T) { + dir := strings.Repeat("nested/", maxSnapshotDepth-1) + "project" + var calls atomic.Int32 + root := batchWorkspace(t, func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"secrets": []models.Secret{{SecretPath: "/" + dir, SecretKey: "DEEPEST", SecretValue: "yes"}}}) + }) + got, err := fetchSecretsForProjects(context.Background(), root, []string{dir}, "dev") + if err != nil || got[dir]["DEEPEST"] != "yes" || calls.Load() != 1 { + t.Fatalf("supported depth failed: %v %v", got, err) + } + for _, locale := range []string{"en-US", "zh-CN"} { + t.Run(locale, func(t *testing.T) { + if err := i18n.Init(locale); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = i18n.Init("en-US") }) + tooDeep := dir + "/child" + got, err := fetchSecretsForProjects(context.Background(), root, []string{"apps/home", tooDeep}, "dev") + if got != nil || err == nil || err.Error() != i18n.Tf("infisical.snapshot_depth_exceeded", tooDeep, maxSnapshotDepth) || calls.Load() != 1 { + t.Fatalf("unsupported depth was not rejected before fetching: %v", err) } }) } } func TestBatchFetchCancellation(t *testing.T) { - started := make(chan string, 6) + started := make(chan struct{}, 1) + cancelled := make(chan struct{}, 1) + var calls atomic.Int32 root := batchWorkspace(t, func(w http.ResponseWriter, r *http.Request) { - started <- r.URL.Query().Get("secretPath") + calls.Add(1) + started <- struct{}{} <-r.Context().Done() + cancelled <- struct{}{} }) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) done := make(chan error, 1) - go func() { _, err := fetchSecretsForProjects(ctx, root, []string{"apps/home"}, "dev"); done <- err }() - waitFolderRequests(t, started, 3) + go func() { + _, err := fetchSecretsForProjects(ctx, root, []string{"apps/home", "apps/admin"}, "dev") + done <- err + }() + waitSnapshotRequest(t, started) cancel() select { case err := <-done: @@ -269,18 +262,21 @@ func TestBatchFetchCancellation(t *testing.T) { t.Fatalf("error = %v", err) } case <-time.After(3 * time.Second): - t.Fatal("HTTP requests ignored cancellation") + t.Fatal("HTTP request ignored cancellation") } + waitSnapshotRequest(t, cancelled) values, err := fetchSecretsForProjects(ctx, root, []string{"apps/home"}, "dev") - if !errors.Is(err, context.Canceled) || values != nil { + if !errors.Is(err, context.Canceled) || values != nil || calls.Load() != 1 { t.Fatal("pre-cancelled load did work") } } func TestBatchFetchEnvironmentIsolation(t *testing.T) { + var calls atomic.Int32 root := batchWorkspace(t, func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"secrets": []map[string]string{{"secretKey": "ENV", "secretValue": r.URL.Query().Get("environment")}}}) + _ = json.NewEncoder(w).Encode(map[string]any{"secrets": []models.Secret{{SecretPath: "/", SecretKey: "ENV", SecretValue: r.URL.Query().Get("environment")}}}) }) var wg sync.WaitGroup for _, env := range []string{"dev", "staging"} { @@ -293,4 +289,7 @@ func TestBatchFetchEnvironmentIsolation(t *testing.T) { }) } wg.Wait() + if calls.Load() != 2 { + t.Fatalf("requests = %d, want one per environment", calls.Load()) + } } diff --git a/packages/cli/internal/adapters/env/infisical/fetch_test.go b/packages/cli/internal/adapters/env/infisical/fetch_test.go index 4ae2a0e5..1bd97b33 100644 --- a/packages/cli/internal/adapters/env/infisical/fetch_test.go +++ b/packages/cli/internal/adapters/env/infisical/fetch_test.go @@ -27,7 +27,7 @@ func TestFetchProjectEnvironmentsStayIsolated(t *testing.T) { } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - if r.URL.Path != "/api/v3/secrets/raw" || q.Get("workspaceId") != "remote" || q.Get("recursive") == "true" { + if r.URL.Path != "/api/v3/secrets/raw" || q.Get("workspaceId") != "remote" || q.Get("recursive") != "true" || q.Get("secretPath") != "/" { t.Errorf("unexpected request %s", r.URL) } env := q.Get("environment") @@ -35,8 +35,10 @@ func TestFetchProjectEnvironmentsStayIsolated(t *testing.T) { t.Errorf("unexpected environment %q", env) } values := []map[string]string{} - for k, v := range folders[q.Get("secretPath")] { - values = append(values, map[string]string{"secretKey": k, "secretValue": env + ":" + v}) + for path, folder := range folders { + for k, v := range folder { + values = append(values, map[string]string{"secretPath": path, "secretKey": k, "secretValue": env + ":" + v}) + } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"secrets": values}) diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json index 0b889c5c..63fb7f29 100644 --- a/packages/cli/internal/platform/i18n/locales/en-US.json +++ b/packages/cli/internal/platform/i18n/locales/en-US.json @@ -155,6 +155,8 @@ "infisical.relogin": "The Infisical session is no longer valid. Run one logout, then one login.", "infisical.folder_missing": "Infisical folder not found (environment=%s, folder=%s).", "infisical.request_failed": "The Infisical request failed. Check the project, environment, path, and access permissions.", + "infisical.snapshot_path_missing": "The Infisical recursive response contains a secret without an absolute folder path. Check your Infisical server version before retrying.", + "infisical.snapshot_depth_exceeded": "Project directory %s exceeds Infisical's recursive read limit of %d levels. Shorten the project directory path before retrying.", "infisical.token_missing": "An Infisical access token is required to create a project.", "infisical.unreachable": "Cannot connect to Infisical.", "infisical.request_error": "Infisical request failed.", diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json index 409573da..bcbfec29 100644 --- a/packages/cli/internal/platform/i18n/locales/zh-CN.json +++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json @@ -155,6 +155,8 @@ "infisical.relogin": "Infisical 登录失效,请先运行 one logout,再运行 one login。", "infisical.folder_missing": "Infisical 目录不存在(环境=%s,目录=%s)。", "infisical.request_failed": "Infisical 请求失败,请检查项目、环境、路径和访问权限。", + "infisical.snapshot_path_missing": "Infisical 递归响应中有变量缺少绝对目录路径,请检查 Infisical 服务版本后重试。", + "infisical.snapshot_depth_exceeded": "项目目录 %s 超出 Infisical 递归读取的 %d 层限制,请缩短项目目录路径后重试。", "infisical.token_missing": "Infisical access token 不可用,无法调用 create-project。", "infisical.unreachable": "无法连接到 Infisical", "infisical.request_error": "Infisical 请求失败",