mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-23 14:33:36 +08:00
1392 lines
45 KiB
TypeScript
1392 lines
45 KiB
TypeScript
import { constants, type Dirent } from 'node:fs'
|
|
import { lstat, mkdir, mkdtemp, open, opendir, realpath, rename, rm, writeFile } from 'node:fs/promises'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
|
|
export const CUSTOM_PET_SPRITESHEET_WIDTH = 1536
|
|
export const CUSTOM_PET_SPRITESHEET_HEIGHT = 2288
|
|
export const CUSTOM_PET_SPRITESHEET_PIXELS =
|
|
CUSTOM_PET_SPRITESHEET_WIDTH * CUSTOM_PET_SPRITESHEET_HEIGHT
|
|
export const CUSTOM_PET_SINGLE_IMAGE_MIN_DIMENSION = 32
|
|
export const CUSTOM_PET_SINGLE_IMAGE_MAX_DIMENSION = 4096
|
|
export const CUSTOM_PET_SINGLE_IMAGE_MAX_PIXELS =
|
|
CUSTOM_PET_SINGLE_IMAGE_MAX_DIMENSION * CUSTOM_PET_SINGLE_IMAGE_MAX_DIMENSION
|
|
export const CUSTOM_PET_SINGLE_IMAGE_MANIFEST_VERSION = 1
|
|
export const CUSTOM_PET_SINGLE_IMAGE_RENDERER_VERSION = 1
|
|
export const CUSTOM_PET_SINGLE_IMAGE_MOTION_PROFILE = 'soft-spring-v1' as const
|
|
export const CUSTOM_PET_FOLDER_MAX_LENGTH = 73
|
|
export const DEFAULT_CUSTOM_PET_MAX_ENTRIES = 128
|
|
export const DEFAULT_CUSTOM_PET_MAX_DECODED_PIXELS = CUSTOM_PET_SPRITESHEET_PIXELS * 16
|
|
export const DEFAULT_CUSTOM_PET_MAX_MANIFEST_BYTES = 64 * 1024
|
|
export const DEFAULT_CUSTOM_PET_MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
|
export const DEFAULT_CUSTOM_PET_MAX_TOTAL_IMAGE_BYTES = 8 * 1024 * 1024
|
|
export const DEFAULT_CUSTOM_PET_MAX_TOTAL_DATA_URL_BYTES = 12 * 1024 * 1024
|
|
|
|
const MAX_DISPLAY_NAME_LENGTH = 80
|
|
const MAX_DESCRIPTION_LENGTH = 500
|
|
const MAX_IMAGE_PATH_LENGTH = 512
|
|
const MIN_CUSTOM_PET_IMAGE_BYTES = 20
|
|
const PET_FOLDER_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/
|
|
|
|
export type CustomPetImageMimeType = 'image/png' | 'image/webp'
|
|
|
|
export type ImageSize = {
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
export type ImageSizeInspectorInput = {
|
|
data: Buffer
|
|
mimeType: CustomPetImageMimeType
|
|
}
|
|
|
|
export type ImageSizeInspector = (
|
|
input: ImageSizeInspectorInput,
|
|
) => ImageSize | Promise<ImageSize>
|
|
|
|
type LoadedCustomPetBase = {
|
|
id: string
|
|
displayName: string
|
|
description: string
|
|
mimeType: CustomPetImageMimeType
|
|
dataUrl: string
|
|
}
|
|
|
|
export type LoadedCustomAtlasPet = LoadedCustomPetBase & {
|
|
spriteVersionNumber: 2
|
|
spritesheetPath: string
|
|
}
|
|
|
|
export type LoadedCustomImagePet = LoadedCustomPetBase & {
|
|
manifestVersion: 1
|
|
spriteVersionNumber: 1
|
|
imagePath: string
|
|
motionProfile: typeof CUSTOM_PET_SINGLE_IMAGE_MOTION_PROFILE
|
|
}
|
|
|
|
export type LoadedCustomPet = LoadedCustomAtlasPet | LoadedCustomImagePet
|
|
|
|
export type CustomPetLoadErrorCode =
|
|
| 'root-invalid'
|
|
| 'entry-limit'
|
|
| 'invalid-id'
|
|
| 'symlink-entry'
|
|
| 'missing-manifest'
|
|
| 'symlink-manifest'
|
|
| 'manifest-too-large'
|
|
| 'invalid-manifest'
|
|
| 'invalid-display-name'
|
|
| 'invalid-description'
|
|
| 'invalid-manifest-version'
|
|
| 'invalid-renderer'
|
|
| 'invalid-sprite-version'
|
|
| 'invalid-spritesheet-path'
|
|
| 'invalid-image-path'
|
|
| 'unsupported-image-format'
|
|
| 'missing-image'
|
|
| 'symlink-image'
|
|
| 'image-too-large'
|
|
| 'total-image-bytes-exceeded'
|
|
| 'decode-budget-exceeded'
|
|
| 'invalid-image'
|
|
| 'invalid-image-dimensions'
|
|
| 'duplicate-id'
|
|
| 'directory-changed'
|
|
| 'io-error'
|
|
|
|
export type CustomPetLoadError = {
|
|
entry?: string
|
|
code: CustomPetLoadErrorCode
|
|
message: string
|
|
}
|
|
|
|
export type CustomPetLoadResult = {
|
|
root: string
|
|
pets: LoadedCustomPet[]
|
|
errors: CustomPetLoadError[]
|
|
}
|
|
|
|
export type CustomPetCatalogLoader = (() => Promise<CustomPetLoadResult>) & {
|
|
invalidate: () => void
|
|
invalidateAfter: <T>(mutation: () => Promise<T>) => Promise<T>
|
|
}
|
|
|
|
export function createCustomPetCatalogLoader(
|
|
load: () => Promise<CustomPetLoadResult>,
|
|
): CustomPetCatalogLoader {
|
|
let inFlight: Promise<CustomPetLoadResult> | null = null
|
|
const loadCatalog = (() => {
|
|
if (inFlight) return inFlight
|
|
const current = load()
|
|
inFlight = current
|
|
void current.finally(() => {
|
|
if (inFlight === current) inFlight = null
|
|
}).catch(() => undefined)
|
|
return current
|
|
}) as CustomPetCatalogLoader
|
|
loadCatalog.invalidate = () => {
|
|
inFlight = null
|
|
}
|
|
loadCatalog.invalidateAfter = async mutation => {
|
|
const result = await mutation()
|
|
loadCatalog.invalidate()
|
|
return result
|
|
}
|
|
return loadCatalog
|
|
}
|
|
|
|
export type CustomPetsRootOptions = {
|
|
root?: string
|
|
env?: NodeJS.ProcessEnv
|
|
homeDir?: string
|
|
}
|
|
|
|
export type LoadCustomPetsOptions = CustomPetsRootOptions & {
|
|
inspectImageSize?: ImageSizeInspector
|
|
maxEntries?: number
|
|
maxManifestBytes?: number
|
|
maxImageBytes?: number
|
|
maxTotalImageBytes?: number
|
|
maxTotalDataUrlBytes?: number
|
|
maxDecodedPixels?: number
|
|
}
|
|
|
|
export type CreateCustomPetFromAtlasInput = {
|
|
slug: string
|
|
displayName: string
|
|
description: string
|
|
atlasPath: string
|
|
}
|
|
|
|
export type CreateCustomPetFromAtlasOptions = CustomPetsRootOptions & {
|
|
inspectImageSize?: ImageSizeInspector
|
|
}
|
|
|
|
export type CreateCustomPetFromImageInput = {
|
|
slug: string
|
|
displayName: string
|
|
description: string
|
|
imagePath: string
|
|
motionProfile?: typeof CUSTOM_PET_SINGLE_IMAGE_MOTION_PROFILE
|
|
}
|
|
|
|
export type CreateCustomPetFromImageOptions = CustomPetsRootOptions & {
|
|
inspectImageSize?: ImageSizeInspector
|
|
}
|
|
|
|
type SanitizedAtlasManifest = Omit<LoadedCustomAtlasPet, 'mimeType' | 'dataUrl'>
|
|
type SanitizedImageManifest = Omit<LoadedCustomImagePet, 'mimeType' | 'dataUrl'>
|
|
type SanitizedManifest = SanitizedAtlasManifest | SanitizedImageManifest
|
|
|
|
type ManifestCandidate = {
|
|
entry: string
|
|
imagePath: string
|
|
mimeType: CustomPetImageMimeType
|
|
imageKind: 'atlas-v2' | 'single-image'
|
|
directoryIdentities: DirectoryIdentity[]
|
|
metadata: SanitizedManifest
|
|
}
|
|
|
|
type DirectoryIdentity = {
|
|
directoryPath: string
|
|
realPath: string
|
|
dev: number
|
|
ino: number
|
|
missingCode: CustomPetLoadErrorCode
|
|
symlinkCode: CustomPetLoadErrorCode
|
|
invalidCode: CustomPetLoadErrorCode
|
|
changedCode: CustomPetLoadErrorCode
|
|
invalidMessage: string
|
|
changedMessage: string
|
|
}
|
|
|
|
class PetPackageError extends Error {
|
|
readonly code: CustomPetLoadErrorCode
|
|
|
|
constructor(code: CustomPetLoadErrorCode, message: string) {
|
|
super(message)
|
|
this.name = 'PetPackageError'
|
|
this.code = code
|
|
}
|
|
}
|
|
|
|
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
return error instanceof Error && 'code' in error
|
|
}
|
|
|
|
function normalizeLimit(value: number | undefined, fallback: number): number {
|
|
return Number.isSafeInteger(value) && value !== undefined && value >= 0
|
|
? value
|
|
: fallback
|
|
}
|
|
|
|
function resolveHomePath(input: string, homeDir: string): string {
|
|
if (input === '~') return homeDir
|
|
if (input.startsWith(`~${path.sep}`) || input.startsWith('~/') || input.startsWith('~\\')) {
|
|
return path.join(homeDir, input.slice(2))
|
|
}
|
|
return input
|
|
}
|
|
|
|
export function resolveCustomPetsRoot(options: CustomPetsRootOptions = {}): string {
|
|
if (options.root) return path.resolve(options.root)
|
|
|
|
const env = options.env ?? process.env
|
|
const homeDir = path.resolve(options.homeDir ?? os.homedir())
|
|
const configuredRoot = env.CLAUDE_CONFIG_DIR?.trim()
|
|
const claudeConfigDir = configuredRoot
|
|
? path.resolve(resolveHomePath(configuredRoot, homeDir))
|
|
: path.join(homeDir, '.claude')
|
|
return path.join(claudeConfigDir, 'cc-haha', 'pets')
|
|
}
|
|
|
|
export async function ensureCustomPetsRoot(options: CustomPetsRootOptions = {}): Promise<string> {
|
|
const root = resolveCustomPetsRoot(options)
|
|
await mkdir(root, { recursive: true, mode: 0o700 })
|
|
const rootStat = await lstat(root)
|
|
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
throw new Error('Custom pets root must be a real directory')
|
|
}
|
|
return root
|
|
}
|
|
|
|
function sanitizeErrorEntry(entry: string): string | undefined {
|
|
if (!entry || entry.length > 256 || CONTROL_CHARACTER_PATTERN.test(entry)) return undefined
|
|
return entry
|
|
}
|
|
|
|
function packageError(entry: string, error: unknown): CustomPetLoadError {
|
|
const safeEntry = sanitizeErrorEntry(entry)
|
|
const normalized = error instanceof PetPackageError
|
|
? { code: error.code, message: error.message }
|
|
: { code: 'io-error' as const, message: 'Unable to read this custom pet package.' }
|
|
return safeEntry ? { entry: safeEntry, ...normalized } : normalized
|
|
}
|
|
|
|
function rootError(code: CustomPetLoadErrorCode, message: string): CustomPetLoadError {
|
|
return { code, message }
|
|
}
|
|
|
|
type DirectoryIdentityOptions = Pick<
|
|
DirectoryIdentity,
|
|
| 'missingCode'
|
|
| 'symlinkCode'
|
|
| 'invalidCode'
|
|
| 'changedCode'
|
|
| 'invalidMessage'
|
|
| 'changedMessage'
|
|
>
|
|
|
|
async function captureDirectoryIdentity(
|
|
directoryPath: string,
|
|
options: DirectoryIdentityOptions,
|
|
): Promise<DirectoryIdentity> {
|
|
let beforeStat
|
|
try {
|
|
beforeStat = await lstat(directoryPath)
|
|
} catch {
|
|
throw new PetPackageError(options.missingCode, options.invalidMessage)
|
|
}
|
|
if (beforeStat.isSymbolicLink()) {
|
|
throw new PetPackageError(options.symlinkCode, options.invalidMessage)
|
|
}
|
|
if (!beforeStat.isDirectory()) {
|
|
throw new PetPackageError(options.invalidCode, options.invalidMessage)
|
|
}
|
|
|
|
let resolvedPath: string
|
|
let afterStat
|
|
try {
|
|
resolvedPath = await realpath(directoryPath)
|
|
afterStat = await lstat(directoryPath)
|
|
} catch {
|
|
throw new PetPackageError(options.changedCode, options.changedMessage)
|
|
}
|
|
if (
|
|
afterStat.isSymbolicLink() ||
|
|
!afterStat.isDirectory() ||
|
|
beforeStat.dev !== afterStat.dev ||
|
|
beforeStat.ino !== afterStat.ino
|
|
) {
|
|
throw new PetPackageError(options.changedCode, options.changedMessage)
|
|
}
|
|
|
|
return {
|
|
...options,
|
|
directoryPath,
|
|
realPath: path.resolve(resolvedPath),
|
|
dev: afterStat.dev,
|
|
ino: afterStat.ino,
|
|
}
|
|
}
|
|
|
|
async function assertDirectoryIdentity(identity: DirectoryIdentity): Promise<void> {
|
|
const current = await captureDirectoryIdentity(identity.directoryPath, identity)
|
|
if (
|
|
current.realPath !== identity.realPath ||
|
|
current.dev !== identity.dev ||
|
|
current.ino !== identity.ino
|
|
) {
|
|
throw new PetPackageError(identity.changedCode, identity.changedMessage)
|
|
}
|
|
}
|
|
|
|
async function assertDirectoryIdentities(identities: DirectoryIdentity[]): Promise<void> {
|
|
for (const identity of identities) {
|
|
await assertDirectoryIdentity(identity)
|
|
}
|
|
}
|
|
|
|
function assertDirectRealChild(parent: DirectoryIdentity, child: DirectoryIdentity): void {
|
|
if (path.dirname(child.realPath) !== parent.realPath) {
|
|
throw new PetPackageError(
|
|
child.changedCode,
|
|
child.changedMessage,
|
|
)
|
|
}
|
|
}
|
|
|
|
const ROOT_DIRECTORY_OPTIONS: DirectoryIdentityOptions = {
|
|
missingCode: 'root-invalid',
|
|
symlinkCode: 'root-invalid',
|
|
invalidCode: 'root-invalid',
|
|
changedCode: 'root-invalid',
|
|
invalidMessage: 'Custom pets root must be a real directory.',
|
|
changedMessage: 'Custom pets root changed while loading.',
|
|
}
|
|
|
|
const PACKAGE_DIRECTORY_OPTIONS: DirectoryIdentityOptions = {
|
|
missingCode: 'directory-changed',
|
|
symlinkCode: 'symlink-entry',
|
|
invalidCode: 'directory-changed',
|
|
changedCode: 'directory-changed',
|
|
invalidMessage: 'The custom pet package must be a real directory.',
|
|
changedMessage: 'The custom pet package changed while loading.',
|
|
}
|
|
|
|
const IMAGE_DIRECTORY_OPTIONS: DirectoryIdentityOptions = {
|
|
missingCode: 'missing-image',
|
|
symlinkCode: 'symlink-image',
|
|
invalidCode: 'invalid-image',
|
|
changedCode: 'directory-changed',
|
|
invalidMessage: 'The pet image path must contain only real directories.',
|
|
changedMessage: 'The pet image directory changed while loading.',
|
|
}
|
|
|
|
async function readDirectEntries(
|
|
root: string,
|
|
maxEntries: number,
|
|
validateRoot: () => Promise<void>,
|
|
): Promise<{ entries: Dirent[], capped: boolean }> {
|
|
await validateRoot()
|
|
const directory = await opendir(root)
|
|
const entries: Dirent[] = []
|
|
try {
|
|
await validateRoot()
|
|
while (entries.length <= maxEntries) {
|
|
const entry = await directory.read()
|
|
if (!entry) break
|
|
entries.push(entry)
|
|
}
|
|
await validateRoot()
|
|
} finally {
|
|
try {
|
|
await directory.close()
|
|
} catch {
|
|
// A completed scan can already close the handle on some runtimes.
|
|
}
|
|
}
|
|
|
|
return {
|
|
entries: entries.slice(0, maxEntries).sort((left, right) => left.name.localeCompare(right.name)),
|
|
capped: entries.length > maxEntries,
|
|
}
|
|
}
|
|
|
|
async function readBoundedRegularFile(options: {
|
|
filePath: string
|
|
maxBytes: number
|
|
validatePathContext?: () => Promise<void>
|
|
onBytesRead?: (bytes: number) => void
|
|
missingCode: CustomPetLoadErrorCode
|
|
symlinkCode: CustomPetLoadErrorCode
|
|
tooLargeCode: CustomPetLoadErrorCode
|
|
invalidCode: CustomPetLoadErrorCode
|
|
missingMessage: string
|
|
symlinkMessage: string
|
|
tooLargeMessage: string
|
|
invalidMessage: string
|
|
}): Promise<Buffer> {
|
|
await options.validatePathContext?.()
|
|
let pathStat
|
|
try {
|
|
pathStat = await lstat(options.filePath)
|
|
} catch (error) {
|
|
if (isNodeError(error) && error.code === 'ENOENT') {
|
|
throw new PetPackageError(options.missingCode, options.missingMessage)
|
|
}
|
|
throw new PetPackageError(options.invalidCode, options.invalidMessage)
|
|
}
|
|
|
|
if (pathStat.isSymbolicLink()) {
|
|
throw new PetPackageError(options.symlinkCode, options.symlinkMessage)
|
|
}
|
|
if (!pathStat.isFile()) {
|
|
throw new PetPackageError(options.invalidCode, options.invalidMessage)
|
|
}
|
|
if (pathStat.size > options.maxBytes) {
|
|
throw new PetPackageError(options.tooLargeCode, options.tooLargeMessage)
|
|
}
|
|
await options.validatePathContext?.()
|
|
|
|
const noFollow = (constants as typeof constants & { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0
|
|
let file
|
|
try {
|
|
file = await open(options.filePath, constants.O_RDONLY | noFollow)
|
|
} catch (error) {
|
|
if (isNodeError(error) && error.code === 'ENOENT') {
|
|
throw new PetPackageError(options.missingCode, options.missingMessage)
|
|
}
|
|
if (isNodeError(error) && error.code === 'ELOOP') {
|
|
throw new PetPackageError(options.symlinkCode, options.symlinkMessage)
|
|
}
|
|
throw new PetPackageError(options.invalidCode, options.invalidMessage)
|
|
}
|
|
|
|
try {
|
|
const openedStat = await file.stat()
|
|
if (!openedStat.isFile() || openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
|
|
throw new PetPackageError(options.invalidCode, options.invalidMessage)
|
|
}
|
|
if (openedStat.size > options.maxBytes) {
|
|
throw new PetPackageError(options.tooLargeCode, options.tooLargeMessage)
|
|
}
|
|
await options.validatePathContext?.()
|
|
const chunks: Buffer[] = []
|
|
let totalBytes = 0
|
|
while (totalBytes < openedStat.size) {
|
|
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, openedStat.size - totalBytes))
|
|
const { bytesRead } = await file.read(chunk, 0, chunk.byteLength, null)
|
|
if (bytesRead === 0) break
|
|
totalBytes += bytesRead
|
|
options.onBytesRead?.(bytesRead)
|
|
chunks.push(chunk.subarray(0, bytesRead))
|
|
}
|
|
const afterReadStat = await file.stat()
|
|
if (
|
|
!afterReadStat.isFile() ||
|
|
afterReadStat.dev !== openedStat.dev ||
|
|
afterReadStat.ino !== openedStat.ino ||
|
|
afterReadStat.size !== openedStat.size ||
|
|
totalBytes !== openedStat.size
|
|
) {
|
|
if (afterReadStat.size > options.maxBytes) {
|
|
throw new PetPackageError(options.tooLargeCode, options.tooLargeMessage)
|
|
}
|
|
throw new PetPackageError(options.invalidCode, options.invalidMessage)
|
|
}
|
|
await options.validatePathContext?.()
|
|
return Buffer.concat(chunks, totalBytes)
|
|
} finally {
|
|
await file.close()
|
|
}
|
|
}
|
|
|
|
function decodeManifest(data: Buffer): Record<string, unknown> {
|
|
let text: string
|
|
try {
|
|
text = new TextDecoder('utf-8', { fatal: true }).decode(data)
|
|
} catch {
|
|
throw new PetPackageError('invalid-manifest', 'pet.json must contain valid UTF-8 JSON.')
|
|
}
|
|
|
|
let manifest: unknown
|
|
try {
|
|
manifest = JSON.parse(text)
|
|
} catch {
|
|
throw new PetPackageError('invalid-manifest', 'pet.json must contain valid JSON.')
|
|
}
|
|
if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) {
|
|
throw new PetPackageError('invalid-manifest', 'pet.json must contain a JSON object.')
|
|
}
|
|
return manifest as Record<string, unknown>
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
}
|
|
|
|
function sanitizedTextField(
|
|
value: unknown,
|
|
maxLength: number,
|
|
code: CustomPetLoadErrorCode,
|
|
fieldName: string,
|
|
): string {
|
|
if (typeof value !== 'string') {
|
|
throw new PetPackageError(code, `${fieldName} must be a string.`)
|
|
}
|
|
const trimmed = value.trim()
|
|
if (!trimmed || trimmed.length > maxLength || CONTROL_CHARACTER_PATTERN.test(trimmed)) {
|
|
throw new PetPackageError(code, `${fieldName} is invalid.`)
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
function resolvePortableRelativePath(
|
|
packageDir: string,
|
|
value: unknown,
|
|
options: {
|
|
code: 'invalid-spritesheet-path' | 'invalid-image-path'
|
|
fieldName: 'spritesheetPath' | 'imagePath'
|
|
},
|
|
): {
|
|
relativePath: string
|
|
absolutePath: string
|
|
} {
|
|
if (typeof value !== 'string' || !value || value !== value.trim()) {
|
|
throw new PetPackageError(options.code, `${options.fieldName} must be a relative path.`)
|
|
}
|
|
if (value.length > MAX_IMAGE_PATH_LENGTH || CONTROL_CHARACTER_PATTERN.test(value)) {
|
|
throw new PetPackageError(options.code, `${options.fieldName} is invalid.`)
|
|
}
|
|
if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) {
|
|
throw new PetPackageError(options.code, `${options.fieldName} must be relative.`)
|
|
}
|
|
|
|
const portablePath = value.replaceAll('\\', '/')
|
|
const segments = portablePath.split('/')
|
|
if (
|
|
segments.some(segment => !segment || segment === '.' || segment === '..' || segment.includes(':')) ||
|
|
path.posix.normalize(portablePath) !== portablePath
|
|
) {
|
|
throw new PetPackageError(options.code, `${options.fieldName} cannot traverse the package.`)
|
|
}
|
|
|
|
const absolutePath = path.resolve(packageDir, ...segments)
|
|
const relativeToPackage = path.relative(packageDir, absolutePath)
|
|
if (!relativeToPackage || relativeToPackage.startsWith('..') || path.isAbsolute(relativeToPackage)) {
|
|
throw new PetPackageError(options.code, `${options.fieldName} must stay inside the package.`)
|
|
}
|
|
return { relativePath: segments.join('/'), absolutePath }
|
|
}
|
|
|
|
function mimeTypeForPath(
|
|
imagePath: string,
|
|
fieldName: 'spritesheetPath' | 'imagePath',
|
|
): CustomPetImageMimeType {
|
|
const extension = path.posix.extname(imagePath).toLowerCase()
|
|
if (extension === '.png') return 'image/png'
|
|
if (extension === '.webp') return 'image/webp'
|
|
throw new PetPackageError(
|
|
'unsupported-image-format',
|
|
`${fieldName} must point to a PNG or WebP image.`,
|
|
)
|
|
}
|
|
|
|
async function captureImageDirectoryIdentities(
|
|
packageIdentity: DirectoryIdentity,
|
|
imagePath: string,
|
|
): Promise<DirectoryIdentity[]> {
|
|
const identities = [packageIdentity]
|
|
const relativePath = path.relative(packageIdentity.directoryPath, imagePath)
|
|
const segments = relativePath.split(path.sep)
|
|
let current = packageIdentity.directoryPath
|
|
let parentIdentity = packageIdentity
|
|
for (const segment of segments.slice(0, -1)) {
|
|
current = path.join(current, segment)
|
|
const componentIdentity = await captureDirectoryIdentity(current, IMAGE_DIRECTORY_OPTIONS)
|
|
assertDirectRealChild(parentIdentity, componentIdentity)
|
|
identities.push(componentIdentity)
|
|
parentIdentity = componentIdentity
|
|
}
|
|
return identities
|
|
}
|
|
|
|
function hasExpectedImageSignature(data: Buffer, mimeType: CustomPetImageMimeType): boolean {
|
|
if (mimeType === 'image/png') {
|
|
return data.byteLength >= 24 &&
|
|
data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) &&
|
|
data.readUInt32BE(8) === 13 &&
|
|
data.toString('ascii', 12, 16) === 'IHDR'
|
|
}
|
|
|
|
return data.byteLength >= 20 &&
|
|
data.toString('ascii', 0, 4) === 'RIFF' &&
|
|
data.toString('ascii', 8, 12) === 'WEBP' &&
|
|
data.readUInt32LE(4) + 8 === data.byteLength
|
|
}
|
|
|
|
const PNG_ACTL_CHUNK = 0x6163544c
|
|
const WEBP_VP8X_CHUNK = 0x56503858
|
|
const WEBP_ANIM_CHUNK = 0x414e494d
|
|
const WEBP_ANMF_CHUNK = 0x414e4d46
|
|
const WEBP_VP8X_ANIMATION_FLAG = 0x02
|
|
|
|
function hasPngAnimationChunk(data: Buffer): boolean {
|
|
let offset = 8
|
|
while (offset + 12 <= data.byteLength) {
|
|
const chunkLength = data.readUInt32BE(offset)
|
|
if (chunkLength > data.byteLength - offset - 12) break
|
|
if (data.readUInt32BE(offset + 4) === PNG_ACTL_CHUNK) return true
|
|
offset += 12 + chunkLength
|
|
}
|
|
return false
|
|
}
|
|
|
|
function hasWebpAnimationChunk(data: Buffer): boolean {
|
|
let offset = 12
|
|
while (offset + 8 <= data.byteLength) {
|
|
const chunkType = data.readUInt32BE(offset)
|
|
const chunkLength = data.readUInt32LE(offset + 4)
|
|
const chunkStart = offset + 8
|
|
if (chunkLength > data.byteLength - chunkStart) break
|
|
if (chunkType === WEBP_ANIM_CHUNK || chunkType === WEBP_ANMF_CHUNK) return true
|
|
if (
|
|
chunkType === WEBP_VP8X_CHUNK &&
|
|
chunkLength >= 1 &&
|
|
(data[chunkStart]! & WEBP_VP8X_ANIMATION_FLAG) !== 0
|
|
) {
|
|
return true
|
|
}
|
|
offset = chunkStart + chunkLength + (chunkLength % 2)
|
|
}
|
|
return false
|
|
}
|
|
|
|
function hasEmbeddedImageAnimation(data: Buffer, mimeType: CustomPetImageMimeType): boolean {
|
|
return mimeType === 'image/png'
|
|
? hasPngAnimationChunk(data)
|
|
: hasWebpAnimationChunk(data)
|
|
}
|
|
|
|
function readUInt24LE(data: Buffer, offset: number): number {
|
|
return data[offset]! | (data[offset + 1]! << 8) | (data[offset + 2]! << 16)
|
|
}
|
|
|
|
export function inspectPetImageSize({ data, mimeType }: ImageSizeInspectorInput): ImageSize {
|
|
if (!hasExpectedImageSignature(data, mimeType)) {
|
|
throw new Error('Invalid image header')
|
|
}
|
|
if (mimeType === 'image/png') {
|
|
return { width: data.readUInt32BE(16), height: data.readUInt32BE(20) }
|
|
}
|
|
|
|
let offset = 12
|
|
while (offset + 8 <= data.byteLength) {
|
|
const chunkType = data.toString('ascii', offset, offset + 4)
|
|
const chunkLength = data.readUInt32LE(offset + 4)
|
|
const chunkStart = offset + 8
|
|
const chunkEnd = chunkStart + chunkLength
|
|
if (chunkEnd > data.byteLength) break
|
|
|
|
if (chunkType === 'VP8X' && chunkLength >= 10) {
|
|
return {
|
|
width: readUInt24LE(data, chunkStart + 4) + 1,
|
|
height: readUInt24LE(data, chunkStart + 7) + 1,
|
|
}
|
|
}
|
|
if (chunkType === 'VP8L' && chunkLength >= 5 && data[chunkStart] === 0x2f) {
|
|
const b1 = data[chunkStart + 1]!
|
|
const b2 = data[chunkStart + 2]!
|
|
const b3 = data[chunkStart + 3]!
|
|
const b4 = data[chunkStart + 4]!
|
|
return {
|
|
width: 1 + b1 + ((b2 & 0x3f) << 8),
|
|
height: 1 + (b2 >> 6) + (b3 << 2) + ((b4 & 0x0f) << 10),
|
|
}
|
|
}
|
|
if (
|
|
chunkType === 'VP8 ' &&
|
|
chunkLength >= 10 &&
|
|
data[chunkStart + 3] === 0x9d &&
|
|
data[chunkStart + 4] === 0x01 &&
|
|
data[chunkStart + 5] === 0x2a
|
|
) {
|
|
return {
|
|
width: data.readUInt16LE(chunkStart + 6) & 0x3fff,
|
|
height: data.readUInt16LE(chunkStart + 8) & 0x3fff,
|
|
}
|
|
}
|
|
|
|
offset = chunkEnd + (chunkLength % 2)
|
|
}
|
|
throw new Error('Unsupported WebP image header')
|
|
}
|
|
|
|
function isValidImageDimension(value: number): boolean {
|
|
return Number.isSafeInteger(value) && value > 0
|
|
}
|
|
|
|
function imagePixels(size: ImageSize): number {
|
|
if (!isValidImageDimension(size.width) || !isValidImageDimension(size.height)) return 0
|
|
const pixels = size.width * size.height
|
|
return Number.isSafeInteger(pixels) ? pixels : 0
|
|
}
|
|
|
|
function assertAtlasImageSize(size: ImageSize): void {
|
|
if (
|
|
size.width !== CUSTOM_PET_SPRITESHEET_WIDTH ||
|
|
size.height !== CUSTOM_PET_SPRITESHEET_HEIGHT
|
|
) {
|
|
throw new PetPackageError(
|
|
'invalid-image-dimensions',
|
|
`The spritesheet image must be ${CUSTOM_PET_SPRITESHEET_WIDTH}x${CUSTOM_PET_SPRITESHEET_HEIGHT}.`,
|
|
)
|
|
}
|
|
}
|
|
|
|
function assertSingleImageSize(size: ImageSize): void {
|
|
const pixels = imagePixels(size)
|
|
if (
|
|
size.width < CUSTOM_PET_SINGLE_IMAGE_MIN_DIMENSION ||
|
|
size.height < CUSTOM_PET_SINGLE_IMAGE_MIN_DIMENSION ||
|
|
size.width > CUSTOM_PET_SINGLE_IMAGE_MAX_DIMENSION ||
|
|
size.height > CUSTOM_PET_SINGLE_IMAGE_MAX_DIMENSION ||
|
|
pixels === 0 ||
|
|
pixels > CUSTOM_PET_SINGLE_IMAGE_MAX_PIXELS
|
|
) {
|
|
throw new PetPackageError(
|
|
'invalid-image-dimensions',
|
|
`The pet image must be between ${CUSTOM_PET_SINGLE_IMAGE_MIN_DIMENSION} and ${CUSTOM_PET_SINGLE_IMAGE_MAX_DIMENSION} pixels per side and contain no more than ${CUSTOM_PET_SINGLE_IMAGE_MAX_PIXELS} pixels.`,
|
|
)
|
|
}
|
|
}
|
|
|
|
function assertCandidateImageSize(
|
|
size: ImageSize,
|
|
imageKind: ManifestCandidate['imageKind'],
|
|
): void {
|
|
if (imageKind === 'atlas-v2') assertAtlasImageSize(size)
|
|
else assertSingleImageSize(size)
|
|
}
|
|
|
|
function parseManifestRenderer(
|
|
packageDir: string,
|
|
entry: string,
|
|
manifest: Record<string, unknown>,
|
|
displayName: string,
|
|
description: string,
|
|
): Pick<ManifestCandidate, 'imagePath' | 'mimeType' | 'imageKind' | 'metadata'> {
|
|
if (manifest.manifestVersion === undefined) {
|
|
if (manifest.renderer !== undefined || manifest.spriteVersionNumber !== 2) {
|
|
throw new PetPackageError(
|
|
'invalid-sprite-version',
|
|
'spriteVersionNumber must be 2 for a legacy atlas pet.',
|
|
)
|
|
}
|
|
const resolvedImage = resolvePortableRelativePath(
|
|
packageDir,
|
|
manifest.spritesheetPath,
|
|
{ code: 'invalid-spritesheet-path', fieldName: 'spritesheetPath' },
|
|
)
|
|
return {
|
|
imagePath: resolvedImage.absolutePath,
|
|
mimeType: mimeTypeForPath(resolvedImage.relativePath, 'spritesheetPath'),
|
|
imageKind: 'atlas-v2',
|
|
metadata: {
|
|
id: `custom:${entry}`,
|
|
displayName,
|
|
description,
|
|
spriteVersionNumber: 2,
|
|
spritesheetPath: resolvedImage.relativePath,
|
|
},
|
|
}
|
|
}
|
|
|
|
if (manifest.manifestVersion !== CUSTOM_PET_SINGLE_IMAGE_MANIFEST_VERSION) {
|
|
throw new PetPackageError(
|
|
'invalid-manifest-version',
|
|
`manifestVersion must be ${CUSTOM_PET_SINGLE_IMAGE_MANIFEST_VERSION}.`,
|
|
)
|
|
}
|
|
if (!isRecord(manifest.renderer)) {
|
|
throw new PetPackageError('invalid-renderer', 'renderer must be an object.')
|
|
}
|
|
const renderer = manifest.renderer
|
|
if (
|
|
renderer.kind !== 'single-image' ||
|
|
renderer.version !== CUSTOM_PET_SINGLE_IMAGE_RENDERER_VERSION
|
|
) {
|
|
throw new PetPackageError(
|
|
'invalid-renderer',
|
|
`renderer must use single-image version ${CUSTOM_PET_SINGLE_IMAGE_RENDERER_VERSION}.`,
|
|
)
|
|
}
|
|
const motionProfile = renderer.motionProfile ?? CUSTOM_PET_SINGLE_IMAGE_MOTION_PROFILE
|
|
if (motionProfile !== CUSTOM_PET_SINGLE_IMAGE_MOTION_PROFILE) {
|
|
throw new PetPackageError('invalid-renderer', 'The single-image motion profile is unsupported.')
|
|
}
|
|
const resolvedImage = resolvePortableRelativePath(
|
|
packageDir,
|
|
renderer.imagePath,
|
|
{ code: 'invalid-image-path', fieldName: 'imagePath' },
|
|
)
|
|
return {
|
|
imagePath: resolvedImage.absolutePath,
|
|
mimeType: mimeTypeForPath(resolvedImage.relativePath, 'imagePath'),
|
|
imageKind: 'single-image',
|
|
metadata: {
|
|
id: `custom:${entry}`,
|
|
displayName,
|
|
description,
|
|
manifestVersion: CUSTOM_PET_SINGLE_IMAGE_MANIFEST_VERSION,
|
|
spriteVersionNumber: 1,
|
|
imagePath: resolvedImage.relativePath,
|
|
motionProfile,
|
|
},
|
|
}
|
|
}
|
|
|
|
async function readManifestCandidate(
|
|
root: string,
|
|
rootIdentity: DirectoryIdentity,
|
|
entry: Dirent,
|
|
maxManifestBytes: number,
|
|
): Promise<ManifestCandidate | null> {
|
|
const packageDir = path.join(root, entry.name)
|
|
if (entry.isSymbolicLink()) {
|
|
throw new PetPackageError('symlink-entry', 'Custom pet package symlinks are not allowed.')
|
|
}
|
|
if (!entry.isDirectory()) return null
|
|
if (entry.name.length > CUSTOM_PET_FOLDER_MAX_LENGTH || !PET_FOLDER_PATTERN.test(entry.name)) {
|
|
throw new PetPackageError('invalid-id', 'Custom pet folder name is not a safe slug.')
|
|
}
|
|
|
|
await assertDirectoryIdentity(rootIdentity)
|
|
const packageIdentity = await captureDirectoryIdentity(
|
|
packageDir,
|
|
PACKAGE_DIRECTORY_OPTIONS,
|
|
)
|
|
assertDirectRealChild(rootIdentity, packageIdentity)
|
|
const manifestDirectoryIdentities = [rootIdentity, packageIdentity]
|
|
|
|
const manifestData = await readBoundedRegularFile({
|
|
filePath: path.join(packageDir, 'pet.json'),
|
|
maxBytes: maxManifestBytes,
|
|
validatePathContext: () => assertDirectoryIdentities(manifestDirectoryIdentities),
|
|
missingCode: 'missing-manifest',
|
|
symlinkCode: 'symlink-manifest',
|
|
tooLargeCode: 'manifest-too-large',
|
|
invalidCode: 'invalid-manifest',
|
|
missingMessage: 'The custom pet package is missing pet.json.',
|
|
symlinkMessage: 'pet.json cannot be a symlink.',
|
|
tooLargeMessage: 'pet.json exceeds the allowed size.',
|
|
invalidMessage: 'pet.json must be a regular file.',
|
|
})
|
|
const manifest = decodeManifest(manifestData)
|
|
const displayName = sanitizedTextField(
|
|
manifest.displayName,
|
|
MAX_DISPLAY_NAME_LENGTH,
|
|
'invalid-display-name',
|
|
'displayName',
|
|
)
|
|
const description = sanitizedTextField(
|
|
manifest.description,
|
|
MAX_DESCRIPTION_LENGTH,
|
|
'invalid-description',
|
|
'description',
|
|
)
|
|
const renderer = parseManifestRenderer(
|
|
packageDir,
|
|
entry.name,
|
|
manifest,
|
|
displayName,
|
|
description,
|
|
)
|
|
const imageDirectoryIdentities = await captureImageDirectoryIdentities(
|
|
packageIdentity,
|
|
renderer.imagePath,
|
|
)
|
|
const directoryIdentities = [rootIdentity, ...imageDirectoryIdentities]
|
|
await assertDirectoryIdentities(directoryIdentities)
|
|
|
|
return {
|
|
entry: entry.name,
|
|
...renderer,
|
|
directoryIdentities,
|
|
}
|
|
}
|
|
|
|
async function loadCandidateImage(
|
|
candidate: ManifestCandidate,
|
|
inspectImageSize: ImageSizeInspector,
|
|
maxImageBytes: number,
|
|
tooLargeCode: 'image-too-large' | 'total-image-bytes-exceeded',
|
|
onImageRead: (bytes: number) => void,
|
|
onDecodeAttempt: (decodedPixels: number) => void,
|
|
): Promise<Buffer> {
|
|
const data = await readBoundedRegularFile({
|
|
filePath: candidate.imagePath,
|
|
maxBytes: maxImageBytes,
|
|
validatePathContext: () => assertDirectoryIdentities(candidate.directoryIdentities),
|
|
onBytesRead: onImageRead,
|
|
missingCode: 'missing-image',
|
|
symlinkCode: 'symlink-image',
|
|
tooLargeCode,
|
|
invalidCode: 'invalid-image',
|
|
missingMessage: 'The pet image does not exist.',
|
|
symlinkMessage: 'The pet image cannot be a symlink.',
|
|
tooLargeMessage: tooLargeCode === 'image-too-large'
|
|
? 'The pet image exceeds the allowed size.'
|
|
: 'The total custom pet image budget has been reached.',
|
|
invalidMessage: 'The pet image must be a regular file.',
|
|
})
|
|
if (!hasExpectedImageSignature(data, candidate.mimeType)) {
|
|
throw new PetPackageError('invalid-image', 'The pet image header is invalid.')
|
|
}
|
|
if (hasEmbeddedImageAnimation(data, candidate.mimeType)) {
|
|
throw new PetPackageError(
|
|
'invalid-image',
|
|
'The pet image must be a static PNG or WebP.',
|
|
)
|
|
}
|
|
|
|
let headerSize: ImageSize
|
|
try {
|
|
headerSize = inspectPetImageSize({
|
|
data,
|
|
mimeType: candidate.mimeType,
|
|
})
|
|
} catch {
|
|
throw new PetPackageError('invalid-image', 'The pet image header is invalid.')
|
|
}
|
|
assertCandidateImageSize(headerSize, candidate.imageKind)
|
|
onDecodeAttempt(imagePixels(headerSize))
|
|
|
|
if (inspectImageSize !== inspectPetImageSize) {
|
|
let decodedSize: ImageSize
|
|
try {
|
|
decodedSize = await inspectImageSize({
|
|
data,
|
|
mimeType: candidate.mimeType,
|
|
})
|
|
} catch {
|
|
throw new PetPackageError('invalid-image', 'The pet image cannot be decoded.')
|
|
}
|
|
assertCandidateImageSize(decodedSize, candidate.imageKind)
|
|
if (decodedSize.width !== headerSize.width || decodedSize.height !== headerSize.height) {
|
|
throw new PetPackageError(
|
|
'invalid-image',
|
|
'The decoded pet image dimensions do not match its header.',
|
|
)
|
|
}
|
|
}
|
|
await assertDirectoryIdentities(candidate.directoryIdentities)
|
|
return data
|
|
}
|
|
|
|
function dataUrlPrefix(mimeType: CustomPetImageMimeType): string {
|
|
return `data:${mimeType};base64,`
|
|
}
|
|
|
|
function maxRawBytesForDataUrl(mimeType: CustomPetImageMimeType, remainingBytes: number): number {
|
|
const availableBase64Bytes = remainingBytes - dataUrlPrefix(mimeType).length
|
|
if (availableBase64Bytes < 4) return 0
|
|
return Math.floor(availableBase64Bytes / 4) * 3
|
|
}
|
|
|
|
function totalImageBudgetError(): PetPackageError {
|
|
return new PetPackageError(
|
|
'total-image-bytes-exceeded',
|
|
'The total custom pet image budget has been reached.',
|
|
)
|
|
}
|
|
|
|
async function assertCustomPetTargetAvailable(targetPath: string): Promise<void> {
|
|
try {
|
|
await lstat(targetPath)
|
|
} catch (error) {
|
|
if (isNodeError(error) && error.code === 'ENOENT') return
|
|
throw new PetPackageError('io-error', 'Unable to inspect the custom pet destination.')
|
|
}
|
|
throw new PetPackageError('duplicate-id', 'A custom pet with this ID already exists.')
|
|
}
|
|
|
|
export async function createCustomPetFromAtlas(
|
|
input: CreateCustomPetFromAtlasInput,
|
|
options: CreateCustomPetFromAtlasOptions = {},
|
|
): Promise<LoadedCustomAtlasPet> {
|
|
const slug = input.slug.trim()
|
|
if (
|
|
slug !== input.slug
|
|
|| slug.length === 0
|
|
|| slug.length > CUSTOM_PET_FOLDER_MAX_LENGTH
|
|
|| !PET_FOLDER_PATTERN.test(slug)
|
|
) {
|
|
throw new PetPackageError('invalid-id', 'Custom pet ID must be a lowercase kebab-case slug.')
|
|
}
|
|
const displayName = sanitizedTextField(
|
|
input.displayName,
|
|
MAX_DISPLAY_NAME_LENGTH,
|
|
'invalid-display-name',
|
|
'displayName',
|
|
)
|
|
const description = sanitizedTextField(
|
|
input.description,
|
|
MAX_DESCRIPTION_LENGTH,
|
|
'invalid-description',
|
|
'description',
|
|
)
|
|
const atlasPath = path.resolve(input.atlasPath)
|
|
const mimeType = mimeTypeForPath(atlasPath, 'spritesheetPath')
|
|
const extension = mimeType === 'image/png' ? 'png' : 'webp'
|
|
const spritesheetPath = `spritesheet.${extension}`
|
|
const atlasData = await readBoundedRegularFile({
|
|
filePath: atlasPath,
|
|
maxBytes: DEFAULT_CUSTOM_PET_MAX_IMAGE_BYTES,
|
|
missingCode: 'missing-image',
|
|
symlinkCode: 'symlink-image',
|
|
tooLargeCode: 'image-too-large',
|
|
invalidCode: 'invalid-image',
|
|
missingMessage: 'The selected spritesheet image does not exist.',
|
|
symlinkMessage: 'The selected spritesheet image cannot be a symlink.',
|
|
tooLargeMessage: 'The selected spritesheet image exceeds the allowed size.',
|
|
invalidMessage: 'The selected spritesheet image must be a regular file.',
|
|
})
|
|
|
|
const root = await ensureCustomPetsRoot(options)
|
|
const rootIdentity = await captureDirectoryIdentity(root, ROOT_DIRECTORY_OPTIONS)
|
|
const targetPath = path.join(root, slug)
|
|
await assertCustomPetTargetAvailable(targetPath)
|
|
|
|
const stagingRoot = await mkdtemp(path.join(path.dirname(root), '.pet-install-'))
|
|
const packagePath = path.join(stagingRoot, slug)
|
|
try {
|
|
await mkdir(packagePath, { mode: 0o700 })
|
|
await writeFile(path.join(packagePath, spritesheetPath), atlasData, { flag: 'wx', mode: 0o600 })
|
|
await writeFile(
|
|
path.join(packagePath, 'pet.json'),
|
|
`${JSON.stringify({
|
|
id: slug,
|
|
displayName,
|
|
description,
|
|
spriteVersionNumber: 2,
|
|
spritesheetPath,
|
|
}, null, 2)}\n`,
|
|
{ flag: 'wx', mode: 0o600 },
|
|
)
|
|
|
|
const validation = await loadCustomPets({
|
|
root: stagingRoot,
|
|
inspectImageSize: options.inspectImageSize,
|
|
maxEntries: 1,
|
|
})
|
|
const pet = validation.pets.find(candidate => candidate.id === `custom:${slug}`)
|
|
const validationError = validation.errors[0]
|
|
if (!pet || pet.spriteVersionNumber !== 2 || validationError) {
|
|
throw new PetPackageError(
|
|
validationError?.code ?? 'invalid-image',
|
|
validationError?.message ?? 'The custom pet package could not be validated.',
|
|
)
|
|
}
|
|
|
|
await assertDirectoryIdentity(rootIdentity)
|
|
await assertCustomPetTargetAvailable(targetPath)
|
|
try {
|
|
await rename(packagePath, targetPath)
|
|
} catch (error) {
|
|
if (isNodeError(error) && (error.code === 'EEXIST' || error.code === 'ENOTEMPTY')) {
|
|
throw new PetPackageError('duplicate-id', 'A custom pet with this ID already exists.')
|
|
}
|
|
throw error
|
|
}
|
|
await assertDirectoryIdentity(rootIdentity)
|
|
return pet
|
|
} finally {
|
|
await rm(stagingRoot, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
export async function createCustomPetFromImage(
|
|
input: CreateCustomPetFromImageInput,
|
|
options: CreateCustomPetFromImageOptions = {},
|
|
): Promise<LoadedCustomImagePet> {
|
|
const slug = input.slug.trim()
|
|
if (
|
|
slug !== input.slug ||
|
|
slug.length === 0 ||
|
|
slug.length > CUSTOM_PET_FOLDER_MAX_LENGTH ||
|
|
!PET_FOLDER_PATTERN.test(slug)
|
|
) {
|
|
throw new PetPackageError('invalid-id', 'Custom pet ID must be a lowercase kebab-case slug.')
|
|
}
|
|
const displayName = sanitizedTextField(
|
|
input.displayName,
|
|
MAX_DISPLAY_NAME_LENGTH,
|
|
'invalid-display-name',
|
|
'displayName',
|
|
)
|
|
const description = sanitizedTextField(
|
|
input.description,
|
|
MAX_DESCRIPTION_LENGTH,
|
|
'invalid-description',
|
|
'description',
|
|
)
|
|
const motionProfile = input.motionProfile ?? CUSTOM_PET_SINGLE_IMAGE_MOTION_PROFILE
|
|
if (motionProfile !== CUSTOM_PET_SINGLE_IMAGE_MOTION_PROFILE) {
|
|
throw new PetPackageError('invalid-renderer', 'The single-image motion profile is unsupported.')
|
|
}
|
|
|
|
const sourcePath = path.resolve(input.imagePath)
|
|
const mimeType = mimeTypeForPath(sourcePath, 'imagePath')
|
|
const extension = mimeType === 'image/png' ? 'png' : 'webp'
|
|
const imagePath = `pet.${extension}`
|
|
const imageData = await readBoundedRegularFile({
|
|
filePath: sourcePath,
|
|
maxBytes: DEFAULT_CUSTOM_PET_MAX_IMAGE_BYTES,
|
|
missingCode: 'missing-image',
|
|
symlinkCode: 'symlink-image',
|
|
tooLargeCode: 'image-too-large',
|
|
invalidCode: 'invalid-image',
|
|
missingMessage: 'The selected pet image does not exist.',
|
|
symlinkMessage: 'The selected pet image cannot be a symlink.',
|
|
tooLargeMessage: 'The selected pet image exceeds the allowed size.',
|
|
invalidMessage: 'The selected pet image must be a regular file.',
|
|
})
|
|
|
|
const root = await ensureCustomPetsRoot(options)
|
|
const rootIdentity = await captureDirectoryIdentity(root, ROOT_DIRECTORY_OPTIONS)
|
|
const targetPath = path.join(root, slug)
|
|
await assertCustomPetTargetAvailable(targetPath)
|
|
|
|
const stagingRoot = await mkdtemp(path.join(path.dirname(root), '.pet-install-'))
|
|
const packagePath = path.join(stagingRoot, slug)
|
|
try {
|
|
await mkdir(packagePath, { mode: 0o700 })
|
|
await writeFile(path.join(packagePath, imagePath), imageData, { flag: 'wx', mode: 0o600 })
|
|
await writeFile(
|
|
path.join(packagePath, 'pet.json'),
|
|
`${JSON.stringify({
|
|
id: slug,
|
|
displayName,
|
|
description,
|
|
manifestVersion: CUSTOM_PET_SINGLE_IMAGE_MANIFEST_VERSION,
|
|
renderer: {
|
|
kind: 'single-image',
|
|
version: CUSTOM_PET_SINGLE_IMAGE_RENDERER_VERSION,
|
|
imagePath,
|
|
motionProfile,
|
|
},
|
|
}, null, 2)}\n`,
|
|
{ flag: 'wx', mode: 0o600 },
|
|
)
|
|
|
|
const validation = await loadCustomPets({
|
|
root: stagingRoot,
|
|
inspectImageSize: options.inspectImageSize,
|
|
maxEntries: 1,
|
|
})
|
|
const pet = validation.pets.find(candidate => candidate.id === `custom:${slug}`)
|
|
const validationError = validation.errors[0]
|
|
if (!pet || pet.spriteVersionNumber !== 1 || validationError) {
|
|
throw new PetPackageError(
|
|
validationError?.code ?? 'invalid-image',
|
|
validationError?.message ?? 'The custom pet package could not be validated.',
|
|
)
|
|
}
|
|
|
|
await assertDirectoryIdentity(rootIdentity)
|
|
await assertCustomPetTargetAvailable(targetPath)
|
|
try {
|
|
await rename(packagePath, targetPath)
|
|
} catch (error) {
|
|
if (isNodeError(error) && (error.code === 'EEXIST' || error.code === 'ENOTEMPTY')) {
|
|
throw new PetPackageError('duplicate-id', 'A custom pet with this ID already exists.')
|
|
}
|
|
throw error
|
|
}
|
|
await assertDirectoryIdentity(rootIdentity)
|
|
return pet
|
|
} finally {
|
|
await rm(stagingRoot, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
function packageCanBeSkippedForExhaustedBudget(entry: Dirent): boolean {
|
|
return entry.isDirectory() &&
|
|
entry.name.length <= CUSTOM_PET_FOLDER_MAX_LENGTH &&
|
|
PET_FOLDER_PATTERN.test(entry.name)
|
|
}
|
|
|
|
function canFitAnyCustomPetImage(options: {
|
|
maxImageBytes: number
|
|
remainingImageBytes: number
|
|
remainingDataUrlBytes: number
|
|
}): boolean {
|
|
const dataUrlRawBudget = Math.max(
|
|
maxRawBytesForDataUrl('image/png', options.remainingDataUrlBytes),
|
|
maxRawBytesForDataUrl('image/webp', options.remainingDataUrlBytes),
|
|
)
|
|
return Math.min(
|
|
options.maxImageBytes,
|
|
options.remainingImageBytes,
|
|
dataUrlRawBudget,
|
|
) >= MIN_CUSTOM_PET_IMAGE_BYTES
|
|
}
|
|
|
|
export async function loadCustomPets(
|
|
options: LoadCustomPetsOptions = {},
|
|
): Promise<CustomPetLoadResult> {
|
|
const root = resolveCustomPetsRoot(options)
|
|
const pets: LoadedCustomPet[] = []
|
|
const errors: CustomPetLoadError[] = []
|
|
const maxEntries = normalizeLimit(options.maxEntries, DEFAULT_CUSTOM_PET_MAX_ENTRIES)
|
|
const maxManifestBytes = normalizeLimit(
|
|
options.maxManifestBytes,
|
|
DEFAULT_CUSTOM_PET_MAX_MANIFEST_BYTES,
|
|
)
|
|
const maxImageBytes = normalizeLimit(options.maxImageBytes, DEFAULT_CUSTOM_PET_MAX_IMAGE_BYTES)
|
|
const maxTotalImageBytes = normalizeLimit(
|
|
options.maxTotalImageBytes,
|
|
DEFAULT_CUSTOM_PET_MAX_TOTAL_IMAGE_BYTES,
|
|
)
|
|
const maxTotalDataUrlBytes = normalizeLimit(
|
|
options.maxTotalDataUrlBytes,
|
|
DEFAULT_CUSTOM_PET_MAX_TOTAL_DATA_URL_BYTES,
|
|
)
|
|
const maxDecodedPixels = normalizeLimit(
|
|
options.maxDecodedPixels,
|
|
DEFAULT_CUSTOM_PET_MAX_DECODED_PIXELS,
|
|
)
|
|
const inspectImageSize = options.inspectImageSize ?? inspectPetImageSize
|
|
|
|
let rootStat
|
|
try {
|
|
rootStat = await lstat(root)
|
|
} catch (error) {
|
|
if (isNodeError(error) && error.code === 'ENOENT') return { root, pets, errors }
|
|
return {
|
|
root,
|
|
pets,
|
|
errors: [rootError('io-error', 'Unable to read the custom pets root.')],
|
|
}
|
|
}
|
|
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
return {
|
|
root,
|
|
pets,
|
|
errors: [rootError('root-invalid', 'Custom pets root must be a real directory.')],
|
|
}
|
|
}
|
|
|
|
let rootIdentity: DirectoryIdentity
|
|
try {
|
|
rootIdentity = await captureDirectoryIdentity(root, ROOT_DIRECTORY_OPTIONS)
|
|
} catch (error) {
|
|
const normalized = error instanceof PetPackageError
|
|
? rootError(error.code, error.message)
|
|
: rootError('root-invalid', 'Custom pets root changed while loading.')
|
|
return { root, pets, errors: [normalized] }
|
|
}
|
|
|
|
let directEntries: { entries: Dirent[], capped: boolean }
|
|
try {
|
|
directEntries = await readDirectEntries(
|
|
root,
|
|
maxEntries,
|
|
() => assertDirectoryIdentity(rootIdentity),
|
|
)
|
|
} catch (error) {
|
|
const normalized = error instanceof PetPackageError
|
|
? rootError(error.code, error.message)
|
|
: rootError('io-error', 'Unable to scan the custom pets root.')
|
|
return {
|
|
root,
|
|
pets,
|
|
errors: [normalized],
|
|
}
|
|
}
|
|
if (directEntries.capped) {
|
|
errors.push(rootError('entry-limit', `Only the first ${maxEntries} custom pet entries were scanned.`))
|
|
}
|
|
|
|
let remainingImageBytes = maxTotalImageBytes
|
|
let remainingDataUrlBytes = maxTotalDataUrlBytes
|
|
let remainingDecodedPixels = maxDecodedPixels
|
|
for (const entry of directEntries.entries) {
|
|
if (!canFitAnyCustomPetImage({
|
|
maxImageBytes,
|
|
remainingImageBytes,
|
|
remainingDataUrlBytes,
|
|
})) {
|
|
if (packageCanBeSkippedForExhaustedBudget(entry)) {
|
|
errors.push(packageError(entry.name, totalImageBudgetError()))
|
|
} else if (entry.isSymbolicLink()) {
|
|
errors.push(packageError(
|
|
entry.name,
|
|
new PetPackageError('symlink-entry', 'Custom pet package symlinks are not allowed.'),
|
|
))
|
|
} else if (
|
|
entry.isDirectory() &&
|
|
(entry.name.length > CUSTOM_PET_FOLDER_MAX_LENGTH || !PET_FOLDER_PATTERN.test(entry.name))
|
|
) {
|
|
errors.push(packageError(
|
|
entry.name,
|
|
new PetPackageError('invalid-id', 'Custom pet folder name is not a safe slug.'),
|
|
))
|
|
}
|
|
continue
|
|
}
|
|
|
|
try {
|
|
const candidate = await readManifestCandidate(root, rootIdentity, entry, maxManifestBytes)
|
|
if (!candidate) continue
|
|
|
|
const remainingDataUrlRawBytes = maxRawBytesForDataUrl(
|
|
candidate.mimeType,
|
|
remainingDataUrlBytes,
|
|
)
|
|
const remainingReadBytes = Math.min(
|
|
maxImageBytes,
|
|
remainingImageBytes,
|
|
remainingDataUrlRawBytes,
|
|
)
|
|
if (remainingReadBytes === 0) throw totalImageBudgetError()
|
|
|
|
const data = await loadCandidateImage(
|
|
candidate,
|
|
inspectImageSize,
|
|
remainingReadBytes,
|
|
remainingReadBytes < maxImageBytes
|
|
? 'total-image-bytes-exceeded'
|
|
: 'image-too-large',
|
|
bytes => {
|
|
remainingImageBytes -= bytes
|
|
},
|
|
decodedPixels => {
|
|
if (remainingDecodedPixels < decodedPixels) {
|
|
throw new PetPackageError(
|
|
'decode-budget-exceeded',
|
|
'The total custom pet decoded-pixel budget has been reached.',
|
|
)
|
|
}
|
|
remainingDecodedPixels -= decodedPixels
|
|
},
|
|
)
|
|
const dataUrl = `${dataUrlPrefix(candidate.mimeType)}${data.toString('base64')}`
|
|
if (dataUrl.length > remainingDataUrlBytes) throw totalImageBudgetError()
|
|
remainingDataUrlBytes -= dataUrl.length
|
|
pets.push({
|
|
...candidate.metadata,
|
|
mimeType: candidate.mimeType,
|
|
dataUrl,
|
|
})
|
|
} catch (error) {
|
|
errors.push(packageError(entry.name, error))
|
|
}
|
|
}
|
|
|
|
try {
|
|
await assertDirectoryIdentity(rootIdentity)
|
|
} catch (error) {
|
|
pets.length = 0
|
|
const normalized = error instanceof PetPackageError
|
|
? rootError(error.code, error.message)
|
|
: rootError('root-invalid', 'Custom pets root changed while loading.')
|
|
errors.push(normalized)
|
|
}
|
|
|
|
return { root, pets, errors }
|
|
}
|