@salesforce/source-deploy-retrieve
Version:
JavaScript library to run Salesforce metadata deploys and retrieves
460 lines • 23.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.isWebApplicationConfig = isWebApplicationConfig;
exports.validateWebApplicationJson = validateWebApplicationJson;
/*
* Copyright 2026, Salesforce, Inc.
*
* 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.
*/
const node_path_1 = require("node:path");
const messages_1 = require("@salesforce/core/messages");
const sfError_1 = require("@salesforce/core/sfError");
;
const msgs = new messages_1.Messages('@salesforce/source-deploy-retrieve', 'webApplicationValidation', new Map([["webapp_empty_file", "webapplication.json must not be empty (%s)."], ["webapp_empty_file.actions", "Add at least one property, e.g. { \"outputDir\": \"dist\" }"], ["webapp_size_exceeded", "webapplication.json exceeds the maximum allowed size of %s KB (actual: %s KB)."], ["webapp_size_exceeded.actions", "Reduce the file size. The descriptor should only contain configuration, not static content."], ["webapp_whitespace_only", "webapplication.json must not be empty or contain only whitespace (%s)."], ["webapp_whitespace_only.actions", "Replace the whitespace with valid JSON, e.g. { \"outputDir\": \"dist\" }"], ["webapp_invalid_json", "webapplication.json is not valid JSON: %s"], ["webapp_invalid_json.actions", "Fix the JSON syntax in webapplication.json. Use a JSON validator to find the exact issue."], ["webapp_not_object", "webapplication.json must be a JSON object, but found %s."], ["webapp_not_object.actions", "Wrap the content in curly braces, e.g. { \"outputDir\": \"dist\" }"], ["webapp_empty_object", "webapplication.json must contain at least one property."], ["webapp_empty_object.actions", "Add a property: outputDir, routing, or headers."], ["webapp_unknown_props", "webapplication.json contains unknown %s: %s. Allowed: %s."], ["webapp_type_mismatch", "webapplication.json '%s' must be %s (received %s)."], ["webapp_empty_value", "webapplication.json '%s' must not be empty."], ["webapp_invalid_enum", "webapplication.json '%s' must be one of: %s (received \"%s\")."], ["webapp_min_items", "webapplication.json '%s' must contain at least one %s."], ["webapp_unknown_prop", "webapplication.json '%s' contains unknown property '%s'. Allowed: %s."], ["webapp_non_empty_string", "webapplication.json '%s' must be a non-empty string."], ["webapp_path_unsafe", "webapplication.json '%s' value \"%s\" contains %s. Config paths must use forward slashes."], ["webapp_path_traversal", "webapplication.json '%s' value \"%s\" resolves outside the application bundle. Path traversal is not allowed."], ["webapp_outputdir_is_root", "webapplication.json 'outputDir' value \"%s\" resolves to the bundle root. It must reference a subdirectory."], ["webapp_outputdir_is_root.actions", "Set outputDir to a subdirectory like \"dist\" or \"build\"."], ["webapp_dir_not_found", "webapplication.json 'outputDir' references \"%s\", but the directory does not exist at %s."], ["webapp_dir_empty", "webapplication.json 'outputDir' (\"%s\") exists but contains no files. It must contain at least one deployable file."], ["webapp_file_not_found", "webapplication.json '%s' references \"%s\", but the file does not exist at %s."]]));
/** Basic shape check — use after field-level validation to narrow the type. */
function isWebApplicationConfig(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
// Keep in sync with server-side validation (WebApplicationFileProcessor.java).
const ALLOWED_TOP_LEVEL = new Set(['outputDir', 'routing', 'headers']);
const ROUTING_ALLOWED = new Set(['rewrites', 'redirects', 'fallback', 'trailingSlash', 'fileBasedRouting']);
const TRAILING_SLASH_VALUES = new Set(['always', 'never', 'auto']);
const REDIRECT_STATUS_CODES = new Set([301, 302, 307, 308]);
const MAX_RECURSION_DEPTH = 20;
const MAX_WEBAPPLICATION_JSON_BYTES = 102_400; // 100 KB
// Matches ".." as a standalone path segment.
const DOT_DOT_SEGMENT = /(?:^|[/\\])\.\.[/\\]|(?:^|[/\\])\.\.$/;
/** Strip leading separators so "/index.html" resolves relative to outputDir. */
function stripLeadingSep(p) {
return p.replace(new RegExp(`^[${node_path_1.sep.replace(/\\/g, '\\\\')}/]+`), '');
}
/** Returns a reason string if the path contains unsafe patterns, undefined otherwise. */
function containsPathTraversal(value) {
if (DOT_DOT_SEGMENT.test(value)) {
return 'path traversal (..)';
}
if (value === '..') {
return 'path traversal (..)';
}
if (value.startsWith('/') || value.startsWith('\\')) {
return 'absolute path';
}
if (value.includes('\0')) {
return 'null byte';
}
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) < 0x20) {
return 'control character';
}
}
if (value.includes('*') || value.includes('?')) {
return 'glob wildcard';
}
if (value.includes('\\')) {
return 'backslash (use forward slashes)';
}
if (value.includes('%')) {
return 'percent-encoding';
}
return undefined;
}
/** Throws if the path looks unsafe (traversal, absolute, special chars, etc.). */
function assertSafePath(value, configKey) {
const reason = containsPathTraversal(value);
if (reason) {
throw createConfigError(msgs.getMessage('webapp_path_unsafe', [configKey, value, reason]), [
`Fix "${value}": use relative paths with forward slashes only, no special characters.`,
]);
}
}
/** Like typeof, but returns "null" or "array" when appropriate. */
function describeType(value) {
if (value === null) {
return 'null';
}
if (Array.isArray(value)) {
return 'array';
}
return typeof value;
}
function createConfigError(message, actions) {
return new sfError_1.SfError(message, 'InvalidWebApplicationConfigError', actions);
}
function createFileError(message, actions) {
return new sfError_1.SfError(message, 'ExpectedSourceFilesError', actions);
}
/** Validate webapplication.json contents. Checks structure first, then schema, then file existence. */
function validateWebApplicationJson(raw, descriptorPath, contentPath, tree) {
if (!raw || raw.length === 0) {
throw createConfigError(msgs.getMessage('webapp_empty_file', [descriptorPath]), [
msgs.getMessage('webapp_empty_file.actions'),
]);
}
if (raw.length > MAX_WEBAPPLICATION_JSON_BYTES) {
throw createConfigError(msgs.getMessage('webapp_size_exceeded', [
String(MAX_WEBAPPLICATION_JSON_BYTES / 1024),
(raw.length / 1024).toFixed(1),
]), [msgs.getMessage('webapp_size_exceeded.actions')]);
}
const trimmed = raw.toString('utf8').trim();
if (trimmed.length === 0) {
throw createConfigError(msgs.getMessage('webapp_whitespace_only', [descriptorPath]), [
msgs.getMessage('webapp_whitespace_only.actions'),
]);
}
let config;
try {
config = JSON.parse(trimmed);
}
catch (e) {
const detail = e instanceof Error ? e.message : String(e);
throw createConfigError(msgs.getMessage('webapp_invalid_json', [detail]), [
msgs.getMessage('webapp_invalid_json.actions'),
]);
}
if (!isWebApplicationConfig(config)) {
throw createConfigError(msgs.getMessage('webapp_not_object', [describeType(config)]), [
msgs.getMessage('webapp_not_object.actions'),
]);
}
const rawObj = config;
const keys = Object.keys(rawObj);
if (keys.length === 0) {
throw createConfigError(msgs.getMessage('webapp_empty_object'), [msgs.getMessage('webapp_empty_object.actions')]);
}
// Report all unknown properties at once.
const disallowed = keys.filter((k) => !ALLOWED_TOP_LEVEL.has(k));
if (disallowed.length > 0) {
const list = disallowed.map((k) => `'${k}'`).join(', ');
const word = disallowed.length === 1 ? 'property' : 'properties';
throw createConfigError(msgs.getMessage('webapp_unknown_props', [word, list, 'outputDir, routing, headers']), [
`Remove ${list} from webapplication.json.`,
]);
}
const outputDir = rawObj.outputDir !== undefined ? validateOutputDir(rawObj.outputDir) : undefined;
if (rawObj.routing !== undefined) {
validateRouting(rawObj.routing);
}
if (rawObj.headers !== undefined) {
validateHeaders(rawObj.headers);
}
// Safe to cast after field-level checks pass.
const obj = rawObj;
if (outputDir ?? obj.routing) {
validateFileExistence(obj, outputDir, contentPath, tree);
}
}
function validateOutputDir(value) {
if (typeof value !== 'string') {
throw createConfigError(msgs.getMessage('webapp_type_mismatch', ['outputDir', 'a string', describeType(value)]), [
'Set outputDir to a directory path like "dist" or "build".',
]);
}
if (value.length === 0) {
throw createConfigError(msgs.getMessage('webapp_empty_value', ['outputDir']), [
'Provide a directory name, e.g. "dist".',
]);
}
assertSafePath(value, 'outputDir');
return value;
}
function validateRouting(value) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw createConfigError(msgs.getMessage('webapp_type_mismatch', ['routing', 'an object', describeType(value)]), [
'Set routing to an object, e.g. { "trailingSlash": "auto" }.',
]);
}
const routing = value;
const routingKeys = Object.keys(routing);
if (routingKeys.length === 0) {
throw createConfigError(msgs.getMessage('webapp_min_items', ['routing', 'property']), [
'Add a routing property: rewrites, redirects, fallback, trailingSlash, or fileBasedRouting.',
]);
}
const unknownRouting = routingKeys.filter((k) => !ROUTING_ALLOWED.has(k));
if (unknownRouting.length > 0) {
const list = unknownRouting.map((k) => `'${k}'`).join(', ');
const word = unknownRouting.length === 1 ? 'property' : 'properties';
throw createConfigError(msgs.getMessage('webapp_unknown_props', [
word,
list,
'rewrites, redirects, fallback, trailingSlash, fileBasedRouting',
]), [`Remove ${list} from routing.`]);
}
if (routing.trailingSlash !== undefined) {
validateTrailingSlash(routing.trailingSlash);
}
if (routing.fileBasedRouting !== undefined && typeof routing.fileBasedRouting !== 'boolean') {
throw createConfigError(msgs.getMessage('webapp_type_mismatch', [
'routing.fileBasedRouting',
'a boolean',
describeType(routing.fileBasedRouting),
]));
}
if (routing.fallback !== undefined) {
validateFallback(routing.fallback);
}
if (routing.rewrites !== undefined) {
validateRewritesList(routing.rewrites);
}
if (routing.redirects !== undefined) {
validateRedirectsList(routing.redirects);
}
}
function validateTrailingSlash(value) {
if (typeof value !== 'string') {
throw createConfigError(msgs.getMessage('webapp_type_mismatch', ['routing.trailingSlash', 'a string', describeType(value)]));
}
if (!TRAILING_SLASH_VALUES.has(value)) {
throw createConfigError(msgs.getMessage('webapp_invalid_enum', ['routing.trailingSlash', 'always, never, auto', value]));
}
}
function validateFallback(value) {
if (typeof value !== 'string') {
throw createConfigError(msgs.getMessage('webapp_type_mismatch', ['routing.fallback', 'a string', describeType(value)]));
}
if (value.length === 0) {
throw createConfigError(msgs.getMessage('webapp_empty_value', ['routing.fallback']), [
'Provide a file path like "index.html".',
]);
}
assertSafePath(value, 'routing.fallback');
}
function validateRewritesList(value) {
if (!Array.isArray(value)) {
throw createConfigError(msgs.getMessage('webapp_type_mismatch', ['routing.rewrites', 'an array', describeType(value)]));
}
if (value.length === 0) {
throw createConfigError(msgs.getMessage('webapp_min_items', ['routing.rewrites', 'item']), [
'Add a rewrite entry or remove the empty rewrites array.',
]);
}
for (let i = 0; i < value.length; i++) {
validateRewriteItem(value[i], i);
}
}
function validateRedirectsList(value) {
if (!Array.isArray(value)) {
throw createConfigError(msgs.getMessage('webapp_type_mismatch', ['routing.redirects', 'an array', describeType(value)]));
}
if (value.length === 0) {
throw createConfigError(msgs.getMessage('webapp_min_items', ['routing.redirects', 'item']), [
'Add a redirect entry or remove the empty redirects array.',
]);
}
for (let i = 0; i < value.length; i++) {
validateRedirectItem(value[i], i);
}
}
function validateHeaders(value) {
if (!Array.isArray(value)) {
throw createConfigError(msgs.getMessage('webapp_type_mismatch', ['headers', 'an array', describeType(value)]));
}
if (value.length === 0) {
throw createConfigError(msgs.getMessage('webapp_min_items', ['headers', 'item']), [
'Add a header entry or remove the empty headers array.',
]);
}
for (let i = 0; i < value.length; i++) {
validateHeaderItem(value[i], i);
}
}
function validateRewriteItem(item, i) {
const key = `routing.rewrites[${i}]`;
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
throw createConfigError(msgs.getMessage('webapp_type_mismatch', [key, 'an object', describeType(item)]));
}
const obj = item;
if (Object.keys(obj).length === 0) {
throw createConfigError(msgs.getMessage('webapp_min_items', [key, 'property']), [
'Add route and/or rewrite to this entry.',
]);
}
const unknown = Object.keys(obj).filter((k) => k !== 'route' && k !== 'rewrite');
if (unknown.length > 0) {
throw createConfigError(msgs.getMessage('webapp_unknown_prop', [key, unknown[0], 'route, rewrite']));
}
if (obj.route !== undefined && (typeof obj.route !== 'string' || obj.route.length === 0)) {
throw createConfigError(msgs.getMessage('webapp_non_empty_string', [`${key}.route`]));
}
if (obj.rewrite !== undefined) {
if (typeof obj.rewrite !== 'string' || obj.rewrite.length === 0) {
throw createConfigError(msgs.getMessage('webapp_non_empty_string', [`${key}.rewrite`]));
}
assertSafePath(obj.rewrite, `${key}.rewrite`);
}
}
function validateRedirectItem(item, i) {
const key = `routing.redirects[${i}]`;
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
throw createConfigError(msgs.getMessage('webapp_type_mismatch', [key, 'an object', describeType(item)]));
}
const obj = item;
if (Object.keys(obj).length === 0) {
throw createConfigError(msgs.getMessage('webapp_min_items', [key, 'property']), [
'Add route, redirect, and/or statusCode to this entry.',
]);
}
const unknown = Object.keys(obj).filter((k) => k !== 'route' && k !== 'redirect' && k !== 'statusCode');
if (unknown.length > 0) {
throw createConfigError(msgs.getMessage('webapp_unknown_prop', [key, unknown[0], 'route, redirect, statusCode']));
}
if (obj.route !== undefined && (typeof obj.route !== 'string' || obj.route.length === 0)) {
throw createConfigError(msgs.getMessage('webapp_non_empty_string', [`${key}.route`]));
}
if (obj.redirect !== undefined && (typeof obj.redirect !== 'string' || obj.redirect.length === 0)) {
throw createConfigError(msgs.getMessage('webapp_non_empty_string', [`${key}.redirect`]));
}
if (obj.statusCode !== undefined) {
if (!Number.isInteger(obj.statusCode) || !REDIRECT_STATUS_CODES.has(obj.statusCode)) {
throw createConfigError(msgs.getMessage('webapp_invalid_enum', [
`${key}.statusCode`,
'301, 302, 307, 308',
JSON.stringify(obj.statusCode),
]));
}
}
}
function validateHeaderItem(item, i) {
const key = `headers[${i}]`;
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
throw createConfigError(msgs.getMessage('webapp_type_mismatch', [key, 'an object', describeType(item)]));
}
const obj = item;
if (Object.keys(obj).length === 0) {
throw createConfigError(msgs.getMessage('webapp_min_items', [key, 'property']), [
'Add source and/or headers to this entry.',
]);
}
const unknown = Object.keys(obj).filter((k) => k !== 'source' && k !== 'headers');
if (unknown.length > 0) {
throw createConfigError(msgs.getMessage('webapp_unknown_prop', [key, unknown[0], 'source, headers']));
}
if (obj.source !== undefined && (typeof obj.source !== 'string' || obj.source.length === 0)) {
throw createConfigError(msgs.getMessage('webapp_non_empty_string', [`${key}.source`]));
}
if (obj.headers !== undefined) {
if (!Array.isArray(obj.headers)) {
throw createConfigError(msgs.getMessage('webapp_type_mismatch', [`${key}.headers`, 'an array', describeType(obj.headers)]));
}
if (obj.headers.length === 0) {
throw createConfigError(msgs.getMessage('webapp_min_items', [`${key}.headers`, 'item']), [
'Add a { "key": "...", "value": "..." } entry or remove the empty array.',
]);
}
for (let j = 0; j < obj.headers.length; j++) {
validateHeaderKeyValue(obj.headers[j], i, j);
}
}
}
function validateHeaderKeyValue(item, i, j) {
const key = `headers[${i}].headers[${j}]`;
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
throw createConfigError(msgs.getMessage('webapp_type_mismatch', [key, 'an object', describeType(item)]));
}
const obj = item;
if (Object.keys(obj).length === 0) {
throw createConfigError(msgs.getMessage('webapp_min_items', [key, 'property']), [
'Add key and/or value to this header entry.',
]);
}
const unknown = Object.keys(obj).filter((k) => k !== 'key' && k !== 'value');
if (unknown.length > 0) {
throw createConfigError(msgs.getMessage('webapp_unknown_prop', [key, unknown[0], 'key, value']));
}
if (obj.key !== undefined && (typeof obj.key !== 'string' || obj.key.length === 0)) {
throw createConfigError(msgs.getMessage('webapp_non_empty_string', [`${key}.key`]));
}
if (obj.value !== undefined && (typeof obj.value !== 'string' || obj.value.length === 0)) {
throw createConfigError(msgs.getMessage('webapp_non_empty_string', [`${key}.value`]));
}
}
/** Throws if the resolved path lands outside the parent directory. */
function assertNoTraversal(resolvedPath, parentDir, configKey, rawValue) {
const normalizedResolved = (0, node_path_1.resolve)(resolvedPath);
const normalizedParent = (0, node_path_1.resolve)(parentDir) + node_path_1.sep;
if (!normalizedResolved.startsWith(normalizedParent) && normalizedResolved !== (0, node_path_1.resolve)(parentDir)) {
throw createConfigError(msgs.getMessage('webapp_path_traversal', [configKey, rawValue]), [
`Remove ".." segments from "${rawValue}". Paths must stay within the bundle directory.`,
]);
}
}
/** Verify that referenced paths (outputDir, fallback, rewrite targets) actually exist. */
function validateFileExistence(obj, outputDir, contentPath, tree) {
let basePath = contentPath;
if (outputDir) {
const outputDirPath = (0, node_path_1.join)(contentPath, outputDir);
assertNoTraversal(outputDirPath, contentPath, 'outputDir', outputDir);
if ((0, node_path_1.resolve)(outputDirPath) === (0, node_path_1.resolve)(contentPath)) {
throw createConfigError(msgs.getMessage('webapp_outputdir_is_root', [outputDir]), [
msgs.getMessage('webapp_outputdir_is_root.actions'),
]);
}
if (!tree.exists(outputDirPath) || !tree.isDirectory(outputDirPath)) {
throw createFileError(msgs.getMessage('webapp_dir_not_found', [outputDir, outputDirPath]), [
`Create the directory "${outputDir}" in your web application bundle, or change outputDir to an existing directory.`,
]);
}
const hasFileUnder = (dirPath, depth = 0) => {
if (depth >= MAX_RECURSION_DEPTH) {
return false;
}
const entries = tree.readDirectory(dirPath);
for (const entry of entries) {
const fullPath = (0, node_path_1.join)(dirPath, entry);
if (tree.exists(fullPath)) {
if (tree.isDirectory(fullPath)) {
if (hasFileUnder(fullPath, depth + 1)) {
return true;
}
}
else {
return true;
}
}
}
return false;
};
if (!hasFileUnder(outputDirPath)) {
throw createFileError(msgs.getMessage('webapp_dir_empty', [outputDir]), [
`Add files to "${outputDir}", e.g. an index.html.`,
]);
}
basePath = outputDirPath;
}
const baseLabel = outputDir ?? 'bundle root';
const { routing } = obj;
if (routing?.fallback) {
const stripped = stripLeadingSep(routing.fallback);
const fallbackPath = (0, node_path_1.join)(basePath, stripped);
assertNoTraversal(fallbackPath, basePath, 'routing.fallback', routing.fallback);
if (!tree.exists(fallbackPath)) {
throw createFileError(msgs.getMessage('webapp_file_not_found', ['routing.fallback', routing.fallback, fallbackPath]), [`Create the file "${stripped}" inside "${baseLabel}", or update the fallback path.`]);
}
}
if (routing?.rewrites) {
for (let i = 0; i < routing.rewrites.length; i++) {
const target = routing.rewrites[i].rewrite;
if (target) {
const stripped = stripLeadingSep(target);
const rewritePath = (0, node_path_1.join)(basePath, stripped);
assertNoTraversal(rewritePath, basePath, `routing.rewrites[${i}].rewrite`, target);
if (!tree.exists(rewritePath)) {
throw createFileError(msgs.getMessage('webapp_file_not_found', [`routing.rewrites[${i}].rewrite`, target, rewritePath]), [`Create the file "${stripped}" inside "${baseLabel}", or update the rewrite path.`]);
}
}
}
}
}
//# sourceMappingURL=webApplicationValidation.js.map