From 8cd73a4101b0bf35c93bd5b474da9e4bbd539335 Mon Sep 17 00:00:00 2001 From: Thibault Gagnaux Date: Mon, 13 Jul 2026 14:04:52 +0200 Subject: [PATCH 1/4] fix: skip ..-prefixed dirs in artifact walker --- .../pkg/immutable/loader.go | 24 ++++++ .../pkg/immutable/loader_test.go | 81 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 gateway/gateway-controller/pkg/immutable/loader_test.go diff --git a/gateway/gateway-controller/pkg/immutable/loader.go b/gateway/gateway-controller/pkg/immutable/loader.go index 3085e27dd5..a955b55e2d 100644 --- a/gateway/gateway-controller/pkg/immutable/loader.go +++ b/gateway/gateway-controller/pkg/immutable/loader.go @@ -210,6 +210,30 @@ func (g *ImmutableGW) applyArtifact(path, kind, contentType string, data []byte, return nil } +func collectArtifacts(dir string) ([]string, error) { + var paths []string + if err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return fmt.Errorf("error accessing path %s: %w", path, walkErr) + } + if d.IsDir() { + if strings.HasPrefix(d.Name(), "..") { + return fs.SkipDir + } + return nil + } + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".yaml" && ext != ".yml" && ext != ".json" { + return nil + } + paths = append(paths, path) + return nil + }); err != nil { + return nil, err + } + return paths, nil +} + // Middleware returns a handler that rejects POST, PUT, and DELETE with 405 // when immutable mode is enabled. When disabled, it returns a passthrough handler. func (g *ImmutableGW) Middleware() func(http.Handler) http.Handler { diff --git a/gateway/gateway-controller/pkg/immutable/loader_test.go b/gateway/gateway-controller/pkg/immutable/loader_test.go new file mode 100644 index 0000000000..4ef681bd74 --- /dev/null +++ b/gateway/gateway-controller/pkg/immutable/loader_test.go @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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 immutable + +import ( + "os" + "path/filepath" + "testing" +) + +const petstoreArtifact = `apiVersion: gateway.api-platform.wso2.com/v1 +kind: RestApi +metadata: + name: petstore-v1 +spec: + displayName: Petstore + version: v1.0 + context: /petstore/$version + upstream: + main: + url: https://petstore3.swagger.io/api/v3 + operations: + - method: GET + path: /pet/{petId} +` + +func TestCollectArtifacts_ConfigMapMountYieldsFileOnce(t *testing.T) { + // Kubernetes ConfigMap mount layout: + // / + // ..2026_07_13_.../petstore.yaml (real file in timestamped dir) + // ..data -> ..2026_07_13_.../ (symlink to current revision) + // petstore.yaml -> ..data/petstore.yaml (per-key symlink) + dir := t.TempDir() + + tsDir := filepath.Join(dir, "..2026_07_13_10_00_00.123456") + if err := os.Mkdir(tsDir, 0o700); err != nil { + t.Fatalf("setup: mkdir %s: %v", tsDir, err) + } + if err := os.WriteFile(filepath.Join(tsDir, "petstore.yaml"), []byte(petstoreArtifact), 0o600); err != nil { + t.Fatalf("setup: write real file: %v", err) + } + + dotData := filepath.Join(dir, "..data") + if err := os.Symlink(tsDir, dotData); err != nil { + t.Fatalf("setup: symlink ..data: %v", err) + } + + topLevel := filepath.Join(dir, "petstore.yaml") + if err := os.Symlink(filepath.Join(dotData, "petstore.yaml"), topLevel); err != nil { + t.Fatalf("setup: symlink petstore.yaml: %v", err) + } + + paths, err := collectArtifacts(dir) + + if err != nil { + t.Fatalf("collectArtifacts: unexpected error: %v", err) + } + if len(paths) != 1 { + t.Fatalf("got %d path(s) %v; want exactly 1", len(paths), paths) + } + if paths[0] != topLevel { + t.Errorf("got %q; want top-level symlink %q", paths[0], topLevel) + } +} + From 9dfe2e17190fb7fcc85e6a4f8f6f4493c2866f23 Mon Sep 17 00:00:00 2001 From: Thibault Gagnaux Date: Mon, 13 Jul 2026 14:24:06 +0200 Subject: [PATCH 2/4] refactor: wire collectArtifacts into LoadArtifacts --- .../pkg/immutable/loader.go | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/gateway/gateway-controller/pkg/immutable/loader.go b/gateway/gateway-controller/pkg/immutable/loader.go index a955b55e2d..d9d29fb450 100644 --- a/gateway/gateway-controller/pkg/immutable/loader.go +++ b/gateway/gateway-controller/pkg/immutable/loader.go @@ -91,18 +91,13 @@ func (g *ImmutableGW) LoadArtifacts(log *slog.Logger) error { // pass3: RestApi, WebSubApi, LlmProxy, Mcp — LlmProxy depends on LlmProvider var pass1, pass2, pass3 []artifact - if err := filepath.WalkDir(g.cfg.ArtifactsDir, func(path string, d fs.DirEntry, walkErr error) error { - if walkErr != nil { - return fmt.Errorf("error accessing path %s: %w", path, walkErr) - } - if d.IsDir() { - return nil - } - ext := strings.ToLower(filepath.Ext(path)) - if ext != ".yaml" && ext != ".yml" && ext != ".json" { - return nil - } + artifactPaths, err := collectArtifacts(g.cfg.ArtifactsDir) + if err != nil { + return err + } + for _, path := range artifactPaths { + ext := strings.ToLower(filepath.Ext(path)) data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("failed to read artifact %s: %w", path, err) @@ -134,9 +129,6 @@ func (g *ImmutableGW) LoadArtifacts(log *slog.Logger) error { default: return fmt.Errorf("artifact %s has unsupported kind %q", path, envelope.Kind) } - return nil - }); err != nil { - return err } total := len(pass1) + len(pass2) + len(pass3) From 02c5c65334da0f1a128b8cda868dfe2d6260dec1 Mon Sep 17 00:00:00 2001 From: Thibault Gagnaux Date: Mon, 13 Jul 2026 15:02:22 +0200 Subject: [PATCH 3/4] test: add edge case tests for collectArtifacts --- .../pkg/immutable/loader_test.go | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/gateway/gateway-controller/pkg/immutable/loader_test.go b/gateway/gateway-controller/pkg/immutable/loader_test.go index 4ef681bd74..0aed6f6ff7 100644 --- a/gateway/gateway-controller/pkg/immutable/loader_test.go +++ b/gateway/gateway-controller/pkg/immutable/loader_test.go @@ -79,3 +79,38 @@ func TestCollectArtifacts_ConfigMapMountYieldsFileOnce(t *testing.T) { } } +func TestCollectArtifacts_DescendsIntoNonDotDotDirs(t *testing.T) { + cases := []struct { + name string + subdir string + }{ + {"nested subdirectory", "rest-apis"}, + {"single-dot directory", ".hidden"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, tc.subdir) + if err := os.Mkdir(sub, 0o700); err != nil { + t.Fatalf("setup: %v", err) + } + want := filepath.Join(sub, "petstore.yaml") + if err := os.WriteFile(want, []byte(petstoreArtifact), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + paths, err := collectArtifacts(dir) + + if err != nil { + t.Fatalf("collectArtifacts: %v", err) + } + if len(paths) != 1 { + t.Fatalf("got %d path(s) %v; want 1", len(paths), paths) + } + if paths[0] != want { + t.Errorf("got %q; want %q", paths[0], want) + } + }) + } +} + From 35f8b86cb7785ed86d7815b24324e1451b53fd7d Mon Sep 17 00:00:00 2001 From: Thibault Gagnaux Date: Wed, 15 Jul 2026 10:15:11 +0200 Subject: [PATCH 4/4] fix: follow directory symlinks in ConfigMap nested key layout Replace filepath.WalkDir with os.ReadDir + os.Stat to handle Kubernetes ConfigMap atomic-writer mounts. Dot-prefixed entries (..data, ..TIMESTAMP) are skipped; os.Stat follows symlinks so user-visible entries resolve transparently. This matches the pattern used by the kubelet itself (filepath.Glob with [^.]*). --- .../pkg/immutable/loader.go | 55 +++++++----- .../pkg/immutable/loader_test.go | 89 ++++++++++++------- 2 files changed, 90 insertions(+), 54 deletions(-) diff --git a/gateway/gateway-controller/pkg/immutable/loader.go b/gateway/gateway-controller/pkg/immutable/loader.go index d9d29fb450..092874c1c9 100644 --- a/gateway/gateway-controller/pkg/immutable/loader.go +++ b/gateway/gateway-controller/pkg/immutable/loader.go @@ -21,7 +21,6 @@ package immutable import ( "errors" "fmt" - "io/fs" "log/slog" "net/http" "os" @@ -37,6 +36,13 @@ import ( "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils" ) +// artifactContentTypes maps supported file extensions to their content types. +var artifactContentTypes = map[string]string{ + ".yaml": "application/x-yaml", + ".yml": "application/x-yaml", + ".json": "application/json", +} + // ImmutableGW manages immutable gateway mode: artifact loading on startup and // read-only enforcement on the management API at runtime. // When cfg.Enabled is false, all methods are no-ops. @@ -91,23 +97,23 @@ func (g *ImmutableGW) LoadArtifacts(log *slog.Logger) error { // pass3: RestApi, WebSubApi, LlmProxy, Mcp — LlmProxy depends on LlmProvider var pass1, pass2, pass3 []artifact - artifactPaths, err := collectArtifacts(g.cfg.ArtifactsDir) + filePaths, err := collectArtifacts(g.cfg.ArtifactsDir) if err != nil { return err } - for _, path := range artifactPaths { + for _, path := range filePaths { ext := strings.ToLower(filepath.Ext(path)) + contentType, ok := artifactContentTypes[ext] + if !ok { + log.Warn("Skipping file with unsupported extension", slog.String("path", path)) + continue + } data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("failed to read artifact %s: %w", path, err) } - contentType := "application/x-yaml" - if ext == ".json" { - contentType = "application/json" - } - var envelope struct { Kind string `yaml:"kind" json:"kind"` } @@ -202,26 +208,31 @@ func (g *ImmutableGW) applyArtifact(path, kind, contentType string, data []byte, return nil } +// collectArtifacts lists files in dir, skipping dot-prefixed entries. func collectArtifacts(dir string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("reading artifacts dir %s: %w", dir, err) + } var paths []string - if err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error { - if walkErr != nil { - return fmt.Errorf("error accessing path %s: %w", path, walkErr) + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".") { + continue } - if d.IsDir() { - if strings.HasPrefix(d.Name(), "..") { - return fs.SkipDir - } - return nil + path := filepath.Join(dir, e.Name()) + fi, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("stat %s: %w", path, err) } - ext := strings.ToLower(filepath.Ext(path)) - if ext != ".yaml" && ext != ".yml" && ext != ".json" { - return nil + if fi.IsDir() { + nested, err := collectArtifacts(path) + if err != nil { + return nil, err + } + paths = append(paths, nested...) + continue } paths = append(paths, path) - return nil - }); err != nil { - return nil, err } return paths, nil } diff --git a/gateway/gateway-controller/pkg/immutable/loader_test.go b/gateway/gateway-controller/pkg/immutable/loader_test.go index 0aed6f6ff7..e78fb317a2 100644 --- a/gateway/gateway-controller/pkg/immutable/loader_test.go +++ b/gateway/gateway-controller/pkg/immutable/loader_test.go @@ -79,38 +79,63 @@ func TestCollectArtifacts_ConfigMapMountYieldsFileOnce(t *testing.T) { } } -func TestCollectArtifacts_DescendsIntoNonDotDotDirs(t *testing.T) { - cases := []struct { - name string - subdir string - }{ - {"nested subdirectory", "rest-apis"}, - {"single-dot directory", ".hidden"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - dir := t.TempDir() - sub := filepath.Join(dir, tc.subdir) - if err := os.Mkdir(sub, 0o700); err != nil { - t.Fatalf("setup: %v", err) - } - want := filepath.Join(sub, "petstore.yaml") - if err := os.WriteFile(want, []byte(petstoreArtifact), 0o600); err != nil { - t.Fatalf("setup: %v", err) - } - - paths, err := collectArtifacts(dir) - - if err != nil { - t.Fatalf("collectArtifacts: %v", err) - } - if len(paths) != 1 { - t.Fatalf("got %d path(s) %v; want 1", len(paths), paths) - } - if paths[0] != want { - t.Errorf("got %q; want %q", paths[0], want) - } - }) +func TestCollectArtifacts_ConfigMapNestedDirSymlinkYieldsFileOnce(t *testing.T) { + // Kubernetes ConfigMap mount layout with a nested key (rest-apis/petstore.yaml): + // / + // ..2026_07_13_.../rest-apis/petstore.yaml (real file in timestamped dir) + // ..data -> ..2026_07_13_.../ (symlink to current revision) + // rest-apis -> ..data/rest-apis (top-level symlink to DIRECTORY) + dir := t.TempDir() + + tsDir := filepath.Join(dir, "..2026_07_13_10_00_00.123456") + if err := os.MkdirAll(filepath.Join(tsDir, "rest-apis"), 0o700); err != nil { + t.Fatalf("setup: mkdirall: %v", err) + } + if err := os.WriteFile(filepath.Join(tsDir, "rest-apis", "petstore.yaml"), []byte(petstoreArtifact), 0o600); err != nil { + t.Fatalf("setup: write real file: %v", err) + } + + dotData := filepath.Join(dir, "..data") + if err := os.Symlink(tsDir, dotData); err != nil { + t.Fatalf("setup: symlink ..data: %v", err) + } + + // Top-level entry is a symlink to a directory, not a file. + if err := os.Symlink(filepath.Join(dotData, "rest-apis"), filepath.Join(dir, "rest-apis")); err != nil { + t.Fatalf("setup: symlink rest-apis: %v", err) + } + + want := filepath.Join(dir, "rest-apis", "petstore.yaml") + paths, err := collectArtifacts(dir) + + if err != nil { + t.Fatalf("collectArtifacts: unexpected error: %v", err) + } + if len(paths) != 1 { + t.Fatalf("got %d path(s) %v; want exactly 1", len(paths), paths) + } + if paths[0] != want { + t.Errorf("got %q; want %q", paths[0], want) + } +} + +func TestCollectArtifacts_SkipsDotPrefixedEntries(t *testing.T) { + dir := t.TempDir() + hidden := filepath.Join(dir, ".hidden") + if err := os.Mkdir(hidden, 0o700); err != nil { + t.Fatalf("setup: %v", err) + } + if err := os.WriteFile(filepath.Join(hidden, "petstore.yaml"), []byte(petstoreArtifact), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + paths, err := collectArtifacts(dir) + + if err != nil { + t.Fatalf("collectArtifacts: %v", err) + } + if len(paths) != 0 { + t.Fatalf("got %d path(s) %v; want 0 (dot-prefixed entries should be skipped)", len(paths), paths) } }