s3-key-validator
Version:
AWS S3 object key validation library for TypeScript
536 lines (525 loc) • 14.8 kB
JavaScript
// src/core/result.ts
var ValidationResultBuilder = class {
constructor() {
this.errors = [];
this.warnings = [];
this.suggestions = [];
}
addError(error) {
this.errors.push(error);
return this;
}
addErrors(errors) {
this.errors.push(...errors);
return this;
}
addWarning(warning) {
this.warnings.push(warning);
return this;
}
addWarnings(warnings) {
this.warnings.push(...warnings);
return this;
}
addSuggestion(suggestion) {
this.suggestions.push(suggestion);
return this;
}
addSuggestions(suggestions) {
this.suggestions.push(...suggestions);
return this;
}
build() {
return {
isValid: this.errors.length === 0,
errors: this.errors,
warnings: this.warnings.length > 0 ? this.warnings : void 0,
suggestions: this.suggestions.length > 0 ? this.suggestions : void 0
};
}
static createValid() {
return {
isValid: true,
errors: []
};
}
static createInvalid(errors) {
return {
isValid: false,
errors
};
}
};
// src/validators/character.ts
var CharacterValidator = class {
static validate(key, options) {
const errors = [];
const warnings = [];
for (let i = 0; i < key.length; i++) {
const char = key[i];
const result = this.validateCharacter(char, i, options);
if (result.error) {
errors.push(result.error);
}
if (result.warning) {
warnings.push(result.warning);
}
}
return { errors, warnings };
}
static validateCharacter(char, position, options) {
const charCode = char.charCodeAt(0);
if (this.isSafeCharacter(char)) {
return {};
}
if (this.isForbiddenCharacter(char)) {
return {
error: {
type: "CHARACTER",
message: `Forbidden character '${char}' at position ${position}`,
position,
character: char
}
};
}
if (this.isSpecialCharacter(char)) {
const allowed = this.isSpecialCharacterAllowed(char, options);
if (!allowed) {
return {
error: {
type: "CHARACTER",
message: `Special character '${char}' not allowed at position ${position}`,
position,
character: char
}
};
}
return {
warning: {
type: "COMPATIBILITY",
message: `Special character '${char}' may cause compatibility issues`
}
};
}
if (this.isLanguageCharacter(charCode)) {
const allowed = this.isLanguageCharacterAllowed(charCode, options);
if (!allowed) {
return {
error: {
type: "CHARACTER",
message: `Multi-byte character '${char}' not allowed at position ${position}`,
position,
character: char
}
};
}
return {
warning: {
type: "ENCODING",
message: "Non-ASCII characters may cause compatibility issues"
}
};
}
if (options.additionalChars?.includes(char)) {
return {};
}
return {
error: {
type: "CHARACTER",
message: `Invalid character '${char}' at position ${position}`,
position,
character: char
}
};
}
static isSafeCharacter(char) {
return /^[0-9a-zA-Z!\-_.*'()]$/.test(char);
}
static isForbiddenCharacter(char) {
return /^[\\{}^%`\]">~<#|]$/.test(char);
}
static isSpecialCharacter(char) {
return Object.values(this.SPECIAL_CHARS).includes(char);
}
static isSpecialCharacterAllowed(char, options) {
const specialChars = options.specialChars || {};
switch (char) {
case this.SPECIAL_CHARS.slash:
return specialChars.allowSlash || false;
case this.SPECIAL_CHARS.colon:
return specialChars.allowColon || false;
case this.SPECIAL_CHARS.space:
return specialChars.allowSpace || false;
case this.SPECIAL_CHARS.at:
return specialChars.allowAt || false;
case this.SPECIAL_CHARS.ampersand:
return specialChars.allowAmpersand || false;
case this.SPECIAL_CHARS.dollar:
return specialChars.allowDollar || false;
default:
return false;
}
}
static isLanguageCharacter(charCode) {
const ranges = this.UNICODE_RANGES;
return charCode >= ranges.hiragana[0] && charCode <= ranges.hiragana[1] || charCode >= ranges.katakana[0] && charCode <= ranges.katakana[1] || charCode >= ranges.cjkUnified[0] && charCode <= ranges.cjkUnified[1] || charCode >= ranges.hangul[0] && charCode <= ranges.hangul[1];
}
static isLanguageCharacterAllowed(charCode, options) {
const languages = options.languages || {};
const ranges = this.UNICODE_RANGES;
if (languages.allowCJK) {
return true;
}
if (languages.allowJapanese) {
if (charCode >= ranges.hiragana[0] && charCode <= ranges.hiragana[1] || charCode >= ranges.katakana[0] && charCode <= ranges.katakana[1] || charCode >= ranges.cjkUnified[0] && charCode <= ranges.cjkUnified[1]) {
return true;
}
}
if (languages.allowKorean) {
if (charCode >= ranges.hangul[0] && charCode <= ranges.hangul[1]) {
return true;
}
}
if (languages.allowChinese) {
if (charCode >= ranges.cjkUnified[0] && charCode <= ranges.cjkUnified[1]) {
return true;
}
}
return false;
}
};
CharacterValidator.SAFE_CHARS = "0-9a-zA-Z!\\-_.*'()";
CharacterValidator.SPECIAL_CHARS = {
slash: "/",
colon: ":",
space: " ",
at: "@",
ampersand: "&",
dollar: "$"
};
CharacterValidator.FORBIDDEN_CHARS = '\\\\{}^%`]">~<#|';
CharacterValidator.UNICODE_RANGES = {
hiragana: [12352, 12447],
katakana: [12448, 12543],
cjkUnified: [19968, 40879],
hangul: [44032, 55215]
};
// src/validators/length.ts
var LengthValidator = class {
static validate(key, options) {
const errors = [];
const maxLength = options.maxLength || this.DEFAULT_MAX_LENGTH;
if (key.length === 0) {
errors.push({
type: "LENGTH",
message: "Key cannot be empty"
});
return errors;
}
const byteLength = this.getByteLength(key);
if (byteLength > maxLength) {
errors.push({
type: "LENGTH",
message: `Key exceeds maximum length of ${maxLength} bytes (current: ${byteLength} bytes)`
});
}
return errors;
}
static getByteLength(str) {
return new globalThis.TextEncoder().encode(str).length;
}
};
LengthValidator.DEFAULT_MAX_LENGTH = 1024;
// src/validators/encoding.ts
var EncodingValidator = class {
static validate(key) {
const errors = [];
if (!this.isValidUTF8(key)) {
errors.push({
type: "ENCODING",
message: "Key contains invalid UTF-8 sequences"
});
}
const controlCharErrors = this.checkControlCharacters(key);
errors.push(...controlCharErrors);
return errors;
}
static isValidUTF8(str) {
try {
const encoder = new globalThis.TextEncoder();
const decoder = new globalThis.TextDecoder("utf-8", { fatal: true });
const encoded = encoder.encode(str);
const decoded = decoder.decode(encoded);
return decoded === str;
} catch {
return false;
}
}
static checkControlCharacters(key) {
const errors = [];
for (let i = 0; i < key.length; i++) {
const char = key[i];
const charCode = char.charCodeAt(0);
if (this.isControlCharacter(charCode)) {
errors.push({
type: "ENCODING",
message: `Control character (U+${charCode.toString(16).toUpperCase().padStart(4, "0")}) at position ${i}`,
position: i,
character: char
});
}
}
return errors;
}
static isControlCharacter(charCode) {
return charCode >= 0 && charCode <= 31 || charCode >= 127 && charCode <= 159;
}
};
// src/validators/path.ts
var PathValidator = class {
static validate(key, options) {
const errors = [];
if (!options.allowDotPrefix && key.startsWith("./")) {
errors.push({
type: "PATH",
message: 'Keys starting with "./" are not allowed (console limitation)',
position: 0
});
}
if (!options.allowRelativePaths && key.includes("../")) {
const position = key.indexOf("../");
errors.push({
type: "PATH",
message: 'Relative path elements "../" are not allowed',
position
});
}
const consecutiveSlashErrors = this.checkConsecutiveSlashes(key);
errors.push(...consecutiveSlashErrors);
const trailingSlashErrors = this.checkTrailingSlashes(key);
errors.push(...trailingSlashErrors);
return errors;
}
static checkConsecutiveSlashes(key) {
const errors = [];
let position = key.indexOf("//");
while (position !== -1) {
errors.push({
type: "PATH",
message: 'Consecutive slashes "//" are not recommended',
position
});
position = key.indexOf("//", position + 1);
}
return errors;
}
static checkTrailingSlashes(key) {
const errors = [];
if (key.endsWith("/")) {
errors.push({
type: "PATH",
message: "Trailing slash is not recommended for object keys",
position: key.length - 1
});
}
return errors;
}
};
// src/presets/strict.ts
var strictPreset = {
mode: "strict",
specialChars: {
allowSlash: false,
allowColon: false,
allowSpace: false,
allowAt: false,
allowAmpersand: false,
allowDollar: false
},
languages: {
allowJapanese: false,
allowKorean: false,
allowChinese: false,
allowCJK: false
},
additionalChars: [],
maxLength: 1024,
allowRelativePaths: false,
allowDotPrefix: false
};
// src/presets/standard.ts
var standardPreset = {
mode: "standard",
specialChars: {
allowSlash: true,
allowColon: false,
allowSpace: false,
allowAt: false,
allowAmpersand: false,
allowDollar: false
},
languages: {
allowJapanese: false,
allowKorean: false,
allowChinese: false,
allowCJK: false
},
additionalChars: [],
maxLength: 1024,
allowRelativePaths: false,
allowDotPrefix: false
};
// src/presets/permissive.ts
var permissivePreset = {
mode: "permissive",
specialChars: {
allowSlash: true,
allowColon: true,
allowSpace: true,
allowAt: true,
allowAmpersand: true,
allowDollar: true
},
languages: {
allowJapanese: false,
allowKorean: false,
allowChinese: false,
allowCJK: false
},
additionalChars: [],
maxLength: 1024,
allowRelativePaths: false,
allowDotPrefix: false
};
// src/presets/index.ts
function getPreset(mode) {
switch (mode) {
case "strict":
return strictPreset;
case "standard":
return standardPreset;
case "permissive":
return permissivePreset;
default:
return standardPreset;
}
}
// src/core/validator.ts
var S3KeyValidator = class {
static validate(key, options = {}) {
const resolvedOptions = this.resolveOptions(options);
const resultBuilder = new ValidationResultBuilder();
const lengthErrors = LengthValidator.validate(key, resolvedOptions);
resultBuilder.addErrors(lengthErrors);
const encodingErrors = EncodingValidator.validate(key);
resultBuilder.addErrors(encodingErrors);
const pathErrors = PathValidator.validate(key, resolvedOptions);
resultBuilder.addErrors(pathErrors);
const characterResult = CharacterValidator.validate(key, resolvedOptions);
resultBuilder.addErrors(characterResult.errors);
resultBuilder.addWarnings(characterResult.warnings);
this.addSuggestions(resultBuilder, key, resolvedOptions);
return resultBuilder.build();
}
static resolveOptions(options) {
if (options.mode) {
const preset = getPreset(options.mode);
return {
...preset,
...options,
specialChars: {
...preset.specialChars,
...options.specialChars
},
languages: {
...preset.languages,
...options.languages
}
};
}
return {
mode: "standard",
specialChars: {
allowSlash: true,
allowColon: false,
allowSpace: false,
allowAt: false,
allowAmpersand: false,
allowDollar: false
},
languages: {
allowJapanese: false,
allowKorean: false,
allowChinese: false,
allowCJK: false
},
additionalChars: [],
maxLength: 1024,
allowRelativePaths: false,
allowDotPrefix: false,
...options
};
}
static addSuggestions(resultBuilder, key, options) {
if (key.includes("//")) {
resultBuilder.addSuggestion("Remove consecutive slashes (//)");
}
if (key.endsWith("/")) {
resultBuilder.addSuggestion("Remove trailing slash");
}
if (key.startsWith("./")) {
resultBuilder.addSuggestion('Remove "./" prefix for better console compatibility');
}
if (key.includes("../")) {
resultBuilder.addSuggestion('Remove relative path elements ("../")');
}
if (/[\\{}^%`]">~<#|]/.test(key)) {
resultBuilder.addSuggestion('Remove forbidden characters: \\ { } ^ % ` ] " > [ ~ < # |');
}
if (key.includes(" ") && !options.specialChars?.allowSpace) {
resultBuilder.addSuggestion("Replace spaces with hyphens (-) or underscores (_)");
}
if (key.includes(":") && !options.specialChars?.allowColon) {
resultBuilder.addSuggestion("Replace colons (:) with hyphens (-) or underscores (_)");
}
if (key.includes("@") && !options.specialChars?.allowAt) {
resultBuilder.addSuggestion("Replace @ symbols with hyphens (-) or underscores (_)");
}
}
};
// src/index.ts
function validateS3Key(key, options) {
return S3KeyValidator.validate(key, options);
}
function isValidS3Key(key, options) {
const result = S3KeyValidator.validate(key, options);
return result.isValid;
}
function sanitizeS3Key(key, options) {
let sanitized = key;
sanitized = sanitized.replace(/[\\{}^%`\]">~<#|]/g, "");
sanitized = sanitized.replace(/\/+/g, "/");
sanitized = sanitized.replace(/\/$/, "");
if (sanitized.startsWith("./")) {
sanitized = sanitized.substring(2);
}
sanitized = sanitized.replace(/\.\.\//g, "");
if (!options?.specialChars?.allowSpace) {
sanitized = sanitized.replace(/\s+/g, "-");
}
if (!options?.specialChars?.allowColon) {
sanitized = sanitized.replace(/:/g, "-");
}
if (!options?.specialChars?.allowAt) {
sanitized = sanitized.replace(/@/g, "-");
}
return sanitized;
}
export {
isValidS3Key,
permissivePreset,
sanitizeS3Key,
standardPreset,
strictPreset,
validateS3Key
};