From d2e599a617122245ff2932ae9c0dbb3617946534 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 15:26:46 +0000 Subject: [PATCH] feat(browser-utils): add automatic OS version detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements automatic detection of OS versions at runtime instead of hardcoded values. This ensures the library reports accurate platform versions to WhatsApp without manual updates. Version Detection: - Linux: Reads /etc/os-release for distribution version (e.g., '24.04.1') - macOS: Converts Darwin kernel version to macOS version (e.g., Darwin 24.x → macOS 15.x) - Windows: Uses os.release() directly (already returns correct format) Security Fixes (from PR #62 review): - Fix isValidBrowserPreset() to use Object.prototype.hasOwnProperty.call() to prevent matching inherited properties like 'toString' or 'constructor' - Fix getPlatformId() signature to accept 'unknown' type for runtime safety Other Improvements: - Add FALLBACK_VERSIONS constant with updated 2025 versions - Add DARWIN_TO_MACOS mapping for accurate macOS version conversion - Export detectedOSVersions for debugging and logging - Improve JSDoc documentation with accurate examples - Add 36 comprehensive unit tests (up from 25) The version is detected once at module load and cached for consistent behavior throughout the application lifecycle. https://claude.ai/code/session_018XbFWEYwCfeeXioFDLvSXV --- src/Utils/browser-utils.ts | 268 ++++++++++++++++++++-- src/__tests__/Utils/browser-utils.test.ts | 117 +++++++++- 2 files changed, 348 insertions(+), 37 deletions(-) diff --git a/src/Utils/browser-utils.ts b/src/Utils/browser-utils.ts index 7659c4c5..bb8b4a19 100644 --- a/src/Utils/browser-utils.ts +++ b/src/Utils/browser-utils.ts @@ -1,4 +1,5 @@ import { platform, release } from 'os' +import { readFileSync, existsSync } from 'fs' import { proto } from '../../WAProto/index.js' import type { BrowsersMap } from '../Types' @@ -17,6 +18,40 @@ const DEFAULT_PLATFORM_NAME = 'Ubuntu' as const */ const DEFAULT_PLATFORM_ID = '1' as const +/** + * Default OS version fallback when detection fails + */ +const DEFAULT_OS_VERSION = '1.0.0' as const + +/** + * Fallback versions used when automatic detection fails. + * These should be updated periodically to reflect current OS versions. + * Last updated: 2025-01 + */ +const FALLBACK_VERSIONS = { + ubuntu: '24.04.1', + macOS: '15.2', + windows: '10.0.26100', + baileys: '6.5.0', +} as const + +/** + * Maps Darwin kernel versions to macOS marketing versions. + * Darwin 24.x = macOS 15.x (Sequoia) + * Darwin 23.x = macOS 14.x (Sonoma) + * Darwin 22.x = macOS 13.x (Ventura) + * Darwin 21.x = macOS 12.x (Monterey) + * Darwin 20.x = macOS 11.x (Big Sur) + */ +const DARWIN_TO_MACOS: Readonly> = { + 24: 15, // Sequoia + 23: 14, // Sonoma + 22: 13, // Ventura + 21: 12, // Monterey + 20: 11, // Big Sur + 19: 10, // Catalina (10.15) +} as const + /** * Maps Node.js platform identifiers to WhatsApp-recognized platform names. * Values of `undefined` will fall back to DEFAULT_PLATFORM_NAME. @@ -42,6 +77,9 @@ const PLATFORM_MAP: Readonly> = { /** * Pre-computed map of browser names to platform IDs. * Built once at module load to avoid repeated proto access. + * + * Note: Protobuf enums have bidirectional mappings (name→value and value→name). + * We filter to only include string keys with numeric values. */ const BROWSER_TO_PLATFORM_ID: ReadonlyMap = (() => { const platformType = proto.DeviceProps?.PlatformType @@ -51,8 +89,6 @@ const BROWSER_TO_PLATFORM_ID: ReadonlyMap = (() => { const entries: Array<[string, string]> = [] for (const [key, value] of Object.entries(platformType)) { - // PlatformType has both numeric values and reverse mappings - // We only want the string keys with numeric values if (typeof value === 'number' && typeof key === 'string' && !/^\d+$/.test(key)) { entries.push([key.toUpperCase(), value.toString()]) } @@ -60,6 +96,151 @@ const BROWSER_TO_PLATFORM_ID: ReadonlyMap = (() => { return new Map(entries) })() +// ============================================================ +// Version Detection Functions +// ============================================================ + +/** + * Detects the Linux distribution version by reading /etc/os-release. + * This file is standard on most modern Linux distributions. + * + * @returns The VERSION_ID from os-release, or fallback version + * + * @example + * // On Ubuntu 24.04: + * getLinuxVersion() // Returns '24.04' or '24.04.1' + */ +const getLinuxVersion = (): string => { + try { + const osReleasePaths = ['/etc/os-release', '/usr/lib/os-release'] + + for (const filePath of osReleasePaths) { + if (existsSync(filePath)) { + const content = readFileSync(filePath, 'utf-8') + + // Try VERSION_ID first (e.g., "24.04") + const versionIdMatch = content.match(/^VERSION_ID\s*=\s*"?([^"\n]+)"?/m) + if (versionIdMatch?.[1]) { + return versionIdMatch[1] + } + + // Fallback to VERSION (e.g., "24.04.1 LTS (Noble Numbat)") + const versionMatch = content.match(/^VERSION\s*=\s*"?([0-9][0-9.]*)/m) + if (versionMatch?.[1]) { + return versionMatch[1] + } + } + } + } catch { + // Silently fail and use fallback + } + + return FALLBACK_VERSIONS.ubuntu +} + +/** + * Converts Darwin kernel version to macOS marketing version. + * Darwin versions map to macOS versions with an offset. + * + * @returns macOS version string (e.g., '15.2') + * + * @example + * // On macOS Sequoia with Darwin 24.2.0: + * getMacOSVersion() // Returns '15.2' + */ +const getMacOSVersion = (): string => { + try { + const darwinVersion = release() // e.g., '24.2.0' + const parts = darwinVersion.split('.') + const majorVersionStr = parts[0] + const minorVersionStr = parts[1] + + if (!majorVersionStr) { + return FALLBACK_VERSIONS.macOS + } + + const majorVersion = parseInt(majorVersionStr, 10) + if (isNaN(majorVersion)) { + return FALLBACK_VERSIONS.macOS + } + + const minorVersion = minorVersionStr || '0' + const macOSMajor = DARWIN_TO_MACOS[majorVersion] + + if (macOSMajor === undefined) { + // For newer Darwin versions, estimate macOS version + // Darwin 24 = macOS 15, so offset is 9 + const estimatedMajor = majorVersion >= 20 ? majorVersion - 9 : 10 + return `${estimatedMajor}.${minorVersion}` + } + + // Special case: macOS 10.x (Catalina and earlier) + if (macOSMajor === 10) { + return `10.15.${minorVersion}` + } + + return `${macOSMajor}.${minorVersion}` + } catch { + return FALLBACK_VERSIONS.macOS + } +} + +/** + * Gets Windows version directly from os.release(). + * Windows correctly reports version (e.g., '10.0.22631'). + * + * @returns Windows version string + */ +const getWindowsVersion = (): string => { + try { + const version = release() + // Validate it looks like a Windows version (X.X.XXXXX) + if (/^\d+\.\d+\.\d+$/.test(version)) { + return version + } + } catch { + // Silently fail + } + + return FALLBACK_VERSIONS.windows +} + +/** + * Detects the current OS version automatically based on the platform. + * Uses platform-specific methods for accurate version detection. + * + * @returns Object containing detected versions for all platforms + */ +const detectOSVersions = (): Readonly<{ + ubuntu: string + macOS: string + windows: string + baileys: string +}> => { + const currentPlatform = (() => { + try { + return platform() + } catch { + return 'linux' as NodeJS.Platform + } + })() + + // Detect versions based on current platform + // For non-matching platforms, use fallback values + return { + ubuntu: currentPlatform === 'linux' ? getLinuxVersion() : FALLBACK_VERSIONS.ubuntu, + macOS: currentPlatform === 'darwin' ? getMacOSVersion() : FALLBACK_VERSIONS.macOS, + windows: currentPlatform === 'win32' ? getWindowsVersion() : FALLBACK_VERSIONS.windows, + baileys: FALLBACK_VERSIONS.baileys, + } +} + +/** + * Cached OS versions, detected once at module load. + * This ensures consistent versions throughout the application lifecycle. + */ +const OS_VERSIONS = detectOSVersions() + // ============================================================ // Helper Functions // ============================================================ @@ -82,7 +263,7 @@ const getPlatformName = (): string => { /** * Normalizes a browser identifier for platform type lookup. * - * @param browser - The browser identifier to normalize + * @param browser - The browser identifier to normalize (accepts any type for runtime safety) * @returns Uppercase trimmed string, or null if input is invalid */ const normalizeBrowserKey = (browser: unknown): string | null => { @@ -94,15 +275,29 @@ const normalizeBrowserKey = (browser: unknown): string | null => { } /** - * Safely gets the OS release version with fallback + * Gets the appropriate OS version for the current platform. + * Uses automatic detection with fallback to sensible defaults. * - * @returns OS release string or empty string on failure + * @returns OS version string appropriate for the current platform */ -const safeRelease = (): string => { +const getAppropriateVersion = (): string => { try { - return release() + const currentPlatform = platform() + + switch (currentPlatform) { + case 'darwin': + return OS_VERSIONS.macOS + case 'win32': + return OS_VERSIONS.windows + case 'linux': + return OS_VERSIONS.ubuntu + default: + // For other platforms, try os.release() directly + const version = release() + return version || DEFAULT_OS_VERSION + } } catch { - return '' + return DEFAULT_OS_VERSION } } @@ -114,24 +309,35 @@ const safeRelease = (): string => { * Browser configuration presets for WhatsApp device registration. * Each factory returns a tuple of [platform, browser, version]. * - * @example - * // Use Ubuntu preset - * const config = Browsers.ubuntu('Chrome') - * // Returns: ['Ubuntu', 'Chrome', '22.04.4'] + * Versions are automatically detected at module load: + * - Linux: Reads /etc/os-release for distribution version + * - macOS: Converts Darwin kernel version to macOS version + * - Windows: Uses os.release() directly * * @example - * // Use automatic platform detection + * // Use Ubuntu preset with auto-detected version + * const config = Browsers.ubuntu('Chrome') + * // Returns: ['Ubuntu', 'Chrome', '24.04.1'] (version detected automatically) + * + * @example + * // Use automatic platform and version detection * const config = Browsers.appropriate('MyApp') - * // Returns: ['Mac OS', 'MyApp', '23.1.0'] (on macOS) + * // Returns: ['Mac OS', 'MyApp', '15.2'] (on macOS Sequoia) */ export const Browsers: BrowsersMap = { - ubuntu: (browser: string): [string, string, string] => ['Ubuntu', browser, '22.04.4'], - macOS: (browser: string): [string, string, string] => ['Mac OS', browser, '14.4.1'], - windows: (browser: string): [string, string, string] => ['Windows', browser, '10.0.22631'], - baileys: (browser: string): [string, string, string] => ['Baileys', browser, '6.5.0'], - appropriate: (browser: string): [string, string, string] => [getPlatformName(), browser, safeRelease()], + ubuntu: (browser: string): [string, string, string] => ['Ubuntu', browser, OS_VERSIONS.ubuntu], + macOS: (browser: string): [string, string, string] => ['Mac OS', browser, OS_VERSIONS.macOS], + windows: (browser: string): [string, string, string] => ['Windows', browser, OS_VERSIONS.windows], + baileys: (browser: string): [string, string, string] => ['Baileys', browser, OS_VERSIONS.baileys], + appropriate: (browser: string): [string, string, string] => [getPlatformName(), browser, getAppropriateVersion()], } as const +/** + * Exposed OS versions for debugging and logging purposes. + * These are the versions that will be used by the Browsers presets. + */ +export const detectedOSVersions = OS_VERSIONS + // ============================================================ // Exported Functions // ============================================================ @@ -140,7 +346,11 @@ export const Browsers: BrowsersMap = { * Resolves the platform type ID for a given browser name. * Uses the WhatsApp protocol buffer definitions for mapping. * - * @param browser - Browser identifier (e.g., 'chrome', 'firefox', 'safari') + * This function safely handles invalid inputs (null, undefined, non-strings) + * by returning the default Chrome platform ID. + * + * @param browser - Browser identifier (e.g., 'chrome', 'firefox', 'safari'). + * Accepts unknown types for runtime safety. * @returns Platform type ID as string (defaults to '1' for Chrome) * * @example @@ -148,9 +358,8 @@ export const Browsers: BrowsersMap = { * getPlatformId('CHROME') // Returns '1' (case-insensitive) * getPlatformId('firefox') // Returns platform-specific ID * getPlatformId('') // Returns '1' (default) - * getPlatformId(undefined) // Returns '1' (default, handles invalid input) */ -export const getPlatformId = (browser: string): string => { +export const getPlatformId = (browser: unknown): string => { const key = normalizeBrowserKey(browser) if (key === null) { return DEFAULT_PLATFORM_ID @@ -160,11 +369,20 @@ export const getPlatformId = (browser: string): string => { } /** - * Type guard to check if a platform string is a valid browser preset key. + * Type guard to check if a value is a valid browser preset key. * Useful for external validation before using Browsers presets. * + * Uses Object.prototype.hasOwnProperty to avoid matching inherited + * properties like 'toString' or 'constructor'. + * * @param value - Value to check - * @returns True if value is a valid browser preset key + * @returns True if value is a valid browser preset key ('ubuntu', 'macOS', 'windows', 'baileys', 'appropriate') + * + * @example + * isValidBrowserPreset('ubuntu') // true + * isValidBrowserPreset('macOS') // true + * isValidBrowserPreset('invalid') // false + * isValidBrowserPreset('toString') // false (inherited property) * * @example * if (isValidBrowserPreset(userInput)) { @@ -172,5 +390,5 @@ export const getPlatformId = (browser: string): string => { * } */ export const isValidBrowserPreset = (value: unknown): value is keyof BrowsersMap => { - return typeof value === 'string' && value in Browsers + return typeof value === 'string' && Object.prototype.hasOwnProperty.call(Browsers, value) } diff --git a/src/__tests__/Utils/browser-utils.test.ts b/src/__tests__/Utils/browser-utils.test.ts index e09a11c6..ad29a427 100644 --- a/src/__tests__/Utils/browser-utils.test.ts +++ b/src/__tests__/Utils/browser-utils.test.ts @@ -1,30 +1,44 @@ -import { Browsers, getPlatformId, isValidBrowserPreset } from '../../Utils/browser-utils' +import { Browsers, getPlatformId, isValidBrowserPreset, detectedOSVersions } from '../../Utils/browser-utils' describe('browser-utils', () => { describe('Browsers', () => { describe('ubuntu', () => { it('should return correct tuple for Ubuntu', () => { const result = Browsers.ubuntu('Chrome') - expect(result).toEqual(['Ubuntu', 'Chrome', '22.04.4']) + expect(result[0]).toBe('Ubuntu') + expect(result[1]).toBe('Chrome') + expect(result[2]).toMatch(/^\d+\.\d+/) // Version format like '24.04' or '24.04.1' }) it('should handle empty browser string', () => { const result = Browsers.ubuntu('') - expect(result).toEqual(['Ubuntu', '', '22.04.4']) + expect(result[0]).toBe('Ubuntu') + expect(result[1]).toBe('') + }) + + it('should use auto-detected or fallback version', () => { + const result = Browsers.ubuntu('Test') + // Version should be a non-empty string + expect(typeof result[2]).toBe('string') + expect(result[2].length).toBeGreaterThan(0) }) }) describe('macOS', () => { it('should return correct tuple for macOS', () => { const result = Browsers.macOS('Safari') - expect(result).toEqual(['Mac OS', 'Safari', '14.4.1']) + expect(result[0]).toBe('Mac OS') + expect(result[1]).toBe('Safari') + expect(result[2]).toMatch(/^\d+\.\d+/) // Version format like '15.2' }) }) describe('windows', () => { it('should return correct tuple for Windows', () => { const result = Browsers.windows('Edge') - expect(result).toEqual(['Windows', 'Edge', '10.0.22631']) + expect(result[0]).toBe('Windows') + expect(result[1]).toBe('Edge') + expect(result[2]).toMatch(/^\d+\.\d+\.\d+/) // Version format like '10.0.26100' }) }) @@ -51,6 +65,41 @@ describe('browser-utils', () => { // Should be a valid platform name expect(['Ubuntu', 'Mac OS', 'Windows', 'AIX', 'Android', 'FreeBSD', 'OpenBSD', 'Solaris']).toContain(result[0]) }) + + it('should return a valid version string', () => { + const result = Browsers.appropriate('Test') + expect(result[2]).toBeDefined() + expect(typeof result[2]).toBe('string') + expect(result[2].length).toBeGreaterThan(0) + }) + }) + }) + + describe('detectedOSVersions', () => { + it('should expose detected versions', () => { + expect(detectedOSVersions).toBeDefined() + expect(typeof detectedOSVersions.ubuntu).toBe('string') + expect(typeof detectedOSVersions.macOS).toBe('string') + expect(typeof detectedOSVersions.windows).toBe('string') + expect(typeof detectedOSVersions.baileys).toBe('string') + }) + + it('should have valid version formats', () => { + // Ubuntu: X.X or X.X.X + expect(detectedOSVersions.ubuntu).toMatch(/^\d+\.\d+(\.\d+)?$/) + // macOS: X.X or X.X.X + expect(detectedOSVersions.macOS).toMatch(/^\d+\.\d+(\.\d+)?$/) + // Windows: X.X.XXXXX + expect(detectedOSVersions.windows).toMatch(/^\d+\.\d+\.\d+$/) + // Baileys: semver + expect(detectedOSVersions.baileys).toMatch(/^\d+\.\d+\.\d+$/) + }) + + it('should match Browsers preset versions', () => { + expect(Browsers.ubuntu('Test')[2]).toBe(detectedOSVersions.ubuntu) + expect(Browsers.macOS('Test')[2]).toBe(detectedOSVersions.macOS) + expect(Browsers.windows('Test')[2]).toBe(detectedOSVersions.windows) + expect(Browsers.baileys('Test')[2]).toBe(detectedOSVersions.baileys) }) }) @@ -89,34 +138,37 @@ describe('browser-utils', () => { }) it('should return default ID for undefined input', () => { - // @ts-expect-error - Testing runtime behavior with invalid input const result = getPlatformId(undefined) expect(result).toBe('1') }) it('should return default ID for null input', () => { - // @ts-expect-error - Testing runtime behavior with invalid input const result = getPlatformId(null) expect(result).toBe('1') }) it('should return default ID for number input', () => { - // @ts-expect-error - Testing runtime behavior with invalid input const result = getPlatformId(123) expect(result).toBe('1') }) it('should return default ID for object input', () => { - // @ts-expect-error - Testing runtime behavior with invalid input const result = getPlatformId({ browser: 'chrome' }) expect(result).toBe('1') }) + it('should return default ID for array input', () => { + const result = getPlatformId(['chrome']) + expect(result).toBe('1') + }) + it('should always return a string', () => { const validResult = getPlatformId('chrome') const invalidResult = getPlatformId('nonexistent') + const undefinedResult = getPlatformId(undefined) expect(typeof validResult).toBe('string') expect(typeof invalidResult).toBe('string') + expect(typeof undefinedResult).toBe('string') }) }) @@ -133,6 +185,7 @@ describe('browser-utils', () => { expect(isValidBrowserPreset('invalid')).toBe(false) expect(isValidBrowserPreset('UBUNTU')).toBe(false) expect(isValidBrowserPreset('Ubuntu')).toBe(false) + expect(isValidBrowserPreset('MacOS')).toBe(false) }) it('should return false for non-string values', () => { @@ -141,6 +194,17 @@ describe('browser-utils', () => { expect(isValidBrowserPreset(123)).toBe(false) expect(isValidBrowserPreset({})).toBe(false) expect(isValidBrowserPreset([])).toBe(false) + expect(isValidBrowserPreset(true)).toBe(false) + }) + + // Critical security test: inherited properties should not match + it('should return false for inherited Object properties (security)', () => { + expect(isValidBrowserPreset('toString')).toBe(false) + expect(isValidBrowserPreset('constructor')).toBe(false) + expect(isValidBrowserPreset('valueOf')).toBe(false) + expect(isValidBrowserPreset('hasOwnProperty')).toBe(false) + expect(isValidBrowserPreset('__proto__')).toBe(false) + expect(isValidBrowserPreset('prototype')).toBe(false) }) it('should work as type guard', () => { @@ -165,12 +229,11 @@ describe('browser-utils', () => { it('should not throw on getPlatformId with any input', () => { expect(() => getPlatformId('valid')).not.toThrow() expect(() => getPlatformId('')).not.toThrow() - // @ts-expect-error - Testing runtime behavior expect(() => getPlatformId(undefined)).not.toThrow() - // @ts-expect-error - Testing runtime behavior expect(() => getPlatformId(null)).not.toThrow() - // @ts-expect-error - Testing runtime behavior expect(() => getPlatformId({})).not.toThrow() + expect(() => getPlatformId([])).not.toThrow() + expect(() => getPlatformId(Symbol('test'))).not.toThrow() }) it('all Browsers methods should return readonly-compatible tuples', () => { @@ -182,5 +245,35 @@ describe('browser-utils', () => { expect(result.every(item => typeof item === 'string')).toBe(true) } }) + + it('version strings should never be empty', () => { + const presets = ['ubuntu', 'macOS', 'windows', 'baileys', 'appropriate'] as const + for (const preset of presets) { + const result = Browsers[preset]('Test') + expect(result[2].length).toBeGreaterThan(0) + } + }) + }) + + describe('version detection', () => { + it('should detect a reasonable Ubuntu version', () => { + const version = detectedOSVersions.ubuntu + // Should be at least 18.04 (oldest supported LTS) + const majorVersion = parseFloat(version) + expect(majorVersion).toBeGreaterThanOrEqual(18) + }) + + it('should detect a reasonable macOS version', () => { + const version = detectedOSVersions.macOS + // Should be at least macOS 10.x or 11+ + const majorVersion = parseFloat(version) + expect(majorVersion).toBeGreaterThanOrEqual(10) + }) + + it('should detect a reasonable Windows version', () => { + const version = detectedOSVersions.windows + // Should start with 10. (Windows 10/11) + expect(version).toMatch(/^10\./) + }) }) })