shakapacker 9.3.0.beta.7 → 9.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -109
  3. data/Gemfile.lock +1 -1
  4. data/README.md +53 -2
  5. data/docs/configuration.md +28 -0
  6. data/docs/rspack_migration_guide.md +238 -2
  7. data/docs/troubleshooting.md +21 -21
  8. data/eslint.config.fast.js +8 -0
  9. data/eslint.config.js +47 -10
  10. data/knip.ts +8 -1
  11. data/lib/install/config/shakapacker.yml +6 -6
  12. data/lib/shakapacker/configuration.rb +227 -4
  13. data/lib/shakapacker/dev_server.rb +88 -1
  14. data/lib/shakapacker/doctor.rb +4 -4
  15. data/lib/shakapacker/instance.rb +85 -1
  16. data/lib/shakapacker/manifest.rb +85 -11
  17. data/lib/shakapacker/version.rb +1 -1
  18. data/lib/shakapacker.rb +143 -3
  19. data/lib/tasks/shakapacker/doctor.rake +1 -1
  20. data/lib/tasks/shakapacker/export_bundler_config.rake +4 -4
  21. data/package/config.ts +0 -1
  22. data/package/configExporter/buildValidator.ts +53 -29
  23. data/package/configExporter/cli.ts +81 -56
  24. data/package/configExporter/configFile.ts +33 -26
  25. data/package/configExporter/types.ts +64 -0
  26. data/package/configExporter/yamlSerializer.ts +118 -43
  27. data/package/dev_server.ts +2 -1
  28. data/package/env.ts +1 -1
  29. data/package/environments/base.ts +4 -4
  30. data/package/environments/development.ts +7 -6
  31. data/package/environments/production.ts +6 -7
  32. data/package/environments/test.ts +2 -1
  33. data/package/index.ts +28 -4
  34. data/package/loaders.d.ts +2 -2
  35. data/package/optimization/webpack.ts +29 -31
  36. data/package/rspack/index.ts +2 -1
  37. data/package/rules/file.ts +1 -0
  38. data/package/rules/jscommon.ts +1 -0
  39. data/package/utils/helpers.ts +0 -1
  40. data/package/utils/pathValidation.ts +68 -7
  41. data/package/utils/requireOrError.ts +10 -2
  42. data/package/utils/typeGuards.ts +43 -46
  43. data/package/webpack-types.d.ts +2 -2
  44. data/package/webpackDevServerConfig.ts +1 -0
  45. data/package.json +2 -3
  46. data/test/package/configExporter/cli.test.js +440 -0
  47. data/test/package/configExporter/types.test.js +163 -0
  48. data/test/package/configExporter.test.js +264 -0
  49. data/test/package/yamlSerializer.test.js +204 -0
  50. data/test/typescript/pathValidation.test.js +44 -0
  51. data/test/typescript/requireOrError.test.js +49 -0
  52. data/yarn.lock +0 -32
  53. metadata +11 -5
  54. data/.eslintrc.fast.js +0 -40
  55. data/.eslintrc.js +0 -84
@@ -1,4 +1,5 @@
1
1
  import { dirname, sep, normalize } from "path"
2
+
2
3
  const {
3
4
  additional_paths: additionalPaths,
4
5
  source_path: sourcePath
@@ -1,5 +1,6 @@
1
1
  import { resolve } from "path"
2
2
  import { realpathSync } from "fs"
3
+
3
4
  const {
4
5
  source_path: sourcePath,
5
6
  additional_paths: additionalPaths
@@ -59,7 +59,6 @@ const loaderMatches = <T = unknown>(
59
59
 
60
60
  const packageFullVersion = (packageName: string): string => {
61
61
  try {
62
- // eslint-disable-next-line import/no-dynamic-require
63
62
  const packageJsonPath = require.resolve(`${packageName}/package.json`)
64
63
  // eslint-disable-next-line import/no-dynamic-require, global-require
65
64
  const packageJson = require(packageJsonPath) as { version: string }
@@ -28,26 +28,87 @@ export function isPathTraversalSafe(inputPath: string): boolean {
28
28
  /**
29
29
  * Resolves and validates a path within a base directory
30
30
  * Prevents directory traversal attacks by ensuring the resolved path
31
- * stays within the base directory
31
+ * stays within the base directory.
32
+ * Also resolves symlinks to prevent symlink-based path traversal attacks.
33
+ *
34
+ * @param basePath - The base directory to validate against
35
+ * @param userPath - The user-provided path to validate
36
+ * @param resolveSymlinks - Whether to resolve symlinks (default: true for security)
37
+ * @returns The validated absolute path
38
+ * @throws Error if path is outside base directory
32
39
  */
33
- export function safeResolvePath(basePath: string, userPath: string): string {
34
- // Normalize the base path
35
- const normalizedBase = path.resolve(basePath)
40
+ export function safeResolvePath(
41
+ basePath: string,
42
+ userPath: string,
43
+ resolveSymlinks = true
44
+ ): string {
45
+ // Resolve the base path through symlinks if enabled
46
+ let normalizedBase: string
47
+ try {
48
+ normalizedBase = resolveSymlinks
49
+ ? fs.realpathSync(basePath)
50
+ : path.resolve(basePath)
51
+ } catch (error: unknown) {
52
+ // If basePath doesn't exist (ENOENT), fall back to path.resolve
53
+ // Rethrow other errors (e.g., permission issues) as they indicate real problems
54
+ const nodeError = error as NodeJS.ErrnoException
55
+ if (nodeError?.code === "ENOENT") {
56
+ normalizedBase = path.resolve(basePath)
57
+ } else {
58
+ throw error
59
+ }
60
+ }
61
+
62
+ // For paths that may not exist yet, validate the parent directory
63
+ const absolutePath = path.resolve(basePath, userPath)
64
+ const parentDir = path.dirname(absolutePath)
65
+ const fileName = path.basename(absolutePath)
66
+
67
+ // Resolve parent directory through symlinks if it exists and symlink resolution is enabled
68
+ let resolvedParent: string
69
+ try {
70
+ resolvedParent = resolveSymlinks
71
+ ? fs.realpathSync(parentDir)
72
+ : path.resolve(parentDir)
73
+ } catch (error: unknown) {
74
+ // If parent doesn't exist (ENOENT), validate the absolute path as-is
75
+ // Rethrow other errors (e.g., permission issues) as they indicate real problems
76
+ const nodeError = error as NodeJS.ErrnoException
77
+ if (nodeError?.code === "ENOENT") {
78
+ if (
79
+ !absolutePath.startsWith(normalizedBase + path.sep) &&
80
+ absolutePath !== normalizedBase
81
+ ) {
82
+ throw new Error(
83
+ `[SHAKAPACKER SECURITY] Path traversal attempt detected.\n` +
84
+ `Requested path would resolve outside of allowed directory.\n` +
85
+ `Base: ${normalizedBase}\n` +
86
+ `Attempted: ${userPath}\n` +
87
+ `Resolved to: ${absolutePath}`
88
+ )
89
+ }
90
+ return absolutePath
91
+ }
92
+ throw error
93
+ }
36
94
 
37
- // Resolve the user path relative to base
38
- const resolved = path.resolve(normalizedBase, userPath)
95
+ // Reconstruct the full path with the resolved (symlink-free) parent
96
+ const resolved = path.resolve(resolvedParent, fileName)
39
97
 
40
98
  // Ensure the resolved path is within the base directory
41
99
  if (
42
100
  !resolved.startsWith(normalizedBase + path.sep) &&
43
101
  resolved !== normalizedBase
44
102
  ) {
103
+ const symlinkNote = resolveSymlinks
104
+ ? ` (symlink-resolved from ${userPath})`
105
+ : ""
45
106
  throw new Error(
46
107
  `[SHAKAPACKER SECURITY] Path traversal attempt detected.\n` +
47
108
  `Requested path would resolve outside of allowed directory.\n` +
48
109
  `Base: ${normalizedBase}\n` +
49
110
  `Attempted: ${userPath}\n` +
50
- `Resolved to: ${resolved}`
111
+ `Resolved to: ${resolved}${symlinkNote}`
51
112
  )
52
113
  }
53
114
 
@@ -2,13 +2,21 @@
2
2
  /* eslint import/no-dynamic-require: 0 */
3
3
  const config = require("../config")
4
4
 
5
+ interface ErrorWithCause extends Error {
6
+ cause?: unknown
7
+ }
8
+
5
9
  const requireOrError = (moduleName: string): any => {
6
10
  try {
7
11
  return require(moduleName)
8
- } catch (error) {
9
- throw new Error(
12
+ } catch (originalError: unknown) {
13
+ const error: ErrorWithCause = new Error(
10
14
  `[SHAKAPACKER]: ${moduleName} is required for ${config.assets_bundler} but is not installed. View Shakapacker's documented dependencies at https://github.com/shakacode/shakapacker/tree/main/docs/peer-dependencies.md`
11
15
  )
16
+ // Add the original error as the cause for better debugging (ES2022+)
17
+ // Using custom interface since target is ES2020 but runtime supports it
18
+ error.cause = originalError
19
+ throw error
12
20
  }
13
21
  }
14
22
 
@@ -66,6 +66,38 @@ export function clearValidationCache(): void {
66
66
  }
67
67
  }
68
68
 
69
+ /**
70
+ * Type guard to validate DevServerConfig object at runtime
71
+ * In production, performs minimal validation for performance
72
+ */
73
+ export function isValidDevServerConfig(obj: unknown): obj is DevServerConfig {
74
+ if (typeof obj !== "object" || obj === null) {
75
+ return false
76
+ }
77
+
78
+ // In production, skip deep validation unless explicitly enabled
79
+ if (!shouldValidate()) {
80
+ return true
81
+ }
82
+
83
+ const config = obj as Record<string, unknown>
84
+
85
+ // All fields are optional, just check types if present
86
+ if (
87
+ config.hmr !== undefined &&
88
+ typeof config.hmr !== "boolean" &&
89
+ config.hmr !== "only"
90
+ ) {
91
+ return false
92
+ }
93
+
94
+ if (config.port !== undefined && !validatePort(config.port)) {
95
+ return false
96
+ }
97
+
98
+ return true
99
+ }
100
+
69
101
  /**
70
102
  * Type guard to validate Config object at runtime
71
103
  * In production, caches results for performance unless SHAKAPACKER_STRICT_VALIDATION is set
@@ -79,7 +111,7 @@ export function isValidConfig(obj: unknown): obj is Config {
79
111
  }
80
112
 
81
113
  // Check cache with TTL
82
- const cached = validatedConfigs.get(obj as object)
114
+ const cached = validatedConfigs.get(obj)
83
115
  if (cached && Date.now() - cached.timestamp < getCacheTTL()) {
84
116
  if (debugCache) {
85
117
  console.log(
@@ -104,7 +136,7 @@ export function isValidConfig(obj: unknown): obj is Config {
104
136
  for (const field of requiredStringFields) {
105
137
  if (typeof config[field] !== "string") {
106
138
  // Cache negative result
107
- validatedConfigs.set(obj as object, {
139
+ validatedConfigs.set(obj, {
108
140
  result: false,
109
141
  timestamp: Date.now()
110
142
  })
@@ -112,14 +144,11 @@ export function isValidConfig(obj: unknown): obj is Config {
112
144
  }
113
145
  // SECURITY: Path traversal validation ALWAYS runs (not subject to shouldValidate)
114
146
  // This ensures paths are safe regardless of environment or validation mode
115
- if (
116
- field.includes("path") &&
117
- !isPathTraversalSafe(config[field] as string)
118
- ) {
147
+ if (field.includes("path") && !isPathTraversalSafe(config[field])) {
119
148
  console.warn(
120
149
  `[SHAKAPACKER SECURITY] Invalid path in ${field}: ${config[field]}`
121
150
  )
122
- validatedConfigs.set(obj as object, {
151
+ validatedConfigs.set(obj, {
123
152
  result: false,
124
153
  timestamp: Date.now()
125
154
  })
@@ -142,7 +171,7 @@ export function isValidConfig(obj: unknown): obj is Config {
142
171
  for (const field of requiredBooleanFields) {
143
172
  if (typeof config[field] !== "boolean") {
144
173
  // Cache negative result
145
- validatedConfigs.set(obj as object, {
174
+ validatedConfigs.set(obj, {
146
175
  result: false,
147
176
  timestamp: Date.now()
148
177
  })
@@ -153,7 +182,7 @@ export function isValidConfig(obj: unknown): obj is Config {
153
182
  // Check arrays
154
183
  if (!Array.isArray(config.additional_paths)) {
155
184
  // Cache negative result
156
- validatedConfigs.set(obj as object, {
185
+ validatedConfigs.set(obj, {
157
186
  result: false,
158
187
  timestamp: Date.now()
159
188
  })
@@ -167,7 +196,7 @@ export function isValidConfig(obj: unknown): obj is Config {
167
196
  console.warn(
168
197
  `[SHAKAPACKER SECURITY] Invalid additional_path: ${additionalPath}`
169
198
  )
170
- validatedConfigs.set(obj as object, {
199
+ validatedConfigs.set(obj, {
171
200
  result: false,
172
201
  timestamp: Date.now()
173
202
  })
@@ -179,7 +208,7 @@ export function isValidConfig(obj: unknown): obj is Config {
179
208
  // Security checks above still run regardless of this flag
180
209
  if (!shouldValidate()) {
181
210
  // Cache positive result - basic structure and security validated
182
- validatedConfigs.set(obj as object, { result: true, timestamp: Date.now() })
211
+ validatedConfigs.set(obj, { result: true, timestamp: Date.now() })
183
212
  return true
184
213
  }
185
214
 
@@ -189,7 +218,7 @@ export function isValidConfig(obj: unknown): obj is Config {
189
218
  !isValidDevServerConfig(config.dev_server)
190
219
  ) {
191
220
  // Cache negative result
192
- validatedConfigs.set(obj as object, {
221
+ validatedConfigs.set(obj, {
193
222
  result: false,
194
223
  timestamp: Date.now()
195
224
  })
@@ -203,7 +232,7 @@ export function isValidConfig(obj: unknown): obj is Config {
203
232
  typeof integrity.cross_origin !== "string"
204
233
  ) {
205
234
  // Cache negative result
206
- validatedConfigs.set(obj as object, {
235
+ validatedConfigs.set(obj, {
207
236
  result: false,
208
237
  timestamp: Date.now()
209
238
  })
@@ -212,39 +241,7 @@ export function isValidConfig(obj: unknown): obj is Config {
212
241
  }
213
242
 
214
243
  // Cache positive result
215
- validatedConfigs.set(obj as object, { result: true, timestamp: Date.now() })
216
-
217
- return true
218
- }
219
-
220
- /**
221
- * Type guard to validate DevServerConfig object at runtime
222
- * In production, performs minimal validation for performance
223
- */
224
- export function isValidDevServerConfig(obj: unknown): obj is DevServerConfig {
225
- if (typeof obj !== "object" || obj === null) {
226
- return false
227
- }
228
-
229
- // In production, skip deep validation unless explicitly enabled
230
- if (!shouldValidate()) {
231
- return true
232
- }
233
-
234
- const config = obj as Record<string, unknown>
235
-
236
- // All fields are optional, just check types if present
237
- if (
238
- config.hmr !== undefined &&
239
- typeof config.hmr !== "boolean" &&
240
- config.hmr !== "only"
241
- ) {
242
- return false
243
- }
244
-
245
- if (config.port !== undefined && !validatePort(config.port)) {
246
- return false
247
- }
244
+ validatedConfigs.set(obj, { result: true, timestamp: Date.now() })
248
245
 
249
246
  return true
250
247
  }
@@ -1,4 +1,4 @@
1
- // @ts-ignore: webpack is an optional peer dependency (using type-only import)
1
+ // @ts-expect-error: webpack is an optional peer dependency (using type-only import)
2
2
  import type { Configuration, RuleSetRule, RuleSetUseItem } from "webpack"
3
3
 
4
4
  export interface ShakapackerWebpackConfig extends Configuration {
@@ -13,7 +13,7 @@ export interface ShakapackerRule extends RuleSetRule {
13
13
  }
14
14
 
15
15
  export interface ShakapackerLoaderOptions {
16
- [key: string]: any
16
+ [key: string]: unknown
17
17
  }
18
18
 
19
19
  export interface ShakapackerLoader {
@@ -1,4 +1,5 @@
1
1
  import { DevServerConfig } from "./types"
2
+
2
3
  const snakeToCamelCase = require("./utils/snakeToCamelCase")
3
4
 
4
5
  const shakapackerDevServerYamlConfig =
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shakapacker",
3
- "version": "9.3.0-beta.7",
3
+ "version": "9.3.0",
4
4
  "description": "Use webpack to manage app-like JavaScript modules in Rails",
5
5
  "homepage": "https://github.com/shakacode/shakapacker",
6
6
  "bugs": {
@@ -74,7 +74,6 @@
74
74
  "eslint-plugin-import": "^2.32.0",
75
75
  "eslint-plugin-jest": "^29.0.1",
76
76
  "eslint-plugin-jsx-a11y": "^6.10.2",
77
- "eslint-plugin-prettier": "^5.5.4",
78
77
  "eslint-plugin-react": "^7.37.5",
79
78
  "eslint-plugin-react-hooks": "^7.0.0",
80
79
  "husky": "^9.1.7",
@@ -109,7 +108,7 @@
109
108
  "babel-loader": "^8.2.4 || ^9.0.0 || ^10.0.0",
110
109
  "compression-webpack-plugin": "^9.0.0 || ^10.0.0 || ^11.0.0",
111
110
  "css-loader": "^6.8.1 || ^7.0.0",
112
- "esbuild": "^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0 || ^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0",
111
+ "esbuild": "^0.14.0 || ^0.15.0 || ^0.16.0 || ^0.17.0 || ^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0",
113
112
  "esbuild-loader": "^2.0.0 || ^3.0.0 || ^4.0.0",
114
113
  "mini-css-extract-plugin": "^2.0.0",
115
114
  "rspack-manifest-plugin": "^5.0.0",